@mjasnikovs/pi-task 0.18.25 → 0.18.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/config.d.ts +29 -0
- package/dist/config/config.js +25 -1
- package/dist/config/register.js +23 -2
- package/dist/index.js +2 -0
- package/dist/task/api-synthesis.d.ts +48 -0
- package/dist/task/api-synthesis.js +155 -0
- package/dist/task/command-watchdog.d.ts +67 -0
- package/dist/task/command-watchdog.js +114 -0
- package/dist/task/final-gate.js +6 -0
- package/dist/task/phases.js +85 -13
- package/dist/task/prompts.js +3 -0
- package/dist/task/verify-quality.d.ts +26 -0
- package/dist/task/verify-quality.js +182 -0
- package/package.json +1 -1
package/dist/config/config.d.ts
CHANGED
|
@@ -42,7 +42,36 @@ export interface PiTaskConfig {
|
|
|
42
42
|
* /task-config, which enumerates the currently installed extensions.
|
|
43
43
|
*/
|
|
44
44
|
extensionWhitelist: string[];
|
|
45
|
+
/**
|
|
46
|
+
* Wall-clock ceiling (ms) on a SINGLE tool execution in the MAIN session
|
|
47
|
+
* before the command watchdog cancels it and reminds the model to set its
|
|
48
|
+
* own timeout. Local models routinely run a command that never returns
|
|
49
|
+
* (e.g. `godot --headless` with no timeout, a dev server, a hung test) and
|
|
50
|
+
* the run wedges until the user manually aborts. pi's bash tool has an
|
|
51
|
+
* OPTIONAL timeout with NO default (bash.js), so a command the model didn't
|
|
52
|
+
* bound runs forever; this is the missing default, enforced from the host
|
|
53
|
+
* side via ctx.abort() (which kills the tool's whole process tree) plus an
|
|
54
|
+
* auto-reminder turn. Tool-agnostic: it arms on any tool execution, though
|
|
55
|
+
* in practice only bash runs long enough to trip it. 0 = off.
|
|
56
|
+
* DEFAULT 15 min: long enough for a real build/test suite, short enough that
|
|
57
|
+
* a true hang doesn't cost half an hour of dead time.
|
|
58
|
+
*/
|
|
59
|
+
requestTimeoutMs: number;
|
|
45
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* The command-watchdog timeout choices offered by /task-config, newest-first in
|
|
63
|
+
* the cycle order the picker shows. The stored config value is the ms number;
|
|
64
|
+
* the label is display-only (mirrors the searchProvider label/value split).
|
|
65
|
+
*/
|
|
66
|
+
export declare const COMMAND_TIMEOUT_OPTIONS: ReadonlyArray<{
|
|
67
|
+
label: string;
|
|
68
|
+
ms: number;
|
|
69
|
+
}>;
|
|
70
|
+
/**
|
|
71
|
+
* A hand-edited or stale config could carry any number (or a string); pin it to
|
|
72
|
+
* one of the offered choices so the watchdog never arms on a nonsense value.
|
|
73
|
+
*/
|
|
74
|
+
export declare function sanitizeRequestTimeoutMs(value: unknown): number;
|
|
46
75
|
/**
|
|
47
76
|
* A hand-edited config can hold anything; keep only string entries so a stray
|
|
48
77
|
* object/number can't reach the child argv as `-e [object Object]`.
|
package/dist/config/config.js
CHANGED
|
@@ -3,6 +3,28 @@ import * as fsp from 'node:fs/promises';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import * as os from 'node:os';
|
|
5
5
|
import { isSearchProvider } from '../workers/search-types.js';
|
|
6
|
+
/**
|
|
7
|
+
* The command-watchdog timeout choices offered by /task-config, newest-first in
|
|
8
|
+
* the cycle order the picker shows. The stored config value is the ms number;
|
|
9
|
+
* the label is display-only (mirrors the searchProvider label/value split).
|
|
10
|
+
*/
|
|
11
|
+
export const COMMAND_TIMEOUT_OPTIONS = [
|
|
12
|
+
{ label: '5 min', ms: 5 * 60_000 },
|
|
13
|
+
{ label: '10 min', ms: 10 * 60_000 },
|
|
14
|
+
{ label: '15 min', ms: 15 * 60_000 },
|
|
15
|
+
{ label: '30 min', ms: 30 * 60_000 },
|
|
16
|
+
{ label: 'off', ms: 0 }
|
|
17
|
+
];
|
|
18
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 15 * 60_000;
|
|
19
|
+
/**
|
|
20
|
+
* A hand-edited or stale config could carry any number (or a string); pin it to
|
|
21
|
+
* one of the offered choices so the watchdog never arms on a nonsense value.
|
|
22
|
+
*/
|
|
23
|
+
export function sanitizeRequestTimeoutMs(value) {
|
|
24
|
+
return COMMAND_TIMEOUT_OPTIONS.some(o => o.ms === value) ?
|
|
25
|
+
value
|
|
26
|
+
: DEFAULT_REQUEST_TIMEOUT_MS;
|
|
27
|
+
}
|
|
6
28
|
const DEFAULTS = {
|
|
7
29
|
remote: true,
|
|
8
30
|
compressReasoning: true,
|
|
@@ -15,7 +37,8 @@ const DEFAULTS = {
|
|
|
15
37
|
// 3/3, 0 collisions; ~14.5s of repeated docs lookups collapse to 0ms on a hit).
|
|
16
38
|
researchCache: true,
|
|
17
39
|
searchProvider: 'exa',
|
|
18
|
-
extensionWhitelist: []
|
|
40
|
+
extensionWhitelist: [],
|
|
41
|
+
requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS
|
|
19
42
|
};
|
|
20
43
|
/**
|
|
21
44
|
* A hand-edited config can hold anything; keep only string entries so a stray
|
|
@@ -43,6 +66,7 @@ if (!G.loaded) {
|
|
|
43
66
|
if (!isSearchProvider(parsed.searchProvider))
|
|
44
67
|
delete parsed.searchProvider;
|
|
45
68
|
parsed.extensionWhitelist = sanitizeExtensionWhitelist(parsed.extensionWhitelist);
|
|
69
|
+
parsed.requestTimeoutMs = sanitizeRequestTimeoutMs(parsed.requestTimeoutMs);
|
|
46
70
|
G.config = { ...DEFAULTS, ...parsed };
|
|
47
71
|
}
|
|
48
72
|
catch {
|
package/dist/config/register.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { SettingsList, visibleWidth } from '@earendil-works/pi-tui';
|
|
2
2
|
import { registerBridgeCommand } from '../remote/bridge.js';
|
|
3
3
|
import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../workers/search-types.js';
|
|
4
|
-
import { getConfig, saveConfig } from './config.js';
|
|
4
|
+
import { COMMAND_TIMEOUT_OPTIONS, getConfig, saveConfig } from './config.js';
|
|
5
5
|
import { listInstalledExtensions } from './extension-list.js';
|
|
6
6
|
const CONFIG_TITLE = 'pi-task settings';
|
|
7
7
|
/**
|
|
@@ -95,12 +95,28 @@ const ITEMS = [
|
|
|
95
95
|
description: 'Engine behind web search (pi-worker-search + freshness checks). Exa and DuckDuckGo need no API key; Brave needs BRAVE_SEARCH_API_KEY',
|
|
96
96
|
// Display full engine names; the stored config value stays the short id.
|
|
97
97
|
values: SEARCH_PROVIDERS.map(p => SEARCH_PROVIDER_LABELS[p])
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: 'requestTimeoutMs',
|
|
101
|
+
label: 'command timeout',
|
|
102
|
+
description: 'Cancel a single command that runs longer than this and remind the model to set '
|
|
103
|
+
+ 'its own timeout. Catches a local model that runs a command which never returns '
|
|
104
|
+
+ '(hung build, dev server, no-timeout check) so the run stops itself instead of '
|
|
105
|
+
+ 'waiting for a manual abort. off disables it',
|
|
106
|
+
// Display human labels; the stored config value stays the ms number.
|
|
107
|
+
values: COMMAND_TIMEOUT_OPTIONS.map(o => o.label)
|
|
98
108
|
}
|
|
99
109
|
];
|
|
110
|
+
/** Human label for the stored command-timeout ms (falls back to the raw ms). */
|
|
111
|
+
function timeoutLabel(ms) {
|
|
112
|
+
return COMMAND_TIMEOUT_OPTIONS.find(o => o.ms === ms)?.label ?? `${ms}ms`;
|
|
113
|
+
}
|
|
100
114
|
/** What /task-config shows for a setting's current value. */
|
|
101
115
|
function displayValue(cfg, id, isEnum) {
|
|
102
116
|
if (id === 'searchProvider')
|
|
103
117
|
return SEARCH_PROVIDER_LABELS[cfg.searchProvider];
|
|
118
|
+
if (id === 'requestTimeoutMs')
|
|
119
|
+
return timeoutLabel(cfg.requestTimeoutMs);
|
|
104
120
|
if (isEnum)
|
|
105
121
|
return String(cfg[id]);
|
|
106
122
|
return cfg[id] ? 'on' : 'off';
|
|
@@ -173,6 +189,11 @@ async function handleTaskConfig(_args, ctx) {
|
|
|
173
189
|
if (provider)
|
|
174
190
|
cfg.searchProvider = provider;
|
|
175
191
|
}
|
|
192
|
+
else if (id === 'requestTimeoutMs') {
|
|
193
|
+
const opt = COMMAND_TIMEOUT_OPTIONS.find(o => o.label === newValue);
|
|
194
|
+
if (opt)
|
|
195
|
+
cfg.requestTimeoutMs = opt.ms;
|
|
196
|
+
}
|
|
176
197
|
else {
|
|
177
198
|
;
|
|
178
199
|
cfg[id] = newValue === 'on';
|
|
@@ -185,7 +206,7 @@ async function handleTaskConfig(_args, ctx) {
|
|
|
185
206
|
export function registerConfig(pi) {
|
|
186
207
|
registerBridgeCommand(pi, 'task-config', {
|
|
187
208
|
description: 'Configure pi-task settings (remote, compress reasoning, auto-commit, orientation, '
|
|
188
|
-
+ 'enforce guidelines, extension whitelist for child sessions).',
|
|
209
|
+
+ 'enforce guidelines, command timeout, extension whitelist for child sessions).',
|
|
189
210
|
handler: handleTaskConfig
|
|
190
211
|
});
|
|
191
212
|
}
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { registerTaskAuto } from './task/auto-orchestrator.js';
|
|
|
4
4
|
import { registerWorkers } from './workers/index.js';
|
|
5
5
|
import { registerRemote } from './remote/register.js';
|
|
6
6
|
import { registerThinkingCompression } from './thinking/compress.js';
|
|
7
|
+
import { registerCommandWatchdog } from './task/command-watchdog.js';
|
|
7
8
|
export default function (pi) {
|
|
8
9
|
registerConfig(pi);
|
|
9
10
|
registerTask(pi);
|
|
@@ -11,4 +12,5 @@ export default function (pi) {
|
|
|
11
12
|
registerWorkers(pi);
|
|
12
13
|
registerRemote(pi);
|
|
13
14
|
registerThinkingCompression(pi);
|
|
15
|
+
registerCommandWatchdog(pi);
|
|
14
16
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anti-synthesis guard for grill/clarify auto-answers (mx5 run-13 Bug A).
|
|
3
|
+
*
|
|
4
|
+
* The grill auto-answer channel invented `Bun.mkdirSync` (does not exist) while
|
|
5
|
+
* the task's own research APIS section carried the correct list (Bun.build,
|
|
6
|
+
* Bun.spawn). Nothing cross-checked the answer against it, so the invention was
|
|
7
|
+
* promoted into the task's title, requirements, acceptance criteria AND VERIFY
|
|
8
|
+
* block, and the implementer shipped a fake ambient declare to compile it.
|
|
9
|
+
*
|
|
10
|
+
* Lever: the same verbatim-substring anti-synthesis check as the F3 contract
|
|
11
|
+
* registry. Extract API-shaped identifiers (`Namespace.member`) from the answer;
|
|
12
|
+
* an identifier is SYNTHESIZED when
|
|
13
|
+
* (a) the full identifier appears nowhere in the research (APIS/docs/context
|
|
14
|
+
* sections, verbatim substring, case-sensitive) and nowhere in the
|
|
15
|
+
* question itself, AND
|
|
16
|
+
* (b) the research DOES mention that namespace's API surface (`Bun.` appears
|
|
17
|
+
* somewhere) — i.e. research claims coverage of the namespace, so a
|
|
18
|
+
* member absent from it is suspicious rather than merely uncovered.
|
|
19
|
+
* Gate (b) is the step-aside rule: when research never mentions the namespace
|
|
20
|
+
* at all (React.StrictMode in a task whose research covered no React API), the
|
|
21
|
+
* check is INCONCLUSIVE and must not fire — the guard may only cost time,
|
|
22
|
+
* never work. Same for the clarify-triage seam, whose research slot is a stub:
|
|
23
|
+
* no namespace coverage ⇒ no findings ⇒ guard inert by construction.
|
|
24
|
+
*
|
|
25
|
+
* Caller contract (phaseAutoAnswer): findings ⇒ re-ask ONCE with the research
|
|
26
|
+
* API lines injected (belt); a re-asked answer that still carries a flagged
|
|
27
|
+
* identifier is surfaced to the user as UNKNOWN instead of being promoted.
|
|
28
|
+
*/
|
|
29
|
+
export interface SynthesizedApiFinding {
|
|
30
|
+
/** The full flagged identifier, e.g. "Bun.mkdirSync". */
|
|
31
|
+
identifier: string;
|
|
32
|
+
/** Its namespace, e.g. "Bun" — research mentions `Bun.` but not this member. */
|
|
33
|
+
namespace: string;
|
|
34
|
+
}
|
|
35
|
+
/** All API-shaped identifiers in a text, deduped, first-seen order. */
|
|
36
|
+
export declare function extractApiIdentifiers(text: string): string[];
|
|
37
|
+
/**
|
|
38
|
+
* The synthesized identifiers in an auto-answer: API-shaped, absent from the
|
|
39
|
+
* research and the question, in a namespace the research claims to cover.
|
|
40
|
+
* Verbatim-substring membership — never a model judgement.
|
|
41
|
+
*/
|
|
42
|
+
export declare function findSynthesizedApis(answer: string, question: string, research: string): SynthesizedApiFinding[];
|
|
43
|
+
/**
|
|
44
|
+
* Re-ask hint (SYSTEM NOTE shape, mirrors GRILL_AUTO_FORMAT_HINT): names the
|
|
45
|
+
* unverified identifiers, injects the research lines that ARE verified for
|
|
46
|
+
* those namespaces, and demands an answer grounded in them — or UNKNOWN.
|
|
47
|
+
*/
|
|
48
|
+
export declare function synthesizedApiReaskHint(findings: SynthesizedApiFinding[], research: string): string;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anti-synthesis guard for grill/clarify auto-answers (mx5 run-13 Bug A).
|
|
3
|
+
*
|
|
4
|
+
* The grill auto-answer channel invented `Bun.mkdirSync` (does not exist) while
|
|
5
|
+
* the task's own research APIS section carried the correct list (Bun.build,
|
|
6
|
+
* Bun.spawn). Nothing cross-checked the answer against it, so the invention was
|
|
7
|
+
* promoted into the task's title, requirements, acceptance criteria AND VERIFY
|
|
8
|
+
* block, and the implementer shipped a fake ambient declare to compile it.
|
|
9
|
+
*
|
|
10
|
+
* Lever: the same verbatim-substring anti-synthesis check as the F3 contract
|
|
11
|
+
* registry. Extract API-shaped identifiers (`Namespace.member`) from the answer;
|
|
12
|
+
* an identifier is SYNTHESIZED when
|
|
13
|
+
* (a) the full identifier appears nowhere in the research (APIS/docs/context
|
|
14
|
+
* sections, verbatim substring, case-sensitive) and nowhere in the
|
|
15
|
+
* question itself, AND
|
|
16
|
+
* (b) the research DOES mention that namespace's API surface (`Bun.` appears
|
|
17
|
+
* somewhere) — i.e. research claims coverage of the namespace, so a
|
|
18
|
+
* member absent from it is suspicious rather than merely uncovered.
|
|
19
|
+
* Gate (b) is the step-aside rule: when research never mentions the namespace
|
|
20
|
+
* at all (React.StrictMode in a task whose research covered no React API), the
|
|
21
|
+
* check is INCONCLUSIVE and must not fire — the guard may only cost time,
|
|
22
|
+
* never work. Same for the clarify-triage seam, whose research slot is a stub:
|
|
23
|
+
* no namespace coverage ⇒ no findings ⇒ guard inert by construction.
|
|
24
|
+
*
|
|
25
|
+
* Caller contract (phaseAutoAnswer): findings ⇒ re-ask ONCE with the research
|
|
26
|
+
* API lines injected (belt); a re-asked answer that still carries a flagged
|
|
27
|
+
* identifier is surfaced to the user as UNKNOWN instead of being promoted.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* `Namespace.member` where the namespace starts uppercase (Bun, React, Deno —
|
|
31
|
+
* the global/imported-namespace API shape; run-13's TP is exactly this) and
|
|
32
|
+
* both sides are ≥2 chars (kills "U.S.", "e.G" prose shapes). Member may start
|
|
33
|
+
* either case: `Bun.mkdirSync` and `React.StrictMode` are both API-shaped.
|
|
34
|
+
*/
|
|
35
|
+
const API_IDENT_RE = /\b([A-Z][A-Za-z0-9_$]+)\.([A-Za-z_$][A-Za-z0-9_$]+)\b/g;
|
|
36
|
+
/**
|
|
37
|
+
* Member names that make the match a file name, domain, or version-ish token
|
|
38
|
+
* rather than an API (Node.js, App.tsx, README.md, Fly.io, Express.com). All
|
|
39
|
+
* lowercase-compared, so `INDEX.HTML` is excluded too.
|
|
40
|
+
*/
|
|
41
|
+
const NON_API_MEMBERS = new Set([
|
|
42
|
+
'js',
|
|
43
|
+
'ts',
|
|
44
|
+
'jsx',
|
|
45
|
+
'tsx',
|
|
46
|
+
'mjs',
|
|
47
|
+
'cjs',
|
|
48
|
+
'mts',
|
|
49
|
+
'cts',
|
|
50
|
+
'json',
|
|
51
|
+
'jsonc',
|
|
52
|
+
'md',
|
|
53
|
+
'html',
|
|
54
|
+
'htm',
|
|
55
|
+
'css',
|
|
56
|
+
'scss',
|
|
57
|
+
'less',
|
|
58
|
+
'svg',
|
|
59
|
+
'png',
|
|
60
|
+
'jpg',
|
|
61
|
+
'jpeg',
|
|
62
|
+
'gif',
|
|
63
|
+
'ico',
|
|
64
|
+
'txt',
|
|
65
|
+
'yml',
|
|
66
|
+
'yaml',
|
|
67
|
+
'toml',
|
|
68
|
+
'lock',
|
|
69
|
+
'map',
|
|
70
|
+
'env',
|
|
71
|
+
'sh',
|
|
72
|
+
'sql',
|
|
73
|
+
'db',
|
|
74
|
+
'sqlite',
|
|
75
|
+
'wasm',
|
|
76
|
+
'node',
|
|
77
|
+
'exe',
|
|
78
|
+
'com',
|
|
79
|
+
'org',
|
|
80
|
+
'net',
|
|
81
|
+
'io',
|
|
82
|
+
'dev',
|
|
83
|
+
'app',
|
|
84
|
+
'ai',
|
|
85
|
+
'co',
|
|
86
|
+
'gg'
|
|
87
|
+
]);
|
|
88
|
+
/** All API-shaped identifiers in a text, deduped, first-seen order. */
|
|
89
|
+
export function extractApiIdentifiers(text) {
|
|
90
|
+
const out = [];
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
for (const m of text.matchAll(API_IDENT_RE)) {
|
|
93
|
+
const [full, , member] = m;
|
|
94
|
+
if (NON_API_MEMBERS.has(member.toLowerCase()))
|
|
95
|
+
continue;
|
|
96
|
+
if (seen.has(full))
|
|
97
|
+
continue;
|
|
98
|
+
seen.add(full);
|
|
99
|
+
out.push(full);
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The synthesized identifiers in an auto-answer: API-shaped, absent from the
|
|
105
|
+
* research and the question, in a namespace the research claims to cover.
|
|
106
|
+
* Verbatim-substring membership — never a model judgement.
|
|
107
|
+
*/
|
|
108
|
+
export function findSynthesizedApis(answer, question, research) {
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const identifier of extractApiIdentifiers(answer)) {
|
|
111
|
+
if (research.includes(identifier) || question.includes(identifier))
|
|
112
|
+
continue;
|
|
113
|
+
const namespace = identifier.slice(0, identifier.indexOf('.'));
|
|
114
|
+
// Step-aside gate: research must claim this namespace's API surface.
|
|
115
|
+
if (!research.includes(`${namespace}.`))
|
|
116
|
+
continue;
|
|
117
|
+
out.push({ identifier, namespace });
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
/** Research lines that mention any flagged namespace — the verified API list to inject. */
|
|
122
|
+
function verifiedApiLines(findings, research) {
|
|
123
|
+
const namespaces = new Set(findings.map(f => f.namespace));
|
|
124
|
+
const lines = [];
|
|
125
|
+
for (const line of research.split('\n')) {
|
|
126
|
+
const t = line.trim();
|
|
127
|
+
if (t.length === 0)
|
|
128
|
+
continue;
|
|
129
|
+
for (const ns of namespaces) {
|
|
130
|
+
if (t.includes(`${ns}.`)) {
|
|
131
|
+
lines.push(t);
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return lines.slice(0, 20);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Re-ask hint (SYSTEM NOTE shape, mirrors GRILL_AUTO_FORMAT_HINT): names the
|
|
140
|
+
* unverified identifiers, injects the research lines that ARE verified for
|
|
141
|
+
* those namespaces, and demands an answer grounded in them — or UNKNOWN.
|
|
142
|
+
*/
|
|
143
|
+
export function synthesizedApiReaskHint(findings, research) {
|
|
144
|
+
const flagged = findings.map(f => `\`${f.identifier}\``).join(', ');
|
|
145
|
+
const verified = verifiedApiLines(findings, research);
|
|
146
|
+
return (`[SYSTEM NOTE: Your previous answer named ${flagged} — NOT present in this task's `
|
|
147
|
+
+ 'verified research API list, so it may not exist (a plausible-looking invented API '
|
|
148
|
+
+ 'poisons the whole task: it gets promoted into requirements and VERIFY, and the '
|
|
149
|
+
+ 'implementation fakes type declarations to compile it). The VERIFIED research lines '
|
|
150
|
+
+ 'for that namespace are:\n'
|
|
151
|
+
+ verified.map(l => ` ${l}`).join('\n')
|
|
152
|
+
+ '\nAnswer again using ONLY APIs from the research or the question. If the behavior '
|
|
153
|
+
+ 'needs an API the research does not list, do NOT invent one — describe the behavior '
|
|
154
|
+
+ 'without naming a concrete API, or tag UNKNOWN.]');
|
|
155
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
/**
|
|
3
|
+
* Command watchdog — cancels a single tool execution that overruns the
|
|
4
|
+
* configured ceiling and reminds the model to bound its own commands.
|
|
5
|
+
*
|
|
6
|
+
* WHY: a local model in the MAIN session routinely runs a command that never
|
|
7
|
+
* returns — `godot --headless --check-only` with no timeout, a dev server, a
|
|
8
|
+
* hung test — and the run wedges until the user manually aborts and tells the
|
|
9
|
+
* model to add a timeout. pi's bash tool takes an OPTIONAL `timeout` with NO
|
|
10
|
+
* default (see pi-coding-agent tools/bash.js), so any command the model didn't
|
|
11
|
+
* bound runs forever. This supplies the missing default from the host side.
|
|
12
|
+
*
|
|
13
|
+
* HOW: arm a wall-clock timer on `tool_execution_start`, disarm it on
|
|
14
|
+
* `tool_execution_end`. If it elapses, `ctx.abort()` cancels the in-flight
|
|
15
|
+
* operation — which fires the tool's AbortSignal, and pi's bash executor kills
|
|
16
|
+
* the whole process tree on abort — then a follow-up user turn tells the model
|
|
17
|
+
* what happened so it retries with a timeout instead of hanging again.
|
|
18
|
+
*
|
|
19
|
+
* Tool-agnostic: it arms on ANY tool, honouring "any command can run forever",
|
|
20
|
+
* though in practice only bash runs long enough to trip it. The pure timer
|
|
21
|
+
* state lives in {@link CommandWatchdog}; all side effects (abort, reminder,
|
|
22
|
+
* per-call ctx lookup) live in the registration's `onFire`, so the machine is
|
|
23
|
+
* unit-testable without a real pi session.
|
|
24
|
+
*/
|
|
25
|
+
/** Opaque timer handle — a real `setTimeout` return in production, anything the
|
|
26
|
+
* test's fake scheduler hands back under test. */
|
|
27
|
+
export type TimerHandle = unknown;
|
|
28
|
+
export interface WatchdogDeps {
|
|
29
|
+
/**
|
|
30
|
+
* The ceiling in ms, read PER command-start so a /task-config change takes
|
|
31
|
+
* effect on the next command with no reload. 0 (or any non-positive value)
|
|
32
|
+
* means the watchdog is off and never arms.
|
|
33
|
+
*/
|
|
34
|
+
getTimeoutMs: () => number;
|
|
35
|
+
schedule: (fn: () => void, ms: number) => TimerHandle;
|
|
36
|
+
cancel: (handle: TimerHandle) => void;
|
|
37
|
+
/** Invoked when a command overruns: the registration aborts + reminds here. */
|
|
38
|
+
onFire: (toolCallId: string, toolName: string, timeoutMs: number) => void;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The reminder delivered to the model after its command is cancelled. Kept pure
|
|
42
|
+
* and exported so a test can assert its shape without driving the whole session.
|
|
43
|
+
*/
|
|
44
|
+
export declare function reminderMessage(toolName: string, timeoutMs: number): string;
|
|
45
|
+
export declare class CommandWatchdog {
|
|
46
|
+
private readonly deps;
|
|
47
|
+
/** Armed timers, keyed by the tool call they guard. Tool executions are
|
|
48
|
+
* sequential, so this holds at most one entry in normal operation, but the
|
|
49
|
+
* map keeps it correct even if pi ever overlaps two calls. */
|
|
50
|
+
private readonly active;
|
|
51
|
+
constructor(deps: WatchdogDeps);
|
|
52
|
+
/** Arm a timer for a starting tool. No-op when the watchdog is off. */
|
|
53
|
+
onStart(toolCallId: string, toolName: string): void;
|
|
54
|
+
/** Disarm the timer for a finished tool. */
|
|
55
|
+
onEnd(toolCallId: string): void;
|
|
56
|
+
/** Cancel every armed timer — a turn-end / session-shutdown safety net so no
|
|
57
|
+
* stray timer can fire into a later, unrelated command. */
|
|
58
|
+
clearAll(): void;
|
|
59
|
+
private disarm;
|
|
60
|
+
private fire;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Wire the watchdog into the main session. Only ever active in the host session
|
|
64
|
+
* (children run `--no-extensions`), which is exactly where the observed hangs
|
|
65
|
+
* happen.
|
|
66
|
+
*/
|
|
67
|
+
export declare function registerCommandWatchdog(pi: ExtensionAPI): void;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { getConfig } from '../config/config.js';
|
|
2
|
+
/**
|
|
3
|
+
* The reminder delivered to the model after its command is cancelled. Kept pure
|
|
4
|
+
* and exported so a test can assert its shape without driving the whole session.
|
|
5
|
+
*/
|
|
6
|
+
export function reminderMessage(toolName, timeoutMs) {
|
|
7
|
+
const mins = Math.max(1, Math.round(timeoutMs / 60_000));
|
|
8
|
+
return (`[SYSTEM] Your \`${toolName}\` call ran longer than ${mins} minute`
|
|
9
|
+
+ `${mins === 1 ? '' : 's'} and was automatically cancelled — it looked stuck. `
|
|
10
|
+
// Anti-fabrication: a live run showed the model react to the cancel by
|
|
11
|
+
// reporting the killed command as succeeded ("the server is now running").
|
|
12
|
+
// State plainly that it produced nothing so the model can't claim success.
|
|
13
|
+
+ `The command was killed before it finished and produced NO result, so do not `
|
|
14
|
+
+ `report it as completed or successful, and do not claim that anything it would `
|
|
15
|
+
+ `have started (a server, build, or process) is now running. `
|
|
16
|
+
+ `If it was a genuinely long-running command, you MUST re-run it with an explicit `
|
|
17
|
+
+ `timeout — set the bash tool's \`timeout\` parameter (in seconds) so it cannot hang `
|
|
18
|
+
+ `again — or break it into smaller steps. Do NOT simply retry the same unbounded command.`);
|
|
19
|
+
}
|
|
20
|
+
export class CommandWatchdog {
|
|
21
|
+
deps;
|
|
22
|
+
/** Armed timers, keyed by the tool call they guard. Tool executions are
|
|
23
|
+
* sequential, so this holds at most one entry in normal operation, but the
|
|
24
|
+
* map keeps it correct even if pi ever overlaps two calls. */
|
|
25
|
+
active = new Map();
|
|
26
|
+
constructor(deps) {
|
|
27
|
+
this.deps = deps;
|
|
28
|
+
}
|
|
29
|
+
/** Arm a timer for a starting tool. No-op when the watchdog is off. */
|
|
30
|
+
onStart(toolCallId, toolName) {
|
|
31
|
+
const ms = this.deps.getTimeoutMs();
|
|
32
|
+
if (!(ms > 0))
|
|
33
|
+
return;
|
|
34
|
+
// A duplicate start for the same id must not leak the previous timer.
|
|
35
|
+
this.disarm(toolCallId);
|
|
36
|
+
const handle = this.deps.schedule(() => this.fire(toolCallId, toolName, ms), ms);
|
|
37
|
+
this.active.set(toolCallId, handle);
|
|
38
|
+
}
|
|
39
|
+
/** Disarm the timer for a finished tool. */
|
|
40
|
+
onEnd(toolCallId) {
|
|
41
|
+
this.disarm(toolCallId);
|
|
42
|
+
}
|
|
43
|
+
/** Cancel every armed timer — a turn-end / session-shutdown safety net so no
|
|
44
|
+
* stray timer can fire into a later, unrelated command. */
|
|
45
|
+
clearAll() {
|
|
46
|
+
for (const handle of this.active.values())
|
|
47
|
+
this.deps.cancel(handle);
|
|
48
|
+
this.active.clear();
|
|
49
|
+
}
|
|
50
|
+
disarm(toolCallId) {
|
|
51
|
+
const handle = this.active.get(toolCallId);
|
|
52
|
+
if (handle !== undefined) {
|
|
53
|
+
this.deps.cancel(handle);
|
|
54
|
+
this.active.delete(toolCallId);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
fire(toolCallId, toolName, ms) {
|
|
58
|
+
// If the tool ended in the same tick the timer fired, its entry is gone
|
|
59
|
+
// already — never abort a command that has just finished cleanly.
|
|
60
|
+
if (!this.active.has(toolCallId))
|
|
61
|
+
return;
|
|
62
|
+
this.active.delete(toolCallId);
|
|
63
|
+
this.deps.onFire(toolCallId, toolName, ms);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Wire the watchdog into the main session. Only ever active in the host session
|
|
68
|
+
* (children run `--no-extensions`), which is exactly where the observed hangs
|
|
69
|
+
* happen.
|
|
70
|
+
*/
|
|
71
|
+
export function registerCommandWatchdog(pi) {
|
|
72
|
+
// The ctx that owns each in-flight tool's AbortSignal, captured per start so
|
|
73
|
+
// the timer callback (which fires outside the event handler) aborts the
|
|
74
|
+
// right operation.
|
|
75
|
+
const ctxByCall = new Map();
|
|
76
|
+
const watchdog = new CommandWatchdog({
|
|
77
|
+
getTimeoutMs: () => getConfig().requestTimeoutMs,
|
|
78
|
+
schedule: (fn, ms) => {
|
|
79
|
+
const handle = setTimeout(fn, ms);
|
|
80
|
+
// Don't let a pending watchdog timer keep the process alive on exit.
|
|
81
|
+
if (typeof handle.unref === 'function') {
|
|
82
|
+
;
|
|
83
|
+
handle.unref();
|
|
84
|
+
}
|
|
85
|
+
return handle;
|
|
86
|
+
},
|
|
87
|
+
cancel: handle => clearTimeout(handle),
|
|
88
|
+
onFire: (toolCallId, toolName, timeoutMs) => {
|
|
89
|
+
const ctx = ctxByCall.get(toolCallId);
|
|
90
|
+
ctxByCall.delete(toolCallId);
|
|
91
|
+
// Cancel the stuck command (kills the tool's whole process tree via
|
|
92
|
+
// the turn's AbortSignal), then start a fresh turn telling the model
|
|
93
|
+
// to bound its next attempt.
|
|
94
|
+
ctx?.abort();
|
|
95
|
+
pi.sendUserMessage(reminderMessage(toolName, timeoutMs), { deliverAs: 'followUp' });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
pi.on('tool_execution_start', (event, ctx) => {
|
|
99
|
+
ctxByCall.set(event.toolCallId, ctx);
|
|
100
|
+
watchdog.onStart(event.toolCallId, event.toolName);
|
|
101
|
+
});
|
|
102
|
+
pi.on('tool_execution_end', event => {
|
|
103
|
+
ctxByCall.delete(event.toolCallId);
|
|
104
|
+
watchdog.onEnd(event.toolCallId);
|
|
105
|
+
});
|
|
106
|
+
// Safety net: nothing should outlive its turn, but if a start ever lacks a
|
|
107
|
+
// matching end, clear on turn/session teardown so no timer fires stale.
|
|
108
|
+
const reset = () => {
|
|
109
|
+
watchdog.clearAll();
|
|
110
|
+
ctxByCall.clear();
|
|
111
|
+
};
|
|
112
|
+
pi.on('turn_end', reset);
|
|
113
|
+
pi.on('session_shutdown', reset);
|
|
114
|
+
}
|
package/dist/task/final-gate.js
CHANGED
|
@@ -383,6 +383,12 @@ export function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}) {
|
|
|
383
383
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
384
384
|
env: { ...process.env }
|
|
385
385
|
});
|
|
386
|
+
// Best-effort cleanup only: killGroup below can silently fail to reap the
|
|
387
|
+
// process (platform/sandbox-specific — observed on a GH Actions Linux
|
|
388
|
+
// runner where the group-kill did not take, hanging the whole `bun test
|
|
389
|
+
// --isolate` run on the leaked child's piped stdio). unref() so a child
|
|
390
|
+
// we already tried to kill can never itself keep this process alive.
|
|
391
|
+
child.unref();
|
|
386
392
|
let out = '';
|
|
387
393
|
let err = '';
|
|
388
394
|
let listenerSeen = false;
|
package/dist/task/phases.js
CHANGED
|
@@ -30,6 +30,8 @@ import { findSkipEscapes, skipEscapeDefectText } from './skip-escape.js';
|
|
|
30
30
|
import { findSynthesizedWiring, wiringProbeText, readReferencedDocs } from './wiring-claims.js';
|
|
31
31
|
import { findAbsenceConflicts, absenceProbeText, siblingTitlesFromPlanContext } from './verify-reconcile.js';
|
|
32
32
|
import { findFrozenPathConflicts, frozenConflictProbeText } from './frozen-conflict.js';
|
|
33
|
+
import { findSynthesizedApis, synthesizedApiReaskHint } from './api-synthesis.js';
|
|
34
|
+
import { findGrepOnlyVerify, grepOnlyVerifyDefectText, GREP_THEATER_RETRY_HINT } from './verify-quality.js';
|
|
33
35
|
import { existsSync } from 'node:fs';
|
|
34
36
|
import { readContracts, buildContractsBlock, buildContractsVerifyBlock } from './contracts.js';
|
|
35
37
|
import { readRequirements, buildRequirementsBlock } from './requirements.js';
|
|
@@ -633,7 +635,43 @@ export async function phaseAutoAnswer(deps, refined, research, question, autoDep
|
|
|
633
635
|
// otherwise a preamble line leaks out as the recommended answer.
|
|
634
636
|
text = await runPhaseChild(deps, 'grill-auto', 'read', prependHint(GRILL_AUTO_FORMAT_HINT, basePrompt));
|
|
635
637
|
}
|
|
636
|
-
|
|
638
|
+
let parsed = parseAutoAnswer(text);
|
|
639
|
+
// Anti-synthesis guard (mx5 run 13, Bug A): the auto-answer invented
|
|
640
|
+
// `Bun.mkdirSync` while research's APIS section carried the correct list,
|
|
641
|
+
// and the invention was promoted into requirements + VERIFY. Deterministic
|
|
642
|
+
// verbatim-substring check: an API-shaped identifier in the answer that is
|
|
643
|
+
// absent from the research AND the question, in a namespace the research
|
|
644
|
+
// claims to cover, triggers ONE re-ask with the verified research lines
|
|
645
|
+
// injected. Still synthesizing after the re-ask ⇒ surface to the user as a
|
|
646
|
+
// recommendation instead of silently promoting it (costs time, never work).
|
|
647
|
+
if (parsed.kind === 'answered') {
|
|
648
|
+
const synth = findSynthesizedApis(parsed.text, question, research);
|
|
649
|
+
if (synth.length > 0) {
|
|
650
|
+
deps.logDebug?.('grill-auto: unverified API identifier(s) in answer — '
|
|
651
|
+
+ synth.map(f => f.identifier).join(', ')
|
|
652
|
+
+ ' — re-asking with the research API list injected');
|
|
653
|
+
let reasked = null;
|
|
654
|
+
try {
|
|
655
|
+
const text2 = await runPhaseChild(deps, 'grill-auto', 'read', prependHint(synthesizedApiReaskHint(synth, research), basePrompt));
|
|
656
|
+
if (autoAnswerHasTag(text2))
|
|
657
|
+
reasked = parseAutoAnswer(text2);
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
reasked = null;
|
|
661
|
+
}
|
|
662
|
+
if (reasked === null
|
|
663
|
+
|| (reasked.kind === 'answered'
|
|
664
|
+
&& findSynthesizedApis(reasked.text, question, research).length > 0)) {
|
|
665
|
+
const still = reasked ?? parsed;
|
|
666
|
+
const suggested = still.kind === 'answered' ? still.text : parsed.text;
|
|
667
|
+
deps.logDebug?.('grill-auto: answer still carries an unverified API — surfacing to user');
|
|
668
|
+
parsed = { kind: 'unknown', suggested, raw: still.raw };
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
parsed = reasked;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
637
675
|
// Surviving-unknown routing: an integration / build-wiring unknown whose
|
|
638
676
|
// wrong guess is a structural landmine must NOT be silently auto-answered.
|
|
639
677
|
// We first try to ground it from fetched docs (the enrichment fan-out
|
|
@@ -866,6 +904,17 @@ export async function phaseCritique(deps, spec, refined, qa, planContext, resear
|
|
|
866
904
|
deps.logDebug?.('unsatisfiable freeze/requires-edit pair flagged in spec: '
|
|
867
905
|
+ frozenConflicts.map(c => c.path).join(' | '));
|
|
868
906
|
}
|
|
907
|
+
// DETERMINISTIC grep-theater probe (mx5 run 13, Bug B): a VERIFY block that
|
|
908
|
+
// grep-asserts the SOURCE of a runnable deliverable while every command in
|
|
909
|
+
// the block is static inspection — the build script "verified" by three
|
|
910
|
+
// greps that was never run, shipping broken for 14 tasks. Forced into the
|
|
911
|
+
// rewrite like the skip-escape finding: VERIFY must EXECUTE the artifact
|
|
912
|
+
// and assert an observable outcome of that run.
|
|
913
|
+
const grepOnly = findGrepOnlyVerify(spec);
|
|
914
|
+
const grepOnlyProbe = grepOnly.length > 0 ? grepOnlyVerifyDefectText(grepOnly) : null;
|
|
915
|
+
if (grepOnlyProbe) {
|
|
916
|
+
deps.logDebug?.('grep-theater VERIFY flagged in spec: ' + grepOnly.map(f => f.target).join(' | '));
|
|
917
|
+
}
|
|
869
918
|
let triageDefects = null;
|
|
870
919
|
if (parseVerifyBlock(spec) !== null) {
|
|
871
920
|
const tTriage = Date.now();
|
|
@@ -883,14 +932,15 @@ export async function phaseCritique(deps, spec, refined, qa, planContext, resear
|
|
|
883
932
|
deps.recordSubStep?.('triage', Date.now() - tTriage);
|
|
884
933
|
if (verdict !== null) {
|
|
885
934
|
// A deterministic skip-escape, synthesized-wiring, plan-contradiction,
|
|
886
|
-
//
|
|
935
|
+
// unsatisfiable-pair, or grep-theater finding overrides a CLEAN triage: the draft must
|
|
887
936
|
// be rewritten to resolve it even if the model judged the rest clean
|
|
888
937
|
// (the model does not self-discover any of them reliably).
|
|
889
938
|
if (isCritiqueClean(verdict)) {
|
|
890
939
|
if (skipDefects === null
|
|
891
940
|
&& wiringProbe === null
|
|
892
941
|
&& absenceProbe === null
|
|
893
|
-
&& frozenProbe === null
|
|
942
|
+
&& frozenProbe === null
|
|
943
|
+
&& grepOnlyProbe === null) {
|
|
894
944
|
return spec;
|
|
895
945
|
}
|
|
896
946
|
}
|
|
@@ -900,22 +950,39 @@ export async function phaseCritique(deps, spec, refined, qa, planContext, resear
|
|
|
900
950
|
}
|
|
901
951
|
}
|
|
902
952
|
// Merge the deterministic skip-escape + synthesized-wiring + plan-contradiction
|
|
903
|
-
// + unsatisfiable-pair defects with any triage defects for the
|
|
904
|
-
// forced FOCUS items).
|
|
905
|
-
const rewriteDefects = [skipDefects, wiringProbe, absenceProbe, frozenProbe, triageDefects]
|
|
953
|
+
// + unsatisfiable-pair + grep-theater defects with any triage defects for the
|
|
954
|
+
// rewrite (all are forced FOCUS items).
|
|
955
|
+
const rewriteDefects = [skipDefects, wiringProbe, absenceProbe, frozenProbe, grepOnlyProbe, triageDefects]
|
|
906
956
|
.filter(Boolean)
|
|
907
957
|
.join('\n\n') || null;
|
|
908
958
|
const tRewrite = Date.now();
|
|
909
959
|
try {
|
|
910
|
-
return await runWithEmphasisRetry(deps, 'critique', 'read', problem =>
|
|
960
|
+
return await runWithEmphasisRetry(deps, 'critique', 'read', problem => {
|
|
961
|
+
const base = CRITIQUE_PROMPT(spec, refined, qa, problem === 'no_verify_block', rewriteDefects, contractsBlock);
|
|
962
|
+
// Theater retry gets a targeted hint (the generic emphasis line
|
|
963
|
+
// says "previous attempt had no VERIFY block", which is wrong
|
|
964
|
+
// here — it had one, it just never ran the deliverable).
|
|
965
|
+
return problem === 'verify_grep_theater' ?
|
|
966
|
+
prependHint(GREP_THEATER_RETRY_HINT, base)
|
|
967
|
+
: base;
|
|
968
|
+
}, text => {
|
|
911
969
|
// The rewrite (thinking on) sometimes prepends narration before
|
|
912
970
|
// GOAL; the prompt forbids it but this validator only checks for
|
|
913
971
|
// a VERIFY block. Strip it so the delivered spec starts at GOAL.
|
|
914
972
|
const stripped = stripSpecPreamble(text);
|
|
915
|
-
|
|
916
|
-
{ ok:
|
|
917
|
-
|
|
918
|
-
|
|
973
|
+
if (parseVerifyBlock(stripped) === null) {
|
|
974
|
+
return { ok: false, problem: 'no_verify_block' };
|
|
975
|
+
}
|
|
976
|
+
// Detector-backed closure on the grep-theater defect: when the
|
|
977
|
+
// draft was flagged, the rewrite must actually resolve it (live
|
|
978
|
+
// A/B: 1/5 rewrites ignored the injected defect and re-shipped
|
|
979
|
+
// the grep-only block). One emphasis retry with a targeted hint;
|
|
980
|
+
// a second miss falls back to the draft in critiqueWithFallback.
|
|
981
|
+
if (grepOnlyProbe !== null && findGrepOnlyVerify(stripped).length > 0) {
|
|
982
|
+
return { ok: false, problem: 'verify_grep_theater' };
|
|
983
|
+
}
|
|
984
|
+
return { ok: true, value: stripped };
|
|
985
|
+
}, problem => new Error(problem));
|
|
919
986
|
}
|
|
920
987
|
finally {
|
|
921
988
|
deps.recordSubStep?.('rewrite', Date.now() - tRewrite);
|
|
@@ -928,7 +995,7 @@ export async function critiqueWithFallback(d, p) {
|
|
|
928
995
|
}
|
|
929
996
|
catch (err) {
|
|
930
997
|
const msg = err instanceof Error ? err.message : String(err);
|
|
931
|
-
if (msg !== 'no_verify_block')
|
|
998
|
+
if (msg !== 'no_verify_block' && msg !== 'verify_grep_theater')
|
|
932
999
|
throw err;
|
|
933
1000
|
// Fall back to the compose draft — but only if it actually carries a
|
|
934
1001
|
// runnable VERIFY block. Critique reaches its rewrite path precisely
|
|
@@ -937,9 +1004,14 @@ export async function critiqueWithFallback(d, p) {
|
|
|
937
1004
|
// handoff gate rejects and resume can't heal. Compose now enforces a
|
|
938
1005
|
// parseable VERIFY, so this should hold; keep the guard so a regression
|
|
939
1006
|
// fails the run cleanly instead of shipping a broken spec.
|
|
1007
|
+
// (verify_grep_theater: both rewrite attempts kept a grep-only VERIFY;
|
|
1008
|
+
// the draft carries the same defect but is the validated-shape fallback
|
|
1009
|
+
// — deliver it rather than fail the run. The guard costs time, never work.)
|
|
940
1010
|
if (parseVerifyBlock(p.spec) === null)
|
|
941
1011
|
throw err;
|
|
942
|
-
p.ctx.ui.notify(
|
|
1012
|
+
p.ctx.ui.notify(msg === 'verify_grep_theater' ?
|
|
1013
|
+
'Critique rewrite kept a grep-only VERIFY — using compose draft. Consider adding a command that RUNS the deliverable.'
|
|
1014
|
+
: "Critique couldn't produce a VERIFY block — using compose draft. Edit the spec manually if needed.", 'warning');
|
|
943
1015
|
return p.spec;
|
|
944
1016
|
}
|
|
945
1017
|
}
|
package/dist/task/prompts.js
CHANGED
|
@@ -231,6 +231,8 @@ LIVE-DATA RULE:
|
|
|
231
231
|
- No npm block + question is about latest/current version → tag UNKNOWN (training data goes stale).
|
|
232
232
|
- VERSION-PIN questions ("pin to X.y vs latest", "which major version") are costly-to-reverse build-shaping choices: unless the spec or an "### npm:" block already settles it (then ANSWER that value), tag UNKNOWN and surface it. NEVER auto-answer a downgrade to an OLDER major "to avoid breaking changes" from memory — that reasoning is exactly the stale-training-data trap. If an "### npm:" block shows a newer major than your instinct, that block is the live latest; do not silently pin an older major the live data and spec never asked for.
|
|
233
233
|
|
|
234
|
+
API-GROUNDING RULE: never name a concrete API (\`Namespace.member\`, an imported function, a runtime builtin) that appears in neither the research notes nor the question. The research APIS list was verified against the installed types; an API you remember but the research does not list may simply not exist, and an invented one poisons the whole task downstream. If the behavior you recommend needs an API the research does not list, describe the behavior without naming an API, or tag UNKNOWN.
|
|
235
|
+
|
|
234
236
|
TRIAGE — run these checks IN ORDER first. The REVERSIBILITY TEST below applies ONLY to a question that survives all checks as a genuine preference.
|
|
235
237
|
|
|
236
238
|
1. ALREADY-DECIDED CHECK — scan the refined task and research for a value, shape, response body, schema, route, or requirement that ALREADY determines the answer. If one does, this is a fact, not a preference. Emit "ANSWER: <value taken from that source>". If your instinct or a "nicer" alternative contradicts that source, the SOURCE WINS — never override a stated contract with a preferred default. (E.g. a stated response shape { items, total, page, pageSize } already answers a pagination question — page/offset — you may NOT answer "cursor".)
|
|
@@ -323,6 +325,7 @@ VERIFY must exercise the surface area the task actually touches. Draw VERIFY com
|
|
|
323
325
|
- TypeScript / JavaScript source changes → MUST include the project's typecheck, lint, and test commands when those scripts exist in TOOLING. Include build only if the change could affect the build output.
|
|
324
326
|
- Python / Go / Rust / other source changes → MUST include the language's standard verification from TOOLING (e.g. \`pytest\`, \`go test ./...\`, \`cargo test\`) plus lint/typecheck if configured.
|
|
325
327
|
- Config / infra-only changes with no executable verification → state that explicitly with a single command that re-reads or validates the config (e.g. \`docker compose config\`, \`nginx -t\`, \`yamllint file.yml\`). Never leave VERIFY with only \`true\` or \`echo ok\`.
|
|
328
|
+
- Runnable deliverables (a build script, server, CLI, seed/migrate script) → VERIFY must EXECUTE the artifact and assert an observable outcome of that run (exit code, a file the run produces, a served response). A grep on the artifact's SOURCE proves nothing about behavior and is never sufficient on its own.
|
|
326
329
|
|
|
327
330
|
When this task is one step of a larger plan: sibling steps' deliverables may already exist in the tree and more will land after this task. NEVER write a VERIFY check that fails because sibling work exists (e.g. "file X must not exist" when another step owns X). The plan context forbids you from BUILDING other steps' work — it does not make their work absent. Verify what THIS task adds or changes.
|
|
328
331
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface GrepOnlyVerifyFinding {
|
|
2
|
+
/** The runnable source file being grep-asserted, e.g. "build.ts". */
|
|
3
|
+
target: string;
|
|
4
|
+
/** The VERIFY lines that inspect it, verbatim. */
|
|
5
|
+
lines: string[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Scan a composed spec's VERIFY block: all-static block that grep/cat-asserts
|
|
9
|
+
* runnable source ⇒ one finding per inspected file. Empty when the block
|
|
10
|
+
* contains any execution command, has no VERIFY block, or inspects no runnable
|
|
11
|
+
* source (doc/config-only tasks).
|
|
12
|
+
*/
|
|
13
|
+
export declare function findGrepOnlyVerify(spec: string): GrepOnlyVerifyFinding[];
|
|
14
|
+
/**
|
|
15
|
+
* Retry hint when the critique rewrite KEPT the grep-theater block it was told
|
|
16
|
+
* to fix (live A/B: 1/5 rewrites ignored the injected defect). Prepended to the
|
|
17
|
+
* second rewrite attempt; the defect block naming the exact files is still in
|
|
18
|
+
* the prompt body.
|
|
19
|
+
*/
|
|
20
|
+
export declare const GREP_THEATER_RETRY_HINT: string;
|
|
21
|
+
/**
|
|
22
|
+
* Render the findings as a defect block for the critique rewrite: VERIFY must
|
|
23
|
+
* EXECUTE the runnable deliverable and assert an observable outcome of THAT
|
|
24
|
+
* run, not grep its source.
|
|
25
|
+
*/
|
|
26
|
+
export declare function grepOnlyVerifyDefectText(findings: GrepOnlyVerifyFinding[]): string;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grep-theater VERIFY detector (mx5 run-13 Bug B), compose-critique side.
|
|
3
|
+
*
|
|
4
|
+
* TASK_0018's VERIFY block "verified" a build script with tsc + three greps on
|
|
5
|
+
* build.ts's SOURCE — it never ran `bun build.ts`. The greps asserted the
|
|
6
|
+
* hallucinated `Bun.mkdirSync` line was present, so a broken build shipped
|
|
7
|
+
* green and stayed broken for 14 tasks. Grep-on-source is not verification of
|
|
8
|
+
* a runnable deliverable; only executing the artifact is.
|
|
9
|
+
*
|
|
10
|
+
* Deterministic shape (findSkipEscapes → critique-rewrite pattern): a finding
|
|
11
|
+
* fires when the VERIFY block (a) grep/cat-asserts the SOURCE of a runnable
|
|
12
|
+
* file (.ts/.js/.sh — a build script, server, CLI entry) and (b) contains NO
|
|
13
|
+
* execution command at all — every command is static inspection (grep, test,
|
|
14
|
+
* ls, cat, tsc --noEmit, eslint, prettier). Any real execution anywhere in the
|
|
15
|
+
* block (bun/node/npm run/test, curl, ./script) means the deliverable-runs
|
|
16
|
+
* question is at worst partially covered, and we step aside — the guard may
|
|
17
|
+
* only cost time, never work, so recall is floored at the unambiguous
|
|
18
|
+
* all-static case rather than chasing which command exercises which file.
|
|
19
|
+
*/
|
|
20
|
+
import { parseVerifyBlock } from './spec-validation.js';
|
|
21
|
+
/** Commands that only inspect — they never execute the shipped artifact. */
|
|
22
|
+
const STATIC_HEADS = new Set([
|
|
23
|
+
'grep',
|
|
24
|
+
'rg',
|
|
25
|
+
'cat',
|
|
26
|
+
'ls',
|
|
27
|
+
'test',
|
|
28
|
+
'[',
|
|
29
|
+
'[[',
|
|
30
|
+
'find',
|
|
31
|
+
'wc',
|
|
32
|
+
'head',
|
|
33
|
+
'tail',
|
|
34
|
+
'diff',
|
|
35
|
+
'stat',
|
|
36
|
+
'echo',
|
|
37
|
+
'printf',
|
|
38
|
+
'true',
|
|
39
|
+
'false',
|
|
40
|
+
'cd',
|
|
41
|
+
'pwd',
|
|
42
|
+
'which',
|
|
43
|
+
'command',
|
|
44
|
+
'file',
|
|
45
|
+
'jq',
|
|
46
|
+
'sed',
|
|
47
|
+
'awk',
|
|
48
|
+
'sort',
|
|
49
|
+
'uniq',
|
|
50
|
+
'cut',
|
|
51
|
+
'tr',
|
|
52
|
+
'sleep',
|
|
53
|
+
'exit',
|
|
54
|
+
// static-analysis tools: they read source, they don't run the deliverable
|
|
55
|
+
'tsc',
|
|
56
|
+
'eslint',
|
|
57
|
+
'prettier',
|
|
58
|
+
'biome'
|
|
59
|
+
]);
|
|
60
|
+
/** A bare (unquoted) path token ending in a runnable-source extension. */
|
|
61
|
+
const RUNNABLE_SRC_RE = /^[\w@./-]+\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts|sh)$/;
|
|
62
|
+
/** Heads whose file arguments count as "inspecting the source of". */
|
|
63
|
+
const INSPECT_HEADS = new Set(['grep', 'rg', 'cat', 'head', 'tail', 'wc']);
|
|
64
|
+
/**
|
|
65
|
+
* Shell-control noise that precedes (or IS) a segment without being a command:
|
|
66
|
+
* `if grep -q x f; then` splits into an `if`-prefixed segment plus bare `then`;
|
|
67
|
+
* `… || { echo FAIL; exit 1; }` yields `{ echo …` and `}` segments. Treating
|
|
68
|
+
* these as unknown heads would count them as execution and silently blind the
|
|
69
|
+
* detector on exactly the incident shape (run-13's VERIFY used all of them).
|
|
70
|
+
*/
|
|
71
|
+
const CONTROL_PREFIX = new Set(['if', 'elif', 'while', 'until', 'then', 'else', 'do', '!']);
|
|
72
|
+
const CONTROL_ONLY = new Set(['}', ')', 'fi', 'done', 'esac']);
|
|
73
|
+
/**
|
|
74
|
+
* The effective head of one pipeline segment: shell-control prefixes, leading
|
|
75
|
+
* `(`/`{`, VAR=val prefixes and `timeout N` are skipped; `bunx`/`npx` resolve
|
|
76
|
+
* to the tool they invoke (so `bunx tsc --noEmit` is static).
|
|
77
|
+
* `bun`/`npm`/`yarn`/`pnpm`/`node` stay as themselves — whatever they run (a
|
|
78
|
+
* script, a test suite, a file) is execution.
|
|
79
|
+
*/
|
|
80
|
+
function segmentHead(segment) {
|
|
81
|
+
const tokens = segment.split(/\s+/).filter(t => t.length > 0);
|
|
82
|
+
let i = 0;
|
|
83
|
+
while (i < tokens.length) {
|
|
84
|
+
const t = tokens[i].replace(/^[({!]+/, '');
|
|
85
|
+
if (t.length === 0 || CONTROL_PREFIX.has(t)) {
|
|
86
|
+
i++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
tokens[i] = t;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
if (i >= tokens.length || CONTROL_ONLY.has(tokens[i]))
|
|
93
|
+
return null;
|
|
94
|
+
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]))
|
|
95
|
+
i++;
|
|
96
|
+
if (i < tokens.length && tokens[i] === 'timeout') {
|
|
97
|
+
i++;
|
|
98
|
+
if (i < tokens.length && /^\d/.test(tokens[i]))
|
|
99
|
+
i++;
|
|
100
|
+
}
|
|
101
|
+
if (i >= tokens.length)
|
|
102
|
+
return null;
|
|
103
|
+
let head = tokens[i];
|
|
104
|
+
if (head === 'bunx' || head === 'npx') {
|
|
105
|
+
i++;
|
|
106
|
+
while (i < tokens.length && tokens[i].startsWith('-'))
|
|
107
|
+
i++;
|
|
108
|
+
if (i >= tokens.length)
|
|
109
|
+
return null;
|
|
110
|
+
head = tokens[i];
|
|
111
|
+
}
|
|
112
|
+
return { head, args: tokens.slice(i + 1) };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Scan a composed spec's VERIFY block: all-static block that grep/cat-asserts
|
|
116
|
+
* runnable source ⇒ one finding per inspected file. Empty when the block
|
|
117
|
+
* contains any execution command, has no VERIFY block, or inspects no runnable
|
|
118
|
+
* source (doc/config-only tasks).
|
|
119
|
+
*/
|
|
120
|
+
export function findGrepOnlyVerify(spec) {
|
|
121
|
+
const cmds = parseVerifyBlock(spec);
|
|
122
|
+
if (!cmds)
|
|
123
|
+
return [];
|
|
124
|
+
const inspected = new Map();
|
|
125
|
+
for (const { raw } of cmds) {
|
|
126
|
+
// Split into pipeline segments; quotes are rare in VERIFY one-liners and
|
|
127
|
+
// a mis-split only risks a MISSED finding (a quoted `&&` making a fake
|
|
128
|
+
// segment whose head is unknown ⇒ counted as execution ⇒ step aside).
|
|
129
|
+
for (const segment of raw.split(/&&|\|\||;|\|/)) {
|
|
130
|
+
const s = segmentHead(segment);
|
|
131
|
+
if (s === null)
|
|
132
|
+
continue;
|
|
133
|
+
if (!STATIC_HEADS.has(s.head))
|
|
134
|
+
return []; // real execution — step aside
|
|
135
|
+
if (!INSPECT_HEADS.has(s.head))
|
|
136
|
+
continue;
|
|
137
|
+
for (const arg of s.args) {
|
|
138
|
+
if (arg.startsWith('-') || arg.startsWith("'") || arg.startsWith('"'))
|
|
139
|
+
continue;
|
|
140
|
+
if (!RUNNABLE_SRC_RE.test(arg))
|
|
141
|
+
continue;
|
|
142
|
+
const lines = inspected.get(arg) ?? [];
|
|
143
|
+
if (!lines.includes(raw))
|
|
144
|
+
lines.push(raw);
|
|
145
|
+
inspected.set(arg, lines);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return [...inspected.entries()].map(([target, lines]) => ({ target, lines }));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Retry hint when the critique rewrite KEPT the grep-theater block it was told
|
|
153
|
+
* to fix (live A/B: 1/5 rewrites ignored the injected defect). Prepended to the
|
|
154
|
+
* second rewrite attempt; the defect block naming the exact files is still in
|
|
155
|
+
* the prompt body.
|
|
156
|
+
*/
|
|
157
|
+
export const GREP_THEATER_RETRY_HINT = '[SYSTEM NOTE: Your previous rewrite still shipped a VERIFY block whose only signal '
|
|
158
|
+
+ 'on the runnable deliverable is grep-on-source — every command is static inspection '
|
|
159
|
+
+ '(grep/cat/test/tsc) and the artifact is never run. This exact shape shipped a broken '
|
|
160
|
+
+ 'build that stayed broken for 14 tasks. The rewritten VERIFY MUST execute the '
|
|
161
|
+
+ 'deliverable (e.g. `bun <script>.ts`, `bun run <script>`, start it and curl it) and '
|
|
162
|
+
+ 'assert an observable outcome of that run (exit code, a file the run produces, a '
|
|
163
|
+
+ 'served response). Keep greps only as additions to the run, never as the only signal.]';
|
|
164
|
+
/**
|
|
165
|
+
* Render the findings as a defect block for the critique rewrite: VERIFY must
|
|
166
|
+
* EXECUTE the runnable deliverable and assert an observable outcome of THAT
|
|
167
|
+
* run, not grep its source.
|
|
168
|
+
*/
|
|
169
|
+
export function grepOnlyVerifyDefectText(findings) {
|
|
170
|
+
return [
|
|
171
|
+
'GREP-THEATER VERIFY — every command in the VERIFY block is static inspection',
|
|
172
|
+
'(grep/cat/test/tsc), yet the deliverable includes runnable source. Grep-asserting',
|
|
173
|
+
'that a source file CONTAINS some text proves nothing about behavior (run-13: a',
|
|
174
|
+
'build script "verified" by greps shipped broken and stayed broken for 14 tasks',
|
|
175
|
+
'because `bun build.ts` was never run). Rewrite the VERIFY block so it EXECUTES the',
|
|
176
|
+
'runnable deliverable and asserts an OBSERVABLE OUTCOME of that run — exit code,',
|
|
177
|
+
'a produced file (`rm -rf dist && bun run build && test -f dist/…`), a served',
|
|
178
|
+
'response (`curl -sf http://…`). Keep static checks only as ADDITIONS to the run,',
|
|
179
|
+
'never as the sole signal. Runnable files currently only grep/cat-inspected:',
|
|
180
|
+
...findings.map((f, i) => ` ${i + 1}. ${f.target} — via: ${f.lines.join(' ; ')}`)
|
|
181
|
+
].join('\n');
|
|
182
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.27",
|
|
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",
|