dotmd-cli 0.69.0 → 0.70.1

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 (54) hide show
  1. package/README.md +144 -964
  2. package/bin/dotmd.mjs +251 -202
  3. package/dotmd.config.example.mjs +5 -8
  4. package/package.json +6 -10
  5. package/src/agent-context.mjs +132 -0
  6. package/src/atomic-mutation.mjs +1505 -0
  7. package/src/baton.mjs +109 -114
  8. package/src/bulk-tag.mjs +7 -7
  9. package/src/check-collapse.mjs +2 -2
  10. package/src/commands.mjs +326 -12
  11. package/src/completions.mjs +38 -98
  12. package/src/config.mjs +18 -3
  13. package/src/diff.mjs +7 -3
  14. package/src/doctor.mjs +25 -15
  15. package/src/export.mjs +154 -25
  16. package/src/fix-refs.mjs +2 -0
  17. package/src/frontmatter-fix.mjs +9 -7
  18. package/src/frontmatter.mjs +3 -2
  19. package/src/git.mjs +722 -14
  20. package/src/graph.mjs +53 -25
  21. package/src/guard.mjs +163 -60
  22. package/src/hud.mjs +65 -76
  23. package/src/index-file.mjs +28 -16
  24. package/src/index.mjs +21 -13
  25. package/src/init.mjs +1 -1
  26. package/src/journal.mjs +145 -12
  27. package/src/lifecycle.mjs +596 -294
  28. package/src/lint.mjs +117 -56
  29. package/src/managed-path.mjs +192 -0
  30. package/src/migrate-prompts.mjs +2 -0
  31. package/src/migrate-template.mjs +2 -0
  32. package/src/migrate.mjs +7 -1
  33. package/src/new.mjs +135 -54
  34. package/src/output-identity.mjs +106 -0
  35. package/src/pickup-card.mjs +24 -10
  36. package/src/pickup.mjs +457 -0
  37. package/src/prompts.mjs +134 -75
  38. package/src/query.mjs +22 -10
  39. package/src/reference-planner.mjs +292 -0
  40. package/src/rename.mjs +65 -73
  41. package/src/render.mjs +24 -11
  42. package/src/runlist.mjs +109 -71
  43. package/src/section.mjs +2 -1
  44. package/src/ship.mjs +39 -20
  45. package/src/stats.mjs +1 -1
  46. package/src/status-metadata.mjs +87 -0
  47. package/src/statuses.mjs +11 -26
  48. package/src/summary.mjs +14 -3
  49. package/src/update.mjs +38 -10
  50. package/src/use.mjs +4 -1
  51. package/src/util.mjs +1 -0
  52. package/src/validate.mjs +53 -17
  53. package/src/watch.mjs +6 -1
  54. package/src/notion.mjs +0 -528
