@smartmemory/compose 0.2.57-beta → 0.3.0

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 (104) hide show
  1. package/README.md +2 -2
  2. package/bin/compose.js +55 -54
  3. package/contracts/feature-json.schema.json +16 -0
  4. package/dist/assets/{App-BmhlHOXF.js → App-DJ5xk_Wx.js} +219 -219
  5. package/dist/assets/{abnfDiagram-VRR7QNED-CMOTYAnt.js → abnfDiagram-VRR7QNED-BPWGdCFx.js} +1 -1
  6. package/dist/assets/{arc-BJAv_6dL.js → arc-DX5jqmcO.js} +1 -1
  7. package/dist/assets/{architectureDiagram-ZJ3FMSHR-xa4DLUcp.js → architectureDiagram-ZJ3FMSHR-B6jmNVw7.js} +1 -1
  8. package/dist/assets/{blockDiagram-677ZJIJ3-BdkDqeQF.js → blockDiagram-677ZJIJ3-DAx2i-sB.js} +1 -1
  9. package/dist/assets/{c4Diagram-LMCZKHZV-DiNrLIuL.js → c4Diagram-LMCZKHZV-zmsZCplj.js} +1 -1
  10. package/dist/assets/channel-vsDnTvkh.js +1 -0
  11. package/dist/assets/{chunk-2Q5K7J3B-BsjtgrSL.js → chunk-2Q5K7J3B-3QSh6sI7.js} +1 -1
  12. package/dist/assets/{chunk-32BRIVSS-DpImULWy.js → chunk-32BRIVSS-BiH5Di_y.js} +1 -1
  13. package/dist/assets/{chunk-5VM5RSS4-DTdG5JnR.js → chunk-5VM5RSS4-C0EfjFLd.js} +1 -1
  14. package/dist/assets/{chunk-EX3LRPZG-Cj1jNuKY.js → chunk-EX3LRPZG-BGyfmVm-.js} +1 -1
  15. package/dist/assets/{chunk-JWPE2WC7-DwQ0wwWn.js → chunk-JWPE2WC7-Ba0mrNlg.js} +1 -1
  16. package/dist/assets/{chunk-MOJQB5TN-DZZa7unc.js → chunk-MOJQB5TN-D3QO9EzH.js} +1 -1
  17. package/dist/assets/{chunk-RYQCIY6F-CgdjqYQj.js → chunk-RYQCIY6F-D1uvNA6d.js} +1 -1
  18. package/dist/assets/{chunk-V7JOEXUC-DrPIj6XI.js → chunk-V7JOEXUC-fihlnodT.js} +1 -1
  19. package/dist/assets/{chunk-VR4S4FIN-BBUOVC5W.js → chunk-VR4S4FIN-nmRUEo1H.js} +1 -1
  20. package/dist/assets/{chunk-XXDRQBXY-BEIiZ6gS.js → chunk-XXDRQBXY-CM693yEg.js} +1 -1
  21. package/dist/assets/classDiagram-OUVF2IWQ-DO-wdSFZ.js +1 -0
  22. package/dist/assets/classDiagram-v2-EOCWNBFH-DO-wdSFZ.js +1 -0
  23. package/dist/assets/{cose-bilkent-JH36ORCC-ClFTQEyD.js → cose-bilkent-JH36ORCC-CkacPn8W.js} +1 -1
  24. package/dist/assets/{cynefin-VYW2F7L2-5Bp7xFO0.js → cynefin-VYW2F7L2-BiHaIptG.js} +1 -1
  25. package/dist/assets/{cynefinDiagram-TSTJHNR4-C-0QlSda.js → cynefinDiagram-TSTJHNR4-CdKxMmiy.js} +1 -1
  26. package/dist/assets/{dagre-VKFMJZFB-DLkqsfBy.js → dagre-VKFMJZFB-BQLY5y_W.js} +1 -1
  27. package/dist/assets/{diagram-FQU43EPY-Cwh_tJ_r.js → diagram-FQU43EPY-D7uMBHvq.js} +1 -1
  28. package/dist/assets/{diagram-G47NLZAW-CvvAjulf.js → diagram-G47NLZAW-B3Z1cuH7.js} +1 -1
  29. package/dist/assets/{diagram-NH7WQ7WH-CCoyx8_e.js → diagram-NH7WQ7WH-BZyRD45e.js} +1 -1
  30. package/dist/assets/{diagram-OA4YK3LP-DfjhKYCp.js → diagram-OA4YK3LP-DcThOTt7.js} +1 -1
  31. package/dist/assets/{diagram-WEI45ONY-Vjz0ygfB.js → diagram-WEI45ONY-BcgRAkqY.js} +1 -1
  32. package/dist/assets/{ebnfDiagram-CCIWWBDH-GsA8c6g2.js → ebnfDiagram-CCIWWBDH-CGwfO_xH.js} +1 -1
  33. package/dist/assets/{erDiagram-Q63AITRT-Cc0lJ9sQ.js → erDiagram-Q63AITRT-pV58-Ncc.js} +1 -1
  34. package/dist/assets/{flowDiagram-23GEKE2U-C_sotK-k.js → flowDiagram-23GEKE2U-DJ_SqE8h.js} +1 -1
  35. package/dist/assets/{ganttDiagram-NO4QXBWP-BBtu_WUe.js → ganttDiagram-NO4QXBWP-Dgy0Iyss.js} +1 -1
  36. package/dist/assets/{gitGraphDiagram-IHSO6WYX-BV_aSB8-.js → gitGraphDiagram-IHSO6WYX-CbiZw9fb.js} +1 -1
  37. package/dist/assets/{index-CKfOVv2N.js → index-DZTJEk-y.js} +2 -2
  38. package/dist/assets/{infoDiagram-FWYZ7A6U-DMAvNGtX.js → infoDiagram-FWYZ7A6U-CtsyyEc-.js} +1 -1
  39. package/dist/assets/{ishikawaDiagram-FXEZZL3T-DfQB91MZ.js → ishikawaDiagram-FXEZZL3T-BJilNFkK.js} +1 -1
  40. package/dist/assets/{journeyDiagram-5HDEW3XC-Bq4Llm9o.js → journeyDiagram-5HDEW3XC-C2UCMP4t.js} +1 -1
  41. package/dist/assets/{kanban-definition-HUTT4EX6-DTXHiDiE.js → kanban-definition-HUTT4EX6-DmLDJBRy.js} +1 -1
  42. package/dist/assets/{linear-BQSYZcVg.js → linear-C1paCqE7.js} +1 -1
  43. package/dist/assets/{mindmap-definition-LN4V7U3C-KuH2NIj0.js → mindmap-definition-LN4V7U3C-CX-RxKVn.js} +1 -1
  44. package/dist/assets/{pegDiagram-2B236MQR-BKoNGQFr.js → pegDiagram-2B236MQR-CTU32H2W.js} +1 -1
  45. package/dist/assets/{pieDiagram-ENE6RG2P-EpYv162Y.js → pieDiagram-ENE6RG2P-D8L1aYoo.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-ABIIQ3AL-C9deUjQ2.js → quadrantDiagram-ABIIQ3AL-hQ3bXosy.js} +1 -1
  47. package/dist/assets/{railroadDiagram-RFXS5EU6-Csqw2hK9.js → railroadDiagram-RFXS5EU6--_vuYcda.js} +1 -1
  48. package/dist/assets/{requirementDiagram-TGXJPOKE-DCuz3emr.js → requirementDiagram-TGXJPOKE-C6h_m3Az.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-HTMAVEWB-BTeTL24f.js → sankeyDiagram-HTMAVEWB-Dpc7CfIQ.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-DBY2YBRQ-Cf1ZkTwO.js → sequenceDiagram-DBY2YBRQ-CK5ZzbvJ.js} +1 -1
  51. package/dist/assets/{sizeCapture-X5ZJPWSS-D3Lbewzg.js → sizeCapture-X5ZJPWSS-BAxVvM9G.js} +1 -1
  52. package/dist/assets/{stateDiagram-2N3HPSRC-O8l37yJI.js → stateDiagram-2N3HPSRC-7j33_2PY.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-6OUMAXLB-GviN0FpJ.js +1 -0
  54. package/dist/assets/{swimlanes-5IMT3BWC-BikmTh8b.js → swimlanes-5IMT3BWC-CBG2yOob.js} +2 -2
  55. package/dist/assets/swimlanesDiagram-G3AALYLV-D0wMoxl1.js +8 -0
  56. package/dist/assets/{timeline-definition-FHXFAJF6-BOxSosG0.js → timeline-definition-FHXFAJF6-BOnZkQvc.js} +1 -1
  57. package/dist/assets/{vennDiagram-L72KCM5P-BSClOErZ.js → vennDiagram-L72KCM5P-D52NhIfj.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-EHGQE667-DHFRjOK0.js → wardleyDiagram-EHGQE667-YoPY2ZU-.js} +1 -1
  59. package/dist/assets/{xychartDiagram-FW5EYKEG-Dh1v4x-O.js → xychartDiagram-FW5EYKEG-BanNfgtO.js} +1 -1
  60. package/dist/index.html +1 -1
  61. package/lib/build-all.js +0 -5
  62. package/lib/build-stream-schema.js +1 -1
  63. package/lib/build.js +1809 -2646
  64. package/lib/consumer-fanout.js +1317 -0
  65. package/lib/escalation.js +69 -0
  66. package/lib/feature-validator.js +6 -4
  67. package/lib/feature-writer.js +58 -3
  68. package/lib/flow-state.js +15 -14
  69. package/lib/gsd-budget.js +48 -10
  70. package/lib/gsd-prompt.js +3 -4
  71. package/lib/gsd-stuck.js +1 -1
  72. package/lib/gsd.js +303 -149
  73. package/lib/lane-gate.js +200 -0
  74. package/lib/local-claude-connector.js +149 -0
  75. package/lib/new.js +162 -307
  76. package/lib/result-normalizer.js +233 -18
  77. package/lib/review-lenses.js +1 -1
  78. package/lib/step-prompt.js +41 -119
  79. package/lib/stratum-engine.js +297 -0
  80. package/lib/stratum-mcp-client.js +224 -213
  81. package/lib/triage.js +144 -0
  82. package/lib/vocabulary-compliance.js +268 -0
  83. package/lib/vocabulary-inject.js +1 -36
  84. package/package.json +2 -1
  85. package/pipelines/build-quick.stratum.yaml +5 -17
  86. package/pipelines/build.profiles.json +11 -0
  87. package/pipelines/build.stratum.yaml +288 -467
  88. package/pipelines/gsd.stratum.yaml +73 -125
  89. package/pipelines/new.stratum.yaml +68 -149
  90. package/server/build-routes.js +4 -1
  91. package/server/design-routes.js +37 -21
  92. package/server/index.js +13 -21
  93. package/server/lifecycle-guard.js +1 -1
  94. package/server/pipeline-routes.js +113 -31
  95. package/server/stratum-client.js +33 -47
  96. package/server/stratum-sync.js +3 -4
  97. package/server/vision-server.js +1 -1
  98. package/dist/assets/channel-CYErfopw.js +0 -1
  99. package/dist/assets/classDiagram-OUVF2IWQ-qCC_hnXu.js +0 -1
  100. package/dist/assets/classDiagram-v2-EOCWNBFH-qCC_hnXu.js +0 -1
  101. package/dist/assets/stateDiagram-v2-6OUMAXLB-BWqk_9py.js +0 -1
  102. package/dist/assets/swimlanesDiagram-G3AALYLV-C60ICdks.js +0 -8
  103. package/lib/connector-factory-shim.js +0 -167
  104. package/server/agent-mcp.js +0 -10
@@ -13,6 +13,7 @@ import { calculateCost } from './model-pricing.js';
13
13
  import { resolveAgentConfig } from './agent-string.js';
14
14
  import { normalizeReviewResult } from './review-normalize.js';
15
15
  import { KNOWN_VERSIONS } from './build-stream-schema.js';
16
+ import { runLocalClaudeAgent } from './local-claude-connector.js';
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // Error classes
@@ -83,6 +84,65 @@ export function outputFieldsToJsonSchema(outputFields) {
83
84
  };
84
85
  }
