@deksden-com/dd-flow-cli 0.4.2 → 0.5.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 (49) hide show
  1. package/CHANGELOG.md +27 -4
  2. package/dist/build-info.json +6 -6
  3. package/dist/cli/help.js +14 -3
  4. package/dist/cli/run-cli.js +32 -16
  5. package/dist/domain/flow-contract.js +81 -2
  6. package/dist/domain/validation.js +56 -28
  7. package/dist/protocol/local-files.js +1 -16
  8. package/dist/schemas/code-stage-report.schema.json +2 -2
  9. package/dist/schemas/flow-contract.schema.json +150 -94
  10. package/dist/schemas/flow-run.schema.json +129 -23
  11. package/dist/schemas/mb-upgrade-review-data.schema.json +2 -2
  12. package/dist/schemas/memorybank-permissions-preflight.schema.json +13 -73
  13. package/dist/schemas/merge-stage-report.schema.json +2 -2
  14. package/dist/schemas/plan-stage-report.schema.json +38 -335
  15. package/dist/schemas/project-flow-pack-manifest.schema.json +4 -4
  16. package/dist/schemas/protocol-plan.schema.json +197 -0
  17. package/dist/schemas/release-impact.schema.json +9 -5
  18. package/dist/schemas/session-usage.schema.json +16 -0
  19. package/dist/schemas/stage-finish-input.schema.json +20 -0
  20. package/dist/schemas/stage-prompt.schema.json +31 -0
  21. package/dist/schemas/stage-report.schema.json +20 -0
  22. package/dist/schemas/stage-start-response.schema.json +29 -0
  23. package/dist/schemas/timeline-event.schema.json +29 -0
  24. package/dist/schemas/worktrunk-workspace.schema.json +19 -0
  25. package/dist/services/branch-context.js +9 -4
  26. package/dist/services/dashboard.js +51 -26
  27. package/dist/services/engines.js +84 -18
  28. package/dist/services/hooks.js +80 -246
  29. package/dist/services/memory-permissions.js +77 -69
  30. package/dist/services/plan-runtime.js +124 -0
  31. package/dist/services/plans.js +22 -84
  32. package/dist/services/projects.js +2 -1
  33. package/dist/services/prompts.js +26 -21
  34. package/dist/services/protocols.js +29 -25
  35. package/dist/services/run-projection.js +77 -11
  36. package/dist/services/runs.js +95 -61
  37. package/dist/services/schema-validation.js +168 -7
  38. package/dist/services/sessions.js +132 -68
  39. package/dist/services/stage-lifecycle.js +572 -0
  40. package/dist/services/tooling.js +285 -0
  41. package/dist/services/usage.js +183 -30
  42. package/dist/services/version-status.js +1 -1
  43. package/dist/services/worktrees.js +88 -39
  44. package/dist/storage/database.js +72 -30
  45. package/dist/storage/paths.js +0 -9
  46. package/package.json +14 -13
  47. package/tools/worktrunk-manifest.json +34 -0
  48. package/dist/schemas/flow-run-index-v3.schema.json +0 -203
  49. package/dist/schemas/flow-run-index.schema.json +0 -175
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+
3
9
  ## 0.4.2
4
10
 
5
11
  ### Patch Changes
@@ -8,19 +14,36 @@
8
14
 
9
15
  ## 0.4.1
10
16
 
17
+ > Release-accounting correction: the entries below were already present in
18
+ > the published `0.4.1` tarball built from `6f85c04`, but their pending
19
+ > Changesets had not been consumed.
20
+
21
+ ### Minor Changes
22
+
23
+ - Add `dd-flow prompt render` for deterministic RUN-local worker prompts,
24
+ including validated semantic context and checked `run://` reads.
25
+
11
26
  ### Patch Changes
12
27
 
13
28
  - 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
29
 
15
30
  - Include production dependencies in installed engine snapshots, so a routed CLI works outside the source checkout.
16
31
 
17
- ## Unreleased
32
+ - Expand `dd-flow run timeline` with stage durations, session roster, usage
33
+ coverage, artifact references and repeatable `--hide` controls.
18
34
 
19
- ### Patch Changes
35
+ - Update canonical Memory Bank resolution for the `mb-upgrade` `05-review`
36
+ stage instead of requiring a separate top-level `mb-upgrade-review.md`.
37
+
38
+ - Make typed `PRT-*` and `RUN-*` identity project-scoped and monotonic across
39
+ registered projects, migrate legacy global keys without renumbering, and
40
+ ignore legacy date-based protocol ids during allocation.
20
41
 
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.
42
+ - Add source-aware flow-flag snapshots, provenance revisions, grouped session
43
+ coverage and privacy-safe timing/usage projections.
22
44
 
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.
45
+ - Validate the complete flow-contract catalog before mutable RUN/protocol
46
+ starts and expose exact read-only diagnostics for invalid catalogs.
24
47
 
25
48
  ## 0.4.0
