@mjasnikovs/pi-task 0.21.10 → 0.23.0

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 CHANGED
@@ -65,7 +65,7 @@ A whole plan — `/task-auto` splits it into an ordered task list and runs each
65
65
  | `/task-auto <feature>` | Plan a feature into a task list and run each title through `/task` in order (resumable). |
66
66
  | `/task-auto-resume [--unattended]` | Resume the active `/task-auto` run at the next unfinished task. `--unattended` is the boot-hook form: in-flight runs only. |
67
67
  | `/task-auto-cancel` | Stop the `/task-auto` loop after the current task (still resumable). |
68
- | `/task-config` | Toggle pi-task settings in an editor dialog: remote control, compress thinking, auto-commit, verify work, enforce guidelines, project tour, command timeout, stuck reply retry, and one `ext:` toggle per installed host extension. |
68
+ | `/task-config` | Toggle pi-task settings in an editor dialog: remote control, compress thinking, auto-commit, verify work, enforce guidelines, project tour, command timeout, stuck reply retry, debug logs, and one `ext:` toggle per installed host extension. |
69
69
  | `/remote` | Show the QR code & URLs for the web view (`/remote stop` to stop). Answer grill questions, start tasks, and watch progress from your phone. |
70
70
 
71
71
  ## The pipeline
@@ -183,6 +183,7 @@ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings pe
183
183
  | **command timeout** | 15 min | Wall-clock ceiling on a **single** tool execution. Local models routinely run a command that never returns (a hung build, a dev server, a check with no timeout) and the run wedges until you abort by hand — pi's bash tool has an optional timeout with no default, so this is the missing one. One knob, two surfaces: in the main session the overrun call is cancelled (killing the tool's whole process tree) plus a reminder turn; in the verify/fix gate children the child is killed and re-spawned with a hint, halving the ceiling on repeat hangs. Choices: 5/10/15/30 min or **off** — off unguards both surfaces, gates included. |
184
184
  | **stuck reply retry** | 10 min | Inactivity ceiling on the **model stream**. A hung or silently-dropped stream throws nothing at all, so neither the connection-error retry (it needs a reported error) nor the **command timeout** (tool calls only) nor the dead-backend stall guard (a reachable endpoint reads as proof of life) can see it — an mx5 run lost ~2.9h to three of them while the model server stayed healthy. Measured as time since the **last stream event of any kind**, so a slow model emitting one token every 30s is never touched, and it pauses while a tool runs. On expiry the main session aborts the turn (through the same channel the command watchdog uses) and posts a resume reminder; a child is killed and routed into the existing connection-error retry. Choices: 5/10/20/30 min or **off**. Keep it generous on local backends — prompt processing on a large context legitimately emits nothing for minutes. |
185
185
  | **yolo mode** | off | **Unattended runs.** Wherever pi-task would stop and ask, it takes the option already marked RECOMMENDED, stamps the artifact `(YOLO)` so an audit can tell a machine decided, and shows no prompt at all — clarify/grill answers, the verify-FAIL picker (auto-**Accept**, recorded as a yolo debt), and the final-gate picker (autofix while the budget lasts, then leave the run FAILED). A question with no recommendation is **skipped**, never invented. For throwaway/test projects nobody is watching; a real run should decide these itself. |
186
+ | **debug logs** | events | How much of a run is written to `.pi-tasks/*-debug.log`. **`events`** keeps decisions and guard actions — which phase ran, why a worker was retried, what the git-state guard restored, what a write-capable child changed on disk, why a gate returned FAIL — a few lines per task. **`full`** adds every line the child model emitted and every tool result; that's ~85% of the bytes (a real 247 KB `verify-debug.log` is 1315 lines, 521 of them tool dumps) and is what you want while actively debugging. **`off`** writes nothing. Nothing in pi-task ever reads these files back, so the setting cannot change how a run behaves — only whether you can explain it afterwards, and a log not written can't be recovered later. |
186
187
  | **ext: …** | all off | One toggle per installed host `pi` extension, loading it into every child session by explicit path. Children otherwise run with extensions off, so a provider registered by an extension (e.g. `pi-lmstudio`) doesn't exist in them and they can't resolve the default model. Children also inherit the extension's tools and hooks, so only enable ones you trust. The list is strictly additive (discovery stays off), and an entry whose file is gone is skipped at spawn time, never fatal. |
187
188
 
188
189
  ## Configuration
@@ -195,6 +196,7 @@ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings pe
195
196
  | `PI_REMOTE_PUSH_SUBJECT` | remote push | VAPID JWT `sub` contact. Defaults to the project URL; set your own `mailto:you@domain.com` or `https://…`. |
196
197
  | `PI_REMOTE_PUSH_DEBUG` | remote push | When set (e.g. `1`), logs push delivery and push-service HTTP status. Off by default. |
197
198
  | `PI_REMOTE_PUSH_LOG` | remote push | Path for the debug log (defaults to `/tmp/pi-task-push.log`). |
199
+ | `PI_TASK_DEBUG_LOG` | task trail | Overrides the **debug logs** setting for one session: `off`, `events`, or `full`. For reproducing a report without walking someone through `/task-config`. An unrecognised value is ignored, not treated as `off`. |
198
200
 
199
201
  Tasks are persisted to `<cwd>/.pi-tasks/TASK_NNNN.md`. Add `.pi-tasks/` to your `.gitignore` if you don't want them checked in.
200
202
 
@@ -96,7 +96,44 @@ export interface PiTaskConfig {
96
96
  * DEFAULT OFF — this is never the behaviour of a normal, watched run.
97
97
  */
98
98
  yoloMode: boolean;
99
+ /**
100
+ * How much the run writes to its `.pi-tasks/*-debug.log` forensic trail
101
+ * (task/debug-log.ts). NOTHING in pi-task ever reads these files back —
102
+ * `task-io.ts` only globs `TASK_NNNN.md`, and auto-commit's trail snapshot
103
+ * copies bytes without parsing them — so this knob is behaviour-neutral by
104
+ * construction. It trades disk and repo noise against the ability to explain
105
+ * a run after it has finished.
106
+ *
107
+ * `full` is every line the child model emitted plus every tool result;
108
+ * `events` keeps only decisions and guard actions; `off` writes nothing.
109
+ *
110
+ * DEFAULT `events`, not `off`, because the two levels are not the same kind
111
+ * of record. Measured on a real 247 KB `verify-debug.log` (IAR1, 1315 lines):
112
+ * the child's own output and the `↳` tool dumps are 85% of the bytes, while
113
+ * the `=== … ===` markers are 15% — and that 15% is the ONLY record of what
114
+ * the guards did (`GIT-STATE GUARD — child mutated graded state`, the
115
+ * write-capable child's `tree changes`, the FAIL reason). mx5 run 11's
116
+ * final-fix child deleted a source file; the tree-changes line is why that
117
+ * was findable at all. Silencing the chatter costs nothing; silencing the
118
+ * guard record makes the next incident unreconstructible, and a debug log
119
+ * cannot be recovered after the fact.
120
+ */
121
+ debugLogs: DebugLogLevel;
99
122
  }
