dotmd-cli 0.35.0 → 0.36.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.
package/bin/dotmd.mjs CHANGED
@@ -32,6 +32,8 @@ Analyze:
32
32
  coverage [--json] Metadata coverage report
33
33
  graph [--dot] [--json] Visualize document relationships
34
34
  deps [file] [--json] Dependency tree or overview
35
+ modules [--sort cleanup] [--json] Module dashboard (plans grouped by module)
36
+ module <name> [--json] Plans for one module, grouped by status
35
37
  unblocks <file> [--json] Show what completes when this doc ships
36
38
  diff [file] [--summarize] Show changes since last updated date
37
39
  summary <file> [--json] AI summary of a document
@@ -297,6 +299,43 @@ Options:
297
299
  --depth <n> Max tree depth (default: 5)
298
300
  --json Machine-readable JSON output`,
299
301
 
302
+ modules: `dotmd modules — module dashboard (plans grouped by module)
303
+
304
+ One row per module discovered in plan frontmatter. Dynamic status columns
305
+ (only statuses with ≥1 plan render). Defaults to --type plan; pass --type
306
+ to scope to docs/prompts.
307
+
308
+ Sort modes:
309
+ --sort total Plan count, desc (default)
310
+ --sort stale Stale-plan count, desc
311
+ --sort age Average age in days, desc
312
+ --sort nextstep % of plans with a next_step set, desc
313
+ --sort cleanup Triage score: (stale × avgAge) / max(total, 1)
314
+
315
+ Options:
316
+ --limit <n> Cap rows (default: 20)
317
+ --all Show every module
318
+ --json Machine-readable shape (includes _totalUnique to
319
+ detect modules: [a, b] double-counting)
320
+
321
+ A plan with \`modules: [a, b]\` counts in both rows — intentional, so
322
+ multi-module plans surface in every relevant triage view. \`(none)\` is a
323
+ literal row for plans with no module tag.`,
324
+
325
+ module: `dotmd module <name> — plans for one module, grouped by status
326
+
327
+ Status groups follow config.statusOrder. Stale plans are flagged inline.
328
+
329
+ Sort modes (within each status group):
330
+ --sort status By config.statusOrder, then age (default)
331
+ --sort updated Most-recently-updated first
332
+ --sort age Oldest first
333
+
334
+ Options:
335
+ --json Machine-readable shape
336
+
337
+ Unknown module name suggests close matches (or lists what's available).`,
338
+
300
339
  doctor: `dotmd doctor — auto-fix everything in one pass
301
340
 
302
341
  Runs in sequence: fix broken references, lint --fix, sync dates from
@@ -563,7 +602,10 @@ Examples:
563
602
  stale: `dotmd stale — list stale documents
564
603
 
565
604
  Shows docs that haven't been updated within their staleness threshold.
566
- Supports all query flags (--status, --json, --sort, etc.)`,
605
+ Supports all query flags (--status, --json, --sort, etc.)
606
+
607
+ Examples:
608
+ dotmd stale --group module Stale plans grouped by module (triage view)`,
567
609
 
