runlist 0.0.0 → 0.94.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1114 -6
  3. package/assets/app/app.css +283 -0
  4. package/assets/app/app.mjs +381 -0
  5. package/assets/app/block-editor.mjs +182 -0
  6. package/assets/app/block-model.mjs +52 -0
  7. package/assets/app/document-create.mjs +86 -0
  8. package/assets/app/document-lifecycle.mjs +36 -0
  9. package/assets/app/document-stages.mjs +22 -0
  10. package/assets/app/document-views.mjs +33 -0
  11. package/assets/app/document-yardstick.mjs +258 -0
  12. package/assets/app/editor-navigation.mjs +51 -0
  13. package/assets/app/filing-navigation.mjs +138 -0
  14. package/assets/app/git-changes.mjs +80 -0
  15. package/assets/app/git-commit.mjs +46 -0
  16. package/assets/app/index.html +70 -0
  17. package/assets/app/library-navigation.mjs +93 -0
  18. package/assets/app/outline.mjs +18 -0
  19. package/assets/app/quick-navigation.mjs +52 -0
  20. package/assets/app/record-navigation.mjs +78 -0
  21. package/assets/app/recovery-center.mjs +53 -0
  22. package/assets/app/semantic-search.mjs +14 -0
  23. package/assets/app/settings.mjs +15 -0
  24. package/assets/app/shared.mjs +104 -0
  25. package/assets/app/stage-source.mjs +75 -0
  26. package/assets/app/template-editor.mjs +47 -0
  27. package/assets/app/theme.mjs +25 -0
  28. package/assets/app/transport.mjs +43 -0
  29. package/assets/contracts/source-editing-v1.md +232 -0
  30. package/assets/opencode/plugin.js +162 -0
  31. package/assets/schemas/runlist-record-v1.schema.json +912 -0
  32. package/bin/dotmd.mjs +5 -0
  33. package/bin/runlist.mjs +2714 -0
  34. package/package.json +53 -6
  35. package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
  36. package/plugins/runlist-codex/bin/runlist-hook +86 -0
  37. package/plugins/runlist-codex/hooks/hooks.json +16 -0
  38. package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
  39. package/runlist.config.example.mjs +370 -0
  40. package/scripts/postinstall.mjs +56 -0
  41. package/src/agent-context.mjs +201 -0
  42. package/src/ai.mjs +30 -0
  43. package/src/app-create.mjs +169 -0
  44. package/src/app-filing.mjs +139 -0
  45. package/src/app-git-commits.mjs +48 -0
  46. package/src/app-git.mjs +180 -0
  47. package/src/app-library.mjs +152 -0
  48. package/src/app-lifecycle-worker.mjs +12 -0
  49. package/src/app-lifecycle.mjs +79 -0
  50. package/src/app-records.mjs +134 -0
  51. package/src/app-search.mjs +80 -0
  52. package/src/app-semantic-search.mjs +105 -0
  53. package/src/app-service.mjs +208 -0
  54. package/src/app-yardstick.mjs +269 -0
  55. package/src/app.mjs +102 -0
  56. package/src/atomic-mutation.mjs +1736 -0
  57. package/src/baton.mjs +435 -0
  58. package/src/body-link.mjs +48 -0
  59. package/src/bulk-tag.mjs +174 -0
  60. package/src/check-collapse.mjs +75 -0
  61. package/src/claude-commands.mjs +75 -0
  62. package/src/cli-editor.mjs +8 -0
  63. package/src/code-refs.mjs +545 -0
  64. package/src/codex-integration.mjs +81 -0
  65. package/src/color.mjs +14 -0
  66. package/src/commands.mjs +374 -0
  67. package/src/completions.mjs +93 -0
  68. package/src/config-edit.mjs +606 -0
  69. package/src/config.mjs +705 -0
  70. package/src/decision-action.mjs +58 -0
  71. package/src/decision.mjs +220 -0
  72. package/src/decisions.mjs +787 -0
  73. package/src/deps.mjs +317 -0
  74. package/src/desktop-install.mjs +158 -0
  75. package/src/desktop-runtime.mjs +4 -0
  76. package/src/desktop.mjs +25 -0
  77. package/src/diff.mjs +101 -0
  78. package/src/doctor.mjs +591 -0
  79. package/src/durable-rename.mjs +55 -0
  80. package/src/errors-read.mjs +44 -0
  81. package/src/export.mjs +496 -0
  82. package/src/extractors.mjs +101 -0
  83. package/src/filing.mjs +255 -0
  84. package/src/fix-membership.mjs +134 -0
  85. package/src/fix-refs.mjs +174 -0
  86. package/src/flags.mjs +318 -0
  87. package/src/frontmatter-fix.mjs +195 -0
  88. package/src/frontmatter.mjs +252 -0
  89. package/src/git-commit-filters.mjs +32 -0
  90. package/src/git-commit-protocol.mjs +198 -0
  91. package/src/git-commit-signing.mjs +75 -0
  92. package/src/git-commit-store.mjs +26 -0
  93. package/src/git-commit-support.mjs +16 -0
  94. package/src/git-commit-worker.mjs +32 -0
  95. package/src/git.mjs +909 -0
  96. package/src/glossary-check.mjs +35 -0
  97. package/src/glossary.mjs +243 -0
  98. package/src/graph.mjs +298 -0
  99. package/src/guard.mjs +488 -0
  100. package/src/health.mjs +212 -0
  101. package/src/hints.mjs +129 -0
  102. package/src/host-integration.mjs +339 -0
  103. package/src/hub-membership.mjs +284 -0
  104. package/src/hub.mjs +292 -0
  105. package/src/hud.mjs +436 -0
  106. package/src/index-file.mjs +163 -0
  107. package/src/index.mjs +478 -0
  108. package/src/init.mjs +512 -0
  109. package/src/install.mjs +222 -0
  110. package/src/journal-read.mjs +116 -0
  111. package/src/journal.mjs +472 -0
  112. package/src/lifecycle.mjs +1444 -0
  113. package/src/lint.mjs +386 -0
  114. package/src/managed-path.mjs +192 -0
  115. package/src/markdown-code-spans.mjs +58 -0
  116. package/src/migrate-prompts.mjs +171 -0
  117. package/src/migrate-template.mjs +170 -0
  118. package/src/migrate.mjs +90 -0
  119. package/src/misuse-read.mjs +61 -0
  120. package/src/model-request.mjs +33 -0
  121. package/src/model.mjs +523 -0
  122. package/src/modules.mjs +355 -0
  123. package/src/naming.mjs +55 -0
  124. package/src/native-action.mjs +59 -0
  125. package/src/native-record.mjs +244 -0
  126. package/src/new.mjs +1321 -0
  127. package/src/output-identity.mjs +111 -0
  128. package/src/parent-plan.mjs +58 -0
  129. package/src/parse-cache.mjs +99 -0
  130. package/src/path-containment.mjs +15 -0
  131. package/src/pickup-card.mjs +293 -0
  132. package/src/pickup.mjs +671 -0
  133. package/src/prompt.mjs +25 -0
  134. package/src/prompts.mjs +585 -0
  135. package/src/query.mjs +977 -0
  136. package/src/record-action.mjs +11 -0
  137. package/src/record-create.mjs +82 -0
  138. package/src/reference-planner.mjs +388 -0
  139. package/src/reference-values.mjs +11 -0
  140. package/src/rename.mjs +107 -0
  141. package/src/render.mjs +678 -0
  142. package/src/roadmap.mjs +184 -0
  143. package/src/runlist.mjs +944 -0
  144. package/src/section.mjs +225 -0
  145. package/src/semantic-search-client.mjs +58 -0
  146. package/src/ship.mjs +166 -0
  147. package/src/show.mjs +101 -0
  148. package/src/skill-drift.mjs +98 -0
  149. package/src/source-editor.mjs +491 -0
  150. package/src/stages.mjs +73 -0
  151. package/src/state-migration.mjs +123 -0
  152. package/src/stats.mjs +200 -0
  153. package/src/status-metadata.mjs +87 -0
  154. package/src/statuses.mjs +721 -0
  155. package/src/summary.mjs +74 -0
  156. package/src/surfaces.mjs +28 -0
  157. package/src/sync-status.mjs +316 -0
  158. package/src/template-store.mjs +62 -0
  159. package/src/update.mjs +338 -0
  160. package/src/use.mjs +58 -0
  161. package/src/util.mjs +337 -0
  162. package/src/validate.mjs +794 -0
  163. package/src/watch.mjs +56 -0
  164. package/src/xref.mjs +406 -0
  165. package/src/yardstick-command.mjs +178 -0
  166. package/src/yardstick.mjs +112 -0
