@smartmemory/compose 0.3.7 → 0.4.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 (217) hide show
  1. package/.claude/skills/compose/SKILL.md +12 -3
  2. package/.compose-deps.json +51 -25
  3. package/README.md +79 -7
  4. package/bin/compose.js +495 -360
  5. package/bin/judgment-migrate.js +387 -0
  6. package/contracts/comp-obs-contract.schema.json +9 -3
  7. package/contracts/fluid-record.schema.json +209 -0
  8. package/contracts/lifecycle-backfill.schema.json +322 -0
  9. package/dist/assets/App-Z4MU-H_F.js +916 -0
  10. package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
  11. package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
  12. package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
  13. package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
  14. package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
  15. package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  16. package/dist/assets/channel-SnZzzh7k.js +1 -0
  17. package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  18. package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
  19. package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  20. package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  21. package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  22. package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
  23. package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  24. package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  25. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  26. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  27. package/dist/assets/clone-DgklGjHm.js +1 -0
  28. package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  29. package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
  30. package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
  31. package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
  32. package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
  33. package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
  34. package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
  35. package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
  36. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
  37. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
  38. package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
  39. package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
  40. package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
  41. package/dist/assets/index-yyrA5OZd.css +1 -0
  42. package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  45. package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
  46. package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
  47. package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
  48. package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
  49. package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
  50. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  51. package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
  52. package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
  53. package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
  54. package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
  55. package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
  56. package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
  57. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  58. package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
  59. package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
  60. package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
  61. package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
  62. package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
  63. package/dist/index.html +3 -3
  64. package/lib/agent-string.js +7 -5
  65. package/lib/append-integrity.js +81 -0
  66. package/lib/backfill-evidence.js +109 -0
  67. package/lib/bug-escalation.js +9 -0
  68. package/lib/build-stream-schema.js +3 -1
  69. package/lib/build-stream-writer.js +25 -0
  70. package/lib/build.js +874 -170
  71. package/lib/canon-guard.js +28 -6
  72. package/lib/canon-override.js +196 -0
  73. package/lib/canon-registry.js +104 -0
  74. package/lib/cli-commands.js +144 -0
  75. package/lib/codex-preflight.js +26 -13
  76. package/lib/colleague/context.js +215 -0
  77. package/lib/colleague/writeback.js +95 -0
  78. package/lib/completion-gate.js +1421 -0
  79. package/lib/completion-writer.js +47 -47
  80. package/lib/consumer-fanout.js +105 -11
  81. package/lib/coverage-gate.js +200 -0
  82. package/lib/deps.js +164 -7
  83. package/lib/dir-lock.js +170 -0
  84. package/lib/dispatch-ledger.js +3 -3
  85. package/lib/feature-json.js +1 -1
  86. package/lib/feature-reconciler.js +8 -0
  87. package/lib/feature-validator.js +64 -1
  88. package/lib/feature-writer.js +57 -2
  89. package/lib/fluid/factory.js +167 -0
  90. package/lib/fluid/ideabox-dates.js +73 -0
  91. package/lib/fluid/ideabox-migrate.js +154 -0
  92. package/lib/fluid/ideabox-ops.js +585 -0
  93. package/lib/fluid/ideabox-view.js +146 -0
  94. package/lib/fluid/import-ideabox.js +186 -0
  95. package/lib/fluid/local-provider.js +606 -0
  96. package/lib/fluid/provider.js +684 -0
  97. package/lib/fluid/record-shape.js +214 -0
  98. package/lib/fluid/record-store.js +328 -0
  99. package/lib/fluid/render-ideabox.js +261 -0
  100. package/lib/fluid/schema.js +40 -0
  101. package/lib/fluid/smartmemory-provider.js +1695 -0
  102. package/lib/gsd.js +63 -23
  103. package/lib/guard-cli.js +175 -0
  104. package/lib/guard-custody.js +141 -0
  105. package/lib/guard-descriptors.js +530 -0
  106. package/lib/guard-enrol.js +254 -0
  107. package/lib/health-score.js +1 -1
  108. package/lib/ideabox-cli.js +315 -0
  109. package/lib/ideabox.js +121 -21
  110. package/lib/judgment/store/index.js +9 -1
  111. package/lib/judgment/store/records.js +1 -1
  112. package/lib/judgment/trace.js +380 -0
  113. package/lib/judgment-decision-write.js +277 -0
  114. package/lib/judgment-decisions.js +466 -0
  115. package/lib/judgment-gen.js +5 -1
  116. package/lib/judgment-writer.js +56 -2
  117. package/lib/lifecycle-modes.js +4 -4
  118. package/lib/lineage.js +400 -0
  119. package/lib/local-claude-connector.js +52 -1
  120. package/lib/maya-client.js +302 -0
  121. package/lib/maya-config.js +53 -0
  122. package/lib/maya-identity.js +283 -0
  123. package/lib/migrate-anon.js +5 -0
  124. package/lib/migrate-roadmap.js +15 -0
  125. package/lib/new.js +13 -1
  126. package/lib/pipeline-compat.js +104 -0
  127. package/lib/policy-catalog.js +295 -0
  128. package/lib/policy-check.js +0 -0
  129. package/lib/process-termination.js +98 -0
  130. package/lib/resolve-workspace.js +5 -1
  131. package/lib/result-normalizer.js +396 -199
  132. package/lib/roadmap-errors.js +65 -0
  133. package/lib/roadmap-preservers.js +24 -4
  134. package/lib/roadmap-residue.js +299 -0
  135. package/lib/smartmemory-client.js +614 -78
  136. package/lib/smartmemory-config.js +54 -0
  137. package/lib/smartmemory-ingest.js +19 -2
  138. package/lib/step-prompt.js +7 -6
  139. package/lib/stratum-engine.js +53 -4
  140. package/lib/stratum-mcp-client.js +271 -36
  141. package/lib/test-bootstrap.js +31 -0
  142. package/lib/tool-inventory.js +122 -0
  143. package/lib/version-check.js +91 -19
  144. package/lib/vision-writer.js +88 -1
  145. package/package.json +7 -6
  146. package/pipelines/bug-fix.stratum.yaml +205 -211
  147. package/pipelines/build-quick.profiles.json +12 -0
  148. package/pipelines/build-quick.stratum.yaml +263 -350
  149. package/pipelines/content.stratum.yaml +81 -77
  150. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  151. package/pipelines/plan.stratum.yaml +76 -86
  152. package/pipelines/refactor.stratum.yaml +125 -125
  153. package/pipelines/research.stratum.yaml +56 -58
  154. package/pipelines/review-fix.profiles.json +6 -0
  155. package/pipelines/review-fix.stratum.yaml +110 -83
  156. package/presets/team-feature.profiles.json +6 -0
  157. package/presets/team-feature.stratum.yaml +93 -66
  158. package/presets/team-research.profiles.json +6 -0
  159. package/presets/team-research.stratum.yaml +89 -80
  160. package/presets/team-review.profiles.json +8 -0
  161. package/presets/team-review.stratum.yaml +98 -80
  162. package/scripts/cost-census.mjs +70 -0
  163. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  164. package/server/agent-health.js +22 -0
  165. package/server/agent-hooks.js +14 -1
  166. package/server/agent-server.js +5 -248
  167. package/server/agent-spawn.js +3 -4
  168. package/server/agent-workspace.js +294 -0
  169. package/server/build-routes.js +6 -5
  170. package/server/build-stream-bridge.js +53 -0
  171. package/server/cc-session-watcher.js +4 -1
  172. package/server/coalescing-buffer.js +7 -1
  173. package/server/completion-projection.js +228 -0
  174. package/server/compose-mcp-tools.js +109 -23
  175. package/server/compose-mcp.js +88 -882
  176. package/server/decision-event-emit.js +41 -2
  177. package/server/decision-event-id.js +17 -0
  178. package/server/decision-events-snapshot.js +3 -0
  179. package/server/design-routes.js +14 -8
  180. package/server/feature-scan.js +76 -2
  181. package/server/file-watcher.js +170 -21
  182. package/server/ideabox-routes.js +166 -224
  183. package/server/index.js +70 -100
  184. package/server/lifecycle-guard.js +240 -10
  185. package/server/lifecycle-phase-history.js +276 -0
  186. package/server/maya-routes.js +507 -0
  187. package/server/mcp-tool-defs.js +940 -0
  188. package/server/mcp-tool-policy.js +34 -2
  189. package/server/model-tiers.js +22 -5
  190. package/server/pipeline-routes.js +21 -11
  191. package/server/project-root.js +58 -19
  192. package/server/remote-utils.js +3 -1
  193. package/server/schema-validator.js +7 -1
  194. package/server/session-manager.js +5 -6
  195. package/server/session-routes.js +3 -1
  196. package/server/stratum-client.js +57 -10
  197. package/server/stratum-sync.js +6 -3
  198. package/server/summarizer.js +3 -4
  199. package/server/supervisor.js +0 -1
  200. package/server/vision-routes.js +208 -98
  201. package/server/vision-server.js +86 -23
  202. package/server/vision-store.js +60 -6
  203. package/server/vision-utils.js +3 -4
  204. package/server/workspace-activity.js +18 -0
  205. package/server/workspace-middleware.js +2 -2
  206. package/server/workspace-runtime.js +243 -0
  207. package/server/worktree-gc.js +1 -0
  208. package/dist/assets/App-PkZzHeMj.js +0 -894
  209. package/dist/assets/channel-qVK_qn4E.js +0 -1
  210. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
  211. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
  212. package/dist/assets/clone-Pu3RyLUh.js +0 -1
  213. package/dist/assets/index-LIwREYgH.css +0 -1
  214. package/dist/assets/mobile-BnXEOE3U.js +0 -17
  215. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
  216. package/lib/staleness.js +0 -87
  217. package/server/ideabox-cache.js +0 -77
