dotmd-cli 0.39.3 → 0.39.5

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
@@ -577,6 +577,7 @@ dotmd bulk archive docs/old-*.md -n # preview
577
577
  ```bash
578
578
  dotmd pickup docs/plans/my-plan.md # set in-session + print body
579
579
  dotmd archive docs/plans/my-plan.md # fully shipped: archive + auto-release lease
580
+ dotmd archive docs/plans/my-plan.md --closeout-template # also inject ## Closeout skeleton
580
581
  dotmd release docs/plans/my-plan.md # need more work: release lease, flip to prior status
581
582
  dotmd status docs/plans/my-plan.md partial # shipped + tail deferred (reference successors in body)
582
583
  dotmd status docs/plans/my-plan.md awaiting # stuck on a human decision
package/bin/dotmd.mjs CHANGED
@@ -68,6 +68,7 @@ Create & Export:
68
68
  Setup:
69
69
  init Create starter config + docs directory
70
70
  statuses [list|add|set|remove|migrate] Manage per-project status taxonomy
71
+ help statuses Full status vocabulary + unstuck-actions + transitions
71
72
  watch [command] Re-run a command on file changes
72
73
  completions <shell> Shell completion script (bash, zsh)
73
74
  journal [--tail N|--errors|--by-command|--session id|--since iso|--json]
@@ -92,6 +93,90 @@ Options:
92
93
 
93
94
  Outputs the complete document index as JSON to stdout.`,
94
95
 
96
+ // Help topic accessed via \`dotmd help statuses\` (not a command — see dispatch
97
+ // below). Single-source-of-truth for the built-in status vocabulary across all
98
+ // three doc types. User-defined types/statuses live in config; introspect them
99
+ // with \`dotmd statuses list\`.
100
+ 'help:statuses': `dotmd help statuses — status vocabulary, unstuck-actions, and transitions
101
+
102
+ Every document has a \`type:\` field; each type has its own valid statuses.
103
+ Status validation is type-aware (type > root > global). To inspect or edit
104
+ the status taxonomy in a specific project, use \`dotmd statuses list\`.
105
+
106
+ ────────────────────────────────────────────────────────────────────
107
+ plan statuses (each maps to a distinct unstuck-action)
108
+
109
+ in-session A Claude session is working on it now.
110
+ Don't pick up unless you own it (auto-reattaches) or pass
111
+ --takeover. Stale lease cleanup: \`dotmd release --stale\`.
112
+
113
+ active Ready to be picked up.
114
+ \`dotmd pickup <file>\` → in-session.
115
+
116
+ planned Queued for future work, not yet ready to execute.
117
+ Transition to active when ready to start.
118
+
119
+ blocked External arrival wait — monitor.
120
+ Hardware, vendor delivery, third-party rollout. Quiet
121
+ (skipStale) — you can't speed it up by nagging.
122
+
123
+ partial Shipped + deferred tail — spawn successor plans.
124
+ Plan body should reference the successor plan(s). Quiet.
125
+
126
+ paused Started but stopped mid-work — re-evaluate to resume.
127
+ Short stale window (3 days) so resume-decisions don't decay.
128
+
129
+ awaiting Needs human input/decision — chase the answer.
130
+ NOT quiet — generates stale pressure so pings aren't forgotten.
131
+
132
+ queued-after Sequenced behind another plan — check predecessor.
133
+ Quiet. Can start once the predecessor ships.
134
+
135
+ archived No longer relevant; auto-moved to archive directory.
136
+
137
+ Canonical transitions:
138
+ active → in-session \`dotmd pickup <file>\`
139
+ in-session → active \`dotmd release <file>\`
140
+ in-session → partial \`dotmd status <file> partial\` (+ release)
141
+ in-session → awaiting \`dotmd status <file> awaiting\` (+ release)
142
+ any → archived \`dotmd archive <file>\`
143
+
144
+ ────────────────────────────────────────────────────────────────────
145
+ doc statuses
146
+
147
+ draft Work-in-progress reference doc.
148
+ active Living document, kept up-to-date.
149
+ review Awaiting peer review.
150
+ reference Stable canonical reference (excluded from stale checks).
151
+ deprecated Superseded but kept for history.
152
+ archived No longer relevant; moved to archive directory.
153
+
154
+ ────────────────────────────────────────────────────────────────────
155
+ prompt statuses
156
+
157
+ pending Ready for the next session to consume.
158
+ \`dotmd prompts use <file>\` prints body + archives atomically.
159
+ \`dotmd prompts next\` does the same for the oldest pending.
160
+
161
+ shelved Saved but hidden from \`hud\` / \`briefing\` / \`prompts next\`.
162
+ Still listed by \`dotmd prompts list\`.
163
+ \`dotmd prompts unshelve <file>\` → pending.
164
+
165
+ claimed Legacy intermediate state (atomic use → archived now).
166
+
167
+ archived Consumed prompt; body preserved in archive directory.
168
+
169
+ ────────────────────────────────────────────────────────────────────
170
+ Related commands:
171
+ dotmd statuses Inspect/manage per-project status taxonomy
172
+ dotmd status <f> <new> Transition a document's status
173
+ dotmd briefing See plans grouped by status
174
+ dotmd plans --status <s> Filter live plans by status
175
+ dotmd hud Two-line actionable triage (held / prompts / stuck)
176
+
177
+ Run \`dotmd statuses list --type plan\` to see the full set (including any
178
+ project-specific custom statuses) with their flags.`,
179
+
95
180
  completions: `dotmd completions <bash|zsh> — output shell completion script
96
181
 
97
182
  Add to your shell config:
@@ -241,6 +326,9 @@ Default plan statuses (each maps to a distinct unstuck-action):
241
326
  queued-after Sequenced behind another plan — check predecessor
242
327
  archived No longer relevant; auto-moved to archive directory
243
328
 
329
+ Run \`dotmd help statuses\` for the full vocabulary across all doc types
330
+ (plan, doc, prompt) plus canonical transitions and related commands.
331
+
244
332
  Use --dry-run (-n) to preview changes without writing anything.`,
