@mgiles/perk 3.0.0 → 3.2.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/extension/adapters/planAdapterPlannotator.ts +12 -9
- package/extension/doors/address.ts +11 -0
- package/extension/doors/commitCompact.ts +98 -10
- package/extension/doors/draftReviewWaveTools.ts +43 -15
- package/extension/doors/dreamWaveTools.ts +489 -0
- package/extension/doors/land.ts +6 -0
- package/extension/doors/learn.ts +16 -3
- package/extension/doors/lifecycleGates.ts +36 -1
- package/extension/doors/objectiveReviewBrowser.ts +36 -13
- package/extension/doors/objectiveStack.ts +424 -24
- package/extension/doors/planReviewBrowser.ts +30 -8
- package/extension/doors/plannotatorHandoff.ts +80 -8
- package/extension/doors/prReview.ts +158 -50
- package/extension/doors/prReviewBrowser.ts +75 -27
- package/extension/doors/prReviewDynamic.ts +33 -13
- package/extension/doors/ready.ts +209 -17
- package/extension/doors/reviewWaveTools.ts +61 -17
- package/extension/doors/stackReviewBrowser.ts +573 -0
- package/extension/doors/submit.ts +36 -10
- package/extension/doors/submitPrReview.ts +116 -19
- package/extension/factories/objectiveDraft.ts +95 -27
- package/extension/factories/objectiveDreamReport.ts +347 -0
- package/extension/factories/objectivePlan.ts +12 -6
- package/extension/factories/objectiveSave.ts +77 -1
- package/extension/factories/planReview.ts +173 -10
- package/extension/index.ts +88 -16
- package/extension/substrate/agentScratch.ts +171 -0
- package/extension/substrate/bindingDelivery.ts +9 -11
- package/extension/substrate/cache.ts +92 -2
- package/extension/substrate/command.ts +9 -6
- package/extension/substrate/config.ts +10 -3
- package/extension/substrate/git.ts +85 -2
- package/extension/substrate/paths.ts +2 -7
- package/extension/substrate/resolverLease.ts +363 -0
- package/extension/substrate/result.ts +3 -2
- package/extension/substrate/sessionData.ts +6 -4
- package/extension/substrate/sessionPointers.ts +3 -4
- package/extension/substrate/toolGating.ts +25 -0
- package/extension/substrate/workflowState.ts +57 -5
- package/extension/surfaces/report.ts +38 -12
- package/extension/surfaces/surfaces.ts +129 -7
- package/extension/vendor/btw/btw.ts +38 -6
- package/extension/waves/adversarialReviewWave.ts +34 -3
- package/extension/waves/draftReviewWave.ts +17 -1
- 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 +1 -1
- package/extension/waves/ponytail.ts +104 -0
- package/extension/waves/prReviewDynamicWave.ts +115 -34
- package/extension/waves/prReviewWave.ts +122 -17
- package/extension/waves/reportWave.ts +103 -7
- package/extension/worker/readOnlySession.ts +2 -3
- package/package.json +6 -3
- package/prompts/_fixtures/live.yaml +112 -0
- package/prompts/commit-and-compact-continuation.md +13 -0
- package/prompts/contexts/adapters/plannotator-objective.md +7 -1
- package/prompts/contexts/adapters/plannotator-plan.md +7 -1
- package/prompts/contexts/adapters/tombell-plan.md +4 -0
- package/prompts/contexts/plan-authoring.md +6 -5
- package/prompts/stages/conflict-resolution-continuation.md +6 -0
- package/prompts/stages/conflict-resolution.md +2 -2
- package/prompts/stages/learn-dream.md +10 -0
- package/prompts/stages/objective-author/adopt.md +1 -1
- package/prompts/stages/objective-author/file.md +1 -1
- package/prompts/stages/objective-author/seed.md +1 -1
- package/prompts/stages/objective-reconcile-ready.md +7 -0
- package/prompts/stages/objective-review-browser.md +1 -1
- package/prompts/stages/objective-sync.md +1 -1
- package/prompts/stages/plan-review-browser.md +1 -1
- package/prompts/stages/pr-review-browser/active.md +1 -1
- package/prompts/stages/pr-review-browser/foreign.md +1 -1
- package/prompts/stages/pr-review-dynamic.md +5 -5
- package/prompts/stages/pr-review-terminal/active.md +1 -1
- package/prompts/stages/pr-review-terminal/foreign.md +1 -1
- package/prompts/stages/pr-review-terminal/local.md +1 -1
- package/prompts/stages/pr-review.md +5 -5
- package/prompts/stages/stack-review/cold.md +1 -0
- package/prompts/stages/stack-review-browser/stack.md +23 -0
- package/shared/README.md +0 -3
- package/shared/bindings.yaml +6 -0
- package/shared/contracts.md +3784 -1851
- package/shared/registry.yaml +28 -13
- package/shared/schemas/inputs/review-post-batch.schema.json +14 -1
- package/shared/schemas/outputs/objective-doctor.schema.json +39 -1
- package/shared/schemas/outputs/objective-stack-status.schema.json +172 -1
- package/shared/schemas/outputs/pr-land.schema.json +3 -3
- package/shared/schemas/outputs/pr-ready.schema.json +110 -2
- package/shared/contracts-history.md +0 -605
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// The conflict-resolver claim (contracts.md §8.51): a machine-local SESSION CLAIM on a retained
|
|
2
|
+
// sync-continuation operation, taken by the warm dispatcher right before it injects the resolver
|
|
3
|
+
// dispatch. It is honestly NOT a child-lifecycle-bound lock — `pi.sendUserMessage` is
|
|
4
|
+
// fire-and-forget and the extension never observes the dispatched child's start or finish — so
|
|
5
|
+
// there is deliberately NO explicit release on dispatch. The claim self-heals instead, via the
|
|
6
|
+
// reclaimability predicate: the holder pid is dead, the recorded operation was consumed (a fresh
|
|
7
|
+
// conflict minted a new operation id), or the lease is missing/corrupt and the lock dir has aged
|
|
8
|
+
// past `RECLAIM_GRACE_MS`. The accepted residual: a live session's claim on a still-pending SAME
|
|
9
|
+
// operation blocks other sessions' dispatch until that session exits or the operation is
|
|
10
|
+
// consumed — the busy reason names the holder pid, the lock path, and the remediation.
|
|
11
|
+
//
|
|
12
|
+
// Reclaim mechanics mirror `hunkFeedback/store.ts::acquireLease` (the interleaving-safe recipe):
|
|
13
|
+
// judge reclaimability → quarantine-RENAME the observed lock dir to a unique name (rename is
|
|
14
|
+
// atomic, so two reclaimers can never both delete a successor) → post-rename re-judgment on the
|
|
15
|
+
// MOVED state (a claim that changed since the judgment, or whose lease is missing/corrupt but
|
|
16
|
+
// still inside the grace window, is renamed back — a raced-in claim is NEVER stolen, whatever
|
|
17
|
+
// operation it names) → ONE fresh-acquire retry → best-effort quarantine removal; a lost retry
|
|
18
|
+
// is an honest busy. Deletion only ever targets our own quarantine dir or our own same-call
|
|
19
|
+
// acquisition, and the explicit withheld-dispatch release is token-fenced through its own
|
|
20
|
+
// quarantine-verify (`releaseResolverClaim`).
|
|
21
|
+
//
|
|
22
|
+
// Error posture: a MISSING or MALFORMED lease is DATA (it routes to the reclaim rules), and the
|
|
23
|
+
// expected race disappearances (ENOENT on read/stat/rename, EEXIST on mkdir) are contention —
|
|
24
|
+
// every OTHER filesystem failure propagates to the typed `io_error` arm, never a fabricated
|
|
25
|
+
// busy/reclaim judgment.
|
|
26
|
+
|
|
27
|
+
import { randomBytes } from "node:crypto";
|
|
28
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, statSync } from "node:fs";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { atomicWriteFileSync } from "./cache.ts";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A corrupt/missing `lease.json` is reclaimable only once the lock dir is older than this —
|
|
34
|
+
* closes the winner's mkdir↔first-write window (implementation constant, not config).
|
|
35
|
+
*/
|
|
36
|
+
export const RECLAIM_GRACE_MS = 60_000;
|
|
37
|
+
|
|
38
|
+
/** The claim lock dir sits beside the continuation manifest it guards. */
|
|
39
|
+
export function resolverLockDir(manifestPath: string): string {
|
|
40
|
+
return `${manifestPath}.resolver-lock`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type LeaseAcquisition =
|
|
44
|
+
| { acquired: true; token: string }
|
|
45
|
+
| { acquired: false; kind: "busy" | "io_error"; reason: string };
|
|
46
|
+
|
|
47
|
+
/** Deterministic-interleave seams for the reclaim-race tests — never set in production. */
|
|
48
|
+
export interface AcquireRaceHooks {
|
|
49
|
+
/** Runs after the reclaimability judgment, before the quarantine rename. */
|
|
50
|
+
beforeQuarantine?(): void;
|
|
51
|
+
/** Runs after the quarantine rename attempt, before the fresh-acquire retry. */
|
|
52
|
+
afterQuarantine?(): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The raw fs operations the claim touches — injectable ONLY for deterministic fault tests. */
|
|
56
|
+
export interface LeaseFsOps {
|
|
57
|
+
/** Non-recursive mkdir: EEXIST is the contention signal. */
|
|
58
|
+
mkdir(path: string): void;
|
|
59
|
+
/** utf8 read. */
|
|
60
|
+
readFile(path: string): string;
|
|
61
|
+
/** Atomic lease write (temp + rename — the atomicWriteFileSync discipline). */
|
|
62
|
+
writeLease(path: string, content: string): void;
|
|
63
|
+
rename(from: string, to: string): void;
|
|
64
|
+
/** Recursive, force. */
|
|
65
|
+
rm(path: string): void;
|
|
66
|
+
statMtimeMs(path: string): number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const REAL_FS: LeaseFsOps = {
|
|
70
|
+
mkdir: (path) => mkdirSync(path),
|
|
71
|
+
readFile: (path) => readFileSync(path, "utf8"),
|
|
72
|
+
writeLease: (path, content) => atomicWriteFileSync(path, content),
|
|
73
|
+
rename: (from, to) => renameSync(from, to),
|
|
74
|
+
rm: (path) => rmSync(path, { recursive: true, force: true }),
|
|
75
|
+
statMtimeMs: (path) => statSync(path).mtimeMs,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
interface ResolverLease {
|
|
79
|
+
schema: 1;
|
|
80
|
+
pid: number;
|
|
81
|
+
operation_id: string;
|
|
82
|
+
/** The per-acquisition ownership fence: rotated on every (re)acquire; release verifies it. */
|
|
83
|
+
token: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
87
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function errorCode(error: unknown): string | undefined {
|
|
91
|
+
return (error as NodeJS.ErrnoException).code;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read the lease as DATA: a missing file or malformed/mis-shaped content is `null` (the
|
|
96
|
+
* corrupt/missing reclaim rules own it). Any OTHER read failure (EACCES, EIO, EISDIR, …) is a
|
|
97
|
+
* genuine I/O failure and THROWS so the caller's typed `io_error` arm reports it honestly.
|
|
98
|
+
*/
|
|
99
|
+
function readLease(fs: LeaseFsOps, lockDir: string): ResolverLease | null {
|
|
100
|
+
let raw: string;
|
|
101
|
+
try {
|
|
102
|
+
raw = fs.readFile(join(lockDir, "lease.json"));
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (errorCode(error) === "ENOENT") return null;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
let parsed: unknown;
|
|
108
|
+
try {
|
|
109
|
+
parsed = JSON.parse(raw);
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
if (
|
|
114
|
+
isRecord(parsed) &&
|
|
115
|
+
parsed.schema === 1 &&
|
|
116
|
+
typeof parsed.pid === "number" &&
|
|
117
|
+
Number.isInteger(parsed.pid) &&
|
|
118
|
+
typeof parsed.operation_id === "string" &&
|
|
119
|
+
typeof parsed.token === "string"
|
|
120
|
+
) {
|
|
121
|
+
return parsed as unknown as ResolverLease;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The lock dir's mtime, or -Infinity when it vanished (ENOENT — a racing reclaim finished);
|
|
127
|
+
* any other stat failure throws to the typed `io_error` arm. */
|
|
128
|
+
function lockDirBasisMs(fs: LeaseFsOps, path: string): number {
|
|
129
|
+
try {
|
|
130
|
+
return fs.statMtimeMs(path);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (errorCode(error) === "ENOENT") return Number.NEGATIVE_INFINITY;
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function leaseBytes(lease: ResolverLease): string {
|
|
138
|
+
return `${JSON.stringify(lease)}\n`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function mintToken(): string {
|
|
142
|
+
return randomBytes(8).toString("hex");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Liveness probe: ESRCH = dead; EPERM (a foreign-uid process) and success both count alive. */
|
|
146
|
+
function defaultIsAlive(pid: number): boolean {
|
|
147
|
+
try {
|
|
148
|
+
process.kill(pid, 0);
|
|
149
|
+
return true;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return errorCode(error) !== "ESRCH";
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Atomic non-recursive `mkdir` (EEXIST = contention) + the first lease write. Returns the fresh
|
|
157
|
+
* token on acquisition, null on contention; throws on any other fs failure — after best-effort
|
|
158
|
+
* removing the dir THIS call created (we own it; any surviving residue self-heals via the
|
|
159
|
+
* aged-corrupt reclaim rule).
|
|
160
|
+
*/
|
|
161
|
+
function tryFreshAcquire(
|
|
162
|
+
fs: LeaseFsOps,
|
|
163
|
+
lockDir: string,
|
|
164
|
+
pid: number,
|
|
165
|
+
operationId: string,
|
|
166
|
+
): string | null {
|
|
167
|
+
try {
|
|
168
|
+
fs.mkdir(lockDir);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (errorCode(error) === "EEXIST") return null;
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
const token = mintToken();
|
|
174
|
+
try {
|
|
175
|
+
fs.writeLease(
|
|
176
|
+
join(lockDir, "lease.json"),
|
|
177
|
+
leaseBytes({ schema: 1, pid, operation_id: operationId, token }),
|
|
178
|
+
);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
try {
|
|
181
|
+
fs.rm(lockDir);
|
|
182
|
+
} catch {
|
|
183
|
+
// best-effort — the corrupt-lease reclaim rule collects it once it ages
|
|
184
|
+
}
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
return token;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function busyHolder(pid: number, lockDir: string): string {
|
|
191
|
+
return (
|
|
192
|
+
`another live session (pid ${pid}) holds the resolver claim at ${lockDir} — ` +
|
|
193
|
+
"dispatch from that session, or remove the lock dir if it is provably stale"
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function busyUnidentified(lockDir: string): string {
|
|
198
|
+
return (
|
|
199
|
+
`an unidentified holder claims the resolver lock at ${lockDir} (lease unreadable, ` +
|
|
200
|
+
"created recently) — retry shortly, or remove the lock dir if it is provably stale"
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function sameLease(a: ResolverLease, b: ResolverLease | null): boolean {
|
|
205
|
+
return b !== null && a.pid === b.pid && a.operation_id === b.operation_id && a.token === b.token;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Acquire the resolver claim for `operationId` on the continuation at `manifestPath`. Never
|
|
210
|
+
* throws: every genuine filesystem failure is caught and returned as `kind: "io_error"` (the
|
|
211
|
+
* expected race disappearances are classified inline — see the module doc). Same-pid contention
|
|
212
|
+
* is an idempotent REACQUIRE that rewrites `lease.json` with the CURRENT operation id and a
|
|
213
|
+
* fresh token (a continue-time NEW conflict reuses the same operation id — the original
|
|
214
|
+
* dispatching session re-claims; it never routes through reclaim). On success the returned
|
|
215
|
+
* `token` is the ownership fence a withheld dispatch passes to `releaseResolverClaim`.
|
|
216
|
+
* `pid`/`isAlive`/`now`/`hooks`/`fs` are injectable for deterministic tests.
|
|
217
|
+
*/
|
|
218
|
+
export function acquireResolverLease(
|
|
219
|
+
manifestPath: string,
|
|
220
|
+
operationId: string,
|
|
221
|
+
opts?: {
|
|
222
|
+
pid?: number;
|
|
223
|
+
isAlive?: (pid: number) => boolean;
|
|
224
|
+
now?: () => number;
|
|
225
|
+
hooks?: AcquireRaceHooks;
|
|
226
|
+
fs?: Partial<LeaseFsOps>;
|
|
227
|
+
},
|
|
228
|
+
): LeaseAcquisition {
|
|
229
|
+
const pid = opts?.pid ?? process.pid;
|
|
230
|
+
const isAlive = opts?.isAlive ?? defaultIsAlive;
|
|
231
|
+
const now = opts?.now ?? Date.now;
|
|
232
|
+
const hooks = opts?.hooks ?? {};
|
|
233
|
+
const fs: LeaseFsOps = { ...REAL_FS, ...(opts?.fs ?? {}) };
|
|
234
|
+
const lockDir = resolverLockDir(manifestPath);
|
|
235
|
+
try {
|
|
236
|
+
const fresh = tryFreshAcquire(fs, lockDir, pid, operationId);
|
|
237
|
+
if (fresh !== null) return { acquired: true, token: fresh };
|
|
238
|
+
|
|
239
|
+
const observed = readLease(fs, lockDir);
|
|
240
|
+
if (observed !== null && observed.pid === pid) {
|
|
241
|
+
// Same pid: reacquire, not reclaim — rewrite with the current operation id + fresh token.
|
|
242
|
+
const token = mintToken();
|
|
243
|
+
fs.writeLease(
|
|
244
|
+
join(lockDir, "lease.json"),
|
|
245
|
+
leaseBytes({ schema: 1, pid, operation_id: operationId, token }),
|
|
246
|
+
);
|
|
247
|
+
return { acquired: true, token };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Reclaimability: dead holder / consumed operation / aged corrupt-or-missing lease.
|
|
251
|
+
if (observed !== null) {
|
|
252
|
+
if (isAlive(observed.pid) && observed.operation_id === operationId) {
|
|
253
|
+
return { acquired: false, kind: "busy", reason: busyHolder(observed.pid, lockDir) };
|
|
254
|
+
}
|
|
255
|
+
} else if (now() - lockDirBasisMs(fs, lockDir) < RECLAIM_GRACE_MS) {
|
|
256
|
+
// Corrupt/missing lease.json inside the grace window (a winner may sit between its
|
|
257
|
+
// mkdir and first write) — busy; a vanished dir counts old and the retry settles it.
|
|
258
|
+
return { acquired: false, kind: "busy", reason: busyUnidentified(lockDir) };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Reclaim: quarantine-rename → post-rename re-judgment → ONE fresh-acquire retry.
|
|
262
|
+
hooks.beforeQuarantine?.();
|
|
263
|
+
const quarantine = `${lockDir}.stale-${pid.toString(36)}-${randomBytes(4).toString("hex")}`;
|
|
264
|
+
let renamed = false;
|
|
265
|
+
try {
|
|
266
|
+
fs.rename(lockDir, quarantine);
|
|
267
|
+
renamed = true;
|
|
268
|
+
} catch (error) {
|
|
269
|
+
// ENOENT = a competing reclaimer moved it first — still take the one retry. Any other
|
|
270
|
+
// rename failure is genuine I/O and must not masquerade as contention.
|
|
271
|
+
if (errorCode(error) !== "ENOENT") throw error;
|
|
272
|
+
renamed = false;
|
|
273
|
+
}
|
|
274
|
+
if (renamed) {
|
|
275
|
+
// Post-rename re-judgment on the MOVED state: between our judgment and the rename a
|
|
276
|
+
// competitor may have installed a successor claim (any operation id — never assume the
|
|
277
|
+
// one we are acquiring), or a winner may sit inside its mkdir↔first-write window (a
|
|
278
|
+
// young dir with no lease yet). Neither is ours to take: restore and report busy. Only
|
|
279
|
+
// the unchanged judged-stale state, a dead raced-in holder, or an AGED lease-less dir
|
|
280
|
+
// proceeds to the retry.
|
|
281
|
+
const moved = readLease(fs, quarantine);
|
|
282
|
+
let busyReason: string | null = null;
|
|
283
|
+
if (moved !== null) {
|
|
284
|
+
if (!sameLease(moved, observed) && isAlive(moved.pid)) {
|
|
285
|
+
busyReason = busyHolder(moved.pid, lockDir);
|
|
286
|
+
}
|
|
287
|
+
} else if (now() - lockDirBasisMs(fs, quarantine) < RECLAIM_GRACE_MS) {
|
|
288
|
+
// rename preserves mtime — the moved dir's age is the original dir's age.
|
|
289
|
+
busyReason = busyUnidentified(lockDir);
|
|
290
|
+
}
|
|
291
|
+
if (busyReason !== null) {
|
|
292
|
+
try {
|
|
293
|
+
fs.rename(quarantine, lockDir);
|
|
294
|
+
} catch {
|
|
295
|
+
// the name was retaken meanwhile — leave the quarantine; it self-heals as residue
|
|
296
|
+
}
|
|
297
|
+
return { acquired: false, kind: "busy", reason: busyReason };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
hooks.afterQuarantine?.();
|
|
301
|
+
const retried = tryFreshAcquire(fs, lockDir, pid, operationId);
|
|
302
|
+
if (renamed) {
|
|
303
|
+
try {
|
|
304
|
+
fs.rm(quarantine);
|
|
305
|
+
} catch {
|
|
306
|
+
// best-effort — a leftover quarantine dir is inert
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (retried !== null) return { acquired: true, token: retried };
|
|
310
|
+
return {
|
|
311
|
+
acquired: false,
|
|
312
|
+
kind: "busy",
|
|
313
|
+
reason:
|
|
314
|
+
`another session claimed the resolver lock at ${lockDir} first — dispatch from that ` +
|
|
315
|
+
"session, or retry once its claim clears",
|
|
316
|
+
};
|
|
317
|
+
} catch (error) {
|
|
318
|
+
return {
|
|
319
|
+
acquired: false,
|
|
320
|
+
kind: "io_error",
|
|
321
|
+
reason: `resolver-claim filesystem failure at ${lockDir}: ${String(error)}`,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Release THIS call's claim — the withheld-dispatch cleanup (a verified-increment failure must
|
|
328
|
+
* not leave a phantom holder). Token-fenced through a quarantine-verify: the claim is renamed
|
|
329
|
+
* to a private name first (atomic — a successor installed at the canonical path is never
|
|
330
|
+
* touched), verified against `token`, and deleted only when it proved ours; anything else is
|
|
331
|
+
* renamed back. Best-effort and never throws: leftover residue self-heals via the reclaim
|
|
332
|
+
* rules.
|
|
333
|
+
*/
|
|
334
|
+
export function releaseResolverClaim(
|
|
335
|
+
manifestPath: string,
|
|
336
|
+
token: string,
|
|
337
|
+
opts?: { fs?: Partial<LeaseFsOps> },
|
|
338
|
+
): void {
|
|
339
|
+
const fs: LeaseFsOps = { ...REAL_FS, ...(opts?.fs ?? {}) };
|
|
340
|
+
const lockDir = resolverLockDir(manifestPath);
|
|
341
|
+
const quarantine = `${lockDir}.release-${process.pid.toString(36)}-${randomBytes(4).toString("hex")}`;
|
|
342
|
+
try {
|
|
343
|
+
try {
|
|
344
|
+
fs.rename(lockDir, quarantine);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (errorCode(error) === "ENOENT") return; // nothing to release
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
const moved = readLease(fs, quarantine);
|
|
350
|
+
if (moved !== null && moved.token === token) {
|
|
351
|
+
fs.rm(quarantine);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
// Not ours (a successor raced in) — put it back untouched.
|
|
355
|
+
try {
|
|
356
|
+
fs.rename(quarantine, lockDir);
|
|
357
|
+
} catch {
|
|
358
|
+
// the name was retaken meanwhile — leave the quarantine; it self-heals as residue
|
|
359
|
+
}
|
|
360
|
+
} catch {
|
|
361
|
+
// best-effort — a leftover claim/quarantine goes stale and is reclaimed by the next acquire
|
|
362
|
+
}
|
|
363
|
+
}
|
|
@@ -53,8 +53,9 @@ export function ok<D extends object>(
|
|
|
53
53
|
/**
|
|
54
54
|
* Bind the module's fail constructor once: `const fail = failFor(ctx, scope)` (or
|
|
55
55
|
* `failFor(ctx, scope, label)` when the content label differs from the report scope). Each call
|
|
56
|
-
* reports
|
|
57
|
-
* canonical soft failure: content
|
|
56
|
+
* reports through the terminal-safe report seam (`alsoLog` mirrors complete diagnostics only in
|
|
57
|
+
* headless/RPC contexts) and returns the canonical soft failure: content
|
|
58
|
+
* `"<label> failed: <message>"`, details
|
|
58
59
|
* `{ ok: false, error: message, error_type: errorType }`, no `terminate`. `X` is the
|
|
59
60
|
* `FailDetails` extras hook: `failFor<X>(…)` lets a call attach module-specific fail details
|
|
60
61
|
* (spread AFTER `error`/`error_type`) — e.g. the learn wave's attempt receipts. Extras stay
|
|
@@ -36,7 +36,7 @@ import { createHash } from "node:crypto";
|
|
|
36
36
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
37
37
|
import { join, relative } from "node:path";
|
|
38
38
|
import type { ReportTarget } from "../surfaces/report.ts";
|
|
39
|
-
import { atomicWriteFileSync, sessionDataDir } from "./cache.ts";
|
|
39
|
+
import { atomicWriteFileSync, ensureRunScratch, sessionDataDir } from "./cache.ts";
|
|
40
40
|
import {
|
|
41
41
|
appendWorkflowState,
|
|
42
42
|
type BranchSource,
|
|
@@ -72,11 +72,13 @@ export function activeSessionDataDir(ctx: SessionDataCtx): string | null {
|
|
|
72
72
|
return sessionDataDir(ctx.cwd, runId);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/** Ensure
|
|
75
|
+
/** Ensure the validated run root, then its data dir; `null` + a warning on failure. */
|
|
76
76
|
export function ensureSessionDataDir(ctx: SessionDataCtx): string | null {
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
77
|
+
const runId = activeSessionRunId(ctx);
|
|
78
|
+
if (runId === null) return null;
|
|
79
|
+
const dir = sessionDataDir(ctx.cwd, runId);
|
|
79
80
|
try {
|
|
81
|
+
ensureRunScratch(ctx.cwd, runId);
|
|
80
82
|
mkdirSync(dir, { recursive: true });
|
|
81
83
|
} catch (error) {
|
|
82
84
|
console.error(`perk: warning: could not create session data dir ${dir}: ${error}`);
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
// wedge the save/launch/drive it rides on. Node builtins + cache.ts + git.ts only (loads under
|
|
14
14
|
// `node --test`).
|
|
15
15
|
|
|
16
|
-
import { existsSync,
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
17
|
import { basename, join } from "node:path";
|
|
18
|
-
import { atomicWriteFileSync, runScratchDir } from "./cache.ts";
|
|
18
|
+
import { atomicWriteFileSync, ensureRunScratch, runScratchDir } from "./cache.ts";
|
|
19
19
|
import { mainCheckoutRoot } from "./git.ts";
|
|
20
20
|
|
|
21
21
|
export const SESSION_POINTERS_FILE = "session-pointers.json";
|
|
@@ -119,8 +119,7 @@ export function recordSessionPointer(
|
|
|
119
119
|
// The run id is authoritative — a record read from disk keeps its own; a fresh one is minted
|
|
120
120
|
// with `runId`. (A mismatched on-disk run_id is left as-is; self-keying guarantees a match.)
|
|
121
121
|
record[klass][site] = pointer;
|
|
122
|
-
|
|
123
|
-
mkdirSync(dir, { recursive: true });
|
|
122
|
+
ensureRunScratch(root, runId);
|
|
124
123
|
atomicWriteFileSync(sessionPointersPath(root, runId), serialize(record));
|
|
125
124
|
return true;
|
|
126
125
|
} catch (error) {
|
|
@@ -263,6 +263,14 @@ export const READ_ONLY_TOOLS = [
|
|
|
263
263
|
// `perk.harvest-analyst` over the already-carved-in SUBAGENT_TOOLS/SUBAGENT_CHILD_TOOLS, and
|
|
264
264
|
// it writes nothing to the worktree (contracts.md §8.48).
|
|
265
265
|
"run_harvest_wave",
|
|
266
|
+
// The dream-wave carve-in: the seeded `perk learn dream` session runs GATED (the read-only
|
|
267
|
+
// objective-author borrow), so `run_dream_wave` must be reachable while read-only. The tool
|
|
268
|
+
// takes NO parameters: its manifest read AND its one write (the fixed-name run-scratch
|
|
269
|
+
// bundle beside that manifest) are both derived from the claimed run's manifest path — no
|
|
270
|
+
// caller-supplied path exists (the `run_audit_wave` no-aimable-writer posture, BOTH sides),
|
|
271
|
+
// and it spawns only the read-only `perk.dream-analyst`/`perk.dream-reducer` over the
|
|
272
|
+
// already-carved-in delegation family (contracts.md §8.61).
|
|
273
|
+
"run_dream_wave",
|
|
266
274
|
];
|
|
267
275
|
|
|
268
276
|
/**
|
|
@@ -287,6 +295,7 @@ export const PERK_TOOLS: readonly string[] = [
|
|
|
287
295
|
"run_learn_wave",
|
|
288
296
|
"run_audit_wave",
|
|
289
297
|
"run_harvest_wave",
|
|
298
|
+
"run_dream_wave",
|
|
290
299
|
"land",
|
|
291
300
|
"post_pr_review",
|
|
292
301
|
"ready",
|
|
@@ -303,6 +312,9 @@ export const PERK_TOOLS: readonly string[] = [
|
|
|
303
312
|
"collect_draft_review_wave",
|
|
304
313
|
"run_ci",
|
|
305
314
|
"submit",
|
|
315
|
+
// The stack-review launch-recovery tool (contracts.md §8.4): parameterless — the snapshot
|
|
316
|
+
// comes only from the `perk objective stack review` launch handoff.
|
|
317
|
+
"open_stack_review",
|
|
306
318
|
// The stacked-delivery warm surface (contracts.md §8.51/§8.56): read + control tools over
|
|
307
319
|
// the cold `objective stack` workers. Never in READ_ONLY_TOOLS — sync/adopt/recover/land
|
|
308
320
|
// mutate published branches and PRs; the gated posture is the driving commands' soft
|
|
@@ -482,6 +494,19 @@ export const STAGE_TOOLS: Readonly<Record<string, readonly string[]>> = {
|
|
|
482
494
|
// (read-only mode), where this list is inert; it exists for the keys≡registry pin and the
|
|
483
495
|
// defensive gate-off arm.
|
|
484
496
|
audit: ["ask_user_question", "run_audit_wave", ...RESEARCH_TOOLS],
|
|
497
|
+
// The stacked-PR browser-review launcher (`perk objective stack review`): exactly the flow
|
|
498
|
+
// set the stack.md guidance names — the launch-recovery tool, the review-wave pair, the
|
|
499
|
+
// annotation push, per-PR posting, delegation (the wave's relay loop), and research.
|
|
500
|
+
"stack-review": [
|
|
501
|
+
"ask_user_question",
|
|
502
|
+
"open_stack_review",
|
|
503
|
+
"start_review_wave",
|
|
504
|
+
"collect_review_wave",
|
|
505
|
+
"push_annotations",
|
|
506
|
+
"submit_pr_review",
|
|
507
|
+
...SUBAGENT_TOOLS,
|
|
508
|
+
...RESEARCH_TOOLS,
|
|
509
|
+
],
|
|
485
510
|
};
|
|
486
511
|
|
|
487
512
|
/** The read-only marker / custom-message type injected into context while active. */
|
|
@@ -45,8 +45,10 @@ export interface WorkflowState {
|
|
|
45
45
|
last_review_batch?: unknown;
|
|
46
46
|
/**
|
|
47
47
|
* The last `/pr-review` outcome posted via the `post_pr_review` warm tool (§8.3):
|
|
48
|
-
* `{pr, verdict, angles, comment_count, mode, at}`.
|
|
49
|
-
* `
|
|
48
|
+
* `{pr, verdict, angles, covered_angles, comment_count, mode, at}`. After a recorded wave,
|
|
49
|
+
* `angles` is the authoritative attempted manifest and `covered_angles` is its schema-valid
|
|
50
|
+
* subset; standalone posts use the caller's `angles` for both. Best-effort tier (per-field LWW
|
|
51
|
+
* in `rebuildWorkflowState`, no rebuild change). The PR comment stays canonical.
|
|
50
52
|
*/
|
|
51
53
|
last_pr_review?: unknown;
|
|
52
54
|
/**
|
|
@@ -55,6 +57,14 @@ export interface WorkflowState {
|
|
|
55
57
|
* `rebuildWorkflowState`, no rebuild change). The submitted PR review stays canonical.
|
|
56
58
|
*/
|
|
57
59
|
last_review?: unknown;
|
|
60
|
+
/**
|
|
61
|
+
* The accumulating per-PR posting ledger of a stacked review (§8.3/§8.4): one
|
|
62
|
+
* `{pr, event, at}` row per REAL `submit_pr_review` success, ordered by posting time
|
|
63
|
+
* (read-rebuild-append — the whole list is re-appended each time). The resume authority for
|
|
64
|
+
* a partially-posted stack sequence: confirmed successes are skipped, never replayed.
|
|
65
|
+
* Best-effort tier (per-field LWW in `rebuildWorkflowState`, no rebuild change).
|
|
66
|
+
*/
|
|
67
|
+
review_posts?: unknown;
|
|
58
68
|
/** Session-artifact provenance pointers, keyed by artifact name (§8.3). */
|
|
59
69
|
session_artifacts?: Record<string, SessionArtifactPointer> | null;
|
|
60
70
|
/**
|
|
@@ -65,9 +75,20 @@ export interface WorkflowState {
|
|
|
65
75
|
*/
|
|
66
76
|
objective_node_claim?: { objective: string; node: string } | null;
|
|
67
77
|
/**
|
|
68
|
-
* The
|
|
69
|
-
*
|
|
70
|
-
*
|
|
78
|
+
* The dream-wave finalized-bundle digest marker (§8.61) — the freshness/integrity authority
|
|
79
|
+
* the dream-report recovery (§8.63) trusts over the bare run-scratch bundle file. `""` =
|
|
80
|
+
* invalidated (cleared unconditionally at wave entry BEFORE the stale-bundle removal attempt,
|
|
81
|
+
* so a failed cleanup leaves prior files behind but recovery refuses them); `sha256:<hex>` =
|
|
82
|
+
* the digest of the current finalized bundle bytes, set only after a successful finalize
|
|
83
|
+
* write. Per-field LWW; no rebuild change.
|
|
84
|
+
*/
|
|
85
|
+
dream_bundle_digest?: string;
|
|
86
|
+
/**
|
|
87
|
+
* The bounded conflict-resolution re-drive counter (§8.3). Incremented on each
|
|
88
|
+
* `perk.conflict-resolver` dispatch from EITHER warm surface — `/submit`'s PR-rebase drive on
|
|
89
|
+
* a definitively-unmergeable PR, or `/objective-sync`'s retained-continuation drive; reset to
|
|
90
|
+
* 0 on any clean completion (a clean submit; a clean non-declined mutating stack
|
|
91
|
+
* sync/continue/abort/adopt). Best-effort tier (cheaply reconstructable). Per-field LWW in
|
|
71
92
|
* `rebuildWorkflowState` handles it with no rebuild change.
|
|
72
93
|
*/
|
|
73
94
|
conflict_resolution_attempts?: number;
|
|
@@ -78,6 +99,8 @@ export interface BranchEntry {
|
|
|
78
99
|
type: string;
|
|
79
100
|
customType?: string;
|
|
80
101
|
data?: Record<string, unknown>;
|
|
102
|
+
/** Present on Pi custom_message entries (hidden model-context messages). */
|
|
103
|
+
content?: unknown;
|
|
81
104
|
}
|
|
82
105
|
|
|
83
106
|
/** The minimal read-only session surface the branch accessor needs. */
|
|
@@ -107,6 +130,35 @@ export function branchCarries(branch: readonly BranchEntry[], needle: string): b
|
|
|
107
130
|
return branch.some((entry) => JSON.stringify(entry).includes(needle));
|
|
108
131
|
}
|
|
109
132
|
|
|
133
|
+
/**
|
|
134
|
+
* The branch entries still represented directly in model context. Before compaction that is the
|
|
135
|
+
* full branch. After compaction, Pi keeps entries from `firstKeptEntryId` onward plus anything
|
|
136
|
+
* appended later; historical entries before that cutoff survive only through the summary.
|
|
137
|
+
* Compaction entries are excluded because text quoted by a summary is not a live custom block.
|
|
138
|
+
*/
|
|
139
|
+
export function activeContextWindow(branch: readonly BranchEntry[]): BranchEntry[] {
|
|
140
|
+
let latestCompaction = -1;
|
|
141
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
142
|
+
if (branch[i]?.type === "compaction") {
|
|
143
|
+
latestCompaction = i;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (latestCompaction === -1) return [...branch];
|
|
148
|
+
|
|
149
|
+
const firstKeptEntryId = (branch[latestCompaction] as { firstKeptEntryId?: unknown })
|
|
150
|
+
.firstKeptEntryId;
|
|
151
|
+
const firstKept =
|
|
152
|
+
typeof firstKeptEntryId === "string"
|
|
153
|
+
? branch.findIndex(
|
|
154
|
+
(entry, index) =>
|
|
155
|
+
index < latestCompaction && (entry as { id?: unknown }).id === firstKeptEntryId,
|
|
156
|
+
)
|
|
157
|
+
: -1;
|
|
158
|
+
const start = firstKept === -1 ? latestCompaction + 1 : firstKept;
|
|
159
|
+
return branch.slice(start).filter((entry) => entry.type !== "compaction");
|
|
160
|
+
}
|
|
161
|
+
|
|
110
162
|
/**
|
|
111
163
|
* Per-field last-write-wins over the `perk:workflow-state` custom entries on a branch.
|
|
112
164
|
* Non-perk entries are ignored; `undefined` fields never clobber (but explicit `null` does).
|
|
@@ -1,20 +1,39 @@
|
|
|
1
|
-
// The one
|
|
2
|
-
// and the
|
|
3
|
-
//
|
|
1
|
+
// The one terminal-safe report seam — owns the `perk: <scope> — <message>` prefix, the severity,
|
|
2
|
+
// and the projection of complete diagnostics into a managed headline plus an optional durable detail
|
|
3
|
+
// sink (cf. the `branchOf`/`BranchSource` seam in workflowState.ts).
|
|
4
4
|
|
|
5
5
|
export type Severity = "info" | "warning" | "error";
|
|
6
|
+
export type ReportDetailSink = (text: string, severity: Severity) => void;
|
|
6
7
|
|
|
7
8
|
/** The minimal headless-aware surface report() needs. `ExtensionContext` satisfies it; tests fake it. */
|
|
8
9
|
export interface ReportTarget {
|
|
9
10
|
hasUI: boolean;
|
|
11
|
+
mode?: "tui" | "rpc" | "json" | "print";
|
|
10
12
|
ui: { notify(message: string, type?: Severity): void };
|
|
11
13
|
}
|
|
12
14
|
|
|
15
|
+
const detailSinks = new WeakMap<ReportTarget, ReportDetailSink>();
|
|
16
|
+
const LOGICAL_LINE = /\r\n|\n|\r/;
|
|
17
|
+
const HORIZONTAL_WHITESPACE = /[^\S\r\n]+/g;
|
|
18
|
+
|
|
19
|
+
/** Attach display-only multiline report detail to this exact context object. */
|
|
20
|
+
export function attachReportDetailSink(target: ReportTarget, sink: ReportDetailSink): void {
|
|
21
|
+
detailSinks.set(target, sink);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function headlineFor(prefix: string, message: string): string {
|
|
25
|
+
for (const line of message.split(LOGICAL_LINE)) {
|
|
26
|
+
const trimmed = line.trim();
|
|
27
|
+
if (trimmed.length > 0) return `${prefix}${trimmed.replace(HORIZONTAL_WHITESPACE, " ")}`;
|
|
28
|
+
}
|
|
29
|
+
return prefix;
|
|
30
|
+
}
|
|
31
|
+
|
|
13
32
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
33
|
+
* Build and return the complete `perk: <scope> — <message>` value. Headless targets receive that
|
|
34
|
+
* value on stderr. Headful targets receive a managed one-line headline; multiline detail goes to an
|
|
35
|
+
* attached display-only sink in every non-RPC mode. `{ alsoLog: true }` is narrowly an RPC/headless
|
|
36
|
+
* diagnostic mirror and never permits raw terminal output in a headful non-RPC context.
|
|
18
37
|
*/
|
|
19
38
|
export function report(
|
|
20
39
|
target: ReportTarget,
|
|
@@ -23,12 +42,19 @@ export function report(
|
|
|
23
42
|
message: string,
|
|
24
43
|
opts?: { alsoLog?: boolean },
|
|
25
44
|
): string {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (opts?.alsoLog) console.error(full);
|
|
30
|
-
} else {
|
|
45
|
+
const prefix = `perk: ${scope} — `;
|
|
46
|
+
const full = `${prefix}${message}`;
|
|
47
|
+
if (!target.hasUI) {
|
|
31
48
|
console.error(full);
|
|
49
|
+
return full;
|
|
32
50
|
}
|
|
51
|
+
|
|
52
|
+
target.ui.notify(headlineFor(prefix, message), severity);
|
|
53
|
+
if (target.mode === "rpc") {
|
|
54
|
+
if (opts?.alsoLog) console.error(full);
|
|
55
|
+
return full;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (message.split(LOGICAL_LINE).length > 1) detailSinks.get(target)?.(full, severity);
|
|
33
59
|
return full;
|
|
34
60
|
}
|