@mjasnikovs/pi-task 0.18.17 → 0.18.18

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.
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![npm](https://img.shields.io/npm/v/@mjasnikovs/pi-task?color=cb3837&logo=npm)](https://www.npmjs.com/package/@mjasnikovs/pi-task)
10
10
  [![license](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](./LICENSE)
11
11
  [![pi extension](https://img.shields.io/badge/pi-extension-7c3aed)](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
12
- [![tests](https://img.shields.io/badge/tests-1617%20passing-3fb950)](#development)
12
+ [![tests](https://img.shields.io/badge/tests-1637%20passing-3fb950)](#development)
13
13
  [![types](https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white)](./tsconfig.json)
14
14
 
15
15
  </div>
@@ -175,7 +175,7 @@ Tasks are persisted to `<cwd>/.pi-tasks/TASK_NNNN.md`. Add `.pi-tasks/` to your
175
175
 
176
176
  ```sh
177
177
  bun install
178
- bun test src/ # 1617 tests across 106 files
178
+ bun test src/ # 1637 tests across 107 files
179
179
  bun run lint # prettier + eslint + tsc --noEmit
180
180
  bun run build # tsc → dist/
181
181
  ```
@@ -33,7 +33,8 @@ import { getConfig } from '../config/config.js';
33
33
  import { configureResearchRun } from '../workers/research-cache.js';
34
34
  import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
35
35
  import { reconcileTitleSources } from './decompose-fidelity.js';
36
- import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
36
+ import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
37
+ import { decideAdoption, groundedCoverage } from './coverage-loop.js';
37
38
  import { LAUNCH_EXTRACT_PROMPT, enumerateScriptCandidates, parseScriptLines, keepGroundedScripts, appendDeclaredScripts } from './launch-contract.js';
38
39
  // Hard ceiling on clarify questions per feature. The loop is open-ended (it stops
39
40
  // when the model emits NONE), but a model that never says NONE would otherwise
@@ -511,86 +512,124 @@ export async function planAuto(ctx, cwd, feature, deps) {
511
512
  // with the missing areas as a hint. Best-effort so a triage fault never blocks
512
513
  // planning (mirrors triageClarifyQuestion).
513
514
  //
514
- // A retry is adopted whenever it is NON-DEGENERATE — not only when it is
515
- // strictly longer. The retry was generated WITH the judge's missing areas in
516
- // its prompt, so it is the better-informed list, and the NEXT round's judge
517
- // (not raw length) decides whether the gaps actually closed. Length survives
518
- // only as a collapse floor against the one-task flake this gate exists for.
519
- // mx5 run 5 (live): the hinted retry ADDED the flagged test-suite task but came
520
- // back 29 titles vs the original 30 — strictly-longer discarded it, round 2
521
- // re-judged the same unchanged list, and the known-incomplete plan shipped
522
- // with no warning.
523
- let unresolvedMissing = null;
524
- // The last per-requirement accounting (goal A): completeness is computed
525
- // HOST-SIDE from it — a holistic "COMPLETE" alone can no longer pass a plan
526
- // while grounded requirements sit unowned (run 11: milestone-parity satisfied
527
- // the judge while §10 Testing had zero tasks). Null when no requirements were
528
- // extracted or every mapping call faulted (⇒ old judge-only behavior).
529
- let accounting = null;
530
- for (let round = 0; round < MAX_COVERAGE_ROUNDS && planTitles.length > 0; round++) {
531
- // Signal 1 — the holistic judge (kept as the belt; catches feature areas
532
- // the requirement extraction itself missed). A fault yields no signal.
515
+ // Two hard-won invariants (mx5 run 12: a complete full-stack plan — 31
516
+ // requirements mapped, frontend pages present — was overwritten by a
517
+ // backend-only regeneration and shipped with only a toast, driven by 3 NEGATIVE
518
+ // requirements no task could own that kept the verdict INCOMPLETE forever):
519
+ // • MONOTONIC replacement (coverage-loop.ts): a retry that DROPS a requirement
520
+ // the current plan already owns is REJECTED, never adopted. Coverage can
521
+ // only hold or grow across rounds — a worse regeneration can no longer
522
+ // overwrite a better plan on the old `length*2` size floor alone.
523
+ // • SHIP THE BEST, not the last: because adoption is monotone, the working
524
+ // plan at exhaustion is the best-covered one seen, so it is what ships.
525
+ // Fix A rides in accountCoverage: an un-ownable prohibition/global-policy
526
+ // requirement is carried CROSS-CUTTING rather than fed back as a missing area,
527
+ // so it no longer forces the loop to regenerate at all. The monotonic rule is
528
+ // the hard backstop that holds even for un-ownable lines the classifier misses.
529
+ //
530
+ // Score one plan: the holistic judge (belt — catches areas the requirement
531
+ // extraction itself missed) plus, when requirements were extracted, the
532
+ // host-side per-requirement map (lever — every grounded requirement gets a
533
+ // falsifiable TASK/CROSS/NONE verdict). Best-effort: a fault degrades a signal,
534
+ // never blocks planning.
535
+ const scorePlan = async (titles) => {
533
536
  let verdict;
534
537
  try {
535
- verdict = parseCoverageVerdict(await deps.runChild('decompose-coverage', '', DECOMPOSE_COVERAGE_PROMPT(featureForModel, clarifications, planTitles)));
538
+ verdict = parseCoverageVerdict(await deps.runChild('decompose-coverage', '', DECOMPOSE_COVERAGE_PROMPT(featureForModel, clarifications, titles)));
536
539
  }
537
540
  catch {
538
541
  verdict = null;
539
542
  }
540
543
  const verdictMissing = verdict?.kind === 'incomplete' ? verdict.missing : [];
541
- // Signal 2 — the per-requirement map (the lever): every grounded
542
- // requirement gets a falsifiable verdict (TASK n / CROSS-CUTTING / NONE);
543
- // the host, not the model, decides what is uncovered. A fault keeps the
544
- // previous round's accounting.
544
+ let acc = null;
545
+ // The monotonic guard's owned-set is grounded DETERMINISTICALLY in
546
+ // requirement↔title token overlap — NOT the coverage-map model's TASK
547
+ // numbers. Live (Qwen3.6-27B) the model over-credits ownership, mapping a
548
+ // "--json output" requirement to a generic "scaffold + argument parser"
549
+ // task, so a plan with no --json task still "owned" it and the drop guard
550
+ // went blind (treatment 1/5). Grounding the drop-signal in the titles the
551
+ // model can't fake takes it back to 5/5. The model map still drives Fix A's
552
+ // cross-cutting/unmapped accounting below (that only affects reprompt
553
+ // aggressiveness, which the monotonic guard now backstops).
554
+ const covered = groundedCoverage(reqEntries.map(e => e.quote), titles, isCrossCuttingRequirement);
545
555
  if (reqEntries.length > 0) {
546
556
  try {
547
- const mapRaw = await deps.runChild('coverage-map', '', COVERAGE_MAP_PROMPT(reqEntries, planTitles));
548
- accounting = accountCoverage(reqEntries, parseCoverageMap(mapRaw, reqEntries.length, planTitles.length));
549
- logPlanDebug(cwd, `coverage-map round ${round + 1}: ${accounting.mapped.length} task-mapped, `
550
- + `${accounting.crossCutting.length} cross-cutting, `
551
- + `${accounting.unmapped.length} unmapped`);
557
+ const mappings = parseCoverageMap(await deps.runChild('coverage-map', '', COVERAGE_MAP_PROMPT(reqEntries, titles)), reqEntries.length, titles.length);
558
+ acc = accountCoverage(reqEntries, mappings);
559
+ logPlanDebug(cwd, `coverage-map (${titles.length} titles): ${acc.mapped.length} task-mapped, `
560
+ + `${acc.crossCutting.length} cross-cutting, ${acc.unmapped.length} unmapped; `
561
+ + `${covered.size} requirement(s) title-grounded`);
552
562
  }
553
563
  catch {
554
- // mapping fault — keep whatever accounting an earlier round produced
564
+ // mapping fault — Fix A accounting degrades; the grounded owned-set
565
+ // above still guards against drops.
555
566
  }
556
567
  }
557
- const unmappedQuotes = (accounting?.unmapped ?? []).map(e => `"${e.quote}"`);
558
- const missing = [...verdictMissing, ...unmappedQuotes];
559
- if (missing.length === 0) {
560
- unresolvedMissing = null;
561
- logPlanDebug(cwd, `decompose-coverage round ${round + 1}: `
562
- + (verdict === null ? 'no judge verdict' : 'judge COMPLETE')
563
- + (reqEntries.length > 0 ?
564
- ' and every grounded requirement is task-mapped or cross-cutting'
568
+ const missing = [...verdictMissing, ...(acc?.unmapped ?? []).map(e => `"${e.quote}"`)];
569
+ return {
570
+ plan: { titles, covered, missing },
571
+ accounting: acc,
572
+ suspect: isSuspectPlan(titles, featureForModel)
573
+ };
574
+ };
575
+ const hasRequirements = reqEntries.length > 0;
576
+ // `best` is both the plan the next round reprompts FROM and the plan that
577
+ // ships — kept identical because adoption is monotone (see coverage-loop.ts).
578
+ let best = await scorePlan(planTitles);
579
+ // The carried accounting (cross-cutting + unowned) for the plan that ships.
580
+ let accounting = best.accounting;
581
+ let round = 0;
582
+ for (;;) {
583
+ if (best.plan.titles.length === 0)
584
+ break;
585
+ if (best.plan.missing.length === 0) {
586
+ logPlanDebug(cwd, 'decompose-coverage: COMPLETE'
587
+ + (hasRequirements ?
588
+ ' — every grounded requirement is task-mapped or cross-cutting'
565
589
  : ' — accepting list'));
566
- // A COMPLETE on a still-suspect plan is the judge's known live
567
- // false-pass mode (bare verdict, indistinguishable from a real one).
568
- // The plan still ships — the floor never rejects on count — but
569
- // never silently: the user decides whether to trust it.
570
- if (isSuspectPlan(planTitles, featureForModel)) {
571
- ctx.ui.notify(`/task-auto: only ${planTitles.length} task(s) planned for a large spec`
590
+ // A COMPLETE on a still-suspect plan is the judge's known live false-pass
591
+ // mode (bare verdict, indistinguishable from a real one). The plan still
592
+ // ships — the floor never rejects on count — but never silently.
593
+ if (best.suspect) {
594
+ ctx.ui.notify(`/task-auto: only ${best.plan.titles.length} task(s) planned for a large spec`
572
595
  + ' and the regeneration did not grow the list — review the plan before running.', 'warning');
573
596
  }
574
597
  break;
575
598
  }
576
- unresolvedMissing = missing;
577
- logPlanDebug(cwd, `decompose-coverage round ${round + 1}: INCOMPLETE — missing: `
578
- + missing.join('; ').slice(0, 300));
579
- const retryRaw = await deps.runChild('auto-decompose', 'read', prependHint(coverageRepromptHint(missing), decomposePrompt));
580
- const retryTitles = parsePlan(retryRaw);
599
+ if (round >= MAX_COVERAGE_ROUNDS)
600
+ break;
601
+ round++;
602
+ logPlanDebug(cwd, `decompose-coverage round ${round}: INCOMPLETE — missing: `
603
+ + best.plan.missing.join('; ').slice(0, 300));
604
+ const retryTitles = parsePlan(await deps.runChild('auto-decompose', 'read', prependHint(coverageRepromptHint(best.plan.missing), decomposePrompt)));
581
605
  logPlanDebug(cwd, `decompose retry produced ${retryTitles.length} title(s)`);
582
- if (retryTitles.length > 0 && retryTitles.length * 2 >= planTitles.length) {
583
- planTitles = retryTitles;
606
+ const cand = await scorePlan(retryTitles);
607
+ const decision = decideAdoption(best.plan, cand.plan, hasRequirements);
608
+ if (decision.adopt) {
609
+ best = cand;
610
+ accounting = cand.accounting ?? accounting;
611
+ logPlanDebug(cwd, `decompose retry ADOPTED — ${decision.reason}`);
584
612
  }
585
613
  else {
586
- logPlanDebug(cwd, `decompose retry discarded as degenerate (${retryTitles.length} vs ${planTitles.length} titles)`);
614
+ // Rejected: keep the better current plan. The loop re-checks it at the
615
+ // top (still incomplete ⇒ another bounded reprompt) but its coverage is
616
+ // never sacrificed to a worse regeneration.
617
+ logPlanDebug(cwd, `decompose retry REJECTED — ${decision.reason}`
618
+ + (decision.dropped.length > 0 ?
619
+ ` [would drop: ${decision.dropped
620
+ .map(i => `"${reqEntries[i].quote}"`)
621
+ .join('; ')
622
+ .slice(0, 200)}]`
623
+ : ''));
587
624
  }
588
625
  }
589
- // Rounds exhausted with the last judgment still INCOMPLETE: the plan ships (the
590
- // gate is best-effort), but silently shipping a KNOWN-gapped plan is how mx5
591
- // run 5 lost its whole test suite — tell the user what the judge last flagged.
626
+ planTitles = best.plan.titles;
627
+ // Exhausted still INCOMPLETE: the best plan ships (the gate is best-effort), but
628
+ // silently shipping a KNOWN-gapped plan is how mx5 run 5 lost its whole test
629
+ // suite — tell the user what is still uncovered.
630
+ const unresolvedMissing = best.plan.missing.length > 0 ? best.plan.missing : null;
592
631
  if (unresolvedMissing !== null) {
593
- logPlanDebug(cwd, `decompose-coverage exhausted ${MAX_COVERAGE_ROUNDS} round(s) still INCOMPLETE — missing: `
632
+ logPlanDebug(cwd, `decompose-coverage exhausted ${round} round(s) still INCOMPLETE — missing: `
594
633
  + unresolvedMissing.join('; ').slice(0, 300));
595
634
  ctx.ui.notify(`/task-auto: plan may be missing coverage — ${unresolvedMissing.join('; ').slice(0, 200)} — review the plan before running.`, 'warning');
596
635
  }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * coverage-loop — the MONOTONIC replacement rule for /task-auto's decompose
3
+ * coverage gate (mx5 run 12).
4
+ *
5
+ * The failure this closes: the coverage-retry loop regenerated the whole plan on
6
+ * every INCOMPLETE verdict, and adopted the regeneration on nothing but a size
7
+ * floor (`retry.length * 2 >= current.length`). A regeneration is a fresh
8
+ * stochastic roll of the ENTIRE plan, so one that DROPPED a previously-covered
9
+ * feature-area but kept the title count replaced the better plan anyway — and,
10
+ * because the loop shipped whatever the LAST round produced, the dropped area was
11
+ * gone with only a toast. Live: a complete full-stack plan (31 requirements
12
+ * mapped, frontend pages present) was overwritten by a backend-only one and
13
+ * shipped, driven by 3 NEGATIVE requirements no task could ever "own" that kept
14
+ * the verdict INCOMPLETE forever.
15
+ *
16
+ * The rule here makes replacement monotone: coverage can only hold or grow across
17
+ * rounds. A retry is adopted ONLY when it drops no requirement the current plan
18
+ * already owns (its owned-set is a superset). Because adoption is monotone, the
19
+ * working plan at exhaustion is the best-covered one seen — so "ship the working
20
+ * plan" is automatically "ship the best", never "ship the last".
21
+ *
22
+ * Spec-shape-agnostic: the only inputs are title counts and the set of
23
+ * requirement INDICES a task owns (from the host-side coverage map). No feature
24
+ * noun, no web-app assumption. A CLI, a data pipeline, a library, a refactor, a
25
+ * docs task all flow through the same integers.
26
+ */
27
+ /** A scored plan candidate — the minimum the adoption rule needs. */
28
+ export interface CoveragePlan {
29
+ titles: string[];
30
+ /**
31
+ * Requirement indices a task OWNS (a per-requirement `TASK n` verdict from the
32
+ * coverage map). Empty when the run extracted no requirements — then the rule
33
+ * degrades to the count-floor path below. Cross-cutting requirements are NOT
34
+ * here: they are carried into every task regardless of plan shape, so they
35
+ * cannot be "dropped" by a plan change and must not gate adoption.
36
+ */
37
+ covered: Set<number>;
38
+ /**
39
+ * Feature areas still uncovered (holistic-judge areas + unowned requirement
40
+ * quotes). Non-empty ⇒ the plan is incomplete and a retry is reprompted. Used
41
+ * as the regression signal on the no-requirements path.
42
+ */
43
+ missing: string[];
44
+ }
45
+ /**
46
+ * DETERMINISTIC owned-set for the monotonic guard — grounded in requirement↔title
47
+ * token overlap, NOT the coverage-map model's `TASK n` verdict.
48
+ *
49
+ * Why this exists (live A/B, Qwen3.6-27B, mx5-shaped CLI spec): the model
50
+ * over-credits ownership — it mapped a "--json output" requirement to a generic
51
+ * "scaffold + argument parser" task, so a plan with NO --json task still reported
52
+ * owning it. A guard that trusts those numbers is blind to the very drop it must
53
+ * catch (treatment held only 1/5 trials). Grounding coverage in whether a task
54
+ * TITLE actually shares a distinctive token with the requirement makes the
55
+ * drop-signal independent of the model's rubber-stamp (treatment → 5/5).
56
+ *
57
+ * A requirement is "covered" when some title shares a DISTINCTIVE token with it —
58
+ * distinctive meaning the token is not shared across more than half the ownable
59
+ * requirements. Without that corpus filter a common object-noun repeated in every
60
+ * requirement (e.g. "range" in a date-range library, present in parse/serialize/
61
+ * timezone alike) would connect a serialize-requirement to a timezone task and
62
+ * hide the drop. Cross-cutting requirements are excluded (carried into every task
63
+ * regardless of plan shape, so they can neither be owned nor dropped).
64
+ *
65
+ * Errs toward UNDER-counting coverage (a reworded title with no shared distinctive
66
+ * noun reads as uncovered), which only makes the guard MORE conservative — it
67
+ * never manufactures coverage that would hide a drop. Index-aligned with `quotes`.
68
+ */
69
+ export declare function groundedCoverage(quotes: string[], titles: string[], isCrossCutting: (quote: string) => boolean): Set<number>;
70
+ /** Requirement indices the current plan owns that the retry does NOT — the drops
71
+ * that make a retry non-monotone. Empty ⇒ the retry is a superset (safe). */
72
+ export declare function droppedCoverage(current: Set<number>, retry: Set<number>): number[];
73
+ export interface AdoptionDecision {
74
+ adopt: boolean;
75
+ reason: string;
76
+ /** Owned-requirement indices the retry would drop (for the debug trail). */
77
+ dropped: number[];
78
+ }
79
+ /**
80
+ * Whether a coverage-retry should REPLACE the current plan.
81
+ *
82
+ * 1. Collapse floor (unchanged): an empty retry, or one under half the current
83
+ * title count, is the one-task degenerate flake this gate exists for — reject.
84
+ * 2. WITH requirement signal: reject any retry that drops a requirement the
85
+ * current plan already owns (non-superset owned-set). This is the monotone
86
+ * guarantee; it holds regardless of how the un-ownable requirements were
87
+ * classified, so it backstops the cross-cutting classifier completely.
88
+ * 3. WITHOUT requirement signal: fall back to the count floor, and additionally
89
+ * refuse a retry that leaves MORE areas uncovered than the current plan — so
90
+ * the no-requirements path also ships the best, not the last.
91
+ */
92
+ export declare function decideAdoption(current: CoveragePlan, retry: CoveragePlan, hasRequirements: boolean): AdoptionDecision;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * coverage-loop — the MONOTONIC replacement rule for /task-auto's decompose
3
+ * coverage gate (mx5 run 12).
4
+ *
5
+ * The failure this closes: the coverage-retry loop regenerated the whole plan on
6
+ * every INCOMPLETE verdict, and adopted the regeneration on nothing but a size
7
+ * floor (`retry.length * 2 >= current.length`). A regeneration is a fresh
8
+ * stochastic roll of the ENTIRE plan, so one that DROPPED a previously-covered
9
+ * feature-area but kept the title count replaced the better plan anyway — and,
10
+ * because the loop shipped whatever the LAST round produced, the dropped area was
11
+ * gone with only a toast. Live: a complete full-stack plan (31 requirements
12
+ * mapped, frontend pages present) was overwritten by a backend-only one and
13
+ * shipped, driven by 3 NEGATIVE requirements no task could ever "own" that kept
14
+ * the verdict INCOMPLETE forever.
15
+ *
16
+ * The rule here makes replacement monotone: coverage can only hold or grow across
17
+ * rounds. A retry is adopted ONLY when it drops no requirement the current plan
18
+ * already owns (its owned-set is a superset). Because adoption is monotone, the
19
+ * working plan at exhaustion is the best-covered one seen — so "ship the working
20
+ * plan" is automatically "ship the best", never "ship the last".
21
+ *
22
+ * Spec-shape-agnostic: the only inputs are title counts and the set of
23
+ * requirement INDICES a task owns (from the host-side coverage map). No feature
24
+ * noun, no web-app assumption. A CLI, a data pipeline, a library, a refactor, a
25
+ * docs task all flow through the same integers.
26
+ */
27
+ // Ubiquitous words that carry no coverage signal: they appear across most task
28
+ // titles and requirement quotes, so overlap on them would falsely connect a
29
+ // requirement to any plan. Stopped so grounding keys on the DISTINCTIVE nouns
30
+ // (json, dead-letter, serialize, symlink…) that actually name a deliverable.
31
+ // English function words + generic task verbs + generic project nouns — all
32
+ // domain-agnostic (no mx5/web vocabulary).
33
+ const COVERAGE_STOPWORDS = new Set([
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',
39
+ // 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',
43
+ // 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'
46
+ ]);
47
+ /** Distinctive content tokens of a phrase: lowercased alphanumeric words ≥3 chars,
48
+ * minus the ubiquitous stopwords. `--json` → `json`, `dead-letter` → `dead`,`letter`.
49
+ * A single trailing `s` is stripped (len ≥4) so `scan`/`scans`, `file`/`files`,
50
+ * `serialize`/`serializes` match — plain plural/3rd-person, no full stemmer. */
51
+ function contentTokens(s) {
52
+ const out = new Set();
53
+ for (const raw of s.toLowerCase().split(/[^a-z0-9]+/)) {
54
+ if (raw.length < 3 || COVERAGE_STOPWORDS.has(raw))
55
+ continue;
56
+ const w = raw.length >= 4 && raw.endsWith('s') && !raw.endsWith('ss') ? raw.slice(0, -1) : raw;
57
+ out.add(w);
58
+ }
59
+ return out;
60
+ }
61
+ /**
62
+ * DETERMINISTIC owned-set for the monotonic guard — grounded in requirement↔title
63
+ * token overlap, NOT the coverage-map model's `TASK n` verdict.
64
+ *
65
+ * Why this exists (live A/B, Qwen3.6-27B, mx5-shaped CLI spec): the model
66
+ * over-credits ownership — it mapped a "--json output" requirement to a generic
67
+ * "scaffold + argument parser" task, so a plan with NO --json task still reported
68
+ * owning it. A guard that trusts those numbers is blind to the very drop it must
69
+ * catch (treatment held only 1/5 trials). Grounding coverage in whether a task
70
+ * TITLE actually shares a distinctive token with the requirement makes the
71
+ * drop-signal independent of the model's rubber-stamp (treatment → 5/5).
72
+ *
73
+ * A requirement is "covered" when some title shares a DISTINCTIVE token with it —
74
+ * distinctive meaning the token is not shared across more than half the ownable
75
+ * requirements. Without that corpus filter a common object-noun repeated in every
76
+ * requirement (e.g. "range" in a date-range library, present in parse/serialize/
77
+ * timezone alike) would connect a serialize-requirement to a timezone task and
78
+ * hide the drop. Cross-cutting requirements are excluded (carried into every task
79
+ * regardless of plan shape, so they can neither be owned nor dropped).
80
+ *
81
+ * Errs toward UNDER-counting coverage (a reworded title with no shared distinctive
82
+ * noun reads as uncovered), which only makes the guard MORE conservative — it
83
+ * never manufactures coverage that would hide a drop. Index-aligned with `quotes`.
84
+ */
85
+ export function groundedCoverage(quotes, titles, isCrossCutting) {
86
+ const ownable = quotes
87
+ .map((q, i) => ({ q, i, tokens: contentTokens(q) }))
88
+ .filter(x => !isCrossCutting(x.q));
89
+ // Document frequency across the OWNABLE requirements: a token shared by more
90
+ // than half of them carries no discriminating signal for this run.
91
+ const df = new Map();
92
+ for (const r of ownable)
93
+ for (const w of r.tokens)
94
+ df.set(w, (df.get(w) ?? 0) + 1);
95
+ const maxDF = Math.max(1, Math.floor(ownable.length / 2));
96
+ const titleTokens = new Set();
97
+ for (const t of titles)
98
+ for (const w of contentTokens(t))
99
+ titleTokens.add(w);
100
+ const covered = new Set();
101
+ for (const r of ownable) {
102
+ for (const w of r.tokens) {
103
+ if ((df.get(w) ?? 0) <= maxDF && titleTokens.has(w)) {
104
+ covered.add(r.i);
105
+ break;
106
+ }
107
+ }
108
+ }
109
+ return covered;
110
+ }
111
+ /** Requirement indices the current plan owns that the retry does NOT — the drops
112
+ * that make a retry non-monotone. Empty ⇒ the retry is a superset (safe). */
113
+ export function droppedCoverage(current, retry) {
114
+ const out = [];
115
+ for (const i of current)
116
+ if (!retry.has(i))
117
+ out.push(i);
118
+ return out;
119
+ }
120
+ /**
121
+ * Whether a coverage-retry should REPLACE the current plan.
122
+ *
123
+ * 1. Collapse floor (unchanged): an empty retry, or one under half the current
124
+ * title count, is the one-task degenerate flake this gate exists for — reject.
125
+ * 2. WITH requirement signal: reject any retry that drops a requirement the
126
+ * current plan already owns (non-superset owned-set). This is the monotone
127
+ * guarantee; it holds regardless of how the un-ownable requirements were
128
+ * classified, so it backstops the cross-cutting classifier completely.
129
+ * 3. WITHOUT requirement signal: fall back to the count floor, and additionally
130
+ * refuse a retry that leaves MORE areas uncovered than the current plan — so
131
+ * the no-requirements path also ships the best, not the last.
132
+ */
133
+ export function decideAdoption(current, retry, hasRequirements) {
134
+ if (retry.titles.length === 0)
135
+ return { adopt: false, reason: 'empty retry', dropped: [] };
136
+ if (retry.titles.length * 2 < current.titles.length) {
137
+ return {
138
+ adopt: false,
139
+ reason: `collapse floor (${retry.titles.length} vs ${current.titles.length} titles)`,
140
+ dropped: []
141
+ };
142
+ }
143
+ if (hasRequirements) {
144
+ const dropped = droppedCoverage(current.covered, retry.covered);
145
+ if (dropped.length > 0) {
146
+ return {
147
+ adopt: false,
148
+ reason: `would drop ${dropped.length} owned requirement(s)`,
149
+ dropped
150
+ };
151
+ }
152
+ return { adopt: true, reason: 'preserves owned coverage', dropped: [] };
153
+ }
154
+ if (retry.missing.length > current.missing.length) {
155
+ return {
156
+ adopt: false,
157
+ reason: `more uncovered areas (${retry.missing.length} vs ${current.missing.length})`,
158
+ dropped: []
159
+ };
160
+ }
161
+ return { adopt: true, reason: 'count floor met, no coverage regression', dropped: [] };
162
+ }
@@ -48,6 +48,10 @@ export type ReqMapping = {
48
48
  } | {
49
49
  kind: 'none';
50
50
  };
51
+ export declare function isCrossCuttingRequirement(quote: string): boolean;
52
+ /** Requirement INDICES a task owns (a `TASK n` verdict), the monotonic-replacement
53
+ * signal (coverage-loop.ts). Index-aligned with the requirements list. */
54
+ export declare function ownedRequirementIndices(mappings: ReqMapping[]): Set<number>;
51
55
  /** Per-requirement coverage verdicts against a task list. Runs with --no-tools. */
52
56
  export declare const COVERAGE_MAP_PROMPT: (requirements: RequirementEntry[], titles: string[]) => string;
53
57
  /**
@@ -186,6 +186,47 @@ export const REQUIREMENT_EXTRACT_PROMPT = (feature, passages = []) => [
186
186
  'Output the REQUIREMENT: lines and nothing else. If the text states no requirements,',
187
187
  'output nothing.'
188
188
  ].join('\n');
189
+ /**
190
+ * A requirement no single task can ever OWN: a PROHIBITION (it states what must
191
+ * NOT exist or happen — there is no task that "delivers" an absence) or a GLOBAL
192
+ * POLICY (a product-wide rule every slice obeys, not one slice's deliverable). The
193
+ * per-task coverage map maps both to NONE forever, so left in the `unmapped` set
194
+ * they kept the decompose loop's verdict INCOMPLETE and forced it to regenerate
195
+ * the whole plan endlessly (mx5 run 12: 3 un-ownable NEGATIVE requirements drove a
196
+ * complete full-stack plan to be overwritten by a backend-only one). These belong
197
+ * in the CROSS-CUTTING carry — injected verbatim into every task — never fed back
198
+ * as a missing area.
199
+ *
200
+ * Deterministic and precision-biased: it only reclassifies clear prohibitions and
201
+ * clearly product-global policies. It does NOT need to catch every un-ownable line
202
+ * — the monotonic replacement rule (coverage-loop.ts) is the hard backstop, so a
203
+ * miss here can at most cost one wasted regeneration, never a dropped area. Spec-
204
+ * shape/domain agnostic: pure phrasing, no feature nouns.
205
+ */
206
+ const PROHIBITION_RE = /\b(?:must not|must never|shall not|should not|may not|cannot|can'?t|won'?t|do(?:es)? not|don'?t|doesn'?t|no|not|never|none|without|avoids?|prohibit(?:ed|s|ing)?|forbid(?:den|s)?|disallow(?:ed|s|ing)?|excludes?|excluded|neither|nor)\b/i;
207
+ // Kept narrow on purpose — bare "all"/"every"/"any" appear in plenty of ownable
208
+ // feature statements ("lists all photos"), so the global branch keys only on
209
+ // scope words that name the WHOLE product and is additionally gated by a modal.
210
+ const GLOBAL_SCOPE_RE = /\b(?:everywhere|throughout|always|global(?:ly)?|across (?:the|all|every)|site-?wide|app(?:lication)?-?wide|universal(?:ly)?|consistent(?:ly)?|entire (?:app|application|site|codebase|product|system|ui|project))\b/i;
211
+ const MODAL_RE = /\b(?:must|shall|should|require[sd]?|required|needs? to|has to|have to)\b/i;
212
+ export function isCrossCuttingRequirement(quote) {
213
+ const q = quote.trim();
214
+ if (PROHIBITION_RE.test(q))
215
+ return true;
216
+ if (GLOBAL_SCOPE_RE.test(q) && MODAL_RE.test(q))
217
+ return true;
218
+ return false;
219
+ }
220
+ /** Requirement INDICES a task owns (a `TASK n` verdict), the monotonic-replacement
221
+ * signal (coverage-loop.ts). Index-aligned with the requirements list. */
222
+ export function ownedRequirementIndices(mappings) {
223
+ const out = new Set();
224
+ mappings.forEach((m, i) => {
225
+ if (m.kind === 'task')
226
+ out.add(i);
227
+ });
228
+ return out;
229
+ }
189
230
  /** Per-requirement coverage verdicts against a task list. Runs with --no-tools. */
190
231
  export const COVERAGE_MAP_PROMPT = (requirements, titles) => [
191
232
  'Below are the REQUIRED CONTENTS of a feature (verbatim quotes mechanically grounded',
@@ -241,6 +282,12 @@ export function accountCoverage(requirements, mappings) {
241
282
  acc.mapped.push({ req: requirements[i], task: m.task });
242
283
  else if (m.kind === 'cross')
243
284
  acc.crossCutting.push(requirements[i]);
285
+ // NONE — but a prohibition/global-policy requirement can never be OWNED by
286
+ // a task (it states an absence or a product-wide rule); the model maps it
287
+ // NONE every round, which used to force endless whole-plan regeneration.
288
+ // Carry it cross-cutting instead, so it stops driving the coverage loop.
289
+ else if (isCrossCuttingRequirement(requirements[i].quote))
290
+ acc.crossCutting.push(requirements[i]);
244
291
  else
245
292
  acc.unmapped.push(requirements[i]);
246
293
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.17",
3
+ "version": "0.18.18",
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",