pi-harness-delegate 0.7.3 → 0.8.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 +19 -4
- package/extensions/activity.ts +3 -0
- package/extensions/command.ts +51 -12
- package/extensions/config.ts +39 -0
- package/extensions/engine.ts +75 -18
- package/extensions/fanout.ts +5 -0
- package/extensions/index.ts +152 -7
- package/extensions/modes.ts +377 -0
- package/extensions/sanitize.ts +85 -0
- package/extensions/subcommands.ts +25 -25
- package/extensions/templates.ts +240 -43
- package/package.json +1 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one sanitizer for template-authored text shown to a model or a terminal (`delegate_modes`,
|
|
3
|
+
* `/delegate list`). Template files are attacker-influenceable in a trusted-but-hostile repo, so
|
|
4
|
+
* everything a reader could be fooled by — terminal escapes, invisible characters, stacked combining
|
|
5
|
+
* marks, homoglyph names — is neutralized here, in one place, rather than per call site.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ANSI CSI / OSC / two-byte escape sequences.
|
|
9
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping terminal escapes is the point
|
|
10
|
+
const ANSI_RE = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|\u001b[@-Z\\-_]/g;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Characters that render as nothing (or act on the terminal) but that a model still reads:
|
|
14
|
+
*
|
|
15
|
+
* - `\p{Cc}` C0/C1 controls and DEL (U+009B is the 8-bit CSI);
|
|
16
|
+
* - `\p{Cf}` every format character — soft hyphen, Arabic letter mark, Mongolian vowel separator,
|
|
17
|
+
* zero-width space/joiners, bidi marks/embeddings/overrides/isolates (Trojan Source), word joiner
|
|
18
|
+
* and invisible operators, BOM, interlinear annotation anchors, the invisible musical-notation
|
|
19
|
+
* formatters, and Unicode tag characters U+E0000–E007F ("ASCII smuggling");
|
|
20
|
+
* - `\p{Zl}`/`\p{Zp}` line / paragraph separators;
|
|
21
|
+
* - `\p{Co}` private-use characters (no standard glyph — free to mean anything to a font or model);
|
|
22
|
+
* - invisible characters outside those categories: variation selectors (U+FE00–FE0F, U+E0100–E01EF),
|
|
23
|
+
* the combining grapheme joiner U+034F, the Hangul fillers U+115F/U+1160/U+3164/U+FFA0, the Khmer
|
|
24
|
+
* inherent vowels U+17B4/U+17B5, and the blank Braille pattern U+2800;
|
|
25
|
+
* - the whole U+2060–U+206F and U+E0000–U+E007F (tag) blocks by range, including their unassigned
|
|
26
|
+
* code points, which `\p{Cf}` alone would miss.
|
|
27
|
+
*
|
|
28
|
+
* The one shared set: `sanitizeTemplateText` strips it (discovery output), `quoteValue` (templates.ts)
|
|
29
|
+
* escapes it as `\uXXXX` (permission/field warnings). Only the character set is shared — each channel
|
|
30
|
+
* keeps its own treatment. Never add a second list elsewhere; widen this one.
|
|
31
|
+
*/
|
|
32
|
+
// Built from a string so the escapes stay escapes (a formatter would otherwise inline them as literal
|
|
33
|
+
// invisible characters).
|
|
34
|
+
export const INVISIBLE_OR_CONTROL_RE = new RegExp(
|
|
35
|
+
[
|
|
36
|
+
'[\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}\\p{Co}',
|
|
37
|
+
'\\u034f\\u115f\\u1160\\u17b4\\u17b5\\u2800\\u3164\\ufe00-\\ufe0f\\uffa0\\u{e0100}-\\u{e01ef}',
|
|
38
|
+
'\\u2060-\\u206f\\u{e0000}-\\u{e007f}]',
|
|
39
|
+
].join(''),
|
|
40
|
+
'gu',
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/** At most this many combining marks in a row survive — enough for real scripts, not for "Zalgo". */
|
|
44
|
+
export const MAX_COMBINING_RUN = 2;
|
|
45
|
+
const COMBINING_RUN_RE = new RegExp(`(\\p{M}{${MAX_COMBINING_RUN}})\\p{M}+`, 'gu');
|
|
46
|
+
|
|
47
|
+
/** Cap `text` at `max` code points (with `…`), never leaving half a surrogate pair behind. */
|
|
48
|
+
function capText(text: string, max: number): string {
|
|
49
|
+
const chars = Array.from(text);
|
|
50
|
+
return chars.length > max ? `${chars.slice(0, Math.max(0, max - 1)).join('')}…` : text;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Template-authored free text made safe to show: ANSI escapes removed, invisible / control
|
|
55
|
+
* characters (`INVISIBLE_OR_CONTROL_RE`) replaced by a space, runs of combining marks cut to
|
|
56
|
+
* `MAX_COMBINING_RUN`, whitespace (including newlines) collapsed to one line, capped at `max`.
|
|
57
|
+
*/
|
|
58
|
+
export function sanitizeTemplateText(text: string, max: number): string {
|
|
59
|
+
const clean = text
|
|
60
|
+
.replace(ANSI_RE, '')
|
|
61
|
+
.replace(INVISIBLE_OR_CONTROL_RE, ' ')
|
|
62
|
+
.replace(COMBINING_RUN_RE, '$1')
|
|
63
|
+
.replace(/\s+/g, ' ')
|
|
64
|
+
.trim();
|
|
65
|
+
return capText(clean, max);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* An identifier (a mode name, a model name) made safe **and unambiguous** to show: sanitized as
|
|
70
|
+
* above, then every character outside printable ASCII escaped as `\u{…}`, so a homoglyph or
|
|
71
|
+
* mixed-script name (`reviеw` with a Cyrillic `е`) can never look identical to a real one (`review`).
|
|
72
|
+
* `escaped` says whether anything was escaped, for a warning alongside it.
|
|
73
|
+
*/
|
|
74
|
+
export function sanitizeIdentifier(text: string, max: number): { text: string; escaped: boolean } {
|
|
75
|
+
const clean = sanitizeTemplateText(text, Number.MAX_SAFE_INTEGER);
|
|
76
|
+
let escaped = false;
|
|
77
|
+
const ascii = Array.from(clean)
|
|
78
|
+
.map(ch => {
|
|
79
|
+
if (/^[\x20-\x7e]$/.test(ch)) return ch;
|
|
80
|
+
escaped = true;
|
|
81
|
+
return `\\u{${(ch.codePointAt(0) ?? 0).toString(16)}}`;
|
|
82
|
+
})
|
|
83
|
+
.join('');
|
|
84
|
+
return { text: capText(ascii, max), escaped };
|
|
85
|
+
}
|
|
@@ -16,34 +16,34 @@ import {
|
|
|
16
16
|
writeDelegateConfig,
|
|
17
17
|
} from './config.ts';
|
|
18
18
|
import { isProjectTrusted } from './engine.ts';
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
ALIASES,
|
|
21
|
+
detectAll,
|
|
22
|
+
getHarness,
|
|
23
|
+
HARNESS_NAMES,
|
|
24
|
+
isKnownHarness,
|
|
25
|
+
resolveHarnessName,
|
|
26
|
+
} from './harnesses/registry.ts';
|
|
20
27
|
import { readAllHistory } from './history.ts';
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
t.harness ? `(${t.harness})` : '',
|
|
29
|
-
];
|
|
30
|
-
return `${parts.filter(Boolean).join(' ')} — ${t.description}`;
|
|
31
|
-
}
|
|
32
|
-
|
|
28
|
+
import { collectModes, formatModeRow, formatOmitted } from './modes.ts';
|
|
29
|
+
import { loadTemplates, projectTemplatePresence } from './templates.ts';
|
|
30
|
+
/**
|
|
31
|
+
* `/delegate list [harness]` — the same read-only discovery data (`collectModes`) the model's
|
|
32
|
+
* `delegate_modes` tool sees, one row per mode: per-harness tier, source tier, defaults, sanitized
|
|
33
|
+
* description. Project-local templates only when the project is trusted, as for a run.
|
|
34
|
+
*/
|
|
33
35
|
export async function showModes(ctx: ExtensionContext, harnessFilter?: string): Promise<void> {
|
|
34
|
-
const all = new Map<string, DelegateTemplate>();
|
|
35
36
|
const trusted = isProjectTrusted(ctx);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const rows = [...all.values()].map(formatTemplateRow);
|
|
37
|
+
const report = collectModes(
|
|
38
|
+
ctx.cwd,
|
|
39
|
+
trusted,
|
|
40
|
+
harnessFilter ? [harnessFilter] : HARNESS_NAMES,
|
|
41
|
+
resolveHarnessName(loadConfigWithSource().config.defaultHarness),
|
|
42
|
+
);
|
|
43
|
+
const rows = report.modes.map(formatModeRow);
|
|
44
|
+
if (report.omitted > 0) rows.push(`… +${report.omitted} more not shown (${formatOmitted(report)})`);
|
|
45
|
+
if (!trusted && projectTemplatePresence(ctx.cwd, HARNESS_NAMES).dirs.length > 0)
|
|
46
|
+
rows.push('(project untrusted — its project-local templates were not loaded; see /delegate status)');
|
|
47
47
|
if (!ctx.hasUI) {
|
|
48
48
|
process.stdout.write(`${rows.join('\n')}\n`);
|
|
49
49
|
return;
|
package/extensions/templates.ts
CHANGED
|
@@ -3,6 +3,10 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import type { NormalizedPermission } from './harnesses/types.ts';
|
|
6
|
+
import { INVISIBLE_OR_CONTROL_RE } from './sanitize.ts';
|
|
7
|
+
|
|
8
|
+
/** Where a loaded template came from: shipped with the package, the user's own dirs, or the project. */
|
|
9
|
+
export type TemplateSource = 'builtin' | 'user' | 'project';
|
|
6
10
|
|
|
7
11
|
export type PermissionMode = 'plan' | 'acceptEdits' | 'bypassPermissions' | 'dontAsk' | 'auto' | 'manual';
|
|
8
12
|
|
|
@@ -29,6 +33,12 @@ export interface DelegateTemplate {
|
|
|
29
33
|
* legacy key that disagrees with `permission:` and was ignored (tier unchanged).
|
|
30
34
|
*/
|
|
31
35
|
permissionWarning?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Set when `permission:` is a native value and legacy `permissionMode:`/`sandbox:` keys were also
|
|
38
|
+
* present (and ignored). Whether they *disagree* depends on the native value's tier, which is only
|
|
39
|
+
* known per harness — so `delegate()` decides, via `nativeOverrideWarning()`. Never changes the tier.
|
|
40
|
+
*/
|
|
41
|
+
ignoredLegacyPermission?: IgnoredLegacyPermission;
|
|
32
42
|
model?: string;
|
|
33
43
|
maxBudgetUsd?: number;
|
|
34
44
|
skill?: string;
|
|
@@ -40,6 +50,95 @@ export interface DelegateTemplate {
|
|
|
40
50
|
addDirs?: string[];
|
|
41
51
|
prompt: string;
|
|
42
52
|
harness?: string;
|
|
53
|
+
/** Which tier this template was loaded from — set by `loadTemplates`, absent from `parseTemplate`. */
|
|
54
|
+
source?: TemplateSource;
|
|
55
|
+
/**
|
|
56
|
+
* Per-run harness timeout in seconds (`timeout: 900`), an integer within
|
|
57
|
+
* [`TEMPLATE_TIMEOUT_MIN_SEC`, `TEMPLATE_TIMEOUT_MAX_SEC`]. An out-of-range or non-integer value is
|
|
58
|
+
* ignored (the config timeout applies) and reported in `fieldWarnings`. See `resolveRunTimeoutMs`.
|
|
59
|
+
*/
|
|
60
|
+
timeoutSec?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Default harness(es) for this mode (`harnesses: codex` or `harnesses: claude, codex`), used ONLY
|
|
63
|
+
* when the caller names no harness. Lowercased; well-formed but unknown names are kept so the
|
|
64
|
+
* existing fan-out reporting (`resolveHarnessList`) names them. Never widens permission. Several
|
|
65
|
+
* names make a run with no harness a fan-out on both the `/delegate` command and the `delegate`
|
|
66
|
+
* tool — see `templateHarnessDefault` (command.ts).
|
|
67
|
+
*/
|
|
68
|
+
harnesses?: string[];
|
|
69
|
+
/**
|
|
70
|
+
* Non-permission frontmatter problems (an invalid `timeout:`, a rejected `harnesses:` entry):
|
|
71
|
+
* the value was ignored and the default applies. Shown in `/delegate list`, `delegate_modes` and
|
|
72
|
+
* at run time — never silently dropped.
|
|
73
|
+
*/
|
|
74
|
+
fieldWarnings?: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Bounds for a template's `timeout:` (seconds). A template can never raise a run past the max. */
|
|
78
|
+
export const TEMPLATE_TIMEOUT_MIN_SEC = 10;
|
|
79
|
+
export const TEMPLATE_TIMEOUT_MAX_SEC = 7200;
|
|
80
|
+
|
|
81
|
+
const HARNESS_NAME_RE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `harnesses:` frontmatter → a deduped, lowercased list. `all` is refused (a template must name
|
|
85
|
+
* its harnesses — `all` would silently fan out, and multiply spend, to whatever happens to be
|
|
86
|
+
* installed) and so is anything that isn't a plain harness-shaped word; both are reported as
|
|
87
|
+
* warnings and dropped. Unknown-but-well-formed names are kept for the run-time reporting.
|
|
88
|
+
*/
|
|
89
|
+
export function parseTemplateHarnesses(raw: string | undefined): { harnesses?: string[]; warnings: string[] } {
|
|
90
|
+
const warnings: string[] = [];
|
|
91
|
+
const out: string[] = [];
|
|
92
|
+
for (const item of parseList(raw) ?? []) {
|
|
93
|
+
const name = item.toLowerCase();
|
|
94
|
+
if (name === 'all') {
|
|
95
|
+
warnings.push('harnesses: "all" ignored — name the harnesses explicitly');
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (!HARNESS_NAME_RE.test(name)) {
|
|
99
|
+
warnings.push(`harnesses: entry ${quoteValue(item)} ignored — not a harness name`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!out.includes(name)) out.push(name);
|
|
103
|
+
}
|
|
104
|
+
return { harnesses: out.length > 0 ? out : undefined, warnings };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `timeout:` frontmatter → seconds, or a warning (value ignored) when it isn't a bounded integer. */
|
|
108
|
+
export function parseTemplateTimeout(raw: string | undefined): { timeoutSec?: number; warning?: string } {
|
|
109
|
+
const v = raw?.trim();
|
|
110
|
+
if (!v) return {};
|
|
111
|
+
const n = /^\d{1,9}$/.test(v) ? Number(v) : NaN;
|
|
112
|
+
if (Number.isInteger(n) && n >= TEMPLATE_TIMEOUT_MIN_SEC && n <= TEMPLATE_TIMEOUT_MAX_SEC) return { timeoutSec: n };
|
|
113
|
+
return {
|
|
114
|
+
warning: `timeout: ${quoteValue(v)} ignored — must be a whole number of seconds from ${TEMPLATE_TIMEOUT_MIN_SEC} to ${TEMPLATE_TIMEOUT_MAX_SEC}; the configured timeout applies`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A per-call timeout (`delegate` tool `timeoutSec`, `/delegate --timeout=<sec>`) must be a whole
|
|
120
|
+
* number of seconds within the same bounds as a template's `timeout:`. Unlike a bad frontmatter
|
|
121
|
+
* value (ignored with a warning — the template author isn't there to ask), a bad per-call value is
|
|
122
|
+
* an error: the caller asked for a specific limit we can't honor. `null` when valid.
|
|
123
|
+
*/
|
|
124
|
+
export function callTimeoutError(sec: unknown): string | null {
|
|
125
|
+
if (
|
|
126
|
+
typeof sec === 'number' &&
|
|
127
|
+
Number.isInteger(sec) &&
|
|
128
|
+
sec >= TEMPLATE_TIMEOUT_MIN_SEC &&
|
|
129
|
+
sec <= TEMPLATE_TIMEOUT_MAX_SEC
|
|
130
|
+
)
|
|
131
|
+
return null;
|
|
132
|
+
const shown =
|
|
133
|
+
typeof sec === 'number' && Number.isFinite(sec) ? String(sec) : JSON.stringify(String(sec)).slice(0, 40);
|
|
134
|
+
return `timeout must be a whole number of seconds from ${TEMPLATE_TIMEOUT_MIN_SEC} to ${TEMPLATE_TIMEOUT_MAX_SEC} (got ${shown})`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The legacy keys ignored next to a native `permission:` value — keys already sanitized (`displayKey`). */
|
|
138
|
+
export interface IgnoredLegacyPermission {
|
|
139
|
+
/** The `permission:` key as the author spelled it. */
|
|
140
|
+
permissionKey: string;
|
|
141
|
+
legacy: FrontmatterEntry[];
|
|
43
142
|
}
|
|
44
143
|
|
|
45
144
|
/** Tier order, least permissive first — used to pick the safer of two conflicting legacy keys. */
|
|
@@ -62,12 +161,23 @@ const LEGACY_SANDBOX_TIERS: Record<string, NormalizedPermission> = {
|
|
|
62
161
|
'danger-full-access': 'danger',
|
|
63
162
|
};
|
|
64
163
|
|
|
164
|
+
/** Each match → `\uXXXX` (per UTF-16 code unit), so it is visible and inert. */
|
|
165
|
+
function escapeInvisible(text: string): string {
|
|
166
|
+
return text.replace(INVISIBLE_OR_CONTROL_RE, ch =>
|
|
167
|
+
Array.from({ length: ch.length }, (_, i) => `\\u${ch.charCodeAt(i).toString(16).padStart(4, '0')}`).join(''),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
65
171
|
/**
|
|
66
|
-
* A frontmatter
|
|
67
|
-
*
|
|
172
|
+
* A frontmatter-derived string echoed back in a warning (`/delegate list`, run-time notes): JSON-quoted
|
|
173
|
+
* so C0 controls / ANSI escapes are inert, the characters `JSON.stringify` leaves raw (C1, bidi,
|
|
174
|
+
* zero-width, U+2028/9, tag characters, … — the shared `INVISIBLE_OR_CONTROL_RE` set from
|
|
175
|
+
* `sanitize.ts`) escaped as `\uXXXX` too, and capped at `max` chars so a huge value can't flood the
|
|
176
|
+
* line. Escaped rather than stripped (unlike `sanitizeTemplateText`): a warning must show what the
|
|
177
|
+
* author actually wrote.
|
|
68
178
|
*/
|
|
69
|
-
function quoteValue(value: string): string {
|
|
70
|
-
return JSON.stringify(value).slice(0,
|
|
179
|
+
export function quoteValue(value: string, max = 60): string {
|
|
180
|
+
return escapeInvisible(JSON.stringify(value)).slice(0, max);
|
|
71
181
|
}
|
|
72
182
|
|
|
73
183
|
interface LegacyPermission {
|
|
@@ -96,23 +206,48 @@ function classifyLegacyValue(value: string): Omit<LegacyPermission, 'permissionW
|
|
|
96
206
|
return tier ? { permission: tier, permissionMode: TIER_PERMISSION_MODE[tier] } : null;
|
|
97
207
|
}
|
|
98
208
|
|
|
99
|
-
/** The legacy keys actually set (non-empty), as `[key, value]` pairs. */
|
|
209
|
+
/** The legacy keys actually set (non-empty), as `[key, value]` pairs — `key` as the author spelled it. */
|
|
100
210
|
function legacyEntries(permissionMode: FrontmatterValues, sandbox: FrontmatterValues): [string, string][] {
|
|
101
|
-
return [
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
211
|
+
return [...entries(permissionMode, 'permissionMode'), ...entries(sandbox, 'sandbox')];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** One occurrence of a permission key: its value, plus the key as the author spelled it (`Sandbox`). */
|
|
215
|
+
export interface FrontmatterEntry {
|
|
216
|
+
key: string;
|
|
217
|
+
value: string;
|
|
105
218
|
}
|
|
106
219
|
|
|
107
220
|
/**
|
|
108
221
|
* A permission key's value(s): the keys are read case-insensitively and every occurrence is kept
|
|
109
222
|
* (`sandbox:` + `Sandbox:` is two values), so callers take an array; a single string still works.
|
|
223
|
+
* A bare string element is attributed to the canonical key; a `FrontmatterEntry` keeps the author's.
|
|
110
224
|
*/
|
|
111
|
-
type FrontmatterValues = string | readonly string[] | undefined;
|
|
225
|
+
type FrontmatterValues = string | readonly (string | FrontmatterEntry)[] | undefined;
|
|
226
|
+
|
|
227
|
+
/** Non-empty trimmed `[key, value]` pairs — an empty value means "not set". */
|
|
228
|
+
function entries(v: FrontmatterValues, canonical: string): [string, string][] {
|
|
229
|
+
return (typeof v === 'string' ? [v] : (v ?? []))
|
|
230
|
+
.map((x): [string, string] =>
|
|
231
|
+
typeof x === 'string' ? [canonical, x.trim()] : [displayKey(x.key, canonical), x.value.trim()],
|
|
232
|
+
)
|
|
233
|
+
.filter(([, value]) => value !== '');
|
|
234
|
+
}
|
|
112
235
|
|
|
113
236
|
/** Non-empty trimmed values — an empty value means "not set". */
|
|
114
237
|
function values(v: FrontmatterValues): string[] {
|
|
115
|
-
return (
|
|
238
|
+
return entries(v, '').map(([, value]) => value);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A frontmatter key echoed back in a warning: the author's own spelling (`Sandbox`, `SANDBOX`) so
|
|
243
|
+
* the warning points at the line they actually wrote — but only when it is a plain case variant of
|
|
244
|
+
* the canonical key made of `[A-Za-z_]` (capped). Keys come from template files a hostile project
|
|
245
|
+
* controls, so anything else (control chars, ANSI escapes, Unicode look-alikes) falls back to the
|
|
246
|
+
* canonical name rather than reaching the terminal.
|
|
247
|
+
*/
|
|
248
|
+
export function displayKey(raw: string, canonical: string): string {
|
|
249
|
+
const key = raw.trim();
|
|
250
|
+
return /^[A-Za-z_]{1,32}$/.test(key) && key.toLowerCase() === canonical.toLowerCase() ? key : canonical;
|
|
116
251
|
}
|
|
117
252
|
|
|
118
253
|
/**
|
|
@@ -156,6 +291,7 @@ export function normalizePermission(
|
|
|
156
291
|
nativePermission?: string;
|
|
157
292
|
permissionMode: PermissionMode;
|
|
158
293
|
permissionWarning?: string;
|
|
294
|
+
ignoredLegacyPermission?: IgnoredLegacyPermission;
|
|
159
295
|
} {
|
|
160
296
|
// Prefer normalized permission
|
|
161
297
|
const raws = values(raw);
|
|
@@ -166,36 +302,87 @@ export function normalizePermission(
|
|
|
166
302
|
const all = raws.map(normalizeTierValue);
|
|
167
303
|
const distinct = new Set(all.map(r => `${r.permission}|${r.nativePermission ?? ''}`));
|
|
168
304
|
if (distinct.size > 1) {
|
|
169
|
-
const listed = raws.map(quoteValue).join(', ');
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
305
|
+
const listed = raws.map(v => quoteValue(v)).join(', ');
|
|
306
|
+
const least = all.some(r => r.nativePermission)
|
|
307
|
+
? undefined
|
|
308
|
+
: all.reduce((a, b) => (TIER_RANK[b.permission] < TIER_RANK[a.permission] ? b : a));
|
|
309
|
+
const resolved = least
|
|
310
|
+
? {
|
|
311
|
+
...least,
|
|
312
|
+
permissionWarning: `conflicting permission: values ${listed} — using the least permissive, ${least.permission}`,
|
|
313
|
+
}
|
|
314
|
+
: {
|
|
315
|
+
permission: 'readonly' as const,
|
|
316
|
+
permissionMode: 'plan' as const,
|
|
317
|
+
permissionWarning: `conflicting permission: values ${listed} — loaded as readonly (fail closed)`,
|
|
318
|
+
};
|
|
319
|
+
// Legacy keys are ignored here too — judged against the tier this resolved to, and appended
|
|
320
|
+
// rather than dropped (no native value survives this branch, so delegate() can't judge them).
|
|
321
|
+
const [[permissionKey]] = entries(raw, 'permission');
|
|
322
|
+
const ignored = legacyOverrideWarning(
|
|
323
|
+
`${permissionKey}: ${resolved.permission}`,
|
|
324
|
+
resolved.permission,
|
|
325
|
+
legacyEntries(fallbackMode, sandbox),
|
|
326
|
+
);
|
|
327
|
+
return ignored ? { ...resolved, permissionWarning: `${resolved.permissionWarning}; ${ignored}` } : resolved;
|
|
181
328
|
}
|
|
182
329
|
}
|
|
183
330
|
if (raws.length > 0) {
|
|
184
331
|
const resolved = normalizeTierValue(raws[0]);
|
|
185
332
|
// `permission:` always wins; a legacy key that disagrees is ignored, but said so — an author who
|
|
186
|
-
// wrote `sandbox: read-only` next to `permission: edit` should see which one ran.
|
|
187
|
-
//
|
|
333
|
+
// wrote `sandbox: read-only` next to `permission: edit` should see which one ran. For a native
|
|
334
|
+
// value the tier is only known per harness, so the ignored keys are carried for delegate() to judge.
|
|
188
335
|
const legacy = legacyEntries(fallbackMode, sandbox);
|
|
189
|
-
if (
|
|
190
|
-
const
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
|
|
336
|
+
if (legacy.length === 0) return resolved;
|
|
337
|
+
const [[permissionKey]] = entries(raw, 'permission');
|
|
338
|
+
if (resolved.nativePermission)
|
|
339
|
+
return {
|
|
340
|
+
...resolved,
|
|
341
|
+
ignoredLegacyPermission: { permissionKey, legacy: legacy.map(([key, value]) => ({ key, value })) },
|
|
342
|
+
};
|
|
343
|
+
const warning = legacyOverrideWarning(`${permissionKey}: ${resolved.permission}`, resolved.permission, legacy);
|
|
344
|
+
return warning ? { ...resolved, permissionWarning: warning } : resolved;
|
|
194
345
|
}
|
|
195
346
|
// Legacy permissionMode/sandbox mapping — fails closed to readonly on an unrecognized value
|
|
196
347
|
return normalizeLegacyPermission(fallbackMode, sandbox);
|
|
197
348
|
}
|
|
198
349
|
|
|
350
|
+
/**
|
|
351
|
+
* `<label> overrides <legacy keys> (ignored)` unless every ignored legacy value is recognized and maps
|
|
352
|
+
* to exactly `tier` — so one that disagrees with `tier`, one that is unrecognized, or ignored keys that
|
|
353
|
+
* disagree with each other (`sandbox: read-only` + `Sandbox: workspace-write`, even when the least
|
|
354
|
+
* permissive of them matches `tier`) are all flagged; `undefined` when there are none or they all
|
|
355
|
+
* agree. Keys are echoed as given (already `displayKey`-sanitized), values quoted via `quoteValue`.
|
|
356
|
+
*/
|
|
357
|
+
function legacyOverrideWarning(
|
|
358
|
+
label: string,
|
|
359
|
+
tier: NormalizedPermission,
|
|
360
|
+
legacy: readonly [string, string][],
|
|
361
|
+
): string | undefined {
|
|
362
|
+
if (legacy.every(([, v]) => classifyLegacyValue(v)?.permission === tier)) return undefined;
|
|
363
|
+
const ignored = legacy.map(([k, v]) => `${k}: ${quoteValue(v)}`).join(', ');
|
|
364
|
+
return `${label} overrides ${ignored} (ignored)`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The run-time counterpart of the override warning for a native `permission:` value: `delegate()`
|
|
369
|
+
* passes the tier that value actually runs at on `harnessName` (readonly/edit for an allowlisted
|
|
370
|
+
* value, danger for one gated as danger); `harnessName` must be the canonical, registry-resolved
|
|
371
|
+
* name (it is echoed unquoted). Only a warning — the tier is never changed by it.
|
|
372
|
+
*/
|
|
373
|
+
export function nativeOverrideWarning(
|
|
374
|
+
ignored: IgnoredLegacyPermission,
|
|
375
|
+
nativePermission: string,
|
|
376
|
+
tier: NormalizedPermission,
|
|
377
|
+
harnessName: string,
|
|
378
|
+
): string | undefined {
|
|
379
|
+
return legacyOverrideWarning(
|
|
380
|
+
`${ignored.permissionKey}: ${quoteValue(nativePermission)} (${tier} on ${harnessName})`,
|
|
381
|
+
tier,
|
|
382
|
+
ignored.legacy.map(e => [e.key, e.value]),
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
199
386
|
/** One `permission:` value → tier, or (unknown value) the native escape hatch. */
|
|
200
387
|
function normalizeTierValue(raw: string): {
|
|
201
388
|
permission: NormalizedPermission;
|
|
@@ -242,14 +429,15 @@ export function parseTemplate(text: string): DelegateTemplate | null {
|
|
|
242
429
|
const meta: Record<string, string> = {};
|
|
243
430
|
// The keys that decide the tier are matched case-insensitively and keep every occurrence: an
|
|
244
431
|
// exact-case lookup let `Sandbox: read-only` be silently ignored (→ the `edit` default, verify on).
|
|
245
|
-
|
|
432
|
+
// Each occurrence keeps the author's key spelling, so a warning names the line they wrote.
|
|
433
|
+
const perm: Record<PermissionKey, FrontmatterEntry[]> = { permission: [], permissionMode: [], sandbox: [] };
|
|
246
434
|
for (const line of m[1].split('\n')) {
|
|
247
435
|
const i = line.indexOf(':');
|
|
248
436
|
if (i <= 0) continue;
|
|
249
437
|
const key = line.slice(0, i).trim();
|
|
250
438
|
const value = line.slice(i + 1).trim();
|
|
251
439
|
const permKey = PERMISSION_KEYS.get(key.toLowerCase());
|
|
252
|
-
if (permKey) perm[permKey].push(value);
|
|
440
|
+
if (permKey) perm[permKey].push({ key, value });
|
|
253
441
|
else meta[key] = value;
|
|
254
442
|
}
|
|
255
443
|
|
|
@@ -260,6 +448,11 @@ export function parseTemplate(text: string): DelegateTemplate | null {
|
|
|
260
448
|
|
|
261
449
|
const budget = meta.maxBudgetUsd ? Number(meta.maxBudgetUsd) : NaN;
|
|
262
450
|
const description = meta.description ?? '';
|
|
451
|
+
const fieldWarnings: string[] = [];
|
|
452
|
+
const timeout = parseTemplateTimeout(meta.timeout);
|
|
453
|
+
if (timeout.warning) fieldWarnings.push(timeout.warning);
|
|
454
|
+
const harnesses = parseTemplateHarnesses(meta.harnesses);
|
|
455
|
+
fieldWarnings.push(...harnesses.warnings);
|
|
263
456
|
|
|
264
457
|
return {
|
|
265
458
|
name,
|
|
@@ -271,6 +464,7 @@ export function parseTemplate(text: string): DelegateTemplate | null {
|
|
|
271
464
|
nativePermission: norm.nativePermission,
|
|
272
465
|
permissionMode: norm.permissionMode,
|
|
273
466
|
permissionWarning: norm.permissionWarning,
|
|
467
|
+
ignoredLegacyPermission: norm.ignoredLegacyPermission,
|
|
274
468
|
model: meta.model || undefined,
|
|
275
469
|
maxBudgetUsd: Number.isFinite(budget) && budget > 0 ? budget : undefined,
|
|
276
470
|
skill: meta.skill || undefined,
|
|
@@ -280,16 +474,19 @@ export function parseTemplate(text: string): DelegateTemplate | null {
|
|
|
280
474
|
addDirs: parseList(meta.addDirs),
|
|
281
475
|
prompt: m[2].trim(),
|
|
282
476
|
harness: meta.harness || undefined,
|
|
477
|
+
timeoutSec: timeout.timeoutSec,
|
|
478
|
+
harnesses: harnesses.harnesses,
|
|
479
|
+
fieldWarnings: fieldWarnings.length > 0 ? fieldWarnings : undefined,
|
|
283
480
|
};
|
|
284
481
|
}
|
|
285
482
|
|
|
286
|
-
function loadDir(dir: string, out: Map<string, DelegateTemplate
|
|
483
|
+
function loadDir(dir: string, out: Map<string, DelegateTemplate>, source: TemplateSource): void {
|
|
287
484
|
if (!existsSync(dir)) return;
|
|
288
485
|
for (const f of readdirSync(dir)) {
|
|
289
486
|
if (!f.endsWith('.md')) continue;
|
|
290
487
|
try {
|
|
291
488
|
const t = parseTemplate(readFileSync(join(dir, f), 'utf8'));
|
|
292
|
-
if (t) out.set(t.name, t);
|
|
489
|
+
if (t) out.set(t.name, { ...t, source });
|
|
293
490
|
} catch {
|
|
294
491
|
// skip unreadable files
|
|
295
492
|
}
|
|
@@ -342,20 +539,20 @@ export function loadTemplates(cwd: string, harnessName?: string, trusted = false
|
|
|
342
539
|
const out = new Map<string, DelegateTemplate>();
|
|
343
540
|
const harness = harnessName ?? 'claude';
|
|
344
541
|
// legacy root builtins (templates/*.md) lowest — for migration from pi-claude-delegate
|
|
345
|
-
loadDir(builtinTemplatesDir(), out);
|
|
542
|
+
loadDir(builtinTemplatesDir(), out, 'builtin');
|
|
346
543
|
// shared canonical bodies
|
|
347
|
-
loadDir(sharedTemplatesDir(), out);
|
|
544
|
+
loadDir(sharedTemplatesDir(), out, 'builtin');
|
|
348
545
|
// harness-specific builtins override shared
|
|
349
|
-
loadDir(builtinHarnessTemplatesDir(harness), out);
|
|
546
|
+
loadDir(builtinHarnessTemplatesDir(harness), out, 'builtin');
|
|
350
547
|
// user globals: legacy before new so new wins
|
|
351
|
-
loadDir(legacyUserTemplatesDir(), out);
|
|
352
|
-
loadDir(userTemplatesDir(), out);
|
|
353
|
-
loadDir(userTemplatesDir(harness), out);
|
|
548
|
+
loadDir(legacyUserTemplatesDir(), out, 'user');
|
|
549
|
+
loadDir(userTemplatesDir(), out, 'user');
|
|
550
|
+
loadDir(userTemplatesDir(harness), out, 'user');
|
|
354
551
|
// project locals: legacy before new so new wins — only if trusted
|
|
355
552
|
if (trusted) {
|
|
356
|
-
loadDir(legacyProjectTemplatesDir(cwd), out);
|
|
357
|
-
loadDir(projectTemplatesDir(cwd), out);
|
|
358
|
-
loadDir(projectTemplatesDir(cwd, harness), out);
|
|
553
|
+
loadDir(legacyProjectTemplatesDir(cwd), out, 'project');
|
|
554
|
+
loadDir(projectTemplatesDir(cwd), out, 'project');
|
|
555
|
+
loadDir(projectTemplatesDir(cwd, harness), out, 'project');
|
|
359
556
|
}
|
|
360
557
|
return out;
|
|
361
558
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-harness-delegate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Delegate work to any harness (Claude Code, Muse, OpenCode, Amp, Devin) from the pi coding agent \u2014 code reviews, plans, implementation, security audits, docs, or your own custom templates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "bun@1.3.14",
|