dotmd-cli 0.25.0 → 0.27.0
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/bin/dotmd.mjs +37 -25
- package/package.json +1 -1
- package/src/claude-commands.mjs +10 -2
- package/src/frontmatter.mjs +104 -6
- package/src/hud.mjs +38 -1
- package/src/index.mjs +2 -1
- package/src/lifecycle.mjs +65 -9
- package/src/prompts.mjs +135 -0
- package/src/validate.mjs +25 -0
package/bin/dotmd.mjs
CHANGED
|
@@ -21,7 +21,8 @@ View & Query:
|
|
|
21
21
|
focus [status] [--json] Detailed view for one status group
|
|
22
22
|
query [filters] [--json] Filtered search (--status, --keyword, --stale, etc.)
|
|
23
23
|
plans Live plans (excludes archived; --include-archived for all)
|
|
24
|
-
prompts
|
|
24
|
+
prompts [list|next|use|archive|new]
|
|
25
|
+
Manage saved prompts (default: list pending)
|
|
25
26
|
stale Stale docs (preset)
|
|
26
27
|
actionable Docs with next steps (preset)
|
|
27
28
|
|
|
@@ -260,24 +261,29 @@ Shows detailed info for all docs matching the given status (default: active).
|
|
|
260
261
|
Options:
|
|
261
262
|
--json Output as JSON`,
|
|
262
263
|
|
|
263
|
-
hud: `dotmd hud —
|
|
264
|
+
hud: `dotmd hud — actionable triage for session start
|
|
264
265
|
|
|
265
|
-
Prints up to
|
|
266
|
+
Prints up to four lines, in order:
|
|
266
267
|
▶ You hold N plans: <slugs> (leases owned by current session)
|
|
267
268
|
▶ N handoffs queued: <slugs> (resume-prompt sidecars waiting)
|
|
269
|
+
▶ N pending prompts: <slugs> (saved prompts in docs/prompts/)
|
|
268
270
|
⚠ N stuck leases >24h (suggest \`dotmd release --stale\`)
|
|
269
271
|
|
|
270
|
-
Silent when all
|
|
272
|
+
Silent when all four are empty — designed for SessionStart hooks where
|
|
271
273
|
zero noise is the right default. Distinct from \`dotmd briefing\`, which
|
|
272
274
|
dumps the full plan-status pipeline and per-plan next_step bodies (kilobytes
|
|
273
275
|
on large repos). Use hud for ergonomic session boot; use briefing for
|
|
274
276
|
explicit "give me the full picture."
|
|
275
277
|
|
|
278
|
+
The pending-prompts line tells Claude to consume them via
|
|
279
|
+
\`dotmd prompts use <file>\` rather than reading/cat'ing — that atomically
|
|
280
|
+
prints the body and archives the prompt so it cannot be double-consumed.
|
|
281
|
+
|
|
276
282
|
Recommended SessionStart hook (in ~/.claude/settings.json):
|
|
277
283
|
"SessionStart": [{ "hooks": [{ "type": "command", "command": "dotmd hud", "timeout": 5 }] }]
|
|
278
284
|
|
|
279
285
|
Options:
|
|
280
|
-
--json Output as JSON ({ owned, queued, stale })`,
|
|
286
|
+
--json Output as JSON ({ owned, queued, prompts, stale })`,
|
|
281
287
|
|
|
282
288
|
briefing: `dotmd briefing — compact summary for session start
|
|
283
289
|
|
|
@@ -555,19 +561,36 @@ Examples:
|
|
|
555
561
|
dotmd plans --group module # plans grouped by module
|
|
556
562
|
dotmd plans --json # JSON output`,
|
|
557
563
|
|
|
558
|
-
prompts: `dotmd prompts —
|
|
564
|
+
prompts: `dotmd prompts — manage saved prompts (subcommand namespace)
|
|
565
|
+
|
|
566
|
+
Prompts are documents with \`type: prompt\`, typically saved under
|
|
567
|
+
docs/prompts/. They seed future Claude sessions; consuming a prompt
|
|
568
|
+
prints its body to stdout and atomically archives it (one-shot).
|
|
559
569
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
570
|
+
Subcommands:
|
|
571
|
+
list List pending prompts (default)
|
|
572
|
+
next Consume the oldest pending prompt:
|
|
573
|
+
print body to stdout, flip status to archived
|
|
574
|
+
use <file> Consume a specific prompt (same as next, but
|
|
575
|
+
targets <file> instead of picking oldest)
|
|
576
|
+
archive <file> Archive a prompt without printing its body
|
|
577
|
+
new <slug> [body] Create a new prompt (alias for
|
|
578
|
+
\`dotmd new prompt <slug> [body]\`)
|
|
563
579
|
|
|
564
580
|
Default prompt statuses: pending, claimed, archived.
|
|
565
581
|
|
|
566
582
|
Examples:
|
|
567
583
|
dotmd prompts # pending prompts (default)
|
|
568
|
-
dotmd prompts --include-archived
|
|
569
|
-
dotmd prompts --status claimed
|
|
570
|
-
dotmd prompts --json # JSON output
|
|
584
|
+
dotmd prompts list --include-archived # all prompts including archived
|
|
585
|
+
dotmd prompts list --status claimed # already-consumed prompts
|
|
586
|
+
dotmd prompts --json # JSON output
|
|
587
|
+
|
|
588
|
+
claude "$(dotmd prompts next)" # consume oldest pending + run claude
|
|
589
|
+
claude "$(dotmd prompts use docs/prompts/foo.md)"
|
|
590
|
+
|
|
591
|
+
dotmd prompts next --dry-run # preview without consuming
|
|
592
|
+
dotmd prompts archive docs/prompts/old.md
|
|
593
|
+
dotmd prompts new my-prompt "Body text here"`,
|
|
571
594
|
|
|
572
595
|
stale: `dotmd stale — list stale documents
|
|
573
596
|
|
|
@@ -769,19 +792,8 @@ async function main() {
|
|
|
769
792
|
return;
|
|
770
793
|
}
|
|
771
794
|
if (command === 'prompts') {
|
|
772
|
-
const {
|
|
773
|
-
|
|
774
|
-
const index = buildIndex(config);
|
|
775
|
-
const sub = restArgs[0];
|
|
776
|
-
let defaults;
|
|
777
|
-
let extras = restArgs;
|
|
778
|
-
if (sub === 'status') {
|
|
779
|
-
defaults = ['--type', 'prompt', '--exclude-archived', '--sort', 'status', '--all'];
|
|
780
|
-
extras = restArgs.slice(1);
|
|
781
|
-
} else {
|
|
782
|
-
defaults = ['--type', 'prompt', '--exclude-archived', '--sort', 'updated', '--limit', '10'];
|
|
783
|
-
}
|
|
784
|
-
runQuery(index, [...defaults, ...extras], config, { preset: 'prompts' });
|
|
795
|
+
const { runPrompts } = await import('../src/prompts.mjs');
|
|
796
|
+
await runPrompts(restArgs, config, { dryRun });
|
|
785
797
|
return;
|
|
786
798
|
}
|
|
787
799
|
|
package/package.json
CHANGED
package/src/claude-commands.mjs
CHANGED
|
@@ -23,7 +23,9 @@ function generatePlansCommand(config) {
|
|
|
23
23
|
lines.push('- `dotmd unblocks <file>` — what depends on / is blocked by a plan');
|
|
24
24
|
lines.push('- `dotmd next` — ready plans with next steps (what to promote)');
|
|
25
25
|
lines.push('- `dotmd new plan <name>` — scaffold with full phase structure');
|
|
26
|
-
lines.push('- `dotmd new
|
|
26
|
+
lines.push('- `dotmd prompts new <name> "<body>"` — save a resume-prompt to docs/prompts/');
|
|
27
|
+
lines.push('- `dotmd prompts next` — consume oldest pending prompt (prints body, auto-archives)');
|
|
28
|
+
lines.push('- `dotmd prompts use <file>` — consume a specific prompt (prints body, auto-archives)');
|
|
27
29
|
lines.push('- `dotmd archive <file>` — archive with auto ref-fixing (both directions)');
|
|
28
30
|
lines.push('- `dotmd bulk archive <files>` — archive multiple at once');
|
|
29
31
|
lines.push('- `dotmd status <file> <status>` — transition status');
|
|
@@ -39,6 +41,8 @@ function generatePlansCommand(config) {
|
|
|
39
41
|
lines.push('If the user asks to change a plan\'s status, use `dotmd status <file> <status>`.');
|
|
40
42
|
lines.push('If the user asks to archive a plan, use `dotmd archive <file>`.');
|
|
41
43
|
lines.push('');
|
|
44
|
+
lines.push('**Saved prompts (`docs/prompts/*.md`):** if the user references a file under `docs/prompts/` — e.g. "resume via docs/prompts/foo.md", "use this prompt", "load that one" — consume it with `dotmd prompts use <file>` (atomically prints the body and archives the prompt so it cannot be double-consumed). Do NOT `cat` it, read it with the file-reading tool, or copy its body into chat. To pick the oldest pending prompt without naming a file, use `dotmd prompts next`.');
|
|
45
|
+
lines.push('');
|
|
42
46
|
|
|
43
47
|
return lines.join('\n');
|
|
44
48
|
}
|
|
@@ -118,7 +122,9 @@ function generateDocsCommand(config) {
|
|
|
118
122
|
lines.push('Lifecycle:');
|
|
119
123
|
lines.push('- `dotmd new plan <name>` — scaffold new plan');
|
|
120
124
|
lines.push('- `dotmd new doc <name>` — scaffold reference doc');
|
|
121
|
-
lines.push('- `dotmd new
|
|
125
|
+
lines.push('- `dotmd prompts new <name> "<body>"` — save a resume-prompt');
|
|
126
|
+
lines.push('- `dotmd prompts next` — consume oldest pending prompt (prints body, auto-archives)');
|
|
127
|
+
lines.push('- `dotmd prompts use <file>` — consume a specific prompt (prints body, auto-archives)');
|
|
122
128
|
lines.push('- `dotmd status <file> <status>` — transition status');
|
|
123
129
|
lines.push('- `dotmd archive <file>` — archive with auto ref-fixing');
|
|
124
130
|
lines.push('- `dotmd bulk archive <files>` — archive multiple at once');
|
|
@@ -127,6 +133,8 @@ function generateDocsCommand(config) {
|
|
|
127
133
|
lines.push('- `dotmd fix-refs` — repair broken references and body links');
|
|
128
134
|
lines.push('- `dotmd rename <old> <new>` — rename doc + update all references');
|
|
129
135
|
lines.push('');
|
|
136
|
+
lines.push('**Saved prompts (`docs/prompts/*.md`):** if the user references a file under `docs/prompts/` — e.g. "resume via docs/prompts/foo.md", "use this prompt" — consume it with `dotmd prompts use <file>` (prints the body and archives atomically). Do NOT `cat` it or read it with the file-reading tool. To pick the oldest pending prompt without naming a file, use `dotmd prompts next`.');
|
|
137
|
+
lines.push('');
|
|
130
138
|
|
|
131
139
|
return lines.join('\n');
|
|
132
140
|
}
|
package/src/frontmatter.mjs
CHANGED
|
@@ -26,15 +26,22 @@ export function replaceFrontmatter(raw, newFrontmatter) {
|
|
|
26
26
|
// structural issues (e.g. duplicate keys) — caller decides whether to surface
|
|
27
27
|
// them. Default behavior is unchanged: keep first occurrence of a duplicate
|
|
28
28
|
// key, ignore subsequent ones.
|
|
29
|
+
//
|
|
30
|
+
// Supports:
|
|
31
|
+
// inline scalars `key: value`
|
|
32
|
+
// arrays `key:\n - item\n - item`
|
|
33
|
+
// folded block scalar `key: >\n one line\n continues` → "one line continues"
|
|
34
|
+
// literal block scalar `key: |\n one\n two` → "one\ntwo"
|
|
35
|
+
// chomping indicators `>-`, `|-` (strip), `>+`, `|+` (keep), default (clip to one trailing \n)
|
|
29
36
|
export function parseSimpleFrontmatter(text, warnings) {
|
|
30
37
|
const data = {};
|
|
31
38
|
const seenDupKeys = new Set();
|
|
32
39
|
let currentArrayKey = null;
|
|
33
|
-
|
|
40
|
+
const lines = text.split('\n');
|
|
34
41
|
|
|
35
|
-
for (
|
|
36
|
-
lineNum
|
|
37
|
-
const line =
|
|
42
|
+
for (let i = 0; i < lines.length; i++) {
|
|
43
|
+
const lineNum = i + 1;
|
|
44
|
+
const line = lines[i].replace(/\r$/, '');
|
|
38
45
|
if (!line.trim()) continue;
|
|
39
46
|
|
|
40
47
|
const keyMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
@@ -49,11 +56,25 @@ export function parseSimpleFrontmatter(text, warnings) {
|
|
|
49
56
|
}
|
|
50
57
|
continue;
|
|
51
58
|
}
|
|
52
|
-
|
|
59
|
+
|
|
60
|
+
const trimmedValue = rawValue.trim();
|
|
61
|
+
|
|
62
|
+
// Block scalar marker: > or | with optional chomping indicator (-/+).
|
|
63
|
+
const blockMatch = trimmedValue.match(/^([>|])([-+])?\s*$/);
|
|
64
|
+
if (blockMatch) {
|
|
65
|
+
const [, style, chomp] = blockMatch;
|
|
66
|
+
const { value, consumed } = collectBlockScalar(lines, i + 1, style, chomp);
|
|
67
|
+
data[key] = value;
|
|
68
|
+
i += consumed;
|
|
69
|
+
currentArrayKey = null;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!trimmedValue) {
|
|
53
74
|
data[key] = [];
|
|
54
75
|
currentArrayKey = key;
|
|
55
76
|
} else {
|
|
56
|
-
data[key] = parseScalar(
|
|
77
|
+
data[key] = parseScalar(trimmedValue);
|
|
57
78
|
currentArrayKey = null;
|
|
58
79
|
}
|
|
59
80
|
continue;
|
|
@@ -71,6 +92,83 @@ export function parseSimpleFrontmatter(text, warnings) {
|
|
|
71
92
|
return data;
|
|
72
93
|
}
|
|
73
94
|
|
|
95
|
+
// Reads lines starting at startIdx and collects them as a YAML block scalar
|
|
96
|
+
// body. Stops when a line is encountered that is dedented to (or past) the
|
|
97
|
+
// key's indent level (zero in our frontmatter context). Returns the joined
|
|
98
|
+
// string and the number of lines consumed (for the caller to advance `i`).
|
|
99
|
+
function collectBlockScalar(lines, startIdx, style, chomp) {
|
|
100
|
+
// Determine content indent from the first non-blank line.
|
|
101
|
+
let contentIndent = null;
|
|
102
|
+
const collected = [];
|
|
103
|
+
let i = startIdx;
|
|
104
|
+
for (; i < lines.length; i++) {
|
|
105
|
+
const line = lines[i].replace(/\r$/, '');
|
|
106
|
+
if (line.trim() === '') {
|
|
107
|
+
collected.push(''); // preserve as blank for folding/literal rules
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const indent = line.match(/^(\s*)/)[1].length;
|
|
111
|
+
if (contentIndent === null) {
|
|
112
|
+
// First non-blank content line establishes the indent.
|
|
113
|
+
// If it's at column 0, that's a sibling key — block was empty.
|
|
114
|
+
if (indent === 0) break;
|
|
115
|
+
contentIndent = indent;
|
|
116
|
+
collected.push(line.slice(contentIndent));
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (indent < contentIndent) {
|
|
120
|
+
// Dedented past content level — end of block scalar.
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
collected.push(line.slice(contentIndent));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Strip trailing blank lines we accidentally captured before the dedent
|
|
127
|
+
// (they belong to the document, not the scalar's chomping window).
|
|
128
|
+
while (collected.length > 0 && collected[collected.length - 1] === '') {
|
|
129
|
+
collected.pop();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Join according to style.
|
|
133
|
+
let value;
|
|
134
|
+
if (style === '|') {
|
|
135
|
+
// Literal: each line preserved as-is, joined with \n.
|
|
136
|
+
value = collected.join('\n');
|
|
137
|
+
} else {
|
|
138
|
+
// Folded: single newline between non-blank lines folds to space;
|
|
139
|
+
// a blank-line run between content becomes a single \n (paragraph break).
|
|
140
|
+
value = '';
|
|
141
|
+
let hasContent = false;
|
|
142
|
+
let prevWasBlank = false;
|
|
143
|
+
for (const line of collected) {
|
|
144
|
+
if (line === '') {
|
|
145
|
+
if (hasContent && !prevWasBlank) value += '\n';
|
|
146
|
+
prevWasBlank = true;
|
|
147
|
+
} else {
|
|
148
|
+
if (hasContent && !prevWasBlank) value += ' ';
|
|
149
|
+
value += line;
|
|
150
|
+
hasContent = true;
|
|
151
|
+
prevWasBlank = false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Apply chomping: default = clip (single trailing \n if any content),
|
|
157
|
+
// '-' = strip (no trailing \n), '+' = keep (preserve all).
|
|
158
|
+
if (chomp === '-') {
|
|
159
|
+
value = value.replace(/\n+$/, '');
|
|
160
|
+
} else if (chomp === '+') {
|
|
161
|
+
if (!value.endsWith('\n')) value = value + '\n';
|
|
162
|
+
} else {
|
|
163
|
+
// Clip: strip multiple trailing newlines down to none for inline content
|
|
164
|
+
// (matches the practical expectation that `key: >` yields a string without
|
|
165
|
+
// trailing whitespace artifacts when used inline).
|
|
166
|
+
value = value.replace(/\n+$/, '');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { value, consumed: i - startIdx };
|
|
170
|
+
}
|
|
171
|
+
|
|
74
172
|
function parseScalar(value) {
|
|
75
173
|
let unquoted = value;
|
|
76
174
|
if (value.length > 1 &&
|
package/src/hud.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { readLeases, findStaleLeases, currentSessionId } from './lease.mjs';
|
|
3
4
|
import { listQueuedHandoffs } from './handoff.mjs';
|
|
5
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
6
|
+
import { asString, toRepoPath } from './util.mjs';
|
|
4
7
|
import { green, yellow, dim } from './color.mjs';
|
|
5
8
|
|
|
6
9
|
const MAX_PREVIEW = 5;
|
|
@@ -13,14 +16,45 @@ function previewList(items, max = MAX_PREVIEW) {
|
|
|
13
16
|
return slugs.join(', ') + more;
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
function findPendingPrompts(config) {
|
|
20
|
+
const roots = config.docsRoots || (config.docsRoot ? [config.docsRoot] : []);
|
|
21
|
+
const archiveDir = config.archiveDir || 'archived';
|
|
22
|
+
const found = [];
|
|
23
|
+
|
|
24
|
+
for (const root of roots) {
|
|
25
|
+
const dir = path.join(root, 'prompts');
|
|
26
|
+
if (!existsSync(dir)) continue;
|
|
27
|
+
let entries;
|
|
28
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
if (entry.isDirectory()) continue;
|
|
31
|
+
if (!entry.name.endsWith('.md')) continue;
|
|
32
|
+
const filePath = path.join(dir, entry.name);
|
|
33
|
+
// Skip any nested archived/ collisions just in case
|
|
34
|
+
if (filePath.includes(`/${archiveDir}/`)) continue;
|
|
35
|
+
let raw;
|
|
36
|
+
try { raw = readFileSync(filePath, 'utf8'); } catch { continue; }
|
|
37
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
38
|
+
if (!frontmatter) continue;
|
|
39
|
+
const fm = parseSimpleFrontmatter(frontmatter);
|
|
40
|
+
if (asString(fm.type) !== 'prompt') continue;
|
|
41
|
+
if (asString(fm.status) !== 'pending') continue;
|
|
42
|
+
found.push(toRepoPath(filePath, config.repoRoot));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return found.sort();
|
|
47
|
+
}
|
|
48
|
+
|
|
16
49
|
export function buildHud(config) {
|
|
17
50
|
const session = currentSessionId();
|
|
18
51
|
const leases = readLeases(config);
|
|
19
52
|
const owned = Object.values(leases).filter(l => l.session === session).map(l => l.path);
|
|
20
53
|
const queued = listQueuedHandoffs(config).map(h => h.repoPath);
|
|
21
54
|
const stale = findStaleLeases(config).map(l => l.path);
|
|
55
|
+
const prompts = findPendingPrompts(config);
|
|
22
56
|
|
|
23
|
-
return { owned, queued, stale };
|
|
57
|
+
return { owned, queued, stale, prompts };
|
|
24
58
|
}
|
|
25
59
|
|
|
26
60
|
export function runHud(argv, config) {
|
|
@@ -39,6 +73,9 @@ export function runHud(argv, config) {
|
|
|
39
73
|
if (hud.queued.length > 0) {
|
|
40
74
|
lines.push(green(`▶ ${hud.queued.length} handoff${hud.queued.length === 1 ? '' : 's'} queued: ${previewList(hud.queued)} ${dim('(resume: dotmd pickup)')}`));
|
|
41
75
|
}
|
|
76
|
+
if (hud.prompts.length > 0) {
|
|
77
|
+
lines.push(green(`▶ ${hud.prompts.length} pending prompt${hud.prompts.length === 1 ? '' : 's'}: ${previewList(hud.prompts)} ${dim('(consume: `dotmd prompts use <file>` — do not cat/read)')}`));
|
|
78
|
+
}
|
|
42
79
|
if (hud.stale.length > 0) {
|
|
43
80
|
lines.push(yellow(`⚠ ${hud.stale.length} stuck lease${hud.stale.length === 1 ? '' : 's'} >24h ${dim('(run: dotmd release --stale)')}`));
|
|
44
81
|
}
|
package/src/index.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
4
|
import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
|
|
5
5
|
import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn } from './util.mjs';
|
|
6
|
-
import { validateDoc, validatePlanShape, checkBidirectionalReferences, checkGitStaleness, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate } from './validate.mjs';
|
|
6
|
+
import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate } from './validate.mjs';
|
|
7
7
|
import { checkIndex } from './index-file.mjs';
|
|
8
8
|
import { checkClaudeCommands } from './claude-commands.mjs';
|
|
9
9
|
|
|
@@ -194,5 +194,6 @@ export function parseDocFile(filePath, config) {
|
|
|
194
194
|
|
|
195
195
|
validateDoc(doc, parsedFrontmatter, headingTitle, config);
|
|
196
196
|
validatePlanShape(doc, body, parsedFrontmatter, config);
|
|
197
|
+
validateDocShape(doc, body, parsedFrontmatter, config);
|
|
197
198
|
return doc;
|
|
198
199
|
}
|
package/src/lifecycle.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from './lease.mjs';
|
|
19
19
|
import { hasHandoff, consumeHandoff, appendHandoff, handoffPath, listQueuedHandoffs } from './handoff.mjs';
|
|
20
20
|
import { buildCard, renderCard } from './pickup-card.mjs';
|
|
21
|
+
import { walkSections, findSection } from './section.mjs';
|
|
21
22
|
|
|
22
23
|
function findFileRoot(filePath, config) {
|
|
23
24
|
const roots = config.docsRoots || [config.docsRoot];
|
|
@@ -101,6 +102,7 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
updateFrontmatter(filePath, { status: newStatus, updated: today });
|
|
105
|
+
appendVersionHistory(filePath, `Status: ${oldStatus ?? 'unknown'} → ${newStatus}.`);
|
|
104
106
|
|
|
105
107
|
if (isArchiving) {
|
|
106
108
|
mkdirSync(archiveDir, { recursive: true });
|
|
@@ -211,6 +213,16 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
211
213
|
if (oldStatus !== 'in-session') {
|
|
212
214
|
updateFrontmatter(filePath, { status: 'in-session', updated: today });
|
|
213
215
|
}
|
|
216
|
+
// VH append per lease outcome:
|
|
217
|
+
// acquired → `Picked up (<old> → in-session).`
|
|
218
|
+
// taken-over → `Took over from <session>.`
|
|
219
|
+
// reattached → no entry (same-session noise)
|
|
220
|
+
if (leaseOutcome === 'acquired') {
|
|
221
|
+
appendVersionHistory(filePath, `Picked up (${oldStatus ?? 'unknown'} → in-session).`);
|
|
222
|
+
} else if (leaseOutcome === 'taken-over') {
|
|
223
|
+
const fromSession = result.conflict?.session ?? 'unknown';
|
|
224
|
+
appendVersionHistory(filePath, `Took over from ${fromSession}.`);
|
|
225
|
+
}
|
|
214
226
|
}
|
|
215
227
|
|
|
216
228
|
let handoffBody = null;
|
|
@@ -317,6 +329,7 @@ export async function runUnpickup(argv, config, opts = {}) {
|
|
|
317
329
|
if (cur === 'in-session') {
|
|
318
330
|
const today = nowIso();
|
|
319
331
|
updateFrontmatter(filePath, { status: newStatus, updated: today });
|
|
332
|
+
appendVersionHistory(filePath, `Released (in-session → ${newStatus}).`);
|
|
320
333
|
}
|
|
321
334
|
// If frontmatter is no longer in-session (manual flip), leave it alone.
|
|
322
335
|
} catch (err) {
|
|
@@ -447,7 +460,7 @@ export async function runFinish(argv, config, opts = {}) {
|
|
|
447
460
|
}
|
|
448
461
|
|
|
449
462
|
export function runArchive(argv, config, opts = {}) {
|
|
450
|
-
const { dryRun } = opts;
|
|
463
|
+
const { dryRun, out = process.stdout } = opts;
|
|
451
464
|
const input = argv[0];
|
|
452
465
|
|
|
453
466
|
if (!input) { die('Usage: dotmd archive <file>'); }
|
|
@@ -472,20 +485,21 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
472
485
|
|
|
473
486
|
if (dryRun) {
|
|
474
487
|
const prefix = dim('[dry-run]');
|
|
475
|
-
|
|
488
|
+
out.write(`${prefix} Would update frontmatter: status: ${oldStatus} → archived, updated: ${today}\n`);
|
|
476
489
|
if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
|
|
477
|
-
|
|
478
|
-
if (config.indexPath)
|
|
490
|
+
out.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
491
|
+
if (config.indexPath) out.write(`${prefix} Would regenerate index\n`);
|
|
479
492
|
|
|
480
493
|
// Preview reference updates
|
|
481
494
|
const refCount = countRefsToUpdate(filePath, targetPath, config);
|
|
482
495
|
if (refCount > 0) {
|
|
483
|
-
|
|
496
|
+
out.write(`${prefix} Would update references in ${refCount} file(s)\n`);
|
|
484
497
|
}
|
|
485
498
|
return;
|
|
486
499
|
}
|
|
487
500
|
|
|
488
501
|
updateFrontmatter(filePath, { status: 'archived', updated: today });
|
|
502
|
+
appendVersionHistory(filePath, 'Archived.');
|
|
489
503
|
|
|
490
504
|
mkdirSync(targetDir, { recursive: true });
|
|
491
505
|
if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
|
|
@@ -504,10 +518,10 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
504
518
|
writeIndex(renderIndexFile(index, config), config);
|
|
505
519
|
}
|
|
506
520
|
|
|
507
|
-
|
|
508
|
-
if (selfRefsFixed)
|
|
509
|
-
if (updatedRefCount > 0)
|
|
510
|
-
if (config.indexPath)
|
|
521
|
+
out.write(`${green('Archived')}: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
522
|
+
if (selfRefsFixed) out.write('Updated references in archived file.\n');
|
|
523
|
+
if (updatedRefCount > 0) out.write(`Updated references in ${updatedRefCount} file(s).\n`);
|
|
524
|
+
if (config.indexPath) out.write('Index regenerated.\n');
|
|
511
525
|
|
|
512
526
|
try { releaseLease(config, oldRepoPath, { force: true }); } catch (err) { warn(`Could not release lease for ${oldRepoPath}: ${err.message}`); }
|
|
513
527
|
|
|
@@ -821,6 +835,7 @@ export async function runHandoff(argv, config, opts = {}) {
|
|
|
821
835
|
if (oldStatus === 'in-session') {
|
|
822
836
|
updateFrontmatter(filePath, { status: targetStatus, updated: today });
|
|
823
837
|
}
|
|
838
|
+
appendVersionHistory(filePath, `Handoff queued (in-session → ${targetStatus}).`);
|
|
824
839
|
releaseLease(config, repoPath, { force: true });
|
|
825
840
|
|
|
826
841
|
if (json) {
|
|
@@ -839,6 +854,47 @@ export async function runHandoff(argv, config, opts = {}) {
|
|
|
839
854
|
try { config.hooks.onUnpickup?.({ path: repoPath, oldStatus: 'in-session', newStatus: targetStatus }); } catch (err) { warn(`Hook 'onUnpickup' threw: ${err.message}`); }
|
|
840
855
|
}
|
|
841
856
|
|
|
857
|
+
// Append a one-line dated bullet to the file's `## Version History` section.
|
|
858
|
+
// Newest-first ordering: inserted at the top of the section, right after the
|
|
859
|
+
// heading + blank-line gap. If the section is missing, this is a silent no-op
|
|
860
|
+
// — never auto-creates the section (don't surprise users on old plans/docs).
|
|
861
|
+
export function appendVersionHistory(filePath, entry) {
|
|
862
|
+
let raw;
|
|
863
|
+
try { raw = readFileSync(filePath, 'utf8'); } catch { return false; }
|
|
864
|
+
if (!raw.startsWith('---\n')) return false;
|
|
865
|
+
|
|
866
|
+
const endMarker = raw.indexOf('\n---\n', 4);
|
|
867
|
+
if (endMarker === -1) return false;
|
|
868
|
+
const frontmatter = raw.slice(4, endMarker);
|
|
869
|
+
const body = raw.slice(endMarker + 5);
|
|
870
|
+
|
|
871
|
+
const vh = findSection(walkSections(body), 'Version History');
|
|
872
|
+
if (!vh) return false;
|
|
873
|
+
|
|
874
|
+
const bullet = `- **${nowIso()}** ${entry}`;
|
|
875
|
+
const lines = body.split('\n');
|
|
876
|
+
|
|
877
|
+
// vh.lineStart is 1-indexed for the heading line. The line immediately
|
|
878
|
+
// after the heading is at 0-indexed `vh.lineStart`. Skip leading blanks
|
|
879
|
+
// to find the first content line (existing bullet or next heading).
|
|
880
|
+
let insertAt = vh.lineStart;
|
|
881
|
+
while (insertAt < lines.length && lines[insertAt].trim() === '') {
|
|
882
|
+
insertAt++;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// If we're inserting just before another heading (next H2), pad with a
|
|
886
|
+
// blank line after our bullet for readability. Otherwise just splice in.
|
|
887
|
+
const atSectionBoundary = insertAt >= lines.length || lines[insertAt].startsWith('#');
|
|
888
|
+
if (atSectionBoundary) {
|
|
889
|
+
lines.splice(insertAt, 0, bullet, '');
|
|
890
|
+
} else {
|
|
891
|
+
lines.splice(insertAt, 0, bullet);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
writeFileSync(filePath, `---\n${frontmatter}\n---\n${lines.join('\n')}`, 'utf8');
|
|
895
|
+
return true;
|
|
896
|
+
}
|
|
897
|
+
|
|
842
898
|
export function updateFrontmatter(filePath, updates) {
|
|
843
899
|
const raw = readFileSync(filePath, 'utf8');
|
|
844
900
|
if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block.`);
|
package/src/prompts.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
3
|
+
import { asString, toRepoPath, die, resolveDocPath } from './util.mjs';
|
|
4
|
+
import { buildIndex } from './index.mjs';
|
|
5
|
+
import { runQuery } from './query.mjs';
|
|
6
|
+
import { runArchive } from './lifecycle.mjs';
|
|
7
|
+
import { runNew } from './new.mjs';
|
|
8
|
+
import { green, dim } from './color.mjs';
|
|
9
|
+
|
|
10
|
+
const SUBCOMMANDS = new Set(['list', 'next', 'use', 'archive', 'new']);
|
|
11
|
+
|
|
12
|
+
export async function runPrompts(argv, config, opts = {}) {
|
|
13
|
+
const sub = argv[0];
|
|
14
|
+
|
|
15
|
+
if (!sub || !SUBCOMMANDS.has(sub)) {
|
|
16
|
+
return runPromptsList(argv, config, opts);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const rest = argv.slice(1);
|
|
20
|
+
switch (sub) {
|
|
21
|
+
case 'list': return runPromptsList(rest, config, opts);
|
|
22
|
+
case 'next': return runPromptsNext(rest, config, opts);
|
|
23
|
+
case 'use': return runPromptsUse(rest, config, opts);
|
|
24
|
+
case 'archive': return runPromptsArchive(rest, config, opts);
|
|
25
|
+
case 'new': return runPromptsNew(rest, config, opts);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function runPromptsList(argv, config) {
|
|
30
|
+
const index = buildIndex(config);
|
|
31
|
+
const hasStatusFlag = argv.includes('--status');
|
|
32
|
+
const includeArchived = argv.includes('--include-archived');
|
|
33
|
+
const sub = argv[0];
|
|
34
|
+
|
|
35
|
+
let defaults;
|
|
36
|
+
let extras = argv;
|
|
37
|
+
if (sub === 'status') {
|
|
38
|
+
defaults = ['--type', 'prompt', '--exclude-archived', '--sort', 'status', '--all'];
|
|
39
|
+
extras = argv.slice(1);
|
|
40
|
+
} else if (hasStatusFlag || includeArchived) {
|
|
41
|
+
defaults = ['--type', 'prompt', '--sort', 'updated', '--limit', '10'];
|
|
42
|
+
} else {
|
|
43
|
+
defaults = ['--type', 'prompt', '--exclude-archived', '--sort', 'updated', '--limit', '10'];
|
|
44
|
+
}
|
|
45
|
+
runQuery(index, [...defaults, ...extras], config, { preset: 'prompts' });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function pendingPromptsOldestFirst(config) {
|
|
49
|
+
const index = buildIndex(config);
|
|
50
|
+
const prompts = index.docs.filter(d => d.type === 'prompt' && d.status === 'pending');
|
|
51
|
+
|
|
52
|
+
return prompts
|
|
53
|
+
.map(d => {
|
|
54
|
+
const abs = resolveDocPath(d.path, config);
|
|
55
|
+
let mtime = 0;
|
|
56
|
+
try { mtime = abs ? statSync(abs).mtimeMs : 0; } catch { mtime = 0; }
|
|
57
|
+
return { doc: d, abs, created: d.created ?? '', mtime };
|
|
58
|
+
})
|
|
59
|
+
.sort((a, b) => {
|
|
60
|
+
if (a.created && b.created && a.created !== b.created) return a.created.localeCompare(b.created);
|
|
61
|
+
if (a.created && !b.created) return -1;
|
|
62
|
+
if (!a.created && b.created) return 1;
|
|
63
|
+
return a.mtime - b.mtime;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function runPromptsNext(argv, config, opts = {}) {
|
|
68
|
+
const queue = pendingPromptsOldestFirst(config);
|
|
69
|
+
if (queue.length === 0) {
|
|
70
|
+
die('No pending prompts.');
|
|
71
|
+
}
|
|
72
|
+
const head = queue[0];
|
|
73
|
+
if (!head.abs) die(`Could not resolve path: ${head.doc.path}`);
|
|
74
|
+
consumePrompt(head.abs, config, opts);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function runPromptsUse(argv, config, opts = {}) {
|
|
78
|
+
const input = argv.find(a => !a.startsWith('-'));
|
|
79
|
+
if (!input) die('Usage: dotmd prompts use <file>');
|
|
80
|
+
const filePath = resolveDocPath(input, config);
|
|
81
|
+
if (!filePath) die(`File not found: ${input}`);
|
|
82
|
+
consumePrompt(filePath, config, opts);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function consumePrompt(filePath, config, opts) {
|
|
86
|
+
const { dryRun } = opts;
|
|
87
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
88
|
+
const { frontmatter, body } = extractFrontmatter(raw);
|
|
89
|
+
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
90
|
+
const docType = asString(parsed.type);
|
|
91
|
+
const status = asString(parsed.status);
|
|
92
|
+
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
93
|
+
|
|
94
|
+
if (docType !== 'prompt') {
|
|
95
|
+
die(`Not a prompt (type: ${docType ?? 'unknown'}): ${repoPath}`);
|
|
96
|
+
}
|
|
97
|
+
if (status === 'archived') {
|
|
98
|
+
die(`Already consumed: ${repoPath}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (dryRun) {
|
|
102
|
+
process.stderr.write(`${dim('[dry-run]')} Would emit body and archive: ${repoPath} (${status ?? 'unknown'} → archived)\n`);
|
|
103
|
+
runArchive([filePath], config, { dryRun: true, out: process.stderr });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
process.stdout.write(body);
|
|
108
|
+
if (!body.endsWith('\n')) process.stdout.write('\n');
|
|
109
|
+
|
|
110
|
+
runArchive([filePath], config, { out: process.stderr });
|
|
111
|
+
process.stderr.write(`${green('✓ Consumed')}: ${repoPath}\n`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function runPromptsArchive(argv, config, opts = {}) {
|
|
115
|
+
const input = argv.find(a => !a.startsWith('-'));
|
|
116
|
+
if (!input) die('Usage: dotmd prompts archive <file>');
|
|
117
|
+
const filePath = resolveDocPath(input, config);
|
|
118
|
+
if (!filePath) die(`File not found: ${input}`);
|
|
119
|
+
|
|
120
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
121
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
122
|
+
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
123
|
+
if (asString(parsed.type) !== 'prompt') {
|
|
124
|
+
die(`Not a prompt: ${toRepoPath(filePath, config.repoRoot)}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
runArchive([filePath], config, opts);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function runPromptsNew(argv, config, opts = {}) {
|
|
131
|
+
if (!argv[0] || argv[0].startsWith('-')) {
|
|
132
|
+
die('Usage: dotmd prompts new <slug> [body]\n body: inline text | "-" (stdin) | "@path" (file) | --message "..."');
|
|
133
|
+
}
|
|
134
|
+
return runNew(['prompt', ...argv], config, opts);
|
|
135
|
+
}
|
package/src/validate.mjs
CHANGED
|
@@ -259,6 +259,31 @@ export function validatePlanShape(doc, body, frontmatter, config) {
|
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
// Doc-shape lint: soft warnings on convention drift. Doc-only.
|
|
263
|
+
// Mirrors validatePlanShape's structure.
|
|
264
|
+
export function validateDocShape(doc, body, frontmatter, config) {
|
|
265
|
+
if (doc.type !== 'doc') return;
|
|
266
|
+
if (config.lifecycle.terminalStatuses.has(doc.status) || config.lifecycle.archiveStatuses.has(doc.status)) return;
|
|
267
|
+
if (config.lifecycle.skipWarningsFor.has(doc.status)) return;
|
|
268
|
+
|
|
269
|
+
if (!body) return;
|
|
270
|
+
|
|
271
|
+
// Heading drift for docs.
|
|
272
|
+
const headingDrift = [
|
|
273
|
+
{ wrong: /^##\s+Related Documents\s*$/m, right: '## Related Documentation' },
|
|
274
|
+
];
|
|
275
|
+
for (const { wrong, right } of headingDrift) {
|
|
276
|
+
const m = body.match(wrong);
|
|
277
|
+
if (m) {
|
|
278
|
+
doc.warnings.push({
|
|
279
|
+
path: doc.path,
|
|
280
|
+
level: 'warning',
|
|
281
|
+
message: `Heading drift: \`${m[0].trim()}\` → suggest \`${right}\`.`,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
262
287
|
export function computeDaysSinceUpdate(updated) {
|
|
263
288
|
if (!updated) return null;
|
|
264
289
|
const parsed = new Date(updated);
|