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.
- package/LICENSE +21 -0
- package/README.md +1112 -6
- package/assets/app/app.css +283 -0
- package/assets/app/app.mjs +381 -0
- package/assets/app/block-editor.mjs +182 -0
- package/assets/app/block-model.mjs +52 -0
- package/assets/app/document-create.mjs +86 -0
- package/assets/app/document-lifecycle.mjs +36 -0
- package/assets/app/document-stages.mjs +22 -0
- package/assets/app/document-views.mjs +33 -0
- package/assets/app/document-yardstick.mjs +258 -0
- package/assets/app/editor-navigation.mjs +51 -0
- package/assets/app/filing-navigation.mjs +138 -0
- package/assets/app/git-changes.mjs +80 -0
- package/assets/app/git-commit.mjs +46 -0
- package/assets/app/index.html +70 -0
- package/assets/app/library-navigation.mjs +93 -0
- package/assets/app/outline.mjs +18 -0
- package/assets/app/quick-navigation.mjs +52 -0
- package/assets/app/record-navigation.mjs +78 -0
- package/assets/app/recovery-center.mjs +53 -0
- package/assets/app/semantic-search.mjs +14 -0
- package/assets/app/settings.mjs +15 -0
- package/assets/app/shared.mjs +104 -0
- package/assets/app/stage-source.mjs +75 -0
- package/assets/app/template-editor.mjs +47 -0
- package/assets/app/theme.mjs +25 -0
- package/assets/app/transport.mjs +43 -0
- package/assets/contracts/source-editing-v1.md +232 -0
- package/assets/opencode/plugin.js +162 -0
- package/assets/schemas/runlist-record-v1.schema.json +912 -0
- package/bin/dotmd.mjs +5 -0
- package/bin/runlist.mjs +2714 -0
- package/package.json +53 -6
- package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
- package/plugins/runlist-codex/bin/runlist-hook +86 -0
- package/plugins/runlist-codex/hooks/hooks.json +16 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
- package/runlist.config.example.mjs +370 -0
- package/scripts/postinstall.mjs +56 -0
- package/src/agent-context.mjs +201 -0
- package/src/ai.mjs +30 -0
- package/src/app-create.mjs +169 -0
- package/src/app-filing.mjs +139 -0
- package/src/app-git-commits.mjs +48 -0
- package/src/app-git.mjs +180 -0
- package/src/app-library.mjs +152 -0
- package/src/app-lifecycle-worker.mjs +12 -0
- package/src/app-lifecycle.mjs +79 -0
- package/src/app-records.mjs +134 -0
- package/src/app-search.mjs +80 -0
- package/src/app-semantic-search.mjs +105 -0
- package/src/app-service.mjs +208 -0
- package/src/app-yardstick.mjs +269 -0
- package/src/app.mjs +102 -0
- package/src/atomic-mutation.mjs +1736 -0
- package/src/baton.mjs +435 -0
- package/src/body-link.mjs +48 -0
- package/src/bulk-tag.mjs +174 -0
- package/src/check-collapse.mjs +75 -0
- package/src/claude-commands.mjs +75 -0
- package/src/cli-editor.mjs +8 -0
- package/src/code-refs.mjs +545 -0
- package/src/codex-integration.mjs +81 -0
- package/src/color.mjs +14 -0
- package/src/commands.mjs +374 -0
- package/src/completions.mjs +93 -0
- package/src/config-edit.mjs +606 -0
- package/src/config.mjs +705 -0
- package/src/decision-action.mjs +58 -0
- package/src/decision.mjs +220 -0
- package/src/decisions.mjs +787 -0
- package/src/deps.mjs +317 -0
- package/src/desktop-install.mjs +158 -0
- package/src/desktop-runtime.mjs +4 -0
- package/src/desktop.mjs +25 -0
- package/src/diff.mjs +101 -0
- package/src/doctor.mjs +591 -0
- package/src/durable-rename.mjs +55 -0
- package/src/errors-read.mjs +44 -0
- package/src/export.mjs +496 -0
- package/src/extractors.mjs +101 -0
- package/src/filing.mjs +255 -0
- package/src/fix-membership.mjs +134 -0
- package/src/fix-refs.mjs +174 -0
- package/src/flags.mjs +318 -0
- package/src/frontmatter-fix.mjs +195 -0
- package/src/frontmatter.mjs +252 -0
- package/src/git-commit-filters.mjs +32 -0
- package/src/git-commit-protocol.mjs +198 -0
- package/src/git-commit-signing.mjs +75 -0
- package/src/git-commit-store.mjs +26 -0
- package/src/git-commit-support.mjs +16 -0
- package/src/git-commit-worker.mjs +32 -0
- package/src/git.mjs +909 -0
- package/src/glossary-check.mjs +35 -0
- package/src/glossary.mjs +243 -0
- package/src/graph.mjs +298 -0
- package/src/guard.mjs +488 -0
- package/src/health.mjs +212 -0
- package/src/hints.mjs +129 -0
- package/src/host-integration.mjs +339 -0
- package/src/hub-membership.mjs +284 -0
- package/src/hub.mjs +296 -0
- package/src/hud.mjs +436 -0
- package/src/index-file.mjs +163 -0
- package/src/index.mjs +478 -0
- package/src/init.mjs +512 -0
- package/src/install.mjs +222 -0
- package/src/journal-read.mjs +116 -0
- package/src/journal.mjs +472 -0
- package/src/lifecycle.mjs +1444 -0
- package/src/lint.mjs +386 -0
- package/src/managed-path.mjs +192 -0
- package/src/markdown-code-spans.mjs +58 -0
- package/src/migrate-prompts.mjs +171 -0
- package/src/migrate-template.mjs +170 -0
- package/src/migrate.mjs +90 -0
- package/src/misuse-read.mjs +61 -0
- package/src/model-request.mjs +33 -0
- package/src/model.mjs +523 -0
- package/src/modules.mjs +355 -0
- package/src/naming.mjs +55 -0
- package/src/native-action.mjs +59 -0
- package/src/native-record.mjs +244 -0
- package/src/new.mjs +1321 -0
- package/src/output-identity.mjs +111 -0
- package/src/parent-plan.mjs +58 -0
- package/src/parse-cache.mjs +99 -0
- package/src/path-containment.mjs +15 -0
- package/src/pickup-card.mjs +293 -0
- package/src/pickup.mjs +671 -0
- package/src/prompt.mjs +25 -0
- package/src/prompts.mjs +585 -0
- package/src/public-inspection.mjs +19 -0
- package/src/query.mjs +977 -0
- package/src/record-action.mjs +11 -0
- package/src/record-create.mjs +82 -0
- package/src/reference-planner.mjs +388 -0
- package/src/reference-values.mjs +11 -0
- package/src/rename.mjs +107 -0
- package/src/render.mjs +678 -0
- package/src/roadmap.mjs +184 -0
- package/src/runlist.mjs +944 -0
- package/src/section.mjs +225 -0
- package/src/semantic-search-client.mjs +58 -0
- package/src/ship.mjs +166 -0
- package/src/show.mjs +101 -0
- package/src/skill-drift.mjs +98 -0
- package/src/source-editor.mjs +491 -0
- package/src/stages.mjs +73 -0
- package/src/state-migration.mjs +123 -0
- package/src/stats.mjs +200 -0
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +721 -0
- package/src/summary.mjs +74 -0
- package/src/surfaces.mjs +28 -0
- package/src/sync-status.mjs +316 -0
- package/src/template-store.mjs +62 -0
- package/src/update.mjs +338 -0
- package/src/use.mjs +58 -0
- package/src/util.mjs +337 -0
- package/src/validate.mjs +795 -0
- package/src/watch.mjs +56 -0
- package/src/xref.mjs +406 -0
- package/src/yardstick-command.mjs +178 -0
- 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
|
+
}
|