@mgiles/perk 1.1.0 → 2.0.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.
Files changed (93) hide show
  1. package/README.md +68 -44
  2. package/extension/adapters/planAdapterPlannotator.ts +27 -41
  3. package/extension/adapters/planAdapterTombell.ts +15 -28
  4. package/extension/adapters/todoAdapterJuicesharp.ts +10 -13
  5. package/extension/checkpoints/checkpoints.ts +19 -12
  6. package/extension/doors/address.ts +4 -4
  7. package/extension/doors/askUser.ts +12 -8
  8. package/extension/doors/ciExecutor.ts +21 -14
  9. package/extension/doors/hunkHandoff.ts +202 -0
  10. package/extension/doors/land.ts +31 -9
  11. package/extension/doors/learn.ts +2 -2
  12. package/extension/doors/learnFactory.ts +144 -0
  13. package/extension/doors/plannotatorHandoff.ts +509 -0
  14. package/extension/doors/prReview.ts +4 -4
  15. package/extension/doors/prReviewBrowser.ts +341 -0
  16. package/extension/doors/prReviewTerminal.ts +267 -0
  17. package/extension/doors/selfcheck.ts +238 -5
  18. package/extension/doors/submit.ts +20 -0
  19. package/extension/doors/submitPrReview.ts +408 -0
  20. package/extension/factories/objective.ts +15 -5
  21. package/extension/factories/objectiveAuthor.ts +15 -32
  22. package/extension/factories/objectiveDraft.ts +1 -1
  23. package/extension/factories/objectivePlan.ts +12 -10
  24. package/extension/factories/objectiveSave.ts +2 -2
  25. package/extension/factories/planMode.ts +22 -40
  26. package/extension/factories/planReview.ts +213 -191
  27. package/extension/factories/planSave.ts +7 -7
  28. package/extension/index.ts +83 -25
  29. package/extension/substrate/bindingDelivery.ts +32 -10
  30. package/extension/substrate/bindings.ts +4 -2
  31. package/extension/substrate/cache.ts +34 -7
  32. package/extension/substrate/clipboard.ts +81 -0
  33. package/extension/substrate/config.ts +88 -65
  34. package/extension/substrate/git.ts +43 -0
  35. package/extension/substrate/paths.ts +1 -1
  36. package/extension/substrate/prompts.ts +2 -2
  37. package/extension/substrate/providers.ts +62 -8
  38. package/extension/substrate/sessionPointers.ts +35 -6
  39. package/extension/substrate/structuredOutput.ts +3 -1
  40. package/extension/substrate/terminalLaunch.ts +178 -0
  41. package/extension/substrate/toolGating.ts +330 -79
  42. package/extension/substrate/toolParams.ts +7 -0
  43. package/extension/substrate/workflowState.ts +54 -2
  44. package/extension/surfaces/footerProvider.ts +8 -4
  45. package/extension/surfaces/surfaces.ts +330 -12
  46. package/extension/vendor/btw/btw.ts +10 -0
  47. package/extension/worker/readOnlySession.ts +19 -6
  48. package/extension/worker/worker.ts +77 -7
  49. package/extension/workerMain.ts +12 -13
  50. package/package.json +3 -3
  51. package/prompts/_fixtures/live.yaml +117 -2
  52. package/prompts/contexts/adapters/juicesharp-todo.md +7 -0
  53. package/prompts/contexts/adapters/plannotator-objective.md +7 -0
  54. package/prompts/contexts/adapters/plannotator-plan.md +6 -0
  55. package/prompts/contexts/adapters/tombell-plan.md +17 -0
  56. package/prompts/contexts/objective-authoring.md +20 -0
  57. package/prompts/contexts/plan-authoring.md +24 -0
  58. package/prompts/contexts/read-only.md +10 -0
  59. package/prompts/stages/conflict-resolution.md +1 -1
  60. package/prompts/stages/learn-code.md +1 -1
  61. package/prompts/stages/learn-docs.md +2 -2
  62. package/prompts/stages/learn-orchestrate.md +1 -1
  63. package/prompts/stages/objective-author/adopt.md +1 -1
  64. package/prompts/stages/objective-author/file.md +1 -1
  65. package/prompts/stages/objective-plan/guidance.md +1 -1
  66. package/prompts/stages/objective-plan/seed.md +1 -1
  67. package/prompts/stages/objective-reconcile.md +1 -1
  68. package/prompts/stages/objective-replan.md +1 -1
  69. package/prompts/stages/plan-from/adopt.md +2 -2
  70. package/prompts/stages/plan-from/file.md +2 -2
  71. package/prompts/stages/pr-review-browser/active.md +11 -0
  72. package/prompts/stages/pr-review-browser/foreign.md +11 -0
  73. package/prompts/stages/pr-review-terminal/active.md +12 -0
  74. package/prompts/stages/pr-review-terminal/foreign.md +13 -0
  75. package/prompts/stages/pr-review-terminal/local.md +4 -0
  76. package/prompts/stages/pr-review.md +1 -1
  77. package/prompts/stages/replan.md +2 -2
  78. package/prompts/stages/skills/create-from.md +1 -1
  79. package/prompts/stages/skills/create.md +1 -1
  80. package/prompts/stages/skills/refine.md +1 -1
  81. package/shared/README.md +22 -18
  82. package/shared/bindings.yaml +10 -2
  83. package/shared/contracts-history.md +24 -0
  84. package/shared/contracts.md +1442 -1787
  85. package/shared/providers.yaml +8 -1
  86. package/shared/registry.yaml +7 -8
  87. package/shared/schemas/inputs/review-submit-batch.schema.json +66 -0
  88. package/shared/schemas/outputs/pr-review-checkout.schema.json +69 -0
  89. package/shared/schemas/outputs/pr-review-cleanup.schema.json +54 -0
  90. package/shared/schemas/outputs/pr-review-submit.schema.json +64 -0
  91. package/extension/doors/learnCode.ts +0 -100
  92. package/extension/doors/learnDocs.ts +0 -100
  93. package/extension/doors/prReviewLocal.ts +0 -229
