amsd-pipeline-shared 0.0.0-stage → 0.2.0
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 +60 -2
- package/bin/amsd-pipeline-shared.js +96 -0
- package/docs/CONSUMER-REVIEW-B56.md +66 -0
- package/docs/gateway-baseline-2.0.80.md5 +37 -0
- package/docs/gateway-files.txt +38 -0
- package/orchestrations/scripts/agent-attempt-analyst.sh +287 -0
- package/orchestrations/scripts/lib/agent-contract.js +84 -0
- package/orchestrations/scripts/lib/agent-ladder.sh +182 -0
- package/orchestrations/scripts/lib/agent-messages.js +92 -0
- package/orchestrations/scripts/lib/agent-si.js +81 -0
- package/orchestrations/scripts/lib/constraint-compiler.js +109 -0
- package/orchestrations/scripts/lib/constraint-sanity.js +109 -0
- package/orchestrations/scripts/lib/content-retry.js +260 -0
- package/orchestrations/scripts/lib/cost-emitter.js +669 -0
- package/orchestrations/scripts/lib/cost-record.sh +127 -0
- package/orchestrations/scripts/lib/engine-prompt.js +393 -0
- package/orchestrations/scripts/lib/env-file.sh +149 -0
- package/orchestrations/scripts/lib/evidence-windows.js +41 -0
- package/orchestrations/scripts/lib/failure-signature.js +101 -0
- package/orchestrations/scripts/lib/handlers/emit-cost.js +50 -0
- package/orchestrations/scripts/lib/jq-vals.sh +111 -0
- package/orchestrations/scripts/lib/kb-apply.sh +169 -0
- package/orchestrations/scripts/lib/kb-arbitration.js +138 -0
- package/orchestrations/scripts/lib/kb-cli.js +219 -0
- package/orchestrations/scripts/lib/kb-store.js +249 -0
- package/orchestrations/scripts/lib/kb-synthesizer.js +253 -0
- package/orchestrations/scripts/lib/langfuse-emit.js +369 -0
- package/orchestrations/scripts/lib/llm-settings-resolve.js +342 -0
- package/orchestrations/scripts/lib/model-ladder.sh +1891 -0
- package/orchestrations/scripts/lib/model-ladders.sh +236 -0
- package/orchestrations/scripts/lib/pipeline-tools.js +60 -0
- package/orchestrations/scripts/lib/relative-paths.js +57 -0
- package/orchestrations/scripts/lib/render-prompt-section.js +100 -0
- package/orchestrations/scripts/lib/seam-invocation.js +825 -0
- package/orchestrations/scripts/lib/seam-ladder.sh +171 -0
- package/orchestrations/scripts/lib/self-heal.js +174 -0
- package/orchestrations/scripts/lib/stage-coverage-gate.sh +221 -0
- package/orchestrations/scripts/lib/tool-timeouts.js +46 -0
- package/orchestrations/scripts/lib/transcript-tool-calls.js +173 -0
- package/orchestrations/scripts/llm-handler.sh +1102 -0
- package/orchestrations/scripts/spec-mode-runner.js +11229 -0
- package/orchestrations/scripts/team-lead-review.sh +1140 -0
- package/orchestrations/scripts/update-monitor.sh +302 -0
- package/package.json +21 -4
package/README.md
CHANGED
|
@@ -1,3 +1,61 @@
|
|
|
1
|
-
#
|
|
1
|
+
# amsd-pipeline-shared
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The AMSD pipeline's shared components: one source of code for the bash pipeline (`amsd-pipeline`) and its Claude
|
|
4
|
+
workflows (`amsd-pipeline-workflow`). A fix lands here, once, and both run the same bytes.
|
|
5
|
+
|
|
6
|
+
## What is in it
|
|
7
|
+
|
|
8
|
+
| Component | Files | What it does |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| The model-call gateway | `orchestrations/scripts/llm-handler.sh` and the 34 libraries it runs (`docs/gateway-files.txt`) | Every model call: retry up to `EPAM_CALL_MAX_ATTEMPTS`, the agent's ladder (next rung on a failure), the previous-refusal block, the KB episode for self-heal, cost and Langfuse |
|
|
11
|
+
| The Writer's reviewer | `orchestrations/scripts/team-lead-review.sh` | A failed review call is no verdict — retried up its ladder, then *review incomplete*, never approved |
|
|
12
|
+
| The Spec runner | `orchestrations/scripts/spec-mode-runner.js` | A failed mint re-proposal keeps the accepted roles and says so |
|
|
13
|
+
| The KB synthesiser | `orchestrations/scripts/lib/kb-synthesizer.js` | Runs on its own seam's model |
|
|
14
|
+
| Agent-to-agent messages | `orchestrations/scripts/lib/agent-messages.js` | send / receive / list / route on the pipeline's own bus format (`logs/messages/`, `agent-messages.jsonl`); "router" goes to the stage's router |
|
|
15
|
+
|
|
16
|
+
Every file sits at the path it has in a pipeline install. The first commit is the 35 gateway files exactly as released
|
|
17
|
+
in amsd-pipeline 2.0.80 (`docs/gateway-baseline-2.0.80.md5`); every change since is its own commit with its tests.
|
|
18
|
+
`docs/CONSUMER-REVIEW-B56.md` reads every caller of the gateway before and after the fix.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
After the pipeline, into the same install. The pipeline and its installer are not changed:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx amsd-pipeline@2.0.80 --dest <install> --ref v2.0.80 --no-docker
|
|
26
|
+
npx amsd-pipeline-shared@<version> install --into <install>
|
|
27
|
+
npx amsd-pipeline-shared@<version> verify --into <install> # every file is this package's
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`install` replaces a file only when the install holds the released 2.0.80 copy, or a copy an earlier version of this
|
|
31
|
+
package laid down (an upgrade). Anything else — a local edit, another release — is refused before a byte is written,
|
|
32
|
+
with the list. The replaced copies are kept in `<install>/.amsd-pipeline-shared/replaced/`, and
|
|
33
|
+
`amsd-pipeline-shared revert --into <install>` puts the released bytes back exactly.
|
|
34
|
+
|
|
35
|
+
Needs: Node 20+, bash, jq.
|
|
36
|
+
|
|
37
|
+
## Messages from the shell
|
|
38
|
+
|
|
39
|
+
For the bash pipeline (backlog B55), one JSON line out:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
amsd-pipeline-shared messages send --dir <logs/messages> --from <agent> --to <agent|router> --router <agent> --type info --subject S --text T
|
|
43
|
+
amsd-pipeline-shared messages receive --dir <logs/messages> --agent <agent> [--mark-read yes]
|
|
44
|
+
amsd-pipeline-shared messages list --dir <logs/messages>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The pipeline's own `receive-messages.sh` and `list-messages.sh` read what this writes.
|
|
48
|
+
|
|
49
|
+
## Tests
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npm test # node --test, one file at a time; against a pipeline install (AMSD_INSTALL, AMSD_PLAIN_INSTALL)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The gateway, the reviewer, the mint re-proposal and the synthesiser each have a test written red first and
|
|
56
|
+
mutation-checked (md5 before and after). The install is tested on a copy of a 2.0.80 install: exactly the package's
|
|
57
|
+
files change, an edited file is refused, an upgrade replaces only its own copies, revert gives back the release.
|
|
58
|
+
|
|
59
|
+
## Version
|
|
60
|
+
|
|
61
|
+
`amsd-pipeline-shared version`. Released with a git tag `v<version>` and the npm publish of the same version.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// THE SHARED COMPONENT INTO A PIPELINE INSTALL — without changing the pipeline's installer (operator, 2026-10-18: no bash
|
|
4
|
+
// pipeline change outside the shared component). After `npx amsd-pipeline@<v> --dest <install>`:
|
|
5
|
+
// amsd-pipeline-shared install --into <install> lay this package's files into <install>/orchestrations
|
|
6
|
+
// amsd-pipeline-shared verify --into <install> every file is this package's (exit 1 and the list when not)
|
|
7
|
+
// amsd-pipeline-shared revert --into <install> put back the files this package replaced
|
|
8
|
+
// amsd-pipeline-shared messages send|receive|list --dir <bus> … the agent-to-agent bus (lib/agent-messages.js)
|
|
9
|
+
// amsd-pipeline-shared version
|
|
10
|
+
// A file is replaced only when the install holds the released copy this package was built from (docs/gateway-baseline-
|
|
11
|
+
// 2.0.80.md5) or this package's own copy (a re-install); anything else is refused, file by file, and nothing is written.
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
|
|
16
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
17
|
+
const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
|
18
|
+
const FILES = fs.readFileSync(path.join(ROOT, 'docs', 'gateway-files.txt'), 'utf8').trim().split('\n').filter(Boolean);
|
|
19
|
+
const BASE = Object.fromEntries(fs.readFileSync(path.join(ROOT, 'docs', 'gateway-baseline-2.0.80.md5'), 'utf8').trim().split('\n')
|
|
20
|
+
.map((l) => l.trim().split(/\s+/)).map(([sum, f]) => [f, sum]));
|
|
21
|
+
const md5 = (f) => (fs.existsSync(f) ? crypto.createHash('md5').update(fs.readFileSync(f)).digest('hex') : null);
|
|
22
|
+
const fail = (m, code = 1) => { process.stderr.write(`amsd-pipeline-shared: ${m}\n`); process.exit(code); };
|
|
23
|
+
|
|
24
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
25
|
+
if (cmd === 'version') { process.stdout.write(`${PKG.version}\n`); process.exit(0); }
|
|
26
|
+
// THE MESSAGE BUS FOR SHELL CALLERS (the bash pipeline's adoption, backlog B55): one JSON line out
|
|
27
|
+
if (cmd === 'messages') {
|
|
28
|
+
const m = require(path.join(ROOT, 'orchestrations', 'scripts', 'lib', 'agent-messages.js'));
|
|
29
|
+
const [sub, ...o] = rest; const opt = {}; for (let i = 0; i < o.length; i += 2) opt[o[i].replace(/^--/, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = o[i + 1];
|
|
30
|
+
try {
|
|
31
|
+
const out = sub === 'send' ? m.send({ ...opt, data: opt.data ? JSON.parse(opt.data) : {} })
|
|
32
|
+
: sub === 'receive' ? m.receive({ ...opt, unread: opt.unread !== 'no', markRead: opt.markRead === 'yes' })
|
|
33
|
+
: sub === 'list' ? m.list(opt) : (() => { throw new Error('messages send|receive|list --dir <bus> …'); })();
|
|
34
|
+
process.stdout.write(`${JSON.stringify(out)}\n`); process.exit(0);
|
|
35
|
+
} catch (e) { fail(String(e && e.message)); }
|
|
36
|
+
}
|
|
37
|
+
const at = rest.indexOf('--into');
|
|
38
|
+
if (!['install', 'verify', 'revert'].includes(cmd) || at < 0 || !rest[at + 1]) fail('usage: amsd-pipeline-shared install|verify|revert --into <pipeline install> | version', 64);
|
|
39
|
+
const INSTALL = path.resolve(rest[at + 1]);
|
|
40
|
+
const ORCH = path.join(INSTALL, 'orchestrations');
|
|
41
|
+
if (!fs.existsSync(path.join(ORCH, 'scripts'))) fail(`${INSTALL} is not a pipeline install (no orchestrations/scripts)`);
|
|
42
|
+
const STATE = path.join(INSTALL, '.amsd-pipeline-shared');
|
|
43
|
+
const BACKUP = path.join(STATE, 'replaced');
|
|
44
|
+
|
|
45
|
+
if (cmd === 'verify') {
|
|
46
|
+
const off = FILES.filter((f) => md5(path.join(ORCH, f)) !== md5(path.join(ROOT, 'orchestrations', f)));
|
|
47
|
+
if (off.length) fail(`${off.length} file(s) are not this package's (${PKG.version}):\n ${off.join('\n ')}`);
|
|
48
|
+
process.stdout.write(`amsd-pipeline-shared ${PKG.version}: all ${FILES.length} file(s) in ${ORCH} are this package's\n`);
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (cmd === 'revert') {
|
|
53
|
+
if (!fs.existsSync(path.join(STATE, 'installed.json'))) fail(`nothing to revert: no amsd-pipeline-shared install recorded in ${STATE}`);
|
|
54
|
+
for (const f of FILES) {
|
|
55
|
+
const b = path.join(BACKUP, f);
|
|
56
|
+
if (fs.existsSync(b)) fs.copyFileSync(b, path.join(ORCH, f));
|
|
57
|
+
else if (!BASE[f]) fs.rmSync(path.join(ORCH, f), { force: true }); // this package's own file: the release has none
|
|
58
|
+
}
|
|
59
|
+
const back = FILES.filter((f) => (BASE[f] ? md5(path.join(ORCH, f)) !== BASE[f] : fs.existsSync(path.join(ORCH, f))));
|
|
60
|
+
if (back.length) fail(`revert incomplete — not the released copy:\n ${back.join('\n ')}`);
|
|
61
|
+
fs.rmSync(STATE, { recursive: true, force: true });
|
|
62
|
+
process.stdout.write(`amsd-pipeline-shared: reverted ${FILES.length} file(s) to the released copies\n`);
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// install: every target must be the released copy or this package's — checked for all files before any is written
|
|
67
|
+
// an earlier version of this package laid down: its copies are replaceable too (an upgrade), never anyone else's
|
|
68
|
+
let PREV = {}; try { PREV = JSON.parse(fs.readFileSync(path.join(STATE, 'installed.json'), 'utf8')).files || {}; } catch { PREV = {}; }
|
|
69
|
+
const plan = FILES.map((f) => {
|
|
70
|
+
const now = md5(path.join(ORCH, f)); const ours = md5(path.join(ROOT, 'orchestrations', f));
|
|
71
|
+
if (now && now !== ours && PREV[f] === now) return { f, now, ours, state: 'previous' };
|
|
72
|
+
// a file the release never had (this package's own, e.g. lib/agent-messages.js): installed where absent
|
|
73
|
+
if (!BASE[f]) return { f, now, ours, state: now === ours ? 'current' : (now === null ? 'new' : 'other') };
|
|
74
|
+
return { f, now, ours, state: now === ours ? 'current' : (now === BASE[f] ? 'released' : 'other') };
|
|
75
|
+
});
|
|
76
|
+
const other = plan.filter((p) => p.state === 'other');
|
|
77
|
+
if (other.length) {
|
|
78
|
+
fail(`refused — ${other.length} file(s) in the install are neither the released copy (2.0.80) nor this package's; nothing was written:\n `
|
|
79
|
+
+ other.map((p) => `${p.f} (md5 ${p.now || 'absent'})`).join('\n '));
|
|
80
|
+
}
|
|
81
|
+
for (const p of plan.filter((x) => x.state === 'released')) {
|
|
82
|
+
const b = path.join(BACKUP, p.f); fs.mkdirSync(path.dirname(b), { recursive: true });
|
|
83
|
+
if (!fs.existsSync(b)) fs.copyFileSync(path.join(ORCH, p.f), b);
|
|
84
|
+
}
|
|
85
|
+
for (const p of plan.filter((x) => ['released', 'new', 'previous'].includes(x.state))) {
|
|
86
|
+
const dst = path.join(ORCH, p.f);
|
|
87
|
+
const mode = p.state === 'new' ? fs.statSync(path.join(ROOT, 'orchestrations', p.f)).mode : fs.statSync(dst).mode;
|
|
88
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
89
|
+
fs.copyFileSync(path.join(ROOT, 'orchestrations', p.f), dst);
|
|
90
|
+
fs.chmodSync(dst, mode);
|
|
91
|
+
}
|
|
92
|
+
fs.mkdirSync(STATE, { recursive: true });
|
|
93
|
+
fs.writeFileSync(path.join(STATE, 'installed.json'), `${JSON.stringify({ package: PKG.name, version: PKG.version,
|
|
94
|
+
files: Object.fromEntries(FILES.map((f) => [f, md5(path.join(ROOT, 'orchestrations', f))])) }, null, 2)}\n`);
|
|
95
|
+
const n = plan.filter((x) => ['released', 'new', 'previous'].includes(x.state)).length;
|
|
96
|
+
process.stdout.write(`amsd-pipeline-shared ${PKG.version} → ${ORCH}: ${n} file(s) replaced, ${FILES.length - n} already current\n`);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# B56 consumer review — who receives a real failure instead of "success, empty answer"
|
|
2
|
+
|
|
3
|
+
The fix (`llm-handler.sh`, claude and codemie-claude arms): a call whose CLI fails is retried up to
|
|
4
|
+
`EPAM_CALL_MAX_ATTEMPTS` (3) with the ladder consulted, the previous-refusal block added and a KB episode recorded; if
|
|
5
|
+
every attempt fails, the hub exits 1. Before the fix every failed call exited 0 with an empty answer.
|
|
6
|
+
|
|
7
|
+
Read by hand against amsd-pipeline v2.0.80 (`git show v2.0.80:…`), 2026-10-18. Every caller of `ai-run.sh` /
|
|
8
|
+
`llm-handler.sh` / `AI_RUNNER_CMD` in `orchestrations/scripts` (tests and tools excluded).
|
|
9
|
+
|
|
10
|
+
## Same outcome as today, after the hub's retries (a failure handled as an empty answer)
|
|
11
|
+
| Caller | How it handles a non-zero exit |
|
|
12
|
+
|---|---|
|
|
13
|
+
| spec-mode-runner.js `runClaude` → `runAgentForJson` | rejects; each call site below catches |
|
|
14
|
+
| — spec coordinator (:1825), coordinator review (:2524), LLM decisions (:2704) | catch → default / no review, logged |
|
|
15
|
+
| — ticket links (:7614), spec agent (:7890), speckit review (:8223) | catch → "could not be read" / failed attempt |
|
|
16
|
+
| — mint proposals (:4339), survey review (:4874) | inside `retryUntilParsedAsync`: "the call itself failed" → next attempt |
|
|
17
|
+
| — roster review (:5476 via mint-agents-step :917), project roster review (:5249 via roster-seams :174) | catch → `review_failed` |
|
|
18
|
+
| lib/llm-call.js (discovery, ac-gate, cpa) | already rejects on non-zero exit AND on empty — unchanged |
|
|
19
|
+
| lib/kb-synthesizer.js | already handles `r.status !== 0` |
|
|
20
|
+
| lib/cli-commands.sh :261, story-attempt.sh :752 / :791, review-cycle.sh :43, vc-coverage-check.sh :158, brownfield-repro-test-writer.sh :867, retry-extension.sh :199, run-agent-orchestration.sh :4052–4132 | `\|\| echo ""` / `\|\| true` / `if … then … else` — the empty-answer path |
|
|
21
|
+
| prd-change-review.sh :216, :452 | `\|\| echo ""` / its else branch |
|
|
22
|
+
|
|
23
|
+
## Better — the failure is now classified as a failure
|
|
24
|
+
| Caller | Before | After |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| failure-healing.sh :1278 (failure analyst) | call "ok", answer unparseable | its call-failed branch; its own analyst retry/ladder loop runs |
|
|
27
|
+
| model-ladder.sh :1378 (ladder coordinator) | call "ok", no JSON | its call-failed branch |
|
|
28
|
+
| prd-change-review.sh :86 (PRD-change reviewer) | empty reply parsed | `verdict: fail — the change was NOT reviewed` |
|
|
29
|
+
| post-impl-tc-writer.sh :234 | "no criteria extracted" | "Agent failed (exit 1)" (TC_EXIT is passed on) |
|
|
30
|
+
| orch-prompt.sh :133 (the Writer, via claude.sh) | a crashed writer call looked like a finished attempt | `_rc` = 1 → the Writer's failure path (analyst, ladder) |
|
|
31
|
+
| agent-attempt-analyst.sh :240 | — | already treats non-zero as `self_heal_failed` (exit 2) |
|
|
32
|
+
| surveyEstate (:3724) | `not_investigated` | `failed` with the reason (catch) |
|
|
33
|
+
|
|
34
|
+
## Changes that need a decision
|
|
35
|
+
1. **Mint re-proposal (spec-mode-runner.js :4414) has no catch, nor has its caller (mint-agents-step.js :843 → the
|
|
36
|
+
top-level catch, exit 1).** Before: an empty re-proposal ended the loop and the mint kept the roles already accepted.
|
|
37
|
+
After: three failed attempts there fail the mint step.
|
|
38
|
+
**Fixed in this package** (operator: "yes move both in"): spec-mode-runner.js joined the component; the call is caught,
|
|
39
|
+
the loop ends as today and says so (test/mint-reproposal.test.js).
|
|
40
|
+
2. **The reviewer (team-lead-review.sh :326 / :877) ignores the call's exit status and reads `2>&1`.** A failed call's
|
|
41
|
+
output is the hub's error text, which passes the "no verdict" check and is parsed as the review → the fallback
|
|
42
|
+
`changes_requested` with a "could not parse" blocker. Fail-safe (never approves) — before and after the fix — but the
|
|
43
|
+
Writer is sent to fix an issue that does not exist instead of "review incomplete — reviewer unavailable".
|
|
44
|
+
**Fixed in this package**: team-lead-review.sh joined the component; a failed call is no verdict — retried up the
|
|
45
|
+
reviewer's ladder, then review-incomplete (test/reviewer.test.js).
|
|
46
|
+
|
|
47
|
+
## Cost
|
|
48
|
+
A persistent failure now costs up to 3 hub attempts per caller attempt (callers with their own retry loop multiply:
|
|
49
|
+
content-retry ×3, the failure analyst's ladder loop, the roster review ×2). Calls that succeed cost the same as before.
|
|
50
|
+
|
|
51
|
+
## Found while proving the analyst in the real project environment (2026-10-18)
|
|
52
|
+
- The analyst works there: `self-heal.js` → `agent-attempt-analyst.sh` diagnosed a planted no-json failure on
|
|
53
|
+
claude-sonnet-5 and marked it analysed. (It declined only in a stripped environment with no project config.)
|
|
54
|
+
- One analysis = 4 model calls by design: the analyst + its plan pass, then `kb-synthesizer` + its plan pass (and 12 free
|
|
55
|
+
`--help` capability probes).
|
|
56
|
+
- **`kb-synthesizer` runs with no model resolved** (AI_MODEL unset) — it falls back to the CLI's default model instead of
|
|
57
|
+
its seam's. An "appropriate LLM settings" defect. **Fixed in this package**: with no model given it runs on its seam's
|
|
58
|
+
start rung (seamInvocationEnv; claude-haiku-4-5 on the claude set); a named model still wins (test/kb-synthesizer-model.test.js).
|
|
59
|
+
- **On a fresh install the synthesiser cannot render its prompt**: `projects/<p>/prompts/kb-enforcement-synthesis.json`
|
|
60
|
+
is a minted project prompt, absent on wf-280 ("project-authority prompt missing … Mint it"). To check against how the
|
|
61
|
+
codeline cache supplies prompts before the next live leg; the self-heal listener must report a synthesis failure.
|
|
62
|
+
- **The ladder position `mid` resolves to the middle tier, `high`** (seam-invocation.js resolveTierPosition: base/mid/top
|
|
63
|
+
over the declared order medium/high/highest), whose chain is haiku → sonnet-5 → opus-5-5. config/llm-defaults.claude.json
|
|
64
|
+
says of `medium`: "A `mid` seam never reaches Opus: 14 seams resolve here". 12 seams declare `mid` (ticket-links among
|
|
65
|
+
them) and, by the code, can reach Opus. The pipeline's code and its declaration disagree — for the operator to rule;
|
|
66
|
+
the gateway follows the code (agent_ladder_model), as the hub does.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
b2470a21b73b03abb95713f05acaa827 scripts/agent-attempt-analyst.sh
|
|
2
|
+
c45cda62ef52ee95c8de6acead3408f8 scripts/lib/agent-contract.js
|
|
3
|
+
f881c989bd12e76e13e017ff2d984041 scripts/lib/agent-ladder.sh
|
|
4
|
+
c2dfd2381db0242fdbc487a948fd7feb scripts/lib/agent-si.js
|
|
5
|
+
488870d661550b057077104147b54004 scripts/lib/constraint-compiler.js
|
|
6
|
+
639d21b8d2adb82d2ed98ceffc3c2175 scripts/lib/constraint-sanity.js
|
|
7
|
+
f31ac2a712f0e1f288606b0fdbb7d001 scripts/lib/content-retry.js
|
|
8
|
+
e24bfedd32af37785a42fa8c168ae52a scripts/lib/cost-emitter.js
|
|
9
|
+
d7fb52664b0f0528b6142bf0574c1f49 scripts/lib/cost-record.sh
|
|
10
|
+
f20fe173b49d25008e206f82ace56619 scripts/lib/engine-prompt.js
|
|
11
|
+
773a5b134875d61f988b15934a9d5fec scripts/lib/env-file.sh
|
|
12
|
+
66f728a2d4f3f4dac44bfc08775cc485 scripts/lib/evidence-windows.js
|
|
13
|
+
e80316d37f028cdf83f3a8fa43913509 scripts/lib/failure-signature.js
|
|
14
|
+
1ada64b928930ab3937c4b26269fb4ec scripts/lib/handlers/emit-cost.js
|
|
15
|
+
dccf359893d3c9a2c233ab19e0a64728 scripts/lib/jq-vals.sh
|
|
16
|
+
a2589a1ff6443fc1c18b43095d9d8872 scripts/lib/kb-apply.sh
|
|
17
|
+
740f541460c80aa0f3220656069f0838 scripts/lib/kb-arbitration.js
|
|
18
|
+
8d6e41fe34703497cf5e8b5d1320e3e7 scripts/lib/kb-cli.js
|
|
19
|
+
c0a3a88d1a895e0a86ea483cada15411 scripts/lib/kb-store.js
|
|
20
|
+
133c3a3df52044457822755a23f40bd2 scripts/lib/kb-synthesizer.js
|
|
21
|
+
84348b573adb9c666eb040fb8f7621b2 scripts/lib/langfuse-emit.js
|
|
22
|
+
991a2faba5aaa513e63fa64689761248 scripts/lib/llm-settings-resolve.js
|
|
23
|
+
ed15fbd317db2643b19a3fd7ecd1979a scripts/lib/model-ladder.sh
|
|
24
|
+
4d2dc2a636736b5b77711ad7736f9075 scripts/lib/model-ladders.sh
|
|
25
|
+
97d25b9108a01292d1b28da0d3c129e9 scripts/lib/pipeline-tools.js
|
|
26
|
+
6bfffd3ba6eb845f738d3157168bf056 scripts/lib/relative-paths.js
|
|
27
|
+
6894e248c7a86d07626b7e981d534354 scripts/lib/render-prompt-section.js
|
|
28
|
+
912511e457b0a6f4801c479400b1c3a3 scripts/lib/seam-invocation.js
|
|
29
|
+
010eaf91866f87040076c6889c6dc148 scripts/lib/seam-ladder.sh
|
|
30
|
+
49cafcda352d69ed32966950902d8e4d scripts/lib/self-heal.js
|
|
31
|
+
177515d856a573dad5f515ece454d835 scripts/lib/stage-coverage-gate.sh
|
|
32
|
+
be6d0a378fa9c3bc9da6240877aaeae3 scripts/lib/tool-timeouts.js
|
|
33
|
+
5574182fb27aaeabbfff261b3a8d6d39 scripts/lib/transcript-tool-calls.js
|
|
34
|
+
c4b96230c15b52a845cf20fd3266fe01 scripts/llm-handler.sh
|
|
35
|
+
40c65de8360d5a9f59902fca26d1d7a8 scripts/update-monitor.sh
|
|
36
|
+
dfa070b168a7c01fab8aab5a46d16192 scripts/team-lead-review.sh
|
|
37
|
+
f206025f304e8320b8417a6dd6606044 scripts/spec-mode-runner.js
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
scripts/agent-attempt-analyst.sh
|
|
2
|
+
scripts/lib/agent-contract.js
|
|
3
|
+
scripts/lib/agent-ladder.sh
|
|
4
|
+
scripts/lib/agent-si.js
|
|
5
|
+
scripts/lib/constraint-compiler.js
|
|
6
|
+
scripts/lib/constraint-sanity.js
|
|
7
|
+
scripts/lib/content-retry.js
|
|
8
|
+
scripts/lib/cost-emitter.js
|
|
9
|
+
scripts/lib/cost-record.sh
|
|
10
|
+
scripts/lib/engine-prompt.js
|
|
11
|
+
scripts/lib/env-file.sh
|
|
12
|
+
scripts/lib/evidence-windows.js
|
|
13
|
+
scripts/lib/failure-signature.js
|
|
14
|
+
scripts/lib/handlers/emit-cost.js
|
|
15
|
+
scripts/lib/jq-vals.sh
|
|
16
|
+
scripts/lib/kb-apply.sh
|
|
17
|
+
scripts/lib/kb-arbitration.js
|
|
18
|
+
scripts/lib/kb-cli.js
|
|
19
|
+
scripts/lib/kb-store.js
|
|
20
|
+
scripts/lib/kb-synthesizer.js
|
|
21
|
+
scripts/lib/langfuse-emit.js
|
|
22
|
+
scripts/lib/llm-settings-resolve.js
|
|
23
|
+
scripts/lib/model-ladder.sh
|
|
24
|
+
scripts/lib/model-ladders.sh
|
|
25
|
+
scripts/lib/pipeline-tools.js
|
|
26
|
+
scripts/lib/relative-paths.js
|
|
27
|
+
scripts/lib/render-prompt-section.js
|
|
28
|
+
scripts/lib/seam-invocation.js
|
|
29
|
+
scripts/lib/seam-ladder.sh
|
|
30
|
+
scripts/lib/self-heal.js
|
|
31
|
+
scripts/lib/stage-coverage-gate.sh
|
|
32
|
+
scripts/lib/tool-timeouts.js
|
|
33
|
+
scripts/lib/transcript-tool-calls.js
|
|
34
|
+
scripts/llm-handler.sh
|
|
35
|
+
scripts/update-monitor.sh
|
|
36
|
+
scripts/team-lead-review.sh
|
|
37
|
+
scripts/spec-mode-runner.js
|
|
38
|
+
scripts/lib/agent-messages.js
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# agent-attempt-analyst.sh <failure_class> <failed_output_file> [context_file]
|
|
3
|
+
#
|
|
4
|
+
# Reusable AGENT-EXECUTION failure analyst — the self-heal for agents that fail by
|
|
5
|
+
# producing NO usable output (max-iterations, no-file, no-json, malformed). This is the
|
|
6
|
+
# greenfield-safe SIBLING of claude.sh's run_failure_analyst (which handles CODE/TEST
|
|
7
|
+
# failures and is left untouched). It shares the crown-jewel discipline: the failure-analyst
|
|
8
|
+
# profile, the escalation model, and GROUND-TRUTH context (the real task the agent was given),
|
|
9
|
+
# so it diagnoses WHY the agent failed and prescribes a SHORT corrective directive for the
|
|
10
|
+
# NEXT attempt — instead of a canned string.
|
|
11
|
+
#
|
|
12
|
+
# Used by: brownfield-repro-test-writer.sh (now), the code-graph-detective (next).
|
|
13
|
+
#
|
|
14
|
+
# It does NOT diagnose provider/infra failures (no agent-behaviour to correct) — for those it
|
|
15
|
+
# emits nothing and the caller just retries/ladders.
|
|
16
|
+
#
|
|
17
|
+
# Output: the corrective directive on stdout (empty string = nothing to add, just retry).
|
|
18
|
+
#
|
|
19
|
+
# Exit contract (B30, 2026-07-25) — deliberately three-valued, because "produced
|
|
20
|
+
# no corrective" is a LEGITIMATE outcome for provider/infra/timeout and must stay
|
|
21
|
+
# distinguishable from a BROKEN analyst:
|
|
22
|
+
# 0 + marker -> analysed; the corrective is recorded as a KB EPISODE, not returned as prose
|
|
23
|
+
# 0 + no marker -> deliberate skip (provider/infra/timeout: no behaviour to fix)
|
|
24
|
+
#
|
|
25
|
+
# THE CORRECTIVE IS NOT ON STDOUT. It used to be returned for the caller to prepend to the next
|
|
26
|
+
# attempt; that is a self-heal push into a prompt, which is banned. Nothing has been written to
|
|
27
|
+
# stdout since, so a caller distinguishing "analysed" from "skipped" by the presence of output
|
|
28
|
+
# would read every success as a skip. The caller declares SELF_HEAL_ANALYSED_MARKER and this
|
|
29
|
+
# script echoes it on stderr when it actually analyses.
|
|
30
|
+
# 2 -> the analyst itself failed; the caller must RECORD that the
|
|
31
|
+
# next attempt is running with no corrective guidance
|
|
32
|
+
#
|
|
33
|
+
# This is still best-effort and still must not block the caller. Previously it
|
|
34
|
+
# was `exit 0` unconditionally, swallowed its runner's stderr, and emitted
|
|
35
|
+
# self_heal_complete before checking anything had been produced — so a dead
|
|
36
|
+
# analyst was indistinguishable from a working one in both logs and dashboard,
|
|
37
|
+
# and every retry silently re-ran the identical prompt. Not blocking the caller
|
|
38
|
+
# is not the same as not telling anyone.
|
|
39
|
+
|
|
40
|
+
# THE PIPELINE DOES NOT RUN CODE NOBODY HAS TESTED. This stage asks how much of the code it is
|
|
41
|
+
# about to execute has a test behind it, and halts when the project says it must.
|
|
42
|
+
_scg_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/stage-coverage-gate.sh"
|
|
43
|
+
# shellcheck source=/dev/null
|
|
44
|
+
[ -f "$_scg_lib" ] && . "$_scg_lib" && require_stage_coverage selfheal || exit 1
|
|
45
|
+
|
|
46
|
+
set -uo pipefail
|
|
47
|
+
|
|
48
|
+
FAILURE_CLASS="${1:-}"
|
|
49
|
+
FAILED_OUTPUT_FILE="${2:-/dev/null}"
|
|
50
|
+
CONTEXT_FILE="${3:-/dev/null}"
|
|
51
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
52
|
+
# jq_vals — prompt values files whose content never becomes an argv entry.
|
|
53
|
+
# Placed with the other library sources, NOT beside SCRIPT_DIR: the path-resolution
|
|
54
|
+
# block is lifted verbatim by tests that build a minimal script tree, and a source
|
|
55
|
+
# line inside it makes those probes fail on a library they have no reason to carry.
|
|
56
|
+
source "$SCRIPT_DIR/lib/jq-vals.sh"
|
|
57
|
+
|
|
58
|
+
# THIS SEAM ASKS FOR ITS LADDER.
|
|
59
|
+
#
|
|
60
|
+
# Until 2026-08-12 only team-lead-review.sh called this, so sixteen of seventeen seams kept
|
|
61
|
+
# whatever fixed model their script hardcoded while the registry looked authoritative. The
|
|
62
|
+
# EVERY ENTRY POINT READS THE LADDER DECLARATION ITSELF.
|
|
63
|
+
#
|
|
64
|
+
# lib/model-ladders.sh exists so that "what a tier contains" is declared once and read the same
|
|
65
|
+
# way everywhere. Only claude.sh, run-agent-orchestration.sh and detective-rerun.sh ever called
|
|
66
|
+
# it, so this script resolved its model ONLY from environment its parent happened to export. Run
|
|
67
|
+
# standalone — a replay, a retest, a test harness — nothing set EPAM_MODEL_LADDER_<TIER>,
|
|
68
|
+
# seam_ladder_export set no EPAM_MODEL, and this seam skipped its work while exiting 0.
|
|
69
|
+
#
|
|
70
|
+
# export_model_ladders leaves an already-set value alone, so calling it here changes nothing when
|
|
71
|
+
# the orchestrator has already exported the chain, and supplies it when nobody has.
|
|
72
|
+
_ml_lib="${SCRIPT_DIR:-$(dirname "${BASH_SOURCE[0]}")}/lib/model-ladders.sh"
|
|
73
|
+
if [ -f "$_ml_lib" ]; then
|
|
74
|
+
# shellcheck source=lib/model-ladders.sh
|
|
75
|
+
. "$_ml_lib" || true
|
|
76
|
+
command -v export_model_ladders >/dev/null 2>&1 \
|
|
77
|
+
&& export_model_ladders "${EPAM_LLM_SETTINGS_FILE:-${EPAM_PROJECT_CONFIG_DIR:+$EPAM_PROJECT_CONFIG_DIR/llm-settings.json}}" || true
|
|
78
|
+
fi
|
|
79
|
+
# ask must come BEFORE any model is resolved below: seam_ladder_export sets EPAM_MODEL, and
|
|
80
|
+
# a later assignment that wins makes the whole thing decorative.
|
|
81
|
+
#
|
|
82
|
+
# Guarded: these run mid-pipeline, and a packaging error must degrade to the previous fixed
|
|
83
|
+
# model rather than kill a run.
|
|
84
|
+
# shellcheck source=lib/seam-ladder.sh
|
|
85
|
+
. "$SCRIPT_DIR/lib/seam-ladder.sh" 2>/dev/null || true
|
|
86
|
+
# WHICH AGENT THIS IS — declared ONCE, and exported so ai-run.sh keys this agent's ladder rung
|
|
87
|
+
# state to it. Without it every agent shared one counter ("agent__<story>"): one agent escalating
|
|
88
|
+
# advanced the ladder for all of them, and team-lead-review's cross-process resume read a key
|
|
89
|
+
# nothing ever wrote.
|
|
90
|
+
_SEAM_NAME="agent-failure-analyst"
|
|
91
|
+
export EPAM_AGENT_NAME="$_SEAM_NAME"
|
|
92
|
+
command -v seam_ladder_export >/dev/null 2>&1 && seam_ladder_export "$_SEAM_NAME"
|
|
93
|
+
|
|
94
|
+
AI_RUNNER_CMD="${AI_RUNNER_CMD:-$SCRIPT_DIR/ai-run.sh}"
|
|
95
|
+
# shellcheck source=lib/agent-invoke.sh
|
|
96
|
+
source "$SCRIPT_DIR/lib/agent-invoke.sh"
|
|
97
|
+
|
|
98
|
+
log() { echo "[agent-attempt-analyst] $*" >&2; }
|
|
99
|
+
|
|
100
|
+
# Provider/infra failures carry no agent behaviour to correct — skip (caller just retries).
|
|
101
|
+
case "$FAILURE_CLASS" in
|
|
102
|
+
provider|infra|timeout) exit 0 ;;
|
|
103
|
+
esac
|
|
104
|
+
|
|
105
|
+
# THE SEAM DECIDES. seam_ladder_export (above) sets EPAM_MODEL to the first rung of the chain this
|
|
106
|
+
# seam's ARCHETYPE declares, which is the whole point of declaring a ladder. The literal that stood
|
|
107
|
+
# last in this chain overrode that silently: the seam asked for its tier and the answer was
|
|
108
|
+
# discarded, so editing the declaration moved no model. Operator overrides still win; what is gone
|
|
109
|
+
# is the hardcoded final fallback, which no configuration could remove.
|
|
110
|
+
# HEAL ON THE RUNG THAT FAILED — the whole rung, not its model.
|
|
111
|
+
#
|
|
112
|
+
# Operator, 2026-08-22: "if analyst is used in a retry the self-heal ladder rung must be
|
|
113
|
+
# inherited by the analyst."
|
|
114
|
+
#
|
|
115
|
+
# Diagnosing a stronger rung's attempt from a weaker one is guesswork about reasoning the
|
|
116
|
+
# analyst cannot reproduce, and the diagnosis reads as authoritative anyway. Model alone is not
|
|
117
|
+
# enough: claude.sh's ladder moves provider, reasoning effort and temperature with it, so an
|
|
118
|
+
# analyst on the right model at the wrong effort is still analysing a setup that never ran.
|
|
119
|
+
#
|
|
120
|
+
# The rung is persisted by the writer on every attempt (lib/story-outputs.sh). No guard and no
|
|
121
|
+
# fallback: for a story that has been attempted it EXISTS, and defending against its absence
|
|
122
|
+
# would restore the silent seam-default diagnosis this replaces. An operator override still
|
|
123
|
+
# wins — a deliberate cross-rung diagnosis stays possible.
|
|
124
|
+
# shellcheck source=lib/story-outputs.sh
|
|
125
|
+
. "$SCRIPT_DIR/lib/story-outputs.sh"
|
|
126
|
+
_rung_model=$(story_rung_get "${LOG_DIR:-}" "${AGENT_ANALYST_STORY_ID:-}" model)
|
|
127
|
+
_rung_provider=$(story_rung_get "${LOG_DIR:-}" "${AGENT_ANALYST_STORY_ID:-}" provider)
|
|
128
|
+
_rung_effort=$(story_rung_get "${LOG_DIR:-}" "${AGENT_ANALYST_STORY_ID:-}" reasoningEffort)
|
|
129
|
+
_rung_temperature=$(story_rung_get "${LOG_DIR:-}" "${AGENT_ANALYST_STORY_ID:-}" temperature)
|
|
130
|
+
if [ -n "$_rung_model" ]; then
|
|
131
|
+
export EPAM_REASONING_EFFORT="$_rung_effort"
|
|
132
|
+
export EPAM_TEMPERATURE="$_rung_temperature"
|
|
133
|
+
warning "analyst inherits the failing rung: model=$_rung_model provider=$_rung_provider effort=${_rung_effort:-unset} temp=${_rung_temperature:-unset}"
|
|
134
|
+
fi
|
|
135
|
+
_model="${AGENT_ANALYST_MODEL:-${_rung_model:-${ESCALATION_MODEL:-${EPAM_MODEL:-${EPAM_MODEL:-}}}}}"
|
|
136
|
+
_provider="${AGENT_ANALYST_PROVIDER:-${_rung_provider:-${ORCH_GATE_PROVIDER:-${EPAM_ORCHESTRATION_PROVIDER:-}}}}"
|
|
137
|
+
if [ -z "$_model" ]; then
|
|
138
|
+
# A diagnosis produced by a guessed model is worse than an honest absence: it reads as
|
|
139
|
+
# authoritative and nothing downstream can tell it was never grounded in a declared tier.
|
|
140
|
+
warning "no model resolved for this seam — its archetype declares no ladder, or the tier's chain is unset. Not analysing rather than guessing a model."
|
|
141
|
+
exit 0
|
|
142
|
+
fi
|
|
143
|
+
|
|
144
|
+
# The analyst's identity is its SI (seam agent-failure-analyst), resolved by the hub — never a persona
|
|
145
|
+
# pasted into the prompt (operator, 2026-09-30: one path).
|
|
146
|
+
# THE PROMPT, AND THE FIVE CLASS HINTS, FROM THE TEMPLATE LAYER.
|
|
147
|
+
#
|
|
148
|
+
# All of it used to sit here: the fallback role line, a five-arm case statement whose every arm
|
|
149
|
+
# was a sentence addressed to the analyst, and the prompt heredoc. The engine still chooses
|
|
150
|
+
# WHICH hint by failure class -- that is a decision, not wording -- but it no longer holds the
|
|
151
|
+
# wording of any of them.
|
|
152
|
+
. "$SCRIPT_DIR/lib/render-engine-prompt.sh"
|
|
153
|
+
|
|
154
|
+
_tpl="$(dirname "$SCRIPT_DIR")/prompts/templates/agent-failure-analyst.json"
|
|
155
|
+
_body() { "${NODE_BIN:-node}" -e '
|
|
156
|
+
const d = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
|
|
157
|
+
process.stdout.write(String((d.bodies || {})[process.argv[2]] || ""));
|
|
158
|
+
' "$_tpl" "$1"; }
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
_failed_output=$(cat "$FAILED_OUTPUT_FILE" 2>/dev/null || echo "")
|
|
162
|
+
_context=$(cat "$CONTEXT_FILE" 2>/dev/null || echo "")
|
|
163
|
+
|
|
164
|
+
# Class-specific framing so the analyst targets the right behaviour.
|
|
165
|
+
case "$FAILURE_CLASS" in
|
|
166
|
+
max_iterations) _class_hint="$(_body hint_max_iterations)" ;;
|
|
167
|
+
no_file) _class_hint="$(_body hint_no_file)" ;;
|
|
168
|
+
no_json|malformed) _class_hint="$(_body hint_no_json)" ;;
|
|
169
|
+
rejected) _class_hint="$(_body hint_rejected)" ;;
|
|
170
|
+
invalid_test) _class_hint="$(_body hint_invalid_test)" ;;
|
|
171
|
+
*) _class_hint="$(_body hint_default)" ;;
|
|
172
|
+
esac
|
|
173
|
+
|
|
174
|
+
# Values via a FILE, never argv: a failed agent's output is routinely megabytes and argv is
|
|
175
|
+
# capped at ARG_MAX. This is the same crash that took out the failure analyst on 2026-08-15.
|
|
176
|
+
# WHY THE CALLER REFUSED THIS ATTEMPT (AGENT_ANALYST_REASON). Every caller knows it — a reviewer's
|
|
177
|
+
# finding, a validator's verdict, a contract refusal — and none of it reached this prompt until
|
|
178
|
+
# 2026-10-06. Absent is said aloud, in the prompt and here: a caller that drops it is a defect.
|
|
179
|
+
_reason="${AGENT_ANALYST_REASON:-}"
|
|
180
|
+
[ -n "${AGENT_ANALYST_REASON_FILE:-}" ] && [ -f "$AGENT_ANALYST_REASON_FILE" ] && _reason="$(cat "$AGENT_ANALYST_REASON_FILE")"
|
|
181
|
+
[ -n "${_reason//[[:space:]]/}" ] || { log "WARNING: no reason was given for this ${FAILURE_CLASS} failure — the caller dropped it; the analyst diagnoses from the output alone"; _reason="(the caller gave no reason — diagnose from the output alone)"; }
|
|
182
|
+
_aa_vals=$(mktemp "${TMPDIR:-/tmp}/agent-failure-analyst-vals-XXXXXX.json")
|
|
183
|
+
# Where the spec pass's detective found the fix, for the story whose agent failed — the same
|
|
184
|
+
# rendering the reviewer and impl-failure-analyst read (lib/handlers/fix-sites.js).
|
|
185
|
+
_fix_sites=""
|
|
186
|
+
if [ -n "${AGENT_ANALYST_STORY_ID:-}" ] && [ -f "${MAIN_PRD_FILE:-${PRD_FILE:-}}" ]; then
|
|
187
|
+
_fix_sites=$("${NODE_BIN:-node}" "$SCRIPT_DIR/lib/handlers/fix-sites.js" "${MAIN_PRD_FILE:-$PRD_FILE}" "$AGENT_ANALYST_STORY_ID" 2>/dev/null || true)
|
|
188
|
+
fi
|
|
189
|
+
jq_vals --arg class_hint "$_class_hint" \
|
|
190
|
+
--arg failure_class "$FAILURE_CLASS" \
|
|
191
|
+
--arg failure_reason "$_reason" \
|
|
192
|
+
--arg failed_output "${_failed_output:-(empty — it produced nothing)}" \
|
|
193
|
+
--arg context "${_context:-(not provided)}" \
|
|
194
|
+
--arg fix_sites "${_fix_sites:-(the spec pass recorded none for this story)}" \
|
|
195
|
+
'{"__CLASS_HINT__":$class_hint,"__FAILURE_CLASS__":$failure_class,"__FAILURE_REASON__":$failure_reason,"__FAILED_OUTPUT__":$failed_output,"__CONTEXT__":$context,"__FIX_SITES__":$fix_sites}' \
|
|
196
|
+
> "$_aa_vals"
|
|
197
|
+
if ! _prompt=$(render_engine_prompt agent-failure-analyst "$_aa_vals" prompt); then
|
|
198
|
+
rm -f "$_aa_vals"
|
|
199
|
+
warning "cannot render the analyst prompt — not analysing rather than asking with no instructions"
|
|
200
|
+
exit 0
|
|
201
|
+
fi
|
|
202
|
+
rm -f "$_aa_vals"
|
|
203
|
+
|
|
204
|
+
# Activity emit — the failure-agent is a first-class agent and MUST be visible in
|
|
205
|
+
# agent-activity.html like every other agent (2026-07-24). storyId comes from the caller.
|
|
206
|
+
_fa_sid="${AGENT_ANALYST_STORY_ID:-}"
|
|
207
|
+
_emit_fa() { bash "$SCRIPT_DIR/update-monitor.sh" event "$1" "$2" "$_fa_sid" "main" "failure-analyst" 2>/dev/null || true; }
|
|
208
|
+
_emit_fa "self_heal_start" "failure-analyst diagnosing ${FAILURE_CLASS}${_fa_sid:+ for ${_fa_sid}}"
|
|
209
|
+
|
|
210
|
+
log "diagnosing ${FAILURE_CLASS} via ${_model}"
|
|
211
|
+
|
|
212
|
+
# THE CALLER DECLARES HOW IT WILL RECOGNISE A REAL ANALYSIS, and this echoes it back.
|
|
213
|
+
|
|
214
|
+
# self-heal.js used to decide by regex-matching the sentence above, so rewording one line
|
|
215
|
+
|
|
216
|
+
# of prose turned every successful analysis into a "decline" — the same indistinguishable
|
|
217
|
+
|
|
218
|
+
# state recorded live on 2026-08-27 as seven refusals with zero episodes and zero rc=2.
|
|
219
|
+
|
|
220
|
+
# One string, chosen by the reader, and the prose is free to change.
|
|
221
|
+
|
|
222
|
+
[ -n "${SELF_HEAL_ANALYSED_MARKER:-}" ] && printf '%s\n' "$SELF_HEAL_ANALYSED_MARKER" >&2
|
|
223
|
+
|
|
224
|
+
# Keep the runner's stderr: it is the only evidence of WHY self-heal failed, and
|
|
225
|
+
# both call sites used to discard it along with everything else.
|
|
226
|
+
_analyst_err="$(mktemp 2>/dev/null || echo /tmp/agent-analyst-err.$$)"
|
|
227
|
+
# THROUGH THE ONE DOOR -- AND THE ANALYST CAN FINALLY LOOK AT SOMETHING.
|
|
228
|
+
#
|
|
229
|
+
# This granted NO TOOLS. Not a restricted set: none. EPAM_ALLOWED_TOOLS was never set and the tool
|
|
230
|
+
# gate was never opened, so the agent asked to work out WHY an attempt failed could not read the
|
|
231
|
+
# file that failed, search for the symbol, or run the check that reported it. It diagnosed from a
|
|
232
|
+
# description of the evidence.
|
|
233
|
+
#
|
|
234
|
+
# Its profile has declared "toolGrant": "execute" the whole time. Nothing read it, because nothing
|
|
235
|
+
# went through lib/agent-invoke.sh -- so the declaration and the invocation disagreed and the
|
|
236
|
+
# invocation won silently.
|
|
237
|
+
#
|
|
238
|
+
# The budget was the same story one field over: three ${VAR:-default} literals duplicating a
|
|
239
|
+
# profile that already states them.
|
|
240
|
+
_note=$(echo "$_prompt" | invoke_agent agent-failure-analyst \
|
|
241
|
+
--model "$_model" --provider "$_provider" \
|
|
242
|
+
--codeline "${PROJECT_ROOT:-}" \
|
|
243
|
+
2>"$_analyst_err")
|
|
244
|
+
_analyst_rc=$?
|
|
245
|
+
|
|
246
|
+
# Trim to a bounded directive; never emit a huge blob.
|
|
247
|
+
_trimmed="$(printf '%s' "$_note" | head -c 800)"
|
|
248
|
+
|
|
249
|
+
if [ "$_analyst_rc" -ne 0 ] || [ -z "${_trimmed//[[:space:]]/}" ]; then
|
|
250
|
+
log "FAILED — no corrective produced for ${FAILURE_CLASS} (exit=${_analyst_rc}, model=${_model})"
|
|
251
|
+
[ -s "$_analyst_err" ] && log " runner stderr: $(head -c 400 "$_analyst_err" | tr '\n' ' ')"
|
|
252
|
+
log " the next attempt will retry WITHOUT corrective guidance"
|
|
253
|
+
_emit_fa "self_heal_failed" "failure-analyst FAILED (${FAILURE_CLASS}, exit=${_analyst_rc}) — next attempt has NO corrective guidance"
|
|
254
|
+
rm -f "$_analyst_err" 2>/dev/null || true
|
|
255
|
+
exit 2
|
|
256
|
+
else
|
|
257
|
+
_emit_fa "self_heal_complete" "failure-analyst prescribed corrective directive (${FAILURE_CLASS})"
|
|
258
|
+
fi
|
|
259
|
+
|
|
260
|
+
rm -f "$_analyst_err" 2>/dev/null || true
|
|
261
|
+
|
|
262
|
+
# ── Feed the KB, never the prompt ────────────────────────────────────────────
|
|
263
|
+
# The diagnosis used to be returned as prose for the caller to prepend to the
|
|
264
|
+
# next attempt ("CORRECTIVE GUIDANCE FROM SELF-HEAL: ..."). That is a self-heal
|
|
265
|
+
# push into a prompt, which is banned: appended text is silently trimmed on long
|
|
266
|
+
# runs and nothing verifies the agent obeyed it.
|
|
267
|
+
#
|
|
268
|
+
# Instead the diagnosis becomes an EPISODE, and synthesis turns it into an
|
|
269
|
+
# enforceable constraint via the path that is already tested and schema-bounded.
|
|
270
|
+
# Threshold 1 because this heal is same-story: the retry happens seconds later,
|
|
271
|
+
# and waiting for a second identical failure would leave the very next attempt —
|
|
272
|
+
# the one that matters most — with nothing. Arbitration, TTL ageing and
|
|
273
|
+
# quarantine are all in place, so a single-episode rule is safe: if it is wrong,
|
|
274
|
+
# it ages out.
|
|
275
|
+
_kb_lib="$SCRIPT_DIR/lib/kb-apply.sh"
|
|
276
|
+
if [ -f "$_kb_lib" ]; then
|
|
277
|
+
# shellcheck disable=SC1090
|
|
278
|
+
. "$_kb_lib"
|
|
279
|
+
# Here-string, NOT a pipe: `x | kb_record_episode` runs the function in a
|
|
280
|
+
# subshell, so the signature it captures dies with it and synthesis has no key.
|
|
281
|
+
kb_record_episode "${AGENT_ANALYST_STORY_ID:-}" "${STORY_ROLE:-}" "$_trimmed" "$FAILURE_CLASS" \
|
|
282
|
+
<<< "$_failed_output" || true
|
|
283
|
+
KB_SYNTHESIS_THRESHOLD=1 kb_maybe_synthesize "${STORY_ROLE:-}" || true
|
|
284
|
+
fi
|
|
285
|
+
|
|
286
|
+
# Deliberately no stdout: there is no prose channel back to the caller any more.
|
|
287
|
+
exit 0
|