@smartmemory/compose 0.1.1-beta → 0.1.3-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.
- package/.claude/skills/bug-fix/SKILL.md +143 -0
- package/.claude/skills/compose/SKILL.md +604 -0
- package/.compose-deps.json +89 -0
- package/README.md +47 -983
- package/bin/compose.js +473 -0
- package/contracts/comp-obs-contract.schema.json +362 -0
- package/contracts/cross-model-review-result.json +78 -0
- package/contracts/review-result.json +126 -0
- package/dist/assets/{_baseUniq-CQwX6VLz.js → _baseUniq-D-avYfn5.js} +1 -1
- package/dist/assets/{arc-SxJ2J1sh.js → arc-BC4dfQ-X.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BykunY1F.js → architectureDiagram-Q4EWVU46-BZmFXnGI.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-ohAKBOUw.js → blockDiagram-DXYQGD6D-DlfWSuux.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-DBDC3ENB.js → c4Diagram-AHTNJAMY-Y__uJrRx.js} +1 -1
- package/dist/assets/channel-LRG9kHqJ.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-Cv93Z7uM.js → chunk-4BX2VUAB-BfMePfTp.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-DE0WBDkj.js → chunk-4TB4RGXK-BdlMSdEA.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-CE1EXenG.js → chunk-55IACEB6-vrQHZTdv.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-DA7Ana6H.js → chunk-EDXVE4YY-B8wioVlW.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-CTDIPA3p.js → chunk-FMBD7UC4-Cd6Hrux2.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-uGBaPaTX.js → chunk-OYMX7WX6-CfrhdQXY.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-CYlnXuUO.js → chunk-QZHKN3VN-B9JQerOU.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-ojGkzcZK.js → chunk-YZCP3GAM-DFN9X99H.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BC9a6pDE.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-BC9a6pDE.js +1 -0
- package/dist/assets/clone-dRxgFrBv.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-Bktn9hL-.js → cose-bilkent-S5V4N54A-BAn0ap_E.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-DFaSzuRF.js → dagre-KV5264BT-DyxnVq1g.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DnfmDzEm.js → diagram-5BDNPKRD-XCrzqski.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-Bm8W9YnG.js → diagram-G4DWMVQ6-MBCAXft_.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-B5-TSKvp.js → diagram-MMDJMWI5-DbtB2yS6.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-ls4rqlky.js → diagram-TYMM5635-Bb5NzX61.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-giG6WO-r.js → erDiagram-SMLLAGMA-CpIeCOh2.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-XvlUuz-7.js → flowDiagram-DWJPFMVM-CHyoKnhW.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-hLBV57oV.js → ganttDiagram-T4ZO3ILL-DErKteO_.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-BHu3s_Gn.js → gitGraphDiagram-UUTBAWPF-KFVAtj2F.js} +1 -1
- package/dist/assets/{graph-D0Cfv00Y.js → graph-CRnO_ifT.js} +1 -1
- package/dist/assets/index-DKBsEUJ-.css +1 -0
- package/dist/assets/index-DkRKLuNr.js +1144 -0
- package/dist/assets/{infoDiagram-42DDH7IO-DbqRsOo3.js → infoDiagram-42DDH7IO-BZFnuSp5.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-DnCdx7zb.js → ishikawaDiagram-UXIWVN3A-4Xe2Szde.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-CfD7eNcP.js → journeyDiagram-VCZTEJTY-CZRByfS-.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-BYaO9-mK.js → kanban-definition-6JOO6SKY-B95sk6Fk.js} +1 -1
- package/dist/assets/{layout-Bj72wOEB.js → layout-BqNQzxWT.js} +1 -1
- package/dist/assets/{linear-BRFo114D.js → linear-CUh7qb64.js} +1 -1
- package/dist/assets/{min-GCHnKlJS.js → min-wXgOS3ig.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-n0PMebY4.js → mindmap-definition-QFDTVHPH-DB6iaAbO.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-pN4CljHF.js → pieDiagram-DEJITSTG-CHkZHrTW.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-DNoAy8-D.js → quadrantDiagram-34T5L4WZ-DoTEO8e3.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-BhtY05PT.js → requirementDiagram-MS252O5E-Dn8peXYp.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-B6AD-16A.js → sankeyDiagram-XADWPNL6-DRXs6Ipb.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-DShHM-uk.js → sequenceDiagram-FGHM5R23-wBBYZ0aq.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-DMxn7HTo.js → stateDiagram-FHFEXIEX-DPlBNGmf.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-BW0ezXb4.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-Cdu6uq52.js → timeline-definition-GMOUNBTQ-CbbyTlHk.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-CpK29iRe.js → vennDiagram-DHZGUBPP-Bj4GaFfj.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-BQgSkdcO.js → wardley-RL74JXVD-RtNzq8KU.js} +55 -55
- package/dist/assets/{wardleyDiagram-NUSXRM2D-DJHYev6O.js → wardleyDiagram-NUSXRM2D-CDfE3zSj.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-1d75pbaO.js → xychartDiagram-5P7HB3ND-CZXHHYD5.js} +1 -1
- package/dist/index.html +2 -2
- package/lib/budget-ledger.js +45 -0
- package/lib/bug-bisect.js +292 -0
- package/lib/bug-checkpoint.js +191 -0
- package/lib/bug-escalation.js +306 -0
- package/lib/bug-index-gen.js +136 -0
- package/lib/bug-ledger.js +126 -0
- package/lib/build-stream-schema.js +176 -0
- package/lib/build-stream-writer.js +3 -1
- package/lib/build.js +854 -284
- package/lib/connector-factory-shim.js +167 -0
- package/lib/constants.js +18 -0
- package/lib/debug-discipline.js +176 -27
- package/lib/deps.js +205 -0
- package/lib/health-score.js +4 -4
- package/lib/import.js +26 -13
- package/lib/inject-schema.js +21 -0
- package/lib/new.js +27 -53
- package/lib/result-normalizer.js +160 -144
- package/lib/review-lenses.js +5 -5
- package/lib/review-normalize.js +413 -0
- package/lib/review-prompt.js +163 -0
- package/lib/sections.js +325 -0
- package/lib/step-prompt.js +21 -1
- package/lib/step-validator.js +5 -3
- package/lib/stratum-mcp-client.js +172 -7
- package/package.json +14 -3
- package/pipelines/bug-fix.stratum.yaml +39 -1
- package/pipelines/build.stratum.yaml +28 -45
- package/pipelines/review-fix.stratum.yaml +1 -1
- package/presets/team-review.stratum.yaml +21 -14
- package/server/build-stream-bridge.js +28 -0
- package/server/cc-session-feature-resolver.js +111 -0
- package/server/cc-session-reader.js +327 -0
- package/server/cc-session-watcher.js +318 -0
- package/server/compose-mcp-tools.js +0 -125
- package/server/compose-mcp.js +2 -4
- package/server/contract-diff.js +192 -0
- package/server/decision-event-emit.js +175 -0
- package/server/decision-event-id.js +64 -0
- package/server/decision-events-snapshot.js +166 -0
- package/server/design-routes.js +92 -49
- package/server/drift-axes.js +365 -0
- package/server/drift-emit.js +121 -0
- package/server/gate-log-store.js +102 -0
- package/server/lifecycle-phase-history.js +44 -0
- package/server/open-loops-store.js +102 -0
- package/server/schema-validator.js +49 -0
- package/server/status-emit.js +27 -0
- package/server/status-snapshot.js +218 -0
- package/server/vision-routes.js +332 -4
- package/server/vision-server.js +104 -12
- package/server/vision-store.js +21 -0
- package/dist/assets/channel-DGElom1e.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-KqWP9wWZ.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-KqWP9wWZ.js +0 -1
- package/dist/assets/clone-DUJKJXd7.js +0 -1
- package/dist/assets/index-CUd6pFGF.css +0 -1
- package/dist/assets/index-DReRlzZI.js +0 -1144
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-o6PnCs4e.js +0 -1
- package/server/connectors/agent-connector.js +0 -78
- package/server/connectors/claude-sdk-connector.js +0 -198
- package/server/connectors/codex-connector.js +0 -240
- package/server/connectors/connector-discovery.js +0 -18
- package/server/connectors/connector-runtime.js +0 -13
- package/server/connectors/opencode-connector.js +0 -200
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bug-checkpoint.js — COMP-FIX-HARD T2.
|
|
3
|
+
*
|
|
4
|
+
* Emits docs/bugs/<bug_code>/checkpoint.md when a bug-mode pipeline force-terminates,
|
|
5
|
+
* then triggers regeneration of the global docs/bugs/INDEX.md.
|
|
6
|
+
*
|
|
7
|
+
* The bug-index-gen.js module is imported dynamically to tolerate parallel-task
|
|
8
|
+
* development: if T3's file is not yet present, we still write the checkpoint and
|
|
9
|
+
* emit a warning instead of throwing. Tests inject a stub via
|
|
10
|
+
* __setRegenerateBugIndexForTest.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { join, dirname } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { execSync } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
const DIFF_CAP = 5000;
|
|
19
|
+
|
|
20
|
+
// Test seam: tests can install a fake regenerator. When null, the default
|
|
21
|
+
// dynamic-import path is used.
|
|
22
|
+
let regeneratorOverride = null;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Test-only hook to override the regenerateBugIndex implementation.
|
|
26
|
+
* Pass `null` to restore default behavior.
|
|
27
|
+
* @param {((cwd: string) => void | Promise<void>) | null} fn
|
|
28
|
+
*/
|
|
29
|
+
export function __setRegenerateBugIndexForTest(fn) {
|
|
30
|
+
regeneratorOverride = fn;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Emit a checkpoint markdown file for a bug whose pipeline has force-terminated,
|
|
35
|
+
* then regenerate the bugs INDEX.
|
|
36
|
+
*
|
|
37
|
+
* @param {{ cwd: string, bug_code: string }} context - Build context (must include cwd and bug_code).
|
|
38
|
+
* @param {string} stepId - The pipeline step that exhausted retries.
|
|
39
|
+
* @param {{ violations?: any[], retries_exhausted?: number, [k: string]: any }} terminalResult
|
|
40
|
+
* - The final step response that triggered termination.
|
|
41
|
+
* @returns {Promise<string>} Absolute path of the written checkpoint.md.
|
|
42
|
+
*/
|
|
43
|
+
export async function emitCheckpoint(context, stepId, terminalResult) {
|
|
44
|
+
const { cwd, bug_code } = context;
|
|
45
|
+
const bugDir = join(cwd, 'docs', 'bugs', bug_code);
|
|
46
|
+
mkdirSync(bugDir, { recursive: true });
|
|
47
|
+
|
|
48
|
+
const ts = new Date().toISOString();
|
|
49
|
+
const retriesExhausted =
|
|
50
|
+
(terminalResult && typeof terminalResult.retries_exhausted === 'number'
|
|
51
|
+
? terminalResult.retries_exhausted
|
|
52
|
+
: null) ?? readActiveBuildRetries(cwd, stepId) ?? 0;
|
|
53
|
+
|
|
54
|
+
const diffBody = getCurrentDiff(cwd);
|
|
55
|
+
|
|
56
|
+
const violations =
|
|
57
|
+
terminalResult && Array.isArray(terminalResult.violations) ? terminalResult.violations : [];
|
|
58
|
+
const failureBody = formatLastFailure(violations[0]);
|
|
59
|
+
|
|
60
|
+
const ledgerPointer = existsSync(join(bugDir, 'hypotheses.jsonl'))
|
|
61
|
+
? '[hypotheses.jsonl](./hypotheses.jsonl)'
|
|
62
|
+
: '(none yet)';
|
|
63
|
+
|
|
64
|
+
const md = [
|
|
65
|
+
`# Checkpoint: ${bug_code}`,
|
|
66
|
+
'',
|
|
67
|
+
`**Time:** ${ts}`,
|
|
68
|
+
`**Step:** ${stepId}`,
|
|
69
|
+
`**Retries exhausted:** ${retriesExhausted}`,
|
|
70
|
+
'',
|
|
71
|
+
'## Current Diff',
|
|
72
|
+
'',
|
|
73
|
+
'```diff',
|
|
74
|
+
diffBody,
|
|
75
|
+
'```',
|
|
76
|
+
'',
|
|
77
|
+
'## Last Failure',
|
|
78
|
+
'',
|
|
79
|
+
'```',
|
|
80
|
+
failureBody,
|
|
81
|
+
'```',
|
|
82
|
+
'',
|
|
83
|
+
'## Hypothesis Ledger',
|
|
84
|
+
'',
|
|
85
|
+
ledgerPointer,
|
|
86
|
+
'',
|
|
87
|
+
'## To Resume',
|
|
88
|
+
'',
|
|
89
|
+
'```bash',
|
|
90
|
+
`compose fix ${bug_code} --resume`,
|
|
91
|
+
'```',
|
|
92
|
+
'',
|
|
93
|
+
'## Next Steps',
|
|
94
|
+
'',
|
|
95
|
+
'- Inspect the diff above and decide whether to keep, amend, or revert it.',
|
|
96
|
+
'- Review the hypothesis ledger for previously rejected diagnoses.',
|
|
97
|
+
'- Run the resume command to re-enter the failed step with full context.',
|
|
98
|
+
'- If the bug is unsolvable in this session, mark it `PARKED` in the roadmap.',
|
|
99
|
+
'',
|
|
100
|
+
].join('\n');
|
|
101
|
+
|
|
102
|
+
const checkpointPath = join(bugDir, 'checkpoint.md');
|
|
103
|
+
writeFileSync(checkpointPath, md, 'utf8');
|
|
104
|
+
|
|
105
|
+
await invokeRegenerateBugIndex(cwd);
|
|
106
|
+
|
|
107
|
+
return checkpointPath;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Capture `git diff --no-color HEAD`, capped at DIFF_CAP chars.
|
|
112
|
+
* Returns "(unable to get diff)" if git is unavailable or cwd is not a repo.
|
|
113
|
+
* Never throws.
|
|
114
|
+
*/
|
|
115
|
+
function getCurrentDiff(cwd) {
|
|
116
|
+
try {
|
|
117
|
+
const out = execSync('git diff --no-color HEAD', {
|
|
118
|
+
cwd,
|
|
119
|
+
encoding: 'utf8',
|
|
120
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
121
|
+
// 2MB cap: large enough that realistic multi-file diffs at force-terminate
|
|
122
|
+
// time fit (round-1 reduced from 50MB to avoid OOM; round-2 caught that
|
|
123
|
+
// 20KB / DIFF_CAP*4 was so small ENOBUFS fired on most real diffs and
|
|
124
|
+
// checkpoint lost the diff entirely). 2MB is the sweet spot: keeps OOM
|
|
125
|
+
// safety while preserving usable diffs for normal cases.
|
|
126
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
127
|
+
});
|
|
128
|
+
if (!out || out.length === 0) return '(no changes)';
|
|
129
|
+
return out.length > DIFF_CAP ? out.slice(0, DIFF_CAP) : out;
|
|
130
|
+
} catch {
|
|
131
|
+
return '(unable to get diff)';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Best-effort lookup of retries-so-far from .compose/data/active-build.json.
|
|
137
|
+
* Returns null when unavailable.
|
|
138
|
+
*/
|
|
139
|
+
function readActiveBuildRetries(cwd, stepId) {
|
|
140
|
+
try {
|
|
141
|
+
const p = join(cwd, '.compose', 'data', 'active-build.json');
|
|
142
|
+
if (!existsSync(p)) return null;
|
|
143
|
+
const raw = readFileSync(p, 'utf8');
|
|
144
|
+
const obj = JSON.parse(raw);
|
|
145
|
+
const counters = obj?.retryCounters || obj?.retry_counters;
|
|
146
|
+
if (counters && typeof counters[stepId] === 'number') return counters[stepId];
|
|
147
|
+
return null;
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Format the head violation for the Last Failure block.
|
|
155
|
+
* - JSON-stringify objects (pretty), pass strings through.
|
|
156
|
+
*/
|
|
157
|
+
function formatLastFailure(v) {
|
|
158
|
+
if (v == null) return '(no violation captured)';
|
|
159
|
+
if (typeof v === 'string') return v;
|
|
160
|
+
try {
|
|
161
|
+
return JSON.stringify(v, null, 2);
|
|
162
|
+
} catch {
|
|
163
|
+
return String(v);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Resolve the regenerateBugIndex function and call it. Honors test override.
|
|
169
|
+
* Falls back gracefully (warning to stderr) if the sibling module isn't present yet.
|
|
170
|
+
*/
|
|
171
|
+
async function invokeRegenerateBugIndex(cwd) {
|
|
172
|
+
if (typeof regeneratorOverride === 'function') {
|
|
173
|
+
await regeneratorOverride(cwd);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
178
|
+
const target = join(here, 'bug-index-gen.js');
|
|
179
|
+
if (!existsSync(target)) {
|
|
180
|
+
// Sibling module not built yet (parallel-task race); skip silently in that
|
|
181
|
+
// narrow window. INDEX will refresh next time a checkpoint emits.
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const mod = await import(`file://${target}`);
|
|
185
|
+
if (typeof mod.regenerateBugIndex === 'function') {
|
|
186
|
+
await mod.regenerateBugIndex(cwd);
|
|
187
|
+
}
|
|
188
|
+
} catch (err) {
|
|
189
|
+
process.stderr.write(`[bug-checkpoint] regenerateBugIndex failed: ${err?.message || err}\n`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bug-escalation.js — COMP-FIX-HARD T10 Tier 1 + Tier 2 escalation.
|
|
3
|
+
*
|
|
4
|
+
* Tier 1: Codex second opinion (read-only). Costs ~30s, no writes.
|
|
5
|
+
* - Constructs a bounded prompt (one bug, one ledger, one diff).
|
|
6
|
+
* - Dispatches via stratum.runAgentText('codex', prompt, {cwd}).
|
|
7
|
+
* - Parses output to canonical ReviewResult via review-normalize.
|
|
8
|
+
* - Appends an `escalation_tier_1` entry to the bug's hypothesis ledger.
|
|
9
|
+
*
|
|
10
|
+
* Tier 2: Fresh agent in an isolated git worktree (patch-only, never commits).
|
|
11
|
+
* - "Materially new" gate: only proceeds if Codex's hypothesis is not already
|
|
12
|
+
* present in the ledger as a rejected entry.
|
|
13
|
+
* - Creates a detached worktree under ~/.stratum/worktrees/comp-fix-hard/<bug>-<ts>/.
|
|
14
|
+
* - Dispatches a fresh Claude agent with explicit "DO NOT commit" instructions
|
|
15
|
+
* and a target patch artifact path docs/bugs/<bug>/escalation-patch-<N>.md.
|
|
16
|
+
* - Cleanup runs in finally — worktree is removed on both success and error.
|
|
17
|
+
*
|
|
18
|
+
* Pattern references:
|
|
19
|
+
* - lib/build.js:2670+ (parallel-dispatch worktree create/remove)
|
|
20
|
+
* - lib/review-normalize.js (normalizeReviewResult)
|
|
21
|
+
* - lib/stratum-mcp-client.js (runAgentText)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { execSync } from 'node:child_process';
|
|
28
|
+
|
|
29
|
+
import { normalizeReviewResult } from './review-normalize.js';
|
|
30
|
+
import { appendHypothesisEntry, readHypotheses } from './bug-ledger.js';
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Tier 1 — Codex read-only second opinion
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Format the hypothesis ledger as a "Previously attempted" block for the
|
|
38
|
+
* Codex prompt. Includes both rejected and accepted entries so Codex sees
|
|
39
|
+
* the full investigation history, not just dead ends.
|
|
40
|
+
*/
|
|
41
|
+
function formatHypothesisBlock(hypotheses) {
|
|
42
|
+
if (!Array.isArray(hypotheses) || hypotheses.length === 0) {
|
|
43
|
+
return '## Previously attempted hypotheses\n\n_(none — this is the first escalation.)_\n';
|
|
44
|
+
}
|
|
45
|
+
const lines = ['## Previously attempted hypotheses', ''];
|
|
46
|
+
for (const h of hypotheses) {
|
|
47
|
+
if (!h) continue;
|
|
48
|
+
lines.push(`- **Attempt ${h.attempt ?? '?'}** (${h.verdict ?? 'unknown'}): ${h.hypothesis ?? '(no hypothesis recorded)'}`);
|
|
49
|
+
if (Array.isArray(h.evidence_against) && h.evidence_against.length > 0) {
|
|
50
|
+
for (const ev of h.evidence_against) lines.push(` - against: ${typeof ev === 'string' ? ev : JSON.stringify(ev)}`);
|
|
51
|
+
}
|
|
52
|
+
if (h.next_to_try) lines.push(` - next_to_try: ${h.next_to_try}`);
|
|
53
|
+
}
|
|
54
|
+
return lines.join('\n') + '\n';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildCodexPrompt(bugDescription, reproTest, currentDiff, hypotheses) {
|
|
58
|
+
return [
|
|
59
|
+
'# Bug-fix second opinion (read-only)',
|
|
60
|
+
'',
|
|
61
|
+
'You are Codex acting as a second opinion on a stuck bug-fix loop. Do NOT modify any files. Output a structured review.',
|
|
62
|
+
'',
|
|
63
|
+
'## Bug description',
|
|
64
|
+
'',
|
|
65
|
+
bugDescription || '(no description provided)',
|
|
66
|
+
'',
|
|
67
|
+
'## Reproducer test',
|
|
68
|
+
'',
|
|
69
|
+
'```',
|
|
70
|
+
reproTest || '(no repro provided)',
|
|
71
|
+
'```',
|
|
72
|
+
'',
|
|
73
|
+
'## Current working diff',
|
|
74
|
+
'',
|
|
75
|
+
'```diff',
|
|
76
|
+
currentDiff || '(no diff yet)',
|
|
77
|
+
'```',
|
|
78
|
+
'',
|
|
79
|
+
formatHypothesisBlock(hypotheses),
|
|
80
|
+
'',
|
|
81
|
+
'## Output format',
|
|
82
|
+
'',
|
|
83
|
+
'Respond with a JSON object: { "summary": string, "findings": [{ "lens": "general", "file": string|null, "line": number|null, "severity": "must-fix"|"should-fix"|"nit", "finding": string, "confidence": 1-10, "rationale": string }] }.',
|
|
84
|
+
'',
|
|
85
|
+
'Focus on hypotheses NOT already attempted above. If you spot a materially new angle, flag it as must-fix with high confidence.',
|
|
86
|
+
'',
|
|
87
|
+
].join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Tier 1 — dispatch Codex for a read-only second opinion on a stuck bug.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} stratum StratumMcpClient (must expose runAgentText)
|
|
94
|
+
* @param {object} context { cwd, mode:'bug', bug_code }
|
|
95
|
+
* @param {string} bugDescription
|
|
96
|
+
* @param {string} reproTest
|
|
97
|
+
* @param {string} currentDiff
|
|
98
|
+
* @param {object[]} hypotheses ledger entries (readHypotheses output)
|
|
99
|
+
* @returns {Promise<object>} canonical ReviewResult
|
|
100
|
+
*/
|
|
101
|
+
export async function tier1CodexReview(stratum, context, bugDescription, reproTest, currentDiff, hypotheses) {
|
|
102
|
+
const codexPrompt = buildCodexPrompt(bugDescription, reproTest, currentDiff, hypotheses ?? []);
|
|
103
|
+
const rawText = await stratum.runAgentText('codex', codexPrompt, { cwd: context.cwd });
|
|
104
|
+
|
|
105
|
+
const review = await normalizeReviewResult(rawText, {
|
|
106
|
+
agentType: 'codex',
|
|
107
|
+
lens: 'general',
|
|
108
|
+
confidenceGate: 7,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Append to ledger as escalation_tier_1.
|
|
112
|
+
try {
|
|
113
|
+
const prior = readHypotheses(context.cwd, context.bug_code);
|
|
114
|
+
// Use max(prior.attempt) + 1 to keep attempt numbers monotonic — using
|
|
115
|
+
// length+1 caused collisions with diagnose-success entries written
|
|
116
|
+
// concurrently across retries.
|
|
117
|
+
const maxAttempt = prior.reduce((acc, e) => Math.max(acc, Number(e.attempt) || 0), 0);
|
|
118
|
+
const attempt = maxAttempt + 1;
|
|
119
|
+
appendHypothesisEntry(context.cwd, context.bug_code, {
|
|
120
|
+
attempt,
|
|
121
|
+
ts: new Date().toISOString(),
|
|
122
|
+
hypothesis: review.summary || (review.findings[0]?.finding ?? '(codex returned no summary)'),
|
|
123
|
+
verdict: 'escalation_tier_1',
|
|
124
|
+
agent: 'codex',
|
|
125
|
+
findings: review.findings,
|
|
126
|
+
});
|
|
127
|
+
} catch (err) {
|
|
128
|
+
// Best-effort: ledger I/O must not abort the escalation flow.
|
|
129
|
+
// eslint-disable-next-line no-console
|
|
130
|
+
console.warn(`[bug-escalation] tier1 ledger append failed: ${err?.message || err}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return review;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Tier 2 — Fresh agent in worktree (patch-only)
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
const WORKTREE_BASE = join(homedir(), '.stratum', 'worktrees', 'comp-fix-hard');
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Determine whether Codex's hypothesis is "materially new" — i.e., not already
|
|
144
|
+
* in the ledger with verdict 'rejected'. Comparison is normalized token-overlap
|
|
145
|
+
* (Jaccard); substring containment was too aggressive — short rejected entries
|
|
146
|
+
* like "race condition" suppressed any future Codex angle that mentioned them.
|
|
147
|
+
*/
|
|
148
|
+
function tokenize(s) {
|
|
149
|
+
return new Set(
|
|
150
|
+
String(s ?? '')
|
|
151
|
+
.toLowerCase()
|
|
152
|
+
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
|
153
|
+
.split(/\s+/)
|
|
154
|
+
.filter(t => t.length >= 3) // drop stopwords-like noise
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function jaccard(a, b) {
|
|
159
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
160
|
+
let overlap = 0;
|
|
161
|
+
for (const t of a) if (b.has(t)) overlap++;
|
|
162
|
+
return overlap / (a.size + b.size - overlap);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const SAME_HYPOTHESIS_THRESHOLD = 0.7;
|
|
166
|
+
|
|
167
|
+
function isMateriallyNew(codexReview, ledgerEntries) {
|
|
168
|
+
const codexHyp = codexReview?.summary ?? codexReview?.findings?.[0]?.finding ?? '';
|
|
169
|
+
const codexTokens = tokenize(codexHyp);
|
|
170
|
+
// Un-tokenizable summary (e.g. terse "OOM", numeric-only) → treat as novel.
|
|
171
|
+
// Returning false here would silently suppress Tier 2 for any short Codex
|
|
172
|
+
// hypothesis that didn't survive the length≥3 filter.
|
|
173
|
+
if (codexTokens.size === 0) return true;
|
|
174
|
+
for (const e of ledgerEntries ?? []) {
|
|
175
|
+
if (e?.verdict !== 'rejected') continue;
|
|
176
|
+
const priorTokens = tokenize(e.hypothesis);
|
|
177
|
+
if (priorTokens.size === 0) continue;
|
|
178
|
+
if (jaccard(codexTokens, priorTokens) >= SAME_HYPOTHESIS_THRESHOLD) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Pick the next escalation-patch-N.md filename, counting existing ones in
|
|
187
|
+
* docs/bugs/<bug-code>/.
|
|
188
|
+
*/
|
|
189
|
+
function nextPatchPath(cwd, bugCode) {
|
|
190
|
+
const bugDir = join(cwd, 'docs', 'bugs', bugCode);
|
|
191
|
+
let n = 1;
|
|
192
|
+
if (existsSync(bugDir)) {
|
|
193
|
+
const existing = readdirSync(bugDir).filter(f => /^escalation-patch-(\d+)\.md$/.test(f));
|
|
194
|
+
if (existing.length > 0) {
|
|
195
|
+
const max = existing.reduce((acc, name) => {
|
|
196
|
+
const m = name.match(/^escalation-patch-(\d+)\.md$/);
|
|
197
|
+
const num = m ? parseInt(m[1], 10) : 0;
|
|
198
|
+
return num > acc ? num : acc;
|
|
199
|
+
}, 0);
|
|
200
|
+
n = max + 1;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return join(bugDir, `escalation-patch-${n}.md`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function buildFreshAgentPrompt(bugCode, codexReview, hypotheses, patchPath, checkpointPath) {
|
|
207
|
+
return [
|
|
208
|
+
'# Fresh-agent escalation (patch-only, NO COMMITS)',
|
|
209
|
+
'',
|
|
210
|
+
`You are a fresh agent dispatched to investigate bug **${bugCode}** with no prior context from the original session.`,
|
|
211
|
+
'',
|
|
212
|
+
'## Hard rules',
|
|
213
|
+
'',
|
|
214
|
+
'1. **DO NOT commit.** Do not run `git commit` or `git add` under any circumstances.',
|
|
215
|
+
'2. **DO NOT push.** Do not modify the remote.',
|
|
216
|
+
`3. Produce a single artifact at \`${patchPath}\` describing the proposed patch (diff + reasoning) and STOP.`,
|
|
217
|
+
'4. The original session will review your artifact and decide whether to apply it.',
|
|
218
|
+
'',
|
|
219
|
+
'## Codex second-opinion review',
|
|
220
|
+
'',
|
|
221
|
+
`Summary: ${codexReview?.summary ?? '(no summary)'}`,
|
|
222
|
+
'',
|
|
223
|
+
'Findings:',
|
|
224
|
+
...((codexReview?.findings ?? []).map(f => `- [${f.severity}] ${f.file ?? '(no file)'}:${f.line ?? '?'} — ${f.finding}`)),
|
|
225
|
+
'',
|
|
226
|
+
formatHypothesisBlock(hypotheses ?? []),
|
|
227
|
+
'',
|
|
228
|
+
checkpointPath ? `## Checkpoint reference\n\nSee ${checkpointPath} for prior context.\n` : '',
|
|
229
|
+
'## Output',
|
|
230
|
+
'',
|
|
231
|
+
`Write to ${patchPath}:`,
|
|
232
|
+
'- A unified diff of your proposed change',
|
|
233
|
+
'- Reasoning: why this addresses the Codex finding without retreading rejected hypotheses',
|
|
234
|
+
'- Risk notes: blast radius and rollback steps',
|
|
235
|
+
'',
|
|
236
|
+
'Then STOP.',
|
|
237
|
+
'',
|
|
238
|
+
].join('\n');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Tier 2 — dispatch a fresh agent in an isolated git worktree to draft a
|
|
243
|
+
* patch (no commits). Cleanup is guaranteed via finally.
|
|
244
|
+
*
|
|
245
|
+
* @param {object} stratum
|
|
246
|
+
* @param {object} context { cwd, mode:'bug', bug_code }
|
|
247
|
+
* @param {object} codexReview output of tier1CodexReview
|
|
248
|
+
* @param {object[]} hypotheses ledger entries (readHypotheses output)
|
|
249
|
+
* @param {string|null} checkpointPath
|
|
250
|
+
* @returns {Promise<{skipped?: boolean, reason?: string, patch_path?: string, agent_reasoning?: string}>}
|
|
251
|
+
*/
|
|
252
|
+
export async function tier2FreshAgent(stratum, context, codexReview, hypotheses, checkpointPath) {
|
|
253
|
+
// Materially-new gate — load ledger fresh in case caller passed stale data.
|
|
254
|
+
let ledger = hypotheses;
|
|
255
|
+
if (!Array.isArray(ledger) || ledger.length === 0) {
|
|
256
|
+
try { ledger = readHypotheses(context.cwd, context.bug_code); } catch { ledger = []; }
|
|
257
|
+
}
|
|
258
|
+
if (!isMateriallyNew(codexReview, ledger)) {
|
|
259
|
+
return { skipped: true, reason: 'no new hypothesis (codex matches a previously rejected entry)' };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Compute patch artifact path BEFORE creating the worktree, so the agent
|
|
263
|
+
// prompt references the same path that we report back to the caller.
|
|
264
|
+
const patchPath = nextPatchPath(context.cwd, context.bug_code);
|
|
265
|
+
|
|
266
|
+
// Create the detached worktree.
|
|
267
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
268
|
+
const wtPath = join(WORKTREE_BASE, `${context.bug_code}-${ts}`);
|
|
269
|
+
// mkdir -p the parent (homedir() always exists; .stratum/worktrees may not)
|
|
270
|
+
try {
|
|
271
|
+
execSync(`mkdir -p "${WORKTREE_BASE}"`, { encoding: 'utf-8', timeout: 5000 });
|
|
272
|
+
} catch { /* best-effort */ }
|
|
273
|
+
|
|
274
|
+
// git worktree add can partially succeed (creates dir, fails registration).
|
|
275
|
+
// Wrap so we rm -rf the lingering dir before rethrowing.
|
|
276
|
+
try {
|
|
277
|
+
execSync(`git worktree add "${wtPath}" --detach HEAD`, {
|
|
278
|
+
cwd: context.cwd, encoding: 'utf-8', timeout: 30_000, stdio: 'pipe',
|
|
279
|
+
});
|
|
280
|
+
} catch (err) {
|
|
281
|
+
try { execSync(`rm -rf "${wtPath}"`, { encoding: 'utf-8', timeout: 10_000 }); } catch { /* best-effort */ }
|
|
282
|
+
throw err;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const prompt = buildFreshAgentPrompt(
|
|
287
|
+
context.bug_code,
|
|
288
|
+
codexReview,
|
|
289
|
+
ledger,
|
|
290
|
+
patchPath,
|
|
291
|
+
checkpointPath,
|
|
292
|
+
);
|
|
293
|
+
const agent_reasoning = await stratum.runAgentText('claude', prompt, { cwd: wtPath });
|
|
294
|
+
return { patch_path: patchPath, agent_reasoning: agent_reasoning ?? '' };
|
|
295
|
+
} finally {
|
|
296
|
+
try {
|
|
297
|
+
execSync(`git worktree remove "${wtPath}" --force`, {
|
|
298
|
+
cwd: context.cwd, encoding: 'utf-8', timeout: 30_000, stdio: 'pipe',
|
|
299
|
+
});
|
|
300
|
+
} catch {
|
|
301
|
+
// If `git worktree remove` fails (e.g. cwd no longer a git repo in tests),
|
|
302
|
+
// fall back to `rm -rf` so the worktree dir doesn't linger.
|
|
303
|
+
try { execSync(`rm -rf "${wtPath}"`, { encoding: 'utf-8', timeout: 10_000 }); } catch { /* give up */ }
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bug-index-gen.js — Generate docs/bugs/INDEX.md from per-bug checkpoint.md files.
|
|
3
|
+
*
|
|
4
|
+
* Per-bug `docs/bugs/<code>/checkpoint.md` files are the source of truth.
|
|
5
|
+
* INDEX.md is a rendered view, atomically written so concurrent invocations
|
|
6
|
+
* don't clobber each other.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors the rendering style of `lib/roadmap-gen.js`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
existsSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
statSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
unlinkSync,
|
|
19
|
+
} from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
const HEADER = '# Bugs Index\n\nGenerated by compose. Do not edit by hand.\n\n';
|
|
23
|
+
const NO_ATTEMPT = '(no attempts yet)';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract the `**Time:** <iso>` line from a checkpoint.md body.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} body
|
|
29
|
+
* @returns {string | null}
|
|
30
|
+
*/
|
|
31
|
+
function extractCheckpointTime(body) {
|
|
32
|
+
const m = body.match(/^\*\*Time:\*\*\s*(.+)$/m);
|
|
33
|
+
return m ? m[1].trim() : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Collect metadata for a single bug folder.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} bugsDir
|
|
40
|
+
* @param {string} code
|
|
41
|
+
* @returns {{ code: string, lastAttempt: string|null, openSince: string, status: 'OPEN'|'CLOSED', sortKey: number }}
|
|
42
|
+
*/
|
|
43
|
+
function readBugMeta(bugsDir, code) {
|
|
44
|
+
const dir = join(bugsDir, code);
|
|
45
|
+
const checkpointPath = join(dir, 'checkpoint.md');
|
|
46
|
+
const closedPath = join(dir, 'closed.md');
|
|
47
|
+
|
|
48
|
+
let lastAttempt = null;
|
|
49
|
+
let openSinceMs;
|
|
50
|
+
|
|
51
|
+
if (existsSync(checkpointPath)) {
|
|
52
|
+
const body = readFileSync(checkpointPath, 'utf-8');
|
|
53
|
+
lastAttempt = extractCheckpointTime(body);
|
|
54
|
+
try {
|
|
55
|
+
openSinceMs = statSync(checkpointPath).mtimeMs;
|
|
56
|
+
} catch {
|
|
57
|
+
openSinceMs = statSync(dir).mtimeMs;
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
openSinceMs = statSync(dir).mtimeMs;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const status = existsSync(closedPath) ? 'CLOSED' : 'OPEN';
|
|
64
|
+
|
|
65
|
+
// Sort key: parsed ISO time of last attempt; missing → 0 (sorts last in desc).
|
|
66
|
+
let sortKey = 0;
|
|
67
|
+
if (lastAttempt) {
|
|
68
|
+
const t = Date.parse(lastAttempt);
|
|
69
|
+
if (!Number.isNaN(t)) sortKey = t;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
code,
|
|
74
|
+
lastAttempt,
|
|
75
|
+
openSince: new Date(openSinceMs).toISOString(),
|
|
76
|
+
status,
|
|
77
|
+
sortKey,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Render the markdown table from collected bug metadata.
|
|
83
|
+
*
|
|
84
|
+
* @param {ReturnType<typeof readBugMeta>[]} entries
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
function renderTable(entries) {
|
|
88
|
+
const lines = [
|
|
89
|
+
'| Bug | Last attempt | Open since | Status |',
|
|
90
|
+
'|-----|--------------|------------|--------|',
|
|
91
|
+
];
|
|
92
|
+
for (const e of entries) {
|
|
93
|
+
const last = e.lastAttempt ?? NO_ATTEMPT;
|
|
94
|
+
const since = e.lastAttempt ? e.openSince : NO_ATTEMPT;
|
|
95
|
+
lines.push(`| ${e.code} | ${last} | ${since} | ${e.status} |`);
|
|
96
|
+
}
|
|
97
|
+
return lines.join('\n') + '\n';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Regenerate `docs/bugs/INDEX.md` from per-bug checkpoint files.
|
|
102
|
+
*
|
|
103
|
+
* No-op when `docs/bugs/` doesn't exist. Writes atomically via tmp+rename
|
|
104
|
+
* so concurrent invocations don't clobber a half-written file.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} cwd - Project root
|
|
107
|
+
* @returns {void}
|
|
108
|
+
*/
|
|
109
|
+
export function regenerateBugIndex(cwd) {
|
|
110
|
+
const bugsDir = join(cwd, 'docs', 'bugs');
|
|
111
|
+
if (!existsSync(bugsDir)) return;
|
|
112
|
+
|
|
113
|
+
const dirents = readdirSync(bugsDir, { withFileTypes: true });
|
|
114
|
+
const codes = dirents
|
|
115
|
+
.filter(d => d.isDirectory())
|
|
116
|
+
.map(d => d.name)
|
|
117
|
+
.filter(name => !name.startsWith('.'));
|
|
118
|
+
|
|
119
|
+
const entries = codes.map(code => readBugMeta(bugsDir, code));
|
|
120
|
+
|
|
121
|
+
// Sort by Last attempt desc; bugs with no attempts (sortKey 0) go last.
|
|
122
|
+
entries.sort((a, b) => b.sortKey - a.sortKey);
|
|
123
|
+
|
|
124
|
+
const content = HEADER + renderTable(entries);
|
|
125
|
+
|
|
126
|
+
const indexPath = join(bugsDir, 'INDEX.md');
|
|
127
|
+
const tmpPath = join(bugsDir, 'INDEX.md.tmp');
|
|
128
|
+
|
|
129
|
+
// Clear any stale tmp from a previously-crashed run before writing.
|
|
130
|
+
if (existsSync(tmpPath)) {
|
|
131
|
+
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
writeFileSync(tmpPath, content);
|
|
135
|
+
renameSync(tmpPath, indexPath);
|
|
136
|
+
}
|