@mgiles/perk 1.0.1 → 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 (157) hide show
  1. package/README.md +67 -57
  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 +21 -13
  6. package/extension/doors/address.ts +6 -5
  7. package/extension/doors/askUser.ts +12 -8
  8. package/extension/doors/ciExecutor.ts +23 -15
  9. package/extension/doors/hunkHandoff.ts +202 -0
  10. package/extension/doors/land.ts +33 -10
  11. package/extension/doors/learn.ts +240 -34
  12. package/extension/doors/learnFactory.ts +144 -0
  13. package/extension/doors/lifecycleGates.ts +2 -1
  14. package/extension/doors/plannotatorHandoff.ts +509 -0
  15. package/extension/doors/prReview.ts +22 -37
  16. package/extension/doors/prReviewBrowser.ts +341 -0
  17. package/extension/doors/prReviewTerminal.ts +267 -0
  18. package/extension/doors/ready.ts +2 -1
  19. package/extension/doors/selfcheck.ts +240 -6
  20. package/extension/doors/submit.ts +37 -19
  21. package/extension/doors/submitPrReview.ts +408 -0
  22. package/extension/factories/implementHere.ts +116 -0
  23. package/extension/factories/objective.ts +17 -6
  24. package/extension/factories/objectiveAuthor.ts +15 -32
  25. package/extension/factories/objectiveDraft.ts +1 -1
  26. package/extension/factories/objectivePlan.ts +16 -34
  27. package/extension/factories/objectiveSave.ts +7 -17
  28. package/extension/factories/planMode.ts +24 -38
  29. package/extension/factories/planReview.ts +291 -177
  30. package/extension/factories/planSave.ts +23 -8
  31. package/extension/index.ts +117 -20
  32. package/extension/substrate/bindingDelivery.ts +32 -10
  33. package/extension/substrate/bindings.ts +4 -2
  34. package/extension/substrate/cache.ts +36 -9
  35. package/extension/substrate/clipboard.ts +81 -0
  36. package/extension/substrate/command.ts +25 -0
  37. package/extension/substrate/config.ts +98 -76
  38. package/extension/substrate/consoleCapture.ts +90 -0
  39. package/extension/substrate/git.ts +77 -0
  40. package/extension/substrate/miniJinja.ts +480 -0
  41. package/extension/substrate/paths.ts +38 -0
  42. package/extension/substrate/prompts.ts +15 -24
  43. package/extension/substrate/providers.ts +62 -8
  44. package/extension/substrate/sessionData.ts +1 -1
  45. package/extension/substrate/sessionPointers.ts +184 -0
  46. package/extension/substrate/structuredOutput.ts +3 -1
  47. package/extension/substrate/terminalLaunch.ts +178 -0
  48. package/extension/substrate/toolGating.ts +332 -73
  49. package/extension/substrate/toolParams.ts +7 -0
  50. package/extension/substrate/workflowState.ts +54 -2
  51. package/extension/surfaces/footerProvider.ts +8 -4
  52. package/extension/surfaces/surfaces.ts +338 -13
  53. package/extension/vendor/btw/btw.ts +10 -0
  54. package/extension/worker/readOnlySession.ts +19 -6
  55. package/extension/worker/worker.ts +187 -31
  56. package/extension/workerMain.ts +14 -13
  57. package/package.json +3 -7
  58. package/prompts/README.md +56 -5
  59. package/prompts/_fixtures/cases.yaml +52 -131
  60. package/prompts/_fixtures/golden/cond_elif-a.txt +3 -0
  61. package/prompts/_fixtures/golden/cond_elif-b.txt +3 -0
  62. package/prompts/_fixtures/golden/cond_elif-c.txt +3 -0
  63. package/prompts/_fixtures/golden/cond_if-false.txt +3 -0
  64. package/prompts/_fixtures/golden/cond_if-true.txt +3 -0
  65. package/prompts/_fixtures/golden/cond_ops-1.txt +3 -0
  66. package/prompts/_fixtures/golden/cond_ops-2.txt +3 -0
  67. package/prompts/_fixtures/golden/no_trailing_nl.txt +2 -0
  68. package/prompts/_fixtures/golden/trailing_nl.txt +2 -0
  69. package/prompts/_fixtures/golden/trim_block.txt +3 -0
  70. package/prompts/_fixtures/golden/trim_inline.txt +1 -0
  71. package/prompts/_fixtures/live.yaml +396 -0
  72. package/prompts/_fixtures/templates/cond_elif.md +9 -0
  73. package/prompts/_fixtures/templates/cond_if.md +7 -0
  74. package/prompts/_fixtures/templates/cond_ops.md +3 -0
  75. package/prompts/_fixtures/templates/no_trailing_nl.md +2 -0
  76. package/prompts/_fixtures/templates/trailing_nl.md +2 -0
  77. package/prompts/_fixtures/templates/trim_block.md +5 -0
  78. package/prompts/_fixtures/templates/trim_inline.md +1 -0
  79. package/prompts/contexts/adapters/juicesharp-todo.md +7 -0
  80. package/prompts/contexts/adapters/plannotator-objective.md +7 -0
  81. package/prompts/contexts/adapters/plannotator-plan.md +6 -0
  82. package/prompts/contexts/adapters/tombell-plan.md +17 -0
  83. package/prompts/contexts/objective-authoring.md +20 -0
  84. package/prompts/contexts/plan-authoring.md +24 -0
  85. package/prompts/contexts/read-only.md +10 -0
  86. package/prompts/stages/conflict-resolution.md +4 -0
  87. package/prompts/stages/learn-code.md +8 -0
  88. package/prompts/stages/learn-docs.md +7 -6
  89. package/prompts/stages/learn-orchestrate.md +6 -0
  90. package/prompts/stages/learn.md +1 -1
  91. package/prompts/stages/objective-author/adopt.md +12 -0
  92. package/prompts/stages/objective-author/file.md +9 -0
  93. package/prompts/stages/objective-author/seed.md +9 -0
  94. package/prompts/stages/objective-plan/guidance.md +1 -1
  95. package/prompts/stages/objective-plan/seed.md +3 -2
  96. package/prompts/stages/objective-reconcile.md +7 -0
  97. package/prompts/stages/objective-replan.md +14 -0
  98. package/prompts/stages/objective-save.md +9 -0
  99. package/prompts/stages/plan-from/adopt.md +10 -0
  100. package/prompts/stages/plan-from/file.md +9 -0
  101. package/prompts/stages/pr-review-browser/active.md +11 -0
  102. package/prompts/stages/pr-review-browser/foreign.md +11 -0
  103. package/prompts/stages/pr-review-terminal/active.md +12 -0
  104. package/prompts/stages/pr-review-terminal/foreign.md +13 -0
  105. package/prompts/stages/pr-review-terminal/local.md +4 -0
  106. package/prompts/stages/pr-review.md +6 -0
  107. package/prompts/stages/replan.md +13 -0
  108. package/prompts/stages/skills/create-from.md +15 -0
  109. package/prompts/stages/skills/create.md +9 -0
  110. package/prompts/stages/skills/refine.md +9 -0
  111. package/shared/README.md +23 -13
  112. package/shared/bindings.yaml +22 -2
  113. package/shared/contracts-history.md +191 -0
  114. package/shared/contracts.md +2258 -1638
  115. package/shared/providers.yaml +8 -1
  116. package/shared/registry.yaml +10 -11
  117. package/shared/schemas/contracts/bindings.schema.json +38 -0
  118. package/shared/schemas/contracts/providers.schema.json +89 -0
  119. package/shared/schemas/contracts/registry.schema.json +98 -0
  120. package/shared/schemas/inputs/handoff-arg.schema.json +6 -0
  121. package/shared/schemas/inputs/resolve-threads-batch.schema.json +37 -0
  122. package/shared/schemas/inputs/review-post-batch.schema.json +84 -0
  123. package/shared/schemas/inputs/review-submit-batch.schema.json +66 -0
  124. package/shared/schemas/inputs/structured-roadmap-node.schema.json +102 -0
  125. package/shared/schemas/outputs/doctor-report.schema.json +236 -0
  126. package/shared/schemas/outputs/init-report.schema.json +419 -0
  127. package/shared/schemas/outputs/learn-capture.schema.json +90 -0
  128. package/shared/schemas/outputs/learn-skip.schema.json +59 -0
  129. package/shared/schemas/outputs/plan-save.schema.json +209 -0
  130. package/shared/schemas/outputs/pr-feedback.schema.json +334 -0
  131. package/shared/schemas/outputs/pr-land.schema.json +187 -0
  132. package/shared/schemas/outputs/pr-ready.schema.json +75 -0
  133. package/shared/schemas/outputs/pr-review-checkout.schema.json +69 -0
  134. package/shared/schemas/outputs/pr-review-cleanup.schema.json +54 -0
  135. package/shared/schemas/outputs/pr-review-context.schema.json +86 -0
  136. package/shared/schemas/outputs/pr-review-submit.schema.json +64 -0
  137. package/shared/schemas/outputs/pr-submit.schema.json +147 -0
  138. package/extension/doors/learnDocs.ts +0 -99
  139. package/prompts/_fixtures/golden/address-action-model.txt +0 -10
  140. package/prompts/_fixtures/golden/address-action.txt +0 -10
  141. package/prompts/_fixtures/golden/address-preview-model.txt +0 -6
  142. package/prompts/_fixtures/golden/address-preview.txt +0 -6
  143. package/prompts/_fixtures/golden/implement-github.txt +0 -8
  144. package/prompts/_fixtures/golden/learn-docs.txt +0 -8
  145. package/prompts/_fixtures/golden/learn-github.txt +0 -11
  146. package/prompts/_fixtures/golden/learn-linear.txt +0 -11
  147. package/prompts/_fixtures/golden/learn-no-ref.txt +0 -8
  148. package/prompts/_fixtures/golden/learn-other.txt +0 -8
  149. package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +0 -8
  150. package/prompts/_fixtures/golden/objective-plan-guidance.txt +0 -8
  151. package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +0 -20
  152. package/prompts/_fixtures/golden/objective-plan-seed.txt +0 -15
  153. package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +0 -1
  154. package/prompts/_fixtures/golden/objective-read-linear.txt +0 -1
  155. package/prompts/_fixtures/golden/plan-read-github.txt +0 -1
  156. package/prompts/_fixtures/golden/plan-read-linear.txt +0 -1
  157. package/prompts/_fixtures/golden/plan-read-other.txt +0 -1
