@mjasnikovs/pi-task 0.21.10 → 0.22.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.
@@ -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();
@@ -42,6 +42,7 @@ import { isYoloMode, yoloPickAnswer, yoloFinalGateChoice, YOLO_STAMP } from './y
42
42
  import { configureResearchRun, resumeResearchRun } from '../workers/research-cache.js';
43
43
  import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
44
44
  import { reconcileTitleSources } from './decompose-fidelity.js';
45
+ import { granularityFloor, granularitySplitHint, isPlanShapeQuestion, isTooCoarse, planShapeIsHostsToAnswer, PLAN_SHAPE_ANSWER } from './decompose-granularity.js';
45
46
  import { mandatesTestsInSameChange, rewriteBatchTestPlan } from './batch-test-task.js';
46
47
  import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, writeOwnedRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
47
48
  import { decideAdoption, groundedCoverage } from './coverage-loop.js';
@@ -424,6 +425,54 @@ export async function planAuto(ctx, cwd, feature, deps) {
424
425
  taskId: '',
425
426
  signal: new AbortController().signal
426
427
  }).catch(() => '');
428
+ // Requirement extraction (mx5 run 11, goal A): grounded requirement units,
429
+ // extracted from whatever structure the spec has, BEFORE decompose — they ride
430
+ // into the decompose prompt as a ledger (structure-mirroring can't discharge
431
+ // them) and drive the per-requirement coverage accounting below.
432
+ //
433
+ // Runs BEFORE clarify (it depends only on the inlined feature, never on the
434
+ // answers) so the plan-shape gate below has a real count to judge with: the
435
+ // host must not seize the granularity fork on a spec that has no breakdown to
436
+ // speak of. Best-effort:
437
+ // a fault leaves reqEntries empty and the whole channel degrades to the old
438
+ // behavior (one-liners / doc-less features naturally yield few or none).
439
+ let reqEntries = [];
440
+ try {
441
+ // Recall floor: the obligation-marked passages ride into the prompt as a
442
+ // checklist, and a marked passage that produced NO quote is hard evidence
443
+ // for one forced re-extraction (measured live: 1/5 extractions missed the
444
+ // entire marked testing section without this).
445
+ const passages = enumerateObligationPassages(featureForModel);
446
+ const extractOnce = async (hint) => keepGroundedRequirements(parseRequirementLines(await deps.runChild('requirement-extract', '', prependHint(hint, REQUIREMENT_EXTRACT_PROMPT(featureForModel, passages)))), featureForModel);
447
+ reqEntries = await extractOnce(null);
448
+ const uncovered = uncoveredPassages(passages, reqEntries);
449
+ if (uncovered.length > 0) {
450
+ logPlanDebug(cwd, `requirement extraction: ${uncovered.length} obligation-marked passage(s) `
451
+ + 'uncovered — forcing one re-extraction');
452
+ const retry = await extractOnce(extractionRetryHint(uncovered));
453
+ // Union of both grounded passes (keepGrounded dedupes).
454
+ reqEntries = keepGroundedRequirements([...reqEntries, ...retry], featureForModel);
455
+ }
456
+ // Bound with marked-passage priority — a plain first-N cap truncates the
457
+ // doc's tail sections (measured live: an eager model fills 40 top-down).
458
+ reqEntries = capRequirements(reqEntries, passages, featureForModel);
459
+ logPlanDebug(cwd, `requirement extraction: ${reqEntries.length} grounded requirement(s) kept`);
460
+ }
461
+ catch {
462
+ // best-effort channel
463
+ }
464
+ // Granularity floor (mx5 Jul 25 vs Jul 27): the plan's task COUNT was being set
465
+ // by an auto-resolved clarify line the user never saw — the same spec planned
466
+ // into 41 tasks one day and 11 the next, with identical code. Derive the floor
467
+ // from the requirements a task can own, so an unreviewable "one task per
468
+ // milestone" decision cannot collapse the plan; it also gates whether the
469
+ // plan-shape fork below is the host's to answer at all. 0 ownable ⇒ no channel.
470
+ const ownableRequirements = reqEntries.filter(e => !isCrossCuttingRequirement(e.quote)).length;
471
+ const coarseFloor = granularityFloor(ownableRequirements);
472
+ if (coarseFloor > 0) {
473
+ logPlanDebug(cwd, `granularity floor: ${ownableRequirements} ownable requirement(s) ⇒ at least `
474
+ + `${coarseFloor} task(s)`);
475
+ }
427
476
  const answers = [];
