@mjasnikovs/pi-task 0.23.0 → 0.23.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.
@@ -89,9 +89,26 @@ function coverageRepromptHint(missing) {
89
89
  // that insists twice still ships its small plan, with a warning.
90
90
  const SUSPECT_PLAN_MAX_TITLES = 2;
91
91
  const SUSPECT_PLAN_MIN_SPEC_CHARS = 4000;
92
+ /**
93
+ * Extra retries granted when the plan is EMPTY rather than merely small. One
94
+ * hinted retry heals a small-but-nonempty plan reliably; an empty generation is a
95
+ * harder fault and was measured recurring back-to-back (2026-07-28 smoke: 13 empty
96
+ * draws across 24 reps of a 20KB spec, including two in a row in one rep).
97
+ */
98
+ const EMPTY_PLAN_RETRIES = 2;
99
+ /**
100
+ * An empty list is NEVER a valid decomposition of any feature request, at any spec
101
+ * size. It used to escape this guard entirely — the old predicate opened with
102
+ * `titles.length > 0`, so zero titles was not "suspect", the suspect-retry never
103
+ * fired, the coverage loop broke immediately on `titles.length === 0`, and the run
104
+ * aborted with "no tasks produced from the feature". A single degenerate
105
+ * generation killed the whole run with no retry, which is the opposite of how the
106
+ * same fault is treated one title higher.
107
+ */
92
108
  function isSuspectPlan(titles, featureForModel) {
93
- return (titles.length > 0
94
- && titles.length <= SUSPECT_PLAN_MAX_TITLES
109
+ if (titles.length === 0)
110
+ return true;
111
+ return (titles.length <= SUSPECT_PLAN_MAX_TITLES
95
112
  && featureForModel.length >= SUSPECT_PLAN_MIN_SPEC_CHARS);
96
113
  }
97
114
  /** Reprompt prefix for a suspect (degenerate-count) list; unlike
@@ -695,8 +712,32 @@ export async function planAuto(ctx, cwd, feature, deps) {
695
712
  // catch it (3/10 live false-pass) and a hinted retry heals it reliably
696
713
  // (5/5 live). Longer list wins; a still-suspect plan falls through to the
697
714
  // judge loop as before, so this never blocks planning.
698
- if (isSuspectPlan(planTitles, featureForModel)) {
715
+ // An EMPTY plan gets extra attempts (see EMPTY_PLAN_RETRIES): falling through
716
+ // with zero titles aborts the whole run, so one roll of the dice is not enough.
717
+ // A merely-small plan keeps its single retry — it still ships if the retry does
718
+ // not help, so spending more children on it buys nothing.
719
+ //
720
+ // The two budgets are tracked SEPARATELY on purpose. A single counter bounded by
721
+ // `plan.length === 0 ? EMPTY_PLAN_RETRIES : 0` re-reads the bound against the
722
+ // CURRENT plan, so an empty draw that healed to a still-suspect 1-title plan saw
723
+ // the bound collapse to 0 and skipped the small-plan retry that an identical
724
+ // 1-title FIRST draw would have received. Same end state, different treatment,
725
+ // purely because of how it got there.
726
+ let emptyAttempts = 0;
727
+ let smallRetryUsed = false;
728
+ while (isSuspectPlan(planTitles, featureForModel)) {
729
+ if (planTitles.length === 0) {
730
+ if (emptyAttempts > EMPTY_PLAN_RETRIES)
731
+ break;
732
+ emptyAttempts++;
733
+ }
734
+ else {
735
+ if (smallRetryUsed)
736
+ break;
737
+ smallRetryUsed = true;
738
+ }
699
739
  logPlanDebug(cwd, `decompose suspect (${planTitles.length} title(s) for a ${featureForModel.length}-char spec)`
740
+ + `${emptyAttempts > 1 ? ` — empty retry ${emptyAttempts}` : ''}`
700
741
  + ` — raw output: ${listRaw.trim().slice(0, 300)}`);
701
742
  const retryRaw = await deps.runChild('auto-decompose', 'read', prependHint(suspectPlanHint(planTitles.length), decomposePrompt));
702
743
  const retryTitles = parsePlan(retryRaw);
@@ -85,6 +85,10 @@ export interface AdoptionDecision {
85
85
  * current plan already owns (non-superset owned-set). This is the monotone
86
86
  * guarantee; it holds regardless of how the un-ownable requirements were
87
87
  * classified, so it backstops the cross-cutting classifier completely.
88
+ * 2b. WITH requirement signal: reject a retry that grows the plan while covering
89
+ * NOTHING new — the tiebreak that makes "ship the best" also mean "ship the
90
+ * smallest among equals". Without it the guard is inert against the superset
91
+ * the reprompt asks for; see the long note at the branch.
88
92
  * 3. WITHOUT requirement signal: fall back to the count floor, and additionally
89
93
  * refuse a retry that leaves MORE areas uncovered than the current plan — so
90
94
  * the no-requirements path also ships the best, not the last.
@@ -215,6 +215,10 @@ export function droppedCoverage(current, retry) {
215
215
  * current plan already owns (non-superset owned-set). This is the monotone
216
216
  * guarantee; it holds regardless of how the un-ownable requirements were
217
217
  * classified, so it backstops the cross-cutting classifier completely.
218
+ * 2b. WITH requirement signal: reject a retry that grows the plan while covering
219
+ * NOTHING new — the tiebreak that makes "ship the best" also mean "ship the
220
+ * smallest among equals". Without it the guard is inert against the superset
221
+ * the reprompt asks for; see the long note at the branch.
218
222
  * 3. WITHOUT requirement signal: fall back to the count floor, and additionally
219
223
  * refuse a retry that leaves MORE areas uncovered than the current plan — so
220
224
  * the no-requirements path also ships the best, not the last.
@@ -238,6 +242,43 @@ export function decideAdoption(current, retry, hasRequirements) {
238
242
  dropped
239
243
  };
240
244
  }
245
+ // Growth must PAY FOR ITSELF. Past this point the retry's owned-set is a
246
+ // superset, so an equal size means the sets are IDENTICAL — the retry
247
+ // covers nothing new. Adopting it anyway is how mx5 (2026-07-28) went
248
+ // 26 → 32 → 60 titles with the owned-set pinned at 27 in all three rounds,
249
+ // both retries logged as "preserves owned coverage".
250
+ //
251
+ // The old rule could not object, by construction: groundedCoverage is
252
+ // monotone in the title set (titleTokens is a union over titles; df/maxDF
253
+ // depend only on the quotes), and coverageRepromptHint asks the model for
254
+ // "every task your previous list already had ... PLUS" — a superset. So
255
+ // `dropped` is structurally empty whenever the model obeys the hint and the
256
+ // guard adopted unconditionally (measured: 2000/2000 superset retries
257
+ // adopted; 563/2000 independently-sampled ones rejected — the guard has
258
+ // power, just not against the shape the prompt requests).
259
+ //
260
+ // REJECT, never break. Rejection keeps the smaller plan and lets the loop
261
+ // reprompt again; breaking here forfeits a later round that would have
262
+ // gained (live: 19t/24c → 38t/24c → 40t/25c, where stopping at the tie
263
+ // loses the 25th requirement). "No gain this round" is not "no gain ever".
264
+ //
265
+ // Safety is structural, not statistical: this branch is reachable only when
266
+ // the retry covers NO MORE than the current plan, so it can never decline a
267
+ // strictly better one. Live A/B (Qwen3.6-27B, mx5 20KB spec, 24 reps,
268
+ // precondition 24/24): inflated plans 7/24 → 0/24, Fisher two-sided
269
+ // p=0.0094; coverage mean 24.58 → 24.79 (higher in 6 reps, lower in 1, and
270
+ // that one rep never fired this clause); plan size 41.8 → 38.8, which is
271
+ // NOT significant (sign 15/8, p=0.21) — the win is removing pathological
272
+ // inflation, not shrinking plans generally.
273
+ if (retry.covered.size <= current.covered.size
274
+ && retry.titles.length > current.titles.length) {
275
+ return {
276
+ adopt: false,
277
+ reason: `no coverage gain for +${retry.titles.length - current.titles.length} titles `
278
+ + `(${current.covered.size} owned, unchanged)`,
279
+ dropped: []
280
+ };
281
+ }
241
282
  return { adopt: true, reason: 'preserves owned coverage', dropped: [] };
242
283
  }
243
284
  if (retry.missing.length > current.missing.length) {
@@ -43,6 +43,18 @@ export declare const MAX_REQUIREMENTS_PER_TASK = 2;
43
43
  /**
44
44
  * Fewest task titles a plan may have for `ownable` requirements. Zero when the
45
45
  * requirement channel produced nothing, which disables every check below.
46
+ *
47
+ * Also zero below MIN_REQUIREMENTS_FOR_PLAN_SHAPE, for the reason that constant
48
+ * already documents: under a handful of requirements the plan is one or two tasks
49
+ * either way, and the requirement COUNT at that scale is an artifact of extraction
50
+ * granularity rather than real breadth. Measured (2026-07-28 size smoke): the
51
+ * 78-char feature "Add a `--version` flag to the CLI that prints the package
52
+ * version and exits 0" extracted THREE ownable requirements — the flag, the
53
+ * print, the exit code — yielding a floor of 2 for what is unambiguously one
54
+ * task. Both arms correctly shipped 1 title, so the floor bought nothing and cost
55
+ * a split-retry child; had anything ever made it binding it would have forced a
56
+ * bad split. The same cut governs both because it is the same judgement: the
57
+ * requirement channel is not load-bearing for shape until a feature has real breadth.
46
58
  */
47
59
  export declare function granularityFloor(ownable: number): number;
48
60
  /** Is this plan too coarse for the requirements it has to carry? */
@@ -43,9 +43,23 @@ export const MAX_REQUIREMENTS_PER_TASK = 2;
43
43
  /**
44
44
  * Fewest task titles a plan may have for `ownable` requirements. Zero when the
45
45
  * requirement channel produced nothing, which disables every check below.
46
+ *
47
+ * Also zero below MIN_REQUIREMENTS_FOR_PLAN_SHAPE, for the reason that constant
48
+ * already documents: under a handful of requirements the plan is one or two tasks
49
+ * either way, and the requirement COUNT at that scale is an artifact of extraction
50
+ * granularity rather than real breadth. Measured (2026-07-28 size smoke): the
51
+ * 78-char feature "Add a `--version` flag to the CLI that prints the package
52
+ * version and exits 0" extracted THREE ownable requirements — the flag, the
53
+ * print, the exit code — yielding a floor of 2 for what is unambiguously one
54
+ * task. Both arms correctly shipped 1 title, so the floor bought nothing and cost
55
+ * a split-retry child; had anything ever made it binding it would have forced a
56
+ * bad split. The same cut governs both because it is the same judgement: the
57
+ * requirement channel is not load-bearing for shape until a feature has real breadth.
46
58
  */
47
59
  export function granularityFloor(ownable) {
48
- return ownable <= 0 ? 0 : Math.ceil(ownable / MAX_REQUIREMENTS_PER_TASK);
60
+ if (ownable < MIN_REQUIREMENTS_FOR_PLAN_SHAPE)
61
+ return 0;
62
+ return Math.ceil(ownable / MAX_REQUIREMENTS_PER_TASK);
49
63
  }
50
64
  /** Is this plan too coarse for the requirements it has to carry? */
51
65
  export function isTooCoarse(titles, floor) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.23.0",
3
+ "version": "0.23.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",