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/init.mjs
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
6
|
+
import { green, dim, yellow } from './color.mjs';
|
|
7
|
+
import { warn } from './util.mjs';
|
|
8
|
+
import { removeGeneratedSlashCommands } from './claude-commands.mjs';
|
|
9
|
+
|
|
10
|
+
// Subdirectories scaffolded under docsRoot and tracked separately during scans.
|
|
11
|
+
// Each maps to a builtin type (plan, prompt). New types added here should also
|
|
12
|
+
// have a matching builtin template so `runlist new <type>` lands files correctly.
|
|
13
|
+
const TYPE_SUBDIRS = ['plans', 'prompts'];
|
|
14
|
+
|
|
15
|
+
// Look for a `runlist hud` SessionStart hook already wired in either the project
|
|
16
|
+
// (.claude/settings{,.local}.json) or the user-global config (~/.claude/
|
|
17
|
+
// settings.json). User-global counts because Claude Code merges global hooks
|
|
18
|
+
// into every project — if the user has it wired globally, this project gets it
|
|
19
|
+
// for free and the init snippet would be noise. We only inspect — we do NOT
|
|
20
|
+
// mutate any file. Settings-merge logic is hostile to do silently (clobbering
|
|
21
|
+
// an existing SessionStart entry would surprise the user), so init just prints
|
|
22
|
+
// a paste-ready snippet when the hook isn't found.
|
|
23
|
+
function detectSessionStartHook(cwd) {
|
|
24
|
+
const candidates = [
|
|
25
|
+
path.join(cwd, '.claude', 'settings.json'),
|
|
26
|
+
path.join(cwd, '.claude', 'settings.local.json'),
|
|
27
|
+
path.join(os.homedir(), '.claude', 'settings.json'),
|
|
28
|
+
];
|
|
29
|
+
for (const file of candidates) {
|
|
30
|
+
if (!existsSync(file)) continue;
|
|
31
|
+
let parsed;
|
|
32
|
+
try { parsed = JSON.parse(readFileSync(file, 'utf8')); }
|
|
33
|
+
catch { continue; }
|
|
34
|
+
const sessionStart = parsed?.hooks?.SessionStart;
|
|
35
|
+
if (!Array.isArray(sessionStart)) continue;
|
|
36
|
+
for (const entry of sessionStart) {
|
|
37
|
+
const inner = Array.isArray(entry?.hooks) ? entry.hooks : [];
|
|
38
|
+
for (const hook of inner) {
|
|
39
|
+
if (typeof hook?.command === 'string' && /\b(?:runlist|dotmd)\s+hud\b/.test(hook.command)) {
|
|
40
|
+
const rel = file.startsWith(cwd) ? path.relative(cwd, file) : file;
|
|
41
|
+
return { wired: true, file: rel };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { wired: false };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const STARTER_CONFIG = `// runlist.config.mjs — document management configuration
|
|
50
|
+
// All exports are optional. See runlist.config.example.mjs for full reference.
|
|
51
|
+
|
|
52
|
+
export const root = 'docs';
|
|
53
|
+
|
|
54
|
+
export const index = {
|
|
55
|
+
path: 'docs/docs.md',
|
|
56
|
+
startMarker: '<!-- GENERATED:runlist:start -->',
|
|
57
|
+
endMarker: '<!-- GENERATED:runlist:end -->',
|
|
58
|
+
archivedLimit: 8,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Frontmatter fields graph / deps / unblocks / pickup's Related: resolver
|
|
62
|
+
// traverse. Defaults match what the built-in plan/doc/prompt templates scaffold.
|
|
63
|
+
// Add field names here (and to your templates) to track more relationships.
|
|
64
|
+
export const referenceFields = {
|
|
65
|
+
bidirectional: ['related_plans', 'related_docs'],
|
|
66
|
+
unidirectional: ['parent_plan', 'runlist'],
|
|
67
|
+
};
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
// A git repository cannot hold an empty directory, so scaffolding `docs/plans/`
|
|
71
|
+
// and `docs/prompts/` and stopping there means both vanish for the next clone —
|
|
72
|
+
// `runlist init` produced three committable files and two directories that existed
|
|
73
|
+
// only on the machine that ran it.
|
|
74
|
+
//
|
|
75
|
+
// plans/ gets a real sample rather than a keepfile: it is the one place a new user
|
|
76
|
+
// benefits from seeing the frontmatter shape before running `runlist new`. It is
|
|
77
|
+
// written `status: planned` so it sits quietly in the pipeline instead of posing
|
|
78
|
+
// as live work, and it says how to delete itself.
|
|
79
|
+
//
|
|
80
|
+
// prompts/ gets a keepfile instead, and cannot get a sample: the live queue is
|
|
81
|
+
// gitignored by the rule this same command writes, and a committed prompt at
|
|
82
|
+
// `status: pending` would be silently consumed by the next no-arg `runlist use`.
|
|
83
|
+
const SAMPLE_PLAN_NAME = 'example-plan.md';
|
|
84
|
+
const samplePlan = (today) => `---
|
|
85
|
+
type: plan
|
|
86
|
+
status: planned
|
|
87
|
+
created: ${today}
|
|
88
|
+
updated: ${today}
|
|
89
|
+
title: Example Plan
|
|
90
|
+
summary: A scaffolded sample showing what a runlist plan looks like — safe to delete.
|
|
91
|
+
current_state: "Scaffolded by \`runlist init\` as a shape reference. Nothing here is real work."
|
|
92
|
+
next_step: "Delete this file, or edit it into your first real plan."
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
# Example Plan
|
|
96
|
+
|
|
97
|
+
> A scaffolded sample showing what a runlist plan looks like — safe to delete.
|
|
98
|
+
|
|
99
|
+
## Problem
|
|
100
|
+
|
|
101
|
+
\`runlist init\` leaves this file behind so \`docs/plans/\` survives a clone (git cannot
|
|
102
|
+
track an empty directory) and so the frontmatter above has something to point at.
|
|
103
|
+
|
|
104
|
+
The fields that matter: \`status\` drives every listing, \`current_state\` and
|
|
105
|
+
\`next_step\` are what \`runlist briefing\` reads out, and \`updated\` drives staleness.
|
|
106
|
+
Never hand-edit \`status:\` — \`runlist set <status> <file>\` writes it, validates it
|
|
107
|
+
against the type, and runs the lifecycle hooks.
|
|
108
|
+
|
|
109
|
+
## Phases
|
|
110
|
+
|
|
111
|
+
- [ ] Delete this file: \`runlist archive docs/plans/${SAMPLE_PLAN_NAME}\`
|
|
112
|
+
- [ ] Write a real one: \`runlist new plan <name>\`
|
|
113
|
+
|
|
114
|
+
## Version History
|
|
115
|
+
|
|
116
|
+
- Scaffolded by \`runlist init\`.
|
|
117
|
+
`;
|
|
118
|
+
|
|
119
|
+
const STARTER_INDEX = `# Docs
|
|
120
|
+
|
|
121
|
+
<!-- GENERATED:runlist:start -->
|
|
122
|
+
|
|
123
|
+
_No docs yet. Run \`runlist list\` after creating your first document._
|
|
124
|
+
|
|
125
|
+
<!-- GENERATED:runlist:end -->
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
function scanExistingDocs(dir) {
|
|
129
|
+
const statuses = new Set();
|
|
130
|
+
const surfaces = new Set();
|
|
131
|
+
const modules = new Set();
|
|
132
|
+
const refFieldNames = new Set();
|
|
133
|
+
let docCount = 0;
|
|
134
|
+
// Files without a frontmatter block, OR with a block that's missing `type:`
|
|
135
|
+
// or `status:`. Surfaced by the init "bulk-tag hint" to point users at the
|
|
136
|
+
// command that can fix them all in one shot.
|
|
137
|
+
let untaggedCount = 0;
|
|
138
|
+
// Track files per top-level subdir under `dir` (e.g. plans/, prompts/, "")
|
|
139
|
+
// so callers can report what's already there — including files without frontmatter,
|
|
140
|
+
// which are otherwise invisible to detection.
|
|
141
|
+
const subdirCounts = {};
|
|
142
|
+
|
|
143
|
+
function bump(subdir, hasFrontmatter) {
|
|
144
|
+
if (!subdirCounts[subdir]) subdirCounts[subdir] = { withFrontmatter: 0, withoutFrontmatter: 0 };
|
|
145
|
+
if (hasFrontmatter) subdirCounts[subdir].withFrontmatter++;
|
|
146
|
+
else subdirCounts[subdir].withoutFrontmatter++;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function walk(d, topSubdir) {
|
|
150
|
+
let entries;
|
|
151
|
+
try { entries = readdirSync(d, { withFileTypes: true }); } catch (err) { warn(`Could not read ${d}: ${err.message}`); return; }
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
if (entry.isDirectory()) {
|
|
154
|
+
const nextTop = topSubdir === null ? entry.name : topSubdir;
|
|
155
|
+
walk(path.join(d, entry.name), nextTop);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (!entry.name.endsWith('.md')) continue;
|
|
159
|
+
let raw;
|
|
160
|
+
try { raw = readFileSync(path.join(d, entry.name), 'utf8'); } catch (err) { warn(`Could not read ${entry.name}: ${err.message}`); continue; }
|
|
161
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
162
|
+
const subdir = topSubdir ?? '';
|
|
163
|
+
if (!frontmatter) { bump(subdir, false); untaggedCount++; continue; }
|
|
164
|
+
bump(subdir, true);
|
|
165
|
+
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
166
|
+
docCount++;
|
|
167
|
+
const hasType = typeof parsed.type === 'string' && parsed.type.length > 0;
|
|
168
|
+
const hasStatus = typeof parsed.status === 'string' && parsed.status.length > 0;
|
|
169
|
+
if (!hasType || !hasStatus) untaggedCount++;
|
|
170
|
+
if (parsed.status) statuses.add(String(parsed.status).toLowerCase());
|
|
171
|
+
if (parsed.surface) surfaces.add(String(parsed.surface));
|
|
172
|
+
if (Array.isArray(parsed.surfaces)) parsed.surfaces.forEach(s => surfaces.add(String(s)));
|
|
173
|
+
if (parsed.module) modules.add(String(parsed.module));
|
|
174
|
+
if (Array.isArray(parsed.modules)) parsed.modules.forEach(m => modules.add(String(m)));
|
|
175
|
+
for (const [key, val] of Object.entries(parsed)) {
|
|
176
|
+
if (Array.isArray(val) && val.some(v => String(v).endsWith('.md'))) {
|
|
177
|
+
refFieldNames.add(key);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
walk(dir, null);
|
|
184
|
+
return { docCount, statuses, surfaces, modules, refFieldNames, subdirCounts, untaggedCount };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Count .md files (regardless of frontmatter) directly inside a single directory.
|
|
188
|
+
// Used to detect root-level plans/ or prompts/ siblings that aren't under docsRoot.
|
|
189
|
+
function countMarkdownFiles(dir) {
|
|
190
|
+
let withFrontmatter = 0;
|
|
191
|
+
let withoutFrontmatter = 0;
|
|
192
|
+
let entries;
|
|
193
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return { withFrontmatter, withoutFrontmatter }; }
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
196
|
+
let raw;
|
|
197
|
+
try { raw = readFileSync(path.join(dir, entry.name), 'utf8'); } catch { continue; }
|
|
198
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
199
|
+
if (frontmatter) withFrontmatter++; else withoutFrontmatter++;
|
|
200
|
+
}
|
|
201
|
+
return { withFrontmatter, withoutFrontmatter };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Sensible default stale thresholds (days) for statuses runlist recognizes, used
|
|
205
|
+
// only to scope the generated config's staleDays to detected statuses. Mirrors
|
|
206
|
+
// the global + per-type defaults in config.mjs DEFAULTS; the repo's own custom
|
|
207
|
+
// statuses are intentionally absent so we don't invent a threshold for vocab we
|
|
208
|
+
// don't understand.
|
|
209
|
+
const KNOWN_STALE_DAYS = {
|
|
210
|
+
'in-session': 1, active: 14, ready: 14, planned: 30, blocked: 30,
|
|
211
|
+
scoping: 30, paused: 3, awaiting: 14, draft: 30, review: 14, pending: 30,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
function generateDetectedConfig(scan, rootPath) {
|
|
215
|
+
const lines = [`// runlist.config.mjs — auto-detected from ${scan.docCount} existing docs`, ''];
|
|
216
|
+
lines.push(`export const root = '${rootPath}';`);
|
|
217
|
+
lines.push('');
|
|
218
|
+
|
|
219
|
+
const defaultOrder = ['active', 'ready', 'planned', 'scoping', 'blocked', 'reference', 'archived'];
|
|
220
|
+
const ordered = defaultOrder.filter(s => scan.statuses.has(s));
|
|
221
|
+
const extra = [...scan.statuses].filter(s => !defaultOrder.includes(s)).sort();
|
|
222
|
+
const allStatuses = [...ordered, ...extra];
|
|
223
|
+
if (allStatuses.length > 0) {
|
|
224
|
+
lines.push('export const statuses = {');
|
|
225
|
+
lines.push(` order: [${allStatuses.map(s => `'${s}'`).join(', ')}],`);
|
|
226
|
+
// Scope staleDays to the detected statuses. `statuses.staleDays` is a
|
|
227
|
+
// replace-key, so emitting it here stops the resolver from inheriting the
|
|
228
|
+
// default map (keyed by `ready`/`scoping`/… that this repo may not use) —
|
|
229
|
+
// which otherwise makes every command warn about statuses the user never
|
|
230
|
+
// wrote. Only statuses with a sensible known threshold get an entry;
|
|
231
|
+
// unrecognized ones (the repo's own vocab) are left for the user to tune.
|
|
232
|
+
const staleEntries = allStatuses.filter(s => s in KNOWN_STALE_DAYS);
|
|
233
|
+
if (staleEntries.length > 0) {
|
|
234
|
+
lines.push(' staleDays: {');
|
|
235
|
+
for (const s of staleEntries) lines.push(` '${s}': ${KNOWN_STALE_DAYS[s]},`);
|
|
236
|
+
lines.push(' },');
|
|
237
|
+
}
|
|
238
|
+
lines.push('};');
|
|
239
|
+
lines.push('');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (scan.surfaces.size > 0 || scan.modules.size > 0) {
|
|
243
|
+
lines.push('export const taxonomy = {');
|
|
244
|
+
// Emit the full detected set so every existing doc passes — taxonomy
|
|
245
|
+
// enforcement only flags values outside the list, and the scan collected
|
|
246
|
+
// all of them. New surfaces/modules added later warn until appended here.
|
|
247
|
+
if (scan.surfaces.size > 0)
|
|
248
|
+
lines.push(` surfaces: [${[...scan.surfaces].sort().map(s => `'${s}'`).join(', ')}],`);
|
|
249
|
+
if (scan.modules.size > 0)
|
|
250
|
+
lines.push(` modules: [${[...scan.modules].sort().map(m => `'${m}'`).join(', ')}],`);
|
|
251
|
+
lines.push('};');
|
|
252
|
+
lines.push('');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (scan.refFieldNames.size > 0) {
|
|
256
|
+
const names = [...scan.refFieldNames].sort();
|
|
257
|
+
lines.push('export const referenceFields = {');
|
|
258
|
+
lines.push(` bidirectional: [${names.map(n => `'${n}'`).join(', ')}],`);
|
|
259
|
+
lines.push(' unidirectional: [],');
|
|
260
|
+
lines.push('};');
|
|
261
|
+
lines.push('');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
lines.push('export const index = {');
|
|
265
|
+
lines.push(` path: '${rootPath}/docs.md',`);
|
|
266
|
+
lines.push(` startMarker: '<!-- GENERATED:runlist:start -->',`);
|
|
267
|
+
lines.push(` endMarker: '<!-- GENERATED:runlist:end -->',`);
|
|
268
|
+
lines.push(` snapshot: 'status',`);
|
|
269
|
+
lines.push('};');
|
|
270
|
+
lines.push('');
|
|
271
|
+
|
|
272
|
+
return lines.join('\n');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function runInit(cwd, config, opts = {}) {
|
|
276
|
+
const { dryRun = false } = opts;
|
|
277
|
+
const configPath = path.join(cwd, 'runlist.config.mjs');
|
|
278
|
+
const docsDir = path.join(cwd, 'docs');
|
|
279
|
+
const indexPath = path.join(docsDir, 'docs.md');
|
|
280
|
+
|
|
281
|
+
// Prefix every reported line during dry-run so the user can't mistake the
|
|
282
|
+
// preview for a real run. Without this, every write below would silently
|
|
283
|
+
// execute — runInit previously ignored the `--dry-run` flag entirely.
|
|
284
|
+
const dryTag = dryRun ? `${dim('[dry-run]')} ` : '';
|
|
285
|
+
|
|
286
|
+
process.stdout.write('\n');
|
|
287
|
+
|
|
288
|
+
const scan = existsSync(docsDir) ? scanExistingDocs(docsDir) : null;
|
|
289
|
+
|
|
290
|
+
if (existsSync(configPath)) {
|
|
291
|
+
process.stdout.write(` ${dryTag}${dim('exists')} ${path.basename(config.configPath)}\n`);
|
|
292
|
+
} else {
|
|
293
|
+
if (scan && scan.docCount > 0) {
|
|
294
|
+
if (!dryRun) writeFileSync(configPath, generateDetectedConfig(scan, 'docs'), 'utf8');
|
|
295
|
+
process.stdout.write(` ${dryTag}${green('create')} runlist.config.mjs (detected ${scan.docCount} docs)\n`);
|
|
296
|
+
} else {
|
|
297
|
+
if (!dryRun) writeFileSync(configPath, STARTER_CONFIG, 'utf8');
|
|
298
|
+
process.stdout.write(` ${dryTag}${green('create')} runlist.config.mjs\n`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (existsSync(docsDir)) {
|
|
303
|
+
process.stdout.write(` ${dryTag}${dim('exists')} docs/\n`);
|
|
304
|
+
} else {
|
|
305
|
+
if (!dryRun) mkdirSync(docsDir, { recursive: true });
|
|
306
|
+
process.stdout.write(` ${dryTag}${green('create')} docs/\n`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Inspect root-level siblings (e.g. ./plans/, ./prompts/) before scaffolding.
|
|
310
|
+
// If a sibling already holds content, skip creating the matching docs/<sub>/
|
|
311
|
+
// so we don't quietly create a parallel dir the user has to reconcile.
|
|
312
|
+
const siblingsWithContent = [];
|
|
313
|
+
for (const sub of TYPE_SUBDIRS) {
|
|
314
|
+
const siblingPath = path.join(cwd, sub);
|
|
315
|
+
if (!existsSync(siblingPath)) continue;
|
|
316
|
+
const c = countMarkdownFiles(siblingPath);
|
|
317
|
+
const total = c.withFrontmatter + c.withoutFrontmatter;
|
|
318
|
+
if (total > 0) siblingsWithContent.push({ sub, total });
|
|
319
|
+
}
|
|
320
|
+
const siblingSet = new Set(siblingsWithContent.map(s => s.sub));
|
|
321
|
+
|
|
322
|
+
// Scaffold the canonical type subdirs (docs/plans/, docs/prompts/) so the
|
|
323
|
+
// builtin `runlist new plan` / `runlist new prompt` templates land somewhere
|
|
324
|
+
// sensible without extra config.
|
|
325
|
+
for (const sub of TYPE_SUBDIRS) {
|
|
326
|
+
const subPath = path.join(docsDir, sub);
|
|
327
|
+
const counts = scan?.subdirCounts?.[sub];
|
|
328
|
+
const total = counts ? counts.withFrontmatter + counts.withoutFrontmatter : 0;
|
|
329
|
+
if (siblingSet.has(sub) && !existsSync(subPath)) {
|
|
330
|
+
process.stdout.write(` ${dryTag}${yellow('skip')} docs/${sub}/ (root-level ./${sub}/ already holds content)\n`);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (existsSync(subPath)) {
|
|
334
|
+
const detail = total > 0
|
|
335
|
+
? ` (${counts.withFrontmatter} runlist-tracked, ${counts.withoutFrontmatter} plain .md)`
|
|
336
|
+
: '';
|
|
337
|
+
process.stdout.write(` ${dryTag}${dim('exists')} docs/${sub}/${detail}\n`);
|
|
338
|
+
} else {
|
|
339
|
+
if (!dryRun) mkdirSync(subPath, { recursive: true });
|
|
340
|
+
process.stdout.write(` ${dryTag}${green('create')} docs/${sub}/\n`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Give the directory something git can carry — but only when it is genuinely
|
|
344
|
+
// empty. Re-running init over a populated tree must never drop a sample plan
|
|
345
|
+
// into someone's real estate.
|
|
346
|
+
const keeper = sub === 'plans'
|
|
347
|
+
? { file: SAMPLE_PLAN_NAME, body: samplePlan(new Date().toISOString().slice(0, 10)) }
|
|
348
|
+
: { file: '.gitkeep', body: '' };
|
|
349
|
+
const keeperPath = path.join(subPath, keeper.file);
|
|
350
|
+
const dirIsEmpty = !existsSync(subPath) || readdirSync(subPath).length === 0;
|
|
351
|
+
if (existsSync(keeperPath)) {
|
|
352
|
+
process.stdout.write(` ${dryTag}${dim('exists')} docs/${sub}/${keeper.file}\n`);
|
|
353
|
+
} else if (dirIsEmpty || dryRun) {
|
|
354
|
+
if (!dryRun) writeFileSync(keeperPath, keeper.body, 'utf8');
|
|
355
|
+
process.stdout.write(` ${dryTag}${green('create')} docs/${sub}/${keeper.file}\n`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (existsSync(indexPath)) {
|
|
360
|
+
process.stdout.write(` ${dryTag}${dim('exists')} docs/docs.md\n`);
|
|
361
|
+
} else {
|
|
362
|
+
if (!dryRun) writeFileSync(indexPath, STARTER_INDEX, 'utf8');
|
|
363
|
+
process.stdout.write(` ${dryTag}${green('create')} docs/docs.md\n`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (siblingsWithContent.length > 0) {
|
|
367
|
+
const list = siblingsWithContent
|
|
368
|
+
.map(({ sub, total }) => `${sub}/ (${total} .md file${total === 1 ? '' : 's'})`)
|
|
369
|
+
.join(', ');
|
|
370
|
+
const subs = siblingsWithContent.map(s => s.sub);
|
|
371
|
+
process.stdout.write(`\n ${yellow('notice')} found at repo root: ${list}\n`);
|
|
372
|
+
process.stdout.write(` these are NOT under docs/ and won't be tracked by the default config. Either:\n`);
|
|
373
|
+
for (const sub of subs) {
|
|
374
|
+
process.stdout.write(` • move into docs/: mv ./${sub}/* docs/${sub}/ && rmdir ./${sub}\n`);
|
|
375
|
+
}
|
|
376
|
+
process.stdout.write(` • or use a flat layout — set in runlist.config.mjs:\n`);
|
|
377
|
+
process.stdout.write(` export const root = [${subs.map(s => `'${s}'`).join(', ')}];\n`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// .gitignore: three rules.
|
|
381
|
+
//
|
|
382
|
+
// .runlist/ — session ownership records
|
|
383
|
+
// .dotmd/ — legacy state during the compatibility window
|
|
384
|
+
// <docs>/prompts/*.md — the LIVE saved-prompt queue
|
|
385
|
+
//
|
|
386
|
+
// The second one is load-bearing. Saved prompts are session-local by design and
|
|
387
|
+
// the workflow docs say never to commit them, but nothing enforced that: a plain
|
|
388
|
+
// `git add -A` from any session swept the queue into the repo. Scoped with a
|
|
389
|
+
// single `*` so it stops at the directory — `prompts/archived/` is the committed
|
|
390
|
+
// historical record and must stay tracked. Anchored with a leading `/` so a
|
|
391
|
+
// `prompts/` directory elsewhere in the tree is unaffected.
|
|
392
|
+
const gitignorePath = path.join(cwd, '.gitignore');
|
|
393
|
+
const docsRel = path.relative(cwd, docsDir).split(path.sep).join('/');
|
|
394
|
+
const promptsIgnore = `/${docsRel ? `${docsRel}/` : ''}prompts/*.md`;
|
|
395
|
+
const ignoreRules = [
|
|
396
|
+
{ line: '.runlist/', accepts: (l) => l === '.runlist/' || l === '.runlist' },
|
|
397
|
+
{ line: '.dotmd/', accepts: (l) => l === '.dotmd/' || l === '.dotmd' },
|
|
398
|
+
{ line: promptsIgnore, accepts: (l) => l === promptsIgnore || l === promptsIgnore.slice(1) },
|
|
399
|
+
];
|
|
400
|
+
if (existsSync(gitignorePath)) {
|
|
401
|
+
const current = readFileSync(gitignorePath, 'utf8');
|
|
402
|
+
const present = new Set(current.split('\n').map(l => l.trim()));
|
|
403
|
+
const missing = ignoreRules.filter(r => ![...present].some(l => r.accepts(l)));
|
|
404
|
+
if (missing.length) {
|
|
405
|
+
const sep = current.endsWith('\n') ? '' : '\n';
|
|
406
|
+
const added = missing.map(r => `${r.line}\n`).join('');
|
|
407
|
+
if (!dryRun) writeFileSync(gitignorePath, `${current}${sep}${added}`, 'utf8');
|
|
408
|
+
process.stdout.write(` ${dryTag}${green('update')} .gitignore (+${missing.map(r => r.line).join(', ')})\n`);
|
|
409
|
+
} else {
|
|
410
|
+
process.stdout.write(` ${dryTag}${dim('exists')} .gitignore\n`);
|
|
411
|
+
}
|
|
412
|
+
} else {
|
|
413
|
+
if (!dryRun) writeFileSync(gitignorePath, ignoreRules.map(r => `${r.line}\n`).join(''), 'utf8');
|
|
414
|
+
process.stdout.write(` ${dryTag}${green('create')} .gitignore\n`);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Warn when docs/ is gitignored — silently scaffolding into an ignored dir
|
|
418
|
+
// means every doc we manage falls outside git, which a doc-management tool
|
|
419
|
+
// should not leave the user guessing about. Three of gmax's six docs were
|
|
420
|
+
// force-added; the other three were untracked and the user had no way to
|
|
421
|
+
// know without `git ls-files docs/`.
|
|
422
|
+
if (existsSync(path.join(cwd, '.git'))) {
|
|
423
|
+
const probe = spawnSync('git', ['check-ignore', '-q', 'docs/'], {
|
|
424
|
+
cwd, encoding: 'utf8',
|
|
425
|
+
});
|
|
426
|
+
// Exit 0 → ignored. Exit 1 → not ignored. Exit 128 → not in repo / git error.
|
|
427
|
+
if (probe.status === 0) {
|
|
428
|
+
process.stdout.write(`\n ${yellow('notice')} docs/ is gitignored — files runlist manages will NOT be tracked.\n`);
|
|
429
|
+
process.stdout.write(` Add an exception to .gitignore so docs/ is tracked:\n`);
|
|
430
|
+
process.stdout.write(` !docs/\n`);
|
|
431
|
+
process.stdout.write(` Or run: echo '!docs/' >> .gitignore\n`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Bulk-tag hint — when init found pre-existing .md files without
|
|
436
|
+
// type/status, point at the command that can tag them in one shot. Init's
|
|
437
|
+
// job here is discovery; the per-file detail lives in `bulk-tag --dry-run`.
|
|
438
|
+
if (scan?.untaggedCount > 0) {
|
|
439
|
+
const n = scan.untaggedCount;
|
|
440
|
+
const noun = n === 1 ? 'file' : 'files';
|
|
441
|
+
process.stdout.write(`\n ${yellow('hint')} ${n} untagged .md ${noun} found — run \`runlist bulk-tag --dry-run\` to preview tagging.\n`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Claude Code integration. runlist no longer scaffolds per-repo
|
|
445
|
+
// `.claude/commands/*.md` slash commands — the runlist plugin's SKILL.md is the
|
|
446
|
+
// canonical agent-facing workflow now, and `runlist hud` injects this repo's
|
|
447
|
+
// status vocab at runtime.
|
|
448
|
+
const hasProjectClaude = existsSync(path.join(cwd, '.claude'));
|
|
449
|
+
// A project `.claude/` proves it; a user-global `~/.claude/` means they run
|
|
450
|
+
// Claude Code elsewhere, so the plugin nudge is still relevant before this
|
|
451
|
+
// repo has any `.claude/` of its own (the common greenfield case).
|
|
452
|
+
const likelyClaudeUser = hasProjectClaude || existsSync(path.join(os.homedir(), '.claude'));
|
|
453
|
+
|
|
454
|
+
// If a `.claude/` exists, sweep any retired generated command files
|
|
455
|
+
// (banner-gated, so hand-authored ones survive).
|
|
456
|
+
if (hasProjectClaude) {
|
|
457
|
+
const removed = removeGeneratedSlashCommands(cwd, { dryRun });
|
|
458
|
+
for (const r of removed) {
|
|
459
|
+
const verb = dryRun ? 'would remove' : 'removed';
|
|
460
|
+
process.stdout.write(` ${dryTag}${yellow('clean')} .claude/commands/${r.name} (retired — ${verb}; guidance ships via the runlist plugin)\n`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (likelyClaudeUser) {
|
|
465
|
+
const sessionStart = detectSessionStartHook(cwd);
|
|
466
|
+
if (sessionStart.wired) {
|
|
467
|
+
process.stdout.write(` ${dim('exists')} ${sessionStart.file} (SessionStart hook for \`runlist hud\` already wired)\n`);
|
|
468
|
+
} else {
|
|
469
|
+
process.stdout.write(`\n ${yellow('hint')} install the runlist Claude Code plugin so its hooks + workflow skill\n`);
|
|
470
|
+
process.stdout.write(` travel to every session and subagent automatically:\n\n`);
|
|
471
|
+
process.stdout.write(` /plugin marketplace add reowens/runlist\n`);
|
|
472
|
+
process.stdout.write(` /plugin install runlist@runlist\n\n`);
|
|
473
|
+
process.stdout.write(` The plugin's hooks call \`runlist\` on your PATH, so install the CLI\n`);
|
|
474
|
+
process.stdout.write(` globally too — ${green('npm i -g runlist')} (a project devDependency won't power them).\n\n`);
|
|
475
|
+
process.stdout.write(` Or, without the plugin, wire \`runlist hud\` at SessionStart by hand —\n`);
|
|
476
|
+
process.stdout.write(` add to .claude/settings.json (merge into any existing hooks):\n\n`);
|
|
477
|
+
process.stdout.write(` "hooks": { "SessionStart": [\n`);
|
|
478
|
+
process.stdout.write(` { "hooks": [{ "type": "command", "command": "runlist hud" }] }\n`);
|
|
479
|
+
process.stdout.write(` ] }\n`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Render the index against what we just scaffolded.
|
|
484
|
+
//
|
|
485
|
+
// `docs.md` ships a "no docs yet" placeholder, which stopped being true the
|
|
486
|
+
// moment this command also started writing a sample plan — so a fresh init
|
|
487
|
+
// committed an index that contradicted the tree beside it, and the user's first
|
|
488
|
+
// `runlist check` opened with a stale-index warning. The config passed into
|
|
489
|
+
// runInit predates the file we just wrote, so re-resolve before rendering.
|
|
490
|
+
//
|
|
491
|
+
// Best-effort: a repo that scaffolds correctly but cannot render its index is
|
|
492
|
+
// still a successful init, and the next command regenerates it anyway.
|
|
493
|
+
if (!dryRun) {
|
|
494
|
+
try {
|
|
495
|
+
const { resolveConfig } = await import('./config.mjs');
|
|
496
|
+
const freshConfig = await resolveConfig(cwd);
|
|
497
|
+
if (freshConfig.indexPath) {
|
|
498
|
+
const { buildIndex } = await import('./index.mjs');
|
|
499
|
+
const { writeRenderedIndex } = await import('./index-file.mjs');
|
|
500
|
+
writeRenderedIndex(() => buildIndex(freshConfig, { fast: true }), freshConfig);
|
|
501
|
+
}
|
|
502
|
+
} catch {
|
|
503
|
+
// Leave the placeholder; `runlist check` self-heals it on first run.
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
process.stdout.write(`\nReady. A few starting points:\n`);
|
|
508
|
+
process.stdout.write(` runlist new doc my-doc # scaffold a reference doc\n`);
|
|
509
|
+
process.stdout.write(` runlist new plan my-plan # scaffold an execution plan\n`);
|
|
510
|
+
process.stdout.write(` runlist list # see what you've got\n`);
|
|
511
|
+
process.stdout.write(` runlist hud # session-start triage\n\n`);
|
|
512
|
+
}
|