428
477
  // Plain text of every question already shown, for the duplicate backstop.
429
478
  const askedQuestions = [];
@@ -458,6 +507,19 @@ export async function planAuto(ctx, cwd, feature, deps) {
458
507
  const shownQ = renderInlineMarkdown(question, theme);
459
508
  const plainQ = stripInlineMarkdown(question);
460
509
  askedQuestions.push(plainQ);
510
+ // PLAN SHAPE is the host's call, not the triage's (mx5 41→11 tasks on the
511
+ // same spec, same base commit, same code — see decompose-granularity.ts).
512
+ // The triage answers this fork for itself in 8/8 live reps and stamps it
513
+ // "already settled by the spec" while the spec settles no such thing, so the
514
+ // single most load-bearing decision in a run was an invisible coin flip.
515
+ // Answer it deterministically instead: same channel, same transcript, but a
516
+ // fixed value the user can read in the AUTO file and override next run.
517
+ if (planShapeIsHostsToAnswer(ownableRequirements) && isPlanShapeQuestion(plainQ)) {
518
+ logPlanDebug(cwd, `plan-shape question answered host-side (not the triage): ${plainQ.replace(/\s+/g, ' ').slice(0, 120)}`);
519
+ answers.push(`Q${answers.length + 1}: ${plainQ}\n`
520
+ + `A${answers.length + 1}: ${PLAN_SHAPE_ANSWER} (host-set — plan granularity is not left to chance)`);
521
+ continue;
522
+ }
461
523
  // Answer-side triage (grill parity): if the inlined spec already settles
462
524
  // this question, auto-resolve it and never show it. The resolved value is
463
525
  // recorded so decompose sees the decision and the next gen call's priorQA
@@ -544,37 +606,6 @@ export async function planAuto(ctx, cwd, feature, deps) {
544
606
  ctx.ui.notify('No clarifying questions needed — planning tasks…', 'info');
545
607
  }
546
608
  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
609
  // Artifact-production closure, plan side (mx5 run 13, PROMPT 2): runtime
579
610
  // files the spec REFERENCES (server snippets, prose "serve the built
580
611
  // index.html") that neither its file tree, its parsed build outputs, nor the
@@ -636,6 +667,20 @@ export async function planAuto(ctx, cwd, feature, deps) {
636
667
  const listRaw = await deps.runChild('auto-decompose', 'read', decomposePrompt);
637
668
  let planTitles = parsePlan(listRaw);
638
669
  logPlanDebug(cwd, `decompose produced ${planTitles.length} title(s)`);
670
+ // BRACES for the floor: the prompt clause alone is a preference the model can
671
+ // ignore, so a plan under the floor is sent back ONCE to be split (never
672
+ // regenerated — a fresh roll can drop a covered area, mx5 run 12). Longer plan
673
+ // wins; a still-coarse plan falls through to the coverage judge as before, so
674
+ // this can never block planning.
675
+ if (isTooCoarse(planTitles.length, coarseFloor)) {
676
+ logPlanDebug(cwd, `plan under the granularity floor (${planTitles.length} < ${coarseFloor}) — `
677
+ + 'reprompting once to split');
678
+ const splitRaw = await deps.runChild('auto-decompose', 'read', prependHint(granularitySplitHint(planTitles.length, ownableRequirements), decomposePrompt));
679
+ const splitTitles = parsePlan(splitRaw);
680
+ logPlanDebug(cwd, `granularity split-retry produced ${splitTitles.length} title(s)`);
681
+ if (splitTitles.length > planTitles.length)
682
+ planTitles = splitTitles;
683
+ }
639
684
  // Distrust floor (see isSuspectPlan): a ≤2-title plan for a multi-KB spec is
640
685
  // regenerated once BEFORE the judge runs — the judge cannot be trusted to
641
686
  // 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
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.21.10",
3
+ "version": "0.22.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",