@specverse/engines 6.21.2 → 6.27.10

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 (59) hide show
  1. package/dist/ai/analyse-runner.d.ts +16 -0
  2. package/dist/ai/analyse-runner.d.ts.map +1 -1
  3. package/dist/ai/analyse-runner.js +417 -53
  4. package/dist/ai/analyse-runner.js.map +1 -1
  5. package/dist/ai/microcall-orchestrator.d.ts +187 -0
  6. package/dist/ai/microcall-orchestrator.d.ts.map +1 -0
  7. package/dist/ai/microcall-orchestrator.js +673 -0
  8. package/dist/ai/microcall-orchestrator.js.map +1 -0
  9. package/dist/ai/skeleton-emitter.d.ts +94 -0
  10. package/dist/ai/skeleton-emitter.d.ts.map +1 -0
  11. package/dist/ai/skeleton-emitter.js +752 -0
  12. package/dist/ai/skeleton-emitter.js.map +1 -0
  13. package/dist/analyse-prepass/adapters/express-routes.d.ts +71 -0
  14. package/dist/analyse-prepass/adapters/express-routes.d.ts.map +1 -0
  15. package/dist/analyse-prepass/adapters/express-routes.js +329 -0
  16. package/dist/analyse-prepass/adapters/express-routes.js.map +1 -0
  17. package/dist/analyse-prepass/adapters/typescript-interfaces.d.ts +91 -0
  18. package/dist/analyse-prepass/adapters/typescript-interfaces.d.ts.map +1 -0
  19. package/dist/analyse-prepass/adapters/typescript-interfaces.js +411 -0
  20. package/dist/analyse-prepass/adapters/typescript-interfaces.js.map +1 -0
  21. package/dist/analyse-prepass/backends/gitnexus.d.ts.map +1 -1
  22. package/dist/analyse-prepass/backends/gitnexus.js +36 -8
  23. package/dist/analyse-prepass/backends/gitnexus.js.map +1 -1
  24. package/dist/analyse-prepass/backends/index.d.ts.map +1 -1
  25. package/dist/analyse-prepass/backends/index.js +3 -5
  26. package/dist/analyse-prepass/backends/index.js.map +1 -1
  27. package/dist/analyse-prepass/behavior-step-classifier.d.ts +3 -0
  28. package/dist/analyse-prepass/behavior-step-classifier.d.ts.map +1 -1
  29. package/dist/analyse-prepass/behavior-step-classifier.js +1 -0
  30. package/dist/analyse-prepass/behavior-step-classifier.js.map +1 -1
  31. package/dist/analyse-prepass/index.d.ts +69 -0
  32. package/dist/analyse-prepass/index.d.ts.map +1 -1
  33. package/dist/analyse-prepass/index.js +385 -17
  34. package/dist/analyse-prepass/index.js.map +1 -1
  35. package/dist/analyse-prepass/method-body-walker.d.ts +4 -0
  36. package/dist/analyse-prepass/method-body-walker.d.ts.map +1 -1
  37. package/dist/analyse-prepass/method-body-walker.js +14 -0
  38. package/dist/analyse-prepass/method-body-walker.js.map +1 -1
  39. package/dist/audit/realize-recorder.d.ts +164 -0
  40. package/dist/audit/realize-recorder.d.ts.map +1 -0
  41. package/dist/audit/realize-recorder.js +153 -0
  42. package/dist/audit/realize-recorder.js.map +1 -0
  43. package/dist/audit/verify-checks.d.ts +32 -0
  44. package/dist/audit/verify-checks.d.ts.map +1 -0
  45. package/dist/audit/verify-checks.js +202 -0
  46. package/dist/audit/verify-checks.js.map +1 -0
  47. package/dist/audit/verify-recorder.d.ts +84 -0
  48. package/dist/audit/verify-recorder.d.ts.map +1 -0
  49. package/dist/audit/verify-recorder.js +90 -0
  50. package/dist/audit/verify-recorder.js.map +1 -0
  51. package/dist/inference/core/specly-converter.d.ts.map +1 -1
  52. package/dist/inference/core/specly-converter.js +67 -36
  53. package/dist/inference/core/specly-converter.js.map +1 -1
  54. package/dist/libs/instance-factories/services/templates/_shared/step-matching.js +39 -15
  55. package/dist/realize/index.d.ts.map +1 -1
  56. package/dist/realize/index.js +63 -0
  57. package/dist/realize/index.js.map +1 -1
  58. package/libs/instance-factories/services/templates/_shared/step-matching.ts +61 -16
  59. package/package.json +1 -1