245
333
 
246
334
  check: `dotmd check — validate frontmatter and references
@@ -271,6 +359,13 @@ Options:
271
359
  listing every doc/index path the command touched
272
360
  (deduped, sorted, repo-relative). Lets agents do
273
361
  \`git add\` with the exact set instead of guessing.
362
+ --closeout-template Inject a \`## Closeout\` skeleton into the plan body
363
+ before archiving — bullets for outcomes, key
364
+ commits, deferrals. No-op if a \`## Closeout\`
365
+ section already exists. Placed just before
366
+ \`## Version History\` if present, else at end
367
+ of body. Fill it in after archive (the archived
368
+ file is still editable).
274
369
  --dry-run, -n Preview changes without writing anything.`,
275
370
 
276
371
  coverage: `dotmd coverage — metadata coverage report
@@ -436,10 +531,19 @@ Modes:
436
531
  history; a "Migrated to v0.21 template" entry
437
532
  in their Version History would be misleading).
438
533
  --migrate-template --json Machine-readable result.
534
+ --frontmatter-fix Auto-fix the long-frontmatter warnings that
535
+ \`dotmd check\` flags: \`current_state\` >500 chars
536
+ or \`next_step\` >300 chars. Truncates the
537
+ frontmatter field at the nearest sentence
538
+ boundary under the target (300 / 200) and
539
+ appends the remainder to a \`## Current State\`
540
+ / \`## Next Step\` body section (created above
541
+ the first H2 if absent, appended otherwise).
542
+ Plans only; honors --dry-run.
439
543
 
440
544
  --apply (or --yes) opts into writes for the default auto-fix pass.
441
- Sub-modes (--statuses, --migrate-*) keep their existing contracts:
442
- they write by default and honor --dry-run.`,
545
+ Sub-modes (--statuses, --migrate-*, --frontmatter-fix) keep their
546
+ existing contracts: they write by default and honor --dry-run.`,
443
547
 
444
548
  'fix-refs': `dotmd fix-refs — auto-fix broken reference paths
445
549
 
@@ -630,8 +734,8 @@ sorted by status. Supports all query flags (--status, --module, --json,
630
734
  --sort, --group, etc.).
631
735
 
632
736
  Default plan statuses: in-session, active, planned, blocked, partial,
