@mgiles/perk 2.3.0 → 3.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 +1 -1
- package/extension/adapters/planAdapterPlannotator.ts +135 -91
- package/extension/adapters/planAdapterTombell.ts +5 -3
- package/extension/doors/address.ts +235 -37
- package/extension/doors/annotationPush.ts +924 -0
- package/extension/doors/auditWaveTools.ts +352 -0
- package/extension/doors/ciExecutor.ts +220 -55
- package/extension/doors/commitCompact.ts +98 -10
- package/extension/doors/draftReviewWaveTools.ts +412 -0
- package/extension/doors/dreamWaveTools.ts +475 -0
- package/extension/doors/harvestWaveTools.ts +279 -0
- package/extension/doors/land.ts +1 -0
- package/extension/doors/learn.ts +24 -6
- package/extension/doors/learnFactory.ts +33 -7
- package/extension/doors/lifecycleGates.ts +1 -1
- package/extension/doors/objectiveReviewBrowser.ts +537 -0
- package/extension/doors/objectiveStack.ts +1143 -0
- package/extension/doors/planReviewBrowser.ts +475 -0
- package/extension/doors/plannotatorHandoff.ts +120 -49
- package/extension/doors/prReview.ts +180 -63
- package/extension/doors/prReviewBrowser.ts +31 -23
- package/extension/doors/prReviewDynamic.ts +57 -30
- package/extension/doors/prReviewTerminal.ts +6 -11
- package/extension/doors/reviewWaveTools.ts +397 -0
- package/extension/doors/selfcheck.ts +1 -1
- package/extension/doors/submit.ts +82 -7
- package/extension/factories/gistAuthor.ts +4 -4
- package/extension/factories/gistDraft.ts +1 -1
- package/extension/factories/gistSave.ts +2 -2
- package/extension/factories/objective.ts +7 -7
- package/extension/factories/objectiveAuthor.ts +5 -4
- package/extension/factories/objectiveDraft.ts +153 -30
- package/extension/factories/objectiveDreamReport.ts +347 -0
- package/extension/factories/objectivePlan.ts +163 -9
- package/extension/factories/objectiveSave.ts +90 -2
- package/extension/factories/planMode.ts +5 -3
- package/extension/factories/planReview.ts +304 -46
- package/extension/factories/planTitle.ts +27 -9
- package/extension/hunkFeedback/inbox.ts +389 -0
- package/extension/hunkFeedback/perkFeedback.ts +364 -0
- package/extension/hunkFeedback/receiver.ts +296 -0
- package/extension/hunkFeedback/store.ts +533 -0
- package/extension/index.ts +169 -57
- package/extension/substrate/agentScratch.ts +171 -0
- package/extension/substrate/bindingDelivery.ts +9 -11
- package/extension/substrate/cache.ts +166 -23
- package/extension/substrate/coldDoor.ts +2 -3
- package/extension/substrate/command.ts +9 -6
- package/extension/substrate/config.ts +41 -12
- package/extension/substrate/git.ts +95 -2
- package/extension/substrate/providers.ts +4 -33
- package/extension/substrate/resources.ts +11 -0
- package/extension/substrate/result.ts +16 -7
- package/extension/substrate/sessionData.ts +8 -6
- package/extension/substrate/sessionPointers.ts +4 -5
- package/extension/substrate/structuredOutput.ts +78 -19
- package/extension/substrate/toolGating.ts +179 -23
- package/extension/substrate/workflowState.ts +50 -2
- package/extension/surfaces/footerProvider.ts +6 -6
- package/extension/surfaces/report.ts +38 -12
- package/extension/surfaces/surfaces.ts +213 -275
- package/extension/vendor/btw/btw.ts +94 -17
- package/extension/waves/adversarialReviewWave.ts +181 -0
- package/extension/waves/auditWave.ts +312 -0
- package/extension/waves/draftReviewWave.ts +205 -0
- package/extension/waves/dreamReducerWave.ts +700 -0
- package/extension/waves/dreamReport.ts +1494 -0
- package/extension/waves/dreamWave.ts +927 -0
- package/extension/waves/harvestWave.ts +399 -0
- package/extension/waves/memoryAdapter.ts +14 -1
- package/extension/waves/objectiveExplorerWave.ts +120 -0
- package/extension/waves/ponytail.ts +104 -0
- package/extension/waves/prReviewDynamicWave.ts +383 -72
- package/extension/waves/prReviewWave.ts +175 -25
- package/extension/waves/reportWave.ts +503 -107
- package/extension/waves/reviewClassifierWave.ts +109 -0
- package/extension/waves/rpcAdapter.ts +65 -2
- package/extension/worker/readOnlySession.ts +4 -5
- package/extension/worker/worker.ts +71 -93
- package/extension/workerMain.ts +4 -6
- package/package.json +18 -5
- package/prompts/README.md +19 -0
- package/prompts/_fixtures/live.yaml +115 -65
- package/prompts/commit-and-compact-continuation.md +13 -0
- package/prompts/common/resume-advisory.md +1 -0
- package/prompts/contexts/adapters/plannotator-gist.md +13 -0
- package/prompts/contexts/adapters/plannotator-objective.md +8 -3
- package/prompts/contexts/adapters/plannotator-plan.md +8 -3
- package/prompts/contexts/gist-authoring.md +6 -20
- package/prompts/contexts/objective-authoring.md +6 -18
- package/prompts/stages/address/action.md +3 -14
- package/prompts/stages/address/preview.md +2 -13
- package/prompts/stages/audit.md +18 -0
- package/prompts/stages/conflict-resolution.md +1 -1
- package/prompts/stages/gist-author/seed.md +4 -4
- package/prompts/stages/gist-save.md +1 -1
- package/prompts/stages/implement.md +3 -1
- package/prompts/stages/learn-code.md +1 -1
- package/prompts/stages/learn-docs.md +2 -2
- package/prompts/stages/learn-dream.md +10 -0
- package/prompts/stages/learn-harvest.md +15 -0
- package/prompts/stages/learn-orchestrate.md +1 -1
- package/prompts/stages/objective-author/adopt.md +5 -3
- package/prompts/stages/objective-author/file.md +5 -4
- package/prompts/stages/objective-author/seed.md +5 -4
- package/prompts/stages/objective-land.md +7 -0
- package/prompts/stages/objective-plan/guidance.md +2 -13
- package/prompts/stages/objective-plan/seed.md +5 -12
- package/prompts/stages/objective-recover.md +8 -0
- package/prompts/stages/objective-replan.md +4 -2
- package/prompts/stages/objective-review-browser.md +9 -0
- package/prompts/stages/objective-sync.md +6 -0
- package/prompts/stages/plan-from/adopt.md +2 -2
- package/prompts/stages/plan-from/file.md +2 -2
- package/prompts/stages/plan-review-browser.md +9 -0
- package/prompts/stages/pr-review-browser/active.md +6 -14
- package/prompts/stages/pr-review-browser/foreign.md +6 -14
- package/prompts/stages/pr-review-dynamic.md +6 -6
- package/prompts/stages/pr-review-terminal/active.md +7 -15
- package/prompts/stages/pr-review-terminal/foreign.md +7 -15
- package/prompts/stages/pr-review-terminal/local.md +1 -1
- package/prompts/stages/pr-review.md +5 -5
- package/prompts/stages/replan.md +3 -3
- package/shared/README.md +2 -2
- package/shared/bindings.yaml +15 -0
- package/shared/contracts-history.md +11 -0
- package/shared/contracts.md +5373 -281
- package/shared/providers.yaml +27 -60
- package/shared/registry.yaml +42 -18
- package/shared/schemas/inputs/review-post-batch.schema.json +14 -1
- package/shared/schemas/outputs/objective-doctor.schema.json +487 -0
- package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
- package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
- package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
- package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
- package/shared/schemas/outputs/plan-save.schema.json +12 -0
- package/shared/schemas/outputs/pr-land.schema.json +3 -3
- package/shared/schemas/outputs/pr-submit.schema.json +177 -1
- package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
- package/extension/checkpoints/checkpoints.ts +0 -550
- package/extension/checkpoints/planSteps.ts +0 -108
- package/extension/doors/askUser.ts +0 -198
- package/prompts/common/output-schemas/objective-explorer.md +0 -36
- package/prompts/common/output-schemas/review-classifier.md +0 -47
- package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
|
@@ -10,9 +10,56 @@
|
|
|
10
10
|
// the read-only gate engages. The Python twins (src/perk/state/cache.py) deliberately keep
|
|
11
11
|
// RAISING `CacheError` (exterior plane, launch-time fail-loud) — the cross-plane contract is
|
|
12
12
|
// the *files*, not error semantics.
|
|
13
|
+
//
|
|
14
|
+
// Write discipline (contracts.md §8.1): every `.perk/workflow/` write goes through
|
|
15
|
+
// `atomicWriteFileSync` (temp file in the same directory + atomic rename) so a concurrent
|
|
16
|
+
// writer can never tear a file — a reader sees either the old bytes or the new bytes, never a
|
|
17
|
+
// mix (guard-tested by writeGuard.test.ts). The exemptions are the append-only NDJSON streams
|
|
18
|
+
// — the worker's `events.ndjson` (worker/worker.ts) and the §8.58 hunk-watch `outbox.ndjson` /
|
|
19
|
+
// `delivered.ndjson` (hunkFeedback/perkFeedback.ts / hunkFeedback/store.ts) — where O_APPEND
|
|
20
|
+
// appends cannot truncate-tear and whole-file replace would introduce a read-modify-write race
|
|
21
|
+
// between independent processes. Atomicity is not mutual exclusion — whole-file
|
|
22
|
+
// last-writer-wins between concurrent writers is the accepted residual.
|
|
23
|
+
|
|
24
|
+
import { randomBytes } from "node:crypto";
|
|
25
|
+
import {
|
|
26
|
+
chmodSync,
|
|
27
|
+
existsSync,
|
|
28
|
+
lstatSync,
|
|
29
|
+
mkdirSync,
|
|
30
|
+
readdirSync,
|
|
31
|
+
readFileSync,
|
|
32
|
+
realpathSync,
|
|
33
|
+
renameSync,
|
|
34
|
+
rmSync,
|
|
35
|
+
writeFileSync,
|
|
36
|
+
} from "node:fs";
|
|
37
|
+
import { join, relative } from "node:path";
|
|
13
38
|
|
|
14
|
-
|
|
15
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Atomically replace `path` with `content` (the interior atomic-write seam).
|
|
41
|
+
*
|
|
42
|
+
* Writes a temp file in the same directory (so the rename never crosses filesystems) then swaps
|
|
43
|
+
* it into place; a concurrent reader sees either the old bytes or the new bytes, never a torn
|
|
44
|
+
* mix. On failure the temp file is best-effort removed and the error re-thrown. Precondition:
|
|
45
|
+
* the parent directory exists (same contract as `writeFileSync`; call sites mkdir first).
|
|
46
|
+
* Deliberately no fsync — crash durability is out of scope; the target is inter-process tearing
|
|
47
|
+
* of regenerable, gitignored workflow state.
|
|
48
|
+
*/
|
|
49
|
+
export function atomicWriteFileSync(path: string, content: string): void {
|
|
50
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
51
|
+
try {
|
|
52
|
+
writeFileSync(tmp, content, "utf8");
|
|
53
|
+
renameSync(tmp, path);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
try {
|
|
56
|
+
rmSync(tmp, { force: true });
|
|
57
|
+
} catch {
|
|
58
|
+
// best-effort cleanup — a cleanup failure must never mask the original write/rename error
|
|
59
|
+
}
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
16
63
|
|
|
17
64
|
export interface Handoff {
|
|
18
65
|
run_id: string;
|
|
@@ -67,7 +114,7 @@ export function markHandoffConsumed(
|
|
|
67
114
|
if (data === null) return;
|
|
68
115
|
data.consumed = true;
|
|
69
116
|
if (opts.piSessionId !== undefined) data.pi_session_id = opts.piSessionId;
|
|
70
|
-
|
|
117
|
+
atomicWriteFileSync(handoffPath(cwd, runId), `${JSON.stringify(data, null, 2)}\n`);
|
|
71
118
|
}
|
|
72
119
|
|
|
73
120
|
// --- scratch -----------------------------------------------------------------------------
|
|
@@ -85,6 +132,11 @@ export function runScratchDir(cwd: string, runId: string): string {
|
|
|
85
132
|
return join(scratchDir(cwd), "runs", runId);
|
|
86
133
|
}
|
|
87
134
|
|
|
135
|
+
/** The run-owned directory for disposable, non-authoritative model intermediates. */
|
|
136
|
+
export function agentScratchDir(cwd: string, runId: string): string {
|
|
137
|
+
return join(runScratchDir(cwd, runId), "agent");
|
|
138
|
+
}
|
|
139
|
+
|
|
88
140
|
/**
|
|
89
141
|
* The session data dir for a run — a dedicated `data/` subdir so
|
|
90
142
|
* run-scoped session artifacts never overlap perk machine records (dispatch.json,
|
|
@@ -95,9 +147,91 @@ export function sessionDataDir(cwd: string, runId: string): string {
|
|
|
95
147
|
return join(runScratchDir(cwd, runId), "data");
|
|
96
148
|
}
|
|
97
149
|
|
|
150
|
+
/** Reject run ids that could select anything except one child of the shared runs directory. */
|
|
151
|
+
function assertSafeRunId(runId: string): void {
|
|
152
|
+
if (
|
|
153
|
+
runId.length === 0 ||
|
|
154
|
+
runId === "." ||
|
|
155
|
+
runId === ".." ||
|
|
156
|
+
runId.includes("/") ||
|
|
157
|
+
runId.includes("\\") ||
|
|
158
|
+
runId.includes("\0")
|
|
159
|
+
) {
|
|
160
|
+
throw new Error(`refusing unsafe run id ${JSON.stringify(runId)}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Ensure one checkout-owned path component is a real directory, never a static redirect.
|
|
166
|
+
* Check-before-create races against a same-UID process are intentionally out of scope.
|
|
167
|
+
*/
|
|
168
|
+
function ensureUnredirectedDirectory(
|
|
169
|
+
path: string,
|
|
170
|
+
opts: { createMode: number; rejectGroupWorldWrite: boolean },
|
|
171
|
+
): void {
|
|
172
|
+
let stat: ReturnType<typeof lstatSync>;
|
|
173
|
+
try {
|
|
174
|
+
stat = lstatSync(path);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
177
|
+
mkdirSync(path, { mode: opts.createMode });
|
|
178
|
+
stat = lstatSync(path);
|
|
179
|
+
}
|
|
180
|
+
if (stat.isSymbolicLink()) throw new Error(`refusing a symlinked run-scratch path: ${path}`);
|
|
181
|
+
if (!stat.isDirectory()) throw new Error(`refusing a non-directory run-scratch path: ${path}`);
|
|
182
|
+
if (opts.rejectGroupWorldWrite && (stat.mode & 0o022) !== 0) {
|
|
183
|
+
throw new Error(`refusing a group/world-writable run-scratch path: ${path}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Establish a run root beneath this checkout without following redirected checkout content.
|
|
189
|
+
* Symlinks above `cwd` remain legal; every existing component from `.perk` through the run root
|
|
190
|
+
* must be a real directory without group/world write permission, and missing components are
|
|
191
|
+
* created no broader than 0755 even under a permissive umask. Run-id validation happens before
|
|
192
|
+
* the first filesystem write.
|
|
193
|
+
*/
|
|
98
194
|
export function ensureRunScratch(cwd: string, runId: string): string {
|
|
195
|
+
assertSafeRunId(runId);
|
|
99
196
|
const dir = runScratchDir(cwd, runId);
|
|
100
|
-
|
|
197
|
+
const components = [
|
|
198
|
+
join(cwd, ".perk"),
|
|
199
|
+
join(cwd, ".perk", "workflow"),
|
|
200
|
+
scratchDir(cwd),
|
|
201
|
+
join(scratchDir(cwd), "runs"),
|
|
202
|
+
dir,
|
|
203
|
+
];
|
|
204
|
+
for (const component of components) {
|
|
205
|
+
ensureUnredirectedDirectory(component, {
|
|
206
|
+
createMode: 0o755,
|
|
207
|
+
rejectGroupWorldWrite: true,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const expected = join(realpathSync(cwd), relative(cwd, dir));
|
|
212
|
+
if (realpathSync(dir) !== expected) {
|
|
213
|
+
throw new Error(`refusing a redirected run-scratch dir: ${dir}`);
|
|
214
|
+
}
|
|
215
|
+
return dir;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Create the private run-owned agent directory as 0700 from the outset, then re-apply that mode on
|
|
220
|
+
* reuse. The mode protects against other OS users; it is not a sandbox from another process running
|
|
221
|
+
* as the same user.
|
|
222
|
+
*/
|
|
223
|
+
export function ensureAgentScratch(cwd: string, runId: string): string {
|
|
224
|
+
const runDir = ensureRunScratch(cwd, runId);
|
|
225
|
+
const dir = agentScratchDir(cwd, runId);
|
|
226
|
+
ensureUnredirectedDirectory(dir, {
|
|
227
|
+
createMode: 0o700,
|
|
228
|
+
rejectGroupWorldWrite: false,
|
|
229
|
+
});
|
|
230
|
+
const expected = join(realpathSync(runDir), "agent");
|
|
231
|
+
if (realpathSync(dir) !== expected) {
|
|
232
|
+
throw new Error(`refusing a redirected agent scratch dir: ${dir}`);
|
|
233
|
+
}
|
|
234
|
+
chmodSync(dir, 0o700);
|
|
101
235
|
return dir;
|
|
102
236
|
}
|
|
103
237
|
|
|
@@ -142,29 +276,37 @@ export function readPlanRef(cwd: string): PlanRef | null {
|
|
|
142
276
|
|
|
143
277
|
export function writePlanRef(cwd: string, ref: PlanRef): void {
|
|
144
278
|
mkdirSync(workflowDir(cwd), { recursive: true });
|
|
145
|
-
|
|
279
|
+
atomicWriteFileSync(planRefPath(cwd), `${JSON.stringify(ref, null, 2)}\n`);
|
|
146
280
|
}
|
|
147
281
|
|
|
148
|
-
// ---
|
|
282
|
+
// --- hunk-watch: the watch-feedback bridge family (contracts.md §8.58) ---------------------
|
|
283
|
+
//
|
|
284
|
+
// Worktree-local, disposable: append-only NDJSON streams plus the single-consumer lease dir.
|
|
285
|
+
// This module is the INTERIOR construction site for the family; the hunk-plane twin is the
|
|
286
|
+
// self-contained bundled publisher (extension/hunkFeedback/perkFeedback.ts), pinned to these
|
|
287
|
+
// helpers by a path-parity test.
|
|
149
288
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
* (`perk implement` → `launch.materialize_plan_body`) when it positions the worktree; read here so
|
|
153
|
-
* in-session checkpoints seed from its `## Steps` list (inert when absent).
|
|
154
|
-
*/
|
|
155
|
-
export function planBodyPath(cwd: string): string {
|
|
156
|
-
return join(workflowDir(cwd), "plan.md");
|
|
289
|
+
export function hunkWatchDir(cwd: string): string {
|
|
290
|
+
return join(workflowDir(cwd), "hunk-watch");
|
|
157
291
|
}
|
|
158
292
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
293
|
+
/** Append-only feedback records (the Hunk publisher writes; the Pi receiver reads). */
|
|
294
|
+
export function hunkOutboxPath(cwd: string): string {
|
|
295
|
+
return join(hunkWatchDir(cwd), "outbox.ndjson");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Append-only delivery acknowledgements (the Pi receiver writes). */
|
|
299
|
+
export function hunkDeliveredPath(cwd: string): string {
|
|
300
|
+
return join(hunkWatchDir(cwd), "delivered.ndjson");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The single-consumer lease dir (atomic mkdir is the acquisition primitive). */
|
|
304
|
+
export function hunkConsumerLockDir(cwd: string): string {
|
|
305
|
+
return join(hunkWatchDir(cwd), "consumer.lock");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function hunkLeasePath(cwd: string): string {
|
|
309
|
+
return join(hunkConsumerLockDir(cwd), "lease.json");
|
|
168
310
|
}
|
|
169
311
|
|
|
170
312
|
// --- markers (existence-only) ------------------------------------------------------------
|
|
@@ -178,7 +320,8 @@ export function markerPath(cwd: string, name: string): string {
|
|
|
178
320
|
|
|
179
321
|
export function setMarker(cwd: string, name: string): void {
|
|
180
322
|
mkdirSync(join(workflowDir(cwd), "markers"), { recursive: true });
|
|
181
|
-
|
|
323
|
+
// Routed through the atomic seam for uniformity (empty content is trivially safe either way).
|
|
324
|
+
atomicWriteFileSync(markerPath(cwd, name), "");
|
|
182
325
|
}
|
|
183
326
|
|
|
184
327
|
export function hasMarker(cwd: string, name: string): boolean {
|
|
@@ -14,10 +14,9 @@
|
|
|
14
14
|
// });
|
|
15
15
|
// if (!r.ok) return fail(r.message, r.errorType);
|
|
16
16
|
|
|
17
|
-
import { writeFileSync } from "node:fs";
|
|
18
17
|
import { join } from "node:path";
|
|
19
18
|
import type { ExecOptions, ExecResult } from "@earendil-works/pi-coding-agent";
|
|
20
|
-
import { ensureRunScratch } from "./cache.ts";
|
|
19
|
+
import { atomicWriteFileSync, ensureRunScratch } from "./cache.ts";
|
|
21
20
|
import { type BranchSource, branchOf, rebuildWorkflowState } from "./workflowState.ts";
|
|
22
21
|
|
|
23
22
|
/** Minimal exec surface — `ExtensionAPI` satisfies it (compile-checked in the test); tests fake it. */
|
|
@@ -134,7 +133,7 @@ export async function runColdDoor<T>(
|
|
|
134
133
|
try {
|
|
135
134
|
const dir = ensureRunScratch(ctx.cwd, activeRunId(ctx));
|
|
136
135
|
const path = join(dir, opts.stdin.filename);
|
|
137
|
-
|
|
136
|
+
atomicWriteFileSync(path, opts.stdin.content);
|
|
138
137
|
fullArgv = [...argv, opts.stdin.flag, path];
|
|
139
138
|
} catch (err) {
|
|
140
139
|
return {
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// The single chokepoint that gives every perk command a uniform, immediate "running…"
|
|
2
2
|
// acknowledgement at entry. pi does not echo the invoked command and handlers only report() at the
|
|
3
3
|
// end, so without this a command's async work (cold-door subprocess calls, GitHub round-trips) is
|
|
4
|
-
// dead air between Enter and completion. registerPerkCommand
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// dead air between Enter and completion. registerPerkCommand attaches the command's durable report-
|
|
5
|
+
// detail sink to the exact context object, then emits one transient entry toast through report()
|
|
6
|
+
// before awaiting the original handler. The toast fires synchronously before the first await, so it
|
|
7
|
+
// lands before any cold-door work, sendUserMessage drive, or gate transition; the wrapper does not
|
|
8
|
+
// try/catch, so errors propagate exactly as before. The WeakMap attachment deliberately survives the
|
|
9
|
+
// handler for background work launched with the same context.
|
|
9
10
|
|
|
10
11
|
import type { ExtensionAPI, RegisteredCommand } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import { report } from "../surfaces/report.ts";
|
|
12
|
+
import { attachReportDetailSink, report } from "../surfaces/report.ts";
|
|
13
|
+
import { createReportDetailSink } from "../surfaces/surfaces.ts";
|
|
12
14
|
|
|
13
15
|
export function registerPerkCommand(
|
|
14
16
|
pi: ExtensionAPI,
|
|
@@ -18,6 +20,7 @@ export function registerPerkCommand(
|
|
|
18
20
|
pi.registerCommand(name, {
|
|
19
21
|
...options,
|
|
20
22
|
handler: async (args, ctx) => {
|
|
23
|
+
attachReportDetailSink(ctx, createReportDetailSink(pi));
|
|
21
24
|
report(ctx, name, "info", "running…");
|
|
22
25
|
await options.handler(args, ctx);
|
|
23
26
|
},
|
|
@@ -48,11 +48,16 @@ export interface PerkConfig {
|
|
|
48
48
|
/**
|
|
49
49
|
* The agent-keyed `[models.subagents]` table: a per-agent model override for each perk-owned
|
|
50
50
|
* project agent (`pr-reviewer`, `review-classifier`, `objective-explorer`, `conflict-resolver`,
|
|
51
|
-
* `learn-analyst`, `adversarial-reviewer`, `review-angle-selector`
|
|
51
|
+
* `learn-analyst`, `adversarial-reviewer`, `review-angle-selector`, `draft-reviewer`,
|
|
52
|
+
* `harvest-analyst` — consumed by `run_harvest_wave` at execute time — `dream-analyst` and
|
|
53
|
+
* `dream-reducer` — consumed by `run_dream_wave` at execute time — and the
|
|
54
|
+
* dev-only `session-auditor`, whose def is repo-local to perk's own repository
|
|
55
|
+
* (`.pi/agents/perk-dev/session-auditor.md`, never delivered by `perk init`), so the key is
|
|
56
|
+
* dormant in consumer repos). Each configured
|
|
52
57
|
* value is injected as the top-level workflow-level `model` on that agent's one `subagent`
|
|
53
58
|
* workflowScript call — a default flowing onto every lane, single-child runs included (as
|
|
54
|
-
* /pr-review does); when
|
|
55
|
-
*
|
|
59
|
+
* /pr-review does); when a key is absent the agent's frontmatter `model` (in
|
|
60
|
+
* `.pi/agents/perk/<name>.md`; the session-auditor's in its repo-local def) is the default.
|
|
56
61
|
* (`subagents.agentOverrides` does NOT reach project agents — `pi-subagents`'
|
|
57
62
|
* `applyBuiltinOverrides` applies only to builtins — so this inline injection is the mechanism.)
|
|
58
63
|
* A value may carry a `:thinking` suffix (`"anthropic/claude-sonnet-4-5:high"`) or be the
|
|
@@ -69,6 +74,11 @@ export interface PerkConfig {
|
|
|
69
74
|
"learn-analyst"?: string;
|
|
70
75
|
"adversarial-reviewer"?: string;
|
|
71
76
|
"review-angle-selector"?: string;
|
|
77
|
+
"draft-reviewer"?: string;
|
|
78
|
+
"harvest-analyst"?: string;
|
|
79
|
+
"dream-analyst"?: string;
|
|
80
|
+
"dream-reducer"?: string;
|
|
81
|
+
"session-auditor"?: string;
|
|
72
82
|
};
|
|
73
83
|
/**
|
|
74
84
|
* Optional `[compaction] objective_threshold` — the context-usage fraction (0,1] that triggers
|
|
@@ -86,8 +96,6 @@ export interface PerkConfig {
|
|
|
86
96
|
*/
|
|
87
97
|
providers: {
|
|
88
98
|
plan?: string;
|
|
89
|
-
todo?: string;
|
|
90
|
-
askuser?: string;
|
|
91
99
|
footer?: string;
|
|
92
100
|
web?: string;
|
|
93
101
|
};
|
|
@@ -268,6 +276,24 @@ export function loadPerkConfig(cwd: string): PerkConfig {
|
|
|
268
276
|
};
|
|
269
277
|
}
|
|
270
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Resolve one `[models.subagents]` model at execute time — the flow-tool read for sessions that
|
|
281
|
+
* may run inside a linked worktree. Committed `.perk/config.toml` is read from `cwd` (the
|
|
282
|
+
* worktree's committed semantics), but the gitignored `.perk/local.toml` lives only in the MAIN
|
|
283
|
+
* checkout when `cwd` is a linked worktree (worktrees never materialize it), so the local
|
|
284
|
+
* overlay is additionally anchored via `mainCheckoutRoot` — a user's session-transient model
|
|
285
|
+
* override survives the cold worktree launch. A worktree-local `local.toml`, if one exists,
|
|
286
|
+
* still wins (most specific last); in the main checkout the two local reads are the same file
|
|
287
|
+
* (byte-identical behavior). Fail-open like everything here — missing/malformed files are empty.
|
|
288
|
+
*/
|
|
289
|
+
export function subagentModel(cwd: string, agent: SubagentKey): string | undefined {
|
|
290
|
+
const merged = overlay(
|
|
291
|
+
overlay(readTomlFile(configFile(cwd)), readTomlFile(localConfigFile(mainCheckoutRoot(cwd)))),
|
|
292
|
+
readTomlFile(localConfigFile(cwd)),
|
|
293
|
+
);
|
|
294
|
+
return parseSubagentsSelection(merged.tables["models.subagents"])[agent];
|
|
295
|
+
}
|
|
296
|
+
|
|
271
297
|
/**
|
|
272
298
|
* Read the `[[ci.checks]]` array-of-tables into an ordered `CiCheck[]`. A row is kept only when
|
|
273
299
|
* both `name` and `command` are non-blank strings; `glob` is kept only when a non-blank string.
|
|
@@ -289,6 +315,9 @@ export function parseCiChecks(rows: Array<Record<string, TomlScalar>>): CiCheck[
|
|
|
289
315
|
return checks;
|
|
290
316
|
}
|
|
291
317
|
|
|
318
|
+
/** One perk-owned project agent name configurable via the `[models.subagents]` table. */
|
|
319
|
+
export type SubagentKey = (typeof SUBAGENT_KEYS)[number];
|
|
320
|
+
|
|
292
321
|
/** The perk-owned project agents configurable via the `[models.subagents]` table. */
|
|
293
322
|
const SUBAGENT_KEYS = [
|
|
294
323
|
"pr-reviewer",
|
|
@@ -298,6 +327,12 @@ const SUBAGENT_KEYS = [
|
|
|
298
327
|
"learn-analyst",
|
|
299
328
|
"adversarial-reviewer",
|
|
300
329
|
"review-angle-selector",
|
|
330
|
+
"draft-reviewer",
|
|
331
|
+
"harvest-analyst",
|
|
332
|
+
"dream-analyst",
|
|
333
|
+
"dream-reducer",
|
|
334
|
+
// Dev-only: the perk-dev session-audit judgment wave's auditor (dormant in consumer repos).
|
|
335
|
+
"session-auditor",
|
|
301
336
|
] as const;
|
|
302
337
|
|
|
303
338
|
/**
|
|
@@ -316,24 +351,18 @@ function parseSubagentsSelection(
|
|
|
316
351
|
return selection;
|
|
317
352
|
}
|
|
318
353
|
|
|
319
|
-
/** Read the flat `[providers]` table into a `{plan?,
|
|
354
|
+
/** Read the flat `[providers]` table into a `{plan?, footer?, web?}` selection (string values only). Retired keys (`review`, `askuser`, `todo`) are silently ignored (the TS fail-safe posture; the Python plane's tripwire is the loud surface). */
|
|
320
355
|
function parseProvidersSelection(table: Record<string, TomlScalar> | undefined): {
|
|
321
356
|
plan?: string;
|
|
322
|
-
todo?: string;
|
|
323
|
-
askuser?: string;
|
|
324
357
|
footer?: string;
|
|
325
358
|
web?: string;
|
|
326
359
|
} {
|
|
327
360
|
const selection: {
|
|
328
361
|
plan?: string;
|
|
329
|
-
todo?: string;
|
|
330
|
-
askuser?: string;
|
|
331
362
|
footer?: string;
|
|
332
363
|
web?: string;
|
|
333
364
|
} = {};
|
|
334
365
|
if (typeof table?.plan === "string") selection.plan = table.plan;
|
|
335
|
-
if (typeof table?.todo === "string") selection.todo = table.todo;
|
|
336
|
-
if (typeof table?.askuser === "string") selection.askuser = table.askuser;
|
|
337
366
|
if (typeof table?.footer === "string") selection.footer = table.footer;
|
|
338
367
|
if (typeof table?.web === "string") selection.web = table.web;
|
|
339
368
|
return selection;
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// Node builtins only (so it loads cleanly under `node --test`); shells `git` via `execFileSync`,
|
|
4
4
|
// never with a shell. Fail-open by design: every failure degrades to the caller's `cwd` (or null
|
|
5
5
|
// where stated) rather than throwing — the carriers that use this must never wedge a session.
|
|
6
|
+
// The ONE deliberate fail-closed composition is `revalidationBracket` (documented there): a
|
|
7
|
+
// snapshot proof must treat an unprovable probe as drift, never as "unchanged".
|
|
6
8
|
|
|
7
9
|
import { execFileSync } from "node:child_process";
|
|
8
10
|
import { isAbsolute, resolve } from "node:path";
|
|
@@ -48,6 +50,16 @@ function git(cwd: string, args: string[], timeout?: number): string | null {
|
|
|
48
50
|
}
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Tracked files under `pathspec` (repo-relative names), [] when none or on ANY failure (not a
|
|
55
|
+
* repo, git missing — the module's fail-open style). Callers deciding trust on the result must
|
|
56
|
+
* treat [] as "nothing PROVEN tracked", not proof of cleanliness.
|
|
57
|
+
*/
|
|
58
|
+
export function lsFiles(cwd: string, pathspec: string): string[] {
|
|
59
|
+
const out = git(cwd, ["ls-files", "--", pathspec]);
|
|
60
|
+
return out === null ? [] : out.split("\n").filter((line) => line !== "");
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
/** The bounded best-effort `git fetch` budget (ms) — see `sinceBaseSha` step 2. */
|
|
52
64
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
53
65
|
|
|
@@ -105,9 +117,90 @@ export function worktreeDirty(cwd: string): boolean | null {
|
|
|
105
117
|
}
|
|
106
118
|
|
|
107
119
|
/**
|
|
108
|
-
*
|
|
120
|
+
* Whether the index carries `assume-unchanged` (a lowercase `git ls-files -v` tag) or
|
|
121
|
+
* `skip-worktree` (`S`/`s` — sparse checkouts) entries. Either bit hides worktree edits from
|
|
122
|
+
* `git status --porcelain`, so a status-based cleanliness proof over a flagged index is not a
|
|
123
|
+
* proof. **Fail-open to null** on any failure (not a repo, git missing) — callers must NOT
|
|
124
|
+
* conflate null with "no flags". Own `execFileSync` rather than the `git()` helper: `git()`
|
|
125
|
+
* conflates empty output (an empty index — meaningful here) with failure.
|
|
126
|
+
*/
|
|
127
|
+
export function indexHidesChanges(cwd: string): boolean | null {
|
|
128
|
+
try {
|
|
129
|
+
const out = execFileSync("git", ["ls-files", "-v"], {
|
|
130
|
+
cwd,
|
|
131
|
+
encoding: "utf8",
|
|
132
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
133
|
+
});
|
|
134
|
+
return out.split("\n").some((line) => {
|
|
135
|
+
const tag = line[0];
|
|
136
|
+
return tag !== undefined && (tag === "S" || (tag >= "a" && tag <= "z"));
|
|
137
|
+
});
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The dream-snapshot revalidation bracket (contracts.md §8.65) — the module's ONE deliberately
|
|
145
|
+
* **fail-closed** composition (a documented exception to the fail-open charter above): it exists
|
|
146
|
+
* to PROVE the repository still matches a stamped snapshot, so an unprovable probe must read as
|
|
147
|
+
* drift, never as "unchanged". The claim is END-STATE equality only — HEAD unchanged, the
|
|
148
|
+
* working tree clean, and no assume-unchanged/skip-worktree index flags (which would hide edits
|
|
149
|
+
* from the status probe) at the moment of the check — never mid-window byte immutability (a
|
|
150
|
+
* transient modify-and-restore inside the window is invisible by design; §8.65's accepted
|
|
151
|
+
* residuals). `probes` defaults to the real `headSha`/`worktreeDirty`/`indexHidesChanges` and
|
|
152
|
+
* exists so tests can pin each fail-closed arm independently (from a non-repo fixture the HEAD
|
|
153
|
+
* arm returns first, making the later null arms reachable only through the seam).
|
|
154
|
+
*/
|
|
155
|
+
export function revalidationBracket(
|
|
156
|
+
cwd: string,
|
|
157
|
+
expectedSha: string,
|
|
158
|
+
probes?: {
|
|
159
|
+
head?: (cwd: string) => string | null;
|
|
160
|
+
dirty?: (cwd: string) => boolean | null;
|
|
161
|
+
flags?: (cwd: string) => boolean | null;
|
|
162
|
+
},
|
|
163
|
+
): { ok: boolean; detail: string | null } {
|
|
164
|
+
const head = probes?.head ?? headSha;
|
|
165
|
+
const dirty = probes?.dirty ?? worktreeDirty;
|
|
166
|
+
const flags = probes?.flags ?? indexHidesChanges;
|
|
167
|
+
const actual = head(cwd);
|
|
168
|
+
if (actual === null) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
detail: "HEAD could not be resolved — cannot prove the snapshot is unchanged",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (actual !== expectedSha) {
|
|
175
|
+
return { ok: false, detail: `HEAD moved from ${expectedSha} to ${actual}` };
|
|
176
|
+
}
|
|
177
|
+
const isDirty = dirty(cwd);
|
|
178
|
+
if (isDirty === null) {
|
|
179
|
+
return { ok: false, detail: "working-tree cleanliness could not be verified" };
|
|
180
|
+
}
|
|
181
|
+
if (isDirty) {
|
|
182
|
+
return { ok: false, detail: "the working tree is no longer clean" };
|
|
183
|
+
}
|
|
184
|
+
const hidden = flags(cwd);
|
|
185
|
+
if (hidden === null) {
|
|
186
|
+
return { ok: false, detail: "index flag state could not be verified" };
|
|
187
|
+
}
|
|
188
|
+
if (hidden) {
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
detail:
|
|
192
|
+
"the index carries assume-unchanged/skip-worktree flag(s) — worktree cleanliness " +
|
|
193
|
+
"cannot be proven against the snapshot",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return { ok: true, detail: null };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The `git log --oneline <fromSha>..HEAD` listing of commits now ahead of `fromSha` — or every
|
|
109
201
|
* commit (`git log --oneline HEAD`) when `fromSha` is null (HEAD was unborn at capture time).
|
|
110
|
-
*
|
|
202
|
+
* This is range evidence, not proof that this command created every listed commit. **Fail-open**:
|
|
203
|
+
* null on failure or when the range is empty.
|
|
111
204
|
*/
|
|
112
205
|
export function commitsSince(cwd: string, fromSha: string | null): string | null {
|
|
113
206
|
const range = fromSha === null ? "HEAD" : `${fromSha}..HEAD`;
|
|
@@ -2,13 +2,12 @@
|
|
|
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/
|
|
5
|
+
// SET — the catalog of plan/footer/web providers perk knows how to wire —
|
|
6
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
|
|
10
|
-
// `planMode` (plan-seam registration-time vacating when a foreign plan provider is selected)
|
|
11
|
-
// todo-seam runtime deferral is likewise live.
|
|
10
|
+
// `planMode` (plan-seam registration-time vacating when a foreign plan provider is selected).
|
|
12
11
|
|
|
13
12
|
import { readFileSync } from "node:fs";
|
|
14
13
|
import { join } from "node:path";
|
|
@@ -24,12 +23,10 @@ export interface Provider {
|
|
|
24
23
|
packageFilter?: Record<string, unknown>;
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
export const PROVIDER_SEAMS = ["plan", "
|
|
26
|
+
export const PROVIDER_SEAMS = ["plan", "footer", "web"] as const;
|
|
28
27
|
|
|
29
28
|
/** The bundled reference provider ids (the behavior-preserving no-config defaults per seam). */
|
|
30
29
|
export const PERK_PLAN_PROVIDER_ID = "perk-plan";
|
|
31
|
-
export const PERK_CHECKPOINTS_PROVIDER_ID = "perk-checkpoints";
|
|
32
|
-
export const PERK_ASK_USER_PROVIDER_ID = "perk-ask-user";
|
|
33
30
|
export const PERK_FOOTER_PROVIDER_ID = "perk-footer";
|
|
34
31
|
|
|
35
32
|
/** The foreign `@tombell/pi-plan` plan-provider id. */
|
|
@@ -38,12 +35,6 @@ export const TOMBELL_PLAN_PROVIDER_ID = "tombell-plan";
|
|
|
38
35
|
/** The foreign `@plannotator/pi-extension` plan-provider id (augment-posture adapter selection check). */
|
|
39
36
|
export const PLANNOTATOR_PLAN_PROVIDER_ID = "plannotator-plan";
|
|
40
37
|
|
|
41
|
-
/** The foreign `@juicesharp/rpiv-todo` todo-provider id. */
|
|
42
|
-
export const JUICESHARP_TODO_PROVIDER_ID = "juicesharp-todo";
|
|
43
|
-
|
|
44
|
-
/** The foreign `@juicesharp/rpiv-ask-user-question` askuser-provider id (vacate-only interface seam). */
|
|
45
|
-
export const JUICESHARP_ASK_USER_PROVIDER_ID = "juicesharp-ask-user";
|
|
46
|
-
|
|
47
38
|
/** The foreign `pi-powerline-footer` footer-provider id (vacate-only interface seam). */
|
|
48
39
|
export const POWERLINE_FOOTER_PROVIDER_ID = "powerline-footer";
|
|
49
40
|
|
|
@@ -106,8 +97,6 @@ export function loadProviders(): Provider[] {
|
|
|
106
97
|
*/
|
|
107
98
|
export interface ResolvedProviders {
|
|
108
99
|
plan: Provider;
|
|
109
|
-
todo: Provider;
|
|
110
|
-
askuser: Provider;
|
|
111
100
|
footer: Provider;
|
|
112
101
|
web: Provider;
|
|
113
102
|
issues: string[];
|
|
@@ -124,20 +113,6 @@ export interface ResolvedProviders {
|
|
|
124
113
|
*/
|
|
125
114
|
const REFERENCE_FALLBACKS: Record<(typeof PROVIDER_SEAMS)[number], Provider> = {
|
|
126
115
|
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
116
|
footer: {
|
|
142
117
|
id: PERK_FOOTER_PROVIDER_ID,
|
|
143
118
|
seam: "footer",
|
|
@@ -183,8 +158,6 @@ function byId(set: Provider[]): Map<string, Provider> {
|
|
|
183
158
|
export function resolveProviders(
|
|
184
159
|
selection: {
|
|
185
160
|
plan?: string;
|
|
186
|
-
todo?: string;
|
|
187
|
-
askuser?: string;
|
|
188
161
|
footer?: string;
|
|
189
162
|
web?: string;
|
|
190
163
|
},
|
|
@@ -205,7 +178,7 @@ export function resolveProviders(
|
|
|
205
178
|
return def;
|
|
206
179
|
};
|
|
207
180
|
|
|
208
|
-
const resolveSeam = (seam: "plan" | "
|
|
181
|
+
const resolveSeam = (seam: "plan" | "footer" | "web"): Provider => {
|
|
209
182
|
const selected = selection[seam];
|
|
210
183
|
if (selected == null) return requireDefault(seam);
|
|
211
184
|
const provider = ids.get(selected);
|
|
@@ -222,8 +195,6 @@ export function resolveProviders(
|
|
|
222
195
|
|
|
223
196
|
return {
|
|
224
197
|
plan: resolveSeam("plan"),
|
|
225
|
-
todo: resolveSeam("todo"),
|
|
226
|
-
askuser: resolveSeam("askuser"),
|
|
227
198
|
footer: resolveSeam("footer"),
|
|
228
199
|
web: resolveSeam("web"),
|
|
229
200
|
issues,
|
|
@@ -39,3 +39,14 @@ export function perkVersion(): string {
|
|
|
39
39
|
return "0.0.0";
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The vintage-stamp filter over a perkVersion() value: the version, or undefined for the
|
|
45
|
+
* "0.0.0" failure sentinel. A `perk_version` stamp claims an EXACT session vintage
|
|
46
|
+
* (contracts §8.3 — the session-audit read side treats it as the exact basis), so the
|
|
47
|
+
* self-confessed-unknown sentinel must not masquerade as one; omitting the stamp leaves the
|
|
48
|
+
* session on the honest timestamp-estimate arm.
|
|
49
|
+
*/
|
|
50
|
+
export function versionStamp(version: string): string | undefined {
|
|
51
|
+
return version === "0.0.0" ? undefined : version;
|
|
52
|
+
}
|