@mjasnikovs/pi-task 0.38.15 → 0.38.16

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.
Files changed (62) hide show
  1. package/dist/shared/child-process.js +9 -16
  2. package/dist/task/accept-debt.d.ts +7 -5
  3. package/dist/task/accept-debt.js +16 -13
  4. package/dist/task/auto-orchestrator.js +38 -36
  5. package/dist/task/autofix-ledger.d.ts +113 -0
  6. package/dist/task/autofix-ledger.js +152 -0
  7. package/dist/task/boot-probe.d.ts +63 -1
  8. package/dist/task/boot-probe.js +98 -2
  9. package/dist/task/child-runner.d.ts +50 -6
  10. package/dist/task/child-runner.js +48 -69
  11. package/dist/task/command-run.d.ts +49 -6
  12. package/dist/task/command-run.js +154 -18
  13. package/dist/task/external-context.d.ts +9 -12
  14. package/dist/task/external-context.js +5 -5
  15. package/dist/task/failure-classifier.d.ts +9 -1
  16. package/dist/task/failure-classifier.js +9 -0
  17. package/dist/task/final-gate-fix.d.ts +22 -26
  18. package/dist/task/final-gate-fix.js +2 -7
  19. package/dist/task/final-gate.d.ts +10 -2
  20. package/dist/task/final-gate.js +49 -88
  21. package/dist/task/gate-deps.js +20 -13
  22. package/dist/task/orchestrator.d.ts +33 -24
  23. package/dist/task/orchestrator.js +66 -44
  24. package/dist/task/phases.d.ts +58 -34
  25. package/dist/task/phases.js +140 -113
  26. package/dist/task/plan-orchestrator.js +2 -2
  27. package/dist/task/repo-health-check.d.ts +21 -21
  28. package/dist/task/repo-health-check.js +43 -112
  29. package/dist/task/run-end.d.ts +77 -0
  30. package/dist/task/run-end.js +37 -0
  31. package/dist/task/run-final-gate.js +71 -79
  32. package/dist/task/task-gates.d.ts +8 -0
  33. package/dist/task/task-gates.js +23 -4
  34. package/dist/task/terminal-outcome.d.ts +1 -1
  35. package/dist/task/terminal-outcome.js +12 -0
  36. package/dist/workers/brave-search.d.ts +7 -0
  37. package/dist/workers/brave-search.js +36 -55
  38. package/dist/workers/ddg-search.d.ts +1 -1
  39. package/dist/workers/ddg-search.js +27 -47
  40. package/dist/workers/exa-search.d.ts +2 -2
  41. package/dist/workers/exa-search.js +53 -68
  42. package/dist/workers/html-clean.js +67 -88
  43. package/dist/workers/http-request.d.ts +74 -0
  44. package/dist/workers/http-request.js +103 -0
  45. package/dist/workers/npm-version.js +37 -42
  46. package/dist/workers/pi-worker-core.d.ts +13 -2
  47. package/dist/workers/pi-worker-core.js +12 -17
  48. package/dist/workers/pi-worker-docs.d.ts +1 -1
  49. package/dist/workers/pi-worker-docs.js +49 -68
  50. package/dist/workers/pi-worker-fetch.d.ts +1 -1
  51. package/dist/workers/pi-worker-fetch.js +20 -21
  52. package/dist/workers/pi-worker-search.js +6 -4
  53. package/dist/workers/pi-worker.js +5 -4
  54. package/dist/workers/search-core.d.ts +1 -1
  55. package/dist/workers/search-core.js +36 -42
  56. package/dist/workers/search-types.d.ts +13 -0
  57. package/dist/workers/search-types.js +27 -0
  58. package/dist/workers/shared.d.ts +51 -11
  59. package/dist/workers/shared.js +0 -0
  60. package/dist/workers/worker-channels.d.ts +60 -0
  61. package/dist/workers/worker-channels.js +98 -0
  62. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { spawn as defaultSpawn, spawnSync as spawnSyncDefault } from 'node:child_process';
2
2
  import { realStreamTimerDeps, StreamWatchdog } from './stream-watchdog.js';
3
+ import { workerChannel } from '../workers/worker-channels.js';
3
4
  /** Grace period between SIGTERM and SIGKILL (ms). */
4
5
  export const KILL_GRACE_MS = 5000;
5
6
  /** Base flags shared by all child pi invocations. */
