@smartmemory/compose 0.2.6-beta → 0.2.8-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/bin/compose.js +45 -3
  2. package/bin/git-hooks/pre-push.template +41 -13
  3. package/contracts/gsd-stuck.json +141 -0
  4. package/dist/assets/{App-j8fWZcGr.js → App-D3ehVPvi.js} +4 -4
  5. package/dist/assets/{arc-BFqOo_jJ.js → arc-Dmf69iHG.js} +1 -1
  6. package/dist/assets/{architectureDiagram-3BPJPVTR-D722w0RE.js → architectureDiagram-3BPJPVTR-xYo993Yw.js} +1 -1
  7. package/dist/assets/{blockDiagram-GPEHLZMM-B4w0mOAJ.js → blockDiagram-GPEHLZMM-UX4EF98O.js} +1 -1
  8. package/dist/assets/{c4Diagram-AAUBKEIU-D6LE8-j8.js → c4Diagram-AAUBKEIU-DaP9CGWb.js} +1 -1
  9. package/dist/assets/channel-D_RXsFFT.js +1 -0
  10. package/dist/assets/{chunk-2J33WTMH-CrazA7xu.js → chunk-2J33WTMH-CKk_RN3A.js} +1 -1
  11. package/dist/assets/{chunk-4BX2VUAB-Cp90GiCM.js → chunk-4BX2VUAB-DboAwYKw.js} +1 -1
  12. package/dist/assets/{chunk-55IACEB6-Bnais1SK.js → chunk-55IACEB6-Dsy9RYvI.js} +1 -1
  13. package/dist/assets/{chunk-727SXJPM-kD07Sqp5.js → chunk-727SXJPM-fAH0QO9v.js} +1 -1
  14. package/dist/assets/{chunk-AQP2D5EJ-DmIxhJc8.js → chunk-AQP2D5EJ-DyZYerFP.js} +1 -1
  15. package/dist/assets/{chunk-FMBD7UC4-Jti_und8.js → chunk-FMBD7UC4-BnboGO5t.js} +1 -1
  16. package/dist/assets/{chunk-ND2GUHAM-Ipx3noKz.js → chunk-ND2GUHAM-Di9tYXme.js} +1 -1
  17. package/dist/assets/{chunk-QZHKN3VN-CeblRnPF.js → chunk-QZHKN3VN-zRPRlAIL.js} +1 -1
  18. package/dist/assets/classDiagram-4FO5ZUOK-K6wdB4ic.js +1 -0
  19. package/dist/assets/classDiagram-v2-Q7XG4LA2-K6wdB4ic.js +1 -0
  20. package/dist/assets/{cose-bilkent-S5V4N54A-fNQlSmHt.js → cose-bilkent-S5V4N54A-C7Hqukaf.js} +1 -1
  21. package/dist/assets/{dagre-BM42HDAG-D27D6YAL.js → dagre-BM42HDAG-B-cR-BjI.js} +1 -1
  22. package/dist/assets/{diagram-2AECGRRQ-CtXeohzN.js → diagram-2AECGRRQ-B6-5onDk.js} +1 -1
  23. package/dist/assets/{diagram-5GNKFQAL-C_BqZkx0.js → diagram-5GNKFQAL-DoZZgFAM.js} +1 -1
  24. package/dist/assets/{diagram-KO2AKTUF-B29ynQz4.js → diagram-KO2AKTUF-77jEGlJh.js} +1 -1
  25. package/dist/assets/{diagram-LMA3HP47-DAYJMc2I.js → diagram-LMA3HP47-D3S7XDRD.js} +1 -1
  26. package/dist/assets/{diagram-OG6HWLK6-CBJMis3l.js → diagram-OG6HWLK6-KbYL9aCY.js} +1 -1
  27. package/dist/assets/{erDiagram-TEJ5UH35-nd3GWiPn.js → erDiagram-TEJ5UH35-DezFbJP-.js} +1 -1
  28. package/dist/assets/{flowDiagram-I6XJVG4X-HFUno_nV.js → flowDiagram-I6XJVG4X-4x31cK9j.js} +1 -1
  29. package/dist/assets/{ganttDiagram-6RSMTGT7-CPPAAjwR.js → ganttDiagram-6RSMTGT7-FopfSTyZ.js} +1 -1
  30. package/dist/assets/{gitGraphDiagram-PVQCEYII-NBq1F6K2.js → gitGraphDiagram-PVQCEYII-DSiQGKbN.js} +1 -1
  31. package/dist/assets/graph-Cs_vqCR0.js +331 -0
  32. package/dist/assets/{index-uHKnp74B.js → index-ClX6LVAf.js} +2 -2
  33. package/dist/assets/{infoDiagram-5YYISTIA-D-TOBtCq.js → infoDiagram-5YYISTIA-DE6BqzK_.js} +1 -1
  34. package/dist/assets/{ishikawaDiagram-YF4QCWOH-nXOztZiZ.js → ishikawaDiagram-YF4QCWOH-Dml8NwQI.js} +1 -1
  35. package/dist/assets/{journeyDiagram-JHISSGLW-Bko3tTdh.js → journeyDiagram-JHISSGLW-CwWeJgjE.js} +1 -1
  36. package/dist/assets/{kanban-definition-UN3LZRKU-1e-7i8st.js → kanban-definition-UN3LZRKU-DnG956Wh.js} +1 -1
  37. package/dist/assets/{linear-Dx5ZJB7F.js → linear-CA3N7Rpi.js} +1 -1
  38. package/dist/assets/{mindmap-definition-RKZ34NQL-CNwNkDqN.js → mindmap-definition-RKZ34NQL-CxfIOjLX.js} +1 -1
  39. package/dist/assets/{pieDiagram-4H26LBE5-C5fvCej-.js → pieDiagram-4H26LBE5-O7aIwy1x.js} +1 -1
  40. package/dist/assets/{quadrantDiagram-W4KKPZXB-4NoQsF61.js → quadrantDiagram-W4KKPZXB-CPQ2qq7c.js} +1 -1
  41. package/dist/assets/{requirementDiagram-4Y6WPE33-q5WxB9LO.js → requirementDiagram-4Y6WPE33-C23horL4.js} +1 -1
  42. package/dist/assets/{sankeyDiagram-5OEKKPKP-DlQNB367.js → sankeyDiagram-5OEKKPKP-DPY04kOW.js} +1 -1
  43. package/dist/assets/{sequenceDiagram-3UESZ5HK-BzHclOKt.js → sequenceDiagram-3UESZ5HK-BKaTfIvo.js} +1 -1
  44. package/dist/assets/{stateDiagram-AJRCARHV-BvWRI9zK.js → stateDiagram-AJRCARHV-B9na_6mY.js} +1 -1
  45. package/dist/assets/stateDiagram-v2-BHNVJYJU-Cf84VDiH.js +1 -0
  46. package/dist/assets/{timeline-definition-PNZ67QCA-j2wKjAti.js → timeline-definition-PNZ67QCA-BBWPqd7X.js} +1 -1
  47. package/dist/assets/{vennDiagram-CIIHVFJN-B77g7htC.js → vennDiagram-CIIHVFJN-tWqiHsOZ.js} +1 -1
  48. package/dist/assets/{wardley-L42UT6IY-83Im2mo2.js → wardley-L42UT6IY-DorxG6os.js} +1 -1
  49. package/dist/assets/{wardleyDiagram-YWT4CUSO-CK-XB-bO.js → wardleyDiagram-YWT4CUSO-B49f8GzW.js} +1 -1
  50. package/dist/assets/{xychartDiagram-2RQKCTM6-D42FcVOY.js → xychartDiagram-2RQKCTM6-BgKSj8Qb.js} +1 -1
  51. package/dist/index.html +1 -1
  52. package/lib/budget-ledger.js +84 -0
  53. package/lib/build-stream-schema.js +5 -3
  54. package/lib/build.js +91 -2
  55. package/lib/feature-validator.js +40 -8
  56. package/lib/gsd-budget.js +205 -0
  57. package/lib/gsd-stuck.js +275 -0
  58. package/lib/gsd.js +499 -8
  59. package/lib/result-normalizer.js +5 -1
  60. package/package.json +2 -2
  61. package/server/agent-spawn.js +7 -1
  62. package/server/compose-mcp-tools.js +103 -1
  63. package/server/compose-mcp.js +34 -4
  64. package/server/design-routes.js +4 -1
  65. package/server/mcp-tool-policy.js +112 -0
  66. package/dist/assets/channel-BD-5_hPW.js +0 -1
  67. package/dist/assets/classDiagram-4FO5ZUOK-mSW5R7DY.js +0 -1
  68. package/dist/assets/classDiagram-v2-Q7XG4LA2-mSW5R7DY.js +0 -1
  69. package/dist/assets/graph-CJVNlri5.js +0 -331
  70. package/dist/assets/stateDiagram-v2-BHNVJYJU-CDlF0VA8.js +0 -1
