---
name: halo-project-management
description: Workflow primer for Halo PSA project, task, and budget operations via the grcitsm MCP catalog — creating/structuring Projects (type 5) and Project Tasks (type 20), the read-merge-write recipe for the full-replace budgets[] array, budget-type attribution so logged time rolls into budget actuals, and budget-vs-actual tracking. Use whenever composing project, project-task, budget, or project-tracking MCP calls.
---

# halo-project-management — Project & Budget Workflow Primer

## References

| Reference | Type | Location | When to Use |
|---|---|---|---|
| `grcitsm` | Skill | `plugins/grcitsm-analyst/skills/grcitsm/` | Base MCP-composition primer: the create→update chain, lookup sequence, ticket-type routing |
| `halo-bulk-update` | Skill | `plugins/grcitsm-analyst/skills/halo-bulk-update/` | Six-phase methodology when a project/budget change touches >~20 rows |
| `halo-action-pipeline` | Skill | `plugins/grcitsm-analyst/skills/halo-action-pipeline/` | Plan→apply action-file pipeline for bulk or composed (non-direct) writes |
| `halo-platform/write-semantics.md` | File | `reference-docs/halo-platform/write-semantics.md` | Payload cliffs, full-replace semantics, lookup-value rules |

**The MCP tool descriptions are the source of truth** for each tool's contract — this skill
covers only the ordering, full-replace discipline, and failure modes that span tools. It is
vendor-neutral: resolve all IDs (budget types, agents, categories, statuses) against the live
catalog at runtime; never hardcode tenant-specific IDs.

## When to load this skill

- Creating or structuring a Project (type 5) or Project Tasks (type 20) and their hierarchy.
- Setting or reading a Project's budget, or attributing tasks to budget types.
- Reporting budget-vs-actual burn-down, or seeding a project's structure from a SOW.

## 1. Ticket model

- **Project = ticket type 5. Project Task = ticket type 20. Internal Project = type 53.** Tasks
  roll up to a Project via `set_ticket_parent(ticket_id=<task>, parent_ticket_id=<project>)` (one
  parent, many children).
- **HARD RULE: model a per-person / per-workstream / per-phase program as ONE Project (type 5)
  with the people/workstreams/phases as Project Tasks (type 20) under it — NEVER as a parent
  Project with nested child Projects.** A Project must not be parented under another Project. The
  Task layer (type 20) is what holds the per-person breakdown, rolls budget/time up to the one
  Project, and reports as a single burn-down. Nesting child Projects fragments the budget (see §3)
  and forces a teardown/rebuild.
- `create_new_ticket` logs against a `user_email` and inherits the type's default
  status/team/SLA/priority (and the type's default budget type). It does NOT set agent, category,
  dates, Strategic Value, or SOW — follow up after create.
- **HARD RULE: every ticket gets an agent AND a category.** Never leave Unassigned/uncategorized.
  Owner via `assign_agent` (resolve the agent via `agents`); category via the `category` path
  param on `agent_note`/`update_ticket_fields` (resolve a valid path via
  `ticket_type_allowed_categories` filtered to the type). Verify with `get_ticket`.
- Operational field writes (status, dates, parent, client/site, Strategic Value, SOW, custom
  fields) go through `update_ticket_fields` (generic `/api/Tickets` pass-through, omitted-
  PRESERVED for `customfields[]`) or the narrow `set_ticket_*` / `assign_agent` tools. A billable
  note + time → `agent_note`.

## 2. Project setup (incl. SOW-seeded)

0. **Template check first.** If the requested project matches a recurring/structured intent (e.g. an
   onboarding program or a training project — illustrative only; enumerate live via
   `ticket_templates`, and skip this step silently if the tool is absent from the catalog), present
   the matching parent templates (`Ticket Template` rows — name, ticket type, to-do count, whether
   it Creates a Project) and ASK before applying — never substitute silently. On yes:
   `apply_ticket_template(template_id, site_id[, user_email, summary])` replaces the create,
   assignment/category defaults, and task/child fan-out (steps 1–3 and 5) where the template
   carries them, in one server-side expansion — the client derives from `site_id` and the requester
   defaults to the site's designated General User, so the `set_ticket_client_site` + General-User
   re-point chain is NOT needed. A template does NOT carry per-project CF data: when the request
   supplies a SOW, strategic value, or repository URL, still run step 4's `update_ticket_fields`
   writes on the applied ticket. Then verify via a `get_ticket` read-back that the ticket id,
   agent, and category landed — transport success proves nothing (the fail-loud guard lives inside
   the backing runbook). Never hardcode template ids or to-do counts. On no, or no match: proceed
   with step 1 unchanged.
1. `create_new_ticket(user_email, summary, description, ticket_type="Project")` → returns the new id.
   - Client-scoped Projects: chain `set_ticket_client_site(ticket_id, client_id, site_id)` immediately
     (explicit `site_id` — omitting it no-ops when the client is already set), then re-point the requester
     at the site's designated General User: resolve via the `users` catalog (filter Client ID + Site ID +
     `Is General User = Yes`) and `update_ticket_fields({id, user_id})`. Never hardcode user ids.