@@ -460,25 +461,17 @@ export function summarizeToolArgs(toolName, args) {
460
461
  if (!args || typeof args !== 'object')
461
462
  return '';
462
463
  const a = args;
463
- const clip = (s) => {
464
- const one = s.replace(/\s+/g, ' ').trim();
465
- return one.length > 60 ? one.slice(0, 59) + '…' : one;
466
- };
467
464
  if (toolName === 'bash' && typeof a.command === 'string') {
468
465
  return a.command.replace(/\s+/g, ' ').trim();
469
466
  }
470
- if (toolName === 'pi-worker-docs'
471
- && typeof a.module === 'string'
472
- && typeof a.query === 'string') {
473
- return `${a.module} "${clip(a.query)}"`;
474
- }
475
- // Search/fetch workers: without these the debug log shows a bare tool name
476
- // and a run audit cannot tell WHAT was searched or fetched.
477
- if (toolName === 'pi-worker-search' && typeof a.query === 'string') {
478
- return `"${clip(a.query)}"`;
479
- }
480
- if (toolName === 'pi-worker-fetch' && typeof a.url === 'string') {
481
- return clip(a.url);
467
+ // Each worker tool's argument shape is its own row's fact (worker-channels.ts);
468
+ // this used to be a third copy of the names AND a re-statement of each one's
469
+ // parameters. A row that has nothing to say falls through to the generic keys.
470
+ const channel = workerChannel(toolName);
471
+ if (channel) {
472
+ const summary = channel.summarize(a);
473
+ if (summary)
474
+ return summary;
482
475
  }
483
476
  if (typeof a.file_path === 'string')
484
477
  return a.file_path;
@@ -190,12 +190,12 @@ export declare function recheckAcceptDebts(debts: AcceptDebt[], opts: {
190
190
  * caller is expected to have made `pass` mean "ran, exited 0, and changed
191
191
  * nothing tracked" (`inv-no-write`).
192
192
  */
193
- rerunVerify?: (command: string, debt: AcceptDebt) => VerifyRerunResult;
194
- }): {
193
+ rerunVerify?: (command: string, debt: AcceptDebt) => Promise<VerifyRerunResult>;
194
+ }): Promise<{
195
195
  open: AcceptDebt[];
196
196
  resolved: AcceptDebt[];
197
197
  trail: string[];
198
- };
198
+ }>;
199
199
  /**
200
200
  * Extract the paths whose EXISTENCE the reason asserts as the failure — a path
201
201
  * token immediately followed by "exists" / "still exists", or by "must/should not
@@ -247,7 +247,9 @@ export declare function describeDebt(d: AcceptDebt): string;
247
247
  * thing that can auto-close a static-class debt — so a caller that does not know
248
248
  * must pass `false` (unprovable ⇒ stays open), never a guess.
249
249
  */