@@ -7,15 +7,17 @@
7
7
  * Design decisions:
8
8
  * - Uses AJV (already in compose deps) compiled once at module load.
9
9
  * - On validation failure the caller should warn+drop — never throw.
10
- * - KNOWN_VERSIONS: set of accepted schema_version strings. v0.2.5 accepted for
11
- * one-cycle backward compatibility; v0.2.6 is current.
10
+ * - KNOWN_VERSIONS: set of accepted schema_version strings. v0.2.5/v0.2.6 accepted
11
+ * for backward compatibility; v0.2.7 is current (STRAT-PAR-STREAM-TOOLDETAIL:
12
+ * enriched tool_use_summary.input + tool_use_id, new tool_result kind — both
13
+ * ride the open catch-all, so no closed metadata schema is added here).
12
14
  * - reply_required (Option A, STRAT-PAR-STREAM-CONSUMER-VALIDATE design):
13
15
  * optional boolean reserved for future gate/permission/question kinds.
14
16
  */
15
17
 
16
18
  import Ajv2020 from 'ajv/dist/2020.js';
17
19
 
18
- export const KNOWN_VERSIONS = new Set(['0.2.5', '0.2.6']);
20
+ export const KNOWN_VERSIONS = new Set(['0.2.5', '0.2.6', '0.2.7']);
19
21
 
