@mjasnikovs/pi-task 0.29.3 → 0.30.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.
@@ -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';
@@ -1322,6 +1323,11 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1322
1323
  // after MAX_FINAL_GATE_AUTOFIX attempts that still FAIL the
1323
1324
  // autofix card is withdrawn so the loop cannot run unbounded.
1324
1325
  let fixAttempts = 0;
1326
+ // Gitignored paths the fix passes have written so far in this
1327
+ // resolution loop (mx5 run 19). Accumulated across attempts: a
1328
+ // `.env` written by a failed attempt is still on disk for the next
1329
+ // one, and that attempt's own before/after diff cannot see it.
1330
+ let ignoredWritten = [];
1325
1331
  // Sub-fixes a non-converging autofix attempt left uncommitted.
1326
1332
  // Refreshed after every attempt; drives the picker note and the
1327
1333
  // terminal commit (mx5 run 13 PROMPT 4 item 3, run 14 item 2b).
@@ -1446,7 +1452,30 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1446
1452
  const seed = choice.guidance ?
1447
1453
  `${fin.reason}\n\nUser guidance: ${choice.guidance}`
1448
1454
  : fin.reason;
1449
- const fix = await deps.finalGateFix(active, cwd, seed);
1455
+ const fix = await deps.finalGateFix(active, cwd, seed, ignoredWritten);
1456
+ // IGNORED-PATH WRITES (mx5 run 19). The pass wrote file(s)
1457
+ // git ignores, so they are not in the commit and a fresh
1458
+ // clone does not have them. Trailed on EVERY outcome — a
1459
+ // rejected attempt's tracked edits are discarded while its
1460
+ // ignored writes survive on disk — and carried forward, so a
1461
+ // later attempt's PASS is judged against everything this loop
1462
+ // wrote, not just its own attempt. PATH NAMES ONLY: an ignored
1463
+ // file's contents (`.env` is the canonical case) never enter a
1464
+ // log, a debt or a child prompt.
1465
+ if (fix.ignoredWrites && fix.ignoredWrites.length > 0) {
1466
+ ignoredWritten = [
1467
+ ...new Set([...ignoredWritten, ...fix.ignoredWrites])
1468
+ ].sort();
1469
+ await recGate(ignoredWriteTrailLine(fix.ignoredWrites));
1470
+ // Debt only where a verdict can rest on the file: the
1471
+ // probe proved the gate needs it, or the question stayed
1472
+ // open. A write the gate demonstrably does NOT need is
1473
+ // trailed and nothing more — a ledger full of scratch
1474
+ // files is a ledger nobody reads.
1475
+ if (fix.ignoredDependent !== false) {
1476
+ await recordFinalGateUnobservedDebt(cwd, id, ignoredWriteDebtReason(fix.ignoredWrites, fix.ignoredDependent));
1477
+ }
1478
+ }
1450
1479
  if (fix.ok) {
1451
1480
  await deps.commit(cwd, `FINAL GATE AUTOFIX (${id})`);
1452
1481
  // 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
  }
