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 +1 -0
- package/bin/dotmd.mjs +121 -6
- package/package.json +1 -1
- package/src/doctor.mjs +5 -0
- package/src/frontmatter-fix.mjs +193 -0
- package/src/lifecycle.mjs +83 -11
- package/src/prompts.mjs +76 -2
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
|
|
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
|
|
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
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
|
|
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
|
}
|
|
@@ -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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|