// The minimal TS config port. Mirrors `perk/substrate/config.py`'s overlay: read // `.perk/config.toml` (committed) overlaid by `.perk/local.toml` (gitignored, local wins). The only // setting consumed today is an optional `[workflow]` plan-authoring addendum, appended into the // `perk:plan-context` injection (extension/factories/planMode.ts) when present. // // Deliberately dependency-free: rather than pull a runtime TOML dependency into the published // extension, this reads the narrow TOML subset perk actually uses — `[section]` headers + // `[[name]]` array-of-tables + `key = "basic"` / `key = """multiline"""` strings + native // booleans/numbers + `#` comments. // Read-only, LBYL: a missing/unreadable file is `{}`; anything outside the subset is ignored. // Dynamic `resources_discover` skill/prompt contribution is a flagged follow-up, not built here. import { existsSync, readFileSync } from "node:fs"; import { parseUserBindings, type SkillBinding } from "./bindings.ts"; import { mainCheckoutRoot } from "./git.ts"; import { configFile, localConfigFile } from "./paths.ts"; /** * A TOML scalar the subset parser reads: quoted strings plus native booleans and numbers. * Anything else (dates, arrays, inline tables) is still deliberately ignored. */ export type TomlScalar = string | boolean | number; /** * One configured CI check (a `[[ci.checks]]` array-of-tables row). `name`/`command` are required * non-blank strings; an optional `glob` (a single comma-separated pattern string, e.g. * `"*.ts,*.tsx"`) declares which changed files the check is relevant to — the read-only CI * executor skips it on the run-all path when no changed file (vs trunk) matches. */ export interface CiCheck { name: string; command: string; glob?: string; } export interface PerkConfig { /** Optional project-supplied plan-authoring addendum (`[workflow] plan_authoring = "..."`). */ planAuthoring?: string; /** * The `[ci]` verification namespace. `checks` is the `[[ci.checks]]` ordered array-of-tables * (each row name/command/optional glob) the read-only CI executor consumes; absent/empty ⇒ `[]`. * `trusted` (`[ci] trusted = true`, a native boolean) declares those project-supplied checks * trusted, so the executor runs them WITHOUT a per-session confirm on every surface, including * headless (it overrides the fail-closed refuse). Absent/`false`/non-boolean ⇒ untrusted * (confirm with UI; refuse headless). Always present; defaults `{trusted: false, checks: []}`. */ ci: { trusted: boolean; checks: CiCheck[] }; /** * The agent-keyed `[models.subagents]` table: a per-agent model override for each perk-owned * project agent (`pr-reviewer`, `review-classifier`, `objective-explorer`, `conflict-resolver`, * `learn-analyst`, `adversarial-reviewer`). Each configured * value is injected as a per-call inline `model` override on that agent's `subagent` spawn; when * a key is absent the agent's frontmatter `model` (in `.pi/agents/.md`) is the default. * (`subagents.agentOverrides` does NOT reach project agents — `pi-subagents`' * `applyBuiltinOverrides` applies only to builtins — so this inline override is the mechanism.) * A value may carry a `:thinking` suffix (`"anthropic/claude-sonnet-4-5:high"`) or be the * `"inherit"` sentinel (child inherits the parent session's model) — both resolved by * pi-subagents on the inline override (the last-colon segment counts as thinking only when it * is a pi level, so ollama-style tags stay part of the model id). * Always-present object; absent keys omitted (mirror of `providers`). */ subagents: { "pr-reviewer"?: string; "review-classifier"?: string; "objective-explorer"?: string; "conflict-resolver"?: string; "learn-analyst"?: string; "adversarial-reviewer"?: string; }; /** * Optional `[compaction] objective_threshold` — the context-usage fraction (0,1] that triggers * threshold compaction while an objective is active. A native TOML float (e.g. * `objective_threshold = 0.8`); string/out-of-range values are ignored. The Python plane * deliberately ignores this key (it converges the rest of `[compaction]` into settings). */ objectiveCompactThreshold?: number; /** The `[[bindings]]` user overlay, resolved against shipped defaults downstream. */ bindings: SkillBinding[]; /** * The flat `[providers]` per-seam selection — bare provider-id strings pointing into * `shared/providers.yaml`. Absent keys mean “use the seam default”; resolution against the * supported set is a downstream concern. */ providers: { plan?: string; todo?: string; askuser?: string; footer?: string; web?: string; }; } /** A nested scalar table: `{ section: { key: scalar } }` (dotted section names kept literal). */ type ScalarTable = Record>; /** * The narrow TOML subset perk reads: `[section]`/top-level scalar tables plus `[[name]]` * array-of-tables (each row a scalar table). Mirrors `tomllib`'s shape for the keys perk uses. */ interface TomlSubset { tables: ScalarTable; arrays: Record>>; } function unescapeBasic(raw: string): string { return raw .replace(/\\n/g, "\n") .replace(/\\t/g, "\t") .replace(/\\"/g, '"') .replace(/\\\\/g, "\\"); } /** * Parse the narrow TOML subset perk consumes. Returns `{ tables, arrays }`: `tables` is a * `{ section: { key: scalar } }` map (top-level keys under the `""` section); `arrays` is a * `{ name: [{ key: scalar }, ...] }` map fed by `[[name]]` array-of-tables. Scalars are quoted * strings, native `true`/`false` booleans, and numeric literals; anything else is skipped — * this is intentionally NOT a full TOML parser. */ export function parseTomlSubset(text: string): TomlSubset { const root: Record = {}; const tables: ScalarTable = { "": root }; const arrays: Record>> = {}; // The current write target for `key = value` lines (a section table or an array-of-tables row). let dest: Record = root; const lines = text.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const line = (lines[i] ?? "").trim(); if (line === "" || line.startsWith("#")) continue; // `[[name]]` array-of-tables must be detected BEFORE the `[section]` header (it also matches). const arrayHeader = line.match(/^\[\[([^\]]+)\]\]$/); if (arrayHeader) { const name = (arrayHeader[1] ?? "").trim(); const row: Record = {}; let rows = arrays[name]; if (!rows) { rows = []; arrays[name] = rows; } rows.push(row); dest = row; continue; } const header = line.match(/^\[([^\]]+)\]$/); if (header) { const section = (header[1] ?? "").trim(); if (!tables[section]) tables[section] = {}; dest = tables[section]; continue; } const eq = line.indexOf("="); if (eq === -1) continue; const key = line.slice(0, eq).trim(); const value = line.slice(eq + 1).trim(); if (key === "") continue; // Multi-line basic string: """ ... """ (possibly spanning lines). if (value.startsWith('"""')) { let body = value.slice(3); if (body.endsWith('"""') && body.length >= 3) { body = body.slice(0, -3); } else { const parts: string[] = [body]; i++; for (; i < lines.length; i++) { const raw = lines[i] ?? ""; const end = raw.indexOf('"""'); if (end !== -1) { // A bare closing delimiter on its own line contributes no trailing content (so the // newline that precedes it is not appended as an empty segment). if (end > 0) parts.push(raw.slice(0, end)); break; } parts.push(raw); } body = parts.join("\n"); // A leading newline immediately after the opening delimiter is trimmed (TOML rule). if (body.startsWith("\n")) body = body.slice(1); } dest[key] = unescapeBasic(body); continue; } // Single-line basic string: "..." (strip a trailing inline comment outside the quotes). const basic = value.match(/^"((?:[^"\\]|\\.)*)"/); if (basic) { dest[key] = unescapeBasic(basic[1] ?? ""); continue; } // Unquoted scalar: strip an inline `#` comment, then read native booleans and numbers. const hash = value.indexOf("#"); const bare = (hash === -1 ? value : value.slice(0, hash)).trim(); if (bare === "true" || bare === "false") { dest[key] = bare === "true"; continue; } if (/^[+-]?\d[\d_]*(\.[\d_]+)?([eE][+-]?\d+)?$/.test(bare)) { const parsed = Number(bare.replace(/_/g, "")); if (Number.isFinite(parsed)) dest[key] = parsed; } // Other value shapes (dates, arrays, inline tables) are intentionally ignored. } return { tables, arrays }; } function emptySubset(): TomlSubset { return { tables: { "": {} }, arrays: {} }; } function readTomlFile(path: string): TomlSubset { if (!existsSync(path)) return emptySubset(); try { return parseTomlSubset(readFileSync(path, "utf8")); } catch (error) { // Loud-but-non-fatal: a malformed config never blocks the session. console.error(`perk: ignoring malformed ${path} — ${error}`); return emptySubset(); } } /** * Overlay `over` onto `base` (local wins). Section tables merge leaf-by-leaf; array-of-tables * replace as a whole array (mirror of perk/substrate/config.py's list-replaces-list overlay). */ function overlay(base: TomlSubset, over: TomlSubset): TomlSubset { const tables: ScalarTable = {}; for (const [section, kv] of Object.entries(base.tables)) tables[section] = { ...kv }; for (const [section, kv] of Object.entries(over.tables)) { tables[section] = { ...(tables[section] ?? {}), ...kv }; } const arrays: Record>> = { ...base.arrays }; for (const [name, rows] of Object.entries(over.arrays)) arrays[name] = rows; return { tables, arrays }; } /** Load `.perk/config.toml` overlaid by `.perk/local.toml` from `cwd` (mirror of perk/substrate/config.py). */ export function loadPerkConfig(cwd: string): PerkConfig { let merged: TomlSubset = emptySubset(); for (const file of [configFile(cwd), localConfigFile(cwd)]) { merged = overlay(merged, readTomlFile(file)); } const planAuthoring = merged.tables.workflow?.plan_authoring; // `[compaction] objective_threshold` is a native float in (0,1]; strings/out-of-range ignored. const rawThreshold = merged.tables.compaction?.objective_threshold; const objectiveCompactThreshold = typeof rawThreshold === "number" && rawThreshold > 0 && rawThreshold <= 1 ? rawThreshold : undefined; return { planAuthoring: typeof planAuthoring === "string" && planAuthoring.trim() ? planAuthoring : undefined, ci: { trusted: merged.tables.ci?.trusted === true, checks: parseCiChecks(merged.arrays["ci.checks"] ?? []), }, subagents: parseSubagentsSelection(merged.tables["models.subagents"]), objectiveCompactThreshold, bindings: parseUserBindings(merged.arrays.bindings ?? []), providers: parseProvidersSelection(merged.tables.providers), }; } /** * Read the `[[ci.checks]]` array-of-tables into an ordered `CiCheck[]`. A row is kept only when * both `name` and `command` are non-blank strings; `glob` is kept only when a non-blank string. * Declared order is preserved; ill-typed rows are silently dropped (mirror of * `parseProvidersSelection`). */ export function parseCiChecks(rows: Array>): CiCheck[] { const checks: CiCheck[] = []; for (const row of rows) { const name = row.name; const command = row.command; if (typeof name !== "string" || !name.trim()) continue; if (typeof command !== "string" || !command.trim()) continue; const check: CiCheck = { name, command }; const glob = row.glob; if (typeof glob === "string" && glob.trim()) check.glob = glob; checks.push(check); } return checks; } /** The perk-owned project agents configurable via the `[models.subagents]` table. */ const SUBAGENT_KEYS = [ "pr-reviewer", "review-classifier", "objective-explorer", "conflict-resolver", "learn-analyst", "adversarial-reviewer", ] as const; /** * Read the agent-keyed `[models.subagents]` table into a selection (string values only). For each * known agent key, the value is kept only when it is a non-blank string; absent/ill-typed/unknown * keys are omitted (mirror of `parseProvidersSelection`). */ function parseSubagentsSelection( table: Record | undefined, ): PerkConfig["subagents"] { const selection: PerkConfig["subagents"] = {}; for (const key of SUBAGENT_KEYS) { const value = table?.[key]; if (typeof value === "string" && value.trim()) selection[key] = value; } return selection; } /** 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). */ function parseProvidersSelection(table: Record | undefined): { plan?: string; todo?: string; askuser?: string; footer?: string; web?: string; } { const selection: { plan?: string; todo?: string; askuser?: string; footer?: string; web?: string; } = {}; if (typeof table?.plan === "string") selection.plan = table.plan; if (typeof table?.todo === "string") selection.todo = table.todo; if (typeof table?.askuser === "string") selection.askuser = table.askuser; if (typeof table?.footer === "string") selection.footer = table.footer; if (typeof table?.web === "string") selection.web = table.web; return selection; } /** The `[issues] backend` vocabulary (contracts.md §8.21). */ export type IssueBackendId = "github" | "linear"; export const GITHUB_ISSUE_BACKEND_ID: IssueBackendId = "github"; /** * The fail-safe TS mirror of the issue-backend selection. * * Reads ONLY committed `.perk/config.toml` — deliberately not `loadPerkConfig`'s overlay, mirroring * the Python committed-only read (the backend decides where canonical durable state is written; * a per-user `.perk/local.toml` override would fragment the canonical store). The read is * anchored to the MAIN checkout via `mainCheckoutRoot` (fail-open: `cwd` outside a git repo), * mirroring Python's main-worktree anchoring — a linked worktree's checkout state (detached / * stale branch / missing `.perk/`) must never flip a Linear repo's prompt clauses to GitHub. * Python (`perk/backends/resolve.py::resolve_issue_backend_id`) is the AUTHORITATIVE validator and * **raises** on unknown values; this mirror is fail-safe (absence/unknown/any error → `"github"`) * because the TS plane only renders prompts — it never writes canonical issues. */ export function resolveIssueBackendId(cwd: string): IssueBackendId { try { const committed = readTomlFile(configFile(mainCheckoutRoot(cwd))); const backend = committed.tables.issues?.backend; if (backend === "github" || backend === "linear") return backend; return GITHUB_ISSUE_BACKEND_ID; } catch { return GITHUB_ISSUE_BACKEND_ID; } }