@mjasnikovs/pi-task 0.38.16 → 0.38.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.
Files changed (38) hide show
  1. package/dist/config/config.d.ts +37 -0
  2. package/dist/config/config.js +81 -18
  3. package/dist/task/accept-debt.js +2 -1
  4. package/dist/task/artifact-closure.js +18 -63
  5. package/dist/task/auto-orchestrator.js +205 -214
  6. package/dist/task/boot-probe.d.ts +46 -0
  7. package/dist/task/boot-probe.js +41 -21
  8. package/dist/task/coverage-loop.d.ts +11 -0
  9. package/dist/task/coverage-loop.js +16 -0
  10. package/dist/task/final-gate-fix.js +14 -24
  11. package/dist/task/final-gate.js +6 -1
  12. package/dist/task/fix-child.d.ts +64 -0
  13. package/dist/task/fix-child.js +66 -0
  14. package/dist/task/lint-fix.d.ts +7 -0
  15. package/dist/task/lint-fix.js +45 -9
  16. package/dist/task/orchestrator.js +9 -2
  17. package/dist/task/phases.d.ts +66 -4
  18. package/dist/task/phases.js +94 -34
  19. package/dist/task/plan-rounds.d.ts +86 -0
  20. package/dist/task/plan-rounds.js +105 -0
  21. package/dist/task/plan-session.d.ts +31 -21
  22. package/dist/task/plan-session.js +97 -120
  23. package/dist/task/qa-transcript.d.ts +100 -0
  24. package/dist/task/qa-transcript.js +99 -0
  25. package/dist/task/question-source.d.ts +117 -0
  26. package/dist/task/question-source.js +174 -0
  27. package/dist/task/serve-entry.js +6 -57
  28. package/dist/task/shipped-source.d.ts +67 -0
  29. package/dist/task/shipped-source.js +144 -0
  30. package/dist/task/task-gates.d.ts +1 -1
  31. package/dist/task/task-gates.js +4 -2
  32. package/dist/task/verify-work.d.ts +46 -0
  33. package/dist/task/verify-work.js +51 -3
  34. package/dist/task/widget.js +41 -9
  35. package/dist/workers/docs-core.d.ts +71 -1
  36. package/dist/workers/docs-core.js +131 -71
  37. package/dist/workers/pi-worker-core.js +23 -8
  38. package/package.json +1 -1
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Where the NEXT question comes from — the other half of `question-dialog.ts`.
3
+ *
4
+ * `question-dialog.ts` unified the ANSWER side of the adaptive dialogs (the picker
5
+ * cards, the reply mapping) and its own docstring makes the argument for doing so:
6
+ * *"It was written three times… the two mirrors were never converted, and they had
7
+ * already drifted apart in three ways… The next edit to any of them is where the
8
+ * bug lands."* The QUESTION side — generate → parse → pick the real question →
9
+ * dedupe → spend one corrective re-prompt → yield or exhaust — was left behind,
10
+ * and it had drifted between the two loops that use the SAME parser on the SAME
11
+ * prompt format (`plan-prompts.ts` says `parseClarifyList` parses it "UNCHANGED";
12
+ * `auto-prompts.ts` specifies the identical shape).
13
+ *
14
+ * The five drifts, all in clarify's favour of being wrong:
15
+ *
16
+ * 1. **Which entry is the question.** `parseClarifyList` pushes an entry for
17
+ * EVERY numbered line, and the local model writes numbered analysis notes
18
+ * before the question it was asked for (measured live). `pickQuestion` prefers
19
+ * the first entry carrying a `SUGGESTED:` line; clarify took `parsed[0]`
20
+ * blindly, showing the note as the question and losing the recommendation
21
+ * attached further down.
22
+ * 2. **NONE vs unparseable.** The parser returns `[]` for both. Clarify's
23
+ * `if (parsed.length === 0) break` ended the whole clarify — and decomposed the
24
+ * feature with ZERO clarifications — on a formatting slip.
25
+ * 3. **A re-typed sentinel.** `isNoneReply`'s regex was a byte-identical second
26
+ * copy of the parser's own.
27
+ * 4. **Missing SUGGESTED** bought one corrective re-prompt in plan and none in
28
+ * clarify, so clarify showed a card-less question.
29
+ * 5. **The deferral guard.** It exists because an accepted "clarify with the user
30
+ * before proceeding" rode into `/task`'s handoff AS AN AUTHORITATIVE DECISION
31
+ * and produced a task whose VERIFY asserted no source file had changed.
32
+ * Clarify's answers ride into the decompose prompt and the AUTO file with
33
+ * exactly the same authority, and had no guard.
34
+ *
35
+ * NOT unified here: grill's generation loop. It uses a different parser
36
+ * (`parseGrillQuestions`, which yields bare strings) and has no `SUGGESTED` at
37
+ * generation time at all — grill's recommendation comes from `phaseAutoAnswer`
38
+ * one step later, so every quality rule below is inapplicable to it. Folding it in
39
+ * would mean a generic over the parsed shape with one consumer opting out of the
40
+ * entire rule table: a wider interface for less behaviour.
41
+ */
42
+ import { parseClarifyList } from './parsers.js';
43
+ import { DUP_REPROMPT_HINT, isDuplicateQuestion, MAX_DUP_STRIKES } from './question-dedup.js';
44
+ import { stripInlineMarkdown } from './inline-markdown.js';
45
+ /**
46
+ * The cap on distinct questions ONE adaptive dialog may ask.
47
+ *
48
+ * Clarify and plan each declared their own `8`, linked only by a comment saying
49
+ * "matches /task-auto's MAX_CLARIFY_QUESTIONS, for the same reason". They bound
50
+ * the same thing for the same reason; this is that reason, once.
51
+ */
52
+ export const MAX_DIALOG_QUESTIONS = 8;
53
+ /** True when the reply is the deliberate "nothing left to ask" sentinel, as
54
+ * opposed to output the parser simply could not read. */
55
+ export function isNoneReply(raw) {
56
+ return /^\s*NONE\s*$/m.test(raw);
57
+ }
58
+ /**
59
+ * Which of the parsed entries is the actual question.
60
+ *
61
+ * `parseClarifyList` turns EVERY numbered line into an entry, and the local model
62
+ * sometimes writes a numbered analysis note or two before the question it was
63
+ * asked for (measured live: the first numbered line was a note like
64
+ * "1. gateDebugWriter in orchestrator.ts — wraps a raw append function"). Taking
65
+ * entry 0 blindly then shows the note as the question and loses the SUGGESTED line
66
+ * attached further down. The prompt requires exactly one SUGGESTED, and the parser
67
+ * attaches it to the entry it follows.
68
+ */
69
+ export function pickQuestion(parsed) {
70
+ return parsed.find(q => q.suggested !== undefined && q.suggested.length > 0) ?? parsed[0];
71
+ }
72
+ /**
73
+ * A deep module over a state machine that was five mutable locals per site.
74
+ *
75
+ * The interface is one method. Behind it: the cap, the duplicate backstop and its
76
+ * strike budget, the NONE-vs-unparseable distinction, `pickQuestion`, the one-shot
77
+ * budget shared by every quality rule, and the hint precedence between a format
78
+ * re-prompt and a duplicate re-prompt.
79
+ */
80
+ export function makeQuestionSource(deps) {
81
+ const cap = deps.cap ?? MAX_DIALOG_QUESTIONS;
82
+ const rules = deps.rules ?? [];
83
+ const asked = [];
84
+ let dupStrikes = 0;
85
+ let dupHint = null;
86
+ // The one-shot budget is per QUESTION, not per dialog: a fresh draw starts
87
+ // with every rule available again. `hint` being non-null is also what spends
88
+ // it — a rule may not fire while another rule's re-prompt is in flight, which
89
+ // is what stops two rules ping-ponging a stateless child forever.
90
+ let hint = null;
91
+ async function next() {
92
+ for (;;) {
93
+ if (asked.length >= cap) {
94
+ deps.log?.(`question cap (${cap}) reached`);
95
+ return { kind: 'exhausted', why: 'cap' };
96
+ }
97
+ const raw = await deps.generate(hint ?? dupHint);
98
+ const parsed = parseClarifyList(raw);
99
+ if (parsed.length === 0) {
100
+ // `[]` means BOTH "deliberate NONE" and "could not parse". Ending
101
+ // the dialog on the second is how a formatting slip becomes a
102
+ // feature decomposed with zero clarifications.
103
+ if (!isNoneReply(raw) && hint === null) {
104
+ deps.log?.('unparseable question reply — one format re-prompt');
105
+ hint = deps.formatHint;
106
+ continue;
107
+ }
108
+ // A SECOND unreadable reply is not a NONE. Recording it as one is
109
+ // the very conflation this module exists to end — it would put
110
+ // "model has no further questions" on the trail for a run where
111
+ // the model produced two malformed replies.
112
+ if (!isNoneReply(raw)) {
113
+ deps.log?.('second unparseable reply — giving up on this draw');
114
+ hint = null;
115
+ return { kind: 'exhausted', why: 'unparseable' };
116
+ }
117
+ deps.log?.('model has no further questions (NONE)');
118
+ hint = null;
119
+ return { kind: 'exhausted', why: 'none' };
120
+ }
121
+ let picked = pickQuestion(parsed);
122
+ const plain = stripInlineMarkdown(picked.question);
123
+ // The duplicate backstop runs BEFORE any quality re-prompt: a question
124
+ // about to be discarded as a re-ask must not first buy itself an extra
125
+ // child call to be polished.
126
+ if (isDuplicateQuestion(asked, plain)) {
127
+ dupStrikes++;
128
+ deps.log?.(`duplicate question, strike ${dupStrikes}/${MAX_DUP_STRIKES}`);
129
+ hint = null;
130
+ if (dupStrikes >= MAX_DUP_STRIKES)
131
+ return { kind: 'exhausted', why: 'dups' };
132
+ dupHint = DUP_REPROMPT_HINT;
133
+ continue;
134
+ }
135
+ let reprompted = false;
136
+ for (const rule of rules) {
137
+ const h = rule.detect(picked, plain);
138
+ if (h === null)
139
+ continue;
140
+ if (hint === null) {
141
+ deps.log?.(`${rule.id} — one re-prompt`);
142
+ hint = h;
143
+ reprompted = true;
144
+ break;
145
+ }
146
+ // Survived its re-prompt: degrade rather than discard.
147
+ if (rule.repair) {
148
+ deps.log?.(`${rule.id} — survived the re-prompt, repaired`);
149
+ picked = rule.repair(picked);
150
+ }
151
+ }
152
+ if (reprompted)
153
+ continue;
154
+ hint = null;
155
+ dupStrikes = 0;
156
+ dupHint = null;
157
+ asked.push(plain);
158
+ return { kind: 'question', q: picked, plain, index: asked.length };
159
+ }
160
+ }
161
+ /**
162
+ * Clear the DUP strike budget after the caller supplies new context.
163
+ *
164
+ * `/task-plan` lets the user ask the model a question or state a decision
165
+ * mid-session; that is new context, so a generator that had struck out may now
166
+ * have something novel. The plan loop reset its own `dupStrikes` at two sites
167
+ * for exactly this; the budget lives here now, so the reset has to.
168
+ */
169
+ function reopen() {
170
+ dupStrikes = 0;
171
+ dupHint = null;
172
+ }
173
+ return { next, asked: () => asked, reopen };
174
+ }
@@ -38,8 +38,9 @@
38
38
  *
