@mjasnikovs/pi-task 0.38.7 → 0.38.9
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.d.ts +81 -0
- package/dist/task/auto-orchestrator.js +115 -51
- package/dist/task/child-runner.d.ts +15 -2
- package/dist/task/child-runner.js +12 -5
- package/dist/task/phases.d.ts +9 -0
- package/dist/task/phases.js +1 -1
- package/dist/task/run-final-gate.d.ts +16 -1
- package/dist/task/run-final-gate.js +2 -2
- package/package.json +1 -1
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import { type GateDeps } from './task-gates.js';
|
|
3
3
|
import { type FinalGateStageDeps } from './run-final-gate.js';
|
|
4
|
+
import { type RequirementEntry } from './requirements.js';
|
|
5
|
+
import { type ScoredPlan } from './coverage-loop.js';
|
|
6
|
+
import { type DanglingRef } from './artifact-closure.js';
|
|
4
7
|
/**
|
|
5
8
|
* Injectable seams so the planner and loop are testable without spawning pi.
|
|
6
9
|
* `runChild` is the planning-only seam used by planAuto; everything else is one of
|
|
@@ -75,6 +78,84 @@ export declare function attachSpecRefs(titles: string[], refs: string[]): string
|
|
|
75
78
|
* authoritative spec ref still rides on THIS step's own title via attachSpecRefs.
|
|
76
79
|
*/
|
|
77
80
|
export declare function buildScopeFence(titles: string[], currentIndex: number): string;
|
|
81
|
+
/**
|
|
82
|
+
* What ORIENT establishes about the feature before anyone is asked anything: the
|
|
83
|
+
* spec text the planning children will actually read, and the requirement ledger
|
|
84
|
+
* derived from it. Every later stage reads these; none of them writes one.
|
|
85
|
+
*/
|
|
86
|
+
export interface OrientedFeature {
|
|
87
|
+
/** The inlined spec, with phantom runtime specifiers struck out. */
|
|
88
|
+
featureForModel: string;
|
|
89
|
+
/** Manifest/config already on disk, fed to every triage call. '' when absent. */
|
|
90
|
+
existingFilesBlock: string;
|
|
91
|
+
/** Grounded requirement units extracted from the spec. */
|
|
92
|
+
reqEntries: RequirementEntry[];
|
|
93
|
+
/** How many of those a single task could own (cross-cutting ones excluded). */
|
|
94
|
+
ownableRequirements: number;
|
|
95
|
+
/** The task-count floor those ownable requirements imply. 0 ⇒ no channel. */
|
|
96
|
+
coarseFloor: number;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* ORIENT — read the feature, strike what must never reach a planning child, and
|
|
100
|
+
* derive the requirement ledger. Depends on nothing but the feature and the tree,
|
|
101
|
+
* which is why it runs before clarify: the plan-shape fork below needs a real
|
|
102
|
+
* count to judge with. Every fallible part is best-effort; a fault degrades the
|
|
103
|
+
* channel it belongs to and never fails planning.
|
|
104
|
+
*/
|
|
105
|
+
export declare function orientFeature(cwd: string, feature: string, deps: AutoDeps): Promise<OrientedFeature>;
|
|
106
|
+
/**
|
|
107
|
+
* ELICIT — clarify, sequential & adaptive: ask one question at a time, feeding every
|
|
108
|
+
* answer back into the next call so later questions react to earlier ones (e.g. a
|
|
109
|
+
* framework choice reshapes what gets asked). Each question is shown exactly like
|
|
110
|
+
* /task's grill dialog: a binary fork offers two options (A/B), otherwise the model's
|
|
111
|
+
* recommendation is shown as the input placeholder and in the title. Nothing is
|
|
112
|
+
* pre-filled into the editor — submitting an empty field is what accepts the
|
|
113
|
+
* recommendation; typing overrides it. Each generated question first runs the
|
|
114
|
+
* answer-side TRIAGE (triageClarifyQuestion): a question the inlined spec already
|
|
115
|
+
* settles is auto-resolved and never shown — only genuine open forks reach the user.
|
|
116
|
+
* The model emits NONE when nothing remains.
|
|
117
|
+
*
|
|
118
|
+
* The ONLY stage that talks to the user, and so the only one that can be dismissed:
|
|
119
|
+
* `null` means the user cancelled and the cancellation has already been announced.
|
|
120
|
+
* Every other outcome is a transcript, possibly empty.
|
|
121
|
+
*/
|
|
122
|
+
export declare function elicitClarifications(ctx: ExtensionCommandContext, cwd: string, deps: AutoDeps, oriented: OrientedFeature): Promise<string | null>;
|
|
123
|
+
/**
|
|
124
|
+
* What DECOMPOSE settles: the task list, plus the two things the coverage loop
|
|
125
|
+
* needs to ask for a better one. `decomposePrompt` and `parsePlan` are returned
|
|
126
|
+
* rather than rebuilt because COVER re-prompts with the identical prompt and must
|
|
127
|
+
* reconcile the reply identically — rebuilding either is how the two paths drift.
|
|
128
|
+
*/
|
|
129
|
+
export interface DecomposedPlan {
|
|
130
|
+
/** The reconciled, de-batched task titles. May be empty. */
|
|
131
|
+
planTitles: string[];
|
|
132
|
+
/** The exact prompt that produced them, for COVER's re-prompt. */
|
|
133
|
+
decomposePrompt: string;
|
|
134
|
+
/** Parse + fidelity-reconcile + de-batch, applied to EVERY decompose output. */
|
|
135
|
+
parsePlan: (raw: string) => string[];
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* DECOMPOSE — turn the settled feature into a task list, then defend that list's
|
|
139
|
+
* SHAPE: a plan under the granularity floor is sent back once to be split, and a
|
|
140
|
+
* suspect (empty or tiny) plan is regenerated on its own separate budget. Neither
|
|
141
|
+
* guard can block planning — a plan that survives both falls through to the judge.
|
|
142
|
+
*/
|
|
143
|
+
export declare function decomposePlan(cwd: string, deps: AutoDeps, oriented: OrientedFeature, clarifications: string): Promise<DecomposedPlan>;
|
|
144
|
+
/** What COVER settles: the plan that ships, and the accounting behind it. */
|
|
145
|
+
export interface CoveredPlan {
|
|
146
|
+
/** The best-covered plan seen across the rounds — adoption is monotone. */
|
|
147
|
+
best: ScoredPlan;
|
|
148
|
+
/** That plan's titles, which is what everything downstream persists. */
|
|
149
|
+
planTitles: string[];
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* COVER — judge the plan against the feature and re-prompt for a better one, up to
|
|
153
|
+
* a bounded number of rounds. Adoption is MONOTONE: a retry that drops a
|
|
154
|
+
* requirement the current plan owns is rejected, so coverage can only hold or grow,
|
|
155
|
+
* and the plan at exhaustion is the best one seen rather than the last one drawn.
|
|
156
|
+
* Best-effort throughout — a fault degrades a signal, it never blocks planning.
|
|
157
|
+
*/
|
|
158
|
+
export declare function coverPlan(ctx: ExtensionCommandContext, cwd: string, deps: AutoDeps, oriented: OrientedFeature, clarifications: string, decomposed: DecomposedPlan, specDangling: DanglingRef[]): Promise<CoveredPlan>;
|
|
78
159
|
/** Plan phase: clarify → decompose → write AUTO file. Returns the new id, or null. */
|
|
79
160
|
export declare function planAuto(ctx: ExtensionCommandContext, cwd: string, feature: string, deps: AutoDeps): Promise<string | null>;
|
|
80
161
|
export declare function requestAutoCancel(): void;
|
|
@@ -25,7 +25,7 @@ import { requestCancel, resetCancel, isCancelRequested, cancelCheckpoint } from
|
|
|
25
25
|
import { armCancelListener, disarmCancelListener } from './cancel-input.js';
|
|
26
26
|
import { beginRun, endRun } from './mid-run-input.js';
|
|
27
27
|
import { reportDroppedInput } from './dropped-input.js';
|
|
28
|
-
import { refineExistingFilesBlock } from './phases.js';
|
|
28
|
+
import { refineExistingFilesBlock, SINGLE_READ_EXTENSION_PATH } from './phases.js';
|
|
29
29
|
import { SessionUI, registerBridgeCommand, publishLifecycleNotice } from '../remote/bridge.js';
|
|
30
30
|
import { pushNotify } from '../remote/push.js';
|
|
31
31
|
import { startAutoLoader } from './widget.js';
|
|
@@ -409,21 +409,14 @@ async function schedulePendingRepairs(cwd, id, afterIndex, ctx, deps) {
|
|
|
409
409
|
// the plan is best-effort here; the underlying debt is already recorded
|
|
410
410
|
}
|
|
411
411
|
}
|
|
412
|
-
/**
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
// field is what accepts the recommendation (see the typed.length === 0 branch
|
|
421
|
-
// below); typing overrides it. Each generated question first runs the
|
|
422
|
-
// answer-side TRIAGE (triageClarifyQuestion): a question the inlined spec
|
|
423
|
-
// already settles is auto-resolved and never shown — only genuine open forks
|
|
424
|
-
// reach the user. The model emits NONE when nothing remains.
|
|
425
|
-
const theme = ctx.ui.theme;
|
|
426
|
-
const ui = new SessionUI(ctx);
|
|
412
|
+
/**
|
|
413
|
+
* ORIENT — read the feature, strike what must never reach a planning child, and
|
|
414
|
+
* derive the requirement ledger. Depends on nothing but the feature and the tree,
|
|
415
|
+
* which is why it runs before clarify: the plan-shape fork below needs a real
|
|
416
|
+
* count to judge with. Every fallible part is best-effort; a fault degrades the
|
|
417
|
+
* channel it belongs to and never fails planning.
|
|
418
|
+
*/
|
|
419
|
+
export async function orientFeature(cwd, feature, deps) {
|
|
427
420
|
// Inline any @file spec the user referenced so clarify/decompose reason over
|
|
428
421
|
// the real content, not a one-line "Implement @file" that reads as trivial.
|
|
429
422
|
const rawFeatureForModel = await expandFeatureMentions(cwd, feature);
|
|
@@ -456,12 +449,8 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
456
449
|
// into the decompose prompt as a ledger (structure-mirroring can't discharge
|
|
457
450
|
// them) and drive the per-requirement coverage accounting below.
|
|
458
451
|
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
// host must not seize the granularity fork on a spec that has no breakdown to
|
|
462
|
-
// speak of. Best-effort:
|
|
463
|
-
// a fault leaves reqEntries empty and the whole channel degrades to the old
|
|
464
|
-
// behavior (one-liners / doc-less features naturally yield few or none).
|
|
452
|
+
// Best-effort: a fault leaves reqEntries empty and the whole channel degrades to
|
|
453
|
+
// the old behavior (one-liners / doc-less features naturally yield few or none).
|
|
465
454
|
let reqEntries = [];
|
|
466
455
|
try {
|
|
467
456
|
// Recall floor: the obligation-marked passages ride into the prompt as a
|
|
@@ -499,6 +488,28 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
499
488
|
logPlanDebug(cwd, `granularity floor: ${ownableRequirements} ownable requirement(s) ⇒ at least `
|
|
500
489
|
+ `${coarseFloor} task(s)`);
|
|
501
490
|
}
|
|
491
|
+
return { featureForModel, existingFilesBlock, reqEntries, ownableRequirements, coarseFloor };
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* ELICIT — clarify, sequential & adaptive: ask one question at a time, feeding every
|
|
495
|
+
* answer back into the next call so later questions react to earlier ones (e.g. a
|
|
496
|
+
* framework choice reshapes what gets asked). Each question is shown exactly like
|
|
497
|
+
* /task's grill dialog: a binary fork offers two options (A/B), otherwise the model's
|
|
498
|
+
* recommendation is shown as the input placeholder and in the title. Nothing is
|
|
499
|
+
* pre-filled into the editor — submitting an empty field is what accepts the
|
|
500
|
+
* recommendation; typing overrides it. Each generated question first runs the
|
|
501
|
+
* answer-side TRIAGE (triageClarifyQuestion): a question the inlined spec already
|
|
502
|
+
* settles is auto-resolved and never shown — only genuine open forks reach the user.
|
|
503
|
+
* The model emits NONE when nothing remains.
|
|
504
|
+
*
|
|
505
|
+
* The ONLY stage that talks to the user, and so the only one that can be dismissed:
|
|
506
|
+
* `null` means the user cancelled and the cancellation has already been announced.
|
|
507
|
+
* Every other outcome is a transcript, possibly empty.
|
|
508
|
+
*/
|
|
509
|
+
export async function elicitClarifications(ctx, cwd, deps, oriented) {
|
|
510
|
+
const { featureForModel, existingFilesBlock, ownableRequirements } = oriented;
|
|
511
|
+
const theme = ctx.ui.theme;
|
|
512
|
+
const ui = new SessionUI(ctx);
|
|
502
513
|
const answers = [];
|
|
503
514
|
// Plain text of every question already shown, for the duplicate backstop.
|
|
504
515
|
const askedQuestions = [];
|
|
@@ -615,26 +626,16 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
615
626
|
if (answers.length === 0) {
|
|
616
627
|
ctx.ui.notify('No clarifying questions needed — planning tasks…', 'info');
|
|
617
628
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
try {
|
|
629
|
-
specDangling = findSpecDanglingArtifacts(featureForModel, rel => existsSync(path.join(cwd, rel)));
|
|
630
|
-
if (specDangling.length > 0) {
|
|
631
|
-
logPlanDebug(cwd, `artifact closure: ${specDangling.length} dangling runtime artifact(s) in the `
|
|
632
|
-
+ `spec: ${specDangling.map(d => d.path).join(', ')}`);
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
catch {
|
|
636
|
-
// best-effort channel
|
|
637
|
-
}
|
|
629
|
+
return answers.join('\n');
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* DECOMPOSE — turn the settled feature into a task list, then defend that list's
|
|
633
|
+
* SHAPE: a plan under the granularity floor is sent back once to be split, and a
|
|
634
|
+
* suspect (empty or tiny) plan is regenerated on its own separate budget. Neither
|
|
635
|
+
* guard can block planning — a plan that survives both falls through to the judge.
|
|
636
|
+
*/
|
|
637
|
+
export async function decomposePlan(cwd, deps, oriented, clarifications) {
|
|
638
|
+
const { featureForModel, reqEntries, ownableRequirements, coarseFloor } = oriented;
|
|
638
639
|
// Tests-in-the-same-change cadence (mx5 run 14, PROMPT item 6): when the
|
|
639
640
|
// decisions mandate it, a whole-project batch test task contradicts them —
|
|
640
641
|
// run 14 shipped one anyway (TASK_0037, 4.7h, yolo-accepted FAIL) because
|
|
@@ -729,6 +730,19 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
729
730
|
if (retryTitles.length > planTitles.length)
|
|
730
731
|
planTitles = retryTitles;
|
|
731
732
|
}
|
|
733
|
+
return { planTitles, decomposePrompt, parsePlan };
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* COVER — judge the plan against the feature and re-prompt for a better one, up to
|
|
737
|
+
* a bounded number of rounds. Adoption is MONOTONE: a retry that drops a
|
|
738
|
+
* requirement the current plan owns is rejected, so coverage can only hold or grow,
|
|
739
|
+
* and the plan at exhaustion is the best one seen rather than the last one drawn.
|
|
740
|
+
* Best-effort throughout — a fault degrades a signal, it never blocks planning.
|
|
741
|
+
*/
|
|
742
|
+
export async function coverPlan(ctx, cwd, deps, oriented, clarifications, decomposed, specDangling) {
|
|
743
|
+
const { featureForModel, reqEntries } = oriented;
|
|
744
|
+
const { decomposePrompt, parsePlan } = decomposed;
|
|
745
|
+
let planTitles = decomposed.planTitles;
|
|
732
746
|
// Coverage gate: a stochastic degenerate completion (live mx5: ONE task +
|
|
733
747
|
// natural EOS for an 18KB design doc) is nonempty, so the length guard below
|
|
734
748
|
// never fires and the whole run "completes" after one task. Judge the list
|
|
@@ -905,6 +919,44 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
905
919
|
+ 'task. To give it one, stop now and add it to the plan in .pi-tasks/; otherwise '
|
|
906
920
|
+ 'it proceeds.', 'warning');
|
|
907
921
|
}
|
|
922
|
+
return { best, planTitles };
|
|
923
|
+
}
|
|
924
|
+
/** Plan phase: clarify → decompose → write AUTO file. Returns the new id, or null. */
|
|
925
|
+
export async function planAuto(ctx, cwd, feature, deps) {
|
|
926
|
+
// ORIENT. Reads the feature and the tree, asks nobody anything.
|
|
927
|
+
const oriented = await orientFeature(cwd, feature, deps);
|
|
928
|
+
// The floor and the ownable count are DECOMPOSE's to enforce; what the tail of
|
|
929
|
+
// this function still reads is the spec text and the requirement ledger.
|
|
930
|
+
const { featureForModel, reqEntries } = oriented;
|
|
931
|
+
// ELICIT. The only stage that talks to the user.
|
|
932
|
+
const clarifications = await elicitClarifications(ctx, cwd, deps, oriented);
|
|
933
|
+
if (clarifications === null)
|
|
934
|
+
return null; // dismissed; already announced
|
|
935
|
+
// Artifact-production closure, plan side (mx5 run 13, PROMPT 2): runtime
|
|
936
|
+
// files the spec REFERENCES (server snippets, prose "serve the built
|
|
937
|
+
// index.html") that neither its file tree, its parsed build outputs, nor the
|
|
938
|
+
// existing scaffold produce. Sentence-grounded coverage credited the SERVING
|
|
939
|
+
// side and reported "0 unowned" while nothing ever CREATED the file — so
|
|
940
|
+
// these ride the coverage loop's `missing` list as unowned areas until some
|
|
941
|
+
// task title claims the artifact (grounded in titles, which the coverage-map
|
|
942
|
+
// model cannot fake — the run-12 lesson). Deterministic and best-effort.
|
|
943
|
+
let specDangling = [];
|
|
944
|
+
try {
|
|
945
|
+
specDangling = findSpecDanglingArtifacts(featureForModel, rel => existsSync(path.join(cwd, rel)));
|
|
946
|
+
if (specDangling.length > 0) {
|
|
947
|
+
logPlanDebug(cwd, `artifact closure: ${specDangling.length} dangling runtime artifact(s) in the `
|
|
948
|
+
+ `spec: ${specDangling.map(d => d.path).join(', ')}`);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
catch {
|
|
952
|
+
// best-effort channel
|
|
953
|
+
}
|
|
954
|
+
// DECOMPOSE. Produces the task list and the means to ask for a better one.
|
|
955
|
+
const decomposedPlan = await decomposePlan(cwd, deps, oriented, clarifications);
|
|
956
|
+
// COVER. Judges the plan and re-prompts for a better one, monotonically.
|
|
957
|
+
const covered = await coverPlan(ctx, cwd, deps, oriented, clarifications, decomposedPlan, specDangling);
|
|
958
|
+
const best = covered.best;
|
|
959
|
+
const planTitles = covered.planTitles;
|
|
908
960
|
// Carry what no single task owns (goal A(b)/(c)): cross-cutting requirements
|
|
909
961
|
// become `.pi-tasks/requirements.md`, injected VERBATIM into every task's
|
|
910
962
|
// refine/compose (run 11: §10's test-first cadence had no carrier — the "spec
|
|
@@ -942,6 +994,20 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
942
994
|
ctx.ui.notify(`/task-auto: carrying ${parts.join(', ')} requirement(s) into every task`
|
|
943
995
|
+ ' — see .pi-tasks/requirements.md.', 'info');
|
|
944
996
|
}
|
|
997
|
+
// Thread the feature's spec doc(s) into every title so each per-task
|
|
998
|
+
// pipeline — which only ever sees its title — reads the real spec instead of
|
|
999
|
+
// a lossy one-line paraphrase of it.
|
|
1000
|
+
const refs = await readableMentions(cwd, feature);
|
|
1001
|
+
const titles = attachSpecRefs(planTitles, refs);
|
|
1002
|
+
if (titles.length === 0) {
|
|
1003
|
+
announceDone(ctx, '/task-auto: no tasks produced from the feature.', 'warning');
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
// The two grounded extractions below run AFTER the empty-plan guard on purpose.
|
|
1007
|
+
// They each spawn a child and each APPEND to a run-level artifact; on the
|
|
1008
|
+
// empty-plan path the plan is discarded one line later, so running them first
|
|
1009
|
+
// burned two model calls and left contracts.md / launch-contract.md carrying
|
|
1010
|
+
// facts for a run that never produced a task.
|
|
945
1011
|
// Cross-slice contract registry (mx5 run 8, F3): now that the plan is settled,
|
|
946
1012
|
// extract the interface facts MORE THAN ONE slice must agree on — endpoint paths,
|
|
947
1013
|
// exported signatures, file layouts, env var names the DESIGN pins — into a
|
|
@@ -983,15 +1049,6 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
983
1049
|
ground: emitted => keepGroundedScripts(emitted, featureForModel),
|
|
984
1050
|
append: appendDeclaredScripts
|
|
985
1051
|
});
|
|
986
|
-
// Thread the feature's spec doc(s) into every title so each per-task
|
|
987
|
-
// pipeline — which only ever sees its title — reads the real spec instead of
|
|
988
|
-
// a lossy one-line paraphrase of it.
|
|
989
|
-
const refs = await readableMentions(cwd, feature);
|
|
990
|
-
const titles = attachSpecRefs(planTitles, refs);
|
|
991
|
-
if (titles.length === 0) {
|
|
992
|
-
announceDone(ctx, '/task-auto: no tasks produced from the feature.', 'warning');
|
|
993
|
-
return null;
|
|
994
|
-
}
|
|
995
1052
|
// Persist the TASK-MAPPED requirements keyed by the (spec-ref-attached) title
|
|
996
1053
|
// each task will carry (mx5 run 16: only cross-cutting entries travelled;
|
|
997
1054
|
// the 33 mapped ones shaped the title list and vanished — TASK_0008 narrowed
|
|
@@ -1079,6 +1136,13 @@ function defaultDeps(ctx, cwd, signal, title) {
|
|
|
1079
1136
|
cwd,
|
|
1080
1137
|
taskId: '',
|
|
1081
1138
|
signal,
|
|
1139
|
+
// IN-RUN thrash guard for the planning children (mx5-n 2026-08-14: a
|
|
1140
|
+
// decompose child re-read DESIGN/marketplace.html until it filled a
|
|
1141
|
+
// 120k window, and ran 16m23s without returning). Every planning child
|
|
1142
|
+
// gets its source doc INLINED in its prompt, so a second read of a file
|
|
1143
|
+
// it has already opened can only be thrash — which makes the read-once
|
|
1144
|
+
// block safe here in a way it is not for a phase that must explore.
|
|
1145
|
+
childExtensions: [SINGLE_READ_EXTENSION_PATH],
|
|
1082
1146
|
onChildOutput: (line) => {
|
|
1083
1147
|
lastLine = line;
|
|
1084
1148
|
},
|
|
@@ -58,14 +58,16 @@ export interface PhaseRunResult {
|
|
|
58
58
|
/** Set when the child's final turn failed with stopReason "error" (model/provider failure). */
|
|
59
59
|
modelError?: string;
|
|
60
60
|
}
|
|
61
|
-
export declare function childArgs(tools: string): string[];
|
|
61
|
+
export declare function childArgs(tools: string, extensions?: readonly string[]): string[];
|
|
62
62
|
export declare const USER_CANCELLED = "__user_cancelled__";
|
|
63
63
|
/**
|
|
64
64
|
* Run a child pi process with JSON event-stream output, loop detection, and
|
|
65
65
|
* context-usage tracking. This is the typed convenience wrapper used by
|
|
66
66
|
* phase-level code.
|
|
67
67
|
*/
|
|
68
|
-
export declare function runChild(cwd: string, tools: string, prompt: string, signal: AbortSignal, onLine?: (line: string) => void, onContextUsage?: (snapshot: ContextSnapshot) => void, onToolCall?: (call: ToolCall) => LoopHit | null, spawnFn?: SpawnFn
|
|
68
|
+
export declare function runChild(cwd: string, tools: string, prompt: string, signal: AbortSignal, onLine?: (line: string) => void, onContextUsage?: (snapshot: ContextSnapshot) => void, onToolCall?: (call: ToolCall) => LoopHit | null, spawnFn?: SpawnFn,
|
|
69
|
+
/** Internal `-e` extension paths for in-run guards (see childArgs). */
|
|
70
|
+
extensions?: readonly string[]): Promise<PhaseRunResult>;
|
|
69
71
|
interface PhaseDeps {
|
|
70
72
|
cwd: string;
|
|
71
73
|
taskId: string;
|
|
@@ -79,6 +81,17 @@ interface PhaseDeps {
|
|
|
79
81
|
*/
|
|
80
82
|
recordSubStep?: (label: string, ms: number) => void;
|
|
81
83
|
spawn?: SpawnFn;
|
|
84
|
+
/**
|
|
85
|
+
* Internal `-e` extension paths loaded into this child for IN-RUN guards.
|
|
86
|
+
*
|
|
87
|
+
* The point of a guard that runs inside the child is that it does not have
|
|
88
|
+
* to kill it: pi turns a `tool_call` handler's `{block, reason}` into an
|
|
89
|
+
* error tool result, so the model reads the reason as its own tool output
|
|
90
|
+
* and continues with its context intact. The host's only alternative is to
|
|
91
|
+
* kill and re-spawn from nothing, which just re-runs a model that
|
|
92
|
+
* deterministically re-thrashes (workers/single-read-guard.ts).
|
|
93
|
+
*/
|
|
94
|
+
childExtensions?: readonly string[];
|
|
82
95
|
/**
|
|
83
96
|
* Wall-clock budget for ONE spawn of this child, in ms. Defaults to
|
|
84
97
|
* PHASE_CHILD_TIMEOUT_MS; `0` disables the cap. Mirrors runWorker's
|
|
@@ -127,7 +127,7 @@ export function connectionRetryBackoffMs(attempt) {
|
|
|
127
127
|
}
|
|
128
128
|
const defaultSleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
129
129
|
// ─── Spawn helpers ───────────────────────────────────────────────────────────
|
|
130
|
-
export function childArgs(tools) {
|
|
130
|
+
export function childArgs(tools, extensions = []) {
|
|
131
131
|
// `--mode json` puts the child into the structured event stream the
|
|
132
132
|
// unified runner parses in `mode: 'json-events'`. Without it the child
|
|
133
133
|
// emits plain text, every line fails JSON.parse, finalText stays empty,
|
|
@@ -142,8 +142,13 @@ export function childArgs(tools) {
|
|
|
142
142
|
// The prompt is NOT an argv element: it goes to the child over stdin (see
|
|
143
143
|
// runChild below / getPiInvocation), so a large inlined-design prompt can't
|
|
144
144
|
// overflow the OS command-line limit (Windows `spawn ENAMETOOLONG`).
|
|
145
|
+
//
|
|
146
|
+
// `extensions` are internal `-e` loads for in-run guards (the caller supplies
|
|
147
|
+
// the path). A no-tools child cannot make a tool call, so it never carries
|
|
148
|
+
// one — the guards all hang off pi's `tool_call` hook.
|
|
145
149
|
const toolFlags = tools === '' ? ['--no-tools'] : ['--tools', tools];
|
|
146
|
-
|
|
150
|
+
const internal = tools === '' ? [] : extensions;
|
|
151
|
+
return [...childBaseArgs(internal), '--mode', 'json', ...toolFlags];
|
|
147
152
|
}
|
|
148
153
|
// Sentinel error thrown when the user dismisses a grill-me dialog.
|
|
149
154
|
// Defined here (not in failure-classifier.ts) to avoid circular dependency.
|
|
@@ -154,8 +159,10 @@ export const USER_CANCELLED = '__user_cancelled__';
|
|
|
154
159
|
* context-usage tracking. This is the typed convenience wrapper used by
|
|
155
160
|
* phase-level code.
|
|
156
161
|
*/
|
|
157
|
-
export async function runChild(cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawnFn
|
|
158
|
-
|
|
162
|
+
export async function runChild(cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawnFn,
|
|
163
|
+
/** Internal `-e` extension paths for in-run guards (see childArgs). */
|
|
164
|
+
extensions) {
|
|
165
|
+
const invocation = getPiInvocation(childArgs(tools, extensions), prompt);
|
|
159
166
|
let loopHit;
|
|
160
167
|
const result = await runChildUnified(spawnFn ?? spawn, invocation, cwd, signal, {
|
|
161
168
|
mode: 'json-events',
|
|
@@ -289,7 +296,7 @@ export async function runPhaseChild(deps, name, tools, prompt) {
|
|
|
289
296
|
const clock = phaseTimeout(deps.signal, budgetMs);
|
|
290
297
|
let r;
|
|
291
298
|
try {
|
|
292
|
-
r = await runChild(deps.cwd, tools, prependHint(hint, prompt), clock.signal, deps.onChildOutput, deps.onContextUsage, call => detector.record(call), deps.spawn);
|
|
299
|
+
r = await runChild(deps.cwd, tools, prependHint(hint, prompt), clock.signal, deps.onChildOutput, deps.onContextUsage, call => detector.record(call), deps.spawn, deps.childExtensions);
|
|
293
300
|
}
|
|
294
301
|
finally {
|
|
295
302
|
clock.cleanup();
|
package/dist/task/phases.d.ts
CHANGED
|
@@ -111,6 +111,15 @@ export declare function searchConfigured(getEnv?: (k: string) => string | undefi
|
|
|
111
111
|
/** Extra prompt block for the APIS worker when search is available — trigger-framed
|
|
112
112
|
* (the validated shape for getting a local model to actually reach for search). */
|
|
113
113
|
export declare const RESEARCH_SEARCH_HINT: string;
|
|
114
|
+
/**
|
|
115
|
+
* In-process guards loaded into the TOOLING worker only: block a re-read of any
|
|
116
|
+
* file already read, and block any byte-identical grep/find/ls repeat, feeding
|
|
117
|
+
* the model "you already have this, answer now" instead of letting it re-run.
|
|
118
|
+
* TOOLING reads each file once and never needs an identical search twice in any
|
|
119
|
+
* healthy recorded run, so neither rule has a legitimate false positive here.
|
|
120
|
+
* See single-read-guard.ts.
|
|
121
|
+
*/
|
|
122
|
+
export declare const SINGLE_READ_EXTENSION_PATH: string;
|
|
114
123
|
/**
|
|
115
124
|
* The TOOLING worker only needs to know which verification commands the task
|
|
116
125
|
* cares about — never the per-file edit list. Big refined prompts embed a long
|
package/dist/task/phases.js
CHANGED
|
@@ -332,7 +332,7 @@ export const RESEARCH_SEARCH_HINT = '\n\nLIVE WEB — use pi-worker-search for e
|
|
|
332
332
|
* healthy recorded run, so neither rule has a legitimate false positive here.
|
|
333
333
|
* See single-read-guard.ts.
|
|
334
334
|
*/
|
|
335
|
-
const SINGLE_READ_EXTENSION_PATH = fileURLToPath(new URL('../workers/single-read-extension.js', import.meta.url));
|
|
335
|
+
export const SINGLE_READ_EXTENSION_PATH = fileURLToPath(new URL('../workers/single-read-extension.js', import.meta.url));
|
|
336
336
|
/**
|
|
337
337
|
* Task-file heading under which a research worker's validated output is cached.
|
|
338
338
|
* A resumed research phase reads these to skip workers that already succeeded,
|
|
@@ -30,7 +30,8 @@ import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
|
30
30
|
import type { CommitResult } from './auto-commit.js';
|
|
31
31
|
import type { FinalGateOutcome } from './final-gate.js';
|
|
32
32
|
import type { FinalGateFixFn } from './gate-deps.js';
|
|
33
|
-
import { type AcceptDebt } from './accept-debt.js';
|
|
33
|
+
import { type AcceptDebt, type DebtOrigin } from './accept-debt.js';
|
|
34
|
+
import { type OwnedRequirement } from './requirements.js';
|
|
34
35
|
/**
|
|
35
36
|
* The seams this stage drives. A strict subset of what /task-auto builds, and
|
|
36
37
|
* deliberately narrow: every optional dep absent degrades to a documented earlier
|
|
@@ -86,6 +87,20 @@ export interface FinalGateStageDeps {
|
|
|
86
87
|
* file) — the same auditability contract the per-task records carry. Best-effort:
|
|
87
88
|
* absent in tests → skipped; a failure never breaks the gate. */
|
|
88
89
|
record?: (cwd: string, taskId: string, line: string) => Promise<void>;
|
|
90
|
+
/**
|
|
91
|
+
* Write one ACCEPT debt to the ledger. The run-level twin of `GateDeps.recordDebt`,
|
|
92
|
+
* and injectable for the same reason: without it a test that only wants to observe
|
|
93
|
+
* WHICH debts a scenario carries has to write, and then read back, the real ledger
|
|
94
|
+
* on disk. Absent → the real `accept-debt.ts` writer, so production wiring and the
|
|
95
|
+
* prior behaviour are unchanged.
|
|
96
|
+
*/
|
|
97
|
+
recordDebt?: (cwd: string, taskId: string, reason: string, origin: DebtOrigin) => Promise<void>;
|
|
98
|
+
/**
|
|
99
|
+
* Read the owned-requirement ledger, to report obligations a task DETACHED and no
|
|
100
|
+
* later task claimed. Absent → the real `requirements.ts` reader (prior behaviour);
|
|
101
|
+
* injectable so that check is testable without seeding a real ledger file.
|
|
102
|
+
*/
|
|
103
|
+
ownedRequirements?: (cwd: string) => Promise<OwnedRequirement[]>;
|
|
89
104
|
}
|
|
90
105
|
/** Inputs that vary per caller. */
|
|
91
106
|
export interface FinalGateStageParams {
|
|
@@ -73,7 +73,7 @@ export async function runFinalGateStage(active, deps, p) {
|
|
|
73
73
|
* What they no longer each restate is WHERE the debt goes: the run's own id, under
|
|
74
74
|
* origin 'final-gate', which is what the next run's gate re-checks.
|
|
75
75
|
*/
|
|
76
|
-
const carryDebt = (reason) => recordDebt(cwd, id, reason, 'final-gate');
|
|
76
|
+
const carryDebt = (reason) => (deps.recordDebt ?? recordDebt)(cwd, id, reason, 'final-gate');
|
|
77
77
|
// Set when the gate finished having observed NOTHING dynamic. Declared out here so
|
|
78
78
|
// the run-completion announcement can say so.
|
|
79
79
|
let unobservedNote = null;
|
|
@@ -147,7 +147,7 @@ export async function runFinalGateStage(active, deps, p) {
|
|
|
147
147
|
// satisfy it, nexttask 2) and no later task claimed. Detach never deletes the
|
|
148
148
|
// quote, so the run ends holding it — say so, or the resolution would be a quieter
|
|
149
149
|
// version of the deletion it exists to prevent.
|
|
150
|
-
const unclaimed = unclaimedPendingRequirements(await readOwnedRequirements(cwd).catch(() => []));
|
|
150
|
+
const unclaimed = unclaimedPendingRequirements(await (deps.ownedRequirements ?? readOwnedRequirements)(cwd).catch(() => []));
|
|
151
151
|
for (const o of unclaimed) {
|
|
152
152
|
await recGate(`owned requirement UNCLAIMED — "${o.quote.slice(0, 200)}"`
|
|
153
153
|
+ ` [frozen in "${o.title.slice(0, 60)}"; no task claimed`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.9",
|
|
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",
|