jpicklyk / init
Sets up Task Orchestrator for a project or for the user: creates (or finds) the project anchor root, writes the project: block into .taskorchestrator/config.yaml at the main checkout root, pushes it to the server, and seeds the bundled process rules. In project mode it also writes a project-level client.json when the server is a loopback HTTP server. With --user it creates a personal root and writes the user-level config.yaml and client.json instead. Use when a user says: initialize task orchestrator, task-orchestrator init, set up task orchestrator for this project, set up a personal root, init --user, or when the SessionStart notice says to run /task-orchestrator:init. NOT Claude Code's built-in /init (that writes CLAUDE.md), NOT tutorial onboarding (quick-start), NOT migrating an already-populated unscoped database (adopt-project-scope), and NOT launching or reconfiguring the server (configure-server).
Init — Project and Personal Root Setup
The explicit setup step that the SessionStart notices point at (/task-orchestrator:init for a project, /task-orchestrator:init --user for a personal root). It creates or finds a root work item, records its id in a project: block of the right config.yaml, pushes that config to the server, and makes sure the bundled process rules are present for that root.
Mode: --user in $ARGUMENTS selects the personal-root flow. Any other non-flag text is the project (or root) name. No flag means project mode.
Everything is created through MCP tools; the skill never creates roots over REST.
Step 0 — Resolve paths
Resolve the plugin root: try ${CLAUDE_PLUGIN_ROOT} (confirm the literal string ${CLAUDE_PLUGIN_ROOT} did not survive into the command you are about to run); otherwise use the dev-checkout path <git toplevel>/claude-plugins/task-orchestrator. Call it <plugin root>.
Print the locator's view with one command (forward slashes only, no backslashes anywhere in it, because the Bash tool collapses them):
node --input-type=module -e "const m = await import('file:///<plugin root>/hooks/config-locator.mjs'); const l = m.locateConfig(); console.log(JSON.stringify({located: l, userHome: m.userHome(), userConfig: m.userConfigPath(), userClient: m.userClientPath(), TASK_ORCHESTRATOR_HOME: process.env.TASK_ORCHESTRATOR_HOME || null, AGENT_CONFIG_DIR: process.env.AGENT_CONFIG_DIR || null}, null, 2))"Use the drive-letter form with forward slashes on Windows (file:///D:/...). If the plugin root cannot be resolved, derive the same paths by hand: the user home is $TASK_ORCHESTRATOR_HOME when non-empty, else the OS home; the user config is <home>/.taskorchestrator/config.yaml; the client file is <home>/.taskorchestrator/client.json.
Every later message names files by these resolved paths, never a hard-coded ~.
Step 1 — Verify the server
Call get_context(). If the Task Orchestrator MCP tools are absent or the call errors, stop and point the user to /task-orchestrator:configure-server.
Project mode (default)
P1 — Find the main checkout root
The target root is the parent of git rev-parse --path-format=absolute --git-common-dir (this resolves to the main checkout even from a linked worktree). Not a git repository: use the current directory. Call it <main>.
- Refuse a home directory. If
<main>equals any home (userHome(),os.homedir(), oros.userInfo().homedir), stop: a config directly in a home directory is never a project config. Offer/task-orchestrator:init --userinstead. - Warn on
AGENT_CONFIG_DIR. When it is set, its config shadows the one about to be written; say which file wins.
P2 — Already initialized?
If <main>/.taskorchestrator/config.yaml exists with a non-empty project.rootId, the project is initialized. Skip to P5 (push), then the P5b client.json step, then Rule seeding, so an already-initialized project still gets its client.json. Re-running is idempotent.
P3 — Find or create the anchor
query_items(operation="search", type="project", depth=0)Drop any item tagged personal-root from the results.
- Matches remain ->
AskUserQuestion: attach to one of them, create a new anchor, or abort. Attaching uses that item's id asrootId. - No matches -> census the depth-0 roots with
query_items(operation="overview"). If roots exist other than the global containers (Session Retrospectives, Improvement Proposals,agent-observationitems,type=projectitems), the database already holds unscoped work. Offer viaAskUserQuestion: hand off to/task-orchestrator:adopt-project-scope(it creates the anchor, re-parents the work and writes the config; init then resumes at Rule seeding), create a fresh anchor and leave those roots alone, or abort. - Nothing but global containers -> create a fresh anchor.
Name: from $ARGUMENTS, else the main checkout's directory name; confirm with the user. Create it at depth 0:
manage_items(operation="create", items=[{title: "<name>", type: "project", priority: "low"}])P4 — Write the project: block
Write at <main>/.taskorchestrator/config.yaml:
project:
rootId: "<anchor-uuid>"
name: "<name>"Read-modify-write surgically: preserve all other content byte for byte; update an existing project: key in place rather than duplicating it. Create the file with only the block when it does not exist.
P5 — Push to the server
If a manage_project_config tool is available, push the full current file text:
manage_project_config(operation="push", rootId="<anchor-uuid>", configYaml="<full file text>")- Success: the returned
fingerprintconfirms it; re-pushing identical content returns the same fingerprint. VALIDATION_ERROR: show the parse error; the local write is saved, so tell the user to fix the file and re-run (or/manage-schemas validate).CONFLICT_ERROR(superseded): the server holds a newer config. Fetch it withmanage_project_config(operation="get", rootId=...)and reconcile, or passforce: trueonly if overwriting is intended.- A
warningfield: relay it (non-fatal). - Tool absent: note it and continue; the local file is authoritative.
P5b — Project-level client.json
So the config-sync and other hooks can reach the REST API without an environment variable. The API URL resolves in this order: TASK_ORCHESTRATOR_API_URL, then apiUrl in a client.json beside the located project config (and, in a linked worktree, the one in the main checkout), then apiUrl in the user-level client.json; if none resolves the hooks do nothing.
Run
apiBaseUrl()(and, below,isLoopbackApiUrl()) from<plugin root>/hooks/api-client.mjswith a one-liner like Step 0:`bash
node --input-type=module -e "const m = await import('file:///<plugin root>/hooks/api-client.mjs'); console.log(JSON.stringify(m.apiBaseUrl({cwd: '<main>'})))"`
A URL resolves: skip, with one line naming it.- Otherwise find the registered http MCP entry and derive and health-check
<base>exactly as U4 steps 1-3. Stdio or an unhealthy server: skip with one line. - Check the host with
m.isLoopbackApiUrl("<base>"). A project-level file is honoured only as a bare loopback origin: anhttp/httpsURL without credentials whose host islocalhost, an IPv4 address in127.0.0.0/8, or[::1], with an optional port and no path, query or fragment (a file inside a repo must not redirect the bearer token to a remote host or choose the request path); anything else is ignored silently. When the check is false, do not write the file: say a project-level file would be ignored for this URL, and that a non-loopback server, or one behind a path prefix, needsTASK_ORCHESTRATOR_API_URLor/task-orchestrator:init --user(the user-level file is unrestricted). - When true, write
<main>/.taskorchestrator/client.jsonas UTF-8 JSON containing only{"apiUrl": "<base>"}. If the file exists with a different value, show both and ask. - Note how the file is found: the hooks read
client.jsonbeside the located config first. In a linked worktree, when theclient.jsonbeside the worktree's own config yields no usable loopback URL, the hooks also read.taskorchestrator/client.jsonin the main checkout, under the same loopback rule.AGENT_CONFIG_DIRpins both files to the directory it names. One file in the main checkout therefore serves every linked worktree. Advise adding.taskorchestrator/client.jsonto.gitignore: the URL is machine-specific.
P6 — Rule seeding, then advise
Run Rule seeding below, then advise committing .taskorchestrator/config.yaml (and .taskorchestrator/rules/ if present). Never commit client.json.
Close with one line on the orchestration mode, because a located config turns the plugin's orchestration hooks on by default. Read the effective mode from the config just written (orchestration.mode; an absent block means workflow) and say:
Orchestration mode:
<mode>. The plugin now injects its orchestration core at session start, deniesAgentdispatches that omitmodel, and adds a dispatch hint afteradvance_item. Setorchestration: { mode: schema }(no tiers or model table) or{ mode: off }(all three hooks silent) in.taskorchestrator/config.yamlto change it.
Do not write the orchestration: block yourself in project mode: absent already means workflow, and init touches this file surgically.
--user mode
U1 — State the target
Say the directory about to be written: <userHome()>/.taskorchestrator/ (config at userConfigPath(), client file at userClientPath()).
When TASK_ORCHESTRATOR_HOME is set, warn that it replaces the home: the user-level config.yaml and client.json are read only from $TASK_ORCHESTRATOR_HOME/.taskorchestrator/, and <OS home>/.taskorchestrator/config.yaml is ignored unless AGENT_CONFIG_DIR points at it.
TASK_ORCHESTRATOR_CEILING is an opt-in test seam that stops project-config discovery from climbing above that directory; init neither sets it nor needs it.
U2 — Find or create the personal root
query_items(operation="search", type="project", depth=0, tags="personal-root")One match: reuse it. Several: ask which. None:
manage_items(operation="create", items=[{title: "Personal", type: "project", tags: "personal-root", priority: "low"}])(Use $ARGUMENTS for the title when given.)
U3 — Write and push the user-level config
Write the project: block (same surgical rule as P4) into userConfigPath(), then push it with manage_project_config exactly as in P5.
U3b — Orchestration scope for the personal root
A user-level config counts as "config located" for every directory that has no project config of its own. The orchestration hooks therefore apply everywhere once this file exists: the orchestration core is injected at session start, an Agent dispatch without model is denied, and advance_item gains a dispatch hint, in unrelated repositories too. A project-level config only reaches its own checkout, so this is the one place the reach has to be chosen deliberately.
If the user-level config already has an orchestration: block, keep it and state its mode in one line. Otherwise ask:
AskUserQuestion(questions: [{
question: "A personal root applies Task Orchestrator's orchestration hooks in every directory without its own project config. Which mode should those directories get?",
header: "Orchestration",
multiSelect: false,
options: [
{ label: "Workflow everywhere (default)", description: "Leave the block out. Tier-aware orchestration core, model guard and dispatch hint are active in every unconfigured directory." },
{ label: "Schema everywhere", description: "Write orchestration: { mode: schema }. No tiers or model table; the model guard and dispatch hint stay active." },
{ label: "Off outside projects", description: "Write orchestration: { mode: off }. All three hooks stay silent in directories that have no project config. The personal root still anchors items and syncs config." }
]
}])For the second and third answers, add the block to userConfigPath() with the same surgical rule, in block form:
orchestration:
mode: off # or schemaNo re-push is needed: the key is read client-side by the hooks and ignored by the server. The user-level mode applies only where no project config is located. Inside a project the project's own config is found first and decides alone — its orchestration.mode, or workflow when it has no such block — so off here does not switch orchestration off in initialized projects.
U4 — client.json
client.json lets the config-sync and other hooks find the REST API without an environment variable. It holds apiUrl only and never a token. The user-level file is unrestricted: unlike the project-level file (P5b) it is honoured for any host.
- Find the registered Task Orchestrator MCP entry the way
hooks/session-start.mjsdoes:.mcp.jsonmcpServers, then~/.claude.jsonmcpServersandprojects[<dir>].mcpServers. Anhttpentry carriesurl. - No http entry (stdio): skip with one line. Config-sync stays a no-op in that case; MCP rule seeding below still works.
Otherwise
<base>= scheme + host + port + path with a trailing/mcpremoved. Check it:`bash
curl -s -o /dev/null -w "%{http_code}" <base>/api/v1/health`
Anything but200: do not write; report the code.- Write
userClientPath()as UTF-8 JSON containing only{"apiUrl": "<base>"}. If the file exists with a different value, show both and ask. - If
TASK_ORCHESTRATOR_API_URLis set in the environment, note that the environment value wins over the file.
Then run Rule seeding.
Rule seeding (both modes)
The plugin ships these rules in <plugin root>/bundled-rules/: protocol.entry-seat, protocol.in-phase-seat, protocol.read-only-agent, commit-discipline, review-scoping, plus a manifest.json mapping each key to the list of hashes ever shipped, current one last. Config-sync's policy per key: absent on the server -> push; a known older hash -> overwrite; any other hash -> never touched.
REST path (an API URL resolves: TASK_ORCHESTRATOR_API_URL, or a client.json, project-level or user-level). Run the hook by hand with stdin closed so it behaves as SessionStart:
node "<plugin root>/hooks/config-sync.mjs" < /dev/nullProject mode: run from the workspace directory. --user mode: prefix AGENT_CONFIG_DIR=<userHome()> (drive-letter form, forward slashes) so it locates the user-level config. It prints one JSON line with additionalContext; relay it. If it reports deferred items, say they will be retried: re-run init or wait for the next session. Always follow with verification:
query_rules(operation="list", rootId="<root-uuid>")MCP path (no API URL): query_rules(operation="list", rootId=...), then per manifest key apply the same policy:
- server hash equals the manifest's current hash: in sync.
- key absent, or the server hash is one of the manifest's older hashes: stash it.
- any other hash: leave it (project-authored or a newer plugin) and say so.
Stash with the LF text of bundled-rules/<key>.md (the server does not normalize CRLF in an inline body):
manage_plan_documents(operation="stash", rootId="<root-uuid>", slug="rule/<key>", body="<LF text>")Compare the returned contentHash with the manifest's LAST hash for that key. Mismatch: stash once more. Still mismatched: report the key and both hashes, and warn that config-sync will treat that body as project-authored and never repair it.
Rules need a depth-0 root; both modes use one.
End report
State: mode; root id and name; each file written (resolved path); push result; a rule table (key, action: pushed / in sync / kept / failed); and the next step: start a new session so the setup status changes from not-set-up to configured.