@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
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
* producing task). It activates only when a run-GLOBAL freeze source exists.
|
|
45
45
|
*/
|
|
46
46
|
import { USER_CANCELLED } from './child-runner.js';
|
|
47
|
-
import { findForbiddenDeletions } from './write-guard.js';
|
|
47
|
+
import { findForbiddenDeletions, diffIgnoredSnapshots, ignoredWriteTrailLine, ignoredWriteUnobservedNote } from './write-guard.js';
|
|
48
48
|
import { findNarrowedCommands, narrowingRejectionText } from './command-shrink.js';
|
|
49
49
|
/** Same bounded-fix contract as lint-fix: edit in place, bash exists to RUN the
|
|
50
50
|
* failing command (and the project's own tooling), not to mutate git state. */
|
|
@@ -215,6 +215,10 @@ export function strandedFixNote(paths) {
|
|
|
215
215
|
export async function runFinalGateAutofix(deps) {
|
|
216
216
|
const before = deps.discoverLabels(deps.cwd);
|
|
217
217
|
const bodiesBefore = deps.discoverBodies?.(deps.cwd) ?? {};
|
|
218
|
+
// Ignored paths as they stood BEFORE the child. Attribution needs both ends:
|
|
219
|
+
// ignored files are untracked, so git alone cannot tell a file this pass wrote
|
|
220
|
+
// from one that was already sitting in the worktree.
|
|
221
|
+
const ignoredBefore = deps.ignoredSnapshot ? await deps.ignoredSnapshot() : null;
|
|
218
222
|
let text;
|
|
219
223
|
try {
|
|
220
224
|
text = await deps.runChild(FINAL_FIX_TOOLS, buildFinalFixPrompt(deps.failReason), deps.signal);
|
|
@@ -225,7 +229,22 @@ export async function runFinalGateAutofix(deps) {
|
|
|
225
229
|
throw err;
|
|
226
230
|
return { ok: false, reason: `fix child failed: ${msg}` };
|
|
227
231
|
}
|
|
228
|
-
|
|
232
|
+
// What the child wrote to gitignored paths. Recorded on the trail IMMEDIATELY —
|
|
233
|
+
// before any guard can reject the attempt — because `discard` reverts tracked
|
|
234
|
+
// edits only: an ignored file the pass wrote survives a rejection, and the trail
|
|
235
|
+
// is the only place that fact can ever be read back.
|
|
236
|
+
const ignoredWrites = ignoredBefore === null || !deps.ignoredSnapshot ?
|
|
237
|
+
[]
|
|
238
|
+
: [
|
|
239
|
+
...new Set([
|
|
240
|
+
...diffIgnoredSnapshots(ignoredBefore, await deps.ignoredSnapshot()),
|
|
241
|
+
...(deps.ignoredKnown ?? [])
|
|
242
|
+
])
|
|
243
|
+
].sort();
|
|
244
|
+
if (ignoredWrites.length > 0)
|
|
245
|
+
deps.log?.(ignoredWriteTrailLine(ignoredWrites));
|
|
246
|
+
const withIgnored = (r) => ignoredWrites.length > 0 ? { ...r, ignoredWrites } : r;
|
|
247
|
+
const rejected = (what) => withIgnored({
|
|
229
248
|
ok: false,
|
|
230
249
|
reason: `${what} — edits ${deps.discard ? 'discarded' : 'REJECTED but left in the tree (no discard available)'}`,
|
|
231
250
|
guardTripped: true,
|
|
@@ -311,16 +330,44 @@ export async function runFinalGateAutofix(deps) {
|
|
|
311
330
|
const marker = parseFinalFixMarker(text);
|
|
312
331
|
if (marker.blocked) {
|
|
313
332
|
// Self-declared blocked: skip the (expensive) gate re-run; nothing converged.
|
|
314
|
-
return { ok: false, reason: `fix child blocked: ${marker.note}` };
|
|
333
|
+
return withIgnored({ ok: false, reason: `fix child blocked: ${marker.note}` });
|
|
315
334
|
}
|
|
316
335
|
const fin = await deps.gate(deps.cwd);
|
|
317
336
|
if (!fin.ok) {
|
|
318
|
-
return {
|
|
337
|
+
return withIgnored({
|
|
319
338
|
ok: false,
|
|
320
339
|
reason: `did not converge: ${fin.reason}`,
|
|
321
340
|
gateReason: fin.reason,
|
|
322
341
|
gateFailures: fin.failures
|
|
323
|
-
};
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
// IGNORED-DEPENDENCY DOWNGRADE (mx5 run 19). The gate says PASS; the question
|
|
345
|
+
// this answers is whether that PASS belongs to the REPOSITORY or only to this
|
|
346
|
+
// worktree. Decided mechanically, never by judgement: move the ignored files
|
|
347
|
+
// the pass wrote aside, re-run the gate once, put them back. Still passing ⇒
|
|
348
|
+
// they were incidental and the PASS stands. Failing ⇒ the checks were passing
|
|
349
|
+
// on state no fresh clone has, which is the definition of UNOBSERVED (see
|
|
350
|
+
// final-gate.ts unobservedVerdict) — not a FAIL: the fix is real, it just did
|
|
351
|
+
// not ship. The probe runs only here, so a run with no ignored writes (the
|
|
352
|
+
// overwhelming majority — 1 of 68 recorded child logs) pays nothing.
|
|
353
|
+
let ignoredDependent;
|
|
354
|
+
if (ignoredWrites.length > 0 && deps.gateWithoutIgnored) {
|
|
355
|
+
const passesWithout = await deps.gateWithoutIgnored(ignoredWrites);
|
|
356
|
+
if (passesWithout !== null)
|
|
357
|
+
ignoredDependent = !passesWithout;
|
|
324
358
|
}
|
|
325
|
-
|
|
359
|
+
const notes = [
|
|
360
|
+
...(fin.unobserved ? [fin.unobserved] : []),
|
|
361
|
+
...(ignoredDependent === true ? [ignoredWriteUnobservedNote(ignoredWrites)] : [])
|
|
362
|
+
];
|
|
363
|
+
if (ignoredDependent === true) {
|
|
364
|
+
deps.log?.(`final-gate: converged PASS DOWNGRADED to UNOBSERVED — the gate does not pass with `
|
|
365
|
+
+ `${ignoredWrites.join(', ')} moved aside, and those path(s) are gitignored`);
|
|
366
|
+
}
|
|
367
|
+
return withIgnored({
|
|
368
|
+
ok: true,
|
|
369
|
+
reason: fin.reason,
|
|
370
|
+
...(notes.length > 0 ? { unobserved: notes.join(' ') } : {}),
|
|
371
|
+
...(ignoredDependent !== undefined ? { ignoredDependent } : {})
|
|
372
|
+
});
|
|
326
373
|
}
|
|
@@ -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
|
package/dist/task/final-gate.js
CHANGED
|
@@ -1038,6 +1038,56 @@ function runGateCommand(cwd, [bin, args], timeoutMs, extraGapRe) {
|
|
|
1038
1038
|
}
|
|
1039
1039
|
return { outcome: 'pass' };
|
|
1040
1040
|
}
|
|
1041
|
+
/** The command word of a shell line, past any leading `VAR=value` assignments. */
|
|
1042
|
+
function leadingBin(line) {
|
|
1043
|
+
for (const tok of line.trim().split(/\s+/)) {
|
|
1044
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok))
|
|
1045
|
+
continue;
|
|
1046
|
+
return tok;
|
|
1047
|
+
}
|
|
1048
|
+
return null;
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Re-run one VERIFY-block command line (nexttask 5) under the gate's existing
|
|
1052
|
+
* env-gap contract, so a debt whose reason NAMES that command can be closed by the
|
|
1053
|
+
* command itself rather than by a judgement about it.
|
|
1054
|
+
*
|
|
1055
|
+
* Runs through `sh -c` because a VERIFY line is a shell line, not an argv: run 19's
|
|
1056
|
+
* is `AGENT=1 bun test test/listings.test.ts`, and env prefixes, `&&` and redirects
|
|
1057
|
+
* are all ordinary there. The leading command word is still resolved through
|
|
1058
|
+
* runner-resolve so a login-shell-stripped PATH cannot make every re-run look like a
|
|
1059
|
+
* gap (mx5 run 16's blindness, one level down).
|
|
1060
|
+
*
|
|
1061
|
+
* The asymmetry is the point: only exit 0 is conclusive. Every other ending — real
|
|
1062
|
+
* failure, missing tool, unreachable database, timeout, no POSIX shell — leaves the
|
|
1063
|
+
* debt exactly as open as it was.
|
|
1064
|
+
*/
|
|
1065
|
+
export function runVerifyCommandLine(cwd, line, timeoutMs, extraGapRe) {
|
|
1066
|
+
const bin = leadingBin(line);
|
|
1067
|
+
const runner = bin === null ? null : resolveRunner(bin);
|
|
1068
|
+
const r = spawnSync('sh', ['-c', line], {
|
|
1069
|
+
cwd,
|
|
1070
|
+
encoding: 'utf8',
|
|
1071
|
+
timeout: timeoutMs,
|
|
1072
|
+
env: runner ? runnerEnv(runner) : { ...process.env }
|
|
1073
|
+
});
|
|
1074
|
+
if (r.error)
|
|
1075
|
+
return { outcome: 'gap', detail: `shell did not spawn (${r.error.message})` };
|
|
1076
|
+
if (r.status === null)
|
|
1077
|
+
return { outcome: 'gap', detail: 'killed (timeout or signal)' };
|
|
1078
|
+
const output = `${r.stdout ?? ''}\n${r.stderr ?? ''}`;
|
|
1079
|
+
if (r.status === 0)
|
|
1080
|
+
return { outcome: 'pass' };
|
|
1081
|
+
if (isCommandNotFound(r.status, output)) {
|
|
1082
|
+
return { outcome: 'gap', detail: 'command not found (127)' };
|
|
1083
|
+
}
|
|
1084
|
+
if (ENV_GAP_OUTPUT_RE.test(output))
|
|
1085
|
+
return { outcome: 'gap', detail: 'missing browser/runtime' };
|
|
1086
|
+
if (INFRA_GAP_OUTPUT_RE.test(output) || extraGapRe?.test(output) === true) {
|
|
1087
|
+
return { outcome: 'gap', detail: 'external infrastructure unreachable' };
|
|
1088
|
+
}
|
|
1089
|
+
return { outcome: 'fail', status: r.status, tail: outputTail(r.stdout ?? '', r.stderr ?? '') };
|
|
1090
|
+
}
|
|
1041
1091
|
/**
|
|
1042
1092
|
* The full-skip blindness guard (mx5 run 16, validated): dynamic commands were
|
|
1043
1093
|
* DISCOVERED but every single one skipped as an environment gap, so the gate
|
|
@@ -1204,12 +1254,16 @@ export { taskThatIntroduced };
|
|
|
1204
1254
|
* must pass `false` (unprovable ⇒ stays open), never a guess.
|
|
1205
1255
|
*/
|
|
1206
1256
|
export async function deriveOpenDebts(cwd, staticOk) {
|
|
1207
|
-
const { open: openRaw, resolved } = recheckAcceptDebts(await readAcceptDebts(cwd), {
|
|
1257
|
+
const { open: openRaw, resolved, trail } = recheckAcceptDebts(await readAcceptDebts(cwd), {
|
|
1208
1258
|
staticOk,
|
|
1209
1259
|
// Cross-task-deletion debts auto-close iff the deleted file is back in the
|
|
1210
1260
|
// tree — a deterministic existence check, corroborating the per-file
|
|
1211
1261
|
// provenance the record already carries.
|
|
1212
|
-
fileExists: rel => existsSync(path.join(cwd, rel))
|
|
1262
|
+
fileExists: rel => existsSync(path.join(cwd, rel)),
|
|
1263
|
+
// VERIFY-COMMAND class (nexttask 5): a debt that NAMES a command is settled
|
|
1264
|
+
// by running that command, under the gate's own env-gap contract and behind
|
|
1265
|
+
// the no-write guard below.
|
|
1266
|
+
rerunVerify: cmd => rerunDebtVerifyCommand(cwd, cmd)
|
|
1213
1267
|
});
|
|
1214
1268
|
if (resolved.length > 0)
|
|
1215
1269
|
await writeAcceptDebts(cwd, openRaw);
|
|
@@ -1219,7 +1273,65 @@ export async function deriveOpenDebts(cwd, staticOk) {
|
|
|
1219
1273
|
// a deletion instruction. Pure git-history lookup; degrades to no annotation.
|
|
1220
1274
|
const openDebts = annotateDebtConflicts(openRaw, p => taskThatIntroduced(cwd, p));
|
|
1221
1275
|
const debtNote = buildAcceptDebtNote(openDebts);
|
|
1222
|
-
return { openDebts, ...(debtNote ? { debtNote } : {}) };
|
|
1276
|
+
return { openDebts, ...(debtNote ? { debtNote } : {}), ...(trail.length > 0 ? { trail } : {}) };
|
|
1277
|
+
}
|
|
1278
|
+
/** Per-command ceiling for a debt re-run (`inv-bounded`). */
|
|
1279
|
+
const DEBT_RERUN_TIMEOUT_MS = 300_000;
|
|
1280
|
+
/**
|
|
1281
|
+
* Extra infrastructure-gap shapes recognised ONLY when re-running a debt's command,
|
|
1282
|
+
* never in the gate's own verdicts. A driver that reports its connection simply
|
|
1283
|
+
* closed (`ERR_POSTGRES_CONNECTION_CLOSED` — what bun's SQL client says when the
|
|
1284
|
+
* database is not there at all, as on this box with the mx5 container stopped) is an
|
|
1285
|
+
* absent dependency, and calling that "the defect is still present" would be a
|
|
1286
|
+
* finding the environment invented. Kept out of INFRA_GAP_OUTPUT_RE on purpose: in a
|
|
1287
|
+
* gate verdict the same wording can be a real fault the suite must own, and only the
|
|
1288
|
+
* debt re-check needs the conservative reading — where it costs nothing, because gap
|
|
1289
|
+
* and fail both leave the debt open.
|
|
1290
|
+
*/
|
|
1291
|
+
const DEBT_INFRA_GAP_RE = /ERR_POSTGRES_CONNECTION_CLOSED|ERR_MYSQL_CONNECTION|ECONNRESET/i;
|
|
1292
|
+
/**
|
|
1293
|
+
* Re-run ONE debt's stored VERIFY command for the re-check, with the no-write guard
|
|
1294
|
+
* (`inv-no-write`) wrapped around it.
|
|
1295
|
+
*
|
|
1296
|
+
* A VERIFY command is the project's own command and may legitimately write (a build
|
|
1297
|
+
* emits `dist/`, a suite writes a snapshot). What it may NOT do is turn the tree into
|
|
1298
|
+
* a passing tree and have that count as the debt being fixed — the run would then be
|
|
1299
|
+
* certifying its own side effect. So tracked state is captured before and after, and
|
|
1300
|
+
* a pass that came with a tracked change is downgraded to INCONCLUSIVE with the
|
|
1301
|
+
* change named. Untracked output is left alone: it is what a build legitimately
|
|
1302
|
+
* produces, and `git status --porcelain` in a repo with the usual ignores does not
|
|
1303
|
+
* see it.
|
|
1304
|
+
*
|
|
1305
|
+
* A repository the guard cannot read (no git, git absent) is not a licence to skip
|
|
1306
|
+
* the guard: the re-run is INCONCLUSIVE there, because "nothing changed" would be an
|
|
1307
|
+
* assumption rather than an observation.
|
|
1308
|
+
*/
|
|
1309
|
+
export function rerunDebtVerifyCommand(cwd, command) {
|
|
1310
|
+
const tracked = () => {
|
|
1311
|
+
const r = spawnSync('git', ['status', '--porcelain', '--untracked-files=no'], {
|
|
1312
|
+
cwd,
|
|
1313
|
+
encoding: 'utf8',
|
|
1314
|
+
timeout: 60_000
|
|
1315
|
+
});
|
|
1316
|
+
return r.error || r.status !== 0 ? null : (r.stdout ?? '');
|
|
1317
|
+
};
|
|
1318
|
+
const before = tracked();
|
|
1319
|
+
const r = runVerifyCommandLine(cwd, command, DEBT_RERUN_TIMEOUT_MS, DEBT_INFRA_GAP_RE);
|
|
1320
|
+
if (r.outcome === 'fail')
|
|
1321
|
+
return { outcome: 'fail', detail: `exit ${r.status} — ${r.tail}` };
|
|
1322
|
+
if (r.outcome === 'gap')
|
|
1323
|
+
return { outcome: 'gap', detail: r.detail };
|
|
1324
|
+
const after = tracked();
|
|
1325
|
+
if (before === null || after === null) {
|
|
1326
|
+
return { outcome: 'gap', detail: 'tracked-state guard could not read git status' };
|
|
1327
|
+
}
|
|
1328
|
+
if (before !== after) {
|
|
1329
|
+
return {
|
|
1330
|
+
outcome: 'gap',
|
|
1331
|
+
detail: 'the re-run itself CHANGED tracked files — a command that edits the tree into a pass proves nothing'
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
return { outcome: 'pass' };
|
|
1223
1335
|
}
|
|
1224
1336
|
/**
|
|
1225
1337
|
* Run the final gate: static analysis first, then the lockfile consistency
|
package/dist/task/gate-deps.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { GateDeps } from './task-gates.js';
|
|
|
3
3
|
import { type FinalFixResult } from './final-gate-fix.js';
|
|
4
4
|
import { type AddedLine } from './probe-gaming.js';
|
|
5
5
|
import { type ChangedFile } from './substitution-probe.js';
|
|
6
|
-
import { type TreeChangeSummary } from './write-guard.js';
|
|
6
|
+
import { type TreeChangeSummary, type IgnoredSnapshot } from './write-guard.js';
|
|
7
7
|
/** A function that re-runs a task's implementation turn (AUTOFIX). Injected by the
|
|
8
8
|
* command so this module stays free of the orchestrators (avoids an import cycle). */
|
|
9
9
|
export type RunTaskFn = GateDeps['runTask'];
|
|
@@ -15,7 +15,11 @@ export type RunTaskFn = GateDeps['runTask'];
|
|
|
15
15
|
export declare function truncateToolResult(text: string, limit?: number): string;
|
|
16
16
|
/** One bounded final-gate fix attempt (see final-gate-fix.ts): fix child →
|
|
17
17
|
* shrink guard → gate re-run. Consumed by /task-auto's run-end gate branch. */
|
|
18
|
-
export type FinalGateFixFn = (ctx: ExtensionCommandContext, cwd: string, failReason: string
|
|
18
|
+
export type FinalGateFixFn = (ctx: ExtensionCommandContext, cwd: string, failReason: string,
|
|
19
|
+
/** Ignored paths earlier attempts in this resolution loop already wrote (see
|
|
20
|
+
* FinalFixDeps.ignoredKnown) — a failed attempt's ignored writes survive its
|
|
21
|
+
* discard and can green a later attempt. */
|
|
22
|
+
ignoredKnown?: string[]) => Promise<FinalFixResult>;
|
|
19
23
|
/**
|
|
20
24
|
* Collect the task's changed files as pure GIT SHAPE — path + added-line count,
|
|
21
25
|
* no content, no language parsing — for the self-verification probe. Before the
|
|
@@ -40,6 +44,41 @@ export declare function collectAddedLines(cwd: string, signal?: AbortSignal): Pr
|
|
|
40
44
|
* Failures degrade to an empty summary — the guard then has nothing to reject.
|
|
41
45
|
*/
|
|
42
46
|
export declare function collectTreeChanges(cwd: string, signal?: AbortSignal): Promise<TreeChangeSummary>;
|
|
47
|
+
/**
|
|
48
|
+
* Build output directories declared by the project's OWN build commands
|
|
49
|
+
* (`--outdir=X`, `--out-dir X`), so the ignored-write exemption follows the real
|
|
50
|
+
* tooling instead of a name list. Best-effort: an unreadable or non-JSON manifest
|
|
51
|
+
* contributes nothing and the name-list fallback in classifyIgnoredPath applies.
|
|
52
|
+
*/
|
|
53
|
+
export declare function parseBuildOutdirs(cwd: string): string[];
|
|
54
|
+
/**
|
|
55
|
+
* IGNORED-PATH CHANNEL (mx5 run 19 — see write-guard.ts). A fingerprint of every
|
|
56
|
+
* ACTIONABLE ignored path (`git status --porcelain --ignored=matching`, minus
|
|
57
|
+
* build output / node_modules / .pi-tasks / .git), taken before and after a
|
|
58
|
+
* write-capable gate child so its writes to files git never reports are
|
|
59
|
+
* attributable to it.
|
|
60
|
+
*
|
|
61
|
+
* `--ignored=matching` collapses a wholly-ignored directory into ONE entry, which
|
|
62
|
+
* is what keeps this cheap: `node_modules/` is one exempt line, never 40,000
|
|
63
|
+
* stats. Every failure mode degrades to `{}` — no git, an older git that rejects
|
|
64
|
+
* `--ignored=matching`, an unreadable path — so the gate behaves exactly as it did
|
|
65
|
+
* before this channel existed.
|
|
66
|
+
*/
|
|
67
|
+
export declare function collectIgnoredSnapshot(cwd: string, signal?: AbortSignal): Promise<IgnoredSnapshot>;
|
|
68
|
+
/**
|
|
69
|
+
* The dependency test, decided mechanically rather than by judgement: move the
|
|
70
|
+
* ignored paths aside, re-run the gate once, put them back. A gate that no longer
|
|
71
|
+
* passes without them was passing on state the repository does not contain.
|
|
72
|
+
*
|
|
73
|
+
* Returns null when the question could not be answered (nothing movable, a move or
|
|
74
|
+
* a restore fault, too many paths) — an unanswered probe never downgrades a
|
|
75
|
+
* verdict. Restoration runs in a finally and is best-effort per path: leaving a
|
|
76
|
+
* developer's `.env` renamed on disk would be a far worse failure than a missed
|
|
77
|
+
* downgrade.
|
|
78
|
+
*/
|
|
79
|
+
export declare function gatePassesWithoutIgnored(cwd: string, paths: string[], runGate: (cwd: string) => Promise<{
|
|
80
|
+
ok: boolean;
|
|
81
|
+
}>, log?: (msg: string) => void): Promise<boolean | null>;
|
|
43
82
|
/**
|
|
44
83
|
* The task's changes for the cross-task deletion probe: the working tree's status
|
|
45
84
|
* when the work is uncommitted (pre-commit verify), else the LAST COMMIT's
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* path-revisit disabled because re-running the same check IS the job), each with a
|
|
14
14
|
* status widget and a per-gate debug log under .pi-tasks/.
|
|
15
15
|
*/
|
|
16
|
-
import { existsSync } from 'node:fs';
|
|
16
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
17
17
|
import * as fsp from 'node:fs/promises';
|
|
18
18
|
import * as path from 'node:path';
|
|
19
19
|
import { tasksDir, readTaskFile, appendGateRecord } from './task-io.js';
|
|
@@ -32,7 +32,7 @@ import { extractProhibitions, findProhibitionViolations } from './prohibition-pr
|
|
|
32
32
|
import { frozenPathsFromSpec, revertFrozenPaths } from './frozen-path-guard.js';
|
|
33
33
|
import { findProbeGaming, parseAddedLines } from './probe-gaming.js';
|
|
34
34
|
import { findSubstitutionSuspects, isTestFile } from './substitution-probe.js';
|
|
35
|
-
import { parseTreeChanges, parseNameStatusChanges, formatTreeChanges } from './write-guard.js';
|
|
35
|
+
import { parseTreeChanges, parseNameStatusChanges, formatTreeChanges, findActionableIgnoredWrites } from './write-guard.js';
|
|
36
36
|
import { taskThatIntroduced, findCrossTaskDeletions } from './task-provenance.js';
|
|
37
37
|
import { findTestRebuiltAssemblies, testAssemblyVerifyFindings } from './test-assembly.js';
|
|
38
38
|
import { runBoundedLintFix } from './lint-fix.js';
|
|
@@ -253,6 +253,133 @@ export async function collectTreeChanges(cwd, signal) {
|
|
|
253
253
|
const r = await git(cwd, ['status', '--porcelain', '--', '.', EXCLUDE_TASKS_DIR], signal);
|
|
254
254
|
return r.exitCode === 0 ? parseTreeChanges(r.stdout) : { modified: [], deleted: [], added: [] };
|
|
255
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Build output directories declared by the project's OWN build commands
|
|
258
|
+
* (`--outdir=X`, `--out-dir X`), so the ignored-write exemption follows the real
|
|
259
|
+
* tooling instead of a name list. Best-effort: an unreadable or non-JSON manifest
|
|
260
|
+
* contributes nothing and the name-list fallback in classifyIgnoredPath applies.
|
|
261
|
+
*/
|
|
262
|
+
export function parseBuildOutdirs(cwd) {
|
|
263
|
+
let raw;
|
|
264
|
+
try {
|
|
265
|
+
raw = readFileSync(path.join(cwd, 'package.json'), 'utf8');
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
let scripts;
|
|
271
|
+
try {
|
|
272
|
+
scripts = JSON.parse(raw).scripts ?? {};
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
const out = new Set();
|
|
278
|
+
for (const body of Object.values(scripts)) {
|
|
279
|
+
if (typeof body !== 'string')
|
|
280
|
+
continue;
|
|
281
|
+
for (const m of body.matchAll(/--out-?dir[= ]([^\s'"]+)/g)) {
|
|
282
|
+
const p = (m[1] ?? '').replace(/^\.\//, '').replace(/\/+$/, '');
|
|
283
|
+
if (p.length > 0 && !p.startsWith('-'))
|
|
284
|
+
out.add(p);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return [...out];
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* IGNORED-PATH CHANNEL (mx5 run 19 — see write-guard.ts). A fingerprint of every
|
|
291
|
+
* ACTIONABLE ignored path (`git status --porcelain --ignored=matching`, minus
|
|
292
|
+
* build output / node_modules / .pi-tasks / .git), taken before and after a
|
|
293
|
+
* write-capable gate child so its writes to files git never reports are
|
|
294
|
+
* attributable to it.
|
|
295
|
+
*
|
|
296
|
+
* `--ignored=matching` collapses a wholly-ignored directory into ONE entry, which
|
|
297
|
+
* is what keeps this cheap: `node_modules/` is one exempt line, never 40,000
|
|
298
|
+
* stats. Every failure mode degrades to `{}` — no git, an older git that rejects
|
|
299
|
+
* `--ignored=matching`, an unreadable path — so the gate behaves exactly as it did
|
|
300
|
+
* before this channel existed.
|
|
301
|
+
*/
|
|
302
|
+
export async function collectIgnoredSnapshot(cwd, signal) {
|
|
303
|
+
const r = await git(cwd, ['status', '--porcelain', '--ignored=matching', '--', '.', EXCLUDE_TASKS_DIR], signal);
|
|
304
|
+
if (r.exitCode !== 0)
|
|
305
|
+
return {};
|
|
306
|
+
const outdirs = parseBuildOutdirs(cwd);
|
|
307
|
+
const paths = r.stdout
|
|
308
|
+
.split('\n')
|
|
309
|
+
.filter(l => l.startsWith('!! '))
|
|
310
|
+
.map(l => l.slice(3).trim())
|
|
311
|
+
.map(p => (p.startsWith('"') && p.endsWith('"') ? p.slice(1, -1) : p))
|
|
312
|
+
.filter(p => p.length > 0);
|
|
313
|
+
const snap = {};
|
|
314
|
+
for (const rel of findActionableIgnoredWrites(paths, outdirs)) {
|
|
315
|
+
try {
|
|
316
|
+
const st = await fsp.stat(path.join(cwd, rel));
|
|
317
|
+
// A directory's own mtime moves when entries are added or removed; that
|
|
318
|
+
// is the whole fingerprint available for one without walking it, and a
|
|
319
|
+
// walk is exactly the cost this channel refuses to pay.
|
|
320
|
+
snap[rel] = st.isDirectory() ? `dir:${st.mtimeMs}` : `${st.mtimeMs}:${st.size}`;
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// Vanished between status and stat — nothing to fingerprint.
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return snap;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* The dependency test, decided mechanically rather than by judgement: move the
|
|
330
|
+
* ignored paths aside, re-run the gate once, put them back. A gate that no longer
|
|
331
|
+
* passes without them was passing on state the repository does not contain.
|
|
332
|
+
*
|
|
333
|
+
* Returns null when the question could not be answered (nothing movable, a move or
|
|
334
|
+
* a restore fault, too many paths) — an unanswered probe never downgrades a
|
|
335
|
+
* verdict. Restoration runs in a finally and is best-effort per path: leaving a
|
|
336
|
+
* developer's `.env` renamed on disk would be a far worse failure than a missed
|
|
337
|
+
* downgrade.
|
|
338
|
+
*/
|
|
339
|
+
export async function gatePassesWithoutIgnored(cwd, paths, runGate, log) {
|
|
340
|
+
if (paths.length === 0 || paths.length > MAX_IGNORED_PROBE_PATHS)
|
|
341
|
+
return null;
|
|
342
|
+
const moved = [];
|
|
343
|
+
try {
|
|
344
|
+
for (const rel of paths) {
|
|
345
|
+
// `--ignored=matching` reports a wholly-ignored DIRECTORY with a trailing
|
|
346
|
+
// slash (`logs/`). Left on, `${path.join(cwd, 'logs/')}.pi-gate-probe`
|
|
347
|
+
// names a path INSIDE the directory, so the rename is a move-into-itself
|
|
348
|
+
// and the probe silently answers null for every directory entry.
|
|
349
|
+
const from = path.join(cwd, rel.replace(/\/+$/, ''));
|
|
350
|
+
const to = `${from}.pi-gate-probe`;
|
|
351
|
+
try {
|
|
352
|
+
await fsp.rename(from, to);
|
|
353
|
+
moved.push({ from, to });
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
// Could not move one → the probe cannot answer the question at all.
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (moved.length === 0)
|
|
361
|
+
return null;
|
|
362
|
+
const again = await runGate(cwd);
|
|
363
|
+
return again.ok;
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
finally {
|
|
369
|
+
for (const m of moved) {
|
|
370
|
+
try {
|
|
371
|
+
await fsp.rename(m.to, m.from);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
log?.(`final-gate: WARNING — could not restore ${path.relative(cwd, m.from)} after `
|
|
375
|
+
+ `the ignored-dependency probe; it is on disk as ${path.basename(m.to)}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/** Bound on the ignored-dependency probe: past this the set is not a fix child's
|
|
381
|
+
* handful of files and moving them is not a safe thing to do to a worktree. */
|
|
382
|
+
const MAX_IGNORED_PROBE_PATHS = 20;
|
|
256
383
|
/**
|
|
257
384
|
* The task's changes for the cross-task deletion probe: the working tree's status
|
|
258
385
|
* when the work is uncommitted (pre-commit verify), else the LAST COMMIT's
|
|
@@ -806,7 +933,7 @@ export function buildGateDeps(params) {
|
|
|
806
933
|
return r.exitCode === 0 && r.stdout.trim().length > 0;
|
|
807
934
|
},
|
|
808
935
|
discardEdits: discardTreeEdits,
|
|
809
|
-
finalGateFix: (fixCtx, cwd2, failReason) => runFinalGateAutofix({
|
|
936
|
+
finalGateFix: (fixCtx, cwd2, failReason, ignoredKnown) => runFinalGateAutofix({
|
|
810
937
|
cwd: cwd2,
|
|
811
938
|
signal,
|
|
812
939
|
failReason,
|
|
@@ -831,6 +958,17 @@ export function buildGateDeps(params) {
|
|
|
831
958
|
// preserve registry), never a per-task union.
|
|
832
959
|
treeChanges: () => collectTreeChanges(cwd2, signal),
|
|
833
960
|
probeScan: () => collectAddedLines(cwd2, signal).then(findProbeGaming),
|
|
961
|
+
// IGNORED-PATH CHANNEL (mx5 run 19): the write guards above read
|
|
962
|
+
// `git status --porcelain`, which never reports ignored paths, so
|
|
963
|
+
// the pass that greened `bun run seed` by writing credentials into
|
|
964
|
+
// a gitignored `.env` was structurally invisible to all of them —
|
|
965
|
+
// and the gate certified a PASS no fresh clone can reproduce. This
|
|
966
|
+
// does not reject the write (a local `.env` is often the only way to
|
|
967
|
+
// make a check run); it records it, and downgrades a PASS proven to
|
|
968
|
+
// depend on it.
|
|
969
|
+
ignoredSnapshot: () => collectIgnoredSnapshot(cwd2, signal),
|
|
970
|
+
...(ignoredKnown && ignoredKnown.length > 0 ? { ignoredKnown } : {}),
|
|
971
|
+
gateWithoutIgnored: paths => gatePassesWithoutIgnored(cwd2, paths, c => runFinalIntegrationGate(c), makeDebugAppender(path.join(tasksDir(cwd2), 'final-gate-debug.log'))),
|
|
834
972
|
log: makeDebugAppender(path.join(tasksDir(cwd2), 'final-gate-debug.log'))
|
|
835
973
|
}),
|
|
836
974
|
recommend: async (recCtx, cwd2, taskTitle, taskId, failReason) => {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Next free TASK_PLAN_NNNN id. Mirrors allocateAutoId. */
|
|
2
|
+
export declare function allocatePlanId(cwd: string): Promise<string>;
|
|
3
|
+
/**
|
|
4
|
+
* How an answer to a model question was produced. Recorded verbatim in the plan
|
|
5
|
+
* file because "the user chose this" and "the recommendation was accepted by
|
|
6
|
+
* default" are different levels of evidence when you read the plan back later —
|
|
7
|
+
* the same distinction /task-auto draws with its "(accepted recommendation)" and
|
|
8
|
+
* "(YOLO)" stamps.
|
|
9
|
+
*/
|
|
10
|
+
export type AnswerSource = 'chosen' | 'accepted' | 'typed' | 'skipped' | 'yolo';
|
|
11
|
+
export type PlanEntry =
|
|
12
|
+
/** The model asked; the user answered. */
|
|
13
|
+
{
|
|
14
|
+
kind: 'decision';
|
|
15
|
+
question: string;
|
|
16
|
+
answer: string;
|
|
17
|
+
source: AnswerSource;
|
|
18
|
+
}
|
|
19
|
+
/** The user volunteered a decision without being asked. */
|
|
20
|
+
| {
|
|
21
|
+
kind: 'stated';
|
|
22
|
+
text: string;
|
|
23
|
+
}
|
|
24
|
+
/** The user asked; the model answered. Advisory — decides nothing on its own. */
|
|
25
|
+
| {
|
|
26
|
+
kind: 'note';
|
|
27
|
+
question: string;
|
|
28
|
+
answer: string;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* The transcript as the MODEL sees it — fed back as `priorQA` on every next
|
|
32
|
+
* question, and prepended to the handoff prompt. Decisions are numbered so a
|
|
33
|
+
* later question can refer to one; notes and stated decisions are labelled by who
|
|
34
|
+
* said them, because a model answer the user merely READ must not be mistaken for
|
|
35
|
+
* a decision the user MADE.
|
|
36
|
+
*/
|
|
37
|
+
export declare function formatPlanTranscript(entries: readonly PlanEntry[]): string;
|
|
38
|
+
/** Body of a fresh plan file, before any entry is recorded. */
|
|
39
|
+
export declare function buildPlanBody(task: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* The `## decisions` section: the human-readable transcript. Same content as
|
|
42
|
+
* {@link formatPlanTranscript} — the model and the reader see the same record, so
|
|
43
|
+
* there is no hidden channel.
|
|
44
|
+
*/
|
|
45
|
+
export declare function formatPlanDecisions(entries: readonly PlanEntry[]): string;
|
|
46
|
+
/**
|
|
47
|
+
* The prompt handed to /task when the user proceeds to execution.
|
|
48
|
+
*
|
|
49
|
+
* The task prompt leads, exactly as a bare `/task <prompt>` would, so refine sees
|
|
50
|
+
* a normal task description first; the decisions follow as an authoritative block.
|
|
51
|
+
* Anything the user did NOT settle is simply absent — /task's own grill phase asks
|
|
52
|
+
* about what is left, which is why this block never invents a decision to fill a
|
|
53
|
+
* gap.
|
|
54
|
+
*/
|
|
55
|
+
export declare function buildHandoffPrompt(task: string, entries: readonly PlanEntry[]): string;
|