dotmd-cli 0.39.8 → 0.40.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 CHANGED
@@ -35,6 +35,7 @@ Analyze:
35
35
  deps [file] [--json] Dependency tree or overview
36
36
  modules [--sort cleanup] [--json] Module dashboard (plans grouped by module)
37
37
  module <name> [--json] Plans for one module, grouped by status
38
+ surfaces [--json] List configured surface taxonomy
38
39
  unblocks <file> [--json] Show what completes when this doc ships
39
40
  diff [file] [--summarize] Show changes since last updated date
40
41
  summary <file> [--json] AI summary of a document
@@ -492,6 +493,17 @@ Options:
492
493
 
493
494
  Unknown module name suggests close matches (or lists what's available).`,
494
495
 
496
+ surfaces: `dotmd surfaces — list configured surface taxonomy
497
+
498
+ Prints the values accepted in \`surfaces:\` frontmatter, one per line.
499
+ Source: \`config.taxonomy.surfaces\` in dotmd.config.mjs.
500
+
501
+ Options:
502
+ --json Machine-readable shape: { surfaces: [...] }
503
+
504
+ When the project has no taxonomy configured, any surface value is accepted —
505
+ the command says so instead of printing an empty list.`,
506
+
495
507
  doctor: `dotmd doctor — auto-fix everything in one pass
496
508
 
497
509
  Runs in sequence: fix broken references, lint --fix, sync dates from
@@ -760,12 +772,17 @@ Prompts are documents with \`type: prompt\`, typically saved under
760
772
  docs/prompts/. They seed future Claude sessions; consuming a prompt
761
773
  prints its body to stdout and atomically archives it (one-shot).
762
774
 
775
+ \`dotmd prompt\` (singular) is an alias for \`dotmd prompts\` — every
776
+ subcommand below works under either spelling.
777
+
763
778
  Subcommands:
764
779
  list List pending prompts (default)
765
780
  next Consume the oldest pending prompt:
766
781
  print body to stdout, flip status to archived
767
782
  use <file-or-slug> Consume a specific prompt (same as next, but
768
783
  targets the named prompt instead of picking oldest)
784
+ resume <file-or-slug> Alias for \`use\` — same behavior, easier name
785
+ when continuing a session
769
786
  archive <file-or-slug> Archive a prompt without printing its body
770
787
  shelve <file-or-slug> Park a prompt (status → shelved): kept in list,
771
788
  hidden from hud/briefing pending surfaces, skipped
@@ -792,6 +809,8 @@ Examples:
792
809
  claude "$(dotmd prompts next)" # consume oldest pending + run claude
793
810
  claude "$(dotmd prompts use resume-foo)" # by slug
794
811
  claude "$(dotmd prompts use docs/prompts/foo.md)" # by path
812
+ claude "$(dotmd prompts resume resume-foo)" # \`resume\` is an alias for \`use\`
813
+ dotmd prompt list # singular alias for \`dotmd prompts list\`
795
814
 
796
815
  dotmd prompts next --dry-run # preview without consuming
797
816
  dotmd prompts archive old-thing
@@ -964,7 +983,7 @@ the whole docs tree is scanned.`,
964
983
 
965
984
  async function main() {
966
985
  const args = process.argv.slice(2);
967
- const command = args[0] ?? 'list';
986
+ let command = args[0] ?? 'list';
968
987
 
969
988
  // Pre-config flags
970
989
  if (args.includes('--version') || args.includes('-v')) {
@@ -985,6 +1004,14 @@ async function main() {
985
1004
  return;
986
1005
  }
987
1006
 
1007
+ // Singular-form alias for the prompts subcommand namespace. Trivial
1008
+ // no-collision collapse — `prompt` was previously "unknown command", now
1009
+ // routes everywhere `prompts` does (incl. per-command --help below, and the
1010
+ // subcommand dispatch at the `prompts` branch in the chain). The other
1011
+ // singular/plural pairs (`plan`/`plans`, `module`/`modules`,
1012
+ // `status`/`statuses`) are deliberately kept distinct — see F20 plan.
1013
+ if (command === 'prompt') command = 'prompts';
1014
+
988
1015
  // Per-command help
989
1016
  if (args.includes('--help') || args.includes('-h')) {
990
1017
  process.stdout.write(`${HELP[command] ?? HELP._main}\n`);
@@ -1302,6 +1329,11 @@ async function main() {
1302
1329
  }
1303
1330
  return;
1304
1331
  }
1332
+ if (command === 'surfaces') {
1333
+ const { runSurfaces } = await import('../src/surfaces.mjs');
1334
+ runSurfaces(restArgs, config);
1335
+ return;
1336
+ }
1305
1337
  if (command === 'briefing') {
1306
1338
  if (args.includes('--json')) {
1307
1339
  const plans = index.docs.filter(d => d.type === 'plan');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.39.8",
3
+ "version": "0.40.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,8 +9,8 @@ import { bold, green, dim } from './color.mjs';
9
9
  // are deliberately under the cap so a fix-then-edit cycle doesn't reintroduce
10
10
  // the warning on the next few-word touch-up.
11
11
  const FIELDS = [
12
- { name: 'current_state', cap: 500, target: 300, heading: '## Current State' },
13
- { name: 'next_step', cap: 300, target: 200, heading: '## Next Step' },
12
+ { name: 'current_state', cap: 1500, target: 1200, heading: '## Current State' },
13
+ { name: 'next_step', cap: 300, target: 200, heading: '## Next Step' },
14
14
  ];
15
15
 
16
16
  export function runFrontmatterFix(config, opts = {}) {
package/src/hud.mjs CHANGED
@@ -76,10 +76,13 @@ export function buildHud(config) {
76
76
  // Validation error count — hud's "silent when clean" contract should treat
77
77
  // `check` errors as not-clean. Without this, a SessionStart hook firing hud
78
78
  // can leave the agent with no visible signal that a check is failing.
79
- // buildIndex wraps the same scan every other read command does; cost is fine.
79
+ // `errorsOnly: true` skips warning-only cross-doc passes (git staleness,
80
+ // bidirectional refs, claude-commands) that hud never reads — ~6× faster on
81
+ // SessionStart for platform-scale corpora. Per-file validation + checkIndex
82
+ // still run, so the error count matches `dotmd check`'s.
80
83
  let errors = 0;
81
84
  try {
82
- const index = buildIndex(config);
85
+ const index = buildIndex(config, { errorsOnly: true });
83
86
  errors = index.errors.length;
84
87
  } catch { /* swallow — bad config shouldn't break the SessionStart hook */ }
85
88
 
package/src/index.mjs CHANGED
@@ -7,14 +7,22 @@ import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalRef
7
7
  import { checkIndex } from './index-file.mjs';
8
8
  import { checkClaudeCommands } from './claude-commands.mjs';
9
9
 
10
- // `fast: true` skips every pass that only produces warnings/errors — the
11
- // rendered index file consumes only status/title/snapshot/etc., not the
12
- // validation output. Use it from `regenIndex` (post-mutation index refresh)
13
- // where validation has already run elsewhere (or will, next time the user
14
- // runs `dotmd check`). Saves the full-repo `git log` scan in
15
- // `checkGitStaleness` plus the bidirectional ref walk + claude-commands check.
10
+ // `fast: true` skips every pass that produces warnings/errors — the rendered
11
+ // index file consumes only status/title/snapshot/etc., not the validation
12
+ // output. Use it from `regenIndex` (post-mutation index refresh) where
13
+ // validation has already run elsewhere (or will, next time the user runs
14
+ // `dotmd check`). Saves the full-repo `git log` scan in `checkGitStaleness`
15
+ // plus the bidirectional ref walk + claude-commands check.
16
+ //
17
+ // `errorsOnly: true` runs every error-producing pass (per-file `validateDoc`,
18
+ // `checkIndex`, the `validate` hook) but skips the warning-only cross-doc
19
+ // passes (bidirectional refs, runlist back-pointers, git staleness, claude
20
+ // commands). Use it from `dotmd hud` — the SessionStart hook only renders the
21
+ // error COUNT, so the warning-only passes are pure overhead there. Preserves
22
+ // the invariant that hud's "✗ N validation errors" line matches `dotmd check`.
16
23
  export function buildIndex(config, opts = {}) {
17
- const { fast = false } = opts;
24
+ const { fast = false, errorsOnly = false } = opts;
25
+ const skipWarningOnlyChecks = fast || errorsOnly;
18
26
  const docs = collectDocFiles(config).map(f => parseDocFile(f, config, { fast }));
19
27
  if (!fast) {
20
28
  // Per-file validation (validateDoc) ran during parse without sibling
@@ -86,13 +94,13 @@ export function buildIndex(config, opts = {}) {
86
94
  countsByType[type][doc.status] = (countsByType[type][doc.status] ?? 0) + 1;
87
95
  }
88
96
 
89
- if (!fast) {
90
- if (config.indexPath) {
91
- const indexCheck = checkIndex(transformedDocs, config);
92
- warnings.push(...indexCheck.warnings);
93
- errors.push(...indexCheck.errors);
94
- }
97
+ if (!fast && config.indexPath) {
98
+ const indexCheck = checkIndex(transformedDocs, config);
99
+ warnings.push(...indexCheck.warnings);
100
+ errors.push(...indexCheck.errors);
101
+ }
95
102
 
103
+ if (!skipWarningOnlyChecks) {
96
104
  const refCheck = checkBidirectionalReferences(transformedDocs, config);
97
105
  warnings.push(...refCheck.warnings);
98
106
 
package/src/new.mjs CHANGED
@@ -5,10 +5,23 @@ 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';
8
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
8
9
 
9
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
11
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
11
12
 
13
+ // Surface-taxonomy hint emitted above the `surfaces:` line in scaffolded docs.
14
+ // Discoverable-by-default: the author sees valid values without leaving the file
15
+ // and without grepping sibling docs (issue #12 trap 1). When the project has no
16
+ // configured taxonomy, fall back to a bare `surfaces:` line.
17
+ function surfacesScaffold(ctx) {
18
+ const valid = ctx?.validSurfaces;
19
+ if (Array.isArray(valid) && valid.length > 0) {
20
+ return `# surfaces — valid: ${valid.join(', ')}\nsurfaces:`;
21
+ }
22
+ return 'surfaces:';
23
+ }
24
+
12
25
  const BUILTIN_TEMPLATES = {
13
26
  doc: {
14
27
  description: 'Reference doc, design note, module overview — build-up shape lite',
@@ -17,13 +30,15 @@ const BUILTIN_TEMPLATES = {
17
30
  // it lands in the Overview section. Without it, Overview is left blank
18
31
  // and the user fills it in.
19
32
  acceptsBody: true,
20
- frontmatter: (s, d) => [
33
+ frontmatter: (s, d, ctx) => [
21
34
  'type: doc',
22
35
  `status: ${s}`,
23
36
  `created: ${d}`,
24
37
  `updated: ${d}`,
38
+ '# modules — real module name(s), or `none` for platform/infra docs',
25
39
  'modules:',
26
- 'surfaces:',
40
+ ' - none',
41
+ surfacesScaffold(ctx),
27
42
  'domain:',
28
43
  'audience: internal',
29
44
  'related_plans:',
@@ -54,13 +69,15 @@ ${ctx?.bodyInput?.trim() ?? ''}
54
69
  // Body input lands in the Problem section. Plans don't have an Overview;
55
70
  // Problem is the established opening section in the build-up shape.
56
71
  acceptsBody: true,
57
- frontmatter: (s, d) => [
72
+ frontmatter: (s, d, ctx) => [
58
73
  'type: plan',
59
74
  `status: ${s}`,
60
75
  `created: ${d}`,
61
76
  `updated: ${d}`,
62
- 'surfaces:',
77
+ surfacesScaffold(ctx),
78
+ '# modules — real module name(s), or `none` for tooling/infra plans',
63
79
  'modules:',
80
+ ' - none',
64
81
  'domain:',
65
82
  'audience: internal',
66
83
  'parent_plan:',
@@ -164,6 +181,68 @@ Status markers (put in heading text):
164
181
  },
165
182
  };
166
183
 
184
+ // Body inputs from agents often arrive as a full document (frontmatter + body)
185
+ // written to a tempfile and passed via `@path` or stdin. Without this split,
186
+ // `dotmd new` would prepend its scaffold frontmatter and treat the input's
187
+ // frontmatter as literal body content — resulting in two `---` blocks and a
188
+ // duplicated title. We instead parse the leading block (if any), merge its
189
+ // keys onto the scaffold, and use only what follows as body. See issue #12
190
+ // trap 4. Returns `{ frontmatter: object|null, body: string }`.
191
+ function splitBodyFrontmatter(rawBody) {
192
+ if (!rawBody || typeof rawBody !== 'string') return { frontmatter: null, body: rawBody };
193
+ if (!rawBody.startsWith('---\n')) return { frontmatter: null, body: rawBody };
194
+ const { frontmatter: fmText, body } = extractFrontmatter(rawBody);
195
+ if (!fmText) return { frontmatter: null, body: rawBody };
196
+ const parsed = parseSimpleFrontmatter(fmText);
197
+ return { frontmatter: parsed, body };
198
+ }
199
+
200
+ // Serialize a single frontmatter key/value pair to a YAML block. Mirrors the
201
+ // scaffold's shape so merged output reads naturally next to scaffold defaults.
202
+ function serializeFmEntry(key, value) {
203
+ if (value === null || value === undefined || value === '') return `${key}:`;
204
+ if (Array.isArray(value)) {
205
+ if (value.length === 0) return `${key}:`;
206
+ return `${key}:\n${value.map(v => ` - ${v}`).join('\n')}`;
207
+ }
208
+ if (typeof value === 'string' && value.includes('\n')) {
209
+ const indented = value.split('\n').map(l => ` ${l}`).join('\n');
210
+ return `${key}: |\n${indented}`;
211
+ }
212
+ return `${key}: ${value}`;
213
+ }
214
+
215
+ // Replace each key in `overrides` within the scaffold-generated frontmatter
216
+ // string. Keys not present in the scaffold are appended. `type:` is never
217
+ // overwritten — the CLI's type arg wins (warning emitted on conflict).
218
+ function mergeBodyFrontmatter(scaffoldFm, overrides, cliType) {
219
+ if (!overrides || Object.keys(overrides).length === 0) return scaffoldFm;
220
+ let fm = scaffoldFm;
221
+ const appended = [];
222
+ for (const [key, value] of Object.entries(overrides)) {
223
+ if (key === 'type') {
224
+ if (cliType && value && value !== cliType) {
225
+ warn(`Body frontmatter declares \`type: ${value}\` but CLI arg is \`${cliType}\`; using \`${cliType}\`.`);
226
+ }
227
+ continue;
228
+ }
229
+ if (key === 'created' || key === 'updated') continue; // scaffold owns timestamps
230
+ const serialized = serializeFmEntry(key, value);
231
+ // Match `key:` line + any indented continuation (block-array items or
232
+ // block-scalar bodies). Indented lines start with whitespace; scaffold keys
233
+ // never do, so this consumes only the right slice.
234
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
235
+ const re = new RegExp(`^${escaped}:.*(\\n[ \\t]+.*)*`, 'm');
236
+ if (re.test(fm)) {
237
+ fm = fm.replace(re, serialized);
238
+ } else {
239
+ appended.push(serialized);
240
+ }
241
+ }
242
+ if (appended.length > 0) fm = fm + '\n' + appended.join('\n');
243
+ return fm;
244
+ }
245
+
167
246
  function readBodyInput(source) {
168
247
  if (source === '-') {
169
248
  try { return readFileSync(0, 'utf8'); } catch (err) { die(`Could not read body from stdin: ${err.message}`); }
@@ -261,6 +340,20 @@ export async function runNew(argv, config, opts = {}) {
261
340
  bodyInputSource = bodyArg === '-' ? 'stdin (`-`)' : (bodyArg.startsWith('@') ? `file (\`${bodyArg}\`)` : 'inline body argument');
262
341
  }
263
342
 
343
+ // If the body input has a leading `---…---` frontmatter block, lift its keys
344
+ // out so they override scaffold defaults; only the content after the closing
345
+ // `---` is treated as body. The natural agent pattern is to draft a full doc
346
+ // to a tempfile and pass `@path` — without this, the scaffold ends up with
347
+ // two `---` blocks. See issue #12 trap 4.
348
+ let bodyFrontmatter = null;
349
+ if (bodyInput !== null) {
350
+ const split = splitBodyFrontmatter(bodyInput);
351
+ if (split.frontmatter) {
352
+ bodyFrontmatter = split.frontmatter;
353
+ bodyInput = split.body;
354
+ }
355
+ }
356
+
264
357
  if (template.requiresBody && (!bodyInput || !bodyInput.trim())) {
265
358
  die(`\`${typeName}\` template requires a body. Pass inline, --message "...", - for stdin, or @path for a file.`);
266
359
  }
@@ -359,11 +452,13 @@ export async function runNew(argv, config, opts = {}) {
359
452
 
360
453
  // Generate content
361
454
  let content;
362
- const tmplCtx = { status, title: docTitle, today, bodyInput };
455
+ const validSurfaces = config.raw?.taxonomy?.surfaces ?? (config.validSurfaces ? [...config.validSurfaces] : null);
456
+ const tmplCtx = { status, title: docTitle, today, bodyInput, validSurfaces };
363
457
  if (typeof template === 'function') {
364
458
  content = template(name, tmplCtx);
365
459
  } else {
366
- const fm = template.frontmatter(status, today, tmplCtx);
460
+ let fm = template.frontmatter(status, today, tmplCtx);
461
+ if (bodyFrontmatter) fm = mergeBodyFrontmatter(fm, bodyFrontmatter, typeName);
367
462
  const body = template.body(docTitle, tmplCtx);
368
463
  content = `---\n${fm}\n---\n${body}`;
369
464
  }
package/src/prompts.mjs CHANGED
@@ -8,7 +8,10 @@ import { runArchive, runStatus } from './lifecycle.mjs';
8
8
  import { runNew } from './new.mjs';
9
9
  import { green, dim } from './color.mjs';
10
10
 
11
- const SUBCOMMANDS = new Set(['list', 'next', 'use', 'archive', 'new', 'shelve', 'unshelve']);
11
+ // `resume` is an alias for `use` agents reach for "resume" when continuing a
12
+ // session; `use` reads as internal mechanics. Both names stay valid; the
13
+ // canonical output ("Consumed: …") is unchanged.
14
+ const SUBCOMMANDS = new Set(['list', 'next', 'use', 'resume', 'archive', 'new', 'shelve', 'unshelve']);
12
15
 
13
16
  export async function runPrompts(argv, config, opts = {}) {
14
17
  const sub = argv[0];
@@ -22,6 +25,7 @@ export async function runPrompts(argv, config, opts = {}) {
22
25
  case 'list': return runPromptsList(rest, config, opts);
23
26
  case 'next': return runPromptsNext(rest, config, opts);
24
27
  case 'use': return runPromptsUse(rest, config, opts);
28
+ case 'resume': return runPromptsUse(rest, config, opts);
25
29
  case 'archive': return runPromptsArchive(rest, config, opts);
26
30
  case 'new': return runPromptsNew(rest, config, opts);
27
31
  case 'shelve': return runPromptsShelve(rest, config, opts);
@@ -0,0 +1,28 @@
1
+ // `dotmd surfaces` — print the configured surface taxonomy.
2
+ //
3
+ // The surface taxonomy (`config.taxonomy.surfaces`) gates which `surfaces:`
4
+ // values the validator accepts. Before this command existed the only way to
5
+ // discover the valid set was to grep sibling plans or open the config file —
6
+ // which sent agents into a retry loop of "guess a surface, run check, get
7
+ // flagged, guess again." See issue #12 trap 1.
8
+ import { dim } from './color.mjs';
9
+
10
+ export function runSurfaces(argv, config) {
11
+ const json = argv.includes('--json');
12
+ // Read from raw user config (preserves declaration order) — `config.taxonomy`
13
+ // isn't exposed on the resolved object; only the derived `validSurfaces` Set is.
14
+ const surfaces = config.raw?.taxonomy?.surfaces ?? null;
15
+
16
+ if (json) {
17
+ process.stdout.write(JSON.stringify({ surfaces: surfaces ?? [] }, null, 2) + '\n');
18
+ return;
19
+ }
20
+
21
+ if (!surfaces || surfaces.length === 0) {
22
+ process.stdout.write(dim('No surface taxonomy configured. Any surface value is accepted.\n'));
23
+ process.stdout.write(dim('To restrict, set `taxonomy.surfaces` in dotmd.config.mjs.\n'));
24
+ return;
25
+ }
26
+
27
+ for (const s of surfaces) process.stdout.write(s + '\n');
28
+ }
package/src/validate.mjs CHANGED
@@ -444,13 +444,16 @@ export function validatePlanShape(doc, body, frontmatter, config) {
444
444
  });
445
445
  }
446
446
 
447
- // 2. current_state length cap (500 chars)
447
+ // 2. current_state length cap (1500 chars). Was 500; raised because agents
448
+ // legitimately need ~150-250 words of resume-context (prior incidents, what
449
+ // shipped, what's verified, where to look) and the prior cap forced a
450
+ // truncate-and-move-to-body retry loop on non-trivial plans.
448
451
  const currentState = typeof frontmatter.current_state === 'string' ? frontmatter.current_state : '';
449
- if (currentState.length > 500) {
452
+ if (currentState.length > 1500) {
450
453
  doc.warnings.push({
451
454
  path: doc.path,
452
455
  level: 'warning',
453
- message: `\`current_state\` is ${currentState.length} chars (cap: 500). Long prose belongs in the body.`,
456
+ message: `\`current_state\` is ${currentState.length} chars (cap: 1500). Long prose belongs in the body.`,
454
457
  });
455
458
  }
456
459