@ijfw/memory-server 1.4.3 → 1.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 (233) hide show
  1. package/fixtures/truncation-corpus/_generate-corpus.js +367 -0
  2. package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
  3. package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
  4. package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
  5. package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
  6. package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
  7. package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
  8. package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
  9. package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
  10. package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
  11. package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
  12. package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
  13. package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
  14. package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
  15. package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
  16. package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
  17. package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
  18. package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
  19. package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
  20. package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
  21. package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
  22. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
  23. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
  24. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
  25. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
  26. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
  27. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
  28. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
  29. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
  30. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
  31. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
  32. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
  33. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
  34. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
  35. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
  36. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
  37. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
  38. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
  39. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
  40. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
  41. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
  42. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
  43. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
  44. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
  45. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
  46. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
  47. package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
  48. package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
  49. package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
  50. package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
  51. package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
  52. package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
  53. package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
  54. package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  55. package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
  56. package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
  57. package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
  58. package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
  59. package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
  60. package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
  61. package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
  62. package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  63. package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
  64. package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
  65. package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
  66. package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
  67. package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
  68. package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
  69. package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
  70. package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
  71. package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
  72. package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
  73. package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
  74. package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
  75. package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
  76. package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
  77. package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
  78. package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
  79. package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
  80. package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
  81. package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
  82. package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
  83. package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
  84. package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
  85. package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
  86. package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
  87. package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
  88. package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
  89. package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
  90. package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
  91. package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
  92. package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
  93. package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
  94. package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
  95. package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
  96. package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
  97. package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
  98. package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
  99. package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
  100. package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  101. package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
  102. package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
  103. package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
  104. package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
  105. package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
  106. package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
  107. package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
  108. package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
  109. package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  110. package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
  111. package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
  112. package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
  113. package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
  114. package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
  115. package/package.json +1 -1
  116. package/src/active-extension-writer.js +144 -64
  117. package/src/api-client.js +43 -5
  118. package/src/audit-roster.js +80 -5
  119. package/src/blackboard.js +298 -6
  120. package/src/cli-run.js +33 -5
  121. package/src/codex-agents.js +96 -5
  122. package/src/cost/aggregator.js +39 -9
  123. package/src/cost/pricing.js +57 -0
  124. package/src/cost/readers/gemini.js +1 -1
  125. package/src/cross-audit-chunker.js +189 -0
  126. package/src/cross-dispatcher.js +124 -21
  127. package/src/cross-orchestrator-cli.js +550 -14
  128. package/src/cross-orchestrator.js +1171 -10
  129. package/src/cross-project-search.js +195 -9
  130. package/src/dashboard-client-planning.html +273 -0
  131. package/src/dashboard-client-waves.html +304 -0
  132. package/src/dashboard-client.html +17 -2
  133. package/src/dashboard-server.js +152 -0
  134. package/src/deploy-alerts.js +150 -0
  135. package/src/design/iframe-bridge.js +242 -0
  136. package/src/design-companion.js +144 -0
  137. package/src/dispatch/checkpoint-cli.js +97 -0
  138. package/src/dispatch/colon-syntax.js +81 -1
  139. package/src/dispatch/extension.js +27 -1
  140. package/src/dispatch/registry-cli.js +4 -1
  141. package/src/dispatch/wave-cli.js +323 -0
  142. package/src/dispatch/worktree-cli.js +40 -0
  143. package/src/dispatch-planner.js +97 -2
  144. package/src/dream/runner.mjs +47 -11
  145. package/src/dream/stage-runner.js +40 -0
  146. package/src/dream/state-file.js +102 -0
  147. package/src/extension-installer.js +70 -24
  148. package/src/extension-quota-tracker.js +4 -2
  149. package/src/extension-registry.js +289 -35
  150. package/src/feedback-detector.js +26 -0
  151. package/src/fs-lock.js +259 -7
  152. package/src/gate-result.js +95 -1
  153. package/src/hero-line.js +86 -5
  154. package/src/intent-router.js +35 -0
  155. package/src/lib/a11y-contract.js +117 -0
  156. package/src/lib/atomic-io.js +29 -8
  157. package/src/lib/cache-keepalive.js +150 -0
  158. package/src/lib/jsonl-rotation.js +104 -0
  159. package/src/lib/lighthouse-pillar.js +121 -0
  160. package/src/lib/llm-call.js +121 -0
  161. package/src/lib/playwright-baseline.js +205 -0
  162. package/src/lib/rekor-bridge.js +221 -0
  163. package/src/lib/repo-map.js +392 -0
  164. package/src/lib/shasum-verify.js +164 -0
  165. package/src/lib/sketches-gc.js +132 -0
  166. package/src/lib/tmp-suffix.js +62 -0
  167. package/src/lib/ui-review-runner.js +554 -0
  168. package/src/lib/uispec-drift.js +301 -0
  169. package/src/lib/uispec-intake.js +381 -0
  170. package/src/lib/worktree-guards.js +118 -0
  171. package/src/lib/worktree-recovery.js +100 -0
  172. package/src/memory/auto-linker.js +152 -0
  173. package/src/memory/benchmark.js +498 -0
  174. package/src/memory/dedup.js +126 -0
  175. package/src/memory/embedding-cache.js +136 -0
  176. package/src/memory/fact-extractor.js +168 -0
  177. package/src/memory/fts5.js +65 -1
  178. package/src/memory/migrations/004-bitemporal.js +91 -0
  179. package/src/memory/migrations/005-vector-cache.js +61 -0
  180. package/src/memory/migrations/006-obsidian-graph.js +46 -0
  181. package/src/memory/migrations/007-skill-telemetry.js +24 -0
  182. package/src/memory/migrations/008-write-provenance.js +41 -0
  183. package/src/memory/obsidian-parser.js +91 -0
  184. package/src/memory/query-dataview.js +86 -0
  185. package/src/memory/search.js +10 -0
  186. package/src/memory/temporal.js +529 -0
  187. package/src/memory/tokenize.js +10 -0
  188. package/src/memory-facts-handler.js +37 -0
  189. package/src/memory-feedback.js +260 -2
  190. package/src/model-refresh.js +292 -0
  191. package/src/observability/cost-anomaly.js +166 -0
  192. package/src/observability/evaluator-checkpoint-contract.js +117 -0
  193. package/src/observability/trace-id.js +163 -0
  194. package/src/orchestrator/agents-md-blackboard.js +152 -0
  195. package/src/orchestrator/checkpoint-contract.md +140 -0
  196. package/src/orchestrator/debug-trident.js +570 -0
  197. package/src/orchestrator/merge-block-aware.js +350 -0
  198. package/src/orchestrator/plan-checker.js +475 -0
  199. package/src/orchestrator/post-done-runner.js +249 -0
  200. package/src/orchestrator/review.js +136 -0
  201. package/src/orchestrator/runtime-loop.js +430 -0
  202. package/src/orchestrator/skill-telemetry-sink.js +29 -0
  203. package/src/orchestrator/skill-telemetry.js +37 -0
  204. package/src/orchestrator/state-events.js +459 -0
  205. package/src/orchestrator/state-sdk.js +1764 -0
  206. package/src/orchestrator/status-protocol.js +235 -0
  207. package/src/orchestrator/subagent-telemetry.js +452 -0
  208. package/src/orchestrator/termination.js +160 -0
  209. package/src/orchestrator/verification-gate.js +281 -0
  210. package/src/orchestrator/wave-state.js +564 -0
  211. package/src/orchestrator/worktree-provision.js +77 -0
  212. package/src/override-use-registry.js +111 -5
  213. package/src/receipts.js +36 -4
  214. package/src/recovery/checkpoint.js +56 -3
  215. package/src/recovery/code-fixer.js +656 -0
  216. package/src/recovery/truncation.js +317 -0
  217. package/src/redactor.js +75 -6
  218. package/src/runtime-mediator.js +15 -0
  219. package/src/sanitizer.js +10 -0
  220. package/src/search-hybrid.js +139 -0
  221. package/src/server.js +603 -59
  222. package/src/swarm/worktree.js +27 -4
  223. package/src/swarm-config.js +113 -12
  224. package/src/team/domain-templates/book.json +51 -0
  225. package/src/team/domain-templates/business.json +41 -0
  226. package/src/team/domain-templates/content.json +50 -0
  227. package/src/team/domain-templates/design.json +44 -0
  228. package/src/team/domain-templates/research.json +41 -0
  229. package/src/team/domain-templates/software.json +40 -0
  230. package/src/team/generator.js +278 -3
  231. package/src/team/modify.js +203 -0
  232. package/src/team/schemas.js +48 -0
  233. package/src/update-apply.js +19 -3
