dotmd-cli 0.30.0 → 0.31.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.
package/README.md CHANGED
@@ -146,10 +146,9 @@ 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 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)
149
+ dotmd hud Actionable triage (silent when clean — ideal SessionStart hook)
150
+ dotmd pickup <file> Pick up a plan (in-session + print body)
151
151
  dotmd release [<file>] Release in-session lease (alias: unpickup)
152
- dotmd handoff <file> [...] Queue a resume-prompt sidecar + release
153
152
  dotmd status <file> <status> Transition document status
154
153
  dotmd archive <file> Archive (status + move + update refs)
155
154
  dotmd bulk archive <files> Archive multiple files at once
@@ -267,7 +266,7 @@ dotmd prompts archive <file> # archive a prompt
267
266
  dotmd prompts new <name> [body] # alias for `dotmd new prompt`
268
267
  ```
269
268
 
270
- `dotmd hud` surfaces pending prompts on session start (alongside held leases and queued handoffs), so a saved prompt acts as a self-addressed reminder: write it now, the next session sees it.
269
+ `dotmd hud` surfaces pending prompts on session start (alongside held leases), so a saved prompt acts as a self-addressed reminder: write it now, the next session sees it.
271
270
 
272
271
  Statuses: `pending` (drafted, awaiting a session), `claimed` (consumed by a session), `archived`.
273
272
 
@@ -448,7 +447,7 @@ dotmd bulk archive docs/old-*.md -n # preview
448
447
  ### Pickup & Closeout
449
448
 
450
449
  ```bash
451
- dotmd pickup docs/plans/my-plan.md # set in-session + print body (or queued handoff)
450
+ dotmd pickup docs/plans/my-plan.md # set in-session + print body
452
451
  dotmd archive docs/plans/my-plan.md # fully shipped: archive + auto-release lease
453
452
  dotmd release docs/plans/my-plan.md # need more work: release lease, flip to prior status
454
453
  dotmd status docs/plans/my-plan.md partial # shipped + tail deferred (reference successors in body)
@@ -457,30 +456,6 @@ dotmd status docs/plans/my-plan.md awaiting # stuck on a human decision
457
456
 
458
457
  `finish` is a legacy command that defaults to `status: done`, which is no longer in the default plan vocabulary as of 0.16. Use `archive` (fully shipped) or `release` + `status` (anything else). If you need it back, add `done` to `types.plan.statuses` in your config.
459
458
 
460
- ### Handoff (resume-prompts attached to plans)
461
-
462
- When you're stopping mid-work and the next session will need to pick up where
463
- you left off, write a handoff sidecar instead of printing a resume prompt to
464
- chat for copy-paste:
465
-
466
- ```bash
467
- dotmd handoff docs/plans/foo.md "continue from validate(); next: write tests"
468
- dotmd handoff docs/plans/foo.md - <<'EOF' # heredoc-friendly for Claude
469
- …multi-line resume prompt…
470
- EOF
471
- dotmd handoff docs/plans/foo.md @/tmp/handoff.md # from file
472
- ```
473
-
474
- The handoff is written to `<repoRoot>/.dotmd/handoffs/<plan-path>` as a
475
- timestamped section (append mode by default; `--replace` to overwrite). The
476
- lease is released and the plan flips back to its prior status. The next
477
- `dotmd pickup` of that plan prints the handoff *instead of* the plan body
478
- and atomically unlinks the sidecar — single-claim, can't be consumed twice.
479
-
480
- The included `/handoff` slash command (scaffolded under
481
- `.claude/commands/handoff.md` by `dotmd init` / `dotmd doctor`) instructs
482
- Claude to synthesize and queue handoffs for every plan the session holds.
483
-
484
459
  ### Session leases & release
485
460
 
486
461
  `dotmd pickup` records a lease at `<repoRoot>/.dotmd/in-session.json` that
