@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.
- package/README.md +1 -0
- package/dist/config/config.d.ts +19 -0
- package/dist/config/config.js +10 -2
- package/dist/config/reasoning-args.d.ts +10 -0
- package/dist/config/reasoning-args.js +23 -0
- package/dist/config/reasoning.d.ts +160 -0
- package/dist/config/reasoning.js +530 -0
- package/dist/config/register.d.ts +72 -2
- package/dist/config/register.js +182 -33
- package/dist/shared/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
package/dist/task/phases.js
CHANGED
|
@@ -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
|
|
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',
|
|
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',
|
|
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
|
|
731
|
+
// llama.cpp backend (single GPU, same task/model) — and the answer FLIPS
|
|
721
732
|
// with thinking:
|
|
722
|
-
// - thinking ON
|
|
733
|
+
// - thinking ON → parallel wins: long decodes batch well, 4 concurrent
|
|
723
734
|
// finish in ~max(worker), not the sum.
|
|
724
|
-
// -
|
|
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
|
-
//
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
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:
|
|
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 =>
|
|
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:
|
|
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:
|
|
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', '',
|
|
1479
|
+
verdict = await runPhaseChild(deps, 'critique-triage', '', CRITIQUE_TRIAGE_PROMPT(spec, refined, qa, contractsBlock));
|
|
1457
1480
|
}
|
|
1458
1481
|
catch {
|
|
1459
1482
|
verdict = null;
|
package/dist/task/prompts.d.ts
CHANGED
|
@@ -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
|
package/dist/task/prompts.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/task/title-label.js
CHANGED
|
@@ -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
|
|
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', '',
|
|
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 = () => [
|
|
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,
|
package/dist/workers/index.js
CHANGED
|
@@ -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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 = [
|
|
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
|
-
|
|
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) {
|