pi-harness-delegate 0.7.2 → 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 +28 -13
- package/extensions/acp-runner.ts +7 -1
- package/extensions/activity.ts +6 -0
- package/extensions/command.ts +65 -16
- package/extensions/config.ts +52 -9
- package/extensions/engine.ts +102 -14
- package/extensions/fanout.ts +5 -0
- package/extensions/harnesses/amp.ts +3 -0
- package/extensions/harnesses/claude.ts +3 -0
- package/extensions/harnesses/codex.ts +1 -0
- package/extensions/harnesses/devin.ts +3 -0
- package/extensions/harnesses/opencode.ts +3 -0
- package/extensions/harnesses/registry.ts +46 -5
- package/extensions/harnesses/types.ts +7 -0
- package/extensions/index.ts +162 -8
- package/extensions/modes.ts +377 -0
- package/extensions/sanitize.ts +85 -0
- package/extensions/subcommands.ts +25 -25
- package/extensions/templates.ts +406 -41
- package/package.json +1 -1
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
|
|
|
@@ -23,6 +27,18 @@ export interface DelegateTemplate {
|
|
|
23
27
|
nativePermission?: string;
|
|
24
28
|
/** Legacy raw permissionMode for transcript compat. */
|
|
25
29
|
permissionMode: PermissionMode;
|
|
30
|
+
/**
|
|
31
|
+
* A problem with the template's permission keys, shown in `/delegate list` and at run time: an
|
|
32
|
+
* unrecognized legacy `permissionMode:`/`sandbox:` value (failed closed to `readonly`), or a
|
|
33
|
+
* legacy key that disagrees with `permission:` and was ignored (tier unchanged).
|
|
34
|
+
*/
|
|
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;
|
|
26
42
|
model?: string;
|
|
27
43
|
maxBudgetUsd?: number;
|
|
28
44
|
skill?: string;
|
|
@@ -34,37 +50,359 @@ export interface DelegateTemplate {
|
|
|
34
50
|
addDirs?: string[];
|
|
35
51
|
prompt: string;
|
|
36
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[];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Tier order, least permissive first — used to pick the safer of two conflicting legacy keys. */
|
|
145
|
+
const TIER_RANK: Record<NormalizedPermission, number> = { readonly: 0, edit: 1, danger: 2 };
|
|
146
|
+
|
|
147
|
+
const TIER_PERMISSION_MODE: Record<NormalizedPermission, PermissionMode> = {
|
|
148
|
+
readonly: 'plan',
|
|
149
|
+
edit: 'acceptEdits',
|
|
150
|
+
danger: 'bypassPermissions',
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** Claude `PermissionMode` names, matched case- and `-`/`_`-insensitively (`accept-edits` → `acceptEdits`). */
|
|
154
|
+
const PERMISSION_MODES_BY_LOWER = new Map<string, PermissionMode>([...PERMISSION_MODES].map(m => [m.toLowerCase(), m]));
|
|
155
|
+
|
|
156
|
+
/** Codex `--sandbox` values (the legacy `sandbox:` key) → normalized tier. Keys are lowercase, `_`→`-`. */
|
|
157
|
+
const LEGACY_SANDBOX_TIERS: Record<string, NormalizedPermission> = {
|
|
158
|
+
'read-only': 'readonly',
|
|
159
|
+
readonly: 'readonly',
|
|
160
|
+
'workspace-write': 'edit',
|
|
161
|
+
'danger-full-access': 'danger',
|
|
162
|
+
};
|
|
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
|
+
|
|
171
|
+
/**
|
|
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.
|
|
178
|
+
*/
|
|
179
|
+
export function quoteValue(value: string, max = 60): string {
|
|
180
|
+
return escapeInvisible(JSON.stringify(value)).slice(0, max);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface LegacyPermission {
|
|
184
|
+
permission: NormalizedPermission;
|
|
185
|
+
permissionMode: PermissionMode;
|
|
186
|
+
/** Set when a legacy value was unrecognized and the template was failed closed to `readonly`. */
|
|
187
|
+
permissionWarning?: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Map one legacy `permissionMode:`/`sandbox:` value onto a normalized tier. Both keys accept both
|
|
192
|
+
* vocabularies (claude PermissionMode names and codex sandbox values), case/whitespace/`-`/`_`-insensitive.
|
|
193
|
+
* Returns `null` for an unrecognized value — the caller fails that closed.
|
|
194
|
+
*/
|
|
195
|
+
function classifyLegacyValue(value: string): Omit<LegacyPermission, 'permissionWarning'> | null {
|
|
196
|
+
const lower = value.trim().toLowerCase();
|
|
197
|
+
// Claude names have no separators of their own, so `accept-edits`/`bypass_permissions` can only
|
|
198
|
+
// ever mean that same name (and tier) — danger stays gated behind allowDangerous downstream.
|
|
199
|
+
const mode = PERMISSION_MODES_BY_LOWER.get(lower.replace(/[-_]/g, ''));
|
|
200
|
+
if (mode) {
|
|
201
|
+
if (mode === 'plan') return { permission: 'readonly', permissionMode: mode };
|
|
202
|
+
if (mode === 'bypassPermissions') return { permission: 'danger', permissionMode: mode };
|
|
203
|
+
return { permission: 'edit', permissionMode: mode };
|
|
204
|
+
}
|
|
205
|
+
const tier = LEGACY_SANDBOX_TIERS[lower.replace(/_/g, '-')];
|
|
206
|
+
return tier ? { permission: tier, permissionMode: TIER_PERMISSION_MODE[tier] } : null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The legacy keys actually set (non-empty), as `[key, value]` pairs — `key` as the author spelled it. */
|
|
210
|
+
function legacyEntries(permissionMode: FrontmatterValues, sandbox: FrontmatterValues): [string, string][] {
|
|
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;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A permission key's value(s): the keys are read case-insensitively and every occurrence is kept
|
|
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.
|
|
224
|
+
*/
|
|
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
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Non-empty trimmed values — an empty value means "not set". */
|
|
237
|
+
function values(v: FrontmatterValues): string[] {
|
|
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;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Legacy `permissionMode:`/`sandbox:` keys → normalized tier. Only consulted when `permission:` is
|
|
255
|
+
* absent. An empty/absent value means "not set" (default `edit`, as always).
|
|
256
|
+
*
|
|
257
|
+
* **Fails closed:** an unrecognized value (a typo, a value from some other harness's vocabulary)
|
|
258
|
+
* yields `readonly` plus `permissionWarning` — never the old silent `edit`, and never a refusal
|
|
259
|
+
* to load. Not loading would let a same-named lower tier (e.g. the builtin `implement`, `edit`)
|
|
260
|
+
* silently win instead, which can be MORE permissive than an author who wrote `sandbox: readonyl`
|
|
261
|
+
* meant; `readonly` is the one tier that can never exceed what any author meant (and it also skips
|
|
262
|
+
* `verify:`). When both keys (or several case variants of one) are set, the least permissive wins.
|
|
263
|
+
*/
|
|
264
|
+
export function normalizeLegacyPermission(
|
|
265
|
+
permissionMode: FrontmatterValues,
|
|
266
|
+
sandbox: FrontmatterValues,
|
|
267
|
+
): LegacyPermission {
|
|
268
|
+
const set = legacyEntries(permissionMode, sandbox);
|
|
269
|
+
if (set.length === 0) return { permission: 'edit', permissionMode: 'acceptEdits' };
|
|
270
|
+
let best: Omit<LegacyPermission, 'permissionWarning'> | null = null;
|
|
271
|
+
for (const [key, value] of set) {
|
|
272
|
+
const c = classifyLegacyValue(value);
|
|
273
|
+
if (!c) {
|
|
274
|
+
return {
|
|
275
|
+
permission: 'readonly',
|
|
276
|
+
permissionMode: 'plan',
|
|
277
|
+
permissionWarning: `unrecognized ${key}: ${quoteValue(value)} — loaded as readonly (fail closed)`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
if (!best || TIER_RANK[c.permission] < TIER_RANK[best.permission]) best = c;
|
|
281
|
+
}
|
|
282
|
+
return best as LegacyPermission;
|
|
37
283
|
}
|
|
38
284
|
|
|
39
285
|
export function normalizePermission(
|
|
40
|
-
raw:
|
|
41
|
-
fallbackMode:
|
|
42
|
-
|
|
286
|
+
raw: FrontmatterValues,
|
|
287
|
+
fallbackMode: FrontmatterValues,
|
|
288
|
+
sandbox?: FrontmatterValues,
|
|
289
|
+
): {
|
|
290
|
+
permission: NormalizedPermission;
|
|
291
|
+
nativePermission?: string;
|
|
292
|
+
permissionMode: PermissionMode;
|
|
293
|
+
permissionWarning?: string;
|
|
294
|
+
ignoredLegacyPermission?: IgnoredLegacyPermission;
|
|
295
|
+
} {
|
|
43
296
|
// Prefer normalized permission
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
297
|
+
const raws = values(raw);
|
|
298
|
+
if (raws.length > 1) {
|
|
299
|
+
// `permission:` given more than once (e.g. `permission:` and `Permission:`) — the same tier is
|
|
300
|
+
// fine; disagreeing tiers take the least permissive, and a disagreement involving a native value
|
|
301
|
+
// (whose tier is per-harness, so not comparable here) fails closed to readonly. Never "last wins".
|
|
302
|
+
const all = raws.map(normalizeTierValue);
|
|
303
|
+
const distinct = new Set(all.map(r => `${r.permission}|${r.nativePermission ?? ''}`));
|
|
304
|
+
if (distinct.size > 1) {
|
|
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;
|
|
328
|
+
}
|
|
59
329
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
330
|
+
if (raws.length > 0) {
|
|
331
|
+
const resolved = normalizeTierValue(raws[0]);
|
|
332
|
+
// `permission:` always wins; a legacy key that disagrees is ignored, but said so — an author who
|
|
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.
|
|
335
|
+
const legacy = legacyEntries(fallbackMode, sandbox);
|
|
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;
|
|
66
345
|
}
|
|
67
|
-
|
|
346
|
+
// Legacy permissionMode/sandbox mapping — fails closed to readonly on an unrecognized value
|
|
347
|
+
return normalizeLegacyPermission(fallbackMode, sandbox);
|
|
348
|
+
}
|
|
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
|
+
|
|
386
|
+
/** One `permission:` value → tier, or (unknown value) the native escape hatch. */
|
|
387
|
+
function normalizeTierValue(raw: string): {
|
|
388
|
+
permission: NormalizedPermission;
|
|
389
|
+
nativePermission?: string;
|
|
390
|
+
permissionMode: PermissionMode;
|
|
391
|
+
} {
|
|
392
|
+
const lower = raw.trim().toLowerCase();
|
|
393
|
+
if (lower === 'readonly' || lower === 'read-only' || lower === 'read_only')
|
|
394
|
+
return { permission: 'readonly', permissionMode: 'plan' };
|
|
395
|
+
if (lower === 'edit' || lower === 'acceptedits' || lower === 'accept-edits')
|
|
396
|
+
return { permission: 'edit', permissionMode: 'acceptEdits' };
|
|
397
|
+
if (
|
|
398
|
+
lower === 'danger' ||
|
|
399
|
+
lower === 'bypasspermissions' ||
|
|
400
|
+
lower === 'danger-full-access' ||
|
|
401
|
+
lower === 'danger_full_access'
|
|
402
|
+
)
|
|
403
|
+
return { permission: 'danger', permissionMode: 'bypassPermissions' };
|
|
404
|
+
// Unknown native — treat as native escape hatch
|
|
405
|
+
return { permission: 'edit', nativePermission: raw.trim(), permissionMode: 'acceptEdits' };
|
|
68
406
|
}
|
|
69
407
|
|
|
70
408
|
/** Comma-separated frontmatter list (`a, b`) — undefined when absent or empty. */
|
|
@@ -76,33 +414,57 @@ function parseList(raw: string | undefined): string[] | undefined {
|
|
|
76
414
|
return items.length > 0 ? items : undefined;
|
|
77
415
|
}
|
|
78
416
|
|
|
417
|
+
type PermissionKey = 'permission' | 'permissionMode' | 'sandbox';
|
|
418
|
+
const PERMISSION_KEYS = new Map<string, PermissionKey>([
|
|
419
|
+
['permission', 'permission'],
|
|
420
|
+
['permissionmode', 'permissionMode'],
|
|
421
|
+
['sandbox', 'sandbox'],
|
|
422
|
+
]);
|
|
423
|
+
|
|
79
424
|
/** Parse a template file: frontmatter (---\nkey: value\n---) + markdown body. */
|
|
80
425
|
export function parseTemplate(text: string): DelegateTemplate | null {
|
|
81
426
|
const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text.trimStart());
|
|
82
427
|
if (!m) return null;
|
|
83
428
|
|
|
84
429
|
const meta: Record<string, string> = {};
|
|
430
|
+
// The keys that decide the tier are matched case-insensitively and keep every occurrence: an
|
|
431
|
+
// exact-case lookup let `Sandbox: read-only` be silently ignored (→ the `edit` default, verify on).
|
|
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: [] };
|
|
85
434
|
for (const line of m[1].split('\n')) {
|
|
86
435
|
const i = line.indexOf(':');
|
|
87
436
|
if (i <= 0) continue;
|
|
88
|
-
|
|
437
|
+
const key = line.slice(0, i).trim();
|
|
438
|
+
const value = line.slice(i + 1).trim();
|
|
439
|
+
const permKey = PERMISSION_KEYS.get(key.toLowerCase());
|
|
440
|
+
if (permKey) perm[permKey].push({ key, value });
|
|
441
|
+
else meta[key] = value;
|
|
89
442
|
}
|
|
90
443
|
|
|
91
444
|
const name = meta.name?.trim();
|
|
92
445
|
if (!name) return null;
|
|
93
446
|
|
|
94
|
-
const
|
|
95
|
-
const permModeRaw = meta.permissionMode?.trim() ?? meta.sandbox?.trim();
|
|
96
|
-
const norm = normalizePermission(permRaw, permModeRaw);
|
|
447
|
+
const norm = normalizePermission(perm.permission, perm.permissionMode, perm.sandbox);
|
|
97
448
|
|
|
98
449
|
const budget = meta.maxBudgetUsd ? Number(meta.maxBudgetUsd) : NaN;
|
|
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);
|
|
99
456
|
|
|
100
457
|
return {
|
|
101
458
|
name,
|
|
102
|
-
|
|
459
|
+
// A permission-key problem is surfaced where the template is listed (`/delegate list`).
|
|
460
|
+
description: norm.permissionWarning
|
|
461
|
+
? `⚠ ${norm.permissionWarning}${description ? ` · ${description}` : ''}`
|
|
462
|
+
: description,
|
|
103
463
|
permission: norm.permission,
|
|
104
464
|
nativePermission: norm.nativePermission,
|
|
105
465
|
permissionMode: norm.permissionMode,
|
|
466
|
+
permissionWarning: norm.permissionWarning,
|
|
467
|
+
ignoredLegacyPermission: norm.ignoredLegacyPermission,
|
|
106
468
|
model: meta.model || undefined,
|
|
107
469
|
maxBudgetUsd: Number.isFinite(budget) && budget > 0 ? budget : undefined,
|
|
108
470
|
skill: meta.skill || undefined,
|
|
@@ -112,16 +474,19 @@ export function parseTemplate(text: string): DelegateTemplate | null {
|
|
|
112
474
|
addDirs: parseList(meta.addDirs),
|
|
113
475
|
prompt: m[2].trim(),
|
|
114
476
|
harness: meta.harness || undefined,
|
|
477
|
+
timeoutSec: timeout.timeoutSec,
|
|
478
|
+
harnesses: harnesses.harnesses,
|
|
479
|
+
fieldWarnings: fieldWarnings.length > 0 ? fieldWarnings : undefined,
|
|
115
480
|
};
|
|
116
481
|
}
|
|
117
482
|
|
|
118
|
-
function loadDir(dir: string, out: Map<string, DelegateTemplate
|
|
483
|
+
function loadDir(dir: string, out: Map<string, DelegateTemplate>, source: TemplateSource): void {
|
|
119
484
|
if (!existsSync(dir)) return;
|
|
120
485
|
for (const f of readdirSync(dir)) {
|
|
121
486
|
if (!f.endsWith('.md')) continue;
|
|
122
487
|
try {
|
|
123
488
|
const t = parseTemplate(readFileSync(join(dir, f), 'utf8'));
|
|
124
|
-
if (t) out.set(t.name, t);
|
|
489
|
+
if (t) out.set(t.name, { ...t, source });
|
|
125
490
|
} catch {
|
|
126
491
|
// skip unreadable files
|
|
127
492
|
}
|
|
@@ -174,20 +539,20 @@ export function loadTemplates(cwd: string, harnessName?: string, trusted = false
|
|
|
174
539
|
const out = new Map<string, DelegateTemplate>();
|
|
175
540
|
const harness = harnessName ?? 'claude';
|
|
176
541
|
// legacy root builtins (templates/*.md) lowest — for migration from pi-claude-delegate
|
|
177
|
-
loadDir(builtinTemplatesDir(), out);
|
|
542
|
+
loadDir(builtinTemplatesDir(), out, 'builtin');
|
|
178
543
|
// shared canonical bodies
|
|
179
|
-
loadDir(sharedTemplatesDir(), out);
|
|
544
|
+
loadDir(sharedTemplatesDir(), out, 'builtin');
|
|
180
545
|
// harness-specific builtins override shared
|
|
181
|
-
loadDir(builtinHarnessTemplatesDir(harness), out);
|
|
546
|
+
loadDir(builtinHarnessTemplatesDir(harness), out, 'builtin');
|
|
182
547
|
// user globals: legacy before new so new wins
|
|
183
|
-
loadDir(legacyUserTemplatesDir(), out);
|
|
184
|
-
loadDir(userTemplatesDir(), out);
|
|
185
|
-
loadDir(userTemplatesDir(harness), out);
|
|
548
|
+
loadDir(legacyUserTemplatesDir(), out, 'user');
|
|
549
|
+
loadDir(userTemplatesDir(), out, 'user');
|
|
550
|
+
loadDir(userTemplatesDir(harness), out, 'user');
|
|
186
551
|
// project locals: legacy before new so new wins — only if trusted
|
|
187
552
|
if (trusted) {
|
|
188
|
-
loadDir(legacyProjectTemplatesDir(cwd), out);
|
|
189
|
-
loadDir(projectTemplatesDir(cwd), out);
|
|
190
|
-
loadDir(projectTemplatesDir(cwd, harness), out);
|
|
553
|
+
loadDir(legacyProjectTemplatesDir(cwd), out, 'project');
|
|
554
|
+
loadDir(projectTemplatesDir(cwd), out, 'project');
|
|
555
|
+
loadDir(projectTemplatesDir(cwd, harness), out, 'project');
|
|
191
556
|
}
|
|
192
557
|
return out;
|
|
193
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",
|