dotmd-cli 0.74.1 → 0.74.2
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/bin/dotmd.mjs +19 -1
- package/package.json +1 -1
- package/src/commands.mjs +1 -1
- package/src/doctor.mjs +110 -3
- package/src/hud.mjs +1 -14
- package/src/lifecycle.mjs +9 -1
- package/src/new.mjs +11 -2
- package/src/pickup.mjs +176 -12
- package/src/prompts.mjs +2 -0
- package/src/util.mjs +17 -0
package/bin/dotmd.mjs
CHANGED
|
@@ -202,6 +202,7 @@ Analyze:
|
|
|
202
202
|
Validate & Fix:
|
|
203
203
|
doctor [--apply] Auto-fix everything: refs, lint, long fields, dates, index (preview by default)
|
|
204
204
|
doctor --transactions Report/clear wedged mutation transactions (run this if mutations refuse repo-wide)
|
|
205
|
+
doctor --claims Report/release plan claims held by sessions that are gone ("busy in another session")
|
|
205
206
|
self-check Project/version skew diagnostic (alias: doctor --project)
|
|
206
207
|
lint [--fix] Check and auto-fix frontmatter issues
|
|
207
208
|
fix-refs [--dry-run] Auto-fix broken reference paths + body links
|
|
@@ -727,6 +728,21 @@ Modes:
|
|
|
727
728
|
clear the transactions whose files already agree on
|
|
728
729
|
one generation (no document content is touched);
|
|
729
730
|
the rest are reported for manual review.
|
|
731
|
+
--claims Report which plans are claimed by which session, how
|
|
732
|
+
old each claim is, and whether the owning session's
|
|
733
|
+
process is still alive. A claim is what makes \`set\`,
|
|
734
|
+
\`archive\`, \`baton\`, and \`rename\` refuse a single
|
|
735
|
+
plan ("Plan is busy in another session"); nothing
|
|
736
|
+
expires one, so a session that died holds its plan
|
|
737
|
+
until someone takes it back. Add --apply to release
|
|
738
|
+
the claims whose owning process is provably gone
|
|
739
|
+
(their plans return to \`active\`).
|
|
740
|
+
--claims --apply --older-than <24h|3d>
|
|
741
|
+
Also release claims dotmd cannot judge — ones written
|
|
742
|
+
before it recorded the owning process, or held on
|
|
743
|
+
another machine — that are older than the duration.
|
|
744
|
+
That threshold is your judgement, not dotmd's: it
|
|
745
|
+
cannot tell a dead session from a slow one.
|
|
730
746
|
--statuses Read-only diagnostic: detect overloaded status
|
|
731
747
|
buckets where one status holds plans pursuing
|
|
732
748
|
multiple distinct unstuck-actions. Suggests how
|
|
@@ -1741,7 +1757,9 @@ async function main() {
|
|
|
1741
1757
|
const doctorDryRun = doctorSubMode ? dryRun : (dryRun || !doctorExplicitApply);
|
|
1742
1758
|
const filtered = restArgs.filter(a => a !== '--apply' && a !== '--yes');
|
|
1743
1759
|
const { runDoctor } = await import('../src/doctor.mjs');
|
|
1744
|
-
|
|
1760
|
+
// Awaited because --claims releases through `runSet`, which is async; the
|
|
1761
|
+
// other modes return undefined and are unaffected.
|
|
1762
|
+
await runDoctor(filtered, config, { dryRun: doctorDryRun });
|
|
1745
1763
|
return;
|
|
1746
1764
|
}
|
|
1747
1765
|
if (command === 'statuses') { const { runStatuses } = await import('../src/statuses.mjs'); await runStatuses(restArgs, config, { dryRun, type: typeArg }); return; }
|
package/package.json
CHANGED
package/src/commands.mjs
CHANGED
|
@@ -134,7 +134,7 @@ const definitions = [
|
|
|
134
134
|
command('migrate', mutates('managed source sweep'), 'mutate', [form('<field> <old> <new> [files...]', { args: positionals(3, Infinity), options: [flag('--show-files')] })]),
|
|
135
135
|
command('fix-refs', mutates('managed source sweep'), 'mutate', [form('', { options: [flag('--show-files')] })]),
|
|
136
136
|
command('sync-status', mutates('managed source sweep'), 'mutate', [form('[hubs...]', { args: positionals(0, Infinity), options: [flag('--adopt'), flag('--json')] })]),
|
|
137
|
-
command('doctor', mutates('managed sweeps, repo index, and maintenance config paths by mode'), 'mutate', [form('[path]', { args: positionals(0, 1), options: [flag('--apply', '--yes'), flag('--statuses'), optionalValue('--migrate-template'), flag('--migrate-prompts'), flag('--frontmatter-fix'), flag('--project'), flag('--transactions'), flag('--json'), flag('--include-archived')] })]),
|
|
137
|
+
command('doctor', mutates('managed sweeps, repo index, and maintenance config paths by mode'), 'mutate', [form('[path]', { args: positionals(0, 1), options: [flag('--apply', '--yes'), flag('--statuses'), optionalValue('--migrate-template'), flag('--migrate-prompts'), flag('--frontmatter-fix'), flag('--project'), flag('--transactions'), flag('--claims'), value('--older-than'), flag('--json'), flag('--include-archived')] })]),
|
|
138
138
|
command('statuses', mutates('project config path; document scan is read-only'), 'mutate', [
|
|
139
139
|
form('list', { subcommands: ['list'], options: [value('--type'), flag('--json')] }),
|
|
140
140
|
form('add <name>', { subcommands: ['add'], args: positionals(1, 1), options: STATUS_PROPERTY_OPTIONS }),
|
package/src/doctor.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fixBrokenRefs } from './fix-refs.mjs';
|
|
4
4
|
import { runLint } from './lint.mjs';
|
|
5
5
|
import { syncHubStatuses } from './sync-status.mjs';
|
|
6
|
-
import { runTouch } from './lifecycle.mjs';
|
|
6
|
+
import { runSet, runTouch } from './lifecycle.mjs';
|
|
7
7
|
import { buildIndex, collectDocFiles } from './index.mjs';
|
|
8
8
|
import { writeRenderedIndex } from './index-file.mjs';
|
|
9
9
|
import { renderCheck, renderManualFixes } from './render.mjs';
|
|
@@ -14,8 +14,9 @@ import { runMigrateTemplate } from './migrate-template.mjs';
|
|
|
14
14
|
import { runMigratePrompts } from './migrate-prompts.mjs';
|
|
15
15
|
import { runFrontmatterFix } from './frontmatter-fix.mjs';
|
|
16
16
|
import { normalizeEol } from './frontmatter.mjs';
|
|
17
|
-
import { toRepoPath } from './util.mjs';
|
|
17
|
+
import { die, relTime, toRepoPath } from './util.mjs';
|
|
18
18
|
import { inspectTransactions, resolveTransactions } from './atomic-mutation.mjs';
|
|
19
|
+
import { availableSessionId, releaseVanishedPlanClaim, surveyOwnershipClaims } from './pickup.mjs';
|
|
19
20
|
|
|
20
21
|
// Tunable thresholds for `dotmd doctor --statuses` conflation detection.
|
|
21
22
|
// MIN_BUCKET_SIZE: only flag buckets with at least this many docs (small buckets aren't worth nagging).
|
|
@@ -105,6 +106,109 @@ function runDoctorTransactions(argv, config, opts = {}) {
|
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
function parseOlderThan(argv) {
|
|
110
|
+
const idx = argv.indexOf('--older-than');
|
|
111
|
+
if (idx === -1) return null;
|
|
112
|
+
const raw = argv[idx + 1];
|
|
113
|
+
const match = /^(\d+)([hd])$/.exec(raw ?? '');
|
|
114
|
+
if (!match) die('--older-than takes a duration like 24h or 3d');
|
|
115
|
+
return Number(match[1]) * (match[2] === 'h' ? 3600_000 : 86_400_000) ;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The counterpart to --transactions: a claim nobody will ever release wedges
|
|
119
|
+
// `set`, `archive`, `baton` and `rename` on that plan forever, and until now
|
|
120
|
+
// nothing in the tool could even show you the claims, let alone end one.
|
|
121
|
+
//
|
|
122
|
+
// Two tiers, because two very different things are being asked. A claim whose
|
|
123
|
+
// session process is provably gone is released by --apply on its own: that is
|
|
124
|
+
// dotmd observing a fact, the same bar a forced hook-delivery takeover uses.
|
|
125
|
+
// A claim dotmd *cannot* judge — written before it recorded the owning process,
|
|
126
|
+
// taken from a plain terminal, or held on another machine — is never released
|
|
127
|
+
// by a plain --apply, because "I can't see the owner" is not evidence the owner
|
|
128
|
+
// left. Releasing those needs --older-than, which is the user supplying the
|
|
129
|
+
// judgement dotmd doesn't have, as a policy rather than a guess.
|
|
130
|
+
async function runDoctorClaims(argv, config, opts = {}) {
|
|
131
|
+
const json = argv.includes('--json');
|
|
132
|
+
const apply = !opts.dryRun;
|
|
133
|
+
const olderThanMs = parseOlderThan(argv);
|
|
134
|
+
const claims = surveyOwnershipClaims(config);
|
|
135
|
+
|
|
136
|
+
const dead = claims.filter(claim => !claim.corrupt && claim.liveness === 'dead');
|
|
137
|
+
const aged = olderThanMs === null ? [] : claims.filter(claim =>
|
|
138
|
+
!claim.corrupt && claim.liveness !== 'dead' && claim.ageMs !== null && claim.ageMs >= olderThanMs);
|
|
139
|
+
const targets = [...dead, ...aged];
|
|
140
|
+
|
|
141
|
+
const released = [];
|
|
142
|
+
// The repair runs under whatever identity the shell has, and a shell with none
|
|
143
|
+
// is a normal place to run it from — this is the command you reach for when the
|
|
144
|
+
// sessions are gone. Mirrors the synthetic id `set --dry-run` already uses.
|
|
145
|
+
const operator = availableSessionId() ?? 'doctor:claims-repair';
|
|
146
|
+
if (apply) {
|
|
147
|
+
for (const claim of targets) {
|
|
148
|
+
try {
|
|
149
|
+
// Two shapes of release, because a vanished plan has no file to write a
|
|
150
|
+
// status into. Deciding by existence here rather than by catching
|
|
151
|
+
// runSet's "File not found" keeps the bypass narrow and explicit.
|
|
152
|
+
if (existsSync(path.resolve(config.repoRoot, claim.plan))) {
|
|
153
|
+
await runSet(['active', claim.plan], config, { force: true, sessionId: operator, note: 'Claim released by `dotmd doctor --claims` — the owning session was gone.' });
|
|
154
|
+
} else {
|
|
155
|
+
releaseVanishedPlanClaim(claim, config);
|
|
156
|
+
claim.vanished = true;
|
|
157
|
+
}
|
|
158
|
+
released.push(claim.plan);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
claim.error = err.message.split('\n')[0];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (json) {
|
|
166
|
+
process.stdout.write(JSON.stringify({ claims, released }, null, 2) + '\n');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (claims.length === 0) {
|
|
170
|
+
process.stdout.write(green('✓') + ' No plans are claimed — nothing can be wedged by ownership.\n');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const releasedSet = new Set(released);
|
|
175
|
+
process.stdout.write(bold(`Plan claims (${claims.length})\n`));
|
|
176
|
+
for (const claim of claims) {
|
|
177
|
+
if (claim.corrupt) {
|
|
178
|
+
process.stdout.write(` ${yellow('?')} ${path.basename(claim.recordPath)} — ${claim.reason}\n`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const mark = releasedSet.has(claim.plan) ? green('✓') : claim.liveness === 'dead' ? yellow('!') : dim('·');
|
|
182
|
+
const age = claim.since ? relTime(claim.since) : 'age unknown';
|
|
183
|
+
const note = releasedSet.has(claim.plan)
|
|
184
|
+
? (claim.vanished ? 'released (plan no longer exists)' : 'released')
|
|
185
|
+
: claim.error ?? `owner ${claim.liveness}`;
|
|
186
|
+
process.stdout.write(` ${mark} ${claim.plan} — ${age}, session ${claim.sessionId}, ${note}\n`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (released.length) {
|
|
190
|
+
// Only the claims with a plan behind them came back as `active`; saying so
|
|
191
|
+
// of a vanished one would promise a file the next command cannot open.
|
|
192
|
+
const vanished = targets.filter(claim => claim.vanished && releasedSet.has(claim.plan)).length;
|
|
193
|
+
const revived = released.length - vanished;
|
|
194
|
+
const parts = [];
|
|
195
|
+
if (revived) parts.push(`${revived} plan${revived === 1 ? ' is' : 's are'} active again`);
|
|
196
|
+
if (vanished) parts.push(`${vanished} pinned a plan that no longer exists`);
|
|
197
|
+
process.stdout.write(green(`\n✓ Released ${released.length} claim${released.length === 1 ? '' : 's'}; ${parts.join(', ')}.\n`));
|
|
198
|
+
}
|
|
199
|
+
const pendingDead = dead.filter(claim => !releasedSet.has(claim.plan));
|
|
200
|
+
if (pendingDead.length) {
|
|
201
|
+
process.stdout.write(yellow(`\n${pendingDead.length} held by a session whose process is gone.\n`));
|
|
202
|
+
process.stdout.write(dim('Run `dotmd doctor --claims --apply` to release them.\n'));
|
|
203
|
+
}
|
|
204
|
+
const unjudgeable = claims.filter(claim =>
|
|
205
|
+
!claim.corrupt && claim.liveness !== 'dead' && !releasedSet.has(claim.plan));
|
|
206
|
+
if (unjudgeable.length && olderThanMs === null) {
|
|
207
|
+
process.stdout.write(dim(`\n${unjudgeable.length} cannot be judged from here — no owning process was recorded, or it is on another machine.\n`));
|
|
208
|
+
process.stdout.write(dim('If you know those sessions are over: `dotmd doctor --claims --apply --older-than 24h`.\n'));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
108
212
|
export function runDoctor(argv, config, opts = {}) {
|
|
109
213
|
if (argv.includes('--project')) {
|
|
110
214
|
runDoctorProject(config, { json: argv.includes('--json') });
|
|
@@ -130,6 +234,9 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
130
234
|
runDoctorTransactions(argv, config, opts);
|
|
131
235
|
return;
|
|
132
236
|
}
|
|
237
|
+
if (argv.includes('--claims')) {
|
|
238
|
+
return runDoctorClaims(argv, config, opts);
|
|
239
|
+
}
|
|
133
240
|
|
|
134
241
|
const { dryRun, testHooks } = opts;
|
|
135
242
|
// 0.37.0 (F4): the mode banner makes it impossible to mistake a preview run
|
package/src/hud.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { currentSessionId, isArchivedPath } from './util.mjs';
|
|
4
|
+
import { currentSessionId, isArchivedPath, relTime } from './util.mjs';
|
|
5
5
|
import { dim, yellow } from './color.mjs';
|
|
6
6
|
import { buildIndex } from './index.mjs';
|
|
7
7
|
import { readJournalEntries, journalFilePath, readMisuseEntries } from './journal.mjs';
|
|
@@ -67,19 +67,6 @@ const REJECTIONS_CAP = 3;
|
|
|
67
67
|
const FLEET_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
68
68
|
const REJECTIONS_WINDOW_MS = 60 * 60 * 1000;
|
|
69
69
|
|
|
70
|
-
function relTime(ts, now = Date.now()) {
|
|
71
|
-
const t = new Date(ts).getTime();
|
|
72
|
-
if (!Number.isFinite(t)) return '?';
|
|
73
|
-
const delta = Math.max(0, now - t);
|
|
74
|
-
const sec = Math.floor(delta / 1000);
|
|
75
|
-
if (sec < 60) return `${sec}s ago`;
|
|
76
|
-
const min = Math.floor(sec / 60);
|
|
77
|
-
if (min < 60) return `${min}m ago`;
|
|
78
|
-
const hr = Math.floor(min / 60);
|
|
79
|
-
if (hr < 24) return `${hr}h ago`;
|
|
80
|
-
return `${Math.floor(hr / 24)}d ago`;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
70
|
// Coarse error-class for rejection grouping. Most dotmd die() messages follow
|
|
84
71
|
// `<class>: <variable detail>` (e.g. "File not found: docs/foo.md", "Already
|
|
85
72
|
// archived: docs/plans/x.md", "Too many arguments to status"). Take the chunk
|
package/src/lifecycle.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
commitPlanClaim,
|
|
25
25
|
finishClaimHookDelivery,
|
|
26
26
|
listOwnedPlans,
|
|
27
|
+
ownershipLiveness,
|
|
27
28
|
pickupFactsForDoc,
|
|
28
29
|
prepareOwnershipRelease,
|
|
29
30
|
readPlanOwnership,
|
|
@@ -607,6 +608,7 @@ export async function startPlan(argv, config, opts = {}) {
|
|
|
607
608
|
let disposition = classifyPlanPickup({
|
|
608
609
|
type: docType,
|
|
609
610
|
status: oldStatus,
|
|
611
|
+
ownerLiveness: ownershipLiveness(ownership),
|
|
610
612
|
validStatuses: config.typeStatuses?.get('plan') ?? config.validStatuses,
|
|
611
613
|
startableStatuses: config.lifecycle.startableStatuses,
|
|
612
614
|
terminalStatuses: config.lifecycle.terminalStatuses,
|
|
@@ -959,7 +961,13 @@ export async function runSet(argv, config, opts = {}) {
|
|
|
959
961
|
const releasing = asString(oldFm?.type) === 'plan'
|
|
960
962
|
&& (asString(oldFm?.status) === 'in-session' || oldOwnership?.state === 'owned' || oldOwnership?.corrupt);
|
|
961
963
|
if (releasing) {
|
|
962
|
-
sessionId
|
|
964
|
+
// opts.sessionId lets a repair caller supply the operator identity. Only
|
|
965
|
+
// `doctor --claims` passes it: releasing a claim stamps the id into the
|
|
966
|
+
// tombstone as provenance and takes nothing, so demanding an *authoritative*
|
|
967
|
+
// one of the repairer is backwards for the command that exists to unwedge
|
|
968
|
+
// sessions — it made the recovery path unusable from any shell without a
|
|
969
|
+
// session of its own (a bare login shell, cron, CI).
|
|
970
|
+
sessionId ??= opts.sessionId ?? authoritativeSessionId();
|
|
963
971
|
assertPlanMutationAuthorized(repoPath, config, { sessionId, force });
|
|
964
972
|
if (!dryRun) ensurePlanCompletionBeforeRelease(repoPath, config, { testHooks: opts.testHooks });
|
|
965
973
|
else if (planHasPendingCompletion(repoPath, config)) process.stderr.write(`${dim('[dry-run]')} Pending claim completion would block this release.\n`);
|
package/src/new.mjs
CHANGED
|
@@ -287,8 +287,15 @@ function isBodyPlaceholder(file) {
|
|
|
287
287
|
return BODY_PLACEHOLDER_NAMES.has(file);
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
+
// `@-` is the natural composition of the two spellings dotmd documents (`@path`
|
|
291
|
+
// and `-`), and agents write it — three times in the platform transcripts, each
|
|
292
|
+
// with a valid heredoc already on stdin that was then thrown away for a file
|
|
293
|
+
// literally named `-`. Nothing else can sensibly be meant by it, so it means
|
|
294
|
+
// stdin, unconditionally. That follows the universal CLI convention rather than
|
|
295
|
+
// the placeholder rule below (where a real file wins): under this convention a
|
|
296
|
+
// file actually named `-` is spelled `./-`, which still takes the file branch.
|
|
290
297
|
export function readBodyInput(source) {
|
|
291
|
-
if (source === '-') {
|
|
298
|
+
if (source === '-' || source === '@-') {
|
|
292
299
|
try { return readFileSync(0, 'utf8'); } catch (err) { die(`Could not read body from stdin: ${err.message}`); }
|
|
293
300
|
}
|
|
294
301
|
if (typeof source === 'string' && source.startsWith('@')) {
|
|
@@ -711,7 +718,9 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
711
718
|
if (bodyFlag !== null) { bodyInput = readBodyInput(bodyFlag); bodyInputSource = bodyFlagName; }
|
|
712
719
|
else if (bodyArg !== null) {
|
|
713
720
|
bodyInput = readBodyInput(bodyArg);
|
|
714
|
-
bodyInputSource = bodyArg === '-'
|
|
721
|
+
bodyInputSource = (bodyArg === '-' || bodyArg === '@-')
|
|
722
|
+
? `stdin (\`${bodyArg}\`)`
|
|
723
|
+
: (bodyArg.startsWith('@') ? `file (\`${bodyArg}\`)` : 'inline body argument');
|
|
715
724
|
} else {
|
|
716
725
|
// Auto-consume piped or redirected stdin so agents don't need the `-`
|
|
717
726
|
// placeholder for the most common pattern (`cat draft.md | dotmd new …`,
|
package/src/pickup.mjs
CHANGED
|
@@ -2,9 +2,10 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { authorizeManagedSource, authorizeRepoGeneratedPath } from './managed-path.mjs';
|
|
5
|
-
import
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { currentProcessOwner, mutateFileSet, processOwnerLiveness, processStartIdentity, replaceSnapshot, snapshotFile, withPathLocks } from './atomic-mutation.mjs';
|
|
6
7
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
7
|
-
import { asString } from './util.mjs';
|
|
8
|
+
import { asString, relTime } from './util.mjs';
|
|
8
9
|
|
|
9
10
|
export const OWNERSHIP_SCHEMA = 2;
|
|
10
11
|
export const HOOK_DELIVERY_LEASE_MS = 30_000;
|
|
@@ -29,6 +30,34 @@ export function availableSessionId(env = process.env) {
|
|
|
29
30
|
try { return authoritativeSessionId(env); } catch { return null; }
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
// The process that OWNS the session, not the one taking the claim. `dotmd` exits
|
|
34
|
+
// within the second, so its own pid is always dead a moment later and can say
|
|
35
|
+
// nothing about whether the session still exists; the agent harness that spawned
|
|
36
|
+
// it is what outlives the command. Recording that process is what lets a claim
|
|
37
|
+
// answer "is its owner still there?" with the liveness check dotmd already has,
|
|
38
|
+
// instead of an age threshold that cannot tell a three-day-dead session from a
|
|
39
|
+
// long-running one. Absent (a plain terminal, an unknown harness) is not an
|
|
40
|
+
// error — it yields null, which reads as 'unverifiable' and never auto-reclaims.
|
|
41
|
+
export function sessionProcessOwner(env = process.env) {
|
|
42
|
+
const raw = (env.DOTMD_SESSION_PID ?? env.CLAUDE_PID)?.trim();
|
|
43
|
+
const pid = Number(raw);
|
|
44
|
+
if (!raw || !Number.isInteger(pid) || pid <= 0) return null;
|
|
45
|
+
return {
|
|
46
|
+
pid,
|
|
47
|
+
hostname: os.hostname(),
|
|
48
|
+
processStartIdentity: processStartIdentity(pid),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Only a claim that names a process we can probe, on this host, can be judged.
|
|
53
|
+
// Everything else — a record written before sessionOwner existed, a claim from a
|
|
54
|
+
// plain terminal, another machine's claim on a shared filesystem — is
|
|
55
|
+
// 'unverifiable' and is treated as live, so a takeover stays an explicit --force.
|
|
56
|
+
export function ownershipLiveness(ownership) {
|
|
57
|
+
if (!ownership || ownership.corrupt || !ownership.sessionOwner) return 'unverifiable';
|
|
58
|
+
return processOwnerLiveness(ownership.sessionOwner);
|
|
59
|
+
}
|
|
60
|
+
|
|
32
61
|
function sameIdentity(left, right, fs = { statSync }) {
|
|
33
62
|
try {
|
|
34
63
|
const a = fs.statSync(left, { bigint: true });
|
|
@@ -61,6 +90,10 @@ export function classifyPlanPickup(facts) {
|
|
|
61
90
|
const {
|
|
62
91
|
type, status, validStatuses, startableStatuses, terminalStatuses,
|
|
63
92
|
archiveStatuses, physicallyArchived, ownership, sessionId, malformed,
|
|
93
|
+
// Defaulted rather than required: a caller with no view of the owning
|
|
94
|
+
// process (and every existing test) gets the conservative answer, which is
|
|
95
|
+
// that the owner might still be there.
|
|
96
|
+
ownerLiveness = 'unverifiable',
|
|
64
97
|
} = facts;
|
|
65
98
|
if (malformed) return { kind: 'malformed', pickupable: false };
|
|
66
99
|
if (type !== 'plan') return { kind: 'wrong-type', pickupable: false };
|
|
@@ -68,11 +101,15 @@ export function classifyPlanPickup(facts) {
|
|
|
68
101
|
if (physicallyArchived) return { kind: 'physical-archive', pickupable: false };
|
|
69
102
|
if (archiveStatuses?.has(status) || terminalStatuses?.has(status)) return { kind: 'terminal', pickupable: false };
|
|
70
103
|
if (ownership?.corrupt) return { kind: 'ownership-corrupt', pickupable: false };
|
|
71
|
-
if (ownership?.state === 'owned' && ownership.sessionId !== sessionId) {
|
|
104
|
+
if (ownership?.state === 'owned' && ownership.sessionId !== sessionId && ownerLiveness !== 'dead') {
|
|
72
105
|
return { kind: 'busy', pickupable: false, owner: ownership.sessionId };
|
|
73
106
|
}
|
|
74
107
|
if (status === 'in-session') {
|
|
75
|
-
|
|
108
|
+
// `resume` means picking my own claim back up. A record left by a session
|
|
109
|
+
// whose process is gone reached here because it is no longer busy, and
|
|
110
|
+
// taking that over is an adopt — the same disposition as a plan sitting
|
|
111
|
+
// `in-session` with no record at all.
|
|
112
|
+
if (ownership?.state === 'owned' && ownership.sessionId === sessionId) return { kind: 'resume', pickupable: true };
|
|
76
113
|
return { kind: 'adopt', pickupable: true };
|
|
77
114
|
}
|
|
78
115
|
if (startableStatuses?.has(status)) return { kind: 'start', pickupable: true };
|
|
@@ -150,6 +187,40 @@ function parseOwnership(raw, recordPath) {
|
|
|
150
187
|
}
|
|
151
188
|
}
|
|
152
189
|
|
|
190
|
+
// The bar for reclaiming another session's plan without being asked to, and it is
|
|
191
|
+
// deliberately the same bar `assertHookDeliveryReclaimable` already sets for a
|
|
192
|
+
// forced hook-delivery takeover: *demonstrably* dead. `processOwnerLiveness`
|
|
193
|
+
// answers 'dead' only for a probe that came back ESRCH on this host, or a pid
|
|
194
|
+
// whose process start-identity no longer matches the one recorded — so a reused
|
|
195
|
+
// pid reads live, another machine's claim reads unverifiable, and a record
|
|
196
|
+
// written before `sessionOwner` existed reads unverifiable. Every one of those
|
|
197
|
+
// keeps the refusal and leaves the takeover to an explicit --force. Only a
|
|
198
|
+
// process we watched go away is treated as gone.
|
|
199
|
+
function abandonedByDeadSession(ownership) {
|
|
200
|
+
return ownershipLiveness(ownership) === 'dead';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const BUSY_TAKEOVER_RECOVERY = 're-run this command with --force to take the plan over.';
|
|
204
|
+
|
|
205
|
+
// A busy refusal almost always fires on the command that would have *cleared*
|
|
206
|
+
// the wedge — `baton`, `set`, `archive` — so the message has to carry the two
|
|
207
|
+
// things that decide what to do next: how long the owner has held the claim, and
|
|
208
|
+
// the verb that takes it back. Nothing expires a session claim, so an owner
|
|
209
|
+
// three days gone looks exactly like one mid-edit; a survey of real sessions
|
|
210
|
+
// found 16 of these against 7 plans, every owner long dead, and not one of them
|
|
211
|
+
// recovered. What agents did instead was re-run the same command with the plan
|
|
212
|
+
// addressed differently (path, then slug) — reading a bare "busy in another
|
|
213
|
+
// session (<uuid>)" as "you named the plan wrong". The age is the judgement
|
|
214
|
+
// input and the recovery line is the way out; neither was there to read.
|
|
215
|
+
function planBusyError(repoPath, ownership, recovery = BUSY_TAKEOVER_RECOVERY) {
|
|
216
|
+
const since = ownership.updatedAt ?? ownership.claimedAt ?? null;
|
|
217
|
+
const age = since ? relTime(since) : null;
|
|
218
|
+
const held = age && age !== '?' ? ` since ${since} (${age})` : '';
|
|
219
|
+
return new Error(`Plan is busy in another session: ${repoPath}\n`
|
|
220
|
+
+ ` held by session ${ownership.sessionId}${held}\n`
|
|
221
|
+
+ ` If that session is gone, ${recovery}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
153
224
|
function validateBinding(record, identity, config) {
|
|
154
225
|
if (record.corrupt) return record;
|
|
155
226
|
const expectedPath = recordPathForIdentity(identity, config);
|
|
@@ -176,14 +247,20 @@ export function prepareOwnershipMigration(oldRepoPath, newPath, config, { sessio
|
|
|
176
247
|
const ownership = readPlanOwnership(oldRepoPath, config);
|
|
177
248
|
if (!ownership) return null;
|
|
178
249
|
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
|
-
|
|
250
|
+
if (ownership.state === 'owned' && ownership.sessionId !== sessionId && !abandonedByDeadSession(ownership)) {
|
|
251
|
+
// `rename` has no --force of its own: a rename carries the claim across to
|
|
252
|
+
// the new path rather than ending it, so the takeover has to happen first.
|
|
253
|
+
throw planBusyError(oldRepoPath, ownership,
|
|
254
|
+
`release it first with \`dotmd set <status> ${oldRepoPath} --force\`, then rename.`);
|
|
181
255
|
}
|
|
182
256
|
const identity = plannedPlanIdentity(newPath, config);
|
|
183
257
|
const recordPath = recordPathForIdentity(identity, config);
|
|
184
258
|
const content = recordContent({
|
|
185
259
|
identity,
|
|
186
260
|
sessionId: ownership.sessionId,
|
|
261
|
+
// A rename moves the claim, it does not re-take it: the same session still
|
|
262
|
+
// owns the plan, so its owning process carries across untouched.
|
|
263
|
+
sessionOwner: ownership.sessionOwner,
|
|
187
264
|
state: ownership.state,
|
|
188
265
|
now,
|
|
189
266
|
claimedAt: ownership.claimedAt,
|
|
@@ -228,7 +305,12 @@ export function listOwnedPlans(config, sessionId = authoritativeSessionId()) {
|
|
|
228
305
|
return Object.assign(found, { diagnostics });
|
|
229
306
|
}
|
|
230
307
|
|
|
231
|
-
|
|
308
|
+
// `sessionOwner` is deliberately additive rather than a schema bump: parseOwnership
|
|
309
|
+
// accepts exactly OWNERSHIP_SCHEMA, so raising it would turn every record already
|
|
310
|
+
// on disk corrupt — which is a worse wedge than the one this fixes. An old record
|
|
311
|
+
// simply has no sessionOwner and stays 'unverifiable' for its whole life; an older
|
|
312
|
+
// dotmd reading a new record ignores a field it does not validate.
|
|
313
|
+
function recordContent({ identity, sessionId, state, now, claimedAt, operation, sessionOwner }) {
|
|
232
314
|
return JSON.stringify({
|
|
233
315
|
schema: OWNERSHIP_SCHEMA,
|
|
234
316
|
state,
|
|
@@ -236,6 +318,7 @@ function recordContent({ identity, sessionId, state, now, claimedAt, operation }
|
|
|
236
318
|
canonicalPath: identity.canonicalPath,
|
|
237
319
|
identityKey: identity.key,
|
|
238
320
|
sessionId,
|
|
321
|
+
sessionOwner: sessionOwner ?? null,
|
|
239
322
|
claimedAt: claimedAt ?? now,
|
|
240
323
|
updatedAt: now,
|
|
241
324
|
operation: operation ?? null,
|
|
@@ -256,7 +339,8 @@ export function preparePlanClaim({ filePath, sourceContent, renderedContent, own
|
|
|
256
339
|
hook: 'pending',
|
|
257
340
|
};
|
|
258
341
|
const content = recordContent({ identity, sessionId, state: 'owned', now,
|
|
259
|
-
|
|
342
|
+
sessionOwner: sessionProcessOwner(), operation,
|
|
343
|
+
claimedAt: ownership && !ownership.corrupt ? ownership.claimedAt : null });
|
|
260
344
|
const updates = [];
|
|
261
345
|
const guards = [];
|
|
262
346
|
if (renderedContent !== null && renderedContent !== sourceContent) {
|
|
@@ -291,8 +375,9 @@ export function prepareOwnershipRelease(repoPath, config, { sessionId = authorit
|
|
|
291
375
|
const ownership = readPlanOwnership(repoPath, config);
|
|
292
376
|
if (!ownership) return null;
|
|
293
377
|
if (ownership.corrupt && !force) throw new Error(`Ownership record is corrupt for ${repoPath}: ${ownership.reason}; use an explicit path with --force to recover.`);
|
|
294
|
-
if (!ownership.corrupt && ownership.state === 'owned' && ownership.sessionId !== sessionId
|
|
295
|
-
|
|
378
|
+
if (!ownership.corrupt && ownership.state === 'owned' && ownership.sessionId !== sessionId
|
|
379
|
+
&& !force && !abandonedByDeadSession(ownership)) {
|
|
380
|
+
throw planBusyError(repoPath, ownership);
|
|
296
381
|
}
|
|
297
382
|
if (!ownership.corrupt && ownership.state === 'released') return null;
|
|
298
383
|
return {
|
|
@@ -308,12 +393,90 @@ export function assertPlanMutationAuthorized(repoPath, config, { sessionId = aut
|
|
|
308
393
|
if (ownership?.corrupt && !force) {
|
|
309
394
|
throw new Error(`Ownership record is corrupt for ${repoPath}: ${ownership.reason}; use an explicit path with --force to recover.`);
|
|
310
395
|
}
|
|
311
|
-
if (ownership?.state === 'owned' && ownership.sessionId !== sessionId
|
|
312
|
-
|
|
396
|
+
if (ownership?.state === 'owned' && ownership.sessionId !== sessionId
|
|
397
|
+
&& !force && !abandonedByDeadSession(ownership)) {
|
|
398
|
+
throw planBusyError(repoPath, ownership);
|
|
313
399
|
}
|
|
314
400
|
return ownership;
|
|
315
401
|
}
|
|
316
402
|
|
|
403
|
+
// Every owned claim in the repo, whoever holds it — the survey `listOwnedPlans`
|
|
404
|
+
// deliberately cannot do, since that one answers "what does THIS session own?".
|
|
405
|
+
// Unlike that function this keeps records whose plan has drifted or vanished:
|
|
406
|
+
// a claim pinning a plan nobody can read is exactly the kind that wedges a repo
|
|
407
|
+
// and so is exactly the kind worth showing.
|
|
408
|
+
export function surveyOwnershipClaims(config, now = Date.now()) {
|
|
409
|
+
const root = ownershipRoot(config);
|
|
410
|
+
if (!existsSync(root)) return [];
|
|
411
|
+
const claims = [];
|
|
412
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
413
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
414
|
+
const recordPath = path.join(root, entry.name);
|
|
415
|
+
let record;
|
|
416
|
+
try { record = parseOwnership(readFileSync(recordPath, 'utf8'), recordPath); }
|
|
417
|
+
catch { record = { corrupt: true, recordPath, reason: 'unreadable ownership record' }; }
|
|
418
|
+
if (record.corrupt) {
|
|
419
|
+
claims.push({ recordPath, plan: null, corrupt: true, reason: record.reason, liveness: 'unverifiable', ageMs: null });
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (record.state !== 'owned') continue;
|
|
423
|
+
const since = record.updatedAt ?? record.claimedAt ?? null;
|
|
424
|
+
const parsed = since ? Date.parse(since) : NaN;
|
|
425
|
+
claims.push({
|
|
426
|
+
recordPath,
|
|
427
|
+
plan: record.plan,
|
|
428
|
+
sessionId: record.sessionId,
|
|
429
|
+
corrupt: false,
|
|
430
|
+
since,
|
|
431
|
+
ageMs: Number.isFinite(parsed) ? Math.max(0, now - parsed) : null,
|
|
432
|
+
liveness: ownershipLiveness(record),
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
claims.sort((a, b) => (b.ageMs ?? 0) - (a.ageMs ?? 0));
|
|
436
|
+
return claims;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// A claim whose plan file no longer exists — deleted, or renamed by something
|
|
440
|
+
// other than `dotmd rename`, which would have carried the record across. The
|
|
441
|
+
// survey deliberately keeps these (a claim pinning a plan nobody can read is
|
|
442
|
+
// exactly the kind worth showing), but the release path could not act on one:
|
|
443
|
+
// it routes through `runSet`, which needs a file to write a status into, so the
|
|
444
|
+
// record survived every `--apply` and sat in the report forever. Worse, the key
|
|
445
|
+
// is a hash of the path, so a plan later created at that same path would read
|
|
446
|
+
// as owned by a session years gone — born wedged.
|
|
447
|
+
//
|
|
448
|
+
// So this releases from the record itself. No `canonicalPlanIdentity` (it
|
|
449
|
+
// realpaths, which is the thing that cannot work here); the record already
|
|
450
|
+
// carries the identity it was written with. Existence is re-checked under the
|
|
451
|
+
// lock, because "the plan is gone" is the entire justification for bypassing
|
|
452
|
+
// the status write, and a `dotmd new` racing us would invalidate it.
|
|
453
|
+
export function releaseVanishedPlanClaim(claim, config, { now = new Date().toISOString() } = {}) {
|
|
454
|
+
const recordPath = claim.recordPath;
|
|
455
|
+
return withPathLocks([recordPath], { repoRoot: config.repoRoot }, () => {
|
|
456
|
+
const snapshot = snapshotFile(recordPath);
|
|
457
|
+
const ownership = parseOwnership(snapshot.content, recordPath);
|
|
458
|
+
if (ownership.corrupt || ownership.state !== 'owned') {
|
|
459
|
+
throw new Error(`Claim changed under us for ${claim.plan}; re-run to see the current state.`);
|
|
460
|
+
}
|
|
461
|
+
if (existsSync(path.resolve(config.repoRoot, ownership.plan))) {
|
|
462
|
+
throw new Error(`${ownership.plan} exists after all — release it with \`dotmd set active\` instead.`);
|
|
463
|
+
}
|
|
464
|
+
replaceSnapshot(snapshot, JSON.stringify({
|
|
465
|
+
schema: OWNERSHIP_SCHEMA,
|
|
466
|
+
state: 'released',
|
|
467
|
+
plan: ownership.plan,
|
|
468
|
+
canonicalPath: ownership.canonicalPath,
|
|
469
|
+
identityKey: ownership.identityKey,
|
|
470
|
+
sessionId: ownership.sessionId,
|
|
471
|
+
sessionOwner: ownership.sessionOwner ?? null,
|
|
472
|
+
claimedAt: ownership.claimedAt ?? now,
|
|
473
|
+
updatedAt: now,
|
|
474
|
+
operation: null,
|
|
475
|
+
}, null, 2) + '\n', { repoRoot: config.repoRoot, locked: true });
|
|
476
|
+
return ownership.plan;
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
317
480
|
export function updateOwnershipOperation(repoPath, config, expected, mutate) {
|
|
318
481
|
const ownership = readPlanOwnership(repoPath, config);
|
|
319
482
|
if (!ownership || ownership.corrupt || ownership.state !== 'owned' || !ownership.operation) {
|
|
@@ -455,6 +618,7 @@ export function pickupFactsForDoc(doc, config, { sessionId = availableSessionId(
|
|
|
455
618
|
type: doc?.type ?? null,
|
|
456
619
|
status: doc?.status ?? null,
|
|
457
620
|
validStatuses: config.typeStatuses?.get('plan') ?? config.validStatuses,
|
|
621
|
+
ownerLiveness: ownershipLiveness(ownership),
|
|
458
622
|
startableStatuses: config.lifecycle.startableStatuses,
|
|
459
623
|
terminalStatuses: config.lifecycle.terminalStatuses,
|
|
460
624
|
archiveStatuses: config.lifecycle.archiveStatuses,
|
package/src/prompts.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { authorizeManagedSource } from './managed-path.mjs';
|
|
|
11
11
|
import {
|
|
12
12
|
authoritativeSessionId,
|
|
13
13
|
classifyPlanPickup,
|
|
14
|
+
ownershipLiveness,
|
|
14
15
|
preparePlanClaim,
|
|
15
16
|
readPlanOwnership,
|
|
16
17
|
} from './pickup.mjs';
|
|
@@ -380,6 +381,7 @@ function prepareLinkedPromptClaim(planRef, config, promptDir) {
|
|
|
380
381
|
const disposition = classifyPlanPickup({
|
|
381
382
|
type: asString(parsed.type),
|
|
382
383
|
status: oldStatus,
|
|
384
|
+
ownerLiveness: ownershipLiveness(ownership),
|
|
383
385
|
validStatuses: config.typeStatuses?.get('plan') ?? config.validStatuses,
|
|
384
386
|
startableStatuses: config.lifecycle.startableStatuses,
|
|
385
387
|
terminalStatuses: config.lifecycle.terminalStatuses,
|
package/src/util.mjs
CHANGED
|
@@ -92,6 +92,23 @@ export function nowIso() {
|
|
|
92
92
|
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// Coarse "how long ago", for messages where the exact interval matters less than
|
|
96
|
+
// which order of magnitude it is — a claim held 3d is a dead session, one held
|
|
97
|
+
// 4m is a colleague mid-edit. Lives here rather than in either caller because
|
|
98
|
+
// `hud` and `pickup` both need it and util is a leaf both already import.
|
|
99
|
+
export function relTime(ts, now = Date.now()) {
|
|
100
|
+
const t = new Date(ts).getTime();
|
|
101
|
+
if (!Number.isFinite(t)) return '?';
|
|
102
|
+
const delta = Math.max(0, now - t);
|
|
103
|
+
const sec = Math.floor(delta / 1000);
|
|
104
|
+
if (sec < 60) return `${sec}s ago`;
|
|
105
|
+
const min = Math.floor(sec / 60);
|
|
106
|
+
if (min < 60) return `${min}m ago`;
|
|
107
|
+
const hr = Math.floor(min / 60);
|
|
108
|
+
if (hr < 24) return `${hr}h ago`;
|
|
109
|
+
return `${Math.floor(hr / 24)}d ago`;
|
|
110
|
+
}
|
|
111
|
+
|
|
95
112
|
export function warn(message) {
|
|
96
113
|
process.stderr.write(`${dim(message)}\n`);
|
|
97
114
|
}
|