39
39
  * Ground truth is the file tree only. No model, no network.
40
40
  */
41
- import { readdirSync, readFileSync, statSync } from 'node:fs';
41
+ import { readFileSync } from 'node:fs';
42
42
  import * as path from 'node:path';
43
+ import { shippedSources, stripCommentLines, SOURCE_JS_RE } from './shipped-source.js';
43
44
  /** A server-app construction: framework, and the regex that recognises it. */
44
45
  const CONSTRUCT_PATTERNS = [
45
46
  { re: /\bnew\s+Hono\s*[<(]/, construct: 'new Hono()' },
@@ -180,62 +181,10 @@ export function opaqueLauncher(cwd) {
180
181
  }
181
182
  return null;
182
183
  }
183
- // Directories never scanned: VCS/dep trees, build output (bundled copies of the
184
- // same sources), and test/fixture/example/doc trees a test that stands up a
185
- // throwaway listener is not the app's launch, and a doc snippet is not code.
186
- const SKIP_DIR_RE = /^(?:\.git|node_modules|\.pi-tasks|dist|build|out|coverage|target|vendor|__pycache__|\.venv|venv|tmp|test|tests|__tests__|__mocks__|__fixtures__|fixtures|e2e|examples|example|docs|doc|bench|benchmarks)$/;
187
- const SKIP_FILE_RE = /\.(?:test|spec|stories|bench)\.[a-z]+$|\.d\.[mc]?ts$/i;
188
- const SCAN_RE = /\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts)$/i;
189
- const MAX_SCAN_FILES = 3000;
190
- const MAX_FILE_BYTES = 400_000;
191
- /** Authored sources, bounded and in deterministic order. */
192
- function scanCandidates(cwd) {
193
- const out = [];
194
- const walk = (rel) => {
195
- if (out.length >= MAX_SCAN_FILES)
196
- return;
197
- let entries;
198
- try {
199
- entries = readdirSync(path.join(cwd, rel)).sort();
200
- }
201
- catch {
202
- return;
203
- }
204
- for (const name of entries) {
205
- if (out.length >= MAX_SCAN_FILES)
206
- return;
207
- const relPath = rel === '' ? name : `${rel}/${name}`;
208
- let st;
209
- try {
210
- st = statSync(path.join(cwd, relPath));
211
- }
212
- catch {
213
- continue;
214
- }
215
- if (st.isDirectory()) {
216
- if (name.startsWith('.') || SKIP_DIR_RE.test(name))
217
- continue;
218
- walk(relPath);
219
- }
220
- else if (st.isFile() && st.size <= MAX_FILE_BYTES) {
221
- if (SKIP_FILE_RE.test(name))
222
- continue;
223
- if (SCAN_RE.test(name))
224
- out.push(relPath);
225
- }
226
- }
227
- };
228
- walk('');
229
- return out;
230
- }
231
- /** Strip comment-only lines — a `Bun.serve` quoted in a comment is not a bind, and
232
- * a commented-out catch-all is not a route. Inline comments are left alone. */
233
- function stripCommentLines(src) {
234
- return src
235
- .split('\n')
236
- .filter(l => !/^\s*(?:\/\/|\*|\/\*)/.test(l))
237
- .join('\n');
238
- }
184
+ // The tree walk, the caps, the skip sets and the comment strip live in
185
+ // task/shipped-source.ts this scan and artifact-closure's were the same walker
186
+ // written twice, and their skip sets had drifted apart on `bench`/`benchmarks`.
187
+ const scanCandidates = (cwd) => shippedSources(cwd, { ext: SOURCE_JS_RE });
239
188
  /** A `/…/flags` literal whose body carries a regex METACHARACTER — `[`, `\`, `+`,
240
189
  * `?`, `|`, a group. A path string like `'/api/admin'` has none, so it survives. */
