dotmd-cli 0.17.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -146,7 +146,10 @@ dotmd plans List all plans
146
146
  dotmd stale List stale docs
147
147
  dotmd actionable List docs with next steps
148
148
  dotmd index [--write] Generate/update docs.md index block
149
- dotmd pickup <file> Pick up a plan (in-session + print)
149
+ dotmd hud Three-line actionable triage (silent when clean ideal SessionStart hook)
150
+ dotmd pickup <file> Pick up a plan (in-session + print body or queued handoff)
151
+ dotmd release [<file>] Release in-session lease (alias: unpickup)
152
+ dotmd handoff <file> [...] Queue a resume-prompt sidecar + release
150
153
  dotmd finish <file> Finish a plan (done or active)
151
154
  dotmd status <file> <status> Transition document status
152
155
  dotmd archive <file> Archive (status + move + update refs)
@@ -397,12 +400,36 @@ dotmd bulk archive docs/old-*.md -n # preview
397
400
  ### Pickup & Finish
398
401
 
399
402
  ```bash
400
- dotmd pickup docs/plans/my-plan.md # set in-session + print content
403
+ dotmd pickup docs/plans/my-plan.md # set in-session + print body (or queued handoff)
401
404
  dotmd finish docs/plans/my-plan.md # set done + bump date
402
405
  dotmd finish docs/plans/my-plan.md active # back to active for more work
403
406
  ```
404
407
 
405
- ### Session leases & unpickup
408
+ ### Handoff (resume-prompts attached to plans)
409
+
410
+ When you're stopping mid-work and the next session will need to pick up where
411
+ you left off, write a handoff sidecar instead of printing a resume prompt to
412
+ chat for copy-paste:
413
+
414
+ ```bash
415
+ dotmd handoff docs/plans/foo.md "continue from validate(); next: write tests"
416
+ dotmd handoff docs/plans/foo.md - <<'EOF' # heredoc-friendly for Claude
417
+ …multi-line resume prompt…
418
+ EOF
419
+ dotmd handoff docs/plans/foo.md @/tmp/handoff.md # from file
420
+ ```
421
+
422
+ The handoff is written to `<repoRoot>/.dotmd/handoffs/<plan-path>` as a
423
+ timestamped section (append mode by default; `--replace` to overwrite). The
424
+ lease is released and the plan flips back to its prior status. The next
425
+ `dotmd pickup` of that plan prints the handoff *instead of* the plan body
426
+ and atomically unlinks the sidecar — single-claim, can't be consumed twice.
427
+
428
+ The included `/handoff` slash command (scaffolded under
429
+ `.claude/commands/handoff.md` by `dotmd init` / `dotmd doctor`) instructs
430
+ Claude to synthesize and queue handoffs for every plan the session holds.
431
+
432
+ ### Session leases & release
406
433
 
407
434
  `dotmd pickup` records a lease at `<repoRoot>/.dotmd/in-session.json` that
408
435
  identifies which Claude session owns the plan. The lease enables three
@@ -416,15 +443,15 @@ distinct outcomes when a plan is already `in-session`:
416
443
  - **Stale lease.** If the holder's pid is dead (or the lease is >24h old),
417
444
  pickup refuses but suggests `--takeover`.
418
445
 
419
- Releasing leases:
446
+ Releasing leases (both names work; `release` is the recommended verb):
420
447
 
421
448
  ```bash
422
- dotmd unpickup # release every lease owned by current session
423
- dotmd unpickup docs/plans/foo.md # release that one (refuses cross-session)
424
- dotmd unpickup --to planned # override target status (default: lease.oldStatus)
425
- dotmd unpickup --stale # release leases with dead pid or >24h old
426
- dotmd unpickup --all # release every lease (administrative)
427
- dotmd unpickup --json # { released: [...], skipped: [...] }
449
+ dotmd release # release every lease owned by current session
450
+ dotmd release docs/plans/foo.md # release that one (refuses cross-session)
451
+ dotmd release --to planned # override target status (default: lease.oldStatus)
452
+ dotmd release --stale # release leases with dead pid or >24h old
453
+ dotmd release --all # release every lease (administrative)
454
+ dotmd release --json # { released: [...], skipped: [...] }
428
455
  ```
429
456
 
430
457
  `finish`, `archive`, and `rename` auto-release / migrate the lease, so the
@@ -440,16 +467,23 @@ common closeout paths are covered without ceremony.
440
467
  The session id survives `/clear` and auto-compaction, so a re-attach after
441
468
  either is silent.
442
469
 
