@mgiles/perk 1.0.1 → 1.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 +7 -21
- package/extension/checkpoints/checkpoints.ts +2 -1
- package/extension/doors/address.ts +2 -1
- package/extension/doors/ciExecutor.ts +3 -2
- package/extension/doors/land.ts +2 -1
- package/extension/doors/learn.ts +239 -33
- package/extension/doors/learnCode.ts +100 -0
- package/extension/doors/learnDocs.ts +4 -3
- package/extension/doors/lifecycleGates.ts +2 -1
- package/extension/doors/prReview.ts +20 -35
- package/extension/doors/prReviewLocal.ts +229 -0
- package/extension/doors/ready.ts +2 -1
- package/extension/doors/selfcheck.ts +2 -1
- package/extension/doors/submit.ts +17 -19
- package/extension/factories/implementHere.ts +116 -0
- package/extension/factories/objective.ts +2 -1
- package/extension/factories/objectivePlan.ts +4 -24
- package/extension/factories/objectiveSave.ts +5 -15
- package/extension/factories/planMode.ts +5 -1
- package/extension/factories/planReview.ts +103 -11
- package/extension/factories/planSave.ts +16 -1
- package/extension/index.ts +42 -3
- package/extension/substrate/bindings.ts +1 -1
- package/extension/substrate/cache.ts +2 -2
- package/extension/substrate/command.ts +25 -0
- package/extension/substrate/config.ts +12 -13
- package/extension/substrate/consoleCapture.ts +90 -0
- package/extension/substrate/git.ts +34 -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 +1 -1
- package/extension/substrate/sessionData.ts +1 -1
- package/extension/substrate/sessionPointers.ts +155 -0
- package/extension/substrate/toolGating.ts +8 -0
- package/extension/surfaces/surfaces.ts +10 -3
- package/extension/worker/worker.ts +111 -25
- package/extension/workerMain.ts +5 -3
- package/package.json +1 -5
- 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 +281 -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/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/seed.md +2 -1
- 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.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 +7 -1
- package/shared/bindings.yaml +12 -0
- package/shared/contracts-history.md +167 -0
- package/shared/contracts.md +1307 -342
- package/shared/registry.yaml +3 -3
- 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/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-context.schema.json +86 -0
- package/shared/schemas/outputs/pr-submit.schema.json +147 -0
- 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,155 @@
|
|
|
1
|
+
// The run-cache session-pointer carrier (contracts.md §8.35) — the capture side of the cross-run
|
|
2
|
+
// session-pointer record. The TS twin of perk/state/session_pointers.py; both planes read/write the
|
|
3
|
+
// same `session-pointers.json` (the cross-plane contract is the file).
|
|
4
|
+
//
|
|
5
|
+
// Each run writes only its OWN record, keyed by `run_id`, under the SHARED MAIN CHECKOUT
|
|
6
|
+
// (`mainCheckoutRoot(cwd)`) so a linked-worktree run and a later resolver agree on one location. A
|
|
7
|
+
// run fills only the slots it owns (planning runs → `planning.*`; implement runs →
|
|
8
|
+
// `implementation.*`); the four class/site slots are always present (null when unset) so this
|
|
9
|
+
// read-modify-write merges trivially and never clobbers a sibling write.
|
|
10
|
+
//
|
|
11
|
+
// Seam doctrine — best-effort + loud-but-non-fatal: every write is wrapped so a failure (unwritable
|
|
12
|
+
// root, bad disk) warns to stderr and returns false, NEVER throws. A capture failure must never
|
|
13
|
+
// wedge the save/launch/drive it rides on. Node builtins + cache.ts + git.ts only (loads under
|
|
14
|
+
// `node --test`).
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { basename, join } from "node:path";
|
|
18
|
+
import { runScratchDir } from "./cache.ts";
|
|
19
|
+
import { mainCheckoutRoot } from "./git.ts";
|
|
20
|
+
|
|
21
|
+
export const SESSION_POINTERS_FILE = "session-pointers.json";
|
|
22
|
+
|
|
23
|
+
/** One captured session pointer (a `main` or `worker` slot of a class). */
|
|
24
|
+
export interface SessionPointer {
|
|
25
|
+
/** The session-file basename (matches the `perk:workflow-state` stamp). */
|
|
26
|
+
pi_session_id: string;
|
|
27
|
+
/** The absolute path known at capture (informational). */
|
|
28
|
+
session_file: string;
|
|
29
|
+
/** The inherited parent session (fork/replacement provenance), else null. */
|
|
30
|
+
parent_pi_session_id: string | null;
|
|
31
|
+
/** ISO-8601 capture time. */
|
|
32
|
+
at: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The two capture sites of one session class (`main` = interior, `worker` = headless). */
|
|
36
|
+
export interface SessionClassPointers {
|
|
37
|
+
main: SessionPointer | null;
|
|
38
|
+
worker: SessionPointer | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A run's full session-pointer record (`session-pointers.json`). */
|
|
42
|
+
export interface SessionPointers {
|
|
43
|
+
run_id: string;
|
|
44
|
+
planning: SessionClassPointers;
|
|
45
|
+
implementation: SessionClassPointers;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type SessionClass = "planning" | "implementation";
|
|
49
|
+
export type SessionSite = "main" | "worker";
|
|
50
|
+
|
|
51
|
+
/** The empty four-slot record for a run (all slots null until a capture fills one). */
|
|
52
|
+
function emptyRecord(runId: string): SessionPointers {
|
|
53
|
+
return {
|
|
54
|
+
run_id: runId,
|
|
55
|
+
planning: { main: null, worker: null },
|
|
56
|
+
implementation: { main: null, worker: null },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sessionPointersPath(root: string, runId: string): string {
|
|
61
|
+
return join(runScratchDir(root, runId), SESSION_POINTERS_FILE);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read a run's session-pointers record from the shared main checkout; `null` when absent or
|
|
66
|
+
* unparseable (best-effort — a corrupt record is treated as absent, never thrown). `root` is the
|
|
67
|
+
* MAIN checkout (resolve via `mainCheckoutRoot(cwd)` at the call site).
|
|
68
|
+
*/
|
|
69
|
+
export function readSessionPointers(root: string, runId: string): SessionPointers | null {
|
|
70
|
+
const path = sessionPointersPath(root, runId);
|
|
71
|
+
if (!existsSync(path)) return null;
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(readFileSync(path, "utf8")) as SessionPointers;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Record one session pointer into `<class>.<site>` of the run's record, under the shared main
|
|
81
|
+
* checkout (`mainCheckoutRoot(cwd)`). Read-modify-write: an existing record is loaded (or a fresh
|
|
82
|
+
* four-slot record minted), only the named slot is set, and the whole record is written back — so
|
|
83
|
+
* a planning write and an implementation write to the SAME run record never clobber each other.
|
|
84
|
+
* Best-effort: returns `true` on a successful write, `false` (with a stderr warning) on any
|
|
85
|
+
* failure. Never throws. Serialized byte-compatibly with the Python writer (key order +
|
|
86
|
+
* 2-space indent + trailing newline).
|
|
87
|
+
*/
|
|
88
|
+
export function recordSessionPointer(
|
|
89
|
+
cwd: string,
|
|
90
|
+
runId: string,
|
|
91
|
+
klass: SessionClass,
|
|
92
|
+
site: SessionSite,
|
|
93
|
+
pointer: SessionPointer,
|
|
94
|
+
): boolean {
|
|
95
|
+
if (!runId) return false;
|
|
96
|
+
const root = mainCheckoutRoot(cwd);
|
|
97
|
+
try {
|
|
98
|
+
const record = readSessionPointers(root, runId) ?? emptyRecord(runId);
|
|
99
|
+
// The run id is authoritative — a record read from disk keeps its own; a fresh one is minted
|
|
100
|
+
// with `runId`. (A mismatched on-disk run_id is left as-is; self-keying guarantees a match.)
|
|
101
|
+
record[klass][site] = pointer;
|
|
102
|
+
const dir = runScratchDir(root, runId);
|
|
103
|
+
mkdirSync(dir, { recursive: true });
|
|
104
|
+
writeFileSync(sessionPointersPath(root, runId), serialize(record), "utf8");
|
|
105
|
+
return true;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error(`perk: warning: could not record session pointer (${klass}.${site}): ${error}`);
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Capture one session pointer into `<class>.<site>` from a session file path — the call-site
|
|
114
|
+
* convenience over `recordSessionPointer`. Derives `pi_session_id` from the basename and stamps
|
|
115
|
+
* `at`. A `null`/empty `sessionFile` or `runId` is a no-op (`false`) — best-effort, never throws.
|
|
116
|
+
*/
|
|
117
|
+
export function captureSessionPointer(args: {
|
|
118
|
+
cwd: string;
|
|
119
|
+
runId: string;
|
|
120
|
+
klass: SessionClass;
|
|
121
|
+
site: SessionSite;
|
|
122
|
+
sessionFile: string | null | undefined;
|
|
123
|
+
parentSessionId?: string | null;
|
|
124
|
+
}): boolean {
|
|
125
|
+
const { cwd, runId, klass, site, sessionFile } = args;
|
|
126
|
+
if (!sessionFile || !runId) return false;
|
|
127
|
+
return recordSessionPointer(cwd, runId, klass, site, {
|
|
128
|
+
pi_session_id: basename(sessionFile),
|
|
129
|
+
session_file: sessionFile,
|
|
130
|
+
parent_pi_session_id: args.parentSessionId ?? null,
|
|
131
|
+
at: new Date().toISOString(),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Serialize a record byte-compatibly with the Python writer (explicit key order, null slots). */
|
|
136
|
+
function serialize(record: SessionPointers): string {
|
|
137
|
+
const slot = (p: SessionPointer | null): Record<string, unknown> | null =>
|
|
138
|
+
p === null
|
|
139
|
+
? null
|
|
140
|
+
: {
|
|
141
|
+
pi_session_id: p.pi_session_id,
|
|
142
|
+
session_file: p.session_file,
|
|
143
|
+
parent_pi_session_id: p.parent_pi_session_id,
|
|
144
|
+
at: p.at,
|
|
145
|
+
};
|
|
146
|
+
const payload = {
|
|
147
|
+
run_id: record.run_id,
|
|
148
|
+
planning: { main: slot(record.planning.main), worker: slot(record.planning.worker) },
|
|
149
|
+
implementation: {
|
|
150
|
+
main: slot(record.implementation.main),
|
|
151
|
+
worker: slot(record.implementation.worker),
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
return `${JSON.stringify(payload, null, 2)}\n`;
|
|
155
|
+
}
|
|
@@ -33,6 +33,14 @@ export const READ_ONLY_TOOLS = [
|
|
|
33
33
|
// working-objective artifact in the session data dir (fixed artifact name, seam-derived
|
|
34
34
|
// path); the gate's edit/write/bash blocking is unchanged.
|
|
35
35
|
"objective_draft",
|
|
36
|
+
// The objective_node carve-out: it never touches the worktree — it delegates a bounded,
|
|
37
|
+
// workflow-owned node transition to the canonical Python plane (`perk objective node`). Both
|
|
38
|
+
// objective-plan factory paths run gated (the cold door hands off `mode: read-only`; the warm
|
|
39
|
+
// `/objective-plan` enters the gate before seeding), and the factory loop's
|
|
40
|
+
// `objective_node_claim` carrier — which the approval-driven save's node-link recovery depends
|
|
41
|
+
// on — can only be written by calling this tool inside the gated session. Excluding it
|
|
42
|
+
// silently breaks the warm `/objective-plan` path: the plan saves unlinked.
|
|
43
|
+
"objective_node",
|
|
36
44
|
// The `web` seam providers' research tools: the UNION of all known web-provider tool
|
|
37
45
|
// names, allowlisted statically and inert when the package is absent (the plan_review precedent
|
|
38
46
|
// — setActiveTools simply has nothing to enable). None mutate the repo — fetch_content's
|
|
@@ -192,7 +192,7 @@ export function createPerkStatus(): PerkStatusHandle {
|
|
|
192
192
|
/**
|
|
193
193
|
* The raw material for one composed footer line. Left group (charter order 1–3): `identity`,
|
|
194
194
|
* `objective`, `checkpoints` — the segments render verbatim (they carry their own 🎯/📋 marks).
|
|
195
|
-
* Right group (charter order 4, 5, +context, 6): `branch`, `model`, `context`, `guests` —
|
|
195
|
+
* Right group (charter order 4, 5, +context, 6): `branch`, `model`, `thinking`, `context`, `guests` —
|
|
196
196
|
* right-aligned, non-segment system text dim-themed.
|
|
197
197
|
*/
|
|
198
198
|
export interface FooterParts {
|
|
@@ -206,6 +206,8 @@ export interface FooterParts {
|
|
|
206
206
|
branch?: string;
|
|
207
207
|
/** Model id (dim); omitted when no model. */
|
|
208
208
|
model?: string;
|
|
209
|
+
/** The session thinking level (dim; e.g. `high`/`off`); omitted when there is no model. */
|
|
210
|
+
thinking?: string;
|
|
209
211
|
/** Context usage — rendered `<pct>%/<window>` (dim; warning >70, error >90; `?` when null). */
|
|
210
212
|
context?: { percent: number | null; contextWindow: number };
|
|
211
213
|
/** Guest extension statuses (dim), pre-sorted by slot key; sanitized here. */
|
|
@@ -236,14 +238,15 @@ function formatContextSegment(
|
|
|
236
238
|
* Compose THE one footer line (FOOTER_MAX_LINES = 1): left group = identity + objective +
|
|
237
239
|
* checkpoints (two-space-joined, charter order); right group = branch + model + context + guests
|
|
238
240
|
* (two-space-joined), right-aligned with ≥2 spaces of padding. When the line exceeds `width`,
|
|
239
|
-
* whole segments drop in the extended D9 order — guests (rightmost-first) →
|
|
240
|
-
* context → checkpoints; `identity` and `objective` are NEVER dropped — then `truncateToWidth`
|
|
241
|
+
* whole segments drop in the extended D9 order — guests (rightmost-first) → thinking → model →
|
|
242
|
+
* branch → context → checkpoints; `identity` and `objective` are NEVER dropped — then `truncateToWidth`
|
|
241
243
|
* as the last resort (ANSI- and 2-cell-emoji-aware).
|
|
242
244
|
*/
|
|
243
245
|
export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: number): string {
|
|
244
246
|
const keep = {
|
|
245
247
|
guests: parts.guests.map((g) => sanitizeGuestStatus(g)),
|
|
246
248
|
model: true,
|
|
249
|
+
thinking: true,
|
|
247
250
|
branch: true,
|
|
248
251
|
context: true,
|
|
249
252
|
checkpoints: true,
|
|
@@ -255,6 +258,7 @@ export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: n
|
|
|
255
258
|
const right: string[] = [];
|
|
256
259
|
if (keep.branch && parts.branch !== undefined) right.push(theme.fg("dim", parts.branch));
|
|
257
260
|
if (keep.model && parts.model !== undefined) right.push(theme.fg("dim", parts.model));
|
|
261
|
+
if (keep.thinking && parts.thinking !== undefined) right.push(theme.fg("dim", parts.thinking));
|
|
258
262
|
if (keep.context && parts.context !== undefined) {
|
|
259
263
|
right.push(formatContextSegment(parts.context, theme));
|
|
260
264
|
}
|
|
@@ -268,6 +272,7 @@ export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: n
|
|
|
268
272
|
let line = compose();
|
|
269
273
|
while (visibleWidth(line) > width) {
|
|
270
274
|
if (keep.guests.length > 0) keep.guests.pop();
|
|
275
|
+
else if (keep.thinking) keep.thinking = false;
|
|
271
276
|
else if (keep.model) keep.model = false;
|
|
272
277
|
else if (keep.branch) keep.branch = false;
|
|
273
278
|
else if (keep.context) keep.context = false;
|
|
@@ -293,6 +298,7 @@ export interface PerkFooterDeps {
|
|
|
293
298
|
identity: string;
|
|
294
299
|
status: PerkStatusHandle;
|
|
295
300
|
getModelId(): string | null;
|
|
301
|
+
getThinkingLevel(): string | null;
|
|
296
302
|
getContext(): { percent: number | null; contextWindow: number } | null;
|
|
297
303
|
}
|
|
298
304
|
|
|
@@ -331,6 +337,7 @@ export function perkFooter(deps: PerkFooterDeps): PerkFooterFactory {
|
|
|
331
337
|
checkpoints: deps.status.get("checkpoints"),
|
|
332
338
|
branch: footerData.getGitBranch() ?? undefined,
|
|
333
339
|
model: deps.getModelId() ?? undefined,
|
|
340
|
+
thinking: deps.getThinkingLevel() ?? undefined,
|
|
334
341
|
context: deps.getContext() ?? undefined,
|
|
335
342
|
guests,
|
|
336
343
|
};
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
//
|
|
15
15
|
// Inverse of `extension/worker/readOnlySession.ts`: that builds a fully-isolated READ-ONLY child (loads
|
|
16
16
|
// nothing, `["read","grep","find","ls"]`); the worker is the OPPOSITE — read-write defaults + the
|
|
17
|
-
// real perk extension loaded from the worktree's `.pi/settings.json` (
|
|
17
|
+
// real perk extension loaded from the worktree's `.pi/settings.json` (disk-layered settings:
|
|
18
|
+
// `SettingsManager.create(worktree, throwawayAgentDir)` resolves the managed project-tier
|
|
19
|
+
// `packages` list — perk + the borrowed set, the same package set as a warm session), with the
|
|
18
20
|
// user-global tier locked out via a throwaway `agentDir`.
|
|
19
21
|
|
|
20
22
|
import { appendFileSync, mkdtempSync } from "node:fs";
|
|
@@ -25,7 +27,6 @@ import type { Api, Model } from "@earendil-works/pi-ai";
|
|
|
25
27
|
import {
|
|
26
28
|
AuthStorage,
|
|
27
29
|
type CreateAgentSessionRuntimeFactory,
|
|
28
|
-
type CreateAgentSessionServicesOptions,
|
|
29
30
|
createAgentSessionFromServices,
|
|
30
31
|
createAgentSessionRuntime,
|
|
31
32
|
createAgentSessionServices,
|
|
@@ -37,6 +38,7 @@ import { planReadInstruction } from "../doors/lifecycleGates.ts";
|
|
|
37
38
|
import { ensureRunScratch, type PlanRef, readPlanRef, runEventsPath } from "../substrate/cache.ts";
|
|
38
39
|
import { loadPerkConfig } from "../substrate/config.ts";
|
|
39
40
|
import { render } from "../substrate/prompts.ts";
|
|
41
|
+
import { captureSessionPointer } from "../substrate/sessionPointers.ts";
|
|
40
42
|
import { rebuildWorkflowState } from "../substrate/workflowState.ts";
|
|
41
43
|
import { capForModel } from "./readOnlySession.ts";
|
|
42
44
|
|
|
@@ -115,7 +117,12 @@ export interface DriveStageOptions {
|
|
|
115
117
|
stage: DriveStage;
|
|
116
118
|
/** The seeded first prompt (see `initialPromptFor`). */
|
|
117
119
|
initialPrompt: string;
|
|
118
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Explicit model; else the SDK's own default resolution picks one at session creation
|
|
122
|
+
* (settings `defaultModel` → pi's per-provider defaults → first available — Gap 5). Never
|
|
123
|
+
* pre-pinned here: `getAvailable()` sorts alphabetically, so `[0]` is the *oldest* model of
|
|
124
|
+
* the first provider (a since-removed `claude-3-5-haiku` date-pin 404'd a whole remote drive).
|
|
125
|
+
*/
|
|
119
126
|
model?: Model<Api>;
|
|
120
127
|
authStorage?: AuthStorage;
|
|
121
128
|
modelRegistry?: ModelRegistry;
|
|
@@ -126,12 +133,11 @@ export interface DriveStageOptions {
|
|
|
126
133
|
|
|
127
134
|
/**
|
|
128
135
|
* The offline seam (mirrors `readOnlySession.test.ts`'s `runTask` injection). `createRuntime`
|
|
129
|
-
* overrides the production runtime factory so tests drive synthetic sessions; `
|
|
130
|
-
*
|
|
136
|
+
* overrides the production runtime factory so tests drive synthetic sessions; `now` injects the
|
|
137
|
+
* clock for deterministic `elapsed_ms`.
|
|
131
138
|
*/
|
|
132
139
|
export interface DriveStageDeps {
|
|
133
140
|
createRuntime?: (opts: DriveStageOptions) => Promise<DriveRuntimeLike>;
|
|
134
|
-
resourceLoaderOptions?: CreateAgentSessionServicesOptions["resourceLoaderOptions"];
|
|
135
141
|
now?: () => number;
|
|
136
142
|
/** The structured run-event sink. Absent ⇒ the default run-scoped NDJSON file sink. */
|
|
137
143
|
eventSink?: RunEventSink;
|
|
@@ -162,7 +168,13 @@ export interface DriveSessionLike {
|
|
|
162
168
|
prompt(text: string): Promise<void>;
|
|
163
169
|
abort(): Promise<void>;
|
|
164
170
|
dispose(): void;
|
|
165
|
-
sessionManager: { getBranch(): unknown[] };
|
|
171
|
+
sessionManager: { getBranch(): unknown[]; getSessionFile?(): string | null };
|
|
172
|
+
/**
|
|
173
|
+
* Optional (presence-gated): when the session exposes its extension runner, `driveStage`
|
|
174
|
+
* preflights the stage's terminating perk tool post-bind and fails fast (zero-turn
|
|
175
|
+
* `no_extension_tools`) instead of burning the budget on a tool-less session.
|
|
176
|
+
*/
|
|
177
|
+
extensionRunner?: { getAllRegisteredTools(): { definition: { name: string } }[] };
|
|
166
178
|
}
|
|
167
179
|
|
|
168
180
|
/** The runtime surface (structurally satisfied by pi's `AgentSessionRuntime`). */
|
|
@@ -314,6 +326,17 @@ export function evaluateTerminal(args: {
|
|
|
314
326
|
};
|
|
315
327
|
}
|
|
316
328
|
|
|
329
|
+
/**
|
|
330
|
+
* The post-bind preflight rule (pure): the stage's terminating perk tool must be registered —
|
|
331
|
+
* `implement` → `submit`, `address` → `resolve_review_threads`. Returns the required tool name
|
|
332
|
+
* when absent, else `null`. Deliberately does NOT require the `subagent` tool for `address` — the
|
|
333
|
+
* subagent-under-worker live smoke stays the §8.11 carried risk.
|
|
334
|
+
*/
|
|
335
|
+
export function missingTerminatingTool(stage: DriveStage, toolNames: string[]): string | null {
|
|
336
|
+
const required = stage === "implement" ? "submit" : "resolve_review_threads";
|
|
337
|
+
return toolNames.includes(required) ? null : required;
|
|
338
|
+
}
|
|
339
|
+
|
|
317
340
|
/** Pull a `{ number, url }` PR from a captured `submit` details block; null when malformed. */
|
|
318
341
|
function extractPr(
|
|
319
342
|
details: Record<string, unknown> | null,
|
|
@@ -548,20 +571,24 @@ export function createBindManager(binding: unknown, listener: (event: DriveEvent
|
|
|
548
571
|
/**
|
|
549
572
|
* Build the asymmetric runtime: `cwd = worktree` (project tier — perk's `@mgiles/perk` extension via the
|
|
550
573
|
* managed `.pi/settings.json`, the managed `AGENTS.md`/`APPEND_SYSTEM.md`) and `agentDir = throwaway`
|
|
551
|
-
* (user-global tier OUT
|
|
552
|
-
*
|
|
553
|
-
*
|
|
574
|
+
* (user-global tier OUT — the throwaway dir has no `settings.json`, so the global tier is empty),
|
|
575
|
+
* env-var/registry auth+model (Gap 5). Settings are DISK-LAYERED (`SettingsManager.create` +
|
|
576
|
+
* `applyOverrides`, the SDK's sanctioned "with overrides" shape — docs/sdk.md "Settings
|
|
577
|
+
* Management"): the project tier resolves the managed `packages` list, while the compaction-off/
|
|
578
|
+
* retry-off determinism overrides ride the merged view only (package resolution reads the
|
|
579
|
+
* per-scope raws — overrides cannot leak into it). Missing `npm:` packages auto-install into
|
|
580
|
+
* `.pi/npm` during the loader's reload (skipped under `PI_OFFLINE`); an install failure throws →
|
|
581
|
+
* `driveStage`'s catch arm → a loud `failed`/`drive_error`. No `tools` allowlist — read-write
|
|
582
|
+
* defaults + extension tools. The `createAgentSessionServices` factory builds the
|
|
583
|
+
* `DefaultResourceLoader` internally from `cwd`/`agentDir` (recipe correction #1).
|
|
554
584
|
*/
|
|
555
585
|
async function defaultCreateRuntime(
|
|
556
586
|
opts: DriveStageOptions,
|
|
557
|
-
|
|
558
|
-
resolved: { authStorage: AuthStorage; modelRegistry: ModelRegistry; model: Model<Api> },
|
|
587
|
+
resolved: ResolvedAuth,
|
|
559
588
|
): Promise<DriveRuntimeLike> {
|
|
560
589
|
const agentDir = mkdtempSync(join(tmpdir(), "perk-worker-agent-"));
|
|
561
|
-
const settingsManager = SettingsManager.
|
|
562
|
-
|
|
563
|
-
retry: { enabled: false },
|
|
564
|
-
});
|
|
590
|
+
const settingsManager = SettingsManager.create(opts.worktree, agentDir);
|
|
591
|
+
settingsManager.applyOverrides({ compaction: { enabled: false }, retry: { enabled: false } });
|
|
565
592
|
const factory: CreateAgentSessionRuntimeFactory = async (factoryOpts) => {
|
|
566
593
|
const services = await createAgentSessionServices({
|
|
567
594
|
cwd: factoryOpts.cwd,
|
|
@@ -569,14 +596,29 @@ async function defaultCreateRuntime(
|
|
|
569
596
|
authStorage: resolved.authStorage,
|
|
570
597
|
settingsManager,
|
|
571
598
|
modelRegistry: resolved.modelRegistry,
|
|
572
|
-
resourceLoaderOptions: deps.resourceLoaderOptions,
|
|
573
599
|
});
|
|
574
600
|
const result = await createAgentSessionFromServices({
|
|
575
601
|
services,
|
|
576
602
|
sessionManager: factoryOpts.sessionManager,
|
|
577
603
|
sessionStartEvent: factoryOpts.sessionStartEvent,
|
|
604
|
+
// `undefined` ⇒ the SDK's initial-model resolution picks the model (see `resolveAuth`).
|
|
578
605
|
model: resolved.model,
|
|
579
606
|
});
|
|
607
|
+
// Name the model that will actually drive (the SDK may have picked it) — the remote step
|
|
608
|
+
// log is otherwise silent about it until a provider error.
|
|
609
|
+
const chosen = result.session.model;
|
|
610
|
+
console.error(
|
|
611
|
+
`perk worker: model ${chosen ? `${chosen.provider}/${chosen.id}` : "unresolved"}`,
|
|
612
|
+
);
|
|
613
|
+
// Loud construction diagnostics (the CAUSE behind a later `no_extension_tools` symptom):
|
|
614
|
+
// settings I/O errors and extension load errors are recorded, not raised, by the SDK —
|
|
615
|
+
// surfacing them is the app layer's job. Fail-soft reporting only; never throws.
|
|
616
|
+
for (const entry of result.extensionsResult.errors) {
|
|
617
|
+
console.error(`perk worker: extension load error — ${entry.path}: ${entry.error}`);
|
|
618
|
+
}
|
|
619
|
+
for (const entry of settingsManager.drainErrors()) {
|
|
620
|
+
console.error(`perk worker: settings error (${entry.scope}) — ${String(entry.error)}`);
|
|
621
|
+
}
|
|
580
622
|
return { ...result, services, diagnostics: services.diagnostics };
|
|
581
623
|
};
|
|
582
624
|
const runtime = await createAgentSessionRuntime(factory, {
|
|
@@ -589,19 +631,25 @@ async function defaultCreateRuntime(
|
|
|
589
631
|
|
|
590
632
|
// --- model/auth resolution (Gap 5) --------------------------------------------------------------
|
|
591
633
|
|
|
592
|
-
interface ResolvedAuth {
|
|
634
|
+
export interface ResolvedAuth {
|
|
593
635
|
authStorage: AuthStorage;
|
|
594
636
|
modelRegistry: ModelRegistry;
|
|
595
|
-
model
|
|
637
|
+
/** The EXPLICIT model only; `undefined` defers the pick to the SDK at session creation. */
|
|
638
|
+
model: Model<Api> | undefined;
|
|
596
639
|
}
|
|
597
640
|
|
|
598
|
-
/**
|
|
599
|
-
|
|
641
|
+
/**
|
|
642
|
+
* Resolve auth; returns null (never throws) when no model is available at all. The model is NOT
|
|
643
|
+
* pre-pinned from the registry: an `undefined` model lets `createAgentSession` run its own
|
|
644
|
+
* initial-model resolution (settings `defaultModel` → pi's curated per-provider defaults → first
|
|
645
|
+
* available), which picks a current-generation model instead of the registry's
|
|
646
|
+
* alphabetically-first (= oldest) entry.
|
|
647
|
+
*/
|
|
648
|
+
export function resolveAuth(opts: DriveStageOptions): ResolvedAuth | null {
|
|
600
649
|
const authStorage = opts.authStorage ?? AuthStorage.create();
|
|
601
650
|
const modelRegistry = opts.modelRegistry ?? ModelRegistry.create(authStorage);
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
return { authStorage, modelRegistry, model };
|
|
651
|
+
if (!opts.model && modelRegistry.getAvailable().length === 0) return null;
|
|
652
|
+
return { authStorage, modelRegistry, model: opts.model };
|
|
605
653
|
}
|
|
606
654
|
|
|
607
655
|
// --- the drive primitive ------------------------------------------------------------------------
|
|
@@ -677,12 +725,50 @@ export async function driveStage(
|
|
|
677
725
|
runtime = deps.createRuntime
|
|
678
726
|
? await deps.createRuntime(opts)
|
|
679
727
|
: // biome-ignore lint/style/noNonNullAssertion: resolved is non-null on the production path.
|
|
680
|
-
await defaultCreateRuntime(opts,
|
|
728
|
+
await defaultCreateRuntime(opts, resolved!);
|
|
681
729
|
|
|
682
730
|
let boundSession = runtime.session;
|
|
683
731
|
await bindManager.bind(boundSession);
|
|
684
732
|
emitter.emit({ kind: "run_started", run_id: runId, stage: opts.stage });
|
|
685
733
|
|
|
734
|
+
// Terminating-tool preflight (presence-gated on `extensionRunner`): disk discovery has a
|
|
735
|
+
// silent-zero arm — a missing/unparseable `.pi/settings.json` or an unresolvable local-path
|
|
736
|
+
// package yields ZERO extension tools without throwing — so fail fast (zero turns) instead of
|
|
737
|
+
// burning the whole budget on a drive that can never call its terminating tool. Reuses the
|
|
738
|
+
// `model_error` terminal signal with a distinct `error.type` (the `no_model` precedent).
|
|
739
|
+
if (boundSession.extensionRunner) {
|
|
740
|
+
const toolNames = boundSession.extensionRunner
|
|
741
|
+
.getAllRegisteredTools()
|
|
742
|
+
.map((t) => t.definition.name);
|
|
743
|
+
const missing = missingTerminatingTool(opts.stage, toolNames);
|
|
744
|
+
if (missing !== null) {
|
|
745
|
+
return finish({
|
|
746
|
+
status: "failed",
|
|
747
|
+
terminal_signal: "model_error",
|
|
748
|
+
pr: null,
|
|
749
|
+
errorType: "no_extension_tools",
|
|
750
|
+
errorMessage:
|
|
751
|
+
`perk extension tools did not register — the ${opts.stage} stage's terminating ` +
|
|
752
|
+
`tool \`${missing}\` is missing. Check the worktree's .pi/settings.json packages ` +
|
|
753
|
+
"list (perk init converges it); construction diagnostics are on stderr.",
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Implementation/worker session pointer (contracts.md §8.35): the headless drive records the
|
|
759
|
+
// inner driven session's file under THIS run id into the shared main checkout (the worktree's
|
|
760
|
+
// `mainCheckoutRoot`), labelled `.worker` by capture site. The inner session's own
|
|
761
|
+
// `session_start` records the matching `.main`. Best-effort + non-fatal (carrier warns).
|
|
762
|
+
if (opts.stage === "implement") {
|
|
763
|
+
captureSessionPointer({
|
|
764
|
+
cwd: opts.worktree,
|
|
765
|
+
runId,
|
|
766
|
+
klass: "implementation",
|
|
767
|
+
site: "worker",
|
|
768
|
+
sessionFile: boundSession.sessionManager.getSessionFile?.() ?? null,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
|
|
686
772
|
// Budget/abort wiring (Gap 2): wall-clock timer + external signal both trip → session.abort().
|
|
687
773
|
const timer = setTimeout(() => trip("budget"), opts.budget.wallClockMs);
|
|
688
774
|
if (opts.signal) {
|
package/extension/workerMain.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { argv, env, exit, stderr, stdout } from "node:process";
|
|
12
12
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
13
13
|
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
14
|
-
import { runEventsPath } from "./substrate/cache.ts";
|
|
14
|
+
import { runEventsPath, workflowDir } from "./substrate/cache.ts";
|
|
15
15
|
import {
|
|
16
16
|
type DriveBudget,
|
|
17
17
|
type DriveStage,
|
|
@@ -90,12 +90,14 @@ async function main(): Promise<number> {
|
|
|
90
90
|
const initialPrompt = initialPromptForWorktree(parsed.worktree, parsed.stage);
|
|
91
91
|
if (initialPrompt === null) {
|
|
92
92
|
stderr.write(
|
|
93
|
-
`perk worker: no plan-ref under ${parsed.worktree}
|
|
93
|
+
`perk worker: no plan-ref under ${workflowDir(parsed.worktree)} — cannot seed the ${parsed.stage} prompt.\n`,
|
|
94
94
|
);
|
|
95
95
|
return 2;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
// Headless auth/model (Gap 5): env-var key resolution; `--model provider/id` else
|
|
98
|
+
// Headless auth/model (Gap 5): env-var key resolution; `--model provider/id` else the SDK's
|
|
99
|
+
// default resolution at session creation (settings default → pi's per-provider defaults →
|
|
100
|
+
// first available).
|
|
99
101
|
const authStorage = AuthStorage.create();
|
|
100
102
|
const modelRegistry = ModelRegistry.create(authStorage);
|
|
101
103
|
let model: Model<Api> | undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mgiles/perk",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "perk Pi extension (session interior) for the plan-oriented workflow.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,9 +34,6 @@
|
|
|
34
34
|
"lint": "biome check extension",
|
|
35
35
|
"format": "biome format --write extension"
|
|
36
36
|
},
|
|
37
|
-
"dependencies": {
|
|
38
|
-
"nunjucks": "3.2.4"
|
|
39
|
-
},
|
|
40
37
|
"peerDependencies": {
|
|
41
38
|
"@earendil-works/pi-ai": "*",
|
|
42
39
|
"@earendil-works/pi-coding-agent": "*",
|
|
@@ -48,7 +45,6 @@
|
|
|
48
45
|
"@earendil-works/pi-ai": "0.78.1",
|
|
49
46
|
"@earendil-works/pi-coding-agent": "0.78.1",
|
|
50
47
|
"@types/node": "22.19.19",
|
|
51
|
-
"@types/nunjucks": "3.2.6",
|
|
52
48
|
"typescript": "6.0.3",
|
|
53
49
|
"yaml": "2.9.0"
|
|
54
50
|
}
|
package/prompts/README.md
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
# prompts —
|
|
1
|
+
# prompts — perk's externalized prompt prose
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This directory is the canonical home for **all** of perk's externalized prompt prose. A
|
|
4
|
+
template may be **cross-plane** (rendered in production by both planes) or **single-plane**
|
|
5
|
+
(rendered by only one plane — e.g. a warm-door-only or cold-door-only seed/guidance prompt);
|
|
6
|
+
either way it is authored within the frozen mini-jinja subset below and listed in
|
|
7
|
+
`_fixtures/live.yaml`, where it is rendered on **both** engines and asserted byte-equal
|
|
8
|
+
regardless of which plane consumes it in production (a free cross-engine portability guarantee,
|
|
9
|
+
costing nothing since the subset is shared).
|
|
10
|
+
|
|
11
|
+
The templates are authored once and **bundled into every build artifact**
|
|
4
12
|
(the Python wheel as package data `perk/_prompts/`; the npm package under `prompts/`),
|
|
5
13
|
exactly like `shared/`. Each plane locates this directory at runtime through its own
|
|
6
14
|
resolver — `prompts_dir()` (`perk/_resources.py`) and `promptsDir()`
|
|
@@ -10,6 +18,49 @@ Templates are rendered by jinja2 (Python) and a vendored TS subset (the extensio
|
|
|
10
18
|
are loaded by explicit name through the resolver — never by scanning the directory, so
|
|
11
19
|
this README is a durable doc, not a template.
|
|
12
20
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
21
|
+
## Frozen template grammar
|
|
22
|
+
|
|
23
|
+
The templates use a deliberately tiny, **frozen** subset of jinja syntax — the canonical
|
|
24
|
+
"mini-jinja" surface. jinja2 is the reference engine; the extension renders the same subset. A
|
|
25
|
+
**cross-plane conformance guard** (`tests/test_prompt_grammar.py` +
|
|
26
|
+
`extension/substrate/promptGrammar.test.ts`) fails CI if any template uses a construct outside the
|
|
27
|
+
subset.
|
|
28
|
+
|
|
29
|
+
## Render parity — two decoupled tiers
|
|
30
|
+
|
|
31
|
+
The two render seams (jinja2 on Python, the vendored mini-jinja on TS) are kept byte-identical by
|
|
32
|
+
two tiers that separate the frozen render **contract** from real prompt **prose**:
|
|
33
|
+
|
|
34
|
+
- **Tier A — contract snapshots.** `_fixtures/cases.yaml` lists purpose-built fixture templates
|
|
35
|
+
under `_fixtures/templates/` (one per render feature) with committed goldens under
|
|
36
|
+
`_fixtures/golden/` (jinja2's output). Both planes assert `render == golden`
|
|
37
|
+
(`tests/test_prompts.py` + `extension/substrate/prompts.test.ts`). These goldens change only when
|
|
38
|
+
the render contract changes — never when a real prompt's prose changes.
|
|
39
|
+
- **Tier B — live cross-engine equality.** `_fixtures/live.yaml` lists every real template with
|
|
40
|
+
representative vars and **no** golden. `tests/test_prompt_parity.py` renders each real template
|
|
41
|
+
with jinja2, shells out once to `extension/testing/renderLive.ts` (mini-jinja), and asserts the
|
|
42
|
+
two outputs are byte-equal — so editing a real prompt's prose touches no fixture. A coverage
|
|
43
|
+
guard asserts every real template is listed in `live.yaml`.
|
|
44
|
+
|
|
45
|
+
The subset is exactly four categories:
|
|
46
|
+
|
|
47
|
+
1. **Variable substitution** — `{{ name }}`, where the contents are a single bare identifier
|
|
48
|
+
(`[A-Za-z_][A-Za-z0-9_]*`). Nothing else: no filters, no dotted access, no parentheses, no
|
|
49
|
+
literals, no operators.
|
|
50
|
+
2. **Include** — `{% include "path/to/file.md" %}`, a double-quoted root-relative path only.
|
|
51
|
+
3. **Conditionals** — `{% if cond %}` / `{% elif cond %}` / `{% else %}` / `{% endif %}`, where
|
|
52
|
+
`cond` uses only bare identifiers (truthiness), double-quoted string literals, `==`, and the
|
|
53
|
+
keywords `and` / `or` / `not`. For example: `{% if provider == "github" or provider == "linear" %}`,
|
|
54
|
+
`{% if not pr_id %}`.
|
|
55
|
+
4. **Plain tags** — `{% %}` only. The whitespace-control markers `{%- … -%}` / `{{- … -}}` are
|
|
56
|
+
**not allowed**; tag-line stripping is handled by the render env's `trim_blocks` setting.
|
|
57
|
+
|
|
58
|
+
**Not allowed** (the guard fails on these): `{% for x in y %}` / `{% endfor %}`, `{% set %}`,
|
|
59
|
+
`{% macro %}` / `{% block %}` / `{% extends %}` / `{% raw %}`, `{# comments #}`, filters
|
|
60
|
+
(`{{ x | upper }}`), attribute access (`{{ user.name }}`), `!=` / `<` / `>`, `in`, `is`,
|
|
61
|
+
parentheses, and numeric literals.
|
|
62
|
+
|
|
63
|
+
This README is **excluded** from the guard's scan — it is documentation, never rendered, and the
|
|
64
|
+
out-of-subset examples above are shown deliberately as prose. The canonical spec lives in
|
|
65
|
+
[`shared/contracts.md` §8.31](../shared/contracts.md). Widening the subset is a deliberate
|
|
66
|
+
decision that amends §8.31 **and** both guards.
|