241
190
  const REGEX_LITERAL_RE = /\/(?![*/])((?:\\.|\[(?:\\.|[^\]\\])*\]|[^/\\\n])+)\/[gimsuyd]*/g;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * What counts as SHIPPED SOURCE — the input every run-level closure scan reads.
3
+ *
4
+ * `CLOSURE_SCANS` (final-gate.ts) deepened the DRIVER of these scans: the fault
5
+ * isolation, the rank, the stage. It did not unify their INPUT, and the copies
6
+ * had drifted.
7
+ *
8
+ * - `scanCandidates` existed twice, near-byte-identical — `serve-entry.ts` and
9
+ * `artifact-closure.ts` — same `readdirSync().sort()` → `statSync` → recurse,
10
+ * same 3000-file cap, same 400 KB per-file cap, same dot-dir rule.
11
+ * - The skip sets had diverged: serve-entry carried `bench|benchmarks` and
12
+ * `*.bench.*`; artifact-closure did not, so a dangling artifact reference in a
13
+ * benchmark file was a run-level finding while the same file was invisible to
14
+ * the sibling scan. Nothing in either file acknowledged the other.
15
+ * - The same extension regex was declared twice under two names (`SCAN_RE`,
16
+ * `SCAN_JS_RE`), and `stripCommentLines` was byte-identical in both.
17
+ * - `.pi-tasks` was hardcoded into both skip sets rather than derived from
18
+ * `TASKS_DIR_NAME`.
19
+ *
20
+ * The locality proof is in the suites: `artifact-closure.test.ts` has 28
21
+ * references to the pure extractors and 5 calls to the driver, and NO test in the
22
+ * cluster asserted a skip set at all. That is the shape recorded under
23
+ * `resolveTypeSource` — pure functions extracted for testability while the real
24
+ * logic stayed in how they are CALLED.
25
+ *
26
+ * NOT unified here: `env-template-closure.ts`. It asks a different question —
27
+ * which TRACKED files could read an env var, including `.py`/`.go`, including
28
+ * tests — and answering it over this walk would silently change which env
29
+ * findings a run produces. Its comment rule is not this one either: it is a
30
+ * per-line predicate that also treats `#` as a comment opener, which it must,
31
+ * and which would be wrong for JS/TS. Recorded as a real divergence rather than
32
+ * harmonised; changing it is an env-policy change with its own A/B.
33
+ */
34
+ /** Authored JS/TS. The one declaration — it was two constants under two names. */
35
+ export declare const SOURCE_JS_RE: RegExp;
36
+ /** Markup a produced artifact can be referenced from. */
37
+ export declare const SOURCE_HTML_RE: RegExp;
38
+ /** Bounds. A scan is a gate step, not a search: it must terminate on any tree. */
39
+ export declare const MAX_SCAN_FILES = 3000;
40
+ export declare const MAX_FILE_BYTES = 400000;
41
+ /** Is this directory NAME (not path) one no closure scan descends into? */
42
+ export declare function isSkippedDir(name: string): boolean;
43
+ /** Is this file NAME one no closure scan reads? */
44
+ export declare function isSkippedFile(name: string): boolean;
45
+ export interface ShippedSourceOptions {
46
+ /** Which extensions this scan reads. */
47
+ ext: RegExp;
48
+ /**
49
+ * Extra ROOT-LEVEL directory names to exclude. artifact-closure's own: a
50
+ * produced output tree re-referencing its own chunks is noise, and which dirs
51
+ * are produced is discovered per run, so it cannot live in the static set.
52
+ */
53
+ excludeRoots?: ReadonlySet<string>;
54
+ }
55
+ /**
56
+ * Walk `cwd` for shipped sources — bounded, deterministic order, never throws.
57
+ *
58
+ * Unreadable directories and unstattable entries are skipped rather than fatal:
59
+ * a closure scan runs against whatever tree the implementation left behind.
60
+ */
61
+ export declare function shippedSources(cwd: string, opts: ShippedSourceOptions): string[];
62
+ /**
63
+ * Strip comment-only lines. A `Bun.serve` quoted in a comment is not a bind, a
64
+ * commented-out catch-all is not a route, and a path in a comment is not a
65
+ * runtime read. Inline comments are left alone — strings may contain `//`.
66
+ */
67
+ export declare function stripCommentLines(src: string): string;
@@ -0,0 +1,144 @@
1
+ /**
2
+ * What counts as SHIPPED SOURCE — the input every run-level closure scan reads.
3
+ *
4
+ * `CLOSURE_SCANS` (final-gate.ts) deepened the DRIVER of these scans: the fault
5
+ * isolation, the rank, the stage. It did not unify their INPUT, and the copies
6
+ * had drifted.
7
+ *
8
+ * - `scanCandidates` existed twice, near-byte-identical — `serve-entry.ts` and
9
+ * `artifact-closure.ts` — same `readdirSync().sort()` → `statSync` → recurse,
10
+ * same 3000-file cap, same 400 KB per-file cap, same dot-dir rule.
11
+ * - The skip sets had diverged: serve-entry carried `bench|benchmarks` and
12
+ * `*.bench.*`; artifact-closure did not, so a dangling artifact reference in a
13
+ * benchmark file was a run-level finding while the same file was invisible to
14
+ * the sibling scan. Nothing in either file acknowledged the other.
15
+ * - The same extension regex was declared twice under two names (`SCAN_RE`,
16
+ * `SCAN_JS_RE`), and `stripCommentLines` was byte-identical in both.
17
+ * - `.pi-tasks` was hardcoded into both skip sets rather than derived from
18
+ * `TASKS_DIR_NAME`.
19
+ *
20
+ * The locality proof is in the suites: `artifact-closure.test.ts` has 28
21
+ * references to the pure extractors and 5 calls to the driver, and NO test in the
22
+ * cluster asserted a skip set at all. That is the shape recorded under
23
+ * `resolveTypeSource` — pure functions extracted for testability while the real
24
+ * logic stayed in how they are CALLED.
25
+ *
26
+ * NOT unified here: `env-template-closure.ts`. It asks a different question —
27
+ * which TRACKED files could read an env var, including `.py`/`.go`, including
28
+ * tests — and answering it over this walk would silently change which env
29
+ * findings a run produces. Its comment rule is not this one either: it is a
30
+ * per-line predicate that also treats `#` as a comment opener, which it must,
31
+ * and which would be wrong for JS/TS. Recorded as a real divergence rather than
32
+ * harmonised; changing it is an env-policy change with its own A/B.
33
+ */
34
+ import { readdirSync, statSync } from 'node:fs';
35
+ import * as path from 'node:path';
36
+ import { TASKS_DIR_NAME } from './task-types.js';
37
+ /**
38
+ * Directories never scanned: VCS/dep trees, build output (bundled copies of the
39
+ * same sources), and test/fixture/example/doc/bench trees — a test that stands up
40
+ * a throwaway listener is not the app's launch, a doc snippet is not code, and a
41
+ * benchmark references fixture paths freely.
42
+ */
43
+ const SKIP_DIRS = new Set([
44
+ '.git',
45
+ 'node_modules',
46
+ TASKS_DIR_NAME,
47
+ 'dist',
48
+ 'build',
49
+ 'out',
50
+ 'coverage',
51
+ 'target',
52
+ 'vendor',
53
+ '__pycache__',
54
+ '.venv',
55
+ 'venv',
56
+ 'tmp',
57
+ 'test',
58
+ 'tests',
59
+ '__tests__',
60
+ '__mocks__',
61
+ '__fixtures__',
62
+ 'fixtures',
63
+ 'e2e',
64
+ 'examples',
65
+ 'example',
66
+ 'docs',
67
+ 'doc',
68
+ 'bench',
69
+ 'benchmarks'
70
+ ]);
71
+ const SKIP_FILE_RE = /\.(?:test|spec|stories|bench)\.[a-z]+$|\.d\.[mc]?ts$/i;
72
+ /** Authored JS/TS. The one declaration — it was two constants under two names. */
73
+ export const SOURCE_JS_RE = /\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts)$/i;
74
+ /** Markup a produced artifact can be referenced from. */
75
+ export const SOURCE_HTML_RE = /\.html?$/i;
76
+ /** Bounds. A scan is a gate step, not a search: it must terminate on any tree. */
77
+ export const MAX_SCAN_FILES = 3000;
78
+ export const MAX_FILE_BYTES = 400_000;
79
+ /** Is this directory NAME (not path) one no closure scan descends into? */
80
+ export function isSkippedDir(name) {
81
+ return name.startsWith('.') || SKIP_DIRS.has(name);
82
+ }
83
+ /** Is this file NAME one no closure scan reads? */
84
+ export function isSkippedFile(name) {
85
+ return SKIP_FILE_RE.test(name);
86
+ }
87
+ /**
88
+ * Walk `cwd` for shipped sources — bounded, deterministic order, never throws.
89
+ *
90
+ * Unreadable directories and unstattable entries are skipped rather than fatal:
91
+ * a closure scan runs against whatever tree the implementation left behind.
92
+ */
93
+ export function shippedSources(cwd, opts) {
94
+ const out = [];
95
+ const walk = (rel) => {
96
+ if (out.length >= MAX_SCAN_FILES)
97
+ return;
98
+ let entries;
99
+ try {
100
+ entries = readdirSync(path.join(cwd, rel)).sort();
101
+ }
102
+ catch {
103
+ return;
104
+ }
105
+ for (const name of entries) {
106
+ if (out.length >= MAX_SCAN_FILES)
107
+ return;
108
+ const relPath = rel === '' ? name : `${rel}/${name}`;
109
+ let st;
110
+ try {
111
+ st = statSync(path.join(cwd, relPath));
112
+ }
113
+ catch {
114
+ continue;
115
+ }
116
+ if (st.isDirectory()) {
117
+ if (isSkippedDir(name))
118
+ continue;
119
+ if (rel === '' && opts.excludeRoots?.has(name))
120
+ continue;
121
+ walk(relPath);
122
+ }
123
+ else if (st.isFile() && st.size <= MAX_FILE_BYTES) {
124
+ if (isSkippedFile(name))
125
+ continue;
126
+ if (opts.ext.test(name))
127
+ out.push(relPath);
128
+ }
129
+ }
130
+ };
131
+ walk('');
132
+ return out;
133
+ }
134
+ /**
135
+ * Strip comment-only lines. A `Bun.serve` quoted in a comment is not a bind, a
136
+ * commented-out catch-all is not a route, and a path in a comment is not a
137
+ * runtime read. Inline comments are left alone — strings may contain `//`.
138
+ */
139
+ export function stripCommentLines(src) {
140
+ return src
141
+ .split('\n')
142
+ .filter(l => !/^\s*(?:\/\/|\*|\/\*)/.test(l))
143
+ .join('\n');
144
+ }
@@ -31,7 +31,7 @@
31
31
  import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
