@smartmemory/compose 0.3.7 → 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 (215) hide show
  1. package/.compose-deps.json +1 -13
  2. package/README.md +72 -5
  3. package/bin/compose.js +470 -351
  4. package/bin/judgment-migrate.js +387 -0
  5. package/contracts/comp-obs-contract.schema.json +9 -3
  6. package/contracts/fluid-record.schema.json +209 -0
  7. package/contracts/lifecycle-backfill.schema.json +322 -0
  8. package/dist/assets/App-Z4MU-H_F.js +916 -0
  9. package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
  10. package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
  11. package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
  12. package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
  13. package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
  14. package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  15. package/dist/assets/channel-SnZzzh7k.js +1 -0
  16. package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  17. package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
  18. package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  19. package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  20. package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  21. package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
  22. package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  23. package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  24. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  25. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  26. package/dist/assets/clone-DgklGjHm.js +1 -0
  27. package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  28. package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
  29. package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
  30. package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
  31. package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
  32. package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
  33. package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
  34. package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
  35. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
  36. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
  37. package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
  38. package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
  39. package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
  40. package/dist/assets/index-yyrA5OZd.css +1 -0
  41. package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
  42. package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
  43. package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  44. package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
  45. package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
  46. package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
  47. package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
  48. package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
  49. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  50. package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
  51. package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
  52. package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
  53. package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
  54. package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
  55. package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
  56. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  57. package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
  58. package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
  59. package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
  60. package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
  61. package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
  62. package/dist/index.html +3 -3
  63. package/lib/agent-string.js +7 -5
  64. package/lib/append-integrity.js +81 -0
  65. package/lib/backfill-evidence.js +109 -0
  66. package/lib/bug-escalation.js +9 -0
  67. package/lib/build-stream-schema.js +3 -1
  68. package/lib/build-stream-writer.js +25 -0
  69. package/lib/build.js +874 -170
  70. package/lib/canon-guard.js +28 -6
  71. package/lib/canon-override.js +196 -0
  72. package/lib/canon-registry.js +104 -0
  73. package/lib/cli-commands.js +144 -0
  74. package/lib/codex-preflight.js +26 -13
  75. package/lib/colleague/context.js +215 -0
  76. package/lib/colleague/writeback.js +95 -0
  77. package/lib/completion-gate.js +1421 -0
  78. package/lib/completion-writer.js +47 -47
  79. package/lib/consumer-fanout.js +105 -11
  80. package/lib/coverage-gate.js +200 -0
  81. package/lib/dir-lock.js +170 -0
  82. package/lib/dispatch-ledger.js +3 -3
  83. package/lib/feature-json.js +1 -1
  84. package/lib/feature-reconciler.js +8 -0
  85. package/lib/feature-validator.js +64 -1
  86. package/lib/feature-writer.js +57 -2
  87. package/lib/fluid/factory.js +167 -0
  88. package/lib/fluid/ideabox-dates.js +73 -0
  89. package/lib/fluid/ideabox-migrate.js +154 -0
  90. package/lib/fluid/ideabox-ops.js +585 -0
  91. package/lib/fluid/ideabox-view.js +146 -0
  92. package/lib/fluid/import-ideabox.js +186 -0
  93. package/lib/fluid/local-provider.js +606 -0
  94. package/lib/fluid/provider.js +684 -0
  95. package/lib/fluid/record-shape.js +214 -0
  96. package/lib/fluid/record-store.js +328 -0
  97. package/lib/fluid/render-ideabox.js +261 -0
  98. package/lib/fluid/schema.js +40 -0
  99. package/lib/fluid/smartmemory-provider.js +1695 -0
  100. package/lib/gsd.js +63 -23
  101. package/lib/guard-cli.js +175 -0
  102. package/lib/guard-custody.js +141 -0
  103. package/lib/guard-descriptors.js +530 -0
  104. package/lib/guard-enrol.js +254 -0
  105. package/lib/health-score.js +1 -1
  106. package/lib/ideabox-cli.js +315 -0
  107. package/lib/ideabox.js +121 -21
  108. package/lib/judgment/store/index.js +9 -1
  109. package/lib/judgment/store/records.js +1 -1
  110. package/lib/judgment/trace.js +380 -0
  111. package/lib/judgment-decision-write.js +277 -0
  112. package/lib/judgment-decisions.js +466 -0
  113. package/lib/judgment-gen.js +5 -1
  114. package/lib/judgment-writer.js +56 -2
  115. package/lib/lifecycle-modes.js +4 -4
  116. package/lib/lineage.js +400 -0
  117. package/lib/local-claude-connector.js +52 -1
  118. package/lib/maya-client.js +302 -0
  119. package/lib/maya-config.js +53 -0
  120. package/lib/maya-identity.js +283 -0
  121. package/lib/migrate-anon.js +5 -0
  122. package/lib/migrate-roadmap.js +15 -0
  123. package/lib/new.js +13 -1
  124. package/lib/pipeline-compat.js +104 -0
  125. package/lib/policy-catalog.js +295 -0
  126. package/lib/policy-check.js +0 -0
  127. package/lib/process-termination.js +98 -0
  128. package/lib/resolve-workspace.js +5 -1
  129. package/lib/result-normalizer.js +396 -199
  130. package/lib/roadmap-errors.js +65 -0
  131. package/lib/roadmap-preservers.js +24 -4
  132. package/lib/roadmap-residue.js +299 -0
  133. package/lib/smartmemory-client.js +614 -78
  134. package/lib/smartmemory-config.js +54 -0
  135. package/lib/smartmemory-ingest.js +19 -2
  136. package/lib/step-prompt.js +7 -6
  137. package/lib/stratum-engine.js +53 -4
  138. package/lib/stratum-mcp-client.js +271 -36
  139. package/lib/test-bootstrap.js +31 -0
  140. package/lib/tool-inventory.js +122 -0
  141. package/lib/version-check.js +91 -19
  142. package/lib/vision-writer.js +88 -1
  143. package/package.json +7 -6
  144. package/pipelines/bug-fix.stratum.yaml +205 -211
  145. package/pipelines/build-quick.profiles.json +12 -0
  146. package/pipelines/build-quick.stratum.yaml +263 -350
  147. package/pipelines/content.stratum.yaml +81 -77
  148. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  149. package/pipelines/plan.stratum.yaml +76 -86
  150. package/pipelines/refactor.stratum.yaml +125 -125
  151. package/pipelines/research.stratum.yaml +56 -58
  152. package/pipelines/review-fix.profiles.json +6 -0
  153. package/pipelines/review-fix.stratum.yaml +110 -83
  154. package/presets/team-feature.profiles.json +6 -0
  155. package/presets/team-feature.stratum.yaml +93 -66
  156. package/presets/team-research.profiles.json +6 -0
  157. package/presets/team-research.stratum.yaml +89 -80
  158. package/presets/team-review.profiles.json +8 -0
  159. package/presets/team-review.stratum.yaml +98 -80
  160. package/scripts/cost-census.mjs +70 -0
  161. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  162. package/server/agent-health.js +22 -0
  163. package/server/agent-hooks.js +14 -1
  164. package/server/agent-server.js +5 -248
  165. package/server/agent-spawn.js +3 -4
  166. package/server/agent-workspace.js +294 -0
  167. package/server/build-routes.js +6 -5
  168. package/server/build-stream-bridge.js +53 -0
  169. package/server/cc-session-watcher.js +4 -1
  170. package/server/coalescing-buffer.js +7 -1
  171. package/server/completion-projection.js +228 -0
  172. package/server/compose-mcp-tools.js +109 -23
  173. package/server/compose-mcp.js +88 -882
  174. package/server/decision-event-emit.js +41 -2
  175. package/server/decision-event-id.js +17 -0
  176. package/server/decision-events-snapshot.js +3 -0
  177. package/server/design-routes.js +14 -8
  178. package/server/feature-scan.js +76 -2
  179. package/server/file-watcher.js +170 -21
  180. package/server/ideabox-routes.js +166 -224
  181. package/server/index.js +70 -100
  182. package/server/lifecycle-guard.js +240 -10
  183. package/server/lifecycle-phase-history.js +276 -0
  184. package/server/maya-routes.js +507 -0
  185. package/server/mcp-tool-defs.js +940 -0
  186. package/server/mcp-tool-policy.js +34 -2
  187. package/server/model-tiers.js +22 -5
  188. package/server/pipeline-routes.js +21 -11
  189. package/server/project-root.js +58 -19
  190. package/server/remote-utils.js +3 -1
  191. package/server/schema-validator.js +7 -1
  192. package/server/session-manager.js +5 -6
  193. package/server/session-routes.js +3 -1
  194. package/server/stratum-client.js +57 -10
  195. package/server/stratum-sync.js +6 -3
  196. package/server/summarizer.js +3 -4
  197. package/server/supervisor.js +0 -1
  198. package/server/vision-routes.js +208 -98
  199. package/server/vision-server.js +86 -23
  200. package/server/vision-store.js +60 -6
  201. package/server/vision-utils.js +3 -4
  202. package/server/workspace-activity.js +18 -0
  203. package/server/workspace-middleware.js +2 -2
  204. package/server/workspace-runtime.js +243 -0
  205. package/server/worktree-gc.js +1 -0
  206. package/dist/assets/App-PkZzHeMj.js +0 -894
  207. package/dist/assets/channel-qVK_qn4E.js +0 -1
  208. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
  209. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
  210. package/dist/assets/clone-Pu3RyLUh.js +0 -1
  211. package/dist/assets/index-LIwREYgH.css +0 -1
  212. package/dist/assets/mobile-BnXEOE3U.js +0 -17
  213. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
  214. package/lib/staleness.js +0 -87
  215. package/server/ideabox-cache.js +0 -77