20
22
  // ---------------------------------------------------------------------------
21
23
  // Envelope schema (top-level fields only; metadata shape is kind-specific)
package/lib/build.js CHANGED
@@ -25,6 +25,7 @@ import { resolvePort } from './resolve-port.js';
25
25
  import { probeServer } from './server-probe.js';
26
26
  import { CliProgress } from './cli-progress.js';
27
27
  import { BuildStreamWriter } from './build-stream-writer.js';
28
+ import { KNOWN_VERSIONS } from './build-stream-schema.js';
28
29
  import { resolveAgentConfig } from './agent-string.js';
29
30
  import { installFactoryShim } from './connector-factory-shim.js';
30
31
  import { emitSections as emitPlanSections, appendTrailers as appendSectionTrailers, analyzeRollup, writeRollup } from './sections.js';
@@ -2946,12 +2947,19 @@ export async function executeParallelDispatchServer(
2946
2947
  progress,
2947
2948
  streamWriter,
2948
2949
  baseCwd,
2950
+ opts = {},
2949
2951
  ) {
2950
2952
  const { flow_id: flowId, step_id: stepId,
2951
2953
  step_number: stepNum, total_steps: totalSteps,
2952
2954
  tasks } = dispatchResponse;
2953
2955
  const emittedStates = new Map();
2954
2956
 
2957
+ // COMP-GSD-5: optional stuck detector. ONLY the gsd path passes one; build
2958
+ // mode invokes this fn with 6 args (opts={}) so every detector branch below
2959
+ // is skipped and build behavior stays byte-identical.
2960
+ const stuckDetector = opts.stuckDetector ?? null;
2961
+ const startedTasks = new Set();
2962
+
2955
2963
  if (streamWriter) {
2956
2964
  streamWriter.write({
2957
2965
  type: 'build_step_start', stepId,
@@ -2981,9 +2989,18 @@ export async function executeParallelDispatchServer(
2981
2989
  // state-machine driver. Forward valid events through streamWriter so the
2982
2990
  // bridge rebroadcasts them via SSE under the buildStreamEvent wrapper.
2983
2991
  let unsubscribePush = null;
2984
- if (typeof stratum.onEvent === 'function' && streamWriter) {
2992
+ if (typeof stratum.onEvent === 'function' && (streamWriter || stuckDetector)) {
2985
2993
  unsubscribePush = stratum.onEvent(flowId, stepId, (event) => {
2986
- if (!event || event.schema_version !== '0.2.5') return;
2994
+ // Accept all KNOWN_VERSIONS (producer emits 0.2.6); pinning '0.2.5' dropped
2995
+ // every push event from the current producer. Client already validated.
2996
+ if (!event || !KNOWN_VERSIONS.has(event.schema_version)) return;
2997
+ // COMP-GSD-5: feed the per-task stuck detector (no-op in build mode —
2998
+ // stuckDetector is null there). record() ignores all but
2999
+ // tool_use_summary/tool_result and keys by event.task_id.
3000
+ if (stuckDetector && (event.kind === 'tool_use_summary' || event.kind === 'tool_result')) {
3001
+ try { stuckDetector.record(event); } catch (err) { console.error('[build] stuck detector record failed:', err); }
3002
+ }
3003
+ if (!streamWriter) return;
2987
3004
  try {
2988
3005
  streamWriter.write({ type: 'build_stream_event', event });
2989
3006
  } catch (err) {
@@ -2995,6 +3012,7 @@ export async function executeParallelDispatchServer(
2995
3012
  try {
2996
3013
  // Poll until outcome is present (NOT can_advance — see design §3)
2997
3014
  let pollResult;
3015
+ let stuckVerdict = null;
2998
3016
  const intervalMs = SERVER_DISPATCH_POLL_MS();
2999
3017
  while (true) {
3000
3018
  pollResult = await stratum.parallelPoll(flowId, stepId);
@@ -3004,10 +3022,81 @@ export async function executeParallelDispatchServer(
3004
3022
  );
3005
3023
  }
3006
3024
  emitPerTaskProgress(streamWriter, pollResult, emittedStates);
3025
+
3026
+ // COMP-GSD-5: real-time stuck detection (gsd path only — null in build).
3027
+ // For each running task: establish a wall-clock baseline on first sight,
3028
+ // then ask the detector for a verdict. On the first stuck task, cancel the
3029
+ // step via the terminal cascade primitive (parallelAdvance 'conflict' —
3030
+ // the same path T2-F5 uses) and break with a stuck outcome.
3031
+ if (stuckDetector) {
3032
+ const now = Date.now();
3033
+ for (const [taskId, ts] of Object.entries(pollResult.tasks ?? {})) {
3034
+ if (ts.state === 'running') stuckDetector.startTask(taskId, now);
3035
+ if (ts.state !== 'running') continue;
3036
+ const v = stuckDetector.check(taskId, now);
3037
+ if (v?.stuck) {
3038
+ stuckVerdict = {
3039
+ ...v,
3040
+ taskId,
3041
+ attemptCounts: stuckDetector.attemptCounts(taskId),
3042
+ };
3043
+ if (streamWriter) {
3044
+ streamWriter.write({
3045
+ type: 'system', subtype: 'gsd_stuck',
3046
+ stepId, taskId, signal: v.signal, detail: v.detail, parallel: true,
3047
+ });
3048
+ }
3049
+ const cancel = await stratum.parallelAdvance(flowId, stepId, 'conflict');
3050
+ if (cancel?.error) {
3051
+ throw new Error(
3052
+ `stratum_parallel_advance (stuck cancel) failed: ${cancel.error}: ${cancel.message || ''}`,
3053
+ );
3054
+ }
3055
+ pollResult = { ...pollResult, outcome: cancel };
3056
+ break;
3057
+ }
3058
+ }
3059
+ }
3060
+ if (stuckVerdict) break;
3061
+
3007
3062
  if (pollResult.outcome != null) break;
3008
3063
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
3009
3064
  }
3010
3065
 
3066
+ // COMP-GSD-5: short-circuit return on a stuck verdict. The cancel outcome is
3067
+ // returned verbatim with the verdict attached; the gsd run loop branches on
3068
+ // `.stuck`. This bypasses the merge/advance bookkeeping below — there is
3069
+ // nothing to merge for a cancelled task.
3070
+ if (stuckVerdict) {
3071
+ if (streamWriter) {
3072
+ streamWriter.write({
3073
+ type: 'build_step_done', stepId, parallel: true,
3074
+ summary: { ...(pollResult.summary ?? {}), stuck: stuckVerdict.signal }, flowId,
3075
+ });
3076
+ }
3077
+ // unsubscribePush is invoked by the enclosing finally.
3078
+ return { ...(pollResult.outcome ?? {}), stuck: stuckVerdict };
3079
+ }
3080
+
3081
+ // COMP-GSD-4: budget_exhausted is a stratum terminal status. When the run
3082
+ // budget trips mid-dispatch, the flow has already cascade-cancelled the
3083
+ // in-flight siblings (server-side) and the poll returns the terminal
3084
+ // envelope (carrying budget_state). There is nothing to merge — short-circuit
3085
+ // like stuck and hand the envelope back so the gsd run loop halts. No-op for
3086
+ // build mode: build flows declare no `budget:` block, so the status never
3087
+ // appears and this branch is never taken (byte-identical). The
3088
+ // advance-carried case (a parallelAdvance that returns budget_exhausted)
3089
+ // falls through to the final `return pollResult.outcome` below.
3090
+ if (pollResult.outcome?.status === 'budget_exhausted') {
3091
+ if (streamWriter) {
3092
+ streamWriter.write({
3093
+ type: 'build_step_done', stepId, parallel: true,
3094
+ summary: { ...(pollResult.summary ?? {}), budget_exhausted: true }, flowId,
3095
+ });
3096
+ }
3097
+ return pollResult.outcome;
3098
+ }
3099
+
3011
3100
  if (pollResult.outcome.status === 'already_advanced') {
3012
3101
  throw new Error(
3013
3102
  `stratum_parallel_poll returned already_advanced for step ${stepId} — ` +
@@ -385,6 +385,17 @@ function normalizeStatus(s) {
385
385
  return String(s).toUpperCase();
386
386
  }
387
387
 
388
+ // vision-state's status vocabulary (contracts/vision-state.schema.json) is the
389
+ // tracker's set MINUS `PARTIAL` — it cannot represent "partially shipped". A
390
+ // tracker status of PARTIAL is the same lifecycle reality as vision's
391
+ // IN_PROGRESS (partially shipped = still in progress), so project the tracker
392
+ // side onto the vision vocabulary before any *_VS_VISION_STATE comparison.
393
+ // Tracker↔tracker comparisons (ROADMAP_VS_FEATUREJSON) keep the full
394
+ // vocabulary — PARTIAL vs IN_PROGRESS there is a real distinction.
395
+ function projectToVisionStatus(s) {
396
+ return s === 'PARTIAL' ? 'IN_PROGRESS' : s;
397
+ }
398
+
388
399
  function runStateMismatchChecks(fctx, findings) {
389
400
  const { code, roadmap, vision, featureJson } = fctx;
390
401
  const rStatus = normalizeStatus(roadmap?.status);
@@ -401,22 +412,43 @@ function runStateMismatchChecks(fctx, findings) {
401
412
  'STATUS_MISMATCH_ROADMAP_VS_FEATUREJSON', code,
402
413
  `ROADMAP says ${rStatus}, feature.json says ${fStatus}`));
403
414
  }
404
- if (rStatus && vStatus && rStatus !== vStatus) {
405
- findings.push(finding(statusSeverity(rStatus, vStatus),
415
+ // Project BOTH sides to the vision vocabulary (PARTIAL→IN_PROGRESS) before
416
+ // comparing. Projecting the tracker side stops a legitimately-PARTIAL feature
417
+ // false-firing against a vision item that can only say in_progress; projecting
418
+ // the vision side keeps it symmetric so a malformed/legacy vision status of
419
+ // "partial" (schema-invalid — reported as VISION_STATE_SCHEMA_VIOLATION) still
420
+ // aligns with tracker PARTIAL instead of double-reporting. Real drift (PARTIAL
421
+ // vs complete/planned) still differs and fires.
422
+ const rVis = projectToVisionStatus(rStatus);
423
+ const fVis = projectToVisionStatus(fStatus);
424
+ const vVis = projectToVisionStatus(vStatus);
425
+ if (rStatus && vStatus && rVis !== vVis) {
426
+ findings.push(finding(statusSeverity(rVis, vVis),
406
427
  'STATUS_MISMATCH_ROADMAP_VS_VISION_STATE', code,
407
428
  `ROADMAP says ${rStatus}, vision-state says ${vStatus}`));
408
429
  }
409
- if (fStatus && vStatus && fStatus !== vStatus) {
410
- findings.push(finding(statusSeverity(fStatus, vStatus),
430
+ if (fStatus && vStatus && fVis !== vVis) {
431
+ findings.push(finding(statusSeverity(fVis, vVis),
411
432
  'STATUS_MISMATCH_FEATUREJSON_VS_VISION_STATE', code,
412
433
  `feature.json says ${fStatus}, vision-state says ${vStatus}`));
413
434
  }
414
- // CONTRADICTORY_PHASE_CLAIM
415
- const fPhase = featureJson?.phase || featureJson?.lifecycle?.currentPhase;
416
- const vPhase = vision?.phase || vision?.lifecycle?.currentPhase;
435
+ // CONTRADICTORY_PHASE_CLAIM — compare LIFECYCLE phase to LIFECYCLE phase.
436
+ // feature.json's top-level `phase` holds the ROADMAP heading (e.g. "Phase 7:
437
+ // MCP Writers"), NOT a lifecycle stage; comparing it to vision-state's
438
+ // lifecycle phase ("vision"/"explore_design"/…) is a category mismatch that
439
+ // false-fired on ~every feature with a vision item. Use only the lifecycle
440
+ // sources on both sides — which is what the "does not involve the roadmap"
441
+ // comment above always intended. (feature.json doesn't currently carry a
442
+ // lifecycle phase, so this correctly yields no finding until it does.)
443
+ const fPhase = featureJson?.lifecycle?.currentPhase;
444
+ // Lifecycle phase ONLY on both sides. Do NOT fall back to vision.phase — that
445
+ // is the legacy board-column taxonomy (planning|implementation|…), a different
446
+ // vocabulary from lifecycle.currentPhase (explore_design|blueprint|…), and
447
+ // mixing them reintroduces the category mismatch this fix removes (Codex).
448
+ const vPhase = vision?.lifecycle?.currentPhase;
417
449
  if (fPhase && vPhase && fPhase !== vPhase) {
418
450
  findings.push(finding('error', 'CONTRADICTORY_PHASE_CLAIM', code,
419
- `feature.json phase '${fPhase}' vs vision-state phase '${vPhase}'`));
451
+ `feature.json lifecycle phase '${fPhase}' vs vision-state phase '${vPhase}'`));
420
452
  }
421
453
  // COMPLEXITY_OR_DESCRIPTION_DRIFT
422
454
  if (roadmap && featureJson) {
@@ -0,0 +1,205 @@
1
+ /**
2
+ * gsd-budget.js — COMP-GSD-4 budget-ceiling helpers for autonomous `compose gsd`.
3
+ *
4
+ * This module does NOT count tokens or enforce budgets — that is the stratum
5
+ * flow budget (STRAT-WORKFLOW-BUDGET): a `budget:` block on the flow makes
6
+ * stratum debit every server-dispatched agent and halt the run with a terminal
7
+ * `budget_exhausted` status that carries `budget_state = {caps, consumed}`.
8
+ *
9
+ * GSD-4's job is purely compose-side glue:
10
+ * - readGsdBudgetConfig: read `.compose/compose.json` `gsd.budget.*` (no defaults).
11
+ * - buildBudgetBlock: map that config → the stratum flow `budget` block
12
+ * (+ a per-task task_timeout in seconds).
13
+ * - injectBudget: inject the block into the gsd spec YAML — IDENTITY
14
+ * when nothing is configured (byte-identical guarantee).
15
+ * - composeBudgetDiagnostic: render budget.json + budget.md from budget_state.
16
+ *
17
+ * Enforced axes (stratum): ms (wall-clock), max_agent_dispatches, max_tokens, usd.
18
+ * See: docs/features/COMP-GSD-4/{design,blueprint}.md, stratum run_budget.py.
19
+ */
20
+
21
+ import { existsSync, readFileSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import YAML from 'yaml';
24
+
25
+ /**
26
+ * Read `.compose/compose.json` → `gsd.budget`. Returns {} when absent or
27
+ * unparseable. NO defaults (gate decision 7): a gsd run is unbounded unless the
28
+ * user sets a budget. Mirrors readGsdStuckConfig in gsd.js.
29
+ */
30
+ export function readGsdBudgetConfig(cwd) {
31
+ const configPath = join(cwd, '.compose', 'compose.json');
32
+ if (!existsSync(configPath)) return {};
33
+ try {
34
+ const cfg = JSON.parse(readFileSync(configPath, 'utf-8'));
35
+ return cfg?.gsd?.budget ?? {};
36
+ } catch {
37
+ return {};
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Map snake_case `gsd.budget.*` config → the stratum flow budget block and an
43
+ * optional per-task timeout (seconds). Only keys the user set appear.
44
+ *
45
+ * Config keys:
46
+ * max_tokens, max_agent_dispatches, usd → flow budget axes
47
+ * per_run_ms (alias: ms) → flow budget `ms` (wall-clock)
48
+ * per_task_ms → execute step `task_timeout` (sec)
49
+ * cumulative: { max_total_tokens, max_total_cost_usd } → cross-session ceiling
50
+ *
51
+ * @returns {{ budget?: object, taskTimeoutSec?: number, cumulative?: object }}
52
+ */
53
+ export function buildBudgetBlock(cfg = {}) {
54
+ const out = {};
55
+
56
+ const budget = {};
57
+ if (cfg.max_tokens != null) budget.max_tokens = cfg.max_tokens;
58
+ if (cfg.max_agent_dispatches != null) budget.max_agent_dispatches = cfg.max_agent_dispatches;
59
+ if (cfg.usd != null) budget.usd = cfg.usd;
60
+ const ms = cfg.per_run_ms ?? cfg.ms;
61
+ if (ms != null) budget.ms = ms;
62
+ if (Object.keys(budget).length > 0) out.budget = budget;
63
+
64
+ if (cfg.per_task_ms != null) {
65
+ // stratum parallel_dispatch per-task timeout is `task_timeout` in SECONDS
66
+ // (spec.py:145, schema minimum 1). Convert from ms, floor at 1s.
67
+ out.taskTimeoutSec = Math.max(1, Math.ceil(cfg.per_task_ms / 1000));
68
+ }
69
+
70
+ if (cfg.cumulative && typeof cfg.cumulative === 'object') {
71
+ const cum = {};
72
+ if (cfg.cumulative.max_total_tokens != null) cum.maxTotalTokens = cfg.cumulative.max_total_tokens;
73
+ if (cfg.cumulative.max_total_cost_usd != null) cum.maxTotalCostUsd = cfg.cumulative.max_total_cost_usd;
74
+ if (Object.keys(cum).length > 0) out.cumulative = cum;
75
+ }
76
+
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * Inject the budget block into the gsd flow spec YAML.
82
+ *
83
+ * BYTE-IDENTICAL GUARANTEE: when nothing is configured (no flow budget AND no
84
+ * per-task timeout), the original `specYaml` string is returned VERBATIM — no
85
+ * YAML.parse/stringify round-trip (which would reorder/reformat). This keeps an
86
+ * un-budgeted `compose gsd` (and plain `compose build`) bit-for-bit unchanged.
87
+ *
88
+ * @param {string} specYaml — the gsd.stratum.yaml contents
89
+ * @param {object} cfg — raw gsd.budget config (from readGsdBudgetConfig)
90
+ * @returns {string}
91
+ */
92
+ export function injectBudget(specYaml, cfg = {}) {
93
+ const built = buildBudgetBlock(cfg);
94
+ if (!built.budget && built.taskTimeoutSec == null) {
95
+ return specYaml; // identity — nothing to inject
96
+ }
97
+
98
+ const parsed = YAML.parse(specYaml);
99
+ const flow = parsed?.flows?.gsd;
100
+ if (!flow) {
101
+ // Defensive: spec shape changed. Don't silently drop the budget — surface it.
102
+ throw new Error('injectBudget: spec has no flows.gsd to attach a budget to');
103
+ }
104
+
105
+ if (built.budget) flow.budget = built.budget;
106
+
107
+ if (built.taskTimeoutSec != null && Array.isArray(flow.steps)) {
108
+ const execute = flow.steps.find((s) => s && s.id === 'execute');
109
+ if (execute) execute.task_timeout = built.taskTimeoutSec;
110
+ }
111
+
112
+ return YAML.stringify(parsed);
113
+ }
114
+
115
+ // Maps a stratum budget axis → human label for diagnostics.
116
+ const AXIS_LABEL = {
117
+ max_tokens: 'tokens',
118
+ max_agent_dispatches: 'agent dispatches',
119
+ ms: 'wall-clock',
120
+ usd: 'cost (USD)',
121
+ };
122
+
123
+ /**
124
+ * Identify which enforced axis tripped, comparing consumed vs caps.
125
+ * Mirrors stratum run_budget.budget_exhausted() (consumed >= cap), in the same
126
+ * precedence order. Returns null if nothing is over (shouldn't happen on a
127
+ * budget_exhausted terminal, but the diagnostic stays honest).
128
+ */
129
+ export function trippedAxis(budgetState) {
130
+ const caps = budgetState?.caps ?? {};
131
+ const consumed = budgetState?.consumed ?? {};
132
+ if (caps.ms != null && (consumed.wall_s ?? 0) >= caps.ms / 1000) return 'ms';
133
+ if (caps.max_agent_dispatches != null && (consumed.dispatches ?? 0) >= caps.max_agent_dispatches) return 'max_agent_dispatches';
134
+ if (caps.max_tokens != null && (consumed.tokens ?? 0) >= caps.max_tokens) return 'max_tokens';
135
+ if (caps.usd != null && (consumed.dollars ?? 0) >= caps.usd) return 'usd';
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * Build the budget.json + budget.md diagnostic from the stratum terminal
141
+ * envelope's budget_state.
142
+ *
143
+ * @param {object} budgetState — {caps, consumed:{tokens,dispatches,wall_s,dollars}}
144
+ * @param {{feature:string, decomposedTasks?:Array, completedTaskIds?:Array, cumulative?:object}} meta
145
+ * @returns {{ json: object, md: string }}
146
+ */
147
+ export function composeBudgetDiagnostic(budgetState, meta = {}) {
148
+ const caps = budgetState?.caps ?? {};
149
+ const consumed = budgetState?.consumed ?? {};
150
+ const axis = meta.axis ?? trippedAxis(budgetState);
151
+ const feature = meta.feature ?? '';
152
+
153
+ const completed = new Set(meta.completedTaskIds ?? []);
154
+ const remaining = (meta.decomposedTasks ?? [])
155
+ .map((t) => t.id)
156
+ .filter((id) => id && !completed.has(id));
157
+
158
+ const json = {
159
+ feature,
160
+ kind: 'budget',
161
+ axis,
162
+ caps,
163
+ consumed,
164
+ remainingTaskIds: remaining,
165
+ ts: new Date().toISOString(),
166
+ };
167
+
168
+ const rows = [];
169
+ if (caps.max_tokens != null) rows.push(`| tokens | ${consumed.tokens ?? 0} | ${caps.max_tokens} |`);
170
+ if (caps.max_agent_dispatches != null) rows.push(`| agent dispatches | ${consumed.dispatches ?? 0} | ${caps.max_agent_dispatches} |`);
171
+ if (caps.ms != null) rows.push(`| wall-clock (s) | ${Math.round(consumed.wall_s ?? 0)} | ${Math.round(caps.ms / 1000)} |`);
172
+ if (caps.usd != null) rows.push(`| cost (USD) | ${(consumed.dollars ?? 0).toFixed(4)} | ${Number(caps.usd).toFixed(4)} |`);
173
+
174
+ const md = [
175
+ `# GSD budget halt — ${feature}`,
176
+ '',
177
+ `**Tripped axis:** ${AXIS_LABEL[axis] ?? axis ?? 'cumulative'}`,
178
+ `**When:** ${json.ts}`,
179
+ '',
180
+ '## Consumed vs cap',
181
+ '',
182
+ '| Axis | Consumed | Cap |',
183
+ '|------|----------|-----|',
184
+ ...rows,
185
+ '',
186
+ `## Remaining tasks (${remaining.length})`,
187
+ '',
188
+ remaining.length ? remaining.map((id) => `- ${id}`).join('\n') : '_none — all tasks completed before the halt._',
189
+ '',
190
+ '## Resume',
191
+ '',
192
+ 'Raise the relevant `gsd.budget.*` cap in `.compose/compose.json` (or run with',
193
+ '`--reset-budget` to clear the cumulative ledger), then:',
194
+ '',
195
+ '```',
196
+ `compose gsd ${feature} --resume`,
197
+ '```',
198
+ '',
199
+ 'Completed task results are preserved in the blackboard; --resume re-dispatches',
200
+ 'only the remaining tasks.',
201
+ '',
202
+ ].join('\n');
203
+
204
+ return { json, md };
205
+ }