@mjasnikovs/pi-task 0.33.0 → 0.35.0

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.
@@ -0,0 +1,63 @@
1
+ /** Variables the PLATFORM supplies, not the project. Deliberately short: every
2
+ * entry is a name no `.env.example` is expected to carry. */
3
+ export declare const AMBIENT_ENV: Set<string>;
4
+ /** The same rule by MECHANISM rather than by name: a variable INJECTED by a
5
+ * platform (CI runner, package manager, freedesktop base dirs). STEP 0 found
6
+ * `GITHUB_ENV` (written by GitHub Actions), `npm_execpath` and `XDG_CACHE_HOME`
7
+ * classified as required in real trees; a template that declared them would be
8
+ * wrong, so the exclusion is the prefix, not a growing list of names. */
9
+ export declare const AMBIENT_PREFIXES: string[];
10
+ /** Rule 4: is this variable the platform's to supply? */
11
+ export declare function isAmbient(name: string): boolean;
12
+ /** Basenames that count as the project's env TEMPLATE. */
13
+ export declare const ENV_TEMPLATE_NAMES: string[];
14
+ /** Why a read does not require a declaration. `null` ⇒ it does. */
15
+ export type StepAside = 'default' | 'compared' | 'assigned' | 'ambient' | 'optional-api' | 'probe';
16
+ export interface EnvRead {
17
+ /** Variable name, always a literal. */
18
+ name: string;
19
+ /** Repo-relative file the read lives in. */
20
+ file: string;
21
+ /** 1-indexed line of the read. */
22
+ line: number;
23
+ /** The matched construct (`process.env`, `os.getenv`, …). */
24
+ construct: string;
25
+ /** null ⇒ REQUIRED; otherwise the mechanical reason it stepped aside. */
26
+ stepAside: StepAside | null;
27
+ }
28
+ export interface EnvClosure {
29
+ /** Tracked template files found (repo-relative). Empty ⇒ check is inert. */
30
+ templates: string[];
31
+ /** Every variable any template declares. */
32
+ declared: Set<string>;
33
+ /** Every literal env read seen in tracked source, step-asides included. */
34
+ reads: EnvRead[];
35
+ /** Required reads whose variable no template declares — the findings. */
36
+ missing: EnvRead[];
37
+ }
38
+ /** An inert result: what every tree with no tracked template returns. */
39
+ export declare function inertClosure(): EnvClosure;
40
+ /** Tracked files, repo-root-relative. null when `cwd` is not a git work tree. */
41
+ export declare function trackedFiles(cwd: string): string[] | null;
42
+ /** Variables a template file declares. A bare `X=` line declares `X`. */
43
+ export declare function parseTemplate(text: string): string[];
44
+ /** Every literal env read in one file's text. */
45
+ export declare function scanSource(file: string, text: string): EnvRead[];
46
+ /**
47
+ * Scan a tree: tracked templates × required reads in tracked source.
48
+ *
49
+ * Inert (empty templates, no findings) when the tree is not a git work tree or
50
+ * carries no tracked env template — the ENOENT=pass contract.
51
+ */
52
+ export declare function scanEnvTemplateClosure(cwd: string): EnvClosure;
53
+ /**
54
+ * The gate's view: one finding per VARIABLE (first read site, source order), not
55
+ * one per read — a var read in four files is one artifact defect, and the ranked
56
+ * failure list is read by a human and seeds the autofix prompt.
57
+ */
58
+ export declare function findMissingEnvDeclarations(cwd: string): {
59
+ templates: string[];
60
+ missing: EnvRead[];
61
+ };
62
+ /** One finding, as the final gate would phrase it. */
63
+ export declare function envGateFailureText(t: EnvRead, templates: string[]): string;
@@ -0,0 +1,397 @@
1
+ /**
2
+ * env-template-closure — a shipped source file requires an env var the shipped
3
+ * template never mentions (nexttask 10).
4
+ *
5
+ * The failure this closes (mx5 run 19, `dfbdd6f`, validated): `src/server/seed.ts`
6
+ * reads `process.env.ADMIN_PHONE` / `ADMIN_PASSWORD` with no default, the tracked
7
+ * `.env.example` declares only `DATABASE_URL` + `APP_URL`, and `bun run seed` was
8
+ * one of the run's final-gate commands. It exited 1; the gate's autofix wrote the
9
+ * two vars into `.env`, which is GITIGNORED (nexttask 4) — so the committed tree
10
+ * still cannot seed and nothing at run end ever said why. Coverage credits the
11
+ * CONSUMING side (seed.ts exists, is owned, passes its own VERIFY); the template is
12
+ * a separate artifact nobody re-reads after TASK_0001.
13
+ *
14
+ * THE RULE. Every REQUIRED env read in TRACKED source must appear in the TRACKED
15
+ * env template. One-directional: a variable DECLARED but never read is silence, not
16
+ * a finding (dace-pro ships 13 declared / 7 required and must stay clean).
17
+ *
18
+ * REQUIRED is mechanical. A read steps aside when it:
19
+ * 1. carries a default — `?? …`, `|| …`, `os.getenv('X', d)`, `environ.get('X', d)`;
20
+ * 2. is compared, not consumed — `===`, `!==`, `==`, `!=` on either side;
21
+ * 3. is an assignment TARGET — `process.env.DATABASE_URL = testDbUrl`, which is
22
+ * what every one of mx5's seven test files does and is the single largest FP
23
+ * source in the corpus;
24
+ * 4. names an AMBIENT variable — a short fixed allowlist (below) of things the
25
+ * platform, not the project, supplies.
26
+ *
27
+ * FP discipline (the artifact-closure discipline, one layer up):
28
+ * • literal names only. `process.env[key]`, `const {A} = process.env` and every
29
+ * other dynamic form is OPAQUE and steps aside — a false negative is free, a
30
+ * false rank-0 gate failure is not.
31
+ * • TRACKED files only, via `git ls-files`, so a gitignored `.env` or an
32
+ * untracked scratch file can neither declare nor require anything.
33
+ * • generated/vendored trees (`dist/`, `build/`, `node_modules/`, …) are not
34
+ * authored source and are skipped; they only ever mirror a read we already saw.
35
+ * • NO TEMPLATE IN THE TREE ⇒ THE CHECK IS INERT (as `repo-health-check` does
36
+ * with a missing manifest). This must never invent a file the project chose
37
+ * not to have.
38
+ *
39
+ * Not npm-shaped (`memory/gate-blind-on-non-npm-projects.md`): JS/TS
40
+ * (`process.env` / `Bun.env`), Python (`os.environ[…]`, `os.environ.get`,
41
+ * `os.getenv`) and Go (`os.Getenv`) read the same way. Go's `os.LookupEnv` is the
42
+ * explicit may-be-absent API and is treated as its own step-aside.
43
+ */
44
+ import { spawnSync } from 'node:child_process';
45
+ import { readFileSync } from 'node:fs';
46
+ import * as path from 'node:path';
47
+ /** Variables the PLATFORM supplies, not the project. Deliberately short: every
48
+ * entry is a name no `.env.example` is expected to carry. */
49
+ export const AMBIENT_ENV = new Set([
50
+ 'NODE_ENV',
51
+ 'CI',
52
+ 'PATH',
53
+ 'HOME',
54
+ 'TZ',
55
+ 'PORT',
56
+ 'USER',
57
+ 'SHELL',
58
+ 'TMPDIR',
59
+ 'LANG',
60
+ // Windows/OS equivalents of the above, and the package manager's own vars —
61
+ // every one of these was OBSERVED as a "required" read during STEP 0's
62
+ // hand-read (gofer-rag `npm_execpath`, the pi-task corpus's `LOCALAPPDATA` /
63
+ // `XDG_CACHE_HOME`), and none of them belongs in a project's template.
64
+ 'APPDATA',
65
+ 'LOCALAPPDATA',
66
+ 'USERPROFILE',
67
+ 'TEMP',
68
+ 'TMP',
69
+ 'PWD',
70
+ 'HOSTNAME',
71
+ 'LOGNAME',
72
+ 'TERM',
73
+ 'COMSPEC'
74
+ ]);
75
+ /** The same rule by MECHANISM rather than by name: a variable INJECTED by a
76
+ * platform (CI runner, package manager, freedesktop base dirs). STEP 0 found
77
+ * `GITHUB_ENV` (written by GitHub Actions), `npm_execpath` and `XDG_CACHE_HOME`
78
+ * classified as required in real trees; a template that declared them would be
79
+ * wrong, so the exclusion is the prefix, not a growing list of names. */
80
+ export const AMBIENT_PREFIXES = ['npm_', 'GITHUB_', 'RUNNER_', 'CI_', 'XDG_', 'BUN_', 'NODE_'];
81
+ /** Rule 4: is this variable the platform's to supply? */
82
+ export function isAmbient(name) {
83
+ return AMBIENT_ENV.has(name) || AMBIENT_PREFIXES.some(p => name.startsWith(p));
84
+ }
85
+ /** Basenames that count as the project's env TEMPLATE. */
86
+ export const ENV_TEMPLATE_NAMES = ['.env.example', '.env.sample', '.env.template', '.env.dist'];
87
+ /** Source extensions we can read env accesses out of. */
88
+ const SOURCE_EXT_RE = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go)$/i;
89
+ /** Generated / vendored trees — not authored source. */
90
+ const GENERATED_RE = /(?:^|\/)(?:node_modules|dist|build|out|vendor|coverage|\.next|\.output)\//;
91
+ /** An inert result: what every tree with no tracked template returns. */
92
+ export function inertClosure() {
93
+ return { templates: [], declared: new Set(), reads: [], missing: [] };
94
+ }
95
+ function git(cwd, args) {
96
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
97
+ if (r.error || r.status !== 0 || typeof r.stdout !== 'string')
98
+ return null;
99
+ return r.stdout;
100
+ }
101
+ /** Tracked files, repo-root-relative. null when `cwd` is not a git work tree. */
102
+ export function trackedFiles(cwd) {
103
+ const out = git(cwd, ['ls-files', '-z']);
104
+ if (out === null)
105
+ return null;
106
+ return out.split('\0').filter(p => p.length > 0);
107
+ }
108
+ /** Variables a template file declares. A bare `X=` line declares `X`. */
109
+ export function parseTemplate(text) {
110
+ const names = [];
111
+ for (const raw of text.split(/\r?\n/)) {
112
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(raw);
113
+ if (m)
114
+ names.push(m[1]);
115
+ }
116
+ return names;
117
+ }
118
+ /** Strip line comments so a read quoted in prose is not a read. Conservative:
119
+ * only whole-line `//`, `#`, `*` and `/*` openers — never mid-line, because a
120
+ * `//` inside a string literal (a URL) would eat real code. */
121
+ function isCommentLine(line) {
122
+ return /^\s*(?:\/\/|#|\*|\/\*)/.test(line);
123
+ }
124
+ /** A trailing `, default` inside the call — `os.getenv('X', 'y')`. */
125
+ const callHasDefault = (after) => /^\s*,/.test(after);
126
+ const MATCHERS = [
127
+ // JS/TS — dotted and bracketed.
128
+ { re: /\b(?:process|Bun)\.env\s*\.\s*([A-Za-z_][A-Za-z0-9_]*)/g, construct: 'process.env' },
129
+ {
130
+ re: /\b(?:process|Bun)\.env\s*\[\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]\s*\]/g,
131
+ construct: 'process.env[]'
132
+ },
133
+ // Python.
134
+ {
135
+ re: /\bos\.environ\s*\[\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]\s*\]/g,
136
+ construct: 'os.environ[]'
137
+ },
138
+ {
139
+ re: /\bos\.environ\.get\s*\(\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]/g,
140
+ construct: 'os.environ.get',
141
+ call: true,
142
+ classify: (_m, after) => (callHasDefault(after) ? 'default' : null)
143
+ },
144
+ {
145
+ re: /\bos\.getenv\s*\(\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]/g,
146
+ construct: 'os.getenv',
147
+ call: true,
148
+ classify: (_m, after) => (callHasDefault(after) ? 'default' : null)
149
+ },
150
+ // Go.
151
+ { re: /\bos\.Getenv\s*\(\s*"([A-Za-z_][A-Za-z0-9_]*)"/g, construct: 'os.Getenv', call: true },
152
+ {
153
+ re: /\bos\.LookupEnv\s*\(\s*"([A-Za-z_][A-Za-z0-9_]*)"/g,
154
+ construct: 'os.LookupEnv',
155
+ call: true,
156
+ classify: () => 'optional-api'
157
+ }
158
+ ];
159
+ /** Does the text right after the read supply a default, or continue onto a line
160
+ * that opens with one? (`process.env.X\n ?? 'y'` is one expression.)
161
+ *
162
+ * A ternary CONDITION counts: `process.env.npm_execpath ? a : b` names both
163
+ * outcomes, so nothing is required (gofer-rag, hand-read in STEP 0). `?.` is
164
+ * optional chaining, not a ternary, and must not match. */
165
+ function hasDefault(before, after, nextLine) {
166
+ // The read IS the fallback: `X ?? process.env.BRAVE_API_KEY` (brave-warning.ts)
167
+ // — an alternative to another source, never independently required.
168
+ if (/(?:\?\?|\|\|)\s*$/.test(before))
169
+ return true;
170
+ if (/^\s*(?:\?\?|\|\||\?(?![.?]))/.test(after))
171
+ return true;
172
+ if (after.trim().length === 0 && nextLine !== undefined && /^\s*(?:\?\?|\|\|)/.test(nextLine))
173
+ return true;
174
+ return false;
175
+ }
176
+ /**
177
+ * Rule 5 — a PRESENCE PROBE: the read asks whether the variable is set and the
178
+ * code carries on either way. Demanded by the STEP-0 hand-read, which found three
179
+ * shapes of it in real trees and NONE of them belongs in a template:
180
+ *
181
+ * `if (process.env.PI_BIN) {…}` pi-invocation.ts — a test override
182
+ * `const c = process.env.CHROME_BIN` render-check.ts — falls back to the
183
+ * `if (c) …` Playwright cache when unset
184
+ *
185
+ * The discriminator is the SIGN of the guard, and it is exactly what separates
186
+ * those from the lead: mx5's seed.ts writes `const phone = process.env.ADMIN_PHONE`
187
+ * … `if (!phone) throw`. A NEGATED guard means the variable is required and the
188
+ * program stops without it; a POSITIVE guard means it is an override. So a bare
189
+ * `if (` / `while (` head steps aside, `if (!` does not, and an assign-then-guard
190
+ * is resolved by looking ahead for the FIRST guard on that variable.
191
+ */
192
+ const GUARD_HEAD_RE = /\b(?:if|while)\s*\(\s*$/;
193
+ /** How far an assign-then-guard may reach. mx5's is 4 lines; 10 is slack. */
194
+ const GUARD_LOOKAHEAD = 10;
195
+ function isProbeHead(before) {
196
+ return GUARD_HEAD_RE.test(before);
197
+ }
198
+ /** `const v = process.env.X` → the variable's name, else null. */
199
+ function assignedTo(before) {
200
+ const m = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*$/.exec(before);
201
+ return m ? m[1] : null;
202
+ }
203
+ /**
204
+ * What the guard's own body does. A NEGATED guard only means "required" when the
205
+ * program STOPS without the variable — `if (!phone) throw` (mx5, the lead). When
206
+ * it merely returns or falls through — `if (!process.env.PI_REMOTE_PUSH_DEBUG)
207
+ * return` (push.ts, a debug flag) — the variable is optional and the negation
208
+ * proves nothing. Same syntax, opposite meaning; the body is the discriminator.
209
+ */
210
+ const HARD_STOP_RE = /\bthrow\b|process\.exit\s*\(|\bos\.Exit\s*\(|\bsys\.exit\s*\(|\bpanic\s*\(|\braise\b/;
211
+ const HARD_STOP_LOOKAHEAD = 3;
212
+ function bodyStops(lines, at) {
213
+ for (let i = at; i < Math.min(lines.length, at + HARD_STOP_LOOKAHEAD); i++) {
214
+ if (HARD_STOP_RE.test(lines[i]))
215
+ return true;
216
+ }
217
+ return false;
218
+ }
219
+ /** First `if (v)` / `if (!v)` on `name` within the window. `required` only for a
220
+ * negated guard that stops; anything else is an override probe. */
221
+ function guardVerdict(lines, from, name) {
222
+ const re = new RegExp(`\\bif\\s*\\(\\s*(!?)\\s*${name}\\b`);
223
+ for (let i = from; i < Math.min(lines.length, from + GUARD_LOOKAHEAD); i++) {
224
+ const m = re.exec(lines[i]);
225
+ if (m)
226
+ return m[1] === '!' && bodyStops(lines, i) ? 'required' : 'probe';
227
+ }
228
+ return null;
229
+ }
230
+ /** Rule 6 — a file that WRITES the variable is not a consumer of the project's
231
+ * env contract for it. Covers the test save/restore idiom (`prev =
232
+ * process.env.X` in `beforeEach`, `process.env.X = prev` in `afterEach`), which
233
+ * reads the variable only to put it back. File-scoped extension of rule 3. */
234
+ function locallySupplied(text, name) {
235
+ const n = name.replace(/[$]/g, '\\$');
236
+ return (new RegExp(`(?:process|Bun)\\.env\\s*\\.\\s*${n}\\s*(?:\\+|\\?\\?|\\|\\|)?=(?![=>])`).test(text)
237
+ || new RegExp(`(?:process|Bun)\\.env\\s*\\[\\s*['"]${n}['"]\\s*\\]\\s*=(?![=>])`).test(text)
238
+ || new RegExp(`delete\\s+(?:process|Bun)\\.env\\s*\\.\\s*${n}\\b`).test(text)
239
+ || new RegExp(`\\bos\\.environ\\s*\\[\\s*['"]${n}['"]\\s*\\]\\s*=(?![=])`).test(text)
240
+ || new RegExp(`\\bos\\.[Ss]etenv\\s*\\(\\s*['"]${n}['"]`).test(text));
241
+ }
242
+ /** Rule 5b — a file that PRESENCE-CHECKS the variable positively anywhere treats
243
+ * it as an override, so every read of it in that file is a probe. Without this,
244
+ * the CONSUMING read inside the guarded branch — `if (process.env.PI_BIN) return
245
+ * {command: process.env.PI_BIN}` (pi-invocation.ts), `process.env.GOFER_PYTHON ?
246
+ * [process.env.GOFER_PYTHON] : [...]` (gofer) — reads as required. */
247
+ function locallyProbed(text, name) {
248
+ const e = `(?:process|Bun)\\.env\\s*\\.\\s*${name}`;
249
+ return (new RegExp(`\\b(?:if|while)\\s*\\(\\s*${e}\\b`).test(text)
250
+ || new RegExp(`${e}\\s*\\?(?![.?])`).test(text)
251
+ || new RegExp(`\\bBoolean\\s*\\(\\s*${e}\\b`).test(text));
252
+ }
253
+ /** Comparison on either side — a probe of the environment, not a consumption. */
254
+ function isCompared(before, after) {
255
+ if (/^\s*(?:===|!==|==|!=)/.test(after))
256
+ return true;
257
+ if (/(?:===|!==|==|!=)\s*$/.test(before))
258
+ return true;
259
+ return false;
260
+ }
261
+ /** Rule 3: the read is a WRITE — `process.env.X = …` (but not `==`/`===`), or
262
+ * `delete process.env.X`. Both mean the file SUPPLIES the variable rather than
263
+ * consuming it; mx5's seven test files are the assignment case and gofer's
264
+ * `wdio.packaged.conf.ts` (`delete process.env.GOFER_GDFORMAT`) is the delete
265
+ * case — a scrub of a developer override, the opposite of a requirement. */
266
+ function isAssigned(before, after) {
267
+ if (/^\s*=(?![=>])/.test(after))
268
+ return true;
269
+ if (/\bdelete\s*$/.test(before))
270
+ return true;
271
+ return false;
272
+ }
273
+ /** Every literal env read in one file's text. */
274
+ export function scanSource(file, text) {
275
+ const lines = text.split(/\r?\n/);
276
+ const reads = [];
277
+ for (let i = 0; i < lines.length; i++) {
278
+ const line = lines[i];
279
+ if (isCommentLine(line))
280
+ continue;
281
+ for (const matcher of MATCHERS) {
282
+ matcher.re.lastIndex = 0;
283
+ let m;
284
+ while ((m = matcher.re.exec(line)) !== null) {
285
+ const name = m[1];
286
+ const before = line.slice(0, m.index);
287
+ const raw = line.slice(m.index + m[0].length);
288
+ let stepAside = matcher.classify?.(m, raw) ?? null;
289
+ // Past the call's own closing paren, so an operator that applies to
290
+ // the RESULT is seen (`os.Getenv("X") == ""`).
291
+ const after = matcher.call ? raw.replace(/^\s*\)/, '') : raw;
292
+ // …and past any WRAPPING call's brackets, so `Number(process.env.X)
293
+ // || 32` (gofer-rag, the largest FP class the STEP-0 hand-read
294
+ // found) reads as defaulted. Stepping aside when the `||` in fact
295
+ // defaults an enclosing expression is a false NEGATIVE, which this
296
+ // check spends freely; a false rank-0 gate failure it does not.
297
+ // …and past a trailing accessor chain, so `process.env.X?.trim() ||
298
+ // '/tmp/…'` (push.ts) is the defaulted read it plainly is.
299
+ const unwrapped = after
300
+ .replace(/^(?:\s*\??\.\s*[A-Za-z_$][\w$]*(?:\([^()]*\))?)*/, '')
301
+ .replace(/^[\s)\]]+/, '');
302
+ if (stepAside === null && isAmbient(name))
303
+ stepAside = 'ambient';
304
+ if (stepAside === null && isAssigned(before, after))
305
+ stepAside = 'assigned';
306
+ if (stepAside === null && isCompared(before, unwrapped))
307
+ stepAside = 'compared';
308
+ if (stepAside === null && hasDefault(before, unwrapped, lines[i + 1]))
309
+ stepAside = 'default';
310
+ if (stepAside === null && locallySupplied(text, name))
311
+ stepAside = 'assigned';
312
+ if (stepAside === null && (isProbeHead(before) || locallyProbed(text, name)))
313
+ stepAside = 'probe';
314
+ // `if (!process.env.X) …` — required only if the body stops.
315
+ if (stepAside === null
316
+ && /\b(?:if|while)\s*\(\s*!\s*$/.test(before)
317
+ && !bodyStops(lines, i))
318
+ stepAside = 'probe';
319
+ if (stepAside === null) {
320
+ const v = assignedTo(before);
321
+ if (v !== null && guardVerdict(lines, i + 1, v) === 'probe')
322
+ stepAside = 'probe';
323
+ }
324
+ reads.push({ name, file, line: i + 1, construct: matcher.construct, stepAside });
325
+ }
326
+ }
327
+ }
328
+ return reads;
329
+ }
330
+ /**
331
+ * Scan a tree: tracked templates × required reads in tracked source.
332
+ *
333
+ * Inert (empty templates, no findings) when the tree is not a git work tree or
334
+ * carries no tracked env template — the ENOENT=pass contract.
335
+ */
336
+ export function scanEnvTemplateClosure(cwd) {
337
+ const tracked = trackedFiles(cwd);
338
+ if (tracked === null)
339
+ return inertClosure();
340
+ const templates = tracked.filter(p => ENV_TEMPLATE_NAMES.includes(path.posix.basename(p)));
341
+ if (templates.length === 0)
342
+ return inertClosure();
343
+ const declared = new Set();
344
+ for (const t of templates) {
345
+ try {
346
+ for (const n of parseTemplate(readFileSync(path.join(cwd, t), 'utf8')))
347
+ declared.add(n);
348
+ }
349
+ catch {
350
+ // a tracked-but-absent template (dirty worktree) declares nothing
351
+ }
352
+ }
353
+ const reads = [];
354
+ for (const f of tracked) {
355
+ if (!SOURCE_EXT_RE.test(f) || GENERATED_RE.test(f))
356
+ continue;
357
+ let text;
358
+ try {
359
+ text = readFileSync(path.join(cwd, f), 'utf8');
360
+ }
361
+ catch {
362
+ continue;
363
+ }
364
+ // Cheap prefilter. Case-insensitive substring, NOT a word boundary:
365
+ // `os.Getenv(` / `os.LookupEnv(` carry no boundary before `env`, and a
366
+ // boundary-anchored filter silently drops every Go file (caught by the
367
+ // go fixture in STEP 0).
368
+ if (!/env/i.test(text))
369
+ continue;
370
+ reads.push(...scanSource(f, text));
371
+ }
372
+ const missing = reads.filter(r => r.stepAside === null && !declared.has(r.name));
373
+ return { templates, declared, reads, missing };
374
+ }
375
+ /**
376
+ * The gate's view: one finding per VARIABLE (first read site, source order), not
377
+ * one per read — a var read in four files is one artifact defect, and the ranked
378
+ * failure list is read by a human and seeds the autofix prompt.
379
+ */
380
+ export function findMissingEnvDeclarations(cwd) {
381
+ const c = scanEnvTemplateClosure(cwd);
382
+ const seen = new Set();
383
+ const missing = [];
384
+ for (const r of c.missing) {
385
+ if (seen.has(r.name))
386
+ continue;
387
+ seen.add(r.name);
388
+ missing.push(r);
389
+ }
390
+ return { templates: c.templates, missing };
391
+ }
392
+ /** One finding, as the final gate would phrase it. */
393
+ export function envGateFailureText(t, templates) {
394
+ return (`env closure: \`${t.name}\` is required by ${t.file}:${t.line} (${t.construct}) but no `
395
+ + `tracked env template declares it (${templates.join(', ')}) — a fresh clone cannot `
396
+ + 'supply it.');
397
+ }
@@ -58,6 +58,7 @@ import { collectProjectEnv, pinnedLocalPort, runDeepRenderCheck } from './deep-r
58
58
  import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js';
59
59
  import { taskThatIntroduced } from './task-provenance.js';
60
60
  import { findDanglingArtifacts, danglingGateFailureText } from './artifact-closure.js';
61
+ import { findMissingEnvDeclarations, envGateFailureText } from './env-template-closure.js';
61
62
  import { findMissingServeEntry, serveEntryGateFailureText } from './serve-entry.js';
62
63
  import { makefileRecipe } from './command-shrink.js';
63
64
  function packageScripts(cwd) {
@@ -1604,6 +1605,22 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1604
1605
  catch {
1605
1606
  // best-effort scan — a scanner fault must never break the gate
1606
1607
  }
1608
+ // Env-template closure (mx5 run 19, nexttask 10): a shipped source file
1609
+ // requires an env var the shipped template never mentions. `seed.ts` read
1610
+ // `process.env.ADMIN_PHONE`/`ADMIN_PASSWORD`, `.env.example` declared neither,
1611
+ // `bun run seed` exited 1, and the autofix "fixed" it by writing the GITIGNORED
1612
+ // `.env` — so the committed tree still cannot seed and nothing at run end said
1613
+ // why. Same shape and rank as the dangling-artifact scan one layer up: naming
1614
+ // the ARTIFACT that is wrong, statically, instead of only the command that
1615
+ // failed. Inert on any tree with no tracked template (ENOENT = pass).
1616
+ try {
1617
+ const env = findMissingEnvDeclarations(cwd);
1618
+ for (const m of env.missing)
1619
+ fail(envGateFailureText(m, env.templates), 0);
1620
+ }
1621
+ catch {
1622
+ // best-effort scan — a scanner fault must never break the gate
1623
+ }
1607
1624
  if (failures.length > 0) {
1608
1625
  // Stable sort: boot/render (rank 0) leads, everything else keeps execution
1609
1626
  // order. One failure keeps the exact single-failure wording; several become
@@ -158,6 +158,27 @@ export interface PhaseAutoAnswerDeps {
158
158
  }
159
159
  export declare function phaseAutoAnswer(deps: PhaseDeps, refined: string, research: string, question: string, autoDeps?: PhaseAutoAnswerDeps): Promise<AutoAnswer>;
160
160
  export declare function phaseGrill(deps: PhaseDeps, ctx: ExtensionCommandContext, widgetState: WidgetState, refined: string, research: string): Promise<string>;
161
+ /**
162
+ * A refutation is a DELETION. Where the run's own research explicitly says a
163
+ * dependency refine invented is not needed, drop that token from CONSTRAINTS —
164
+ * compose cannot forbid the design's own API "because the refined task
165
+ * explicitly requires `argon2`" if the refined task no longer requires it.
166
+ *
167
+ * Applied to the REFINED TASK ITSELF, not to compose's copy of it, and that is
168
+ * load-bearing: critique receives the refined task as GROUND TRUTH and is told
169
+ * its CONSTRAINTS "MUST be preserved in spirit — do not silently drop or weaken
170
+ * them", so a deletion visible only to compose is restored one phase later. Both
171
+ * spec-producing phases have to see the same text.
172
+ *
173
+ * Purely subtractive and never touches an owned line (task/refuted-constraint.ts).
174
+ * Idempotent, so a resumed run re-deriving `refined` from the task file lands in
175
+ * the same place. The task file's `## refined prompt` is deliberately left as
176
+ * refine wrote it; the drop is recorded on the `## gates` trail with both source
177
+ * lines quoted, so the decision stays auditable after the fact.
178
+ *
179
+ * STEP 0 `scripts/refuted-constraint-baserate.ts`; A/B-1 `…-ab.ts` (PASS).
180
+ */
181
+ export declare function dropRefutedConstraints(deps: PhaseDeps, refined: string, research: string): Promise<string>;
161
182
  export declare function phaseCompose(deps: PhaseDeps, refined: string, research: string, qa: string): Promise<string>;
162
183
  export declare function phaseCritique(deps: PhaseDeps, spec: string, refined: string, qa: string, planContext?: string, research?: string,
163
184
  /**
@@ -23,7 +23,8 @@ import { resolve } from 'node:path';
23
23
  import { formatServiceBlock, formatFreshnessSkippedBlock } from './service-blocks.js';
24
24
  import { gatherExternalContext } from './external-context.js';
25
25
  import { REFINE_PROMPT, RESEARCH_FILES_PROMPT, RESEARCH_APIS_PROMPT, RESEARCH_CONTEXT_PROMPT, RESEARCH_TOOLING_PROMPT, GRILL_GEN_PROMPT, GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT, COMPOSE_PROMPT, CRITIQUE_PROMPT, CRITIQUE_TRIAGE_PROMPT, VERIFY_TOOLING_PROMPT, MAX_GRILL_QUESTIONS, appendNoThink } from './prompts.js';
26
- import { readSection, removeTaskSection, setTaskSection, updateTaskFrontMatter } from './task-io.js';
26
+ import { appendGateRecord, readSection, removeTaskSection, setTaskSection, updateTaskFrontMatter } from './task-io.js';
27
+ import { applyRefutations } from './refuted-constraint.js';
27
28
  import { spawnSync } from 'node:child_process';
28
29
  import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js';
29
30
  import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
@@ -639,13 +640,21 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
639
640
  // the pattern nexxtasks exists to prevent. To re-run the experiment, restore the block
640
641
  // this comment replaces — see the git history of this file and the PROMPT 4 entry in
641
642
  // nexxtasks.txt RESULTS.
642
- // nexttask 5B fan-out bounds. Both read their env ONCE per research phase, so
643
- // every worker in a run sees the same policy and a harness cannot half-apply
644
- // an arm; both are null in the shipped configuration.
643
+ // nexttask 5B fan-out bounds. All four read their env ONCE per research
644
+ // phase, so every worker in a run sees the same policy and a harness cannot
645
+ // half-apply an arm. CAP, SCALE and carry-forward are null/false in the
646
+ // shipped configuration; the progress deadline shipped ON (nexttask 9).
645
647
  const fanoutBudget = projectDocsBudget();
646
648
  const fanoutTimeout = fanoutTimeoutPolicy();
647
649
  const carryForward = workerCarryForward();
648
650
  const progressCeilingMs = workerProgressCeilingMs();
651
+ // Which deadline policy was in force is a fact about how every number below
652
+ // was produced. Run 18's 120 discarded minutes were only recoverable because
653
+ // 5A started writing down what the workers actually did; a run whose logs do
654
+ // not say which policy it ran under cannot be compared with one that does.
655
+ deps.logDebug?.(progressCeilingMs === null ?
656
+ 'phase:research: worker deadline = fixed elapsed cap (progress deadline DISABLED)'
657
+ : `phase:research: worker deadline = no-progress, ceiling ${progressCeilingMs}ms`);
649
658
  let doneCount = 0;
650
659
  const updateProgress = () => {
651
660
  doneCount++;
@@ -842,9 +851,11 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
842
851
  // 5B SCALE arm — null unless both env vars are set. Only the
843
852
  // docs-capable worker can fan out, so only it can be scaled.
844
853
  ...(spec.fanoutBounded && fanoutTimeout ? { fanoutTimeout } : {}),
845
- // 5B RESCUE arm off unless its env vars are set. Applies to
846
- // EVERY research worker, not just the docs-capable one: any
847
- // worker that gets killed loses its work the same way.
854
+ // 5B RESCUE. Applies to EVERY research worker, not just the
855
+ // docs-capable one: any worker that gets killed loses its work
856
+ // the same way. carry-forward stays OFF unless asked for
857
+ // (measured harmful on its own); the progress deadline SHIPPED
858
+ // ON in nexttask 9 and is null only when explicitly disabled.
848
859
  ...(carryForward ? { carryForward: true } : {}),
849
860
  ...(progressCeilingMs !== null ?
850
861
  { progressTimeoutCeilingMs: progressCeilingMs }
@@ -1324,6 +1335,36 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
1324
1335
  return '(no questions produced)';
1325
1336
  return out.join('\n');
1326
1337
  }
1338
+ /**
1339
+ * A refutation is a DELETION. Where the run's own research explicitly says a
1340
+ * dependency refine invented is not needed, drop that token from CONSTRAINTS —
1341
+ * compose cannot forbid the design's own API "because the refined task
1342
+ * explicitly requires `argon2`" if the refined task no longer requires it.
1343
+ *
1344
+ * Applied to the REFINED TASK ITSELF, not to compose's copy of it, and that is
1345
+ * load-bearing: critique receives the refined task as GROUND TRUTH and is told
1346
+ * its CONSTRAINTS "MUST be preserved in spirit — do not silently drop or weaken
1347
+ * them", so a deletion visible only to compose is restored one phase later. Both
1348
+ * spec-producing phases have to see the same text.
1349
+ *
1350
+ * Purely subtractive and never touches an owned line (task/refuted-constraint.ts).
1351
+ * Idempotent, so a resumed run re-deriving `refined` from the task file lands in
1352
+ * the same place. The task file's `## refined prompt` is deliberately left as
1353
+ * refine wrote it; the drop is recorded on the `## gates` trail with both source
1354
+ * lines quoted, so the decision stays auditable after the fact.
1355
+ *
1356
+ * STEP 0 `scripts/refuted-constraint-baserate.ts`; A/B-1 `…-ab.ts` (PASS).
1357
+ */
1358
+ export async function dropRefutedConstraints(deps, refined, research) {
1359
+ const refuted = applyRefutations(refined, research);
1360
+ if (refuted.trail.length === 0)
1361
+ return refined;
1362
+ for (const line of refuted.trail) {
1363
+ deps.logDebug?.(`compose: ${line}`);
1364
+ await appendGateRecord(deps.cwd, deps.taskId, line).catch(() => { });
1365
+ }
1366
+ return refuted.refined;
1367
+ }
1327
1368
  export async function phaseCompose(deps, refined, research, qa) {
1328
1369
  // CLAIM before the belt is built: an obligation an earlier task had to
1329
1370
  // detach (its own spec froze the only file that could satisfy it) becomes
@@ -1627,7 +1668,10 @@ export const PHASES = [
1627
1668
  name: 'compose',
1628
1669
  section: 'spec',
1629
1670
  field: 'spec',
1630
- run: (d, p) => phaseCompose(d, p.refined, p.research, p.qa)
1671
+ run: async (d, p) => {
1672
+ p.refined = await dropRefutedConstraints(d, p.refined, p.research);
1673
+ return await phaseCompose(d, p.refined, p.research, p.qa);
1674
+ }
1631
1675
  },
1632
1676
  {
1633
1677
  name: 'critique',
@@ -0,0 +1,82 @@
1
+ /**
2
+ * A refutation is a DELETION, not an addition.
3
+ *
4
+ * THE LEAD (mx5 run 19, `~/hub/mx5 @ dfbdd6f`, TASK_0001). Refine invented a
5
+ * dependency the design explicitly rules out; the run's own research said so, in
6
+ * writing, in the same file; the composed spec then turned the invention into a
7
+ * capitalised prohibition against the design's own API:
8
+ *
9
+ * :21 refine CONSTRAINTS "Add only new entries the task requires (e.g.,
10
+ * `hono`, `bun-sql`-equivalent, …, `argon2`, …)"
11
+ * :92 research CONTEXT "Password hashing uses `Bun.password` (built-in
12
+ * argon2id) — no external `argon2` or
13
+ * `@node-rs/argon2` dependency needed despite the
14
+ * task's mention of it."
15
+ * :132 spec CONSTRAINTS "Do NOT use built-in `Bun.password` for hashing —
16
+ * the refined task explicitly requires `argon2`."
17
+ *
18
+ * `argon2@^0.41.0` shipped as a runtime dependency of a repo that never imports
19
+ * it, locked in by a VERIFY assertion, next to a spec telling the next
20
+ * implementer not to use `Bun.sql` — the API the whole data layer is built on.
21
+ *
22
+ * This is `memory/phantom-correction-additive-not-subtractive.md` at spec scale:
23
+ * the correction was APPENDED to research CONTEXT while the wrong text stayed in
24
+ * refine's CONSTRAINTS, and CONSTRAINTS is what the implementer is told is
25
+ * authoritative.
26
+ *
27
+ * THE LEVER. Deterministic detection, then a scoped removal, BEFORE compose sees
28
+ * the refined task. Never a model rewrite: every model-rewrite lever at this seam
29
+ * has resolved contradictions by deleting the AUTHORITATIVE side
30
+ * (`memory/owned-freeze-critique-lever-refuted.md`, 11/20). This pass can only
31
+ * delete a refine-invented token, and can never touch an owned line.
32
+ *
33
+ * The match is lexical, never semantic: a research CONTEXT bullet refutes a
34
+ * refine constraint when it carries one of a closed set of negation-of-need
35
+ * shapes AROUND a backticked token, and that same token appears backticked in a
36
+ * refine CONSTRAINTS line. Both sides are already separate strings in the task
37
+ * file.
38
+ *
39
+ * STEP 0 (`scripts/refuted-constraint-baserate.ts`) measured the closed set over
40
+ * every recorded task file in the corpus before any of it was wired.
41
+ */
42
+ export type Refutation = {
43
+ /** The token as it appears inside the backticks, e.g. `argon2`. */
44
+ token: string;
45
+ /** Index into the refined prompt's lines. */
46
+ line: number;
47
+ /** The refine CONSTRAINTS line, verbatim, before the drop. */
48
+ constraint: string;
49
+ /** The research CONTEXT bullet that refutes it, verbatim. */
50
+ research: string;
51
+ /** Which member of the closed negation set fired. */
52
+ pattern: string;
53
+ };
54
+ /** Section body between a bare ALL-CAPS header and the next one (or EOF). */
55
+ export declare function extractCapsSection(text: string, heading: string): string | null;
56
+ /**
57
+ * Find every (refine constraint line, research refutation bullet, shared token)
58
+ * triple. `refined` is the refined prompt, `research` the research output; both
59
+ * are the exact strings compose is handed.
60
+ */
61
+ export declare function detectRefutations(refined: string, research: string): Refutation[];
62
+ /**
63
+ * Delete one token from a constraint line: the backticked span, any word-suffix
64
+ * glued to it (`` `bun-sql` ``-equivalent), and ONE adjacent list separator —
65
+ * the preceding one where there is one, so the list keeps its shape.
66
+ *
67
+ * Returns null when nothing but boilerplate would be left, which the caller
68
+ * turns into a whole-line drop. Never rephrases, never adds a character.
69
+ */
70
+ export declare function dropToken(line: string, token: string): string | null;
71
+ export type RefutationResult = {
72
+ /** The refined prompt with every refuted token deleted. */
73
+ refined: string;
74
+ /** One trail line per drop, both source lines quoted. */
75
+ trail: string[];
76
+ refutations: Refutation[];
77
+ };
78
+ /**
79
+ * Apply every detected refutation to the refined prompt. Purely subtractive: the
80
+ * result is always a character subsequence of the input (`inv-no-line-invention`).
81
+ */
82
+ export declare function applyRefutations(refined: string, research: string): RefutationResult;
@@ -0,0 +1,281 @@
1
+ /**
2
+ * A refutation is a DELETION, not an addition.
3
+ *
4
+ * THE LEAD (mx5 run 19, `~/hub/mx5 @ dfbdd6f`, TASK_0001). Refine invented a
5
+ * dependency the design explicitly rules out; the run's own research said so, in
6
+ * writing, in the same file; the composed spec then turned the invention into a
7
+ * capitalised prohibition against the design's own API:
8
+ *
9
+ * :21 refine CONSTRAINTS "Add only new entries the task requires (e.g.,
10
+ * `hono`, `bun-sql`-equivalent, …, `argon2`, …)"
11
+ * :92 research CONTEXT "Password hashing uses `Bun.password` (built-in
12
+ * argon2id) — no external `argon2` or
13
+ * `@node-rs/argon2` dependency needed despite the
14
+ * task's mention of it."
15
+ * :132 spec CONSTRAINTS "Do NOT use built-in `Bun.password` for hashing —
16
+ * the refined task explicitly requires `argon2`."
17
+ *
18
+ * `argon2@^0.41.0` shipped as a runtime dependency of a repo that never imports
19
+ * it, locked in by a VERIFY assertion, next to a spec telling the next
20
+ * implementer not to use `Bun.sql` — the API the whole data layer is built on.
21
+ *
22
+ * This is `memory/phantom-correction-additive-not-subtractive.md` at spec scale:
23
+ * the correction was APPENDED to research CONTEXT while the wrong text stayed in
24
+ * refine's CONSTRAINTS, and CONSTRAINTS is what the implementer is told is
25
+ * authoritative.
26
+ *
27
+ * THE LEVER. Deterministic detection, then a scoped removal, BEFORE compose sees
28
+ * the refined task. Never a model rewrite: every model-rewrite lever at this seam
29
+ * has resolved contradictions by deleting the AUTHORITATIVE side
30
+ * (`memory/owned-freeze-critique-lever-refuted.md`, 11/20). This pass can only
31
+ * delete a refine-invented token, and can never touch an owned line.
32
+ *
33
+ * The match is lexical, never semantic: a research CONTEXT bullet refutes a
34
+ * refine constraint when it carries one of a closed set of negation-of-need
35
+ * shapes AROUND a backticked token, and that same token appears backticked in a
36
+ * refine CONSTRAINTS line. Both sides are already separate strings in the task
37
+ * file.
38
+ *
39
+ * STEP 0 (`scripts/refuted-constraint-baserate.ts`) measured the closed set over
40
+ * every recorded task file in the corpus before any of it was wired.
41
+ */
42
+ /** Bare ALL-CAPS section header, the boundary convention used across the
43
+ * refined prompt and every research section. */
44
+ const HEADER = /^[A-Z][A-Z -]*$/;
45
+ /** A backticked run with no whitespace inside — the only thing this pass will
46
+ * ever treat as a token. */
47
+ const TOKEN = '`[^`\\s]+`';
48
+ /** One token, or a list of them joined by `,` / `or` / `and`. */
49
+ const TOKEN_LIST = `${TOKEN}(?:(?:\\s*,\\s*|\\s+or\\s+|\\s+and\\s+)${TOKEN})*`;
50
+ /**
51
+ * The closed negation set. Each pattern anchors the token list INSIDE the
52
+ * negation, so a bullet that names one package negatively and another positively
53
+ * ("no `@node-rs/argon2` dependency — we use `argon2`") can only ever refute the
54
+ * one inside the phrase.
55
+ *
56
+ * Kept deliberately small. `inv-precision` is a FAIL of the whole task on one
57
+ * false drop, so a shape earns its place here only by surviving a hand-read of
58
+ * every corpus hit it produces.
59
+ */
60
+ const NEGATIONS = [
61
+ {
62
+ // "no external `argon2` or `@node-rs/argon2` dependency needed" — THE LEAD
63
+ name: 'no-external-dep-needed',
64
+ re: new RegExp(`\\bno\\s+external\\s+(${TOKEN_LIST})\\s+(?:\\w+\\s+){0,2}dependenc(?:y|ies)\\b[^.;]{0,40}?\\b(?:needed|required)\\b`, 'i')
65
+ },
66
+ {
67
+ // "no `bun-sql` npm package exists"
68
+ name: 'no-package-exists',
69
+ re: new RegExp(`\\bno\\s+(${TOKEN_LIST})\\s+(?:npm\\s+)?(?:package|module)\\s+exists\\b`, 'i')
70
+ },
71
+ {
72
+ // "no `argon2` dependency is needed".
73
+ //
74
+ // The trailing need-negation is NOT optional, and STEP 0 is why. The
75
+ // first cut of this pattern was the bare "no `X` dependency", and it
76
+ // fired 300 times in 306 corpus hits — every one of them on a
77
+ // MANIFEST-STATE bullet that says the exact opposite of a refutation:
78
+ //
79
+ // "`package.json` currently lists no `hono` or `@hono/zod-validator`
80
+ // dependencies; they MUST BE ADDED at versions 4.12.27 and 0.8.0"
81
+ // "`package.json` has … and no `sharp` dependency — MUST ADD `sharp`
82
+ // as devDependency"
83
+ //
84
+ // Dropping on that signal mutilated real constraints ("use with the
85
+ // shared `loginSchema`"). "The repo does not have X yet" is a fact about
86
+ // the tree; "X is not needed" is a claim about the task. Only the second
87
+ // one refutes anything.
88
+ name: 'no-dep-needed',
89
+ re: new RegExp(`\\bno\\s+(${TOKEN_LIST})\\s+dependenc(?:y|ies)\\s+(?:is\\s+|are\\s+)?(?:needed|required|necessary)\\b`, 'i')
90
+ },
91
+ // The two shapes below name no dependency noun of their own, so they carry a
92
+ // DEP_WORD guard. STEP 0's near-miss census is the reason: "does not require"
93
+ // appears in 412 corpus CONTEXT bullets, and the shape is overwhelmingly
94
+ // prose about behaviour, not dependencies — "the `GET /api/listings`
95
+ // endpoint does NOT require authentication for the `mine` filter". Token
96
+ // adjacency alone would eventually reach a backticked API expression there.
97
+ {
98
+ // "`argon2` is not needed" / "`argon2` and `sharp` are not required"
99
+ name: 'not-needed',
100
+ re: new RegExp(`(${TOKEN_LIST})\\s+(?:is|are)\\s+not\\s+(?:needed|required)\\b`, 'i'),
101
+ needsDepWord: true
102
+ },
103
+ {
104
+ // "does not require `argon2`"
105
+ name: 'does-not-require',
106
+ re: new RegExp(`\\b(?:does|do)\\s+not\\s+require\\s+(${TOKEN_LIST})`, 'i'),
107
+ needsDepWord: true
108
+ }
109
+ ];
110
+ /** The claim has to be ABOUT a dependency for a dependency to be dropped. */
111
+ const DEP_WORD = /\bdependenc(?:y|ies)\b|\bnpm\b|\bpackages?\b|\bdevDependenc(?:y|ies)\b/i;
112
+ /** The owned-requirement stamp (requirements.ts:813). A line carrying it is a
113
+ * design-sourced obligation and is NEVER refutable by research — this is the
114
+ * guard that keeps this pass out of nexttask 2's failure mode. */
115
+ const OWNED_MARKER = 'owned requirement from the source design';
116
+ /** Section body between a bare ALL-CAPS header and the next one (or EOF). */
117
+ export function extractCapsSection(text, heading) {
118
+ const lines = text.split('\n');
119
+ const start = lines.findIndex(l => l.trim() === heading);
120
+ if (start === -1)
121
+ return null;
122
+ const rest = lines.slice(start + 1);
123
+ const end = rest.findIndex(l => HEADER.test(l.trim()) && l.trim().length > 1);
124
+ return (end === -1 ? rest : rest.slice(0, end)).join('\n');
125
+ }
126
+ function tokensIn(span) {
127
+ return [...span.matchAll(/`([^`\s]+)`/g)].map(m => m[1]);
128
+ }
129
+ /**
130
+ * Does this look like a package/module name at all? Refutations are about
131
+ * DEPENDENCIES; a backticked prose fragment, a path, or an API expression is not
132
+ * one, and dropping it from a constraint would be a semantic edit.
133
+ */
134
+ function isPackageToken(tok) {
135
+ if (tok.length > 64)
136
+ return false;
137
+ if (!/^@?[a-z0-9][a-z0-9._/-]*$/i.test(tok))
138
+ return false;
139
+ // `Bun.password`, `p.dependencies` — dotted API expressions, not packages.
140
+ if (/^[A-Z]/.test(tok))
141
+ return false;
142
+ return true;
143
+ }
144
+ /** Every research CONTEXT bullet, one entry per bullet (continuations joined). */
145
+ function bullets(section) {
146
+ const out = [];
147
+ for (const raw of section.split('\n')) {
148
+ const line = raw.trimEnd();
149
+ if (!line.trim())
150
+ continue;
151
+ if (/^\s*[-*]\s+/.test(line))
152
+ out.push(line.trim());
153
+ else if (out.length > 0)
154
+ out[out.length - 1] += ` ${line.trim()}`;
155
+ }
156
+ return out.length > 0 ? out : section.split('\n').filter(l => l.trim());
157
+ }
158
+ /**
159
+ * Find every (refine constraint line, research refutation bullet, shared token)
160
+ * triple. `refined` is the refined prompt, `research` the research output; both
161
+ * are the exact strings compose is handed.
162
+ */
163
+ export function detectRefutations(refined, research) {
164
+ const constraintsBody = extractCapsSection(refined, 'CONSTRAINTS');
165
+ const contextBody = extractCapsSection(research, 'CONTEXT');
166
+ if (constraintsBody === null || contextBody === null)
167
+ return [];
168
+ // Refuted token → the bullet that refuted it, and the pattern that fired.
169
+ const refuted = new Map();
170
+ for (const bullet of bullets(contextBody)) {
171
+ for (const { name, re, needsDepWord } of NEGATIONS) {
172
+ const m = re.exec(bullet);
173
+ if (!m)
174
+ continue;
175
+ if (needsDepWord && !DEP_WORD.test(bullet))
176
+ continue;
177
+ for (const tok of tokensIn(m[1])) {
178
+ if (!isPackageToken(tok))
179
+ continue;
180
+ if (!refuted.has(tok))
181
+ refuted.set(tok, { research: bullet, pattern: name });
182
+ }
183
+ }
184
+ }
185
+ if (refuted.size === 0)
186
+ return [];
187
+ const lines = refined.split('\n');
188
+ const constraintStart = lines.findIndex(l => l.trim() === 'CONSTRAINTS');
189
+ const out = [];
190
+ for (let i = constraintStart + 1; i < lines.length; i++) {
191
+ const line = lines[i];
192
+ if (HEADER.test(line.trim()) && line.trim().length > 1)
193
+ break;
194
+ if (line.includes(OWNED_MARKER))
195
+ continue;
196
+ for (const [tok, src] of refuted) {
197
+ if (!new RegExp(`\`${escapeRe(tok)}\``).test(line))
198
+ continue;
199
+ out.push({
200
+ token: tok,
201
+ line: i,
202
+ constraint: line,
203
+ research: src.research,
204
+ pattern: src.pattern
205
+ });
206
+ }
207
+ }
208
+ return out;
209
+ }
210
+ function escapeRe(s) {
211
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
212
+ }
213
+ /**
214
+ * Delete one token from a constraint line: the backticked span, any word-suffix
215
+ * glued to it (`` `bun-sql` ``-equivalent), and ONE adjacent list separator —
216
+ * the preceding one where there is one, so the list keeps its shape.
217
+ *
218
+ * Returns null when nothing but boilerplate would be left, which the caller
219
+ * turns into a whole-line drop. Never rephrases, never adds a character.
220
+ */
221
+ export function dropToken(line, token) {
222
+ const tokRe = new RegExp(`\`${escapeRe(token)}\`[A-Za-z0-9-]*`, 'g');
223
+ let next = line;
224
+ for (;;) {
225
+ const m = tokRe.exec(next);
226
+ if (!m)
227
+ break;
228
+ const start = m.index;
229
+ const end = start + m[0].length;
230
+ // Prefer eating the separator BEFORE the token; fall back to the one
231
+ // after it (first element of a list).
232
+ const before = next.slice(0, start);
233
+ const sepBefore = /(?:,\s*|\s+or\s+|\s+and\s+)$/.exec(before);
234
+ if (sepBefore) {
235
+ next = next.slice(0, start - sepBefore[0].length) + next.slice(end);
236
+ }
237
+ else {
238
+ const sepAfter = /^(?:\s*,\s*|\s+or\s+|\s+and\s+)/.exec(next.slice(end));
239
+ next = before + next.slice(end + (sepAfter ? sepAfter[0].length : 0));
240
+ }
241
+ tokRe.lastIndex = 0;
242
+ }
243
+ if (next === line)
244
+ return line;
245
+ // An emptied list ("(e.g., )") or an emptied bullet is a whole-line drop.
246
+ const carcass = next
247
+ .replace(/^\s*[-*]\s*/, '')
248
+ .replace(/\(\s*e\.g\.,?\s*\)/gi, '')
249
+ .replace(/[\s,.;:()]/g, '');
250
+ if (carcass.length === 0)
251
+ return null;
252
+ return next;
253
+ }
254
+ /**
255
+ * Apply every detected refutation to the refined prompt. Purely subtractive: the
256
+ * result is always a character subsequence of the input (`inv-no-line-invention`).
257
+ */
258
+ export function applyRefutations(refined, research) {
259
+ const refutations = detectRefutations(refined, research);
260
+ if (refutations.length === 0)
261
+ return { refined, trail: [], refutations };
262
+ const lines = refined.split('\n');
263
+ const dropped = new Set();
264
+ const trail = [];
265
+ for (const r of refutations) {
266
+ const current = lines[r.line];
267
+ const next = dropToken(current, r.token);
268
+ if (next === null) {
269
+ dropped.add(r.line);
270
+ trail.push(`constraint refuted by research — dropped the whole CONSTRAINTS line for '${r.token}'`
271
+ + ` | constraint: "${r.constraint.trim()}" | research: "${r.research.trim()}"`);
272
+ }
273
+ else {
274
+ lines[r.line] = next;
275
+ trail.push(`constraint refuted by research — dropped '${r.token}' from CONSTRAINTS`
276
+ + ` | constraint: "${r.constraint.trim()}" | research: "${r.research.trim()}"`);
277
+ }
278
+ }
279
+ const kept = lines.filter((_l, i) => !dropped.has(i));
280
+ return { refined: kept.join('\n'), trail, refutations };
281
+ }
@@ -1,13 +1,18 @@
1
1
  /**
2
2
  * nexttask 5B — the two candidate bounds on worker:apis's project-source fan-out.
3
3
  *
4
- * ⚠ NOT WIRED. Both levers here are OFF unless their env var is set, so the
5
- * shipped path is bit-for-bit what it was. They exist so
6
- * `scripts/live-research-fanout-budget-ab.ts` can run them against the shipped
7
- * baseline in the SAME build the alternative (dist surgery) measures a patched
8
- * copy of the code and not the code. Nothing may read these outside that harness
9
- * until it reports PASS; a lever wired on argument rather than measurement is the
10
- * failure mode nexttasks exists to prevent.
4
+ * ⚠ ONE of the levers in this file is wired: the RESCUE progress deadline
5
+ * (`workerProgressCeilingMs`) SHIPPED ON in nexttask 9, on a PASS measured over 42
6
+ * trials per arm against an instrument whose own false-break rate is on record at
7
+ * 1.5%. CAP, SCALE and RESCUE-CARRY remain OFF unless their env var is set — CAP
8
+ * and SCALE were rejected on argument (see below), carry-forward was measured
9
+ * HARMFUL on its own.
10
+ *
11
+ * The OFF levers exist so `scripts/live-research-fanout-budget-ab.ts` can run them
12
+ * against the shipped baseline in the SAME build — the alternative (dist surgery)
13
+ * measures a patched copy of the code and not the code. Nothing may read them
14
+ * outside that harness until it reports PASS; a lever wired on argument rather
15
+ * than measurement is the failure mode nexttasks exists to prevent.
11
16
  *
12
17
  * THE FAULT THEY TARGET (mx5 run 18, measured — scripts/research-restart-baserate.ts):
13
18
  * `worker:apis` fans out `pi-worker-docs(module: ".")` project-source lookups, each
@@ -102,9 +107,40 @@ export declare function fanoutTimeoutPolicy(env?: Env): {
102
107
  */
103
108
  export declare function workerCarryForward(env?: Env): boolean;
104
109
  /**
105
- * The absolute backstop for the progress-based deadline, or null when off.
106
- * Required rather than defaulted: a progress deadline with no ceiling is an
107
- * unbounded worker, which is the one thing the fixed cap exists to prevent.
110
+ * The absolute backstop for the progress-based deadline.
111
+ *
112
+ * WHY THIS NUMBER. It is not a budget and it does not decide how long a worker
113
+ * may take — the no-progress deadline does that, and it resets on every tool call.
114
+ * This is the last-resort bound on a worker that never stops moving (an infinite
115
+ * tool-call loop the loop detector somehow misses), so its only requirement is to
116
+ * sit clear of the real workload. Measured on 42 progress-arm trials
117
+ * (`~/tmp/research-fanout-ab-v3`): median 275s, p90 523s, **max 730s**. 20 minutes
118
+ * is 1.6x the observed worst case, and 1.7x the 720s the SHIPPED path already
119
+ * spends on a worker that burns all three attempts and returns nothing.
120
+ *
121
+ * A ceiling that never fires in production is the correct behaviour for a
122
+ * backstop, not evidence it is untested: it fires under test
123
+ * (`pi-worker-core.test.ts` — 'the absolute ceiling still bounds a worker that
124
+ * never stops moving'), and a worker that goes QUIET is killed long before it, at
125
+ * `timeoutMs` without progress and by the stall probe.
126
+ */
127
+ export declare const DEFAULT_WORKER_PROGRESS_CEILING_MS = 1200000;
128
+ /**
129
+ * The progress-based deadline's ceiling, or null when the lever is OFF.
130
+ *
131
+ * SHIPPED ON as of nexttask 9 — the env var is now the OFF switch, not the on
132
+ * switch. Measured baseline vs progress over 42 trials/arm on a calibrated
133
+ * instrument (A/A false-break 1.5%): worker-timeout restarts 22/24 → 0/24,
134
+ * degrades 8/24 → 0/24, entries up on all four high-fan-out fixtures (TASK_0021
135
+ * 11.0 → 25.5), quality invariants HOLD, every treatment-arm ungrounded flag
136
+ * hand-verified as an instrument artifact rather than a fabrication.
137
+ *
138
+ * unset ON at DEFAULT_WORKER_PROGRESS_CEILING_MS
139
+ * "0" | "off" OFF — the fixed elapsed-time cap, exactly as before
140
+ * positive int ON at that ceiling, in ms
141
+ *
142
+ * A garbage value keeps the SHIPPED behaviour rather than silently disabling the
143
+ * lever: turning it off is a decision and has to be spelled.
108
144
  */
109
145
  export declare function workerProgressCeilingMs(env?: Env): number | null;
110
146
  /**
@@ -1,13 +1,18 @@
1
1
  /**
2
2
  * nexttask 5B — the two candidate bounds on worker:apis's project-source fan-out.
3
3
  *
4
- * ⚠ NOT WIRED. Both levers here are OFF unless their env var is set, so the
5
- * shipped path is bit-for-bit what it was. They exist so
6
- * `scripts/live-research-fanout-budget-ab.ts` can run them against the shipped
7
- * baseline in the SAME build the alternative (dist surgery) measures a patched
8
- * copy of the code and not the code. Nothing may read these outside that harness
9
- * until it reports PASS; a lever wired on argument rather than measurement is the
10
- * failure mode nexttasks exists to prevent.
4
+ * ⚠ ONE of the levers in this file is wired: the RESCUE progress deadline
5
+ * (`workerProgressCeilingMs`) SHIPPED ON in nexttask 9, on a PASS measured over 42
6
+ * trials per arm against an instrument whose own false-break rate is on record at
7
+ * 1.5%. CAP, SCALE and RESCUE-CARRY remain OFF unless their env var is set — CAP
8
+ * and SCALE were rejected on argument (see below), carry-forward was measured
9
+ * HARMFUL on its own.
10
+ *
11
+ * The OFF levers exist so `scripts/live-research-fanout-budget-ab.ts` can run them
12
+ * against the shipped baseline in the SAME build — the alternative (dist surgery)
13
+ * measures a patched copy of the code and not the code. Nothing may read them
14
+ * outside that harness until it reports PASS; a lever wired on argument rather
15
+ * than measurement is the failure mode nexttasks exists to prevent.
11
16
  *
12
17
  * THE FAULT THEY TARGET (mx5 run 18, measured — scripts/research-restart-baserate.ts):
13
18
  * `worker:apis` fans out `pi-worker-docs(module: ".")` project-source lookups, each
@@ -113,12 +118,48 @@ export function workerCarryForward(env = defaultEnv) {
113
118
  return env(WORKER_CARRY_FORWARD_ENV) === '1';
114
119
  }
115
120
  /**
116
- * The absolute backstop for the progress-based deadline, or null when off.
117
- * Required rather than defaulted: a progress deadline with no ceiling is an
118
- * unbounded worker, which is the one thing the fixed cap exists to prevent.
121
+ * The absolute backstop for the progress-based deadline.
122
+ *
123
+ * WHY THIS NUMBER. It is not a budget and it does not decide how long a worker
124
+ * may take — the no-progress deadline does that, and it resets on every tool call.
125
+ * This is the last-resort bound on a worker that never stops moving (an infinite
126
+ * tool-call loop the loop detector somehow misses), so its only requirement is to
127
+ * sit clear of the real workload. Measured on 42 progress-arm trials
128
+ * (`~/tmp/research-fanout-ab-v3`): median 275s, p90 523s, **max 730s**. 20 minutes
129
+ * is 1.6x the observed worst case, and 1.7x the 720s the SHIPPED path already
130
+ * spends on a worker that burns all three attempts and returns nothing.
131
+ *
132
+ * A ceiling that never fires in production is the correct behaviour for a
133
+ * backstop, not evidence it is untested: it fires under test
134
+ * (`pi-worker-core.test.ts` — 'the absolute ceiling still bounds a worker that
135
+ * never stops moving'), and a worker that goes QUIET is killed long before it, at
136
+ * `timeoutMs` without progress and by the stall probe.
137
+ */
138
+ export const DEFAULT_WORKER_PROGRESS_CEILING_MS = 1_200_000;
139
+ /**
140
+ * The progress-based deadline's ceiling, or null when the lever is OFF.
141
+ *
142
+ * SHIPPED ON as of nexttask 9 — the env var is now the OFF switch, not the on
143
+ * switch. Measured baseline vs progress over 42 trials/arm on a calibrated
144
+ * instrument (A/A false-break 1.5%): worker-timeout restarts 22/24 → 0/24,
145
+ * degrades 8/24 → 0/24, entries up on all four high-fan-out fixtures (TASK_0021
146
+ * 11.0 → 25.5), quality invariants HOLD, every treatment-arm ungrounded flag
147
+ * hand-verified as an instrument artifact rather than a fabrication.
148
+ *
149
+ * unset ON at DEFAULT_WORKER_PROGRESS_CEILING_MS
150
+ * "0" | "off" OFF — the fixed elapsed-time cap, exactly as before
151
+ * positive int ON at that ceiling, in ms
152
+ *
153
+ * A garbage value keeps the SHIPPED behaviour rather than silently disabling the
154
+ * lever: turning it off is a decision and has to be spelled.
119
155
  */
120
156
  export function workerProgressCeilingMs(env = defaultEnv) {
121
- return positiveInt(env(WORKER_PROGRESS_CEILING_ENV));
157
+ const raw = env(WORKER_PROGRESS_CEILING_ENV);
158
+ if (raw === undefined || raw.trim() === '')
159
+ return DEFAULT_WORKER_PROGRESS_CEILING_MS;
160
+ if (raw.trim() === '0' || raw.trim().toLowerCase() === 'off')
161
+ return null;
162
+ return positiveInt(raw) ?? DEFAULT_WORKER_PROGRESS_CEILING_MS;
122
163
  }
123
164
  /**
124
165
  * The upfront half of the CAP arm, appended to the APIS worker's prompt.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
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",