@mjasnikovs/pi-task 0.34.0 → 0.36.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.
@@ -57,6 +57,12 @@ export interface AskSpec {
57
57
  * {@link AskQuestionBoxSpec.manualLabel}). Ignored without `options`.
58
58
  */
59
59
  manualLabel?: string;
60
+ /**
61
+ * Where the free-text card sits among `options` (see
62
+ * {@link AskQuestionBoxSpec.manualPosition}). Local picker only — remote
63
+ * browsers always render the text box above the action buttons.
64
+ */
65
+ manualPosition?: number;
60
66
  /**
61
67
  * Extra buttons the BROWSER card shows alongside the recommendation, each
62
68
  * answering with its own `value`. Unlike `options` — which are answers, and
@@ -101,6 +101,7 @@ export class SessionUI {
101
101
  recommended: i === 0
102
102
  })),
103
103
  ...(spec.manualLabel !== undefined && { manualLabel: spec.manualLabel }),
104
+ ...(spec.manualPosition !== undefined && { manualPosition: spec.manualPosition }),
104
105
  signal
105
106
  });
106
107
  }
@@ -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
@@ -43,13 +43,31 @@ export declare function buildPlanBody(task: string): string;
43
43
  * there is no hidden channel.
44
44
  */
45
45
  export declare function formatPlanDecisions(entries: readonly PlanEntry[]): string;
46
+ /**
47
+ * The line that pins the DELIVERABLE, and it is not optional.
48
+ *
49
+ * The task prompt leads the handoff verbatim, and users reach /task-plan by
50
+ * phrasing the request as planning — live (aiz-client TASK_PLAN_0001,
51
+ * 2026-08-05): "Lets plan new tab and report @src/app/reports/". /task's refine
52
+ * read the verb as the deliverable and produced a task titled "Plan the addition
53
+ * of a new sub-tab…", whose ACCEPTANCE was "a planning document exists with
54
+ * placeholder sections" and whose VERIFY asserted that no `.ts`/`.tsx` file had
55
+ * changed. It passed. Nothing was built.
56
+ *
57
+ * Planning already happened — this prompt IS its output — so the handoff says so
58
+ * rather than letting the request's own wording re-open it. It rides on every
59
+ * handoff, decisions or none: the verb leaks regardless of how much got settled.
60
+ */
61
+ export declare const HANDOFF_DELIVERABLE_RULE: string;
46
62
  /**
47
63
  * The prompt handed to /task when the user proceeds to execution.
48
64
  *
49
65
  * The task prompt leads, exactly as a bare `/task <prompt>` would, so refine sees
50
- * a normal task description first; the decisions follow as an authoritative block.
51
- * Anything the user did NOT settle is simply absent — /task's own grill phase asks
52
- * about what is left, which is why this block never invents a decision to fill a
53
- * gap.
66
+ * a normal task description first; the deliverable rule and then the decisions
67
+ * follow as an authoritative block. Anything the user did NOT settle is simply
68
+ * absent — /task's own grill phase asks about what is left, which is why this
69
+ * block never invents a decision to fill a gap. A question the user left
70
+ * unanswered is likewise absent: "(skipped)" is not a decision, and carrying it
71
+ * as one is how a non-answer becomes an instruction.
54
72
  */
55
73
  export declare function buildHandoffPrompt(task: string, entries: readonly PlanEntry[]): string;
