@hyperdreamer/pi-webui 1.10.7 → 1.11.0-beta.10

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.
Files changed (92) hide show
  1. package/README.md +17 -2
  2. package/dist/cli.js +265 -32
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/{CodeViewer-BIb2Wx8W.js → CodeViewer-CAf_j27u.js} +1 -1
  5. package/dist/client/assets/{UnifiedDiffViewer-DpWiGlQo.js → UnifiedDiffViewer-wBjEJWa1.js} +1 -1
  6. package/dist/client/assets/{index-DX583DEv.js → index-5EtDb9nj.js} +1197 -476
  7. package/dist/client/index.html +1 -1
  8. package/dist/config.js +52 -11
  9. package/dist/config.js.map +1 -1
  10. package/dist/pi-webui-plugins/workspace-memory/pi-webui-plugin.js +3 -2
  11. package/dist/pi-webui-plugins/workspace-tasks/pi-webui-plugin.js +1 -0
  12. package/dist/plugin-api.d.ts +30 -0
  13. package/dist/server/app.js +6 -1
  14. package/dist/server/app.js.map +1 -1
  15. package/dist/server/realtime/sessionEventHub.js +40 -5
  16. package/dist/server/realtime/sessionEventHub.js.map +1 -1
  17. package/dist/server/realtime/sessionStatusCoalescer.js +137 -0
  18. package/dist/server/realtime/sessionStatusCoalescer.js.map +1 -0
  19. package/dist/server/sessiond/sessionProxyRoutes.js +2 -0
  20. package/dist/server/sessiond/sessionProxyRoutes.js.map +1 -1
  21. package/dist/server/sessiond.js +57 -5
  22. package/dist/server/sessiond.js.map +1 -1
  23. package/dist/server/sessions/modelPolicyCapability.js +88 -0
  24. package/dist/server/sessions/modelPolicyCapability.js.map +1 -0
  25. package/dist/server/sessions/modelPolicyTool.js +31 -0
  26. package/dist/server/sessions/modelPolicyTool.js.map +1 -0
  27. package/dist/server/sessions/modelTierRegistry.js +14 -0
  28. package/dist/server/sessions/modelTierRegistry.js.map +1 -1
  29. package/dist/server/sessions/modelTierSettingsRoutes.js +36 -0
  30. package/dist/server/sessions/modelTierSettingsRoutes.js.map +1 -0
  31. package/dist/server/sessions/modelTierSettingsService.js +100 -0
  32. package/dist/server/sessions/modelTierSettingsService.js.map +1 -0
  33. package/dist/server/sessions/piSessionService.js +1643 -433
  34. package/dist/server/sessions/piSessionService.js.map +1 -1
  35. package/dist/server/sessions/sessionDefaultsRoutes.js +39 -0
  36. package/dist/server/sessions/sessionDefaultsRoutes.js.map +1 -1
  37. package/dist/server/sessions/sessionDefaultsService.js +36 -6
  38. package/dist/server/sessions/sessionDefaultsService.js.map +1 -1
  39. package/dist/server/sessions/sessionMetadataStore.js +107 -13
  40. package/dist/server/sessions/sessionMetadataStore.js.map +1 -1
  41. package/dist/server/sessions/sessionModelPolicy.js +118 -0
  42. package/dist/server/sessions/sessionModelPolicy.js.map +1 -0
  43. package/dist/server/sessions/sessionReorder.js +117 -0
  44. package/dist/server/sessions/sessionReorder.js.map +1 -0
  45. package/dist/server/sessions/sessionRouteFastifyOptions.js +8 -0
  46. package/dist/server/sessions/sessionRouteFastifyOptions.js.map +1 -0
  47. package/dist/server/sessions/sessionRoutes.js +208 -3
  48. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  49. package/dist/server/sessions/starterModelPolicyPreferenceStore.js +164 -0
  50. package/dist/server/sessions/starterModelPolicyPreferenceStore.js.map +1 -0
  51. package/dist/server/sessions/utilityModelExtension.js +199 -0
  52. package/dist/server/sessions/utilityModelExtension.js.map +1 -0
  53. package/dist/server/sessions/utilityModelResolver.js +77 -0
  54. package/dist/server/sessions/utilityModelResolver.js.map +1 -0
  55. package/dist/server/sessions/utilityModelSettingsRoutes.js +62 -0
  56. package/dist/server/sessions/utilityModelSettingsRoutes.js.map +1 -0
  57. package/dist/server/sessions/utilityModelSettingsService.js +101 -0
  58. package/dist/server/sessions/utilityModelSettingsService.js.map +1 -0
  59. package/dist/server/skills/optionalSkillInstall.js +69 -0
  60. package/dist/server/skills/optionalSkillInstall.js.map +1 -0
  61. package/dist/server/skills/optionalSkillInstaller.js +148 -0
  62. package/dist/server/skills/optionalSkillInstaller.js.map +1 -0
  63. package/dist/shared/apiTypes.d.ts +129 -3
  64. package/dist/shared/apiTypes.js +10 -0
  65. package/dist/shared/apiTypes.js.map +1 -1
  66. package/dist/shared/capabilities.js +15 -0
  67. package/dist/shared/capabilities.js.map +1 -1
  68. package/dist/shared/federatedRoutes.js +7 -0
  69. package/dist/shared/federatedRoutes.js.map +1 -1
  70. package/docs/assets/pi-webui-desktop.png +0 -0
  71. package/docs/config.md +61 -3
  72. package/docs/plugins.md +93 -16
  73. package/optional-skills/deterministic-subagent-driven-development/SKILL.md +224 -0
  74. package/optional-skills/deterministic-subagent-driven-development/pi-webui-skill.json +28 -0
  75. package/optional-skills/deterministic-subagent-driven-development/prompts/final-reviewer.md +132 -0
  76. package/optional-skills/deterministic-subagent-driven-development/prompts/implementer.md +101 -0
  77. package/optional-skills/deterministic-subagent-driven-development/prompts/re-reviewer.md +60 -0
  78. package/optional-skills/deterministic-subagent-driven-development/prompts/task-reviewer.md +80 -0
  79. package/optional-skills/deterministic-subagent-driven-development/references/capability-contract.md +174 -0
  80. package/optional-skills/deterministic-subagent-driven-development/references/plan-contract.md +268 -0
  81. package/optional-skills/deterministic-subagent-driven-development/references/state-machine.md +177 -0
  82. package/optional-skills/deterministic-subagent-driven-development/scripts/lib/manifest.mjs +258 -0
  83. package/optional-skills/deterministic-subagent-driven-development/scripts/lib/plan-policy.mjs +350 -0
  84. package/optional-skills/deterministic-subagent-driven-development/scripts/lib/prompt-renderer.mjs +290 -0
  85. package/optional-skills/deterministic-subagent-driven-development/scripts/lib/state-machine.mjs +1264 -0
  86. package/optional-skills/deterministic-subagent-driven-development/scripts/lib/state-store.mjs +532 -0
  87. package/optional-skills/deterministic-subagent-driven-development/scripts/sdd-state +3 -0
  88. package/optional-skills/deterministic-subagent-driven-development/scripts/sdd-state.mjs +349 -0
  89. package/optional-skills/deterministic-writing-plans/SKILL.md +232 -0
  90. package/optional-skills/deterministic-writing-plans/references/grammar.md +84 -0
  91. package/optional-skills/deterministic-writing-plans/templates/plan-skeleton.md +143 -0
  92. package/package.json +13 -9
