@mjasnikovs/pi-task 0.18.16 → 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 +2 -2
- package/dist/task/auto-orchestrator.js +96 -57
- package/dist/task/coverage-loop.d.ts +92 -0
- package/dist/task/coverage-loop.js +162 -0
- package/dist/task/launch-contract.js +4 -1
- package/dist/task/render-check.d.ts +10 -0
- package/dist/task/render-check.js +1 -1
- package/dist/task/requirements.d.ts +4 -0
- package/dist/task/requirements.js +51 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
[](https://www.npmjs.com/package/@mjasnikovs/pi-task)
|
|
10
10
|
[](./LICENSE)
|
|
11
11
|
[](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
|
|
12
|
-
[](#development)
|
|
13
13
|
[](./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/ #
|
|
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
|
-
//
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
// the
|
|
528
|
-
//
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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,
|
|
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
|
-
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
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
|
|
548
|
-
|
|
549
|
-
logPlanDebug(cwd, `coverage-map
|
|
550
|
-
+ `${
|
|
551
|
-
+ `${
|
|
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 —
|
|
564
|
+
// mapping fault — Fix A accounting degrades; the grounded owned-set
|
|
565
|
+
// above still guards against drops.
|
|
555
566
|
}
|
|
556
567
|
}
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
//
|
|
568
|
-
//
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
583
|
-
|
|
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
|
-
|
|
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
|
-
|
|
590
|
-
//
|
|
591
|
-
//
|
|
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 ${
|
|
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
|
+
}
|
|
@@ -94,7 +94,10 @@ export function keepGroundedScripts(names, sourceDoc) {
|
|
|
94
94
|
export function enumerateScriptCandidates(sourceDoc) {
|
|
95
95
|
const out = [];
|
|
96
96
|
const seen = new Set();
|
|
97
|
-
|
|
97
|
+
// Normalise CRLF/CR → LF first: a Windows-authored design would otherwise
|
|
98
|
+
// collapse into one paragraph (the split marker never matches `\r\n\r\n`),
|
|
99
|
+
// caps out on early package-name backticks, and drops the real scripts.
|
|
100
|
+
for (const para of sourceDoc.replace(/\r\n?/g, '\n').split(/\n[ \t]*\n/)) {
|
|
98
101
|
if (!/\bscripts?\b/i.test(para))
|
|
99
102
|
continue;
|
|
100
103
|
for (const m of para.matchAll(/`([^`\n]+)`/g)) {
|
|
@@ -8,6 +8,16 @@ export type RenderOutcome = {
|
|
|
8
8
|
outcome: 'skip';
|
|
9
9
|
note: string;
|
|
10
10
|
};
|
|
11
|
+
/**
|
|
12
|
+
* The newest Playwright-cache Chromium, if any — the HEADLESS SHELL preferred over
|
|
13
|
+
* the full build. Found, never installed: the cache exists on any box that ever ran
|
|
14
|
+
* Playwright browsers (the mx5-class projects install it as their own test
|
|
15
|
+
* dependency). The headless shell is purpose-built for `--dump-dom` and returns
|
|
16
|
+
* promptly on a network URL where the full system chromium can stall under a
|
|
17
|
+
* virtual-time budget (validated live on this box: shell exits 0 in ~1s, full
|
|
18
|
+
* chromium times out at 30s on the same http:// URL).
|
|
19
|
+
*/
|
|
20
|
+
export declare function playwrightCachedChromium(): string | null;
|
|
11
21
|
/**
|
|
12
22
|
* A launchable Chrome-family binary, or null when this box has none: the explicit
|
|
13
23
|
* CHROME_BIN override first, then the Playwright cache (headless shell — reliable
|
|
@@ -52,7 +52,7 @@ function onPath(bin) {
|
|
|
52
52
|
* virtual-time budget (validated live on this box: shell exits 0 in ~1s, full
|
|
53
53
|
* chromium times out at 30s on the same http:// URL).
|
|
54
54
|
*/
|
|
55
|
-
function playwrightCachedChromium() {
|
|
55
|
+
export function playwrightCachedChromium() {
|
|
56
56
|
const cache = process.env.PLAYWRIGHT_BROWSERS_PATH
|
|
57
57
|
?? (process.platform === 'darwin' ?
|
|
58
58
|
path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright')
|
|
@@ -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
|
/**
|
|
@@ -109,7 +109,10 @@ export function capRequirements(entries, passages) {
|
|
|
109
109
|
*/
|
|
110
110
|
export function enumerateObligationPassages(doc) {
|
|
111
111
|
const out = [];
|
|
112
|
-
|
|
112
|
+
// Normalise CRLF/CR → LF: a Windows-authored spec would otherwise collapse
|
|
113
|
+
// into one giant paragraph (the split marker never matches `\r\n\r\n`) and
|
|
114
|
+
// the per-obligation recall floor would enumerate nothing.
|
|
115
|
+
for (const para of doc.replace(/\r\n?/g, '\n').split(/\n[ \t]*\n/)) {
|
|
113
116
|
const p = para.trim();
|
|
114
117
|
if (p.length < MIN_QUOTE_LENGTH)
|
|
115
118
|
continue;
|
|
@@ -183,6 +186,47 @@ export const REQUIREMENT_EXTRACT_PROMPT = (feature, passages = []) => [
|
|
|
183
186
|
'Output the REQUIREMENT: lines and nothing else. If the text states no requirements,',
|
|
184
187
|
'output nothing.'
|
|
185
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
|
+
}
|
|
186
230
|
/** Per-requirement coverage verdicts against a task list. Runs with --no-tools. */
|
|
187
231
|
export const COVERAGE_MAP_PROMPT = (requirements, titles) => [
|
|
188
232
|
'Below are the REQUIRED CONTENTS of a feature (verbatim quotes mechanically grounded',
|
|
@@ -238,6 +282,12 @@ export function accountCoverage(requirements, mappings) {
|
|
|
238
282
|
acc.mapped.push({ req: requirements[i], task: m.task });
|
|
239
283
|
else if (m.kind === 'cross')
|
|
240
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]);
|
|
241
291
|
else
|
|
242
292
|
acc.unmapped.push(requirements[i]);
|
|
243
293
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.18.
|
|
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",
|