@mjasnikovs/pi-task 0.23.1 → 0.24.1

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.
@@ -108,6 +108,21 @@ export declare function scopedToolingGoal(refined: string): string;
108
108
  * partial text, so an empty degrade is never mistaken for a real finding.
109
109
  */
110
110
  export declare function degradedSectionBody(name: string, reason: string, partial: string): string;
111
+ /**
112
+ * The body written for a research section the worker confirmed has no entries.
113
+ *
114
+ * Three states have to stay distinguishable to anyone — human or later phase —
115
+ * reading a research section, so each carries its own marker:
116
+ * `(none — …)` the worker RAN and answered "nothing applies" (this)
117
+ * `(degraded: …)` the worker was killed mid-answer, text may be partial
118
+ * (degradedSectionBody)
119
+ * section absent the worker never got that far — the phase threw
120
+ *
121
+ * Naming the worker inside the marker keeps it true after assembly, where the
122
+ * section headings are all that separate the four workers' output.
123
+ */
124
+ export declare function emptySectionBody(name: string): string;
125
+ export declare function isBareNoneAnswer(text: string): boolean;
111
126
  export declare function phaseResearch(deps: PhaseDeps, refined: string, researchDeps?: PhaseResearchDeps): Promise<string>;
112
127
  export interface PhaseAutoAnswerDeps {
113
128
  docsFocused?: typeof docsFocused;
@@ -55,6 +55,12 @@ export function extractToolingCommands(research) {
55
55
  const line = raw.trim();
56
56
  if (!line)
57
57
  continue;
58
+ // A section MARKER describes the worker, not a command. Without this the
59
+ // "(none — the TOOLING worker ran …)" / "(degraded: …)" line is handed to the
60
+ // verify child as a command to run, which can only be rejected — noise in the
61
+ // prompt and one more thing that reads like a real tool in the task file.
62
+ if (/^\((?:none —|degraded:)/.test(line))
63
+ continue;
58
64
  const match = line.match(/^\S.*?\s{2,}(.+)$/);
59
65
  if (match) {
60
66
  commands.push(match[1].trim());
@@ -300,11 +306,27 @@ export function scopedToolingGoal(refined) {
300
306
  * (exit 143) OR a clean exit 0 with truncated text, so loopHit/timedOut — not
301
307
  * exitCode — are the reliable signal and are checked first.
302
308
  *
303
- * - 'fatal' (non-zero exit that isn't a loop-kill, empty output, or a leaked
304
- * never-executed tool call): the output is untrustworthy in a way partial text
305
- * can't paper over (broken env, model disconnect, wrong tool-call dialect).
306
- * These still throw — degrading them would launder a real breakage into a
307
- * plausible-looking section. Returns null when the result is trustworthy.
309
+ * - 'fatal' (non-zero exit that isn't a loop-kill, a provider error behind an
310
+ * empty answer, or a leaked never-executed tool call): the output is
311
+ * untrustworthy in a way partial text can't paper over (broken env, model
312
+ * disconnect, wrong tool-call dialect). These still throw — degrading them
313
+ * would launder a real breakage into a plausible-looking section.
314
+ *
315
+ * - 'empty' (clean exit 0, no provider error, no loop/timeout — the model simply
316
+ * wrote nothing): NOT a failure. On an extremely simple task ("create a folder
317
+ * with an index.html in it") three of the four workers have genuinely nothing
318
+ * to report, and each worker prompt tells the model to emit ONLY what this task
319
+ * touches and to drop everything else — so silence is the CORRECT answer and
320
+ * was killing the whole task at research (issue #10). Measured live on the
321
+ * issue's own prompt (30 reps/worker, local Qwen3.6-27B): every APIS answer was
322
+ * semantically "there is nothing here", and 2/30 were literally zero bytes on a
323
+ * clean exit — the other 28 survived only because the model happened to wrap the
324
+ * same non-answer in a parenthetical, which is model style, not signal. The
325
+ * caller retries once and then accepts an explicit empty section; what stays
326
+ * fatal is silence WITH a reported cause, which is the masked-disconnect case
327
+ * this branch was written for and which `modelError` now names outright.
328
+ *
329
+ * Returns null when the result is trustworthy.
308
330
  */
309
331
  function classifyResearchWorker(name, result) {
310
332
  if (result.loopHit) {
@@ -326,7 +348,33 @@ function classifyResearchWorker(name, result) {
326
348
  };
327
349
  }
328
350
  if (result.text.trim().length === 0) {
329
- return { kind: 'fatal', error: new Error(`Research ${name} worker produced no output`) };
351
+ // NOTHING CAME BACK two different events wear the same face, and the whole
352
+ // point of this branch is to tell them apart:
353
+ //
354
+ // FAILED, cause reported: pi delivers a failed turn as an empty assistant
355
+ // message with stopReason "error" and exit 0, so the real cause used to be
356
+ // discarded and reported as the useless "produced no output". Name it.
357
+ // FAILED, child never spoke: no stdout at all means the child died before it
358
+ // could run (unresolvable provider, missing key, bad argv) — it never
359
+ // answered, so it cannot have answered "nothing".
360
+ // EMPTY: a child that streamed, exited 0, reported no error, and wrote no
361
+ // answer. The worker ran and the model had nothing to say — a real answer
362
+ // on a task that touches nothing, not a failure.
363
+ if (result.modelError) {
364
+ return {
365
+ kind: 'fatal',
366
+ error: new Error(`Research ${name} worker: model error — ${result.modelError.slice(0, 200)}`)
367
+ };
368
+ }
369
+ if (!result.sawOutput) {
370
+ return {
371
+ kind: 'fatal',
372
+ error: new Error(`Research ${name} worker produced no output — the child never wrote a `
373
+ + 'single byte, so it died before it could answer'
374
+ + (result.stderr ? `: ${result.stderr.slice(-300)}` : ''))
375
+ };
376
+ }
377
+ return { kind: 'empty' };
330
378
  }
331
379
  if (result.leakedToolCall) {
332
380
  return {
@@ -365,6 +413,67 @@ async function manifestDependencyNames(cwd) {
365
413
  return [];
366
414
  }
367
415
  }
416
+ /**
417
+ * The body written for a research section the worker confirmed has no entries.
418
+ *
419
+ * Three states have to stay distinguishable to anyone — human or later phase —
420
+ * reading a research section, so each carries its own marker:
421
+ * `(none — …)` the worker RAN and answered "nothing applies" (this)
422
+ * `(degraded: …)` the worker was killed mid-answer, text may be partial
423
+ * (degradedSectionBody)
424
+ * section absent the worker never got that far — the phase threw
425
+ *
426
+ * Naming the worker inside the marker keeps it true after assembly, where the
427
+ * section headings are all that separate the four workers' output.
428
+ */
429
+ export function emptySectionBody(name) {
430
+ return `(none — the ${name} worker ran and reported no entries for this task)`;
431
+ }
432
+ /**
433
+ * A worker answer that IS the word "nothing" and carries no other content:
434
+ * `(none)`, `N/A`, `- none`, `(no content)`, `(no entries)`. Live workers write
435
+ * these often on a task that touches nothing (measured on the issue's prompt:
436
+ * `(no content)`, `(no response)`, a bare `(none)` from the gate's own retry),
437
+ * and each one means exactly what an empty answer means — so they are recorded
438
+ * with the same marker rather than passed through in whatever shape the model
439
+ * happened to pick. Deliberately NARROW: it matches only a lone token, never
440
+ * prose like "(no APIs to list — this task creates a plain HTML file …)", which
441
+ * carries a reason worth keeping.
442
+ */
443
+ const BARE_NONE_ANSWER = /^[-*\s]*\(?\s*(?:none|n\/?a|nothing|no (?:content|entries|response|items|results))\s*\.?\s*\)?\s*$/i;
444
+ export function isBareNoneAnswer(text) {
445
+ return BARE_NONE_ANSWER.test(text.trim());
446
+ }
447
+ /**
448
+ * Prepended on the ONE retry the empty-section gate triggers. A zero-byte answer is
449
+ * ambiguous — a crashed worker looks exactly like a worker with nothing to say — so
450
+ * the retry's only job is to remove the ambiguity: answer properly, or say "(none)"
451
+ * in as many words.
452
+ *
453
+ * It must NOT turn into an invitation to skip the work: `(none)` is offered only
454
+ * behind an explicit "after you have looked" condition, because this retry also
455
+ * fires on a normal project where the first attempt died for an unrelated reason,
456
+ * and an easy opt-out there would silence real research.
457
+ *
458
+ * MEASUREMENT OPEN. The recovery path's QUALITY on a real repo is being measured
459
+ * (scripts live under /home/edgars/tmp/issue10: first FILES answer faulted to
460
+ * empty, every other child live, against an uninterrupted control). First rep on
461
+ * an earlier wording did NOT take the `(none)` exit but drifted into writing code
462
+ * instead of listing paths — the deliverable-not-inputs failure the base prompt
463
+ * already forbids below this preamble. Blast radius is bounded: the gate fires
464
+ * only on a run that would otherwise have FAILED outright, so a mediocre recovered
465
+ * section is strictly better than the dead task it replaces — but if the drift
466
+ * reproduces, this preamble must restate the section's output contract, not just
467
+ * demand an answer.
468
+ */
469
+ const EMPTY_SECTION_PREAMBLE = 'STOP. Your previous attempt returned an EMPTY answer — zero characters. An empty '
470
+ + 'response cannot be accepted, because it is indistinguishable from a worker that '
471
+ + 'crashed before it wrote anything. Answer again now, and do the research properly '
472
+ + 'this time: look first, then write what you found, in the required format. Only if '
473
+ + 'you have looked and there is genuinely nothing to report — the task touches no '
474
+ + 'existing file, needs no external symbol, or the project has no such tooling — write '
475
+ + 'exactly `(none)` and nothing else. Do not answer `(none)` to avoid the work, and '
476
+ + 'never answer with silence.';
368
477
  /**
369
478
  * Prepended to worker:apis's prompt on the ONE retry the zero-retrieval gate triggers. It
370
479
  * names the exact failure (a section written with no retrieval) so the correction is concrete,
@@ -638,6 +747,35 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
638
747
  }
639
748
  }));
640
749
  let r = await runOnce();
750
+ // EMPTY-SECTION GATE (issue #10). A worker that returns zero bytes on a clean run
751
+ // used to fail the whole task ("Research APIS worker produced no output"), which is
752
+ // exactly what an extremely simple task provokes: with nothing on disk to survey and
753
+ // no external symbol in play, silence is the correct answer and the run died on it.
754
+ // Retry ONCE — silence is genuinely ambiguous, and a worker that crashed before
755
+ // writing deserves a second attempt — then accept an explicitly empty section. A
756
+ // provider error behind the silence is classified fatal below and never reaches here.
757
+ let confirmedEmpty = false;
758
+ if (classifyResearchWorker(spec.section, r)?.kind === 'empty') {
759
+ deps.logDebug?.(`${spec.label}: EMPTY answer on a clean exit — retrying once before`
760
+ + ' accepting the section as having no entries');
761
+ deps.onChildOutput?.(`${spec.label}: empty — retrying`);
762
+ const retry = await runOnce(EMPTY_SECTION_PREAMBLE);
763
+ if (retry.text.trim().length > 0) {
764
+ deps.logDebug?.(`${spec.label}: retry answered (len=${retry.text.trim().length})`
765
+ + ' — replacing the empty section');
766
+ r = retry;
767
+ }
768
+ else {
769
+ confirmedEmpty = classifyResearchWorker(spec.section, retry)?.kind === 'empty';
770
+ deps.logDebug?.(`${spec.label}: retry STILL empty — `
771
+ + (confirmedEmpty ?
772
+ 'the worker ran twice and reported no entries; recording the'
773
+ + ' section as empty (NOT a failure)'
774
+ : 'and this attempt did not run cleanly — failing the phase'));
775
+ if (!confirmedEmpty)
776
+ r = retry;
777
+ }
778
+ }
641
779
  // ZERO-RETRIEVAL GATE — a deterministic handle, not another instruction. A non-empty
642
780
  // section produced with no grounding-retrieval call was written from memory; retry ONCE
643
781
  // with a forced retrieval-first pass and keep the retry only if it actually retrieved.
@@ -675,7 +813,10 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
675
813
  degradedSectionBody(spec.section, f.reason, res.text)
676
814
  : res.text.trim();
677
815
  };
678
- if (spec.retryIfSilent) {
816
+ // `confirmedEmpty` already spent a retry on exactly this ("you wrote nothing"), and
817
+ // the worker answered "nothing applies" a second time — re-asking here would just
818
+ // burn a third child for the same answer.
819
+ if (spec.retryIfSilent && !confirmedEmpty) {
679
820
  const body = silentBodyOf(r);
680
821
  const verdict = body === null ? null : classifyContextSilence(body);
681
822
  if (verdict?.silent && verdict.genuineLoss) {
@@ -703,12 +844,20 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
703
844
  const failure = classifyResearchWorker(spec.section, r);
704
845
  if (failure?.kind === 'fatal')
705
846
  throw failure.error;
706
- const rawText = failure?.kind === 'runaway' ?
707
- degradedSectionBody(spec.section, failure.reason, r.text)
708
- : r.text.trim();
847
+ // A worker that answers "nothing applies" is recorded the same way whether it
848
+ // said so with zero bytes (confirmedEmpty) or with a bare "(none)"/"N/A" — the
849
+ // two are the same answer, and only the marker makes either one distinguishable
850
+ // from a worker that never answered at all.
851
+ const rawText = failure?.kind === 'runaway' ? degradedSectionBody(spec.section, failure.reason, r.text)
852
+ : confirmedEmpty || isBareNoneAnswer(r.text) ? emptySectionBody(spec.section)
853
+ : r.text.trim();
709
854
  if (failure?.kind === 'runaway') {
710
855
  deps.logDebug?.(`${spec.label}: degraded — ${failure.reason}`);
711
856
  }
857
+ if (!confirmedEmpty && isBareNoneAnswer(r.text)) {
858
+ deps.logDebug?.(`${spec.label}: answered "${r.text.trim().slice(0, 40)}" — recording it as`
859
+ + ' an empty section (the worker ran and found nothing)');
860
+ }
712
861
  // Post-check the worker's own output before it is persisted, so the cache a
713
862
  // resume reads back is already gated. A degraded partial goes through it too —
714
863
  // a truncated section can still carry a laundered claim.
@@ -31,7 +31,18 @@ export declare function keepGroundedRequirements(entries: RequirementEntry[], so
31
31
  * never the model-authored anchor text. Without `sourceDoc` (or for quotes that
32
32
  * cannot be located) the fill degrades to the old given-order behavior.
33
33
  */
34
- export declare function capRequirements(entries: RequirementEntry[], passages: string[], sourceDoc?: string): RequirementEntry[];
34
+ export declare function capRequirements(entries: RequirementEntry[], passages: string[], sourceDoc?: string,
35
+ /** A/B seam: `false` reproduces the pre-budget rule, so an offline harness can
36
+ * score the shipped rule against the one it replaced without transcribing
37
+ * sectionFairFill and letting the copy drift. Production never passes it. */
38
+ deprioritiseLowValue?: boolean): RequirementEntry[];
39
+ /**
40
+ * Quotes that pass the grounding guard (verbatim substring of the doc) but state
41
+ * no obligation. There is no obligation test anywhere else in the pipeline —
42
+ * `keepGroundedRequirements` only checks the quote really appears in the source,
43
+ * so any sentence at all survives it and precision rests entirely on the model.
44
+ */
45
+ export declare function isLowValueQuote(quote: string): boolean;
35
46
  /**
36
47
  * DETERMINISTIC RECALL FLOOR (same medicine as the launch-contract checklist):
37
48
  * paragraphs carrying an obligation marker (word-bounded "required"/"must").
@@ -98,7 +98,11 @@ export function keepGroundedRequirements(entries, sourceDoc) {
98
98
  * never the model-authored anchor text. Without `sourceDoc` (or for quotes that
99
99
  * cannot be located) the fill degrades to the old given-order behavior.
100
100
  */
101
- export function capRequirements(entries, passages, sourceDoc) {
101
+ export function capRequirements(entries, passages, sourceDoc,
102
+ /** A/B seam: `false` reproduces the pre-budget rule, so an offline harness can
103
+ * score the shipped rule against the one it replaced without transcribing
104
+ * sectionFairFill and letting the copy drift. Production never passes it. */
105
+ deprioritiseLowValue = true) {
102
106
  if (entries.length <= MAX_REQUIREMENTS)
103
107
  return entries;
104
108
  const norms = passages.map(normalise);
@@ -109,7 +113,114 @@ export function capRequirements(entries, passages, sourceDoc) {
109
113
  const marked = entries.filter(covers);
110
114
  const rest = entries.filter(e => !covers(e));
111
115
  const budget = MAX_REQUIREMENTS - Math.min(marked.length, MAX_REQUIREMENTS);
112
- return [...marked.slice(0, MAX_REQUIREMENTS), ...sectionFairFill(rest, budget, sourceDoc)];
116
+ // The low-value filter applies to the UNMARKED remainder only. Quoting an
117
+ // obligation-marked passage is the pipeline's existing, validated evidence
118
+ // that a quote states an obligation, and it outranks any lexical heuristic:
119
+ // "MUST log every request" is 22 characters and every length-based rule reads
120
+ // it as a fragment. Filtering ahead of the marked/rest split deleted it.
121
+ //
122
+ // INSURANCE, not a measured win on mx5: across both 30-run pools exactly one
123
+ // distinct quote per pool is low-value AND marked, and it is a genuinely
124
+ // truncated one. The layering matters for specs whose obligations are SHORT,
125
+ // which mx5's are not. Do not cite it as the reason tail coverage holds —
126
+ // tail coverage is identical with the filter applied before the split.
127
+ const pool = deprioritiseLowValue ? budgetedByObligation(rest, budget, sourceDoc) : rest;
128
+ return [...marked.slice(0, MAX_REQUIREMENTS), ...sectionFairFill(pool, budget, sourceDoc)];
129
+ }
130
+ /** Longest a dependency-pin row can be before it is presumed to carry an
131
+ * obligation after the pin. Real pins in the measured corpus run 32..48 chars;
132
+ * the pin-prefixed lines that DO obligate ("TypeScript `6.0.3` — one strict
133
+ * `tsconfig.json`: `strict`, `noUncheckedIndexedAccess`, …") run 130..260. */
134
+ const MAX_PIN_LENGTH = 80;
135
+ /** Cut mid-expression: an unbalanced fence or bracket, or a trailing separator.
136
+ * NOT `;` — a complete clause legitimately ends with one, and dropping on `;`
137
+ * discarded a runnable `lint` = `prettier … && eslint … && tsc --noEmit` line. */
138
+ function isTruncatedQuote(q) {
139
+ if ((q.match(/`/g) ?? []).length % 2 === 1)
140
+ return true;
141
+ for (const [open, close] of [
142
+ ['(', ')'],
143
+ ['[', ']'],
144
+ ['{', '}']
145
+ ]) {
146
+ const o = (q.match(new RegExp(`\\${open}`, 'g')) ?? []).length;
147
+ const c = (q.match(new RegExp(`\\${close}`, 'g')) ?? []).length;
148
+ if (o > c)
149
+ return true;
150
+ }
151
+ return /[,:([{]\s*$/.test(q.trim());
152
+ }
153
+ /** A bare version row — data, not an obligation. Length-gated, see MAX_PIN_LENGTH. */
154
+ function isDependencyPin(q) {
155
+ const t = q.trim().replace(/^\*\*|\*\*$/g, '');
156
+ if (t.length > MAX_PIN_LENGTH)
157
+ return false;
158
+ return /^\**[`*]?[\w@/-]+[`*]?\**\s*[`']?\d+\.\d+/.test(t);
159
+ }
160
+ /** A DDL/schema row: `col type …`. */
161
+ function isSchemaRow(q) {
162
+ return /^\s*[\w_]+\s+(?:uuid|text|int|integer|bigint|boolean|timestamptz|bytea|jsonb|numeric|smallint)\b/i.test(q);
163
+ }
164
+ /** Too short to state an obligation. MIN_QUOTE_LENGTH (6) admits "Contact
165
+ * seller"; a clause needs a subject and a predicate. */
166
+ function isQuoteFragment(q) {
167
+ const t = q.trim();
168
+ return t.length < 25 || t.split(/\s+/).length < 4;
169
+ }
170
+ /**
171
+ * Quotes that pass the grounding guard (verbatim substring of the doc) but state
172
+ * no obligation. There is no obligation test anywhere else in the pipeline —
173
+ * `keepGroundedRequirements` only checks the quote really appears in the source,
174
+ * so any sentence at all survives it and precision rests entirely on the model.
175
+ */
176
+ export function isLowValueQuote(quote) {
177
+ return (isTruncatedQuote(quote)
178
+ || isDependencyPin(quote)
179
+ || isSchemaRow(quote)
180
+ || isQuoteFragment(quote));
181
+ }
182
+ /**
183
+ * Deprioritise obligation-free quotes, but only as far as the BUDGET requires.
184
+ *
185
+ * Measured (mx5, 20402-char spec, two independent 30-run extraction pools): the
186
+ * extractor's single-pass yield swings 20..160 for byte-identical input, and the
187
+ * padding crowds real obligations out of the 40 that ship — high-yield runs land
188
+ * FEWER critical obligations than low-yield ones. Critical obligations reaching
189
+ * the shipped list go 8.50 → 9.37 of 16 on the design pool (10 runs better, 0
190
+ * worse, p=0.0020) and 7.70 → 8.43 on the confirmation pool (12 / 0, p=0.0005).
191
+ *
192
+ * BUDGETED, not absolute. Below the cap no slot is contested, so dropping there
193
+ * destroys information and buys nothing; a run that filtered 55 quotes down to 20
194
+ * lost its only carrier of the Argon2id obligation — a DDL row that was correctly
195
+ * classified as one — while 20 slots sat empty. So the low-value entries come back
196
+ * in source-doc order until the list reaches the cap.
197
+ *
198
+ * Absolute filtering scored marginally higher on raw count (24 gains vs 22 across
199
+ * both pools) and was rejected anyway: its extra gains are one more obligation in
200
+ * an already-populated list, while its one loss is an obligation vanishing from a
201
+ * run outright. Those are not the same size of mistake.
202
+ *
203
+ * Doc order for the restore is the neutral choice: which entries return only
204
+ * matters when more were dropped than there are free slots, and ordering by
205
+ * anything fitted to an observed loss would be tuning the rule to one pool.
206
+ */
207
+ function budgetedByObligation(entries, budget, sourceDoc) {
208
+ const keep = entries.filter(e => !isLowValueQuote(e.quote));
209
+ if (keep.length >= budget)
210
+ return keep;
211
+ const at = (e) => {
212
+ if (!sourceDoc)
213
+ return Number.MAX_SAFE_INTEGER;
214
+ const i = sourceDoc.indexOf(e.quote.trim());
215
+ return i < 0 ? Number.MAX_SAFE_INTEGER : i;
216
+ };
217
+ const restored = entries
218
+ .filter(e => isLowValueQuote(e.quote))
219
+ .map((e, given) => ({ e, at: at(e), given }))
220
+ .sort((x, y) => x.at - y.at || x.given - y.given)
221
+ .slice(0, budget - keep.length)
222
+ .map(x => x.e);
223
+ return [...keep, ...restored];
113
224
  }
114
225
  /** The doc split into heading-delimited sections, each pre-normalised for
115
226
  * containment tests. Text before the first heading is its own section. */
@@ -92,6 +92,30 @@ export interface RunWorkerResult {
92
92
  exitCode: number;
93
93
  stderr: string;
94
94
  aborted: boolean;
95
+ /**
96
+ * The provider-reported cause when the model turn itself failed (disconnect,
97
+ * fetch failed, 5xx after pi's own retries): pi delivers it as an assistant
98
+ * message with stopReason "error" and EMPTY text, exit code 0. Phase children
99
+ * have always surfaced this (child-runner.ts) — research workers did not, so a
100
+ * swallowed provider error reached the caller as an indistinguishable empty
101
+ * answer and was reported as the useless "produced no output" (issue #10).
102
+ * Only meaningful when `text` is empty: a turn that produced text after pi
103
+ * recovered is a success, and the first-error capture must not relabel it.
104
+ */
105
+ modelError?: string;
106
+ /**
107
+ * Whether the child ever produced a single byte of stdout. Under `--mode json`
108
+ * a live pi child streams protocol events long before any assistant text, so
109
+ * this separates the two ways a worker can come back with nothing:
110
+ * sawOutput true — the child ran and the MODEL chose to write nothing
111
+ * (a legitimately empty section on a trivial task)
112
+ * sawOutput false — the child never spoke at all: it died at startup
113
+ * (unresolvable provider, missing key, bad argv). That is
114
+ * a FAILURE and must never be recorded as "no entries".
115
+ * Derived from the same first-byte timestamp `waitMs`/`workMs` use, so it
116
+ * cannot disagree with them.
117
+ */
118
+ sawOutput: boolean;
95
119
  /**
96
120
  * Milliseconds between spawn and the child's first stdout chunk. When
97
121
  * multiple workers run concurrently and the upstream model API queues at
@@ -331,7 +331,9 @@ export async function runWorker(input) {
331
331
  aborted: result.aborted,
332
332
  waitMs,
333
333
  workMs,
334
+ sawOutput: tFirstByte !== null,
334
335
  groundingRetrievalCount,
336
+ ...(result.modelError ? { modelError: result.modelError } : {}),
335
337
  ...(leaked ? { leakedToolCall: leaked } : {}),
336
338
  ...(loopHit ? { loopHit } : {}),
337
339
  ...(timedOut ? { timedOut: true } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.23.1",
3
+ "version": "0.24.1",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",