runlist 0.0.0 → 0.94.2

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 (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1112 -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 +296 -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/public-inspection.mjs +19 -0
  136. package/src/query.mjs +977 -0
  137. package/src/record-action.mjs +11 -0
  138. package/src/record-create.mjs +82 -0
  139. package/src/reference-planner.mjs +388 -0
  140. package/src/reference-values.mjs +11 -0
  141. package/src/rename.mjs +107 -0
  142. package/src/render.mjs +678 -0
  143. package/src/roadmap.mjs +184 -0
  144. package/src/runlist.mjs +944 -0
  145. package/src/section.mjs +225 -0
  146. package/src/semantic-search-client.mjs +58 -0
  147. package/src/ship.mjs +166 -0
  148. package/src/show.mjs +101 -0
  149. package/src/skill-drift.mjs +98 -0
  150. package/src/source-editor.mjs +491 -0
  151. package/src/stages.mjs +73 -0
  152. package/src/state-migration.mjs +123 -0
  153. package/src/stats.mjs +200 -0
  154. package/src/status-metadata.mjs +87 -0
  155. package/src/statuses.mjs +721 -0
  156. package/src/summary.mjs +74 -0
  157. package/src/surfaces.mjs +28 -0
  158. package/src/sync-status.mjs +316 -0
  159. package/src/template-store.mjs +62 -0
  160. package/src/update.mjs +338 -0
  161. package/src/use.mjs +58 -0
  162. package/src/util.mjs +337 -0
  163. package/src/validate.mjs +795 -0
  164. package/src/watch.mjs +56 -0
  165. package/src/xref.mjs +406 -0
  166. package/src/yardstick-command.mjs +178 -0
  167. package/src/yardstick.mjs +112 -0
package/src/new.mjs ADDED
@@ -0,0 +1,1321 @@
1
+ import { applyTemplateOverride, readTemplateStore, templateFile } from './template-store.mjs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { existsSync, readFileSync, mkdirSync, fstatSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { runNewDecision } from './decision.mjs';
7
+ import { toRepoPath, die, warn, nowIso, emitFilesFooter } from './util.mjs';
8
+ import { green, dim, bold } from './color.mjs';
9
+ import { isInteractive, promptText } from './prompt.mjs';
10
+ import { regenIndex } from './lifecycle.mjs';
11
+ import { extractFrontmatter, parseSimpleFrontmatter, normalizeEol } from './frontmatter.mjs';
12
+ import { authorizeManagedDestination } from './managed-path.mjs';
13
+ import { createFileExclusive, mutateFileSet, MutationConflictError } from './atomic-mutation.mjs';
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
17
+
18
+ // Surface-taxonomy hint emitted above the `surfaces:` line in scaffolded docs.
19
+ // Discoverable-by-default: the author sees valid values without leaving the file
20
+ // and without grepping sibling docs (issue #12 trap 1). When the project has no
21
+ // configured taxonomy, fall back to a bare `surfaces:` line.
22
+ function surfacesScaffold(ctx) {
23
+ const valid = ctx?.validSurfaces;
24
+ if (Array.isArray(valid) && valid.length > 0) {
25
+ return `# surfaces — valid: ${valid.join(', ')}\nsurfaces:`;
26
+ }
27
+ return 'surfaces:';
28
+ }
29
+
30
+ // Module-taxonomy parallel of surfacesScaffold. When `taxonomy.modules` is set,
31
+ // scaffold lists valid values + a `- none` placeholder so the validator's
32
+ // modules-required check passes by default and the author can swap to a real
33
+ // module from the listed taxonomy. When unset, the project doesn't enumerate
34
+ // modules — drop the placeholder entirely so new plans aren't sprinkled with a
35
+ // meaningless sentinel.
36
+ function modulesScaffold(ctx, kind /* 'doc' | 'plan' */) {
37
+ const valid = ctx?.validModules;
38
+ if (Array.isArray(valid) && valid.length > 0) {
39
+ const comment = kind === 'plan'
40
+ ? `# modules — valid: ${valid.join(', ')} (or \`none\` for tooling/infra plans)`
41
+ : `# modules — valid: ${valid.join(', ')} (or \`none\` for platform/infra docs)`;
42
+ return `${comment}\nmodules:\n - none`;
43
+ }
44
+ return 'modules:';
45
+ }
46
+
47
+ // Full-body shortcut shared by the default plan scaffold and every plan body
48
+ // variant (--lite / --audit / --runlist / --coordination hubs). When the
49
+ // author's body input already carries its own `## Section` headings, it's a
50
+ // complete body they wrote start-to-finish — return it verbatim (honoring a
51
+ // leading `# Title`, otherwise prepending the scaffold title) rather than
52
+ // nesting it inside the builder's single slot (`## Problem` / `## Scope`) and
53
+ // appending skeleton sections after it. Without this, a full coordination/lite/
54
+ // audit/runlist body got duplicated: the whole document landed under `## Scope`
55
+ // and the skeleton's own `## Ranked queue` / `## Version History` were appended
56
+ // below (leaving stray `## Closeout` etc.). Returns the verbatim body string,
57
+ // or null when the input is section-content (no `## ` heading) and should flow
58
+ // into the builder's slot as before.
59
+ function fullBodyShortcut(title, bodyInput) {
60
+ const b = (bodyInput ?? '').trim();
61
+ if (!b || !/^##\s+\S/m.test(b)) return null;
62
+ const hasOwnTitle = /^#\s+\S/.test(b);
63
+ return hasOwnTitle ? `\n${b}\n` : `\n# ${title}\n\n${b}\n`;
64
+ }
65
+
66
+ function lexicallyInside(parent, child) {
67
+ return child === parent || child.startsWith(parent + path.sep);
68
+ }
69
+
70
+ // The default root for a type that doesn't name one of its own. This used to be
71
+ // "whichever root is listed first", which drops a `doc` into `docs/plans` for
72
+ // any project that lists its plans root first — the plan type's own root, for a
73
+ // type that isn't a plan. When one configured root contains another it is
74
+ // structurally the catch-all (`docs` holding `docs/plans` and `docs/adr`), so
75
+ // prefer that; the deepest one, so a nested chain picks the most specific
76
+ // container rather than the outermost. Single-root projects and flat sibling
77
+ // roots have no catch-all to find and keep first-listed order.
78
+ export function catchAllRoot(config) {
79
+ const roots = config.docsRoots ?? [config.docsRoot];
80
+ if (roots.length < 2) return config.docsRoot;
81
+ const containers = roots.filter(root => roots.some(other => other !== root && lexicallyInside(root, other)));
82
+ if (!containers.length) return config.docsRoot;
83
+ const depth = root => root.split(path.sep).length;
84
+ return containers.reduce((deepest, root) => depth(root) > depth(deepest) ? root : deepest);
85
+ }
86
+
87
+ const BUILTIN_TEMPLATES = {
88
+ doc: {
89
+ description: 'Reference doc, design note, module overview — build-up shape lite',
90
+ defaultStatus: 'active',
91
+ // Body input optional. When passed (inline / --body / @file / stdin),
92
+ // it lands in the Overview section. Without it, Overview is left blank
93
+ // and the user fills it in.
94
+ acceptsBody: true,
95
+ frontmatter: (s, d, ctx) => [
96
+ 'type: doc',
97
+ `status: ${s}`,
98
+ `created: ${d}`,
99
+ `updated: ${d}`,
100
+ modulesScaffold(ctx, 'doc'),
101
+ surfacesScaffold(ctx),
102
+ 'domain:',
103
+ 'audience: internal',
104
+ 'related_plans:',
105
+ 'related_docs:',
106
+ ].join('\n'),
107
+ body: (t, ctx) => {
108
+ // Same full-body shortcut the plan templates use, and for the same
109
+ // reason: a body that already authors `## Section` headings is a complete
110
+ // document, not section-content for one slot. Nesting it under `## Overview`
111
+ // produced a second `# Title` under the scaffold's, a placeholder summary
112
+ // line above the author's own opening, and the skeleton's trailing
113
+ // sections after a document that had already ended.
114
+ const authored = fullBodyShortcut(t, ctx?.bodyInput);
115
+ if (authored !== null) return authored;
116
+ return `
117
+ # ${t}
118
+
119
+ > One-line summary of what this doc covers.
120
+
121
+ ## Overview
122
+
123
+ ${ctx?.bodyInput?.trim() ?? ''}
124
+
125
+ ## Version History
126
+
127
+ - **${ctx?.today ?? ''}** Created.
128
+
129
+ ## Related Documentation
130
+
131
+ `;
132
+ },
133
+ },
134
+ plan: {
135
+ description: 'Execution plan — build-up shape (Problem → Phases → Closeout) with phase status markers and Version History',
136
+ dir: 'plans',
137
+ targetRoot: 'plans',
138
+ // A scaffolded plan is scoped, not being worked; `runlist use` starts it.
139
+ // Defaulting to `active` forced a second, guarded status write for every
140
+ // plan that was only being written down.
141
+ defaultStatus: 'planned',
142
+ // Body input lands in the Problem section. Plans don't have an Overview;
143
+ // Problem is the established opening section in the build-up shape.
144
+ acceptsBody: true,
145
+ frontmatter: (s, d, ctx) => [
146
+ 'type: plan',
147
+ `status: ${s}`,
148
+ `created: ${d}`,
149
+ `updated: ${d}`,
150
+ surfacesScaffold(ctx),
151
+ modulesScaffold(ctx, 'plan'),
152
+ 'domain:',
153
+ 'audience: internal',
154
+ 'parent_plan:',
155
+ 'related_plans:',
156
+ 'related_docs:',
157
+ 'current_state:',
158
+ 'next_step:',
159
+ ].join('\n'),
160
+ body: (t, ctx) => {
161
+ // Full-body shortcut (shared with the plan variants): a body that already
162
+ // authors `## Section` headings is a complete plan the user/agent wrote
163
+ // start-to-finish — return it verbatim and drop the scaffold ladder.
164
+ const authored = fullBodyShortcut(t, ctx?.bodyInput);
165
+ if (authored !== null) return authored;
166
+ const bodyInput = ctx?.bodyInput?.trim() ?? '';
167
+ return `
168
+ # ${t}
169
+
170
+ > One-paragraph problem statement: what this plan is for, why now.
171
+
172
+ ## Problem
173
+
174
+ ${bodyInput}
175
+
176
+ ## Goals
177
+
178
+
179
+
180
+ ## Non-Goals
181
+
182
+
183
+
184
+ ## What Exists Today
185
+
186
+
187
+
188
+ ## Constraints
189
+
190
+
191
+
192
+ ## Decisions
193
+
194
+
195
+
196
+ ## Open Questions
197
+
198
+
199
+
200
+ ## Phases
201
+
202
+ <!--
203
+ Status markers (put in heading text):
204
+ ⬜ not started
205
+ 🟡 in progress (pickup targets this)
206
+ ✅ shipped (history; pickup skips)
207
+ ⏭ skipped (with reason in body)
208
+ 🚧 blocked (link to blocker)
209
+ -->
210
+
211
+ ### Phase 1 — <title> ⬜
212
+
213
+
214
+
215
+ ## Deferred
216
+
217
+
218
+
219
+ ## Version History
220
+
221
+ - **${ctx?.today ?? ''}** Created.
222
+
223
+ ## Closeout
224
+
225
+ <!-- Filled on archive: what shipped, key commits, deferrals dispositioned. -->
226
+ `;
227
+ },
228
+ },
229
+ prompt: {
230
+ description: 'Saved prompt to seed a future Claude session — body is required',
231
+ dir: 'prompts',
232
+ targetRoot: 'prompts',
233
+ defaultStatus: 'pending',
234
+ requiresBody: true,
235
+ acceptsBody: true,
236
+ frontmatter: (s, d, ctx) => [
237
+ 'type: prompt',
238
+ `status: ${s}`,
239
+ `created: ${d}`,
240
+ `updated: ${d}`,
241
+ `runlist_version: ${pkg.version}`,
242
+ `context: ${ctx?.title ? `"${ctx.title.replace(/"/g, '\\"')}"` : ''}`,
243
+ 'related_plans:',
244
+ ].join('\n'),
245
+ body: (t, ctx) => `\n${ctx?.bodyInput ?? '<!-- prompt body -->'}\n`,
246
+ },
247
+ };
248
+
249
+ // Body inputs from agents often arrive as a full document (frontmatter + body)
250
+ // written to a tempfile and passed via `@path` or stdin. Without this split,
251
+ // `runlist new` would prepend its scaffold frontmatter and treat the input's
252
+ // frontmatter as literal body content — resulting in two `---` blocks and a
253
+ // duplicated title. We instead parse the leading block (if any), merge its
254
+ // keys onto the scaffold, and use only what follows as body. See issue #12
255
+ // trap 4. Returns `{ frontmatter: object|null, body: string }`.
256
+ function splitBodyFrontmatter(rawBody) {
257
+ if (!rawBody || typeof rawBody !== 'string') return { frontmatter: null, body: rawBody };
258
+ if (!normalizeEol(rawBody).startsWith('---\n')) return { frontmatter: null, body: rawBody };
259
+ const { frontmatter: fmText, body } = extractFrontmatter(rawBody);
260
+ if (!fmText) return { frontmatter: null, body: rawBody };
261
+ const parsed = parseSimpleFrontmatter(fmText);
262
+ return { frontmatter: parsed, body };
263
+ }
264
+
265
+ // Serialize a single frontmatter key/value pair to a YAML block. Mirrors the
266
+ // scaffold's shape so merged output reads naturally next to scaffold defaults.
267
+ function serializeScalar(value) {
268
+ if (typeof value !== 'string') return String(value);
269
+ return !value || /^[\s>|'"\[\]{},&*!%#@`]/.test(value) || /[:#\n\r\t\\]/.test(value) || /^(?:true|false|null)$/i.test(value)
270
+ ? JSON.stringify(value) : value;
271
+ }
272
+
273
+ function serializeFmEntry(key, value) {
274
+ if (value === null || value === undefined || value === '') return `${key}:`;
275
+ if (Array.isArray(value)) {
276
+ if (value.length === 0) return `${key}:`;
277
+ return `${key}:\n${value.map(v => ` - ${serializeScalar(v)}`).join('\n')}`;
278
+ }
279
+ if (typeof value === 'string' && value.includes('\n')) {
280
+ const indented = value.split('\n').map(l => ` ${l}`).join('\n');
281
+ return `${key}: |\n${indented}`;
282
+ }
283
+ return `${key}: ${serializeScalar(value)}`;
284
+ }
285
+
286
+ // Replace each key in `overrides` within the scaffold-generated frontmatter
287
+ // string. Keys not present in the scaffold are appended. `type:` is never
288
+ // overwritten — the CLI's type arg wins (warning emitted on conflict).
289
+ function mergeBodyFrontmatter(scaffoldFm, overrides, cliType) {
290
+ if (!overrides || Object.keys(overrides).length === 0) return scaffoldFm;
291
+ let fm = scaffoldFm;
292
+ const appended = [];
293
+ for (const [key, value] of Object.entries(overrides)) {
294
+ if (key === 'type') {
295
+ if (cliType && value && value !== cliType) {
296
+ warn(`Body frontmatter declares \`type: ${value}\` but CLI arg is \`${cliType}\`; using \`${cliType}\`.`);
297
+ }
298
+ continue;
299
+ }
300
+ if (key === 'created' || key === 'updated') continue; // scaffold owns timestamps
301
+ const serialized = serializeFmEntry(key, value);
302
+ // Match `key:` line + any indented continuation (block-array items or
303
+ // block-scalar bodies). Indented lines start with whitespace; scaffold keys
304
+ // never do, so this consumes only the right slice.
305
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
306
+ const re = new RegExp(`^${escaped}:.*(\\n[ \\t]+.*)*`, 'm');
307
+ if (re.test(fm)) {
308
+ fm = fm.replace(re, serialized);
309
+ } else {
310
+ appended.push(serialized);
311
+ }
312
+ }
313
+ if (appended.length > 0) fm = fm + '\n' + appended.join('\n');
314
+ return fm;
315
+ }
316
+
317
+ // Metavariables that ship in help text, `runlist baton`'s signature, and the
318
+ // wrap-up nudge. Agents copy them verbatim, and the bare `Body file not found:
319
+ // draft` that resulted read like a missing file rather than an unsubstituted
320
+ // placeholder — so the retry was usually another guess at the path. Only
321
+ // consulted after existsSync fails, so a real file by any of these names wins.
322
+ const BODY_PLACEHOLDER_NAMES = new Set([
323
+ 'draft', 'draft.md', '/tmp/draft.md', 'tmp/draft.md',
324
+ '/tmp/baton.md', '/tmp/my-draft.md',
325
+ 'path', 'file', 'body', 'FILE', 'PATH', 'BODY',
326
+ ]);
327
+
328
+ function isBodyPlaceholder(file) {
329
+ if (/^<.+>$/.test(file)) return true; // <draft-file>, <path>, …
330
+ return BODY_PLACEHOLDER_NAMES.has(file);
331
+ }
332
+
333
+ // `@-` is the natural composition of the two spellings runlist documents (`@path`
334
+ // and `-`), and agents write it — three times in the platform transcripts, each
335
+ // with a valid heredoc already on stdin that was then thrown away for a file
336
+ // literally named `-`. Nothing else can sensibly be meant by it, so it means
337
+ // stdin, unconditionally. That follows the universal CLI convention rather than
338
+ // the placeholder rule below (where a real file wins): under this convention a
339
+ // file actually named `-` is spelled `./-`, which still takes the file branch.
340
+ export function readBodyInput(source) {
341
+ if (source === '-' || source === '@-') {
342
+ try { return readFileSync(0, 'utf8'); } catch (err) { die(`Could not read body from stdin: ${err.message}`); }
343
+ }
344
+ if (typeof source === 'string' && source.startsWith('@')) {
345
+ const file = source.slice(1);
346
+ if (!existsSync(file)) {
347
+ if (isBodyPlaceholder(file)) {
348
+ die(`\`@${file}\` is a placeholder, not a real path — write the body to a file first, then pass it:\n` +
349
+ ` @/tmp/my-draft.md read the body from that file\n` +
350
+ ` - read the body from stdin (\`cat notes.md | runlist …\`)\n` +
351
+ ` --message "..." short one-liners only`);
352
+ }
353
+ die(`Body file not found: ${file}`);
354
+ }
355
+ return readFileSync(file, 'utf8');
356
+ }
357
+ return source;
358
+ }
359
+
360
+ // An agent's shell can hand a command a pipe that is open and never written or
361
+ // closed (Claude Code does for chained and backgrounded commands), so a plain
362
+ // blocking read of an implicit pipe hangs until the tool times out: a bare
363
+ // `runlist baton` sat there for two minutes in the platform transcripts. A
364
+ // child reads the pipe instead and gives up when the first byte doesn't arrive
365
+ // within the window; once data starts it waits for EOF, so a slow producer that
366
+ // has begun writing is never cut off.
367
+ export const PIPED_STDIN_IDLE_MS = 1500;
368
+ const STDIN_SILENT_EXIT = 3;
369
+ const PIPE_READER = `let d='',got=false;` +
370
+ `const t=setTimeout(()=>{if(!got)process.exit(${STDIN_SILENT_EXIT})},Number(process.argv[1]));` +
371
+ `process.stdin.setEncoding('utf8');` +
372
+ `process.stdin.on('data',c=>{got=true;d+=c});` +
373
+ `process.stdin.on('end',()=>{clearTimeout(t);process.stdout.write(d)});`;
374
+
375
+ function readPipeUnlessSilent() {
376
+ const r = spawnSync(process.execPath, ['-e', PIPE_READER, String(PIPED_STDIN_IDLE_MS)], {
377
+ stdio: [0, 'pipe', 'ignore'],
378
+ encoding: 'utf8',
379
+ maxBuffer: 64 * 1024 * 1024,
380
+ windowsHide: true,
381
+ });
382
+ if (r.status !== 0) return null;
383
+ return r.stdout;
384
+ }
385
+
386
+ export function readPipedBodyInput() {
387
+ try {
388
+ const stat = fstatSync(0);
389
+ if (stat.isFile()) {
390
+ const redirected = readFileSync(0, 'utf8');
391
+ return redirected.length > 0 ? redirected : null;
392
+ }
393
+ const isWindowsPipe = process.platform === 'win32' && !process.stdin.isTTY;
394
+ if (stat.isFIFO() || stat.isSocket() || isWindowsPipe) {
395
+ const piped = readPipeUnlessSilent();
396
+ return piped ? piped : null;
397
+ }
398
+ } catch { /* stdin not introspectable */ }
399
+ return null;
400
+ }
401
+
402
+ // Slug/title helpers shared by name resolution and runlist child generation.
403
+ export function slugify(s) {
404
+ return s.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
405
+ }
406
+ export function titleize(s) {
407
+ return s.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
408
+ }
409
+
410
+ // Where saved prompts are written: the prompt template's targetRoot when a
411
+ // root matches it, else the primary root plus the template's `dir`.
412
+ export function promptDirectory(config, template = resolveTemplate('prompt', config)) {
413
+ let targetRoot = config.docsRoot;
414
+ let routed = false;
415
+ if (template.targetRoot) {
416
+ const match = (config.docsRoots || [config.docsRoot]).find(root => root.endsWith(template.targetRoot) || path.basename(root) === template.targetRoot);
417
+ if (match) { targetRoot = match; routed = true; }
418
+ }
419
+ return template.dir && !routed ? path.join(targetRoot, template.dir) : targetRoot;
420
+ }
421
+
422
+ export function preparePromptDocument(name, bodyInput, config, { plan = null, dryRun = false } = {}) {
423
+ const template = resolveTemplate('prompt', config);
424
+ const typeStatuses = config.typeStatuses?.get('prompt');
425
+ const status = template.defaultStatus && (!typeStatuses || typeStatuses.has(template.defaultStatus))
426
+ ? template.defaultStatus
427
+ : ([...(typeStatuses ?? [])][0] ?? 'pending');
428
+ if (!bodyInput?.trim()) die('`prompt` template requires a body.');
429
+ const slug = slugify(path.basename(name, '.md'));
430
+ const title = titleize(path.basename(name, '.md'));
431
+ const filePath = path.join(promptDirectory(config, template), `${slug}.md`);
432
+ authorizeManagedDestination(filePath, config, { kind: 'Baton prompt destination' });
433
+ if (dryRun) return { slug, filePath, repoPath: toRepoPath(filePath, config.repoRoot), content: null };
434
+
435
+ const today = nowIso();
436
+ const split = splitBodyFrontmatter(bodyInput);
437
+ const body = split.frontmatter ? split.body : bodyInput;
438
+ const ctx = { status, title, today, bodyInput: body };
439
+ let content;
440
+ if (typeof template === 'function') {
441
+ content = template(name, ctx);
442
+ if (plan) {
443
+ const end = content.indexOf('\n---\n', 4);
444
+ if (!content.startsWith('---\n') || end === -1) throw new Error('Custom prompt template must return frontmatter for baton plan binding.');
445
+ const fm = mergeBodyFrontmatter(content.slice(4, end), { plan }, 'prompt');
446
+ content = `---\n${fm}${content.slice(end)}`;
447
+ }
448
+ } else {
449
+ let fm = template.frontmatter(status, today, ctx);
450
+ if (split.frontmatter) fm = mergeBodyFrontmatter(fm, split.frontmatter, 'prompt');
451
+ if (plan) fm = mergeBodyFrontmatter(fm, { plan }, 'prompt');
452
+ content = `---\n${fm}\n---\n${template.body(title, ctx)}`;
453
+ }
454
+ return { slug, filePath, repoPath: toRepoPath(filePath, config.repoRoot), content };
455
+ }
456
+
457
+ // Resolve one `--runlist` token to a scaffolded child plan: a bare slug becomes
458
+ // `<hub>-NN-<slug>.md` (the documented runlist naming convention). `pos` is the
459
+ // 1-based position used for the zero-padded NN prefix. Tokens must be bare slugs
460
+ // — a path is rejected (wiring an ordered hub to a plan that already lives
461
+ // elsewhere needs a hub-relative ref, so it's a hand-edit, not a scaffold).
462
+ function planChildFromToken(hubSlug, token, pos) {
463
+ const t = token.trim();
464
+ if (t.includes('/') || t.includes(path.sep)) {
465
+ die(`Runlist child "${token}" must be a bare slug, not a path. To put an existing plan in the runlist, add it to the hub's runlist: by hand.`);
466
+ }
467
+ const childSlug = slugify(t.replace(/\.md$/, ''));
468
+ if (!childSlug) die(`Runlist child token resolves to an empty slug: "${token}"`);
469
+ const nn = String(pos).padStart(2, '0');
470
+ return { file: `${hubSlug}-${nn}-${childSlug}.md`, title: titleize(t.replace(/\.md$/, '')) };
471
+ }
472
+
473
+ // Body for a sprint runlist hub: the children ARE the phases, so the heavy
474
+ // generic plan scaffold (Goals/Phases/Deferred/…) is replaced by an ordered
475
+ // `## Order of operations` list that mirrors the `runlist:` frontmatter.
476
+ function runlistHubBody(title, hubSlug, children, bodyInput, today) {
477
+ const steps = children
478
+ .map((c, i) => `${i + 1}. [${c.title}](${c.file}) ⬜`)
479
+ .join('\n');
480
+ const n = children.length;
481
+ return `
482
+ # ${title}
483
+
484
+ > One-paragraph problem statement: what this ordered hub sprints toward, why now.
485
+ > The plans below are ordered on this hub; no separate runlist document is needed.
486
+
487
+ ## Problem
488
+
489
+ ${bodyInput?.trim() ?? ''}
490
+
491
+ ## Order of Operations
492
+
493
+ ${steps}
494
+
495
+ Pick up the next child with \`runlist runlist next ${hubSlug}\` — it targets the
496
+ first startable child, skipping archived and parked plans.
497
+ \`runlist runlist ${hubSlug}\` shows the sequence + status.
498
+
499
+ ## Version History
500
+
501
+ - **${today}** Created (ordered hub, ${n} ${n === 1 ? 'child' : 'children'}).
502
+ `;
503
+ }
504
+
505
+ // Body for a coordination hub: prose-first domain map with a ranked-queue table.
506
+ // Mirrors the `execution_mode: coordination` shape `runlist runlists` reads.
507
+ function coordinationHubBody(title, bodyInput, today) {
508
+ return `
509
+ # ${title}
510
+
511
+ > One-paragraph: the domain this hub coordinates and how to read the queue below.
512
+ > Older docs call this organizing document a runlist. Plans and supporting docs
513
+ > belong to this hub; the ranked queue gives its plans an execution order.
514
+
515
+ ## Scope
516
+
517
+ ${bodyInput?.trim() ?? ''}
518
+
519
+ ## Ranked Queue
520
+
521
+ <!-- One row per coordinated plan, in pickup order; the gating column explains
522
+ dependencies. Wire each plan into related_plans: so the "N related" count and
523
+ graph pick it up. -->
524
+
525
+ | # | Plan | Why / gating | Status |
526
+ |---|------|--------------|--------|
527
+ | 1 | \`<plan>.md\` | | |
528
+
529
+ ## Version History
530
+
531
+ - **${today}** Created (coordination hub).
532
+ `;
533
+ }
534
+
535
+ // Body for an optional roadmap hub that collects other hubs and rolls their
536
+ // progress up. Children are wired via related_plans:; direct plans are supported.
537
+ function roadmapHubBody(title, hubSlug, bodyInput, today) {
538
+ return `
539
+ # ${title}
540
+
541
+ > One-paragraph: the domain this roadmap coordinates. A roadmap is an optional
542
+ > hub collecting other hubs (called runlists in older docs) and rolling up their
543
+ > progress. Direct plan children are also supported — see \`runlist roadmap ${hubSlug}\`.
544
+
545
+ ## Scope
546
+
547
+ ${bodyInput?.trim() ?? ''}
548
+
549
+ ## Runlists
550
+
551
+ <!-- One row per child hub or direct plan, in priority order. Wire each into
552
+ related_plans: so the rollup + graph pick it up. This table's Runlists label
553
+ means hubs: coordination maps, ordered hubs or other roadmap hubs.
554
+ Optional horizon flavor: replace this table with ## Now / ## Next / ## Later /
555
+ ## Icebox sections, each linking the hubs or plans in that horizon. -->
556
+
557
+ | # | Runlist | Why / gating | Progress |
558
+ |---|---------|--------------|----------|
559
+ | 1 | \`<name>-runlist.md\` | | |
560
+
561
+ ## Version History
562
+
563
+ - **${today}** Created (roadmap hub).
564
+ `;
565
+ }
566
+
567
+ // Body for a `--lite` plan: the full build-up scaffold (Goals / Non-Goals /
568
+ // What Exists Today / Constraints / Decisions / Open Questions / Deferred /
569
+ // Closeout) is stripped down to the essentials — Problem → Phases → Version
570
+ // History — for a quick plan that doesn't need the full ceremony.
571
+ function litePlanBody(title, bodyInput, today) {
572
+ return `
573
+ # ${title}
574
+
575
+ > One-paragraph problem statement: what this plan is for, why now.
576
+
577
+ ## Problem
578
+
579
+ ${bodyInput?.trim() ?? ''}
580
+
581
+ ## Phases
582
+
583
+ <!-- Status markers in heading text: ⬜ not started · 🟡 in progress (pickup
584
+ targets this) · ✅ shipped · ⏭ skipped · 🚧 blocked. -->
585
+
586
+ ### Phase 1 — <title> ⬜
587
+
588
+
589
+
590
+ ## Version History
591
+
592
+ - **${today}** Created.
593
+ `;
594
+ }
595
+
596
+ // Body for an `--audit` plan: the recurring "investigated X, here's what I
597
+ // found" shape — ranked findings with impact tags, a suggested order to act on
598
+ // them, and open questions. The build-up phases scaffold doesn't fit audits
599
+ // (e.g. the onboarding audit), which is why this shape recurs by hand.
600
+ function auditPlanBody(title, bodyInput, today) {
601
+ return `
602
+ # ${title}
603
+
604
+ > One-paragraph: what was audited, and the headline finding.
605
+
606
+ ## Problem
607
+
608
+ ${bodyInput?.trim() ?? ''}
609
+
610
+ ## Findings (Ranked)
611
+
612
+ ### 1. <finding> [impact]
613
+
614
+
615
+
616
+ ### 2. <finding> [impact]
617
+
618
+
619
+
620
+ ## Suggested Order
621
+
622
+ 1. <which finding to act on first, and why>
623
+
624
+ ## Open Questions
625
+
626
+
627
+
628
+ ## Version History
629
+
630
+ - **${today}** Created (audit).
631
+ `;
632
+ }
633
+
634
+ // Minimal child plan stub for a scaffolded runlist child. parent_plan points
635
+ // back at the hub (same dir) so \`runlist doctor\` is satisfied and the reverse
636
+ // link/graph work; status starts `planned` (queued behind the hub).
637
+ export function runlistChildContent(childTitle, hubSlug, hubTitle, childStatus, today) {
638
+ return `---
639
+ type: plan
640
+ status: ${childStatus}
641
+ created: ${today}
642
+ updated: ${today}
643
+ parent_plan: ${hubSlug}.md
644
+ related_plans:
645
+ current_state:
646
+ next_step:
647
+ ---
648
+
649
+ # ${childTitle}
650
+
651
+ > Plan in the ordered hub [${hubTitle}](${hubSlug}.md).
652
+
653
+ ## Problem
654
+
655
+
656
+
657
+ ## Version History
658
+
659
+ - **${today}** Created (child plan of hub ${hubSlug}).
660
+ `;
661
+ }
662
+
663
+ export async function runNew(argv, config, opts = {}) {
664
+ const { dryRun } = opts;
665
+
666
+ if (argv.find(a => !a.startsWith('-')) === 'decision') return runNewDecisionArgs(argv, config, opts);
667
+
668
+ const knownTypes = new Set(Object.keys(BUILTIN_TEMPLATES));
669
+ // Also include any custom templates from config
670
+ for (const k of Object.keys(config.raw?.templates ?? {})) knownTypes.add(k);
671
+ // `hub` is a plan with a hub body; `decision` is handled above.
672
+ knownTypes.add('hub');
673
+ knownTypes.add('decision');
674
+
675
+ const hasNameForBody = args => {
676
+ if (args.length >= 2 && knownTypes.has(args[0])) return true;
677
+ if (args.length >= 1 && !knownTypes.has(args[0])) return true;
678
+ return false;
679
+ };
680
+
681
+ // Parse args. Pull out flags first.
682
+ const positional = [];
683
+ let status = null;
684
+ let title = null;
685
+ let rootName = opts.root ?? null;
686
+ let bodyFlag = null;
687
+ let bodyFlagName = null; // tracks which spelling the caller used, for error attribution
688
+ let showFiles = opts.showFiles ?? false;
689
+ let runlistArg = null; // --runlist a,b,c → sprint hub + child stubs
690
+ let coordination = false; // --coordination → coordination hub skeleton
691
+ let roadmap = false; // --roadmap → roadmap hub skeleton
692
+ let lite = false; // --lite/--minimal → trimmed plan body
693
+ let renderPreview = false;
694
+ let audit = false; // --audit/--findings → ranked-findings plan body
695
+ for (let i = 0; i < argv.length; i++) {
696
+ if (argv[i] === '--render-preview') { renderPreview = true; continue; }
697
+ if (argv[i] === '--status' && argv[i + 1]) { status = argv[++i]; continue; }
698
+ if (argv[i] === '--title' && argv[i + 1]) { title = argv[++i]; continue; }
699
+ if (argv[i] === '--runlist' && argv[i + 1]) { runlistArg = argv[++i]; continue; }
700
+ if (argv[i] === '--coordination') { coordination = true; continue; }
701
+ if (argv[i] === '--roadmap') { roadmap = true; continue; }
702
+ if (argv[i] === '--lite' || argv[i] === '--minimal') { lite = true; continue; }
703
+ if (argv[i] === '--audit' || argv[i] === '--findings') { audit = true; continue; }
704
+ // --body is the canonical flag; --message is a back-compat alias.
705
+ if ((argv[i] === '--body' || argv[i] === '--message') && argv[i + 1]) {
706
+ bodyFlagName = argv[i];
707
+ bodyFlag = argv[++i];
708
+ continue;
709
+ }
710
+ if (argv[i] === '--root' && argv[i + 1]) { rootName = argv[++i]; continue; }
711
+ if (argv[i] === '--config') { i++; continue; }
712
+ if (argv[i] === '--show-files') { showFiles = true; continue; }
713
+ if (argv[i] === '--list-templates' || argv[i] === '--list-types') {
714
+ listTemplates(config);
715
+ return;
716
+ }
717
+ // Treat `-` alone (stdin marker) as a positional, not a flag.
718
+ // Once the type/name have been collected, a positional body may itself
719
+ // start with `---` frontmatter. Preserve it instead of dropping it as an
720
+ // unknown flag; the leading frontmatter merge below will handle it.
721
+ if (!argv[i].startsWith('-') || argv[i] === '-' || hasNameForBody(positional)) positional.push(argv[i]);
722
+ }
723
+
724
+ // Resolve type vs name:
725
+ // `runlist new plan auth-revamp` → type=plan, name=auth-revamp
726
+ // `runlist new auth-revamp` → type=doc (default), name=auth-revamp
727
+ // `runlist new prompt foo "body"` → type=prompt, name=foo, bodyArg="body"
728
+ let typeName, name, bodyArg = null;
729
+ if (positional.length >= 1 && knownTypes.has(positional[0])) {
730
+ typeName = positional[0];
731
+ name = positional[1];
732
+ if (positional.length > 2) bodyArg = positional.slice(2).join(' ');
733
+ } else {
734
+ // `new <name> <inline body>` is a doc with the type left out, but a single
735
+ // bare word in the body slot is almost always a mistyped type and a slug
736
+ // (`new decison foo`), which used to create `<type-name>.md` with the slug
737
+ // as its whole body.
738
+ const [first, second] = positional;
739
+ if (positional.length === 2 && !/[/\\]|\.md$/.test(first) && /^[A-Za-z][\w.-]*$/.test(second)) {
740
+ die(`Unknown type \`${first}\`. Types: ${[...knownTypes].join(', ')}.\n`
741
+ + `For a doc named ${first} with "${second}" as its body, run: runlist new doc ${first} "${second}"`);
742
+ }
743
+ typeName = 'doc';
744
+ name = positional[0];
745
+ if (positional.length > 1) bodyArg = positional.slice(1).join(' ');
746
+ }
747
+
748
+ // A hub is a plan with a hub body. Coordination is the default shape, being
749
+ // the one a hub without children or tiers has.
750
+ if (typeName === 'hub') {
751
+ typeName = 'plan';
752
+ if (runlistArg === null && !roadmap) coordination = true;
753
+ }
754
+
755
+ if (!name) {
756
+ if (isInteractive()) {
757
+ name = await promptText(`${typeName} name: `);
758
+ if (!name) die('No name provided.');
759
+ } else {
760
+ die(`Usage: runlist new <type> <name> [body]\n types: ${[...knownTypes].join(', ')}\n body: inline text | piped stdin (auto) | "@path" (file) | --body "..."`);
761
+ }
762
+ }
763
+
764
+ // Resolve template (by type name, falls back to lookup)
765
+ const template = resolveTemplate(typeName, config);
766
+
767
+ // Validate status. The template's `defaultStatus` is only used when it's
768
+ // actually valid in the user's per-type config — otherwise fall back to the
769
+ // first valid type status. This avoids the "Invalid status `active` for type
770
+ // `doc`" loop when a project overrides doc statuses to exclude 'active'.
771
+ if (!status) {
772
+ const typeStatuses = config.typeStatuses?.get(typeName);
773
+ const tmplDefault = (typeof template === 'object' && template.defaultStatus) ? template.defaultStatus : null;
774
+ if (tmplDefault && (!typeStatuses || typeStatuses.size === 0 || typeStatuses.has(tmplDefault))) {
775
+ status = tmplDefault;
776
+ } else if (typeStatuses && typeStatuses.size > 0) {
777
+ status = [...typeStatuses][0];
778
+ } else {
779
+ status = tmplDefault ?? 'active';
780
+ }
781
+ }
782
+ const effective = config.typeStatuses?.get(typeName) ?? config.validStatuses;
783
+ if (!effective.has(status)) {
784
+ die(`Invalid status \`${status}\` for type \`${typeName}\`\nValid: ${[...effective].join(', ')}`);
785
+ }
786
+
787
+ // Runlist/coordination hubs and the --lite/--audit variants are all plan
788
+ // *body shapes*, not separate types. Guard them to type plan and reject any
789
+ // combination — a plan has exactly one body shape (a sprint `runlist:` array,
790
+ // a prose-first coordination map, a trimmed lite plan, or a ranked-findings
791
+ // audit are mutually exclusive).
792
+ const isRunlistHub = runlistArg !== null;
793
+ const isCoordinationHub = coordination;
794
+ const isRoadmap = roadmap;
795
+ const isLite = lite;
796
+ const isAudit = audit;
797
+ const planShapes = [
798
+ ['--runlist', isRunlistHub],
799
+ ['--coordination', isCoordinationHub],
800
+ ['--roadmap', isRoadmap],
801
+ ['--lite', isLite],
802
+ ['--audit', isAudit],
803
+ ].filter(([, on]) => on);
804
+ if (planShapes.length > 0 && typeName !== 'plan') {
805
+ const flag = planShapes[0][0];
806
+ const usage = flag === '--runlist' ? '--runlist a,b,c' : flag;
807
+ die(`${flag} only applies to plans. Use: runlist new plan <name> ${usage}`);
808
+ }
809
+ if (planShapes.length > 1) {
810
+ die(`${planShapes.map(([f]) => f).join(' and ')} are mutually exclusive — a plan has one body shape. Pick one.`);
811
+ }
812
+ const runlistTokens = isRunlistHub
813
+ ? runlistArg.split(',').map(s => s.trim()).filter(Boolean)
814
+ : [];
815
+ if (isRunlistHub && runlistTokens.length === 0) {
816
+ die('--runlist needs at least one child, e.g. --runlist extract,rewrite,cleanup');
817
+ }
818
+
819
+ // Body input resolution: --body flag > positional bodyArg > auto-piped-stdin > nothing
820
+ let bodyInput = null;
821
+ let bodyInputSource = null;
822
+ if (bodyFlag !== null) { bodyInput = readBodyInput(bodyFlag); bodyInputSource = bodyFlagName; }
823
+ else if (bodyArg !== null) {
824
+ bodyInput = readBodyInput(bodyArg);
825
+ bodyInputSource = (bodyArg === '-' || bodyArg === '@-')
826
+ ? `stdin (\`${bodyArg}\`)`
827
+ : (bodyArg.startsWith('@') ? `file (\`${bodyArg}\`)` : 'inline body argument');
828
+ } else {
829
+ // Auto-consume piped or redirected stdin so agents don't need the `-`
830
+ // placeholder for the most common pattern (`cat draft.md | runlist new …`,
831
+ // `runlist new … < draft.md`, or a `<<'EOF'` heredoc). We probe stdin via
832
+ // fstatSync rather than `!isTTY` so a closed/inherited fd doesn't trigger
833
+ // a blocking read of an empty stream. We accept FIFO (shell pipes), regular
834
+ // file (shell redirection / heredoc), and socket (Node spawnSync `input:`
835
+ // delivers stdin as an AF_UNIX socket). Probe this even for templates that
836
+ // don't accept bodies so the fail-fast guard below can reject accidental
837
+ // heredoc/input instead of silently scaffolding without it.
838
+ const piped = readPipedBodyInput();
839
+ if (piped !== null) {
840
+ bodyInput = piped;
841
+ bodyInputSource = 'piped stdin';
842
+ }
843
+ }
844
+
845
+ // If the body input has a leading `---…---` frontmatter block, lift its keys
846
+ // out so they override scaffold defaults; only the content after the closing
847
+ // `---` is treated as body. The natural agent pattern is to draft a full doc
848
+ // to a tempfile and pass `@path` — without this, the scaffold ends up with
849
+ // two `---` blocks. See issue #12 trap 4.
850
+ let bodyFrontmatter = null;
851
+ if (bodyInput !== null) {
852
+ const split = splitBodyFrontmatter(bodyInput);
853
+ if (split.frontmatter) {
854
+ bodyFrontmatter = split.frontmatter;
855
+ bodyInput = split.body;
856
+ }
857
+ }
858
+
859
+ if (template.requiresBody && (!bodyInput || !bodyInput.trim())) {
860
+ die(`\`${typeName}\` template requires a body. Pipe stdin (\`cat draft.md | runlist new ${typeName} <slug>\`), pass @path, --body "...", or inline text.`);
861
+ }
862
+
863
+ // Fail-fast when the user passes body input to a template that doesn't
864
+ // consume it — silently discarding heredoc content is the worst UX.
865
+ // Templates opt in via `acceptsBody: true` or `requiresBody: true`.
866
+ if (bodyInput !== null && !template.acceptsBody && !template.requiresBody) {
867
+ const configTemplates = config.raw?.templates ?? {};
868
+ // Compute the accepting list from the RESOLVED set (config merged over
869
+ // built-ins) so the hint doesn't contradict the rejection.
870
+ const resolvedNames = new Set([...Object.keys(BUILTIN_TEMPLATES), ...Object.keys(configTemplates)]);
871
+ const accepting = [...resolvedNames]
872
+ .filter(n => n !== typeName)
873
+ .filter(n => {
874
+ const t = resolveTemplate(n, config);
875
+ return t.acceptsBody || t.requiresBody;
876
+ });
877
+ const hint = accepting.length > 0
878
+ ? ` Templates that accept body input: ${accepting.join(', ')}.`
879
+ : '';
880
+
881
+ // Override-of-builtin diagnosis: the most common cause is a project
882
+ // runlist.config.mjs that copy-pasted a stripped-down `plan` template
883
+ // and dropped the body-acceptance contract. Name that explicitly so
884
+ // an agent can self-fix without spelunking the config.
885
+ const builtin = BUILTIN_TEMPLATES[typeName];
886
+ const isOverride = Boolean(configTemplates[typeName] && builtin);
887
+ const builtinAccepts = Boolean(builtin && (builtin.acceptsBody || builtin.requiresBody));
888
+ let cause;
889
+ if (isOverride && builtinAccepts) {
890
+ const where = config.configPath ? toRepoPath(config.configPath, config.repoRoot) : 'runlist.config.mjs';
891
+ cause = `Your config (${where}) overrides the built-in \`${typeName}\` template, and the override drops body acceptance.\nFix: in that override, add \`acceptsBody: true\` AND interpolate \`\${ctx?.bodyInput?.trim() ?? ''}\` into your \`body\` fn (e.g., inside \`## Problem\`). Or drop the override to use the built-in.`;
892
+ } else {
893
+ cause = `Either drop the body, switch to a template that accepts it, or set \`acceptsBody: true\` on your custom \`${typeName}\` template in runlist.config.mjs.`;
894
+ }
895
+ die(`\`${typeName}\` template does not accept body input, but body was passed via ${bodyInputSource}.${hint}\n${cause}`);
896
+ }
897
+
898
+ // If name contains path separators, split into directory prefix and basename
899
+ let nameDir = null;
900
+ let namePart = name;
901
+ if (name.includes('/') || name.includes(path.sep)) {
902
+ nameDir = path.dirname(name);
903
+ namePart = path.basename(name, '.md');
904
+ } else if (name.endsWith('.md')) {
905
+ namePart = name.slice(0, -3);
906
+ }
907
+ // A prefix the *user* typed and one a template declares resolve differently
908
+ // below, so the distinction has to survive the template's assignment to nameDir.
909
+ const userNameDir = nameDir;
910
+
911
+ // Slugify
912
+ const slug = namePart.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
913
+ if (!slug) { die('Name resolves to empty slug: ' + name); }
914
+
915
+ // Title
916
+ const docTitle = title ?? namePart.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
917
+
918
+ // Resolve target root. Precedence: CLI --root > template.targetRoot > config.docsRoot.
919
+ // When the chosen root is a first-class type-container (matched by --root or targetRoot),
920
+ // we skip the `template.dir` join — the root already points at the right directory.
921
+ let targetRoot = catchAllRoot(config);
922
+ let routedToTypeRoot = false;
923
+ if (rootName) {
924
+ const roots = config.docsRoots || [config.docsRoot];
925
+ const match = roots.find(r => r.endsWith(rootName) || path.basename(r) === rootName);
926
+ if (!match) {
927
+ const available = roots.map(r => path.basename(r)).join(', ');
928
+ die(`Unknown root: ${rootName}\nAvailable: ${available}`);
929
+ }
930
+ targetRoot = match;
931
+ routedToTypeRoot = true;
932
+ } else if (typeof template === 'object' && template.targetRoot) {
933
+ const roots = config.docsRoots || [config.docsRoot];
934
+ const match = roots.find(r => r.endsWith(template.targetRoot) || path.basename(r) === template.targetRoot);
935
+ if (match) {
936
+ targetRoot = match;
937
+ routedToTypeRoot = true;
938
+ }
939
+ }
940
+
941
+ // Template-declared subdirectory (e.g., prompt → 'prompts') — only relevant when we
942
+ // didn't already land in a type-specific root via --root or targetRoot.
943
+ if (typeof template === 'object' && template.dir && !nameDir && !routedToTypeRoot) {
944
+ nameDir = path.join(path.relative(config.repoRoot, targetRoot), template.dir);
945
+ }
946
+
947
+ // Path — a directory prefix is read relative to the repo, because `runlist new
948
+ // plan docs/plans/feature` is a full repo path and has to stay one. That was
949
+ // the ONLY reading, which is how `--root` came to be silently ignored the
950
+ // moment a name contained a slash: the block above picked a root and this line
951
+ // threw it away, so the one flag the out-of-root error advertises could not
952
+ // fix the error. With an explicit --root the prefix is now read relative to
953
+ // that root — unless the repo-relative reading already lands inside it, so
954
+ // full paths keep working.
955
+ const allRoots = config.docsRoots ?? [config.docsRoot];
956
+ let baseDir;
957
+ if (!nameDir) baseDir = targetRoot;
958
+ else if (userNameDir && rootName) {
959
+ const repoRelative = path.resolve(config.repoRoot, nameDir);
960
+ baseDir = lexicallyInside(targetRoot, repoRelative) ? repoRelative : path.resolve(targetRoot, nameDir);
961
+ } else baseDir = path.resolve(config.repoRoot, nameDir);
962
+ const filePath = path.join(baseDir, slug + '.md');
963
+ const repoPath = toRepoPath(filePath, config.repoRoot);
964
+
965
+ // Without --root there is nothing to disambiguate with, so a prefix pointing
966
+ // outside every root stays an error — but it names the flag that resolves it,
967
+ // and the flag now works. The generic containment error underneath reports
968
+ // absolute paths and no remedy, which is what agents kept re-guessing at.
969
+ //
970
+ // Gated on the remedy actually working: the suggestion is only offered when
971
+ // `--root` would land the file inside that root. That is what keeps this off
972
+ // a traversal (`../escaped`, an absolute path), where `--root` fixes nothing
973
+ // and the containment check below is the error that should speak — printing
974
+ // an untested remedy is the very defect this finding is about.
975
+ if (userNameDir && !rootName && !allRoots.some(root => lexicallyInside(root, filePath))) {
976
+ const rooted = path.resolve(targetRoot, userNameDir, slug + '.md');
977
+ if (lexicallyInside(targetRoot, rooted)) {
978
+ die(`Destination is outside every configured root:\n`
979
+ + ` ${repoPath}\n\n`
980
+ + `A name with a \`/\` is read relative to the repo.\n`
981
+ + `To place it under a root instead:\n`
982
+ + ` runlist new ${typeName} ${name} --root ${path.basename(targetRoot)}\n`
983
+ + ` → ${toRepoPath(rooted, config.repoRoot)}\n\n`
984
+ + `Roots: ${allRoots.map(root => path.basename(root)).join(', ')}`);
985
+ }
986
+ }
987
+ const destinationAuthorization = authorizeManagedDestination(filePath, config, { kind: 'New document destination' });
988
+
989
+ if (existsSync(filePath)) {
990
+ die(`File already exists: ${repoPath}`);
991
+ }
992
+
993
+ const today = nowIso();
994
+
995
+ // Resolve runlist children from the hub slug (e.g. `extract` → hub-01-extract.md).
996
+ const runlistChildren = runlistTokens.map((tok, i) => planChildFromToken(slug, tok, i + 1));
997
+ for (const child of runlistChildren) {
998
+ authorizeManagedDestination(path.join(baseDir, child.file), config, {
999
+ root: destinationAuthorization.root,
1000
+ kind: 'Runlist child destination',
1001
+ });
1002
+ }
1003
+ const childStatus = effective.has('planned') ? 'planned' : status;
1004
+
1005
+ // When the project has >1 root and `--root` was omitted, surface the choice
1006
+ // so agents can see that an alternative root was available. Cheap visibility
1007
+ // for the "ended up in docs/plans/ for a doc" foot-gun.
1008
+ // Report the root the file actually landed in, not the one resolution started
1009
+ // from: a directory prefix can move the destination into a different root
1010
+ // entirely, and the line used to say `Root: plans` while writing docs/prospects/.
1011
+ let rootHint = '';
1012
+ if (!rootName && allRoots.length > 1) {
1013
+ const owningRoot = destinationAuthorization.root.lexicalPath;
1014
+ const chosenLabel = path.basename(owningRoot);
1015
+ const others = allRoots
1016
+ .filter(r => r !== owningRoot)
1017
+ .map(r => path.basename(r));
1018
+ rootHint = `Root: ${chosenLabel} (others: ${others.join(', ')} — pass --root <name> to change)\n`;
1019
+ }
1020
+
1021
+ const hubKind = isRunlistHub ? ' (runlist hub)'
1022
+ : isCoordinationHub ? ' (coordination hub)'
1023
+ : isRoadmap ? ' (roadmap hub)'
1024
+ : isLite ? ' (lite plan)'
1025
+ : isAudit ? ' (audit plan)'
1026
+ : '';
1027
+ const isCustomTemplate = Object.prototype.hasOwnProperty.call(config.raw?.templates ?? {}, typeName);
1028
+
1029
+ if (dryRun && !renderPreview) {
1030
+ if (isCustomTemplate) {
1031
+ process.stdout.write(`${dim('[dry-run]')} Target: ${repoPath}\n`);
1032
+ process.stdout.write(`${dim('[dry-run]')} Custom template rendering skipped; use --render-preview to explicitly execute its JavaScript and inspect the resulting document.\n`);
1033
+ } else {
1034
+ process.stdout.write(`${dim('[dry-run]')} Would create: ${repoPath}\n`);
1035
+ }
1036
+ process.stdout.write(`${dim('[dry-run]')} Type: ${typeName}${hubKind}\n`);
1037
+ for (const c of runlistChildren) {
1038
+ const childPath = path.join(baseDir, c.file);
1039
+ const action = existsSync(childPath) ? 'Would leave existing child unchanged' : 'Would create child';
1040
+ process.stdout.write(`${dim('[dry-run]')} ${action}: ${toRepoPath(childPath, config.repoRoot)}\n`);
1041
+ }
1042
+ if (rootHint) process.stdout.write(`${dim('[dry-run]')} ${rootHint}`);
1043
+ return;
1044
+ }
1045
+
1046
+ // Generate content only for a real create. Custom template functions are
1047
+ // user code and may have side effects, so previews stop before invoking them.
1048
+ let content;
1049
+ const validSurfaces = config.raw?.taxonomy?.surfaces ?? (config.validSurfaces ? [...config.validSurfaces] : null);
1050
+ const validModules = config.raw?.taxonomy?.modules ?? (config.validModules ? [...config.validModules] : null);
1051
+ const tmplCtx = { status, title: docTitle, today, bodyInput, validSurfaces, validModules };
1052
+ if (typeof template === 'function') {
1053
+ content = template(name, tmplCtx);
1054
+ } else {
1055
+ let fm = template.frontmatter(status, today, tmplCtx);
1056
+ if (bodyFrontmatter) fm = mergeBodyFrontmatter(fm, bodyFrontmatter, typeName);
1057
+ if (isRunlistHub) fm = mergeBodyFrontmatter(fm, { runlist: runlistChildren.map(c => c.file) }, typeName);
1058
+ if (isCoordinationHub) fm = mergeBodyFrontmatter(fm, { execution_mode: 'coordination' }, typeName);
1059
+ if (isRoadmap) fm = mergeBodyFrontmatter(fm, { execution_mode: 'roadmap' }, typeName);
1060
+ let body;
1061
+ const variantBody = isRunlistHub || isCoordinationHub || isRoadmap || isLite || isAudit;
1062
+ // A repo that overrides the plan or doc template keeps the built-in promise
1063
+ // that an authored body replaces the outline. Without this, the override's
1064
+ // body fn dropped the draft into its first slot and appended its own
1065
+ // sections after it, so a draft opening `## Problem` came out with two, and
1066
+ // sessions trimmed the tail by hand. Other custom types render as written.
1067
+ const overridesShortcutType = template._overridesBuiltin && (typeName === 'plan' || typeName === 'doc');
1068
+ const authored = variantBody || overridesShortcutType ? fullBodyShortcut(docTitle, bodyInput) : null;
1069
+ if (authored !== null) body = authored;
1070
+ else if (isRunlistHub) body = runlistHubBody(docTitle, slug, runlistChildren, bodyInput, today);
1071
+ else if (isCoordinationHub) body = coordinationHubBody(docTitle, bodyInput, today);
1072
+ else if (isRoadmap) body = roadmapHubBody(docTitle, slug, bodyInput, today);
1073
+ else if (isLite) body = litePlanBody(docTitle, bodyInput, today);
1074
+ else if (isAudit) body = auditPlanBody(docTitle, bodyInput, today);
1075
+ else body = template.body(docTitle, tmplCtx);
1076
+ content = `---\n${fm}\n---\n${body}`;
1077
+ }
1078
+
1079
+ if (dryRun) {
1080
+ process.stdout.write(`${dim('[dry-run]')} Would create: ${repoPath}\n${content}`);
1081
+ return;
1082
+ }
1083
+
1084
+ // Ensure parent dir exists (templates with `dir:` may target a new subdirectory)
1085
+ mkdirSync(path.dirname(filePath), { recursive: true });
1086
+
1087
+ const childPaths = [];
1088
+ const creations = [{ path: filePath, content }];
1089
+ for (const c of runlistChildren) {
1090
+ const childPath = path.join(baseDir, c.file);
1091
+ if (existsSync(childPath)) {
1092
+ warn(`Runlist child already exists, left as-is: ${toRepoPath(childPath, config.repoRoot)}`);
1093
+ continue;
1094
+ }
1095
+ creations.push({ path: childPath, content: runlistChildContent(c.title, slug, docTitle, childStatus, today) });
1096
+ childPaths.push(childPath);
1097
+ }
1098
+ try {
1099
+ if (runlistChildren.length > 0) mutateFileSet({ creations }, { repoRoot: config.repoRoot, testHooks: opts.testHooks });
1100
+ else createFileExclusive(filePath, content, { repoRoot: config.repoRoot, testHooks: opts.testHooks });
1101
+ } catch (err) {
1102
+ if (err instanceof MutationConflictError) die(`Could not create runlist atomically because a destination already exists: ${err.message}`);
1103
+ throw err;
1104
+ }
1105
+ process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName}${hubKind})`)}\n`);
1106
+ if (rootHint) process.stdout.write(dim(rootHint));
1107
+
1108
+ // Existing children discovered before the transaction are consistently left
1109
+ // unchanged; absent children and the hub publish or roll back together.
1110
+ for (const c of runlistChildren) {
1111
+ const childPath = path.join(baseDir, c.file);
1112
+ if (!childPaths.includes(childPath)) continue;
1113
+ process.stdout.write(`${green('Created')}: ${toRepoPath(childPath, config.repoRoot)} ${dim(`(plan · runlist child, ${childStatus})`)}\n`);
1114
+ }
1115
+
1116
+ // Post-create guidance. Prompts are the classic confusion point: agents
1117
+ // reflexively `git add && commit` a freshly-created file, but saved prompts
1118
+ // are session-local handoff artifacts — the next session consumes them via
1119
+ // `runlist use`, and the prompts dir is often gitignored (the commit then fails
1120
+ // confusingly). Tell the agent the next step explicitly, and flag a gitignored
1121
+ // target for any type so "why won't this commit" never happens silently.
1122
+ if (typeName === 'prompt') {
1123
+ process.stdout.write(dim('Session-local — no need to commit. The next session runs `runlist use` (or `runlist use ' + repoPath + '`) to consume it.\n'));
1124
+ }
1125
+ // Teach the field-length contract at the moment the fields get written —
1126
+ // learning it from a cap warning later sends sessions into hand-trim /
1127
+ // re-check loops.
1128
+ if (typeName === 'plan') {
1129
+ process.stdout.write(dim('current_state = 2-4 sentence summary (cap 1500 chars); next_step = 1-2 sentence pointer (cap 800). Detail goes in the body, not frontmatter.\n'));
1130
+ }
1131
+ try {
1132
+ const { isGitIgnored } = await import('./git.mjs');
1133
+ if (isGitIgnored(filePath, config.repoRoot)) {
1134
+ process.stdout.write(dim(`Note: ${repoPath} is gitignored — don't try to git add/commit it.\n`));
1135
+ }
1136
+ } catch { /* git absent / not a repo — skip the note */ }
1137
+
1138
+ const indexRegenerated = !opts.deferIndex ? regenIndex(config) : false;
1139
+
1140
+ if (showFiles) {
1141
+ const touched = [filePath, ...childPaths];
1142
+ if (config.indexPath && !opts.deferIndex) touched.push(config.indexPath);
1143
+ emitFilesFooter(touched, config);
1144
+ }
1145
+
1146
+ try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle, type: typeName }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
1147
+ const sessionLocal = typeName === 'prompt';
1148
+ return {
1149
+ operation: 'new',
1150
+ repositoryFiles: sessionLocal ? [] : [repoPath, ...childPaths.map(item => toRepoPath(item, config.repoRoot))],
1151
+ sessionFiles: sessionLocal ? [repoPath] : [],
1152
+ generatedFiles: config.indexPath && indexRegenerated ? [toRepoPath(config.indexPath, config.repoRoot)] : [],
1153
+ deferredGeneratedFiles: config.indexPath && opts.deferIndex ? [toRepoPath(config.indexPath, config.repoRoot)] : [],
1154
+ path: repoPath,
1155
+ };
1156
+ }
1157
+
1158
+ // The part of `new --help` only the repo can answer: which types exist here,
1159
+ // what status each starts at, which statuses are valid, and where each lands.
1160
+ // Sessions otherwise read the config file to find out.
1161
+ export function newHelpForRepo(config) {
1162
+ // Only types with a template: a status-only type can't be created by `new`.
1163
+ const types = new Set([...Object.keys(BUILTIN_TEMPLATES), ...Object.keys(config.raw?.templates ?? {})]);
1164
+ const rows = [];
1165
+ const width = Math.max(...[...types].map(t => t.length));
1166
+ for (const typeName of types) {
1167
+ const template = resolveTemplate(typeName, config);
1168
+ if (!template) continue;
1169
+ const statuses = [...(config.typeStatuses?.get(typeName) ?? [])];
1170
+ const tmplDefault = typeof template === 'object' ? template.defaultStatus : null;
1171
+ const start = tmplDefault && (!statuses.length || statuses.includes(tmplDefault)) ? tmplDefault : (statuses[0] ?? tmplDefault ?? 'active');
1172
+ const roots = config.docsRoots ?? [config.docsRoot];
1173
+ // Mirrors runNew: a matching targetRoot wins, else the catch-all root plus
1174
+ // the template's `dir`.
1175
+ const typeRoot = typeof template === 'object' && template.targetRoot
1176
+ ? roots.find(r => r.endsWith(template.targetRoot) || path.basename(r) === template.targetRoot)
1177
+ : null;
1178
+ const dest = typeRoot ?? (typeof template === 'object' && template.dir
1179
+ ? path.join(catchAllRoot(config), template.dir)
1180
+ : catchAllRoot(config));
1181
+ const where = toRepoPath(dest, config.repoRoot) + '/';
1182
+ const custom = readTemplateStore(config).data.templates[typeName] ? ' (Markdown template)' : Object.prototype.hasOwnProperty.call(config.raw?.templates ?? {}, typeName) ? ' (this repo\'s template)' : '';
1183
+ rows.push(` ${typeName.padEnd(width)} → ${where}<slug>.md, starts ${start}${custom}`);
1184
+ if (statuses.length) rows.push(` ${''.padEnd(width)} statuses: ${statuses.join(', ')}`);
1185
+ }
1186
+ const roots = (config.docsRoots ?? [config.docsRoot]).map(r => path.basename(r));
1187
+ rows.push(` ${'hub'.padEnd(width)} → a plan with a hub body (coordination unless --runlist or --roadmap)`);
1188
+ rows.push(` ${'decision'.padEnd(width)} → an entry in an existing plan's decisions section`);
1189
+ return `This repo:
1190
+ ${rows.join('\n')}
1191
+ roots (for --root): ${roots.join(', ')}`;
1192
+ }
1193
+
1194
+ function resolveTemplate(name, config) {
1195
+ return applyTemplateOverride(name, resolveBaseTemplate(name, config), config, pkg.version);
1196
+ }
1197
+
1198
+ function resolveBaseTemplate(name, config) {
1199
+ const configTemplates = config.raw?.templates ?? {};
1200
+ const override = configTemplates[name];
1201
+ const builtin = BUILTIN_TEMPLATES[name];
1202
+
1203
+ if (override) {
1204
+ if (!builtin) return { ...override, _overridesBuiltin: false };
1205
+ // Partial-override DX: shallow-merge built-in under override so missing
1206
+ // fields (description, dir, targetRoot, defaultStatus, frontmatter, body,
1207
+ // acceptsBody, requiresBody) fall back to the built-in. Anything the
1208
+ // override explicitly declares wins.
1209
+ const merged = { ...builtin, ...override, _overridesBuiltin: true };
1210
+
1211
+ // Body-loss guard: if the override supplies its OWN body fn but doesn't
1212
+ // explicitly opt in to body acceptance, the inherited built-in
1213
+ // `acceptsBody`/`requiresBody` could let body input flow into a custom
1214
+ // body fn that doesn't honor `ctx.bodyInput` — silently discarding the
1215
+ // heredoc, the worst-UX bug fix #9 was added to prevent. Strip the
1216
+ // inherited flags so the fail-fast guard fires. EXCEPT when the custom
1217
+ // body fn references `bodyInput` itself, in which case it's clearly
1218
+ // body-aware and inheriting acceptsBody is the agent-first move.
1219
+ const overrodeBody = typeof override.body === 'function';
1220
+ const declaredAcceptance = override.acceptsBody !== undefined || override.requiresBody !== undefined;
1221
+ if (overrodeBody && !declaredAcceptance) {
1222
+ const bodyAware = /bodyInput/.test(override.body.toString());
1223
+ if (!bodyAware) {
1224
+ merged.acceptsBody = undefined;
1225
+ merged.requiresBody = undefined;
1226
+ }
1227
+ }
1228
+ return merged;
1229
+ }
1230
+
1231
+ if (builtin) return builtin;
1232
+
1233
+ const available = [...new Set([...Object.keys(BUILTIN_TEMPLATES), ...Object.keys(configTemplates)])];
1234
+ die(`Unknown type: ${name}\nAvailable: ${available.join(', ')}`);
1235
+ }
1236
+
1237
+ function listTemplates(config) {
1238
+ const configTemplates = config.raw?.templates ?? {};
1239
+ const all = { ...BUILTIN_TEMPLATES };
1240
+ for (const [k, v] of Object.entries(configTemplates)) {
1241
+ all[k] = v;
1242
+ }
1243
+
1244
+ process.stdout.write(bold('Available types') + '\n\n');
1245
+ for (const [name, tmpl] of Object.entries(all)) {
1246
+ const desc = typeof tmpl === 'function'
1247
+ ? '(custom function)'
1248
+ : (tmpl.description ?? '');
1249
+ const source = readTemplateStore(config).data.templates[name] ? dim(' (runlist.templates.json)') : configTemplates[name] ? dim(' (config)') : '';
1250
+ process.stdout.write(` ${name}${source}\n`);
1251
+ if (desc) process.stdout.write(` ${dim(desc)}\n`);
1252
+ process.stdout.write('\n');
1253
+ }
1254
+ process.stdout.write(` hub\n ${dim('A plan with a hub body: coordination by default, --runlist or --roadmap for the others.')}\n\n`);
1255
+ process.stdout.write(` decision\n ${dim('An entry in an existing plan\'s decisions section: runlist new decision <plan> --question "…" @record.md')}\n\n`);
1256
+ }
1257
+
1258
+ function runNewDecisionArgs(argv, config, opts) {
1259
+ const positional = [];
1260
+ let question = null;
1261
+ let disposition = null;
1262
+ let answers = null;
1263
+ let bodyFlag = null;
1264
+ for (let i = 0; i < argv.length; i++) {
1265
+ const a = argv[i];
1266
+ if (a === '--question' && argv[i + 1] !== undefined) { question = argv[++i]; continue; }
1267
+ if (a === '--disposition' && argv[i + 1] !== undefined) { disposition = argv[++i]; continue; }
1268
+ if (a === '--answers' && argv[i + 1] !== undefined) { answers = argv[++i]; continue; }
1269
+ if ((a === '--body' || a === '--message') && argv[i + 1] !== undefined) { bodyFlag = argv[++i]; continue; }
1270
+ if (a === '--config' || a === '--root') { i++; continue; }
1271
+ if (!a.startsWith('-') || a === '-') positional.push(a);
1272
+ }
1273
+ const [, planArg, ...rest] = positional;
1274
+ let record = null;
1275
+ if (bodyFlag !== null) record = readBodyInput(bodyFlag);
1276
+ else if (rest.length) record = readBodyInput(rest.join(' '));
1277
+ else record = readPipedBodyInput();
1278
+ return runNewDecision({ planArg, question, disposition, record, answers }, config, { dryRun: opts.dryRun });
1279
+ }
1280
+
1281
+ // Browsing templates must never invoke repository-provided JavaScript.
1282
+ export function renderAppDocument(name,{title,status,today,bodyInput},config) {
1283
+ const type=name==='hub'?'plan':name;
1284
+ if(!['plan','doc'].includes(type))throw new Error('Choose a plan, doc or hub scaffold.');
1285
+ // Use the CLI's built-in scaffold/Markdown override and authored-body rules.
1286
+ // Repository JavaScript functions are deliberately excluded from previews.
1287
+ const template=name==='hub'?BUILTIN_TEMPLATES.plan:applyTemplateOverride(type,BUILTIN_TEMPLATES[type],config,pkg.version);
1288
+ const ctx={title,status,today,bodyInput,validSurfaces:config.raw?.taxonomy?.surfaces??(config.validSurfaces?[...config.validSurfaces]:null),validModules:config.raw?.taxonomy?.modules??(config.validModules?[...config.validModules]:null)};
1289
+ let fm=template.frontmatter(status,today,ctx);
1290
+ if(name==='hub')fm=mergeBodyFrontmatter(fm,{execution_mode:'coordination'},'plan');
1291
+ const authored=name==='hub'||template._overridesBuiltin?fullBodyShortcut(title,bodyInput):null;
1292
+ const body=authored??(name==='hub'?coordinationHubBody(title,bodyInput,today):template.body(title,ctx));
1293
+ return `---\n${fm}\n---\n${body}`;
1294
+ }
1295
+
1296
+ export function appDocumentTemplates(config) {
1297
+ const catalog = appTemplates(config);
1298
+ const templates = catalog.templates.filter(t => ['plan','doc'].includes(t.name)).map(t => ({
1299
+ ...t, origin:t.configuredSource && !t.overridden ? 'Built-in Markdown' : t.origin, type:t.name, defaultStatus:t.name === 'plan' ? 'planned' : 'active',
1300
+ // JavaScript templates cannot be safely executed just to show a preview.
1301
+ note:t.configuredSource && !t.overridden ? 'Uses the built-in Markdown scaffold. Customize and save a Markdown template to use repository-specific content here.' : null,
1302
+ }));
1303
+ const plan = templates.find(t => t.name === 'plan');
1304
+ templates.push({name:'hub',type:'plan',description:'Coordination hub for related plans',defaultStatus:'planned',origin:'Built-in hub',
1305
+ source:`---\n${extractFrontmatter(plan.defaultSource).frontmatter}\nexecution_mode: coordination\n---\n${coordinationHubBody('{{title}}','{{body}}','{{date}}')}`});
1306
+ return {...catalog,templates};
1307
+ }
1308
+
1309
+ export function appTemplates(config) {
1310
+ const store = readTemplateStore(config);
1311
+ const configFile = config.configPath ? toRepoPath(config.configPath,config.repoRoot) : 'runlist.config.mjs';
1312
+ const code = value => typeof value === 'function' ? value.toString() : Object.entries(value).map(([key,v])=>`${key}: ${typeof v === 'function' ? v.toString() : JSON.stringify(v)}`).join('\n\n');
1313
+ const templates = Object.entries(BUILTIN_TEMPLATES).map(([name,builtin]) => {
1314
+ const ctx = {title:'{{title}}',status:'{{status}}',today:'{{date}}',bodyInput:'{{body}}',validSurfaces:config.raw?.taxonomy?.surfaces ?? (config.validSurfaces ? [...config.validSurfaces] : null),validModules:config.raw?.taxonomy?.modules ?? (config.validModules ? [...config.validModules] : null)};
1315
+ const defaultSource = `---\n${builtin.frontmatter(ctx.status,ctx.today,ctx)}\n---\n${builtin.body(ctx.title,ctx)}`.replaceAll(pkg.version,'{{version}}');
1316
+ const configured = config.raw?.templates?.[name];
1317
+ return {name,description:builtin.description,source:store.data.templates[name]?.source ?? defaultSource,defaultSource,overridden:!!store.data.templates[name],origin:store.data.templates[name] ? templateFile : configured ? `${configFile} (JavaScript)` : 'Built-in',configuredSource:configured ? code(configured) : null,editable:true};
1318
+ });
1319
+ for (const [name,value] of Object.entries(config.raw?.templates ?? {})) if (!BUILTIN_TEMPLATES[name]) templates.push({name,description:value.description ?? 'Repository template',origin:`${configFile} (JavaScript)`,source:code(value),editable:false});
1320
+ return {templates,revision:store.revision,file:templateFile,version:pkg.version};
1321
+ }