dotmd-cli 0.39.4 → 0.39.6
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 +1 -0
- package/bin/dotmd.mjs +19 -3
- package/package.json +1 -1
- package/src/doctor.mjs +5 -0
- package/src/frontmatter-fix.mjs +193 -0
- package/src/index.mjs +13 -0
- package/src/lifecycle.mjs +64 -10
- package/src/stats.mjs +39 -9
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
|
|
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
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/index.mjs
CHANGED
|
@@ -74,6 +74,18 @@ export function buildIndex(config, opts = {}) {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// Per-type counts (F6): same input docs, keyed by `type` first so callers
|
|
78
|
+
// can distinguish `plan/partial` (work shipped + tail deferred) from
|
|
79
|
+
// `doc/partial` (incomplete reference material). Untyped docs (pre-0.30
|
|
80
|
+
// corpora) land under `unknown` rather than getting dropped silently.
|
|
81
|
+
const countsByType = {};
|
|
82
|
+
for (const doc of transformedDocs) {
|
|
83
|
+
if (!doc.status) continue;
|
|
84
|
+
const type = doc.type || 'unknown';
|
|
85
|
+
if (!countsByType[type]) countsByType[type] = {};
|
|
86
|
+
countsByType[type][doc.status] = (countsByType[type][doc.status] ?? 0) + 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
77
89
|
if (!fast) {
|
|
78
90
|
if (config.indexPath) {
|
|
79
91
|
const indexCheck = checkIndex(transformedDocs, config);
|
|
@@ -95,6 +107,7 @@ export function buildIndex(config, opts = {}) {
|
|
|
95
107
|
generatedAt: new Date().toISOString(),
|
|
96
108
|
docs: transformedDocs,
|
|
97
109
|
countsByStatus,
|
|
110
|
+
countsByType,
|
|
98
111
|
warnings,
|
|
99
112
|
errors,
|
|
100
113
|
};
|
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
|
|
48
|
-
//
|
|
49
|
-
//
|
|
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
|
-
|
|
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');
|
package/src/stats.mjs
CHANGED
|
@@ -46,6 +46,7 @@ export function buildStats(index, config) {
|
|
|
46
46
|
generatedAt: new Date().toISOString(),
|
|
47
47
|
totalDocs: docs.length,
|
|
48
48
|
countsByStatus: index.countsByStatus,
|
|
49
|
+
countsByType: index.countsByType ?? {},
|
|
49
50
|
health: {
|
|
50
51
|
staleCount,
|
|
51
52
|
stalePct: pct(staleCount, nonArchived.length),
|
|
@@ -97,16 +98,45 @@ function _renderStats(stats, config) {
|
|
|
97
98
|
lines.push(bold(`Stats`) + dim(` — ${stats.totalDocs} docs`));
|
|
98
99
|
lines.push('');
|
|
99
100
|
|
|
100
|
-
// Status
|
|
101
|
+
// Status. With F6, render one line per type when 2+ types have docs so
|
|
102
|
+
// `plan/partial` and `doc/partial` (semantically distinct under per-type
|
|
103
|
+
// taxonomies) don't collapse into one number. Single-type corpora keep the
|
|
104
|
+
// existing flat line — no needless `Plans:` header on a plans-only repo.
|
|
101
105
|
lines.push(bold('Status'));
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
106
|
+
const typesWithDocs = Object.entries(stats.countsByType ?? {})
|
|
107
|
+
.filter(([, statusMap]) => Object.values(statusMap).some(n => n > 0));
|
|
108
|
+
if (typesWithDocs.length > 1) {
|
|
109
|
+
// Type order: respect config.validTypes declaration order; unknown last.
|
|
110
|
+
const typeOrder = [...(config.validTypes ?? [])];
|
|
111
|
+
const sortedTypes = typesWithDocs.sort(([a], [b]) => {
|
|
112
|
+
const ai = typeOrder.indexOf(a);
|
|
113
|
+
const bi = typeOrder.indexOf(b);
|
|
114
|
+
if (ai === -1 && bi === -1) return a.localeCompare(b);
|
|
115
|
+
if (ai === -1) return 1;
|
|
116
|
+
if (bi === -1) return -1;
|
|
117
|
+
return ai - bi;
|
|
118
|
+
});
|
|
119
|
+
const labelFor = (t) => t === 'unknown' ? 'Untyped' : (t.charAt(0).toUpperCase() + t.slice(1) + 's');
|
|
120
|
+
for (const [type, statusMap] of sortedTypes) {
|
|
121
|
+
const statusesForType = [
|
|
122
|
+
...config.statusOrder.filter(s => statusMap[s]),
|
|
123
|
+
...Object.keys(statusMap).filter(s => !config.statusOrder.includes(s)).sort(),
|
|
124
|
+
];
|
|
125
|
+
const parts = statusesForType
|
|
126
|
+
.filter(s => statusMap[s])
|
|
127
|
+
.map(s => `${s}: ${statusMap[s]}`);
|
|
128
|
+
lines.push(` ${dim(labelFor(type) + ':')} ${parts.join(' ')}`);
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
const allStatuses = [
|
|
132
|
+
...config.statusOrder.filter(s => stats.countsByStatus[s]),
|
|
133
|
+
...Object.keys(stats.countsByStatus).filter(s => !config.statusOrder.includes(s)).sort(),
|
|
134
|
+
];
|
|
135
|
+
const statusParts = allStatuses
|
|
136
|
+
.filter(s => stats.countsByStatus[s])
|
|
137
|
+
.map(s => `${s}: ${stats.countsByStatus[s]}`);
|
|
138
|
+
lines.push(' ' + statusParts.join(' '));
|
|
139
|
+
}
|
|
110
140
|
lines.push('');
|
|
111
141
|
|
|
112
142
|
// Health
|