@mjasnikovs/pi-task 0.38.23 → 0.38.25

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 (48) hide show
  1. package/README.md +2 -2
  2. package/dist/config/reasoning-args.d.ts +12 -1
  3. package/dist/config/reasoning-args.js +5 -2
  4. package/dist/config/reasoning.d.ts +47 -6
  5. package/dist/config/reasoning.js +84 -9
  6. package/dist/config/register.d.ts +50 -26
  7. package/dist/config/register.js +96 -80
  8. package/dist/shared/reasoning-capability.d.ts +2 -5
  9. package/dist/shared/reasoning-capability.js +31 -4
  10. package/dist/task/auto-orchestrator.d.ts +2 -0
  11. package/dist/task/auto-orchestrator.js +28 -41
  12. package/dist/task/child-runner.d.ts +89 -24
  13. package/dist/task/child-runner.js +67 -46
  14. package/dist/task/gate-child.js +11 -11
  15. package/dist/task/orchestrator.d.ts +14 -20
  16. package/dist/task/orchestrator.js +12 -9
  17. package/dist/task/phases.d.ts +0 -23
  18. package/dist/task/phases.js +48 -464
  19. package/dist/task/question-dialog.d.ts +56 -0
  20. package/dist/task/question-dialog.js +53 -0
  21. package/dist/task/research-fanout-budget.d.ts +20 -0
  22. package/dist/task/research-fanout-budget.js +29 -0
  23. package/dist/task/research-worker.d.ts +183 -0
  24. package/dist/task/research-worker.js +429 -0
  25. package/dist/workers/brave-warning.js +4 -30
  26. package/dist/workers/docs-core.d.ts +8 -4
  27. package/dist/workers/docs-core.js +30 -21
  28. package/dist/workers/docs-lookup.d.ts +72 -0
  29. package/dist/workers/docs-lookup.js +53 -0
  30. package/dist/workers/docs-project.d.ts +9 -0
  31. package/dist/workers/docs-project.js +15 -0
  32. package/dist/workers/pi-worker-core.d.ts +112 -109
  33. package/dist/workers/pi-worker-core.js +33 -48
  34. package/dist/workers/pi-worker-docs.js +27 -31
  35. package/dist/workers/pi-worker.js +6 -0
  36. package/dist/workers/reasoning-warning.d.ts +10 -16
  37. package/dist/workers/reasoning-warning.js +25 -57
  38. package/dist/workers/session-hint.d.ts +37 -0
  39. package/dist/workers/session-hint.js +82 -0
  40. package/dist/workers/worker-failure.d.ts +34 -0
  41. package/dist/workers/worker-failure.js +27 -16
  42. package/dist/workers/worker-kill.d.ts +84 -0
  43. package/dist/workers/worker-kill.js +124 -0
  44. package/dist/workers/worker-profiles.d.ts +314 -0
  45. package/dist/workers/worker-profiles.js +220 -0
  46. package/package.json +1 -1
  47. package/dist/task/reasoning-groups.d.ts +0 -36
  48. package/dist/task/reasoning-groups.js +0 -36
@@ -29,6 +29,7 @@
29
29
  * actions. Those genuinely differ.
30
30
  */
31
31
  import type { AnswerSource } from './plan-io.js';
32
+ import type { YoloPick } from './yolo.js';
32
33
  /** One question awaiting an answer, in both the stored and the displayed form. */
