dotmd-cli 0.22.1 → 0.23.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
@@ -55,7 +55,7 @@ Lifecycle:
55
55
  migrate <field> <old> <new> [f...]Batch update a frontmatter field value (optional file filter)
56
56
 
57
57
  Create & Export:
58
- new <name> [--template <t>] Create doc from template (plan, adr, rfc, audit, design)
58
+ new <type> <name> [body] Create doc of given type (plan, doc, prompt, research)
59
59
  index [--write] Generate/update docs.md index block
60
60
  export [--format md|html|json] Export docs as markdown, HTML, or JSON
61
61
  notion import|export|sync [db-id] Notion database integration
@@ -386,22 +386,42 @@ With --write, updates the configured index file in place.
386
386
 
387
387
  Use --dry-run (-n) with --write to preview without writing.`,
388
388
 
389
- new: `dotmd new <name> — create a new document
389
+ new: `dotmd new <type> <name> [body] — create a new document
390
390
 
391
- Creates a new markdown document with frontmatter in the docs root.
391
+ Types and their default destinations:
392
+ plan docs/plans/<slug>.md (build-up template: Problem → Phases → Closeout)
393
+ doc docs/<slug>.md (minimal reference doc)
394
+ prompt docs/prompts/<slug>.md (saved prompt to seed a future session — body required)
395
+ research docs/<slug>.md (audit / investigation)
392
396
 
393
- Options:
394
- --template <name> Use a template (default, plan, adr, rfc, audit, design)
395
- --status <s> Set initial status (default: active)
396
- --title <t> Override the document title
397
+ \`<type>\` can be omitted; defaults to \`doc\`.
398
+ \`<name>\` is slugified for the filename.
399
+
400
+ Body input (prompt type only):
401
+ <text> Inline body as 3rd positional
402
+ --message "<text>" Explicit inline body
403
+ - Read body from stdin (heredoc-friendly for agents)
404
+ @path Read body from a file
405
+
406
+ Examples:
407
+ dotmd new plan auth-revamp
408
+ dotmd new prompt cleanup-tomorrow "look at remaining lint warnings"
409
+ dotmd new prompt resume-foo - <<'EOF'
410
+ multi-line
411
+ prompt body
412
+ EOF
413
+ dotmd new prompt from-file @/tmp/draft.md
414
+
415
+ Other options:
416
+ --status <s> Set initial status (defaults to first valid status for the type)
417
+ --title <t> Override the auto-derived title
397
418
  --root <name> Create in a specific docs root
398
- --list-templates Show available templates
419
+ --list-types Show registered types (alias: --list-templates)
399
420
 
400
421
  For plans, the default status vocabulary is: in-session, active, planned,
401
- blocked, partial, paused, awaiting, queued-after, archived. Run
402
- \`dotmd status --help\` for what each one means.
422
+ blocked, partial, paused, awaiting, queued-after, archived.
423
+ For prompts: pending (default), claimed, archived.
403
424
 
404
- The filename is derived from <name> by slugifying it.
405
425
  Use --dry-run (-n) to preview without creating the file.`,
406
426
 
407
427
  watch: `dotmd watch [command] — re-run a command on file changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.22.1",
3
+ "version": "0.23.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",
@@ -22,7 +22,8 @@ function generatePlansCommand(config) {
22
22
  lines.push('- `dotmd health` — plan velocity, aging, checklist progress, pipeline view');
23
23
  lines.push('- `dotmd unblocks <file>` — what depends on / is blocked by a plan');
24
24
  lines.push('- `dotmd next` — ready plans with next steps (what to promote)');
25
- lines.push('- `dotmd new <name> --template plan` — scaffold with full phase structure');
25
+ lines.push('- `dotmd new plan <name>` — scaffold with full phase structure');
26
+ lines.push('- `dotmd new prompt <name> "<body>"` — save a resume-prompt to docs/prompts/');
26
27
  lines.push('- `dotmd archive <file>` — archive with auto ref-fixing (both directions)');
27
28
  lines.push('- `dotmd bulk archive <files>` — archive multiple at once');
28
29
  lines.push('- `dotmd status <file> <status>` — transition status');
@@ -115,7 +116,10 @@ function generateDocsCommand(config) {
115
116
 
116
117
  lines.push('');
117
118
  lines.push('Lifecycle:');
118
- lines.push('- `dotmd new <name> --template plan` — scaffold new plan');
119
+ lines.push('- `dotmd new plan <name>` — scaffold new plan');
120
+ lines.push('- `dotmd new doc <name>` — scaffold reference doc');
121
+ lines.push('- `dotmd new prompt <name> "<body>"` — save a resume-prompt');
122
+ lines.push('- `dotmd new research <name>` — scaffold an audit/investigation');
119
123
  lines.push('- `dotmd status <file> <status>` — transition status');
120
124
  lines.push('- `dotmd archive <file>` — archive with auto ref-fixing');
121
125
  lines.push('- `dotmd bulk archive <files>` — archive multiple at once');
package/src/config.mjs CHANGED
@@ -40,6 +40,11 @@ const DEFAULTS = {
40
40
  context: { expanded: ['active'], listed: [], counted: ['reference', 'archived'] },
41
41
  staleDays: { active: 30 },
42
42
  },
43
+ prompt: {
44
+ statuses: ['pending', 'claimed', 'archived'],
45
+ context: { expanded: ['pending'], listed: [], counted: ['claimed', 'archived'] },
46
+ staleDays: { pending: 30 },
47
+ },
43
48
  },
44
49
 
45
50
  statuses: {
package/src/new.mjs CHANGED
@@ -1,17 +1,24 @@
1
- import { existsSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
3
4
  import { toRepoPath, die, warn, nowIso } from './util.mjs';
4
5
  import { green, dim, bold } from './color.mjs';
5
6
  import { isInteractive, promptText } from './prompt.mjs';
6
7
 
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
10
+
7
11
  const BUILTIN_TEMPLATES = {
8
- default: {
9
- description: 'Minimal document with status and updated date',
10
- frontmatter: (s, d) => `type: doc\nstatus: ${s}\nupdated: ${d}`,
12
+ doc: {
13
+ description: 'Reference doc, design note, glossary entry, etc.',
14
+ defaultStatus: 'active',
15
+ frontmatter: (s, d) => `type: doc\nstatus: ${s}\ncreated: ${d}\nupdated: ${d}`,
11
16
  body: (t) => `\n# ${t}\n`,
