Imported from aicraft-sdk/craftflow (
plugins/craftflow/skills/craftflow-router/SKILL.md). Install upstream withnpx skills add aicraft-sdk/craftflow --skill craftflow-router. Copyright stays with the author.
craftflow Router
Runtime contract only. v10 restores trust-first orchestration: route intent, hydrate workflow state, write workflow artifacts, execute the task graph, validate agent output, and fail closed on ambiguity, skipped work, or missing persistence.
Mandatory reference read: before routing (## 1.) or dispatching any agent, read
tools/craftflow-plugin/plugins/craftflow/skills/_shared/router-protocol.md once per
session if not already read. It holds the host-agnostic Intent Routing table and the
dispatch prompt scaffold, both Read() from there rather than inlined below — see backlog
item 8's hooks-as-bridge redesign. A missing or unreadable shared doc is a hard-stop
condition, same as any other required reference read in this file — do not silently
proceed with routing/dispatch decisions from stale in-context memory of its content.
1. Intent Routing
Shared with Cursor — canonical text lives in
tools/craftflow-plugin/plugins/craftflow/skills/_shared/router-protocol.md §
"Intent Routing" (Phase 3 of the hooks-as-bridge redesign, backlog item 8). Read() that
file now if you have not already this session; it has the full priority/keyword/chain
table, routing rules, and the announce-line convention. See references/fast-path.md
for the risk-keyword detection table used to choose between BUILD's fast path and full
chain. An optional Jev hint block (Claude Code only; produced by the opt-in
UserPromptSubmit hook gated by config/jev.json, off by default), when present, is
consulted per the shared doc's hint-precedence rule (ERROR keywords always win).
Jev auto-detect consent contract: When a consent-request block appears in additionalContext during the optional auto-detect SessionStart hook, execute the embedded AskUserQuestion contract before your first substantive reply and run the matching --record-consent command. See router-protocol.md "## Jev Auto-Detect Consent Request Contract" for the full contract and tag format.
0. Resolve Project Root
Shared with Cursor — canonical text lives in
tools/craftflow-plugin/plugins/craftflow/skills/_shared/router-protocol.md §
"Resolve Project Root" (Phase 3b of the hooks-as-bridge redesign, backlog item 8).
Read() that file now if you have not already this session; it has the full
single-repo/multi-repo resolution algorithm — the git rev-parse --show-toplevel check,
the 1a. multi-repo branch (its own dedicated resolver script), and the
DETERMINISTIC/AMBIGUOUS/NO_REPO_FOUND outcome handling. Runs once per session,
before ## 1. routing and before any .craftflow/state/... path is touched. PROJECT_ROOT
resolved here is reused verbatim by every later step in this document (memory load,
workflow-artifact creation, resume, and — for BUILD only — worktree creation) — never
re-run git rev-parse --show-toplevel a second time later in a session.
0a. Task Tool Capability Detection
Runs once per session, immediately after ## 0. resolves PROJECT_ROOT and before the first
workflow artifact write of that session (i.e. before ## 5. Workflow Preparation creates a new
workflow, and before ## 4. Resume And Hydration needs to decide whether TaskList() is safe to
call). Never re-probe within the same session once a value is recorded — reuse the in-context
result for every later workflow created or resumed this session. Always re-probe fresh in a new
session; never trust a capabilities.task_tools_available value carried over from a prior
session's resumed artifact (that value can go stale — tool availability has been observed to
reappear or disappear across restarts).
Detection procedure:
- Call
ToolSearch(select: "TaskCreate,TaskList,TaskGet,TaskUpdate")(or the top-level tool listing query if theselectparameter is unsupported in the current Claude Code build). - All 4 names present in the result ->
task_tools_available = true. - None of the 4 names present ->
task_tools_available = false. - Any ambiguous, partial, or errored probe result -> fail-safe to
task_tools_available = false. Never assumetruewithout an unambiguous positive confirmation. - Record the boolean under
capabilities.task_tools_availablein the workflow artifact JSON (see## 6. Workflow Task Graphs -> Parent workflow creationfor the artifactWrite()literal this field is added to, and## 2a. Workflow Artifact And Hook Policyfor the schema documentation this field is added to).
This section's content is inherently Claude-only vocabulary (task_tools_available,
ToolSearch) and always lives here in craftflow-router/SKILL.md, never in
skills/_shared/router-protocol.md, regardless of whether ## 0.'s own project-root resolution
algorithm is shared or host-specific.
2. Memory Load And Template Validation
Always run this before routing or resuming. Memory is organized in three tiers:
- project/ — long-lived cross-workflow state (architecture decisions, durable patterns, ongoing blockers). Always load first.
- workflows/{wf-id}/ — per-workflow isolated state (current focus, active phase, in-flight tasks). Load only when a
workflow_uuidis already known (resume path). - workspace/ — long-lived, cross-repo state shared by every project under a configured workspace root. Lowest precedence; loaded only when a workspace marker is discovered.
1. Bash("mkdir -p \"$PROJECT_ROOT/.craftflow/state/project\"")
2. Read("$PROJECT_ROOT/.craftflow/state/project/activeContext.md")
3. Read("$PROJECT_ROOT/.craftflow/state/project/patterns.md")
4. Read("$PROJECT_ROOT/.craftflow/state/project/progress.md")
5. Read("$PROJECT_ROOT/.craftflow/state/project/constitution.md") — skip gracefully if absent; when present, MUST constraints are active for this session
5a. Workspace-tier discovery (capped upward walk, max 3 levels above PROJECT_ROOT, to
avoid runaway scans): starting at PROJECT_ROOT's parent, check each ancestor for
EITHER a `.craftflow-workspace.json` file OR a `.craftflow/state/workspace/`
directory. Stop at the first match, or after 3 levels, or at the filesystem root,
whichever comes first. Never select `$HOME` or `/` itself as a matched workspace
root even if a marker is somehow present there (defense in depth alongside
`craftflow_workspace_init.py`'s own refusal list).
- No marker found: skip silently -- zero behavior change (existing production
reality for every non-workspace session today).
- Marker found at {workspace_root}:
Read("{workspace_root}/.craftflow/state/workspace/activeContext.md")
Read("{workspace_root}/.craftflow/state/workspace/patterns.md")
Read("{workspace_root}/.craftflow/state/workspace/progress.md")
Missing/malformed file: auto-heal via craftflow:session-memory template (same rule
as project/'s own auto-heal), never a hard stop.
Unreadable (permission error): skip with a logged note, never a hard stop.
- workspace_root == PROJECT_ROOT (the current project IS itself the configured
workspace root): do not double-load; treat workspace tier as absent for this
session to avoid merging a tier with itself.
Merge precedence (lowest to highest): workspace/ < project/ < workflows/{wf-id}/ --
for ## Current Focus / ## Next Steps / ## Tasks, the highest-precedence tier present
wins; ## Decisions / ## User Standards / ## Architecture Patterns always come from
project/ (unchanged rule) with workspace/'s own such sections available as
additional read-only context, never overriding project/'s.
Note: this **load** walk is deliberately NOT membership-gated, unlike
`discover_workspace_root()`'s write-grant walk in `craftflow_hooklib.py`. The
divergence is intentional (back-compat with existing workspace-memory users) and
ADR-recorded -- do not "re-sync" the two without reading that ADR first.
6. If workflow_uuid is known (resume path):
a. Bash("mkdir -p \"$PROJECT_ROOT/.craftflow/state/workflows/{workflow_uuid}\"")
b. Read("$PROJECT_ROOT/.craftflow/state/workflows/{workflow_uuid}/activeContext.md")
c. Read("$PROJECT_ROOT/.craftflow/state/workflows/{workflow_uuid}/patterns.md")
d. Read("$PROJECT_ROOT/.craftflow/state/workflows/{workflow_uuid}/progress.md")
Merge: workflow-scoped values override project-scoped for current-focus
fields (## Current Focus, ## Next Steps, ## Tasks) only.
7. Fallback: If project/ files are missing or empty, also read the root-flat
files ($PROJECT_ROOT/.craftflow/state/activeContext.md etc.) and merge content into project/
before proceeding. Root-flat files are the backward-compat layer.
Do not parallelize step 1 with reads.
State-read compaction self-heal: if any Read(...) in this section is denied with a
state-read-compaction reason (the target .craftflow/state/** file is oversized), do not
treat the denial as a hard failure. Instead, run the exact craftflow_state_query.py ... --mode summary command named in the deny message via Bash(...) and treat its stdout as the
loaded memory content for that file.
If a project/ memory file is missing:
- Create it using the
craftflow:session-memorytemplate. - Read it before continuing.
Required sections — shared with Cursor — canonical table lives in
tools/craftflow-plugin/plugins/craftflow/skills/_shared/router-protocol.md §
"Memory File Required Sections". Read() that file now if you have not already this
session.
Auto-heal rule:
- Insert missing sections before
## Last Updated. - After every
Edit(...), immediatelyRead(...)and verify the new section exists.
JUST_GO:
- Read
$PROJECT_ROOT/.craftflow/state/project/activeContext.md ## Session Settings. - If
AUTO_PROCEED: true, setJUST_GO=true. - While
JUST_GO=true, auto-default all non-REVERT AskUserQuestion gates to the recommended option and log the choice in## Decisions. - Exception: the Skill-Distill Approval Flow's
AskUserQuestion(§ 8) is carved out as a de facto REVERT-class gate under JUST_GO — see its own "JUST_GO carve-out" for the fail-closed default (Defer, neverApprove).
v10 trust rule:
JUST_GOnever overrides explicit user/project standards, open plan decisions, or failure-stop gates — including any gate that explicitly documents its own exception, such as the multi-repo AMBIGUOUS resolution gate in Worktree Isolation.- If a plan still has unresolved
Open Decisions, BUILD may not start, even inJUST_GO.
2a. Workflow Artifact And Hook Policy
Core law:
- Durable router state lives under
$PROJECT_ROOT/.craftflow/state/workflows/{workflow_uuid}.json - Companion event log lives under
$PROJECT_ROOT/.craftflow/state/workflows/{workflow_uuid}.events.jsonl - Router-owned gates still include
plan_trust_gate,phase_exit_gate,failure_stop_gate,memory_sync_gate, andskill_precedence_gate
Mandatory reference read:
- Before workflow creation, artifact mutation, hook policy changes, or resume logic that depends on artifact fields, immediately read
references/workflow-artifact-and-hook-policy.md. - That reference contains the verbatim artifact schema, event log contract, hook policy, and gate wording extracted from the prior router monolith. Treat it as load-bearing orchestration law, not optional background.
3. Task Metadata Contract
Every CRAFTFLOW task description starts with normalized metadata lines:
wf:{workflow_uuid}
kind:{workflow|agent|remfix|memory|reverify|research}
origin:{router|component-builder|bug-investigator|code-reviewer|silent-failure-hunter|integration-verifier|planner}
phase:{build|build-implement|build-review|build-hunt|build-verify|build-doc-sync|learn-distill|skill-distill|debug|debug-investigate|debug-review|debug-verify|doubt-verify|review|review-audit|plan|plan-create|plan-review-gap-1|plan-review-gap-2|memory-finalize|re-review|re-hunt|re-verify|re-plan|research-web|research-github|plan-bakeoff-candidate-opus|plan-bakeoff-candidate-sonnet|plan-bakeoff-candidate-haiku|plan-bakeoff-candidate-fable|plan-bakeoff-judge}
plan:{path|N/A}
scope:{ALL_ISSUES|CRITICAL_ONLY|N/A}
reason:{short reason or N/A}
Rules:
wf:is mandatory on every child task.- Router must generate
workflow_uuidbeforeTaskCreate()and use it from the first write.wf:PENDING_SELFis retired in v10. kind:is mandatory and drives resume, routing, and counting logic.origin:is mandatory on everykind:remfixtask.plan:is required on workflow, agent, reverify, and memory tasks.reason:is required on remediation and research tasks.- The router must never depend on loose prose when metadata can answer the question.
4. Resume And Hydration
After memory load:
TaskList()
Task-tool fallback:* when capabilities.task_tools_available == false (or still
"unknown" — run ## 0a first in that case, then re-evaluate), skip the TaskList() call
above entirely. Identify the active workflow by finding the most recently updated live workflow
artifact under .craftflow/state/workflows/*.json (by file modification time), applying the
same never-unscoped-fallback safety rule this section already documents for TaskList()-derived
resume — if more than one live artifact is ambiguous for the current conversation, do not resume
without explicit user scoping. Reconstruct runnable phases from that artifact's
phase_status/phase_cursor/normalized_phases fields plus the tail of the matching
.craftflow/state/workflows/{workflow_uuid}.events.jsonl, instead of TaskGet()/TaskList()-
derived task state. The Resume algorithm's steps 3-5 below and the Safety rules apply unchanged
in spirit — substitute "read the workflow artifact + events.jsonl" everywhere those steps say
"read tasks whose descriptions contain wf:". When capabilities.task_tools_available == true,
this section runs exactly as documented today — no behavior change on that path.
Hydration rules:
- Find active parent workflow tasks by subject prefix
CRAFTFLOW BUILD:,CRAFTFLOW DEBUG:,CRAFTFLOW REVIEW:,CRAFTFLOW PLAN:. - If more than one active workflow exists, scope by the current conversation and matching
wf:markers. Do not resume a workflow you cannot scope confidently. - Reconstruct runnable tasks from
TaskList()andTaskGet()usingwf:+kind:+phase:. Do not rely on stored task IDs for correctness. - Read and write only the state namespace. Ignore legacy
.craftflow/*.mdand.craftflow/workflows/*state during hydration. State lives under.craftflow/state/. [craftflow-internal] memory_task_idinactiveContext.mdis only a transient optimization. If it is missing, stale, or points to a differentwf:, ignore it and reconstruct the memory task from the current workflow scope. [EASY TO MISS: stale memory_task_id is the #1 cause of cross-workflow pollution]- Never use an unscoped fallback like "first pending Memory Update task". [EASY TO MISS: unscoped lookups silently pick up orphan tasks from prior workflows]
Resume algorithm:
- Identify the active parent workflow.
- Extract
workflow_uuidfrom thewf:line. - Read all CRAFTFLOW tasks whose descriptions contain that
wf:. - Derive runnable tasks from
statusandblockedBy. - Reconstruct the memory task as the unique pending/in_progress
kind:memorytask in the samewf:.
Scope-decision resume:
- Before normal routing, check
$PROJECT_ROOT/.craftflow/state/project/activeContext.md ## Decisionsfor a live marker:[SCOPE-DECISION-PENDING: wf:{workflow_uuid} reason:{...}]
- If present, treat the current user reply as the answer to that pending BUILD scope gate:
critical only-> create the pending REM-FIX withscope:CRITICAL_ONLYall issues-> create the pending REM-FIX withscope:ALL_ISSUES- anything else -> ask again with the same two options and stop
- After consuming a valid answer:
- remove the pending marker from
## Decisions - create the scoped REM-FIX
- block downstream re-review / re-hunt / verifier tasks as normal
- stop after task creation so the next turn resumes from task state, not from repeated prose parsing
- [EASY TO MISS: When persisting user decisions, use the user's exact words. Paraphrasing introduces drift that compounds across resume cycles.]
- remove the pending marker from
Safety rules:
- If a task list is shared across sessions, always scope by
wf:before resuming. - If a task has
status=in_progressand unresolved blockers, treat it as waiting on remediation, not as a free-running orphan. - If a task has
status=in_progressand no blockers, ask the user whether to resume, delete, or mark complete. - If legacy tasks exist with subjects starting
BUILD:,DEBUG:,REVIEW:, orPLAN:without theCRAFTFLOWprefix, ask whether to resume the legacy workflow or start a fresh CRAFTFLOW workflow.
5. Workflow Preparation
Shared preparation
Before creating a new workflow:
- Read
$PROJECT_ROOT/.craftflow/state/project/activeContext.md ## Referencesto discoverPlan,Design, and priorResearchfiles. - Read
$PROJECT_ROOT/.craftflow/state/project/activeContext.md ## Decisionsfor prior planner/build clarifications. - Read
$PROJECT_ROOT/.craftflow/state/project/progress.md ## Current Workflowand## Tasksfor pending work that should resume instead of duplicating. - Read the latest
$PROJECT_ROOT/.craftflow/state/workflows/*.jsonartifact if one exists for the current conversation.
Intent Readiness Gate (MANDATORY before PLAN or BUILD): Before dispatching to planner or builder, verify the intent contract meets three conditions:
- Context-bounded: The full intent (goal + constraints + acceptance criteria) fits within the agent's prompt scaffold without truncation. If the intent requires loading more than 5 source files to be understood, decompose first (switch to PLAN).
- Contradiction-free: No acceptance criterion contradicts a stated constraint or non-goal. If contradictions exist, halt and persist
pending_gate="intent_contradiction". - Sufficiently specific: Every acceptance criterion maps to at least one verifiable scenario. If a criterion is unverifiable ("make it better" without a metric), halt and ask for specificity.
Router-owned interface fields:
plan_mode:direct|execution_plan|decision_rfcverification_rigor:standard|critical_pathcheckpoint_type:none|human_verify|decision|human_actionproof_status:passed|gaps_found|human_needed
BUILD preparation
- Before any BUILD-specific readiness decision or child-task creation, immediately read
references/build-workflow.md. - Use the
### BUILD preparationand### BUILD task graphblocks in that file as the canonical BUILD law. - Before fast-path routing (performed during BUILD preparation), read
references/fast-path.mdfor the canonical keyword table, agent dispatch table, gate table, and escalation protocol.
Fast Path Detection
Before BUILD child task creation, read references/fast-path.md (contains canonical keyword table and escalation protocol).
Perform risk_keyword_scan:
- Scan request text (case-insensitive) against all keyword groups in
references/fast-path.md → risk_keyword_scan — Keyword Table - Collect matched keywords into
fast_path_risk_signals - Assign
build_mode:fast_path_risk_signals == []→build_mode = "fast_path"fast_path_risk_signals != []→build_mode = "standard"
- Write to workflow artifact:
build_mode,fast_path_risk_signals,fast_path_escalated: false - Announce routing decision:
- Fast path:
-> FAST-PATH BUILD (no risk signals) - Standard:
-> FULL BUILD (risk signals: {matched keywords})
- Fast path:
Worktree Isolation (BUILD Default)
Every new BUILD workflow attempts to isolate file writes in a dedicated git worktree:
-
At BUILD start,
PROJECT_ROOTwas already resolved once by## 0. Resolve Project Rootat session start — reuse it directly. Do not re-rungit rev-parse --show-toplevelor invoke the workspace-root resolver script a second time.[EASY TO MISS: if
## 0.fell back toNO_REPO_FOUND/RESOLVE_SCRIPT_ERROR(no git-repo children under cwd, or the resolver script itself failed),PROJECT_ROOTis already set to$(pwd)from that fallback — Worktree Isolation still proceeds with themkdir/git worktree addblock below using that same fallback value, exactly like the single-repo path. There is no separate NO_REPO_FOUND branch here anymore;## 0.already handled it.]With
PROJECT_ROOTreused from## 0.above, capture both commands' output and exit code, mirroring theMERGE_EXIT/COPY_FALLBACK_EXIT/WORKTREE_REMOVE_EXITpattern used later in this section — never leave either command's exit code unchecked:MKDIR_OUTPUT=$(mkdir -p "$PROJECT_ROOT/.claude/worktrees" 2>&1) MKDIR_EXIT=$? WORKTREE_ADD_OUTPUT=$(git worktree add "$PROJECT_ROOT/.claude/worktrees/{worktree_dir}" -b {worktree_branch} 2>&1) WORKTREE_ADD_EXIT=$?where
worktree_dirandworktree_branchcome from thecraftflow_workflow_id.pyhelper output (see step 1 of Parent workflow creation above). The trailing 8-hex suffix in both names ties the worktree back to the workflow id, guaranteeing concurrent same-feature workflows always get distinct dirs/branches.- If
MKDIR_EXIT != 0ORWORKTREE_ADD_EXIT != 0: proceed to step 3 ("On failure") below. Use$MKDIR_OUTPUTas the{error}value ifMKDIR_EXIT != 0; otherwise use$WORKTREE_ADD_OUTPUT. - Otherwise (
MKDIR_EXIT == 0ANDWORKTREE_ADD_EXIT == 0): proceed to step 2 ("On success") below.
- If
-
On success:
- Set
worktree_mode: "auto_created"in the workflow artifact - Set
worktree_path: "{project_root}/.claude/worktrees/{worktree_dir}"(absolute;worktree_dirfrom the helper) - Set
worktree_branch: "{worktree_branch}"(from the helper) - Add
## Worktreesection to every builder and verifier task description:## Worktree WORKTREE_PATH: {worktree_path} All file reads, edits, and writes must use paths rooted at WORKTREE_PATH. Do not modify files outside WORKTREE_PATH during this BUILD phase.
- Set
-
On failure (any git error — shallow clone, detached HEAD, path conflict):
- Set
worktree_mode: null - Append
{"event":"worktree_fallback","reason":"{error}"}to the event log - Continue with main tree — never block a workflow over worktree failure
- Omit the
## Worktreesection from task descriptions when in fallback mode
- Set
-
After
integration-verifierreturns PASS on the final phase and BEFORE memory-finalize, run the pre-merge safety guard, then finalize. The guard applies identically whether finalize ends up running a realgit mergeor falling back to the copy script (4e) — never skip it because a fallback path is expected. Ifworktree_mode != "auto_created", skip this entire step (nothing to merge).a. Resolve project root, the plugin install path, and this workflow's own identity: Guard first, before anything else in this step: read
worktree_pathfrom the current workflow artifact. Ifworktree_pathis null or empty despiteworktree_mode == "auto_created"(a corrupted or partially-written artifact), treat it identically toworktree_mode != "auto_created"at the top of step 4 — skip the rest of step 4 entirely. Do NOT run thedirnamederivation below or any of 4b-4e (nogit status, nogit merge, nogit worktree remove, nogit branch -d). Append{"event":"worktree_path_missing"}to the event log and proceed straight to doc-sync/memory-finalize, exactly like the ordinary no-worktree case. [EASY TO MISS:dirnameon an empty/unset string silently returns.(the current working directory) at every nesting level — no error, no sentinel. Without this guard,PROJECT_ROOTwould silently become an arbitrary directory and every downstream command in 4b-4e would run against the wrong root with no failure signal.]Once
worktree_pathis confirmed present, readworkflow_uuidandworktree_branchfrom the current workflow artifact (already set in step 2) —PROJECT_ROOTis derived fromworktree_path, never re-derived viagit rev-parse --show-toplevel. A workflow whose worktree was created via the step 1a multi-repo resolver still has a cwd that does not resolve to a git repo at merge time either; re-runninggit rev-parse --show-toplevelhere would fail again for exactly the same reason it failed at worktree-creation time.Guard also required for
worktree_branch(identical corrupted/partial-artifact threat model as theworktree_pathguard above): ifworktree_branchis null or empty despiteworktree_mode == "auto_created", treat it identically toworktree_mode != "auto_created"at the top of step 4 — skip the rest of step 4 entirely. Do NOT run thedirnamederivation below or any of 4b-4e (nogit status, nogit merge, nogit worktree remove, nogit branch -d). Append{"event":"worktree_branch_missing"}to the event log and proceed straight to doc-sync/memory-finalize, exactly like the ordinary no-worktree case. [EASY TO MISS: an empty{worktree_branch}substituted directly intogit merge {worktree_branch}in step 4e would not fail with a clear, recognizable error — it risks matching an unrelated ref or producing a confusing generic git error that obscures the real missing-artifact problem. Without this guard, the failure would surface deep inside 4e instead of being caught at the earliest possible point, the same way an unguardedworktree_pathwould silently corruptPROJECT_ROOTviadirname.]# worktree_path = "{project_root}/.claude/worktrees/{worktree_dir}" (set in step 2) -- # three levels down from PROJECT_ROOT (.claude/worktrees/{worktree_dir}), so its # great-grandparent directory is always PROJECT_ROOT, in both the single-repo and # multi-repo-resolved cases. PROJECT_ROOT=$(dirname "$(dirname "$(dirname "{worktree_path}")")") CRAFTFLOW_INSTALL=$(python3 -c " import json, pathlib reg = json.loads(pathlib.Path.home().joinpath('.claude/plugins/installed_plugins.json').read_text()) print(reg['plugins']['craftflow@craftflow'][0]['installPath']) ") CRAFTFLOW_INSTALL_EXIT=$? LOCK_DIR="$PROJECT_ROOT/.claude/worktrees/.merge.lock"(A non-zero
CRAFTFLOW_INSTALL_EXIThere is not handled as a separate branch — it surfaces downstream as an empty/unusable$CRAFTFLOW_INSTALLpath in whichever script it is used to invoke next, e.g. the lock-stalenessDECISION_EXITcapture and defaultcasearm in step 4b below, orCOPY_FALLBACK_EXITin step 4e.)b. Acquire the merge lock. The lock is a directory, not a plain file —
mkdiris atomic on POSIX filesystems, which a bare existence check is not. The staleness/contention decision is delegated to a real script file,craftflow_worktree_lock_staleness.py(installed alongsidecraftflow_workflow_id.py, invoked the same way) — never an inline heredoc:ATTEMPT=0 MAX_ATTEMPTS=9 # ~45s total wait at 5s per attempt LOCK_ACQUIRED=false while [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; do if mkdir "$LOCK_DIR" 2>/dev/null; then printf '{"workflow_uuid":"%s","worktree_path":"%s","acquired_at":"%s"}' \ "{workflow_uuid}" "{worktree_path}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ > "$LOCK_DIR/metadata.json" LOCK_ACQUIRED=true break fi METADATA_BEFORE=$(cat "$LOCK_DIR/metadata.json" 2>/dev/null) DECISION=$(python3 "$CRAFTFLOW_INSTALL/scripts/craftflow_worktree_lock_staleness.py" \ "$LOCK_DIR/metadata.json" "$PROJECT_ROOT" "{workflow_uuid}") DECISION_EXIT=$? case "$DECISION" in STALE_WORKTREE_GONE*|STALE_INACTIVE*|SELF_RECLAIM*) # TOCTOU guard: `decide()` above is a pure snapshot read with no # synchronization -- re-read the metadata NOW, immediately before # deleting, and compare it byte-for-byte against what was # captured immediately BEFORE `decide()` ran. Only delete if it # is unchanged. If it changed (e.g. the original holder released # and a third process won a fresh `mkdir` with its own live # metadata in the window between the two reads), do NOT delete -- # a fresh, live lock must never be destroyed based on a stale # decision about a DIFFERENT lock occupant. This does not need a # separate `decide()` re-run: an unchanged byte-for-byte # metadata.json between the two reads means nothing relevant to # the decision could have changed either. METADATA_AFTER=$(cat "$LOCK_DIR/metadata.json" 2>/dev/null) if [ -n "$METADATA_AFTER" ] && [ "$METADATA_BEFORE" = "$METADATA_AFTER" ]; then rm -rf "$LOCK_DIR" continue # reclaimed -- retry mkdir immediately, no sleep fi # Metadata changed underneath us -- fall through to the normal # wait/retry path below, exactly like ordinary contention. ;; *) # Unrecognized $DECISION -- covers ordinary CONTENDED*/LOCK_READ_ERROR*/ # GIT_WORKTREE_LIST_ERROR* outcomes (no reclaim action needed, just wait/retry) # AND a genuinely unknown/garbled value (e.g. empty output from a non-zero # DECISION_EXIT, or an unexpected outcome word this router doesn't recognize). # Both are treated identically to CONTENDED_UNKNOWN_HOLDER: fail closed, never # reclaim on an unrecognized state, fall through to the wait/retry path below. ;; esac ATTEMPT=$((ATTEMPT + 1)) sleep 5 done[EASY TO MISS: the
METADATA_BEFORE/METADATA_AFTERbyte-for-byte compare immediately beforerm -rf "$LOCK_DIR"closes a real TOCTOU window — without it, a stale-looking lock could legitimately be released and re-acquired by a third process between the staleness script's read and this loop'srm -rf, and the reclaiming process would delete that THIRD process's brand-new, live lock instead of the one it actually evaluated. This is the smallest correct fix: it reuses the same metadata content the staleness decision itself was based on rather than re-invokingdecide()a second time, and it still has one irreducible-but-negligible window (between the secondcatand therm -rf) that a full atomic compare-and-delete primitive would close — POSIX shell has no such primitive for directories, so this is the practical minimum-diff mitigation, not a claim of perfect atomicity.] [EASY TO MISS:craftflow_worktree_lock_staleness.pynever reclaims on unreadable/corrupt lock metadata (CONTENDED_UNKNOWN_HOLDER) or on a genuine filesystem/permission read error (LOCK_READ_ERROR, distinct from contention) — both fail closed, always waiting out the budget rather than risk stealing a live lock. It DOES reclaim immediately, skipping the age/inactivity window, when the lock's recordedworkflow_uuidmatches this workflow's own{workflow_uuid}AND the lock's recordedworktree_pathis positively confirmed gone (SELF_RECLAIM) — a workflow resuming after crashing while it itself held the lock must never wait out its own dead lock once that proof exists. If the sameworkflow_uuidresumes while its own worktree still exists (e.g. two concurrent processes for the same workflow, genuinely still mid-merge), it is NOT given this shortcut — it waits/gates exactly like a stranger's lock would, because aworkflow_uuidmatch alone is never treated as proof the prior holder is dead. Ametadata.jsonthat exists but fails to parse as a JSON object (corrupt/truncated, e.g. a crash mid-printf-write) is NOT a permanentCONTENDED_UNKNOWN_HOLDERdead-end either — it falls back to the lock directory's own mtime as a substituteacquired_atand can still reclaim via the age check, surfacing asSTALE_INACTIVE unknown(matches theSTALE_INACTIVE*reclaim pattern above). A failure of thegit worktree list --porcelainsubprocess call itself (e.g.gitnot on PATH) surfaces asGIT_WORKTREE_LIST_ERROR <exception_class_name>— likeLOCK_READ_ERROR, this deliberately does NOT match any reclaim pattern above and fails closed, waiting out the budget. This script is the single source of truth for this decision —SKILL.mdandscripts/craftflow_worktree_merge_guard_check.pyboth invoke the exact same file; there is no separate copy to keep in sync.]c. If the lock was never acquired (loop exhausted at
MAX_ATTEMPTSwhile still contended):- Persist
pending_gate: "worktree_merge_locked"on the workflow artifact, naming the last-known holder as follows, based on the final$DECISION's outcome word:CONTENDED <workflow_uuid>→ record thatworkflow_uuidas the holder.CONTENDED_UNKNOWN_HOLDER→ record"unknown"as the holder.LOCK_READ_ERROR <exception_class_name>→ record the exception class name itself (e.g."PermissionError") as the holder value — not"unknown"and not aworkflow_uuid— since this outcome is not evidence of any other workflow at all, and recording it as"unknown"would blur it back into ordinary contention.GIT_WORKTREE_LIST_ERROR <exception_class_name>→ same treatment asLOCK_READ_ERROR: record the exception class name itself as the holder value, never"unknown"— this outcome means the localgit worktree list --porcelaincall itself failed to run (e.g.gitnot on PATH), not that another workflow holds the lock.
- Do NOT touch the main tree, the worktree, or the branch.
- Stop before memory-finalize. If the final
$DECISIONstarted withLOCK_READ_ERRORorGIT_WORKTREE_LIST_ERROR, use a distinct message template that makes clear this is a local filesystem/permission/environment problem, not another workflow: "Failed to evaluate the merge lock due to a local error ({exception_class_name}) — this is NOT evidence of another live workflow. ForLOCK_READ_ERROR, check filesystem permissions on.claude/worktrees/.merge.lock; forGIT_WORKTREE_LIST_ERROR, confirmgitis onPATHand runnable. Then resume this workflow to retry." Otherwise (aCONTENDEDorCONTENDED_UNKNOWN_HOLDERoutcome) tell the user: "Another BUILD workflow ({holder_workflow_uuid_or_unknown}) currently holds the merge lock. Wait for it to finish, then resume this workflow to retry. If that workflow is actually dead (not just slow), you can manually delete.claude/worktrees/.merge.lockand resume — only after confirming it isn't still running." - Resuming this workflow re-enters this step from 4a.
d. Clean-tree check (only reached once the lock is held):
DIRTY_STATUS=$(git -C "$PROJECT_ROOT" status --porcelain 2>&1) DIRTY_EXIT=$?- If
DIRTY_EXIT != 0(thegit statuscommand itself failed) ORDIRTY_STATUSis non-empty (uncommitted changes exist):- Release the lock:
rm -rf "$LOCK_DIR"; RELEASE_LOCK_EXIT=$?(a non-zero exit here is self-detecting via the next workflow's contention path in step 4b — not treated as fatal here) - Persist
pending_gate: "worktree_dirty_main_tree"(include$DIRTY_STATUSin the event log for visibility). - Do NOT run
git merge, do NOT copy any files from the worktree, do NOT remove the worktree or branch. - Stop before memory-finalize. Tell the user: "The main tree has uncommitted changes this BUILD did not make (likely a concurrent DEBUG/PLAN/REVIEW session, or an unrelated manual edit). Commit, stash, or otherwise resolve those changes, then resume this workflow to retry the merge."
- Resuming this workflow re-enters this step from 4a.
- Release the lock:
- If clean (empty output, exit 0): proceed to 4e.
e. Finalize (destination behavior unchanged from before this fix, now guarded — and now covers the copy-fallback path explicitly and executably for the first time):
- Attempt (capture both output and exit code, mirroring the
TOPLEVEL_EXIT/RESOLVE_EXIT/DIRTY_EXITpattern used earlier in this section — never leavegit merge's own exit code unchecked):
(fromMERGE_OUTPUT=$(git merge {worktree_branch} 2>&1) MERGE_EXIT=$?$PROJECT_ROOT; readworktree_branchfrom the workflow artifact.) - If
MERGE_EXIT == 0AND$MERGE_OUTPUTcontains"Already up to date": this means the worktree's builder edited files but never committed them on{worktree_branch}— a real, frequently-observed gotcha in this repo, not a sign there's nothing to merge. Recover the actual changes directly from the worktree's own uncommitted state via the real copy-fallback script, never via free-form manual copying — capture both its output and its own exit code, mirroring theMERGE_EXITpattern immediately above (never leave this script's exit code unchecked either):
This script parsesCOPY_FALLBACK_OUTPUT=$(python3 "$CRAFTFLOW_INSTALL/scripts/craftflow_worktree_copy_fallback.py" \ "{worktree_path}" "$PROJECT_ROOT" 2>&1) COPY_FALLBACK_EXIT=$?git status --porcelain=1 -zin{worktree_path}(NUL-delimited, unquoted paths — chosen to correctly handle renamed/copied entries and paths containing spaces) and applies Added/Modified/untracked entries as copies, Deleted entries as removals, and Renamed/Copied entries as an old-path removal plus new-path copy. It does notgit addor commit — the changes land in the main tree exactly as uncommitted changes, matching this router's existing practice. [EASY TO MISS: this fallback only ever runs after the clean-tree check in 4d has already passed for$PROJECT_ROOT— it never bypasses that check, because it is reached only from this already-guarded branch.] - If
COPY_FALLBACK_EXIT != 0(the copy-fallback script itself exits non-zero): the script's own documented guarantee is partial-apply-but-diagnosable, not atomic — earlier tokens in the same run that already applied successfully remain applied in the main tree even if a later token fails, so a non-zero exit does NOT mean nothing landed.- Release the lock:
rm -rf "$LOCK_DIR"; RELEASE_LOCK_EXIT=$?(a non-zero exit here is self-detecting via the next workflow's contention path in step 4b — not treated as fatal here) - Persist
pending_gate: "worktree_copy_fallback_failed"(include$COPY_FALLBACK_OUTPUTin the event log for visibility). - Do NOT remove the worktree, do NOT delete the branch — the worktree still holds the uncommitted source of truth for whatever did not land.
- Stop before memory-finalize. Tell the user: "The copy-fallback script failed while
applying the worktree's uncommitted changes to the main tree:
{copy_fallback_output}. This may be a partial apply — earlier files in this run may have already landed. Rungit status --porcelainin the main tree to see exactly what applied before resuming. Resolve the underlying issue (e.g. a filesystem/permission problem, or a genuine conflict/rename edge case the script refused to guess on), then resume this workflow to retry." - Resuming this workflow re-enters this step from 4a.
- Release the lock:
- If
COPY_FALLBACK_EXIT == 0: the fallback applied cleanly. Proceed to the final cleanup below exactly as a successfulgit mergewould. - If
MERGE_EXIT == 0AND$MERGE_OUTPUTdoes not contain"Already up to date"(a real merge succeeded with actual content merged, no conflicts): nothing further needed here. - If
MERGE_EXIT != 0AND$MERGE_OUTPUTcontains a conflict marker ("CONFLICT") (existing, unchanged outcome, now gated onMERGE_EXIT+ output text rather than assumed by exclusion):- Release the lock:
rm -rf "$LOCK_DIR"; RELEASE_LOCK_EXIT=$?(a non-zero exit here is self-detecting via the next workflow's contention path in step 4b — not treated as fatal here) - Persist
pending_gate: "worktree_merge_conflict", ask user to resolve before memory-finalize. - Do NOT remove the worktree or delete the branch while conflicts are unresolved.
- Resuming this workflow re-enters this step from 4a once the user has resolved the conflict.
- Release the lock:
- If
MERGE_EXIT != 0AND$MERGE_OUTPUTmatches neither"Already up to date"nor a conflict marker (an unrecognized merge failure — e.g. an invalid ref, an unrelated-histories error, or an uncommitted-changes-would-be-overwritten error that slipped past the 4d clean-tree check): this is the explicit 4th branch closing the by-exclusion gap — nothing was actually merged, so nothing may be treated as if it had merged.- Release the lock:
rm -rf "$LOCK_DIR"; RELEASE_LOCK_EXIT=$?(a non-zero exit here is self-detecting via the next workflow's contention path in step 4b — not treated as fatal here) - Persist
pending_gate: "worktree_merge_unrecognized_failure"(include$MERGE_OUTPUTin the event log for visibility). - Do NOT remove the worktree, do NOT delete the branch, do NOT run the copy-fallback script — nothing has been confirmed merged, so the worktree's committed/uncommitted content remains the only source of truth.
- Stop before memory-finalize. Tell the user: "
git merge {worktree_branch}failed with an error this router does not recognize as either 'already up to date' or a merge conflict:{merge_output}. Nothing has been merged, and the worktree/branch have not been touched. Inspect the error above, resolve the underlying issue in$PROJECT_ROOT, then resume this workflow to retry." - Resuming this workflow re-enters this step from 4a.
- Release the lock:
- On successful merge or successful copy-fallback, clean up — capture the exit code of
each command, mirroring the
MERGE_EXIT/COPY_FALLBACK_EXITpattern above (never leave either cleanup command's exit code unchecked):WORKTREE_REMOVE_OUTPUT=$(git worktree remove {worktree_path} --force 2>&1) WORKTREE_REMOVE_EXIT=$?- If
WORKTREE_REMOVE_EXIT != 0(e.g. the worktree is busy/locked — a process still has an open handle inside it): do NOT rungit branch -d, do NOT setworktree_mode → "merged_and_removed".- Release the lock:
rm -rf "$LOCK_DIR"; RELEASE_LOCK_EXIT=$?(a non-zero exit here is self-detecting via the next workflow's contention path in step 4b — not treated as fatal here) - Persist
pending_gate: "worktree_cleanup_failed"(include$WORKTREE_REMOVE_OUTPUTand the failing command,git worktree remove, in the event log for visibility). - Stop before memory-finalize. Tell the user: "The merge/copy-fallback succeeded, but
git worktree remove {worktree_path} --forcefailed:{worktree_remove_output}. The worktree and its branch are still present and untouched. Resolve the underlying issue (e.g. a process still holding a handle inside the worktree), then resume this workflow to retry cleanup." - Resuming this workflow re-enters this step from 4a.
- Release the lock:
- If
WORKTREE_REMOVE_EXIT == 0, proceed to delete the branch:BRANCH_DELETE_OUTPUT=$(git branch -d {worktree_branch} 2>&1) BRANCH_DELETE_EXIT=$?- If
BRANCH_DELETE_EXIT != 0(e.g.git branch -drefuses because the branch is not fully merged into the current branch — a real correctness signal, not a cosmetic failure): do NOT setworktree_mode → "merged_and_removed".- Release the lock:
rm -rf "$LOCK_DIR"; RELEASE_LOCK_EXIT=$?(a non-zero exit here is self-detecting via the next workflow's contention path in step 4b — not treated as fatal here) - Persist
pending_gate: "worktree_cleanup_failed"(include$BRANCH_DELETE_OUTPUTand the failing command,git branch -d, in the event log for visibility). - Stop before memory-finalize. Tell the user: "The merge/copy-fallback succeeded and
the worktree was removed, but
git branch -d {worktree_branch}failed:{branch_delete_output}. This can mean the branch is not fully merged — a real correctness signal, not a cosmetic failure. Inspectgit log {worktree_branch}in$PROJECT_ROOT, resolve the discrepancy (or delete the branch manually withgit branch -Donce you've confirmed nothing is lost), then resume this workflow to retry cleanup." - Resuming this workflow re-enters this step from 4a.
- Release the lock:
- If
BRANCH_DELETE_EXIT == 0: both cleanup commands succeeded.- Update artifact:
worktree_mode → "merged_and_removed" - Release the lock:
rm -rf "$LOCK_DIR"; RELEASE_LOCK_EXIT=$?(a non-zero exit here is self-detecting via the next workflow's contention path in step 4b — not treated as fatal here) - Continue to doc-sync/memory-finalize as today.
- Update artifact:
- If
- If
- Persist
Safety: Worktree creates are idempotent in the event log. If a resume finds worktree_mode: "auto_created" already set, re-use worktree_path from the artifact rather than creating a new worktree. If worktree_path is null despite worktree_mode: "auto_created", treat as fallback and proceed with main tree. If a resume finds pending_gate set to worktree_merge_locked, worktree_dirty_main_tree, worktree_merge_conflict, worktree_merge_unrecognized_failure, worktree_copy_fallback_failed, or worktree_cleanup_failed, re-enter this step from 4a — none of these gates require any bespoke resume branch beyond the generic resume algorithm in ## 4. Resume And Hydration.
DEBUG preparation
- Before any DEBUG-specific readiness decision or child-task creation, immediately read
references/debug-workflow.md. - Use the
### DEBUG preparationand### DEBUG task graphblocks in that file as the canonical DEBUG law.
REVIEW preparation
- Before any REVIEW-specific readiness decision or child-task creation, immediately read
references/review-workflow.md. - Use the
### REVIEW preparationand### REVIEW task graphblocks in that file as the canonical REVIEW law.
PLAN preparation
- Before any PLAN-specific readiness decision or child-task creation, immediately read
references/plan-workflow.md. - Use the
### PLAN preparationand### PLAN task graphblocks in that file as the canonical PLAN law. - If planner clarification, review-loop findings, or plan remediation rules trigger later in the workflow, also read
references/remediation-and-research.mdbefore continuing.
6. Workflow Task Graphs
Parent workflow creation
Use this pattern for every new workflow:
- Generate a stable workflow UUID, worktree names, and
iso_timestampbeforeTaskCreate()by running the minting helper:
# Locate the helper via the plugin registry
CRAFTFLOW_INSTALL=$(python3 -c "
import json, pathlib
reg = json.loads(pathlib.Path.home().joinpath('.claude/plugins/installed_plugins.json').read_text())
print(reg['plugins']['craftflow@craftflow'][0]['installPath'])
")
# Mint the id — pass the user request; the helper auto-detects the current git branch
WF_INFO=$(python3 "${CRAFTFLOW_INSTALL}/scripts/craftflow_workflow_id.py" \
--request "USER_REQUEST_SHELL_ESCAPED" \
--project "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" \
--json)
Replace USER_REQUEST_SHELL_ESCAPED with the actual user request, properly shell-quoted.
Then parse the JSON to bind: workflow_uuid · iso_timestamp · worktree_dir · worktree_branch.
workflow_uuid=$(printf '%s' "$WF_INFO" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['workflow_uuid'])")
iso_timestamp=$(printf '%s' "$WF_INFO" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['iso_timestamp'])")
worktree_dir=$(printf '%s' "$WF_INFO" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['worktree_dir'])")
worktree_branch=$(printf '%s' "$WF_INFO" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['worktree_branch'])")
ID format: wf-{slug}-{YYYYMMDD-HHMMSS}-{8hex}.
Slug = slugified git branch name (if a genuine feature branch, i.e. not main/master/develop/dev/trunk or a craftflow-generated wf-/worktree- branch) — otherwise slugified request text.
The iso_timestamp from the helper is the authoritative creation timestamp — use it for all {iso_timestamp} placeholders in the artifact Write below (no separate time derivation needed).
Task-tool fallback:* when capabilities.task_tools_available == false, skip step 2
(TaskCreate()) entirely — there is no parent orchestration task to create. Proceed directly to
step 3 (the artifact Write() calls). When capabilities.task_tools_available == true, step 2
runs exactly as documented today — no behavior change on that path.
- Create the parent workflow task with that UUID from the first write:
TaskCreate({
subject: "CRAFTFLOW {WORKFLOW}: {summary}",
description: "wf:{workflow_uuid}\nkind:workflow\norigin:router\nphase:{build|debug|review|plan}\nplan:{plan_file or 'N/A'}\nscope:N/A\nreason:User request\n\nUser request: {request}\nChain: {chain description}",
activeForm: "{workflow active form}"
})
- Immediately write the v10 artifact and event log:
Write(
file_path="$PROJECT_ROOT/.craftflow/state/workflows/{workflow_uuid}.json",
content="{\"workflow_uuid\":\"{workflow_uuid}\",\"workflow_id\":\"{workflow_uuid}\",\"workflow_type\":\"{WORKFLOW}\",\"state_root\":\".craftflow/state\",\"user_request\":\"{request}\",\"plan_file\":null,\"design_file\":null,\"research_files\":[],\"approved_decisions\":[],\"plan_mode\":null,\"verification_rigor\":\"standard\",\"proof_status\":\"gaps_found\",\"plan_file_stem\":null,\"bakeoff_n\":null,\"bakeoff_n_requested\":null,\"bakeoff_models\":[],\"bakeoff_triggered\":false,\"bakeoff_all_failed\":false,\"bakeoff_candidate_failures\":[],\"traceability\":{\"requirements\":[],\"phases\":[],\"verification\":[],\"remediation\":[]},\"intent\":{\"goal\":null,\"non_goals\":[],\"constraints\":[],\"acceptance_criteria\":[],\"open_decisions\":[]},\"normalized_phases\":[],\"phase_cursor\":null,\"capabilities\":{\"brightdata_available\":\"unknown\",\"octocode_available\":\"unknown\",\"websearch_available\":\"unknown\",\"webfetch_available\":\"unknown\",\"task_tools_available\":\"unknown\"},\"research_rounds\":[],\"research_backend_history\":[],\"research_quality\":{\"web\":\"none\",\"github\":\"none\",\"overall\":\"none\"},\"task_ids\":{\"planner_create\":null,\"planning_review_pass1\":null,\"planner_replan\":null,\"planning_review_pass2\":null,\"memory_finalize\":null,\"plan_bakeoff_candidates\":{},\"plan_bakeoff_judge\":null},\"phase_status\":{},\"results\":{\"builder\":null,\"investigator\":null,\"reviewer\":null,\"hunter\":null,\"verifier\":null,\"planner\":null,\"planning_reviewer\":null,\"research\":{\"web\":null,\"github\":null,\"synthesis\":null},\"bakeoff\":[],\"plan_bakeoff_judge\":null},\"evidence\":{\"builder\":[],\"investigator\":[],\"reviewer\":[],\"hunter\":[],\"verifier\":[],\"planning_reviewer\":[]},\"telemetry\":{\"task_metrics_available\":\"unknown\",\"workflow_wall_clock_seconds\":0,\"agent_wall_clock_seconds\":{\"builder\":0,\"investigator\":0,\"reviewer\":0,\"hunter\":0,\"verifier\":0,\"planner\":0},\"loop_counts\":{\"re_review\":0,\"re_hunt\":0,\"re_verify\":0},\"verifier\":{\"phase_exit_proof_runs\":0,\"extended_audit_runs\":0,\"workload_seconds\":{\"tests\":0,\"build\":0,\"scan\":0,\"reconcile\":0,\"reasoning\":0}}},\"quality\":{\"confidence\":null,\"evidence_complete\":false,\"scenario_coverage\":0,\"research_quality\":\"none\",\"convergence_state\":\"pending\"},\"planning_review_runs\":0,\"planning_review_findings\":[],\"planning_review_status\":\"not_started\",\"build_mode\":null,\"fast_path_risk_signals\":[],\"fast_path_escalated\":false,\"worktree_mode\":null,\"worktree_path\":null,\"worktree_branch\":null,\"workspace_writable_paths\":[],\"memory_notes\":[],\"pending_gate\":null,\"status_history\":[{\"event\":\"workflow_started\",\"ts\":\"{iso_timestamp}\",\"phase\":\"{build|debug|review|plan}\"}],\"remediation_history\":[],\"created_at\":\"{iso_timestamp}\",\"updated_at\":\"{iso_timestamp}\"}"
)
Write(
file_path="$PROJECT_ROOT/.craftflow/state/workflows/{workflow_uuid}.events.jsonl",
content="{\"ts\":\"{iso_timestamp}\",\"wf\":\"{workflow_uuid}\",\"event\":\"workflow_started\",\"host\":\"claude-code\",\"phase\":\"{build|debug|review|plan}\",\"task_id\":\"{parent_task_id}\",\"agent\":\"router\",\"decision\":\"start\",\"reason\":\"User request\"}\n"
)
Conditional — only if ## 0. recorded a project_root_resolution_fallback reason for this
session (i.e. TOPLEVEL_EXIT != 0 and the outcome was NO_REPO_FOUND or
RESOLVE_SCRIPT_ERROR): append a second status_history entry and a second events.jsonl line
alongside workflow_started, using the same {workflow_uuid}/{iso_timestamp} values as step
3 above — {"event":"project_root_resolution_fallback","ts":"{iso_timestamp}","reason":"NO_REPO_FOUND"|"RESOLVE_SCRIPT_ERROR"}.
If ## 0. did not fall back (the common, single-repo case), skip this — there is nothing to
append.
Conditional — only if ## 0. step 1a set WORKSPACE_WRITABLE_PATHS_JSON to something other
than the empty-array default (i.e. TOPLEVEL_EXIT != 0 in ## 0. AND that variable is set and
!= '[]'): substitute that JSON array value in place of the workspace_writable_paths:[]
default in the artifact Write above, instead of leaving it as []. If ## 0. never ran step 1a
(the common single-repo path), or step 1a ran but the array is empty, leave the default [] in
place — no substitution needed.
Additionally, if WORKSPACE_WRITABLE_PATHS_DROPPED_JSON from ## 0. is non-empty, append a
second status_history entry and a second events.jsonl line alongside workflow_started (same
mechanics as the project_root_resolution_fallback conditional above) —
{"event":"workspace_writable_paths_entries_dropped","ts":"{iso_timestamp}","dropped":{WORKSPACE_WRITABLE_PATHS_DROPPED_JSON}}.
- Immediately after artifact creation, initialize the per-workflow state directory:
Bash("mkdir -p \"$PROJECT_ROOT/.craftflow/state/workflows/{workflow_uuid}\"")
This directory is where the memory-finalize task will write workflow-scoped memory (activeContext.md, patterns.md, progress.md for this workflow only).
Only create child tasks after the v10 artifact and state directory exist.
BUILD task graph
- See
references/build-workflow.mdand apply its### BUILD task graphblock verbatim before creating BUILD child tasks.
DEBUG task graph
- See
references/debug-workflow.mdand apply its### DEBUG task graphblock verbatim before creating DEBUG child tasks.
REVIEW task graph
- See
references/review-workflow.mdand apply its### REVIEW task graphblock verbatim before creating REVIEW child tasks.
PLAN task graph
- See
references/plan-workflow.mdand apply its### PLAN task graphblock verbatim before creating PLAN child tasks.
Research tasks
- When a workflow explicitly triggers research task creation, immediately read
references/remediation-and-research.md. - Use the
## 10. Research Orchestration,## Research Quality, and## Research Filesblocks there before creating or consuming research tasks.
Marker rules
- BUILD writes
[BUILD-START: wf:{workflow_uuid}] - DEBUG writes
[DEBUG-RESET: wf:{workflow_uuid}] - PLAN writes
[PLAN-START: wf:{workflow_uuid}]
Task*-tool fallback for per-type task graphs and remediation/research task sites
Wherever any TaskCreate() call site in references/build-workflow.md,
references/debug-workflow.md, references/review-workflow.md, references/plan-workflow.md,
or references/remediation-and-research.md would create a child task, and
capabilities.task_tools_available == false: skip that TaskCreate() call and instead append a
{phase} entry with status: "pending" directly into the workflow artifact's phase_status map
(and into normalized_phases, where the referenced block also populates that field) instead of
calling TaskCreate(). This is the same fallback mechanism as the other capabilities.task_tools_available == false branches in this document (skip
TaskCreate(), append the equivalent tracking entry to the workflow artifact instead), applied to
every TaskCreate() site in these 5 files — the rule is scoped to "any TaskCreate() call site
in these 5 files," never gated on matching a specific heading name or pattern, so it is not
invalidated by any file reorganizing its own subsections later. blockedBy relationships
declared at any of these sites become ordering constraints enforced by phase_cursor advancement
in ## 12. Chain Execution Loop's fallback rather than by task-graph blocker fields. Skip every
paired TaskUpdate({ taskId: <var bound by a skipped TaskCreate()>, addBlockedBy: [...] }) call
in the same block too — do not attempt these; the ordering they would declare is captured
structurally by insertion order in phase_status/normalized_phases instead. When
capabilities.task_tools_available == true, every one of these sites runs exactly
as written today — no behavior change on that path.
7. Dispatcher And Agent Prompt Contract
Explicit dispatcher
Shared with Cursor — canonical text lives in
tools/craftflow-plugin/plugins/craftflow/skills/_shared/router-protocol.md §
"Explicit Dispatcher (Phase-to-Agent Table)" (Phase 3d of the hooks-as-bridge redesign,
backlog item 8). Read() that file now if you have not already this session; it has the
full phase→agent mapping. Claude Code resolves each row via Task()/TaskCreate()
against a registered subagent type usin
Truncated - read the full file at https://github.com/aicraft-sdk/craftflow/blob/c61c01714e7a24d61aff8fc7e30d97b757ef81ae/plugins/craftflow/skills/craftflow-router/SKILL.md.