@@ -0,0 +1,25 @@
1
+ // The single chokepoint that gives every perk command a uniform, immediate "running…"
2
+ // acknowledgement at entry. pi does not echo the invoked command and handlers only report() at the
3
+ // end, so without this a command's async work (cold-door subprocess calls, GitHub round-trips) is
4
+ // dead air between Enter and completion. registerPerkCommand wraps the handler to emit one transient
5
+ // entry toast through the headless-safe report() seam (no cleanup state, headless-fail-safe for
6
+ // free) before awaiting the original handler. The toast fires synchronously before the first await,
7
+ // so it lands before any cold-door work, sendUserMessage drive, or gate transition; the wrapper does
8
+ // not try/catch, so errors propagate exactly as before.
9
+
10
+ import type { ExtensionAPI, RegisteredCommand } from "@earendil-works/pi-coding-agent";
11
+ import { report } from "../surfaces/report.ts";
12
+
13
+ export function registerPerkCommand(
14
+ pi: ExtensionAPI,
15
+ name: string,
16
+ options: Omit<RegisteredCommand, "name" | "sourceInfo">,
17
+ ): void {
18
+ pi.registerCommand(name, {
19
+ ...options,
20
+ handler: async (args, ctx) => {
21
+ report(ctx, name, "info", "running…");
22
+ await options.handler(args, ctx);
23
+ },
24
+ });
25
+ }
@@ -1,23 +1,27 @@
1
1
  // The minimal TS config port. Mirrors `perk/substrate/config.py`'s overlay: read