@@ -544,7 +519,7 @@ either is silent.
544
519
  ```
545
520
 
546
521
  - **SessionStart** runs `dotmd hud`, which prints up to three actionable
547
- lines (held leases, queued handoffs, stale leases) and stays silent when
522
+ lines (held leases, pending prompts, stale leases) and stays silent when
548
523
  nothing is queued. Use this instead of `dotmd briefing` for the hook role
549
524
  — `briefing` dumps per-plan next_step prose that can run to many kilobytes
550
525
  on large repos. `hud` is the zero-pollution surface.
package/bin/dotmd.mjs CHANGED
@@ -14,7 +14,7 @@ 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
+ hud [--json] Two-line actionable triage (held / prompts / stuck) — silent when clean
18
18
  list [--verbose] [--json] List docs grouped by status (default command)
19
19
  briefing [--json] Full briefing with plan status counts + next steps
20
20
  context [--summarize] [--json] Full briefing (LLM-oriented)
@@ -44,9 +44,8 @@ Validate & Fix:
44
44
  fix-refs [--dry-run] Auto-fix broken reference paths + body links
45
45
 
46
46
  Lifecycle:
47
- pickup <file> [--takeover] Pick up a plan (set in-session + print body or queued handoff)
47
+ pickup <file> [--takeover] Pick up a plan (set in-session + print body)
48
48
  release [<file>] [--to <s>] Release in-session lease (alias: unpickup)
49
- handoff <file> [text|-|@path] Queue a resume-prompt sidecar + release (append-mode)
50
49
  finish <file> [done|active] Finish a plan (set done or active)
51
50
  status <file> <status> Transition document status
52
51
  archive <file> Archive (status + move + update refs)
@@ -125,10 +124,6 @@ Sets the plan to in-session and prints its content (prefixed with a
125
124
  Writes a session lease to <repoRoot>/.dotmd/in-session.json so the same
126
125
  Claude session can re-attach silently after compaction or /clear.
127
126
 
128
- If a handoff sidecar is queued for the plan (\`.dotmd/handoffs/<path>\`),
129
- pickup prints the handoff *instead of* the plan body and unlinks the
130
- sidecar atomically — single-claim, can't be consumed twice.
131
-
132
127
  If a plan is already in-session:
133
128
  - Same session → silent re-attach (prints body, no error).
134
129
  - Different session, live pid → refuses with "Held by …" message.
@@ -139,8 +134,7 @@ Options:
139
134
  --json Output as JSON
140
135
  --dry-run, -n Preview without writing
141
136
 
142
- If no file is given, prompts with a list of active/planned plans plus
143
- any plan with a queued handoff (sorted to the top).`,
137
+ If no file is given, prompts with a list of active/planned plans.`,
144
138
 
145
139
  unpickup: `dotmd unpickup [<file>] — release a plan from in-session
146
140
 
@@ -171,37 +165,6 @@ status. With no file, releases every lease owned by the current session.
171
165
  Identical behavior to \`dotmd unpickup\`; both names route to the same
172
166
  implementation. See \`dotmd unpickup --help\` for full option list.`,
173
167
 
174
- handoff: `dotmd handoff <file> [text | - | @path] — queue resume prompt + release
175
-
176
- Writes a handoff sidecar attached to the plan, then releases the lease
177
- and flips frontmatter back to the prior status. The next \`dotmd pickup\`
178
- of the plan prints the handoff (instead of the plan body) and atomically
179
- unlinks the sidecar — single-claim guaranteed.
180
-
181
- Sidecars live at <repoRoot>/.dotmd/handoffs/<plan-path> and accumulate
182
- timestamped sections on repeat writes. To replace the chain instead of
183
- appending, pass --replace.
184
-
185
- Sources for handoff text (pick one):
186
- <text> Inline argument (best for short notes)
187
- --message "<text>" Explicit --message flag (equivalent to inline)
188
- - Read from stdin (heredoc-friendly for Claude)
189
- @path Read from a file
190
-
191
- Examples:
192
- dotmd handoff plans/foo.md "continue from validate(); next: write tests"
193
- dotmd handoff plans/foo.md - <<'EOF'
194
- …multi-line handoff…
195
- EOF
196
- dotmd handoff plans/foo.md @/tmp/handoff.md
197
-
198
- Options:
199
- --replace Overwrite the sidecar chain instead of appending
200
- --json Output as JSON
201
- --dry-run, -n Preview without writing
202
-
203
- Refuses if the plan is not currently held by this session.`,
204
-
205
168
  finish: `dotmd finish <file> [done|active] — finish working on a plan
206
169
 
207
170
  Sets the plan status to done (default) or back to active.
@@ -263,13 +226,12 @@ Options:
263
226
 
264
227
  hud: `dotmd hud — actionable triage for session start
265
228
 
266
- Prints up to four lines, in order:
229
+ Prints up to three lines, in order:
267
230
  ▶ You hold N plans: <slugs> (leases owned by current session)
268
- ▶ N handoffs queued: <slugs> (resume-prompt sidecars waiting)
269
231
  ▶ N pending prompts: <slugs> (saved prompts in docs/prompts/)