12
17
  },
13
18
  plan: {
14
19
  description: 'Execution plan — build-up shape (Problem → Phases → Closeout) with phase status markers and Version History',
20
+ dir: 'plans',
21
+ defaultStatus: 'active',
15
22
  frontmatter: (s, d) => [
16
23
  'type: plan',
17
24
  `status: ${s}`,
@@ -88,67 +95,127 @@ Status markers (put in heading text):
88
95
  <!-- Filled on archive: what shipped, key commits, deferrals dispositioned. -->
89
96
  `,
90
97
  },
91
- adr: {
92
- description: 'Architecture Decision Record',
93
- frontmatter: (s, d) => `type: doc\nstatus: ${s}\nupdated: ${d}\ndecision_date:\ndeciders:`,
94
- body: (t) => `\n# ${t}\n\n## Context\n\n\n\n## Decision\n\n\n\n## Consequences\n\n\n`,
95
- },
96
- rfc: {
97
- description: 'Request for Comments',
98
- frontmatter: (s, d) => `type: doc\nstatus: ${s}\nupdated: ${d}\nowner:\nreviewers:`,
99
- body: (t) => `\n# ${t}\n\n## Summary\n\n\n\n## Motivation\n\n\n\n## Detailed Design\n\n\n\n## Alternatives\n\n\n\n## Open Questions\n\n\n`,
100
- },
101
- audit: {
98
+ research: {
102
99
  description: 'Codebase audit or research investigation',
103
- frontmatter: (s, d) => `type: research\nstatus: ${s}\nupdated: ${d}\naudited: ${d}\naudit_level: pass1\nmodule:\nsource_of_truth: code\nsupports_plans:`,
100
+ defaultStatus: 'active',
101
+ frontmatter: (s, d) => [
102
+ 'type: research',
103
+ `status: ${s}`,
104
+ `created: ${d}`,
105
+ `updated: ${d}`,
106
+ `audited: ${d}`,
107
+ 'audit_level: pass1',
108
+ 'module:',
109
+ 'source_of_truth: code',
110
+ 'supports_plans: []',
111
+ ].join('\n'),
104
112
  body: (t) => `\n# ${t}\n\n## Scope\n\n\n\n## Findings\n\n\n\n## Recommendations\n\n\n`,
105
113
  },
106
- design: {
107
- description: 'Design document with goals, non-goals, and implementation plan',
108
- frontmatter: (s, d) => `type: doc\nstatus: ${s}\nupdated: ${d}\nowner:\nsurface:\nmodule:\nrelated_plans:`,
109
- body: (t) => `\n# ${t}\n\n## Overview\n\n\n\n## Goals\n\n\n\n## Non-Goals\n\n\n\n## Design\n\n\n\n## Implementation Plan\n\n- [ ] \n`,
114
+ prompt: {
115
+ description: 'Saved prompt to seed a future Claude session — body is required',
116
+ dir: 'prompts',
117
+ defaultStatus: 'pending',
118
+ requiresBody: true,
119
+ frontmatter: (s, d, ctx) => [
120
+ 'type: prompt',
121
+ `status: ${s}`,
122
+ `created: ${d}`,
123
+ `dotmd_version: ${pkg.version}`,
124
+ `context: ${ctx?.title ? `"${ctx.title.replace(/"/g, '\\"')}"` : ''}`,
125
+ 'related_plans: []',
126
+ ].join('\n'),
127
+ body: (t, ctx) => `\n${ctx?.bodyInput ?? '<!-- prompt body -->'}\n`,
110
128
  },
111
129
  };
