@mjasnikovs/pi-task 0.38.19 → 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 (44) 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/model-endpoint.d.ts +34 -0
  11. package/dist/shared/model-endpoint.js +36 -0
  12. package/dist/shared/reasoning-capability.d.ts +86 -0
  13. package/dist/shared/reasoning-capability.js +82 -0
  14. package/dist/task/child-runner.d.ts +19 -26
  15. package/dist/task/child-runner.js +48 -6
  16. package/dist/task/decompose-fidelity.d.ts +21 -8
  17. package/dist/task/decompose-fidelity.js +98 -17
  18. package/dist/task/gate-child.d.ts +10 -0
  19. package/dist/task/gate-child.js +1 -0
  20. package/dist/task/gate-deps.js +4 -0
  21. package/dist/task/implementation-thinking.d.ts +54 -0
  22. package/dist/task/implementation-thinking.js +33 -0
  23. package/dist/task/orchestrator.d.ts +7 -0
  24. package/dist/task/orchestrator.js +42 -14
  25. package/dist/task/phases.js +47 -24
  26. package/dist/task/prompts.d.ts +0 -23
  27. package/dist/task/prompts.js +0 -25
  28. package/dist/task/reasoning-groups.d.ts +36 -0
  29. package/dist/task/reasoning-groups.js +36 -0
  30. package/dist/task/spec-validation.d.ts +28 -0
  31. package/dist/task/spec-validation.js +44 -0
  32. package/dist/task/title-label.js +2 -2
  33. package/dist/workers/docs-core.js +4 -0
  34. package/dist/workers/fetch-core.js +4 -0
  35. package/dist/workers/focused-extractor.d.ts +12 -1
  36. package/dist/workers/focused-extractor.js +6 -2
  37. package/dist/workers/index.js +2 -0
  38. package/dist/workers/pi-worker-core.d.ts +22 -0
  39. package/dist/workers/pi-worker-core.js +24 -6
  40. package/dist/workers/pi-worker-docs.js +4 -0
  41. package/dist/workers/pi-worker.js +11 -1
  42. package/dist/workers/reasoning-warning.d.ts +64 -0
  43. package/dist/workers/reasoning-warning.js +142 -0
  44. package/package.json +1 -1
@@ -21,7 +21,7 @@ import { getConfig } from '../config/config.js';
21
21
  import { readFile } from 'node:fs/promises';
22
22
  import { resolve } from 'node:path';
23
23
  import { buildExternalContext, gatherExternalContext } from './external-context.js';
24
- import { REFINE_PROMPT, RESEARCH_FILES_PROMPT, RESEARCH_APIS_PROMPT, RESEARCH_CONTEXT_PROMPT, RESEARCH_TOOLING_PROMPT, GRILL_GEN_PROMPT, GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT, COMPOSE_PROMPT, CRITIQUE_PROMPT, CRITIQUE_TRIAGE_PROMPT, VERIFY_TOOLING_PROMPT, MAX_GRILL_QUESTIONS, appendNoThink } from './prompts.js';
24
+ import { REFINE_PROMPT, RESEARCH_FILES_PROMPT, RESEARCH_APIS_PROMPT, RESEARCH_CONTEXT_PROMPT, RESEARCH_TOOLING_PROMPT, GRILL_GEN_PROMPT, GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT, COMPOSE_PROMPT, CRITIQUE_PROMPT, CRITIQUE_TRIAGE_PROMPT, VERIFY_TOOLING_PROMPT, MAX_GRILL_QUESTIONS } from './prompts.js';
25
25
  import { appendGateRecord, readSection, removeTaskSection, setTaskSection, updateTaskFrontMatter } from './task-io.js';
26
26
  import { applyRefutations } from './refuted-constraint.js';
27
27
  import { spawnSync } from 'node:child_process';
@@ -29,7 +29,7 @@ import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js'
29
29
  import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
30
30
  import { parseGrillQuestions, parseAutoAnswer, autoAnswerHasTag, parseVerifyToolingOutput, deriveTitle } from './parsers.js';
31
31
  import { compressTitle } from './title-label.js';
32
- import { parseVerifyBlock, validateSpecShape, stripSpecPreamble, isCritiqueClean } from './spec-validation.js';
32
+ import { parseVerifyBlock, validateSpecShape, validateRefineShape, stripSpecPreamble, isCritiqueClean } from './spec-validation.js';
33
33
  import { collectCritiqueDefects } from './critique-probes.js';
34
34
  import { buildOptionCards, resolveAnswer } from './question-dialog.js';
35
35
  import { findSynthesizedApis, synthesizedApiReaskHint } from './api-synthesis.js';
@@ -42,6 +42,7 @@ import { runPhaseChild, runWithEmphasisRetry, prependHint, USER_CANCELLED } from
42
42
  import { SessionUI } from '../remote/bridge.js';
43
43
  import { isYoloMode, yoloPickAutoAnswer } from './yolo.js';
44
44
  import { QaTranscript, GRILL_QA_POLICY } from './qa-transcript.js';
45
+ import { groupThinkingArgs } from '../config/reasoning-args.js';
45
46
  // ─── Re-export constants from their home modules ────────────────────────────
46
47
  export { MAX_GRILL_QUESTIONS };