2
- // `.pi/perk.toml` (committed) overlaid by `.pi/perk.local.toml` (gitignored, local wins). The only
2
+ // `.perk/config.toml` (committed) overlaid by `.perk/local.toml` (gitignored, local wins). The only
3
3
  // setting consumed today is an optional `[workflow]` plan-authoring addendum, appended into the
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
- import { join } from "node:path";
14
14
  import { parseUserBindings, type SkillBinding } from "./bindings.ts";
15
+ import { configFile, localConfigFile } from "./paths.ts";
15
16
 
16
- const CONFIG_FILENAME = "perk.toml";
17
- const LOCAL_CONFIG_FILENAME = "perk.local.toml";
17
+ /**
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;
18
22
 
19
23
  /**
20
- * One configured CI check (a `[[ci]]` array-of-tables row). `name`/`command` are required
24
+ * One configured CI check (a `[[ci.checks]]` array-of-tables row). `name`/`command` are required
21
25
  * non-blank strings; an optional `glob` (a single comma-separated pattern string, e.g.
22
26
  * `"*.ts,*.tsx"`) declares which changed files the check is relevant to — the read-only CI
23
27
  * executor skips it on the run-all path when no changed file (vs trunk) matches.
@@ -32,18 +36,26 @@ export interface PerkConfig {
32
36
  /** Optional project-supplied plan-authoring addendum (`[workflow] plan_authoring = "..."`). */