@@ -0,0 +1,532 @@
1
+ /**
2
+ * Durable state persistence for the deterministic SDD controller.
3
+ *
4
+ * The reducer decides *what* a transition means. This module decides *when it is
5
+ * durable*, which is a different problem with different failure modes: torn
6
+ * writes, concurrent controllers, and a crash between the state write and the
7
+ * audit append.
8
+ *
9
+ * `state.json` is canonical and the progress ledger is derived from it. Every
10
+ * mutation therefore follows one order, and no step is optional:
11
+ *
12
+ * acquire lock -> reread state -> verify identity -> check expected revision
13
+ * -> reduce in memory -> write+fsync temp -> rename -> fsync dir
14
+ * -> append+fsync one audit line -> release lock
15
+ *
16
+ * Locking uses `link()` rather than `O_EXCL` on the lock path itself, so the
17
+ * owner record is complete on disk *before* the lock becomes visible. Acquiring
18
+ * with `O_EXCL` and then writing metadata leaves a window where another process
19
+ * sees a lock it cannot attribute, which is indistinguishable from corruption.
20
+ *
21
+ * Filesystem, clock, and identity are injected. The CLI owns Git subprocesses and
22
+ * supplies observed identity; the store never shells out, and callers never
23
+ * supply transition timestamps or lock tokens.
24
+ */
25
+
26
+ import { createHash, randomUUID } from "node:crypto";
27
+ import {
28
+ closeSync,
29
+ fsyncSync,
30
+ linkSync,
31
+ mkdirSync,
32
+ openSync,
33
+ readFileSync,
34
+ renameSync,
35
+ rmSync,
36
+ statSync,
37
+ unlinkSync,
38
+ writeSync,
39
+ } from "node:fs";
40
+ import { hostname } from "node:os";
41
+ import { dirname, resolve } from "node:path";
42
+
43
+ import { createInitialState, reduceState, validateState } from "./state-machine.mjs";
44
+ import { parsePlanText } from "./plan-policy.mjs";
45
+
46
+ /** Exit codes. The CLI surfaces these directly, so they are part of the contract. */
47
+ export const EXIT = Object.freeze({
48
+ OK: 0,
49
+ VALIDATION: 2,
50
+ CONTENTION: 3,
51
+ IDENTITY: 4,
52
+ AUDIT_REPAIR_NEEDED: 5,
53
+ UNRESOLVED_LOCK: 6,
54
+ });
55
+
56
+ /** Maximum bytes for an event or decision JSON file. */
57
+ const MAX_EVENT_BYTES = 64 * 1024;
58
+
59
+ /** The audit-line marker, matching the reducer's rendered transition. */
60
+ const AUDIT_MARKER = "<!-- sdd-transition:";
61
+
62
+ /** A failure carrying the exit code the CLI should use. */
63
+ export class StoreError extends Error {
64
+ constructor(message, code) {
65
+ super(message);
66
+ this.name = "StoreError";
67
+ this.code = code;
68
+ }
69
+ }
70
+
71
+ const failWith = (code, message) => {
72
+ throw new StoreError(message, code);
73
+ };
74
+
75
+ /** Default adapters. Tests replace these to inject failures or fixed values. */
76
+ export const defaultAdapters = Object.freeze({
77
+ now: () => new Date().toISOString().replace(/\.\d+Z$/u, ".000Z"),
78
+ token: () => randomUUID().replaceAll("-", ""),
79
+ pid: () => process.pid,
80
+ host: () => hostname(),
81
+ rename: renameSync,
82
+ });
83
+
84
+ /** Write a file and fsync it, so its bytes survive a power loss. */
85
+ const writeSynced = (path, contents) => {
86
+ const handle = openSync(path, "w");
87
+ try {
88
+ writeSync(handle, contents);
89
+ fsyncSync(handle);
90
+ } finally {
91
+ closeSync(handle);
92
+ }
93
+ };
94
+
95
+ /** Append one line and fsync. */
96
+ const appendSynced = (path, line) => {
97
+ const handle = openSync(path, "a");
98
+ try {
99
+ writeSync(handle, line);
100
+ fsyncSync(handle);
101
+ } finally {
102
+ closeSync(handle);
103
+ }
104
+ };
105
+
106
+ /** fsync a directory so a rename within it is durable. */
107
+ const syncDirectory = (path) => {
108
+ const handle = openSync(path, "r");
109
+ try {
110
+ fsyncSync(handle);
111
+ } finally {
112
+ closeSync(handle);
113
+ }
114
+ };
115
+
116
+ const lockPathFor = (statePath) => `${statePath}.lock`;
117
+
118
+ /**
119
+ * Acquire the run lock.
120
+ *
121
+ * The owner record is written to a unique sibling and then hard-linked to the
122
+ * fixed lock path, so the lock is never visible in a metadata-empty state. The
123
+ * unique file is removed either way: on success it has served its purpose, and on
124
+ * contention it would otherwise leak.
125
+ */
126
+ const acquireLock = (statePath, adapters) => {
127
+ const lockPath = lockPathFor(statePath);
128
+ const owner = {
129
+ token: adapters.token(),
130
+ pid: adapters.pid(),
131
+ host: adapters.host(),
132
+ at: adapters.now(),
133
+ };
134
+ const uniquePath = `${lockPath}.${owner.token}`;
135
+ writeSynced(uniquePath, `${JSON.stringify(owner)}\n`);
136
+ try {
137
+ linkSync(uniquePath, lockPath);
138
+ } catch (error) {
139
+ rmSync(uniquePath, { force: true });
140
+ if (error.code === "EEXIST") {
141
+ failWith(EXIT.CONTENTION, `the run is locked by another process: ${lockPath}`);
142
+ }
143
+ throw error;
144
+ }
145
+ rmSync(uniquePath, { force: true });
146
+ return owner;
147
+ };
148
+
149
+ const releaseLock = (statePath) => {
150
+ rmSync(lockPathFor(statePath), { force: true });
151
+ };
152
+
153
+ /** Read the current lock owner, or null when unlocked. */
154
+ export const readLockOwner = (statePath) => {
155
+ try {
156
+ return JSON.parse(readFileSync(lockPathFor(statePath), "utf8"));
157
+ } catch (error) {
158
+ if (error.code === "ENOENT") return null;
159
+ // A malformed lock is reported, never silently cleared: a lock we cannot
160
+ // attribute is exactly the case that needs a human ruling.
161
+ failWith(EXIT.UNRESOLVED_LOCK, `the lock file is unreadable: ${lockPathFor(statePath)}`);
162
+ }
163
+ return null;
164
+ };
165
+
166
+ /** Is the recorded owner still alive on this host? */
167
+ const ownerIsLive = (owner, adapters) => {
168
+ if (owner === null) return false;
169
+ if (owner.host !== adapters.host()) return true;
170
+ try {
171
+ process.kill(owner.pid, 0);
172
+ return true;
173
+ } catch (error) {
174
+ return error.code === "EPERM";
175
+ }
176
+ };
177
+
178
+ const readJsonFile = (path, field) => {
179
+ let raw;
180
+ try {
181
+ raw = readFileSync(path);
182
+ } catch {
183
+ failWith(EXIT.VALIDATION, `${field} is not readable: ${path}`);
184
+ }
185
+ if (raw.byteLength > MAX_EVENT_BYTES) {
186
+ failWith(EXIT.VALIDATION, `${field} exceeds ${String(MAX_EVENT_BYTES)} bytes (64 KiB)`);
187
+ }
188
+ try {
189
+ return JSON.parse(raw.toString("utf8"));
190
+ } catch {
191
+ return failWith(EXIT.VALIDATION, `${field} is not valid JSON: ${path}`);
192
+ }
193
+ };
194
+
195
+ /**
196
+ * Load and validate persisted state.
197
+ *
198
+ * A state document that fails validation is corruption, not a validation error
199
+ * the caller can fix by passing different arguments, so it exits 4.
200
+ */
201
+ const loadState = (statePath) => {
202
+ let parsed;
203
+ try {
204
+ parsed = JSON.parse(readFileSync(statePath, "utf8"));
205
+ } catch (error) {
206
+ if (error.code === "ENOENT") {
207
+ failWith(EXIT.VALIDATION, `no state file at ${statePath}; run init first`);
208
+ }
209
+ return failWith(EXIT.IDENTITY, `the state file is unreadable or malformed: ${statePath}`);
210
+ }
211
+ try {
212
+ return validateState(parsed);
213
+ } catch (error) {
214
+ return failWith(EXIT.IDENTITY, `the state file is invalid: ${error.message}`);
215
+ }
216
+ };
217
+
218
+ /** Recompute the plan digest and compare pinned Git identity. */
219
+ const verifyIdentity = (state, { planPath, worktree, branch, mergeBase }) => {
220
+ let bytes;
221
+ try {
222
+ bytes = readFileSync(planPath);
223
+ } catch {
224
+ failWith(EXIT.IDENTITY, `the pinned plan file is missing: ${planPath}`);
225
+ }
226
+ const digest = createHash("sha256").update(bytes).digest("hex");
227
+ if (digest !== state.planDigest) {
228
+ failWith(
229
+ EXIT.IDENTITY,
230
+ `the plan digest changed under the run: pinned ${state.planDigest.slice(0, 12)}, observed ${digest.slice(0, 12)}`,
231
+ );
232
+ }
233
+ for (const [field, observed, pinned] of [
234
+ ["worktree", worktree, state.worktree],
235
+ ["branch", branch, state.branch],
236
+ ["merge-base", mergeBase, state.mergeBase],
237
+ ]) {
238
+ if (observed !== undefined && observed !== pinned) {
239
+ failWith(EXIT.IDENTITY, `${field} drifted: pinned ${pinned}, observed ${observed}`);
240
+ }
241
+ }
242
+ };
243
+
244
+ /** Confine a path beneath a root, so an event file cannot come from anywhere. */
245
+ const requireBeneath = (path, root, field) => {
246
+ const absolute = resolve(path);
247
+ if (absolute !== resolve(root) && !absolute.startsWith(`${resolve(root)}/`)) {
248
+ failWith(EXIT.VALIDATION, `${field} must live beneath the run root ${root}`);
249
+ }
250
+ return absolute;
251
+ };
252
+
253
+ /** Initialize a run. Fails if state already exists, so a rerun cannot reset it. */
254
+ export function initRun(options, adapters = defaultAdapters) {
255
+ const { planPath, statePath, progressPath, repoRoot, worktree, branch, baseRef, mergeBase } =
256
+ options;
257
+
258
+ if (existsPath(statePath)) {
259
+ failWith(EXIT.VALIDATION, `a run is already initialized at ${statePath}`);
260
+ }
261
+
262
+ let bytes;
263
+ try {
264
+ bytes = readFileSync(planPath);
265
+ } catch {
266
+ return failWith(EXIT.VALIDATION, `no such plan file: ${planPath}`);
267
+ }
268
+
269
+ let parsed;
270
+ try {
271
+ parsed = parsePlanText(bytes.toString("utf8"), planPath);
272
+ } catch (error) {
273
+ return failWith(EXIT.VALIDATION, error.message);
274
+ }
275
+
276
+ const at = adapters.now();
277
+ let state;
278
+ try {
279
+ state = createInitialState({
280
+ planPath: resolve(planPath),
281
+ planDigest: createHash("sha256").update(bytes).digest("hex"),
282
+ repoRoot: resolve(repoRoot),
283
+ worktree: resolve(worktree),
284
+ runRoot: resolve(dirname(statePath)),
285
+ branch,
286
+ baseRef,
287
+ mergeBase,
288
+ tasks: parsed.tasks.map((task) => ({
289
+ number: task.number,
290
+ implementerTier: task.implementerTier,
291
+ })),
292
+ at,
293
+ });
294
+ } catch (error) {
295
+ return failWith(EXIT.VALIDATION, error.message);
296
+ }
297
+
298
+ mkdirSync(dirname(statePath), { recursive: true });
299
+ const owner = acquireLock(statePath, adapters);
300
+ try {
301
+ // Re-check under the lock: two concurrent inits must not both proceed.
302
+ if (existsPath(statePath)) {
303
+ failWith(EXIT.VALIDATION, `a run is already initialized at ${statePath}`);
304
+ }
305
+ commitState(statePath, progressPath, state, adapters, `# SDD ledger — plan: ${planPath}\n`);
306
+ } finally {
307
+ releaseLock(statePath);
308
+ }
309
+ return { state, owner: owner.token };
310
+ }
311
+
312
+ const existsPath = (path) => {
313
+ try {
314
+ statSync(path);
315
+ return true;
316
+ } catch {
317
+ return false;
318
+ }
319
+ };
320
+
321
+ /**
322
+ * Durably replace state and append its audit line.
323
+ *
324
+ * Temp-write, fsync, rename, fsync directory, then append. The append is last so
325
+ * a crash can only ever leave the ledger *behind* canonical state, which
326
+ * `repair-audit` can fix. The reverse order would leave a marker for a
327
+ * transition that never became durable, which is unrepairable.
328
+ */
329
+ const commitState = (statePath, progressPath, state, adapters, ledgerHeader = null) => {
330
+ const temporaryPath = `${statePath}.tmp.${adapters.token()}`;
331
+ writeSynced(temporaryPath, `${JSON.stringify(state, null, 2)}\n`);
332
+ try {
333
+ adapters.rename(temporaryPath, statePath);
334
+ } catch (error) {
335
+ // The prior state is still intact because the rename never happened.
336
+ rmSync(temporaryPath, { force: true });
337
+ failWith(EXIT.IDENTITY, `could not durably replace state: ${error.message}`);
338
+ }
339
+ syncDirectory(dirname(statePath));
340
+
341
+ if (ledgerHeader !== null && !existsPath(progressPath)) {
342
+ writeSynced(progressPath, ledgerHeader);
343
+ }
344
+ appendSynced(progressPath, `${state.lastTransition}\n`);
345
+ };
346
+
347
+ /** Apply one event under the lock at an exact expected revision. */
348
+ export function transition(options, adapters = defaultAdapters) {
349
+ const { statePath, progressPath, planPath, expectedRevision, eventFile, worktree, branch, mergeBase } =
350
+ options;
351
+
352
+ const state = loadState(statePath);
353
+ const eventPath = requireBeneath(eventFile, state.runRoot, "--event-file");
354
+ const event = readJsonFile(eventPath, "the event file");
355
+
356
+ // The store owns time. A caller-supplied timestamp would let a stuck
357
+ // controller manufacture ordering.
358
+ if (event.at !== undefined) {
359
+ failWith(
360
+ EXIT.VALIDATION,
361
+ "the event file must not carry a transition timestamp; the store supplies it",
362
+ );
363
+ }
364
+
365
+ const owner = acquireLock(statePath, adapters);
366
+ try {
367
+ const current = loadState(statePath);
368
+ verifyIdentity(current, { planPath, worktree, branch, mergeBase });
369
+
370
+ if (current.revision !== expectedRevision) {
371
+ failWith(
372
+ EXIT.CONTENTION,
373
+ `expected revision ${String(expectedRevision)} but found ${String(current.revision)}`,
374
+ );
375
+ }
376
+
377
+ let next;
378
+ try {
379
+ next = reduceState(current, { ...event, at: adapters.now() });
380
+ } catch (error) {
381
+ return failWith(EXIT.VALIDATION, error.message);
382
+ }
383
+
384
+ commitState(statePath, progressPath, next, adapters);
385
+ return { state: next, owner: owner.token };
386
+ } finally {
387
+ releaseLock(statePath);
388
+ }
389
+ }
390
+
391
+ /** Count audit markers in the ledger. */
392
+ const auditMarkers = (progressPath) => {
393
+ try {
394
+ return readFileSync(progressPath, "utf8")
395
+ .split("\n")
396
+ .filter((line) => line.includes(AUDIT_MARKER));
397
+ } catch (error) {
398
+ if (error.code === "ENOENT") return [];
399
+ throw error;
400
+ }
401
+ };
402
+
403
+ /**
404
+ * Report state and audit health without writing anything.
405
+ *
406
+ * `show` never recommends repair while a live lock exists, because the apparent
407
+ * gap is far more likely to be a transition in progress than corruption.
408
+ */
409
+ export function show(options, adapters = defaultAdapters) {
410
+ const { statePath, progressPath } = options;
411
+ const state = loadState(statePath);
412
+ const markers = auditMarkers(progressPath);
413
+ const owner = readLockOwner(statePath);
414
+ const locked = ownerIsLive(owner, adapters);
415
+
416
+ const expected = state.revision + 1;
417
+ let audit;
418
+ if (locked) {
419
+ audit = { status: "RUN_LOCKED", owner: { host: owner.host, pid: owner.pid, at: owner.at } };
420
+ } else if (markers.length === expected) {
421
+ audit = { status: "OK", markers: markers.length };
422
+ } else if (markers.length === expected - 1) {
423
+ audit = { status: "AUDIT_REPAIR_NEEDED", markers: markers.length, expected };
424
+ } else {
425
+ audit = { status: "AUDIT_CORRUPT", markers: markers.length, expected };
426
+ }
427
+
428
+ // A dispatch intent with no recorded session id is the unclosable spawn
429
+ // window. Recovery must see it named, not infer it from a phase.
430
+ const ambiguous =
431
+ state.phase === "DISPATCH_AMBIGUOUS" ||
432
+ (state.dispatch !== null &&
433
+ state.dispatch.sessionId === null &&
434
+ state.phase.endsWith("_DISPATCH_INTENT"));
435
+
436
+ return {
437
+ state,
438
+ audit,
439
+ lock: owner === null ? { status: "UNLOCKED" } : { status: locked ? "LIVE" : "STALE", owner },
440
+ dispatch:
441
+ state.dispatch === null
442
+ ? null
443
+ : {
444
+ dispatchKey: state.dispatch.dispatchKey,
445
+ role: state.dispatch.role,
446
+ tier: state.dispatch.tier,
447
+ sessionId: state.dispatch.sessionId,
448
+ renderedPromptBytes: Buffer.byteLength(state.dispatch.renderedPrompt ?? "", "utf8"),
449
+ ambiguous,
450
+ },
451
+ nextAction: ambiguous
452
+ ? "record a dispatch ruling: adopt an observed session id, or reissue the stored prompt bytes"
453
+ : null,
454
+ };
455
+ }
456
+
457
+ /** Append the one missing audit marker, projecting only `lastTransition`. */
458
+ export function repairAudit(options, adapters = defaultAdapters) {
459
+ const { statePath, progressPath } = options;
460
+ const owner = readLockOwner(statePath);
461
+ if (ownerIsLive(owner, adapters)) {
462
+ failWith(EXIT.CONTENTION, "the run is locked; repair is not safe while a transition may be live");
463
+ }
464
+
465
+ const acquired = acquireLock(statePath, adapters);
466
+ try {
467
+ const state = loadState(statePath);
468
+ const markers = auditMarkers(progressPath);
469
+ const expected = state.revision + 1;
470
+ if (markers.length === expected) {
471
+ return { repaired: false, markers: markers.length };
472
+ }
473
+ if (markers.length !== expected - 1) {
474
+ failWith(
475
+ EXIT.IDENTITY,
476
+ `the ledger holds ${String(markers.length)} markers but ${String(expected)} are expected; this is corruption, not a missing marker`,
477
+ );
478
+ }
479
+ appendSynced(progressPath, `${state.lastTransition}\n`);
480
+ return { repaired: true, markers: markers.length + 1, owner: acquired.token };
481
+ } finally {
482
+ releaseLock(statePath);
483
+ }
484
+ }
485
+
486
+ /** Report lock status without changing it. */
487
+ export function lockStatus(options, adapters = defaultAdapters) {
488
+ const owner = readLockOwner(options.statePath);
489
+ if (owner === null) return { status: "UNLOCKED" };
490
+ return { status: ownerIsLive(owner, adapters) ? "LIVE" : "STALE", owner };
491
+ }
492
+
493
+ /**
494
+ * Clear a stale lock.
495
+ *
496
+ * Never guesses. Requires the exact owner token, the same host, a dead PID, and a
497
+ * persisted decision file. A lock held by a live process, or by another host
498
+ * where liveness cannot be checked, is left alone.
499
+ */
500
+ export function clearStaleLock(options, adapters = defaultAdapters) {
501
+ const { statePath, expectedOwnerToken, decisionFile } = options;
502
+ const owner = readLockOwner(statePath);
503
+ if (owner === null) return { cleared: false, reason: "the run is not locked" };
504
+
505
+ const decision = readJsonFile(decisionFile, "the decision file");
506
+ for (const field of ["action", "ownerToken", "reason", "approvedAt"]) {
507
+ if (typeof decision[field] !== "string" || decision[field].length === 0) {
508
+ failWith(EXIT.VALIDATION, `the decision file requires a non-empty ${field}`);
509
+ }
510
+ }
511
+ if (decision.action !== "clear-stale-lock") {
512
+ failWith(EXIT.VALIDATION, `the decision action must be "clear-stale-lock"`);
513
+ }
514
+ if (decision.ownerToken !== expectedOwnerToken || owner.token !== expectedOwnerToken) {
515
+ failWith(EXIT.UNRESOLVED_LOCK, "the owner token does not match the lock on disk");
516
+ }
517
+ if (owner.host !== adapters.host()) {
518
+ failWith(
519
+ EXIT.UNRESOLVED_LOCK,
520
+ `the lock is held on ${owner.host}; liveness cannot be checked from ${adapters.host()}`,
521
+ );
522
+ }
523
+ if (ownerIsLive(owner, adapters)) {
524
+ failWith(EXIT.UNRESOLVED_LOCK, `the lock owner (pid ${String(owner.pid)}) is still alive`);
525
+ }
526
+
527
+ unlinkSync(lockPathFor(statePath));
528
+ return {
529
+ cleared: true,
530
+ receipt: `cleared lock token ${owner.token.slice(0, 12)} held by dead pid ${String(owner.pid)} on ${owner.host}; approved ${decision.approvedAt}`,
531
+ };
532
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ exec node "$(cd "$(dirname "$0")" && pwd)/sdd-state.mjs" "$@"