dotmd-cli 0.39.4 → 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
@@ -359,6 +359,13 @@ Options:
359
359
  listing every doc/index path the command touched
360
360
  (deduped, sorted, repo-relative). Lets agents do
361
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).
362
369
  --dry-run, -n Preview changes without writing anything.`,
363
370
 
364
371
  coverage: `dotmd coverage — metadata coverage report
@@ -524,10 +531,19 @@ Modes:
524
531
  history; a "Migrated to v0.21 template" entry
525
532
  in their Version History would be misleading).
526
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.
527
543
 
528
544
  --apply (or --yes) opts into writes for the default auto-fix pass.
529
- Sub-modes (--statuses, --migrate-*) keep their existing contracts:
530
- 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.`,
531
547
 
532
548
  'fix-refs': `dotmd fix-refs — auto-fix broken reference paths
533
549
 
@@ -1065,7 +1081,7 @@ async function main() {
1065
1081
  // auto-fix path — sub-modes (--statuses, --migrate-template,
1066
1082
  // --migrate-prompts) keep their existing "write unless --dry-run"
1067
1083
  // contract because they're explicit one-shots the user opted into.
1068
- 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');
1069
1085
  const explicitApply = args.includes('--apply') || args.includes('--yes');
1070
1086
  const explicitDryRun = args.includes('--dry-run') || args.includes('-n');
1071
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.4",
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
  }
@@ -545,7 +583,8 @@ export function runArchive(argv, config, opts = {}) {
545
583
  const { dryRun, out = process.stdout } = opts;
546
584
  const noIndex = argv.includes('--no-index') || opts.noIndex;
547
585
  const showFiles = argv.includes('--show-files') || opts.showFiles;
548
- 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');
549
588
  const input = argv[0];
550
589
 
551
590
  if (!input) { die('Usage: dotmd archive <file>'); }
@@ -558,9 +597,10 @@ export function runArchive(argv, config, opts = {}) {
558
597
  if (relFromRoot.startsWith(config.archiveDir + '/') || relFromRoot.startsWith(config.archiveDir + path.sep)) { die(`Already archived: ${toRepoPath(filePath, config.repoRoot)}`); }
559
598
 
560
599
  const raw = readFileSync(filePath, 'utf8');
561
- const { frontmatter } = extractFrontmatter(raw);
600
+ const { frontmatter, body } = extractFrontmatter(raw);
562
601
  const parsed = parseSimpleFrontmatter(frontmatter);
563
602
  const oldStatus = asString(parsed.status) ?? 'unknown';
603
+ const closeoutAction = closeoutTemplate ? planCloseoutInjection(body) : null;
564
604
 
565
605
  const today = nowIso();
566
606
  const targetDir = path.join(archiveFileRoot, config.archiveDir);
@@ -570,6 +610,11 @@ export function runArchive(argv, config, opts = {}) {
570
610
 
571
611
  if (dryRun) {
572
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
+ }
573
618
  out.write(`${prefix} Would update frontmatter: status: ${oldStatus} → archived, updated: ${today}\n`);
574
619
  out.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
575
620
  if (config.indexPath && !noIndex) out.write(`${prefix} Would regenerate index\n`);
@@ -593,6 +638,10 @@ export function runArchive(argv, config, opts = {}) {
593
638
  return;
594
639
  }
595
640
 
641
+ if (closeoutAction?.action === 'inject') {
642
+ writeFileSync(filePath, `---\n${frontmatter}\n---\n${closeoutAction.newBody}`, 'utf8');
643
+ }
644
+
596
645
  updateFrontmatter(filePath, { status: 'archived', updated: today });
597
646
  appendVersionHistory(filePath, 'Archived.');
598
647
 
@@ -610,6 +659,11 @@ export function runArchive(argv, config, opts = {}) {
610
659
  if (!noIndex) regenIndex(config);
611
660
 
612
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
+ }
613
667
  if (selfRefsFixed) out.write('Updated references in archived file.\n');
614
668
  if (updatedRefCount > 0) out.write(`Updated references in ${updatedRefCount} file(s).\n`);
615
669
  if (config.indexPath && !noIndex) out.write('Index regenerated.\n');