568
610
  actionable: `dotmd actionable — list docs with next steps
569
611
 
@@ -970,6 +1012,22 @@ async function main() {
970
1012
 
971
1013
  if (command === 'focus') { runFocus(index, restArgs, config); return; }
972
1014
  if (command === 'query') { runQuery(index, restArgs, config); return; }
1015
+ if (command === 'modules' || command === 'module') {
1016
+ // D3: default `--type plan` when the user didn't pass --type explicitly.
1017
+ // applyIndexFilters already narrowed by typeArg if it was set; if not, the
1018
+ // index still spans all types, and the dashboard would mix plans/docs/prompts
1019
+ // into the same module rows. Narrow here so the docs/prompts case stays a
1020
+ // deliberate `--type doc` opt-in (deferred per plan).
1021
+ const scoped = typeArg ? index : { ...index, docs: index.docs.filter(d => d.type === 'plan') };
1022
+ if (command === 'modules') {
1023
+ const { runModulesDashboard } = await import('../src/modules.mjs');
1024
+ runModulesDashboard(scoped, restArgs, config);
1025
+ } else {
1026
+ const { runModuleDetail } = await import('../src/modules.mjs');
1027
+ runModuleDetail(scoped, restArgs, config);
1028
+ }
1029
+ return;
1030
+ }
973
1031
  if (command === 'briefing') {
974
1032
  if (args.includes('--json')) {
975
1033
  const plans = index.docs.filter(d => d.type === 'plan');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.35.0",
3
+ "version": "0.36.1",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/commands.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  export const KNOWN_COMMANDS = [
6
6
  'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'hud',
7
7
  'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'finish', 'status', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor',
8
- 'unblocks', 'health', 'glossary',
8
+ 'unblocks', 'health', 'glossary', 'modules', 'module',
9
9
  'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
10
10
  'watch', 'diff', 'new', 'init', 'completions', 'statuses',
11
11
  ];
package/src/lifecycle.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso } from './util.mjs';
4
+ import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso, suggestCandidates } from './util.mjs';
5
5
  import { gitMv, getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
6
6
  import { buildIndex, collectDocFiles } from './index.mjs';
7
7
  import { renderIndexFile, writeIndex } from './index-file.mjs';
@@ -101,7 +101,11 @@ export async function runStatus(argv, config, opts = {}) {
101
101
  }
102
102
  }
103
103
 
104
- if (!effectiveValid.has(newStatus)) { die(`Invalid status: ${newStatus}\nValid: ${[...effectiveValid].join(', ')}`); }
104
+ if (!effectiveValid.has(newStatus)) {
105
+ const suggestions = suggestCandidates(newStatus, [...effectiveValid]);
106
+ const hint = suggestions.length ? `\nDid you mean: ${suggestions.join(', ')}?` : '';
107
+ die(`Invalid status: ${newStatus}\nValid: ${[...effectiveValid].join(', ')}${hint}`);
108
+ }
105
109
 
106
110
  const oldStatus = asString(parsedFm.status);
107
111
 
@@ -0,0 +1,355 @@
1
+ // Modules dashboard (F16). Two view-only commands:
2
+ // `dotmd modules` — one row per discovered module, dynamic status columns
3
+ // `dotmd module <name>` — plans for one module, grouped by status, stale flagged inline
4
+ //
5
+ // Aggregation lives in `aggregateModules` (pure data). Both renderers consume
6
+ // the same shape, so JSON output and the table share one source of truth.
7
+ //
8
+ // Constraint cheatsheet (see docs/archived/modules-dashboard.md):
9
+ // M1 dynamic columns — discover statuses from rendered rows, drop empty cols
10
+ // M2 overflow fallback — drop-empty first, then stacked render if still too wide
11
+ // M3 double-counting — `modules: [a, b]` increments both rows (intended)
12
+ // M4 (none) bucket — surface when ≥1 plan has empty modules
13
+ // M6 archived/skipStale — terminal statuses excluded; skipStale don't contribute to `stale`
14
+ import { toSlug, truncate, die, suggestCandidates } from './util.mjs';
15
+ import { bold, dim, yellow, red, green, blue, magenta, cyan, brightYellow } from './color.mjs';
16
+
17
+ const STATUS_COLORS = {
18
+ 'in-session': (s) => bold(red(s)),
19
+ 'active': green,
20
+ 'planned': blue,
21
+ 'blocked': yellow,
22
+ 'partial': (s) => dim(green(s)),
23
+ 'paused': magenta,
24
+ 'awaiting': brightYellow,
25
+ 'queued-after': (s) => dim(cyan(s)),
26
+ 'archived': dim,
27
+ 'pending': bold,
28
+ 'claimed': dim,
29
+ };
30
+
31
+ function colorStatus(status, text) {
32
+ const fn = STATUS_COLORS[status] ?? ((s) => s);
33
+ return fn(text);
34
+ }
35
+
36
+ // Single pass over docs → Map<moduleName, aggregate>. Terminal-status docs are
37
+ // excluded entirely (M6) — they pollute the active-work picture. skipStale
38
+ // statuses are counted toward totals but never contribute to `stale` so they
39
+ // don't lie about staleness when a custom config (e.g. `backlog`) opts out.
40
+ export function aggregateModules(docs, config) {
41
+ const terminal = config.lifecycle.terminalStatuses;
42
+ const skipStale = config.lifecycle.skipStaleFor;
43
+ const liveDocs = docs.filter(d => !terminal.has(d.status));
44
+ const uniqueTotal = liveDocs.length;
45
+
46
+ const modules = new Map();
47
+ function bucket(name) {
48
+ if (!modules.has(name)) {
49
+ modules.set(name, {
50
+ name,
51
+ plans: [],
52
+ byStatus: {},
53
+ stale: 0,
54
+ ageSum: 0,
55
+ ageCount: 0,
56
+ oldest: null,
57
+ nextStepCount: 0,
58
+ });
59
+ }
60
+ return modules.get(name);
61
+ }
62
+
63
+ for (const doc of liveDocs) {
64
+ const names = doc.modules?.length ? doc.modules : ['(none)'];
65
+ for (const name of names) {
66
+ const m = bucket(name);
67
+ m.plans.push(doc);
68
+ m.byStatus[doc.status] = (m.byStatus[doc.status] ?? 0) + 1;
69
+ if (doc.isStale && !skipStale.has(doc.status)) m.stale += 1;
70
+ if (typeof doc.daysSinceUpdate === 'number') {
71
+ m.ageSum += doc.daysSinceUpdate;
72
+ m.ageCount += 1;
73
+ if (!m.oldest || doc.daysSinceUpdate > m.oldest.ageDays) {
74
+ m.oldest = { slug: toSlug(doc), ageDays: doc.daysSinceUpdate };
75
+ }
76
+ }
77
+ if (doc.hasNextStep) m.nextStepCount += 1;
78
+ }
79
+ }
80
+
81
+ const rows = [...modules.values()].map(m => ({
82
+ name: m.name,
83
+ total: m.plans.length,
84
+ byStatus: m.byStatus,
85
+ stale: m.stale,
86
+ avgAgeDays: m.ageCount > 0 ? m.ageSum / m.ageCount : 0,
87
+ oldest: m.oldest,
88
+ nextStepPct: m.plans.length > 0 ? m.nextStepCount / m.plans.length : 0,
89
+ _plans: m.plans,
90
+ }));
91
+
92
+ return { rows, totalUnique: uniqueTotal };
93
+ }
94
+
95
+ // Cleanup-rank formula (R3): high stale share + high average age + lower total
96
+ // floats modules that have rotted; low total in the denominator keeps a single
97
+ // ancient plan from outweighing a 30-plan module with two stale ones. Document
98
+ // in `--help`; iterate after running on real corpora.
99
+ function cleanupScore(row) {
100
+ return (row.stale * row.avgAgeDays) / Math.max(row.total, 1);
101
+ }
102
+
103
+ function buildSorter(sort) {
104
+ if (sort === 'stale') return (a, b) => b.stale - a.stale || b.total - a.total;
105
+ if (sort === 'age') return (a, b) => b.avgAgeDays - a.avgAgeDays || b.total - a.total;
106
+ if (sort === 'nextstep') return (a, b) => b.nextStepPct - a.nextStepPct || b.total - a.total;
107
+ if (sort === 'cleanup') return (a, b) => cleanupScore(b) - cleanupScore(a) || b.stale - a.stale;
108
+ return (a, b) => b.total - a.total || a.name.localeCompare(b.name);
109
+ }
110
+
111
+ function parseFlags(argv) {
112
+ const flags = { sort: 'total', json: false, limit: 20, all: false };
113
+ for (let i = 0; i < argv.length; i++) {
114
+ const arg = argv[i];
115
+ if (arg === '--sort' && argv[i + 1]) { flags.sort = argv[++i]; continue; }
116
+ if (arg === '--json') { flags.json = true; continue; }
117
+ if (arg === '--limit' && argv[i + 1]) { flags.limit = parseInt(argv[++i], 10); continue; }
118
+ if (arg === '--all') { flags.all = true; continue; }
119
+ }
120
+ const validSorts = new Set(['total', 'stale', 'age', 'nextstep', 'cleanup']);
121
+ if (!validSorts.has(flags.sort)) {
122
+ die(`Invalid --sort value: ${flags.sort}. Valid: ${[...validSorts].join(', ')}.`);
123
+ }
124
+ return flags;
125
+ }
126
+
127
+ // Discover which status columns to render. Only statuses with ≥1 non-zero cell
128
+ // across the rendered rows make the cut (M1). Ordered by config.statusOrder
129
+ // first, then alphabetically for any custom statuses the index encountered
130
+ // that aren't in statusOrder (so a Beyond-style `research` column appears).
131
+ function discoverStatusColumns(rows, config) {
132
+ const seen = new Set();
133
+ for (const row of rows) {
134
+ for (const [status, count] of Object.entries(row.byStatus)) {
135
+ if (count > 0) seen.add(status);
136
+ }
137
+ }
138
+ const ordered = config.statusOrder.filter(s => seen.has(s));
139
+ const extras = [...seen].filter(s => !config.statusOrder.includes(s)).sort();
140
+ return [...ordered, ...extras];
141
+ }
142
+
143
+ function fmtAge(days) {
144
+ if (!Number.isFinite(days)) return '-';
145
+ if (days < 1) return '<1d';
146
+ if (days < 10) return `${days.toFixed(1)}d`;
147
+ return `${Math.round(days)}d`;
148
+ }
149
+
150
+ function fmtPct(frac) {
151
+ if (!Number.isFinite(frac)) return '-';
152
+ return `${Math.round(frac * 100)}%`;
153
+ }
154
+
155
+ function fmtOldest(oldest) {
156
+ if (!oldest) return '-';
157
+ const slug = truncate(oldest.slug, 14);
158
+ return `${slug} (${fmtAge(oldest.ageDays)})`;
159
+ }
160
+
161
+ // Heuristic column-width budget. Returns true if the dynamic-columns table
162
+ // will fit within the terminal; caller switches to the stacked render if not.
163
+ function tableFits(rows, statuses, termWidth) {
164
+ const nameCol = Math.max(8, ...rows.map(r => r.name.length));
165
+ const statusCols = statuses.length * 7; // each status header padded
166
+ const tailCols = 6 /*stale*/ + 8 /*age*/ + 22 /*oldest*/ + 6 /*next%*/ + 8 /*gutters*/;
167
+ return nameCol + statusCols + tailCols <= termWidth;
168
+ }
169
+
170
+ function pad(s, width) {
171
+ if (s.length >= width) return s;
172
+ return s + ' '.repeat(width - s.length);
173
+ }
174
+
175
+ function padLeft(s, width) {
176
+ if (s.length >= width) return s;
177
+ return ' '.repeat(width - s.length) + s;
178
+ }
179
+
180
+ function renderTable(rows, statuses, config) {
181
+ const nameCol = Math.max(8, ...rows.map(r => r.name.length));
182
+ const lines = [];
183
+ const headerCells = [
184
+ pad('module', nameCol),
185
+ ...statuses.map(s => padLeft(s.slice(0, 5), 5)),
186
+ padLeft('stale', 5),
187
+ padLeft('avgAge', 7),
188
+ pad('oldest', 22),
189
+ padLeft('next%', 5),
190
+ ];
191
+ lines.push(dim(headerCells.join(' ')));
192
+ for (const row of rows) {
193
+ const cells = [
194
+ pad(row.name, nameCol),
195
+ ...statuses.map(s => {
196
+ const n = row.byStatus[s] ?? 0;
197
+ const text = padLeft(String(n), 5);
198
+ return n > 0 ? colorStatus(s, text) : dim(text);
199
+ }),
200
+ padLeft(String(row.stale), 5),
201
+ padLeft(fmtAge(row.avgAgeDays), 7),
202
+ pad(fmtOldest(row.oldest), 22),
203
+ padLeft(fmtPct(row.nextStepPct), 5),
204
+ ];
205
+ lines.push(cells.join(' '));
206
+ }
207
+ return lines.join('\n') + '\n';
208
+ }
209
+
210
+ // Stacked fallback (M2 step 2): one block per module, status counts indented.
211
+ // Preserves every value — never collapses to "Other".
212
+ function renderStacked(rows, statuses) {
213
+ const lines = [];
214
+ for (const row of rows) {
215
+ lines.push(bold(row.name) + dim(` · ${row.total} total · ${row.stale} stale · avg ${fmtAge(row.avgAgeDays)} · next-step ${fmtPct(row.nextStepPct)}`));
216
+ const cells = statuses
217
+ .filter(s => (row.byStatus[s] ?? 0) > 0)
218
+ .map(s => `${colorStatus(s, s)}: ${row.byStatus[s]}`);
219
+ if (cells.length) lines.push(' ' + cells.join(' '));
220
+ if (row.oldest) lines.push(dim(` oldest: ${row.oldest.slug} (${fmtAge(row.oldest.ageDays)})`));
221
+ lines.push('');
222
+ }
223
+ return lines.join('\n');
224
+ }
225
+
226
+ export function runModulesDashboard(index, argv, config) {
227
+ const flags = parseFlags(argv);
228
+ const { rows, totalUnique } = aggregateModules(index.docs, config);
229
+ rows.sort(buildSorter(flags.sort));
230
+ const totalModules = rows.length;
231
+ const displayRows = flags.all ? rows : rows.slice(0, flags.limit);
232
+
233
+ if (flags.json) {
234
+ const out = {
235
+ type: 'plan',
236
+ sort: flags.sort,
237
+ _totalUnique: totalUnique,
238
+ modules: displayRows.map(r => ({
239
+ name: r.name,
240
+ total: r.total,
241
+ byStatus: r.byStatus,
242
+ stale: r.stale,
243
+ avgAgeDays: Math.round(r.avgAgeDays * 10) / 10,
244
+ oldest: r.oldest,
245
+ nextStepPct: Math.round(r.nextStepPct * 100) / 100,
246
+ })),
247
+ };
248
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n');
249
+ return;
250
+ }
251
+
252
+ if (rows.length === 0) {
253
+ process.stdout.write('No modules found.\n');
254
+ return;
255
+ }
256
+
257
+ const statuses = discoverStatusColumns(displayRows, config);
258
+ const termWidth = process.stdout.columns || 120;
259
+
260
+ const header = `${totalModules} modules · ${totalUnique} plans · sort: ${flags.sort}`;
261
+ process.stdout.write(dim(header) + '\n\n');
262
+
263
+ if (tableFits(displayRows, statuses, termWidth)) {
264
+ process.stdout.write(renderTable(displayRows, statuses, config));
265
+ } else {
266
+ process.stdout.write(renderStacked(displayRows, statuses));
267
+ }
268
+
269
+ if (!flags.all && rows.length > flags.limit) {
270
+ const hidden = rows.length - flags.limit;
271
+ process.stdout.write('\n' + dim(` ${hidden} more module${hidden === 1 ? '' : 's'} · dotmd modules --all\n`));
272
+ }
273
+ }
274
+
275
+ export function runModuleDetail(index, argv, config) {
276
+ const flags = { sort: 'status', json: false };
277
+ const positional = [];
278
+ for (let i = 0; i < argv.length; i++) {
279
+ const arg = argv[i];
280
+ if (arg === '--sort' && argv[i + 1]) { flags.sort = argv[++i]; continue; }
281
+ if (arg === '--json') { flags.json = true; continue; }
282
+ if (arg.startsWith('-')) continue;
283
+ positional.push(arg);
284
+ }
285
+ const name = positional[0];
286
+ if (!name) die('Usage: dotmd module <name>');
287
+
288
+ const { rows } = aggregateModules(index.docs, config);
289
+ const row = rows.find(r => r.name === name);
290
+ if (!row) {
291
+ const candidates = rows.map(r => r.name);
292
+ const suggestions = suggestCandidates(name, candidates);
293
+ const hint = suggestions.length
294
+ ? `Did you mean: ${suggestions.join(', ')}?`
295
+ : `Available: ${candidates.slice(0, 5).join(', ')}${candidates.length > 5 ? ', …' : ''} (run \`dotmd modules\` for the full list).`;
296
+ die(`Module '${name}' not found. ${hint}`);
297
+ }
298
+
299
+ const plans = row._plans.slice();
300
+ if (flags.sort === 'updated' || flags.sort === 'age') {
301
+ plans.sort((a, b) => (b.daysSinceUpdate ?? 0) - (a.daysSinceUpdate ?? 0));
302
+ } else {
303
+ plans.sort((a, b) => {
304
+ const ai = config.statusOrder.indexOf(a.status);
305
+ const bi = config.statusOrder.indexOf(b.status);
306
+ const aIdx = ai === -1 ? Number.MAX_SAFE_INTEGER : ai;
307
+ const bIdx = bi === -1 ? Number.MAX_SAFE_INTEGER : bi;
308
+ if (aIdx !== bIdx) return aIdx - bIdx;
309
+ return (b.daysSinceUpdate ?? 0) - (a.daysSinceUpdate ?? 0);
310
+ });
311
+ }
312
+
313
+ if (flags.json) {
314
+ const out = {
315
+ name: row.name,
316
+ total: row.total,
317
+ plans: plans.map(p => ({
318
+ path: p.path,
319
+ slug: toSlug(p),
320
+ status: p.status,
321
+ daysSinceUpdate: p.daysSinceUpdate,
322
+ isStale: p.isStale,
323
+ hasNextStep: p.hasNextStep,
324
+ summary: p.summary,
325
+ })),
326
+ };
327
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n');
328
+ return;
329
+ }
330
+
331
+ process.stdout.write(dim(`${row.total} plans · ${row.stale} stale · module: ${row.name}`) + '\n');
332
+
333
+ const skipStale = config.lifecycle.skipStaleFor;
334
+ const groups = new Map();
335
+ for (const p of plans) {
336
+ if (!groups.has(p.status)) groups.set(p.status, []);
337
+ groups.get(p.status).push(p);
338
+ }
339
+ const orderedStatuses = [
340
+ ...config.statusOrder.filter(s => groups.has(s)),
341
+ ...[...groups.keys()].filter(s => !config.statusOrder.includes(s)),
342
+ ];
343
+ for (const status of orderedStatuses) {
344
+ const group = groups.get(status);
345
+ process.stdout.write(`\n${bold(colorStatus(status, status))} (${group.length})\n`);
346
+ for (const p of group) {
347
+ const slug = toSlug(p);
348
+ const age = fmtAge(p.daysSinceUpdate);
349
+ const staleTag = p.isStale && !skipStale.has(p.status) ? dim(' [stale]') : '';
350
+ const next = p.nextStep ? ` — ${truncate(p.nextStep, 60)}` : '';
351
+ process.stdout.write(` ${slug} ${dim(age)}${staleTag}${next}\n`);
352
+ }
353
+ }
354
+ process.stdout.write('\n');
355
+ }
package/src/new.mjs CHANGED
@@ -69,14 +69,27 @@ ${ctx?.bodyInput?.trim() ?? ''}
69
69
  'current_state:',