633
- paused, awaiting, queued-after, archived. Run \`dotmd status --help\` for
634
- the unstuck-action behind each one.
737
+ paused, awaiting, queued-after, archived. Run \`dotmd help statuses\` for
738
+ the unstuck-action behind each one and canonical transitions.
635
739
 
636
740
  Examples:
637
741
  dotmd plans # live plans (default)
@@ -671,6 +775,9 @@ Default prompt statuses: pending, shelved, claimed, archived.
671
775
 
672
776
  Examples:
673
777
  dotmd prompts # pending prompts (default)
778
+ dotmd prompts list --verbose # one row per prompt + target plan ref
779
+ # (from related_plans, parent_plan,
780
+ # or the first body .md link)
674
781
  dotmd prompts list --include-archived # all prompts including archived
675
782
  dotmd prompts list --status claimed # already-consumed prompts
676
783
  dotmd prompts --json # JSON output
@@ -825,6 +932,14 @@ async function main() {
825
932
  }
826
933
 
827
934
  if (command === 'help' || command === '--help' || command === '-h') {
935
+ const topic = args[1];
936
+ if (topic) {
937
+ const key = `help:${topic}`;
938
+ if (HELP[key]) { process.stdout.write(`${HELP[key]}\n`); return; }
939
+ if (HELP[topic]) { process.stdout.write(`${HELP[topic]}\n`); return; }
940
+ process.stderr.write(`Unknown help topic: ${topic}\n\nAvailable topics: statuses\nPer-command help: dotmd <cmd> --help\n`);
941
+ process.exit(1);
942
+ }
828
943
  process.stdout.write(`${HELP._main}\n`);
829
944
  return;
830
945
  }
@@ -927,7 +1042,7 @@ async function main() {
927
1042
  }
928
1043
  if (command === 'prompts') {
929
1044
  const { runPrompts } = await import('../src/prompts.mjs');
930
- await runPrompts(restArgs, config, { dryRun });
1045
+ await runPrompts(restArgs, config, { dryRun, verbose });
931
1046
  return;
932
1047
  }
933
1048
 
@@ -966,7 +1081,7 @@ async function main() {
966
1081
  // auto-fix path — sub-modes (--statuses, --migrate-template,
967
1082
  // --migrate-prompts) keep their existing "write unless --dry-run"
968
1083
  // contract because they're explicit one-shots the user opted into.
969
- const subMode = args.includes('--statuses') || args.includes('--migrate-template') || args.includes('--migrate-prompts');
1084
+ const subMode = args.includes('--statuses') || args.includes('--migrate-template') || args.includes('--migrate-prompts') || args.includes('--frontmatter-fix');
970
1085
  const explicitApply = args.includes('--apply') || args.includes('--yes');
971
1086
  const explicitDryRun = args.includes('--dry-run') || args.includes('-n');
972
1087
  const doctorDryRun = subMode ? dryRun : (explicitDryRun || !explicitApply);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.39.3",
3
+ "version": "0.39.5",
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",
package/src/doctor.mjs CHANGED
@@ -8,6 +8,7 @@ import { bold, dim, green, yellow } from './color.mjs';
8
8
  import { scaffoldClaudeCommands } from './claude-commands.mjs';
9
9
  import { runMigrateTemplate } from './migrate-template.mjs';
10
10
  import { runMigratePrompts } from './migrate-prompts.mjs';
11
+ import { runFrontmatterFix } from './frontmatter-fix.mjs';
11
12
 
12
13
  // Tunable thresholds for `dotmd doctor --statuses` conflation detection.
13
14
  // MIN_BUCKET_SIZE: only flag buckets with at least this many docs (small buckets aren't worth nagging).
@@ -51,6 +52,10 @@ export function runDoctor(argv, config, opts = {}) {
51
52
  runMigratePrompts(argv, config, opts);
52
53
  return;
53
54
  }
55
+ if (argv.includes('--frontmatter-fix')) {
56
+ runFrontmatterFix(config, opts);
57
+ return;
58
+ }
54
59
 
55
60
  const { dryRun } = opts;
56
61
  // 0.37.0 (F4): the mode banner makes it impossible to mistake a preview run
@@ -0,0 +1,193 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
+ import { asString, toRepoPath, escapeRegex, warn } from './util.mjs';
4
+ import { collectDocFiles } from './index.mjs';
5
+ import { bold, green, dim } from './color.mjs';
6
+
7
+ // Caps must stay in lockstep with the warnings emitted by validatePlanShape in
8
+ // src/validate.mjs — that's where the user first sees these numbers. Targets
9
+ // are deliberately under the cap so a fix-then-edit cycle doesn't reintroduce
10
+ // the warning on the next few-word touch-up.
11
+ const FIELDS = [
12
+ { name: 'current_state', cap: 500, target: 300, heading: '## Current State' },
13
+ { name: 'next_step', cap: 300, target: 200, heading: '## Next Step' },
14
+ ];
15
+
16
+ export function runFrontmatterFix(config, opts = {}) {
17
+ const { dryRun, out = process.stdout } = opts;
18
+ const allFiles = collectDocFiles(config);
19
+ const results = [];
20
+
21
+ for (const filePath of allFiles) {
22
+ const raw = readFileSync(filePath, 'utf8');
23
+ const { frontmatter: fm, body } = extractFrontmatter(raw);
24
+ if (!fm) continue;
25
+ const parsed = parseSimpleFrontmatter(fm);
26
+ const docType = asString(parsed.type);
27
+ // The warnings only fire for type: plan (validatePlanShape). Untyped docs
28
+ // skip the warning too, so skip them here as well — auto-injecting a
29
+ // `## Current State` into a non-plan doc would be surprising.
30
+ if (docType !== 'plan') continue;
31
+
32
+ const ops = [];
33
+ for (const { name, cap, target, heading } of FIELDS) {
34
+ const value = asString(parsed[name]);
35
+ if (!value || value.length <= cap) continue;
36
+ const { head, tail } = splitAtBoundary(value, target);
37
+ if (!tail) continue;
38
+ ops.push({ field: name, heading, before: value.length, head, tail });
39
+ }
40
+ if (ops.length === 0) continue;
41
+
42
+ let newFm = fm;
43
+ let newBody = body;
44
+ for (const op of ops) {
45
+ newFm = replaceFrontmatterField(newFm, op.field, op.head);
46
+ newBody = insertOrAppendSection(newBody, op.heading, op.tail);
47
+ }
48
+
49
+ if (!dryRun) {
50
+ writeFileSync(filePath, `---\n${newFm}\n---\n${newBody}`, 'utf8');
51
+ try { config.hooks.onLint?.({ path: toRepoPath(filePath, config.repoRoot), fixes: ops.map(o => ({ field: o.field, type: 'frontmatter-fix' })) }); } catch (err) { warn(`Hook 'onLint' threw: ${err.message}`); }
52
+ }
53
+ results.push({ filePath, repoPath: toRepoPath(filePath, config.repoRoot), ops });
54
+ }
55
+
56
+ const prefix = dryRun ? dim('[dry-run] ') : '';
57
+ const banner = dryRun ? dim(' [preview — run without --dry-run to write]') : '';
58
+ out.write(bold('dotmd doctor --frontmatter-fix') + banner + '\n\n');
59
+
60
+ if (results.length === 0) {
61
+ out.write(green('No over-cap fields found.') + '\n');
62
+ return { results };
63
+ }
64
+
65
+ out.write(`${results.length} file(s) with over-cap fields:\n\n`);
66
+ for (const r of results) {
67
+ out.write(` ${r.repoPath}\n`);
68
+ for (const op of r.ops) {
69
+ const moved = op.before - op.head.length;
70
+ out.write(dim(` ${prefix}${op.field}: ${op.before} → ${op.head.length} chars (moved ${moved} to \`${op.heading}\`)\n`));
71
+ }
72
+ }
73
+ out.write(`\n${prefix}${green(dryRun ? 'Would fix' : 'Fixed')}: ${results.length} file(s)\n`);
74
+ return { results };
75
+ }
76
+
77
+ // Splits at the last sentence-ending punctuation (`.!?` + space/newline/EOS)
78
+ // within `target` chars. If no good sentence boundary lands in the back half of
79
+ // the window, falls back to the last whitespace. If even that fails, a hard
80
+ // cut at `target` — the result still parses; readability just suffers.
81
+ export function splitAtBoundary(value, target) {
82
+ if (value.length <= target) return { head: value, tail: '' };
83
+
84
+ const windowStr = value.slice(0, target);
85
+ // Sentence boundary: greedy match capturing everything up to the last `.!?`
86
+ // followed by whitespace or end-of-string inside the window.
87
+ const sentenceRe = /^[\s\S]*[.!?](?=\s|$)/;
88
+ const sentenceMatch = windowStr.match(sentenceRe);
89
+ if (sentenceMatch && sentenceMatch[0].length >= Math.floor(target / 2)) {
90
+ const splitIdx = sentenceMatch[0].length;
91
+ return {
92
+ head: value.slice(0, splitIdx).trim(),
93
+ tail: value.slice(splitIdx).trim(),
94
+ };
95
+ }
96
+
97
+ const wsIdx = Math.max(windowStr.lastIndexOf(' '), windowStr.lastIndexOf('\n'));
98
+ if (wsIdx >= Math.floor(target / 2)) {
99
+ return {
100
+ head: value.slice(0, wsIdx).trim(),
101
+ tail: value.slice(wsIdx + 1).trim(),
102
+ };
103
+ }
104
+
105
+ return {
106
+ head: value.slice(0, target).trim(),
107
+ tail: value.slice(target).trim(),
108
+ };
109
+ }
110
+
111
+ // Replace a single frontmatter scalar with a folded block scalar (`key: >\n …`).
112
+ // Folded form is safe regardless of YAML-special chars in the value (colons,
113
+ // quotes, leading dashes) and matches what `parseSimpleFrontmatter` already
114
+ // reads. Consumes the existing key's continuation block if it was multi-line.
115
+ export function replaceFrontmatterField(fm, key, newValue) {
116
+ const lines = fm.split('\n');
117
+ const out = [];
118
+ let i = 0;
119
+ let replaced = false;
120
+
121
+ while (i < lines.length) {
122
+ const line = lines[i];
123
+ const keyRe = new RegExp(`^${escapeRegex(key)}:(.*)$`);
124
+ const match = !replaced && line.match(keyRe);
125
+ if (match) {
126
+ const rest = match[1].trim();
127
+ const isBlock = /^[>|][-+]?\s*$/.test(rest);
128
+ i++;
129
+ if (isBlock || rest === '') {
130
+ // Consume continuation: blank or indented lines until the next
131
+ // top-level key. The parser uses the same dedent rule (block scalar
132
+ // ends when indent returns to 0 and the line is non-blank).
133
+ while (i < lines.length) {
134
+ if (/^[A-Za-z0-9_-]+:/.test(lines[i])) break;
135
+ i++;
136
+ }
137
+ }
138
+ const folded = foldBlockScalar(newValue);
139
+ out.push(`${key}: >`);
140
+ for (const fLine of folded) out.push(` ${fLine}`);
141
+ replaced = true;
142
+ continue;
143
+ }
144
+ out.push(line);
145
+ i++;
146
+ }
147
+
148
+ if (!replaced) {
149
+ const folded = foldBlockScalar(newValue);
150
+ out.push(`${key}: >`);
151
+ for (const fLine of folded) out.push(` ${fLine}`);
152
+ }
153
+
154
+ return out.join('\n');
155
+ }
156
+
157
+ // Collapse whitespace into a single line — folded block scalar joins on a
158
+ // single space anyway, so writing one wide line keeps the diff tight and
159
+ // round-trips identically.
160
+ function foldBlockScalar(value) {
161
+ const single = value.replace(/\s+/g, ' ').trim();
162
+ return [single];
163
+ }
164
+
165
+ // Ensure a `## <heading>` section exists in the body and contains `content`.
166
+ // If the section is present (case-insensitive heading match), append the new
167
+ // content to its end. If absent, insert a new section just before the first
168
+ // H2 (so it lands above other content sections) — or append at end if no H2
169
+ // exists.
170
+ export function insertOrAppendSection(body, heading, content) {
171
+ const headingPattern = new RegExp(`^${escapeRegex(heading)}\\s*$`, 'mi');
172
+ const existing = body.match(headingPattern);
173
+ if (existing && existing.index !== undefined) {
174
+ const startIdx = existing.index + existing[0].length;
175
+ const after = body.slice(startIdx);
176
+ const nextHeaderRel = after.search(/\n#{1,2}\s+/);
177
+ const sectionEnd = nextHeaderRel >= 0 ? startIdx + nextHeaderRel : body.length;
178
+ const before = body.slice(0, sectionEnd).replace(/\s+$/, '');
179
+ const rest = body.slice(sectionEnd);
180
+ return `${before}\n\n${content}\n${rest.startsWith('\n') ? rest : '\n' + rest}`;
181
+ }
182
+
183
+ const firstH2 = body.match(/^##\s+/m);
184
+ if (firstH2 && firstH2.index !== undefined) {
185
+ const insertIdx = firstH2.index;
186
+ const before = body.slice(0, insertIdx).replace(/\s+$/, '');
187
+ const rest = body.slice(insertIdx);
188
+ return `${before}\n\n${heading}\n\n${content}\n\n${rest}`;
189
+ }
190
+
191
+ const trimmed = body.replace(/\s+$/, '');
192
+ return `${trimmed}\n\n${heading}\n\n${content}\n`;
193
+ }
package/src/lifecycle.mjs CHANGED
@@ -44,24 +44,62 @@ export function regenIndex(config) {
44
44
  }
45
45
 
46
46
  // Pick an archive destination that won't clobber an existing record. If
47
- // `<dir>/<basename>` is free, returns it unchanged; otherwise appends a UTC
48
- // timestamp (and a counter on the vanishingly rare same-second collision) so
49
- // both the prior archive and the current one survive.
47
+ // `<dir>/<basename>` is free, returns it unchanged; otherwise appends a
48
+ // numeric suffix (`-2`, `-3`, …) so the slug path mapping stays readable
49
+ // across re-archives (issue #10 finding #6). The pre-0.39.5 behavior used a
50
+ // UTC timestamp on collision, which made the second archive's path
51
+ // non-deterministic and harder to cross-reference against the original.
52
+ // Closeout skeleton injected by `dotmd archive --closeout-template`. Loose
53
+ // bullet shape (not sub-headings) matches the freeform prose-and-bullets style
54
+ // of existing in-repo closeouts — agents replace bullets with prose when that
55
+ // flows better. The HTML comment is the agent-facing prompt.
56
+ const CLOSEOUT_SKELETON = `## Closeout
57
+
58
+ <!-- Fill in below. Replace bullets with prose if that flows better. -->
59
+ - **Outcomes:**
60
+ - **Key commits:**
61
+ - **Deferrals:**
62
+ `;
63
+
64
+ // Plans where to inject the closeout skeleton without writing anything. Returns:
65
+ // { action: 'skip' } — section already present
66
+ // { action: 'inject', placement, newBody } — built body with skeleton inserted
67
+ // Placement: just before `## Version History` (so the closeout reads as work
68
+ // content, not appendix); falls back to end-of-body if VH is absent.
69
+ export function planCloseoutInjection(body) {
70
+ if (/^##\s+Closeout\s*$/mi.test(body)) {
71
+ return { action: 'skip' };
72
+ }
73
+ const vhMatch = body.match(/^##\s+Version History\s*$/mi);
74
+ if (vhMatch && vhMatch.index !== undefined) {
75
+ const before = body.slice(0, vhMatch.index).replace(/\s+$/, '');
76
+ const rest = body.slice(vhMatch.index);
77
+ return {
78
+ action: 'inject',
79
+ placement: 'before `## Version History`',
80
+ newBody: `${before}\n\n${CLOSEOUT_SKELETON}\n${rest}`,
81
+ };
82
+ }
83
+ const trimmed = body.replace(/\s+$/, '');
84
+ return {
85
+ action: 'inject',
86
+ placement: 'end of body',
87
+ newBody: `${trimmed}\n\n${CLOSEOUT_SKELETON}`,
88
+ };
89
+ }
90
+
50
91
  function uniqueArchiveTarget(targetDir, basename) {
51
92
  const base = path.join(targetDir, basename);
52
93
  if (!existsSync(base)) return base;
53
94
 
54
95
  const ext = path.extname(basename);
55
96
  const stem = basename.slice(0, -ext.length);
56
- const d = new Date();
57
- const pad = (n) => String(n).padStart(2, '0');
58
- const stamp = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
59
97
 
60
- let target = path.join(targetDir, `${stem}-${stamp}${ext}`);
61
98
  let n = 2;
99
+ let target = path.join(targetDir, `${stem}-${n}${ext}`);
62
100
  while (existsSync(target)) {
63
- target = path.join(targetDir, `${stem}-${stamp}-${n}${ext}`);
64
101
  n++;
102
+ target = path.join(targetDir, `${stem}-${n}${ext}`);
65
103
  }
66
104
  return target;
67
105
  }
@@ -246,7 +284,15 @@ export async function runPickup(argv, config, opts = {}) {
246
284
  }
247
285
 
248
286
  const pickupable = new Set(['active', 'planned', 'in-session']);
249
- if (oldStatus && !pickupable.has(oldStatus)) die(`Cannot pick up a plan with status '${oldStatus}'. Must be active or planned.\n ${repoPath}`);
287
+ if (oldStatus && !pickupable.has(oldStatus)) {
288
+ die(
289
+ `Cannot pick up a plan with status '${oldStatus}'. Must be active or planned.\n` +
290
+ ` ${repoPath}\n` +
291
+ `\n` +
292
+ `Recover with:\n` +
293
+ ` dotmd status ${repoPath} active && dotmd pickup ${repoPath}`,
294
+ );
295
+ }
250
296
 
251
297
  const today = nowIso();
252
298
  const leaseOldStatus = oldStatus === 'in-session' ? 'active' : (oldStatus ?? 'active');
@@ -537,7 +583,8 @@ export function runArchive(argv, config, opts = {}) {
537
583
  const { dryRun, out = process.stdout } = opts;
538
584
  const noIndex = argv.includes('--no-index') || opts.noIndex;
539
585
  const showFiles = argv.includes('--show-files') || opts.showFiles;
540
- argv = argv.filter(a => a !== '--no-index' && a !== '--show-files');
586
+ const closeoutTemplate = argv.includes('--closeout-template');
587
+ argv = argv.filter(a => a !== '--no-index' && a !== '--show-files' && a !== '--closeout-template');
541
588
  const input = argv[0];
542
589
 
543
590
  if (!input) { die('Usage: dotmd archive <file>'); }
@@ -550,9 +597,10 @@ export function runArchive(argv, config, opts = {}) {
550
597
  if (relFromRoot.startsWith(config.archiveDir + '/') || relFromRoot.startsWith(config.archiveDir + path.sep)) { die(`Already archived: ${toRepoPath(filePath, config.repoRoot)}`); }
551
598
 
552
599
  const raw = readFileSync(filePath, 'utf8');
553
- const { frontmatter } = extractFrontmatter(raw);
600
+ const { frontmatter, body } = extractFrontmatter(raw);
554
601
  const parsed = parseSimpleFrontmatter(frontmatter);
555
602
  const oldStatus = asString(parsed.status) ?? 'unknown';
603
+ const closeoutAction = closeoutTemplate ? planCloseoutInjection(body) : null;
556
604
 
557
605
  const today = nowIso();
558
606
  const targetDir = path.join(archiveFileRoot, config.archiveDir);
@@ -562,6 +610,11 @@ export function runArchive(argv, config, opts = {}) {
562
610
 
563
611
  if (dryRun) {
564
612
  const prefix = dim('[dry-run]');
613
+ if (closeoutAction?.action === 'inject') {
614
+ out.write(`${prefix} Would inject \`## Closeout\` template (${closeoutAction.placement})\n`);
615
+ } else if (closeoutAction?.action === 'skip') {
616
+ out.write(`${prefix} \`## Closeout\` section already present — no injection\n`);
617
+ }
565
618
  out.write(`${prefix} Would update frontmatter: status: ${oldStatus} → archived, updated: ${today}\n`);
566
619
  out.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
567
620
  if (config.indexPath && !noIndex) out.write(`${prefix} Would regenerate index\n`);
@@ -572,9 +625,23 @@ export function runArchive(argv, config, opts = {}) {
572
625
  if (refCount > 0) {
573
626
  out.write(`${prefix} Would update references in ${refCount} file(s)\n`);
574
627
  }
628
+
629
+ // Preview lease release (only if a lease exists for this plan)
630
+ if (readLeases(config)[oldRepoPath]) {
631
+ out.write(`${prefix} Would release in-session lease: ${oldRepoPath}\n`);
632
+ }
633
+
634
+ // Preview onArchive hook fire
635
+ if (config.hooks?.onArchive) {
636
+ out.write(`${prefix} Would fire hook: onArchive\n`);
637
+ }
575
638
  return;
576
639
  }
577
640
 
641
+ if (closeoutAction?.action === 'inject') {
642
+ writeFileSync(filePath, `---\n${frontmatter}\n---\n${closeoutAction.newBody}`, 'utf8');
643
+ }
644
+
578
645
  updateFrontmatter(filePath, { status: 'archived', updated: today });
579
646
  appendVersionHistory(filePath, 'Archived.');
580
647
 
@@ -592,6 +659,11 @@ export function runArchive(argv, config, opts = {}) {
592
659
  if (!noIndex) regenIndex(config);
593
660
 
594
661
  out.write(`${green('Archived')}: ${oldRepoPath} → ${newRepoPath}\n`);
662
+ if (closeoutAction?.action === 'inject') {
663
+ out.write(`Injected \`## Closeout\` template — fill in: outcomes, key commits, deferrals.\n`);
664
+ } else if (closeoutAction?.action === 'skip') {
665
+ out.write(dim('(closeout template skipped — `## Closeout` section already present)\n'));
666
+ }
595
667
  if (selfRefsFixed) out.write('Updated references in archived file.\n');
596
668
  if (updatedRefCount > 0) out.write(`Updated references in ${updatedRefCount} file(s).\n`);
597
669
  if (config.indexPath && !noIndex) out.write('Index regenerated.\n');
package/src/prompts.mjs CHANGED
@@ -29,12 +29,17 @@ export async function runPrompts(argv, config, opts = {}) {
29
29
  }
30
30
  }
31
31
 
32
- function runPromptsList(argv, config) {
32
+ function runPromptsList(argv, config, opts = {}) {
33
33
  const index = buildIndex(config);
34
34
  const hasStatusFlag = argv.includes('--status');
35
35
  const includeArchived = argv.includes('--include-archived');
36
36
  const sub = argv[0];
37
37
 
38
+ if (opts.verbose && !argv.includes('--json')) {
39
+ renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived });
40
+ return;
41
+ }
42
+
38
43
  let defaults;
39
44
  let extras = argv;
40
45
  if (sub === 'status') {
@@ -48,6 +53,67 @@ function runPromptsList(argv, config) {
48
53
  runQuery(index, [...defaults, ...extras], config, { preset: 'prompts' });
49
54
  }
50
55
 
56
+ // Resolve a prompt's "target plan" for `prompts list --verbose`. Order:
57
+ // 1. frontmatter `related_plans:` (first entry — assumed plan slug)
58
+ // 2. frontmatter `parent_plan:`
59
+ // 3. first body markdown link to a .md file
60
+ // Returns a repo-relative display path or null.
61
+ function findPromptTarget(promptDoc, config) {
62
+ const refs = promptDoc.refFields ?? {};
63
+ const fmTargets = [...(refs.related_plans ?? []), ...(refs.parent_plan ?? [])];
64
+ for (const t of fmTargets) {
65
+ if (typeof t === 'string' && t.trim()) return slugToPlanPath(t.trim(), config);
66
+ }
67
+
68
+ const links = promptDoc.bodyLinks ?? [];
69
+ const mdLink = links.find(l => /\.md(?:#|$)/.test(l.href ?? ''));
70
+ if (mdLink) return resolveBodyLink(mdLink.href, promptDoc.path);
71
+ return null;
72
+ }
73
+
74
+ // Plan slugs in frontmatter (e.g. `related_plans: [foo-bar]`) resolve to
75
+ // <docs-root>/plans/<slug>.md.
76
+ function slugToPlanPath(s, config) {
77
+ const cleaned = s.replace(/#.*$/, '').replace(/^\.\//, '');
78
+ if (cleaned.includes('/') || cleaned.endsWith('.md')) return cleaned;
79
+ return `${config.docsRootPrefix || 'docs/'}plans/${cleaned}.md`;
80
+ }
81
+
82
+ // Resolve a markdown body link relative to the prompt's location so e.g.
83
+ // `../plans/foo.md` from docs/prompts/x.md → docs/plans/foo.md.
84
+ function resolveBodyLink(link, promptRepoPath) {
85
+ const cleaned = link.replace(/#.*$/, '');
86
+ if (cleaned.startsWith('/')) return cleaned.replace(/^\/+/, '');
87
+ const promptDir = path.dirname(promptRepoPath);
88
+ return path.normalize(path.join(promptDir, cleaned));
89
+ }
90
+
91
+ function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived }) {
92
+ let prompts = index.docs.filter(d => d.type === 'prompt');
93
+ if (!hasStatusFlag && !includeArchived) {
94
+ prompts = prompts.filter(d => d.status !== 'archived');
95
+ }
96
+ if (prompts.length === 0) {
97
+ process.stdout.write('No prompts.\n');
98
+ return;
99
+ }
100
+
101
+ prompts.sort((a, b) => (b.updated ?? '').localeCompare(a.updated ?? ''));
102
+
103
+ const counts = {};
104
+ for (const p of prompts) counts[p.status ?? 'unknown'] = (counts[p.status ?? 'unknown'] ?? 0) + 1;
105
+ const summary = Object.entries(counts).map(([s, n]) => `${n} ${s}`).join(' · ');
106
+ process.stdout.write(`${prompts.length} prompt${prompts.length === 1 ? '' : 's'} · ${summary}\n\n`);
107
+
108
+ for (const p of prompts) {
109
+ const slug = path.basename(p.path, '.md');
110
+ const target = findPromptTarget(p, config);
111
+ const status = (p.status ?? 'unknown').toUpperCase();
112
+ const arrow = target ? ` ${dim('→')} ${target}` : ` ${dim('→ (no target plan)')}`;
113
+ process.stdout.write(` ${green(slug)} [${status}]\n${arrow}\n`);
114
+ }
115
+ }
116
+
51
117
  function pendingPromptsOldestFirst(config) {
52
118
  const index = buildIndex(config);
53
119
  const prompts = index.docs.filter(d => d.type === 'prompt' && d.status === 'pending');
@@ -139,7 +205,15 @@ function consumePrompt(filePath, config, opts) {
139
205
  }
140
206
 
141
207
  if (dryRun) {
142
- process.stderr.write(`${dim('[dry-run]')} Would emit body and archive: ${repoPath} (${status ?? 'unknown'} → archived)\n`);
208
+ const prefix = dim('[dry-run]');
209
+ process.stderr.write(`${prefix} Would emit body and archive: ${repoPath} (${status ?? 'unknown'} → archived)\n`);
210
+ const bytes = Buffer.byteLength(body, 'utf8');
211
+ const lines = body.split('\n').length;
212
+ process.stderr.write(`${prefix} body preview (${bytes}B, ${lines} lines):\n`);
213
+ process.stderr.write(`${dim('---8<---')}\n`);
214
+ process.stderr.write(body);
215
+ if (!body.endsWith('\n')) process.stderr.write('\n');
216
+ process.stderr.write(`${dim('--->8---')}\n`);
143
217
  runArchive([filePath], config, { dryRun: true, noIndex, out: process.stderr });
144
218
  return;
145
219
  }