@mjasnikovs/pi-task 0.38.16 → 0.38.17
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/dist/config/config.d.ts +26 -0
- package/dist/config/config.js +68 -17
- package/dist/task/accept-debt.js +2 -1
- package/dist/task/artifact-closure.js +18 -63
- package/dist/task/auto-orchestrator.js +205 -214
- package/dist/task/boot-probe.d.ts +46 -0
- package/dist/task/boot-probe.js +41 -21
- package/dist/task/coverage-loop.d.ts +11 -0
- package/dist/task/coverage-loop.js +16 -0
- package/dist/task/final-gate-fix.js +14 -24
- package/dist/task/final-gate.js +6 -1
- package/dist/task/fix-child.d.ts +64 -0
- package/dist/task/fix-child.js +66 -0
- package/dist/task/lint-fix.d.ts +7 -0
- package/dist/task/lint-fix.js +45 -9
- package/dist/task/orchestrator.js +9 -2
- package/dist/task/phases.d.ts +66 -4
- package/dist/task/phases.js +94 -34
- package/dist/task/plan-rounds.d.ts +86 -0
- package/dist/task/plan-rounds.js +105 -0
- package/dist/task/plan-session.d.ts +31 -21
- package/dist/task/plan-session.js +97 -120
- package/dist/task/qa-transcript.d.ts +100 -0
- package/dist/task/qa-transcript.js +99 -0
- package/dist/task/question-source.d.ts +117 -0
- package/dist/task/question-source.js +174 -0
- package/dist/task/serve-entry.js +6 -57
- package/dist/task/shipped-source.d.ts +67 -0
- package/dist/task/shipped-source.js +144 -0
- package/dist/task/task-gates.d.ts +1 -1
- package/dist/task/task-gates.js +4 -2
- package/dist/task/verify-work.d.ts +46 -0
- package/dist/task/verify-work.js +51 -3
- package/dist/workers/docs-core.d.ts +71 -1
- package/dist/workers/docs-core.js +131 -71
- package/dist/workers/pi-worker-core.js +23 -8
- 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
|
+
}
|
package/dist/task/serve-entry.js
CHANGED
|
@@ -38,8 +38,9 @@
|
|
|
38
38
|
*
|
|
39
39
|
* Ground truth is the file tree only. No model, no network.
|
|
40
40
|
*/
|
|
41
|
-
import {
|
|
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
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
const
|
|
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
|
|
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';
|
package/dist/task/task-gates.js
CHANGED
|
@@ -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
|
-
|
|
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 &&
|
|
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
|
package/dist/task/verify-work.js
CHANGED
|
@@ -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 {
|
|
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
|
};
|