@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.
- package/dist/remote/bridge.d.ts +6 -0
- package/dist/remote/bridge.js +1 -0
- package/dist/task/env-template-closure.d.ts +63 -0
- package/dist/task/env-template-closure.js +397 -0
- package/dist/task/final-gate.js +17 -0
- package/dist/task/plan-io.d.ts +22 -4
- package/dist/task/plan-io.js +30 -7
- package/dist/task/plan-prompts.js +8 -0
- package/dist/task/plan-session.d.ts +36 -3
- package/dist/task/plan-session.js +89 -14
- package/dist/task/question-box.d.ts +8 -0
- package/dist/task/question-box.js +6 -4
- package/package.json +1 -1
package/dist/remote/bridge.d.ts
CHANGED
|
@@ -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
|
package/dist/remote/bridge.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/task/final-gate.js
CHANGED
|
@@ -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
|
package/dist/task/plan-io.d.ts
CHANGED
|
@@ -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
|
|
51
|
-
* Anything the user did NOT settle is simply
|
|
52
|
-
* about what is left, which is why this
|
|
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;
|
package/dist/task/plan-io.js
CHANGED
|
@@ -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
|
|
81
|
-
* Anything the user did NOT settle is simply
|
|
82
|
-
* about what is left, which is why this
|
|
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
|
|
89
|
-
return (`${
|
|
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
|
|
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
|
|
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:
|
|
168
|
-
{ label:
|
|
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 (
|
|
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
|
-
...(
|
|
298
|
-
suggested: stripInlineMarkdown(
|
|
299
|
-
shownSuggested: render(
|
|
372
|
+
...(usableSuggested !== undefined && {
|
|
373
|
+
suggested: stripInlineMarkdown(usableSuggested),
|
|
374
|
+
shownSuggested: render(usableSuggested)
|
|
300
375
|
}),
|
|
301
|
-
...(
|
|
302
|
-
alt: stripInlineMarkdown(
|
|
303
|
-
shownAlt: render(
|
|
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.
|
|
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.
|
|
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",
|