@mgiles/perk 1.0.1

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 (98) hide show
  1. package/README.md +105 -0
  2. package/extension/adapters/planAdapterPlannotator.ts +269 -0
  3. package/extension/adapters/planAdapterTombell.ts +147 -0
  4. package/extension/adapters/todoAdapterJuicesharp.ts +105 -0
  5. package/extension/checkpoints/checkpoints.ts +542 -0
  6. package/extension/checkpoints/planSteps.ts +108 -0
  7. package/extension/doors/address.ts +360 -0
  8. package/extension/doors/askUser.ts +194 -0
  9. package/extension/doors/ciExecutor.ts +583 -0
  10. package/extension/doors/land.ts +222 -0
  11. package/extension/doors/learn.ts +235 -0
  12. package/extension/doors/learnDocs.ts +99 -0
  13. package/extension/doors/lifecycleGates.ts +171 -0
  14. package/extension/doors/prReview.ts +339 -0
  15. package/extension/doors/ready.ts +86 -0
  16. package/extension/doors/selfcheck.ts +155 -0
  17. package/extension/doors/submit.ts +253 -0
  18. package/extension/factories/objective.ts +240 -0
  19. package/extension/factories/objectiveAuthor.ts +114 -0
  20. package/extension/factories/objectiveDraft.ts +343 -0
  21. package/extension/factories/objectivePlan.ts +838 -0
  22. package/extension/factories/objectiveSave.ts +285 -0
  23. package/extension/factories/planDraft.ts +140 -0
  24. package/extension/factories/planMode.ts +214 -0
  25. package/extension/factories/planReview.ts +644 -0
  26. package/extension/factories/planSave.ts +589 -0
  27. package/extension/factories/planTitle.ts +123 -0
  28. package/extension/index.ts +459 -0
  29. package/extension/substrate/bindingDelivery.ts +199 -0
  30. package/extension/substrate/bindings.ts +180 -0
  31. package/extension/substrate/cache.ts +163 -0
  32. package/extension/substrate/coldDoor.ts +226 -0
  33. package/extension/substrate/config.ts +339 -0
  34. package/extension/substrate/miniYaml.ts +262 -0
  35. package/extension/substrate/prompts.ts +35 -0
  36. package/extension/substrate/providers.ts +177 -0
  37. package/extension/substrate/registry.ts +62 -0
  38. package/extension/substrate/resources.ts +41 -0
  39. package/extension/substrate/result.ts +72 -0
  40. package/extension/substrate/runId.ts +49 -0
  41. package/extension/substrate/sessionData.ts +229 -0
  42. package/extension/substrate/structuredOutput.ts +141 -0
  43. package/extension/substrate/toolGating.ts +400 -0
  44. package/extension/substrate/toolParams.ts +106 -0
  45. package/extension/substrate/workflowState.ts +233 -0
  46. package/extension/surfaces/footerProvider.ts +43 -0
  47. package/extension/surfaces/report.ts +34 -0
  48. package/extension/surfaces/surfaces.ts +460 -0
  49. package/extension/vendor/btw/btw.ts +964 -0
  50. package/extension/vendor/btw/core.ts +153 -0
  51. package/extension/vendor/whimsical/whimsical.ts +485 -0
  52. package/extension/worker/readOnlySession.ts +282 -0
  53. package/extension/worker/worker.ts +765 -0
  54. package/extension/workerMain.ts +150 -0
  55. package/package.json +55 -0
  56. package/prompts/README.md +15 -0
  57. package/prompts/_fixtures/cases.yaml +140 -0
  58. package/prompts/_fixtures/golden/address-action-model.txt +10 -0
  59. package/prompts/_fixtures/golden/address-action.txt +10 -0
  60. package/prompts/_fixtures/golden/address-preview-model.txt +6 -0
  61. package/prompts/_fixtures/golden/address-preview.txt +6 -0
  62. package/prompts/_fixtures/golden/hello.txt +1 -0
  63. package/prompts/_fixtures/golden/implement-github.txt +8 -0
  64. package/prompts/_fixtures/golden/learn-docs.txt +8 -0
  65. package/prompts/_fixtures/golden/learn-github.txt +11 -0
  66. package/prompts/_fixtures/golden/learn-linear.txt +11 -0
  67. package/prompts/_fixtures/golden/learn-no-ref.txt +8 -0
  68. package/prompts/_fixtures/golden/learn-other.txt +8 -0
  69. package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +8 -0
  70. package/prompts/_fixtures/golden/objective-plan-guidance.txt +8 -0
  71. package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +20 -0
  72. package/prompts/_fixtures/golden/objective-plan-seed.txt +15 -0
  73. package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +1 -0
  74. package/prompts/_fixtures/golden/objective-read-linear.txt +1 -0
  75. package/prompts/_fixtures/golden/plan-read-github.txt +1 -0
  76. package/prompts/_fixtures/golden/plan-read-linear.txt +1 -0
  77. package/prompts/_fixtures/golden/plan-read-other.txt +1 -0
  78. package/prompts/_fixtures/golden/with_include.txt +4 -0
  79. package/prompts/_fixtures/templates/_greeting.md +1 -0
  80. package/prompts/_fixtures/templates/hello.md +1 -0
  81. package/prompts/_fixtures/templates/with_include.md +4 -0
  82. package/prompts/common/objective-read/linear.md +1 -0
  83. package/prompts/common/plan-read/github.md +1 -0
  84. package/prompts/common/plan-read/linear.md +1 -0
  85. package/prompts/common/plan-read/other.md +1 -0
  86. package/prompts/stages/address/action.md +10 -0
  87. package/prompts/stages/address/preview.md +6 -0
  88. package/prompts/stages/implement.md +8 -0
  89. package/prompts/stages/learn-docs.md +8 -0
  90. package/prompts/stages/learn.md +21 -0
  91. package/prompts/stages/objective-plan/guidance.md +12 -0
  92. package/prompts/stages/objective-plan/seed.md +20 -0
  93. package/shared/README.md +29 -0
  94. package/shared/bindings.yaml +64 -0
  95. package/shared/contracts-history.md +403 -0
  96. package/shared/contracts.md +4172 -0
  97. package/shared/providers.yaml +221 -0
  98. package/shared/registry.yaml +199 -0