70
70
  'next_step:',
71
71
  ].join('\n'),
72
- body: (t, ctx) => `
72
+ body: (t, ctx) => {
73
+ const bodyInput = ctx?.bodyInput?.trim() ?? '';
74
+ // Full-body shortcut: if the input already authors `## Section` headings,
75
+ // it's a complete plan body the user/agent wrote start-to-finish. Drop
76
+ // the scaffold's later sections to avoid duplicate empty `## Goals`,
77
+ // `## Phases`, etc. below the user's already-filled versions. A bare title
78
+ // (`# X`) at the head of the body is honored — we don't double-print the
79
+ // scaffold's title. Otherwise emit the scaffold and slot the body into
80
+ // `## Problem` as before (section-content mode).
81
+ if (/^##\s+\S/m.test(bodyInput)) {
82
+ const hasOwnTitle = /^#\s+\S/.test(bodyInput);
83
+ return hasOwnTitle ? `\n${bodyInput}\n` : `\n# ${t}\n\n${bodyInput}\n`;
84
+ }
85
+ return `
73
86
  # ${t}
74
87
 
75
88
  > One-paragraph problem statement: what this plan is for, why now.
76
89
 
77
90
  ## Problem
78
91
 
79
- ${ctx?.bodyInput?.trim() ?? ''}
92
+ ${bodyInput}
80
93
 
81
94
  ## Goals
82
95
 
@@ -128,7 +141,8 @@ Status markers (put in heading text):
128
141
  ## Closeout
129
142
 
130
143
  <!-- Filled on archive: what shipped, key commits, deferrals dispositioned. -->
131
- `,
144
+ `;
145
+ },
132
146
  },
133
147
  prompt: {
134
148
  description: 'Saved prompt to seed a future Claude session — body is required',