2. `assign_agent(ticket_id, agent_id)` — resolve the owner via `agents`.
3. Category: pick a Project-valid path from `ticket_type_allowed_categories` (type 5) and set it.
   **Preflight the category against the SPECIFIC ticket type before you write it** — a category
   existing in the tenant-wide `categories` catalog does NOT mean it is scoped to your ticket type.
   `ticket_type_allowed_categories` is per-type: a category can be allowed on type 5 but not type
   20 (or vice versa), so check each type separately. If the category you want is missing from the
   type's allow-list, that is a tenant-config change (scope the category to the ticket type) — not
   something a write can force; setting an unlisted category fails or silently drops.
4. `update_ticket_fields` for the Project custom fields (resolve names via `ticket_type_fields`,
   type 5): `CFStrategicValue` (`High|Medium|Low`), `CFStatementOfWork` (the SOW text/reference),
   `CFRepositoryURL`, and `CFProjectTaskCreationMethod` (`Manual - From Template` | `AI - From
   SoW`). **SOW seeding:** when building tasks from a documented SOW, set
   `CFProjectTaskCreationMethod = "AI - From SoW"`, store the SOW in `CFStatementOfWork`, then
   create the Project Tasks the SOW implies.
5. For each Project Task: `create_new_ticket(ticket_type="Project Task")` →
   `set_ticket_parent(task, project)` → the same client/site + General-User requester re-point as
   step 1 (when the Project is client-scoped) → `assign_agent` → set a Task-valid category (type 20).
6. Verify the structure with `get_ticket` (check `parent_id`, `child_count`, agent, category).

## 3. Budgets — read-merge-write the full-replace array

Budgets live on the **single Project (type 5 / 53)**, not on tasks and not spread across child
projects. Because a program is ONE Project with Project Tasks under it (§1), put **one
whole-project budget** on that Project — one `budgets[]` row per budget type covering the whole
program — and let task-level time roll up into it (§4). Do not split the budget into per-task or
per-child-project rows; that is the fragmentation the nested-Project anti-pattern causes.

A Project's budget table is the **`budgets[]`** array on the `/api/Tickets` object, written via
**`update_ticket_fields`** (no dedicated write tool). Settable entry keys ONLY:

- `id` — the Budget Row ID. **Present = update that row in place; omit = create a new row.**
- `budgettype_id` — which budget type (resolve via `budget_types`).
- `hours` — the budgeted "Budget Hours" estimate.
- `rate` — defaults to the budget type's Default Rate; may be overridden per project.

Everything else (`money`, `actual_*`, `scheduled_*`, `remaining_*`, `estimated_*`, Completed
Time/Money) is **computed by Halo — never send it.**

**`budgets[]` is FULL-REPLACE → read-merge-write is MANDATORY** (verified: sending a partial
array drops the omitted rows). To add or change one row:

1. **Read** the current rows: `get_project_budgets(project_id)` — returns one row per budget type
   with its `Budget Row ID`. (This is the ONLY budget read — `get_ticket` does NOT echo
   `budgets[]`.)
2. **Merge**: build the UNION of every existing row (each with its `id`) plus the new/changed row.
3. **Write**: `update_ticket_fields` with that full `budgets[]`.

Example — preserve two rows, add a third (resolve the IDs via `budget_types` first):

