@mgiles/perk 1.1.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -44
- 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 +19 -12
- package/extension/doors/address.ts +4 -4
- package/extension/doors/askUser.ts +12 -8
- package/extension/doors/ciExecutor.ts +21 -14
- package/extension/doors/hunkHandoff.ts +202 -0
- package/extension/doors/land.ts +31 -9
- package/extension/doors/learn.ts +2 -2
- package/extension/doors/learnFactory.ts +144 -0
- package/extension/doors/plannotatorHandoff.ts +509 -0
- package/extension/doors/prReview.ts +4 -4
- package/extension/doors/prReviewBrowser.ts +341 -0
- package/extension/doors/prReviewTerminal.ts +267 -0
- package/extension/doors/selfcheck.ts +238 -5
- package/extension/doors/submit.ts +20 -0
- package/extension/doors/submitPrReview.ts +408 -0
- package/extension/factories/objective.ts +15 -5
- package/extension/factories/objectiveAuthor.ts +15 -32
- package/extension/factories/objectiveDraft.ts +1 -1
- package/extension/factories/objectivePlan.ts +12 -10
- package/extension/factories/objectiveSave.ts +2 -2
- package/extension/factories/planMode.ts +22 -40
- package/extension/factories/planReview.ts +213 -191
- package/extension/factories/planSave.ts +7 -7
- package/extension/index.ts +83 -25
- package/extension/substrate/bindingDelivery.ts +32 -10
- package/extension/substrate/bindings.ts +4 -2
- package/extension/substrate/cache.ts +34 -7
- package/extension/substrate/clipboard.ts +81 -0
- package/extension/substrate/config.ts +97 -70
- package/extension/substrate/git.ts +43 -0
- package/extension/substrate/paths.ts +1 -1
- package/extension/substrate/prompts.ts +2 -2
- package/extension/substrate/providers.ts +62 -8
- package/extension/substrate/sessionPointers.ts +35 -6
- package/extension/substrate/structuredOutput.ts +3 -1
- package/extension/substrate/terminalLaunch.ts +178 -0
- package/extension/substrate/toolGating.ts +330 -79
- 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 +330 -12
- package/extension/vendor/btw/btw.ts +10 -0
- package/extension/worker/readOnlySession.ts +19 -6
- package/extension/worker/worker.ts +77 -7
- package/extension/workerMain.ts +12 -13
- package/package.json +3 -3
- package/prompts/_fixtures/live.yaml +117 -2
- 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 +1 -1
- package/prompts/stages/learn-code.md +1 -1
- package/prompts/stages/learn-docs.md +2 -2
- package/prompts/stages/learn-orchestrate.md +1 -1
- package/prompts/stages/objective-author/adopt.md +1 -1
- package/prompts/stages/objective-author/file.md +1 -1
- package/prompts/stages/objective-plan/guidance.md +1 -1
- package/prompts/stages/objective-plan/seed.md +1 -1
- package/prompts/stages/objective-reconcile.md +1 -1
- package/prompts/stages/objective-replan.md +1 -1
- package/prompts/stages/plan-from/adopt.md +2 -2
- package/prompts/stages/plan-from/file.md +2 -2
- 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 +1 -1
- package/prompts/stages/replan.md +2 -2
- package/prompts/stages/skills/create-from.md +1 -1
- package/prompts/stages/skills/create.md +1 -1
- package/prompts/stages/skills/refine.md +1 -1
- package/shared/README.md +22 -18
- package/shared/bindings.yaml +10 -2
- package/shared/contracts-history.md +24 -0
- package/shared/contracts.md +1538 -1803
- package/shared/providers.yaml +8 -1
- package/shared/registry.yaml +7 -8
- package/shared/schemas/inputs/review-submit-batch.schema.json +66 -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-submit.schema.json +64 -0
- package/extension/doors/learnCode.ts +0 -100
- package/extension/doors/learnDocs.ts +0 -100
- package/extension/doors/prReviewLocal.ts +0 -229
|
@@ -4,17 +4,25 @@
|
|
|
4
4
|
// `perk:plan-context` injection (extension/factories/planMode.ts) when present.
|
|
5
5
|
//
|
|
6
6
|
// Deliberately dependency-free: rather than pull a runtime TOML dependency into the published
|
|
7
|
-
// extension
|
|
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
14
|
import { parseUserBindings, type SkillBinding } from "./bindings.ts";
|
|
15
|
+
import { mainCheckoutRoot } from "./git.ts";
|
|
14
16
|
import { configFile, localConfigFile } from "./paths.ts";
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
|
-
*
|
|
19
|
+
* A TOML scalar the subset parser reads: quoted strings plus native booleans and numbers.
|
|
20
|
+
* Anything else (dates, arrays, inline tables) is still deliberately ignored.
|
|
21
|
+
*/
|
|
22
|
+
export type TomlScalar = string | boolean | number;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One configured CI check (a `[[ci.checks]]` array-of-tables row). `name`/`command` are required
|
|
18
26
|
* non-blank strings; an optional `glob` (a single comma-separated pattern string, e.g.
|
|
19
27
|
* `"*.ts,*.tsx"`) declares which changed files the check is relevant to — the read-only CI
|
|
20
28
|
* executor skips it on the run-all path when no changed file (vs trunk) matches.
|
|
@@ -29,19 +37,26 @@ export interface PerkConfig {
|
|
|
29
37
|
/** Optional project-supplied plan-authoring addendum (`[workflow] plan_authoring = "..."`). */
|
|
30
38
|
planAuthoring?: string;
|
|
31
39
|
/**
|
|
32
|
-
* The `[
|
|
33
|
-
* read-only CI executor consumes
|
|
34
|
-
* `
|
|
40
|
+
* The `[ci]` verification namespace. `checks` is the `[[ci.checks]]` ordered array-of-tables
|
|
41
|
+
* (each row name/command/optional glob) the read-only CI executor consumes; absent/empty ⇒ `[]`.
|
|
42
|
+
* `trusted` (`[ci] trusted = true`, a native boolean) declares those project-supplied checks
|
|
43
|
+
* trusted, so the executor runs them WITHOUT a per-session confirm on every surface, including
|
|
44
|
+
* headless (it overrides the fail-closed refuse). Absent/`false`/non-boolean ⇒ untrusted
|
|
45
|
+
* (confirm with UI; refuse headless). Always present; defaults `{trusted: false, checks: []}`.
|
|
35
46
|
*/
|
|
36
|
-
ci: CiCheck[];
|
|
47
|
+
ci: { trusted: boolean; checks: CiCheck[] };
|
|
37
48
|
/**
|
|
38
|
-
* The agent-keyed `[subagents]` table: a per-agent model override for each perk-owned
|
|
49
|
+
* The agent-keyed `[models.subagents]` table: a per-agent model override for each perk-owned
|
|
39
50
|
* project agent (`pr-reviewer`, `review-classifier`, `objective-explorer`, `conflict-resolver`,
|
|
40
|
-
* `learn-analyst`). Each configured
|
|
51
|
+
* `learn-analyst`, `adversarial-reviewer`). Each configured
|
|
41
52
|
* value is injected as a per-call inline `model` override on that agent's `subagent` spawn; when
|
|
42
53
|
* a key is absent the agent's frontmatter `model` (in `.pi/agents/<name>.md`) is the default.
|
|
43
54
|
* (`subagents.agentOverrides` does NOT reach project agents — `pi-subagents`'
|
|
44
55
|
* `applyBuiltinOverrides` applies only to builtins — so this inline override is the mechanism.)
|
|
56
|
+
* A value may carry a `:thinking` suffix (`"anthropic/claude-sonnet-4-5:high"`) or be the
|
|
57
|
+
* `"inherit"` sentinel (child inherits the parent session's model) — both resolved by
|
|
58
|
+
* pi-subagents on the inline override (the last-colon segment counts as thinking only when it
|
|
59
|
+
* is a pi level, so ollama-style tags stay part of the model id).
|
|
45
60
|
* Always-present object; absent keys omitted (mirror of `providers`).
|
|
46
61
|
*/
|
|
47
62
|
subagents: {
|
|
@@ -50,11 +65,13 @@ export interface PerkConfig {
|
|
|
50
65
|
"objective-explorer"?: string;
|
|
51
66
|
"conflict-resolver"?: string;
|
|
52
67
|
"learn-analyst"?: string;
|
|
68
|
+
"adversarial-reviewer"?: string;
|
|
53
69
|
};
|
|
54
70
|
/**
|
|
55
|
-
* Optional `[
|
|
56
|
-
* threshold compaction while an objective is active.
|
|
57
|
-
*
|
|
71
|
+
* Optional `[compaction] objective_threshold` — the context-usage fraction (0,1] that triggers
|
|
72
|
+
* threshold compaction while an objective is active. A native TOML float (e.g.
|
|
73
|
+
* `objective_threshold = 0.8`); string/out-of-range values are ignored. The Python plane
|
|
74
|
+
* deliberately ignores this key (it converges the rest of `[compaction]` into settings).
|
|
58
75
|
*/
|
|
59
76
|
objectiveCompactThreshold?: number;
|
|
60
77
|
/** The `[[bindings]]` user overlay, resolved against shipped defaults downstream. */
|
|
@@ -64,28 +81,25 @@ export interface PerkConfig {
|
|
|
64
81
|
* `shared/providers.yaml`. Absent keys mean “use the seam default”; resolution against the
|
|
65
82
|
* supported set is a downstream concern.
|
|
66
83
|
*/
|
|
67
|
-
providers: {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
* grow further trust keys later.
|
|
75
|
-
*/
|
|
76
|
-
trust: { ci?: boolean };
|
|
84
|
+
providers: {
|
|
85
|
+
plan?: string;
|
|
86
|
+
todo?: string;
|
|
87
|
+
askuser?: string;
|
|
88
|
+
footer?: string;
|
|
89
|
+
web?: string;
|
|
90
|
+
};
|
|
77
91
|
}
|
|
78
92
|
|
|
79
|
-
/** A nested
|
|
80
|
-
type
|
|
93
|
+
/** A nested scalar table: `{ section: { key: scalar } }` (dotted section names kept literal). */
|
|
94
|
+
type ScalarTable = Record<string, Record<string, TomlScalar>>;
|
|
81
95
|
|
|
82
96
|
/**
|
|
83
|
-
* The narrow TOML subset perk reads: `[section]`/top-level
|
|
84
|
-
* array-of-tables (each row a
|
|
97
|
+
* The narrow TOML subset perk reads: `[section]`/top-level scalar tables plus `[[name]]`
|
|
98
|
+
* array-of-tables (each row a scalar table). Mirrors `tomllib`'s shape for the keys perk uses.
|
|
85
99
|
*/
|
|
86
100
|
interface TomlSubset {
|
|
87
|
-
tables:
|
|
88
|
-
arrays: Record<string, Array<Record<string,
|
|
101
|
+
tables: ScalarTable;
|
|
102
|
+
arrays: Record<string, Array<Record<string, TomlScalar>>>;
|
|
89
103
|
}
|
|
90
104
|
|
|
91
105
|
function unescapeBasic(raw: string): string {
|
|
@@ -98,16 +112,17 @@ function unescapeBasic(raw: string): string {
|
|
|
98
112
|
|
|
99
113
|
/**
|
|
100
114
|
* Parse the narrow TOML subset perk consumes. Returns `{ tables, arrays }`: `tables` is a
|
|
101
|
-
* `{ section: { key:
|
|
102
|
-
* `{ name: [{ key:
|
|
103
|
-
*
|
|
115
|
+
* `{ section: { key: scalar } }` map (top-level keys under the `""` section); `arrays` is a
|
|
116
|
+
* `{ name: [{ key: scalar }, ...] }` map fed by `[[name]]` array-of-tables. Scalars are quoted
|
|
117
|
+
* strings, native `true`/`false` booleans, and numeric literals; anything else is skipped —
|
|
118
|
+
* this is intentionally NOT a full TOML parser.
|
|
104
119
|
*/
|
|
105
120
|
export function parseTomlSubset(text: string): TomlSubset {
|
|
106
|
-
const root: Record<string,
|
|
107
|
-
const tables:
|
|
108
|
-
const arrays: Record<string, Array<Record<string,
|
|
121
|
+
const root: Record<string, TomlScalar> = {};
|
|
122
|
+
const tables: ScalarTable = { "": root };
|
|
123
|
+
const arrays: Record<string, Array<Record<string, TomlScalar>>> = {};
|
|
109
124
|
// The current write target for `key = value` lines (a section table or an array-of-tables row).
|
|
110
|
-
let dest: Record<string,
|
|
125
|
+
let dest: Record<string, TomlScalar> = root;
|
|
111
126
|
const lines = text.split(/\r?\n/);
|
|
112
127
|
for (let i = 0; i < lines.length; i++) {
|
|
113
128
|
const line = (lines[i] ?? "").trim();
|
|
@@ -117,7 +132,7 @@ export function parseTomlSubset(text: string): TomlSubset {
|
|
|
117
132
|
const arrayHeader = line.match(/^\[\[([^\]]+)\]\]$/);
|
|
118
133
|
if (arrayHeader) {
|
|
119
134
|
const name = (arrayHeader[1] ?? "").trim();
|
|
120
|
-
const row: Record<string,
|
|
135
|
+
const row: Record<string, TomlScalar> = {};
|
|
121
136
|
let rows = arrays[name];
|
|
122
137
|
if (!rows) {
|
|
123
138
|
rows = [];
|
|
@@ -173,8 +188,21 @@ export function parseTomlSubset(text: string): TomlSubset {
|
|
|
173
188
|
const basic = value.match(/^"((?:[^"\\]|\\.)*)"/);
|
|
174
189
|
if (basic) {
|
|
175
190
|
dest[key] = unescapeBasic(basic[1] ?? "");
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Unquoted scalar: strip an inline `#` comment, then read native booleans and numbers.
|
|
195
|
+
const hash = value.indexOf("#");
|
|
196
|
+
const bare = (hash === -1 ? value : value.slice(0, hash)).trim();
|
|
197
|
+
if (bare === "true" || bare === "false") {
|
|
198
|
+
dest[key] = bare === "true";
|
|
199
|
+
continue;
|
|
176
200
|
}
|
|
177
|
-
|
|
201
|
+
if (/^[+-]?\d[\d_]*(\.[\d_]+)?([eE][+-]?\d+)?$/.test(bare)) {
|
|
202
|
+
const parsed = Number(bare.replace(/_/g, ""));
|
|
203
|
+
if (Number.isFinite(parsed)) dest[key] = parsed;
|
|
204
|
+
}
|
|
205
|
+
// Other value shapes (dates, arrays, inline tables) are intentionally ignored.
|
|
178
206
|
}
|
|
179
207
|
return { tables, arrays };
|
|
180
208
|
}
|
|
@@ -199,12 +227,12 @@ function readTomlFile(path: string): TomlSubset {
|
|
|
199
227
|
* replace as a whole array (mirror of perk/substrate/config.py's list-replaces-list overlay).
|
|
200
228
|
*/
|
|
201
229
|
function overlay(base: TomlSubset, over: TomlSubset): TomlSubset {
|
|
202
|
-
const tables:
|
|
230
|
+
const tables: ScalarTable = {};
|
|
203
231
|
for (const [section, kv] of Object.entries(base.tables)) tables[section] = { ...kv };
|
|
204
232
|
for (const [section, kv] of Object.entries(over.tables)) {
|
|
205
233
|
tables[section] = { ...(tables[section] ?? {}), ...kv };
|
|
206
234
|
}
|
|
207
|
-
const arrays: Record<string, Array<Record<string,
|
|
235
|
+
const arrays: Record<string, Array<Record<string, TomlScalar>>> = { ...base.arrays };
|
|
208
236
|
for (const [name, rows] of Object.entries(over.arrays)) arrays[name] = rows;
|
|
209
237
|
return { tables, arrays };
|
|
210
238
|
}
|
|
@@ -217,30 +245,33 @@ export function loadPerkConfig(cwd: string): PerkConfig {
|
|
|
217
245
|
}
|
|
218
246
|
|
|
219
247
|
const planAuthoring = merged.tables.workflow?.plan_authoring;
|
|
220
|
-
|
|
221
|
-
const
|
|
248
|
+
// `[compaction] objective_threshold` is a native float in (0,1]; strings/out-of-range ignored.
|
|
249
|
+
const rawThreshold = merged.tables.compaction?.objective_threshold;
|
|
222
250
|
const objectiveCompactThreshold =
|
|
223
|
-
|
|
224
|
-
?
|
|
251
|
+
typeof rawThreshold === "number" && rawThreshold > 0 && rawThreshold <= 1
|
|
252
|
+
? rawThreshold
|
|
225
253
|
: undefined;
|
|
226
254
|
return {
|
|
227
255
|
planAuthoring:
|
|
228
256
|
typeof planAuthoring === "string" && planAuthoring.trim() ? planAuthoring : undefined,
|
|
229
|
-
ci:
|
|
230
|
-
|
|
257
|
+
ci: {
|
|
258
|
+
trusted: merged.tables.ci?.trusted === true,
|
|
259
|
+
checks: parseCiChecks(merged.arrays["ci.checks"] ?? []),
|
|
260
|
+
},
|
|
261
|
+
subagents: parseSubagentsSelection(merged.tables["models.subagents"]),
|
|
231
262
|
objectiveCompactThreshold,
|
|
232
263
|
bindings: parseUserBindings(merged.arrays.bindings ?? []),
|
|
233
264
|
providers: parseProvidersSelection(merged.tables.providers),
|
|
234
|
-
trust: parseTrustSelection(merged.tables.trust),
|
|
235
265
|
};
|
|
236
266
|
}
|
|
237
267
|
|
|
238
268
|
/**
|
|
239
|
-
* Read the `[[ci]]` array-of-tables into an ordered `CiCheck[]`. A row is kept only when
|
|
240
|
-
* `name` and `command` are non-blank strings; `glob` is kept only when a non-blank string.
|
|
241
|
-
* order is preserved; ill-typed rows are silently dropped (mirror of
|
|
269
|
+
* Read the `[[ci.checks]]` array-of-tables into an ordered `CiCheck[]`. A row is kept only when
|
|
270
|
+
* both `name` and `command` are non-blank strings; `glob` is kept only when a non-blank string.
|
|
271
|
+
* Declared order is preserved; ill-typed rows are silently dropped (mirror of
|
|
272
|
+
* `parseProvidersSelection`).
|
|
242
273
|
*/
|
|
243
|
-
export function parseCiChecks(rows: Array<Record<string,
|
|
274
|
+
export function parseCiChecks(rows: Array<Record<string, TomlScalar>>): CiCheck[] {
|
|
244
275
|
const checks: CiCheck[] = [];
|
|
245
276
|
for (const row of rows) {
|
|
246
277
|
const name = row.name;
|
|
@@ -255,22 +286,23 @@ export function parseCiChecks(rows: Array<Record<string, string>>): CiCheck[] {
|
|
|
255
286
|
return checks;
|
|
256
287
|
}
|
|
257
288
|
|
|
258
|
-
/** The perk-owned project agents configurable via the `[subagents]` table. */
|
|
289
|
+
/** The perk-owned project agents configurable via the `[models.subagents]` table. */
|
|
259
290
|
const SUBAGENT_KEYS = [
|
|
260
291
|
"pr-reviewer",
|
|
261
292
|
"review-classifier",
|
|
262
293
|
"objective-explorer",
|
|
263
294
|
"conflict-resolver",
|
|
264
295
|
"learn-analyst",
|
|
296
|
+
"adversarial-reviewer",
|
|
265
297
|
] as const;
|
|
266
298
|
|
|
267
299
|
/**
|
|
268
|
-
* Read the agent-keyed `[subagents]` table into a selection (string values only). For each
|
|
269
|
-
* agent key, the value is kept only when it is a non-blank string; absent/ill-typed/unknown
|
|
270
|
-
* are omitted (mirror of `parseProvidersSelection`).
|
|
300
|
+
* Read the agent-keyed `[models.subagents]` table into a selection (string values only). For each
|
|
301
|
+
* known agent key, the value is kept only when it is a non-blank string; absent/ill-typed/unknown
|
|
302
|
+
* keys are omitted (mirror of `parseProvidersSelection`).
|
|
271
303
|
*/
|
|
272
304
|
function parseSubagentsSelection(
|
|
273
|
-
table: Record<string,
|
|
305
|
+
table: Record<string, TomlScalar> | undefined,
|
|
274
306
|
): PerkConfig["subagents"] {
|
|
275
307
|
const selection: PerkConfig["subagents"] = {};
|
|
276
308
|
for (const key of SUBAGENT_KEYS) {
|
|
@@ -280,16 +312,8 @@ function parseSubagentsSelection(
|
|
|
280
312
|
return selection;
|
|
281
313
|
}
|
|
282
314
|
|
|
283
|
-
/** Read the `[
|
|
284
|
-
function
|
|
285
|
-
const selection: { ci?: boolean } = {};
|
|
286
|
-
if (typeof table?.ci === "string" && table.ci.trim().toLowerCase() === "true")
|
|
287
|
-
selection.ci = true;
|
|
288
|
-
return selection;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/** Read the flat `[providers]` table into a `{plan?, todo?, askuser?, footer?, web?}` selection (string values only). */
|
|
292
|
-
function parseProvidersSelection(table: Record<string, string> | undefined): {
|
|
315
|
+
/** Read the flat `[providers]` table into a `{plan?, todo?, askuser?, footer?, web?}` selection (string values only). A retired `review` key is silently ignored (the TS fail-safe posture; the Python plane's tripwire is the loud surface). */
|
|
316
|
+
function parseProvidersSelection(table: Record<string, TomlScalar> | undefined): {
|
|
293
317
|
plan?: string;
|
|
294
318
|
todo?: string;
|
|
295
319
|
askuser?: string;
|
|
@@ -321,14 +345,17 @@ export const GITHUB_ISSUE_BACKEND_ID: IssueBackendId = "github";
|
|
|
321
345
|
*
|
|
322
346
|
* Reads ONLY committed `.perk/config.toml` — deliberately not `loadPerkConfig`'s overlay, mirroring
|
|
323
347
|
* the Python committed-only read (the backend decides where canonical durable state is written;
|
|
324
|
-
* a per-user `.perk/local.toml` override would fragment the canonical store).
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
348
|
+
* a per-user `.perk/local.toml` override would fragment the canonical store). The read is
|
|
349
|
+
* anchored to the MAIN checkout via `mainCheckoutRoot` (fail-open: `cwd` outside a git repo),
|
|
350
|
+
* mirroring Python's main-worktree anchoring — a linked worktree's checkout state (detached /
|
|
351
|
+
* stale branch / missing `.perk/`) must never flip a Linear repo's prompt clauses to GitHub.
|
|
352
|
+
* Python (`perk/backends/resolve.py::resolve_issue_backend_id`) is the AUTHORITATIVE validator and
|
|
353
|
+
* **raises** on unknown values; this mirror is fail-safe (absence/unknown/any error → `"github"`)
|
|
354
|
+
* because the TS plane only renders prompts — it never writes canonical issues.
|
|
328
355
|
*/
|
|
329
356
|
export function resolveIssueBackendId(cwd: string): IssueBackendId {
|
|
330
357
|
try {
|
|
331
|
-
const committed = readTomlFile(configFile(cwd));
|
|
358
|
+
const committed = readTomlFile(configFile(mainCheckoutRoot(cwd)));
|
|
332
359
|
const backend = committed.tables.issues?.backend;
|
|
333
360
|
if (backend === "github" || backend === "linear") return backend;
|
|
334
361
|
return GITHUB_ISSUE_BACKEND_ID;
|
|
@@ -32,3 +32,46 @@ export function mainCheckoutRoot(cwd: string): string {
|
|
|
32
32
|
const common = isAbsolute(out) ? out : resolve(cwd, out);
|
|
33
33
|
return resolve(common, "..");
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
/** Run one git command; trimmed stdout, or null on any failure (the module's fail-open style). */
|
|
37
|
+
function git(cwd: string, args: string[], timeout?: number): string | null {
|
|
38
|
+
try {
|
|
39
|
+
const out = execFileSync("git", args, {
|
|
40
|
+
cwd,
|
|
41
|
+
encoding: "utf8",
|
|
42
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
43
|
+
...(timeout !== undefined ? { timeout } : {}),
|
|
44
|
+
}).trim();
|
|
45
|
+
return out === "" ? null : out;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The bounded best-effort `git fetch` budget (ms) — see `sinceBaseSha` step 2. */
|
|
52
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The since-base merge-base of the working tree: `merge-base(HEAD, origin/<base>)` — the sha the
|
|
56
|
+
* terminal review door diffs the active worktree against. **Fail-open**: null on any failure
|
|
57
|
+
* (not a repo, no such ref, git missing), never throws.
|
|
58
|
+
*
|
|
59
|
+
* 1. Resolve the base branch name: `base` when given; else the repo default via
|
|
60
|
+
* `git symbolic-ref --short refs/remotes/origin/HEAD` (`origin/main` → `main`).
|
|
61
|
+
* 2. Best-effort `git fetch origin <branch>` with a bounded timeout — a failure (offline, no
|
|
62
|
+
* remote) is swallowed and the stale local ref is used, keeping the door usable offline (and
|
|
63
|
+
* the test scaffold network-free).
|
|
64
|
+
* 3. `git merge-base HEAD origin/<branch>` → the full sha.
|
|
65
|
+
*/
|
|
66
|
+
export function sinceBaseSha(cwd: string, base: string | null | undefined): string | null {
|
|
67
|
+
let branch = base ?? null;
|
|
68
|
+
if (branch === null) {
|
|
69
|
+
const head = git(cwd, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
|
|
70
|
+
if (head === null) return null;
|
|
71
|
+
// `origin/main` → `main` (keep anything after the first slash — branch names may carry `/`).
|
|
72
|
+
branch = head.includes("/") ? head.slice(head.indexOf("/") + 1) : head;
|
|
73
|
+
}
|
|
74
|
+
if (branch === "") return null;
|
|
75
|
+
git(cwd, ["fetch", "origin", branch], FETCH_TIMEOUT_MS);
|
|
76
|
+
return git(cwd, ["merge-base", "HEAD", `origin/${branch}`]);
|
|
77
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// `.pi/perk.toml` migration is Python-side). The workflow family lives in the established cache
|
|
7
7
|
// seam (substrate/cache.ts's `workflowDir`); together these two modules own every perk-owned
|
|
8
8
|
// dot-path on this plane.
|
|
9
|
-
//
|
|
9
|
+
// Remaining families migrate to `.perk/` one at a time — redirecting a family is a
|
|
10
10
|
// single edit here. **Pi-native** `.pi/...` paths (`.pi/settings.json`, `.pi/agents/`, `.pi/npm`,
|
|
11
11
|
// `.pi/APPEND_SYSTEM.md`) are intentionally NOT owned here — `.pi/` is not generally perk-owned.
|
|
12
12
|
//
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
// keeps the extension zero-runtime-dep / loadable from a bare git clone (guarded by
|
|
16
16
|
// extension/bareImportGuard.test.ts).
|
|
17
17
|
//
|
|
18
|
-
// This seam is LIVE in production: render is imported by
|
|
19
|
-
//
|
|
18
|
+
// This seam is LIVE in production: render is imported by the worker, the warm doors, the
|
|
19
|
+
// factories, and the mode/adapter context modules (the `prompts/contexts/` injections).
|
|
20
20
|
|
|
21
21
|
import { render as miniJinjaRender } from "./miniJinja.ts";
|
|
22
22
|
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Twin of perk/substrate/providers.py: both planes parse the SAME bundled file (no codegen). This is the
|
|
4
4
|
// THIRD parsed cross-plane contract (after registry.yaml and bindings.yaml). It is the SUPPORTED
|
|
5
|
-
// SET — the catalog of plan/todo/askuser/footer/web providers perk knows how to wire —
|
|
6
|
-
// the per-repo SELECTION (the flat `[providers]` table in .perk/config.toml).
|
|
5
|
+
// SET — the catalog of plan/todo/askuser/footer/web providers perk knows how to wire —
|
|
6
|
+
// distinct from the per-repo SELECTION (the flat `[providers]` table in .perk/config.toml).
|
|
7
7
|
//
|
|
8
8
|
// The Python CLI is the authoritative validator (perk/substrate/providers.py); this side does a thin
|
|
9
9
|
// structural parse only — no deep content validation here. `resolveProviders` is CONSUMED by
|
|
@@ -113,6 +113,47 @@ export interface ResolvedProviders {
|
|
|
113
113
|
issues: string[];
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* The synthesized reference fallback per seam — the provider `resolveProviders` returns when the
|
|
118
|
+
* bundled catalog carries no `default: true` entry for that seam (version skew: a long-lived warm
|
|
119
|
+
* session's in-memory code reading a live-edited `shared/providers.yaml` across a seam
|
|
120
|
+
* add/retire, in either direction). Guarantees PER-SEAM ISOLATION: one seam's catalog gap can
|
|
121
|
+
* never collapse another seam's resolution (the plannotator plan_review no-launch incident — the
|
|
122
|
+
* retired review seam's gap collapsed the plan seam to first-party through a thrown
|
|
123
|
+
* `requireDefault` + a silent caller catch).
|
|
124
|
+
*/
|
|
125
|
+
const REFERENCE_FALLBACKS: Record<(typeof PROVIDER_SEAMS)[number], Provider> = {
|
|
126
|
+
plan: { id: PERK_PLAN_PROVIDER_ID, seam: "plan", package: null, adapter: null, default: true },
|
|
127
|
+
todo: {
|
|
128
|
+
id: PERK_CHECKPOINTS_PROVIDER_ID,
|
|
129
|
+
seam: "todo",
|
|
130
|
+
package: null,
|
|
131
|
+
adapter: null,
|
|
132
|
+
default: true,
|
|
133
|
+
},
|
|
134
|
+
askuser: {
|
|
135
|
+
id: PERK_ASK_USER_PROVIDER_ID,
|
|
136
|
+
seam: "askuser",
|
|
137
|
+
package: null,
|
|
138
|
+
adapter: null,
|
|
139
|
+
default: true,
|
|
140
|
+
},
|
|
141
|
+
footer: {
|
|
142
|
+
id: PERK_FOOTER_PROVIDER_ID,
|
|
143
|
+
seam: "footer",
|
|
144
|
+
package: null,
|
|
145
|
+
adapter: null,
|
|
146
|
+
default: true,
|
|
147
|
+
},
|
|
148
|
+
web: {
|
|
149
|
+
id: PI_WEB_ACCESS_PROVIDER_ID,
|
|
150
|
+
seam: "web",
|
|
151
|
+
package: "npm:pi-web-access",
|
|
152
|
+
adapter: null,
|
|
153
|
+
default: true,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
116
157
|
/** The first `default: true` provider for `seam` (the validator enforces exactly one). */
|
|
117
158
|
function defaultFor(set: Provider[], seam: string): Provider | undefined {
|
|
118
159
|
return set.find((p) => p.seam === seam && p.default);
|
|
@@ -131,22 +172,35 @@ function byId(set: Provider[]): Map<string, Provider> {
|
|
|
131
172
|
* For each seam, the selection resolves to the named provider **iff** the id exists AND its `seam`
|
|
132
173
|
* matches the key; otherwise it falls back to `defaultFor(seam)` and appends a loud-but-non-fatal
|
|
133
174
|
* issue (unknown id / seam mismatch). An **absent** key falls back to the default **silently** (the
|
|
134
|
-
* zero-config default — no issue). Defaults are trusted (not re-validated).
|
|
135
|
-
*
|
|
136
|
-
*
|
|
175
|
+
* zero-config default — no issue). Defaults are trusted (not re-validated). A seam with NO
|
|
176
|
+
* `default: true` entry in the set is PER-SEAM FAIL-OPEN: it resolves to the synthesized
|
|
177
|
+
* REFERENCE_FALLBACKS entry with a loud-but-non-fatal issue — never a throw (a throw here let one
|
|
178
|
+
* seam's catalog gap collapse every other seam's resolution through the callers' fail-safe
|
|
179
|
+
* catches; §8.10 names this a deliberate cross-plane difference — the Python `_require_default`
|
|
180
|
+
* stays strict because short-lived processes reading the wheel-bundled `perk/_shared` cannot
|
|
181
|
+
* skew). Omitting `set` loads the bundled `providers.yaml`.
|
|
137
182
|
*/
|
|
138
183
|
export function resolveProviders(
|
|
139
|
-
selection: {
|
|
184
|
+
selection: {
|
|
185
|
+
plan?: string;
|
|
186
|
+
todo?: string;
|
|
187
|
+
askuser?: string;
|
|
188
|
+
footer?: string;
|
|
189
|
+
web?: string;
|
|
190
|
+
},
|
|
140
191
|
set?: Provider[],
|
|
141
192
|
): ResolvedProviders {
|
|
142
193
|
const providers = set ?? loadProviders();
|
|
143
194
|
const ids = byId(providers);
|
|
144
195
|
const issues: string[] = [];
|
|
145
196
|
|
|
146
|
-
const requireDefault = (seam:
|
|
197
|
+
const requireDefault = (seam: (typeof PROVIDER_SEAMS)[number]): Provider => {
|
|
147
198
|
const def = defaultFor(providers, seam);
|
|
148
199
|
if (def === undefined) {
|
|
149
|
-
|
|
200
|
+
issues.push(
|
|
201
|
+
`seam \`${seam}\` has no default in the bundled catalog (version skew?) — using the built-in reference`,
|
|
202
|
+
);
|
|
203
|
+
return REFERENCE_FALLBACKS[seam];
|
|
150
204
|
}
|
|
151
205
|
return def;
|
|
152
206
|
};
|
|
@@ -84,6 +84,13 @@ export function readSessionPointers(root: string, runId: string): SessionPointer
|
|
|
84
84
|
* Best-effort: returns `true` on a successful write, `false` (with a stderr warning) on any
|
|
85
85
|
* failure. Never throws. Serialized byte-compatibly with the Python writer (key order +
|
|
86
86
|
* 2-space indent + trailing newline).
|
|
87
|
+
*
|
|
88
|
+
* `preserveForeign` makes the slot first-write-wins (defense in depth against pointer
|
|
89
|
+
* shadowing): when set and the slot already holds a pointer whose `pi_session_id` differs from
|
|
90
|
+
* the incoming one, the write is SKIPPED with a loud stderr warning naming both session ids
|
|
91
|
+
* (returns `false`) — so any future shadow vector surfaces instead of silently corrupting
|
|
92
|
+
* evidence. A same-session re-capture still refreshes the slot. Default `false` keeps today's
|
|
93
|
+
* overwrite semantics (each default-caller slot has exactly one legitimate writer).
|
|
87
94
|
*/
|
|
88
95
|
export function recordSessionPointer(
|
|
89
96
|
cwd: string,
|
|
@@ -91,11 +98,24 @@ export function recordSessionPointer(
|
|
|
91
98
|
klass: SessionClass,
|
|
92
99
|
site: SessionSite,
|
|
93
100
|
pointer: SessionPointer,
|
|
101
|
+
opts: { preserveForeign?: boolean } = {},
|
|
94
102
|
): boolean {
|
|
95
103
|
if (!runId) return false;
|
|
96
104
|
const root = mainCheckoutRoot(cwd);
|
|
97
105
|
try {
|
|
98
106
|
const record = readSessionPointers(root, runId) ?? emptyRecord(runId);
|
|
107
|
+
const existing = record[klass][site];
|
|
108
|
+
if (
|
|
109
|
+
opts.preserveForeign === true &&
|
|
110
|
+
existing !== null &&
|
|
111
|
+
existing.pi_session_id !== pointer.pi_session_id
|
|
112
|
+
) {
|
|
113
|
+
console.error(
|
|
114
|
+
`perk: warning: session pointer ${klass}.${site} for run ${runId} already held by ` +
|
|
115
|
+
`${existing.pi_session_id} — skipping foreign overwrite by ${pointer.pi_session_id}`,
|
|
116
|
+
);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
99
119
|
// The run id is authoritative — a record read from disk keeps its own; a fresh one is minted
|
|
100
120
|
// with `runId`. (A mismatched on-disk run_id is left as-is; self-keying guarantees a match.)
|
|
101
121
|
record[klass][site] = pointer;
|
|
@@ -113,6 +133,7 @@ export function recordSessionPointer(
|
|
|
113
133
|
* Capture one session pointer into `<class>.<site>` from a session file path — the call-site
|
|
114
134
|
* convenience over `recordSessionPointer`. Derives `pi_session_id` from the basename and stamps
|
|
115
135
|
* `at`. A `null`/empty `sessionFile` or `runId` is a no-op (`false`) — best-effort, never throws.
|
|
136
|
+
* `preserveForeign` threads through to `recordSessionPointer`'s first-write-wins guard.
|
|
116
137
|
*/
|
|
117
138
|
export function captureSessionPointer(args: {
|
|
118
139
|
cwd: string;
|
|
@@ -121,15 +142,23 @@ export function captureSessionPointer(args: {
|
|
|
121
142
|
site: SessionSite;
|
|
122
143
|
sessionFile: string | null | undefined;
|
|
123
144
|
parentSessionId?: string | null;
|
|
145
|
+
preserveForeign?: boolean;
|
|
124
146
|
}): boolean {
|
|
125
147
|
const { cwd, runId, klass, site, sessionFile } = args;
|
|
126
148
|
if (!sessionFile || !runId) return false;
|
|
127
|
-
return recordSessionPointer(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
149
|
+
return recordSessionPointer(
|
|
150
|
+
cwd,
|
|
151
|
+
runId,
|
|
152
|
+
klass,
|
|
153
|
+
site,
|
|
154
|
+
{
|
|
155
|
+
pi_session_id: basename(sessionFile),
|
|
156
|
+
session_file: sessionFile,
|
|
157
|
+
parent_pi_session_id: args.parentSessionId ?? null,
|
|
158
|
+
at: new Date().toISOString(),
|
|
159
|
+
},
|
|
160
|
+
{ preserveForeign: args.preserveForeign },
|
|
161
|
+
);
|
|
133
162
|
}
|
|
134
163
|
|
|
135
164
|
/** Serialize a record byte-compatibly with the Python writer (explicit key order, null slots). */
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
import {
|
|
14
14
|
type Api,
|
|
15
15
|
type Context,
|
|
16
|
-
complete,
|
|
17
16
|
type Model,
|
|
18
17
|
type Static,
|
|
19
18
|
type Tool,
|
|
@@ -21,6 +20,9 @@ import {
|
|
|
21
20
|
type TSchema,
|
|
22
21
|
validateToolCall,
|
|
23
22
|
} from "@earendil-works/pi-ai";
|
|
23
|
+
// `complete` (the old global API) lives on the /compat entrypoint from pi-ai 0.80; the root
|
|
24
|
+
// keeps the types. Pi's extension loader aliases both the root and /compat to the compat entry.
|
|
25
|
+
import { complete } from "@earendil-works/pi-ai/compat";
|
|
24
26
|
|
|
25
27
|
/** Structurally-minimal slice of `ExtensionContext` needed to reuse the session's model + auth. */
|
|
26
28
|
export interface ModelAuthContext {
|