@deksden-com/dd-flow-cli 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 47cd270: Add SPC-006 bootstrap stage start/context packets and sealed stage finish, while repairing Memory Bank upgrade stage routing, Codex hook event identity, PATH-stable per-home hook installation, canonical Codex session registration, session coverage projections, terminal orchestrator closure, and safe reconciliation of runs whose legacy authority and workspace are gone.
8
+
3
9
  ## 0.5.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -75,10 +75,10 @@ dd-flow protocol register HANDSHAKE-001 --project-root "$PWD" --json
75
75
  dd-flow protocol status PRT-HANDSHAKE-001 --project-root "$PWD" --json
76
76
  ```
77
77
 
78
- Attach and update a plan:
78
+ Inspect the canonical plan and update its SQLite progress projection:
79
79
 
80
80
  ```bash
81
- dd-flow plan set PRT-HANDSHAKE-001 --project-root "$PWD" --file plan.json --json
81
+ dd-flow plan status PRT-HANDSHAKE-001 --project-root "$PWD" --json
82
82
  dd-flow plan item start PRT-HANDSHAKE-001 P1 --project-root "$PWD" --json
83
83
  dd-flow plan item done PRT-HANDSHAKE-001 P1 --project-root "$PWD" --summary "Implemented" --evidence "pnpm test" --json
84
84
  ```
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.5.0",
4
- "cli_commit": "64b75acbb2e62c9e80eb29c501f5f3481f86cf20",
5
- "built_at": "2026-08-11T10:20:58.455Z",
3
+ "cli_version": "0.6.0",
4
+ "cli_commit": "22cf9c8f5293168dae76212c9a4b74b90a1efeab",
5
+ "built_at": "2026-08-12T05:09:56.163Z",
6
6
  "built_with_canon": {
7
- "version": "3.0.0",
8
- "commit": "1e8c39078ed7a34adde93315a9cdcf2bbfc15374",
7
+ "version": "3.1.0",
8
+ "commit": "c201167b896a039ff4881d6d816b9735fba2f799",
9
9
  "flow_contract": "dd-flow-canonical-2026-08",
10
10
  "repo_root": "/Users/deksden/Documents/_Projects/dd-memorybank",
11
11
  "memorybank_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank",
package/dist/cli/help.js CHANGED
@@ -143,7 +143,7 @@ Usage:
143
143
  dd-flow id next --type protocol|run --project-root <root> --slug <slug> --json
144
144
  dd-flow run status <RUN-ID|RUN-short-id> --project-root <root> --json
145
145
  dd-flow run list --project-root <root> --json
146
- dd-flow run timeline <RUN-ID|RUN-short-id> --project-root <root> [--hide stages|events|sessions|usage|artifacts] --json
146
+ dd-flow run timeline <RUN-ID|RUN-short-id> --project-root <root> --json
147
147
  dd-flow run usage <RUN-ID|RUN-short-id> --project-root <root> [--group-by session|role|stage|aspect|plan-item] --json
148
148
  dd-flow run flags status <RUN-ID|RUN-short-id> --project-root <root> --json
149
149
  dd-flow run flags revise <RUN-ID|RUN-short-id> --project-root <root> --expected-revision <n> --idempotency-key <key> [--preset <name>] [--flag key=value] [--flag key=value] [--allow-downgrade --reason <text>] --json
@@ -165,9 +165,10 @@ Examples:
165
165
 
166
166
  Usage:
167
167
  dd-flow stage start <RUN-ID|RUN-short-id> --project-root <root> --stage <name> [--dir <NN-stage-slug>] --json
168
- dd-flow stage finish <RUN-ID|RUN-short-id> --project-root <root> --stage <name> [--status done|blocked|failed] [--semantic-file <RUN-local-json>] --json
168
+ dd-flow stage start --bootstrap --project-root <root> --stage specify --subject <safe-subject> [--intake-file <path>] --json
169
+ dd-flow stage finish <RUN-ID|RUN-short-id> --project-root <root> --stage <name> [--outcome done|blocked|failed] [--semantic-file <RUN-local-json>] --json
169
170
 
170
- stage start attaches the stage, archives an earlier attempt when needed and writes one RUN-local stage-prompt.md. stage finish derives a bounded report and always generates validated JSON, Markdown, template-backed HTML and a durable protocol-summary projection. Raw intake and telemetry remain RUN-local.`
171
+ stage start is the worker's first practical flow command. It performs deterministic setup and returns a ready-to-use, stage-specific work packet with trusted runtime facts, compatibility and permission preflight, aliases, write boundary and exact finish command. The same packet is written to RUN-local stage-prompt.md and stage-prompt.json. --bootstrap creates the initial protocol/RUN envelope and copies the supplied intake into the RUN home before starting specify. stage finish defaults to @stage/stage-input.json, accepts semantic --outcome and always generates validated JSON, Markdown, template-backed HTML and a durable protocol-summary projection. Raw intake and telemetry remain RUN-local.`
171
172
  ],
