dotmd-cli 0.72.0 → 0.72.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.72.0",
3
+ "version": "0.72.1",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/init.mjs CHANGED
@@ -67,6 +67,55 @@ export const referenceFields = {
67
67
  };
68
68
  `;
69
69
 
70
+ // A git repository cannot hold an empty directory, so scaffolding `docs/plans/`
71
+ // and `docs/prompts/` and stopping there means both vanish for the next clone —
72
+ // `dotmd init` produced three committable files and two directories that existed
73
+ // only on the machine that ran it.
74
+ //
75
+ // plans/ gets a real sample rather than a keepfile: it is the one place a new user
76
+ // benefits from seeing the frontmatter shape before running `dotmd new`. It is
77
+ // written `status: planned` so it sits quietly in the pipeline instead of posing
78
+ // as live work, and it says how to delete itself.
79
+ //
80
+ // prompts/ gets a keepfile instead, and cannot get a sample: the live queue is
81
+ // gitignored by the rule this same command writes, and a committed prompt at
82
+ // `status: pending` would be silently consumed by the next no-arg `dotmd use`.
83
+ const SAMPLE_PLAN_NAME = 'example-plan.md';
84
+ const samplePlan = (today) => `---
85
+ type: plan
86
+ status: planned
87
+ created: ${today}
88
+ updated: ${today}
89
+ title: Example Plan
90
+ summary: A scaffolded sample showing what a dotmd plan looks like — safe to delete.
91
+ current_state: "Scaffolded by \`dotmd init\` as a shape reference. Nothing here is real work."
92
+ next_step: "Delete this file, or edit it into your first real plan."
93
+ ---
94
+
95
+ # Example Plan
96
+
97
+ > A scaffolded sample showing what a dotmd plan looks like — safe to delete.
98
+
99
+ ## Problem
100
+
101
+ \`dotmd init\` leaves this file behind so \`docs/plans/\` survives a clone (git cannot
102
+ track an empty directory) and so the frontmatter above has something to point at.
103
+
104
+ The fields that matter: \`status\` drives every listing, \`current_state\` and
105
+ \`next_step\` are what \`dotmd briefing\` reads out, and \`updated\` drives staleness.
106
+ Never hand-edit \`status:\` — \`dotmd set <status> <file>\` writes it, validates it
107
+ against the type, and runs the lifecycle hooks.
108
+
109
+ ## Phases
110
+
111
+ - [ ] Delete this file: \`dotmd archive docs/plans/${SAMPLE_PLAN_NAME}\`
112
+ - [ ] Write a real one: \`dotmd new plan <name>\`
113
+
114
+ ## Version History
115
+
116
+ - Scaffolded by \`dotmd init\`.
117
+ `;
118
+
70
119
  const STARTER_INDEX = `# Docs
71
120
 
72
121
  <!-- GENERATED:dotmd:start -->
@@ -290,6 +339,21 @@ export async function runInit(cwd, config, opts = {}) {
290
339
  if (!dryRun) mkdirSync(subPath, { recursive: true });
291
340
  process.stdout.write(` ${dryTag}${green('create')} docs/${sub}/\n`);
292
341
  }
342
+
343
+ // Give the directory something git can carry — but only when it is genuinely
344
+ // empty. Re-running init over a populated tree must never drop a sample plan
345
+ // into someone's real estate.
346
+ const keeper = sub === 'plans'
347
+ ? { file: SAMPLE_PLAN_NAME, body: samplePlan(new Date().toISOString().slice(0, 10)) }
348
+ : { file: '.gitkeep', body: '' };
349
+ const keeperPath = path.join(subPath, keeper.file);
350
+ const dirIsEmpty = !existsSync(subPath) || readdirSync(subPath).length === 0;
351
+ if (existsSync(keeperPath)) {
352
+ process.stdout.write(` ${dryTag}${dim('exists')} docs/${sub}/${keeper.file}\n`);
353
+ } else if (dirIsEmpty || dryRun) {
354
+ if (!dryRun) writeFileSync(keeperPath, keeper.body, 'utf8');
355
+ process.stdout.write(` ${dryTag}${green('create')} docs/${sub}/${keeper.file}\n`);
356
+ }
293
357
  }
294
358
 
295
359
  if (existsSync(indexPath)) {
@@ -313,21 +377,38 @@ export async function runInit(cwd, config, opts = {}) {
313
377
  process.stdout.write(` export const root = [${subs.map(s => `'${s}'`).join(', ')}];\n`);
314
378
  }
315
379
 
316
- // .gitignore: ensure .dotmd/ is ignored (session ownership records live there)
380
+ // .gitignore: two rules.
381
+ //
382
+ // .dotmd/ — session ownership records
383
+ // <docs>/prompts/*.md — the LIVE saved-prompt queue
384
+ //
385
+ // The second one is load-bearing. Saved prompts are session-local by design and
386
+ // the workflow docs say never to commit them, but nothing enforced that: a plain
387
+ // `git add -A` from any session swept the queue into the repo. Scoped with a
388
+ // single `*` so it stops at the directory — `prompts/archived/` is the committed
389
+ // historical record and must stay tracked. Anchored with a leading `/` so a
390
+ // `prompts/` directory elsewhere in the tree is unaffected.
317
391
  const gitignorePath = path.join(cwd, '.gitignore');
318
- const ignoreLine = '.dotmd/';
392
+ const docsRel = path.relative(cwd, docsDir).split(path.sep).join('/');
393
+ const promptsIgnore = `/${docsRel ? `${docsRel}/` : ''}prompts/*.md`;
394
+ const ignoreRules = [
395
+ { line: '.dotmd/', accepts: (l) => l === '.dotmd/' || l === '.dotmd' },
396
+ { line: promptsIgnore, accepts: (l) => l === promptsIgnore || l === promptsIgnore.slice(1) },
397
+ ];
319
398
  if (existsSync(gitignorePath)) {
320
399
  const current = readFileSync(gitignorePath, 'utf8');
321
- const has = current.split('\n').some(l => l.trim() === ignoreLine || l.trim() === '.dotmd');
322
- if (!has) {
400
+ const present = new Set(current.split('\n').map(l => l.trim()));
401
+ const missing = ignoreRules.filter(r => ![...present].some(l => r.accepts(l)));
402
+ if (missing.length) {
323
403
  const sep = current.endsWith('\n') ? '' : '\n';
324
- if (!dryRun) writeFileSync(gitignorePath, `${current}${sep}${ignoreLine}\n`, 'utf8');
325
- process.stdout.write(` ${dryTag}${green('update')} .gitignore (+${ignoreLine})\n`);
404
+ const added = missing.map(r => `${r.line}\n`).join('');
405
+ if (!dryRun) writeFileSync(gitignorePath, `${current}${sep}${added}`, 'utf8');
406
+ process.stdout.write(` ${dryTag}${green('update')} .gitignore (+${missing.map(r => r.line).join(', ')})\n`);
326
407
  } else {
327
408
  process.stdout.write(` ${dryTag}${dim('exists')} .gitignore\n`);
328
409
  }
329
410
  } else {
330
- if (!dryRun) writeFileSync(gitignorePath, `${ignoreLine}\n`, 'utf8');
411
+ if (!dryRun) writeFileSync(gitignorePath, ignoreRules.map(r => `${r.line}\n`).join(''), 'utf8');
331
412
  process.stdout.write(` ${dryTag}${green('create')} .gitignore\n`);
332
413
  }
333
414
 
@@ -397,6 +478,30 @@ export async function runInit(cwd, config, opts = {}) {
397
478
  }
398
479
  }
399
480
 
481
+ // Render the index against what we just scaffolded.
482
+ //
483
+ // `docs.md` ships a "no docs yet" placeholder, which stopped being true the
484
+ // moment this command also started writing a sample plan — so a fresh init
485
+ // committed an index that contradicted the tree beside it, and the user's first
486
+ // `dotmd check` opened with a stale-index warning. The config passed into
487
+ // runInit predates the file we just wrote, so re-resolve before rendering.
488
+ //
489
+ // Best-effort: a repo that scaffolds correctly but cannot render its index is
490
+ // still a successful init, and the next command regenerates it anyway.
491
+ if (!dryRun) {
492
+ try {
493
+ const { resolveConfig } = await import('./config.mjs');
494
+ const freshConfig = await resolveConfig(cwd);
495
+ if (freshConfig.indexPath) {
496
+ const { buildIndex } = await import('./index.mjs');
497
+ const { writeRenderedIndex } = await import('./index-file.mjs');
498
+ writeRenderedIndex(() => buildIndex(freshConfig, { fast: true }), freshConfig);
499
+ }
500
+ } catch {
501
+ // Leave the placeholder; `dotmd check` self-heals it on first run.
502
+ }
503
+ }
504
+
400
505
  process.stdout.write(`\nReady. A few starting points:\n`);
401
506
  process.stdout.write(` dotmd new doc my-doc # scaffold a reference doc\n`);
402
507
  process.stdout.write(` dotmd new plan my-plan # scaffold an execution plan\n`);
package/src/prompts.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, resolveDocPath, isArchivedPath } from './util.mjs';
4
+ import { asString, toRepoPath, die, resolveDocPath, resolveRefPath, isArchivedPath } from './util.mjs';
5
5
  import { buildIndex, resolveDocArg } from './index.mjs';
6
6
  import { runQuery } from './query.mjs';
7
7
  import { completePlanClaim, regenIndex, renderLifecycleMutation, runArchive, runStatus } from './lifecycle.mjs';
@@ -258,7 +258,7 @@ export async function consumePrompt(filePath, config, opts) {
258
258
 
259
259
  const planRef = asString(parsed.plan);
260
260
  let linkedClaim = null;
261
- if (planRef) linkedClaim = prepareLinkedPromptClaim(planRef, config);
261
+ if (planRef) linkedClaim = prepareLinkedPromptClaim(planRef, config, path.dirname(filePath));
262
262
 
263
263
  if (dryRun) {
264
264
  const prefix = dim('[dry-run]');
@@ -353,8 +353,16 @@ export async function writeConsumedBody(body, archivedPath, write = null, linked
353
353
  }
354
354
  }
355
355
 
356
- function prepareLinkedPromptClaim(planRef, config) {
357
- let planPath = resolveDocPath(planRef, config) ?? resolveDocArg(planRef, config, { dieOnMiss: false });
356
+ // `planRef` is a reference written in the prompt's own frontmatter, so it obeys
357
+ // the same convention every other ref field does: doc-relative first (baton's
358
+ // `../plans/x.md` from `docs/prompts/`), repo-root-relative second. Resolving it
359
+ // with `resolveDocPath` alone read only the repo-root form, so a doc-relative
360
+ // link — the form nothing validates, since `plan` is not a `referenceFields`
361
+ // entry — died as "missing" while pointing at a file that was plainly there.
362
+ function prepareLinkedPromptClaim(planRef, config, promptDir) {
363
+ let planPath = resolveRefPath(planRef, promptDir, config.repoRoot)
364
+ ?? resolveDocPath(planRef, config)
365
+ ?? resolveDocArg(planRef, config, { dieOnMiss: false });
358
366
  if (!planPath || !existsSync(planPath)) die(`Linked plan is missing; prompt was not consumed: ${planRef}`);
359
367
  planPath = authorizeManagedSource(planPath, config, { kind: 'Prompt linked plan source' }).path;
360
368
  const raw = readFileSync(planPath, 'utf8');