@mjasnikovs/pi-task 0.30.0 → 0.32.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,30 @@ 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
+ **Planning is read-only.** Nothing in your project is created, edited, or deleted while you plan — the planning model runs with a one-tool allowlist (`read`), which also excludes write tools contributed by any extension you have whitelisted for helper sessions. That is verified as well as prevented: the working tree is compared before and after every step, and if anything outside `.pi-tasks/` ever changes, the run says so loudly and records it in the plan file rather than carrying on quietly. Read-only ends the moment you choose **Proceed to execution** — from there it is a normal `/task` run and it writes code.
115
+
116
+ The one thing written during planning is the plan file itself, `.pi-tasks/TASK_PLAN_NNNN.md` — the task prompt, the `## decisions` transcript, and a separate `## notes` section for what you asked (a session you abandon before deciding anything deletes its own file, so an aborted plan leaves nothing at all). 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.
117
+
118
+ 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.
119
+
89
120
  ## Orchestrating multiple tasks — `/task-auto`
90
121
 
91
122
  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
  /**
@@ -1290,6 +1290,15 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1290
1290
  openDebts: fresh.openDebts,
1291
1291
  ...(fresh.debtNote ? { debtNote: fresh.debtNote } : {})
1292
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
+ }
1293
1302
  // Identity is (task, origin, reason), but a RESOLUTION claim
1294
1303
  // needs more than a key miss: a ledger entry whose TEXT changed
1295
1304
  // is the same defect re-recorded, never a fix. So a debt counts
@@ -1309,7 +1318,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1309
1318
  return;
1310
1319
  }
1311
1320
  for (const d of closed) {
1312
- await recGate(`defect RESOLVED by the final-gate autofix — ${d.taskId || '(unknown task)'}: `
1321
+ await recGate(`defect RESOLVED — ${d.taskId || '(unknown task)'}: `
1313
1322
  + `${d.reason.slice(0, 240)}`);
1314
1323
  }
1315
1324
  await surfaceOpenDebts(added);
@@ -1,5 +1,5 @@
1
1
  import { type HealthCommand } from './repo-health-check.js';
2
- import { type AcceptDebt } from './accept-debt.js';
2
+ import { type AcceptDebt, type VerifyRerunResult } from './accept-debt.js';
3
3
  import { type RenderOutcome } from './render-check.js';
4
4
  import { type DeepRenderOutcome } from './deep-render-check.js';
5
5
  import { taskThatIntroduced } from './task-provenance.js';
@@ -344,6 +344,40 @@ export declare function discoverGateCommandBodies(cwd: string): Record<string, s
344
344
  * this box; the same wording in a `test` run is a real failure the suite must own).
345
345
  */
346
346
  export declare const INFRA_GAP_OUTPUT_RE: RegExp;
347
+ /**
348
+ * How a re-run of ONE recorded VERIFY command line ended.
349
+ * pass — it ran and exited 0. The ONLY outcome that may close a debt.
350
+ * fail — it ran and exited non-zero for a real reason. Debt stays open.
351
+ * gap — nothing was observed: the shell/runner never spawned, 127 inside the
352
+ * chain, a timeout, a missing browser, or absent external infrastructure.
353
+ * INCONCLUSIVE, so the debt stays open (surface, never re-hide).
354
+ */
355
+ export type VerifyRerunOutcome = {
356
+ outcome: 'pass';
357
+ } | {
358
+ outcome: 'fail';
359
+ status: number;
360
+ tail: string;
361
+ } | {
362
+ outcome: 'gap';
363
+ detail: string;
364
+ };
365
+ /**
366
+ * Re-run one VERIFY-block command line (nexttask 5) under the gate's existing
367
+ * env-gap contract, so a debt whose reason NAMES that command can be closed by the
368
+ * command itself rather than by a judgement about it.
369
+ *
370
+ * Runs through `sh -c` because a VERIFY line is a shell line, not an argv: run 19's
371
+ * is `AGENT=1 bun test test/listings.test.ts`, and env prefixes, `&&` and redirects
372
+ * are all ordinary there. The leading command word is still resolved through
373
+ * runner-resolve so a login-shell-stripped PATH cannot make every re-run look like a
374
+ * gap (mx5 run 16's blindness, one level down).
375
+ *
376
+ * The asymmetry is the point: only exit 0 is conclusive. Every other ending — real
377
+ * failure, missing tool, unreachable database, timeout, no POSIX shell — leaves the
378
+ * debt exactly as open as it was.
379
+ */
380
+ export declare function runVerifyCommandLine(cwd: string, line: string, timeoutMs: number, extraGapRe?: RegExp): VerifyRerunOutcome;
347
381
  /**
348
382
  * The full-skip blindness guard (mx5 run 16, validated): dynamic commands were
349
383
  * DISCOVERED but every single one skipped as an environment gap, so the gate
@@ -484,7 +518,26 @@ export { taskThatIntroduced };
484
518
  export declare function deriveOpenDebts(cwd: string, staticOk: boolean): Promise<{
485
519
  openDebts: AcceptDebt[];
486
520
  debtNote?: string;
521
+ trail?: string[];
487
522
  }>;
523
+ /**
524
+ * Re-run ONE debt's stored VERIFY command for the re-check, with the no-write guard
525
+ * (`inv-no-write`) wrapped around it.
526
+ *
527
+ * A VERIFY command is the project's own command and may legitimately write (a build
528
+ * emits `dist/`, a suite writes a snapshot). What it may NOT do is turn the tree into
529
+ * a passing tree and have that count as the debt being fixed — the run would then be
530
+ * certifying its own side effect. So tracked state is captured before and after, and
531
+ * a pass that came with a tracked change is downgraded to INCONCLUSIVE with the
532
+ * change named. Untracked output is left alone: it is what a build legitimately
533
+ * produces, and `git status --porcelain` in a repo with the usual ignores does not
534
+ * see it.
535
+ *
536
+ * A repository the guard cannot read (no git, git absent) is not a licence to skip
537
+ * the guard: the re-run is INCONCLUSIVE there, because "nothing changed" would be an
538
+ * assumption rather than an observation.
539
+ */
540
+ export declare function rerunDebtVerifyCommand(cwd: string, command: string): VerifyRerunResult;
488
541
  /**
489
542
  * Run the final gate: static analysis first, then the lockfile consistency
490
543
  * checks, then the discovered integration commands, then one boot exercise of