@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.
- package/dist/ai/analyse-runner.d.ts +16 -0
- package/dist/ai/analyse-runner.d.ts.map +1 -1
- package/dist/ai/analyse-runner.js +417 -53
- package/dist/ai/analyse-runner.js.map +1 -1
- package/dist/ai/microcall-orchestrator.d.ts +187 -0
- package/dist/ai/microcall-orchestrator.d.ts.map +1 -0
- package/dist/ai/microcall-orchestrator.js +673 -0
- package/dist/ai/microcall-orchestrator.js.map +1 -0
- package/dist/ai/skeleton-emitter.d.ts +94 -0
- package/dist/ai/skeleton-emitter.d.ts.map +1 -0
- package/dist/ai/skeleton-emitter.js +752 -0
- package/dist/ai/skeleton-emitter.js.map +1 -0
- package/dist/analyse-prepass/adapters/express-routes.d.ts +71 -0
- package/dist/analyse-prepass/adapters/express-routes.d.ts.map +1 -0
- package/dist/analyse-prepass/adapters/express-routes.js +329 -0
- package/dist/analyse-prepass/adapters/express-routes.js.map +1 -0
- package/dist/analyse-prepass/adapters/typescript-interfaces.d.ts +91 -0
- package/dist/analyse-prepass/adapters/typescript-interfaces.d.ts.map +1 -0
- package/dist/analyse-prepass/adapters/typescript-interfaces.js +411 -0
- package/dist/analyse-prepass/adapters/typescript-interfaces.js.map +1 -0
- package/dist/analyse-prepass/backends/gitnexus.d.ts.map +1 -1
- package/dist/analyse-prepass/backends/gitnexus.js +36 -8
- package/dist/analyse-prepass/backends/gitnexus.js.map +1 -1
- package/dist/analyse-prepass/backends/index.d.ts.map +1 -1
- package/dist/analyse-prepass/backends/index.js +3 -5
- package/dist/analyse-prepass/backends/index.js.map +1 -1
- package/dist/analyse-prepass/behavior-step-classifier.d.ts +3 -0
- package/dist/analyse-prepass/behavior-step-classifier.d.ts.map +1 -1
- package/dist/analyse-prepass/behavior-step-classifier.js +1 -0
- package/dist/analyse-prepass/behavior-step-classifier.js.map +1 -1
- package/dist/analyse-prepass/index.d.ts +69 -0
- package/dist/analyse-prepass/index.d.ts.map +1 -1
- package/dist/analyse-prepass/index.js +385 -17
- package/dist/analyse-prepass/index.js.map +1 -1
- package/dist/analyse-prepass/method-body-walker.d.ts +4 -0
- package/dist/analyse-prepass/method-body-walker.d.ts.map +1 -1
- package/dist/analyse-prepass/method-body-walker.js +14 -0
- package/dist/analyse-prepass/method-body-walker.js.map +1 -1
- package/dist/audit/realize-recorder.d.ts +164 -0
- package/dist/audit/realize-recorder.d.ts.map +1 -0
- package/dist/audit/realize-recorder.js +153 -0
- package/dist/audit/realize-recorder.js.map +1 -0
- package/dist/audit/verify-checks.d.ts +32 -0
- package/dist/audit/verify-checks.d.ts.map +1 -0
- package/dist/audit/verify-checks.js +202 -0
- package/dist/audit/verify-checks.js.map +1 -0
- package/dist/audit/verify-recorder.d.ts +84 -0
- package/dist/audit/verify-recorder.d.ts.map +1 -0
- package/dist/audit/verify-recorder.js +90 -0
- package/dist/audit/verify-recorder.js.map +1 -0
- package/dist/inference/core/specly-converter.d.ts.map +1 -1
- package/dist/inference/core/specly-converter.js +67 -36
- package/dist/inference/core/specly-converter.js.map +1 -1
- package/dist/libs/instance-factories/services/templates/_shared/step-matching.js +39 -15
- package/dist/realize/index.d.ts.map +1 -1
- package/dist/realize/index.js +63 -0
- package/dist/realize/index.js.map +1 -1
- package/libs/instance-factories/services/templates/_shared/step-matching.ts +61 -16
- 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
|