@mmerterden/multi-agent-pipeline 20.23.2 → 20.24.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/CHANGELOG.md +22 -0
- package/README.md +7 -6
- package/README.tr.md +6 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +3 -3
- package/install/catalog-history.json +1 -1
- package/install/templates/claude-hooks.json +14 -1
- package/manifest.json +75 -54
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/postmortem/SKILL.md +78 -0
- package/pipeline/commands/multi-agent/setup/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/lib/analysis-jira-write.sh +70 -0
- package/pipeline/lib/decisions.mjs +130 -0
- package/pipeline/lib/plan-todos.sh +156 -33
- package/pipeline/lib/task-graph.mjs +446 -0
- package/pipeline/lib/tdd-evidence.mjs +579 -0
- package/pipeline/multi-agent-refs/analysis-template-corporate.md +2 -0
- package/pipeline/multi-agent-refs/analysis-template.md +4 -2
- package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -4
- package/pipeline/multi-agent-refs/features/analysis-jira.md +42 -0
- package/pipeline/multi-agent-refs/features/autopilot-decisions.md +34 -0
- package/pipeline/multi-agent-refs/features/model-fallback.md +4 -2
- package/pipeline/multi-agent-refs/features/mutation-probe.md +58 -0
- package/pipeline/multi-agent-refs/features/plan-checks.md +67 -0
- package/pipeline/multi-agent-refs/features/plan-todos.md +40 -1
- package/pipeline/multi-agent-refs/features/rework-escalation.md +49 -0
- package/pipeline/multi-agent-refs/features/scope-check.md +1 -0
- package/pipeline/multi-agent-refs/help/en.md +1 -0
- package/pipeline/multi-agent-refs/help/tr.md +1 -0
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -3
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +5 -7
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +13 -11
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +3 -3
- package/pipeline/multi-agent-refs/picker-contract.md +3 -2
- package/pipeline/multi-agent-refs/response-shape.md +65 -0
- package/pipeline/rules/response-shape.md +18 -0
- package/pipeline/schemas/agent-state.schema.json +31 -1
- package/pipeline/schemas/plan-todos.schema.json +101 -0
- package/pipeline/schemas/planning-output.schema.json +46 -0
- package/pipeline/schemas/prefs.schema.json +96 -0
- package/pipeline/scripts/_jsonl.mjs +151 -0
- package/pipeline/scripts/_plan-overlap.mjs +231 -0
- package/pipeline/scripts/_redact.mjs +124 -10
- package/pipeline/scripts/analysis-story-tree.mjs +153 -8
- package/pipeline/scripts/autopilot-publish.mjs +12 -1
- package/pipeline/scripts/cost-analyze.mjs +6 -24
- package/pipeline/scripts/decisions-log.mjs +118 -0
- package/pipeline/scripts/edit-syntax-gate.mjs +240 -0
- package/pipeline/scripts/edit-syntax-gate.sh +29 -0
- package/pipeline/scripts/find-polluter.mjs +281 -0
- package/pipeline/scripts/gate-ledger.mjs +46 -13
- package/pipeline/scripts/mutation-probe.mjs +484 -0
- package/pipeline/scripts/plan-complexity.mjs +259 -0
- package/pipeline/scripts/review-decision-gate.mjs +33 -1
- package/pipeline/scripts/rework-escalation.mjs +264 -0
- package/pipeline/scripts/test-strength.mjs +5 -3
- package/pipeline/scripts/transcript-index.mjs +950 -0
- package/pipeline/scripts/validate-planning.mjs +48 -6
- package/pipeline/skills/.skill-manifest.json +11 -7
- package/pipeline/skills/.skills-index.json +12 -1
- package/pipeline/skills/shared/README.md +4 -3
- package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +4 -2
- package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +66 -0
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +15 -0
- package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +13 -0
- package/pipeline/skills/shared/external/debugging-strategies/SKILL.md +8 -0
- package/pipeline/skills/skills-index.md +2 -1
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* find-polluter.mjs - which test leaves state behind that something else trips on?
|
|
4
|
+
*
|
|
5
|
+
* Two symptoms, one search:
|
|
6
|
+
*
|
|
7
|
+
* --absent <path> a file or directory appears after the suite runs and no
|
|
8
|
+
* test should leave it behind
|
|
9
|
+
* --target <id> a test that passes on its own fails after some others
|
|
10
|
+
*
|
|
11
|
+
* Given the candidate tests in suite order and a command that runs one of them,
|
|
12
|
+
* it bisects the list: run half of the candidates from a clean state, check the
|
|
13
|
+
* symptom, keep the half that reproduces it. When neither half does on its own,
|
|
14
|
+
* the pollution needs a test from each, and both halves are searched with the
|
|
15
|
+
* other held in place, so the answer is the smallest set that still reproduces
|
|
16
|
+
* it (usually one test, sometimes a pair). Each trial costs at most the tests in
|
|
17
|
+
* that half; a single polluter among n tests is found in about 2*log2(n) trials.
|
|
18
|
+
*
|
|
19
|
+
* Clean state between trials: with --absent the path is removed (only a path
|
|
20
|
+
* under --cwd whose parent, symlinks resolved, is under --cwd too, and only
|
|
21
|
+
* one that did not exist when the search started; one that already exists,
|
|
22
|
+
* a dangling symlink included, is refused rather than deleted). --reset "<cmd>" runs
|
|
23
|
+
* before every trial for anything else the tests mutate. Without a reset,
|
|
24
|
+
* --target assumes each trial starts clean on its own, and checks it: when the
|
|
25
|
+
* target fails alone after the full-list trial, the search stops with exit 2
|
|
26
|
+
* rather than blaming whichever test ran first.
|
|
27
|
+
*
|
|
28
|
+
* Usage:
|
|
29
|
+
* node find-polluter.mjs --run "<command with {test}>" (--list <file> | --test <id>...)
|
|
30
|
+
* (--absent <path> | --target <id>) [--reset "<cmd>"] [--cwd <dir>]
|
|
31
|
+
* [--timeout-sec n] [--json]
|
|
32
|
+
*
|
|
33
|
+
* {test} is replaced by the shell-quoted test id; a command without it gets
|
|
34
|
+
* the id appended. --list holds one id per line (blank and # lines skipped).
|
|
35
|
+
* Commands run through /bin/sh from --cwd (default: the current directory).
|
|
36
|
+
*
|
|
37
|
+
* Exit: 0 one polluter found · 4 only a combination reproduces it (all named) ·
|
|
38
|
+
* 3 the full list does not reproduce the symptom · 2 usage or setup error
|
|
39
|
+
*
|
|
40
|
+
* Zero runtime dependencies.
|
|
41
|
+
*
|
|
42
|
+
* @module pipeline/scripts/find-polluter
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { spawnSync } from "node:child_process";
|
|
46
|
+
import { existsSync, lstatSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
47
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
48
|
+
|
|
49
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
50
|
+
|
|
51
|
+
const DEFAULT_TIMEOUT_SEC = 300;
|
|
52
|
+
|
|
53
|
+
class UsageError extends Error {}
|
|
54
|
+
|
|
55
|
+
const shellQuote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
56
|
+
|
|
57
|
+
/** Fill {test}; append the id when the command names none. */
|
|
58
|
+
export function renderRun(template, id) {
|
|
59
|
+
if (!template.includes("{test}")) return `${template} ${shellQuote(id)}`;
|
|
60
|
+
return template.replaceAll("{test}", shellQuote(id));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Smallest subset of `items` for which `pollutes` holds, found by halving.
|
|
65
|
+
* The caller has already seen `pollutes(items)` hold. Subsets are passed in
|
|
66
|
+
* the original order.
|
|
67
|
+
*
|
|
68
|
+
* @template T
|
|
69
|
+
* @param {T[]} items
|
|
70
|
+
* @param {(subset: T[]) => boolean} pollutes
|
|
71
|
+
* @returns {{culprits: T[], trials: number}}
|
|
72
|
+
*/
|
|
73
|
+
export function bisect(items, pollutes) {
|
|
74
|
+
let trials = 0;
|
|
75
|
+
const order = (idx) => [...new Set(idx)].sort((a, b) => a - b);
|
|
76
|
+
const test = (idx) => {
|
|
77
|
+
trials++;
|
|
78
|
+
return pollutes(order(idx).map((i) => items[i]));
|
|
79
|
+
};
|
|
80
|
+
const search = (cands, context) => {
|
|
81
|
+
if (cands.length <= 1) return cands;
|
|
82
|
+
const mid = Math.ceil(cands.length / 2);
|
|
83
|
+
const a = cands.slice(0, mid);
|
|
84
|
+
const b = cands.slice(mid);
|
|
85
|
+
if (test([...context, ...a])) return search(a, context);
|
|
86
|
+
if (test([...context, ...b])) return search(b, context);
|
|
87
|
+
const fromA = search(a, [...context, ...b]);
|
|
88
|
+
return [...fromA, ...search(b, [...context, ...fromA])];
|
|
89
|
+
};
|
|
90
|
+
const found = search(
|
|
91
|
+
items.map((_, i) => i),
|
|
92
|
+
[],
|
|
93
|
+
);
|
|
94
|
+
return { culprits: order(found).map((i) => items[i]), trials };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseArgs(argv) {
|
|
98
|
+
const opts = { tests: [], timeoutSec: DEFAULT_TIMEOUT_SEC };
|
|
99
|
+
for (let i = 0; i < argv.length; i++) {
|
|
100
|
+
const a = argv[i];
|
|
101
|
+
const val = () => {
|
|
102
|
+
const v = argv[++i];
|
|
103
|
+
if (v === undefined) throw new UsageError(`${a} needs a value`);
|
|
104
|
+
return v;
|
|
105
|
+
};
|
|
106
|
+
if (a === "--run") opts.run = val();
|
|
107
|
+
else if (a === "--list") opts.list = val();
|
|
108
|
+
else if (a === "--test") opts.tests.push(val());
|
|
109
|
+
else if (a === "--absent") opts.absent = val();
|
|
110
|
+
else if (a === "--target") opts.target = val();
|
|
111
|
+
else if (a === "--reset") opts.reset = val();
|
|
112
|
+
else if (a === "--cwd") opts.cwd = val();
|
|
113
|
+
else if (a === "--timeout-sec") opts.timeoutSec = Number(val());
|
|
114
|
+
else if (a === "--json") opts.json = true;
|
|
115
|
+
else throw new UsageError(`unexpected argument ${a}`);
|
|
116
|
+
}
|
|
117
|
+
if (!opts.run) throw new UsageError("--run is required");
|
|
118
|
+
if (Boolean(opts.absent) === Boolean(opts.target)) {
|
|
119
|
+
throw new UsageError("exactly one of --absent or --target is required");
|
|
120
|
+
}
|
|
121
|
+
if (!(opts.timeoutSec > 0)) throw new UsageError("--timeout-sec must be a positive number");
|
|
122
|
+
if (opts.list) {
|
|
123
|
+
let text;
|
|
124
|
+
try {
|
|
125
|
+
text = readFileSync(opts.list, "utf8");
|
|
126
|
+
} catch (err) {
|
|
127
|
+
throw new UsageError(`cannot read --list: ${err.message}`);
|
|
128
|
+
}
|
|
129
|
+
for (const line of text.split("\n")) {
|
|
130
|
+
const id = line.trim();
|
|
131
|
+
if (id && !id.startsWith("#")) opts.tests.push(id);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
opts.tests = opts.tests.filter((t) => t !== opts.target);
|
|
135
|
+
if (opts.tests.length === 0) throw new UsageError("no candidate tests");
|
|
136
|
+
return opts;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const inside = (child, parent) => {
|
|
140
|
+
const rel = relative(parent, child);
|
|
141
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel) && !rel.split(sep).includes("..");
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** True when something, a dangling symlink included, sits at `path`. */
|
|
145
|
+
function present(path) {
|
|
146
|
+
try {
|
|
147
|
+
lstatSync(path);
|
|
148
|
+
return true;
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* True when the nearest existing ancestor of `path`, with symlinks resolved,
|
|
156
|
+
* is `cwd` or lies under it, so removing `path` cannot reach outside.
|
|
157
|
+
*/
|
|
158
|
+
function parentInside(path, cwd) {
|
|
159
|
+
let dir = dirname(path);
|
|
160
|
+
while (!existsSync(dir)) {
|
|
161
|
+
const up = dirname(dir);
|
|
162
|
+
if (up === dir) return false;
|
|
163
|
+
dir = up;
|
|
164
|
+
}
|
|
165
|
+
const real = realpathSync(dir);
|
|
166
|
+
return real === cwd || inside(real, cwd);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function sh(command, cwd, timeoutMs) {
|
|
170
|
+
const { NODE_TEST_CONTEXT: _inherited, ...env } = process.env;
|
|
171
|
+
const r = spawnSync("/bin/sh", ["-c", command], {
|
|
172
|
+
cwd,
|
|
173
|
+
env,
|
|
174
|
+
stdio: "ignore",
|
|
175
|
+
timeout: timeoutMs,
|
|
176
|
+
killSignal: "SIGKILL",
|
|
177
|
+
});
|
|
178
|
+
return r.status === 0 && !r.error;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @returns {{exit: number, report: object}}
|
|
183
|
+
*/
|
|
184
|
+
export function findPolluter(argv) {
|
|
185
|
+
const opts = parseArgs(argv);
|
|
186
|
+
const cwd = realpathSync(resolve(opts.cwd || process.cwd()));
|
|
187
|
+
const timeoutMs = Math.round(opts.timeoutSec * 1000);
|
|
188
|
+
const runTest = (id) => sh(renderRun(opts.run, id), cwd, timeoutMs);
|
|
189
|
+
|
|
190
|
+
let stray = null;
|
|
191
|
+
if (opts.absent) {
|
|
192
|
+
stray = resolve(cwd, opts.absent);
|
|
193
|
+
if (!inside(stray, cwd) || !parentInside(stray, cwd)) {
|
|
194
|
+
throw new UsageError(`--absent ${opts.absent} is not under --cwd ${cwd}`);
|
|
195
|
+
}
|
|
196
|
+
if (present(stray)) {
|
|
197
|
+
throw new UsageError(`${opts.absent} already exists; remove it before searching`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let runs = 0;
|
|
202
|
+
const reset = () => {
|
|
203
|
+
if (stray && parentInside(stray, cwd)) rmSync(stray, { recursive: true, force: true });
|
|
204
|
+
if (opts.reset) sh(opts.reset, cwd, timeoutMs);
|
|
205
|
+
};
|
|
206
|
+
const pollutes = (subset) => {
|
|
207
|
+
reset();
|
|
208
|
+
for (const id of subset) {
|
|
209
|
+
runTest(id);
|
|
210
|
+
runs++;
|
|
211
|
+
}
|
|
212
|
+
if (stray) return present(stray);
|
|
213
|
+
runs++;
|
|
214
|
+
return !runTest(opts.target);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const report = {
|
|
218
|
+
symptom: stray ? { absent: opts.absent } : { target: opts.target },
|
|
219
|
+
candidates: opts.tests.length,
|
|
220
|
+
polluters: [],
|
|
221
|
+
alone: null,
|
|
222
|
+
trials: 0,
|
|
223
|
+
runs: 0,
|
|
224
|
+
};
|
|
225
|
+
try {
|
|
226
|
+
if (!stray) {
|
|
227
|
+
reset();
|
|
228
|
+
runs++;
|
|
229
|
+
if (!runTest(opts.target)) throw new UsageError(`${opts.target} fails on its own`);
|
|
230
|
+
}
|
|
231
|
+
if (!pollutes(opts.tests)) {
|
|
232
|
+
report.reason = "the full list does not reproduce the symptom";
|
|
233
|
+
return { exit: 3, report };
|
|
234
|
+
}
|
|
235
|
+
if (!stray && !opts.reset) {
|
|
236
|
+
runs++;
|
|
237
|
+
if (!runTest(opts.target)) {
|
|
238
|
+
throw new UsageError(
|
|
239
|
+
`${opts.target} now fails on its own: state persists between trials, pass --reset "<cmd>" to restore it`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const { culprits, trials } = bisect(opts.tests, pollutes);
|
|
244
|
+
report.polluters = culprits;
|
|
245
|
+
report.trials = trials;
|
|
246
|
+
report.alone = culprits.length === 1 ? pollutes(culprits) : false;
|
|
247
|
+
return { exit: culprits.length === 1 ? 0 : 4, report };
|
|
248
|
+
} finally {
|
|
249
|
+
reset();
|
|
250
|
+
report.runs = runs;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function main(argv) {
|
|
255
|
+
let result;
|
|
256
|
+
try {
|
|
257
|
+
result = findPolluter(argv);
|
|
258
|
+
} catch (err) {
|
|
259
|
+
process.stderr.write(`find-polluter: ${err.message}\n`);
|
|
260
|
+
if (err instanceof UsageError) {
|
|
261
|
+
process.stderr.write(
|
|
262
|
+
'usage: find-polluter.mjs --run "<cmd with {test}>" (--list file | --test id...) (--absent path | --target id) [--reset "<cmd>"] [--cwd dir] [--timeout-sec n] [--json]\n',
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return 2;
|
|
266
|
+
}
|
|
267
|
+
const { exit, report } = result;
|
|
268
|
+
if (argv.includes("--json")) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
269
|
+
else if (report.polluters.length) {
|
|
270
|
+
const how = report.alone ? "on its own" : "together";
|
|
271
|
+
process.stdout.write(`polluter (${how}): ${report.polluters.join(", ")}\n`);
|
|
272
|
+
process.stdout.write(`${report.trials} trials, ${report.runs} test runs\n`);
|
|
273
|
+
} else {
|
|
274
|
+
process.stdout.write(`${report.reason}\n`);
|
|
275
|
+
}
|
|
276
|
+
return exit;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (invokedDirectly(import.meta.url)) {
|
|
280
|
+
process.exit(main(process.argv.slice(2)));
|
|
281
|
+
}
|
|
@@ -52,6 +52,7 @@ import { spawnSync } from "node:child_process";
|
|
|
52
52
|
import { existsSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
53
53
|
import { basename, dirname } from "node:path";
|
|
54
54
|
|
|
55
|
+
import { decisionEntry, withDecision } from "../lib/decisions.mjs";
|
|
55
56
|
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
56
57
|
import { readRegularFile, readRegularJson } from "../lib/regular-file.mjs";
|
|
57
58
|
import { attendedVerdict, gatesActive } from "../lib/unattended.mjs";
|
|
@@ -316,6 +317,9 @@ export async function recordQuietly(gate, verdict, detail, opts) {
|
|
|
316
317
|
* Park an unattended run for a person. The runner reads `waitingFor` as
|
|
317
318
|
* awaiting-answer and `verificationFailed` as verification-failed.
|
|
318
319
|
*
|
|
320
|
+
* Parking is the run deciding not to go on, so the same write appends the
|
|
321
|
+
* entry to `decisions[]` that the Phase 5 report and the PR body list.
|
|
322
|
+
*
|
|
319
323
|
* @param {string} path
|
|
320
324
|
* @param {{outcome: "awaiting-answer"|"verification-failed", waitingFor?: string,
|
|
321
325
|
* gate?: string, reason?: string}} opts
|
|
@@ -327,26 +331,55 @@ export async function park(path, { outcome, waitingFor, gate, reason = "", pendi
|
|
|
327
331
|
if (waitingFor === "question" && !pendingQuestion) {
|
|
328
332
|
throw new LedgerError("usage", "waitingFor question needs a pendingQuestion to answer");
|
|
329
333
|
}
|
|
330
|
-
return updateState(path, (s) =>
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
334
|
+
return updateState(path, (s) =>
|
|
335
|
+
withDecision(
|
|
336
|
+
{
|
|
337
|
+
...s,
|
|
338
|
+
waitingFor,
|
|
339
|
+
...(pendingQuestion ? { pendingQuestion } : {}),
|
|
340
|
+
status: "awaiting_input",
|
|
341
|
+
haltReason: reason || `waiting for ${waitingFor}`,
|
|
342
|
+
},
|
|
343
|
+
decisionEntry({
|
|
344
|
+
what: `Parked the run to wait for ${waitingFor}`,
|
|
345
|
+
why: reason || `waiting for ${waitingFor}`,
|
|
346
|
+
costIfWrong:
|
|
347
|
+
"the run stops until someone answers, even if the default would have been fine",
|
|
348
|
+
phase: phaseOf(s),
|
|
349
|
+
at,
|
|
350
|
+
}),
|
|
351
|
+
),
|
|
352
|
+
);
|
|
337
353
|
}
|
|
338
354
|
if (outcome === "verification-failed") {
|
|
339
355
|
if (!gate) throw new LedgerError("usage", "verification-failed needs --gate");
|
|
340
|
-
return updateState(path, (s) =>
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
356
|
+
return updateState(path, (s) =>
|
|
357
|
+
withDecision(
|
|
358
|
+
{
|
|
359
|
+
...s,
|
|
360
|
+
status: "failed",
|
|
361
|
+
verificationFailed: { gate, reason, at },
|
|
362
|
+
haltReason: `verification failed at ${gate}: ${reason}`,
|
|
363
|
+
},
|
|
364
|
+
decisionEntry({
|
|
365
|
+
what: `Parked the run: ${gate} rejected the work`,
|
|
366
|
+
why: reason || `${gate} failed`,
|
|
367
|
+
costIfWrong: "work that would have passed is held back until someone resumes it",
|
|
368
|
+
phase: phaseOf(s),
|
|
369
|
+
at,
|
|
370
|
+
}),
|
|
371
|
+
),
|
|
372
|
+
);
|
|
346
373
|
}
|
|
347
374
|
throw new LedgerError("usage", `unknown outcome ${outcome}`);
|
|
348
375
|
}
|
|
349
376
|
|
|
377
|
+
/** The run's current phase, held to the 0..5 range a decision entry takes. */
|
|
378
|
+
function phaseOf(state) {
|
|
379
|
+
const n = Number(state?.currentPhase);
|
|
380
|
+
return Number.isInteger(n) ? Math.min(5, Math.max(0, n)) : 0;
|
|
381
|
+
}
|
|
382
|
+
|
|
350
383
|
/**
|
|
351
384
|
* Latest entry per gate for one sha. "Latest" is by `at`, then by position.
|
|
352
385
|
*/
|