package/src/pickup.mjs ADDED
@@ -0,0 +1,457 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { authorizeManagedSource, authorizeRepoGeneratedPath } from './managed-path.mjs';
5
+ import { currentProcessOwner, mutateFileSet, processOwnerLiveness, replaceSnapshot, snapshotFile, withPathLocks } from './atomic-mutation.mjs';
6
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
7
+ import { asString } from './util.mjs';
8
+
9
+ export const OWNERSHIP_SCHEMA = 2;
10
+ export const HOOK_DELIVERY_LEASE_MS = 30_000;
11
+
12
+ export function authoritativeSessionId(env = process.env) {
13
+ const candidates = [
14
+ ['DOTMD_SESSION_ID', 'dotmd'],
15
+ ['CLAUDE_CODE_SESSION_ID', 'claude'],
16
+ ['CLAUDE_SESSION_ID', 'claude'],
17
+ ['OPENCODE_SESSION_ID', 'opencode'],
18
+ ['OPENCODE_SESSION', 'opencode'],
19
+ ['TERM_SESSION_ID', 'term'],
20
+ ];
21
+ for (const [name, host] of candidates) {
22
+ const value = env[name]?.trim();
23
+ if (value) return host === 'term' ? `term:${value}` : value;
24
+ }
25
+ throw new Error('No authoritative session identity. Set DOTMD_SESSION_ID for this shell or host session.');
26
+ }
27
+
28
+ export function availableSessionId(env = process.env) {
29
+ try { return authoritativeSessionId(env); } catch { return null; }
30
+ }
31
+
32
+ function sameIdentity(left, right, fs = { statSync }) {
33
+ try {
34
+ const a = fs.statSync(left, { bigint: true });
35
+ const b = fs.statSync(right, { bigint: true });
36
+ return a.dev === b.dev && a.ino === b.ino;
37
+ } catch { return false; }
38
+ }
39
+
40
+ // realpath may preserve caller-provided case on case-insensitive filesystems.
41
+ // Recover the directory entry spelling at every level. The resulting identity
42
+ // survives atomic replacement because it is path-based, not keyed by inode.
43
+ export function canonicalizePathEntrySpelling(input, fs = { readdirSync, statSync }) {
44
+ const absolute = path.resolve(input);
45
+ const parsed = path.parse(absolute);
46
+ const segments = absolute.slice(parsed.root.length).split(path.sep).filter(Boolean);
47
+ let current = parsed.root;
48
+ for (const segment of segments) {
49
+ const requested = path.join(current, segment);
50
+ let entries;
51
+ try { entries = fs.readdirSync(current); } catch { return absolute; }
52
+ const exact = entries.find(entry => entry === segment);
53
+ const actual = exact ?? entries.find(entry => sameIdentity(path.join(current, entry), requested, fs));
54
+ if (!actual) return absolute;
55
+ current = path.join(current, actual);
56
+ }
57
+ return current;
58
+ }
59
+
60
+ export function classifyPlanPickup(facts) {
61
+ const {
62
+ type, status, validStatuses, startableStatuses, terminalStatuses,
63
+ archiveStatuses, physicallyArchived, ownership, sessionId, malformed,
64
+ } = facts;
65
+ if (malformed) return { kind: 'malformed', pickupable: false };
66
+ if (type !== 'plan') return { kind: 'wrong-type', pickupable: false };
67
+ if (!status || !validStatuses?.has(status)) return { kind: 'unconfigured-status', pickupable: false };
68
+ if (physicallyArchived) return { kind: 'physical-archive', pickupable: false };
69
+ if (archiveStatuses?.has(status) || terminalStatuses?.has(status)) return { kind: 'terminal', pickupable: false };
70
+ if (ownership?.corrupt) return { kind: 'ownership-corrupt', pickupable: false };
71
+ if (ownership?.state === 'owned' && ownership.sessionId !== sessionId) {
72
+ return { kind: 'busy', pickupable: false, owner: ownership.sessionId };
73
+ }
74
+ if (status === 'in-session') {
75
+ if (ownership?.state === 'owned') return { kind: 'resume', pickupable: true };
76
+ return { kind: 'adopt', pickupable: true };
77
+ }
78
+ if (startableStatuses?.has(status)) return { kind: 'start', pickupable: true };
79
+ return { kind: 'parked', pickupable: false };
80
+ }
81
+
82
+ function ownershipRoot(config) {
83
+ return path.join(config.repoRoot, '.dotmd', 'ownership');
84
+ }
85
+
86
+ export function canonicalPlanIdentity(filePath, config) {
87
+ const authorized = authorizeManagedSource(filePath, config, { kind: 'Ownership plan source' });
88
+ const resolved = process.platform === 'win32'
89
+ ? path.join(realpathSync(path.dirname(authorized.path)), path.basename(authorized.path))
90
+ : canonicalizePathEntrySpelling(realpathSync(authorized.path));
91
+ const canonicalPath = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
92
+ let repoPath = path.relative(config.repoRoot, authorized.path);
93
+ if (process.platform === 'win32') repoPath = repoPath.toLowerCase();
94
+ else {
95
+ const displayRoot = canonicalizePathEntrySpelling(authorized.root.lexicalPath);
96
+ const managedDisplayPath = canonicalizePathEntrySpelling(authorized.path);
97
+ repoPath = path.relative(config.repoRoot, path.join(authorized.root.lexicalPath, path.relative(displayRoot, managedDisplayPath)));
98
+ }
99
+ return {
100
+ canonicalPath,
101
+ key: createHash('sha256').update(canonicalPath).digest('hex'),
102
+ repoPath: repoPath.split(path.sep).join('/'),
103
+ managedPath: authorized.path,
104
+ };
105
+ }
106
+
107
+ export function plannedPlanIdentity(filePath, config) {
108
+ const absolute = path.resolve(filePath);
109
+ let parent = path.dirname(absolute);
110
+ const suffix = [path.basename(absolute)];
111
+ while (!existsSync(parent)) {
112
+ suffix.unshift(path.basename(parent));
113
+ parent = path.dirname(parent);
114
+ }
115
+ const resolved = canonicalizePathEntrySpelling(path.join(realpathSync(parent), ...suffix));
116
+ const canonicalPath = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
117
+ return {
118
+ canonicalPath,
119
+ key: createHash('sha256').update(canonicalPath).digest('hex'),
120
+ repoPath: (process.platform === 'win32'
121
+ ? path.relative(config.repoRoot, absolute).toLowerCase()
122
+ : path.relative(config.repoRoot, absolute))
123
+ .split(path.sep).join('/'),
124
+ managedPath: absolute,
125
+ };
126
+ }
127
+
128
+ function recordPathForIdentity(identity, config) {
129
+ return authorizeRepoGeneratedPath(path.join(ownershipRoot(config), `${identity.key}.json`), config, {
130
+ kind: 'Plan ownership record',
131
+ }).path;
132
+ }
133
+
134
+ export function ownershipPathFor(repoPath, config) {
135
+ return recordPathForIdentity(canonicalPlanIdentity(path.resolve(config.repoRoot, repoPath), config), config);
136
+ }
137
+
138
+ function parseOwnership(raw, recordPath) {
139
+ try {
140
+ const value = JSON.parse(raw);
141
+ if (value?.schema !== OWNERSHIP_SCHEMA
142
+ || !['owned', 'released'].includes(value.state)
143
+ || typeof value.plan !== 'string'
144
+ || typeof value.canonicalPath !== 'string'
145
+ || typeof value.identityKey !== 'string'
146
+ || typeof value.sessionId !== 'string') throw new Error('invalid shape');
147
+ return { ...value, recordPath, raw, corrupt: false };
148
+ } catch {
149
+ return { corrupt: true, recordPath, raw, reason: 'invalid ownership JSON or schema' };
150
+ }
151
+ }
152
+
153
+ function validateBinding(record, identity, config) {
154
+ if (record.corrupt) return record;
155
+ const expectedPath = recordPathForIdentity(identity, config);
156
+ if (record.recordPath !== expectedPath
157
+ || record.identityKey !== identity.key
158
+ || record.canonicalPath !== identity.canonicalPath
159
+ || record.plan !== identity.repoPath) {
160
+ return { ...record, corrupt: true, reason: 'ownership record identity/path binding mismatch' };
161
+ }
162
+ return record;
163
+ }
164
+
165
+ export function readPlanOwnership(repoPath, config) {
166
+ let identity;
167
+ try { identity = canonicalPlanIdentity(path.resolve(config.repoRoot, repoPath), config); }
168
+ catch { return null; }
169
+ const recordPath = recordPathForIdentity(identity, config);
170
+ if (!existsSync(recordPath)) return null;
171
+ try { return validateBinding(parseOwnership(readFileSync(recordPath, 'utf8'), recordPath), identity, config); }
172
+ catch { return { corrupt: true, recordPath, raw: null, reason: 'unreadable ownership record' }; }
173
+ }
174
+
175
+ export function prepareOwnershipMigration(oldRepoPath, newPath, config, { sessionId = authoritativeSessionId(), now = new Date().toISOString() } = {}) {
176
+ const ownership = readPlanOwnership(oldRepoPath, config);
177
+ if (!ownership) return null;
178
+ if (ownership.corrupt) throw new Error(`Ownership record is corrupt for ${oldRepoPath}: ${ownership.reason}; repair or release it before rename.`);
179
+ if (ownership.state === 'owned' && ownership.sessionId !== sessionId) {
180
+ throw new Error(`Plan is busy in another session (${ownership.sessionId}): ${oldRepoPath}`);
181
+ }
182
+ const identity = plannedPlanIdentity(newPath, config);
183
+ const recordPath = recordPathForIdentity(identity, config);
184
+ const content = recordContent({
185
+ identity,
186
+ sessionId: ownership.sessionId,
187
+ state: ownership.state,
188
+ now,
189
+ claimedAt: ownership.claimedAt,
190
+ operation: ownership.operation,
191
+ });
192
+ return {
193
+ oldRecordPath: ownership.recordPath,
194
+ oldContent: ownership.raw,
195
+ newRecordPath: recordPath,
196
+ newContent: content,
197
+ };
198
+ }
199
+
200
+ export function listOwnedPlans(config, sessionId = authoritativeSessionId()) {
201
+ const root = ownershipRoot(config);
202
+ const found = [];
203
+ const diagnostics = [];
204
+ if (!existsSync(root)) return Object.assign(found, { diagnostics });
205
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
206
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
207
+ const recordPath = path.join(root, entry.name);
208
+ let record;
209
+ try { record = parseOwnership(readFileSync(recordPath, 'utf8'), recordPath); }
210
+ catch { record = { corrupt: true, recordPath, reason: 'unreadable ownership record' }; }
211
+ if (record.corrupt) { diagnostics.push(`${entry.name}: ${record.reason}`); continue; }
212
+ if (record.state !== 'owned' || record.sessionId !== sessionId) continue;
213
+ let identity;
214
+ try { identity = canonicalPlanIdentity(path.resolve(config.repoRoot, record.plan), config); }
215
+ catch { diagnostics.push(`${record.plan}: owned record points to a missing or unmanaged plan`); continue; }
216
+ record = validateBinding(record, identity, config);
217
+ if (record.corrupt) { diagnostics.push(`${record.plan}: ${record.reason}`); continue; }
218
+ let planFm;
219
+ try { planFm = parseSimpleFrontmatter(extractFrontmatter(readFileSync(identity.managedPath, 'utf8')).frontmatter); }
220
+ catch { diagnostics.push(`${record.plan}: owned plan cannot be read`); continue; }
221
+ if (asString(planFm.type) !== 'plan' || asString(planFm.status) !== 'in-session') {
222
+ diagnostics.push(`${record.plan}: owned record is stale because the plan is not in-session`);
223
+ continue;
224
+ }
225
+ found.push(record);
226
+ }
227
+ found.sort((a, b) => a.plan.localeCompare(b.plan));
228
+ return Object.assign(found, { diagnostics });
229
+ }
230
+
231
+ function recordContent({ identity, sessionId, state, now, claimedAt, operation }) {
232
+ return JSON.stringify({
233
+ schema: OWNERSHIP_SCHEMA,
234
+ state,
235
+ plan: identity.repoPath,
236
+ canonicalPath: identity.canonicalPath,
237
+ identityKey: identity.key,
238
+ sessionId,
239
+ claimedAt: claimedAt ?? now,
240
+ updatedAt: now,
241
+ operation: operation ?? null,
242
+ }, null, 2) + '\n';
243
+ }
244
+
245
+ export function preparePlanClaim({ filePath, sourceContent, renderedContent, ownership, sessionId, now, config, operationId = randomUUID() }) {
246
+ const identity = canonicalPlanIdentity(filePath, config);
247
+ const recordPath = recordPathForIdentity(identity, config);
248
+ const operation = {
249
+ id: operationId,
250
+ kind: 'claim',
251
+ oldStatus: (() => {
252
+ const match = sourceContent.match(/^status:\s*(.+)$/m);
253
+ return match?.[1]?.trim() ?? null;
254
+ })(),
255
+ index: config.indexPath ? 'pending' : 'skipped',
256
+ hook: 'pending',
257
+ };
258
+ const content = recordContent({ identity, sessionId, state: 'owned', now,
259
+ claimedAt: ownership && !ownership.corrupt ? ownership.claimedAt : null, operation });
260
+ const updates = [];
261
+ if (renderedContent !== null && renderedContent !== sourceContent) {
262
+ updates.push({ path: filePath, expectedContent: sourceContent, content: renderedContent });
263
+ }
264
+ const creations = [];
265
+ if (ownership) updates.push({ path: recordPath, expectedContent: ownership.raw, content });
266
+ else creations.push({ path: recordPath, content });
267
+ return { identity, recordPath, updates, creations, operationId };
268
+ }
269
+
270
+ export function commitPlanClaim(args) {
271
+ const prepared = preparePlanClaim(args);
272
+ mkdirSync(path.dirname(prepared.recordPath), { recursive: true });
273
+ mutateFileSet({ updates: prepared.updates, creations: prepared.creations }, {
274
+ repoRoot: args.config.repoRoot,
275
+ testHooks: args.testHooks,
276
+ });
277
+ return prepared;
278
+ }
279
+
280
+ export function prepareOwnershipRelease(repoPath, config, { sessionId = authoritativeSessionId(), force = false, now = new Date().toISOString() } = {}) {
281
+ const identity = canonicalPlanIdentity(path.resolve(config.repoRoot, repoPath), config);
282
+ const ownership = readPlanOwnership(repoPath, config);
283
+ if (!ownership) return null;
284
+ if (ownership.corrupt && !force) throw new Error(`Ownership record is corrupt for ${repoPath}: ${ownership.reason}; use an explicit path with --force to recover.`);
285
+ if (!ownership.corrupt && ownership.state === 'owned' && ownership.sessionId !== sessionId && !force) {
286
+ throw new Error(`Plan is busy in another session (${ownership.sessionId}): ${repoPath}`);
287
+ }
288
+ if (!ownership.corrupt && ownership.state === 'released') return null;
289
+ return {
290
+ path: ownership.recordPath,
291
+ expectedContent: ownership.raw,
292
+ content: recordContent({ identity, sessionId, state: 'released', now,
293
+ claimedAt: ownership.corrupt ? null : ownership.claimedAt, operation: null }),
294
+ };
295
+ }
296
+
297
+ export function assertPlanMutationAuthorized(repoPath, config, { sessionId = authoritativeSessionId(), force = false } = {}) {
298
+ const ownership = readPlanOwnership(repoPath, config);
299
+ if (ownership?.corrupt && !force) {
300
+ throw new Error(`Ownership record is corrupt for ${repoPath}: ${ownership.reason}; use an explicit path with --force to recover.`);
301
+ }
302
+ if (ownership?.state === 'owned' && ownership.sessionId !== sessionId && !force) {
303
+ throw new Error(`Plan is busy in another session (${ownership.sessionId}): ${repoPath}`);
304
+ }
305
+ return ownership;
306
+ }
307
+
308
+ export function updateOwnershipOperation(repoPath, config, expected, mutate) {
309
+ const ownership = readPlanOwnership(repoPath, config);
310
+ if (!ownership || ownership.corrupt || ownership.state !== 'owned' || !ownership.operation) {
311
+ throw new Error(`Claim completion is stale for ${repoPath}; ownership is no longer active.`);
312
+ }
313
+ if (ownership.sessionId !== expected.sessionId
314
+ || ownership.operation.id !== expected.operationId
315
+ || ownership.identityKey !== expected.identityKey) {
316
+ throw new Error(`Claim completion is stale for ${repoPath}; ownership or operation changed.`);
317
+ }
318
+ const next = structuredClone(ownership);
319
+ delete next.recordPath; delete next.raw; delete next.corrupt; delete next.reason;
320
+ mutate(next.operation);
321
+ next.updatedAt = new Date().toISOString();
322
+ mutateFileSet({ updates: [{ path: ownership.recordPath, expectedContent: ownership.raw, content: JSON.stringify(next, null, 2) + '\n' }] }, { repoRoot: config.repoRoot });
323
+ return next;
324
+ }
325
+
326
+ export function validatedClaimOperation(repoPath, config, expected = null) {
327
+ const ownership = readPlanOwnership(repoPath, config);
328
+ if (!ownership || ownership.corrupt || ownership.state !== 'owned' || !ownership.operation) return null;
329
+ const binding = {
330
+ sessionId: ownership.sessionId,
331
+ operationId: ownership.operation.id,
332
+ identityKey: ownership.identityKey,
333
+ };
334
+ if (expected && (binding.sessionId !== expected.sessionId
335
+ || binding.operationId !== expected.operationId
336
+ || binding.identityKey !== expected.identityKey)) return null;
337
+ return { ownership, binding, operation: ownership.operation };
338
+ }
339
+
340
+ function lockedOperation(repoPath, config, expected, callback) {
341
+ const identity = canonicalPlanIdentity(path.resolve(config.repoRoot, repoPath), config);
342
+ const recordPath = recordPathForIdentity(identity, config);
343
+ return withPathLocks([recordPath], { repoRoot: config.repoRoot }, () => {
344
+ const snapshot = snapshotFile(recordPath);
345
+ const ownership = validateBinding(parseOwnership(snapshot.content, recordPath), identity, config);
346
+ if (ownership.corrupt || ownership.state !== 'owned' || !ownership.operation
347
+ || ownership.sessionId !== expected.sessionId
348
+ || ownership.operation.id !== expected.operationId
349
+ || ownership.identityKey !== expected.identityKey) {
350
+ throw new Error(`Claim completion is stale for ${repoPath}; ownership or operation changed.`);
351
+ }
352
+ return callback({ ownership, snapshot });
353
+ });
354
+ }
355
+
356
+ function writeLockedOperation(ownership, snapshot, config, mutate) {
357
+ const next = structuredClone(ownership);
358
+ delete next.recordPath; delete next.raw; delete next.corrupt; delete next.reason;
359
+ mutate(next.operation);
360
+ next.updatedAt = new Date().toISOString();
361
+ replaceSnapshot(snapshot, JSON.stringify(next, null, 2) + '\n', { repoRoot: config.repoRoot, locked: true });
362
+ return next;
363
+ }
364
+
365
+ export function beginClaimHookDelivery(repoPath, config, expected, options = {}) {
366
+ const now = options.now ?? new Date();
367
+ const leaseMs = options.leaseMs ?? HOOK_DELIVERY_LEASE_MS;
368
+ const ownerLiveness = options.ownerLiveness ?? processOwnerLiveness;
369
+ return lockedOperation(repoPath, config, expected, ({ ownership, snapshot }) => {
370
+ const operation = ownership.operation;
371
+ if (['done', 'skipped'].includes(operation.hook)) return null;
372
+ if (operation.hook === 'delivering') {
373
+ const age = now.getTime() - Date.parse(operation.hookDeliveryStartedAt ?? '');
374
+ const liveness = ownerLiveness(operation.hookDeliveryOwner);
375
+ if (!Number.isFinite(age) || age < leaseMs || liveness !== 'dead') {
376
+ return { busy: true, operationId: operation.id, liveness };
377
+ }
378
+ }
379
+ const token = randomUUID();
380
+ const next = writeLockedOperation(ownership, snapshot, config, op => {
381
+ op.hook = 'delivering';
382
+ op.hookDeliveryToken = token;
383
+ op.hookDeliveryStartedAt = now.toISOString();
384
+ op.hookDeliveryOwner = currentProcessOwner();
385
+ });
386
+ return { busy: false, token, operation: next.operation };
387
+ });
388
+ }
389
+
390
+ export function finishClaimHookDelivery(repoPath, config, expected, token) {
391
+ return lockedOperation(repoPath, config, expected, ({ ownership, snapshot }) => {
392
+ if (ownership.operation.hook !== 'delivering' || ownership.operation.hookDeliveryToken !== token) {
393
+ throw new Error(`Claim hook delivery token is stale for ${repoPath}.`);
394
+ }
395
+ return writeLockedOperation(ownership, snapshot, config, op => {
396
+ op.hook = 'done';
397
+ delete op.hookDeliveryToken;
398
+ delete op.hookDeliveryStartedAt;
399
+ delete op.hookDeliveryOwner;
400
+ });
401
+ });
402
+ }
403
+
404
+ export function abandonClaimHookDelivery(repoPath, config, expected, token) {
405
+ try {
406
+ return lockedOperation(repoPath, config, expected, ({ ownership, snapshot }) => {
407
+ if (ownership.operation.hook !== 'delivering' || ownership.operation.hookDeliveryToken !== token) return ownership;
408
+ return writeLockedOperation(ownership, snapshot, config, op => {
409
+ op.hook = 'pending';
410
+ delete op.hookDeliveryToken;
411
+ delete op.hookDeliveryStartedAt;
412
+ delete op.hookDeliveryOwner;
413
+ });
414
+ });
415
+ } catch { return null; }
416
+ }
417
+
418
+ export function skipClaimHookDelivery(repoPath, config, expected, token) {
419
+ return lockedOperation(repoPath, config, expected, ({ ownership, snapshot }) => {
420
+ if (ownership.operation.hook !== 'delivering' || ownership.operation.hookDeliveryToken !== token) {
421
+ throw new Error(`Claim hook delivery token is stale for ${repoPath}.`);
422
+ }
423
+ return writeLockedOperation(ownership, snapshot, config, op => {
424
+ op.hook = 'skipped';
425
+ delete op.hookDeliveryToken;
426
+ delete op.hookDeliveryStartedAt;
427
+ delete op.hookDeliveryOwner;
428
+ });
429
+ });
430
+ }
431
+
432
+ export function assertHookDeliveryTakeoverSafe(ownership, options = {}) {
433
+ if (ownership?.operation?.hook !== 'delivering') return;
434
+ const now = options.now ?? new Date();
435
+ const leaseMs = options.leaseMs ?? HOOK_DELIVERY_LEASE_MS;
436
+ const age = now.getTime() - Date.parse(ownership.operation.hookDeliveryStartedAt ?? '');
437
+ const liveness = (options.ownerLiveness ?? processOwnerLiveness)(ownership.operation.hookDeliveryOwner);
438
+ if (!Number.isFinite(age) || age < leaseMs || liveness !== 'dead') {
439
+ throw new Error(`Plan hook delivery is active (${liveness}); force takeover is refused until its owner is demonstrably dead and the delivery lease expires.`);
440
+ }
441
+ }
442
+
443
+ export function pickupFactsForDoc(doc, config, { sessionId = availableSessionId() } = {}) {
444
+ const ownership = doc?.path ? readPlanOwnership(doc.path, config) : null;
445
+ return classifyPlanPickup({
446
+ type: doc?.type ?? null,
447
+ status: doc?.status ?? null,
448
+ validStatuses: config.typeStatuses?.get('plan') ?? config.validStatuses,
449
+ startableStatuses: config.lifecycle.startableStatuses,
450
+ terminalStatuses: config.lifecycle.terminalStatuses,
451
+ archiveStatuses: config.lifecycle.archiveStatuses,
452
+ physicallyArchived: Boolean(doc?.path?.split('/').includes(config.archiveDir)),
453
+ ownership,
454
+ sessionId,
455
+ malformed: !doc || doc.parseError === true,
456
+ });
457
+ }