@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.
Files changed (52) hide show
  1. package/README.md +1 -0
  2. package/dist/config/config.d.ts +19 -0
  3. package/dist/config/config.js +10 -2
  4. package/dist/config/reasoning-args.d.ts +10 -0
  5. package/dist/config/reasoning-args.js +23 -0
  6. package/dist/config/reasoning.d.ts +160 -0
  7. package/dist/config/reasoning.js +530 -0
  8. package/dist/config/register.d.ts +72 -2
  9. package/dist/config/register.js +182 -33
  10. package/dist/shared/child-process.d.ts +19 -0
  11. package/dist/shared/child-process.js +9 -10
  12. package/dist/shared/model-endpoint.d.ts +34 -0
  13. package/dist/shared/model-endpoint.js +36 -0
  14. package/dist/shared/reasoning-capability.d.ts +86 -0
  15. package/dist/shared/reasoning-capability.js +82 -0
  16. package/dist/task/child-runner.d.ts +36 -26
  17. package/dist/task/child-runner.js +63 -6
  18. package/dist/task/child-status.d.ts +11 -3
  19. package/dist/task/child-status.js +14 -3
  20. package/dist/task/context-usage.d.ts +1 -1
  21. package/dist/task/context-usage.js +1 -1
  22. package/dist/task/decompose-fidelity.d.ts +21 -8
  23. package/dist/task/decompose-fidelity.js +98 -17
  24. package/dist/task/gate-child.d.ts +10 -0
  25. package/dist/task/gate-child.js +5 -1
  26. package/dist/task/gate-deps.js +4 -0
  27. package/dist/task/implementation-thinking.d.ts +54 -0
  28. package/dist/task/implementation-thinking.js +33 -0
  29. package/dist/task/orchestrator.d.ts +7 -0
  30. package/dist/task/orchestrator.js +46 -14
  31. package/dist/task/phases.js +47 -24
  32. package/dist/task/prompts.d.ts +0 -23
  33. package/dist/task/prompts.js +0 -25
  34. package/dist/task/reasoning-groups.d.ts +36 -0
  35. package/dist/task/reasoning-groups.js +36 -0
  36. package/dist/task/spec-validation.d.ts +28 -0
  37. package/dist/task/spec-validation.js +44 -0
  38. package/dist/task/stall-detector.d.ts +5 -0
  39. package/dist/task/stall-detector.js +5 -0
  40. package/dist/task/title-label.js +2 -2
  41. package/dist/workers/docs-core.js +4 -0
  42. package/dist/workers/fetch-core.js +4 -0
  43. package/dist/workers/focused-extractor.d.ts +12 -1
  44. package/dist/workers/focused-extractor.js +6 -2
  45. package/dist/workers/index.js +2 -0
  46. package/dist/workers/pi-worker-core.d.ts +33 -4
  47. package/dist/workers/pi-worker-core.js +28 -7
  48. package/dist/workers/pi-worker-docs.js +4 -0
  49. package/dist/workers/pi-worker.js +11 -1
  50. package/dist/workers/reasoning-warning.d.ts +64 -0
  51. package/dist/workers/reasoning-warning.js +142 -0
  52. 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[]): 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): Promise<PhaseRunResult>;
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
- const invocation = getPiInvocation(childArgs(tools, extensions), prompt);
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 context_usage snapshot into the gauge: the child's own window,
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
- /** Wire a `ChildStatus` as a phase child's stream callbacks. */
95
- export declare function statusCallbacks(status: ChildStatus): Pick<PhaseDeps, 'onChildOutput' | 'onContextUsage'>;
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 context_usage snapshot into the gauge: the child's own window,
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
- /** Wire a `ChildStatus` as a phase child's stream callbacks. */
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 context_usage snapshot into a display snapshot: prefer the child's
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 context_usage snapshot into a display snapshot: prefer the child's
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 any source clause stripped. */
2
+ /** The title with every source clause stripped. */
3
3
  base: string;
4
- /** The cited spec line, when present and grounded in the source doc. */
5
- source?: string;
4
+ /** The cited spec lines, in order, keeping only those GROUNDED in the doc. */
5
+ sources: string[];
6
6
  }
7
- /** Split a decompose title into its base and its GROUNDED source citation.
8
- * An absent clause yields no source; a fabricated (ungrounded) one is stripped
9
- * and dropped — exactly like keepGroundedContracts rejects a paraphrased quote. */
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 line they came from. */
28
- source: string;
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
- /** Trailing `[source: "…"]` clause a decompose line may carry (prompt asks for it last). */
32
- const SOURCE_RE = /\s*\[source:\s*"(.+)"\s*\]\s*$/i;
33
- /** Split a decompose title into its base and its GROUNDED source citation.
34
- * An absent clause yields no source; a fabricated (ungrounded) one is stripped
35
- * and dropped exactly like keepGroundedContracts rejects a paraphrased quote. */
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 m = SOURCE_RE.exec(title);
38
- if (!m)
39
- return { base: title.trim() };
40
- const base = title.slice(0, m.index).trim();
41
- const quote = m[1].trim();
42
- if (quote.length === 0 || !normalise(sourceDoc).includes(normalise(quote))) {
43
- return { base };
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, source: quote };
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, source } = extractTitleSource(titles[i], sourceDoc);
109
- if (source === undefined) {
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
- const missing = findDroppedPlusFragments(source, base);
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, source });
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
@@ -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 {
@@ -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
+ }