@mjasnikovs/pi-task 0.28.2 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/config/config.d.ts +18 -0
- package/dist/config/config.js +19 -0
- package/dist/config/register.d.ts +11 -1
- package/dist/config/register.js +56 -6
- package/dist/config/tool-list.d.ts +50 -0
- package/dist/config/tool-list.js +53 -0
- package/dist/shared/command-watchdog.d.ts +11 -2
- package/dist/shared/command-watchdog.js +14 -4
- package/dist/task/command-watchdog.d.ts +5 -2
- package/dist/task/command-watchdog.js +6 -2
- package/dist/task/owned-freeze-conflict.d.ts +95 -0
- package/dist/task/owned-freeze-conflict.js +359 -0
- package/dist/task/phases.d.ts +12 -1
- package/dist/task/phases.js +15 -2
- package/dist/task/verify-quality.d.ts +23 -0
- package/dist/task/verify-quality.js +22 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -184,6 +184,7 @@ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings pe
|
|
|
184
184
|
| **stuck reply retry** | 10 min | Inactivity ceiling on the **model stream**. A hung or silently-dropped stream throws nothing at all, so neither the connection-error retry (it needs a reported error) nor the **command timeout** (tool calls only) nor the dead-backend stall guard (a reachable endpoint reads as proof of life) can see it — an mx5 run lost ~2.9h to three of them while the model server stayed healthy. Measured as time since the **last stream event of any kind**, so a slow model emitting one token every 30s is never touched, and it pauses while a tool runs. On expiry the main session aborts the turn (through the same channel the command watchdog uses) and posts a resume reminder; a child is killed and routed into the existing connection-error retry. Choices: 5/10/20/30 min or **off**. Keep it generous on local backends — prompt processing on a large context legitimately emits nothing for minutes. |
|
|
185
185
|
| **yolo mode** | off | **Unattended runs.** Wherever pi-task would stop and ask, it takes the option already marked RECOMMENDED, stamps the artifact `(YOLO)` so an audit can tell a machine decided, and shows no prompt at all — clarify/grill answers, the verify-FAIL picker (auto-**Accept**, recorded as a yolo debt), and the final-gate picker (autofix while the budget lasts, then leave the run FAILED). A question with no recommendation is **skipped**, never invented. For throwaway/test projects nobody is watching; a real run should decide these itself. |
|
|
186
186
|
| **debug logs** | events | How much of a run is written to `.pi-tasks/*-debug.log`. **`events`** keeps decisions and guard actions — which phase ran, why a worker was retried, what the git-state guard restored, what a write-capable child changed on disk, why a gate returned FAIL — a few lines per task. **`full`** adds every line the child model emitted and every tool result; that's ~85% of the bytes (a real 247 KB `verify-debug.log` is 1315 lines, 521 of them tool dumps) and is what you want while actively debugging. **`off`** writes nothing. Nothing in pi-task ever reads these files back, so the setting cannot change how a run behaves — only whether you can explain it afterwards, and a log not written can't be recovered later. |
|
|
187
|
+
| **watch: …** | all on | One toggle per tool in the live session, deciding whether **command timeout** applies to it. The list is discovered from `pi.getAllTools()` when the menu opens — built-ins first, then each extension's tools with the owning entry-point path in the description — so nothing is typed by hand and an uninstalled tool just stops being listed. Turn one **off** only for a tool that already owns a longer bounded, cancellable contract of its own (the guard exists because pi's `bash` has an optional timeout with *no* default — that reasoning doesn't transfer to a tool that has one). Two things to know before you do: a genuine hang in an unwatched tool is caught by nothing, since **stuck reply retry** is paused for the whole time any tool runs; and an unwatched tool is still killed as collateral if a *watched* sibling in the same turn overruns, because pi runs sibling tool calls concurrently and the abort ends the whole turn. Stored as exemptions, so the default and every tool pi-task has never seen stay guarded. |
|
|
187
188
|
| **ext: …** | all off | One toggle per installed host `pi` extension, loading it into every child session by explicit path. Children otherwise run with extensions off, so a provider registered by an extension (e.g. `pi-lmstudio`) doesn't exist in them and they can't resolve the default model. Children also inherit the extension's tools and hooks, so only enable ones you trust. The list is strictly additive (discovery stays off), and an entry whose file is gone is skipped at spawn time, never fatal. |
|
|
188
189
|
|
|
189
190
|
## Configuration
|
package/dist/config/config.d.ts
CHANGED
|
@@ -61,6 +61,22 @@ export interface PiTaskConfig {
|
|
|
61
61
|
* a true hang doesn't cost half an hour of dead time.
|
|
62
62
|
*/
|
|
63
63
|
requestTimeoutMs: number;
|
|
64
|
+
/**
|
|
65
|
+
* Tools the generic command watchdog must NOT arm on, by exact name — for
|
|
66
|
+
* tools that own a stronger, domain-specific timeout and cancellation
|
|
67
|
+
* contract of their own and would otherwise be aborted mid-transaction at
|
|
68
|
+
* the generic ceiling. `bash` and every unlisted tool stay guarded.
|
|
69
|
+
*
|
|
70
|
+
* Stored as the EXEMPTIONS, not the guarded set, so the default (`[]`) and
|
|
71
|
+
* every tool pi-task has never seen are guarded — a new or renamed tool can
|
|
72
|
+
* never silently lose its watchdog.
|
|
73
|
+
*
|
|
74
|
+
* Populated from `/task-config`'s `watch:` rows, which are discovered from
|
|
75
|
+
* the live session via `pi.getAllTools()` (see config/tool-list.ts), so the
|
|
76
|
+
* names here are pi's own and never hand-typed. A stale entry left behind by
|
|
77
|
+
* an uninstalled tool matches nothing and is harmless.
|
|
78
|
+
*/
|
|
79
|
+
commandTimeoutExemptTools: string[];
|
|
64
80
|
/**
|
|
65
81
|
* Inactivity ceiling (ms) on the MODEL STREAM before the stream watchdog
|
|
66
82
|
* aborts the request (shared/stream-watchdog.ts). A hung or silently-dropped
|
|
@@ -148,6 +164,8 @@ export declare const COMMAND_TIMEOUT_OPTIONS: ReadonlyArray<{
|
|
|
148
164
|
* one of the offered choices so the watchdog never arms on a nonsense value.
|
|
149
165
|
*/
|
|
150
166
|
export declare function sanitizeRequestTimeoutMs(value: unknown): number;
|
|
167
|
+
/** Keep only exact, unique Pi tool names from an advanced config override. */
|
|
168
|
+
export declare function sanitizeCommandTimeoutExemptTools(value: unknown): string[];
|
|
151
169
|
/**
|
|
152
170
|
* The stream-watchdog choices offered by /task-config, in cycle order. Every
|
|
153
171
|
* option is minutes, not seconds: the failure this guards costs hours, and the
|
package/dist/config/config.js
CHANGED
|
@@ -43,6 +43,23 @@ export function sanitizeRequestTimeoutMs(value) {
|
|
|
43
43
|
value
|
|
44
44
|
: DEFAULT_REQUEST_TIMEOUT_MS;
|
|
45
45
|
}
|
|
46
|
+
/** Keep only exact, unique Pi tool names from an advanced config override. */
|
|
47
|
+
export function sanitizeCommandTimeoutExemptTools(value) {
|
|
48
|
+
if (!Array.isArray(value))
|
|
49
|
+
return [];
|
|
50
|
+
const result = [];
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
for (const item of value) {
|
|
53
|
+
if (typeof item !== 'string')
|
|
54
|
+
continue;
|
|
55
|
+
const name = item.trim();
|
|
56
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9_.:-]*$/.test(name) || seen.has(name))
|
|
57
|
+
continue;
|
|
58
|
+
seen.add(name);
|
|
59
|
+
result.push(name);
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
46
63
|
/**
|
|
47
64
|
* The stream-watchdog choices offered by /task-config, in cycle order. Every
|
|
48
65
|
* option is minutes, not seconds: the failure this guards costs hours, and the
|
|
@@ -76,6 +93,7 @@ const DEFAULTS = {
|
|
|
76
93
|
searchProvider: 'exa',
|
|
77
94
|
extensionWhitelist: [],
|
|
78
95
|
requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
|
|
96
|
+
commandTimeoutExemptTools: [],
|
|
79
97
|
streamInactivityMs: DEFAULT_STREAM_INACTIVITY_MS,
|
|
80
98
|
// OFF: auto-answering is for unattended throwaway runs only.
|
|
81
99
|
yoloMode: false,
|
|
@@ -110,6 +128,7 @@ if (!G.loaded) {
|
|
|
110
128
|
delete parsed.searchProvider;
|
|
111
129
|
parsed.extensionWhitelist = sanitizeExtensionWhitelist(parsed.extensionWhitelist);
|
|
112
130
|
parsed.requestTimeoutMs = sanitizeRequestTimeoutMs(parsed.requestTimeoutMs);
|
|
131
|
+
parsed.commandTimeoutExemptTools = sanitizeCommandTimeoutExemptTools(parsed.commandTimeoutExemptTools);
|
|
113
132
|
parsed.streamInactivityMs = sanitizeStreamInactivityMs(parsed.streamInactivityMs);
|
|
114
133
|
// A hand-edited `"yoloMode": "false"` is a truthy string — it must not
|
|
115
134
|
// silently switch a watched run into unattended auto-pick. Only a real
|
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-c
|
|
|
2
2
|
import type { Component } from '@earendil-works/pi-tui';
|
|
3
3
|
import { type PiTaskConfig } from './config.js';
|
|
4
4
|
import { type InstalledExtension } from './extension-list.js';
|
|
5
|
+
import { type GuardableTool } from './tool-list.js';
|
|
5
6
|
type Theme = ExtensionCommandContext['ui']['theme'];
|
|
6
7
|
/**
|
|
7
8
|
* Frames a child component (the settings list) in a rounded border with a title
|
|
@@ -46,6 +47,15 @@ export declare function extensionItems(extensions: InstalledExtension[], whiteli
|
|
|
46
47
|
}[];
|
|
47
48
|
/** Apply an extension toggle to the config's whitelist (idempotent both ways). */
|
|
48
49
|
export declare function applyExtensionToggle(whitelist: readonly string[], entryPath: string, on: boolean): string[];
|
|
50
|
+
export declare function toolItems(tools: readonly GuardableTool[], exempt: readonly string[]): {
|
|
51
|
+
id: string;
|
|
52
|
+
label: string;
|
|
53
|
+
description: string;
|
|
54
|
+
currentValue: string;
|
|
55
|
+
values: string[];
|
|
56
|
+
}[];
|
|
57
|
+
/** Apply a per-tool watchdog toggle to the exemption list (idempotent both ways). */
|
|
58
|
+
export declare function applyToolToggle(exempt: readonly string[], toolName: string, watched: boolean): string[];
|
|
49
59
|
/**
|
|
50
60
|
* Tallest body the settings list can render, so {@link BorderedBox} can pad
|
|
51
61
|
* every frame to it and hold the border still. Mirrors SettingsList's own
|
|
@@ -68,6 +78,6 @@ export type PanelItem = {
|
|
|
68
78
|
*/
|
|
69
79
|
export declare function createSettingsPanel(items: PanelItem[], theme: Theme, onChange: (id: string, newValue: string) => void, onCancel: () => void): BorderedBox;
|
|
70
80
|
/** The full settings row list for the current config, in menu order. */
|
|
71
|
-
export declare function panelItems(cfg: PiTaskConfig, installed: InstalledExtension[]): PanelItem[];
|
|
81
|
+
export declare function panelItems(cfg: PiTaskConfig, installed: InstalledExtension[], tools?: readonly GuardableTool[]): PanelItem[];
|
|
72
82
|
export declare function registerConfig(pi: ExtensionAPI): void;
|
|
73
83
|
export {};
|
package/dist/config/register.js
CHANGED
|
@@ -4,6 +4,7 @@ import { readPkgVersion } from '../shared/pkg-version.js';
|
|
|
4
4
|
import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../workers/search-types.js';
|
|
5
5
|
import { COMMAND_TIMEOUT_OPTIONS, DEBUG_LOG_OPTIONS, getConfig, sanitizeDebugLogs, saveConfig, STREAM_INACTIVITY_OPTIONS } from './config.js';
|
|
6
6
|
import { listInstalledExtensions } from './extension-list.js';
|
|
7
|
+
import { listGuardableTools } from './tool-list.js';
|
|
7
8
|
// Version in the title so a bug report or screenshot says which build it came
|
|
8
9
|
// from without anyone having to go look it up.
|
|
9
10
|
const CONFIG_TITLE = `pi-task ${readPkgVersion()} settings`;
|
|
@@ -215,6 +216,35 @@ export function applyExtensionToggle(whitelist, entryPath, on) {
|
|
|
215
216
|
const rest = whitelist.filter(p => p !== entryPath);
|
|
216
217
|
return on ? [...rest, entryPath] : rest;
|
|
217
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* One /task-config toggle per tool in the live session, so the command watchdog
|
|
221
|
+
* can be turned off for a single tool without unguarding `bash` with it.
|
|
222
|
+
*
|
|
223
|
+
* The list is DISCOVERED (see tool-list.ts), never typed: the id carries the
|
|
224
|
+
* exact tool name pi reports, which is the same string the watchdog matches on.
|
|
225
|
+
* A tool that is uninstalled simply stops being listed, and a stale name left in
|
|
226
|
+
* the config matches nothing.
|
|
227
|
+
*/
|
|
228
|
+
const TOOL_ID_PREFIX = 'tool:';
|
|
229
|
+
export function toolItems(tools, exempt) {
|
|
230
|
+
return tools.map(t => ({
|
|
231
|
+
id: TOOL_ID_PREFIX + t.name,
|
|
232
|
+
label: `watch: ${t.name}`,
|
|
233
|
+
description: `Apply the command timeout to this tool. Leave it on unless the tool runs its own `
|
|
234
|
+
+ `bounded, cancellable work for longer than the timeout — turning it off means a `
|
|
235
|
+
+ `genuine hang in this tool will never be caught, and nothing else is watching `
|
|
236
|
+
+ `while a tool runs. ${t.origin}`,
|
|
237
|
+
// Stored inverted: the config records the EXEMPTIONS, so an empty list
|
|
238
|
+
// (and any tool pi-task has never heard of) stays guarded by default.
|
|
239
|
+
currentValue: exempt.includes(t.name) ? 'off' : 'on',
|
|
240
|
+
values: ['on', 'off']
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
/** Apply a per-tool watchdog toggle to the exemption list (idempotent both ways). */
|
|
244
|
+
export function applyToolToggle(exempt, toolName, watched) {
|
|
245
|
+
const rest = exempt.filter(n => n !== toolName);
|
|
246
|
+
return watched ? rest : [...rest, toolName];
|
|
247
|
+
}
|
|
218
248
|
/** Overlay width; the list gets `- 4` of it, the description `- 4` again. */
|
|
219
249
|
const OVERLAY_WIDTH = 68;
|
|
220
250
|
/** Settings rows shown at once before the list scrolls. */
|
|
@@ -259,7 +289,7 @@ export function createSettingsPanel(items, theme, onChange, onCancel) {
|
|
|
259
289
|
return new BorderedBox(list, CONFIG_TITLE, s => theme.fg('borderMuted', s), s => theme.fg('accent', theme.bold(s)), settingsBodyHeight(items.map(i => i.description), MAX_VISIBLE, OVERLAY_WIDTH - 8));
|
|
260
290
|
}
|
|
261
291
|
/** The full settings row list for the current config, in menu order. */
|
|
262
|
-
export function panelItems(cfg, installed) {
|
|
292
|
+
export function panelItems(cfg, installed, tools = []) {
|
|
263
293
|
return [
|
|
264
294
|
...ITEMS.map(({ id, label, description, values }) => ({
|
|
265
295
|
id: id,
|
|
@@ -268,17 +298,30 @@ export function panelItems(cfg, installed) {
|
|
|
268
298
|
currentValue: displayValue(cfg, id, Boolean(values)),
|
|
269
299
|
values: values ?? ['on', 'off']
|
|
270
300
|
})),
|
|
301
|
+
...toolItems(tools, cfg.commandTimeoutExemptTools),
|
|
271
302
|
...extensionItems(installed, cfg.extensionWhitelist)
|
|
272
303
|
];
|
|
273
304
|
}
|
|
274
|
-
async function handleTaskConfig(_args, ctx) {
|
|
275
|
-
const cfg = {
|
|
305
|
+
async function handleTaskConfig(_args, ctx, getTools = () => []) {
|
|
306
|
+
const cfg = {
|
|
307
|
+
...getConfig(),
|
|
308
|
+
extensionWhitelist: [...getConfig().extensionWhitelist],
|
|
309
|
+
commandTimeoutExemptTools: [...getConfig().commandTimeoutExemptTools]
|
|
310
|
+
};
|
|
276
311
|
// Enumerated live at open so an installed extension appears and an
|
|
277
312
|
// uninstalled one vanishes without pi-task doing any bookkeeping. A failed
|
|
278
313
|
// enumeration only costs the extension toggles, never the whole menu.
|
|
279
314
|
const installed = await listInstalledExtensions({ cwd: ctx.cwd }).catch(() => []);
|
|
315
|
+
// Same contract for tools, and for the same reason — plus one pi-specific
|
|
316
|
+
// one: getAllTools() throws until the extension runtime is initialized, so
|
|
317
|
+
// it can only be read here, when the menu opens, never at registration.
|
|
318
|
+
const tools = getTools();
|
|
280
319
|
if (ctx.mode !== 'tui') {
|
|
281
320
|
const lines = ITEMS.map(({ id, label, values }) => `${label.padEnd(22)} ${displayValue(cfg, id, Boolean(values))}`);
|
|
321
|
+
for (const t of tools) {
|
|
322
|
+
const state = cfg.commandTimeoutExemptTools.includes(t.name) ? 'off' : 'on';
|
|
323
|
+
lines.push(`${('watch: ' + t.name).padEnd(22)} ${state}`);
|
|
324
|
+
}
|
|
282
325
|
for (const e of installed) {
|
|
283
326
|
const state = cfg.extensionWhitelist.includes(e.path) ? 'on' : 'off';
|
|
284
327
|
lines.push(`${('ext: ' + e.label).padEnd(22)} ${state}`);
|
|
@@ -286,10 +329,13 @@ async function handleTaskConfig(_args, ctx) {
|
|
|
286
329
|
ctx.ui.notify(lines.join(' | '), 'info');
|
|
287
330
|
return;
|
|
288
331
|
}
|
|
289
|
-
await ctx.ui.custom((_tui, theme, _kb, done) => createSettingsPanel(panelItems(cfg, installed), theme, (id, newValue) => {
|
|
332
|
+
await ctx.ui.custom((_tui, theme, _kb, done) => createSettingsPanel(panelItems(cfg, installed, tools), theme, (id, newValue) => {
|
|
290
333
|
if (id.startsWith(EXT_ID_PREFIX)) {
|
|
291
334
|
cfg.extensionWhitelist = applyExtensionToggle(cfg.extensionWhitelist, id.slice(EXT_ID_PREFIX.length), newValue === 'on');
|
|
292
335
|
}
|
|
336
|
+
else if (id.startsWith(TOOL_ID_PREFIX)) {
|
|
337
|
+
cfg.commandTimeoutExemptTools = applyToolToggle(cfg.commandTimeoutExemptTools, id.slice(TOOL_ID_PREFIX.length), newValue === 'on');
|
|
338
|
+
}
|
|
293
339
|
else if (id === 'searchProvider') {
|
|
294
340
|
const provider = providerForLabel(newValue);
|
|
295
341
|
if (provider)
|
|
@@ -321,7 +367,11 @@ async function handleTaskConfig(_args, ctx) {
|
|
|
321
367
|
export function registerConfig(pi) {
|
|
322
368
|
registerBridgeCommand(pi, 'task-config', {
|
|
323
369
|
description: 'Configure pi-task settings (remote control, auto-commit, verify work, enforce '
|
|
324
|
-
+ 'guidelines, research, timeouts, extensions for helper
|
|
325
|
-
|
|
370
|
+
+ 'guidelines, research, timeouts, per-tool command watchdog, extensions for helper '
|
|
371
|
+
+ 'sessions).',
|
|
372
|
+
// `pi` is closed over rather than taken from ctx: ExtensionCommandContext
|
|
373
|
+
// has no tool accessor, and the read must happen inside the handler
|
|
374
|
+
// anyway (getAllTools throws until the runtime is initialized).
|
|
375
|
+
handler: (args, ctx) => handleTaskConfig(args, ctx, () => listGuardableTools(pi))
|
|
326
376
|
});
|
|
327
377
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
/**
|
|
3
|
+
* The tools /task-config offers a command-watchdog toggle for, discovered from
|
|
4
|
+
* the live session rather than named by hand.
|
|
5
|
+
*
|
|
6
|
+
* WHY DISCOVERY AND NOT A HAND-EDITED NAME LIST: the watchdog arms on ANY tool
|
|
7
|
+
* (see task/command-watchdog.ts), which is correct for `bash` — pi's bash takes
|
|
8
|
+
* an OPTIONAL `timeout` with no default, so an unbounded command runs forever —
|
|
9
|
+
* but wrong for an extension tool that already owns a longer, bounded contract
|
|
10
|
+
* of its own. Those tools get aborted mid-transaction at the generic ceiling.
|
|
11
|
+
* The operator has to be able to say "not this one", and the only identity that
|
|
12
|
+
* survives a rename or an uninstall is the one pi itself reports.
|
|
13
|
+
*
|
|
14
|
+
* MEASURED against pi 0.83.0 (`pi.getAllTools()`), not assumed:
|
|
15
|
+
* - built-ins report `source: "builtin"`, `path: "<builtin:bash>"`
|
|
16
|
+
* - extension tools report the extension's real entry-point path — the SAME
|
|
17
|
+
* identity `extensionWhitelist` keys on (see extension-list.ts)
|
|
18
|
+
* - `getAllTools()` THROWS during extension loading ("Extension runtime not
|
|
19
|
+
* initialized"); it is only callable from `session_start` onward, which is
|
|
20
|
+
* why this is read when the menu opens and never at registration.
|
|
21
|
+
* - `getActiveTools()` is a strict SUBSET (4 of 7 built-ins in a plain
|
|
22
|
+
* session), so it is the wrong source: a tool the model isn't currently
|
|
23
|
+
* offered is still a tool the watchdog would arm on if it ran.
|
|
24
|
+
*/
|
|
25
|
+
export interface GuardableTool {
|
|
26
|
+
/** Exact tool name — the watchdog's key, and what the config stores. */
|
|
27
|
+
name: string;
|
|
28
|
+
/** Provenance shown in the menu, e.g. "built in" or "npm:pi-fable". */
|
|
29
|
+
origin: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Every tool in the live session, built-ins first and each extension's tools in
|
|
33
|
+
* registration order after them, so the menu reads owner-by-owner.
|
|
34
|
+
*
|
|
35
|
+
* Pure over the ToolInfo list so the ordering and labelling are unit-testable
|
|
36
|
+
* without a running pi; {@link listGuardableTools} supplies the live one.
|
|
37
|
+
*/
|
|
38
|
+
export declare function toGuardableTools(tools: readonly {
|
|
39
|
+
name: string;
|
|
40
|
+
sourceInfo: {
|
|
41
|
+
source: string;
|
|
42
|
+
path: string;
|
|
43
|
+
};
|
|
44
|
+
}[]): GuardableTool[];
|
|
45
|
+
/**
|
|
46
|
+
* The live tool list, or `[]` if pi cannot answer. Enumeration failing must cost
|
|
47
|
+
* only the per-tool rows, never the whole settings menu — the same contract
|
|
48
|
+
* listInstalledExtensions() has in the command handler.
|
|
49
|
+
*/
|
|
50
|
+
export declare function listGuardableTools(pi: ExtensionAPI): GuardableTool[];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** `source` values pi reports for its own tools rather than an extension's. */
|
|
2
|
+
const BUILTIN_SOURCE = 'builtin';
|
|
3
|
+
/**
|
|
4
|
+
* Human provenance for a tool's source metadata. `source` is pi's own word for
|
|
5
|
+
* where the owner came from ("builtin", "npm:...", "auto", "cli"); the two
|
|
6
|
+
* discovery-shaped values are spelled out because "auto" tells the operator
|
|
7
|
+
* nothing about which extension they are about to unguard.
|
|
8
|
+
*/
|
|
9
|
+
function toolOrigin(source, path) {
|
|
10
|
+
if (source === BUILTIN_SOURCE)
|
|
11
|
+
return 'built in';
|
|
12
|
+
if (source === 'auto')
|
|
13
|
+
return `discovered (${path})`;
|
|
14
|
+
if (source === 'cli')
|
|
15
|
+
return `-e flag (${path})`;
|
|
16
|
+
return `${source} (${path})`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Every tool in the live session, built-ins first and each extension's tools in
|
|
20
|
+
* registration order after them, so the menu reads owner-by-owner.
|
|
21
|
+
*
|
|
22
|
+
* Pure over the ToolInfo list so the ordering and labelling are unit-testable
|
|
23
|
+
* without a running pi; {@link listGuardableTools} supplies the live one.
|
|
24
|
+
*/
|
|
25
|
+
export function toGuardableTools(tools) {
|
|
26
|
+
const builtins = [];
|
|
27
|
+
const rest = [];
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
for (const t of tools) {
|
|
30
|
+
// A duplicate name cannot be told apart by the watchdog (it only ever
|
|
31
|
+
// sees `toolName`), so a second registration must not add a second row
|
|
32
|
+
// that silently toggles the first one's guard.
|
|
33
|
+
if (seen.has(t.name))
|
|
34
|
+
continue;
|
|
35
|
+
seen.add(t.name);
|
|
36
|
+
const entry = { name: t.name, origin: toolOrigin(t.sourceInfo.source, t.sourceInfo.path) };
|
|
37
|
+
(t.sourceInfo.source === BUILTIN_SOURCE ? builtins : rest).push(entry);
|
|
38
|
+
}
|
|
39
|
+
return [...builtins, ...rest];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The live tool list, or `[]` if pi cannot answer. Enumeration failing must cost
|
|
43
|
+
* only the per-tool rows, never the whole settings menu — the same contract
|
|
44
|
+
* listInstalledExtensions() has in the command handler.
|
|
45
|
+
*/
|
|
46
|
+
export function listGuardableTools(pi) {
|
|
47
|
+
try {
|
|
48
|
+
return toGuardableTools(pi.getAllTools());
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -18,8 +18,15 @@
|
|
|
18
18
|
* TIMER STATE MACHINE is identical, and lives here once. What differs is only
|
|
19
19
|
* the `onFire` side effect, which each adapter supplies:
|
|
20
20
|
*
|
|
21
|
-
* main session — ctx.abort()
|
|
22
|
-
*
|
|
21
|
+
* main session — ctx.abort() ends the whole agent operation, not just the one
|
|
22
|
+
* tool call (pi types it "Abort the current agent operation"),
|
|
23
|
+
* and pi runs sibling tool calls CONCURRENTLY by default. So an
|
|
24
|
+
* overrun kills every tool in flight in that turn — including
|
|
25
|
+
* one exempted via commandTimeoutExemptTools, which only stops
|
|
26
|
+
* a timer being armed FOR that tool, not its being collateral
|
|
27
|
+
* when a guarded sibling trips. There is no per-call
|
|
28
|
+
* cancellation channel to do better with. The session itself
|
|
29
|
+
* survives to receive the follow-up reminder turn.
|
|
23
30
|
* child — there is no per-tool cancellation channel into a child, so
|
|
24
31
|
* the whole child is killed and re-spawned with
|
|
25
32
|
* {@link commandTimeoutHint} prepended. Coarser by necessity:
|
|
@@ -38,6 +45,8 @@ export interface WatchdogDeps {
|
|
|
38
45
|
* 0 (or any non-positive value) means the watchdog is off and never arms.
|
|
39
46
|
*/
|
|
40
47
|
getTimeoutMs: () => number;
|
|
48
|
+
/** Optional exact policy for tools with their own bounded execution contract. */
|
|
49
|
+
shouldWatch?: (toolName: string) => boolean;
|
|
41
50
|
schedule: (fn: () => void, ms: number) => TimerHandle;
|
|
42
51
|
cancel: (handle: TimerHandle) => void;
|
|
43
52
|
/** Invoked when a command overruns: each adapter aborts/kills here. */
|
|
@@ -18,8 +18,15 @@
|
|
|
18
18
|
* TIMER STATE MACHINE is identical, and lives here once. What differs is only
|
|
19
19
|
* the `onFire` side effect, which each adapter supplies:
|
|
20
20
|
*
|
|
21
|
-
* main session — ctx.abort()
|
|
22
|
-
*
|
|
21
|
+
* main session — ctx.abort() ends the whole agent operation, not just the one
|
|
22
|
+
* tool call (pi types it "Abort the current agent operation"),
|
|
23
|
+
* and pi runs sibling tool calls CONCURRENTLY by default. So an
|
|
24
|
+
* overrun kills every tool in flight in that turn — including
|
|
25
|
+
* one exempted via commandTimeoutExemptTools, which only stops
|
|
26
|
+
* a timer being armed FOR that tool, not its being collateral
|
|
27
|
+
* when a guarded sibling trips. There is no per-call
|
|
28
|
+
* cancellation channel to do better with. The session itself
|
|
29
|
+
* survives to receive the follow-up reminder turn.
|
|
23
30
|
* child — there is no per-tool cancellation channel into a child, so
|
|
24
31
|
* the whole child is killed and re-spawned with
|
|
25
32
|
* {@link commandTimeoutHint} prepended. Coarser by necessity:
|
|
@@ -105,11 +112,14 @@ export class CommandWatchdog {
|
|
|
105
112
|
}
|
|
106
113
|
/** Arm a timer for a starting tool. No-op when the watchdog is off. */
|
|
107
114
|
onStart(toolCallId, toolName) {
|
|
115
|
+
// Disarm first so a live policy/config change cannot leave a timer from
|
|
116
|
+
// a duplicate start armed after the tool becomes exempt or watchdog-off.
|
|
117
|
+
this.disarm(toolCallId);
|
|
118
|
+
if (this.deps.shouldWatch?.(toolName) === false)
|
|
119
|
+
return;
|
|
108
120
|
const ms = this.deps.getTimeoutMs();
|
|
109
121
|
if (!(ms > 0))
|
|
110
122
|
return;
|
|
111
|
-
// A duplicate start for the same id must not leak the previous timer.
|
|
112
|
-
this.disarm(toolCallId);
|
|
113
123
|
const handle = this.deps.schedule(() => this.fire(toolCallId, toolName, ms), ms);
|
|
114
124
|
this.active.set(toolCallId, handle);
|
|
115
125
|
}
|
|
@@ -15,8 +15,11 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
|
15
15
|
* the whole process tree on abort — then a follow-up user turn tells the model
|
|
16
16
|
* what happened so it retries with a timeout instead of hanging again.
|
|
17
17
|
*
|
|
18
|
-
* Tool-agnostic: it arms on
|
|
19
|
-
*
|
|
18
|
+
* Tool-agnostic by default: it arms on every tool except exact names listed in
|
|
19
|
+
* `commandTimeoutExemptTools`, which /task-config fills from the live tool list
|
|
20
|
+
* (config/tool-list.ts). Exemptions are for tools that already own a bounded
|
|
21
|
+
* timeout and cancellation contract — the guard's whole justification is that
|
|
22
|
+
* pi's bash has NO default timeout, which says nothing about a tool that does.
|
|
20
23
|
*
|
|
21
24
|
* SCOPE — this covers the main session ONLY, which is where the implementation
|
|
22
25
|
* turn runs (orchestrator hands the spec off via sendUserMessage). Gate
|
|
@@ -16,8 +16,11 @@ import { CommandWatchdog, realTimerDeps, reminderMessage } from '../shared/comma
|
|
|
16
16
|
* the whole process tree on abort — then a follow-up user turn tells the model
|
|
17
17
|
* what happened so it retries with a timeout instead of hanging again.
|
|
18
18
|
*
|
|
19
|
-
* Tool-agnostic: it arms on
|
|
20
|
-
*
|
|
19
|
+
* Tool-agnostic by default: it arms on every tool except exact names listed in
|
|
20
|
+
* `commandTimeoutExemptTools`, which /task-config fills from the live tool list
|
|
21
|
+
* (config/tool-list.ts). Exemptions are for tools that already own a bounded
|
|
22
|
+
* timeout and cancellation contract — the guard's whole justification is that
|
|
23
|
+
* pi's bash has NO default timeout, which says nothing about a tool that does.
|
|
21
24
|
*
|
|
22
25
|
* SCOPE — this covers the main session ONLY, which is where the implementation
|
|
23
26
|
* turn runs (orchestrator hands the spec off via sendUserMessage). Gate
|
|
@@ -64,6 +67,7 @@ export function registerCommandWatchdog(pi) {
|
|
|
64
67
|
const ctxByCall = new Map();
|
|
65
68
|
const watchdog = new CommandWatchdog({
|
|
66
69
|
getTimeoutMs: () => getConfig().requestTimeoutMs,
|
|
70
|
+
shouldWatch: toolName => !getConfig().commandTimeoutExemptTools.includes(toolName),
|
|
67
71
|
...realTimerDeps,
|
|
68
72
|
onFire: (toolCallId, toolName, timeoutMs) => {
|
|
69
73
|
const ctx = ctxByCall.get(toolCallId);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { OwnedRequirement } from './requirements.js';
|
|
2
|
+
/** A freeze whose scope is a CATEGORY of files, with the paths it carves out. */
|
|
3
|
+
export interface CategoryFreeze {
|
|
4
|
+
/** The freeze line, verbatim. */
|
|
5
|
+
constraint: string;
|
|
6
|
+
/** The paths the category exempts — everything else is frozen. */
|
|
7
|
+
exempt: string[];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* One unsatisfiable pair, keyed by the REQUIREMENT rather than by the path.
|
|
11
|
+
*
|
|
12
|
+
* Grouping matters for the resolution's size. mx5 run 18's TASK_0023 carries
|
|
13
|
+
* two owned build-contract clauses that between them name three frozen paths;
|
|
14
|
+
* per-path findings would demand three separate ownership grants (and the same
|
|
15
|
+
* pair twice over, because that spec states its freeze in CONSTRAINTS and again
|
|
16
|
+
* in ACCEPTANCE). Per requirement, the rewrite is told which files that one
|
|
17
|
+
* obligation names and grants only what it needs — which is what keeps
|
|
18
|
+
* `inv-no-spec-inflation` satisfiable at all.
|
|
19
|
+
*/
|
|
20
|
+
export interface OwnedFreezeConflict {
|
|
21
|
+
/** The owned requirement line, verbatim. */
|
|
22
|
+
requirement: string;
|
|
23
|
+
/** The files it names that this freeze covers. */
|
|
24
|
+
paths: string[];
|
|
25
|
+
/** The category freeze line, verbatim. */
|
|
26
|
+
constraint: string;
|
|
27
|
+
/** The paths that freeze exempts (the resolution has to widen this, or
|
|
28
|
+
* move the requirement to a task whose scope already includes the file). */
|
|
29
|
+
exempt: string[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Backtick-quoted path-shaped tokens in a text, INCLUDING the ones embedded in
|
|
33
|
+
* a backticked command (`bun run --watch src/server/index.ts` is one backtick
|
|
34
|
+
* span; the path inside it is what the requirement is about). Route literals
|
|
35
|
+
* (`/api`) and bare directories survive this filter — the caller decides what
|
|
36
|
+
* to do with them; `findOwnedFreezeConflicts` keeps only files.
|
|
37
|
+
*/
|
|
38
|
+
export declare function pathTokensIn(text: string): string[];
|
|
39
|
+
/**
|
|
40
|
+
* Every CATEGORY freeze in the spec: a modification ban (or its passive/only-
|
|
41
|
+
* form equivalent) scoped to a class of files rather than to named paths. A
|
|
42
|
+
* freeze that only names paths is `frozen-conflict.ts`'s job, not this one.
|
|
43
|
+
*/
|
|
44
|
+
export declare function findCategoryFreezes(spec: string | null | undefined): CategoryFreeze[];
|
|
45
|
+
/**
|
|
46
|
+
* The spec lines carrying an AUTHORITATIVE owned requirement: the machine-
|
|
47
|
+
* stamped ones, plus — when the run's ledger is supplied — any line carrying an
|
|
48
|
+
* owned quote verbatim (compose folding the quote in itself leaves no stamp).
|
|
49
|
+
*/
|
|
50
|
+
export declare function ownedRequirementLines(spec: string | null | undefined, owned?: OwnedRequirement[]): string[];
|
|
51
|
+
export interface OwnedFreezeOptions {
|
|
52
|
+
/** The run's owned-requirement ledger, so belt-folded (unstamped) quotes
|
|
53
|
+
* count too. Omitted → stamped lines only. */
|
|
54
|
+
owned?: OwnedRequirement[];
|
|
55
|
+
/**
|
|
56
|
+
* Is this repo-relative token an existing SOURCE file of the tree the spec
|
|
57
|
+
* will run against? Supplied by the caller (compose knows its cwd; the
|
|
58
|
+
* measured implementation is "tracked by git" — see `trackedSourceOracle`).
|
|
59
|
+
*
|
|
60
|
+
* Two false-positive classes die here, both observed at STEP 0 on the real
|
|
61
|
+
* TASK_0023 spec: route literals and build outputs (`/api`, `dist/`,
|
|
62
|
+
* `dist/app.css` — named by the very clause that is the true positive, but
|
|
63
|
+
* not files anyone can edit), and files a task is about to CREATE, which a
|
|
64
|
+
* freeze on the existing tree does not block. Omitted → every path-shaped
|
|
65
|
+
* token counts (the broad reading, reported alongside at STEP 0).
|
|
66
|
+
*/
|
|
67
|
+
isSource?: (p: string) => boolean;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Every unsatisfiable pair in the composed spec: an AUTHORITATIVE owned
|
|
71
|
+
* requirement naming a file that a CATEGORY freeze in the same spec forbids
|
|
72
|
+
* touching. Deterministic, pure text (plus the caller's existence oracle).
|
|
73
|
+
* Empty when the spec froze no category or carries no owned requirement — the
|
|
74
|
+
* ordinary single-`/task` case degrades to a no-op.
|
|
75
|
+
*/
|
|
76
|
+
export declare function findOwnedFreezeConflicts(spec: string | null | undefined, opts?: OwnedFreezeOptions): OwnedFreezeConflict[];
|
|
77
|
+
/**
|
|
78
|
+
* The measured `isSource` oracle: a token counts only when git tracks it in
|
|
79
|
+
* `cwd`. Tracked ⇒ it exists, it is not a build output, and it is not ignored —
|
|
80
|
+
* exactly the "source file" the category freezes talk about. `git` missing or
|
|
81
|
+
* the tree not a repo ⇒ every token counts (the broad reading), so the detector
|
|
82
|
+
* degrades toward firing rather than toward silent blindness.
|
|
83
|
+
*/
|
|
84
|
+
export declare function trackedSourceOracle(lsFiles: (p: string) => {
|
|
85
|
+
stdout: string;
|
|
86
|
+
exitCode: number;
|
|
87
|
+
}): (p: string) => boolean;
|
|
88
|
+
/**
|
|
89
|
+
* The forced critique-rewrite defect text, in the shape the existing four
|
|
90
|
+
* families use: MANDATORY, self-contained, naming the exact resolutions. Prose
|
|
91
|
+
* surrender and silently dropping the requirement are called out as
|
|
92
|
+
* non-resolutions because narrowing the clause is precisely what run 16 did and
|
|
93
|
+
* freezing its file is precisely what run 18 did.
|
|
94
|
+
*/
|
|
95
|
+
export declare function ownedFreezeConflictProbeText(conflicts: OwnedFreezeConflict[]): string;
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* owned-freeze-conflict — the FIFTH unsatisfiable-pair family (nexttask 7):
|
|
3
|
+
* an AUTHORITATIVE owned requirement whose file falls inside a CATEGORY freeze
|
|
4
|
+
* written by the same spec.
|
|
5
|
+
*
|
|
6
|
+
* WHY frozen-conflict.ts does not see it (mx5 run 18, TASK_0023). The owned
|
|
7
|
+
* channel worked: `.pi-tasks/requirements-owned.md` carried the design clause
|
|
8
|
+
* "**Server:** `bun run --watch src/server/index.ts` — serves `/api` + static
|
|
9
|
+
* `dist/`." and the composed spec carried it verbatim under CONSTRAINTS, marked
|
|
10
|
+
* AUTHORITATIVE. The same CONSTRAINTS block then said "Do not modify
|
|
11
|
+
* `docker-compose.dev.yml`, …, or any source files outside of `package.json`",
|
|
12
|
+
* and the spec's ACCEPTANCE/VERIFY converted the behavioural half of the clause
|
|
13
|
+
* ("serves `/api` + static `dist/`") into a string-match on `package.json`. The
|
|
14
|
+
* only file that could implement it — `src/server/index.ts` — was frozen. The
|
|
15
|
+
* requirement was structurally unsatisfiable inside its owning task, VERIFY
|
|
16
|
+
* PASSed honestly, and the app shipped with no static route.
|
|
17
|
+
*
|
|
18
|
+
* The existing detector misses this shape twice over (both visible in its own
|
|
19
|
+
* header): its freeze side needs a NAMED path (`pathNamedIn`), and run 18's
|
|
20
|
+
* freeze names a CATEGORY ("any source files outside of `package.json`"); its
|
|
21
|
+
* statement side must match one of four measured phrasing families, and a plain
|
|
22
|
+
* behavioural claim matches none of them.
|
|
23
|
+
*
|
|
24
|
+
* HIGH-PRECISION BY CONSTRUCTION — the statement side needs no NLP:
|
|
25
|
+
* - owned requirement lines are MACHINE-MARKED. `appendOwnedConstraints`
|
|
26
|
+
* stamps every one it appends with "owned requirement from the source
|
|
27
|
+
* design (AUTHORITATIVE; …)"; when compose folded the quote in by itself
|
|
28
|
+
* the marker is absent, so the caller may also pass the run's ledger
|
|
29
|
+
* entries and the quote is matched verbatim against the spec text.
|
|
30
|
+
* - the requirement names its path literally, so the intersection is lexical.
|
|
31
|
+
* - category freezes are a small closed lexical set ("any source files
|
|
32
|
+
* outside of X", "any files other than X", "only X may be modified",
|
|
33
|
+
* "no files outside X").
|
|
34
|
+
*
|
|
35
|
+
* ── STATUS: NOT WIRED. The critique seam FAILED its A/B, 2026-08-04. ─────────
|
|
36
|
+
*
|
|
37
|
+
* The detector is precise — 1 finding over 58 real composed specs, and it is the
|
|
38
|
+
* true positive (scripts/owned-freeze-conflict-fp-suite.ts, PASS; STEP 0 in
|
|
39
|
+
* scripts/owned-vs-freeze-baserate.ts). What failed is the LEVER built on it,
|
|
40
|
+
* for two independent reasons, both measured:
|
|
41
|
+
*
|
|
42
|
+
* 1. THE SEAM IS BLIND IN PRODUCTION. `appendOwnedConstraints` — the BRACES that
|
|
43
|
+
* stamp the machine-marked owned bullet this detector keys on — runs AFTER
|
|
44
|
+
* `critiqueWithFallback`, inside the `critique` step (phases.ts). When
|
|
45
|
+
* critique runs, the stamped line does not exist yet; compose's own folding
|
|
46
|
+
* is a PARAPHRASE ("The server watch command must match the contract exactly:
|
|
47
|
+
* `…` — serves `/api` + static `dist/`"), so neither the stamp nor the
|
|
48
|
+
* verbatim quote is there to match. Live: 0/40 compose drafts carried a
|
|
49
|
+
* detectable pair while 11/40 carried the clause semantically. A critique-
|
|
50
|
+
* time probe cannot see the shape it was designed for.
|
|
51
|
+
* 2. THE REWRITE RESOLVES IT BY DELETING THE REQUIREMENT. Forced through the
|
|
52
|
+
* controlled critique seam on run 18's real TASK_0023 draft (n=20/arm):
|
|
53
|
+
* pair-present 8/20 → 0/20, but the resolution was scoped ownership in only
|
|
54
|
+
* 9/20 — the other 11/20 removed the AUTHORITATIVE clause outright and
|
|
55
|
+
* rationalised it ("this references an existing file; no edits are required
|
|
56
|
+
* or permitted"), with 0 of the 11 reassigning it to the task that owns the
|
|
57
|
+
* file. VERIFY behaviour-observation was 6/20 in BOTH arms: the delivered
|
|
58
|
+
* spec still verifies the requirement by grepping `package.json`.
|
|
59
|
+
*
|
|
60
|
+
* Removal of the pair is not satisfaction of the requirement — the same lesson
|
|
61
|
+
* as the run-16 lever's delivery metric, one level down. Anything built here
|
|
62
|
+
* next has to act AFTER the braces, where the pair actually exists, and cannot
|
|
63
|
+
* be a model rewrite: the braces are the last spec-producing step.
|
|
64
|
+
*
|
|
65
|
+
* It is a pure text function so the base rate, the FP suite and the live A/B all
|
|
66
|
+
* measure the same object.
|
|
67
|
+
*/
|
|
68
|
+
import { PROHIBITION_RE } from './prohibition-probe.js';
|
|
69
|
+
import { pathNamedIn } from './frozen-path-guard.js';
|
|
70
|
+
/**
|
|
71
|
+
* The machine stamp `appendOwnedConstraints` writes on every owned requirement
|
|
72
|
+
* it appends to CONSTRAINTS. Matching the stamp — not the prose — is what keeps
|
|
73
|
+
* the statement side free of NLP.
|
|
74
|
+
*/
|
|
75
|
+
const OWNED_MARKER_RE = /owned\s+requirement\s+from\s+the\s+source\s+design/i;
|
|
76
|
+
/**
|
|
77
|
+
* Modification verbs, shared by the active and passive freeze families. Scoped
|
|
78
|
+
* to modification exactly as `PROHIBITION_RE` is: a "do not CREATE any files
|
|
79
|
+
* other than X" line is a creation ban and freezes no existing file, so it must
|
|
80
|
+
* never be read as a category freeze (mx5 run 18 TASK_0009 ships that line).
|
|
81
|
+
*/
|
|
82
|
+
const MOD_VERB = 'modif|touch|edit|chang|alter|rewrit|overwrit';
|
|
83
|
+
/**
|
|
84
|
+
* A category noun phrase with an exception: "any source files outside of X",
|
|
85
|
+
* "any existing file other than X", "no files except X". `\w+` slots absorb the
|
|
86
|
+
* qualifiers seen in the corpora (source/existing/other), bounded so the phrase
|
|
87
|
+
* cannot span a whole paragraph.
|
|
88
|
+
*/
|
|
89
|
+
const CATEGORY_NOUN = String.raw `(?:any|no)\s+(?:\w+\s+){0,3}?files?\b`;
|
|
90
|
+
const EXCEPT_KEYWORD = String.raw `(?:outside(?:\s+of)?|other\s+than|except(?:\s+for)?|besides|apart\s+from|beyond)`;
|
|
91
|
+
/**
|
|
92
|
+
* Active family: a modification ban whose object is the category phrase. The
|
|
93
|
+
* tempered gap forbids crossing a creation/addition verb, so the compound line
|
|
94
|
+
* "Do not create any files other than `X` and do not modify `Y`" — where the
|
|
95
|
+
* category belongs to the CREATE half — cannot be mis-read as a category freeze.
|
|
96
|
+
*/
|
|
97
|
+
const ACTIVE_CATEGORY_RE = new RegExp(String.raw `\b(?:do\s+not|do\s+NOT|don'?t|must\s+not|never)\s+(?:${MOD_VERB})\w*\b`
|
|
98
|
+
+ String.raw `(?:(?!\b(?:creat|add|introduc|generat)\w*\b)[\s\S]){0,200}?`
|
|
99
|
+
+ String.raw `\b${CATEGORY_NOUN}[^\n]{0,40}?\b${EXCEPT_KEYWORD}\b`, 'i');
|
|
100
|
+
/**
|
|
101
|
+
* Passive family: "No files other than `package.json` are modified." — run 18's
|
|
102
|
+
* ACCEPTANCE line, identical in force to the CONSTRAINTS freeze above it.
|
|
103
|
+
*/
|
|
104
|
+
const PASSIVE_CATEGORY_RE = new RegExp(String.raw `\b${CATEGORY_NOUN}[^\n]{0,40}?\b${EXCEPT_KEYWORD}\b`
|
|
105
|
+
+ String.raw `[^\n]{0,120}?\b(?:are|is|may|must|should|can|will)\s+(?:be\s+|been\s+)?(?:${MOD_VERB})\w*`, 'i');
|
|
106
|
+
/**
|
|
107
|
+
* A SCOPED-OWNERSHIP grant — "You MAY edit `X` ONLY to …/ONLY as far as …" —
|
|
108
|
+
* which is the resolution this detector demands. Whatever else the spec says,
|
|
109
|
+
* the paths named on such a line are editable, so they can never be the frozen
|
|
110
|
+
* side of a pair. Without this the rewrite that grants ownership on a NEW line
|
|
111
|
+
* while leaving the category freeze in place would re-fire forever.
|
|
112
|
+
*/
|
|
113
|
+
const SCOPED_GRANT_RE = new RegExp(String.raw `\b(?:may|can|are\s+allowed\s+to|is\s+allowed\s+to)\s+(?:${MOD_VERB})\w*\b[^\n]{0,80}?\bonly\b`, 'i');
|
|
114
|
+
/** "Only `X` may be modified" / "Only `X` is edited". */
|
|
115
|
+
const ONLY_CATEGORY_RE = new RegExp(String.raw `\bonly\b[^\n]{0,60}?\b(?:may|must|should|can|will|is|are)\s+(?:be\s+)?(?:${MOD_VERB})\w*`, 'i');
|
|
116
|
+
/**
|
|
117
|
+
* Backtick-quoted path-shaped tokens in a text, INCLUDING the ones embedded in
|
|
118
|
+
* a backticked command (`bun run --watch src/server/index.ts` is one backtick
|
|
119
|
+
* span; the path inside it is what the requirement is about). Route literals
|
|
120
|
+
* (`/api`) and bare directories survive this filter — the caller decides what
|
|
121
|
+
* to do with them; `findOwnedFreezeConflicts` keeps only files.
|
|
122
|
+
*/
|
|
123
|
+
export function pathTokensIn(text) {
|
|
124
|
+
return tokensWithOrigin(text).map(t => t.path);
|
|
125
|
+
}
|
|
126
|
+
function tokensWithOrigin(text) {
|
|
127
|
+
const out = [];
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
for (const m of text.matchAll(/`([^`]+)`/g)) {
|
|
130
|
+
const span = m[1];
|
|
131
|
+
const fromCommand = /\s/.test(span.trim());
|
|
132
|
+
for (const raw of span.split(/[\s,;()"']+/)) {
|
|
133
|
+
const token = raw.trim().replace(/[.,;:]+$/, '');
|
|
134
|
+
if (token.length === 0 || !/^[\w.@~/-]+$/.test(token))
|
|
135
|
+
continue;
|
|
136
|
+
if (!(token.includes('/') || /\.[A-Za-z0-9]+$/.test(token) || token.startsWith('.'))) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
// A leading `/` is a route literal or an absolute path — never a
|
|
140
|
+
// repo-relative source file, and `/api` is named by the very clause
|
|
141
|
+
// that is the true positive, so this one is not hypothetical.
|
|
142
|
+
if (token.startsWith('/'))
|
|
143
|
+
continue;
|
|
144
|
+
const n = token.replace(/^\.\//, '').replace(/\/+$/, '');
|
|
145
|
+
if (n.length === 0 || seen.has(n))
|
|
146
|
+
continue;
|
|
147
|
+
seen.add(n);
|
|
148
|
+
out.push({ path: n, fromCommand });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Does the requirement claim anything about the files it names BEYOND quoting a
|
|
155
|
+
* command that mentions them?
|
|
156
|
+
*
|
|
157
|
+
* This is what separates the two build-contract clauses of mx5 run 18's
|
|
158
|
+
* TASK_0023, which are otherwise the same shape:
|
|
159
|
+
*
|
|
160
|
+
* "**Client CSS:** `bunx @tailwindcss/cli -i src/client/index.css -o dist/app.css`"
|
|
161
|
+
* "**Server:** `bun run --watch src/server/index.ts` — serves `/api` + static `dist/`."
|
|
162
|
+
*
|
|
163
|
+
* The first is satisfied by putting that command in `package.json`; nothing
|
|
164
|
+
* about `src/client/index.css` has to change, so the freeze does not make it
|
|
165
|
+
* impossible. The second attaches a BEHAVIOURAL claim to the quoted file, and
|
|
166
|
+
* that claim can only be satisfied inside the file. Prose outside the backtick
|
|
167
|
+
* spans — minus the label, the anchor tag and the machine marker — is the
|
|
168
|
+
* signal; two words of it are enough.
|
|
169
|
+
*/
|
|
170
|
+
function hasClaimOutsideCommand(requirement) {
|
|
171
|
+
const prose = requirement
|
|
172
|
+
.replace(/`[^`]*`/g, ' ')
|
|
173
|
+
.replace(/—\s*owned\s+requirement\s+from\s+the\s+source\s+design[\s\S]*$/i, ' ')
|
|
174
|
+
.replace(/\[[^\]]*\]/g, ' ')
|
|
175
|
+
.replace(/\*\*[^*]*\*\*/g, ' ')
|
|
176
|
+
.replace(/^[\s\-"']+/, ' ');
|
|
177
|
+
const words = prose.match(/\b[A-Za-z][A-Za-z-]{1,}\b/g) ?? [];
|
|
178
|
+
return words.length >= 2;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The clause the exception keyword governs: from the keyword to the first
|
|
182
|
+
* clause break (an em dash, a semicolon, or a sentence end). Everything else on
|
|
183
|
+
* the line — notably the "— all engine modules (`document.ts`, …) must remain
|
|
184
|
+
* untouched" tail of gofer-pixel's freeze — is NOT an exemption.
|
|
185
|
+
*/
|
|
186
|
+
function exemptClause(line) {
|
|
187
|
+
const m = new RegExp(String.raw `\b${EXCEPT_KEYWORD}\b`, 'i').exec(line);
|
|
188
|
+
if (!m)
|
|
189
|
+
return null;
|
|
190
|
+
const rest = line.slice(m.index + m[0].length);
|
|
191
|
+
const brk = /\s+[—–]\s+|;|\.\s+[A-Z]|\.$/.exec(rest);
|
|
192
|
+
return brk ? rest.slice(0, brk.index) : rest;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Every CATEGORY freeze in the spec: a modification ban (or its passive/only-
|
|
196
|
+
* form equivalent) scoped to a class of files rather than to named paths. A
|
|
197
|
+
* freeze that only names paths is `frozen-conflict.ts`'s job, not this one.
|
|
198
|
+
*/
|
|
199
|
+
export function findCategoryFreezes(spec) {
|
|
200
|
+
if (!spec)
|
|
201
|
+
return [];
|
|
202
|
+
const out = [];
|
|
203
|
+
const seen = new Set();
|
|
204
|
+
for (const raw of spec.split('\n')) {
|
|
205
|
+
const line = raw.trim();
|
|
206
|
+
if (line.length === 0 || seen.has(line))
|
|
207
|
+
continue;
|
|
208
|
+
const isCategory = ACTIVE_CATEGORY_RE.test(line)
|
|
209
|
+
|| PASSIVE_CATEGORY_RE.test(line)
|
|
210
|
+
|| (ONLY_CATEGORY_RE.test(line) && PROHIBITION_RE.test(line) === false);
|
|
211
|
+
if (!isCategory || SCOPED_GRANT_RE.test(line))
|
|
212
|
+
continue;
|
|
213
|
+
const clause = ONLY_CATEGORY_RE.test(line) && exemptClause(line) === null ? line : exemptClause(line);
|
|
214
|
+
seen.add(line);
|
|
215
|
+
out.push({ constraint: line, exempt: clause === null ? [] : pathTokensIn(clause) });
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
const basename = (p) => p.slice(p.lastIndexOf('/') + 1);
|
|
220
|
+
/**
|
|
221
|
+
* Is `p` inside the freeze — i.e. NOT one of the exempted paths, a file under
|
|
222
|
+
* an exempted directory, or the same file spelled shorter?
|
|
223
|
+
*
|
|
224
|
+
* The last clause is load-bearing. gofer-pixel TASK_0011 exempts
|
|
225
|
+
* `src/components/Canvas.tsx` while its owned requirement quotes the design's
|
|
226
|
+
* table cell, which says just `Canvas.tsx` — the same file, and the spec is
|
|
227
|
+
* correctly formed. A bare basename is matched against the exempted paths'
|
|
228
|
+
* basenames; a path WITH a directory must match exactly or by prefix, so
|
|
229
|
+
* `src/a/config.ts` never counts as exempted by `src/b/config.ts`.
|
|
230
|
+
*/
|
|
231
|
+
function insideFreeze(p, exempt) {
|
|
232
|
+
return !exempt.some(e => e === p
|
|
233
|
+
|| p.startsWith(`${e}/`)
|
|
234
|
+
|| pathNamedIn(p, e)
|
|
235
|
+
|| (!p.includes('/') && basename(e) === p));
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* The spec lines carrying an AUTHORITATIVE owned requirement: the machine-
|
|
239
|
+
* stamped ones, plus — when the run's ledger is supplied — any line carrying an
|
|
240
|
+
* owned quote verbatim (compose folding the quote in itself leaves no stamp).
|
|
241
|
+
*/
|
|
242
|
+
export function ownedRequirementLines(spec, owned = []) {
|
|
243
|
+
if (!spec)
|
|
244
|
+
return [];
|
|
245
|
+
const quotes = owned.map(o => o.quote.trim()).filter(q => q.length > 0);
|
|
246
|
+
const out = [];
|
|
247
|
+
const seen = new Set();
|
|
248
|
+
for (const raw of spec.split('\n')) {
|
|
249
|
+
const line = raw.trim();
|
|
250
|
+
if (line.length === 0 || seen.has(line))
|
|
251
|
+
continue;
|
|
252
|
+
if (!OWNED_MARKER_RE.test(line) && !quotes.some(q => line.includes(q)))
|
|
253
|
+
continue;
|
|
254
|
+
seen.add(line);
|
|
255
|
+
out.push(line);
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Every unsatisfiable pair in the composed spec: an AUTHORITATIVE owned
|
|
261
|
+
* requirement naming a file that a CATEGORY freeze in the same spec forbids
|
|
262
|
+
* touching. Deterministic, pure text (plus the caller's existence oracle).
|
|
263
|
+
* Empty when the spec froze no category or carries no owned requirement — the
|
|
264
|
+
* ordinary single-`/task` case degrades to a no-op.
|
|
265
|
+
*/
|
|
266
|
+
export function findOwnedFreezeConflicts(spec, opts = {}) {
|
|
267
|
+
if (!spec)
|
|
268
|
+
return [];
|
|
269
|
+
const freezes = findCategoryFreezes(spec);
|
|
270
|
+
if (freezes.length === 0)
|
|
271
|
+
return [];
|
|
272
|
+
// Scoped ownership granted ANYWHERE in the spec settles the file, even when
|
|
273
|
+
// the grant is a line the rewrite added next to an untouched category
|
|
274
|
+
// freeze — otherwise a correctly resolved spec re-fires forever.
|
|
275
|
+
const granted = spec
|
|
276
|
+
.split('\n')
|
|
277
|
+
.filter(l => SCOPED_GRANT_RE.test(l))
|
|
278
|
+
.flatMap(l => pathTokensIn(l));
|
|
279
|
+
const out = [];
|
|
280
|
+
for (const requirement of ownedRequirementLines(spec, opts.owned)) {
|
|
281
|
+
// An owned requirement that is itself prohibition-shaped restates the
|
|
282
|
+
// freeze side; it can never be the thing the freeze makes impossible.
|
|
283
|
+
if (PROHIBITION_RE.test(requirement))
|
|
284
|
+
continue;
|
|
285
|
+
const claim = hasClaimOutsideCommand(requirement);
|
|
286
|
+
const named = tokensWithOrigin(requirement)
|
|
287
|
+
.filter(t => claim || !t.fromCommand)
|
|
288
|
+
.map(t => t.path)
|
|
289
|
+
.filter(p => !opts.isSource || opts.isSource(p));
|
|
290
|
+
if (named.length === 0)
|
|
291
|
+
continue;
|
|
292
|
+
// One finding per requirement: the FIRST freeze that covers any of its
|
|
293
|
+
// files. A spec that restates the same freeze under ACCEPTANCE (run 18
|
|
294
|
+
// does) must not double the rewrite's work.
|
|
295
|
+
for (const f of freezes) {
|
|
296
|
+
const paths = named.filter(p => insideFreeze(p, [...f.exempt, ...granted]));
|
|
297
|
+
if (paths.length === 0)
|
|
298
|
+
continue;
|
|
299
|
+
out.push({ requirement, paths, constraint: f.constraint, exempt: f.exempt });
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* The measured `isSource` oracle: a token counts only when git tracks it in
|
|
307
|
+
* `cwd`. Tracked ⇒ it exists, it is not a build output, and it is not ignored —
|
|
308
|
+
* exactly the "source file" the category freezes talk about. `git` missing or
|
|
309
|
+
* the tree not a repo ⇒ every token counts (the broad reading), so the detector
|
|
310
|
+
* degrades toward firing rather than toward silent blindness.
|
|
311
|
+
*/
|
|
312
|
+
export function trackedSourceOracle(lsFiles) {
|
|
313
|
+
const cache = new Map();
|
|
314
|
+
return p => {
|
|
315
|
+
const hit = cache.get(p);
|
|
316
|
+
if (hit !== undefined)
|
|
317
|
+
return hit;
|
|
318
|
+
const r = lsFiles(p);
|
|
319
|
+
const ok = r.exitCode !== 0 ? true : r.stdout.trim().length > 0;
|
|
320
|
+
cache.set(p, ok);
|
|
321
|
+
return ok;
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* The forced critique-rewrite defect text, in the shape the existing four
|
|
326
|
+
* families use: MANDATORY, self-contained, naming the exact resolutions. Prose
|
|
327
|
+
* surrender and silently dropping the requirement are called out as
|
|
328
|
+
* non-resolutions because narrowing the clause is precisely what run 16 did and
|
|
329
|
+
* freezing its file is precisely what run 18 did.
|
|
330
|
+
*/
|
|
331
|
+
export function ownedFreezeConflictProbeText(conflicts) {
|
|
332
|
+
const items = conflicts.map(c => `- the spec carries the AUTHORITATIVE owned requirement `
|
|
333
|
+
+ `"${c.requirement.slice(0, 200)}", which names ${c.paths.map(p => `\`${p}\``).join(', ')} — `
|
|
334
|
+
+ `and the spec FREEZES ${c.paths.length > 1 ? 'those files' : 'that file'} with a CATEGORY freeze `
|
|
335
|
+
+ `("${c.constraint.slice(0, 160)}"`
|
|
336
|
+
+ (c.exempt.length > 0 ?
|
|
337
|
+
`, which exempts only ${c.exempt.map(e => `\`${e}\``).join(', ')}`
|
|
338
|
+
: '')
|
|
339
|
+
+ ')');
|
|
340
|
+
return [
|
|
341
|
+
'UNSATISFIABLE-CONSTRAINT FINDING (deterministic; MUST be resolved, it overrides a CLEAN triage):',
|
|
342
|
+
...items,
|
|
343
|
+
'An owned requirement is AUTHORITATIVE: it comes from the source design and this task',
|
|
344
|
+
'owns it. A category freeze that covers the only file which could satisfy it makes the',
|
|
345
|
+
'requirement structurally impossible INSIDE THIS TASK, and the task will still pass its',
|
|
346
|
+
'own VERIFY — because VERIFY can then only assert strings in the files it is allowed to',
|
|
347
|
+
'touch. Narrowing the requirement to what the unfrozen files can express, or dropping it,',
|
|
348
|
+
'is NOT a resolution.',
|
|
349
|
+
'REWRITE the spec to resolve it in exactly ONE of these two ways:',
|
|
350
|
+
' (a) SCOPED OWNERSHIP — widen the category freeze for that file only:',
|
|
351
|
+
' "You MAY edit `<path>` ONLY as far as the owned requirement requires; any other',
|
|
352
|
+
' change to `<path>` is forbidden." Then make ACCEPTANCE state the requirement\'s',
|
|
353
|
+
' BEHAVIOUR and make VERIFY exercise that behaviour, not the presence of a string.',
|
|
354
|
+
' (b) REASSIGN — state that the owned requirement is not satisfiable in this task and',
|
|
355
|
+
" belongs to the task that owns `<path>`, and remove it from this spec's",
|
|
356
|
+
' CONSTRAINTS/ACCEPTANCE so it is not falsely claimed as met here.',
|
|
357
|
+
'Never ship both the category freeze and the owned requirement it makes impossible.'
|
|
358
|
+
].join('\n');
|
|
359
|
+
}
|
package/dist/task/phases.d.ts
CHANGED
|
@@ -132,7 +132,18 @@ export interface PhaseAutoAnswerDeps {
|
|
|
132
132
|
export declare function phaseAutoAnswer(deps: PhaseDeps, refined: string, research: string, question: string, autoDeps?: PhaseAutoAnswerDeps): Promise<AutoAnswer>;
|
|
133
133
|
export declare function phaseGrill(deps: PhaseDeps, ctx: ExtensionCommandContext, widgetState: WidgetState, refined: string, research: string): Promise<string>;
|
|
134
134
|
export declare function phaseCompose(deps: PhaseDeps, refined: string, research: string, qa: string): Promise<string>;
|
|
135
|
-
export declare function phaseCritique(deps: PhaseDeps, spec: string, refined: string, qa: string, planContext?: string, research?: string
|
|
135
|
+
export declare function phaseCritique(deps: PhaseDeps, spec: string, refined: string, qa: string, planContext?: string, research?: string,
|
|
136
|
+
/**
|
|
137
|
+
* An additional deterministic defect block, forced into the rewrite exactly
|
|
138
|
+
* like the probes below and overriding a CLEAN triage the same way.
|
|
139
|
+
*
|
|
140
|
+
* This is the A/B seam for a probe that is not wired yet: the discipline
|
|
141
|
+
* here is "wire only on PASS" (memory/prompt4-spec-urls-failed.md), so a
|
|
142
|
+
* candidate probe has to be measurable through the SHIPPED critique path
|
|
143
|
+
* rather than through a hand-copied replica of it, or the two arms differ by
|
|
144
|
+
* more than the probe. Undefined in production.
|
|
145
|
+
*/
|
|
146
|
+
extraDefects?: string | null): Promise<string>;
|
|
136
147
|
export declare function critiqueWithFallback(d: PhaseDeps, p: PhaseContext): Promise<string>;
|
|
137
148
|
export declare const PHASES: PhaseConfig[];
|
|
138
149
|
export declare function postCommitPhase(phase: PhaseConfig, deps: PhaseDeps, pc: PhaseContext, out: string): Promise<void>;
|
package/dist/task/phases.js
CHANGED
|
@@ -1269,7 +1269,18 @@ export async function phaseCompose(deps, refined, research, qa) {
|
|
|
1269
1269
|
return { ok: true, value: stripped };
|
|
1270
1270
|
}, problem => new Error(`compose_invalid: ${problem}`));
|
|
1271
1271
|
}
|
|
1272
|
-
export async function phaseCritique(deps, spec, refined, qa, planContext, research
|
|
1272
|
+
export async function phaseCritique(deps, spec, refined, qa, planContext, research,
|
|
1273
|
+
/**
|
|
1274
|
+
* An additional deterministic defect block, forced into the rewrite exactly
|
|
1275
|
+
* like the probes below and overriding a CLEAN triage the same way.
|
|
1276
|
+
*
|
|
1277
|
+
* This is the A/B seam for a probe that is not wired yet: the discipline
|
|
1278
|
+
* here is "wire only on PASS" (memory/prompt4-spec-urls-failed.md), so a
|
|
1279
|
+
* candidate probe has to be measurable through the SHIPPED critique path
|
|
1280
|
+
* rather than through a hand-copied replica of it, or the two arms differ by
|
|
1281
|
+
* more than the probe. Undefined in production.
|
|
1282
|
+
*/
|
|
1283
|
+
extraDefects) {
|
|
1273
1284
|
// Fast triage before the expensive full rewrite. The rewrite regenerates
|
|
1274
1285
|
// the entire spec from scratch and is the costliest tail of the pipeline
|
|
1275
1286
|
// (observed up to ~240s). Most compose drafts are already good, so we first
|
|
@@ -1392,7 +1403,8 @@ export async function phaseCritique(deps, spec, refined, qa, planContext, resear
|
|
|
1392
1403
|
&& absenceProbe === null
|
|
1393
1404
|
&& frozenProbe === null
|
|
1394
1405
|
&& grepOnlyProbe === null
|
|
1395
|
-
&& scriptProbe === null
|
|
1406
|
+
&& scriptProbe === null
|
|
1407
|
+
&& (extraDefects ?? null) === null) {
|
|
1396
1408
|
return spec;
|
|
1397
1409
|
}
|
|
1398
1410
|
}
|
|
@@ -1411,6 +1423,7 @@ export async function phaseCritique(deps, spec, refined, qa, planContext, resear
|
|
|
1411
1423
|
frozenProbe,
|
|
1412
1424
|
grepOnlyProbe,
|
|
1413
1425
|
scriptProbe,
|
|
1426
|
+
extraDefects ?? null,
|
|
1414
1427
|
triageDefects
|
|
1415
1428
|
]
|
|
1416
1429
|
.filter(Boolean)
|
|
@@ -11,6 +11,29 @@ export interface GrepOnlyVerifyFinding {
|
|
|
11
11
|
* source (doc/config-only tasks).
|
|
12
12
|
*/
|
|
13
13
|
export declare function findGrepOnlyVerify(spec: string): GrepOnlyVerifyFinding[];
|
|
14
|
+
/** One VERIFY command, classified by what it can observe. */
|
|
15
|
+
export interface VerifyCommandClass {
|
|
16
|
+
/** The command line, verbatim. */
|
|
17
|
+
raw: string;
|
|
18
|
+
/** Every pipeline segment is a STATIC head (grep/test/tsc/…) — it inspects
|
|
19
|
+
* files and can never observe the deliverable's behaviour. */
|
|
20
|
+
staticOnly: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* The command observes RUNTIME behaviour: an HTTP request, a port probe, a
|
|
23
|
+
* process it starts and watches. This is the distinction nexttask 7's M3
|
|
24
|
+
* turns on — mx5 run 18's TASK_0023 VERIFY is all `node -e "…package.json…"`,
|
|
25
|
+
* which EXECUTES node yet can only assert that a string is present in a
|
|
26
|
+
* config file, and the behavioural half of the owned requirement ("serves
|
|
27
|
+
* `/api` + static `dist/`") is exactly what it cannot see.
|
|
28
|
+
*/
|
|
29
|
+
observesBehaviour: boolean;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Classify each command of a spec's VERIFY block. Shares `segmentHead` with the
|
|
33
|
+
* grep-theater detector, so "static" means one thing across the two measures.
|
|
34
|
+
* Empty when the spec has no runnable VERIFY block.
|
|
35
|
+
*/
|
|
36
|
+
export declare function classifyVerifyCommands(spec: string): VerifyCommandClass[];
|
|
14
37
|
/**
|
|
15
38
|
* Retry hint when the critique rewrite KEPT the grep-theater block it was told
|
|
16
39
|
* to fix (live A/B: 1/5 rewrites ignored the injected defect). Prepended to the
|
|
@@ -148,6 +148,28 @@ export function findGrepOnlyVerify(spec) {
|
|
|
148
148
|
}
|
|
149
149
|
return [...inspected.entries()].map(([target, lines]) => ({ target, lines }));
|
|
150
150
|
}
|
|
151
|
+
const BEHAVIOUR_RE = /\bcurl\b|\bwget\b|\bhttpie?\b|https?:\/\/|\bnc\s+-z\b|\bss\s+-|\blsof\b|127\.0\.0\.1|localhost|\bplaywright\b|\bfetch\(/i;
|
|
152
|
+
/**
|
|
153
|
+
* Classify each command of a spec's VERIFY block. Shares `segmentHead` with the
|
|
154
|
+
* grep-theater detector, so "static" means one thing across the two measures.
|
|
155
|
+
* Empty when the spec has no runnable VERIFY block.
|
|
156
|
+
*/
|
|
157
|
+
export function classifyVerifyCommands(spec) {
|
|
158
|
+
const cmds = parseVerifyBlock(spec);
|
|
159
|
+
if (!cmds)
|
|
160
|
+
return [];
|
|
161
|
+
return cmds.map(({ raw }) => {
|
|
162
|
+
let staticOnly = true;
|
|
163
|
+
for (const segment of raw.split(/&&|\|\||;|\|/)) {
|
|
164
|
+
const s = segmentHead(segment);
|
|
165
|
+
if (s === null)
|
|
166
|
+
continue;
|
|
167
|
+
if (!STATIC_HEADS.has(s.head))
|
|
168
|
+
staticOnly = false;
|
|
169
|
+
}
|
|
170
|
+
return { raw, staticOnly, observesBehaviour: BEHAVIOUR_RE.test(raw) };
|
|
171
|
+
});
|
|
172
|
+
}
|
|
151
173
|
/**
|
|
152
174
|
* Retry hint when the critique rewrite KEPT the grep-theater block it was told
|
|
153
175
|
* to fix (live A/B: 1/5 rewrites ignored the injected defect). Prepended to the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|