@mjasnikovs/pi-task 0.38.18 → 0.38.20
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/README.md +1 -0
- package/dist/config/config.d.ts +19 -0
- package/dist/config/config.js +10 -2
- package/dist/config/reasoning-args.d.ts +10 -0
- package/dist/config/reasoning-args.js +23 -0
- package/dist/config/reasoning.d.ts +160 -0
- package/dist/config/reasoning.js +530 -0
- package/dist/config/register.d.ts +72 -2
- package/dist/config/register.js +182 -33
- package/dist/shared/child-process.d.ts +19 -0
- package/dist/shared/child-process.js +9 -10
- package/dist/shared/model-endpoint.d.ts +34 -0
- package/dist/shared/model-endpoint.js +36 -0
- package/dist/shared/reasoning-capability.d.ts +86 -0
- package/dist/shared/reasoning-capability.js +82 -0
- package/dist/task/child-runner.d.ts +36 -26
- package/dist/task/child-runner.js +63 -6
- package/dist/task/child-status.d.ts +11 -3
- package/dist/task/child-status.js +14 -3
- package/dist/task/context-usage.d.ts +1 -1
- package/dist/task/context-usage.js +1 -1
- package/dist/task/decompose-fidelity.d.ts +21 -8
- package/dist/task/decompose-fidelity.js +98 -17
- package/dist/task/gate-child.d.ts +10 -0
- package/dist/task/gate-child.js +5 -1
- package/dist/task/gate-deps.js +4 -0
- package/dist/task/implementation-thinking.d.ts +54 -0
- package/dist/task/implementation-thinking.js +33 -0
- package/dist/task/orchestrator.d.ts +7 -0
- package/dist/task/orchestrator.js +46 -14
- package/dist/task/phases.js +47 -24
- package/dist/task/prompts.d.ts +0 -23
- package/dist/task/prompts.js +0 -25
- package/dist/task/reasoning-groups.d.ts +36 -0
- package/dist/task/reasoning-groups.js +36 -0
- package/dist/task/spec-validation.d.ts +28 -0
- package/dist/task/spec-validation.js +44 -0
- package/dist/task/stall-detector.d.ts +5 -0
- package/dist/task/stall-detector.js +5 -0
- package/dist/task/title-label.js +2 -2
- package/dist/workers/docs-core.js +4 -0
- package/dist/workers/fetch-core.js +4 -0
- package/dist/workers/focused-extractor.d.ts +12 -1
- package/dist/workers/focused-extractor.js +6 -2
- package/dist/workers/index.js +2 -0
- package/dist/workers/pi-worker-core.d.ts +33 -4
- package/dist/workers/pi-worker-core.js +28 -7
- package/dist/workers/pi-worker-docs.js +4 -0
- package/dist/workers/pi-worker.js +11 -1
- package/dist/workers/reasoning-warning.d.ts +64 -0
- package/dist/workers/reasoning-warning.js +142 -0
- package/package.json +4 -4
|
@@ -67,7 +67,14 @@ export interface PhaseRunResult {
|
|
|
67
67
|
/** Set when the child's final turn failed with stopReason "error" (model/provider failure). */
|
|
68
68
|
modelError?: string;
|
|
69
69
|
}
|
|
70
|
-
export declare function childArgs(tools: string, extensions?: readonly string[]
|
|
70
|
+
export declare function childArgs(tools: string, extensions?: readonly string[],
|
|
71
|
+
/**
|
|
72
|
+
* An already-resolved `['--thinking', level]`, or `[]` for "emit no flag".
|
|
73
|
+
* Resolved by the CALLER, never here: the level is a property of the child's
|
|
74
|
+
* ROLE, and this function is handed tools and extensions, not a name.
|
|
75
|
+
* Omitted ⇒ byte-identical argv to the version before reasoning profiles.
|
|
76
|
+
*/
|
|
77
|
+
thinking?: readonly string[]): string[];
|
|
71
78
|
export declare const USER_CANCELLED = "__user_cancelled__";
|
|
72
79
|
/**
|
|
73
80
|
* Run a child pi process with JSON event-stream output, loop detection, and
|
|
@@ -82,13 +89,40 @@ extensions?: readonly string[],
|
|
|
82
89
|
* needs the size of what actually entered the child's context, which the
|
|
83
90
|
* CALL alone does not carry (task/stall-detector.ts).
|
|
84
91
|
*/
|
|
85
|
-
onToolResult?: (text: string, isError: boolean) => void
|
|
92
|
+
onToolResult?: (text: string, isError: boolean) => void,
|
|
93
|
+
/**
|
|
94
|
+
* The child's context window in tokens. Nothing in pi's event stream reports
|
|
95
|
+
* one (issue #16), so the parent hands its own down — children carry no `-m`
|
|
96
|
+
* and resolve the same default model. 0 / omitted = unknown, as before.
|
|
97
|
+
*/
|
|
98
|
+
contextWindow?: number,
|
|
99
|
+
/**
|
|
100
|
+
* The resolved `['--thinking', level]` fragment for this child's reasoning
|
|
101
|
+
* group, or `[]`/omitted to inherit the session default as before.
|
|
102
|
+
*
|
|
103
|
+
* A 13th trailing positional is not pretty. It is how `contextWindow` (the
|
|
104
|
+
* 12th) was added, and an options-bag refactor of this signature is a
|
|
105
|
+
* separate change from wiring one flag — doing both at once would put a
|
|
106
|
+
* behaviour change inside a mechanical one. DEBT: convert to an options bag.
|
|
107
|
+
*/
|
|
108
|
+
thinking?: readonly string[]): Promise<PhaseRunResult>;
|
|
86
109
|
interface PhaseDeps {
|
|
87
110
|
cwd: string;
|
|
88
111
|
taskId: string;
|
|
89
112
|
signal: AbortSignal;
|
|
90
113
|
onChildOutput?: (line: string) => void;
|
|
91
114
|
onContextUsage?: (snapshot: ContextSnapshot) => void;
|
|
115
|
+
/**
|
|
116
|
+
* The parent session's context window in tokens, handed down to every child.
|
|
117
|
+
*
|
|
118
|
+
* pi's `--mode json` stream reports token counts but no window (issue #16),
|
|
119
|
+
* so without this the gauge shows a bare number and — worse — the
|
|
120
|
+
* StallDetector's CONTEXT CHURN rule, which is gated on a positive window,
|
|
121
|
+
* can never fire. Children are spawned without `-m` (CHILD_BASE_ARGS) and so
|
|
122
|
+
* run the parent's own default model; its window is the honest value.
|
|
123
|
+
* Absent = unknown, and both consumers degrade exactly as they did before.
|
|
124
|
+
*/
|
|
125
|
+
contextWindow?: number;
|
|
92
126
|
/**
|
|
93
127
|
* Record a sub-step duration under the currently running top-level phase.
|
|
94
128
|
* The orchestrator rebinds this between phases so each call lands in the
|
|
@@ -178,30 +212,6 @@ interface PhaseDeps {
|
|
|
178
212
|
searchFn?: (input: SearchCoreInput) => Promise<SearchCoreResult>;
|
|
179
213
|
}
|
|
180
214
|
export type { PhaseDeps };
|
|
181
|
-
/**
|
|
182
|
-
* Run a child pi and return its assistant text. Throws if exit code != 0.
|
|
183
|
-
*
|
|
184
|
-
* If the child leaks a tool call as plain text (wrong dialect — never executed),
|
|
185
|
-
* re-prompt with a correction hint up to MAX_LEAK_RETRIES times; if it keeps
|
|
186
|
-
* leaking, throw LeakedToolCallError rather than returning the unexecuted call.
|
|
187
|
-
* Empty completions and connection-class model errors share that same budget —
|
|
188
|
-
* see triageChildResult, which decides every one of those cases.
|
|
189
|
-
*
|
|
190
|
-
* THREE RUNAWAY GUARDS ride the same budget, because this is the runner every
|
|
191
|
-
* /task-auto planning child goes through (clarify, decompose, coverage,
|
|
192
|
-
* contract-extract) and until mx5-n 2026-08-14 it had none:
|
|
193
|
-
* • a LoopDetector, so an identical repeated tool call is killed and
|
|
194
|
-
* re-prompted instead of being allowed to fill the context window;
|
|
195
|
-
* • a StallDetector, the backstop for the varied-args thrash the loop
|
|
196
|
-
* detector's short window cannot see — the shape that actually cost us a
|
|
197
|
-
* 16-minute decompose child that was never going to return. It bounds
|
|
198
|
-
* consecutive no-new-ground calls and total context churn, NOT elapsed time;
|
|
199
|
-
* • PHASE_CHILD_TIMEOUT_MS, a hard wall clock, OFF by default because the
|
|
200
|
-
* measured healthy range (610-927s for a reasoning-on decompose) overlaps
|
|
201
|
-
* any value that would catch the pathology. See its comment.
|
|
202
|
-
* All three are checked BEFORE the triage ladder: we killed the child, so its
|
|
203
|
-
* exit status describes our SIGTERM and says nothing about its verdict.
|
|
204
|
-
*/
|
|
205
215
|
export declare function runPhaseChild(deps: PhaseDeps, name: string, tools: string, prompt: string, opts?: PhaseChildOptions): Promise<string>;
|
|
206
216
|
export declare function formatLoopHint(hit: LoopHit): string;
|
|
207
217
|
/**
|
|
@@ -15,6 +15,8 @@ import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../s
|
|
|
15
15
|
import { readSection, setTaskSection } from './task-io.js';
|
|
16
16
|
import { streamStallCause } from '../shared/stream-watchdog.js';
|
|
17
17
|
import { getConfig } from '../config/config.js';
|
|
18
|
+
import { groupThinkingArgs } from '../config/reasoning-args.js';
|
|
19
|
+
import { reasoningGroupForChild } from './reasoning-groups.js';
|
|
18
20
|
// ─── Loop detection constants ────────────────────────────────────────────────
|
|
19
21
|
// Defined here (not in phases.ts) to avoid a circular dependency:
|
|
20
22
|
// phases.ts → child-runner.ts → phases.ts
|
|
@@ -132,7 +134,14 @@ export function connectionRetryBackoffMs(attempt) {
|
|
|
132
134
|
}
|
|
133
135
|
const defaultSleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
134
136
|
// ─── Spawn helpers ───────────────────────────────────────────────────────────
|
|
135
|
-
export function childArgs(tools, extensions = []
|
|
137
|
+
export function childArgs(tools, extensions = [],
|
|
138
|
+
/**
|
|
139
|
+
* An already-resolved `['--thinking', level]`, or `[]` for "emit no flag".
|
|
140
|
+
* Resolved by the CALLER, never here: the level is a property of the child's
|
|
141
|
+
* ROLE, and this function is handed tools and extensions, not a name.
|
|
142
|
+
* Omitted ⇒ byte-identical argv to the version before reasoning profiles.
|
|
143
|
+
*/
|
|
144
|
+
thinking = []) {
|
|
136
145
|
// `--mode json` puts the child into the structured event stream the
|
|
137
146
|
// unified runner parses in `mode: 'json-events'`. Without it the child
|
|
138
147
|
// emits plain text, every line fails JSON.parse, finalText stays empty,
|
|
@@ -153,7 +162,7 @@ export function childArgs(tools, extensions = []) {
|
|
|
153
162
|
// one — the guards all hang off pi's `tool_call` hook.
|
|
154
163
|
const toolFlags = tools === '' ? ['--no-tools'] : ['--tools', tools];
|
|
155
164
|
const internal = tools === '' ? [] : extensions;
|
|
156
|
-
return [...childBaseArgs(internal), '--mode', 'json', ...toolFlags];
|
|
165
|
+
return [...childBaseArgs(internal), ...thinking, '--mode', 'json', ...toolFlags];
|
|
157
166
|
}
|
|
158
167
|
// Sentinel error thrown when the user dismisses a grill-me dialog.
|
|
159
168
|
// Defined here (not in failure-classifier.ts) to avoid circular dependency.
|
|
@@ -172,8 +181,24 @@ extensions,
|
|
|
172
181
|
* needs the size of what actually entered the child's context, which the
|
|
173
182
|
* CALL alone does not carry (task/stall-detector.ts).
|
|
174
183
|
*/
|
|
175
|
-
onToolResult
|
|
176
|
-
|
|
184
|
+
onToolResult,
|
|
185
|
+
/**
|
|
186
|
+
* The child's context window in tokens. Nothing in pi's event stream reports
|
|
187
|
+
* one (issue #16), so the parent hands its own down — children carry no `-m`
|
|
188
|
+
* and resolve the same default model. 0 / omitted = unknown, as before.
|
|
189
|
+
*/
|
|
190
|
+
contextWindow,
|
|
191
|
+
/**
|
|
192
|
+
* The resolved `['--thinking', level]` fragment for this child's reasoning
|
|
193
|
+
* group, or `[]`/omitted to inherit the session default as before.
|
|
194
|
+
*
|
|
195
|
+
* A 13th trailing positional is not pretty. It is how `contextWindow` (the
|
|
196
|
+
* 12th) was added, and an options-bag refactor of this signature is a
|
|
197
|
+
* separate change from wiring one flag — doing both at once would put a
|
|
198
|
+
* behaviour change inside a mechanical one. DEBT: convert to an options bag.
|
|
199
|
+
*/
|
|
200
|
+
thinking) {
|
|
201
|
+
const invocation = getPiInvocation(childArgs(tools, extensions, thinking), prompt);
|
|
177
202
|
let loopHit;
|
|
178
203
|
const result = await runChildUnified(spawnFn ?? spawn, invocation, cwd, signal, {
|
|
179
204
|
mode: 'json-events',
|
|
@@ -184,6 +209,7 @@ onToolResult) {
|
|
|
184
209
|
streamInactivityMs: getConfig().streamInactivityMs,
|
|
185
210
|
onLine,
|
|
186
211
|
onContextUsage,
|
|
212
|
+
...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
|
|
187
213
|
onToolResult: onToolResult ? r => onToolResult(r.text, r.isError) : undefined,
|
|
188
214
|
onToolCall: call => {
|
|
189
215
|
if (!onToolCall)
|
|
@@ -303,9 +329,28 @@ async function triageChildResult(deps, name, r, attempt, budget, verb) {
|
|
|
303
329
|
* All three are checked BEFORE the triage ladder: we killed the child, so its
|
|
304
330
|
* exit status describes our SIGTERM and says nothing about its verdict.
|
|
305
331
|
*/
|
|
332
|
+
/**
|
|
333
|
+
* The `--thinking` fragment for a named child, or `[]` when the name is unmapped.
|
|
334
|
+
*
|
|
335
|
+
* An unmapped name INHERITS rather than throwing: a child that reaches the model
|
|
336
|
+
* with today's argv is always safe, and aborting a user's task over a missing
|
|
337
|
+
* table row would be a worse failure than the one it reports. The guard that
|
|
338
|
+
* makes the table complete is `reasoning-groups.test.ts`, which fails the BUILD —
|
|
339
|
+
* where someone can actually fix it.
|
|
340
|
+
*/
|
|
341
|
+
function thinkingForChild(name) {
|
|
342
|
+
const group = reasoningGroupForChild(name);
|
|
343
|
+
return group ? groupThinkingArgs(group) : [];
|
|
344
|
+
}
|
|
306
345
|
export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
|
|
307
346
|
if (deps.runChild)
|
|
308
347
|
return await deps.runChild(name, tools, prompt);
|
|
348
|
+
// Resolved ONCE per call, not per attempt: a /task-config change landing
|
|
349
|
+
// between a loop-kill and its retry would otherwise make the two attempts
|
|
350
|
+
// different experiments, and the retry exists to repeat the first one with a
|
|
351
|
+
// hint. An unmapped name inherits, which is today's argv — the build-time
|
|
352
|
+
// guard for that is reasoning-groups.test.ts, not a throw in a user's run.
|
|
353
|
+
const thinking = thinkingForChild(name);
|
|
309
354
|
const verb = opts.verb ?? 'retry';
|
|
310
355
|
let hint = null;
|
|
311
356
|
const loopHistory = [];
|
|
@@ -316,13 +361,21 @@ export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
|
|
|
316
361
|
throw new Error(USER_CANCELLED);
|
|
317
362
|
const detector = new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD);
|
|
318
363
|
const stall = new StallDetector();
|
|
364
|
+
// Arm the churn rule BEFORE the first tool call. The window used to reach
|
|
365
|
+
// the detector only through a context snapshot, and pi's stream never
|
|
366
|
+
// carries one, so it was always 0 and rule 2 never fired (issue #16). The
|
|
367
|
+
// parent knows the value at spawn time — say it then, not later.
|
|
368
|
+
stall.noteContext(deps.contextWindow ?? 0);
|
|
319
369
|
const clock = phaseTimeout(deps.signal, budgetMs);
|
|
320
370
|
let r;
|
|
321
371
|
try {
|
|
322
372
|
r = await runChild(deps.cwd, tools, prependHint(hint, prompt), clock.signal, deps.onChildOutput, snapshot => {
|
|
373
|
+
// Real window or nothing: noteContext ignores 0, and until
|
|
374
|
+
// deps.contextWindow existed 0 was all it ever saw, which
|
|
375
|
+
// left the churn rule permanently disarmed (issue #16).
|
|
323
376
|
stall.noteContext(snapshot.contextWindow);
|
|
324
377
|
deps.onContextUsage?.(snapshot);
|
|
325
|
-
}, call => detector.record(call) ?? stall.record(call), deps.spawn, deps.childExtensions, (text, isError) => stall.noteResult(text, isError));
|
|
378
|
+
}, call => detector.record(call) ?? stall.record(call), deps.spawn, deps.childExtensions, (text, isError) => stall.noteResult(text, isError), deps.contextWindow, thinking);
|
|
326
379
|
}
|
|
327
380
|
finally {
|
|
328
381
|
clock.cleanup();
|
|
@@ -430,7 +483,11 @@ async function appendLoopEvent(cwd, taskId, phase, hit, strike, outcome) {
|
|
|
430
483
|
async function runDegradedFinalAttempt(deps, name, prompt, hit, loopHistory) {
|
|
431
484
|
deps.logDebug?.(`${name}: loop budget exhausted — degrading to a no-tools final attempt`);
|
|
432
485
|
const r = await runChild(deps.cwd, '', // --no-tools: the model cannot read/grep/list, only answer
|
|
433
|
-
prependHint(formatDegradeHint(hit), prompt), deps.signal, deps.onChildOutput, deps.onContextUsage, undefined, deps.spawn
|
|
486
|
+
prependHint(formatDegradeHint(hit), prompt), deps.signal, deps.onChildOutput, deps.onContextUsage, undefined, deps.spawn, undefined, undefined, deps.contextWindow,
|
|
487
|
+
// Same group as the attempts that led here. The degrade changes the
|
|
488
|
+
// TOOLS, not the role — running it at a different thinking level would
|
|
489
|
+
// make the fallback a different experiment from the thing it rescues.
|
|
490
|
+
thinkingForChild(name));
|
|
434
491
|
if (r.exitCode !== 0 || r.modelError || r.text.trim().length === 0) {
|
|
435
492
|
throw new LoopExhaustedError(name, loopHistory);
|
|
436
493
|
}
|
|
@@ -34,10 +34,15 @@ export declare class ChildStatus {
|
|
|
34
34
|
private readonly _parentContextWindow;
|
|
35
35
|
private readonly _startLoader;
|
|
36
36
|
constructor(deps: ChildStatusDeps);
|
|
37
|
+
/**
|
|
38
|
+
* The parent session's window — the value handed DOWN to each child so its
|
|
39
|
+
* own readout carries one, and the last fallback when a child reports none.
|
|
40
|
+
*/
|
|
41
|
+
get parentContextWindow(): number;
|
|
37
42
|
/** The child's latest stream line. Bind as `onChildOutput` / `onLine`. */
|
|
38
43
|
onLine(line: string): void;
|
|
39
44
|
/**
|
|
40
|
-
* Fold a raw
|
|
45
|
+
* Fold a raw context snapshot into the gauge: the child's own window,
|
|
41
46
|
* else the last known one, else the parent's (`resolveContextUsage`).
|
|
42
47
|
*/
|
|
43
48
|
onContextUsage(snapshot: ContextSnapshot): void;
|
|
@@ -91,5 +96,8 @@ export declare function runPlanningChild(opts: {
|
|
|
91
96
|
prompt: string;
|
|
92
97
|
loader: PlanningChildLoader;
|
|
93
98
|
}): Promise<string>;
|
|
94
|
-
/**
|
|
95
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Wire a `ChildStatus` as a phase child's stream callbacks — plus the window the
|
|
101
|
+
* child must be TOLD, since pi's event stream never reports one (issue #16).
|
|
102
|
+
*/
|
|
103
|
+
export declare function statusCallbacks(status: ChildStatus): Pick<PhaseDeps, 'onChildOutput' | 'onContextUsage' | 'contextWindow'>;
|
|
@@ -30,12 +30,19 @@ export class ChildStatus {
|
|
|
30
30
|
this._parentContextWindow = deps.parentContextWindow;
|
|
31
31
|
this._startLoader = deps.startLoader ?? startAutoLoader;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* The parent session's window — the value handed DOWN to each child so its
|
|
35
|
+
* own readout carries one, and the last fallback when a child reports none.
|
|
36
|
+
*/
|
|
37
|
+
get parentContextWindow() {
|
|
38
|
+
return this._parentContextWindow;
|
|
39
|
+
}
|
|
33
40
|
/** The child's latest stream line. Bind as `onChildOutput` / `onLine`. */
|
|
34
41
|
onLine(line) {
|
|
35
42
|
this._lastLine = line;
|
|
36
43
|
}
|
|
37
44
|
/**
|
|
38
|
-
* Fold a raw
|
|
45
|
+
* Fold a raw context snapshot into the gauge: the child's own window,
|
|
39
46
|
* else the last known one, else the parent's (`resolveContextUsage`).
|
|
40
47
|
*/
|
|
41
48
|
onContextUsage(snapshot) {
|
|
@@ -90,10 +97,14 @@ export async function runPlanningChild(opts) {
|
|
|
90
97
|
startedAt
|
|
91
98
|
}), () => runPhaseChild(phaseDeps, name, tools, prompt));
|
|
92
99
|
}
|
|
93
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Wire a `ChildStatus` as a phase child's stream callbacks — plus the window the
|
|
102
|
+
* child must be TOLD, since pi's event stream never reports one (issue #16).
|
|
103
|
+
*/
|
|
94
104
|
export function statusCallbacks(status) {
|
|
95
105
|
return {
|
|
96
106
|
onChildOutput: line => status.onLine(line),
|
|
97
|
-
onContextUsage: snapshot => status.onContextUsage(snapshot)
|
|
107
|
+
onContextUsage: snapshot => status.onContextUsage(snapshot),
|
|
108
|
+
contextWindow: status.parentContextWindow
|
|
98
109
|
};
|
|
99
110
|
}
|
|
@@ -9,7 +9,7 @@ import type { ContextSnapshot } from '../shared/child-process.js';
|
|
|
9
9
|
/** The parent session's context window, or 0 when the model doesn't expose it. */
|
|
10
10
|
export declare function getParentContextWindow(ctx: ExtensionCommandContext): number;
|
|
11
11
|
/**
|
|
12
|
-
* Fold a raw
|
|
12
|
+
* Fold a raw context snapshot into a display snapshot: prefer the child's
|
|
13
13
|
* own contextWindow, else the last known one, else the parent session's; then
|
|
14
14
|
* derive percent against it — falling back to the child's reported percent when
|
|
15
15
|
* no window is known at all.
|
|
@@ -9,7 +9,7 @@ export function getParentContextWindow(ctx) {
|
|
|
9
9
|
return (ctx.model?.contextWindow ?? 0);
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
|
-
* Fold a raw
|
|
12
|
+
* Fold a raw context snapshot into a display snapshot: prefer the child's
|
|
13
13
|
* own contextWindow, else the last known one, else the parent session's; then
|
|
14
14
|
* derive percent against it — falling back to the child's reported percent when
|
|
15
15
|
* no window is known at all.
|
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
export interface SourcedTitle {
|
|
2
|
-
/** The title with
|
|
2
|
+
/** The title with every source clause stripped. */
|
|
3
3
|
base: string;
|
|
4
|
-
/** The cited spec
|
|
5
|
-
|
|
4
|
+
/** The cited spec lines, in order, keeping only those GROUNDED in the doc. */
|
|
5
|
+
sources: string[];
|
|
6
6
|
}
|
|
7
|
-
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
/**
|
|
8
|
+
* Split a decompose title into its base and its GROUNDED source citations.
|
|
9
|
+
*
|
|
10
|
+
* PLURAL, because the model emits plural. The prompt asks for one trailing
|
|
11
|
+
* citation and a quarter of real titles carry more — 62 of 244 across the 20
|
|
12
|
+
* recorded runs. The old pattern was `\[source:\s*"(.+)"\]$`: greedy `.+`
|
|
13
|
+
* against an end anchor, so on `[source: "A"] [source: "B"]` it matched from the
|
|
14
|
+
* FIRST clause to the LAST quote and produced the superstring `A"] [source: "B`,
|
|
15
|
+
* which of course is not in the document. Two real citations became one
|
|
16
|
+
* fabricated one, and both were discarded. Peeling from the end with
|
|
17
|
+
* lastIndexOf is the fix; a lazy quantifier is NOT, because leftmost-first
|
|
18
|
+
* matching plus the `$` anchor expands it across the later clauses just the same.
|
|
19
|
+
*
|
|
20
|
+
* An absent clause yields no sources; a fabricated (ungrounded) one is dropped
|
|
21
|
+
* — exactly like keepGroundedContracts rejects a paraphrased quote.
|
|
22
|
+
*/
|
|
10
23
|
export declare function extractTitleSource(title: string, sourceDoc: string): SourcedTitle;
|
|
11
24
|
/**
|
|
12
25
|
* The `+`-joined trailing constraint fragments of `sourceLine` whose words are
|
|
@@ -24,8 +37,8 @@ export interface TitleRestoration {
|
|
|
24
37
|
index: number;
|
|
25
38
|
/** The verbatim fragments re-attached to the title. */
|
|
26
39
|
fragments: string[];
|
|
27
|
-
/** The grounded source
|
|
28
|
-
|
|
40
|
+
/** The grounded source lines they came from, in citation order. */
|
|
41
|
+
sources: string[];
|
|
29
42
|
}
|
|
30
43
|
export interface ReconciledPlan {
|
|
31
44
|
titles: string[];
|
|
@@ -28,21 +28,88 @@
|
|
|
28
28
|
* presence is exact word membership (with a singular/plural `s` allowance).
|
|
29
29
|
*/
|
|
30
30
|
import { normalise } from './contracts.js';
|
|
31
|
-
/**
|
|
32
|
-
const SOURCE_RE =
|
|
33
|
-
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
31
|
+
/** One trailing `[source: "…"]` clause, anchored so it is the WHOLE remainder. */
|
|
32
|
+
const SOURCE_RE = /^\[source:\s*"([\s\S]*)"\s*\]$/i;
|
|
33
|
+
/**
|
|
34
|
+
* Markdown MARKUP dropped before grounding — emphasis runs, list and heading
|
|
35
|
+
* markers, table pipes and CODE BACKTICKS. Not content: no word, number or
|
|
36
|
+
* punctuation inside a sentence is touched, so this cannot make an invented
|
|
37
|
+
* quote match.
|
|
38
|
+
*
|
|
39
|
+
* WHY. A model copies a spec line as it READS, and what it reads is rendered:
|
|
40
|
+
* `2. **Auth** — sessions, login/logout/me, guards + tests.` comes back as
|
|
41
|
+
* `Auth — sessions, login/logout/me, guards + tests.` That is a verbatim copy of
|
|
42
|
+
* the line's TEXT, and the exact-substring test called it fabricated and threw
|
|
43
|
+
* it away — including, as here, the "+ tests" line that is this module's own
|
|
44
|
+
* worked example.
|
|
45
|
+
*
|
|
46
|
+
* BACKTICKS ARE THE SAME CLASS and were the larger half. A code span renders as
|
|
47
|
+
* bare text, so `3. **Invites** — create/validate/redeem, \`/join/:token\` page.`
|
|
48
|
+
* comes back as `Invites — create/validate/redeem, /join/:token page.` Measured
|
|
49
|
+
* on the mx5 fixture, screening every spec line in its RENDERED form:
|
|
50
|
+
* 107/216 grounded with backticks kept, 216/216 with them dropped.
|
|
51
|
+
*
|
|
52
|
+
* MEASURED 2026-08-27 over the 20 recorded decompose runs in
|
|
53
|
+
* ab-grouplab/ledger-planning.jsonl, and screened both ways first:
|
|
54
|
+
* FLOOR real spec lines with ONE content word altered: 0/216 pass.
|
|
55
|
+
* CEILING real spec lines quoted without their markup: 209/257 = 81.3% passed
|
|
56
|
+
* before, 257/257 after. 48 genuine lines were being rejected.
|
|
57
|
+
* Re-screened by scripts/decompose-fidelity-screen.ts, which is the standing
|
|
58
|
+
* check: CEILING raw 216/216, CEILING rendered 216/216, FLOOR 0/216.
|
|
59
|
+
*/
|
|
60
|
+
function demark(s) {
|
|
61
|
+
return s
|
|
62
|
+
.replace(/\*\*|__/g, '')
|
|
63
|
+
.replace(/^\s*(?:[-*+]|\d+\.)\s+/gm, '')
|
|
64
|
+
.replace(/^#+\s*/gm, '')
|
|
65
|
+
.replace(/`/g, '')
|
|
66
|
+
.replace(/\|/g, ' ');
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Undo the backslash-escaping a model applies to a quote it is putting INSIDE a
|
|
70
|
+
* double-quoted clause. `[source: "… \`import { sql } from \\"bun\\"\` gotcha …"]`
|
|
71
|
+
* is a faithful copy of a line the document stores with plain quotes; the
|
|
72
|
+
* backslashes are an artefact of the delimiter, not content. Measured live: 4 of
|
|
73
|
+
* the 19 ungrounded clauses in the n=30/arm planning run were this and nothing
|
|
74
|
+
* else. Only `\"` is undone — no other escape sequence is interpreted, so this
|
|
75
|
+
* cannot rewrite a quote into something the document happens to contain.
|
|
76
|
+
*/
|
|
77
|
+
function unescapeQuotes(s) {
|
|
78
|
+
return s.replace(/\\"/g, '"');
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Split a decompose title into its base and its GROUNDED source citations.
|
|
82
|
+
*
|
|
83
|
+
* PLURAL, because the model emits plural. The prompt asks for one trailing
|
|
84
|
+
* citation and a quarter of real titles carry more — 62 of 244 across the 20
|
|
85
|
+
* recorded runs. The old pattern was `\[source:\s*"(.+)"\]$`: greedy `.+`
|
|
86
|
+
* against an end anchor, so on `[source: "A"] [source: "B"]` it matched from the
|
|
87
|
+
* FIRST clause to the LAST quote and produced the superstring `A"] [source: "B`,
|
|
88
|
+
* which of course is not in the document. Two real citations became one
|
|
89
|
+
* fabricated one, and both were discarded. Peeling from the end with
|
|
90
|
+
* lastIndexOf is the fix; a lazy quantifier is NOT, because leftmost-first
|
|
91
|
+
* matching plus the `$` anchor expands it across the later clauses just the same.
|
|
92
|
+
*
|
|
93
|
+
* An absent clause yields no sources; a fabricated (ungrounded) one is dropped
|
|
94
|
+
* — exactly like keepGroundedContracts rejects a paraphrased quote.
|
|
95
|
+
*/
|
|
36
96
|
export function extractTitleSource(title, sourceDoc) {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
97
|
+
const ref = normalise(demark(sourceDoc));
|
|
98
|
+
let base = title.trim();
|
|
99
|
+
const sources = [];
|
|
100
|
+
for (;;) {
|
|
101
|
+
const at = base.toLowerCase().lastIndexOf('[source:');
|
|
102
|
+
if (at === -1)
|
|
103
|
+
break;
|
|
104
|
+
const m = SOURCE_RE.exec(base.slice(at).trim());
|
|
105
|
+
if (!m)
|
|
106
|
+
break;
|
|
107
|
+
const quote = m[1].trim();
|
|
108
|
+
base = base.slice(0, at).trim();
|
|
109
|
+
if (quote.length > 0 && ref.includes(normalise(demark(unescapeQuotes(quote)))))
|
|
110
|
+
sources.unshift(quote);
|
|
44
111
|
}
|
|
45
|
-
return { base,
|
|
112
|
+
return { base, sources };
|
|
46
113
|
}
|
|
47
114
|
/** Word tokens for presence checks: alphanumeric runs, lowercased. */
|
|
48
115
|
function words(s) {
|
|
@@ -105,18 +172,32 @@ export function reconcileTitleSources(titles, sourceDoc) {
|
|
|
105
172
|
const restored = [];
|
|
106
173
|
let sourced = 0;
|
|
107
174
|
for (let i = 0; i < titles.length; i++) {
|
|
108
|
-
const { base,
|
|
109
|
-
if (
|
|
175
|
+
const { base, sources } = extractTitleSource(titles[i], sourceDoc);
|
|
176
|
+
if (sources.length === 0) {
|
|
110
177
|
out.push(base);
|
|
111
178
|
continue;
|
|
112
179
|
}
|
|
113
180
|
sourced++;
|
|
114
|
-
|
|
181
|
+
// EVERY grounded citation is checked, not just the first. A title that
|
|
182
|
+
// cites three spec lines can drop a constraint from any of them, and the
|
|
183
|
+
// fragments are deduped because two cited lines routinely share one
|
|
184
|
+
// ("+ tests" appears on four §12 milestones).
|
|
185
|
+
const seen = new Set();
|
|
186
|
+
const missing = [];
|
|
187
|
+
for (const src of sources) {
|
|
188
|
+
for (const f of findDroppedPlusFragments(src, base)) {
|
|
189
|
+
const k = f.toLowerCase();
|
|
190
|
+
if (seen.has(k))
|
|
191
|
+
continue;
|
|
192
|
+
seen.add(k);
|
|
193
|
+
missing.push(f);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
115
196
|
if (missing.length === 0) {
|
|
116
197
|
out.push(base);
|
|
117
198
|
continue;
|
|
118
199
|
}
|
|
119
|
-
restored.push({ index: i, fragments: missing,
|
|
200
|
+
restored.push({ index: i, fragments: missing, sources });
|
|
120
201
|
out.push(`${base} — MUST also cover (restored from its spec line): ${missing.join(', ')}`);
|
|
121
202
|
}
|
|
122
203
|
return { titles: out, restored, sourced };
|
|
@@ -75,6 +75,16 @@ export interface GateChildDeps {
|
|
|
75
75
|
commandTimeoutMs: number;
|
|
76
76
|
/** Hung-stream bound; the probe-based stall guard cannot supply it. */
|
|
77
77
|
streamInactivityMs: number;
|
|
78
|
+
/**
|
|
79
|
+
* The resolved `['--thinking', level]` fragment for the `gate` reasoning
|
|
80
|
+
* group, or `[]` to inherit the session default.
|
|
81
|
+
*
|
|
82
|
+
* REQUIRED, like its two neighbours above: gate-child takes resolved config
|
|
83
|
+
* values and gate-deps supplies them. Optional-with-a-default would let a new
|
|
84
|
+
* gate wiring silently run at a level nobody chose, which is the failure the
|
|
85
|
+
* whole profile feature exists to end.
|
|
86
|
+
*/
|
|
87
|
+
thinking: readonly string[];
|
|
78
88
|
/**
|
|
79
89
|
* The live widget state this child feeds and its loader reads. SHARED with
|
|
80
90
|
* the caller — the verify gate's own loader reads the same status while this
|
package/dist/task/gate-child.js
CHANGED
|
@@ -94,6 +94,7 @@ export function makeGateChild(deps) {
|
|
|
94
94
|
timeoutMs: 0,
|
|
95
95
|
commandTimeoutMs: deps.commandTimeoutMs,
|
|
96
96
|
streamInactivityMs: deps.streamInactivityMs,
|
|
97
|
+
thinking: deps.thinking,
|
|
97
98
|
// Exact-match loop guard only: pathThreshold Infinity disables
|
|
98
99
|
// the path-revisit heuristic, so revisiting one file (which IS
|
|
99
100
|
// the job) never trips — only a literally-identical call
|
|
@@ -119,7 +120,10 @@ export function makeGateChild(deps) {
|
|
|
119
120
|
+ deps.truncateToolResult(text), 'stream')
|
|
120
121
|
}
|
|
121
122
|
: {}),
|
|
122
|
-
onContextUsage: snapshot => deps.status.onContextUsage(snapshot)
|
|
123
|
+
onContextUsage: snapshot => deps.status.onContextUsage(snapshot),
|
|
124
|
+
// The gate child has to be TOLD its window: nothing in pi's
|
|
125
|
+
// event stream reports one (issue #16).
|
|
126
|
+
contextWindow: deps.status.parentContextWindow
|
|
123
127
|
});
|
|
124
128
|
}
|
|
125
129
|
finally {
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -42,6 +42,7 @@ import { assessRunnerGlobs, runnerGlobVerifyFindings } from './runner-globs.js';
|
|
|
42
42
|
import { captureGitState, reconcileGitState } from './git-state-guard.js';
|
|
43
43
|
import { runWorker } from '../workers/pi-worker-core.js';
|
|
44
44
|
import { getConfig } from '../config/config.js';
|
|
45
|
+
import { groupThinkingArgs } from '../config/reasoning-args.js';
|
|
45
46
|
import { makeDebugAppender } from './debug-log.js';
|
|
46
47
|
import { startAutoLoader } from './widget.js';
|
|
47
48
|
import { ChildStatus } from './child-status.js';
|
|
@@ -584,6 +585,9 @@ export function buildGateDeps(params) {
|
|
|
584
585
|
...(opts.loader === undefined ? {} : { loader: opts.loader }),
|
|
585
586
|
commandTimeoutMs: getConfig().requestTimeoutMs,
|
|
586
587
|
streamInactivityMs: getConfig().streamInactivityMs,
|
|
588
|
+
// Read per gateChild() call, like its two neighbours, so a
|
|
589
|
+
// /task-config change lands on the next gate without a restart.
|
|
590
|
+
thinking: groupThinkingArgs('gate'),
|
|
587
591
|
status,
|
|
588
592
|
runWorker,
|
|
589
593
|
makeDebugAppender,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hold the host session at the `implementation` group's thinking level for the
|
|
3
|
+
* duration of one implementation turn, then put it back.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS NOT LIKE THE OTHER SIX GROUPS
|
|
6
|
+
* -----------------------------------------
|
|
7
|
+
* Every other group is a child process, so its level is one argv flag and it
|
|
8
|
+
* dies with the child. The implementation turn runs in the USER'S OWN session
|
|
9
|
+
* (orchestrator.ts `sendSpec` → sendUserMessage → superviseImplementation), so
|
|
10
|
+
* the only lever is `pi.setThinkingLevel`, which is session-global and visible.
|
|
11
|
+
*
|
|
12
|
+
* THREE THINGS pi DOES that this has to survive. All three read from
|
|
13
|
+
* pi-coding-agent's agent-session `setThinkingLevel`:
|
|
14
|
+
*
|
|
15
|
+
* 1. IT PERSISTS. On a real change it calls
|
|
16
|
+
* `settingsManager.setDefaultThinkingLevel(...)`, writing
|
|
17
|
+
* `~/.pi/agent/settings.json`. This is not a session-local toggle — without
|
|
18
|
+
* the restore, running one task would silently rewrite the user's global
|
|
19
|
+
* default. That makes `release()` load-bearing, not tidy-up.
|
|
20
|
+
* 2. IT CLAMPS, to what the model declares it supports. We may ask for `medium`
|
|
21
|
+
* and be given `off`. So the restore writes back what was READ after
|
|
22
|
+
* setting, never what was asked for — otherwise a clamp would ratchet the
|
|
23
|
+
* stored default a little further every run.
|
|
24
|
+
* 3. IT IS OBSERVABLE, and the user can change it mid-turn (shift+tab cycles
|
|
25
|
+
* the level). Restoring blindly would clobber a choice they just made. We
|
|
26
|
+
* detect it by comparing the live level at release against what we applied:
|
|
27
|
+
* if it has moved, somebody else moved it, and we leave it alone.
|
|
28
|
+
*
|
|
29
|
+
* We compare rather than subscribe because `pi.on` returns no unsubscribe
|
|
30
|
+
* handle, so a per-turn listener could only ever be added, never removed. The
|
|
31
|
+
* comparison answers the same question with no accumulating state.
|
|
32
|
+
*/
|
|
33
|
+
import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
|
|
34
|
+
import { type GroupSetting } from '../config/reasoning.js';
|
|
35
|
+
/**
|
|
36
|
+
* The slice of the extension API this needs, named so tests can drive it without
|
|
37
|
+
* a live pi session. Every other dependency in `RunSingleTaskOptions` is
|
|
38
|
+
* injectable; this one has to be too, or the restore logic is only exercisable
|
|
39
|
+
* by running a real task.
|
|
40
|
+
*/
|
|
41
|
+
export interface ThinkingControl {
|
|
42
|
+
get(): ThinkingLevel;
|
|
43
|
+
set(level: ThinkingLevel): void;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Put the session at the implementation group's level and return the function
|
|
47
|
+
* that puts it back. Always call the returned function — `finally`, not the
|
|
48
|
+
* happy path.
|
|
49
|
+
*
|
|
50
|
+
* `inherit` makes NO call at all, not even a redundant set-to-current: a set
|
|
51
|
+
* that happens to be a no-op still goes through pi's change detection, and the
|
|
52
|
+
* shipped default must not touch the user's settings file.
|
|
53
|
+
*/
|
|
54
|
+
export declare function holdImplementationThinking(control: ThinkingControl, setting?: GroupSetting): () => void;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { getConfig } from '../config/config.js';
|
|
2
|
+
import { resolveReasoning } from '../config/reasoning.js';
|
|
3
|
+
/**
|
|
4
|
+
* Put the session at the implementation group's level and return the function
|
|
5
|
+
* that puts it back. Always call the returned function — `finally`, not the
|
|
6
|
+
* happy path.
|
|
7
|
+
*
|
|
8
|
+
* `inherit` makes NO call at all, not even a redundant set-to-current: a set
|
|
9
|
+
* that happens to be a no-op still goes through pi's change detection, and the
|
|
10
|
+
* shipped default must not touch the user's settings file.
|
|
11
|
+
*/
|
|
12
|
+
export function holdImplementationThinking(control, setting = resolveReasoning('implementation', getConfig())) {
|
|
13
|
+
if (setting === 'inherit')
|
|
14
|
+
return () => { };
|
|
15
|
+
const before = control.get();
|
|
16
|
+
control.set(setting);
|
|
17
|
+
// Post-clamp, so a model that cannot do `medium` does not leave us believing
|
|
18
|
+
// it is at `medium` and treating the user's later change as our own.
|
|
19
|
+
const applied = control.get();
|
|
20
|
+
if (applied === before)
|
|
21
|
+
return () => { };
|
|
22
|
+
let released = false;
|
|
23
|
+
return () => {
|
|
24
|
+
// Idempotent: the caller's `finally` may run alongside an outer one on an
|
|
25
|
+
// abort path, and a second restore would fight a user change made in
|
|
26
|
+
// between.
|
|
27
|
+
if (released)
|
|
28
|
+
return;
|
|
29
|
+
released = true;
|
|
30
|
+
if (control.get() === applied)
|
|
31
|
+
control.set(before);
|
|
32
|
+
};
|
|
33
|
+
}
|