@deksden-com/dd-flow-cli 0.4.2 → 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.
Files changed (53) hide show
  1. package/CHANGELOG.md +33 -4
  2. package/README.md +2 -2
  3. package/dist/build-info.json +6 -6
  4. package/dist/cli/help.js +16 -4
  5. package/dist/cli/run-cli.js +42 -17
  6. package/dist/domain/flow-contract.js +82 -3
  7. package/dist/domain/session-coverage.js +88 -0
  8. package/dist/domain/validation.js +56 -28
  9. package/dist/protocol/local-files.js +1 -16
  10. package/dist/schemas/code-stage-report.schema.json +2 -2
  11. package/dist/schemas/flow-contract.schema.json +152 -94
  12. package/dist/schemas/flow-run.schema.json +129 -23
  13. package/dist/schemas/mb-upgrade-review-data.schema.json +2 -2
  14. package/dist/schemas/memorybank-permissions-preflight.schema.json +13 -73
  15. package/dist/schemas/merge-stage-report.schema.json +2 -2
  16. package/dist/schemas/plan-stage-report.schema.json +38 -335
  17. package/dist/schemas/project-flow-pack-manifest.schema.json +4 -4
  18. package/dist/schemas/protocol-plan.schema.json +197 -0
  19. package/dist/schemas/release-impact.schema.json +9 -5
  20. package/dist/schemas/session-usage.schema.json +16 -0
  21. package/dist/schemas/stage-finish-input.schema.json +20 -0
  22. package/dist/schemas/stage-prompt.schema.json +35 -0
  23. package/dist/schemas/stage-report.schema.json +20 -0
  24. package/dist/schemas/stage-start-response.schema.json +30 -0
  25. package/dist/schemas/timeline-event.schema.json +29 -0
  26. package/dist/schemas/worktrunk-workspace.schema.json +19 -0
  27. package/dist/services/branch-context.js +9 -4
  28. package/dist/services/cleanup.js +77 -0
  29. package/dist/services/dashboard.js +51 -26
  30. package/dist/services/engines.js +84 -18
  31. package/dist/services/hooks.js +101 -250
  32. package/dist/services/memory-permissions.js +77 -69
  33. package/dist/services/migrations.js +1 -1
  34. package/dist/services/plan-runtime.js +124 -0
  35. package/dist/services/plans.js +22 -84
  36. package/dist/services/projects.js +2 -1
  37. package/dist/services/prompts.js +26 -21
  38. package/dist/services/protocols.js +29 -25
  39. package/dist/services/run-projection.js +93 -13
  40. package/dist/services/runs.js +128 -61
  41. package/dist/services/schema-validation.js +168 -7
  42. package/dist/services/sessions.js +97 -73
  43. package/dist/services/stage-lifecycle.js +737 -0
  44. package/dist/services/tooling.js +285 -0
  45. package/dist/services/usage.js +183 -30
  46. package/dist/services/version-status.js +1 -1
  47. package/dist/services/worktrees.js +88 -39
  48. package/dist/storage/database.js +72 -30
  49. package/dist/storage/paths.js +0 -9
  50. package/package.json +14 -13
  51. package/tools/worktrunk-manifest.json +34 -0
  52. package/dist/schemas/flow-run-index-v3.schema.json +0 -203
  53. package/dist/schemas/flow-run-index.schema.json +0 -175
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
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
+
9
+ ## 0.5.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 43bf9c2: Cut over runtime execution to the canonical SQLite-backed flow contract with a single PreToolUse binding, canonical plan progress, resumable worker coverage, and validated `stage-report.json`, `stage-report.md`, and `stage-report.html` artifacts.
14
+
3
15
  ## 0.4.2
4
16
 
5
17
  ### Patch Changes
@@ -8,19 +20,36 @@
8
20
 
9
21
  ## 0.4.1
10
22
 
23
+ > Release-accounting correction: the entries below were already present in
24
+ > the published `0.4.1` tarball built from `6f85c04`, but their pending
25
+ > Changesets had not been consumed.
26
+
27
+ ### Minor Changes
28
+
29
+ - Add `dd-flow prompt render` for deterministic RUN-local worker prompts,
30
+ including validated semantic context and checked `run://` reads.
31
+
11
32
  ### Patch Changes
