@mgiles/perk 1.1.0 → 2.1.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 +97 -70
  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 +1538 -1803
  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,17 +4,25 @@
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
 
12
13
  import { existsSync, readFileSync } from "node:fs";
13
14
  import { parseUserBindings, type SkillBinding } from "./bindings.ts";
15
+ import { mainCheckoutRoot } from "./git.ts";
14
16
  import { configFile, localConfigFile } from "./paths.ts";
15
17
 
16
18
  /**
17
- * One configured CI check (a `[[ci]]` array-of-tables row). `name`/`command` are required
19
+ * A TOML scalar the subset parser reads: quoted strings plus native booleans and numbers.
20
+ * Anything else (dates, arrays, inline tables) is still deliberately ignored.
21
+ */
22
+ export type TomlScalar = string | boolean | number;
23
+
24
+ /**
25
+ * One configured CI check (a `[[ci.checks]]` array-of-tables row). `name`/`command` are required
18
26
  * non-blank strings; an optional `glob` (a single comma-separated pattern string, e.g.
19
27
  * `"*.ts,*.tsx"`) declares which changed files the check is relevant to — the read-only CI
20
28
  * executor skips it on the run-all path when no changed file (vs trunk) matches.
@@ -29,19 +37,26 @@ export interface PerkConfig {
29
37
  /** Optional project-supplied plan-authoring addendum (`[workflow] plan_authoring = "..."`). */
30
38
  planAuthoring?: string;
31
39
  /**
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 `[]`.
40
+ * The `[ci]` verification namespace. `checks` is the `[[ci.checks]]` ordered array-of-tables
41
+ * (each row name/command/optional glob) the read-only CI executor consumes; absent/empty `[]`.
42
+ * `trusted` (`[ci] trusted = true`, a native boolean) declares those project-supplied checks
43
+ * trusted, so the executor runs them WITHOUT a per-session confirm on every surface, including
44
+ * headless (it overrides the fail-closed refuse). Absent/`false`/non-boolean ⇒ untrusted
45
+ * (confirm with UI; refuse headless). Always present; defaults `{trusted: false, checks: []}`.
35
46
  */
36
- ci: CiCheck[];
47
+ ci: { trusted: boolean; checks: CiCheck[] };
37
48
  /**
38
- * The agent-keyed `[subagents]` table: a per-agent model override for each perk-owned
49
+ * The agent-keyed `[models.subagents]` table: a per-agent model override for each perk-owned
39
50
  * project agent (`pr-reviewer`, `review-classifier`, `objective-explorer`, `conflict-resolver`,
40
- * `learn-analyst`). Each configured
51
+ * `learn-analyst`, `adversarial-reviewer`). Each configured
41
52
  * value is injected as a per-call inline `model` override on that agent's `subagent` spawn; when
42
53
  * a key is absent the agent's frontmatter `model` (in `.pi/agents/<name>.md`) is the default.
43
54
  * (`subagents.agentOverrides` does NOT reach project agents — `pi-subagents`'
44
55
  * `applyBuiltinOverrides` applies only to builtins — so this inline override is the mechanism.)
56
+ * A value may carry a `:thinking` suffix (`"anthropic/claude-sonnet-4-5:high"`) or be the
57
+ * `"inherit"` sentinel (child inherits the parent session's model) — both resolved by
58
+ * pi-subagents on the inline override (the last-colon segment counts as thinking only when it
59
+ * is a pi level, so ollama-style tags stay part of the model id).
45
60
  * Always-present object; absent keys omitted (mirror of `providers`).
46
61
  */
