@mjasnikovs/pi-task 0.18.4 → 0.18.6

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.
Files changed (40) hide show
  1. package/dist/config/config.d.ts +18 -0
  2. package/dist/config/config.js +10 -1
  3. package/dist/config/register.js +32 -5
  4. package/dist/task/accept-debt.d.ts +52 -0
  5. package/dist/task/accept-debt.js +0 -0
  6. package/dist/task/auto-orchestrator.d.ts +2 -0
  7. package/dist/task/auto-orchestrator.js +20 -0
  8. package/dist/task/final-gate.d.ts +8 -0
  9. package/dist/task/final-gate.js +27 -7
  10. package/dist/task/frozen-path-guard.d.ts +39 -0
  11. package/dist/task/frozen-path-guard.js +116 -0
  12. package/dist/task/gate-deps.js +25 -0
  13. package/dist/task/phases.d.ts +6 -2
  14. package/dist/task/phases.js +7 -2
  15. package/dist/task/repo-health-check.d.ts +11 -0
  16. package/dist/task/repo-health-check.js +26 -3
  17. package/dist/task/service-blocks.d.ts +2 -2
  18. package/dist/task/service-blocks.js +3 -1
  19. package/dist/task/task-gates.d.ts +24 -0
  20. package/dist/task/task-gates.js +78 -8
  21. package/dist/workers/brave-search.d.ts +2 -5
  22. package/dist/workers/brave-warning.d.ts +4 -6
  23. package/dist/workers/brave-warning.js +11 -10
  24. package/dist/workers/ddg-search.d.ts +24 -0
  25. package/dist/workers/ddg-search.js +130 -0
  26. package/dist/workers/exa-search.d.ts +24 -0
  27. package/dist/workers/exa-search.js +164 -0
  28. package/dist/workers/pi-worker-docs.js +13 -1
  29. package/dist/workers/pi-worker-fetch.js +10 -1
  30. package/dist/workers/pi-worker-search.d.ts +7 -1
  31. package/dist/workers/pi-worker-search.js +18 -5
  32. package/dist/workers/research-cache.d.ts +39 -0
  33. package/dist/workers/research-cache.js +140 -0
  34. package/dist/workers/search-core.d.ts +14 -2
  35. package/dist/workers/search-core.js +34 -2
  36. package/dist/workers/search-types.d.ts +15 -0
  37. package/dist/workers/search-types.js +4 -0
  38. package/dist/workers/shared.d.ts +17 -0
  39. package/dist/workers/shared.js +0 -0
  40. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { type SearchProvider } from '../workers/search-types.js';
1
2
  export interface PiTaskConfig {
2
3
  remote: boolean;
3
4
  compressReasoning: boolean;
@@ -12,6 +13,23 @@ export interface PiTaskConfig {
12
13
  * phases.ts). Turn on only for a parallel-capable backend.
13
14
  */
14
15
  parallelResearchWorkers: boolean;
16
+ /**
17
+ * Cache docs/search/fetch worker RESULTS for the duration of one /task-auto run
18
+ * so sibling tasks re-asking the same (package/url, query) reuse the first
19
+ * pipeline's digest instead of re-fetching (mx5 run-8 F10: the research phase
20
+ * burned 75 of 363 min largely re-fetching the same external docs across ~20
21
+ * siblings). Per-run isolated, external-only (project-source `.` lookups excluded),
22
+ * success-only. DEFAULT ON — the F10 live A/B showed no answer-quality regression
23
+ * (a cache hit serves byte-identical text to the first fetch; distinct queries never
24
+ * collide).
25
+ */
26
+ researchCache: boolean;
27
+ /**
28
+ * Which engine backs web search (pi-worker-search + freshness/enrichment).
29
+ * `exa` and `ddg` need no API key; `brave` needs BRAVE_SEARCH_API_KEY.
30
+ * DEFAULT `exa` so search works out of the box with zero configuration.
31
+ */
32
+ searchProvider: SearchProvider;
15
33
  }
16
34
  export declare function getConfig(): PiTaskConfig;
17
35
  export declare function saveConfig(config: PiTaskConfig): Promise<void>;
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
2
2
  import * as fsp from 'node:fs/promises';
3
3
  import * as path from 'node:path';
4
4
  import * as os from 'node:os';