@@ -73,20 +73,43 @@ export function buildPlanBody(task) {
73
73
  export function formatPlanDecisions(entries) {
74
74
  return entries.length === 0 ? '(none yet)' : formatPlanTranscript(entries);
75
75
  }
76
+ /**
77
+ * The line that pins the DELIVERABLE, and it is not optional.
78
+ *
79
+ * The task prompt leads the handoff verbatim, and users reach /task-plan by
80
+ * phrasing the request as planning — live (aiz-client TASK_PLAN_0001,
81
+ * 2026-08-05): "Lets plan new tab and report @src/app/reports/". /task's refine
82
+ * read the verb as the deliverable and produced a task titled "Plan the addition
83
+ * of a new sub-tab…", whose ACCEPTANCE was "a planning document exists with
84
+ * placeholder sections" and whose VERIFY asserted that no `.ts`/`.tsx` file had
85
+ * changed. It passed. Nothing was built.
86
+ *
87
+ * Planning already happened — this prompt IS its output — so the handoff says so
88
+ * rather than letting the request's own wording re-open it. It rides on every
89
+ * handoff, decisions or none: the verb leaks regardless of how much got settled.
90
+ */
91
+ export const HANDOFF_DELIVERABLE_RULE = 'PLANNING IS ALREADY DONE. This prompt is the OUTPUT of an interactive planning session '
92
+ + 'that has now ended; you are the implementation step. Build the thing. Do not produce a '
93
+ + 'plan, a design, a proposal, or a "planning-only" deliverable, do not write a document '
94
+ + 'whose acceptance is that no code changed, and do not defer the work pending user '
95
+ + 'confirmation — no user is available from here on.';
76
96
  /**
77
97
  * The prompt handed to /task when the user proceeds to execution.
78
98
  *
79
99
  * The task prompt leads, exactly as a bare `/task <prompt>` would, so refine sees
80
- * a normal task description first; the decisions follow as an authoritative block.
81
- * Anything the user did NOT settle is simply absent — /task's own grill phase asks
82
- * about what is left, which is why this block never invents a decision to fill a
83
- * gap.
100
+ * a normal task description first; the deliverable rule and then the decisions
101
+ * follow as an authoritative block. Anything the user did NOT settle is simply
102
+ * absent — /task's own grill phase asks about what is left, which is why this
103
+ * block never invents a decision to fill a gap. A question the user left
104
+ * unanswered is likewise absent: "(skipped)" is not a decision, and carrying it
105
+ * as one is how a non-answer becomes an instruction.
84
106
  */
85
107
  export function buildHandoffPrompt(task, entries) {
86
- const decisions = entries.filter(e => e.kind !== 'note');
108
+ const decisions = entries.filter(e => e.kind !== 'note' && !(e.kind === 'decision' && /^\(skipped/i.test(e.answer.trim())));
109
+ const head = `${task.trim()}\n\n${HANDOFF_DELIVERABLE_RULE}`;
87
110
  if (decisions.length === 0)
88
- return task.trim();
89
- return (`${task.trim()}\n\n`
111
+ return head;
112
+ return (`${head}\n\n`
90
113
  + `PLANNING DECISIONS — these were settled with the user before this task was started. `
91
114
  + `They are authoritative: implement them as written, and do not re-open or contradict `
92
115
  + `them. They may not cover everything; decide anything they leave open as usual.\n\n`
@@ -67,6 +67,14 @@ repo, and any stated constraints; it is shown to the user as a recommendation th
67
67
  accept or override. When the question is a genuine binary "A or B?" fork, also give
68
68
  the single best alternative as an ALT line; otherwise emit only the one SUGGESTED.
69
69
 
70
+ The SUGGESTED must DECIDE. Never recommend asking, clarifying, confirming, or
71
+ checking with the user, and never recommend waiting, deferring, or leaving the
72
+ question open — the user is answering this very question right now, so "find out
73
+ from the user" is not an answer, it is the question you just asked. If you truly
74
+ cannot tell which way to go, still commit to the option you would take if it were
75
+ your call, and let the user override it. A default that could not be implemented
76
+ as written is not a default.
77
+
70
78
  OUTPUT FORMAT (exact) — read as much as you like, but your written REPLY is 2 or
71
79
  3 lines and nothing else:
72
80
  - Do NOT report what you read. No preamble, no analysis, no findings, no numbered
@@ -20,7 +20,10 @@
20
20
  * doubles as "state a decision" when the model
21
21
  * has nothing to ask
22
22
  * ▶ proceed to execution — stop planning, hand the decisions to /task
23
- * (PLAN_PROCEED)
23
+ * (PLAN_PROCEED). Always the LAST card in the
24
+ * box — it ends the session, so it sits under
25
+ * every move that continues it, including the
26
+ * free-text card (see `manualPosition`).
24
27
  *
25
28
  * The loop is pure with respect to I/O: every side effect (child calls, dialogs,
26
29
  * persistence) arrives through {@link PlanSessionDeps}, so the whole interaction
@@ -85,6 +88,32 @@ export declare function pickQuestion<T extends {
85
88
  * Deliberately shallow — an "X or Y?" in the question's own clause.
86
89
  */
87
90
  export declare function looksLikeFork(question: string): boolean;
91
+ /**
92
+ * Does the recommended default DEFER the decision instead of making one?
93
+ *
94
+ * The prompt asks for a "concrete, decisive default", and nothing enforced it.
95
+ * Live (aiz-client TASK_PLAN_0001, 2026-08-05): the model asked "what specific
96
+ * report should this new tab display?" and recommended
97
+ * "clarify with the user what the report is meant to show before proceeding".
98
+ * The user pressed enter, so it was recorded `(accepted recommendation)` and rode
99
+ * into /task's handoff as an AUTHORITATIVE decision — an order not to proceed,
100
+ * addressed to a run where no user exists. /task duly built a task whose
101
+ * ACCEPTANCE was "a planning document with placeholder sections" and whose VERIFY
102
+ * asserted that no source file had changed.
103
+ *
104
+ * A deferral is not an answer, and the one place it can never be one is here: the
105
+ * user IS present during planning, so "ask the user" is a null move — that IS the
106
+ * question. Detection is anchored to the START of the default, which keeps it off
107
+ * legitimate product behaviour ("prompt the user to confirm deletion" decides
108
+ * something; "ask the user which report" decides nothing).
109
+ */
110
+ export declare function isDeferralSuggestion(suggested: string): boolean;
111
+ /**
112
+ * Corrective re-prompt for a default that deferred the decision. Same one-shot
113
+ * budget and same quote-it-back shape as {@link planForkHint}, because the child
114
+ * is stateless and cannot otherwise know what it just recommended.
115
+ */
116
+ export declare function planDecisiveHint(question: string, suggested: string): string;
88
117
  /**
89
118
  * Corrective re-prompt for a fork-shaped question that shipped only ONE option.
90
119
  *
@@ -109,6 +138,7 @@ export type PlanAskSpec = AskSpec & {
109
138
  value: string;
110
139
  }[];
111
140
  manualLabel: string;
141
+ manualPosition: number;
112
142
  actions: {
113
143
  label: string;
114
144
  value: string;
@@ -155,9 +185,10 @@ interface PendingQuestion {
155
185
  /**
156
186
  * Build the picker for a pending model question: the recommendation first (index
157
187
  * 0 is the green RECOMMENDED card), the alternative second when the question is a
158
- * binary fork, then the two control actions. The free-text card is appended by
188
+ * binary fork, then the two control actions. The free-text card is supplied by
159
189
  * askQuestionBox itself — that is the "answer in your own words" affordance, and
160
- * it is the same card grill and clarify already show.
190
+ * it is the same card grill and clarify already show — placed just above the
191
+ * trailing "proceed to execution" card so proceed stays last.
161
192
  */
162
193
  export declare function buildQuestionSpec(p: PendingQuestion): PlanAskSpec;
163
194
  /**
@@ -165,6 +196,8 @@ export declare function buildQuestionSpec(p: PendingQuestion): PlanAskSpec;
165
196
  * questions, or the cap/duplicate backstop stopped it. The same three moves are
166
197
  * still on offer; only "answer this question" is gone, because there is no
167
198
  * question, so the free-text card becomes "add a decision of your own".
199
+ * Proceed stays last here, as it is under a question: handing off to /task ends
200
+ * planning, so it never sits where a reflexive first-item press can hit it.
168
201
  */
169
202
  export declare function buildIdleSpec(): PlanAskSpec;
170
203
  /**
@@ -20,7 +20,10 @@
20
20
  * doubles as "state a decision" when the model
21
21
  * has nothing to ask
22
22
  * ▶ proceed to execution — stop planning, hand the decisions to /task
23
- * (PLAN_PROCEED)
23
+ * (PLAN_PROCEED). Always the LAST card in the
24
+ * box — it ends the session, so it sits under
25
+ * every move that continues it, including the
26
+ * free-text card (see `manualPosition`).
24
27
  *
25
28
  * The loop is pure with respect to I/O: every side effect (child calls, dialogs,
26
29
  * persistence) arrives through {@link PlanSessionDeps}, so the whole interaction
@@ -98,6 +101,51 @@ export function pickQuestion(parsed) {
98
101
  export function looksLikeFork(question) {
99
102
  return /\bor\b/i.test(question.split('?')[0] ?? '');
100
103
  }
104
+ /**
105
+ * Does the recommended default DEFER the decision instead of making one?
106
+ *
107
+ * The prompt asks for a "concrete, decisive default", and nothing enforced it.
108
+ * Live (aiz-client TASK_PLAN_0001, 2026-08-05): the model asked "what specific
109
+ * report should this new tab display?" and recommended
110
+ * "clarify with the user what the report is meant to show before proceeding".
111
+ * The user pressed enter, so it was recorded `(accepted recommendation)` and rode
112
+ * into /task's handoff as an AUTHORITATIVE decision — an order not to proceed,
113
+ * addressed to a run where no user exists. /task duly built a task whose
114
+ * ACCEPTANCE was "a planning document with placeholder sections" and whose VERIFY
115
+ * asserted that no source file had changed.
116
+ *
117
+ * A deferral is not an answer, and the one place it can never be one is here: the
118
+ * user IS present during planning, so "ask the user" is a null move — that IS the
119
+ * question. Detection is anchored to the START of the default, which keeps it off
120
+ * legitimate product behaviour ("prompt the user to confirm deletion" decides
121
+ * something; "ask the user which report" decides nothing).
122
+ */
123
+ export function isDeferralSuggestion(suggested) {
124
+ const s = suggested.trim().replace(/^["'`*_\s]+/, '');
125
+ return (/^(ask|clarify|confirm|check|discuss|decide)\b[^.]{0,60}\b(with |from |the )?user\b/i.test(s)
126
+ || /^(wait|hold off|hold|defer|postpone|pause|park)\b/i.test(s)
127
+ || /^(tbd|to be (determined|decided|defined|specified))\b/i.test(s)
128
+ || /^(pending|awaiting|await)\b/i.test(s)
129
+ || /^leave (it|this|that)?\s*(to|for|open|undecided|unspecified)\b/i.test(s)
130
+ || /^the user (must|should|needs? to|has to|will)\b/i.test(s)
131
+ || /^(do not|don'?t|no)\b[^.]{0,40}\b(proceed|implement|build|start|write|decide)\b/i.test(s));
132
+ }
133
+ /**
134
+ * Corrective re-prompt for a default that deferred the decision. Same one-shot
135
+ * budget and same quote-it-back shape as {@link planForkHint}, because the child
136
+ * is stateless and cannot otherwise know what it just recommended.
137
+ */
138
+ export function planDecisiveHint(question, suggested) {
139
+ return ('[SYSTEM NOTE: Your previous reply asked this question:\n'
140
+ + `"${question}"\n`
141
+ + `and recommended: "${suggested}"\n`
142
+ + 'That recommendation DEFERS the decision instead of making one — it tells the user to '
143
+ + 'ask, clarify, wait, or leave it open. The user is answering this question RIGHT NOW, so '
144
+ + '"find out from the user" is not an answer, it is the question you just asked. Ask the '
145
+ + 'SAME question again — do not change the subject — and this time make SUGGESTED a '
146
+ + 'concrete choice that could be implemented as written, naming real things from the repo. '
147
+ + 'If the question is a genuine A-or-B fork, add the ALT line too. Nothing else.]');
148
+ }
101
149
  /**
102
150
  * Corrective re-prompt for a fork-shaped question that shipped only ONE option.
103
151
  *
@@ -124,9 +172,10 @@ export function planForkHint(question) {
124
172
  /**
125
173
  * Build the picker for a pending model question: the recommendation first (index
126
174
  * 0 is the green RECOMMENDED card), the alternative second when the question is a
127
- * binary fork, then the two control actions. The free-text card is appended by
175
+ * binary fork, then the two control actions. The free-text card is supplied by
128
176
  * askQuestionBox itself — that is the "answer in your own words" affordance, and
129
- * it is the same card grill and clarify already show.
177
+ * it is the same card grill and clarify already show — placed just above the
178
+ * trailing "proceed to execution" card so proceed stays last.
130
179
  */
131
180
  export function buildQuestionSpec(p) {
132
181
  const options = [];
@@ -153,7 +202,10 @@ export function buildQuestionSpec(p) {
153
202
  allowSkip: false,
154
203
  options: [...options, ...actions],
155
204
  actions,
156
- manualLabel: PLAN_ANSWER_LABEL
205
+ manualLabel: PLAN_ANSWER_LABEL,
206
+ // The free-text card goes ABOVE "proceed to execution" — proceed ends the
207
+ // session, so it is the last card in the box, under every other move.
208
+ manualPosition: options.length + actions.length - 1
157
209
  };
158
210
  }
159
211
  /**
@@ -161,11 +213,13 @@ export function buildQuestionSpec(p) {
161
213
  * questions, or the cap/duplicate backstop stopped it. The same three moves are
162
214
  * still on offer; only "answer this question" is gone, because there is no
163
215
  * question, so the free-text card becomes "add a decision of your own".
216
+ * Proceed stays last here, as it is under a question: handing off to /task ends
217
+ * planning, so it never sits where a reflexive first-item press can hit it.
164
218
  */
165
219
  export function buildIdleSpec() {
166
220
  const actions = [
167
- { label: PLAN_PROCEED_LABEL, value: PLAN_PROCEED },
168
- { label: PLAN_ASK_LABEL, value: PLAN_ASK }
221
+ { label: PLAN_ASK_LABEL, value: PLAN_ASK },
222
+ { label: PLAN_PROCEED_LABEL, value: PLAN_PROCEED }
169
223
  ];
170
224
  return {
171
225
  localTitle: PLAN_NO_QUESTIONS,
@@ -177,7 +231,8 @@ export function buildIdleSpec() {
177
231
  allowSkip: false,
178
232
  options: actions,
179
233
  actions,
180
- manualLabel: PLAN_STATE_LABEL
234
+ manualLabel: PLAN_STATE_LABEL,
235
+ manualPosition: actions.length - 1
181
236
  };
182
237
  }
183
238
  /**
@@ -278,11 +333,31 @@ export async function runPlanSession(deps) {
278
333
  formatHint = PLAN_FORMAT_HINT;
279
334
  continue;
280
335
  }
336
+ // A default that defers decides nothing, and an accepted deferral
337
+ // reaches /task dressed as an authoritative decision. One re-prompt to
338
+ // make it decisive; if it comes back deferring anyway the option is
339
+ // DROPPED rather than shown, so an empty submit records "(skipped)" —
340
+ // an unanswered question — instead of a decision the user never made.
341
+ const defers = suggested !== undefined && isDeferralSuggestion(suggested);
342
+ if (defers && formatHint === null) {
343
+ deps.logDebug?.('plan: SUGGESTED deferred the decision — one re-prompt');
344
+ formatHint = planDecisiveHint(plain, suggested);
345
+ continue;
346
+ }
347
+ // When only the recommendation defers, the ALT is still a real
348
+ // commitment: promote it so the question keeps a usable default.
349
+ const usableSuggested = defers ? alt : suggested;
350
+ const usableAlt = defers ? undefined : alt;
351
+ if (defers) {
352
+ deps.logDebug?.(usableSuggested === undefined ?
353
+ 'plan: SUGGESTED still deferred — question shown with no recommendation'
354
+ : 'plan: SUGGESTED still deferred — promoted the ALT to the recommendation');
355
+ }
281
356
  // A question that offers a choice but ships one option leaves the
282
357
  // user typing out the alternative the model just named. Same one-shot
283
358
  // budget, quoting the question back so the (stateless) child re-asks
284
359
  // this one instead of a new one.
285
- if (alt === undefined && formatHint === null && looksLikeFork(plain)) {
360
+ if (usableAlt === undefined && formatHint === null && !defers && looksLikeFork(plain)) {
286
361
  deps.logDebug?.('plan: fork-shaped question with no ALT — one re-prompt');
287
362
  formatHint = planForkHint(plain);
288
363
  continue;
@@ -294,13 +369,13 @@ export async function runPlanSession(deps) {
294
369
  pending = {
295
370
  plain,
296
371
  shown: render(question),
297
- ...(suggested !== undefined && {
298
- suggested: stripInlineMarkdown(suggested),
299
- shownSuggested: render(suggested)
372
+ ...(usableSuggested !== undefined && {
373
+ suggested: stripInlineMarkdown(usableSuggested),
374
+ shownSuggested: render(usableSuggested)
300
375
  }),
301
- ...(alt !== undefined && {
302
- alt: stripInlineMarkdown(alt),
303
- shownAlt: render(alt)
376
+ ...(usableAlt !== undefined && {
377
+ alt: stripInlineMarkdown(usableAlt),
378
+ shownAlt: render(usableAlt)
304
379
  })
305
380
  };
306
381
  }
@@ -84,6 +84,14 @@ export interface AskQuestionBoxSpec {
84
84
  * what the card actually does.
85
85
  */
86
86
  manualLabel?: string;
87
+ /**
88
+ * Where the free-text card sits among the options. Defaults to the end, which
89
+ * is right when the picker is answering a question. /task-plan passes an
90
+ * earlier index so its "proceed to execution" card — the one that ENDS the
91
+ * session — is literally the last entry in the list, and never sits above a
92
+ * card the user is more likely to want.
93
+ */
94
+ manualPosition?: number;
87
95
  }
88
96
  /**
89
97
  * Show the boxed picker and resolve to the chosen option's `value`, the text the
@@ -143,12 +143,14 @@ export class QuestionBoxComponent {
143
143
  */
144
144
  export async function askQuestionBox(ctx, spec) {
145
145
  const { question, options, inputTitle, signal } = spec;
146
+ const manualIndex = Math.max(0, Math.min(options.length, spec.manualPosition ?? options.length));
147
+ const asCard = (o) => ({ label: o.label, recommended: o.recommended });
146
148
  const cards = [
147
- ...options.map(o => ({ label: o.label, recommended: o.recommended })),
148
- { label: spec.manualLabel ?? MANUAL_CARD_LABEL }
149
+ ...options.slice(0, manualIndex).map(asCard),
150
+ { label: spec.manualLabel ?? MANUAL_CARD_LABEL },
151
+ ...options.slice(manualIndex).map(asCard)
149
152
  ];
150
153
  const colors = boxColors(ctx.ui.theme);
151
- const manualIndex = options.length;
152
154
  const choice = await ctx.ui.custom((_tui, _theme, _kb, done) => {
153
155
  if (signal.aborted) {
154
156
  done(undefined);
@@ -167,5 +169,5 @@ export async function askQuestionBox(ctx, spec) {
167
169
  if (choice === manualIndex) {
168
170
  return ctx.ui.input(inputTitle, undefined, { signal });
169
171
  }
170
- return options[choice]?.value;
172
+ return options[choice < manualIndex ? choice : choice - 1]?.value;
171
173
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.34.0",
3
+ "version": "0.36.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",