85
86
 
87
+ /**
88
+ * Build a nested JSON Schema from a consumer descriptor's contract CLOSURE
89
+ * (`{ root, contracts }`), resolving the engine's type grammar so the agent-facing
90
+ * schema — and thus the ENGINE's strict validation — see the same shapes:
91
+ * - `X?` optional field (omitted from `required`)
92
+ * - `X[]` typed array (recursively; nests as `X[][]`)
93
+ * - `(a|b)[]` enum array
94
+ * - `a|b` enum
95
+ * - `Name` named record reference, resolved against the closure
96
+ * - primitives / `object` / `array` as before
97
+ *
98
+ * The flat `outputFieldsToJsonSchema` only mapped primitives, so a named record
99
+ * (`Artifact`) or typed array (`Artifact[]`) degraded to `{}`, leaving the agent
100
+ * blind to nested fields the engine still requires. Returns null for a malformed
101
+ * closure so callers can fall back to the flat schema.
102
+ */
103
+ export function contractClosureToJsonSchema(closure) {
104
+ if (!closure || typeof closure !== 'object') return null;
105
+ const { root, contracts } = closure;
106
+ if (typeof root !== 'string' || !contracts || typeof contracts !== 'object') return null;
107
+ if (!Object.hasOwn(contracts, root)) return null;
108
+
109
+ function typeToSchema(typeStr, seen) {
110
+ let raw = String(typeStr);
111
+ let optional = false;
112
+ if (raw.endsWith('?')) { optional = true; raw = raw.slice(0, -1); }
113
+
114
+ const enumArray = /^\(([^()]+)\)\[\]$/.exec(raw);
115
+ if (enumArray) {
116
+ return { optional, schema: { type: 'array', items: { enum: enumArray[1].split('|') } } };
117
+ }
118
+ if (raw.endsWith('[]')) {
119
+ return { optional, schema: { type: 'array', items: typeToSchema(raw.slice(0, -2), seen).schema } };
120
+ }
121
+ const lower = raw.toLowerCase();
122
+ if (TYPE_MAP[lower]) return { optional, schema: { ...TYPE_MAP[lower] } };
123
+ if (Object.hasOwn(contracts, raw)) return { optional, schema: buildRecord(raw, seen) };
124
+ if (raw.includes('|')) return { optional, schema: { enum: raw.split('|') } };
125
+ return { optional, schema: {} }; // unknown → unconstrained
126
+ }
127
+
128
+ function buildRecord(name, seen) {
129
+ if (seen.has(name)) return { type: 'object' }; // cycle guard (engine forbids recursion)
130
+ const fields = contracts[name];
131
+ if (!fields || typeof fields !== 'object') return { type: 'object' };
132
+ const nextSeen = new Set(seen).add(name);
133
+ const properties = {};
134
+ const required = [];
135
+ for (const [field, typeStr] of Object.entries(fields)) {
136
+ const { schema, optional } = typeToSchema(typeStr, nextSeen);
137
+ properties[field] = schema;
138
+ if (!optional) required.push(field);
139
+ }
140
+ return { type: 'object', required, properties };
141
+ }
142
+
143
+ return buildRecord(root, new Set());
144
+ }
145
+
86
146
  // ---------------------------------------------------------------------------