270
232
  ⚠ N stuck leases >24h (suggest \`dotmd release --stale\`)
271
233
 
272
- Silent when all four are empty — designed for SessionStart hooks where
234
+ Silent when all three are empty — designed for SessionStart hooks where
273
235
  zero noise is the right default. Distinct from \`dotmd briefing\`, which
274
236
  dumps the full plan-status pipeline and per-plan next_step bodies (kilobytes
275
237
  on large repos). Use hud for ergonomic session boot; use briefing for
@@ -816,7 +778,7 @@ async function main() {
816
778
  if (command === 'hud') { const { runHud } = await import('../src/hud.mjs'); runHud(restArgs, config); return; }
817
779
  if (command === 'pickup') { const { runPickup } = await import('../src/lifecycle.mjs'); await runPickup(restArgs, config, { dryRun }); return; }
818
780
  if (command === 'unpickup' || command === 'release') { const { runUnpickup } = await import('../src/lifecycle.mjs'); await runUnpickup(restArgs, config, { dryRun }); return; }
819
- if (command === 'handoff') { const { runHandoff } = await import('../src/lifecycle.mjs'); await runHandoff(restArgs, config, { dryRun }); return; }
781
+ if (command === 'handoff') { die('`dotmd handoff` was removed in 0.31.0. Use `dotmd prompts new <name>` to create a saved prompt instead. The .dotmd/handoffs/ sidecar mechanism no longer exists; see CHANGELOG.'); }
820
782
  if (command === 'finish') { const { runFinish } = await import('../src/lifecycle.mjs'); await runFinish(restArgs, config, { dryRun }); return; }
821
783
  if (command === 'status') { const { runStatus } = await import('../src/lifecycle.mjs'); await runStatus(restArgs, config, { dryRun }); return; }
822
784
  if (command === 'archive') { const { runArchive } = await import('../src/lifecycle.mjs'); runArchive(restArgs, config, { dryRun }); return; }
@@ -1076,7 +1038,7 @@ async function main() {
1076
1038
  // Unknown command — suggest closest match
1077
1039
  const allCommands = [
1078
1040
  'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'hud',
1079
- 'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'handoff', 'finish', 'status', 'archive', 'bulk', 'touch', 'doctor',
1041
+ 'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'finish', 'status', 'archive', 'bulk', 'touch', 'doctor',
1080
1042
  'unblocks', 'health', 'glossary',
1081
1043
  'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
1082
1044
  'watch', 'diff', 'new', 'init', 'completions', 'statuses',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
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,9 +16,8 @@ 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)');
19
+ lines.push('- `dotmd pickup <file>` — pick up a plan (set in-session + print body)');
20
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)');
22
21
  lines.push('- `dotmd health` — plan velocity, aging, checklist progress, pipeline view');
23
22
  lines.push('- `dotmd unblocks <file>` — what depends on / is blocked by a plan');
24
23
  lines.push('- `dotmd next` — ready plans with next steps (what to promote)');
@@ -47,44 +46,6 @@ function generatePlansCommand(config) {
47
46
  return lines.join('\n');
48
47
  }
49
48
 
50
- function generateHandoffCommand(config) {
51
- const lines = [VERSION_MARKER, ''];
52
- 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.');
53
- lines.push('');
54
- lines.push('Steps:');
55
- lines.push('');
56
- lines.push('1. Identify which plans this session is holding by running:');
57
- lines.push(' ```');
58
- lines.push(' dotmd plans --status in-session --json');
59
- lines.push(' ```');
60
- 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.');
61
- lines.push('');
62
- lines.push('2. For each held plan, synthesize a handoff prompt covering:');
63
- lines.push(' - One-line summary of where the session got to');
64
- lines.push(' - What is already done (and verified working)');
65
- lines.push(' - What is in flight / partial / needs verification');
66
- lines.push(' - Concrete next step the resuming session should take');
67
- lines.push(' - Key files touched (with paths) and any non-obvious gotchas');
68
- lines.push(' - Open questions or decisions deferred');
69
- lines.push('');
70
- lines.push('3. Write each handoff via Bash heredoc:');
71
- lines.push(' ```bash');
72
- lines.push(' dotmd handoff <plan-path> - <<\'EOF\'');
73
- lines.push(' <synthesized handoff body>');
74
- lines.push(' EOF');
75
- lines.push(' ```');
76
- 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.');
77
- lines.push('');
78
- 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.');
79
- lines.push('');
80
- lines.push('Rules:');
81
- lines.push('- If a plan is not held by this session, do not write a handoff for it (dotmd will refuse anyway).');
82
- 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`.');
83
- lines.push('- Never print the resume text into the conversation as a copy-paste block.');
84
- lines.push('');
85
- return lines.join('\n');
86
- }
87
-
88
49
  function generateDocsCommand(config) {
89
50
  const roots = Array.isArray(config.raw?.root) ? config.raw.root : [config.raw?.root ?? 'docs'];
90
51
  const rootCount = roots.length;
@@ -156,7 +117,6 @@ export function scaffoldClaudeCommands(cwd, config) {
156
117
  const files = [
157
118
  { name: 'plans.md', generate: () => generatePlansCommand(config) },
158
119
  { name: 'docs.md', generate: () => generateDocsCommand(config) },
159
- { name: 'handoff.md', generate: () => generateHandoffCommand(config) },
160
120
  ];
161
121
 
162
122
  for (const { name, generate } of files) {
@@ -189,7 +149,7 @@ export function checkClaudeCommands(cwd) {
189
149
  if (!existsSync(commandsDir)) return [];
190
150
 
191
151
  const warnings = [];
192
- for (const name of ['plans.md', 'docs.md', 'handoff.md']) {
152
+ for (const name of ['plans.md', 'docs.md']) {
193
153
  const filePath = path.join(commandsDir, name);
194
154
  const installedVersion = getInstalledVersion(filePath);
195
155
  if (installedVersion && installedVersion !== pkg.version) {
package/src/export.mjs CHANGED
@@ -151,8 +151,8 @@ function exportMarkdown(docs, config) {
151
151
  lines.push(`### ${doc.title}`);
152
152
  const meta = [`Status: ${doc.status}`];
153
153
  if (doc.updated) meta.push(`Updated: ${doc.updated}`);
154
- if (doc.module) meta.push(`Module: ${doc.module}`);
155
- if (doc.surface) meta.push(`Surface: ${doc.surface}`);
154
+ if (doc.modules?.length) meta.push(`Module: ${doc.modules.join(', ')}`);
155
+ if (doc.surfaces?.length) meta.push(`Surface: ${doc.surfaces.join(', ')}`);
156
156
  if (doc.owner) meta.push(`Owner: ${doc.owner}`);
157
157
  lines.push(`> ${meta.join(' | ')}`, '');
158
158
  if (doc.body.trim()) lines.push(doc.body.trim());
@@ -292,8 +292,8 @@ function buildDocPage(doc) {
292
292
  let meta = `<table class="meta">`;
293
293
  meta += `<tr><td>Status</td><td><span class="badge ${badgeClass}">${doc.status ?? 'unknown'}</span></td></tr>`;
294
294
  if (doc.updated) meta += `<tr><td>Updated</td><td>${escHtml(doc.updated)}</td></tr>`;
295
- if (doc.module) meta += `<tr><td>Module</td><td>${escHtml(doc.module)}</td></tr>`;
296
- if (doc.surface) meta += `<tr><td>Surface</td><td>${escHtml(doc.surface)}</td></tr>`;
295
+ if (doc.modules?.length) meta += `<tr><td>Module</td><td>${escHtml(doc.modules.join(', '))}</td></tr>`;
296
+ if (doc.surfaces?.length) meta += `<tr><td>Surface</td><td>${escHtml(doc.surfaces.join(', '))}</td></tr>`;
297
297
  if (doc.owner) meta += `<tr><td>Owner</td><td>${escHtml(doc.owner)}</td></tr>`;
298
298
  meta += `<tr><td>Path</td><td><code>${escHtml(doc.path)}</code></td></tr>`;
299
299
  meta += `</table>`;
package/src/git.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { renameSync } from 'node:fs';
3
+ import path from 'node:path';
2
4
 
3
5
  let gitChecked = false;
4
6
  function ensureGit() {
@@ -49,6 +51,20 @@ export function getGitLastModifiedBatch(repoRoot) {
49
51
 
50
52
  export function gitMv(source, target, repoRoot) {
51
53
  ensureGit();
54
+ // Source is untracked (scaffolded this session, never committed; or repoRoot
55
+ // is not a git repo at all): a plain rename is the only correct move. `git mv`
56
+ // would error with `fatal: not under version control` and the user can't act
57
+ // on that — the file is genuinely a doc, just not yet staged.
58
+ if (!isTracked(source, repoRoot)) {
59
+ const absSource = path.isAbsolute(source) ? source : path.join(repoRoot, source);
60
+ const absTarget = path.isAbsolute(target) ? target : path.join(repoRoot, target);
61
+ try {
62
+ renameSync(absSource, absTarget);
63
+ return { status: 0, stderr: '' };
64
+ } catch (err) {
65
+ return { status: 1, stderr: err.message };
66
+ }
67
+ }
52
68
  const result = spawnSync('git', ['mv', source, target], {
53
69
  cwd: repoRoot,
54
70
  encoding: 'utf8',
@@ -56,6 +72,15 @@ export function gitMv(source, target, repoRoot) {
56
72
  return { status: result.status, stderr: result.stderr };
57
73
  }
58
74
 
75
+ function isTracked(source, repoRoot) {
76
+ const relSource = path.isAbsolute(source) ? path.relative(repoRoot, source) : source;
77
+ const result = spawnSync('git', ['ls-files', '--error-unmatch', '--', relSource], {
78
+ cwd: repoRoot,
79
+ encoding: 'utf8',
80
+ });
81
+ return result.status === 0;
82
+ }
83
+
59
84
  export function gitDiffSince(relPath, sinceDate, repoRoot, opts = {}) {
60
85
  ensureGit();
61
86
  // Find the last commit at or before sinceDate
package/src/graph.mjs CHANGED
@@ -43,7 +43,9 @@ export function buildGraph(index, config, filters = {}) {
43
43
  title: d.title,
44
44
  status: d.status,
45
45
  module: d.module,
46
+ modules: d.modules,
46
47
  surface: d.surface,
48
+ surfaces: d.surfaces,
47
49
  edgeCount: 0,
48
50
  }));
49
51
  const nodeMap = new Map(nodes.map(n => [n.id, n]));
package/src/hud.mjs CHANGED
@@ -1,7 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { readLeases, findStaleLeases, currentSessionId } from './lease.mjs';
4
- import { listQueuedHandoffs } from './handoff.mjs';
5
4
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
6
5
  import { asString, toRepoPath } from './util.mjs';
7
6
  import { green, yellow, dim } from './color.mjs';
@@ -69,11 +68,10 @@ export function buildHud(config) {
69
68
  const session = currentSessionId();
70
69
  const leases = readLeases(config);
71
70
  const owned = Object.values(leases).filter(l => l.session === session).map(l => l.path);
72
- const queued = listQueuedHandoffs(config).map(h => h.repoPath);
73
71
  const stale = findStaleLeases(config).map(l => l.path);
74
72
  const prompts = findActionablePrompts(config);
75
73
 
76
- return { owned, queued, stale, prompts };
74
+ return { owned, stale, prompts };
77
75
  }
78
76
 
79
77
  export function runHud(argv, config) {
@@ -89,9 +87,6 @@ export function runHud(argv, config) {
89
87
  if (hud.owned.length > 0) {
90
88
  lines.push(green(`▶ You hold ${hud.owned.length} plan${hud.owned.length === 1 ? '' : 's'}: ${previewList(hud.owned)}`));
91
89
  }
92
- if (hud.queued.length > 0) {
93
- lines.push(green(`▶ ${hud.queued.length} handoff${hud.queued.length === 1 ? '' : 's'} queued: ${previewList(hud.queued)} ${dim('(resume: dotmd pickup)')}`));
94
- }
95
90
  if (hud.prompts.length > 0) {
96
91
  lines.push(green(`▶ ${hud.prompts.length} pending prompt${hud.prompts.length === 1 ? '' : 's'}: ${previewList(hud.prompts)} ${dim('(consume: `dotmd prompts use <file>` — do not cat/read)')}`));
97
92
  }
package/src/lifecycle.mjs CHANGED
@@ -16,7 +16,6 @@ import {
16
16
  currentSessionId,
17
17
  migrateLease,
18
18
  } from './lease.mjs';
19
- import { hasHandoff, consumeHandoff, appendHandoff, handoffPath, listQueuedHandoffs } from './handoff.mjs';
20
19
  import { buildCard, renderCard } from './pickup-card.mjs';
21
20
  import { walkSections, findSection } from './section.mjs';
22
21
 
@@ -163,17 +162,15 @@ export async function runPickup(argv, config, opts = {}) {
163
162
  const fullBody = argv.includes('--full');
164
163
  let input = argv.find(a => !a.startsWith('-'));
165
164
 
166
- // Interactive: pick from active/planned plans + anything with a queued handoff
165
+ // Interactive: pick from active/planned plans
167
166
  if (!input) {
168
167
  if (!isInteractive()) die('Usage: dotmd pickup <file>');
169
168
  const index = buildIndex(config);
170
- const queued = new Set(listQueuedHandoffs(config).map(h => h.repoPath));
171
169
  const candidates = index.docs.filter(d =>
172
- d.type === 'plan' && (d.status === 'active' || d.status === 'planned' || queued.has(d.path))
170
+ d.type === 'plan' && (d.status === 'active' || d.status === 'planned')
173
171
  );
174
- if (candidates.length === 0) die('No active/planned plans and no queued handoffs.');
175
- candidates.sort((a, b) => Number(queued.has(b.path)) - Number(queued.has(a.path)));
176
- const labelFor = (d) => `${queued.has(d.path) ? '▶ handoff queued · ' : ''}${d.title} (${d.status}) — ${d.path}`;
172
+ if (candidates.length === 0) die('No active/planned plans.');
173
+ const labelFor = (d) => `${d.title} (${d.status}) ${d.path}`;
177
174
  const choice = await promptChoice('Pick a plan:', candidates.map(labelFor));
178
175
  if (!choice) die('No plan selected.');
179
176
  const idx = candidates.findIndex(d => choice === labelFor(d));
@@ -207,9 +204,8 @@ export async function runPickup(argv, config, opts = {}) {
207
204
  }
208
205
  }
209
206
 
210
- const handoffQueued = hasHandoff(config, repoPath);
211
207
  const pickupable = new Set(['active', 'planned', 'in-session']);
212
- if (oldStatus && !pickupable.has(oldStatus) && !handoffQueued) die(`Cannot pick up a plan with status '${oldStatus}'. Must be active or planned.\n ${repoPath}`);
208
+ if (oldStatus && !pickupable.has(oldStatus)) die(`Cannot pick up a plan with status '${oldStatus}'. Must be active or planned.\n ${repoPath}`);
213
209
 
214
210
  const today = nowIso();
215
211
  const leaseOldStatus = oldStatus === 'in-session' ? 'active' : (oldStatus ?? 'active');
@@ -247,19 +243,13 @@ export async function runPickup(argv, config, opts = {}) {
247
243
  }
248
244
  }
249
245
 
250
- let handoffBody = null;
251
- if (handoffQueued && !dryRun) {
252
- try { handoffBody = consumeHandoff(config, repoPath); } catch (err) { warn(`Could not consume handoff for ${repoPath}: ${err.message}`); }
253
- }
254
-
255
246
  if (json) {
256
- const card = handoffBody ? null : buildCard(filePath, raw, config);
247
+ const card = buildCard(filePath, raw, config);
257
248
  process.stdout.write(JSON.stringify({
258
249
  path: repoPath, oldStatus, newStatus: 'in-session', title,
259
250
  reattached: leaseOutcome === 'reattached',
260
251
  takenOver: leaseOutcome === 'taken-over',
261
- handoffConsumed: handoffBody !== null,
262
- body: (handoffBody ?? body)?.trim() ?? '',
252
+ body: body?.trim() ?? '',
263
253
  card,
264
254
  }, null, 2) + '\n');
265
255
  } else {
@@ -270,18 +260,12 @@ export async function runPickup(argv, config, opts = {}) {
270
260
  } else {
271
261
  process.stderr.write(`${green('▶ Picked up')}: ${repoPath} (${oldStatus ?? 'unset'} → in-session)\n\n`);
272
262
  }
273
- if (handoffBody) {
274
- const header = `[dotmd] holding ${repoPath} — consumed handoff. Release with: dotmd release ${repoPath}\n---\n`;
275
- process.stdout.write(header);
276
- const content = handoffBody.trim();
277
- if (content) process.stdout.write(content + '\n');
278
- } else if (fullBody) {
263
+ if (fullBody) {
279
264
  const header = `[dotmd] holding ${repoPath} — release with: dotmd release ${repoPath}\n---\n`;
280
265
  process.stdout.write(header);
281
266
  const content = (body ?? '').trim();
282
267
  if (content) process.stdout.write(content + '\n');
283
268
  } else {
284
- // Default: card view
285
269
  const card = buildCard(filePath, raw, config);
286
270
  process.stdout.write(renderCard(card));
287
271
  }
@@ -771,109 +755,6 @@ function countRefsToUpdate(oldPath, newPath, config) {
771
755
  return count;
772
756
  }
773
757
 
774
- function readHandoffInput(source) {
775
- if (source === '-') {
776
- const chunks = [];
777
- try { chunks.push(readFileSync(0, 'utf8')); } catch (err) { die(`Could not read handoff from stdin: ${err.message}`); }
778
- return chunks.join('');
779
- }
780
- if (typeof source === 'string' && source.startsWith('@')) {
781
- const file = source.slice(1);
782
- if (!existsSync(file)) die(`Handoff file not found: ${file}`);
783
- return readFileSync(file, 'utf8');
784
- }
785
- return source;
786
- }
787
-
788
- export async function runHandoff(argv, config, opts = {}) {
789
- const { dryRun } = opts;
790
- const json = argv.includes('--json');
791
- const replace = argv.includes('--replace');
792
- const messageIdx = argv.indexOf('--message');
793
- const messageFlag = messageIdx >= 0 ? argv[messageIdx + 1] : null;
794
- const positional = argv.filter((a, i) => !a.startsWith('--') && argv[i - 1] !== '--message');
795
-
796
- let input = positional[0];
797
- let text = messageFlag !== null ? messageFlag : positional[1];
798
-
799
- // Interactive: pick from in-session plans owned by current session
800
- if (!input) {
801
- if (!isInteractive()) die('Usage: dotmd handoff <file> [text | - | @path] (or --message "text")');
802
- const leases = readLeases(config);
803
- const session = currentSessionId();
804
- const owned = Object.values(leases).filter(l => l.session === session);
805
- if (owned.length === 0) die('No in-session plans owned by this session.');
806
- if (owned.length === 1) {
807
- input = owned[0].path;
808
- process.stderr.write(`${dim(`Auto-selected: ${input}`)}\n`);
809
- } else {
810
- const choice = await promptChoice('Handoff which plan:', owned.map(l => l.path));
811
- if (!choice) die('No plan selected.');
812
- input = choice;
813
- }
814
- }
815
-
816
- const filePath = resolveDocPath(input, config);
817
- if (!filePath) die(`File not found: ${input}`);
818
- const repoPath = toRepoPath(filePath, config.repoRoot);
819
-
820
- // Resolve text: explicit arg/stdin/@file, else die (no $EDITOR fallback — sessions are non-interactive)
821
- if (text === undefined || text === null) {
822
- die('Missing handoff text. Pass inline, --message "text", - for stdin, or @path for a file.');
823
- }
824
- const body = readHandoffInput(text);
825
- if (!body.trim()) die('Handoff text is empty.');
826
-
827
- const raw = readFileSync(filePath, 'utf8');
828
- const { frontmatter: fmRaw } = extractFrontmatter(raw);
829
- const parsedFm = parseSimpleFrontmatter(fmRaw);
830
- const oldStatus = asString(parsedFm.status);
831
-
832
- // Must be holding this plan in current session
833
- const leases = readLeases(config);
834
- const lease = leases[repoPath];
835
- const session = currentSessionId();
836
- if (!lease || lease.session !== session) {
837
- die(`Not held by this session: ${repoPath}\n Run \`dotmd pickup ${repoPath}\` first.`);
838
- }
839
-
840
- const today = nowIso();
841
- const targetStatus = lease.oldStatus || 'active';
842
-
843
- if (dryRun) {
844
- process.stderr.write(`${dim('[dry-run]')} Would ${replace ? 'replace' : 'append to'} ${toRepoPath(handoffPath(config, repoPath), config.repoRoot)}\n`);
845
- process.stderr.write(`${dim('[dry-run]')} Would release lease and flip status: in-session → ${targetStatus}\n`);
846
- 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');
847
- return;
848
- }
849
-
850
- // Order: write handoff first, then release. A crash between leaves a queued
851
- // handoff with a held lease (recoverable) instead of a released lease with
852
- // no handoff (silently lost).
853
- const written = appendHandoff(config, repoPath, body, { replace });
854
-
855
- if (oldStatus === 'in-session') {
856
- updateFrontmatter(filePath, { status: targetStatus, updated: today });
857
- }
858
- appendVersionHistory(filePath, `Handoff queued (in-session → ${targetStatus}).`);
859
- releaseLease(config, repoPath, { force: true });
860
-
861
- if (json) {
862
- process.stdout.write(JSON.stringify({
863
- path: repoPath,
864
- handoff: toRepoPath(written, config.repoRoot),
865
- bytes: body.length,
866
- replace,
867
- newStatus: targetStatus,
868
- }, null, 2) + '\n');
869
- } else {
870
- process.stdout.write(`${green('▶ Handoff queued')}: ${toRepoPath(written, config.repoRoot)} (${body.length} bytes${replace ? ', replaced' : ', appended'})\n`);
871
- process.stdout.write(`${green('↩ Released')}: ${repoPath} (in-session → ${targetStatus})\n`);
872
- }
873
-
874
- try { config.hooks.onUnpickup?.({ path: repoPath, oldStatus: 'in-session', newStatus: targetStatus }); } catch (err) { warn(`Hook 'onUnpickup' threw: ${err.message}`); }
875
- }
876
-
877
758
  // Append a one-line dated bullet to the file's `## Version History` section.
878
759
  // Newest-first ordering: inserted at the top of the section, right after the
879
760
  // heading + blank-line gap. If the section is missing, this is a silent no-op
package/src/render.mjs CHANGED
@@ -5,7 +5,6 @@ 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';
9
8
 
10
9
  export function renderCompactList(index, config) {
11
10
  const defaultRenderer = (idx) => _renderCompactList(idx, config);
@@ -267,16 +266,6 @@ function _renderContext(index, config, opts = {}) {
267
266
  export function renderBriefing(index, config) {
268
267
  const lines = [];
269
268
 
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
-
280
269
  const plans = index.docs.filter(d => d.type === 'plan');
281
270
  const docs = index.docs.filter(d => d.type === 'doc');
282
271
  const research = index.docs.filter(d => d.type === 'research');
@@ -390,10 +379,10 @@ export function renderCoverage(index, config) {
390
379
  export function buildCoverage(index, config) {
391
380
  const scope = [...new Set(index.docs.map(d => d.status).filter(s => s && !config.lifecycle.terminalStatuses.has(s)))];
392
381
  const scoped = index.docs.filter(doc => doc.status && !config.lifecycle.terminalStatuses.has(doc.status));
393
- const missingSurface = scoped.filter(doc => !doc.surface);
394
- const missingModule = scoped.filter(doc => !doc.module);
395
- const modulePlatform = scoped.filter(doc => doc.module === 'platform');
396
- const moduleNone = scoped.filter(doc => doc.module === 'none');
382
+ const missingSurface = scoped.filter(doc => !doc.surfaces?.length);
383
+ const missingModule = scoped.filter(doc => !doc.modules?.length);
384
+ const modulePlatform = scoped.filter(doc => doc.modules?.includes('platform'));
385
+ const moduleNone = scoped.filter(doc => doc.modules?.includes('none'));
397
386
  const auditLevelNone = scoped.filter(doc => doc.auditLevel === 'none');
398
387
  const audited = scoped.filter(doc => ['pass1', 'pass2', 'deep'].includes(doc.auditLevel));
399
388
 
package/src/stats.mjs CHANGED
@@ -64,8 +64,8 @@ export function buildStats(index, config) {
64
64
  completeness: {
65
65
  scoped: scoped.length,
66
66
  hasOwner: scoped.filter(d => d.owner).length,
67
- hasSurface: scoped.filter(d => d.surface).length,
68
- hasModule: scoped.filter(d => d.module).length,
67
+ hasSurface: scoped.filter(d => d.surfaces?.length).length,
68
+ hasModule: scoped.filter(d => d.modules?.length).length,
69
69
  hasNextStep: scoped.filter(d => d.hasNextStep).length,
70
70
  },
71
71
  checklists: {
package/src/validate.mjs CHANGED
@@ -102,8 +102,8 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
102
102
  doc.errors.push({ path: doc.path, level: 'error', message: '`modules` must be a YAML list when present.' });
103
103
  }
104
104
 
105
- if (config.moduleRequiredStatuses.has(doc.status) && !doc.module) {
106
- doc.errors.push({ path: doc.path, level: 'error', message: '`module` is required for active/ready/planned/blocked docs; use a real module, `platform`, or `none`.' });
105
+ if (config.moduleRequiredStatuses.has(doc.status) && !doc.modules?.length) {
106
+ doc.errors.push({ path: doc.path, level: 'error', message: '`module` is required for active/ready/planned/blocked docs; use a real module, `platform`, or `none`. Accepts singular `module:` or plural `modules:` list.' });
107
107
  }
108
108
 
109
109
  if (config.validSurfaces) {
package/src/handoff.mjs DELETED
@@ -1,59 +0,0 @@
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
- }