@specverse/engines 6.21.4 → 6.27.12

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