87
147
  // JSON extraction helpers
88
148
  // ---------------------------------------------------------------------------
@@ -150,6 +210,20 @@ export class AgentTimeoutError extends Error {
150
210
  }
151
211
  }
152
212
 
213
+ /**
214
+ * D3: raised when an `onAgentEvent` observer asks to stop an in-flight agent run
215
+ * (e.g. the GSD stuck detector tripped mid-execution). Carries the observer's
216
+ * reason so the caller can render a diagnostic and halt.
217
+ */
218
+ export class AgentAbortedError extends Error {
219
+ constructor(stepId, reason) {
220
+ super(`Agent run on step "${stepId}" aborted by observer`);
221
+ this.name = 'AgentAbortedError';
222
+ this.stepId = stepId;
223
+ this.reason = reason;
224
+ }
225
+ }
226
+
153
227
  /**
154
228
  * STRAT-DEDUP-AGENTRUN-V3: `runAndNormalize` is now a thin wrapper around the
155
229
  * Python connector tier exposed through `stratum_agent_run`. Events arrive as
@@ -178,13 +252,35 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
178
252
 
179
253
  const stepId = stepDispatch.step_id ?? 'unknown';
180
254
  const agentType = stepDispatch.agent ?? 'claude';
181
- const cfg = resolveAgentConfig(agentType);
255
+ // D6: the engine ships only the bare provider literal (stepDispatch.agent), so
256
+ // the full profile string (with tool restrictions + model tier) is supplied
257
+ // compose-side via opts.profile, keyed off the compose-owned sidecar. It
258
+ // overrides the bare literal for capability resolution; the provider is
259
+ // unchanged. Absent → bare literal (no restrictions), preserving old behavior.
260
+ const cfg = resolveAgentConfig(opts.profile || agentType);
261
+ // A read-only profile (no Edit/Write/Bash) also maps to a read-only sandbox so
262
+ // the restriction binds at the engine's connector, not just at this invocation.
263
+ const readOnlyProfile = Array.isArray(cfg.disallowedTools)
264
+ && ['Edit', 'Write'].every((tool) => cfg.disallowedTools.includes(tool));
265
+ const sandboxMode = readOnlyProfile ? 'read-only' : undefined;
182
266
 
183
267
  const outputFields = stepDispatch.output_fields;
184
268
  const hasSchema = outputFields && typeof outputFields === 'object' && Object.keys(outputFields).length > 0;
185
269
  let actualPrompt = prompt;
186
270
  let schema = null;
187
- if (hasSchema) {
271
+ // Consumer-fanout dispatch carries the full contract CLOSURE so nested named
272
+ // records and typed arrays reach the agent (and match the engine's strict
273
+ // validation). Fall back to the flat field→primitive schema otherwise.
274
+ const closureSchema = contractClosureToJsonSchema(stepDispatch.output_contract_closure);
275
+ // A valid but EMPTY root contract ({}) yields zero fields, so `hasSchema` is
276
+ // false while a real (empty-object) closure schema exists. Structured
277
+ // extraction must be gated on "a contract is declared", not "the root has
278
+ // fields" — otherwise the agent's `{}` is discarded and the item wedges.
279
+ const hasStructuredOutput = Boolean(closureSchema) || hasSchema;
280
+ if (closureSchema) {
281
+ schema = closureSchema;
282
+ actualPrompt = injectSchema(prompt, schema);
283
+ } else if (hasSchema) {
188
284
  schema = outputFieldsToJsonSchema(outputFields);
189
285
  actualPrompt = injectSchema(prompt, schema);
190
286
  }
@@ -206,6 +302,25 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
206
302
  let timedOut = false;
207
303
  let userInterruptAction = null;
208
304
  let timeoutHandle = null;
305
+ // D3: an onAgentEvent observer (e.g. the GSD stuck detector) may ask to stop
306
+ // the run mid-stream by returning a truthy reason from a tool event.
307
+ let abortReason = null;
308
+
309
+ // V2/V3: CONTROLLED claude executions (consumer items, review fanout) run via
310
+ // the compose-LOCAL connector — the only seam that can enforce claude tool
311
+ // restrictions AND be interrupted (the sync engine agent_run can do neither;
312
+ // its background mode is codex+read-only-only). Abort replaces cancelAgentRun
313
+ // as the stop mechanism, and the connector streams real tool events so the
314
+ // stuck detector / timeout actually stop a runaway agent.
315
+ const useLocalClaude = opts.localExecution === true && cfg.provider === 'claude';
316
+ const abortController = useLocalClaude ? new AbortController() : null;
317
+ const stopRun = () => {
318
+ if (abortController) {
319
+ try { abortController.abort(); } catch { /* already aborted */ }
320
+ } else {
321
+ stratum.cancelAgentRun(correlationId).catch(() => {});
322
+ }
323
+ };
209
324
 