5
+ import { isSearchProvider } from '../workers/search-types.js';
5
6
  const DEFAULTS = {
6
7
  remote: true,
7
8
  compressReasoning: true,
@@ -9,7 +10,11 @@ const DEFAULTS = {
9
10
  orientation: true,
10
11
  enforceGuidelines: true,
11
12
  verifyWork: true,
12
- parallelResearchWorkers: false
13
+ parallelResearchWorkers: false,
14
+ // ON: the F10 live A/B showed no answer-quality regression (fidelity 3/3, quality
15
+ // 3/3, 0 collisions; ~14.5s of repeated docs lookups collapse to 0ms on a hit).
16
+ researchCache: true,
17
+ searchProvider: 'exa'
13
18
  };
14
19
  const CONFIG_PATH = path.join(os.homedir(), '.config', 'pi-task', 'config.json');
15
20
  const _g = globalThis;
@@ -23,6 +28,10 @@ if (!G.loaded) {
23
28
  try {
24
29
  const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
25
30
  const parsed = JSON.parse(raw);
31
+ // A hand-edited or stale enum value must not leak an unknown provider
32
+ // into the dispatch switch — fall back to the default.
33
+ if (!isSearchProvider(parsed.searchProvider))
34
+ delete parsed.searchProvider;
26
35
  G.config = { ...DEFAULTS, ...parsed };
27
36
  }
28
37
  catch {
@@ -1,5 +1,6 @@
1
1
  import { SettingsList, visibleWidth } from '@earendil-works/pi-tui';
2
2
  import { registerBridgeCommand } from '../remote/bridge.js';
3
+ import { SEARCH_PROVIDERS, isSearchProvider } from '../workers/search-types.js';
3
4
  import { getConfig, saveConfig } from './config.js';
4
5
  const CONFIG_TITLE = 'pi-task settings';
5
6
  /**
@@ -46,6 +47,10 @@ class BorderedBox {
46
47
  this.child.handleInput(data);
47
48
  }
48
49
  }
50
+ /**
51
+ * Every setting rendered by /task-config. Boolean settings omit `values` and
52
+ * toggle on/off; enum settings list their values and cycle through them.
53
+ */
49
54
  const ITEMS = [
50
55
  { id: 'remote', label: 'remote', description: 'Remote UI server (QR code, phone access)' },
51
56
  {
@@ -77,6 +82,17 @@ const ITEMS = [
77
82
  id: 'parallelResearchWorkers',
78
83
  label: 'parallel research',
79
84
  description: 'Run the 4 research workers concurrently. Leave OFF on a single-GPU local server (serial is measurably faster there); turn on only for a parallel-capable model backend'
85
+ },
86
+ {
87
+ id: 'researchCache',
88
+ label: 'research cache',
89
+ description: 'Cache docs/search/fetch results within one /task-auto run so sibling tasks reuse the first pipeline’s digest instead of re-fetching the same external docs. Per-run isolated, external-only, success-only'
90
+ },
91
+ {
92
+ id: 'searchProvider',
93
+ label: 'search provider',
94
+ description: 'Engine behind web search (pi-worker-search + freshness checks). exa and ddg need no API key; brave needs BRAVE_SEARCH_API_KEY',
95
+ values: SEARCH_PROVIDERS
80
96
  }
81
97
  ];
82
98
  function makeTheme(theme) {
@@ -91,21 +107,32 @@ function makeTheme(theme) {
91
107
  async function handleTaskConfig(_args, ctx) {
92
108
  const cfg = { ...getConfig() };
93
109
  if (ctx.mode !== 'tui') {
94
- const lines = ITEMS.map(({ id, label }) => `${label.padEnd(22)} ${cfg[id] ? 'on' : 'off'}`);
110
+ const lines = ITEMS.map(({ id, label, values }) => `${label.padEnd(22)} ${values ? String(cfg[id])
111
+ : cfg[id] ? 'on'
112
+ : 'off'}`);
95
113
  ctx.ui.notify(lines.join(' | '), 'info');
96
114
  return;
97
115
  }
98
116
  await ctx.ui.custom((_tui, theme, _kb, done) => {
99
117
  const listTheme = makeTheme(theme);
100
- const items = ITEMS.map(({ id, label, description }) => ({
118
+ const items = ITEMS.map(({ id, label, description, values }) => ({
101
119
  id,
102
120
  label,
103
121
  description,
104
- currentValue: cfg[id] ? 'on' : 'off',
105
- values: ['on', 'off']
122
+ currentValue: values ? String(cfg[id])
123
+ : cfg[id] ? 'on'
124
+ : 'off',
125
+ values: values ?? ['on', 'off']
106
126
  }));
107
127
  const list = new SettingsList(items, 10, listTheme, (id, newValue) => {
108
- cfg[id] = newValue === 'on';
128
+ if (id === 'searchProvider') {
129
+ if (isSearchProvider(newValue))
130
+ cfg.searchProvider = newValue;
131
+ }
132
+ else {
133
+ ;
134
+ cfg[id] = newValue === 'on';
135
+ }
109
136
  saveConfig(cfg).catch(() => { });
110
137
  }, () => done(undefined));
111
138
  return new BorderedBox(list, CONFIG_TITLE, s => theme.fg('borderMuted', s), s => theme.fg('accent', theme.bold(s)));
@@ -0,0 +1,52 @@
1
+ /** One accepted-despite-FAIL record: the task and why its VERIFY failed. */
2
+ export interface AcceptDebt {
3
+ taskId: string;
4
+ reason: string;
5
+ }
6
+ export declare function acceptDebtFile(cwd: string): string;
7
+ /** The raw stored ledger ('' when none recorded yet). Parse with parseAcceptDebts. */
8
+ export declare function readAcceptDebtsRaw(cwd: string): Promise<string>;
9
+ /**
10
+ * Parse the stored ledger into records. A line without the separator (a reason but
11
+ * no id, e.g. hand-edited) parses with an empty taskId rather than being dropped —
12
+ * a recorded debt is never silently lost.
13
+ */
14
+ export declare function parseAcceptDebts(raw: string): AcceptDebt[];
15
+ /** Read + parse in one step. */
16
+ export declare function readAcceptDebts(cwd: string): Promise<AcceptDebt[]>;
17
+ /**
18
+ * Append one accepted-despite-FAIL record, deduplicated against what is already
19
+ * stored (case-insensitive on task id + reason), keeping the newest MAX_DEBTS.
20
+ * Failures are swallowed — the ledger is an auditing aid, never a blocker of the
21
+ * gate sequence that calls it.
22
+ */
23
+ export declare function recordAcceptDebt(cwd: string, taskId: string, reason: string): Promise<void>;
24
+ /** Overwrite the ledger with exactly these records (used to prune resolved debts). */
25
+ export declare function writeAcceptDebts(cwd: string, debts: AcceptDebt[]): Promise<void>;
26
+ /**
27
+ * STATIC-CLASS debt: one whose accepted FAIL was the deterministic whole-repo static
28
+ * health check (`repo health: …`, the prefix runWorkVerification's repoHealth branch
29
+ * emits). This is the ONE class a deterministic re-check can prove resolved
30
+ * stack-agnostically — the final gate runs the same static check, so a later task
31
+ * that fixed the statics resolves the debt. Every other reason is model-judged or
32
+ * behavioral and cannot be proven resolved without re-running the model.
33
+ */
34
+ export declare function isStaticClassDebt(reason: string): boolean;
35
+ /**
36
+ * Re-check the ledger against the current run state. A static-class debt is RESOLVED
37
+ * iff the final gate's own static check now passes (`staticOk`); every other debt
38
+ * stays OPEN (unprovable ⇒ surface, never re-hide). FP-safe: the only auto-close is
39
+ * the one a deterministic check can stand behind.
40
+ */
41
+ export declare function recheckAcceptDebts(debts: AcceptDebt[], opts: {
42
+ staticOk: boolean;
43
+ }): {
44
+ open: AcceptDebt[];
45
+ resolved: AcceptDebt[];
46
+ };
47
+ /**
48
+ * A one-line-per-debt suffix appended to the final gate's report reason so the still
49
+ * -open accepted defects surface in the gate outcome the user sees (and in the fail
50
+ * picker). Empty when nothing is open.
51
+ */
52
+ export declare function buildAcceptDebtNote(open: AcceptDebt[]): string;
Binary file
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
2
2
  import { type FinalGateFixFn } from './gate-deps.js';
3
3
  import { type GateDeps } from './task-gates.js';
4
+ import type { AcceptDebt } from './accept-debt.js';
4
5
  /**
5
6
  * Injectable seams so the planner and loop are testable without spawning pi.
6
7
  * `runChild` is the planning-only seam used by planAuto; everything else (runTask,
@@ -32,6 +33,7 @@ export interface AutoDeps extends GateDeps {
32
33
  finalGate?: (cwd: string) => Promise<{
33
34
  ok: boolean;
34
35
  reason: string;
36
+ openDebts?: AcceptDebt[];
35
37
  }>;
36
38
  /**
37
39
  * Bounded model-driven fix pass for a final-gate FAIL (see final-gate-fix.ts),
@@ -29,6 +29,7 @@ import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
29
29
  import { runFinalIntegrationGate } from './final-gate.js';
30
30
  import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FINAL_LEAVE_VALUE, FINAL_ACCEPT_LABEL, FINAL_ACCEPT_VALUE, FINAL_AUTOFIX_LABEL, FINAL_AUTOFIX_VALUE } from './final-gate-fix.js';
31
31
  import { getConfig } from '../config/config.js';
32
+ import { configureResearchRun } from '../workers/research-cache.js';
32
33
  import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
33
34
  // Hard ceiling on clarify questions per feature. The loop is open-ended (it stops
34
35
  // when the model emits NONE), but a model that never says NONE would otherwise
@@ -681,6 +682,18 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
681
682
  let fin = await deps.finalGate(cwd);
682
683
  if (!fin.ok)
683
684
  await recGate(`final-gate: FAIL — ${fin.reason.slice(0, 300)}`);
685
+ // ACCEPT-debt re-check surfacing (mx5 run 4 B3 / run 8 TASK_0012):
686
+ // tasks the user accepted despite a verify-FAIL that the gate could
687
+ // not prove resolved against the current tree. Surface them at the
688
+ // gate moment — on PASS or FAIL — so a run never completes silently
689
+ // carrying an accepted defect. Informational: the per-task ACCEPT was
690
+ // already a human decision, so this reports, it does not re-fail.
691
+ if (fin.openDebts && fin.openDebts.length > 0) {
692
+ for (const d of fin.openDebts) {
693
+ await recGate(`accept-debt STILL OPEN — ${d.taskId || '(unknown task)'} was ACCEPTED despite verify-FAIL: ${d.reason.slice(0, 240)}`);
694
+ }
695
+ active.ui.notify(`${id}: ${fin.openDebts.length} task(s) accepted despite verify-FAIL are STILL unresolved at run end — see the gate trail.`, 'warning');
696
+ }
684
697
  // Resolution loop: Leave-failed (recommended) / Autofix (bounded,
685
698
  // model-driven fix pass + gate re-run — run 7's gap: the picker
686
699
  // had NO automated fix path) / Accept. The user always decides;
@@ -916,6 +929,10 @@ async function handleTaskAuto(args, ctx) {
916
929
  return;
917
930
  }
918
931
  autoRunning = true;
932
+ // Stamp a fresh per-run research-cache id (F10) BEFORE planning so enrichment and
933
+ // every task's research phase share one run's cache; disabled ⇒ clears any token a
934
+ // prior run left, so nothing is cached.
935
+ configureResearchRun(getConfig().researchCache);
919
936
  const abort = new AbortController();
920
937
  const deps = defaultDeps(ctx, cwd, abort.signal, deriveTitle(raw));
921
938
  let id;
@@ -958,6 +975,9 @@ async function handleTaskAutoResume(_args, ctx) {
958
975
  ctx.ui.notify(`Resuming ${id}…`, 'info');
959
976
  await updateTaskFrontMatter(cwd, id, { state: 'in_progress' });
960
977
  autoRunning = true;
978
+ // Fresh per-run research-cache id for the resumed run (F10); a resume re-fetches
979
+ // rather than reusing the interrupted run's digest — safe, only slightly less reuse.
980
+ configureResearchRun(getConfig().researchCache);
961
981
  const abort = new AbortController();
962
982
  // Resume only runs the loop (runTask); no planning children, so the loader
963
983
  // title is unused here — pass the id for clarity if that ever changes.
@@ -1,9 +1,17 @@
1
1
  import { type HealthCommand } from './repo-health-check.js';
2
+ import { type AcceptDebt } from './accept-debt.js';
2
3
  export interface FinalGateOutcome {
3
4
  /** true → statics and every runnable integration command passed (or nothing to run). */
4
5
  ok: boolean;
5
6
  /** On a fail: the exact command, its exit code, and the tail of its output. */
6
7
  reason: string;
8
+ /**
9
+ * ACCEPT-despite-verify-FAIL debts still open at run end (mx5 run 4 B3 / run 8
10
+ * TASK_0012): tasks the user blessed as-is despite a verify-FAIL that a
11
+ * deterministic re-check could not prove resolved. The caller surfaces them so a
12
+ * run never completes silently carrying an accepted defect. Empty/absent = none.
13
+ */
14
+ openDebts?: AcceptDebt[];
7
15
  }
8
16
  /**
9
17
  * The project's OWN whole-repo integration commands (test, then build — test
@@ -42,6 +42,7 @@ import { spawn, spawnSync } from 'node:child_process';
42
42
  import { existsSync, readFileSync } from 'node:fs';
43
43
  import * as path from 'node:path';
44
44
  import { runRepoHealthCheck, discoverHealthCommands } from './repo-health-check.js';
45
+ import { readAcceptDebts, recheckAcceptDebts, writeAcceptDebts, buildAcceptDebtNote } from './accept-debt.js';
45
46
  function packageScripts(cwd) {
46
47
  try {
47
48
  const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
@@ -280,13 +281,32 @@ function runGateCommand(cwd, [bin, args], timeoutMs) {
280
281
  */
281
282
  export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGraceMs = 10_000) {
282
283
  const stat = runRepoHealthCheck(cwd);
284
+ // ACCEPT-debt re-check (mx5 run 4 B3 / run 8 TASK_0012): read the ledger of tasks
285
+ // the user accepted despite a verify-FAIL and re-check each against the current
286
+ // tree. A static-class debt whose statics now pass is provably RESOLVED (a later
287
+ // task fixed it) and pruned; every other debt cannot be proven resolved
288
+ // deterministically, so it stays OPEN and is surfaced in this gate's report — a
289
+ // run may not complete silently carrying an accepted defect. FP-safe by
290
+ // construction (see accept-debt.ts). Best-effort: a ledger read/write failure
291
+ // must never break the gate.
292
+ const { open: openDebts, resolved } = recheckAcceptDebts(await readAcceptDebts(cwd), {
293
+ staticOk: stat.ok
294
+ });
295
+ if (resolved.length > 0)
296
+ await writeAcceptDebts(cwd, openDebts);
297
+ const debtNote = buildAcceptDebtNote(openDebts);
298
+ const withDebts = (o) => ({
299
+ ...o,
300
+ reason: `${o.reason}${debtNote}`,
301
+ openDebts
302
+ });
283
303
  if (!stat.ok)
284
- return { ok: false, reason: `static checks: ${stat.reason}` };
304
+ return withDebts({ ok: false, reason: `static checks: ${stat.reason}` });
285
305
  const lockCmds = discoverLockfileChecks(cwd);
286
306
  const { cmds } = discoverIntegrationCommands(cwd);
287
307
  const boot = discoverBootCommand(cwd);
288
308
  if (lockCmds.length === 0 && cmds.length === 0 && !boot) {
289
- return { ok: true, reason: 'no integration command found (statics passed)' };
309
+ return withDebts({ ok: true, reason: 'no integration command found (statics passed)' });
290
310
  }
291
311
  const ran = [];
292
312
  for (const { prefix, list } of [
@@ -299,10 +319,10 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
299
319
  if (r.outcome === 'skip')
300
320
  continue;
301
321
  if (r.outcome === 'fail') {
302
- return {
322
+ return withDebts({
303
323
  ok: false,
304
324
  reason: `${prefix}\`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`
305
- };
325
+ });
306
326
  }
307
327
  ran.push(label);
308
328
  }
@@ -311,15 +331,15 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
311
331
  const label = `${boot[0]} ${boot[1].join(' ')}`;
312
332
  const b = await runBootCheck(cwd, boot, bootGraceMs);
313
333
  if (b.outcome === 'fail') {
314
- return { ok: false, reason: `boot check: \`${label}\` ${b.detail}` };
334
+ return withDebts({ ok: false, reason: `boot check: \`${label}\` ${b.detail}` });
315
335
  }
316
336
  if (b.outcome === 'pass')
317
337
  ran.push(label);
318
338
  }
319
- return {
339
+ return withDebts({
320
340
  ok: true,
321
341
  reason: ran.length > 0 ?
322
342
  `statics + ${ran.map(c => `\`${c}\``).join(', ')} passed`
323
343
  : 'statics passed (integration commands not runnable here)'
324
- };
344
+ });
325
345
  }
@@ -0,0 +1,39 @@
1
+ /** Run a git subcommand in the guard's cwd; only stdout + exit code are read. */
2
+ export type FrozenGit = (args: string[]) => Promise<{
3
+ stdout: string;
4
+ exitCode: number;
5
+ }>;
6
+ /**
7
+ * The concrete paths the spec forbids modifying, normalized and de-duplicated —
8
+ * the SAME extraction the verify prohibition-probe and the accept-debt ledger
9
+ * consume, so "frozen" means one thing across the gates. Empty when the spec is
10
+ * null or names no path-like token on a modification-ban line: the guard is then a
11
+ * no-op by construction.
12
+ */
13
+ export declare function frozenPathsFromSpec(spec: string | null | undefined): string[];
14
+ /**
15
+ * Parse `git status --porcelain` output (already scoped to the frozen pathspec)
16
+ * into the list of changed files, for the gate-trail record and to decide whether
17
+ * anything must be reverted at all. A rename line (`R old -> new`) yields the NEW
18
+ * path — the side that carries the child's write. Deterministic and pure so the
19
+ * parsing is unit-tested without a real repo.
20
+ */
21
+ export declare function parseChangedFrozenFiles(porcelain: string): string[];
22
+ /**
23
+ * Restore the spec-frozen paths to their committed (HEAD) state, undoing any
24
+ * change a just-run write-capable gate child made to them, and return the list of
25
+ * files that had to be reverted (empty ⇒ the pass respected every frozen path).
26
+ *
27
+ * Runs AFTER the task's own work is committed (HEAD), so "restore to HEAD" keeps
28
+ * the verified task's version of the frozen file and discards ONLY the gate
29
+ * child's edit on top of it — the task's own frozen-path edits, if any, are a
30
+ * separate concern the verify prohibition-probe surfaces. `git checkout HEAD`
31
+ * covers modified/deleted tracked files under each pathspec; `git clean` removes
32
+ * any untracked file the pass created under a frozen directory. Both are scoped to
33
+ * the frozen pathspec, so nothing else in the tree is touched.
34
+ *
35
+ * Best-effort: an empty frozen list, a non-git tree, or any git error yields an
36
+ * empty result — the guard must never break the gate on a project it cannot reason
37
+ * about.
38
+ */
39
+ export declare function revertFrozenPaths(paths: string[], git: FrozenGit): Promise<string[]>;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * frozen-path-guard — deterministic write-deny on spec-frozen paths for the
3
+ * write-capable gate children (the enforce EDIT pass especially).
4
+ *
5
+ * The failure class (carried from mx5 run 6, deferred there as the last unshipped
6
+ * piece of the frozen-contract work): a spec's CONSTRAINTS pin a path as
7
+ * off-limits ("**Do NOT modify** `src/server/index.ts`"), and a later
8
+ * write-enabled GATE pass — the enforce child runs `read,edit` — edits that path
9
+ * anyway. Its edits are judged only locally (a guideline-compliance verdict), then
10
+ * committed as an "ENFORCE GUIDELINES" snapshot on top of the verified task. The
11
+ * frozen contract is silently mutated by the very pass meant to police the work.
12
+ *
13
+ * Prompt framing is A/B-PROVEN insufficient for this class (the "FROZEN CONTRACT,
14
+ * MUST NOT edit" instruction held 0/5 unguarded, ~1/5 with framing — the weak
15
+ * local model ignores an explicit capitalized MUST-NOT most of the time). A
16
+ * chmod-style physical deny holds 5/5 but makes the model THRASH on the bare
17
+ * EACCES (1000+ futile re-attempts observed) — unacceptable on the enforce child,
18
+ * which runs UNGUARDED (no wall-clock timeout). pi itself has no path-level edit
19
+ * interception (only tool-NAME allow/deny), so the achievable, thrash-free,
20
+ * stack-agnostic realization of a tool-layer deny is this: let the pass edit
21
+ * freely, then deterministically UNDO any frozen-path change it made before those
22
+ * edits can be committed. The violating write never lands in a commit, regardless
23
+ * of what the model intended — no model in the loop, same discipline as
24
+ * git-state-guard.
25
+ *
26
+ * Everything degrades to a no-op when the spec froze nothing (a single-`/task`
27
+ * run, or a spec with no `Do NOT modify` line naming a path → no frozen paths →
28
+ * nothing snapshotted, nothing reverted) and on any git error. Pure git shape,
29
+ * zero stack assumptions — a frozen path exists for a CLI, a library, a script
30
+ * collection exactly as for a web app.
31
+ */
32
+ import { extractProhibitions } from './prohibition-probe.js';
33
+ /**
34
+ * The concrete paths the spec forbids modifying, normalized and de-duplicated —
35
+ * the SAME extraction the verify prohibition-probe and the accept-debt ledger
36
+ * consume, so "frozen" means one thing across the gates. Empty when the spec is
37
+ * null or names no path-like token on a modification-ban line: the guard is then a
38
+ * no-op by construction.
39
+ */
40
+ export function frozenPathsFromSpec(spec) {
41
+ if (!spec)
42
+ return [];
43
+ const seen = new Set();
44
+ for (const p of extractProhibitions(spec)) {
45
+ const n = p.path.replace(/^\.\//, '').replace(/\/+$/, '');
46
+ if (n.length > 0)
47
+ seen.add(n);
48
+ }
49
+ return [...seen];
50
+ }
51
+ /**
52
+ * Parse `git status --porcelain` output (already scoped to the frozen pathspec)
53
+ * into the list of changed files, for the gate-trail record and to decide whether
54
+ * anything must be reverted at all. A rename line (`R old -> new`) yields the NEW
55
+ * path — the side that carries the child's write. Deterministic and pure so the
56
+ * parsing is unit-tested without a real repo.
57
+ */
58
+ export function parseChangedFrozenFiles(porcelain) {
59
+ const out = [];
60
+ const seen = new Set();
61
+ for (const raw of porcelain.split('\n')) {
62
+ // Porcelain v1: two status chars, a space, then the path. Blank/short lines
63
+ // (trailing newline) carry no entry.
64
+ if (raw.length < 4)
65
+ continue;
66
+ let file = raw.slice(3).trim();
67
+ if (file.length === 0)
68
+ continue;
69
+ // Rename/copy: "orig -> new" — the new path is the one the write produced.
70
+ const arrow = file.indexOf(' -> ');
71
+ if (arrow !== -1)
72
+ file = file.slice(arrow + 4).trim();
73
+ // Porcelain quotes paths with unusual chars; strip the surrounding quotes.
74
+ if (file.startsWith('"') && file.endsWith('"') && file.length >= 2) {
75
+ file = file.slice(1, -1);
76
+ }
77
+ if (file.length === 0 || seen.has(file))
78
+ continue;
79
+ seen.add(file);
80
+ out.push(file);
81
+ }
82
+ return out;
83
+ }
84
+ /**
85
+ * Restore the spec-frozen paths to their committed (HEAD) state, undoing any
86
+ * change a just-run write-capable gate child made to them, and return the list of
87
+ * files that had to be reverted (empty ⇒ the pass respected every frozen path).
88
+ *
89
+ * Runs AFTER the task's own work is committed (HEAD), so "restore to HEAD" keeps
90
+ * the verified task's version of the frozen file and discards ONLY the gate
91
+ * child's edit on top of it — the task's own frozen-path edits, if any, are a
92
+ * separate concern the verify prohibition-probe surfaces. `git checkout HEAD`
93
+ * covers modified/deleted tracked files under each pathspec; `git clean` removes
94
+ * any untracked file the pass created under a frozen directory. Both are scoped to
95
+ * the frozen pathspec, so nothing else in the tree is touched.
96
+ *
97
+ * Best-effort: an empty frozen list, a non-git tree, or any git error yields an
98
+ * empty result — the guard must never break the gate on a project it cannot reason
99
+ * about.
100
+ */
101
+ export async function revertFrozenPaths(paths, git) {
102
+ if (paths.length === 0)
103
+ return [];
104
+ const status = await git(['status', '--porcelain', '--', ...paths]);
105
+ if (status.exitCode !== 0)
106
+ return [];
107
+ const changed = parseChangedFrozenFiles(status.stdout);
108
+ if (changed.length === 0)
109
+ return [];
110
+ // Restore tracked modifications/deletions from HEAD, then remove any untracked
111
+ // additions — both confined to the frozen pathspec so the pass's legitimate
112
+ // edits to OTHER files survive untouched.
113
+ await git(['checkout', '-f', 'HEAD', '--', ...paths]);
114
+ await git(['clean', '-fdq', '--', ...paths]);
115
+ return changed;
116
+ }
@@ -21,11 +21,13 @@ import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-
21
21
  import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
22
22
  import { readEnvNotes, appendEnvNotes } from './env-notes.js';
23
23
  import { readContracts } from './contracts.js';
24
+ import { recordAcceptDebt } from './accept-debt.js';
24
25
  import { runRepoHealthCheck } from './repo-health-check.js';
25
26
  import { runFinalIntegrationGate, discoverGateCommandLabels } from './final-gate.js';
26
27
  import { runFinalGateAutofix } from './final-gate-fix.js';
27
28
  import { researchResolution } from './verify-resolution.js';
28
29
  import { extractProhibitions, findProhibitionViolations } from './prohibition-probe.js';
30
+ import { frozenPathsFromSpec, revertFrozenPaths } from './frozen-path-guard.js';
29
31
  import { findProbeGaming, parseAddedLines } from './probe-gaming.js';
30
32
  import { findSubstitutionSuspects, isTestFile } from './substitution-probe.js';
31
33
  import { findTestRebuiltAssemblies, testAssemblyVerifyFindings } from './test-assembly.js';
@@ -264,6 +266,29 @@ export function buildGateDeps(params) {
264
266
  // Durable per-task gate trail: every verdict/decision lands in the task
265
267
  // file's `## gates` section so gate behavior is auditable from artifacts.
266
268
  record: (cwd2, taskId, line) => appendGateRecord(cwd2, taskId, line),
269
+ // Durable ACCEPT-despite-verify-FAIL ledger under .pi-tasks/ (survives
270
+ // discardEdits): the final integration gate re-checks each debt at run end.
271
+ recordAcceptDebt: (cwd2, taskId, reason) => recordAcceptDebt(cwd2, taskId, reason),
272
+ // Frozen-path write-deny (see frozen-path-guard.ts): the concrete paths this
273
+ // task's spec forbids modifying, so the gate sequence can UNDO any edit the
274
+ // enforce EDIT pass makes to them before those edits are committed. Reads the
275
+ // same composed spec + extractProhibitions the verify probe consumes; empty on
276
+ // a spec that froze nothing → the guard is a no-op.
277
+ frozenPaths: async (cwd2, taskId) => {
278
+ try {
279
+ const { body } = await readTaskFile(cwd2, taskId);
280
+ return frozenPathsFromSpec(extractSpecForVerification(body));
281
+ }
282
+ catch {
283
+ return [];
284
+ }
285
+ },
286
+ // Restore those frozen paths to HEAD, discarding a gate child's edits to them;
287
+ // returns the files actually reverted (for the trail). Best-effort git shape.
288
+ revertFrozenPaths: (cwd2, paths) => revertFrozenPaths(paths, async (args) => {
289
+ const r = await git(cwd2, args, signal);
290
+ return { stdout: r.stdout, exitCode: r.exitCode };
291
+ }),
267
292
  commit: (cwd2, message) => getConfig().autoCommit ?
268
293
  gitCommitAll(cwd2, message, signal)
269
294
  : Promise.resolve({ committed: false, reason: 'auto-commit disabled' }),
@@ -6,6 +6,7 @@ import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
6
6
  import { docsFocused } from '../workers/docs-core.js';
7
7
  import { fetchFocused } from '../workers/fetch-core.js';
8
8
  import type { SearchCoreInput, SearchCoreResult } from '../workers/search-core.js';
9
+ import type { SearchProvider } from '../workers/search-types.js';
9
10
  import { type ExternalContextDeps } from './external-context.js';
10
11
  import { MAX_GRILL_QUESTIONS } from './prompts.js';
11
12
  import { type PhaseName } from './task-types.js';
@@ -63,8 +64,11 @@ export declare function phaseVerifyTooling(deps: PhaseDeps, research: string): P
63
64
  export interface PhaseResearchDeps extends ExternalContextDeps {
64
65
  getFileInventory?: (cwd: string, signal?: AbortSignal) => Promise<string>;
65
66
  }
66
- /** Is live web search configured for this process? Mirrors search-core's env lookup. */
67
- export declare function searchConfigured(getEnv?: (k: string) => string | undefined): boolean;
67
+ /**
68
+ * Is live web search configured for this process? The keyless providers (exa,
69
+ * ddg) always are; only brave needs its API key — mirrors search-core's lookup.
70
+ */
71
+ export declare function searchConfigured(getEnv?: (k: string) => string | undefined, provider?: SearchProvider): boolean;
68
72
  /** Extra prompt block for the APIS worker when search is available — trigger-framed
69
73
  * (the validated shape for getting a local model to actually reach for search). */
70
74
  export declare const RESEARCH_SEARCH_HINT: string;
@@ -166,8 +166,13 @@ const DOCS_EXTENSION_PATH = new URL('../workers/docs-extension.js', import.meta.
166
166
  * STRUCTURAL: three consecutive audited runs made 0 search calls because the
167
167
  * child literally did not have the tool. */
168
168
  const SEARCH_EXTENSION_PATH = new URL('../workers/search-extension.js', import.meta.url).pathname;
169
- /** Is live web search configured for this process? Mirrors search-core's env lookup. */
170
- export function searchConfigured(getEnv = k => process.env[k]) {
169
+ /**
170
+ * Is live web search configured for this process? The keyless providers (exa,
171
+ * ddg) always are; only brave needs its API key — mirrors search-core's lookup.
172
+ */
173
+ export function searchConfigured(getEnv = k => process.env[k], provider = getConfig().searchProvider) {
174
+ if (provider !== 'brave')
175
+ return true;
171
176
  return Boolean(getEnv('BRAVE_SEARCH_API_KEY') ?? getEnv('BRAVE_API_KEY'));
172
177
  }
173
178
  /** Extra prompt block for the APIS worker when search is available — trigger-framed
@@ -6,7 +6,18 @@ export interface HealthOutcome {
6
6
  reason: string;
7
7
  /** Which manifest drove discovery, or null when none was found. */
8
8
  ecosystem: string | null;
9
+ /**
10
+ * First lines of the failing command's combined stderr+stdout — captured so a
11
+ * FAIL is explainable from artifacts alone. Run-8 F8: five enforce passes were
12
+ * discarded on "`bun run lint` exited 2" and the cause was unreproducible
13
+ * post-run because only the exit code was recorded (exit 2 is the linter's
14
+ * CRASH class; findings exit 1 — the captured output is what tells them apart).
15
+ * Empty string on pass / skip.
16
+ */
17
+ output: string;
9
18
  }
19
+ /** Combine a failing command's stderr+stdout into a bounded, first-N-lines snippet. */
20
+ export declare function captureHealthOutput(stdout: string, stderr: string): string;
10
21
  /** One discovered command: the binary and its args, run from the repo root. */
11
22
  export type HealthCommand = [bin: string, args: string[]];
12
23
  /**