@mjasnikovs/pi-task 0.29.3 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,6 +46,12 @@ One change — `/task` runs it through the full pipeline and hands the finished
46
46
  /task add rate limiting to the /api/upload endpoint
47
47
  ```
48
48
 
49
+ One change, but you want a say in HOW — `/task-plan` talks it through with you first, one question at a time, then hands the decisions to `/task`:
50
+
51
+ ```
52
+ /task-plan add rate limiting to the /api/upload endpoint
53
+ ```
54
+
49
55
  A whole plan — `/task-auto` splits it into an ordered task list and runs each one through `/task`:
50
56
 
51
57
  ```
@@ -59,6 +65,7 @@ A whole plan — `/task-auto` splits it into an ordered task list and runs each
59
65
  | Command | What it does |
60
66
  | --- | --- |
61
67
  | `/task <prompt>` | Start a new task and run it through the full pipeline. |
68
+ | `/task-plan <prompt>` | Plan one task with the model — it asks, you answer, ask it something back, or proceed — then run it through `/task`. |
62
69
  | `/task-list` | Open the task list in an editor dialog. |
63
70
  | `/task-resume [id]` | Resume the most recent (or named) unfinished task. |
64
71
  | `/task-cancel` | Cancel the running task (soft-terminal — still resumable). |
@@ -86,6 +93,28 @@ A whole plan — `/task-auto` splits it into an ordered task list and runs each
86
93
 
87
94
  The finished spec is delivered to your main `pi` conversation via `sendUserMessage`, so you keep working in the same chat — no context handoff, no copy-paste.
88
95
 
96
+ ## Planning one task — `/task-plan`
97
+
98
+ `/task` decides most things for you: refine sharpens the ask, research gathers context, and grill only surfaces the questions its research could not settle. That is the right trade when you want the change done. When you want a say in **how** it gets done, `/task-plan` puts the conversation first.
99
+
100
+ ```
101
+ /task-plan add rate limiting to the /api/upload endpoint
102
+ ```
103
+
104
+ It works like the clarify step you already know — **one question at a time, each one shaped by your last answer** — with a recommendation you can take with one keystroke. The difference is that three moves are on screen at *every* prompt, so the conversation is yours to steer:
105
+
106
+ | Move | What it does |
107
+ | --- | --- |
108
+ | **Answer** | Take the recommendation (or the `B` option), or type your own answer. Empty submit = accept the recommendation. |
109
+ | **❓ Ask the model a question** | You ask, it answers — grounded in the repo, with the read tool. The answer is recorded as a **note**, and the same question you were being asked comes straight back. Notes do not decide anything; they are context, and they ride into the next question. |
110
+ | **▶ Proceed to execution** | Stop planning and hand what you have to `/task`. Available from the very first prompt — you are never forced through a question you do not care about. |
111
+
112
+ When the model runs out of questions it says so and offers the same three moves rather than proceeding behind your back. Answering something new re-opens it: a decision you volunteer can make a fresh question worth asking.
113
+
114
+ Everything lands in `.pi-tasks/TASK_PLAN_NNNN.md` — the task prompt, the `## decisions` transcript, and a separate `## notes` section for what you asked. Only the decisions are handed to `/task`, as an authoritative block ahead of your original prompt; the notes stay behind, because an answer you read is not a decision you made. From there it is an ordinary `/task` run — same pipeline, same gates.
115
+
116
+ It works from the browser too (see [Remote](#remote--drive-a-task-from-your-phone)): the prompt card grows an **Ask the model** and a **Proceed to execution** button alongside the usual Accept / Manual answer.
117
+
89
118
  ## Orchestrating multiple tasks — `/task-auto`
90
119
 
91
120
  A real feature is usually several tasks, not one. `/task-auto` is a thin planner on top of the single-task pipeline:
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { registerConfig } from './config/register.js';
2
2
  import { registerTask } from './task/orchestrator.js';
3
3
  import { registerTaskAuto } from './task/auto-orchestrator.js';
4
+ import { registerTaskPlan } from './task/plan-orchestrator.js';
4
5
  import { registerWorkers } from './workers/index.js';
5
6
  import { registerRemote } from './remote/register.js';
6
7
  import { registerThinkingCompression } from './thinking/compress.js';
@@ -10,6 +11,7 @@ export default function (pi) {
10
11
  registerConfig(pi);
11
12
  registerTask(pi);
12
13
  registerTaskAuto(pi);
14
+ registerTaskPlan(pi);
13
15
  registerWorkers(pi);
14
16
  registerRemote(pi);
15
17
  registerThinkingCompression(pi);
@@ -52,6 +52,24 @@ export interface AskSpec {
52
52
  label: string;
53
53
  value: string;
54
54
  }[];
55
+ /**
56
+ * Label for the local picker's trailing free-text card (see
57
+ * {@link AskQuestionBoxSpec.manualLabel}). Ignored without `options`.
58
+ */
59
+ manualLabel?: string;
60
+ /**
61
+ * Extra buttons the BROWSER card shows alongside the recommendation, each
62
+ * answering with its own `value`. Unlike `options` — which are answers, and
63
+ * which the remote already covers with the recommended/recommended2 buttons —
64
+ * these are ACTIONS that mean something other than "here is my answer", so
65
+ * the remote cannot express them by any existing field. /task-plan's "ask the
66
+ * model" and "proceed to execution" are the only producers; every other call
67
+ * site omits this and the card is byte-for-byte what it was.
68
+ */
69
+ actions?: {
70
+ label: string;
71
+ value: string;
72
+ }[];
55
73
  }
56
74
  /** Wraps a live command ctx and fans interactions out to local TUI + browsers. */
57
75
  export declare class SessionUI {
@@ -54,6 +54,7 @@ export class SessionUI {
54
54
  question: spec.question,
55
55
  recommended: spec.recommended,
56
56
  ...(spec.recommended2 !== undefined && { recommended2: spec.recommended2 }),
57
+ ...(spec.actions !== undefined && spec.actions.length > 0 && { actions: spec.actions }),
57
58
  allowSkip: spec.allowSkip
58
59
  };
59
60
  setPrompt(prompt);
@@ -99,6 +100,7 @@ export class SessionUI {
99
100
  value: o.value,
100
101
  recommended: i === 0
101
102
  })),
103
+ ...(spec.manualLabel !== undefined && { manualLabel: spec.manualLabel }),
102
104
  signal
103
105
  });
