@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.
Files changed (127) hide show
  1. package/.claude/agents/compose-architect.md +40 -0
  2. package/.claude/agents/compose-explorer.md +35 -0
  3. package/.claude/hooks/canon-guard.mjs +52 -0
  4. package/README.md +15 -1
  5. package/bin/compose.js +57 -17
  6. package/bin/git-hooks/pre-push.template +26 -1
  7. package/bin/receipts-gate.js +39 -0
  8. package/contracts/fluid-record.schema.json +5 -0
  9. package/dist/assets/{App-Z4MU-H_F.js → App-DC7paCZv.js} +190 -190
  10. package/dist/assets/{_baseUniq-ClWoCPFl.js → _baseUniq-Czad7yiy.js} +1 -1
  11. package/dist/assets/{arc-DY26UIVo.js → arc-EquvLk8y.js} +1 -1
  12. package/dist/assets/{architectureDiagram-Q4EWVU46-6Ggq4DqJ.js → architectureDiagram-Q4EWVU46-Dr_qinWi.js} +1 -1
  13. package/dist/assets/{blockDiagram-DXYQGD6D-CH3Ked0l.js → blockDiagram-DXYQGD6D-D2z46ED_.js} +1 -1
  14. package/dist/assets/{c4Diagram-AHTNJAMY-Bk8dYilu.js → c4Diagram-AHTNJAMY-BHob1Yt0.js} +1 -1
  15. package/dist/assets/channel-B-7ZRCKC.js +1 -0
  16. package/dist/assets/{chunk-4BX2VUAB-BMR0XaAQ.js → chunk-4BX2VUAB-DomWBRa_.js} +1 -1
  17. package/dist/assets/{chunk-4TB4RGXK-JytR14a9.js → chunk-4TB4RGXK-WyC_x_DH.js} +1 -1
  18. package/dist/assets/{chunk-55IACEB6-B4Q97BCP.js → chunk-55IACEB6-BajRv3zx.js} +1 -1
  19. package/dist/assets/{chunk-EDXVE4YY-R_qarkSf.js → chunk-EDXVE4YY-rMnedK_r.js} +1 -1
  20. package/dist/assets/{chunk-FMBD7UC4-C9s7KR9m.js → chunk-FMBD7UC4-BPi03Hcb.js} +1 -1
  21. package/dist/assets/{chunk-OYMX7WX6-BySQzVxc.js → chunk-OYMX7WX6-B7J_mKX0.js} +1 -1
  22. package/dist/assets/{chunk-QZHKN3VN-DdpSYZsW.js → chunk-QZHKN3VN-BLXTVr8N.js} +1 -1
  23. package/dist/assets/{chunk-YZCP3GAM-iE_tzriw.js → chunk-YZCP3GAM-BYWjo2OJ.js} +1 -1
  24. package/dist/assets/classDiagram-6PBFFD2Q-Balz1OEB.js +1 -0
  25. package/dist/assets/classDiagram-v2-HSJHXN6E-Balz1OEB.js +1 -0
  26. package/dist/assets/clone-CfNV0lUO.js +1 -0
  27. package/dist/assets/{cose-bilkent-S5V4N54A-BdlU6ZX_.js → cose-bilkent-S5V4N54A-Coaq0xaU.js} +1 -1
  28. package/dist/assets/{dagre-KV5264BT-Cp3F5KTn.js → dagre-KV5264BT-DvUvAxlj.js} +1 -1
  29. package/dist/assets/{diagram-5BDNPKRD-DiR6_2q_.js → diagram-5BDNPKRD-70bXRUXV.js} +1 -1
  30. package/dist/assets/{diagram-G4DWMVQ6-w0i-p5HX.js → diagram-G4DWMVQ6-hMA8wgzx.js} +1 -1
  31. package/dist/assets/{diagram-MMDJMWI5-tIHhwUv3.js → diagram-MMDJMWI5-BNir7C6i.js} +1 -1
  32. package/dist/assets/{diagram-TYMM5635-BAeY3B19.js → diagram-TYMM5635-BCYl1xrE.js} +1 -1
  33. package/dist/assets/{erDiagram-SMLLAGMA-Ckx_Knko.js → erDiagram-SMLLAGMA-bjxP0_bt.js} +1 -1
  34. package/dist/assets/{flowDiagram-DWJPFMVM-DeoNka6J.js → flowDiagram-DWJPFMVM-CBn9fhEp.js} +1 -1
  35. package/dist/assets/{ganttDiagram-T4ZO3ILL-BmGnFbEg.js → ganttDiagram-T4ZO3ILL-y1O7mWzn.js} +1 -1
  36. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Dk48IHsx.js → gitGraphDiagram-UUTBAWPF-DIxwDXHB.js} +1 -1
  37. package/dist/assets/{graph-BNzKGvoy.js → graph-9D1ZumWp.js} +1 -1
  38. package/dist/assets/{index-BEfrNBp8.js → index-Ds_IXQo3.js} +2 -2
  39. package/dist/assets/{infoDiagram-42DDH7IO-BRf827i0.js → infoDiagram-42DDH7IO-DsWLGhaY.js} +1 -1
  40. package/dist/assets/{ishikawaDiagram-UXIWVN3A-0kCZaeCM.js → ishikawaDiagram-UXIWVN3A-CipZIE90.js} +1 -1
  41. package/dist/assets/{journeyDiagram-VCZTEJTY-rvU7ayRt.js → journeyDiagram-VCZTEJTY-Vr5xqcQm.js} +1 -1
  42. package/dist/assets/{kanban-definition-6JOO6SKY-DpQwX1C5.js → kanban-definition-6JOO6SKY-EqUYneyh.js} +1 -1
  43. package/dist/assets/{layout-BI8cXFPI.js → layout-hfWIIs0-.js} +1 -1
  44. package/dist/assets/{linear-a0glcDiw.js → linear-BdDWoN0t.js} +1 -1
  45. package/dist/assets/{min-vPHfnXcC.js → min-Bn_xAS7n.js} +1 -1
  46. package/dist/assets/{mindmap-definition-QFDTVHPH-D14eF-7C.js → mindmap-definition-QFDTVHPH-qsgubzCF.js} +1 -1
  47. package/dist/assets/{pieDiagram-DEJITSTG-Cno-gETh.js → pieDiagram-DEJITSTG-Bv1xq_58.js} +1 -1
  48. package/dist/assets/{quadrantDiagram-34T5L4WZ-BUQM1Hfm.js → quadrantDiagram-34T5L4WZ-DwMbAegF.js} +1 -1
  49. package/dist/assets/{requirementDiagram-MS252O5E-pOXlN2-q.js → requirementDiagram-MS252O5E-BJVmLNcp.js} +1 -1
  50. package/dist/assets/{sankeyDiagram-XADWPNL6-Crynd3_b.js → sankeyDiagram-XADWPNL6-o5GZb8Y1.js} +1 -1
  51. package/dist/assets/{sequenceDiagram-FGHM5R23-D9fZdCM8.js → sequenceDiagram-FGHM5R23-ocqJp2qk.js} +1 -1
  52. package/dist/assets/{stateDiagram-FHFEXIEX-CW9qVec8.js → stateDiagram-FHFEXIEX-DGaDUFxP.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dz-15i-r.js +1 -0
  54. package/dist/assets/{timeline-definition-GMOUNBTQ-BcHzhm_8.js → timeline-definition-GMOUNBTQ-C4YwFvAn.js} +1 -1
  55. package/dist/assets/{vennDiagram-DHZGUBPP-BfytJcWk.js → vennDiagram-DHZGUBPP-uOKn9j-y.js} +1 -1
  56. package/dist/assets/{wardley-RL74JXVD-DLj-IjyB.js → wardley-RL74JXVD-DIQSmQde.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-NUSXRM2D-Ds0Ue68c.js → wardleyDiagram-NUSXRM2D-CdamsEDC.js} +1 -1
  58. package/dist/assets/{xychartDiagram-5P7HB3ND-vjWDXFL6.js → xychartDiagram-5P7HB3ND-DhLs41yk.js} +1 -1
  59. package/dist/index.html +1 -1
  60. package/lib/agent-string.js +9 -4
  61. package/lib/build-cancel.js +205 -0
  62. package/lib/build-stream-writer.js +6 -0
  63. package/lib/build.js +1189 -165
  64. package/lib/canon-guard.js +3 -24
  65. package/lib/canon-registry.js +2 -71
  66. package/lib/codex-preflight.js +8 -0
  67. package/lib/colleague/context.js +123 -0
  68. package/lib/consumer-fanout.js +427 -17
  69. package/lib/decision-blocks.js +38 -0
  70. package/lib/dispatch-ledger.js +7 -0
  71. package/lib/experiment-pricing.js +5 -1
  72. package/lib/flow-state.js +38 -0
  73. package/lib/fluid/factory.js +112 -1
  74. package/lib/fluid/ideabox-manifest.js +203 -0
  75. package/lib/fluid/ideabox-migrate.js +177 -29
  76. package/lib/fluid/ideabox-preamble.js +155 -0
  77. package/lib/fluid/ideabox-readable.js +83 -0
  78. package/lib/fluid/ideabox-recover.js +393 -0
  79. package/lib/fluid/import-ideabox.js +188 -45
  80. package/lib/fluid/local-provider.js +6 -0
  81. package/lib/fluid/portfolio.js +255 -0
  82. package/lib/fluid/record-shape.js +7 -0
  83. package/lib/fluid/render-ideabox.js +153 -7
  84. package/lib/fluid/smartmemory-provider.js +6 -0
  85. package/lib/gate-prompt.js +14 -7
  86. package/lib/gsd.js +95 -48
  87. package/lib/ideabox-cli.js +68 -0
  88. package/lib/ideabox.js +209 -9
  89. package/lib/maya-identity.js +16 -2
  90. package/lib/model-pricing.js +4 -1
  91. package/lib/output-gate.js +81 -0
  92. package/lib/pipeline-profiles.js +200 -0
  93. package/lib/process-termination.js +121 -3
  94. package/lib/receipts-gate.js +268 -0
  95. package/lib/result-normalizer.js +41 -1
  96. package/lib/smartmemory-client.js +68 -1
  97. package/lib/stratum-mcp-client.js +104 -5
  98. package/lib/team-flag.js +1 -1
  99. package/lib/tool-inventory.js +0 -1
  100. package/lib/version-check.js +9 -3
  101. package/lib/wave-checkpoint.js +100 -0
  102. package/package.json +7 -5
  103. package/presets/team-fable-astra.profiles.json +18 -0
  104. package/presets/team-fable-astra.stratum.yaml +236 -0
  105. package/server/build-stream-bridge.js +43 -1
  106. package/server/cc-session-watcher.js +54 -5
  107. package/server/compose-mcp-tools.js +48 -50
  108. package/server/compose-mcp.js +0 -2
  109. package/server/design-routes.js +1 -1
  110. package/server/file-watcher.js +14 -0
  111. package/server/ideabox-routes.js +10 -0
  112. package/server/index.js +5 -1
  113. package/server/lifecycle-guard.js +13 -0
  114. package/server/maya-routes.js +111 -7
  115. package/server/mcp-tool-defs.js +0 -25
  116. package/server/mcp-tool-policy.js +6 -13
  117. package/server/model-tiers.js +14 -6
  118. package/server/stratum-client.js +61 -15
  119. package/server/supervisor.js +18 -4
  120. package/server/vision-routes.js +9 -3
  121. package/dist/assets/channel-SnZzzh7k.js +0 -1
  122. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
  123. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
  124. package/dist/assets/clone-DgklGjHm.js +0 -1
  125. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
  126. package/lib/append-integrity.js +0 -81
  127. package/lib/canon-override.js +0 -196
@@ -35,7 +35,6 @@ export const EFFECTS = /** @type {const} */ (['read', 'mutating', 'setup']);
35
35
  */
36
36
  export const CANON_IDS = new Set([
37
37
  'roadmap', 'changelog', 'feature-json', 'judgment',
38
- 'override-ledger', 'override-attest', 'override-grants',
39
38
  ]);
40
39
 
41
40
  /**
@@ -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 [core, pre] = s.split('-')
79
- const parts = core.split('.').map(n => Number.parseInt(n, 10))
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: pre ?? null }
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.4.1",
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=300000 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",
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=300000 test/integration/*.test.js",
27
- "test:wave-6": "node --test --test-timeout=300000 test/wave-6-integration.test.js test/wave-6-contract-compliance.test.js",
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.4.5",
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 = watch(this.#composeDir, (eventType, filename) => {
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._onFileChange(full).catch(err => {
291
- console.warn(`[cc-watcher] change handler failed: ${err.message}`);
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._onFileChange(f);
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() {