33
37
  planAuthoring?: string;
34
38
  /**
35
- * The `[[ci]]` checks (an ordered array-of-tables, each row name/command/optional glob); the
36
- * read-only CI executor consumes it. Always-present ordered array (mirror of
37
- * `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: []}`.
38
45
  */
39
- ci: CiCheck[];
46
+ ci: { trusted: boolean; checks: CiCheck[] };
40
47
  /**
41
- * The agent-keyed `[subagents]` table: a per-agent model override for each perk-owned
42
- * project agent (`pr-reviewer`, `review-classifier`, `objective-explorer`). Each configured
48
+ * The agent-keyed `[models.subagents]` table: a per-agent model override for each perk-owned
49
+ * project agent (`pr-reviewer`, `review-classifier`, `objective-explorer`, `conflict-resolver`,
50
+ * `learn-analyst`, `adversarial-reviewer`). Each configured
43
51
  * value is injected as a per-call inline `model` override on that agent's `subagent` spawn; when
44
52
  * a key is absent the agent's frontmatter `model` (in `.pi/agents/<name>.md`) is the default.
45
53
  * (`subagents.agentOverrides` does NOT reach project agents — `pi-subagents`'
46
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).
47
59
  * Always-present object; absent keys omitted (mirror of `providers`).
48
60
  */
