dotmd-cli 0.83.0 → 0.85.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,60 @@ 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
+
262
+ ## Flags
263
+
264
+ A flag is something someone found that the person should know about when they
265
+ come back: a plan that contradicts another, a decision open in one place and
266
+ ruled in another, a citation that no longer says what it claims. Any session,
267
+ person or check can add one, and no model is needed:
268
+
269
+ ```bash
270
+ runlist flag add docs/plans/auth.md:42 "says tokens expire in 1h; the spec says 24h" --severity problem
271
+ runlist flags # open flags, problems first, newest first
272
+ runlist flag accept F3 --note "real, owner agrees"
273
+ runlist flag reject F4 # not a problem; closed
274
+ runlist flag resolve F3 # fixed
275
+ runlist check --flag # the check's errors become flags, attributed to it
276
+ ```
277
+
278
+ Each flag keeps the text of the line it points at, so the list says when that
279
+ line has moved or changed since. A repeat of an open flag on the same place is
280
+ merged. The log is append-only, `.runlist/flags.jsonl` by default
281
+ (`export const flags = { file }` moves it), and triage is recorded as events
282
+ beside the flag, never over it. Every session start shows a count and the top
283
+ open flags.
284
+
228
285
  ## Safety Model
229
286
 
230
287
  - 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, …]\`
@@ -1880,6 +1895,8 @@ async function main() {
1880
1895
  if (command === 'deps') { const { runDeps } = await import('../src/deps.mjs'); runDeps(restArgs, config); return; }
1881
1896
  if (command === 'unblocks') { const { runUnblocks } = await import('../src/deps.mjs'); runUnblocks(restArgs, config); return; }
1882
1897
  if (command === 'health') { const { runHealth } = await import('../src/health.mjs'); runHealth(restArgs, config); return; }
1898
+ if (command === 'flags') { const { runFlags } = await import('../src/flags.mjs'); runFlags(restArgs, config); return; }
1899
+ if (command === 'flag') { const { runFlag } = await import('../src/flags.mjs'); runFlag(restArgs, config); return; }
1883
1900
  if (command === 'glossary') { const { runGlossary } = await import('../src/glossary.mjs'); runGlossary(restArgs, config); return; }
1884
1901
  if (command === 'export') { const { runExport } = await import('../src/export.mjs'); runExport(restArgs, config, { dryRun, root: rootArg, type: typeArg }); return; }
1885
1902
 
@@ -1999,6 +2016,17 @@ async function main() {
1999
2016
  ? ['validate', 'transformDoc', 'formatSnapshot', 'renderCheck']
2000
2017
  .filter(name => typeof config.hooks?.[name] === 'function')
2001
2018
  : [];
2019
+ // `--flag` puts each error on the flags list, attributed to this check, and
2020
+ // resolves the check's earlier flags it no longer reports. Whole-repo runs
2021
+ // only: a scoped run cannot tell a fixed error from one outside its scope.
2022
+ const flagCheckErrors = async (checkIndex) => {
2023
+ if (!args.includes('--flag') || dryRun) return;
2024
+ if (checkTargets.length > 0) die('`--flag` runs on the whole repository; drop the path arguments.');
2025
+ const { syncCheckFlags } = await import('../src/flags.mjs');
2026
+ const findings = checkIndex.errors.filter(e => e.path).map(e => ({ file: e.path, text: e.message }));
2027
+ const { added, resolved } = syncCheckFlags(config, 'runlist check', findings);
2028
+ process.stderr.write(`flags: ${added} added, ${resolved} resolved\n`);
2029
+ };
2002
2030
  const checkJson = (checkIndex) => {
2003
2031
  const builtInPassed = checkIndex.errors.length === 0;
2004
2032
  const complete = skippedCheckHooks.length === 0;
@@ -2053,6 +2081,7 @@ async function main() {
2053
2081
  applyIndexFilters(freshIndex);
2054
2082
  applyPathScopeToIndex(freshIndex, config, checkTargets);
2055
2083
  applyFloor(freshIndex);
2084
+ await flagCheckErrors(freshIndex);
2056
2085
  if (args.includes('--json')) {
2057
2086
  process.stdout.write(JSON.stringify(checkJson(freshIndex), null, 2) + '\n');
2058
2087
  } else {
@@ -2065,6 +2094,7 @@ async function main() {
2065
2094
 
2066
2095
  applyPathScopeToIndex(index, config, checkTargets);
2067
2096
  applyFloor(index);
2097
+ await flagCheckErrors(index);
2068
2098
 
2069
2099
  if (args.includes('--json')) {
2070
2100
  process.stdout.write(JSON.stringify(checkJson(index), null, 2) + '\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.83.0",
3
+ "version": "0.85.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/commands.mjs CHANGED
@@ -78,6 +78,15 @@ const definitions = [
78
78
  command('summary', none, 'read', [form('<file>', { args: positionals(1, 1), options: [value('--model'), value('--max-tokens'), flag('--json')] })]),
79
79
  command('unblocks', none, 'read', [form('<file>', { args: positionals(1, 1), options: [flag('--json')] })]),
80
80
  command('health', none, 'read', [form('', { options: [flag('--json')] })]),
81
+ command('flags', none, 'read', [form('', { options: [flag('--all'), flag('--json')] })]),
82
+ command('flag', mutates('the flags log under the state directory'), 'mutate', [
83
+ form('add <file[:line]> <text...>', { subcommands: ['add'], args: positionals(2, Infinity), options: [value('--severity'), value('--by')] }),
84
+ form('accept <id>', { subcommands: ['accept'], args: positionals(1, 1), options: [value('--note'), value('--by')] }),
85
+ form('reject <id>', { subcommands: ['reject'], args: positionals(1, 1), options: [value('--note'), value('--by')] }),
86
+ form('resolve <id>', { subcommands: ['resolve'], args: positionals(1, 1), options: [value('--note'), value('--by')] }),
87
+ form('show <id>', { subcommands: ['show'], args: positionals(1, 1) }),
88
+ form('sync <check-name> [findings]', { subcommands: ['sync'], args: positionals(1, 2), dashPositionalsAfter: 1 }),
89
+ ]),
81
90
  command('glossary', none, 'read', [form('[term]', { args: positionals(0, 1), options: [flag('--list'), flag('--json')] })]),
82
91
  command('modules', none, 'read', [form('', { options: [value('--sort'), value('--limit'), flag('--all'), flag('--json')] })]),
83
92
  command('module', none, 'read', [form('<name>', { args: positionals(1, 1), options: [value('--sort'), flag('--json')] })]),
@@ -129,7 +138,7 @@ const definitions = [
129
138
  command('touch', mutates('managed source or managed source sweep'), 'mutate', [form('[file...]', { args: positionals(0, Infinity), options: [flag('--git')] })]),
130
139
  command('new', mutates('managed document destination; external body input unrestricted'), 'mutate', [form('[type] <name> [body...]', {
131
140
  args: positionals(0, Infinity),
132
- options: [value('--status'), value('--title'), value('--runlist'), flag('--coordination'), flag('--roadmap'), flag('--lite', '--minimal'), flag('--audit', '--findings'), value('--body', '--message'), value('--root'), flag('--show-files'), flag('--list-templates', '--list-types')],
141
+ options: [value('--status'), value('--title'), value('--runlist'), flag('--coordination'), flag('--roadmap'), flag('--lite', '--minimal'), flag('--audit', '--findings'), value('--body', '--message'), value('--root'), flag('--show-files'), flag('--list-templates', '--list-types'), value('--question'), value('--answers'), value('--disposition')],
133
142
  dashPositionalsAfter: 1,
134
143
  })]),
135
144
  command('lint', mutates('managed source sweep with --fix; otherwise read-only'), 'mutate', [form('', { options: [flag('--fix')] })]),
@@ -151,7 +160,7 @@ const definitions = [
151
160
  form('migrate <type>', { subcommands: ['migrate'], args: positionals(1, 1), options: [flag('--yes', '-y'), flag('--json'), flag('--ignore-lifecycle-override')] }),
152
161
  form('', { options: [value('--type'), flag('--json')] }),
153
162
  ]),
154
- command('check', mutates('managed fix sweeps and repo-generated index; otherwise validation'), 'mutate', [form('[paths...]', { args: positionals(0, Infinity), options: [flag('--fix'), flag('--errors-only'), flag('--no-collapse'), flag('--json'), flag('--verbose'), value('--min-docs')] })]),
163
+ command('check', mutates('managed fix sweeps and repo-generated index; otherwise validation'), 'mutate', [form('[paths...]', { args: positionals(0, Infinity), options: [flag('--fix'), flag('--errors-only'), flag('--no-collapse'), flag('--json'), flag('--verbose'), value('--min-docs'), flag('--flag')] })]),
155
164
  command('index', mutates('repo-generated index destination; --print is read-only'), 'mutate', [form('', { options: [flag('--print')] })]),
156
165
 
157
166
  command('self-check', none, 'internal', [form('', { options: [flag('--json')] })], { visibility: 'internal' }),
package/src/config.mjs CHANGED
@@ -137,6 +137,10 @@ const DEFAULTS = {
137
137
  templates: {},
138
138
 
139
139
  glossary: null,
140
+ // `runlist new decision`: { section, prefix, register: { file, statusLine } }.
141
+ decisions: null,
142
+ // `runlist flag` / `runlist flags`: { file } moves the log from .runlist/flags.jsonl.
143
+ flags: null,
140
144
 
141
145
  // Opt-in JSONL command journal at .dotmd/journal.jsonl. Default off — agents
142
146
  // and users who want usage observability flip this on (or set RUNLIST_JOURNAL=1).
@@ -417,6 +421,14 @@ function validateConfig(userConfig, config, validStatuses, indexPath) {
417
421
  }
418
422
  }
419
423
 
424
+ if (userConfig.decisions != null) {
425
+ const d = userConfig.decisions;
426
+ if (typeof d !== 'object' || Array.isArray(d)) warnings.push('Config: decisions must be an object.');
427
+ else if (d.register != null && (typeof d.register?.file !== 'string' || typeof d.register?.statusLine !== 'string')) {
428
+ warnings.push('Config: decisions.register needs a file and a statusLine.');
429
+ }
430
+ }
431
+
420
432
  // Unknown top-level user config keys
421
433
  for (const key of Object.keys(userConfig)) {
422
434
  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/flags.mjs ADDED
@@ -0,0 +1,299 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { withPathLocks } from './atomic-mutation.mjs';
5
+ import { stateDir } from './naming.mjs';
6
+ import { die, hostSessionSource, nowIso, toRepoPath } from './util.mjs';
7
+ import { bold, dim, green, red, yellow } from './color.mjs';
8
+
9
+ // Flags: what anyone found that the person should know when they come back —
10
+ // a plan contradicting another, a decision open in one place and ruled in
11
+ // another, a citation that no longer says what it claims. A session, a person
12
+ // or a check adds one; no model is needed. A later pass may build context onto
13
+ // an open flag, but the flag stands on what its author wrote.
14
+ //
15
+ // Storage is an append-only event log (`add`, `accept`, `reject`, `resolve`),
16
+ // one JSON object per line, so nothing is overwritten and the triage record
17
+ // survives as written. The current state of a flag is derived from its events.
18
+ // Default path `.runlist/flags.jsonl`; `export const flags = { file }` moves it.
19
+
20
+ export const SEVERITIES = ['problem', 'warn', 'info'];
21
+ const TRIAGE = new Set(['accept', 'reject', 'resolve']);
22
+ const QUOTE_MAX = 200;
23
+
24
+ export function flagsFile(config) {
25
+ const configured = config.raw?.flags?.file;
26
+ return configured ? path.resolve(config.repoRoot, configured) : path.join(stateDir(config.repoRoot), 'flags.jsonl');
27
+ }
28
+
29
+ export function readFlagEvents(file) {
30
+ if (!existsSync(file)) return [];
31
+ const events = [];
32
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
33
+ if (!line.trim()) continue;
34
+ try { events.push(JSON.parse(line)); } catch { /* a torn line is skipped, never fatal */ }
35
+ }
36
+ return events;
37
+ }
38
+
39
+ export function deriveFlags(events) {
40
+ const flags = new Map();
41
+ for (const e of events) {
42
+ if (e.event === 'add' && e.id && !flags.has(e.id)) {
43
+ flags.set(e.id, { ...e, state: 'open', triage: null, history: [] });
44
+ continue;
45
+ }
46
+ const flag = flags.get(e.id);
47
+ if (!flag || !TRIAGE.has(e.event)) continue;
48
+ flag.history.push({ event: e.event, at: e.at, by: e.by, note: e.note ?? null });
49
+ if (e.event === 'accept') flag.triage = 'accepted';
50
+ if (e.event === 'reject') { flag.triage = 'rejected'; flag.state = 'closed'; }
51
+ if (e.event === 'resolve') flag.state = 'resolved';
52
+ }
53
+ return [...flags.values()];
54
+ }
55
+
56
+ function nextId(flags) {
57
+ let max = 0;
58
+ for (const f of flags) max = Math.max(max, Number(String(f.id).replace(/^F/, '')) || 0);
59
+ return `F${max + 1}`;
60
+ }
61
+
62
+ // `--by check:<name>`, `--by model:<name>`, `--by person:<name>`; otherwise the
63
+ // session the environment names, or the person at the keyboard.
64
+ export function resolveAuthor(byArg, env = process.env) {
65
+ if (byArg) {
66
+ const m = byArg.match(/^(session|person|check|model):(.+)$/);
67
+ if (!m) die('--by is <kind>:<name>, where kind is session, person, check or model.');
68
+ return { kind: m[1], name: m[2].trim() };
69
+ }
70
+ const source = hostSessionSource(env);
71
+ if (source?.scope === 'session') return { kind: 'session', name: source.host, session: source.id };
72
+ return { kind: 'person', name: os.userInfo().username };
73
+ }
74
+
75
+ export function normalizeText(text) {
76
+ return text.replace(/\s+/g, ' ').trim().toLowerCase();
77
+ }
78
+
79
+ // `docs/plans/x.md:12` → file and line; the file must exist in the repo and
80
+ // the line must be inside it. The line's text is kept, so a later read can say
81
+ // whether the place still says what was flagged.
82
+ export function resolvePlace(place, config) {
83
+ const m = place.match(/^(.*?)(?::(\d+))?$/);
84
+ const rel = m[1];
85
+ const line = m[2] ? Number(m[2]) : null;
86
+ const abs = path.resolve(config.repoRoot, rel);
87
+ if (!existsSync(abs)) die(`No such file: ${rel}`);
88
+ const repoPath = toRepoPath(abs, config.repoRoot);
89
+ if (repoPath.startsWith('..')) die(`${rel} is outside the repository.`);
90
+ let quote = null;
91
+ if (line !== null) {
92
+ const lines = readFileSync(abs, 'utf8').split('\n');
93
+ if (line < 1 || line > lines.length) die(`${repoPath} has ${lines.length} lines; there is no line ${line}.`);
94
+ quote = lines[line - 1].trim().slice(0, QUOTE_MAX);
95
+ }
96
+ return { file: repoPath, line, quote };
97
+ }
98
+
99
+ // Where the flagged text is now: unchanged, moved to another line, or gone.
100
+ export function locateFlag(flag, config) {
101
+ if (flag.line == null) return { status: existsSync(path.resolve(config.repoRoot, flag.file)) ? 'here' : 'gone' };
102
+ let lines;
103
+ try { lines = readFileSync(path.resolve(config.repoRoot, flag.file), 'utf8').split('\n'); }
104
+ catch { return { status: 'gone' }; }
105
+ if ((lines[flag.line - 1] ?? '').trim().slice(0, QUOTE_MAX) === flag.quote) return { status: 'here', line: flag.line };
106
+ if (flag.quote) {
107
+ const at = lines.findIndex(l => l.trim().slice(0, QUOTE_MAX) === flag.quote);
108
+ if (at !== -1) return { status: 'moved', line: at + 1 };
109
+ }
110
+ return { status: 'changed' };
111
+ }
112
+
113
+ function withFlagsLock(config, fn) {
114
+ const file = flagsFile(config);
115
+ const dir = path.dirname(file);
116
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
117
+ return withPathLocks([file], { repoRoot: config.repoRoot }, () => fn(file));
118
+ }
119
+
120
+ function append(file, event) {
121
+ appendFileSync(file, `${JSON.stringify(event)}\n`, { flag: 'a' });
122
+ }
123
+
124
+ export function addFlag(config, { place, text, severity = 'warn', by = null }) {
125
+ if (!text || !text.trim()) die('A flag says what is wrong: runlist flag add <file[:line]> "<what is wrong>"');
126
+ if (!SEVERITIES.includes(severity)) die(`--severity is one of ${SEVERITIES.join(', ')}.`);
127
+ const where = resolvePlace(place, config);
128
+ const author = typeof by === 'object' && by ? by : resolveAuthor(by);
129
+ return withFlagsLock(config, file => {
130
+ const flags = deriveFlags(readFlagEvents(file));
131
+ const key = normalizeText(text);
132
+ const same = flags.find(f => f.state === 'open' && f.file === where.file && f.line === where.line && normalizeText(f.text) === key);
133
+ if (same) return { flag: same, added: false };
134
+ const event = { event: 'add', id: nextId(flags), at: nowIso(), ...where, text: text.trim(), severity, by: author };
135
+ append(file, event);
136
+ return { flag: { ...event, state: 'open', triage: null, history: [] }, added: true };
137
+ });
138
+ }
139
+
140
+ export function triageFlag(config, { id, event, note = null, by = null }) {
141
+ const author = typeof by === 'object' && by ? by : resolveAuthor(by);
142
+ return withFlagsLock(config, file => {
143
+ const flag = deriveFlags(readFlagEvents(file)).find(f => f.id === id);
144
+ if (!flag) die(`No flag ${id}.`);
145
+ if (flag.state !== 'open') die(`${id} is already ${flag.state === 'closed' ? 'rejected' : flag.state}.`);
146
+ append(file, { event, id, at: nowIso(), by: author, ...(note ? { note } : {}) });
147
+ return flag;
148
+ });
149
+ }
150
+
151
+ export function openFlags(config) {
152
+ const rank = f => SEVERITIES.indexOf(f.severity);
153
+ return deriveFlags(readFlagEvents(flagsFile(config)))
154
+ .filter(f => f.state === 'open')
155
+ .sort((a, b) => rank(a) - rank(b) || String(b.at).localeCompare(String(a.at)));
156
+ }
157
+
158
+ // A check's flags follow what the check reports: each error it reports is
159
+ // flagged once, and a flag it raised earlier that it no longer reports is
160
+ // resolved, so the list never holds a problem the check has stopped seeing.
161
+ // A finding is matched to its open flag on file and text, not line: a check
162
+ // reports the same problem at a new line after an edit above it, and that is
163
+ // still one flag.
164
+ export function syncCheckFlags(config, checkName, findings) {
165
+ const by = { kind: 'check', name: checkName };
166
+ const keyOf = f => `${f.file}\0${normalizeText(f.text)}`;
167
+ const current = new Set(findings.map(keyOf));
168
+ const open = new Set(openFlags(config).filter(f => f.by?.kind === 'check' && f.by?.name === checkName).map(keyOf));
169
+ let added = 0;
170
+ let resolved = 0;
171
+ for (const finding of findings) {
172
+ if (open.has(keyOf(finding))) continue;
173
+ if (!existsSync(path.resolve(config.repoRoot, finding.file))) continue;
174
+ const place = finding.line ? `${finding.file}:${finding.line}` : finding.file;
175
+ const severity = SEVERITIES.includes(finding.severity) ? finding.severity : 'problem';
176
+ try {
177
+ if (addFlag(config, { place, text: finding.text, severity, by }).added) { added++; open.add(keyOf(finding)); }
178
+ } catch { /* a line the file no longer has: the next run reports it again */ }
179
+ }
180
+ for (const flag of openFlags(config)) {
181
+ if (flag.by?.kind !== 'check' || flag.by?.name !== checkName) continue;
182
+ if (current.has(`${flag.file}\0${normalizeText(flag.text)}`)) continue;
183
+ triageFlag(config, { id: flag.id, event: 'resolve', note: `no longer reported by ${checkName}`, by });
184
+ resolved++;
185
+ }
186
+ return { added, resolved };
187
+ }
188
+
189
+ function authorLabel(by) {
190
+ if (!by) return 'unknown';
191
+ return by.kind === 'session' ? `${by.name} session ${String(by.session ?? '').slice(0, 8)}` : `${by.kind} ${by.name}`;
192
+ }
193
+
194
+ function placeLabel(flag, config) {
195
+ const where = locateFlag(flag, config);
196
+ const base = flag.line != null ? `${flag.file}:${flag.line}` : flag.file;
197
+ if (where.status === 'moved') return `${base} ${dim(`(now line ${where.line})`)}`;
198
+ if (where.status === 'changed') return `${base} ${yellow('(text there has changed)')}`;
199
+ if (where.status === 'gone') return `${base} ${yellow('(file gone)')}`;
200
+ return base;
201
+ }
202
+
203
+ const severityLabel = s => (s === 'problem' ? red(s) : s === 'warn' ? yellow(s) : dim(s));
204
+
205
+ // One line for the session-start banner, or null when nothing is open.
206
+ export function flagsHudLine(config, { top = 3 } = {}) {
207
+ let open;
208
+ try { open = openFlags(config); } catch { return null; }
209
+ if (open.length === 0) return null;
210
+ const problems = open.filter(f => f.severity === 'problem').length;
211
+ const items = open.slice(0, top).map(f => `${f.id} ${f.line != null ? `${f.file}:${f.line}` : f.file} ${f.text.slice(0, 80)}`).join('; ');
212
+ return `[runlist] ${open.length} open flag${open.length === 1 ? '' : 's'}${problems ? `, ${problems} problem${problems === 1 ? '' : 's'}` : ''}, for awareness: ${items}. List: \`runlist flags\`.`;
213
+ }
214
+
215
+ export function runFlags(argv, config) {
216
+ const json = argv.includes('--json');
217
+ const all = argv.includes('--all');
218
+ const events = readFlagEvents(flagsFile(config));
219
+ const flags = deriveFlags(events)
220
+ .filter(f => all || f.state === 'open')
221
+ .sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity) || String(b.at).localeCompare(String(a.at)));
222
+ if (json) {
223
+ process.stdout.write(`${JSON.stringify(flags.map(f => ({ ...f, location: locateFlag(f, config) })), null, 2)}\n`);
224
+ return;
225
+ }
226
+ if (flags.length === 0) {
227
+ process.stdout.write(dim(all ? 'No flags.\n' : 'No open flags.\n'));
228
+ return;
229
+ }
230
+ for (const f of flags) {
231
+ const state = f.state === 'open' ? (f.triage === 'accepted' ? green('accepted') : '') : dim(f.state === 'closed' ? 'rejected' : f.state);
232
+ process.stdout.write(`${bold(f.id)} ${severityLabel(f.severity)} ${placeLabel(f, config)} ${state}\n`);
233
+ process.stdout.write(` ${f.text}\n`);
234
+ process.stdout.write(dim(` ${authorLabel(f.by)}, ${f.at}`) + '\n');
235
+ }
236
+ }
237
+
238
+ export function runFlag(argv, config) {
239
+ const [sub, ...rest] = argv;
240
+ const positional = [];
241
+ let severity;
242
+ let by = null;
243
+ let note = null;
244
+ for (let i = 0; i < rest.length; i++) {
245
+ const a = rest[i];
246
+ if (a === '--severity' && rest[i + 1]) { severity = rest[++i]; continue; }
247
+ if (a === '--by' && rest[i + 1]) { by = rest[++i]; continue; }
248
+ if (a === '--note' && rest[i + 1]) { note = rest[++i]; continue; }
249
+ if (a === '--config') { i++; continue; }
250
+ if (a.startsWith('-')) continue;
251
+ positional.push(a);
252
+ }
253
+ const usage = 'Usage: runlist flag add <file[:line]> "<what is wrong>" [--severity problem|warn|info]\n'
254
+ + ' runlist flag accept|reject|resolve <id> [--note "..."]\n'
255
+ + ' runlist flag show <id>\n'
256
+ + ' runlist flag sync <check-name> [@findings.json | -]';
257
+
258
+ if (sub === 'add') {
259
+ const [place, ...words] = positional;
260
+ if (!place) die(usage);
261
+ const { flag, added } = addFlag(config, { place, text: words.join(' '), severity, by });
262
+ process.stdout.write(added ? `${green('Flagged')} ${flag.id} ${placeLabel(flag, config)}\n` : `${dim('Already open as')} ${flag.id}\n`);
263
+ return;
264
+ }
265
+ if (TRIAGE.has(sub)) {
266
+ const [id] = positional;
267
+ if (!id) die(usage);
268
+ triageFlag(config, { id, event: sub, note, by });
269
+ process.stdout.write(`${green({ accept: 'Accepted', reject: 'Rejected', resolve: 'Resolved' }[sub])} ${id}\n`);
270
+ return;
271
+ }
272
+ if (sub === 'sync') {
273
+ // A check outside runlist hands over everything it reports now, as a JSON
274
+ // array of { file, line?, text, severity? }; the list follows it.
275
+ const [name, source] = positional;
276
+ if (!name) die('Usage: runlist flag sync <check-name> [@findings.json | -] (JSON array of { file, line?, text, severity? })');
277
+ let raw;
278
+ try { raw = source && source !== '-' ? readFileSync(source.replace(/^@/, ''), 'utf8') : readFileSync(0, 'utf8'); }
279
+ catch (err) { die(`Could not read the findings: ${err.message}`); }
280
+ let findings;
281
+ try { findings = JSON.parse(raw); } catch { die('The findings are not valid JSON.'); }
282
+ if (!Array.isArray(findings) || findings.some(f => typeof f?.file !== 'string' || typeof f?.text !== 'string')) {
283
+ die('The findings are a JSON array of { file, line?, text, severity? }.');
284
+ }
285
+ const { added, resolved } = syncCheckFlags(config, name, findings);
286
+ process.stdout.write(`flags from ${name}: ${added} added, ${resolved} resolved\n`);
287
+ return;
288
+ }
289
+ if (sub === 'show') {
290
+ const flag = deriveFlags(readFlagEvents(flagsFile(config))).find(f => f.id === positional[0]);
291
+ if (!flag) die(`No flag ${positional[0] ?? ''}.`);
292
+ process.stdout.write(`${bold(flag.id)} ${severityLabel(flag.severity)} ${placeLabel(flag, config)}\n ${flag.text}\n`);
293
+ if (flag.quote) process.stdout.write(dim(` flagged line: ${flag.quote}`) + '\n');
294
+ process.stdout.write(dim(` raised by ${authorLabel(flag.by)}, ${flag.at}`) + '\n');
295
+ for (const h of flag.history) process.stdout.write(dim(` ${h.event} by ${authorLabel(h.by)}, ${h.at}${h.note ? `: ${h.note}` : ''}`) + '\n');
296
+ return;
297
+ }
298
+ die(usage);
299
+ }
package/src/hud.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { flagsHudLine, openFlags } from './flags.mjs';
1
2
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
@@ -263,6 +264,7 @@ export function buildHud(config) {
263
264
  fleet,
264
265
  recentRejections,
265
266
  misuseRecap,
267
+ flags: (() => { try { return openFlags(config).map(f => ({ id: f.id, severity: f.severity, file: f.file, line: f.line, text: f.text })); } catch { return []; } })(),
266
268
  };
267
269
  }
268
270
 
@@ -403,5 +405,10 @@ export function runHud(argv, config) {
403
405
  process.stdout.write(yellow(`[runlist] ${n} pending prompt${n === 1 ? '' : 's'} queued for this session — unless the user asks for something else, start by running \`runlist use\` to consume the oldest (${hud.prompts[0]}) and act on it. Peek first: \`runlist prompts show <file>\`; list: \`runlist prompts\`.`) + '\n');
404
406
  }
405
407
  if (hud.misuseRecap) process.stdout.write(yellow(`[runlist] ${hud.misuseRecap}`) + '\n');
408
+ // Open flags are the one piece of passive state printed here: the person
409
+ // asked that every session start knowing where things stand. It is worded
410
+ // as awareness, not an instruction, so no session treats it as its task.
411
+ const flagsLine = flagsHudLine(config);
412
+ if (flagsLine) process.stdout.write(yellow(flagsLine) + '\n');
406
413
  if (drift) process.stdout.write(yellow(drift) + '\n');
407
414
  }
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
  }