dotmd-cli 0.83.0 → 0.84.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/README.md CHANGED
@@ -213,6 +213,9 @@ runlist new plan platform-work --coordination
213
213
  runlist runlists
214
214
  ```
215
215
 
216
+ `runlist new hub platform-work` makes the same coordination hub; add
217
+ `--runlist a,b,c` or `--roadmap` for the other two shapes.
218
+
216
219
  For progress across several runlists, create a roadmap:
217
220
 
218
221
  ```bash
@@ -225,6 +228,37 @@ Roadmaps roll up progress recursively and choose the first startable plan across
225
228
  their child runlists. Runlists and roadmaps are held out of actionable plan
226
229
  counts so dashboards do not double-count their children.
227
230
 
231
+ ## Decisions
232
+
233
+ A decision is an entry in its plan, not a document of its own. Write the record
234
+ (the situation, what exists today, what each answer leaves in place) to a file
235
+ and add it:
236
+
237
+ ```bash
238
+ runlist new decision auth-revamp --question "Which token store?" @record.md
239
+ ```
240
+
241
+ It takes the next id (`D1`, `D2`, …) that no decision item in that plan uses,
242
+ lands at the end of the plan's top-level decisions section (created before
243
+ `## Version History` when there is none) with a `Disposition: OPEN.` line, and
244
+ refuses an empty record. `--disposition held` parks it instead.
245
+
246
+ A corpus that indexes its decisions in one register numbers them in one
247
+ sequence. Name the register, and the id follows the highest the register or the
248
+ plan uses, and the register gets the entry's row in the same locked write:
249
+
250
+ ```js
251
+ export const decisions = {
252
+ section: 'Decisions',
253
+ prefix: 'D',
254
+ register: { file: 'docs/plans/register.md', statusLine: 'waiting on you:' },
255
+ };
256
+ ```
257
+
258
+ The register block is the fenced block whose first line carries `statusLine`.
259
+ The row is the question plus `--answers` (what each answer leaves in place),
260
+ which is required when a register is configured.
261
+
228
262
  ## Safety Model
229
263
 
230
264
  - Mutation commands support `--dry-run` / `-n`.
package/bin/dotmd.mjs CHANGED
@@ -1001,6 +1001,21 @@ Examples:
1001
1001
  EOF
1002
1002
  runlist new prompt cleanup-tomorrow "look at remaining lint warnings"
1003
1003
 
