dotmd-cli 0.82.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, …]\`
@@ -1752,12 +1767,18 @@ async function main() {
1752
1767
  process.stderr.write(`Repo root: ${config.repoRoot}\n`);
1753
1768
  }
1754
1769
 
1770
+ // A printed list shows status, title, age and next step, none of which the
1771
+ // validating passes produce, so it reads the index the way `prompts` and
1772
+ // `hud` do. `--json` emits each document's warnings and errors, so it still
1773
+ // pays for the full pass.
1774
+ const listIndexOptions = listArgs => (listArgs.includes('--json') ? {} : { fast: true });
1775
+
1755
1776
  // Preset aliases (user config can override built-in commands below)
1756
1777
  if ((command === 'stale' || command === 'actionable') && !config.configuredPresetNames.has(command)) {
1757
1778
  const { buildIndex } = await import('../src/index.mjs');
1758
1779
  const { runQuery } = await import('../src/query.mjs');
1759
1780
  const { statusMetadataFor } = await import('../src/status-metadata.mjs');
1760
- const index = buildIndex(config);
1781
+ const index = buildIndex(config, listIndexOptions(restArgs));
1761
1782
  applyIndexFilters(index);
1762
1783
  const docs = index.docs.filter(doc => {
1763
1784
  const metadata = statusMetadataFor(config, doc.type, doc.status);
@@ -1774,7 +1795,7 @@ async function main() {
1774
1795
  if (config.presets[command]) {
1775
1796
  const { buildIndex } = await import('../src/index.mjs');
1776
1797
  const { runQuery } = await import('../src/query.mjs');
1777
- const index = buildIndex(config);
1798
+ const index = buildIndex(config, listIndexOptions([...config.presets[command], ...restArgs]));
1778
1799
  applyIndexFilters(index);
1779
1800
  runQuery(index, [...config.presets[command], ...restArgs], config, { preset: command, type: typeArg, root: rootArg });
1780
1801
  return;
@@ -1787,7 +1808,7 @@ async function main() {
1787
1808
  if (command === 'plans') {
1788
1809
  const { buildIndex } = await import('../src/index.mjs');
1789
1810
  const { runQuery } = await import('../src/query.mjs');
1790
- const index = buildIndex(config);
1811
+ const index = buildIndex(config, listIndexOptions(restArgs));
1791
1812
  applyIndexFilters(index);
1792
1813
  const sub = restArgs[0];
1793
1814
  let defaults;
@@ -1807,7 +1828,7 @@ async function main() {
1807
1828
  if (command === 'runlists') {
1808
1829
  const { buildIndex } = await import('../src/index.mjs');
1809
1830
  const { runRunlists } = await import('../src/query.mjs');
1810
- const index = buildIndex(config);
1831
+ const index = buildIndex(config, listIndexOptions(restArgs));
1811
1832
  applyIndexFilters(index);
1812
1833
  runRunlists(index, restArgs, config);
1813
1834
  return;
@@ -1818,7 +1839,7 @@ async function main() {
1818
1839
  if (command === 'roadmaps') {
1819
1840
  const { buildIndex } = await import('../src/index.mjs');
1820
1841
  const { runRoadmaps } = await import('../src/roadmap.mjs');
1821
- const index = buildIndex(config);
1842
+ const index = buildIndex(config, listIndexOptions(restArgs));
1822
1843
  applyIndexFilters(index);
1823
1844
  runRoadmaps(index, restArgs, config);
1824
1845
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.82.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
+ }
@@ -42,7 +42,8 @@ export function extractNextStep(body) {
42
42
  }
43
43
 
44
44
  export function extractBodyLinks(body) {
45
- if (!body) return [];
45
+ // Every inline link contains `](`, and masking never creates one.
46
+ if (!body || !body.includes('](')) return [];
46
47
  // Strip fenced code blocks, then MASK inline code rather than delete it.
47
48
  // Deleting it ate the commonest link idiom in a plan hub: [`plan.md`](plan.md)
48
49
  // has its link TEXT as a code span, so removing the span left `[](plan.md)`,
package/src/index.mjs CHANGED
@@ -4,6 +4,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
5
5
  import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn, die, resolveDocPath, suggestCandidates } from './util.mjs';
6
6
  import { findLexicalDocsRoot } from './managed-path.mjs';
7
+ import { openParseCache, fileStamp, stampSize } from './parse-cache.mjs';
7
8
  import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, checkCoordinationHubExecutionMode, checkRoadmapHubExecutionMode, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
8
9
  import { checkIndex } from './index-file.mjs';
9
10
  import { checkClaudeCommands } from './claude-commands.mjs';
@@ -30,7 +31,9 @@ export function buildIndex(config, opts = {}) {
30
31
  const invokeHooks = opts.invokeHooks ?? !config._execution?.suppressSideEffects;
31
32
  const gitStaleness = opts.gitStaleness ?? config._execution?.gitStaleness ?? true;
32
33
  const skipWarningOnlyChecks = fast || errorsOnly;
33
- const docs = collectDocFiles(config).map(f => parseDocFile(f, config, { fast }));
34
+ const cache = openParseCache(config);
35
+ const docs = collectDocFiles(config).map(f => parseDocFile(f, config, { fast, cache }));
36
+ if (cache && !config._execution?.suppressSideEffects) cache.save();
34
37
  if (!fast) {
35
38
  // Per-file validation (validateDoc) ran during parse without sibling
36
39
  // visibility. Now that the full index is materialized, enrich
@@ -271,16 +274,45 @@ function walkMarkdownFiles(directory, files, excludedDirs, skipPaths, seen = new
271
274
  }
272
275
  }
273
276
 
274
- export function parseDocFile(filePath, config, opts = {}) {
275
- const { fast = false } = opts;
276
- const relativePath = toRepoPath(filePath, config.repoRoot);
277
- const raw = readFileSync(filePath, 'utf8');
278
- const { frontmatter, body } = extractFrontmatter(raw);
277
+ // Everything parseDocFile takes from the file's text alone, with no config and
278
+ // no clock, which is what the parse cache may keep.
279
+ function extractDocText(frontmatter, body) {
279
280
  const fmWarnings = [];
280
281
  const parsedFrontmatter = parseSimpleFrontmatter(frontmatter, fmWarnings);
281
- const headingTitle = extractFirstHeading(body);
282
+ return {
283
+ parsedFrontmatter,
284
+ fmWarnings: fmWarnings.map(w => ({ message: w.message })),
285
+ headingTitle: extractFirstHeading(body),
286
+ bodySummary: extractSummary(body),
287
+ bodyStatusSnapshot: extractStatusSnapshot(body),
288
+ bodyNextStep: extractNextStep(body),
289
+ checklist: extractChecklistCounts(body),
290
+ bodyLinks: extractBodyLinks(body),
291
+ hasCloseout: /^##\s+Closeout/m.test(body),
292
+ };
293
+ }
294
+
295
+ export function parseDocFile(filePath, config, opts = {}) {
296
+ const { fast = false, cache = null } = opts;
297
+ const relativePath = toRepoPath(filePath, config.repoRoot);
298
+ const stamp = cache ? fileStamp(filePath) : null;
299
+ let text = stamp ? cache.get(relativePath, stamp) : null;
300
+ // Validation reads the body itself, so only a fast build can skip the read.
301
+ let body = null;
302
+ if (!text || !fast) {
303
+ const raw = readFileSync(filePath, 'utf8');
304
+ const extracted = extractFrontmatter(raw);
305
+ body = extracted.body;
306
+ // A file rewritten between the stat and the read no longer matches its stamp.
307
+ if (text && Buffer.byteLength(raw) !== stampSize(stamp)) text = null;
308
+ if (!text) {
309
+ text = extractDocText(extracted.frontmatter, body);
310
+ if (stamp) cache.set(relativePath, stamp, text);
311
+ }
312
+ }
313
+ const { parsedFrontmatter, fmWarnings, headingTitle, checklist, bodyLinks, hasCloseout } = text;
282
314
  const title = asString(parsedFrontmatter.title) ?? headingTitle ?? path.basename(filePath, '.md');
283
- const summary = asString(parsedFrontmatter.summary) ?? extractSummary(body) ?? null;
315
+ const summary = asString(parsedFrontmatter.summary) ?? text.bodySummary ?? null;
284
316
  // For terminal-status docs (archived / reference / deprecated by default),
285
317
  // skip the body-scrape and the "No current_state set" fallback when the user
286
318
  // didn't set `current_state:` in frontmatter explicitly. Body text on a
@@ -305,7 +337,7 @@ export function parseDocFile(filePath, config, opts = {}) {
305
337
  } else if (isTerminalDoc) {
306
338
  currentState = null;
307
339
  } else {
308
- const scraped = extractStatusSnapshot(body);
340
+ const scraped = text.bodyStatusSnapshot;
309
341
  if (scraped) {
310
342
  currentState = scraped;
311
343
  currentStateOrigin = 'body';
@@ -313,7 +345,7 @@ export function parseDocFile(filePath, config, opts = {}) {
313
345
  currentState = 'No current_state set';
314
346
  }
315
347
  }
316
- const nextStep = asString(parsedFrontmatter.next_step) ?? extractNextStep(body) ?? null;
348
+ const nextStep = asString(parsedFrontmatter.next_step) ?? text.bodyNextStep ?? null;
317
349
  // `blocked_by` is accepted as an alias for `blockers` since 0.39.3 — agents
318
350
  // filing tickets naturally reach for the JIRA/Linear name. If both are set,
319
351
  // they're merged (de-duped via normalizeBlockers → mergeUniqueStrings).
@@ -328,9 +360,6 @@ export function parseDocFile(filePath, config, opts = {}) {
328
360
  const domain = asString(parsedFrontmatter.domain) ?? null;
329
361
  const audience = asString(parsedFrontmatter.audience) ?? null;
330
362
  const executionMode = asString(parsedFrontmatter.execution_mode) ?? null;
331
- const checklist = extractChecklistCounts(body);
332
- const bodyLinks = extractBodyLinks(body);
333
- const hasCloseout = /^##\s+Closeout/m.test(body);
334
363
 
335
364
  // Dynamic reference field extraction. A leading `>` on a value (e.g.
336
365
  // `"> docs/audit-beyond-platform.md"`) marks that single ref as one-way —
@@ -1,7 +1,5 @@
1
- function maskRange(value, start, end) {
2
- return value.slice(0, start)
3
- + value.slice(start, end).replace(/[^\n]/g, 'x')
4
- + value.slice(end);
1
+ function mask(text) {
2
+ return text.includes('\n') ? text.replace(/[^\n]/g, 'x') : 'x'.repeat(text.length);
5
3
  }
6
4
 
7
5
  // Markdown code spans close only on a backtick run of the same length as their
@@ -11,13 +9,19 @@ function maskRange(value, start, end) {
11
9
  // rewriter survive: after an unmatched opener, nothing is treated as prose
12
10
  // until a compatible closer appears.
13
11
  export function maskInlineCodeLine(line, state = { run: null }) {
14
- const ranges = [];
12
+ if (!line.includes('`')) return state.run === null ? line : mask(line);
13
+
15
14
  const runs = [...line.matchAll(/`+/g)];
15
+ const findCloser = (from, length) => {
16
+ for (let i = from; i < runs.length; i++) if (runs[i][0].length === length) return i;
17
+ return -1;
18
+ };
19
+ const ranges = [];
16
20
  let index = 0;
17
21
 
18
22
  if (state.run !== null) {
19
- const closingIndex = runs.findIndex(candidate => candidate[0].length === state.run);
20
- if (closingIndex === -1) return maskRange(line, 0, line.length);
23
+ const closingIndex = findCloser(0, state.run);
24
+ if (closingIndex === -1) return mask(line);
21
25
  const closing = runs[closingIndex];
22
26
  ranges.push([0, closing.index + closing[0].length]);
23
27
  index = closingIndex + 1;
@@ -26,8 +30,7 @@ export function maskInlineCodeLine(line, state = { run: null }) {
26
30
 
27
31
  for (; index < runs.length; index++) {
28
32
  const opening = runs[index];
29
- const closingIndex = runs.findIndex((candidate, candidateIndex) =>
30
- candidateIndex > index && candidate[0].length === opening[0].length);
33
+ const closingIndex = findCloser(index + 1, opening[0].length);
31
34
  if (closingIndex === -1) {
32
35
  ranges.push([opening.index, line.length]);
33
36
  state.run = opening[0].length;
@@ -38,7 +41,15 @@ export function maskInlineCodeLine(line, state = { run: null }) {
38
41
  index = closingIndex;
39
42
  }
40
43
 
41
- return ranges.reduceRight((masked, [start, end]) => maskRange(masked, start, end), line);
44
+ // Ranges are ascending and disjoint, so one left-to-right pass builds the
45
+ // result instead of re-copying the whole line once per span.
46
+ let out = '';
47
+ let cursor = 0;
48
+ for (const [start, end] of ranges) {
49
+ out += line.slice(cursor, start) + mask(line.slice(start, end));
50
+ cursor = end;
51
+ }
52
+ return out + line.slice(cursor);
42
53
  }
43
54
 
44
55
  export function maskInlineCodeSpans(value) {
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
  }
@@ -0,0 +1,99 @@
1
+ import { existsSync, readFileSync, statSync, writeFileSync, unlinkSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { commitRename } from './durable-rename.mjs';
5
+ import { readEnv, stateDir } from './naming.mjs';
6
+
7
+ // What every build of the index used to redo from scratch: read each document
8
+ // and pull its frontmatter, links, checklist and summary out of the body. None
9
+ // of that depends on the config or the clock, so it is kept per file under the
10
+ // state directory and reused while the file's size, times and inode are
11
+ // unchanged. Everything that does depend on them (terminal statuses, staleness,
12
+ // reference fields, validation) is still computed on every run.
13
+ //
14
+ // One line per document, `path \t stamp \t json`, so a hit parses only its own
15
+ // line and hands back fresh objects: nothing a caller does to a document can
16
+ // leak into what is saved. The cache is an optimisation and never an input:
17
+ // a missing, unreadable or foreign-version file is treated as empty, a failed
18
+ // write is dropped, and `RUNLIST_NO_PARSE_CACHE=1` turns it off.
19
+
20
+ const SCHEMA = 1;
21
+ const FILE_NAME = 'parse-cache';
22
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
+ const VERSION = (() => {
24
+ try { return JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version; }
25
+ catch { return 'unknown'; }
26
+ })();
27
+ const HEADER = JSON.stringify({ schema: SCHEMA, version: VERSION });
28
+
29
+ export function fileStamp(filePath) {
30
+ try {
31
+ const s = statSync(filePath, { bigint: true });
32
+ return `${s.size}:${s.mtimeNs}:${s.ctimeNs}:${s.ino}`;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export function stampSize(stamp) {
39
+ return Number(stamp.slice(0, stamp.indexOf(':')));
40
+ }
41
+
42
+ export function openParseCache(config) {
43
+ if (readEnv('NO_PARSE_CACHE') === '1') return null;
44
+ const dir = stateDir(config.repoRoot);
45
+ // The state directory is created, and ignored by git, by `init` and the
46
+ // commands that own it; a repo without one gets no cache rather than a new
47
+ // untracked folder from a read-only command.
48
+ if (!existsSync(dir)) return null;
49
+ const cachePath = path.join(dir, FILE_NAME);
50
+ const lines = new Map();
51
+ try {
52
+ const text = readFileSync(cachePath, 'utf8');
53
+ const rows = text.split('\n');
54
+ if (rows[0] === HEADER) {
55
+ for (let i = 1; i < rows.length; i++) {
56
+ const row = rows[i];
57
+ const a = row.indexOf('\t');
58
+ const b = row.indexOf('\t', a + 1);
59
+ if (a < 0 || b < 0) continue;
60
+ lines.set(row.slice(0, a), { stamp: row.slice(a + 1, b), row });
61
+ }
62
+ }
63
+ } catch { /* no cache yet */ }
64
+
65
+ const seen = new Set();
66
+ let dirty = false;
67
+
68
+ return {
69
+ get(key, stamp) {
70
+ seen.add(key);
71
+ const entry = lines.get(key);
72
+ if (!entry || entry.stamp !== stamp) return null;
73
+ try { return JSON.parse(entry.row.slice(entry.row.indexOf('\t', entry.row.indexOf('\t') + 1) + 1)); }
74
+ catch { return null; }
75
+ },
76
+ set(key, stamp, value) {
77
+ seen.add(key);
78
+ if (/[\t\n]/.test(key)) return;
79
+ lines.set(key, { stamp, row: `${key}\t${stamp}\t${JSON.stringify(value)}` });
80
+ dirty = true;
81
+ },
82
+ save({ complete = true } = {}) {
83
+ if (complete) {
84
+ for (const key of lines.keys()) {
85
+ if (!seen.has(key)) { lines.delete(key); dirty = true; }
86
+ }
87
+ }
88
+ if (!dirty) return;
89
+ const temp = `${cachePath}.${process.pid}.${Date.now()}.tmp`;
90
+ try {
91
+ writeFileSync(temp, [HEADER, ...[...lines.values()].map(entry => entry.row)].join('\n'), 'utf8');
92
+ commitRename(temp, cachePath);
93
+ dirty = false;
94
+ } catch {
95
+ try { unlinkSync(temp); } catch { /* already gone */ }
96
+ }
97
+ },
98
+ };
99
+ }
package/src/pickup.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { authorizeManagedSource, authorizeRepoGeneratedPath } from './managed-path.mjs';
5
5
  import os from 'node:os';
@@ -233,7 +233,48 @@ function validateBinding(record, identity, config) {
233
233
  return record;
234
234
  }
235
235
 
236
+ // Working out a plan's canonical identity lists every directory on its path,
237
+ // three times over, and listing plans asks it of every plan in the repo. A
238
+ // record can only belong to a plan whose file name it carries, so the names in
239
+ // the ownership folder rule most plans out first. The summary is rebuilt
240
+ // whenever the folder changes, and any record it cannot read turns the check
241
+ // off, so the full identity check still decides every case it could get wrong.
242
+ let ownershipNamesCache = null;
243
+
244
+ function ownershipNames(config) {
245
+ const dir = ownershipRoot(config);
246
+ let stamp;
247
+ try { stamp = `${dir}\0${statSync(dir, { bigint: true }).mtimeNs}`; }
248
+ catch { return new Set(); }
249
+ if (ownershipNamesCache?.stamp === stamp) return ownershipNamesCache.names;
250
+ let names = new Set();
251
+ try {
252
+ for (const entry of readdirSync(dir)) {
253
+ if (!entry.endsWith('.json')) continue;
254
+ const record = parseOwnership(readFileSync(path.join(dir, entry), 'utf8'), entry);
255
+ if (record.corrupt) { names = null; break; }
256
+ names.add(path.basename(record.canonicalPath).toLowerCase());
257
+ names.add(path.basename(record.plan).toLowerCase());
258
+ }
259
+ } catch { names = null; }
260
+ ownershipNamesCache = { stamp, names };
261
+ return names;
262
+ }
263
+
264
+ function mayHaveOwnershipRecord(absolutePath, config) {
265
+ const names = ownershipNames(config);
266
+ if (names === null || names.has(path.basename(absolutePath).toLowerCase())) return true;
267
+ try {
268
+ if (lstatSync(absolutePath).isSymbolicLink()) return true;
269
+ // A record whose fields were rewritten still sits at its identity's file
270
+ // name, and has to be found to be reported corrupt.
271
+ const guess = createHash('sha256').update(realpathSync(absolutePath)).digest('hex');
272
+ return existsSync(path.join(ownershipRoot(config), `${guess}.json`));
273
+ } catch { return true; }
274
+ }
275
+
236
276
  export function readPlanOwnership(repoPath, config) {
277
+ if (!mayHaveOwnershipRecord(path.resolve(config.repoRoot, repoPath), config)) return null;
237
278
  let identity;
238
279
  try { identity = canonicalPlanIdentity(path.resolve(config.repoRoot, repoPath), config); }
239
280
  catch { return null; }