123
+ /** How verbose the `.pi-tasks/*-debug.log` trail is. See {@link PiTaskConfig.debugLogs}. */
124
+ export type DebugLogLevel = 'off' | 'events' | 'full';
125
+ /**
126
+ * The debug-log choices offered by /task-config, in cycle order (quietest →
127
+ * loudest, so the cycle reads as a volume dial). Unlike the timeout options the
128
+ * stored value IS the label — the level is already a word.
129
+ */
130
+ export declare const DEBUG_LOG_OPTIONS: readonly DebugLogLevel[];
131
+ /**
132
+ * Same pinning as the timeout sanitizers: a hand-edited `"debugLogs": true` or a
133
+ * level from a future version must not reach the writer as an unknown string —
134
+ * it falls back to the default rather than silently disabling the trail.
135
+ */
136
+ export declare function sanitizeDebugLogs(value: unknown): DebugLogLevel;
100
137
  /**
101
138
  * The command-watchdog timeout choices offered by /task-config, newest-first in
102
139
  * the cycle order the picker shows. The stored config value is the ms number;
@@ -4,6 +4,23 @@ import * as path from 'node:path';
4
4
  import * as os from 'node:os';
5
5
  import { isSearchProvider } from '../workers/search-types.js';
6
6
  import { DEFAULT_STREAM_INACTIVITY_MS } from '../shared/stream-watchdog.js';
7
+ /**
8
+ * The debug-log choices offered by /task-config, in cycle order (quietest →
9
+ * loudest, so the cycle reads as a volume dial). Unlike the timeout options the
10
+ * stored value IS the label — the level is already a word.
11
+ */
12
+ export const DEBUG_LOG_OPTIONS = ['off', 'events', 'full'];
13
+ const DEFAULT_DEBUG_LOGS = 'events';
14
+ /**
15
+ * Same pinning as the timeout sanitizers: a hand-edited `"debugLogs": true` or a
16
+ * level from a future version must not reach the writer as an unknown string —
17
+ * it falls back to the default rather than silently disabling the trail.
18
+ */
19
+ export function sanitizeDebugLogs(value) {
20
+ return DEBUG_LOG_OPTIONS.includes(value) ?
21
+ value
22
+ : DEFAULT_DEBUG_LOGS;
23
+ }
7
24
  /**
8
25
  * The command-watchdog timeout choices offered by /task-config, newest-first in
9
26
  * the cycle order the picker shows. The stored config value is the ms number;
@@ -61,7 +78,10 @@ const DEFAULTS = {
61
78
  requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
62
79
  streamInactivityMs: DEFAULT_STREAM_INACTIVITY_MS,
63
80
  // OFF: auto-answering is for unattended throwaway runs only.
64
- yoloMode: false
81
+ yoloMode: false,
82
+ // EVENTS: the model chatter is 85% of the bytes and nobody reads it; the
83
+ // guard/verdict markers are the 15% that explains a failed run.
84
+ debugLogs: DEFAULT_DEBUG_LOGS
65
85
  };
66
86
  /**
67
87
  * A hand-edited config can hold anything; keep only string entries so a stray
@@ -96,6 +116,7 @@ if (!G.loaded) {
96
116
  // boolean counts; anything else falls back to the OFF default.
97
117
  if (typeof parsed.yoloMode !== 'boolean')
98
118
  delete parsed.yoloMode;
119
+ parsed.debugLogs = sanitizeDebugLogs(parsed.debugLogs);
99
120
  G.config = { ...DEFAULTS, ...parsed };
100
121
  }
101
122
  catch {
@@ -2,7 +2,7 @@ import { SettingsList, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi
2
2
  import { registerBridgeCommand } from '../remote/bridge.js';
3
3
  import { readPkgVersion } from '../shared/pkg-version.js';
4
4
  import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../workers/search-types.js';
5
- import { COMMAND_TIMEOUT_OPTIONS, getConfig, saveConfig, STREAM_INACTIVITY_OPTIONS } from './config.js';
5
+ import { COMMAND_TIMEOUT_OPTIONS, DEBUG_LOG_OPTIONS, getConfig, sanitizeDebugLogs, saveConfig, STREAM_INACTIVITY_OPTIONS } from './config.js';
6
6
  import { listInstalledExtensions } from './extension-list.js';
7
7
  // Version in the title so a bug report or screenshot says which build it came
8
8
  // from without anyone having to go look it up.
@@ -160,6 +160,16 @@ const ITEMS = [
160
160
  + 'check is accepted and written down as debt, and a failed final check is retried '
161
161
  + 'until the budget runs out. Each auto-answer is marked (YOLO) in the task file. '
162
162
  + 'For throwaway projects you are not watching'
163
+ },
164
+ {
165
+ id: 'debugLogs',
166
+ label: 'debug logs',
167
+ description: 'How much of a run gets written to .pi-tasks/*-debug.log. "events" keeps the '
168
+ + 'decisions and the guard actions — what a checking step changed, why something '
169
+ + 'failed — a few lines per task. "full" adds everything the model said and every '
170
+ + 'command it ran, which is most of the size and only useful while you are digging '
171
+ + 'into a problem. "off" writes nothing, and nothing can be reconstructed later',
172
+ values: [...DEBUG_LOG_OPTIONS]
163
173
  }
164
174
  ];
165
175
  /** Human label for the stored command-timeout ms (falls back to the raw ms). */
@@ -295,6 +305,12 @@ async function handleTaskConfig(_args, ctx) {
295
305
  if (opt)
296
306
  cfg.streamInactivityMs = opt.ms;
297
307
  }
308
+ else if (id === 'debugLogs') {
309
+ // The stored value IS the label here, but it must still go
310
+ // through the sanitizer — the generic `else` below would
311
+ // write the boolean `newValue === 'on'` into an enum field.
312
+ cfg.debugLogs = sanitizeDebugLogs(newValue);
313
+ }
298
314
  else {
299
315
  ;
300
316
  cfg[id] = newValue === 'on';
@@ -335,6 +335,40 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
335
335
  else
336
336
  stdout += chunk;
337
337
  });
