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.
- package/docs/gateway-baseline-2.0.80.md5 +5 -0
- package/docs/gateway-files.txt +5 -0
- package/orchestrations/scripts/lib/codeline-lanes.sh +20 -0
- package/orchestrations/scripts/lib/mint-and-spec.sh +853 -0
- package/orchestrations/scripts/lib/prelaunch.js +21 -1
- package/orchestrations/scripts/lib/project-prompt-builder.js +1413 -0
- package/orchestrations/scripts/lib/prompt-library.js +298 -0
- package/orchestrations/scripts/lib/prompt-variant.sh +123 -0
- package/orchestrations/scripts/mint-agents-step.js +1417 -0
- package/package.json +1 -1
|
@@ -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 };
|