dotmd-cli 0.39.1 → 0.39.3
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 -1
- package/package.json +1 -1
- package/src/index.mjs +7 -1
- package/src/lifecycle.mjs +84 -13
- package/src/new.mjs +68 -11
- package/src/prompts.mjs +9 -5
- package/src/util.mjs +12 -0
- package/src/validate.mjs +4 -0
package/bin/dotmd.mjs
CHANGED
|
@@ -170,6 +170,8 @@ If a plan is already in-session:
|
|
|
170
170
|
|
|
171
171
|
Options:
|
|
172
172
|
--takeover Force-claim a plan held by another session
|
|
173
|
+
--no-index Skip index regen (see \`dotmd archive --help\`)
|
|
174
|
+
--show-files Append \`files: …\` line to stderr (see \`dotmd archive --help\`)
|
|
173
175
|
--json Output as JSON
|
|
174
176
|
--dry-run, -n Preview without writing
|
|
175
177
|
|
|
@@ -191,6 +193,8 @@ Options:
|
|
|
191
193
|
--all Release every lease in the file (administrative)
|
|
192
194
|
--stale Release leases whose pid is dead or age >24h
|
|
193
195
|
--force Override "not yours" refusal on a specific file
|
|
196
|
+
--no-index Skip index regen (see \`dotmd archive --help\`)
|
|
197
|
+
--show-files Append \`files: …\` line to stderr (see \`dotmd archive --help\`)
|
|
194
198
|
--json Output as JSON ({ released, skipped })
|
|
195
199
|
--dry-run, -n Preview without writing
|
|
196
200
|
|
|
@@ -221,6 +225,11 @@ Moves the document to the new status. If transitioning to an archive
|
|
|
221
225
|
status, automatically moves the file to the archive directory and
|
|
222
226
|
regenerates the index (if configured).
|
|
223
227
|
|
|
228
|
+
Options:
|
|
229
|
+
--no-index Skip index regen (useful in concurrent-session repos
|
|
230
|
+
doing path-limited commits — see \`dotmd archive --help\`).
|
|
231
|
+
--show-files Append \`files: …\` line to stderr (see \`dotmd archive --help\`).
|
|
232
|
+
|
|
224
233
|
Default plan statuses (each maps to a distinct unstuck-action):
|
|
225
234
|
in-session A Claude session is working on it now
|
|
226
235
|
active Ready to be picked up
|
|
@@ -252,7 +261,17 @@ Options:
|
|
|
252
261
|
Sets status to 'archived', moves to the archive directory, auto-updates
|
|
253
262
|
references in other docs, and regenerates the index.
|
|
254
263
|
|
|
255
|
-
|
|
264
|
+
Options:
|
|
265
|
+
--no-index Skip index regen. Use when multiple sessions are
|
|
266
|
+
working concurrently and you want a path-limited
|
|
267
|
+
commit that doesn't pull other agents' uncommitted
|
|
268
|
+
index changes into your staging area. Run \`dotmd index\`
|
|
269
|
+
later (or wire it into a commit hook) to refresh.
|
|
270
|
+
--show-files Append a final \`files: a b c …\` line to stderr
|
|
271
|
+
listing every doc/index path the command touched
|
|
272
|
+
(deduped, sorted, repo-relative). Lets agents do
|
|
273
|
+
\`git add\` with the exact set instead of guessing.
|
|
274
|
+
--dry-run, -n Preview changes without writing anything.`,
|
|
256
275
|
|
|
257
276
|
coverage: `dotmd coverage — metadata coverage report
|
|
258
277
|
|
|
@@ -488,6 +507,8 @@ Other options:
|
|
|
488
507
|
--status <s> Set initial status (defaults to first valid status for the type)
|
|
489
508
|
--title <t> Override the auto-derived title
|
|
490
509
|
--root <name> Create in a specific docs root
|
|
510
|
+
--show-files Append \`files: …\` line to stderr listing what was touched
|
|
511
|
+
(the new doc + the index file). See \`dotmd archive --help\`.
|
|
491
512
|
--list-types Show registered types (alias: --list-templates)
|
|
492
513
|
|
|
493
514
|
For plans, the default status vocabulary is: in-session, active, planned,
|
|
@@ -680,6 +701,21 @@ Supports all query flags (--status, --json, --sort, etc.)`,
|
|
|
680
701
|
Shows documents that reference or depend on the given file.
|
|
681
702
|
Useful for impact analysis before archiving or changing a plan.
|
|
682
703
|
|
|
704
|
+
The dependency edge is read from each plan's \`blockers:\` frontmatter
|
|
705
|
+
(a YAML list of plan slugs or paths). \`blocked_by:\` is accepted as
|
|
706
|
+
an alias since 0.39.3 — both populate the same index field, so use
|
|
707
|
+
whichever name reads better.
|
|
708
|
+
|
|
709
|
+
Frontmatter shape:
|
|
710
|
+
|
|
711
|
+
---
|
|
712
|
+
type: plan
|
|
713
|
+
status: blocked
|
|
714
|
+
blockers:
|
|
715
|
+
- foo-plan.md
|
|
716
|
+
- docs/plans/bar-plan.md
|
|
717
|
+
---
|
|
718
|
+
|
|
683
719
|
Options:
|
|
684
720
|
--json Output as JSON`,
|
|
685
721
|
|
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
|
|
@@ -253,7 +271,11 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
253
271
|
}
|
|
254
272
|
if (oldStatus !== 'in-session') {
|
|
255
273
|
updateFrontmatter(filePath, { status: 'in-session', updated: today });
|
|
256
|
-
|
|
274
|
+
if (noIndex) {
|
|
275
|
+
process.stderr.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
|
|
276
|
+
} else {
|
|
277
|
+
regenIndex(config);
|
|
278
|
+
}
|
|
257
279
|
}
|
|
258
280
|
// VH append per lease outcome:
|
|
259
281
|
// acquired → `Picked up (<old> → in-session).`
|
|
@@ -295,6 +317,12 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
295
317
|
}
|
|
296
318
|
}
|
|
297
319
|
|
|
320
|
+
if (showFiles && oldStatus !== 'in-session') {
|
|
321
|
+
const touched = [filePath];
|
|
322
|
+
if (config.indexPath && !noIndex) touched.push(config.indexPath);
|
|
323
|
+
emitFilesFooter(touched, config);
|
|
324
|
+
}
|
|
325
|
+
|
|
298
326
|
try { config.hooks.onPickup?.({ path: repoPath, oldStatus, newStatus: 'in-session' }); } catch (err) { warn(`Hook 'onPickup' threw: ${err.message}`); }
|
|
299
327
|
}
|
|
300
328
|
|
|
@@ -304,10 +332,13 @@ export async function runUnpickup(argv, config, opts = {}) {
|
|
|
304
332
|
const all = argv.includes('--all');
|
|
305
333
|
const stale = argv.includes('--stale');
|
|
306
334
|
const force = argv.includes('--force');
|
|
335
|
+
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
336
|
+
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
307
337
|
const toIdx = argv.indexOf('--to');
|
|
308
338
|
const toStatus = toIdx >= 0 ? argv[toIdx + 1] : null;
|
|
309
339
|
const positional = argv.filter((a, i) => !a.startsWith('-') && argv[i - 1] !== '--to');
|
|
310
340
|
const fileArg = positional[0];
|
|
341
|
+
const touched = [];
|
|
311
342
|
|
|
312
343
|
const session = currentSessionId();
|
|
313
344
|
const released = [];
|
|
@@ -360,7 +391,12 @@ export async function runUnpickup(argv, config, opts = {}) {
|
|
|
360
391
|
const today = nowIso();
|
|
361
392
|
updateFrontmatter(filePath, { status: newStatus, updated: today });
|
|
362
393
|
appendVersionHistory(filePath, `Released (in-session → ${newStatus}).`);
|
|
363
|
-
|
|
394
|
+
touched.push(filePath);
|
|
395
|
+
if (noIndex) {
|
|
396
|
+
process.stderr.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
|
|
397
|
+
} else {
|
|
398
|
+
regenIndex(config);
|
|
399
|
+
}
|
|
364
400
|
}
|
|
365
401
|
// If frontmatter is no longer in-session (manual flip), leave it alone.
|
|
366
402
|
} catch (err) {
|
|
@@ -429,6 +465,12 @@ export async function runUnpickup(argv, config, opts = {}) {
|
|
|
429
465
|
process.stderr.write(`${yellow('⚠ Skipped')}: ${s.path} (held by ${s.session}; use --force to override)\n`);
|
|
430
466
|
}
|
|
431
467
|
}
|
|
468
|
+
|
|
469
|
+
if (showFiles && touched.length > 0) {
|
|
470
|
+
const all = [...touched];
|
|
471
|
+
if (config.indexPath && !noIndex) all.push(config.indexPath);
|
|
472
|
+
emitFilesFooter(all, config);
|
|
473
|
+
}
|
|
432
474
|
}
|
|
433
475
|
|
|
434
476
|
export async function runFinish(argv, config, opts = {}) {
|
|
@@ -493,6 +535,9 @@ export async function runFinish(argv, config, opts = {}) {
|
|
|
493
535
|
|
|
494
536
|
export function runArchive(argv, config, opts = {}) {
|
|
495
537
|
const { dryRun, out = process.stdout } = opts;
|
|
538
|
+
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
539
|
+
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
540
|
+
argv = argv.filter(a => a !== '--no-index' && a !== '--show-files');
|
|
496
541
|
const input = argv[0];
|
|
497
542
|
|
|
498
543
|
if (!input) { die('Usage: dotmd archive <file>'); }
|
|
@@ -519,7 +564,8 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
519
564
|
const prefix = dim('[dry-run]');
|
|
520
565
|
out.write(`${prefix} Would update frontmatter: status: ${oldStatus} → archived, updated: ${today}\n`);
|
|
521
566
|
out.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
522
|
-
if (config.indexPath) out.write(`${prefix} Would regenerate index\n`);
|
|
567
|
+
if (config.indexPath && !noIndex) out.write(`${prefix} Would regenerate index\n`);
|
|
568
|
+
if (config.indexPath && noIndex) out.write(`${prefix} Would skip index regen (--no-index)\n`);
|
|
523
569
|
|
|
524
570
|
// Preview reference updates
|
|
525
571
|
const refCount = countRefsToUpdate(filePath, targetPath, config);
|
|
@@ -541,22 +587,31 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
541
587
|
const selfRefsFixed = updateRefsFromMovedFile(filePath, targetPath, config);
|
|
542
588
|
|
|
543
589
|
// Auto-update references in other docs
|
|
544
|
-
const updatedRefCount = updateRefsAfterMove(filePath, targetPath, config);
|
|
590
|
+
const { count: updatedRefCount, paths: refTouchedPaths } = updateRefsAfterMove(filePath, targetPath, config);
|
|
545
591
|
|
|
546
|
-
regenIndex(config);
|
|
592
|
+
if (!noIndex) regenIndex(config);
|
|
547
593
|
|
|
548
594
|
out.write(`${green('Archived')}: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
549
595
|
if (selfRefsFixed) out.write('Updated references in archived file.\n');
|
|
550
596
|
if (updatedRefCount > 0) out.write(`Updated references in ${updatedRefCount} file(s).\n`);
|
|
551
|
-
if (config.indexPath) out.write('Index regenerated.\n');
|
|
597
|
+
if (config.indexPath && !noIndex) out.write('Index regenerated.\n');
|
|
598
|
+
if (config.indexPath && noIndex) out.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
|
|
552
599
|
|
|
553
600
|
try { releaseLease(config, oldRepoPath, { force: true }); } catch (err) { warn(`Could not release lease for ${oldRepoPath}: ${err.message}`); }
|
|
554
601
|
|
|
602
|
+
const touched = [oldRepoPath, newRepoPath, ...refTouchedPaths];
|
|
603
|
+
if (config.indexPath && !noIndex) touched.push(config.indexPath);
|
|
604
|
+
if (showFiles) emitFilesFooter(touched, config);
|
|
605
|
+
|
|
555
606
|
try { config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath }); } catch (err) { warn(`Hook 'onArchive' threw: ${err.message}`); }
|
|
607
|
+
|
|
608
|
+
return { touched };
|
|
556
609
|
}
|
|
557
610
|
|
|
558
611
|
export function runBulkArchive(argv, config, opts = {}) {
|
|
559
612
|
const { dryRun } = opts;
|
|
613
|
+
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
614
|
+
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
560
615
|
const inputs = argv.filter(a => !a.startsWith('-'));
|
|
561
616
|
if (inputs.length === 0) die('Usage: dotmd bulk archive <file1> <file2> ... or <glob>');
|
|
562
617
|
|
|
@@ -592,14 +647,30 @@ export function runBulkArchive(argv, config, opts = {}) {
|
|
|
592
647
|
}
|
|
593
648
|
|
|
594
649
|
process.stdout.write('\n');
|
|
650
|
+
// Bulk archives always defer index regen to the end — N individual regens
|
|
651
|
+
// is wasteful and the final state is the same. `--no-index` skips even
|
|
652
|
+
// the final one.
|
|
653
|
+
const bulkTouched = [];
|
|
595
654
|
for (const f of unique) {
|
|
596
655
|
const relPath = toRepoPath(f, config.repoRoot);
|
|
597
656
|
try {
|
|
598
|
-
runArchive([relPath], config, opts);
|
|
657
|
+
const result = runArchive([relPath], config, { ...opts, noIndex: true, showFiles: false });
|
|
658
|
+
if (result?.touched) bulkTouched.push(...result.touched);
|
|
599
659
|
} catch (err) {
|
|
600
660
|
warn(`Failed to archive ${relPath}: ${err.message}`);
|
|
601
661
|
}
|
|
602
662
|
}
|
|
663
|
+
if (!noIndex) {
|
|
664
|
+
regenIndex(config);
|
|
665
|
+
if (config.indexPath) process.stdout.write('Index regenerated.\n');
|
|
666
|
+
} else if (config.indexPath) {
|
|
667
|
+
process.stdout.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
|
|
668
|
+
}
|
|
669
|
+
if (showFiles) {
|
|
670
|
+
const all = [...bulkTouched];
|
|
671
|
+
if (config.indexPath && !noIndex) all.push(config.indexPath);
|
|
672
|
+
emitFilesFooter(all, config);
|
|
673
|
+
}
|
|
603
674
|
}
|
|
604
675
|
|
|
605
676
|
export function runTouch(argv, config, opts = {}) {
|
|
@@ -682,7 +753,7 @@ export function runTouch(argv, config, opts = {}) {
|
|
|
682
753
|
function updateRefsAfterMove(oldPath, newPath, config) {
|
|
683
754
|
const basename = path.basename(oldPath);
|
|
684
755
|
const allFiles = collectDocFiles(config);
|
|
685
|
-
|
|
756
|
+
const touched = [];
|
|
686
757
|
|
|
687
758
|
for (const docFile of allFiles) {
|
|
688
759
|
if (docFile === newPath) continue;
|
|
@@ -710,11 +781,11 @@ function updateRefsAfterMove(oldPath, newPath, config) {
|
|
|
710
781
|
if (newFm !== fm) {
|
|
711
782
|
raw = replaceFrontmatter(raw, newFm);
|
|
712
783
|
writeFileSync(docFile, raw, 'utf8');
|
|
713
|
-
|
|
784
|
+
touched.push(docFile);
|
|
714
785
|
}
|
|
715
786
|
}
|
|
716
787
|
|
|
717
|
-
return
|
|
788
|
+
return { count: touched.length, paths: touched };
|
|
718
789
|
}
|
|
719
790
|
|
|
720
791
|
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;
|
|
@@ -265,17 +267,37 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
265
267
|
|
|
266
268
|
// Fail-fast when the user passes body input to a template that doesn't
|
|
267
269
|
// consume it — silently discarding heredoc content is the worst UX.
|
|
268
|
-
// Templates opt in via `acceptsBody: true` or `requiresBody: true`.
|
|
269
|
-
// built-in templates (doc, plan, prompt) accept body; this guard fires
|
|
270
|
-
// only for custom templates that opt out.
|
|
270
|
+
// Templates opt in via `acceptsBody: true` or `requiresBody: true`.
|
|
271
271
|
if (bodyInput !== null && !template.acceptsBody && !template.requiresBody) {
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
272
|
+
const configTemplates = config.raw?.templates ?? {};
|
|
273
|
+
// Compute the accepting list from the RESOLVED set (config merged over
|
|
274
|
+
// built-ins) so the hint doesn't contradict the rejection.
|
|
275
|
+
const resolvedNames = new Set([...Object.keys(BUILTIN_TEMPLATES), ...Object.keys(configTemplates)]);
|
|
276
|
+
const accepting = [...resolvedNames]
|
|
277
|
+
.filter(n => n !== typeName)
|
|
278
|
+
.filter(n => {
|
|
279
|
+
const t = resolveTemplate(n, config);
|
|
280
|
+
return t.acceptsBody || t.requiresBody;
|
|
281
|
+
});
|
|
275
282
|
const hint = accepting.length > 0
|
|
276
283
|
? ` Templates that accept body input: ${accepting.join(', ')}.`
|
|
277
284
|
: '';
|
|
278
|
-
|
|
285
|
+
|
|
286
|
+
// Override-of-builtin diagnosis: the most common cause is a project
|
|
287
|
+
// dotmd.config.mjs that copy-pasted a stripped-down `plan` template
|
|
288
|
+
// and dropped the body-acceptance contract. Name that explicitly so
|
|
289
|
+
// an agent can self-fix without spelunking the config.
|
|
290
|
+
const builtin = BUILTIN_TEMPLATES[typeName];
|
|
291
|
+
const isOverride = Boolean(configTemplates[typeName] && builtin);
|
|
292
|
+
const builtinAccepts = Boolean(builtin && (builtin.acceptsBody || builtin.requiresBody));
|
|
293
|
+
let cause;
|
|
294
|
+
if (isOverride && builtinAccepts) {
|
|
295
|
+
const where = config.configPath ? toRepoPath(config.configPath, config.repoRoot) : 'dotmd.config.mjs';
|
|
296
|
+
cause = `Your config (${where}) overrides the built-in \`${typeName}\` template, and the override drops body acceptance.\nFix: in that override, add \`acceptsBody: true\` AND interpolate \`\${ctx?.bodyInput?.trim() ?? ''}\` into your \`body\` fn (e.g., inside \`## Problem\`). Or drop the override to use the built-in.`;
|
|
297
|
+
} else {
|
|
298
|
+
cause = `Either drop the body, switch to a template that accepts it, or set \`acceptsBody: true\` on your custom \`${typeName}\` template in dotmd.config.mjs.`;
|
|
299
|
+
}
|
|
300
|
+
die(`\`${typeName}\` template does not accept body input, but body was passed via ${bodyInputSource}.${hint}\n${cause}`);
|
|
279
301
|
}
|
|
280
302
|
|
|
281
303
|
// If name contains path separators, split into directory prefix and basename
|
|
@@ -375,14 +397,49 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
375
397
|
|
|
376
398
|
regenIndex(config);
|
|
377
399
|
|
|
400
|
+
if (showFiles) {
|
|
401
|
+
const touched = [filePath];
|
|
402
|
+
if (config.indexPath) touched.push(config.indexPath);
|
|
403
|
+
emitFilesFooter(touched, config);
|
|
404
|
+
}
|
|
405
|
+
|
|
378
406
|
try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle, type: typeName }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
|
|
379
407
|
}
|
|
380
408
|
|
|
381
409
|
function resolveTemplate(name, config) {
|
|
382
|
-
// Config templates take priority
|
|
383
410
|
const configTemplates = config.raw?.templates ?? {};
|
|
384
|
-
|
|
385
|
-
|
|
411
|
+
const override = configTemplates[name];
|
|
412
|
+
const builtin = BUILTIN_TEMPLATES[name];
|
|
413
|
+
|
|
414
|
+
if (override) {
|
|
415
|
+
if (!builtin) return { ...override, _overridesBuiltin: false };
|
|
416
|
+
// Partial-override DX: shallow-merge built-in under override so missing
|
|
417
|
+
// fields (description, dir, targetRoot, defaultStatus, frontmatter, body,
|
|
418
|
+
// acceptsBody, requiresBody) fall back to the built-in. Anything the
|
|
419
|
+
// override explicitly declares wins.
|
|
420
|
+
const merged = { ...builtin, ...override, _overridesBuiltin: true };
|
|
421
|
+
|
|
422
|
+
// Body-loss guard: if the override supplies its OWN body fn but doesn't
|
|
423
|
+
// explicitly opt in to body acceptance, the inherited built-in
|
|
424
|
+
// `acceptsBody`/`requiresBody` could let body input flow into a custom
|
|
425
|
+
// body fn that doesn't honor `ctx.bodyInput` — silently discarding the
|
|
426
|
+
// heredoc, the worst-UX bug fix #9 was added to prevent. Strip the
|
|
427
|
+
// inherited flags so the fail-fast guard fires. EXCEPT when the custom
|
|
428
|
+
// body fn references `bodyInput` itself, in which case it's clearly
|
|
429
|
+
// body-aware and inheriting acceptsBody is the agent-first move.
|
|
430
|
+
const overrodeBody = typeof override.body === 'function';
|
|
431
|
+
const declaredAcceptance = override.acceptsBody !== undefined || override.requiresBody !== undefined;
|
|
432
|
+
if (overrodeBody && !declaredAcceptance) {
|
|
433
|
+
const bodyAware = /bodyInput/.test(override.body.toString());
|
|
434
|
+
if (!bodyAware) {
|
|
435
|
+
merged.acceptsBody = undefined;
|
|
436
|
+
merged.requiresBody = undefined;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return merged;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (builtin) return builtin;
|
|
386
443
|
|
|
387
444
|
const available = [...new Set([...Object.keys(BUILTIN_TEMPLATES), ...Object.keys(configTemplates)])];
|
|
388
445
|
die(`Unknown type: ${name}\nAvailable: ${available.join(', ')}`);
|
package/src/prompts.mjs
CHANGED
|
@@ -116,12 +116,14 @@ function resolvePromptInput(input, config) {
|
|
|
116
116
|
function runPromptsUse(argv, config, opts = {}) {
|
|
117
117
|
const input = argv.find(a => !a.startsWith('-'));
|
|
118
118
|
if (!input) die('Usage: dotmd prompts use <file-or-slug>');
|
|
119
|
+
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
120
|
+
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
119
121
|
const filePath = resolvePromptInput(input, config);
|
|
120
|
-
consumePrompt(filePath, config, opts);
|
|
122
|
+
consumePrompt(filePath, config, { ...opts, noIndex, showFiles });
|
|
121
123
|
}
|
|
122
124
|
|
|
123
125
|
function consumePrompt(filePath, config, opts) {
|
|
124
|
-
const { dryRun } = opts;
|
|
126
|
+
const { dryRun, noIndex, showFiles } = opts;
|
|
125
127
|
const raw = readFileSync(filePath, 'utf8');
|
|
126
128
|
const { frontmatter, body } = extractFrontmatter(raw);
|
|
127
129
|
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
@@ -138,20 +140,22 @@ function consumePrompt(filePath, config, opts) {
|
|
|
138
140
|
|
|
139
141
|
if (dryRun) {
|
|
140
142
|
process.stderr.write(`${dim('[dry-run]')} Would emit body and archive: ${repoPath} (${status ?? 'unknown'} → archived)\n`);
|
|
141
|
-
runArchive([filePath], config, { dryRun: true, out: process.stderr });
|
|
143
|
+
runArchive([filePath], config, { dryRun: true, noIndex, out: process.stderr });
|
|
142
144
|
return;
|
|
143
145
|
}
|
|
144
146
|
|
|
145
147
|
process.stdout.write(body);
|
|
146
148
|
if (!body.endsWith('\n')) process.stdout.write('\n');
|
|
147
149
|
|
|
148
|
-
runArchive([filePath], config, { out: process.stderr });
|
|
150
|
+
runArchive([filePath], config, { noIndex, showFiles, out: process.stderr });
|
|
149
151
|
process.stderr.write(`${green('✓ Consumed')}: ${repoPath}\n`);
|
|
150
152
|
}
|
|
151
153
|
|
|
152
154
|
function runPromptsArchive(argv, config, opts = {}) {
|
|
153
155
|
const input = argv.find(a => !a.startsWith('-'));
|
|
154
156
|
if (!input) die('Usage: dotmd prompts archive <file-or-slug>');
|
|
157
|
+
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
158
|
+
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
155
159
|
const filePath = resolvePromptInput(input, config);
|
|
156
160
|
|
|
157
161
|
const raw = readFileSync(filePath, 'utf8');
|
|
@@ -161,7 +165,7 @@ function runPromptsArchive(argv, config, opts = {}) {
|
|
|
161
165
|
die(`Not a prompt: ${toRepoPath(filePath, config.repoRoot)}`);
|
|
162
166
|
}
|
|
163
167
|
|
|
164
|
-
runArchive([filePath], config, opts);
|
|
168
|
+
runArchive([filePath], config, { ...opts, noIndex, showFiles });
|
|
165
169
|
}
|
|
166
170
|
|
|
167
171
|
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
|
}
|