@mjasnikovs/pi-task 0.38.4 → 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.
@@ -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 && cmd.length <= MAX_REASON_LENGTH && !/[\t\n\r]/.test(cmd);
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
@@ -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
- fail(`boot check: \`${label}\` ${b.detail}`, 0);
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 texts = [...failures].sort((a, b) => a.rank - b.rank).map(f => f.text);
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({ previousSignature: prevFailSig, currentDetail: detail, edited })) {
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));
@@ -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
- * Bound a captured health-check output before it is embedded in a gate-trail line.
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.4",
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",