jpicklyk / adopt-project-scope
Migrates an already-populated, unscoped Task Orchestrator database in place to the project-scoping convention — creates a project anchor root, re-parents existing work trees under it, and writes rootId back to config.yaml. Use when a user says: adopt project scope, migrate this database to project scoping, make this DB multi-project, project-scope this workspace, adopt existing database, or set up project scoping for an existing DB.
Adopt Project Scope — In-Place Migration to Project Scoping
Take an existing, unscoped database (many depth-0 roots, no project anchor) and migrate it in place to the project-scoping convention: one type=project root that owns the workspace's work trees, with its UUID written back to .taskorchestrator/config.yaml under a project: block. Global containers (retrospectives, observations) stay at depth 0; test artifacts are flagged for cleanup, never moved or deleted.
This is a destructive-by-execution skill: the EXECUTE step re-parents real items. It defaults to a dry run and never mutates anything without an explicit confirmation. The only thing it ever deletes is its own throwaway server-probe tree.
For a fresh workspace with no existing unscoped work, use /task-orchestrator:init instead; this skill is for databases that already hold unscoped work trees.
Non-goals: cross-DB consolidation (merging items from a second database); merging multiple existing project anchors into one; any server-side enforcement of scope. This skill adopts a single workspace's single database.
Step 0 — Parse Arguments
- Project name — the first non-flag token(s) in
$ARGUMENTS. If absent, do not guess; ask for it in Step 3 (the dry-run plan) viaAskUserQuestionbefore any mutation. --dry-run— if present, the skill stops after Step 3 (the plan) and performs zero mutations regardless of confirmation. The dry run is ALSO always rendered before execution even without the flag; the flag only forces an early exit.
Step 1 — Preflight (abort gates)
Run these checks in order. Each abort prints a clear, user-facing reason and stops.
1a — Already adopted?
Read .taskorchestrator/config.yaml. If it contains a project: block with a non-empty rootId:, the workspace is already scoped:
◆ Workspace already adopted — config.yaml already points at project root <rootId>.
Nothing to do. Use /work-summary to inspect the existing project tree.Stop. (If the file is missing entirely, that is fine — a fresh unscoped DB has no config project: block. Continue.)
1b — Existing anchor already in the DB?
query_items(operation="search", type="project", depth=0)(list mode — query omitted, filtering by type and depth.) Drop any result tagged personal-root: a personal root is not a project anchor and must never be adopted as one.
- No results → continue to 1c.
One or more results → do NOT silently create a second anchor. Offer, via
AskUserQuestion:- Attach to existing — write the existing anchor's UUID into
config.yaml(skip anchor creation in EXECUTE; still re-parent MOVE roots under it) and continue. - Abort — stop and let the user resolve the ambiguity manually.
If the user picks "attach", record the existing anchor UUID as
ANCHOR_IDand note that EXECUTE step (a) is skipped.- Attach to existing — write the existing anchor's UUID into
1c — Server capability probe (the depth-sweep fix)
Bulk re-parenting is exactly the scenario that a pre-fix server corrupts: on a pre-#219/#220 server, re-parenting a subtree does NOT recompute descendant depths (bug 99a3e642), so this skill would silently leave the moved trees with wrong depths. Verify the fix is deployed with a disposable probe, then delete it.
Build a probe where a grandchild's depth MUST change when a middle node is re-parented deeper:
Create a throwaway root and a two-level branch under it in one call:
`
manage_items(operation="create", items=[
{ title: "zzprobe-root", type: "container", priority: "low" }
])`
Capture its UUID asP.Create the branch (sequential parentage, small tree).
manage_itemscreate has no ref/parentRef
mechanism (onlycreate_work_treedoes), so a sibling's UUID does not exist until its own create
call returns — M and G cannot be created in the same batch since G'sparentIdneeds M's real UUID:`
manage_items(operation="create", items=[
{ title: "zzprobe-A", parentId: P, priority: "low" } → capture as A (depth 1)
])``
manage_items(operation="create", items=[
{ title: "zzprobe-M", parentId: P, priority: "low" } → capture as M (depth 1, sibling of A)
])`
Capture M's returned UUID, then create G referencing it:`
manage_items(operation="create", items=[
{ title: "zzprobe-G", parentId: "<M's real UUID>", priority: "low" } → capture as G (depth 2, child of M)
])`Re-parent the middle node M under A (making it deeper):
`
manage_items(operation="update", items=[{ itemId: M, parentId: A }])`
Post-fix, M becomes depth 2 and G becomes depth 3.Read the grandchild's depth and capture it:
`
query_items(operation="get", itemId=G)`Delete the probe immediately, before acting on the result — this runs on every path, pass or fail:
`
manage_items(operation="delete", itemIds=[P], recursive=true)`Now evaluate the captured depth:
depth == 3→ fix is present. Continue.depth == 2(stale) → abort (probe already deleted):⊘ Server is missing the reparent depth-sweep fix (bug 99a3e642). Bulk re-parenting on this server would corrupt subtree depths. Upgrade the Task Orchestrator server (PR #219/#220 or later) and retry.
--dry-run skips this probe entirely — no execution will happen, so the capability check is unnecessary and the dry run stays genuinely mutation-free. The probe runs only on the real-execution path, after the user confirms in Step 3.
1d — Config-push capability (tolerant)
manage_project_config may be absent on older servers. Do not probe it destructively here — just remember to attempt the push in EXECUTE step (d) and, if the tool is unavailable, skip it with a note rather than failing the whole adoption. The local config.yaml write (step c) is the source of truth; the server push is a convenience sync.
Step 2 — Inventory & Classify
Full depth-0 census:
query_items(operation="overview", excludeTerminal=false, includeChildren=true, limit=<total>)If the response reports truncated: true, re-issue with limit set to the reported total so no root is missed.
Classify every depth-0 root into exactly one bucket:
| Bucket | Rule | Action |
|---|---|---|
| KEEP-GLOBAL | Title matches a global container — Session Retrospectives, Improvement Proposals; OR the root itself is tagged/typed agent-observation (these are standalone depth-0 items, each its own root — never children of a container); OR a depth-0 type=project item tagged personal-root (never MOVE it). | Stays at depth 0. |
| MOVE | Any work container or work tree (a root with work/queue/review children), and any standalone work item. Everything that is real project work. | Re-parented under the new anchor. |
| CLEANUP-CANDIDATE | Evident test artifacts: titles containing probe, smoke, depth-test, zzprobe; or tags matching mtest-*. | Flagged only — never moved, never deleted. Recommend /batch-complete. |
Ambiguous roots (no clear rule match — e.g., an untagged standalone item that could be work or a stray container): do NOT guess. Collect them and present via AskUserQuestion (multiSelect) asking which should MOVE vs stay KEEP-GLOBAL. Test-artifact-looking items always go to CLEANUP-CANDIDATE without asking.
Step 3 — Dry-Run Plan (always shown; --dry-run stops here)
Render the full plan before any mutation. Require explicit confirmation to proceed.
◆ Adopt Project Scope — Plan [project: "<name>"]
| Root | ID | Classification | Action |
|------|----|----------------|--------|
| Auth System | `a1b2c3d4` | MOVE | re-parent under new anchor |
| Payments | `e5f6a7b8` | MOVE | re-parent under new anchor |
| Session Retrospectives | `c9d0e1f2` | KEEP-GLOBAL | stays at depth 0 |
| Improvement Proposals | `13243546` | KEEP-GLOBAL | stays at depth 0 |
| zzprobe-smoke-tree | `5768798a` | CLEANUP-CANDIDATE | flag for /batch-complete (not moved) |
Summary: 2 move · 2 stay global · 1 cleanup candidate
Execution will create a "<name>" project anchor and re-parent 2 tree(s) under it.- If the project name is still unknown, ask for it now via
AskUserQuestionbefore showing the final counts. --dry-run: stop here. State plainly: "Dry run — no changes made. Re-run without --dry-run to execute."Otherwise, confirm via
AskUserQuestion:◆ Proceed with adoption? This re-parents <N> tree(s) under a new project anchor. 1. Yes — execute 2. No — abort, make no changesWait for the answer. Only "Yes" proceeds.
Step 4 — Execute
Perform in this order. Record pre-execution counts first (used by Step 5): the depth-0 root count, the MOVE count, and the KEEP-GLOBAL count from Step 2.
(a) Create the project anchor — skip if attaching to an existing anchor (Step 1b):
manage_items(operation="create", items=[{
title: "<project name>",
type: "project",
summary: "Project anchor — subtree scope for <project name>",
priority: "low"
}])Capture the new UUID as ANCHOR_ID.
(b) Batch re-parent the MOVE roots — one call, items array (never a sequential call per root):
manage_items(operation="update", items=[
{ itemId: "<move-root-1>", parentId: ANCHOR_ID },
{ itemId: "<move-root-2>", parentId: ANCHOR_ID },
...
])This relies on the depth-sweep fix verified in Step 1c — each moved subtree's descendant depths and root_id are recomputed server-side.
(c) Write the project: block into config.yaml at the main checkout root (the parent of git rev-parse --path-format=absolute --git-common-dir, so it is correct from a linked worktree; refuse a home directory, as /task-orchestrator:init does) — read-modify-write, preserving ALL existing content:
- Read the current
<main>/.taskorchestrator/config.yamltext (create the file with just the block if it does not exist). Insert this top-level block (surgical insert/append — do NOT regenerate or reformat the rest of the file; leave
work_item_schemas:,traits:,actor_authentication:, comments, and formatting byte-for-byte intact):project: rootId: "<ANCHOR_ID>" name: "<project name>"- If a
project:key somehow already exists (it should not — Step 1a would have aborted), update itsrootId/namein place rather than adding a duplicate key.
(d) Push the config server-side (tolerant — skip if the tool is absent):
manage_project_config(operation="push", rootId=ANCHOR_ID, configYaml="<full config.yaml text>")- A
warningfield about the root's type is only returned when the anchor'stypeis notproject; since step (a) setstype: "project", none is expected. Report it if present but treat it as non-fatal. - If
manage_project_configis unavailable on this server, skip this step and note: "Config pushed locally only — server does not support manageprojectconfig; the local config.yaml is authoritative."
Step 5 — Verify & Reconcile
Confirm the migration landed correctly. On ANY mismatch, report loudly and do NOT auto-rollback (say the remedy explicitly).
Anchored child count — the new anchor's direct children must equal the MOVE count:
`
queryitems(operation="overview", anchorId=ANCHORID)`
The anchor's direct-child count must equalN move.Global census — remaining depth-0 roots must reconcile:
`
query_items(operation="overview", excludeTerminal=false, limit=<total>)`
Expected depth-0 roots = KEEP-GLOBAL count + 1 (the anchor) + any CLEANUP-CANDIDATE roots left in place (cleanup candidates were flagged, not moved). Confirm the moved roots are no longer at depth 0.Depth spot-check — pick one moved tree that had a grandchild; confirm the grandchild's depth increased by exactly 1 from its pre-move value (the anchor added one level above the old root):
`
query_items(operation="get", itemId="<grandchild-of-a-moved-tree>")`
Render a before/after table:
✓ Adoption Complete — "<project name>" (anchor `<8-char-id>`)
| | Before | After |
|---------------------|--------|-------|
| Depth-0 roots | 5 | 4 |
| Under project anchor| 0 | 2 |
| Global containers | 2 | 2 |
| Cleanup candidates | 1 | 1 (flagged, not moved) |
↳ Config: project.rootId written to .taskorchestrator/config.yaml
↳ Server push: applied | skipped (tool absent)
↳ Cleanup candidates flagged for /batch-complete: <titles>On mismatch (e.g., anchored child count ≠ MOVE count):
⚠ Reconciliation mismatch — expected <X> children under the anchor, found <Y>.
No rollback was performed. To undo: re-parent the affected roots back to depth 0
with manage_items(operation="update", items=[{ itemId, parentId: null }]) for each,
then delete the anchor. Investigate before retrying.Finish by seeding the bundled process rules for the new anchor: run /task-orchestrator:init (a re-run is idempotent and resumes at rule seeding), or rely on the next session's config-sync.
Remind the user that schema changes / new config require an MCP reconnect (/mcp) to take effect if they rely on per-root schema resolution immediately.
Safety Summary
- Default dry run. No mutation without an explicit
AskUserQuestion"Yes". - Never deletes user data. Cleanup candidates are only flagged; the sole deletion is the throwaway server probe in Step 1c.
- Never regenerates config.yaml. The
project:block is surgically inserted; all other content is preserved verbatim. - Abort, don't corrupt. A pre-fix server (missing the depth-sweep) aborts in preflight rather than silently corrupting subtree depths.
- No auto-rollback. On verify mismatch, the skill reports the manual remedy rather than guessing at an undo.
Verification (before first real run)
Per the specification, exercise the skill against a COPY of a production-shaped DB:
- Dry run on the copy must produce zero mutations and a correct classification table.
- Execute on the copy and confirm Step 5 count reconciliation passes (depth-0 roots, anchored children, grandchild depth +1).
Only after the copy passes should the skill be run against the real database.