104
106
  }
@@ -4,6 +4,15 @@ export interface PromptMessage {
4
4
  question: string;
5
5
  recommended?: string;
6
6
  recommended2?: string;
7
+ /**
8
+ * Buttons that answer with their own `value` instead of with an answer to the
9
+ * question — /task-plan's "ask the model" and "proceed to execution". Absent
10
+ * on every other prompt, so an older card renders exactly as before.
11
+ */
12
+ actions?: {
13
+ label: string;
14
+ value: string;
15
+ }[];
7
16
  allowSkip: boolean;
8
17
  }
9
18
  export interface PromptResolvedMessage {
@@ -109,6 +109,7 @@ export function clientScript(wsUrl) {
109
109
  let activePromptId = null;
110
110
  let activeRecommended = '';
111
111
  let activeRecommended2 = '';
112
+ let activeActions = [];
112
113
  let cancelArmTimer = null;
113
114
  const toolCallMap = {};
114
115
  let currentBubble = null;
@@ -144,6 +145,7 @@ export function clientScript(wsUrl) {
144
145
 
145
146
  const COMMANDS = [
146
147
  { name: '/task', desc: 'Start a new task' },
148
+ { name: '/task-plan', desc: 'Plan one task with the model, then run it' },
147
149
  { name: '/task-list', desc: 'List tasks in this project' },
148
150
  { name: '/task-resume', desc: 'Resume a task' },
149
151
  { name: '/task-cancel', desc: 'Cancel the currently running task' },
@@ -782,6 +784,7 @@ export function clientScript(wsUrl) {
782
784
  promptInput.style.display = 'none';
783
785
  promptRec.style.display = 'none';
784
786
  activeRecommended2 = '';
787
+ activeActions = [];
785
788
  if (cancelArmTimer) { clearTimeout(cancelArmTimer); cancelArmTimer = null; }
786
789
  refreshComposer();
787
790
  }
@@ -815,10 +818,26 @@ export function clientScript(wsUrl) {
815
818
  return btn;
816
819
  }
817
820
 
821
+ // Action buttons (/task-plan's "ask the model" / "proceed to execution").
822
+ // They answer with their own sentinel value rather than with an answer to the
823
+ // question, so they sit between the answer buttons and Cancel. Empty — and
824
+ // therefore invisible — for every prompt that carries no actions.
825
+ function makeActionBtns() {
826
+ const out = [];
827
+ for (let i = 0; i < activeActions.length; i++) {
828
+ (function (a) {
829
+ out.push(makeBtn(a.label, 'secondary', function () { answer(a.value); }));
830
+ })(activeActions[i]);
831
+ }
832
+ return out;
833
+ }
834
+
818
835
  function renderButtons(buttons, stacked) {
819
836
  promptButtons.className = stacked ? 'row stacked' : 'row';
820
837
  promptButtons.innerHTML = '';
821
838
  for (let i = 0; i < buttons.length; i++) promptButtons.appendChild(buttons[i]);
839
+ const actions = makeActionBtns();
840
+ for (let i = 0; i < actions.length; i++) promptButtons.appendChild(actions[i]);
822
841
  promptButtons.appendChild(makeCancelBtn());
823
842
  }
824
843
 
@@ -861,6 +880,7 @@ export function clientScript(wsUrl) {
861
880
  promptQ.textContent = msg.question;
862
881
  activeRecommended = msg.recommended || '';
863
882
  activeRecommended2 = msg.recommended2 || '';
883
+ activeActions = msg.actions || [];
864
884
  if (msg.recommended) {
865
885
  // Mode A: recommendation(s) present. Render markdown in the panel so a
866
886
  // recommendation with code/emphasis reads the same as an assistant bubble.
@@ -64,6 +64,19 @@ export interface AcceptDebt {
64
64
  * child `rm`'d TASK_0008's verified admin page to satisfy exactly such a claim.
65
65
  */
66
66
  conflict?: string;
67
+ /**
68
+ * The ONE command this debt's own reason NAMES, quoted verbatim in backticks and
69
+ * present byte-identically in the owning task's VERIFY block (nexttask 5, mx5 run
70
+ * 19 TASK_0009). Set at record time by classifyVerifyCommand; absent whenever the
71
+ * reason names no such command — which is most of them (measured: 2 of 19
72
+ * PROJECT-pool debts, `scripts/debt-verify-class-baserate.ts`).
73
+ *
74
+ * It exists so the run-end re-check can settle the debt the way the debt was
75
+ * created: by RUNNING the command and reading its exit status. Never
76
+ * synthesised, never paraphrased, never reconstructed from prose — the stored
77
+ * string is the VERIFY-block line itself (`inv-command-provenance`).
78
+ */
79
+ verifyCommand?: string;
67
80
  }
68
81
  export declare function acceptDebtFile(cwd: string): string;
69
82
  /** The raw stored ledger ('' when none recorded yet). Parse with parseAcceptDebts. */
@@ -159,20 +172,56 @@ export declare function writeAcceptDebts(cwd: string, debts: AcceptDebt[]): Prom
159
172
  * behavioral and cannot be proven resolved without re-running the model.
160
173
  */
161
174
  export declare function isStaticClassDebt(reason: string): boolean;
175
+ /**
176
+ * Read the owning task's spec and return the VERIFY command its FAIL reason names,
177
+ * or null. Best-effort by design: a missing task file, an unparseable or UNCLOSED
178
+ * VERIFY fence, or a reason that quotes nothing all yield null, and null simply
179
+ * means the debt keeps today's behaviour (surfaced, never auto-closed).
180
+ *
181
+ * The STRICT parse matters here. mx5 run 19's `TASK_0001.md` opens ```sh and never
182
+ * closes it, so the lenient parser hands back the phase-timings table and every
183
+ * appended gate-trail line as "VERIFY commands" — including sentences that quote
184
+ * `bun run lint`. Matching a reason against that would mint a stored, re-runnable
185
+ * command with fabricated provenance, which is the one thing this class may not do.
186
+ */
187
+ export declare function classifyVerifyCommand(cwd: string, taskId: string, reason: string): Promise<string | null>;
188
+ export declare function verifyCommandFromReason(reason: string, verifyCommands: readonly string[]): string | null;
189
+ /**
190
+ * How a re-run of a debt's stored VERIFY command ended, as the re-check sees it.
191
+ * `pass` is the ONLY conclusive outcome: everything else — a real failure, a missing
192
+ * tool, unreachable infrastructure, a timeout, a tree the run mutated — leaves the
193
+ * debt exactly as open as it was.
194
+ */
195
+ export interface VerifyRerunResult {
196
+ outcome: 'pass' | 'fail' | 'gap';
197
+ detail?: string;
198
+ }
162
199
  /**
163
200
  * Re-check the ledger against the current run state. A static-class debt is RESOLVED
164
201
  * iff the final gate's own static check now passes (`staticOk`); a cross-task-deletion
165
202
  * debt is RESOLVED iff the file it names is back in the tree (`fileExists` — a later
166
203
  * task or a human restored it, so the deletion no longer holds); every other debt
167
- * stays OPEN (unprovable ⇒ surface, never re-hide). FP-safe: the only auto-closes are
168
- * ones a deterministic check can stand behind.
204
+ * stays OPEN (unprovable ⇒ surface, never re-hide) unless it carries a stored
205
+ * `verifyCommand` and `rerunVerify` re-runs that command to a ZERO exit, which is the
206
+ * third class (nexttask 5): the debt named a command, the command was run, and it
207
+ * passed. FP-safe: the only auto-closes are ones a deterministic check can stand
208
+ * behind, and here the check is the task spec's own command.
169
209
  */
170
210
  export declare function recheckAcceptDebts(debts: AcceptDebt[], opts: {
171
211
  staticOk: boolean;
172
212
  fileExists?: (rel: string) => boolean;
213
+ /**
214
+ * Re-run a debt's stored VERIFY command (nexttask 5). Absent ⇒ the class is
215
+ * inert and every debt behaves exactly as it did before it existed. Only
216
+ * `pass` may close a debt; `fail` and `gap` both leave it open, and the
217
+ * caller is expected to have made `pass` mean "ran, exited 0, and changed
218
+ * nothing tracked" (`inv-no-write`).
219
+ */
220
+ rerunVerify?: (command: string, debt: AcceptDebt) => VerifyRerunResult;
173
221
  }): {
174
222
  open: AcceptDebt[];
175
223
  resolved: AcceptDebt[];
224
+ trail: string[];
176
225
  };
177
226
  /**
178
227
  * Extract the paths whose EXISTENCE the reason asserts as the failure — a path
@@ -27,7 +27,8 @@
27
27
  */
28
28
  import * as fsp from 'node:fs/promises';
29
29
  import * as path from 'node:path';
30
- import { tasksDir } from './task-io.js';
30
+ import { parseVerifyBlockStrict } from './spec-validation.js';
31
+ import { taskFilePath, tasksDir } from './task-io.js';
31
32
  const ACCEPT_DEBT_FILE = 'accept-debt.md';
32
33
  /** Cap kept records so a run that accepts many FAILs cannot grow the report unboundedly. */
33
34
  const MAX_DEBTS = 60;
@@ -71,6 +72,9 @@ export function parseAcceptDebts(raw) {
71
72
  continue;
72
73
  }
73
74
  const origin = parts[2]?.trim();
75
+ // 4th field: the verbatim VERIFY command the reason names (nexttask 5).
76
+ // Absent in every legacy record, and absent in most new ones.
77
+ const verifyCommand = parts[3]?.trim();
74
78
  out.push({
75
79
  taskId: parts[0].trim(),
76
80
  reason: parts[1].trim(),
@@ -82,7 +86,8 @@ export function parseAcceptDebts(raw) {
82
86
  || origin === 'final-gate'
83
87
  || origin === 'root-cause') ?
84
88
  { origin: origin }
85
- : {})
89
+ : {}),
90
+ ...(verifyCommand !== undefined && verifyCommand.length > 0 ? { verifyCommand } : {})
86
91
  });
87
92
  }
88
93
  return out;
@@ -101,6 +106,11 @@ function normaliseReason(reason) {
101
106
  function serialize(d) {
102
107
  // Legacy 2-field shape for 'accepted' (backward compatible); a 3rd origin field
103
108
  // only for the non-accepted classes, so old readers/files round-trip unchanged.
109
+ // The 4th verify-command field forces the origin field to be written (positional
110
+ // format) — 'accepted' spelled out there parses back to the same absent origin.
111
+ if (d.verifyCommand !== undefined && d.verifyCommand.length > 0) {
112
+ return [d.taskId, d.reason, d.origin ?? 'accepted', d.verifyCommand].join(FIELD_SEP);
113
+ }
104
114
  return d.origin && d.origin !== 'accepted' ?
105
115
  `${d.taskId}${FIELD_SEP}${d.reason}${FIELD_SEP}${d.origin}`
106
116
  : `${d.taskId}${FIELD_SEP}${d.reason}`;
@@ -119,6 +129,12 @@ async function appendDebt(cwd, entry) {
119
129
  if (entry.reason.length === 0)
120
130
  return;
121
131
  try {
132
+ // Classify AT RECORD TIME, against the spec as it stands when the defect is
133
+ // recorded (nexttask 5). Doing it later would read a spec a subsequent task
134
+ // may have rewritten — the provenance claim has to be made where it is true.
135
+ const verifyCommand = entry.verifyCommand ?? (await classifyVerifyCommand(cwd, entry.taskId, entry.reason));
136
+ if (verifyCommand !== null && verifyCommand !== undefined)
137
+ entry = { ...entry, verifyCommand };
122
138
  const existing = parseAcceptDebts(await readAcceptDebtsRaw(cwd));
123
139
  const seen = new Set(existing.map(debtKey));
124
140
  if (seen.has(debtKey(entry)))
@@ -269,17 +285,101 @@ export async function writeAcceptDebts(cwd, debts) {
269
285
  export function isStaticClassDebt(reason) {
270
286
  return /^\s*repo health:/i.test(reason);
271
287
  }
288
+ /**
289
+ * The VERIFY-COMMAND class (nexttask 5): the ONE command a recorded reason itself
290
+ * NAMES, quoted verbatim in backticks, and present byte-identically in the owning
291
+ * task's VERIFY block.
292
+ *
293
+ * mx5 run 19 recorded `work did not verify: The VERIFY block command \`AGENT=1 bun
294
+ * test test/listings.test.ts\` fails unaided …`; eleven minutes later the final-gate
295
+ * autofix fixed exactly that, the gate's own re-run printed `121 pass 0 fail`, and
296
+ * the run still ended reporting the debt STILL OPEN — because no reachable code path
297
+ * could ever have closed it (see recheckAcceptDebts: two classes, neither reachable
298
+ * for a `work did not verify:` reason).
299
+ *
300
+ * Deliberately NOT fuzzy: a backticked span is accepted only when it equals a parsed
301
+ * VERIFY line exactly (after trimming). No paraphrase, no reconstruction, no prefix
302
+ * match — the returned string is the VERIFY-block entry itself, so anything stored
303
+ * or re-run carries the task spec's own provenance (`inv-command-provenance`). A
304
+ * reason that quotes a path, a symbol or a truncated command matches nothing and the
305
+ * debt stays unclassified, i.e. exactly as un-closable as it is today.
306
+ */
307
+ /**
308
+ * A stored command must survive the ledger's own storage format and stay something
309
+ * a shell can be handed verbatim. A tab would break the positional record, a newline
310
+ * would split it into two, and an over-long line is a heredoc/prose artefact rather
311
+ * than a command. Any of those ⇒ not stored ⇒ the debt is simply unclassified, i.e.
312
+ * exactly as un-closable as it is today.
313
+ */
314
+ function isStorableCommand(cmd) {
315
+ return cmd.length > 0 && cmd.length <= MAX_REASON_LENGTH && !/[\t\n\r]/.test(cmd);
316
+ }
317
+ /**
318
+ * Read the owning task's spec and return the VERIFY command its FAIL reason names,
319
+ * or null. Best-effort by design: a missing task file, an unparseable or UNCLOSED
320
+ * VERIFY fence, or a reason that quotes nothing all yield null, and null simply
321
+ * means the debt keeps today's behaviour (surfaced, never auto-closed).
322
+ *
323
+ * The STRICT parse matters here. mx5 run 19's `TASK_0001.md` opens ```sh and never
324
+ * closes it, so the lenient parser hands back the phase-timings table and every
325
+ * appended gate-trail line as "VERIFY commands" — including sentences that quote
326
+ * `bun run lint`. Matching a reason against that would mint a stored, re-runnable
327
+ * command with fabricated provenance, which is the one thing this class may not do.
328
+ */
329
+ export async function classifyVerifyCommand(cwd, taskId, reason) {
330
+ if (taskId.trim().length === 0)
331
+ return null;
332
+ try {
333
+ const spec = await fsp.readFile(taskFilePath(cwd, taskId.trim()), 'utf8');
334
+ const cmds = parseVerifyBlockStrict(spec);
335
+ if (cmds === null || cmds.length === 0)
336
+ return null;
337
+ const hit = verifyCommandFromReason(reason, cmds.map(c => c.raw));
338
+ return hit !== null && isStorableCommand(hit) ? hit : null;
339
+ }
340
+ catch {
341
+ return null;
342
+ }
343
+ }
344
+ export function verifyCommandFromReason(reason, verifyCommands) {
345
+ const byText = new Map();
346
+ for (const c of verifyCommands) {
347
+ const t = c.trim();
348
+ if (t.length > 0 && !byText.has(t))
349
+ byText.set(t, t);
350
+ }
351
+ if (byText.size === 0)
352
+ return null;
353
+ for (const m of reason.matchAll(/`([^`]+)`/g)) {
354
+ const hit = byText.get(m[1].trim());
355
+ if (hit !== undefined)
356
+ return hit;
357
+ }
358
+ return null;
359
+ }
360
+ /**
361
+ * Re-runs allowed per re-check. `inv-bounded`: a run that accepted many command-shaped
362
+ * FAILs must not turn its own report into an unbounded second test suite. Three covers
363
+ * every recorded run in the corpus (max classified per run: 1) with room to spare, and
364
+ * anything past it stays open with the budget stated in the trail — never closed.
365
+ */
366
+ const MAX_VERIFY_RERUNS = 3;
272
367
  /**
273
368
  * Re-check the ledger against the current run state. A static-class debt is RESOLVED
274
369
  * iff the final gate's own static check now passes (`staticOk`); a cross-task-deletion
275
370
  * debt is RESOLVED iff the file it names is back in the tree (`fileExists` — a later
276
371
  * task or a human restored it, so the deletion no longer holds); every other debt
277
- * stays OPEN (unprovable ⇒ surface, never re-hide). FP-safe: the only auto-closes are
278
- * ones a deterministic check can stand behind.
372
+ * stays OPEN (unprovable ⇒ surface, never re-hide) unless it carries a stored
373
+ * `verifyCommand` and `rerunVerify` re-runs that command to a ZERO exit, which is the
374
+ * third class (nexttask 5): the debt named a command, the command was run, and it
375
+ * passed. FP-safe: the only auto-closes are ones a deterministic check can stand
376
+ * behind, and here the check is the task spec's own command.
279
377
  */
280
378
  export function recheckAcceptDebts(debts, opts) {
281
379
  const open = [];
282
380
  const resolved = [];
381
+ const trail = [];
382
+ let rerunsLeft = MAX_VERIFY_RERUNS;
283
383
  for (const d of debts) {
284
384
  if (d.origin === 'cross-task-deletion') {
285
385
  const p = extractDeletedDebtPath(d.reason);
@@ -296,12 +396,45 @@ export function recheckAcceptDebts(debts, opts) {
296
396
  open.push(d);
297
397
  continue;
298
398
  }
299
- if (opts.staticOk && isStaticClassDebt(d.reason))
399
+ if (opts.staticOk && isStaticClassDebt(d.reason)) {
300
400
  resolved.push(d);
301
- else
401
+ continue;
402
+ }
403
+ // VERIFY-COMMAND class, LAST so the two older classes decide exactly what they
404
+ // decided before (`inv-existing-classes-kept`) and nothing is re-run that was
405
+ // already settled without running anything.
406
+ const cmd = d.verifyCommand;
407
+ if (opts.rerunVerify === undefined || cmd === undefined || !isStorableCommand(cmd)) {
408
+ open.push(d);
409
+ continue;
410
+ }
411
+ if (rerunsLeft <= 0) {
412
+ trail.push(`${d.taskId}: NOT re-checked — the per-run re-run budget `
413
+ + `(${MAX_VERIFY_RERUNS}) is spent; the debt stays open`);
302
414
  open.push(d);
415
+ continue;
416
+ }
417
+ rerunsLeft -= 1;
418
+ let r;
419
+ try {
420
+ r = opts.rerunVerify(cmd, d);
421
+ }
422
+ catch {
423
+ // A harness fault observes nothing, so it proves nothing.
424
+ r = { outcome: 'gap', detail: 're-run harness fault' };
425
+ }
426
+ if (r.outcome === 'pass') {
427
+ resolved.push(d);
428
+ trail.push(`${d.taskId}: RESOLVED — re-ran \`${cmd}\` and it exited 0`);
429
+ continue;
430
+ }
431
+ trail.push(`${d.taskId}: still open — re-ran \`${cmd}\`: `
432
+ + (r.outcome === 'fail' ?
433
+ `it FAILED${r.detail ? ` (${r.detail})` : ''}`
434
+ : `INCONCLUSIVE${r.detail ? ` (${r.detail})` : ''}, nothing was observed`));
435
+ open.push(d);
303
436
  }
304
- return { open, resolved };
437
+ return { open, resolved, trail };
305
438
  }
306
439
  // ─── Conflicting-claim classification (mx5 run 11) ──────────────────────────
307
440
  //
@@ -73,6 +73,7 @@ export interface AutoDeps extends GateDeps {
73
73
  recheckOpenDebts?: (cwd: string, staticOk: boolean) => Promise<{
74
74
  openDebts: AcceptDebt[];
75
75
  debtNote?: string;
76
+ trail?: string[];
76
77
  }>;
77
78
  }
78
79
  /**
@@ -35,6 +35,7 @@ import { runGatesForTask } from './task-gates.js';
35
35
  import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
36
36
  import { runFinalIntegrationGate, deriveOpenDebts } from './final-gate.js';
37
37
  import { describeDebt, recordFinalGateUnobservedDebt } from './accept-debt.js';
38
+ import { ignoredWriteTrailLine, ignoredWriteDebtReason } from './write-guard.js';
38
39
  import { applyDemotions, isNonProgress, normalizeFailureDetail, rankedFirstFailure, unobservedDebtReason } from './final-gate-progress.js';
39
40
  import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FINAL_LEAVE_VALUE, FINAL_ACCEPT_LABEL, FINAL_ACCEPT_VALUE, FINAL_AUTOFIX_LABEL, FINAL_AUTOFIX_VALUE, STRANDED_FIX_COMMIT, strandedFixNote } from './final-gate-fix.js';
40
41
  import { getConfig } from '../config/config.js';
@@ -1289,6 +1290,15 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1289
1290
  openDebts: fresh.openDebts,
1290
1291
  ...(fresh.debtNote ? { debtNote: fresh.debtNote } : {})
1291
1292
  };
1293
+ // Per-debt evidence from the VERIFY-COMMAND re-check
1294
+ // (nexttask 5): which command was re-run and what it did. A
1295
+ // close that cannot be read back from the trail is a close
1296
+ // nobody can audit, and an INCONCLUSIVE re-run is worth
1297
+ // saying out loud — it is the difference between "still
1298
+ // broken" and "nothing could observe it".
1299
+ for (const line of fresh.trail ?? []) {
1300
+ await recGate(`defect re-check: ${line}`);
1301
+ }
1292
1302
  // Identity is (task, origin, reason), but a RESOLUTION claim
1293
1303
  // needs more than a key miss: a ledger entry whose TEXT changed
1294
1304
  // is the same defect re-recorded, never a fix. So a debt counts
@@ -1308,7 +1318,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1308
1318
  return;
1309
1319
  }
1310
1320
  for (const d of closed) {
1311
- await recGate(`defect RESOLVED by the final-gate autofix — ${d.taskId || '(unknown task)'}: `
1321
+ await recGate(`defect RESOLVED — ${d.taskId || '(unknown task)'}: `
1312
1322
  + `${d.reason.slice(0, 240)}`);
1313
1323
  }
1314
1324
  await surfaceOpenDebts(added);
@@ -1322,6 +1332,11 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1322
1332
  // after MAX_FINAL_GATE_AUTOFIX attempts that still FAIL the
1323
1333
  // autofix card is withdrawn so the loop cannot run unbounded.
1324
1334
  let fixAttempts = 0;
1335
+ // Gitignored paths the fix passes have written so far in this
1336
+ // resolution loop (mx5 run 19). Accumulated across attempts: a
1337
+ // `.env` written by a failed attempt is still on disk for the next
1338
+ // one, and that attempt's own before/after diff cannot see it.
1339
+ let ignoredWritten = [];
1325
1340
  // Sub-fixes a non-converging autofix attempt left uncommitted.
1326
1341
  // Refreshed after every attempt; drives the picker note and the
1327
1342
  // terminal commit (mx5 run 13 PROMPT 4 item 3, run 14 item 2b).
@@ -1446,7 +1461,30 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1446
1461
  const seed = choice.guidance ?
1447
1462
  `${fin.reason}\n\nUser guidance: ${choice.guidance}`
1448
1463
  : fin.reason;
1449
- const fix = await deps.finalGateFix(active, cwd, seed);
1464
+ const fix = await deps.finalGateFix(active, cwd, seed, ignoredWritten);
1465
+ // IGNORED-PATH WRITES (mx5 run 19). The pass wrote file(s)
1466
+ // git ignores, so they are not in the commit and a fresh
1467
+ // clone does not have them. Trailed on EVERY outcome — a
1468
+ // rejected attempt's tracked edits are discarded while its
1469
+ // ignored writes survive on disk — and carried forward, so a
1470
+ // later attempt's PASS is judged against everything this loop
1471
+ // wrote, not just its own attempt. PATH NAMES ONLY: an ignored
1472
+ // file's contents (`.env` is the canonical case) never enter a
1473
+ // log, a debt or a child prompt.
1474
+ if (fix.ignoredWrites && fix.ignoredWrites.length > 0) {
1475
+ ignoredWritten = [
1476
+ ...new Set([...ignoredWritten, ...fix.ignoredWrites])
1477
+ ].sort();
1478
+ await recGate(ignoredWriteTrailLine(fix.ignoredWrites));
1479
+ // Debt only where a verdict can rest on the file: the
1480
+ // probe proved the gate needs it, or the question stayed
1481
+ // open. A write the gate demonstrably does NOT need is
1482
+ // trailed and nothing more — a ledger full of scratch
1483
+ // files is a ledger nobody reads.
1484
+ if (fix.ignoredDependent !== false) {
1485
+ await recordFinalGateUnobservedDebt(cwd, id, ignoredWriteDebtReason(fix.ignoredWrites, fix.ignoredDependent));
1486
+ }
1487
+ }
1450
1488
  if (fix.ok) {
1451
1489
  await deps.commit(cwd, `FINAL GATE AUTOFIX (${id})`);
1452
1490
  // A converged re-run that observed nothing dynamic is
@@ -1,4 +1,4 @@
1
- import { type TreeChangeSummary } from './write-guard.js';
1
+ import { type TreeChangeSummary, type IgnoredSnapshot } from './write-guard.js';
2
2
  /** Same bounded-fix contract as lint-fix: edit in place, bash exists to RUN the
3
3
  * failing command (and the project's own tooling), not to mutate git state. */
4
4
  export declare const FINAL_FIX_TOOLS = "read,edit,bash";
@@ -108,6 +108,14 @@ export interface FinalFixResult {
108
108
  * labels a converge-on-statics-alone the same way it labels a first-pass one —
109
109
  * "converged" must never quietly mean "we stopped being able to check". */
110
110
  unobserved?: string;
111
+ /** Gitignored path(s) this fix pass wrote, exempt classes already removed (see
112
+ * write-guard.ts). Present whether or not the gate converged — the caller
113
+ * trails them either way; path names only, never contents. */
114
+ ignoredWrites?: string[];
115
+ /** …and the mechanical dependency probe found the converged gate does NOT pass
116
+ * without them, so `unobserved` above carries the downgrade. Absent when the
117
+ * probe could not answer (no probe wired, restore risk, too many paths). */
118
+ ignoredDependent?: boolean;
111
119
  /** A write-guard rejected this attempt (deletion / shrink / probe-gaming). */
112
120
  guardTripped?: boolean;
113
121
  /** …and its edits were discarded. When a guard tripped and this is false, the
@@ -158,6 +166,19 @@ export interface FinalFixDeps {
158
166
  * run 11's autofix replaced the typed client with a hand-written contract
159
167
  * copy to green the lint. Findings are verbatim offending lines. */
160
168
  probeScan?: () => Promise<string[]>;
169
+ /** Fingerprint of the ACTIONABLE ignored paths (build output and node_modules
170
+ * already exempt). Called before and after the child; the difference is what
171
+ * this pass wrote. Absent → the channel is off and behaviour is unchanged. */
172
+ ignoredSnapshot?: () => Promise<IgnoredSnapshot>;
173
+ /** Ignored paths EARLIER attempts in this resolution loop already wrote. An
174
+ * attempt that fails still leaves its ignored writes on disk (discard reverts
175
+ * tracked files only), so without this a `.env` written by attempt 1 would be
176
+ * invisible to attempt 2's before/after diff — and attempt 2's converged PASS
177
+ * would rest on it unrecorded. The caller accumulates. */
178
+ ignoredKnown?: string[];
179
+ /** The mechanical dependency test: does the gate still pass with these paths
180
+ * moved aside? `null` ⇒ unanswerable, which never downgrades a verdict. */
181
+ gateWithoutIgnored?: (paths: string[]) => Promise<boolean | null>;
161
182
  /** Write a timestamped line to the gate debug log (guard events). */
162
183
  log?: (msg: string) => void;
163
184
  }