210
325
  // Subscribe BEFORE calling agentRun — events fire during the call.
211
326
  const unsub = stratum.onEvent(correlationId, subStepId, (env) => {
@@ -271,12 +386,22 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
271
386
  default:
272
387
  break;
273
388
  }
389
+ // D3: let an observer inspect every processed envelope and request a stop
390
+ // (returns a truthy reason). Cancel the in-flight run once; the post-run
391
+ // guard rethrows as AgentAbortedError.
392
+ if (opts.onAgentEvent) {
393
+ const stop = opts.onAgentEvent(env);
394
+ if (stop && !abortReason) {
395
+ abortReason = stop;
396
+ stopRun();
397
+ }
398
+ }
274
399
  });
275
400
 
276
401
  if (maxDurationMs) {
277
402
  timeoutHandle = setTimeout(() => {
278
403
  timedOut = true;
279
- stratum.cancelAgentRun(correlationId).catch(() => {});
404
+ stopRun();
280
405
  }, maxDurationMs);
281
406
  }
282
407
 
@@ -284,34 +409,118 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
284
409
  if (progress?.on) {
285
410
  onInterrupt = () => {
286
411
  userInterruptAction = progress.consumeAction?.() ?? 'skip';
287
- stratum.cancelAgentRun(correlationId).catch(() => {});
412
+ stopRun();
288
413
  };
289
414
  progress.on('interrupt', onInterrupt);
290
415
  }
