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,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
|
+
});
|