@@ -0,0 +1,673 @@
1
+ /**
2
+ * Per-action LLM micro-call orchestrator. Phase B of the
3
+ * 2026-05-04-ANALYSE-VIA-FAITHFUL-SKELETON-AND-MICROCALLS plan.
4
+ *
5
+ * Architecture:
6
+ * - Skeleton emitter (Phase A) produced skeleton + actionStubs[]
7
+ * - This orchestrator walks actionStubs[], firing a focused LLM call
8
+ * per stub in parallel (with a concurrency cap)
9
+ * - Plus a single manifest call running alongside
10
+ * - Each call's prompt + response + parsed output + latency is
11
+ * forward-logged to MicrocallProvenance
12
+ * - Final assembly merges skeleton + filled action bodies + manifest
13
+ *
14
+ * Forward-logging discipline: every LLM call records WHICH stub it
15
+ * filled, the assembled prompt, the raw response, the parsed body, and
16
+ * timing metadata. The report assembler reads provenance directly. No
17
+ * heuristic matching of LLM output to spec sections.
18
+ */
19
+ import { runPrompt } from './prompt-runner.js';
20
+ const DEFAULT_CONCURRENCY = 10;
21
+ const DEFAULT_TIMEOUT_MS = 5 * 60_000;
22
+ /**
23
+ * Run the per-action micro-call pass + manifest call.
24
+ * Returns the filled action bodies indexed by yamlPath plus the
25
+ * manifest yaml string (when fired) plus full provenance.
26
+ */
27
+ export async function runActionMicrocalls(opts) {
28
+ const startedAt = new Date().toISOString();
29
+ const t0 = Date.now();
30
+ const stubs = opts.skeletonProvenance.actionStubs;
31
+ const concurrencyCap = opts.concurrencyCap ?? DEFAULT_CONCURRENCY;
32
+ const timeoutMs = opts.timeoutMsPerCall ?? DEFAULT_TIMEOUT_MS;
33
+ const runPromptFn = opts.runPromptOverride ?? runPrompt;
34
+ // Skeleton context for per-action prompts: pass the full skeleton.
35
+ // It's only ~10KB even on idle-meta. Trimming to a subtree adds
36
+ // complexity (cross-entity refs need full context) for marginal savings.
37
+ const skeletonContext = opts.skeleton;
38
+ const actionBodies = new Map();
39
+ const actionCalls = [];
40
+ // Fire manifest call in parallel with the action calls (when configured).
41
+ const manifestPromise = opts.manifestInputs
42
+ ? runManifestCall({ ...opts, runPromptFn, timeoutMs })
43
+ : Promise.resolve(null);
44
+ // Concurrency-bounded action calls.
45
+ let inFlight = 0;
46
+ let nextIdx = 0;
47
+ let completed = 0;
48
+ await new Promise((resolve) => {
49
+ if (stubs.length === 0) {
50
+ resolve();
51
+ return;
52
+ }
53
+ const dispatchNext = () => {
54
+ while (inFlight < concurrencyCap && nextIdx < stubs.length) {
55
+ const ordinal = nextIdx;
56
+ const stub = stubs[nextIdx];
57
+ nextIdx++;
58
+ inFlight++;
59
+ runActionCall({
60
+ ordinal, stub, skeletonContext,
61
+ candidateStepsByMethodKey: opts.candidateStepsByMethodKey,
62
+ model: opts.model, sessionId: opts.sessionId, timeoutMs,
63
+ runPromptFn,
64
+ })
65
+ .then((record) => {
66
+ actionCalls.push(record);
67
+ if (record.parsedBody)
68
+ actionBodies.set(stub.yamlPath, record.parsedBody);
69
+ opts.onProgress?.(record);
70
+ })
71
+ .catch((e) => {
72
+ actionCalls.push({
73
+ ordinal,
74
+ stubRef: {
75
+ componentName: stub.componentName,
76
+ ownerKind: stub.ownerKind,
77
+ ownerName: stub.ownerName,
78
+ actionName: stub.actionName,
79
+ yamlPath: stub.yamlPath,
80
+ },
81
+ promptHash: '',
82
+ promptBytes: 0,
83
+ responseBytes: 0,
84
+ latencyMs: 0,
85
+ parsedBody: null,
86
+ failure: e?.message ?? String(e),
87
+ responseTextSnippet: '',
88
+ });
89
+ })
90
+ .finally(() => {
91
+ inFlight--;
92
+ completed++;
93
+ if (completed === stubs.length)
94
+ resolve();
95
+ else
96
+ dispatchNext();
97
+ });
98
+ }
99
+ };
100
+ dispatchNext();
101
+ });
102
+ const manifestRecord = await manifestPromise;
103
+ // Sort actionCalls by ordinal for stable output.
104
+ actionCalls.sort((a, b) => a.ordinal - b.ordinal);
105
+ const endedAt = new Date().toISOString();
106
+ const wallTimeMs = Date.now() - t0;
107
+ const succeeded = actionCalls.filter((c) => c.parsedBody).length;
108
+ const failed = actionCalls.length - succeeded;
109
+ const avgLatency = actionCalls.length > 0
110
+ ? actionCalls.reduce((s, c) => s + c.latencyMs, 0) / actionCalls.length
111
+ : 0;
112
+ return {
113
+ actionBodies,
114
+ manifestYaml: manifestRecord?.manifestYaml ?? null,
115
+ provenance: {
116
+ schemaVersion: '1.0',
117
+ startedAt,
118
+ endedAt,
119
+ wallTimeMs,
120
+ concurrencyCap,
121
+ actionCalls,
122
+ manifestCall: manifestRecord,
123
+ totals: {
124
+ actionCallsAttempted: actionCalls.length,
125
+ actionCallsSucceeded: succeeded,
126
+ actionCallsFailed: failed,
127
+ avgLatencyMsPerCall: Math.round(avgLatency),
128
+ },
129
+ },
130
+ };
131
+ }
132
+ /** Rate-limit retry policy (engines 6.27.8+). The claude-cli provider
133
+ * surfaces `Server is temporarily limiting requests · Rate limited`
134
+ * as a transient hard-fail; without retry we burn the entire microcall
135
+ * budget on the first sustained rate-limit window. Backoff schedule
136
+ * is loosely inspired by the Anthropic SDK's defaults: 5s, 15s, 45s.
137
+ * Caps total retry wait around 65s per call — short enough to
138
+ * recover from a typical 60s rate-limit window, long enough not to
139
+ * pile up on a sustained outage. */
140
+ const RATE_LIMIT_BACKOFF_MS = [5_000, 15_000, 45_000];
141
+ const RATE_LIMIT_MAX_RETRIES = RATE_LIMIT_BACKOFF_MS.length;
142
+ /** True when the error message looks like a transient rate-limit. */
143
+ function isRateLimitError(message) {
144
+ const m = message.toLowerCase();
145
+ return (m.includes('rate limit') ||
146
+ m.includes('temporarily limiting requests') ||
147
+ m.includes('429') ||
148
+ m.includes('quota exceeded'));
149
+ }
150
+ async function runActionCall(p) {
151
+ const t0 = Date.now();
152
+ const key = `${p.stub.candidateMethodRef.className}::${p.stub.candidateMethodRef.methodIndex}`;
153
+ const candidateSteps = p.candidateStepsByMethodKey.get(key) ?? '(no candidate steps)';
154
+ // Action stub yaml — caller assembled the original skeleton; we
155
+ // reproduce a minimal stub here for the prompt.
156
+ const actionStub = `${p.stub.actionName}:\n # source: ${p.stub.sourceLocation.filePath}:${p.stub.sourceLocation.lineRange[0]}-${p.stub.sourceLocation.lineRange[1]}`;
157
+ let promptHash = '';
158
+ let promptBytes = 0;
159
+ let rateLimitRetries = 0;
160
+ let lastError = null;
161
+ // Retry loop: re-fire on rate-limit errors with exponential backoff.
162
+ // Other errors (timeout, parse-fail downstream) bail immediately
163
+ // since they're not transient.
164
+ for (let attempt = 0; attempt <= RATE_LIMIT_MAX_RETRIES; attempt++) {
165
+ try {
166
+ const result = await p.runPromptFn({
167
+ operation: 'analyse-action',
168
+ values: {
169
+ ownerKind: p.stub.ownerKind,
170
+ ownerName: p.stub.ownerName,
171
+ actionName: p.stub.actionName,
172
+ skeletonContext: p.skeletonContext,
173
+ actionStub,
174
+ candidateSteps,
175
+ sourceFile: p.stub.sourceLocation.filePath,
176
+ sourceStartLine: String(p.stub.sourceLocation.lineRange[0]),
177
+ sourceEndLine: String(p.stub.sourceLocation.lineRange[1]),
178
+ sourceBody: '', // future: opt-in source body
179
+ },
180
+ model: p.model,
181
+ timeoutMs: p.timeoutMs,
182
+ onAssembled: ({ system, user }) => {
183
+ promptBytes = (system?.length ?? 0) + (user?.length ?? 0);
184
+ // Lightweight content fingerprint for reproducibility checks.
185
+ promptHash = simpleHash(system + '\0' + user);
186
+ },
187
+ });
188
+ const parsedBody = await parseActionBodyYaml(result.text);
189
+ const latencyMs = Date.now() - t0;
190
+ return {
191
+ ordinal: p.ordinal,
192
+ stubRef: {
193
+ componentName: p.stub.componentName,
194
+ ownerKind: p.stub.ownerKind,
195
+ ownerName: p.stub.ownerName,
196
+ actionName: p.stub.actionName,
197
+ yamlPath: p.stub.yamlPath,
198
+ },
199
+ promptHash,
200
+ promptBytes,
201
+ responseBytes: result.text.length,
202
+ latencyMs,
203
+ parsedBody,
204
+ failure: parsedBody ? null : 'response did not contain a parseable yaml body',
205
+ responseTextSnippet: result.text.slice(0, 4096),
206
+ rateLimitRetries,
207
+ };
208
+ }
209
+ catch (e) {
210
+ lastError = e;
211
+ const errorMessage = e?.message ?? String(e);
212
+ // Only retry on rate-limit errors; everything else is terminal.
213
+ if (!isRateLimitError(errorMessage) || attempt >= RATE_LIMIT_MAX_RETRIES) {
214
+ break;
215
+ }
216
+ const delayMs = RATE_LIMIT_BACKOFF_MS[attempt];
217
+ rateLimitRetries++;
218
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
219
+ }
220
+ }
221
+ // Exhausted retries (or hit a non-retryable error).
222
+ return {
223
+ ordinal: p.ordinal,
224
+ stubRef: {
225
+ componentName: p.stub.componentName,
226
+ ownerKind: p.stub.ownerKind,
227
+ ownerName: p.stub.ownerName,
228
+ actionName: p.stub.actionName,
229
+ yamlPath: p.stub.yamlPath,
230
+ },
231
+ promptHash,
232
+ promptBytes,
233
+ responseBytes: 0,
234
+ latencyMs: Date.now() - t0,
235
+ parsedBody: null,
236
+ failure: lastError?.message ?? String(lastError),
237
+ responseTextSnippet: '',
238
+ rateLimitRetries,
239
+ };
240
+ }
241
+ async function runManifestCall(p) {
242
+ if (!p.manifestInputs)
243
+ return null;
244
+ const t0 = Date.now();
245
+ let promptHash = '';
246
+ let promptBytes = 0;
247
+ let lastError = null;
248
+ // Same rate-limit retry policy as runActionCall.
249
+ for (let attempt = 0; attempt <= RATE_LIMIT_MAX_RETRIES; attempt++) {
250
+ try {
251
+ const result = await p.runPromptFn({
252
+ operation: 'manifest',
253
+ values: p.manifestInputs,
254
+ model: p.model,
255
+ timeoutMs: p.timeoutMs,
256
+ onAssembled: ({ system, user }) => {
257
+ promptBytes = (system?.length ?? 0) + (user?.length ?? 0);
258
+ promptHash = simpleHash(system + '\0' + user);
259
+ },
260
+ });
261
+ const rawManifest = extractFirstYamlBlock(result.text);
262
+ // The prompt asks for the bare body (`name: …`, `capabilityMappings: …`)
263
+ // so the LLM doesn't have to know the realize-loader's container shape.
264
+ // Normalize here: prepend `specVersion: "1.0"` if absent. Without it the
265
+ // loader rejects with `Invalid manifest format: missing "manifests"
266
+ // container or "specVersion"` (manifest-loader.ts:150).
267
+ const manifestYaml = rawManifest ? ensureManifestSpecVersion(rawManifest) : null;
268
+ return {
269
+ promptHash,
270
+ promptBytes,
271
+ responseBytes: result.text.length,
272
+ latencyMs: Date.now() - t0,
273
+ manifestYaml,
274
+ failure: manifestYaml ? null : 'response did not contain a yaml block',
275
+ };
276
+ }
277
+ catch (e) {
278
+ lastError = e;
279
+ const errorMessage = e?.message ?? String(e);
280
+ if (!isRateLimitError(errorMessage) || attempt >= RATE_LIMIT_MAX_RETRIES) {
281
+ break;
282
+ }
283
+ await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_BACKOFF_MS[attempt]));
284
+ }
285
+ }
286
+ return {
287
+ promptHash,
288
+ promptBytes,
289
+ responseBytes: 0,
290
+ latencyMs: Date.now() - t0,
291
+ manifestYaml: null,
292
+ failure: lastError?.message ?? String(lastError),
293
+ };
294
+ }
295
+ /** Extract the first ```yaml fenced block from an LLM response. Falls
296
+ * back to a bare-yaml mode (no fence) when the response begins with
297
+ * one of the known top-level action-body keys — handles LLMs that
298
+ * follow the prompt's content but skip the fence. */
299
+ function extractFirstYamlBlock(text) {
300
+ const fenced = text.match(/```ya?ml\s*\n([\s\S]*?)```/i);
301
+ if (fenced?.[1])
302
+ return fenced[1];
303
+ // Generic ``` (no language tag) — also valid yaml in many cases.
304
+ const generic = text.match(/```\s*\n((?:steps|requires|ensures|emits)[\s\S]*?)```/);
305
+ if (generic?.[1])
306
+ return generic[1];
307
+ // Bare mode: the response starts with one of our keys, no fence.
308
+ const trimmed = text.trim();
309
+ if (/^(steps|requires|ensures|emits)\s*:/.test(trimmed)) {
310
+ return trimmed;
311
+ }
312
+ return null;
313
+ }
314
+ /** Parse an action body yaml — expects steps/requires/ensures/publishes keys.
315
+ * Tolerates LLMs that wrap the body inside the action name (e.g.
316
+ * `applyEffect: { steps: [...] }`) — we unwrap a single-key parent
317
+ * whose child has the expected body shape. */
318
+ export async function parseActionBodyYaml(text) {
319
+ const yamlBody = extractFirstYamlBlock(text);
320
+ if (!yamlBody)
321
+ return null;
322
+ try {
323
+ const yamlModule = await import('js-yaml');
324
+ let parsed = yamlModule.load(yamlBody);
325
+ if (!parsed || typeof parsed !== 'object')
326
+ return null;
327
+ const bodyKeys = ['steps', 'requires', 'ensures', 'publishes', 'emits', 'description'];
328
+ const hasBodyShape = (o) => o && typeof o === 'object'
329
+ && bodyKeys.some((k) => k in o);
330
+ // Unwrap single-key parent if present (`{ actionName: { steps: ... } }`).
331
+ if (!hasBodyShape(parsed)) {
332
+ const topKeys = Object.keys(parsed);
333
+ if (topKeys.length === 1 && hasBodyShape(parsed[topKeys[0]])) {
334
+ parsed = parsed[topKeys[0]];
335
+ }
336
+ }
337
+ if (!hasBodyShape(parsed))
338
+ return null;
339
+ const out = {};
340
+ if (Array.isArray(parsed.steps))
341
+ out.steps = parsed.steps.filter((s) => typeof s === 'string');
342
+ if (Array.isArray(parsed.requires))
343
+ out.requires = parsed.requires.filter((s) => typeof s === 'string');
344
+ if (Array.isArray(parsed.ensures))
345
+ out.ensures = parsed.ensures.filter((s) => typeof s === 'string');
346
+ if (Array.isArray(parsed.publishes))
347
+ out.publishes = parsed.publishes.filter((s) => typeof s === 'string');
348
+ else if (Array.isArray(parsed.emits))
349
+ out.publishes = parsed.emits.filter((s) => typeof s === 'string');
350
+ return out;
351
+ }
352
+ catch {
353
+ return null;
354
+ }
355
+ }
356
+ /** Parse an `spv validate` error output and classify each error by
357
+ * whether it falls under one of the skeleton's action stubs. */
358
+ export function classifyValidationErrors(errorText, skeletonProvenance) {
359
+ const stubPaths = skeletonProvenance.actionStubs.map((s) => s.yamlPath);
360
+ // Sort by length descending so we match the most specific stub first.
361
+ const stubPathsByLen = [...stubPaths].sort((a, b) => b.length - a.length);
362
+ const actionPathErrors = new Map();
363
+ const structuralErrors = [];
364
+ for (const line of errorText.split('\n')) {
365
+ const trimmed = line.trim();
366
+ if (!trimmed || trimmed.startsWith('Validation failed'))
367
+ continue;
368
+ // Errors look like: `/components/X/services/Y/.../Z: error message`
369
+ // Convert leading slash-path to dotted yamlPath.
370
+ const m = trimmed.match(/^\/([^\s:]+)(?::|$|\s)/);
371
+ if (!m) {
372
+ structuralErrors.push(trimmed);
373
+ continue;
374
+ }
375
+ const yamlPath = m[1].replace(/\//g, '.');
376
+ // Find the longest stub path that is a prefix of this error path.
377
+ const matchingStub = stubPathsByLen.find((sp) => yamlPath === sp || yamlPath.startsWith(sp + '.'));
378
+ if (matchingStub) {
379
+ if (!actionPathErrors.has(matchingStub))
380
+ actionPathErrors.set(matchingStub, []);
381
+ actionPathErrors.get(matchingStub).push(trimmed);
382
+ }
383
+ else {
384
+ structuralErrors.push(trimmed);
385
+ }
386
+ }
387
+ return { actionPathErrors, structuralErrors };
388
+ }
389
+ /** Re-fire microcalls for action stubs whose previous body produced
390
+ * validation errors. Builds a focused prompt-input set that includes
391
+ * the prior failed body + the specific error, asking the LLM to fix it.
392
+ *
393
+ * Skeleton-structural errors in `classification.structuralErrors` are
394
+ * NOT retried — they're skeleton-emitter bugs. The caller (analyse-
395
+ * runner) records them as humanReviewItems via the verify recorder. */
396
+ export async function runSurgicalRetry(opts) {
397
+ const { skeleton, skeletonProvenance, candidateStepsByMethodKey, prior } = opts;
398
+ const stubsToRetry = skeletonProvenance.actionStubs.filter((s) => prior.classification.actionPathErrors.has(s.yamlPath));
399
+ if (stubsToRetry.length === 0) {
400
+ return { actionBodies: new Map(prior.actionBodies), retriedStubs: 0, retryProvenance: [] };
401
+ }
402
+ // Re-fire microcalls for affected stubs, with the prior failed body +
403
+ // the specific error feeding back into the prompt as additional context.
404
+ // Reuses the standard analyse-action prompt; the candidate-step timeline
405
+ // alone is usually enough for the LLM to produce a clean body. (More
406
+ // sophisticated error-feedback prompting could ship as v2 if needed.)
407
+ const targetedProvenance = {
408
+ ...skeletonProvenance,
409
+ actionStubs: stubsToRetry,
410
+ };
411
+ const result = await runActionMicrocalls({
412
+ skeleton,
413
+ skeletonProvenance: targetedProvenance,
414
+ candidateStepsByMethodKey,
415
+ model: opts.model,
416
+ concurrencyCap: opts.concurrencyCap ?? 5,
417
+ timeoutMsPerCall: opts.timeoutMsPerCall,
418
+ runPromptOverride: opts.runPromptOverride,
419
+ onProgress: opts.onProgress,
420
+ });
421
+ // Merge: take prior bodies, replace the retried ones with new bodies
422
+ // (when parse succeeded). Failed retries leave the prior body alone.
423
+ const merged = new Map(prior.actionBodies);
424
+ for (const [path, body] of result.actionBodies) {
425
+ merged.set(path, body);
426
+ }
427
+ return {
428
+ actionBodies: merged,
429
+ retriedStubs: stubsToRetry.length,
430
+ retryProvenance: result.provenance.actionCalls,
431
+ };
432
+ }
433
+ /** Tiny non-cryptographic hash — sufficient for reproducibility fingerprinting. */
434
+ function simpleHash(s) {
435
+ let h = 5381;
436
+ for (let i = 0; i < s.length; i++)
437
+ h = ((h << 5) + h + s.charCodeAt(i)) | 0;
438
+ return (h >>> 0).toString(16);
439
+ }
440
+ /**
441
+ * Merge the skeleton with the filled action bodies. Returns the final
442
+ * spec yaml. Uses simple line-based replacement: for each yamlPath in
443
+ * actionBodies, find the action's stub in the skeleton and inject the
444
+ * body fields immediately after the stub line.
445
+ *
446
+ * The skeleton's stub format is:
447
+ * <indent>actionName:
448
+ * <indent> # body filled by per-action LLM micro-call (skeleton-emitter stub)
449
+ *
450
+ * We replace the comment line with the action body fields at body-indent.
451
+ */
452
+ export function assembleFinalSpec(skeleton, skeletonProvenance, actionBodies) {
453
+ const lines = skeleton.split('\n');
454
+ // Build a quick map: yamlPath → starting line number of the stub.
455
+ const stubLineByPath = new Map();
456
+ for (const stub of skeletonProvenance.actionStubs) {
457
+ const emission = skeletonProvenance.emissions.find((e) => e.yamlPath === stub.yamlPath && e.rule === 'action-stub-from-business-method');
458
+ if (emission)
459
+ stubLineByPath.set(stub.yamlPath, emission.lineRange[0]);
460
+ }
461
+ // Walk EVERY action stub (not just the ones with bodies). Stubs whose
462
+ // microcall failed get a minimal-valid fallback body so the spec
463
+ // validates without a giant-call retry. Failed stubs are visible
464
+ // in `microcall-decisions.json` for human review.
465
+ // Walk in REVERSE source-order so line edits don't invalidate later
466
+ // positions.
467
+ const allStubPaths = skeletonProvenance.actionStubs
468
+ .map((s) => s.yamlPath)
469
+ .sort((a, b) => (stubLineByPath.get(b) ?? 0) - (stubLineByPath.get(a) ?? 0));
470
+ for (const yamlPath of allStubPaths) {
471
+ const stubLineIdx = stubLineByPath.get(yamlPath);
472
+ if (stubLineIdx === undefined)
473
+ continue;
474
+ const stubLine = lines[stubLineIdx - 1] ?? '';
475
+ const bodyIndent = (stubLine.match(/^(\s*)/)?.[1] ?? '') + ' ';
476
+ const body = actionBodies.get(yamlPath);
477
+ const bodyLines = [];
478
+ if (body) {
479
+ // Microcall succeeded — emit its authored body.
480
+ if (body.steps?.length) {
481
+ bodyLines.push(`${bodyIndent}steps:`);
482
+ for (const s of body.steps)
483
+ bodyLines.push(`${bodyIndent} - ${quoteIfNeeded(s)}`);
484
+ }
485
+ if (body.requires?.length) {
486
+ bodyLines.push(`${bodyIndent}requires:`);
487
+ for (const r of body.requires)
488
+ bodyLines.push(`${bodyIndent} - ${quoteIfNeeded(r)}`);
489
+ }
490
+ if (body.ensures?.length) {
491
+ bodyLines.push(`${bodyIndent}ensures:`);
492
+ for (const e of body.ensures)
493
+ bodyLines.push(`${bodyIndent} - ${quoteIfNeeded(e)}`);
494
+ }
495
+ if (body.publishes?.length) {
496
+ bodyLines.push(`${bodyIndent}publishes:`);
497
+ for (const ev of body.publishes) {
498
+ // Schema expects each publishes entry to be a simple event-name
499
+ // identifier. LLMs sometimes inject YAML mapping syntax
500
+ // (`EventName: { fields }`) or em-dash commentary. Sanitize to
501
+ // the leading identifier-shaped portion; if none, quote the full
502
+ // string to keep yaml valid.
503
+ const idMatch = ev.match(/^[A-Za-z_][A-Za-z0-9_]*/);
504
+ const safe = idMatch ? idMatch[0] : null;
505
+ if (safe)
506
+ bodyLines.push(`${bodyIndent} - ${safe}`);
507
+ else
508
+ bodyLines.push(`${bodyIndent} - ${quoteIfNeeded(ev)}`);
509
+ }
510
+ }
511
+ }
512
+ else {
513
+ // Microcall failed — emit a minimal-valid stub so the spec passes
514
+ // schema validation. The action's name is preserved; the body is
515
+ // marked for human review (visible via `microcall-decisions.json`).
516
+ bodyLines.push(`${bodyIndent}description: "(microcall failed; see microcall-decisions.json)"`);
517
+ bodyLines.push(`${bodyIndent}steps: []`);
518
+ }
519
+ // If body has no content (rare — succeeded but empty), emit minimal too.
520
+ if (bodyLines.length === 0) {
521
+ bodyLines.push(`${bodyIndent}description: "(microcall returned empty body)"`);
522
+ bodyLines.push(`${bodyIndent}steps: []`);
523
+ }
524
+ // Replace the placeholder comment line (right after the actionName line).
525
+ const placeholderArrIdx = stubLineIdx;
526
+ if (lines[placeholderArrIdx]?.trim().startsWith('# body filled')) {
527
+ lines.splice(placeholderArrIdx, 1, ...bodyLines);
528
+ }
529
+ else {
530
+ lines.splice(placeholderArrIdx, 0, ...bodyLines);
531
+ }
532
+ }
533
+ // Post-process: declare every event referenced in any `publishes:`
534
+ // block at the owning component level. Without this, the structural
535
+ // invariant `every-emit-references-declared-event` fails (warning
536
+ // only, but the spec is incomplete — events should be first-class
537
+ // declarations under the component, not implicit).
538
+ return injectEventDeclarations(lines.join('\n'));
539
+ }
540
+ /**
541
+ * Walk the spec, collect every event name in `publishes:` blocks per
542
+ * owning component, and inject minimal `events:` declarations after each
543
+ * affected component's `version:` line.
544
+ *
545
+ * Idempotent: if a component already has an `events:` block declaring
546
+ * the event, leave it alone. Adds only what's missing.
547
+ */
548
+ function injectEventDeclarations(spec) {
549
+ const lines = spec.split('\n');
550
+ // Pass 1: collect publishes per component and existing event names per
551
+ // component.
552
+ const componentLineOf = new Map(); // specName → line idx of header
553
+ const versionLineOf = new Map(); // specName → line idx of ` version: "..."`
554
+ const publishedByComponent = new Map(); // specName → event names
555
+ const declaredByComponent = new Map(); // specName → event names already in events:
556
+ let currentComponent = null;
557
+ let inPublishesBlock = false;
558
+ let inEventsBlock = false;
559
+ let eventsBlockIndent = -1;
560
+ for (let i = 0; i < lines.length; i++) {
561
+ const line = lines[i] ?? '';
562
+ const compMatch = line.match(/^ ([A-Z][A-Za-z0-9_]*):\s*$/);
563
+ if (compMatch) {
564
+ currentComponent = compMatch[1];
565
+ componentLineOf.set(currentComponent, i);
566
+ inPublishesBlock = false;
567
+ inEventsBlock = false;
568
+ continue;
569
+ }
570
+ if (currentComponent && /^ version:/.test(line)) {
571
+ versionLineOf.set(currentComponent, i);
572
+ continue;
573
+ }
574
+ if (currentComponent && /^ events:\s*$/.test(line)) {
575
+ inEventsBlock = true;
576
+ eventsBlockIndent = 4;
577
+ continue;
578
+ }
579
+ if (inEventsBlock) {
580
+ // Stop the events block when we hit a sibling section (4-indent)
581
+ // or shallower (component header).
582
+ const m = line.match(/^( {0,4})(\S)/);
583
+ if (m && m[1].length <= 4 && !/^ [A-Z]/.test(line)) {
584
+ // Either a 4-indent sibling (new section) or component header.
585
+ if (m[1].length < eventsBlockIndent || /^[^ ]/.test(line)) {
586
+ inEventsBlock = false;
587
+ }
588
+ else if (m[1].length === eventsBlockIndent && !line.includes(':')) {
589
+ inEventsBlock = false;
590
+ }
591
+ else if (/^ [a-z]/.test(line)) {
592
+ // New 4-indent key like `models:`, `controllers:`, etc.
593
+ inEventsBlock = false;
594
+ }
595
+ }
596
+ // Match event names declared inside events block (6-indent identifier).
597
+ const evDecl = line.match(/^ ([A-Z][A-Za-z0-9_]*):\s*$/);
598
+ if (evDecl && inEventsBlock) {
599
+ const set = declaredByComponent.get(currentComponent) ?? new Set();
600
+ set.add(evDecl[1]);
601
+ declaredByComponent.set(currentComponent, set);
602
+ }
603
+ }
604
+ // publishes: blocks are at variable depth (action body or model behavior).
605
+ if (currentComponent && /publishes:\s*$/.test(line)) {
606
+ inPublishesBlock = true;
607
+ continue;
608
+ }
609
+ if (inPublishesBlock) {
610
+ const itemMatch = line.match(/^\s*-\s+([A-Z][A-Za-z0-9_]*)\s*$/);
611
+ if (itemMatch) {
612
+ const set = publishedByComponent.get(currentComponent) ?? new Set();
613
+ set.add(itemMatch[1]);
614
+ publishedByComponent.set(currentComponent, set);
615
+ continue;
616
+ }
617
+ // Anything else terminates the publishes block.
618
+ if (line.trim() !== '')
619
+ inPublishesBlock = false;
620
+ }
621
+ }
622
+ // Pass 2: for each component, compute the missing-event set and emit
623
+ // declarations after the version line. Process components in REVERSE
624
+ // line order so insertions don't shift earlier indices.
625
+ const insertions = [];
626
+ for (const [comp, published] of publishedByComponent) {
627
+ const declared = declaredByComponent.get(comp) ?? new Set();
628
+ const missing = [...published].filter((ev) => !declared.has(ev)).sort();
629
+ if (missing.length === 0)
630
+ continue;
631
+ const versionLine = versionLineOf.get(comp);
632
+ if (versionLine === undefined)
633
+ continue;
634
+ const out = [];
635
+ if (!declaredByComponent.has(comp)) {
636
+ out.push(' events:');
637
+ }
638
+ for (const ev of missing) {
639
+ out.push(` ${ev}:`);
640
+ out.push(` description: "Auto-declared by skeleton emitter — published by an action in this component."`);
641
+ }
642
+ insertions.push({ atLineIdx: versionLine + 1, lines: out });
643
+ }
644
+ insertions.sort((a, b) => b.atLineIdx - a.atLineIdx);
645
+ for (const ins of insertions) {
646
+ lines.splice(ins.atLineIdx, 0, ...ins.lines);
647
+ }
648
+ return lines.join('\n');
649
+ }
650
+ /**
651
+ * Normalise an LLM-emitted manifest body so the realize manifest-loader
652
+ * accepts it. The loader (manifest-loader.ts) accepts either
653
+ * `manifests:` container or `specVersion:` at top-level — the prompt
654
+ * deliberately produces neither (it asks for a bare manifest body), so
655
+ * we prepend `specVersion: "1.0"` here. Idempotent: if the LLM already
656
+ * emitted `specVersion:` (or wrapped in `manifests:`), pass through.
657
+ */
658
+ export function ensureManifestSpecVersion(yaml) {
659
+ if (/^\s*manifests\s*:/m.test(yaml))
660
+ return yaml;
661
+ if (/^\s*specVersion\s*:/m.test(yaml))
662
+ return yaml;
663
+ return `specVersion: "1.0"\n${yaml}`;
664
+ }
665
+ function quoteIfNeeded(s) {
666
+ // Always emit double-quoted form. Backslashes and embedded quotes
667
+ // MUST be escaped — the prior "default to quoted" branch dropped
668
+ // escaping, which broke whenever the LLM produced bullets like
669
+ // `result matches "${prefix}-${id}"` (YAML treats the inner quote
670
+ // as terminating the outer string and chokes on the rest).
671
+ return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
672
+ }
673
+ //# sourceMappingURL=microcall-orchestrator.js.map