291
416
 
417
+ // V2/V3: on the local path the connector's tool_use events (not engine
418
+ // progress notifications) drive narration + the stuck detector. This bridge
419
+ // mirrors the engine onEvent handler's tool handling and lets the observer
420
+ // abort a spinning run.
421
+ const localOnToolUse = ({ tool, input }) => {
422
+ if (onToolUse) onToolUse({ tool, input, timestamp: Date.now() });
423
+ if (streamWriter) streamWriter.write({ type: 'tool_use', tool, input: input ?? {} });
424
+ if (progress) progress.toolUse(tool, input?.command ?? input?.pattern ?? input?.file_path ?? '');
425
+ if (opts.onAgentEvent && !abortReason) {
426
+ const env = { schema_version: '0.2.6', kind: 'tool_use_summary', metadata: { tool, input: input ?? {}, summary: '', output: '' } };
427
+ const stop = opts.onAgentEvent(env);
428
+ if (stop) { abortReason = stop; stopRun(); }
429
+ }
430
+ };
431
+
292
432
  let runResult;
293
433
  try {
294
- runResult = await stratum.agentRun(agentType, actualPrompt, {
295
- modelID: cfg.modelID ?? undefined,
296
- allowedTools: cfg.allowedTools ?? undefined,
297
- disallowedTools: cfg.disallowedTools ?? undefined,
298
- thinking: cfg.thinking ?? undefined,
299
- effort: cfg.effort ?? undefined,
300
- cwd: opts.cwd ?? undefined,
301
- correlationId,
302
- });
434
+ if (useLocalClaude) {
435
+ // Test seam: an installed factory shim exposes an SDK-shaped query adapter
436
+ // so the goldens drive the local path without spawning a real claude.
437
+ // Gated on NODE_ENV=test so production always uses the real SDK.
438
+ const localQuery = opts.localQuery
439
+ ?? (process.env.NODE_ENV === 'test' && stratum ? stratum._localQuery : undefined);
440
+ runResult = await runLocalClaudeAgent(actualPrompt, {
441
+ cwd: opts.cwd ?? undefined,
442
+ model: cfg.modelID ?? undefined,
443
+ allowedTools: cfg.allowedTools ?? undefined,
444
+ disallowedTools: cfg.disallowedTools ?? undefined,
445
+ thinking: cfg.thinking ?? undefined,
446
+ abortController,
447
+ onToolUse: localOnToolUse,
448
+ ...(localQuery ? { query: localQuery } : {}),
449
+ });
450
+ } else {
451
+ runResult = await stratum.agentRun(agentType, actualPrompt, {
452
+ modelID: cfg.modelID ?? undefined,
453
+ allowedTools: cfg.allowedTools ?? undefined,
454
+ disallowedTools: cfg.disallowedTools ?? undefined,
455
+ thinking: cfg.thinking ?? undefined,
456
+ effort: cfg.effort ?? undefined,
457
+ sandboxMode,
458
+ cwd: opts.cwd ?? undefined,
459
+ correlationId,
460
+ });
461
+ }
303
462
  } catch (err) {
304
- if (timedOut) throw new AgentTimeoutError(stepId, Date.now() - startTime);
463
+ // F3/G3: preserve any billable usage the failed run reported (the local
464
+ // connector attaches it on a non-success result / usage-bearing rejection) so
465
+ // the consumer failure envelope can still debit the engine/GSD ledgers. G3:
466
+ // the timeout/abort throws happen here too — attach the usage to THOSE errors
467
+ // (not only the generic AgentError) so timeout/stuck attempts are billed.
468
+ const errUsage = (err && typeof err === 'object' && err.usage) ? err.usage : null;
469
+ if (timedOut) {
470
+ const e = new AgentTimeoutError(stepId, Date.now() - startTime);
471
+ if (errUsage) e.usage = errUsage;
472
+ throw e;
473
+ }
305
474
  if (userInterruptAction) throw new UserInterruptError(stepId, userInterruptAction);
306
- throw new AgentError(err?.message ?? 'Agent run failed');
475
+ if (abortReason) {
476
+ const e = new AgentAbortedError(stepId, abortReason);
477
+ if (errUsage) e.usage = errUsage;
478
+ throw e;
479
+ }
480
+ const agentError = new AgentError(err?.message ?? 'Agent run failed');
481
+ if (errUsage) agentError.usage = errUsage;
482
+ throw agentError;
307
483
  } finally {
308
484
  if (timeoutHandle) clearTimeout(timeoutHandle);
309
485
  if (onInterrupt && progress?.removeListener) progress.removeListener('interrupt', onInterrupt);
310
486
  unsub();
311
487
  }