33
34
  export interface PendingQuestion {
34
35
  /** Plain text — persisted, and fed back to the model. */
@@ -69,3 +70,58 @@ export declare function resolveAnswer(p: PendingQuestion, raw: string): {
69
70
  answer: string;
70
71
  source: AnswerSource;
71
72
  };
73
+ /**
74
+ * Everything one adaptive dialog needs to settle ONE question.
75
+ *
76
+ * `ui.ask` is typed structurally rather than as `SessionUI` so this module stays
77
+ * out of the remote bridge's import graph — the only thing it needs is the ask.
78
+ */
79
+ export interface SettleQuestionInput {
80
+ ui: {
81
+ ask: (spec: {
82
+ localTitle: string;
83
+ displayQuestion: string;
84
+ question: string;
85
+ recommended?: string;
86
+ recommended2?: string;
87
+ allowSkip: boolean;
88
+ options?: Array<{
89
+ label: string;
90
+ value: string;
91
+ }>;
92
+ }) => Promise<string | undefined>;
93
+ };
94
+ /** Where the settled answer is recorded. */
95
+ transcript: {
96
+ add: (kind: SettledKind, question: string, answer: string) => void;
97
+ };
98
+ /** The question, plain (persisted / fed back) and rendered (displayed). */
99
+ plain: string;
100
+ shown: string;
101
+ /** The recommendation and the alternative, as the model wrote them (markdown). */
102
+ suggested?: string;
103
+ alt?: string;
104
+ /** Render inline markdown for display. */
105
+ render: (md: string) => string;
106
+ /**
107
+ * This site's already-decided YOLO outcome. A PARAMETER, not a hook: yolo.ts
108
+ * states why the policy is per-site (grill has an anti-synthesis channel to
109
+ * step aside from, clarify runs before research and has none), and settling a
110
+ * question must not become the place that decides one.
111
+ */
112
+ yolo: YoloPick;
113
+ /** Run just before the ask — grill's "awaiting Qn" widget line. */
114
+ onAsk?: () => void;
115
+ }
116
+ /** The provenance kinds settling one question can produce. */
117
+ export type SettledKind = 'yolo' | 'yolo-skip' | 'accepted' | 'typed';
118
+ /**
119
+ * Settle one question: YOLO short-circuit, cards, ask, record.
120
+ *
121
+ * Returns `'cancelled'` rather than throwing, because that is the one thing the
122
+ * two callers genuinely disagree about — grill throws `USER_CANCELLED` into the
123
+ * phase ladder, clarify announces and returns null from the plan. Everything
124
+ * before it was written out twice at ~50 lines each and had already drifted on
125
+ * `recommended2`, harmless today only because the bridge re-guards it.
126
+ */
127
+ export declare function settleQuestion(input: SettleQuestionInput): Promise<'settled' | 'cancelled'>;
@@ -28,6 +28,7 @@
28
28
  * widget line, clarify's plan-shape and triage pre-emption, plan's control
29
29
  * actions. Those genuinely differ.
30
30
  */
31
+ import { stripInlineMarkdown } from './inline-markdown.js';
31
32
  /** True when the question is a binary fork rather than a single recommendation. */
32
33
  export function isTwoOption(p) {
33
34
  return p.suggested !== undefined && p.alt !== undefined;
@@ -87,3 +88,55 @@ export function resolveAnswer(p, raw) {
87
88
  }
88
89
  return { answer: typed, source: 'typed' };
89
90
  }
91
+ /**
92
+ * Settle one question: YOLO short-circuit, cards, ask, record.
93
+ *
94
+ * Returns `'cancelled'` rather than throwing, because that is the one thing the
95
+ * two callers genuinely disagree about — grill throws `USER_CANCELLED` into the
96
+ * phase ladder, clarify announces and returns null from the plan. Everything
97
+ * before it was written out twice at ~50 lines each and had already drifted on
98
+ * `recommended2`, harmless today only because the bridge re-guards it.
99
+ */
100
+ export async function settleQuestion(input) {
101
+ const { ui, transcript, plain, shown, render, yolo } = input;
102
+ const plainSuggested = input.suggested === undefined ? undefined : stripInlineMarkdown(input.suggested);
103
+ const plainAlt = input.alt === undefined ? undefined : stripInlineMarkdown(input.alt);
104
+ if (yolo !== null) {
105
+ // The stamp is a RECORD fact, and which kinds are stamped is the
106
+ // transcript policy's call — never this call site's.
107
+ if (yolo.kind === 'answer') {
108
+ transcript.add('yolo', plain, stripInlineMarkdown(yolo.answer));
109
+ }
110
+ else {
111
+ transcript.add('yolo-skip', plain, `(skipped — ${yolo.note})`);
112
+ }
113
+ return 'settled';
114
+ }
115
+ const pending = {
116
+ plain,
117
+ shown,
118
+ ...(plainSuggested !== undefined && {
119
+ suggested: plainSuggested,
120
+ shownSuggested: render(input.suggested)
121
+ }),
122
+ ...(plainAlt !== undefined && { alt: plainAlt, shownAlt: render(input.alt) })
123
+ };
124
+ const options = buildOptionCards(pending);
125
+ input.onAsk?.();
126
+ const a = await ui.ask({
127
+ localTitle: shown,
128
+ displayQuestion: shown,
129
+ question: plain,
130
+ recommended: plainSuggested,
131
+ ...(plainAlt !== undefined && { recommended2: plainAlt }),
132
+ allowSkip: plainSuggested === undefined && plainAlt === undefined,
133
+ ...(options && { options })
134
+ });
135
+ if (a === undefined)
136
+ return 'cancelled';
137
+ // An accept covers both routes to it: submitting empty, and pressing the
138
+ // single green card.
139
+ const resolved = resolveAnswer(pending, a);
140
+ transcript.add(resolved.source === 'accepted' ? 'accepted' : 'typed', plain, resolved.answer);
141
+ return 'settled';
142
+ }
@@ -81,6 +81,26 @@ export declare const WORKER_CARRY_FORWARD_ENV = "PI_TASK_WORKER_CARRY_FORWARD";
81
81
  /** RESCUE: deadline on lack of progress instead of elapsed time. Value = absolute ceiling, ms. */
82
82
  export declare const WORKER_PROGRESS_CEILING_ENV = "PI_TASK_WORKER_PROGRESS_CEILING_MS";
83
83
  type Env = (key: string) => string | undefined;
84
+ /**
85
+ * Every lever env var this module owns.
86
+ *
87
+ * Exists so `snapshotLeverEnv` cannot drift from the levers: adding a lever
88
+ * without adding it here would leave that one lever read LATE, which is the
89
+ * half-applied arm the snapshot exists to prevent.
90
+ */
91
+ export declare const RESEARCH_LEVER_ENVS: readonly string[];
92
+ /**
93
+ * The levers, read ONCE, as a reader the profile table can be handed.
94
+ *
95
+ * WHY A SNAPSHOT AND NOT `process.env`. Every worker in one research phase must
96
+ * see the same arm. The three lever values used to be resolved once in
97
+ * `phases.ts` and threaded down as three separate `ResearchWorkerRun` fields for
98
+ * exactly that reason; moving the resolution into the `research` profile would
99
+ * have moved the READ down to each worker with it, and a harness that flips a
100
+ * var mid-phase would then half-apply its own arm. Freezing the reader keeps the
101
+ * read-once property while letting the profile own what the values MEAN.
102
+ */
103
+ export declare function snapshotLeverEnv(env?: Env): Env;
84
104
  /**
85
105
  * The CAP arm's budget, or null when the lever is off (the shipped default).
86
106
  * A non-numeric or non-positive value is off too: a typo'd env var must not
@@ -81,6 +81,35 @@ export const WORKER_CARRY_FORWARD_ENV = 'PI_TASK_WORKER_CARRY_FORWARD';
81
81
  /** RESCUE: deadline on lack of progress instead of elapsed time. Value = absolute ceiling, ms. */
82
82
  export const WORKER_PROGRESS_CEILING_ENV = 'PI_TASK_WORKER_PROGRESS_CEILING_MS';
83
83
  const defaultEnv = key => process.env[key];
84
+ /**
85
+ * Every lever env var this module owns.
86
+ *
87
+ * Exists so `snapshotLeverEnv` cannot drift from the levers: adding a lever
88
+ * without adding it here would leave that one lever read LATE, which is the
89
+ * half-applied arm the snapshot exists to prevent.
90
+ */
91
+ export const RESEARCH_LEVER_ENVS = [
92
+ PROJECT_DOCS_BUDGET_ENV,
93
+ FANOUT_TIMEOUT_PER_LOOKUP_ENV,
94
+ FANOUT_TIMEOUT_CEILING_ENV,
95
+ WORKER_CARRY_FORWARD_ENV,
96
+ WORKER_PROGRESS_CEILING_ENV
97
+ ];
98
+ /**
99
+ * The levers, read ONCE, as a reader the profile table can be handed.
100
+ *
101
+ * WHY A SNAPSHOT AND NOT `process.env`. Every worker in one research phase must
102
+ * see the same arm. The three lever values used to be resolved once in
103
+ * `phases.ts` and threaded down as three separate `ResearchWorkerRun` fields for
104
+ * exactly that reason; moving the resolution into the `research` profile would
105
+ * have moved the READ down to each worker with it, and a harness that flips a
106
+ * var mid-phase would then half-apply its own arm. Freezing the reader keeps the
107
+ * read-once property while letting the profile own what the values MEAN.
108
+ */
109
+ export function snapshotLeverEnv(env = defaultEnv) {
110
+ const snap = new Map(RESEARCH_LEVER_ENVS.map(k => [k, env(k)]));
111
+ return key => snap.get(key);
112
+ }
84
113
  function positiveInt(raw) {
85
114
  if (raw === undefined)
86
115
  return null;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * ONE research worker, cache-skip to persist.
3
+ *
4
+ * WHY IT IS A MODULE. This was a 228-line closure inside `phaseResearch` over
5
+ * eleven locals, and inside it live the three RETRY GATES and their precedence:
6
+ * the EMPTY-SECTION gate (the only one that can fail the phase), the
7
+ * ZERO-RETRIEVAL gate and the SILENT gate (both of which discard a failed retry
8
+ * and ship the original). Getting that order wrong is how a run either dies on a
9
+ * legitimately empty section or ships one written from memory.
10
+ *
11
+ * The cost was in the TESTS. Reaching the gates meant a temp dir, a real task
12
+ * file, and a fake spawn routed on prose lifted out of `prompts.ts` — plus, for
13
+ * attempt-1-vs-attempt-2, a second sentence lifted out of a module-private
14
+ * preamble constant. In a codebase whose whole workflow is re-wording prompts and
15
+ * measuring what changed, that means a reworded preamble silently stops the gate
16
+ * tests from testing the gate. Behind this interface a test scripts
17
+ * `runWorker(label, attempt)` and states the `RunWorkerResult` fields a gate
18
+ * reads.
19
+ *
20
+ * THE INVARIANT, stated once: the empty gate runs FIRST and is the only one that
21
+ * can throw; the other two keep the original when their retry does not improve a
22
+ * named measure; and `confirmedEmpty` suppresses the silent gate, because that
23
+ * retry already asked "you wrote nothing" and got the same answer.
24
+ */
25
+ import type { RunWorkerInput, RunWorkerResult } from '../workers/pi-worker-core.js';
26
+ import type { SpawnFn } from '../shared/child-process.js';
27
+ import type { DebugLine } from './debug-log.js';
28
+ /**
29
+ * One research worker's row. `section` is the heading its output is assembled
30
+ * and cached under; `label` is its child NAME — what the loader, the debug trail
31
+ * and the A/B ledgers print, and the key into `REASONING_GROUP_BY_CHILD`.
32
+ */
33
+ export interface ResearchWorkerSpec {
34
+ section: string;
35
+ label: string;
36
+ /** Static, or built from the sections completed so far (serial mode hands
37
+ * APIS the finished FILES map; parallel mode hands it nothing). */
38
+ prompt: string | ((prior: ReadonlyArray<{
39
+ name: string;
40
+ text: string;
41
+ }>) => string);
42
+ tools?: string;
43
+ extensions?: string[];
44
+ /** Deterministic gate over the worker's own output, run BEFORE the section is
45
+ * persisted, so nothing it rejects survives into the cache or into compose. */
46
+ postProcess?: (text: string) => string;
47
+ /** When set, a non-empty section produced with ZERO grounding-retrieval calls is
48
+ * re-run ONCE with this preamble prepended, forcing a retrieval-first pass. The
49
+ * retry replaces the original only if it actually retrieved. */
50
+ zeroRetrievalRetry?: string;
51
+ /** When set, a section that comes out SILENT — zero parseable bullets from a
52
+ * loop-degrade banner or a hallucinated non-bullet fragment — is re-run ONCE
53
+ * with this preamble. A legitimately-empty section is NOT retried. */
54
+ retryIfSilent?: string;
55
+ /** This worker can issue project-source docs lookups, so the 5B fan-out bounds
56
+ * apply to it (see task/research-fanout-budget.ts). */
57
+ fanoutBounded?: true;
58
+ }
59
+ /**
60
+ * The small record the driver needs: one way to run a worker, and where to put
61
+ * what comes back.
62
+ *
63
+ * Everything here is a fact about THIS RUN, not about the worker — which is what
64
+ * lets the four rows be plain data and the driver be a function of two arguments.
65
+ */
66
+ export interface ResearchWorkerRun {
67
+ runWorker: (label: string, input: RunWorkerInput) => Promise<RunWorkerResult>;
68
+ cwd: string;
69
+ taskId: string;
70
+ signal: AbortSignal;
71
+ spawn?: SpawnFn;
72
+ /** The `--thinking` fragment for a named child. */
73
+ thinkingFor: (label: string) => string[];
74
+ logDebug?: (msg: string, kind?: DebugLine) => void;
75
+ onChildOutput?: (line: string) => void;
76
+ /** Record one finished worker's timing splits. */
77
+ record: (label: string, p: Promise<RunWorkerResult>) => Promise<RunWorkerResult>;
78
+ /** Called once per worker that reaches an outcome, cached ones included. */
79
+ onDone: () => void;
80
+ /**
81
+ * Read one worker's cached output back, or '' when there is none.
82
+ *
83
+ * A SEAM, and the symmetric half of `persistSection`. The cache skip is one
84
+ * of the four outcomes this driver has, and reaching it used to require a
85
+ * real task file on disk — which is most of why the gate tests needed a temp
86
+ * dir at all.
87
+ */
88
+ readCached: (heading: string) => Promise<string>;
89
+ /** Write one validated section to the task file. Serialised by the caller. */
90
+ persistSection: (heading: string, text: string) => Promise<void>;
91
+ /**
92
+ * The 5B lever env vars, READ ONCE for the whole phase.
93
+ *
94
+ * Was three resolved values (`carryForward`, `fanoutTimeout`,
95
+ * `progressCeilingMs`). It is one frozen reader now because the `research`
96
+ * profile owns what those values mean; what this layer still owns is that
97
+ * every worker in a run sees the SAME arm, which a live `process.env` read
98
+ * per worker would lose. See `snapshotLeverEnv`.
99
+ */
100
+ leverEnv: (key: string) => string | undefined;
101
+ }
102
+ /**
103
+ * Task-file heading under which a research worker's validated output is cached.
104
+ * A resumed research phase reads these to skip workers that already succeeded,
105
+ * instead of re-running all four from scratch when one of them fails — the
106
+ * expensive case (e.g. 3 healthy workers thrown away because the 4th looped).
107
+ */
108
+ export declare function researchWorkerCacheHeading(section: string): string;
109
+ /**
110
+ * Classify a research worker's result so the phase can react per-worker instead
111
+ * of treating every failure the same. Two distinct failure shapes:
112
+ *
113
+ * - 'runaway' (loop-kill OR per-worker wall-clock timeout): the worker explored
114
+ * too long and was killed *after* burning its MAX_LOOP_RESTARTS restarts. It
115
+ * did real work and left partial text; the other three workers are unaffected.
116
+ * Failing the whole task here would throw away every already-good worker AND
117
+ * abort the entire auto-run over the weakest section — and because the loop is
118
+ * deterministic, a resume just re-loops and re-fails. So this DEGRADES: keep
119
+ * the partial answer (marked), cache it, move on. A loop-kill is a SIGTERM
120
+ * (exit 143) OR a clean exit 0 with truncated text, so loopHit/timedOut — not
121
+ * exitCode — are the reliable signal and are checked first.
122
+ *
123
+ * - 'fatal' (non-zero exit that isn't a loop-kill, a provider error behind an
124
+ * empty answer, or a leaked never-executed tool call): the output is
125
+ * untrustworthy in a way partial text can't paper over (broken env, model
126
+ * disconnect, wrong tool-call dialect). These still throw — degrading them
127
+ * would launder a real breakage into a plausible-looking section.
128
+ *
129
+ * - 'empty' (clean exit 0, no provider error, no loop/timeout — the model simply
130
+ * wrote nothing): NOT a failure. On an extremely simple task ("create a folder
131
+ * with an index.html in it") three of the four workers have genuinely nothing
132
+ * to report, and each worker prompt tells the model to emit ONLY what this task
133
+ * touches and to drop everything else — so silence is the CORRECT answer and
134
+ * was killing the whole task at research (issue #10). Measured live on the
135
+ * issue's own prompt (30 reps/worker, local Qwen3.6-27B): every APIS answer was
136
+ * semantically "there is nothing here", and 2/30 were literally zero bytes on a
137
+ * clean exit — the other 28 survived only because the model happened to wrap the
138
+ * same non-answer in a parenthetical, which is model style, not signal. The
139
+ * caller retries once and then accepts an explicit empty section; what stays
140
+ * fatal is silence WITH a reported cause, which is the masked-disconnect case
141
+ * this branch was written for and which `modelError` now names outright.
142
+ *
143
+ * Returns null when the result is trustworthy.
144
+ */
145
+ export declare function classifyResearchWorker(name: string, result: RunWorkerResult): {
146
+ kind: 'runaway';
147
+ reason: string;
148
+ } | {
149
+ kind: 'fatal';
150
+ error: Error;
151
+ } | {
152
+ kind: 'empty';
153
+ } | null;
154
+ /**
155
+ * Build a degraded section body for a runaway worker: a one-line marker naming
156
+ * the failure (so downstream phases and a human reading the task file know this
157
+ * section is incomplete) followed by whatever partial answer the worker streamed
158
+ * before it was killed. The marker is always present even when there is no
159
+ * partial text, so an empty degrade is never mistaken for a real finding.
160
+ */
161
+ export declare function degradedSectionBody(name: string, reason: string, partial: string): string;
162
+ /**
163
+ * The body written for a research section the worker confirmed has no entries.
164
+ *
165
+ * Three states have to stay distinguishable to anyone — human or later phase —
166
+ * reading a research section, so each carries its own marker:
167
+ * `(none — …)` the worker RAN and answered "nothing applies" (this)
168
+ * `(degraded: …)` the worker was killed mid-answer, text may be partial
169
+ * (degradedSectionBody)
170
+ * section absent the worker never got that far — the phase threw
171
+ *
172
+ * Naming the worker inside the marker keeps it true after assembly, where the
173
+ * section headings are all that separate the four workers' output.
174
+ */
175
+ export declare function emptySectionBody(name: string): string;
176
+ export declare function isBareNoneAnswer(text: string): boolean;
177
+ export declare function runResearchWorker(spec: ResearchWorkerSpec, run: ResearchWorkerRun, prior?: ReadonlyArray<{
178
+ name: string;
179
+ text: string;
180
+ }>): Promise<{
181
+ name: string;
182
+ text: string;
183
+ }>;