26
49
 
@@ -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.5.0",
4
+ "cli_commit": "64b75acbb2e62c9e80eb29c501f5f3481f86cf20",
5
+ "built_at": "2026-08-11T10:20:58.455Z",
6
6
  "built_with_canon": {
7
- "version": "2.17.0",
8
- "commit": "2fe1fdc33a0ac43ac223c973c7c05eef0066213a",
9
- "flow_contract": "dd-flow-canonical-2026-06",
7
+ "version": "3.0.0",
8
+ "commit": "1e8c39078ed7a34adde93315a9cdcf2bbfc15374",
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.
@@ -150,13 +151,23 @@ 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 finish <RUN-ID|RUN-short-id> --project-root <root> --stage <name> [--status done|blocked|failed] [--semantic-file <RUN-local-json>] --json
169
+
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.`
160
171
  ],
161
172
  [
162
173
  "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,28 @@ 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
+ return startStage(context, {
545
+ projectRoot: requiredOption(parsed, "project-root"),
546
+ runId: requiredPosition(parsed, 0, "run-id"),
547
+ stage: requiredOption(parsed, "stage"),
548
+ ...(dir ? { dir } : {})
549
+ });
550
+ }
551
+ if (family === "stage" && command === "finish") {
552
+ const dir = optionalOption(parsed, "dir");
553
+ const status = optionalOption(parsed, "status");
554
+ const semanticFile = optionalOption(parsed, "semantic-file") ?? optionalOption(parsed, "data");
555
+ return finishStage(context, {
556
+ projectRoot: requiredOption(parsed, "project-root"),
557
+ runId: requiredPosition(parsed, 0, "run-id"),
558
+ stage: requiredOption(parsed, "stage"),
559
+ ...(dir ? { dir } : {}),
560
+ ...(status ? { status } : {}),
561
+ ...(semanticFile ? { semanticFile } : {})
562
+ });
563
+ }
548
564
  if (family === "lane") {
549
565
  return dispatchLane(context, command, parsed);
550
566
  }
@@ -1313,7 +1329,7 @@ function dispatchCodexHook(context, parsed, stdin) {
1313
1329
  if (action === "handle") {
1314
1330
  return handleCodexHook(context, {
1315
1331
  event: requiredOption(parsed, "event"),
1316
- projectRoot: requiredOption(parsed, "project-root"),
1332
+ projectRoot: optionalOption(parsed, "project-root"),
1317
1333
  stdin
1318
1334
  });
1319
1335
  }
@@ -1591,9 +1607,6 @@ function projectRootForMutation(context, args, result, resolvedScope) {
1591
1607
  const transitionParsed = parseArgs([command ?? "", ...rest]);
1592
1608
  return protocolProjectRoot(context, requiredPosition(transitionParsed, 0, "protocol-id"));
1593
1609
  }
1594
- if (family === "plan" && command === "set") {
1595
- return protocolProjectRoot(context, requiredPosition(parsed, 0, "protocol-id"));
1596
- }
1597
1610
  if (family === "plan" && command === "item") {
1598
1611
  return protocolProjectRoot(context, requiredPosition(parsed, 1, "protocol-id"));
1599
1612
  }
@@ -1624,11 +1637,14 @@ function projectRootForMutation(context, args, result, resolvedScope) {
1624
1637
  if (family === "run" && ["start", "attach-stage", "complete-stage", "complete", "flags"].includes(command ?? "")) {
1625
1638
  return requiredOption(parsed, "project-root");
1626
1639
  }
1640
+ if (family === "stage" && ["start", "finish"].includes(command ?? "")) {
1641
+ return requiredOption(parsed, "project-root");
1642
+ }
1627
1643
  if (family === "worktree" && ["create", "bootstrap", "close"].includes(command ?? "")) {
1628
1644
  return protocolProjectRoot(context, requiredOption(parsed, "protocol-id"));
1629
1645
  }
1630
1646
  if (family === "codex" && command === "hook" && requiredPosition(parsed, 0, "codex hook action") === "handle") {
1631
- return requiredOption(parsed, "project-root");
1647
+ return optionalOption(parsed, "project-root");
1632
1648
  }
1633
1649
  if (family === "codex" && command === "hooks") {
1634
1650
  const action = requiredPosition(parsed, 0, "codex hooks action");
@@ -1671,7 +1687,7 @@ function isScopedProtocolMutation(family, command) {
1671
1687
  if (family === "protocol")
1672
1688
  return ["transition", "sync-from-run", "ready-for-merge", "cancel"].includes(command ?? "");
1673
1689
  if (family === "plan")
1674
- return command === "set" || command === "item";
1690
+ return command === "item";
1675
1691
  if (family === "worktree")
1676
1692
  return ["create", "bootstrap", "close"].includes(command ?? "");
1677
1693
  if (family === "merge-queue")
@@ -360,9 +360,12 @@ function numberValue(value) {
360
360
  return typeof value === "number" && Number.isInteger(value) ? value : null;
361
361
  }
362
362
  export function normalizeFlowContract(value) {
363
- const id = requireString(value.id, "/id");
364
363
  const version = requireNumber(value.version, "/version");
365
- if (version > 3)
364
+ const id = requireString(value.id, "/id");
365
+ if (version === 4 || version === 5) {
366
+ return normalizeCanonicalRuntimeContract(value, id, version);
367
+ }
368
+ if (version > 5)
366
369
  fail("/version", `unsupported flow-contract version: ${version}`);
367
370
  if (version === 3 && !/^dd-flow-canonical-[0-9]{4}-[0-9]{2}$/.test(id)) {
368
371
  fail("/id", "must identify a canonical version 3 flow contract");
@@ -396,6 +399,82 @@ export function normalizeFlowContract(value) {
396
399
  ...(flowFlags ? { flow_flags: flowFlags } : {})
397
400
  };
398
401
  }
402
+ /**
403
+ * Canonical 2026-08 deliberately owns runtime mechanics rather than the
404
+ * protocol transition graph. Keep the latter as a CLI-owned derived adapter
405
+ * so existing protocol services can make the same guarded decisions while
406
+ * the project file remains the canonical v4/v5 contract.
407
+ */
408
+ function normalizeCanonicalRuntimeContract(value, id, version) {
409
+ if (id !== "dd-flow-canonical-2026-08") {
410
+ fail("/id", "must identify the canonical 2026-08 runtime contract");
411
+ }
412
+ const stages = {
413
+ registered: { terminal: false },
414
+ priming: { terminal: false },
415
+ specify: { terminal: false },
416
+ plan: { terminal: false },
417
+ interactive: { terminal: false },
418
+ consolidation: { terminal: false },
419
+ hardening: { terminal: false },
420
+ implementation: { terminal: false },
421
+ readiness: { terminal: false },
422
+ ready_for_merge: { terminal: false },
423
+ queued_for_merge: { terminal: false },
424
+ integration: { terminal: false },
425
+ cancelled: { terminal: true },
426
+ closed: { terminal: true },
427
+ blocked: { terminal: false },
428
+ waiting_for_user: { terminal: false }
429
+ };
430
+ const legacyAliases = {
431
+ f: "priming",
432
+ prime_intake: "specify",
433
+ g0: "specify",
434
+ m1: "readiness",
435
+ m2: "integration",
436
+ prime: "priming"
437
+ };
438
+ const transitions = {
439
+ registered: ["priming", "specify", "interactive", "blocked", "waiting_for_user"],
440
+ priming: ["specify", "interactive", "closed", "blocked", "waiting_for_user"],
441
+ specify: ["plan", "implementation", "blocked", "waiting_for_user"],
442
+ plan: ["implementation", "blocked", "waiting_for_user"],
443
+ interactive: ["consolidation", "blocked", "waiting_for_user"],
444
+ consolidation: ["hardening", "blocked", "waiting_for_user"],
445
+ hardening: ["readiness", "blocked", "waiting_for_user"],
446
+ implementation: ["readiness", "blocked", "waiting_for_user"],
447
+ readiness: ["ready_for_merge", "closed", "blocked", "waiting_for_user"],
448
+ ready_for_merge: ["queued_for_merge", "integration", "closed", "blocked", "waiting_for_user"],
449
+ queued_for_merge: ["integration", "blocked", "waiting_for_user"],
450
+ integration: ["closed", "blocked", "waiting_for_user"],
451
+ cancelled: [],
452
+ closed: [],
453
+ blocked: ["specify", "plan", "interactive", "consolidation", "hardening", "implementation", "readiness", "ready_for_merge", "closed"],
454
+ waiting_for_user: ["specify", "plan", "interactive", "consolidation", "hardening", "implementation", "readiness", "ready_for_merge", "blocked", "closed"]
455
+ };
456
+ const entrypoints = recordValue(value.stage_lifecycle);
457
+ const autoPolicy = recordValue(value.validation);
458
+ return {
459
+ id,
460
+ version,
461
+ capabilities: [
462
+ "flow_run_snapshot.v1",
463
+ "session_coverage_units.v1",
464
+ "privacy_safe_observability.v1",
465
+ ...(version === 5 ? ["canonical_protocol_plan.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"]) {
@@ -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;
@@ -124,10 +124,10 @@
124
124
  "run": {
125
125
  "type": "object",
126
126
  "additionalProperties": true,
127
- "required": ["run_id", "run_index"],
127
+ "required": ["run_id", "run_state"],
128
128
  "properties": {
129
129
  "run_id": { "type": "string", "pattern": "^RUN-[0-9]{3}-[a-z0-9]+(?:-[a-z0-9]+)*$" },
130
- "run_index": { "type": "string", "minLength": 1 }
130
+ "run_state": { "type": "string", "minLength": 1 }
131
131
  }
132
132
  },
133
133
  "stage": {