@@ -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
- const rejected = (what) => ({
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
- return { ok: true, reason: fin.reason, ...(fin.unobserved ? { unobserved: fin.unobserved } : {}) };
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
  }
@@ -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) => Promise<FinalFixResult>;
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
@@ -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) => {
@@ -54,6 +54,48 @@ export declare function parseNameStatusChanges(nameStatus: string): TreeChangeSu
54
54
  * destroyed a sibling task's verified deliverable.
55
55
  */
56
56
  export declare function findForbiddenDeletions(changes: TreeChangeSummary): string[];
57
+ /** Why an ignored path is (or is not) something the gate may rule on. */
58
+ export type IgnoredClass = 'build-output' | 'dep-dir' | 'task-dir' | 'vcs-meta' | 'actionable';
59
+ /**
60
+ * Classify one repo-relative ignored path. `outdirs` are build output directories
61
+ * parsed from the project's own build commands. Only `actionable` may produce a
62
+ * finding — everything else is reproducible from the repository by running the
63
+ * project's own tooling, which is exactly what makes it not a gate concern.
64
+ */
65
+ export declare function classifyIgnoredPath(rel: string, outdirs: string[]): IgnoredClass;
66
+ /** The ignored paths a gate may rule on: everything not exempt by mechanism. */
67
+ export declare function findActionableIgnoredWrites(paths: string[], outdirs: string[]): string[];
68
+ /**
69
+ * A fingerprint per ignored path (`mtimeMs:size`, or a marker for a directory),
70
+ * taken before and after a write-capable child so a write is ATTRIBUTED to that
71
+ * child rather than to the tree's pre-existing state. Ignored files are untracked,
72
+ * so git cannot tell "changed" from "was always there" — only the snapshot can.
73
+ */
74
+ export type IgnoredSnapshot = Record<string, string>;
75
+ /**
76
+ * Paths whose fingerprint appeared or changed across the child's window. Pure, so
77
+ * the attribution rule is unit-testable without a repo.
78
+ */
79
+ export declare function diffIgnoredSnapshots(before: IgnoredSnapshot, after: IgnoredSnapshot): string[];
80
+ /**
81
+ * The gate-trail line. PATH NAMES ONLY: the contents of an ignored file are never
82
+ * read into a log, a debt reason or a child prompt (`.env` is the canonical case —
83
+ * this channel exists because of a file full of credentials).
84
+ */
85
+ export declare function ignoredWriteTrailLine(paths: string[]): string;
86
+ /**
87
+ * The durable debt reason for the same event. Path names only, same rule.
88
+ *
89
+ * The wording tracks what was actually PROVEN. `dependent === true` is the probe's
90
+ * answer that the gate does not pass without these files; `undefined` is an
91
+ * unanswered probe (the attempt never converged, or the probe could not run), which
92
+ * is still worth carrying because the file is on disk and can green a LATER attempt.
93
+ * A debt that overstates its evidence is the same defect this whole channel exists
94
+ * to fix, one level up.
95
+ */
96
+ export declare function ignoredWriteDebtReason(paths: string[], dependent?: boolean): string;
97
+ /** The UNOBSERVED note that replaces such a PASS. Path names only, same rule. */
98
+ export declare function ignoredWriteUnobservedNote(paths: string[]): string;
57
99
  /**
58
100
  * One-line summary for the gate debug log — the diff capture every write-capable
59
101
  * child gets so "what did this pass change" is answerable from artifacts (the
@@ -132,6 +132,114 @@ export function findForbiddenDeletions(changes) {
132
132
  const addedNames = new Set(changes.added.map(basename));
133
133
  return changes.deleted.filter(p => !addedNames.has(basename(p)));
134
134
  }
135
+ /**
136
+ * Directory names that are build output or a dependency tree by convention. The
137
+ * parsed outdirs (see classifyIgnoredPath's `outdirs`) come from the project's
138
+ * own tooling and are the primary mechanism; this list is the fallback for
139
+ * projects whose build is not declared in a package.json (CMake, cargo, gradle).
140
+ */
141
+ const BUILD_DIR_NAMES = new Set([
142
+ 'node_modules',
143
+ 'dist',
144
+ 'build',
145
+ 'target',
146
+ 'out',
147
+ 'coverage',
148
+ '.next',
149
+ '.nuxt',
150
+ '.svelte-kit',
151
+ '.turbo',
152
+ '.cache',
153
+ '.parcel-cache',
154
+ '.vite',
155
+ '.gradle',
156
+ '__pycache__',
157
+ '.pytest_cache',
158
+ '.venv',
159
+ 'venv'
160
+ ]);
161
+ /**
162
+ * Classify one repo-relative ignored path. `outdirs` are build output directories
163
+ * parsed from the project's own build commands. Only `actionable` may produce a
164
+ * finding — everything else is reproducible from the repository by running the
165
+ * project's own tooling, which is exactly what makes it not a gate concern.
166
+ */
167
+ export function classifyIgnoredPath(rel, outdirs) {
168
+ const segs = rel
169
+ .replace(/^\.\//, '')
170
+ .split('/')
171
+ .filter(s => s.length > 0);
172
+ if (segs.length === 0)
173
+ return 'actionable';
174
+ if (segs.includes('.pi-tasks'))
175
+ return 'task-dir';
176
+ if (segs.includes('.git'))
177
+ return 'vcs-meta';
178
+ if (segs.includes('node_modules'))
179
+ return 'dep-dir';
180
+ for (const o of outdirs) {
181
+ const oSegs = o
182
+ .replace(/^\.\//, '')
183
+ .split('/')
184
+ .filter(s => s.length > 0);
185
+ if (oSegs.length > 0 && oSegs.every((s, i) => segs[i] === s))
186
+ return 'build-output';
187
+ }
188
+ if (segs.some(s => BUILD_DIR_NAMES.has(s)))
189
+ return 'build-output';
190
+ return 'actionable';
191
+ }
192
+ /** The ignored paths a gate may rule on: everything not exempt by mechanism. */
193
+ export function findActionableIgnoredWrites(paths, outdirs) {
194
+ return paths.filter(p => classifyIgnoredPath(p, outdirs) === 'actionable');
195
+ }
196
+ /**
197
+ * Paths whose fingerprint appeared or changed across the child's window. Pure, so
198
+ * the attribution rule is unit-testable without a repo.
199
+ */
200
+ export function diffIgnoredSnapshots(before, after) {
201
+ const out = [];
202
+ for (const [p, fp] of Object.entries(after)) {
203
+ if (before[p] !== fp)
204
+ out.push(p);
205
+ }
206
+ return out.sort();
207
+ }
208
+ /**
209
+ * The gate-trail line. PATH NAMES ONLY: the contents of an ignored file are never
210
+ * read into a log, a debt reason or a child prompt (`.env` is the canonical case —
211
+ * this channel exists because of a file full of credentials).
212
+ */
213
+ export function ignoredWriteTrailLine(paths) {
214
+ return (`final-gate: fix pass modified IGNORED path(s) — ${paths.join(', ')}; `
215
+ + 'these are NOT committed and NOT reproducible from the repository');
216
+ }
217
+ /**
218
+ * The durable debt reason for the same event. Path names only, same rule.
219
+ *
220
+ * The wording tracks what was actually PROVEN. `dependent === true` is the probe's
221
+ * answer that the gate does not pass without these files; `undefined` is an
222
+ * unanswered probe (the attempt never converged, or the probe could not run), which
223
+ * is still worth carrying because the file is on disk and can green a LATER attempt.
224
+ * A debt that overstates its evidence is the same defect this whole channel exists
225
+ * to fix, one level up.
226
+ */
227
+ export function ignoredWriteDebtReason(paths, dependent) {
228
+ const head = dependent === true ?
229
+ `final-gate PASS depended on gitignored file(s) the run wrote and cannot ship: ${paths.join(', ')}. `
230
+ + 'A fresh clone does NOT have them, so the checks that passed here cannot be reproduced. '
231
+ : `the final-gate fix pass wrote gitignored file(s) that are NOT in the commit: ${paths.join(', ')}. `
232
+ + 'Whether the gate needs them was not established, so a fresh clone may not reproduce this run. ';
233
+ return (head
234
+ + 'The durable fix is a TRACKED counterpart (e.g. .env.example) or a check that '
235
+ + 'does not need the file — never committing the ignored file itself.');
236
+ }
237
+ /** The UNOBSERVED note that replaces such a PASS. Path names only, same rule. */
238
+ export function ignoredWriteUnobservedNote(paths) {
239
+ return (`UNOBSERVED — NOT a pass: the gate's checks passed only with gitignored file(s) `
240
+ + `this run wrote (${paths.join(', ')}), which are not in the commit; re-running `
241
+ + 'them without those files FAILS, so no reproducible evidence was produced.');
242
+ }
135
243
  /**
136
244
  * One-line summary for the gate debug log — the diff capture every write-capable
137
245
  * child gets so "what did this pass change" is answerable from artifacts (the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.29.3",
3
+ "version": "0.30.0",
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",