amsd-pipeline-shared 0.5.6 → 0.5.8

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.
@@ -3,7 +3,8 @@
3
3
  "node": {
4
4
  "runtimeNames": ["TypeError", "ReferenceError", "SyntaxError", "RangeError"],
5
5
  "runtimeMessage": "Unexpected token|is not valid JSON|Unexpected end of JSON|Cannot read propert|Cannot set propert|is not a function|is not defined|is not iterable|Cannot destructure|is not a constructor|Invalid array length|Maximum call stack",
6
- "external": "\\bE(NOSPC|ACCES|PERM|MFILE|NOMEM|CONNRESET|CONNREFUSED|TIMEDOUT|NOTFOUND|AI_AGAIN|PIPE)\\b|bounded: cap|exited (137|143)\\b|\\b(401|403|429|500|502|503|529)\\b|overloaded|rate.?limit|credit|quota|<!DOCTYPE|<html|out of memory|killed",
6
+ "external": "\\bE(NOSPC|ACCES|PERM|MFILE|NOMEM|CONNRESET|CONNREFUSED|TIMEDOUT|NOTFOUND|AI_AGAIN|PIPE)\\b|bounded: REFUSED|exited (137|143)\\b|\\b(401|403|429|500|502|503|529)\\b|overloaded|rate.?limit|credit|quota|<!DOCTYPE|<html|out of memory|killed",
7
+ "ownBudget": "bounded: cap=\\d+MB",
7
8
  "generalRefusal": "refused|usage:",
8
9
  "internalFrame": "^node:",
9
10
  "dependencyDir": "node_modules"
@@ -19,12 +19,16 @@ function signaturesFor(runtime, injected) {
19
19
  const all = injected || JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', '..', 'config', 'doctor-signatures.json'), 'utf8'));
20
20
  const s = all[runtime];
21
21
  if (!s) throw new Error(`no signatures for runtime "${runtime}" (config/doctor-signatures.json knows: ${Object.keys(all).filter((k) => !k.startsWith('$')).join(', ')})`);
22
- return { runtimeNames: new Set(s.runtimeNames), runtimeMessage: rxOf(s.runtimeMessage), external: rxOf(s.external), generalRefusal: rxOf(s.generalRefusal),
22
+ return { runtimeNames: new Set(s.runtimeNames), runtimeMessage: rxOf(s.runtimeMessage), external: rxOf(s.external), ownBudget: s.ownBudget ? rxOf(s.ownBudget) : /$^/, generalRefusal: rxOf(s.generalRefusal),
23
23
  internalFrame: rxOf(s.internalFrame), dependencyDir: s.dependencyDir };
24
24
  }
25
25
 
26
26
  function classify({ profile, portRoot, res, crash, aborted, since, signatures, evidence }) {
27
27
  const sig = signaturesFor(profile.classify.runtime, signatures);
28
+ const takesMost = profile.classify.policy === 'all-but-refusals';
29
+ // WHAT IS EXTERNAL: the machine, the network, the model service, a kill. Under "all-but-refusals" a kill that happened inside the pipeline's OWN memory budget (the bounded wrapper's start banner is in the text:
30
+ // the cap the pipeline chose, so what ran inside it is the pipeline's to bound — run 11, jest workers inside a Writer session's cap) is not external
31
+ const isExternal = (text) => sig.external.test(text) && !(takesMost && sig.ownBudget.test(text));
28
32
  if (!res || typeof res !== 'object') return no('no-result', 'the leg gave no result');
29
33
  if (aborted) return no('aborted', 'the operator or a watcher ended the leg');
30
34
  // AN AGENT-CAUSED ABORT (operator, 2026-10-20: look for the agentic solution first): a failed leg whose reason is one of the pipeline's own agent-caused
@@ -39,19 +43,30 @@ function classify({ profile, portRoot, res, crash, aborted, since, signatures, e
39
43
  // has a number and its reason and matches none. The marked line is still refused if it names the machine, the network, the model service or a kill.
40
44
  const line = evidence && profile.classify.faultMarks.length ? String(evidence).split('\n').find((l) => profile.classify.faultMarks.some((r) => r.test(l))) : null;
41
45
  if (line) {
42
- if (sig.external.test(line)) return no('external', `the failing line also names the machine, the network, the model service or a kill — not the pipeline: ${line.slice(0, 160)}`);
46
+ if (isExternal(line)) return no('external', `the failing line also names the machine, the network, the model service or a kill — not the pipeline: ${line.slice(0, 160)}`);
43
47
  return { call: true, kind: 'fault', layer: 'undecided', why: `the leg failed without a verdict: ${line.trim().slice(0, 200)}` };
44
48
  }
49
+ // MOST FAILURES (profile classify.policy "all-but-refusals"): the pipeline's own refusals are its decision and what names the machine, the network, the model service or a kill is not the code —
50
+ // everything else, self-heal giving up included, goes to the doctor, which still needs a RED test and a GREEN fix to change anything. Judged on the reason and the END of the evidence.
51
+ if (profile.classify.policy === 'all-but-refusals') {
52
+ const lines = String(evidence || '').split('\n').filter((l) => l.trim());
53
+ const window = [reason, ...lines.slice(-25)].filter(Boolean).join('\n');
54
+ if (!window.trim()) return no('not-a-crash', 'the leg failed with no reason and no evidence — nothing to investigate');
55
+ if (isExternal(window)) return no('external', 'the end of the failure also names the machine, the network, the model service or a kill — not the pipeline');
56
+ if (profile.classify.refusals.some((r) => r.test(window))) return no('refusal', 'the pipeline stopped on purpose (its own refusal or pre-condition)');
57
+ const last = [...lines].reverse().find((l) => /fail|HALT|block|error/i.test(l)) || reason;
58
+ return { call: true, kind: 'failure', layer: 'undecided', why: `the leg failed (${reason || 'no reason'}): ${String(last).trim().slice(0, 200)}` };
59
+ }
45
60
  return no('not-a-crash', 'the leg failed on a verdict that names no agent-caused message — reported, not repaired');
46
61
  }
47
- if (sig.external.test(reason)) return no('external', 'the agent-caused message also names the machine, the network, the model service or a kill — not the pipeline');
62
+ if (isExternal(reason)) return no('external', 'the agent-caused message also names the machine, the network, the model service or a kill — not the pipeline');
48
63
  return { call: true, kind: 'agentic-abort', layer: 'agentic', why: `an agent caused the abort: ${reason.slice(0, 200)}` };
49
64
  }
50
65
  if (res.status !== 'crashed') return no(res.status === 'no-answer' ? 'no-record' : 'not-a-crash', `the leg ended ${res.status}, not crashed — a kill, a verdict or a pause is reported, not repaired`);
51
66
  if (!crash || typeof crash !== 'object') return no('no-record', 'no crash record: nothing proves a defect in the code');
52
67
  if (!crash.at || !since || Date.parse(crash.at) < Date.parse(since)) return no('stale-record', 'the crash record is older than this leg');
53
68
  const text = `${crash.name || ''}: ${crash.message || ''}`;
54
- if (sig.external.test(text)) return no('external', 'the failure names the machine, the network, the model service or a kill — not the code');
69
+ if (isExternal(text)) return no('external', 'the failure names the machine, the network, the model service or a kill — not the code');
55
70
  if (sig.generalRefusal.test(text) || profile.classify.refusals.some((r) => r.test(text))) return no('refusal', 'the pipeline stopped on purpose');
56
71
  const site = (crash.frames || []).find((f) => f && typeof f.file === 'string' && !sig.internalFrame.test(f.file)) || null;
57
72
  if (!site) return no('outside-port', 'no frame of the stack is in any file');
@@ -60,6 +75,8 @@ function classify({ profile, portRoot, res, crash, aborted, since, signatures, e
60
75
  const inPort = !!portRoot && !rel.startsWith('..') && !path.isAbsolute(rel) && profile.scope.repairDirs.includes(parts[0]) && !parts.includes(sig.dependencyDir);
61
76
  if (!inPort) return no('outside-port', `the throw site ${site.file}:${site.line} is not the component's own code`);
62
77
  if (crash.fault === true) return { call: true, kind: 'fault', layer: 'undecided', why: `a fault the component raised: ${String(crash.message).slice(0, 160)} — at ${rel}:${site.line}${crash.step ? ` (step: ${crash.step})` : ''}` };
78
+ // under "all-but-refusals" an Error thrown by the component's own code is the doctor's even without a runtime-exception mark (run 11: `the live session for writer exited null`)
79
+ if (takesMost && !sig.runtimeNames.has(crash.name) && !sig.runtimeMessage.test(crash.message || '')) return { call: true, kind: 'crash', layer: 'undecided', why: `${crash.name}: ${String(crash.message).slice(0, 160)} — thrown at ${rel}:${site.line}${crash.step ? ` (step: ${crash.step})` : ''}` };
63
80
  if (!sig.runtimeNames.has(crash.name) && !sig.runtimeMessage.test(crash.message || '')) return no('ambiguous', `an Error in ${rel}:${site.line} with no sign of a code defect — reported`);
64
81
  return { call: true, kind: 'port-defect', layer: 'code', why: `${crash.name}: ${String(crash.message).slice(0, 160)} — thrown at ${rel}:${site.line}${crash.step ? ` (step: ${crash.step})` : ''}` };
65
82
  }
@@ -12,7 +12,7 @@ const path = require('path');
12
12
  const { spawnSync } = require('child_process');
13
13
  const { classify } = require('./classify.js');
14
14
  const { settings, open } = require('./state.js');
15
- const { verifyRepair, reinstall, installedMismatches, checkComponent, snapshotRepos, repoChanges } = require('./repair.js');
15
+ const { verifyRepair, reinstall, installedMismatches, checkComponent, snapshotRepos, repoChanges, promote } = require('./repair.js');
16
16
  const { renderPrompt } = require('./agent.js');
17
17
  const { restart } = require('./restarter.js');
18
18
 
@@ -30,7 +30,9 @@ async function handle(a) {
30
30
  const portRoot = path.join(install, profile.install.dest);
31
31
 
32
32
  // the leg's evidence tail (the files the profile names), read BEFORE the decision: a failure that threw nothing is judged on what the run itself wrote
33
- const evidence = profile.logs.evidence.map((f) => tail(path.join(runDir, f), profile.logs.evidenceTailLines)).join('\n');
33
+ // the logs are cumulative across legs: the decision reads only what follows the last leg-start line in each file (the whole tail when the profile names none or the tail holds none)
34
+ const thisLeg = (text) => { if (!profile.logs.legStart) return text; const ls = text.split('\n'); let at = -1; ls.forEach((l, i) => { if (profile.logs.legStart.test(l)) at = i; }); return at < 0 ? text : ls.slice(at).join('\n'); };
35
+ const evidence = profile.logs.evidence.map((f) => thisLeg(tail(path.join(runDir, f), profile.logs.evidenceTailLines))).join('\n');
34
36
  const c = classify({ profile, portRoot, res, crash, aborted, since, evidence });
35
37
  if (!c.call) { d.event('not-called', { kind: c.kind, why: c.why }); return { handled: false, kind: c.kind, why: c.why }; }
36
38
  d.event('trigger', { kind: c.kind, layer: c.layer, why: c.why, step: (crash && crash.step) || '' });
@@ -109,6 +111,10 @@ async function handle(a) {
109
111
  fs.writeFileSync(path.join(A, 'reinstall.txt'), `${ri.installer || ''}\n${ri.why}\n${(ri.mismatches || []).join('\n')}\n`);
110
112
  if (!ri.ok) return stop('reinstall-failed', ri.why);
111
113
  d.event('reinstalled', { layer, commit: ri.commit, files: v.files }, attempt.n);
114
+ // PROMOTE: the main line follows the install (local, never forced) — otherwise the next repair in this run would be refused as "repo-not-installed"
115
+ const pr = promote({ repo, commit: ri.commit });
116
+ if (pr.ok) d.event('promoted', { from: pr.from, to: pr.to, branch: pr.branch }, attempt.n);
117
+ else { d.event('promote-skipped', { why: pr.why }, attempt.n); alert(`repair installed but not promoted: ${pr.why} — the repository's HEAD is not what is installed, so the next repair would be refused until it is`); }
112
118
 
113
119
  fs.writeFileSync(path.join(A, 'verdict.json'), `${JSON.stringify({ status: 'repaired', layer, layerWhy: r.answer.layerWhy, summary: r.answer.summary, cause: r.answer.cause, files: v.files, branch, commit: ri.commit }, null, 2)}\n`);
114
120
  d.event('repaired', { layer, layerWhy: r.answer.layerWhy, summary: r.answer.summary, cause: r.answer.cause, branch, commit: ri.commit }, attempt.n);
@@ -79,14 +79,48 @@ function validate(input) {
79
79
  if (need('classify', cl, 'refusals')) out.classify.refusals = rxs('classify.refusals', cl.refusals);
80
80
  // THE LINES THAT MEAN "THE MACHINERY GAVE NO VERDICT" (optional; none = a failed leg is never taken for a fault): a pipeline's own knowledge, matched against the leg's evidence tail
81
81
  out.classify.faultMarks = cl.faultMarks === undefined ? [] : rxs('classify.faultMarks', cl.faultMarks);
82
+ // WHICH FAILED LEGS CALL THE DOCTOR (operator, 2026-10-20: "let the doctor fix most issues … unless the pipeline decides it"): "faults-only" (default) = a crash, an agentic abort or a marked
83
+ // fault; "all-but-refusals" = every failed leg except an operator abort, an external failure and the pipeline's own refusals (classify.refusals)
84
+ out.classify.policy = cl.policy === undefined ? 'faults-only' : cl.policy;
85
+ if (!['faults-only', 'all-but-refusals'].includes(out.classify.policy)) bad('classify.policy', 'must be "faults-only" or "all-but-refusals"');
82
86
  }
83
87
 
84
88
  const se = p.settings;
85
89
  if (se) {
86
90
  out.settings = {};
87
91
  for (const k of ['on', 'restarts', 'repo', 'budget']) if (need('settings', se, k)) { const v = str(`settings.${k}`, se[k]); if (v !== null && !ENV_NAME.test(v)) bad(`settings.${k}`, `"${v}" must be an environment variable name (A-Z, 0-9, _)`); else out.settings[k] = v; }
88
- const names = ['on', 'restarts', 'repo', 'budget'].map((k) => se[k]).filter(Boolean);
89
- if (new Set(names).size !== names.length) bad('settings', 'the four setting names must be different');
92
+ // the release setting is optional, and required by a release section: a pipeline that publishes says which launch setting is the operator's yes
93
+ if (has(se, 'release')) { const v = str('settings.release', se.release); if (v !== null && !ENV_NAME.test(v)) bad('settings.release', `"${v}" must be an environment variable name (capital letters, digits, underscores)`); else if (v !== null) out.settings.release = v; }
94
+ const names = ['on', 'restarts', 'repo', 'budget', 'release'].map((k) => se[k]).filter(Boolean);
95
+ if (new Set(names).size !== names.length) bad('settings', 'the setting names must be different');
96
+ }
97
+
98
+ // THE RELEASE (operator, 2026-10-20: "get doctor to commit and update npm and update install as well"): optional. The pipeline lists, as data, the commands that turn the promoted
99
+ // repairs into a published version and put it in the install; the doctor runs them in order, stops at the first failure, and runs them only when the operator's setting says yes.
100
+ if (has(p, 'release')) {
101
+ const r = p.release; out.release = {};
102
+ if (!r || typeof r !== 'object' || Array.isArray(r)) bad('release', 'an object is required');
103
+ else {
104
+ const TOKENS = new Set(['node', 'repo', 'worktree', 'install', 'run', 'package', 'version', 'tag']);
105
+ const cmd = (path, v) => {
106
+ const x = strs(path, v, { nonEmpty: true }); if (!x) return null;
107
+ for (const w of x) for (const m of w.matchAll(/\{([a-zA-Z]+)\}/g)) if (!TOKENS.has(m[1])) bad(path, `unknown token {${m[1]}} (known: ${[...TOKENS].map((t) => `{${t}}`).join(' ')})`);
108
+ return x;
109
+ };
110
+ if (need('release', r, 'versionFile')) out.release.versionFile = rel('release.versionFile', r.versionFile);
111
+ if (need('release', r, 'tag')) { out.release.tag = str('release.tag', r.tag); if (out.release.tag && !out.release.tag.includes('{version}')) bad('release.tag', 'must contain {version}'); }
112
+ if (need('release', r, 'steps')) {
113
+ if (!Array.isArray(r.steps) || !r.steps.length) bad('release.steps', 'a non-empty list of { name, command } is required');
114
+ else out.release.steps = r.steps.map((st, i) => ({ name: str(`release.steps[${i}].name`, st && st.name), command: cmd(`release.steps[${i}].command`, st && st.command) }));
115
+ }
116
+ if (need('release', r, 'reinstall')) {
117
+ const ri = r.reinstall || {};
118
+ out.release.reinstall = { command: cmd('release.reinstall.command', ri.command), retries: 0, delaySeconds: 0 };
119
+ if (!Number.isInteger(ri.retries) || ri.retries < 0) bad('release.reinstall.retries', 'a whole number, 0 or more, is required'); else out.release.reinstall.retries = ri.retries;
120
+ if (typeof ri.delaySeconds !== 'number' || ri.delaySeconds < 0) bad('release.reinstall.delaySeconds', 'a number of seconds, 0 or more, is required'); else out.release.reinstall.delaySeconds = ri.delaySeconds;
121
+ }
122
+ if (!has(se || {}, 'release')) bad('settings.release', 'a release section needs the launch setting that switches it on');
123
+ }
90
124
  }
91
125
 
92
126
  if (p.restart) { if (need('restart', p.restart, 'args')) out.restart = { args: strs('restart.args', p.restart.args, { nonEmpty: true }) }; }
@@ -100,6 +134,8 @@ function validate(input) {
100
134
  if (need('logs', lg, 'dir')) out.logs.dir = rel('logs.dir', lg.dir);
101
135
  if (need('logs', lg, 'alerts')) out.logs.alerts = file('logs.alerts', lg.alerts);
102
136
  if (need('logs', lg, 'evidence')) { const v = strs('logs.evidence', lg.evidence, { nonEmpty: false }); if (v) { v.forEach((d, n) => rel(`logs.evidence[${n}]`, d)); out.logs.evidence = v; } }
137
+ // the line each leg of a run starts with (optional): the evidence the doctor DECIDES on is what follows its last occurrence — the logs are cumulative across legs
138
+ if (has(lg, 'legStart')) out.logs.legStart = rx('logs.legStart', lg.legStart);
103
139
  if (need('logs', lg, 'evidenceTailLines')) { if (!Number.isInteger(lg.evidenceTailLines) || lg.evidenceTailLines < 1) bad('logs.evidenceTailLines', 'a positive whole number is required'); else out.logs.evidenceTailLines = lg.evidenceTailLines; }
104
140
  }
105
141
 
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+ // THE DOCTOR'S RELEASE (operator, 2026-10-20: "get doctor to commit and update npm and update install as well"). A verified repair is in the install and, once promoted, at the repository's HEAD. A release makes
3
+ // it a published version: the pipeline's profile lists the commands (a version bump, a commit, a tag, a push, a publish — whatever that pipeline's registry needs) and the command that puts the
4
+ // published package in the install again. Nothing here knows npm, git hosting or any pipeline: the steps are the profile's data, the tokens are {node} {repo} {worktree} {install} {run} {package}
5
+ // {version} {tag}, and the operator's launch setting (profile settings.release = yes) is the standing yes — without it nothing runs and nothing is written. It runs once, at the END of a leg, for
6
+ // every repair of this run not yet released; stops at the first failing step (no rollback — it says how far it got); and never runs on a dirty checkout or an install that is not at HEAD.
7
+ // release({ profile, runDir, outDir, install, values, runId, deps? }) → { released, why?, stopped?, version?, tag? }
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { spawnSync } = require('child_process');
11
+ const { settings, open } = require('./state.js');
12
+ const { installedMismatches, checkComponent } = require('./repair.js');
13
+
14
+ const git = (repo, ...a) => spawnSync('git', ['-C', repo, ...a], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
15
+ const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
16
+ const sleep = (s) => new Promise((r) => setTimeout(r, Math.round(s * 1000)));
17
+
18
+ function fill(words, tokens) { return words.map((w) => w.replace(/\{([a-zA-Z]+)\}/g, (m, k) => (k in tokens ? tokens[k] : m))); }
19
+ const tail = (r) => `${r.stderr || ''}${r.stdout || ''}`.trim().slice(-400);
20
+
21
+ async function release({ profile, runDir, outDir, install, values, runId, deps }) {
22
+ const rc = profile.release; const cfg = settings(values, profile);
23
+ if (!rc || !cfg.release) return { released: false, why: 'the release is off' };
24
+ const alert = (text) => { try { fs.mkdirSync(outDir, { recursive: true }); fs.appendFileSync(path.join(outDir, profile.logs.alerts), `${new Date().toISOString()} DOCTOR ${text}\n`); } catch { /* the doctor's own log has it */ } };
25
+ if (!cfg.on) { alert(`release not run: ${cfg.problem || 'the doctor is off'}`); return { released: false, why: cfg.problem || 'the doctor is off' }; }
26
+ const d = open(runDir, cfg, profile);
27
+ const skip = (why) => { d.event('release-skipped', { why }); alert(`release skipped: ${why}`); return { released: false, why }; };
28
+
29
+ const recFile = path.join(install, profile.install.dest, profile.install.patchRecord);
30
+ const rec = readJson(recFile, null);
31
+ const mine = ((rec && rec.patches) || []).filter((p) => p.run === runId && !p.released);
32
+ if (!mine.length) return { released: false, why: 'nothing to release: no repair of this run in the install, or all are released already' };
33
+
34
+ const comp = checkComponent({ repo: cfg.repo, profile, protectedRepos: [] });
35
+ if (!comp.ok) return skip(comp.why);
36
+ const repo = comp.root;
37
+ if (git(repo, 'status', '--porcelain', '--untracked-files=no').stdout.trim()) return skip('the repository has uncommitted changes — a release needs a clean checkout');
38
+ const stale = installedMismatches({ repo, branch: 'HEAD', install, profile });
39
+ if (stale.length) return skip(`the install is not at the repository's HEAD (${stale.slice(0, 4).join(', ')}) — the repairs were not promoted`);
40
+ for (const p of mine) if (git(repo, 'merge-base', '--is-ancestor', p.commit, 'HEAD').status !== 0) return skip(`the repair ${String(p.commit).slice(0, 9)} is not part of HEAD`);
41
+
42
+ const version = () => { const j = readJson(path.join(repo, rc.versionFile), {}); return String(j.version || ''); };
43
+ const tokens = () => { const v = version(); return { node: process.execPath, repo, worktree: repo, install, run: runId, package: profile.component.package, version: v, tag: rc.tag.replace('{version}', v) }; };
44
+ const done = [];
45
+ const run = (words) => { const c = fill(words, tokens()); return (deps && deps.spawn ? deps.spawn : spawnSync)(c[0], c.slice(1), { cwd: repo, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); };
46
+ const stop = (step, r) => {
47
+ d.event('release-failed', { step, status: r.status, tail: tail(r), done });
48
+ alert(`release stopped at ${step} (exit ${r.status}): ${tail(r).slice(-200)} — done so far: ${done.length ? done.join(', ') : 'nothing'}; the repository is at ${git(repo, 'rev-parse', '--short', 'HEAD').stdout.trim()}; nothing is rolled back`);
49
+ return { released: false, stopped: step, why: `${step} failed`, done };
50
+ };
51
+ d.event('release-start', { repairs: mine.map((p) => p.commit), from: version() });
52
+ for (const st of rc.steps) {
53
+ const r = run(st.command);
54
+ if (r.status !== 0) return stop(st.name, r);
55
+ done.push(st.name);
56
+ }
57
+ const ver = version(); const tag = tokens().tag;
58
+ let last = null;
59
+ for (let n = 0; n <= rc.reinstall.retries; n += 1) {
60
+ last = run(rc.reinstall.command);
61
+ if (last.status === 0) break;
62
+ if (n < rc.reinstall.retries) await sleep(rc.reinstall.delaySeconds);
63
+ }
64
+ if (!last || last.status !== 0) return stop('reinstall', last || { status: null, stdout: '', stderr: 'not run' });
65
+ done.push('reinstall');
66
+ const left = installedMismatches({ repo, branch: 'HEAD', install, profile });
67
+ if (left.length) { d.event('release-failed', { step: 'verify', mismatches: left, done }); alert(`release stopped at verify: the install differs from HEAD after the reinstall (${left.slice(0, 4).join(', ')})`); return { released: false, stopped: 'verify', why: 'the install differs from HEAD after the reinstall', done }; }
68
+
69
+ const at = new Date().toISOString();
70
+ const fresh = readJson(recFile, rec) || rec;
71
+ fresh.patches = (fresh.patches || []).map((p) => (p.run === runId && !p.released ? { ...p, released: { version: ver, tag, at } } : p));
72
+ fs.writeFileSync(recFile, `${JSON.stringify(fresh, null, 2)}\n`);
73
+ d.event('released', { version: ver, tag, repairs: mine.length, done });
74
+ alert(`released ${profile.component.package}@${ver} (${tag}): ${mine.length} repair(s) of ${runId} — ${done.join(' → ')}; the install is the published package`);
75
+ return { released: true, version: ver, tag, done };
76
+ }
77
+
78
+ module.exports = { release };
@@ -163,4 +163,19 @@ function repoChanges(before, after) {
163
163
  return out;
164
164
  }
165
165
 
166
- module.exports = { verifyRepair, reinstall, installedMismatches, packaged, checkComponent, snapshotRepos, repoChanges, expand, hostWrap };
166
+ /**
167
+ * PROMOTE (operator, 2026-10-20): after a verified repair is installed, the repository's main line is fast-forwarded to the repair commit, so HEAD equals what is installed — without it the next
168
+ * repair is refused (repo-not-installed). Local and reversible; never forced: a detached HEAD, uncommitted changes, or a main line that moved since the repair branched are said and left alone.
169
+ */
170
+ function promote({ repo, commit }) {
171
+ const cur = git(repo, 'symbolic-ref', '-q', '--short', 'HEAD');
172
+ if (cur.status !== 0) return bad('the repository is on a detached HEAD — there is no main line to move');
173
+ if (git(repo, 'status', '--porcelain', '--untracked-files=no').stdout.trim()) return bad('the repository has uncommitted changes — a fast-forward would be merged over them');
174
+ const from = git(repo, 'rev-parse', 'HEAD').stdout.trim();
175
+ if (git(repo, 'merge-base', '--is-ancestor', 'HEAD', commit).status !== 0) return bad(`the main line (${cur.stdout.trim()}) moved since the repair branched — it is not an ancestor of the repair, so no fast-forward is possible`);
176
+ const m = git(repo, 'merge', '--ff-only', '-q', commit);
177
+ if (m.status !== 0) return bad(`the fast-forward failed: ${(m.stderr || m.stdout).trim().slice(-300)}`);
178
+ return { ok: true, from, to: commit, branch: cur.stdout.trim() };
179
+ }
180
+
181
+ module.exports = { promote, verifyRepair, reinstall, installedMismatches, packaged, checkComponent, snapshotRepos, repoChanges, expand, hostWrap };
@@ -17,7 +17,7 @@ function settings(values, profile) {
17
17
  const wants = v[n.on] === 'on';
18
18
  const lacks = [!repo && `${n.repo} — the checkout of ${profile.component.package} it repairs`, !budget && `${n.budget} — a spending cap in dollars for one repair session (a positive number)`].filter(Boolean);
19
19
  const problem = wants && lacks.length ? `${n.on}=on needs ${lacks.join(' and ')}` : null;
20
- return { on: wants && !!repo && !!budget, restarts, repo, budget, problem };
20
+ return { on: wants && !!repo && !!budget, restarts, repo, budget, problem, release: !!n.release && v[n.release] === 'yes' };
21
21
  }
22
22
 
23
23
  const readJson = (f, dflt) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return dflt; } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amsd-pipeline-shared",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
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": {