@mjasnikovs/pi-task 0.18.4 → 0.18.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/config.d.ts +11 -0
- package/dist/config/config.js +4 -1
- package/dist/config/register.js +5 -0
- package/dist/task/accept-debt.d.ts +52 -0
- package/dist/task/accept-debt.js +0 -0
- package/dist/task/auto-orchestrator.d.ts +2 -0
- package/dist/task/auto-orchestrator.js +20 -0
- package/dist/task/final-gate.d.ts +8 -0
- package/dist/task/final-gate.js +27 -7
- package/dist/task/frozen-path-guard.d.ts +39 -0
- package/dist/task/frozen-path-guard.js +116 -0
- package/dist/task/gate-deps.js +25 -0
- package/dist/task/repo-health-check.d.ts +11 -0
- package/dist/task/repo-health-check.js +26 -3
- package/dist/task/task-gates.d.ts +24 -0
- package/dist/task/task-gates.js +78 -8
- package/dist/workers/pi-worker-docs.js +13 -1
- package/dist/workers/pi-worker-fetch.js +10 -1
- package/dist/workers/pi-worker-search.js +9 -1
- package/dist/workers/research-cache.d.ts +39 -0
- package/dist/workers/research-cache.js +140 -0
- package/dist/workers/shared.d.ts +17 -0
- package/dist/workers/shared.js +0 -0
- package/package.json +1 -1
package/dist/config/config.d.ts
CHANGED
|
@@ -12,6 +12,17 @@ export interface PiTaskConfig {
|
|
|
12
12
|
* phases.ts). Turn on only for a parallel-capable backend.
|
|
13
13
|
*/
|
|
14
14
|
parallelResearchWorkers: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Cache docs/search/fetch worker RESULTS for the duration of one /task-auto run
|
|
17
|
+
* so sibling tasks re-asking the same (package/url, query) reuse the first
|
|
18
|
+
* pipeline's digest instead of re-fetching (mx5 run-8 F10: the research phase
|
|
19
|
+
* burned 75 of 363 min largely re-fetching the same external docs across ~20
|
|
20
|
+
* siblings). Per-run isolated, external-only (project-source `.` lookups excluded),
|
|
21
|
+
* success-only. DEFAULT ON — the F10 live A/B showed no answer-quality regression
|
|
22
|
+
* (a cache hit serves byte-identical text to the first fetch; distinct queries never
|
|
23
|
+
* collide).
|
|
24
|
+
*/
|
|
25
|
+
researchCache: boolean;
|
|
15
26
|
}
|
|
16
27
|
export declare function getConfig(): PiTaskConfig;
|
|
17
28
|
export declare function saveConfig(config: PiTaskConfig): Promise<void>;
|
package/dist/config/config.js
CHANGED
|
@@ -9,7 +9,10 @@ const DEFAULTS = {
|
|
|
9
9
|
orientation: true,
|
|
10
10
|
enforceGuidelines: true,
|
|
11
11
|
verifyWork: true,
|
|
12
|
-
parallelResearchWorkers: false
|
|
12
|
+
parallelResearchWorkers: false,
|
|
13
|
+
// ON: the F10 live A/B showed no answer-quality regression (fidelity 3/3, quality
|
|
14
|
+
// 3/3, 0 collisions; ~14.5s of repeated docs lookups collapse to 0ms on a hit).
|
|
15
|
+
researchCache: true
|
|
13
16
|
};
|
|
14
17
|
const CONFIG_PATH = path.join(os.homedir(), '.config', 'pi-task', 'config.json');
|
|
15
18
|
const _g = globalThis;
|
package/dist/config/register.js
CHANGED
|
@@ -77,6 +77,11 @@ const ITEMS = [
|
|
|
77
77
|
id: 'parallelResearchWorkers',
|
|
78
78
|
label: 'parallel research',
|
|
79
79
|
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'
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'researchCache',
|
|
83
|
+
label: 'research cache',
|
|
84
|
+
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'
|
|
80
85
|
}
|
|
81
86
|
];
|
|
82
87
|
function makeTheme(theme) {
|
|
@@ -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
|
package/dist/task/final-gate.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -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,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
|
/**
|
|
@@ -32,6 +32,23 @@
|
|
|
32
32
|
import { spawnSync } from 'node:child_process';
|
|
33
33
|
import { existsSync, readFileSync } from 'node:fs';
|
|
34
34
|
import * as path from 'node:path';
|
|
35
|
+
/** How much of a failing command's output to keep — bounded so a wedged tool that
|
|
36
|
+
* spews megabytes cannot bloat the trail. stderr leads (a crash trace lives there). */
|
|
37
|
+
const HEALTH_OUTPUT_MAX_LINES = 40;
|
|
38
|
+
const HEALTH_OUTPUT_MAX_CHARS = 4000;
|
|
39
|
+
/** Combine a failing command's stderr+stdout into a bounded, first-N-lines snippet. */
|
|
40
|
+
export function captureHealthOutput(stdout, stderr) {
|
|
41
|
+
const combined = [stderr, stdout]
|
|
42
|
+
.map(s => (s ?? '').trim())
|
|
43
|
+
.filter(s => s.length > 0)
|
|
44
|
+
.join('\n');
|
|
45
|
+
if (combined.length === 0)
|
|
46
|
+
return '';
|
|
47
|
+
let snippet = combined.split('\n').slice(0, HEALTH_OUTPUT_MAX_LINES).join('\n');
|
|
48
|
+
if (snippet.length > HEALTH_OUTPUT_MAX_CHARS)
|
|
49
|
+
snippet = `${snippet.slice(0, HEALTH_OUTPUT_MAX_CHARS)}…`;
|
|
50
|
+
return snippet;
|
|
51
|
+
}
|
|
35
52
|
function packageScripts(cwd) {
|
|
36
53
|
try {
|
|
37
54
|
const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
@@ -103,7 +120,12 @@ export function discoverHealthCommands(cwd) {
|
|
|
103
120
|
export function runRepoHealthCheck(cwd, timeoutMs = 600_000) {
|
|
104
121
|
const { ecosystem, cmds } = discoverHealthCommands(cwd);
|
|
105
122
|
if (!ecosystem || cmds.length === 0) {
|
|
106
|
-
return {
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
reason: 'no repo-wide static-analysis command found',
|
|
126
|
+
ecosystem,
|
|
127
|
+
output: ''
|
|
128
|
+
};
|
|
107
129
|
}
|
|
108
130
|
for (const [bin, args] of cmds) {
|
|
109
131
|
const r = spawnSync(bin, args, { cwd, encoding: 'utf8', timeout: timeoutMs });
|
|
@@ -119,9 +141,10 @@ export function runRepoHealthCheck(cwd, timeoutMs = 600_000) {
|
|
|
119
141
|
return {
|
|
120
142
|
ok: false,
|
|
121
143
|
reason: `\`${bin} ${args.join(' ')}\` exited ${r.status}`,
|
|
122
|
-
ecosystem
|
|
144
|
+
ecosystem,
|
|
145
|
+
output: captureHealthOutput(r.stdout, r.stderr)
|
|
123
146
|
};
|
|
124
147
|
}
|
|
125
148
|
}
|
|
126
|
-
return { ok: true, reason: `${ecosystem}: static checks passed`, ecosystem };
|
|
149
|
+
return { ok: true, reason: `${ecosystem}: static checks passed`, ecosystem, output: '' };
|
|
127
150
|
}
|
|
@@ -101,6 +101,7 @@ export interface GateDeps {
|
|
|
101
101
|
repoHealth?: (cwd: string) => Promise<{
|
|
102
102
|
ok: boolean;
|
|
103
103
|
reason: string;
|
|
104
|
+
output?: string;
|
|
104
105
|
}>;
|
|
105
106
|
/** Does the working tree hold changes (excluding .pi-tasks)? Lets the pre-commit
|
|
106
107
|
* health check run only when the enforce pass actually edited something. */
|
|
@@ -118,6 +119,29 @@ export interface GateDeps {
|
|
|
118
119
|
* swallowed by the implementation, never by this sequence.
|
|
119
120
|
*/
|
|
120
121
|
record?: (cwd: string, taskId: string, line: string) => Promise<void>;
|
|
122
|
+
/**
|
|
123
|
+
* Record a durable ACCEPT-despite-verify-FAIL debt (task id + FAIL reason) to the
|
|
124
|
+
* run-level ledger (`.pi-tasks/accept-debt.md`, see accept-debt.ts). Called only on
|
|
125
|
+
* the picker's ACCEPT branch — the human blessed a failing artifact as-is, so the
|
|
126
|
+
* defect is real and recorded; the final integration gate re-checks it at run end
|
|
127
|
+
* and surfaces it if still open. Best-effort; absent in tests → no ledger written.
|
|
128
|
+
*/
|
|
129
|
+
recordAcceptDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
|
|
130
|
+
/**
|
|
131
|
+
* The concrete paths this task's spec forbids modifying (its `Do NOT modify`
|
|
132
|
+
* CONSTRAINTS — see frozen-path-guard.ts / prohibition-probe.ts). Used to
|
|
133
|
+
* write-deny the enforce EDIT pass: a violating edit is reverted before it can
|
|
134
|
+
* be committed. Empty when the spec froze nothing → the guard is a no-op.
|
|
135
|
+
* Absent in tests / on a bare `/task` → the guard is skipped.
|
|
136
|
+
*/
|
|
137
|
+
frozenPaths?: (cwd: string, taskId: string) => Promise<string[]>;
|
|
138
|
+
/**
|
|
139
|
+
* Restore the given frozen paths to their committed (HEAD) state, discarding a
|
|
140
|
+
* gate child's edits to them, and return the files actually reverted. Prompt
|
|
141
|
+
* framing is A/B-proven insufficient for this class, so the deny is mechanical:
|
|
142
|
+
* the write is undone, not merely warned about. Absent → the guard warns only.
|
|
143
|
+
*/
|
|
144
|
+
revertFrozenPaths?: (cwd: string, paths: string[]) => Promise<string[]>;
|
|
121
145
|
}
|
|
122
146
|
/** Inputs the sequence needs that vary per caller. */
|
|
123
147
|
export interface GateParams {
|
package/dist/task/task-gates.js
CHANGED
|
@@ -10,6 +10,18 @@ import { SessionUI } from '../remote/bridge.js';
|
|
|
10
10
|
* regardless of this count (blessing an artifact as-is is a human's call).
|
|
11
11
|
*/
|
|
12
12
|
export const MAX_AUTO_AUTOFIX = 3;
|
|
13
|
+
/**
|
|
14
|
+
* Bound a captured health-check output before it is embedded in a gate-trail line.
|
|
15
|
+
* appendGateRecord flattens newlines to spaces, so the trail stays one line per
|
|
16
|
+
* entry; this just caps the volume (a wedged tool can emit megabytes). The health
|
|
17
|
+
* check already trims to its own first-N lines — this is the trail-side ceiling.
|
|
18
|
+
*/
|
|
19
|
+
const TRAIL_OUTPUT_MAX_CHARS = 1200;
|
|
20
|
+
function clampOutput(output) {
|
|
21
|
+
return output.length > TRAIL_OUTPUT_MAX_CHARS ?
|
|
22
|
+
`${output.slice(0, TRAIL_OUTPUT_MAX_CHARS)}…`
|
|
23
|
+
: output;
|
|
24
|
+
}
|
|
13
25
|
/**
|
|
14
26
|
* Show the boxed two-choice picker after a verify FAIL and return what the user
|
|
15
27
|
* decided. The model-recommended card is placed first so the renderer tints it
|
|
@@ -135,6 +147,16 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
135
147
|
}
|
|
136
148
|
if (choice.action === 'accept') {
|
|
137
149
|
await rec('resolution: user ACCEPTED the work despite verify FAIL');
|
|
150
|
+
// Durable debt: the human blessed a FAILing artifact as-is, so the
|
|
151
|
+
// defect ships and nothing else revisits it (mx5 run 4 B3 / run 8
|
|
152
|
+
// TASK_0012). Record it to the run ledger; the final integration gate
|
|
153
|
+
// re-checks it at run end and surfaces it if still open. Best-effort.
|
|
154
|
+
try {
|
|
155
|
+
await deps.recordAcceptDebt?.(p.cwd, p.taskId, failReason);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// recording must never break the gate sequence
|
|
159
|
+
}
|
|
138
160
|
active.ui.notify(`${p.tag}: accepted "${p.title}" despite verify FAIL (${failReason.slice(0, 120)}) — proceeding.`, 'warning');
|
|
139
161
|
break;
|
|
140
162
|
}
|
|
@@ -202,13 +224,40 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
202
224
|
// with no enforce dep.
|
|
203
225
|
if (deps.enforce && commit.committed) {
|
|
204
226
|
const mode = verifyCleanPass ? 'edit' : 'flag';
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
227
|
+
// BASELINE repo health, captured BEFORE the edit pass touches the tree, so the
|
|
228
|
+
// pre-commit gate below is DIFFERENTIAL: it can tell an enforce-CAUSED
|
|
229
|
+
// regression (was clean, now fails) from a repo that was ALREADY unhealthy
|
|
230
|
+
// (run-8 F8: five enforce passes were discarded on a lint that was already
|
|
231
|
+
// crashing — exit 2 — before enforce edited anything; the discard threw away
|
|
232
|
+
// good work for a fault it did not cause). Only meaningful in edit mode (flag
|
|
233
|
+
// makes no edits); the task's work is already committed so this reflects the
|
|
234
|
+
// committed state the pass is about to build on.
|
|
235
|
+
const healthBefore = mode === 'edit' && deps.repoHealth ? await deps.repoHealth(p.cwd) : undefined;
|
|
208
236
|
const verdict = await deps.enforce(active, p.cwd, p.title, mode);
|
|
237
|
+
// FROZEN-PATH WRITE-DENY (mechanical, not prompt — the "MUST NOT edit"
|
|
238
|
+
// instruction is A/B-proven ~0–1/5 reliable on the weak model): the enforce
|
|
239
|
+
// EDIT pass runs read,edit and has been seen mutating a path the spec froze.
|
|
240
|
+
// Before its edits are inspected/committed below, restore any frozen path it
|
|
241
|
+
// touched to the committed task state, so the violating write cannot land in
|
|
242
|
+
// the ENFORCE GUIDELINES commit regardless of what the model intended. The
|
|
243
|
+
// task's OWN frozen-path edits (if any) are already in HEAD and are the verify
|
|
244
|
+
// prohibition-probe's job — this only undoes the gate child's edits on top.
|
|
245
|
+
// No-op when the spec froze nothing or the deps are absent (bare /task, tests).
|
|
246
|
+
if (mode === 'edit' && deps.frozenPaths && deps.revertFrozenPaths) {
|
|
247
|
+
const frozen = await deps.frozenPaths(p.cwd, p.taskId);
|
|
248
|
+
if (frozen.length > 0) {
|
|
249
|
+
const reverted = await deps.revertFrozenPaths(p.cwd, frozen);
|
|
250
|
+
if (reverted.length > 0) {
|
|
251
|
+
await rec(`enforce: frozen-path write DENIED — reverted ${reverted.length} spec-frozen file(s) the edit pass modified: ${reverted.join(', ')}`);
|
|
252
|
+
active.ui.notify(`${p.tag}: guideline edits on "${p.title}" touched spec-frozen path(s) (${reverted.join(', ').slice(0, 120)}) — reverted before commit.`, 'warning');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
209
256
|
// The child's verdict and its edits are independent facts: the pass has been
|
|
210
257
|
// observed declaring "clean" while having edited files (which then get
|
|
211
258
|
// committed as fixes) — record both so the trail cannot contradict itself.
|
|
259
|
+
// `editsMade` is read AFTER the frozen-path revert so a pass whose only edit
|
|
260
|
+
// was to a frozen path correctly shows a clean tree (nothing left to commit).
|
|
212
261
|
const editsMade = mode === 'edit' && deps.dirty ? await deps.dirty(p.cwd) : undefined;
|
|
213
262
|
await rec(`enforce(${mode}): ${verdict.ok ? `clean${verdict.reason ? ` (${verdict.reason})` : ''}` : (verdict.reason ?? 'not clean')}${editsMade ? ' — edits in tree' : ''}`);
|
|
214
263
|
if (!verdict.ok) {
|
|
@@ -220,19 +269,40 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
220
269
|
// and discards the bad edits outright. Only runs when the tree is actually
|
|
221
270
|
// dirty (or dirtiness is unknowable); the differential guard below still
|
|
222
271
|
// catches behavioral regressions the static check cannot see.
|
|
272
|
+
//
|
|
273
|
+
// The gate is DIFFERENTIAL, not absolute (run-8 F8): discard the edits only
|
|
274
|
+
// when they REGRESSED the health signal — clean before, failing after. A repo
|
|
275
|
+
// that was already failing before enforce ran is not enforce's fault, so its
|
|
276
|
+
// edits are KEPT (and the pre-existing failure is recorded, to be caught by the
|
|
277
|
+
// final integration gate, not blamed on this pass). The failing command's
|
|
278
|
+
// output is captured into the trail so the discard is explainable — F8 was
|
|
279
|
+
// unreproducible precisely because only the exit code was recorded.
|
|
223
280
|
let enforceEditsBlocked = false;
|
|
224
281
|
if (mode === 'edit' && deps.repoHealth && editsMade !== false) {
|
|
225
|
-
const
|
|
226
|
-
|
|
282
|
+
const after = await deps.repoHealth(p.cwd);
|
|
283
|
+
// A regression needs a clean (or unknown) baseline turning to a fail. If
|
|
284
|
+
// healthBefore is undefined (repoHealth was absent at baseline time) treat
|
|
285
|
+
// the baseline as clean — the conservative absolute behavior.
|
|
286
|
+
const wasHealthyBefore = healthBefore?.ok ?? true;
|
|
287
|
+
const regressed = !after.ok && wasHealthyBefore;
|
|
288
|
+
if (regressed) {
|
|
227
289
|
enforceEditsBlocked = true;
|
|
290
|
+
const outputTail = after.output ? ` — output:\n${clampOutput(after.output)}` : '';
|
|
228
291
|
if (deps.discardEdits) {
|
|
229
292
|
await deps.discardEdits(p.cwd);
|
|
230
|
-
await rec(`enforce: edits discarded pre-commit
|
|
293
|
+
await rec(`enforce: edits discarded pre-commit — REGRESSED repo health (${after.reason})${outputTail}`);
|
|
231
294
|
}
|
|
232
295
|
else {
|
|
233
|
-
await rec(`enforce: edits
|
|
296
|
+
await rec(`enforce: edits REGRESSED repo health pre-commit (${after.reason}) — no discard available, left uncommitted${outputTail}`);
|
|
234
297
|
}
|
|
235
|
-
active.ui.notify(`${p.tag}: guideline edits on "${p.title}"
|
|
298
|
+
active.ui.notify(`${p.tag}: guideline edits on "${p.title}" regressed repo health (${after.reason.slice(0, 120)}) — discarded before commit.`, 'warning');
|
|
299
|
+
}
|
|
300
|
+
else if (!after.ok) {
|
|
301
|
+
// Failing both before and after → not enforce's fault. Keep the edits;
|
|
302
|
+
// record that the repo entered the gate already unhealthy so the trail
|
|
303
|
+
// explains why a still-failing repo did NOT trigger a discard here.
|
|
304
|
+
const outputTail = after.output ? ` — output:\n${clampOutput(after.output)}` : '';
|
|
305
|
+
await rec(`enforce: repo health still failing after edits but was ALREADY failing before the pass (${after.reason}) — pre-existing, edits kept${outputTail}`);
|
|
236
306
|
}
|
|
237
307
|
}
|
|
238
308
|
if (mode === 'edit' && !enforceEditsBlocked && editsMade === false) {
|
|
@@ -8,6 +8,7 @@ import { runChild, CHILD_BASE_ARGS } from '../shared/child-process.js';
|
|
|
8
8
|
import { parseChildOutput, isExcerptInContent } from '../shared/child-output.js';
|
|
9
9
|
import { getPiInvocation } from '../shared/pi-invocation.js';
|
|
10
10
|
import { formatChildFailure, makeWorkerTool } from './shared.js';
|
|
11
|
+
import { normalizeQuery } from './research-cache.js';
|
|
11
12
|
import { projectDocsRaw, buildProjectPrompt } from './docs-project.js';
|
|
12
13
|
const CHILD_ARGS = [...CHILD_BASE_ARGS, '--no-tools'];
|
|
13
14
|
const RENDER_QUERY_MAX = 100;
|
|
@@ -239,6 +240,17 @@ export function registerPiWorkerDocs(pi, internals = {}) {
|
|
|
239
240
|
text += theme.fg('accent', label);
|
|
240
241
|
text += `\n${theme.fg('dim', ` query: ${truncated}`)}`;
|
|
241
242
|
return new Text(text, 0, 0);
|
|
242
|
-
}
|
|
243
|
+
},
|
|
244
|
+
// Cache npm-package answers per run (a package's installed types/README + latest
|
|
245
|
+
// version do not change within a run). A project-source `.` lookup is NOT cached:
|
|
246
|
+
// the working tree mutates as tasks implement, so its answer can go stale mid-run
|
|
247
|
+
// (the docs SQLite index already keys those on file mtime).
|
|
248
|
+
cacheKey: params => params.module === '.' ?
|
|
249
|
+
null
|
|
250
|
+
: `${normalizeQuery(params.module)}::${normalizeQuery(params.query)}`,
|
|
251
|
+
// Only a completed lookup (child exited 0) is a real answer; not-installed,
|
|
252
|
+
// no-chunks, resolve/cache errors, and aborts omit childExitCode:0 and fall
|
|
253
|
+
// through to a live retry next time.
|
|
254
|
+
cacheable: d => d.childExitCode === 0
|
|
243
255
|
});
|
|
244
256
|
}
|
|
@@ -3,6 +3,7 @@ import { Text } from '@earendil-works/pi-tui';
|
|
|
3
3
|
import { FetchAndCleanError } from './html-clean.js';
|
|
4
4
|
import { fetchFocused, formatResultText } from './fetch-core.js';
|
|
5
5
|
import { formatChildFailure, makeWorkerTool } from './shared.js';
|
|
6
|
+
import { normalizeQuery } from './research-cache.js';
|
|
6
7
|
const RENDER_QUERY_MAX = 100;
|
|
7
8
|
const Params = Type.Object({
|
|
8
9
|
url: Type.String({ description: 'URL to fetch. Must be http or https.' }),
|
|
@@ -79,6 +80,14 @@ export function registerPiWorkerFetch(pi, internals = {}) {
|
|
|
79
80
|
text += theme.fg('accent', args.url);
|
|
80
81
|
text += `\n${theme.fg('dim', ` query: ${truncatedQuery}`)}`;
|
|
81
82
|
return new Text(text, 0, 0);
|
|
82
|
-
}
|
|
83
|
+
},
|
|
84
|
+
// Cache fetch answers per run (the same page re-fetched across sibling tasks
|
|
85
|
+
// otherwise). The URL is kept verbatim (path case can matter); the query is
|
|
86
|
+
// normalised. Both parts key the entry — same page, different question is a
|
|
87
|
+
// different answer.
|
|
88
|
+
cacheKey: params => `${params.url.trim()}::${normalizeQuery(params.query)}`,
|
|
89
|
+
// Only a completed fetch (child exited 0) is a real answer; invalid-URL,
|
|
90
|
+
// fetch failures, and aborts omit childExitCode:0 and fall through.
|
|
91
|
+
cacheable: d => d.childExitCode === 0
|
|
83
92
|
});
|
|
84
93
|
}
|
|
@@ -2,6 +2,7 @@ import { Type } from '@sinclair/typebox';
|
|
|
2
2
|
import { Text } from '@earendil-works/pi-tui';
|
|
3
3
|
import { search } from './search-core.js';
|
|
4
4
|
import { makeWorkerTool } from './shared.js';
|
|
5
|
+
import { normalizeQuery } from './research-cache.js';
|
|
5
6
|
const Params = Type.Object({
|
|
6
7
|
query: Type.String({ description: 'Search query.' }),
|
|
7
8
|
count: Type.Optional(Type.Integer({
|
|
@@ -49,6 +50,13 @@ export function registerPiWorkerSearch(pi, internals = {}) {
|
|
|
49
50
|
text += theme.fg('dim', ` (count=${args.count})`);
|
|
50
51
|
}
|
|
51
52
|
return new Text(text, 0, 0);
|
|
52
|
-
}
|
|
53
|
+
},
|
|
54
|
+
// Cache search results per run (the same query re-run across sibling tasks hits
|
|
55
|
+
// the live web anew otherwise). Count is part of the key — a larger request is a
|
|
56
|
+
// different result set.
|
|
57
|
+
cacheKey: params => `${normalizeQuery(params.query)}::${params.count ?? ''}`,
|
|
58
|
+
// Only a non-empty result set is worth caching; no-key, error, and empty results
|
|
59
|
+
// (resultCount 0) fall through so a later attempt can succeed.
|
|
60
|
+
cacheable: d => d.resultCount > 0
|
|
53
61
|
});
|
|
54
62
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** The env var the orchestrator stamps with the per-run id children inherit. */
|
|
2
|
+
export declare const RESEARCH_RUN_ID_ENV = "PI_TASK_RUN_ID";
|
|
3
|
+
export declare function researchCacheFile(cwd: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* The current run's id, or undefined when caching is off (the orchestrator did not
|
|
6
|
+
* stamp one for this run). A worker treats undefined as "do not cache".
|
|
7
|
+
*/
|
|
8
|
+
export declare function researchRunId(): string | undefined;
|
|
9
|
+
/** A fresh, per-invocation run token — stable within one run, unique across runs. */
|
|
10
|
+
export declare function newRunToken(): string;
|
|
11
|
+
/**
|
|
12
|
+
* Orchestrator hook: called once at the start of every /task-auto invocation. When
|
|
13
|
+
* caching is enabled it stamps a FRESH token (so a long-lived host never reuses a
|
|
14
|
+
* prior run's token, and planAuto + the task loop of THIS run share one id); when
|
|
15
|
+
* disabled it clears any token a prior run left, so the workers cache nothing.
|
|
16
|
+
*/
|
|
17
|
+
export declare function configureResearchRun(enabled: boolean): string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Normalise a query/module string for the cache KEY: collapse whitespace and
|
|
20
|
+
* lowercase, so trivially-varied phrasings of the same question share a digest. The
|
|
21
|
+
* stored value is the real answer, so a case/spacing collision only means two ways
|
|
22
|
+
* of asking the same thing resolve to the same (correct) result.
|
|
23
|
+
*/
|
|
24
|
+
export declare function normalizeQuery(s: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Look up a cached result for `key` in the current run. Returns undefined on a miss,
|
|
27
|
+
* a stale-run file (different id ⇒ another run's digest, ignored), or any failure.
|
|
28
|
+
*/
|
|
29
|
+
export declare function lookupResearch(cwd: string, runId: string, key: string): Promise<{
|
|
30
|
+
text: string;
|
|
31
|
+
details: unknown;
|
|
32
|
+
} | undefined>;
|
|
33
|
+
/**
|
|
34
|
+
* Store a successful result under `key` for the current run. A file written for a
|
|
35
|
+
* different run id is discarded and started fresh (first write of a new run drops the
|
|
36
|
+
* prior run's contents — self-healing per-run isolation without an explicit clear).
|
|
37
|
+
* Best-effort: any failure is swallowed, leaving the caller's live result untouched.
|
|
38
|
+
*/
|
|
39
|
+
export declare function storeResearch(cwd: string, runId: string, key: string, text: string, details: unknown): Promise<void>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* research-cache — a per-run cache of docs/search/fetch worker RESULTS, shared
|
|
3
|
+
* across the sibling task pipelines of one /task-auto run.
|
|
4
|
+
*
|
|
5
|
+
* The failure this serves (mx5 run 8, F10): the research phase alone burned 75 of
|
|
6
|
+
* 363 minutes because ~20 sibling task pipelines each re-fetched the SAME external
|
|
7
|
+
* docs and re-ran the SAME searches (the tailwind CLI docs fetched anew for task
|
|
8
|
+
* after task). Each of those worker results is a deterministic function of (tool,
|
|
9
|
+
* package/url, query) that does not change within a run — so the first pipeline to
|
|
10
|
+
* ask a question can answer every later one from a shared digest instead of a fresh
|
|
11
|
+
* network round-trip plus child-summariser spawn.
|
|
12
|
+
*
|
|
13
|
+
* SCOPE — stable external lookups only: npm-package docs, web search, web fetch. A
|
|
14
|
+
* PROJECT-SOURCE (`.`) docs lookup is deliberately NOT cached: the working tree
|
|
15
|
+
* mutates as tasks implement, so a `.` answer from an early task can be stale by a
|
|
16
|
+
* later one (the docs SQLite index already keys those on file mtime). Only a result
|
|
17
|
+
* the tool marks successful is cached — an error, an empty result, or an abort is
|
|
18
|
+
* never memoised, so a transient failure cannot poison the run.
|
|
19
|
+
*
|
|
20
|
+
* PER-RUN ISOLATION: the orchestrator stamps a FRESH run id into the environment
|
|
21
|
+
* (PI_TASK_RUN_ID) at the start of every /task-auto invocation; the research-worker
|
|
22
|
+
* children inherit it. The cache file records the run id it was written for, and any
|
|
23
|
+
* read or write for a different id discards the stale contents. So a long-lived host
|
|
24
|
+
* process running many /task-auto runs never serves one run's digest to another, and
|
|
25
|
+
* a run started with the feature flag OFF (no id in the environment) does not cache
|
|
26
|
+
* at all — the cache is inert unless the orchestrator turned it on for this run.
|
|
27
|
+
*
|
|
28
|
+
* Stored under `.pi-tasks/` (sibling of env-notes.md / contracts.md), which the
|
|
29
|
+
* git-state guard and discardEdits both exclude. Best-effort throughout: any I/O or
|
|
30
|
+
* parse failure falls back to a live fetch — the cache only ever saves time, it can
|
|
31
|
+
* never change an answer or block a worker.
|
|
32
|
+
*/
|
|
33
|
+
import * as fsp from 'node:fs/promises';
|
|
34
|
+
import * as path from 'node:path';
|
|
35
|
+
import { tasksDir } from '../task/task-io.js';
|
|
36
|
+
const RESEARCH_CACHE_FILE = 'research-cache.json';
|
|
37
|
+
/** The env var the orchestrator stamps with the per-run id children inherit. */
|
|
38
|
+
export const RESEARCH_RUN_ID_ENV = 'PI_TASK_RUN_ID';
|
|
39
|
+
/**
|
|
40
|
+
* Cap stored entries so a chatty run cannot grow the file unboundedly; the newest
|
|
41
|
+
* (by write time) are kept. Sized well above a 20-task run's distinct external
|
|
42
|
+
* lookups (dozens), so a real run never evicts a still-useful digest.
|
|
43
|
+
*/
|
|
44
|
+
const MAX_ENTRIES = 250;
|
|
45
|
+
export function researchCacheFile(cwd) {
|
|
46
|
+
return path.join(tasksDir(cwd), RESEARCH_CACHE_FILE);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The current run's id, or undefined when caching is off (the orchestrator did not
|
|
50
|
+
* stamp one for this run). A worker treats undefined as "do not cache".
|
|
51
|
+
*/
|
|
52
|
+
export function researchRunId() {
|
|
53
|
+
const v = process.env[RESEARCH_RUN_ID_ENV]?.trim();
|
|
54
|
+
return v && v.length > 0 ? v : undefined;
|
|
55
|
+
}
|
|
56
|
+
/** A fresh, per-invocation run token — stable within one run, unique across runs. */
|
|
57
|
+
export function newRunToken() {
|
|
58
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Orchestrator hook: called once at the start of every /task-auto invocation. When
|
|
62
|
+
* caching is enabled it stamps a FRESH token (so a long-lived host never reuses a
|
|
63
|
+
* prior run's token, and planAuto + the task loop of THIS run share one id); when
|
|
64
|
+
* disabled it clears any token a prior run left, so the workers cache nothing.
|
|
65
|
+
*/
|
|
66
|
+
export function configureResearchRun(enabled) {
|
|
67
|
+
if (!enabled) {
|
|
68
|
+
delete process.env[RESEARCH_RUN_ID_ENV];
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
const token = newRunToken();
|
|
72
|
+
process.env[RESEARCH_RUN_ID_ENV] = token;
|
|
73
|
+
return token;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Normalise a query/module string for the cache KEY: collapse whitespace and
|
|
77
|
+
* lowercase, so trivially-varied phrasings of the same question share a digest. The
|
|
78
|
+
* stored value is the real answer, so a case/spacing collision only means two ways
|
|
79
|
+
* of asking the same thing resolve to the same (correct) result.
|
|
80
|
+
*/
|
|
81
|
+
export function normalizeQuery(s) {
|
|
82
|
+
return s.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
83
|
+
}
|
|
84
|
+
async function readCacheFile(cwd) {
|
|
85
|
+
try {
|
|
86
|
+
const raw = await fsp.readFile(researchCacheFile(cwd), 'utf8');
|
|
87
|
+
const parsed = JSON.parse(raw);
|
|
88
|
+
if (parsed
|
|
89
|
+
&& typeof parsed === 'object'
|
|
90
|
+
&& typeof parsed.runId === 'string'
|
|
91
|
+
&& typeof parsed.entries === 'object'
|
|
92
|
+
&& parsed.entries !== null) {
|
|
93
|
+
return parsed;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// missing or corrupt ⇒ treated as empty
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Look up a cached result for `key` in the current run. Returns undefined on a miss,
|
|
103
|
+
* a stale-run file (different id ⇒ another run's digest, ignored), or any failure.
|
|
104
|
+
*/
|
|
105
|
+
export async function lookupResearch(cwd, runId, key) {
|
|
106
|
+
const file = await readCacheFile(cwd);
|
|
107
|
+
if (!file || file.runId !== runId)
|
|
108
|
+
return undefined;
|
|
109
|
+
const entry = file.entries[key];
|
|
110
|
+
return entry ? { text: entry.text, details: entry.details } : undefined;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Store a successful result under `key` for the current run. A file written for a
|
|
114
|
+
* different run id is discarded and started fresh (first write of a new run drops the
|
|
115
|
+
* prior run's contents — self-healing per-run isolation without an explicit clear).
|
|
116
|
+
* Best-effort: any failure is swallowed, leaving the caller's live result untouched.
|
|
117
|
+
*/
|
|
118
|
+
export async function storeResearch(cwd, runId, key, text, details) {
|
|
119
|
+
try {
|
|
120
|
+
const existing = await readCacheFile(cwd);
|
|
121
|
+
const entries = existing && existing.runId === runId ? existing.entries : {};
|
|
122
|
+
entries[key] = { text, details, at: Date.now() };
|
|
123
|
+
// Evict oldest by write time if over the cap.
|
|
124
|
+
const keys = Object.keys(entries);
|
|
125
|
+
if (keys.length > MAX_ENTRIES) {
|
|
126
|
+
const ordered = keys.sort((a, b) => entries[a].at - entries[b].at);
|
|
127
|
+
for (const k of ordered.slice(0, keys.length - MAX_ENTRIES))
|
|
128
|
+
delete entries[k];
|
|
129
|
+
}
|
|
130
|
+
const out = { runId, entries };
|
|
131
|
+
await fsp.mkdir(tasksDir(cwd), { recursive: true });
|
|
132
|
+
// Atomic-ish write so a concurrent reader never sees a half-written file.
|
|
133
|
+
const tmp = `${researchCacheFile(cwd)}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
134
|
+
await fsp.writeFile(tmp, JSON.stringify(out), 'utf8');
|
|
135
|
+
await fsp.rename(tmp, researchCacheFile(cwd));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// best-effort cache
|
|
139
|
+
}
|
|
140
|
+
}
|
package/dist/workers/shared.d.ts
CHANGED
|
@@ -38,6 +38,23 @@ export interface WorkerToolSpec<TParams extends TSchema, TDetails> {
|
|
|
38
38
|
details: TDetails;
|
|
39
39
|
}>;
|
|
40
40
|
renderCall(args: Static<TParams>, theme: Theme): Text;
|
|
41
|
+
/**
|
|
42
|
+
* Per-run research-cache policy (F10). Return a stable cache key for this call —
|
|
43
|
+
* a result keyed on it is a deterministic function of the inputs that does not
|
|
44
|
+
* change within a run, so a later sibling task can reuse it instead of re-running
|
|
45
|
+
* the network fetch + child summariser. Return `null` to opt a particular call
|
|
46
|
+
* OUT of caching (e.g. a project-source `.` lookup, whose answer the working tree
|
|
47
|
+
* mutates within a run). Omit entirely and the tool is never cached. The stored
|
|
48
|
+
* key is namespaced by tool name, so keys need only be unique within a tool.
|
|
49
|
+
*/
|
|
50
|
+
cacheKey?(params: Static<TParams>): string | null;
|
|
51
|
+
/**
|
|
52
|
+
* Whether a produced result is safe to cache. Only a SUCCESS is memoised — an
|
|
53
|
+
* error, empty, or aborted result must fall through so a transient failure never
|
|
54
|
+
* poisons the run. Defaults to always-cacheable when omitted (but a tool with a
|
|
55
|
+
* cacheKey should always supply this).
|
|
56
|
+
*/
|
|
57
|
+
cacheable?(details: TDetails, text: string): boolean;
|
|
41
58
|
}
|
|
42
59
|
/** Register a worker tool from its spec, supplying the shared registration ritual. */
|
|
43
60
|
export declare function makeWorkerTool<TParams extends TSchema, TDetails>(pi: ExtensionAPI, spec: WorkerToolSpec<TParams, TDetails>): void;
|
package/dist/workers/shared.js
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.5",
|
|
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",
|