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.
@@ -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: string | undefined,
41
- fallbackMode: string | undefined,
42
- ): { permission: NormalizedPermission; nativePermission?: string; permissionMode: PermissionMode } {
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
- if (raw) {
45
- const lower = raw.trim().toLowerCase();
46
- if (lower === 'readonly' || lower === 'read-only' || lower === 'read_only')
47
- return { permission: 'readonly', permissionMode: 'plan' };
48
- if (lower === 'edit' || lower === 'acceptedits' || lower === 'accept-edits')
49
- return { permission: 'edit', permissionMode: 'acceptEdits' };
50
- if (
51
- lower === 'danger' ||
52
- lower === 'bypasspermissions' ||
53
- lower === 'danger-full-access' ||
54
- lower === 'danger_full_access'
55
- )
56
- return { permission: 'danger', permissionMode: 'bypassPermissions' };
57
- // Unknown native — treat as native escape hatch
58
- return { permission: 'edit', nativePermission: raw.trim(), permissionMode: 'acceptEdits' };
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
- // Legacy permissionMode mapping
61
- if (fallbackMode && PERMISSION_MODES.has(fallbackMode as PermissionMode)) {
62
- const m = fallbackMode as PermissionMode;
63
- if (m === 'plan') return { permission: 'readonly', permissionMode: m };
64
- if (m === 'bypassPermissions') return { permission: 'danger', permissionMode: m };
65
- return { permission: 'edit', permissionMode: m };
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
- return { permission: 'edit', permissionMode: 'acceptEdits' };
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
- meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
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 permRaw = meta.permission?.trim();
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
- description: meta.description ?? '',
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>): void {
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.7.2",
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",