312
488
 
313
- if (timedOut) throw new AgentTimeoutError(stepId, Date.now() - startTime);
489
+ // H2: when the underlying run RESOLVES late (rather than rejecting) after a
490
+ // timeout/abort fired, its billable usage is on runResult.usage. Copy it onto
491
+ // the thrown error — the same single channel the rejection path (G3) uses — so
492
+ // the consumer timeout envelope / stuck-ledger accounting still bill the attempt.
493
+ // Throwing here means usageTotals is never returned, so this is the ONLY channel
494
+ // (no double count).
495
+ const lateUsage = (runResult && typeof runResult === 'object' && runResult.usage && typeof runResult.usage === 'object')
496
+ ? runResult.usage
497
+ : null;
498
+ if (timedOut) {
499
+ const e = new AgentTimeoutError(stepId, Date.now() - startTime);
500
+ if (lateUsage) e.usage = lateUsage;
501
+ throw e;
502
+ }
314
503
  if (userInterruptAction) throw new UserInterruptError(stepId, userInterruptAction);
504
+ if (abortReason) {
505
+ const e = new AgentAbortedError(stepId, abortReason);
506
+ if (lateUsage) e.usage = lateUsage;
507
+ throw e;
508
+ }
509
+
510
+ // D2(b): the TS agent_run path returns a synchronous `complete` envelope with
511
+ // aggregate usage ({usd?, tokens, ms}) and streams NO step_usage progress
512
+ // events — without folding it in, budget accounting debits nothing on the TS
513
+ // route. The python / factory-shim path streams step_usage events (usageTotals
514
+ // already populated), so adopt runResult.usage only when the event stream
515
+ // contributed nothing (avoids double counting).
516
+ const runUsage = runResult && typeof runResult === 'object' ? runResult.usage : null;
517
+ const usageFromEvents = usageTotals.input_tokens || usageTotals.output_tokens || usageTotals.cost_usd;
518
+ if (runUsage && typeof runUsage === 'object' && !usageFromEvents) {
519
+ if (typeof runUsage.tokens === 'number') usageTotals.output_tokens += runUsage.tokens;
520
+ if (typeof runUsage.usd === 'number') usageTotals.cost_usd += runUsage.usd;
521
+ if (typeof runUsage.ms === 'number') usageTotals.duration_ms = (usageTotals.duration_ms ?? 0) + runUsage.ms;
522
+ if (!usageTotals.model && runResult.telemetry?.model) usageTotals.model = runResult.telemetry.model;
523
+ }
315
524
 
316
525
  const text = (runResult && typeof runResult.text === 'string' && runResult.text.length > 0)
317
526
  ? runResult.text
@@ -350,7 +559,7 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
350
559
  return { text, result: reviewResult, usage: usageTotals };
351
560
  }
352
561
 
353
- if (!hasSchema) {
562
+ if (!hasStructuredOutput) {
354
563
  return { text, result: null, usage: usageTotals };
355
564
  }
356
565
 
@@ -362,7 +571,13 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
362
571
  process.stderr.write(' ⚠ Could not extract JSON from agent output, using fallback\n');
363
572
  }
364
573
  const summary = text.slice(0, 200).replace(/\n/g, ' ').trim();