49
61
  subagents: {
@@ -51,11 +63,14 @@ export interface PerkConfig {
51
63
  "review-classifier"?: string;
52
64
  "objective-explorer"?: string;
53
65
  "conflict-resolver"?: string;
66
+ "learn-analyst"?: string;
67
+ "adversarial-reviewer"?: string;
54
68
  };
55
69
  /**
56
- * Optional `[objective] compact_threshold` — the context-usage fraction (0,1] that triggers
57
- * threshold compaction while an objective is active. Because the TOML subset reads only
58
- * 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).
59
74
  */
60
75
  objectiveCompactThreshold?: number;
61
76
  /** The `[[bindings]]` user overlay, resolved against shipped defaults downstream. */
@@ -65,28 +80,25 @@ export interface PerkConfig {
65
80
  * `shared/providers.yaml`. Absent keys mean “use the seam default”; resolution against the
66
81
  * supported set is a downstream concern.
67
82
  */
68
- providers: { plan?: string; todo?: string; askuser?: string; footer?: string; web?: string };
69
- /**
70
- * The `[trust]` per-repo trust table. `trust.ci === true` (written `ci = "true"` — the subset
71
- * parser reads strings only) declares the project's `[ci]` checks trusted, so the read-only CI
72
- * executor runs them WITHOUT a per-session confirm on every surface, including headless
73
- * (it overrides the fail-closed refuse). Absent/"false" ⇒ unchanged (confirm with UI; refuse
74
- * headless). Always-present object; absent keys omitted (mirror of `providers`). The table may
75
- * grow further trust keys later.
76
- */
77
- trust: { ci?: boolean };
83
+ providers: {
84
+ plan?: string;
85
+ todo?: string;
86
+ askuser?: string;
87
+ footer?: string;
88
+ web?: string;
89
+ };
78
90
  }
79
91
 
80
- /** A nested string table: `{ section: { key: value } }` (the only shape perk reads today). */
81
- 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>>;
82
94
 
83
95
  /**
84
- * The narrow TOML subset perk reads: `[section]`/top-level string tables plus `[[name]]`
85
- * 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.
86
98
  */
87
99
  interface TomlSubset {
88
- tables: StringTable;
89
- arrays: Record<string, Array<Record<string, string>>>;
100
+ tables: ScalarTable;
101
+ arrays: Record<string, Array<Record<string, TomlScalar>>>;
90
102
  }
91
103
 
92
104
  function unescapeBasic(raw: string): string {
@@ -99,16 +111,17 @@ function unescapeBasic(raw: string): string {
99
111
 
100
112
  /**
101
113
  * Parse the narrow TOML subset perk consumes. Returns `{ tables, arrays }`: `tables` is a
102
- * `{ section: { key: stringValue } }` map (top-level keys under the `""` section); `arrays` is a
103
- * `{ name: [{ key: stringValue }, ...] }` map fed by `[[name]]` array-of-tables. Non-string values
104
- * 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.
105
118
  */
106
119
  export function parseTomlSubset(text: string): TomlSubset {
107
- const root: Record<string, string> = {};
108
- const tables: StringTable = { "": root };
109
- 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>>> = {};
110
123
  // The current write target for `key = value` lines (a section table or an array-of-tables row).
111
- let dest: Record<string, string> = root;
124
+ let dest: Record<string, TomlScalar> = root;
112
125
  const lines = text.split(/\r?\n/);
113
126
  for (let i = 0; i < lines.length; i++) {
114
127
  const line = (lines[i] ?? "").trim();
@@ -118,7 +131,7 @@ export function parseTomlSubset(text: string): TomlSubset {
118
131
  const arrayHeader = line.match(/^\[\[([^\]]+)\]\]$/);
119
132
  if (arrayHeader) {
120
133
  const name = (arrayHeader[1] ?? "").trim();
121
- const row: Record<string, string> = {};
134
+ const row: Record<string, TomlScalar> = {};
122
135
  let rows = arrays[name];
123
136
  if (!rows) {
124
137
  rows = [];
@@ -174,8 +187,21 @@ export function parseTomlSubset(text: string): TomlSubset {
174
187
  const basic = value.match(/^"((?:[^"\\]|\\.)*)"/);
175
188
  if (basic) {
176
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;
177
199
  }
178
- // 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.
179
205
  }
180
206
  return { tables, arrays };
181
207
  }
@@ -200,49 +226,51 @@ function readTomlFile(path: string): TomlSubset {
200
226
  * replace as a whole array (mirror of perk/substrate/config.py's list-replaces-list overlay).
201
227
  */
202
228
  function overlay(base: TomlSubset, over: TomlSubset): TomlSubset {
203
- const tables: StringTable = {};
229
+ const tables: ScalarTable = {};
204
230
  for (const [section, kv] of Object.entries(base.tables)) tables[section] = { ...kv };
205
231
  for (const [section, kv] of Object.entries(over.tables)) {
206
232
  tables[section] = { ...(tables[section] ?? {}), ...kv };
207
233
  }
208
- const arrays: Record<string, Array<Record<string, string>>> = { ...base.arrays };
234
+ const arrays: Record<string, Array<Record<string, TomlScalar>>> = { ...base.arrays };
209
235
  for (const [name, rows] of Object.entries(over.arrays)) arrays[name] = rows;
210
236
  return { tables, arrays };
211
237
  }
212
238
 
213
- /** Load `.pi/perk.toml` overlaid by `.pi/perk.local.toml` from `cwd` (mirror of perk/substrate/config.py). */
239
+ /** Load `.perk/config.toml` overlaid by `.perk/local.toml` from `cwd` (mirror of perk/substrate/config.py). */
214
240
  export function loadPerkConfig(cwd: string): PerkConfig {
215
- const piDir = join(cwd, ".pi");
216
241
  let merged: TomlSubset = emptySubset();
217
- for (const name of [CONFIG_FILENAME, LOCAL_CONFIG_FILENAME]) {
218
- merged = overlay(merged, readTomlFile(join(piDir, name)));
242
+ for (const file of [configFile(cwd), localConfigFile(cwd)]) {
243
+ merged = overlay(merged, readTomlFile(file));
219
244
  }
220
245
 
221
246
  const planAuthoring = merged.tables.workflow?.plan_authoring;
222
- const rawThreshold = merged.tables.objective?.compact_threshold;
223
- 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;
224
249
  const objectiveCompactThreshold =
225
- Number.isFinite(parsedThreshold) && parsedThreshold > 0 && parsedThreshold <= 1
226
- ? parsedThreshold
250
+ typeof rawThreshold === "number" && rawThreshold > 0 && rawThreshold <= 1
251
+ ? rawThreshold
227
252
  : undefined;
228
253
  return {
229
254
  planAuthoring:
230
255
  typeof planAuthoring === "string" && planAuthoring.trim() ? planAuthoring : undefined,
231
- ci: parseCiChecks(merged.arrays.ci ?? []),
232
- 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"]),
233
261
  objectiveCompactThreshold,
234
262
  bindings: parseUserBindings(merged.arrays.bindings ?? []),
235
263
  providers: parseProvidersSelection(merged.tables.providers),
236
- trust: parseTrustSelection(merged.tables.trust),
237
264
  };
238
265
  }
239
266
 
240
267
  /**
241
- * Read the `[[ci]]` array-of-tables into an ordered `CiCheck[]`. A row is kept only when both
242
- * `name` and `command` are non-blank strings; `glob` is kept only when a non-blank string. Declared
243
- * 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`).
244
272
  */
245
- export function parseCiChecks(rows: Array<Record<string, string>>): CiCheck[] {
273
+ export function parseCiChecks(rows: Array<Record<string, TomlScalar>>): CiCheck[] {
246
274
  const checks: CiCheck[] = [];
247
275
  for (const row of rows) {
248
276
  const name = row.name;
@@ -257,21 +285,23 @@ export function parseCiChecks(rows: Array<Record<string, string>>): CiCheck[] {
257
285
  return checks;
258
286
  }
259
287
 
260
- /** The perk-owned project agents configurable via the `[subagents]` table. */
288
+ /** The perk-owned project agents configurable via the `[models.subagents]` table. */
261
289
  const SUBAGENT_KEYS = [
262
290
  "pr-reviewer",
263
291
  "review-classifier",
264
292
  "objective-explorer",
265
293
  "conflict-resolver",
294
+ "learn-analyst",
295
+ "adversarial-reviewer",
266
296
  ] as const;
267
297
 
268
298
  /**
269
- * Read the agent-keyed `[subagents]` table into a selection (string values only). For each known
270
- * agent key, the value is kept only when it is a non-blank string; absent/ill-typed/unknown keys
271
- * 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`).
272
302
  */
273
303
  function parseSubagentsSelection(
274
- table: Record<string, string> | undefined,
304
+ table: Record<string, TomlScalar> | undefined,
275
305
  ): PerkConfig["subagents"] {
276
306
  const selection: PerkConfig["subagents"] = {};
277
307
  for (const key of SUBAGENT_KEYS) {
@@ -281,16 +311,8 @@ function parseSubagentsSelection(
281
311
  return selection;
282
312
  }
283
313
 
284
- /** Read the `[trust]` table into a `{ci?}` selection. `ci` is true only for the string "true". */
285
- function parseTrustSelection(table: Record<string, string> | undefined): { ci?: boolean } {
286
- const selection: { ci?: boolean } = {};
287
- if (typeof table?.ci === "string" && table.ci.trim().toLowerCase() === "true")
288
- selection.ci = true;
289
- return selection;
290
- }
291
-
292
- /** Read the flat `[providers]` table into a `{plan?, todo?, askuser?, footer?, web?}` selection (string values only). */
293
- 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): {
294
316
  plan?: string;
295
317
  todo?: string;
296
318
  askuser?: string;
@@ -320,16 +342,16 @@ export const GITHUB_ISSUE_BACKEND_ID: IssueBackendId = "github";
320
342
  /**
321
343
  * The fail-safe TS mirror of the issue-backend selection.
322
344
  *
323
- * Reads ONLY committed `.pi/perk.toml` — deliberately not `loadPerkConfig`'s overlay, mirroring
345
+ * Reads ONLY committed `.perk/config.toml` — deliberately not `loadPerkConfig`'s overlay, mirroring
324
346
  * the Python committed-only read (the backend decides where canonical durable state is written;
325
- * a per-user `perk.local.toml` override would fragment the canonical store). Python
347
+ * a per-user `.perk/local.toml` override would fragment the canonical store). Python
326
348
  * (`perk/backends/issues.py::resolve_issue_backend_id`) is the AUTHORITATIVE validator and **raises** on
327
349
  * "linear"/unknown; this mirror is fail-safe (absence/unknown/any error → `"github"`) because
328
350
  * the TS plane only renders prompts — it never writes canonical issues.
329
351
  */
330
352
  export function resolveIssueBackendId(cwd: string): IssueBackendId {
331
353
  try {
332
- const committed = readTomlFile(join(cwd, ".pi", CONFIG_FILENAME));
354
+ const committed = readTomlFile(configFile(cwd));
333
355
  const backend = committed.tables.issues?.backend;
334
356
  if (backend === "github" || backend === "linear") return backend;
335
357
  return GITHUB_ISSUE_BACKEND_ID;
@@ -0,0 +1,90 @@
1
+ // A small, pure swap of the global `console.error` so in-process chatter can be re-routed through a
2
+ // TUI-safe sink while a background task runs. plannotator's browser code-review setup writes
3
+ // progress straight to `process.stderr` via plain `console.error(...)` (fetching the PR, creating
4
+ // the local checkout, …); those raw writes bypass pi's managed rendering and paint over the input
5
+ // box. While the request is in flight we install a replacement that forwards each captured line to
6
+ // an injected sink (perk routes it through `report()`), then restore the original.
7
+ //
8
+ // Restore is debounce-driven: setup emits a burst of lines then goes quiet once the browser is up,
9
+ // so we restore after `quietMs` with no new line (self-adjusts to the variable several-second
10
+ // setup, no fixed-duration guess). A `finally` backstop in the caller restores too; `restore()` is
11
+ // idempotent and only reassigns `console.error` when our replacement is still installed, so an
12
+ // accidental overlap with a newer patcher is safe rather than clobbering. The replacement is inert
13
+ // once restored (a stale reference delegates to the original) and re-entrancy-safe (a console.error
14
+ // from inside the sink delegates to the original instead of recursing).
15
+
16
+ export type ConsoleErrorSink = (line: string) => void;
17
+
18
+ export interface ConsoleErrorInterceptor {
19
+ restore(): void;
20
+ }
21
+
22
+ interface InterceptOptions {
23
+ quietMs: number;
24
+ /** Injectable purely so the unit test drives a deterministic fake clock. */
25
+ schedule?: (fn: () => void, ms: number) => unknown;
26
+ clearScheduled?: (handle: unknown) => void;
27
+ }
28
+
29
+ /**
30
+ * Swap `console.error` so captured lines reach `sink` (joined with a space for multi-arg calls,
31
+ * mirroring plannotator's occasional 2-arg `console.error`). Returns an interceptor whose
32
+ * `restore()` reinstates the original. The original is also restored automatically once no captured
33
+ * line has arrived for `quietMs`.
34
+ */
35
+ export function interceptConsoleError(
36
+ sink: ConsoleErrorSink,
37
+ opts: InterceptOptions,
38
+ ): ConsoleErrorInterceptor {
39
+ const schedule = opts.schedule ?? ((fn, ms) => globalThis.setTimeout(fn, ms));
40
+ const clearScheduled =
41
+ opts.clearScheduled ??
42
+ ((handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>));
43
+
44
+ const original = console.error;
45
+ let restored = false;
46
+ let active = false;
47
+ let handle: unknown;
48
+
49
+ const restore = (): void => {
50
+ if (handle !== undefined) {
51
+ clearScheduled(handle);
52
+ handle = undefined;
53
+ }
54
+ if (restored) return;
55
+ restored = true;
56
+ // Only reclaim the slot if it is still OUR replacement — never clobber a newer patcher.
57
+ if (console.error === replacement) console.error = original;
58
+ };
59
+
60
+ const resetQuietTimer = (): void => {
61
+ if (handle !== undefined) clearScheduled(handle);
62
+ handle = schedule(restore, opts.quietMs);
63
+ // Never keep the event loop alive on our account.
64
+ (handle as { unref?: () => void })?.unref?.();
65
+ };
66
+
67
+ const replacement = (...args: unknown[]): void => {
68
+ // Once restored, behave as the original — a stale reference still holding `replacement`
69
+ // (a module that cached console.error, or a wrapping patcher) never re-routes to the sink.
70
+ // And if the sink (or a downstream report()/ui.notify) ever writes to console.error while we
71
+ // are installed, delegate to the original instead of recursing into ourselves.
72
+ if (restored || active) {
73
+ original(...args);
74
+ return;
75
+ }
76
+ active = true;
77
+ try {
78
+ sink(args.map(String).join(" "));
79
+ } finally {
80
+ active = false;
81
+ }
82
+ resetQuietTimer();
83
+ };
84
+
85
+ console.error = replacement;
86
+ // Start the timer immediately so a zero-line case (no setup output) still restores after quietMs.
87
+ resetQuietTimer();
88
+
89
+ return { restore };
90
+ }
@@ -0,0 +1,77 @@
1
+ // A thin `git`-shelling seam for the extension interior — the TS twin of perk/substrate/git.py.
2
+ //
3
+ // Node builtins only (so it loads cleanly under `node --test`); shells `git` via `execFileSync`,
4
+ // never with a shell. Fail-open by design: every failure degrades to the caller's `cwd` (or null
5
+ // where stated) rather than throwing — the carriers that use this must never wedge a session.
6
+
7
+ import { execFileSync } from "node:child_process";
8
+ import { isAbsolute, resolve } from "node:path";
9
+
10
+ /**
11
+ * The MAIN working tree's root, even when `cwd` is inside a linked worktree — the TS twin of
12
+ * `main_worktree_root`. Resolves `git rev-parse --git-common-dir` (the shared `.git` of the main
13
+ * checkout) and returns its parent (equal to the repo root in the main checkout). **Fail-open**:
14
+ * any failure (not a repo, git missing) returns `cwd`, so a session-pointer write always has a
15
+ * location — never throws. (Python returns `null` outside a repo; here the single caller wants
16
+ * `main_worktree_root(cwd) or cwd`, so we fold the fallback in.)
17
+ */
18
+ export function mainCheckoutRoot(cwd: string): string {
19
+ let out: string;
20
+ try {
21
+ out = execFileSync("git", ["rev-parse", "--git-common-dir"], {
22
+ cwd,
23
+ encoding: "utf8",
24
+ stdio: ["ignore", "pipe", "ignore"],
25
+ }).trim();
26
+ } catch {
27
+ return cwd;
28
+ }
29
+ if (out === "") return cwd;
30
+ // `--git-common-dir` may be relative (to `cwd`) or absolute; resolve then take the parent
31
+ // (the dir containing `.git` = the main checkout root).
32
+ const common = isAbsolute(out) ? out : resolve(cwd, out);
33
+ return resolve(common, "..");
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
+ }