@@ -0,0 +1,226 @@
1
+ // The one cold-door delegation seam (cf. result.ts / report.ts / branchOf). Every warm door that
2
+ // shells out to a Python `--json` cold door funnels through
3
+ // `runColdDoor<T>`: PERK_BIN resolution, the run-scoped scratch stdin channel (pi.exec has no
4
+ // stdin), the envelope-aware killed/code check, the JSON boundary, the `success` envelope check,
5
+ // and a caller-supplied validated decode. The client never reports/notifies and never throws —
6
+ // `failFor` (result.ts) keeps owning the loud-but-soft reporting; report-only doors branch on
7
+ // `errorType` directly.
8
+ //
9
+ // A door consumes it as:
10
+ //
11
+ // const r = await runColdDoor<SubmitOk>(pi, ctx, ["pr", "submit", "--json"], {
12
+ // label: "perk pr submit",
13
+ // decode,
14
+ // });
15
+ // if (!r.ok) return fail(r.message, r.errorType);
16
+
17
+ import { writeFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import type { ExecOptions, ExecResult } from "@earendil-works/pi-coding-agent";
20
+ import { ensureRunScratch } from "./cache.ts";
21
+ import { type BranchSource, branchOf, rebuildWorkflowState } from "./workflowState.ts";
22
+
23
+ /** Minimal exec surface — `ExtensionAPI` satisfies it (compile-checked in the test); tests fake it. */
24
+ export interface ExecHost {
25
+ exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
26
+ }
27
+
28
+ /** Minimal context slice — `ExtensionContext` satisfies it (the `BranchSource` precedent). */
29
+ export interface ColdDoorCtx extends BranchSource {
30
+ cwd: string;
31
+ signal?: AbortSignal;
32
+ }
33
+
34
+ /** A parsed cold-door JSON payload (the envelope already validated). */
35
+ export type ColdJson = Record<string, unknown>;
36
+
37
+ export type ColdDoorResult<T> =
38
+ | { ok: true; data: T }
39
+ | {
40
+ ok: false;
41
+ message: string;
42
+ errorType: string;
43
+ /** The parsed `success: false` envelope, when one was present — doors that render
44
+ * partial-failure detail (e.g. address's per-thread rows) narrow it themselves. Absent on
45
+ * exec-throw, scratch-failure, unparseable-JSON, and bad_output arms. */
46
+ payload?: ColdJson;
47
+ };
48
+
49
+ export interface ColdDoorOpts<T> {
50
+ /** Human door label for error text, e.g. "perk pr-submit" / "perk objective reconcile". */
51
+ label: string;
52
+ /** Narrow the success payload; return null to signal a missing/invalid payload (bad_output). */
53
+ decode: (payload: ColdJson) => T | null;
54
+ /** The temp-file stdin channel: stage `content` in run scratch, append [flag, path] to argv. */
55
+ stdin?: { flag: string; content: string; filename: string };
56
+ }
57
+
58
+ /**
59
+ * Read the active run id from the rebuilt workflow-state (for the scratch dir); else a stamp.
60
+ * Exported for tests; doors never call it directly.
61
+ */
62
+ export function activeRunId(ctx: ColdDoorCtx): string {
63
+ try {
64
+ const runId = rebuildWorkflowState(branchOf(ctx)).run_id;
65
+ if (typeof runId === "string" && runId.length > 0) return runId;
66
+ } catch {
67
+ // fall through to a stamp
68
+ }
69
+ return `cold-door-${Date.now()}`;
70
+ }
71
+
72
+ /** Pull a string-typed field off the parsed payload; non-strings yield undefined. */
73
+ export function stringField(payload: ColdJson, key: string): string | undefined {
74
+ const value = payload[key];
75
+ return typeof value === "string" ? value : undefined;
76
+ }
77
+
78
+ /** A nullable-string field: string or null accepted; anything else yields undefined. */
79
+ export function nullableStringField(obj: ColdJson, key: string): string | null | undefined {
80
+ const value = obj[key];
81
+ return typeof value === "string" || value === null ? value : undefined;
82
+ }
83
+
84
+ /** Pull a number-typed field off the parsed payload; non-numbers yield undefined. */
85
+ export function numberField(payload: ColdJson, key: string): number | undefined {
86
+ const value = payload[key];
87
+ return typeof value === "number" ? value : undefined;
88
+ }
89
+
90
+ /** Pull a boolean-typed field off the parsed payload; non-booleans yield undefined. */
91
+ export function booleanField(payload: ColdJson, key: string): boolean | undefined {
92
+ const value = payload[key];
93
+ return typeof value === "boolean" ? value : undefined;
94
+ }
95
+
96
+ /** Pull a plain-object field off the parsed payload; arrays/null/non-objects yield undefined. */
97
+ export function objectField(payload: ColdJson, key: string): ColdJson | undefined {
98
+ const value = payload[key];
99
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
100
+ return value as ColdJson;
101
+ }
102
+
103
+ /** Best-effort parse of stdout into a plain object; null on anything else. */
104
+ function parseObject(stdout: string): ColdJson | null {
105
+ let parsed: unknown;
106
+ try {
107
+ parsed = JSON.parse(stdout);
108
+ } catch {
109
+ return null;
110
+ }
111
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
112
+ return parsed as ColdJson;
113
+ }
114
+
115
+ /**
116
+ * Run a Python `--json` cold door and decode its success payload. The canonical seven-step flow:
117
+ * bin resolution → stdin staging → exec → envelope-aware killed/code check → JSON boundary →
118
+ * envelope check → validated decode. Never throws. The decode-null arm (a `success: true`
119
+ * envelope whose payload the door cannot narrow) names probable CLI↔extension version skew —
120
+ * the most likely cause when both planes are individually self-consistent.
121
+ */
122
+ export async function runColdDoor<T>(
123
+ pi: ExecHost,
124
+ ctx: ColdDoorCtx,
125
+ argv: string[],
126
+ opts: ColdDoorOpts<T>,
127
+ ): Promise<ColdDoorResult<T>> {
128
+ const perkBin = process.env.PERK_BIN ?? "perk";
129
+
130
+ // Stdin staging: pi.exec has no stdin channel → write the payload to a run-scoped scratch
131
+ // file (deliberately not cleaned up — run-scratch debuggability) and pass its path.
132
+ let fullArgv = argv;
133
+ if (opts.stdin !== undefined) {
134
+ try {
135
+ const dir = ensureRunScratch(ctx.cwd, activeRunId(ctx));
136
+ const path = join(dir, opts.stdin.filename);
137
+ writeFileSync(path, opts.stdin.content, "utf8");
138
+ fullArgv = [...argv, opts.stdin.flag, path];
139
+ } catch (err) {
140
+ return {
141
+ ok: false,
142
+ message: `could not stage input for ${opts.label}: ${String(err)}`,
143
+ errorType: "scratch_failed",
144
+ };
145
+ }
146
+ }
147
+
148
+ let res: ExecResult;
149
+ try {
150
+ res = await pi.exec(perkBin, fullArgv, { cwd: ctx.cwd, signal: ctx.signal });
151
+ } catch (err) {
152
+ return {
153
+ ok: false,
154
+ message: `could not run '${perkBin}': ${String(err)}`,
155
+ errorType: "exec_failed",
156
+ };
157
+ }
158
+
159
+ if (res.killed || res.code !== 0) {
160
+ // Envelope-aware: the Python plane prints {"success": false, error_type, message} to stdout
161
+ // before exiting non-zero — prefer that structured error over the generic fallback.
162
+ const parsed = parseObject(res.stdout);
163
+ if (parsed !== null && parsed.success === false) {
164
+ const message = stringField(parsed, "message");
165
+ const errorType = stringField(parsed, "error_type");
166
+ return {
167
+ ok: false,
168
+ message: message ?? fallbackExitMessage(opts.label, perkBin, res),
169
+ errorType: errorType ?? "exec_failed",
170
+ payload: parsed,
171
+ };
172
+ }
173
+ return {
174
+ ok: false,
175
+ message: fallbackExitMessage(opts.label, perkBin, res),
176
+ errorType: "exec_failed",
177
+ };
178
+ }
179
+
180
+ const parsed = parseObject(res.stdout);
181
+ if (parsed === null) {
182
+ return {
183
+ ok: false,
184
+ message: `${opts.label} returned unparseable JSON`,
185
+ errorType: "bad_output",
186
+ };
187
+ }
188
+
189
+ if (parsed.success !== true) {
190
+ return {
191
+ ok: false,
192
+ message: stringField(parsed, "message") ?? `${opts.label} reported failure`,
193
+ errorType: stringField(parsed, "error_type") ?? "github_error",
194
+ payload: parsed,
195
+ };
196
+ }
197
+
198
+ let data: T | null;
199
+ try {
200
+ data = opts.decode(parsed);
201
+ } catch {
202
+ data = null;
203
+ }
204
+ if (data === null) {
205
+ return {
206
+ ok: false,
207
+ message:
208
+ `${opts.label} reported success but returned an unexpected payload — the perk CLI and ` +
209
+ "the perk extension may be version-skewed (update/rebase so both planes match)",
210
+ errorType: "bad_output",
211
+ };
212
+ }
213
+ return { ok: true, data };
214
+ }
215
+
216
+ /** The byte-compatible fallback text today's doors emit on a non-zero exit. */
217
+ function fallbackExitMessage(
218
+ label: string,
219
+ perkBin: string,
220
+ res: Pick<ExecResult, "stderr" | "code">,
221
+ ): string {
222
+ const tail = res.stderr.trim();
223
+ return tail
224
+ ? `${label} failed (exit ${res.code}): ${tail}`
225
+ : `could not run '${perkBin}' (exit ${res.code}) — is the perk CLI on PATH or PERK_BIN set?`;
226
+ }
@@ -0,0 +1,339 @@
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
3
+ // setting consumed today is an optional `[workflow]` plan-authoring addendum, appended into the
4
+ // `perk:plan-context` injection (extension/factories/planMode.ts) when present.
5
+ //
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.
9
+ // Read-only, LBYL: a missing/unreadable file is `{}`; anything outside the subset is ignored.
10
+ // Dynamic `resources_discover` skill/prompt contribution is a flagged follow-up, not built here.
11
+
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { parseUserBindings, type SkillBinding } from "./bindings.ts";
15
+
16
+ const CONFIG_FILENAME = "perk.toml";
17
+ const LOCAL_CONFIG_FILENAME = "perk.local.toml";
18
+
19
+ /**
20
+ * One configured CI check (a `[[ci]]` array-of-tables row). `name`/`command` are required
21
+ * non-blank strings; an optional `glob` (a single comma-separated pattern string, e.g.
22
+ * `"*.ts,*.tsx"`) declares which changed files the check is relevant to — the read-only CI
23
+ * executor skips it on the run-all path when no changed file (vs trunk) matches.
24
+ */
25
+ export interface CiCheck {
26
+ name: string;
27
+ command: string;
28
+ glob?: string;
29
+ }
30
+
31
+ export interface PerkConfig {
32
+ /** Optional project-supplied plan-authoring addendum (`[workflow] plan_authoring = "..."`). */
33
+ planAuthoring?: string;
34
+ /**
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 ⇒ `[]`.
38
+ */
39
+ ci: CiCheck[];
40
+ /**
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
43
+ * value is injected as a per-call inline `model` override on that agent's `subagent` spawn; when
44
+ * a key is absent the agent's frontmatter `model` (in `.pi/agents/<name>.md`) is the default.
45
+ * (`subagents.agentOverrides` does NOT reach project agents — `pi-subagents`'
46
+ * `applyBuiltinOverrides` applies only to builtins — so this inline override is the mechanism.)
47
+ * Always-present object; absent keys omitted (mirror of `providers`).
48
+ */
49
+ subagents: {
50
+ "pr-reviewer"?: string;
51
+ "review-classifier"?: string;
52
+ "objective-explorer"?: string;
53
+ "conflict-resolver"?: string;
54
+ };
55
+ /**
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"`).
59
+ */
60
+ objectiveCompactThreshold?: number;
61
+ /** The `[[bindings]]` user overlay, resolved against shipped defaults downstream. */
62
+ bindings: SkillBinding[];
63
+ /**
64
+ * The flat `[providers]` per-seam selection — bare provider-id strings pointing into
65
+ * `shared/providers.yaml`. Absent keys mean “use the seam default”; resolution against the
66
+ * supported set is a downstream concern.
67
+ */
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 };
78
+ }
79
+
80
+ /** A nested string table: `{ section: { key: value } }` (the only shape perk reads today). */
81
+ type StringTable = Record<string, Record<string, string>>;
82
+
83
+ /**
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.
86
+ */
87
+ interface TomlSubset {
88
+ tables: StringTable;
89
+ arrays: Record<string, Array<Record<string, string>>>;
90
+ }
91
+
92
+ function unescapeBasic(raw: string): string {
93
+ return raw
94
+ .replace(/\\n/g, "\n")
95
+ .replace(/\\t/g, "\t")
96
+ .replace(/\\"/g, '"')
97
+ .replace(/\\\\/g, "\\");
98
+ }
99
+
100
+ /**
101
+ * 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.
105
+ */
106
+ 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>>> = {};
110
+ // The current write target for `key = value` lines (a section table or an array-of-tables row).
111
+ let dest: Record<string, string> = root;
112
+ const lines = text.split(/\r?\n/);
113
+ for (let i = 0; i < lines.length; i++) {
114
+ const line = (lines[i] ?? "").trim();
115
+ if (line === "" || line.startsWith("#")) continue;
116
+
117
+ // `[[name]]` array-of-tables must be detected BEFORE the `[section]` header (it also matches).
118
+ const arrayHeader = line.match(/^\[\[([^\]]+)\]\]$/);
119
+ if (arrayHeader) {
120
+ const name = (arrayHeader[1] ?? "").trim();
121
+ const row: Record<string, string> = {};
122
+ let rows = arrays[name];
123
+ if (!rows) {
124
+ rows = [];
125
+ arrays[name] = rows;
126
+ }
127
+ rows.push(row);
128
+ dest = row;
129
+ continue;
130
+ }
131
+
132
+ const header = line.match(/^\[([^\]]+)\]$/);
133
+ if (header) {
134
+ const section = (header[1] ?? "").trim();
135
+ if (!tables[section]) tables[section] = {};
136
+ dest = tables[section];
137
+ continue;
138
+ }
139
+
140
+ const eq = line.indexOf("=");
141
+ if (eq === -1) continue;
142
+ const key = line.slice(0, eq).trim();
143
+ const value = line.slice(eq + 1).trim();
144
+ if (key === "") continue;
145
+
146
+ // Multi-line basic string: """ ... """ (possibly spanning lines).
147
+ if (value.startsWith('"""')) {
148
+ let body = value.slice(3);
149
+ if (body.endsWith('"""') && body.length >= 3) {
150
+ body = body.slice(0, -3);
151
+ } else {
152
+ const parts: string[] = [body];
153
+ i++;
154
+ for (; i < lines.length; i++) {
155
+ const raw = lines[i] ?? "";
156
+ const end = raw.indexOf('"""');
157
+ if (end !== -1) {
158
+ // A bare closing delimiter on its own line contributes no trailing content (so the
159
+ // newline that precedes it is not appended as an empty segment).
160
+ if (end > 0) parts.push(raw.slice(0, end));
161
+ break;
162
+ }
163
+ parts.push(raw);
164
+ }
165
+ body = parts.join("\n");
166
+ // A leading newline immediately after the opening delimiter is trimmed (TOML rule).
167
+ if (body.startsWith("\n")) body = body.slice(1);
168
+ }
169
+ dest[key] = unescapeBasic(body);
170
+ continue;
171
+ }
172
+
173
+ // Single-line basic string: "..." (strip a trailing inline comment outside the quotes).
174
+ const basic = value.match(/^"((?:[^"\\]|\\.)*)"/);
175
+ if (basic) {
176
+ dest[key] = unescapeBasic(basic[1] ?? "");
177
+ }
178
+ // Non-string scalars are intentionally ignored (perk reads only strings today).
179
+ }
180
+ return { tables, arrays };
181
+ }
182
+
183
+ function emptySubset(): TomlSubset {
184
+ return { tables: { "": {} }, arrays: {} };
185
+ }
186
+
187
+ function readTomlFile(path: string): TomlSubset {
188
+ if (!existsSync(path)) return emptySubset();
189
+ try {
190
+ return parseTomlSubset(readFileSync(path, "utf8"));
191
+ } catch (error) {
192
+ // Loud-but-non-fatal: a malformed config never blocks the session.
193
+ console.error(`perk: ignoring malformed ${path} — ${error}`);
194
+ return emptySubset();
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Overlay `over` onto `base` (local wins). Section tables merge leaf-by-leaf; array-of-tables
200
+ * replace as a whole array (mirror of perk/substrate/config.py's list-replaces-list overlay).
201
+ */
202
+ function overlay(base: TomlSubset, over: TomlSubset): TomlSubset {
203
+ const tables: StringTable = {};
204
+ for (const [section, kv] of Object.entries(base.tables)) tables[section] = { ...kv };
205
+ for (const [section, kv] of Object.entries(over.tables)) {
206
+ tables[section] = { ...(tables[section] ?? {}), ...kv };
207
+ }
208
+ const arrays: Record<string, Array<Record<string, string>>> = { ...base.arrays };
209
+ for (const [name, rows] of Object.entries(over.arrays)) arrays[name] = rows;
210
+ return { tables, arrays };
211
+ }
212
+
213
+ /** Load `.pi/perk.toml` overlaid by `.pi/perk.local.toml` from `cwd` (mirror of perk/substrate/config.py). */
214
+ export function loadPerkConfig(cwd: string): PerkConfig {
215
+ const piDir = join(cwd, ".pi");
216
+ let merged: TomlSubset = emptySubset();
217
+ for (const name of [CONFIG_FILENAME, LOCAL_CONFIG_FILENAME]) {
218
+ merged = overlay(merged, readTomlFile(join(piDir, name)));
219
+ }
220
+
221
+ 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;
224
+ const objectiveCompactThreshold =
225
+ Number.isFinite(parsedThreshold) && parsedThreshold > 0 && parsedThreshold <= 1
226
+ ? parsedThreshold
227
+ : undefined;
228
+ return {
229
+ planAuthoring:
230
+ typeof planAuthoring === "string" && planAuthoring.trim() ? planAuthoring : undefined,
231
+ ci: parseCiChecks(merged.arrays.ci ?? []),
232
+ subagents: parseSubagentsSelection(merged.tables.subagents),
233
+ objectiveCompactThreshold,
234
+ bindings: parseUserBindings(merged.arrays.bindings ?? []),
235
+ providers: parseProvidersSelection(merged.tables.providers),
236
+ trust: parseTrustSelection(merged.tables.trust),
237
+ };
238
+ }
239
+
240
+ /**
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`).
244
+ */
245
+ export function parseCiChecks(rows: Array<Record<string, string>>): CiCheck[] {
246
+ const checks: CiCheck[] = [];
247
+ for (const row of rows) {
248
+ const name = row.name;
249
+ const command = row.command;
250
+ if (typeof name !== "string" || !name.trim()) continue;
251
+ if (typeof command !== "string" || !command.trim()) continue;
252
+ const check: CiCheck = { name, command };
253
+ const glob = row.glob;
254
+ if (typeof glob === "string" && glob.trim()) check.glob = glob;
255
+ checks.push(check);
256
+ }
257
+ return checks;
258
+ }
259
+
260
+ /** The perk-owned project agents configurable via the `[subagents]` table. */
261
+ const SUBAGENT_KEYS = [
262
+ "pr-reviewer",
263
+ "review-classifier",
264
+ "objective-explorer",
265
+ "conflict-resolver",
266
+ ] as const;
267
+
268
+ /**
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`).
272
+ */
273
+ function parseSubagentsSelection(
274
+ table: Record<string, string> | undefined,
275
+ ): PerkConfig["subagents"] {
276
+ const selection: PerkConfig["subagents"] = {};
277
+ for (const key of SUBAGENT_KEYS) {
278
+ const value = table?.[key];
279
+ if (typeof value === "string" && value.trim()) selection[key] = value;
280
+ }
281
+ return selection;
282
+ }
283
+
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): {
294
+ plan?: string;
295
+ todo?: string;
296
+ askuser?: string;
297
+ footer?: string;
298
+ web?: string;
299
+ } {
300
+ const selection: {
301
+ plan?: string;
302
+ todo?: string;
303
+ askuser?: string;
304
+ footer?: string;
305
+ web?: string;
306
+ } = {};
307
+ if (typeof table?.plan === "string") selection.plan = table.plan;
308
+ if (typeof table?.todo === "string") selection.todo = table.todo;
309
+ if (typeof table?.askuser === "string") selection.askuser = table.askuser;
310
+ if (typeof table?.footer === "string") selection.footer = table.footer;
311
+ if (typeof table?.web === "string") selection.web = table.web;
312
+ return selection;
313
+ }
314
+
315
+ /** The `[issues] backend` vocabulary (contracts.md §8.21). */
316
+ export type IssueBackendId = "github" | "linear";
317
+
318
+ export const GITHUB_ISSUE_BACKEND_ID: IssueBackendId = "github";
319
+
320
+ /**
321
+ * The fail-safe TS mirror of the issue-backend selection.
322
+ *
323
+ * Reads ONLY committed `.pi/perk.toml` — deliberately not `loadPerkConfig`'s overlay, mirroring
324
+ * 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
326
+ * (`perk/backends/issues.py::resolve_issue_backend_id`) is the AUTHORITATIVE validator and **raises** on
327
+ * "linear"/unknown; this mirror is fail-safe (absence/unknown/any error → `"github"`) because
328
+ * the TS plane only renders prompts — it never writes canonical issues.
329
+ */
330
+ export function resolveIssueBackendId(cwd: string): IssueBackendId {
331
+ try {
332
+ const committed = readTomlFile(join(cwd, ".pi", CONFIG_FILENAME));
333
+ const backend = committed.tables.issues?.backend;
334
+ if (backend === "github" || backend === "linear") return backend;
335
+ return GITHUB_ISSUE_BACKEND_ID;
336
+ } catch {
337
+ return GITHUB_ISSUE_BACKEND_ID;
338
+ }
339
+ }