@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 +29 -0
- package/dist/index.js +2 -0
- package/dist/remote/bridge.d.ts +18 -0
- package/dist/remote/bridge.js +2 -0
- package/dist/remote/protocol.d.ts +9 -0
- package/dist/remote/ui-script.js +20 -0
- package/dist/task/accept-debt.d.ts +51 -2
- package/dist/task/accept-debt.js +140 -7
- package/dist/task/auto-orchestrator.d.ts +1 -0
- package/dist/task/auto-orchestrator.js +40 -2
- package/dist/task/final-gate-fix.d.ts +22 -1
- package/dist/task/final-gate-fix.js +53 -6
- package/dist/task/final-gate.d.ts +54 -1
- package/dist/task/final-gate.js +115 -3
- package/dist/task/gate-deps.d.ts +41 -2
- package/dist/task/gate-deps.js +141 -3
- package/dist/task/plan-io.d.ts +55 -0
- package/dist/task/plan-io.js +94 -0
- package/dist/task/plan-orchestrator.d.ts +52 -0
- package/dist/task/plan-orchestrator.js +234 -0
- package/dist/task/plan-prompts.d.ts +40 -0
- package/dist/task/plan-prompts.js +138 -0
- package/dist/task/plan-session.d.ts +184 -0
- package/dist/task/plan-session.js +373 -0
- package/dist/task/question-box.d.ts +8 -0
- package/dist/task/question-box.js +1 -1
- package/dist/task/spec-validation.d.ts +14 -0
- package/dist/task/spec-validation.js +21 -1
- package/dist/task/widget.d.ts +4 -0
- package/dist/task/widget.js +2 -2
- package/dist/task/write-guard.d.ts +42 -0
- package/dist/task/write-guard.js +108 -0
- package/package.json +1 -1
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);
|
package/dist/remote/bridge.d.ts
CHANGED
|
@@ -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 {
|
package/dist/remote/bridge.js
CHANGED
|
@@ -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 {
|
package/dist/remote/ui-script.js
CHANGED
|
@@ -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)
|
|
168
|
-
*
|
|
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
|
package/dist/task/accept-debt.js
CHANGED
|
@@ -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 {
|
|
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)
|
|
278
|
-
*
|
|
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
|
-
|
|
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
|
//
|
|
@@ -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
|
|
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
|
}
|