47
62
  subagents: {
@@ -50,11 +65,13 @@ export interface PerkConfig {
50
65
  "objective-explorer"?: string;
51
66
  "conflict-resolver"?: string;
52
67
  "learn-analyst"?: string;
68
+ "adversarial-reviewer"?: string;
53
69
  };
54
70
  /**
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"`).
71
+ * Optional `[compaction] objective_threshold` — the context-usage fraction (0,1] that triggers
72
+ * threshold compaction while an objective is active. A native TOML float (e.g.
73
+ * `objective_threshold = 0.8`); string/out-of-range values are ignored. The Python plane
74
+ * deliberately ignores this key (it converges the rest of `[compaction]` into settings).
58
75
  */
59
76
  objectiveCompactThreshold?: number;
60
77
  /** The `[[bindings]]` user overlay, resolved against shipped defaults downstream. */
@@ -64,28 +81,25 @@ export interface PerkConfig {
64
81
  * `shared/providers.yaml`. Absent keys mean “use the seam default”; resolution against the
65
82
  * supported set is a downstream concern.
66
83
  */
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 };
84
+ providers: {
85
+ plan?: string;
86
+ todo?: string;
87
+ askuser?: string;
88
+ footer?: string;
89
+ web?: string;
90
+ };
77
91
  }
78
92
 
79
- /** A nested string table: `{ section: { key: value } }` (the only shape perk reads today). */
80
- type StringTable = Record<string, Record<string, string>>;
93
+ /** A nested scalar table: `{ section: { key: scalar } }` (dotted section names kept literal). */
94
+ type ScalarTable = Record<string, Record<string, TomlScalar>>;
81
95
 
82
96
  /**
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.
97
+ * The narrow TOML subset perk reads: `[section]`/top-level scalar tables plus `[[name]]`
98
+ * array-of-tables (each row a scalar table). Mirrors `tomllib`'s shape for the keys perk uses.
85
99
  */
86
100
  interface TomlSubset {
87
- tables: StringTable;
88
- arrays: Record<string, Array<Record<string, string>>>;
101
+ tables: ScalarTable;
102
+ arrays: Record<string, Array<Record<string, TomlScalar>>>;
89
103
  }
90
104
 