@@ -4,8 +4,9 @@
4
4
  // `perk:plan-context` injection (extension/factories/planMode.ts) when present.
5
5
  //
6
6
  // Deliberately dependency-free: rather than pull a runtime TOML dependency into the published
7
- // extension for a single optional string, this reads the narrow TOML subset perk actually uses —
8
- // `[section]` headers + `key = "basic"` / `key = """multiline"""` string values + `#` comments.
7
+ // extension, this reads the narrow TOML subset perk actually uses — `[section]` headers +
8
+ // `[[name]]` array-of-tables + `key = "basic"` / `key = """multiline"""` strings + native
9
+ // booleans/numbers + `#` comments.
9
10
  // Read-only, LBYL: a missing/unreadable file is `{}`; anything outside the subset is ignored.
10
11
  // Dynamic `resources_discover` skill/prompt contribution is a flagged follow-up, not built here.
11
12
 
@@ -14,7 +15,13 @@ import { parseUserBindings, type SkillBinding } from "./bindings.ts";
14
15
  import { configFile, localConfigFile } from "./paths.ts";
15
16
 
16
17
  /**
17
- * One configured CI check (a `[[ci]]` array-of-tables row). `name`/`command` are required
18
+ * A TOML scalar the subset parser reads: quoted strings plus native booleans and numbers.
19
+ * Anything else (dates, arrays, inline tables) is still deliberately ignored.
20
+ */
21
+ export type TomlScalar = string | boolean | number;
22
+
23
+ /**
24
+ * One configured CI check (a `[[ci.checks]]` array-of-tables row). `name`/`command` are required
18
25
  * non-blank strings; an optional `glob` (a single comma-separated pattern string, e.g.
19
26
  * `"*.ts,*.tsx"`) declares which changed files the check is relevant to — the read-only CI
20
27
  * executor skips it on the run-all path when no changed file (vs trunk) matches.
@@ -29,19 +36,26 @@ export interface PerkConfig {
29
36
  /** Optional project-supplied plan-authoring addendum (`[workflow] plan_authoring = "..."`). */
30
37
  planAuthoring?: string;
31
38
  /**
32
- * The `[[ci]]` checks (an ordered array-of-tables, each row name/command/optional glob); the
33
- * read-only CI executor consumes it. Always-present ordered array (mirror of
34
- * `bindings`/`providers`); absent/empty `[]`.
39
+ * The `[ci]` verification namespace. `checks` is the `[[ci.checks]]` ordered array-of-tables
40
+ * (each row name/command/optional glob) the read-only CI executor consumes; absent/empty `[]`.
41
+ * `trusted` (`[ci] trusted = true`, a native boolean) declares those project-supplied checks
42
+ * trusted, so the executor runs them WITHOUT a per-session confirm on every surface, including
43
+ * headless (it overrides the fail-closed refuse). Absent/`false`/non-boolean ⇒ untrusted
44
+ * (confirm with UI; refuse headless). Always present; defaults `{trusted: false, checks: []}`.
35
45
  */