```
update_ticket_fields {"id":<project>,"budgets":[
  {"id":<row1>,"budgettype_id":<A>,"hours":120,"rate":175},
  {"id":<row2>,"budgettype_id":<B>,"hours":20,"rate":125},
  {"budgettype_id":<C>,"hours":10,"rate":95}
]}
```

- **Scope: Projects only (type 5 / 53).** `update_ticket_fields` has no type guard — only send
  `budgets[]` to a Project. Guard this in your logic.
- **Do NOT clear all rows with an empty array.** `budgets:[]` is NOT a valid clear — it is rejected
  with `System.Exception: Record not found` (observed; empty-array clear is unsupported here, an
  exception to the platform's generic "send `[]` to clear an array" rule). To change budgets,
  read-merge-write the rows you want to keep; the supported per-row DELETE mechanism is not
  confirmed, so do not rely on removing individual rows via this array until verified — keep at
  least the rows you intend to retain in the union.
- After any write, **re-read via `get_project_budgets`** to confirm (the `update_ticket_fields`
  echo is the full ticket, large, and can be stale).

## 4. Task → budget attribution (so logged time tracks)

Time logged on a Project Task rolls into its budget type's Completed Hours/Money on the parent
Project — **automatically, by the task's budget type** (verified: 2h on a task → +2h / +$350 on
that type's project budget row). No budget field goes on the time note.

- A new Project Task **defaults** to the ticket type's default budget type. Per-task re-attribution
  has a **prerequisite that is easy to miss:** the Budget Type / per-task time-budget field is NOT
  on the Project Task ticket type (type 20) by default — verify with `ticket_type_fields` filtered
  to type 20 (out of the box it exposes only `CFStrategicValue`). Until that field is added to the
  type-20 ticket type in tenant config, there is nowhere on the task to carry a per-task Budget
  Type override, so plan around the default attribution (one whole-project budget, §3) rather than
  per-task overrides.
  *(Even once the field exists, the exact body field for the override is not yet pinned down —
  `fbudgettype` did not take in testing; try top-level `budgettype_id`. Confirm with a
  `get_project_budgets` rollup check before relying on it. Rollup works on the default attribution
  regardless.)*
- Time itself is logged with `agent_note(ticket_id=<task>, note, time_taken=<hours>)`. Whether the
  time is billable, and at what rate, is derived by Halo from the budget type's configuration —
  not something this layer composes.

## 5. Tracking & reporting

- `get_project_budgets(project_id)` is the burn-down read: per budget type, `Budget Hours` /
  `Rate` (budgeted) vs `Completed Time` / `Completed Money` (actuals Halo computes from logged
  time).
- **Flag over-budget** types (Completed Time > Budget Hours) and **Projects with no budget set**
  (empty `get_project_budgets`).

## 6. Guardrails

- Every ticket gets an **agent AND a category** — verify via `get_ticket` (binds on template-created
  tickets too — template defaults are per-template; fill whatever the template left unset).
- **Template-aware creates**: check `ticket_templates` before any net-new project create, ASK
  before applying, and confirm the returned ticket id via `get_ticket` — never trust transport
  success; never hardcode template ids or to-do counts.
- Client-scoped tickets carry the site's **designated General User as requester** — verify
  `user_name` on the `get_ticket` read-back (trust id fields over stale name echoes).
- **Preflight the category against the specific ticket type** (`ticket_type_allowed_categories`,
  type 5 and type 20 separately) — tenant-wide existence ≠ type-scoped; an unlisted category fails.
- **One whole-project budget on the single Project** — never per-task or per-child-project rows.
- **Never clear budgets with `budgets:[]`** — empty-array clear is rejected ("Record not found").
- **Read-merge-write before any `budgets[]` write** — `get_project_budgets` first, send the union.
- **Budget writes only to Projects** (type 5 / 53).
- **Verify after writes** via `get_ticket` / `get_project_budgets`; never trust the
  `update_ticket_fields` echo (large + can be stale).
- Halo notes render **HTML**, not markdown — use `<p>`/`<ul>`/`<strong>` and HTML-encode `&`.
- **Bulk (>~5 rows)** routes to the `halo-action-pipeline`, not ad-hoc loops.