12
33
 
13
34
  - Preserve `--project-root` through engine routing, compatibility preflight, protocol services, nested lookups, merge-queue commands, and post-mutation dashboard refresh. Duplicate rootless protocol ids fail closed without side effects, while successful mutations remain successful when an optional dashboard projection fails.
14
35
 
15
36
  - Include production dependencies in installed engine snapshots, so a routed CLI works outside the source checkout.
16
37
 
17
- ## Unreleased
38
+ - Expand `dd-flow run timeline` with stage durations, session roster, usage
39
+ coverage, artifact references and repeatable `--hide` controls.
18
40
 
19
- ### Patch Changes
41
+ - Update canonical Memory Bank resolution for the `mb-upgrade` `05-review`
42
+ stage instead of requiring a separate top-level `mb-upgrade-review.md`.
43
+
44
+ - Make typed `PRT-*` and `RUN-*` identity project-scoped and monotonic across
45
+ registered projects, migrate legacy global keys without renumbering, and
46
+ ignore legacy date-based protocol ids during allocation.
20
47
 
21
- - Make `PRT-*` and `RUN-*` allocation, persistence and resolution project-scoped. SQLite migrates legacy global primary keys without renumbering ids, including databases whose columns were appended in historical order; duplicate full ids may coexist across projects, and ambiguous context-free protocol lookup fails closed with project candidates.
48
+ - Add source-aware flow-flag snapshots, provenance revisions, grouped session
49
+ coverage and privacy-safe timing/usage projections.
22
50
 
23
- - Allow `prompt render` to preserve validated RUN-local `required_read` and discovery references through `run://<relative-path>`, while keeping write scopes project-local and rejecting traversal or environment-secret paths.
51
+ - Validate the complete flow-contract catalog before mutable RUN/protocol
52
+ starts and expose exact read-only diagnostics for invalid catalogs.
24
53
 
25
54
  ## 0.4.0
26
55
 
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,12 +1,12 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.4.2",
4
- "cli_commit": "5443eeea84ccea3740546a8b0bc3867161ac340b",
5
- "built_at": "2026-08-09T09:53:08.521Z",
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": "2.17.0",
8
- "commit": "2fe1fdc33a0ac43ac223c973c7c05eef0066213a",
9
- "flow_contract": "dd-flow-canonical-2026-06",
7
+ "version": "3.1.0",
8
+ "commit": "c201167b896a039ff4881d6d816b9735fba2f799",
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",
12
12
  "flow_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank/dd-flow",
package/dist/cli/help.js CHANGED
@@ -21,8 +21,9 @@ Core commands:
21
21
  migration impact/plan/report/verify Assess release impact and prepare mb-upgrade migration evidence.
22
22
  cleanup scan/apply Detect and repair stale local runtime state.
23
23
  run start/status/list/timeline/usage/flags Track concrete flow executions, timing, source-aware usage and RUN-local flow flags.
24
+ stage start/finish Run the canonical two-command stage lifecycle and generate evidence.
24
25
  id next Preview next typed ids for protocol or run allocation.
25
- plan set/status/item Attach and update protocol plans.
26
+ plan status/item Inspect canonical plans and update SQLite progress.
26
27
  prompt render Render one bounded, RUN-local worker launch prompt.
27
28
  lane workspace/lock/status Manage shared workspace lanes and leases.
28
29
  merge status/one-shot/bundle Inspect, claim, or complete merge work from the current session.
@@ -142,7 +143,7 @@ Usage:
142
143
  dd-flow id next --type protocol|run --project-root <root> --slug <slug> --json
143
144
  dd-flow run status <RUN-ID|RUN-short-id> --project-root <root> --json
144
145
  dd-flow run list --project-root <root> --json
145
- 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
146
147
  dd-flow run usage <RUN-ID|RUN-short-id> --project-root <root> [--group-by session|role|stage|aspect|plan-item] --json
147
148
  dd-flow run flags status <RUN-ID|RUN-short-id> --project-root <root> --json
148
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
@@ -150,13 +151,24 @@ Usage:
150
151
  dd-flow run complete-stage <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --status <status> [--stage-report <path>] [--data <path>] [--data-schema-id <id>] [--report <path>] [--alias <path>] --json
