@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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|