amsd-pipeline-shared 0.5.12 → 0.5.13
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/README.md
CHANGED
|
@@ -50,6 +50,18 @@ amsd-pipeline-shared prelaunch --install <install> --project <p> --run-id <id> -
|
|
|
50
50
|
Prints every check and the 12-item checklist, writes `<out>/prelaunch.json`; exit 0 all green, 1 any red. Nothing it does
|
|
51
51
|
calls a model.
|
|
52
52
|
|
|
53
|
+
## A project's prompts brought up to its templates, without a run
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
. <install>/orchestrations/scripts/lib/env-file.sh; load_project_env <install>/orchestrations/projects/<p>
|
|
57
|
+
node <install>/orchestrations/scripts/refresh-project-prompts.js --project <install>/orchestrations/projects/<p> --prd <run prd.json> [--stand-in]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Per codeline in the PRD: the codeline's recorded agents and prompts restored, then the pipeline's own builder under its resume
|
|
61
|
+
rule — a prompt still the copy of its template is kept with no call; one whose template changed is regenerated and reviewed.
|
|
62
|
+
`--stand-in` calls no model (the generator answers with the template's text, the reviewer approves): run it on a copy of the
|
|
63
|
+
project to see which prompts would be rebuilt before spending anything. Prints one JSON line: per codeline, what was rebuilt.
|
|
64
|
+
|
|
53
65
|
## Messages from the shell
|
|
54
66
|
|
|
55
67
|
For the bash pipeline (backlog B55), one JSON line out:
|
|
@@ -232,3 +232,4 @@ e902dfbf9802f60b06b7cd8ef48deaf5 prompts/templates/vc-review.json
|
|
|
232
232
|
7e82a5b7d9ea0dd04f7dd4db5300e2c0 prompts/templates/writer-codegraph-block.json
|
|
233
233
|
ff9ea38d594f181363c40295ea42012a prompts/templates/writer-plan-section.json
|
|
234
234
|
8e18f7be6b28fa94c8dbbc723a23dc57 prompts/templates/writer-string-invariants.json
|
|
235
|
+
28adf858dafddc3ef7bb03d3686dfe6d scripts/lib/project-prompt-contract.js
|
package/docs/gateway-files.txt
CHANGED
|
@@ -223,6 +223,7 @@ scripts/lib/plan-of-record.sh
|
|
|
223
223
|
scripts/lib/plan-scope.js
|
|
224
224
|
scripts/lib/preflight.sh
|
|
225
225
|
scripts/lib/prelaunch.js
|
|
226
|
+
scripts/lib/project-prompt-contract.js
|
|
226
227
|
scripts/lib/qa-gate-evidence.sh
|
|
227
228
|
scripts/lib/relative-paths.js
|
|
228
229
|
scripts/lib/release-lines.sh
|
|
@@ -245,6 +246,7 @@ scripts/llm-handler.sh
|
|
|
245
246
|
scripts/preflight-check.sh
|
|
246
247
|
scripts/preflight-prd-integrity.sh
|
|
247
248
|
scripts/preflight-static.sh
|
|
249
|
+
scripts/refresh-project-prompts.js
|
|
248
250
|
scripts/spec-mode-runner.js
|
|
249
251
|
scripts/team-lead-review.sh
|
|
250
252
|
scripts/update-monitor.sh
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* THE CONTRACT A GENERATED PROJECT PROMPT MUST HONOUR.
|
|
4
|
+
*
|
|
5
|
+
* Templates are generic and immutable. The project-authority copy is generated from one and
|
|
6
|
+
* is the ONLY thing prompt-library.js will render — there is no fallback to the template, by
|
|
7
|
+
* operator mandate, because a silent degrade runs an engine-embedded default for a whole
|
|
8
|
+
* campaign without anyone noticing.
|
|
9
|
+
*
|
|
10
|
+
* WHY THIS IS CODE AND NOT AN INSTRUCTION IN THE PROMPT. Rendering is strict in both
|
|
11
|
+
* directions: every declared placeholder must appear in the body, every placeholder in the
|
|
12
|
+
* body must be declared, and every one must be supplied at render time. A generator that
|
|
13
|
+
* adds, drops or renames a single placeholder therefore produces a prompt that does not
|
|
14
|
+
* degrade — it THROWS, mid-run, after the roster is minted and the run is already spending.
|
|
15
|
+
* Asking a model nicely to preserve placeholders is not a guarantee; checking its output is.
|
|
16
|
+
*
|
|
17
|
+
* The check runs at GENERATION time so the failure names the generator, rather than surfacing
|
|
18
|
+
* three stages later at whichever seam happened to need that prompt.
|
|
19
|
+
*/
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const crypto = require('crypto');
|
|
23
|
+
|
|
24
|
+
// ONE DEFINITION, IMPORTED — not a second copy of the rule.
|
|
25
|
+
//
|
|
26
|
+
// This file carried its own placeholder regex, and it was the GREEDY version. engine-prompt.js
|
|
27
|
+
// was fixed to match lazily so that adjacent placeholders (__A____B__, which is what ${a}${b}
|
|
28
|
+
// becomes) read as two, and this copy was not — so the contract saw seven adjacent blocks as
|
|
29
|
+
// one token and refused every generated spec-agent-openspec as "dropping placeholders" the
|
|
30
|
+
// template required. Two copies of a rule is two rules.
|
|
31
|
+
const { placeholdersIn: _placeholdersIn } = require('./engine-prompt.js');
|
|
32
|
+
|
|
33
|
+
/** Placeholders present in a body, deduped and sorted. */
|
|
34
|
+
function placeholdersIn(body) {
|
|
35
|
+
return [..._placeholdersIn(body)].sort();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const sorted = (a) => [...(Array.isArray(a) ? a : [])].sort();
|
|
39
|
+
const missingFrom = (want, have) => want.filter((p) => !have.includes(p));
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Is this generated prompt safe to install as the project authority?
|
|
43
|
+
*
|
|
44
|
+
* @param {{id:string, body:string, placeholders:string[]}} template the generic source
|
|
45
|
+
* @param {{id:string, body:string, placeholders:string[]}} generated the model's output
|
|
46
|
+
* @returns {{ok:boolean, reason:string}} reason NAMES the offending placeholders — a bare
|
|
47
|
+
* "invalid" leaves the operator diffing two prompts by eye.
|
|
48
|
+
*/
|
|
49
|
+
function checkGeneratedPrompt(template, generated) {
|
|
50
|
+
if (!generated || typeof generated.body !== 'string' || !generated.body.trim()) {
|
|
51
|
+
return { ok: false, reason: 'the generated prompt has an empty body — nothing would be sent' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const tplWanted = sorted(template && template.placeholders);
|
|
55
|
+
const genUsed = placeholdersIn(generated.body);
|
|
56
|
+
const genDeclared = sorted(generated.placeholders);
|
|
57
|
+
|
|
58
|
+
// 1. Every value the pipeline supplies must still have somewhere to land.
|
|
59
|
+
const dropped = missingFrom(tplWanted, genUsed);
|
|
60
|
+
if (dropped.length) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
reason: `the generated prompt dropped placeholder(s) the template requires: ${dropped.join(', ')}. `
|
|
64
|
+
+ 'The evidence they carry would silently never reach the agent.',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 2. Nothing may be invented: render supplies values by NAME, so an unknown placeholder is
|
|
69
|
+
// never substituted and prompt-library throws "still contains placeholders after rendering".
|
|
70
|
+
const invented = missingFrom(genUsed, tplWanted);
|
|
71
|
+
if (invented.length) {
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
reason: `the generated prompt invented placeholder(s) nothing supplies: ${invented.join(', ')}. `
|
|
75
|
+
+ 'Rendering would throw at the seam that needs this prompt.',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 3. The declaration must match the body, in both directions — prompt-library enforces this
|
|
80
|
+
// at render time, and catching it here names the generator instead of the seam.
|
|
81
|
+
const undeclared = missingFrom(genUsed, genDeclared);
|
|
82
|
+
if (undeclared.length) {
|
|
83
|
+
return { ok: false, reason: `body uses undeclared placeholder(s): ${undeclared.join(', ')}` };
|
|
84
|
+
}
|
|
85
|
+
const orphan = missingFrom(genDeclared, genUsed);
|
|
86
|
+
if (orphan.length) {
|
|
87
|
+
return { ok: false, reason: `declares placeholder(s) it never uses: ${orphan.join(', ')}` };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 4. THE OUTPUT CONTRACT MUST SURVIVE GENERATION.
|
|
91
|
+
//
|
|
92
|
+
// A generated prompt may specialise anything about HOW an agent works — this project's
|
|
93
|
+
// codelines, its stack, its tools. It may not change what an ANSWER looks like, because the
|
|
94
|
+
// consumer that reads the answer was not regenerated with it.
|
|
95
|
+
//
|
|
96
|
+
// Checked because two real generations shipped without it. prompt-review lost
|
|
97
|
+
// <PROMPT_REVIEW>{"falseClaims": []}</PROMPT_REVIEW> while lib/prompt-review.js parses exactly
|
|
98
|
+
// that tag, so the reviewer's answer could never be read. skill-assessment-prephase lost every
|
|
99
|
+
// output key and came back half the length. Both are the run-8 shape: the agent obeys its
|
|
100
|
+
// prompt and the engine reads something else, and nothing can say which side is wrong.
|
|
101
|
+
//
|
|
102
|
+
// ADDITIONS ARE FINE. Only a LOSS is refused: the consumer still reads what the template
|
|
103
|
+
// promised, so dropping it is what breaks.
|
|
104
|
+
const tplBody = String((template && template.body) || Object.values((template && template.bodies) || {}).join('\n') || '');
|
|
105
|
+
const genBody = generated.body;
|
|
106
|
+
|
|
107
|
+
// Response tags — <PROMPT_REVIEW>, <SPEC_AGENT> — are how a consumer finds the answer at all.
|
|
108
|
+
// A RESPONSE TAG IS PAIRED. A VALUE PLACEHOLDER IS NOT.
|
|
109
|
+
//
|
|
110
|
+
// This took every <UPPERCASE> in the body. skill-assessment-prephase's instructions contain an
|
|
111
|
+
// example JSONL record — {"timestamp":"<ISO8601>", ...} — where <ISO8601> is a TYPE placeholder
|
|
112
|
+
// inside an illustration, not a marker any consumer looks for. The guard demanded the generated
|
|
113
|
+
// prompt reproduce it verbatim, the generator legitimately rephrased the example, and mock3
|
|
114
|
+
// run 9 exhausted all three attempts and failed the step after 29 prompts had succeeded.
|
|
115
|
+
//
|
|
116
|
+
// A tag a consumer parses always wraps something: <PROMPT_REVIEW>…</PROMPT_REVIEW>. Across all
|
|
117
|
+
// 117 templates that separates them exactly — 4 paired (DISCOVERY_VOCABULARY, MODEL_REVIEW,
|
|
118
|
+
// PROMPT_REVIEW, SPEC_REVIEW), 1 unpaired (ISO8601). So pairing is the discriminator, and it is
|
|
119
|
+
// derived from the template rather than an exception list that would go stale.
|
|
120
|
+
const tplTags = [...new Set(tplBody.match(/<[A-Z][A-Z0-9_]+>/g) || [])]
|
|
121
|
+
.filter((t) => tplBody.includes(`</${t.slice(1, -1)}>`))
|
|
122
|
+
.flatMap((t) => [t, `</${t.slice(1, -1)}>`]);
|
|
123
|
+
const lostTags = tplTags.filter((t) => !genBody.includes(t));
|
|
124
|
+
if (lostTags.length) {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
reason: `the generated prompt dropped the output tag(s) the template states: ${lostTags.join(', ')}. `
|
|
128
|
+
+ 'The consumer looks for exactly that marker, so the answer would be unreadable however '
|
|
129
|
+
+ 'good it is.',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Field names the template states as part of its response shape.
|
|
134
|
+
const tplKeys = outputFieldsIn(tplBody);
|
|
135
|
+
const lostKeys = tplKeys.filter((k) => !genBody.includes(k));
|
|
136
|
+
if (lostKeys.length) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
reason: `the generated prompt dropped output field(s) the template states: ${lostKeys.join(', ')}. `
|
|
140
|
+
+ 'The consumer still reads them, so the agent would answer a shape nothing accepts.',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { ok: true, reason: '' };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* THE RESPONSE-SHAPE FIELD NAMES A TEMPLATE STATES.
|
|
149
|
+
*
|
|
150
|
+
* Defined once and exported, because two places need the same answer: this contract check, which
|
|
151
|
+
* REFUSES a generated prompt that lost a field, and the generator prompt, which must be TOLD the
|
|
152
|
+
* fields so it can keep them. Judging an agent against a list it was never shown is how
|
|
153
|
+
* skill-assessment-prephase came back missing timestamp, phase_id, agent_role, event,
|
|
154
|
+
* skill_category, context and added_by — the generator prompt gave placeholders their own section
|
|
155
|
+
* and an explicit list, and gave output fields one clause in the middle of a sentence.
|
|
156
|
+
*/
|
|
157
|
+
function outputFieldsIn(body) {
|
|
158
|
+
return [...new Set((String(body == null ? '' : body).match(/"([a-zA-Z][a-zA-Z0-9_]*)"\s*:/g) || [])
|
|
159
|
+
.map((m) => m.replace(/[":\s]/g, '')))];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Assemble the document to write as the project authority.
|
|
164
|
+
*
|
|
165
|
+
* derivedFromSha256 is taken over the TEMPLATE, so a later run can tell whether the template
|
|
166
|
+
* has moved on since this copy was generated. Without it, a project prompt generated from a
|
|
167
|
+
* since-corrected template looks indistinguishable from a current one.
|
|
168
|
+
*/
|
|
169
|
+
/** The template digest a GENERATED copy records — one definition, also read when a resume keeps a copy. */
|
|
170
|
+
// THE TEXT, WHEREVER THE TEMPLATE KEEPS IT. Reading `body` alone hashed a template whose text is in `bodies` the same whatever
|
|
171
|
+
// it said, so its project copy was never rebuilt (blocker-discipline v3, 2026-10-21). A `body` template hashes as before.
|
|
172
|
+
function generatedTemplateDigest(template) {
|
|
173
|
+
const text = template.body !== undefined ? { body: template.body } : { body: template.body, bodies: template.bodies };
|
|
174
|
+
return crypto
|
|
175
|
+
.createHash('sha256')
|
|
176
|
+
.update(JSON.stringify({ id: template.id, ...text, placeholders: sorted(template.placeholders) }))
|
|
177
|
+
.digest('hex');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function buildGeneratedDoc(template, generatedBody) {
|
|
181
|
+
const body = String(generatedBody);
|
|
182
|
+
return {
|
|
183
|
+
$why: [
|
|
184
|
+
`Generated from the ${template.id} template by the agent that minted this project's roster.`,
|
|
185
|
+
'The template is never executed; this copy is. Placeholders were checked against the',
|
|
186
|
+
'template before installation — see lib/project-prompt-contract.js.',
|
|
187
|
+
],
|
|
188
|
+
authority: 'project',
|
|
189
|
+
body,
|
|
190
|
+
derivedFromSha256: generatedTemplateDigest(template),
|
|
191
|
+
derivedFromVersion: template.version == null ? null : template.version,
|
|
192
|
+
id: template.id,
|
|
193
|
+
// NO `seams` FIELD. It was a hand-maintained inverse index of something the registry already
|
|
194
|
+
// declares — a seam names the template it runs — and the two copies drifted: run
|
|
195
|
+
// 20260817T211517Z installed a copy claiming seams ["failure-analyst"] where its template says
|
|
196
|
+
// ["impl-failure-analyst"], and the link failed after 37 prompts had provisioned. Worse, that
|
|
197
|
+
// one template serves TWO seams and a single array cannot name both, so the relationship was
|
|
198
|
+
// unrepresentable rather than merely mis-copied.
|
|
199
|
+
//
|
|
200
|
+
// prompt-agent-link now reads the registry directly, so nothing consumes this field. Writing
|
|
201
|
+
// it anyway would only invite the drift back.
|
|
202
|
+
placeholders: placeholdersIn(body),
|
|
203
|
+
// THE DECLARATION TRAVELS WITH THE COPY THAT IS EXECUTED.
|
|
204
|
+
//
|
|
205
|
+
// mayBeEmpty says a placeholder may legitimately render blank — no prior gaps on a first pass,
|
|
206
|
+
// no fix sites before discovery, no forced retry on attempt one. The TEMPLATE declared it and
|
|
207
|
+
// this doc did not carry it, so every generated prompt lost the protection and the renderer
|
|
208
|
+
// refused at runtime on a block that was supposed to be empty.
|
|
209
|
+
//
|
|
210
|
+
// Live 2026-08-27, run 20260827T125654Z: the pipeline reached the specification pass for the
|
|
211
|
+
// first time and died there deterministically —
|
|
212
|
+
// prompt 'spec-agent-openspec' was given EMPTY values for: __DECLARED_FILE_BLOCK__,
|
|
213
|
+
// __FIX_SITE_BLOCK__, __FORCED_RETRY_BLOCK__, __PRIOR_GAPS_BLOCK__, …
|
|
214
|
+
// Three retries changed nothing because no model was ever asked anything, and both lanes
|
|
215
|
+
// halted. guard-vocabulary's template declared two and its generated copy carried none.
|
|
216
|
+
//
|
|
217
|
+
// FILTERED TO WHAT THIS BODY ACTUALLY HAS. A declaration naming a placeholder the generated
|
|
218
|
+
// text does not contain protects nothing and would only mislead the next reader — the same
|
|
219
|
+
// drift that removed the `seams` field above.
|
|
220
|
+
mayBeEmpty: (Array.isArray(template.mayBeEmpty) ? template.mayBeEmpty : [])
|
|
221
|
+
.filter((ph) => body.includes(ph)),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* splitByPlaceholders(body) — prose segments and the slots between them.
|
|
227
|
+
*
|
|
228
|
+
* THE SLOTS ARE DELIMITERS, AND TREATING THEM AS PAYLOAD IS THE DEFECT. prompt-builder asked the
|
|
229
|
+
* model to rewrite a whole template while transcribing every __SLOT__ character-for-character:
|
|
230
|
+
* a writing task and a copying task at once. Models lose literals while rewriting the prose around
|
|
231
|
+
* them — live 2026-09-08, 25 refusals, every one "dropped placeholder(s) the template requires",
|
|
232
|
+
* and runtime-boundary-review lost all six of its slots five attempts running and aborted the run.
|
|
233
|
+
*
|
|
234
|
+
* Split here, and the model is only ever shown, and only ever returns, the prose BETWEEN slots.
|
|
235
|
+
* It cannot drop a placeholder it was never given.
|
|
236
|
+
*
|
|
237
|
+
* INVARIANT: body === segments[0] + slots[0] + segments[1] + ... + segments[n], so segments is
|
|
238
|
+
* always exactly one longer than slots, and every occurrence of a repeated slot is kept in order.
|
|
239
|
+
*/
|
|
240
|
+
function splitByPlaceholders(body) {
|
|
241
|
+
const text = String(body == null ? '' : body);
|
|
242
|
+
const slots = [];
|
|
243
|
+
const segments = [];
|
|
244
|
+
const re = /__[A-Z][A-Z0-9_]*?__/g;
|
|
245
|
+
let last = 0;
|
|
246
|
+
let m = re.exec(text);
|
|
247
|
+
while (m) {
|
|
248
|
+
segments.push(text.slice(last, m.index));
|
|
249
|
+
slots.push(m[0]);
|
|
250
|
+
last = m.index + m[0].length;
|
|
251
|
+
m = re.exec(text);
|
|
252
|
+
}
|
|
253
|
+
segments.push(text.slice(last));
|
|
254
|
+
return { segments, slots };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* assembleFromSegments(segments, slots) — the body the pipeline builds, never the model.
|
|
259
|
+
*
|
|
260
|
+
* The slots go back exactly where they were, in their original order, however the model reworded
|
|
261
|
+
* the prose. Every contract invariant — nothing dropped, nothing invented, declared == used —
|
|
262
|
+
* therefore holds BY CONSTRUCTION rather than by inspection and retry.
|
|
263
|
+
*
|
|
264
|
+
* REFUSES A WRONG COUNT rather than padding. A model that returns the wrong number of segments has
|
|
265
|
+
* misunderstood the task, and quietly filling the gap would install a prompt whose prose no longer
|
|
266
|
+
* lines up with the evidence its slots carry — the same silent-wrongness this whole change exists
|
|
267
|
+
* to remove.
|
|
268
|
+
*/
|
|
269
|
+
function assembleFromSegments(segments, slots) {
|
|
270
|
+
const segs = Array.isArray(segments) ? segments : [];
|
|
271
|
+
const sl = Array.isArray(slots) ? slots : [];
|
|
272
|
+
if (segs.length !== sl.length + 1) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`[project-prompt-contract] expected ${sl.length + 1} segment(s) for ${sl.length} placeholder(s), got ${segs.length}`);
|
|
275
|
+
}
|
|
276
|
+
let out = '';
|
|
277
|
+
for (let i = 0; i < sl.length; i += 1) out += String(segs[i]) + sl[i];
|
|
278
|
+
return out + String(segs[segs.length - 1]);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* extractSegmentsReply(text, expectedCount) — the segments a reply meant to give.
|
|
283
|
+
*
|
|
284
|
+
* MODELS THINK OUT LOUD, and that is not an error worth a paid retry. Live 2026-09-08 with real
|
|
285
|
+
* tokens, replies routinely looked like:
|
|
286
|
+
*
|
|
287
|
+
* {"segments":["","","","","","","",""][0:0]}
|
|
288
|
+
* Let me answer properly.
|
|
289
|
+
* {"segments":["","\n\n---\nRUNTIME BOUNDARY REVIEW: "," — ", ...
|
|
290
|
+
*
|
|
291
|
+
* The first extractor used /\{[\s\S]*\}/ — greedy — so it spanned the abandoned attempt, the
|
|
292
|
+
* prose, and the real answer as one string and failed to parse every time. runtime-boundary-review
|
|
293
|
+
* burned all three attempts on replies whose FINAL object was perfectly good.
|
|
294
|
+
*
|
|
295
|
+
* Scanned with a brace counter that respects strings and escapes, because segment prose legitimately
|
|
296
|
+
* contains braces and quotes. The LAST candidate that parses and carries a segments array wins; when
|
|
297
|
+
* a count is known, a candidate matching it wins over one that does not, so a false start of the
|
|
298
|
+
* wrong length cannot beat the real answer.
|
|
299
|
+
*/
|
|
300
|
+
function extractSegmentsReply(text, expectedCount) {
|
|
301
|
+
const s = String(text == null ? '' : text);
|
|
302
|
+
const found = [];
|
|
303
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
304
|
+
if (s[i] !== '{') continue;
|
|
305
|
+
let depth = 0; let inStr = false; let esc = false;
|
|
306
|
+
for (let j = i; j < s.length; j += 1) {
|
|
307
|
+
const ch = s[j];
|
|
308
|
+
if (esc) { esc = false; continue; }
|
|
309
|
+
if (inStr) {
|
|
310
|
+
if (ch === '\\') esc = true;
|
|
311
|
+
else if (ch === '"') inStr = false;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (ch === '"') { inStr = true; continue; }
|
|
315
|
+
if (ch === '{') depth += 1;
|
|
316
|
+
else if (ch === '}') {
|
|
317
|
+
depth -= 1;
|
|
318
|
+
if (depth === 0) {
|
|
319
|
+
try {
|
|
320
|
+
const o = JSON.parse(s.slice(i, j + 1));
|
|
321
|
+
if (o && Array.isArray(o.segments)) found.push(o.segments);
|
|
322
|
+
} catch { /* not this candidate */ }
|
|
323
|
+
i = j; // continue scanning AFTER this object
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (!found.length) {
|
|
330
|
+
throw new Error('[project-prompt-contract] no {"segments":[...]} object in the reply');
|
|
331
|
+
}
|
|
332
|
+
if (typeof expectedCount === 'number') {
|
|
333
|
+
for (let k = found.length - 1; k >= 0; k -= 1) {
|
|
334
|
+
if (found[k].length === expectedCount) return found[k];
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return found[found.length - 1];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* parseSegmentsReply(text, expectedCount) — the segments a reply meant to give, delimited.
|
|
342
|
+
*
|
|
343
|
+
* WHY NOT JSON. The segments contract asked for the prose between placeholders as a JSON array.
|
|
344
|
+
* Live 2026-09-08, pipeline-tests-45: team-lead-review (17 placeholders, 5,545-char body) burned
|
|
345
|
+
* all five attempts and aborted the run at 7 of 39 prompts. The replies were not truncated -- they
|
|
346
|
+
* ended correctly with "]} -- they simply did not parse:
|
|
347
|
+
*
|
|
348
|
+
* Expected ',' or ']' after array element at position 609
|
|
349
|
+
* ..."worse than no fix.","\n---\nREVIEW TASK: Story ","," — ","\n\nDESCRIPTION:\n"...
|
|
350
|
+
*
|
|
351
|
+
* Segments that ARE punctuation -- ", ", "," -- sit badly beside JSON's own delimiters, and
|
|
352
|
+
* escaping quotes, commas and newlines across 7kB of prose is where it breaks. Another reply
|
|
353
|
+
* showed the model catching itself ("Wait — that response is invalid") and the corrected object
|
|
354
|
+
* failing to parse as well.
|
|
355
|
+
*
|
|
356
|
+
* The INPUT already uses --- SEGMENT n --- markers and the model reads them perfectly. Using the
|
|
357
|
+
* same shape for the OUTPUT removes escaping entirely: a newline is a newline and a segment that
|
|
358
|
+
* is a single comma is a line containing a comma.
|
|
359
|
+
*
|
|
360
|
+
* JSON is still accepted. A model that answers correctly in the harder format should not be
|
|
361
|
+
* refused for it.
|
|
362
|
+
*
|
|
363
|
+
* A FALSE START IS NORMAL. Models correct themselves mid-answer, so the LAST complete set wins.
|
|
364
|
+
*/
|
|
365
|
+
/**
|
|
366
|
+
* THE SEGMENT FORM, WRITTEN ONCE. The generator shows segments in this form and asks for the same
|
|
367
|
+
* form back; the parser below reads it. A second spelling anywhere — the builder's prompt, the
|
|
368
|
+
* rehearsal mock — is a copy that drifts, so both call this.
|
|
369
|
+
*/
|
|
370
|
+
function formatSegments(segments) {
|
|
371
|
+
return segments.map((seg, i) => `--- SEGMENT ${i + 1} ---\n${seg}`).join('\n')
|
|
372
|
+
+ '\n--- END SEGMENTS ---\n';
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function parseSegmentsReply(text, expectedCount) {
|
|
376
|
+
const s = String(text == null ? '' : text);
|
|
377
|
+
|
|
378
|
+
// Delimited form first: split on marker LINES so prose that merely mentions the marker inline
|
|
379
|
+
// cannot be mistaken for one.
|
|
380
|
+
const marker = /^[ \t]*-{2,}[ \t]*SEGMENT[ \t]+(\d+)[ \t]*-{2,}[ \t]*$/gm;
|
|
381
|
+
const hits = [];
|
|
382
|
+
let m = marker.exec(s);
|
|
383
|
+
while (m) { hits.push({ n: Number(m[1]), start: m.index, end: marker.lastIndex }); m = marker.exec(s); }
|
|
384
|
+
if (hits.length) {
|
|
385
|
+
// A reply may contain several attempts. Each time the numbering restarts at 1 a new set begins;
|
|
386
|
+
// the last set is the model's final answer.
|
|
387
|
+
let setStart = 0;
|
|
388
|
+
for (let i = 1; i < hits.length; i += 1) if (hits[i].n === 1) setStart = i;
|
|
389
|
+
const set = hits.slice(setStart);
|
|
390
|
+
const endMarker = /^[ \t]*-{2,}[ \t]*END SEGMENTS[ \t]*-{2,}[ \t]*$/gm;
|
|
391
|
+
endMarker.lastIndex = set[set.length - 1].end;
|
|
392
|
+
const tail = endMarker.exec(s);
|
|
393
|
+
const segs = set.map((h, i) => {
|
|
394
|
+
const from = h.end;
|
|
395
|
+
const to = i + 1 < set.length ? set[i + 1].start : (tail ? tail.index : s.length);
|
|
396
|
+
return s.slice(from, to).replace(/^\n/, '').replace(/\n$/, '');
|
|
397
|
+
});
|
|
398
|
+
if (typeof expectedCount !== 'number' || segs.length === expectedCount) return segs;
|
|
399
|
+
throw new Error(
|
|
400
|
+
`[project-prompt-contract] expected ${expectedCount} segment(s), got ${segs.length}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// JSON fallback, for a reply that got the harder format right.
|
|
404
|
+
const viaJson = (() => { try { return extractSegmentsReply(s, expectedCount); } catch { return null; } })();
|
|
405
|
+
if (viaJson) {
|
|
406
|
+
if (typeof expectedCount !== 'number' || viaJson.length === expectedCount) return viaJson;
|
|
407
|
+
throw new Error(
|
|
408
|
+
`[project-prompt-contract] expected ${expectedCount} segment(s), got ${viaJson.length}`);
|
|
409
|
+
}
|
|
410
|
+
throw new Error('[project-prompt-contract] no --- SEGMENT n --- blocks and no usable JSON in the reply');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
module.exports = { checkGeneratedPrompt, buildGeneratedDoc, generatedTemplateDigest, placeholdersIn, outputFieldsIn,
|
|
414
|
+
splitByPlaceholders, assembleFromSegments, extractSegmentsReply, parseSegmentsReply, formatSegments };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// A PROJECT'S PROMPTS BROUGHT UP TO ITS CURRENT TEMPLATES, WITHOUT A RUN (operator, 2026-10-21: "only generate the ones
|
|
4
|
+
// needed - I do not want to do a full pipeline run to adjust"). Exactly what the Agents phase does for a codeline whose
|
|
5
|
+
// prompt set is no longer complete for the current templates, and nothing else of that phase: per codeline, the
|
|
6
|
+
// codeline's recorded agents and prompts restored (restoreMinted, as codeline_minted_restore does), then the pipeline's
|
|
7
|
+
// own builder (buildProjectPrompts) under its resume rule, keepExisting — a prompt still the copy of its template is kept
|
|
8
|
+
// byte for byte with no call, one whose template moved on is regenerated and reviewed (lib/prompt-build-calls.js, the
|
|
9
|
+
// mint's own generator and reviewer). The builder records the codeline's completion itself.
|
|
10
|
+
//
|
|
11
|
+
// refresh-project-prompts.js --project <project config dir> --prd <run PRD> [--log-dir <dir>] [--stand-in]
|
|
12
|
+
//
|
|
13
|
+
// The codelines are the PRD's outputDirs, each one's id as the builder files it (codelineFromPrd of a one-codeline PRD).
|
|
14
|
+
// --stand-in: no model is called — the generator answers with the template's own text and the reviewer approves — so
|
|
15
|
+
// the rebuild can be rehearsed on a copy of the project at £0 and its decisions read: which prompts it would regenerate.
|
|
16
|
+
// Run with the project's env loaded (lib/env-file.sh load_project_env), as the mint step is.
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
const a = {}; const argv = process.argv.slice(2);
|
|
21
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
22
|
+
if (argv[i] === '--stand-in') a['stand-in'] = true; else { a[argv[i].replace(/^--/, '')] = argv[i + 1]; i += 1; }
|
|
23
|
+
}
|
|
24
|
+
const SCRIPTS = process.env.SCRIPT_DIR || __dirname;
|
|
25
|
+
const ENGINE = path.resolve(SCRIPTS, '..', '..');
|
|
26
|
+
const lib = (f) => require(path.join(SCRIPTS, 'lib', f));
|
|
27
|
+
|
|
28
|
+
function standInCalls(templatesDir) {
|
|
29
|
+
const { splitByPlaceholders } = lib('project-prompt-contract.js');
|
|
30
|
+
const { templateBodyText } = lib('project-prompt-builder.js');
|
|
31
|
+
return {
|
|
32
|
+
runText: async (_prompt, meta) => {
|
|
33
|
+
const tpl = JSON.parse(fs.readFileSync(path.join(templatesDir, `${meta.id}.json`), 'utf8'));
|
|
34
|
+
return splitByPlaceholders(templateBodyText(tpl)).segments.map((s, i) => `--- SEGMENT ${i + 1} ---\n${s}`).join('\n');
|
|
35
|
+
},
|
|
36
|
+
reviewPrompt: async () => ({ ok: true, reviewed: true }),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function main() {
|
|
41
|
+
if (!a.project || !a.prd) throw new Error('usage: refresh-project-prompts.js --project <dir> --prd <run prd.json> [--log-dir <dir>] [--stand-in]');
|
|
42
|
+
const projectConfigDir = path.resolve(a.project);
|
|
43
|
+
const logDir = path.resolve(a['log-dir'] || path.join(projectConfigDir, '..', '..', 'logs', 'refresh-project-prompts'));
|
|
44
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
45
|
+
const builder = lib('project-prompt-builder.js');
|
|
46
|
+
const promptsLib = lib('prompt-library.js');
|
|
47
|
+
const templatesDir = path.join(ENGINE, promptsLib.TEMPLATE_DIR_REL);
|
|
48
|
+
const prd = JSON.parse(fs.readFileSync(a.prd, 'utf8'));
|
|
49
|
+
const stories = prd.stories || [];
|
|
50
|
+
const dirs = ((prd.project && prd.project.outputDirs) || []).filter((o) => o && o.path);
|
|
51
|
+
if (!dirs.length) throw new Error(`${a.prd} names no codeline (project.outputDirs)`);
|
|
52
|
+
const report = [];
|
|
53
|
+
for (const o of dirs) {
|
|
54
|
+
const slice = path.join(logDir, `prd.${path.basename(o.path)}.json`);
|
|
55
|
+
fs.writeFileSync(slice, JSON.stringify({ ...prd, project: { ...prd.project, outputDirs: [o], outputDir: o.path } }));
|
|
56
|
+
const codeline = builder.codelineFromPrd(slice);
|
|
57
|
+
process.env.EPAM_CODELINE_ID = codeline;
|
|
58
|
+
process.env.EPAM_PROJECT_CONFIG_DIR = projectConfigDir;
|
|
59
|
+
process.env.PRD_FILE = slice;
|
|
60
|
+
const restored = builder.restoreMinted({ projectConfigDir, codeline });
|
|
61
|
+
const roles = lib('project-roster.js').mintedFromRegistries(projectConfigDir);
|
|
62
|
+
const codelines = [{ name: o.codeline, path: o.path }];
|
|
63
|
+
const codelineContext = lib('codeline-context.js').buildCodelineContext({
|
|
64
|
+
codelines, factsFile: path.join(projectConfigDir, 'codeline-facts.json'), surveyed: [] });
|
|
65
|
+
const calls = a['stand-in'] ? standInCalls(templatesDir) : (() => {
|
|
66
|
+
const spec = require(path.join(SCRIPTS, 'spec-mode-runner.js'));
|
|
67
|
+
return lib('prompt-build-calls.js').promptBuildCalls({
|
|
68
|
+
engineRoot: ENGINE, logDir, runClaude: spec.runClaude,
|
|
69
|
+
promptExec: spec.resolvePromptExec(process.env.AI_RUNNER_CMD || path.join(SCRIPTS, 'ai-run.sh')),
|
|
70
|
+
projectConfigDir, codelineContext, stories, mintedDetail: roles,
|
|
71
|
+
toolGrant: lib('agent-tools.js').readOnlyToolGrant(codelines.map((c) => c.path)),
|
|
72
|
+
reviewEnabled: String(process.env.EPAM_PROMPT_REVIEW_ENABLED ?? '1') !== '0',
|
|
73
|
+
});
|
|
74
|
+
})();
|
|
75
|
+
const lines = [];
|
|
76
|
+
const built = await builder.buildProjectPrompts({
|
|
77
|
+
keepExisting: true, templatesDir,
|
|
78
|
+
bootstrapFile: path.join(ENGINE, 'orchestrations', 'prompts', 'bootstrap.json'),
|
|
79
|
+
registryFile: path.join(ENGINE, 'orchestrations', 'agents', 'invocation-profiles.json'),
|
|
80
|
+
projectConfigDir, mode: process.env.EPAM_PROMPT_PROVISION_MODE,
|
|
81
|
+
attempts: Number(process.env.EPAM_PROMPT_ATTEMPTS || 5),
|
|
82
|
+
projectContext: [`Project: ${path.basename(projectConfigDir)}`,
|
|
83
|
+
`Tickets in scope: ${stories.map((t) => `${t.jiraKey || t.id}: ${t.title || ''}`).join(' | ')}`].join('\n'),
|
|
84
|
+
codelineContext,
|
|
85
|
+
mintedRoles: roles.length ? roles.map((m) => `- ${m.name} [${m.kind}] — ${m.rationale || ''}`).join('\n') : '(none minted this run)',
|
|
86
|
+
...calls,
|
|
87
|
+
log: (m) => { lines.push(m); process.stderr.write(`[${codeline}] ${m}\n`); },
|
|
88
|
+
});
|
|
89
|
+
const rebuilt = lines.filter((l) => /template changed since this copy was made/.test(l)).map((l) => (l.match(/\] (\S+?):/) || [])[1]);
|
|
90
|
+
report.push({ codeline, restored, rebuilt, kept: built.generated.length + built.copied.length - rebuilt.length });
|
|
91
|
+
}
|
|
92
|
+
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { standInCalls };
|
|
96
|
+
if (require.main === module) main().catch((e) => { process.stderr.write(`[refresh-project-prompts] FAILED: ${(e && e.stack) || e}\n`); process.exit(1); });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amsd-pipeline-shared",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.13",
|
|
4
4
|
"description": "The AMSD pipeline's shared components, one source for the bash pipeline and its Claude workflows: the model-call gateway (llm-handler.sh — retry, ladder, self-heal, cost) and the libraries it runs.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"bin": {
|