365
- return { text, result: { summary: summary || 'Step complete' }, usage: usageTotals };
574
+ const normalizationFailure = summary || 'Could not extract structured output';
575
+ return {
576
+ text,
577
+ result: { summary: normalizationFailure },
578
+ usage: usageTotals,
579
+ normalizationFailure,
580
+ };
366
581
  }
367
582
 
368
583
  return { text, result, usage: usageTotals };
@@ -158,7 +158,7 @@ export function shouldRunCrossModel(filesChanged, lineCount = null) {
158
158
  * Triage: decide which lenses to activate.
159
159
  *
160
160
  * @param {string[]} fileList - list of changed file paths
161
- * @returns {Array<object>} LensTask[] for parallel_dispatch
161
+ * @returns {Array<object>} lens items for TS consumer fanout
162
162
  */
163
163
  export function triageLenses(fileList) {
164
164
  const activeLensIds = [...BASELINE_LENSES];
@@ -71,6 +71,36 @@ export function buildStepPrompt(stepDispatch, context) {
71
71
 
72
72
  sections.push(`## Intent\n${stepDispatch.intent}`);
73
73
 
74
+ // D7(a): restore the exact TaskResult filename contract. The engine renders
75
+ // only whole `${item}` (dotted `${item.id}` is REF_INVALID), so the per-item
76
+ // output path is rendered compose-side from the item's id. When the consumer
77
+ // loop supplies it, spell out the exact path so the agent cannot misname the
78
+ // file the blackboard reader expects.
79
+ if (context.taskResultPath) {
80
+ sections.push(
81
+ `## TaskResult Output File\n`
82
+ + `Write your TaskResult JSON to EXACTLY this path (do not choose a different name):\n`
83
+ + `\`${context.taskResultPath}\``,
84
+ );
85
+ }
86
+
87
+ // A retried issuance (e.g. a consumer-fanout item that failed its contract,
88
+ // ensure, or connector on a prior attempt) carries the engine's structured
89
+ // previousFailure. Render its reason so the retry can fix the cause rather
90
+ // than repeat it.
91
+ if (stepDispatch.previousFailure) {
92
+ const pf = stepDispatch.previousFailure;
93
+ const reason = typeof pf === 'string'
94
+ ? pf
95
+ : (typeof pf.reason === 'string' ? pf.reason : JSON.stringify(pf));
96
+ if (reason) {
97
+ const attemptNote = typeof pf === 'object' && Number.isInteger(pf.attempt)
98
+ ? ` (attempt ${pf.attempt})`
99
+ : '';
100
+ sections.push(`## Previous Attempt Failed${attemptNote}\nYour previous attempt was rejected. Fix the cause before finishing:\n${reason}`);
101
+ }
102
+ }
103
+
74
104
  sections.push(`## Inputs\n${JSON.stringify(stepDispatch.inputs, null, 2)}`);
75
105
 
76
106
  if (Array.isArray(stepDispatch.output_fields) && stepDispatch.output_fields.length > 0) {
@@ -133,127 +163,19 @@ export function buildStepPrompt(stepDispatch, context) {
133
163
  }
134
164
  }
135
165
 
136
- return sections.join('\n\n');
137
- }
138
-
139
- /**
140
- * Build a "File Ownership Conflicts" section for decompose-step retry prompts.
141
- *
142
- * @param {Array<{task_a: string, task_b: string, files: string[]}>} conflicts
143
- * @returns {string}
144
- */
145
- function buildConflictSection(conflicts) {
146
- const lines = [
147
- '## File Ownership Conflicts — Resolution Required',
148
- '',
149
- 'The following task pairs share `files_owned` entries but have no `depends_on`',
150
- 'relationship. Independent tasks may not both claim the same file.',
151
- 'Add a `depends_on` edge from the later task to the earlier task to resolve each conflict:',
152
- '',
153
- ];
154
-
155
- for (const { task_a, task_b, files } of conflicts) {
156
- lines.push(`- **${task_a}** and **${task_b}** both own:`);
157
- for (const f of files) lines.push(` - \`${f}\``);
158
- lines.push(` → Add \`depends_on: [${task_a}]\` to \`${task_b}\` (or vice versa).`);
159
- lines.push('');
160
- }
161
-
162
- return lines.join('\n');
163
- }
164
-
165
- // COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY: pre-merge bounce context (gate_failed /
166
- // merge_conflict) is injected into a re-dispatched task's prompt on BOTH paths:
167
- // - server-dispatch: SERVER-SIDE in Stratum's ParallelExecutor._render_prompt
168
- // (the server re-resolves the task list from flow state on each re-dispatch).
169
- // - consumer-dispatch: COMPOSE-SIDE via formatBounceForPrompt() below, appended
170
- // in executeParallelDispatch's retry loop (Compose, not Stratum, builds the
171
- // per-task prompt on that path). This is the Compose mirror of
172
- // parallel_exec.py `_format_bounce_for_prompt`.
173
-
174
- /**
175
- * Format a ParMergeBounce record (contracts/par-merge-bounce.json) as a prompt
176
- * section telling a re-dispatched task its prior attempt was rejected before
177
- * merge. Mirrors Stratum's `_format_bounce_for_prompt` (parallel_exec.py).
178
- * Degrades to '' on null/non-object input so a malformed bounce never breaks a
179
- * re-run prompt.
180
- *
181
- * @param {{task_id?:string, reason?:string, command?:string|null,
182
- * exit_code?:number|null, files?:string[], excerpt?:string}} bounce
183
- * @returns {string}
184
- */
185
- export function formatBounceForPrompt(bounce) {
186
- if (!bounce || typeof bounce !== 'object') return '';
187
- const files = Array.isArray(bounce.files) && bounce.files.length
188
- ? bounce.files.join(', ')
189
- : '(none reported)';
190
- const lines = ['## Previous attempt was rejected before merge — fix this before finishing'];
191
- if (bounce.reason === 'gate_failed') {
192
- const code = bounce.exit_code == null ? '?' : String(bounce.exit_code);
193
- lines.push(`Your last attempt FAILED the pre-merge gate \`${bounce.command ?? '?'}\` (exit ${code}). It was not merged.`);
194
- } else if (bounce.reason === 'merge_conflict') {
195
- lines.push("Your last attempt produced changes that CONFLICTED with another task's changes at merge time. It was not merged.");
196
- } else {
197
- lines.push('Your last attempt was rejected before merge.');
198
- }
199
- lines.push(`Files involved: ${files}`);
200
- if (bounce.excerpt) {
201
- lines.push('Failure output:', '```', String(bounce.excerpt), '```');
202
- }
203
- return lines.join('\n');
204
- }
205
-
206
- /**
207
- * Build a retry prompt when postconditions failed.
208
- *
209
- * @param {object} stepDispatch - Original step dispatch
210
- * @param {string[]} violations - List of postcondition violations
211
- * @param {object} context - Execution context
212
- * @param {Array<{task_a, task_b, files}>} [conflicts] - Structured file conflicts (optional)
213
- * @returns {string}
214
- */
215
- export function buildRetryPrompt(stepDispatch, violations, context, conflicts) {
216
- const violationLines = violations.map(v => `- ${v}`).join('\n');
217
- const header = `RETRY — Previous attempt failed postconditions:\n${violationLines}\n\nFix these issues and try again.`;
218
-
219
- const sections = [header, buildStepPrompt(stepDispatch, context)];
220
-
221
- if (conflicts && conflicts.length > 0) {
222
- sections.push(buildConflictSection(conflicts));
223
- }
224
-
225
- let prompt = sections.join('\n\n');
226
-
227
- // COMP-FIX-HARD T6: in bug-mode diagnose retries, prepend a digest of
228
- // previously rejected hypotheses so the next attempt avoids dead ends.
229
- // Guard: silent no-op if any precondition fails (regression-safe).
230
- if (
231
- context && context.mode === 'bug'
232
- && stepDispatch && stepDispatch.step_id === 'diagnose'
233
- && context.bug_code && context.cwd
234
- ) {
235
- try {
236
- const entries = readHypotheses(context.cwd, context.bug_code);
237
- const block = formatRejectedHypotheses(entries);
238
- if (block) prompt = block + '\n' + prompt;
239
- } catch {
240
- // best-effort: never let ledger I/O break a retry
241
- }
166
+ // COMP-FIX-HARD T6: on a bug-mode DIAGNOSE (re)attempt, surface the previously
167
+ // REJECTED hypotheses from the per-bug ledger at the very TOP of the prompt so
168
+ // the agent does not re-propose a diagnosis already ruled out. The TS diagnose
169
+ // retry reissues through buildStepPrompt (there is no separate buildRetryPrompt
170
+ // in v1), so the ledger context which previousFailure.reason alone dropped —
171
+ // is restored here. Only bug mode + the diagnose step + a populated ledger of
172
+ // rejected entries renders anything; every other step is byte-identical.
173
+ if (context.mode === 'bug' && context.bug_code && stepDispatch.step_id === 'diagnose') {
174
+ const rejectedBlock = formatRejectedHypotheses(readHypotheses(context.cwd, context.bug_code));
175
+ if (rejectedBlock) sections.unshift(rejectedBlock.trimEnd());
242
176
  }
243
177
 
244
- return prompt;
245
- }
246
-
247
- /**
248
- * Build a prompt for a child flow step within a larger workflow.
249
- *
250
- * @param {object} flowDispatch - Flow dispatch (child_flow_name, child_step)
251
- * @param {object} context - Execution context
252
- * @returns {string}
253
- */
254
- export function buildFlowStepPrompt(flowDispatch, context) {
255
- const header = `You are executing a sub-workflow "${flowDispatch.child_flow_name}" as part of a larger workflow.`;
256
- return `${header}\n\n${buildStepPrompt(flowDispatch.child_step, context)}`;
178
+ return sections.join('\n\n');
257
179
  }
258
180
 
259
181
  /**