@smartmemory/compose 0.4.1 → 0.5.1
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/agents/compose-architect.md +40 -0
- package/.claude/agents/compose-explorer.md +35 -0
- package/.claude/hooks/canon-guard.mjs +52 -0
- package/README.md +15 -1
- package/bin/compose.js +57 -17
- package/bin/git-hooks/pre-push.template +26 -1
- package/bin/receipts-gate.js +39 -0
- package/contracts/fluid-record.schema.json +5 -0
- package/dist/assets/{App-Z4MU-H_F.js → App-DC7paCZv.js} +190 -190
- package/dist/assets/{_baseUniq-ClWoCPFl.js → _baseUniq-Czad7yiy.js} +1 -1
- package/dist/assets/{arc-DY26UIVo.js → arc-EquvLk8y.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-6Ggq4DqJ.js → architectureDiagram-Q4EWVU46-Dr_qinWi.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-CH3Ked0l.js → blockDiagram-DXYQGD6D-D2z46ED_.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-Bk8dYilu.js → c4Diagram-AHTNJAMY-BHob1Yt0.js} +1 -1
- package/dist/assets/channel-B-7ZRCKC.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-BMR0XaAQ.js → chunk-4BX2VUAB-DomWBRa_.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-JytR14a9.js → chunk-4TB4RGXK-WyC_x_DH.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-B4Q97BCP.js → chunk-55IACEB6-BajRv3zx.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-R_qarkSf.js → chunk-EDXVE4YY-rMnedK_r.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-C9s7KR9m.js → chunk-FMBD7UC4-BPi03Hcb.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-BySQzVxc.js → chunk-OYMX7WX6-B7J_mKX0.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-DdpSYZsW.js → chunk-QZHKN3VN-BLXTVr8N.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-iE_tzriw.js → chunk-YZCP3GAM-BYWjo2OJ.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-Balz1OEB.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-Balz1OEB.js +1 -0
- package/dist/assets/clone-CfNV0lUO.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BdlU6ZX_.js → cose-bilkent-S5V4N54A-Coaq0xaU.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-Cp3F5KTn.js → dagre-KV5264BT-DvUvAxlj.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DiR6_2q_.js → diagram-5BDNPKRD-70bXRUXV.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-w0i-p5HX.js → diagram-G4DWMVQ6-hMA8wgzx.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-tIHhwUv3.js → diagram-MMDJMWI5-BNir7C6i.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-BAeY3B19.js → diagram-TYMM5635-BCYl1xrE.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-Ckx_Knko.js → erDiagram-SMLLAGMA-bjxP0_bt.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-DeoNka6J.js → flowDiagram-DWJPFMVM-CBn9fhEp.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-BmGnFbEg.js → ganttDiagram-T4ZO3ILL-y1O7mWzn.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Dk48IHsx.js → gitGraphDiagram-UUTBAWPF-DIxwDXHB.js} +1 -1
- package/dist/assets/{graph-BNzKGvoy.js → graph-9D1ZumWp.js} +1 -1
- package/dist/assets/{index-BEfrNBp8.js → index-Ds_IXQo3.js} +2 -2
- package/dist/assets/{infoDiagram-42DDH7IO-BRf827i0.js → infoDiagram-42DDH7IO-DsWLGhaY.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-0kCZaeCM.js → ishikawaDiagram-UXIWVN3A-CipZIE90.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-rvU7ayRt.js → journeyDiagram-VCZTEJTY-Vr5xqcQm.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-DpQwX1C5.js → kanban-definition-6JOO6SKY-EqUYneyh.js} +1 -1
- package/dist/assets/{layout-BI8cXFPI.js → layout-hfWIIs0-.js} +1 -1
- package/dist/assets/{linear-a0glcDiw.js → linear-BdDWoN0t.js} +1 -1
- package/dist/assets/{min-vPHfnXcC.js → min-Bn_xAS7n.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-D14eF-7C.js → mindmap-definition-QFDTVHPH-qsgubzCF.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-Cno-gETh.js → pieDiagram-DEJITSTG-Bv1xq_58.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-BUQM1Hfm.js → quadrantDiagram-34T5L4WZ-DwMbAegF.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-pOXlN2-q.js → requirementDiagram-MS252O5E-BJVmLNcp.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-Crynd3_b.js → sankeyDiagram-XADWPNL6-o5GZb8Y1.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-D9fZdCM8.js → sequenceDiagram-FGHM5R23-ocqJp2qk.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-CW9qVec8.js → stateDiagram-FHFEXIEX-DGaDUFxP.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dz-15i-r.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-BcHzhm_8.js → timeline-definition-GMOUNBTQ-C4YwFvAn.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-BfytJcWk.js → vennDiagram-DHZGUBPP-uOKn9j-y.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-DLj-IjyB.js → wardley-RL74JXVD-DIQSmQde.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Ds0Ue68c.js → wardleyDiagram-NUSXRM2D-CdamsEDC.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-vjWDXFL6.js → xychartDiagram-5P7HB3ND-DhLs41yk.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/agent-string.js +9 -4
- package/lib/build-cancel.js +205 -0
- package/lib/build-stream-writer.js +6 -0
- package/lib/build.js +1189 -165
- package/lib/canon-guard.js +3 -24
- package/lib/canon-registry.js +2 -71
- package/lib/codex-preflight.js +8 -0
- package/lib/colleague/context.js +123 -0
- package/lib/consumer-fanout.js +427 -17
- package/lib/decision-blocks.js +38 -0
- package/lib/dispatch-ledger.js +7 -0
- package/lib/experiment-pricing.js +5 -1
- package/lib/flow-state.js +38 -0
- package/lib/fluid/factory.js +112 -1
- package/lib/fluid/ideabox-manifest.js +203 -0
- package/lib/fluid/ideabox-migrate.js +177 -29
- package/lib/fluid/ideabox-preamble.js +155 -0
- package/lib/fluid/ideabox-readable.js +83 -0
- package/lib/fluid/ideabox-recover.js +393 -0
- package/lib/fluid/import-ideabox.js +188 -45
- package/lib/fluid/local-provider.js +6 -0
- package/lib/fluid/portfolio.js +255 -0
- package/lib/fluid/record-shape.js +7 -0
- package/lib/fluid/render-ideabox.js +153 -7
- package/lib/fluid/smartmemory-provider.js +6 -0
- package/lib/gate-prompt.js +14 -7
- package/lib/gsd.js +95 -48
- package/lib/ideabox-cli.js +68 -0
- package/lib/ideabox.js +209 -9
- package/lib/maya-identity.js +16 -2
- package/lib/model-pricing.js +4 -1
- package/lib/output-gate.js +81 -0
- package/lib/pipeline-profiles.js +200 -0
- package/lib/process-termination.js +121 -3
- package/lib/receipts-gate.js +268 -0
- package/lib/result-normalizer.js +41 -1
- package/lib/smartmemory-client.js +68 -1
- package/lib/stratum-mcp-client.js +104 -5
- package/lib/team-flag.js +1 -1
- package/lib/tool-inventory.js +0 -1
- package/lib/version-check.js +9 -3
- package/lib/wave-checkpoint.js +100 -0
- package/package.json +7 -5
- package/presets/team-fable-astra.profiles.json +18 -0
- package/presets/team-fable-astra.stratum.yaml +236 -0
- package/server/build-stream-bridge.js +43 -1
- package/server/cc-session-watcher.js +54 -5
- package/server/compose-mcp-tools.js +48 -50
- package/server/compose-mcp.js +0 -2
- package/server/design-routes.js +1 -1
- package/server/file-watcher.js +14 -0
- package/server/ideabox-routes.js +10 -0
- package/server/index.js +5 -1
- package/server/lifecycle-guard.js +13 -0
- package/server/maya-routes.js +111 -7
- package/server/mcp-tool-defs.js +0 -25
- package/server/mcp-tool-policy.js +6 -13
- package/server/model-tiers.js +14 -6
- package/server/stratum-client.js +61 -15
- package/server/supervisor.js +18 -4
- package/server/vision-routes.js +9 -3
- package/dist/assets/channel-SnZzzh7k.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
- package/dist/assets/clone-DgklGjHm.js +0 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
- package/lib/append-integrity.js +0 -81
- package/lib/canon-override.js +0 -196
package/lib/tool-inventory.js
CHANGED
package/lib/version-check.js
CHANGED
|
@@ -75,10 +75,16 @@ async function fetchLatest(pkg) {
|
|
|
75
75
|
export function compareVersions(a, b) {
|
|
76
76
|
if (typeof a !== 'string' || typeof b !== 'string') return null
|
|
77
77
|
const parse = (s) => {
|
|
78
|
-
const
|
|
79
|
-
const
|
|
78
|
+
const buildIndex = s.indexOf('+')
|
|
79
|
+
const withoutBuild = buildIndex === -1 ? s : s.slice(0, buildIndex)
|
|
80
|
+
const prereleaseIndex = withoutBuild.indexOf('-')
|
|
81
|
+
const core = prereleaseIndex === -1 ? withoutBuild : withoutBuild.slice(0, prereleaseIndex)
|
|
82
|
+
const pre = prereleaseIndex === -1 ? null : withoutBuild.slice(prereleaseIndex + 1)
|
|
83
|
+
// parseInt is lenient: '3garbage' -> 3. A strict all-digits test is what makes
|
|
84
|
+
// the NaN guard below actually fire for trailing junk (COMP-SEMVER-STRICT).
|
|
85
|
+
const parts = core.split('.').map(n => (/^\d+$/.test(n) ? Number(n) : NaN))
|
|
80
86
|
if (parts.length !== 3 || parts.some(n => Number.isNaN(n))) return null
|
|
81
|
-
return { parts, pre
|
|
87
|
+
return { parts, pre }
|
|
82
88
|
}
|
|
83
89
|
const pa = parse(a)
|
|
84
90
|
const pb = parse(b)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Git object/ref plumbing. Never checks out a branch or changes the real index. */
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { withTemporaryIndex, snapshotWorkingTree } from './consumer-fanout.js';
|
|
4
|
+
|
|
5
|
+
export class WaveCheckpointError extends Error {
|
|
6
|
+
constructor(code, message) { super(message); this.name = 'WaveCheckpointError'; this.code = code; }
|
|
7
|
+
}
|
|
8
|
+
const fail = (code, message) => { throw new WaveCheckpointError(code, message); };
|
|
9
|
+
function git(cwd, args, opts = {}) {
|
|
10
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: 'pipe', maxBuffer: 512 * 1024 * 1024, ...opts }).trim();
|
|
11
|
+
}
|
|
12
|
+
function symbolicRef(cwd, ref) {
|
|
13
|
+
try { return git(cwd, ['symbolic-ref', '-q', ref]); }
|
|
14
|
+
catch (error) { if (error.status === 1) return null; throw error; }
|
|
15
|
+
}
|
|
16
|
+
function validateRef(cwd, ref) {
|
|
17
|
+
if (typeof ref !== 'string' || !ref.startsWith('refs/heads/compose/wave/')) fail('WAVE_CHECKPOINT_DIVERGED', 'Expected a Compose wave ref');
|
|
18
|
+
try { git(cwd, ['check-ref-format', ref]); }
|
|
19
|
+
catch { fail('WAVE_CHECKPOINT_DIVERGED', 'Invalid wave ref'); }
|
|
20
|
+
if (symbolicRef(cwd, ref)) fail('WAVE_CHECKPOINT_DIVERGED', 'Symbolic wave refs are not allowed');
|
|
21
|
+
// Include other linked worktrees: updating their checked-out branch moves HEAD too.
|
|
22
|
+
const worktrees = git(cwd, ['worktree', 'list', '--porcelain']);
|
|
23
|
+
if (worktrees.split('\n').includes(`branch ${ref}`)) fail('WAVE_CHECKPOINT_DIVERGED', 'Wave ref is checked out in a worktree');
|
|
24
|
+
}
|
|
25
|
+
export function readCheckpointRef({ cwd, ref }) {
|
|
26
|
+
validateRef(cwd, ref);
|
|
27
|
+
try { return git(cwd, ['show-ref', '--verify', '--hash', ref]); }
|
|
28
|
+
catch (error) { if (error.status === 1 || error.status === 128 && /not a valid ref/.test(error.stderr?.toString())) return null; throw error; }
|
|
29
|
+
}
|
|
30
|
+
export function prepareCheckpoint({ cwd, ref, parentCommit, tree, workingTree, message, commitMetadata }) {
|
|
31
|
+
validateRef(cwd, ref);
|
|
32
|
+
if (typeof parentCommit !== 'string' || !/^[a-f0-9]{40,64}$/.test(parentCommit)) fail('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Parent must be a commit OID');
|
|
33
|
+
const parent = git(cwd, ['rev-parse', '--verify', `${parentCommit}^{commit}`]);
|
|
34
|
+
const capturedTree = tree ?? (workingTree === true ? snapshotWorkingTree(cwd) : workingTree);
|
|
35
|
+
if (!capturedTree) fail('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Checkpoint needs a tree or workingTree:true');
|
|
36
|
+
const treeId = git(cwd, ['rev-parse', '--verify', `${capturedTree}^{tree}`]);
|
|
37
|
+
const metadata = structuredClone(commitMetadata ?? {
|
|
38
|
+
authorName: git(cwd, ['config', 'user.name']), authorEmail: git(cwd, ['config', 'user.email']),
|
|
39
|
+
date: new Date().toISOString(),
|
|
40
|
+
});
|
|
41
|
+
if (!metadata.authorName || !metadata.authorEmail || !Number.isFinite(Date.parse(metadata.date))) fail('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Invalid pinned commit metadata');
|
|
42
|
+
metadata.date = new Date(metadata.date).toISOString();
|
|
43
|
+
if (typeof message !== 'string' || !message) fail('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Checkpoint message is required');
|
|
44
|
+
const env = { ...process.env, GIT_AUTHOR_NAME: metadata.authorName, GIT_COMMITTER_NAME: metadata.authorName,
|
|
45
|
+
GIT_AUTHOR_EMAIL: metadata.authorEmail, GIT_COMMITTER_EMAIL: metadata.authorEmail,
|
|
46
|
+
GIT_AUTHOR_DATE: metadata.date, GIT_COMMITTER_DATE: metadata.date };
|
|
47
|
+
const commit = git(cwd, ['commit-tree', treeId, '-p', parent], { input: message, env });
|
|
48
|
+
return { ref, parentCommit: parent, tree: treeId, commit, message, commitMetadata: metadata };
|
|
49
|
+
}
|
|
50
|
+
export function publishCheckpoint({ cwd, ref, expected, commit }) {
|
|
51
|
+
validateRef(cwd, ref);
|
|
52
|
+
git(cwd, ['cat-file', '-e', `${commit}^{commit}`]);
|
|
53
|
+
const zero = '0'.repeat(git(cwd, ['rev-parse', 'HEAD']).length);
|
|
54
|
+
try { git(cwd, ['update-ref', ref, commit, expected ?? zero]); }
|
|
55
|
+
catch (error) { fail('WAVE_CHECKPOINT_DIVERGED', `Checkpoint compare-and-swap refused: ${error.message}`); }
|
|
56
|
+
return commit;
|
|
57
|
+
}
|
|
58
|
+
export function worktreeBaseFor({ journal, ref }) {
|
|
59
|
+
const wave = journal?.wave;
|
|
60
|
+
if (!wave) return null;
|
|
61
|
+
if (wave.checkpoints.some(checkpoint => checkpoint.state !== 'published')) fail('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Checkpoint publication is pending');
|
|
62
|
+
const expected = wave.checkpoints.at(-1)?.commit ?? null;
|
|
63
|
+
if (ref !== expected) fail('WAVE_CHECKPOINT_DIVERGED', 'Wave ref differs from the published journal tip');
|
|
64
|
+
return expected ?? wave.baseCommit;
|
|
65
|
+
}
|
|
66
|
+
export function squashOntoBase({ cwd, ref, base }) {
|
|
67
|
+
if (git(cwd, ['rev-parse', 'HEAD']) !== base) fail('WAVE_CHECKPOINT_DIVERGED', 'HEAD moved from the pinned base');
|
|
68
|
+
const tip = readCheckpointRef({ cwd, ref });
|
|
69
|
+
if (!tip) fail('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Wave ref is missing');
|
|
70
|
+
return withTemporaryIndex(cwd, env => {
|
|
71
|
+
git(cwd, ['read-tree', base], { env });
|
|
72
|
+
const diff = execFileSync('git', ['diff', '--binary', base, tip, '--'], { cwd, encoding: 'utf8', maxBuffer: 512 * 1024 * 1024 });
|
|
73
|
+
if (diff) git(cwd, ['apply', '--cached', '--binary', '-'], { env, input: diff });
|
|
74
|
+
const tree = git(cwd, ['write-tree'], { env });
|
|
75
|
+
if (tree !== git(cwd, ['rev-parse', `${tip}^{tree}`])) fail('WAVE_CHECKPOINT_DIVERGED', 'Squashed tree differs from checkpoint');
|
|
76
|
+
return tree;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
export function removeCheckpointRef({ cwd, ref, expected }) {
|
|
80
|
+
validateRef(cwd, ref);
|
|
81
|
+
if (!expected) fail('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Deletion requires the expected tip');
|
|
82
|
+
git(cwd, ['update-ref', '-d', ref, expected]);
|
|
83
|
+
}
|
|
84
|
+
/** Pure resume-table classification; all supplied decisions are durable ordinal evidence. */
|
|
85
|
+
export function reconcileCheckpoint({ journalEntry: entry, refValue }) {
|
|
86
|
+
if (!entry) return refValue == null ? 'ADMIT_FROM_BASE' : 'WAVE_CHECKPOINT_DIVERGED';
|
|
87
|
+
if (entry.evidenceMissing) return 'WAVE_CHECKPOINT_EVIDENCE_MISSING';
|
|
88
|
+
if (entry.diverged) return 'WAVE_CHECKPOINT_DIVERGED';
|
|
89
|
+
if (!entry.commit) {
|
|
90
|
+
if (refValue !== (entry.previousCommit ?? null)) return 'WAVE_CHECKPOINT_DIVERGED';
|
|
91
|
+
return entry.gateOutcome === 'approve' ? 'PREPARE_AND_PUBLISH' : 'RECOVER_WAITING_GATE';
|
|
92
|
+
}
|
|
93
|
+
if (refValue === entry.commit) {
|
|
94
|
+
if (entry.state === 'prepared') return 'MARK_PUBLISHED';
|
|
95
|
+
return entry.terminal ? 'PRESERVE_PUBLISHED' : 'ALREADY_PUBLISHED';
|
|
96
|
+
}
|
|
97
|
+
if (refValue === entry.parentCommit || refValue == null && entry.waveNumber === 1) return 'REPLAY_AND_PUBLISH';
|
|
98
|
+
if (entry.knownAncestorCommits?.includes(refValue)) return 'RECONCILE_IN_ORDER';
|
|
99
|
+
return 'WAVE_CHECKPOINT_DIVERGED';
|
|
100
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smartmemory/compose",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Structured AI dev pipeline: your agent writes the code, Compose makes it prove it. Gated design decisions, enforced postconditions, and independent review from goal to shipped code.",
|
|
5
5
|
"author": "SmartMemory",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
"dev:client": "vite",
|
|
21
21
|
"build": "vite build",
|
|
22
22
|
"preview": "vite preview",
|
|
23
|
-
"test": "node --import ./test/suppress-expected-drift.js --test --test-timeout=
|
|
23
|
+
"test": "node --import ./test/suppress-expected-drift.js --test --test-timeout=900000 test/*.test.js test/comp-obs-branch/*.test.js test/integration/*.test.js test/golden/*.test.js && npm run test:ui && npm run test:tracker",
|
|
24
24
|
"test:ui": "vitest run",
|
|
25
25
|
"test:tracker": "vitest run --config vitest.tracker.config.js",
|
|
26
|
-
"test:integration": "node --test --test-timeout=
|
|
27
|
-
"test:wave-6": "node --test --test-timeout=
|
|
26
|
+
"test:integration": "node --test --test-timeout=900000 test/integration/*.test.js",
|
|
27
|
+
"test:wave-6": "node --test --test-timeout=900000 test/wave-6-integration.test.js test/wave-6-contract-compliance.test.js",
|
|
28
28
|
"prepublishOnly": "npm run build"
|
|
29
29
|
},
|
|
30
30
|
"keywords": [
|
|
@@ -55,6 +55,8 @@
|
|
|
55
55
|
},
|
|
56
56
|
"files": [
|
|
57
57
|
".compose-deps.json",
|
|
58
|
+
".claude/agents/**",
|
|
59
|
+
".claude/hooks/**",
|
|
58
60
|
".claude/skills/**",
|
|
59
61
|
"bin/**",
|
|
60
62
|
"server/**",
|
|
@@ -86,7 +88,7 @@
|
|
|
86
88
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
|
87
89
|
"@radix-ui/react-tooltip": "^1.2.8",
|
|
88
90
|
"@smartmemory/sdk-js": "^1.4.60",
|
|
89
|
-
"@smartmemory/stratum": "^0.
|
|
91
|
+
"@smartmemory/stratum": "^0.5.2",
|
|
90
92
|
"@tanstack/react-virtual": "^3.13.23",
|
|
91
93
|
"ajv": "^8.18.0",
|
|
92
94
|
"ajv-formats": "^3.0.1",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"plan": "claude:orchestrator:coordinator",
|
|
3
|
+
"execute": { "default": "codex:implementer:critical", "tier_from": "item.tier" },
|
|
4
|
+
"verify": "claude:orchestrator:standard",
|
|
5
|
+
"review": "codex:read-only-reviewer:critical",
|
|
6
|
+
"assess": "claude:orchestrator:coordinator",
|
|
7
|
+
"assess_gate": {
|
|
8
|
+
"decide_from": {
|
|
9
|
+
"step": "assess", "field": "action",
|
|
10
|
+
"approve": ["complete"], "revise": ["repair", "implement"], "kill": ["blocked"]
|
|
11
|
+
},
|
|
12
|
+
"validators": [{ "name": "WaveDecision", "review_step": "review", "tasks_field": "tasks" }]
|
|
13
|
+
},
|
|
14
|
+
"_consumer": {
|
|
15
|
+
"execute": { "ownership": "item.files_owned", "independent": true, "checkpoint_gate": "execute_merge" }
|
|
16
|
+
},
|
|
17
|
+
"_costCeiling": { "input": "cost_ceiling_usd", "default": 150, "gates": ["assess_gate"] }
|
|
18
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# TEAM PRESET: fable-astra
|
|
2
|
+
#
|
|
3
|
+
# Purpose: Fable plans and assesses bounded waves; Astra implements and reviews.
|
|
4
|
+
# Pattern: Plan → parallel execute → merge → verify → fresh review → assess
|
|
5
|
+
# → repair/implement another wave, ship on complete, or kill on blocked.
|
|
6
|
+
# Capability: Per-task Codex tiers; fresh read-only Astra reviewer; Fable decisions.
|
|
7
|
+
# Isolation: worktree, literal file ownership; sequential merge and checkpoints.
|
|
8
|
+
# Use with: compose build <feature-code> --team fable-astra
|
|
9
|
+
# Customize: Copy BOTH team-fable-astra.stratum.yaml AND its .profiles.json
|
|
10
|
+
# sidecar into your project's pipelines/ and edit them together.
|
|
11
|
+
# Ruling: Concurrency is literal 3, customized by copying this preset, rather
|
|
12
|
+
# than an input as designed (TS schema accepts numeric literals only).
|
|
13
|
+
# cost_ceiling_usd remains an optional flow input, default 150 in the
|
|
14
|
+
# sidecar; compose build --cost-ceiling-usd overrides that ceiling.
|
|
15
|
+
# Carry rules: References use ${} only, never when/set/ensure/iterate.until.
|
|
16
|
+
# Every wave consumer must descend from plan, be reset by the
|
|
17
|
+
# assess_gate revise closure, and precede that gate through after.
|
|
18
|
+
# execute_merge retries preserve wave; assess_gate replaces it.
|
|
19
|
+
|
|
20
|
+
version: 1
|
|
21
|
+
|
|
22
|
+
contracts:
|
|
23
|
+
Task:
|
|
24
|
+
id: string
|
|
25
|
+
description: string
|
|
26
|
+
files_owned: string[]
|
|
27
|
+
files_read: string[]
|
|
28
|
+
depends_on: string[]
|
|
29
|
+
tier: critical|standard|fast
|
|
30
|
+
tier_rationale: string
|
|
31
|
+
TaskGraph:
|
|
32
|
+
tasks: Task[]
|
|
33
|
+
Verification:
|
|
34
|
+
commands: string[]
|
|
35
|
+
outcomes: string[]
|
|
36
|
+
TaskResult:
|
|
37
|
+
outcome: string
|
|
38
|
+
summary: string
|
|
39
|
+
files_changed: string[]
|
|
40
|
+
verification: Verification
|
|
41
|
+
VerifyResult:
|
|
42
|
+
tests_pass: boolean
|
|
43
|
+
summary: string
|
|
44
|
+
verification: Verification
|
|
45
|
+
merged_diff: string
|
|
46
|
+
Finding:
|
|
47
|
+
severity: string
|
|
48
|
+
files: string[]
|
|
49
|
+
claim: string
|
|
50
|
+
evidence: string
|
|
51
|
+
ReviewFindings:
|
|
52
|
+
findings: Finding[]
|
|
53
|
+
blocking: boolean
|
|
54
|
+
WaveDecision:
|
|
55
|
+
action: repair|implement|complete|blocked
|
|
56
|
+
tasks: Task[]
|
|
57
|
+
rationale: string
|
|
58
|
+
addressed_findings: Finding[]
|
|
59
|
+
open_findings: Finding[]
|
|
60
|
+
blocking: boolean
|
|
61
|
+
open_count: integer
|
|
62
|
+
ShipResult:
|
|
63
|
+
phase: string
|
|
64
|
+
artifact: string
|
|
65
|
+
outcome: string
|
|
66
|
+
summary: string
|
|
67
|
+
files_changed: string[]?
|
|
68
|
+
commit_hash: string?
|
|
69
|
+
|
|
70
|
+
flows:
|
|
71
|
+
entry: team_fable_astra
|
|
72
|
+
team_fable_astra:
|
|
73
|
+
input:
|
|
74
|
+
featureCode: string
|
|
75
|
+
description: string
|
|
76
|
+
# Accept the existing feature-build envelope; providers stay pinned below.
|
|
77
|
+
implementer_agent: string?
|
|
78
|
+
reviewer_agent: string?
|
|
79
|
+
pre_merge_gate: string[]?
|
|
80
|
+
cost_ceiling_usd: number?
|
|
81
|
+
output:
|
|
82
|
+
from: "${ship.output}"
|
|
83
|
+
contract: ShipResult
|
|
84
|
+
max_rounds: 4
|
|
85
|
+
carry:
|
|
86
|
+
wave:
|
|
87
|
+
initial: "${plan.output.tasks}"
|
|
88
|
+
on_revise:
|
|
89
|
+
assess_gate: "${assess.output.tasks}"
|
|
90
|
+
steps:
|
|
91
|
+
- id: plan
|
|
92
|
+
agent: claude
|
|
93
|
+
do: |
|
|
94
|
+
Plan wave 1 for ${input.featureCode}. Goal and acceptance criteria:
|
|
95
|
+
${input.description}
|
|
96
|
+
Read the feature's design, brief, plan and acceptance criteria before planning.
|
|
97
|
+
MUST checklist:
|
|
98
|
+
- Return TaskGraph with 1..6 tasks, each with id, description, files_owned,
|
|
99
|
+
files_read, depends_on, tier and a one-line tier_rationale.
|
|
100
|
+
- Wave 1 must contain only mutually independent tasks: depends_on is empty
|
|
101
|
+
for every task. Waves carry dependencies; dependent work goes in later
|
|
102
|
+
waves after its prerequisite checkpoint, not into this wave.
|
|
103
|
+
- No two tasks may share files_owned. Redo the graph if ownership overlaps.
|
|
104
|
+
- files_owned must be literal repo-relative paths, never globs or directories.
|
|
105
|
+
- Use critical for tasks with design judgment or root-causing, standard
|
|
106
|
+
for brief-bounded implementation, fast for transcription-level edits; repair
|
|
107
|
+
waves default to the tier of the task being repaired or higher, never lower.
|
|
108
|
+
- Each task must fit one focused session; target 2-4 tasks for a typical feature.
|
|
109
|
+
Return the task graph only; do not implement or commit.
|
|
110
|
+
out: TaskGraph
|
|
111
|
+
ensure:
|
|
112
|
+
- expr: "len(result.tasks) >= 1"
|
|
113
|
+
- expr: "len(result.tasks) <= 6"
|
|
114
|
+
attempts: 3
|
|
115
|
+
|
|
116
|
+
- id: execute
|
|
117
|
+
after: [plan]
|
|
118
|
+
fanout:
|
|
119
|
+
over: "${wave}"
|
|
120
|
+
dispatch: consumer
|
|
121
|
+
concurrency: 3
|
|
122
|
+
isolation: worktree
|
|
123
|
+
require: all
|
|
124
|
+
merge: sequential
|
|
125
|
+
steps:
|
|
126
|
+
- agent: codex
|
|
127
|
+
do: |
|
|
128
|
+
Implement the task described by ${item} using TDD. Write the test
|
|
129
|
+
first, watch it fail, implement, and watch it pass. Honor the
|
|
130
|
+
item's id, description, files_owned (you may create/modify these)
|
|
131
|
+
and files_read (you may read but NOT modify these).
|
|
132
|
+
MUST touch only files_owned — anything else fails the task at merge.
|
|
133
|
+
MUST return {outcome, summary, files_changed, verification}, with
|
|
134
|
+
verification {commands, outcomes}: exact commands and corresponding
|
|
135
|
+
observed outcomes, including failures. Do not commit.
|
|
136
|
+
out: TaskResult
|
|
137
|
+
|
|
138
|
+
- id: execute_merge
|
|
139
|
+
after: [execute]
|
|
140
|
+
gate:
|
|
141
|
+
on_approve: verify
|
|
142
|
+
on_revise: execute
|
|
143
|
+
on_kill: null
|
|
144
|
+
max_rounds: 2
|
|
145
|
+
|
|
146
|
+
- id: verify
|
|
147
|
+
after: [execute_merge]
|
|
148
|
+
agent: claude
|
|
149
|
+
do: |
|
|
150
|
+
Verify the integrated tree for ${input.featureCode} against the goal and
|
|
151
|
+
acceptance criteria: ${input.description}
|
|
152
|
+
MUST run the project's relevant tests and integration checks on the merged
|
|
153
|
+
tree, check for merge conflicts, and record exact commands and outcomes.
|
|
154
|
+
MUST capture the full cumulative merged diff: git diff HEAD -- for tracked
|
|
155
|
+
files, plus git ls-files --others --exclude-standard and added-file diffs
|
|
156
|
+
using git diff --no-index -- /dev/null <path> for each new feature file
|
|
157
|
+
(exit 1 means differences). HEAD remains the build base until ship; new
|
|
158
|
+
merged files can still be untracked. Return VerifyResult with tests_pass,
|
|
159
|
+
summary, verification {commands, outcomes}, and merged_diff. Describe only
|
|
160
|
+
observed verification evidence; never copy worker summaries. Do not edit.
|
|
161
|
+
Report failures truthfully so review and assess can request a repair wave.
|
|
162
|
+
out: VerifyResult
|
|
163
|
+
ensure:
|
|
164
|
+
- expr: "len(result.summary) > 0"
|
|
165
|
+
|
|
166
|
+
- id: review
|
|
167
|
+
after: [verify]
|
|
168
|
+
agent: codex
|
|
169
|
+
do: |
|
|
170
|
+
Fresh independent read-only review of ${input.featureCode}.
|
|
171
|
+
Goal and acceptance criteria: ${input.description}
|
|
172
|
+
VerifyResult (including the cumulative merged diff): ${verify.output}
|
|
173
|
+
MUST checklist:
|
|
174
|
+
- Read the feature's design, brief and plan for its acceptance criteria.
|
|
175
|
+
Read the merged diff against the goal, those criteria and VerifyResult;
|
|
176
|
+
inspect the integrated source and affected callers.
|
|
177
|
+
- Probe cross-module wiring: a green unit suite is not evidence that the
|
|
178
|
+
user-facing path reaches the implementation. Cite the actual call path.
|
|
179
|
+
- Never read worker summaries, TaskResults, worker journals or transcripts.
|
|
180
|
+
Form your judgment from the diff, source, criteria and verification only.
|
|
181
|
+
- Return one finding per defect, with severity, files (literal repo-relative
|
|
182
|
+
paths), claim and concrete evidence. Include failed verification and unmet
|
|
183
|
+
criteria as findings; set blocking when any defect prevents completion.
|
|
184
|
+
- Return ReviewFindings {findings, blocking}. Do not edit or commit.
|
|
185
|
+
out: ReviewFindings
|
|
186
|
+
|
|
187
|
+
- id: assess
|
|
188
|
+
after: [review]
|
|
189
|
+
agent: claude
|
|
190
|
+
do: |
|
|
191
|
+
Assess ${input.featureCode} against its goal and acceptance criteria:
|
|
192
|
+
${input.description}
|
|
193
|
+
Current tasks: ${wave}
|
|
194
|
+
TaskResults with verification evidence: ${execute.output}
|
|
195
|
+
VerifyResult and cumulative merged diff: ${verify.output}
|
|
196
|
+
Fresh ReviewFindings: ${review.output}
|
|
197
|
+
MUST checklist:
|
|
198
|
+
- Address every finding: reproduce its full finding object in exactly one
|
|
199
|
+
of addressed_findings or open_findings; justify dispositions in rationale.
|
|
200
|
+
- Copy blocking from review. Set open_count to len(open_findings).
|
|
201
|
+
- repair tasks must each own at least one file named by an open finding.
|
|
202
|
+
Dispatch only affected work; do not repeat accepted unaffected tasks.
|
|
203
|
+
- For repair or implement return 1..6 mutually independent tasks with empty
|
|
204
|
+
depends_on, disjoint literal repo-relative files_owned, and all Task fields.
|
|
205
|
+
Later waves start from the previous checkpoint containing prerequisites.
|
|
206
|
+
- Use critical for tasks with design judgment or root-causing, standard
|
|
207
|
+
for brief-bounded implementation, fast for transcription-level edits; repair
|
|
208
|
+
waves default to the tier of the task being repaired or higher, never lower.
|
|
209
|
+
- Choose implement when additional scoped work is needed; repair for defects.
|
|
210
|
+
- complete only when all acceptance criteria are met, verification passes,
|
|
211
|
+
there are zero open findings and blocking is false; return tasks: [].
|
|
212
|
+
- blocked when repair cannot proceed; retain the open findings (open_count
|
|
213
|
+
must be positive), explain why, and return tasks: [].
|
|
214
|
+
Return WaveDecision only. Do not edit or commit.
|
|
215
|
+
out: WaveDecision
|
|
216
|
+
ensure:
|
|
217
|
+
- expr: "result.action != 'complete' || (result.open_count == 0 && result.blocking == false)"
|
|
218
|
+
- expr: "result.action != 'blocked' || result.open_count > 0"
|
|
219
|
+
- expr: "result.open_count >= 0"
|
|
220
|
+
|
|
221
|
+
- id: assess_gate
|
|
222
|
+
after: [assess]
|
|
223
|
+
gate:
|
|
224
|
+
on_approve: ship
|
|
225
|
+
on_revise: execute
|
|
226
|
+
on_kill: null
|
|
227
|
+
max_rounds: 2
|
|
228
|
+
|
|
229
|
+
# Compose intercepts ship: squash the wave checkpoints and selectively commit.
|
|
230
|
+
- id: ship
|
|
231
|
+
after: [assess_gate]
|
|
232
|
+
agent: claude
|
|
233
|
+
do: |
|
|
234
|
+
Ship ${input.featureCode}: run final tests and squash the accepted wave
|
|
235
|
+
changes into one commit whose parent is the build base. Return ShipResult.
|
|
236
|
+
out: ShipResult
|
|
@@ -45,18 +45,26 @@ export class BuildStreamBridge {
|
|
|
45
45
|
#debounceTimer = null;
|
|
46
46
|
#crashTimer = null;
|
|
47
47
|
#polling = false;
|
|
48
|
+
#safetyInterval = null;
|
|
49
|
+
#watchFn;
|
|
50
|
+
#pollIntervalMs;
|
|
48
51
|
|
|
49
52
|
/**
|
|
50
53
|
* @param {string} composeDir Path to .compose directory
|
|
51
54
|
* @param {Function} broadcast broadcast(msg) function from agent-server
|
|
52
55
|
* @param {object} [opts]
|
|
53
56
|
* @param {number} [opts.crashTimeoutMs] Crash detection timeout (default 5min)
|
|
57
|
+
* @param {number} [opts.pollIntervalMs] Safety re-read cadence (default 2s)
|
|
58
|
+
* @param {Function} [opts.watchFn] Injected for tests that need a watcher
|
|
59
|
+
* which never delivers — the condition the safety poll exists to survive.
|
|
54
60
|
*/
|
|
55
61
|
constructor(composeDir, broadcast, opts = {}) {
|
|
56
62
|
this.#composeDir = composeDir;
|
|
57
63
|
this.#filePath = join(composeDir, JSONL_FILENAME);
|
|
58
64
|
this.#broadcast = broadcast;
|
|
59
65
|
this.#crashTimeoutMs = opts.crashTimeoutMs ?? DEFAULT_CRASH_TIMEOUT_MS;
|
|
66
|
+
this.#pollIntervalMs = opts.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
67
|
+
this.#watchFn = opts.watchFn ?? watch;
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
/**
|
|
@@ -97,6 +105,10 @@ export class BuildStreamBridge {
|
|
|
97
105
|
clearInterval(this.#pollInterval);
|
|
98
106
|
this.#pollInterval = null;
|
|
99
107
|
}
|
|
108
|
+
if (this.#safetyInterval) {
|
|
109
|
+
clearInterval(this.#safetyInterval);
|
|
110
|
+
this.#safetyInterval = null;
|
|
111
|
+
}
|
|
100
112
|
if (this.#debounceTimer) {
|
|
101
113
|
clearTimeout(this.#debounceTimer);
|
|
102
114
|
this.#debounceTimer = null;
|
|
@@ -112,8 +124,11 @@ export class BuildStreamBridge {
|
|
|
112
124
|
// ---------------------------------------------------------------------------
|
|
113
125
|
|
|
114
126
|
_startWatching() {
|
|
127
|
+
// The safety poll is armed FIRST and unconditionally, because the watcher
|
|
128
|
+
// cannot be trusted to be listening (see _startSafetyPoll).
|
|
129
|
+
this._startSafetyPoll();
|
|
115
130
|
try {
|
|
116
|
-
this.#watcher =
|
|
131
|
+
this.#watcher = this.#watchFn(this.#composeDir, (eventType, filename) => {
|
|
117
132
|
if (filename === JSONL_FILENAME || filename === null) {
|
|
118
133
|
this._debouncedRead();
|
|
119
134
|
}
|
|
@@ -129,6 +144,33 @@ export class BuildStreamBridge {
|
|
|
129
144
|
}
|
|
130
145
|
}
|
|
131
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Re-read on a timer for as long as we are tailing, regardless of the watcher.
|
|
149
|
+
*
|
|
150
|
+
* `fs.watch` is an OPTIMISATION here, never the guarantee. It is not armed
|
|
151
|
+
* when the call returns: on macOS libuv registers the FSEvents stream
|
|
152
|
+
* asynchronously, so writes landing between `watch()` returning and the stream
|
|
153
|
+
* actually listening are delivered to nobody. `start()` arms the watcher and
|
|
154
|
+
* then reads synchronously, which is exactly that window — and under load the
|
|
155
|
+
* window stretches to cover a whole build's first events.
|
|
156
|
+
*
|
|
157
|
+
* Measured before this existed (600 runs of the scenario, 6 concurrent
|
|
158
|
+
* processes, full suite as load): 14/450 runs saw the watcher deliver NOTHING
|
|
159
|
+
* after start, so the bridge broadcast the pre-existing lines and then went
|
|
160
|
+
* permanently deaf — no periodic re-check existed to recover it. The same 450
|
|
161
|
+
* runs with a settle delay before the writes: 0 failures. That was a live
|
|
162
|
+
* cockpit stream that silently never starts, not a test-timing problem.
|
|
163
|
+
*
|
|
164
|
+
* A missed event is therefore recoverable rather than terminal. `_readNewLines`
|
|
165
|
+
* exits on a single `statSync` when the file has not grown, so the standing
|
|
166
|
+
* cost is one stat per interval.
|
|
167
|
+
*/
|
|
168
|
+
_startSafetyPoll() {
|
|
169
|
+
if (this.#safetyInterval) return;
|
|
170
|
+
this.#safetyInterval = setInterval(() => this._readNewLines(), this.#pollIntervalMs);
|
|
171
|
+
this.#safetyInterval.unref();
|
|
172
|
+
}
|
|
173
|
+
|
|
132
174
|
_pollForDirectory() {
|
|
133
175
|
if (this.#polling) return;
|
|
134
176
|
this.#polling = true;
|
|
@@ -68,6 +68,8 @@ export class CCSessionWatcher {
|
|
|
68
68
|
// COMP-OBS-DRIFT: optional deps for post-lineage drift broadcast
|
|
69
69
|
emitDriftAxes = null,
|
|
70
70
|
projectRoot = null,
|
|
71
|
+
// Backstop-poll cadence. Injectable so a test need not wait 2s for it.
|
|
72
|
+
pollIntervalMs = 2000,
|
|
71
73
|
}) {
|
|
72
74
|
if (!projectsRoot) throw new Error('projectsRoot required');
|
|
73
75
|
if (!sessionsFile) throw new Error('sessionsFile required');
|
|
@@ -87,6 +89,7 @@ export class CCSessionWatcher {
|
|
|
87
89
|
// COMP-OBS-DRIFT: optional drift emitter
|
|
88
90
|
this._emitDriftAxes = emitDriftAxes;
|
|
89
91
|
this._projectRoot = projectRoot;
|
|
92
|
+
this.pollIntervalMs = pollIntervalMs;
|
|
90
93
|
|
|
91
94
|
// featureCode → (cc_session_id → BranchOutcome[])
|
|
92
95
|
this._accum = new Map();
|
|
@@ -272,6 +275,29 @@ export class CCSessionWatcher {
|
|
|
272
275
|
if (scanned) await this._flush([scanned.featureCode]);
|
|
273
276
|
}
|
|
274
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Dispatch one file change, never concurrently with another.
|
|
280
|
+
*
|
|
281
|
+
* `_flush` is idempotent SEQUENTIALLY — the lineage POST is a replace-by-key
|
|
282
|
+
* (`updateLifecycleExt`), and a fork already in `emitted_event_ids` is not
|
|
283
|
+
* re-broadcast. It is NOT idempotent CONCURRENTLY: `emitted.add(eventId)` runs
|
|
284
|
+
* only after `await postBranchLineage`, so two overlapping flushes both see
|
|
285
|
+
* the same fork as new and both broadcast its DecisionEvent.
|
|
286
|
+
*
|
|
287
|
+
* With the watcher as the sole dispatcher that overlap was rare. Arming the
|
|
288
|
+
* safety poll beside it (see `start`) would have made it ordinary, so
|
|
289
|
+
* dispatch is serialised here rather than left to chance — which also closes
|
|
290
|
+
* the same pre-existing overlap between `fullScan()` and a watcher event.
|
|
291
|
+
*/
|
|
292
|
+
_dispatch(jsonlPath) {
|
|
293
|
+
this._chain = (this._chain ?? Promise.resolve())
|
|
294
|
+
.then(() => this._onFileChange(jsonlPath))
|
|
295
|
+
.catch(err => {
|
|
296
|
+
console.warn(`[cc-watcher] change handler failed: ${err.message}`);
|
|
297
|
+
});
|
|
298
|
+
return this._chain;
|
|
299
|
+
}
|
|
300
|
+
|
|
275
301
|
start() {
|
|
276
302
|
// C5: the fs.watch fallback leaves `_watcher` null, so guarding on it alone
|
|
277
303
|
// let every resume() start ANOTHER poll interval — one leaked per switch.
|
|
@@ -279,6 +305,20 @@ export class CCSessionWatcher {
|
|
|
279
305
|
if (!fs.existsSync(this.projectsRoot)) {
|
|
280
306
|
fs.mkdirSync(this.projectsRoot, { recursive: true });
|
|
281
307
|
}
|
|
308
|
+
// The poll is armed ALONGSIDE the watcher, not only when fs.watch throws.
|
|
309
|
+
//
|
|
310
|
+
// A watcher that stops delivering does not throw and does not emit 'error';
|
|
311
|
+
// it simply goes quiet, and every subsequent session write is lost with no
|
|
312
|
+
// symptom. Polling used to be reachable only from the synchronous throw
|
|
313
|
+
// below, so the one failure mode that actually needs recovery — a live
|
|
314
|
+
// watcher that has silently stopped — had none, and missed CC sessions meant
|
|
315
|
+
// branch DecisionEvents that never fire and never self-heal.
|
|
316
|
+
//
|
|
317
|
+
// (Unlike the build-stream bridge, the fs.watch ARMING window is not the
|
|
318
|
+
// hazard here: CC sessions are written minutes after start, not in the
|
|
319
|
+
// microseconds between `watch()` returning and the stream listening. The
|
|
320
|
+
// justification is dead-watcher recovery.)
|
|
321
|
+
this._startPolling();
|
|
282
322
|
try {
|
|
283
323
|
this._watcher = fs.watch(this.projectsRoot, { recursive: true }, (_evt, filename) => {
|
|
284
324
|
if (!filename || !filename.endsWith('.jsonl')) return;
|
|
@@ -287,9 +327,16 @@ export class CCSessionWatcher {
|
|
|
287
327
|
const now = Date.now();
|
|
288
328
|
if (now - last < DEFAULT_DEBOUNCE_MS) return;
|
|
289
329
|
this._debounce.set(full, now);
|
|
290
|
-
this.
|
|
291
|
-
|
|
292
|
-
|
|
330
|
+
this._dispatch(full);
|
|
331
|
+
});
|
|
332
|
+
// A watcher can die AFTER construction. Without this the failure is
|
|
333
|
+
// completely silent; the poll below is already running, so recovery is
|
|
334
|
+
// just dropping the dead handle.
|
|
335
|
+
this._watcher.on?.('error', (err) => {
|
|
336
|
+
console.warn(`[cc-watcher] watcher died, polling continues: ${err?.message}`);
|
|
337
|
+
try { this._watcher?.close(); } catch { /* already gone */ }
|
|
338
|
+
this._watcher = null;
|
|
339
|
+
this._startPolling();
|
|
293
340
|
});
|
|
294
341
|
} catch (err) {
|
|
295
342
|
console.warn(`[cc-watcher] fs.watch unavailable, falling back to polling: ${err.message}`);
|
|
@@ -297,7 +344,7 @@ export class CCSessionWatcher {
|
|
|
297
344
|
}
|
|
298
345
|
}
|
|
299
346
|
|
|
300
|
-
_startPolling(intervalMs = 2000) {
|
|
347
|
+
_startPolling(intervalMs = this.pollIntervalMs ?? 2000) {
|
|
301
348
|
if (this._pollTimer) return;
|
|
302
349
|
this._pollTimer = setInterval(async () => {
|
|
303
350
|
const files = listJsonlFiles(this.projectsRoot);
|
|
@@ -308,10 +355,12 @@ export class CCSessionWatcher {
|
|
|
308
355
|
const last = this._debounce.get(key) || 0;
|
|
309
356
|
if (stat.mtimeMs > last) {
|
|
310
357
|
this._debounce.set(key, stat.mtimeMs);
|
|
311
|
-
await this.
|
|
358
|
+
await this._dispatch(f);
|
|
312
359
|
}
|
|
313
360
|
}
|
|
314
361
|
}, intervalMs);
|
|
362
|
+
// Never hold the process open on this alone — it is a backstop, not work.
|
|
363
|
+
this._pollTimer.unref?.();
|
|
315
364
|
}
|
|
316
365
|
|
|
317
366
|
stop() {
|