package/lib/lineage.js ADDED
@@ -0,0 +1,400 @@
1
+ /**
2
+ * lineage.js — COMP-PROV-LINEAGE: W3C PROV-O artifact lineage (vocabulary only).
3
+ *
4
+ * Compose artifacts form a derivation chain — design.md produces blueprint.md
5
+ * produces plan.md produces report.md — but nothing recorded it, so when an
6
+ * upstream artifact changes, downstream artifacts went stale silently.
7
+ *
8
+ * We borrow the W3C PROV-O vocabulary (https://www.w3.org/TR/prov-o/), NOT the
9
+ * RDF stack. See docs/features/COMP-PROV-LINEAGE/prov-o-mapping.md for the full
10
+ * term mapping. In Compose terms:
11
+ *
12
+ * prov:Entity → a lifecycle artifact (design.md, blueprint.md, ...)
13
+ * prov:Activity → a lifecycle phase (explore_design, blueprint, ...)
14
+ * prov:wasGeneratedBy → the phase that produced this artifact (Entity → Activity)
15
+ * prov:wasDerivedFrom → the upstream artifact(s) this was built on (Entity → Entity)
16
+ *
17
+ * `wasDerivedFrom` is the load-bearing edge: staleness becomes a graph
18
+ * reachability query over it rather than bespoke logic.
19
+ *
20
+ * Storage: the lineage lives as HTML-comment markers in the first lines of each
21
+ * artifact (COMP-PROV-LINEAGE decision, embedded markers over a feature.json
22
+ * block — canonical artifacts stay auto-discovered, never registered):
23
+ *
24
+ * <!-- wasGeneratedBy: blueprint -->
25
+ * <!-- wasDerivedFrom: design.md -->
26
+ *
27
+ * The reachability query does NOT require the markers to have been stamped: for
28
+ * canonical artifacts it falls back to CANONICAL_CHAIN, so it works on any
29
+ * feature folder today. Markers only override the default derivation.
30
+ *
31
+ * Explicitly out of scope: RDF runtime, triple store, SPARQL, JSON-LD. A
32
+ * JSON-LD export stays possible later by walking these same edges.
33
+ */
34
+
35
+ import { readFileSync, writeFileSync, existsSync, statSync, utimesSync } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import { artifactsOf, edgeEvidenceOf } from './lifecycle-modes.js';
38
+
39
+ /**
40
+ * Canonical derivation chain for the `build` lifecycle, in derivation order.
41
+ * Each entry is { file, phase } where `phase` is the prov:Activity that
42
+ * generates the prov:Entity `file`.
43
+ *
44
+ * Ordered so that entry N wasDerivedFrom the nearest EXISTING predecessor
45
+ * (predecessors may be skipped — prd/architecture/report are skippable phases).
46
+ *
47
+ * Mirrors lib/lifecycle-modes.js build mode. A test
48
+ * (test/lineage.test.js) asserts this stays consistent with
49
+ * `artifactsOf('build')` / `edgeEvidenceOf('build')` so the two never drift.
50
+ */
51
+ export const CANONICAL_CHAIN = [
52
+ { file: 'design.md', phase: 'explore_design' },
53
+ { file: 'prd.md', phase: 'prd' },
54
+ { file: 'architecture.md', phase: 'architecture' },
55
+ { file: 'blueprint.md', phase: 'blueprint' },
56
+ { file: 'plan.md', phase: 'plan' },
57
+ { file: 'report.md', phase: 'report' },
58
+ ];
59
+
60
+ const CHAIN_FILES = CANONICAL_CHAIN.map((e) => e.file);
61
+ const PHASE_BY_FILE = new Map(CANONICAL_CHAIN.map((e) => [e.file, e.phase]));
62
+
63
+ /**
64
+ * The prov:Activity (phase) that generates a canonical artifact, or null if the
65
+ * file is not a known canonical artifact.
66
+ * @param {string} filename
67
+ * @returns {string|null}
68
+ */
69
+ export function generatingPhaseOf(filename) {
70
+ return PHASE_BY_FILE.get(filename) ?? null;
71
+ }
72
+
73
+ /**
74
+ * Extract PROV-O lineage markers from the first lines of artifact text.
75
+ * Reads only the header region (first 8 lines) — a cheap header scan.
76
+ *
77
+ * @param {string} content
78
+ * @returns {{ wasGeneratedBy: string|null, wasDerivedFrom: string[] }}
79
+ */
80
+ export function extractLineageMarkers(content) {
81
+ const lines = content.split('\n').slice(0, 8);
82
+ let wasGeneratedBy = null;
83
+ let wasDerivedFrom = null; // null = no usable marker present
84
+ for (const line of lines) {
85
+ // Anchored to a full line (optional surrounding whitespace) so a marker must
86
+ // BE the line, not merely appear inside prose or a fenced code example.
87
+ const g = line.match(/^\s*<!--\s*wasGeneratedBy:\s*([\w_-]+)\s*-->\s*$/);
88
+ if (g) wasGeneratedBy = g[1];
89
+ const d = line.match(/^\s*<!--\s*wasDerivedFrom:\s*([^>]*?)\s*-->\s*$/);
90
+ if (d) {
91
+ const parents = d[1]
92
+ .split(',')
93
+ .map((s) => s.trim())
94
+ // Parents are sibling artifact filenames — never a path. Drop anything
95
+ // with a separator or traversal so a hand-edited marker cannot point the
96
+ // derivation graph outside the feature folder.
97
+ .filter((s) => s && !s.includes('/') && !s.includes('\\') && !s.includes('..'));
98
+ // An empty marker (`<!-- wasDerivedFrom: -->`) is treated as no override
99
+ // rather than an explicit "[]" state — Compose has no mid-chain
100
+ // origin-override use case, and the phantom [] broke stamp idempotency.
101
+ wasDerivedFrom = parents.length > 0 ? parents : null;
102
+ }
103
+ }
104
+ return { wasGeneratedBy, wasDerivedFrom };
105
+ }
106
+
107
+ /**
108
+ * Compute the canonical lineage for an artifact filename given which sibling
109
+ * artifacts actually exist in the feature folder. `wasDerivedFrom` is the
110
+ * nearest EXISTING predecessor in CANONICAL_CHAIN (so skipped phases don't
111
+ * break the edge); the first artifact in the chain derives from nothing.
112
+ *
113
+ * @param {string} filename
114
+ * @param {string[]} existingFiles filenames present in the feature folder
115
+ * @returns {{ entity: string, wasGeneratedBy: string|null, wasDerivedFrom: string[] }}
116
+ */
117
+ export function canonicalLineageOf(filename, existingFiles) {
118
+ const idx = CHAIN_FILES.indexOf(filename);
119
+ if (idx === -1) {
120
+ return { entity: filename, wasGeneratedBy: null, wasDerivedFrom: [] };
121
+ }
122
+ const present = new Set(existingFiles);
123
+ let wasDerivedFrom = [];
124
+ for (let i = idx - 1; i >= 0; i--) {
125
+ if (present.has(CHAIN_FILES[i])) {
126
+ wasDerivedFrom = [CHAIN_FILES[i]];
127
+ break;
128
+ }
129
+ }
130
+ return {
131
+ entity: filename,
132
+ wasGeneratedBy: PHASE_BY_FILE.get(filename) ?? null,
133
+ wasDerivedFrom,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Resolve the effective lineage for an on-disk artifact: markers override the
139
+ * canonical chain. Returns null if the file does not exist.
140
+ *
141
+ * @param {string} featureDir
142
+ * @param {string} filename
143
+ * @param {string[]} [existingFiles] optional precomputed sibling list
144
+ * @returns {{ entity: string, wasGeneratedBy: string|null, wasDerivedFrom: string[], source: 'marker'|'canonical' }|null}
145
+ */
146
+ export function lineageOf(featureDir, filename, existingFiles) {
147
+ const filePath = join(featureDir, filename);
148
+ if (!existsSync(filePath)) return null;
149
+ const siblings = existingFiles ?? CHAIN_FILES.filter((f) => existsSync(join(featureDir, f)));
150
+
151
+ let content;
152
+ try {
153
+ content = readFileSync(filePath, 'utf-8');
154
+ } catch {
155
+ content = '';
156
+ }
157
+ const markers = extractLineageMarkers(content);
158
+ const canonical = canonicalLineageOf(filename, siblings);
159
+
160
+ // Markers override; fall back to canonical per-field.
161
+ const usesMarker = markers.wasGeneratedBy !== null || markers.wasDerivedFrom !== null;
162
+ return {
163
+ entity: filename,
164
+ wasGeneratedBy: markers.wasGeneratedBy ?? canonical.wasGeneratedBy,
165
+ wasDerivedFrom: markers.wasDerivedFrom ?? canonical.wasDerivedFrom,
166
+ source: usesMarker ? 'marker' : 'canonical',
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Build the derivation graph (child -> parents) for the canonical artifacts that
172
+ * exist in a feature folder, honouring marker overrides.
173
+ *
174
+ * @param {string} featureDir
175
+ * @returns {Map<string, string[]>} filename -> its wasDerivedFrom parents
176
+ */
177
+ export function buildDerivationGraph(featureDir) {
178
+ const existing = CHAIN_FILES.filter((f) => existsSync(join(featureDir, f)));
179
+ const graph = new Map();
180
+ for (const file of existing) {
181
+ const lin = lineageOf(featureDir, file, existing);
182
+ graph.set(file, lin ? lin.wasDerivedFrom : []);
183
+ }
184
+ return graph;
185
+ }
186
+
187
+ /**
188
+ * Reachability query — the core deliverable.
189
+ *
190
+ * Given an artifact that changed, return the downstream artifacts (transitive
191
+ * descendants via wasDerivedFrom) that are now STALE: they exist, they descend
192
+ * from the changed artifact, and their last-modified time is older than the
193
+ * changed artifact's (they were derived before the upstream changed).
194
+ *
195
+ * @param {string} featureDir
196
+ * @param {string} changedFile filename of the artifact that changed
197
+ * @returns {Array<{ file: string, stale: boolean, derivedFromChanged: boolean, mtimeMs: number }>}
198
+ * descendants of changedFile, stale ones first
199
+ */
200
+ export function findStaleDescendants(featureDir, changedFile) {
201
+ const graph = buildDerivationGraph(featureDir); // child -> parents
202
+
203
+ // Invert to parent -> children for forward reachability.
204
+ const children = new Map();
205
+ for (const [child, parents] of graph) {
206
+ for (const parent of parents) {
207
+ if (!children.has(parent)) children.set(parent, []);
208
+ children.get(parent).push(child);
209
+ }
210
+ }
211
+
212
+ // BFS forward from changedFile to collect all transitive descendants.
213
+ const descendants = new Set();
214
+ const queue = [...(children.get(changedFile) ?? [])];
215
+ while (queue.length > 0) {
216
+ const node = queue.shift();
217
+ if (descendants.has(node)) continue;
218
+ descendants.add(node);
219
+ for (const c of children.get(node) ?? []) queue.push(c);
220
+ }
221
+
222
+ const changedPath = join(featureDir, changedFile);
223
+ let changedMtime = 0;
224
+ try {
225
+ changedMtime = statSync(changedPath).mtimeMs;
226
+ } catch {
227
+ return []; // changed file gone — nothing to compare against
228
+ }
229
+
230
+ const results = [];
231
+ for (const file of descendants) {
232
+ let mtimeMs = 0;
233
+ try {
234
+ mtimeMs = statSync(join(featureDir, file)).mtimeMs;
235
+ } catch {
236
+ continue;
237
+ }
238
+ results.push({
239
+ file,
240
+ stale: mtimeMs < changedMtime,
241
+ derivedFromChanged: true,
242
+ mtimeMs,
243
+ });
244
+ }
245
+ // Stale first, then by chain order for stable output.
246
+ results.sort((a, b) => {
247
+ if (a.stale !== b.stale) return a.stale ? -1 : 1;
248
+ return CHAIN_FILES.indexOf(a.file) - CHAIN_FILES.indexOf(b.file);
249
+ });
250
+ return results;
251
+ }
252
+
253
+ /**
254
+ * Global staleness scan — the changed-file-free form of the reachability query,
255
+ * for callers (the build health signal) that want "which artifacts are stale?"
256
+ * without a specific changed anchor.
257
+ *
258
+ * An artifact is stale when any of its transitive ancestors (via wasDerivedFrom)
259
+ * has a newer mtime than the artifact itself — i.e. an upstream it was built on
260
+ * has changed since. Returns one row per existing canonical artifact, shape
261
+ * `{ file, stale, staleAgainst }`, compatible with scoreDocFreshness (which only
262
+ * reads `.stale`).
263
+ *
264
+ * @param {string} featureDir
265
+ * @returns {Array<{ file: string, stale: boolean, staleAgainst: string[] }>}
266
+ */
267
+ export function findStaleArtifacts(featureDir) {
268
+ const graph = buildDerivationGraph(featureDir); // child -> parents
269
+
270
+ const mtime = new Map();
271
+ for (const file of graph.keys()) {
272
+ try {
273
+ mtime.set(file, statSync(join(featureDir, file)).mtimeMs);
274
+ } catch { /* unreadable — omit from comparison */ }
275
+ }
276
+
277
+ const results = [];
278
+ for (const file of graph.keys()) {
279
+ if (!mtime.has(file)) continue;
280
+
281
+ // Collect transitive ancestors (cycle-safe).
282
+ const ancestors = new Set();
283
+ const queue = [...(graph.get(file) ?? [])];
284
+ while (queue.length > 0) {
285
+ const p = queue.shift();
286
+ if (ancestors.has(p)) continue;
287
+ ancestors.add(p);
288
+ for (const gp of graph.get(p) ?? []) queue.push(gp);
289
+ }
290
+
291
+ const staleAgainst = [];
292
+ for (const anc of ancestors) {
293
+ if (mtime.has(anc) && mtime.get(anc) > mtime.get(file)) staleAgainst.push(anc);
294
+ }
295
+ results.push({ file, stale: staleAgainst.length > 0, staleAgainst });
296
+ }
297
+
298
+ results.sort((a, b) => CHAIN_FILES.indexOf(a.file) - CHAIN_FILES.indexOf(b.file));
299
+ return results;
300
+ }
301
+
302
+ /**
303
+ * Render the PROV-O marker header lines for an artifact's lineage.
304
+ * @param {{ wasGeneratedBy: string|null, wasDerivedFrom: string[] }} lineage
305
+ * @returns {string[]} marker lines (may be empty)
306
+ */
307
+ function renderMarkers(lineage) {
308
+ const out = [];
309
+ if (lineage.wasGeneratedBy) out.push(`<!-- wasGeneratedBy: ${lineage.wasGeneratedBy} -->`);
310
+ if (lineage.wasDerivedFrom && lineage.wasDerivedFrom.length > 0) {
311
+ out.push(`<!-- wasDerivedFrom: ${lineage.wasDerivedFrom.join(', ')} -->`);
312
+ }
313
+ return out;
314
+ }
315
+
316
+ const MARKER_RE = /^<!--\s*(?:wasGeneratedBy|wasDerivedFrom):[^>]*-->\s*$/;
317
+
318
+ /**
319
+ * Stamp PROV-O lineage markers into an artifact's content, idempotently.
320
+ * Existing wasGeneratedBy/wasDerivedFrom marker lines are removed and replaced,
321
+ * so re-stamping never duplicates. A leading `<!-- phase: -->` marker, if any
322
+ * artifact still carries one, is left untouched and lineage is inserted after it.
323
+ * Returns the new content (unchanged if lineage is empty and no markers present).
324
+ *
325
+ * Pure string transform — does not touch the filesystem. Callers persist.
326
+ *
327
+ * @param {string} content
328
+ * @param {{ wasGeneratedBy: string|null, wasDerivedFrom: string[] }} lineage
329
+ * @returns {string}
330
+ */
331
+ export function stampLineageContent(content, lineage) {
332
+ const markers = renderMarkers(lineage);
333
+ const lines = content.split('\n');
334
+
335
+ // Remove any existing lineage markers (anywhere in the header region or body —
336
+ // they are only ever ours), preserving everything else.
337
+ const kept = lines.filter((line) => !MARKER_RE.test(line.trim()));
338
+
339
+ if (markers.length === 0) return kept.join('\n');
340
+
341
+ // Insert markers directly after a leading `<!-- phase: -->` marker if present,
342
+ // otherwise at the very top, so all lineage lives together in the header.
343
+ let insertAt = 0;
344
+ if (kept[0] && /^<!--\s*phase:/.test(kept[0].trim())) insertAt = 1;
345
+ kept.splice(insertAt, 0, ...markers);
346
+ return kept.join('\n');
347
+ }
348
+
349
+ /**
350
+ * Stamp PROV-O lineage markers onto every existing canonical artifact in a
351
+ * feature folder, on disk. Effective lineage comes from `lineageOf`, so any
352
+ * hand-authored marker override is preserved (re-written identically, no diff).
353
+ * Idempotent: running twice makes no second change.
354
+ *
355
+ * This is the "lifecycle writer" surface — the point at which lineage markers
356
+ * are materialised into artifacts during the lifecycle (invoked by
357
+ * `compose lineage stamp`).
358
+ *
359
+ * @param {string} featureDir
360
+ * @returns {Array<{ file: string, changed: boolean, wasGeneratedBy: string|null, wasDerivedFrom: string[] }>}
361
+ */
362
+ export function stampFeatureLineage(featureDir) {
363
+ const existing = CHAIN_FILES.filter((f) => existsSync(join(featureDir, f)));
364
+ const out = [];
365
+ for (const file of existing) {
366
+ const filePath = join(featureDir, file);
367
+ let content;
368
+ try {
369
+ content = readFileSync(filePath, 'utf-8');
370
+ } catch {
371
+ continue;
372
+ }
373
+ const lin = lineageOf(featureDir, file, existing);
374
+ const next = stampLineageContent(content, lin);
375
+ const changed = next !== content;
376
+ if (changed) {
377
+ // Stamping lineage markers is a metadata annotation, NOT a regeneration of
378
+ // the artifact — it must not reset the mtime that findStaleDescendants uses
379
+ // as the derivation clock, or a retrofit stamp would erase existing
380
+ // staleness. Preserve the original mtime across the write.
381
+ let mtime = null;
382
+ try {
383
+ mtime = statSync(filePath).mtime;
384
+ } catch { /* no prior stat — leave mtime as written */ }
385
+ writeFileSync(filePath, next);
386
+ if (mtime) {
387
+ try {
388
+ utimesSync(filePath, mtime, mtime);
389
+ } catch { /* best effort */ }
390
+ }
391
+ }
392
+ out.push({
393
+ file,
394
+ changed,
395
+ wasGeneratedBy: lin.wasGeneratedBy,
396
+ wasDerivedFrom: lin.wasDerivedFrom,
397
+ });
398
+ }
399
+ return out;
400
+ }
@@ -1,3 +1,5 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { processTermination } from './process-termination.js';
1
3
  /**
2
4
  * local-claude-connector.js — V2/V3 (STRAT-TS-FANOUT-CONSUMER).
3
5
  *
@@ -123,6 +125,20 @@ function recordDispatch(dispatchId, context, capture, outcome, elapsedMs, effort
123
125
  */
124
126
  export async function runLocalClaudeAgent(prompt, opts = {}) {
125
127
  const query = opts.query ?? sdkQuery;
128
+ const controller = opts.abortController;
129
+ controller?.signal.throwIfAborted();
130
+ // C2: group cancellation needs POSIX process groups. Windows has none, so
131
+ // instead of refusing the run (which killed EVERY local dispatch there,
132
+ // cancellable or not) we skip the custom spawn hook and let the SDK's own
133
+ // abortController tear the run down. That is weaker — the SDK kills only the
134
+ // CLI leader, so grandchildren can outlive a cancel — but a non-cancel run
135
+ // must never fail before spawn.
136
+ const platform = opts.platform ?? process.platform;
137
+ const ownProcessGroup = Boolean(controller) && platform !== 'win32';
138
+ const children = [];
139
+ let stderr = '';
140
+ const terminate = () => { for (const child of children) void child.terminate(); };
141
+ controller?.signal.addEventListener('abort', terminate, { once: true });
126
142
  const { telemetry } = opts;
127
143
  const telemetryContext = telemetry && typeof telemetry === 'object'
128
144
  ? telemetry
@@ -138,6 +154,24 @@ export async function runLocalClaudeAgent(prompt, opts = {}) {
138
154
  model: opts.model ?? process.env.CLAUDE_MODEL ?? 'claude-sonnet-4-6',
139
155
  permissionMode: 'acceptEdits',
140
156
  env,
157
+ ...(ownProcessGroup ? { spawnClaudeCodeProcess: (options) => {
158
+ controller.signal.throwIfAborted();
159
+ const child = spawn(options.command, options.args, {
160
+ cwd: options.cwd, env: options.env, detached: true, stdio: ['pipe', 'pipe', 'pipe'],
161
+ });
162
+ const termination = processTermination(child, true, opts.cancellationGraceMs);
163
+ children.push(termination);
164
+ child.stderr.setEncoding('utf8');
165
+ child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-16384); });
166
+ const sdkAbort = () => { void termination.terminate(); };
167
+ options.signal.addEventListener('abort', sdkAbort, { once: true });
168
+ void termination.close.then(() => options.signal.removeEventListener('abort', sdkAbort));
169
+ if (options.signal.aborted) sdkAbort();
170
+ return { stdin: child.stdin, stdout: child.stdout, pid: child.pid,
171
+ get killed() { return child.killed; }, get exitCode() { return child.exitCode; },
172
+ kill: () => { void termination.terminate(); return true; },
173
+ on: child.on.bind(child), once: child.once.bind(child), off: child.off.bind(child) };
174
+ } } : {}),
141
175
  ...(opts.abortController ? { abortController: opts.abortController } : {}),
142
176
  ...(opts.thinking !== undefined ? { thinking: opts.thinking } : {}),
143
177
  ...(appliedEffort !== null ? { effort: appliedEffort } : {}),
@@ -189,7 +223,12 @@ export async function runLocalClaudeAgent(prompt, opts = {}) {
189
223
  if (raw.type === 'assistant' && isRecord(raw.message) && Array.isArray(raw.message.content)) {
190
224
  for (const block of raw.message.content) {
191
225
  if (!isRecord(block)) continue;
192
- if (block.type === 'text' && typeof block.text === 'string') assistantText += block.text;
226
+ if (block.type === 'text' && typeof block.text === 'string') {
227
+ assistantText += block.text;
228
+ // COMP-AGENT-LANES: optional live-relay seam, mirroring onToolUse.
229
+ // Without a subscriber the text only accumulates (historical shape).
230
+ if (typeof opts.onAssistantText === 'function') opts.onAssistantText(block.text);
231
+ }
193
232
  if (block.type === 'tool_use' && typeof block.name === 'string' && typeof opts.onToolUse === 'function') {
194
233
  opts.onToolUse({ tool: block.name, input: isRecord(block.input) ? block.input : {} });
195
234
  }
@@ -239,6 +278,12 @@ export async function runLocalClaudeAgent(prompt, opts = {}) {
239
278
  }
240
279
  }
241
280
 
281
+ // H2: do NOT throwIfAborted() here. A run that RESOLVES after its
282
+ // timeout/abort fired still reported billable usage, and the caller
283
+ // (result-normalizer's late-resolve branch) is the single place that turns
284
+ // that into AgentTimeoutError/AgentAbortedError/UserInterruptError with the
285
+ // usage attached. Throwing a bare AbortError here loses the usage and the
286
+ // attempt is never billed.
242
287
  const result = {
243
288
  text: finalText ?? assistantText,
244
289
  usage: {
@@ -289,6 +334,12 @@ export async function runLocalClaudeAgent(prompt, opts = {}) {
289
334
  appliedEffort,
290
335
  );
291
336
  attachDispatchId(error, dispatchId);
337
+ if (controller?.signal.aborted) { terminate(); await Promise.all(children.map(child => child.finish())); }
338
+ if (stderr) { error.stderr = stderr; error.cause ??= new Error(stderr.trim()); }
292
339
  throw error;
340
+ } finally {
341
+ controller?.signal.removeEventListener('abort', terminate);
342
+ if (controller?.signal.aborted) terminate();
343
+ await Promise.all(children.map(child => child.finish()));
293
344
  }
294
345
  }