32
32
  import type { RunSingleTaskResult } from './orchestrator.js';
33
33
  import type { CommitResult } from './auto-commit.js';
34
- import type { VerifyOutcome } from './verify-work.js';
34
+ import { type VerifyOutcome } from './verify-work.js';
35
35
  import type { EnforceOutcome } from './enforce-guidelines.js';
36
36
  import { type ResolutionOutcome, type ResolutionChoice } from './verify-resolution.js';
37
37
  import { type RepairCandidate } from './root-cause-repair.js';
@@ -1,3 +1,4 @@
1
+ import { verifyFailClass } from './verify-work.js';
1
2
  import { resolutionOptions, classifyResolutionAnswer } from './verify-resolution.js';
2
3
  import { SessionUI } from '../remote/bridge.js';
3
4
  import { isYoloMode, yoloVerifyResolution, YOLO_STAMP } from './yolo.js';
@@ -121,7 +122,8 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
121
122
  // bounded fix attempt before the picker — smallest tool first. Applied →
122
123
  // re-verify and re-enter the loop on the fresh verdict; not applied (guard
123
124
  // trip, no convergence) → fall through to the ordinary picker unchanged.
124
- if (!lintFixAttempted && deps.lintFix && failReason.startsWith('repo health:')) {
125
+ const failClass = verifyFailClass(verified);
126
+ if (!lintFixAttempted && deps.lintFix && failClass === 'repo-health') {
125
127
  lintFixAttempted = true;
126
128
  active.ui.notify(`${p.tag}: static findings on "${p.title}" — attempting bounded lint fix…`, 'info');
127
129
  const fix = await deps.lintFix(active, p.cwd, p.title, p.taskId, failReason);
@@ -149,7 +151,7 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
149
151
  // records the (already-recorded) defect as the human's call. Applies
150
152
  // only while the FAIL is still the repo-health one the contradiction
151
153
  // explains — a later, different FAIL gets the ordinary resolution path.
152
- const isFrozenBlocked = frozenContradiction !== null && failReason.startsWith('repo health:');
154
+ const isFrozenBlocked = frozenContradiction !== null && failClass === 'repo-health';
153
155
  const recOutcome = isUnobserved ? { recommend: 'autofix', rationale: failReason }
154
156
  : isFrozenBlocked ?
155
157
  {
@@ -34,7 +34,53 @@ export interface VerifyOutcome {
34
34
  * can record each as a durable debt if the user ACCEPTs anyway — the deletion
35
35
  * then ships in the next commit and the final gate must re-check it. */
36
36
  crossTaskDeletions?: CrossTaskDeletion[];
37
+ /**
38
+ * WHICH KIND of FAIL this is, as data. Only meaningful when ok === false.
39
+ *
40
+ * `unobserved` was already carried as a typed field and read as one. Its
41
+ * siblings were not: the repo-health class travelled only as the `repo health:`
42
+ * PREFIX of `reason`, and three independent production sites recovered it by
43
+ * re-typing that literal with two different matchers — the graduated lint-fix
44
+ * gate, the frozen-blocked contradiction test, and the ONE auto-closing debt
45
+ * class. A reword of the mint disabled all three, with no compile error and a
46
+ * green suite. This is the `observedFailures` finding one altitude down: the
47
+ * outcome CLASS never travelled with the failure TEXT, so every classifier
48
+ * downstream had to guess.
49
+ */
50
+ failClass?: VerifyFailClass;
37
51
  }
52
+ /**
53
+ * The kinds of verify FAIL. A new member is a compile error until it declares a
54
+ * display prefix below.
55
+ *
56
+ * `static-checks` is the RUN-level twin of `repo-health`: `final-gate.ts` mints
57
+ * `static checks: …` for the same concept at the other altitude, which is why
58
+ * `isStaticClassDebt` was structurally blind to every run-level static failure
59
+ * that reached the ledger.
60
+ */
61
+ export type VerifyFailClass = 'repo-health' | 'static-checks' | 'unobserved' | 'model-verdict' | 'harness-fault';
62
+ /**
63
+ * The prefix each class MINTS, stated once.
64
+ *
65
+ * These strings are byte-frozen: the debt ledger stores `reason` verbatim, so a
66
+ * reword would orphan every debt already on disk. The registry exists so minting
67
+ * and matching cannot drift apart, not to make the wording editable.
68
+ */
69
+ export declare const VERIFY_FAIL_PREFIX: Record<VerifyFailClass, string>;
70
+ /**
71
+ * The class of a FAIL — from the typed field when it is there, else from the
72
+ * prefix the registry above owns.
73
+ *
74
+ * The prefix test survives in exactly ONE place instead of three. It has to
75
+ * survive somewhere: `GateDeps.verify` is a seam, a debt read back off disk is a
76
+ * bare string with no outcome attached, and the run-level gate mints its own
77
+ * `static checks:` line through a different path entirely.
78
+ */
79
+ export declare function verifyFailClass(o: Pick<VerifyOutcome, 'failClass' | 'reason'>): VerifyFailClass | undefined;
80
+ /** The class a recorded reason STRING belongs to, by its minted prefix. */
81
+ export declare function failClassOfReason(reason: string): VerifyFailClass | undefined;
82
+ /** Does this class name a deterministic whole-repo static check, at either altitude? */
83
+ export declare function isStaticClass(cls: VerifyFailClass | undefined): boolean;
38
84
  /**
39
85
  * Slice the delivered spec (GOAL / CONSTRAINTS / ACCEPTANCE / VERIFY) out of a
40
86
  * task file body. The composed spec lives under a `## spec` header and runs until
@@ -87,6 +87,47 @@ import { crossTaskDeletionVerifyFindings } from './task-provenance.js';
87
87
  * enforce pass had to drop `write` to stop.
88
88
  */
89
89
  const VERIFY_TOOLS = 'read,bash';
90
+ /**
91
+ * The prefix each class MINTS, stated once.
92
+ *
93
+ * These strings are byte-frozen: the debt ledger stores `reason` verbatim, so a
94
+ * reword would orphan every debt already on disk. The registry exists so minting
95
+ * and matching cannot drift apart, not to make the wording editable.
96
+ */
97
+ export const VERIFY_FAIL_PREFIX = {
98
+ 'repo-health': 'repo health:',
99
+ 'static-checks': 'static checks:',
100
+ unobserved: 'work unobserved:',
101
+ 'model-verdict': 'work did not verify:',
102
+ 'harness-fault': 'verification pass could not run:'
103
+ };
104
+ /**
105
+ * The class of a FAIL — from the typed field when it is there, else from the
106
+ * prefix the registry above owns.
107
+ *
108
+ * The prefix test survives in exactly ONE place instead of three. It has to
109
+ * survive somewhere: `GateDeps.verify` is a seam, a debt read back off disk is a
110
+ * bare string with no outcome attached, and the run-level gate mints its own
111
+ * `static checks:` line through a different path entirely.
112
+ */
113
+ export function verifyFailClass(o) {
114
+ if (o.failClass)
115
+ return o.failClass;
116
+ return failClassOfReason(o.reason ?? '');
117
+ }
118
+ /** The class a recorded reason STRING belongs to, by its minted prefix. */
119
+ export function failClassOfReason(reason) {
120
+ const head = reason.trimStart().toLowerCase();
121
+ for (const [cls, prefix] of Object.entries(VERIFY_FAIL_PREFIX)) {
122
+ if (head.startsWith(prefix.toLowerCase()))
123
+ return cls;
124
+ }
125
+ return undefined;
126
+ }
127
+ /** Does this class name a deterministic whole-repo static check, at either altitude? */
128
+ export function isStaticClass(cls) {
129
+ return cls === 'repo-health' || cls === 'static-checks';
130
+ }
90
131
  /**
91
132
  * Slice the delivered spec (GOAL / CONSTRAINTS / ACCEPTANCE / VERIFY) out of a
92
133
  * task file body. The composed spec lives under a `## spec` header and runs until
@@ -762,8 +803,9 @@ export async function runWorkVerification(deps) {
762
803
  if (deps.repoHealth) {
763
804
  stage('repo health');
764
805
  const h = await deps.repoHealth();
765
- if (!h.ok)
766
- return { ok: false, reason: `repo health: ${h.reason}` };
806
+ if (!h.ok) {
807
+ return { ok: false, failClass: 'repo-health', reason: `repo health: ${h.reason}` };
808
+ }
767
809
  }
768
810
  if (!deps.spec || deps.spec.trim().length === 0) {
769
811
  return { ok: true, reason: 'no spec to verify' };
@@ -818,7 +860,11 @@ export async function runWorkVerification(deps) {
818
860
  if (err instanceof Error && err.message === USER_CANCELLED)
819
861
  throw err;
820
862
  const msg = err instanceof Error ? err.message : String(err);
821
- return { ok: false, reason: `verification pass could not run: ${msg}` };
863
+ return {
864
+ ok: false,
865
+ failClass: 'harness-fault',
866
+ reason: `${VERIFY_FAIL_PREFIX['harness-fault']} ${msg}`
867
+ };
822
868
  }
823
869
  // Capture the environment facts the child shared — regardless of verdict
824
870
  // (a FAIL run's discoveries are just as reusable).
@@ -860,12 +906,14 @@ export async function runWorkVerification(deps) {
860
906
  return {
861
907
  ok: false,
862
908
  unobserved: true,
909
+ failClass: 'unobserved',
863
910
  reason: `work unobserved: ${verdict.detail}`,
864
911
  ...deletions
865
912
  };
866
913
  }
867
914
  return {
868
915
  ok: false,
916
+ failClass: 'model-verdict',
869
917
  reason: `work did not verify: ${verdict.detail}${verdict.detail === 'no verdict emitted' ? ' (after verify retry)' : ''}`,
870
918
  ...deletions
871
919
  };