@mjasnikovs/pi-task 0.18.19 → 0.18.20

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.
@@ -42,7 +42,19 @@ import { LAUNCH_EXTRACT_PROMPT, enumerateScriptCandidates, parseScriptLines, kee
42
42
  const MAX_CLARIFY_QUESTIONS = 8;
43
43
  // Bounded coverage-triage rounds after decompose: judge → reprompt-with-missing
44
44
  // → judge again, at most. Two rounds so one flaky retry doesn't end the gate,
45
- // while a judge that keeps flagging can't loop the plan phase forever.
45
+ // while a judge that keeps flagging can't loop the plan phase forever. Each round
46
+ // spawns three model children (decompose + coverage-map + coverage-verdict), so
47
+ // the ceiling is also a latency/spawn budget, not just a correctness bound.
48
+ //
49
+ // Why 2 is safe to sit this low: when this number was picked (2026-07-03) adoption
50
+ // was LAST-WINS, so more rounds meant more chances to overwrite a good plan with a
51
+ // worse regeneration — the cap was protective. Adoption is now MONOTONE
52
+ // (coverage-loop.ts, 2026-07-15): a retry that drops owned coverage is rejected,
53
+ // never adopted, so extra rounds can only hold or grow coverage. The one gap that
54
+ // remained is that an adoption landing ON the last round can expose a NEW area with
55
+ // no round left to chase it — handled surgically by a single bonus round granted
56
+ // only in that exact case (see the loop), rather than by raising this ceiling for
57
+ // every run.
46
58
  const MAX_COVERAGE_ROUNDS = 2;
47
59
  /** Reprompt prefix when the coverage triage found feature areas no task covers. */
48
60
  function coverageRepromptHint(missing) {
@@ -106,6 +118,17 @@ function logPlanDebug(cwd, msg) {
106
118
  .then(() => fsp.appendFile(path.join(dir, 'plan-debug.log'), line))
107
119
  .catch(() => { });
108
120
  }
121
+ /** Normalise a missing-area string for cross-round identity — lowercased alnum
122
+ * words, punctuation and quote-wrapping collapsed. Used only to tell whether an
123
+ * adopted plan introduced a NEW gap versus re-surfacing the same one (#2 bonus
124
+ * round); intentionally coarse, so trivial rewording of the same area does not
125
+ * read as new and buy an extra round. */
126
+ function normMissingArea(s) {
127
+ return s
128
+ .toLowerCase()
129
+ .replace(/[^a-z0-9]+/g, ' ')
130
+ .trim();
131
+ }
109
132
  /**
110
133
  * Clarify's answer-side TRIAGE — the second stage /task-auto's clarify gate was
111
134
  * missing that /task's grill already had. /task-auto's clarify was single-stage:
@@ -569,7 +592,8 @@ export async function planAuto(ctx, cwd, feature, deps) {
569
592
  return {
570
593
  plan: { titles, covered, missing },
571
594
  accounting: acc,
572
- suspect: isSuspectPlan(titles, featureForModel)
595
+ suspect: isSuspectPlan(titles, featureForModel),
596
+ judgeMissing: verdictMissing
573
597
  };
574
598
  };
575
599
  const hasRequirements = reqEntries.length > 0;
@@ -579,6 +603,17 @@ export async function planAuto(ctx, cwd, feature, deps) {
579
603
  // The carried accounting (cross-cutting + unowned) for the plan that ships.
580
604
  let accounting = best.accounting;
581
605
  let round = 0;
606
+ // #2: the round cap can be lifted ONCE. An adoption is a fresh whole-plan roll,
607
+ // so the plan that gets adopted can expose an uncovered area the pre-adoption
608
+ // plan never had — and if that adoption lands on the last allowed round, the
609
+ // loop breaks before the new gap ever gets a reprompt (mx5 2026-07-16: the
610
+ // 55-title plan was adopted on the final round AND was the first to reveal §10's
611
+ // test-infra gap; the cap fired the same instant, so it was never chased). Grant
612
+ // exactly one bonus round when — and only when — an adoption introduces a NEW
613
+ // missing area at the cap. Bounded to one so a judge that flags forever still
614
+ // cannot loop the plan phase; a persistent (non-new) gap never re-triggers it.
615
+ let roundCap = MAX_COVERAGE_ROUNDS;
616
+ let bonusRoundUsed = false;
582
617
  for (;;) {
583
618
  if (best.plan.titles.length === 0)
584
619
  break;
@@ -596,7 +631,7 @@ export async function planAuto(ctx, cwd, feature, deps) {
596
631
  }
597
632
  break;
598
633
  }
599
- if (round >= MAX_COVERAGE_ROUNDS)
634
+ if (round >= roundCap)
600
635
  break;
601
636
  round++;
602
637
  logPlanDebug(cwd, `decompose-coverage round ${round}: INCOMPLETE — missing: `
@@ -606,9 +641,29 @@ export async function planAuto(ctx, cwd, feature, deps) {
606
641
  const cand = await scorePlan(retryTitles);
607
642
  const decision = decideAdoption(best.plan, cand.plan, hasRequirements);
608
643
  if (decision.adopt) {
644
+ // Snapshot the pre-adoption plan to decide whether this adoption earns a
645
+ // bonus round. Two guards keep the bonus off generic judge churn: it must
646
+ // be a real coverage GAIN (grounded covered-set strictly grew — a flaky
647
+ // judge that just relabels the same-shaped plan's gap does not qualify),
648
+ // and it must expose a NEW area (a gap already present is one we have or
649
+ // will reprompt against anyway). Requirements-path only: without grounded
650
+ // requirements "missing" is pure holistic-judge free-text that can change
651
+ // every round, so there is no trustworthy "grew"/"new" signal to gate on.
652
+ const priorCovered = best.plan.covered.size;
653
+ const priorMissing = new Set(best.plan.missing.map(normMissingArea));
609
654
  best = cand;
610
655
  accounting = cand.accounting ?? accounting;
611
656
  logPlanDebug(cwd, `decompose retry ADOPTED — ${decision.reason}`);
657
+ if (!bonusRoundUsed
658
+ && round >= roundCap
659
+ && hasRequirements
660
+ && cand.plan.covered.size > priorCovered
661
+ && cand.plan.missing.some(m => !priorMissing.has(normMissingArea(m)))) {
662
+ bonusRoundUsed = true;
663
+ roundCap++;
664
+ logPlanDebug(cwd, 'decompose-coverage: bonus round granted — adoption grew coverage and '
665
+ + 'exposed a new uncovered area at the cap');
666
+ }
612
667
  }
613
668
  else {
614
669
  // Rejected: keep the better current plan. The loop re-checks it at the
@@ -631,7 +686,10 @@ export async function planAuto(ctx, cwd, feature, deps) {
631
686
  if (unresolvedMissing !== null) {
632
687
  logPlanDebug(cwd, `decompose-coverage exhausted ${round} round(s) still INCOMPLETE — missing: `
633
688
  + unresolvedMissing.join('; ').slice(0, 300));
634
- ctx.ui.notify(`/task-auto: plan may be missing coverage — ${unresolvedMissing.join('; ').slice(0, 200)} — review the plan before running.`, 'warning');
689
+ ctx.ui.notify(`/task-auto: no task fully owns — ${unresolvedMissing.join('; ').slice(0, 200)}. `
690
+ + 'Carried into every task via .pi-tasks/requirements.md, but not as a dedicated '
691
+ + 'task. To give it one, stop now and add it to the plan in .pi-tasks/; otherwise '
692
+ + 'it proceeds.', 'warning');
635
693
  }
636
694
  // Carry what no single task owns (goal A(b)/(c)): cross-cutting requirements
637
695
  // become `.pi-tasks/requirements.md`, injected VERBATIM into every task's
@@ -639,15 +697,25 @@ export async function planAuto(ctx, cwd, feature, deps) {
639
697
  // is authoritative" pointer recovered it in 1 of ~6 tasks; content travels,
640
698
  // pointers don't). Requirements still unmapped after the rounds are carried
641
699
  // too — marked — and recorded user-visibly in the plan file, never dropped.
642
- if (accounting !== null) {
643
- await appendCarriedRequirements(cwd, accounting.crossCutting, accounting.unmapped);
644
- if (accounting.crossCutting.length > 0 || accounting.unmapped.length > 0) {
645
- ctx.ui.notify(`/task-auto: carrying ${accounting.crossCutting.length} cross-cutting`
646
- + (accounting.unmapped.length > 0 ?
647
- ` and ${accounting.unmapped.length} unowned`
648
- : '')
649
- + ' requirement(s) into every task — see .pi-tasks/requirements.md.', 'info');
650
- }
700
+ //
701
+ // #1: the holistic-judge missing areas are carried as a THIRD channel. They are
702
+ // areas requirement-extraction never captured as a tracked entry (so the
703
+ // grounded accounting is structurally blind to them), seen only by the judge —
704
+ // exactly the class that, having no carrier, was warned-about then dropped (mx5
705
+ // 2026-07-16, §10 test-infra). Carried independent of `accounting` so a mapping
706
+ // fault (accounting === null) can't strand them either.
707
+ const carriedCrossCutting = accounting?.crossCutting ?? [];
708
+ const carriedUnmapped = accounting?.unmapped ?? [];
709
+ const carriedJudge = best.judgeMissing;
710
+ if (carriedCrossCutting.length > 0 || carriedUnmapped.length > 0 || carriedJudge.length > 0) {
711
+ await appendCarriedRequirements(cwd, carriedCrossCutting, carriedUnmapped, carriedJudge);
712
+ const parts = [
713
+ carriedCrossCutting.length > 0 ? `${carriedCrossCutting.length} cross-cutting` : '',
714
+ carriedUnmapped.length > 0 ? `${carriedUnmapped.length} unowned` : '',
715
+ carriedJudge.length > 0 ? `${carriedJudge.length} judge-flagged` : ''
716
+ ].filter(p => p.length > 0);
717
+ ctx.ui.notify(`/task-auto: carrying ${parts.join(', ')} requirement(s) into every task`
718
+ + ' — see .pi-tasks/requirements.md.', 'info');
651
719
  }
652
720
  // Cross-slice contract registry (mx5 run 8, F3): now that the plan is settled,
653
721
  // extract the interface facts MORE THAN ONE slice must agree on — endpoint paths,
@@ -32,17 +32,106 @@
32
32
  // domain-agnostic (no mx5/web vocabulary).
33
33
  const COVERAGE_STOPWORDS = new Set([
34
34
  // function words
35
- 'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'for', 'with', 'by', 'at', 'as', 'is',
36
- 'are', 'be', 'it', 'its', 'that', 'this', 'from', 'into', 'out', 'up', 'per', 'via', 'not', 'no',
37
- 'but', 'if', 'then', 'than', 'so', 'such', 'each', 'any', 'all', 'every', 'when', 'where', 'must',
38
- 'should', 'shall', 'may', 'can', 'will', 'end', 'new',
35
+ 'the',
36
+ 'a',
37
+ 'an',
38
+ 'and',
39
+ 'or',
40
+ 'of',
41
+ 'to',
42
+ 'in',
43
+ 'on',
44
+ 'for',
45
+ 'with',
46
+ 'by',
47
+ 'at',
48
+ 'as',
49
+ 'is',
50
+ 'are',
51
+ 'be',
52
+ 'it',
53
+ 'its',
54
+ 'that',
55
+ 'this',
56
+ 'from',
57
+ 'into',
58
+ 'out',
59
+ 'up',
60
+ 'per',
61
+ 'via',
62
+ 'not',
63
+ 'no',
64
+ 'but',
65
+ 'if',
66
+ 'then',
67
+ 'than',
68
+ 'so',
69
+ 'such',
70
+ 'each',
71
+ 'any',
72
+ 'all',
73
+ 'every',
74
+ 'when',
75
+ 'where',
76
+ 'must',
77
+ 'should',
78
+ 'shall',
79
+ 'may',
80
+ 'can',
81
+ 'will',
82
+ 'end',
83
+ 'new',
39
84
  // generic task verbs
40
- 'add', 'implement', 'create', 'build', 'scaffold', 'setup', 'set', 'support', 'handle', 'apply',
41
- 'use', 'used', 'using', 'make', 'makes', 'made', 'enable', 'provide', 'ensure', 'allow', 'run',
42
- 'runs', 'get', 'gets', 'define', 'configure', 'init', 'update', 'manage',
85
+ 'add',
86
+ 'implement',
87
+ 'create',
88
+ 'build',
89
+ 'scaffold',
90
+ 'setup',
91
+ 'set',
92
+ 'support',
93
+ 'handle',
94
+ 'apply',
95
+ 'use',
96
+ 'used',
97
+ 'using',
98
+ 'make',
99
+ 'makes',
100
+ 'made',
101
+ 'enable',
102
+ 'provide',
103
+ 'ensure',
104
+ 'allow',
105
+ 'run',
106
+ 'runs',
107
+ 'get',
108
+ 'gets',
109
+ 'define',
110
+ 'configure',
111
+ 'init',
112
+ 'update',
113
+ 'manage',
43
114
  // generic project nouns
44
- 'cli', 'tool', 'app', 'application', 'project', 'feature', 'task', 'tasks', 'user', 'users',
45
- 'mode', 'flag', 'flags', 'option', 'options', 'system', 'code', 'thing', 'things', 'work'
115
+ 'cli',
116
+ 'tool',
117
+ 'app',
118
+ 'application',
119
+ 'project',
120
+ 'feature',
121
+ 'task',
122
+ 'tasks',
123
+ 'user',
124
+ 'users',
125
+ 'mode',
126
+ 'flag',
127
+ 'flags',
128
+ 'option',
129
+ 'options',
130
+ 'system',
131
+ 'code',
132
+ 'thing',
133
+ 'things',
134
+ 'work'
46
135
  ]);
47
136
  /** Distinctive content tokens of a phrase: lowercased alphanumeric words ≥3 chars,
48
137
  * minus the ubiquitous stopwords. `--json` → `json`, `dead-letter` → `dead`,`letter`.
@@ -76,11 +76,20 @@ export declare function accountCoverage(requirements: RequirementEntry[], mappin
76
76
  /** The stored carried-requirements text ('' when none recorded). */
77
77
  export declare function readRequirements(cwd: string): Promise<string>;
78
78
  /**
79
- * Append carried requirements (cross-cutting, plus any left unmapped after the
80
- * retry rounds — better carried into every task than silently lost), deduped
81
- * against what is stored. Host-side only; children never write it. Best-effort.
79
+ * Append carried requirements, deduped against what is stored. Three channels,
80
+ * each better carried into every task than silently lost — host-side only,
81
+ * children never write it, best-effort:
82
+ * • `crossCutting` — obligations no single task owns (policy/global rules).
83
+ * • `unresolved` — grounded requirements still unmapped after the retry rounds.
84
+ * • `judgeFlagged` — free-text areas the holistic coverage judge flagged as
85
+ * uncovered that requirement-extraction never captured as a tracked entry, so
86
+ * the grounded channels above are structurally blind to them (mx5 2026-07-16:
87
+ * §10's test-infra setup was seen ONLY by the judge and, having no carrier,
88
+ * was warned-about then dropped). These are plain strings, not quotes of the
89
+ * source; marked distinctly so a task can tell an inferred area from a verbatim
90
+ * obligation.
82
91
  */
83
- export declare function appendCarriedRequirements(cwd: string, crossCutting: RequirementEntry[], unresolved?: RequirementEntry[]): Promise<void>;
92
+ export declare function appendCarriedRequirements(cwd: string, crossCutting: RequirementEntry[], unresolved?: RequirementEntry[], judgeFlagged?: string[]): Promise<void>;
84
93
  /**
85
94
  * The read-only block refine/compose receive when carried requirements exist.
86
95
  * Verbatim content travels with every task (the directive pattern that works),
@@ -308,12 +308,21 @@ function formatEntry(e, marker) {
308
308
  return `"${e.quote}"${anchor}${marker ? ` [${marker}]` : ''}`;
309
309
  }
310
310
  /**
311
- * Append carried requirements (cross-cutting, plus any left unmapped after the
312
- * retry rounds — better carried into every task than silently lost), deduped
313
- * against what is stored. Host-side only; children never write it. Best-effort.
311
+ * Append carried requirements, deduped against what is stored. Three channels,
312
+ * each better carried into every task than silently lost — host-side only,
313
+ * children never write it, best-effort:
314
+ * • `crossCutting` — obligations no single task owns (policy/global rules).
315
+ * • `unresolved` — grounded requirements still unmapped after the retry rounds.
316
+ * • `judgeFlagged` — free-text areas the holistic coverage judge flagged as
317
+ * uncovered that requirement-extraction never captured as a tracked entry, so
318
+ * the grounded channels above are structurally blind to them (mx5 2026-07-16:
319
+ * §10's test-infra setup was seen ONLY by the judge and, having no carrier,
320
+ * was warned-about then dropped). These are plain strings, not quotes of the
321
+ * source; marked distinctly so a task can tell an inferred area from a verbatim
322
+ * obligation.
314
323
  */
315
- export async function appendCarriedRequirements(cwd, crossCutting, unresolved = []) {
316
- if (crossCutting.length === 0 && unresolved.length === 0)
324
+ export async function appendCarriedRequirements(cwd, crossCutting, unresolved = [], judgeFlagged = []) {
325
+ if (crossCutting.length === 0 && unresolved.length === 0 && judgeFlagged.length === 0)
317
326
  return;
318
327
  try {
319
328
  const existing = (await readRequirements(cwd)).split('\n').filter(l => l.trim().length > 0);
@@ -324,7 +333,11 @@ export async function appendCarriedRequirements(cwd, crossCutting, unresolved =
324
333
  const merged = [...existing];
325
334
  for (const [entries, marker] of [
326
335
  [crossCutting, undefined],
327
- [unresolved, 'no task owns this — surfaced at plan time']
336
+ [unresolved, 'no task owns this — surfaced at plan time'],
337
+ [
338
+ judgeFlagged.map(q => ({ quote: q, anchor: '' })),
339
+ 'judge-flagged uncovered area, no task owns this — surfaced at plan time'
340
+ ]
328
341
  ]) {
329
342
  for (const e of entries) {
330
343
  const key = normalise(e.quote);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.19",
3
+ "version": "0.18.20",
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",