36
- ci: CiCheck[];
46
+ ci: { trusted: boolean; checks: CiCheck[] };
37
47
  /**
38
- * The agent-keyed `[subagents]` table: a per-agent model override for each perk-owned
48
+ * The agent-keyed `[models.subagents]` table: a per-agent model override for each perk-owned
39
49
  * project agent (`pr-reviewer`, `review-classifier`, `objective-explorer`, `conflict-resolver`,
40
- * `learn-analyst`). Each configured
50
+ * `learn-analyst`, `adversarial-reviewer`). Each configured
41
51
  * value is injected as a per-call inline `model` override on that agent's `subagent` spawn; when
42
52
  * a key is absent the agent's frontmatter `model` (in `.pi/agents/<name>.md`) is the default.
43
53
  * (`subagents.agentOverrides` does NOT reach project agents — `pi-subagents`'
44
54
  * `applyBuiltinOverrides` applies only to builtins — so this inline override is the mechanism.)
55
+ * A value may carry a `:thinking` suffix (`"anthropic/claude-sonnet-4-5:high"`) or be the
56
+ * `"inherit"` sentinel (child inherits the parent session's model) — both resolved by
57
+ * pi-subagents on the inline override (the last-colon segment counts as thinking only when it
58
+ * is a pi level, so ollama-style tags stay part of the model id).
45
59
  * Always-present object; absent keys omitted (mirror of `providers`).
46
60
  */
