@smartmemory/compose 0.1.0 → 0.1.2-beta

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 (124) hide show
  1. package/.claude/skills/bug-fix/SKILL.md +143 -0
  2. package/.claude/skills/compose/SKILL.md +604 -0
  3. package/.compose-deps.json +89 -0
  4. package/README.md +14 -3
  5. package/bin/compose.js +473 -0
  6. package/contracts/comp-obs-contract.schema.json +362 -0
  7. package/contracts/cross-model-review-result.json +78 -0
  8. package/contracts/review-result.json +126 -0
  9. package/dist/assets/{_baseUniq-CQwX6VLz.js → _baseUniq-D-avYfn5.js} +1 -1
  10. package/dist/assets/{arc-SxJ2J1sh.js → arc-BC4dfQ-X.js} +1 -1
  11. package/dist/assets/{architectureDiagram-Q4EWVU46-BykunY1F.js → architectureDiagram-Q4EWVU46-BZmFXnGI.js} +1 -1
  12. package/dist/assets/{blockDiagram-DXYQGD6D-ohAKBOUw.js → blockDiagram-DXYQGD6D-DlfWSuux.js} +1 -1
  13. package/dist/assets/{c4Diagram-AHTNJAMY-DBDC3ENB.js → c4Diagram-AHTNJAMY-Y__uJrRx.js} +1 -1
  14. package/dist/assets/channel-LRG9kHqJ.js +1 -0
  15. package/dist/assets/{chunk-4BX2VUAB-Cv93Z7uM.js → chunk-4BX2VUAB-BfMePfTp.js} +1 -1
  16. package/dist/assets/{chunk-4TB4RGXK-DE0WBDkj.js → chunk-4TB4RGXK-BdlMSdEA.js} +1 -1
  17. package/dist/assets/{chunk-55IACEB6-CE1EXenG.js → chunk-55IACEB6-vrQHZTdv.js} +1 -1
  18. package/dist/assets/{chunk-EDXVE4YY-DA7Ana6H.js → chunk-EDXVE4YY-B8wioVlW.js} +1 -1
  19. package/dist/assets/{chunk-FMBD7UC4-CTDIPA3p.js → chunk-FMBD7UC4-Cd6Hrux2.js} +1 -1
  20. package/dist/assets/{chunk-OYMX7WX6-uGBaPaTX.js → chunk-OYMX7WX6-CfrhdQXY.js} +1 -1
  21. package/dist/assets/{chunk-QZHKN3VN-CYlnXuUO.js → chunk-QZHKN3VN-B9JQerOU.js} +1 -1
  22. package/dist/assets/{chunk-YZCP3GAM-ojGkzcZK.js → chunk-YZCP3GAM-DFN9X99H.js} +1 -1
  23. package/dist/assets/classDiagram-6PBFFD2Q-BC9a6pDE.js +1 -0
  24. package/dist/assets/classDiagram-v2-HSJHXN6E-BC9a6pDE.js +1 -0
  25. package/dist/assets/clone-dRxgFrBv.js +1 -0
  26. package/dist/assets/{cose-bilkent-S5V4N54A-Bktn9hL-.js → cose-bilkent-S5V4N54A-BAn0ap_E.js} +1 -1
  27. package/dist/assets/{dagre-KV5264BT-DFaSzuRF.js → dagre-KV5264BT-DyxnVq1g.js} +1 -1
  28. package/dist/assets/{diagram-5BDNPKRD-DnfmDzEm.js → diagram-5BDNPKRD-XCrzqski.js} +1 -1
  29. package/dist/assets/{diagram-G4DWMVQ6-Bm8W9YnG.js → diagram-G4DWMVQ6-MBCAXft_.js} +1 -1
  30. package/dist/assets/{diagram-MMDJMWI5-B5-TSKvp.js → diagram-MMDJMWI5-DbtB2yS6.js} +1 -1
  31. package/dist/assets/{diagram-TYMM5635-ls4rqlky.js → diagram-TYMM5635-Bb5NzX61.js} +1 -1
  32. package/dist/assets/{erDiagram-SMLLAGMA-giG6WO-r.js → erDiagram-SMLLAGMA-CpIeCOh2.js} +1 -1
  33. package/dist/assets/{flowDiagram-DWJPFMVM-XvlUuz-7.js → flowDiagram-DWJPFMVM-CHyoKnhW.js} +1 -1
  34. package/dist/assets/{ganttDiagram-T4ZO3ILL-hLBV57oV.js → ganttDiagram-T4ZO3ILL-DErKteO_.js} +1 -1
  35. package/dist/assets/{gitGraphDiagram-UUTBAWPF-BHu3s_Gn.js → gitGraphDiagram-UUTBAWPF-KFVAtj2F.js} +1 -1
  36. package/dist/assets/{graph-D0Cfv00Y.js → graph-CRnO_ifT.js} +1 -1
  37. package/dist/assets/index-DKBsEUJ-.css +1 -0
  38. package/dist/assets/index-DkRKLuNr.js +1144 -0
  39. package/dist/assets/{infoDiagram-42DDH7IO-DbqRsOo3.js → infoDiagram-42DDH7IO-BZFnuSp5.js} +1 -1
  40. package/dist/assets/{ishikawaDiagram-UXIWVN3A-DnCdx7zb.js → ishikawaDiagram-UXIWVN3A-4Xe2Szde.js} +1 -1
  41. package/dist/assets/{journeyDiagram-VCZTEJTY-CfD7eNcP.js → journeyDiagram-VCZTEJTY-CZRByfS-.js} +1 -1
  42. package/dist/assets/{kanban-definition-6JOO6SKY-BYaO9-mK.js → kanban-definition-6JOO6SKY-B95sk6Fk.js} +1 -1
  43. package/dist/assets/{layout-Bj72wOEB.js → layout-BqNQzxWT.js} +1 -1
  44. package/dist/assets/{linear-BRFo114D.js → linear-CUh7qb64.js} +1 -1
  45. package/dist/assets/{min-GCHnKlJS.js → min-wXgOS3ig.js} +1 -1
  46. package/dist/assets/{mindmap-definition-QFDTVHPH-n0PMebY4.js → mindmap-definition-QFDTVHPH-DB6iaAbO.js} +1 -1
  47. package/dist/assets/{pieDiagram-DEJITSTG-pN4CljHF.js → pieDiagram-DEJITSTG-CHkZHrTW.js} +1 -1
  48. package/dist/assets/{quadrantDiagram-34T5L4WZ-DNoAy8-D.js → quadrantDiagram-34T5L4WZ-DoTEO8e3.js} +1 -1
  49. package/dist/assets/{requirementDiagram-MS252O5E-BhtY05PT.js → requirementDiagram-MS252O5E-Dn8peXYp.js} +1 -1
  50. package/dist/assets/{sankeyDiagram-XADWPNL6-B6AD-16A.js → sankeyDiagram-XADWPNL6-DRXs6Ipb.js} +1 -1
  51. package/dist/assets/{sequenceDiagram-FGHM5R23-DShHM-uk.js → sequenceDiagram-FGHM5R23-wBBYZ0aq.js} +1 -1
  52. package/dist/assets/{stateDiagram-FHFEXIEX-DMxn7HTo.js → stateDiagram-FHFEXIEX-DPlBNGmf.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BW0ezXb4.js +1 -0
  54. package/dist/assets/{timeline-definition-GMOUNBTQ-Cdu6uq52.js → timeline-definition-GMOUNBTQ-CbbyTlHk.js} +1 -1
  55. package/dist/assets/{vennDiagram-DHZGUBPP-CpK29iRe.js → vennDiagram-DHZGUBPP-Bj4GaFfj.js} +1 -1
  56. package/dist/assets/{wardley-RL74JXVD-BQgSkdcO.js → wardley-RL74JXVD-RtNzq8KU.js} +55 -55
  57. package/dist/assets/{wardleyDiagram-NUSXRM2D-DJHYev6O.js → wardleyDiagram-NUSXRM2D-CDfE3zSj.js} +1 -1
  58. package/dist/assets/{xychartDiagram-5P7HB3ND-1d75pbaO.js → xychartDiagram-5P7HB3ND-CZXHHYD5.js} +1 -1
  59. package/dist/index.html +2 -2
  60. package/lib/budget-ledger.js +45 -0
  61. package/lib/bug-bisect.js +292 -0
  62. package/lib/bug-checkpoint.js +191 -0
  63. package/lib/bug-escalation.js +306 -0
  64. package/lib/bug-index-gen.js +136 -0
  65. package/lib/bug-ledger.js +126 -0
  66. package/lib/build-stream-schema.js +176 -0
  67. package/lib/build-stream-writer.js +3 -1
  68. package/lib/build.js +854 -284
  69. package/lib/connector-factory-shim.js +167 -0
  70. package/lib/constants.js +18 -0
  71. package/lib/debug-discipline.js +176 -27
  72. package/lib/deps.js +205 -0
  73. package/lib/health-score.js +4 -4
  74. package/lib/import.js +26 -13
  75. package/lib/inject-schema.js +21 -0
  76. package/lib/new.js +27 -53
  77. package/lib/result-normalizer.js +160 -144
  78. package/lib/review-lenses.js +5 -5
  79. package/lib/review-normalize.js +413 -0
  80. package/lib/review-prompt.js +163 -0
  81. package/lib/sections.js +325 -0
  82. package/lib/step-prompt.js +21 -1
  83. package/lib/step-validator.js +5 -3
  84. package/lib/stratum-mcp-client.js +172 -7
  85. package/package.json +14 -3
  86. package/pipelines/bug-fix.stratum.yaml +39 -1
  87. package/pipelines/build.stratum.yaml +28 -45
  88. package/pipelines/review-fix.stratum.yaml +1 -1
  89. package/presets/team-review.stratum.yaml +21 -14
  90. package/server/build-stream-bridge.js +28 -0
  91. package/server/cc-session-feature-resolver.js +111 -0
  92. package/server/cc-session-reader.js +327 -0
  93. package/server/cc-session-watcher.js +318 -0
  94. package/server/compose-mcp-tools.js +0 -125
  95. package/server/compose-mcp.js +2 -4
  96. package/server/contract-diff.js +192 -0
  97. package/server/decision-event-emit.js +175 -0
  98. package/server/decision-event-id.js +64 -0
  99. package/server/decision-events-snapshot.js +166 -0
  100. package/server/design-routes.js +92 -49
  101. package/server/drift-axes.js +365 -0
  102. package/server/drift-emit.js +121 -0
  103. package/server/gate-log-store.js +102 -0
  104. package/server/lifecycle-phase-history.js +44 -0
  105. package/server/open-loops-store.js +102 -0
  106. package/server/schema-validator.js +49 -0
  107. package/server/status-emit.js +27 -0
  108. package/server/status-snapshot.js +218 -0
  109. package/server/vision-routes.js +332 -4
  110. package/server/vision-server.js +104 -12
  111. package/server/vision-store.js +21 -0
  112. package/dist/assets/channel-DGElom1e.js +0 -1
  113. package/dist/assets/classDiagram-6PBFFD2Q-KqWP9wWZ.js +0 -1
  114. package/dist/assets/classDiagram-v2-HSJHXN6E-KqWP9wWZ.js +0 -1
  115. package/dist/assets/clone-DUJKJXd7.js +0 -1
  116. package/dist/assets/index-CUd6pFGF.css +0 -1
  117. package/dist/assets/index-DReRlzZI.js +0 -1144
  118. package/dist/assets/stateDiagram-v2-QKLJ7IA2-o6PnCs4e.js +0 -1
  119. package/server/connectors/agent-connector.js +0 -78
  120. package/server/connectors/claude-sdk-connector.js +0 -198
  121. package/server/connectors/codex-connector.js +0 -240
  122. package/server/connectors/connector-discovery.js +0 -18
  123. package/server/connectors/connector-runtime.js +0 -13
  124. package/server/connectors/opencode-connector.js +0 -200
@@ -0,0 +1,167 @@
1
+ /**
2
+ * connector-factory-shim.js — Backward-compatibility adapter for tests that
3
+ * pass a legacy `connectorFactory(agentType, opts)` returning an object with
4
+ * an async-generator `run(prompt)` that yields `{type: 'assistant'|'tool_use'
5
+ * |'tool_use_summary'|'usage'|'error'|'result', ...}` events.
6
+ *
7
+ * After STRAT-DEDUP-AGENTRUN-V3 the consumer pipeline calls
8
+ * `stratum.agentRun(...)` and consumes BuildStreamEvent envelopes via
9
+ * `stratum.onEvent(correlationId, '_agent_run', handler)`. This shim adapts
10
+ * the legacy factory to dispatch envelopes through the StratumMcpClient's
11
+ * onEvent pathway so existing tests continue to assert wire-level behavior.
12
+ *
13
+ * Used only when `opts.stratum` is NOT injected directly. Production paths
14
+ * never instantiate this shim.
15
+ */
16
+
17
+ /**
18
+ * Install fake `agentRun`, `runAgentText`, `cancelAgentRun` methods on a
19
+ * StratumMcpClient instance backed by a legacy connector factory.
20
+ *
21
+ * @param {object} stratum - StratumMcpClient (uses its #dispatchEvent path indirectly via internal subscribers).
22
+ * @param {Function} factory - legacy `factory(agentType, opts)` returning {run(prompt), interrupt(), isRunning}
23
+ * @param {string} defaultCwd
24
+ */
25
+ export function installFactoryShim(stratum, factory, defaultCwd) {
26
+ // We dispatch via the public onEvent subscribers map. There's no public
27
+ // emit method, so we synthesize the same JSON-string-via-progress path the
28
+ // real client uses by directly invoking subscribed handlers.
29
+ function emit(correlationId, kind, metadata, seq) {
30
+ // Replicate the dispatch path: lookup `${flow}::${step}` subscribers and
31
+ // hand them a parsed envelope. This mirrors `#dispatchEvent` (private)
32
+ // but works against the public `onEvent` registry implicitly because we
33
+ // only run inside a single tool-call lifecycle.
34
+ const env = {
35
+ schema_version: '0.2.5',
36
+ flow_id: correlationId,
37
+ step_id: '_agent_run',
38
+ task_id: null,
39
+ seq,
40
+ ts: new Date().toISOString(),
41
+ kind,
42
+ metadata,
43
+ };
44
+ // Lean on the stratum client's existing #makeProgressHandler-style path:
45
+ // re-encode and feed via a shadow handler is overkill — we instead reach
46
+ // into the onEvent subscribers via a dispatch closure attached on first
47
+ // install.
48
+ if (!stratum._shimDispatch) {
49
+ // Fallback: directly walk subscribers if dispatch closure not wired.
50
+ // Here we expose a temporary dispatcher by hijacking onEvent's contract:
51
+ // each subscribe call adds to a map; we mirror lookups via a private
52
+ // tracker. The simpler approach is to wrap `onEvent` to also register
53
+ // with our own map. Done at install time below.
54
+ }
55
+ stratum._shimDispatch(env);
56
+ }
57
+
58
+ // Wrap onEvent to keep a parallel registry we can dispatch into.
59
+ if (!stratum._shimSubs) {
60
+ const subs = new Map();
61
+ stratum._shimSubs = subs;
62
+ const realOnEvent = stratum.onEvent.bind(stratum);
63
+ stratum.onEvent = (flowId, stepId, handler) => {
64
+ const key = `${flowId}::${stepId}`;
65
+ let set = subs.get(key);
66
+ if (!set) { set = new Set(); subs.set(key, set); }
67
+ set.add(handler);
68
+ const realUnsub = realOnEvent(flowId, stepId, handler);
69
+ return () => {
70
+ const s = subs.get(key);
71
+ if (s) { s.delete(handler); if (s.size === 0) subs.delete(key); }
72
+ realUnsub();
73
+ };
74
+ };
75
+ stratum._shimDispatch = (env) => {
76
+ const set = subs.get(`${env.flow_id}::${env.step_id}`);
77
+ if (!set) return;
78
+ for (const h of set) {
79
+ try { h(env); } catch (err) { console.error('[shim] handler threw:', err); }
80
+ }
81
+ };
82
+ }
83
+
84
+ stratum.agentRun = async (agentType, prompt, agentOpts = {}) => {
85
+ const correlationId = agentOpts.correlationId ?? `mock-${Date.now()}-${Math.random().toString(36).slice(2)}`;
86
+ const connector = factory(agentType, { cwd: agentOpts.cwd ?? defaultCwd });
87
+ const parts = [];
88
+ let seq = 0;
89
+ let interruptHook = null;
90
+ if (typeof connector.interrupt === 'function') {
91
+ interruptHook = () => { try { connector.interrupt(); } catch { /* best-effort */ } };
92
+ }
93
+ // Hook for cancelAgentRun: stash the interrupt function under correlationId.
94
+ if (!stratum._shimInterrupts) stratum._shimInterrupts = new Map();
95
+ if (interruptHook) stratum._shimInterrupts.set(correlationId, interruptHook);
96
+ try {
97
+ for await (const ev of connector.run(prompt, {})) {
98
+ if (ev.type === 'assistant' && ev.content) {
99
+ parts.push(ev.content);
100
+ stratum._shimDispatch({
101
+ schema_version: '0.2.5',
102
+ flow_id: correlationId, step_id: '_agent_run', task_id: null,
103
+ seq: seq++, ts: new Date().toISOString(),
104
+ kind: 'agent_relay',
105
+ metadata: { role: 'assistant', text: ev.content },
106
+ });
107
+ } else if (ev.type === 'result' && ev.content && parts.length === 0) {
108
+ parts.push(ev.content);
109
+ stratum._shimDispatch({
110
+ schema_version: '0.2.5',
111
+ flow_id: correlationId, step_id: '_agent_run', task_id: null,
112
+ seq: seq++, ts: new Date().toISOString(),
113
+ kind: 'agent_relay',
114
+ metadata: { role: 'assistant', text: ev.content },
115
+ });
116
+ } else if (ev.type === 'tool_use' && ev.tool) {
117
+ stratum._shimDispatch({
118
+ schema_version: '0.2.5',
119
+ flow_id: correlationId, step_id: '_agent_run', task_id: null,
120
+ seq: seq++, ts: new Date().toISOString(),
121
+ kind: 'tool_use_summary',
122
+ metadata: { tool: ev.tool, input: ev.input ?? {}, summary: '', output: '' },
123
+ });
124
+ } else if (ev.type === 'tool_use_summary') {
125
+ stratum._shimDispatch({
126
+ schema_version: '0.2.5',
127
+ flow_id: correlationId, step_id: '_agent_run', task_id: null,
128
+ seq: seq++, ts: new Date().toISOString(),
129
+ kind: 'tool_use_summary',
130
+ metadata: { tool: ev.tool ?? '', input: ev.input ?? {}, summary: ev.summary ?? '', output: ev.output ?? '' },
131
+ });
132
+ } else if (ev.type === 'usage') {
133
+ stratum._shimDispatch({
134
+ schema_version: '0.2.5',
135
+ flow_id: correlationId, step_id: '_agent_run', task_id: null,
136
+ seq: seq++, ts: new Date().toISOString(),
137
+ kind: 'step_usage',
138
+ metadata: {
139
+ input_tokens: ev.input_tokens ?? 0,
140
+ output_tokens: ev.output_tokens ?? 0,
141
+ cache_creation_input_tokens: ev.cache_creation_input_tokens ?? 0,
142
+ cache_read_input_tokens: ev.cache_read_input_tokens ?? 0,
143
+ cost_usd: ev.cost_usd ?? null,
144
+ model: ev.model ?? null,
145
+ },
146
+ });
147
+ } else if (ev.type === 'error') {
148
+ throw new Error(ev.message);
149
+ }
150
+ }
151
+ } finally {
152
+ stratum._shimInterrupts?.delete(correlationId);
153
+ }
154
+ return { text: parts.join(''), correlation_id: correlationId };
155
+ };
156
+
157
+ stratum.runAgentText = async (agentType, prompt, agentOpts = {}) => {
158
+ const r = await stratum.agentRun(agentType, prompt, agentOpts);
159
+ return r.text;
160
+ };
161
+
162
+ stratum.cancelAgentRun = async (correlationId) => {
163
+ const hook = stratum._shimInterrupts?.get(correlationId);
164
+ if (hook) hook();
165
+ return { status: hook ? 'cancelled' : 'not_found', correlation_id: correlationId };
166
+ };
167
+ }
package/lib/constants.js CHANGED
@@ -43,6 +43,24 @@ export const GATE_ARTIFACTS = {
43
43
  report_gate: 'report.md',
44
44
  };
45
45
 
46
+ /**
47
+ * Subdirectory under a feature folder where per-task section files live.
48
+ * Emitted only when a plan exceeds the section threshold.
49
+ */
50
+ export const SECTIONS_DIR = 'sections';
51
+
52
+ /**
53
+ * Read the section-emission threshold from env.
54
+ * Default 5; unparseable falls back to default; finite is clamped to >= 1.
55
+ */
56
+ export function getSectionsThreshold() {
57
+ const envVal = process.env.COMPOSE_PLAN_SECTIONS_THRESHOLD;
58
+ if (envVal === undefined || envVal === null || envVal === '') return 5;
59
+ const raw = parseInt(envVal, 10);
60
+ if (!Number.isFinite(raw)) return 5;
61
+ return Math.max(1, raw);
62
+ }
63
+
46
64
  /**
47
65
  * Title-case a step ID for display when no label exists.
48
66
  * e.g. "some_step" -> "Some Step"
@@ -14,25 +14,44 @@ import { join } from 'node:path';
14
14
  // Fix-Chain Detector
15
15
  // ---------------------------------------------------------------------------
16
16
 
17
+ const FEATURE_MODE_KEY = '__feature_mode__';
18
+ const LEGACY_KEY = '__legacy__';
19
+
17
20
  /**
18
21
  * Tracks which files are modified across fix iterations.
19
22
  * Detects thrashing: same file touched in multiple iterations.
23
+ *
24
+ * Per-bug keying (COMP-FIX-HARD T9): state is namespaced by bug code.
25
+ * Legacy flat API (recordIteration/detect/iteration) delegates to a
26
+ * synthetic `__feature_mode__` key for feature-mode builds.
20
27
  */
21
28
  export class FixChainDetector {
22
29
  constructor() {
23
- this.fileHits = new Map();
24
- this.iteration = 0;
30
+ /** @type {Map<string, { iteration: number, fileHits: Map<string, number> }>} */
31
+ this.byBug = new Map();
25
32
  }
26
33
 
27
- recordIteration(filesChanged) {
28
- this.iteration++;
34
+ _slot(bugCode) {
35
+ let s = this.byBug.get(bugCode);
36
+ if (!s) {
37
+ s = { iteration: 0, fileHits: new Map() };
38
+ this.byBug.set(bugCode, s);
39
+ }
40
+ return s;
41
+ }
42
+
43
+ recordIterationForBug(bugCode, filesChanged) {
44
+ const s = this._slot(bugCode);
45
+ s.iteration++;
29
46
  for (const file of filesChanged) {
30
- this.fileHits.set(file, (this.fileHits.get(file) ?? 0) + 1);
47
+ s.fileHits.set(file, (s.fileHits.get(file) ?? 0) + 1);
31
48
  }
32
49
  }
33
50
 
34
- detect() {
35
- return [...this.fileHits.entries()]
51
+ detectForBug(bugCode) {
52
+ const s = this.byBug.get(bugCode);
53
+ if (!s) return [];
54
+ return [...s.fileHits.entries()]
36
55
  .filter(([, count]) => count >= 2)
37
56
  .map(([file, count]) => ({
38
57
  file,
@@ -41,17 +60,74 @@ export class FixChainDetector {
41
60
  }));
42
61
  }
43
62
 
63
+ getIterationForBug(bugCode) {
64
+ return this.byBug.get(bugCode)?.iteration ?? 0;
65
+ }
66
+
67
+ resetForBug(bugCode) {
68
+ this.byBug.delete(bugCode);
69
+ }
70
+
71
+ // --- Legacy global API (feature mode) ----------------------------------
72
+
73
+ recordIteration(filesChanged) {
74
+ this.recordIterationForBug(FEATURE_MODE_KEY, filesChanged);
75
+ }
76
+
77
+ detect() {
78
+ return this.detectForBug(FEATURE_MODE_KEY);
79
+ }
80
+
81
+ get iteration() {
82
+ return this.getIterationForBug(FEATURE_MODE_KEY);
83
+ }
84
+
85
+ set iteration(v) {
86
+ this._slot(FEATURE_MODE_KEY).iteration = v;
87
+ }
88
+
89
+ get fileHits() {
90
+ return this._slot(FEATURE_MODE_KEY).fileHits;
91
+ }
92
+
93
+ set fileHits(map) {
94
+ this._slot(FEATURE_MODE_KEY).fileHits = map instanceof Map ? map : new Map(Object.entries(map ?? {}));
95
+ }
96
+
97
+ // --- Serialization ------------------------------------------------------
98
+
44
99
  toJSON() {
45
- return {
46
- iteration: this.iteration,
47
- fileHits: Object.fromEntries(this.fileHits),
48
- };
100
+ const out = {};
101
+ for (const [key, s] of this.byBug.entries()) {
102
+ out[key] = {
103
+ iteration: s.iteration,
104
+ fileHits: Object.fromEntries(s.fileHits),
105
+ };
106
+ }
107
+ return out;
49
108
  }
50
109
 
51
110
  static fromJSON(json) {
52
111
  const d = new FixChainDetector();
53
- d.iteration = json.iteration ?? 0;
54
- d.fileHits = new Map(Object.entries(json.fileHits ?? {}));
112
+ if (!json || typeof json !== 'object') return d;
113
+ // Legacy detection: flat shape has top-level `iteration` and/or `fileHits`
114
+ // and no per-bug sub-objects with the per-bug shape.
115
+ // Salvage any top-level legacy fields. Fold into __feature_mode__ so the
116
+ // existing global-API getters (recordIteration / detect / iteration getter)
117
+ // continue to surface it. If there's already a per-bug subkey for
118
+ // __feature_mode__ in the same JSON, the explicit subkey wins.
119
+ if ('iteration' in json || 'fileHits' in json) {
120
+ const slot = d._slot(FEATURE_MODE_KEY);
121
+ slot.iteration = json.iteration ?? 0;
122
+ slot.fileHits = new Map(Object.entries(json.fileHits ?? {}));
123
+ }
124
+ for (const [key, sub] of Object.entries(json)) {
125
+ if (key === 'iteration' || key === 'fileHits') continue; // top-level legacy, already folded
126
+ if (!sub || typeof sub !== 'object') continue;
127
+ const slot = d._slot(key);
128
+ slot.iteration = sub.iteration ?? 0;
129
+ slot.fileHits = new Map(Object.entries(sub.fileHits ?? {}));
130
+ }
55
131
  return d;
56
132
  }
57
133
  }
@@ -65,42 +141,115 @@ const VISUAL_EXTENSIONS = /\.(css|scss|jsx|tsx)$/i;
65
141
  /**
66
142
  * Tracks fix attempts and enforces thresholds with escalation.
67
143
  * Visual bugs escalate at attempt 2; all bugs escalate at attempt 5.
144
+ *
145
+ * Per-bug keying (COMP-FIX-HARD T9): state is namespaced by bug code.
146
+ * Legacy flat API (record/getIntervention/count/isVisual) delegates to a
147
+ * synthetic `__feature_mode__` key for feature-mode builds.
68
148
  */
69
149
  export class AttemptCounter {
70
150
  constructor() {
71
- this.count = 0;
72
- this.isVisual = false;
151
+ /** @type {Map<string, { count: number, isVisual: boolean }>} */
152
+ this.byBug = new Map();
73
153
  }
74
154
 
75
- record({ filesChanged = [], isVisual = null }) {
76
- this.count++;
155
+ _slot(bugCode) {
156
+ let s = this.byBug.get(bugCode);
157
+ if (!s) {
158
+ s = { count: 0, isVisual: false };
159
+ this.byBug.set(bugCode, s);
160
+ }
161
+ return s;
162
+ }
163
+
164
+ recordForBug(bugCode, { filesChanged = [], isVisual = null } = {}) {
165
+ const s = this._slot(bugCode);
166
+ s.count++;
77
167
  if (isVisual !== null) {
78
- this.isVisual = isVisual;
168
+ s.isVisual = isVisual;
79
169
  } else if (filesChanged.some(f => AttemptCounter.isVisualFile(f))) {
80
- this.isVisual = true;
170
+ s.isVisual = true;
81
171
  }
82
172
  }
83
173
 
84
- getIntervention() {
85
- if (this.count >= 5) return 'escalate';
86
- if (this.count >= 3 && !this.isVisual) return 'trace_refresh';
87
- if (this.count >= 2 && this.isVisual) return 'escalate';
88
- if (this.count >= 2) return 'trace_reminder';
174
+ getCountForBug(bugCode) {
175
+ return this.byBug.get(bugCode)?.count ?? 0;
176
+ }
177
+
178
+ getInterventionForBug(bugCode) {
179
+ const s = this.byBug.get(bugCode);
180
+ if (!s) return null;
181
+ if (s.count >= 5) return 'escalate';
182
+ if (s.count >= 3 && !s.isVisual) return 'trace_refresh';
183
+ if (s.count >= 2 && s.isVisual) return 'escalate';
184
+ if (s.count >= 2) return 'trace_reminder';
89
185
  return null;
90
186
  }
91
187
 
188
+ resetForBug(bugCode) {
189
+ this.byBug.delete(bugCode);
190
+ }
191
+
192
+ // --- Legacy global API (feature mode) ----------------------------------
193
+
194
+ record(opts) {
195
+ this.recordForBug(FEATURE_MODE_KEY, opts ?? {});
196
+ }
197
+
198
+ getIntervention() {
199
+ return this.getInterventionForBug(FEATURE_MODE_KEY);
200
+ }
201
+
202
+ get count() {
203
+ return this.getCountForBug(FEATURE_MODE_KEY);
204
+ }
205
+
206
+ set count(v) {
207
+ this._slot(FEATURE_MODE_KEY).count = v;
208
+ }
209
+
210
+ get isVisual() {
211
+ return this.byBug.get(FEATURE_MODE_KEY)?.isVisual ?? false;
212
+ }
213
+
214
+ set isVisual(v) {
215
+ this._slot(FEATURE_MODE_KEY).isVisual = !!v;
216
+ }
217
+
92
218
  static isVisualFile(file) {
93
219
  return VISUAL_EXTENSIONS.test(file);
94
220
  }
95
221
 
222
+ // --- Serialization ------------------------------------------------------
223
+
96
224
  toJSON() {
97
- return { count: this.count, isVisual: this.isVisual };
225
+ const out = {};
226
+ for (const [key, s] of this.byBug.entries()) {
227
+ out[key] = { count: s.count, isVisual: s.isVisual };
228
+ }
229
+ return out;
98
230
  }
99
231
 
100
232
  static fromJSON(json) {
101
233
  const c = new AttemptCounter();
102
- c.count = json.count ?? 0;
103
- c.isVisual = json.isVisual ?? false;
234
+ if (!json || typeof json !== 'object') return c;
235
+ // Legacy detection: flat shape has top-level `count`/`isVisual` and
236
+ // no per-bug sub-objects with the per-bug shape.
237
+ // Salvage any top-level legacy fields. Fold into __feature_mode__ so the
238
+ // existing global-API getters (count getter, getIntervention, etc.)
239
+ // continue to surface it. If an explicit __feature_mode__ subkey is also
240
+ // present, it wins (loop below).
241
+ if ('count' in json || 'isVisual' in json) {
242
+ const slot = c._slot(FEATURE_MODE_KEY);
243
+ slot.count = json.count ?? 0;
244
+ slot.isVisual = json.isVisual ?? false;
245
+ }
246
+ for (const [key, sub] of Object.entries(json)) {
247
+ if (key === 'count' || key === 'isVisual') continue; // top-level legacy, already handled
248
+ if (!sub || typeof sub !== 'object') continue;
249
+ const slot = c._slot(key);
250
+ slot.count = sub.count ?? 0;
251
+ slot.isVisual = sub.isVisual ?? false;
252
+ }
104
253
  return c;
105
254
  }
106
255
  }
package/lib/deps.js ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * COMP-DEPS-PACKAGE — external skill dependency manifest helpers.
3
+ *
4
+ * Three exported functions used by `bin/compose.js` (and tested in
5
+ * `test/comp-deps-package.test.js`):
6
+ *
7
+ * loadDeps(packageRoot) — load and validate .compose-deps.json
8
+ * checkExternalSkills(deps, home?) — scan disk, return present/missing dep records
9
+ * printDepReport(result, opts?) — human or JSON output, returns true if all required deps present
10
+ *
11
+ * Manifest schema is documented in docs/features/COMP-DEPS-PACKAGE/blueprint.md.
12
+ */
13
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import { homedir } from 'node:os'
16
+
17
+ /**
18
+ * Load .compose-deps.json from `packageRoot`. Returns the parsed manifest with
19
+ * invalid entries filtered out (skip-and-warn), or null if the manifest file
20
+ * is missing/unparseable/structurally invalid.
21
+ */
22
+ export function loadDeps(packageRoot) {
23
+ const manifestPath = join(packageRoot, '.compose-deps.json')
24
+ if (!existsSync(manifestPath)) return null
25
+ let raw
26
+ try {
27
+ raw = JSON.parse(readFileSync(manifestPath, 'utf-8'))
28
+ } catch (e) {
29
+ console.warn(`Warning: failed to parse .compose-deps.json: ${e.message}`)
30
+ return null
31
+ }
32
+ if (raw.version !== 1) {
33
+ console.warn(`Warning: .compose-deps.json version ${raw.version} unsupported (expected 1)`)
34
+ return null
35
+ }
36
+ if (!Array.isArray(raw.external_skills)) {
37
+ console.warn('Warning: .compose-deps.json external_skills must be an array')
38
+ return null
39
+ }
40
+ const valid = []
41
+ for (const dep of raw.external_skills) {
42
+ const idOk = typeof dep?.id === 'string'
43
+ const reqOk = Array.isArray(dep?.required_for) && dep.required_for.every(v => typeof v === 'string')
44
+ const installOk = typeof dep?.install === 'string'
45
+ const fallbackOk = dep?.fallback === null || typeof dep?.fallback === 'string'
46
+ const optOk = typeof dep?.optional === 'boolean'
47
+ if (!(idOk && reqOk && installOk && fallbackOk && optOk)) {
48
+ console.warn(`Warning: skipping invalid dep entry in .compose-deps.json: ${JSON.stringify(dep)}`)
49
+ continue
50
+ }
51
+ valid.push(dep)
52
+ }
53
+ return { ...raw, external_skills: valid }
54
+ }
55
+
56
+ /**
57
+ * Scan disk for installed skills and commands matching the manifest's deps.
58
+ * Returns { present: [...], missing: [...], scannedPaths: [...] }.
59
+ *
60
+ * Scans:
61
+ * - <home>/.claude/skills/<id>/SKILL.md (bare-name skills)
62
+ * - <home>/.claude/plugins/marketplaces/<m>/plugins/<p>/skills/<s>/SKILL.md (pattern A)
63
+ * - <home>/.claude/plugins/marketplaces/<m>/plugins/<p>/commands/<n>.md (pattern A')
64
+ * - <home>/.claude/plugins/marketplaces/<m>/.claude/skills/<s>/SKILL.md (pattern B)
65
+ * - <home>/.claude/plugins/marketplaces/<m>/.claude/commands/<n>.md (pattern B')
66
+ * - <home>/.claude/plugins/cache/<marketplace>/<plugin>/<version>/skills/<s>/SKILL.md (pattern C)
67
+ */
68
+ export function checkExternalSkills(deps, home = homedir()) {
69
+ const userSkillsRoot = join(home, '.claude', 'skills')
70
+ const marketplacesRoot = join(home, '.claude', 'plugins', 'marketplaces')
71
+ const cacheRoot = join(home, '.claude', 'plugins', 'cache')
72
+ const scannedPaths = [userSkillsRoot, marketplacesRoot, cacheRoot]
73
+
74
+ const bareInstalled = new Set()
75
+ const namespacedInstalled = new Set()
76
+ const addNs = (ns, leaf) => namespacedInstalled.add(`${ns}:${leaf}`)
77
+
78
+ // Helpers: list only entries of a given dirent kind, swallow scandir errors.
79
+ const listDirs = (path) => {
80
+ if (!existsSync(path)) return []
81
+ try {
82
+ return readdirSync(path, { withFileTypes: true })
83
+ .filter(e => e.isDirectory())
84
+ .map(e => e.name)
85
+ } catch { return [] }
86
+ }
87
+ const listFiles = (path) => {
88
+ if (!existsSync(path)) return []
89
+ try {
90
+ return readdirSync(path, { withFileTypes: true })
91
+ .filter(e => e.isFile())
92
+ .map(e => e.name)
93
+ } catch { return [] }
94
+ }
95
+
96
+ // 1. Bare-name user skills
97
+ for (const entry of listDirs(userSkillsRoot)) {
98
+ if (existsSync(join(userSkillsRoot, entry, 'SKILL.md'))) bareInstalled.add(entry)
99
+ }
100
+
101
+ // 2. Marketplaces — skills (A, B) and commands (A', B')
102
+ for (const m of listDirs(marketplacesRoot)) {
103
+ // Pattern A / A': marketplaces/<m>/plugins/<p>/{skills,commands}/...
104
+ const pluginsDir = join(marketplacesRoot, m, 'plugins')
105
+ for (const p of listDirs(pluginsDir)) {
106
+ const skillsDir = join(pluginsDir, p, 'skills')
107
+ for (const s of listDirs(skillsDir)) {
108
+ if (existsSync(join(skillsDir, s, 'SKILL.md'))) addNs(p, s)
109
+ }
110
+ const commandsDir = join(pluginsDir, p, 'commands')
111
+ for (const f of listFiles(commandsDir)) {
112
+ if (f.endsWith('.md')) addNs(p, f.slice(0, -3))
113
+ }
114
+ }
115
+ // Pattern B / B': marketplaces/<m>/.claude/{skills,commands}/...
116
+ const dotSkillsDir = join(marketplacesRoot, m, '.claude', 'skills')
117
+ for (const s of listDirs(dotSkillsDir)) {
118
+ if (existsSync(join(dotSkillsDir, s, 'SKILL.md'))) addNs(m, s)
119
+ }
120
+ const dotCommandsDir = join(marketplacesRoot, m, '.claude', 'commands')
121
+ for (const f of listFiles(dotCommandsDir)) {
122
+ if (f.endsWith('.md')) addNs(m, f.slice(0, -3))
123
+ }
124
+ }
125
+
126
+ // 3. Cache — pattern C: cache/<marketplace>/<plugin>/<version>/skills/<s>/SKILL.md
127
+ for (const marketplace of listDirs(cacheRoot)) {
128
+ for (const plugin of listDirs(join(cacheRoot, marketplace))) {
129
+ for (const version of listDirs(join(cacheRoot, marketplace, plugin))) {
130
+ const skillsDir = join(cacheRoot, marketplace, plugin, version, 'skills')
131
+ for (const s of listDirs(skillsDir)) {
132
+ if (existsSync(join(skillsDir, s, 'SKILL.md'))) addNs(plugin, s)
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ const present = []
139
+ const missing = []
140
+ for (const dep of deps.external_skills) {
141
+ const isNamespaced = dep.id.includes(':')
142
+ const found = isNamespaced ? namespacedInstalled.has(dep.id) : bareInstalled.has(dep.id)
143
+ if (found) present.push(dep); else missing.push(dep)
144
+ }
145
+
146
+ return { present, missing, scannedPaths }
147
+ }
148
+
149
+ /**
150
+ * Print human or JSON dep report. Returns true if all required (non-optional) deps present.
151
+ *
152
+ * opts:
153
+ * json — emit JSON with full dep records (id, required_for, install, fallback, optional)
154
+ * verbose — also list scanned paths in human mode
155
+ */
156
+ export function printDepReport(result, opts = {}) {
157
+ const projectDep = (d) => ({
158
+ id: d.id,
159
+ required_for: d.required_for,
160
+ install: d.install,
161
+ fallback: d.fallback ?? null,
162
+ optional: d.optional,
163
+ })
164
+
165
+ if (opts.json) {
166
+ console.log(JSON.stringify({
167
+ present: result.present.map(projectDep),
168
+ missing: result.missing.map(projectDep),
169
+ scannedPaths: result.scannedPaths,
170
+ }, null, 2))
171
+ return result.missing.every(d => d.optional)
172
+ }
173
+
174
+ console.log('\nExternal skill dependencies:')
175
+ for (const dep of result.present) {
176
+ console.log(` ✓ ${dep.id}`)
177
+ }
178
+
179
+ const missingRequired = result.missing.filter(d => !d.optional)
180
+ const missingOptional = result.missing.filter(d => d.optional)
181
+
182
+ for (const dep of missingRequired) {
183
+ console.log(` ✗ ${dep.id} — install: ${dep.install}`)
184
+ }
185
+ for (const dep of missingOptional) {
186
+ console.log(` ○ ${dep.id} (optional) — install: ${dep.install}`)
187
+ }
188
+
189
+ const total = result.present.length + result.missing.length
190
+ if (result.missing.length === 0) {
191
+ console.log(`\nAll ${total} deps present.`)
192
+ } else {
193
+ console.log(`\n${result.missing.length} of ${total} deps missing (${missingRequired.length} required, ${missingOptional.length} optional).`)
194
+ if (missingRequired.length > 0) {
195
+ console.log('Lifecycle will run in degraded mode for affected phases. See SKILL.md §Dependencies for fallback paths.')
196
+ }
197
+ }
198
+
199
+ if (opts.verbose) {
200
+ console.log('\nScanned paths:')
201
+ for (const p of result.scannedPaths) console.log(` ${p}`)
202
+ }
203
+
204
+ return missingRequired.length === 0
205
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Aggregates signals from existing build artifacts into a 0-100 composite
5
5
  * health score. No new data sources — only aggregates what already exists:
6
- * - Review findings from parallel_review's MergedReviewResult
6
+ * - Review findings from parallel_review's ReviewResult (canonical — STRAT-CLAUDE-EFFORT-PARITY)
7
7
  * - Test pass/fail from coverage_check result
8
8
  * - Contract compliance from Stratum ensure results
9
9
  * - Doc freshness from lib/staleness.js
@@ -52,19 +52,19 @@ export function scoreTestCoverage(testResult) {
52
52
  }
53
53
 
54
54
  /**
55
- * Score review findings from a MergedReviewResult.
55
+ * Score review findings from a ReviewResult.
56
56
  *
57
57
  * Severity breakdown:
58
58
  * must-fix → -20 per finding
59
59
  * should-fix → -5 per finding
60
60
  * nit → -1 per finding
61
61
  *
62
- * @param {object|null} mergedResult { findings: [{severity, ...}] }
62
+ * @param {object|null} mergedResult ReviewResult: { findings: [{severity, ...}] }
63
63
  * @returns {number} 0-100 (floored at 0)
64
64
  */
65
65
  export function scoreReviewFindings(mergedResult) {
66
66
  if (mergedResult == null) return 50; // neutral — no data
67
- const findings = mergedResult.findings ?? mergedResult.all_findings ?? [];
67
+ const findings = mergedResult.findings ?? [];
68
68
  if (findings.length === 0) return 100;
69
69
 
70
70
  let score = 100;