amsd-pipeline-shared 0.5.21 → 0.5.22

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.
@@ -0,0 +1,1413 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * THE MINT BUILDS THIS PROJECT'S PROMPTS.
4
+ *
5
+ * Operator design, 2026-08-15: the agent that mints the roster also builds the prompts,
6
+ * because it is the stage that already knows what this project is and which roles will work
7
+ * on it — the two things a specialised prompt needs.
8
+ *
9
+ * Before this existed, nothing generated project prompts. prompt-library.js renders only the
10
+ * project-authority copy and refuses to fall back to a template, so a project without them
11
+ * cannot run — yet the only way to obtain them was to write them by hand. Hence exactly seven
12
+ * in the repo, all for one project.
13
+ *
14
+ * TWO PROVISIONING MODES, DECLARED IN bootstrap.json:
15
+ * copyVerbatim — installed byte-identical. These cannot be generated because generating
16
+ * them would require themselves (the mint's own prompt; this generator's).
17
+ * generated — specialised by the model, checked against the template, then installed.
18
+ *
19
+ * FAILURE IS LOUD AND TOTAL. A generated prompt that drops, renames or invents one
20
+ * placeholder does not degrade — prompt-library throws at whichever seam needed it, mid-run,
21
+ * after the roster is minted and the run is already spending. So a prompt that fails its
22
+ * contract is never written, and the builder throws rather than leaving a project
23
+ * half-provisioned. It never falls back to the generic template: running a template is
24
+ * forbidden outright, and a silent degrade is how an engine default runs a whole campaign
25
+ * without anyone noticing.
26
+ */
27
+ 'use strict';
28
+
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+
32
+ // The one template this builder reads a PROJECT copy of, in order to generate the rest. Named once
33
+ // so the provisioning guard above and the read below cannot disagree about it.
34
+ const PROJECT_PROMPT_GENERATOR_ID = 'project-prompt-generation';
35
+ const crypto = require('crypto');
36
+
37
+ const { checkGeneratedPrompt, buildGeneratedDoc, generatedTemplateDigest } = require('./project-prompt-contract.js');
38
+
39
+ const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf8'));
40
+
41
+ /**
42
+ * Render the generator's own prompt for one template.
43
+ *
44
+ * The generator is itself a project-authority prompt (bootstrap-copied), so it is rendered
45
+ * through the same library as everything else rather than assembled here — an engine-side
46
+ * copy of its text is precisely the thing this whole design removes.
47
+ */
48
+ const { refusalBlock } = require('./refusal-block.js');
49
+ // THE VARIANT RULE, IMPORTED — NOT RE-EXPRESSED. engine-prompt resolves a brownfield variant for
50
+ // the seams it renders; this builder resolves templates out of a directory handed to it, so it
51
+ // answers existence itself and takes the same decision from the same function. Two copies of a
52
+ // routing rule drift, and the drift is invisible: both sides keep working, on different prompts.
53
+ const { variantIdFor } = require('./engine-prompt.js');
54
+
55
+ /**
56
+ * The template's body as one string — the SAME expression checkGeneratedPrompt uses, so the text
57
+ * the generator is shown and the text its output is judged against cannot drift apart.
58
+ */
59
+ /**
60
+ * THE SLOT THE GENERATOR EMBEDS THE TEMPLATE'S SEGMENTS IN — named once. The rehearsal mock finds
61
+ * the generator template by this slot; a second spelling there would drift from this one.
62
+ */
63
+ const GEN_BODY_SLOT = '__GEN_TEMPLATE_BODY__';
64
+
65
+ function templateBodyText(template) {
66
+ if (!template) return '';
67
+ if (typeof template.body === 'string' && template.body) return template.body;
68
+ const bodies = template.bodies && typeof template.bodies === 'object' ? template.bodies : {};
69
+ return Object.values(bodies).filter((b) => typeof b === 'string').join('\n');
70
+ }
71
+
72
+
73
+ /**
74
+ * WHAT IDENTIFIES A ROSTER, FOR CACHING — its roles, not the prose describing them.
75
+ *
76
+ * The mint reports each role as `- <name> [<kind>] — <rationale>`, and the rationale is written
77
+ * by a model, so it is reworded on every mint of the same codeline. Digesting the whole string
78
+ * meant two runs producing IDENTICAL roles got different cache keys: measured 2026-09-04, 39
79
+ * cache entries and 0 hits, 28 of them blocked on exactly this — regenerating 28 prompts at model
80
+ * prices every run for a difference no prompt can observe.
81
+ *
82
+ * The key is decided by what a prompt can OBSERVE. usesRoles() marks a prompt as roster-dependent
83
+ * by extracting role NAMES and asking whether the generated text mentions any of them, so names
84
+ * are the only thing that can invalidate it — with kinds, because a kind decides whether an agent
85
+ * may author code at all. The rationale is commentary the generator reads and never quotes.
86
+ *
87
+ * Sorted, because the mint promises no ordering. Nothing here names a role, a kind or a count.
88
+ */
89
+ /**
90
+ * THE CODELINE THIS RUN DETECTED, from the artifact the run itself wrote.
91
+ *
92
+ * Operator, 2026-09-06: "code line is detected in a live run ... this var will never be preset in
93
+ * a live run." EPAM_CODELINE_ID is read in six places in this repo and SET in none — no launcher,
94
+ * config or env file exports it. In a live run the codeline becomes known when discovery resolves
95
+ * the PRD's scope: after pre-run-reset has finished, before the mint and this builder run. So
96
+ * waiting for the variable means the cache never hits, silently, while the log reports normally.
97
+ *
98
+ * The PRD is the run, and run-agent-orchestration.sh exports PRD_FILE, so it is already in this
99
+ * process's environment — there is no call site to wire and no seam to rot. The fields read are
100
+ * the ones lib/codeline-scope.sh's codeline_scope_paths already treats as the run's scope, so
101
+ * both sides of the engine answer "which codeline?" from one place.
102
+ *
103
+ * EXACTLY ONE, or nothing. A run scoped to two codelines produces prompts specialised for both;
104
+ * attributing them to either would hand the other's run a set built for a repository it is not
105
+ * working in. Nothing is a legitimate answer — the entries stay project-level, as before.
106
+ */
107
+ function codelineFromPrd(prdFile) {
108
+ try {
109
+ const f = String(prdFile || '').trim();
110
+ if (!f || !fs.existsSync(f)) return '';
111
+ const prd = JSON.parse(fs.readFileSync(f, 'utf8'));
112
+ const project = (prd && prd.project) || {};
113
+ const dirs = []
114
+ .concat(Array.isArray(project.outputDirs) ? project.outputDirs.map((o) => o && o.path) : [])
115
+ .concat(project.outputDir ? [project.outputDir] : [])
116
+ .filter((d) => typeof d === 'string' && d.trim());
117
+ const unique = [...new Set(dirs.map((d) => path.basename(String(d).replace(/\/+$/, ''))))]
118
+ .filter(Boolean);
119
+ return unique.length === 1 ? unique[0] : '';
120
+ } catch { return ''; }
121
+ }
122
+
123
+ /** The PRD's codeline folders: every outputDirs path's folder, then outputDir's, each once, in the PRD's order. */
124
+ function _prdCodelines(prdFile) {
125
+ try {
126
+ const f = String(prdFile || '').trim();
127
+ if (!f || !fs.existsSync(f)) return { all: [], own: '' };
128
+ const project = (JSON.parse(fs.readFileSync(f, 'utf8')) || {}).project || {};
129
+ const base = (d) => (typeof d === 'string' && d.trim() ? path.basename(d.replace(/\/+$/, '')) : '');
130
+ const all = [...new Set([...(Array.isArray(project.outputDirs) ? project.outputDirs.map((o) => base(o && o.path)) : []), base(project.outputDir)]
131
+ .filter(Boolean))].map(codelineSegment).filter(Boolean);
132
+ return { all, own: codelineSegment(base(project.outputDir)) };
133
+ } catch { return { all: [], own: '' }; }
134
+ }
135
+
136
+ /** EVERY CODELINE OF A STORY — the folders its PRD is worked in, in the PRD's order. */
137
+ function codelinesFromPrd(prdFile) { return _prdCodelines(prdFile).all; }
138
+
139
+ /**
140
+ * THE CODELINE A PRD IS WORKED IN: its project.outputDir's folder. A lane's PRD lists every codeline of the story in outputDirs
141
+ * and its own in outputDir; a story's PRD names its primary codeline there. With no outputDir, its only codeline.
142
+ */
143
+ function codelineOfPrd(prdFile) {
144
+ const { all, own } = _prdCodelines(prdFile);
145
+ return own || (all.length === 1 ? all[0] : '');
146
+ }
147
+
148
+ /**
149
+ * A CODELINE'S RECORDED SET IS COMPLETE: its completion marker built from the current inputs, its minted agents, its prompts.
150
+ * { complete, reason } — the reason names the part that is not there.
151
+ */
152
+ function codelineSetComplete({ projectConfigDir, codeline, templatesDir, registryFile } = {}) {
153
+ const m = codelinePromptsComplete({ projectConfigDir, codeline, templatesDir, registryFile });
154
+ if (m.complete !== true) return { complete: false, reason: m.reason };
155
+ if (!hasMintedSnapshot({ projectConfigDir, codeline })) return { complete: false, reason: `no recorded agents at ${mintedSnapshotDir(projectConfigDir, codeline)}` };
156
+ const rp = recordedPromptsDir(projectConfigDir, codeline);
157
+ if (!fs.existsSync(rp) || !fs.readdirSync(rp).some((n) => n.endsWith('.json'))) return { complete: false, reason: `no recorded prompts at ${rp}` };
158
+ return { complete: true, reason: '' };
159
+ }
160
+
161
+ /**
162
+ * THE CODELINE THE MINT'S REUSE IS DECIDED FOR. One codeline: it (codelineFromPrd, as before). Several: the story's own codeline
163
+ * (codelineOfPrd) when EVERY codeline of the story has its complete recorded set — the agents before the lanes then run from
164
+ * that codeline's set, and each lane from its own — else '' (provisioned again, and recorded for every codeline).
165
+ */
166
+ function scopeCodelineFromPrd({ prdFile, projectConfigDir, templatesDir, registryFile } = {}) {
167
+ const one = codelineFromPrd(prdFile);
168
+ if (one) return one;
169
+ const all = codelinesFromPrd(prdFile);
170
+ if (all.length < 2 || !projectConfigDir) return '';
171
+ if (!all.every((codeline) => codelineSetComplete({ projectConfigDir, codeline, templatesDir, registryFile }).complete)) return '';
172
+ const own = codelineOfPrd(prdFile);
173
+ return all.includes(own) ? own : '';
174
+ }
175
+
176
+ /**
177
+ * A LANE WORKS FROM ITS OWN CODELINE'S SET: that codeline's recorded minted agents and prompts made the project's active set
178
+ * (restoreMinted). A codeline with no recorded agents, or no recorded prompts, is no set — said, and nothing is changed: a lane
179
+ * never runs another codeline's set or the project-wide one.
180
+ */
181
+ function restoreCodelineSet({ projectConfigDir, codeline } = {}) {
182
+ if (!projectConfigDir || !codeline) return { ok: false, reason: 'no project or codeline named' };
183
+ if (!hasMintedSnapshot({ projectConfigDir, codeline })) return { ok: false, reason: `no recorded agents at ${mintedSnapshotDir(projectConfigDir, codeline)}` };
184
+ const rp = recordedPromptsDir(projectConfigDir, codeline);
185
+ if (!fs.existsSync(rp) || !fs.readdirSync(rp).some((n) => n.endsWith('.json'))) return { ok: false, reason: `no recorded prompts at ${rp}` };
186
+ return restoreMinted({ projectConfigDir, codeline }) ? { ok: true, reason: '' } : { ok: false, reason: 'its recorded set could not be restored' };
187
+ }
188
+
189
+ /**
190
+ * LANES THAT RUN TOGETHER SHARE ONE ACTIVE SET, so parallel lanes can each work from their own codeline's set only when those sets
191
+ * are the same, byte for byte (prompts and minted agents). The sets compared, and the first difference when they are not.
192
+ */
193
+ function codelineSetsIdentical({ projectConfigDir, codelines } = {}) {
194
+ const read = (d) => (fs.existsSync(d) ? Object.fromEntries(fs.readdirSync(d).filter((n) => n.endsWith('.json')).sort()
195
+ .map((n) => [n, crypto.createHash('sha256').update(fs.readFileSync(path.join(d, n))).digest('hex')])) : {});
196
+ const sets = (codelines || []).map((c) => ({ c, prompts: read(recordedPromptsDir(projectConfigDir, c)), minted: read(mintedSnapshotDir(projectConfigDir, c)) }));
197
+ for (const x of sets.slice(1)) {
198
+ for (const part of ['prompts', 'minted']) {
199
+ if (JSON.stringify(x[part]) !== JSON.stringify(sets[0][part])) return { same: false, reason: `${x.c}'s recorded ${part} differ from ${sets[0].c}'s` };
200
+ }
201
+ }
202
+ return { same: true, reason: '' };
203
+ }
204
+
205
+ /**
206
+ * ONE SAFE PATH SEGMENT from a codeline id, or '' when none is declared.
207
+ *
208
+ * The id names a directory under .prompt-cache. It arrives from configuration and from a
209
+ * discovery step's output, so a separator or a traversal in it must decide nothing about where
210
+ * this process writes. Everything outside [A-Za-z0-9._-] collapses to '-', leading dots are
211
+ * dropped so '..' cannot survive, and an id that reduces to nothing is treated as undeclared
212
+ * rather than as a directory named ''.
213
+ */
214
+ function codelineSegment(raw) {
215
+ const t = String(raw == null ? '' : raw).trim();
216
+ if (!t) return '';
217
+ const seg = t.replace(/[^A-Za-z0-9._-]/g, '-').replace(/^\.+/, '');
218
+ return seg || '';
219
+ }
220
+
221
+ /**
222
+ * ADOPT PRE-CODELINE CACHE ENTRIES, ONCE.
223
+ *
224
+ * Before the cache was keyed by codeline, entries sat flat at <project>/.prompt-cache/<id>.json
225
+ * and recorded nothing about which codeline produced them. The only evidence that can attribute
226
+ * them is the codeline the run declares, so they move under it the first time one does. metrolinx
227
+ * carries 39 such entries beside a marker naming next.gotransit.com.
228
+ *
229
+ * Only when the codeline's directory does not exist yet: once it does, this codeline has its own
230
+ * entries and a flat file is a leftover, not an ancestor. The completion markers (.complete-*)
231
+ * carry no .json extension and are not moved — they already name their codeline.
232
+ *
233
+ * Never throws. A migration that cannot run costs a regeneration, never correctness.
234
+ */
235
+ function migrateFlatEntries(cacheRoot, cacheDir, log) {
236
+ if (!cacheRoot || !cacheDir || cacheDir === cacheRoot) return 0;
237
+ try {
238
+ if (fs.existsSync(cacheDir)) return 0;
239
+ if (!fs.existsSync(cacheRoot)) return 0;
240
+ // ONLY WHAT PREDATES CODELINE KEYING, never what merely sits at the root today. If ANY
241
+ // codeline directory already exists, this cache has been keyed before, so a flat file is a
242
+ // leftover from a run that declared no codeline — and adopting it would attribute one
243
+ // codeline's prompts to another on no evidence. That guess is the contamination this whole
244
+ // change exists to remove, so the migration declines it and the prompts regenerate.
245
+ const alreadyKeyed = fs.readdirSync(cacheRoot)
246
+ .some((f) => { try { return fs.statSync(path.join(cacheRoot, f)).isDirectory(); } catch { return false; } });
247
+ if (alreadyKeyed) return 0;
248
+ const flat = fs.readdirSync(cacheRoot).filter((f) => f.endsWith('.json')
249
+ && fs.statSync(path.join(cacheRoot, f)).isFile());
250
+ if (!flat.length) return 0;
251
+ fs.mkdirSync(cacheDir, { recursive: true });
252
+ let moved = 0;
253
+ for (const f of flat) {
254
+ try { fs.renameSync(path.join(cacheRoot, f), path.join(cacheDir, f)); moved += 1; } catch { /* leave it */ }
255
+ }
256
+ if (moved && typeof log === 'function') {
257
+ log(`[prompt-builder] adopted ${moved} pre-codeline cache entr${moved === 1 ? 'y' : 'ies'} `
258
+ + `under ${path.basename(cacheDir)} — nothing regenerated`);
259
+ }
260
+ return moved;
261
+ } catch { return 0; }
262
+ }
263
+
264
+ function rolesIdentity(mintedRoles) {
265
+ const out = [];
266
+ for (const line of String(mintedRoles || '').split('\n')) {
267
+ // `- <name> [<kind>]` — the identity the mint declares, whatever follows it.
268
+ const m = line.match(/^\s*-\s*([A-Za-z0-9][A-Za-z0-9-]*)\s*\[([^\]]*)\]/);
269
+ if (m) out.push(`${m[1]}[${m[2].trim()}]`);
270
+ }
271
+ return out.sort().join(',');
272
+ }
273
+
274
+ /**
275
+ * THE ACTIVE SET'S ANSWER BUDGET, in characters — what one model turn can return on it, as the set
276
+ * declares (a runner's `limits`: the smallest visible answer measured, at the measured characters
277
+ * per token). Infinity when no runner of the active set declares one: nothing changes for that set.
278
+ */
279
+ function answerBudgetChars() {
280
+ try {
281
+ // eslint-disable-next-line global-require
282
+ const { activeSetFile, readSetFile } = require('./llm-settings-resolve.js');
283
+ const f = activeSetFile();
284
+ const runners = (f && (readSetFile(f) || {}).runners) || {};
285
+ const budgets = Object.values(runners).map((r) => r && r.limits)
286
+ .filter((l) => l && l.visibleTokensObservedMin > 0 && l.charsPerTokenObserved > 0)
287
+ .map((l) => Math.floor(l.visibleTokensObservedMin * l.charsPerTokenObserved));
288
+ return budgets.length ? Math.min(...budgets) : Infinity;
289
+ } catch { return Infinity; }
290
+ }
291
+
292
+ /**
293
+ * THE TEMPLATE'S PROSE IN PARTS THAT FIT ONE TURN.
294
+ *
295
+ * A unit is one segment, or — when a single segment is itself longer than a turn can return — one
296
+ * piece of it, split at paragraph breaks (PARAGRAPH, rejoined with the same). Units are grouped so
297
+ * each group's reply (its markers and wording, at the length shown) stays within the budget. With
298
+ * no budget declared, or when everything fits, there is one group of the whole segments: the single
299
+ * call every set made before. Returns { units: [{ seg, text }], groups: [{ from, to }] }.
300
+ */
301
+ const PARAGRAPH = '\n\n';
302
+ function promptParts(segments, budget) {
303
+ // eslint-disable-next-line global-require
304
+ const { formatSegments } = require('./project-prompt-contract.js');
305
+ const whole = { units: segments.map((text, seg) => ({ seg, text })), groups: [{ from: 0, to: segments.length }] };
306
+ if (!Number.isFinite(budget) || formatSegments(segments).length <= budget) return whole;
307
+ const units = [];
308
+ segments.forEach((text, seg) => {
309
+ if (formatSegments([text]).length <= budget) { units.push({ seg, text }); return; }
310
+ let cur = [];
311
+ for (const para of text.split(PARAGRAPH)) {
312
+ if (cur.length && formatSegments([[...cur, para].join(PARAGRAPH)]).length > budget) { units.push({ seg, text: cur.join(PARAGRAPH) }); cur = []; }
313
+ cur.push(para);
314
+ }
315
+ units.push({ seg, text: cur.join(PARAGRAPH) });
316
+ });
317
+ const groups = []; let from = 0;
318
+ for (let i = 0; i < units.length; i += 1) {
319
+ if (i > from && formatSegments(units.slice(from, i + 1).map((u) => u.text)).length > budget) { groups.push({ from, to: i }); from = i; }
320
+ }
321
+ groups.push({ from, to: units.length });
322
+ return { units, groups };
323
+ }
324
+
325
+ /** The template's segments back from the units' replies: each segment's pieces rejoined as split. */
326
+ function segmentsFromUnits(units, texts, segmentCount) {
327
+ const out = Array.from({ length: segmentCount }, () => []);
328
+ units.forEach((u, i) => out[u.seg].push(texts[i]));
329
+ return out.map((pieces) => pieces.join(PARAGRAPH));
330
+ }
331
+
332
+ function renderGeneratorPrompt({ generatorBody, template, projectContext, codelineContext, mintedRoles, refusal, part }) {
333
+ let out = generatorBody
334
+ .split('__GEN_TEMPLATE_ID__').join(template.id)
335
+ .split('__GEN_TEMPLATE_DESCRIPTION__').join(template.description || '')
336
+ // A MULTI-BODY TEMPLATE HAS NO .body, AND join(undefined) IS A COMMA.
337
+ //
338
+ // 21 templates carry `bodies` instead of `body`. This read template.body — undefined for every
339
+ // one of them — and String.prototype.join(undefined) falls back to its DEFAULT separator, so
340
+ // the generator prompt received the single character "," where the template should have been.
341
+ // The model was asked to rewrite a comma, could not possibly preserve the placeholders it had
342
+ // never seen, and the shape guard refused all three attempts for "dropped placeholder(s)".
343
+ //
344
+ // That aborted mock3 runs 9 and 10 at skill-assessment-prephase, each after ~29 single-body
345
+ // templates had generated cleanly — and it was invisible because the refusal named the
346
+ // placeholders rather than the empty input that caused them to be missing.
347
+ //
348
+ // Joined the way checkGeneratedPrompt already joins them, so the text the model is given and
349
+ // the text its output is checked against are the same text. Two readers of one template that
350
+ // disagree is the defect this whole file keeps meeting.
351
+ .split('__GEN_TEMPLATE_PLACEHOLDERS__').join((template.placeholders || []).join(', ') || '(none)')
352
+ // THE SAME LIST THE CONTRACT CHECK WILL JUDGE IT AGAINST, from the same function, so the
353
+ // generator cannot be refused for losing a field it was never told about.
354
+ .split('__GEN_TEMPLATE_OUTPUT_FIELDS__').join(
355
+ // eslint-disable-next-line global-require
356
+ (require('./project-prompt-contract.js').outputFieldsIn(templateBodyText(template)) || []).join(', ') || '(none)')
357
+ .split('__GEN_PROJECT_CONTEXT__').join(projectContext || '')
358
+ .split('__GEN_CODELINE_CONTEXT__').join(codelineContext || '')
359
+ .split('__GEN_MINTED_ROLES__').join(mintedRoles || '')
360
+ // THE RETRY MUST BE TOLD WHY — re-sending an identical instruction gets an identical answer,
361
+ // and the refusal is the only new information the next attempt has. The words that carry it
362
+ // are the GENERATOR PROMPT'S, not this file's: they used to be appended here in JavaScript,
363
+ // where the prompt layer could not review them, the drift checks could not see them, and no
364
+ // project could change them. roster-specialisation.json has always done it this way.
365
+ //
366
+ // An absent refusal substitutes empty, so a first attempt carries no heading for a refusal
367
+ // that never happened.
368
+ .split('__GEN_PREVIOUS_REFUSAL__').join(refusalBlock(refusal, 'prompt'));
369
+
370
+ // THE TEMPLATE BODY GOES IN LAST, AND THIS ORDER IS THE WHOLE POINT.
371
+ //
372
+ // Three of the generator's own placeholders — __PROJECT_CONTEXT__, __CODELINE_CONTEXT__ and
373
+ // __PREVIOUS_REFUSAL__ — are ALSO placeholders in templates it has to specialise. The body was
374
+ // embedded first and the value substitutions then ran across the entire string, so the embedded
375
+ // template's own placeholders were replaced with real project text before the model ever saw
376
+ // them. It was then refused for "dropping" placeholders it was never shown.
377
+ //
378
+ // Live 2026-08-27: roster-specialisation refused on exactly those three, on every attempt and on
379
+ // every rung, because no model can reproduce a token that is not in its input. Same shape as the
380
+ // join(undefined) comma that aborted runs 9 and 10 — the input was wrong, and the refusal
381
+ // described the output.
382
+ //
383
+ // Substituted last, the body is inert text by the time it arrives: nothing after this line can
384
+ // reach inside it.
385
+ out = out // SEGMENTS, NOT THE BODY. The model is shown only the prose BETWEEN placeholders and asked
386
+ // for the same number back; the slots never enter its input or its output, so it cannot drop
387
+ // one. 25 refusals on 2026-09-08 were all "dropped placeholder(s) the template requires", and
388
+ // a dropped slot silently starves the agent of the evidence that slot carries.
389
+ .split(GEN_BODY_SLOT).join((() => {
390
+ const { splitByPlaceholders, formatSegments } = require('./project-prompt-contract.js');
391
+ const all = splitByPlaceholders(templateBodyText(template)).segments;
392
+ // A PART, when one turn cannot return the whole: only its pieces are shown and asked for,
393
+ // numbered from 1, and said to be a part so the model words them as part of the whole.
394
+ const segments = part ? part.texts : all;
395
+ const where = part && part.of > 1
396
+ ? `This is part ${part.index + 1} of ${part.of} of the template; the other parts are asked for separately.\n`
397
+ : '';
398
+ return where + formatSegments(segments) + '\n'
399
+ + `Reply with the same ${segments.length} markers, in order, each followed by your wording `
400
+ + 'for that segment, and close with the END SEGMENTS marker. Plain text between the '
401
+ + 'markers — no JSON, no escaping, no code fences. A segment may be empty. Do not write '
402
+ + 'any placeholder tokens; the pipeline restores them between your segments.';
403
+ })());
404
+ return out;
405
+ }
406
+
407
+ /**
408
+ * Provision every prompt this project needs.
409
+ *
410
+ * @param {object} o
411
+ * @param {string} o.templatesDir the immutable generic zone
412
+ * @param {string} o.bootstrapFile declares copyVerbatim / generated (auxiliary prompts)
413
+ * @param {string} [o.registryFile] invocation-profiles.json — every seam.template needs a copy
414
+ * @param {string} o.projectConfigDir prompts are written to <dir>/prompts
415
+ * @param {Function} o.runText async (prompt) => model's reply text
416
+ * @param {Function} [o.reviewPrompt] async ({id,template,generated}) => {ok, reason}
417
+ * falsifies the derivative's claims about the project;
418
+ * omitted means no review, exactly as before
419
+ * @param {string} o.projectContext what this project is
420
+ * @param {string} o.codelineContext codelines and declared dependencies
421
+ * @param {string} o.mintedRoles the roles the mint just produced
422
+ * @param {number} [o.attempts=3] generation attempts per prompt
423
+ * @param {Function} [o.log] progress sink
424
+ * @returns {Promise<{copied:string[], generated:string[]}>}
425
+ */
426
+ /**
427
+ * WHAT NEEDS A PROJECT COPY — resolved from the registry, with bootstrap supplying only what the
428
+ * registry cannot know.
429
+ *
430
+ * Pure and exported so the contract is assertable without provisioning anything.
431
+ *
432
+ * THE REGISTRY IS THE SOURCE. A seam declaring `template: X` IS the statement that X needs a
433
+ * project copy. bootstrap.generated used to restate 25 of those by hand, and a hand-kept copy of a
434
+ * derivable fact only ever drifts: seven seam-run templates had already fallen out of it, the last
435
+ * being prompt-review — a seam added by the very session that documented the drift at six.
436
+ *
437
+ * bootstrap now carries ONLY auxiliary prompts: sub-prompts referenced INSIDE a template body
438
+ * (retry prefixes, hint bodies) which no seam names and which deriving alone would drop.
439
+ *
440
+ * A seam naming a template that exists NOWHERE throws. Under the old union that was rescued into
441
+ * silence; the registry being the source means a gap in it is an error, not something bootstrap
442
+ * quietly covers for.
443
+ *
444
+ * @param {object} o.bootstrap parsed bootstrap.json
445
+ * @param {object} o.registry parsed invocation-profiles.json
446
+ * @param {function} [o.templateExists] (id) => bool; defaults to assuming it does
447
+ * @returns {string[]} template ids needing a generated project copy
448
+ */
449
+ function provisioningList({ bootstrap = {}, registry = {}, templateExists = () => true }) {
450
+ const copyVerbatim = Array.isArray(bootstrap.copyVerbatim) ? bootstrap.copyVerbatim : [];
451
+ const aux = Array.isArray(bootstrap.generated) ? bootstrap.generated : [];
452
+
453
+ const seamRun = [...new Set(
454
+ Object.values(registry.profiles || {}).map((p) => p && p.template).filter(Boolean),
455
+ )];
456
+
457
+ const orphaned = seamRun.filter((t) => !templateExists(t));
458
+ if (orphaned.length) {
459
+ throw new Error(
460
+ `[prompt-builder] seam registry names template(s) that exist nowhere: ${orphaned.join(', ')}. `
461
+ + 'A seam that runs a template with no file cannot be provisioned, and provisioning around it '
462
+ + 'would leave that seam executing nothing. Add the template, or correct the seam.');
463
+ }
464
+
465
+ // Union of the two KINDS, not of two copies of one kind: seam-declared, plus the auxiliaries the
466
+ // registry has no way to see. copyVerbatim is installed unchanged and is never generated.
467
+ return [...new Set([...seamRun, ...aux])].filter((t) => !copyVerbatim.includes(t));
468
+ }
469
+
470
+ /**
471
+ * WHAT THE SELF-HEAL ANALYST IS TOLD THE OUTPUT IS. It was told only the placeholder names, saw them
472
+ * unfilled in the output, and diagnosed that as the failure (AMSD-2974 run 3, 2026-10-09). A project
473
+ * prompt is reused for every call; its placeholders are filled per call at run time and must stay.
474
+ */
475
+ function healContext(id, template) {
476
+ return `The output is a reusable PROJECT PROMPT generated from template ${id}: the same prompt serves every `
477
+ + 'story and every call this project makes to that agent. Its placeholders '
478
+ + `(${((template && template.placeholders) || []).join(', ')}) are filled per call at run time with that `
479
+ + 'call\'s story, files and criteria, and must appear unfilled — an unfilled placeholder is never the defect, '
480
+ + 'and the corrective never writes into one. Anything true only for one ticket belongs in those run-time '
481
+ + 'values, never in the prompt; what the prompt may say about the project is what stays true for every '
482
+ + 'story. Diagnose the refusal reason given in that light.';
483
+ }
484
+
485
+ async function buildProjectPrompts({
486
+ templatesDir, bootstrapFile, registryFile, projectConfigDir, runText, reviewPrompt,
487
+ projectContext, codelineContext, mintedRoles, attempts = 3, mode = 'generate', log = () => {},
488
+ keepExisting = false,
489
+ }) {
490
+ // SAY WHICH IT WAS. This reviewer is opt-in (registry: prompt-review.optIn), and a run with no
491
+ // reviewer looked exactly like a run whose reviewer approved everything — 35 prompts
492
+ // provisioned, "REVIEW REJECTED: 0". Silence is not a clean bill of health, so the run states
493
+ // plainly that these prompts went in unexamined. Turned on per project via
494
+ // EPAM_PROMPT_REVIEW_ENABLED; see mint-agents-step.js.
495
+ if (typeof reviewPrompt === 'function') {
496
+ log('[prompt-builder] prompt review ENABLED — each generated prompt is falsified before install');
497
+ } else {
498
+ log('[prompt-builder] prompt review is OFF — generated prompts are installed NOT REVIEWED '
499
+ + '(this project set EPAM_PROMPT_REVIEW_ENABLED=0 — review is ON by default)');
500
+ }
501
+
502
+ const boot = readJson(bootstrapFile);
503
+ let copyVerbatim = Array.isArray(boot.copyVerbatim) ? boot.copyVerbatim : [];
504
+ let generated = Array.isArray(boot.generated) ? boot.generated : [];
505
+
506
+ // WHAT NEEDS A PROJECT COPY IS DERIVED FROM THE SEAM REGISTRY, NOT MAINTAINED BY HAND.
507
+ //
508
+ // A seam names the template it runs. If a seam runs it, that seam needs a PROJECT copy —
509
+ // otherwise it executes the immutable generic parent, unspecialised for the project it is
510
+ // working on. That makes the list a fact of the registry, not a list anyone curates.
511
+ //
512
+ // Curating it had already drifted: of 33 templates referenced by a seam, six appeared in
513
+ // bootstrap NOWHERE — codeline-bridge, e2e-route-check, assign-agent-roles, spec-story-block,
514
+ // skill-assessment-prephase, prd-model-coordinator. Those seams had no project-prompt path at
515
+ // all, and nothing reported it, because the only list was the one that omitted them.
516
+ //
517
+ // bootstrap.generated is still read: it carries AUXILIARY prompts that a seam template
518
+ // references but no seam names directly (retry prefixes, hint bodies, sub-prompts). Union, not
519
+ // replacement — deriving alone would silently drop those.
520
+ if (registryFile && fs.existsSync(registryFile)) {
521
+ // ONE RESOLUTION, ONE PLACE. provisioningList is the contract, asserted directly by
522
+ // test/unit/orchestration/bootstrap-duplicates-the-seam-registry.test.ts; computing the list a
523
+ // second time here is how the two would come to disagree.
524
+ generated = provisioningList({
525
+ bootstrap: boot,
526
+ registry: readJson(registryFile),
527
+ templateExists: (id) => fs.existsSync(path.join(templatesDir, `${id}.json`)),
528
+ });
529
+ log(`[prompt-builder] ${generated.length} template(s) need a project copy `
530
+ + '(seam-declared, plus bootstrap auxiliaries)');
531
+ }
532
+
533
+ // WHICH TEMPLATE AN ID ACTUALLY MEANS FOR THIS RUN.
534
+ //
535
+ // A brownfield run wants different text from the same seam. The variant is a dimension of the
536
+ // ID, so it is resolved once, here, and every reader that must follow it calls this — nobody
537
+ // chooses per call site and nobody can forget.
538
+ //
539
+ // WHAT DELIBERATELY DOES NOT USE IT: the existence guards and _layerOf, which stay on the BASE.
540
+ // The base must exist for every provisioned id regardless of variant — that is what makes the
541
+ // fallback total — and a variant must never move a prompt between layers, because that is the
542
+ // same seam being served from a different zone.
543
+ const _tplId = (id) => variantIdFor(id, (v) => fs.existsSync(path.join(templatesDir, `${v}.json`)));
544
+ const _tplPath = (id) => path.join(templatesDir, `${_tplId(id)}.json`);
545
+
546
+ // ONLY WHAT A PROJECT-LAYER RENDERER READS.
547
+ //
548
+ // There are two renderers and the split is deliberate: prompt-library.js reads the PROJECT
549
+ // copy, engine-prompt.js reads the TEMPLATE. A project copy of an engine-layer prompt can
550
+ // therefore never be executed — codeline discovery, for one, runs before the mint that would
551
+ // have written its copy.
552
+ //
553
+ // Provisioning them anyway cost three things: model spend under 'generate' mode; a file that
554
+ // reads as live to anyone who opens it; and a place for self-heal corrections to land and do
555
+ // nothing. One had already gone stale against its template and silently dropped the rule that
556
+ // makes discovery emit codeline facts — a capability lost in a file nothing runs.
557
+ //
558
+ // The template declares its own layer, because inference does not hold: a project prompt can be
559
+ // addressed dynamically (agent-inputs.js resolves an id through the seam registry), so no scan
560
+ // of call sites is complete. Absent means engine — the safe default writes nothing.
561
+ // EXISTENCE FIRST. Reading the layer of a template that is not there cannot answer the
562
+ // question, and answering 'engine' would silently drop it — turning a bootstrap that names a
563
+ // missing template into a quiet no-op instead of the loud refusal it must be.
564
+ for (const id of [...copyVerbatim, ...generated]) {
565
+ const p = path.join(templatesDir, `${id}.json`);
566
+ if (!fs.existsSync(p)) {
567
+ throw new Error(`[prompt-builder] bootstrap declares '${id}' but no template exists at ${p}`);
568
+ }
569
+ }
570
+
571
+ const _layerOf = (id) => readJson(path.join(templatesDir, `${id}.json`)).layer || 'engine';
572
+
573
+ // A TEMPLATE THIS BUILDER ITSELF READS CANNOT BE FILTERED OUT OF ITS OWN INSTALL.
574
+ //
575
+ // The layer heuristic decides whether a project copy is worth writing. But this builder reads
576
+ // the PROJECT copy of the generator prompt to produce every other prompt (see genId below), so
577
+ // dropping it is self-contradictory: bootstrap declares it, the filter removes it, and the
578
+ // failure surfaces eighty lines later as "'project-prompt-generation' is not installed. It must
579
+ // be declared in bootstrap.copyVerbatim" — which it already was.
580
+ //
581
+ // Derived from the code that reads them, not a list anyone maintains: whatever this builder
582
+ // requires from outDir is required, whatever its template metadata happens to say.
583
+ const _requiredFromProjectCopy = [PROJECT_PROMPT_GENERATOR_ID];
584
+ for (const id of _requiredFromProjectCopy) {
585
+ if ([...copyVerbatim, ...generated].includes(id) && _layerOf(id) !== 'project') {
586
+ throw new Error(
587
+ `[prompt-builder] '${id}' is declared in bootstrap but its template is layer='${_layerOf(id)}', `
588
+ + 'so provisioning would drop it — and this builder reads the project copy of it to generate '
589
+ + 'every other prompt. Set "layer": "project" on the template, or remove it from bootstrap.');
590
+ }
591
+ }
592
+
593
+ // A TEMPLATE A SEAM RUNS IS PROJECT-LAYER BY DEFINITION.
594
+ //
595
+ // The layer field was the only gate, and absent defaults to 'engine' — so 37 of 44 templates
596
+ // were dropped and their seams executed the generic parent on a specific project. That is the
597
+ // condition this whole layer exists to prevent, decided by a metadata field nobody set.
598
+ //
599
+ // Running it is the proof: if a seam names a template, that seam needs a specialised copy. The
600
+ // layer field still decides for templates NO seam names — auxiliary bodies that only another
601
+ // prompt references.
602
+ const _seamRun = new Set();
603
+ if (registryFile && fs.existsSync(registryFile)) {
604
+ for (const p of Object.values(readJson(registryFile).profiles || {})) {
605
+ if (p.template) _seamRun.add(p.template);
606
+ }
607
+ }
608
+ const _needsProjectCopy = (id) => _seamRun.has(id) || _layerOf(id) === 'project';
609
+ const _dropped = [...copyVerbatim, ...generated].filter((id) => !_needsProjectCopy(id));
610
+ if (_dropped.length) {
611
+ // Said out loud. A silently shorter list is indistinguishable from a bootstrap that never
612
+ // declared them.
613
+ log(`[prompt-builder] ${_dropped.length} template(s) are engine-layer and are not provisioned `
614
+ + `— nothing would ever read a project copy: ${_dropped.join(', ')}`);
615
+ }
616
+ copyVerbatim = copyVerbatim.filter(_needsProjectCopy);
617
+ generated = generated.filter(_needsProjectCopy);
618
+
619
+ // TWO PROVISIONING MODES, one per project, by operator design:
620
+ //
621
+ // 'copy' the template layer is installed AS IS. What the project runs is exactly the
622
+ // generic text — no model involved, nothing to draw differently twice.
623
+ // 'generate' everything outside the bootstrap set is specialised by the agent that minted
624
+ // the roster.
625
+ //
626
+ // The mode belongs to the PROJECT, not to this file: a project is data. Copy mode is not a
627
+ // degraded generate — it is the deliberate choice for a project whose prompts should stay
628
+ // identical to the generic ones until someone decides otherwise.
629
+ if (mode === 'copy') {
630
+ copyVerbatim = [...copyVerbatim, ...generated];
631
+ generated = [];
632
+ } else if (mode !== 'generate') {
633
+ throw new Error(`[prompt-builder] unknown mode '${mode}' — expected 'copy' or 'generate'`);
634
+ }
635
+
636
+ // ── A MULTI-PART TEMPLATE CANNOT SURVIVE GENERATION, SO IT IS NOT GENERATED ────────────────
637
+ //
638
+ // A template may declare several named bodies, and each consumer renders ONE by name:
639
+ //
640
+ // renderEngineTemplate('spec-story-block', {...}, 'retrieved_docs')
641
+ //
642
+ // Generation had no concept of that. templateBodyText() joins every part into one string, the
643
+ // model rewrites the join, and buildGeneratedDoc writes a single `body`. The installed copy
644
+ // therefore has no parts at all and declares the UNION of every part's placeholders.
645
+ //
646
+ // What that does to a consumer asking for a part:
647
+ // - where its values do not cover the union, the render is REFUSED. Live 2026-09-04:
648
+ // "ticket-link review skipped (prompt 'spec-story-block' is missing values for:
649
+ // __COMMENT_BLOCK__, __COMPONENTS__, ... __TITLE__)"
650
+ // after the review had already fetched 4 of 4 linked documents, which then never reached
651
+ // the run;
652
+ // - where they DO cover it, there is no error at all and the agent is silently handed every
653
+ // part concatenated — several prompts glued together. That is the worse half, and it was
654
+ // invisible.
655
+ //
656
+ // 18 seam-declared templates are multi-part, including story-writer, spec-agent, the failure
657
+ // analyst and the QA gates. None of them has ever been correctly specialised: the specialised
658
+ // text was only ever delivered as an unusable merge. So copying them verbatim is not a loss of
659
+ // a working capability — it replaces a corrupt merge with the correct parts, costs 18 fewer
660
+ // model calls, and shortens the mint.
661
+ //
662
+ // Per-part generation is the richer answer and remains open: this is the decision that makes
663
+ // the parts correct now, and it is reversible.
664
+ const _multiPart = (id) => {
665
+ try {
666
+ const t = readJson(_tplPath(id));
667
+ return t && t.bodies && typeof t.bodies === 'object'
668
+ && Object.values(t.bodies).filter((b) => typeof b === 'string').length > 1;
669
+ } catch { return false; }
670
+ };
671
+ const _movedToVerbatim = generated.filter(_multiPart);
672
+ if (_movedToVerbatim.length) {
673
+ generated = generated.filter((id) => !_multiPart(id));
674
+ copyVerbatim = [...copyVerbatim, ..._movedToVerbatim];
675
+ // Said out loud: a provisioning decision nobody can see is one nobody can reverse.
676
+ log(`[prompt-builder] ${_movedToVerbatim.length} multi-part template(s) are copied verbatim `
677
+ + 'rather than generated — generation flattens their named bodies into one, which hands a '
678
+ + `consumer every part at once: ${_movedToVerbatim.join(', ')}`);
679
+ }
680
+
681
+ // EVERY DECLARED TEMPLATE MUST EXIST BEFORE ANYTHING IS WRITTEN. Discovering a missing one
682
+ // halfway through leaves the project partially provisioned, which starts and then dies.
683
+ for (const id of [...copyVerbatim, ...generated]) {
684
+ const p = path.join(templatesDir, `${id}.json`);
685
+ if (!fs.existsSync(p)) {
686
+ throw new Error(`[prompt-builder] bootstrap declares '${id}' but no template exists at ${p}`);
687
+ }
688
+ }
689
+
690
+ const outDir = path.join(projectConfigDir, 'prompts');
691
+ fs.mkdirSync(outDir, { recursive: true });
692
+
693
+ // THE PREVIOUS RUN'S COMPLETION MARKER GOES FIRST.
694
+ //
695
+ // From here on this run is rewriting the project's prompts. If it dies partway, a marker
696
+ // left by the LAST run still reads 'complete', and the NEXT run would inherit a
697
+ // half-written set as finished. Cleared on the way in, rewritten only on the way out.
698
+ // The operator's explicit id still wins; otherwise the run's own detected scope decides.
699
+ const safeCodeline = codelineSegment(process.env.EPAM_CODELINE_ID)
700
+ || codelineSegment(codelineFromPrd(process.env.PRD_FILE));
701
+ // ONLY THIS CODELINE'S CLAIM: another codeline's completion records its own agents and prompts,
702
+ // so it stays true while this one rewrites prompts/ (A -> B -> A, 2026-10-01).
703
+ clearCompletionMarker({ outDir, codeline: safeCodeline });
704
+
705
+ // ── Bootstrap: verbatim, byte for byte ──────────────────────────────────
706
+ const copied = [];
707
+ // A RESUME COMPLETES, IT NEVER REBUILDS. keepExisting (a resumed run): a prompt already installed
708
+ // for this run is kept byte-for-byte — no call, no rewrite — and only the missing ones are built.
709
+ // An interrupted build left 15 of 43 prompts and a resume either skipped provisioning (so the
710
+ // failure analyst had no prompt) or would have rebuilt all of them (found 2026-09-25).
711
+ //
712
+ // KEPT ONLY WHILE IT IS STILL THE COPY OF ITS TEMPLATE. Each copy records the digest of the template
713
+ // it came from (derivedFromSha256); a resume that kept it regardless went on executing the old
714
+ // template's text after the template was corrected — a fix could not reach a resumed run (the
715
+ // analysts' fix-site input, 2026-10-04). A copy whose template moved on is rebuilt, and says so.
716
+ const _verbatimDigest = (doc) => (doc.bodies !== undefined
717
+ ? crypto.createHash('sha256').update(JSON.stringify(doc.bodies, null, 0)).digest('hex')
718
+ : crypto.createHash('sha256').update(String(doc.body)).digest('hex'));
719
+ const _alreadyInstalled = (id, kind) => {
720
+ const file = path.join(outDir, `${id}.json`);
721
+ if (!keepExisting || !fs.existsSync(file)) return false;
722
+ let installed; let tpl;
723
+ try { installed = readJson(file); tpl = readJson(_tplPath(id)); } catch { return false; }
724
+ const want = kind === 'generated' ? generatedTemplateDigest(tpl) : _verbatimDigest(tpl);
725
+ if (installed && installed.derivedFromSha256 === want) return true;
726
+ log(`[prompt-builder] ${id}: its template changed since this copy was made — rebuilding it`);
727
+ return false;
728
+ };
729
+ for (const id of copyVerbatim) {
730
+ if (_alreadyInstalled(id, 'verbatim')) { copied.push(id); log(`[prompt-builder] kept ${id} (installed for this run)`); continue; }
731
+ const src = _tplPath(id);
732
+ const doc = readJson(src);
733
+
734
+ // THE TEXT IS COPIED VERBATIM; THE PROVENANCE IS ADDED.
735
+ //
736
+ // A project copy must record which template it came from, or a later template edit is
737
+ // invisible: the project keeps running the older prompt while the template claims
738
+ // otherwise. Writing the file byte-for-byte lost that — and worse, ERASED the provenance
739
+ // the hand-minted copies already carried.
740
+ //
741
+ // The digest covers the prompt TEXT only, matching the convention the existing copies
742
+ // used: bodies for a multi-part prompt, body for a single one.
743
+ // TWO CONVENTIONS ALREADY EXIST, and both are load-bearing because separate tests assert
744
+ // each: a MULTI-PART prompt hashes JSON.stringify(bodies), a single-body prompt hashes the
745
+ // RAW body string. Picking one would have silently invalidated every copy of the other
746
+ // shape. Matched rather than unified — unifying them is a change to what "unchanged"
747
+ // means for prompts already in the field.
748
+ doc.derivedFromSha256 = _verbatimDigest(doc);
749
+ doc.authority = 'project';
750
+
751
+ fs.writeFileSync(path.join(outDir, `${id}.json`), JSON.stringify(doc, null, 2) + '\n');
752
+ copied.push(id);
753
+ log(`[prompt-builder] copied ${id}`);
754
+ }
755
+
756
+ // The generator's own prompt comes from the project copy just installed, not from an
757
+ // engine-side string.
758
+ const genId = PROJECT_PROMPT_GENERATOR_ID;
759
+ const genPath = path.join(outDir, `${genId}.json`);
760
+ const generatorBody = fs.existsSync(genPath) ? readJson(genPath).body : null;
761
+ if (generated.length && !generatorBody) {
762
+ throw new Error(
763
+ `[prompt-builder] cannot generate ${generated.length} prompt(s): '${genId}' is not installed. `
764
+ + 'It must be declared in bootstrap.copyVerbatim — it cannot be its own output.');
765
+ }
766
+
767
+ // ── Generated: specialise, check, then install ──────────────────────────
768
+ const built = [];
769
+ // WHAT WROTE AND JUDGED A GENERATED PROMPT: the generator's and the reviewer's installed copies, by the template
770
+ // digest each records. A kept prompt is judged again only when this differs from what it recorded.
771
+ const _judges = () => [genId, 'prompt-review'].map((j) => {
772
+ try { return readJson(path.join(outDir, `${j}.json`)).derivedFromSha256 || ''; } catch { return ''; }
773
+ }).join(':');
774
+ // WHERE THE TEMPLATES ARE BEING READ FROM, said once.
775
+ //
776
+ // Run 20260817T211517Z installed a copy whose seams did not match its template and whose
777
+ // provenance digest was empty — a value buildGeneratedDoc cannot produce. The same code
778
+ // reproduces correctly offline, so what differs is which FILE was read, and nothing recorded
779
+ // that. One line here answers it instead of another run spent guessing.
780
+ log(`[prompt-builder] templates read from ${templatesDir}`);
781
+
782
+ // ── REUSE: DO NOT PAY TWICE FOR THE SAME PROMPT ──────────────────────────────────────────
783
+ //
784
+ // The pre-run reset deletes <project>/prompts every run, so every prompt was regenerated from
785
+ // unchanged immutable templates. Measured on mock3 run 9: 29 prompts, $5.48 — 89% of the run's
786
+ // cost, before a single story was touched.
787
+ //
788
+ // The obstacle is __MINTED_ROLES__: the mint invents new role names every run, so a digest over
789
+ // all inputs never matches. But of run 9's 31 generated prompts only FOUR embed a minted role
790
+ // name. Whether a template's OUTPUT depends on the roster is a fact about that template, and it
791
+ // is knowable — after generating once, by looking at what it produced.
792
+ //
793
+ // Each entry records both digests and which one governs it: a prompt naming a minted role is
794
+ // regenerated when the roster changes, one that does not is reused. Nothing is guessed — a
795
+ // template not in the cache is always generated.
796
+ //
797
+ // The cache lives OUTSIDE prompts/ so the reset's clean slate is untouched. This is memoisation
798
+ // on an exact key, not surviving state.
799
+ const cacheRoot = path.join(outDir, '..', '.prompt-cache');
800
+ // KEYED BY CODELINE, because that is what a generated prompt is specialised FOR.
801
+ //
802
+ // Operator: "tagged to a CODELINE and reused for the CODELINE". The completion marker already
803
+ // carries the codeline in its name; the entries it vouches for did not, so a run under codeline
804
+ // B reused prompts naming codeline A's repositories, modules and dependencies. That is the
805
+ // cross-codeline contamination the marker exists to prevent, arriving through what it points at.
806
+ //
807
+ // A codeline id reaches here from configuration and from a discovery step's output, so it is
808
+ // reduced to a single safe path segment before it names a directory — 'a/b' and '../x' must not
809
+ // decide where this process writes.
810
+ // The operator's explicit id still wins; otherwise the run's own detected scope decides.
811
+ const cacheDir = safeCodeline ? path.join(cacheRoot, safeCodeline) : cacheRoot;
812
+ // MIGRATION, not discard. Entries written before this change sit flat at the cache root and
813
+ // record no codeline, so they can only be attributed by the codeline the run declares. Moving
814
+ // them costs nothing; discarding them would charge a full regeneration for a change that is
815
+ // purely about where they live. It happens once: after the move the root holds no *.json.
816
+ migrateFlatEntries(cacheRoot, cacheDir, log);
817
+ const sha = (t) => crypto.createHash('sha256').update(String(t)).digest('hex');
818
+ // THE TEMPLATE AND THE GENERATOR — NOT THE PROSE ABOUT THE PROJECT.
819
+ //
820
+ // This keyed on { t, generatorBody, projectContext, codelineContext }. The last two are written
821
+ // by the estate survey and codeline discovery, both of which run EVERY run, and a model rewords
822
+ // itself every time. So the key changed on every run and the cache never hit once: measured
823
+ // 2026-09-06 on pipeline-tests-27, 39 entries and 0 hits, with the templates byte-identical
824
+ // between v1.43 and v1.47 (git diff over prompts/templates: 0 files).
825
+ //
826
+ // Exactly the defect rolesIdentity fixes immediately below, one layer up and left unfixed:
827
+ // "Digesting the mint's raw text rebuilt every roster-dependent prompt on every run because a
828
+ // model rewords itself."
829
+ //
830
+ // What remains in the key is what actually decides the generated text: the template, and the
831
+ // generator body that rewrites it. The contexts are still PASSED to the generator — they shape
832
+ // the prompt — they just no longer invalidate a prompt that would come out the same.
833
+ //
834
+ // Codeline identity is not weakened by this: it is enforced separately and more strictly by the
835
+ // per-codeline completion marker (.complete-<codeline>), so one codeline's prompts cannot serve
836
+ // another even though the prose is no longer in the key.
837
+ const baseDigest = (t) => sha(JSON.stringify({ t, generatorBody }));
838
+ // The ROLES, not the prose about them — see rolesIdentity. Digesting the mint's raw text
839
+ // rebuilt every roster-dependent prompt on every run because a model rewords itself.
840
+ const rolesDigest = sha(rolesIdentity(mintedRoles));
841
+ const usesRoles = (doc, roles) => {
842
+ const names = String(roles || '').match(/[a-z][a-z0-9]*(?:-[a-z0-9]+)+/g) || [];
843
+ const body = JSON.stringify(doc);
844
+ return names.some((n) => body.includes(n));
845
+ };
846
+ const cacheRead = (id) => {
847
+ // NO CODELINE DECLARED CHANGES NOTHING. The requirement is that a codeline's prompts are
848
+ // reused FOR that codeline; it says nothing about a run that declares none, and no launcher in
849
+ // this repo sets EPAM_CODELINE_ID — the operator exports it. Making an undeclared run stop
850
+ // reusing would silently charge a full regeneration for a forgotten variable, and would break
851
+ // the remedy a-reused-prompt-was-reviewed encodes. Such a run reads the flat root, as before.
852
+ try { return JSON.parse(fs.readFileSync(path.join(cacheDir, `${_tplId(id)}.json`), 'utf8')); }
853
+ catch { return null; }
854
+ };
855
+ const cacheWrite = (id, entry) => {
856
+ try {
857
+ fs.mkdirSync(cacheDir, { recursive: true });
858
+ fs.writeFileSync(path.join(cacheDir, `${_tplId(id)}.json`), JSON.stringify(entry, null, 2) + '\n');
859
+ } catch { /* a cache that cannot be written costs money, never correctness */ }
860
+ };
861
+
862
+ // THE REVIEWER'S OWN PROMPT IS PROVISIONED FIRST, because it cannot review anything until it
863
+ // exists. In run 20260827T151832Z it was generated 32nd of 39: the first 31 prompts were
864
+ // handed to a reviewer whose prompt was not on disk yet, the render failed, and every one of
865
+ // them was installed UNREVIEWED — while the log said review was enabled.
866
+ //
867
+ // Ordering only. Nothing is skipped and nothing is generated twice; the reviewer is simply
868
+ // built before the artefacts it must judge.
869
+ // WHICH prompt is the reviewer's is DERIVED, never named here: it is the template run by the
870
+ // seam that produces the prompt verdict. A literal would put a seam's identity in engine code
871
+ // and go stale the moment the seam is renamed.
872
+ const _reviewerTemplate = (() => {
873
+ try {
874
+ const _reg = registryFile && fs.existsSync(registryFile) ? readJson(registryFile) : null;
875
+ for (const prof of Object.values((_reg && _reg.profiles) || {})) {
876
+ if (prof && prof.produces === 'prompt-verdict' && prof.template) return prof.template;
877
+ }
878
+ } catch { /* absent stays absent — provisioning order is then unchanged */ }
879
+ return '';
880
+ })();
881
+ if (_reviewerTemplate) {
882
+ generated = [...generated].sort((a, b) => (a === _reviewerTemplate ? -1
883
+ : b === _reviewerTemplate ? 1 : 0));
884
+ }
885
+ // ONE ROUTE TO THE ANALYST for every refusal of a generated prompt — the contract's and the
886
+ // reviewer's, of a fresh generation, a cached copy or a kept one — with the text that was refused.
887
+ const _heal = (id, template, reason, output, failureClass) => {
888
+ try {
889
+ // eslint-disable-next-line global-require
890
+ const _sh = require('./self-heal.js').selfHeal({
891
+ agent: `prompt-builder:${id}`, reason, output: output, failureClass,
892
+ // The rung that produced it — without this the analyst declines and heals nothing.
893
+ model: process.env.EPAM_MODEL || '', provider: process.env.AI_PROVIDER || '',
894
+ projectConfigDir: process.env.EPAM_PROJECT_CONFIG_DIR || '',
895
+ context: healContext(id, template),
896
+ });
897
+ if (_sh && _sh.rc === 2) log(`[prompt-builder] self-heal analyst FAILED for ${id} — the next attempt has no corrective`);
898
+ } catch { /* a diagnostic must never fail the run it is diagnosing */ }
899
+ };
900
+ for (const id of generated) {
901
+ // WHY THE PREVIOUS ANSWER WAS REFUSED — seeded when a kept or cached copy is rejected, so the
902
+ // regeneration that follows is told what was wrong rather than asked the same question again.
903
+ let refusal = '';
904
+ // A KEPT COPY IS JUDGED AGAIN BY THE CURRENT REVIEWER (operator, 2026-10-06). A resume keeps the
905
+ // prompts its run built, and the contract cannot see what the reviewer can: metrolinx's
906
+ // repro-test-writer was narrowed to one ticket with its slots displaced, every slot still present.
907
+ // Keeping it unexamined meant no correction to the generator or the reviewer could reach the run.
908
+ // Approved, or no verdict (a reviewer's own failure is not evidence against the prompt): kept.
909
+ // Rejected: regenerated, told why, and the rejection goes to the analyst.
910
+ let _keptRejected = false;
911
+ if (_alreadyInstalled(id, 'generated')) {
912
+ if (typeof reviewPrompt !== 'function') { built.push(id); log(`[prompt-builder] kept ${id} (installed for this run)`); continue; }
913
+ const _kept = readJson(path.join(outDir, `${id}.json`));
914
+ // JUDGED AGAIN ONLY WHEN WHAT JUDGED IT CHANGED (operator, 2026-10-13: "I don't want a full review"). The rule
915
+ // above exists so a corrected generator or reviewer reaches a resumed run; a copy whose template, generator and
916
+ // reviewer are the ones that approved it has nothing new to be judged by. A copy that predates the record is
917
+ // kept as built — re-judging it is the full review this replaced.
918
+ if (!_kept.judgedWith || _kept.judgedWith === _judges()) {
919
+ built.push(id);
920
+ log(`[prompt-builder] kept ${id} (installed for this run) — ${_kept.judgedWith
921
+ ? 'its template, generator and reviewer are the ones that approved it' : 'no record of what judged it; kept as built'}`);
922
+ continue;
923
+ }
924
+ const _v = await reviewPrompt({ id, template: readJson(_tplPath(id)), generated: _kept });
925
+ if (!_v || _v.ok !== false) {
926
+ built.push(id);
927
+ log(`[prompt-builder] kept ${id} (installed for this run) — ${_v && _v.reviewed === false
928
+ ? `unreviewed: the reviewer gave no verdict (${_v.reason || 'no reason'})`
929
+ : 'judged again by the current reviewer and approved'}`);
930
+ continue;
931
+ }
932
+ _keptRejected = true;
933
+ refusal = `the copy this run was using was rejected by the reviewer: ${_v.reason}`;
934
+ log(`[prompt-builder] ! ${id}: the kept copy was REJECTED by the current reviewer (${_v.reason}) — regenerating`);
935
+ _heal(id, readJson(_tplPath(id)), _v.reason, _kept.body, 'rejected');
936
+ }
937
+ const template = readJson(_tplPath(id));
938
+ // Per-template, only when it could matter: a template whose seam name differs from its id is
939
+ // the one case where losing `seams` is visible at all. 36 of 37 hide the same rewrite.
940
+ if (Array.isArray(template.seams) && template.seams.some((sm) => sm !== template.id)) {
941
+ log(`[prompt-builder] ${id}: template declares seams ${JSON.stringify(template.seams)}`);
942
+ }
943
+ // REUSE, before any model time is spent.
944
+ const _base = baseDigest(template);
945
+ const _hit = cacheRead(id);
946
+ // A CACHE ENTRY IS ONLY A HIT IF IT PASSED THE GATES THAT APPLY NOW.
947
+ //
948
+ // The key was (template, generatorBody, contexts) and said nothing about REVIEW. So when
949
+ // review was finally switched on, 39 entries written while it was off were reused verbatim —
950
+ // never regenerated, therefore never reviewed — and the run logged "prompt review ENABLED"
951
+ // while reviewing almost nothing.
952
+ //
953
+ // Memoisation on inputs must also be keyed on the gates the artefact passed. An entry made
954
+ // without review is a MISS while review is on; one made WITH review stays a hit, so the cache
955
+ // keeps paying for itself.
956
+ const _reviewNow = typeof reviewPrompt === 'function';
957
+ // A kept copy the reviewer just rejected is not served back from the cache, which may hold it.
958
+ const _inputsSame = !_keptRejected && !!(_hit && _hit.base === _base && (!_hit.usesRoles || _hit.roles === rolesDigest));
959
+ // WHAT A CACHED, UNREVIEWED PROMPT LACKS IS A REVIEW — NOT A NEW PROMPT. It used to be
960
+ // regenerated "so it is reviewed": a paid generation repeated (on a resume, a step run twice)
961
+ // when only the reviewer had not answered. It is reviewed as it stands; regenerated only if
962
+ // that review finds a false claim.
963
+ let _useCached = _inputsSame && (!_reviewNow || _hit.reviewed === true);
964
+ if (_inputsSame && _reviewNow && _hit.reviewed !== true) {
965
+ const _late = await reviewPrompt({ id, template, generated: _hit.doc });
966
+ if (_late && _late.ok !== false) {
967
+ _hit.reviewed = _late.reviewed !== false;
968
+ cacheWrite(id, _hit);
969
+ log(`[prompt-builder] ${id}: cached copy ${_hit.reviewed ? 'reviewed now' : 'still unreviewed — the reviewer gave no verdict'} — reused, not regenerated`);
970
+ _useCached = true;
971
+ } else {
972
+ log(`[prompt-builder] ${id}: cached copy failed review (${(_late && _late.reason) || 'no reason'}) — regenerating`);
973
+ refusal = `the cached copy was rejected by the reviewer: ${(_late && _late.reason) || 'no reason'}`;
974
+ _heal(id, template, (_late && _late.reason) || 'rejected by the reviewer', _hit.doc && _hit.doc.body, 'rejected');
975
+ }
976
+ }
977
+ if (_useCached) {
978
+ fs.writeFileSync(path.join(outDir, `${id}.json`), JSON.stringify(_hit.doc, null, 2) + '\n');
979
+ built.push(id);
980
+ log(`[prompt-builder] reused ${id} (inputs unchanged${_hit.usesRoles ? ', roster unchanged' : ''})`);
981
+ continue;
982
+ }
983
+
984
+ let callFailure = '';
985
+ let installed = false;
986
+
987
+ for (let attempt = 1; attempt <= Math.max(1, attempts); attempt += 1) {
988
+ // ONE TURN PER PART: a set that declares an answer budget gets the segments in groups that
989
+ // fit it (CodeMie web cuts a longer answer and calls it a success — measured 2026-09-30).
990
+ const _allSegments = require('./project-prompt-contract.js').splitByPlaceholders(templateBodyText(template)).segments;
991
+ const _plan = promptParts(_allSegments, answerBudgetChars());
992
+ const _parts = _plan.groups.map((g, index) => ({ index, of: _plan.groups.length, from: g.from, to: g.to,
993
+ texts: _plan.units.slice(g.from, g.to).map((u) => u.text) }));
994
+ const prompt = renderGeneratorPrompt({
995
+ generatorBody, template, projectContext, codelineContext, mintedRoles, refusal, part: _parts.length > 1 ? _parts[0] : null,
996
+ });
997
+ // A CALL THAT NEVER CAME BACK IS THE MOST RETRYABLE FAILURE THERE IS.
998
+ //
999
+ // This loop already retries a prompt that came back WRONG — a dropped placeholder is refused,
1000
+ // the reason is fed back, the next attempt corrects it. A call that THREW escaped the loop
1001
+ // entirely, out of buildProjectPrompts and out of the mint step.
1002
+ //
1003
+ // Live 2026-08-17, run 20260817T185759Z: "prompt runner timed out after 360000ms" destroyed
1004
+ // the estate survey, the minted roster, the story assignment and 12 already-generated
1005
+ // prompts — 30 minutes into the first run that had cleared every earlier stage correctly.
1006
+ // One slow call out of 51.
1007
+ //
1008
+ // Nothing is known to be wrong with the request; it simply did not finish. So it costs one
1009
+ // attempt, exactly as a refusal does — but stays DISTINGUISHABLE from one, because a
1010
+ // contract refusal is fixed in the prompt and a failed call is fixed in the budget or the
1011
+ // provider, and collapsing them sends the reader to the wrong file.
1012
+ let body;
1013
+ const _replies = [];
1014
+ try {
1015
+ body = String((await runText(prompt, { id, attempt })) || '');
1016
+ _replies.push(body);
1017
+ for (const part of _parts.slice(1)) {
1018
+ _replies.push(String((await runText(renderGeneratorPrompt({
1019
+ generatorBody, template, projectContext, codelineContext, mintedRoles, refusal, part,
1020
+ }), { id, attempt })) || ''));
1021
+ }
1022
+ } catch (err) {
1023
+ const why = (err && err.message) || String(err);
1024
+ callFailure = why;
1025
+ refusal = `the previous attempt did not come back: ${why}. Nothing was found wrong with `
1026
+ + 'your answer — it never arrived. Produce the same prompt again.';
1027
+ log(`[prompt-builder] ! ${id} attempt ${attempt}/${attempts} CALL FAILED: ${why}`);
1028
+ continue;
1029
+ }
1030
+ // THE PIPELINE ASSEMBLES THE BODY; THE MODEL ONLY WORDS THE PROSE.
1031
+ //
1032
+ // The reply is {"segments":[...]} — the prose between placeholders, in order. The slots are
1033
+ // put back here, in their original positions, so nothing the model wrote can drop or invent
1034
+ // one. Every contract invariant below then holds by construction rather than by retry.
1035
+ //
1036
+ // A reply that is not parseable segments costs one attempt, exactly like any other refusal,
1037
+ // and says so precisely: the fix for "wrong segment count" is in the prompt, and the reader
1038
+ // must not be sent looking for a dropped placeholder that cannot happen any more.
1039
+ let assembled;
1040
+ try {
1041
+ const { splitByPlaceholders, assembleFromSegments, parseSegmentsReply } =
1042
+ require('./project-prompt-contract.js');
1043
+ const { slots } = splitByPlaceholders(templateBodyText(template));
1044
+ // DELIMITED, NOT JSON. Escaping 7kB of prose containing quotes, commas and newlines is
1045
+ // where the JSON form broke — team-lead-review burned five attempts on replies that were
1046
+ // complete but unparseable. parseSegmentsReply reads the same marker shape the prompt
1047
+ // shows, tolerates a false start by taking the last complete set, and still accepts JSON.
1048
+ // Each part's reply carries its own segments; together, in order, they are the whole.
1049
+ const parsed = _parts.length === 1
1050
+ ? parseSegmentsReply(body, slots.length + 1)
1051
+ : segmentsFromUnits(_plan.units, _parts.flatMap((part, i) => parseSegmentsReply(_replies[i], part.to - part.from)), slots.length + 1);
1052
+ assembled = assembleFromSegments(parsed, slots);
1053
+ } catch (err) {
1054
+ refusal = `your answer could not be assembled: ${(err && err.message) || String(err)}. `
1055
+ + 'Reply with one --- SEGMENT n --- marker per segment shown, in order, each followed by '
1056
+ + 'your wording, closing with --- END SEGMENTS ---. Plain text, no JSON, no code fences, '
1057
+ + 'and no placeholder tokens.';
1058
+ log(`[prompt-builder] ! ${id} attempt ${attempt}/${attempts} refused: ${refusal}`);
1059
+ continue;
1060
+ }
1061
+ const doc = buildGeneratedDoc(template, assembled);
1062
+ const verdict = checkGeneratedPrompt(template, doc);
1063
+
1064
+ if (verdict.ok) {
1065
+ // THE CONTRACT CHECK PROVES IT IS WIREABLE, NOT THAT IT IS TRUE.
1066
+ //
1067
+ // checkGeneratedPrompt verifies placeholders in and out. A derivative can satisfy that
1068
+ // completely and still tell an agent this codeline's tests live in a directory it does
1069
+ // not have, or that a dependency is present when it is not — and install cleanly, because
1070
+ // every placeholder was in the right place. The agent then inherits the claim as
1071
+ // instruction and nothing later questions it.
1072
+ //
1073
+ // The roster already gets this treatment: roster-review reads the repositories and
1074
+ // falsifies a generated brief before any implementer inherits it. reviewPrompt is that
1075
+ // reviewer pointed at prompts. It is OPTIONAL — a caller that supplies none provisions
1076
+ // exactly as before, so this cannot block a project that has not adopted it.
1077
+ let review = null;
1078
+ if (typeof reviewPrompt === 'function') {
1079
+ review = await reviewPrompt({ id, template, generated: doc });
1080
+ if (review && review.ok === false) {
1081
+ refusal = `a review of this prompt found claims about the project that are false: ${review.reason}`;
1082
+ log(`[prompt-builder] ! ${id} attempt ${attempt}/${attempts} REVIEW REJECTED: ${review.reason}`);
1083
+ // EVERY REJECTION REACHES THE ANALYST, with the prompt that was rejected — a review
1084
+ // rejection is an agent failure like a contract refusal, and only reached the retry.
1085
+ _heal(id, template, review.reason, doc.body, 'rejected');
1086
+ continue;
1087
+ }
1088
+ }
1089
+
1090
+ // Written only once the contract passes AND the review found nothing false — never a
1091
+ // partial, a refused, or an unreviewed-but-rejected prompt.
1092
+ doc.judgedWith = _judges();
1093
+ fs.writeFileSync(path.join(outDir, `${id}.json`), JSON.stringify(doc, null, 2) + '\n');
1094
+ built.push(id);
1095
+ installed = true;
1096
+ // Whether this template's OUTPUT depends on the roster is decided by looking at what it
1097
+ // produced, not guessed from what it was handed.
1098
+ cacheWrite(id, { base: _base, roles: rolesDigest, usesRoles: usesRoles(doc, mintedRoles), doc,
1099
+ // Whether a reviewer actually JUDGED it — not whether one was configured. A review that
1100
+ // produced no verdict recorded `reviewed: true` here, and a later run reused the prompt as
1101
+ // reviewed (found 2026-09-25).
1102
+ reviewed: !!(review && review.reviewed !== false) });
1103
+ log(`[prompt-builder] generated ${id} (attempt ${attempt}/${attempts})`);
1104
+ break;
1105
+ }
1106
+ refusal = verdict.reason;
1107
+ // THE REFUSED TEXT IS THE ONLY EVIDENCE OF WHY, AND IT WAS THROWN AWAY.
1108
+ //
1109
+ // The log records WHICH placeholders went missing; the model's actual answer — the thing
1110
+ // that would say whether it emitted JSON, truncated, or rewrote the body — was discarded
1111
+ // the moment the verdict came back. Run 20260827T092415Z aborted on 'tc-writer' after three
1112
+ // refusals and left nothing to read, so the cause had to be inferred from the symptom. The
1113
+ // comment at the top of this file records the same blindness misdiagnosing runs 9 and 10.
1114
+ //
1115
+ // Generated output must be persisted — a refused artefact most of all, because it is the
1116
+ // only one nobody can regenerate by rerunning a step that now succeeds.
1117
+ try {
1118
+ const refusedDir = path.join(outDir, '.refused');
1119
+ fs.mkdirSync(refusedDir, { recursive: true });
1120
+ fs.writeFileSync(
1121
+ path.join(refusedDir, `${id}.attempt-${attempt}.txt`),
1122
+ `# refused: ${verdict.reason}\n`
1123
+ + `# template: ${id} attempt: ${attempt}/${attempts}\n`
1124
+ + `# required placeholders: ${(template.placeholders || []).join(', ') || '(none)'}\n`
1125
+ + `# ---------------- model reply below, verbatim ----------------\n`
1126
+ + body);
1127
+ } catch (e) {
1128
+ // Never let evidence-keeping break the build it is evidence about.
1129
+ log(`[prompt-builder] (could not persist refused ${id} attempt ${attempt}: ${e.message})`);
1130
+ }
1131
+ // THE ANALYST SEES THE REFUSED PROMPT ITSELF, not just which placeholder went missing.
1132
+ // Seven generations were refused across runs 13 and 14 and none reached self-heal.
1133
+ _heal(id, template, verdict.reason, body);
1134
+ log(`[prompt-builder] ! ${id} attempt ${attempt}/${attempts} refused: ${verdict.reason}`);
1135
+ }
1136
+
1137
+ if (!installed) {
1138
+ // No fallback. The template is never executed, so there is nothing to degrade to, and
1139
+ // a project missing one prompt must not look provisioned.
1140
+ throw new Error(
1141
+ `[prompt-builder] could not generate a valid '${id}' in ${attempts} attempt(s). `
1142
+ + (callFailure
1143
+ ? `The call itself failed: ${callFailure}. That is a budget or provider problem, not a `
1144
+ + 'prompt one — check the seam\'s declared timeoutSecs before changing the template.'
1145
+ : `Last refusal: ${refusal}`));
1146
+ }
1147
+ }
1148
+
1149
+ // EVERY PROMPT INSTALLED — say so, so the next run against this codeline reuses them.
1150
+ //
1151
+ // Reaching this line is the proof: the loop above THROWS when a prompt cannot be
1152
+ // installed within its attempts ('a project missing one prompt must not look
1153
+ // provisioned'), so there is no partial path to here. The marker records that fact
1154
+ // rather than forming a second opinion that could disagree with it.
1155
+ //
1156
+ // Untagged is unusable: with no codeline the marker is not written at all, because a
1157
+ // marker any codeline could claim is the cross-codeline reuse pre-run-reset refuses.
1158
+ // A STORY OF SEVERAL CODELINES RECORDS ITS SET FOR EVERY ONE OF THEM (operator, 2026-10-22: "prompts/profiles BY codeline";
1159
+ // "Reuse over codelines is fine"): each codeline of the story gets its own recorded set — the same set, reused — so every lane
1160
+ // works from its own codeline's folder (lane_codeline_set_restore) and the mint is reused only when every codeline has one.
1161
+ for (const codeline of (safeCodeline ? [safeCodeline] : codelinesFromPrd(process.env.PRD_FILE))) {
1162
+ writeCompletionMarker({
1163
+ outDir,
1164
+ // THE SAME codeline the entries were filed under. A marker naming one codeline while
1165
+ // the cache holds another lets the mint gate skip against a set it never checked.
1166
+ codeline,
1167
+ provisioned: copied.length + built.length,
1168
+ // WHAT THESE PROMPTS WERE BUILT FROM, so the next launch can tell whether they still are.
1169
+ templatesDir,
1170
+ registryFile,
1171
+ });
1172
+ }
1173
+
1174
+ return { copied, generated: built };
1175
+ }
1176
+
1177
+ /**
1178
+ * promptInputsDigest({ templatesDir, registryFile }) — a digest of everything the project's
1179
+ * prompts are built FROM: every template in the layer, the seam registry, and this builder's own
1180
+ * generator. Two launches with the same digest would build byte-identical prompts.
1181
+ *
1182
+ * regintel 20260920T232518Z ($5.73, 2026-09-20): the completion marker was an empty file — it
1183
+ * recorded that a build happened, not what it was built from — so a launch after a day of
1184
+ * template changes reused prompts built from the previous day's templates, and four fixes were
1185
+ * inert ("given values it does not use: __SHARED_FILE_OWNERSHIP_BLOCK__ … DROPPED").
1186
+ */
1187
+ function promptInputsDigest({ templatesDir, registryFile } = {}) {
1188
+ const h = crypto.createHash('sha256');
1189
+ const dir = templatesDir || path.join(__dirname, '..', '..', 'prompts', 'templates');
1190
+ const reg = registryFile || path.join(__dirname, '..', '..', 'agents', 'invocation-profiles.json');
1191
+ let names = [];
1192
+ try { names = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort(); } catch { names = []; }
1193
+ // ONLY THE TEMPLATES THIS BUILDER READS (AMSD-2974 run 9 iteration 3, 2026-10-13): the digest covered every file in
1194
+ // the layer, so adding release-line-port — an engine template no project prompt is built from — made every kept
1195
+ // prompt of the run "changed" and sent all 44 back to the reviewer, paid, mid-run. The inputs are the ones the build
1196
+ // itself provisions (bootstrap.copyVerbatim, provisioningList) and the variant each resolves to; a bootstrap that
1197
+ // cannot be read leaves the whole layer in, as before.
1198
+ const _inputs = promptInputIds({ dir, registryFile: reg });
1199
+ if (_inputs) names = names.filter((n) => _inputs.has(n.replace(/\.json$/, '')));
1200
+ for (const n of names) {
1201
+ h.update(n); h.update('\0');
1202
+ try { h.update(fs.readFileSync(path.join(dir, n))); } catch { /* absent counts as absent */ }
1203
+ h.update('\0');
1204
+ }
1205
+ try { h.update('registry\0'); h.update(fs.readFileSync(reg)); } catch { /* none */ }
1206
+ try { h.update('builder\0'); h.update(fs.readFileSync(__filename)); } catch { /* none */ }
1207
+ return h.digest('hex');
1208
+ }
1209
+
1210
+ /**
1211
+ * promptInputIds({ dir, registryFile }) — the template ids a build reads: bootstrap.copyVerbatim, the provisioning list
1212
+ * (the same provisioningList the build uses) and the variant each resolves to for this run. null when the bootstrap
1213
+ * beside the templates cannot be read.
1214
+ */
1215
+ function promptInputIds({ dir, registryFile }) {
1216
+ let boot;
1217
+ try { boot = readJson(path.join(dir, '..', 'bootstrap.json')); } catch { return null; }
1218
+ let registry = {};
1219
+ try { registry = readJson(registryFile); } catch { registry = {}; }
1220
+ const exists = (id) => fs.existsSync(path.join(dir, `${id}.json`));
1221
+ const ids = new Set();
1222
+ const copy = Array.isArray(boot.copyVerbatim) ? boot.copyVerbatim : [];
1223
+ for (const id of [...copy, ...provisioningList({ bootstrap: boot, registry, templateExists: exists })]) {
1224
+ ids.add(id);
1225
+ ids.add(variantIdFor(id, exists));
1226
+ }
1227
+ return ids;
1228
+ }
1229
+
1230
+ /**
1231
+ * codelinePromptsComplete({ projectConfigDir, codeline, templatesDir, registryFile }) — whether
1232
+ * the prompts on disk for this codeline were built from the CURRENT inputs. An empty marker (the
1233
+ * old shape) proves nothing and reads as not complete. Returns { complete, reason }.
1234
+ */
1235
+ function codelinePromptsComplete({ projectConfigDir, codeline, templatesDir, registryFile } = {}) {
1236
+ if (!projectConfigDir || !codeline) return { complete: false, reason: 'no project or codeline named' };
1237
+ const variant = process.env.EPAM_BROWNFIELD === '1' ? BROWNFIELD_VARIANT : '';
1238
+ const p = path.join(projectConfigDir, '.prompt-cache', `.complete-${String(codeline).trim()}${variant}`);
1239
+ let stored = '';
1240
+ try { stored = fs.readFileSync(p, 'utf8').trim(); } catch { return { complete: false, reason: `no completion marker at ${p}` }; }
1241
+ if (!stored) return { complete: false, reason: 'the completion marker is empty — it records that a build happened, not what it was built from; rebuilding from the current templates' };
1242
+ const now = promptInputsDigest({ templatesDir, registryFile });
1243
+ if (stored !== now) return { complete: false, reason: `the prompt inputs changed since these prompts were built (template layer, registry or generator: ${stored.slice(0, 12)} → ${now.slice(0, 12)}) — rebuilding the changed prompts; the roster is kept` };
1244
+ return { complete: true, reason: 'prompt inputs unchanged since the build' };
1245
+ }
1246
+
1247
+ /**
1248
+ * THE MINT'S OUTPUTS — the files a codeline's agents live in. Defined HERE, once: the mint's clean
1249
+ * slate (lib/mint-and-spec.sh, via mintedFiles) clears exactly these, and a completion records
1250
+ * exactly these. Per project on disk; per codeline in the cache, as the prompts are.
1251
+ */
1252
+ const MINTED_FILES = ['roster.json', 'project-roles.json', 'project-investigators.json', 'agent-profiles.json'];
1253
+
1254
+ /** The marker-name suffix of a brownfield provisioning (kept in step with lib/prompt-variant.sh). */
1255
+ const BROWNFIELD_VARIANT = '.brownfield';
1256
+
1257
+ /** Where a codeline's completion records the prompts it installed — beside its recorded agents. */
1258
+ function recordedPromptsDir(projectConfigDir, codeline) {
1259
+ return path.join(String(projectConfigDir || ''), '.prompt-cache', codelineSegment(codeline) || '_', 'prompts');
1260
+ }
1261
+
1262
+ function mintedSnapshotDir(projectConfigDir, codeline) {
1263
+ return path.join(String(projectConfigDir || ''), '.prompt-cache', codelineSegment(codeline) || '_', 'minted');
1264
+ }
1265
+
1266
+ function hasMintedSnapshot({ projectConfigDir, codeline } = {}) {
1267
+ const d = mintedSnapshotDir(projectConfigDir, codeline);
1268
+ return fs.existsSync(d) && fs.readdirSync(d).some((f) => MINTED_FILES.includes(f));
1269
+ }
1270
+
1271
+ /**
1272
+ * Record the minted files on disk as THIS codeline's (replacing any earlier record). None on disk
1273
+ * records none — the prompts may still be complete, but their agents can then never be reused.
1274
+ */
1275
+ function snapshotMinted({ projectConfigDir, codeline }) {
1276
+ const d = mintedSnapshotDir(projectConfigDir, codeline);
1277
+ fs.rmSync(d, { recursive: true, force: true });
1278
+ fs.mkdirSync(d, { recursive: true });
1279
+ let n = 0;
1280
+ for (const f of MINTED_FILES) {
1281
+ const src = path.join(projectConfigDir, f);
1282
+ if (fs.existsSync(src)) { fs.copyFileSync(src, path.join(d, f)); n += 1; }
1283
+ }
1284
+ if (!n) fs.rmSync(d, { recursive: true, force: true });
1285
+ return n;
1286
+ }
1287
+
1288
+ /**
1289
+ * Put THIS codeline's recorded agents on disk — every minted file it recorded, and none another
1290
+ * codeline left behind. Returns false (touching nothing) when the codeline recorded none.
1291
+ */
1292
+ function restoreMinted({ projectConfigDir, codeline } = {}) {
1293
+ if (!hasMintedSnapshot({ projectConfigDir, codeline })) return false;
1294
+ const d = mintedSnapshotDir(projectConfigDir, codeline);
1295
+ for (const f of MINTED_FILES) {
1296
+ const rec = path.join(d, f); const dst = path.join(projectConfigDir, f);
1297
+ if (fs.existsSync(rec)) fs.copyFileSync(rec, dst); else fs.rmSync(dst, { force: true });
1298
+ }
1299
+ // AND ITS PROMPTS: prompts/ is one directory per project, so another codeline's may be on disk.
1300
+ // Exactly the recorded set — none another codeline left behind.
1301
+ const rp = recordedPromptsDir(projectConfigDir, codeline);
1302
+ if (fs.existsSync(rp)) {
1303
+ const out = path.join(projectConfigDir, 'prompts');
1304
+ fs.rmSync(out, { recursive: true, force: true });
1305
+ fs.mkdirSync(out, { recursive: true });
1306
+ for (const f of fs.readdirSync(rp).filter((n) => n.endsWith('.json'))) fs.copyFileSync(path.join(rp, f), path.join(out, f));
1307
+ }
1308
+ return true;
1309
+ }
1310
+
1311
+ /** The mint's clean slate: every minted file, removed. */
1312
+ function clearMinted({ projectConfigDir } = {}) {
1313
+ for (const f of MINTED_FILES) fs.rmSync(path.join(String(projectConfigDir || ''), f), { force: true });
1314
+ }
1315
+
1316
+ /**
1317
+ * WHERE THE COMPLETION MARKER LIVES — beside the cache, never inside prompts/.
1318
+ *
1319
+ * pre-run-reset deletes and recreates prompts/ on any run that is not reusing, so a marker in
1320
+ * there could never survive to be read by the run that needs it.
1321
+ */
1322
+ function _markerPath(outDir, codeline) {
1323
+ // THE CODELINE IS THE FILENAME, and the file is empty.
1324
+ //
1325
+ // This was a JSON document carrying {codeline, provisioned, refused} that the consumer parsed
1326
+ // and compared field by field. Every one of those fields was redundant: the producer only runs
1327
+ // after EVERY prompt installed (it throws otherwise), so the file's EXISTENCE already proves
1328
+ // completeness, and putting the codeline in the NAME turns "does it match?" into "does it
1329
+ // exist?". That deletes the parse, the field comparison, and the corrupt-marker case — a file
1330
+ // with no content cannot be malformed.
1331
+ // THE VARIANT IS PART OF THE CLAIM. The marker makes the mint skip ENTIRELY (four call sites),
1332
+ // so without this a codeline provisioned greenfield would skip provisioning on the next
1333
+ // brownfield run and serve the greenfield prompts sitting on disk — on a cached codeline, the
1334
+ // normal case, the brownfield variant would never once execute.
1335
+ //
1336
+ // Greenfield's name is unchanged, byte for byte, so no marker already on disk is invalidated.
1337
+ // Kept in step with prompt_marker_key in lib/prompt-variant.sh, which the shell call sites use.
1338
+ const variant = process.env.EPAM_BROWNFIELD === '1' ? BROWNFIELD_VARIANT : '';
1339
+ return path.join(outDir, '..', '.prompt-cache', `.complete-${codeline}${variant}`);
1340
+ }
1341
+
1342
+ /**
1343
+ * RECORD THAT THIS CODELINE'S PROMPTS ARE COMPLETE, so the next run reuses them.
1344
+ *
1345
+ * Operator, 2026-09-05: "not regenerated unless there is an override for the codeline to
1346
+ * regenerate", and "if prompts complete this run we consider for the cache".
1347
+ *
1348
+ * The fact is already established by control flow — buildProjectPrompts THROWS when a prompt
1349
+ * cannot be installed, so reaching the end means every one of them was. This writes that down
1350
+ * rather than forming a second opinion that could disagree with it.
1351
+ *
1352
+ * UNTAGGED IS UNUSABLE. Without a codeline the marker would be honoured by whichever codeline ran
1353
+ * next, which is exactly the cross-codeline reuse the consumer refuses — so it writes nothing.
1354
+ *
1355
+ * NEVER THROWS. The marker is a cost optimisation; the worst case of losing it is that the next
1356
+ * run regenerates, which is today's behaviour. Failing a provisioned run over it would trade a
1357
+ * saving for an outage.
1358
+ */
1359
+ function writeCompletionMarker({ outDir, codeline, provisioned, templatesDir, registryFile } = {}) {
1360
+ try {
1361
+ if (!outDir) return;
1362
+ if (!codeline || !String(codeline).trim()) return;
1363
+ if (!(Number(provisioned) > 0)) return;
1364
+ const p = _markerPath(outDir, String(codeline).trim());
1365
+ fs.mkdirSync(path.dirname(p), { recursive: true });
1366
+ // The agents this codeline was provisioned with, recorded beside its prompts. The mint keeps a
1367
+ // codeline's agents only by restoring this record (codeline_minted_restore) — a marker alone
1368
+ // says the prompts are complete, never whose agents are on disk.
1369
+ snapshotMinted({ projectConfigDir: path.join(outDir, '..'), codeline: String(codeline).trim() });
1370
+ const rp = recordedPromptsDir(path.join(outDir, '..'), String(codeline).trim());
1371
+ fs.rmSync(rp, { recursive: true, force: true });
1372
+ fs.mkdirSync(rp, { recursive: true });
1373
+ for (const f of fs.readdirSync(outDir).filter((n) => n.endsWith('.json'))) fs.copyFileSync(path.join(outDir, f), path.join(rp, f));
1374
+ // The digest of what was built from — never an empty file (see promptInputsDigest).
1375
+ fs.writeFileSync(p, promptInputsDigest({ templatesDir, registryFile }) + '\n');
1376
+ } catch { /* an optimisation must never fail the run it optimises */ }
1377
+ }
1378
+
1379
+ /**
1380
+ * DROP THE PREVIOUS RUN'S MARKER BEFORE THIS RUN PROVISIONS.
1381
+ *
1382
+ * The dangerous ordering, and the reason this exists separately: this run wipes prompts/ and
1383
+ * starts regenerating. If it dies partway, a marker left by the LAST run still reads "complete",
1384
+ * and the NEXT run inherits a half-written set as though it were finished. Cleared on the way in,
1385
+ * rewritten only on the way out.
1386
+ */
1387
+ function clearCompletionMarker({ outDir, codeline } = {}) {
1388
+ try {
1389
+ if (!outDir) return;
1390
+ const dir = path.join(outDir, '..', '.prompt-cache');
1391
+ // THIS CODELINE'S CLAIM, in either variant: its prompts are being rewritten. Another codeline's
1392
+ // completion recorded its own agents and prompts, and the mint restores them when it keeps
1393
+ // that codeline — so its claim stays true. With no codeline named, no claim is provably
1394
+ // unaffected: every one goes.
1395
+ const cl = String(codeline || '').trim();
1396
+ const mine = cl ? [`.complete-${cl}`, `.complete-${cl}${BROWNFIELD_VARIANT}`] : null;
1397
+ for (const n of (fs.existsSync(dir) ? fs.readdirSync(dir) : [])) {
1398
+ if (!n.startsWith('.complete-')) continue;
1399
+ if (!mine || mine.includes(n)) fs.rmSync(path.join(dir, n), { force: true });
1400
+ }
1401
+ } catch { /* best effort, same reasoning as above */ }
1402
+ }
1403
+
1404
+ module.exports = { buildProjectPrompts, healContext, renderGeneratorPrompt, provisioningList, rolesIdentity, GEN_BODY_SLOT,
1405
+ PROJECT_PROMPT_GENERATOR_ID, answerBudgetChars, promptParts,
1406
+ writeCompletionMarker, clearCompletionMarker, promptInputsDigest, codelinePromptsComplete,
1407
+ MINTED_FILES, mintedSnapshotDir, recordedPromptsDir, hasMintedSnapshot, restoreMinted, clearMinted,
1408
+ // Exported so the codeline keying is assertable without provisioning: a segment that escapes
1409
+ // the cache directory is a defect no end-to-end assertion would localise.
1410
+ codelineSegment, migrateFlatEntries, codelineFromPrd, codelinesFromPrd, codelineOfPrd, scopeCodelineFromPrd, restoreCodelineSet, codelineSetsIdentical, codelineSetComplete,
1411
+ // Exported so the prompt REVIEWER reads a template the same way the generator and the
1412
+ // contract check do. Three readers of one shape is how the last three of these drifted.
1413
+ templateBodyText };