runlist 0.99.2 → 0.100.0
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/GETTING_STARTED.md +249 -0
- package/README.md +414 -46
- package/bin/runlist.mjs +402 -51
- package/package.json +3 -1
- package/plugins/runlist-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/runlist-codex/bin/consume-prompt.mjs +45 -0
- package/plugins/runlist-codex/bin/runlist-hook +29 -11
- package/plugins/runlist-codex/bin/session-start.mjs +16 -0
- package/plugins/runlist-codex/bin/subagent-start.mjs +16 -0
- package/plugins/runlist-codex/hooks/hooks.json +11 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +27 -11
- package/plugins/runlist-codex/skills/runlist-setup/SKILL.md +109 -0
- package/runlist.config.example.mjs +6 -0
- package/src/agent-context.mjs +8 -2
- package/src/agent-hosts.mjs +305 -0
- package/src/app-create.mjs +11 -1
- package/src/atomic-mutation.mjs +140 -35
- package/src/baton.mjs +127 -33
- package/src/codex-integration.mjs +57 -7
- package/src/commands.mjs +92 -38
- package/src/config-command.mjs +115 -0
- package/src/config-setting.mjs +163 -0
- package/src/config.mjs +57 -12
- package/src/doctor-hosts.mjs +192 -0
- package/src/doctor.mjs +16 -6
- package/src/export.mjs +11 -0
- package/src/git.mjs +58 -2
- package/src/guard.mjs +41 -13
- package/src/handoff-record.mjs +290 -30
- package/src/handoffs-help.mjs +143 -0
- package/src/handoffs-mode.mjs +486 -0
- package/src/handoffs-scope.mjs +98 -0
- package/src/help.mjs +72 -0
- package/src/host-integration.mjs +7 -1
- package/src/hud.mjs +174 -29
- package/src/index.mjs +11 -4
- package/src/init.mjs +255 -160
- package/src/install.mjs +35 -18
- package/src/installation.mjs +110 -27
- package/src/journal.mjs +2 -2
- package/src/lifecycle.mjs +22 -11
- package/src/managed-path.mjs +68 -4
- package/src/migrate.mjs +7 -0
- package/src/naming.mjs +26 -0
- package/src/new.mjs +202 -91
- package/src/pickup-card.mjs +1 -1
- package/src/plan-changes.mjs +49 -0
- package/src/prompt-handoff.mjs +16 -0
- package/src/prompts.mjs +101 -27
- package/src/rename.mjs +13 -0
- package/src/setup-carry.mjs +291 -0
- package/src/setup-waiting.mjs +20 -0
- package/src/setup-worker.mjs +257 -0
- package/src/setup.mjs +404 -0
- package/src/statuses.mjs +5 -2
- package/src/update.mjs +239 -79
- package/src/use.mjs +4 -2
- package/src/validate.mjs +7 -0
package/src/init.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { appendFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
6
6
|
import { green, dim, yellow } from './color.mjs';
|
|
7
|
-
import { warn } from './util.mjs';
|
|
8
|
-
import {
|
|
7
|
+
import { die, warn } from './util.mjs';
|
|
8
|
+
import { catchAllRoot, promptDirectory } from './new.mjs';
|
|
9
|
+
import { authorizeRepoGeneratedPath, inLegacyPromptFolder, legacyPromptFolderPlace } from './managed-path.mjs';
|
|
10
|
+
import { LEGACY_PROMPT_DIR } from './naming.mjs';
|
|
9
11
|
|
|
10
12
|
// Subdirectories scaffolded under docsRoot and tracked separately during scans.
|
|
11
13
|
// Each maps to a builtin type (plan, prompt). New types added here should also
|
|
@@ -50,6 +52,7 @@ const STARTER_CONFIG = `// runlist.config.mjs — document management configurat
|
|
|
50
52
|
// All exports are optional. See runlist.config.example.mjs for full reference.
|
|
51
53
|
|
|
52
54
|
export const root = 'docs';
|
|
55
|
+
export const promptTracking = 'untracked';
|
|
53
56
|
|
|
54
57
|
export const index = {
|
|
55
58
|
path: 'docs/docs.md',
|
|
@@ -77,11 +80,10 @@ export const referenceFields = {
|
|
|
77
80
|
// written `status: planned` so it sits quietly in the pipeline instead of posing
|
|
78
81
|
// as live work, and it says how to delete itself.
|
|
79
82
|
//
|
|
80
|
-
// prompts/ gets a keepfile instead
|
|
81
|
-
//
|
|
82
|
-
// `status: pending` would be silently consumed by the next no-arg `runlist use`.
|
|
83
|
+
// prompts/ gets a keepfile instead of a live sample, which could otherwise be
|
|
84
|
+
// consumed by the next no-arg `runlist use`. Ignore rules are opt-in.
|
|
83
85
|
const SAMPLE_PLAN_NAME = 'example-plan.md';
|
|
84
|
-
const samplePlan = (today) => `---
|
|
86
|
+
const samplePlan = (today, planPath) => `---
|
|
85
87
|
type: plan
|
|
86
88
|
status: planned
|
|
87
89
|
created: ${today}
|
|
@@ -98,7 +100,7 @@ next_step: "Delete this file, or edit it into your first real plan."
|
|
|
98
100
|
|
|
99
101
|
## Problem
|
|
100
102
|
|
|
101
|
-
\`runlist init\` leaves this file behind so
|
|
103
|
+
\`runlist init\` leaves this file behind so \`${path.posix.dirname(planPath)}/\` survives a clone (git cannot
|
|
102
104
|
track an empty directory) and so the frontmatter above has something to point at.
|
|
103
105
|
|
|
104
106
|
The fields that matter: \`status\` drives every listing, \`current_state\` and
|
|
@@ -108,7 +110,7 @@ against the type, and runs the lifecycle hooks.
|
|
|
108
110
|
|
|
109
111
|
## Phases
|
|
110
112
|
|
|
111
|
-
- [ ] Delete this file: \`runlist archive
|
|
113
|
+
- [ ] Delete this file: \`runlist archive ${planPath}\`
|
|
112
114
|
- [ ] Write a real one: \`runlist new plan <name>\`
|
|
113
115
|
|
|
114
116
|
## Version History
|
|
@@ -116,6 +118,16 @@ against the type, and runs the lifecycle hooks.
|
|
|
116
118
|
- Scaffolded by \`runlist init\`.
|
|
117
119
|
`;
|
|
118
120
|
|
|
121
|
+
// A config `runlist setup` writes spells out its prompt routing
|
|
122
|
+
// (SETUP_PROMPT_ROUTING in src/naming.mjs), so a root such as review-prompts/
|
|
123
|
+
// keeps its prompts in review-prompts/prompts/.
|
|
124
|
+
const SETUP_PROMPT_ROUTING_SOURCE = `
|
|
125
|
+
// Prompts are saved in the prompts/ folder inside the docs root.
|
|
126
|
+
export const templates = {
|
|
127
|
+
prompt: { dir: 'prompts', targetRoot: null },
|
|
128
|
+
};
|
|
129
|
+
`;
|
|
130
|
+
|
|
119
131
|
const STARTER_INDEX = `# Docs
|
|
120
132
|
|
|
121
133
|
<!-- GENERATED:runlist:start -->
|
|
@@ -125,7 +137,8 @@ _No docs yet. Run \`runlist list\` after creating your first document._
|
|
|
125
137
|
<!-- GENERATED:runlist:end -->
|
|
126
138
|
`;
|
|
127
139
|
|
|
128
|
-
function scanExistingDocs(
|
|
140
|
+
function scanExistingDocs(roots, indexPath, excludeDirs = new Set()) {
|
|
141
|
+
const seen = new Set();
|
|
129
142
|
const statuses = new Set();
|
|
130
143
|
const surfaces = new Set();
|
|
131
144
|
const modules = new Set();
|
|
@@ -147,15 +160,18 @@ function scanExistingDocs(dir) {
|
|
|
147
160
|
}
|
|
148
161
|
|
|
149
162
|
function walk(d, topSubdir) {
|
|
163
|
+
if (seen.has(d)) return;
|
|
164
|
+
seen.add(d);
|
|
150
165
|
let entries;
|
|
151
166
|
try { entries = readdirSync(d, { withFileTypes: true }); } catch (err) { warn(`Could not read ${d}: ${err.message}`); return; }
|
|
152
167
|
for (const entry of entries) {
|
|
153
168
|
if (entry.isDirectory()) {
|
|
169
|
+
if (['.git', 'node_modules'].includes(entry.name) || excludeDirs.has(entry.name)) continue;
|
|
154
170
|
const nextTop = topSubdir === null ? entry.name : topSubdir;
|
|
155
171
|
walk(path.join(d, entry.name), nextTop);
|
|
156
172
|
continue;
|
|
157
173
|
}
|
|
158
|
-
if (!entry.name.endsWith('.md')) continue;
|
|
174
|
+
if (!entry.isFile() || !entry.name.endsWith('.md') || path.join(d, entry.name) === indexPath) continue;
|
|
159
175
|
let raw;
|
|
160
176
|
try { raw = readFileSync(path.join(d, entry.name), 'utf8'); } catch (err) { warn(`Could not read ${entry.name}: ${err.message}`); continue; }
|
|
161
177
|
const { frontmatter } = extractFrontmatter(raw);
|
|
@@ -180,19 +196,19 @@ function scanExistingDocs(dir) {
|
|
|
180
196
|
}
|
|
181
197
|
}
|
|
182
198
|
|
|
183
|
-
walk(dir, null);
|
|
199
|
+
for (const dir of roots) if (existsSync(dir)) walk(dir, null);
|
|
184
200
|
return { docCount, statuses, surfaces, modules, refFieldNames, subdirCounts, untaggedCount };
|
|
185
201
|
}
|
|
186
202
|
|
|
187
203
|
// Count .md files (regardless of frontmatter) directly inside a single directory.
|
|
188
204
|
// Used to detect root-level plans/ or prompts/ siblings that aren't under docsRoot.
|
|
189
|
-
function countMarkdownFiles(dir) {
|
|
205
|
+
function countMarkdownFiles(dir, excludeFiles = new Set()) {
|
|
190
206
|
let withFrontmatter = 0;
|
|
191
207
|
let withoutFrontmatter = 0;
|
|
192
208
|
let entries;
|
|
193
209
|
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return { withFrontmatter, withoutFrontmatter }; }
|
|
194
210
|
for (const entry of entries) {
|
|
195
|
-
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
211
|
+
if (!entry.isFile() || !entry.name.endsWith('.md') || excludeFiles.has(path.join(dir, entry.name))) continue;
|
|
196
212
|
let raw;
|
|
197
213
|
try { raw = readFileSync(path.join(dir, entry.name), 'utf8'); } catch { continue; }
|
|
198
214
|
const { frontmatter } = extractFrontmatter(raw);
|
|
@@ -211,9 +227,10 @@ const KNOWN_STALE_DAYS = {
|
|
|
211
227
|
scoping: 30, paused: 3, awaiting: 14, draft: 30, review: 14, pending: 30,
|
|
212
228
|
};
|
|
213
229
|
|
|
214
|
-
function generateDetectedConfig(scan, rootPath) {
|
|
230
|
+
function generateDetectedConfig(scan, rootPath, promptTracking, setupRouting = false) {
|
|
215
231
|
const lines = [`// runlist.config.mjs — auto-detected from ${scan.docCount} existing docs`, ''];
|
|
216
|
-
lines.push(`export const root =
|
|
232
|
+
lines.push(`export const root = ${rootPath === 'docs' ? "'docs'" : JSON.stringify(rootPath)};`);
|
|
233
|
+
lines.push(`export const promptTracking = '${promptTracking}';`);
|
|
217
234
|
lines.push('');
|
|
218
235
|
|
|
219
236
|
const defaultOrder = ['active', 'ready', 'planned', 'scoping', 'blocked', 'reference', 'archived'];
|
|
@@ -262,180 +279,250 @@ function generateDetectedConfig(scan, rootPath) {
|
|
|
262
279
|
}
|
|
263
280
|
|
|
264
281
|
lines.push('export const index = {');
|
|
265
|
-
lines.push(` path:
|
|
282
|
+
lines.push(` path: ${rootPath === 'docs' ? "'docs/docs.md'" : JSON.stringify(path.posix.join(rootPath, 'docs.md'))},`);
|
|
266
283
|
lines.push(` startMarker: '<!-- GENERATED:runlist:start -->',`);
|
|
267
284
|
lines.push(` endMarker: '<!-- GENERATED:runlist:end -->',`);
|
|
268
285
|
lines.push(` snapshot: 'status',`);
|
|
269
286
|
lines.push('};');
|
|
270
287
|
lines.push('');
|
|
271
288
|
|
|
272
|
-
return lines.join('\n');
|
|
289
|
+
return lines.join('\n') + (setupRouting ? SETUP_PROMPT_ROUTING_SOURCE : '');
|
|
273
290
|
}
|
|
274
291
|
|
|
275
292
|
export async function runInit(cwd, config, opts = {}) {
|
|
276
|
-
const { dryRun = false } = opts;
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
293
|
+
const { dryRun = false, argv = [] } = opts;
|
|
294
|
+
let requestedPolicy;
|
|
295
|
+
for (let i = 0; i < argv.length; i++) {
|
|
296
|
+
if (argv[i] === '--prompt-tracking' || argv[i].startsWith('--prompt-tracking=')) {
|
|
297
|
+
requestedPolicy = argv[i] === '--prompt-tracking' ? argv[++i] : argv[i].slice('--prompt-tracking='.length);
|
|
298
|
+
if (!['untracked', 'tracked', 'local'].includes(requestedPolicy)) {
|
|
299
|
+
die('--prompt-tracking must be one of: untracked, tracked, local.');
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const existingConfig = Boolean(config.configPath && existsSync(config.configPath));
|
|
304
|
+
// An existing planning config gets init as it always was. A new project, and
|
|
305
|
+
// a handoffs-only one setup is turning planning on in (`handoffsUpgrade`,
|
|
306
|
+
// internal), keep prompts out of the root prompts/ (src/naming.mjs).
|
|
307
|
+
const planningConfig = existingConfig && !opts.handoffsUpgrade;
|
|
308
|
+
// Internal setup input; the public init argv contract stays unchanged.
|
|
309
|
+
const selectedRoot = opts.docsRoot ?? 'docs';
|
|
310
|
+
if (typeof selectedRoot !== 'string' || !selectedRoot.trim() || path.isAbsolute(selectedRoot)
|
|
311
|
+
|| /[\r\n\0\\]/.test(selectedRoot)
|
|
312
|
+
|| selectedRoot.split('/').includes('..')) {
|
|
313
|
+
die('docsRoot must be a relative project path without traversal.');
|
|
314
|
+
}
|
|
315
|
+
if (existingConfig && opts.docsRoot !== undefined
|
|
316
|
+
&& (config.docsRoots ?? [config.docsRoot]).some(root => path.resolve(config.repoRoot, selectedRoot) !== root)) {
|
|
317
|
+
die('Existing configured roots are preserved. Edit the root export manually to change the docs layout.');
|
|
318
|
+
}
|
|
319
|
+
const promptTracking = existingConfig ? (config.promptTracking ?? config.raw?.promptTracking ?? 'local') : (requestedPolicy ?? 'untracked');
|
|
320
|
+
if (!['untracked', 'tracked', 'local'].includes(promptTracking)) {
|
|
321
|
+
die('promptTracking must be one of: untracked, tracked, local.');
|
|
322
|
+
}
|
|
323
|
+
if (existingConfig && requestedPolicy !== undefined && requestedPolicy !== promptTracking) {
|
|
324
|
+
die(`Existing config preserved; requested policy was not applied. Use: runlist config set promptTracking ${requestedPolicy}. For a symlinked or computed config, edit the promptTracking export manually.`);
|
|
325
|
+
}
|
|
289
326
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
327
|
+
// Existing configs own their layout, including flat root arrays and an absent
|
|
328
|
+
// index. A fresh project gets the starter docs/ layout rather than the
|
|
329
|
+
// resolver's config-less root (which is the whole working directory).
|
|
330
|
+
const projectRoot = existingConfig ? config.repoRoot : cwd;
|
|
331
|
+
const configPath = existingConfig ? config.configPath : path.join(cwd, 'runlist.config.mjs');
|
|
332
|
+
const docsRoots = existingConfig ? (config.docsRoots ?? [config.docsRoot]) : [path.resolve(cwd, selectedRoot)];
|
|
333
|
+
const indexPath = existingConfig ? config.indexPath : path.join(docsRoots[0], 'docs.md');
|
|
334
|
+
const layout = existingConfig ? config : { ...config, repoRoot: cwd, docsRoot: docsRoots[0], docsRoots };
|
|
335
|
+
const label = file => path.relative(projectRoot, file).split(path.sep).join('/') || '.';
|
|
336
|
+
const dryTag = dryRun ? `${dim('[dry-run]')} ` : '';
|
|
337
|
+
const planTemplate = { dir: 'plans', targetRoot: 'plans', ...config.raw?.templates?.plan };
|
|
338
|
+
const planRoot = planTemplate.targetRoot && docsRoots.find(root => root.endsWith(planTemplate.targetRoot) || path.basename(root) === planTemplate.targetRoot);
|
|
339
|
+
const plansDir = planRoot || (planTemplate.dir ? path.join(catchAllRoot(layout), planTemplate.dir) : catchAllRoot(layout));
|
|
340
|
+
const promptsDir = promptDirectory(layout);
|
|
341
|
+
const typeDirs = new Map([['plans', plansDir], ['prompts', promptsDir]]);
|
|
342
|
+
|
|
343
|
+
// A new project's folders never lead into the root prompts/ older builds
|
|
344
|
+
// saved prompts in (a docs/ symlinked there, say): nothing new is written
|
|
345
|
+
// there (src/naming.mjs). Checked before anything is carried or written.
|
|
346
|
+
if (!existingConfig) {
|
|
347
|
+
for (const destination of [...docsRoots, ...typeDirs.values()]) {
|
|
348
|
+
if (!inLegacyPromptFolder(destination, projectRoot)) continue;
|
|
349
|
+
die(`Nothing initialized: ${label(destination)}/ ${legacyPromptFolderPlace(destination, projectRoot)}, and runlist no longer writes there. Change the link that leads there, then rerun.`);
|
|
299
350
|
}
|
|
300
351
|
}
|
|
301
352
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
353
|
+
// Preflight opt-in ignore writes before ANY scaffolding. Refuse unsafe
|
|
354
|
+
// destinations and ambiguous patterns rather than appending a misleading or
|
|
355
|
+
// broader rule. Never follow a .gitignore symlink, including a dangling one.
|
|
356
|
+
let ignorePlan = null;
|
|
357
|
+
if (argv.includes('--gitignore')) {
|
|
358
|
+
const gitignorePath = path.join(projectRoot, '.gitignore');
|
|
359
|
+
authorizeRepoGeneratedPath(gitignorePath, layout, { kind: 'Init .gitignore destination' });
|
|
360
|
+
const exists = existsSync(gitignorePath);
|
|
361
|
+
if (exists && !lstatSync(gitignorePath).isFile()) die('Init .gitignore destination must be a regular file.');
|
|
362
|
+
const ignoreRules = [
|
|
363
|
+
{ line: '.runlist/', accepts: l => l === '.runlist/' || l === '.runlist' },
|
|
364
|
+
{ line: '.dotmd/', accepts: l => l === '.dotmd/' || l === '.dotmd' },
|
|
365
|
+
];
|
|
366
|
+
if (promptTracking !== 'tracked') {
|
|
367
|
+
authorizeRepoGeneratedPath(promptsDir, layout, { kind: 'Prompt ignore directory' });
|
|
368
|
+
const promptsRel = path.relative(projectRoot, promptsDir).split(path.sep).join('/');
|
|
369
|
+
if (/[\r\n\0\\*?\[\]]/.test(promptsRel)) {
|
|
370
|
+
die('Cannot add a prompt ignore rule: the configured prompt path contains a newline or Git pattern characters.');
|
|
371
|
+
}
|
|
372
|
+
const promptsIgnore = `/${promptsRel ? promptsRel + '/' : ''}*.md`;
|
|
373
|
+
ignoreRules.push({ line: promptsIgnore, accepts: l => l === promptsIgnore || l === promptsIgnore.slice(1) });
|
|
374
|
+
}
|
|
375
|
+
const current = exists ? readFileSync(gitignorePath, 'utf8') : '';
|
|
376
|
+
const present = current.split(/\r?\n/).map(l => l.trim());
|
|
377
|
+
const missing = ignoreRules.filter(rule => !present.some(rule.accepts));
|
|
378
|
+
ignorePlan = { gitignorePath, exists, current, missing };
|
|
307
379
|
}
|
|
380
|
+
const scan = scanExistingDocs(docsRoots, indexPath, config.excludeDirs);
|
|
381
|
+
|
|
382
|
+
// Setup plans and runs its own carry and passes its moves. A new project
|
|
383
|
+
// started by init carries the prompts an older runlist saved in the root
|
|
384
|
+
// prompts/ into its prompt folder itself, with setup's transactional carry,
|
|
385
|
+
// before its config is written: refused on a collision with nothing
|
|
386
|
+
// written, and listed by --dry-run (src/handoffs-mode.mjs).
|
|
387
|
+
let initCarry = null;
|
|
388
|
+
if (!existingConfig && opts.carryMoves === undefined) {
|
|
389
|
+
const { planInitCarry } = await import('./handoffs-mode.mjs');
|
|
390
|
+
initCarry = await planInitCarry(projectRoot, config, layout, { dryRun, out: process.stdout });
|
|
391
|
+
if (initCarry.previewStopped) return;
|
|
392
|
+
}
|
|
393
|
+
const carryMoves = opts.carryMoves ?? initCarry?.carry.moves ?? [];
|
|
308
394
|
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
// so we don't quietly create a parallel dir the user has to reconcile.
|
|
395
|
+
// Detect content outside the selected roots before creating parallel type
|
|
396
|
+
// directories. Offer a config choice; never move or delete anyone's files.
|
|
312
397
|
const siblingsWithContent = [];
|
|
313
398
|
for (const sub of TYPE_SUBDIRS) {
|
|
314
|
-
const siblingPath = path.join(
|
|
315
|
-
if (
|
|
316
|
-
const
|
|
317
|
-
const total =
|
|
399
|
+
const siblingPath = path.join(projectRoot, sub);
|
|
400
|
+
if (docsRoots.some(root => siblingPath === root || siblingPath.startsWith(root + path.sep))) continue;
|
|
401
|
+
const counts = countMarkdownFiles(siblingPath, new Set(carryMoves.map(move => move.source)));
|
|
402
|
+
const total = counts.withFrontmatter + counts.withoutFrontmatter;
|
|
318
403
|
if (total > 0) siblingsWithContent.push({ sub, total });
|
|
319
404
|
}
|
|
320
405
|
const siblingSet = new Set(siblingsWithContent.map(s => s.sub));
|
|
321
406
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
407
|
+
if (initCarry) {
|
|
408
|
+
const { carryLegacyPrompts } = await import('./handoffs-mode.mjs');
|
|
409
|
+
await carryLegacyPrompts(initCarry, { dryRun, out: process.stdout, nothing: 'Nothing initialized' });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
process.stdout.write('\n');
|
|
413
|
+
if (existingConfig) {
|
|
414
|
+
process.stdout.write(` ${dryTag}${dim('exists')} ${label(configPath)}\n`);
|
|
415
|
+
} else {
|
|
416
|
+
const body = scan.docCount > 0
|
|
417
|
+
? generateDetectedConfig(scan, selectedRoot, promptTracking, opts.docsRoot !== undefined)
|
|
418
|
+
// Replacement callbacks: a `$&` or `$'` in a chosen folder name is text, not a pattern.
|
|
419
|
+
: STARTER_CONFIG.replace("root = 'docs'", () => `root = ${opts.docsRoot === undefined ? "'docs'" : JSON.stringify(selectedRoot)}`)
|
|
420
|
+
.replace("path: 'docs/docs.md'", () => `path: ${opts.docsRoot === undefined ? "'docs/docs.md'" : JSON.stringify(path.posix.join(selectedRoot, 'docs.md'))}`)
|
|
421
|
+
.replace("promptTracking = 'untracked'", () => `promptTracking = '${promptTracking}'`)
|
|
422
|
+
+ (opts.docsRoot === undefined ? '' : SETUP_PROMPT_ROUTING_SOURCE);
|
|
423
|
+
if (!dryRun) writeFileSync(configPath, body, 'utf8');
|
|
424
|
+
const detail = scan.docCount > 0 ? ` (detected ${scan.docCount} docs)` : '';
|
|
425
|
+
process.stdout.write(` ${dryTag}${green('create')} runlist.config.mjs${detail}\n`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// A root that is also a type folder (a handoffs project's prompts/ kept as a
|
|
429
|
+
// second root) is listed once, below, with its document counts.
|
|
430
|
+
const typePaths = new Set(typeDirs.values());
|
|
431
|
+
for (const dir of docsRoots.filter(root => !typePaths.has(root))) {
|
|
432
|
+
const exists = existsSync(dir);
|
|
433
|
+
if (!exists && !dryRun) mkdirSync(dir, { recursive: true });
|
|
434
|
+
process.stdout.write(` ${dryTag}${exists ? dim('exists') : green('create')} ${label(dir)}/\n`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
for (const [sub, subPath] of typeDirs) {
|
|
438
|
+
// Prompts never go to the root prompts/ outside an existing planning
|
|
439
|
+
// config, so the prompt folder is made whatever is left there.
|
|
440
|
+
if (siblingSet.has(sub) && (planningConfig || sub !== LEGACY_PROMPT_DIR) && !existsSync(subPath) && !carryMoves.some(move => path.dirname(move.target) === subPath)) {
|
|
441
|
+
process.stdout.write(` ${dryTag}${yellow('skip')} ${label(subPath)}/ (root-level ./${sub}/ already holds content)\n`);
|
|
331
442
|
continue;
|
|
332
443
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
|
|
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)) }
|
|
444
|
+
const exists = existsSync(subPath);
|
|
445
|
+
const counts = countMarkdownFiles(subPath);
|
|
446
|
+
const total = counts.withFrontmatter + counts.withoutFrontmatter;
|
|
447
|
+
const detail = total > 0 ? ` (${counts.withFrontmatter} runlist-tracked, ${counts.withoutFrontmatter} plain .md)` : '';
|
|
448
|
+
if (!exists && !dryRun) mkdirSync(subPath, { recursive: true });
|
|
449
|
+
process.stdout.write(` ${dryTag}${exists ? dim('exists') : green('create')} ${label(subPath)}/${detail}\n`);
|
|
450
|
+
|
|
451
|
+
const keeper = sub === 'plans' && opts.samplePlan !== false
|
|
452
|
+
? { file: SAMPLE_PLAN_NAME, body: samplePlan(new Date().toISOString().slice(0, 10), label(path.join(subPath, SAMPLE_PLAN_NAME))) }
|
|
348
453
|
: { file: '.gitkeep', body: '' };
|
|
349
454
|
const keeperPath = path.join(subPath, keeper.file);
|
|
350
|
-
const dirIsEmpty = !existsSync(subPath) || readdirSync(subPath).length === 0;
|
|
455
|
+
const dirIsEmpty = (!existsSync(subPath) || readdirSync(subPath).length === 0) && !carryMoves.some(move => move.target.startsWith(subPath + path.sep));
|
|
351
456
|
if (existsSync(keeperPath)) {
|
|
352
|
-
process.stdout.write(` ${dryTag}${dim('exists')}
|
|
353
|
-
} else if (dirIsEmpty
|
|
457
|
+
process.stdout.write(` ${dryTag}${dim('exists')} ${label(keeperPath)}\n`);
|
|
458
|
+
} else if (dirIsEmpty) {
|
|
354
459
|
if (!dryRun) writeFileSync(keeperPath, keeper.body, 'utf8');
|
|
355
|
-
process.stdout.write(` ${dryTag}${green('create')}
|
|
460
|
+
process.stdout.write(` ${dryTag}${green('create')} ${label(keeperPath)}\n`);
|
|
356
461
|
}
|
|
357
462
|
}
|
|
358
463
|
|
|
359
|
-
if (
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
|
|
464
|
+
if (indexPath) {
|
|
465
|
+
if (existsSync(indexPath)) {
|
|
466
|
+
process.stdout.write(` ${dryTag}${dim('exists')} ${label(indexPath)}\n`);
|
|
467
|
+
} else {
|
|
468
|
+
const body = STARTER_INDEX
|
|
469
|
+
.replace('<!-- GENERATED:runlist:start -->', config.indexStartMarker ?? '<!-- GENERATED:runlist:start -->')
|
|
470
|
+
.replace('<!-- GENERATED:runlist:end -->', config.indexEndMarker ?? '<!-- GENERATED:runlist:end -->');
|
|
471
|
+
if (!dryRun) {
|
|
472
|
+
mkdirSync(path.dirname(indexPath), { recursive: true });
|
|
473
|
+
writeFileSync(indexPath, body, 'utf8');
|
|
474
|
+
}
|
|
475
|
+
process.stdout.write(` ${dryTag}${green('create')} ${label(indexPath)}\n`);
|
|
476
|
+
}
|
|
364
477
|
}
|
|
365
478
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
479
|
+
// The root prompts/ older builds saved prompts in is never suggested as a
|
|
480
|
+
// root outside an existing planning config (src/naming.mjs): its prompts
|
|
481
|
+
// were carried and whatever else is there is the user's. An existing
|
|
482
|
+
// planning config is told, as before, how to include it.
|
|
483
|
+
const sibling = ({ sub, total }) => `${sub}/ (${total} .md file${total === 1 ? '' : 's'})`;
|
|
484
|
+
const legacySibling = planningConfig ? null : siblingsWithContent.find(({ sub }) => sub === LEGACY_PROMPT_DIR);
|
|
485
|
+
if (legacySibling) process.stdout.write(`\n ${yellow('notice')} ${sibling(legacySibling)} is left untouched: runlist keeps prompts in ${label(promptsDir)}/ and does not read or write ${LEGACY_PROMPT_DIR}/.\n`);
|
|
486
|
+
const otherSiblings = siblingsWithContent.filter(item => item !== legacySibling);
|
|
487
|
+
if (otherSiblings.length > 0) {
|
|
488
|
+
const list = otherSiblings.map(sibling).join(', ');
|
|
489
|
+
const roots = [...new Set([...docsRoots.map(label), ...otherSiblings.map(s => s.sub)])];
|
|
371
490
|
process.stdout.write(`\n ${yellow('notice')} found at repo root: ${list}\n`);
|
|
372
|
-
process.stdout.write(`
|
|
373
|
-
|
|
374
|
-
|
|
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`);
|
|
491
|
+
process.stdout.write(` These are outside the configured roots. To include them without moving files,\n`);
|
|
492
|
+
process.stdout.write(` choose the roots in ${label(configPath)}, for example:\n`);
|
|
493
|
+
process.stdout.write(` export const root = ${JSON.stringify(roots)};\n`);
|
|
378
494
|
}
|
|
379
495
|
|
|
380
|
-
|
|
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)));
|
|
496
|
+
if (ignorePlan) {
|
|
497
|
+
const { gitignorePath, exists, current, missing } = ignorePlan;
|
|
404
498
|
if (missing.length) {
|
|
405
|
-
const
|
|
406
|
-
const
|
|
407
|
-
if (!dryRun)
|
|
408
|
-
|
|
499
|
+
const newline = current.includes('\r\n') ? '\r\n' : '\n';
|
|
500
|
+
const sep = current.length === 0 || current.endsWith('\n') ? '' : newline;
|
|
501
|
+
if (!dryRun) {
|
|
502
|
+
authorizeRepoGeneratedPath(gitignorePath, layout, { kind: 'Init .gitignore destination' });
|
|
503
|
+
appendFileSync(gitignorePath, sep + missing.map(rule => rule.line + newline).join(''), 'utf8');
|
|
504
|
+
}
|
|
505
|
+
process.stdout.write(` ${dryTag}${green(exists ? 'update' : 'create')} .gitignore (+${missing.map(rule => rule.line).join(', ')})\n`);
|
|
409
506
|
} else {
|
|
410
507
|
process.stdout.write(` ${dryTag}${dim('exists')} .gitignore\n`);
|
|
411
508
|
}
|
|
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
509
|
}
|
|
416
510
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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`);
|
|
511
|
+
if (existsSync(path.join(projectRoot, '.git'))) {
|
|
512
|
+
for (const dir of docsRoots) {
|
|
513
|
+
const rel = `${label(dir)}/`;
|
|
514
|
+
const probe = spawnSync('git', ['check-ignore', '-q', '--', rel], { cwd: projectRoot, encoding: 'utf8' });
|
|
515
|
+
if (probe.status === 0) {
|
|
516
|
+
process.stdout.write(`\n ${yellow('notice')} ${rel} is gitignored — files runlist manages will NOT be tracked.\n`);
|
|
517
|
+
process.stdout.write(` Review the existing ignore rules if you want Git to track this root.\n`);
|
|
518
|
+
}
|
|
432
519
|
}
|
|
433
520
|
}
|
|
434
521
|
|
|
435
522
|
// Bulk-tag hint — when init found pre-existing .md files without
|
|
436
523
|
// type/status, point at the command that can tag them in one shot. Init's
|
|
437
524
|
// job here is discovery; the per-file detail lives in `bulk-tag --dry-run`.
|
|
438
|
-
if (scan?.untaggedCount > 0) {
|
|
525
|
+
if (opts.guidance !== false && scan?.untaggedCount > 0) {
|
|
439
526
|
const n = scan.untaggedCount;
|
|
440
527
|
const noun = n === 1 ? 'file' : 'files';
|
|
441
528
|
process.stdout.write(`\n ${yellow('hint')} ${n} untagged .md ${noun} found — run \`runlist bulk-tag --dry-run\` to preview tagging.\n`);
|
|
@@ -451,17 +538,7 @@ export async function runInit(cwd, config, opts = {}) {
|
|
|
451
538
|
// repo has any `.claude/` of its own (the common greenfield case).
|
|
452
539
|
const likelyClaudeUser = hasProjectClaude || existsSync(path.join(os.homedir(), '.claude'));
|
|
453
540
|
|
|
454
|
-
|
|
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) {
|
|
541
|
+
if (opts.guidance !== false && likelyClaudeUser) {
|
|
465
542
|
const sessionStart = detectSessionStartHook(cwd);
|
|
466
543
|
if (sessionStart.wired) {
|
|
467
544
|
process.stdout.write(` ${dim('exists')} ${sessionStart.file} (SessionStart hook for \`runlist hud\` already wired)\n`);
|
|
@@ -493,7 +570,7 @@ export async function runInit(cwd, config, opts = {}) {
|
|
|
493
570
|
if (!dryRun) {
|
|
494
571
|
try {
|
|
495
572
|
const { resolveConfig } = await import('./config.mjs');
|
|
496
|
-
const freshConfig = await resolveConfig(cwd);
|
|
573
|
+
const freshConfig = await resolveConfig(cwd, configPath);
|
|
497
574
|
if (freshConfig.indexPath) {
|
|
498
575
|
const { buildIndex } = await import('./index.mjs');
|
|
499
576
|
const { writeRenderedIndex } = await import('./index-file.mjs');
|
|
@@ -504,9 +581,27 @@ export async function runInit(cwd, config, opts = {}) {
|
|
|
504
581
|
}
|
|
505
582
|
}
|
|
506
583
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
584
|
+
if (promptTracking === 'tracked') {
|
|
585
|
+
process.stdout.write('\n Prompt tracking: tracked — prompt Git operations are allowed.\n');
|
|
586
|
+
} else if (promptTracking === 'local') {
|
|
587
|
+
process.stdout.write('\n Prompt tracking: local — the guard denies prompt Git operations.\n');
|
|
588
|
+
process.stdout.write(' This policy does not itself add ignore rules; git add -A can still stage prompts that are not ignored.\n');
|
|
589
|
+
} else {
|
|
590
|
+
process.stdout.write('\n Prompt tracking: untracked. Leave pending prompts untracked to reduce commit churn.\n');
|
|
591
|
+
process.stdout.write(' `git add -A` can still stage any pending prompts that are not ignored.\n');
|
|
592
|
+
}
|
|
593
|
+
if (!argv.includes('--gitignore')) {
|
|
594
|
+
process.stdout.write(' Ignore files unchanged: runtime state in .runlist/ (and legacy .dotmd/) may also be staged by git add -A.\n');
|
|
595
|
+
process.stdout.write(' Review selected files, or preview explicit ignore rules with `runlist init --gitignore --dry-run`.\n');
|
|
596
|
+
}
|
|
597
|
+
if (opts.guidance !== false) {
|
|
598
|
+
process.stdout.write(dryRun
|
|
599
|
+
? '\nReady to initialize the project. Host integration is separate.\n'
|
|
600
|
+
: '\nReady. Project initialized. Host integration is separate.\n');
|
|
601
|
+
process.stdout.write('A few starting points:\n');
|
|
602
|
+
process.stdout.write(` runlist new doc my-doc # scaffold a reference doc\n`);
|
|
603
|
+
process.stdout.write(` runlist new plan my-plan # scaffold an execution plan\n`);
|
|
604
|
+
process.stdout.write(` runlist list # see what you've got\n`);
|
|
605
|
+
process.stdout.write(` runlist hud # session-start triage\n\n`);
|
|
606
|
+
}
|
|
512
607
|
}
|