@mjasnikovs/pi-task 0.38.5 → 0.38.6
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/dist/task/accept-debt.js +16 -1
- package/dist/task/clamp-output.d.ts +1 -0
- package/dist/task/clamp-output.js +15 -0
- package/dist/task/final-gate-fix.d.ts +10 -0
- package/dist/task/final-gate-fix.js +2 -1
- package/dist/task/final-gate-progress.d.ts +27 -0
- package/dist/task/final-gate-progress.js +21 -0
- package/dist/task/final-gate.d.ts +22 -0
- package/dist/task/final-gate.js +25 -3
- package/dist/task/render-check.d.ts +31 -0
- package/dist/task/render-check.js +77 -1
- package/dist/task/run-final-gate.js +10 -1
- package/dist/task/task-gates.js +3 -12
- package/dist/task/unfailable-command.d.ts +56 -0
- package/dist/task/unfailable-command.js +263 -0
- package/package.json +1 -1
package/dist/task/accept-debt.js
CHANGED
|
@@ -29,6 +29,7 @@ import * as fsp from 'node:fs/promises';
|
|
|
29
29
|
import * as path from 'node:path';
|
|
30
30
|
import { parseVerifyBlockStrict } from './spec-validation.js';
|
|
31
31
|
import { taskFilePath, tasksDir } from './task-io.js';
|
|
32
|
+
import { isUnfailableCommand } from './unfailable-command.js';
|
|
32
33
|
const ACCEPT_DEBT_FILE = 'accept-debt.md';
|
|
33
34
|
/** Cap kept records so a run that accepts many FAILs cannot grow the report unboundedly. */
|
|
34
35
|
const MAX_DEBTS = 60;
|
|
@@ -264,9 +265,23 @@ export function isStaticClassDebt(reason) {
|
|
|
264
265
|
* would split it into two, and an over-long line is a heredoc/prose artefact rather
|
|
265
266
|
* than a command. Any of those ⇒ not stored ⇒ the debt is simply unclassified, i.e.
|
|
266
267
|
* exactly as un-closable as it is today.
|
|
268
|
+
*
|
|
269
|
+
* …and one more condition (nexttask 19C): a command whose EXIT STATUS IS DESTROYED
|
|
270
|
+
* BY ITS OWN CONSTRUCTION is not storable either. The whole auto-close rests on a
|
|
271
|
+
* ZERO exit meaning "the check passed" (see recheckAcceptDebts below), and 16 of
|
|
272
|
+
* the 612 store-eligible VERIFY lines on this box exit zero whatever the tree
|
|
273
|
+
* contains — 12 of them in IAR1, a CMake/C++ OBS plugin with no database, no
|
|
274
|
+
* frontend and no HTTP server, where one task is seven consecutive
|
|
275
|
+
* `test -f … && echo "PASS" || echo "FAIL"` lines standing in for a build check.
|
|
276
|
+
* Refusing to store one leaves the debt OPEN and surfaced, which is strictly the
|
|
277
|
+
* smaller claim. See unfailable-command.ts for what is and is not decidable, and
|
|
278
|
+
* why bare `|| true` stays out of scope.
|
|
267
279
|
*/
|
|
268
280
|
function isStorableCommand(cmd) {
|
|
269
|
-
return cmd.length > 0
|
|
281
|
+
return (cmd.length > 0
|
|
282
|
+
&& cmd.length <= MAX_REASON_LENGTH
|
|
283
|
+
&& !/[\t\n\r]/.test(cmd)
|
|
284
|
+
&& !isUnfailableCommand(cmd));
|
|
270
285
|
}
|
|
271
286
|
/**
|
|
272
287
|
* Read the owning task's spec and return the VERIFY command its FAIL reason names,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function clampOutput(output: string): string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* clamp-output — the ONE trail-side ceiling for captured tool output.
|
|
3
|
+
*
|
|
4
|
+
* `appendGateRecord` flattens newlines to spaces, so the gate trail stays one line
|
|
5
|
+
* per entry; this caps the volume, because a wedged tool can emit megabytes.
|
|
6
|
+
* Extracted from task-gates.ts so every probe that carries evidence into a failure
|
|
7
|
+
* detail clamps the SAME way: a second clamp with a second ceiling is how two
|
|
8
|
+
* trails start disagreeing about what was captured.
|
|
9
|
+
*/
|
|
10
|
+
const TRAIL_OUTPUT_MAX_CHARS = 1200;
|
|
11
|
+
export function clampOutput(output) {
|
|
12
|
+
return output.length > TRAIL_OUTPUT_MAX_CHARS ?
|
|
13
|
+
`${output.slice(0, TRAIL_OUTPUT_MAX_CHARS)}…`
|
|
14
|
+
: output;
|
|
15
|
+
}
|
|
@@ -103,6 +103,12 @@ export interface FinalFixResult {
|
|
|
103
103
|
/** The fresh gate's individual ranked failures (see FinalGateOutcome.failures),
|
|
104
104
|
* so the caller can trail each entry — never just the first. */
|
|
105
105
|
gateFailures?: string[];
|
|
106
|
+
/** …and which of them a PROBE returned after OBSERVING (see
|
|
107
|
+
* FinalGateOutcome.observedFailures). Carried so the caller's non-progress
|
|
108
|
+
* classifier can ask the probe's own verdict instead of guessing from the
|
|
109
|
+
* failure text — a check that was observed to FAIL is never demote-eligible
|
|
110
|
+
* (nexttask 19A). */
|
|
111
|
+
gateObservedFailures?: string[];
|
|
106
112
|
/** On a converged outcome: the re-run gate's UNOBSERVED note, if it observed
|
|
107
113
|
* nothing dynamic (see FinalGateOutcome.unobserved). Carried so the caller
|
|
108
114
|
* labels a converge-on-statics-alone the same way it labels a first-pass one —
|
|
@@ -139,6 +145,10 @@ export interface FinalFixDeps {
|
|
|
139
145
|
ok: boolean;
|
|
140
146
|
reason: string;
|
|
141
147
|
failures?: string[];
|
|
148
|
+
/** Which of them a probe returned after OBSERVING (nexttask 19A) — carried
|
|
149
|
+
* through so the caller's demote decision can ask the probe's own verdict
|
|
150
|
+
* instead of re-deriving observability from the failure string. */
|
|
151
|
+
observedFailures?: string[];
|
|
142
152
|
unobserved?: string;
|
|
143
153
|
}>;
|
|
144
154
|
/** Labels of every currently-discoverable gate command (static + integration),
|
|
@@ -338,7 +338,8 @@ export async function runFinalGateAutofix(deps) {
|
|
|
338
338
|
ok: false,
|
|
339
339
|
reason: `did not converge: ${fin.reason}`,
|
|
340
340
|
gateReason: fin.reason,
|
|
341
|
-
gateFailures: fin.failures
|
|
341
|
+
gateFailures: fin.failures,
|
|
342
|
+
...(fin.observedFailures ? { gateObservedFailures: fin.observedFailures } : {})
|
|
342
343
|
});
|
|
343
344
|
}
|
|
344
345
|
// IGNORED-DEPENDENCY DOWNGRADE (mx5 run 19). The gate says PASS; the question
|
|
@@ -50,11 +50,38 @@ export interface NonProgressInput {
|
|
|
50
50
|
/** Did this attempt actually change the tree AND survive the guards? Only an
|
|
51
51
|
* attempt that edited and re-tested says anything about falsifiability. */
|
|
52
52
|
edited: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Did a PROBE return this failure after OBSERVING? (nexttask 19A.) Read off
|
|
55
|
+
* `FinalGateOutcome.observedFailures` by exact text identity — never re-derived
|
|
56
|
+
* from the failure string.
|
|
57
|
+
*
|
|
58
|
+
* True ⇒ never non-progress. See the guard in `isNonProgress` for why.
|
|
59
|
+
*/
|
|
60
|
+
observed?: boolean;
|
|
53
61
|
}
|
|
54
62
|
/**
|
|
55
63
|
* True when this attempt is evidence that the ranked-first check is unfalsifiable
|
|
56
64
|
* here: it edited the tree, the gate re-ran, and returned the same first failure
|
|
57
65
|
* as the previous attempt.
|
|
66
|
+
*
|
|
67
|
+
* …EXCEPT when the probe OBSERVED the failure (nexttask 19A). This rule was built
|
|
68
|
+
* as a blind compensator for a probe limitation — run 14's boot check "could not
|
|
69
|
+
* observe a listener in that sandbox at all" — and that limitation was fixed
|
|
70
|
+
* upstream ELEVEN MINUTES BEFORE this rule landed (`b0f90a7` 23:34:05, `dd3b0c3`
|
|
71
|
+
* 23:45:09, both 2026-07-19). Every probe now reports "I could not look" as its
|
|
72
|
+
* own outcome, with the reason. What was left was a second, blind judgment of the
|
|
73
|
+
* same question, made DOWNSTREAM from the evidence — and its only reachable effect
|
|
74
|
+
* was to overrule a probe that DID look.
|
|
75
|
+
*
|
|
76
|
+
* It has to be overruled, because a deterministic un-fixed defect emits an
|
|
77
|
+
* IDENTICAL failure by definition. String equality therefore reads reproducibility
|
|
78
|
+
* as evidence against the instrument, which is backwards for every project type: a
|
|
79
|
+
* CLI that exits 2 twice, a build that fails on the same symbol twice, a C++ plugin
|
|
80
|
+
* missing the same export twice. mx5 run 21 is the one recorded instance and it
|
|
81
|
+
* released a product whose every page was blank as a `completed` run.
|
|
82
|
+
*
|
|
83
|
+
* The fix is NOT a better string pattern — the bug is that the decision was made
|
|
84
|
+
* downstream from the evidence, not that the pattern was too coarse.
|
|
58
85
|
*/
|
|
59
86
|
export declare function isNonProgress(input: NonProgressInput): boolean;
|
|
60
87
|
/**
|
|
@@ -96,8 +96,29 @@ export function rankedFirstFailure(outcome) {
|
|
|
96
96
|
* True when this attempt is evidence that the ranked-first check is unfalsifiable
|
|
97
97
|
* here: it edited the tree, the gate re-ran, and returned the same first failure
|
|
98
98
|
* as the previous attempt.
|
|
99
|
+
*
|
|
100
|
+
* …EXCEPT when the probe OBSERVED the failure (nexttask 19A). This rule was built
|
|
101
|
+
* as a blind compensator for a probe limitation — run 14's boot check "could not
|
|
102
|
+
* observe a listener in that sandbox at all" — and that limitation was fixed
|
|
103
|
+
* upstream ELEVEN MINUTES BEFORE this rule landed (`b0f90a7` 23:34:05, `dd3b0c3`
|
|
104
|
+
* 23:45:09, both 2026-07-19). Every probe now reports "I could not look" as its
|
|
105
|
+
* own outcome, with the reason. What was left was a second, blind judgment of the
|
|
106
|
+
* same question, made DOWNSTREAM from the evidence — and its only reachable effect
|
|
107
|
+
* was to overrule a probe that DID look.
|
|
108
|
+
*
|
|
109
|
+
* It has to be overruled, because a deterministic un-fixed defect emits an
|
|
110
|
+
* IDENTICAL failure by definition. String equality therefore reads reproducibility
|
|
111
|
+
* as evidence against the instrument, which is backwards for every project type: a
|
|
112
|
+
* CLI that exits 2 twice, a build that fails on the same symbol twice, a C++ plugin
|
|
113
|
+
* missing the same export twice. mx5 run 21 is the one recorded instance and it
|
|
114
|
+
* released a product whose every page was blank as a `completed` run.
|
|
115
|
+
*
|
|
116
|
+
* The fix is NOT a better string pattern — the bug is that the decision was made
|
|
117
|
+
* downstream from the evidence, not that the pattern was too coarse.
|
|
99
118
|
*/
|
|
100
119
|
export function isNonProgress(input) {
|
|
120
|
+
if (input.observed === true)
|
|
121
|
+
return false;
|
|
101
122
|
if (!input.edited)
|
|
102
123
|
return false;
|
|
103
124
|
if (input.previousSignature === null || input.currentDetail === null)
|
|
@@ -27,6 +27,28 @@ export interface FinalGateOutcome {
|
|
|
27
27
|
* each entry and show the full list wherever an ACCEPT decision is made.
|
|
28
28
|
*/
|
|
29
29
|
failures?: string[];
|
|
30
|
+
/**
|
|
31
|
+
* The SUBSET of `failures` that a PROBE returned after actually observing —
|
|
32
|
+
* entries whose evidence is "we looked, and what we saw was bad", as opposed to
|
|
33
|
+
* "we could not look" (nexttask 19A).
|
|
34
|
+
*
|
|
35
|
+
* The probes have always known this about themselves: `RenderOutcome` is
|
|
36
|
+
* `pass | fail | skip`, `f648f5b` (2026-07-14) turned a render `skip` into
|
|
37
|
+
* "render check UNOBSERVED: <reason>", and `b0f90a7` (2026-07-19) made an
|
|
38
|
+
* unenumerable boot return PASS-stamped-UNOBSERVED rather than FAIL. What was
|
|
39
|
+
* missing is that the outcome CLASS never travelled with the failure TEXT, so
|
|
40
|
+
* the non-progress classifier downstream had to guess — and guessed by string
|
|
41
|
+
* equality, which a deterministic un-fixed defect satisfies by definition.
|
|
42
|
+
*
|
|
43
|
+
* mx5 run 21: the render probe FAILED on a blank page, the same failure came
|
|
44
|
+
* back from two tree-changing fix attempts (because the defect was real and
|
|
45
|
+
* unfixed), the classifier read that as evidence against the INSTRUMENT, and
|
|
46
|
+
* the run shipped a product whose every page was blank as `completed`.
|
|
47
|
+
*
|
|
48
|
+
* Membership is by exact text identity with an entry of `failures` — never a
|
|
49
|
+
* pattern, never a re-derivation. Absent/empty on a pass.
|
|
50
|
+
*/
|
|
51
|
+
observedFailures?: string[];
|
|
30
52
|
/**
|
|
31
53
|
* Human-facing suffix listing the still-open accepted-defect claims (see
|
|
32
54
|
* buildAcceptDebtNote) — for the picker question and the trail, NEVER for the
|
package/dist/task/final-gate.js
CHANGED
|
@@ -1427,6 +1427,15 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1427
1427
|
const fail = (text, rank = 1) => {
|
|
1428
1428
|
failures.push({ rank, text });
|
|
1429
1429
|
};
|
|
1430
|
+
/**
|
|
1431
|
+
* A failure a PROBE returned after observing (nexttask 19A — see
|
|
1432
|
+
* FinalGateOutcome.observedFailures). Used by exactly one caller: the boot
|
|
1433
|
+
* section, whose `fail` outcome can only arise from a probe that looked. Every
|
|
1434
|
+
* other `fail()` keeps today's class, so nothing else changes.
|
|
1435
|
+
*/
|
|
1436
|
+
const failObserved = (text, rank = 1) => {
|
|
1437
|
+
failures.push({ rank, text, observed: true });
|
|
1438
|
+
};
|
|
1430
1439
|
if (!stat.ok)
|
|
1431
1440
|
fail(`static checks: ${stat.reason}`);
|
|
1432
1441
|
// Launch-contract diff (mx5 run 10 item 4): the design declared `migrate`/`seed`
|
|
@@ -1669,7 +1678,14 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1669
1678
|
expectServer
|
|
1670
1679
|
});
|
|
1671
1680
|
if (b.outcome === 'fail') {
|
|
1672
|
-
|
|
1681
|
+
// OBSERVED (nexttask 19A). Every path that produces `fail` here is a
|
|
1682
|
+
// probe that looked: the render judge saw an empty body, the deep
|
|
1683
|
+
// session saw the authenticated half dead, the enumerator saw no
|
|
1684
|
+
// listener, or the launch command itself exited non-zero. The one
|
|
1685
|
+
// condition that means "we could not look" — no ss/netstat/lsof, mx5
|
|
1686
|
+
// run 14 — returns PASS stamped UNOBSERVED and never reaches here
|
|
1687
|
+
// (`b0f90a7`, final-gate.ts `if (!canEnumerate) return passAndKill(…)`).
|
|
1688
|
+
failObserved(`boot check: \`${label}\` ${b.detail}`, 0);
|
|
1673
1689
|
}
|
|
1674
1690
|
else if (b.outcome === 'orphan-port') {
|
|
1675
1691
|
// Could not clear the port. Distinct HARNESS diagnosis, never a bare app
|
|
@@ -1723,7 +1739,12 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1723
1739
|
// order. One failure keeps the exact single-failure wording; several become
|
|
1724
1740
|
// a numbered list so the trail, the ACCEPT picker, and the autofix seed all
|
|
1725
1741
|
// carry the complete ranked picture.
|
|
1726
|
-
const
|
|
1742
|
+
const ranked = [...failures].sort((a, b) => a.rank - b.rank);
|
|
1743
|
+
const texts = ranked.map(f => f.text);
|
|
1744
|
+
// The observed subset rides along by exact text identity (19A) — the demote
|
|
1745
|
+
// decision downstream reads THIS, instead of re-deriving observability from
|
|
1746
|
+
// the failure string.
|
|
1747
|
+
const observed = ranked.filter(f => f.observed === true).map(f => f.text);
|
|
1727
1748
|
return withDebts({
|
|
1728
1749
|
ok: false,
|
|
1729
1750
|
reason: texts.length === 1 ?
|
|
@@ -1731,7 +1752,8 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1731
1752
|
: `${texts.length} failures (ranked, most load-bearing first):\n${texts
|
|
1732
1753
|
.map((t, i) => `${i + 1}. ${t}`)
|
|
1733
1754
|
.join('\n')}`,
|
|
1734
|
-
failures: texts
|
|
1755
|
+
failures: texts,
|
|
1756
|
+
...(observed.length > 0 ? { observedFailures: observed } : {})
|
|
1735
1757
|
});
|
|
1736
1758
|
}
|
|
1737
1759
|
const warningNote = warnings.length > 0 ? ` — WARNING: ${warnings.join('; WARNING: ')}` : '';
|
|
@@ -34,6 +34,37 @@ export declare function judgeRenderedDom(html: string): {
|
|
|
34
34
|
ok: boolean;
|
|
35
35
|
detail: string;
|
|
36
36
|
};
|
|
37
|
+
/**
|
|
38
|
+
* The page's console output, as captured while the DOM was being rendered.
|
|
39
|
+
*
|
|
40
|
+
* MEASURED on this box (2026-08-14) against the shipped mx5 run-21 bundle, both
|
|
41
|
+
* binaries the probe can discover:
|
|
42
|
+
*
|
|
43
|
+
* /usr/bin/chromium --dump-dom alone → 0 bytes of stderr
|
|
44
|
+
* + --enable-logging=stderr --v=0 → 2 CONSOLE
|
|
45
|
+
* lines, one of them the cause
|
|
46
|
+
* playwright chrome-headless-shell
|
|
47
|
+
* → the 2 CONSOLE lines either way
|
|
48
|
+
*
|
|
49
|
+
* and in ALL FOUR arms stdout was byte-identical at 318 bytes, so the DOM the
|
|
50
|
+
* judge reads is untouched by the flags.
|
|
51
|
+
*/
|
|
52
|
+
export declare function parseConsoleLines(stderr: string): string[];
|
|
53
|
+
/**
|
|
54
|
+
* Append the console output to a FAIL detail — and to nothing else.
|
|
55
|
+
*
|
|
56
|
+
* WHY (nexttask 19B; the class 18A opened for repo-health). Run 21's fix child was
|
|
57
|
+
* told "the body is EMPTY" and nothing more. It spent 45 minutes on tests, bundler
|
|
58
|
+
* config and static serving, read the offending line twice, and moved on. The
|
|
59
|
+
* probe was holding the answer the whole time: `Uncaught ReferenceError: process
|
|
60
|
+
* is not defined`, at main.js:322.
|
|
61
|
+
*
|
|
62
|
+
* Strictly additive by construction: it takes an existing FAIL detail and returns
|
|
63
|
+
* it with text appended. It cannot turn a PASS into a FAIL, cannot reach
|
|
64
|
+
* `judgeRenderedDom`, and returns the detail unchanged when the page logged
|
|
65
|
+
* nothing.
|
|
66
|
+
*/
|
|
67
|
+
export declare function withConsoleEvidence(detail: string, stderr: string): string;
|
|
37
68
|
/**
|
|
38
69
|
* Load `url` once in a headless Chrome and judge the rendered DOM. Blocking
|
|
39
70
|
* (spawnSync) by design — the caller holds the booted server alive exactly for
|
|
@@ -29,6 +29,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
29
29
|
import { existsSync, readdirSync } from 'node:fs';
|
|
30
30
|
import * as os from 'node:os';
|
|
31
31
|
import * as path from 'node:path';
|
|
32
|
+
import { clampOutput } from './clamp-output.js';
|
|
32
33
|
/** PATH names tried in order for a system Chrome-family binary. */
|
|
33
34
|
const CHROME_PATH_CANDIDATES = [
|
|
34
35
|
'chromium',
|
|
@@ -144,6 +145,74 @@ export function judgeRenderedDom(html) {
|
|
|
144
145
|
/** Wall-clock cap for the whole browser run; virtual-time budget for the page JS. */
|
|
145
146
|
const RENDER_TIMEOUT_MS = 30_000;
|
|
146
147
|
const VIRTUAL_TIME_BUDGET_MS = 8_000;
|
|
148
|
+
/**
|
|
149
|
+
* A Chrome console line on stderr:
|
|
150
|
+
*
|
|
151
|
+
* [11506:11506:0814/090315.702981:INFO:CONSOLE:322] "Uncaught ReferenceError:
|
|
152
|
+
* process is not defined", source: http://localhost:8791/main.js (322)
|
|
153
|
+
*
|
|
154
|
+
* Only the severity and the message survive. The bracketed pid/tid/timestamp
|
|
155
|
+
* prefix is DROPPED on purpose: it changes every run, and the failure detail is
|
|
156
|
+
* the string `normalizeFailureDetail` compares across autofix attempts. A volatile
|
|
157
|
+
* prefix in there would make two runs of the SAME defect look different, which is
|
|
158
|
+
* a change to the non-progress classifier's behaviour — and 19B may not change any
|
|
159
|
+
* verdict, only explain one.
|
|
160
|
+
*/
|
|
161
|
+
const CONSOLE_LINE_RE = /^\[[^\]]*:(INFO|WARNING|ERROR|VERBOSE\d*):CONSOLE:\d*\]\s*(.*)$/;
|
|
162
|
+
/** At most this many console lines ride along; the whole block is clamped again. */
|
|
163
|
+
const MAX_CONSOLE_LINES = 12;
|
|
164
|
+
/**
|
|
165
|
+
* The page's console output, as captured while the DOM was being rendered.
|
|
166
|
+
*
|
|
167
|
+
* MEASURED on this box (2026-08-14) against the shipped mx5 run-21 bundle, both
|
|
168
|
+
* binaries the probe can discover:
|
|
169
|
+
*
|
|
170
|
+
* /usr/bin/chromium --dump-dom alone → 0 bytes of stderr
|
|
171
|
+
* + --enable-logging=stderr --v=0 → 2 CONSOLE
|
|
172
|
+
* lines, one of them the cause
|
|
173
|
+
* playwright chrome-headless-shell
|
|
174
|
+
* → the 2 CONSOLE lines either way
|
|
175
|
+
*
|
|
176
|
+
* and in ALL FOUR arms stdout was byte-identical at 318 bytes, so the DOM the
|
|
177
|
+
* judge reads is untouched by the flags.
|
|
178
|
+
*/
|
|
179
|
+
export function parseConsoleLines(stderr) {
|
|
180
|
+
const out = [];
|
|
181
|
+
for (const raw of stderr.split('\n')) {
|
|
182
|
+
const m = CONSOLE_LINE_RE.exec(raw.trim());
|
|
183
|
+
if (!m)
|
|
184
|
+
continue;
|
|
185
|
+
const text = m[2].trim();
|
|
186
|
+
if (text.length === 0)
|
|
187
|
+
continue;
|
|
188
|
+
const line = `${m[1].toLowerCase()}: ${text}`;
|
|
189
|
+
if (!out.includes(line))
|
|
190
|
+
out.push(line);
|
|
191
|
+
if (out.length >= MAX_CONSOLE_LINES)
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Append the console output to a FAIL detail — and to nothing else.
|
|
198
|
+
*
|
|
199
|
+
* WHY (nexttask 19B; the class 18A opened for repo-health). Run 21's fix child was
|
|
200
|
+
* told "the body is EMPTY" and nothing more. It spent 45 minutes on tests, bundler
|
|
201
|
+
* config and static serving, read the offending line twice, and moved on. The
|
|
202
|
+
* probe was holding the answer the whole time: `Uncaught ReferenceError: process
|
|
203
|
+
* is not defined`, at main.js:322.
|
|
204
|
+
*
|
|
205
|
+
* Strictly additive by construction: it takes an existing FAIL detail and returns
|
|
206
|
+
* it with text appended. It cannot turn a PASS into a FAIL, cannot reach
|
|
207
|
+
* `judgeRenderedDom`, and returns the detail unchanged when the page logged
|
|
208
|
+
* nothing.
|
|
209
|
+
*/
|
|
210
|
+
export function withConsoleEvidence(detail, stderr) {
|
|
211
|
+
const lines = parseConsoleLines(stderr);
|
|
212
|
+
if (lines.length === 0)
|
|
213
|
+
return detail;
|
|
214
|
+
return `${detail} — console output during the load: ${clampOutput(lines.join(' | '))}`;
|
|
215
|
+
}
|
|
147
216
|
/**
|
|
148
217
|
* Load `url` once in a headless Chrome and judge the rendered DOM. Blocking
|
|
149
218
|
* (spawnSync) by design — the caller holds the booted server alive exactly for
|
|
@@ -163,6 +232,11 @@ export function runRenderCheck(url, browser) {
|
|
|
163
232
|
'--no-sandbox',
|
|
164
233
|
'--disable-dev-shm-usage',
|
|
165
234
|
`--virtual-time-budget=${VIRTUAL_TIME_BUDGET_MS}`,
|
|
235
|
+
// Route the page's console to stderr (nexttask 19B). stdout — the DOM
|
|
236
|
+
// the judge reads — is byte-identical with and without these; measured
|
|
237
|
+
// on both discoverable binaries, see withConsoleEvidence.
|
|
238
|
+
'--enable-logging=stderr',
|
|
239
|
+
'--v=0',
|
|
166
240
|
'--dump-dom',
|
|
167
241
|
url
|
|
168
242
|
], { encoding: 'utf8', timeout: RENDER_TIMEOUT_MS, env: { ...process.env } });
|
|
@@ -180,7 +254,9 @@ export function runRenderCheck(url, browser) {
|
|
|
180
254
|
};
|
|
181
255
|
}
|
|
182
256
|
const judged = judgeRenderedDom(dom);
|
|
257
|
+
// The verdict is the judge's, unchanged. Only a FAIL grows: it carries the
|
|
258
|
+
// console output the probe already had at the moment it judged (19B).
|
|
183
259
|
return judged.ok ?
|
|
184
260
|
{ outcome: 'pass', detail: judged.detail }
|
|
185
|
-
: { outcome: 'fail', detail: judged.detail };
|
|
261
|
+
: { outcome: 'fail', detail: withConsoleEvidence(judged.detail, r.stderr ?? '') };
|
|
186
262
|
}
|
|
@@ -412,8 +412,17 @@ export async function runFinalGateStage(active, deps, p) {
|
|
|
412
412
|
failures: fix.gateFailures
|
|
413
413
|
});
|
|
414
414
|
const edited = fix.gateReason !== undefined && stranded.length > 0;
|
|
415
|
+
// …and whether a PROBE OBSERVED this failure (nexttask 19A). Exact text
|
|
416
|
+
// identity against the gate's own observed subset — not a second string
|
|
417
|
+
// pattern, which is the mistake `isNonProgress` already made once.
|
|
418
|
+
const observed = fix.gateObservedFailures?.includes(detail ?? '') === true;
|
|
415
419
|
if (detail !== null
|
|
416
|
-
&& isNonProgress({
|
|
420
|
+
&& isNonProgress({
|
|
421
|
+
previousSignature: prevFailSig,
|
|
422
|
+
currentDetail: detail,
|
|
423
|
+
edited,
|
|
424
|
+
observed
|
|
425
|
+
})) {
|
|
417
426
|
demoted.add(normalizeFailureDetail(detail));
|
|
418
427
|
prevFailSig = null;
|
|
419
428
|
await carryDebt(unobservedDebtReason(detail));
|
package/dist/task/task-gates.js
CHANGED
|
@@ -8,6 +8,7 @@ import { attributeEnforceFailure } from './enforce-attribution.js';
|
|
|
8
8
|
// come from accept-debt.ts directly — the latter because its writer and its
|
|
9
9
|
// re-check-side parser (extractDeletedDebtPath) have to move together.
|
|
10
10
|
import { crossTaskDeletionReason } from './accept-debt.js';
|
|
11
|
+
import { clampOutput } from './clamp-output.js';
|
|
11
12
|
/**
|
|
12
13
|
* How many times a verify FAIL may be auto-fixed UNATTENDED (the research
|
|
13
14
|
* recommended AUTOFIX, so pi re-runs the impl turn without prompting) before the
|
|
@@ -41,18 +42,8 @@ export function yoloAcceptReason(c) {
|
|
|
41
42
|
`judge recommended ACCEPT (autofix budget 0/${MAX_AUTO_AUTOFIX} unused)`
|
|
42
43
|
: `judge recommended ACCEPT (autofix budget ${c.autoFixCount}/${MAX_AUTO_AUTOFIX} already spent)`;
|
|
43
44
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
* appendGateRecord flattens newlines to spaces, so the trail stays one line per
|
|
47
|
-
* entry; this just caps the volume (a wedged tool can emit megabytes). The health
|
|
48
|
-
* check already trims to its own first-N lines — this is the trail-side ceiling.
|
|
49
|
-
*/
|
|
50
|
-
const TRAIL_OUTPUT_MAX_CHARS = 1200;
|
|
51
|
-
function clampOutput(output) {
|
|
52
|
-
return output.length > TRAIL_OUTPUT_MAX_CHARS ?
|
|
53
|
-
`${output.slice(0, TRAIL_OUTPUT_MAX_CHARS)}…`
|
|
54
|
-
: output;
|
|
55
|
-
}
|
|
45
|
+
// The trail-side output ceiling lives in clamp-output.ts, so the render probe's
|
|
46
|
+
// evidence clamps identically (nexttask 19B) — one ceiling, one implementation.
|
|
56
47
|
/**
|
|
57
48
|
* Show the boxed two-choice picker after a verify FAIL and return what the user
|
|
58
49
|
* decided. The model-recommended card is placed first so the renderer tints it
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* unfailable-command — is this shell command's exit status DESTROYED by its own
|
|
3
|
+
* construction? (nexttask 19C)
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. `recheckAcceptDebts` may auto-close an accepted debt on exactly
|
|
6
|
+
* one piece of evidence: the debt named a VERIFY command, that command was re-run,
|
|
7
|
+
* and it exited ZERO (accept-debt.ts — "the debt named a command, the command was
|
|
8
|
+
* run, and it passed"). `isStorableCommand` used to filter only on length and
|
|
9
|
+
* control characters, so nothing asked whether a ZERO exit could ever mean
|
|
10
|
+
* anything. Sixteen stored-eligible VERIFY lines in the corpus on this box cannot
|
|
11
|
+
* exit non-zero no matter what the tree contains:
|
|
12
|
+
*
|
|
13
|
+
* C `test -f "$SO_LIB" && echo "PASS: …" || echo "FAIL: …"` 15 ← both
|
|
14
|
+
* branches that can be LAST are echoes, so the status is echo's: 0.
|
|
15
|
+
* A `bun -e "console.assert(…)"` 1 ← measured
|
|
16
|
+
* in both runtimes: `console.assert(1===2,'X')` prints and exits 0.
|
|
17
|
+
* B `npx tsc --noEmit 2>&1 | tail -5; test $? -eq 0 && …` 1 ← `$?` is
|
|
18
|
+
* tail's status, not tsc's.
|
|
19
|
+
*
|
|
20
|
+
* IAR1 (CMake / C++ / OBS plugin — no database, no frontend, no HTTP server)
|
|
21
|
+
* carries 11 of the 16; one of its tasks is SEVEN consecutive `test -f … && echo
|
|
22
|
+
* "PASS" || echo "FAIL"` lines standing in for a build verification.
|
|
23
|
+
*
|
|
24
|
+
* THE VERDICT IS A REFUSAL, NOT A CLAIM. An unfailable command is not stored, so
|
|
25
|
+
* the debt stays OPEN and surfaced — the strictly smaller claim. Nothing here can
|
|
26
|
+
* close a debt, fail a gate, or edit a spec.
|
|
27
|
+
*
|
|
28
|
+
* DECIDED ON SHELL SHAPE, NEVER ON THE VERB. `grep -q …` and `ctest …` set a real
|
|
29
|
+
* status and are untouched; `test -f X || { echo …; exit 1; }` exits non-zero and
|
|
30
|
+
* is untouched. Naming verbs is the mistake nexttask 3 already paid for
|
|
31
|
+
* (command-shrink's guard compared NAMES) and 16B re-bought.
|
|
32
|
+
*
|
|
33
|
+
* OUT OF SCOPE BY DESIGN: bare `|| true`. skip-escape.ts:11-19 records the FP
|
|
34
|
+
* measurement — of 45 `||` uses in the historical VERIFY blocks exactly one was a
|
|
35
|
+
* real skip-escape; a blanket `|| true` rule is ~90% false positives (teardown,
|
|
36
|
+
* setup, negative tests). `rm -rf build || true` classifies CAN-FAIL here and must
|
|
37
|
+
* keep doing so.
|
|
38
|
+
*
|
|
39
|
+
* THREE OUTCOMES, and `unknown` is a first-class answer: a shape this cannot
|
|
40
|
+
* decide is never guessed at, it is left alone (which is today's behaviour).
|
|
41
|
+
*/
|
|
42
|
+
/** How a command's exit status behaves. `unknown` ⇒ do not act. */
|
|
43
|
+
export type ExitStatusClass = 'unfailable' | 'can-fail' | 'unknown';
|
|
44
|
+
export interface UnfailableVerdict {
|
|
45
|
+
cls: ExitStatusClass;
|
|
46
|
+
/** Which sub-rule decided it (A/B/C), and in prose. Empty for `can-fail`. */
|
|
47
|
+
reason: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Is this command's exit status destroyed by construction? The only caller that
|
|
51
|
+
* may ACT on `unfailable` is `isStorableCommand`, and the only action is a refusal
|
|
52
|
+
* to store — never a close, never a gate failure.
|
|
53
|
+
*/
|
|
54
|
+
export declare function classifyExitStatus(cmd: string): UnfailableVerdict;
|
|
55
|
+
/** Convenience predicate for the one place that acts on this. */
|
|
56
|
+
export declare function isUnfailableCommand(cmd: string): boolean;
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* unfailable-command — is this shell command's exit status DESTROYED by its own
|
|
3
|
+
* construction? (nexttask 19C)
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. `recheckAcceptDebts` may auto-close an accepted debt on exactly
|
|
6
|
+
* one piece of evidence: the debt named a VERIFY command, that command was re-run,
|
|
7
|
+
* and it exited ZERO (accept-debt.ts — "the debt named a command, the command was
|
|
8
|
+
* run, and it passed"). `isStorableCommand` used to filter only on length and
|
|
9
|
+
* control characters, so nothing asked whether a ZERO exit could ever mean
|
|
10
|
+
* anything. Sixteen stored-eligible VERIFY lines in the corpus on this box cannot
|
|
11
|
+
* exit non-zero no matter what the tree contains:
|
|
12
|
+
*
|
|
13
|
+
* C `test -f "$SO_LIB" && echo "PASS: …" || echo "FAIL: …"` 15 ← both
|
|
14
|
+
* branches that can be LAST are echoes, so the status is echo's: 0.
|
|
15
|
+
* A `bun -e "console.assert(…)"` 1 ← measured
|
|
16
|
+
* in both runtimes: `console.assert(1===2,'X')` prints and exits 0.
|
|
17
|
+
* B `npx tsc --noEmit 2>&1 | tail -5; test $? -eq 0 && …` 1 ← `$?` is
|
|
18
|
+
* tail's status, not tsc's.
|
|
19
|
+
*
|
|
20
|
+
* IAR1 (CMake / C++ / OBS plugin — no database, no frontend, no HTTP server)
|
|
21
|
+
* carries 11 of the 16; one of its tasks is SEVEN consecutive `test -f … && echo
|
|
22
|
+
* "PASS" || echo "FAIL"` lines standing in for a build verification.
|
|
23
|
+
*
|
|
24
|
+
* THE VERDICT IS A REFUSAL, NOT A CLAIM. An unfailable command is not stored, so
|
|
25
|
+
* the debt stays OPEN and surfaced — the strictly smaller claim. Nothing here can
|
|
26
|
+
* close a debt, fail a gate, or edit a spec.
|
|
27
|
+
*
|
|
28
|
+
* DECIDED ON SHELL SHAPE, NEVER ON THE VERB. `grep -q …` and `ctest …` set a real
|
|
29
|
+
* status and are untouched; `test -f X || { echo …; exit 1; }` exits non-zero and
|
|
30
|
+
* is untouched. Naming verbs is the mistake nexttask 3 already paid for
|
|
31
|
+
* (command-shrink's guard compared NAMES) and 16B re-bought.
|
|
32
|
+
*
|
|
33
|
+
* OUT OF SCOPE BY DESIGN: bare `|| true`. skip-escape.ts:11-19 records the FP
|
|
34
|
+
* measurement — of 45 `||` uses in the historical VERIFY blocks exactly one was a
|
|
35
|
+
* real skip-escape; a blanket `|| true` rule is ~90% false positives (teardown,
|
|
36
|
+
* setup, negative tests). `rm -rf build || true` classifies CAN-FAIL here and must
|
|
37
|
+
* keep doing so.
|
|
38
|
+
*
|
|
39
|
+
* THREE OUTCOMES, and `unknown` is a first-class answer: a shape this cannot
|
|
40
|
+
* decide is never guessed at, it is left alone (which is today's behaviour).
|
|
41
|
+
*/
|
|
42
|
+
const CAN_FAIL = { cls: 'can-fail', reason: '' };
|
|
43
|
+
// ─── a quote- and group-aware splitter ───────────────────────────────────────
|
|
44
|
+
/**
|
|
45
|
+
* Split `s` on the top-level occurrences of `seps` (longest first), respecting
|
|
46
|
+
* single quotes, double quotes, `$(…)`/backtick substitution and `(…)`/`{…}`
|
|
47
|
+
* groups. Returns the pieces and the separators that joined them.
|
|
48
|
+
*/
|
|
49
|
+
function splitTopLevel(s, seps) {
|
|
50
|
+
const parts = [];
|
|
51
|
+
const ops = [];
|
|
52
|
+
let buf = '';
|
|
53
|
+
let depth = 0;
|
|
54
|
+
let quote = null;
|
|
55
|
+
for (let i = 0; i < s.length; i++) {
|
|
56
|
+
const c = s[i];
|
|
57
|
+
if (quote !== null) {
|
|
58
|
+
buf += c;
|
|
59
|
+
if (c === '\\' && quote === '"') {
|
|
60
|
+
if (i + 1 < s.length)
|
|
61
|
+
buf += s[++i];
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (c === quote)
|
|
65
|
+
quote = null;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (c === '\\') {
|
|
69
|
+
buf += c;
|
|
70
|
+
if (i + 1 < s.length)
|
|
71
|
+
buf += s[++i];
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
75
|
+
quote = c;
|
|
76
|
+
buf += c;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (c === '(' || (c === '{' && /(?:^|\s)$/.test(buf))) {
|
|
80
|
+
depth++;
|
|
81
|
+
buf += c;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (c === ')' || (c === '}' && depth > 0)) {
|
|
85
|
+
depth = Math.max(0, depth - 1);
|
|
86
|
+
buf += c;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (depth === 0) {
|
|
90
|
+
const hit = seps.find(sep => s.startsWith(sep, i));
|
|
91
|
+
if (hit !== undefined) {
|
|
92
|
+
parts.push(buf);
|
|
93
|
+
ops.push(hit);
|
|
94
|
+
buf = '';
|
|
95
|
+
i += hit.length - 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
buf += c;
|
|
100
|
+
}
|
|
101
|
+
parts.push(buf);
|
|
102
|
+
return { parts, ops };
|
|
103
|
+
}
|
|
104
|
+
/** `a; b; c` — the LINE's exit status is the LAST segment's. */
|
|
105
|
+
function segments(cmd) {
|
|
106
|
+
return splitTopLevel(cmd, [';'])
|
|
107
|
+
.parts.map(p => p.trim())
|
|
108
|
+
.filter(p => p.length > 0);
|
|
109
|
+
}
|
|
110
|
+
/** `a && b || c` — the pieces and the operators between them. */
|
|
111
|
+
function chain(seg) {
|
|
112
|
+
const { parts, ops } = splitTopLevel(seg, ['&&', '||']);
|
|
113
|
+
return { parts: parts.map(p => p.trim()), ops };
|
|
114
|
+
}
|
|
115
|
+
/** A top-level `|` that is not `||`. */
|
|
116
|
+
function hasPipeline(seg) {
|
|
117
|
+
const { ops } = splitTopLevel(seg, ['||', '|']);
|
|
118
|
+
return ops.includes('|');
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Which commands of an `&&`/`||` chain can be the LAST one executed — i.e. whose
|
|
122
|
+
* exit status can become the chain's.
|
|
123
|
+
*
|
|
124
|
+
* `c_i` (before the end) can be terminal only if some single status short-circuits
|
|
125
|
+
* EVERY remaining operator: `&&` skips what follows when the status is non-zero,
|
|
126
|
+
* `||` skips it when the status is zero. A chain that mixes the two after `c_i`
|
|
127
|
+
* therefore always runs on past it. For `A && B || C`: A is never terminal (the
|
|
128
|
+
* `||` picks C up after A fails), B is terminal when it succeeds, C is terminal —
|
|
129
|
+
* so the status is always an echo's, which is the run-21 shape.
|
|
130
|
+
*/
|
|
131
|
+
function terminalIndices(ops, n) {
|
|
132
|
+
const out = [];
|
|
133
|
+
for (let i = 0; i < n; i++) {
|
|
134
|
+
if (i === n - 1) {
|
|
135
|
+
out.push(i);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const rest = ops.slice(i, n - 1);
|
|
139
|
+
const skipsOnFail = rest.every(o => o === '&&');
|
|
140
|
+
const skipsOnOk = rest.every(o => o === '||');
|
|
141
|
+
if (skipsOnFail || skipsOnOk)
|
|
142
|
+
out.push(i);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
// ─── the three sub-rules ─────────────────────────────────────────────────────
|
|
147
|
+
/** The command word, with leading `VAR=value` assignments and `env` stripped. */
|
|
148
|
+
function commandWord(cmd) {
|
|
149
|
+
let rest = cmd.trim();
|
|
150
|
+
for (;;) {
|
|
151
|
+
const m = /^(?:env\s+|[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)/.exec(rest);
|
|
152
|
+
if (!m)
|
|
153
|
+
break;
|
|
154
|
+
rest = rest.slice(m[0].length);
|
|
155
|
+
}
|
|
156
|
+
return /^[^\s]*/.exec(rest)?.[0] ?? '';
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* A branch that cannot do anything but succeed: `echo …` / `printf …` with no
|
|
160
|
+
* redirection to a file (a redirect CAN fail on a bad path, and this class is
|
|
161
|
+
* about statuses destroyed by construction, not about likely ones).
|
|
162
|
+
*/
|
|
163
|
+
function isPureEcho(cmd) {
|
|
164
|
+
const word = commandWord(cmd);
|
|
165
|
+
if (word !== 'echo' && word !== 'printf')
|
|
166
|
+
return false;
|
|
167
|
+
return !splitTopLevel(cmd, ['>>', '>']).ops.length;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* RULE A — a `console.assert` check. Measured, not assumed, in both runtimes:
|
|
171
|
+
*
|
|
172
|
+
* $ bun -e "console.assert(1===2,'X'); console.log('end')"; echo $? → 0
|
|
173
|
+
* $ node -e "console.assert(1===2,'X'); console.log('end')"; echo $? → 0
|
|
174
|
+
*
|
|
175
|
+
* It prints and continues. Narrowed so an eval that ALSO has a real exit path
|
|
176
|
+
* (`process.exit`, `throw`, a shell `exit`) is untouched — those can fail.
|
|
177
|
+
*/
|
|
178
|
+
function ruleA(cmd) {
|
|
179
|
+
if (!cmd.includes('console.assert'))
|
|
180
|
+
return false;
|
|
181
|
+
return !/\bprocess\.exit\b|\bthrow\b|\bexit\s+\d/.test(cmd);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* RULE B — `$?` read after a PIPELINE. `a | b ; test $? …` tests b's status, not
|
|
185
|
+
* a's, so the check reports on the filter rather than on the thing being checked.
|
|
186
|
+
* Shape-only: which command ends the pipeline is never inspected.
|
|
187
|
+
*/
|
|
188
|
+
function ruleB(segs) {
|
|
189
|
+
for (let i = 1; i < segs.length; i++) {
|
|
190
|
+
if (segs[i].includes('$?') && hasPipeline(segs[i - 1]))
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* RULE C — every branch of the top-level `&&`/`||` chain that can be LAST is a
|
|
197
|
+
* pure echo/printf, so the chain's status is an echo's: zero, always.
|
|
198
|
+
*/
|
|
199
|
+
function ruleC(seg) {
|
|
200
|
+
const { parts, ops } = chain(seg);
|
|
201
|
+
if (parts.length < 2)
|
|
202
|
+
return false;
|
|
203
|
+
const terms = terminalIndices(ops, parts.length);
|
|
204
|
+
return terms.length > 0 && terms.every(i => isPureEcho(parts[i]));
|
|
205
|
+
}
|
|
206
|
+
// ─── the verdict ─────────────────────────────────────────────────────────────
|
|
207
|
+
/** Shapes this refuses to judge — never a guess, always today's behaviour. */
|
|
208
|
+
function undecidable(cmd) {
|
|
209
|
+
const t = cmd.trim();
|
|
210
|
+
// `set -e` changes what a non-zero status DOES, all the way up the line.
|
|
211
|
+
if (/(?:^|[;&|(]\s*)set\s+-[a-z]*e/.test(t))
|
|
212
|
+
return '`set -e` is in effect for this line';
|
|
213
|
+
// The whole line wrapped in a command substitution: the status is the outer
|
|
214
|
+
// context's, which is not in the text.
|
|
215
|
+
if (/^\$\([\s\S]*\)$/.test(t) || /^`[\s\S]*`$/.test(t)) {
|
|
216
|
+
return 'the whole line is a command substitution';
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Is this command's exit status destroyed by construction? The only caller that
|
|
222
|
+
* may ACT on `unfailable` is `isStorableCommand`, and the only action is a refusal
|
|
223
|
+
* to store — never a close, never a gate failure.
|
|
224
|
+
*/
|
|
225
|
+
export function classifyExitStatus(cmd) {
|
|
226
|
+
const t = cmd.trim();
|
|
227
|
+
if (t.length === 0)
|
|
228
|
+
return CAN_FAIL;
|
|
229
|
+
const skip = undecidable(t);
|
|
230
|
+
if (skip !== null)
|
|
231
|
+
return { cls: 'unknown', reason: skip };
|
|
232
|
+
const segs = segments(t);
|
|
233
|
+
if (segs.length === 0)
|
|
234
|
+
return CAN_FAIL;
|
|
235
|
+
if (ruleB(segs)) {
|
|
236
|
+
return {
|
|
237
|
+
cls: 'unfailable',
|
|
238
|
+
reason: 'B: `$?` is read after a pipeline, so it holds the LAST pipeline element’s '
|
|
239
|
+
+ 'status and not the checked command’s'
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
// The line's status is the LAST segment's; earlier segments cannot change it.
|
|
243
|
+
const last = segs[segs.length - 1];
|
|
244
|
+
if (ruleA(last)) {
|
|
245
|
+
return {
|
|
246
|
+
cls: 'unfailable',
|
|
247
|
+
reason: 'A: `console.assert` never exits non-zero and never throws — it prints and '
|
|
248
|
+
+ 'continues, in both bun and node'
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (ruleC(last)) {
|
|
252
|
+
return {
|
|
253
|
+
cls: 'unfailable',
|
|
254
|
+
reason: 'C: every branch of the `&&`/`||` chain that can run LAST is an `echo`/`printf`, '
|
|
255
|
+
+ 'so the exit status is the echo’s — zero whatever the check found'
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
return CAN_FAIL;
|
|
259
|
+
}
|
|
260
|
+
/** Convenience predicate for the one place that acts on this. */
|
|
261
|
+
export function isUnfailableCommand(cmd) {
|
|
262
|
+
return classifyExitStatus(cmd).cls === 'unfailable';
|
|
263
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.6",
|
|
4
4
|
"description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|