@mgiles/perk 1.1.0 → 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 +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 +88 -65
- 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 +1442 -1787
- 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,8 +4,9 @@
|
|
|
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
|
|
|
@@ -14,7 +15,13 @@ import { parseUserBindings, type SkillBinding } from "./bindings.ts";
|
|
|
14
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;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One configured CI check (a `[[ci.checks]]` array-of-tables row). `name`/`command` are required
|
|
18
25
|
* non-blank strings; an optional `glob` (a single comma-separated pattern string, e.g.
|
|
19
26
|
* `"*.ts,*.tsx"`) declares which changed files the check is relevant to — the read-only CI
|
|
20
27
|
* executor skips it on the run-all path when no changed file (vs trunk) matches.
|
|
@@ -29,19 +36,26 @@ export interface PerkConfig {
|
|
|
29
36
|
/** Optional project-supplied plan-authoring addendum (`[workflow] plan_authoring = "..."`). */
|
|
30
37
|
planAuthoring?: string;
|
|
31
38
|
/**
|
|
32
|
-
* The `[
|
|
33
|
-
* read-only CI executor consumes
|
|
34
|
-
* `
|
|
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: []}`.
|
|
35
45
|
*/
|
|
36
|
-
ci: CiCheck[];
|
|
46
|
+
ci: { trusted: boolean; checks: CiCheck[] };
|
|
37
47
|
/**
|
|
38
|
-
* The agent-keyed `[subagents]` table: a per-agent model override for each perk-owned
|
|
48
|
+
* The agent-keyed `[models.subagents]` table: a per-agent model override for each perk-owned
|
|
39
49
|
* project agent (`pr-reviewer`, `review-classifier`, `objective-explorer`, `conflict-resolver`,
|
|
40
|
-
* `learn-analyst`). Each configured
|
|
50
|
+
* `learn-analyst`, `adversarial-reviewer`). Each configured
|
|
41
51
|
* value is injected as a per-call inline `model` override on that agent's `subagent` spawn; when
|
|
42
52
|
* a key is absent the agent's frontmatter `model` (in `.pi/agents/<name>.md`) is the default.
|
|
43
53
|
* (`subagents.agentOverrides` does NOT reach project agents — `pi-subagents`'
|
|
44
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).
|
|
45
59
|
* Always-present object; absent keys omitted (mirror of `providers`).
|
|
46
60
|
*/
|
|
47
61
|
subagents: {
|
|
@@ -50,11 +64,13 @@ export interface PerkConfig {
|
|
|
50
64
|
"objective-explorer"?: string;
|
|
51
65
|
"conflict-resolver"?: string;
|
|
52
66
|
"learn-analyst"?: string;
|
|
67
|
+
"adversarial-reviewer"?: string;
|
|
53
68
|
};
|
|
54
69
|
/**
|
|
55
|
-
* Optional `[
|
|
56
|
-
* threshold compaction while an objective is active.
|
|
57
|
-
*
|
|
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).
|
|
58
74
|
*/
|
|
59
75
|
objectiveCompactThreshold?: number;
|
|
60
76
|
/** The `[[bindings]]` user overlay, resolved against shipped defaults downstream. */
|
|
@@ -64,28 +80,25 @@ export interface PerkConfig {
|
|
|
64
80
|
* `shared/providers.yaml`. Absent keys mean “use the seam default”; resolution against the
|
|
65
81
|
* supported set is a downstream concern.
|
|
66
82
|
*/
|
|
67
|
-
providers: {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
* grow further trust keys later.
|
|
75
|
-
*/
|
|
76
|
-
trust: { ci?: boolean };
|
|
83
|
+
providers: {
|
|
84
|
+
plan?: string;
|
|
85
|
+
todo?: string;
|
|
86
|
+
askuser?: string;
|
|
87
|
+
footer?: string;
|
|
88
|
+
web?: string;
|
|
89
|
+
};
|
|
77
90
|
}
|
|
78
91
|
|
|
79
|
-
/** A nested
|
|
80
|
-
type
|
|
92
|
+
/** A nested scalar table: `{ section: { key: scalar } }` (dotted section names kept literal). */
|
|
93
|
+
type ScalarTable = Record<string, Record<string, TomlScalar>>;
|
|
81
94
|
|
|
82
95
|
/**
|
|
83
|
-
* The narrow TOML subset perk reads: `[section]`/top-level
|
|
84
|
-
* 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.
|
|
85
98
|
*/
|
|
86
99
|
interface TomlSubset {
|
|
87
|
-
tables:
|
|
88
|
-
arrays: Record<string, Array<Record<string,
|
|
100
|
+
tables: ScalarTable;
|
|
101
|
+
arrays: Record<string, Array<Record<string, TomlScalar>>>;
|
|
89
102
|
}
|
|
90
103
|
|
|
91
104
|
function unescapeBasic(raw: string): string {
|
|
@@ -98,16 +111,17 @@ function unescapeBasic(raw: string): string {
|
|
|
98
111
|
|
|
99
112
|
/**
|
|
100
113
|
* Parse the narrow TOML subset perk consumes. Returns `{ tables, arrays }`: `tables` is a
|
|
101
|
-
* `{ section: { key:
|
|
102
|
-
* `{ name: [{ key:
|
|
103
|
-
*
|
|
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.
|
|
104
118
|
*/
|
|
105
119
|
export function parseTomlSubset(text: string): TomlSubset {
|
|
106
|
-
const root: Record<string,
|
|
107
|
-
const tables:
|
|
108
|
-
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>>> = {};
|
|
109
123
|
// The current write target for `key = value` lines (a section table or an array-of-tables row).
|
|
110
|
-
let dest: Record<string,
|
|
124
|
+
let dest: Record<string, TomlScalar> = root;
|
|
111
125
|
const lines = text.split(/\r?\n/);
|
|
112
126
|
for (let i = 0; i < lines.length; i++) {
|
|
113
127
|
const line = (lines[i] ?? "").trim();
|
|
@@ -117,7 +131,7 @@ export function parseTomlSubset(text: string): TomlSubset {
|
|
|
117
131
|
const arrayHeader = line.match(/^\[\[([^\]]+)\]\]$/);
|
|
118
132
|
if (arrayHeader) {
|
|
119
133
|
const name = (arrayHeader[1] ?? "").trim();
|
|
120
|
-
const row: Record<string,
|
|
134
|
+
const row: Record<string, TomlScalar> = {};
|
|
121
135
|
let rows = arrays[name];
|
|
122
136
|
if (!rows) {
|
|
123
137
|
rows = [];
|
|
@@ -173,8 +187,21 @@ export function parseTomlSubset(text: string): TomlSubset {
|
|
|
173
187
|
const basic = value.match(/^"((?:[^"\\]|\\.)*)"/);
|
|
174
188
|
if (basic) {
|
|
175
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;
|
|
176
199
|
}
|
|
177
|
-
|
|
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.
|
|
178
205
|
}
|
|
179
206
|
return { tables, arrays };
|
|
180
207
|
}
|
|
@@ -199,12 +226,12 @@ function readTomlFile(path: string): TomlSubset {
|
|
|
199
226
|
* replace as a whole array (mirror of perk/substrate/config.py's list-replaces-list overlay).
|
|
200
227
|
*/
|
|
201
228
|
function overlay(base: TomlSubset, over: TomlSubset): TomlSubset {
|
|
202
|
-
const tables:
|
|
229
|
+
const tables: ScalarTable = {};
|
|
203
230
|
for (const [section, kv] of Object.entries(base.tables)) tables[section] = { ...kv };
|
|
204
231
|
for (const [section, kv] of Object.entries(over.tables)) {
|
|
205
232
|
tables[section] = { ...(tables[section] ?? {}), ...kv };
|
|
206
233
|
}
|
|
207
|
-
const arrays: Record<string, Array<Record<string,
|
|
234
|
+
const arrays: Record<string, Array<Record<string, TomlScalar>>> = { ...base.arrays };
|
|
208
235
|
for (const [name, rows] of Object.entries(over.arrays)) arrays[name] = rows;
|
|
209
236
|
return { tables, arrays };
|
|
210
237
|
}
|
|
@@ -217,30 +244,33 @@ export function loadPerkConfig(cwd: string): PerkConfig {
|
|
|
217
244
|
}
|
|
218
245
|
|
|
219
246
|
const planAuthoring = merged.tables.workflow?.plan_authoring;
|
|
220
|
-
|
|
221
|
-
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;
|
|
222
249
|
const objectiveCompactThreshold =
|
|
223
|
-
|
|
224
|
-
?
|
|
250
|
+
typeof rawThreshold === "number" && rawThreshold > 0 && rawThreshold <= 1
|
|
251
|
+
? rawThreshold
|
|
225
252
|
: undefined;
|
|
226
253
|
return {
|
|
227
254
|
planAuthoring:
|
|
228
255
|
typeof planAuthoring === "string" && planAuthoring.trim() ? planAuthoring : undefined,
|
|
229
|
-
ci:
|
|
230
|
-
|
|
256
|
+
ci: {
|
|
257
|
+
trusted: merged.tables.ci?.trusted === true,
|
|
258
|
+
checks: parseCiChecks(merged.arrays["ci.checks"] ?? []),
|
|
259
|
+
},
|
|
260
|
+
subagents: parseSubagentsSelection(merged.tables["models.subagents"]),
|
|
231
261
|
objectiveCompactThreshold,
|
|
232
262
|
bindings: parseUserBindings(merged.arrays.bindings ?? []),
|
|
233
263
|
providers: parseProvidersSelection(merged.tables.providers),
|
|
234
|
-
trust: parseTrustSelection(merged.tables.trust),
|
|
235
264
|
};
|
|
236
265
|
}
|
|
237
266
|
|
|
238
267
|
/**
|
|
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
|
|
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`).
|
|
242
272
|
*/
|
|
243
|
-
export function parseCiChecks(rows: Array<Record<string,
|
|
273
|
+
export function parseCiChecks(rows: Array<Record<string, TomlScalar>>): CiCheck[] {
|
|
244
274
|
const checks: CiCheck[] = [];
|
|
245
275
|
for (const row of rows) {
|
|
246
276
|
const name = row.name;
|
|
@@ -255,22 +285,23 @@ export function parseCiChecks(rows: Array<Record<string, string>>): CiCheck[] {
|
|
|
255
285
|
return checks;
|
|
256
286
|
}
|
|
257
287
|
|
|
258
|
-
/** The perk-owned project agents configurable via the `[subagents]` table. */
|
|
288
|
+
/** The perk-owned project agents configurable via the `[models.subagents]` table. */
|
|
259
289
|
const SUBAGENT_KEYS = [
|
|
260
290
|
"pr-reviewer",
|
|
261
291
|
"review-classifier",
|
|
262
292
|
"objective-explorer",
|
|
263
293
|
"conflict-resolver",
|
|
264
294
|
"learn-analyst",
|
|
295
|
+
"adversarial-reviewer",
|
|
265
296
|
] as const;
|
|
266
297
|
|
|
267
298
|
/**
|
|
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`).
|
|
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`).
|
|
271
302
|
*/
|
|
272
303
|
function parseSubagentsSelection(
|
|
273
|
-
table: Record<string,
|
|
304
|
+
table: Record<string, TomlScalar> | undefined,
|
|
274
305
|
): PerkConfig["subagents"] {
|
|
275
306
|
const selection: PerkConfig["subagents"] = {};
|
|
276
307
|
for (const key of SUBAGENT_KEYS) {
|
|
@@ -280,16 +311,8 @@ function parseSubagentsSelection(
|
|
|
280
311
|
return selection;
|
|
281
312
|
}
|
|
282
313
|
|
|
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): {
|
|
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): {
|
|
293
316
|
plan?: string;
|
|
294
317
|
todo?: string;
|
|
295
318
|
askuser?: string;
|
|
@@ -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 {
|