47
61
  subagents: {
@@ -50,11 +64,13 @@ export interface PerkConfig {
50
64
  "objective-explorer"?: string;
51
65
  "conflict-resolver"?: string;
52
66
  "learn-analyst"?: string;
67
+ "adversarial-reviewer"?: string;
53
68
  };
54
69
  /**
55
- * Optional `[objective] compact_threshold` — the context-usage fraction (0,1] that triggers
56
- * threshold compaction while an objective is active. Because the TOML subset reads only
57
- * string values, it must be written as a quoted string (e.g. `compact_threshold = "0.8"`).
70
+ * Optional `[compaction] objective_threshold` — the context-usage fraction (0,1] that triggers
71
+ * threshold compaction while an objective is active. A native TOML float (e.g.
72
+ * `objective_threshold = 0.8`); string/out-of-range values are ignored. The Python plane
73
+ * deliberately ignores this key (it converges the rest of `[compaction]` into settings).
58
74
  */
59
75
  objectiveCompactThreshold?: number;
60
76
  /** The `[[bindings]]` user overlay, resolved against shipped defaults downstream. */
@@ -64,28 +80,25 @@ export interface PerkConfig {
64
80
  * `shared/providers.yaml`. Absent keys mean “use the seam default”; resolution against the
65
81
  * supported set is a downstream concern.
66
82
  */
67
- providers: { plan?: string; todo?: string; askuser?: string; footer?: string; web?: string };
68
- /**
69
- * The `[trust]` per-repo trust table. `trust.ci === true` (written `ci = "true"` — the subset
70
- * parser reads strings only) declares the project's `[ci]` checks trusted, so the read-only CI
71
- * executor runs them WITHOUT a per-session confirm on every surface, including headless
72
- * (it overrides the fail-closed refuse). Absent/"false" ⇒ unchanged (confirm with UI; refuse
73
- * headless). Always-present object; absent keys omitted (mirror of `providers`). The table may
74
- * grow further trust keys later.
75
- */
76
- trust: { ci?: boolean };
83
+ providers: {
84
+ plan?: string;
85
+ todo?: string;
86
+ askuser?: string;
87
+ footer?: string;
88
+ web?: string;
89
+ };
77
90
  }
78
91
 
79
- /** A nested string table: `{ section: { key: value } }` (the only shape perk reads today). */
80
- type StringTable = Record<string, Record<string, string>>;
92
+ /** A nested scalar table: `{ section: { key: scalar } }` (dotted section names kept literal). */
93
+ type ScalarTable = Record<string, Record<string, TomlScalar>>;
81
94
 
82
95
  /**
83
- * The narrow TOML subset perk reads: `[section]`/top-level string tables plus `[[name]]`
84
- * array-of-tables (each row a string table). Mirrors `tomllib`'s shape for the keys perk uses.
96
+ * The narrow TOML subset perk reads: `[section]`/top-level scalar tables plus `[[name]]`
97
+ * array-of-tables (each row a scalar table). Mirrors `tomllib`'s shape for the keys perk uses.
85
98
  */
86
99
  interface TomlSubset {
87
- tables: StringTable;
88
- arrays: Record<string, Array<Record<string, string>>>;
100
+ tables: ScalarTable;
101
+ arrays: Record<string, Array<Record<string, TomlScalar>>>;
89
102
  }
90
103
 
91
104
  function unescapeBasic(raw: string): string {
@@ -98,16 +111,17 @@ function unescapeBasic(raw: string): string {
98
111
 
99
112
  /**
100
113
  * Parse the narrow TOML subset perk consumes. Returns `{ tables, arrays }`: `tables` is a
101
- * `{ section: { key: stringValue } }` map (top-level keys under the `""` section); `arrays` is a
102
- * `{ name: [{ key: stringValue }, ...] }` map fed by `[[name]]` array-of-tables. Non-string values
103
- * and unknown syntax are skipped this is intentionally NOT a full TOML parser.
114
+ * `{ section: { key: scalar } }` map (top-level keys under the `""` section); `arrays` is a
115
+ * `{ name: [{ key: scalar }, ...] }` map fed by `[[name]]` array-of-tables. Scalars are quoted
116
+ * strings, native `true`/`false` booleans, and numeric literals; anything else is skipped
117
+ * this is intentionally NOT a full TOML parser.
104
118
  */
105
119
  export function parseTomlSubset(text: string): TomlSubset {
106
- const root: Record<string, string> = {};
107
- const tables: StringTable = { "": root };
108
- const arrays: Record<string, Array<Record<string, string>>> = {};
120
+ const root: Record<string, TomlScalar> = {};
121
+ const tables: ScalarTable = { "": root };
122
+ const arrays: Record<string, Array<Record<string, TomlScalar>>> = {};
109
123
  // The current write target for `key = value` lines (a section table or an array-of-tables row).
110
- let dest: Record<string, string> = root;
124
+ let dest: Record<string, TomlScalar> = root;
111
125
  const lines = text.split(/\r?\n/);
112
126
  for (let i = 0; i < lines.length; i++) {
113
127
  const line = (lines[i] ?? "").trim();
@@ -117,7 +131,7 @@ export function parseTomlSubset(text: string): TomlSubset {
117
131
  const arrayHeader = line.match(/^\[\[([^\]]+)\]\]$/);
118
132
  if (arrayHeader) {
119
133
  const name = (arrayHeader[1] ?? "").trim();
120
- const row: Record<string, string> = {};
134
+ const row: Record<string, TomlScalar> = {};
121
135
  let rows = arrays[name];
122
136
  if (!rows) {
123
137
  rows = [];
@@ -173,8 +187,21 @@ export function parseTomlSubset(text: string): TomlSubset {
173
187
  const basic = value.match(/^"((?:[^"\\]|\\.)*)"/);
174
188
  if (basic) {
175
189
  dest[key] = unescapeBasic(basic[1] ?? "");
190
+ continue;
191
+ }
192
+
193
+ // Unquoted scalar: strip an inline `#` comment, then read native booleans and numbers.
194
+ const hash = value.indexOf("#");
195
+ const bare = (hash === -1 ? value : value.slice(0, hash)).trim();
196
+ if (bare === "true" || bare === "false") {
197
+ dest[key] = bare === "true";
198
+ continue;
176
199
  }
177
- // Non-string scalars are intentionally ignored (perk reads only strings today).
200
+ if (/^[+-]?\d[\d_]*(\.[\d_]+)?([eE][+-]?\d+)?$/.test(bare)) {
201
+ const parsed = Number(bare.replace(/_/g, ""));
202
+ if (Number.isFinite(parsed)) dest[key] = parsed;
203
+ }
204
+ // Other value shapes (dates, arrays, inline tables) are intentionally ignored.
178
205
  }
179
206
  return { tables, arrays };
180
207
  }
@@ -199,12 +226,12 @@ function readTomlFile(path: string): TomlSubset {
199
226
  * replace as a whole array (mirror of perk/substrate/config.py's list-replaces-list overlay).
200
227
  */
201
228
  function overlay(base: TomlSubset, over: TomlSubset): TomlSubset {
202
- const tables: StringTable = {};
229
+ const tables: ScalarTable = {};
203
230
  for (const [section, kv] of Object.entries(base.tables)) tables[section] = { ...kv };
204
231
  for (const [section, kv] of Object.entries(over.tables)) {
205
232
  tables[section] = { ...(tables[section] ?? {}), ...kv };
206
233
  }
207
- const arrays: Record<string, Array<Record<string, string>>> = { ...base.arrays };
234
+ const arrays: Record<string, Array<Record<string, TomlScalar>>> = { ...base.arrays };
208
235
  for (const [name, rows] of Object.entries(over.arrays)) arrays[name] = rows;
209
236
  return { tables, arrays };
210
237
  }
@@ -217,30 +244,33 @@ export function loadPerkConfig(cwd: string): PerkConfig {
217
244
  }
218
245
 
219
246
  const planAuthoring = merged.tables.workflow?.plan_authoring;
220
- const rawThreshold = merged.tables.objective?.compact_threshold;
221
- const parsedThreshold = rawThreshold != null ? Number.parseFloat(rawThreshold) : Number.NaN;
247
+ // `[compaction] objective_threshold` is a native float in (0,1]; strings/out-of-range ignored.
248
+ const rawThreshold = merged.tables.compaction?.objective_threshold;
222
249
  const objectiveCompactThreshold =
223
- Number.isFinite(parsedThreshold) && parsedThreshold > 0 && parsedThreshold <= 1
224
- ? parsedThreshold
250
+ typeof rawThreshold === "number" && rawThreshold > 0 && rawThreshold <= 1
251
+ ? rawThreshold
225
252
  : undefined;
226
253
  return {
227
254
  planAuthoring:
228
255
  typeof planAuthoring === "string" && planAuthoring.trim() ? planAuthoring : undefined,
229
- ci: parseCiChecks(merged.arrays.ci ?? []),
230
- subagents: parseSubagentsSelection(merged.tables.subagents),
256
+ ci: {
257
+ trusted: merged.tables.ci?.trusted === true,
258
+ checks: parseCiChecks(merged.arrays["ci.checks"] ?? []),
259
+ },
260
+ subagents: parseSubagentsSelection(merged.tables["models.subagents"]),
231
261
  objectiveCompactThreshold,
232
262
  bindings: parseUserBindings(merged.arrays.bindings ?? []),
233
263
  providers: parseProvidersSelection(merged.tables.providers),
234
- trust: parseTrustSelection(merged.tables.trust),
235
264
  };
236
265
  }
237
266
 
238
267
  /**
239
- * Read the `[[ci]]` array-of-tables into an ordered `CiCheck[]`. A row is kept only when both
240
- * `name` and `command` are non-blank strings; `glob` is kept only when a non-blank string. Declared
241
- * order is preserved; ill-typed rows are silently dropped (mirror of `parseProvidersSelection`).
268
+ * Read the `[[ci.checks]]` array-of-tables into an ordered `CiCheck[]`. A row is kept only when
269
+ * both `name` and `command` are non-blank strings; `glob` is kept only when a non-blank string.
270
+ * Declared order is preserved; ill-typed rows are silently dropped (mirror of
271
+ * `parseProvidersSelection`).
242
272
  */
243
- export function parseCiChecks(rows: Array<Record<string, string>>): CiCheck[] {
273
+ export function parseCiChecks(rows: Array<Record<string, TomlScalar>>): CiCheck[] {
244
274
  const checks: CiCheck[] = [];
245
275
  for (const row of rows) {
246
276
  const name = row.name;
@@ -255,22 +285,23 @@ export function parseCiChecks(rows: Array<Record<string, string>>): CiCheck[] {
255
285
  return checks;
256
286
  }
257
287
 
258
- /** The perk-owned project agents configurable via the `[subagents]` table. */
288
+ /** The perk-owned project agents configurable via the `[models.subagents]` table. */
259
289
  const SUBAGENT_KEYS = [
260
290
  "pr-reviewer",
261
291
  "review-classifier",
262
292
  "objective-explorer",
263
293
  "conflict-resolver",
264
294
  "learn-analyst",
295
+ "adversarial-reviewer",
265
296
  ] as const;
266
297
 
267
298
  /**
268
- * Read the agent-keyed `[subagents]` table into a selection (string values only). For each known
269
- * agent key, the value is kept only when it is a non-blank string; absent/ill-typed/unknown keys
270
- * are omitted (mirror of `parseProvidersSelection`).
299
+ * Read the agent-keyed `[models.subagents]` table into a selection (string values only). For each
300
+ * known agent key, the value is kept only when it is a non-blank string; absent/ill-typed/unknown
301
+ * keys are omitted (mirror of `parseProvidersSelection`).
271
302
  */
272
303
  function parseSubagentsSelection(
273
- table: Record<string, string> | undefined,
304
+ table: Record<string, TomlScalar> | undefined,
274
305
  ): PerkConfig["subagents"] {
275
306
  const selection: PerkConfig["subagents"] = {};
276
307
  for (const key of SUBAGENT_KEYS) {
@@ -280,16 +311,8 @@ function parseSubagentsSelection(
280
311
  return selection;
281
312
  }
282
313
 
283
- /** Read the `[trust]` table into a `{ci?}` selection. `ci` is true only for the string "true". */
284
- function parseTrustSelection(table: Record<string, string> | undefined): { ci?: boolean } {
285
- const selection: { ci?: boolean } = {};
286
- if (typeof table?.ci === "string" && table.ci.trim().toLowerCase() === "true")
287
- selection.ci = true;
288
- return selection;
289
- }
290
-
291
- /** Read the flat `[providers]` table into a `{plan?, todo?, askuser?, footer?, web?}` selection (string values only). */
292
- function parseProvidersSelection(table: Record<string, string> | undefined): {
314
+ /** Read the flat `[providers]` table into a `{plan?, todo?, askuser?, footer?, web?}` selection (string values only). A retired `review` key is silently ignored (the TS fail-safe posture; the Python plane's tripwire is the loud surface). */
315
+ function parseProvidersSelection(table: Record<string, TomlScalar> | undefined): {
293
316
  plan?: string;
294
317
  todo?: string;
295
318
  askuser?: string;
@@ -32,3 +32,46 @@ export function mainCheckoutRoot(cwd: string): string {
32
32
  const common = isAbsolute(out) ? out : resolve(cwd, out);
33
33
  return resolve(common, "..");
34
34
  }
35
+
36
+ /** Run one git command; trimmed stdout, or null on any failure (the module's fail-open style). */
37
+ function git(cwd: string, args: string[], timeout?: number): string | null {
38
+ try {
39
+ const out = execFileSync("git", args, {
40
+ cwd,
41
+ encoding: "utf8",
42
+ stdio: ["ignore", "pipe", "ignore"],
43
+ ...(timeout !== undefined ? { timeout } : {}),
44
+ }).trim();
45
+ return out === "" ? null : out;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /** The bounded best-effort `git fetch` budget (ms) — see `sinceBaseSha` step 2. */
52
+ const FETCH_TIMEOUT_MS = 15_000;
53
+
54
+ /**
55
+ * The since-base merge-base of the working tree: `merge-base(HEAD, origin/<base>)` — the sha the
56
+ * terminal review door diffs the active worktree against. **Fail-open**: null on any failure
57
+ * (not a repo, no such ref, git missing), never throws.
58
+ *
59
+ * 1. Resolve the base branch name: `base` when given; else the repo default via
60
+ * `git symbolic-ref --short refs/remotes/origin/HEAD` (`origin/main` → `main`).
61
+ * 2. Best-effort `git fetch origin <branch>` with a bounded timeout — a failure (offline, no
62
+ * remote) is swallowed and the stale local ref is used, keeping the door usable offline (and
63
+ * the test scaffold network-free).
64
+ * 3. `git merge-base HEAD origin/<branch>` → the full sha.
65
+ */
66
+ export function sinceBaseSha(cwd: string, base: string | null | undefined): string | null {
67
+ let branch = base ?? null;
68
+ if (branch === null) {
69
+ const head = git(cwd, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
70
+ if (head === null) return null;
71
+ // `origin/main` → `main` (keep anything after the first slash — branch names may carry `/`).
72
+ branch = head.includes("/") ? head.slice(head.indexOf("/") + 1) : head;
73
+ }
74
+ if (branch === "") return null;
75
+ git(cwd, ["fetch", "origin", branch], FETCH_TIMEOUT_MS);
76
+ return git(cwd, ["merge-base", "HEAD", `origin/${branch}`]);
77
+ }
@@ -6,7 +6,7 @@
6
6
  // `.pi/perk.toml` migration is Python-side). The workflow family lives in the established cache
7
7
  // seam (substrate/cache.ts's `workflowDir`); together these two modules own every perk-owned
8
8
  // dot-path on this plane.
9
- // Objective #878 migrates each family to `.perk/` one phase at a time — redirecting a family is a
9
+ // Remaining families migrate to `.perk/` one at a time — redirecting a family is a
10
10
  // single edit here. **Pi-native** `.pi/...` paths (`.pi/settings.json`, `.pi/agents/`, `.pi/npm`,
11
11
  // `.pi/APPEND_SYSTEM.md`) are intentionally NOT owned here — `.pi/` is not generally perk-owned.
12
12
  //
@@ -15,8 +15,8 @@
15
15
  // keeps the extension zero-runtime-dep / loadable from a bare git clone (guarded by
16
16
  // extension/bareImportGuard.test.ts).
17
17
  //
18
- // This seam is LIVE in production: render is imported by extension/worker/worker.ts, the
19
- // learn/address/learnDocs/lifecycleGates doors, and extension/factories/objectivePlan.ts.
18
+ // This seam is LIVE in production: render is imported by the worker, the warm doors, the
19
+ // factories, and the mode/adapter context modules (the `prompts/contexts/` injections).
20
20
 
21
21
  import { render as miniJinjaRender } from "./miniJinja.ts";
22
22
 
@@ -2,8 +2,8 @@
2
2
  //
3
3
  // Twin of perk/substrate/providers.py: both planes parse the SAME bundled file (no codegen). This is the
4
4
  // THIRD parsed cross-plane contract (after registry.yaml and bindings.yaml). It is the SUPPORTED
5
- // SET — the catalog of plan/todo/askuser/footer/web providers perk knows how to wire — distinct from
6
- // the per-repo SELECTION (the flat `[providers]` table in .perk/config.toml).
5
+ // SET — the catalog of plan/todo/askuser/footer/web providers perk knows how to wire —
6
+ // distinct from the per-repo SELECTION (the flat `[providers]` table in .perk/config.toml).
7
7
  //
8
8
  // The Python CLI is the authoritative validator (perk/substrate/providers.py); this side does a thin
9
9
  // structural parse only — no deep content validation here. `resolveProviders` is CONSUMED by
@@ -113,6 +113,47 @@ export interface ResolvedProviders {
113
113
  issues: string[];
114
114
  }
115
115
 
116
+ /**
117
+ * The synthesized reference fallback per seam — the provider `resolveProviders` returns when the
118
+ * bundled catalog carries no `default: true` entry for that seam (version skew: a long-lived warm
119
+ * session's in-memory code reading a live-edited `shared/providers.yaml` across a seam
120
+ * add/retire, in either direction). Guarantees PER-SEAM ISOLATION: one seam's catalog gap can
121
+ * never collapse another seam's resolution (the plannotator plan_review no-launch incident — the
122
+ * retired review seam's gap collapsed the plan seam to first-party through a thrown
123
+ * `requireDefault` + a silent caller catch).
124
+ */
125
+ const REFERENCE_FALLBACKS: Record<(typeof PROVIDER_SEAMS)[number], Provider> = {
126
+ plan: { id: PERK_PLAN_PROVIDER_ID, seam: "plan", package: null, adapter: null, default: true },
127
+ todo: {
128
+ id: PERK_CHECKPOINTS_PROVIDER_ID,
129
+ seam: "todo",
130
+ package: null,
131
+ adapter: null,
132
+ default: true,
133
+ },
134
+ askuser: {
135
+ id: PERK_ASK_USER_PROVIDER_ID,
136
+ seam: "askuser",
137
+ package: null,
138
+ adapter: null,
139
+ default: true,
140
+ },
141
+ footer: {
142
+ id: PERK_FOOTER_PROVIDER_ID,
143
+ seam: "footer",
144
+ package: null,
145
+ adapter: null,
146
+ default: true,
147
+ },
148
+ web: {
149
+ id: PI_WEB_ACCESS_PROVIDER_ID,
150
+ seam: "web",
151
+ package: "npm:pi-web-access",
152
+ adapter: null,
153
+ default: true,
154
+ },
155
+ };
156
+
116
157
  /** The first `default: true` provider for `seam` (the validator enforces exactly one). */
117
158
  function defaultFor(set: Provider[], seam: string): Provider | undefined {
118
159
  return set.find((p) => p.seam === seam && p.default);
@@ -131,22 +172,35 @@ function byId(set: Provider[]): Map<string, Provider> {
131
172
  * For each seam, the selection resolves to the named provider **iff** the id exists AND its `seam`
132
173
  * matches the key; otherwise it falls back to `defaultFor(seam)` and appends a loud-but-non-fatal
133
174
  * issue (unknown id / seam mismatch). An **absent** key falls back to the default **silently** (the
134
- * zero-config default — no issue). Defaults are trusted (not re-validated). Throws if the bundled
135
- * set has no default for a seam (a corrupt install the caller's try/catch fails safe). Omitting
136
- * `set` loads the bundled `providers.yaml`.
175
+ * zero-config default — no issue). Defaults are trusted (not re-validated). A seam with NO
176
+ * `default: true` entry in the set is PER-SEAM FAIL-OPEN: it resolves to the synthesized
177
+ * REFERENCE_FALLBACKS entry with a loud-but-non-fatal issue — never a throw (a throw here let one
178
+ * seam's catalog gap collapse every other seam's resolution through the callers' fail-safe
179
+ * catches; §8.10 names this a deliberate cross-plane difference — the Python `_require_default`
180
+ * stays strict because short-lived processes reading the wheel-bundled `perk/_shared` cannot
181
+ * skew). Omitting `set` loads the bundled `providers.yaml`.
137
182
  */
138
183
  export function resolveProviders(
139
- selection: { plan?: string; todo?: string; askuser?: string; footer?: string; web?: string },
184
+ selection: {
185
+ plan?: string;
186
+ todo?: string;
187
+ askuser?: string;
188
+ footer?: string;
189
+ web?: string;
190
+ },
140
191
  set?: Provider[],
141
192
  ): ResolvedProviders {
142
193
  const providers = set ?? loadProviders();
143
194
  const ids = byId(providers);
144
195
  const issues: string[] = [];
145
196
 
146
- const requireDefault = (seam: string): Provider => {
197
+ const requireDefault = (seam: (typeof PROVIDER_SEAMS)[number]): Provider => {
147
198
  const def = defaultFor(providers, seam);
148
199
  if (def === undefined) {
149
- throw new Error(`perk: no default provider for seam \`${seam}\` — reinstall perk`);
200
+ issues.push(
201
+ `seam \`${seam}\` has no default in the bundled catalog (version skew?) — using the built-in reference`,
202
+ );
203
+ return REFERENCE_FALLBACKS[seam];
150
204
  }
151
205
  return def;
152
206
  };
@@ -84,6 +84,13 @@ export function readSessionPointers(root: string, runId: string): SessionPointer
84
84
  * Best-effort: returns `true` on a successful write, `false` (with a stderr warning) on any
85
85
  * failure. Never throws. Serialized byte-compatibly with the Python writer (key order +
86
86
  * 2-space indent + trailing newline).
87
+ *
88
+ * `preserveForeign` makes the slot first-write-wins (defense in depth against pointer
89
+ * shadowing): when set and the slot already holds a pointer whose `pi_session_id` differs from
90
+ * the incoming one, the write is SKIPPED with a loud stderr warning naming both session ids
91
+ * (returns `false`) — so any future shadow vector surfaces instead of silently corrupting
92
+ * evidence. A same-session re-capture still refreshes the slot. Default `false` keeps today's
93
+ * overwrite semantics (each default-caller slot has exactly one legitimate writer).
87
94
  */
88
95
  export function recordSessionPointer(
89
96
  cwd: string,
@@ -91,11 +98,24 @@ export function recordSessionPointer(
91
98
  klass: SessionClass,
92
99
  site: SessionSite,
93
100
  pointer: SessionPointer,
101
+ opts: { preserveForeign?: boolean } = {},
94
102
  ): boolean {
95
103
  if (!runId) return false;
96
104
  const root = mainCheckoutRoot(cwd);
97
105
  try {
98
106
  const record = readSessionPointers(root, runId) ?? emptyRecord(runId);
107
+ const existing = record[klass][site];
108
+ if (
109
+ opts.preserveForeign === true &&
110
+ existing !== null &&
111
+ existing.pi_session_id !== pointer.pi_session_id
112
+ ) {
113
+ console.error(
114
+ `perk: warning: session pointer ${klass}.${site} for run ${runId} already held by ` +
115
+ `${existing.pi_session_id} — skipping foreign overwrite by ${pointer.pi_session_id}`,
116
+ );
117
+ return false;
118
+ }
99
119
  // The run id is authoritative — a record read from disk keeps its own; a fresh one is minted
100
120
  // with `runId`. (A mismatched on-disk run_id is left as-is; self-keying guarantees a match.)
101
121
  record[klass][site] = pointer;
@@ -113,6 +133,7 @@ export function recordSessionPointer(
113
133
  * Capture one session pointer into `<class>.<site>` from a session file path — the call-site
114
134
  * convenience over `recordSessionPointer`. Derives `pi_session_id` from the basename and stamps
115
135
  * `at`. A `null`/empty `sessionFile` or `runId` is a no-op (`false`) — best-effort, never throws.
136
+ * `preserveForeign` threads through to `recordSessionPointer`'s first-write-wins guard.
116
137
  */
117
138
  export function captureSessionPointer(args: {
118
139
  cwd: string;
@@ -121,15 +142,23 @@ export function captureSessionPointer(args: {
121
142
  site: SessionSite;
122
143
  sessionFile: string | null | undefined;
123
144
  parentSessionId?: string | null;
145
+ preserveForeign?: boolean;
124
146
  }): boolean {
125
147
  const { cwd, runId, klass, site, sessionFile } = args;
126
148
  if (!sessionFile || !runId) return false;
127
- return recordSessionPointer(cwd, runId, klass, site, {
128
- pi_session_id: basename(sessionFile),
129
- session_file: sessionFile,
130
- parent_pi_session_id: args.parentSessionId ?? null,
131
- at: new Date().toISOString(),
132
- });
149
+ return recordSessionPointer(
150
+ cwd,
151
+ runId,
152
+ klass,
153
+ site,
154
+ {
155
+ pi_session_id: basename(sessionFile),
156
+ session_file: sessionFile,
157
+ parent_pi_session_id: args.parentSessionId ?? null,
158
+ at: new Date().toISOString(),
159
+ },
160
+ { preserveForeign: args.preserveForeign },
161
+ );
133
162
  }
134
163
 
135
164
  /** Serialize a record byte-compatibly with the Python writer (explicit key order, null slots). */
@@ -13,7 +13,6 @@
13
13
  import {
14
14
  type Api,
15
15
  type Context,
16
- complete,
17
16
  type Model,
18
17
  type Static,
19
18
  type Tool,
@@ -21,6 +20,9 @@ import {
21
20
  type TSchema,
22
21
  validateToolCall,
23
22
  } from "@earendil-works/pi-ai";
23
+ // `complete` (the old global API) lives on the /compat entrypoint from pi-ai 0.80; the root
24
+ // keeps the types. Pi's extension loader aliases both the root and /compat to the compat entry.
25
+ import { complete } from "@earendil-works/pi-ai/compat";
24
26
 
25
27
  /** Structurally-minimal slice of `ExtensionContext` needed to reuse the session's model + auth. */
26
28
  export interface ModelAuthContext {