@@ -0,0 +1,58 @@
1
+ import { parseDecisionItems, decisionSettings, dispositionOf } from './decisions.mjs';
2
+ import { SourceEditError } from './source-editor.mjs';
3
+
4
+ // A source preparation shared by the CLI and the app. The source editor owns
5
+ // authority, claims, revision checking, operation receipts, and publication.
6
+ export function prepareDecisionAction(source, request, settings = {}) {
7
+ const {id,line,disposition,note,choice,actor,at}=request;
8
+ const fail=message=>{throw new SourceEditError('decision-invalid-action',message);};
9
+ if(!['open','held','ruled','closed'].includes(disposition))fail('Choose open, held, ruled or closed.');
10
+ if(!actor?.id||!['human','agent'].includes(actor.kind))fail('A verified actor is required.');
11
+ if(disposition==='ruled'&&actor.kind!=='human')fail('A ruling must be recorded by a human; an agent can keep a recommendation in the source.');
12
+ if(typeof note!=='string'||!note.trim()||note.length>4000)fail('Give a reason, up to 4,000 characters.');
13
+ if(disposition==='ruled'&&(typeof choice!=='string'||!choice.trim()))fail('Write the selected answer before recording a ruling.');
14
+ if(typeof at!=='string'||Number.isNaN(Date.parse(at)))fail('A recording timestamp is required.');
15
+ const settingsResolved=decisionSettings(settings??{}),items=parseDecisionItems(source,settingsResolved);
16
+ const item=items.find(item=>item.id===id&&item.line===line);
17
+ if(!item)throw new SourceEditError('decision-revision-conflict','This decision moved or changed. Reload its record before recording an outcome.');
18
+ const one=value=>String(value??'').replace(/[\r\n]+/g,' ').replace(/[<>|]/g,c=>({'<':'&lt;','>':'&gt;','|':'&#124;'}[c]));
19
+ // Inline provenance follows the newest explicit disposition. Earlier source
20
+ // and earlier outcomes remain verbatim, behind it, in the same record.
21
+ const outcome=`Disposition: ${disposition.toUpperCase()} ${at}. Recorded by ${one(actor.label??actor.id)} (${one(actor.kind)}). ${disposition==='ruled'?`Selected answer: ${one(choice)}. `:''}Reason: ${one(note)}.`;
22
+ const lines=source.match(/[^\r\n]*(?:\r\n|\n|\r|$)/g).filter(Boolean),index=line-1;
23
+ const original=lines[index],eol=original.match(/\r\n|\n|\r$/)?.[0]??'\n';
24
+ if(item.kind==='heading'){if(!/[\r\n]$/.test(original))lines[index]+=eol;lines.splice(index+1,0,`${outcome}${eol}`);}
25
+ else {
26
+ // Keep a one-line record one line, including register/table rows.
27
+ const escaped=id.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
28
+ const match=item.kind==='table'?original.match(/^(\|\s*\**[^|]+\**\s*\|\s*)/):item.kind==='register'?original.match(/^(\S+\s{1,3})/):original.match(new RegExp(`^((?:[-*]\\s+(?:\\[[ xX]\\]\\s+)?)?\\**${escaped}\\**[\\s,.:—-]+)`));
29
+ if(!match)fail('This record cannot be updated unambiguously. Open its source to edit it.');
30
+ lines[index]=original.slice(0,match[0].length)+'**Recorded outcome:** '+outcome+' **Original record:** '+original.slice(match[0].length);
31
+ }
32
+ const next=lines.join(''),after=parseDecisionItems(next,settingsResolved).find(i=>i.id===id&&i.line===line);
33
+ if(!after||dispositionOf(after,settingsResolved)!==disposition)fail('The updated record no longer parses. Its source was left unchanged.');
34
+ return next;
35
+ }
36
+
37
+ export async function runDecisionAction(argv,config,{dryRun=false,env=process.env}={}) {
38
+ const [{createSourceEditor},{resolveDocArg},{hostSessionSource},{randomUUID,createHash},{default:os},{default:path}]=await Promise.all([import('./source-editor.mjs'),import('./index.mjs'),import('./util.mjs'),import('node:crypto'),import('node:os'),import('node:path')]);
39
+ const positional=[],options={};
40
+ for(let i=0;i<argv.length;i++){if(argv[i].startsWith('--')){if(argv[i]==='--json')options.json=true;else options[argv[i].slice(2)]=argv[++i];}else positional.push(argv[i]);}
41
+ const [input,id]=positional;
42
+ if(!input||!id||!options.disposition||!options.note)throw new SourceEditError('decision-invalid-action','Use runlist decision <file> <id> --disposition open|held|ruled|closed --note <reason> [--choice <answer>].');
43
+ const file=resolveDocArg(input,config),session=hostSessionSource(env),agent=session?.scope==='session';
44
+ if(session?.scope==='process')throw new SourceEditError('decision-invalid-actor','An agent needs a session-scoped identity before recording an outcome.');
45
+ const actor=agent?{kind:'agent',id:session.id,session_id:session.id,label:session.host??session.id}:{kind:'human',id:`human:local:${createHash('sha256').update(`${os.userInfo().username}:${os.userInfo().uid}`).digest('hex').slice(0,24)}`,label:os.userInfo().username};
46
+ const editor=createSourceEditor({config,legacyTypes:[...(config.validTypes??['plan','doc'])].filter(t=>t!=='prompt'),authenticate:()=>actor,authorize:({path:target})=>({allowed:target===file&&!path.relative(config.repoRoot,target).split(path.sep).some(p=>p==='prompts'||p==='.git'||config.excludeDirs?.has(p)),...(agent?{grant_id:`cli:decision:${actor.session_id}:${path.relative(config.repoRoot,file)}`}:{})})});
47
+ const current=editor.read(null,{path:file});
48
+ if(['flag','decision'].includes(current.type))throw new SourceEditError('record-read-only','Native flag and decision records require their domain writer; this command records outcomes in existing plan/register sections.');
49
+ const items=parseDecisionItems(current.source,decisionSettings(config.raw?.decisions??{}));
50
+ const matches=items.filter(item=>item.id===id);
51
+ if(matches.length!==1)throw new SourceEditError('decision-invalid-action','The source must contain exactly one decision with this ID.');
52
+ if(!current.editable)throw new SourceEditError('record-read-only','This source does not support managed decision actions.');
53
+ if(options['expected-revision']&&options['expected-revision']!==current.revision)throw new SourceEditError('decision-revision-conflict','The source changed; read the decision again before recording an outcome.');
54
+ const source=prepareDecisionAction(current.source,{id,line:matches[0].line,disposition:options.disposition,note:options.note,choice:options.choice,actor,at:new Date().toISOString()},config.raw?.decisions);
55
+ if(dryRun){process.stdout.write(source);return {dryRun:true,source};}
56
+ const outcome=editor.save(null,{path:file,source,expectedRevision:current.revision,operationId:randomUUID()});
57
+ process.stdout.write(options.json?JSON.stringify(outcome)+'\n':`${id}: ${options.disposition} recorded in ${path.relative(config.repoRoot,file)}.\n`);return outcome;
58
+ }
@@ -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
+ }