package/lib/new.js CHANGED
@@ -13,6 +13,7 @@ import { promptGate } from './gate-prompt.js';
13
13
  import { VisionWriter } from './vision-writer.js';
14
14
  import { readFlowRound } from './flow-state.js';
15
15
  import { validateStep } from './step-validator.js';
16
+ import { tsCompatibilityOf, quarantineMessage } from './pipeline-compat.js';
16
17
 
17
18
  const KICKOFF_VALIDATION = new Map([
18
19
  ['research', {
@@ -81,7 +82,18 @@ export async function runNew(intent, opts = {}) {
81
82
  throw new Error(`Kickoff spec not found: ${specPath}. Run 'compose init' to get default pipelines.`);
82
83
  }
83
84
 
84
- const spec = YAML.parse(readFileSync(specPath, 'utf-8'));
85
+ // COMP-PIPELINE-QUARANTINE follow-up: `compose new` has its own runner and
86
+ // calls stratum.plan directly, so it never passed through runBuild's
87
+ // compatibility check. An existing workspace pinning an old new.stratum.yaml
88
+ // (init never overwrites one) still got the opaque engine rejection this check
89
+ // exists to replace.
90
+ const specText = readFileSync(specPath, 'utf-8');
91
+ const specCompat = tsCompatibilityOf(specText);
92
+ if (!specCompat.compatible) {
93
+ throw new Error(quarantineMessage(specPath, specCompat));
94
+ }
95
+
96
+ const spec = YAML.parse(specText);
85
97
  if (opts.skipResearch) {
86
98
  const research = spec.flows?.new?.steps?.find((step) => step.id === 'research');
87
99
  if (research) research.when = 'false';
@@ -0,0 +1,104 @@
1
+ /**
2
+ * TS-engine spec compatibility.
3
+ *
4
+ * COMP-PIPELINE-QUARANTINE: STRAT-PY-RETIRE converted only the two production
5
+ * pipelines (build, gsd) from the v0.3 dialect to TS v1 — see compose commit
6
+ * 9221548, "Both production pipelines (build, gsd) are re-authored as TS v1".
7
+ * Everything else in pipelines/ and presets/ stayed behind — most on v0.3,
8
+ * bug-fix on v0.1 — and the older execution paths were deleted, so those specs
9
+ * cannot run at all: the engine
10
+ * refuses them at `stratum_plan` with a bare `MCP error -32602: spec validation
11
+ * failed`, which tells the user nothing about why.
12
+ *
13
+ * This module is the single place that answers "can the TS engine run this
14
+ * spec". It reads the spec's OWN version stamp rather than consulting a list of
15
+ * pipeline names — so migrating a spec to `version: 1` lifts its quarantine
16
+ * automatically, with nothing here to remember to update.
17
+ */
18
+
19
+ import { parse as parseYaml } from 'yaml';
20
+
21
+ /** The only spec dialect the TS engine accepts. */
22
+ export const TS_SPEC_VERSION = 1;
23
+
24
+ /**
25
+ * The specs `compose init` seeds into a workspace's pipelines/ directory.
26
+ *
27
+ * Shared with resolveTemplatePath, which deliberately does NOT fall back to the
28
+ * bundled copy for these: their absence means the workspace was never
29
+ * initialized, and failing loudly with "Lifecycle spec not found" is the honest
30
+ * answer. Silently running Compose's own bundled build pipeline against an
31
+ * uninitialized project would be worse than the error. Every OTHER shipped
32
+ * pipeline (content, coverage-sweep, refactor, research, review-fix) is never
33
+ * copied by init, so the bundled copy is its only source and the fallback is the
34
+ * only way `--template research` can resolve at all.
35
+ */
36
+ export const INIT_PROVISIONED_SPECS = Object.freeze([
37
+ 'build',
38
+ 'build-quick',
39
+ 'bug-fix',
40
+ 'new',
41
+ 'plan',
42
+ ]);
43
+
44
+ /**
45
+ * Classify a spec's engine compatibility.
46
+ *
47
+ * Deliberately shallow: a `version: 1` stamp means "authored for this engine",
48
+ * not "valid". Full validation belongs to the engine, which reports precise
49
+ * errors. This exists to turn the common, uninformative failure — a spec from
50
+ * the retired dialect — into a message that names the cause.
51
+ *
52
+ * @param {string} specText Raw spec YAML
53
+ * @returns {{compatible: boolean, version: unknown, reason: string|null}}
54
+ */
55
+ export function tsCompatibilityOf(specText) {
56
+ let parsed;
57
+ try {
58
+ parsed = parseYaml(specText);
59
+ } catch (err) {
60
+ return { compatible: false, version: null, reason: `spec is not parseable YAML: ${err.message}` };
61
+ }
62
+ const version = parsed?.version ?? null;
63
+ if (version === TS_SPEC_VERSION) return { compatible: true, version, reason: null };
64
+ if (version === null) {
65
+ return { compatible: false, version, reason: 'spec declares no `version`' };
66
+ }
67
+ return {
68
+ compatible: false,
69
+ version,
70
+ reason: `spec declares version ${JSON.stringify(version)}, but the TS engine only runs version ${TS_SPEC_VERSION}`,
71
+ };
72
+ }
73
+
74
+ /** True iff the TS engine will accept this spec's dialect. */
75
+ export function isTsEngineSpec(specText) {
76
+ return tsCompatibilityOf(specText).compatible;
77
+ }
78
+
79
+ /**
80
+ * The message shown when a quarantined pipeline is invoked. Names the spec, the
81
+ * cause, and the only two ways forward — never a raw engine error code.
82
+ *
83
+ * @param {string} specPath Absolute path of the offending spec
84
+ * @param {{reason: string|null}} compat Result of tsCompatibilityOf
85
+ */
86
+ export function quarantineMessage(specPath, compat) {
87
+ // Only a spec that declares an OLDER dialect gets the migration story. A
88
+ // malformed or version-less spec is far more likely to be a fresh typo, and
89
+ // telling its author it was "left behind by STRAT-PY-RETIRE" is a confident
90
+ // false history that sends them looking in the wrong place.
91
+ const isRetiredDialect = typeof compat.version === 'string' && /^0\./.test(compat.version);
92
+ const lines = [
93
+ `This pipeline cannot run: ${specPath}`,
94
+ ` ${compat.reason}.`,
95
+ ];
96
+ if (isRetiredDialect) {
97
+ lines.push(
98
+ ' It was left on a retired dialect when STRAT-PY-RETIRE cut the engine over to TS v1 and',
99
+ ' deleted the older execution path, so it has been unrunnable since that cutover.',
100
+ );
101
+ }
102
+ lines.push(` The spec must declare \`version: ${TS_SPEC_VERSION}\` and use the TS v1 dialect.`);
103
+ return lines.join('\n');
104
+ }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * policy-catalog.js — COMP-POLICY-CHECK-1: local detection-pattern catalog loader.
3
+ *
4
+ * Reads the `## Detection patterns` fenced-yaml convention out of a Claude Code
5
+ * memory directory (`<memory-dir>/feedback_*.md`) and turns it into data. This
6
+ * mirrors SmartMemory's Python loader
7
+ * (`smart-memory-core/smartmemory/adherence/detection.py`) field-for-field —
8
+ * SmartMemory's `memory_get_violation_patterns` MCP tool is a thin wrapper over
9
+ * that same parse, and Compose's engine is plain Node with no MCP client, so we
10
+ * parse the markdown directly rather than fetching it.
11
+ *
12
+ * Pure parsing + caching. No enforcement, no response inspection: that is
13
+ * `lib/policy-check.js`.
14
+ *
15
+ * Degradation contract:
16
+ * - memory dir absent → empty catalog, silent (opt-in feature)
17
+ * - file without a block → skipped, silent (the section is opt-in)
18
+ * - block present but malformed → skipped with a WARNING (authored intent
19
+ * that produced nothing must be visible — no-silent-degradation rule)
20
+ */
21
+
22
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
23
+ import { createHash } from 'node:crypto';
24
+ import { isAbsolute, join, resolve } from 'node:path';
25
+ import { homedir } from 'node:os';
26
+ import YAML from 'yaml';
27
+
28
+ // Leading `---` ... `---` frontmatter block.
29
+ const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n/;
30
+
31
+ // `## Detection patterns` heading followed by the first fenced code block.
32
+ // The fence language tag (```yaml / ```yml / ```) is optional.
33
+ const DETECTION_RE = /^##[ \t]+Detection patterns[ \t]*\n+```(?:ya?ml)?[ \t]*\n([\s\S]*?)\n```/im;
34
+
35
+ const PATTERN_KEYS = ['regex', 'phrase', 'exclude_regex'];
36
+ const DEFAULT_SCAN_TARGET = 'response';
37
+ const DEFAULT_RECENT_TURN_WINDOW = 1;
38
+ const DEFAULT_RULE_TYPE = 'feedback';
39
+
40
+ /** dir → { key, catalog } — per-process, invalidated on max-mtime/file-count change. */
41
+ const catalogCache = new Map();
42
+
43
+ /**
44
+ * Read `.compose/compose.json` → `policyCheck` block. Uncached direct read,
45
+ * try/catch → {} on missing/malformed (same shape as smartmemory-config.js).
46
+ *
47
+ * An ABSENT block means enabled: the check ships as the Compose default and is
48
+ * structurally inert without a catalog. `enabled: false` is the kill switch.
49
+ *
50
+ * @param {string} cwd
51
+ * @returns {{ enabled?: boolean, memoryDir?: string }}
52
+ */
53
+ export function getPolicyCheckConfig(cwd) {
54
+ try {
55
+ const cfg = JSON.parse(readFileSync(join(cwd, '.compose', 'compose.json'), 'utf-8'));
56
+ const block = cfg.policyCheck;
57
+ return block && typeof block === 'object' && !Array.isArray(block) ? block : {};
58
+ } catch {
59
+ return {};
60
+ }
61
+ }
62
+
63
+ /**
64
+ * @param {string} cwd
65
+ * @param {object} [config] pre-read config block (avoids a second file read)
66
+ * @returns {boolean} false only when the kill switch is explicitly set
67
+ */
68
+ export function isPolicyCheckEnabled(cwd, config = getPolicyCheckConfig(cwd)) {
69
+ return config.enabled !== false;
70
+ }
71
+
72
+ /** Expand a leading `~` against the current user's home directory. */
73
+ function expandHome(p) {
74
+ if (p === '~') return homedir();
75
+ if (p.startsWith('~/')) return join(homedir(), p.slice(2));
76
+ return p;
77
+ }
78
+
79
+ /**
80
+ * Claude Code's project-directory encoding: the absolute project path with `/`
81
+ * and `.` replaced by `-` (so `/Users/x/reg/my/App` → `-Users-x-reg-my-App`).
82
+ *
83
+ * @param {string} cwd
84
+ * @returns {string}
85
+ */
86
+ export function encodeProjectDir(cwd) {
87
+ return resolve(cwd).replace(/[/.]/g, '-');
88
+ }
89
+
90
+ /**
91
+ * Resolve the memory directory for a project: config override (absolute, `~`,
92
+ * or cwd-relative) → the default Claude Code project memory dir.
93
+ *
94
+ * @param {string} cwd
95
+ * @param {object} [config]
96
+ * @returns {string} absolute path (existence not checked)
97
+ */
98
+ export function resolveMemoryDir(cwd, config = getPolicyCheckConfig(cwd)) {
99
+ if (typeof config.memoryDir === 'string' && config.memoryDir.length > 0) {
100
+ const expanded = expandHome(config.memoryDir);
101
+ return isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
102
+ }
103
+ return join(homedir(), '.claude', 'projects', encodeProjectDir(cwd), 'memory');
104
+ }
105
+
106
+ /**
107
+ * Normalize a YAML list into the `{regex|phrase|exclude_regex: string}`
108
+ * contract. Non-list → []. Items are reduced to recognized string-valued keys;
109
+ * an item with no such key is dropped, so garbage never reaches consumers that
110
+ * assume string patterns.
111
+ */
112
+ function validPatternItems(value) {
113
+ if (!Array.isArray(value)) return [];
114
+ const items = [];
115
+ for (const item of value) {
116
+ if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
117
+ const cleaned = {};
118
+ for (const key of PATTERN_KEYS) {
119
+ if (typeof item[key] === 'string') cleaned[key] = item[key];
120
+ }
121
+ if (Object.keys(cleaned).length > 0) items.push(cleaned);
122
+ }
123
+ return items;
124
+ }
125
+
126
+ function parseFrontmatter(text) {
127
+ const m = FRONTMATTER_RE.exec(text);
128
+ if (!m) return {};
129
+ try {
130
+ const data = YAML.parse(m[1]);
131
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
132
+ } catch {
133
+ return {};
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Return the parsed detection block, or null when absent/unusable.
139
+ * Absent → null, silently. Present-but-broken → null plus a WARNING.
140
+ */
141
+ function parseDetectionBlock(text, sourceFile) {
142
+ const m = DETECTION_RE.exec(text);
143
+ if (!m) return null; // no block — normal, not an error
144
+
145
+ let data;
146
+ try {
147
+ data = YAML.parse(m[1]);
148
+ } catch (err) {
149
+ // eslint-disable-next-line no-console
150
+ console.warn(`[policy-catalog] invalid YAML in detection block of ${sourceFile}: ${err.message}`);
151
+ return null;
152
+ }
153
+
154
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
155
+ // eslint-disable-next-line no-console
156
+ console.warn(`[policy-catalog] detection block in ${sourceFile} is not a mapping; skipping`);
157
+ return null;
158
+ }
159
+ if (validPatternItems(data.patterns).length === 0) {
160
+ // eslint-disable-next-line no-console
161
+ console.warn(
162
+ `[policy-catalog] detection block in ${sourceFile} has no valid 'patterns' ` +
163
+ "(need {regex|phrase|exclude_regex: str}); skipping"
164
+ );
165
+ return null;
166
+ }
167
+ return data;
168
+ }
169
+
170
+ function safeInt(value, fallback) {
171
+ const n = Number(value);
172
+ return Number.isFinite(n) ? Math.trunc(n) : fallback;
173
+ }
174
+
175
+ /**
176
+ * Load `## Detection patterns` records from a memory directory.
177
+ *
178
+ * @param {string} memoryDir
179
+ * @param {object} [opts]
180
+ * @param {string} [opts.ruleType='feedback'] filename prefix to scan; also the
181
+ * fallback rule type when a file's frontmatter omits `type`
182
+ * @returns {Array<{name: string, description: string, ruleType: string,
183
+ * patterns: object[], suppressionSignals: object[], scanTarget: string,
184
+ * recentTurnWindow: number, sourceFile: string}>}
185
+ */
186
+ export function loadCatalog(memoryDir, { ruleType = DEFAULT_RULE_TYPE } = {}) {
187
+ let names;
188
+ try {
189
+ names = readdirSync(memoryDir);
190
+ } catch {
191
+ // Absent (or unreadable) memory dir: the feature is opt-in, so an empty
192
+ // catalog here is the expected no-op rather than a degradation.
193
+ return [];
194
+ }
195
+
196
+ const files = names
197
+ .filter(n => n.startsWith(`${ruleType}_`) && n.endsWith('.md'))
198
+ .sort();
199
+
200
+ const out = [];
201
+ for (const name of files) {
202
+ const path = join(memoryDir, name);
203
+ let text;
204
+ try {
205
+ text = readFileSync(path, 'utf-8');
206
+ } catch (err) {
207
+ // eslint-disable-next-line no-console
208
+ console.warn(`[policy-catalog] could not read ${path}: ${err.message}`);
209
+ continue;
210
+ }
211
+
212
+ const block = parseDetectionBlock(text, path);
213
+ if (block === null) continue;
214
+
215
+ try {
216
+ const fm = parseFrontmatter(text);
217
+ const metadata = block.metadata && typeof block.metadata === 'object' && !Array.isArray(block.metadata)
218
+ ? block.metadata
219
+ : {};
220
+ out.push({
221
+ name: String(fm.name || name.replace(/\.md$/, '')),
222
+ description: String(fm.description || ''),
223
+ ruleType: String(fm.type || fm?.metadata?.type || ruleType),
224
+ patterns: validPatternItems(block.patterns),
225
+ suppressionSignals: validPatternItems(block.suppression_signals),
226
+ scanTarget: String(metadata.scan_target || DEFAULT_SCAN_TARGET),
227
+ recentTurnWindow: safeInt(metadata.recent_turn_window, DEFAULT_RECENT_TURN_WINDOW),
228
+ sourceFile: path,
229
+ });
230
+ } catch (err) {
231
+ // Defensive: one bad file must not sink the batch.
232
+ // eslint-disable-next-line no-console
233
+ console.warn(`[policy-catalog] failed to build record for ${path}: ${err.message}`);
234
+ }
235
+ }
236
+ return out;
237
+ }
238
+
239
+ /**
240
+ * Cache key for a directory: a digest over the sorted (path, mtimeMs, size) of
241
+ * every rule file. Per-file rather than count + max-mtime, because that older
242
+ * key could not see an edit to an OLDER file while a newer one sat untouched —
243
+ * the max mtime never moved, so a real rule change went unnoticed for the life
244
+ * of the process.
245
+ */
246
+ function cacheKey(memoryDir, ruleType) {
247
+ let names;
248
+ try {
249
+ names = readdirSync(memoryDir);
250
+ } catch {
251
+ return 'missing';
252
+ }
253
+ const parts = [];
254
+ for (const name of names.filter(n => n.startsWith(`${ruleType}_`) && n.endsWith('.md')).sort()) {
255
+ try {
256
+ const { mtimeMs, size } = statSync(join(memoryDir, name));
257
+ parts.push(`${name}:${mtimeMs}:${size}`);
258
+ } catch {
259
+ // Raced deletion between readdir and stat — record the absence so the key
260
+ // still changes rather than silently matching the previous digest.
261
+ parts.push(`${name}:gone`);
262
+ }
263
+ }
264
+ return createHash('sha1').update(parts.join('\n')).digest('hex');
265
+ }
266
+
267
+ /**
268
+ * Resolve + load the catalog for a project, cached per process and invalidated
269
+ * when any rule file changes. Returns [] when the kill switch is set or no
270
+ * catalog exists.
271
+ *
272
+ * @param {string} cwd
273
+ * @param {object} [opts]
274
+ * @param {string} [opts.memoryDir] explicit override (skips config resolution)
275
+ * @param {string} [opts.ruleType='feedback']
276
+ * @returns {Array<object>} catalog records (see loadCatalog)
277
+ */
278
+ export function getCatalog(cwd, { memoryDir, ruleType = DEFAULT_RULE_TYPE } = {}) {
279
+ const config = getPolicyCheckConfig(cwd);
280
+ if (!isPolicyCheckEnabled(cwd, config)) return [];
281
+
282
+ const dir = memoryDir ?? resolveMemoryDir(cwd, config);
283
+ const key = `${ruleType}|${cacheKey(dir, ruleType)}`;
284
+ const cached = catalogCache.get(dir);
285
+ if (cached && cached.key === key) return cached.catalog;
286
+
287
+ const catalog = loadCatalog(dir, { ruleType });
288
+ catalogCache.set(dir, { key, catalog });
289
+ return catalog;
290
+ }
291
+
292
+ /** Test hook: drop the per-process catalog cache. */
293
+ export function _clearCatalogCache() {
294
+ catalogCache.clear();
295
+ }
Binary file
@@ -0,0 +1,98 @@
1
+ /**
2
+ * process-termination.js — graceful teardown for a spawned agent child process.
3
+ *
4
+ * Compose spawns the Claude Code CLI itself (see `lib/local-claude-connector.js`)
5
+ * so that a cancelled run tears down the WHOLE process tree, not just the leader.
6
+ * The child is spawned `detached: true`, which makes it a process-group leader;
7
+ * signalling `-pid` reaches every descendant.
8
+ *
9
+ * Only what compose uses lives here. The teardown contract is:
10
+ * SIGTERM the group → wait up to the grace period → SIGKILL anything still
11
+ * alive → await leader close and group disappearance (bounded at 2 seconds).
12
+ *
13
+ * Grace period: `COMPOSE_CANCEL_GRACE_MS` (default 5000ms).
14
+ */
15
+
16
+ const DEFAULT_GRACE_MS = 5000;
17
+
18
+ /** Read the configured cancellation grace period. */
19
+ function graceMsFromEnv(env = process.env) {
20
+ const raw = env.COMPOSE_CANCEL_GRACE_MS;
21
+ if (raw === undefined || raw === '') return DEFAULT_GRACE_MS;
22
+ const value = Number(raw);
23
+ if (!Number.isFinite(value) || value < 0) {
24
+ throw new Error('COMPOSE_CANCEL_GRACE_MS must be a nonnegative number');
25
+ }
26
+ return value;
27
+ }
28
+
29
+ /**
30
+ * Own the teardown of a spawned child, separately from `child.kill()`, so SDK
31
+ * cleanup cannot bypass the grace period.
32
+ *
33
+ * @param {import('node:child_process').ChildProcess} child
34
+ * @param {boolean} group — signal the whole process group (child was detached)
35
+ * @param {number} [graceMs]
36
+ * @returns {{ close: Promise<void>, terminate: () => Promise<void>, finish: () => Promise<void> }}
37
+ */
38
+ export function processTermination(child, group, graceMs = graceMsFromEnv(), reapTimeoutMs = 2000) {
39
+ let closed = false;
40
+ const close = new Promise((resolve) => child.once('close', () => { closed = true; resolve(); }));
41
+ let teardown;
42
+
43
+ const send = (signal) => {
44
+ if (group && child.pid) {
45
+ try {
46
+ process.kill(-child.pid, signal);
47
+ } catch (error) {
48
+ if (error.code !== 'ESRCH') throw error;
49
+ }
50
+ } else if (!closed) {
51
+ child.kill(signal);
52
+ }
53
+ };
54
+
55
+ // The group outlives the leader: `close` firing does not mean the descendants
56
+ // are gone, so liveness is probed with signal 0 against the group.
57
+ const alive = () => {
58
+ if (!group || !child.pid) return !closed;
59
+ try {
60
+ process.kill(-child.pid, 0);
61
+ return true;
62
+ } catch (error) {
63
+ if (error.code === 'ESRCH') return false;
64
+ throw error;
65
+ }
66
+ };
67
+
68
+ const terminate = () => {
69
+ teardown ??= (async () => {
70
+ send('SIGTERM');
71
+ let timer;
72
+ try {
73
+ await Promise.race([
74
+ new Promise((resolve) => { timer = setTimeout(resolve, graceMs); }),
75
+ // A leader that closed while its group lives must still wait out the
76
+ // grace period, so that branch parks on a never-settling promise.
77
+ close.then(() => (alive() ? new Promise(() => {}) : undefined)),
78
+ ]);
79
+ if (alive()) send('SIGKILL');
80
+ await close;
81
+ const deadline = Date.now() + reapTimeoutMs;
82
+ while (group && alive()) {
83
+ if (Date.now() >= deadline) throw Object.assign(new Error(`Process group still exists after ${reapTimeoutMs}ms reap deadline`), { code: 'CANCELLATION_TEARDOWN_TIMEOUT' });
84
+ await new Promise(resolve => setTimeout(resolve, 10));
85
+ }
86
+ } catch (error) {
87
+ if (error.code !== 'CANCELLATION_TEARDOWN_TIMEOUT') error.code = 'CANCELLATION_UNCONFIRMED';
88
+ throw error;
89
+ } finally {
90
+ if (timer) clearTimeout(timer);
91
+ }
92
+ })();
93
+ void teardown.catch(() => {});
94
+ return teardown;
95
+ };
96
+
97
+ return { close, terminate, finish: () => teardown ?? Promise.resolve() };
98
+ }
@@ -1,7 +1,11 @@
1
1
  /**
2
2
  * resolve-workspace.js — single resolver chain for compose workspaces.
3
3
  *
4
- * Precedence:
4
+ * MCP effective precedence: explicit workspaceId > session binding > COMPOSE_TARGET
5
+ * > discovery. compose-mcp promotes its session binding to hint.workspaceId before
6
+ * calling this resolver, so an established session wins over the process default.
7
+ *
8
+ * Generic resolver precedence (including CLI callers):
5
9
  * 1. explicit hint.workspaceId (cheap upward walk first; falls back to discovery)
6
10
  * 2. COMPOSE_TARGET env (absolute path bypasses discovery; id routes through it)
7
11
  * 3. hint.getBinding() (MCP binding)