@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.
- package/README.md +67 -57
- package/extension/adapters/planAdapterPlannotator.ts +27 -41
- package/extension/adapters/planAdapterTombell.ts +15 -28
- package/extension/adapters/todoAdapterJuicesharp.ts +10 -13
- package/extension/checkpoints/checkpoints.ts +21 -13
- package/extension/doors/address.ts +6 -5
- package/extension/doors/askUser.ts +12 -8
- package/extension/doors/ciExecutor.ts +23 -15
- package/extension/doors/hunkHandoff.ts +202 -0
- package/extension/doors/land.ts +33 -10
- package/extension/doors/learn.ts +240 -34
- package/extension/doors/learnFactory.ts +144 -0
- package/extension/doors/lifecycleGates.ts +2 -1
- package/extension/doors/plannotatorHandoff.ts +509 -0
- package/extension/doors/prReview.ts +22 -37
- package/extension/doors/prReviewBrowser.ts +341 -0
- package/extension/doors/prReviewTerminal.ts +267 -0
- package/extension/doors/ready.ts +2 -1
- package/extension/doors/selfcheck.ts +240 -6
- package/extension/doors/submit.ts +37 -19
- package/extension/doors/submitPrReview.ts +408 -0
- package/extension/factories/implementHere.ts +116 -0
- package/extension/factories/objective.ts +17 -6
- package/extension/factories/objectiveAuthor.ts +15 -32
- package/extension/factories/objectiveDraft.ts +1 -1
- package/extension/factories/objectivePlan.ts +16 -34
- package/extension/factories/objectiveSave.ts +7 -17
- package/extension/factories/planMode.ts +24 -38
- package/extension/factories/planReview.ts +291 -177
- package/extension/factories/planSave.ts +23 -8
- package/extension/index.ts +117 -20
- package/extension/substrate/bindingDelivery.ts +32 -10
- package/extension/substrate/bindings.ts +4 -2
- package/extension/substrate/cache.ts +36 -9
- package/extension/substrate/clipboard.ts +81 -0
- package/extension/substrate/command.ts +25 -0
- package/extension/substrate/config.ts +98 -76
- package/extension/substrate/consoleCapture.ts +90 -0
- package/extension/substrate/git.ts +77 -0
- package/extension/substrate/miniJinja.ts +480 -0
- package/extension/substrate/paths.ts +38 -0
- package/extension/substrate/prompts.ts +15 -24
- package/extension/substrate/providers.ts +62 -8
- package/extension/substrate/sessionData.ts +1 -1
- package/extension/substrate/sessionPointers.ts +184 -0
- package/extension/substrate/structuredOutput.ts +3 -1
- package/extension/substrate/terminalLaunch.ts +178 -0
- package/extension/substrate/toolGating.ts +332 -73
- package/extension/substrate/toolParams.ts +7 -0
- package/extension/substrate/workflowState.ts +54 -2
- package/extension/surfaces/footerProvider.ts +8 -4
- package/extension/surfaces/surfaces.ts +338 -13
- package/extension/vendor/btw/btw.ts +10 -0
- package/extension/worker/readOnlySession.ts +19 -6
- package/extension/worker/worker.ts +187 -31
- package/extension/workerMain.ts +14 -13
- package/package.json +3 -7
- package/prompts/README.md +56 -5
- package/prompts/_fixtures/cases.yaml +52 -131
- package/prompts/_fixtures/golden/cond_elif-a.txt +3 -0
- package/prompts/_fixtures/golden/cond_elif-b.txt +3 -0
- package/prompts/_fixtures/golden/cond_elif-c.txt +3 -0
- package/prompts/_fixtures/golden/cond_if-false.txt +3 -0
- package/prompts/_fixtures/golden/cond_if-true.txt +3 -0
- package/prompts/_fixtures/golden/cond_ops-1.txt +3 -0
- package/prompts/_fixtures/golden/cond_ops-2.txt +3 -0
- package/prompts/_fixtures/golden/no_trailing_nl.txt +2 -0
- package/prompts/_fixtures/golden/trailing_nl.txt +2 -0
- package/prompts/_fixtures/golden/trim_block.txt +3 -0
- package/prompts/_fixtures/golden/trim_inline.txt +1 -0
- package/prompts/_fixtures/live.yaml +396 -0
- package/prompts/_fixtures/templates/cond_elif.md +9 -0
- package/prompts/_fixtures/templates/cond_if.md +7 -0
- package/prompts/_fixtures/templates/cond_ops.md +3 -0
- package/prompts/_fixtures/templates/no_trailing_nl.md +2 -0
- package/prompts/_fixtures/templates/trailing_nl.md +2 -0
- package/prompts/_fixtures/templates/trim_block.md +5 -0
- package/prompts/_fixtures/templates/trim_inline.md +1 -0
- package/prompts/contexts/adapters/juicesharp-todo.md +7 -0
- package/prompts/contexts/adapters/plannotator-objective.md +7 -0
- package/prompts/contexts/adapters/plannotator-plan.md +6 -0
- package/prompts/contexts/adapters/tombell-plan.md +17 -0
- package/prompts/contexts/objective-authoring.md +20 -0
- package/prompts/contexts/plan-authoring.md +24 -0
- package/prompts/contexts/read-only.md +10 -0
- package/prompts/stages/conflict-resolution.md +4 -0
- package/prompts/stages/learn-code.md +8 -0
- package/prompts/stages/learn-docs.md +7 -6
- package/prompts/stages/learn-orchestrate.md +6 -0
- package/prompts/stages/learn.md +1 -1
- package/prompts/stages/objective-author/adopt.md +12 -0
- package/prompts/stages/objective-author/file.md +9 -0
- package/prompts/stages/objective-author/seed.md +9 -0
- package/prompts/stages/objective-plan/guidance.md +1 -1
- package/prompts/stages/objective-plan/seed.md +3 -2
- package/prompts/stages/objective-reconcile.md +7 -0
- package/prompts/stages/objective-replan.md +14 -0
- package/prompts/stages/objective-save.md +9 -0
- package/prompts/stages/plan-from/adopt.md +10 -0
- package/prompts/stages/plan-from/file.md +9 -0
- package/prompts/stages/pr-review-browser/active.md +11 -0
- package/prompts/stages/pr-review-browser/foreign.md +11 -0
- package/prompts/stages/pr-review-terminal/active.md +12 -0
- package/prompts/stages/pr-review-terminal/foreign.md +13 -0
- package/prompts/stages/pr-review-terminal/local.md +4 -0
- package/prompts/stages/pr-review.md +6 -0
- package/prompts/stages/replan.md +13 -0
- package/prompts/stages/skills/create-from.md +15 -0
- package/prompts/stages/skills/create.md +9 -0
- package/prompts/stages/skills/refine.md +9 -0
- package/shared/README.md +23 -13
- package/shared/bindings.yaml +22 -2
- package/shared/contracts-history.md +191 -0
- package/shared/contracts.md +2258 -1638
- package/shared/providers.yaml +8 -1
- package/shared/registry.yaml +10 -11
- package/shared/schemas/contracts/bindings.schema.json +38 -0
- package/shared/schemas/contracts/providers.schema.json +89 -0
- package/shared/schemas/contracts/registry.schema.json +98 -0
- package/shared/schemas/inputs/handoff-arg.schema.json +6 -0
- package/shared/schemas/inputs/resolve-threads-batch.schema.json +37 -0
- package/shared/schemas/inputs/review-post-batch.schema.json +84 -0
- package/shared/schemas/inputs/review-submit-batch.schema.json +66 -0
- package/shared/schemas/inputs/structured-roadmap-node.schema.json +102 -0
- package/shared/schemas/outputs/doctor-report.schema.json +236 -0
- package/shared/schemas/outputs/init-report.schema.json +419 -0
- package/shared/schemas/outputs/learn-capture.schema.json +90 -0
- package/shared/schemas/outputs/learn-skip.schema.json +59 -0
- package/shared/schemas/outputs/plan-save.schema.json +209 -0
- package/shared/schemas/outputs/pr-feedback.schema.json +334 -0
- package/shared/schemas/outputs/pr-land.schema.json +187 -0
- package/shared/schemas/outputs/pr-ready.schema.json +75 -0
- package/shared/schemas/outputs/pr-review-checkout.schema.json +69 -0
- package/shared/schemas/outputs/pr-review-cleanup.schema.json +54 -0
- package/shared/schemas/outputs/pr-review-context.schema.json +86 -0
- package/shared/schemas/outputs/pr-review-submit.schema.json +64 -0
- package/shared/schemas/outputs/pr-submit.schema.json +147 -0
- package/extension/doors/learnDocs.ts +0 -99
- package/prompts/_fixtures/golden/address-action-model.txt +0 -10
- package/prompts/_fixtures/golden/address-action.txt +0 -10
- package/prompts/_fixtures/golden/address-preview-model.txt +0 -6
- package/prompts/_fixtures/golden/address-preview.txt +0 -6
- package/prompts/_fixtures/golden/implement-github.txt +0 -8
- package/prompts/_fixtures/golden/learn-docs.txt +0 -8
- package/prompts/_fixtures/golden/learn-github.txt +0 -11
- package/prompts/_fixtures/golden/learn-linear.txt +0 -11
- package/prompts/_fixtures/golden/learn-no-ref.txt +0 -8
- package/prompts/_fixtures/golden/learn-other.txt +0 -8
- package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +0 -8
- package/prompts/_fixtures/golden/objective-plan-guidance.txt +0 -8
- package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +0 -20
- package/prompts/_fixtures/golden/objective-plan-seed.txt +0 -15
- package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +0 -1
- package/prompts/_fixtures/golden/objective-read-linear.txt +0 -1
- package/prompts/_fixtures/golden/plan-read-github.txt +0 -1
- package/prompts/_fixtures/golden/plan-read-linear.txt +0 -1
- 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
|
-
// `.
|
|
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
|
|
8
|
-
// `[
|
|
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
|
-
|
|
17
|
-
|
|
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 `[
|
|
36
|
-
* read-only CI executor consumes
|
|
37
|
-
* `
|
|
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`
|
|
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 `[
|
|
57
|
-
* threshold compaction while an objective is active.
|
|
58
|
-
*
|
|
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: {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
81
|
-
type
|
|
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
|
|
85
|
-
* array-of-tables (each row a
|
|
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:
|
|
89
|
-
arrays: Record<string, Array<Record<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:
|
|
103
|
-
* `{ name: [{ key:
|
|
104
|
-
*
|
|
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,
|
|
108
|
-
const tables:
|
|
109
|
-
const arrays: Record<string, Array<Record<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,
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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 `.
|
|
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
|
|
218
|
-
merged = overlay(merged, readTomlFile(
|
|
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
|
-
|
|
223
|
-
const
|
|
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
|
-
|
|
226
|
-
?
|
|
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:
|
|
232
|
-
|
|
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
|
|
242
|
-
* `name` and `command` are non-blank strings; `glob` is kept only when a non-blank string.
|
|
243
|
-
* order is preserved; ill-typed rows are silently dropped (mirror of
|
|
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,
|
|
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
|
|
270
|
-
* agent key, the value is kept only when it is a non-blank string; absent/ill-typed/unknown
|
|
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,
|
|
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 `[
|
|
285
|
-
function
|
|
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 `.
|
|
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
|
|
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(
|
|
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
|
+
}
|