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.
@@ -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 { ALIASES, detectAll, getHarness, HARNESS_NAMES, isKnownHarness } from './harnesses/registry.ts';
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 { type DelegateTemplate, loadTemplates } from './templates.ts';
22
- export function formatTemplateRow(t: DelegateTemplate): string {
23
- const parts = [
24
- t.name,
25
- `[${t.permission}]`,
26
- t.model ? `model=${t.model}` : '',
27
- t.defaultTask ? '↳ default task' : '',
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
- // collect from all harnesses if no filter
37
- if (harnessFilter) {
38
- for (const [k, v] of loadTemplates(ctx.cwd, harnessFilter, trusted)) all.set(k, v);
39
- } else {
40
- for (const h of [...HARNESS_NAMES, 'shared']) {
41
- for (const [k, v] of loadTemplates(ctx.cwd, h, trusted)) if (!all.has(k)) all.set(k, v);
42
- }
43
- // also load without harness param
44
- for (const [k, v] of loadTemplates(ctx.cwd, undefined, trusted)) if (!all.has(k)) all.set(k, v);
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;
@@ -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 value echoed back in a warning (`/delegate list`, run-time notes): JSON-quoted so
67
- * control characters / ANSI escapes are inert, and capped so a huge value can't flood the line.
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, 60);
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
- ...values(permissionMode).map(v => ['permissionMode', v] as [string, string]),
103
- ...values(sandbox).map(v => ['sandbox', v] as [string, string]),
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 (typeof v === 'string' ? [v] : (v ?? [])).map(x => x.trim()).filter(Boolean);
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
- if (all.some(r => r.nativePermission))
171
- return {
172
- permission: 'readonly',
173
- permissionMode: 'plan',
174
- permissionWarning: `conflicting permission: values ${listed} — loaded as readonly (fail closed)`,
175
- };
176
- const least = all.reduce((a, b) => (TIER_RANK[b.permission] < TIER_RANK[a.permission] ? b : a));
177
- return {
178
- ...least,
179
- permissionWarning: `conflicting permission: values ${listed} — using the least permissive, ${least.permission}`,
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. Skipped for a
187
- // native value: its tier is only known per harness at run time, so "disagrees" isn't decidable here.
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 (resolved.nativePermission || legacy.length === 0) return resolved;
190
- const l = normalizeLegacyPermission(fallbackMode, sandbox);
191
- if (!l.permissionWarning && l.permission === resolved.permission) return resolved;
192
- const ignored = legacy.map(([k, v]) => `${k}: ${quoteValue(v)}`).join(', ');
193
- return { ...resolved, permissionWarning: `permission: ${resolved.permission} overrides ${ignored} (ignored)` };
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
- const perm: Record<PermissionKey, string[]> = { permission: [], permissionMode: [], sandbox: [] };
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>): void {
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.7.3",
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",