172
173
  [
173
174
  "id",
@@ -541,23 +541,28 @@ async function dispatch(args, context, io, scopeProjectRoot = null) {
541
541
  }
542
542
  if (family === "stage" && command === "start") {
543
543
  const dir = optionalOption(parsed, "dir");
544
+ const bootstrap = hasOption(parsed, "bootstrap");
545
+ const subject = optionalOption(parsed, "subject");
546
+ const intakeFile = optionalOption(parsed, "intake-file");
547
+ const sessionId = optionalOption(parsed, "session-id");
544
548
  return startStage(context, {
545
549
  projectRoot: requiredOption(parsed, "project-root"),
546
- runId: requiredPosition(parsed, 0, "run-id"),
550
+ ...(bootstrap ? { bootstrap: { subject: subject ?? requiredOption(parsed, "subject"), ...(intakeFile ? { intakeFile } : {}) } } : { runId: requiredPosition(parsed, 0, "run-id") }),
547
551
  stage: requiredOption(parsed, "stage"),
552
+ ...(sessionId ? { sessionId } : {}),
548
553
  ...(dir ? { dir } : {})
549
554
  });
550
555
  }
551
556
  if (family === "stage" && command === "finish") {
552
557
  const dir = optionalOption(parsed, "dir");
553
- const status = optionalOption(parsed, "status");
558
+ const outcome = optionalOption(parsed, "outcome") ?? optionalOption(parsed, "status");
554
559
  const semanticFile = optionalOption(parsed, "semantic-file") ?? optionalOption(parsed, "data");
555
560
  return finishStage(context, {
556
561
  projectRoot: requiredOption(parsed, "project-root"),
557
562
  runId: requiredPosition(parsed, 0, "run-id"),
558
563
  stage: requiredOption(parsed, "stage"),
559
564
  ...(dir ? { dir } : {}),
560
- ...(status ? { status } : {}),
565
+ ...(outcome ? { outcome } : {}),
561
566
  ...(semanticFile ? { semanticFile } : {})
562
567
  });
563
568
  }
@@ -1327,11 +1332,15 @@ function dispatchCodexHome(context, parsed) {
1327
1332
  function dispatchCodexHook(context, parsed, stdin) {
1328
1333
  const action = requiredPosition(parsed, 0, "codex hook action");
1329
1334
  if (action === "handle") {
1330
- return handleCodexHook(context, {
1335
+ const result = handleCodexHook(context, {
1331
1336
  event: requiredOption(parsed, "event"),
1332
1337
  projectRoot: optionalOption(parsed, "project-root"),
1333
1338
  stdin
1334
1339
  });
1340
+ if (result && typeof result === "object" && !Array.isArray(result) && "hookSpecificOutput" in result) {
1341
+ return { hookSpecificOutput: result.hookSpecificOutput };
1342
+ }
1343
+ return {};
1335
1344
  }
1336
1345
  throw new AppError("usage", `Unknown codex hook action: ${action}`, 2);
1337
1346
  }
@@ -332,7 +332,6 @@ function flagStrength(key, value, definition) {
332
332
  "subagents.route": ["self_check", "grouped_subagent", "focused_subagent", "mixed"],
333
333
  "subagents.grouping": ["off", "allowlisted"],
334
334
  "subagents.pool_fallback": ["one", "runtime"],
335
- "observability.detail": ["compact", "normal", "full"],
336
335
  "knowledge.extract": ["skip", "conditional", "required"],
337
336
  "knowledge.promote": ["skip", "conditional", "required"],
338
337
  "workspace.bootstrap.mode": ["not_required", "revalidate", "required"],
@@ -362,10 +361,10 @@ function numberValue(value) {
362
361
  export function normalizeFlowContract(value) {
363
362
  const version = requireNumber(value.version, "/version");
364
363
  const id = requireString(value.id, "/id");
365
- if (version === 4 || version === 5) {
364
+ if (version === 4 || version === 5 || version === 6) {
366
365
  return normalizeCanonicalRuntimeContract(value, id, version);
367
366
  }
368
- if (version > 5)
367
+ if (version > 6)
369
368
  fail("/version", `unsupported flow-contract version: ${version}`);
370
369
  if (version === 3 && !/^dd-flow-canonical-[0-9]{4}-[0-9]{2}$/.test(id)) {
371
370
  fail("/id", "must identify a canonical version 3 flow contract");
@@ -462,7 +461,8 @@ function normalizeCanonicalRuntimeContract(value, id, version) {
462
461
  "flow_run_snapshot.v1",
463
462
  "session_coverage_units.v1",
464
463
  "privacy_safe_observability.v1",
465
- ...(version === 5 ? ["canonical_protocol_plan.v1"] : [])
464
+ ...(version >= 5 ? ["canonical_protocol_plan.v1"] : []),
465
+ ...(version >= 6 ? ["stage_bootstrap_context_packet.v1"] : [])
466
466
  ],
467
467
  stages,
468
468
  transitions,
@@ -0,0 +1,88 @@
1
+ export function reconcileSessionCoverageRows(sessions) {
2
+ const sessionCounts = countValues(sessions.map((session) => session.session_id));
3
+ const duplicateSessionIds = duplicates(sessionCounts);
4
+ const invalidSessionIds = [];
5
+ const parsed = sessions.map((session) => {
6
+ try {
7
+ return { session, units: normalizeCoverageUnits(JSON.parse(session.coverage_units_json || "[]")) };
8
+ }
9
+ catch {
10
+ invalidSessionIds.push(session.session_id);
11
+ return { session, units: [] };
12
+ }
13
+ });
14
+ const orchestrators = parsed.filter(({ session }) => session.session_kind === "orchestrator");
15
+ const workers = parsed.filter(({ session }) => session.session_kind !== "orchestrator");
16
+ const expectedCounts = countValues(orchestrators.flatMap(({ units }) => units.map((unit) => unit.unit_id)));
17
+ const expectedUnitIds = [...expectedCounts.keys()].sort();
18
+ const duplicateExpectedUnitIds = duplicates(expectedCounts);
19
+ const observedOwners = new Map();
20
+ for (const { session, units } of workers) {
21
+ for (const unit of units) {
22
+ const owners = observedOwners.get(unit.unit_id) ?? [];
23
+ owners.push(session.session_id);
24
+ observedOwners.set(unit.unit_id, owners);
25
+ }
26
+ }
27
+ const observedUnitIds = [...observedOwners.keys()].sort();
28
+ const missingUnitIds = expectedUnitIds.filter((unitId) => !observedOwners.has(unitId));
29
+ const duplicateUnitIds = [...observedOwners.entries()]
30
+ .filter(([, owners]) => new Set(owners).size > 1)
31
+ .map(([id]) => id)
32
+ .sort();
33
+ const diagnostics = [];
34
+ if (orchestrators.length === 0)
35
+ diagnostics.push("orchestrator_session_missing");
36
+ if (orchestrators.length > 1)
37
+ diagnostics.push("multiple_orchestrator_sessions");
38
+ if (workers.length > 0 && expectedUnitIds.length === 0)
39
+ diagnostics.push("expected_worker_units_missing");
40
+ if (missingUnitIds.length > 0)
41
+ diagnostics.push("expected_worker_units_unobserved");
42
+ if (duplicateUnitIds.length > 0 || duplicateExpectedUnitIds.length > 0)
43
+ diagnostics.push("duplicate_coverage_binding");
44
+ if (duplicateSessionIds.length > 0)
45
+ diagnostics.push("duplicate_session_binding");
46
+ if (invalidSessionIds.length > 0)
47
+ diagnostics.push("invalid_coverage_units");
48
+ const unavailable = orchestrators.length === 0 || invalidSessionIds.length > 0;
49
+ const partial = orchestrators.length !== 1
50
+ || (workers.length > 0 && expectedUnitIds.length === 0)
51
+ || missingUnitIds.length > 0
52
+ || duplicateUnitIds.length > 0
53
+ || duplicateExpectedUnitIds.length > 0
54
+ || duplicateSessionIds.length > 0;
55
+ return {
56
+ status: unavailable ? "unavailable" : partial ? "partial" : "complete",
57
+ expected_unit_ids: expectedUnitIds,
58
+ observed_unit_ids: observedUnitIds,
59
+ missing_unit_ids: missingUnitIds,
60
+ duplicate_unit_ids: duplicateUnitIds,
61
+ duplicate_expected_unit_ids: duplicateExpectedUnitIds,
62
+ duplicate_session_ids: duplicateSessionIds,
63
+ orchestrator_session_ids: orchestrators.map(({ session }) => session.session_id).sort(),
64
+ invalid_session_ids: [...new Set(invalidSessionIds)].sort(),
65
+ diagnostics
66
+ };
67
+ }
68
+ function normalizeCoverageUnits(value) {
69
+ if (!Array.isArray(value))
70
+ throw new Error("coverage_units must be an array");
71
+ return value.map((item) => {
72
+ if (!item || typeof item !== "object" || Array.isArray(item))
73
+ throw new Error("coverage unit must be an object");
74
+ const unitId = item.unit_id;
75
+ if (typeof unitId !== "string" || unitId.length === 0)
76
+ throw new Error("coverage unit requires unit_id");
77
+ return { unit_id: unitId };
78
+ });
79
+ }
80
+ function countValues(values) {
81
+ const counts = new Map();
82
+ for (const value of values)
83
+ counts.set(value, (counts.get(value) ?? 0) + 1);
84
+ return counts;
85
+ }
86
+ function duplicates(counts) {
87
+ return [...counts.entries()].filter(([, count]) => count > 1).map(([id]) => id).sort();
88
+ }
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
- "$id": "dd-flow/flow-contract@5",
3
+ "$id": "dd-flow/flow-contract@6",
4
4
  "title": "dd-flow canonical runtime contract",
5
- "description": "Structural contract for the SPC-004 v0.2 and SPC-005 runtime/PLAN cutover. Dynamic command and cross-repository checks belong to companion projects.",
5
+ "description": "Structural contract for the SPC-004/005/006 runtime, PLAN and deterministic stage-context cutover. Dynamic command and cross-repository checks belong to companion projects.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "required": [
@@ -25,21 +25,21 @@
25
25
  "policy"
26
26
  ],
27
27
  "properties": {
28
- "schema_id": {"const": "dd-flow/flow-contract@5"},
28
+ "schema_id": {"const": "dd-flow/flow-contract@6"},
29
29
  "id": {"const": "dd-flow-canonical-2026-08"},
30
30
  "contract_id": {"type": "string", "pattern": "^dd-flow-canonical-[0-9]{4}-[0-9]{2}$"},
31
- "version": {"const": 5},
31
+ "version": {"const": 6},
32
32
  "status": {"const": "active"},
33
33
  "source": {"const": ".memory-bank/dd-flow/common/runtime-contract.md"},
34
34
  "specifications": {
35
35
  "type": "array",
36
- "minItems": 2,
36
+ "minItems": 3,
37
37
  "items": {
38
38
  "type": "object",
39
39
  "additionalProperties": false,
40
40
  "required": ["id", "version", "sha256"],
41
41
  "properties": {
42
- "id": {"enum": ["SPC-004", "SPC-005"]},
42
+ "id": {"enum": ["SPC-004", "SPC-005", "SPC-006"]},
43
43
  "version": {"type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"},
44
44
  "sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}
45
45
  }
@@ -73,21 +73,23 @@
73
73
  "stageLifecycle": {
74
74
  "type": "object",
75
75
  "additionalProperties": false,
76
- "required": ["actions", "start_command", "finish_command", "prompt_sections", "current_write_alias", "archive_pattern"],
76
+ "required": ["actions", "start_command", "bootstrap_command", "finish_command", "prompt_sections", "current_write_alias", "archive_pattern"],
77
77
  "properties": {
78
78
  "actions": {
79
79
  "const": ["stage_start", "stage_finish"]
80
80
  },
81
81
  "start_command": {"const": "dd-flow stage start <RUN> --stage <stage> --json"},
82
- "finish_command": {"const": "dd-flow stage finish <RUN> --stage <stage> --status done --json"},
82
+ "bootstrap_command": {"const": "dd-flow stage start --bootstrap --stage specify --project-root <root> --subject <label> --intake-file <path> --json"},
83
+ "finish_command": {"const": "dd-flow stage finish <RUN> --stage <stage> --outcome <outcome> --json"},
83
84
  "prompt_sections": {
84
85
  "const": [
85
86
  "stage_identity",
86
- "runtime_context",
87
- "intake",
87
+ "authoritative_runtime_facts",
88
+ "preflight",
89
+ "task_intake",
88
90
  "applicable_instructions",
89
- "stage_cli",
90
- "file_boundaries",
91
+ "required_context",
92
+ "work_contract",
91
93
  "completion_contract"
92
94
  ]
93
95
  },
@@ -112,7 +114,7 @@
112
114
  "required": ["finish_outputs", "model_input", "mechanical_facts"],
113
115
  "properties": {
114
116
  "finish_outputs": {"const": ["stage-report.json", "stage-report.md", "stage-report.html", "summary.md"]},
115
- "model_input": {"const": "semantic-only"},
117
+ "model_input": {"const": "semantic-only; outcome flag"},
116
118
  "mechanical_facts": {"const": "cli-owned"}
117
119
  }
118
120
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
- "$id": "dd-flow/stage-prompt@1",
3
+ "$id": "dd-flow/stage-prompt@2",
4
4
  "title": "Generated stage prompt",
5
5
  "type": "object",
6
6
  "additionalProperties": false,
@@ -16,16 +16,20 @@
16
16
  }
17
17
  }
18
18
  ],
19
- "required": ["schema_id", "run_id", "stage", "generated_at", "prompt_path", "sections", "aliases", "write_boundary", "source_fragments"],
19
+ "required": ["schema_id", "run_id", "stage", "generated_at", "prompt_path", "sections", "aliases", "write_boundary", "source_fragments", "authoritative_facts", "preflight", "required_context", "worker_prompt_markdown"],
20
20
  "properties": {
21
- "schema_id": {"const": "dd-flow/stage-prompt@1"},
21
+ "schema_id": {"const": "dd-flow/stage-prompt@2"},
22
22
  "run_id": {"type": "string", "minLength": 1},
23
23
  "stage": {"type": "string", "minLength": 1},
24
24
  "generated_at": {"type": "string", "format": "date-time"},
25
25
  "prompt_path": {"type": "string", "minLength": 1},
26
- "sections": {"type": "array", "minItems": 7, "maxItems": 7, "items": {"enum": ["stage_identity", "runtime_context", "intake", "applicable_instructions", "stage_cli", "file_boundaries", "completion_contract"]}},
26
+ "sections": {"type": "array", "minItems": 8, "maxItems": 8, "items": {"enum": ["stage_identity", "authoritative_runtime_facts", "preflight", "task_intake", "applicable_instructions", "required_context", "work_contract", "completion_contract"]}},
27
27
  "aliases": {"type": "object", "required": ["project", "workspace", "run", "stage", "protocol", "intake"], "additionalProperties": {"type": "string"}},
28
28
  "write_boundary": {"type": "object", "required": ["current", "archive", "archive_writable"], "properties": {"current": {"const": "@stage"}, "archive": {"pattern": "try-[0-9]{3}"}, "archive_writable": {"const": false}}, "additionalProperties": false},
29
- "source_fragments": {"type": "array", "items": {"type": "string", "minLength": 1}}
29
+ "source_fragments": {"type": "array", "items": {"type": "string", "minLength": 1}},
30
+ "authoritative_facts": {"type": "object", "additionalProperties": true},
31
+ "preflight": {"type": "object", "additionalProperties": true},
32
+ "required_context": {"type": "array", "items": {"type": "string", "minLength": 1}},
33
+ "worker_prompt_markdown": {"type": "string", "minLength": 1}
30
34
  }
31
35
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
- "$id": "dd-flow/stage-start-response@1",
3
+ "$id": "dd-flow/stage-start-response@2",
4
4
  "title": "Stage start response",
5
5
  "type": "object",
6
6
  "additionalProperties": false,
@@ -10,9 +10,9 @@
10
10
  "then": {"required": ["plan_ref", "aspect_map_ref"]}
11
11
  }
12
12
  ],
13
- "required": ["schema_id", "run_id", "stage", "attempt_number", "stage_root", "prompt_path", "aliases", "next_command"],
13
+ "required": ["schema_id", "run_id", "stage", "attempt_number", "stage_root", "prompt_path", "aliases", "next_command", "worker_prompt_markdown"],
14
14
  "properties": {
15
- "schema_id": {"const": "dd-flow/stage-start-response@1"},
15
+ "schema_id": {"const": "dd-flow/stage-start-response@2"},
16
16
  "run_id": {"type": "string", "minLength": 1},
17
17
  "stage": {"type": "string", "minLength": 1},
18
18
  "attempt_number": {"type": "integer", "minimum": 1},
@@ -23,7 +23,8 @@
23
23
  "resolved_context": {"type": "object", "additionalProperties": true},
24
24
  "plan_ref": {"type": "string", "pattern": "^\\.memory-bank/protocol/[^/]+/plan\\.json$"},
25
25
  "aspect_map_ref": {"type": "string", "minLength": 1},
26
- "next_command": {"const": "dd-flow stage finish"},
27
- "permission_probe": {"type": "object", "additionalProperties": true}
26
+ "next_command": {"type": "string", "pattern": "^dd-flow stage finish "},
27
+ "permission_probe": {"type": "object", "additionalProperties": true},
28
+ "worker_prompt_markdown": {"type": "string", "minLength": 1}
28
29
  }
29
30
  }
@@ -12,6 +12,7 @@ const actionKinds = new Set([
12
12
  "repair_protocol_state",
13
13
  "cancel_queue_job",
14
14
  "close_flow_session",
15
+ "discard_stale_run",
15
16
  "expire_lane_lock",
16
17
  "expire_lane_waiter",
17
18
  "close_worktree_record"
@@ -86,6 +87,36 @@ export function cleanupScan(context, input) {
86
87
  });
87
88
  }
88
89
  }
90
+ const staleRuns = context.db.all(`SELECT id, runtime_path, run_dir, workspace_root FROM flow_runs
91
+ WHERE project_id = ? AND status = 'running'
92
+ ORDER BY updated_at ASC, id ASC`, [project.id]);
93
+ for (const run of staleRuns) {
94
+ const missingAuthority = !fs.existsSync(run.runtime_path);
95
+ const missingRunHome = !fs.existsSync(run.run_dir);
96
+ const missingWorkspace = !fs.existsSync(run.workspace_root);
97
+ if (!missingAuthority)
98
+ continue;
99
+ const details = {
100
+ code: "stale_run_missing_authority",
101
+ severity: "warning",
102
+ run_id: run.id,
103
+ runtime_path: run.runtime_path,
104
+ missing_run_home: missingRunHome,
105
+ missing_workspace: missingWorkspace
106
+ };
107
+ if (missingRunHome && missingWorkspace) {
108
+ pushFinding(findings, actions, details.code, details.severity, {
109
+ run_id: run.id,
110
+ runtime_path: run.runtime_path,
111
+ missing_run_home: missingRunHome,
112
+ missing_workspace: missingWorkspace,
113
+ action: { kind: "discard_stale_run", project_id: project.id, run_id: run.id }
114
+ });
115
+ }
116
+ else {
117
+ findings.push({ ...details, action: null, reason: "manual_reconciliation_required" });
118
+ }
119
+ }
89
120
  const now = context.now();
90
121
  const expiredLocks = context.db.all(`SELECT id, lane, worker_id, expires_at FROM lane_locks
91
122
  WHERE project_id = ? AND status = 'active' AND expires_at <= ?
@@ -289,6 +320,41 @@ function applyAction(context, action, reason, force, now) {
289
320
  });
290
321
  return { kind: action.kind, changed: true, session_id: action.session_id };
291
322
  }
323
+ if (action.kind === "discard_stale_run" && action.run_id) {
324
+ const run = context.db.get("SELECT status, runtime_path, run_dir, workspace_root, index_json FROM flow_runs WHERE project_id = ? AND id = ?", [action.project_id, action.run_id]);
325
+ if (!run) {
326
+ return { kind: action.kind, changed: false, skipped: true, reason: "run_not_found", run_id: action.run_id };
327
+ }
328
+ if (run.status === "discarded") {
329
+ const index = discardedRunIndex(run.index_json, reason, now);
330
+ context.db.run("UPDATE flow_runs SET verdict = 'discarded', next_action = ?, index_json = ?, updated_at = ?, completed_at = COALESCE(completed_at, ?) WHERE project_id = ? AND id = ?", [reason, JSON.stringify(index), now, now, action.project_id, action.run_id]);
331
+ if (fs.existsSync(run.runtime_path))
332
+ fs.writeFileSync(run.runtime_path, `${JSON.stringify(index, null, 2)}\n`);
333
+ return { kind: action.kind, changed: true, run_id: action.run_id };
334
+ }
335
+ if (run.status !== "running") {
336
+ return { kind: action.kind, changed: false, skipped: true, reason: `already_${run.status}`, run_id: action.run_id };
337
+ }
338
+ if (fs.existsSync(run.runtime_path) || fs.existsSync(run.run_dir) || fs.existsSync(run.workspace_root)) {
339
+ return { kind: action.kind, changed: false, skipped: true, reason: "runtime_or_workspace_reappeared", run_id: action.run_id };
340
+ }
341
+ const index = discardedRunIndex(run.index_json, reason, now);
342
+ context.db.run(`UPDATE flow_runs SET status = 'discarded', verdict = 'discarded', next_action = ?, index_json = ?, updated_at = ?, completed_at = ?
343
+ WHERE project_id = ? AND id = ? AND status = 'running'`, [reason, JSON.stringify(index), now, now, action.project_id, action.run_id]);
344
+ fs.mkdirSync(run.run_dir, { recursive: true });
345
+ fs.writeFileSync(run.runtime_path, `${JSON.stringify(index, null, 2)}\n`);
346
+ context.db.run(`UPDATE flow_sessions SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
347
+ WHERE project_id = ? AND run_id = ? AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [reason, now, now, action.project_id, action.run_id]);
348
+ context.db.run("UPDATE flow_session_segments SET ended_at = ? WHERE project_id = ? AND run_id = ? AND ended_at IS NULL", [now, action.project_id, action.run_id]);
349
+ appendAudit(context, {
350
+ projectId: action.project_id,
351
+ eventType: "flow_run.discarded",
352
+ reason,
353
+ forced: force,
354
+ payload: { run_id: action.run_id, source: "cleanup.apply", authority_missing: true }
355
+ });
356
+ return { kind: action.kind, changed: true, run_id: action.run_id };
357
+ }
292
358
  if (action.kind === "expire_lane_lock" && typeof action.lock_id === "number") {
293
359
  const result = context.db.run("UPDATE lane_locks SET status = 'expired', reason = ?, updated_at = ? WHERE project_id = ? AND id = ? AND status = 'active'", [reason, now, action.project_id, action.lock_id]);
294
360
  return {
@@ -330,6 +396,17 @@ function applyAction(context, action, reason, force, now) {
330
396
  }
331
397
  return { kind: action.kind, changed: false, skipped: true, reason: "invalid_action" };
332
398
  }
399
+ function discardedRunIndex(indexJson, reason, now) {
400
+ const index = JSON.parse(indexJson);
401
+ index.schema_id = "dd-flow/flow-run@2";
402
+ index.status = "discarded";
403
+ index.verdict = "discarded";
404
+ index.next_action = reason;
405
+ index.updated_at = now;
406
+ index.completed_at ??= now;
407
+ index.finished_at ??= now;
408
+ return index;
409
+ }
333
410
  function dirtyGit(worktreePath) {
334
411
  if (!fs.existsSync(worktreePath)) {
335
412
  return false;
@@ -196,7 +196,7 @@ export function getCodexHooksStatus(context, input) {
196
196
  };
197
197
  }
198
198
  const status = hookFileStatus(location.hooksPath, project.root);
199
- const recorded = hookInstallation(context, project.id, installationScope(target, location.profile));
199
+ const recorded = hookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath));
200
200
  const driftStatus = status.installed ? "none" : recorded?.installed === 1 ? "drifted" : status.drift_status;
201
201
  return {
202
202
  ok: true,
@@ -224,7 +224,7 @@ export function installCodexHooks(context, input) {
224
224
  const nextConfig = mergeHooksConfig(existing, project.root);
225
225
  ensureDir(path.dirname(location.hooksPath));
226
226
  writeJsonFile(location.hooksPath, nextConfig);
227
- recordHookInstallation(context, project.id, installationScope(target, location.profile), location.hooksPath, true, "none");
227
+ recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, true, "none");
228
228
  appendAudit(context, {
229
229
  projectId: project.id,
230
230
  eventType: "codex_hooks.installed",
@@ -256,7 +256,7 @@ export function removeCodexHooks(context, input) {
256
256
  const nextConfig = removeManagedHooks(existing, project.root);
257
257
  ensureDir(path.dirname(location.hooksPath));
258
258
  writeJsonFile(location.hooksPath, nextConfig);
259
- recordHookInstallation(context, project.id, installationScope(target, location.profile), location.hooksPath, false, "not_installed");
259
+ recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, false, "not_installed");
260
260
  appendAudit(context, {
261
261
  projectId: project.id,
262
262
  eventType: "codex_hooks.removed",
@@ -277,13 +277,16 @@ export function handleCodexHook(context, input) {
277
277
  if (!command)
278
278
  return { ok: true, observed: false, reason: "non_bash_tool" };
279
279
  const flowPayload = flowSessionPayloadFromRegisterCommand(command);
280
- if (!flowPayload)
280
+ const bootstrapStageStart = /\bdd-flow\s+stage\s+start\b/.test(command) && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
281
+ if (!flowPayload && !bootstrapStageStart)
281
282
  return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
282
283
  const project = projectForHook(context, input.projectRoot, stringValue(payload.cwd));
283
284
  if (!project)
284
285
  return { ok: true, observed: false, reason: "unrelated_cwd" };
285
286
  const binding = sessionId ? upsertSessionBindingFromPayload(context, project, sessionId, payload) : undefined;
286
- const observedSession = bindObservedFlowSession(context, project, flowPayload, sessionId ?? flowPayload.session_id ?? undefined);
287
+ const observedSession = flowPayload
288
+ ? bindObservedFlowSession(context, project, flowPayload, sessionId ?? flowPayload.session_id ?? undefined)
289
+ : undefined;
287
290
  const effectiveSessionId = observedSession?.session_id ?? sessionId ?? null;
288
291
  const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
289
292
  const eventKey = hookEventKey(payload, eventName, toolName, command);
@@ -315,9 +318,23 @@ export function handleCodexHook(context, input) {
315
318
  duplicate: !inserted,
316
319
  event_key: eventKey,
317
320
  session_id: effectiveSessionId,
318
- protocol_id: protocolId
321
+ protocol_id: protocolId,
322
+ ...(sessionId
323
+ ? {
324
+ hookSpecificOutput: {
325
+ hookEventName: "PreToolUse",
326
+ permissionDecision: "allow",
327
+ updatedInput: { command: commandWithSessionId(command, sessionId) }
328
+ }
329
+ }
330
+ : {})
319
331
  };
320
332
  }
333
+ function commandWithSessionId(command, sessionId) {
334
+ if (/(?:^|\s)--session-id(?:=|\s)/.test(command))
335
+ return command;
336
+ return `${command} --session-id ${JSON.stringify(sessionId)}`;
337
+ }
321
338
  export function hookStatusForProject(context, projectId) {
322
339
  return context.db.all(`SELECT scope, config_path, content_hash, installed, drift_status, updated_at
323
340
  FROM hook_installations WHERE project_id = ? ORDER BY updated_at DESC`, [projectId]);
@@ -460,7 +477,7 @@ function commandHook(command, statusMessage) {
460
477
  return { type: "command", command, timeout: 5, statusMessage };
461
478
  }
462
479
  function hookCommand(event) {
463
- return `dd-flow codex hook handle --event ${event} --json`;
480
+ return `PATH="\${HOME}/Library/pnpm:/opt/homebrew/bin:/usr/local/bin:\${PATH}" dd-flow codex hook handle --event ${event} --json`;
464
481
  }
465
482
  function resolveHookTarget(target) {
466
483
  if (!target || target === "isolated") {
@@ -632,8 +649,8 @@ function recordHookInstallation(context, projectId, scope, configPath, installed
632
649
  drift_status = excluded.drift_status,
633
650
  updated_at = excluded.updated_at`, [projectId, scope, configPath, hashObject(readJsonIfExists(configPath) ?? {}), installed ? 1 : 0, driftStatus, now, now]);
634
651
  }
635
- function installationScope(target, profile) {
636
- return target === "isolated" ? `isolated:${profile}` : "default";
652
+ function installationScope(target, profile, hooksPath) {
653
+ return target === "isolated" ? `isolated:${profile}` : `default:${path.dirname(hooksPath)}`;
637
654
  }
638
655
  function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
639
656
  const existing = sessionBinding(context, project.id, sessionId);
@@ -688,7 +705,7 @@ function projectForHook(context, explicitRoot, cwd) {
688
705
  .sort((left, right) => right.root.length - left.root.length)[0];
689
706
  }
690
707
  function hookEventKey(payload, eventName, toolName, command) {
691
- const explicit = stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
708
+ const explicit = stringValue(payload.tool_use_id) ?? stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
692
709
  if (explicit)
693
710
  return explicit;
694
711
  return crypto.createHash("sha256").update(JSON.stringify({
@@ -232,7 +232,7 @@ function adjacentMigrationChain(source, target) {
232
232
  function activeStateSummary(context, projectId) {
233
233
  return {
234
234
  protocols: context.db.all("SELECT id, status, stage FROM protocols WHERE project_id = ? AND status NOT IN ('closed', 'cancelled') ORDER BY updated_at DESC", [projectId]),
235
- runs: context.db.all("SELECT id, status, verdict FROM flow_runs WHERE project_id = ? AND status NOT IN ('done', 'cancelled', 'failed') ORDER BY updated_at DESC", [projectId]),
235
+ runs: context.db.all("SELECT id, status, verdict FROM flow_runs WHERE project_id = ? AND status = 'running' ORDER BY updated_at DESC", [projectId]),
236
236
  merge_queue: context.db.all("SELECT protocol_id, status FROM merge_queue WHERE project_id = ? AND status IN ('ready', 'claimed', 'requeued') ORDER BY updated_at DESC", [projectId]),
237
237
  lane_locks: context.db.all("SELECT lane, worker_id, status FROM lane_locks WHERE project_id = ? AND status = 'active' ORDER BY updated_at DESC", [projectId])
238
238
  };
@@ -1,21 +1,33 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { reconcileSessionCoverageRows } from "../domain/session-coverage.js";
3
4
  export function refreshRunSessionProjection(context, projectId, runId) {
4
- const run = context.db.get("SELECT id, project_root, subject_type, subject_id, runtime_path, run_index_path, index_json FROM flow_runs WHERE project_id = ? AND id = ?", [projectId, runId]);
5
+ const run = context.db.get("SELECT id, status, project_root, subject_type, subject_id, runtime_path, run_index_path, index_json FROM flow_runs WHERE project_id = ? AND id = ?", [projectId, runId]);
5
6
  if (!run)
6
7
  return;
7
8
  const index = JSON.parse(run.index_json);
8
- const sessions = context.db.all(`SELECT session_id, parent_session_id, role, session_kind, worker_id, current_stage, status,
9
+ const sessionRows = context.db.all(`SELECT session_id, parent_session_id, role, session_kind, worker_id, current_stage, status,
9
10
  created_at, updated_at, stopped_at, coverage_units_json
10
- FROM flow_sessions WHERE project_id = ? AND run_id = ? ORDER BY created_at, session_id`, [projectId, runId]).map(sessionProjection);
11
+ FROM flow_sessions WHERE project_id = ? AND run_id = ? ORDER BY created_at, session_id`, [projectId, runId]);
12
+ const sessions = sessionRows.map(sessionProjection);
13
+ const coverage = reconcileSessionCoverageRows(sessionRows);
14
+ const sessionCoverage = {
15
+ status: coverage.status,
16
+ expected: coverage.expected_unit_ids,
17
+ observed: coverage.observed_unit_ids,
18
+ missing: coverage.missing_unit_ids,
19
+ diagnostics: coverage.diagnostics
20
+ };
11
21
  const plan = run.subject_type === "protocol" ? planProjection(context, projectId, run.subject_id, run.project_root) : {};
12
22
  const workers = workerProjection(context, projectId, runId);
13
23
  if (JSON.stringify(index.sessions ?? []) === JSON.stringify(sessions)
24
+ && JSON.stringify(index.session_coverage) === JSON.stringify(sessionCoverage)
14
25
  && JSON.stringify(index.plan_ref) === JSON.stringify(plan.plan_ref)
15
26
  && JSON.stringify(index.plan_progress) === JSON.stringify(plan.plan_progress)
16
27
  && JSON.stringify(index.workers ?? {}) === JSON.stringify(workers))
17
28
  return;
18
29
  index.sessions = sessions;
30
+ index.session_coverage = sessionCoverage;
19
31
  if (plan.plan_ref)
20
32
  index.plan_ref = plan.plan_ref;
21
33
  if (plan.plan_progress)
@@ -31,6 +43,7 @@ export function refreshRunSessionProjection(context, projectId, runId) {
31
43
  index.runtime_revision = runtimeRevision;
32
44
  const authoritative = runtime ?? { ...index, schema_id: "dd-flow/flow-run@2" };
33
45
  authoritative.sessions = sessions;
46
+ authoritative.session_coverage = sessionCoverage;
34
47
  if (plan.plan_ref)
35
48
  authoritative.plan_ref = plan.plan_ref;
36
49
  if (plan.plan_progress)
@@ -38,7 +51,8 @@ export function refreshRunSessionProjection(context, projectId, runId) {
38
51
  authoritative.workers = workers;
39
52
  authoritative.updated_at = index.updated_at;
40
53
  authoritative.runtime_revision = runtimeRevision;
41
- writeJson(run.runtime_path, authoritative);
54
+ if (run.status !== "discarded" || fs.existsSync(run.runtime_path))
55
+ writeJson(run.runtime_path, authoritative);
42
56
  context.db.run("UPDATE flow_runs SET index_json = ?, updated_at = ? WHERE project_id = ? AND id = ?", [JSON.stringify(index), index.updated_at, projectId, runId]);
43
57
  }
44
58
  function planProjection(context, projectId, protocolId, projectRoot) {
@@ -428,6 +428,7 @@ export function completeFlowRun(context, input) {
428
428
  };
429
429
  }
430
430
  persistRunState(context, project, run, index);
431
+ closeRunOrchestrators(context, project.id, run, status, now);
431
432
  refreshRunSessionProjection(context, project.id, run.id);
432
433
  appendAudit(context, {
433
434
  projectId: project.id,
@@ -439,6 +440,30 @@ export function completeFlowRun(context, input) {
439
440
  const updatedRun = requireRunById(context, project.id, run.id);
440
441
  return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
441
442
  }
443
+ function closeRunOrchestrators(context, projectId, run, status, now) {
444
+ if (!["done", "blocked", "cancelled", "failed"].includes(status))
445
+ return;
446
+ const active = context.db.all(`SELECT session_id FROM flow_sessions
447
+ WHERE project_id = ? AND run_id = ? AND session_kind = 'orchestrator'
448
+ AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [projectId, run.id]);
449
+ if (active.length === 0)
450
+ return;
451
+ const reason = `run_${status}`;
452
+ context.db.run(`UPDATE flow_sessions SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
453
+ WHERE project_id = ? AND run_id = ? AND session_kind = 'orchestrator'
454
+ AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [reason, now, now, projectId, run.id]);
455
+ context.db.run(`UPDATE flow_session_segments SET ended_at = ?
456
+ WHERE project_id = ? AND run_id = ? AND session_id IN (${active.map(() => "?").join(", ")}) AND ended_at IS NULL`, [now, projectId, run.id, ...active.map((session) => session.session_id)]);
457
+ for (const session of active) {
458
+ appendRunTimeline(runArtifactRoot(run), {
459
+ at: now,
460
+ type: "session_stopped",
461
+ run_id: run.id,
462
+ session_id: session.session_id,
463
+ reason
464
+ });
465
+ }
466
+ }
442
467
  const timelineSections = ["stages", "events", "sessions", "usage", "artifacts"];
443
468
  export function getFlowRunTimeline(context, input) {
444
469
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -511,6 +536,14 @@ export function appendFlowRunTimelineEvent(context, projectId, runId, event) {
511
536
  appendRunTimeline(runArtifactRoot(run), { run_id: run.id, ...event });
512
537
  }
513
538
  function guidanceForRun(context, run, index) {
539
+ if (run.status === "discarded") {
540
+ return buildRunFlowGuidance({
541
+ stageRuns: index.stage_runs,
542
+ protocolStage: "cancelled",
543
+ runId: run.id,
544
+ runDir: index.run_home?.relative_path ?? path.posix.join("runs", run.id)
545
+ });
546
+ }
514
547
  if (run.subject_type === "protocol") {
515
548
  try {
516
549
  const protocol = requireProtocol(context, run.subject_id, run.project_id);
@@ -8,6 +8,7 @@ import { appendAudit } from "./audit.js";
8
8
  import { cancelLaneWaitersForWorker } from "./lanes.js";
9
9
  import { checkpointSessionUsage } from "./usage.js";
10
10
  import { refreshRunSessionProjection } from "./run-projection.js";
11
+ import { reconcileSessionCoverageRows } from "../domain/session-coverage.js";
11
12
  import { appendFlowRunTimelineEvent } from "./runs.js";
12
13
  export function recordFlowSessionObservation(context, input) {
13
14
  const duplicate = context.db.get("SELECT id FROM flow_session_segments WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
@@ -99,68 +100,7 @@ export function getFlowSessionStatus(context, input) {
99
100
  };
100
101
  }
101
102
  export function reconcileSessionCoverage(sessions) {
102
- const sessionCounts = countValues(sessions.map((session) => session.session_id));
103
- const duplicateSessionIds = [...sessionCounts.entries()].filter(([, count]) => count > 1).map(([id]) => id).sort();
104
- const invalidSessionIds = [];
105
- const parsed = sessions.map((session) => {
106
- try {
107
- return { session, units: normalizeCoverageUnits(JSON.parse(session.coverage_units_json || "[]")) };
108
- }
109
- catch {
110
- invalidSessionIds.push(session.session_id);
111
- return { session, units: [] };
112
- }
113
- });
114
- const orchestrators = parsed.filter(({ session }) => session.session_kind === "orchestrator");
115
- const expectedCounts = countValues(orchestrators.flatMap(({ units }) => units.map((unit) => unit.unit_id)));
116
- const expectedUnitIds = [...expectedCounts.keys()].sort();
117
- const duplicateExpectedUnitIds = [...expectedCounts.entries()].filter(([, count]) => count > 1).map(([id]) => id).sort();
118
- const observedOwners = new Map();
119
- for (const { session, units } of parsed) {
120
- if (session.session_kind === "orchestrator")
121
- continue;
122
- for (const unit of units) {
123
- const owners = observedOwners.get(unit.unit_id) ?? [];
124
- owners.push(session.session_id);
125
- observedOwners.set(unit.unit_id, owners);
126
- }
127
- }
128
- const observedUnitIds = [...observedOwners.keys()].sort();
129
- const missingUnitIds = expectedUnitIds.filter((unitId) => !observedOwners.has(unitId));
130
- const duplicateUnitIds = [...observedOwners.entries()].filter(([, owners]) => new Set(owners).size > 1).map(([id]) => id).sort();
131
- const diagnostics = [];
132
- if (orchestrators.length === 0)
133
- diagnostics.push("orchestrator_session_missing");
134
- if (orchestrators.length > 1)
135
- diagnostics.push("multiple_orchestrator_sessions");
136
- if (expectedUnitIds.length === 0)
137
- diagnostics.push("expected_worker_units_missing");
138
- if (missingUnitIds.length > 0)
139
- diagnostics.push("expected_worker_units_unobserved");
140
- if (duplicateUnitIds.length > 0 || duplicateExpectedUnitIds.length > 0)
141
- diagnostics.push("duplicate_coverage_binding");
142
- if (duplicateSessionIds.length > 0)
143
- diagnostics.push("duplicate_session_binding");
144
- if (invalidSessionIds.length > 0)
145
- diagnostics.push("invalid_coverage_units");
146
- const unavailable = orchestrators.length === 0 || expectedUnitIds.length === 0 || invalidSessionIds.length > 0;
147
- const partial = orchestrators.length !== 1
148
- || missingUnitIds.length > 0
149
- || duplicateUnitIds.length > 0
150
- || duplicateExpectedUnitIds.length > 0
151
- || duplicateSessionIds.length > 0;
152
- return {
153
- status: unavailable ? "unavailable" : partial ? "partial" : "complete",
154
- expected_unit_ids: expectedUnitIds,
155
- observed_unit_ids: observedUnitIds,
156
- missing_unit_ids: missingUnitIds,
157
- duplicate_unit_ids: duplicateUnitIds,
158
- duplicate_expected_unit_ids: duplicateExpectedUnitIds,
159
- duplicate_session_ids: duplicateSessionIds,
160
- orchestrator_session_ids: orchestrators.map(({ session }) => session.session_id).sort(),
161
- invalid_session_ids: [...new Set(invalidSessionIds)].sort(),
162
- diagnostics
163
- };
103
+ return reconcileSessionCoverageRows(sessions);
164
104
  }
165
105
  export function stopFlowSession(context, input) {
166
106
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -245,9 +185,10 @@ export function updateFlowSessionContinuation(context, projectId, sessionId, act
245
185
  return nextCount;
246
186
  }
247
187
  export function flowSessionPayloadFromRegisterCommand(command) {
248
- if (!/\bdd-flow\s+session\s+register\b/.test(command)) {
188
+ if (/\bdd-flow\s+stage\s+start\b/.test(command))
189
+ return flowSessionPayloadFromStageStartCommand(command);
190
+ if (!/\bdd-flow\s+session\s+register\b/.test(command))
249
191
  return undefined;
250
- }
251
192
  const payloadBase64 = optionFromCommand(command, "payload-base64");
252
193
  const payloadJson = optionFromCommand(command, "payload-json");
253
194
  const payloadFile = optionFromCommand(command, "payload-file");
@@ -256,6 +197,25 @@ export function flowSessionPayloadFromRegisterCommand(command) {
256
197
  }
257
198
  return decodeFlowSessionPayload({ payloadBase64, payloadJson, payloadFile });
258
199
  }
200
+ function flowSessionPayloadFromStageStartCommand(command) {
201
+ const projectRoot = optionFromCommand(command, "project-root");
202
+ const stage = optionFromCommand(command, "stage");
203
+ const run = command.match(/\bdd-flow\s+stage\s+start\s+([^\s]+)/)?.[1];
204
+ if (!projectRoot || !stage || !run || run.startsWith("--"))
205
+ return undefined;
206
+ return {
207
+ project_root: projectRoot,
208
+ flow_kind: ["code", "implementation", "readiness", "merge"].includes(stage) ? "implementation" : "planning",
209
+ run_id: run,
210
+ protocol_id: null,
211
+ worker_id: null,
212
+ workspace_path: null,
213
+ continuation_policy: "go_router",
214
+ session_kind: "orchestrator",
215
+ current_stage: stage,
216
+ coverage_units: []
217
+ };
218
+ }
259
219
  function decodeFlowSessionPayload(input) {
260
220
  if (input.payloadFile) {
261
221
  return normalizeFlowSessionPayload(parseJsonObject(fs.readFileSync(input.payloadFile, "utf8"), "session payload"));
@@ -485,9 +445,8 @@ function sessionKind(payload) {
485
445
  function normalizeCoverageUnits(value) {
486
446
  if (value === undefined || value === null)
487
447
  return [];
488
- if (!Array.isArray(value)) {
448
+ if (!Array.isArray(value))
489
449
  throw new AppError("validation", "coverage_units must be an array", 2);
490
- }
491
450
  return value.map((item, index) => {
492
451
  if (!item || typeof item !== "object" || Array.isArray(item)) {
493
452
  throw new AppError("validation", `coverage_units[${index}] must be an object`, 2);
@@ -496,16 +455,17 @@ function normalizeCoverageUnits(value) {
496
455
  if (typeof object.unit_id !== "string" || object.unit_id.length === 0) {
497
456
  throw new AppError("validation", `coverage_units[${index}].unit_id is required`, 2);
498
457
  }
499
- const optional = (key) => object[key] === undefined || object[key] === null ? null : typeof object[key] === "string" ? object[key] : (() => { throw new AppError("validation", `coverage_units[${index}].${key} must be a string`, 2); })();
458
+ const optional = (key) => {
459
+ const field = object[key];
460
+ if (field === undefined || field === null)
461
+ return null;
462
+ if (typeof field === "string")
463
+ return field;
464
+ throw new AppError("validation", `coverage_units[${index}].${key} must be a string`, 2);
465
+ };
500
466
  return { unit_id: object.unit_id, group_id: optional("group_id"), job_id: optional("job_id"), kind: optional("kind") };
501
467
  });
502
468
  }
503
- function countValues(values) {
504
- const counts = new Map();
505
- for (const value of values)
506
- counts.set(value, (counts.get(value) ?? 0) + 1);
507
- return counts;
508
- }
509
469
  function requiredStringField(payload, key) {
510
470
  const value = stringField(payload, key, true);
511
471
  if (!value) {
@@ -5,20 +5,106 @@ import { spawnSync } from "node:child_process";
5
5
  import { requireProjectByRoot } from "./projects.js";
6
6
  import { appendAudit } from "./audit.js";
7
7
  import { persistProtocolState, requireProtocol, readProtocolRuntimeState } from "./protocols.js";
8
- import { attachFlowRunStage, completeFlowRunStage, getFlowRunStatus } from "./runs.js";
8
+ import { attachFlowRunStage, completeFlowRunStage, getFlowRunStatus, startFlowRun } from "./runs.js";
9
9
  import { validateSchema } from "./schema-validation.js";
10
- import { reconcileSessionCoverage } from "./sessions.js";
10
+ import { reconcileSessionCoverage, registerFlowSession } from "./sessions.js";
11
11
  import { usageForRun } from "./usage.js";
12
12
  import { boundCanonicalPlan } from "./plan-runtime.js";
13
13
  import { AppError } from "../shared/errors.js";
14
14
  import { planJsonPath, resolveProjectRoot } from "../storage/paths.js";
15
+ import { resolveCanonRoot } from "./canon.js";
16
+ import { preflightMemoryPermissions } from "./memory-permissions.js";
17
+ import { registerProject } from "./projects.js";
18
+ import { registerProtocol } from "./protocols.js";
19
+ import { previewNextEntityId } from "./ids.js";
20
+ const stagePromptSections = [
21
+ "stage_identity",
22
+ "authoritative_runtime_facts",
23
+ "preflight",
24
+ "task_intake",
25
+ "applicable_instructions",
26
+ "required_context",
27
+ "work_contract",
28
+ "completion_contract"
29
+ ];
30
+ function bootstrapStageRun(context, projectRoot, input) {
31
+ if (input.stage !== "specify") {
32
+ throw new AppError("validation", "--bootstrap currently starts the initial specify stage only", 2, { stage: input.stage });
33
+ }
34
+ const subject = input.bootstrap?.subject.trim();
35
+ if (!subject)
36
+ throw new AppError("usage", "--bootstrap requires --subject", 2);
37
+ registerProject(context, { root: projectRoot });
38
+ const preview = previewNextEntityId(context, { projectRoot, type: "protocol", slug: subject });
39
+ const protocolId = preview.entity.id;
40
+ registerProtocol(context, { handshakeId: protocolId, projectRoot });
41
+ const started = startFlowRun(context, {
42
+ projectRoot,
43
+ flowKind: "mb_sdlc",
44
+ subjectType: "protocol",
45
+ subjectId: protocolId,
46
+ slug: subject,
47
+ nextAction: "run_specify"
48
+ });
49
+ const intakeFile = input.bootstrap?.intakeFile;
50
+ if (intakeFile) {
51
+ const source = path.resolve(intakeFile);
52
+ if (!fs.existsSync(source) || !fs.statSync(source).isFile()) {
53
+ throw new AppError("not_found", "--intake-file must point to an existing file", 1, { intake_file: intakeFile });
54
+ }
55
+ const intakeDir = path.join(started.run.run_home_path ?? path.dirname(started.run.id), "intake");
56
+ fs.mkdirSync(intakeDir, { recursive: true });
57
+ fs.copyFileSync(source, path.join(intakeDir, "user-request.md"));
58
+ }
59
+ return started.run.id;
60
+ }
61
+ function stagePreflight(projectRoot, stageRoot) {
62
+ const memoryBank = preflightMemoryPermissions({
63
+ root: projectRoot,
64
+ memoryBank: ".memory-bank",
65
+ flow: "custom",
66
+ mode: "write",
67
+ targets: [{ path: ".memory-bank", mode: "read" }]
68
+ });
69
+ const probe = path.join(stageRoot, `.dd-flow-permission-${crypto.randomUUID()}`);
70
+ try {
71
+ fs.writeFileSync(probe, "");
72
+ fs.renameSync(probe, `${probe}.renamed`);
73
+ fs.rmSync(`${probe}.renamed`);
74
+ return { ...memoryBank, ok: memoryBank.ok === true, stage_workspace: { path: stageRoot, status: "passed" } };
75
+ }
76
+ catch (error) {
77
+ for (const candidate of [probe, `${probe}.renamed`]) {
78
+ try {
79
+ fs.rmSync(candidate, { force: true });
80
+ }
81
+ catch { /* best effort */ }
82
+ }
83
+ return { ...memoryBank, ok: false, stage_workspace: { path: stageRoot, status: "failed", message: String(error) } };
84
+ }
85
+ }
86
+ function flowKindForStage(stage) {
87
+ return ["code", "implementation", "readiness", "merge"].includes(stage) ? "implementation" : "planning";
88
+ }
89
+ function runtimeFacts(view, projectRoot) {
90
+ return {
91
+ project_root: projectRoot,
92
+ workspace_root: view.run.workspace_root,
93
+ run_id: view.run.id,
94
+ protocol_id: view.run.subject.id,
95
+ git: view.index.execution?.git ?? { branch: null, head: null, status: "unavailable" }
96
+ };
97
+ }
15
98
  export function startStage(context, input) {
16
99
  const projectRoot = resolveProjectRoot(input.projectRoot);
17
- const before = runView(getFlowRunStatus(context, { projectRoot, runId: input.runId }));
18
- const dir = input.dir ?? defaultStageDir(input.stage);
100
+ const runId = input.bootstrap ? bootstrapStageRun(context, projectRoot, input) : input.runId;
101
+ if (!runId)
102
+ throw new AppError("usage", "stage start requires a RUN id or --bootstrap", 2);
103
+ const before = runView(getFlowRunStatus(context, { projectRoot, runId }));
104
+ const dir = input.dir ?? defaultStageDir(input.stage, before.run.flow_kind);
19
105
  const attached = runView(attachFlowRunStage(context, {
20
106
  projectRoot,
21
- runId: input.runId,
107
+ runId,
22
108
  stage: input.stage,
23
109
  dir,
24
110
  status: "running",
@@ -28,9 +114,29 @@ export function startStage(context, input) {
28
114
  const stageRoot = path.join(runHome, dir);
29
115
  ensureWithin(runHome, stageRoot, "stage root");
30
116
  fs.mkdirSync(stageRoot, { recursive: true });
117
+ const preflight = stagePreflight(projectRoot, stageRoot);
118
+ if (preflight.ok !== true)
119
+ throw new AppError("permission_preflight_failed", "Stage workspace is not writable", 1, { preflight });
31
120
  syncProtocolLifecycle(context, attached.run.project_id, attached.run.subject.id, input.stage, "running");
121
+ if (input.sessionId) {
122
+ registerFlowSession(context, {
123
+ sessionId: input.sessionId,
124
+ payloadJson: JSON.stringify({
125
+ project_root: projectRoot,
126
+ flow_kind: flowKindForStage(input.stage),
127
+ run_id: attached.run.id,
128
+ protocol_id: attached.run.subject.id,
129
+ worker_id: null,
130
+ workspace_path: attached.run.workspace_root,
131
+ continuation_policy: "go_router",
132
+ session_kind: "orchestrator",
133
+ current_stage: input.stage,
134
+ cwd: attached.run.workspace_root
135
+ })
136
+ });
137
+ }
32
138
  const promptPath = path.join(stageRoot, "stage-prompt.md");
33
- const prompt = composeStagePrompt(context, projectRoot, before, attached, input.stage, dir);
139
+ const prompt = composeStagePrompt(context, projectRoot, before, attached, input.stage, dir, preflight);
34
140
  atomicWrite(promptPath, prompt);
35
141
  const promptDataPath = path.join(stageRoot, "stage-prompt.json");
36
142
  const planPath = planJsonPath(projectRoot, attached.run.subject.id);
@@ -38,12 +144,12 @@ export function startStage(context, input) {
38
144
  const attempt = attached.index.stage_runs?.find((stage) => stage.stage === input.stage)?.attempt ?? "try-001";
39
145
  const attemptNumber = Number(attempt.replace("try-", "")) || 1;
40
146
  atomicWrite(promptDataPath, {
41
- schema_id: "dd-flow/stage-prompt@1",
147
+ schema_id: "dd-flow/stage-prompt@2",
42
148
  run_id: attached.run.id,
43
149
  stage: input.stage,
44
150
  generated_at: context.now(),
45
151
  prompt_path: promptPath,
46
- sections: ["stage_identity", "runtime_context", "intake", "applicable_instructions", "stage_cli", "file_boundaries", "completion_contract"],
152
+ sections: stagePromptSections,
47
153
  aliases: {
48
154
  project: projectRoot,
49
155
  workspace: attached.run.workspace_root,
@@ -54,11 +160,15 @@ export function startStage(context, input) {
54
160
  ...(input.stage === "plan" ? { plan: planPath, "aspect-map": aspectMapPath } : {})
55
161
  },
56
162
  write_boundary: { current: "@stage", archive: attempt, archive_writable: false },
57
- source_fragments: input.stage === "plan" ? [".memory-bank/dd-flow/plan.md"] : [".memory-bank/dd-flow/code.md", ".memory-bank/dd-flow/mb-sdlc/code/implement.md"]
163
+ source_fragments: stageInstructionSources(context, projectRoot, attached.run.flow_kind, input.stage).map((source) => source.label),
164
+ authoritative_facts: runtimeFacts(attached, projectRoot),
165
+ preflight,
166
+ required_context: stageInstructionSources(context, projectRoot, attached.run.flow_kind, input.stage).map((source) => source.label),
167
+ worker_prompt_markdown: prompt
58
168
  });
59
169
  validateSchema({ schemaName: "stage-prompt", file: promptDataPath, projectRoot });
60
170
  atomicWrite(path.join(stageRoot, "stage-start.json"), {
61
- schema_id: "dd-flow/stage-start@1",
171
+ schema_id: "dd-flow/stage-start@2",
62
172
  run_id: attached.run.id,
63
173
  stage: input.stage,
64
174
  dir,
@@ -68,7 +178,7 @@ export function startStage(context, input) {
68
178
  });
69
179
  return {
70
180
  ok: true,
71
- schema_id: "dd-flow/stage-start-response@1",
181
+ schema_id: "dd-flow/stage-start-response@2",
72
182
  run_id: attached.run.id,
73
183
  stage: input.stage,
74
184
  attempt_number: attemptNumber,
@@ -86,32 +196,33 @@ export function startStage(context, input) {
86
196
  },
87
197
  ...(input.stage === "plan" ? { plan_ref: planPath, aspect_map_ref: aspectMapPath } : {}),
88
198
  resolved_context: { run: attached.run, stage: { name: input.stage, dir, status: "running" } },
89
- next_command: "dd-flow stage finish",
90
- permission_probe: { status: "known_targets_only", project_root: projectRoot },
199
+ next_command: `dd-flow stage finish ${attached.run.id} --stage ${input.stage} --outcome done --project-root ${JSON.stringify(projectRoot)} --json`,
200
+ permission_probe: preflight,
91
201
  run: attached.run,
92
202
  prompt: { path: promptPath, data_path: promptDataPath },
203
+ worker_prompt_markdown: prompt,
93
204
  lifecycle: "stage start -> semantic work -> stage finish"
94
205
  };
95
206
  }
96
207
  export function finishStage(context, input) {
97
208
  const projectRoot = resolveProjectRoot(input.projectRoot);
98
- const view = runView(getFlowRunStatus(context, { projectRoot, runId: input.runId }));
209
+ if (!input.runId)
210
+ throw new AppError("usage", "stage finish requires a RUN id", 2);
211
+ const runId = input.runId;
212
+ const view = runView(getFlowRunStatus(context, { projectRoot, runId }));
99
213
  const existing = view.index.stage_runs?.find((stage) => stage.stage === input.stage);
100
214
  if (!existing) {
101
215
  throw new AppError("not_found", `Run stage is not attached: ${input.stage}`, 1, { run_id: view.run.id, stage: input.stage });
102
216
  }
103
- const dir = existing.dir ?? input.dir ?? defaultStageDir(input.stage);
217
+ const dir = existing.dir ?? input.dir ?? defaultStageDir(input.stage, view.run.flow_kind);
104
218
  const runHome = runHomePath(view.run);
105
219
  const stageRoot = path.join(runHome, dir);
106
220
  ensureWithin(runHome, stageRoot, "stage root");
107
221
  fs.mkdirSync(stageRoot, { recursive: true });
108
- if (!input.semanticFile) {
109
- throw new AppError("stage_finish_input_required", "Stage finish requires --semantic-file with dd-flow/stage-finish-input@1", 2);
110
- }
111
- const semanticFile = resolveStageFile(input.semanticFile, stageRoot, runHome);
222
+ const semanticFile = resolveStageFile(input.semanticFile ?? "@stage/stage-input.json", stageRoot, runHome);
112
223
  validateSchema({ schemaName: "stage-finish-input", file: semanticFile, projectRoot });
113
224
  const semantic = readSemanticFile(semanticFile, runHome);
114
- const status = input.status ?? "done";
225
+ const status = input.outcome ?? "done";
115
226
  if (!["done", "blocked", "failed"].includes(status)) {
116
227
  throw new AppError("validation", "Stage finish status must be done, blocked, or failed", 2, { status });
117
228
  }
@@ -135,7 +246,7 @@ export function finishStage(context, input) {
135
246
  const summaryPath = updateProtocolSummary(context, projectRoot, view, report, dataPath, htmlPath, reportPath);
136
247
  const completed = completeFlowRunStage(context, {
137
248
  projectRoot,
138
- runId: input.runId,
249
+ runId,
139
250
  stage: input.stage,
140
251
  status,
141
252
  stageReport: htmlPath,
@@ -223,8 +334,20 @@ function nextActionForProtocolStage(stage) {
223
334
  function runHomePath(run) {
224
335
  return run.run_home_path ?? path.dirname(run.run_index_path);
225
336
  }
226
- function defaultStageDir(stage) {
337
+ export function defaultStageDir(stage, flowKind) {
227
338
  const normalized = stage === "implementation" ? "code" : stage;
339
+ if (flowKind === "mb-upgrade") {
340
+ const upgrade = {
341
+ preflight: "01-preflight",
342
+ "diff-analysis": "02-diff-analysis",
343
+ upgrade: "03-upgrade",
344
+ lint: "04-lint",
345
+ review: "05-review",
346
+ merge: "06-merge"
347
+ };
348
+ if (upgrade[normalized])
349
+ return upgrade[normalized];
350
+ }
228
351
  const known = { specify: "01-specify", plan: "02-plan", code: "03-code", readiness: "03-code", merge: "04-merge" };
229
352
  return known[normalized] ?? `03-${normalized.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
230
353
  }
@@ -237,54 +360,96 @@ function dataSchemaForStage(stage) {
237
360
  return "dd-flow/merge-stage-report@2";
238
361
  return "dd-flow/code-stage-report@2";
239
362
  }
240
- function composeStagePrompt(context, projectRoot, before, current, stage, dir) {
363
+ function composeStagePrompt(context, projectRoot, before, current, stage, dir, preflight) {
241
364
  const project = requireProjectByRoot(context, projectRoot);
242
365
  const runHome = runHomePath(current.run);
243
- const flowFiles = stage === "plan"
244
- ? [".memory-bank/dd-flow/plan.md"]
245
- : [".memory-bank/dd-flow/code.md", ".memory-bank/dd-flow/mb-sdlc/code/implement.md"];
246
- const instructions = flowFiles.map((file) => {
247
- const absolute = path.join(projectRoot, file);
248
- return fs.existsSync(absolute) ? `### ${file}\n\n${fs.readFileSync(absolute, "utf8").trim()}` : `### ${file}\n\n(unavailable)`;
366
+ const instructions = stageInstructionSources(context, projectRoot, current.run.flow_kind, stage).map(({ label, absolute }) => {
367
+ return fs.existsSync(absolute) ? `### ${label}\n\n${fs.readFileSync(absolute, "utf8").trim()}` : `### ${label}\n\n(unavailable)`;
249
368
  }).join("\n\n");
250
369
  const protocol = safeProtocolState(context, current.run.project_id, current.run.subject.id);
370
+ const requiredContext = stageInstructionSources(context, projectRoot, current.run.flow_kind, stage)
371
+ .map(({ label }) => `- Read: \`${label}\``).join("\n");
251
372
  return [
252
- "# Stage Prompt",
253
- "",
254
- "## Identity",
373
+ "<stage_identity>",
374
+ "# Stage work packet",
255
375
  `- Project: ${project.id}`,
256
376
  `- Run: ${current.run.id}`,
257
377
  `- Protocol: ${current.run.subject.id}`,
258
378
  `- Stage: ${stage}`,
259
379
  `- Attempt: ${current.index.stage_runs?.find((item) => item.stage === stage)?.attempt ?? "try-001"}`,
380
+ "</stage_identity>",
260
381
  "",
261
- "## Runtime",
382
+ "<authoritative_runtime_facts>",
383
+ "These facts were collected by dd-flow. Trust them; do not re-run CLI, Git, compatibility, or permission discovery unless the semantic work changes the relevant state.",
262
384
  `- Project root: ${projectRoot}`,
263
385
  `- Workspace root: ${current.run.workspace_root}`,
264
386
  `- Run home: ${runHome}`,
265
387
  `- Stage root: ${path.join(runHome, dir)}`,
266
388
  `- Git branch: ${current.index.execution?.git?.branch ?? "unknown"}`,
267
389
  `- Git head: ${current.index.execution?.git?.head ?? "unknown"}`,
390
+ "</authoritative_runtime_facts>",
391
+ "",
392
+ "<preflight>",
393
+ `- Permission probe: ${String(preflight.ok === true ? "passed" : "failed")}.`,
394
+ "- CLI compatibility was checked before this state-changing command.",
395
+ "</preflight>",
268
396
  "",
269
- "## Intake",
397
+ "<task_intake>",
270
398
  "Raw intake and provider telemetry stay under the RUN home. Promote only durable decisions to the protocol summary.",
271
399
  `- Protocol next action: ${protocol?.next_action ?? "not recorded"}`,
272
400
  `- Previous stage: ${before.index.stage_runs?.map((item) => `${item.stage}:${item.status}`).join(", ") || "none"}`,
401
+ "</task_intake>",
273
402
  "",
274
- "## Instructions",
403
+ "<applicable_instructions>",
275
404
  instructions,
405
+ "</applicable_instructions>",
276
406
  "",
277
- "## CLI",
278
- "Use the project-compatible dd-flow CLI for state transitions and artifact registration. Keep paths inside the selected workspace and RUN home.",
407
+ "<required_context>",
408
+ requiredContext || "- No additional stage-specific source is required.",
409
+ "</required_context>",
279
410
  "",
280
- "## Boundaries",
281
- "Do not use raw Git as a workspace manager, unverified tools, PATH/latest fallbacks, recursive unrelated validation, or model-supplied session identity.",
411
+ "<work_contract>",
412
+ "Perform only the semantic work for this stage. Write current-stage artifacts to `@stage`; `@stage/try-NNN` is read-only archive history.",
413
+ "Do not repeat deterministic preflight, global CLI help, runtime/Git inspection, session registration, report rendering, summary generation, or state transition work.",
414
+ "</work_contract>",
282
415
  "",
283
- "## Completion Contract",
284
- `Finish with: dd-flow stage finish ${current.run.id} --stage ${stage} --project-root <project-root> --semantic-file @stage/stage-input.json --json`,
416
+ "<completion_contract>",
417
+ `Write semantic output to \`@stage/stage-input.json\`, then run: \`dd-flow stage finish ${current.run.id} --stage ${stage} --outcome done --project-root ${JSON.stringify(projectRoot)} --json\`.`,
285
418
  "The CLI must generate validated JSON, Markdown, HTML and protocol-summary evidence.",
419
+ "</completion_contract>",
286
420
  ].join("\n");
287
421
  }
422
+ function stageInstructionSources(context, projectRoot, flowKind, stage) {
423
+ if (flowKind === "mb-upgrade") {
424
+ const canon = resolveCanonRoot(context);
425
+ if (!canon.ok || !canon.canon) {
426
+ throw new AppError("canon_unavailable", "mb-upgrade stage prompt requires the canonical Memory Bank", 1, {
427
+ blockers: canon.blockers,
428
+ bootstrap: canon.bootstrap
429
+ });
430
+ }
431
+ const flowRoot = canon.canon.flow_root;
432
+ const stageSupport = {
433
+ "diff-analysis": "mb-upgrade/diff-analysis.md",
434
+ lint: "mb-upgrade/lint-verification.md",
435
+ review: "mb-upgrade/review/index.md",
436
+ merge: "mb-upgrade/merge.md"
437
+ };
438
+ return ["mb-upgrade.md", "mb-upgrade/index.md", stageSupport[stage]]
439
+ .filter((file) => Boolean(file))
440
+ .map((file) => ({ label: `canonical:${file}`, absolute: path.join(flowRoot, file) }));
441
+ }
442
+ const filesByStage = {
443
+ specify: [".memory-bank/dd-flow/mb-sdlc/specify/stage.md"],
444
+ plan: [".memory-bank/dd-flow/plan.md"],
445
+ code: [".memory-bank/dd-flow/code.md", ".memory-bank/dd-flow/mb-sdlc/code/implement.md"],
446
+ implementation: [".memory-bank/dd-flow/code.md", ".memory-bank/dd-flow/mb-sdlc/code/implement.md"],
447
+ readiness: [".memory-bank/dd-flow/mb-sdlc/code/readiness.md"],
448
+ merge: [".memory-bank/dd-flow/merge.md"]
449
+ };
450
+ const files = filesByStage[stage] ?? [".memory-bank/dd-flow/mb-sdlc/specify/stage.md"];
451
+ return files.map((file) => ({ label: file, absolute: path.join(projectRoot, file) }));
452
+ }
288
453
  function safeProtocolState(context, projectId, protocolId) {
289
454
  try {
290
455
  const protocol = requireProtocol(context, protocolId, projectId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {