@mjasnikovs/pi-task 0.18.39 → 0.18.41
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/dist/task/accept-debt.d.ts +18 -1
- package/dist/task/accept-debt.js +20 -1
- package/dist/task/auto-io.d.ts +16 -0
- package/dist/task/auto-io.js +54 -0
- package/dist/task/auto-orchestrator.js +93 -5
- package/dist/task/auto-prompts.d.ts +5 -1
- package/dist/task/auto-prompts.js +7 -2
- package/dist/task/batch-test-task.d.ts +71 -0
- package/dist/task/batch-test-task.js +320 -0
- package/dist/task/gate-deps.js +36 -1
- package/dist/task/root-cause-repair.d.ts +122 -0
- package/dist/task/root-cause-repair.js +378 -0
- package/dist/task/task-gates.d.ts +31 -0
- package/dist/task/task-gates.js +58 -1
- package/package.json +1 -1
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* root-cause-repair — turn a verify-FAIL that was CAUSED by another task's file
|
|
3
|
+
* into a scoped repair task in the running plan (mx5 run 14, PROMPT item 5).
|
|
4
|
+
*
|
|
5
|
+
* The failure class this closes, observed end-to-end in run 14: TASK_0007 shipped
|
|
6
|
+
* `test/teardown.ts` with parameterized table names in its TRUNCATE statements.
|
|
7
|
+
* That bug then FAILED two later, unrelated tasks —
|
|
8
|
+
*
|
|
9
|
+
* TASK_0013 "test/teardown.ts has a pre-existing bug (parameterized table names
|
|
10
|
+
* in TRUNCATE statements) … despite all 10 actual tests passing"
|
|
11
|
+
* TASK_0019 "… due to a pre-existing teardown bug in `test/teardown.ts`
|
|
12
|
+
* (created by TASK_0007) … this task did not modify the teardown"
|
|
13
|
+
*
|
|
14
|
+
* — and BOTH ended in an enforce-revert, so the enforce pass's edits were destroyed
|
|
15
|
+
* over a defect the current task did not create. The ledger recorded the root cause
|
|
16
|
+
* twice and NOTHING ever scheduled a fix: the bug survived ~24h, until the final
|
|
17
|
+
* gate's fix child happened to patch it, and it also generated the "2 pre-existing
|
|
18
|
+
* failures / state pollution" excuse notes repeated across TASK_0033…0038.
|
|
19
|
+
*
|
|
20
|
+
* The channel here is deliberately narrow, because a false positive costs a whole
|
|
21
|
+
* task slot and mutates a running plan. Three independent conditions must ALL hold:
|
|
22
|
+
*
|
|
23
|
+
* 1. TEXT — the FAIL reason (or the resolution research's rationale) carries an
|
|
24
|
+
* explicit blame cue ("pre-existing", "created by TASK_nnnn", "bug in", "this
|
|
25
|
+
* task did not modify") and a path token near it. Merely MENTIONING a path is
|
|
26
|
+
* not blame: run 14's TASK_0010 FAIL lists `src/server/db.ts` inside the
|
|
27
|
+
* spec's own "Preserve all existing files on disk" quote, and must not spawn
|
|
28
|
+
* a repair task for it.
|
|
29
|
+
* 2. PROVENANCE — the blamed file was introduced by a DIFFERENT task's commit
|
|
30
|
+
* (task-provenance.ts). Unknown provenance is never evidence.
|
|
31
|
+
* 3. AUTHORSHIP — the current task's own work does not touch the blamed file. If
|
|
32
|
+
* this task edited it, the defect may well be its own and the ordinary
|
|
33
|
+
* autofix/revert path is right.
|
|
34
|
+
*
|
|
35
|
+
* Environment-attributed failures are vetoed outright (run 14's TASK_0006: "no
|
|
36
|
+
* PostgreSQL database server is available in this environment"). No file edit can
|
|
37
|
+
* repair a missing daemon, so a repair task would be a guaranteed non-converging
|
|
38
|
+
* yolo-FAIL.
|
|
39
|
+
*
|
|
40
|
+
* Everything downstream is deduplicated by FILE: N debts naming one root file yield
|
|
41
|
+
* exactly ONE repair task per run (run 14 would otherwise have spawned two for
|
|
42
|
+
* `test/teardown.ts`). The repair task itself is just an ordinary plan entry — if it
|
|
43
|
+
* fails, it lands in the ledger like any other task and is never re-spawned, which
|
|
44
|
+
* is what keeps this from looping.
|
|
45
|
+
*/
|
|
46
|
+
import * as fsp from 'node:fs/promises';
|
|
47
|
+
import * as path from 'node:path';
|
|
48
|
+
import { tasksDir } from './task-io.js';
|
|
49
|
+
/** A path-like token: at least one directory separator, ending in a file name. */
|
|
50
|
+
const PATH_TOKEN_RE = /(?:[\w.@-]+\/)+[\w.@-]+\.\w+/g;
|
|
51
|
+
/**
|
|
52
|
+
* Phrases that ATTRIBUTE a failure to something that predates the current task.
|
|
53
|
+
* Each is a blame cue: the path token nearest a cue is the accused file. Kept
|
|
54
|
+
* deliberately specific — a bare "existing" matches the spec boilerplate
|
|
55
|
+
* "Preserve all existing files on disk" that run 14's TASK_0010 FAIL quotes.
|
|
56
|
+
*/
|
|
57
|
+
const BLAME_CUE_RE = /pre-?\s?existing|existing (?:bug|defect|failure|issue|fault)|(?:created|introduced|added|written) (?:by|in) TASK_\d+|(?:bug|defect|fault|error) in\b|already (?:broken|failing|red)|(?:this task )?did not (?:modify|touch|create|change)|not (?:modified|touched|created|introduced) by this task|unrelated to this task/gi;
|
|
58
|
+
/**
|
|
59
|
+
* Failures the environment causes, not a file. No edit to any file repairs a
|
|
60
|
+
* missing database server, so these must never open the repair channel — run 14's
|
|
61
|
+
* TASK_0006 ("no PostgreSQL database server is available in this environment (no
|
|
62
|
+
* binary, no Docker, no listener on port 5432)") is the canonical shape.
|
|
63
|
+
*/
|
|
64
|
+
const ENVIRONMENT_BLAME_RE = /no (?:postgres|postgresql|mysql|database|redis|docker|network|internet|display)\b|not (?:installed|available|running|present) (?:in|on|for) (?:this |the )?(?:env|environment|sandbox|container|machine|image|system)|(?:environment|env|sandbox|container) (?:gap|limitation|lacks|has no|does not (?:have|provide))|no (?:binary|listener|daemon|server) (?:on|for|available)|missing (?:binary|executable|system (?:tool|package)|runtime)|is not installed|command not found/i;
|
|
65
|
+
/** How far from a blame cue a path token may sit and still be the accused file. */
|
|
66
|
+
const BLAME_WINDOW = 160;
|
|
67
|
+
/** A recorded defect summary is one clamped line, not prose. */
|
|
68
|
+
const MAX_DEFECT_LENGTH = 160;
|
|
69
|
+
/** Ledger ceiling — a pathological run cannot grow the queue unboundedly. */
|
|
70
|
+
const MAX_QUEUED = 40;
|
|
71
|
+
const REPAIR_QUEUE_FILE = 'repair-queue.md';
|
|
72
|
+
const FIELD_SEP = '\t';
|
|
73
|
+
/** True when the FAIL text blames the ENVIRONMENT rather than a file. */
|
|
74
|
+
export function isEnvironmentAttributed(text) {
|
|
75
|
+
return ENVIRONMENT_BLAME_RE.test(text);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The single file a FAIL text accuses, or null. Scans every blame cue, pairs it
|
|
79
|
+
* with the NEAREST path token within {@link BLAME_WINDOW} characters (a cue and its
|
|
80
|
+
* subject sit adjacent in practice: "pre-existing teardown bug in
|
|
81
|
+
* `test/teardown.ts`"), and keeps the closest pair overall. One FAIL has one root
|
|
82
|
+
* cause, so this deliberately returns at most one accusation rather than every
|
|
83
|
+
* path the reason happens to name.
|
|
84
|
+
*/
|
|
85
|
+
export function findAccusedFile(text) {
|
|
86
|
+
if (text.trim().length === 0)
|
|
87
|
+
return null;
|
|
88
|
+
const paths = [...text.matchAll(PATH_TOKEN_RE)].map(m => ({
|
|
89
|
+
value: m[0],
|
|
90
|
+
start: m.index,
|
|
91
|
+
end: m.index + m[0].length
|
|
92
|
+
}));
|
|
93
|
+
if (paths.length === 0)
|
|
94
|
+
return null;
|
|
95
|
+
let best = null;
|
|
96
|
+
// matchAll on a /g regex is safe here (fresh iterator, no shared lastIndex).
|
|
97
|
+
for (const cue of text.matchAll(BLAME_CUE_RE)) {
|
|
98
|
+
const cueStart = cue.index;
|
|
99
|
+
const cueEnd = cueStart + cue[0].length;
|
|
100
|
+
for (const p of paths) {
|
|
101
|
+
const distance = p.start >= cueEnd ? p.start - cueEnd
|
|
102
|
+
: p.end <= cueStart ? cueStart - p.end
|
|
103
|
+
: 0;
|
|
104
|
+
if (distance > BLAME_WINDOW)
|
|
105
|
+
continue;
|
|
106
|
+
if (best === null || distance < best.distance) {
|
|
107
|
+
best = { file: p.value, distance, clause: clauseAround(text, cueStart) };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return best;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The sentence/clause a character offset sits in. Splitting on sentence and dash
|
|
115
|
+
* boundaries keeps the defect summary to the accusation itself instead of the
|
|
116
|
+
* whole multi-clause FAIL reason.
|
|
117
|
+
*/
|
|
118
|
+
function clauseAround(text, offset) {
|
|
119
|
+
const before = text.slice(0, offset);
|
|
120
|
+
const startMatch = /[.;]\s|\s—\s/g;
|
|
121
|
+
let start = 0;
|
|
122
|
+
for (const m of before.matchAll(startMatch))
|
|
123
|
+
start = m.index + m[0].length;
|
|
124
|
+
const after = text.slice(offset);
|
|
125
|
+
const endMatch = /[.;]\s|\s—\s/.exec(after);
|
|
126
|
+
const end = endMatch ? offset + endMatch.index : text.length;
|
|
127
|
+
return text.slice(start, end).trim();
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Collapse whitespace and clamp — a stored defect summary is one short line that
|
|
131
|
+
* has to read well inside a plan title. The gate's own verdict boilerplate ("work
|
|
132
|
+
* did not verify: ") and a leading repeat of the accused file are stripped: the
|
|
133
|
+
* title already names both the step kind and the file, so repeating them there
|
|
134
|
+
* spends the clamp budget on nothing.
|
|
135
|
+
*/
|
|
136
|
+
export function summariseDefect(clause, file) {
|
|
137
|
+
let out = clause.replace(/\s+/g, ' ').trim();
|
|
138
|
+
out = out.replace(/^work (?:did not verify|unobserved|is unverified)\s*:\s*/i, '');
|
|
139
|
+
if (file) {
|
|
140
|
+
const escaped = file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
141
|
+
out = out.replace(new RegExp(`^\`?${escaped}\`?\\s+(?:has|had|contains)\\s+an?\\s+`, 'i'), '');
|
|
142
|
+
}
|
|
143
|
+
out = out.replace(/^[,\-—:\s]+/, '').trim();
|
|
144
|
+
// Drop trailing consequence clauses — "X is broken, which means the VERIFY
|
|
145
|
+
// command fails" restates the FAIL; the repair task only needs the X.
|
|
146
|
+
out = out.replace(/,\s*(?:which|so|meaning|causing the VERIFY)\b.*$/i, '');
|
|
147
|
+
if (out.length <= MAX_DEFECT_LENGTH)
|
|
148
|
+
return out;
|
|
149
|
+
const cut = out.slice(0, MAX_DEFECT_LENGTH);
|
|
150
|
+
const lastSpace = cut.lastIndexOf(' ');
|
|
151
|
+
return `${(lastSpace > MAX_DEFECT_LENGTH / 2 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* A runnable command quoted in the FAIL text — the repair task's VERIFY, per the
|
|
155
|
+
* requirement that it re-run the exact command the debt failed on. Only the first
|
|
156
|
+
* backticked token that STARTS like a shell command (optionally env-prefixed)
|
|
157
|
+
* qualifies, so prose in backticks is never mistaken for a command.
|
|
158
|
+
*/
|
|
159
|
+
export function extractFailingCommand(text) {
|
|
160
|
+
const RUNNER = /^(?:[A-Z][A-Z0-9_]*=\S+\s+)*(?:bun|npm|pnpm|yarn|npx|node|deno|make|cargo|go|python3?|pytest|dotnet|mvn|gradle)\b\s+\S/;
|
|
161
|
+
for (const m of text.matchAll(/`([^`\n]+)`/g)) {
|
|
162
|
+
const cmd = m[1].trim();
|
|
163
|
+
if (RUNNER.test(cmd))
|
|
164
|
+
return cmd;
|
|
165
|
+
}
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The repair candidate a verify FAIL justifies, or null. All three conditions from
|
|
170
|
+
* the module header must hold; anything unknown or environment-shaped returns null.
|
|
171
|
+
*/
|
|
172
|
+
export async function findRepairCandidate(input) {
|
|
173
|
+
const current = input.currentTaskId.trim();
|
|
174
|
+
if (current.length === 0)
|
|
175
|
+
return null;
|
|
176
|
+
if (input.touched === null)
|
|
177
|
+
return null;
|
|
178
|
+
const text = [input.failReason, input.rationale ?? '']
|
|
179
|
+
.filter(t => t.trim().length > 0)
|
|
180
|
+
.join(' ');
|
|
181
|
+
if (text.trim().length === 0)
|
|
182
|
+
return null;
|
|
183
|
+
if (isEnvironmentAttributed(text))
|
|
184
|
+
return null;
|
|
185
|
+
const accused = findAccusedFile(text);
|
|
186
|
+
if (!accused)
|
|
187
|
+
return null;
|
|
188
|
+
// AUTHORSHIP: a file this task's own work touches is not somebody else's bug.
|
|
189
|
+
// Suffix-compare so an absolute or `./`-prefixed status path still matches.
|
|
190
|
+
const touchedSet = input.touched.map(t => normalisePath(t));
|
|
191
|
+
const file = normalisePath(accused.file);
|
|
192
|
+
if (touchedSet.some(t => t === file || t.endsWith(`/${file}`) || file.endsWith(`/${t}`))) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
let owner;
|
|
196
|
+
try {
|
|
197
|
+
owner = await input.introducedBy(accused.file);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
owner = null;
|
|
201
|
+
}
|
|
202
|
+
if (!owner || owner === current)
|
|
203
|
+
return null;
|
|
204
|
+
return {
|
|
205
|
+
file: accused.file,
|
|
206
|
+
owner,
|
|
207
|
+
defect: summariseDefect(accused.clause, accused.file),
|
|
208
|
+
blamedTask: current,
|
|
209
|
+
...(extractFailingCommand(text) ? { verifyCommand: extractFailingCommand(text) } : {})
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function normalisePath(p) {
|
|
213
|
+
return p.replace(/^\.\//, '').replace(/^\/+/, '').trim();
|
|
214
|
+
}
|
|
215
|
+
// ─── Durable queue ───────────────────────────────────────────────────────────
|
|
216
|
+
//
|
|
217
|
+
// The gate sequence (task-gates.ts) DETECTS candidates; the /task-auto loop is
|
|
218
|
+
// what may mutate the plan. They are decoupled through a small ledger under
|
|
219
|
+
// `.pi-tasks/` — the same durability contract as accept-debt.ts: it survives
|
|
220
|
+
// discardEdits and the git-state guard, and a resume picks up what a crash left.
|
|
221
|
+
export function repairQueueFile(cwd) {
|
|
222
|
+
return path.join(tasksDir(cwd), REPAIR_QUEUE_FILE);
|
|
223
|
+
}
|
|
224
|
+
function serialize(c) {
|
|
225
|
+
return [c.file, c.owner, c.blamedTask, c.verifyCommand ?? '', c.defect]
|
|
226
|
+
.map(f => f.replace(/[\t\n]+/g, ' ').trim())
|
|
227
|
+
.join(FIELD_SEP);
|
|
228
|
+
}
|
|
229
|
+
/** Parse the stored queue. Malformed lines are skipped, never thrown on. */
|
|
230
|
+
export function parseRepairQueue(raw) {
|
|
231
|
+
const out = [];
|
|
232
|
+
for (const line of raw.split('\n')) {
|
|
233
|
+
const t = line.trim();
|
|
234
|
+
if (t.length === 0)
|
|
235
|
+
continue;
|
|
236
|
+
const parts = t.split(FIELD_SEP);
|
|
237
|
+
if (parts.length < 5)
|
|
238
|
+
continue;
|
|
239
|
+
const [file, owner, blamedTask, verifyCommand, defect] = parts;
|
|
240
|
+
if (!file.trim() || !owner.trim())
|
|
241
|
+
continue;
|
|
242
|
+
out.push({
|
|
243
|
+
file: file.trim(),
|
|
244
|
+
owner: owner.trim(),
|
|
245
|
+
blamedTask: blamedTask.trim(),
|
|
246
|
+
defect: defect.trim(),
|
|
247
|
+
...(verifyCommand.trim() ? { verifyCommand: verifyCommand.trim() } : {})
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
/** Append one candidate. Best-effort — the queue never blocks a gate. */
|
|
253
|
+
export async function recordRepairCandidate(cwd, c) {
|
|
254
|
+
try {
|
|
255
|
+
const existing = parseRepairQueue(await readQueueRaw(cwd));
|
|
256
|
+
const key = (x) => `${x.file.toLowerCase()} ${x.blamedTask.toLowerCase()}`;
|
|
257
|
+
if (existing.some(e => key(e) === key(c)))
|
|
258
|
+
return;
|
|
259
|
+
const kept = [...existing, c].slice(-MAX_QUEUED);
|
|
260
|
+
await fsp.mkdir(tasksDir(cwd), { recursive: true });
|
|
261
|
+
await fsp.writeFile(repairQueueFile(cwd), kept.map(serialize).join('\n') + '\n', 'utf8');
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// best-effort ledger
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function readQueueRaw(cwd) {
|
|
268
|
+
try {
|
|
269
|
+
return (await fsp.readFile(repairQueueFile(cwd), 'utf8')).trim();
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return '';
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Read the queue and CLEAR it. Draining is what makes the "cap 1 repair task per
|
|
277
|
+
* file per run" bound hold without a second ledger: whatever is drained either
|
|
278
|
+
* becomes a plan entry (which is then itself the dedup key — see
|
|
279
|
+
* {@link planHasRepairFor}) or was already covered by one.
|
|
280
|
+
*/
|
|
281
|
+
export async function drainRepairQueue(cwd) {
|
|
282
|
+
const parsed = parseRepairQueue(await readQueueRaw(cwd));
|
|
283
|
+
if (parsed.length === 0)
|
|
284
|
+
return [];
|
|
285
|
+
try {
|
|
286
|
+
await fsp.writeFile(repairQueueFile(cwd), '', 'utf8');
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
// best-effort; a failed clear at worst re-offers candidates that
|
|
290
|
+
// planHasRepairFor then rejects.
|
|
291
|
+
}
|
|
292
|
+
return parsed;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Collapse candidates by file — MANDATORY dedup: run 14's two teardown.ts debts
|
|
296
|
+
* (TASK_0013, TASK_0019) must yield exactly ONE repair task naming both. First
|
|
297
|
+
* record wins for defect/command (they describe the same fault); blamed tasks
|
|
298
|
+
* accumulate in first-seen order.
|
|
299
|
+
*/
|
|
300
|
+
export function mergeRepairCandidates(candidates) {
|
|
301
|
+
const byFile = new Map();
|
|
302
|
+
for (const c of candidates) {
|
|
303
|
+
const key = normalisePath(c.file).toLowerCase();
|
|
304
|
+
const prev = byFile.get(key);
|
|
305
|
+
if (!prev) {
|
|
306
|
+
byFile.set(key, {
|
|
307
|
+
file: c.file,
|
|
308
|
+
owner: c.owner,
|
|
309
|
+
defect: c.defect,
|
|
310
|
+
blamed: c.blamedTask ? [c.blamedTask] : [],
|
|
311
|
+
...(c.verifyCommand ? { verifyCommand: c.verifyCommand } : {})
|
|
312
|
+
});
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (c.blamedTask && !prev.blamed.includes(c.blamedTask))
|
|
316
|
+
prev.blamed.push(c.blamedTask);
|
|
317
|
+
if (!prev.verifyCommand && c.verifyCommand)
|
|
318
|
+
prev.verifyCommand = c.verifyCommand;
|
|
319
|
+
}
|
|
320
|
+
return [...byFile.values()];
|
|
321
|
+
}
|
|
322
|
+
// ─── Plan entry ──────────────────────────────────────────────────────────────
|
|
323
|
+
/** Machine-recognisable prefix, so a repair entry can be found in a plan again. */
|
|
324
|
+
export const REPAIR_TITLE_PREFIX = 'repair ';
|
|
325
|
+
/**
|
|
326
|
+
* The plan title for a repair task, in the fixed shape
|
|
327
|
+
* `repair <file>: <defect> (root cause of TASK_A, TASK_B debts)`. The file sits
|
|
328
|
+
* immediately after the prefix so {@link parseRepairTitleFile} can recover it —
|
|
329
|
+
* that recovery is both the dedup key and how the loop knows to attach the
|
|
330
|
+
* repair scope fence.
|
|
331
|
+
*/
|
|
332
|
+
export function buildRepairTitle(r) {
|
|
333
|
+
const blame = r.blamed.length > 0 ? ` (root cause of ${r.blamed.join(', ')} debts)` : '';
|
|
334
|
+
return `${REPAIR_TITLE_PREFIX}${r.file}: ${r.defect}${blame}`;
|
|
335
|
+
}
|
|
336
|
+
/** The file a repair title names, or null when the title is not a repair entry. */
|
|
337
|
+
export function parseRepairTitleFile(title) {
|
|
338
|
+
const m = /^repair\s+((?:[\w.@-]+\/)*[\w.@-]+\.\w+)\s*:/.exec(title.trim());
|
|
339
|
+
return m ? m[1] : null;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Is a repair for `file` ALREADY in the plan? This is the cap-1-per-file-per-run
|
|
343
|
+
* bound: it counts checked-off entries too, so a repair task that ran and FAILed
|
|
344
|
+
* is never re-spawned — it lands in the accept-debt ledger like any other task.
|
|
345
|
+
*/
|
|
346
|
+
export function planHasRepairFor(titles, file) {
|
|
347
|
+
const want = normalisePath(file).toLowerCase();
|
|
348
|
+
return titles.some(t => {
|
|
349
|
+
const f = parseRepairTitleFile(t);
|
|
350
|
+
return f !== null && normalisePath(f).toLowerCase() === want;
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* The extra scope fence a repair entry carries into refine. Without it, refine
|
|
355
|
+
* re-expands "repair test/teardown.ts: parameterized table names in TRUNCATE"
|
|
356
|
+
* into "overhaul the test infrastructure" — the /task-auto drift lesson. The
|
|
357
|
+
* fence pins the single editable file and pins the VERIFY to the exact command
|
|
358
|
+
* the debt failed on.
|
|
359
|
+
*/
|
|
360
|
+
export function buildRepairScopeFence(file, verifyCommand) {
|
|
361
|
+
return [
|
|
362
|
+
`REPAIR TASK — this step exists ONLY to fix one specific pre-existing defect in`,
|
|
363
|
+
`\`${file}\`. It was created because that file made OTHER tasks' verification fail;`,
|
|
364
|
+
`it is not a feature step and must not grow into one.`,
|
|
365
|
+
'',
|
|
366
|
+
'HARD CONSTRAINTS for this step (they override any broader reading of the title):',
|
|
367
|
+
` - \`${file}\` is the ONLY file you may modify. Do not refactor, restructure, or`,
|
|
368
|
+
' "improve" anything else, and do not create new files.',
|
|
369
|
+
' - Fix the named defect and nothing more. Do NOT redesign the test harness, the',
|
|
370
|
+
' build, the schema, or any shared infrastructure — a wider change here would',
|
|
371
|
+
" silently overwrite sibling tasks' shipped work.",
|
|
372
|
+
' - Do not delete or weaken any existing test to make the command pass.',
|
|
373
|
+
verifyCommand ?
|
|
374
|
+
` - The VERIFY block MUST be exactly: \`${verifyCommand}\` — the command this`
|
|
375
|
+
+ ' defect was failing. It passing is the whole acceptance criterion.'
|
|
376
|
+
: ' - The VERIFY block MUST re-run the command the defect was failing, unaided.'
|
|
377
|
+
].join('\n');
|
|
378
|
+
}
|
|
@@ -34,6 +34,7 @@ import type { CommitResult } from './auto-commit.js';
|
|
|
34
34
|
import type { VerifyOutcome } from './verify-work.js';
|
|
35
35
|
import type { EnforceOutcome } from './enforce-guidelines.js';
|
|
36
36
|
import { type ResolutionOutcome, type ResolutionChoice } from './verify-resolution.js';
|
|
37
|
+
import { type RepairCandidate } from './root-cause-repair.js';
|
|
37
38
|
/**
|
|
38
39
|
* The deps the gate sequence drives. A superset of these is built once per command
|
|
39
40
|
* by buildGateDeps; AutoDeps extends this with the planning-only `runChild`. Every
|
|
@@ -162,6 +163,36 @@ export interface GateDeps {
|
|
|
162
163
|
path: string;
|
|
163
164
|
owner: string;
|
|
164
165
|
}) => Promise<void>;
|
|
166
|
+
/**
|
|
167
|
+
* Record a durable ROOT-CAUSE debt (mx5 run 14 item 5): this task's verify
|
|
168
|
+
* FAILed on a pre-existing defect in a file ANOTHER task created and this task
|
|
169
|
+
* never touched. Its work is kept (it is not at fault) but the defect is real,
|
|
170
|
+
* so the final gate must re-check and surface it. Best-effort; absent in tests.
|
|
171
|
+
*/
|
|
172
|
+
recordRootCauseDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
|
|
173
|
+
/**
|
|
174
|
+
* Queue a scoped repair task for a root-caused defect. The gate DETECTS the
|
|
175
|
+
* cause; only the /task-auto loop may mutate the plan, so the two are decoupled
|
|
176
|
+
* through the durable `.pi-tasks/repair-queue.md` ledger this writes (see
|
|
177
|
+
* root-cause-repair.ts). Absent (bare `/task`, tests) → detection still records
|
|
178
|
+
* the debt, nothing is scheduled.
|
|
179
|
+
*/
|
|
180
|
+
recordRepairCandidate?: (cwd: string, candidate: RepairCandidate) => Promise<void>;
|
|
181
|
+
/**
|
|
182
|
+
* Paths the CURRENT task's own work touches — the AUTHORSHIP discriminator for
|
|
183
|
+
* the root-cause channel: a FAIL blamed on a file this task edited may well be
|
|
184
|
+
* this task's own fault, and only a file it never touched can be somebody
|
|
185
|
+
* else's pre-existing bug. `worktree` = uncommitted changes (the pre-commit
|
|
186
|
+
* verify site); `committed` = the files the task snapshot + the ENFORCE commit
|
|
187
|
+
* changed (the post-commit enforce site). `null` means UNKNOWN (git
|
|
188
|
+
* unavailable) and stands the whole channel down — inconclusive is never
|
|
189
|
+
* evidence, so an unreadable tree can only cost a repair task, never spawn a
|
|
190
|
+
* wrong one or wrongly keep a regression.
|
|
191
|
+
*/
|
|
192
|
+
touchedFiles?: (cwd: string, scope: 'worktree' | 'committed') => Promise<string[] | null>;
|
|
193
|
+
/** The task whose commit INTRODUCED a file (task-provenance.ts). Null for a
|
|
194
|
+
* file predating the run or any git error → unknown provenance. */
|
|
195
|
+
introducedBy?: (cwd: string, rel: string) => Promise<string | null>;
|
|
165
196
|
/**
|
|
166
197
|
* The concrete paths this task's spec forbids modifying (its `Do NOT modify`
|
|
167
198
|
* CONSTRAINTS — see frozen-path-guard.ts / prohibition-probe.ts). Used to
|
package/dist/task/task-gates.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolutionOptions, classifyResolutionAnswer } from './verify-resolution.js';
|
|
2
2
|
import { SessionUI } from '../remote/bridge.js';
|
|
3
3
|
import { isYoloMode, yoloVerifyResolution, YOLO_STAMP } from './yolo.js';
|
|
4
|
+
import { findRepairCandidate } from './root-cause-repair.js';
|
|
4
5
|
/**
|
|
5
6
|
* How many times a verify FAIL may be auto-fixed UNATTENDED (the research
|
|
6
7
|
* recommended AUTOFIX, so pi re-runs the impl turn without prompting) before the
|
|
@@ -67,6 +68,39 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
67
68
|
// recording must never break the gate sequence
|
|
68
69
|
}
|
|
69
70
|
};
|
|
71
|
+
/**
|
|
72
|
+
* ROOT-CAUSE CHANNEL (mx5 run 14 item 5). Ask whether a FAIL was caused by a
|
|
73
|
+
* pre-existing defect in a file some OTHER task created and this task never
|
|
74
|
+
* touched. On a hit: record the durable debt (so the final gate surfaces it)
|
|
75
|
+
* and queue a scoped repair task (so something finally FIXES it — run 14
|
|
76
|
+
* recorded the same `test/teardown.ts` cause twice and scheduled nothing, and
|
|
77
|
+
* the bug survived ~24h). Returns the candidate so the caller can also decide
|
|
78
|
+
* NOT to punish the current task for it. Never throws: any fault degrades to
|
|
79
|
+
* null, i.e. exactly the pre-existing behavior.
|
|
80
|
+
*/
|
|
81
|
+
const routeRootCause = async (failReason, rationale, scope) => {
|
|
82
|
+
if (!deps.touchedFiles || !deps.introducedBy)
|
|
83
|
+
return null;
|
|
84
|
+
try {
|
|
85
|
+
const candidate = await findRepairCandidate({
|
|
86
|
+
failReason,
|
|
87
|
+
rationale,
|
|
88
|
+
currentTaskId: p.taskId,
|
|
89
|
+
touched: await deps.touchedFiles(p.cwd, scope),
|
|
90
|
+
introducedBy: rel => deps.introducedBy(p.cwd, rel)
|
|
91
|
+
});
|
|
92
|
+
if (!candidate)
|
|
93
|
+
return null;
|
|
94
|
+
await deps.recordRootCauseDebt?.(p.cwd, p.taskId, `${failReason} — ROOT CAUSE: \`${candidate.file}\` (introduced by ${candidate.owner}, not touched by this task)`);
|
|
95
|
+
await deps.recordRepairCandidate?.(p.cwd, candidate);
|
|
96
|
+
await rec(`root-cause: FAIL attributed to \`${candidate.file}\` — a pre-existing defect in ${candidate.owner}'s file that this task never touched; `
|
|
97
|
+
+ 'recorded as durable debt and a scoped repair task queued');
|
|
98
|
+
return candidate;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
70
104
|
const verdictLine = (v) => v.ok ?
|
|
71
105
|
v.reason ?
|
|
72
106
|
`verify: PASS (${v.reason})`
|
|
@@ -230,6 +264,11 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
230
264
|
// recording must never break the gate sequence
|
|
231
265
|
}
|
|
232
266
|
}
|
|
267
|
+
// ROOT CAUSE: an accepted FAIL that some OTHER task's file caused is
|
|
268
|
+
// not fixed by accepting it — run 14 accepted this shape repeatedly
|
|
269
|
+
// and the causing bug outlived the whole run. Queue the scoped repair
|
|
270
|
+
// so the plan actually closes it.
|
|
271
|
+
await routeRootCause(failReason, recOutcome.rationale, 'worktree');
|
|
233
272
|
// Cross-task deletions the verify probe detected ship in the next
|
|
234
273
|
// commit with this ACCEPT — record each as its own durable debt so
|
|
235
274
|
// the final gate re-checks them (mx5 run 12 PROMPT 2). Best-effort.
|
|
@@ -413,7 +452,25 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
413
452
|
const after = deps.verify ?
|
|
414
453
|
await deps.verify(active, p.cwd, p.title, p.taskId)
|
|
415
454
|
: { ok: true };
|
|
416
|
-
|
|
455
|
+
const afterReason = after.reason ?? 'enforce re-verify failed';
|
|
456
|
+
// PRE-EXISTING-CAUSE KEEP PATH (mx5 run 14 item 5b). Both of run
|
|
457
|
+
// 14's enforce-reverts were this shape: the re-verify FAILed on
|
|
458
|
+
// TASK_0007's `test/teardown.ts` TRUNCATE bug — a file neither the
|
|
459
|
+
// task's work nor the enforce pass touched — and the differential
|
|
460
|
+
// reverted enforce's edits anyway, destroying good work over a fault
|
|
461
|
+
// it did not cause AND leaving the actual cause unscheduled. When the
|
|
462
|
+
// FAIL is attributed to another task's untouched file, KEEP the edits
|
|
463
|
+
// and route the real defect to a repair task instead. Everything
|
|
464
|
+
// unknown (git unavailable, no provenance, this task touched the file,
|
|
465
|
+
// an environment-blamed FAIL) falls through to the revert below —
|
|
466
|
+
// the conservative pre-existing behavior.
|
|
467
|
+
const rootCause = after.ok ? null : await routeRootCause(afterReason, '', 'committed');
|
|
468
|
+
if (!after.ok && rootCause) {
|
|
469
|
+
await rec(`enforce: re-verify FAILED (${afterReason.slice(0, 200)}) but the failure is attributed to a PRE-EXISTING defect in \`${rootCause.file}\` `
|
|
470
|
+
+ `(${rootCause.owner}'s file, untouched by this task and by the enforce pass) — edits KEPT, not reverted; repair task queued`);
|
|
471
|
+
active.ui.notify(`${p.tag}: guideline fixes on "${p.title}" re-verified red on a pre-existing defect in ${rootCause.file} (${rootCause.owner}'s file) — keeping the fixes, queued a repair task.`, 'warning');
|
|
472
|
+
}
|
|
473
|
+
else if (!after.ok) {
|
|
417
474
|
if (deps.revert)
|
|
418
475
|
await deps.revert(p.cwd);
|
|
419
476
|
await rec(`enforce: fixes committed but re-verify FAILED (${(after.reason ?? 'now fails').slice(0, 200)}) — ${deps.revert ? 'REVERTED' : 'left in place (no revert available)'}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.41",
|
|
4
4
|
"description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|