250
- export declare function deriveOpenDebts(cwd: string, staticOk: boolean): Promise<{
250
+ export declare function deriveOpenDebts(cwd: string, staticOk: boolean,
251
+ /** The spawner for the VERIFY-COMMAND re-runs. Defaults to the real one. */
252
+ run?: CommandRunner, signal?: AbortSignal): Promise<{
251
253
  openDebts: AcceptDebt[];
252
254
  debtNote?: string;
253
255
  trail?: string[];
@@ -272,4 +274,4 @@ export declare function deriveOpenDebts(cwd: string, staticOk: boolean): Promise
272
274
  export declare function rerunDebtVerifyCommand(cwd: string, command: string,
273
275
  /** The spawner, for BOTH the command and the tracked-state reads. Injected so
274
276
  * the guard's four outcomes are testable without a repo or a real command. */
275
- run?: CommandRunner): VerifyRerunResult;
277
+ run?: CommandRunner, signal?: AbortSignal): Promise<VerifyRerunResult>;
@@ -339,7 +339,7 @@ const MAX_VERIFY_RERUNS = 3;
339
339
  * passed. FP-safe: the only auto-closes are ones a deterministic check can stand
340
340
  * behind, and here the check is the task spec's own command.
341
341
  */
342
- export function recheckAcceptDebts(debts, opts) {
342
+ export async function recheckAcceptDebts(debts, opts) {
343
343
  const open = [];
344
344
  const resolved = [];
345
345
  const trail = [];
@@ -381,7 +381,7 @@ export function recheckAcceptDebts(debts, opts) {
381
381
  rerunsLeft -= 1;
382
382
  let r;
383
383
  try {
384
- r = opts.rerunVerify(cmd, d);
384
+ r = await opts.rerunVerify(cmd, d);
385
385
  }
386
386
  catch {
387
387
  // A harness fault observes nothing, so it proves nothing.
@@ -499,8 +499,10 @@ export function describeDebt(d) {
499
499
  * thing that can auto-close a static-class debt — so a caller that does not know
500
500
  * must pass `false` (unprovable ⇒ stays open), never a guess.
501
501
  */
502
- export async function deriveOpenDebts(cwd, staticOk) {
503
- const { open: openRaw, resolved, trail } = recheckAcceptDebts(await readAcceptDebts(cwd), {
502
+ export async function deriveOpenDebts(cwd, staticOk,
503
+ /** The spawner for the VERIFY-COMMAND re-runs. Defaults to the real one. */
504
+ run = spawnCommand, signal) {
505
+ const { open: openRaw, resolved, trail } = await recheckAcceptDebts(await readAcceptDebts(cwd), {
504
506
  staticOk,
505
507
  // Cross-task-deletion debts auto-close iff the deleted file is back in the
506
508
  // tree — a deterministic existence check, corroborating the per-file
@@ -509,7 +511,7 @@ export async function deriveOpenDebts(cwd, staticOk) {
509
511
  // VERIFY-COMMAND class (nexttask 5): a debt that NAMES a command is settled
510
512
  // by running that command, under the gate's own env-gap contract and behind
511
513
  // the no-write guard below.
512
- rerunVerify: cmd => rerunDebtVerifyCommand(cwd, cmd)
514
+ rerunVerify: cmd => rerunDebtVerifyCommand(cwd, cmd, run, signal)
513
515
  });
514
516
  if (resolved.length > 0)
515
517
  await writeAcceptDebts(cwd, openRaw);
@@ -552,26 +554,27 @@ const DEBT_INFRA_GAP_RE = /ERR_POSTGRES_CONNECTION_CLOSED|ERR_MYSQL_CONNECTION|E
552
554
  * the guard: the re-run is INCONCLUSIVE there, because "nothing changed" would be an
553
555
  * assumption rather than an observation.
554
556
  */
555
- export function rerunDebtVerifyCommand(cwd, command,
557
+ export async function rerunDebtVerifyCommand(cwd, command,
556
558
  /** The spawner, for BOTH the command and the tracked-state reads. Injected so
557
559
  * the guard's four outcomes are testable without a repo or a real command. */
558
- run = spawnCommand) {
559
- const tracked = () => {
560
- const r = run({
560
+ run = spawnCommand, signal) {
561
+ const tracked = async () => {
562
+ const r = await run({
561
563
  cwd,
562
564
  bin: 'git',
563
565
  args: ['status', '--porcelain', '--untracked-files=no'],
564
- timeoutMs: 60_000
566
+ timeoutMs: 60_000,
567
+ ...(signal === undefined ? {} : { signal })
565
568
  });
566
569
  return r.failedToStart || r.status !== 0 ? null : r.stdout;
567
570
  };
568
- const before = tracked();
569
- const r = runVerifyCommandLine(cwd, command, DEBT_RERUN_TIMEOUT_MS, DEBT_INFRA_GAP_RE, run);
571
+ const before = await tracked();
572
+ const r = await runVerifyCommandLine(cwd, command, DEBT_RERUN_TIMEOUT_MS, DEBT_INFRA_GAP_RE, run, signal);
570
573
  if (r.outcome === 'fail')
571
574
  return { outcome: 'fail', detail: `exit ${r.status} — ${r.tail}` };
572
575
  if (r.outcome === 'gap')
573
576
  return { outcome: 'gap', detail: r.detail };
574
- const after = tracked();
577
+ const after = await tracked();
575
578
  if (before === null || after === null) {
576
579
  return { outcome: 'gap', detail: 'tracked-state guard could not read git status' };
577
580
  }
@@ -9,6 +9,7 @@ import { existsSync } from 'node:fs';
9
9
  import * as fsp from 'node:fs/promises';
10
10
  import * as path from 'node:path';
11
11
  import { gateRunTask, markResumable } from './orchestrator.js';
12
+ import { RUN_END_POLICY, runSucceeded } from './run-end.js';
12
13
  import { parseClarifyList, parseAutoAnswer, autoAnswerHasTag, deriveTitle } from './parsers.js';
13
14
  import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js';
14
15
  import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT, DECOMPOSE_COVERAGE_PROMPT } from './auto-prompts.js';
@@ -32,6 +33,7 @@ import { runGatesForTask } from './task-gates.js';
32
33
  import { runFinalGateStage } from './run-final-gate.js';
33
34
  import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
34
35
  import { runFinalIntegrationGate, deriveOpenDebts } from './final-gate.js';
36
+ import { spawnCommand } from './command-run.js';
35
37
  import { getConfig } from '../config/config.js';
36
38
  import { debugLogLevel, shouldLogDebug } from './debug-log.js';
37
39
  import { isYoloMode, yoloPickAnswer, YOLO_STAMP } from './yolo.js';
@@ -1171,7 +1173,7 @@ function defaultDeps(ctx, cwd, signal, title) {
1171
1173
  // The final integration gate follows the `verify work` switch: it is the
1172
1174
  // run-level half of the same verification story.
1173
1175
  finalGate: (cwd2, planText) => getConfig().verifyWork ?
1174
- runFinalIntegrationGate(cwd2, { planText })
1176
+ runFinalIntegrationGate(cwd2, { planText, signal })
1175
1177
  : Promise.resolve({ ok: true, reason: 'disabled' }),
1176
1178
  // Uncommitted paths, for the stranded-sub-fix handling around the final-gate
1177
1179
  // picker (mx5 run 13 PROMPT 4 item 3). Every task is committed by the time
@@ -1183,7 +1185,9 @@ function defaultDeps(ctx, cwd, signal, title) {
1183
1185
  // Re-derive the debt ledger against the FINAL tree after a converged
1184
1186
  // autofix (nexttask 6). Only ever reached from inside the gate's own
1185
1187
  // resolution loop, so it needs no `verify work` switch of its own.
1186
- recheckOpenDebts: (cwd2, staticOk) => deriveOpenDebts(cwd2, staticOk)
1188
+ // Same section, same cancel: this re-runs every ACCEPT-debt VERIFY command
1189
+ // against the final tree, each under its own 300s cap.
1190
+ recheckOpenDebts: (cwd2, staticOk) => deriveOpenDebts(cwd2, staticOk, spawnCommand, signal)
1187
1191
  };
1188
1192
  }
1189
1193
  // ─── Loop ────────────────────────────────────────────────────────────────────
@@ -1317,42 +1321,40 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1317
1321
  onStart: resumeId ? undefined : (innerId => stampTaskInProgress(cwd, id, next.index, innerId, next.title))
1318
1322
  });
1319
1323
  active = res.ctx ?? active;
1320
- if (res.sessionCancelled) {
1321
- announceDone(active, `${id} paused could not start a session. Run /task-auto-resume to retry.`, 'warning');
1322
- return;
1323
- }
1324
- if (res.interrupted) {
1325
- // The user interrupted implementation (ESC) and then declined to
1326
- // steer (empty steer prompt) they want to stop here. Pause
1327
- // without checking the task off, so /task-auto-resume re-delivers
1328
- // this task's spec to finish it. (A plain ESC that the user
1329
- // follows with steering text never reaches here that loops on
1330
- // the same task inside runSingleTask until a turn completes.)
1331
- await markResumable(cwd, res.taskId);
1332
- announceDone(active, `${id} paused at "${next.title}" — resume with /task-auto-resume.`, 'warning');
1333
- return;
1334
- }
1335
- // A phase-boundary cancel surfaces here as a plain !res.ok: the runner
1336
- // caught its own USER_CANCELLED and wrote state 'cancelled' (resumable)
1337
- // to the inner file. Claim it BEFORE the failure branch, or a
1338
- // user-requested stop is announced in red as "stopped … fix and
1339
- // resume". The inner file is already resumable and the parent stays
1340
- // in_progress, matching the loop-top cancel.
1341
- if (!res.ok && isCancelRequested()) {
1342
- announceDone(active, `${id} cancelled during "${next.title}" — resume with /task-auto-resume.`, 'warning');
1343
- return;
1344
- }
1345
- if (!res.ok) {
1346
- // Demote the INNER task file too: it reads `completed` from
1324
+ // One dispatch over the named ending. The five-branch ladder this
1325
+ // replaces had to ask `isCancelRequested()` a module global
1326
+ // `/task-cancel` never sets — to tell a user stop from a fault, so a
1327
+ // cancel during a task was announced in red as "stopped … fix and
1328
+ // resume" and the inner file's `cancelled` was overwritten with
1329
+ // `failed`. The runner names the ending now; resumability is
1330
+ // RUN_END_POLICY's; only the wording is this command's.
1331
+ if (!runSucceeded(res.end)) {
1332
+ const policy = RUN_END_POLICY[res.end.kind];
1333
+ // Demote the INNER task file: it reads `completed` from
1347
1334
  // spec-handoff, and leaving it that way is how a failed run's task
1348
1335
  // file claimed success in the run 6 audit.
1349
- await markResumable(cwd, res.taskId);
1350
- await updateTaskFrontMatter(cwd, id, { state: 'failed' });
1351
- // res.reason is set when the implementation turn itself died
1352
- // (e.g. a context-overflow 400) surface it so the real cause
1353
- // isn't lost behind the generic "stopped" message.
1354
- const why = res.reason ? ` — ${res.reason.slice(0, 160)}` : '';
1355
- announceDone(active, `${id} stopped at "${next.title}"${why} fix and run /task-auto-resume.`, 'error');
1336
+ if (policy.resumable)
1337
+ await markResumable(cwd, res.taskId);
1338
+ // The PLAN fails only on a fault. A declined-steer interrupt leaves
1339
+ // it in progress so /task-auto-resume re-delivers this task's spec.
1340
+ if (policy.failsRun)
1341
+ await updateTaskFrontMatter(cwd, id, { state: 'failed' });
1342
+ const why = res.end.kind === 'failed' && res.end.reason ?
1343
+ ` — ${res.end.reason.slice(0, 160)}`
1344
+ : '';
1345
+ const msg = res.end.kind === 'no-session' ?
1346
+ `${id} paused — could not start a session. Run /task-auto-resume to retry.`
1347
+ : res.end.kind === 'cancelled' ?
1348
+ `${id} cancelled during "${next.title}" — resume with /task-auto-resume.`
1349
+ : res.end.kind === 'interrupted' ?
1350
+ // The user interrupted implementation (ESC) and then declined
1351
+ // to steer — they want to stop here. Paused without checking
1352
+ // the task off, so /task-auto-resume re-delivers this task's
1353
+ // spec. (A plain ESC followed by steering text never reaches
1354
+ // here — that loops inside runSingleTask until a turn ends.)
1355
+ `${id} paused at "${next.title}" — resume with /task-auto-resume.`
1356
+ : `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`;
1357
+ announceDone(active, msg, policy.level);
1356
1358
  return;
1357
1359
  }
1358
1360
  // GATE: actually RUN the task's verification against the just-finished
@@ -0,0 +1,113 @@
1
+ /**
2
+ * autofix-ledger — what the run-end RESOLUTION LOOP records, and the decisions
3
+ * that record makes.
4
+ *
5
+ * The same deepening `GateTally` already proved one altitude down. That replaced
6
+ * twelve mutable locals threaded through ~400 lines of `runFinalIntegrationGate`
7
+ * by closure; this replaces six threaded through ~235 lines of
8
+ * `runFinalGateStage` — the attempt count, the accumulated gitignored writes, the
9
+ * stranded sub-fixes, the previous failure signature, the demoted set, and the
10
+ * rejected-edits flag.
11
+ *
12
+ * The reason is not tidiness. `final-gate-progress.ts` holds five pure functions,
13
+ * each called from exactly ONE site inside that loop — extracted for testability
14
+ * while the decisions about ORDERING and CARRY-FORWARD stayed in the caller. That
15
+ * is precisely the shape `isNonProgress`'s own comment indicts:
16
+ *
17
+ * > The fix is NOT a better string pattern — the bug is that the decision was
18
+ * > made downstream from the evidence.
19
+ *
20
+ * mx5 run 21 shipped a product whose every page was blank as a `completed` run
21
+ * through that gap. Here the evidence and the decision sit in one module, and the
22
+ * loop body reads as picker → apply → record.
23
+ *
24
+ * What is NOT here: anything that talks to the user, writes a ledger file, or
25
+ * commits. The loop keeps those, the same way `GateTally` keeps no I/O — a record
26
+ * that performs effects cannot be driven by a test that only wants the verdict.
27
+ */
28
+ /** The gate-outcome fields this record reads. Structural, so a caller can hand it
29
+ * a `FinalGateOutcome` without this module importing the gate. */
30
+ export interface AutofixOutcomeView {
31
+ reason: string;
32
+ failures?: string[];
33
+ observedFailures?: string[];
34
+ }
35
+ /** What the loop should do about this attempt's ranked-first failure. */
36
+ export interface AttemptVerdict {
37
+ /** The ranked-first failure, trimmed; null when the outcome names none. */
38
+ detail: string | null;
39
+ /**
40
+ * True ⇒ this check is unfalsifiable here: the attempt edited the tree, the
41
+ * gate re-ran, and returned the same first failure as the previous one — and
42
+ * no probe OBSERVED it. Already recorded as demoted when true.
43
+ */
44
+ demoted: boolean;
45
+ /** The debt reason to carry, present exactly when `demoted`. */
46
+ debtReason?: string;
47
+ }
48
+ export declare class AutofixLedger {
49
+ private readonly _budget;
50
+ /** Autofix attempts made in this loop, including the one in flight. */
51
+ private _attempts;
52
+ /**
53
+ * Gitignored paths the fix passes have written so far. ACCUMULATED across
54
+ * attempts: a `.env` written by a failed attempt is still on disk for the next
55
+ * one, and that attempt's own before/after diff cannot see it (mx5 run 19).
56
+ */
57
+ private _ignoredWritten;
58
+ /** Sub-fixes a non-converging attempt left uncommitted. REPLACED each attempt. */
59
+ private _stranded;
60
+ /** The previous attempt's normalized ranked-first failure. */
61
+ private _prevFailSig;
62
+ /** Signatures already carried as debt; a re-run reporting them does not re-fail. */
63
+ private readonly _demoted;
64
+ /**
65
+ * A write-guard rejected an attempt whose edits could NOT be discarded, so
66
+ * REJECTED edits are sitting in the tree and the terminal paths must not commit
67
+ * what they find there (mx5 run 14 item 2b — the cheat guard is never weakened
68
+ * to ease committing).
69
+ */
70
+ private _rejectedEditsInTree;
71
+ constructor(_budget: number);
72
+ /** One autofix attempt is starting. */
73
+ attempt(): number;
74
+ /** Are there attempts left? Drives whether the picker offers the card at all. */
75
+ canAutofix(): boolean;
76
+ attempts(): number;
77
+ /** Gitignored paths this attempt wrote. Accumulated, de-duplicated, order kept. */
78
+ wroteIgnored(paths: readonly string[] | undefined): void;
79
+ ignoredWrites(): readonly string[];
80
+ /** The uncommitted sub-fixes as of now. Replaces, never accumulates. */
81
+ setStranded(paths: readonly string[]): void;
82
+ stranded(): readonly string[];
83
+ /** A guard rejected an attempt and its edits are still in the tree. */
84
+ rejectedEditsRemain(): void;
85
+ /**
86
+ * May a terminal path commit what is in the working tree?
87
+ *
88
+ * False once a guard has rejected an attempt without discarding its edits.
89
+ * This is the ONE question the flag exists to answer, and it is asked at three
90
+ * terminal sites — as a method rather than a bare boolean read at each.
91
+ */
92
+ mayCommitTree(): boolean;
93
+ /**
94
+ * Judge this attempt's gate outcome, and record what the judgement implies.
95
+ *
96
+ * `edited` is the caller's fact — did this attempt change the tree and survive
97
+ * the guards. The observed/non-progress rule and the signature carry-forward
98
+ * are this module's, so they cannot be applied in the wrong order or skipped:
99
+ * a demoted signature is entered into the set and the previous-signature chain
100
+ * is broken in the SAME call that decides to demote.
101
+ */
102
+ judge(outcome: AutofixOutcomeView | undefined, edited: boolean): AttemptVerdict;
103
+ demotedCount(): number;
104
+ /**
105
+ * What still has to pass for the gate to converge: this outcome's failures with
106
+ * every already-demoted signature dropped. An EMPTY array means converged
107
+ * carrying the demotions as debt; `undefined` means the outcome named no list
108
+ * and there is nothing to reason about.
109
+ */
110
+ remaining(outcome: AutofixOutcomeView | undefined): string[] | undefined;
111
+ /** Has anything been demoted? Only then can a run converge on the remainder. */
112
+ hasDemotions(): boolean;
113
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * autofix-ledger — what the run-end RESOLUTION LOOP records, and the decisions
3
+ * that record makes.
4
+ *
5
+ * The same deepening `GateTally` already proved one altitude down. That replaced
6
+ * twelve mutable locals threaded through ~400 lines of `runFinalIntegrationGate`
7
+ * by closure; this replaces six threaded through ~235 lines of
8
+ * `runFinalGateStage` — the attempt count, the accumulated gitignored writes, the
9
+ * stranded sub-fixes, the previous failure signature, the demoted set, and the
10
+ * rejected-edits flag.
11
+ *
12
+ * The reason is not tidiness. `final-gate-progress.ts` holds five pure functions,
13
+ * each called from exactly ONE site inside that loop — extracted for testability
14
+ * while the decisions about ORDERING and CARRY-FORWARD stayed in the caller. That
15
+ * is precisely the shape `isNonProgress`'s own comment indicts:
16
+ *
17
+ * > The fix is NOT a better string pattern — the bug is that the decision was
18
+ * > made downstream from the evidence.
19
+ *
20
+ * mx5 run 21 shipped a product whose every page was blank as a `completed` run
21
+ * through that gap. Here the evidence and the decision sit in one module, and the
22
+ * loop body reads as picker → apply → record.
23
+ *
24
+ * What is NOT here: anything that talks to the user, writes a ledger file, or
25
+ * commits. The loop keeps those, the same way `GateTally` keeps no I/O — a record
26
+ * that performs effects cannot be driven by a test that only wants the verdict.
27
+ */
28
+ import { applyDemotions, isNonProgress, normalizeFailureDetail, rankedFirstFailure, unobservedDebtReason } from './final-gate-progress.js';
29
+ export class AutofixLedger {
30
+ _budget;
31
+ /** Autofix attempts made in this loop, including the one in flight. */
32
+ _attempts = 0;
33
+ /**
34
+ * Gitignored paths the fix passes have written so far. ACCUMULATED across
35
+ * attempts: a `.env` written by a failed attempt is still on disk for the next
36
+ * one, and that attempt's own before/after diff cannot see it (mx5 run 19).
37
+ */
38
+ _ignoredWritten = [];
39
+ /** Sub-fixes a non-converging attempt left uncommitted. REPLACED each attempt. */
40
+ _stranded = [];
41
+ /** The previous attempt's normalized ranked-first failure. */
42
+ _prevFailSig = null;
43
+ /** Signatures already carried as debt; a re-run reporting them does not re-fail. */
44
+ _demoted = new Set();
45
+ /**
46
+ * A write-guard rejected an attempt whose edits could NOT be discarded, so
47
+ * REJECTED edits are sitting in the tree and the terminal paths must not commit
48
+ * what they find there (mx5 run 14 item 2b — the cheat guard is never weakened
49
+ * to ease committing).
50
+ */
51
+ _rejectedEditsInTree = false;
52
+ constructor(_budget) {
53
+ this._budget = _budget;
54
+ }
55
+ // ─── Record ──────────────────────────────────────────────────────────────
56
+ /** One autofix attempt is starting. */
57
+ attempt() {
58
+ return ++this._attempts;
59
+ }
60
+ /** Are there attempts left? Drives whether the picker offers the card at all. */
61
+ canAutofix() {
62
+ return this._attempts < this._budget;
63
+ }
64
+ attempts() {
65
+ return this._attempts;
66
+ }
67
+ /** Gitignored paths this attempt wrote. Accumulated, de-duplicated, order kept. */
68
+ wroteIgnored(paths) {
69
+ if (!paths || paths.length === 0)
70
+ return;
71
+ for (const p of paths)
72
+ if (!this._ignoredWritten.includes(p))
73
+ this._ignoredWritten.push(p);
74
+ }
75
+ ignoredWrites() {
76
+ return this._ignoredWritten;
77
+ }
78
+ /** The uncommitted sub-fixes as of now. Replaces, never accumulates. */
79
+ setStranded(paths) {
80
+ this._stranded = [...paths];
81
+ }
82
+ stranded() {
83
+ return this._stranded;
84
+ }
85
+ /** A guard rejected an attempt and its edits are still in the tree. */
86
+ rejectedEditsRemain() {
87
+ this._rejectedEditsInTree = true;
88
+ }
89
+ /**
90
+ * May a terminal path commit what is in the working tree?
91
+ *
92
+ * False once a guard has rejected an attempt without discarding its edits.
93
+ * This is the ONE question the flag exists to answer, and it is asked at three
94
+ * terminal sites — as a method rather than a bare boolean read at each.
95
+ */
96
+ mayCommitTree() {
97
+ return !this._rejectedEditsInTree;
98
+ }
99
+ // ─── Decide ──────────────────────────────────────────────────────────────
100
+ /**
101
+ * Judge this attempt's gate outcome, and record what the judgement implies.
102
+ *
103
+ * `edited` is the caller's fact — did this attempt change the tree and survive
104
+ * the guards. The observed/non-progress rule and the signature carry-forward
105
+ * are this module's, so they cannot be applied in the wrong order or skipped:
106
+ * a demoted signature is entered into the set and the previous-signature chain
107
+ * is broken in the SAME call that decides to demote.
108
+ */
109
+ judge(outcome, edited) {
110
+ const detail = rankedFirstFailure({
111
+ ...(outcome ? { reason: outcome.reason } : {}),
112
+ ...(outcome?.failures ? { failures: outcome.failures } : {})
113
+ });
114
+ // Whether a PROBE OBSERVED this failure, read off the SAME outcome the
115
+ // failure came from — exact text identity, never a second string pattern.
116
+ const observed = outcome?.observedFailures?.includes(detail ?? '') === true;
117
+ if (detail !== null
118
+ && isNonProgress({
119
+ previousSignature: this._prevFailSig,
120
+ currentDetail: detail,
121
+ edited,
122
+ observed
123
+ })) {
124
+ this._demoted.add(normalizeFailureDetail(detail));
125
+ // A demotion ends the chain: the next attempt has no previous signature
126
+ // to match, so one demotion cannot cascade into a second.
127
+ this._prevFailSig = null;
128
+ return { detail, demoted: true, debtReason: unobservedDebtReason(detail) };
129
+ }
130
+ this._prevFailSig = detail !== null ? normalizeFailureDetail(detail) : null;
131
+ return { detail, demoted: false };
132
+ }
133
+ demotedCount() {
134
+ return this._demoted.size;
135
+ }
136
+ /**
137
+ * What still has to pass for the gate to converge: this outcome's failures with
138
+ * every already-demoted signature dropped. An EMPTY array means converged
139
+ * carrying the demotions as debt; `undefined` means the outcome named no list
140
+ * and there is nothing to reason about.
141
+ */
142
+ remaining(outcome) {
143
+ const failures = outcome?.failures ?? (outcome ? [outcome.reason] : undefined);
144
+ if (failures === undefined)
145
+ return undefined;
146
+ return applyDemotions(failures, this._demoted);
147
+ }
148
+ /** Has anything been demoted? Only then can a run converge on the remainder. */
149
+ hasDemotions() {
150
+ return this._demoted.size > 0;
151
+ }
152
+ }
@@ -294,5 +294,67 @@ export declare function recoverOrphanPort(cwd: string, boot: HealthCommand, firs
294
294
  outcome: 'orphan-port';
295
295
  detail: string;
296
296
  port: number | null;
297
- }, bootGraceMs: number, deps: BootDeps, expectServer: boolean): Promise<BootOutcome>;
297
+ },
298
+ /** One options object, not four trailing positionals — `bootGraceMs` and a
299
+ * boolean sat adjacent and swapped without a type error. */
300
+ opts: {
301
+ graceMs?: number;
302
+ deps: BootDeps;
303
+ expectServer: boolean;
304
+ }): Promise<BootOutcome>;
305
+ /**
306
+ * Everything the run-end gate records about "did the assembled product start?".
307
+ *
308
+ * This module owned the mechanics but not the CONCEPT: answering that question
309
+ * meant reading ~110 lines of `final-gate.ts` as well, where the four-armed
310
+ * {@link BootOutcome} union was destructured, the render/deep-render/port probe
311
+ * defaults were bound, `recoverOrphanPort` was re-invoked with six positional
312
+ * arguments, and the port-holder diagnosis reached back into `BootDeps` a SECOND
313
+ * time to build its own sentence. CONTEXT.md records the earlier extraction as
314
+ * "a file move, not a re-shaping"; this is the re-shaping it left open.
315
+ *
316
+ * The gate's boot branch is now: call this, write the fields into the tally.
317
+ * `runBootCheck` stays exported unchanged — seven harnesses under `scripts/`
318
+ * drive it directly.
319
+ */
320
+ export interface BootSectionVerdict {
321
+ /** The bin the gate counts as ATTEMPTED. Absent ⇒ there was nothing to boot. */
322
+ attempted?: string;
323
+ /** A probe LOOKED. False for a skip, and for a project with no launch surface. */
324
+ observed: boolean;
325
+ /** The runner binary never spawned — feeds the full-blindness guard. */
326
+ spawnFailedBin?: string;
327
+ /** The UNOBSERVED note, offered on both branches (a rejected launch script has one). */
328
+ unobservedNote?: string;
329
+ /**
330
+ * The one failure this section can produce, with the rank the gate gives it (0 —
331
+ * boot failures lead the aggregate) and whether a probe OBSERVED it. A harness
332
+ * condition (a port we could not clear) is a failure that nothing observed about
333
+ * the APP, which is why `observed` is a field and not implied by `failure`.
334
+ */
335
+ failure?: {
336
+ detail: string;
337
+ rank: number;
338
+ observed: boolean;
339
+ };
340
+ /** The label recorded as having RUN, on a clean boot. */
341
+ ranLabel?: string;
342
+ /** UNOBSERVED warnings — a listener that served but whose page could not be judged. */
343
+ warnings: string[];
344
+ }
345
+ export interface BootSectionOptions {
346
+ /** The plan text, for served-app detection. */
347
+ planText?: string;
348
+ /** Grace period for the boot check. */
349
+ graceMs?: number;
350
+ /**
351
+ * Probe overrides. The render, deep-render and preferred-port defaults are
352
+ * bound HERE now: they were the gate's, so a caller that wanted the real boot
353
+ * behaviour had to know to supply three functions it should never have had to
354
+ * name — `findPortHolder` was already defaulted inside this module, and the
355
+ * other three now match it.
356
+ */
357
+ deps?: BootDeps;
358
+ }
359
+ export declare function runBootSection(cwd: string, opts?: BootSectionOptions): Promise<BootSectionVerdict>;
298
360
  export {};