47
48
  // ─── Tooling helpers ─────────────────────────────────────────────────────────
@@ -265,7 +266,7 @@ export const phaseRefine = async (deps, raw, planContext) => {
265
266
  // re-check the output below (lever). Empty on an ordinary prompt → refine unchanged.
266
267
  const directives = extractUserDirectives(raw);
267
268
  const directivesBlock = preserveDirectivesBlock(directives);
268
- const refined = await runPhaseChild(deps, 'refine', 'read', appendNoThink(REFINE_PROMPT(raw, planContext, existingFiles, contracts, directivesBlock)),
269
+ const refined = await runPhaseChild(deps, 'refine', 'read', REFINE_PROMPT(raw, planContext, existingFiles, contracts, directivesBlock),
269
270
  // refine's deliverable is a 4-section text rewrite that never strictly
270
271
  // needs a successful read — on a test-writing task against a large
271
272
  // existing codebase the model over-explores (re-reads source hunting for
@@ -274,6 +275,16 @@ export const phaseRefine = async (deps, raw, planContext) => {
274
275
  // refine looped 3×/resume forever; the deliverable was always producible
275
276
  // from the title + design doc alone.
276
277
  { degradeOnExhaustion: true, verb: 'restart' });
278
+ // Shape check, REPORTED not enforced. Refine's four sections are what
279
+ // extractCapsSection, scopedToolingGoal, deriveTitle and extractEnrichTargets
280
+ // each look for, and all four fail SILENTLY when one is missing — so the loss
281
+ // was previously invisible. Deliberately not a retry or a throw: refine's
282
+ // output is usable prose even when a heading is gone (the four consumers
283
+ // degrade, they do not break), and a run must not die over a heading. This
284
+ // puts the miss in the debug log where an audit can find it.
285
+ const shape = validateRefineShape(refined);
286
+ if (shape)
287
+ deps.logDebug?.(`refine: ${shape}`);
277
288
  // Deterministic backstop: if the refined spec still dropped a directive, append
278
289
  // it verbatim rather than trusting the paraphrase. No model in this path.
279
290
  const { text, appended } = enforceDirectives(refined, directives);
@@ -291,7 +302,7 @@ export async function phaseVerifyTooling(deps, research) {
291
302
  const toolingList = commands.join('\n');
292
303
  let verifyOutput;
293
304
  try {
294
- verifyOutput = await runPhaseChild(deps, 'verify-tooling', 'read,bash', appendNoThink(VERIFY_TOOLING_PROMPT(toolingList)));
305
+ verifyOutput = await runPhaseChild(deps, 'verify-tooling', 'read,bash', VERIFY_TOOLING_PROMPT(toolingList));
295
306
  }
296
307
  catch {
297
308
  return replaceToolingWithVerified(research, commands);
@@ -717,26 +728,33 @@ export async function phaseResearch(deps, refined) {
717
728
  return r;
718
729
  });
719
730
  // Run the four workers ONE AT A TIME. Settled by an A/B on the local
720
- // llama.cpp backend (single GPU, same task/model) — and the answer flips
731
+ // llama.cpp backend (single GPU, same task/model) — and the answer FLIPS
721
732
  // with thinking:
722
- // - thinking ON → parallel wins: long decodes batch well, 4 concurrent
733
+ // - thinking ON → parallel wins: long decodes batch well, 4 concurrent
723
734
  // finish in ~max(worker), not the sum.
724
- // - /no_think → sequential wins: with short decodes the batching upside
735
+ // - thinking OFF → sequential wins: with short decodes the batching upside
725
736
  // is gone, but 4 concurrent streams still split the one GPU and slow
726
737
  // each other ~4x (context worker measured 27s solo vs 128s under load),
727
738
  // so summed-but-fast (~100s) beats max-of-slowed (~130s).
728
- // Every worker runs /no_think (below), so sequential is the faster regime.
729
- // Do NOT switch the DEFAULT back to concurrent without re-running that A/B;
730
- // the opt-in `parallelResearchWorkers` config flag exists for backends that
731
- // genuinely serve parallel streams.
732
739
  //
733
- // `/no_think` is the big win: these are agentic exploration loops, and on a
734
- // reasoning model the child would otherwise emit a full <think> trace at
735
- // every tool step ("let me read X next…") the single largest decode sink
736
- // in the pipeline. Stripping it cut each worker's decode 3-8x in the A/B.
737
- // The worker still calls as many tools as it wants; it just stops narrating
738
- // between them. See appendNoThink. Result order (files, apis, context,
739
- // tooling) is preserved for assembly.
740
+ // KNOWN-OPEN, AND THIS IS THE HONEST STATE OF IT. That A/B was run when
741
+ // every worker carried Qwen3's `/no_think` prompt suffix, so "thinking OFF"
742
+ // was assumed and sequential followed. The suffix has since been measured
743
+ // INERT with server thinking on and `/no_think` still in the prompt,
744
+ // Qwen3.8 emitted a median 17k-char trace anyway (n=25) and it has been
745
+ // removed in favour of the `research` reasoning group (config/reasoning.ts).
746
+ // So the arm this default was chosen under may never have been the arm that
747
+ // ran, and the group's level is now a user-visible setting rather than a
748
+ // constant.
749
+ //
750
+ // The default stays SEQUENTIAL because that is the measured-safe arm on a
751
+ // single-GPU box and because flipping a shipped default on an invalidated
752
+ // premise would be replacing one unmeasured claim with another. The
753
+ // parallel x reasoning-level interaction is unmeasured; re-run the A/B
754
+ // before changing this, and treat `parallelResearchWorkers` as the opt-in
755
+ // for backends that genuinely serve parallel streams.
756
+ //
757
+ // Result order (files, apis, context, tooling) is preserved for assembly.
740
758
  // Resolved once: `searchConfigured()` reads the environment, and the tools
741
759
  // string and the `-e` paths must be derived from the SAME answer.
742
760
  const apisChannels = apisWorkerChannels();
@@ -745,7 +763,7 @@ export async function phaseResearch(deps, refined) {
745
763
  section: 'FILES',
746
764
  label: 'worker:files',
747
765
  // Read-heavy: gets the orientation core (see note above).
748
- prompt: appendNoThink(orientation.block + promptHeader + RESEARCH_FILES_PROMPT(refined))
766
+ prompt: orientation.block + promptHeader + RESEARCH_FILES_PROMPT(refined)
749
767
  },
750
768
  {
751
769
  section: 'APIS',
@@ -756,7 +774,7 @@ export async function phaseResearch(deps, refined) {
756
774
  // the worker doesn't re-derive where-things-live via docs-"."
757
775
  // queries the FILES worker just answered (run-7 F7: up to 10
758
776
  // duplicate `.`-decodes per task through the serial bottleneck).
759
- prompt: prior => appendNoThink(orientation.block
777
+ prompt: prior => orientation.block
760
778
  + promptHeader
761
779
  + RESEARCH_APIS_PROMPT(refined, prior.find(s => s.name === 'FILES')?.text || undefined)
762
780
  + (searchConfigured() ? RESEARCH_SEARCH_HINT : '')
@@ -764,7 +782,7 @@ export async function phaseResearch(deps, refined) {
764
782
  // set. The tool-side half lives in pi-worker-docs.ts; a
765
783
  // budget enforced without being announced would just read
766
784
  // to the worker as a broken tool.
767
- + (fanoutBudget === null ? '' : projectDocsBudgetNotice(fanoutBudget))),
785
+ + (fanoutBudget === null ? '' : projectDocsBudgetNotice(fanoutBudget)),
768
786
  // The tools string and the `-e` paths are ONE fact — which worker
769
787
  // channels this research worker is given — and used to be two literals
770
788
  // kept in step by eye. `channelSet` derives both from the same rows.
@@ -798,7 +816,7 @@ export async function phaseResearch(deps, refined) {
798
816
  {
799
817
  section: 'CONTEXT',
800
818
  label: 'worker:context',
801
- prompt: appendNoThink(promptHeader + RESEARCH_CONTEXT_PROMPT(refined)),
819
+ prompt: promptHeader + RESEARCH_CONTEXT_PROMPT(refined),
802
820
  // Context owns architectural understanding, not path discovery —
803
821
  // FILES handles that. Dropping `find`/`ls` keeps the worker from
804
822
  // spawning long enumeration loops whose output then inflates
@@ -843,7 +861,7 @@ export async function phaseResearch(deps, refined) {
843
861
  label: 'worker:tooling',
844
862
  // Scope to the goal prose only — not the per-file edit checklist that
845
863
  // makes a weak model spelunk source and loop. See scopedToolingGoal.
846
- prompt: appendNoThink(promptHeader + RESEARCH_TOOLING_PROMPT(scopedToolingGoal(refined))),
864
+ prompt: promptHeader + RESEARCH_TOOLING_PROMPT(scopedToolingGoal(refined)),
847
865
  // Block re-reads in-process so a weak model that wants to re-read the
848
866
  // same file is told to answer from what it has instead of looping.
849
867
  extensions: [SINGLE_READ_EXTENSION_PATH]
@@ -883,6 +901,11 @@ export async function phaseResearch(deps, refined) {
883
901
  cwd: deps.cwd,
884
902
  signal: deps.signal,
885
903
  spawn: deps.spawn,
904
+ // All four research workers share one group: they are the same
905
+ // job (read-only exploration of the repo) run over four
906
+ // questions, and a table that could set them apart would be
907
+ // four cells nobody has the trials to fill.
908
+ thinking: groupThinkingArgs('research'),
886
909
  ...(spec.tools ? { tools: spec.tools } : {}),
887
910
  ...(spec.extensions ? { extensions: spec.extensions } : {}),
888
911
  // 5B SCALE arm — null unless both env vars are set. Only the
@@ -1453,7 +1476,7 @@ extraDefects) {
1453
1476
  // Granting `read` here let it wander the repo to "verify" findings,
1454
1477
  // which made the supposedly-cheap pass cost as much as a rewrite
1455
1478
  // (observed ~133s). The judgement needs no file access.
1456
- verdict = await runPhaseChild(deps, 'critique-triage', '', appendNoThink(CRITIQUE_TRIAGE_PROMPT(spec, refined, qa, contractsBlock)));
1479
+ verdict = await runPhaseChild(deps, 'critique-triage', '', CRITIQUE_TRIAGE_PROMPT(spec, refined, qa, contractsBlock));
1457
1480
  }
1458
1481
  catch {
1459
1482
  verdict = null;
@@ -5,29 +5,6 @@
5
5
  * effects, trivially testable.
6
6
  */
7
7
  export declare const MAX_GRILL_QUESTIONS = 20;
8
- /**
9
- * Qwen3 "soft switch": placing `/no_think` in the prompt disables the model's
10
- * <think> reasoning trace for that turn and persists across the tool-call loop
11
- * within the same child session.
12
- *
13
- * On a local reasoning model decode is the bottleneck (~50 t/s here) while
14
- * prefill is ~10x faster, so cost is dominated by *generated* tokens. A runaway
15
- * think trace can be 10k+ tokens — minutes — even when the phase's real output
16
- * is a short list or a one-word verdict (an observed triage spent ~384s
17
- * thinking to emit "CLEAN"). Stripping the monologue does NOT limit the model's
18
- * exploration: it still calls every tool it wants and takes every step it
19
- * needs — it just stops narrating between actions.
20
- *
21
- * We strip thinking from the mechanical / exploration phases (refine, the four
22
- * research workers, verify-tooling, triage) and keep it ON for the judgment
23
- * phases (compose, grill, critique rewrite) where the reasoning earns its
24
- * decode cost. pi's `--thinking off` flag is a no-op for this provider
25
- * (`supportsReasoningEffort: false` in models.json), so the in-prompt soft
26
- * switch is the reliable control.
27
- */
28
- export declare const NO_THINK = "/no_think";
29
- /** Append the Qwen3 `/no_think` soft switch to a prompt. See {@link NO_THINK}. */
30
- export declare function appendNoThink(prompt: string): string;
31
8
  /**
32
9
  * Compress a verbose task title into a short status-bar label. Pure judgment, no
33
10
  * tools — the child reads only the title we hand it. The output is sanitised and
@@ -8,31 +8,6 @@
8
8
  // and the grill loop's total iterations, so a model that never emits NONE can't
9
9
  // run unbounded.
10
10
  export const MAX_GRILL_QUESTIONS = 20;
11
- /**
12
- * Qwen3 "soft switch": placing `/no_think` in the prompt disables the model's
13
- * <think> reasoning trace for that turn and persists across the tool-call loop
14
- * within the same child session.
15
- *
16
- * On a local reasoning model decode is the bottleneck (~50 t/s here) while
17
- * prefill is ~10x faster, so cost is dominated by *generated* tokens. A runaway
18
- * think trace can be 10k+ tokens — minutes — even when the phase's real output
19
- * is a short list or a one-word verdict (an observed triage spent ~384s
20
- * thinking to emit "CLEAN"). Stripping the monologue does NOT limit the model's
21
- * exploration: it still calls every tool it wants and takes every step it
22
- * needs — it just stops narrating between actions.
23
- *
24
- * We strip thinking from the mechanical / exploration phases (refine, the four
25
- * research workers, verify-tooling, triage) and keep it ON for the judgment
26
- * phases (compose, grill, critique rewrite) where the reasoning earns its
27
- * decode cost. pi's `--thinking off` flag is a no-op for this provider
28
- * (`supportsReasoningEffort: false` in models.json), so the in-prompt soft
29
- * switch is the reliable control.
30
- */
31
- export const NO_THINK = '/no_think';
32
- /** Append the Qwen3 `/no_think` soft switch to a prompt. See {@link NO_THINK}. */
33
- export function appendNoThink(prompt) {
34
- return `${prompt}\n\n${NO_THINK}`;
35
- }
36
11
  /**
37
12
  * Compress a verbose task title into a short status-bar label. Pure judgment, no
38
13
  * tools — the child reads only the title we hand it. The output is sanitised and
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Child NAME → reasoning group, for every child that goes through
3
+ * `runPhaseChild` / `runPlanningChild`.
4
+ *
5
+ * WHY KEYED ON THE NAME
6
+ * ---------------------
7
+ * The name is the only identifier in scope at all three spawn paths (phases,
8
+ * /task-auto planning, /task-plan), it is what the loader and the debug trail
9
+ * already print, and it is the one thing a reader can check against the phase
10
+ * list without following the call graph. Threading a group parameter through
11
+ * `PhaseDeps` / `AutoDeps` instead would touch both orchestrators' dep bags to
12
+ * express something the call site already says out loud.
13
+ *
14
+ * AN UNMAPPED NAME IS A BUILD FAILURE, not a silent `inherit`.
15
+ * `reasoning-groups.test.ts` scans every literal child name in src/ and fails if
16
+ * it is missing here. A defaulting lookup would let a phase added next year opt
17
+ * itself out of a measured setting without anyone deciding to — which is exactly
18
+ * how `/no_think` ended up applied to eight prompts and read by none of them.
19
+ *
20
+ * The gate, research and extraction groups are NOT here: those children reach the
21
+ * model through `runWorker` / `focusedChildArgs`, where the group is a property
22
+ * of the call site rather than of a name, and is passed directly.
23
+ */
24
+ import type { ReasoningGroup } from '../config/reasoning.js';
25
+ export declare const REASONING_GROUP_BY_CHILD: Readonly<Record<string, ReasoningGroup>>;
26
+ /**
27
+ * The group a named child belongs to.
28
+ *
29
+ * Returns `undefined` for a name the table does not know, and the CALLER decides
30
+ * what that means. `runPhaseChild` treats it as `inherit` — a child that reaches
31
+ * the model with today's argv is always safe — while the test treats it as a
32
+ * failure. That split is deliberate: the guard belongs at build time, where
33
+ * someone can fix it, not at run time, where it would abort a user's task over a
34
+ * missing table row.
35
+ */
36
+ export declare function reasoningGroupForChild(name: string): ReasoningGroup | undefined;
@@ -0,0 +1,36 @@
1
+ export const REASONING_GROUP_BY_CHILD = {
2
+ // ── phase: task/phases.ts + task/title-label.ts ──────────────────────────
3
+ refine: 'phase',
4
+ 'verify-tooling': 'phase',
5
+ 'grill-auto': 'phase',
6
+ 'grill-gen': 'phase',
7
+ compose: 'phase',
8
+ critique: 'phase',
9
+ 'critique-triage': 'phase',
10
+ 'compress-label': 'phase',
11
+ // ── planning: task/auto-orchestrator.ts ──────────────────────────────────
12
+ 'clarify-triage': 'planning',
13
+ 'auto-clarify': 'planning',
14
+ 'auto-decompose': 'planning',
15
+ 'requirement-extract': 'planning',
16
+ 'decompose-coverage': 'planning',
17
+ 'coverage-map': 'planning',
18
+ 'contract-extract': 'planning',
19
+ 'launch-extract': 'planning',
20
+ // ── plan: task/plan-orchestrator.ts ──────────────────────────────────────
21
+ 'plan-question': 'plan',
22
+ 'plan-answer': 'plan'
23
+ };
24
+ /**
25
+ * The group a named child belongs to.
26
+ *
27
+ * Returns `undefined` for a name the table does not know, and the CALLER decides
28
+ * what that means. `runPhaseChild` treats it as `inherit` — a child that reaches
29
+ * the model with today's argv is always safe — while the test treats it as a
30
+ * failure. That split is deliberate: the guard belongs at build time, where
31
+ * someone can fix it, not at run time, where it would abort a user's task over a
32
+ * missing table row.
33
+ */
34
+ export function reasoningGroupForChild(name) {
35
+ return REASONING_GROUP_BY_CHILD[name];
36
+ }
@@ -35,3 +35,31 @@ export declare function isCritiqueClean(text: string): boolean;
35
35
  */
36
36
  export declare function stripSpecPreamble(spec: string): string;
37
37
  export declare function validateSpecShape(spec: string): string | null;
38
+ /** The four sections REFINE_PROMPT demands, in the order it demands them. */
39
+ export declare const REFINE_SECTIONS: readonly ["GOAL", "CONSTRAINTS", "KNOWN-UNKNOWNS", "EXTERNAL-DEPENDENCIES"];
40
+ /**
41
+ * Is a refine child's output shaped like a refined prompt?
42
+ *
43
+ * Refine shipped with NO shape check at all — `phaseRefine` passes no validator,
44
+ * unlike compose and critique — and every downstream reader of a refined prompt
45
+ * is a PARTIAL parser that tolerates a missing section SILENTLY:
46
+ * `extractCapsSection` (refuted-constraint.ts) returns null, `scopedToolingGoal`
47
+ * (phases.ts) returns the whole text, `deriveTitle` and `extractEnrichTargets`
48
+ * fall back. So a refine answer that dropped a heading degrades four features at
49
+ * once and says nothing. This names the contract in one place.
50
+ *
51
+ * WHAT IT CHECKS, and why it is only this. Every one of those consumers looks
52
+ * for a BARE ALL-CAPS heading alone on its own line — `l.trim() === heading`,
53
+ * `/^GOAL[ \t]*\n/m`. That is the operative contract, so that is the test.
54
+ *
55
+ * It does NOT require the text to START with GOAL, even though REFINE_PROMPT
56
+ * says "four sections, exact headings, in this order" and forbids a preamble.
57
+ * Measured over the 57-task mx5 corpus: 55/56 non-empty refined prompts carry
58
+ * all four bare headings, but only 25/56 open with one — a preamble is what real
59
+ * refine output usually looks like, and production has always consumed it fine.
60
+ * A validator stricter than its consumers would reject work that works.
61
+ *
62
+ * Returns a problem string, or null when the shape is good — same contract as
63
+ * `validateSpecShape` above.
64
+ */
65
+ export declare function validateRefineShape(refined: string): string | null;
@@ -108,3 +108,47 @@ export function validateSpecShape(spec) {
108
108
  }
109
109
  return null;
110
110
  }
111
+ // ─── Refine shape gate ───────────────────────────────────────────────────────
112
+ /** The four sections REFINE_PROMPT demands, in the order it demands them. */
113
+ export const REFINE_SECTIONS = [
114
+ 'GOAL',
115
+ 'CONSTRAINTS',
116
+ 'KNOWN-UNKNOWNS',
117
+ 'EXTERNAL-DEPENDENCIES'
118
+ ];
119
+ /**
120
+ * Is a refine child's output shaped like a refined prompt?
121
+ *
122
+ * Refine shipped with NO shape check at all — `phaseRefine` passes no validator,
123
+ * unlike compose and critique — and every downstream reader of a refined prompt
124
+ * is a PARTIAL parser that tolerates a missing section SILENTLY:
125
+ * `extractCapsSection` (refuted-constraint.ts) returns null, `scopedToolingGoal`
126
+ * (phases.ts) returns the whole text, `deriveTitle` and `extractEnrichTargets`
127
+ * fall back. So a refine answer that dropped a heading degrades four features at
128
+ * once and says nothing. This names the contract in one place.
129
+ *
130
+ * WHAT IT CHECKS, and why it is only this. Every one of those consumers looks
131
+ * for a BARE ALL-CAPS heading alone on its own line — `l.trim() === heading`,
132
+ * `/^GOAL[ \t]*\n/m`. That is the operative contract, so that is the test.
133
+ *
134
+ * It does NOT require the text to START with GOAL, even though REFINE_PROMPT
135
+ * says "four sections, exact headings, in this order" and forbids a preamble.
136
+ * Measured over the 57-task mx5 corpus: 55/56 non-empty refined prompts carry
137
+ * all four bare headings, but only 25/56 open with one — a preamble is what real
138
+ * refine output usually looks like, and production has always consumed it fine.
139
+ * A validator stricter than its consumers would reject work that works.
140
+ *
141
+ * Returns a problem string, or null when the shape is good — same contract as
142
+ * `validateSpecShape` above.
143
+ */
144
+ export function validateRefineShape(refined) {
145
+ const trimmed = refined.trim();
146
+ if (trimmed.length === 0)
147
+ return 'refined prompt is empty';
148
+ const lines = trimmed.split('\n').map(l => l.trim());
149
+ const missing = REFINE_SECTIONS.filter(s => !lines.includes(s));
150
+ if (missing.length > 0) {
151
+ return `refined prompt missing required section(s): ${missing.join(', ')}`;
152
+ }
153
+ return null;
154
+ }
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { runPhaseChild } from './child-runner.js';
14
14
  import { LABEL_MAX, truncateLabel } from './parsers.js';
15
- import { COMPRESS_LABEL_PROMPT, appendNoThink } from './prompts.js';
15
+ import { COMPRESS_LABEL_PROMPT } from './prompts.js';
16
16
  /**
17
17
  * Reduce a raw model completion to a single clean label line: first non-empty
18
18
  * line, stripped of surrounding quotes/backticks and a leading "label:" echo,
@@ -37,7 +37,7 @@ export async function compressTitle(deps, title) {
37
37
  if (title.replace(/\s+/g, ' ').trim().length <= LABEL_MAX)
38
38
  return fallback;
39
39
  try {
40
- const raw = await runPhaseChild(deps, 'compress-label', '', appendNoThink(COMPRESS_LABEL_PROMPT(title, LABEL_MAX)));
40
+ const raw = await runPhaseChild(deps, 'compress-label', '', COMPRESS_LABEL_PROMPT(title, LABEL_MAX));
41
41
  const cleaned = sanitizeLabel(raw);
42
42
  if (cleaned.length === 0)
43
43
  return fallback;
@@ -10,6 +10,7 @@ import { npmVersionLookup as defaultNpmVersionLookup } from './npm-version.js';
10
10
  import { runChild } from '../shared/child-process.js';
11
11
  import { runFocusedExtraction } from './focused-extractor.js';
12
12
  import { buildExtractionPrompt } from './abstention.js';
13
+ import { groupThinkingArgs } from '../config/reasoning-args.js';
13
14
  const DEFAULT_LIMIT = PACKAGE_RETRIEVE_LIMIT;
14
15
  const DEFAULT_BUDGET = RETRIEVE_CONTENT_BUDGET;
15
16
  const NO_CACHE_HEAD = 25_000;
@@ -562,6 +563,9 @@ export async function docsFocused(input) {
562
563
  cwd: input.cwd,
563
564
  signal: input.signal,
564
565
  spawn,
566
+ // The `extraction` group's level. Resolved at the call site so the
567
+ // extractor itself never reads ambient config.
568
+ thinking: groupThinkingArgs('extraction'),
565
569
  abortedMessage: 'Docs lookup aborted.'
566
570
  });
567
571
  const base = {
@@ -1,6 +1,7 @@
1
1
  import { fetchAndClean as defaultFetchAndClean } from './html-clean.js';
2
2
  import { runFocusedExtraction } from './focused-extractor.js';
3
3
  import { abstentionSentence } from './abstention.js';
4
+ import { groupThinkingArgs } from '../config/reasoning-args.js';
4
5
  const CONTENT_BUDGET = 30_000;
5
6
  const HEAD_CHARS = 25_000;
6
7
  const TAIL_CHARS = 5_000;
@@ -79,6 +80,9 @@ export async function fetchFocused(input) {
79
80
  cwd: input.cwd,
80
81
  signal: input.signal,
81
82
  spawn: input.spawn,
83
+ // The `extraction` group's level. Resolved at the call site so the
84
+ // extractor itself never reads ambient config.
85
+ thinking: groupThinkingArgs('extraction'),
82
86
  abortedMessage: 'Fetch aborted.'
83
87
  });
84
88
  const base = {
@@ -8,7 +8,7 @@ import { type ExcerptVerification } from '../shared/child-output.js';
8
8
  * `--no-tools` is the contract, not a default: the child is given all the content it may use
9
9
  * inside its prompt, so a tool call could only reach for something unsourced.
10
10
  */
11
- export declare const focusedChildArgs: () => string[];
11
+ export declare const focusedChildArgs: (thinking?: readonly string[]) => string[];
12
12
  export interface FocusedRequest {
13
13
  /** The fully assembled prompt, including the content block. Delivered on stdin. */
14
14
  prompt: string;
@@ -33,6 +33,17 @@ export interface FocusedRequest {
33
33
  spawn?: SpawnFn;
34
34
  /** The message reported when the child was aborted, e.g. `'Docs lookup aborted.'`. */
35
35
  abortedMessage: string;
36
+ /**
37
+ * An already-resolved `['--thinking', level]` fragment, or `[]`/omitted to
38
+ * inherit the session default.
39
+ *
40
+ * Supplied by the CALLER even though all three call sites are the same
41
+ * `extraction` group. Resolving it inside this module would make
42
+ * `focusedChildArgs` read ambient config, and a function that reads config
43
+ * internally cannot be tested without the developer's own machine state —
44
+ * which is exactly how a `getConfig()` assertion got into a unit test.
45
+ */
46
+ thinking?: readonly string[];
36
47
  }
37
48
  /** What every outcome carries, success or failure — the raw child evidence. */
38
49
  interface FocusedChildEvidence {
@@ -37,7 +37,11 @@ import { formatChildFailure } from './shared.js';
37
37
  * `--no-tools` is the contract, not a default: the child is given all the content it may use
38
38
  * inside its prompt, so a tool call could only reach for something unsourced.
39
39
  */
40
- export const focusedChildArgs = () => [...childBaseArgs(), '--no-tools'];
40
+ export const focusedChildArgs = (thinking = []) => [
41
+ ...childBaseArgs(),
42
+ ...thinking,
43
+ '--no-tools'
44
+ ];
41
45
  /**
42
46
  * Run one focused extraction: spawn the no-tools child on `prompt`, and on success parse its
43
47
  * `<answer>`/`<excerpt>` and verify the excerpt against `verifyAgainst`.
@@ -47,7 +51,7 @@ export const focusedChildArgs = () => [...childBaseArgs(), '--no-tools'];
47
51
  */
48
52
  export async function runFocusedExtraction(req) {
49
53
  const spawn = req.spawn ?? defaultSpawn;
50
- const invocation = getPiInvocation(focusedChildArgs(), req.prompt);
54
+ const invocation = getPiInvocation(focusedChildArgs(req.thinking), req.prompt);
51
55
  const child = await runChild(spawn, invocation, req.cwd, req.signal);
52
56
  const evidence = {
53
57
  exitCode: child.exitCode,
@@ -3,10 +3,12 @@ import { registerPiWorkerSearch } from './pi-worker-search.js';
3
3
  import { registerPiWorkerFetch } from './pi-worker-fetch.js';
4
4
  import { registerPiWorkerDocs } from './pi-worker-docs.js';
5
5
  import { registerBraveKeyWarning } from './brave-warning.js';
6
+ import { registerReasoningWarning } from './reasoning-warning.js';
6
7
  export function registerWorkers(pi) {
7
8
  registerPiWorker(pi);
8
9
  registerPiWorkerSearch(pi);
9
10
  registerPiWorkerFetch(pi);
10
11
  registerPiWorkerDocs(pi);
11
12
  registerBraveKeyWarning(pi);
13
+ registerReasoningWarning(pi);
12
14
  }
@@ -33,6 +33,18 @@ export { isGroundingRetrieval } from './worker-channels.js';
33
33
  * at no particular column and does not repeat that shape.
34
34
  */
35
35
  export declare function hasAnswerContent(text: string): boolean;
36
+ /**
37
+ * Is ONE line an entry — a name, a gap, then a description — rather than prose?
38
+ *
39
+ * Split out of `hasAnswerContent` so the same rule can decide what a line IS,
40
+ * not just how many of them there are. A FILES section's paths are read back
41
+ * with it, and a scorer that used its own idea of an entry counted a preamble
42
+ * sentence and a leaked `</tool_call>` as invented paths.
43
+ *
44
+ * Prose wraps at no particular column, so it carries no two-space gap and no
45
+ * spaced dash; when it does, it ends in `.` or `:` and an entry does not.
46
+ */
47
+ export declare function isEntryLine(raw: string): boolean;
36
48
  /**
37
49
  * Frame a discarded attempt's output as work already done.
38
50
  *
@@ -177,6 +189,16 @@ export interface RunWorkerInput {
177
189
  chars: number;
178
190
  promptCharsBefore: number;
179
191
  }) => void;
192
+ /**
193
+ * An already-resolved `['--thinking', level]` fragment, or `[]`/omitted to
194
+ * inherit the session default exactly as before.
195
+ *
196
+ * Resolved by the CALLER because runWorker serves three different reasoning
197
+ * groups — the research workers, the post-implementation gates, and the
198
+ * ad-hoc `pi-worker` tool — and has nothing in its input that tells them
199
+ * apart. Guessing here would give a verify gate the research workers' level.
200
+ */
201
+ thinking?: readonly string[];
180
202
  /**
181
203
  * Called once per DISCARDED attempt, at the moment the worker decides to
182
204
  * re-spawn — the only window in which a restart is observable at all.
@@ -114,11 +114,22 @@ const CARRY_FORWARD_REASONS = new Set([
114
114
  * at no particular column and does not repeat that shape.
115
115
  */
116
116
  export function hasAnswerContent(text) {
117
- const entryish = text
118
- .split('\n')
119
- .map(l => l.replace(/^\s*(?:[-*•]|\d+[.)])\s+/, '').trim())
120
- .filter(l => /^\S.*?(?:\s{2,}|\s+[—–-]\s+)\S/.test(l) && !/[.:]$/.test(l));
121
- return entryish.length >= 2;
117
+ return text.split('\n').filter(isEntryLine).length >= 2;
118
+ }
119
+ /**
120
+ * Is ONE line an entry — a name, a gap, then a description — rather than prose?
121
+ *
122
+ * Split out of `hasAnswerContent` so the same rule can decide what a line IS,
123
+ * not just how many of them there are. A FILES section's paths are read back
124
+ * with it, and a scorer that used its own idea of an entry counted a preamble
125
+ * sentence and a leaked `</tool_call>` as invented paths.
126
+ *
127
+ * Prose wraps at no particular column, so it carries no two-space gap and no
128
+ * spaced dash; when it does, it ends in `.` or `:` and an entry does not.
129
+ */
130
+ export function isEntryLine(raw) {
131
+ const l = raw.replace(/^\s*(?:[-*•]|\d+[.)])\s+/, '').trim();
132
+ return /^\S.*?(?:\s{2,}|\s+[—–-]\s+)\S/.test(l) && !/[.:]$/.test(l);
122
133
  }
123
134
  /**
124
135
  * Frame a discarded attempt's output as work already done.
@@ -420,7 +431,14 @@ function commandWatch(timeoutMs) {
420
431
  }
421
432
  export async function runWorker(input) {
422
433
  const tools = input.tools ?? DEFAULT_TOOLS;
423
- const baseArgs = [...childBaseArgs(input.extensions ?? []), '--mode', 'json', '--tools', tools];
434
+ const baseArgs = [
435
+ ...childBaseArgs(input.extensions ?? []),
436
+ ...(input.thinking ?? []),
437
+ '--mode',
438
+ 'json',
439
+ '--tools',
440
+ tools
441
+ ];
424
442
  const timeoutMs = input.timeoutMs ?? RESEARCH_WORKER_TIMEOUT_MS;
425
443
  let hint = null;
426
444
  // Loop-kill and timeout share one restart budget, mirroring
@@ -14,6 +14,7 @@ import { normalizeQuery } from './research-cache.js';
14
14
  import { projectDocsRaw, buildProjectPrompt } from './docs-project.js';
15
15
  import { projectDocsBudget, projectDocsBudgetExhausted } from '../task/research-fanout-budget.js';
16
16
  import { isAbstention } from './abstention.js';
17
+ import { groupThinkingArgs } from '../config/reasoning-args.js';
17
18
  const RENDER_QUERY_MAX = 100;
18
19
  const Params = Type.Object({
19
20
  module: Type.String({
@@ -137,6 +138,9 @@ export function registerPiWorkerDocs(pi, internals = {}) {
137
138
  cwd: ctx.cwd,
138
139
  signal,
139
140
  spawn,
141
+ // The `extraction` group's level. Resolved at the call site so the
142
+ // extractor itself never reads ambient config.
143
+ thinking: groupThinkingArgs('extraction'),
140
144
  abortedMessage
141
145
  });
142
146
  // ── Project source lookup ───────────────────────────────────────
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { Text } from '@earendil-works/pi-tui';
10
10
  import { Type } from '@sinclair/typebox';
11
+ import { groupThinkingArgs } from '../config/reasoning-args.js';
11
12
  import { runWorker } from './pi-worker-core.js';
12
13
  import { childFailureReason, formatChildFailure, makeWorkerTool, workerAnswer, workerUnavailable } from './shared.js';
13
14
  const RENDER_PROMPT_MAX = 120;
@@ -38,7 +39,16 @@ export function registerPiWorker(pi) {
38
39
  + '- The task needs the web — use `pi-worker-search` / `pi-worker-fetch`',
39
40
  parameters: WorkerParams,
40
41
  async run(params, signal, ctx) {
41
- const result = await runWorker({ prompt: params.prompt, cwd: ctx.cwd, signal });
42
+ // Grouped with `research`: this is the same read-only exploration
43
+ // loop the four research workers run, just dispatched by a model
44
+ // rather than by the pipeline. Left ungrouped it would be the one
45
+ // child that never honoured a profile.
46
+ const result = await runWorker({
47
+ prompt: params.prompt,
48
+ cwd: ctx.cwd,
49
+ signal,
50
+ thinking: groupThinkingArgs('research')
51
+ });
42
52
  const details = { exitCode: result.exitCode };
43
53
  const failure = formatChildFailure(result, 'Worker aborted.');
44
54
  if (failure !== null) {