@@ -0,0 +1,475 @@
1
+ /**
2
+ * plan-checker.js — v1.5.0-major W12-D C14: pre-dispatch plan validation gate.
3
+ *
4
+ * Pure-function library called by the existing `ijfw_state` MCP tool routing
5
+ * (no new MCP tool — cap is full at 12/12; v1.5.0 T13 absorbed the retired
6
+ * `ijfw_subagent_post_done` tool as the `subagent.post-done` verb). Also
7
+ * surfaced in the `ijfw-plan-check` skill as the deterministic pre-dispatch
8
+ * gate.
9
+ *
10
+ * Distilled from /Users/seandonahoe/.claude/agents/gsd-plan-checker.md — extracts
11
+ * the mechanically-checkable rules (the prose-reasoning ones stay in the skill).
12
+ *
13
+ * No I/O, no network — operates on plan text passed in by caller.
14
+ *
15
+ * v1.5.0 audit-MED-work-M2: this module now optionally composes with
16
+ * `dispatch-planner.js::buildManifest` to surface wave-table file-overlap
17
+ * findings at plan-review time instead of dispatch time. Pass
18
+ * `{ checkWaveOverlap: true }` to opt in. Findings are INFO severity by
19
+ * default (advisory — overlap forces worktree-isolation, not failure).
20
+ */
21
+
22
+ import { parsePlan, buildManifest } from '../dispatch-planner.js';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Constants
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * Literal placeholder tokens that must not appear in a plan handed to execute.
30
+ * Case-insensitive match on word boundaries (except the bracketed/angle forms,
31
+ * which are matched literally).
32
+ */
33
+ const PLACEHOLDER_PATTERNS = [
34
+ { regex: /\bTBD\b/g, token: 'TBD' },
35
+ { regex: /\bFIXME\b/g, token: 'FIXME' },
36
+ { regex: /\bXXX\b/g, token: 'XXX' },
37
+ { regex: /\[fill me in\]/gi, token: '[fill me in]' },
38
+ { regex: /<placeholder>/gi, token: '<placeholder>' },
39
+ { regex: /\?\?\?/g, token: '???' },
40
+ ];
41
+
42
+ /**
43
+ * Acceptance-criteria signal — any one of these substrings is enough to satisfy
44
+ * "task has acceptance criteria" (intentionally loose; planners write in prose).
45
+ */
46
+ const ACCEPTANCE_REGEX = /\b(acceptance|done\s*when|criteria|expected)\b/i;
47
+
48
+ /**
49
+ * Empty-step detector: a list item or numbered step whose body (after stripping
50
+ * the marker) is under 20 chars of non-whitespace.
51
+ */
52
+ const EMPTY_STEP_THRESHOLD = 20;
53
+
54
+ /**
55
+ * Test-skip contradictions — if a task says it adds tests AND also says to
56
+ * skip them, that's a BLOCK regardless of strict mode.
57
+ */
58
+ /* eslint-disable security/detect-unsafe-regex --
59
+ * Matches developer-authored plan markdown on local disk (not network input).
60
+ * Word boundaries + short fixed alternations bound the match — no exponential
61
+ * backtracking risk on any input the planner would actually see.
62
+ */
63
+ const TEST_ADD_REGEX = /\b(add(?:ing)?|write|writing|create|creating)\s+(?:the\s+)?tests?\b/i;
64
+ const TEST_SKIP_REGEX = /\b(skip\s+(?:the\s+)?tests?|tests?\s+not\s+required|no\s+tests?\s+needed)\b/i;
65
+ /* eslint-enable security/detect-unsafe-regex */
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Finding helpers
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * @typedef {Object} Finding
73
+ * @property {'BLOCK'|'HIGH'|'WARN'|'INFO'} severity
74
+ * @property {string} code
75
+ * @property {string} message
76
+ * @property {string} [locationHint]
77
+ */
78
+
79
+ function mkFinding(severity, code, message, locationHint) {
80
+ const f = { severity, code, message };
81
+ if (locationHint) f.locationHint = locationHint;
82
+ return f;
83
+ }
84
+
85
+ /**
86
+ * v1.5.0 T17 (W1 plan-check hard-BLOCK): the canonical set of severities that
87
+ * structurally REFUSE dispatch when emitted by `validatePlan`.
88
+ *
89
+ * The codebase historically used `BLOCK` (this module) and `HIGH` (newer
90
+ * `termination.js` vocabulary — see `mcp-server/src/orchestrator/termination.js`
91
+ * §"FindingSeverity"). The STATE-SDK contract §7 `phase.plan-check` block + the
92
+ * T16 enforcement matrix both name the dispatch-blocking tier as a "HIGH
93
+ * finding". We treat the two labels as synonyms here so that:
94
+ *
95
+ * (a) legacy callers emitting `BLOCK` keep working unchanged, AND
96
+ * (b) any future rule emitting the canonical `HIGH` label also fails dispatch.
97
+ *
98
+ * This is the single source of truth — `phase.plan-check` in state-sdk.js
99
+ * imports the predicate so the gate cannot drift from `validatePlan`'s output.
100
+ */
101
+ const HIGH_TIER_SEVERITIES = Object.freeze(new Set(['BLOCK', 'HIGH']));
102
+
103
+ /**
104
+ * @param {Finding} finding
105
+ * @returns {boolean}
106
+ */
107
+ function isHighFinding(finding) {
108
+ return !!finding && HIGH_TIER_SEVERITIES.has(finding.severity);
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Task-block extraction
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /**
116
+ * A "task" can show up in three forms across the planners IJFW supports:
117
+ * 1. `## Task <name>` / `## Task: <name>` (markdown heading)
118
+ * 2. `### Task <name>` (third-level heading)
119
+ * 3. `task_id: <id>` (frontmatter-ish, e.g. inside <task> XML blocks)
120
+ *
121
+ * We extract task blocks by splitting on these boundaries. Each block keeps
122
+ * the heading line + everything up to the next task boundary or EOF.
123
+ *
124
+ * @param {string} planText
125
+ * @returns {Array<{header: string, body: string, lineStart: number}>}
126
+ */
127
+ function extractTaskBlocks(planText) {
128
+ const lines = planText.split('\n');
129
+ const taskHeaderRegex = /^(?:#{2,3})\s+Task\b[:\s]|^\s*task_id\s*:/i;
130
+ const blocks = [];
131
+ let current = null;
132
+ for (let i = 0; i < lines.length; i++) {
133
+ const line = lines[i];
134
+ if (taskHeaderRegex.test(line)) {
135
+ if (current) blocks.push(current);
136
+ current = { header: line.trim(), body: '', lineStart: i + 1 };
137
+ } else if (current) {
138
+ current.body += line + '\n';
139
+ }
140
+ }
141
+ if (current) blocks.push(current);
142
+ return blocks;
143
+ }
144
+
145
+ /**
146
+ * Extract all task IDs declared in the plan. Looks for `task_id: X`, an `id:`
147
+ * directly under a `## Task` heading, or `## Task <id>` / `### Task <id>`
148
+ * patterns where <id> is a short alnum/dash token.
149
+ *
150
+ * @param {string} planText
151
+ * @returns {Set<string>}
152
+ */
153
+ function extractTaskIds(planText) {
154
+ const ids = new Set();
155
+ const lines = planText.split('\n');
156
+ for (let i = 0; i < lines.length; i++) {
157
+ const line = lines[i];
158
+ let m;
159
+ if ((m = line.match(/^\s*task_id\s*:\s*([A-Za-z0-9_\-.]+)/i))) {
160
+ ids.add(m[1]);
161
+ continue;
162
+ }
163
+ if ((m = line.match(/^\s*id\s*:\s*([A-Za-z0-9_\-.]+)/i))) {
164
+ // Only count `id:` lines that appear shortly after a Task heading.
165
+ const lookback = lines.slice(Math.max(0, i - 5), i).join('\n');
166
+ if (/^#{2,3}\s+Task\b/im.test(lookback)) ids.add(m[1]);
167
+ continue;
168
+ }
169
+ if ((m = line.match(/^#{2,3}\s+Task[:\s]+([A-Za-z0-9_\-.]+)/i))) {
170
+ ids.add(m[1]);
171
+ }
172
+ }
173
+ return ids;
174
+ }
175
+
176
+ /**
177
+ * Extract dependency references from a task body. Matches `depends:` and
178
+ * `blocked-by:` (also `blocked_by:` / `depends_on:` — common variants).
179
+ *
180
+ * @param {string} body
181
+ * @returns {string[]}
182
+ */
183
+ function extractDependencyRefs(body) {
184
+ const refs = [];
185
+ const lines = body.split('\n');
186
+ for (const line of lines) {
187
+ const m = line.match(/^\s*(?:depends(?:_on)?|blocked[-_]by)\s*:\s*(.+)$/i);
188
+ if (!m) continue;
189
+ // Value may be a single id, comma list, or YAML-style ["a", "b"].
190
+ const raw = m[1].trim().replace(/^\[|\]$/g, '');
191
+ const parts = raw.split(/[,\s]+/).map((p) => p.replace(/^["']|["']$/g, '').trim()).filter(Boolean);
192
+ for (const p of parts) refs.push(p);
193
+ }
194
+ return refs;
195
+ }
196
+
197
+ /**
198
+ * Detect "empty step" lines inside a task body — bullet/numbered list items
199
+ * whose payload is short and vague.
200
+ *
201
+ * @param {string} body
202
+ * @param {number} bodyLineOffset 1-indexed line number where the body starts in the plan
203
+ * @returns {Array<{text: string, line: number}>}
204
+ */
205
+ function findEmptySteps(body, bodyLineOffset) {
206
+ const out = [];
207
+ const lines = body.split('\n');
208
+ for (let i = 0; i < lines.length; i++) {
209
+ const line = lines[i];
210
+ const m = line.match(/^\s*(?:[-*+]|\d+\.)\s+(.+?)\s*$/);
211
+ if (!m) continue;
212
+ const payload = m[1].trim();
213
+ // Strip any leading "implement/do/fix" filler and re-measure to catch
214
+ // "implement the thing" style placeholders.
215
+ // eslint-disable-next-line security/detect-unsafe-regex -- short fixed alternation against developer-authored plan markdown on local disk
216
+ const stripped = payload.replace(/^(?:implement|do|fix|handle)\s+(?:the\s+)?/i, '').trim();
217
+ if (stripped.length < EMPTY_STEP_THRESHOLD) {
218
+ out.push({ text: payload, line: bodyLineOffset + i });
219
+ }
220
+ }
221
+ return out;
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Public API
226
+ // ---------------------------------------------------------------------------
227
+
228
+ /**
229
+ * Validate a plan text against the C14 pre-dispatch ruleset.
230
+ *
231
+ * Findings carry one of three severities:
232
+ * - BLOCK: dispatch must abort
233
+ * - WARN: dispatch may proceed but the orchestrator should surface the issue
234
+ * - INFO: advisory only
235
+ *
236
+ * In `strict: true` mode, WARNs from the placeholder check get promoted to
237
+ * BLOCK (the rest keep their natural severity — the strict-promotion is
238
+ * scoped to placeholders by design, matching the gsd-plan-checker semantics
239
+ * where placeholder text in a "ready to dispatch" plan is a hard failure).
240
+ *
241
+ * @param {string} planText
242
+ * @param {{strict?: boolean, checkWaveOverlap?: boolean}} [opts]
243
+ * @returns {{ok: boolean, findings: Finding[]}}
244
+ */
245
+ function validatePlan(planText, opts = {}) {
246
+ const strict = !!opts.strict;
247
+ const checkWaveOverlap = !!opts.checkWaveOverlap;
248
+ /** @type {Finding[]} */
249
+ const findings = [];
250
+
251
+ if (typeof planText !== 'string') {
252
+ return {
253
+ ok: false,
254
+ findings: [mkFinding('BLOCK', 'PC-INPUT', 'planText must be a string')],
255
+ };
256
+ }
257
+
258
+ // ---- Check 1: placeholders ----------------------------------------------
259
+ for (const { regex, token } of PLACEHOLDER_PATTERNS) {
260
+ // reset lastIndex on every call to be safe with the global flag
261
+ regex.lastIndex = 0;
262
+ const matches = planText.match(regex);
263
+ if (matches && matches.length > 0) {
264
+ const sev = strict ? 'BLOCK' : 'WARN';
265
+ findings.push(
266
+ mkFinding(
267
+ sev,
268
+ 'PC-PLACEHOLDER',
269
+ `Found ${matches.length} placeholder token(s) "${token}" — plan is not ready for dispatch`,
270
+ ),
271
+ );
272
+ }
273
+ }
274
+
275
+ // ---- Check 2: completeness (must have ≥1 task) --------------------------
276
+ const taskBlocks = extractTaskBlocks(planText);
277
+ if (taskBlocks.length === 0) {
278
+ findings.push(
279
+ mkFinding(
280
+ 'BLOCK',
281
+ 'PC-NO-TASKS',
282
+ 'Plan contains 0 tasks (expected ≥1 `## Task` / `### Task` / `task_id:` block)',
283
+ ),
284
+ );
285
+ }
286
+
287
+ // ---- Check 3: each task has acceptance criteria -------------------------
288
+ for (const block of taskBlocks) {
289
+ if (!ACCEPTANCE_REGEX.test(block.body) && !ACCEPTANCE_REGEX.test(block.header)) {
290
+ findings.push(
291
+ mkFinding(
292
+ 'WARN',
293
+ 'PC-NO-ACCEPTANCE',
294
+ `Task block missing acceptance criteria (looked for: acceptance|done when|criteria|expected)`,
295
+ `line ${block.lineStart}: ${block.header}`,
296
+ ),
297
+ );
298
+ }
299
+ }
300
+
301
+ // ---- Check 4: empty / under-specified steps -----------------------------
302
+ for (const block of taskBlocks) {
303
+ // body starts on the line after the header
304
+ const empties = findEmptySteps(block.body, block.lineStart + 1);
305
+ for (const e of empties) {
306
+ findings.push(
307
+ mkFinding(
308
+ 'WARN',
309
+ 'PC-EMPTY-STEP',
310
+ `Step is too short / vague to be actionable: "${e.text}"`,
311
+ `line ${e.line}`,
312
+ ),
313
+ );
314
+ }
315
+ }
316
+
317
+ // ---- Check 5: dependency sanity (dangling refs) -------------------------
318
+ const declaredIds = extractTaskIds(planText);
319
+ // Build dependency adjacency map keyed by the FIRST task_id we can derive
320
+ // from each block header (mirrors extractTaskIds heuristic). We collect
321
+ // refs first so the cycle pass below can use the same map.
322
+ /** @type {Map<string, string[]>} */
323
+ const depGraph = new Map();
324
+ /** @type {Map<string, string>} */
325
+ const blockHeaderById = new Map();
326
+ /** @type {Map<string, number>} */
327
+ const blockLineById = new Map();
328
+ for (const block of taskBlocks) {
329
+ // Extract the task id this block declares (best-effort; mirrors
330
+ // extractTaskIds). Without an id we can't participate in cycle detection
331
+ // -- skip silently rather than fabricate a synthetic id.
332
+ let blockId = null;
333
+ const idMatch = block.header.match(/^#{2,3}\s+Task[:\s]+([A-Za-z0-9_\-.]+)/i)
334
+ || block.body.match(/^\s*task_id\s*:\s*([A-Za-z0-9_\-.]+)/im)
335
+ || block.body.match(/^\s*id\s*:\s*([A-Za-z0-9_\-.]+)/im);
336
+ if (idMatch) blockId = idMatch[1];
337
+
338
+ const refs = extractDependencyRefs(block.body);
339
+ for (const ref of refs) {
340
+ if (!declaredIds.has(ref)) {
341
+ findings.push(
342
+ mkFinding(
343
+ 'BLOCK',
344
+ 'PC-DANGLING-DEP',
345
+ `Task references unknown dependency "${ref}" (not declared as a task_id in this plan)`,
346
+ `line ${block.lineStart}: ${block.header}`,
347
+ ),
348
+ );
349
+ }
350
+ }
351
+ if (blockId) {
352
+ depGraph.set(blockId, refs.filter((r) => declaredIds.has(r)));
353
+ blockHeaderById.set(blockId, block.header);
354
+ blockLineById.set(blockId, block.lineStart);
355
+ }
356
+ }
357
+
358
+ // ---- Check 5b: dependency cycles (DFS three-coloring) -------------------
359
+ // v1.5.0 audit-LOW-work-L5: catch dep cycles at plan-review time so they
360
+ // don't deadlock the executor at fan-out. Standard DFS with white/gray/black
361
+ // coloring; a back-edge into a `gray` node is a cycle.
362
+ // BLOCK severity because a cycle is a hard structural break, not a smell.
363
+ {
364
+ const WHITE = 0, GRAY = 1, BLACK = 2;
365
+ const color = new Map();
366
+ for (const id of depGraph.keys()) color.set(id, WHITE);
367
+ /** @type {Set<string>} */
368
+ const cyclesReported = new Set();
369
+ const visit = (id, stack) => {
370
+ color.set(id, GRAY);
371
+ stack.push(id);
372
+ const neighbours = depGraph.get(id) || [];
373
+ for (const n of neighbours) {
374
+ if (!depGraph.has(n)) continue; // dangling — already reported above
375
+ const c = color.get(n) ?? WHITE;
376
+ if (c === GRAY) {
377
+ // Found a back-edge: extract the cycle from the stack.
378
+ const startIdx = stack.indexOf(n);
379
+ const cycle = stack.slice(startIdx).concat(n);
380
+ const key = [...cycle].sort().join('|');
381
+ if (!cyclesReported.has(key)) {
382
+ cyclesReported.add(key);
383
+ findings.push(
384
+ mkFinding(
385
+ 'BLOCK',
386
+ 'PC-DEP-CYCLE',
387
+ `Dependency cycle detected: ${cycle.join(' -> ')}`,
388
+ blockHeaderById.has(n) ? `line ${blockLineById.get(n)}: ${blockHeaderById.get(n)}` : undefined,
389
+ ),
390
+ );
391
+ }
392
+ } else if (c === WHITE) {
393
+ visit(n, stack);
394
+ }
395
+ }
396
+ stack.pop();
397
+ color.set(id, BLACK);
398
+ };
399
+ for (const id of depGraph.keys()) {
400
+ if ((color.get(id) ?? WHITE) === WHITE) visit(id, []);
401
+ }
402
+ }
403
+
404
+ // ---- Check 6: no test-skip contradiction --------------------------------
405
+ for (const block of taskBlocks) {
406
+ const both = TEST_ADD_REGEX.test(block.body) && TEST_SKIP_REGEX.test(block.body);
407
+ if (both) {
408
+ findings.push(
409
+ mkFinding(
410
+ 'BLOCK',
411
+ 'PC-TEST-SKIP-CONTRADICTION',
412
+ 'Task says to add tests AND to skip tests in the same block — pick one',
413
+ `line ${block.lineStart}: ${block.header}`,
414
+ ),
415
+ );
416
+ }
417
+ }
418
+
419
+ // ---- Check 7: wave-table file-overlap (M2 composition, opt-in) ---------
420
+ // Runs `buildManifest` on the plan to surface any sub-waves that would be
421
+ // routed to worktree isolation because they declare overlapping `Files:`
422
+ // lines. These are INFO findings, not BLOCK — overlap is RECOVERABLE
423
+ // (dispatch-planner just isolates), but planners benefit from seeing the
424
+ // overlap during review instead of being surprised at dispatch time.
425
+ if (checkWaveOverlap) {
426
+ try {
427
+ const subwaves = parsePlan(planText);
428
+ const manifest = buildManifest(subwaves);
429
+ for (const m of manifest) {
430
+ if (m.mode === 'worktree' && m.overlaps_with && m.overlaps_with.length > 0) {
431
+ findings.push(
432
+ mkFinding(
433
+ 'INFO',
434
+ 'PC-WAVE-OVERLAP',
435
+ `Sub-wave "${m.id}" overlaps file(s) with peer(s): ${m.overlaps_with.join(', ')} — will be dispatched to worktree isolation`,
436
+ `sub-wave ${m.id} (wave ${m.wave})`,
437
+ ),
438
+ );
439
+ } else if (m.mode === 'worktree' && m.reason === 'no-files-declared') {
440
+ findings.push(
441
+ mkFinding(
442
+ 'INFO',
443
+ 'PC-WAVE-NO-FILES',
444
+ `Sub-wave "${m.id}" declares no Files: line — will default to worktree isolation`,
445
+ `sub-wave ${m.id} (wave ${m.wave})`,
446
+ ),
447
+ );
448
+ }
449
+ }
450
+ } catch {
451
+ // dispatch-planner is best-effort here; a parse failure is not a
452
+ // plan-check failure — fall through silently.
453
+ }
454
+ }
455
+
456
+ // v1.5.0 T17 (W1 plan-check hard-BLOCK): a finding in `HIGH_TIER_SEVERITIES`
457
+ // (BLOCK or HIGH — see comment on `HIGH_TIER_SEVERITIES`) is structurally
458
+ // dispatch-blocking. `strict` mode promotes placeholder WARNs to BLOCKs (the
459
+ // historical behaviour); a HIGH-tier finding from any check — strict or not —
460
+ // makes `ok=false` and the `phase.plan-check` verb refuses pre-dispatch.
461
+ const ok = !findings.some(isHighFinding);
462
+ return { ok, findings };
463
+ }
464
+
465
+ export {
466
+ validatePlan,
467
+ // exported for tests / power users:
468
+ extractTaskBlocks,
469
+ extractTaskIds,
470
+ extractDependencyRefs,
471
+ findEmptySteps,
472
+ PLACEHOLDER_PATTERNS,
473
+ HIGH_TIER_SEVERITIES,
474
+ isHighFinding,
475
+ };