dotmd-cli 0.39.2 → 0.39.4
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 +139 -4
- package/package.json +1 -1
- package/src/index.mjs +7 -1
- package/src/lifecycle.mjs +103 -14
- package/src/new.mjs +9 -1
- package/src/prompts.mjs +85 -7
- package/src/util.mjs +12 -0
- package/src/validate.mjs +4 -0
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:
|
|
@@ -170,6 +255,8 @@ If a plan is already in-session:
|
|
|
170
255
|
|
|
171
256
|
Options:
|
|
172
257
|
--takeover Force-claim a plan held by another session
|
|
258
|
+
--no-index Skip index regen (see \`dotmd archive --help\`)
|
|
259
|
+
--show-files Append \`files: …\` line to stderr (see \`dotmd archive --help\`)
|
|
173
260
|
--json Output as JSON
|
|
174
261
|
--dry-run, -n Preview without writing
|
|
175
262
|
|
|
@@ -191,6 +278,8 @@ Options:
|
|
|
191
278
|
--all Release every lease in the file (administrative)
|
|
192
279
|
--stale Release leases whose pid is dead or age >24h
|
|
193
280
|
--force Override "not yours" refusal on a specific file
|
|
281
|
+
--no-index Skip index regen (see \`dotmd archive --help\`)
|
|
282
|
+
--show-files Append \`files: …\` line to stderr (see \`dotmd archive --help\`)
|
|
194
283
|
--json Output as JSON ({ released, skipped })
|
|
195
284
|
--dry-run, -n Preview without writing
|
|
196
285
|
|
|
@@ -221,6 +310,11 @@ Moves the document to the new status. If transitioning to an archive
|
|
|
221
310
|
status, automatically moves the file to the archive directory and
|
|
222
311
|
regenerates the index (if configured).
|
|
223
312
|
|
|
313
|
+
Options:
|
|
314
|
+
--no-index Skip index regen (useful in concurrent-session repos
|
|
315
|
+
doing path-limited commits — see \`dotmd archive --help\`).
|
|
316
|
+
--show-files Append \`files: …\` line to stderr (see \`dotmd archive --help\`).
|
|
317
|
+
|
|
224
318
|
Default plan statuses (each maps to a distinct unstuck-action):
|
|
225
319
|
in-session A Claude session is working on it now
|
|
226
320
|
active Ready to be picked up
|
|
@@ -232,6 +326,9 @@ Default plan statuses (each maps to a distinct unstuck-action):
|
|
|
232
326
|
queued-after Sequenced behind another plan — check predecessor
|
|
233
327
|
archived No longer relevant; auto-moved to archive directory
|
|
234
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
|
+
|
|
235
332
|
Use --dry-run (-n) to preview changes without writing anything.`,
|
|
236
333
|
|
|
237
334
|
check: `dotmd check — validate frontmatter and references
|
|
@@ -252,7 +349,17 @@ Options:
|
|
|
252
349
|
Sets status to 'archived', moves to the archive directory, auto-updates
|
|
253
350
|
references in other docs, and regenerates the index.
|
|
254
351
|
|
|
255
|
-
|
|
352
|
+
Options:
|
|
353
|
+
--no-index Skip index regen. Use when multiple sessions are
|
|
354
|
+
working concurrently and you want a path-limited
|
|
355
|
+
commit that doesn't pull other agents' uncommitted
|
|
356
|
+
index changes into your staging area. Run \`dotmd index\`
|
|
357
|
+
later (or wire it into a commit hook) to refresh.
|
|
358
|
+
--show-files Append a final \`files: a b c …\` line to stderr
|
|
359
|
+
listing every doc/index path the command touched
|
|
360
|
+
(deduped, sorted, repo-relative). Lets agents do
|
|
361
|
+
\`git add\` with the exact set instead of guessing.
|
|
362
|
+
--dry-run, -n Preview changes without writing anything.`,
|
|
256
363
|
|
|
257
364
|
coverage: `dotmd coverage — metadata coverage report
|
|
258
365
|
|
|
@@ -488,6 +595,8 @@ Other options:
|
|
|
488
595
|
--status <s> Set initial status (defaults to first valid status for the type)
|
|
489
596
|
--title <t> Override the auto-derived title
|
|
490
597
|
--root <name> Create in a specific docs root
|
|
598
|
+
--show-files Append \`files: …\` line to stderr listing what was touched
|
|
599
|
+
(the new doc + the index file). See \`dotmd archive --help\`.
|
|
491
600
|
--list-types Show registered types (alias: --list-templates)
|
|
492
601
|
|
|
493
602
|
For plans, the default status vocabulary is: in-session, active, planned,
|
|
@@ -609,8 +718,8 @@ sorted by status. Supports all query flags (--status, --module, --json,
|
|
|
609
718
|
--sort, --group, etc.).
|
|
610
719
|
|
|
611
720
|
Default plan statuses: in-session, active, planned, blocked, partial,
|
|
612
|
-
paused, awaiting, queued-after, archived. Run \`dotmd
|
|
613
|
-
the unstuck-action behind each one.
|
|
721
|
+
paused, awaiting, queued-after, archived. Run \`dotmd help statuses\` for
|
|
722
|
+
the unstuck-action behind each one and canonical transitions.
|
|
614
723
|
|
|
615
724
|
Examples:
|
|
616
725
|
dotmd plans # live plans (default)
|
|
@@ -650,6 +759,9 @@ Default prompt statuses: pending, shelved, claimed, archived.
|
|
|
650
759
|
|
|
651
760
|
Examples:
|
|
652
761
|
dotmd prompts # pending prompts (default)
|
|
762
|
+
dotmd prompts list --verbose # one row per prompt + target plan ref
|
|
763
|
+
# (from related_plans, parent_plan,
|
|
764
|
+
# or the first body .md link)
|
|
653
765
|
dotmd prompts list --include-archived # all prompts including archived
|
|
654
766
|
dotmd prompts list --status claimed # already-consumed prompts
|
|
655
767
|
dotmd prompts --json # JSON output
|
|
@@ -680,6 +792,21 @@ Supports all query flags (--status, --json, --sort, etc.)`,
|
|
|
680
792
|
Shows documents that reference or depend on the given file.
|
|
681
793
|
Useful for impact analysis before archiving or changing a plan.
|
|
682
794
|
|
|
795
|
+
The dependency edge is read from each plan's \`blockers:\` frontmatter
|
|
796
|
+
(a YAML list of plan slugs or paths). \`blocked_by:\` is accepted as
|
|
797
|
+
an alias since 0.39.3 — both populate the same index field, so use
|
|
798
|
+
whichever name reads better.
|
|
799
|
+
|
|
800
|
+
Frontmatter shape:
|
|
801
|
+
|
|
802
|
+
---
|
|
803
|
+
type: plan
|
|
804
|
+
status: blocked
|
|
805
|
+
blockers:
|
|
806
|
+
- foo-plan.md
|
|
807
|
+
- docs/plans/bar-plan.md
|
|
808
|
+
---
|
|
809
|
+
|
|
683
810
|
Options:
|
|
684
811
|
--json Output as JSON`,
|
|
685
812
|
|
|
@@ -789,6 +916,14 @@ async function main() {
|
|
|
789
916
|
}
|
|
790
917
|
|
|
791
918
|
if (command === 'help' || command === '--help' || command === '-h') {
|
|
919
|
+
const topic = args[1];
|
|
920
|
+
if (topic) {
|
|
921
|
+
const key = `help:${topic}`;
|
|
922
|
+
if (HELP[key]) { process.stdout.write(`${HELP[key]}\n`); return; }
|
|
923
|
+
if (HELP[topic]) { process.stdout.write(`${HELP[topic]}\n`); return; }
|
|
924
|
+
process.stderr.write(`Unknown help topic: ${topic}\n\nAvailable topics: statuses\nPer-command help: dotmd <cmd> --help\n`);
|
|
925
|
+
process.exit(1);
|
|
926
|
+
}
|
|
792
927
|
process.stdout.write(`${HELP._main}\n`);
|
|
793
928
|
return;
|
|
794
929
|
}
|
|
@@ -891,7 +1026,7 @@ async function main() {
|
|
|
891
1026
|
}
|
|
892
1027
|
if (command === 'prompts') {
|
|
893
1028
|
const { runPrompts } = await import('../src/prompts.mjs');
|
|
894
|
-
await runPrompts(restArgs, config, { dryRun });
|
|
1029
|
+
await runPrompts(restArgs, config, { dryRun, verbose });
|
|
895
1030
|
return;
|
|
896
1031
|
}
|
|
897
1032
|
|
package/package.json
CHANGED
package/src/index.mjs
CHANGED
|
@@ -175,7 +175,13 @@ export function parseDocFile(filePath, config, opts = {}) {
|
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
const nextStep = asString(parsedFrontmatter.next_step) ?? extractNextStep(body) ?? null;
|
|
178
|
-
|
|
178
|
+
// `blocked_by` is accepted as an alias for `blockers` since 0.39.3 — agents
|
|
179
|
+
// filing tickets naturally reach for the JIRA/Linear name. If both are set,
|
|
180
|
+
// they're merged (de-duped via normalizeBlockers → mergeUniqueStrings).
|
|
181
|
+
const blockers = mergeUniqueStrings(
|
|
182
|
+
normalizeBlockers(parsedFrontmatter.blockers),
|
|
183
|
+
normalizeBlockers(parsedFrontmatter.blocked_by),
|
|
184
|
+
);
|
|
179
185
|
const surface = asString(parsedFrontmatter.surface) ?? null;
|
|
180
186
|
const surfaces = normalizeStringList(parsedFrontmatter.surfaces);
|
|
181
187
|
const moduleName = asString(parsedFrontmatter.module) ?? null;
|
package/src/lifecycle.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
|
|
4
|
-
import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso, suggestCandidates } from './util.mjs';
|
|
4
|
+
import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso, suggestCandidates, emitFilesFooter } from './util.mjs';
|
|
5
5
|
import { gitMv, getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
|
|
6
6
|
import { buildIndex, collectDocFiles } from './index.mjs';
|
|
7
7
|
import { renderIndexFile, writeIndex } from './index-file.mjs';
|
|
@@ -68,6 +68,9 @@ function uniqueArchiveTarget(targetDir, basename) {
|
|
|
68
68
|
|
|
69
69
|
export async function runStatus(argv, config, opts = {}) {
|
|
70
70
|
const { dryRun } = opts;
|
|
71
|
+
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
72
|
+
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
73
|
+
argv = argv.filter(a => a !== '--no-index' && a !== '--show-files');
|
|
71
74
|
const input = argv[0];
|
|
72
75
|
let newStatus = argv[1];
|
|
73
76
|
|
|
@@ -167,11 +170,24 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
167
170
|
|
|
168
171
|
// Regen the index on every status change — `active → planned` etc. drift
|
|
169
172
|
// the per-status sections just as much as archive crossings. Archive paths
|
|
170
|
-
// also benefit (replaces the previously-gated regen).
|
|
171
|
-
|
|
173
|
+
// also benefit (replaces the previously-gated regen). `--no-index` skips
|
|
174
|
+
// this so concurrent agents can do path-limited commits without pulling
|
|
175
|
+
// each other's uncommitted index changes into the staging area.
|
|
176
|
+
if (noIndex) {
|
|
177
|
+
process.stderr.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
|
|
178
|
+
} else {
|
|
179
|
+
regenIndex(config);
|
|
180
|
+
}
|
|
172
181
|
|
|
173
182
|
process.stdout.write(`${green(toRepoPath(finalPath, config.repoRoot))}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
|
|
174
183
|
|
|
184
|
+
if (showFiles) {
|
|
185
|
+
const touched = [filePath];
|
|
186
|
+
if (finalPath !== filePath) touched.push(finalPath);
|
|
187
|
+
if (config.indexPath && !noIndex) touched.push(config.indexPath);
|
|
188
|
+
emitFilesFooter(touched, config);
|
|
189
|
+
}
|
|
190
|
+
|
|
175
191
|
try { config.hooks.onStatusChange?.({ path: toRepoPath(finalPath, config.repoRoot), oldStatus, newStatus }, {
|
|
176
192
|
oldPath: toRepoPath(filePath, config.repoRoot),
|
|
177
193
|
newPath: toRepoPath(finalPath, config.repoRoot),
|
|
@@ -183,6 +199,8 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
183
199
|
const json = argv.includes('--json');
|
|
184
200
|
const takeover = argv.includes('--takeover');
|
|
185
201
|
const fullBody = argv.includes('--full');
|
|
202
|
+
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
203
|
+
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
186
204
|
let input = argv.find(a => !a.startsWith('-'));
|
|
187
205
|
|
|
188
206
|
// Interactive: pick from active/planned plans
|
|
@@ -228,7 +246,15 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
228
246
|
}
|
|
229
247
|
|
|
230
248
|
const pickupable = new Set(['active', 'planned', 'in-session']);
|
|
231
|
-
if (oldStatus && !pickupable.has(oldStatus))
|
|
249
|
+
if (oldStatus && !pickupable.has(oldStatus)) {
|
|
250
|
+
die(
|
|
251
|
+
`Cannot pick up a plan with status '${oldStatus}'. Must be active or planned.\n` +
|
|
252
|
+
` ${repoPath}\n` +
|
|
253
|
+
`\n` +
|
|
254
|
+
`Recover with:\n` +
|
|
255
|
+
` dotmd status ${repoPath} active && dotmd pickup ${repoPath}`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
232
258
|
|
|
233
259
|
const today = nowIso();
|
|
234
260
|
const leaseOldStatus = oldStatus === 'in-session' ? 'active' : (oldStatus ?? 'active');
|
|
@@ -253,7 +279,11 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
253
279
|
}
|
|
254
280
|
if (oldStatus !== 'in-session') {
|
|
255
281
|
updateFrontmatter(filePath, { status: 'in-session', updated: today });
|
|
256
|
-
|
|
282
|
+
if (noIndex) {
|
|
283
|
+
process.stderr.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
|
|
284
|
+
} else {
|
|
285
|
+
regenIndex(config);
|
|
286
|
+
}
|
|
257
287
|
}
|
|
258
288
|
// VH append per lease outcome:
|
|
259
289
|
// acquired → `Picked up (<old> → in-session).`
|
|
@@ -295,6 +325,12 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
295
325
|
}
|
|
296
326
|
}
|
|
297
327
|
|
|
328
|
+
if (showFiles && oldStatus !== 'in-session') {
|
|
329
|
+
const touched = [filePath];
|
|
330
|
+
if (config.indexPath && !noIndex) touched.push(config.indexPath);
|
|
331
|
+
emitFilesFooter(touched, config);
|
|
332
|
+
}
|
|
333
|
+
|
|
298
334
|
try { config.hooks.onPickup?.({ path: repoPath, oldStatus, newStatus: 'in-session' }); } catch (err) { warn(`Hook 'onPickup' threw: ${err.message}`); }
|
|
299
335
|
}
|
|
300
336
|
|
|
@@ -304,10 +340,13 @@ export async function runUnpickup(argv, config, opts = {}) {
|
|
|
304
340
|
const all = argv.includes('--all');
|
|
305
341
|
const stale = argv.includes('--stale');
|
|
306
342
|
const force = argv.includes('--force');
|
|
343
|
+
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
344
|
+
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
307
345
|
const toIdx = argv.indexOf('--to');
|
|
308
346
|
const toStatus = toIdx >= 0 ? argv[toIdx + 1] : null;
|
|
309
347
|
const positional = argv.filter((a, i) => !a.startsWith('-') && argv[i - 1] !== '--to');
|
|
310
348
|
const fileArg = positional[0];
|
|
349
|
+
const touched = [];
|
|
311
350
|
|
|
312
351
|
const session = currentSessionId();
|
|
313
352
|
const released = [];
|
|
@@ -360,7 +399,12 @@ export async function runUnpickup(argv, config, opts = {}) {
|
|
|
360
399
|
const today = nowIso();
|
|
361
400
|
updateFrontmatter(filePath, { status: newStatus, updated: today });
|
|
362
401
|
appendVersionHistory(filePath, `Released (in-session → ${newStatus}).`);
|
|
363
|
-
|
|
402
|
+
touched.push(filePath);
|
|
403
|
+
if (noIndex) {
|
|
404
|
+
process.stderr.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
|
|
405
|
+
} else {
|
|
406
|
+
regenIndex(config);
|
|
407
|
+
}
|
|
364
408
|
}
|
|
365
409
|
// If frontmatter is no longer in-session (manual flip), leave it alone.
|
|
366
410
|
} catch (err) {
|
|
@@ -429,6 +473,12 @@ export async function runUnpickup(argv, config, opts = {}) {
|
|
|
429
473
|
process.stderr.write(`${yellow('⚠ Skipped')}: ${s.path} (held by ${s.session}; use --force to override)\n`);
|
|
430
474
|
}
|
|
431
475
|
}
|
|
476
|
+
|
|
477
|
+
if (showFiles && touched.length > 0) {
|
|
478
|
+
const all = [...touched];
|
|
479
|
+
if (config.indexPath && !noIndex) all.push(config.indexPath);
|
|
480
|
+
emitFilesFooter(all, config);
|
|
481
|
+
}
|
|
432
482
|
}
|
|
433
483
|
|
|
434
484
|
export async function runFinish(argv, config, opts = {}) {
|
|
@@ -493,6 +543,9 @@ export async function runFinish(argv, config, opts = {}) {
|
|
|
493
543
|
|
|
494
544
|
export function runArchive(argv, config, opts = {}) {
|
|
495
545
|
const { dryRun, out = process.stdout } = opts;
|
|
546
|
+
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
547
|
+
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
548
|
+
argv = argv.filter(a => a !== '--no-index' && a !== '--show-files');
|
|
496
549
|
const input = argv[0];
|
|
497
550
|
|
|
498
551
|
if (!input) { die('Usage: dotmd archive <file>'); }
|
|
@@ -519,13 +572,24 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
519
572
|
const prefix = dim('[dry-run]');
|
|
520
573
|
out.write(`${prefix} Would update frontmatter: status: ${oldStatus} → archived, updated: ${today}\n`);
|
|
521
574
|
out.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
522
|
-
if (config.indexPath) out.write(`${prefix} Would regenerate index\n`);
|
|
575
|
+
if (config.indexPath && !noIndex) out.write(`${prefix} Would regenerate index\n`);
|
|
576
|
+
if (config.indexPath && noIndex) out.write(`${prefix} Would skip index regen (--no-index)\n`);
|
|
523
577
|
|
|
524
578
|
// Preview reference updates
|
|
525
579
|
const refCount = countRefsToUpdate(filePath, targetPath, config);
|
|
526
580
|
if (refCount > 0) {
|
|
527
581
|
out.write(`${prefix} Would update references in ${refCount} file(s)\n`);
|
|
528
582
|
}
|
|
583
|
+
|
|
584
|
+
// Preview lease release (only if a lease exists for this plan)
|
|
585
|
+
if (readLeases(config)[oldRepoPath]) {
|
|
586
|
+
out.write(`${prefix} Would release in-session lease: ${oldRepoPath}\n`);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Preview onArchive hook fire
|
|
590
|
+
if (config.hooks?.onArchive) {
|
|
591
|
+
out.write(`${prefix} Would fire hook: onArchive\n`);
|
|
592
|
+
}
|
|
529
593
|
return;
|
|
530
594
|
}
|
|
531
595
|
|
|
@@ -541,22 +605,31 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
541
605
|
const selfRefsFixed = updateRefsFromMovedFile(filePath, targetPath, config);
|
|
542
606
|
|
|
543
607
|
// Auto-update references in other docs
|
|
544
|
-
const updatedRefCount = updateRefsAfterMove(filePath, targetPath, config);
|
|
608
|
+
const { count: updatedRefCount, paths: refTouchedPaths } = updateRefsAfterMove(filePath, targetPath, config);
|
|
545
609
|
|
|
546
|
-
regenIndex(config);
|
|
610
|
+
if (!noIndex) regenIndex(config);
|
|
547
611
|
|
|
548
612
|
out.write(`${green('Archived')}: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
549
613
|
if (selfRefsFixed) out.write('Updated references in archived file.\n');
|
|
550
614
|
if (updatedRefCount > 0) out.write(`Updated references in ${updatedRefCount} file(s).\n`);
|
|
551
|
-
if (config.indexPath) out.write('Index regenerated.\n');
|
|
615
|
+
if (config.indexPath && !noIndex) out.write('Index regenerated.\n');
|
|
616
|
+
if (config.indexPath && noIndex) out.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
|
|
552
617
|
|
|
553
618
|
try { releaseLease(config, oldRepoPath, { force: true }); } catch (err) { warn(`Could not release lease for ${oldRepoPath}: ${err.message}`); }
|
|
554
619
|
|
|
620
|
+
const touched = [oldRepoPath, newRepoPath, ...refTouchedPaths];
|
|
621
|
+
if (config.indexPath && !noIndex) touched.push(config.indexPath);
|
|
622
|
+
if (showFiles) emitFilesFooter(touched, config);
|
|
623
|
+
|
|
555
624
|
try { config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath }); } catch (err) { warn(`Hook 'onArchive' threw: ${err.message}`); }
|
|
625
|
+
|
|
626
|
+
return { touched };
|
|
556
627
|
}
|
|
557
628
|
|
|
558
629
|
export function runBulkArchive(argv, config, opts = {}) {
|
|
559
630
|
const { dryRun } = opts;
|
|
631
|
+
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
632
|
+
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
560
633
|
const inputs = argv.filter(a => !a.startsWith('-'));
|
|
561
634
|
if (inputs.length === 0) die('Usage: dotmd bulk archive <file1> <file2> ... or <glob>');
|
|
562
635
|
|
|
@@ -592,14 +665,30 @@ export function runBulkArchive(argv, config, opts = {}) {
|
|
|
592
665
|
}
|
|
593
666
|
|
|
594
667
|
process.stdout.write('\n');
|
|
668
|
+
// Bulk archives always defer index regen to the end — N individual regens
|
|
669
|
+
// is wasteful and the final state is the same. `--no-index` skips even
|
|
670
|
+
// the final one.
|
|
671
|
+
const bulkTouched = [];
|
|
595
672
|
for (const f of unique) {
|
|
596
673
|
const relPath = toRepoPath(f, config.repoRoot);
|
|
597
674
|
try {
|
|
598
|
-
runArchive([relPath], config, opts);
|
|
675
|
+
const result = runArchive([relPath], config, { ...opts, noIndex: true, showFiles: false });
|
|
676
|
+
if (result?.touched) bulkTouched.push(...result.touched);
|
|
599
677
|
} catch (err) {
|
|
600
678
|
warn(`Failed to archive ${relPath}: ${err.message}`);
|
|
601
679
|
}
|
|
602
680
|
}
|
|
681
|
+
if (!noIndex) {
|
|
682
|
+
regenIndex(config);
|
|
683
|
+
if (config.indexPath) process.stdout.write('Index regenerated.\n');
|
|
684
|
+
} else if (config.indexPath) {
|
|
685
|
+
process.stdout.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
|
|
686
|
+
}
|
|
687
|
+
if (showFiles) {
|
|
688
|
+
const all = [...bulkTouched];
|
|
689
|
+
if (config.indexPath && !noIndex) all.push(config.indexPath);
|
|
690
|
+
emitFilesFooter(all, config);
|
|
691
|
+
}
|
|
603
692
|
}
|
|
604
693
|
|
|
605
694
|
export function runTouch(argv, config, opts = {}) {
|
|
@@ -682,7 +771,7 @@ export function runTouch(argv, config, opts = {}) {
|
|
|
682
771
|
function updateRefsAfterMove(oldPath, newPath, config) {
|
|
683
772
|
const basename = path.basename(oldPath);
|
|
684
773
|
const allFiles = collectDocFiles(config);
|
|
685
|
-
|
|
774
|
+
const touched = [];
|
|
686
775
|
|
|
687
776
|
for (const docFile of allFiles) {
|
|
688
777
|
if (docFile === newPath) continue;
|
|
@@ -710,11 +799,11 @@ function updateRefsAfterMove(oldPath, newPath, config) {
|
|
|
710
799
|
if (newFm !== fm) {
|
|
711
800
|
raw = replaceFrontmatter(raw, newFm);
|
|
712
801
|
writeFileSync(docFile, raw, 'utf8');
|
|
713
|
-
|
|
802
|
+
touched.push(docFile);
|
|
714
803
|
}
|
|
715
804
|
}
|
|
716
805
|
|
|
717
|
-
return
|
|
806
|
+
return { count: touched.length, paths: touched };
|
|
718
807
|
}
|
|
719
808
|
|
|
720
809
|
function updateRefsFromMovedFile(oldPath, newPath, config) {
|
package/src/new.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { toRepoPath, die, warn, nowIso } from './util.mjs';
|
|
4
|
+
import { toRepoPath, die, warn, nowIso, emitFilesFooter } from './util.mjs';
|
|
5
5
|
import { green, dim, bold } from './color.mjs';
|
|
6
6
|
import { isInteractive, promptText } from './prompt.mjs';
|
|
7
7
|
import { regenIndex } from './lifecycle.mjs';
|
|
@@ -185,12 +185,14 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
185
185
|
let title = null;
|
|
186
186
|
let rootName = opts.root ?? null;
|
|
187
187
|
let messageFlag = null;
|
|
188
|
+
let showFiles = opts.showFiles ?? false;
|
|
188
189
|
for (let i = 0; i < argv.length; i++) {
|
|
189
190
|
if (argv[i] === '--status' && argv[i + 1]) { status = argv[++i]; continue; }
|
|
190
191
|
if (argv[i] === '--title' && argv[i + 1]) { title = argv[++i]; continue; }
|
|
191
192
|
if (argv[i] === '--message' && argv[i + 1]) { messageFlag = argv[++i]; continue; }
|
|
192
193
|
if (argv[i] === '--root' && argv[i + 1]) { rootName = argv[++i]; continue; }
|
|
193
194
|
if (argv[i] === '--config') { i++; continue; }
|
|
195
|
+
if (argv[i] === '--show-files') { showFiles = true; continue; }
|
|
194
196
|
if (argv[i] === '--list-templates' || argv[i] === '--list-types') {
|
|
195
197
|
listTemplates(config);
|
|
196
198
|
return;
|
|
@@ -395,6 +397,12 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
395
397
|
|
|
396
398
|
regenIndex(config);
|
|
397
399
|
|
|
400
|
+
if (showFiles) {
|
|
401
|
+
const touched = [filePath];
|
|
402
|
+
if (config.indexPath) touched.push(config.indexPath);
|
|
403
|
+
emitFilesFooter(touched, config);
|
|
404
|
+
}
|
|
405
|
+
|
|
398
406
|
try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle, type: typeName }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
|
|
399
407
|
}
|
|
400
408
|
|
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');
|
|
@@ -116,12 +182,14 @@ function resolvePromptInput(input, config) {
|
|
|
116
182
|
function runPromptsUse(argv, config, opts = {}) {
|
|
117
183
|
const input = argv.find(a => !a.startsWith('-'));
|
|
118
184
|
if (!input) die('Usage: dotmd prompts use <file-or-slug>');
|
|
185
|
+
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
186
|
+
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
119
187
|
const filePath = resolvePromptInput(input, config);
|
|
120
|
-
consumePrompt(filePath, config, opts);
|
|
188
|
+
consumePrompt(filePath, config, { ...opts, noIndex, showFiles });
|
|
121
189
|
}
|
|
122
190
|
|
|
123
191
|
function consumePrompt(filePath, config, opts) {
|
|
124
|
-
const { dryRun } = opts;
|
|
192
|
+
const { dryRun, noIndex, showFiles } = opts;
|
|
125
193
|
const raw = readFileSync(filePath, 'utf8');
|
|
126
194
|
const { frontmatter, body } = extractFrontmatter(raw);
|
|
127
195
|
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
@@ -137,21 +205,31 @@ function consumePrompt(filePath, config, opts) {
|
|
|
137
205
|
}
|
|
138
206
|
|
|
139
207
|
if (dryRun) {
|
|
140
|
-
|
|
141
|
-
|
|
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`);
|
|
217
|
+
runArchive([filePath], config, { dryRun: true, noIndex, out: process.stderr });
|
|
142
218
|
return;
|
|
143
219
|
}
|
|
144
220
|
|
|
145
221
|
process.stdout.write(body);
|
|
146
222
|
if (!body.endsWith('\n')) process.stdout.write('\n');
|
|
147
223
|
|
|
148
|
-
runArchive([filePath], config, { out: process.stderr });
|
|
224
|
+
runArchive([filePath], config, { noIndex, showFiles, out: process.stderr });
|
|
149
225
|
process.stderr.write(`${green('✓ Consumed')}: ${repoPath}\n`);
|
|
150
226
|
}
|
|
151
227
|
|
|
152
228
|
function runPromptsArchive(argv, config, opts = {}) {
|
|
153
229
|
const input = argv.find(a => !a.startsWith('-'));
|
|
154
230
|
if (!input) die('Usage: dotmd prompts archive <file-or-slug>');
|
|
231
|
+
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
232
|
+
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
155
233
|
const filePath = resolvePromptInput(input, config);
|
|
156
234
|
|
|
157
235
|
const raw = readFileSync(filePath, 'utf8');
|
|
@@ -161,7 +239,7 @@ function runPromptsArchive(argv, config, opts = {}) {
|
|
|
161
239
|
die(`Not a prompt: ${toRepoPath(filePath, config.repoRoot)}`);
|
|
162
240
|
}
|
|
163
241
|
|
|
164
|
-
runArchive([filePath], config, opts);
|
|
242
|
+
runArchive([filePath], config, { ...opts, noIndex, showFiles });
|
|
165
243
|
}
|
|
166
244
|
|
|
167
245
|
async function runPromptsNew(argv, config, opts = {}) {
|
package/src/util.mjs
CHANGED
|
@@ -55,6 +55,18 @@ export function toRepoPath(absolutePath, repoRoot) {
|
|
|
55
55
|
return path.relative(repoRoot, absolutePath).split(path.sep).join('/');
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// Emit a `files: a b c` line to stderr listing every doc / index path
|
|
59
|
+
// the command touched (deduped, sorted, repo-relative). Lets agents do
|
|
60
|
+
// `git add` with the exact set instead of guessing. Opt-in via
|
|
61
|
+
// `--show-files` on lifecycle commands; default off to keep output stable.
|
|
62
|
+
export function emitFilesFooter(paths, config) {
|
|
63
|
+
const rel = [...new Set(paths.filter(Boolean))]
|
|
64
|
+
.map(p => path.isAbsolute(p) ? toRepoPath(p, config.repoRoot) : p)
|
|
65
|
+
.sort();
|
|
66
|
+
if (rel.length === 0) return;
|
|
67
|
+
process.stderr.write(`files: ${rel.join(' ')}\n`);
|
|
68
|
+
}
|
|
69
|
+
|
|
58
70
|
export function nowIso() {
|
|
59
71
|
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
60
72
|
}
|
package/src/validate.mjs
CHANGED
|
@@ -95,6 +95,10 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
95
95
|
doc.errors.push({ path: doc.path, level: 'error', message: '`blockers` must be a YAML list when present.' });
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
if (Object.prototype.hasOwnProperty.call(frontmatter, 'blocked_by') && !Array.isArray(frontmatter.blocked_by)) {
|
|
99
|
+
doc.errors.push({ path: doc.path, level: 'error', message: '`blocked_by` must be a YAML list when present.' });
|
|
100
|
+
}
|
|
101
|
+
|
|
98
102
|
if (Object.prototype.hasOwnProperty.call(frontmatter, 'surfaces') && !Array.isArray(frontmatter.surfaces)) {
|
|
99
103
|
doc.errors.push({ path: doc.path, level: 'error', message: '`surfaces` must be a YAML list when present.' });
|
|
100
104
|
}
|