@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
@@ -136,12 +136,26 @@ export async function provisionIdentity({ smBaseUrl, fetchFn = fetch }) {
136
136
  };
137
137
  }
138
138
 
139
+ /** The oldest NDA version this client knows; the server names a newer one. */
140
+ const NDA_VERSION_FLOOR = 'v1';
141
+
139
142
  /** Accept the beta NDA for a provisioned identity (403 `nda_required` gates
140
143
  * every memory route until this runs — FOH-4 ledger prereq 3). */
141
144
  export async function acceptNda({ smBaseUrl, token, fetchFn = fetch }) {
142
- const res = await requestJson(`${smBaseUrl}/memory/beta/nda/accept`, {
143
- body: { version: 'v1' }, token, fetchFn,
145
+ const attempt = (version) => requestJson(`${smBaseUrl}/memory/beta/nda/accept`, {
146
+ body: { version }, token, fetchFn,
144
147
  });
148
+ let res = await attempt(NDA_VERSION_FLOOR);
149
+ // The NDA version is upstream's to bump, not ours to track: a 409
150
+ // `version_mismatch` names the version currently in force, and accepting the
151
+ // one the server names is the only answer that survives the next bump.
152
+ // FOH-7 live-fire (2026-09-06) found v1 hardcoded after upstream moved to v2 —
153
+ // every provisioned colleague identity failed its first turn.
154
+ const named = res.status === 409 && res.body?.detail?.code === 'version_mismatch'
155
+ ? res.body.detail.current_version : null;
156
+ if (typeof named === 'string' && named && named !== NDA_VERSION_FLOOR) {
157
+ res = await attempt(named);
158
+ }
145
159
  if (res.status < 200 || res.status >= 300) {
146
160
  throw new MayaIdentityError(`maya: NDA accept failed (HTTP ${res.status})`);
147
161
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * model-pricing.js — Token cost lookup and USD calculation.
3
3
  *
4
- * Prices are per-million tokens (MTok) as of 2025.
4
+ * Prices are per-million tokens (MTok) updated for COMP-FABLE-ASTRA slice 1 (2026-09).
5
5
  * Input price includes standard prompt tokens.
6
6
  * Cache write tokens (cache_creation_input_tokens) are billed at 1.25x input rate.
7
7
  * Cache read tokens (cache_read_input_tokens) are billed at 0.1x input rate.
@@ -12,6 +12,9 @@
12
12
  * Keys are matched by prefix so 'claude-sonnet-4-6' matches 'claude-sonnet-4-6-20250514' etc.
13
13
  */
14
14
  export const MODEL_PRICING = {
15
+ 'claude-fable-5-1': { inputPerMTok: 10, outputPerMTok: 50 },
16
+ 'claude-opus-5': { inputPerMTok: 5, outputPerMTok: 25 },
17
+ 'claude-sonnet-5': { inputPerMTok: 2, outputPerMTok: 10 },
15
18
  'claude-opus-4-7': { inputPerMTok: 5, outputPerMTok: 25 },
16
19
  'claude-opus-4-6': { inputPerMTok: 5, outputPerMTok: 25 },
17
20
  'claude-sonnet-4-6': { inputPerMTok: 3, outputPerMTok: 15 },
@@ -0,0 +1,81 @@
1
+ /** Pure output-gate decision. Callers supply current recorded step states. */
2
+ import { ownField, normalizeOwnedPath, validateGateConfig, validateWaveAdmission, profilesDigest } from './pipeline-profiles.js';
3
+
4
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
5
+ const strings = value => Array.isArray(value) && value.every(v => typeof v === 'string');
6
+ function validateDecision(decision, review, validator, { executeProfile, executeProvider }) {
7
+ const findings = [];
8
+ const add = (code, message) => findings.push({ code, message });
9
+ const tasks = ownField(decision, validator.tasks_field ?? 'tasks');
10
+ const findingShape = f => object(f) && typeof f.severity === 'string' && strings(f.files)
11
+ && typeof f.claim === 'string' && typeof f.evidence === 'string';
12
+ const taskShape = t => object(t) && typeof t.id === 'string' && t.id.length > 0
13
+ && typeof t.description === 'string' && strings(t.files_owned) && strings(t.files_read)
14
+ && strings(t.depends_on) && (t.tier_rationale === undefined || typeof t.tier_rationale === 'string');
15
+ if (!object(decision) || !['repair', 'implement', 'complete', 'blocked'].includes(decision.action)
16
+ || typeof decision.rationale !== 'string' || typeof decision.blocking !== 'boolean'
17
+ || !Number.isInteger(decision.open_count) || decision.open_count < 0
18
+ || !Array.isArray(decision.open_findings) || !decision.open_findings.every(findingShape)
19
+ || !Array.isArray(decision.addressed_findings) || !decision.addressed_findings.every(findingShape)
20
+ || !Array.isArray(tasks) || !tasks.every(taskShape)) {
21
+ add('WAVE_DECISION_SHAPE', 'Invalid WaveDecision/task/finding shape');
22
+ return findings;
23
+ }
24
+ if (decision.open_count !== decision.open_findings.length) add('WAVE_OPEN_COUNT_MISMATCH', 'open_count differs from open_findings length');
25
+ if (typeof review?.blocking !== 'boolean' || review.blocking !== decision.blocking) add('WAVE_BLOCKING_MISMATCH', 'blocking differs from recorded review');
26
+ if (decision.action === 'complete' && (decision.open_count !== 0 || decision.blocking)) add('WAVE_COMPLETE_WITH_OPEN_FINDINGS', 'Complete requires no open findings and no blocking');
27
+ if (decision.action === 'blocked' && decision.open_count === 0) add('WAVE_BLOCKED_WITHOUT_FINDINGS', 'Blocked requires open findings');
28
+ if (['repair', 'implement'].includes(decision.action)) {
29
+ if (tasks.length < 1 || tasks.length > 6) add('WAVE_REPAIR_EMPTY', 'Implementation/repair requires 1–6 tasks');
30
+ findings.push(...validateWaveAdmission(executeProfile, tasks,
31
+ { provider: executeProvider, ownership: true, independent: true }).findings);
32
+ }
33
+ if (decision.action === 'repair') {
34
+ try {
35
+ const files = new Set(decision.open_findings.flatMap(f => f.files.map(normalizeOwnedPath)));
36
+ for (const task of tasks) if (!task.files_owned.some(file => files.has(normalizeOwnedPath(file)))) {
37
+ add('WAVE_REPAIR_UNOWNED_FINDING', `Repair task ${task.id} owns no open finding file`);
38
+ }
39
+ } catch (error) { add('WAVE_DECISION_SHAPE', error.message); }
40
+ }
41
+ return findings;
42
+ }
43
+ /**
44
+ * Resolve only from current recorded source, waiting gate and configured review states.
45
+ * executeProfile (the configured execute entry) and executeProvider are required.
46
+ * reviewOutput is ignored; validators always use the configured review step's recorded output.
47
+ */
48
+ export function decideGateFromOutput(config, stepOutputs, { gateStepId, gateToken, reviewOutput, ceiling, executeProfile, executeProvider } = {}) {
49
+ const hold = (reason, extra = {}) => ({ outcome: null, reason, ...extra });
50
+ try { validateGateConfig(config); }
51
+ catch (error) { return hold('GATE_CONFIG_INVALID', { findings: [{ code: 'GATE_CONFIG_INVALID', message: error.message }] }); }
52
+ if (!executeProfile || typeof executeProvider !== 'string' || !executeProvider.trim()) return hold('GATE_CONFIG_INVALID');
53
+ if (ceiling !== undefined) {
54
+ if (!Number.isFinite(ceiling.spent) || ceiling.spent < 0 || !Number.isFinite(ceiling.ceiling) || ceiling.ceiling <= 0) return hold('COST_CEILING_INVALID');
55
+ if (ceiling.spent > ceiling.ceiling) return hold('COST_CEILING_BREACHED', { breach: { spent: ceiling.spent, ceiling: ceiling.ceiling } });
56
+ }
57
+ const sourceState = stepOutputs?.[config.decide_from.step];
58
+ if (!object(sourceState) || sourceState.status !== 'succeeded' || !object(sourceState.output)) return hold('GATE_SOURCE_MISSING');
59
+ const gate = stepOutputs?.[gateStepId];
60
+ if (!object(gate) || gate.status !== 'waiting_gate' || typeof gateToken !== 'string' || !gateToken
61
+ || gate.gateToken !== gateToken || !Number.isInteger(gate.epoch) || gate.epoch < 0
62
+ || sourceState.epoch !== gate.epoch) return hold('GATE_SOURCE_STALE');
63
+ const output = sourceState.output;
64
+ const action = ownField(output, config.decide_from.field);
65
+ const outcome = ['approve', 'revise', 'kill'].find(key => config.decide_from[key].includes(action));
66
+ if (!outcome) return hold('GATE_ACTION_UNKNOWN');
67
+ const findings = [];
68
+ for (const validator of config.validators ?? []) {
69
+ const reviewState = stepOutputs?.[validator.review_step];
70
+ const review = reviewState?.status === 'succeeded' ? reviewState.output : undefined;
71
+ if (reviewState && reviewState.epoch !== gate.epoch) return hold('GATE_SOURCE_STALE');
72
+ findings.push(...validateDecision(output, review, validator, { executeProfile, executeProvider }));
73
+ }
74
+ if (findings.length) return hold('GATE_VALIDATION_FAILED', { findings });
75
+ return {
76
+ outcome, rationale: output.rationale ?? `Mapped ${String(action)} to ${outcome}`,
77
+ source: { step: config.decide_from.step, field: config.decide_from.field, action, gateStepId, gateToken,
78
+ epoch: sourceState.epoch, acceptedDispatchToken: sourceState.acceptedDispatchToken,
79
+ outputDigest: profilesDigest(output), output: structuredClone(output) },
80
+ };
81
+ }
@@ -0,0 +1,200 @@
1
+ /** Compose-owned sidecar schema and whole-wave admission. No dispatch or I/O. */
2
+ import { createHash } from 'node:crypto';
3
+ import { posix } from 'node:path';
4
+ import YAML from 'yaml';
5
+ import { validateAgentString, resolveAgentConfig } from './agent-string.js';
6
+
7
+ export class PipelineProfileError extends Error {
8
+ constructor(code, message) { super(message); this.name = 'PipelineProfileError'; this.code = code; }
9
+ }
10
+ const fail = (message, code = 'PIPELINE_PROFILE_INVALID') => { throw new PipelineProfileError(code, message); };
11
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
12
+ const keys = (value, allowed) => {
13
+ if (!object(value) || Object.keys(value).some(key => !allowed.includes(key))) fail(`Invalid configuration fields: ${JSON.stringify(value)}`);
14
+ };
15
+ export function ownField(value, path) {
16
+ if (typeof path !== 'string' || !/^[A-Za-z_][\w]*(\.[A-Za-z_][\w]*)*$/.test(path)
17
+ || path.split('.').some(key => ['__proto__', 'prototype', 'constructor'].includes(key))) fail('Invalid field path');
18
+ for (const key of path.split('.')) {
19
+ if (!object(value) || !Object.hasOwn(value, key)) return undefined;
20
+ value = value[key];
21
+ }
22
+ return value;
23
+ }
24
+ export function normalizeOwnedPath(path) {
25
+ if (typeof path !== 'string' || !path.length || /^[\\/]|^[A-Za-z]:/.test(path)
26
+ || /[\0*?\[\]{}]/.test(path)) fail('Ownership requires literal repository-relative file paths', 'WAVE_OWNERSHIP_INVALID');
27
+ const parts = path.replaceAll('\\', '/').split('/');
28
+ if (parts.includes('..') || parts.some(part => part.toLowerCase() === '.git')) fail(`Unsafe ownership path: ${path}`, 'WAVE_OWNERSHIP_INVALID');
29
+ const normalized = posix.normalize(parts.join('/'));
30
+ if (normalized === '.' || normalized.endsWith('/')) fail(`Not a file path: ${path}`, 'WAVE_OWNERSHIP_INVALID');
31
+ return normalized;
32
+ }
33
+ function agent(profile, provider) {
34
+ if (typeof profile !== 'string' || !profile.trim() || profile.split(':').length > 3) fail('Profile must be a non-empty agent string');
35
+ validateAgentString(profile);
36
+ const resolved = resolveAgentConfig(profile);
37
+ if (provider && resolved.provider !== provider) fail(`Profile provider ${resolved.provider} differs from stage ${provider}`);
38
+ return { ...resolved, profile };
39
+ }
40
+ export function validateGateConfig(entry) {
41
+ keys(entry, ['decide_from', 'validators']);
42
+ keys(entry.decide_from, ['step', 'field', 'approve', 'revise', 'kill']);
43
+ const mapping = entry.decide_from;
44
+ if (typeof mapping.step !== 'string' || !mapping.step) fail('decide_from.step is required');
45
+ ownField({}, mapping.field);
46
+ const seen = new Set();
47
+ for (const outcome of ['approve', 'revise', 'kill']) {
48
+ if (!Array.isArray(mapping[outcome])) fail(`${outcome} must be a value array`);
49
+ for (const value of mapping[outcome]) {
50
+ if (typeof value !== 'string' || !value || seen.has(value)) fail('Gate values must be nonempty, disjoint strings');
51
+ seen.add(value);
52
+ }
53
+ }
54
+ if (entry.validators !== undefined && !Array.isArray(entry.validators)) fail('validators must be an array');
55
+ for (const validator of entry.validators ?? []) {
56
+ keys(validator, ['name', 'review_step', 'tasks_field']);
57
+ if (validator.name !== 'WaveDecision' || typeof validator.review_step !== 'string' || !validator.review_step) fail('Unknown validator or missing review_step');
58
+ ownField({}, validator.tasks_field ?? 'tasks');
59
+ }
60
+ return entry;
61
+ }
62
+ function ancestor(steps, from, gate, visited = new Set()) {
63
+ if (visited.has(gate)) return false;
64
+ visited.add(gate);
65
+ return (steps.find(step => step.id === gate)?.after ?? []).some(id => id === from || ancestor(steps, from, id, visited));
66
+ }
67
+ export function normalizePipelineProfiles(raw, spec) {
68
+ if (!object(raw)) fail('Profiles must be an object');
69
+ const parsed = typeof spec === 'string' ? YAML.parse(spec) : spec;
70
+ const flows = Object.values(parsed?.flows ?? {}).filter(flow => Array.isArray(flow?.steps));
71
+ const steps = flows.flatMap(flow => flow.steps);
72
+ const normalized = structuredClone(raw);
73
+ for (const [id, entry] of Object.entries(raw)) {
74
+ if (id.startsWith('_')) continue;
75
+ const matches = steps.filter(step => step.id === id);
76
+ if (!matches.length) fail(`Step ${id} not found in spec`);
77
+ for (const step of matches) {
78
+ if (object(entry) && Object.hasOwn(entry, 'decide_from')) {
79
+ validateGateConfig(entry);
80
+ if (!step.gate || step.agent || step.fanout || id === 'review_gate') fail(`Step ${id} is not an available output gate`);
81
+ const flowSteps = flows.find(flow => flow.steps.includes(step)).steps;
82
+ for (const source of [entry.decide_from.step, ...(entry.validators ?? []).map(v => v.review_step)]) {
83
+ if (!ancestor(flowSteps, source, id)) fail(`Source ${source} must be an ancestor of ${id}`);
84
+ }
85
+ continue;
86
+ }
87
+ if (step.gate && !step.agent && !step.fanout) fail(`Gate ${id} requires decide_from`);
88
+ if (object(entry)) {
89
+ keys(entry, ['default', 'tier_from']);
90
+ if (entry.tier_from !== undefined && (entry.tier_from !== 'item.tier' || step.fanout?.dispatch !== 'consumer')) fail('tier_from requires a consumer fanout and item.tier');
91
+ } else if (typeof entry !== 'string') fail(`Invalid profile for ${id}`);
92
+ for (const stage of step.fanout?.steps ?? [step]) {
93
+ const resolved = agent(typeof entry === 'string' ? entry : entry.default, stage.agent ?? 'claude');
94
+ if (step.fanout?.dispatch === 'engine' && (resolved.tier || resolved.template)) fail('Engine dispatch cannot apply Compose templates or tiers');
95
+ if (entry.tier_from) for (const tier of ['critical', 'standard', 'fast']) resolveConsumerProfile(entry, { tier }, resolved.provider);
96
+ }
97
+ }
98
+ }
99
+ if (raw._consumer !== undefined) {
100
+ if (!object(raw._consumer)) fail('_consumer must be an object');
101
+ for (const [id, policy] of Object.entries(raw._consumer)) {
102
+ keys(policy, ['ownership', 'independent', 'checkpoint_gate']);
103
+ if (policy.ownership !== undefined && policy.ownership !== 'item.files_owned') fail('ownership must be item.files_owned');
104
+ if (policy.independent !== undefined && typeof policy.independent !== 'boolean') fail('independent must be boolean');
105
+ const matches = steps.filter(step => step.id === id);
106
+ if (!matches.length) fail(`Consumer ${id} not found`);
107
+ for (const step of matches) {
108
+ if (step.fanout?.dispatch !== 'consumer') fail(`${id} is not consumer-dispatched`);
109
+ if ((policy.ownership || policy.checkpoint_gate) && step.fanout.isolation !== 'worktree') fail('Ownership/checkpoints require worktree isolation');
110
+ if (policy.checkpoint_gate !== undefined) {
111
+ const flow = flows.find(flow => flow.steps.includes(step));
112
+ const gate = flow.steps.find(s => s.id === policy.checkpoint_gate);
113
+ if (!gate?.gate || gate.after?.length !== 1 || gate.after[0] !== id || gate.when) fail('checkpoint_gate must be the direct unconditional merge gate');
114
+ }
115
+ }
116
+ }
117
+ }
118
+ if (raw._costCeiling !== undefined) {
119
+ const c = raw._costCeiling;
120
+ keys(c, ['input', 'default', 'gates']);
121
+ if (typeof c.input !== 'string' || !/^[A-Za-z_]\w*$/.test(c.input)
122
+ || !Number.isFinite(c.default) || c.default <= 0 || !Array.isArray(c.gates) || !c.gates.length
123
+ || c.gates.some(id => !steps.some(s => s.id === id && s.gate))) fail('Invalid _costCeiling');
124
+ }
125
+ return normalized;
126
+ }
127
+ /** Replacing a default must never erase the per-item routing policy. Re-preflight the result. */
128
+ export function mergeRuntimeProfiles(normalized, runtime = {}) {
129
+ const result = structuredClone(normalized);
130
+ for (const [id, override] of Object.entries(runtime)) {
131
+ if (id.startsWith('_') || object(result[id]) && result[id].decide_from) fail(`Runtime override is not an agent profile: ${id}`);
132
+ const previous = result[id];
133
+ if (object(override)) {
134
+ keys(override, ['default', 'tier_from']);
135
+ if (override.tier_from !== undefined && override.tier_from !== previous?.tier_from) fail('Runtime overrides cannot change tier_from');
136
+ } else if (typeof override !== 'string') fail('Runtime override must be an agent profile');
137
+ result[id] = object(previous) ? { ...previous, default: typeof override === 'string' ? override : override.default } : structuredClone(override);
138
+ agent(typeof result[id] === 'string' ? result[id] : result[id].default);
139
+ }
140
+ return result;
141
+ }
142
+ export function resolveConsumerProfile(entry, item, provider) {
143
+ const resolved = agent(typeof entry === 'string' ? entry : entry?.default, provider);
144
+ if (!entry?.tier_from) return resolved;
145
+ if (entry.tier_from !== 'item.tier') fail('Unsupported tier_from');
146
+ const tier = ownField({ item }, entry.tier_from);
147
+ if (tier === undefined && !Object.hasOwn(item ?? {}, 'tier')) return resolved;
148
+ if (!['critical', 'standard', 'fast'].includes(tier)) fail(`Unknown item tier: ${String(tier)}`, 'WAVE_TIER_INVALID');
149
+ return agent(`${resolved.provider}:${resolved.template ?? ''}:${tier}`, provider);
150
+ }
151
+ export function validateWaveAdmission(entry, items, opts = {}) {
152
+ const findings = [];
153
+ const profiles = [];
154
+ const owners = new Map();
155
+ const add = (code, itemIndex, message) => findings.push({ code, itemIndex, message, severity: 'error' });
156
+ if (!Array.isArray(items)) return { ok: false, findings: [{ code: 'WAVE_INPUT_INVALID', message: 'Recorded wave input must be an array' }] };
157
+ items.forEach((item, itemIndex) => {
158
+ try { profiles.push(resolveConsumerProfile(entry, item, opts.provider)); }
159
+ catch (error) { add(error.code ?? 'WAVE_TIER_INVALID', itemIndex, error.message); }
160
+ if (opts.independent && (!Array.isArray(item?.depends_on) || item.depends_on.length)) add('WAVE_DEPENDENCIES_NOT_EMPTY', itemIndex, 'Independent tasks require empty depends_on');
161
+ if (opts.ownership || Object.hasOwn(item ?? {}, 'files_owned')) {
162
+ try {
163
+ if (!Array.isArray(item?.files_owned)) fail('files_owned is required', 'WAVE_OWNERSHIP_INVALID');
164
+ for (const file of item.files_owned.map(normalizeOwnedPath)) {
165
+ if (owners.has(file) && owners.get(file) !== itemIndex) add('WAVE_OWNERSHIP_CONFLICT', itemIndex, `Multiple tasks own ${file}`);
166
+ owners.set(file, itemIndex);
167
+ }
168
+ } catch (error) { add(error.code, itemIndex, error.message); }
169
+ }
170
+ });
171
+ return { ok: findings.length === 0, findings, profiles };
172
+ }
173
+ export function profilesDigest(normalized) {
174
+ const canonical = value => Array.isArray(value) ? value.map(canonical) : object(value)
175
+ ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
176
+ return createHash('sha256').update(JSON.stringify(canonical(normalized))).digest('hex');
177
+ }
178
+ /** Dispatch 2 replaces the string-only wrapper with this, after resolving spec inputs. */
179
+ export function preflightPipelineProfiles(raw, spec, runtime = {}) {
180
+ const normalized = normalizePipelineProfiles(mergeRuntimeProfiles(normalizePipelineProfiles(raw, spec), runtime), spec);
181
+ const resolved = {};
182
+ const overrides = {};
183
+ for (const [id, entry] of Object.entries(normalized)) {
184
+ if (!id.startsWith('_') && !entry?.decide_from) {
185
+ resolved[id] = resolveConsumerProfile(entry, {});
186
+ if (entry.tier_from) overrides[id] = ['critical', 'standard', 'fast'].map(tier => resolveConsumerProfile(entry, { tier }));
187
+ }
188
+ }
189
+ const parsed = typeof spec === 'string' ? YAML.parse(spec) : spec;
190
+ for (const step of Object.values(parsed?.flows ?? {}).flatMap(flow => flow?.steps ?? [])) {
191
+ if (Object.hasOwn(normalized, step.id)) continue;
192
+ const stages = step.fanout?.steps ?? (step.agent ? [step] : []);
193
+ for (const [index, stage] of stages.entries()) {
194
+ const resolution = agent(stage.agent ?? 'claude');
195
+ if (step.fanout?.dispatch === 'engine' && (resolution.template || resolution.tier)) fail('Engine dispatch cannot apply Compose templates or tiers');
196
+ resolved[stages.length === 1 ? step.id : `${step.id}/${index}`] = resolution;
197
+ }
198
+ }
199
+ return { ok: true, normalized, resolved, profilesDigest: profilesDigest({ normalized, resolved, overrides }) };
200
+ }
@@ -11,8 +11,35 @@
11
11
  * alive → await leader close and group disappearance (bounded at 2 seconds).
12
12
  *
13
13
  * Grace period: `COMPOSE_CANCEL_GRACE_MS` (default 5000ms).
14
+ *
15
+ * D-TERM-1 (2026-09-07): `-pid` IS still signalled after the leader is reaped.
16
+ * ------------------------------------------------------------------------
17
+ * The open question was whether signalling the group after `close` aims at a
18
+ * pgid the kernel may have recycled to a stranger. It does not, for the whole
19
+ * window that matters:
20
+ *
21
+ * POSIX 4.13 — "if there exists a process group whose process group ID is
22
+ * equal to that process ID, the process ID shall not be reused until the
23
+ * process group lifetime ends" — and a group's lifetime ends only when its
24
+ * LAST member leaves.
25
+ *
26
+ * So while our group has any living member, `-pid` provably names OUR group,
27
+ * and a living member is exactly the condition teardown is waiting on.
28
+ * Measured on Darwin 25.6.0 rather than taken on faith: with the leader reaped
29
+ * and one grandchild left, 400,000 fork/exit cycles never got the pgid handed
30
+ * back (the pid space is ~100k, so that is four wraps). Emptying the group
31
+ * first, the same pid came back at iteration 98,102 — one full wrap.
32
+ *
33
+ * That measurement RETIRES the fear rather than confirming it: the recycled
34
+ * pgid needs roughly 98,000 process creations between our group emptying and
35
+ * our probe, inside a 2s reap deadline. It is not a millisecond race, and it
36
+ * is the LEAST likely reading of a group-signal failure, not the diagnosis.
37
+ * Keep signalling the group after close — "the group outlives the leader" is
38
+ * correct and load-bearing.
14
39
  */
15
40
 
41
+ import { execFileSync } from 'node:child_process';
42
+
16
43
  const DEFAULT_GRACE_MS = 5000;
17
44
 
18
45
  /** Read the configured cancellation grace period. */
@@ -35,6 +62,89 @@ function graceMsFromEnv(env = process.env) {
35
62
  * @param {number} [graceMs]
36
63
  * @returns {{ close: Promise<void>, terminate: () => Promise<void>, finish: () => Promise<void> }}
37
64
  */
65
+ /**
66
+ * Every process currently in `pgid`, so a group-signal failure can be
67
+ * attributed instead of argued about.
68
+ *
69
+ * `ps -g` is NOT portable — BSD reads it as a pgid list, procps as a
70
+ * session/group list — so the whole table is read and filtered here.
71
+ *
72
+ * @returns {Array<object> | {error: string}} never throws: this runs on an
73
+ * error path, where a second failure would replace the first one.
74
+ */
75
+ function groupMembers(pgid) {
76
+ try {
77
+ const table = execFileSync('ps', ['-eo', 'pid=,pgid=,ppid=,uid=,comm='], {
78
+ encoding: 'utf8', timeout: 2000, maxBuffer: 4 * 1024 * 1024,
79
+ stdio: ['ignore', 'pipe', 'ignore'],
80
+ });
81
+ return table.split('\n')
82
+ .map((line) => line.trim().split(/\s+/))
83
+ .filter((f) => f.length >= 5 && Number(f[1]) === pgid)
84
+ // `comm` is last and may contain spaces (it is a path), so it takes the tail.
85
+ .map((f) => ({ pid: Number(f[0]), ppid: Number(f[2]), uid: Number(f[3]), comm: f.slice(4).join(' ') }));
86
+ } catch (e) {
87
+ return { error: e?.code ?? e?.message ?? 'unknown' };
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Why a group signal failed, stamped on the error itself.
93
+ *
94
+ * A non-ESRCH failure here is rethrown, relabelled `CANCELLATION_UNCONFIRMED`
95
+ * by `terminate`, and reaches the caller carrying only Node's bare message —
96
+ * `kill EPERM` and nothing else, which is unattributable after the fact: two
97
+ * call sites send group signals.
98
+ *
99
+ * WHAT EPERM MEANS HERE. Measured on Darwin 25.6.0: `kill(-pgid, 0)` against a
100
+ * group of processes we may not signal answers EPERM, not ESRCH. So EPERM says
101
+ * exactly one thing — *a group with this pgid exists and every member refused
102
+ * our signal*. Three sub-causes, deliberately unranked except for the last:
103
+ *
104
+ * 1. a member runs as another uid (a tool child that escalated), or
105
+ * 2. a policy — MAC / sandbox — refused the signal for a member that shares
106
+ * our uid, so "same uid" does NOT rule this out, or
107
+ * 3. our group is gone and a stranger holds a recycled pgid.
108
+ *
109
+ * (3) was the original hypothesis and is the LEAST likely of the three: per
110
+ * D-TERM-1 above it needs ~98,000 process creations inside a 2s deadline.
111
+ *
112
+ * `killGroupMembers` is what actually separates them, and it is why this
113
+ * function exists at all: uids other than ours point at (1) or (2), and
114
+ * processes plainly unrelated to the run point at (3).
115
+ *
116
+ * `killLeader` is kept but is NOT the discriminator it shipped as. After
117
+ * `close` the leader has been reaped, so it reads `gone` in every realistic
118
+ * recurrence; `ours` / `not-ours` need the same implausible wrap as (3). Do not
119
+ * read `not-ours` as "recycled pgid confirmed" — that inference was wrong.
120
+ *
121
+ * Diagnostic only: it changes no control flow and rethrows the same error.
122
+ */
123
+ function describeGroupSignalFailure(error, { site, pid, signal }) {
124
+ error.killSite = site;
125
+ error.killTarget = -pid;
126
+ error.killSignal = signal;
127
+ error.killerPgid = typeof process.getpgrp === 'function' ? process.getpgrp() : null;
128
+ try {
129
+ // The LEADER as a plain pid, not the group. Probing must never throw out of
130
+ // an error path, so every outcome is recorded rather than raised.
131
+ process.kill(pid, 0);
132
+ error.killLeader = 'ours';
133
+ } catch (probe) {
134
+ error.killLeader = probe.code === 'EPERM' ? 'not-ours' : (probe.code === 'ESRCH' ? 'gone' : probe.code);
135
+ }
136
+ error.killGroupMembers = groupMembers(pid);
137
+ const members = Array.isArray(error.killGroupMembers)
138
+ ? (error.killGroupMembers.length
139
+ ? error.killGroupMembers.map((m) => `${m.pid}/uid ${m.uid} ${m.comm}`).join(', ')
140
+ : 'none')
141
+ : `unreadable (${error.killGroupMembers.error})`;
142
+ error.message = `${error.message} (${site} ${String(signal)} → pgid ${pid}; `
143
+ + `leader ${error.killLeader}; our pgid ${error.killerPgid}; our uid ${process.getuid?.() ?? '?'}; `
144
+ + `group members: ${members})`;
145
+ return error;
146
+ }
147
+
38
148
  export function processTermination(child, group, graceMs = graceMsFromEnv(), reapTimeoutMs = 2000) {
39
149
  let closed = false;
40
150
  const close = new Promise((resolve) => child.once('close', () => { closed = true; resolve(); }));
@@ -45,7 +155,9 @@ export function processTermination(child, group, graceMs = graceMsFromEnv(), rea
45
155
  try {
46
156
  process.kill(-child.pid, signal);
47
157
  } catch (error) {
48
- if (error.code !== 'ESRCH') throw error;
158
+ if (error.code !== 'ESRCH') {
159
+ throw describeGroupSignalFailure(error, { site: 'send', pid: child.pid, signal });
160
+ }
49
161
  }
50
162
  } else if (!closed) {
51
163
  child.kill(signal);
@@ -61,15 +173,21 @@ export function processTermination(child, group, graceMs = graceMsFromEnv(), rea
61
173
  return true;
62
174
  } catch (error) {
63
175
  if (error.code === 'ESRCH') return false;
64
- throw error;
176
+ throw describeGroupSignalFailure(error, { site: 'alive', pid: child.pid, signal: 0 });
65
177
  }
66
178
  };
67
179
 
68
180
  const terminate = () => {
69
181
  teardown ??= (async () => {
70
- send('SIGTERM');
71
182
  let timer;
72
183
  try {
184
+ // INSIDE the try. Outside it, a refused opening SIGTERM escaped with
185
+ // Node's bare `EPERM` as its code, so the one failure the caller is
186
+ // told to expect from a group signal — CANCELLATION_UNCONFIRMED, per
187
+ // `describeGroupSignalFailure` — was the one code it never got. Only
188
+ // the later `alive()` and SIGKILL sites were ever labelled. Found by
189
+ // the D-TERM-1 test below, which is the first test this path ever had.
190
+ send('SIGTERM');
73
191
  await Promise.race([
74
192
  new Promise((resolve) => { timer = setTimeout(resolve, graceMs); }),
75
193
  // A leader that closed while its group lives must still wait out the