@mjasnikovs/pi-task 0.23.0 → 0.24.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.
@@ -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) {
@@ -31,7 +31,18 @@ export declare function keepGroundedRequirements(entries: RequirementEntry[], so
31
31
  * never the model-authored anchor text. Without `sourceDoc` (or for quotes that
32
32
  * cannot be located) the fill degrades to the old given-order behavior.
33
33
  */
34
- export declare function capRequirements(entries: RequirementEntry[], passages: string[], sourceDoc?: string): RequirementEntry[];
34
+ export declare function capRequirements(entries: RequirementEntry[], passages: string[], sourceDoc?: string,
35
+ /** A/B seam: `false` reproduces the pre-budget rule, so an offline harness can
36
+ * score the shipped rule against the one it replaced without transcribing
37
+ * sectionFairFill and letting the copy drift. Production never passes it. */
38
+ deprioritiseLowValue?: boolean): RequirementEntry[];
39
+ /**
40
+ * Quotes that pass the grounding guard (verbatim substring of the doc) but state
41
+ * no obligation. There is no obligation test anywhere else in the pipeline —
42
+ * `keepGroundedRequirements` only checks the quote really appears in the source,
43
+ * so any sentence at all survives it and precision rests entirely on the model.
44
+ */
45
+ export declare function isLowValueQuote(quote: string): boolean;
35
46
  /**
36
47
  * DETERMINISTIC RECALL FLOOR (same medicine as the launch-contract checklist):
37
48
  * paragraphs carrying an obligation marker (word-bounded "required"/"must").
@@ -98,7 +98,11 @@ export function keepGroundedRequirements(entries, sourceDoc) {
98
98
  * never the model-authored anchor text. Without `sourceDoc` (or for quotes that
99
99
  * cannot be located) the fill degrades to the old given-order behavior.
100
100
  */
101
- export function capRequirements(entries, passages, sourceDoc) {
101
+ export function capRequirements(entries, passages, sourceDoc,
102
+ /** A/B seam: `false` reproduces the pre-budget rule, so an offline harness can
103
+ * score the shipped rule against the one it replaced without transcribing
104
+ * sectionFairFill and letting the copy drift. Production never passes it. */
105
+ deprioritiseLowValue = true) {
102
106
  if (entries.length <= MAX_REQUIREMENTS)
103
107
  return entries;
104
108
  const norms = passages.map(normalise);
@@ -109,7 +113,114 @@ export function capRequirements(entries, passages, sourceDoc) {
109
113
  const marked = entries.filter(covers);
110
114
  const rest = entries.filter(e => !covers(e));
111
115
  const budget = MAX_REQUIREMENTS - Math.min(marked.length, MAX_REQUIREMENTS);
112
- return [...marked.slice(0, MAX_REQUIREMENTS), ...sectionFairFill(rest, budget, sourceDoc)];
116
+ // The low-value filter applies to the UNMARKED remainder only. Quoting an
117
+ // obligation-marked passage is the pipeline's existing, validated evidence
118
+ // that a quote states an obligation, and it outranks any lexical heuristic:
119
+ // "MUST log every request" is 22 characters and every length-based rule reads
120
+ // it as a fragment. Filtering ahead of the marked/rest split deleted it.
121
+ //
122
+ // INSURANCE, not a measured win on mx5: across both 30-run pools exactly one
123
+ // distinct quote per pool is low-value AND marked, and it is a genuinely
124
+ // truncated one. The layering matters for specs whose obligations are SHORT,
125
+ // which mx5's are not. Do not cite it as the reason tail coverage holds —
126
+ // tail coverage is identical with the filter applied before the split.
127
+ const pool = deprioritiseLowValue ? budgetedByObligation(rest, budget, sourceDoc) : rest;
128
+ return [...marked.slice(0, MAX_REQUIREMENTS), ...sectionFairFill(pool, budget, sourceDoc)];
129
+ }
130
+ /** Longest a dependency-pin row can be before it is presumed to carry an
131
+ * obligation after the pin. Real pins in the measured corpus run 32..48 chars;
132
+ * the pin-prefixed lines that DO obligate ("TypeScript `6.0.3` — one strict
133
+ * `tsconfig.json`: `strict`, `noUncheckedIndexedAccess`, …") run 130..260. */
134
+ const MAX_PIN_LENGTH = 80;
135
+ /** Cut mid-expression: an unbalanced fence or bracket, or a trailing separator.
136
+ * NOT `;` — a complete clause legitimately ends with one, and dropping on `;`
137
+ * discarded a runnable `lint` = `prettier … && eslint … && tsc --noEmit` line. */
138
+ function isTruncatedQuote(q) {
139
+ if ((q.match(/`/g) ?? []).length % 2 === 1)
140
+ return true;
141
+ for (const [open, close] of [
142
+ ['(', ')'],
143
+ ['[', ']'],
144
+ ['{', '}']
145
+ ]) {
146
+ const o = (q.match(new RegExp(`\\${open}`, 'g')) ?? []).length;
147
+ const c = (q.match(new RegExp(`\\${close}`, 'g')) ?? []).length;
148
+ if (o > c)
149
+ return true;
150
+ }
151
+ return /[,:([{]\s*$/.test(q.trim());
152
+ }
153
+ /** A bare version row — data, not an obligation. Length-gated, see MAX_PIN_LENGTH. */
154
+ function isDependencyPin(q) {
155
+ const t = q.trim().replace(/^\*\*|\*\*$/g, '');
156
+ if (t.length > MAX_PIN_LENGTH)
157
+ return false;
158
+ return /^\**[`*]?[\w@/-]+[`*]?\**\s*[`']?\d+\.\d+/.test(t);
159
+ }
160
+ /** A DDL/schema row: `col type …`. */
161
+ function isSchemaRow(q) {
162
+ return /^\s*[\w_]+\s+(?:uuid|text|int|integer|bigint|boolean|timestamptz|bytea|jsonb|numeric|smallint)\b/i.test(q);
163
+ }
164
+ /** Too short to state an obligation. MIN_QUOTE_LENGTH (6) admits "Contact
165
+ * seller"; a clause needs a subject and a predicate. */
166
+ function isQuoteFragment(q) {
167
+ const t = q.trim();
168
+ return t.length < 25 || t.split(/\s+/).length < 4;
169
+ }
170
+ /**
171
+ * Quotes that pass the grounding guard (verbatim substring of the doc) but state
172
+ * no obligation. There is no obligation test anywhere else in the pipeline —
173
+ * `keepGroundedRequirements` only checks the quote really appears in the source,
174
+ * so any sentence at all survives it and precision rests entirely on the model.
175
+ */
176
+ export function isLowValueQuote(quote) {
177
+ return (isTruncatedQuote(quote)
178
+ || isDependencyPin(quote)
179
+ || isSchemaRow(quote)
180
+ || isQuoteFragment(quote));
181
+ }
182
+ /**
183
+ * Deprioritise obligation-free quotes, but only as far as the BUDGET requires.
184
+ *
185
+ * Measured (mx5, 20402-char spec, two independent 30-run extraction pools): the
186
+ * extractor's single-pass yield swings 20..160 for byte-identical input, and the
187
+ * padding crowds real obligations out of the 40 that ship — high-yield runs land
188
+ * FEWER critical obligations than low-yield ones. Critical obligations reaching
189
+ * the shipped list go 8.50 → 9.37 of 16 on the design pool (10 runs better, 0
190
+ * worse, p=0.0020) and 7.70 → 8.43 on the confirmation pool (12 / 0, p=0.0005).
191
+ *
192
+ * BUDGETED, not absolute. Below the cap no slot is contested, so dropping there
193
+ * destroys information and buys nothing; a run that filtered 55 quotes down to 20
194
+ * lost its only carrier of the Argon2id obligation — a DDL row that was correctly
195
+ * classified as one — while 20 slots sat empty. So the low-value entries come back
196
+ * in source-doc order until the list reaches the cap.
197
+ *
198
+ * Absolute filtering scored marginally higher on raw count (24 gains vs 22 across
199
+ * both pools) and was rejected anyway: its extra gains are one more obligation in
200
+ * an already-populated list, while its one loss is an obligation vanishing from a
201
+ * run outright. Those are not the same size of mistake.
202
+ *
203
+ * Doc order for the restore is the neutral choice: which entries return only
204
+ * matters when more were dropped than there are free slots, and ordering by
205
+ * anything fitted to an observed loss would be tuning the rule to one pool.
206
+ */
207
+ function budgetedByObligation(entries, budget, sourceDoc) {
208
+ const keep = entries.filter(e => !isLowValueQuote(e.quote));
209
+ if (keep.length >= budget)
210
+ return keep;
211
+ const at = (e) => {
212
+ if (!sourceDoc)
213
+ return Number.MAX_SAFE_INTEGER;
214
+ const i = sourceDoc.indexOf(e.quote.trim());
215
+ return i < 0 ? Number.MAX_SAFE_INTEGER : i;
216
+ };
217
+ const restored = entries
218
+ .filter(e => isLowValueQuote(e.quote))
219
+ .map((e, given) => ({ e, at: at(e), given }))
220
+ .sort((x, y) => x.at - y.at || x.given - y.given)
221
+ .slice(0, budget - keep.length)
222
+ .map(x => x.e);
223
+ return [...keep, ...restored];
113
224
  }
114
225
  /** The doc split into heading-delimited sections, each pre-normalised for
115
226
  * containment tests. Text before the first heading is its own section. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.23.0",
3
+ "version": "0.24.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",