1004
+ Hubs and decisions:
1005
+ runlist new hub <slug> # coordination hub (a plan)
1006
+ runlist new hub <slug> --runlist a,b,c # sprint hub plus child plans
1007
+ runlist new hub <slug> --roadmap # roadmap hub
1008
+ runlist new decision <plan> --question "<question>" [--answers "<…>"] @record.md
1009
+ Adds the next numbered entry to the plan's top-level
1010
+ decisions section with a \`Disposition: OPEN.\` line (or
1011
+ \`--disposition held\`) and the record, which is required;
1012
+ a plan with no such section gets \`## Decisions\`. With a
1013
+ register in config, the id is numbered across the corpus
1014
+ and the register gets its row, question plus --answers
1015
+ (required then), in the same locked write. Config:
1016
+ \`export const decisions = { section, prefix,
1017
+ register: { file, statusLine } }\`.
1018
+
1004
1019
  Scaffolding runlists (plans only):
1005
1020
  --runlist <a,b,c> Create a sprint runlist hub plus one child plan per slug.
1006
1021
  The hub carries \`runlist: [<hub>-01-a.md, <hub>-02-b.md, …]\`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.83.0",
3
+ "version": "0.84.0",
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/config.mjs CHANGED
@@ -137,6 +137,8 @@ const DEFAULTS = {
137
137
  templates: {},
138
138
 
139
139
  glossary: null,
140
+ // `runlist new decision`: { section, prefix, register: { file, statusLine } }.
141
+ decisions: null,
140
142
 
141
143
  // Opt-in JSONL command journal at .dotmd/journal.jsonl. Default off — agents
142
144
  // and users who want usage observability flip this on (or set RUNLIST_JOURNAL=1).
@@ -417,6 +419,14 @@ function validateConfig(userConfig, config, validStatuses, indexPath) {
417
419
  }
418
420
  }
419
421
 
422
+ if (userConfig.decisions != null) {
423
+ const d = userConfig.decisions;
424
+ if (typeof d !== 'object' || Array.isArray(d)) warnings.push('Config: decisions must be an object.');
425
+ else if (d.register != null && (typeof d.register?.file !== 'string' || typeof d.register?.statusLine !== 'string')) {
426
+ warnings.push('Config: decisions.register needs a file and a statusLine.');
427
+ }
428
+ }
429
+
420
430
  // Unknown top-level user config keys
421
431
  for (const key of Object.keys(userConfig)) {
422
432
  if (!VALID_CONFIG_KEYS.has(key)) {
@@ -0,0 +1,220 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { mutateFileSet } from './atomic-mutation.mjs';
4
+ import { authorizeManagedSource } from './managed-path.mjs';
5
+ import { walkSections } from './section.mjs';
6
+ import { die, nowIso, resolveDocPath, toRepoPath } from './util.mjs';
7
+ import { green, dim } from './color.mjs';
8
+
9
+ // `runlist new decision <plan> --question "…" @record.md`
10
+ //
11
+ // A decision is not a document of its own: it is an entry in the owning plan's
12
+ // decisions section, carrying an id, a disposition and a written record. This
13
+ // adds one, numbered after the highest id of its prefix the plan (and the
14
+ // register, when there is one) already uses, at the end of the plan's top-level
15
+ // decisions section, creating `## Decisions` when the plan has none. A
16
+ // decisions heading nested inside a workstream is that workstream's record and
17
+ // is left alone.
18
+ //
19
+ // Config (`runlist.config.mjs`), all optional:
20
+ // export const decisions = {
21
+ // section: 'Decisions', // the heading the entry lands under
22
+ // prefix: 'D', // what a new id is written under
23
+ // register: { file: 'docs/plans/register.md', statusLine: 'waiting on you:' },
24
+ // };
25
+ // With a register, ids are one sequence across the corpus: the next id is
26
+ // numbered after the highest the register or the plan uses, and the register
27
+ // gets the entry's index row in the same locked write as the plan. Without
28
+ // one, ids are plan-local.
29
+
30
+ const DISPOSITIONS = new Set(['open', 'held']);
31
+ const DECISION_HEADING = /\bdecisions?\b/i;
32
+
33
+ function splitDoc(raw) {
34
+ if (!raw.startsWith('---\n')) return null;
35
+ const end = raw.indexOf('\n---\n', 4);
36
+ if (end === -1) return null;
37
+ return { frontmatter: raw.slice(4, end), body: raw.slice(end + 5) };
38
+ }
39
+
40
+ function stripFences(text) {
41
+ return text.replace(/^(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\1[^\n]*$/gm, '');
42
+ }
43
+
44
+ const escapeRe = text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
45
+
46
+ // Ids are read where a decision item starts (a heading, a list or bold lead,
47
+ // a table row or a register row), never from prose, where the same shape is
48
+ // as likely to be a job code or a citation of another plan's decision.
49
+ function highestId(text, prefix) {
50
+ const re = new RegExp(`^\\s*(?:#{1,6}\\s+|[-*]\\s+(?:\\[[ xX]\\]\\s+)?|\\|\\s*)?\\**${escapeRe(prefix)}(\\d{1,4})\\b`);
51
+ let max = 0;
52
+ for (const line of text.split('\n')) {
53
+ const m = line.match(re);
54
+ if (m) max = Math.max(max, Number(m[1]));
55
+ }
56
+ return max;
57
+ }
58
+
59
+ export function nextDecisionId(body, prefix, register = null) {
60
+ let max = highestId(stripFences(body), prefix);
61
+ if (register) {
62
+ const rows = register.rows.map(row => row.match(new RegExp(`^${escapeRe(prefix)}(\\d{1,4})\\b`))?.[1]);
63
+ for (const n of rows) if (n) max = Math.max(max, Number(n));
64
+ }
65
+ return `${prefix}${max + 1}`;
66
+ }
67
+
68
+ // The register block: a fence whose first line carries the configured status
69
+ // line. Returns its rows and where the closing fence sits, or null.
70
+ export function findRegister(text, statusLine) {
71
+ const lines = text.split('\n');
72
+ const wanted = statusLine.trim().toLowerCase();
73
+ for (let i = 0; i < lines.length; i++) {
74
+ const open = lines[i].match(/^(`{3,}|~{3,})/);
75
+ if (!open) continue;
76
+ let close = i + 1;
77
+ while (close < lines.length && !lines[close].startsWith(open[1])) close++;
78
+ if (close >= lines.length) return null;
79
+ const first = (lines[i + 1] ?? '').trim().toLowerCase();
80
+ if (first.includes(wanted)) {
81
+ return { closeLine: close, rows: lines.slice(i + 2, close).filter(line => line.trim()) };
82
+ }
83
+ i = close;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ export function insertRegisterRow(text, register, row) {
89
+ const lines = text.split('\n');
90
+ let at = register.closeLine;
91
+ while (at > 0 && lines[at - 1].trim() === '') at--;
92
+ lines.splice(at, 0, row);
93
+ return lines.join('\n');
94
+ }
95
+
96
+ // Returns the new body and where the entry went. Pure, for tests.
97
+ export function insertDecision(body, { id, question, disposition, record, section: heading = 'Decisions' }) {
98
+ const lines = body.split('\n');
99
+ const sections = walkSections(body);
100
+ const named = s => s.heading.replace(/[^\w\s]+$/, '').trim().toLowerCase() === heading.toLowerCase();
101
+ const section = sections.find(s => s.level === 2 && named(s))
102
+ ?? sections.find(s => s.level === 2 && DECISION_HEADING.test(s.heading));
103
+ const level = section ? Math.min(section.level + 1, 6) : 3;
104
+ const entry = [
105
+ `${'#'.repeat(level)} ${id} ${question.trim()}`,
106
+ '',
107
+ `Disposition: ${disposition.toUpperCase()}.`,
108
+ '',
109
+ record.trim(),
110
+ ];
111
+
112
+ if (section) {
113
+ // After the section's last non-blank line, subsections included.
114
+ let at = section.lineEnd;
115
+ while (at > section.lineStart && lines[at - 1].trim() === '') at--;
116
+ const tail = at < lines.length && lines[at].trim() !== '' ? [''] : [];
117
+ lines.splice(at, 0, '', ...entry, ...tail);
118
+ return { body: lines.join('\n'), placement: `under \`${section.heading}\``, heading: section.heading };
119
+ }
120
+
121
+ const block = [`## ${heading}`, '', ...entry];
122
+ const before = sections.find(s => s.level === 2 && /^(version history|closeout)\b/i.test(s.heading));
123
+ if (before) {
124
+ lines.splice(before.lineStart - 1, 0, ...block, '');
125
+ return { body: lines.join('\n'), placement: `in a new \`## ${heading}\` before \`${before.heading}\``, heading };
126
+ }
127
+ const trimmed = body.replace(/\n+$/, '');
128
+ return { body: `${trimmed}\n\n${block.join('\n')}\n`, placement: `in a new \`## ${heading}\` at the end`, heading };
129
+ }
130
+
131
+ export function runNewDecision({ planArg, question, disposition, record, answers = null }, config, { dryRun = false } = {}) {
132
+ const usage = 'Usage: runlist new decision <plan> --question "<the question>" @record.md';
133
+ if (!planArg) die(`${usage}\nThe plan is the file whose decisions section gets the entry.`);
134
+ if (!question || !question.trim()) die(`--question is required.\n${usage}`);
135
+ if (/\n/.test(question)) die('--question is one line; the record carries the rest.');
136
+ if (!record || !record.trim()) {
137
+ die('A decision needs its record: the situation, what exists today, and what each answer leaves in place.\n'
138
+ + `Write it to a file and pass @path, pipe it in, or pass --body "...".\n${usage}`);
139
+ }
140
+ disposition = (disposition ?? 'open').toLowerCase();
141
+ if (!DISPOSITIONS.has(disposition)) {
142
+ die(`--disposition must be open or held; a ruled or closed decision is edited in place, not added.`);
143
+ }
144
+
145
+ const resolved = resolveDocPath(planArg, config)
146
+ ?? (planArg.endsWith('.md') ? null : resolveDocPath(`${planArg}.md`, config))
147
+ ?? (planArg.includes('/') ? null : resolveDocPath(path.join('plans', `${planArg}.md`), config));
148
+ if (!resolved) die(`No such plan: ${planArg}`);
149
+ const planPath = authorizeManagedSource(resolved, config, { kind: 'Decision target' }).path;
150
+ const planRepoPath = toRepoPath(planPath, config.repoRoot);
151
+
152
+ const settings = config.raw?.decisions ?? {};
153
+ const prefix = settings.prefix ?? 'D';
154
+ const section = settings.section ?? 'Decisions';
155
+ let registerPath = null;
156
+ if (settings.register?.file) {
157
+ if (!settings.register.statusLine) die('decisions.register needs a statusLine: the first line of the register block.');
158
+ const registerResolved = resolveDocPath(settings.register.file, config);
159
+ if (!registerResolved) die(`The decisions register in config does not exist: ${settings.register.file}`);
160
+ registerPath = authorizeManagedSource(registerResolved, config, { kind: 'Decision register' }).path;
161
+ }
162
+ const sameFile = registerPath === planPath;
163
+ if (registerPath && (!answers || !answers.trim())) {
164
+ die('--answers is required with a register: the row says what each answer leaves in place and what it costs.\n'
165
+ + `${usage} --answers "Yes: … No: …"`);
166
+ }
167
+
168
+ const today = nowIso();
169
+ const planRaw = readFileSync(planPath, 'utf8');
170
+ const registerRaw = registerPath && !sameFile ? readFileSync(registerPath, 'utf8') : null;
171
+
172
+ const planDoc = splitDoc(planRaw.replace(/\r\n/g, '\n'));
173
+ if (!planDoc) die(`${planRepoPath} has no frontmatter block; runlist only adds decisions to managed documents.`);
174
+ const registerText = sameFile ? planDoc.body : registerRaw?.replace(/\r\n/g, '\n');
175
+ const register = registerPath ? findRegister(registerText, settings.register.statusLine) : null;
176
+ if (registerPath && !register) {
177
+ die(`${toRepoPath(registerPath, config.repoRoot)} has no block whose first line carries "${settings.register.statusLine}".`);
178
+ }
179
+
180
+ const id = nextDecisionId(planDoc.body, prefix, register);
181
+ const inserted = insertDecision(planDoc.body, { id, question, disposition, record, section });
182
+ let planBody = inserted.body;
183
+ let row = null;
184
+ if (register && sameFile) {
185
+ row = registerRow({ id, disposition, today, question, answers, heading: inserted.heading, link: path.basename(planPath) });
186
+ planBody = insertRegisterRow(planBody, findRegister(planBody, settings.register.statusLine), row);
187
+ }
188
+ const bump = fm => (/^updated:/m.test(fm) ? fm.replace(/^updated:.*$/m, `updated: ${today}`) : fm);
189
+ const planOut = `---\n${bump(planDoc.frontmatter)}\n---\n${planBody}`;
190
+
191
+ const updates = [{ path: planPath, expectedContent: planRaw, content: planOut }];
192
+ if (register && !sameFile) {
193
+ const link = path.relative(path.dirname(registerPath), planPath).split(path.sep).join('/');
194
+ row = registerRow({ id, disposition, today, question, answers, heading: inserted.heading, link });
195
+ const registerOut = insertRegisterRow(registerText, register, row);
196
+ const registerDoc = splitDoc(registerOut);
197
+ updates.push({
198
+ path: registerPath,
199
+ expectedContent: registerRaw,
200
+ content: registerDoc ? `---\n${bump(registerDoc.frontmatter)}\n---\n${registerDoc.body}` : registerOut,
201
+ });
202
+ }
203
+
204
+ const registerRepoPath = registerPath ? toRepoPath(registerPath, config.repoRoot) : null;
205
+ if (dryRun) {
206
+ process.stdout.write(`[dry-run] Would add ${id} to ${planRepoPath} ${inserted.placement}\n`);
207
+ if (row) process.stdout.write(`[dry-run] Would add its row to the register in ${registerRepoPath}:\n ${row}\n`);
208
+ return { id, row };
209
+ }
210
+
211
+ mutateFileSet({ updates }, { repoRoot: config.repoRoot });
212
+ process.stdout.write(`${green('Added')} ${id} to ${planRepoPath} ${dim(inserted.placement)}\n`);
213
+ if (row) process.stdout.write(`${green('Added')} its row to the register in ${registerRepoPath}\n`);
214
+ return { id, row };
215
+ }
216
+
217
+ function registerRow({ id, disposition, today, question, answers, heading, link }) {
218
+ const text = `${question.trim()} ${answers.trim()}`;
219
+ return `${id} ${disposition.toUpperCase()} ${today.slice(0, 10)}: ${text} Record: [${link} § ${heading} ${id}](${link}).`;
220
+ }
package/src/new.mjs CHANGED
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
2
2
  import { existsSync, readFileSync, mkdirSync, fstatSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { runNewDecision } from './decision.mjs';
5
6
  import { toRepoPath, die, warn, nowIso, emitFilesFooter } from './util.mjs';
6
7
  import { green, dim, bold } from './color.mjs';
7
8
  import { isInteractive, promptText } from './prompt.mjs';
@@ -652,9 +653,14 @@ next_step:
652
653
  export async function runNew(argv, config, opts = {}) {
653
654
  const { dryRun } = opts;
654
655
 
656
+ if (argv.find(a => !a.startsWith('-')) === 'decision') return runNewDecisionArgs(argv, config, opts);
657
+
655
658
  const knownTypes = new Set(Object.keys(BUILTIN_TEMPLATES));
656
659
  // Also include any custom templates from config
657
660
  for (const k of Object.keys(config.raw?.templates ?? {})) knownTypes.add(k);
661
+ // `hub` is a plan with a hub body; `decision` is handled above.
662
+ knownTypes.add('hub');
663
+ knownTypes.add('decision');
658
664
 
659
665
  const hasNameForBody = args => {
660
666
  if (args.length >= 2 && knownTypes.has(args[0])) return true;
@@ -713,11 +719,27 @@ export async function runNew(argv, config, opts = {}) {
713
719
  name = positional[1];
714
720
  if (positional.length > 2) bodyArg = positional.slice(2).join(' ');
715
721
  } else {
722
+ // `new <name> <inline body>` is a doc with the type left out, but a single
723
+ // bare word in the body slot is almost always a mistyped type and a slug
724
+ // (`new decison foo`), which used to create `<type-name>.md` with the slug
725
+ // as its whole body.
726
+ const [first, second] = positional;
727
+ if (positional.length === 2 && !/[/\\]|\.md$/.test(first) && /^[A-Za-z][\w.-]*$/.test(second)) {
728
+ die(`Unknown type \`${first}\`. Types: ${[...knownTypes].join(', ')}.\n`
729
+ + `For a doc named ${first} with "${second}" as its body, run: runlist new doc ${first} "${second}"`);
730
+ }
716
731
  typeName = 'doc';
717
732
  name = positional[0];
718
733
  if (positional.length > 1) bodyArg = positional.slice(1).join(' ');
719
734
  }
720
735
 
736
+ // A hub is a plan with a hub body. Coordination is the default shape, being
737
+ // the one a hub without children or tiers has.
738
+ if (typeName === 'hub') {
739
+ typeName = 'plan';
740
+ if (runlistArg === null && !roadmap) coordination = true;
741
+ }
742
+
721
743
  if (!name) {
722
744
  if (isInteractive()) {
723
745
  name = await promptText(`${typeName} name: `);
@@ -1145,6 +1167,8 @@ export function newHelpForRepo(config) {
1145
1167
  if (statuses.length) rows.push(` ${''.padEnd(width)} statuses: ${statuses.join(', ')}`);
1146
1168
  }
1147
1169
  const roots = (config.docsRoots ?? [config.docsRoot]).map(r => path.basename(r));
1170
+ rows.push(` ${'hub'.padEnd(width)} → a plan with a hub body (coordination unless --runlist or --roadmap)`);
1171
+ rows.push(` ${'decision'.padEnd(width)} → an entry in an existing plan's decisions section`);
1148
1172
  return `This repo:
1149
1173
  ${rows.join('\n')}
1150
1174
  roots (for --root): ${roots.join(', ')}`;
@@ -1206,4 +1230,29 @@ function listTemplates(config) {
1206
1230
  if (desc) process.stdout.write(` ${dim(desc)}\n`);
1207
1231
  process.stdout.write('\n');
1208
1232
  }
1233
+ process.stdout.write(` hub\n ${dim('A plan with a hub body: coordination by default, --runlist or --roadmap for the others.')}\n\n`);
1234
+ process.stdout.write(` decision\n ${dim('An entry in an existing plan\'s decisions section: runlist new decision <plan> --question "…" @record.md')}\n\n`);
1235
+ }
1236
+
1237
+ function runNewDecisionArgs(argv, config, opts) {
1238
+ const positional = [];
1239
+ let question = null;
1240
+ let disposition = null;
1241
+ let answers = null;
1242
+ let bodyFlag = null;
1243
+ for (let i = 0; i < argv.length; i++) {
1244
+ const a = argv[i];
1245
+ if (a === '--question' && argv[i + 1] !== undefined) { question = argv[++i]; continue; }
1246
+ if (a === '--disposition' && argv[i + 1] !== undefined) { disposition = argv[++i]; continue; }
1247
+ if (a === '--answers' && argv[i + 1] !== undefined) { answers = argv[++i]; continue; }
1248
+ if ((a === '--body' || a === '--message') && argv[i + 1] !== undefined) { bodyFlag = argv[++i]; continue; }
1249
+ if (a === '--config' || a === '--root') { i++; continue; }
1250
+ if (!a.startsWith('-') || a === '-') positional.push(a);
1251
+ }
1252
+ const [, planArg, ...rest] = positional;
1253
+ let record = null;
1254
+ if (bodyFlag !== null) record = readBodyInput(bodyFlag);
1255
+ else if (rest.length) record = readBodyInput(rest.join(' '));
1256
+ else record = readPipedBodyInput();
1257
+ return runNewDecision({ planArg, question, disposition, record, answers }, config, { dryRun: opts.dryRun });
1209
1258
  }