@mjasnikovs/pi-task 0.18.10 → 0.18.12
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/shared/child-process.d.ts +8 -0
- package/dist/shared/child-process.js +43 -3
- package/dist/task/auto-commit.d.ts +9 -13
- package/dist/task/auto-commit.js +59 -5
- package/dist/task/final-gate.d.ts +16 -1
- package/dist/task/final-gate.js +111 -2
- package/dist/task/gate-deps.js +18 -5
- package/dist/task/git-state-guard.d.ts +15 -1
- package/dist/task/git-state-guard.js +109 -23
- package/dist/task/phases.js +17 -1
- package/dist/task/prompts.d.ts +1 -1
- package/dist/task/prompts.js +2 -2
- package/dist/task/user-directives.d.ts +52 -0
- package/dist/task/user-directives.js +98 -0
- package/package.json +1 -1
|
@@ -14,6 +14,9 @@ export interface ProcLike extends EventEmitter {
|
|
|
14
14
|
stdout: EventEmitter | null;
|
|
15
15
|
stderr: EventEmitter | null;
|
|
16
16
|
killed: boolean;
|
|
17
|
+
/** OS pid; used to signal the child's whole process GROUP (orphan reaping). May
|
|
18
|
+
* be undefined for a mock spawn or a spawn that failed. */
|
|
19
|
+
pid?: number;
|
|
17
20
|
kill(signal: string): boolean | void;
|
|
18
21
|
}
|
|
19
22
|
export type SpawnFn = (command: string, args: ReadonlyArray<string>, options: {
|
|
@@ -23,6 +26,11 @@ export type SpawnFn = (command: string, args: ReadonlyArray<string>, options: {
|
|
|
23
26
|
/** Set only when the invocation needs env overrides (e.g. GIT_INDEX_FILE);
|
|
24
27
|
* absent → the child inherits this process's environment as before. */
|
|
25
28
|
env?: NodeJS.ProcessEnv;
|
|
29
|
+
/** true → give the child its own process group (POSIX `detached`), so any
|
|
30
|
+
* server it backgrounds (`bun run dev &`) can be reaped as a group when the
|
|
31
|
+
* child exits instead of leaking as an orphan holding a port (mx5 run 9
|
|
32
|
+
* item 3). Set only for model children (json-events); plumbing stays put. */
|
|
33
|
+
detached?: boolean;
|
|
26
34
|
}) => ProcLike;
|
|
27
35
|
export interface ChildResult {
|
|
28
36
|
stdout: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn as defaultSpawn } from 'node:child_process';
|
|
1
|
+
import { spawn as defaultSpawn, spawnSync as spawnSyncDefault } from 'node:child_process';
|
|
2
2
|
/** Grace period between SIGTERM and SIGKILL (ms). */
|
|
3
3
|
export const KILL_GRACE_MS = 5000;
|
|
4
4
|
/** Base flags shared by all child pi invocations. */
|
|
@@ -175,10 +175,17 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
175
175
|
// prompt is present we open stdin as a pipe; otherwise keep it 'ignore'
|
|
176
176
|
// (git and other arg-only spawns are unaffected). See GitHub issue #1.
|
|
177
177
|
const usesStdin = invocation.stdin !== undefined;
|
|
178
|
+
// Model children (json-events) run arbitrary bash — they can `bun run dev &`
|
|
179
|
+
// a server that outlives the child and holds a port, wrecking the final gate
|
|
180
|
+
// with a self-inflicted EADDRINUSE (mx5 run 9 item 3). Spawn them in their
|
|
181
|
+
// OWN process group so every such grandchild can be reaped as a unit on exit.
|
|
182
|
+
// Plumbing (git, mode:'text') never backgrounds anything and stays in-group.
|
|
183
|
+
const ownGroup = opts?.mode === 'json-events';
|
|
178
184
|
const proc = spawn(invocation.command, invocation.args, {
|
|
179
185
|
cwd,
|
|
180
186
|
shell: false,
|
|
181
187
|
stdio: [usesStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
188
|
+
...(ownGroup ? { detached: true } : {}),
|
|
182
189
|
...(invocation.env ? { env: invocation.env } : {})
|
|
183
190
|
});
|
|
184
191
|
if (usesStdin) {
|
|
@@ -186,14 +193,39 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
186
193
|
proc.stdin?.write(invocation.stdin);
|
|
187
194
|
proc.stdin?.end();
|
|
188
195
|
}
|
|
189
|
-
//
|
|
190
|
-
//
|
|
196
|
+
// Reap the child's whole process group — the child itself AND anything it
|
|
197
|
+
// backgrounded. No-op unless the child owns a group (ownGroup) and we have a
|
|
198
|
+
// pid; ESRCH (group already gone) is swallowed. POSIX: negative-pid signals
|
|
199
|
+
// the group; Windows has no groups, so taskkill /T tears down the tree.
|
|
200
|
+
const reapGroup = (sig) => {
|
|
201
|
+
if (!ownGroup || !proc.pid)
|
|
202
|
+
return;
|
|
203
|
+
try {
|
|
204
|
+
if (process.platform === 'win32') {
|
|
205
|
+
spawnSyncDefault('taskkill', ['/pid', String(proc.pid), '/T', '/F']);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
process.kill(-proc.pid, sig);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// group already gone
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
// One kill path, shared by user-abort and loop-kill: SIGTERM, then SIGKILL
|
|
216
|
+
// after a grace period if the child ignored the term. For a group-owning
|
|
217
|
+
// (model) child, ALSO sweep the group so anything it backgrounded dies with
|
|
218
|
+
// it — proc.kill hits only the leader, reapGroup the grandchildren.
|
|
191
219
|
const killProc = () => {
|
|
192
220
|
aborted = true;
|
|
193
221
|
proc.kill('SIGTERM');
|
|
222
|
+
if (ownGroup)
|
|
223
|
+
reapGroup('SIGTERM');
|
|
194
224
|
setTimeout(() => {
|
|
195
225
|
if (!proc.killed)
|
|
196
226
|
proc.kill('SIGKILL');
|
|
227
|
+
if (ownGroup)
|
|
228
|
+
reapGroup('SIGKILL');
|
|
197
229
|
}, KILL_GRACE_MS);
|
|
198
230
|
};
|
|
199
231
|
const sink = opts?.mode === 'json-events' ? new JsonEventSink(opts, killProc) : null;
|
|
@@ -250,6 +282,14 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
250
282
|
proc.on('close', (code) => {
|
|
251
283
|
if (stallTimer)
|
|
252
284
|
clearInterval(stallTimer);
|
|
285
|
+
// The child has exited, but anything it backgrounded (a dev server) may
|
|
286
|
+
// still hold its process group and a port — reap the group so the next
|
|
287
|
+
// gate's boot check does not collide with our own orphan. Best-effort:
|
|
288
|
+
// SIGTERM now, SIGKILL shortly after for anything that ignored it.
|
|
289
|
+
if (ownGroup) {
|
|
290
|
+
reapGroup('SIGTERM');
|
|
291
|
+
setTimeout(() => reapGroup('SIGKILL'), 1_000).unref();
|
|
292
|
+
}
|
|
253
293
|
if (sink)
|
|
254
294
|
sink.flush();
|
|
255
295
|
const text = sink ? sink.text : undefined;
|
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-task git commit for /task-auto.
|
|
3
|
-
*
|
|
4
|
-
* After each decomposed task passes, runAutoLoop snapshots the working tree into
|
|
5
|
-
* a single commit so the run produces one commit per task. This is best-effort:
|
|
6
|
-
* outside a git repo, with nothing staged, or on any git error we report the
|
|
7
|
-
* reason and let the loop continue (the task already succeeded).
|
|
8
|
-
*/
|
|
9
1
|
import { type SpawnFn } from '../shared/child-process.js';
|
|
10
2
|
export interface CommitResult {
|
|
11
3
|
committed: boolean;
|
|
@@ -58,11 +50,15 @@ export declare function gitCommitAll(cwd: string, message: string, signal?: Abor
|
|
|
58
50
|
* `git reset --hard HEAD~1` throws the enforce commit away and brings back the
|
|
59
51
|
* verified task commit underneath it.
|
|
60
52
|
*
|
|
61
|
-
* `reset --hard`
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* commit
|
|
65
|
-
*
|
|
53
|
+
* `reset --hard` targets the enforce pass's in-place SOURCE edits. But it must NOT
|
|
54
|
+
* rewind the forensic gate trail: `.pi-tasks/` is frequently TRACKED (the per-task
|
|
55
|
+
* snapshots stage it via `git add -A`), so a bare reset restores TASK_00NN.md to the
|
|
56
|
+
* snapshot commit and ERASES every trail line written after it — the "commit: task
|
|
57
|
+
* snapshot committed", "enforce(edit): …", and resolution lines (mx5 run 9:
|
|
58
|
+
* TASK_0007/0008/0012 each lost their whole post-snapshot trail on this exact path,
|
|
59
|
+
* so a passing-then-reverted task looked like it had never been committed). So the
|
|
60
|
+
* trail is snapshotted before the reset and restored after — the revert undoes code,
|
|
61
|
+
* the audit log survives.
|
|
66
62
|
*
|
|
67
63
|
* Best-effort and never throws: a git failure is swallowed (the caller has
|
|
68
64
|
* already decided to keep the verified work; a failed reset only leaves the
|
package/dist/task/auto-commit.js
CHANGED
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
* outside a git repo, with nothing staged, or on any git error we report the
|
|
7
7
|
* reason and let the loop continue (the task already succeeded).
|
|
8
8
|
*/
|
|
9
|
+
import * as fsp from 'node:fs/promises';
|
|
10
|
+
import * as path from 'node:path';
|
|
9
11
|
import { runChildDefault } from '../shared/child-process.js';
|
|
12
|
+
/** The gate machinery's own state/forensic dir — the trail, debug logs, and per-run
|
|
13
|
+
* ledgers. Preserved verbatim across a revert (see gitDropLastCommit). */
|
|
14
|
+
const TRAIL_DIR = '.pi-tasks';
|
|
10
15
|
/**
|
|
11
16
|
* Does this git stderr describe a missing author identity? Seen live (mx5 run 4):
|
|
12
17
|
* the headless docker container has no HOME gitconfig, so EVERY per-task commit
|
|
@@ -136,16 +141,65 @@ export async function gitCommitAll(cwd, message, signal, spawnFn) {
|
|
|
136
141
|
* `git reset --hard HEAD~1` throws the enforce commit away and brings back the
|
|
137
142
|
* verified task commit underneath it.
|
|
138
143
|
*
|
|
139
|
-
* `reset --hard`
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* commit
|
|
143
|
-
*
|
|
144
|
+
* `reset --hard` targets the enforce pass's in-place SOURCE edits. But it must NOT
|
|
145
|
+
* rewind the forensic gate trail: `.pi-tasks/` is frequently TRACKED (the per-task
|
|
146
|
+
* snapshots stage it via `git add -A`), so a bare reset restores TASK_00NN.md to the
|
|
147
|
+
* snapshot commit and ERASES every trail line written after it — the "commit: task
|
|
148
|
+
* snapshot committed", "enforce(edit): …", and resolution lines (mx5 run 9:
|
|
149
|
+
* TASK_0007/0008/0012 each lost their whole post-snapshot trail on this exact path,
|
|
150
|
+
* so a passing-then-reverted task looked like it had never been committed). So the
|
|
151
|
+
* trail is snapshotted before the reset and restored after — the revert undoes code,
|
|
152
|
+
* the audit log survives.
|
|
144
153
|
*
|
|
145
154
|
* Best-effort and never throws: a git failure is swallowed (the caller has
|
|
146
155
|
* already decided to keep the verified work; a failed reset only leaves the
|
|
147
156
|
* enforce commit in place, which is surfaced as a warning).
|
|
148
157
|
*/
|
|
149
158
|
export async function gitDropLastCommit(cwd, signal, spawnFn) {
|
|
159
|
+
const trail = await snapshotTrail(cwd);
|
|
150
160
|
await git(cwd, ['reset', '--hard', 'HEAD~1'], signal, spawnFn);
|
|
161
|
+
await restoreTrail(cwd, trail);
|
|
162
|
+
}
|
|
163
|
+
/** Read every file under `.pi-tasks/` into memory (relative path → bytes). Best-effort:
|
|
164
|
+
* a missing dir or unreadable file is skipped, so this never blocks the revert. */
|
|
165
|
+
async function snapshotTrail(cwd) {
|
|
166
|
+
const out = new Map();
|
|
167
|
+
const root = path.join(cwd, TRAIL_DIR);
|
|
168
|
+
const walk = async (dir) => {
|
|
169
|
+
let entries;
|
|
170
|
+
try {
|
|
171
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
for (const e of entries) {
|
|
177
|
+
const full = path.join(dir, e.name);
|
|
178
|
+
if (e.isDirectory())
|
|
179
|
+
await walk(full);
|
|
180
|
+
else if (e.isFile()) {
|
|
181
|
+
try {
|
|
182
|
+
out.set(path.relative(cwd, full), await fsp.readFile(full));
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// unreadable — skip
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
await walk(root);
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
/** Re-materialise the snapshotted trail files, overwriting whatever the reset left. */
|
|
194
|
+
async function restoreTrail(cwd, trail) {
|
|
195
|
+
for (const [rel, buf] of trail) {
|
|
196
|
+
const full = path.join(cwd, rel);
|
|
197
|
+
try {
|
|
198
|
+
await fsp.mkdir(path.dirname(full), { recursive: true });
|
|
199
|
+
await fsp.writeFile(full, buf);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// best-effort restore
|
|
203
|
+
}
|
|
204
|
+
}
|
|
151
205
|
}
|
|
@@ -36,7 +36,22 @@ type BootOutcome = {
|
|
|
36
36
|
} | {
|
|
37
37
|
outcome: 'fail';
|
|
38
38
|
detail: string;
|
|
39
|
+
} | {
|
|
40
|
+
outcome: 'orphan-port';
|
|
41
|
+
detail: string;
|
|
42
|
+
port: number | null;
|
|
39
43
|
};
|
|
44
|
+
/** Injectable environment probes for the boot check's orphan-port recovery, so the
|
|
45
|
+
* reap-and-retry path is deterministically testable without a real listener. */
|
|
46
|
+
export interface BootDeps {
|
|
47
|
+
/** The pid + command line holding `port` in LISTEN, or null if none/unknown. */
|
|
48
|
+
findPortHolder?: (port: number) => {
|
|
49
|
+
pid: number;
|
|
50
|
+
command: string;
|
|
51
|
+
} | null;
|
|
52
|
+
/** Terminate a pid we attribute to ourselves; returns whether it was signalled. */
|
|
53
|
+
reap?: (pid: number) => boolean;
|
|
54
|
+
}
|
|
40
55
|
/**
|
|
41
56
|
* Exercise the start command ONCE, with no port/URL/framework knowledge — the
|
|
42
57
|
* command's own fate within the grace window decides:
|
|
@@ -63,5 +78,5 @@ export declare function discoverGateCommandLabels(cwd: string): string[];
|
|
|
63
78
|
* the start command — whole-repo, verbatim, unaided. Deterministic (no model).
|
|
64
79
|
* First real failure wins.
|
|
65
80
|
*/
|
|
66
|
-
export declare function runFinalIntegrationGate(cwd: string, timeoutMs?: number, bootGraceMs?: number): Promise<FinalGateOutcome>;
|
|
81
|
+
export declare function runFinalIntegrationGate(cwd: string, timeoutMs?: number, bootGraceMs?: number, bootDeps?: BootDeps): Promise<FinalGateOutcome>;
|
|
67
82
|
export {};
|
package/dist/task/final-gate.js
CHANGED
|
@@ -167,6 +167,69 @@ export function discoverBootCommand(cwd) {
|
|
|
167
167
|
}
|
|
168
168
|
return null;
|
|
169
169
|
}
|
|
170
|
+
/** Recognise an "address already in use" bind failure across runtimes (Node
|
|
171
|
+
* EADDRINUSE, Bun "Is port N in use?", Go "address already in use", generic). */
|
|
172
|
+
function isAddressInUse(text) {
|
|
173
|
+
return /EADDRINUSE|address already in use|address in use|port \d+ (?:is |already )?in use/i.test(text);
|
|
174
|
+
}
|
|
175
|
+
/** Best-effort port number from a bind-failure message, for the diagnosis line. The
|
|
176
|
+
* digit run ends on any non-digit (a `(?!\d)` lookahead, NOT `\b`): runtimes often
|
|
177
|
+
* print ":3000" flush against the next token with no separating space/newline
|
|
178
|
+
* ("…:3000error: script exited"), where a trailing `\b` would never match. */
|
|
179
|
+
function extractPort(text) {
|
|
180
|
+
const m = /(?:port|:)\s*(\d{2,5})(?!\d)/i.exec(text) ?? /\baddress[^0-9]*(\d{2,5})(?!\d)/i.exec(text);
|
|
181
|
+
if (!m)
|
|
182
|
+
return null;
|
|
183
|
+
const n = Number(m[1]);
|
|
184
|
+
return n > 0 && n < 65536 ? n : null;
|
|
185
|
+
}
|
|
186
|
+
/** Default port-holder lookup: `lsof` first, then `ss`/`fuser`. Returns null on any
|
|
187
|
+
* failure (the diagnosis then omits the pid — never blocks). */
|
|
188
|
+
function defaultFindPortHolder(port) {
|
|
189
|
+
try {
|
|
190
|
+
const t = spawnSync('lsof', ['-i', `:${port}`, '-sTCP:LISTEN', '-t', '-P', '-n'], {
|
|
191
|
+
encoding: 'utf8',
|
|
192
|
+
timeout: 4000
|
|
193
|
+
});
|
|
194
|
+
const pid = Number((t.stdout ?? '').split('\n')[0]?.trim());
|
|
195
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
196
|
+
return null;
|
|
197
|
+
const ps = spawnSync('ps', ['-o', 'args=', '-p', String(pid)], {
|
|
198
|
+
encoding: 'utf8',
|
|
199
|
+
timeout: 4000
|
|
200
|
+
});
|
|
201
|
+
return { pid, command: (ps.stdout ?? '').trim() || `pid ${pid}` };
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function defaultReap(pid) {
|
|
208
|
+
try {
|
|
209
|
+
process.kill(pid, 'SIGTERM');
|
|
210
|
+
setTimeout(() => {
|
|
211
|
+
try {
|
|
212
|
+
process.kill(pid, 'SIGKILL');
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// already gone
|
|
216
|
+
}
|
|
217
|
+
}, 1_000).unref();
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Does the port holder look like one of OUR gate children (a `dev`/`start` run of
|
|
225
|
+
* the discovered boot command)? Only then do we reap it — never a foreign process
|
|
226
|
+
* the user happens to be running. */
|
|
227
|
+
function holderIsOurs(command, boot) {
|
|
228
|
+
const script = boot[1][boot[1].length - 1] ?? ''; // 'start' | 'dev' | 'run'
|
|
229
|
+
const c = command.toLowerCase();
|
|
230
|
+
return ((c.includes('bun') || c.includes('node') || c.includes('npm') || c.includes('make'))
|
|
231
|
+
&& (c.includes(` ${script}`) || c.endsWith(script)));
|
|
232
|
+
}
|
|
170
233
|
/**
|
|
171
234
|
* Exercise the start command ONCE, with no port/URL/framework knowledge — the
|
|
172
235
|
* command's own fate within the grace window decides:
|
|
@@ -232,6 +295,17 @@ export function runBootCheck(cwd, [bin, args], graceMs = 10_000) {
|
|
|
232
295
|
}
|
|
233
296
|
const what = status !== null ? `exited ${status}` : `was killed by ${signal}`;
|
|
234
297
|
const tail = outputTail(out, err);
|
|
298
|
+
// A bind collision is an environment condition, not an app defect — hand
|
|
299
|
+
// it back distinctly so the gate can reap our own orphan and retry rather
|
|
300
|
+
// than reporting the app "crashed" (mx5 run 9 item 3).
|
|
301
|
+
if (isAddressInUse(`${out}\n${err}`)) {
|
|
302
|
+
settle({
|
|
303
|
+
outcome: 'orphan-port',
|
|
304
|
+
port: extractPort(`${out}\n${err}`),
|
|
305
|
+
detail: `${what}${tail ? ` — ${tail}` : ''}`
|
|
306
|
+
});
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
235
309
|
settle({ outcome: 'fail', detail: `${what}${tail ? ` — ${tail}` : ''}` });
|
|
236
310
|
});
|
|
237
311
|
});
|
|
@@ -283,13 +357,33 @@ function runGateCommand(cwd, [bin, args], timeoutMs) {
|
|
|
283
357
|
}
|
|
284
358
|
return { outcome: 'pass' };
|
|
285
359
|
}
|
|
360
|
+
/**
|
|
361
|
+
* Boot check hit an address-in-use bind failure. If the port is held by one of OUR
|
|
362
|
+
* own orphaned gate children (a `dev`/`start` run), reap it and retry the boot once
|
|
363
|
+
* so the app gets a fair launch; otherwise leave the (foreign) holder alone and let
|
|
364
|
+
* the caller emit the harness diagnosis. Never reaps a process we cannot attribute
|
|
365
|
+
* to ourselves.
|
|
366
|
+
*/
|
|
367
|
+
async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps) {
|
|
368
|
+
if (first.port === null)
|
|
369
|
+
return first;
|
|
370
|
+
const holder = (deps.findPortHolder ?? defaultFindPortHolder)(first.port);
|
|
371
|
+
if (!holder || !holderIsOurs(holder.command, boot))
|
|
372
|
+
return first;
|
|
373
|
+
const reaped = (deps.reap ?? defaultReap)(holder.pid);
|
|
374
|
+
if (!reaped)
|
|
375
|
+
return first;
|
|
376
|
+
// Give the OS a moment to release the socket, then re-run the boot once.
|
|
377
|
+
await new Promise(r => setTimeout(r, 1_500));
|
|
378
|
+
return runBootCheck(cwd, boot, bootGraceMs);
|
|
379
|
+
}
|
|
286
380
|
/**
|
|
287
381
|
* Run the final gate: static analysis first, then the lockfile consistency
|
|
288
382
|
* checks, then the discovered integration commands, then one boot exercise of
|
|
289
383
|
* the start command — whole-repo, verbatim, unaided. Deterministic (no model).
|
|
290
384
|
* First real failure wins.
|
|
291
385
|
*/
|
|
292
|
-
export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGraceMs = 10_000) {
|
|
386
|
+
export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGraceMs = 10_000, bootDeps = {}) {
|
|
293
387
|
const stat = runRepoHealthCheck(cwd);
|
|
294
388
|
// ACCEPT-debt re-check (mx5 run 4 B3 / run 8 TASK_0012): read the ledger of tasks
|
|
295
389
|
// the user accepted despite a verify-FAIL and re-check each against the current
|
|
@@ -339,10 +433,25 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
339
433
|
}
|
|
340
434
|
if (boot) {
|
|
341
435
|
const label = `${boot[0]} ${boot[1].join(' ')}`;
|
|
342
|
-
|
|
436
|
+
let b = await runBootCheck(cwd, boot, bootGraceMs);
|
|
437
|
+
if (b.outcome === 'orphan-port') {
|
|
438
|
+
b = await recoverOrphanPort(cwd, boot, b, bootGraceMs, bootDeps);
|
|
439
|
+
}
|
|
343
440
|
if (b.outcome === 'fail') {
|
|
344
441
|
return withDebts({ ok: false, reason: `boot check: \`${label}\` ${b.detail}` });
|
|
345
442
|
}
|
|
443
|
+
if (b.outcome === 'orphan-port') {
|
|
444
|
+
// Could not clear the port. Distinct HARNESS diagnosis, never a bare app
|
|
445
|
+
// FAIL: name the port and (when known) the process squatting on it.
|
|
446
|
+
const holder = b.port !== null ? (bootDeps.findPortHolder ?? defaultFindPortHolder)(b.port) : null;
|
|
447
|
+
const who = holder ? ` — held by an orphaned process (pid ${holder.pid}: ${holder.command})`
|
|
448
|
+
: b.port !== null ? ` — port ${b.port} is held by another process`
|
|
449
|
+
: '';
|
|
450
|
+
return withDebts({
|
|
451
|
+
ok: false,
|
|
452
|
+
reason: `boot check: \`${label}\` could not bind: orphaned process / port already in use${who} (harness condition, not an app fault)`
|
|
453
|
+
});
|
|
454
|
+
}
|
|
346
455
|
if (b.outcome === 'pass')
|
|
347
456
|
ran.push(label);
|
|
348
457
|
}
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -242,8 +242,17 @@ export function buildGateDeps(params) {
|
|
|
242
242
|
const rec = await reconcileGitState(cwd2, guardSnapshot, sig);
|
|
243
243
|
lastGuardReconcile = rec;
|
|
244
244
|
if (rec.mutated) {
|
|
245
|
-
|
|
246
|
-
|
|
245
|
+
// Distinguish the two outcomes in the trail: a tainting
|
|
246
|
+
// mutation (graded work altered → verdict will be
|
|
247
|
+
// discarded) vs benign cleanup (test-runner output the
|
|
248
|
+
// child left behind → verdict stands).
|
|
249
|
+
const label = rec.verdictTainted ?
|
|
250
|
+
'child mutated graded state (verdict discarded)'
|
|
251
|
+
: 'cleaned child test-runner artifacts (verdict kept)';
|
|
252
|
+
log(`=== ${kind} GIT-STATE GUARD — ${label}; restored: ${rec.actions.join('; ')} ===`);
|
|
253
|
+
if (rec.verdictTainted) {
|
|
254
|
+
gateCtx.ui.notify(`${taskTitle}: ${kind} child mutated repo state — restored (${rec.actions.join('; ').slice(0, 140)}).`, 'warning');
|
|
255
|
+
}
|
|
247
256
|
}
|
|
248
257
|
}
|
|
249
258
|
}
|
|
@@ -419,9 +428,13 @@ export function buildGateDeps(params) {
|
|
|
419
428
|
return collectChangedFiles(cwd2, signal).then(files => findProhibitionViolations(banned, files));
|
|
420
429
|
},
|
|
421
430
|
// Git-state guard result of the most recent child run: a verdict
|
|
422
|
-
// computed on a tree the child itself mutated is discarded
|
|
423
|
-
//
|
|
424
|
-
|
|
431
|
+
// computed on a tree the child itself mutated is discarded — but ONLY
|
|
432
|
+
// when the mutation touched graded state (verdictTainted). A child
|
|
433
|
+
// that merely left test-runner output behind (test-results/,
|
|
434
|
+
// playwright-report/ …) judged an equivalent tree; its verdict stands
|
|
435
|
+
// and the artifacts were still cleaned (mx5 run 9 lost 7 verdicts this
|
|
436
|
+
// way — see git-state-guard.ts).
|
|
437
|
+
mutationCheck: () => lastGuardReconcile?.verdictTainted ?
|
|
425
438
|
{ mutated: true, detail: lastGuardReconcile.actions.join('; ') }
|
|
426
439
|
: { mutated: false, detail: '' },
|
|
427
440
|
// Per-run environment-facts cache under .pi-tasks/ (survives
|
|
@@ -11,8 +11,22 @@ export interface GitStateSnapshot {
|
|
|
11
11
|
treeSha: string | null;
|
|
12
12
|
}
|
|
13
13
|
export interface ReconcileResult {
|
|
14
|
-
/** true → the child moved repo state; every detected move was restored.
|
|
14
|
+
/** true → the child moved repo state; every detected move was restored. This
|
|
15
|
+
* covers benign moves too (test-runner output), so it drives logging/notify —
|
|
16
|
+
* NOT the verdict decision. Use `verdictTainted` for that. */
|
|
15
17
|
mutated: boolean;
|
|
18
|
+
/** true → the child changed *graded* state: a tracked-in-HEAD file was
|
|
19
|
+
* modified/deleted, HEAD/branch was moved, a stash was pushed, or an untracked
|
|
20
|
+
* non-artifact (source-shaped) file was modified/deleted. This is the real
|
|
21
|
+
* mutate-to-pass class; a verdict computed on such a tree is discarded.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately false for child-CREATED files and for modified/deleted untracked
|
|
24
|
+
* *test-runner artifacts* (test-results/, playwright-report/, coverage output,
|
|
25
|
+
* *.tsbuildinfo, .last-run.json …): a gate child that merely ran the suite and
|
|
26
|
+
* left its report behind judged a tree whose only difference from pre-run is
|
|
27
|
+
* regenerable output — discarding a 49-min verify over that is the F-class this
|
|
28
|
+
* splits off (mx5 run 9: 7 of 9 guard firings were pure test-results churn). */
|
|
29
|
+
verdictTainted: boolean;
|
|
16
30
|
/** Human-readable restore actions, for the debug log / notify / gate trail. */
|
|
17
31
|
actions: string[];
|
|
18
32
|
}
|
|
@@ -44,6 +44,24 @@ import * as path from 'node:path';
|
|
|
44
44
|
import { runChildDefault } from '../shared/child-process.js';
|
|
45
45
|
/** Keep the gate machinery's own artifacts out of the snapshot and the restore. */
|
|
46
46
|
const EXCLUDE_TASKS_DIR = ':(exclude).pi-tasks';
|
|
47
|
+
/**
|
|
48
|
+
* Untracked paths that are regenerable test/build OUTPUT, not graded source. A gate
|
|
49
|
+
* child creating or rewriting one of these has not mutated the work under judgement,
|
|
50
|
+
* so its verdict stands. Gitignored files never reach the snapshot (git add -A skips
|
|
51
|
+
* them); this list is for the ones a typical project leaves UNIGNORED — Playwright's
|
|
52
|
+
* `test-results/` and `playwright-report/` above all, the exact churn that discarded
|
|
53
|
+
* verify verdicts across mx5 run 9. Kept deliberately narrow: anything not matched
|
|
54
|
+
* here that a child modifies/deletes is treated as graded state (verdict-tainting).
|
|
55
|
+
*/
|
|
56
|
+
const ARTIFACT_PATTERNS = [
|
|
57
|
+
/^(?:test-results|playwright-report|coverage|\.nyc_output|dist|build|\.next|\.turbo|\.svelte-kit)\//,
|
|
58
|
+
/(?:^|\/)\.last-run\.json$/,
|
|
59
|
+
/\.tsbuildinfo$/
|
|
60
|
+
];
|
|
61
|
+
function isBenignArtifact(relPath) {
|
|
62
|
+
const p = relPath.replace(/\\/g, '/');
|
|
63
|
+
return ARTIFACT_PATTERNS.some(re => re.test(p));
|
|
64
|
+
}
|
|
47
65
|
function makeGit(cwd, signal, spawnFn) {
|
|
48
66
|
return async (args, env) => {
|
|
49
67
|
const r = await runChildDefault({ command: 'git', args, ...(env ? { env: { ...process.env, ...env } } : {}) }, cwd, signal, { mode: 'text' }, spawnFn);
|
|
@@ -99,44 +117,103 @@ export async function captureGitState(cwd, signal, spawnFn) {
|
|
|
99
117
|
treeSha: await captureWorktreeTree(git)
|
|
100
118
|
};
|
|
101
119
|
}
|
|
120
|
+
/** Paths tracked in the commit `headSha` points at — the "graded" codebase a gate
|
|
121
|
+
* child must not rewrite. Empty on any git error (the caller then treats every
|
|
122
|
+
* modified/deleted path as tracked, i.e. verdict-tainting — fail safe). */
|
|
123
|
+
async function trackedPathsAt(git, headSha) {
|
|
124
|
+
const r = await git(['ls-tree', '-r', '--name-only', headSha]);
|
|
125
|
+
if (r.exitCode !== 0)
|
|
126
|
+
return new Set();
|
|
127
|
+
return new Set(r.stdout
|
|
128
|
+
.split('\n')
|
|
129
|
+
.map(l => l.trim())
|
|
130
|
+
.filter(l => l.length > 0));
|
|
131
|
+
}
|
|
132
|
+
/** Cap on itemised path lines per class, so a suite that rewrites hundreds of report
|
|
133
|
+
* files cannot flood the gate trail. Beyond it, a single "…and N more" line. */
|
|
134
|
+
const ITEMIZE_CAP = 20;
|
|
135
|
+
function pushCapped(actions, verb, paths) {
|
|
136
|
+
const shown = paths.slice(0, ITEMIZE_CAP);
|
|
137
|
+
for (const p of shown)
|
|
138
|
+
actions.push(`${verb} ${p}`);
|
|
139
|
+
const extra = paths.length - shown.length;
|
|
140
|
+
if (extra > 0)
|
|
141
|
+
actions.push(`${verb} …and ${extra} more`);
|
|
142
|
+
}
|
|
102
143
|
/**
|
|
103
144
|
* Restore every file recorded in `beforeTree` (content + deletions) and remove
|
|
104
145
|
* files that exist in `afterTree` but not in `beforeTree` (files the child
|
|
105
146
|
* created). Uses a throwaway index seeded from the snapshot tree; `checkout-index
|
|
106
147
|
* -a -f` re-materialises the snapshot verbatim.
|
|
148
|
+
*
|
|
149
|
+
* Returns whether any restored change was *verdict-tainting* — a modified/deleted
|
|
150
|
+
* path that is tracked-in-HEAD or an untracked non-artifact (see isBenignArtifact).
|
|
151
|
+
* Creations and test-runner-artifact churn restore identically but do NOT taint.
|
|
152
|
+
* Each changed path is itemised (capped) so the gate trail says WHICH files moved.
|
|
107
153
|
*/
|
|
108
|
-
async function restoreWorktree(cwd, git, beforeTree, afterTree, actions) {
|
|
154
|
+
async function restoreWorktree(cwd, git, beforeTree, afterTree, tracked, actions) {
|
|
109
155
|
const tmpIndex = path.join(os.tmpdir(), `pi-task-guard-restore-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
110
156
|
const env = { GIT_INDEX_FILE: tmpIndex };
|
|
157
|
+
let tainted = false;
|
|
111
158
|
try {
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
159
|
+
// Classify every path the child changed BEFORE touching the tree, so the
|
|
160
|
+
// itemised trail and the taint decision are computed from one diff.
|
|
161
|
+
const created = [];
|
|
162
|
+
const artifactChanges = [];
|
|
163
|
+
const gradedModified = [];
|
|
164
|
+
const gradedDeleted = [];
|
|
165
|
+
const status = await git(['diff-tree', '-r', '--name-status', beforeTree, afterTree]);
|
|
166
|
+
if (status.exitCode === 0) {
|
|
167
|
+
for (const line of status.stdout.split('\n')) {
|
|
168
|
+
const trimmed = line.trim();
|
|
169
|
+
if (trimmed.length === 0)
|
|
170
|
+
continue;
|
|
171
|
+
// "M\tpath", "A\tpath", "D\tpath", "T\tpath" — no -M, so renames show
|
|
172
|
+
// as a D + an A pair; both get classified on their own merits.
|
|
173
|
+
const tab = trimmed.indexOf('\t');
|
|
174
|
+
if (tab < 0)
|
|
175
|
+
continue;
|
|
176
|
+
const code = trimmed[0];
|
|
177
|
+
const name = trimmed.slice(tab + 1).trim();
|
|
125
178
|
if (name.length === 0)
|
|
126
179
|
continue;
|
|
127
|
-
|
|
128
|
-
|
|
180
|
+
if (code === 'A') {
|
|
181
|
+
created.push(name);
|
|
182
|
+
}
|
|
183
|
+
else if (isBenignArtifact(name) && !tracked.has(name)) {
|
|
184
|
+
// Untracked, regenerable test/build output — not graded work.
|
|
185
|
+
artifactChanges.push(name);
|
|
186
|
+
}
|
|
187
|
+
else if (code === 'D') {
|
|
188
|
+
gradedDeleted.push(name);
|
|
189
|
+
tainted = true;
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
// M, T, and anything else touching a graded (tracked or
|
|
193
|
+
// source-shaped untracked) path is the mutate-to-pass class.
|
|
194
|
+
gradedModified.push(name);
|
|
195
|
+
tainted = true;
|
|
196
|
+
}
|
|
129
197
|
}
|
|
130
198
|
}
|
|
199
|
+
// Files the child created — delete them BEFORE checkout so a restore failure
|
|
200
|
+
// cannot leave both halves stale.
|
|
201
|
+
for (const name of created) {
|
|
202
|
+
await fsp.rm(path.join(cwd, name), { force: true }).catch(() => { });
|
|
203
|
+
}
|
|
204
|
+
pushCapped(actions, 'removed child-created file', created);
|
|
205
|
+
pushCapped(actions, 'restored modified file', gradedModified);
|
|
206
|
+
pushCapped(actions, 'restored deleted file', gradedDeleted);
|
|
207
|
+
pushCapped(actions, 'restored test-runner artifact', artifactChanges);
|
|
131
208
|
const read = await git(['read-tree', beforeTree], env);
|
|
132
209
|
if (read.exitCode !== 0) {
|
|
133
210
|
actions.push('worktree restore FAILED (read-tree)');
|
|
134
|
-
return;
|
|
211
|
+
return { tainted };
|
|
135
212
|
}
|
|
136
213
|
const co = await git(['checkout-index', '-a', '-f'], env);
|
|
137
|
-
|
|
138
|
-
'
|
|
139
|
-
|
|
214
|
+
if (co.exitCode !== 0)
|
|
215
|
+
actions.push('worktree restore FAILED (checkout-index)');
|
|
216
|
+
return { tainted };
|
|
140
217
|
}
|
|
141
218
|
finally {
|
|
142
219
|
await fsp.rm(tmpIndex, { force: true }).catch(() => { });
|
|
@@ -157,9 +234,12 @@ async function restoreWorktree(cwd, git, beforeTree, afterTree, actions) {
|
|
|
157
234
|
*/
|
|
158
235
|
export async function reconcileGitState(cwd, before, signal, spawnFn) {
|
|
159
236
|
if (!before.ok)
|
|
160
|
-
return { mutated: false, actions: [] };
|
|
237
|
+
return { mutated: false, verdictTainted: false, actions: [] };
|
|
161
238
|
const git = makeGit(cwd, signal, spawnFn);
|
|
162
239
|
const actions = [];
|
|
240
|
+
// A child that moved HEAD/branch or pushed a stash swallowed graded work — that
|
|
241
|
+
// is unambiguously verdict-tainting. Worktree classification adds to this.
|
|
242
|
+
let tainted = false;
|
|
163
243
|
// 1. HEAD / branch.
|
|
164
244
|
const head = await git(['rev-parse', '-q', '--verify', 'HEAD']);
|
|
165
245
|
const branch = await git(['symbolic-ref', '-q', 'HEAD']);
|
|
@@ -171,12 +251,15 @@ export async function reconcileGitState(cwd, before, signal, spawnFn) {
|
|
|
171
251
|
actions.push(co.exitCode === 0 ?
|
|
172
252
|
`checked HEAD back out to ${target}`
|
|
173
253
|
: `HEAD restore FAILED (checkout ${target})`);
|
|
254
|
+
tainted = true;
|
|
174
255
|
}
|
|
175
256
|
// 2. Worktree content.
|
|
176
257
|
if (before.treeSha) {
|
|
177
258
|
const afterTree = await captureWorktreeTree(git);
|
|
178
259
|
if (afterTree && afterTree !== before.treeSha) {
|
|
179
|
-
await
|
|
260
|
+
const tracked = await trackedPathsAt(git, before.headSha);
|
|
261
|
+
const { tainted: worktreeTainted } = await restoreWorktree(cwd, git, before.treeSha, afterTree, tracked, actions);
|
|
262
|
+
tainted = tainted || worktreeTainted;
|
|
180
263
|
}
|
|
181
264
|
}
|
|
182
265
|
// 3. Stash entries the child pushed. Drop stash@{0} until the ref matches the
|
|
@@ -189,6 +272,9 @@ export async function reconcileGitState(cwd, before, signal, spawnFn) {
|
|
|
189
272
|
};
|
|
190
273
|
let stash = await stashNow();
|
|
191
274
|
if (stash !== before.stashSha) {
|
|
275
|
+
// A child that pushed/popped a stash moved graded work in or out of the tree
|
|
276
|
+
// (mx5 run 6's stash-and-abandon) — always verdict-tainting.
|
|
277
|
+
tainted = true;
|
|
192
278
|
if (before.stashSha === null || (await stashContains(git, stash, before.stashSha))) {
|
|
193
279
|
let dropped = 0;
|
|
194
280
|
while (stash !== before.stashSha && stash !== null && dropped < 10) {
|
|
@@ -206,7 +292,7 @@ export async function reconcileGitState(cwd, before, signal, spawnFn) {
|
|
|
206
292
|
actions.push('stash ref changed in a way that cannot be undone (entry popped/dropped)');
|
|
207
293
|
}
|
|
208
294
|
}
|
|
209
|
-
return { mutated: actions.length > 0, actions };
|
|
295
|
+
return { mutated: actions.length > 0, verdictTainted: tainted, actions };
|
|
210
296
|
}
|
|
211
297
|
/** Is `ancestorStash` still reachable in the stash reflog chain at `tipSha`? Used to
|
|
212
298
|
* tell "child pushed on top" (droppable) from "child popped/dropped ours" (not). */
|
package/dist/task/phases.js
CHANGED
|
@@ -11,6 +11,7 @@ import { findPhantomImports, formatApiCorrections, rewritePhantomSpecifiers } fr
|
|
|
11
11
|
import { search as defaultSearch } from '../workers/search-core.js';
|
|
12
12
|
import { extractEnrichTargets } from './enrichment.js';
|
|
13
13
|
import { isIntegrationUnknown } from './unknown-routing.js';
|
|
14
|
+
import { extractUserDirectives, preserveDirectivesBlock, enforceDirectives } from './user-directives.js';
|
|
14
15
|
import { getFileInventory } from './file-inventory.js';
|
|
15
16
|
import { buildOrientation, orientationTier } from './orientation.js';
|
|
16
17
|
import { getConfig } from '../config/config.js';
|
|
@@ -130,7 +131,14 @@ export async function phaseContractsBlock(deps) {
|
|
|
130
131
|
export const phaseRefine = async (deps, raw, planContext) => {
|
|
131
132
|
const existingFiles = await refineExistingFilesBlock(deps).catch(() => '');
|
|
132
133
|
const contracts = await phaseContractsBlock(deps);
|
|
133
|
-
|
|
134
|
+
// Imperative tool directives the user wrote into the RAW prompt ("via web
|
|
135
|
+
// search", "fetch <url>"). Refine paraphrases the task and a weak model drops
|
|
136
|
+
// these some of the time (mx5 run 9: "via web search" vanished, the whole run
|
|
137
|
+
// made 0 search calls). Hand them to refine as a MUST-PRESERVE block (belt) and
|
|
138
|
+
// re-check the output below (lever). Empty on an ordinary prompt → refine unchanged.
|
|
139
|
+
const directives = extractUserDirectives(raw);
|
|
140
|
+
const directivesBlock = preserveDirectivesBlock(directives);
|
|
141
|
+
const refined = await runPhaseWithLoopGuard(deps, 'refine', 'read', hint => prependHint(hint, appendNoThink(REFINE_PROMPT(raw, planContext, existingFiles, contracts, directivesBlock))),
|
|
134
142
|
// refine's deliverable is a 4-section text rewrite that never strictly
|
|
135
143
|
// needs a successful read — on a test-writing task against a large
|
|
136
144
|
// existing codebase the model over-explores (re-reads source hunting for
|
|
@@ -139,6 +147,14 @@ export const phaseRefine = async (deps, raw, planContext) => {
|
|
|
139
147
|
// refine looped 3×/resume forever; the deliverable was always producible
|
|
140
148
|
// from the title + design doc alone.
|
|
141
149
|
{ degradeOnExhaustion: true });
|
|
150
|
+
// Deterministic backstop: if the refined spec still dropped a directive, append
|
|
151
|
+
// it verbatim rather than trusting the paraphrase. No model in this path.
|
|
152
|
+
const { text, appended } = enforceDirectives(refined, directives);
|
|
153
|
+
if (appended.length > 0) {
|
|
154
|
+
deps.logDebug?.(`refine: re-attached ${appended.length} dropped user directive(s): `
|
|
155
|
+
+ appended.map(d => d.kind).join(', '));
|
|
156
|
+
}
|
|
157
|
+
return text;
|
|
142
158
|
};
|
|
143
159
|
export async function phaseVerifyTooling(deps, research) {
|
|
144
160
|
const commands = extractToolingCommands(research);
|
package/dist/task/prompts.d.ts
CHANGED
|
@@ -47,7 +47,7 @@ export declare const COMPRESS_LABEL_PROMPT: (title: string, maxChars: number) =>
|
|
|
47
47
|
* "Scaffold …" title re-expands the entire design into one task (validated: a real
|
|
48
48
|
* /task-auto run implemented all 24 steps under step 1).
|
|
49
49
|
*/
|
|
50
|
-
declare const REFINE_PROMPT: (raw: string, planContext?: string, existingFiles?: string, contracts?: string) => string;
|
|
50
|
+
declare const REFINE_PROMPT: (raw: string, planContext?: string, existingFiles?: string, contracts?: string, directives?: string) => string;
|
|
51
51
|
declare const RESEARCH_READ_ONLY_CONSTRAINT = "IMPORTANT: You are ONLY allowed to READ. Do NOT create, modify, or delete any files. Use the read, grep, find, and ls tools to inspect the repo.";
|
|
52
52
|
declare const RESEARCH_FILES_PROMPT: (refined: string) => string;
|
|
53
53
|
declare const RESEARCH_APIS_PROMPT: (refined: string, filesMap?: string) => string;
|
package/dist/task/prompts.js
CHANGED
|
@@ -61,7 +61,7 @@ ${title}`;
|
|
|
61
61
|
* "Scaffold …" title re-expands the entire design into one task (validated: a real
|
|
62
62
|
* /task-auto run implemented all 24 steps under step 1).
|
|
63
63
|
*/
|
|
64
|
-
const REFINE_PROMPT = (raw, planContext, existingFiles, contracts) => `${planContext ? planContext + '\n\n---\n\n' : ''}You receive a user's task description for an AI coding agent. Rewrite it to be unambiguous and actionable.
|
|
64
|
+
const REFINE_PROMPT = (raw, planContext, existingFiles, contracts, directives) => `${planContext ? planContext + '\n\n---\n\n' : ''}You receive a user's task description for an AI coding agent. Rewrite it to be unambiguous and actionable.
|
|
65
65
|
|
|
66
66
|
Output structure (four sections, exact headings, in this order):
|
|
67
67
|
|
|
@@ -88,7 +88,7 @@ Rules:
|
|
|
88
88
|
- If the task references a design/spec document (an @-path or a named spec file), READ it and treat it as authoritative. Carry its concrete schema verbatim into GOAL/CONSTRAINTS — table and column names, types, endpoint methods and paths, enum values. The task title is only a pointer into that spec: where the title and the spec disagree, follow the spec, and never introduce a table, column, endpoint, or dependency the spec does not define.
|
|
89
89
|
- CITE interface WIRING, do NOT synthesize it. A wiring specific — how modules/endpoints/files connect (a mount prefix, a route/mount table, a module→path mapping, an exported function/type signature, a file or module layout) — must be citable from the design or the CROSS-SLICE CONTRACTS. The design often pins the interface FACTS (the exact endpoint paths, exported names, layouts) WITHOUT stating the wiring that produces them; when it does, any wiring you write MUST reproduce those pinned facts EXACTLY. Do NOT infer a "uniform" or "tidy" pattern from them — e.g. do not assume one module maps to one mount prefix when the design's pinned facts for that module do not all sit under a single prefix (that exact inference is a seam bug: the consumers follow the pinned facts, the assembly follows your invented pattern, and the seam ships broken). If the design pins neither the fact nor the wiring, leave the detail unspecified rather than inventing a specific.
|
|
90
90
|
- Do not output any preamble, commentary, or markdown headings beyond the four sections above.
|
|
91
|
-
${contracts && contracts.trim() ? `\n${contracts.trim()}\n` : ''}${existingFiles && existingFiles.trim() ? `\n${existingFiles.trim()}\n` : ''}
|
|
91
|
+
${directives && directives.trim() ? `\n${directives.trim()}\n` : ''}${contracts && contracts.trim() ? `\n${contracts.trim()}\n` : ''}${existingFiles && existingFiles.trim() ? `\n${existingFiles.trim()}\n` : ''}
|
|
92
92
|
Task: ${raw}`;
|
|
93
93
|
// ─── Research fan-out prompts ─────────────────────────────────────────────────
|
|
94
94
|
const RESEARCH_READ_ONLY_CONSTRAINT = `IMPORTANT: You are ONLY allowed to READ. Do NOT create, modify, or delete any files. Use the read, grep, find, and ls tools to inspect the repo.`;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* user-directives — deterministic extraction of the imperative tool/method
|
|
3
|
+
* directives a user writes into a raw task prompt, so a downstream rewrite (refine)
|
|
4
|
+
* cannot silently drop them.
|
|
5
|
+
*
|
|
6
|
+
* The failure this closes (mx5 run 9, validated): the raw prompt was
|
|
7
|
+
* "Research Playwright best practices VIA WEB SEARCH — focus on E2E testing…"
|
|
8
|
+
* Refine rewrote the task well in every other respect (it correctly killed a wrong
|
|
9
|
+
* "Next.js" framing) but the "via web search" instruction vanished from the refined
|
|
10
|
+
* spec entirely — and the whole 2-run session then made ZERO pi-worker-search and
|
|
11
|
+
* ZERO pi-worker-fetch calls. A user who explicitly asks for web search must get
|
|
12
|
+
* web search; the model's paraphrase is not allowed to quietly demote it to
|
|
13
|
+
* "research from local files".
|
|
14
|
+
*
|
|
15
|
+
* The mechanism is the lever, the prompt is the belt: refine is HANDED the
|
|
16
|
+
* directives as a MUST-PRESERVE block (prompt), and — because a weak model still
|
|
17
|
+
* drops them some fraction of the time — the refined text is deterministically
|
|
18
|
+
* re-checked afterwards and the directive is APPENDED verbatim if it went missing
|
|
19
|
+
* (mechanism). Extraction is conservative: only concrete tool/method directives
|
|
20
|
+
* (web search, fetch <url>) are recognised, and a negated mention ("do NOT use web
|
|
21
|
+
* search") is ignored, so a prompt that never asked for a tool gets nothing added.
|
|
22
|
+
*/
|
|
23
|
+
export type UserDirectiveKind = 'web-search' | 'fetch-url';
|
|
24
|
+
export interface UserDirective {
|
|
25
|
+
kind: UserDirectiveKind;
|
|
26
|
+
/** The canonical MUST line threaded into refine and appended as a backstop. */
|
|
27
|
+
must: string;
|
|
28
|
+
/** Regexes that, if ANY matches the refined text, prove the directive survived. */
|
|
29
|
+
survives: RegExp[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The imperative tool/method directives present in a raw user prompt. Deterministic
|
|
33
|
+
* and side-effect free. Deduped by kind (a prompt that says "web search" twice yields
|
|
34
|
+
* one directive). Empty when the prompt names no concrete tool directive.
|
|
35
|
+
*/
|
|
36
|
+
export declare function extractUserDirectives(raw: string): UserDirective[];
|
|
37
|
+
/** Does the refined text still carry `directive`? True when ANY survives-regex hits. */
|
|
38
|
+
export declare function directiveSurvives(refined: string, directive: UserDirective): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The MUST-PRESERVE block threaded into the refine prompt (the belt). Empty string
|
|
41
|
+
* when there are no directives, so refine is unchanged on an ordinary prompt.
|
|
42
|
+
*/
|
|
43
|
+
export declare function preserveDirectivesBlock(directives: UserDirective[]): string;
|
|
44
|
+
/**
|
|
45
|
+
* Backstop (the lever): if the refined spec dropped a directive, append it under a
|
|
46
|
+
* CONSTRAINTS-adjacent header so downstream phases still see it. Returns the possibly
|
|
47
|
+
* amended text and the list of directives that had to be force-appended (for logging).
|
|
48
|
+
*/
|
|
49
|
+
export declare function enforceDirectives(refined: string, directives: UserDirective[]): {
|
|
50
|
+
text: string;
|
|
51
|
+
appended: UserDirective[];
|
|
52
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* user-directives — deterministic extraction of the imperative tool/method
|
|
3
|
+
* directives a user writes into a raw task prompt, so a downstream rewrite (refine)
|
|
4
|
+
* cannot silently drop them.
|
|
5
|
+
*
|
|
6
|
+
* The failure this closes (mx5 run 9, validated): the raw prompt was
|
|
7
|
+
* "Research Playwright best practices VIA WEB SEARCH — focus on E2E testing…"
|
|
8
|
+
* Refine rewrote the task well in every other respect (it correctly killed a wrong
|
|
9
|
+
* "Next.js" framing) but the "via web search" instruction vanished from the refined
|
|
10
|
+
* spec entirely — and the whole 2-run session then made ZERO pi-worker-search and
|
|
11
|
+
* ZERO pi-worker-fetch calls. A user who explicitly asks for web search must get
|
|
12
|
+
* web search; the model's paraphrase is not allowed to quietly demote it to
|
|
13
|
+
* "research from local files".
|
|
14
|
+
*
|
|
15
|
+
* The mechanism is the lever, the prompt is the belt: refine is HANDED the
|
|
16
|
+
* directives as a MUST-PRESERVE block (prompt), and — because a weak model still
|
|
17
|
+
* drops them some fraction of the time — the refined text is deterministically
|
|
18
|
+
* re-checked afterwards and the directive is APPENDED verbatim if it went missing
|
|
19
|
+
* (mechanism). Extraction is conservative: only concrete tool/method directives
|
|
20
|
+
* (web search, fetch <url>) are recognised, and a negated mention ("do NOT use web
|
|
21
|
+
* search") is ignored, so a prompt that never asked for a tool gets nothing added.
|
|
22
|
+
*/
|
|
23
|
+
/** ~24 chars of lead-in before a match, scanned for a negation that flips intent. */
|
|
24
|
+
const NEGATION = /\b(?:no|not|never|without|don'?t|do not|avoid|skip)\b[^.?!]{0,24}$/i;
|
|
25
|
+
function isNegated(text, matchIndex) {
|
|
26
|
+
return NEGATION.test(text.slice(Math.max(0, matchIndex - 40), matchIndex));
|
|
27
|
+
}
|
|
28
|
+
// "web search", "search the web/internet", "search online" — the concrete
|
|
29
|
+
// external-research directive. Kept tight so ordinary prose ("search the codebase",
|
|
30
|
+
// "search for the function") never trips it: the object must be web/internet/online.
|
|
31
|
+
const WEB_SEARCH_RE = /\bweb[-\s]*search(?:es|ing)?\b|\bsearch(?:es|ing)?\s+(?:the\s+|on\s+the\s+|across\s+the\s+)?(?:web|internet|online)\b|\bsearch\s+online\b/i;
|
|
32
|
+
// "fetch https://…" / "fetch the page at https://…" — an explicit fetch directive
|
|
33
|
+
// naming a URL. The URL is captured so it can be preserved verbatim.
|
|
34
|
+
const FETCH_URL_RE = /\bfetch(?:es|ing)?\b[^.\n]{0,40}?(https?:\/\/[^\s)>"']+)/i;
|
|
35
|
+
const WEB_SEARCH_SURVIVES = [
|
|
36
|
+
/\bweb[-\s]*search/i,
|
|
37
|
+
/\bsearch(?:es|ing)?\s+(?:the\s+|on\s+the\s+|across\s+the\s+)?(?:web|internet|online)\b/i,
|
|
38
|
+
/\bsearch\s+online\b/i,
|
|
39
|
+
/pi-worker-search/i
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* The imperative tool/method directives present in a raw user prompt. Deterministic
|
|
43
|
+
* and side-effect free. Deduped by kind (a prompt that says "web search" twice yields
|
|
44
|
+
* one directive). Empty when the prompt names no concrete tool directive.
|
|
45
|
+
*/
|
|
46
|
+
export function extractUserDirectives(raw) {
|
|
47
|
+
const out = [];
|
|
48
|
+
const ws = WEB_SEARCH_RE.exec(raw);
|
|
49
|
+
if (ws && !isNegated(raw, ws.index)) {
|
|
50
|
+
out.push({
|
|
51
|
+
kind: 'web-search',
|
|
52
|
+
must: 'MUST use live web search (via pi-worker-search) — the user explicitly asked to search the web. Do NOT restrict this task to local files or pi-worker-docs only; external web research is a required part of the deliverable.',
|
|
53
|
+
survives: WEB_SEARCH_SURVIVES
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const fu = FETCH_URL_RE.exec(raw);
|
|
57
|
+
if (fu && !isNegated(raw, fu.index)) {
|
|
58
|
+
const url = fu[1];
|
|
59
|
+
out.push({
|
|
60
|
+
kind: 'fetch-url',
|
|
61
|
+
must: `MUST fetch ${url} (via pi-worker-fetch) — the user explicitly asked to fetch this URL; keep it named in the spec.`,
|
|
62
|
+
survives: [/pi-worker-fetch/i, new RegExp(escapeRegExp(url), 'i'), /\bfetch\b/i]
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
function escapeRegExp(s) {
|
|
68
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
69
|
+
}
|
|
70
|
+
/** Does the refined text still carry `directive`? True when ANY survives-regex hits. */
|
|
71
|
+
export function directiveSurvives(refined, directive) {
|
|
72
|
+
return directive.survives.some(re => re.test(refined));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The MUST-PRESERVE block threaded into the refine prompt (the belt). Empty string
|
|
76
|
+
* when there are no directives, so refine is unchanged on an ordinary prompt.
|
|
77
|
+
*/
|
|
78
|
+
export function preserveDirectivesBlock(directives) {
|
|
79
|
+
if (directives.length === 0)
|
|
80
|
+
return '';
|
|
81
|
+
const lines = directives.map(d => `- ${d.must}`).join('\n');
|
|
82
|
+
return ('USER TOOL DIRECTIVES — MUST PRESERVE (the raw task explicitly names how to do the '
|
|
83
|
+
+ 'work; carry each into GOAL/CONSTRAINTS verbatim in intent — never drop or soften '
|
|
84
|
+
+ `it to a local-only paraphrase):\n${lines}`);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Backstop (the lever): if the refined spec dropped a directive, append it under a
|
|
88
|
+
* CONSTRAINTS-adjacent header so downstream phases still see it. Returns the possibly
|
|
89
|
+
* amended text and the list of directives that had to be force-appended (for logging).
|
|
90
|
+
*/
|
|
91
|
+
export function enforceDirectives(refined, directives) {
|
|
92
|
+
const appended = directives.filter(d => !directiveSurvives(refined, d));
|
|
93
|
+
if (appended.length === 0)
|
|
94
|
+
return { text: refined, appended };
|
|
95
|
+
const block = appended.map(d => `- ${d.must}`).join('\n');
|
|
96
|
+
const text = `${refined.trimEnd()}\n\nUSER TOOL DIRECTIVES (preserved from the raw prompt):\n${block}\n`;
|
|
97
|
+
return { text, appended };
|
|
98
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.12",
|
|
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",
|