443
- **Auto-release on Claude Code session end** — add this to
444
- `~/.claude/settings.json` (or your project's `.claude/settings.json`):
470
+ **Recommended Claude Code hooks** — add both to `~/.claude/settings.json`
471
+ (or your project's `.claude/settings.json`):
445
472
 
446
473
  ```json
447
474
  {
448
475
  "hooks": {
476
+ "SessionStart": [
477
+ {
478
+ "hooks": [
479
+ { "type": "command", "command": "dotmd hud", "timeout": 5 }
480
+ ]
481
+ }
482
+ ],
449
483
  "SessionEnd": [
450
484
  {
451
485
  "hooks": [
452
- { "type": "command", "command": "dotmd unpickup", "timeout": 10 }
486
+ { "type": "command", "command": "dotmd release", "timeout": 10 }
453
487
  ]
454
488
  }
455
489
  ]
@@ -457,16 +491,22 @@ either is silent.
457
491
  }
458
492
  ```
459
493
 
460
- When the Claude Code session ends, the hook runs `dotmd unpickup` with
461
- `$CLAUDE_CODE_SESSION_ID` in the environment, releasing every lease for
462
- that session and flipping plans back to their prior status.
494
+ - **SessionStart** runs `dotmd hud`, which prints up to three actionable
495
+ lines (held leases, queued handoffs, stale leases) and stays silent when
496
+ nothing is queued. Use this instead of `dotmd briefing` for the hook role
497
+ — `briefing` dumps per-plan next_step prose that can run to many kilobytes
498
+ on large repos. `hud` is the zero-pollution surface.
499
+ - **SessionEnd** runs `dotmd release` (the new name for `dotmd unpickup`;
500
+ both still work), which releases every lease owned by the ending session
501
+ and flips plans back to their prior status.
463
502
 
464
- > The double-`hooks` nesting is correct: `hooks.SessionEnd[*].hooks[*]`
465
- > is the schema Claude Code requires. `Bash(dotmd:*)` should be in your
466
- > `permissions.allow` list as well, otherwise the hook will be blocked.
503
+ > The double-`hooks` nesting is correct: `hooks.<Event>[*].hooks[*]` is the
504
+ > schema Claude Code requires. `Bash(dotmd:*)` should be in your
505
+ > `permissions.allow` list as well, otherwise the hooks will be blocked.
467
506
 
468
- `dotmd briefing` surfaces a `Stuck in-session: N` line when stale leases
469
- exist, with a `dotmd unpickup --stale` suggestion.
507
+ `dotmd hud` (and `dotmd briefing` for the verbose case) surface a
508
+ `⚠ N stuck leases` line when stale leases exist, with a
509
+ `dotmd release --stale` suggestion.
470
510
 
471
511
  ### Touch
472
512
 
package/bin/dotmd.mjs CHANGED
@@ -14,8 +14,9 @@ const HELP = {
14
14
  _main: `dotmd v${pkg.version} — frontmatter markdown document manager
15
15
 
16
16
  View & Query:
17
+ hud [--json] Three-line actionable triage (held / handoffs / stuck) — silent when clean
17
18
  list [--verbose] [--json] List docs grouped by status (default command)
18
- briefing [--json] Compact summary for session start
19
+ briefing [--json] Full briefing with plan status counts + next steps
19
20
  context [--summarize] [--json] Full briefing (LLM-oriented)
20
21
  focus [status] [--json] Detailed view for one status group
21
22
  query [filters] [--json] Filtered search (--status, --keyword, --stale, etc.)
@@ -41,8 +42,9 @@ Validate & Fix:
41
42
  fix-refs [--dry-run] Auto-fix broken reference paths + body links
42
43
 
43
44
  Lifecycle:
44
- pickup <file> [--takeover] Pick up a plan (set in-session + print)
45
- unpickup [<file>] [--to <s>] Release in-session lease (default: all owned by current session)
45
+ pickup <file> [--takeover] Pick up a plan (set in-session + print body or queued handoff)
46
+ release [<file>] [--to <s>] Release in-session lease (alias: unpickup)
47
+ handoff <file> [text|-|@path] Queue a resume-prompt sidecar + release (append-mode)
46
48
  finish <file> [done|active] Finish a plan (set done or active)
47
49
  status <file> <status> Transition document status
48
50
  archive <file> Archive (status + move + update refs)
@@ -116,9 +118,14 @@ Filters:
116
118
 
117
119
  pickup: `dotmd pickup <file> — pick up a plan and start working
118
120
 
119
- Sets the plan to in-session and prints its content. Writes a session
120
- lease to <repoRoot>/.dotmd/in-session.json so the same Claude session
121
- can re-attach silently after compaction or /clear.
121
+ Sets the plan to in-session and prints its content (prefixed with a
122
+ "[dotmd] holding <path>" line so the fresh session knows what it holds).
123
+ Writes a session lease to <repoRoot>/.dotmd/in-session.json so the same
124
+ Claude session can re-attach silently after compaction or /clear.
125
+
126
+ If a handoff sidecar is queued for the plan (\`.dotmd/handoffs/<path>\`),
127
+ pickup prints the handoff *instead of* the plan body and unlinks the
128
+ sidecar atomically — single-claim, can't be consumed twice.
122
129
 
123
130
  If a plan is already in-session:
124
131
  - Same session → silent re-attach (prints body, no error).
@@ -130,7 +137,8 @@ Options:
130
137
  --json Output as JSON
131
138
  --dry-run, -n Preview without writing
132
139
 
133
- If no file is given, prompts with a list of active plans.`,
140
+ If no file is given, prompts with a list of active/planned plans plus
141
+ any plan with a queued handoff (sorted to the top).`,
134
142
 
135
143
  unpickup: `dotmd unpickup [<file>] — release a plan from in-session
136
144
 
@@ -154,6 +162,44 @@ Options:
154
162
  Manual-edit fallback: if the plan's status is in-session but no lease
155
163
  exists, --to <status> flips it anyway with a warning.`,
156
164
 
165
+ release: `dotmd release [<file>] [--to <s>] — alias of dotmd unpickup
166
+
167
+ Release the in-session lease(s) and flip frontmatter back to the prior
168
+ status. With no file, releases every lease owned by the current session.
169
+ Identical behavior to \`dotmd unpickup\`; both names route to the same
170
+ implementation. See \`dotmd unpickup --help\` for full option list.`,
171
+
172
+ handoff: `dotmd handoff <file> [text | - | @path] — queue resume prompt + release
173
+
174
+ Writes a handoff sidecar attached to the plan, then releases the lease
175
+ and flips frontmatter back to the prior status. The next \`dotmd pickup\`
176
+ of the plan prints the handoff (instead of the plan body) and atomically
177
+ unlinks the sidecar — single-claim guaranteed.
178
+
179
+ Sidecars live at <repoRoot>/.dotmd/handoffs/<plan-path> and accumulate
180
+ timestamped sections on repeat writes. To replace the chain instead of
181
+ appending, pass --replace.
182
+
183
+ Sources for handoff text (pick one):
184
+ <text> Inline argument (best for short notes)
185
+ --message "<text>" Explicit --message flag (equivalent to inline)
186
+ - Read from stdin (heredoc-friendly for Claude)
187
+ @path Read from a file
188
+
189
+ Examples:
190
+ dotmd handoff plans/foo.md "continue from validate(); next: write tests"
191
+ dotmd handoff plans/foo.md - <<'EOF'
192
+ …multi-line handoff…
193
+ EOF
194
+ dotmd handoff plans/foo.md @/tmp/handoff.md
195
+
196
+ Options:
197
+ --replace Overwrite the sidecar chain instead of appending
198
+ --json Output as JSON
199
+ --dry-run, -n Preview without writing
200
+
201
+ Refuses if the plan is not currently held by this session.`,
202
+
157
203
  finish: `dotmd finish <file> [done|active] — finish working on a plan
158
204
 
159
205
  Sets the plan status to done (default) or back to active.
@@ -213,6 +259,25 @@ Shows detailed info for all docs matching the given status (default: active).
213
259
  Options:
214
260
  --json Output as JSON`,
215
261
 
262
+ hud: `dotmd hud — three-line actionable triage
263
+
264
+ Prints up to three lines, in order:
265
+ ▶ You hold N plans: <slugs> (leases owned by current session)
266
+ ▶ N handoffs queued: <slugs> (resume-prompt sidecars waiting)
267
+ ⚠ N stuck leases >24h (suggest \`dotmd release --stale\`)
268
+
269
+ Silent when all three are empty — designed for SessionStart hooks where
270
+ zero noise is the right default. Distinct from \`dotmd briefing\`, which
271
+ dumps the full plan-status pipeline and per-plan next_step bodies (kilobytes
272
+ on large repos). Use hud for ergonomic session boot; use briefing for
273
+ explicit "give me the full picture."
274
+
275
+ Recommended SessionStart hook (in ~/.claude/settings.json):
276
+ "SessionStart": [{ "hooks": [{ "type": "command", "command": "dotmd hud", "timeout": 5 }] }]
277
+
278
+ Options:
279
+ --json Output as JSON ({ owned, queued, stale })`,
280
+
216
281
  briefing: `dotmd briefing — compact summary for session start
217
282
 
218
283
  Shows plan statuses with next steps, doc/research counts, and health
@@ -635,8 +700,10 @@ async function main() {
635
700
  if (command === 'notion') { const { runNotion } = await import('../src/notion.mjs'); await runNotion(restArgs, config, { dryRun }); return; }
636
701
 
637
702
  // Lifecycle commands
703
+ if (command === 'hud') { const { runHud } = await import('../src/hud.mjs'); runHud(restArgs, config); return; }
638
704
  if (command === 'pickup') { const { runPickup } = await import('../src/lifecycle.mjs'); await runPickup(restArgs, config, { dryRun }); return; }
639
- if (command === 'unpickup') { const { runUnpickup } = await import('../src/lifecycle.mjs'); await runUnpickup(restArgs, config, { dryRun }); return; }
705
+ if (command === 'unpickup' || command === 'release') { const { runUnpickup } = await import('../src/lifecycle.mjs'); await runUnpickup(restArgs, config, { dryRun }); return; }
706
+ if (command === 'handoff') { const { runHandoff } = await import('../src/lifecycle.mjs'); await runHandoff(restArgs, config, { dryRun }); return; }
640
707
  if (command === 'finish') { const { runFinish } = await import('../src/lifecycle.mjs'); await runFinish(restArgs, config, { dryRun }); return; }
641
708
  if (command === 'status') { const { runStatus } = await import('../src/lifecycle.mjs'); await runStatus(restArgs, config, { dryRun }); return; }
642
709
  if (command === 'archive') { const { runArchive } = await import('../src/lifecycle.mjs'); runArchive(restArgs, config, { dryRun }); return; }
@@ -895,8 +962,8 @@ async function main() {
895
962
 
896
963
  // Unknown command — suggest closest match
897
964
  const allCommands = [
898
- 'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context',
899
- 'focus', 'query', 'plans', 'stale', 'actionable', 'index', 'pickup', 'finish', 'status', 'archive', 'bulk', 'touch', 'doctor',
965
+ 'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'hud',
966
+ 'focus', 'query', 'plans', 'stale', 'actionable', 'index', 'pickup', 'release', 'handoff', 'finish', 'status', 'archive', 'bulk', 'touch', 'doctor',
900
967
  'unblocks', 'health', 'glossary',
901
968
  'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
902
969
  'watch', 'diff', 'new', 'init', 'completions', 'statuses',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.17.1",
3
+ "version": "0.19.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,6 +16,9 @@ function generatePlansCommand(config) {
16
16
  lines.push('');
17
17
  lines.push('Plan-specific commands:');
18
18
  lines.push('- `dotmd context` — briefing with active/paused/ready plans, age tags, next steps');
19
+ lines.push('- `dotmd pickup <file>` — pick up a plan (set in-session + print body or queued handoff)');
20
+ lines.push('- `dotmd release` — release current session\'s leases (alias: unpickup)');
21
+ lines.push('- `dotmd handoff <file> -` — queue a resume-prompt sidecar + release (see /handoff)');
19
22
  lines.push('- `dotmd health` — plan velocity, aging, checklist progress, pipeline view');
20
23
  lines.push('- `dotmd unblocks <file>` — what depends on / is blocked by a plan');
21
24
  lines.push('- `dotmd next` — ready plans with next steps (what to promote)');
@@ -39,6 +42,44 @@ function generatePlansCommand(config) {
39
42
  return lines.join('\n');
40
43
  }
41
44
 
45
+ function generateHandoffCommand(config) {
46
+ const lines = [VERSION_MARKER, ''];
47
+ lines.push('Queue a resume-prompt sidecar for every plan this session is currently holding. Use this whenever the user asks for a "resume prompt" / "handoff" / "context dump", or when you are about to stop mid-work and the next session will need to pick up where you left off. NEVER print the resume prompt into chat for copy-paste — write it as a handoff sidecar so the next `dotmd pickup` can consume it cleanly.');
48
+ lines.push('');
49
+ lines.push('Steps:');
50
+ lines.push('');
51
+ lines.push('1. Identify which plans this session is holding by running:');
52
+ lines.push(' ```');
53
+ lines.push(' dotmd plans --status in-session --json');
54
+ lines.push(' ```');
55
+ lines.push(' Filter to leases owned by the current session (compare `lease.session` against `$CLAUDE_CODE_SESSION_ID` if needed). If the user passed a specific plan path as an argument, use only that one.');
56
+ lines.push('');
57
+ lines.push('2. For each held plan, synthesize a handoff prompt covering:');
58
+ lines.push(' - One-line summary of where the session got to');
59
+ lines.push(' - What is already done (and verified working)');
60
+ lines.push(' - What is in flight / partial / needs verification');
61
+ lines.push(' - Concrete next step the resuming session should take');
62
+ lines.push(' - Key files touched (with paths) and any non-obvious gotchas');
63
+ lines.push(' - Open questions or decisions deferred');
64
+ lines.push('');
65
+ lines.push('3. Write each handoff via Bash heredoc:');
66
+ lines.push(' ```bash');
67
+ lines.push(' dotmd handoff <plan-path> - <<\'EOF\'');
68
+ lines.push(' <synthesized handoff body>');
69
+ lines.push(' EOF');
70
+ lines.push(' ```');
71
+ lines.push(' Append-mode is the default. The sidecar accumulates timestamped sections, so a session that writes multiple handoffs over time builds up a chronicle the next session reads in order.');
72
+ lines.push('');
73
+ lines.push('4. Report back which plans got handoffs queued and where their sidecars live (the `▶ Handoff queued: <path>` output line). The user closes their window; the next session runs `claude "$(dotmd pickup <plan>)"` and lands seeded with the handoff content.');
74
+ lines.push('');
75
+ lines.push('Rules:');
76
+ lines.push('- If a plan is not held by this session, do not write a handoff for it (dotmd will refuse anyway).');
77
+ lines.push('- If `dotmd handoff` errors with a recoverable message (existing sidecar conflict, etc.), read the path it cites, merge mentally, then re-run with `--replace`.');
78
+ lines.push('- Never print the resume text into the conversation as a copy-paste block.');
79
+ lines.push('');
80
+ return lines.join('\n');
81
+ }
82
+
42
83
  function generateDocsCommand(config) {
43
84
  const roots = Array.isArray(config.raw?.root) ? config.raw.root : [config.raw?.root ?? 'docs'];
44
85
  const rootCount = roots.length;
@@ -104,6 +145,7 @@ export function scaffoldClaudeCommands(cwd, config) {
104
145
  const files = [
105
146
  { name: 'plans.md', generate: () => generatePlansCommand(config) },
106
147
  { name: 'docs.md', generate: () => generateDocsCommand(config) },
148
+ { name: 'handoff.md', generate: () => generateHandoffCommand(config) },
107
149
  ];
108
150
 
109
151
  for (const { name, generate } of files) {
@@ -136,7 +178,7 @@ export function checkClaudeCommands(cwd) {
136
178
  if (!existsSync(commandsDir)) return [];
137
179
 
138
180
  const warnings = [];
139
- for (const name of ['plans.md', 'docs.md']) {
181
+ for (const name of ['plans.md', 'docs.md', 'handoff.md']) {
140
182
  const filePath = path.join(commandsDir, name);
141
183
  const installedVersion = getInstalledVersion(filePath);
142
184
  if (installedVersion && installedVersion !== pkg.version) {
@@ -0,0 +1,59 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const HANDOFF_DIR = path.join('.dotmd', 'handoffs');
5
+
6
+ export function handoffPath(config, repoPath) {
7
+ return path.join(config.repoRoot, HANDOFF_DIR, repoPath);
8
+ }
9
+
10
+ export function hasHandoff(config, repoPath) {
11
+ return existsSync(handoffPath(config, repoPath));
12
+ }
13
+
14
+ export function readHandoff(config, repoPath) {
15
+ const file = handoffPath(config, repoPath);
16
+ if (!existsSync(file)) return null;
17
+ return readFileSync(file, 'utf8');
18
+ }
19
+
20
+ export function appendHandoff(config, repoPath, text, opts = {}) {
21
+ const file = handoffPath(config, repoPath);
22
+ mkdirSync(path.dirname(file), { recursive: true });
23
+ const stamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
24
+ const block = `## ${stamp}\n\n${text.trimEnd()}\n`;
25
+ if (opts.replace || !existsSync(file)) {
26
+ writeFileSync(file, block + '\n', 'utf8');
27
+ } else {
28
+ const prior = readFileSync(file, 'utf8').trimEnd();
29
+ writeFileSync(file, `${prior}\n\n${block}\n`, 'utf8');
30
+ }
31
+ return file;
32
+ }
33
+
34
+ export function consumeHandoff(config, repoPath) {
35
+ const file = handoffPath(config, repoPath);
36
+ if (!existsSync(file)) return null;
37
+ const body = readFileSync(file, 'utf8');
38
+ unlinkSync(file);
39
+ return body;
40
+ }
41
+
42
+ export function listQueuedHandoffs(config) {
43
+ const root = path.join(config.repoRoot, HANDOFF_DIR);
44
+ if (!existsSync(root)) return [];
45
+ const out = [];
46
+ function walk(dir) {
47
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
48
+ const full = path.join(dir, entry.name);
49
+ if (entry.isDirectory()) { walk(full); continue; }
50
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
51
+ const repoPath = path.relative(root, full).split(path.sep).join('/');
52
+ const st = statSync(full);
53
+ out.push({ repoPath, path: full, mtimeMs: st.mtimeMs });
54
+ }
55
+ }
56
+ walk(root);
57
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
58
+ return out;
59
+ }
package/src/hud.mjs ADDED
@@ -0,0 +1,48 @@
1
+ import path from 'node:path';
2
+ import { readLeases, findStaleLeases, currentSessionId } from './lease.mjs';
3
+ import { listQueuedHandoffs } from './handoff.mjs';
4
+ import { green, yellow, dim } from './color.mjs';
5
+
6
+ const MAX_PREVIEW = 5;
7
+
8
+ function slug(repoPath) { return path.basename(repoPath, '.md'); }
9
+
10
+ function previewList(items, max = MAX_PREVIEW) {
11
+ const slugs = items.slice(0, max).map(slug);
12
+ const more = items.length > max ? `, +${items.length - max} more` : '';
13
+ return slugs.join(', ') + more;
14
+ }
15
+
16
+ export function buildHud(config) {
17
+ const session = currentSessionId();
18
+ const leases = readLeases(config);
19
+ const owned = Object.values(leases).filter(l => l.session === session).map(l => l.path);
20
+ const queued = listQueuedHandoffs(config).map(h => h.repoPath);
21
+ const stale = findStaleLeases(config).map(l => l.path);
22
+
23
+ return { owned, queued, stale };
24
+ }
25
+
26
+ export function runHud(argv, config) {
27
+ const json = argv.includes('--json');
28
+ const hud = buildHud(config);
29
+
30
+ if (json) {
31
+ process.stdout.write(JSON.stringify(hud, null, 2) + '\n');
32
+ return;
33
+ }
34
+
35
+ const lines = [];
36
+ if (hud.owned.length > 0) {
37
+ lines.push(green(`▶ You hold ${hud.owned.length} plan${hud.owned.length === 1 ? '' : 's'}: ${previewList(hud.owned)}`));
38
+ }
39
+ if (hud.queued.length > 0) {
40
+ lines.push(green(`▶ ${hud.queued.length} handoff${hud.queued.length === 1 ? '' : 's'} queued: ${previewList(hud.queued)} ${dim('(resume: dotmd pickup)')}`));
41
+ }
42
+ if (hud.stale.length > 0) {
43
+ lines.push(yellow(`⚠ ${hud.stale.length} stuck lease${hud.stale.length === 1 ? '' : 's'} >24h ${dim('(run: dotmd release --stale)')}`));
44
+ }
45
+
46
+ if (lines.length === 0) return; // silent when clean
47
+ process.stdout.write(lines.join('\n') + '\n');
48
+ }
package/src/lifecycle.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex } from './util.mjs';
4
+ import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex, nowIso } from './util.mjs';
5
5
  import { gitMv, getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
6
6
  import { buildIndex, collectDocFiles } from './index.mjs';
7
7
  import { renderIndexFile, writeIndex } from './index-file.mjs';
@@ -16,6 +16,7 @@ import {
16
16
  currentSessionId,
17
17
  migrateLease,
18
18
  } from './lease.mjs';
19
+ import { hasHandoff, consumeHandoff, appendHandoff, handoffPath, listQueuedHandoffs } from './handoff.mjs';
19
20
 
20
21
  function findFileRoot(filePath, config) {
21
22
  const roots = config.docsRoots || [config.docsRoot];
@@ -70,7 +71,7 @@ export async function runStatus(argv, config, opts = {}) {
70
71
  return;
71
72
  }
72
73
 
73
- const today = new Date().toISOString().slice(0, 10);
74
+ const today = nowIso();
74
75
  const archiveDir = path.join(fileRoot, config.archiveDir);
75
76
  const relFromRoot = path.relative(fileRoot, filePath);
76
77
  const inArchive = relFromRoot.startsWith(config.archiveDir + '/') || relFromRoot.startsWith(config.archiveDir + path.sep);
@@ -136,17 +137,22 @@ export async function runPickup(argv, config, opts = {}) {
136
137
  const takeover = argv.includes('--takeover');
137
138
  let input = argv.find(a => !a.startsWith('-'));
138
139
 
139
- // Interactive: pick from active plans
140
+ // Interactive: pick from active/planned plans + anything with a queued handoff
140
141
  if (!input) {
141
142
  if (!isInteractive()) die('Usage: dotmd pickup <file>');
142
143
  const index = buildIndex(config);
143
- const active = index.docs.filter(d => d.type === 'plan' && (d.status === 'active' || d.status === 'planned'));
144
- if (active.length === 0) die('No active or planned plans to pick up.');
145
- const choice = await promptChoice('Pick a plan:', active.map(d => `${d.title} (${d.status}) ${d.path}`));
144
+ const queued = new Set(listQueuedHandoffs(config).map(h => h.repoPath));
145
+ const candidates = index.docs.filter(d =>
146
+ d.type === 'plan' && (d.status === 'active' || d.status === 'planned' || queued.has(d.path))
147
+ );
148
+ if (candidates.length === 0) die('No active/planned plans and no queued handoffs.');
149
+ candidates.sort((a, b) => Number(queued.has(b.path)) - Number(queued.has(a.path)));
150
+ const labelFor = (d) => `${queued.has(d.path) ? '▶ handoff queued · ' : ''}${d.title} (${d.status}) — ${d.path}`;
151
+ const choice = await promptChoice('Pick a plan:', candidates.map(labelFor));
146
152
  if (!choice) die('No plan selected.');
147
- const idx = active.findIndex((_, i) => choice === `${active[i].title} (${active[i].status}) — ${active[i].path}`);
153
+ const idx = candidates.findIndex(d => choice === labelFor(d));
148
154
  if (idx === -1) die('No plan selected.');
149
- input = active[idx].path;
155
+ input = candidates[idx].path;
150
156
  }
151
157
 
152
158
  const filePath = resolveDocPath(input, config);
@@ -175,10 +181,11 @@ export async function runPickup(argv, config, opts = {}) {
175
181
  }
176
182
  }
177
183
 
184
+ const handoffQueued = hasHandoff(config, repoPath);
178
185
  const pickupable = new Set(['active', 'planned', 'in-session']);
179
- if (oldStatus && !pickupable.has(oldStatus)) die(`Cannot pick up a plan with status '${oldStatus}'. Must be active or planned.\n ${repoPath}`);
186
+ if (oldStatus && !pickupable.has(oldStatus) && !handoffQueued) die(`Cannot pick up a plan with status '${oldStatus}'. Must be active or planned.\n ${repoPath}`);
180
187
 
181
- const today = new Date().toISOString().slice(0, 10);
188
+ const today = nowIso();
182
189
  const leaseOldStatus = oldStatus === 'in-session' ? 'active' : (oldStatus ?? 'active');
183
190
  let leaseOutcome = 'acquired';
184
191
 
@@ -204,12 +211,18 @@ export async function runPickup(argv, config, opts = {}) {
204
211
  }
205
212
  }
206
213
 
214
+ let handoffBody = null;
215
+ if (handoffQueued && !dryRun) {
216
+ try { handoffBody = consumeHandoff(config, repoPath); } catch (err) { warn(`Could not consume handoff for ${repoPath}: ${err.message}`); }
217
+ }
218
+
207
219
  if (json) {
208
220
  process.stdout.write(JSON.stringify({
209
221
  path: repoPath, oldStatus, newStatus: 'in-session', title,
210
222
  reattached: leaseOutcome === 'reattached',
211
223
  takenOver: leaseOutcome === 'taken-over',
212
- body: body?.trim() ?? '',
224
+ handoffConsumed: handoffBody !== null,
225
+ body: (handoffBody ?? body)?.trim() ?? '',
213
226
  }, null, 2) + '\n');
214
227
  } else {
215
228
  if (leaseOutcome === 'reattached') {
@@ -219,7 +232,12 @@ export async function runPickup(argv, config, opts = {}) {
219
232
  } else {
220
233
  process.stderr.write(`${green('▶ Picked up')}: ${repoPath} (${oldStatus ?? 'unset'} → in-session)\n\n`);
221
234
  }
222
- if (body?.trim()) process.stdout.write(body.trim() + '\n');
235
+ const header = handoffBody
236
+ ? `[dotmd] holding ${repoPath} — consumed handoff. Release with: dotmd release ${repoPath}\n---\n`
237
+ : `[dotmd] holding ${repoPath} — release with: dotmd release ${repoPath}\n---\n`;
238
+ process.stdout.write(header);
239
+ const content = (handoffBody ?? body ?? '').trim();
240
+ if (content) process.stdout.write(content + '\n');
223
241
  }
224
242
 
225
243
  try { config.hooks.onPickup?.({ path: repoPath, oldStatus, newStatus: 'in-session' }); } catch (err) { warn(`Hook 'onPickup' threw: ${err.message}`); }
@@ -284,7 +302,7 @@ export async function runUnpickup(argv, config, opts = {}) {
284
302
  const parsedFm = parseSimpleFrontmatter(fmRaw);
285
303
  const cur = asString(parsedFm.status);
286
304
  if (cur === 'in-session') {
287
- const today = new Date().toISOString().slice(0, 10);
305
+ const today = nowIso();
288
306
  updateFrontmatter(filePath, { status: newStatus, updated: today });
289
307
  }
290
308
  // If frontmatter is no longer in-session (manual flip), leave it alone.
@@ -394,7 +412,7 @@ export async function runFinish(argv, config, opts = {}) {
394
412
 
395
413
  if (oldStatus !== 'in-session') die(`Plan is not in-session (current: ${oldStatus}).\n ${repoPath}`);
396
414
 
397
- const today = new Date().toISOString().slice(0, 10);
415
+ const today = nowIso();
398
416
 
399
417
  if (dryRun) {
400
418
  process.stderr.write(`${dim('[dry-run]')} Would update: status: in-session → ${targetStatus}, updated: ${today}\n`);
@@ -433,7 +451,7 @@ export function runArchive(argv, config, opts = {}) {
433
451
  const parsed = parseSimpleFrontmatter(frontmatter);
434
452
  const oldStatus = asString(parsed.status) ?? 'unknown';
435
453
 
436
- const today = new Date().toISOString().slice(0, 10);
454
+ const today = nowIso();
437
455
  const targetDir = path.join(archiveFileRoot, config.archiveDir);
438
456
  const targetPath = path.join(targetDir, path.basename(filePath));
439
457
  const oldRepoPath = toRepoPath(filePath, config.repoRoot);
@@ -590,7 +608,7 @@ export function runTouch(argv, config, opts = {}) {
590
608
  const filePath = resolveDocPath(input, config);
591
609
  if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
592
610
 
593
- const today = new Date().toISOString().slice(0, 10);
611
+ const today = nowIso();
594
612
 
595
613
  if (dryRun) {
596
614
  process.stdout.write(`${dim('[dry-run]')} Would touch: ${toRepoPath(filePath, config.repoRoot)} (updated → ${today})\n`);
@@ -706,6 +724,108 @@ function countRefsToUpdate(oldPath, newPath, config) {
706
724
  return count;
707
725
  }
708
726
 
727
+ function readHandoffInput(source) {
728
+ if (source === '-') {
729
+ const chunks = [];
730
+ try { chunks.push(readFileSync(0, 'utf8')); } catch (err) { die(`Could not read handoff from stdin: ${err.message}`); }
731
+ return chunks.join('');
732
+ }
733
+ if (typeof source === 'string' && source.startsWith('@')) {
734
+ const file = source.slice(1);
735
+ if (!existsSync(file)) die(`Handoff file not found: ${file}`);
736
+ return readFileSync(file, 'utf8');
737
+ }
738
+ return source;
739
+ }
740
+
741
+ export async function runHandoff(argv, config, opts = {}) {
742
+ const { dryRun } = opts;
743
+ const json = argv.includes('--json');
744
+ const replace = argv.includes('--replace');
745
+ const messageIdx = argv.indexOf('--message');
746
+ const messageFlag = messageIdx >= 0 ? argv[messageIdx + 1] : null;
747
+ const positional = argv.filter((a, i) => !a.startsWith('--') && argv[i - 1] !== '--message');
748
+
749
+ let input = positional[0];
750
+ let text = messageFlag !== null ? messageFlag : positional[1];
751
+
752
+ // Interactive: pick from in-session plans owned by current session
753
+ if (!input) {
754
+ if (!isInteractive()) die('Usage: dotmd handoff <file> [text | - | @path] (or --message "text")');
755
+ const leases = readLeases(config);
756
+ const session = currentSessionId();
757
+ const owned = Object.values(leases).filter(l => l.session === session);
758
+ if (owned.length === 0) die('No in-session plans owned by this session.');
759
+ if (owned.length === 1) {
760
+ input = owned[0].path;
761
+ process.stderr.write(`${dim(`Auto-selected: ${input}`)}\n`);
762
+ } else {
763
+ const choice = await promptChoice('Handoff which plan:', owned.map(l => l.path));
764
+ if (!choice) die('No plan selected.');
765
+ input = choice;
766
+ }
767
+ }
768
+
769
+ const filePath = resolveDocPath(input, config);
770
+ if (!filePath) die(`File not found: ${input}`);
771
+ const repoPath = toRepoPath(filePath, config.repoRoot);
772
+
773
+ // Resolve text: explicit arg/stdin/@file, else die (no $EDITOR fallback — sessions are non-interactive)
774
+ if (text === undefined || text === null) {
775
+ die('Missing handoff text. Pass inline, --message "text", - for stdin, or @path for a file.');
776
+ }
777
+ const body = readHandoffInput(text);
778
+ if (!body.trim()) die('Handoff text is empty.');
779
+
780
+ const raw = readFileSync(filePath, 'utf8');
781
+ const { frontmatter: fmRaw } = extractFrontmatter(raw);
782
+ const parsedFm = parseSimpleFrontmatter(fmRaw);
783
+ const oldStatus = asString(parsedFm.status);
784
+
785
+ // Must be holding this plan in current session
786
+ const leases = readLeases(config);
787
+ const lease = leases[repoPath];
788
+ const session = currentSessionId();
789
+ if (!lease || lease.session !== session) {
790
+ die(`Not held by this session: ${repoPath}\n Run \`dotmd pickup ${repoPath}\` first.`);
791
+ }
792
+
793
+ const today = nowIso();
794
+ const targetStatus = lease.oldStatus || 'active';
795
+
796
+ if (dryRun) {
797
+ process.stderr.write(`${dim('[dry-run]')} Would ${replace ? 'replace' : 'append to'} ${toRepoPath(handoffPath(config, repoPath), config.repoRoot)}\n`);
798
+ process.stderr.write(`${dim('[dry-run]')} Would release lease and flip status: in-session → ${targetStatus}\n`);
799
+ if (json) process.stdout.write(JSON.stringify({ path: repoPath, handoff: toRepoPath(handoffPath(config, repoPath), config.repoRoot), bytes: body.length, replace, dryRun: true }, null, 2) + '\n');
800
+ return;
801
+ }
802
+
803
+ // Order: write handoff first, then release. A crash between leaves a queued
804
+ // handoff with a held lease (recoverable) instead of a released lease with
805
+ // no handoff (silently lost).
806
+ const written = appendHandoff(config, repoPath, body, { replace });
807
+
808
+ if (oldStatus === 'in-session') {
809
+ updateFrontmatter(filePath, { status: targetStatus, updated: today });
810
+ }
811
+ releaseLease(config, repoPath, { force: true });
812
+
813
+ if (json) {
814
+ process.stdout.write(JSON.stringify({
815
+ path: repoPath,
816
+ handoff: toRepoPath(written, config.repoRoot),
817
+ bytes: body.length,
818
+ replace,
819
+ newStatus: targetStatus,
820
+ }, null, 2) + '\n');
821
+ } else {
822
+ process.stdout.write(`${green('▶ Handoff queued')}: ${toRepoPath(written, config.repoRoot)} (${body.length} bytes${replace ? ', replaced' : ', appended'})\n`);
823
+ process.stdout.write(`${green('↩ Released')}: ${repoPath} (in-session → ${targetStatus})\n`);
824
+ }
825
+
826
+ try { config.hooks.onUnpickup?.({ path: repoPath, oldStatus: 'in-session', newStatus: targetStatus }); } catch (err) { warn(`Hook 'onUnpickup' threw: ${err.message}`); }
827
+ }
828
+
709
829
  export function updateFrontmatter(filePath, updates) {
710
830
  const raw = readFileSync(filePath, 'utf8');
711
831
  if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block.`);
package/src/new.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { toRepoPath, die, warn } from './util.mjs';
3
+ import { toRepoPath, die, warn, nowIso } from './util.mjs';
4
4
  import { green, dim, bold } from './color.mjs';
5
5
  import { isInteractive, promptText } from './prompt.mjs';
6
6
 
@@ -11,9 +11,82 @@ const BUILTIN_TEMPLATES = {
11
11
  body: (t) => `\n# ${t}\n`,
12
12
  },
13
13
  plan: {
14
- description: 'Execution plan with module, surface, and cross-references',
15
- frontmatter: (s, d) => `type: plan\nstatus: ${s}\nupdated: ${d}\nsurface:\nmodule:\ncurrent_state:\nrelated_plans:`,
16
- body: (t) => `\n# ${t}\n\n## Overview\n\n\n\n## Implementation Plan\n\n- [ ] \n\n## Open Questions\n\n\n`,
14
+ description: 'Execution plan — build-up shape (Problem → Phases → Closeout) with phase status markers and Version History',
15
+ frontmatter: (s, d) => [
16
+ 'type: plan',
17
+ `status: ${s}`,
18
+ `created: ${d}`,
19
+ `updated: ${d}`,
20
+ 'surfaces: []',
21
+ 'modules: []',
22
+ 'domain:',
23
+ 'audience: internal',
24
+ 'parent_plan:',
25
+ 'related_plans: []',
26
+ 'related_docs: []',
27
+ 'current_state:',
28
+ 'next_step:',
29
+ ].join('\n'),
30
+ body: (t, ctx) => `
31
+ # ${t}
32
+
33
+ > One-paragraph problem statement: what this plan is for, why now.
34
+
35
+ ## Problem
36
+
37
+
38
+
39
+ ## Goals
40
+
41
+
42
+
43
+ ## Non-Goals
44
+
45
+
46
+
47
+ ## What Exists Today
48
+
49
+
50
+
51
+ ## Constraints
52
+
53
+
54
+
55
+ ## Decisions
56
+
57
+
58
+
59
+ ## Open Questions
60
+
61
+
62
+
63
+ ## Phases
64
+
65
+ <!--
66
+ Status markers (put in heading text):
67
+ ⬜ not started
68
+ 🟡 in progress (pickup targets this)
69
+ ✅ shipped (history; pickup skips)
70
+ ⏭ skipped (with reason in body)
71
+ 🚧 blocked (link to blocker)
72
+ -->
73
+
74
+ ### Phase 1 — <title> ⬜
75
+
76
+
77
+
78
+ ## Deferred
79
+
80
+
81
+
82
+ ## Version History
83
+
84
+ - **${ctx?.today ?? ''}** Created.
85
+
86
+ ## Closeout
87
+
88
+ <!-- Filled on archive: what shipped, key commits, deferrals dispositioned. -->
89
+ `,
17
90
  },
18
91
  adr: {
19
92
  description: 'Architecture Decision Record',
@@ -115,7 +188,7 @@ export async function runNew(argv, config, opts = {}) {
115
188
  die(`File already exists: ${repoPath}`);
116
189
  }
117
190
 
118
- const today = new Date().toISOString().slice(0, 10);
191
+ const today = nowIso();
119
192
 
120
193
  // Generate content
121
194
  let content;
@@ -123,7 +196,7 @@ export async function runNew(argv, config, opts = {}) {
123
196
  content = template(name, { status, title: docTitle, today });
124
197
  } else {
125
198
  const fm = template.frontmatter(status, today);
126
- const body = template.body(docTitle);
199
+ const body = template.body(docTitle, { today, status });
127
200
  content = `---\n${fm}\n---\n${body}`;
128
201
  }
129
202
 
package/src/render.mjs CHANGED
@@ -5,6 +5,7 @@ import { extractFrontmatter } from './frontmatter.mjs';
5
5
  import { summarizeDocBody } from './ai.mjs';
6
6
  import { bold, red, yellow, green, dim } from './color.mjs';
7
7
  import { findStaleLeases } from './lease.mjs';
8
+ import { listQueuedHandoffs } from './handoff.mjs';
8
9
 
9
10
  export function renderCompactList(index, config) {
10
11
  const defaultRenderer = (idx) => _renderCompactList(idx, config);
@@ -265,6 +266,17 @@ function _renderContext(index, config, opts = {}) {
265
266
 
266
267
  export function renderBriefing(index, config) {
267
268
  const lines = [];
269
+
270
+ try {
271
+ const queued = listQueuedHandoffs(config);
272
+ if (queued.length > 0) {
273
+ const preview = queued.slice(0, 3).map(h => path.basename(h.repoPath, '.md')).join(', ');
274
+ const more = queued.length > 3 ? `, +${queued.length - 3} more` : '';
275
+ lines.push(green(`▶ ${queued.length} handoff${queued.length === 1 ? '' : 's'} queued: ${preview}${more}`));
276
+ lines.push(dim(` Resume: claude "$(dotmd pickup <plan>)" or just: dotmd pickup`));
277
+ }
278
+ } catch {}
279
+
268
280
  const plans = index.docs.filter(d => d.type === 'plan');
269
281
  const docs = index.docs.filter(d => d.type === 'doc');
270
282
  const research = index.docs.filter(d => d.type === 'research');
@@ -301,7 +313,7 @@ export function renderBriefing(index, config) {
301
313
  try {
302
314
  const staleLeases = findStaleLeases(config);
303
315
  if (staleLeases.length > 0) {
304
- lines.push(yellow(`Stuck in-session: ${staleLeases.length} (>1d or dead pid, run \`dotmd unpickup --stale\`)`));
316
+ lines.push(yellow(`Stuck in-session: ${staleLeases.length} (>1d or dead pid, run \`dotmd release --stale\`)`));
305
317
  }
306
318
  } catch {}
307
319
 
package/src/util.mjs CHANGED
@@ -55,6 +55,10 @@ export function toRepoPath(absolutePath, repoRoot) {
55
55
  return path.relative(repoRoot, absolutePath).split(path.sep).join('/');
56
56
  }
57
57
 
58
+ export function nowIso() {
59
+ return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
60
+ }
61
+
58
62
  export function warn(message) {
59
63
  process.stderr.write(`${dim(message)}\n`);
60
64
  }