338
+ // This accumulation is deliberately unbounded, unlike `discardStdout`
339
+ // above. The asymmetry was investigated on 2026-07-28 and the "stderr can
340
+ // overflow V8's max string length" hypothesis is REFUTED at ~5 orders of
341
+ // magnitude — do not re-open it without new volume evidence.
342
+ //
343
+ // Measured ceiling (binary search on `'a'.repeat`, not cited from memory):
344
+ // node v26.2.0 536,870,888 chars (512 MiB, 2^29-24), bun 1.3.14
345
+ // 2,147,483,647 (2 GiB). Both throw RangeError, not OOM — node's default
346
+ // heap is 4192 MiB, well clear of its own string ceiling. If it were ever
347
+ // reached the severity would be high: a throw in this handler escapes as
348
+ // an uncaughtException and the enclosing promise never settles (measured),
349
+ // so it would kill the host, not fail the child.
350
+ //
351
+ // Measured volume, real runs not synthetic: the largest single tool output
352
+ // in a full mx5 run is 5,043 bytes and that whole run's verify-debug.log is
353
+ // 112,216 bytes. The heaviest commands in the system are far under —
354
+ // pi-task's own 2,452-test suite emits 162 bytes of stderr, mx5's
355
+ // prettier+eslint+tsc lint 90, a cached `bun install` 0. Observed max is
356
+ // ~106,000x below the node ceiling; reaching it inside the 15-min command
357
+ // bound would need ~596 KB/s sustained on stderr for the full window.
358
+ //
359
+ // The structural reason is the population of children that reach here:
360
+ // `pi` model children (stderr near-empty), `git`, and one
361
+ // `npm install --loglevel=error`. Genuinely verbose work — test runners,
362
+ // builds, boot probes — does not use runChild; final-gate.ts spawns its own
363
+ // and already caps at 8000 chars. So `discardStdout`'s single caller
364
+ // (docs-core.ts) is not an oversight to mirror: it discards the verbose
365
+ // stream and KEEPS stderr precisely because stderr is that call's payload.
366
+ //
367
+ // A symmetric `discardStderr` would therefore have no caller, and a naive
368
+ // cap here would be actively unsafe: consumers disagree about which end
369
+ // carries the cause — phases.ts:474 takes the tail (-500), phases.ts:936
370
+ // takes the head (0, 300), and child-runner.ts:172 feeds the whole string
371
+ // into failure classification.
338
372
  proc.stderr?.on('data', (d) => {
339
373
  lastActivity = Date.now();
340
374
  streamWatch?.note();
@@ -38,10 +38,12 @@ import { describeDebt, recordFinalGateUnobservedDebt } from './accept-debt.js';
38
38
  import { applyDemotions, isNonProgress, normalizeFailureDetail, rankedFirstFailure, unobservedDebtReason } from './final-gate-progress.js';
39
39
  import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FINAL_LEAVE_VALUE, FINAL_ACCEPT_LABEL, FINAL_ACCEPT_VALUE, FINAL_AUTOFIX_LABEL, FINAL_AUTOFIX_VALUE, STRANDED_FIX_COMMIT, strandedFixNote } from './final-gate-fix.js';
40
40
  import { getConfig } from '../config/config.js';
41
+ import { debugLogLevel, shouldLogDebug } from './debug-log.js';
41
42
  import { isYoloMode, yoloPickAnswer, yoloFinalGateChoice, YOLO_STAMP } from './yolo.js';
42
43
  import { configureResearchRun, resumeResearchRun } from '../workers/research-cache.js';
43
44
  import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
44
45
  import { reconcileTitleSources } from './decompose-fidelity.js';
46
+ import { granularityFloor, granularitySplitHint, isPlanShapeQuestion, isTooCoarse, planShapeIsHostsToAnswer, PLAN_SHAPE_ANSWER } from './decompose-granularity.js';
45
47
  import { mandatesTestsInSameChange, rewriteBatchTestPlan } from './batch-test-task.js';
46
48
  import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, writeOwnedRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
47
49
  import { decideAdoption, groundedCoverage } from './coverage-loop.js';
@@ -121,8 +123,16 @@ function mentionPath(token) {
121
123
  * before any task file — hence any per-task `TASK_XXXX-debug.log` — exists. Writes
122
124
  * to `.pi-tasks/plan-debug.log`; the `*-debug.log` suffix keeps it grep-compatible
123
125
  * with the per-task logs. Never throws (mkdir + append are best-effort).
126
+ *
127
+ * Every call site here records a plan DECISION (how many titles a round produced,
128
+ * whether a retry was adopted, which clarify answer was auto-resolved), so all of
129
+ * them are `'event'` — this file carries no model chatter and survives at the
130
+ * default level. It is also the only channel the plan phase has: it runs before
131
+ * any task file, hence any `TASK_NNNN-debug.log`, exists.
124
132
  */
125
133
  function logPlanDebug(cwd, msg) {
134
+ if (!shouldLogDebug('event', debugLogLevel()))
135
+ return;
126
136
  const line = `${new Date().toISOString()} ${msg}\n`;
127
137
  const dir = tasksDir(cwd);
128
138
  fsp.mkdir(dir, { recursive: true })
@@ -424,6 +434,54 @@ export async function planAuto(ctx, cwd, feature, deps) {
424
434
  taskId: '',
425
435
  signal: new AbortController().signal
426
436
  }).catch(() => '');
437
+ // Requirement extraction (mx5 run 11, goal A): grounded requirement units,
438
+ // extracted from whatever structure the spec has, BEFORE decompose — they ride
439
+ // into the decompose prompt as a ledger (structure-mirroring can't discharge
440
+ // them) and drive the per-requirement coverage accounting below.
441
+ //
442
+ // Runs BEFORE clarify (it depends only on the inlined feature, never on the
443
+ // answers) so the plan-shape gate below has a real count to judge with: the
444
+ // host must not seize the granularity fork on a spec that has no breakdown to
445
+ // speak of. Best-effort:
446
+ // a fault leaves reqEntries empty and the whole channel degrades to the old
447
+ // behavior (one-liners / doc-less features naturally yield few or none).
448
+ let reqEntries = [];
449
+ try {
450
+ // Recall floor: the obligation-marked passages ride into the prompt as a
451
+ // checklist, and a marked passage that produced NO quote is hard evidence
452
+ // for one forced re-extraction (measured live: 1/5 extractions missed the
453
+ // entire marked testing section without this).
454
+ const passages = enumerateObligationPassages(featureForModel);
455
+ const extractOnce = async (hint) => keepGroundedRequirements(parseRequirementLines(await deps.runChild('requirement-extract', '', prependHint(hint, REQUIREMENT_EXTRACT_PROMPT(featureForModel, passages)))), featureForModel);
456
+ reqEntries = await extractOnce(null);
457
+ const uncovered = uncoveredPassages(passages, reqEntries);
458
+ if (uncovered.length > 0) {
459
+ logPlanDebug(cwd, `requirement extraction: ${uncovered.length} obligation-marked passage(s) `
460
+ + 'uncovered — forcing one re-extraction');
461
+ const retry = await extractOnce(extractionRetryHint(uncovered));
462
+ // Union of both grounded passes (keepGrounded dedupes).
463
+ reqEntries = keepGroundedRequirements([...reqEntries, ...retry], featureForModel);
464
+ }
465
+ // Bound with marked-passage priority — a plain first-N cap truncates the
466
+ // doc's tail sections (measured live: an eager model fills 40 top-down).
467
+ reqEntries = capRequirements(reqEntries, passages, featureForModel);
468
+ logPlanDebug(cwd, `requirement extraction: ${reqEntries.length} grounded requirement(s) kept`);
469
+ }
470
+ catch {
471
+ // best-effort channel
472
+ }
473
+ // Granularity floor (mx5 Jul 25 vs Jul 27): the plan's task COUNT was being set
474
+ // by an auto-resolved clarify line the user never saw — the same spec planned
475
+ // into 41 tasks one day and 11 the next, with identical code. Derive the floor
476
+ // from the requirements a task can own, so an unreviewable "one task per
477
+ // milestone" decision cannot collapse the plan; it also gates whether the
478
+ // plan-shape fork below is the host's to answer at all. 0 ownable ⇒ no channel.
479
+ const ownableRequirements = reqEntries.filter(e => !isCrossCuttingRequirement(e.quote)).length;
480
+ const coarseFloor = granularityFloor(ownableRequirements);
481
+ if (coarseFloor > 0) {
482
+ logPlanDebug(cwd, `granularity floor: ${ownableRequirements} ownable requirement(s) ⇒ at least `
483
+ + `${coarseFloor} task(s)`);
484
+ }
427
485
  const answers = [];
428
486
  // Plain text of every question already shown, for the duplicate backstop.
429
487
  const askedQuestions = [];
@@ -458,6 +516,19 @@ export async function planAuto(ctx, cwd, feature, deps) {
458
516
  const shownQ = renderInlineMarkdown(question, theme);
459
517
  const plainQ = stripInlineMarkdown(question);
460
518
  askedQuestions.push(plainQ);
519
+ // PLAN SHAPE is the host's call, not the triage's (mx5 41→11 tasks on the
520
+ // same spec, same base commit, same code — see decompose-granularity.ts).
521
+ // The triage answers this fork for itself in 8/8 live reps and stamps it
522
+ // "already settled by the spec" while the spec settles no such thing, so the
523
+ // single most load-bearing decision in a run was an invisible coin flip.
524
+ // Answer it deterministically instead: same channel, same transcript, but a
525
+ // fixed value the user can read in the AUTO file and override next run.
526
+ if (planShapeIsHostsToAnswer(ownableRequirements) && isPlanShapeQuestion(plainQ)) {
527
+ logPlanDebug(cwd, `plan-shape question answered host-side (not the triage): ${plainQ.replace(/\s+/g, ' ').slice(0, 120)}`);
528
+ answers.push(`Q${answers.length + 1}: ${plainQ}\n`
529
+ + `A${answers.length + 1}: ${PLAN_SHAPE_ANSWER} (host-set — plan granularity is not left to chance)`);
530
+ continue;
531
+ }
461
532
  // Answer-side triage (grill parity): if the inlined spec already settles
462
533
  // this question, auto-resolve it and never show it. The resolved value is
463
534
  // recorded so decompose sees the decision and the next gen call's priorQA
@@ -544,37 +615,6 @@ export async function planAuto(ctx, cwd, feature, deps) {
544
615
  ctx.ui.notify('No clarifying questions needed — planning tasks…', 'info');
545
616
  }
546
617
  const clarifications = answers.join('\n');
547
- // Requirement extraction (mx5 run 11, goal A): grounded requirement units,
548
- // extracted from whatever structure the spec has, BEFORE decompose — they ride
549
- // into the decompose prompt as a ledger (structure-mirroring can't discharge
550
- // them) and drive the per-requirement coverage accounting below. Best-effort:
551
- // a fault leaves reqEntries empty and the whole channel degrades to the old
552
- // behavior (one-liners / doc-less features naturally yield few or none).
553
- let reqEntries = [];
554
- try {
555
- // Recall floor: the obligation-marked passages ride into the prompt as a
556
- // checklist, and a marked passage that produced NO quote is hard evidence
557
- // for one forced re-extraction (measured live: 1/5 extractions missed the
558
- // entire marked testing section without this).
559
- const passages = enumerateObligationPassages(featureForModel);
560
- const extractOnce = async (hint) => keepGroundedRequirements(parseRequirementLines(await deps.runChild('requirement-extract', '', prependHint(hint, REQUIREMENT_EXTRACT_PROMPT(featureForModel, passages)))), featureForModel);
561
- reqEntries = await extractOnce(null);
562
- const uncovered = uncoveredPassages(passages, reqEntries);
563
- if (uncovered.length > 0) {
564
- logPlanDebug(cwd, `requirement extraction: ${uncovered.length} obligation-marked passage(s) `
565
- + 'uncovered — forcing one re-extraction');
566
- const retry = await extractOnce(extractionRetryHint(uncovered));
567
- // Union of both grounded passes (keepGrounded dedupes).
568
- reqEntries = keepGroundedRequirements([...reqEntries, ...retry], featureForModel);
569
- }
570
- // Bound with marked-passage priority — a plain first-N cap truncates the
571
- // doc's tail sections (measured live: an eager model fills 40 top-down).
572
- reqEntries = capRequirements(reqEntries, passages, featureForModel);
573
- logPlanDebug(cwd, `requirement extraction: ${reqEntries.length} grounded requirement(s) kept`);
574
- }
575
- catch {
576
- // best-effort channel
577
- }
578
618
  // Artifact-production closure, plan side (mx5 run 13, PROMPT 2): runtime
579
619
  // files the spec REFERENCES (server snippets, prose "serve the built
580
620
  // index.html") that neither its file tree, its parsed build outputs, nor the
@@ -636,6 +676,20 @@ export async function planAuto(ctx, cwd, feature, deps) {
636
676
  const listRaw = await deps.runChild('auto-decompose', 'read', decomposePrompt);
637
677
  let planTitles = parsePlan(listRaw);
638
678
  logPlanDebug(cwd, `decompose produced ${planTitles.length} title(s)`);
679
+ // BRACES for the floor: the prompt clause alone is a preference the model can
680
+ // ignore, so a plan under the floor is sent back ONCE to be split (never
681
+ // regenerated — a fresh roll can drop a covered area, mx5 run 12). Longer plan
682
+ // wins; a still-coarse plan falls through to the coverage judge as before, so
683
+ // this can never block planning.
684
+ if (isTooCoarse(planTitles.length, coarseFloor)) {
685
+ logPlanDebug(cwd, `plan under the granularity floor (${planTitles.length} < ${coarseFloor}) — `
686
+ + 'reprompting once to split');
687
+ const splitRaw = await deps.runChild('auto-decompose', 'read', prependHint(granularitySplitHint(planTitles.length, ownableRequirements), decomposePrompt));
688
+ const splitTitles = parsePlan(splitRaw);
689
+ logPlanDebug(cwd, `granularity split-retry produced ${splitTitles.length} title(s)`);
690
+ if (splitTitles.length > planTitles.length)
691
+ planTitles = splitTitles;
692
+ }
639
693
  // Distrust floor (see isSuspectPlan): a ≤2-title plan for a multi-KB spec is
640
694
  // regenerated once BEFORE the judge runs — the judge cannot be trusted to
641
695
  // catch it (3/10 live false-pass) and a hinted retry heals it reliably
@@ -16,6 +16,12 @@ export declare const AUTO_CLARIFY_PROMPT: (feature: string, priorQA: string) =>
16
16
  * `noBatchTests` adds the anti-batch-test rule (batch-test-task.ts) — emitted ONLY
17
17
  * when the decisions mandate tests-in-the-same-change, so every other run sees the
18
18
  * prompt it always saw. It is the belt; the host-side rewrite is the lever.
19
+ *
20
+ * Plan GRANULARITY is deliberately NOT a rule here — it rides in CLARIFICATIONS
21
+ * (decompose-granularity.ts). Live A/B: as a RULES line replacing "prefer a
22
+ * handful", it made plan size explode (81 and 85 titles for a spec whose healthy
23
+ * plan is ~30, one 120k-context blowup); as a clarification, with the counterweight
24
+ * below left intact, the same directive holds 20–39 across 16 reps.
19
25
  */
20
26
  export declare const AUTO_DECOMPOSE_PROMPT: (feature: string, clarifications: string, requirementsLedger?: string, noBatchTests?: boolean) => string;
21
27
  /**
@@ -76,6 +76,12 @@ NONE`;
76
76
  * `noBatchTests` adds the anti-batch-test rule (batch-test-task.ts) — emitted ONLY
77
77
  * when the decisions mandate tests-in-the-same-change, so every other run sees the
78
78
  * prompt it always saw. It is the belt; the host-side rewrite is the lever.
79
+ *
80
+ * Plan GRANULARITY is deliberately NOT a rule here — it rides in CLARIFICATIONS
81
+ * (decompose-granularity.ts). Live A/B: as a RULES line replacing "prefer a
82
+ * handful", it made plan size explode (81 and 85 titles for a spec whose healthy
83
+ * plan is ~30, one 120k-context blowup); as a clarification, with the counterweight
84
+ * below left intact, the same directive holds 20–39 across 16 reps.
79
85
  */
80
86
  export const AUTO_DECOMPOSE_PROMPT = (feature, clarifications, requirementsLedger = '', noBatchTests = false) => `Split this feature into an ordered list of implementation tasks. Each task
81
87
  will be handed, by its title, to a separate pipeline that does its own research
@@ -6,6 +6,7 @@
6
6
  * for phase-level child pi invocations.
7
7
  */
8
8
  import { type SpawnFn, type ContextSnapshot, type ToolCall, type LoopHit } from '../shared/child-process.js';
9
+ import type { DebugLine } from './debug-log.js';
9
10
  export declare const LOOP_WINDOW = 20;
10
11
  export declare const LOOP_THRESHOLD = 5;
11
12
  export declare const MAX_LOOP_RESTARTS = 2;
@@ -44,8 +45,16 @@ interface PhaseDeps {
44
45
  */
45
46
  recordSubStep?: (label: string, ms: number) => void;
46
47
  spawn?: SpawnFn;
47
- /** Write a timestamped line to the per-task debug log. Fire-and-forget. */
48
- logDebug?: (msg: string) => void;
48
+ /**
49
+ * Write a timestamped line to the per-task debug log. Fire-and-forget, and
50
+ * UNSET entirely when the trail is off — so a caller must keep the `?.` and
51
+ * must not do work to build a message outside the call.
52
+ *
53
+ * `kind` defaults to `'event'` (a decision or guard action, kept at the
54
+ * default level). Pass `'stream'` for raw child output and tool results,
55
+ * which only the `full` level keeps. See debug-log.ts.
56
+ */
57
+ logDebug?: (msg: string, kind?: DebugLine) => void;
49
58
  /** Injectable delay for connection-error backoff; defaults to a real timer.
50
59
  * Tests override it with a no-op so retries don't actually sleep. */
51
60
  sleepFor?: (ms: number) => Promise<void>;
@@ -0,0 +1,34 @@
1
+ import { type DebugLogLevel } from '../config/config.js';
2
+ /**
3
+ * Escape hatch for reproducing a user's bug without walking them through
4
+ * /task-config. Follows the existing `PI_TASK_*` instrumentation convention
5
+ * (`PI_TASK_TYPEONLY_LOG`, `PI_REMOTE_PUSH_DEBUG`). An unrecognised value is
6
+ * ignored rather than treated as `off` — a typo in an env var must not silently
7
+ * throw the trail away.
8
+ */
9
+ export declare const DEBUG_LOG_ENV = "PI_TASK_DEBUG_LOG";
10
+ /** A trail line's kind — see the module note. Producers default to `'event'`. */
11
+ export type DebugLine = 'event' | 'stream';
12
+ /**
13
+ * The level in force: env override first, then the saved config. Read per call
14
+ * rather than cached, so flipping the setting mid-run takes effect on the next
15
+ * line instead of at the next restart.
16
+ */
17
+ export declare function debugLogLevel(getEnv?: (k: string) => string | undefined): DebugLogLevel;
18
+ /** Whether a line of this kind should be written at `level`. */
19
+ export declare function shouldLogDebug(kind: DebugLine, level: DebugLogLevel): boolean;
20
+ /**
21
+ * A timestamped fire-and-forget appender for one trail file, level-gated.
22
+ *
23
+ * `kind` defaults to `'event'` so a new call site is quiet-by-default in the
24
+ * useful direction: forgetting to classify a marker keeps it in the audit trail,
25
+ * whereas forgetting to classify chatter would only make the log bigger. Errors
26
+ * are swallowed — an unwritable trail must never fail the run it is describing.
27
+ */
28
+ export declare function makeDebugAppender(logPath: string, appendFile?: (p: string, data: string) => Promise<unknown>): (msg: string, kind?: DebugLine) => void;
29
+ /**
30
+ * Wrap a raw append in the level gate. Returns `undefined` when the level is
31
+ * `off`, so callers that hold an OPTIONAL `logDebug` leave it unset and every
32
+ * `logDebug?.(…)` in the pipeline short-circuits before it formats a string.
33
+ */
34
+ export declare function gateDebugWriter(write: (msg: string) => void, getEnv?: (k: string) => string | undefined): ((msg: string, kind?: DebugLine) => void) | undefined;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * One place that decides whether a `.pi-tasks/*-debug.log` line gets written.
3
+ *
4
+ * THE TRAIL IS WRITE-ONLY. Nothing in pi-task reads these files back: `task-io.ts`
5
+ * globs `TASK_NNNN.md` and skips everything else, and auto-commit's `snapshotTrail`
6
+ * copies the bytes across a `reset --hard` without ever parsing them. Every producer
7
+ * is a `logDebug?.(…)` / `log(…)` side effect whose return value is discarded. So
8
+ * this gate cannot change what a run DOES — only what it can explain afterwards.
9
+ *
10
+ * TWO KINDS OF LINE, and the distinction is the whole point of having three levels
11
+ * rather than a boolean:
12
+ *
13
+ * 'stream' — what the child model said, and what its tools returned. Reproducible
14
+ * by re-running, useful while you are actively debugging, and 85% of the
15
+ * bytes (measured: a 247 KB IAR1 `verify-debug.log` is 1315 lines, of
16
+ * which 521 are `↳` tool dumps and most of the rest is raw model text).
17
+ *
18
+ * 'event' — a decision or a guard action: which phase started, why a worker was
19
+ * retried or degraded, what the git-state guard restored, what a
20
+ * write-capable child changed on disk, why a gate returned FAIL. Ten-ish
21
+ * lines per task, and NOT reproducible — it is the only record that the
22
+ * guard fired at all. mx5 run 11's final-fix child deleted a source file
23
+ * and the `tree changes:` line is the reason anyone could tell.
24
+ *
25
+ * Hence the default is `events`, not `off`: the quiet default users want costs the
26
+ * chatter, not the audit trail.
27
+ */
28
+ import * as fsp from 'node:fs/promises';
29
+ import { getConfig, sanitizeDebugLogs } from '../config/config.js';
30
+ /**
31
+ * Escape hatch for reproducing a user's bug without walking them through
32
+ * /task-config. Follows the existing `PI_TASK_*` instrumentation convention
33
+ * (`PI_TASK_TYPEONLY_LOG`, `PI_REMOTE_PUSH_DEBUG`). An unrecognised value is
34
+ * ignored rather than treated as `off` — a typo in an env var must not silently
35
+ * throw the trail away.
36
+ */
37
+ export const DEBUG_LOG_ENV = 'PI_TASK_DEBUG_LOG';
38
+ /**
39
+ * The level in force: env override first, then the saved config. Read per call
40
+ * rather than cached, so flipping the setting mid-run takes effect on the next
41
+ * line instead of at the next restart.
42
+ */
43
+ export function debugLogLevel(getEnv = k => process.env[k]) {
44
+ const raw = getEnv(DEBUG_LOG_ENV)?.trim();
45
+ // sanitizeDebugLogs falls back to the DEFAULT for anything unrecognised, so
46
+ // an unset/typo'd var must be filtered out here rather than handed to it —
47
+ // otherwise `PI_TASK_DEBUG_LOG=verbose` would quietly override a saved `off`.
48
+ if (raw && sanitizeDebugLogs(raw) === raw)
49
+ return raw;
50
+ return getConfig().debugLogs;
51
+ }
52
+ /** Whether a line of this kind should be written at `level`. */
53
+ export function shouldLogDebug(kind, level) {
54
+ if (level === 'off')
55
+ return false;
56
+ if (level === 'full')
57
+ return true;
58
+ return kind === 'event';
59
+ }
60
+ /**
61
+ * A timestamped fire-and-forget appender for one trail file, level-gated.
62
+ *
63
+ * `kind` defaults to `'event'` so a new call site is quiet-by-default in the
64
+ * useful direction: forgetting to classify a marker keeps it in the audit trail,
65
+ * whereas forgetting to classify chatter would only make the log bigger. Errors
66
+ * are swallowed — an unwritable trail must never fail the run it is describing.
67
+ */
68
+ export function makeDebugAppender(logPath, appendFile = (p, data) => fsp.appendFile(p, data)) {
69
+ return (msg, kind = 'event') => {
70
+ if (!shouldLogDebug(kind, debugLogLevel()))
71
+ return;
72
+ void appendFile(logPath, `${new Date().toISOString()} ${msg}\n`).catch(() => { });
73
+ };
74
+ }
75
+ /**
76
+ * Wrap a raw append in the level gate. Returns `undefined` when the level is
77
+ * `off`, so callers that hold an OPTIONAL `logDebug` leave it unset and every
78
+ * `logDebug?.(…)` in the pipeline short-circuits before it formats a string.
79
+ */
80
+ export function gateDebugWriter(write, getEnv) {
81
+ if (debugLogLevel(getEnv) === 'off')
82
+ return undefined;
83
+ return (msg, kind = 'event') => {
84
+ if (shouldLogDebug(kind, debugLogLevel(getEnv)))
85
+ write(msg);
86
+ };
87
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * decompose-granularity — the deterministic FLOOR on how finely /task-auto cuts
3
+ * a feature into tasks.
4
+ *
5
+ * The failure this closes (mx5, Jul 25 vs Jul 27): the SAME design doc, from the
6
+ * SAME base commit, with a byte-identical planning path, planned once into 41
7
+ * tasks and once into 11. The whole 4x difference is one line of clarify text.
8
+ * /task-auto's clarify head asks a plan-shape question first ("one task per
9
+ * milestone, or split smaller?" — 8/8 live reps), the answer-side triage
10
+ * auto-resolves it 8/8 and stamps it "already settled by the spec", so the user
11
+ * never sees the fork; and the answer decides the whole plan. Live A/B, run 18's
12
+ * transcript with ONLY that line swapped (n=8/arm): coarse mean 11.4 titles vs
13
+ * fine mean 28.6, 63.5/64 pairwise wins, p<0.001.
14
+ *
15
+ * The spec does NOT settle it. mx5's §12 is titled "Build order (milestones)" —
16
+ * an ORDER, 9 items, not a task breakdown. So the plan's granularity, the single
17
+ * highest-leverage decision in a run (each title is handed to its own pipeline
18
+ * that researches and specs it alone), was being decided by a coin flip nobody
19
+ * could see, review, or reproduce.
20
+ *
21
+ * Surfacing the question does not fix it: under YOLO/unattended `yoloPickAnswer`
22
+ * takes the recommendation, which is the same stochastic line. The fix has to be
23
+ * host-side and deterministic, so this module derives the floor from integers the
24
+ * run already has — the count of grounded requirements that a task can OWN.
25
+ *
26
+ * floor = ceil(ownable requirements / MAX_REQUIREMENTS_PER_TASK)
27
+ *
28
+ * MAX_REQUIREMENTS_PER_TASK = 2 is anchored on the two real mx5 plans, not on
29
+ * taste: the 41-task plan carried 0.8 ownable requirements per task, the collapsed
30
+ * 11-task plan carried 2.8. A ceiling of 2 sits between them — it rejects the
31
+ * collapse without demanding the finest plan ever observed.
32
+ *
33
+ * Spec-shape-agnostic: the only inputs are two integers. A CLI, a library, a
34
+ * refactor, a docs job all flow through the same arithmetic, and a feature with
35
+ * no extracted requirements (a one-liner, a doc-less request) yields floor 0 —
36
+ * the whole channel degrades to exactly the previous behaviour.
37
+ */
38
+ /**
39
+ * The most distinct grounded requirements one task may carry before the plan is
40
+ * judged too coarse. See the module docstring for the mx5 anchoring.
41
+ */
42
+ export declare const MAX_REQUIREMENTS_PER_TASK = 2;
43
+ /**
44
+ * Fewest task titles a plan may have for `ownable` requirements. Zero when the
45
+ * requirement channel produced nothing, which disables every check below.
46
+ */
47
+ export declare function granularityFloor(ownable: number): number;
48
+ /** Is this plan too coarse for the requirements it has to carry? */
49
+ export declare function isTooCoarse(titles: number, floor: number): boolean;
50
+ /**
51
+ * Fewest ownable requirements a feature needs before the host takes the
52
+ * plan-shape fork away from the triage.
53
+ *
54
+ * Below this the fork is not load-bearing — the whole plan is one or two tasks
55
+ * either way — and seizing it does harm: live smoke over 24 prompts, the host
56
+ * directive turned "rename the `foo()` helper across the repo" (1 requirement)
57
+ * into a 6-task plan and "add a .editorconfig" (4) into 4. Every case at or above
58
+ * this cut planned inside its expected range. So: a feature with real breadth
59
+ * gets the deterministic answer, a chore keeps the old triage path untouched.
60
+ */
61
+ export declare const MIN_REQUIREMENTS_FOR_PLAN_SHAPE = 5;
62
+ /** Does this feature have enough distinct deliverables for granularity to matter? */
63
+ export declare function planShapeIsHostsToAnswer(ownable: number): boolean;
64
+ /**
65
+ * Does this clarify question decide how finely the feature is CUT into tasks?
66
+ *
67
+ * Deterministic and narrow on purpose. It must fire on the fork the triage keeps
68
+ * answering for itself ("follow the milestones as-is, or split more granularly?")
69
+ * and stay off ordinary scope questions — an over-eager classifier would replace
70
+ * a real user decision with the host's. Matched against the plain-text question.
71
+ */
72
+ export declare function isPlanShapeQuestion(question: string): boolean;
73
+ /**
74
+ * BELT — the host's own answer to that fork, recorded in the clarify transcript in
75
+ * place of the triage's.
76
+ *
77
+ * WHY A CLARIFICATION AND NOT A DECOMPOSE RULE. Both were measured live. As a
78
+ * RULES line replacing "prefer a handful of substantial tasks", the same directive
79
+ * removed the collapse but destroyed plan-size control: 66, then 81 and 85 titles
80
+ * for a spec whose healthy plan is ~30, plus one decompose child that blew the
81
+ * model's 120k context window and killed the planning phase (the baseline produced
82
+ * no such failure in 27 reps). Naming a target count made it worse, not better.
83
+ * In the CLARIFICATIONS block, with the "prefer a handful" counterweight left
84
+ * intact, the identical directive held 20–39 titles across 16 reps. The channel is
85
+ * part of the lever, not a detail.
86
+ *
87
+ * Deliberately count-free: the spec-derived floor stays host-side, where it is
88
+ * enforced silently and cannot be chased.
89
+ */
90
+ export declare const PLAN_SHAPE_QUESTION = "How finely should this feature be split into tasks?";
91
+ export declare const PLAN_SHAPE_ANSWER: string;
92
+ /**
93
+ * BRACES — the reprompt when the returned plan lands under the floor. Also
94
+ * countless, for the reason above: it asks for a SPLIT of the plan in hand rather
95
+ * than a fresh roll (a regeneration is a new stochastic draw over the whole plan
96
+ * and can drop an area the current one covers — mx5 run 12).
97
+ */
98
+ export declare function granularitySplitHint(titles: number, ownable: number): string;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * decompose-granularity — the deterministic FLOOR on how finely /task-auto cuts
3
+ * a feature into tasks.
4
+ *
5
+ * The failure this closes (mx5, Jul 25 vs Jul 27): the SAME design doc, from the
6
+ * SAME base commit, with a byte-identical planning path, planned once into 41
7
+ * tasks and once into 11. The whole 4x difference is one line of clarify text.
8
+ * /task-auto's clarify head asks a plan-shape question first ("one task per
9
+ * milestone, or split smaller?" — 8/8 live reps), the answer-side triage
10
+ * auto-resolves it 8/8 and stamps it "already settled by the spec", so the user
11
+ * never sees the fork; and the answer decides the whole plan. Live A/B, run 18's
12
+ * transcript with ONLY that line swapped (n=8/arm): coarse mean 11.4 titles vs
13
+ * fine mean 28.6, 63.5/64 pairwise wins, p<0.001.
14
+ *
15
+ * The spec does NOT settle it. mx5's §12 is titled "Build order (milestones)" —
16
+ * an ORDER, 9 items, not a task breakdown. So the plan's granularity, the single
17
+ * highest-leverage decision in a run (each title is handed to its own pipeline
18
+ * that researches and specs it alone), was being decided by a coin flip nobody
19
+ * could see, review, or reproduce.
20
+ *
21
+ * Surfacing the question does not fix it: under YOLO/unattended `yoloPickAnswer`
22
+ * takes the recommendation, which is the same stochastic line. The fix has to be
23
+ * host-side and deterministic, so this module derives the floor from integers the
24
+ * run already has — the count of grounded requirements that a task can OWN.
25
+ *
26
+ * floor = ceil(ownable requirements / MAX_REQUIREMENTS_PER_TASK)
27
+ *
28
+ * MAX_REQUIREMENTS_PER_TASK = 2 is anchored on the two real mx5 plans, not on
29
+ * taste: the 41-task plan carried 0.8 ownable requirements per task, the collapsed
30
+ * 11-task plan carried 2.8. A ceiling of 2 sits between them — it rejects the
31
+ * collapse without demanding the finest plan ever observed.
32
+ *
33
+ * Spec-shape-agnostic: the only inputs are two integers. A CLI, a library, a
34
+ * refactor, a docs job all flow through the same arithmetic, and a feature with
35
+ * no extracted requirements (a one-liner, a doc-less request) yields floor 0 —
36
+ * the whole channel degrades to exactly the previous behaviour.
37
+ */
38
+ /**
39
+ * The most distinct grounded requirements one task may carry before the plan is
40
+ * judged too coarse. See the module docstring for the mx5 anchoring.
41
+ */
42
+ export const MAX_REQUIREMENTS_PER_TASK = 2;
43
+ /**
44
+ * Fewest task titles a plan may have for `ownable` requirements. Zero when the
45
+ * requirement channel produced nothing, which disables every check below.
46
+ */
47
+ export function granularityFloor(ownable) {
48
+ return ownable <= 0 ? 0 : Math.ceil(ownable / MAX_REQUIREMENTS_PER_TASK);
49
+ }
50
+ /** Is this plan too coarse for the requirements it has to carry? */
51
+ export function isTooCoarse(titles, floor) {
52
+ return floor > 0 && titles < floor;
53
+ }
54
+ /**
55
+ * Fewest ownable requirements a feature needs before the host takes the
56
+ * plan-shape fork away from the triage.
57
+ *
58
+ * Below this the fork is not load-bearing — the whole plan is one or two tasks
59
+ * either way — and seizing it does harm: live smoke over 24 prompts, the host
60
+ * directive turned "rename the `foo()` helper across the repo" (1 requirement)
61
+ * into a 6-task plan and "add a .editorconfig" (4) into 4. Every case at or above
62
+ * this cut planned inside its expected range. So: a feature with real breadth
63
+ * gets the deterministic answer, a chore keeps the old triage path untouched.
64
+ */
65
+ export const MIN_REQUIREMENTS_FOR_PLAN_SHAPE = 5;
66
+ /** Does this feature have enough distinct deliverables for granularity to matter? */
67
+ export function planShapeIsHostsToAnswer(ownable) {
68
+ return ownable >= MIN_REQUIREMENTS_FOR_PLAN_SHAPE;
69
+ }
70
+ /**
71
+ * Does this clarify question decide how finely the feature is CUT into tasks?
72
+ *
73
+ * Deterministic and narrow on purpose. It must fire on the fork the triage keeps
74
+ * answering for itself ("follow the milestones as-is, or split more granularly?")
75
+ * and stay off ordinary scope questions — an over-eager classifier would replace
76
+ * a real user decision with the host's. Matched against the plain-text question.
77
+ */
78
+ export function isPlanShapeQuestion(question) {
79
+ const q = question.toLowerCase();
80
+ // The fork has to be about the BREAKDOWN itself…
81
+ const aboutBreakdown = /\b(task breakdown|break(ing)? (it|this|the \w+) down|decompos\w*|split\w*|subdivid\w*|granular\w*|fine[- ]grained|one task per|task per (milestone|section|step|phase|feature)|per[- ](milestone|route|component|page|module)\b|(own|separate|standalone|dedicated|self[- ]contained|single)\s+(\w+\s+)?tasks?\b)/.test(q);
82
+ if (!aboutBreakdown)
83
+ return false;
84
+ // …and offer a coarse/fine choice over the plan's own units.
85
+ return /\b(milestone|section|step|phase|task|tasks)\b/.test(q);
86
+ }
87
+ /**
88
+ * BELT — the host's own answer to that fork, recorded in the clarify transcript in
89
+ * place of the triage's.
90
+ *
91
+ * WHY A CLARIFICATION AND NOT A DECOMPOSE RULE. Both were measured live. As a
92
+ * RULES line replacing "prefer a handful of substantial tasks", the same directive
93
+ * removed the collapse but destroyed plan-size control: 66, then 81 and 85 titles
94
+ * for a spec whose healthy plan is ~30, plus one decompose child that blew the
95
+ * model's 120k context window and killed the planning phase (the baseline produced
96
+ * no such failure in 27 reps). Naming a target count made it worse, not better.
97
+ * In the CLARIFICATIONS block, with the "prefer a handful" counterweight left
98
+ * intact, the identical directive held 20–39 titles across 16 reps. The channel is
99
+ * part of the lever, not a detail.
100
+ *
101
+ * Deliberately count-free: the spec-derived floor stays host-side, where it is
102
+ * enforced silently and cannot be chased.
103
+ */
104
+ export const PLAN_SHAPE_QUESTION = 'How finely should this feature be split into tasks?';
105
+ export const PLAN_SHAPE_ANSWER = 'subdivide into smaller per-deliverable tasks — one task per route, page, screen,'
106
+ + ' module, schema, or pipeline stage — rather than one task per milestone or spec'
107
+ + ' section. A milestone or section that spans several deliverables becomes several'
108
+ + ' tasks. (host-set: the spec fixes the build ORDER, not the task breakdown, so'
109
+ + ' pi-task settles granularity deterministically instead of guessing per run)';
110
+ /**
111
+ * BRACES — the reprompt when the returned plan lands under the floor. Also
112
+ * countless, for the reason above: it asks for a SPLIT of the plan in hand rather
113
+ * than a fresh roll (a regeneration is a new stochastic draw over the whole plan
114
+ * and can drop an area the current one covers — mx5 run 12).
115
+ */
116
+ export function granularitySplitHint(titles, ownable) {
117
+ return (`[SYSTEM NOTE: your plan of ${titles} task(s) is too coarse for the`
118
+ + ` ${ownable} required contents this spec lists — several tasks each bundle work`
119
+ + ' that belongs in separate ones. Emit the SAME plan with those tasks SPLIT: keep'
120
+ + ' every task that is already one deliverable, and break each one that bundles'
121
+ + ' several routes, pages, modules, or pipeline stages into one task per piece.'
122
+ + ' Do not drop anything, do not merge, do not pad with trivia — split what is'
123
+ + ' already there.]');
124
+ }
@@ -43,6 +43,7 @@ import { captureGitState, reconcileGitState } from './git-state-guard.js';
43
43
  import { runWorker } from '../workers/pi-worker-core.js';
44
44
  import { formatLoopHint } from './child-runner.js';
45
45
  import { getConfig } from '../config/config.js';
46
+ import { makeDebugAppender } from './debug-log.js';
46
47
  import { startAutoLoader } from './widget.js';
47
48
  import { resolveContextUsage } from './context-usage.js';
48
49
  /** Max chars of a tool result kept in the gate debug log (mx5 run 10 item 6). */
@@ -352,10 +353,11 @@ export function buildGateDeps(params) {
352
353
  contextUsage = undefined;
353
354
  lastGuardReconcile = null;
354
355
  const startedAt = Date.now();
355
- const logPath = path.join(tasksDir(cwd2), logFile);
356
- const log = (msg) => {
357
- void fsp.appendFile(logPath, `${new Date().toISOString()} ${msg}\n`).catch(() => { });
358
- };
356
+ // `kind` defaults to 'event': every marker below (start/end, the
357
+ // git-state guard's restore, the loop warning, a write-capable child's
358
+ // tree changes) is a guard record that survives at the default level.
359
+ // Only the child's own stdout and its tool results pass 'stream'.
360
+ const log = makeDebugAppender(path.join(tasksDir(cwd2), logFile));
359
361
  log(`=== ${kind} start: ${taskTitle} ===`);
360
362
  // GIT-STATE GUARD: these children are read-only BY CONTRACT, but the
361
363
  // contract is prompt-level and the live model breaks it (mx5 run 6: the
@@ -399,14 +401,17 @@ export function buildGateDeps(params) {
399
401
  streamInactivityMs: getConfig().streamInactivityMs,
400
402
  loop: { pathThreshold: Number.POSITIVE_INFINITY },
401
403
  onLine: line => {
404
+ // `lastLine` feeds the LIVE status widget and is not
405
+ // logging — it stays outside the gate, or a quiet
406
+ // trail would also blank the progress display.
402
407
  lastLine = line;
403
- log(line);
408
+ log(line, 'stream');
404
409
  },
405
410
  // Log tool OUTPUTS, not just the command (mx5 run 10 item 6):
406
411
  // without the result "verify claimed curl PASS on a server that
407
412
  // cannot serve" is undecidable from the log. Truncated, tail-kept
408
413
  // (a bind failure / status usually lands at the end), error-flagged.
409
- onToolResult: ({ name, isError, text }) => log(`↳ ${name} [${isError ? 'ERR' : 'ok'}]: ${truncateToolResult(text)}`),
414
+ onToolResult: ({ name, isError, text }) => log(`↳ ${name} [${isError ? 'ERR' : 'ok'}]: ${truncateToolResult(text)}`, 'stream'),
410
415
  onContextUsage: snapshot => {
411
416
  contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
412
417
  }
@@ -549,12 +554,7 @@ export function buildGateDeps(params) {
549
554
  contextUsage = undefined;
550
555
  const startedAt = Date.now();
551
556
  // Per-pass debug log; the enforce child is otherwise unobservable.
552
- const enforceLogPath = path.join(tasksDir(cwd2), 'enforce-debug.log');
553
- const logEnforce = (msg) => {
554
- void fsp
555
- .appendFile(enforceLogPath, `${new Date().toISOString()} ${msg}\n`)
556
- .catch(() => { });
557
- };
557
+ const logEnforce = makeDebugAppender(path.join(tasksDir(cwd2), 'enforce-debug.log'));
558
558
  logEnforce(`=== enforce start: ${taskTitle} ===`);
559
559
  const stopLoader = startAutoLoader(enforceCtx, () => ({
560
560
  title: taskTitle,
@@ -587,8 +587,9 @@ export function buildGateDeps(params) {
587
587
  // literally-identical call repeated past threshold does.
588
588
  loop: { pathThreshold: Number.POSITIVE_INFINITY },
589
589
  onLine: line => {
590
+ // `lastLine` drives the live widget, not the trail.
590
591
  lastLine = line;
591
- logEnforce(line);
592
+ logEnforce(line, 'stream');
592
593
  },
593
594
  onContextUsage: snapshot => {
594
595
  contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
@@ -666,9 +667,7 @@ export function buildGateDeps(params) {
666
667
  // resolves; the remainder is injected under rule 4e, whose point is
667
668
  // that such a path breaks the BUILD — so the checks that would have
668
669
  // caught it report nothing rather than failing.
669
- foreignPathProbe: () => collectForeignPathFindings(cwd2, signal, msg => void fsp
670
- .appendFile(path.join(tasksDir(cwd2), 'verify-debug.log'), `${new Date().toISOString()} ${msg}\n`)
671
- .catch(() => { })),
670
+ foreignPathProbe: () => collectForeignPathFindings(cwd2, signal, makeDebugAppender(path.join(tasksDir(cwd2), 'verify-debug.log'))),
672
671
  // Deterministic neutered-check-script probe (mx5 run 13 PROMPT 4
673
672
  // item 4): a check script this task authored that cannot fail
674
673
  // (`… || true`, an inverted-grep launder). Injected under rule 4f,
@@ -785,11 +784,7 @@ export function buildGateDeps(params) {
785
784
  // preserve registry), never a per-task union.
786
785
  treeChanges: () => collectTreeChanges(cwd2, signal),
787
786
  probeScan: () => collectAddedLines(cwd2, signal).then(findProbeGaming),
788
- log: msg => {
789
- void fsp
790
- .appendFile(path.join(tasksDir(cwd2), 'final-gate-debug.log'), `${new Date().toISOString()} ${msg}\n`)
791
- .catch(() => { });
792
- }
787
+ log: makeDebugAppender(path.join(tasksDir(cwd2), 'final-gate-debug.log'))
793
788
  }),
794
789
  recommend: async (recCtx, cwd2, taskTitle, taskId, failReason) => {
795
790
  // Read the same composed spec the verify gate judged against, so the
@@ -28,6 +28,7 @@ import { armImplWidget, disarmImplWidget, setupImplWidget } from './impl-widget.
28
28
  import { publishViewer, publishNotify, publishLifecycleNotice, registerBridgeCommand, getBridge, SessionUI } from '../remote/bridge.js';
29
29
  import { pushNotify } from '../remote/push.js';
30
30
  import { getConfig } from '../config/config.js';
31
+ import { gateDebugWriter } from './debug-log.js';
31
32
  import { consumeWatchdogAbort, WATCHDOG_CANCEL_MARKER } from './command-watchdog.js';
32
33
  import { buildGateDeps } from './gate-deps.js';
33
34
  import { runGatesForTask } from './task-gates.js';
@@ -191,13 +192,15 @@ export class TaskRunner {
191
192
  await this._onStart(id);
192
193
  // Wire up per-task debug log (<cwd>/.pi-tasks/TASK_XXXX-debug.log).
193
194
  const debugLogPath = path.join(tasksDir(cwd), `${id}-debug.log`);
194
- this._deps.logDebug = (msg) => {
195
+ // Left UNSET at level `off`, so the ~39 `logDebug?.()` sites downstream
196
+ // short-circuit before they format a string and the file is never created.
197
+ this._deps.logDebug = gateDebugWriter((msg) => {
195
198
  const line = `${new Date().toISOString()} ${msg}\n`;
196
199
  fsp.appendFile(debugLogPath, line).catch(() => {
197
200
  /* ignore */
198
201
  });
199
- };
200
- this._deps.logDebug(`run: start phase=${resumePhase}`);
202
+ });
203
+ this._deps.logDebug?.(`run: start phase=${resumePhase}`);
201
204
  // Register as active.
202
205
  this._widgetState.taskId = id;
203
206
  this._widgetState.title = title;
@@ -630,7 +630,10 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
630
630
  ...(spec.tools ? { tools: spec.tools } : {}),
631
631
  ...(spec.extensions ? { extensions: spec.extensions } : {}),
632
632
  onLine: line => {
633
- deps.logDebug?.(`${spec.label}: ${line}`);
633
+ // The one 'stream' site in this file: raw research-worker
634
+ // output. Every other logDebug here records a decision.
635
+ // onChildOutput drives the widget and is not gated.
636
+ deps.logDebug?.(`${spec.label}: ${line}`, 'stream');
634
637
  deps.onChildOutput?.(`${spec.label}: ${line}`);
635
638
  }
636
639
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.21.10",
3
+ "version": "0.23.0",
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",