@mjasnikovs/pi-task 0.18.12 → 0.18.14
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 +17 -0
- package/dist/config/config.js +12 -1
- package/dist/config/extension-list.d.ts +40 -0
- package/dist/config/extension-list.js +66 -0
- package/dist/config/register.d.ts +10 -0
- package/dist/config/register.js +49 -10
- package/dist/shared/child-extensions.d.ts +28 -0
- package/dist/shared/child-extensions.js +47 -0
- package/dist/shared/child-process.d.ts +11 -0
- package/dist/shared/child-process.js +18 -0
- package/dist/task/accept-debt.d.ts +28 -9
- package/dist/task/accept-debt.js +0 -0
- package/dist/task/auto-orchestrator.d.ts +2 -2
- package/dist/task/auto-orchestrator.js +31 -7
- package/dist/task/child-runner.js +3 -2
- package/dist/task/final-gate.d.ts +32 -3
- package/dist/task/final-gate.js +181 -15
- package/dist/task/gate-deps.d.ts +6 -0
- package/dist/task/gate-deps.js +20 -1
- package/dist/task/git-state-guard.js +58 -4
- package/dist/task/launch-contract.d.ts +31 -0
- package/dist/task/launch-contract.js +162 -0
- package/dist/task/task-gates.d.ts +8 -0
- package/dist/task/task-gates.js +7 -0
- package/dist/workers/docs-core.js +4 -3
- package/dist/workers/fetch-core.js +4 -3
- package/dist/workers/pi-worker-core.d.ts +8 -1
- package/dist/workers/pi-worker-core.js +4 -3
- package/dist/workers/pi-worker-docs.js +5 -4
- package/package.json +1 -1
package/dist/config/config.d.ts
CHANGED
|
@@ -30,6 +30,23 @@ export interface PiTaskConfig {
|
|
|
30
30
|
* DEFAULT `exa` so search works out of the box with zero configuration.
|
|
31
31
|
*/
|
|
32
32
|
searchProvider: SearchProvider;
|
|
33
|
+
/**
|
|
34
|
+
* Absolute entry-point paths of host pi extensions to load into every child
|
|
35
|
+
* pi session via explicit `-e` flags (GitHub issue #4: a provider registered
|
|
36
|
+
* by an extension — e.g. pi-lmstudio — otherwise doesn't exist in children,
|
|
37
|
+
* which then can't resolve the default model and demand OAuth/API keys).
|
|
38
|
+
* Children keep `--no-extensions`, so discovery stays off and ONLY these
|
|
39
|
+
* load — the whitelist is strictly additive. Entries whose file no longer
|
|
40
|
+
* exists (extension uninstalled) are skipped at spawn time, never fatal.
|
|
41
|
+
* DEFAULT empty: child isolation is unchanged until the user opts in via
|
|
42
|
+
* /task-config, which enumerates the currently installed extensions.
|
|
43
|
+
*/
|
|
44
|
+
extensionWhitelist: string[];
|
|
33
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* A hand-edited config can hold anything; keep only string entries so a stray
|
|
48
|
+
* object/number can't reach the child argv as `-e [object Object]`.
|
|
49
|
+
*/
|
|
50
|
+
export declare function sanitizeExtensionWhitelist(value: unknown): string[];
|
|
34
51
|
export declare function getConfig(): PiTaskConfig;
|
|
35
52
|
export declare function saveConfig(config: PiTaskConfig): Promise<void>;
|
package/dist/config/config.js
CHANGED
|
@@ -14,8 +14,18 @@ const DEFAULTS = {
|
|
|
14
14
|
// ON: the F10 live A/B showed no answer-quality regression (fidelity 3/3, quality
|
|
15
15
|
// 3/3, 0 collisions; ~14.5s of repeated docs lookups collapse to 0ms on a hit).
|
|
16
16
|
researchCache: true,
|
|
17
|
-
searchProvider: 'exa'
|
|
17
|
+
searchProvider: 'exa',
|
|
18
|
+
extensionWhitelist: []
|
|
18
19
|
};
|
|
20
|
+
/**
|
|
21
|
+
* A hand-edited config can hold anything; keep only string entries so a stray
|
|
22
|
+
* object/number can't reach the child argv as `-e [object Object]`.
|
|
23
|
+
*/
|
|
24
|
+
export function sanitizeExtensionWhitelist(value) {
|
|
25
|
+
if (!Array.isArray(value))
|
|
26
|
+
return [];
|
|
27
|
+
return value.filter((p) => typeof p === 'string' && p.trim().length > 0);
|
|
28
|
+
}
|
|
19
29
|
const CONFIG_PATH = path.join(os.homedir(), '.config', 'pi-task', 'config.json');
|
|
20
30
|
const _g = globalThis;
|
|
21
31
|
if (!_g.__piTaskConfig) {
|
|
@@ -32,6 +42,7 @@ if (!G.loaded) {
|
|
|
32
42
|
// into the dispatch switch — fall back to the default.
|
|
33
43
|
if (!isSearchProvider(parsed.searchProvider))
|
|
34
44
|
delete parsed.searchProvider;
|
|
45
|
+
parsed.extensionWhitelist = sanitizeExtensionWhitelist(parsed.extensionWhitelist);
|
|
35
46
|
G.config = { ...DEFAULTS, ...parsed };
|
|
36
47
|
}
|
|
37
48
|
catch {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enumerates the host's installed pi extensions for the /task-config whitelist
|
|
3
|
+
* (GitHub issue #4) using pi's OWN resolver — the same DefaultPackageManager
|
|
4
|
+
* the host runtime discovers extensions with — so the menu always mirrors what
|
|
5
|
+
* `pi list`/discovery would load: install a package and it appears, uninstall
|
|
6
|
+
* it and it vanishes (a stale whitelist entry is then skipped at spawn time by
|
|
7
|
+
* extensionArgs' existence filter, never fatal).
|
|
8
|
+
*/
|
|
9
|
+
export interface InstalledExtension {
|
|
10
|
+
/** Absolute entry-point path — the whitelist identity AND the `-e` value. */
|
|
11
|
+
path: string;
|
|
12
|
+
/** Short display name: package name for package sources, file stem for loose files. */
|
|
13
|
+
label: string;
|
|
14
|
+
/** Provenance shown in the menu, e.g. "npm:pi-lmstudio (user)" or "discovered (user)". */
|
|
15
|
+
origin: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* pi-task's own package root. Anything resolve() reports under it is pi-task
|
|
19
|
+
* itself (`dist/index.js` shows up as a discovered extension) and MUST be
|
|
20
|
+
* excluded: whitelisting pi-task into its own children would recursively boot
|
|
21
|
+
* the remote server and re-register every command in each child.
|
|
22
|
+
*/
|
|
23
|
+
export declare function selfPackageRoot(fromDir?: string): string;
|
|
24
|
+
/** Display label for a resolved extension entry. */
|
|
25
|
+
export declare function extensionLabel(entryPath: string, source: string): string;
|
|
26
|
+
export interface ListInstalledOptions {
|
|
27
|
+
cwd: string;
|
|
28
|
+
/** Override for tests; defaults to the host's real agent dir (~/.pi/agent). */
|
|
29
|
+
agentDir?: string;
|
|
30
|
+
/** Override for tests; defaults to pi-task's own package root. */
|
|
31
|
+
selfRoot?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* List every extension the host pi would load, excluding pi-task itself.
|
|
35
|
+
* Only enabled entries are offered: an extension the user disabled in
|
|
36
|
+
* `pi config` shouldn't be sneaked back in through a child whitelist.
|
|
37
|
+
* Errors (unreadable settings, missing package dirs) surface as an empty
|
|
38
|
+
* list at the caller — the menu then simply shows no extension toggles.
|
|
39
|
+
*/
|
|
40
|
+
export declare function listInstalledExtensions(opts: ListInstalledOptions): Promise<InstalledExtension[]>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { DefaultPackageManager, SettingsManager, getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
/**
|
|
6
|
+
* pi-task's own package root. Anything resolve() reports under it is pi-task
|
|
7
|
+
* itself (`dist/index.js` shows up as a discovered extension) and MUST be
|
|
8
|
+
* excluded: whitelisting pi-task into its own children would recursively boot
|
|
9
|
+
* the remote server and re-register every command in each child.
|
|
10
|
+
*/
|
|
11
|
+
export function selfPackageRoot(fromDir) {
|
|
12
|
+
let dir = fromDir ?? path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
for (;;) {
|
|
14
|
+
if (fs.existsSync(path.join(dir, 'package.json')))
|
|
15
|
+
return dir;
|
|
16
|
+
const parent = path.dirname(dir);
|
|
17
|
+
if (parent === dir)
|
|
18
|
+
return dir;
|
|
19
|
+
dir = parent;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function isUnder(p, root) {
|
|
23
|
+
const rel = path.relative(root, p);
|
|
24
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
25
|
+
}
|
|
26
|
+
/** Display label for a resolved extension entry. */
|
|
27
|
+
export function extensionLabel(entryPath, source) {
|
|
28
|
+
// Package sources keep their identity ("npm:pi-lmstudio" → "pi-lmstudio");
|
|
29
|
+
// discovered/local files read best as the file or directory stem.
|
|
30
|
+
if (source.includes(':'))
|
|
31
|
+
return source.slice(source.indexOf(':') + 1);
|
|
32
|
+
const base = path.basename(entryPath).replace(/\.(ts|js|mts|mjs|cts|cjs)$/, '');
|
|
33
|
+
return base === 'index' ? path.basename(path.dirname(entryPath)) : base;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* List every extension the host pi would load, excluding pi-task itself.
|
|
37
|
+
* Only enabled entries are offered: an extension the user disabled in
|
|
38
|
+
* `pi config` shouldn't be sneaked back in through a child whitelist.
|
|
39
|
+
* Errors (unreadable settings, missing package dirs) surface as an empty
|
|
40
|
+
* list at the caller — the menu then simply shows no extension toggles.
|
|
41
|
+
*/
|
|
42
|
+
export async function listInstalledExtensions(opts) {
|
|
43
|
+
const agentDir = opts.agentDir ?? getAgentDir();
|
|
44
|
+
const selfRoot = opts.selfRoot ?? selfPackageRoot();
|
|
45
|
+
const settingsManager = SettingsManager.create(opts.cwd, agentDir);
|
|
46
|
+
const pm = new DefaultPackageManager({ cwd: opts.cwd, agentDir, settingsManager });
|
|
47
|
+
// "skip": a package in settings whose install dir is missing must not
|
|
48
|
+
// trigger an interactive install prompt from inside a config menu.
|
|
49
|
+
const resolved = await pm.resolve(() => Promise.resolve('skip'));
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const r of resolved.extensions) {
|
|
52
|
+
if (!r.enabled)
|
|
53
|
+
continue;
|
|
54
|
+
const abs = path.resolve(r.path);
|
|
55
|
+
if (isUnder(abs, selfRoot))
|
|
56
|
+
continue;
|
|
57
|
+
out.push({
|
|
58
|
+
path: abs,
|
|
59
|
+
label: extensionLabel(abs, r.metadata.source),
|
|
60
|
+
origin: r.metadata.source === 'auto' ?
|
|
61
|
+
`discovered (${r.metadata.scope})`
|
|
62
|
+
: `${r.metadata.source} (${r.metadata.scope})`
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
@@ -1,2 +1,12 @@
|
|
|
1
1
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { type InstalledExtension } from './extension-list.js';
|
|
3
|
+
export declare function extensionItems(extensions: InstalledExtension[], whitelist: readonly string[]): {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
description: string;
|
|
7
|
+
currentValue: string;
|
|
8
|
+
values: string[];
|
|
9
|
+
}[];
|
|
10
|
+
/** Apply an extension toggle to the config's whitelist (idempotent both ways). */
|
|
11
|
+
export declare function applyExtensionToggle(whitelist: readonly string[], entryPath: string, on: boolean): string[];
|
|
2
12
|
export declare function registerConfig(pi: ExtensionAPI): void;
|
package/dist/config/register.js
CHANGED
|
@@ -2,6 +2,7 @@ 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
4
|
import { getConfig, saveConfig } from './config.js';
|
|
5
|
+
import { listInstalledExtensions } from './extension-list.js';
|
|
5
6
|
const CONFIG_TITLE = 'pi-task settings';
|
|
6
7
|
/**
|
|
7
8
|
* Frames a child component (the settings list) in a rounded border with a title
|
|
@@ -104,6 +105,29 @@ function displayValue(cfg, id, isEnum) {
|
|
|
104
105
|
return String(cfg[id]);
|
|
105
106
|
return cfg[id] ? 'on' : 'off';
|
|
106
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* One /task-config toggle per installed host extension (GitHub issue #4).
|
|
110
|
+
* The id carries the entry path behind a prefix so the shared onChange handler
|
|
111
|
+
* can tell an extension toggle from a PiTaskConfig field.
|
|
112
|
+
*/
|
|
113
|
+
const EXT_ID_PREFIX = 'ext:';
|
|
114
|
+
export function extensionItems(extensions, whitelist) {
|
|
115
|
+
return extensions.map(e => ({
|
|
116
|
+
id: EXT_ID_PREFIX + e.path,
|
|
117
|
+
label: `ext: ${e.label}`,
|
|
118
|
+
description: `Also load this extension (${e.origin}) in pi-task child sessions — needed when it `
|
|
119
|
+
+ 'registers the model provider the children must use (e.g. pi-lmstudio). Children '
|
|
120
|
+
+ 'otherwise run with extensions off; whitelist only provider-type extensions you '
|
|
121
|
+
+ `trust, since children also get its tools and hooks. ${e.path}`,
|
|
122
|
+
currentValue: whitelist.includes(e.path) ? 'on' : 'off',
|
|
123
|
+
values: ['on', 'off']
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
/** Apply an extension toggle to the config's whitelist (idempotent both ways). */
|
|
127
|
+
export function applyExtensionToggle(whitelist, entryPath, on) {
|
|
128
|
+
const rest = whitelist.filter(p => p !== entryPath);
|
|
129
|
+
return on ? [...rest, entryPath] : rest;
|
|
130
|
+
}
|
|
107
131
|
function makeTheme(theme) {
|
|
108
132
|
return {
|
|
109
133
|
label: (text, selected) => (selected ? theme.fg('accent', text) : text),
|
|
@@ -114,23 +138,37 @@ function makeTheme(theme) {
|
|
|
114
138
|
};
|
|
115
139
|
}
|
|
116
140
|
async function handleTaskConfig(_args, ctx) {
|
|
117
|
-
const cfg = { ...getConfig() };
|
|
141
|
+
const cfg = { ...getConfig(), extensionWhitelist: [...getConfig().extensionWhitelist] };
|
|
142
|
+
// Enumerated live at open so an installed extension appears and an
|
|
143
|
+
// uninstalled one vanishes without pi-task doing any bookkeeping. A failed
|
|
144
|
+
// enumeration only costs the extension toggles, never the whole menu.
|
|
145
|
+
const installed = await listInstalledExtensions({ cwd: ctx.cwd }).catch(() => []);
|
|
118
146
|
if (ctx.mode !== 'tui') {
|
|
119
147
|
const lines = ITEMS.map(({ id, label, values }) => `${label.padEnd(22)} ${displayValue(cfg, id, Boolean(values))}`);
|
|
148
|
+
for (const e of installed) {
|
|
149
|
+
const state = cfg.extensionWhitelist.includes(e.path) ? 'on' : 'off';
|
|
150
|
+
lines.push(`${('ext: ' + e.label).padEnd(22)} ${state}`);
|
|
151
|
+
}
|
|
120
152
|
ctx.ui.notify(lines.join(' | '), 'info');
|
|
121
153
|
return;
|
|
122
154
|
}
|
|
123
155
|
await ctx.ui.custom((_tui, theme, _kb, done) => {
|
|
124
156
|
const listTheme = makeTheme(theme);
|
|
125
|
-
const items =
|
|
126
|
-
id,
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
157
|
+
const items = [
|
|
158
|
+
...ITEMS.map(({ id, label, description, values }) => ({
|
|
159
|
+
id: id,
|
|
160
|
+
label,
|
|
161
|
+
description,
|
|
162
|
+
currentValue: displayValue(cfg, id, Boolean(values)),
|
|
163
|
+
values: values ?? ['on', 'off']
|
|
164
|
+
})),
|
|
165
|
+
...extensionItems(installed, cfg.extensionWhitelist)
|
|
166
|
+
];
|
|
132
167
|
const list = new SettingsList(items, 10, listTheme, (id, newValue) => {
|
|
133
|
-
if (id
|
|
168
|
+
if (id.startsWith(EXT_ID_PREFIX)) {
|
|
169
|
+
cfg.extensionWhitelist = applyExtensionToggle(cfg.extensionWhitelist, id.slice(EXT_ID_PREFIX.length), newValue === 'on');
|
|
170
|
+
}
|
|
171
|
+
else if (id === 'searchProvider') {
|
|
134
172
|
const provider = providerForLabel(newValue);
|
|
135
173
|
if (provider)
|
|
136
174
|
cfg.searchProvider = provider;
|
|
@@ -146,7 +184,8 @@ async function handleTaskConfig(_args, ctx) {
|
|
|
146
184
|
}
|
|
147
185
|
export function registerConfig(pi) {
|
|
148
186
|
registerBridgeCommand(pi, 'task-config', {
|
|
149
|
-
description: 'Configure pi-task settings (remote, compress reasoning, auto-commit, orientation,
|
|
187
|
+
description: 'Configure pi-task settings (remote, compress reasoning, auto-commit, orientation, '
|
|
188
|
+
+ 'enforce guidelines, extension whitelist for child sessions).',
|
|
150
189
|
handler: handleTaskConfig
|
|
151
190
|
});
|
|
152
191
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension whitelist → child argv (GitHub issue #4).
|
|
3
|
+
*
|
|
4
|
+
* Child pi sessions run with `--no-extensions`, so a provider registered by a
|
|
5
|
+
* host extension (e.g. pi-lmstudio) doesn't exist in them — the child can't
|
|
6
|
+
* resolve the default model and demands OAuth/API keys. pi's `--no-extensions`
|
|
7
|
+
* explicitly preserves explicit `-e <path>` loads (only discovery is disabled),
|
|
8
|
+
* so injecting the user's whitelisted entry paths restores the provider while
|
|
9
|
+
* keeping every non-whitelisted extension out. Same mechanism pi-task already
|
|
10
|
+
* uses for its own worker-tool extensions (see runWorker's `extensions` input).
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Map whitelisted entry paths to `-e` argv pairs. Pure given `exists`.
|
|
14
|
+
*
|
|
15
|
+
* A path whose file is gone (extension uninstalled since it was whitelisted)
|
|
16
|
+
* is skipped silently: pi itself would record "Extension path does not exist"
|
|
17
|
+
* as a load ERROR, and a stale toggle must never break every child spawn.
|
|
18
|
+
* Duplicates collapse so a path can't be loaded twice.
|
|
19
|
+
*/
|
|
20
|
+
export declare function extensionArgs(paths: readonly string[], exists?: (p: string) => boolean): string[];
|
|
21
|
+
/**
|
|
22
|
+
* The base argv every child pi invocation starts from: internal worker
|
|
23
|
+
* extensions (verbatim — they always exist, shipped in dist), then the user's
|
|
24
|
+
* whitelisted extensions (existence-filtered, deduped against the internal
|
|
25
|
+
* ones), then CHILD_BASE_ARGS. Computed per spawn, not at module load, so a
|
|
26
|
+
* /task-config toggle takes effect for the next child without a restart.
|
|
27
|
+
*/
|
|
28
|
+
export declare function childBaseArgs(internalExtensions?: readonly string[]): string[];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import { CHILD_BASE_ARGS } from './child-process.js';
|
|
3
|
+
import { getConfig } from '../config/config.js';
|
|
4
|
+
/**
|
|
5
|
+
* Extension whitelist → child argv (GitHub issue #4).
|
|
6
|
+
*
|
|
7
|
+
* Child pi sessions run with `--no-extensions`, so a provider registered by a
|
|
8
|
+
* host extension (e.g. pi-lmstudio) doesn't exist in them — the child can't
|
|
9
|
+
* resolve the default model and demands OAuth/API keys. pi's `--no-extensions`
|
|
10
|
+
* explicitly preserves explicit `-e <path>` loads (only discovery is disabled),
|
|
11
|
+
* so injecting the user's whitelisted entry paths restores the provider while
|
|
12
|
+
* keeping every non-whitelisted extension out. Same mechanism pi-task already
|
|
13
|
+
* uses for its own worker-tool extensions (see runWorker's `extensions` input).
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Map whitelisted entry paths to `-e` argv pairs. Pure given `exists`.
|
|
17
|
+
*
|
|
18
|
+
* A path whose file is gone (extension uninstalled since it was whitelisted)
|
|
19
|
+
* is skipped silently: pi itself would record "Extension path does not exist"
|
|
20
|
+
* as a load ERROR, and a stale toggle must never break every child spawn.
|
|
21
|
+
* Duplicates collapse so a path can't be loaded twice.
|
|
22
|
+
*/
|
|
23
|
+
export function extensionArgs(paths, exists = fs.existsSync) {
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
const args = [];
|
|
26
|
+
for (const p of paths) {
|
|
27
|
+
if (typeof p !== 'string' || p.trim().length === 0 || seen.has(p))
|
|
28
|
+
continue;
|
|
29
|
+
seen.add(p);
|
|
30
|
+
if (!exists(p))
|
|
31
|
+
continue;
|
|
32
|
+
args.push('-e', p);
|
|
33
|
+
}
|
|
34
|
+
return args;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The base argv every child pi invocation starts from: internal worker
|
|
38
|
+
* extensions (verbatim — they always exist, shipped in dist), then the user's
|
|
39
|
+
* whitelisted extensions (existence-filtered, deduped against the internal
|
|
40
|
+
* ones), then CHILD_BASE_ARGS. Computed per spawn, not at module load, so a
|
|
41
|
+
* /task-config toggle takes effect for the next child without a restart.
|
|
42
|
+
*/
|
|
43
|
+
export function childBaseArgs(internalExtensions = []) {
|
|
44
|
+
const internal = internalExtensions.flatMap(e => ['-e', e]);
|
|
45
|
+
const whitelisted = extensionArgs(getConfig().extensionWhitelist.filter(p => !internalExtensions.includes(p)));
|
|
46
|
+
return [...internal, ...whitelisted, ...CHILD_BASE_ARGS];
|
|
47
|
+
}
|
|
@@ -90,6 +90,17 @@ export interface RunChildJsonEventsOptions {
|
|
|
90
90
|
onLine?: (line: string) => void;
|
|
91
91
|
onContextUsage?: (snapshot: ContextSnapshot) => void;
|
|
92
92
|
onToolCall?: (call: ToolCall) => LoopHit | null;
|
|
93
|
+
/**
|
|
94
|
+
* Fires when a tool call finishes, carrying its RESULT (mx5 run 10 item 6: the
|
|
95
|
+
* verify debug log recorded the `bash:` command but never its output, so "verify
|
|
96
|
+
* claimed curl PASS on a server that cannot serve" was undecidable from the log).
|
|
97
|
+
* Text is the tool's combined output; `isError` distinguishes a failed call.
|
|
98
|
+
*/
|
|
99
|
+
onToolResult?: (result: {
|
|
100
|
+
name: string;
|
|
101
|
+
isError: boolean;
|
|
102
|
+
text: string;
|
|
103
|
+
}) => void;
|
|
93
104
|
onFirstByte?: () => void;
|
|
94
105
|
/**
|
|
95
106
|
* Dead-backend stall guard (mx5 run 7: model server died mid-child, the
|
|
@@ -159,9 +159,27 @@ export class JsonEventSink {
|
|
|
159
159
|
if (hit)
|
|
160
160
|
this.onLoopKill();
|
|
161
161
|
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (t === 'tool_execution_end' && opts.onToolResult) {
|
|
165
|
+
const tn = typeof evt.toolName === 'string' ? evt.toolName : 'tool';
|
|
166
|
+
const res = evt.result;
|
|
167
|
+
const text = toolResultText(res?.content);
|
|
168
|
+
opts.onToolResult({ name: tn, isError: evt.isError === true, text });
|
|
162
169
|
}
|
|
163
170
|
}
|
|
164
171
|
}
|
|
172
|
+
/** Flatten a tool result's `content` array (pi's `{type,text}[]`) into one string. */
|
|
173
|
+
function toolResultText(content) {
|
|
174
|
+
if (!Array.isArray(content))
|
|
175
|
+
return '';
|
|
176
|
+
const parts = [];
|
|
177
|
+
for (const c of content) {
|
|
178
|
+
if (c?.type === 'text' && typeof c.text === 'string')
|
|
179
|
+
parts.push(c.text);
|
|
180
|
+
}
|
|
181
|
+
return parts.join('');
|
|
182
|
+
}
|
|
165
183
|
// ─── Unified runChild ────────────────────────────────────────────────────────
|
|
166
184
|
export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
167
185
|
return new Promise(resolve => {
|
|
@@ -1,26 +1,43 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Provenance of a recorded defect:
|
|
3
|
+
* - 'accepted' — the user chose ACCEPT despite a verify-FAIL (the original class).
|
|
4
|
+
* - 'enforce-revert' — an enforce-pass re-verify FAILED and the enforce edits were
|
|
5
|
+
* reverted; the FAIL indicted the ORIGINAL work (mx5 run 10 TASK_0004: "Missing
|
|
6
|
+
* server entry point … the Hono server cannot be started"), so the terminal defect
|
|
7
|
+
* was FOUND and then erased by the very mechanism that found it. Persisted here so
|
|
8
|
+
* the final gate re-checks and surfaces it instead of letting it die with the revert.
|
|
9
|
+
*/
|
|
10
|
+
export type DebtOrigin = 'accepted' | 'enforce-revert';
|
|
11
|
+
/** One recorded defect: the task, why its VERIFY failed, and how it was recorded. */
|
|
2
12
|
export interface AcceptDebt {
|
|
3
13
|
taskId: string;
|
|
4
14
|
reason: string;
|
|
15
|
+
/** Absent in legacy 2-field records → treated as 'accepted'. */
|
|
16
|
+
origin?: DebtOrigin;
|
|
5
17
|
}
|
|
6
18
|
export declare function acceptDebtFile(cwd: string): string;
|
|
7
19
|
/** The raw stored ledger ('' when none recorded yet). Parse with parseAcceptDebts. */
|
|
8
20
|
export declare function readAcceptDebtsRaw(cwd: string): Promise<string>;
|
|
9
21
|
/**
|
|
10
|
-
* Parse the stored ledger into records.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
22
|
+
* Parse the stored ledger into records. Fields are tab-separated: `id`, `reason`, and
|
|
23
|
+
* an optional `origin` (legacy 2-field records have no origin → 'accepted'). Because a
|
|
24
|
+
* stored reason is tab-normalised (see normaliseReason), splitting on the separator is
|
|
25
|
+
* unambiguous. A line without any separator (a reason but no id, e.g. hand-edited)
|
|
26
|
+
* parses with an empty taskId rather than being dropped — a recorded debt is never
|
|
27
|
+
* silently lost.
|
|
13
28
|
*/
|
|
14
29
|
export declare function parseAcceptDebts(raw: string): AcceptDebt[];
|
|
15
30
|
/** Read + parse in one step. */
|
|
16
31
|
export declare function readAcceptDebts(cwd: string): Promise<AcceptDebt[]>;
|
|
32
|
+
/** Record a user-ACCEPTED-despite-verify-FAIL debt. */
|
|
33
|
+
export declare function recordAcceptDebt(cwd: string, taskId: string, reason: string): Promise<void>;
|
|
17
34
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
35
|
+
* Record an ENFORCE-REVERT debt (mx5 run 10 item 3): an enforce re-verify FAILED and
|
|
36
|
+
* the enforce edits were reverted, but the FAIL indicted the ORIGINAL work — so the
|
|
37
|
+
* defect is still in the shipped tree. Durable so the final gate re-checks/surfaces it
|
|
38
|
+
* rather than letting it die with the revert.
|
|
22
39
|
*/
|
|
23
|
-
export declare function
|
|
40
|
+
export declare function recordEnforceRevertDebt(cwd: string, taskId: string, reason: string): Promise<void>;
|
|
24
41
|
/** Overwrite the ledger with exactly these records (used to prune resolved debts). */
|
|
25
42
|
export declare function writeAcceptDebts(cwd: string, debts: AcceptDebt[]): Promise<void>;
|
|
26
43
|
/**
|
|
@@ -50,3 +67,5 @@ export declare function recheckAcceptDebts(debts: AcceptDebt[], opts: {
|
|
|
50
67
|
* picker). Empty when nothing is open.
|
|
51
68
|
*/
|
|
52
69
|
export declare function buildAcceptDebtNote(open: AcceptDebt[]): string;
|
|
70
|
+
/** One-line provenance label for a debt, for the surfaced report. */
|
|
71
|
+
export declare function describeDebt(d: AcceptDebt): string;
|
package/dist/task/accept-debt.js
CHANGED
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import { type FinalGateFixFn } from './gate-deps.js';
|
|
3
3
|
import { type GateDeps } from './task-gates.js';
|
|
4
|
-
import type
|
|
4
|
+
import { type AcceptDebt } from './accept-debt.js';
|
|
5
5
|
/**
|
|
6
6
|
* Injectable seams so the planner and loop are testable without spawning pi.
|
|
7
7
|
* `runChild` is the planning-only seam used by planAuto; everything else (runTask,
|
|
@@ -30,7 +30,7 @@ export interface AutoDeps extends GateDeps {
|
|
|
30
30
|
* own static checks plus its own test/build commands, unaided. Absent (tests /
|
|
31
31
|
* gate off) → the run completes as before.
|
|
32
32
|
*/
|
|
33
|
-
finalGate?: (cwd: string) => Promise<{
|
|
33
|
+
finalGate?: (cwd: string, planText?: string) => Promise<{
|
|
34
34
|
ok: boolean;
|
|
35
35
|
reason: string;
|
|
36
36
|
openDebts?: AcceptDebt[];
|
|
@@ -27,10 +27,12 @@ import { buildGateDeps } from './gate-deps.js';
|
|
|
27
27
|
import { runGatesForTask } from './task-gates.js';
|
|
28
28
|
import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
|
|
29
29
|
import { runFinalIntegrationGate } from './final-gate.js';
|
|
30
|
+
import { describeDebt } from './accept-debt.js';
|
|
30
31
|
import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FINAL_LEAVE_VALUE, FINAL_ACCEPT_LABEL, FINAL_ACCEPT_VALUE, FINAL_AUTOFIX_LABEL, FINAL_AUTOFIX_VALUE } from './final-gate-fix.js';
|
|
31
32
|
import { getConfig } from '../config/config.js';
|
|
32
33
|
import { configureResearchRun } from '../workers/research-cache.js';
|
|
33
34
|
import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
|
|
35
|
+
import { LAUNCH_EXTRACT_PROMPT, parseScriptLines, keepGroundedScripts, appendDeclaredScripts } from './launch-contract.js';
|
|
34
36
|
// Hard ceiling on clarify questions per feature. The loop is open-ended (it stops
|
|
35
37
|
// when the model emits NONE), but a model that never says NONE would otherwise
|
|
36
38
|
// barrage the user — the real mx5 run asked 10, several of them redundant.
|
|
@@ -534,6 +536,21 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
534
536
|
catch {
|
|
535
537
|
// best-effort registry
|
|
536
538
|
}
|
|
539
|
+
// Launch contract (mx5 run 10 item 4): extract the package/build SCRIPTS the design
|
|
540
|
+
// declares the project must expose (`migrate`/`seed` fell through decompose and
|
|
541
|
+
// shipped missing, unchecked). Each emitted name is re-grounded against the design
|
|
542
|
+
// (keepGroundedScripts — kept only if the design backticks it), so the final gate's
|
|
543
|
+
// manifest diff can never false-flag a hallucinated script. Best-effort.
|
|
544
|
+
try {
|
|
545
|
+
const scriptRaw = await deps.runChild('launch-extract', '', LAUNCH_EXTRACT_PROMPT(featureForModel));
|
|
546
|
+
const grounded = keepGroundedScripts(parseScriptLines(scriptRaw), featureForModel);
|
|
547
|
+
logPlanDebug(cwd, `launch-contract extraction: ${grounded.length} grounded script(s) kept`
|
|
548
|
+
+ ` from ${parseScriptLines(scriptRaw).length} emitted`);
|
|
549
|
+
await appendDeclaredScripts(cwd, grounded);
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
// best-effort artifact
|
|
553
|
+
}
|
|
537
554
|
// Thread the feature's spec doc(s) into every title so each per-task
|
|
538
555
|
// pipeline — which only ever sees its title — reads the real spec instead of
|
|
539
556
|
// a lossy one-line paraphrase of it.
|
|
@@ -617,8 +634,8 @@ function defaultDeps(ctx, cwd, signal, title) {
|
|
|
617
634
|
stashRef: cwd2 => gitStashRef(cwd2, signal),
|
|
618
635
|
// The final integration gate follows the `verify work` switch: it is the
|
|
619
636
|
// run-level half of the same verification story.
|
|
620
|
-
finalGate: cwd2 => getConfig().verifyWork ?
|
|
621
|
-
runFinalIntegrationGate(cwd2)
|
|
637
|
+
finalGate: (cwd2, planText) => getConfig().verifyWork ?
|
|
638
|
+
runFinalIntegrationGate(cwd2, undefined, undefined, undefined, planText)
|
|
622
639
|
: Promise.resolve({ ok: true, reason: 'disabled' })
|
|
623
640
|
};
|
|
624
641
|
}
|
|
@@ -679,9 +696,16 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
679
696
|
// recording must never break the gate
|
|
680
697
|
}
|
|
681
698
|
};
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
699
|
+
// Hand the parent plan (the task list) to the gate so it can tell a
|
|
700
|
+
// served app from a CLI: the boot check requires a listener only for
|
|
701
|
+
// the former (mx5 run 10 — a CSS watcher satisfied "still alive").
|
|
702
|
+
let fin = await deps.finalGate(cwd, body);
|
|
703
|
+
// Record the outcome symmetrically (mx5 run 10 item 7): only FAIL was
|
|
704
|
+
// ever trailed, so a PASSing gate was indistinguishable from a gate
|
|
705
|
+
// that never ran. The PASS reason names the commands that were run.
|
|
706
|
+
await recGate(fin.ok ?
|
|
707
|
+
`final-gate: PASS — ${fin.reason.slice(0, 300)}`
|
|
708
|
+
: `final-gate: FAIL — ${fin.reason.slice(0, 300)}`);
|
|
685
709
|
// ACCEPT-debt re-check surfacing (mx5 run 4 B3 / run 8 TASK_0012):
|
|
686
710
|
// tasks the user accepted despite a verify-FAIL that the gate could
|
|
687
711
|
// not prove resolved against the current tree. Surface them at the
|
|
@@ -690,9 +714,9 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
690
714
|
// already a human decision, so this reports, it does not re-fail.
|
|
691
715
|
if (fin.openDebts && fin.openDebts.length > 0) {
|
|
692
716
|
for (const d of fin.openDebts) {
|
|
693
|
-
await recGate(`
|
|
717
|
+
await recGate(`defect STILL OPEN — ${d.taskId || '(unknown task)'}: ${describeDebt(d)}: ${d.reason.slice(0, 240)}`);
|
|
694
718
|
}
|
|
695
|
-
active.ui.notify(`${id}: ${fin.openDebts.length}
|
|
719
|
+
active.ui.notify(`${id}: ${fin.openDebts.length} recorded verify-FAIL defect(s) are STILL unresolved at run end — see the gate trail.`, 'warning');
|
|
696
720
|
}
|
|
697
721
|
// Resolution loop: Leave-failed (recommended) / Autofix (bounded,
|
|
698
722
|
// model-driven fix pass + gate re-run — run 7's gap: the picker
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { spawn } from 'node:child_process';
|
|
9
9
|
import { getPiInvocation } from '../shared/pi-invocation.js';
|
|
10
|
-
import { runChild as runChildUnified
|
|
10
|
+
import { runChild as runChildUnified } from '../shared/child-process.js';
|
|
11
|
+
import { childBaseArgs } from '../shared/child-extensions.js';
|
|
11
12
|
import { LoopDetector } from './loop-detector.js';
|
|
12
13
|
import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
|
|
13
14
|
import { readSection, setTaskSection } from './task-io.js';
|
|
@@ -60,7 +61,7 @@ export function childArgs(tools) {
|
|
|
60
61
|
// runChild below / getPiInvocation), so a large inlined-design prompt can't
|
|
61
62
|
// overflow the OS command-line limit (Windows `spawn ENAMETOOLONG`).
|
|
62
63
|
const toolFlags = tools === '' ? ['--no-tools'] : ['--tools', tools];
|
|
63
|
-
return [...
|
|
64
|
+
return [...childBaseArgs(), '--mode', 'json', ...toolFlags];
|
|
64
65
|
}
|
|
65
66
|
// Sentinel error thrown when the user dismisses a grill-me dialog.
|
|
66
67
|
// Defined here (not in failure-classifier.ts) to avoid circular dependency.
|
|
@@ -51,9 +51,25 @@ export interface BootDeps {
|
|
|
51
51
|
} | null;
|
|
52
52
|
/** Terminate a pid we attribute to ourselves; returns whether it was signalled. */
|
|
53
53
|
reap?: (pid: number) => boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Does process group `pgid` currently own a LISTENing TCP socket? Drives the
|
|
56
|
+
* served-app boot check (mx5 run 10): a watcher (`dev` = tailwind/bundler
|
|
57
|
+
* --watch) stays alive forever without ever listening, so "still alive after the
|
|
58
|
+
* grace window = PASS" blessed a project that cannot serve a single request.
|
|
59
|
+
* Injected so the listener requirement is deterministically testable without a
|
|
60
|
+
* real socket; the default probes ss/lsof + pgid.
|
|
61
|
+
*/
|
|
62
|
+
groupHasListener?: (pgid: number) => boolean;
|
|
54
63
|
}
|
|
55
64
|
/**
|
|
56
|
-
*
|
|
65
|
+
* Does the finished run stand up a listening HTTP server? Deterministic, from the
|
|
66
|
+
* built manifest (a server-framework dependency is the plan's own artifact) OR, when
|
|
67
|
+
* available, the plan/spec text. Used to decide whether the boot check must observe a
|
|
68
|
+
* LISTENER (served app) or may pass on mere survival / quick exit (CLI project).
|
|
69
|
+
*/
|
|
70
|
+
export declare function detectsServedApp(cwd: string, planText?: string): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Exercise the start command ONCE. For a CLI project (`expectServer` false) the
|
|
57
73
|
* command's own fate within the grace window decides:
|
|
58
74
|
*
|
|
59
75
|
* - non-zero exit (or signal death) before the window closes → FAIL, output tail;
|
|
@@ -61,9 +77,22 @@ export interface BootDeps {
|
|
|
61
77
|
* - still alive when the window closes → PASS, then the whole process group is
|
|
62
78
|
* killed (detached spawn = own group; SIGTERM, escalating to SIGKILL).
|
|
63
79
|
*
|
|
80
|
+
* For a SERVED app (`expectServer` true — the spec/plan promised an HTTP server) mere
|
|
81
|
+
* survival is not enough: a watcher (`dev` = tailwind/bundler --watch) stays alive
|
|
82
|
+
* forever without ever listening, and a type-only entrypoint exits 0 in <1s having
|
|
83
|
+
* served nothing (mx5 run 10 — both were blessed by the survival rule). The boot then
|
|
84
|
+
* PASSes only once a LISTENing socket owned by our process group is observed; if the
|
|
85
|
+
* command exits, or the grace window closes, with no listener ever seen → FAIL naming
|
|
86
|
+
* that a listening server was expected. (The listener requirement needs pgid probing,
|
|
87
|
+
* absent on win32, where `expectServer` collapses to the survival rule — best-effort,
|
|
88
|
+
* never a false FAIL on a platform we cannot probe.)
|
|
89
|
+
*
|
|
64
90
|
* Env-gap contract as everywhere: spawn error (ENOENT) or exit 127 → skip.
|
|
65
91
|
*/
|
|
66
|
-
export declare function runBootCheck(cwd: string, [bin, args]: HealthCommand, graceMs?: number
|
|
92
|
+
export declare function runBootCheck(cwd: string, [bin, args]: HealthCommand, graceMs?: number, opts?: {
|
|
93
|
+
expectServer?: boolean;
|
|
94
|
+
deps?: BootDeps;
|
|
95
|
+
}): Promise<BootOutcome>;
|
|
67
96
|
/**
|
|
68
97
|
* Labels (`bin args…`) of every command the gate CAN currently discover — the
|
|
69
98
|
* static half (repo-health) plus the integration half. Pure discovery, nothing
|
|
@@ -78,5 +107,5 @@ export declare function discoverGateCommandLabels(cwd: string): string[];
|
|
|
78
107
|
* the start command — whole-repo, verbatim, unaided. Deterministic (no model).
|
|
79
108
|
* First real failure wins.
|
|
80
109
|
*/
|
|
81
|
-
export declare function runFinalIntegrationGate(cwd: string, timeoutMs?: number, bootGraceMs?: number, bootDeps?: BootDeps): Promise<FinalGateOutcome>;
|
|
110
|
+
export declare function runFinalIntegrationGate(cwd: string, timeoutMs?: number, bootGraceMs?: number, bootDeps?: BootDeps, planText?: string): Promise<FinalGateOutcome>;
|
|
82
111
|
export {};
|