pi-harness-delegate 0.7.1 → 0.7.3

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.
@@ -23,6 +23,12 @@ export interface DelegateTemplate {
23
23
  nativePermission?: string;
24
24
  /** Legacy raw permissionMode for transcript compat. */
25
25
  permissionMode: PermissionMode;
26
+ /**
27
+ * A problem with the template's permission keys, shown in `/delegate list` and at run time: an
28
+ * unrecognized legacy `permissionMode:`/`sandbox:` value (failed closed to `readonly`), or a
29
+ * legacy key that disagrees with `permission:` and was ignored (tier unchanged).
30
+ */
31
+ permissionWarning?: string;
26
32
  model?: string;
27
33
  maxBudgetUsd?: number;
28
34
  skill?: string;
@@ -36,35 +42,180 @@ export interface DelegateTemplate {
36
42
  harness?: string;
37
43
  }
38
44
 
45
+ /** Tier order, least permissive first — used to pick the safer of two conflicting legacy keys. */
46
+ const TIER_RANK: Record<NormalizedPermission, number> = { readonly: 0, edit: 1, danger: 2 };
47
+
48
+ const TIER_PERMISSION_MODE: Record<NormalizedPermission, PermissionMode> = {
49
+ readonly: 'plan',
50
+ edit: 'acceptEdits',
51
+ danger: 'bypassPermissions',
52
+ };
53
+
54
+ /** Claude `PermissionMode` names, matched case- and `-`/`_`-insensitively (`accept-edits` → `acceptEdits`). */
55
+ const PERMISSION_MODES_BY_LOWER = new Map<string, PermissionMode>([...PERMISSION_MODES].map(m => [m.toLowerCase(), m]));
56
+
57
+ /** Codex `--sandbox` values (the legacy `sandbox:` key) → normalized tier. Keys are lowercase, `_`→`-`. */
58
+ const LEGACY_SANDBOX_TIERS: Record<string, NormalizedPermission> = {
59
+ 'read-only': 'readonly',
60
+ readonly: 'readonly',
61
+ 'workspace-write': 'edit',
62
+ 'danger-full-access': 'danger',
63
+ };
64
+
65
+ /**
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.
68
+ */
69
+ function quoteValue(value: string): string {
70
+ return JSON.stringify(value).slice(0, 60);
71
+ }
72
+
73
+ interface LegacyPermission {
74
+ permission: NormalizedPermission;
75
+ permissionMode: PermissionMode;
76
+ /** Set when a legacy value was unrecognized and the template was failed closed to `readonly`. */
77
+ permissionWarning?: string;
78
+ }
79
+
80
+ /**
81
+ * Map one legacy `permissionMode:`/`sandbox:` value onto a normalized tier. Both keys accept both
82
+ * vocabularies (claude PermissionMode names and codex sandbox values), case/whitespace/`-`/`_`-insensitive.
83
+ * Returns `null` for an unrecognized value — the caller fails that closed.
84
+ */
85
+ function classifyLegacyValue(value: string): Omit<LegacyPermission, 'permissionWarning'> | null {
86
+ const lower = value.trim().toLowerCase();
87
+ // Claude names have no separators of their own, so `accept-edits`/`bypass_permissions` can only
88
+ // ever mean that same name (and tier) — danger stays gated behind allowDangerous downstream.
89
+ const mode = PERMISSION_MODES_BY_LOWER.get(lower.replace(/[-_]/g, ''));
90
+ if (mode) {
91
+ if (mode === 'plan') return { permission: 'readonly', permissionMode: mode };
92
+ if (mode === 'bypassPermissions') return { permission: 'danger', permissionMode: mode };
93
+ return { permission: 'edit', permissionMode: mode };
94
+ }
95
+ const tier = LEGACY_SANDBOX_TIERS[lower.replace(/_/g, '-')];
96
+ return tier ? { permission: tier, permissionMode: TIER_PERMISSION_MODE[tier] } : null;
97
+ }
98
+
99
+ /** The legacy keys actually set (non-empty), as `[key, value]` pairs. */
100
+ 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
+ ];
105
+ }
106
+
107
+ /**
108
+ * A permission key's value(s): the keys are read case-insensitively and every occurrence is kept
109
+ * (`sandbox:` + `Sandbox:` is two values), so callers take an array; a single string still works.
110
+ */
111
+ type FrontmatterValues = string | readonly string[] | undefined;
112
+
113
+ /** Non-empty trimmed values — an empty value means "not set". */
114
+ function values(v: FrontmatterValues): string[] {
115
+ return (typeof v === 'string' ? [v] : (v ?? [])).map(x => x.trim()).filter(Boolean);
116
+ }
117
+
118
+ /**
119
+ * Legacy `permissionMode:`/`sandbox:` keys → normalized tier. Only consulted when `permission:` is
120
+ * absent. An empty/absent value means "not set" (default `edit`, as always).
121
+ *
122
+ * **Fails closed:** an unrecognized value (a typo, a value from some other harness's vocabulary)
123
+ * yields `readonly` plus `permissionWarning` — never the old silent `edit`, and never a refusal
124
+ * to load. Not loading would let a same-named lower tier (e.g. the builtin `implement`, `edit`)
125
+ * silently win instead, which can be MORE permissive than an author who wrote `sandbox: readonyl`
126
+ * meant; `readonly` is the one tier that can never exceed what any author meant (and it also skips
127
+ * `verify:`). When both keys (or several case variants of one) are set, the least permissive wins.
128
+ */
129
+ export function normalizeLegacyPermission(
130
+ permissionMode: FrontmatterValues,
131
+ sandbox: FrontmatterValues,
132
+ ): LegacyPermission {
133
+ const set = legacyEntries(permissionMode, sandbox);
134
+ if (set.length === 0) return { permission: 'edit', permissionMode: 'acceptEdits' };
135
+ let best: Omit<LegacyPermission, 'permissionWarning'> | null = null;
136
+ for (const [key, value] of set) {
137
+ const c = classifyLegacyValue(value);
138
+ if (!c) {
139
+ return {
140
+ permission: 'readonly',
141
+ permissionMode: 'plan',
142
+ permissionWarning: `unrecognized ${key}: ${quoteValue(value)} — loaded as readonly (fail closed)`,
143
+ };
144
+ }
145
+ if (!best || TIER_RANK[c.permission] < TIER_RANK[best.permission]) best = c;
146
+ }
147
+ return best as LegacyPermission;
148
+ }
149
+
39
150
  export function normalizePermission(
40
- raw: string | undefined,
41
- fallbackMode: string | undefined,
42
- ): { permission: NormalizedPermission; nativePermission?: string; permissionMode: PermissionMode } {
151
+ raw: FrontmatterValues,
152
+ fallbackMode: FrontmatterValues,
153
+ sandbox?: FrontmatterValues,
154
+ ): {
155
+ permission: NormalizedPermission;
156
+ nativePermission?: string;
157
+ permissionMode: PermissionMode;
158
+ permissionWarning?: string;
159
+ } {
43
160
  // 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' };
161
+ const raws = values(raw);
162
+ if (raws.length > 1) {
163
+ // `permission:` given more than once (e.g. `permission:` and `Permission:`) — the same tier is
164
+ // fine; disagreeing tiers take the least permissive, and a disagreement involving a native value
165
+ // (whose tier is per-harness, so not comparable here) fails closed to readonly. Never "last wins".
166
+ const all = raws.map(normalizeTierValue);
167
+ const distinct = new Set(all.map(r => `${r.permission}|${r.nativePermission ?? ''}`));
168
+ 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
+ };
181
+ }
59
182
  }
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 };
183
+ if (raws.length > 0) {
184
+ const resolved = normalizeTierValue(raws[0]);
185
+ // `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.
188
+ 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)` };
66
194
  }
67
- return { permission: 'edit', permissionMode: 'acceptEdits' };
195
+ // Legacy permissionMode/sandbox mapping — fails closed to readonly on an unrecognized value
196
+ return normalizeLegacyPermission(fallbackMode, sandbox);
197
+ }
198
+
199
+ /** One `permission:` value → tier, or (unknown value) the native escape hatch. */
200
+ function normalizeTierValue(raw: string): {
201
+ permission: NormalizedPermission;
202
+ nativePermission?: string;
203
+ permissionMode: PermissionMode;
204
+ } {
205
+ const lower = raw.trim().toLowerCase();
206
+ if (lower === 'readonly' || lower === 'read-only' || lower === 'read_only')
207
+ return { permission: 'readonly', permissionMode: 'plan' };
208
+ if (lower === 'edit' || lower === 'acceptedits' || lower === 'accept-edits')
209
+ return { permission: 'edit', permissionMode: 'acceptEdits' };
210
+ if (
211
+ lower === 'danger' ||
212
+ lower === 'bypasspermissions' ||
213
+ lower === 'danger-full-access' ||
214
+ lower === 'danger_full_access'
215
+ )
216
+ return { permission: 'danger', permissionMode: 'bypassPermissions' };
217
+ // Unknown native — treat as native escape hatch
218
+ return { permission: 'edit', nativePermission: raw.trim(), permissionMode: 'acceptEdits' };
68
219
  }
69
220
 
70
221
  /** Comma-separated frontmatter list (`a, b`) — undefined when absent or empty. */
@@ -76,33 +227,50 @@ function parseList(raw: string | undefined): string[] | undefined {
76
227
  return items.length > 0 ? items : undefined;
77
228
  }
78
229
 
230
+ type PermissionKey = 'permission' | 'permissionMode' | 'sandbox';
231
+ const PERMISSION_KEYS = new Map<string, PermissionKey>([
232
+ ['permission', 'permission'],
233
+ ['permissionmode', 'permissionMode'],
234
+ ['sandbox', 'sandbox'],
235
+ ]);
236
+
79
237
  /** Parse a template file: frontmatter (---\nkey: value\n---) + markdown body. */
80
238
  export function parseTemplate(text: string): DelegateTemplate | null {
81
239
  const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text.trimStart());
82
240
  if (!m) return null;
83
241
 
84
242
  const meta: Record<string, string> = {};
243
+ // The keys that decide the tier are matched case-insensitively and keep every occurrence: an
244
+ // exact-case lookup let `Sandbox: read-only` be silently ignored (→ the `edit` default, verify on).
245
+ const perm: Record<PermissionKey, string[]> = { permission: [], permissionMode: [], sandbox: [] };
85
246
  for (const line of m[1].split('\n')) {
86
247
  const i = line.indexOf(':');
87
248
  if (i <= 0) continue;
88
- meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
249
+ const key = line.slice(0, i).trim();
250
+ const value = line.slice(i + 1).trim();
251
+ const permKey = PERMISSION_KEYS.get(key.toLowerCase());
252
+ if (permKey) perm[permKey].push(value);
253
+ else meta[key] = value;
89
254
  }
90
255
 
91
256
  const name = meta.name?.trim();
92
257
  if (!name) return null;
93
258
 
94
- const permRaw = meta.permission?.trim();
95
- const permModeRaw = meta.permissionMode?.trim() ?? meta.sandbox?.trim();
96
- const norm = normalizePermission(permRaw, permModeRaw);
259
+ const norm = normalizePermission(perm.permission, perm.permissionMode, perm.sandbox);
97
260
 
98
261
  const budget = meta.maxBudgetUsd ? Number(meta.maxBudgetUsd) : NaN;
262
+ const description = meta.description ?? '';
99
263
 
100
264
  return {
101
265
  name,
102
- description: meta.description ?? '',
266
+ // A permission-key problem is surfaced where the template is listed (`/delegate list`).
267
+ description: norm.permissionWarning
268
+ ? `⚠ ${norm.permissionWarning}${description ? ` · ${description}` : ''}`
269
+ : description,
103
270
  permission: norm.permission,
104
271
  nativePermission: norm.nativePermission,
105
272
  permissionMode: norm.permissionMode,
273
+ permissionWarning: norm.permissionWarning,
106
274
  model: meta.model || undefined,
107
275
  maxBudgetUsd: Number.isFinite(budget) && budget > 0 ? budget : undefined,
108
276
  skill: meta.skill || undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-harness-delegate",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
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",
@@ -40,6 +40,7 @@
40
40
  "scripts": {
41
41
  "typecheck": "tsc --noEmit",
42
42
  "test": "bun test",
43
+ "test:coverage": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov",
43
44
  "lint": "biome check .",
44
45
  "lint:fix": "biome check --write .",
45
46
  "verify": "bun run lint && bun run typecheck && bun run test",