@mjasnikovs/pi-task 0.18.19 → 0.18.21

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
@@ -1,6 +1,6 @@
1
1
  <div align="center">
2
2
 
3
- ![pi-task pipeline: a /task request runs through refine, research, grill, compose and critique, then the final spec is delivered to your main pi session in the same chat. Every phase boundary is persisted to .pi-tasks/TASK_NNNN.md, so the task is crash-safe and resumable.](https://raw.githubusercontent.com/mjasnikovs/pi-task/main/assets/pipeline.svg)
3
+ ![Local models drift. pi-task doesn't trust one prompt. Your ask runs through five fixed phases — refine, research, grill, compose, critique — and what comes out is a spec.](https://raw.githubusercontent.com/mjasnikovs/pi-task/main/assets/hero.svg)
4
4
 
5
5
  # pi-task
6
6
 
@@ -38,6 +38,22 @@ pi install npm:@mjasnikovs/pi-task
38
38
 
39
39
  > Requires [`pi`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) (the Earendil coding agent) ≥ 0.80.
40
40
 
41
+ ## Quickstart
42
+
43
+ One change — `/task` runs it through the full pipeline and hands the finished spec back in the same chat:
44
+
45
+ ```
46
+ /task add rate limiting to the /api/upload endpoint
47
+ ```
48
+
49
+ A whole plan — `/task-auto` splits it into an ordered task list and runs each one through `/task`:
50
+
51
+ ```
52
+ /task-auto Implement @MY_DETAILED_AND_LARGE_PLAN.md
53
+ ```
54
+
55
+ `@`-mentioning a file inlines its **contents**, so point it at the design doc you already wrote — no copy-paste.
56
+
41
57
  ## Slash commands
42
58
 
43
59
  | Command | What it does |
@@ -54,6 +70,12 @@ pi install npm:@mjasnikovs/pi-task
54
70
 
55
71
  ## The pipeline
56
72
 
73
+ <div align="center">
74
+
75
+ ![pi-task pipeline: a /task request runs through refine, research, grill, compose and critique, then the final spec is delivered to your main pi session in the same chat. Every phase boundary is persisted to .pi-tasks/TASK_NNNN.md, so the task is crash-safe and resumable.](https://raw.githubusercontent.com/mjasnikovs/pi-task/main/assets/pipeline.svg)
76
+
77
+ </div>
78
+
57
79
  | Phase | Output section | What happens |
58
80
  | --- | --- | --- |
59
81
  | **refine** | `refined prompt` | Sharpens your raw ask into an unambiguous, self-contained statement. |
Binary file
@@ -0,0 +1,92 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600" width="960" height="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" role="img" aria-label="pi-task. Local models drift. pi-task doesn't trust one prompt. Your ask runs through five fixed phases — refine, research, grill, compose, critique — and what comes out is a spec.">
2
+ <defs>
3
+ <clipPath id="frame"><rect x="6" y="6" width="948" height="588" rx="18"/></clipPath>
4
+ <style>
5
+ .code { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
6
+ .ic { fill:none; stroke-width:2.25; stroke-linecap:round; stroke-linejoin:round; }
7
+ .icf { stroke:none; }
8
+ .ph { font-size:18px; fill:#c9d1d9; }
9
+ .arw { fill:none; stroke:#6e7681; stroke-width:2.07; stroke-linecap:round; stroke-linejoin:round; }
10
+ </style>
11
+ </defs>
12
+
13
+ <rect x="6" y="6" width="948" height="588" rx="18" fill="#0d1117" stroke="#30363d" stroke-width="1.5"/>
14
+
15
+ <!-- ── watermark ── -->
16
+ <defs>
17
+ <linearGradient id="mtFade" gradientUnits="userSpaceOnUse" x1="6" y1="0" x2="688" y2="0">
18
+ <stop offset="0" stop-color="#fff" stop-opacity="1"/>
19
+ <stop offset="0.55" stop-color="#fff" stop-opacity="0.9"/>
20
+ <stop offset="1" stop-color="#fff" stop-opacity="0"/>
21
+ </linearGradient>
22
+ <mask id="mtMask"><rect x="0" y="0" width="960" height="600" fill="url(#mtFade)"/></mask>
23
+ </defs>
24
+ <g clip-path="url(#frame)" mask="url(#mtMask)" fill="none" stroke="#a371f7" stroke-linecap="round" stroke-linejoin="round">
25
+ <path d="M6.0 594.0L75.6 530.2L139.4 550.5L203.2 486.7L275.7 518.6L342.4 449.0L412.0 501.2L484.5 460.6L559.9 521.5L629.5 486.7L687.5 594.0" stroke-width="1.5" opacity="0.12"/>
26
+ <path d="M264.1 330.1L6.0 594.0M264.1 330.1L130.7 594.0M264.1 330.1L255.4 594.0M264.1 330.1L380.1 594.0M264.1 330.1L504.8 594.0M264.1 330.1L629.5 594.0" stroke-width="1" opacity="0.08"/>
27
+ <path d="M56.8 538.9L56.8 594.0M95.9 556.3L95.9 594.0M148.1 475.1L148.1 594.0M191.6 495.4L191.6 594.0M226.4 405.5L226.4 594.0M264.1 330.1L264.1 594.0M290.2 382.3L290.2 594.0M316.3 364.9L316.3 594.0M351.1 443.2L351.1 594.0M385.9 422.9L385.9 594.0M423.6 489.6L423.6 594.0M458.4 469.3L458.4 594.0M499.0 530.2L499.0 594.0M539.6 515.7L539.6 594.0M586.0 567.9L586.0 594.0" stroke-width="1" opacity="0.07"/>
28
+ <path d="M6.0 594.0L56.8 538.9L95.9 556.3L148.1 475.1L191.6 495.4L226.4 405.5L264.1 330.1L290.2 382.3L316.3 364.9L351.1 443.2L385.9 422.9L423.6 489.6L458.4 469.3L499.0 530.2L539.6 515.7L586.0 567.9L629.5 594.0" stroke-width="2" opacity="0.30"/>
29
+ <circle cx="6.0" cy="594.0" r="2.6" fill="#a371f7" stroke="none" opacity="0.34"/>
30
+ <circle cx="148.1" cy="475.1" r="2.6" fill="#a371f7" stroke="none" opacity="0.34"/>
31
+ <circle cx="264.1" cy="330.1" r="2.6" fill="#a371f7" stroke="none" opacity="0.34"/>
32
+ <circle cx="351.1" cy="443.2" r="2.6" fill="#a371f7" stroke="none" opacity="0.34"/>
33
+ <circle cx="458.4" cy="469.3" r="2.6" fill="#a371f7" stroke="none" opacity="0.34"/>
34
+ <circle cx="586.0" cy="567.9" r="2.6" fill="#a371f7" stroke="none" opacity="0.34"/>
35
+ </g>
36
+
37
+ <!-- ── logo lockup · π in a broken circle, drawn as paths (no font dependency) ── -->
38
+ <g transform="translate(371,59) scale(1.2500)" fill="none" stroke="#a371f7" stroke-width="2.40" stroke-linecap="round" stroke-linejoin="round">
39
+ <path d="M30.18 4.98A20 20 0 1 0 41.66 14.61"/>
40
+ <path d="M13.5 17H34.5M19.5 17V32M28.5 17V32"/>
41
+ </g>
42
+ <text x="449" y="99" font-size="44" font-weight="700" fill="#e6edf3">pi-task</text>
43
+
44
+ <!-- ── headline ── -->
45
+ <text x="480" y="224" text-anchor="middle" font-size="82" font-weight="700" fill="#6e7681">Local models drift.</text>
46
+ <text x="480" y="319" text-anchor="middle" font-size="82" font-weight="700" fill="#e6edf3">pi-task doesn&#39;t trust</text>
47
+ <text x="480" y="414" text-anchor="middle" font-size="82" font-weight="700" fill="#e6edf3">one prompt.</text>
48
+
49
+ <!-- ── phase strip ── -->
50
+ <rect x="65" y="473" width="831" height="61" rx="30.5" fill="#161b22" fill-opacity="0.92" stroke="#30363d" stroke-width="1.5"/>
51
+ <g transform="translate(108,492.5) scale(0.9167)" stroke="#58a6ff">
52
+ <circle class="ic" cx="12" cy="12" r="10"/>
53
+ <path class="ic" d="M22 12h-4M6 12H2M12 6V2M12 22v-4"/>
54
+ <circle class="icf" cx="12" cy="12" r="2.4" fill="#58a6ff"/>
55
+ </g>
56
+ <text x="139" y="509.8" class="ph">refine</text>
57
+ <path class="arw" d="M195 503.5h17.2M206.6 496.8l6.7 6.7-6.7 6.7"/>
58
+ <g transform="translate(229,492.5) scale(0.9167)" stroke="#39c5cf">
59
+ <path class="ic" d="M9.56 9.83 15.44 6.67M10.1 12h4.79M9.56 14.17 15.44 17.33"/>
60
+ <circle class="icf" cx="5.5" cy="12" r="2.4" fill="#39c5cf"/>
61
+ <circle class="icf" cx="19.5" cy="4.5" r="2.1" fill="#39c5cf"/>
62
+ <circle class="icf" cx="19.5" cy="12" r="2.1" fill="#39c5cf"/>
63
+ <circle class="icf" cx="19.5" cy="19.5" r="2.1" fill="#39c5cf"/>
64
+ </g>
65
+ <text x="260" y="509.8" class="ph">research</text>
66
+ <path class="arw" d="M341 503.5h17.2M352.6 496.8l6.7 6.7-6.7 6.7"/>
67
+ <g transform="translate(375,492.5) scale(0.9167)" stroke="#d29922">
68
+ <path class="ic" d="M7.6 8.4a4.5 4.5 0 1 1 5.9 4.3c-.9.35-1.5 1.2-1.5 2.2v.6"/>
69
+ <circle class="icf" cx="12" cy="19.8" r="1.5" fill="#d29922"/>
70
+ </g>
71
+ <text x="406" y="509.8" class="ph">grill</text>
72
+ <path class="arw" d="M446 503.5h17.2M457.6 496.8l6.7 6.7-6.7 6.7"/>
73
+ <g transform="translate(480,492.5) scale(0.9167)" stroke="#3fb950">
74
+ <path class="ic" d="M12 2.5 22 7.5 12 12.5 2 7.5 12 2.5z"/>
75
+ <path class="ic" d="M2 16.5 12 21.5 22 16.5"/>
76
+ </g>
77
+ <text x="511" y="509.8" class="ph">compose</text>
78
+ <path class="arw" d="M597 503.5h17.2M608.5 496.8l6.7 6.7-6.7 6.7"/>
79
+ <g transform="translate(631,492.5) scale(0.9167)" stroke="#a371f7">
80
+ <path class="ic" d="M20.5 15a9 9 0 1 1-2.1-9.4L23 10"/>
81
+ <path class="ic" d="M23 4v6h-6"/>
82
+ </g>
83
+ <text x="662" y="509.8" class="ph">critique</text>
84
+ <path class="arw" d="M732 503.5h17.2M743.5 496.8l6.7 6.7-6.7 6.7"/>
85
+ <g transform="translate(766,492.5) scale(0.9167)" stroke="#3fb950">
86
+ <path class="ic" d="M14 2.5H6.5a2 2 0 0 0-2 2v15a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V8z"/>
87
+ <path class="ic" d="M14 2.5V8h5.5"/>
88
+ <path class="ic" d="M8.5 13.5h7M8.5 17.5h7"/>
89
+ </g>
90
+ <text x="797" y="509.8" class="ph" font-weight="600" fill="#3fb950">a spec</text>
91
+
92
+ </svg>
@@ -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`.
@@ -492,17 +492,33 @@ export function buildGateDeps(params) {
492
492
  contracts: () => readContracts(cwd2)
493
493
  });
494
494
  },
495
- lintFix: (fixCtx, cwd2, taskTitle, failReason) => runBoundedLintFix({
496
- cwd: cwd2,
497
- signal,
498
- failReason,
499
- runChild: makeGateChild(fixCtx, cwd2, taskTitle, 'lint-fix', 'verify-debug.log'),
500
- repoHealth: () => Promise.resolve(runRepoHealthCheck(cwd2)),
501
- git: async (args) => {
502
- const r = await git(cwd2, args, signal);
503
- return { exitCode: r.exitCode, stdout: r.stdout };
495
+ lintFix: async (fixCtx, cwd2, taskTitle, taskId, failReason) => {
496
+ // Same frozen extraction the enforce guard and the verify rule-4b
497
+ // probe consume (mx5 run 12: the lint-fix child edited spec-frozen
498
+ // tsconfig.json because ESLint's own error text instructed it, then
499
+ // verify failed the TASK for that edit — the child must know the
500
+ // spec's do-not-touch list AND be mechanically denied it).
501
+ let frozenPaths = [];
502
+ try {
503
+ const { body } = await readTaskFile(cwd2, taskId);
504
+ frozenPaths = frozenPathsFromSpec(extractSpecForVerification(body));
504
505
  }
505
- }),
506
+ catch {
507
+ // spec unreadable → no frozen paths; the guard degrades to a no-op
508
+ }
509
+ return runBoundedLintFix({
510
+ cwd: cwd2,
511
+ signal,
512
+ failReason,
513
+ runChild: makeGateChild(fixCtx, cwd2, taskTitle, 'lint-fix', 'verify-debug.log'),
514
+ repoHealth: () => Promise.resolve(runRepoHealthCheck(cwd2)),
515
+ git: async (args) => {
516
+ const r = await git(cwd2, args, signal);
517
+ return { exitCode: r.exitCode, stdout: r.stdout };
518
+ },
519
+ frozenPaths
520
+ });
521
+ },
506
522
  // Deterministic static check + tree helpers for the enforce pre-commit gate.
507
523
  repoHealth: cwd2 => Promise.resolve(runRepoHealthCheck(cwd2)),
508
524
  dirty: async (cwd2) => {
@@ -1,33 +1,3 @@
1
- /**
2
- * lint-fix — the bounded, graduated resolution for a repo-health verify FAIL.
3
- *
4
- * The failure this closes (mx5 run 3, TASK_0017): AUTOFIX's only hammer is a FULL
5
- * implementation re-run. For a repo-health FAIL of 10 trivial lint findings the live
6
- * run burned two 36–56-minute impl turns, each REGENERATING a fresh 900-line rewrite
7
- * that failed lint differently — the loop cannot converge because the tool is bigger
8
- * than the defect. Validated live on the real TASK_0017 tree: a bounded fix child
9
- * (read,edit,bash) reached lint-clean in 64s and 106s, 2/2.
10
- *
11
- * The same validation caught the design's failure mode: BOTH runs cheated, running
12
- * `git checkout -- src/test/request.ts` — REVERTING the task's uncommitted work to
13
- * make the findings vanish. So this pass ships with a deterministic REVERT-GUARD,
14
- * outcome-based (command filtering can't catch every path to the same effect):
15
- *
16
- * - Before the child runs: snapshot the full working state (`git add -A` +
17
- * `git write-tree`, then unstage) and record which files differ from HEAD.
18
- * - After: any pre-existing work file now byte-identical to HEAD means the child
19
- * discarded work instead of fixing it → restore the snapshot, report not-applied.
20
- * - Converge check: the injected repoHealth must pass; otherwise not-applied.
21
- *
22
- * A file whose ENTIRE pre-fix diff was the lint finding (fixing it legitimately
23
- * restores HEAD) trips the guard conservatively — the fix is discarded and the
24
- * ordinary AUTOFIX picker takes over. Safe direction: the guard may only cost time,
25
- * never work.
26
- *
27
- * Not-applied is never terminal: the caller falls through to the existing
28
- * recommend → AUTOFIX/ACCEPT/dismiss picker, so this pass can only make the loop
29
- * faster, never change what it can decide.
30
- */
31
1
  export interface LintFixResult {
32
2
  /** true → findings fixed, repo health passes, work preserved. */
33
3
  ok: boolean;
@@ -51,6 +21,13 @@ export interface LintFixDeps {
51
21
  exitCode: number;
52
22
  stdout: string;
53
23
  }>;
24
+ /**
25
+ * Paths the task's spec forbids modifying (frozenPathsFromSpec over the same
26
+ * composed spec verify judges). Injected into the child's prompt AND enforced
27
+ * deterministically post-child: a frozen path the child changed is reverted
28
+ * and the fix reported not-applied. Absent/empty → no-op, prior behavior.
29
+ */
30
+ frozenPaths?: string[];
54
31
  }
55
32
  /** The fix child edits and runs the checker; bash exists to RUN the check, not git. */
56
33
  export declare const LINT_FIX_TOOLS = "read,edit,bash";
@@ -60,14 +37,15 @@ export declare const LINT_FIX_TOOLS = "read,edit,bash";
60
37
  * an explicit ban on discarding work (both validation runs reached green via
61
38
  * `git checkout` of the work file until the guard existed).
62
39
  */
63
- export declare function buildLintFixPrompt(failReason: string): string;
40
+ export declare function buildLintFixPrompt(failReason: string, frozenPaths?: string[]): string;
64
41
  /**
65
42
  * Pure guard core: which pre-existing work files did the fix pass revert to HEAD?
66
43
  * `preDirty` = files differing from HEAD before the fix; `stillDirty` = after.
67
44
  */
68
45
  export declare function revertGuardViolations(preDirty: string[], stillDirty: Set<string>): string[];
69
46
  /**
70
- * Run the bounded fix: snapshot → child → revert-guard → converge check. Never
71
- * throws for an outcome; only a child-level user cancel propagates from runChild.
47
+ * Run the bounded fix: snapshot → child → revert-guard → frozen-path guard →
48
+ * converge check. Never throws for an outcome; only a child-level user cancel
49
+ * propagates from runChild.
72
50
  */
73
51
  export declare function runBoundedLintFix(deps: LintFixDeps): Promise<LintFixResult>;
@@ -27,7 +27,24 @@
27
27
  * Not-applied is never terminal: the caller falls through to the existing
28
28
  * recommend → AUTOFIX/ACCEPT/dismiss picker, so this pass can only make the loop
29
29
  * faster, never change what it can decide.
30
+ *
31
+ * FROZEN-PATH GUARD (mx5 run 12, TASK_0021/0022): the checker's own error text
32
+ * can INSTRUCT an edit to a spec-frozen file (typed ESLint: "playwright/index.ts
33
+ * was not found by the project … Consider either including it in the
34
+ * tsconfig.json") and this child complies — while the task's spec froze
35
+ * `tsconfig.json` and verify's rule-4b prohibition probe then fails the TASK for
36
+ * the gate child's edit. Two gates, contradictory rules, same file; the loop
37
+ * never converges (live: TASK_0021 burned all three unattended AUTOFIX rounds).
38
+ * Prompt framing alone is A/B-proven ~0–1/5 on the weak model (see
39
+ * frozen-path-guard.ts), so the deny is mechanical: the spec's frozen paths are
40
+ * threaded in via `frozenPaths`, injected into the prompt as a do-not-touch list
41
+ * (belt), and any frozen path the child still changed is deterministically
42
+ * reverted post-child and the fix reported not-applied (suspenders). Only paths
43
+ * that were CLEAN before the child ran are reverted — a frozen path already
44
+ * dirty with (possibly task) work is left alone, in the guard's safe direction:
45
+ * cost time, never work.
30
46
  */
47
+ import { parseChangedFrozenFiles, revertFrozenPaths } from './frozen-path-guard.js';
31
48
  /** The fix child edits and runs the checker; bash exists to RUN the check, not git. */
32
49
  export const LINT_FIX_TOOLS = 'read,edit,bash';
33
50
  /**
@@ -36,7 +53,20 @@ export const LINT_FIX_TOOLS = 'read,edit,bash';
36
53
  * an explicit ban on discarding work (both validation runs reached green via
37
54
  * `git checkout` of the work file until the guard existed).
38
55
  */
39
- export function buildLintFixPrompt(failReason) {
56
+ export function buildLintFixPrompt(failReason, frozenPaths = []) {
57
+ const frozenBlock = frozenPaths.length === 0 ?
58
+ []
59
+ : [
60
+ '3b. HARD CONSTRAINT — SPEC-FROZEN PATHS. The task spec forbids modifying:',
61
+ ...frozenPaths.map(p => ` - ${p}`),
62
+ ' You must NOT edit, create, or delete anything at or under these paths,',
63
+ " EVEN IF the checker's own error message suggests exactly that fix",
64
+ ' (e.g. "consider including it in the tsconfig.json"). Any change you make',
65
+ ' to a frozen path is detected and reverted, and the whole fix is rejected.',
66
+ ' If the check cannot pass without touching a frozen path, STOP and report',
67
+ ' LINT-FIX: BLOCKED with the reason.',
68
+ ''
69
+ ];
40
70
  return [
41
71
  'You are a bounded static-analysis fix pass. A verification gate just failed',
42
72
  `with: ${failReason}`,
@@ -59,6 +89,7 @@ export function buildLintFixPrompt(failReason) {
59
89
  ' Reverting the work would make the findings vanish — that is destroying the',
60
90
  ' task, not fixing it, and it is detected and rejected.',
61
91
  '',
92
+ ...frozenBlock,
62
93
  '4. Re-run the check after editing and confirm it exits 0.',
63
94
  '',
64
95
  'End with exactly one line:',
@@ -91,8 +122,21 @@ async function dirtyFiles(deps) {
91
122
  .filter(l => l.length > 0);
92
123
  }
93
124
  /**
94
- * Run the bounded fix: snapshot → child → revert-guard → converge check. Never
95
- * throws for an outcome; only a child-level user cancel propagates from runChild.
125
+ * Which spec-frozen paths currently show a change in `git status`? Null when git
126
+ * itself failed — inconclusive, not evidence (same discipline as dirtyFiles).
127
+ */
128
+ async function frozenDirtySet(deps, frozen) {
129
+ if (frozen.length === 0)
130
+ return new Set();
131
+ const r = await deps.git(['status', '--porcelain', '--', ...frozen]);
132
+ if (r.exitCode !== 0)
133
+ return null;
134
+ return new Set(parseChangedFrozenFiles(r.stdout));
135
+ }
136
+ /**
137
+ * Run the bounded fix: snapshot → child → revert-guard → frozen-path guard →
138
+ * converge check. Never throws for an outcome; only a child-level user cancel
139
+ * propagates from runChild.
96
140
  */
97
141
  export async function runBoundedLintFix(deps) {
98
142
  // Snapshot the full working state as a tree object (includes untracked files),
@@ -122,8 +166,14 @@ export async function runBoundedLintFix(deps) {
122
166
  snapshot = wt.stdout.trim();
123
167
  await deps.git(['reset']);
124
168
  }
169
+ // FROZEN-PATH baseline: frozen paths already dirty BEFORE the child ran carry
170
+ // (possibly the task's own) work — the guard must never revert those, only
171
+ // changes the CHILD introduces on top of a clean frozen path. A git failure
172
+ // here disables the guard for this run (inconclusive ≠ license to revert).
173
+ const frozen = deps.frozenPaths ?? [];
174
+ const preFrozenDirty = await frozenDirtySet(deps, frozen);
125
175
  try {
126
- await deps.runChild(LINT_FIX_TOOLS, buildLintFixPrompt(deps.failReason), deps.signal);
176
+ await deps.runChild(LINT_FIX_TOOLS, buildLintFixPrompt(deps.failReason, frozen), deps.signal);
127
177
  }
128
178
  catch (err) {
129
179
  const msg = err instanceof Error ? err.message : String(err);
@@ -174,6 +224,31 @@ export async function runBoundedLintFix(deps) {
174
224
  + `${violations.length > 3 ? ', …' : ''}) — fix ${snapshot ? 'rolled back' : 'REJECTED but no snapshot to restore'}`
175
225
  };
176
226
  }
227
+ // FROZEN-PATH GUARD: a frozen path that was clean pre-child and is changed
228
+ // now is the child's edit — the exact write verify's rule-4b prohibition
229
+ // probe is guaranteed to fail the TASK for (mx5 run 12: ESLint's own error
230
+ // text instructed the tsconfig.json edit and the child complied, 5/5 in the
231
+ // live A/B). Revert JUST those paths (they were clean, so HEAD == pre-state:
232
+ // no work can be lost) and report not-applied so the caller falls through to
233
+ // the ordinary recommend → AUTOFIX/ACCEPT picker. Inconclusive git (either
234
+ // side) → guard steps aside; the verify probe still catches what survives.
235
+ if (preFrozenDirty !== null) {
236
+ const postFrozenDirty = await frozenDirtySet(deps, frozen);
237
+ if (postFrozenDirty !== null) {
238
+ const frozenViolations = [...postFrozenDirty].filter(f => !preFrozenDirty.has(f));
239
+ if (frozenViolations.length > 0) {
240
+ const reverted = await revertFrozenPaths(frozenViolations, deps.git);
241
+ return {
242
+ ok: false,
243
+ reason: `frozen-path: fix child modified spec-frozen path(s) `
244
+ + `(${frozenViolations.slice(0, 3).join(', ')}`
245
+ + `${frozenViolations.length > 3 ? ', …' : ''}) — `
246
+ + `${reverted.length > 0 ? 'reverted' : 'REJECTED (revert found nothing to undo)'}; `
247
+ + `the static findings need a fix that respects the spec's constraints`
248
+ };
249
+ }
250
+ }
251
+ }
177
252
  const health = await deps.repoHealth();
178
253
  if (!health.ok) {
179
254
  return { ok: false, reason: `did not converge: ${health.reason}` };
@@ -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);
@@ -87,7 +87,7 @@ export interface GateDeps {
87
87
  * at most once per gate sequence; not-applied falls through to the picker.
88
88
  * Absent → the loop goes straight to recommend/picker as before.
89
89
  */
90
- lintFix?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string, failReason: string) => Promise<{
90
+ lintFix?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string, taskId: string, failReason: string) => Promise<{
91
91
  ok: boolean;
92
92
  reason?: string;
93
93
  }>;
@@ -98,7 +98,7 @@ export async function runGatesForTask(ctxIn, deps, p) {
98
98
  if (!lintFixAttempted && deps.lintFix && failReason.startsWith('repo health:')) {
99
99
  lintFixAttempted = true;
100
100
  active.ui.notify(`${p.tag}: static findings on "${p.title}" — attempting bounded lint fix…`, 'info');
101
- const fix = await deps.lintFix(active, p.cwd, p.title, failReason);
101
+ const fix = await deps.lintFix(active, p.cwd, p.title, p.taskId, failReason);
102
102
  await rec(`lint-fix: ${fix.ok ?
103
103
  `applied${fix.reason ? ` (${fix.reason})` : ''} — re-verifying`
104
104
  : `not applied (${fix.reason ?? 'failed'})`}`);
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.21",
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",
@@ -85,6 +85,6 @@
85
85
  "extensions": [
86
86
  "dist/index.js"
87
87
  ],
88
- "image": "https://raw.githubusercontent.com/mjasnikovs/pi-task/main/assets/pipeline.png"
88
+ "image": "https://raw.githubusercontent.com/mjasnikovs/pi-task/main/assets/hero.png"
89
89
  }
90
90
  }