amsd-pipeline-shared 0.5.21 → 0.5.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1417 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mint-agents-step — give this project its own agents, then give every story one.
4
+ *
5
+ * Runs AFTER ingest and BEFORE the spec phase. That position is deliberate: the inputs that
6
+ * make a proposed role project-specific rather than a restatement of the canonical core are
7
+ * the tickets and the documents linked on them, and both exist only once ingest has run.
8
+ * A proposer handed just a repo path proposes roles indistinguishable from the generic core.
9
+ *
10
+ * Two agent calls, both through the pipeline's own seam so they inherit ladder, retry,
11
+ * self-heal, timeout and cost capture like every other agent:
12
+ *
13
+ * 1. mint — propose this project's engineering roles; merge them additively into the
14
+ * roster (canonical roles can never be overwritten), seed each one's KB, and
15
+ * register them as this project's implementation roles.
16
+ * 2. assign — give every story one of those roles. synthesize-prd-from-jira.js leaves
17
+ * agentRole null on purpose: at synthesis nothing has analysed the codeline,
18
+ * so there is no roster to choose from.
19
+ *
20
+ * Exit non-zero on any failure. A story with no role is read as "unknown" by fifteen
21
+ * consumers downstream rather than failing, which is exactly the silence this replaces.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ // HOW LONG A LOCAL TOOL MAY TAKE IS DECLARED, not written here. This was the literal 20000 at
27
+ // two call sites — one decision with two homes, so a codeline large enough to need longer got a
28
+ // truncated scan in both, and raising it meant finding both.
29
+ function localToolTimeoutMs(configPath) {
30
+ try {
31
+ return JSON.parse(require('fs').readFileSync(configPath, 'utf8')).timeouts.localToolMs;
32
+ } catch { return undefined; }
33
+ }
34
+
35
+ const fs = require('fs');
36
+ const path = require('path');
37
+ const { execFileSync, spawnSync } = require('child_process');
38
+
39
+ const spec = require('./spec-mode-runner.js');
40
+ const { refusalBlock } = require('./lib/refusal-block.js');
41
+
42
+ const argv = process.argv.slice(2);
43
+ const getArg = (flag, def = '') => {
44
+ const i = argv.indexOf(flag);
45
+ return i !== -1 ? argv[i + 1] : def;
46
+ };
47
+
48
+ const PRD_PATH = getArg('--prd');
49
+ const AGENTS_DIR = getArg('--agents-dir', path.join(__dirname, '..', 'agents'));
50
+ const PROFILES_PATH = getArg('--profiles', path.join(AGENTS_DIR, 'profiles.json'));
51
+ const LOG_DIR = getArg('--log-dir', process.env.OUTPUT_DIR || path.join(__dirname, '..', 'logs'));
52
+ const REPO_ARG = getArg('--codeline-root', '');
53
+
54
+ /**
55
+ * The repo the work actually lands in — NOT the estate root.
56
+ *
57
+ * Live 2026-08-07: this got /projects/<client>, the directory holding 33 repositories, so
58
+ * "read the codeline before you answer" pointed somewhere the answer is not. With no repo and
59
+ * no fetched documents, the mint invented a CMS vendor and briefed all three roles on the
60
+ * wrong product's APIs. A path that is not a repository is worse than no path: it reads as
61
+ * evidence and contains none.
62
+ */
63
+ function resolveRepoPath(prd, stories, repoArg) {
64
+ const isRepo = (d) => { try { return !!d && fs.existsSync(path.join(d, '.git')); } catch { return false; } };
65
+ const arg = repoArg !== undefined ? repoArg : REPO_ARG;
66
+ if (isRepo(arg)) return arg;
67
+ const out = prd && prd.project && prd.project.outputDir;
68
+ if (isRepo(out)) return out;
69
+ for (const s of stories) {
70
+ try { const p2 = spec.resolveCodelinePath(s); if (isRepo(p2)) return p2; } catch { /* next */ }
71
+ }
72
+ return '';
73
+ }
74
+
75
+ /**
76
+ * Make the project's plugins reachable BEFORE the mint runs.
77
+ *
78
+ * The orchestrator provisions .epam/settings.json into each codeline inside the codeline
79
+ * loop — which happens after this step. So the mint could not reach dependency_contract or
80
+ * dependency_available even with tools enabled, and had to trust the vendor documentation
81
+ * about what the SDK exposes. Live 2026-08-07: two briefs prescribed `preview_token`, which
82
+ * does not exist in the version of the SDK this estate pins, because nothing could check.
83
+ *
84
+ * Idempotent and identical to what the lane writes later, so provisioning here changes
85
+ * nothing downstream.
86
+ */
87
+ function provisionPlugins(codelines) {
88
+ const cfgDir = process.env.EPAM_PROJECT_CONFIG_DIR || '';
89
+ let tools = [];
90
+ try {
91
+ const parsed = JSON.parse(fs.readFileSync(path.join(cfgDir, 'plugins.json'), 'utf8'));
92
+ if (Array.isArray(parsed.tools)) tools = parsed.tools;
93
+ } catch { /* no project plugins declared */ }
94
+ const builtin = path.join(__dirname, '..', 'plugins', 'codegraph-plugin.js');
95
+ if (fs.existsSync(builtin)) tools = [...new Set([builtin, ...tools])];
96
+ if (!tools.length) return [];
97
+
98
+ for (const cl of codelines) {
99
+ try {
100
+ fs.mkdirSync(path.join(cl.path, '.epam'), { recursive: true });
101
+ fs.writeFileSync(path.join(cl.path, '.epam', 'settings.json'), JSON.stringify({ tools }, null, 2));
102
+ } catch { /* a codeline we cannot write to simply has no plugins for the mint */ }
103
+ }
104
+ return tools;
105
+ }
106
+
107
+ /**
108
+ * What the mint may call. READ ONLY, by construction.
109
+ *
110
+ * The user's choice: builtin reads plus the project's dependency plugins — enough to verify
111
+ * that an API exists and a package is installed, across every codeline. No bash and no
112
+ * write_file: this stage has no story scope, and a wide grant at a stage that designs the
113
+ * agents is not a trade worth making.
114
+ */
115
+ function mintTools(codelines) {
116
+ // One implementation, shared with specAgentEnv. This used to carry its own
117
+ // READ_ONLY_BUILTINS literal, and the spec pass carried a different one — which is how the
118
+ // mint could see codegraph_query while every spec-mode agent could not. See lib/agent-tools.js.
119
+ return require('./lib/agent-tools.js').readOnlyToolGrant(codelines.map((c) => c && c.path));
120
+ }
121
+
122
+ /**
123
+ * EVERY codeline in scope, not one of them.
124
+ *
125
+ * Operator direction, 2026-08-07: one roster for the whole project, informed by all its
126
+ * codelines. Ingest already discovers them and exports JIRA_CODELINES plus a
127
+ * JIRA_WORKTREE_<NAME> path for each, so this enumerates them generically — no codeline,
128
+ * client or repository is named here.
129
+ *
130
+ * The first run minted against a single repo (the PRD outputDir) while three were in scope,
131
+ * and wrote that one repository's absolute path into every brief.
132
+ */
133
+ function resolveCodelines(prd, stories, repoArg) {
134
+ const isRepo = (d) => { try { return !!d && fs.existsSync(path.join(d, '.git')); } catch { return false; } };
135
+ const out = [];
136
+ const seen = new Set();
137
+ const add = (name, dir) => {
138
+ if (!isRepo(dir) || seen.has(dir)) return;
139
+ seen.add(dir);
140
+ out.push({ name: name || path.basename(dir), path: dir });
141
+ };
142
+
143
+ // THE PERSISTED DISCOVERY ARTEFACT FIRST. Ingest runs as a child process, so the
144
+ // JIRA_CODELINES / JIRA_WORKTREE_* exports it makes die when it exits — which is why this
145
+ // saw one codeline twice while three were in scope. An artefact crosses that boundary.
146
+ try {
147
+ const disc = JSON.parse(fs.readFileSync(path.join(LOG_DIR, 'codeline-discovery.json'), 'utf8'));
148
+ for (const cl of (disc.codelines || [])) add(cl.name, cl.path);
149
+ } catch { /* fall through to the env, then to single-codeline resolution */ }
150
+
151
+ for (const name of String(process.env.JIRA_CODELINES || '').split(',').map((x) => x.trim()).filter(Boolean)) {
152
+ add(name, process.env[`JIRA_WORKTREE_${name.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`]);
153
+ }
154
+ if (!out.length) {
155
+ // Single-codeline fallback: whatever the run actually points at.
156
+ add('', repoArg);
157
+ add('', prd && prd.project && prd.project.outputDir);
158
+ for (const st of stories) {
159
+ try { add(st.codeline, spec.resolveCodelinePath(st)); } catch { /* next */ }
160
+ }
161
+ }
162
+ return out;
163
+ }
164
+
165
+ /**
166
+ * What this codeline DECLARES it depends on. The single most direct answer to "which vendor
167
+ * is this", and the mint had no access to it: the ticket says only "CMS", and the documents
168
+ * that would have named the SDK failed to fetch. Read from whatever manifest the repo has —
169
+ * no vendor, language or ecosystem is named here.
170
+ */
171
+ function declaredDependencies(repoPath) {
172
+ if (!repoPath) return [];
173
+ // WHICH manifest, and WHICH keys inside it, are PROJECT facts — not the engine's to know.
174
+ // This function briefly carried a list of ecosystem manifest filenames, which is stack
175
+ // knowledge hardcoded into the generic pipeline. The project already declares both in
176
+ // .epam/dependency-check.json (provisioned per codeline from the project's own config, and
177
+ // the same file the dependency-contract plugin reads), so there is one declaration and the
178
+ // engine names no ecosystem.
179
+ //
180
+ // Fails CLOSED and SAYS SO: no config means no dependency evidence, never a guessed
181
+ // manifest. A guessed manifest that happens to miss is indistinguishable from a project
182
+ // that genuinely declares nothing.
183
+ // The codeline's provisioned copy first; the project's own config as the fallback. The
184
+ // orchestrator provisions .epam/dependency-check.json into each worktree LATER, inside the
185
+ // codeline loop — after the mint. Live 2026-08-07: "declared dependencies: 0" for a codeline
186
+ // whose project config declares them perfectly well, so the mint lost its second evidence
187
+ // source and the briefs prescribed a package the codeline does not install.
188
+ const candidates = [path.join(repoPath, '.epam', 'dependency-check.json')];
189
+ if (process.env.EPAM_PROJECT_CONFIG_DIR) {
190
+ candidates.push(path.join(process.env.EPAM_PROJECT_CONFIG_DIR, 'dependency-check.json'));
191
+ }
192
+ let cfg = null;
193
+ for (const c of candidates) {
194
+ try { cfg = JSON.parse(fs.readFileSync(c, 'utf8')); break; } catch { /* next */ }
195
+ }
196
+ // THE MANIFEST IS DETECTED, NEVER DECLARED.
197
+ //
198
+ // This required the project to write down manifestFile and manifestKeys in dependency-check.json
199
+ // — so every project had to tell the engine that Node uses package.json and that dependencies
200
+ // live under "dependencies"/"devDependencies". mock3 declared its manifest under a DIFFERENT
201
+ // shape (codelines.<name>.manifest), which this never read, so the mint reported "no dependency
202
+ // evidence" and the agent brief said the codelines "declare no manifest configuration".
203
+ //
204
+ // The roster reviewer then read the repositories, found package.json in both with vitest and
205
+ // typescript, and correctly blocked the roster for describing them wrongly. Two schemas for one
206
+ // fact, and the run died between them.
207
+ //
208
+ // lib/ecosystem-registry.js already answers this for every supported ecosystem: it finds the manifest by
209
+ // PRESENCE and each entry knows how to read its own dependency sections — which is exactly what
210
+ // manifestKeys was spelling out by hand, one project at a time. It extends at runtime through
211
+ // EPAM_CODELINE_MANIFESTS, so a new ecosystem needs no engine change and no project config.
212
+ try {
213
+ const out = execFileSync(process.execPath, [
214
+ path.join(__dirname, 'lib', 'handlers', 'codeline-ecosystem.js'), repoPath,
215
+ ], { encoding: 'utf8', timeout: localToolTimeoutMs(path.join(__dirname, '..', 'config', 'spec-mode-defaults.json')) });
216
+ const facts = JSON.parse(out);
217
+ if (!facts.manifest) {
218
+ process.stderr.write(
219
+ `[mint-step] ${repoPath} carries no manifest this engine recognises — no dependency ` +
220
+ 'evidence. Extend EPAM_CODELINE_MANIFESTS if this ecosystem should be supported.\n');
221
+ return [];
222
+ }
223
+ return facts.declaredDeps || [];
224
+ } catch (err) {
225
+ process.stderr.write(`[mint-step] could not read the ecosystem of ${repoPath}: ${err && err.message}\n`);
226
+ return [];
227
+ }
228
+ }
229
+
230
+ if (require.main === module && (!PRD_PATH || !fs.existsSync(PRD_PATH))) {
231
+ process.stderr.write('[mint-step] --prd <path> is required and must exist\n');
232
+ process.exit(2);
233
+ }
234
+
235
+ /**
236
+ * Documents linked on the tickets, if ingest persisted any. Optional by design: the mint is
237
+ * better with them and must still work without them, so a project whose tickets carry no
238
+ * links is not blocked.
239
+ */
240
+ async function referencedDocs(logDir, stories) {
241
+ const candidates = [
242
+ path.join(logDir, 'referenced-docs.json'),
243
+ path.join(logDir, 'ticket-documents.json'),
244
+ ];
245
+ for (const file of candidates) {
246
+ try {
247
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
248
+ const docs = Array.isArray(parsed) ? parsed : (parsed && parsed.docs);
249
+ if (Array.isArray(docs) && docs.length) return docs;
250
+ } catch { /* try the next one */ }
251
+ }
252
+
253
+ // Nothing persisted yet — fetch from the links ingest put on the stories. ingest carries
254
+ // ticketLinks (url + provenance) for every ticket; the documents behind them are what make
255
+ // a proposed role project-specific, and on 2026-08-06 two of them refuted a story's central
256
+ // assumption. Persisted here so the spec pass and any later reader see the same bytes, and
257
+ // so a run can be audited afterwards.
258
+ const links = [];
259
+ const seen = new Set();
260
+ for (const s of stories) {
261
+ for (const l of (Array.isArray(s.ticketLinks) ? s.ticketLinks : [])) {
262
+ const url = l && (typeof l === 'string' ? l : l.url);
263
+ if (typeof url === 'string' && url && !seen.has(url)) { seen.add(url); links.push(url); }
264
+ }
265
+ }
266
+ if (!links.length) return [];
267
+
268
+ let docs = [];
269
+ try {
270
+ docs = await spec.fetchTicketDocuments(links, logDir) || [];
271
+ } catch (err) {
272
+ // The mint is better with documents and must still work without them. A project whose
273
+ // links are unreachable is not blocked from having agents.
274
+ process.stderr.write(`[mint-step] document fetch failed (continuing without): ${err && err.message}\n`);
275
+ return [];
276
+ }
277
+ try {
278
+ fs.writeFileSync(path.join(logDir, 'referenced-docs.json'), JSON.stringify(docs, null, 2));
279
+ } catch { /* the mint still gets them in memory */ }
280
+ const fetched = docs.filter((d) => d && d.fetchStatus === 'fetched').length;
281
+ process.stderr.write(`[mint-step] documents: ${fetched} fetched of ${links.length} link(s)\n`);
282
+ return docs;
283
+ }
284
+
285
+ /**
286
+ * writeRosterDiff — what this run GENERATED, against the canonical baseline.
287
+ *
288
+ * The roster pause is only useful if the operator can see what changed. profiles.json is ~56
289
+ * entries of prose; eyeballing it against profiles.canonical.json is not review, it is
290
+ * hoping. This states it: roles added, roles whose brief differs, and roles present in
291
+ * canonical but missing here.
292
+ */
293
+ function writeRosterDiff(profilesPath, agentsDir, logDir, mintedThisRun, mintedDetail) {
294
+ const minted = new Set(Array.isArray(mintedThisRun) ? mintedThisRun : []);
295
+ const detail = new Map((Array.isArray(mintedDetail) ? mintedDetail : []).map((m) => [m.name, m]));
296
+ // SILENCE IS NOT A KIND. This ended `|| 'implementer'`, so an agent whose kind the mint never
297
+ // stated was reported under "IMPLEMENTERS, may author code" — a default resolving the unknown
298
+ // toward the more powerful answer, in the one artefact whose job is to show an operator what
299
+ // this run created. It grants nothing (permission is membership in project-roles.json, and
300
+ // project-roster.js refuses an entry whose kind is not declared), which is exactly why the
301
+ // report must not imply otherwise.
302
+ const kindOf = (n) => {
303
+ const k = (detail.get(n) || {}).kind;
304
+ return (typeof k === 'string' && k.trim()) ? k.trim() : '';
305
+ };
306
+ const isImplementer = (n) => kindOf(n) === 'implementer';
307
+ const isInvestigator = (n) => kindOf(n) === 'investigator';
308
+ const kindUnstated = (n) => !kindOf(n);
309
+ const codelineOf = (n) => (detail.get(n) || {}).codeline || '';
310
+ const canonicalPath = path.join(agentsDir, 'profiles.canonical.json');
311
+ let live = {}, canonical = {};
312
+ try { live = JSON.parse(fs.readFileSync(profilesPath, 'utf8')); } catch { return null; }
313
+ try { canonical = JSON.parse(fs.readFileSync(canonicalPath, 'utf8')); } catch { canonical = {}; }
314
+
315
+ const liveKeys = Object.keys(live), canonKeys = Object.keys(canonical);
316
+ const added = liveKeys.filter((k) => !(k in canonical));
317
+ const removed = canonKeys.filter((k) => !(k in live));
318
+ const changed = liveKeys.filter((k) => k in canonical && live[k] !== canonical[k]);
319
+
320
+ const diff = {
321
+ canonicalFile: canonicalPath,
322
+ canonicalRoles: canonKeys.length,
323
+ liveRoles: liveKeys.length,
324
+ generated: added.map((k) => ({
325
+ role: k, briefChars: String(live[k] || '').length, mintedThisRun: minted.has(k),
326
+ })),
327
+ briefChanged: changed.map((k) => ({
328
+ role: k,
329
+ canonicalChars: String(canonical[k] || '').length,
330
+ liveChars: String(live[k] || '').length,
331
+ })),
332
+ missingFromLive: removed,
333
+ };
334
+ try { fs.writeFileSync(path.join(logDir, 'roster-diff.json'), JSON.stringify(diff, null, 2)); } catch {}
335
+
336
+ const md = [
337
+ `# Roster diff — generated vs canonical`,
338
+ ``,
339
+ `canonical: ${canonicalPath} (${canonKeys.length} roles)`,
340
+ `live: ${profilesPath} (${liveKeys.length} roles)`,
341
+ ``,
342
+ `## MINTED BY THIS RUN — IMPLEMENTERS, may author code (${added.filter((k) => minted.has(k) && isImplementer(k)).length})`,
343
+ ...(added.filter((k) => minted.has(k) && isImplementer(k)).length
344
+ ? added.filter((k) => minted.has(k) && isImplementer(k)).map((k) => `- ${k} [${String(live[k] || '').length} chars]`)
345
+ : ['- (none)']),
346
+ ``,
347
+ `## MINTED BY THIS RUN — INVESTIGATORS, read-only, never own a story (${added.filter((k) => minted.has(k) && isInvestigator(k)).length})`,
348
+ ...(added.filter((k) => minted.has(k) && isInvestigator(k)).length
349
+ ? added.filter((k) => minted.has(k) && isInvestigator(k))
350
+ .map((k) => `- ${k} [codeline: ${codelineOf(k) || '(none)'}] [${String(live[k] || '').length} chars]`)
351
+ : ['- (none)']),
352
+ ``,
353
+ // REPORTED, NOT ABSORBED. An agent the mint produced without saying what it is belongs in
354
+ // neither column: putting it in one asserts something the roster never said. Its own section
355
+ // makes the roster's gap visible at the pause, which is where it can still be fixed.
356
+ `## MINTED BY THIS RUN — KIND NOT STATED (${added.filter((k) => minted.has(k) && kindUnstated(k)).length})`,
357
+ ...(added.filter((k) => minted.has(k) && kindUnstated(k)).length
358
+ ? added.filter((k) => minted.has(k) && kindUnstated(k))
359
+ .map((k) => `- ${k} [the roster declared no kind for this agent]`)
360
+ : ['- (none)']),
361
+ ``,
362
+ `## In live but not canonical, NOT minted this run (pre-existing drift) (${added.filter((k) => !minted.has(k)).length})`,
363
+ ...(added.filter((k) => !minted.has(k)).length
364
+ ? added.filter((k) => !minted.has(k)).map((k) => `- ${k}`)
365
+ : ['- (none)']),
366
+ ``,
367
+ `## Brief differs from canonical (${changed.length})`,
368
+ ...(changed.length ? changed.map((k) => `- ${k} canonical ${String(canonical[k] || '').length} -> live ${String(live[k] || '').length} chars`) : ['- (none)']),
369
+ ``,
370
+ `## In canonical but NOT live (${removed.length})`,
371
+ ...(removed.length ? removed.map((k) => `- ${k}`) : ['- (none)']),
372
+ ``,
373
+ ].join('\n');
374
+ try { fs.writeFileSync(path.join(logDir, 'roster-diff.md'), md); } catch {}
375
+
376
+ process.stderr.write(`[mint-step] roster diff: ${added.length} generated, ${changed.length} brief-changed, ${removed.length} missing vs canonical\n`);
377
+ return diff;
378
+ }
379
+
380
+ /**
381
+ * THE AGENT→SEAM CROSS-REFERENCE, WRITTEN WHERE THE AGENTS ARE CREATED.
382
+ *
383
+ * Minting is the only moment the pipeline knows what it just made. A run mints ~64 agents
384
+ * whose names cannot exist in a registry written before the project did —
385
+ * gotransit-investigator, contentstack-live-preview-integration-engineer — and until now every
386
+ * one of them resolved to {} at runtime: no ladder, no effort, no temperature, and nothing
387
+ * said so.
388
+ *
389
+ * Operator: "you cannot just say after mint - oh, it has no seam and then treat it as a bug -
390
+ * that will not work at all and is a poor design." And: "Cross reference must be
391
+ * updated/re-created during minting."
392
+ *
393
+ * So every agent in the roster is resolved through THE SAME resolver the runtime uses — two
394
+ * implementations of "which seam is this agent" would be two answers to one question — and the
395
+ * result is recorded. The mapping stops being implicit-by-pattern and becomes a record an
396
+ * operator can read, diff and override.
397
+ *
398
+ * AN AGENT THAT RESOLVES TO NOTHING FAILS THE MINT, and nothing is written. The failure lands
399
+ * before any story runs, where it can be fixed.
400
+ *
401
+ * No agent name and no seam name appears here: the roster supplies the names, the registry
402
+ * supplies the shapes.
403
+ */
404
+
405
+ /**
406
+ * The roster file exists in TWO shapes in the wild: a flat { agent: brief } map, and a wrapper
407
+ * { runId, _what, profiles: { agent: brief } }. Reading one shape in one function and the other
408
+ * elsewhere is how a validator ends up examining `runId` and `_what` as if they were agents.
409
+ * One reader, both shapes.
410
+ */
411
+ function rosterAgents(profilesPath) {
412
+ const parsed = JSON.parse(fs.readFileSync(profilesPath, 'utf8'));
413
+ const map = (parsed && typeof parsed.profiles === 'object' && parsed.profiles) ? parsed.profiles : parsed;
414
+ const out = {};
415
+ for (const [k, v] of Object.entries(map || {})) {
416
+ if (k.startsWith('_') || k === 'runId') continue;
417
+ out[k] = v;
418
+ }
419
+ return out;
420
+ }
421
+
422
+ function writeAgentSeamCrossReference(profilesPath, registryPath) {
423
+ const { resolveSeam } = require('./lib/seam-invocation.js');
424
+
425
+ const roster = rosterAgents(profilesPath);
426
+ const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
427
+ const profiles = registry.profiles || {};
428
+ // Nothing is carried forward: the roster is derived every run, so a previous run's mapping is
429
+ // not this run's fact.
430
+ const previous = {};
431
+ // Provenance lives BESIDE the map, not inside it: agentSeams stays a plain
432
+ // agent -> seam string, so seam-invocation.js and every other reader is untouched.
433
+ const origin = {};
434
+
435
+ // RE-CREATED, not merged: an agent dropped from the roster must not linger. Rebuilt from the
436
+ // roster alone, so the cross-reference always describes the agents that actually exist.
437
+ const next = {};
438
+ const unresolved = [];
439
+ for (const agent of Object.keys(roster)) {
440
+ // An agent that IS a seam already wins at resolution; recording it too would create a
441
+ // second place to drift from.
442
+ if (profiles[agent]) continue;
443
+ // AN OVERRIDE SURVIVES; A DERIVED VALUE DOES NOT.
444
+ //
445
+ // This used to preserve ANY previous mapping, which is right for an operator's deliberate
446
+ // decision and wrong for a value the mint itself derived last time — and it could not tell
447
+ // them apart. Live 2026-08-13: the -engineer rule had pointed ten implementers at the
448
+ // failure analyst; correcting the rule reached none of them, because every stale entry
449
+ // outranked it. The correction had to be applied by hand, and so would the next one.
450
+ //
451
+ // A BARE STRING IS TREATED AS DERIVED. Every entry written before this change is one, and
452
+ // reading them as overrides would freeze today's mappings permanently.
453
+ const _prevOrigin = (registry.agentSeamOrigin || {})[agent];
454
+ if (_prevOrigin === 'override'
455
+ && Object.prototype.hasOwnProperty.call(previous, agent)
456
+ && profiles[previous[agent]]) {
457
+ next[agent] = previous[agent];
458
+ origin[agent] = 'override';
459
+ continue;
460
+ }
461
+ try {
462
+ // From the RULES, not from what we recorded last time — see resolveSeam's ignoreXref.
463
+ next[agent] = resolveSeam(agent, registryPath, { ignoreXref: true });
464
+ origin[agent] = 'derived';
465
+ } catch (e) {
466
+ unresolved.push(`${agent}: ${(e && e.message) || e}`);
467
+ }
468
+ }
469
+
470
+ if (unresolved.length) {
471
+ throw new Error(
472
+ `${unresolved.length} minted agent(s) resolve to no seam, so they would run unconfigured. `
473
+ + `Add a seamPattern or a defaultSeam to ${registryPath}:\n ` + unresolved.join('\n '));
474
+ }
475
+
476
+ // NEITHER RECORDED NOR WRITTEN BACK. `agentSeams` was a per-project cross-reference kept in
477
+ // the file the ENGINE owns, and every one of its 55 entries had origin 'derived' — a cache of
478
+ // what the seam patterns already answer, holding no decision anyone had made. Maintaining it
479
+ // was the last thing making a run write the engine layer.
480
+ //
481
+ // What the patterns cannot reach is a minted agent whose name the engine cannot know, and that
482
+ // agent's roster entry names its seam beside its persona and its kind. The resolution above
483
+ // still runs: it is what proves every minted agent HAS a seam, and it throws by name when one
484
+ // does not.
485
+ return next;
486
+ }
487
+
488
+
489
+ /**
490
+ * validateWorkflow(profilesPath, registryPath)
491
+ *
492
+ * IS THIS ROSTER BUILDABLE? Every archetype declares what it produces and what it requires, so
493
+ * a roster containing a consumer whose REQUIRED input nobody produces is decidable from the
494
+ * data — before any story runs, with no model involved.
495
+ *
496
+ * That is the shape of every unwinnable story this pipeline has produced: an actor waiting for
497
+ * something nobody was configured to make. The mint already refuses a roster whose agent
498
+ * resolves to no seam, for the same reason and with the same timing — "caught here, before any
499
+ * story runs, not three hours into a run".
500
+ *
501
+ * OPTIONAL INPUTS ARE NOT CHECKED. Optional means the workflow runs without it; requiring a
502
+ * producer for every optional kind would force every roster to carry every archetype, which is
503
+ * the opposite of letting a project mint only what it needs.
504
+ */
505
+ function validateWorkflow(profilesPath, registryPath) {
506
+ const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
507
+ const roster = rosterAgents(profilesPath);
508
+ const archetypes = registry.profiles || {};
509
+ const { resolveSeam } = require('./lib/seam-invocation.js');
510
+
511
+ // What this roster can produce: every archetype an agent resolves to, plus the engine.
512
+ const produced = new Set(registry.engineProduces || []);
513
+ const seamOf = {};
514
+ for (const agent of Object.keys(roster)) {
515
+ const seam = archetypes[agent] ? agent : resolveSeam(agent, registryPath);
516
+ seamOf[agent] = seam;
517
+ const p = archetypes[seam] && archetypes[seam].produces;
518
+ if (p) produced.add(String(p));
519
+ }
520
+
521
+ const missing = [];
522
+ for (const agent of Object.keys(roster)) {
523
+ const arch = archetypes[seamOf[agent]];
524
+ if (!arch) continue;
525
+ for (const c of arch.consumes || []) {
526
+ if (!c || !c.required) continue;
527
+ if (!produced.has(String(c.kind))) {
528
+ missing.push(`${agent} (${seamOf[agent]}) requires '${c.kind}', which nothing in this roster produces`);
529
+ }
530
+ }
531
+ }
532
+ if (missing.length) {
533
+ throw new Error(
534
+ `${missing.length} agent(s) in this roster require an input nobody produces, so they would `
535
+ + `wait forever. Mint a producer, or make the input optional:\n ` + missing.join('\n '));
536
+ }
537
+ return true;
538
+ }
539
+
540
+ /**
541
+ * WHY THE MINT IS BEING SKIPPED, IN WORDS THAT ARE TRUE.
542
+ *
543
+ * One hardcoded sentence served two conditions: "mint skipped (EPAM_SKIP_AGENT_MINT=1) — resuming
544
+ * from a checkpoint". So a roster-only run announced a flag it was never given, and a
545
+ * start-at-the-beginning run announced a checkpoint that did not exist — three times in the paid
546
+ * run of 2026-08-28, on a run that had nothing to resume.
547
+ *
548
+ * The skip itself is correct; only the justification was false. A false reason is worse than
549
+ * silence, because a reason gets believed: the roster review then declined itself citing "the run
550
+ * being resumed" of a run that never existed, and nobody questioned it for months.
551
+ *
552
+ * THE ONLY THING THAT MAKES A RUN A RESUME IS EPAM_RESUME_RUN. Everything else is a mode.
553
+ */
554
+ function mintSkipReason(env) {
555
+ const e = env || process.env;
556
+ const rosterOnly = String(e.EPAM_ROSTER_ONLY || '') === '1';
557
+ const skipMint = String(e.EPAM_SKIP_AGENT_MINT || '') === '1';
558
+ if (!rosterOnly && !skipMint) return '';
559
+ const resumeOf = String(e.EPAM_RESUME_RUN || '').trim();
560
+ const mode = rosterOnly
561
+ ? 'roster-only mode — identities are derived, nothing is minted'
562
+ : 'mint skipped (EPAM_SKIP_AGENT_MINT=1)';
563
+ return resumeOf ? mode + '; resuming ' + resumeOf + ' from its checkpoint' : mode;
564
+ }
565
+
566
+ /**
567
+ * WHY THE ROSTER REVIEW IS NOT RUNNING.
568
+ *
569
+ * Operator ruling, 2026-08-28: skipping the review when the mint was skipped is CORRECT — there is
570
+ * nothing minted to review. The defect was the sentence "reviewed in the run being resumed", said
571
+ * on runs that resumed nothing, which turned a sound decision into an unfalsifiable one.
572
+ */
573
+ function rosterReviewSkipReason(env) {
574
+ const e = env || process.env;
575
+ const resumeOf = String(e.EPAM_RESUME_RUN || '').trim();
576
+ return resumeOf
577
+ ? 'roster carried over from ' + resumeOf + ' — reviewed in that run, not re-reviewed here'
578
+ : 'roster not re-reviewed — the mint did not run, so nothing was minted to review';
579
+ }
580
+
581
+ module.exports = { mintSkipReason, rosterReviewSkipReason, resolveRepoPath, rosterAgents, resolveCodelines, declaredDependencies, writeRosterDiff, mintTools, provisionPlugins, writeAgentSeamCrossReference, validateWorkflow };
582
+
583
+ if (require.main !== module) return;
584
+
585
+ (async () => {
586
+ const prd = JSON.parse(fs.readFileSync(PRD_PATH, 'utf8'));
587
+ const stories = Array.isArray(prd.stories) ? prd.stories : [];
588
+ if (!stories.length) {
589
+ process.stderr.write('[mint-step] the PRD has no stories — nothing to mint for or assign\n');
590
+ process.exit(1);
591
+ }
592
+
593
+ fs.mkdirSync(LOG_DIR, { recursive: true });
594
+ const aiRunnerCmd = process.env.AI_RUNNER_CMD || path.join(__dirname, 'ai-run.sh');
595
+ const promptExec = spec.resolvePromptExec(aiRunnerCmd);
596
+ // RE-APPLY THIS PROJECT'S BRIEFS FIRST. profiles.json was restored from its canonical
597
+ // original at launch, which deletes anything minted on a previous run while the role
598
+ // registry and the KB files survive. Without this the three disagree: on a resume the
599
+ // registry names roles that have no profile, assignment finds zero candidates and refuses.
600
+ const { applyProjectProfiles, rosterRunId: _rrid } = require('./lib/agent-roster.js');
601
+ // Only for THIS run. Re-applying a previous run's briefs would reinstate the mutated roster
602
+ // the restore just removed — the base must be canonical at the start of every run.
603
+ const reapplied = (_rrid(AGENTS_DIR) && _rrid(AGENTS_DIR) === (process.env.ORCH_RUN_ID || ''))
604
+ ? applyProjectProfiles(PROFILES_PATH, AGENTS_DIR)
605
+ : [];
606
+ if (reapplied.length) {
607
+ process.stderr.write(`[mint-step] re-applied ${reapplied.length} project brief(s) after the per-run restore: ${reapplied.join(', ')}\n`);
608
+ }
609
+
610
+ const codelines = resolveCodelines(prd, stories, REPO_ARG);
611
+ if (!codelines.length) {
612
+ process.stderr.write('[mint-step] WARNING: no codeline repository resolved — the mint cannot read the stack\n');
613
+ }
614
+ for (const cl of codelines) {
615
+ cl.dependencies = declaredDependencies(cl.path);
616
+ process.stderr.write(`[mint-step] codeline ${cl.name}: ${cl.path} (${cl.dependencies.length} declared deps)\n`);
617
+ }
618
+ // Tools operate in one working directory; the first codeline is the anchor, and every
619
+ // codeline's path and stack is stated in the prompt.
620
+ const REPO_PATH = codelines.length ? codelines[0].path : '';
621
+ const deps = [...new Set(codelines.flatMap((c) => c.dependencies))];
622
+ const provisioned = provisionPlugins(codelines);
623
+ const toolGrant = mintTools(codelines);
624
+ process.stderr.write(`[mint-step] plugins provisioned: ${provisioned.length} · mint tools: ${toolGrant}\n`);
625
+ // ── THE PROJECT ROSTER ──────────────────────────────────────────────────────
626
+ //
627
+ // Canonical belongs to no project. Running it unchanged gave every project the same personas,
628
+ // which is how a review of a client codeline ran five times with a persona describing this
629
+ // repository. So: canonical is copied into the run, an agent specialises it for THIS project
630
+ // with its own tools, and the result is reviewed against BOTH itself and the source.
631
+ //
632
+ // A FUNCTION, AND NOT PART OF THE MINT. EPAM_SKIP_AGENT_MINT is honoured on every
633
+ // writer-style resume, deliberately: the merge is additive and re-minting accumulated
634
+ // duplicate roles. But deriving the roster is not minting. Skipping the mint means "invent no
635
+ // new agents"; it must never mean "run with no identities", which is what leaving this inside
636
+ // the skipped block would cause the moment consumers stop falling back to the engine roster.
637
+ //
638
+ // Regenerated every launch, resume included.
639
+ const runRosterStage = async (mintedDetail) => {
640
+ const { buildProjectRoster, seamBaseForRun } = require('./lib/project-roster.js');
641
+ const { seamInvocationEnv, registryPath } = require('./lib/seam-invocation.js');
642
+ const projectConfigDir = process.env.EPAM_PROJECT_CONFIG_DIR || '';
643
+ if (!projectConfigDir) {
644
+ throw new Error(
645
+ 'cannot build the project roster: EPAM_PROJECT_CONFIG_DIR is unset. The roster is the '
646
+ + 'only place an agent\'s identity comes from, and there is no engine fallback to read.');
647
+ }
648
+ // THE BASE IS EACH SEAM'S OWN SI, from the registry — never the agent-named profiles
649
+ // (operator, 2026-09-29: "Bases and derived si must be matched to each seam").
650
+ const canonicalPath = seamBaseForRun(registryPath(AGENTS_DIR), LOG_DIR);
651
+
652
+ // Required HERE, not at module load: engine-prompt routes seam prompts through
653
+ // prompt-library, and pulling that chain in at load time would give one of them a
654
+ // half-initialised copy of the other. Every other seam in this file renders the same way.
655
+ // eslint-disable-next-line global-require
656
+ const { renderEngineTemplate } = require('./lib/engine-prompt.js');
657
+ const renderSpecialisation = (vals) => renderEngineTemplate('roster-specialisation', vals);
658
+
659
+ const { rosterSeams } = require('./lib/roster-seams.js');
660
+ const { produce, review } = rosterSeams({
661
+ spec, promptExec, projectConfigDir, LOG_DIR, AGENTS_DIR, REPO_PATH, codelines, stories, prd, mintedDetail, survey, toolGrant,
662
+ });
663
+
664
+ const roster = await buildProjectRoster({
665
+ canonicalPath, logDir: LOG_DIR, projectConfigDir, produce, review,
666
+ attempts: Number(process.env.EPAM_ROSTER_ATTEMPTS || 3),
667
+ log: (m2) => process.stderr.write(`[mint-step] ${m2}\n`),
668
+ });
669
+ process.stderr.write(`[mint-step] project roster: ${Object.keys(roster.agents).length} agent(s)\n`);
670
+ };
671
+
672
+ const docs = await referencedDocs(LOG_DIR, stories);
673
+ const fetchedDocs = docs.filter((d) => d && d.fetchStatus === 'fetched').length;
674
+
675
+ // Surfaced, not buried. 0-of-2 fetched directly weakened the roster and appeared only as a
676
+ // log line; the operator reviewing at the pause must see it without reading the transcript.
677
+ fs.writeFileSync(path.join(LOG_DIR, 'mint-inputs.json'), JSON.stringify({
678
+ codelines: codelines.map((c) => ({ name: c.name, path: c.path, declaredDependencies: c.dependencies.length })),
679
+ codelineRepo: REPO_PATH || null,
680
+ declaredDependencies: deps.length,
681
+ documentsLinked: docs.length,
682
+ documentsFetched: fetchedDocs,
683
+ documentsUnfetched: docs.filter((d) => d && d.fetchStatus !== 'fetched').map((d) => ({ url: d.url, status: d.fetchStatus })),
684
+ }, null, 2));
685
+
686
+ // ESTATE SURVEY — ITS OWN STEP, NOT THE MINT'S.
687
+ //
688
+ // This call used to sit inside the mint's else-branch, so every path that declined to mint
689
+ // also silently declined to survey: EPAM_SKIP_AGENT_MINT=1 meant 'skip the mint' and
690
+ // delivered 'skip discovery too', with nothing saying so. A seam's invocation must not be
691
+ // conditional on another seam's branch — the survey observes the estate, which is worth
692
+ // doing whether or not a roster is being minted from it, and the mint below simply reads
693
+ // what it produced.
694
+
695
+ // DET-1: LOOK AT THE CODE BEFORE ASSEMBLING THE TEAM.
696
+ //
697
+ // Everything the mint has had until now — the ticket, its documents, the declared
698
+ // dependencies — is a CLAIM about the estate. None of it is an observation of it, which is
699
+ // how briefs came to own modules nobody had searched for, and how scope came to be taken
700
+ // from ticket labels that are routinely wrong about which repositories are involved.
701
+ //
702
+ // The survey is cheap by construction: it decides where to look, not what to change. Its
703
+ // failure is not fatal — an estate that cannot be surveyed is the state the roster was
704
+ // minted in until today.
705
+ process.env.EPAM_AGENT_NAME = 'estate-survey';
706
+ const survey = await spec.surveyEstate({
707
+ promptExec,
708
+ tickets: stories,
709
+ referencedDocs: docs,
710
+ declaredDependencies: deps,
711
+ codelines,
712
+ toolGrant,
713
+ logDir: LOG_DIR,
714
+ repoPath: REPO_PATH,
715
+ });
716
+ if (survey.ran) {
717
+ for (const c of survey.codelines) {
718
+ process.stderr.write(`[mint-step] survey: ${c.codeline} — ${c.state}` +
719
+ `${c.surfaces && c.surfaces.length ? ` (${c.surfaces.join(', ')})` : ''}\n`);
720
+ }
721
+ for (const r of survey.recommendedInvestigators) {
722
+ process.stderr.write(`[mint-step] survey recommends an investigator for ${r.codeline}: ${r.focus}\n`);
723
+ }
724
+ } else {
725
+ process.stderr.write('[mint-step] survey did not run — the roster is minted from the ticket alone\n');
726
+ }
727
+
728
+ // ROSTER-ONLY: derive the roster and stop.
729
+ //
730
+ // A writer-style resume skips the mint on purpose, and every launch runs the pre-run reset.
731
+ // The launcher calls this step in roster-only mode on those paths: identities are derived,
732
+ // nothing is re-minted, and no role accumulates a near-duplicate. Placed here because
733
+ // everything the stage needs is already resolved, and everything below costs model time this
734
+ // path is explicitly avoiding.
735
+
736
+
737
+ // NO ROSTER DRIFT ACROSS RUNS OR CODELINES (operator direction, 2026-08-07).
738
+ //
739
+ // The merge is additive so a brief is never rewritten — but that also means a second mint
740
+ // ADDS whatever it proposes this time. Two runs produced five roles: the three from a run
741
+ // whose vendor was wrong, plus new ones, with one name overlapping. A roster that changes
742
+ // shape every run is not a roster.
743
+ //
744
+ // So the project mints ONCE. Thereafter the stored roster is reused verbatim. Re-minting is
745
+ // possible but must be asked for, and it REPLACES rather than accumulates — otherwise
746
+ // "re-mint" is just another word for drift.
747
+ const rosterLib = require('./lib/agent-roster.js');
748
+ const existingRoles = rosterLib.projectRoles(AGENTS_DIR);
749
+ const storedRunId = rosterLib.rosterRunId(AGENTS_DIR);
750
+ const thisRunId = process.env.ORCH_RUN_ID || '';
751
+ const sameRun = !!storedRunId && storedRunId === thisRunId;
752
+ const remint = process.env.EPAM_REMINT_AGENTS === '1';
753
+ let _mintedNames = [];
754
+ let _mintedDetail = [];
755
+ if (rosterLib.hasProjectRoster(AGENTS_DIR) && sameRun && !remint) {
756
+ process.stderr.write(
757
+ `[mint-step] roster already minted in THIS run (${thisRunId}): ${existingRoles.join(', ')} ` +
758
+ '— reusing exactly what was reviewed at the pause.\n');
759
+ } else if (process.env.EPAM_SKIP_AGENT_MINT === '1'
760
+ || String(process.env.EPAM_ROSTER_ONLY || '') === '1') {
761
+ // ROSTER-ONLY DECLINES THE MINT, NOT THE REST OF THE STEP.
762
+ //
763
+ // This used to return outright, several hundred lines above role assignment and prompt
764
+ // provisioning — so a run that skipped the mint also silently skipped both. Neither mints
765
+ // anything: assignment maps existing roles to stories, and provisioning in copy mode is a
766
+ // file copy costing no model call. Live 2026-08-27: mock3 reached agent-check with
767
+ // topology-router.json missing, because the only thing that provisions it sits past that
768
+ // return.
769
+ process.stderr.write(`[mint-step] ${mintSkipReason(process.env)}\n`);
770
+ // RESUME PATH: re-register implementation roles from the stored roster so role-assignment
771
+ // finds them. registerProjectRoles is idempotent — it merges; a fresh-mint run that reaches
772
+ // this branch (sameRun guard above didn't match) would have already registered via the full
773
+ // mint path. This only fires when the mint was explicitly skipped (EPAM_SKIP_AGENT_MINT=1).
774
+ try {
775
+ const { projectRosterPath } = require('./lib/project-roster.js');
776
+ const rosterFile = projectRosterPath(process.env.EPAM_PROJECT_CONFIG_DIR || '');
777
+ const storedRoster = JSON.parse(fs.readFileSync(rosterFile, 'utf8'));
778
+ const implementerNames = Object.entries(storedRoster.agents || {})
779
+ .filter(([, a]) => a && a.kind === 'implementer')
780
+ .map(([name]) => name);
781
+ if (implementerNames.length > 0) {
782
+ rosterLib.registerProjectRoles(AGENTS_DIR, implementerNames);
783
+ process.stderr.write(
784
+ `[mint-step] re-registered ${implementerNames.length} implementation role(s) from stored roster: ` +
785
+ `${implementerNames.join(', ')}\n`);
786
+ } else {
787
+ process.stderr.write('[mint-step] stored roster has no implementers — role-assignment will refuse\n');
788
+ }
789
+ } catch (e) {
790
+ process.stderr.write(`[mint-step] could not re-register roles from roster: ${e.message}\n`);
791
+ }
792
+ } else {
793
+ // Start from the BASE roster, never a mutated one. Anything a previous run minted is
794
+ // cleared before this run proposes: registry, stored briefs and live entries. Canonical
795
+ // roles are untouched. The KB files stay — cross-run agent knowledge is the one thing
796
+ // that is meant to persist (879c705).
797
+ // BOTH registries decide this, never the roles list alone — see hasProjectRoster.
798
+ if (rosterLib.hasProjectRoster(AGENTS_DIR)) {
799
+ const cleared = rosterLib.clearProjectRoster(AGENTS_DIR, PROFILES_PATH);
800
+ process.stderr.write(
801
+ `[mint-step] clearing the previous run's roster before minting (${cleared.length}): ` +
802
+ `${cleared.join(', ')}\n`);
803
+ }
804
+ // Agent identity reaches ai-run.sh through EPAM_AGENT_NAME and is what makes a cost row
805
+ // attributable. Two distinct agents run here, so each names itself rather than sharing one
806
+ // label — an anonymous or shared identity is how per-agent spend becomes unreadable.
807
+ // FALSIFY THE SURVEY BEFORE ANYTHING IS MINTED FROM IT.
808
+ //
809
+ // The survey seeds every investigator brief and scoped the whole run, and nothing reviewed it
810
+ // — a codeline was reported in_scope on a file it does not contain and an implementer was
811
+ // briefed accordingly. Runs HERE, before mintProjectAgents, because reviewing afterwards
812
+ // reports on claims the roster has already absorbed.
813
+ //
814
+ // Never fatal and never edits the survey: it reports, and the findings travel with it.
815
+ try {
816
+ const _sr = await spec.reviewSurvey({
817
+ promptExec, survey, codelines, tickets: stories, logDir: LOG_DIR,
818
+ repoPath: REPO_PATH, toolGrant,
819
+ });
820
+ if (_sr.ran) {
821
+ process.stderr.write(`[mint-step] survey review: ${_sr.findings.length} finding(s) across `
822
+ + `${_sr.reviewed} codeline(s)\n`);
823
+ for (const f of _sr.findings) {
824
+ process.stderr.write(`[mint-step] ! survey claim refuted (${f.codeline}): ${f.claim} `
825
+ + `— checked ${f.checked}, found ${f.found}\n`);
826
+ }
827
+ survey.reviewFindings = _sr.findings;
828
+ }
829
+ } catch (e) {
830
+ // The survey stands unreviewed; its own reviewer failing is not a finding about the survey.
831
+ // But it is SAID: this was a bare catch, and the £0 greenfield harness ran twelve times with
832
+ // the survey review never executing and nothing in any log saying why (2026-09-14).
833
+ process.stderr.write(`[mint-step] survey review did not run — the survey stands UNREVIEWED: ${(e && e.message) || e}\n`);
834
+ }
835
+
836
+ // A boundary crossed is reported, never swallowed: the surveyor is structurally barred
837
+ // from naming fix sites, and an attempt to name one says something about the prompt.
838
+ for (const v of survey.violations || []) {
839
+ process.stderr.write(`[mint-step] ! survey boundary: ${v}\n`);
840
+ }
841
+
842
+ process.env.EPAM_AGENT_NAME = 'agent-mint';
843
+ const mint = await spec.mintProjectAgents({
844
+ promptExec,
845
+ tickets: stories,
846
+ referencedDocs: docs,
847
+ declaredDependencies: deps,
848
+ codelines,
849
+ toolGrant,
850
+ estateSurvey: survey,
851
+ profilesPath: PROFILES_PATH,
852
+ agentsDir: AGENTS_DIR,
853
+ logDir: LOG_DIR,
854
+ repoPath: REPO_PATH,
855
+ });
856
+ // Every proposal in exactly one bucket. `superseded` is a proposal refused on one attempt
857
+ // and corrected on a later one — previously invisible, which left the printed line with a
858
+ // silent remainder on three consecutive runs. `unaccounted` is shown only when the numbers
859
+ // genuinely do not reconcile, rather than being folded into another bucket.
860
+ const _tally = spec.reconcileMintTally(mint);
861
+ process.stderr.write(
862
+ `[mint-step] proposed=${_tally.proposed} minted=${_tally.minted} ` +
863
+ `unchanged=${_tally.unchanged} rejected=${_tally.rejected} superseded=${_tally.superseded}` +
864
+ `${_tally.unaccounted ? ` UNACCOUNTED=${_tally.unaccounted}` : ''}\n`);
865
+ for (const m of mint.minted) {
866
+ const tag = m.kind === 'investigator' ? `investigator:${m.codeline || '(no codeline!)'}` : 'implementer';
867
+ process.stderr.write(`[mint-step] + [${tag}] ${m.name} (${m.surfaces.join(', ')}) — ${m.rationale}\n`);
868
+ }
869
+ // A codeline with no investigator gets the canonical detective — workable, but the
870
+ // operator should see it at the pause rather than infer it from a log line later.
871
+ for (const cl of codelines) {
872
+ const hasInv = mint.minted.some((m) => m.kind === 'investigator' && m.codeline === cl.name);
873
+ if (!hasInv) {
874
+ process.stderr.write(`[mint-step] ! no investigator minted for codeline "${cl.name}" — it will use the canonical detective\n`);
875
+ }
876
+ }
877
+ for (const r of mint.rejected) {
878
+ process.stderr.write(`[mint-step] ! refused ${r.name || '(unnamed)'}: ${r.reason}\n`);
879
+ }
880
+ // Persisted at generation time — an artefact that exists only in a log line is a defect.
881
+ fs.writeFileSync(path.join(LOG_DIR, 'agent-mint.json'), JSON.stringify(mint, null, 2));
882
+ _mintedNames = mint.minted.map((m) => m.name);
883
+ _mintedDetail = mint.minted;
884
+ }
885
+
886
+ // THE ROSTER'S ONLY ADVERSARY, AND A LOOP THAT ACTS ON IT.
887
+ //
888
+ // A reviewer whose findings nothing consumes is a critic. Its first live run produced seven
889
+ // blocking defects and the roster reached the pause unchanged — every one of those briefs
890
+ // would have been inherited by an implementer.
891
+ //
892
+ // Same shape as enforceVerificationCriteria: findings feed a regeneration, the regeneration is
893
+ // re-reviewed, and the cycle is capped. Corrective mints REPLACE rather than accumulate, so a
894
+ // correction cannot leave defective briefs behind beside their replacements.
895
+ //
896
+ // The replacement is TARGETED (ARCH-7). This first cleared the whole roster on any blocking
897
+ // finding, so one defective brief took every sound one with it and re-derived them all.
898
+ // Minting is a sampling process — a re-proposal is a fresh draw, so a brief that had just
899
+ // passed adversarial review was as likely to come back subtly wrong as to come back the
900
+ // same, and the reviewer's own capped budget went on re-checking settled work. Only the
901
+ // agents a blocking finding NAMES are replaced.
902
+ const runRosterReview = async () => {
903
+ // Same reason as reviewRoster: nothing was examined, so nothing may be certified.
904
+ if (!_mintedDetail.length) return { verdict: 'nothing_to_review', findings: [], reviewed: 0 };
905
+ process.env.EPAM_AGENT_NAME = 'roster-review';
906
+ try {
907
+ const liveProfiles = JSON.parse(fs.readFileSync(PROFILES_PATH, 'utf8'));
908
+ // THE PROJECT'S OWN PROFILES TOO. The minted agents' briefs are written here, not into the
909
+ // engine's canonical profiles — so passing only the latter handed the reviewer a header with
910
+ // a blank line under it for every agent it was asked to judge. It reported them all "empty"
911
+ // and blocked the roster twice, correctly about the text and wrongly about the roster.
912
+ let projectProfiles = {};
913
+ try {
914
+ const _pp = path.join(process.env.EPAM_PROJECT_CONFIG_DIR || '', 'agent-profiles.json');
915
+ projectProfiles = JSON.parse(fs.readFileSync(_pp, 'utf8'));
916
+ } catch { /* a project with no minted profiles yet has none to add */ }
917
+ return await spec.reviewRoster({
918
+ promptExec, minted: _mintedDetail, profiles: liveProfiles, projectProfiles,
919
+ codelines, tickets: stories, referencedDocs: docs,
920
+ logDir: LOG_DIR, repoPath: REPO_PATH, toolGrant,
921
+ });
922
+ } catch (err) {
923
+ // A review that cannot run must not read as "no defects" — that is the fail-open shape
924
+ // these gates exist to prevent.
925
+ process.stderr.write(`[mint-step] roster review FAILED: ${err && err.message}\n`);
926
+ return { verdict: 'review_failed', findings: [], reviewed: 0, error: String(err && err.message) };
927
+ }
928
+ };
929
+
930
+ const reportReview = (rv, n) => {
931
+ const blocking = rv.findings.filter((f) => f && f.severity === 'blocking');
932
+ process.stderr.write(
933
+ `[mint-step] roster review (cycle ${n}): ${rv.verdict} — ${rv.findings.length} finding(s), ` +
934
+ `${blocking.length} blocking\n`);
935
+ for (const f of rv.findings) {
936
+ process.stderr.write(`[mint-step] [${f.severity}] ${f.agent}: ${f.claim}\n`);
937
+ process.stderr.write(`[mint-step] checked: ${f.checked}\n`);
938
+ process.stderr.write(`[mint-step] found: ${f.found}\n`);
939
+ if (f.remedy) process.stderr.write(`[mint-step] remedy: ${f.remedy}\n`);
940
+ }
941
+ };
942
+
943
+ // THE CORRECTION BUDGET IS THE SEAM'S RETRY BUDGET — the same one every other seam retries on
944
+ // (SEAM_MAX_RETRIES / SPEC_AGENT_MAX_RETRIES, default 3) plus the first attempt. It was a
945
+ // literal 2: one correction, then refuse. A roster that needed two corrections was refused
946
+ // with the second review's findings unanswered (run 20260916T233216Z).
947
+ const _seamRetries = Math.max(0, parseInt(process.env.SEAM_MAX_RETRIES || process.env.SPEC_AGENT_MAX_RETRIES || '3', 10) || 0);
948
+ const maxCycles = Number(process.env.EPAM_ROSTER_REVIEW_CYCLES || String(_seamRetries + 1));
949
+ let cycle = 1;
950
+ let review = { verdict: 'not_run', findings: [], reviewed: 0 };
951
+
952
+ while (_mintedDetail.length && cycle <= maxCycles) {
953
+ review = await runRosterReview();
954
+ const blocking = review.findings.filter((f) => f && f.severity === 'blocking');
955
+ reportReview(review, cycle);
956
+ if (!blocking.length || cycle === maxCycles) break;
957
+
958
+ // Which agents does the reviewer actually indict? A finding names one in `agent`. A
959
+ // finding that names nothing — or names something not in this roster — is a statement
960
+ // about a GAP ("no one covers this codeline"), which indicts no brief and must remove
961
+ // nothing. Those still feed the corrective prompt, where they read as work to add.
962
+ const { indicted: _indicted, retained: _retained, gaps: _gapFindings, superfluous: _superfluous } =
963
+ rosterLib.partitionRosterFindings(blocking, _mintedDetail);
964
+
965
+ process.stderr.write(
966
+ `[mint-step] cycle ${cycle}: ${blocking.length} blocking finding(s) — ` +
967
+ `${_indicted.length} agent(s) indicted, ${_retained.length} retained` +
968
+ `${_gapFindings ? `, ${_gapFindings} finding(s) name no agent (roster gaps)` : ''}` +
969
+ `${_superfluous && _superfluous.length ? `, ${_superfluous.length} superfluous (no replacement needed)` : ''}\n`);
970
+
971
+ // Nothing indicted and nothing to add would re-mint the same roster forever against the
972
+ // same findings. Stop and let the operator see them rather than burn the budget.
973
+ if (!_indicted.length && !_gapFindings) {
974
+ process.stderr.write(
975
+ '[mint-step] no blocking finding names an agent in this roster — nothing to correct; ' +
976
+ 'surfacing the findings instead of re-minting\n');
977
+ break;
978
+ }
979
+
980
+ const cleared = rosterLib.clearProjectRoster(AGENTS_DIR, PROFILES_PATH, _indicted);
981
+ for (const c of cleared) {
982
+ const isSuper = _superfluous && _superfluous.includes(c);
983
+ process.stderr.write(`[mint-step] − ${c} (${isSuper ? 'superfluous — no replacement' : 'indicted, being replaced'})\n`);
984
+ }
985
+ for (const r of _retained) process.stderr.write(`[mint-step] = ${r.name} (passed review, kept)\n`);
986
+
987
+ // Superfluous agents are removed without replacement: their work is already covered by
988
+ // retained roles. Telling the minter to "cover EVERYTHING" for a superfluous role causes it
989
+ // to create a new catchall that re-introduces the overlap the reviewer just flagged.
990
+ const _replacedForMint = _mintedDetail.filter(
991
+ (m) => _indicted.includes(m.name) && !(_superfluous && _superfluous.includes(m.name)));
992
+ const _superfluousForMint = _mintedDetail.filter(
993
+ (m) => _superfluous && _superfluous.includes(m.name));
994
+
995
+ const remint = await spec.mintProjectAgents({
996
+ promptExec, tickets: stories, referencedDocs: docs, declaredDependencies: deps,
997
+ codelines, toolGrant, profilesPath: PROFILES_PATH, agentsDir: AGENTS_DIR,
998
+ logDir: LOG_DIR, repoPath: REPO_PATH, correctiveFindings: blocking,
999
+ retainedAgents: _retained,
1000
+ replacedAgents: _replacedForMint,
1001
+ superfluousAgents: _superfluousForMint,
1002
+ });
1003
+ // The next review sees the WHOLE roster, not just the replacements: a new brief can
1004
+ // duplicate or contradict a retained one, and only a review of both would catch it.
1005
+ _mintedDetail = [..._retained, ...remint.minted];
1006
+ _mintedNames = _mintedDetail.map((m) => m.name);
1007
+ fs.writeFileSync(path.join(LOG_DIR, `agent-mint-cycle${cycle + 1}.json`), JSON.stringify(remint, null, 2));
1008
+ for (const m of remint.minted) {
1009
+ const tag = m.kind === 'investigator' ? `investigator:${m.codeline || '(no codeline!)'}` : 'implementer';
1010
+ process.stderr.write(`[mint-step] + [${tag}] ${m.name} — ${m.rationale}\n`);
1011
+ }
1012
+
1013
+
1014
+ cycle += 1;
1015
+ }
1016
+
1017
+ review.cycles = cycle;
1018
+ fs.writeFileSync(path.join(LOG_DIR, 'roster-review.json'), JSON.stringify(review, null, 2));
1019
+
1020
+ // THE REVIEW NOT RUNNING IS AS SERIOUS AS THE REVIEW FAILING THE ROSTER.
1021
+ //
1022
+ // Live 2026-08-08: the reviewer returned empty output, reviewRoster reported "sound", and
1023
+ // this roster reached the operator pause labelled clean while nothing had checked it. The
1024
+ // reviewer is the only thing standing between a generated brief and an implementer
1025
+ // inheriting it whole, so an absent review gets the SAME treatment as surviving defects:
1026
+ // stated loudly, and refused outright when no pause is configured for anyone to see it.
1027
+ const _mintWasSkipped = process.env.EPAM_SKIP_AGENT_MINT === '1';
1028
+ if (rosterLib.rosterReviewIsRequired({
1029
+ verdict: review.verdict,
1030
+ mintSkipped: _mintWasSkipped,
1031
+ rosterOnly: String(process.env.EPAM_ROSTER_ONLY || '') === '1',
1032
+ pauseConfigured: /^(1|true|yes)$/i.test(process.env.EPAM_PAUSE_AFTER_AGENT_MINT || ''),
1033
+ })) {
1034
+ process.stderr.write(
1035
+ `[mint-step] ! the roster was NOT reviewed (${review.verdict})` +
1036
+ `${review.error ? `: ${review.error}` : ''}\n`);
1037
+ throw new Error(
1038
+ 'roster review did not run, so these briefs are unchecked, and no roster pause is ' +
1039
+ 'configured for anyone to look at them. An unreviewed roster is not a sound one. ' +
1040
+ 'See roster-review.json.');
1041
+ }
1042
+ if (_mintWasSkipped) {
1043
+ process.stderr.write(
1044
+ `[mint-step] ${rosterReviewSkipReason(process.env)}\n`);
1045
+ }
1046
+
1047
+ // Still defective after the full correction budget. With the pause on, the operator sees it
1048
+ // and decides. With the pause OFF nothing downstream would ever mention it, so it halts here.
1049
+ const _stillBlocking = review.findings.filter((f) => f && f.severity === 'blocking');
1050
+ if (_stillBlocking.length) {
1051
+ process.stderr.write(
1052
+ `[mint-step] ${_stillBlocking.length} blocking finding(s) remain after ${cycle} cycle(s)\n`);
1053
+ if (!/^(1|true|yes)$/i.test(process.env.EPAM_PAUSE_AFTER_AGENT_MINT || '')) {
1054
+ throw new Error(
1055
+ `roster review: ${_stillBlocking.length} blocking finding(s) survive after ${cycle} correction ` +
1056
+ 'cycle(s), and no roster pause is configured for anyone to see them. Refusing to hand these ' +
1057
+ 'briefs to implementers. See roster-review.json.');
1058
+ }
1059
+ process.stderr.write('[mint-step] the roster pause is on — surfacing them for review rather than halting\n');
1060
+ }
1061
+
1062
+ // ASSIGNMENT DERIVES FROM THE ROSTER, SO IT RUNS ONCE THE ROSTER IS SETTLED.
1063
+ //
1064
+ // This used to run BEFORE the review/correction loop. Stories were assigned from the
1065
+ // PROPOSED roster; the reviewer then indicted agents, the correction replaced them, and the
1066
+ // assignments were never revisited. Live 2026-08-08: all three lanes were assigned
1067
+ // 'contentstack-live-preview-engineer' after it had been replaced by
1068
+ // 'contentstack-live-preview-integration-engineer' — a role with no profile. Each lane would
1069
+ // have invoked an agent with an empty system prompt, and the write perimeter would have
1070
+ // refused it, since the name is not in project-roles.json. Exactly the "proposed, briefed,
1071
+ // wired, assigned a story — and then unable to write a byte" failure the perimeter exists to
1072
+ // make impossible.
1073
+ //
1074
+ await runRosterStage(_mintedDetail);
1075
+
1076
+
1077
+ // Targeted correction (ARCH-7) is what made this reachable: a wholesale re-mint would have
1078
+ // failed loudly and obviously. A surgical replacement leaves the roster looking healthy and
1079
+ // only the assignments stale, which is quieter and worse.
1080
+ process.env.EPAM_AGENT_NAME = 'role-assigner';
1081
+ const assignment = await spec.assignAgentRoles({
1082
+ promptExec, stories, profilesPath: PROFILES_PATH, logDir: LOG_DIR, repoPath: REPO_PATH,
1083
+ });
1084
+ for (const a of assignment.assigned) {
1085
+ process.stderr.write(`[mint-step] ${a.storyId} -> ${a.agentRole} (${a.reason})\n`);
1086
+ }
1087
+ fs.writeFileSync(path.join(LOG_DIR, 'role-assignments.json'), JSON.stringify(assignment.assigned, null, 2));
1088
+
1089
+ // A LAST DETERMINISTIC CHECK, BECAUSE THIS IS THE SEAM THAT BROKE.
1090
+ //
1091
+ // Every assignment must name a role that exists in the settled roster. The check is cheap
1092
+ // and mechanical, and it fails the step rather than handing a lane a name with no brief.
1093
+ // THE SETTLED ROSTER IS roster.json, WHICH IS WHAT THIS MESSAGE HAS ALWAYS CLAIMED.
1094
+ //
1095
+ // This read the ENGINE's agents/profiles.json. ba9cee7 (2026-08-22) stopped the mint writing
1096
+ // there — one project's agents were reaching another's roster — moving the briefs to
1097
+ // <project>/agent-profiles.json and the settled roster to <project>/roster.json. This reader
1098
+ // was left behind, so it checked assignments against a file the mint no longer touches and
1099
+ // rejected every project role.
1100
+ //
1101
+ // mock3 run 8: the roster held fare-schedule-logic-engineer as an implementer,
1102
+ // project-roles.json registered it, agent-profiles.json carried its brief — and this threw
1103
+ // "names a role that is not in the settled roster" for the one agent the run had just minted.
1104
+ // Second reader left behind by that move; candidateRoles in spec-mode-runner was the first.
1105
+ //
1106
+ // Falls back to the engine profiles when no project roster exists, so a run without one
1107
+ // behaves exactly as it did before.
1108
+ // eslint-disable-next-line global-require
1109
+ const { projectRosterPath } = require('./lib/project-roster.js');
1110
+ let _finalRoles;
1111
+ try {
1112
+ const _rosterFile = projectRosterPath(process.env.EPAM_PROJECT_CONFIG_DIR || '');
1113
+ _finalRoles = new Set(Object.keys(JSON.parse(fs.readFileSync(_rosterFile, 'utf8')).agents || {}));
1114
+ } catch {
1115
+ _finalRoles = new Set(Object.keys(JSON.parse(fs.readFileSync(PROFILES_PATH, 'utf8'))));
1116
+ }
1117
+ const _orphaned = assignment.assigned
1118
+ .filter((a) => a && a.agentRole && !_finalRoles.has(a.agentRole))
1119
+ .map((a) => `${a.storyId}${a.codeline ? `/${a.codeline}` : ''} -> ${a.agentRole}`);
1120
+ if (_orphaned.length) {
1121
+ throw new Error(
1122
+ `${_orphaned.length} assignment(s) name a role that is not in the settled roster: ` +
1123
+ `${_orphaned.join('; ')}. A lane would invoke an agent with no brief and no write ` +
1124
+ 'permission. Refusing to hand stories to roles that do not exist.');
1125
+ }
1126
+
1127
+ // assignAgentRoles mutates the story objects in place; they are the PRD's own objects.
1128
+ fs.writeFileSync(PRD_PATH, JSON.stringify(prd, null, 2));
1129
+
1130
+ writeRosterDiff(PROFILES_PATH, AGENTS_DIR, LOG_DIR, _mintedNames, _mintedDetail);
1131
+
1132
+ // Record which seam every agent just minted enters the pipeline by. Throws if any of them
1133
+ // resolves to nothing, which fails the mint — that is the point: an unconfigured agent is
1134
+ // caught here, before any story runs, not three hours into a run.
1135
+ const _registryPath = process.env.AGENT_PROFILES_REGISTRY || path.join(AGENTS_DIR, "invocation-profiles.json");
1136
+ const _xref = writeAgentSeamCrossReference(PROFILES_PATH, _registryPath);
1137
+ process.stderr.write(`[mint-step] agent→seam cross-reference written for ${Object.keys(_xref).length} agent(s)\n`);
1138
+
1139
+ // And that the roster we just minted can actually RUN: every required input of every agent has
1140
+ // somebody in this roster — or the engine — producing it. A writer waiting on a plan nobody was
1141
+ // minted to make is the shape of every unwinnable story this pipeline has produced, and it is
1142
+ // decidable here from the data alone, with no model involved and no tokens spent.
1143
+ validateWorkflow(PROFILES_PATH, _registryPath);
1144
+ process.stderr.write(`[mint-step] ✓ workflow is buildable: every required input has a producer\n`);
1145
+
1146
+ // ── THE ROSTER IS SETTLED, SO NOW BUILD THIS PROJECT'S PROMPTS ─────────────
1147
+ //
1148
+ // The agent that minted the roster also builds the prompts, because this is the only stage
1149
+ // that knows both what the project is and which roles will work on it — the two things a
1150
+ // specialised prompt needs. Nothing generated project prompts before this existed, which is
1151
+ // why the repo held a handful of hand-authored ones and a new project had none at all.
1152
+ //
1153
+ // It runs HERE, after the review/correction loop and after validateWorkflow, for the same
1154
+ // reason assignment does: generating against a proposed roster would specialise prompts for
1155
+ // roles the reviewer is about to replace.
1156
+ //
1157
+ // The generator is invoked through runClaude, the same seam every other agent uses, so it
1158
+ // inherits ladder, retry, timeout and cost capture rather than being a private call.
1159
+ {
1160
+ const promptsLib = require('./lib/prompt-library.js');
1161
+ const { makePromptReviewer, makePromptRenderer } = require('./lib/prompt-review.js');
1162
+ const { seamInvocationEnv } = require('./lib/seam-invocation.js');
1163
+ const { buildProjectPrompts, codelineFromPrd } = require('./lib/project-prompt-builder.js');
1164
+ const engineRoot = path.join(__dirname, '..', '..');
1165
+ const templatesDir = path.join(engineRoot, promptsLib.TEMPLATE_DIR_REL);
1166
+ const bootstrapFile = path.join(engineRoot, 'orchestrations', 'prompts', 'bootstrap.json');
1167
+ const projectConfigDir = process.env.EPAM_PROJECT_CONFIG_DIR || '';
1168
+
1169
+ // No project dir means nowhere to install them, and prompt-library will not fall back to a
1170
+ // template. Guessing a location would provision a project nobody asked for.
1171
+ if (!projectConfigDir) {
1172
+ throw new Error(
1173
+ 'cannot build project prompts: EPAM_PROJECT_CONFIG_DIR is unset. The project-authority ' +
1174
+ 'prompts are the only ones ever rendered, and the template is never executed.');
1175
+ }
1176
+
1177
+ // PROMPTS ARE PROVISIONED ONCE, BEFORE PAUSE 1. A RESUME NEVER REBUILDS THEM.
1178
+ //
1179
+ // Operator rule, 2026-09-02: duplication of a process after a pause is not permitted. This
1180
+ // call had no resume guard at all, so every resume re-provisioned all 39 prompts — and the
1181
+ // cache could not absorb it, because the cache key includes mintedRoles and a resume SKIPS the
1182
+ // mint, so that string becomes the literal '(none minted this run)' instead of the roster.
1183
+ // Every entry therefore missed. Measured on the 2026-09-02 resume of 20260902T022134Z: roles
1184
+ // digest 1dad7a5a… at pause 1 against cba40c8d… on the resume, 9 entries reused and 30
1185
+ // rebuilt, for prompts already sitting complete on disk.
1186
+ //
1187
+ // THE INSTALLED PROMPTS ARE THE SIGNAL, as with the settled roster. pre-run-reset keeps
1188
+ // <project>/prompts/ intact on a resume, so a populated directory here means this run already
1189
+ // provisioned them and the operator reviewed what they produced at the pause.
1190
+ //
1191
+ // NOT A CACHE, A SKIP. Reusing them "cheaply" would still resolve every seam, re-render every
1192
+ // generator prompt and re-derive the key; the rule is that the stage does not run.
1193
+ // A FLAG, NOT AN EARLY RETURN. This block is a bare `{ }` inside the step, and returning from
1194
+ // it would abandon everything after — linkPromptsToRoster above all, which checks that every
1195
+ // minted agent's seam actually has a prompt in THIS project. That check is data-only, costs
1196
+ // nothing, and is exactly what a resume still needs: skipping the BUILD must not skip the
1197
+ // verification that what is on disk covers the roster.
1198
+ let _skipProvisioning = false;
1199
+ const _resumingRun = String(process.env.EPAM_RESUME_RUN || '').trim();
1200
+
1201
+ // A CODELINE WHOSE PROMPTS ARE COMPLETE IS THE SAME CASE AS A RESUME.
1202
+ //
1203
+ // Operator, 2026-09-06: "mint should not run nor prompt builder". The skip below already
1204
+ // states the principle — "THE INSTALLED PROMPTS ARE THE SIGNAL ... NOT A CACHE, A SKIP" — and
1205
+ // was gated on EPAM_RESUME_RUN alone, so a FRESH run against a codeline whose set was already
1206
+ // complete rebuilt all 39.
1207
+ //
1208
+ // A CACHE CANNOT COVER THIS, for the reason the comment above already gives: the key includes
1209
+ // mintedRoles, and a run that does not mint passes '(none minted this run)'. Measured on the
1210
+ // 2026-09-06 run — 39 entries on disk, 0 hits. The answer is to not run the builder, not to
1211
+ // make the key cleverer.
1212
+ //
1213
+ // The signal is the one pre-run-reset already trusts: .prompt-cache/.complete-<codeline>,
1214
+ // written only after every prompt installed, cleared when provisioning starts, and named for
1215
+ // the codeline so one codeline's set cannot serve another.
1216
+ // THE CODELINE IS DETECTED, NEVER PRESET. EPAM_CODELINE_ID is read in six places in this repo
1217
+ // and SET in none — no launcher, config file or env file exports it — so keying this skip on
1218
+ // it alone meant it could never fire in a live run: every run re-provisioned all 41 prompts
1219
+ // while the marker sat correct on disk. The run's own resolved scope is in the PRD, and
1220
+ // codelineFromPrd is the SAME function the prompt builder keys its cache with, so the two
1221
+ // cannot disagree about which codeline this run is for.
1222
+ // A STORY OF SEVERAL CODELINES (operator, 2026-10-22: "prompts/profiles BY codeline"): its own codeline when every codeline of
1223
+ // the story has its complete recorded set — the same answer the mint's decision takes (lib/prompt-variant.sh) — else none, and
1224
+ // the build records its set for every codeline of the story.
1225
+ const _codelineId = String(process.env.EPAM_CODELINE_ID || '').trim()
1226
+ || String(codelineFromPrd(process.env.PRD_FILE || PRD_PATH) || '').trim()
1227
+ || String(require('./lib/project-prompt-builder.js').scopeCodelineFromPrd({ prdFile: process.env.PRD_FILE || PRD_PATH, projectConfigDir,
1228
+ templatesDir, registryFile: path.join(AGENTS_DIR, 'invocation-profiles.json') }) || '').trim();
1229
+ let _codelineComplete = false;
1230
+ if (_codelineId) {
1231
+ try {
1232
+ // COMPLETE FOR THE CURRENT INPUTS, not merely marked: the marker's digest is compared
1233
+ // with the template layer, registry and generator now (codelinePromptsComplete, which
1234
+ // honours the brownfield variant). An empty marker let a launch reuse prompts built from
1235
+ // the previous day's templates (regintel 20260920T232518Z, $5.73).
1236
+ const { codelinePromptsComplete } = require('./lib/project-prompt-builder.js');
1237
+ const _cc = codelinePromptsComplete({
1238
+ projectConfigDir, codeline: _codelineId, templatesDir,
1239
+ registryFile: path.join(AGENTS_DIR, 'invocation-profiles.json'),
1240
+ });
1241
+ _codelineComplete = _cc.complete === true;
1242
+ if (!_codelineComplete) process.stderr.write(`[mint-step] prompts for codeline ${_codelineId}: ${_cc.reason}\n`);
1243
+ } catch { _codelineComplete = false; }
1244
+ }
1245
+
1246
+ // SKIPPED ONLY WHEN THE SET IS COMPLETE. A resume used to skip whenever ANY prompt was on disk,
1247
+ // so a build interrupted part-way was never finished: 15 of 43 prompts, no failure-analyst
1248
+ // prompt, and self-heal could not run (regintel, 2026-09-25). An incomplete set on a resume is
1249
+ // COMPLETED — every prompt already installed is kept as it is (keepExisting), only the missing
1250
+ // ones are built. Nothing the run built is rebuilt.
1251
+ if (_resumingRun && !_codelineComplete) {
1252
+ process.stderr.write(`[mint-step] resuming ${_resumingRun}: the prompt set for ${_codelineId || 'this codeline'} is not `
1253
+ + 'complete — keeping every prompt already installed and building only the missing ones\n');
1254
+ }
1255
+ if (_codelineComplete) {
1256
+ const _installedDir = path.join(projectConfigDir, 'prompts');
1257
+ let _installed = [];
1258
+ try {
1259
+ _installed = fs.readdirSync(_installedDir).filter((f) => f.endsWith('.json'));
1260
+ } catch { _installed = []; }
1261
+ if (_installed.length) {
1262
+ process.stderr.write(_resumingRun
1263
+ ? `[mint-step] prompts already provisioned for this run (${_installed.length} on disk) — `
1264
+ + 'not rebuilt; a resume repeats no stage it has already completed\n'
1265
+ : `[mint-step] prompts already complete for codeline ${_codelineId} `
1266
+ + `(${_installed.length} on disk) — not rebuilt; `
1267
+ + 'EPAM_REGENERATE_CODELINE_ASSETS=1 forces regeneration\n');
1268
+ _skipProvisioning = true;
1269
+ } else {
1270
+ // Empty is NOT silently accepted: something cleared them, and continuing without saying so
1271
+ // would look identical to a run that never provisioned any. Only when empty: this printed after every
1272
+ // skip too, saying the opposite of what had just been decided (2026-10-09).
1273
+ process.stderr.write(
1274
+ '[mint-step] the prompt set is marked complete, but no prompts are installed — provisioning them now\n');
1275
+ }
1276
+ }
1277
+
1278
+ // THE PROVISIONING MODE IS THE PROJECT'S DECISION, and no mode was being passed at all —
1279
+ // so every project silently got 'generate', including the ones whose prompts are meant to
1280
+ // stay identical to the generic text. metrolinx is a copy-mode project that the next mint
1281
+ // would have regenerated. A project is data; this is one of its facts.
1282
+ const promptMode = process.env.EPAM_PROMPT_PROVISION_MODE || '';
1283
+ if (!promptMode) {
1284
+ throw new Error(
1285
+ 'cannot build project prompts: EPAM_PROMPT_PROVISION_MODE is unset. Declare it in the '
1286
+ + "project's env as 'copy' (install the generic templates as they are) or "
1287
+ + "'generate' (specialise each one for this project). There is no engine default: "
1288
+ + 'picking one silently is how a project ends up running prompts nobody chose.');
1289
+ }
1290
+
1291
+ // THE PROJECT'S ROLES, as the prompts are specialised for them and the prompt cache is keyed by them: what this run
1292
+ // minted, or — when the mint did not run (a resume, agents kept from the run before) — the agents the mint recorded,
1293
+ // read back from its registries. Kept apart from _mintedDetail, which means "minted THIS run" and is what the roster
1294
+ // review examines: filling it for a kept roster re-reviewed and replaced a kept agent (AMSD-2974 run 7, 2026-10-09).
1295
+ const _projectRoles = _mintedDetail.length
1296
+ ? _mintedDetail
1297
+ : require('./lib/project-roster.js').mintedFromRegistries(projectConfigDir || '');
1298
+
1299
+ process.env.EPAM_AGENT_NAME = 'prompt-builder';
1300
+ // Opt-in per project; the registry's prompt-review.enabledBy names this variable.
1301
+ // ON UNLESS A PROJECT EXPLICITLY TURNS IT OFF.
1302
+ //
1303
+ // This required an opt-in flag no project ever set, so every generated prompt in every run was
1304
+ // installed UNREVIEWED — the one artefact every downstream agent inherits whole was the only
1305
+ // one nobody checked. Absence of a flag is not a decision to skip the review.
1306
+ const _promptReviewEnabled = String(process.env.EPAM_PROMPT_REVIEW_ENABLED ?? '1') !== '0';
1307
+ // Hoisted so the reviewer is handed exactly the codelines the generator was — a reviewer
1308
+ // checking claims against different facts than the writer saw is not a review.
1309
+ // THE GENERATOR IS GIVEN VERIFIED FACTS, OR TOLD IT HAS NONE.
1310
+ //
1311
+ // This passed a bare name per codeline while project-prompt-generation.json instructs the
1312
+ // generator to "name no file, symbol, package or command that does not appear in the context
1313
+ // you were given". With no files in the context, the only compliant answer was to name nothing,
1314
+ // and the model resolved it the other way: the team-lead-review prompt asserted "Form
1315
+ // components are typically in src/components/Checkout/", which does not exist. prompt-review
1316
+ // rejected all three attempts and the mint died (metrolinx AMSD-1919, 2026-09-01).
1317
+ //
1318
+ // codeline-facts.json and the survey's surfaces were already computed by this point and went
1319
+ // nowhere. Surfaces are checked against disk before being named, so the context cannot itself
1320
+ // become the source of a fabrication.
1321
+ const { buildCodelineContext } = require('./lib/codeline-context.js');
1322
+ const _factsFile = projectConfigDir
1323
+ ? path.join(projectConfigDir, 'codeline-facts.json') : null;
1324
+ const _surveyed = (survey && survey.ran && Array.isArray(survey.codelines))
1325
+ ? survey.codelines.map((c) => ({ codeline: c.codeline, surfaces: c.surfaces || [] }))
1326
+ : [];
1327
+ const _codelineContext = buildCodelineContext({
1328
+ codelines, factsFile: _factsFile, surveyed: _surveyed,
1329
+ });
1330
+ const _built = _skipProvisioning
1331
+ ? { copied: [], generated: [] }
1332
+ : await buildProjectPrompts({
1333
+ keepExisting: !!_resumingRun,
1334
+ templatesDir,
1335
+ bootstrapFile,
1336
+ // ONE UNLUCKY PROMPT MUST NOT ABORT AN OTHERWISE COMPLETE RUN, and raising the budget must
1337
+ // not mean editing a shipped file. Live 2026-09-04, pipeline-tests-18: 'runtime-boundary-
1338
+ // review' exhausted its 3 attempts, the mint refused (correctly — no agent, no story), and
1339
+ // the run died after ~75 minutes and ~$9 with ~34 good prompts already generated and
1340
+ // discarded. The roster mint has had EPAM_ROSTER_ATTEMPTS for exactly this reason; prompt
1341
+ // generation had no equivalent, so the only lever was a literal in the library.
1342
+ //
1343
+ // Default 5, not 3: 3 is the number the live failure exhausted. This does not FIX the
1344
+ // rejections — the reviewer and generator changes in this same commit do — it stops a
1345
+ // residual unlucky draw from costing the whole run.
1346
+ attempts: Number(process.env.EPAM_PROMPT_ATTEMPTS || 5),
1347
+ // The seam registry decides what needs a project copy — a seam that runs a template needs
1348
+ // one, or it executes the generic parent. bootstrap still supplies auxiliary prompts.
1349
+ registryFile: path.join(AGENTS_DIR, 'invocation-profiles.json'),
1350
+ projectConfigDir,
1351
+ mode: promptMode,
1352
+ projectContext: [
1353
+ `Project: ${path.basename(String(projectConfigDir || ''))}`,
1354
+ `Tickets in scope: ${stories.map((t) => `${t.jiraKey || t.id}: ${t.title || ''}`).join(' | ')}`,
1355
+ ].join('\n'),
1356
+ // The same text the reviewer is handed, so the generator and its judge see one context.
1357
+ codelineContext: _codelineContext,
1358
+ mintedRoles: _projectRoles.length
1359
+ ? _projectRoles.map((m) => `- ${m.name} [${m.kind}] — ${m.rationale || ''}`).join('\n')
1360
+ : '(none minted this run)',
1361
+ // THE BUILDER RUNS AT ITS OWN SEAM, with the grants that seam declares.
1362
+ //
1363
+ // It passed {} as the environment, so it inherited whatever the mint process happened to
1364
+ // have: no tool grant, no iteration budget, and the ladder of whichever agent ran last.
1365
+ // The operator's design is that this agent READS the templates and decorates them, which
1366
+ // it cannot do without read_file and list_files -- and a declaration that never reaches
1367
+ // the invocation is the same defect as a ladder that resolves to nothing.
1368
+ //
1369
+ // AI_GATE_ALLOW_TOOLS is what actually opens the tool channel; the seam decides WHICH
1370
+ // tools. Granting the channel without the list, or the list without the channel, both
1371
+ // produce an agent that quietly has nothing.
1372
+ // The generator and its reviewer — one definition, shared with the live-token harness.
1373
+ ...require('./lib/prompt-build-calls.js').promptBuildCalls({
1374
+ engineRoot, logDir: LOG_DIR, runClaude: spec.runClaude, promptExec, projectConfigDir,
1375
+ codelineContext: _codelineContext, stories, mintedDetail: _projectRoles, toolGrant,
1376
+ reviewEnabled: _promptReviewEnabled,
1377
+ }),
1378
+ log: (m) => process.stderr.write(`${m}\n`),
1379
+ });
1380
+ // SAY WHICH IT WAS. Reporting "0 copied, 0 generated" for a skip reads as a provisioning run
1381
+ // that produced nothing — the same sentence for two opposite states, which is how a stage that
1382
+ // silently did nothing goes unnoticed for weeks.
1383
+ if (!_skipProvisioning) {
1384
+ process.stderr.write(
1385
+ `[mint-step] ✓ prompts provisioned (${promptMode}): ${_built.copied.length} copied (bootstrap), ` +
1386
+ `${_built.generated.length} generated → ${path.join(projectConfigDir, 'prompts')}\n`);
1387
+ }
1388
+
1389
+ // ── LINK THE PROMPTS TO THE AGENTS JUST MINTED ────────────────────────────
1390
+ //
1391
+ // Provisioning and minting ran in the same stage and knew nothing about each other: the
1392
+ // builder walked a static list, the mint separately resolved agents to seams, and nobody
1393
+ // asked whether the seam a minted agent enters at has a prompt in THIS project. The answer
1394
+ // arrived at that agent's first invocation instead, hours in, with the run already spending.
1395
+ //
1396
+ // Decidable from data — roster, registry, installed library — so it costs nothing and lands
1397
+ // here, where a gap is one line to fix.
1398
+ const { linkPromptsToRoster } = require('./lib/prompt-agent-link.js');
1399
+ const _link = linkPromptsToRoster({
1400
+ projectConfigDir,
1401
+ registryFile: _registryPath,
1402
+ // rosterAgents returns agent -> brief; the link step wants the NAMES. Passing the object
1403
+ // made the roster look empty, which the step correctly refused rather than linking nothing
1404
+ // and reporting success.
1405
+ agents: Object.keys(rosterAgents(PROFILES_PATH)),
1406
+ });
1407
+ process.stderr.write(
1408
+ `[mint-step] ✓ prompts linked: ${Object.keys(_link.agents).length} agent(s) across ` +
1409
+ `${_link.seamsInUse.length} seam(s) → ${path.join(projectConfigDir, 'prompt-agent-link.json')}\n`);
1410
+
1411
+ }
1412
+
1413
+ process.stderr.write(`[mint-step] ✓ roster and assignments written to ${PRD_PATH}\n`);
1414
+ })().catch((err) => {
1415
+ process.stderr.write(`[mint-step] FAILED: ${(err && err.message) || err}\n`);
1416
+ process.exit(1);
1417
+ });