112
130
 
131
+ function readBodyInput(source) {
132
+ if (source === '-') {
133
+ try { return readFileSync(0, 'utf8'); } catch (err) { die(`Could not read body from stdin: ${err.message}`); }
134
+ }
135
+ if (typeof source === 'string' && source.startsWith('@')) {
136
+ const file = source.slice(1);
137
+ if (!existsSync(file)) die(`Body file not found: ${file}`);
138
+ return readFileSync(file, 'utf8');
139
+ }
140
+ return source;
141
+ }
142
+
113
143
  export async function runNew(argv, config, opts = {}) {
114
144
  const { dryRun } = opts;
115
145
 
116
- // Parse args
146
+ // Parse args. Pull out flags first.
117
147
  const positional = [];
118
- let status = 'active';
148
+ let status = null;
119
149
  let title = null;
120
- let templateName = null;
121
150
  let rootName = opts.root ?? null;
151
+ let messageFlag = null;
122
152
  for (let i = 0; i < argv.length; i++) {
123
153
  if (argv[i] === '--status' && argv[i + 1]) { status = argv[++i]; continue; }
124
154
  if (argv[i] === '--title' && argv[i + 1]) { title = argv[++i]; continue; }
125
- if (argv[i] === '--template' && argv[i + 1]) { templateName = argv[++i]; continue; }
155
+ if (argv[i] === '--message' && argv[i + 1]) { messageFlag = argv[++i]; continue; }
126
156
  if (argv[i] === '--root' && argv[i + 1]) { rootName = argv[++i]; continue; }
127
157
  if (argv[i] === '--config') { i++; continue; }
128
- if (argv[i] === '--list-templates') {
158
+ if (argv[i] === '--list-templates' || argv[i] === '--list-types') {
129
159
  listTemplates(config);
130
160
  return;
131
161
  }
132
- if (!argv[i].startsWith('-')) positional.push(argv[i]);
162
+ // Treat `-` alone (stdin marker) as a positional, not a flag.
163
+ if (!argv[i].startsWith('-') || argv[i] === '-') positional.push(argv[i]);
164
+ }
165
+
166
+ // Resolve type vs name:
167
+ // `dotmd new plan auth-revamp` → type=plan, name=auth-revamp
168
+ // `dotmd new auth-revamp` → type=doc (default), name=auth-revamp
169
+ // `dotmd new prompt foo "body"` → type=prompt, name=foo, bodyArg="body"
170
+ const knownTypes = new Set(Object.keys(BUILTIN_TEMPLATES));
171
+ // Also include any custom templates from config
172
+ for (const k of Object.keys(config.raw?.templates ?? {})) knownTypes.add(k);
173
+
174
+ let typeName, name, bodyArg = null;
175
+ if (positional.length >= 1 && knownTypes.has(positional[0])) {
176
+ typeName = positional[0];
177
+ name = positional[1];
178
+ if (positional.length > 2) bodyArg = positional.slice(2).join(' ');
179
+ } else {
180
+ typeName = 'doc';
181
+ name = positional[0];
182
+ if (positional.length > 1) bodyArg = positional.slice(1).join(' ');
133
183
  }
134
184
 
135
- let name = positional[0];
136
185
  if (!name) {
137
186
  if (isInteractive()) {
138
- name = await promptText('Document name: ');
187
+ name = await promptText(`${typeName} name: `);
139
188
  if (!name) die('No name provided.');
140
189
  } else {
141
- die('Usage: dotmd new <name> [--template <t>] [--status <s>] [--title <t>]\n dotmd new --list-templates');
190
+ die(`Usage: dotmd new <type> <name> [body]\n types: ${[...knownTypes].join(', ')}\n body: inline text | "-" (stdin) | "@path" (file) | --message "..."`);
142
191
  }
143
192
  }
144
193
 
145
- // Validate status
146
- if (!config.validStatuses.has(status)) {
147
- die(`Invalid status: ${status}\nValid: ${[...config.validStatuses].join(', ')}`);
194
+ // Resolve template (by type name, falls back to lookup)
195
+ const template = resolveTemplate(typeName, config);
196
+
197
+ // Validate status (template default first, then per-type list, then 'active')
198
+ if (!status) {
199
+ if (typeof template === 'object' && template.defaultStatus) {
200
+ status = template.defaultStatus;
201
+ } else {
202
+ const typeStatuses = config.typeStatuses?.get(typeName);
203
+ status = typeStatuses && typeStatuses.size > 0 ? [...typeStatuses][0] : 'active';
204
+ }
205
+ }
206
+ const effective = config.typeStatuses?.get(typeName) ?? config.validStatuses;
207
+ if (!effective.has(status)) {
208
+ die(`Invalid status \`${status}\` for type \`${typeName}\`\nValid: ${[...effective].join(', ')}`);
148
209
  }
149
210
 
150
- // Resolve template
151
- const template = resolveTemplate(templateName ?? 'default', config);
211
+ // Body input resolution: messageFlag > bodyArg > nothing
212
+ let bodyInput = null;
213
+ if (messageFlag !== null) bodyInput = readBodyInput(messageFlag);
214
+ else if (bodyArg !== null) bodyInput = readBodyInput(bodyArg);
215
+
216
+ if (template.requiresBody && (!bodyInput || !bodyInput.trim())) {
217
+ die(`\`${typeName}\` template requires a body. Pass inline, --message "...", - for stdin, or @path for a file.`);
218
+ }
152
219
 
153
220
  // If name contains path separators, split into directory prefix and basename
154
221
  let nameDir = null;
@@ -179,7 +246,12 @@ export async function runNew(argv, config, opts = {}) {
179
246
  targetRoot = match;
180
247
  }
181
248
 
182
- // Path if user provided a directory prefix, resolve relative to repoRoot
249
+ // Template-declared subdirectory (e.g., prompt 'prompts')
250
+ if (typeof template === 'object' && template.dir && !nameDir) {
251
+ nameDir = path.join(path.relative(config.repoRoot, targetRoot), template.dir);
252
+ }
253
+
254
+ // Path — if user provided a directory prefix OR template declared one, resolve relative to repoRoot
183
255
  const baseDir = nameDir ? path.resolve(config.repoRoot, nameDir) : targetRoot;
184
256
  const filePath = path.join(baseDir, slug + '.md');
185
257
  const repoPath = toRepoPath(filePath, config.repoRoot);
@@ -192,26 +264,28 @@ export async function runNew(argv, config, opts = {}) {
192
264
 
193
265
  // Generate content
194
266
  let content;
267
+ const tmplCtx = { status, title: docTitle, today, bodyInput };
195
268
  if (typeof template === 'function') {
196
- content = template(name, { status, title: docTitle, today });
269
+ content = template(name, tmplCtx);
197
270
  } else {
198
- const fm = template.frontmatter(status, today);
199
- const body = template.body(docTitle, { today, status });
271
+ const fm = template.frontmatter(status, today, tmplCtx);
272
+ const body = template.body(docTitle, tmplCtx);
200
273
  content = `---\n${fm}\n---\n${body}`;
201
274
  }
202
275
 
203
276
  if (dryRun) {
204
277
  process.stdout.write(`${dim('[dry-run]')} Would create: ${repoPath}\n`);
205
- if (templateName) process.stdout.write(`${dim('[dry-run]')} Template: ${templateName}\n`);
278
+ process.stdout.write(`${dim('[dry-run]')} Type: ${typeName}\n`);
206
279
  return;
207
280
  }
208
281
 
282
+ // Ensure parent dir exists (templates with `dir:` may target a new subdirectory)
283
+ mkdirSync(path.dirname(filePath), { recursive: true });
284
+
209
285
  writeFileSync(filePath, content, 'utf8');
210
- process.stdout.write(`${green('Created')}: ${repoPath}`);
211
- if (templateName) process.stdout.write(` ${dim(`(template: ${templateName})`)}`);
212
- process.stdout.write('\n');
286
+ process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName})`)}\n`);
213
287
 
214
- try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle, template: templateName }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
288
+ try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle, type: typeName }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
215
289
  }
216
290
 
217
291
  function resolveTemplate(name, config) {
@@ -221,7 +295,7 @@ function resolveTemplate(name, config) {
221
295
  if (BUILTIN_TEMPLATES[name]) return BUILTIN_TEMPLATES[name];
222
296
 
223
297
  const available = [...new Set([...Object.keys(BUILTIN_TEMPLATES), ...Object.keys(configTemplates)])];
224
- die(`Unknown template: ${name}\nAvailable: ${available.join(', ')}`);
298
+ die(`Unknown type: ${name}\nAvailable: ${available.join(', ')}`);
225
299
  }
226
300
 
227
301
  function listTemplates(config) {
@@ -231,7 +305,7 @@ function listTemplates(config) {
231
305
  all[k] = v;
232
306
  }
233
307
 
234
- process.stdout.write(bold('Available templates') + '\n\n');
308
+ process.stdout.write(bold('Available types') + '\n\n');
235
309
  for (const [name, tmpl] of Object.entries(all)) {
236
310
  const desc = typeof tmpl === 'function'
237
311
  ? '(custom function)'