@mjasnikovs/pi-task 0.38.9 → 0.38.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/task/auto-orchestrator.js +2 -2
- package/dist/task/boot-probe.d.ts +298 -0
- package/dist/task/boot-probe.js +806 -0
- package/dist/task/child-runner.d.ts +17 -0
- package/dist/task/child-runner.js +6 -0
- package/dist/task/final-gate.d.ts +33 -281
- package/dist/task/final-gate.js +24 -834
- package/dist/task/launch-manifest.d.ts +5 -0
- package/dist/task/launch-manifest.js +21 -0
- package/dist/task/orchestrator.js +3 -3
- package/dist/task/phases.d.ts +18 -0
- package/dist/task/phases.js +4 -3
- package/dist/task/task-gates.d.ts +69 -0
- package/dist/task/task-gates.js +114 -90
- package/dist/workers/docs-core.d.ts +0 -4
- package/dist/workers/docs-core.js +10 -34
- package/dist/workers/docs-project.js +3 -3
- package/dist/workers/docs-resolve.d.ts +18 -0
- package/dist/workers/docs-resolve.js +39 -0
- package/dist/workers/docs-retrieve.d.ts +13 -0
- package/dist/workers/docs-retrieve.js +17 -2
- package/dist/workers/fetch-core.d.ts +0 -4
- package/dist/workers/fetch-core.js +2 -5
- package/dist/workers/phantom-imports.d.ts +3 -3
- package/dist/workers/phantom-imports.js +16 -29
- package/dist/workers/pi-worker-docs.d.ts +49 -0
- package/dist/workers/pi-worker-docs.js +33 -9
- package/dist/workers/pi-worker-fetch.d.ts +18 -0
- package/dist/workers/pi-worker-fetch.js +19 -4
- package/dist/workers/research-cache.d.ts +2 -0
- package/dist/workers/research-cache.js +65 -3
- package/package.json +1 -1
|
@@ -35,3 +35,8 @@ export declare function readLaunchManifest(cwd: string): LaunchManifest;
|
|
|
35
35
|
* silently did nothing must not read later as a check that passed.
|
|
36
36
|
*/
|
|
37
37
|
export declare function inertLaunchContractNote(declared: string[], manifest: LaunchManifest): string;
|
|
38
|
+
/** The `scripts` map of this tree's package.json, or `{}` when there isn't one.
|
|
39
|
+
* Shared by the gate and the boot probe — both ask the same manifest question. */
|
|
40
|
+
export declare function packageScripts(cwd: string): Record<string, string>;
|
|
41
|
+
/** Does this tree's Makefile declare `target`? */
|
|
42
|
+
export declare function makeHasTarget(cwd: string, target: string): boolean;
|
|
@@ -149,3 +149,24 @@ export function inertLaunchContractNote(declared, manifest) {
|
|
|
149
149
|
+ `but this project could not be diffed against a manifest — ${manifest.why ?? 'no manifest'}. `
|
|
150
150
|
+ 'The contract was NOT checked here.');
|
|
151
151
|
}
|
|
152
|
+
/** The `scripts` map of this tree's package.json, or `{}` when there isn't one.
|
|
153
|
+
* Shared by the gate and the boot probe — both ask the same manifest question. */
|
|
154
|
+
export function packageScripts(cwd) {
|
|
155
|
+
try {
|
|
156
|
+
const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
157
|
+
return j.scripts ?? {};
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return {};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** Does this tree's Makefile declare `target`? */
|
|
164
|
+
export function makeHasTarget(cwd, target) {
|
|
165
|
+
try {
|
|
166
|
+
const mk = readFileSync(path.join(cwd, 'Makefile'), 'utf8');
|
|
167
|
+
return new RegExp(`^${target}:`, 'm').test(mk);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -303,7 +303,7 @@ export class TaskRunner {
|
|
|
303
303
|
this._stopWidget = null;
|
|
304
304
|
}
|
|
305
305
|
async _deliverSpec(_ctx) {
|
|
306
|
-
const spec = this._specForDelivery();
|
|
306
|
+
const spec = await this._specForDelivery();
|
|
307
307
|
// Keep the rich status block alive across the implementation turn (the phase
|
|
308
308
|
// widget was disposed at handoff). Awaited (/task-auto) stays armed across all
|
|
309
309
|
// sub-turns and is disarmed here; fire-and-forget (/task) arms one-shot and its
|
|
@@ -343,8 +343,8 @@ export class TaskRunner {
|
|
|
343
343
|
* deterministic check proves does not exist. No-op (returns the spec unchanged) when
|
|
344
344
|
* nothing is flagged or the runtime's types aren't installed.
|
|
345
345
|
*/
|
|
346
|
-
_specForDelivery() {
|
|
347
|
-
const phantoms = findDeliveryPhantoms(this._pc.spec, this._cwd);
|
|
346
|
+
async _specForDelivery() {
|
|
347
|
+
const phantoms = await findDeliveryPhantoms(this._pc.spec, this._cwd);
|
|
348
348
|
const apiBanner = formatApiOverrideBanner(phantoms);
|
|
349
349
|
if (apiBanner) {
|
|
350
350
|
this._deps.logDebug?.(`impl-handoff API override banner prepended for: ${phantoms.map(p => p.spec).join(', ')}`);
|
package/dist/task/phases.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
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
|
+
import { type RunWorkerInput, type RunWorkerResult } from '../workers/pi-worker-core.js';
|
|
8
9
|
import type { SearchCoreInput, SearchCoreResult } from '../workers/search-core.js';
|
|
9
10
|
import type { SearchProvider } from '../workers/search-types.js';
|
|
10
11
|
import { type ExternalContextDeps } from './external-context.js';
|
|
@@ -102,6 +103,23 @@ export declare const phaseRefine: (deps: PhaseDeps, raw: string, planContext?: s
|
|
|
102
103
|
export declare function phaseVerifyTooling(deps: PhaseDeps, research: string): Promise<string>;
|
|
103
104
|
export interface PhaseResearchDeps extends ExternalContextDeps {
|
|
104
105
|
getFileInventory?: (cwd: string, signal?: AbortSignal) => Promise<string>;
|
|
106
|
+
/**
|
|
107
|
+
* Run ONE research worker. Absent (production) → the real `runWorker`.
|
|
108
|
+
*
|
|
109
|
+
* The seam exists for the same reason `getFileInventory` does: every decision
|
|
110
|
+
* `runSpec` makes — the three Research retry gates, the fatal/runaway/empty
|
|
111
|
+
* classification, the marker choice, `postProcess` — is a pure function of the
|
|
112
|
+
* returned `RunWorkerResult`, but reaching any of them used to require driving
|
|
113
|
+
* a fake process that emits JSON events. Substituting the result lets a gate be
|
|
114
|
+
* tested by the fields it actually reads.
|
|
115
|
+
*
|
|
116
|
+
* `label` is the worker's name — the same one `recordWorker` trails. It is
|
|
117
|
+
* passed because a substitute must answer differently per worker, and the only
|
|
118
|
+
* alternative is matching a marker sentence inside the prompt, which makes
|
|
119
|
+
* prompt copy load-bearing test infrastructure. Same reason
|
|
120
|
+
* `PhaseDeps.runChild` takes a name.
|
|
121
|
+
*/
|
|
122
|
+
runWorker?: (label: string, input: RunWorkerInput) => Promise<RunWorkerResult>;
|
|
105
123
|
}
|
|
106
124
|
/**
|
|
107
125
|
* Is live web search configured for this process? The keyless providers (exa,
|
package/dist/task/phases.js
CHANGED
|
@@ -606,6 +606,7 @@ const CONTEXT_SILENT_RETRY_PREAMBLE = 'STOP. Your previous attempt at this task
|
|
|
606
606
|
+ 'question. One claim per bullet. Better to emit three sharp sourced bullets than to say nothing.';
|
|
607
607
|
export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
608
608
|
const fileInventoryFn = researchDeps.getFileInventory ?? getFileInventory;
|
|
609
|
+
const runWorkerFn = researchDeps.runWorker ?? ((_label, input) => runWorker(input));
|
|
609
610
|
const externalContext = await gatherExternalContext(refined, deps, researchDeps);
|
|
610
611
|
// Pre-compute the project file inventory once and hand it to every worker.
|
|
611
612
|
// Workers can then jump straight to targeted read/grep on known paths
|
|
@@ -859,7 +860,7 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
|
859
860
|
}
|
|
860
861
|
deps.logDebug?.(`${spec.label}: start`);
|
|
861
862
|
const basePrompt = typeof spec.prompt === 'function' ? spec.prompt(prior) : spec.prompt;
|
|
862
|
-
const runOnce = (extraPreamble) => recordWorker(spec.label,
|
|
863
|
+
const runOnce = (extraPreamble) => recordWorker(spec.label, runWorkerFn(spec.label, {
|
|
863
864
|
prompt: extraPreamble ? `${extraPreamble}\n\n${basePrompt}` : basePrompt,
|
|
864
865
|
cwd: deps.cwd,
|
|
865
866
|
signal: deps.signal,
|
|
@@ -1510,7 +1511,7 @@ export const PHASES = [
|
|
|
1510
1511
|
// (proven: compose re-leaks it 4/4). Rewriting the source so compose has
|
|
1511
1512
|
// nothing to contradict is the fix. Silent + no-op when nothing is wrong
|
|
1512
1513
|
// or the runtime's types aren't installed.
|
|
1513
|
-
const phantoms = findPhantomImports(refined, d.cwd);
|
|
1514
|
+
const phantoms = await findPhantomImports(refined, d.cwd);
|
|
1514
1515
|
if (phantoms.length === 0)
|
|
1515
1516
|
return refined;
|
|
1516
1517
|
d.logDebug?.(`phantom specifiers rewritten in refined: ${phantoms.map(x => x.spec).join(', ')}`);
|
|
@@ -1534,7 +1535,7 @@ export const PHASES = [
|
|
|
1534
1535
|
// through every phase and the implementer fabricates a `declare module`
|
|
1535
1536
|
// shim to compile it. Append the corrections so compose folds them into
|
|
1536
1537
|
// CONSTRAINTS. No LLM cost and silent when nothing is wrong.
|
|
1537
|
-
const corrections = formatApiCorrections(findPhantomImports(p.refined, d.cwd));
|
|
1538
|
+
const corrections = formatApiCorrections(await findPhantomImports(p.refined, d.cwd));
|
|
1538
1539
|
if (corrections) {
|
|
1539
1540
|
d.logDebug?.(`phantom imports flagged:\n${corrections}`);
|
|
1540
1541
|
return `${out}\n\n${corrections}`;
|
|
@@ -63,6 +63,18 @@ export interface GateDeps {
|
|
|
63
63
|
* tests or when `verify work` is off → the sequence treats it as a pass.
|
|
64
64
|
*/
|
|
65
65
|
verify?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string, taskId: string) => Promise<VerifyOutcome>;
|
|
66
|
+
/**
|
|
67
|
+
* The DIFFERENTIAL re-verify the enforce pass runs against the enforced tree,
|
|
68
|
+
* to decide whether its own commit survives. Absent → `verify`, which is what
|
|
69
|
+
* it has always been, so production wiring is untouched.
|
|
70
|
+
*
|
|
71
|
+
* Its own field because it answers a different question from the gate above.
|
|
72
|
+
* While the two shared one field the only way to answer them differently was to
|
|
73
|
+
* count invocations — a `verifyCalls` state machine whose FIRST return existed
|
|
74
|
+
* solely to unlock `mode === 'edit'`, re-invented in the suite and again in
|
|
75
|
+
* scripts/enforce-revert-attribution-replay-ab.ts.
|
|
76
|
+
*/
|
|
77
|
+
reVerify?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string, taskId: string) => Promise<VerifyOutcome>;
|
|
66
78
|
/**
|
|
67
79
|
* Hold the committed work to AGENTS.md / CLAUDE.md. `edit` (fix in place) only
|
|
68
80
|
* with a clean verify signal to guard against; otherwise `flag` (report only).
|
|
@@ -293,4 +305,61 @@ export declare function askVerifyResolution(ctx: ExtensionCommandContext, title:
|
|
|
293
305
|
* only a user cancel inside a gate child propagates (handled by the caller's
|
|
294
306
|
* USER_CANCELLED path).
|
|
295
307
|
*/
|
|
308
|
+
/** The durable per-task gate trail: every outcome is appended to the task file so
|
|
309
|
+
* the sequence is auditable from artifacts alone. Best-effort — recording must
|
|
310
|
+
* never break the gate sequence. */
|
|
311
|
+
type Recorder = (line: string) => Promise<void>;
|
|
312
|
+
/**
|
|
313
|
+
* The root-cause channel: was this FAIL caused by a pre-existing defect in a file
|
|
314
|
+
* some OTHER task created and this task never touched?
|
|
315
|
+
*/
|
|
316
|
+
type RootCauseRouter = (failReason: string, rationale: string, scope: 'worktree' | 'committed' | 'enforce-commit') => Promise<RepairCandidate | null>;
|
|
317
|
+
/**
|
|
318
|
+
* What the VERIFY half settled. Either the sequence is over (`stop` carries the
|
|
319
|
+
* terminal GateResult — dismissed picker, cancelled session, interrupted or failed
|
|
320
|
+
* autofix) or the task proceeds to commit + enforce.
|
|
321
|
+
*
|
|
322
|
+
* `cleanPass` is the ONE fact that crosses to the enforce half: a GENUINE clean
|
|
323
|
+
* pass (a real signal ran and the work met it) is the only thing that gives
|
|
324
|
+
* enforce a signal to revert against, so only then may it edit in place. A no-op
|
|
325
|
+
* pass, a disabled gate or an accept-override leaves it false → flag-only.
|
|
326
|
+
*/
|
|
327
|
+
type VerifyGateStep = {
|
|
328
|
+
stop: GateResult;
|
|
329
|
+
} | {
|
|
330
|
+
proceed: {
|
|
331
|
+
ctx: ExtensionCommandContext;
|
|
332
|
+
cleanPass: boolean;
|
|
333
|
+
};
|
|
334
|
+
};
|
|
335
|
+
/**
|
|
336
|
+
* The VERIFY resolution loop: run the task's verification against the finished
|
|
337
|
+
* work, and negotiate a FAIL through the graduated ladder (bounded lint fix →
|
|
338
|
+
* recommendation → unattended autofix → picker) until it verifies, is accepted,
|
|
339
|
+
* or terminates.
|
|
340
|
+
*
|
|
341
|
+
* Split out of `runGatesForTask` at the single boolean that crosses to the
|
|
342
|
+
* ENFORCE half. It carries 8 mutable locals over ~290 lines and has four terminal
|
|
343
|
+
* exits; enforce carries one local and always falls through. Joining them meant a
|
|
344
|
+
* test of the enforce differential had to traverse this whole loop first, which is
|
|
345
|
+
* why `deps.verify` was driven by an invocation counter whose first return existed
|
|
346
|
+
* only to unlock `mode === 'edit'`.
|
|
347
|
+
*/
|
|
348
|
+
export declare function resolveVerifyGate(ctxIn: ExtensionCommandContext, deps: GateDeps, p: GateParams, rec: Recorder, routeRootCause: RootCauseRouter): Promise<VerifyGateStep>;
|
|
349
|
+
/**
|
|
350
|
+
* The ENFORCE differential: hold the committed work to AGENTS.md / CLAUDE.md,
|
|
351
|
+
* then decide whether the pass\'s own commit survives.
|
|
352
|
+
*
|
|
353
|
+
* `reVerify` is deliberately NOT `deps.verify`. They answer two different
|
|
354
|
+
* questions — the gate above, and this differential — and while they shared one
|
|
355
|
+
* field the only way to answer them differently was to count invocations.
|
|
356
|
+
*
|
|
357
|
+
* Reads `active` and never reassigns it: nothing here can replace the live
|
|
358
|
+
* session, unlike the autofix in the verify half.
|
|
359
|
+
*/
|
|
360
|
+
export declare function runEnforcePass(active: ExtensionCommandContext, deps: GateDeps, p: GateParams, rec: Recorder, routeRootCause: RootCauseRouter, args: {
|
|
361
|
+
cleanPass: boolean;
|
|
362
|
+
commit: CommitResult;
|
|
363
|
+
}): Promise<void>;
|
|
296
364
|
export declare function runGatesForTask(ctxIn: ExtensionCommandContext, deps: GateDeps, p: GateParams): Promise<GateResult>;
|
|
365
|
+
export {};
|
package/dist/task/task-gates.js
CHANGED
|
@@ -67,60 +67,20 @@ export async function askVerifyResolution(ctx, title, failReason, rec) {
|
|
|
67
67
|
return classifyResolutionAnswer(answer);
|
|
68
68
|
}
|
|
69
69
|
/**
|
|
70
|
-
*
|
|
70
|
+
* The VERIFY resolution loop: run the task's verification against the finished
|
|
71
|
+
* work, and negotiate a FAIL through the graduated ladder (bounded lint fix →
|
|
72
|
+
* recommendation → unattended autofix → picker) until it verifies, is accepted,
|
|
73
|
+
* or terminates.
|
|
71
74
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
75
|
+
* Split out of `runGatesForTask` at the single boolean that crosses to the
|
|
76
|
+
* ENFORCE half. It carries 8 mutable locals over ~290 lines and has four terminal
|
|
77
|
+
* exits; enforce carries one local and always falls through. Joining them meant a
|
|
78
|
+
* test of the enforce differential had to traverse this whole loop first, which is
|
|
79
|
+
* why `deps.verify` was driven by an invocation counter whose first return existed
|
|
80
|
+
* only to unlock `mode === 'edit'`.
|
|
78
81
|
*/
|
|
79
|
-
export async function
|
|
82
|
+
export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
|
|
80
83
|
let active = ctxIn;
|
|
81
|
-
// Durable per-task gate trail — every outcome below is also appended to the
|
|
82
|
-
// task file so the sequence is auditable from artifacts alone. Best-effort.
|
|
83
|
-
const rec = async (line) => {
|
|
84
|
-
try {
|
|
85
|
-
await deps.record?.(p.cwd, p.taskId, line);
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
// recording must never break the gate sequence
|
|
89
|
-
}
|
|
90
|
-
};
|
|
91
|
-
/**
|
|
92
|
-
* ROOT-CAUSE CHANNEL (mx5 run 14 item 5). Ask whether a FAIL was caused by a
|
|
93
|
-
* pre-existing defect in a file some OTHER task created and this task never
|
|
94
|
-
* touched. On a hit: record the durable debt (so the final gate surfaces it)
|
|
95
|
-
* and queue a scoped repair task (so something finally FIXES it — run 14
|
|
96
|
-
* recorded the same `test/teardown.ts` cause twice and scheduled nothing, and
|
|
97
|
-
* the bug survived ~24h). Returns the candidate so the caller can also decide
|
|
98
|
-
* NOT to punish the current task for it. Never throws: any fault degrades to
|
|
99
|
-
* null, i.e. exactly the pre-existing behavior.
|
|
100
|
-
*/
|
|
101
|
-
const routeRootCause = async (failReason, rationale, scope) => {
|
|
102
|
-
if (!deps.touchedFiles || !deps.introducedBy)
|
|
103
|
-
return null;
|
|
104
|
-
try {
|
|
105
|
-
const candidate = await findRepairCandidate({
|
|
106
|
-
failReason,
|
|
107
|
-
rationale,
|
|
108
|
-
currentTaskId: p.taskId,
|
|
109
|
-
touched: await deps.touchedFiles(p.cwd, scope),
|
|
110
|
-
introducedBy: rel => deps.introducedBy(p.cwd, rel)
|
|
111
|
-
});
|
|
112
|
-
if (!candidate)
|
|
113
|
-
return null;
|
|
114
|
-
await deps.recordDebt?.(p.cwd, p.taskId, `${failReason} — ROOT CAUSE: \`${candidate.file}\` (introduced by ${candidate.owner}, not touched by this task)`, 'root-cause');
|
|
115
|
-
await deps.recordRepairCandidate?.(p.cwd, candidate);
|
|
116
|
-
await rec(`root-cause: FAIL attributed to \`${candidate.file}\` — a pre-existing defect in ${candidate.owner}'s file that this task never touched; `
|
|
117
|
-
+ 'recorded as durable debt and a scoped repair task queued');
|
|
118
|
-
return candidate;
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
};
|
|
124
84
|
const verdictLine = (v) => v.ok ?
|
|
125
85
|
v.reason ?
|
|
126
86
|
`verify: PASS (${v.reason})`
|
|
@@ -289,7 +249,7 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
289
249
|
}
|
|
290
250
|
if (choice.action === 'cancel') {
|
|
291
251
|
await rec('resolution: user dismissed the verify-FAIL picker — paused');
|
|
292
|
-
return { kind: 'paused', ctx: active, reason: failReason };
|
|
252
|
+
return { stop: { kind: 'paused', ctx: active, reason: failReason } };
|
|
293
253
|
}
|
|
294
254
|
if (choice.action === 'accept') {
|
|
295
255
|
const byYolo = yoloChoice !== null;
|
|
@@ -360,11 +320,11 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
360
320
|
});
|
|
361
321
|
active = fixRes.ctx ?? active;
|
|
362
322
|
if (fixRes.sessionCancelled)
|
|
363
|
-
return { kind: 'session-cancelled', ctx: active };
|
|
323
|
+
return { stop: { kind: 'session-cancelled', ctx: active } };
|
|
364
324
|
if (fixRes.interrupted)
|
|
365
|
-
return { kind: 'interrupted', ctx: active };
|
|
325
|
+
return { stop: { kind: 'interrupted', ctx: active } };
|
|
366
326
|
if (!fixRes.ok)
|
|
367
|
-
return { kind: 'failed', ctx: active, reason: fixRes.reason };
|
|
327
|
+
return { stop: { kind: 'failed', ctx: active, reason: fixRes.reason } };
|
|
368
328
|
// Resume reuses the same inner task id, so p.taskId is stable.
|
|
369
329
|
verified = await deps.verify(active, p.cwd, p.title, p.taskId);
|
|
370
330
|
await rec(verdictLine(verified));
|
|
@@ -374,39 +334,21 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
374
334
|
// accept-override (verified.ok still false at break) is NOT a guardable signal.
|
|
375
335
|
verifyCleanPass = verified.ok && !verified.reason;
|
|
376
336
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
+ `snapshot — regenerable output, not deliverables: `
|
|
393
|
-
+ `${commit.excluded.slice(0, 8).join(', ')}`
|
|
394
|
-
+ `${commit.excluded.length > 8 ? `, +${commit.excluded.length - 8} more` : ''}`);
|
|
395
|
-
}
|
|
396
|
-
active.ui.notify(`${p.tag}: committed "${p.title}".`, 'info');
|
|
397
|
-
}
|
|
398
|
-
else {
|
|
399
|
-
await rec(`commit: skipped (${commit.reason ?? 'unknown'})`);
|
|
400
|
-
// A benign skip ("nothing to commit", auto-commit off) is a warning. A real
|
|
401
|
-
// git failure is louder: it silently disables enforce AND every commit-based
|
|
402
|
-
// guard — mx5 run 4 lost all 10 commits (no container git identity) with only
|
|
403
|
-
// per-task warnings to show for it. "blocked" is the unmerged-index refusal
|
|
404
|
-
// (gitCommitAll) — the same severity: nothing can commit until it's resolved.
|
|
405
|
-
const gitFailure = /^git (commit|add) (failed|blocked)/.test(commit.reason ?? '');
|
|
406
|
-
active.ui.notify(gitFailure ?
|
|
407
|
-
`${p.tag}: COMMIT FAILED (${commit.reason}) — enforce and revert guards are disabled for this task.`
|
|
408
|
-
: `${p.tag}: not committed (${commit.reason ?? 'unknown'}) — continuing.`, gitFailure ? 'error' : 'warning');
|
|
409
|
-
}
|
|
337
|
+
return { proceed: { ctx: active, cleanPass: verifyCleanPass } };
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* The ENFORCE differential: hold the committed work to AGENTS.md / CLAUDE.md,
|
|
341
|
+
* then decide whether the pass\'s own commit survives.
|
|
342
|
+
*
|
|
343
|
+
* `reVerify` is deliberately NOT `deps.verify`. They answer two different
|
|
344
|
+
* questions — the gate above, and this differential — and while they shared one
|
|
345
|
+
* field the only way to answer them differently was to count invocations.
|
|
346
|
+
*
|
|
347
|
+
* Reads `active` and never reassigns it: nothing here can replace the live
|
|
348
|
+
* session, unlike the autofix in the verify half.
|
|
349
|
+
*/
|
|
350
|
+
export async function runEnforcePass(active, deps, p, rec, routeRootCause, args) {
|
|
351
|
+
const { cleanPass: verifyCleanPass, commit } = args;
|
|
410
352
|
// With the task committed, hold its work to AGENTS.md / CLAUDE.md — but as a step
|
|
411
353
|
// INSIDE the validation gate, gated by the verify signal (see GateDeps.enforce).
|
|
412
354
|
// Skipped when nothing was committed this round, when enforce is off, or in tests
|
|
@@ -516,8 +458,9 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
516
458
|
if (enforceCommit.committed) {
|
|
517
459
|
// Differential guard: re-run the verify signal against the enforced
|
|
518
460
|
// tree. A regression ⇒ drop the enforce commit, keep the verified work.
|
|
519
|
-
const
|
|
520
|
-
|
|
461
|
+
const differential = deps.reVerify ?? deps.verify;
|
|
462
|
+
const after = differential ?
|
|
463
|
+
await differential(active, p.cwd, p.title, p.taskId)
|
|
521
464
|
: { ok: true };
|
|
522
465
|
const afterReason = after.reason ?? 'enforce re-verify failed';
|
|
523
466
|
// PRE-EXISTING-CAUSE KEEP PATH (mx5 run 14 item 5b). Both of run
|
|
@@ -632,5 +575,86 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
632
575
|
// so a missing enforce run is explainable from the trail (mx5 audit gap).
|
|
633
576
|
await rec('enforce: skipped (nothing committed this round)');
|
|
634
577
|
}
|
|
578
|
+
}
|
|
579
|
+
export async function runGatesForTask(ctxIn, deps, p) {
|
|
580
|
+
const rec = async (line) => {
|
|
581
|
+
try {
|
|
582
|
+
await deps.record?.(p.cwd, p.taskId, line);
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
// recording must never break the gate sequence
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
/**
|
|
589
|
+
* ROOT-CAUSE CHANNEL (mx5 run 14 item 5). Ask whether a FAIL was caused by a
|
|
590
|
+
* pre-existing defect in a file some OTHER task created and this task never
|
|
591
|
+
* touched. On a hit: record the durable debt (so the final gate surfaces it)
|
|
592
|
+
* and queue a scoped repair task (so something finally FIXES it — run 14
|
|
593
|
+
* recorded the same `test/teardown.ts` cause twice and scheduled nothing, and
|
|
594
|
+
* the bug survived ~24h). Returns the candidate so the caller can also decide
|
|
595
|
+
* NOT to punish the current task for it. Never throws: any fault degrades to
|
|
596
|
+
* null, i.e. exactly the pre-existing behavior.
|
|
597
|
+
*/
|
|
598
|
+
const routeRootCause = async (failReason, rationale, scope) => {
|
|
599
|
+
if (!deps.touchedFiles || !deps.introducedBy)
|
|
600
|
+
return null;
|
|
601
|
+
try {
|
|
602
|
+
const candidate = await findRepairCandidate({
|
|
603
|
+
failReason,
|
|
604
|
+
rationale,
|
|
605
|
+
currentTaskId: p.taskId,
|
|
606
|
+
touched: await deps.touchedFiles(p.cwd, scope),
|
|
607
|
+
introducedBy: rel => deps.introducedBy(p.cwd, rel)
|
|
608
|
+
});
|
|
609
|
+
if (!candidate)
|
|
610
|
+
return null;
|
|
611
|
+
await deps.recordDebt?.(p.cwd, p.taskId, `${failReason} — ROOT CAUSE: \`${candidate.file}\` (introduced by ${candidate.owner}, not touched by this task)`, 'root-cause');
|
|
612
|
+
await deps.recordRepairCandidate?.(p.cwd, candidate);
|
|
613
|
+
await rec(`root-cause: FAIL attributed to \`${candidate.file}\` — a pre-existing defect in ${candidate.owner}'s file that this task never touched; `
|
|
614
|
+
+ 'recorded as durable debt and a scoped repair task queued');
|
|
615
|
+
return candidate;
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
const step = await resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause);
|
|
622
|
+
if ('stop' in step)
|
|
623
|
+
return step.stop;
|
|
624
|
+
const { ctx: active, cleanPass } = step.proceed;
|
|
625
|
+
// Mark the work verified (parent task-list check-off for /task-auto; no-op for
|
|
626
|
+
// /task) BEFORE committing, so the commit captures the check-off too.
|
|
627
|
+
await p.onVerified?.();
|
|
628
|
+
// Commit the task's work as one snapshot FIRST — before guideline enforcement —
|
|
629
|
+
// so a passing task is durably recorded no matter what enforcement later finds.
|
|
630
|
+
const commit = await deps.commit(p.cwd, `task: ${p.title} (${p.taskId})`);
|
|
631
|
+
if (commit.committed) {
|
|
632
|
+
await rec(`commit: task snapshot committed${commit.note ? ` (${commit.note})` : ''}`);
|
|
633
|
+
// SAY WHAT WAS LEFT OUT. The stage skips untracked regenerable test-runner
|
|
634
|
+
// output (mx5 run 20: TASK_0027's `git add -A` swept in three Playwright
|
|
635
|
+
// failure screenshots and two later fix attempts were rejected for deleting
|
|
636
|
+
// them). A SILENT exclusion is the same failure class as the silent
|
|
637
|
+
// ignored-path write nexttask 4 closed, so it gets its own trail line.
|
|
638
|
+
if (commit.excluded && commit.excluded.length > 0) {
|
|
639
|
+
await rec(`commit: left ${commit.excluded.length} untracked test-runner artifact(s) out of the `
|
|
640
|
+
+ `snapshot — regenerable output, not deliverables: `
|
|
641
|
+
+ `${commit.excluded.slice(0, 8).join(', ')}`
|
|
642
|
+
+ `${commit.excluded.length > 8 ? `, +${commit.excluded.length - 8} more` : ''}`);
|
|
643
|
+
}
|
|
644
|
+
active.ui.notify(`${p.tag}: committed "${p.title}".`, 'info');
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
await rec(`commit: skipped (${commit.reason ?? 'unknown'})`);
|
|
648
|
+
// A benign skip ("nothing to commit", auto-commit off) is a warning. A real
|
|
649
|
+
// git failure is louder: it silently disables enforce AND every commit-based
|
|
650
|
+
// guard — mx5 run 4 lost all 10 commits (no container git identity) with only
|
|
651
|
+
// per-task warnings to show for it. "blocked" is the unmerged-index refusal
|
|
652
|
+
// (gitCommitAll) — the same severity: nothing can commit until it's resolved.
|
|
653
|
+
const gitFailure = /^git (commit|add) (failed|blocked)/.test(commit.reason ?? '');
|
|
654
|
+
active.ui.notify(gitFailure ?
|
|
655
|
+
`${p.tag}: COMMIT FAILED (${commit.reason}) — enforce and revert guards are disabled for this task.`
|
|
656
|
+
: `${p.tag}: not committed (${commit.reason ?? 'unknown'}) — continuing.`, gitFailure ? 'error' : 'warning');
|
|
657
|
+
}
|
|
658
|
+
await runEnforcePass(active, deps, p, rec, routeRootCause, { cleanPass, commit });
|
|
635
659
|
return { kind: 'done', ctx: active };
|
|
636
660
|
}
|
|
@@ -183,9 +183,5 @@ export declare function buildPrompt(pkg: ResolvedPackage, query: string, content
|
|
|
183
183
|
* (`{name, version: 'local', root, entryDts: null, readme: null}`) purely to make
|
|
184
184
|
* this call compile, with three of the five fields existing only for that.
|
|
185
185
|
*/
|
|
186
|
-
export declare function formatResultText(header: string, parsed: {
|
|
187
|
-
answer: string;
|
|
188
|
-
excerpt?: string;
|
|
189
|
-
}, verified: boolean | undefined): string;
|
|
190
186
|
/** The header for an npm package answer. */
|
|
191
187
|
export declare function packageHeader(pkg: ResolvedPackage): string;
|
|
@@ -4,15 +4,14 @@ import * as os from 'node:os';
|
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import { openCache as defaultOpenCache } from './docs-cache.js';
|
|
6
6
|
import { ensureIndexed as defaultEnsureIndexed } from './docs-index.js';
|
|
7
|
-
import { resolvePackage as defaultResolvePackage, ResolveError,
|
|
8
|
-
import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
|
|
7
|
+
import { resolvePackage as defaultResolvePackage, ResolveError, isDtsFile, resolveTypeSource, typesPackageName, splitRuntimeNamespace } from './docs-resolve.js';
|
|
8
|
+
import { retrieveChunks as defaultRetrieveChunks, PACKAGE_RETRIEVE_LIMIT, RETRIEVE_CONTENT_BUDGET } from './docs-retrieve.js';
|
|
9
9
|
import { npmVersionLookup as defaultNpmVersionLookup } from './npm-version.js';
|
|
10
10
|
import { runChild } from '../shared/child-process.js';
|
|
11
11
|
import { runFocusedExtraction } from './focused-extractor.js';
|
|
12
12
|
import { buildExtractionPrompt } from './abstention.js';
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
const DEFAULT_BUDGET = 24_000;
|
|
13
|
+
const DEFAULT_LIMIT = PACKAGE_RETRIEVE_LIMIT;
|
|
14
|
+
const DEFAULT_BUDGET = RETRIEVE_CONTENT_BUDGET;
|
|
16
15
|
const NO_CACHE_HEAD = 25_000;
|
|
17
16
|
const NO_CACHE_TAIL = 5_000;
|
|
18
17
|
const NO_CACHE_TOTAL = NO_CACHE_HEAD + NO_CACHE_TAIL;
|
|
@@ -224,31 +223,11 @@ async function tryResolveOrInstall(name, cwd, spawn, resolvePackage, signal) {
|
|
|
224
223
|
}
|
|
225
224
|
}
|
|
226
225
|
}
|
|
227
|
-
/**
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const visited = new Set([pkg.name, extractParentPackage(requested)]);
|
|
233
|
-
let cur = pkg;
|
|
234
|
-
for (let depth = 0; depth < 3; depth++) {
|
|
235
|
-
let next = detectTypesRedirect(cur);
|
|
236
|
-
if (next && visited.has(next))
|
|
237
|
-
next = null;
|
|
238
|
-
if (!next && !hasTypeFiles(cur.root)) {
|
|
239
|
-
const types = typesPackageName(cur.name);
|
|
240
|
-
if (types && !visited.has(types))
|
|
241
|
-
next = types;
|
|
242
|
-
}
|
|
243
|
-
if (!next)
|
|
244
|
-
break;
|
|
245
|
-
visited.add(next);
|
|
246
|
-
const resolved = await tryResolveOrInstall(next, cwd, spawn, resolvePackage, signal);
|
|
247
|
-
if (!resolved)
|
|
248
|
-
break;
|
|
249
|
-
cur = resolved;
|
|
250
|
-
}
|
|
251
|
-
return cur;
|
|
226
|
+
/** The docs pipeline's adapter over the shared redirect walk (docs-resolve.ts):
|
|
227
|
+
* hops resolve through the auto-installing lookup, so a declaration package that is
|
|
228
|
+
* declared but not yet on disk is fetched rather than abandoned. */
|
|
229
|
+
function resolveTypeSourceForDocs(pkg, requested, cwd, spawn, resolvePackage, signal) {
|
|
230
|
+
return resolveTypeSource(pkg, extractParentPackage(requested), next => tryResolveOrInstall(next, cwd, spawn, resolvePackage, signal));
|
|
252
231
|
}
|
|
253
232
|
export async function docsRaw(input) {
|
|
254
233
|
const resolvePackage = input.resolvePackage ?? defaultResolvePackage;
|
|
@@ -344,7 +323,7 @@ export async function docsRaw(input) {
|
|
|
344
323
|
// @types/<name> + triple-slash `<reference types>` chain to the package that
|
|
345
324
|
// actually holds the declarations (e.g. bun -> @types/bun -> bun-types).
|
|
346
325
|
// Best-effort: any failure leaves the original resolution untouched.
|
|
347
|
-
pkg = await
|
|
326
|
+
pkg = await resolveTypeSourceForDocs(pkg, requested, input.cwd, spawn, resolvePackage, input.signal);
|
|
348
327
|
// Step 2: open cache
|
|
349
328
|
let cache = null;
|
|
350
329
|
let cacheError;
|
|
@@ -569,9 +548,6 @@ export function buildPrompt(pkg, query, content) {
|
|
|
569
548
|
* (`{name, version: 'local', root, entryDts: null, readme: null}`) purely to make
|
|
570
549
|
* this call compile, with three of the five fields existing only for that.
|
|
571
550
|
*/
|
|
572
|
-
export function formatResultText(header, parsed, verified) {
|
|
573
|
-
return formatResultTextShared(header, parsed, verified);
|
|
574
|
-
}
|
|
575
551
|
/** The header for an npm package answer. */
|
|
576
552
|
export function packageHeader(pkg) {
|
|
577
553
|
return `Per ${pkg.name}@${pkg.version}:`;
|
|
@@ -2,11 +2,11 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
3
|
import * as fs from 'node:fs';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
|
-
import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
|
|
5
|
+
import { retrieveChunks as defaultRetrieveChunks, PROJECT_RETRIEVE_LIMIT, RETRIEVE_CONTENT_BUDGET } from './docs-retrieve.js';
|
|
6
6
|
import { buildExtractionPrompt } from './abstention.js';
|
|
7
7
|
import { chunkDeclarations } from './docs-chunk.js';
|
|
8
|
-
const DEFAULT_LIMIT =
|
|
9
|
-
const DEFAULT_BUDGET =
|
|
8
|
+
const DEFAULT_LIMIT = PROJECT_RETRIEVE_LIMIT;
|
|
9
|
+
const DEFAULT_BUDGET = RETRIEVE_CONTENT_BUDGET;
|
|
10
10
|
export function getProjectName(cwd) {
|
|
11
11
|
try {
|
|
12
12
|
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
@@ -54,3 +54,21 @@ export declare function countEntryDeclarations(content: string): number;
|
|
|
54
54
|
* local `/// <reference path=... />` aggregator entry, or an entry file that
|
|
55
55
|
* declares anything of its own). */
|
|
56
56
|
export declare function detectTypesRedirect(pkg: ResolvedPackage): string | null;
|
|
57
|
+
/**
|
|
58
|
+
* Follow the `@types/<name>` + triple-slash `<reference types>` redirect chain from
|
|
59
|
+
* a package that ships no usable types of its own to the one that actually holds
|
|
60
|
+
* the declarations — `bun` → `@types/bun` → `bun-types`. Bounded to three hops;
|
|
61
|
+
* returns the package it started from when no better source is found.
|
|
62
|
+
*
|
|
63
|
+
* This is what the four predicates above EXIST for. They were exported and heavily
|
|
64
|
+
* tested (35 references between them) while the loop that calls them lived in two
|
|
65
|
+
* byte-identical copies — `docs-core.ts` and `phantom-imports.ts` — and NEITHER was
|
|
66
|
+
* covered: both of their tests pin only the zero-hop case, so the multi-hop
|
|
67
|
+
* behaviour cited by name in five doc comments was asserted nowhere.
|
|
68
|
+
*
|
|
69
|
+
* `resolveHop` is the one thing the two call sites genuinely disagree about: the
|
|
70
|
+
* docs pipeline resolves the next hop through an auto-installing async lookup, the
|
|
71
|
+
* phantom-import checker through a bare sync resolve that must never install. It
|
|
72
|
+
* returns null to stop the walk.
|
|
73
|
+
*/
|
|
74
|
+
export declare function resolveTypeSource(start: ResolvedPackage, seed: string, resolveHop: (name: string) => Promise<ResolvedPackage | null>): Promise<ResolvedPackage>;
|
|
@@ -304,3 +304,42 @@ export function detectTypesRedirect(pkg) {
|
|
|
304
304
|
return null;
|
|
305
305
|
return target;
|
|
306
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* Follow the `@types/<name>` + triple-slash `<reference types>` redirect chain from
|
|
309
|
+
* a package that ships no usable types of its own to the one that actually holds
|
|
310
|
+
* the declarations — `bun` → `@types/bun` → `bun-types`. Bounded to three hops;
|
|
311
|
+
* returns the package it started from when no better source is found.
|
|
312
|
+
*
|
|
313
|
+
* This is what the four predicates above EXIST for. They were exported and heavily
|
|
314
|
+
* tested (35 references between them) while the loop that calls them lived in two
|
|
315
|
+
* byte-identical copies — `docs-core.ts` and `phantom-imports.ts` — and NEITHER was
|
|
316
|
+
* covered: both of their tests pin only the zero-hop case, so the multi-hop
|
|
317
|
+
* behaviour cited by name in five doc comments was asserted nowhere.
|
|
318
|
+
*
|
|
319
|
+
* `resolveHop` is the one thing the two call sites genuinely disagree about: the
|
|
320
|
+
* docs pipeline resolves the next hop through an auto-installing async lookup, the
|
|
321
|
+
* phantom-import checker through a bare sync resolve that must never install. It
|
|
322
|
+
* returns null to stop the walk.
|
|
323
|
+
*/
|
|
324
|
+
export async function resolveTypeSource(start, seed, resolveHop) {
|
|
325
|
+
const visited = new Set([start.name, seed]);
|
|
326
|
+
let cur = start;
|
|
327
|
+
for (let hop = 0; hop < 3; hop++) {
|
|
328
|
+
let next = detectTypesRedirect(cur);
|
|
329
|
+
if (next && visited.has(next))
|
|
330
|
+
next = null;
|
|
331
|
+
if (!next && !hasTypeFiles(cur.root)) {
|
|
332
|
+
const types = typesPackageName(cur.name);
|
|
333
|
+
if (types && !visited.has(types))
|
|
334
|
+
next = types;
|
|
335
|
+
}
|
|
336
|
+
if (!next)
|
|
337
|
+
break;
|
|
338
|
+
visited.add(next);
|
|
339
|
+
const resolved = await resolveHop(next);
|
|
340
|
+
if (!resolved)
|
|
341
|
+
break;
|
|
342
|
+
cur = resolved;
|
|
343
|
+
}
|
|
344
|
+
return cur;
|
|
345
|
+
}
|