@mjasnikovs/pi-task 0.38.19 → 0.38.21
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 +291 -38
- 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 +19 -26
- package/dist/task/child-runner.js +48 -6
- 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 +1 -0
- 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 +42 -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/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 +22 -0
- package/dist/workers/pi-worker-core.js +24 -6
- 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 +1 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi's own level ladder, in order. The order is the whole algorithm: an
|
|
3
|
+
* unsupported level is resolved by walking UP first, then down.
|
|
4
|
+
*/
|
|
5
|
+
export const THINKING_LADDER = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
6
|
+
/**
|
|
7
|
+
* The levels this model will actually honour.
|
|
8
|
+
*
|
|
9
|
+
* Two rules that are easy to get backwards:
|
|
10
|
+
* - `reasoning: false` collapses everything to `['off']`. That is failure mode
|
|
11
|
+
* 1: the knob is not rejected, it is erased.
|
|
12
|
+
* - a MISSING map entry means "supported" for the standard levels but
|
|
13
|
+
* "unsupported" for `xhigh` / `max`, which are opt-in and must be declared.
|
|
14
|
+
* This is why config/reasoning.ts does not offer those two: a model with no
|
|
15
|
+
* map at all would receive the raw string, and Qwen3.8's chat template
|
|
16
|
+
* answers an unknown effort with HTTP 500 rather than a clamp.
|
|
17
|
+
*/
|
|
18
|
+
export function supportedThinkingLevels(model) {
|
|
19
|
+
if (!model.reasoning)
|
|
20
|
+
return ['off'];
|
|
21
|
+
return THINKING_LADDER.filter(level => {
|
|
22
|
+
const mapped = model.thinkingLevelMap?.[level];
|
|
23
|
+
if (mapped === null)
|
|
24
|
+
return false;
|
|
25
|
+
if (level === 'xhigh' || level === 'max')
|
|
26
|
+
return mapped !== undefined;
|
|
27
|
+
return true;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The level pi will use in place of the one asked for. Equal to the input when
|
|
32
|
+
* the model supports it — which is what makes the inequality a mismatch test.
|
|
33
|
+
*/
|
|
34
|
+
export function clampToModel(model, level) {
|
|
35
|
+
const available = supportedThinkingLevels(model);
|
|
36
|
+
if (available.includes(level))
|
|
37
|
+
return level;
|
|
38
|
+
const requested = THINKING_LADDER.indexOf(level);
|
|
39
|
+
if (requested === -1)
|
|
40
|
+
return available[0] ?? 'off';
|
|
41
|
+
for (let i = requested; i < THINKING_LADDER.length; i++) {
|
|
42
|
+
const candidate = THINKING_LADDER[i];
|
|
43
|
+
if (available.includes(candidate))
|
|
44
|
+
return candidate;
|
|
45
|
+
}
|
|
46
|
+
for (let i = requested - 1; i >= 0; i--) {
|
|
47
|
+
const candidate = THINKING_LADDER[i];
|
|
48
|
+
if (available.includes(candidate))
|
|
49
|
+
return candidate;
|
|
50
|
+
}
|
|
51
|
+
return available[0] ?? 'off';
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Every group whose setting the model will silently change.
|
|
55
|
+
*
|
|
56
|
+
* `inherit` groups are skipped entirely, and that is what keeps a default
|
|
57
|
+
* install permanently quiet: with the shipped all-`inherit` table this returns
|
|
58
|
+
* an empty array for every model, including one with no reasoning at all.
|
|
59
|
+
*
|
|
60
|
+
* It reports mismatches in BOTH directions, which is wider than "warn when
|
|
61
|
+
* reasoning is on but unsupported". The failure actually captured on this
|
|
62
|
+
* machine is the mirror of that — `off` clamped UP to `medium`, so a user who
|
|
63
|
+
* turned thinking off still pays for it — and it is the same comparison. Warning
|
|
64
|
+
* about one direction while staying silent about the other would ship this
|
|
65
|
+
* feature with its own measured failure mode unreported.
|
|
66
|
+
*/
|
|
67
|
+
export function reasoningMismatches(model, settings) {
|
|
68
|
+
// No model resolved yet (session still starting, or none selected): say
|
|
69
|
+
// nothing. A warning naming no model is noise, not information.
|
|
70
|
+
if (!model)
|
|
71
|
+
return [];
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const { group, setting } of settings) {
|
|
74
|
+
if (setting === 'inherit')
|
|
75
|
+
continue;
|
|
76
|
+
const wanted = setting;
|
|
77
|
+
const actual = clampToModel(model, wanted);
|
|
78
|
+
if (actual !== wanted)
|
|
79
|
+
out.push({ group, wanted, actual });
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
@@ -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
|
|
@@ -88,7 +95,17 @@ onToolResult?: (text: string, isError: boolean) => void,
|
|
|
88
95
|
* one (issue #16), so the parent hands its own down — children carry no `-m`
|
|
89
96
|
* and resolve the same default model. 0 / omitted = unknown, as before.
|
|
90
97
|
*/
|
|
91
|
-
contextWindow?: number
|
|
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>;
|
|
92
109
|
interface PhaseDeps {
|
|
93
110
|
cwd: string;
|
|
94
111
|
taskId: string;
|
|
@@ -195,30 +212,6 @@ interface PhaseDeps {
|
|
|
195
212
|
searchFn?: (input: SearchCoreInput) => Promise<SearchCoreResult>;
|
|
196
213
|
}
|
|
197
214
|
export type { PhaseDeps };
|
|
198
|
-
/**
|
|
199
|
-
* Run a child pi and return its assistant text. Throws if exit code != 0.
|
|
200
|
-
*
|
|
201
|
-
* If the child leaks a tool call as plain text (wrong dialect — never executed),
|
|
202
|
-
* re-prompt with a correction hint up to MAX_LEAK_RETRIES times; if it keeps
|
|
203
|
-
* leaking, throw LeakedToolCallError rather than returning the unexecuted call.
|
|
204
|
-
* Empty completions and connection-class model errors share that same budget —
|
|
205
|
-
* see triageChildResult, which decides every one of those cases.
|
|
206
|
-
*
|
|
207
|
-
* THREE RUNAWAY GUARDS ride the same budget, because this is the runner every
|
|
208
|
-
* /task-auto planning child goes through (clarify, decompose, coverage,
|
|
209
|
-
* contract-extract) and until mx5-n 2026-08-14 it had none:
|
|
210
|
-
* • a LoopDetector, so an identical repeated tool call is killed and
|
|
211
|
-
* re-prompted instead of being allowed to fill the context window;
|
|
212
|
-
* • a StallDetector, the backstop for the varied-args thrash the loop
|
|
213
|
-
* detector's short window cannot see — the shape that actually cost us a
|
|
214
|
-
* 16-minute decompose child that was never going to return. It bounds
|
|
215
|
-
* consecutive no-new-ground calls and total context churn, NOT elapsed time;
|
|
216
|
-
* • PHASE_CHILD_TIMEOUT_MS, a hard wall clock, OFF by default because the
|
|
217
|
-
* measured healthy range (610-927s for a reasoning-on decompose) overlaps
|
|
218
|
-
* any value that would catch the pathology. See its comment.
|
|
219
|
-
* All three are checked BEFORE the triage ladder: we killed the child, so its
|
|
220
|
-
* exit status describes our SIGTERM and says nothing about its verdict.
|
|
221
|
-
*/
|
|
222
215
|
export declare function runPhaseChild(deps: PhaseDeps, name: string, tools: string, prompt: string, opts?: PhaseChildOptions): Promise<string>;
|
|
223
216
|
export declare function formatLoopHint(hit: LoopHit): string;
|
|
224
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.
|
|
@@ -178,8 +187,18 @@ onToolResult,
|
|
|
178
187
|
* one (issue #16), so the parent hands its own down — children carry no `-m`
|
|
179
188
|
* and resolve the same default model. 0 / omitted = unknown, as before.
|
|
180
189
|
*/
|
|
181
|
-
contextWindow
|
|
182
|
-
|
|
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);
|
|
183
202
|
let loopHit;
|
|
184
203
|
const result = await runChildUnified(spawnFn ?? spawn, invocation, cwd, signal, {
|
|
185
204
|
mode: 'json-events',
|
|
@@ -310,9 +329,28 @@ async function triageChildResult(deps, name, r, attempt, budget, verb) {
|
|
|
310
329
|
* All three are checked BEFORE the triage ladder: we killed the child, so its
|
|
311
330
|
* exit status describes our SIGTERM and says nothing about its verdict.
|
|
312
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
|
+
}
|
|
313
345
|
export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
|
|
314
346
|
if (deps.runChild)
|
|
315
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);
|
|
316
354
|
const verb = opts.verb ?? 'retry';
|
|
317
355
|
let hint = null;
|
|
318
356
|
const loopHistory = [];
|
|
@@ -337,7 +375,7 @@ export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
|
|
|
337
375
|
// left the churn rule permanently disarmed (issue #16).
|
|
338
376
|
stall.noteContext(snapshot.contextWindow);
|
|
339
377
|
deps.onContextUsage?.(snapshot);
|
|
340
|
-
}, call => detector.record(call) ?? stall.record(call), deps.spawn, deps.childExtensions, (text, isError) => stall.noteResult(text, isError), deps.contextWindow);
|
|
378
|
+
}, call => detector.record(call) ?? stall.record(call), deps.spawn, deps.childExtensions, (text, isError) => stall.noteResult(text, isError), deps.contextWindow, thinking);
|
|
341
379
|
}
|
|
342
380
|
finally {
|
|
343
381
|
clock.cleanup();
|
|
@@ -445,7 +483,11 @@ async function appendLoopEvent(cwd, taskId, phase, hit, strike, outcome) {
|
|
|
445
483
|
async function runDegradedFinalAttempt(deps, name, prompt, hit, loopHistory) {
|
|
446
484
|
deps.logDebug?.(`${name}: loop budget exhausted — degrading to a no-tools final attempt`);
|
|
447
485
|
const r = await runChild(deps.cwd, '', // --no-tools: the model cannot read/grep/list, only answer
|
|
448
|
-
prependHint(formatDegradeHint(hit), prompt), deps.signal, deps.onChildOutput, deps.onContextUsage, undefined, deps.spawn, undefined, undefined, deps.contextWindow
|
|
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));
|
|
449
491
|
if (r.exitCode !== 0 || r.modelError || r.text.trim().length === 0) {
|
|
450
492
|
throw new LoopExhaustedError(name, loopHistory);
|
|
451
493
|
}
|
|
@@ -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
|
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
|
+
}
|
|
@@ -20,6 +20,7 @@ import { type WidgetState } from './widget.js';
|
|
|
20
20
|
import { type RunTaskFn } from './gate-deps.js';
|
|
21
21
|
import { type GateDeps } from './task-gates.js';
|
|
22
22
|
import { type PhaseDeps } from './child-runner.js';
|
|
23
|
+
import { type ThinkingControl } from './implementation-thinking.js';
|
|
23
24
|
import { type RunEnd } from './run-end.js';
|
|
24
25
|
import type { SpawnFn } from '../shared/child-process.js';
|
|
25
26
|
import { type SuperviseOptions } from './implementation-turn.js';
|
|
@@ -166,6 +167,12 @@ export interface RunSingleTaskOptions extends Pick<TaskRunnerOptions, 'resumeId'
|
|
|
166
167
|
* internal per-task runs, which must stay silent. Default false.
|
|
167
168
|
*/
|
|
168
169
|
notifyFinish?: boolean;
|
|
170
|
+
/**
|
|
171
|
+
* How the implementation turn's thinking level is read and written. Defaults
|
|
172
|
+
* to the live pi session; injected by tests, which must be able to assert
|
|
173
|
+
* the restore without a real session to restore.
|
|
174
|
+
*/
|
|
175
|
+
thinkingControl?: ThinkingControl;
|
|
169
176
|
}
|
|
170
177
|
export interface RunSingleTaskResult {
|
|
171
178
|
taskId: string;
|