dotmd-cli 0.39.9 → 0.40.1

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
@@ -334,15 +335,19 @@ Use --dry-run (-n) to preview changes without writing anything.`,
334
335
 
335
336
  check: `dotmd check — validate frontmatter and references
336
337
 
338
+ By default the warning list is suppressed: you see counts plus a one-line
339
+ pointer to \`dotmd doctor\` (auto-fix) or \`dotmd check --verbose\`
340
+ (per-doc detail). Errors are always shown in full.
341
+
337
342
  Options:
338
- --errors-only Show only errors, suppress warnings
343
+ --verbose Show every warning per-doc (with category collapse
344
+ applied — high-frequency auto-fixable categories
345
+ summarize to a one-line bulk-fix hint).
346
+ --no-collapse Like --verbose but disables category collapse too —
347
+ every warning prints raw.
348
+ --errors-only Show only errors, suppress warnings entirely
339
349
  --fix Auto-fix broken refs, lint issues, and regenerate index
340
- --json Output errors and warnings as JSON
341
- --no-collapse Show every warning per-doc (since 0.37.0, high-frequency
342
- auto-fixable warning categories — singular module/surface
343
- deprecations, updated-behind-git — are collapsed into a
344
- one-line summary with the bulk-fix command). --json output
345
- is unchanged regardless.
350
+ --json Output errors and warnings as JSON (always full detail)
346
351
  --dry-run, -n Preview fixes without writing (with --fix)`,
347
352
 
348
353
  archive: `dotmd archive <file> — archive a document
@@ -492,6 +497,17 @@ Options:
492
497
 
493
498
  Unknown module name suggests close matches (or lists what's available).`,
494
499
 
500
+ surfaces: `dotmd surfaces — list configured surface taxonomy
501
+
502
+ Prints the values accepted in \`surfaces:\` frontmatter, one per line.
503
+ Source: \`config.taxonomy.surfaces\` in dotmd.config.mjs.
504
+
505
+ Options:
506
+ --json Machine-readable shape: { surfaces: [...] }
507
+
508
+ When the project has no taxonomy configured, any surface value is accepted —
509
+ the command says so instead of printing an empty list.`,
510
+
495
511
  doctor: `dotmd doctor — auto-fix everything in one pass
496
512
 
497
513
  Runs in sequence: fix broken references, lint --fix, sync dates from
@@ -1207,6 +1223,7 @@ async function main() {
1207
1223
  const fix = args.includes('--fix');
1208
1224
  const errorsOnly = args.includes('--errors-only');
1209
1225
  const noCollapse = args.includes('--no-collapse');
1226
+ const verbose = args.includes('--verbose');
1210
1227
 
1211
1228
  if (fix) {
1212
1229
  // Auto-fix: broken refs, then lint, then rebuild index
@@ -1237,7 +1254,7 @@ async function main() {
1237
1254
  passed: freshIndex.errors.length === 0,
1238
1255
  }, null, 2) + '\n');
1239
1256
  } else {
1240
- process.stdout.write('\n' + renderCheck(freshIndex, config, { errorsOnly, noCollapse }));
1257
+ process.stdout.write('\n' + renderCheck(freshIndex, config, { errorsOnly, noCollapse, verbose }));
1241
1258
  }
1242
1259
  if (freshIndex.errors.length > 0) process.exitCode = 1;
1243
1260
  return;
@@ -1256,7 +1273,7 @@ async function main() {
1256
1273
  return;
1257
1274
  }
1258
1275
 
1259
- process.stdout.write(renderCheck(index, config, { errorsOnly, noCollapse }));
1276
+ process.stdout.write(renderCheck(index, config, { errorsOnly, noCollapse, verbose }));
1260
1277
  if (index.errors.length > 0) process.exitCode = 1;
1261
1278
  return;
1262
1279
  }
@@ -1317,6 +1334,11 @@ async function main() {
1317
1334
  }
1318
1335
  return;
1319
1336
  }
1337
+ if (command === 'surfaces') {
1338
+ const { runSurfaces } = await import('../src/surfaces.mjs');
1339
+ runSurfaces(restArgs, config);
1340
+ return;
1341
+ }
1320
1342
  if (command === 'briefing') {
1321
1343
  if (args.includes('--json')) {
1322
1344
  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.9",
3
+ "version": "0.40.1",
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/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/render.mjs CHANGED
@@ -377,7 +377,7 @@ export function renderCheck(index, config, opts = {}) {
377
377
  }
378
378
 
379
379
  function _renderCheck(index, opts = {}) {
380
- const { errorsOnly, noCollapse } = opts;
380
+ const { errorsOnly, noCollapse, verbose } = opts;
381
381
  const lines = ['Check', ''];
382
382
  lines.push(`- docs scanned: ${index.docs.length}`);
383
383
  lines.push(`- errors: ${index.errors.length}`);
@@ -392,22 +392,32 @@ function _renderCheck(index, opts = {}) {
392
392
  lines.push('');
393
393
  }
394
394
 
395
+ // Warnings: terse by default — print count + pointer. The full per-doc list
396
+ // grew long enough on real projects that it drowned the summary; agents now
397
+ // see warnings as a single line and opt in to detail when they need it.
398
+ // --verbose / --no-collapse expand to the full list (with collapse-by-category
399
+ // still applied unless --no-collapse is set).
395
400
  if (!errorsOnly && index.warnings.length > 0) {
396
- lines.push(yellow('Warnings'));
397
- if (noCollapse) {
398
- for (const issue of index.warnings) {
399
- lines.push(`- ${issue.path}: ${issue.message}`);
401
+ if (verbose || noCollapse) {
402
+ lines.push(yellow('Warnings'));
403
+ if (noCollapse) {
404
+ for (const issue of index.warnings) {
405
+ lines.push(`- ${issue.path}: ${issue.message}`);
406
+ }
407
+ } else {
408
+ const { passthrough, collapsed } = categorizeWarnings(index.warnings);
409
+ for (const issue of passthrough) {
410
+ lines.push(`- ${issue.path}: ${issue.message}`);
411
+ }
412
+ for (const sum of collapsed) {
413
+ lines.push(`- ${sum.count} ${sum.label} — run \`${sum.fix}\` to bulk-fix`);
414
+ }
400
415
  }
416
+ lines.push('');
401
417
  } else {
402
- const { passthrough, collapsed } = categorizeWarnings(index.warnings);
403
- for (const issue of passthrough) {
404
- lines.push(`- ${issue.path}: ${issue.message}`);
405
- }
406
- for (const sum of collapsed) {
407
- lines.push(`- ${sum.count} ${sum.label} — run \`${sum.fix}\` to bulk-fix`);
408
- }
418
+ lines.push(dim(`Run \`dotmd check --verbose\` for per-doc detail, or \`dotmd doctor\` to auto-fix where possible.`));
419
+ lines.push('');
409
420
  }
410
- lines.push('');
411
421
  }
412
422
 
413
423
  if (index.errors.length === 0 && index.warnings.length === 0) {
@@ -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