151
152
  dd-flow run complete <RUN-ID|RUN-short-id> --project-root <root> --status done|blocked|cancelled|failed [--verdict <text>] [--next-action <text>] --json
152
153
 
153
- RUN-* is the execution envelope for one concrete flow launch. Semantic truth remains in protocol, experiment, DEF, scenario, evidence, and Memory Bank documents. New run artifacts are stored under DD_FLOW_HOME/projects/<PRJ-ID-slug>/runs/<RUN-ID-slug>/ with run.json, run-index.json and an append-only timeline together. Timeline is the compact full run report: summary is always present, while --hide may suppress bulky sections. The run usage command reconciles registered local Codex transcript counters before reporting; unavailable data is explicit rather than zero.
154
+ RUN-* is the execution envelope for one concrete flow launch. Semantic truth remains in protocol, experiment, DEF, scenario, evidence, and Memory Bank documents. New run artifacts are stored under DD_FLOW_HOME/projects/<PRJ-ID-slug>/runs/<RUN-ID-slug>/ with one run.json projection and an append-only timeline.jsonl. Timeline is the compact full run report: summary is always present, while --hide may suppress bulky sections. The run usage command reconciles registered local Codex transcript counters before reporting; unavailable data is explicit rather than zero.
154
155
 
155
156
  Examples:
156
157
  dd-flow run start --project-root "$PWD" --flow-kind mb_sdlc --subject-type protocol --subject-id PRT-001-demo --slug demo --json
157
158
  dd-flow id next --type run --project-root "$PWD" --slug demo --json
158
159
  dd-flow run attach-stage RUN-001 --project-root "$PWD" --stage plan --dir 01-plan --status running --json
159
- dd-flow run complete-stage RUN-001 --project-root "$PWD" --stage plan --status done --data 01-plan/stage-report.json --stage-report 01-plan/stage-report.html --report 01-plan/report.md --json`
160
+ dd-flow run complete-stage RUN-001 --project-root "$PWD" --stage plan --status done --data 01-plan/stage-report.json --stage-report 01-plan/stage-report.html --report 01-plan/stage-report.md --json`
161
+ ],
162
+ [
163
+ "stage",
164
+ `dd-flow stage - execute one bounded stage lifecycle
165
+
166
+ Usage:
167
+ dd-flow stage start <RUN-ID|RUN-short-id> --project-root <root> --stage <name> [--dir <NN-stage-slug>] --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
170
+
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.`
160
172
  ],