91
105
  function unescapeBasic(raw: string): string {
@@ -98,16 +112,17 @@ function unescapeBasic(raw: string): string {
98
112
 
99
113
  /**
100
114
  * 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.
115
+ * `{ section: { key: scalar } }` map (top-level keys under the `""` section); `arrays` is a
116
+ * `{ name: [{ key: scalar }, ...] }` map fed by `[[name]]` array-of-tables. Scalars are quoted
117
+ * strings, native `true`/`false` booleans, and numeric literals; anything else is skipped
118
+ * this is intentionally NOT a full TOML parser.
104
119
  */
105
120
  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>>> = {};
121
+ const root: Record<string, TomlScalar> = {};
122
+ const tables: ScalarTable = { "": root };
123
+ const arrays: Record<string, Array<Record<string, TomlScalar>>> = {};
109
124
  // The current write target for `key = value` lines (a section table or an array-of-tables row).
110
- let dest: Record<string, string> = root;
125
+ let dest: Record<string, TomlScalar> = root;
111
126
  const lines = text.split(/\r?\n/);
112
127
  for (let i = 0; i < lines.length; i++) {
113
128
  const line = (lines[i] ?? "").trim();
@@ -117,7 +132,7 @@ export function parseTomlSubset(text: string): TomlSubset {
117
132
  const arrayHeader = line.match(/^\[\[([^\]]+)\]\]$/);
118
133
  if (arrayHeader) {
119
134
  const name = (arrayHeader[1] ?? "").trim();
120
- const row: Record<string, string> = {};
135
+ const row: Record<string, TomlScalar> = {};
121
136
  let rows = arrays[name];
122
137
  if (!rows) {
123
138
  rows = [];
@@ -173,8 +188,21 @@ export function parseTomlSubset(text: string): TomlSubset {
173
188
  const basic = value.match(/^"((?:[^"\\]|\\.)*)"/);
174
189
  if (basic) {
175
190
  dest[key] = unescapeBasic(basic[1] ?? "");
191
+ continue;
192
+ }
193
+
194
+ // Unquoted scalar: strip an inline `#` comment, then read native booleans and numbers.
195
+ const hash = value.indexOf("#");
196
+ const bare = (hash === -1 ? value : value.slice(0, hash)).trim();
197
+ if (bare === "true" || bare === "false") {
198
+ dest[key] = bare === "true";
199
+ continue;
176
200
  }
177
- // Non-string scalars are intentionally ignored (perk reads only strings today).
201
+ if (/^[+-]?\d[\d_]*(\.[\d_]+)?([eE][+-]?\d+)?$/.test(bare)) {
202
+ const parsed = Number(bare.replace(/_/g, ""));
203
+ if (Number.isFinite(parsed)) dest[key] = parsed;
204
+ }
205
+ // Other value shapes (dates, arrays, inline tables) are intentionally ignored.
178
206
  }
179
207
  return { tables, arrays };
180
208
  }
@@ -199,12 +227,12 @@ function readTomlFile(path: string): TomlSubset {
199
227
  * replace as a whole array (mirror of perk/substrate/config.py's list-replaces-list overlay).
200
228
  */
201
229
  function overlay(base: TomlSubset, over: TomlSubset): TomlSubset {
202
- const tables: StringTable = {};
230
+ const tables: ScalarTable = {};
203
231
  for (const [section, kv] of Object.entries(base.tables)) tables[section] = { ...kv };
204
232
  for (const [section, kv] of Object.entries(over.tables)) {
205
233
  tables[section] = { ...(tables[section] ?? {}), ...kv };
206
234
  }
207
- const arrays: Record<string, Array<Record<string, string>>> = { ...base.arrays };
235
+ const arrays: Record<string, Array<Record<string, TomlScalar>>> = { ...base.arrays };
208
236
  for (const [name, rows] of Object.entries(over.arrays)) arrays[name] = rows;
209
237
  return { tables, arrays };
210
238
  }
@@ -217,30 +245,33 @@ export function loadPerkConfig(cwd: string): PerkConfig {
217
245
  }
218
246
 
219
247
  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;
248
+ // `[compaction] objective_threshold` is a native float in (0,1]; strings/out-of-range ignored.
249
+ const rawThreshold = merged.tables.compaction?.objective_threshold;
222
250
  const objectiveCompactThreshold =
223
- Number.isFinite(parsedThreshold) && parsedThreshold > 0 && parsedThreshold <= 1
224
- ? parsedThreshold
251
+ typeof rawThreshold === "number" && rawThreshold > 0 && rawThreshold <= 1
252
+ ? rawThreshold
225
253
  : undefined;
226
254
  return {
227
255
  planAuthoring:
228
256
  typeof planAuthoring === "string" && planAuthoring.trim() ? planAuthoring : undefined,
229
- ci: parseCiChecks(merged.arrays.ci ?? []),
230
- subagents: parseSubagentsSelection(merged.tables.subagents),
257
+ ci: {
258
+ trusted: merged.tables.ci?.trusted === true,
259
+ checks: parseCiChecks(merged.arrays["ci.checks"] ?? []),
260
+ },
261
+ subagents: parseSubagentsSelection(merged.tables["models.subagents"]),
231
262
  objectiveCompactThreshold,
232
263
  bindings: parseUserBindings(merged.arrays.bindings ?? []),
233
264
  providers: parseProvidersSelection(merged.tables.providers),
234
- trust: parseTrustSelection(merged.tables.trust),
235
265
  };
236
266
  }
237
267
 
238
268
  /**
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`).
269
+ * Read the `[[ci.checks]]` array-of-tables into an ordered `CiCheck[]`. A row is kept only when
270
+ * both `name` and `command` are non-blank strings; `glob` is kept only when a non-blank string.
271
+ * Declared order is preserved; ill-typed rows are silently dropped (mirror of
272
+ * `parseProvidersSelection`).
242
273
  */
243
- export function parseCiChecks(rows: Array<Record<string, string>>): CiCheck[] {
274
+ export function parseCiChecks(rows: Array<Record<string, TomlScalar>>): CiCheck[] {
244
275
  const checks: CiCheck[] = [];
245
276
  for (const row of rows) {
246
277
  const name = row.name;
@@ -255,22 +286,23 @@ export function parseCiChecks(rows: Array<Record<string, string>>): CiCheck[] {
255
286
  return checks;
256
287
  }
257
288
 
258
- /** The perk-owned project agents configurable via the `[subagents]` table. */
289
+ /** The perk-owned project agents configurable via the `[models.subagents]` table. */
259
290
  const SUBAGENT_KEYS = [
260
291
  "pr-reviewer",
261
292
  "review-classifier",
262
293
  "objective-explorer",
263
294
  "conflict-resolver",
264
295
  "learn-analyst",
296
+ "adversarial-reviewer",
265
297
  ] as const;
266
298
 
267
299
  /**
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`).
300
+ * Read the agent-keyed `[models.subagents]` table into a selection (string values only). For each
301
+ * known agent key, the value is kept only when it is a non-blank string; absent/ill-typed/unknown
302
+ * keys are omitted (mirror of `parseProvidersSelection`).
271
303
  */
272
304
  function parseSubagentsSelection(
273
- table: Record<string, string> | undefined,
305
+ table: Record<string, TomlScalar> | undefined,
274
306
  ): PerkConfig["subagents"] {
275
307
  const selection: PerkConfig["subagents"] = {};
276
308
  for (const key of SUBAGENT_KEYS) {
@@ -280,16 +312,8 @@ function parseSubagentsSelection(
280
312
  return selection;
281
313
  }
282
314
 
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): {
315
+ /** 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). */
316
+ function parseProvidersSelection(table: Record<string, TomlScalar> | undefined): {
293
317
  plan?: string;
294
318
  todo?: string;
295
319
  askuser?: string;
@@ -321,14 +345,17 @@ export const GITHUB_ISSUE_BACKEND_ID: IssueBackendId = "github";
321
345
  *
322
346
  * Reads ONLY committed `.perk/config.toml` — deliberately not `loadPerkConfig`'s overlay, mirroring
323
347
  * the Python committed-only read (the backend decides where canonical durable state is written;
324
- * a per-user `.perk/local.toml` override would fragment the canonical store). Python
325
- * (`perk/backends/issues.py::resolve_issue_backend_id`) is the AUTHORITATIVE validator and **raises** on
326
- * "linear"/unknown; this mirror is fail-safe (absence/unknown/any error `"github"`) because
327
- * the TS plane only renders prompts it never writes canonical issues.
348
+ * a per-user `.perk/local.toml` override would fragment the canonical store). The read is
349
+ * anchored to the MAIN checkout via `mainCheckoutRoot` (fail-open: `cwd` outside a git repo),
350
+ * mirroring Python's main-worktree anchoring a linked worktree's checkout state (detached /
351
+ * stale branch / missing `.perk/`) must never flip a Linear repo's prompt clauses to GitHub.
352
+ * Python (`perk/backends/resolve.py::resolve_issue_backend_id`) is the AUTHORITATIVE validator and
353
+ * **raises** on unknown values; this mirror is fail-safe (absence/unknown/any error → `"github"`)
354
+ * because the TS plane only renders prompts — it never writes canonical issues.
328
355
  */
329
356
  export function resolveIssueBackendId(cwd: string): IssueBackendId {
330
357
  try {
331
- const committed = readTomlFile(configFile(cwd));
358
+ const committed = readTomlFile(configFile(mainCheckoutRoot(cwd)));
332
359
  const backend = committed.tables.issues?.backend;
333
360
  if (backend === "github" || backend === "linear") return backend;
334
361
  return GITHUB_ISSUE_BACKEND_ID;
@@ -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 {