@smartmemory/compose 0.3.6-beta → 0.3.8

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 (269) hide show
  1. package/.claude/skills/compose/SKILL.md +42 -88
  2. package/.compose-deps.json +1 -13
  3. package/README.md +72 -5
  4. package/bin/compose.js +754 -347
  5. package/bin/git-hooks/pre-push.template +29 -0
  6. package/bin/judgment-import.js +7 -0
  7. package/bin/judgment-migrate.js +387 -0
  8. package/contracts/comp-obs-contract.schema.json +9 -3
  9. package/contracts/feature-json.schema.json +5 -0
  10. package/contracts/fluid-record.schema.json +209 -0
  11. package/contracts/judgment-record.schema.json +425 -4
  12. package/contracts/lifecycle-backfill.schema.json +322 -0
  13. package/dist/assets/App-Z4MU-H_F.js +916 -0
  14. package/dist/assets/_baseUniq-ClWoCPFl.js +1 -0
  15. package/dist/assets/arc-DY26UIVo.js +1 -0
  16. package/dist/assets/architectureDiagram-Q4EWVU46-6Ggq4DqJ.js +36 -0
  17. package/dist/assets/blockDiagram-DXYQGD6D-CH3Ked0l.js +132 -0
  18. package/dist/assets/{browser-BSM23If2.js → browser-BWkrenen.js} +6 -6
  19. package/dist/assets/{c4Diagram-LMCZKHZV-DZf45Fbz.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  20. package/dist/assets/channel-SnZzzh7k.js +1 -0
  21. package/dist/assets/{chunk-JWPE2WC7-_7ujgd_Q.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  22. package/dist/assets/chunk-4TB4RGXK-JytR14a9.js +206 -0
  23. package/dist/assets/{chunk-XXDRQBXY-DfdVhbmA.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  24. package/dist/assets/{chunk-VR4S4FIN-Dt9NZ67m.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  25. package/dist/assets/{chunk-5VM5RSS4-BY4_PV5H.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  26. package/dist/assets/chunk-OYMX7WX6-BySQzVxc.js +231 -0
  27. package/dist/assets/{chunk-2Q5K7J3B-Dn1spZYu.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  28. package/dist/assets/{chunk-32BRIVSS-pURGrJDk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  29. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  30. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  31. package/dist/assets/clone-DgklGjHm.js +1 -0
  32. package/dist/assets/{cose-bilkent-JH36ORCC-BieYif4o.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  33. package/dist/assets/dagre-KV5264BT-Cp3F5KTn.js +4 -0
  34. package/dist/assets/diagram-5BDNPKRD-DiR6_2q_.js +10 -0
  35. package/dist/assets/diagram-G4DWMVQ6-w0i-p5HX.js +24 -0
  36. package/dist/assets/diagram-MMDJMWI5-tIHhwUv3.js +43 -0
  37. package/dist/assets/diagram-TYMM5635-BAeY3B19.js +24 -0
  38. package/dist/assets/erDiagram-SMLLAGMA-Ckx_Knko.js +85 -0
  39. package/dist/assets/flowDiagram-DWJPFMVM-DeoNka6J.js +162 -0
  40. package/dist/assets/ganttDiagram-T4ZO3ILL-BmGnFbEg.js +292 -0
  41. package/dist/assets/gitGraphDiagram-UUTBAWPF-Dk48IHsx.js +106 -0
  42. package/dist/assets/graph-BNzKGvoy.js +1 -0
  43. package/dist/assets/graph-CI_1htl0.js +331 -0
  44. package/dist/assets/index-BEfrNBp8.js +123 -0
  45. package/dist/assets/index-yyrA5OZd.css +1 -0
  46. package/dist/assets/infoDiagram-42DDH7IO-BRf827i0.js +2 -0
  47. package/dist/assets/{ishikawaDiagram-FXEZZL3T-CzEB9fQS.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +5 -5
  48. package/dist/assets/{journeyDiagram-5HDEW3XC-Bz8TCdz2.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  49. package/dist/assets/{kanban-definition-HUTT4EX6-tozrMoV_.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +7 -7
  50. package/dist/assets/katex-DkKDou_j.js +257 -0
  51. package/dist/assets/layout-BI8cXFPI.js +1 -0
  52. package/dist/assets/{linear-Ck7gpa5N.js → linear-a0glcDiw.js} +1 -1
  53. package/dist/assets/min-vPHfnXcC.js +1 -0
  54. package/dist/assets/{mindmap-definition-LN4V7U3C-DTcHO0DJ.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +7 -7
  55. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  56. package/dist/assets/pieDiagram-DEJITSTG-Cno-gETh.js +30 -0
  57. package/dist/assets/quadrantDiagram-34T5L4WZ-BUQM1Hfm.js +7 -0
  58. package/dist/assets/{requirementDiagram-TGXJPOKE-bnI2zJeT.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +3 -3
  59. package/dist/assets/sankeyDiagram-XADWPNL6-Crynd3_b.js +10 -0
  60. package/dist/assets/sequenceDiagram-FGHM5R23-D9fZdCM8.js +157 -0
  61. package/dist/assets/stateDiagram-FHFEXIEX-CW9qVec8.js +1 -0
  62. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  63. package/dist/assets/{timeline-definition-FHXFAJF6-D267GQFF.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +3 -3
  64. package/dist/assets/vennDiagram-DHZGUBPP-BfytJcWk.js +34 -0
  65. package/dist/assets/wardley-RL74JXVD-DLj-IjyB.js +162 -0
  66. package/dist/assets/wardleyDiagram-NUSXRM2D-Ds0Ue68c.js +20 -0
  67. package/dist/assets/xychartDiagram-5P7HB3ND-vjWDXFL6.js +7 -0
  68. package/dist/index.html +3 -3
  69. package/lib/agent-string.js +7 -5
  70. package/lib/append-integrity.js +81 -0
  71. package/lib/backfill-evidence.js +109 -0
  72. package/lib/bug-escalation.js +39 -4
  73. package/lib/build-stream-schema.js +3 -1
  74. package/lib/build-stream-writer.js +25 -0
  75. package/lib/build.js +1624 -195
  76. package/lib/canon-guard.js +245 -0
  77. package/lib/canon-override.js +196 -0
  78. package/lib/canon-registry.js +291 -0
  79. package/lib/cli-commands.js +144 -0
  80. package/lib/codex-preflight.js +50 -15
  81. package/lib/colleague/context.js +215 -0
  82. package/lib/colleague/writeback.js +95 -0
  83. package/lib/completion-gate.js +1421 -0
  84. package/lib/completion-writer.js +47 -47
  85. package/lib/consumer-fanout.js +105 -11
  86. package/lib/coverage-gate.js +200 -0
  87. package/lib/dir-lock.js +170 -0
  88. package/lib/dispatch-ledger.js +301 -0
  89. package/lib/dispatch-metrics.js +236 -0
  90. package/lib/experiment-judge.js +6 -1
  91. package/lib/feature-json.js +1 -1
  92. package/lib/feature-reconciler.js +8 -0
  93. package/lib/feature-validator.js +64 -1
  94. package/lib/feature-writer.js +66 -2
  95. package/lib/fluid/factory.js +167 -0
  96. package/lib/fluid/ideabox-dates.js +73 -0
  97. package/lib/fluid/ideabox-migrate.js +154 -0
  98. package/lib/fluid/ideabox-ops.js +585 -0
  99. package/lib/fluid/ideabox-view.js +146 -0
  100. package/lib/fluid/import-ideabox.js +186 -0
  101. package/lib/fluid/local-provider.js +606 -0
  102. package/lib/fluid/provider.js +684 -0
  103. package/lib/fluid/record-shape.js +214 -0
  104. package/lib/fluid/record-store.js +328 -0
  105. package/lib/fluid/render-ideabox.js +261 -0
  106. package/lib/fluid/schema.js +40 -0
  107. package/lib/fluid/smartmemory-provider.js +1695 -0
  108. package/lib/gsd.js +63 -14
  109. package/lib/guard-cli.js +175 -0
  110. package/lib/guard-custody.js +141 -0
  111. package/lib/guard-descriptors.js +530 -0
  112. package/lib/guard-enrol.js +254 -0
  113. package/lib/health-score.js +1 -1
  114. package/lib/hooks-status.js +32 -3
  115. package/lib/ideabox-cli.js +315 -0
  116. package/lib/ideabox.js +121 -21
  117. package/lib/judgment/store/index.js +166 -0
  118. package/lib/judgment/store/records.js +184 -25
  119. package/lib/judgment/trace.js +380 -0
  120. package/lib/judgment-attest.js +259 -0
  121. package/lib/judgment-decision-write.js +277 -0
  122. package/lib/judgment-decisions.js +466 -0
  123. package/lib/judgment-gen.js +375 -22
  124. package/lib/judgment-verify.js +153 -0
  125. package/lib/judgment-writer.js +2842 -262
  126. package/lib/lane-gate.js +2 -0
  127. package/lib/lifecycle-modes.js +4 -4
  128. package/lib/lineage.js +400 -0
  129. package/lib/local-claude-connector.js +250 -54
  130. package/lib/maya-client.js +302 -0
  131. package/lib/maya-config.js +53 -0
  132. package/lib/maya-identity.js +283 -0
  133. package/lib/mcp-enforcement.js +21 -35
  134. package/lib/migrate-anon.js +5 -0
  135. package/lib/migrate-roadmap.js +15 -0
  136. package/lib/new.js +13 -1
  137. package/lib/pipeline-compat.js +104 -0
  138. package/lib/policy-catalog.js +295 -0
  139. package/lib/policy-check.js +0 -0
  140. package/lib/process-termination.js +98 -0
  141. package/lib/resolve-workspace.js +5 -1
  142. package/lib/result-normalizer.js +428 -153
  143. package/lib/review-normalize.js +4 -0
  144. package/lib/roadmap-errors.js +65 -0
  145. package/lib/roadmap-preservers.js +24 -4
  146. package/lib/roadmap-residue.js +299 -0
  147. package/lib/smartmemory-client.js +614 -78
  148. package/lib/smartmemory-config.js +54 -0
  149. package/lib/smartmemory-ingest.js +19 -2
  150. package/lib/step-prompt.js +7 -6
  151. package/lib/stratum-engine.js +53 -4
  152. package/lib/stratum-mcp-client.js +391 -31
  153. package/lib/test-bootstrap.js +31 -0
  154. package/lib/tool-inventory.js +122 -0
  155. package/lib/version-check.js +91 -19
  156. package/lib/vision-writer.js +88 -1
  157. package/package.json +7 -6
  158. package/pipelines/bug-fix.stratum.yaml +205 -211
  159. package/pipelines/build-quick.profiles.json +12 -0
  160. package/pipelines/build-quick.stratum.yaml +263 -350
  161. package/pipelines/content.stratum.yaml +81 -77
  162. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  163. package/pipelines/plan.stratum.yaml +76 -86
  164. package/pipelines/refactor.stratum.yaml +125 -125
  165. package/pipelines/research.stratum.yaml +56 -58
  166. package/pipelines/review-fix.profiles.json +6 -0
  167. package/pipelines/review-fix.stratum.yaml +110 -83
  168. package/presets/team-feature.profiles.json +6 -0
  169. package/presets/team-feature.stratum.yaml +93 -66
  170. package/presets/team-research.profiles.json +6 -0
  171. package/presets/team-research.stratum.yaml +89 -80
  172. package/presets/team-review.profiles.json +8 -0
  173. package/presets/team-review.stratum.yaml +98 -80
  174. package/scripts/cost-census.mjs +70 -0
  175. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  176. package/server/agent-health.js +22 -0
  177. package/server/agent-hooks.js +14 -1
  178. package/server/agent-server.js +5 -248
  179. package/server/agent-spawn.js +3 -4
  180. package/server/agent-workspace.js +294 -0
  181. package/server/build-routes.js +6 -5
  182. package/server/build-stream-bridge.js +53 -0
  183. package/server/cc-session-watcher.js +4 -1
  184. package/server/coalescing-buffer.js +7 -1
  185. package/server/completion-projection.js +228 -0
  186. package/server/compose-mcp-tools.js +124 -24
  187. package/server/compose-mcp.js +91 -790
  188. package/server/decision-event-emit.js +41 -2
  189. package/server/decision-event-id.js +17 -0
  190. package/server/decision-events-snapshot.js +3 -0
  191. package/server/design-routes.js +14 -8
  192. package/server/feature-scan.js +76 -2
  193. package/server/file-watcher.js +170 -21
  194. package/server/ideabox-routes.js +166 -224
  195. package/server/index.js +70 -100
  196. package/server/lifecycle-guard.js +240 -10
  197. package/server/lifecycle-phase-history.js +276 -0
  198. package/server/maya-routes.js +507 -0
  199. package/server/mcp-tool-defs.js +940 -0
  200. package/server/mcp-tool-policy.js +35 -3
  201. package/server/model-tiers.js +22 -5
  202. package/server/pipeline-routes.js +21 -11
  203. package/server/project-root.js +58 -19
  204. package/server/remote-utils.js +3 -1
  205. package/server/schema-validator.js +7 -1
  206. package/server/session-manager.js +5 -6
  207. package/server/session-routes.js +3 -1
  208. package/server/stratum-client.js +57 -10
  209. package/server/stratum-sync.js +6 -3
  210. package/server/summarizer.js +3 -4
  211. package/server/supervisor.js +0 -1
  212. package/server/vision-routes.js +208 -98
  213. package/server/vision-server.js +86 -23
  214. package/server/vision-store.js +60 -6
  215. package/server/vision-utils.js +3 -4
  216. package/server/workspace-activity.js +18 -0
  217. package/server/workspace-middleware.js +2 -2
  218. package/server/workspace-runtime.js +243 -0
  219. package/server/worktree-gc.js +1 -0
  220. package/dist/assets/App-BG3ngu8H.js +0 -896
  221. package/dist/assets/abnfDiagram-VRR7QNED-CjB_sD3D.js +0 -1
  222. package/dist/assets/arc-_v4hR_uD.js +0 -1
  223. package/dist/assets/architectureDiagram-ZJ3FMSHR-DreJmzXQ.js +0 -36
  224. package/dist/assets/blockDiagram-677ZJIJ3-BG9-c0O1.js +0 -132
  225. package/dist/assets/channel-B3U5wFAT.js +0 -1
  226. package/dist/assets/chunk-EX3LRPZG-DdELs1qP.js +0 -231
  227. package/dist/assets/chunk-MOJQB5TN-D-ky35G-.js +0 -88
  228. package/dist/assets/chunk-RYQCIY6F-Dag_kVlO.js +0 -1
  229. package/dist/assets/chunk-V7JOEXUC-BtewURat.js +0 -206
  230. package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +0 -1
  231. package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +0 -1
  232. package/dist/assets/cynefin-VYW2F7L2-CT2BA6KE.js +0 -178
  233. package/dist/assets/cynefinDiagram-TSTJHNR4-Bh6exbyg.js +0 -62
  234. package/dist/assets/dagre-VKFMJZFB-aXMLSmQL.js +0 -4
  235. package/dist/assets/diagram-FQU43EPY-Dr7JAOuQ.js +0 -3
  236. package/dist/assets/diagram-G47NLZAW-DUvA3FQK.js +0 -24
  237. package/dist/assets/diagram-NH7WQ7WH-BQUARqcu.js +0 -24
  238. package/dist/assets/diagram-OA4YK3LP-dDUc1zHi.js +0 -30
  239. package/dist/assets/diagram-WEI45ONY-B2h5Qlb1.js +0 -41
  240. package/dist/assets/ebnfDiagram-CCIWWBDH-DThRGupB.js +0 -1
  241. package/dist/assets/erDiagram-Q63AITRT-BUCsprO2.js +0 -85
  242. package/dist/assets/flowDiagram-23GEKE2U-DXtNNi6r.js +0 -156
  243. package/dist/assets/ganttDiagram-NO4QXBWP-D4zbBHh_.js +0 -292
  244. package/dist/assets/gitGraphDiagram-IHSO6WYX-DpoQws0W.js +0 -106
  245. package/dist/assets/graph-BXPQrYYB.js +0 -331
  246. package/dist/assets/graph-C9eacEi8.js +0 -1
  247. package/dist/assets/index-3ZH5eMcZ.js +0 -119
  248. package/dist/assets/index-LIwREYgH.css +0 -1
  249. package/dist/assets/infoDiagram-FWYZ7A6U-Bbas2GAo.js +0 -2
  250. package/dist/assets/katex-C5jXJg4s.js +0 -257
  251. package/dist/assets/layout-DEXfKzaS.js +0 -1
  252. package/dist/assets/map-Czzmt4hB.js +0 -1
  253. package/dist/assets/mobile-CaoXUwAr.js +0 -17
  254. package/dist/assets/pegDiagram-2B236MQR-CHiINrNy.js +0 -1
  255. package/dist/assets/pieDiagram-ENE6RG2P-CfS4YFlR.js +0 -39
  256. package/dist/assets/quadrantDiagram-ABIIQ3AL-CadesS9w.js +0 -7
  257. package/dist/assets/railroadDiagram-RFXS5EU6-CgWEspBN.js +0 -1
  258. package/dist/assets/sankeyDiagram-HTMAVEWB-YWKFgOGw.js +0 -40
  259. package/dist/assets/sequenceDiagram-DBY2YBRQ-BvkNOyF9.js +0 -162
  260. package/dist/assets/sizeCapture-X5ZJPWSS-DlFPA2yO.js +0 -1
  261. package/dist/assets/stateDiagram-2N3HPSRC-h8NIx0kQ.js +0 -1
  262. package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +0 -1
  263. package/dist/assets/swimlanes-5IMT3BWC-CT5n22kG.js +0 -2
  264. package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +0 -8
  265. package/dist/assets/vennDiagram-L72KCM5P-Dj-wWLYG.js +0 -34
  266. package/dist/assets/wardleyDiagram-EHGQE667-BxCeYxkG.js +0 -78
  267. package/dist/assets/xychartDiagram-FW5EYKEG-DMFqWn7z.js +0 -7
  268. package/lib/staleness.js +0 -87
  269. package/server/ideabox-cache.js +0 -77
package/lib/gsd.js CHANGED
@@ -26,7 +26,7 @@ import { validateBoundaryMap } from './boundary-map.js';
26
26
  import { enrichTaskGraph } from './gsd-decompose-enrich.js';
27
27
  import { buildTaskDescription } from './gsd-prompt.js';
28
28
  import { writeAll, validate as validateTaskResult, read as readBlackboard } from './gsd-blackboard.js';
29
- import { executeShipStep, runConsumerIssuance, ConsumerStuckError, filesOwnedConflict, toEngineUsage } from './build.js';
29
+ import { executeShipStep, toPhaseResultOutput, runConsumerIssuance, ConsumerStuckError, filesOwnedConflict, toEngineUsage, reportUsageReceipts } from './build.js';
30
30
  import {
31
31
  ConsumerFanoutArtifacts,
32
32
  ConsumerMergeDecisionError,
@@ -175,6 +175,9 @@ export async function runGsd(featureCode, opts = {}) {
175
175
  const stratum = opts.stratum ?? new StratumMcpClient();
176
176
  const ownsStratum = !opts.stratum;
177
177
  if (ownsStratum) await stratum.connect(resolveStratumMcpConnection(cwd));
178
+ const receiptsMode = typeof stratum.hasTool === 'function'
179
+ ? await stratum.hasTool('stratum_usage_report')
180
+ : false;
178
181
  // COMP-GSD-4: ownership flag — release the resume lock in finally ONLY if THIS
179
182
  // process successfully claimed it (set below). Prevents (a) a non-resume run
180
183
  // from clobbering a concurrent resume's valid claim and (b) a claim-race loser
@@ -231,6 +234,7 @@ export async function runGsd(featureCode, opts = {}) {
231
234
  // docs unless context.filesChanged is provided.
232
235
  stepCtx = {
233
236
  stratum, cwd, featureCode, blueprintText, gateCommands, preMergeGate,
237
+ receiptsMode,
234
238
  localSpec, localSpecDigest,
235
239
  filesChanged: [],
236
240
  stuckDetector,
@@ -289,6 +293,7 @@ export async function runGsd(featureCode, opts = {}) {
289
293
  pre_merge_gate: preMergeGate,
290
294
  }, { workspaceRoot: cwd });
291
295
  const flowId = response.runId;
296
+ stepCtx.flowId = flowId;
292
297
  flushState(stepCtx, { flowId, phase: 'decompose' });
293
298
  emitPhaseOnce(stepCtx, 'decompose'); // COMP-GSD-7-EVENTLOG
294
299
 
@@ -518,7 +523,7 @@ async function runOneStep(response, ctx) {
518
523
  // by definition a GSD task, so timing.json + diffs/<id>.diff are written
519
524
  // for the milestone report. The build consumer path omits it (no marker),
520
525
  // so build-mode fanout stays byte-identical.
521
- context: { cwd, featureCode, gsd: true, gsdTaskId: consumerItem?.id, filesChanged: ctx.filesChanged, onUsage: (usage) => recordTsAgentUsage(ctx, usage), taskResultPath },
526
+ context: { cwd, projectCwd: cwd, featureCode, flowId, receiptsMode: ctx.receiptsMode, gsd: true, gsdTaskId: consumerItem?.id, filesChanged: ctx.filesChanged, onUsage: (usage, meta) => recordTsAgentUsage(ctx, usage, meta), taskResultPath },
522
527
  // runAndNormalize narrates via progress.debug/warn/info/toolUse — gsd
523
528
  // has no cockpit, so a COMPLETE no-op (not just stepStart/stepDone) is
524
529
  // required or the item throws "progress.debug is not a function".
@@ -554,7 +559,14 @@ async function runOneStep(response, ctx) {
554
559
  '',
555
560
  null,
556
561
  );
557
- return stratum.stepDone(flowId, step.id, { output: result }, step.dispatchToken);
562
+ // COMP-SHIP-CONTRACT: narrow to PhaseResult before reporting. The raw
563
+ // return carries `commit`/`filesChanged`/`testsAttested`, which the
564
+ // strict contract rejects — attempt 1 was failing on unrecognized keys and
565
+ // only "succeeding" because attempt 2 re-ran ship, found nothing left to
566
+ // stage, and returned the field-less "already committed" shape.
567
+ return stratum.stepDone(
568
+ flowId, step.id, { output: toPhaseResultOutput(result) }, step.dispatchToken,
569
+ );
558
570
  }
559
571
  if (step.id === 'decompose_gsd' && ctx.resumeTaskGraph) {
560
572
  ctx.lastTaskGraph = ctx.resumeTaskGraph;
@@ -565,12 +577,45 @@ async function runOneStep(response, ctx) {
565
577
 
566
578
  // D2(b): dispatch via agentRun (not runAgentText) so the TS complete
567
579
  // envelope's usage is captured and debited into the cumulative ledger.
568
- const runOut = await stratum.agentRun(step.agent ?? 'claude', step.do ?? '', { cwd });
580
+ let runOut;
581
+ try {
582
+ runOut = await stratum.agentRun(step.agent ?? 'claude', step.do ?? '', {
583
+ cwd,
584
+ telemetry: {
585
+ site: 'gsd',
586
+ project_cwd: cwd,
587
+ feature_code: featureCode,
588
+ step_id: step.id,
589
+ ...(typeof step.attempt === 'number' ? { attempt: step.attempt } : {}),
590
+ },
591
+ });
592
+ } catch (error) {
593
+ if (error?.usage) {
594
+ await recordTsAgentUsage(ctx, error.usage, {
595
+ stepId: step.id,
596
+ source: 'main',
597
+ dispatchId: error.dispatchId,
598
+ });
599
+ }
600
+ throw error;
601
+ }
569
602
  const text = runOut?.text ?? '';
570
- recordTsAgentUsage(ctx, runOut?.usage);
603
+ const directUsage = runOut?.usage && typeof runOut.usage === 'object'
604
+ ? {
605
+ ...runOut.usage,
606
+ dispatch_id: runOut.dispatchId,
607
+ model: runOut.telemetry?.model ?? runOut.usage.model ?? 'unknown',
608
+ ...(runOut.telemetry?.effort ? { effort: runOut.telemetry.effort } : {}),
609
+ duration_ms: runOut.telemetry?.durationMs ?? runOut.usage.duration_ms ?? runOut.usage.ms ?? 0,
610
+ ...(['reported', 'estimated'].includes(runOut.usage.usd_source)
611
+ ? { usd_source: runOut.usage.usd_source }
612
+ : {}),
613
+ }
614
+ : runOut?.usage;
615
+ await recordTsAgentUsage(ctx, directUsage, { stepId: step.id, source: 'main' });
571
616
  // V1: also report usage in the step_done envelope so the ENGINE debits its
572
617
  // flow budget (not just compose's cumulative ledger above).
573
- const ordinaryUsage = toEngineUsage(runOut?.usage);
618
+ const ordinaryUsage = ctx.receiptsMode ? null : toEngineUsage(runOut?.usage);
574
619
  let result;
575
620
  try {
576
621
  result = parseJsonFromText(text);
@@ -1411,21 +1456,25 @@ const NOOP_PROGRESS = Object.freeze({
1411
1456
  * shape from runAndNormalize ({input_tokens, output_tokens, cost_usd,
1412
1457
  * duration_ms}) or the raw TS complete usage ({tokens, usd, ms}).
1413
1458
  */
1414
- export function recordTsAgentUsage(ctx, usage) {
1459
+ export async function recordTsAgentUsage(ctx, usage, meta) {
1415
1460
  if (!usage || !ctx?.cwd || !ctx?.featureCode) return;
1416
1461
  const tokens = usage.tokens ?? ((usage.input_tokens ?? 0) + (usage.output_tokens ?? 0));
1417
1462
  const costUsd = usage.cost_usd ?? usage.usd ?? 0;
1418
1463
  const timeMs = usage.duration_ms ?? usage.ms ?? 0;
1419
- if (tokens === 0 && costUsd === 0 && timeMs === 0) return;
1420
- recordGsdUsage(join(ctx.cwd, '.compose'), ctx.featureCode, { tokens, costUsd, timeMs, dispatches: 1 });
1464
+ if (tokens !== 0 || costUsd !== 0 || timeMs !== 0) {
1465
+ recordGsdUsage(join(ctx.cwd, '.compose'), ctx.featureCode, { tokens, costUsd, timeMs, dispatches: 1 });
1466
+ }
1421
1467
  // G4: remember what THIS run recorded incrementally so the terminal/budget-halt
1422
1468
  // fold (recordGsdUsageFromState) records only the engine-only delta, never
1423
1469
  // double-counting these envelope-reported invocations.
1424
- ctx.recordedUsage ??= { tokens: 0, costUsd: 0, dispatches: 0, timeMs: 0 };
1425
- ctx.recordedUsage.tokens += tokens;
1426
- ctx.recordedUsage.costUsd += costUsd;
1427
- ctx.recordedUsage.timeMs += timeMs;
1428
- ctx.recordedUsage.dispatches += 1;
1470
+ if (tokens !== 0 || costUsd !== 0 || timeMs !== 0) {
1471
+ ctx.recordedUsage ??= { tokens: 0, costUsd: 0, dispatches: 0, timeMs: 0 };
1472
+ ctx.recordedUsage.tokens += tokens;
1473
+ ctx.recordedUsage.costUsd += costUsd;
1474
+ ctx.recordedUsage.timeMs += timeMs;
1475
+ ctx.recordedUsage.dispatches += 1;
1476
+ }
1477
+ return reportUsageReceipts(ctx, usage, meta);
1429
1478
  }
1430
1479
 
1431
1480
  /**
@@ -0,0 +1,175 @@
1
+ import { execFile as defaultExecFile } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { readFile as defaultReadFile } from 'node:fs/promises';
4
+ import { createRequire } from 'node:module';
5
+ import path from 'node:path';
6
+
7
+ import { acquireDirLock as defaultAcquireDirLock } from './dir-lock.js';
8
+ import { CUSTODY_TIMEOUT_MS, custodyBackend, custodySign, custodyStatus, DESCRIPTOR_LOCK_TIMEOUT_MS } from './guard-custody.js';
9
+ import {
10
+ currentGeneration as defaultCurrentGeneration, descriptorIdFor, ensureSignedDescriptors as defaultEnsureSignedDescriptors,
11
+ enumerateRegisteredResources as defaultEnumerateRegisteredResources, prepareUnsignedCandidate as defaultPrepareUnsignedCandidate,
12
+ pruneGenerations as defaultPruneGenerations, verifyGeneration as defaultVerifyGeneration,
13
+ } from './guard-descriptors.js';
14
+ import { guardDescriptors as defaultGuardDescriptors } from '../server/stratum-client.js';
15
+ import { runEnrol } from './guard-enrol.js';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const { parseAllowedSigners } = await import(require.resolve('@smartmemory/stratum/dist/guard/sshsig.js'));
19
+
20
+ const LOCK_RELATIVE = path.join('.compose', 'data', 'locks', 'guard-descriptors');
21
+ const LEGACY_FLAT = path.join('.compose', 'guard-upgrades.json');
22
+ const NAMESPACE = 'stratum-guard-descriptors';
23
+
24
+ function descriptorLock(workspaceRoot) { return path.join(workspaceRoot, LOCK_RELATIVE); }
25
+
26
+ function makeResult(status, lines, extra = {}) { return { status, lines, ...extra }; }
27
+
28
+ async function withLock(workspaceRoot, deps, operation) {
29
+ const release = await (deps.acquireDirLock ?? defaultAcquireDirLock)(descriptorLock(workspaceRoot), { timeoutMs: DESCRIPTOR_LOCK_TIMEOUT_MS });
30
+ try { return await operation(); } finally { release(); }
31
+ }
32
+
33
+ function asWorkspace(input) {
34
+ return typeof input === 'string' ? input : input?.workspaceRoot;
35
+ }
36
+
37
+ function manualSignLine(file) {
38
+ return `ssh-keygen -Y sign -f ~/.stratum/guard-signing -n ${NAMESPACE} ${file}`;
39
+ }
40
+
41
+ function signedLine(signature, count) {
42
+ const match = /^verified: signed by (.+) \(([^)]+)\)$/.exec(signature || '');
43
+ if (!match) return null;
44
+ return `signed by ${match[1]} (${match[2]}), ${count} descriptor(s), verified`;
45
+ }
46
+
47
+ async function descriptorCount(file, readFile) {
48
+ try {
49
+ const parsed = JSON.parse(await readFile(file, 'utf8'));
50
+ return Array.isArray(parsed?.descriptors) ? parsed.descriptors.length : 0;
51
+ } catch { return 0; }
52
+ }
53
+
54
+ /** Generate/sign descriptor generations. This returns lines; the bin owns stdout. */
55
+ export async function runGuardDescriptors(input, deps = {}) {
56
+ const workspaceRoot = asWorkspace(input);
57
+ if (!workspaceRoot) throw new Error('workspaceRoot is required');
58
+ try {
59
+ return await withLock(workspaceRoot, deps, async () => {
60
+ const backend = (deps.custodyBackend ?? custodyBackend)();
61
+ const enumerate = deps.enumerateRegisteredResources ?? defaultEnumerateRegisteredResources;
62
+ const resources = await enumerate(workspaceRoot);
63
+ const needChecksums = resources.map((resource) => resource.from_checksum ?? resource.fromChecksum).filter(Boolean);
64
+ const verifier = deps.guardDescriptors ?? defaultGuardDescriptors;
65
+ const ensured = await (deps.ensureSignedDescriptors ?? defaultEnsureSignedDescriptors)({
66
+ workspaceRoot,
67
+ needChecksums,
68
+ custody: { sign: (request) => (deps.custodySign ?? custodySign)(request) },
69
+ verifier,
70
+ enumerate,
71
+ });
72
+ if (ensured.status === 'refused') {
73
+ // Only the plain "no custody available" refusal falls back to the manual
74
+ // unsigned-candidate path. Any other refusal (e.g. an existing generation
75
+ // whose signature does not verify) must be returned as-is — falling back
76
+ // here would print a `ssh-keygen -Y sign` line targeting a path that
77
+ // already carries an invalid `.sig`, which is exactly the "sign over an
78
+ // invalid generation" path S1 forbids.
79
+ if (backend === 'none' && ensured.message === 'no signing custody on this platform') {
80
+ const candidate = await (deps.prepareUnsignedCandidate ?? defaultPrepareUnsignedCandidate)(workspaceRoot, { enumerate });
81
+ return makeResult('manual', [manualSignLine(candidate.path)], { path: candidate.path, sha: candidate.sha });
82
+ }
83
+ return makeResult('refused', [ensured.message, ensured.hint].filter(Boolean), ensured);
84
+ }
85
+ if (ensured.status === 'fresh') return makeResult('fresh', [`fresh: ${ensured.path}`], ensured);
86
+ const inspected = await verifier(ensured.path);
87
+ const verdict = signedLine(inspected?.signature, await descriptorCount(ensured.path, deps.readFile ?? defaultReadFile));
88
+ if (!verdict || inspected?.status !== 'ok' || inspected?.group_or_world_writable !== false) {
89
+ return makeResult('refused', [`signature did not verify: ${inspected?.signature || 'no verifier verdict'}`], { code: 'upgrade_descriptor_unavailable', path: ensured.path });
90
+ }
91
+ return makeResult('signed', [verdict], ensured);
92
+ });
93
+ } catch (error) {
94
+ return makeResult('refused', [error?.message || String(error)], { code: 'upgrade_descriptor_unavailable' });
95
+ }
96
+ }
97
+
98
+ /** Explicit signing uses the same safe generation path as descriptors. */
99
+ export async function runGuardSign(input, deps = {}) {
100
+ return runGuardDescriptors(input, deps);
101
+ }
102
+
103
+ export async function runGuardEnrol(options = {}, deps = {}) {
104
+ return runEnrol(options, deps);
105
+ }
106
+
107
+ function runCommand(execFile, file, args, options) {
108
+ return new Promise((resolve) => {
109
+ try {
110
+ execFile(file, args, options, (error, stdout = '', stderr = '') => resolve({ error, stdout, stderr }));
111
+ } catch (error) { resolve({ error, stdout: '', stderr: '' }); }
112
+ });
113
+ }
114
+
115
+ async function coverage(workspaceRoot, generation, deps) {
116
+ const enumerate = deps.enumerateRegisteredResources ?? defaultEnumerateRegisteredResources;
117
+ const readFile = deps.readFile ?? defaultReadFile;
118
+ try {
119
+ const resources = await enumerate(workspaceRoot);
120
+ if (!generation) return { covered: 0, total: resources.length };
121
+ const parsed = JSON.parse(await readFile(generation.file, 'utf8'));
122
+ const ids = new Set((parsed.descriptors || []).map((item) => item?.id));
123
+ const covered = resources.filter((resource) => {
124
+ const checksum = resource.from_checksum ?? resource.fromChecksum;
125
+ return (parsed.descriptors || []).some((item) => item?.from_checksum === checksum)
126
+ || ids.has(descriptorIdFor(checksum, resource.mode));
127
+ }).length;
128
+ return { covered, total: resources.length };
129
+ } catch (error) { return { error: error?.message || String(error) }; }
130
+ }
131
+
132
+ /** Read-only signing diagnostics. `--prune` is deliberately handled by the caller first. */
133
+ export async function signingStatusLines(input, deps = {}) {
134
+ const workspaceRoot = asWorkspace(input);
135
+ if (!workspaceRoot) throw new Error('workspaceRoot is required');
136
+ const status = await (deps.custodyStatus ?? custodyStatus)();
137
+ const current = await (deps.currentGeneration ?? defaultCurrentGeneration)(workspaceRoot);
138
+ let verdict = null;
139
+ if (current) {
140
+ try { verdict = await (deps.verifyGeneration ?? defaultVerifyGeneration)(workspaceRoot, current.file, deps.guardDescriptors ?? defaultGuardDescriptors); } catch (error) { verdict = { signature: `NOT VERIFIED: ${error.message}`, verified: false }; }
141
+ }
142
+ // Coverage probes every feature dir x mode through the stratum CLI (76 s and
143
+ // ~370 spawns on the compose repo itself, measured 2026-09-06) — opt-in.
144
+ const covered = deps.coverage ? await coverage(workspaceRoot, current, deps) : null;
145
+ const git = await runCommand(deps.execFile ?? defaultExecFile, 'git', ['status', '--porcelain', '--', '.compose/guard-upgrades'], { cwd: workspaceRoot, timeout: CUSTODY_TIMEOUT_MS });
146
+ const publicSigner = status.publicKeyLine ? (() => {
147
+ try { return parseAllowedSigners(`status ${status.publicKeyLine.trim()}\n`)[0]?.fingerprint ?? null; } catch { return null; }
148
+ })() : null;
149
+ const lines = [
150
+ 'signing:',
151
+ ` backend: ${status.backend}`,
152
+ ` installed: ${status.installed ? 'yes' : 'no'}`,
153
+ ` rule: ${status.rule}`,
154
+ ` presence: ${status.presence}`,
155
+ ` enrolled fingerprint: ${publicSigner || 'absent'}`,
156
+ ` current generation: ${current ? current.sha : 'absent'}`,
157
+ ` signature: ${current ? (verdict?.signature || 'NOT VERIFIED') : 'absent'}`,
158
+ ` coverage: ${covered === null ? 'skipped (pass --coverage)' : covered.error ? `unknown (${covered.error})` : `${covered.covered}/${covered.total}`}`,
159
+ ` generations committed: ${!git.error && !String(git.stdout).trim() ? 'yes' : 'no'}`,
160
+ ` legacy flat pair: ${existsSync(path.join(workspaceRoot, LEGACY_FLAT)) && existsSync(path.join(workspaceRoot, `${LEGACY_FLAT}.sig`)) ? 'present — delete after committing generations' : 'absent'}`,
161
+ ` cached admin credential: ${status.cachedCredential}`,
162
+ ];
163
+ for (const detail of status.detail || []) lines.push(` detail: ${detail}`);
164
+ return makeResult('ok', lines, { custodyStatus: status, current, verdict, coverage: covered });
165
+ }
166
+
167
+ export async function pruneGuardGenerations(input, deps = {}) {
168
+ const workspaceRoot = asWorkspace(input);
169
+ try {
170
+ const result = await withLock(workspaceRoot, deps, () => (deps.pruneGenerations ?? defaultPruneGenerations)(workspaceRoot));
171
+ return makeResult('ok', result.removed.length ? result.removed.map((sha) => `pruned: ${sha}`) : ['pruned: none'], result);
172
+ } catch (error) {
173
+ return makeResult('refused', [error?.message || String(error)], { code: 'upgrade_descriptor_unavailable' });
174
+ }
175
+ }
@@ -0,0 +1,141 @@
1
+ import { execFile as defaultExecFile } from 'node:child_process';
2
+ import { readFile, stat } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+
5
+ export const GUARD_DIR = '/Library/Compose/guard';
6
+ export const SIGNER_PATH = '/Library/Compose/guard/sign';
7
+ export const PUBKEY_PATH = '/Library/Compose/guard/signing-key.pub';
8
+ export const PRIVATE_DIR = '/Library/Compose/guard/private';
9
+ export const SUDOERS_PATH = '/private/etc/sudoers.d/compose-guard';
10
+ export const SUDO_LOCAL_PATH = '/private/etc/pam.d/sudo_local';
11
+ export const CUSTODY_TIMEOUT_MS = 120_000;
12
+ export const DESCRIPTOR_LOCK_TIMEOUT_MS = 150_000;
13
+ export const HINT_ENROL = 'compose guard enrol';
14
+ export const HINT_APPROVE = 'approve the Touch ID prompt; if none appeared, run compose guard sign from a terminal on this Mac';
15
+
16
+ /** The only environment the root-owned signer may inherit. */
17
+ export const SUDO_ENV = () => ({
18
+ PATH: '/usr/bin:/bin',
19
+ HOME: os.homedir(),
20
+ USER: os.userInfo().username,
21
+ LANG: 'C',
22
+ });
23
+
24
+ let testBackend = null;
25
+
26
+ /** The selected custody backend. Test injection wins over the host platform. */
27
+ export function custodyBackend() {
28
+ if (testBackend !== null) return typeof testBackend === 'string' ? testBackend : (testBackend.backend ?? 'test');
29
+ return process.platform === 'darwin' ? 'sudo' : 'none';
30
+ }
31
+
32
+ /** Test-only backend injection. Kept unavailable to shipped callers. */
33
+ export function _testOnly_setCustodyBackend(backend) {
34
+ if (process.env.NODE_ENV !== 'test') {
35
+ throw new Error('_testOnly_setCustodyBackend is only available when NODE_ENV=test');
36
+ }
37
+ testBackend = backend ?? null;
38
+ }
39
+
40
+ function firstLine(value, fallback) {
41
+ return String(value || '').split(/\r?\n/, 1)[0] || fallback;
42
+ }
43
+
44
+ function refusal(code, message, hint) {
45
+ return { ok: false, code, message, hint };
46
+ }
47
+
48
+ function isNotApproved(error, stderr) {
49
+ return error?.killed || error?.code === 'ETIMEDOUT'
50
+ || /terminal is required|Sorry, try again|authentication failed|timed out/i.test(String(stderr || error?.message || ''));
51
+ }
52
+
53
+ function spawnWithInput(execFile, file, args, options, bytes) {
54
+ return new Promise((resolve) => {
55
+ let settled = false;
56
+ const done = (error, stdout, stderr) => {
57
+ if (settled) return;
58
+ settled = true;
59
+ resolve({ error, stdout: stdout || '', stderr: stderr || '' });
60
+ };
61
+ try {
62
+ const child = execFile(file, args, options, done);
63
+ if (child?.stdin) {
64
+ try { child.stdin.end(bytes); } catch { /* callback maps the failure */ }
65
+ }
66
+ child?.on?.('error', (error) => done(error, '', ''));
67
+ } catch (error) {
68
+ done(error, '', '');
69
+ }
70
+ });
71
+ }
72
+
73
+ /** Ask the selected custody backend to sign exact in-memory bytes. */
74
+ export async function custodySign({ bytes, namespace }, deps = {}) {
75
+ if (testBackend && typeof testBackend === 'object' && typeof testBackend.sign === 'function') {
76
+ return testBackend.sign({ bytes, namespace });
77
+ }
78
+ if (custodyBackend() !== 'sudo') {
79
+ return refusal('upgrade_descriptor_unavailable', 'no signing custody on this platform', HINT_ENROL);
80
+ }
81
+
82
+ const { error, stdout, stderr } = await spawnWithInput(
83
+ deps.execFile ?? defaultExecFile,
84
+ '/usr/bin/sudo',
85
+ ['-k', SIGNER_PATH, namespace],
86
+ { env: SUDO_ENV(), timeout: CUSTODY_TIMEOUT_MS, maxBuffer: 1 << 20 },
87
+ bytes,
88
+ );
89
+ if (!error) return { ok: true, armored: stdout };
90
+ if (isNotApproved(error, stderr)) {
91
+ return refusal('signature_not_approved', firstLine(stderr || error.message, 'signature approval was not completed'), HINT_APPROVE);
92
+ }
93
+ return refusal('upgrade_descriptor_unavailable', firstLine(stderr || error.message, 'signer failed'), HINT_ENROL);
94
+ }
95
+
96
+ async function commandStatus(args, deps = {}) {
97
+ const { error, stdout, stderr } = await spawnWithInput(
98
+ deps.execFile ?? defaultExecFile, '/usr/bin/sudo', args, { env: SUDO_ENV(), timeout: 5_000 }, Buffer.alloc(0),
99
+ );
100
+ return { code: error?.code ?? 0, stdout, stderr, error };
101
+ }
102
+
103
+ async function expectedFile(file, mode) {
104
+ try {
105
+ const info = await stat(file);
106
+ return info.uid === 0 && (info.mode & 0o777) === mode;
107
+ } catch { return false; }
108
+ }
109
+
110
+ /** Report separately what can be observed about the sudo installation. */
111
+ export async function custodyStatus(deps = {}) {
112
+ if (testBackend && typeof testBackend === 'object' && typeof testBackend.status === 'function') {
113
+ return testBackend.status();
114
+ }
115
+ const backend = custodyBackend();
116
+ if (backend !== 'sudo') {
117
+ return { backend, installed: false, rule: 'absent', presence: 'unknown', cachedCredential: 'unknown', publicKeyLine: null, detail: ['no signing custody on this platform'] };
118
+ }
119
+
120
+ const [guard, privateDir, signer, pubkey, sudoers, sudoLocal] = await Promise.all([
121
+ expectedFile(GUARD_DIR, 0o755), expectedFile(PRIVATE_DIR, 0o700), expectedFile(SIGNER_PATH, 0o755),
122
+ expectedFile(PUBKEY_PATH, 0o644), expectedFile(SUDOERS_PATH, 0o440), readFile(SUDO_LOCAL_PATH, 'utf8').catch(() => ''),
123
+ ]);
124
+ const detail = [];
125
+ const listed = await commandStatus(['-n', '-l', SIGNER_PATH], deps);
126
+ const rule = listed.code === 0 ? 'present'
127
+ : /password is required/i.test(listed.stderr) ? 'unknown' : 'absent';
128
+ if (rule === 'unknown') detail.push('sudo requires authentication to list');
129
+ const cached = await commandStatus(['-n', '-v'], deps);
130
+ const cachedCredential = cached.code === 0 ? 'live' : cached.code === 1 ? 'none' : 'unknown';
131
+ const touchId = /^\s*auth\s+sufficient\s+pam_tid\.so/m.test(sudoLocal);
132
+ const remote = Boolean(process.env.SSH_CONNECTION || process.env.TMUX);
133
+ const presence = remote ? 'unlikely' : touchId ? 'likely' : 'unknown';
134
+ if (remote) detail.push('SSH_CONNECTION or TMUX prevents a reliable Touch ID prompt');
135
+ if (!touchId) detail.push('sudo_local has no active pam_tid line');
136
+ let publicKeyLine = null;
137
+ try { publicKeyLine = (await readFile(PUBKEY_PATH, 'utf8')).trim() || null; } catch { /* reported by installed */ }
138
+ return {
139
+ backend, installed: guard && privateDir && signer && pubkey && sudoers, rule, presence, cachedCredential, publicKeyLine, detail,
140
+ };
141
+ }