161
173
  [
162
174
  "id",
@@ -5,7 +5,7 @@ import { AppError, isAppError } from "../shared/errors.js";
5
5
  import { writeJson } from "../shared/json.js";
6
6
  import { archiveProject, getProjectStatus, migrateProjectIds, registerProject, resolveProject } from "../services/projects.js";
7
7
  import { cancelProtocol, getProtocolBlockers, getProtocolStatus, getReadyProtocols, implementProtocol, readyForMerge, registerProtocol, requireProtocol, syncProtocolFromRun, transitionProtocol } from "../services/protocols.js";
8
- import { blockPlanItem, completePlanItem, getPlanStatus, setProtocolPlan, skipPlanItem, startPlanItem } from "../services/plans.js";
8
+ import { blockPlanItem, completePlanItem, getPlanStatus, skipPlanItem, startPlanItem } from "../services/plans.js";
9
9
  import { cancelMergeQueueJob, claimNextMergeJob, claimMergeBundle, completeMergeBundle, completeMergeJob, failMergeBundle, failMergeJob, getMergeBundleStatus, getMergeQueueStatus, noteMergeJob, waitNextMergeJob } from "../services/merge-queue.js";
10
10
  import { getMergeWorkerStatus, oneShotMergeClaim, startMergeWorker, stopProjectMergeWorker } from "../services/merge-worker.js";
11
11
  import { cleanupApply, cleanupScan } from "../services/cleanup.js";
@@ -31,6 +31,7 @@ import { resolveProjectRoot } from "../storage/paths.js";
31
31
  import { doctorEngines, engineInfo, installCurrentEngine, listEngines, resolveEngine, resolveOperationProjectRoot, routeArgsThroughEngine } from "../services/engines.js";
32
32
  import { preflightCliCompatibility } from "../services/compatibility-preflight.js";
33
33
  import { renderWorkerPrompt } from "../services/prompts.js";
34
+ import { finishStage, startStage } from "../services/stage-lifecycle.js";
34
35
  const defaultIo = {
35
36
  stdout: process.stdout,
36
37
  stderr: process.stderr,
@@ -151,7 +152,8 @@ function progressForCommand(args) {
151
152
  family === "merge-worker" ||
152
153
  (family === "protocol" && command === "cancel") ||
153
154
  (family === "project" && command === "archive") ||
154
- family === "run") {
155
+ family === "run" ||
156
+ family === "stage") {
155
157
  return [
156
158
  { phase: "start", message: `Starting ${family} ${command}`, at: "" },
157
159
  { phase: "done", message: `${family} ${command} finished`, at: "" }
@@ -518,14 +520,6 @@ async function dispatch(args, context, io, scopeProjectRoot = null) {
518
520
  ...(reason ? { reason } : {})
519
521
  });
520
522
  }
521
- if (family === "plan" && command === "set") {
522
- const protocolId = requiredPosition(parsed, 0, "protocol-id");
523
- return setProtocolPlan(context, {
524
- protocolId,
525
- projectRoot: requiredScopeProjectRoot(scopeProjectRoot, "plan set"),
526
- file: requiredOption(parsed, "file")
527
- });
528
- }
529
523
  if (family === "plan" && command === "status") {
530
524
  const protocolId = requiredPosition(parsed, 0, "protocol-id");
531
525
  return getPlanStatus(context, { protocolId, projectRoot: requiredScopeProjectRoot(scopeProjectRoot, "plan status") });
@@ -545,6 +539,33 @@ async function dispatch(args, context, io, scopeProjectRoot = null) {
545
539
  ...(workspaceRoot ? { workspaceRoot } : {})
546
540
  });
547
541
  }
542
+ if (family === "stage" && command === "start") {
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");
548
+ return startStage(context, {
549
+ projectRoot: requiredOption(parsed, "project-root"),
550
+ ...(bootstrap ? { bootstrap: { subject: subject ?? requiredOption(parsed, "subject"), ...(intakeFile ? { intakeFile } : {}) } } : { runId: requiredPosition(parsed, 0, "run-id") }),
551
+ stage: requiredOption(parsed, "stage"),
552
+ ...(sessionId ? { sessionId } : {}),
553
+ ...(dir ? { dir } : {})
554
+ });
555
+ }
556
+ if (family === "stage" && command === "finish") {
557
+ const dir = optionalOption(parsed, "dir");
558
+ const outcome = optionalOption(parsed, "outcome") ?? optionalOption(parsed, "status");
559
+ const semanticFile = optionalOption(parsed, "semantic-file") ?? optionalOption(parsed, "data");
560
+ return finishStage(context, {
561
+ projectRoot: requiredOption(parsed, "project-root"),
562
+ runId: requiredPosition(parsed, 0, "run-id"),
563
+ stage: requiredOption(parsed, "stage"),
564
+ ...(dir ? { dir } : {}),
565
+ ...(outcome ? { outcome } : {}),
566
+ ...(semanticFile ? { semanticFile } : {})
567
+ });
568
+ }
548
569
  if (family === "lane") {
549
570
  return dispatchLane(context, command, parsed);
550
571
  }
@@ -1311,11 +1332,15 @@ function dispatchCodexHome(context, parsed) {
1311
1332
  function dispatchCodexHook(context, parsed, stdin) {
1312
1333
  const action = requiredPosition(parsed, 0, "codex hook action");
1313
1334
  if (action === "handle") {
1314
- return handleCodexHook(context, {
1335
+ const result = handleCodexHook(context, {
1315
1336
  event: requiredOption(parsed, "event"),
1316
- projectRoot: requiredOption(parsed, "project-root"),
1337
+ projectRoot: optionalOption(parsed, "project-root"),
1317
1338
  stdin
1318
1339
  });
1340
+ if (result && typeof result === "object" && !Array.isArray(result) && "hookSpecificOutput" in result) {
1341
+ return { hookSpecificOutput: result.hookSpecificOutput };
1342
+ }
1343
+ return {};
1319
1344
  }
1320
1345
  throw new AppError("usage", `Unknown codex hook action: ${action}`, 2);
1321
1346
  }
@@ -1591,9 +1616,6 @@ function projectRootForMutation(context, args, result, resolvedScope) {
1591
1616
  const transitionParsed = parseArgs([command ?? "", ...rest]);
1592
1617
  return protocolProjectRoot(context, requiredPosition(transitionParsed, 0, "protocol-id"));
1593
1618
  }
1594
- if (family === "plan" && command === "set") {
1595
- return protocolProjectRoot(context, requiredPosition(parsed, 0, "protocol-id"));
1596
- }
1597
1619
  if (family === "plan" && command === "item") {
1598
1620
  return protocolProjectRoot(context, requiredPosition(parsed, 1, "protocol-id"));
1599
1621
  }
@@ -1624,11 +1646,14 @@ function projectRootForMutation(context, args, result, resolvedScope) {
1624
1646
  if (family === "run" && ["start", "attach-stage", "complete-stage", "complete", "flags"].includes(command ?? "")) {
1625
1647
  return requiredOption(parsed, "project-root");
1626
1648
  }
1649
+ if (family === "stage" && ["start", "finish"].includes(command ?? "")) {
1650
+ return requiredOption(parsed, "project-root");
1651
+ }
1627
1652
  if (family === "worktree" && ["create", "bootstrap", "close"].includes(command ?? "")) {
1628
1653
  return protocolProjectRoot(context, requiredOption(parsed, "protocol-id"));
1629
1654
  }
1630
1655
  if (family === "codex" && command === "hook" && requiredPosition(parsed, 0, "codex hook action") === "handle") {
1631
- return requiredOption(parsed, "project-root");
1656
+ return optionalOption(parsed, "project-root");
1632
1657
  }
1633
1658
  if (family === "codex" && command === "hooks") {
1634
1659
  const action = requiredPosition(parsed, 0, "codex hooks action");
@@ -1671,7 +1696,7 @@ function isScopedProtocolMutation(family, command) {
1671
1696
  if (family === "protocol")
1672
1697
  return ["transition", "sync-from-run", "ready-for-merge", "cancel"].includes(command ?? "");
1673
1698
  if (family === "plan")
1674
- return command === "set" || command === "item";
1699
+ return command === "item";
1675
1700
  if (family === "worktree")
1676
1701
  return ["create", "bootstrap", "close"].includes(command ?? "");
1677
1702
  if (family === "merge-queue")
@@ -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"],
@@ -360,9 +359,12 @@ function numberValue(value) {
360
359
  return typeof value === "number" && Number.isInteger(value) ? value : null;
361
360
  }
362
361
  export function normalizeFlowContract(value) {
363
- const id = requireString(value.id, "/id");
364
362
  const version = requireNumber(value.version, "/version");
365
- if (version > 3)
363
+ const id = requireString(value.id, "/id");
364
+ if (version === 4 || version === 5 || version === 6) {
365
+ return normalizeCanonicalRuntimeContract(value, id, version);
366
+ }
367
+ if (version > 6)
366
368
  fail("/version", `unsupported flow-contract version: ${version}`);
367
369
  if (version === 3 && !/^dd-flow-canonical-[0-9]{4}-[0-9]{2}$/.test(id)) {
368
370
  fail("/id", "must identify a canonical version 3 flow contract");
@@ -396,6 +398,83 @@ export function normalizeFlowContract(value) {
396
398
  ...(flowFlags ? { flow_flags: flowFlags } : {})
397
399
  };
398
400
  }
401
+ /**
402
+ * Canonical 2026-08 deliberately owns runtime mechanics rather than the
403
+ * protocol transition graph. Keep the latter as a CLI-owned derived adapter
404
+ * so existing protocol services can make the same guarded decisions while
405
+ * the project file remains the canonical v4/v5 contract.
406
+ */
407
+ function normalizeCanonicalRuntimeContract(value, id, version) {
408
+ if (id !== "dd-flow-canonical-2026-08") {
409
+ fail("/id", "must identify the canonical 2026-08 runtime contract");
410
+ }
411
+ const stages = {
412
+ registered: { terminal: false },
413
+ priming: { terminal: false },
414
+ specify: { terminal: false },
415
+ plan: { terminal: false },
416
+ interactive: { terminal: false },
417
+ consolidation: { terminal: false },
418
+ hardening: { terminal: false },
419
+ implementation: { terminal: false },
420
+ readiness: { terminal: false },
421
+ ready_for_merge: { terminal: false },
422
+ queued_for_merge: { terminal: false },
423
+ integration: { terminal: false },
424
+ cancelled: { terminal: true },
425
+ closed: { terminal: true },
426
+ blocked: { terminal: false },
427
+ waiting_for_user: { terminal: false }
428
+ };
429
+ const legacyAliases = {
430
+ f: "priming",
431
+ prime_intake: "specify",
432
+ g0: "specify",
433
+ m1: "readiness",
434
+ m2: "integration",
435
+ prime: "priming"
436
+ };
437
+ const transitions = {
438
+ registered: ["priming", "specify", "interactive", "blocked", "waiting_for_user"],
439
+ priming: ["specify", "interactive", "closed", "blocked", "waiting_for_user"],
440
+ specify: ["plan", "implementation", "blocked", "waiting_for_user"],
441
+ plan: ["implementation", "blocked", "waiting_for_user"],
442
+ interactive: ["consolidation", "blocked", "waiting_for_user"],
443
+ consolidation: ["hardening", "blocked", "waiting_for_user"],
444
+ hardening: ["readiness", "blocked", "waiting_for_user"],
445
+ implementation: ["readiness", "blocked", "waiting_for_user"],
446
+ readiness: ["ready_for_merge", "closed", "blocked", "waiting_for_user"],
447
+ ready_for_merge: ["queued_for_merge", "integration", "closed", "blocked", "waiting_for_user"],
448
+ queued_for_merge: ["integration", "blocked", "waiting_for_user"],
449
+ integration: ["closed", "blocked", "waiting_for_user"],
450
+ cancelled: [],
451
+ closed: [],
452
+ blocked: ["specify", "plan", "interactive", "consolidation", "hardening", "implementation", "readiness", "ready_for_merge", "closed"],
453
+ waiting_for_user: ["specify", "plan", "interactive", "consolidation", "hardening", "implementation", "readiness", "ready_for_merge", "blocked", "closed"]
454
+ };
455
+ const entrypoints = recordValue(value.stage_lifecycle);
456
+ const autoPolicy = recordValue(value.validation);
457
+ return {
458
+ id,
459
+ version,
460
+ capabilities: [
461
+ "flow_run_snapshot.v1",
462
+ "session_coverage_units.v1",
463
+ "privacy_safe_observability.v1",
464
+ ...(version >= 5 ? ["canonical_protocol_plan.v1"] : []),
465
+ ...(version >= 6 ? ["stage_bootstrap_context_packet.v1"] : [])
466
+ ],
467
+ stages,
468
+ transitions,
469
+ readiness: { command: "ready-for-merge", allowed_from: ["readiness", "ready_for_merge"], target_stage: "ready_for_merge" },
470
+ merge_queue: { complete: { target_stage: "closed", next_action: "none" } },
471
+ defaults: { registered_next_action: "prime_or_protocol" },
472
+ legacy_aliases: legacyAliases,
473
+ ...(entrypoints ? { entrypoints } : {}),
474
+ ...(autoPolicy ? { auto_policy: autoPolicy } : {}),
475
+ route_aliases: { none: "no_plan", short: "compact_plan", full_plan: "full_plan" }
476
+ };
477
+ }
399
478
  function findProjectFlowContractPath(projectRoot) {
400
479
  const basePath = path.join(projectRoot, ".memory-bank", "dd-flow", "flow-contract");
401
480
  for (const extension of [".json", ".yaml", ".yml"]) {
@@ -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,6 +1,11 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { Ajv } from "ajv/dist/ajv.js";
1
5
  import { AppError } from "../shared/errors.js";
2
- import { allowedPlanKinds, allowedPlanStatuses, isAllowedStage } from "./contracts.js";
6
+ import { isAllowedStage } from "./contracts.js";
3
7
  import { normalizeStage } from "./flow-contract.js";
8
+ let validateCanonicalPlan;
4
9
  export function requireStage(value, contract) {
5
10
  if (!isAllowedStage(value, contract)) {
6
11
  throw new AppError("validation", `Unknown protocol stage: ${value}`, 2);
@@ -8,77 +13,100 @@ export function requireStage(value, contract) {
8
13
  return contract ? normalizeStage(value, contract) : value;
9
14
  }
10
15
  export function validatePlan(input, expectedProtocolId) {
11
- const plan = input;
12
- if (!plan || typeof plan !== "object" || Array.isArray(plan)) {
16
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
13
17
  throw new AppError("validation", "Plan must be a JSON object", 2);
14
18
  }
15
- requireString(plan.schema_version, "schema_version");
16
- requireString(plan.plan_id, "plan_id");
17
- requireString(plan.protocol_id, "protocol_id");
18
- requireString(plan.title, "title");
19
+ validateCanonicalPlanStructure(input);
20
+ const plan = input;
21
+ const errors = [];
19
22
  if (expectedProtocolId && plan.protocol_id !== expectedProtocolId) {
20
- throw new AppError("validation", "Plan protocol_id does not match command protocol id", 2, {
23
+ errors.push({
24
+ path: "/protocol_id",
25
+ message: "Plan protocol_id does not match command protocol id",
21
26
  expectedProtocolId,
22
27
  actualProtocolId: plan.protocol_id
23
28
  });
24
29
  }
25
30
  if (!Array.isArray(plan.items)) {
26
- throw new AppError("validation", "Plan items must be an array", 2);
31
+ errors.push({ path: "/items", message: "Plan items must be an array" });
32
+ throw new AppError("validation", "Plan validation failed", 2, { errors });
27
33
  }
28
34
  const ids = new Set();
29
- const items = plan.items.map((item, index) => validatePlanItem(item, index));
35
+ const items = [];
36
+ plan.items.forEach((item, index) => {
37
+ try {
38
+ items.push(validatePlanItem(item, index));
39
+ }
40
+ catch (error) {
41
+ errors.push(validationIssue(error, `/items/${index}`));
42
+ }
43
+ });
30
44
  for (const item of items) {
31
45
  if (ids.has(item.id)) {
32
- throw new AppError("validation", `Duplicate plan item id: ${item.id}`, 2);
46
+ errors.push({ path: "/items", message: `Duplicate plan item id: ${item.id}` });
33
47
  }
34
48
  ids.add(item.id);
35
49
  }
36
50
  for (const item of items) {
37
51
  for (const dep of item.depends_on) {
38
52
  if (!ids.has(dep)) {
39
- throw new AppError("validation", `Plan item ${item.id} depends on missing item ${dep}`, 2);
53
+ errors.push({ path: `/items/${item.id}/depends_on`, message: `Plan item ${item.id} depends on missing item ${dep}` });
40
54
  }
41
55
  }
42
56
  }
57
+ if (errors.length > 0) {
58
+ throw new AppError("validation", "Plan validation failed", 2, { errors });
59
+ }
43
60
  return { ...plan, items };
44
61
  }
45
- function validatePlanItem(input, index) {
62
+ function validationIssue(error, path) {
63
+ return {
64
+ path,
65
+ message: error instanceof AppError ? error.message : String(error)
66
+ };
67
+ }
68
+ export function validatePlanItem(input, index) {
46
69
  const item = input;
47
70
  if (!item || typeof item !== "object" || Array.isArray(item)) {
48
71
  throw new AppError("validation", `Plan item ${index} must be an object`, 2);
49
72
  }
50
73
  const id = requireString(item.id, `items[${index}].id`);
51
74
  const kind = requireString(item.kind, `items[${index}].kind`);
52
- if (!allowedPlanKinds.includes(kind)) {
53
- throw new AppError("validation", `Invalid plan item kind for ${id}: ${kind}`, 2);
54
- }
55
- const status = requireString(item.status, `items[${index}].status`);
56
- if (!allowedPlanStatuses.includes(status)) {
57
- throw new AppError("validation", `Invalid plan item status for ${id}: ${status}`, 2);
58
- }
59
75
  if (!Array.isArray(item.depends_on) || item.depends_on.some((dep) => typeof dep !== "string")) {
60
76
  throw new AppError("validation", `Plan item ${id} depends_on must be a string array`, 2);
61
77
  }
62
- if (!Array.isArray(item.evidence) || item.evidence.some((entry) => typeof entry !== "string")) {
63
- throw new AppError("validation", `Plan item ${id} evidence must be a string array`, 2);
64
- }
78
+ const targetStage = requireString(item.target_stage, `items[${index}].target_stage`);
65
79
  return {
80
+ ...item,
66
81
  id,
67
- kind: kind,
82
+ kind,
68
83
  title: requireString(item.title, `items[${index}].title`),
69
- status: status,
84
+ status: "pending",
70
85
  depends_on: item.depends_on,
71
- stage: requireString(item.stage, `items[${index}].stage`),
86
+ stage: targetStage === "code" ? "implementation" : targetStage,
87
+ target_stage: targetStage,
72
88
  required: requireBoolean(item.required, `items[${index}].required`),
73
89
  owner: requireString(item.owner, `items[${index}].owner`),
74
- summary: typeof item.summary === "string" ? item.summary : "",
75
- evidence: item.evidence,
90
+ summary: requireString(item.summary, `items[${index}].summary`),
91
+ evidence: [],
76
92
  ...(item.semantic_spine ? { semantic_spine: validateSemanticSpine(item.semantic_spine, id) } : {}),
77
93
  ...(item.execution_context ? { execution_context: validateExecutionContext(item.execution_context, id) } : {}),
78
94
  ...(typeof item.block_reason === "string" ? { block_reason: item.block_reason } : {}),
79
95
  ...(typeof item.user_required === "boolean" ? { user_required: item.user_required } : {})
80
96
  };
81
97
  }
98
+ function validateCanonicalPlanStructure(input) {
99
+ const validate = validateCanonicalPlan ??= new Ajv({ allErrors: true, strict: false, validateFormats: false }).compile(JSON.parse(fs.readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "schemas", "protocol-plan.schema.json"), "utf8")));
100
+ if (validate(input))
101
+ return;
102
+ throw new AppError("validation", "Plan validation failed", 2, {
103
+ errors: (validate.errors ?? []).map((error) => ({
104
+ path: error.instancePath || "/",
105
+ message: error.message ?? "schema validation failed",
106
+ keyword: error.keyword
107
+ }))
108
+ });
109
+ }
82
110
  function validateExecutionContext(input, itemId) {
83
111
  const context = requireRecord(input, `Plan item ${itemId} execution_context`);
84
112
  return {
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { schemaVersion } from "../domain/contracts.js";
4
4
  import { loadProjectFlowContract } from "../domain/flow-contract.js";
5
- import { ensureDir, planJsonPath, protocolDir, stateJsonPath } from "../storage/paths.js";
5
+ import { ensureDir, protocolDir, stateJsonPath } from "../storage/paths.js";
6
6
  export function ensureProtocolFiles(input) {
7
7
  const storageRoot = input.storageRoot ?? input.projectRoot;
8
8
  const dir = protocolDir(storageRoot, input.protocolId);
@@ -120,21 +120,6 @@ export function readStateFile(file, protocolId) {
120
120
  export function writeState(file, state) {
121
121
  fs.writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`);
122
122
  }
123
- export function readPlan(projectRoot, protocolId) {
124
- return readPlanFile(planJsonPath(projectRoot, protocolId));
125
- }
126
- export function readPlanFile(file) {
127
- if (!fs.existsSync(file)) {
128
- return undefined;
129
- }
130
- return JSON.parse(fs.readFileSync(file, "utf8"));
131
- }
132
- export function writePlan(projectRoot, protocolId, plan) {
133
- writePlanFile(planJsonPath(projectRoot, protocolId), plan);
134
- }
135
- export function writePlanFile(file, plan) {
136
- fs.writeFileSync(file, `${JSON.stringify(plan, null, 2)}\n`);
137
- }
138
123
  function readStateIfExists(file) {
139
124
  if (!fs.existsSync(file)) {
140
125
  return undefined;