runlist 0.99.1 → 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.
Files changed (58) hide show
  1. package/GETTING_STARTED.md +249 -0
  2. package/README.md +433 -51
  3. package/bin/runlist.mjs +404 -53
  4. package/package.json +3 -1
  5. package/plugins/runlist-codex/.codex-plugin/plugin.json +1 -1
  6. package/plugins/runlist-codex/bin/consume-prompt.mjs +45 -0
  7. package/plugins/runlist-codex/bin/runlist-hook +29 -11
  8. package/plugins/runlist-codex/bin/session-start.mjs +16 -0
  9. package/plugins/runlist-codex/bin/subagent-start.mjs +16 -0
  10. package/plugins/runlist-codex/hooks/hooks.json +11 -0
  11. package/plugins/runlist-codex/skills/runlist/SKILL.md +27 -11
  12. package/plugins/runlist-codex/skills/runlist-setup/SKILL.md +109 -0
  13. package/runlist.config.example.mjs +6 -0
  14. package/src/agent-context.mjs +8 -2
  15. package/src/agent-hosts.mjs +305 -0
  16. package/src/app-create.mjs +11 -1
  17. package/src/atomic-mutation.mjs +140 -35
  18. package/src/baton.mjs +127 -33
  19. package/src/codex-integration.mjs +57 -7
  20. package/src/commands.mjs +92 -38
  21. package/src/config-command.mjs +115 -0
  22. package/src/config-setting.mjs +163 -0
  23. package/src/config.mjs +57 -12
  24. package/src/doctor-hosts.mjs +192 -0
  25. package/src/doctor.mjs +16 -6
  26. package/src/export.mjs +11 -0
  27. package/src/git.mjs +58 -2
  28. package/src/guard.mjs +41 -13
  29. package/src/handoff-record.mjs +290 -30
  30. package/src/handoffs-help.mjs +143 -0
  31. package/src/handoffs-mode.mjs +486 -0
  32. package/src/handoffs-scope.mjs +98 -0
  33. package/src/help.mjs +72 -0
  34. package/src/host-integration.mjs +7 -1
  35. package/src/hud.mjs +174 -29
  36. package/src/index.mjs +11 -4
  37. package/src/init.mjs +255 -160
  38. package/src/install.mjs +35 -18
  39. package/src/installation.mjs +110 -27
  40. package/src/journal.mjs +2 -2
  41. package/src/lifecycle.mjs +22 -11
  42. package/src/managed-path.mjs +68 -4
  43. package/src/migrate.mjs +7 -0
  44. package/src/naming.mjs +26 -0
  45. package/src/new.mjs +202 -91
  46. package/src/pickup-card.mjs +1 -1
  47. package/src/plan-changes.mjs +49 -0
  48. package/src/prompt-handoff.mjs +16 -0
  49. package/src/prompts.mjs +101 -27
  50. package/src/rename.mjs +13 -0
  51. package/src/setup-carry.mjs +291 -0
  52. package/src/setup-waiting.mjs +20 -0
  53. package/src/setup-worker.mjs +257 -0
  54. package/src/setup.mjs +404 -0
  55. package/src/statuses.mjs +5 -2
  56. package/src/update.mjs +239 -79
  57. package/src/use.mjs +4 -2
  58. package/src/validate.mjs +7 -0
@@ -0,0 +1,257 @@
1
+ // Setup runs existing verbs in fresh processes: config imports cannot retain a
2
+ // policy from before a narrow edit, and verb exit codes cannot poison previews.
3
+ import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { resolveConfig } from './config.mjs';
6
+ import { runInit } from './init.mjs';
7
+ import { runConfig } from './config-command.mjs';
8
+ import { runBulkTag } from './bulk-tag.mjs';
9
+ import { runNew, catchAllRoot, promptDirectory, titleize } from './new.mjs';
10
+ import { collectDocFiles, buildIndex, parseDocFile } from './index.mjs';
11
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
12
+ import { authorizeManagedSource, authorizeRepoGeneratedPath } from './managed-path.mjs';
13
+ import { readTemplateStore } from './template-store.mjs';
14
+ import { regenIndex, renderLifecycleMutation } from './lifecycle.mjs';
15
+ import { snapshotFile, replaceSnapshot, inspectTransactions, recoverAbandonedTransactions, transactionResolutionAdvice } from './atomic-mutation.mjs';
16
+ import { planSetupCarry, applySetupCarry, cleanupSetupSourceDirectories, preConfigView, unmanagedCarryReferences } from './setup-carry.mjs';
17
+ import { planHandoffsUpgrade } from './handoffs-mode.mjs';
18
+ import { SETUP_PROMPT_ROUTING } from './naming.mjs';
19
+
20
+ function regularOrMissing(file, directory = false) {
21
+ let stat;
22
+ try { stat = lstatSync(file); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
23
+ if (stat.isSymbolicLink() || !(directory ? stat.isDirectory() : stat.isFile())) {
24
+ throw new Error(`Setup destination must be a regular ${directory ? 'directory' : 'file'}: ${file}`);
25
+ }
26
+ }
27
+
28
+ function firstPlan(config, choices) {
29
+ if (!choices.firstPlan) return null;
30
+ const custom = config.raw.templates?.plan;
31
+ if (typeof custom === 'function' || custom?.frontmatter || custom?.body || readTemplateStore(config).data.templates.plan) {
32
+ throw new Error('Setup cannot safely preview a custom plan template. Preserve it and create your plan separately with runlist new plan.');
33
+ }
34
+ const template = { dir: 'plans', targetRoot: 'plans', ...custom };
35
+ const routed = template.targetRoot && config.docsRoots.find(root => root.endsWith(template.targetRoot) || path.basename(root) === template.targetRoot);
36
+ const directory = routed || path.join(catchAllRoot(config), template.dir || '');
37
+ const file = path.join(directory, `${choices.firstPlan}.md`);
38
+ authorizeRepoGeneratedPath(file, config, { kind: 'Setup first-plan destination' });
39
+ if (!config.docsRoots.some(root => file.startsWith(root + path.sep) || path.dirname(file) === root)) {
40
+ throw new Error(`First-plan destination is outside configured roots: ${file}`);
41
+ }
42
+ if (existsSync(file)) throw new Error(`First plan already exists: ${file}. Inspect it with runlist show ${path.relative(config.repoRoot, file)}, choose another slug, or omit --first-plan.`);
43
+ const statuses = config.typeStatuses.get('plan') ?? config.validStatuses;
44
+ const status = statuses.has('planned') ? 'planned' : [...statuses][0];
45
+ const title = titleize(choices.firstPlan);
46
+ const body = `---\nsummary: ${JSON.stringify(choices.currentState)}\ncurrent_state: ${JSON.stringify(choices.currentState)}\nnext_step: ${JSON.stringify(choices.nextStep)}\n---\n# ${title}\n\n## Problem\n\n${choices.currentState}\n\n## Next step\n\n${choices.nextStep}\n`;
47
+ const today = new Date().toISOString().slice(0, 10);
48
+ const source = `---\ntype: plan\nstatus: ${status}\ncreated: ${today}\nupdated: ${today}\nsummary: ${JSON.stringify(choices.currentState)}\ncurrent_state: ${JSON.stringify(choices.currentState)}\nnext_step: ${JSON.stringify(choices.nextStep)}\n---\n# ${title}\n\n## Problem\n\n${choices.currentState}\n\n## Next step\n\n${choices.nextStep}\n`;
49
+ const doc = parseDocFile(file, config, { source });
50
+ if (doc.errors.length || doc.warnings.length) {
51
+ throw new Error(`First-plan validation failed before writing:\n${[...doc.errors, ...doc.warnings].map(issue => issue.message).join('\n')}`);
52
+ }
53
+ return { path: file, status, body, argv: ['plan', path.relative(config.repoRoot, file), '--status', status, '--body', body], checks: { errors: doc.errors, warnings: doc.warnings } };
54
+ }
55
+
56
+ function scaffolding(config, existing, choices, moves = []) {
57
+ const planTemplate = { dir: 'plans', targetRoot: 'plans', ...config.raw.templates?.plan };
58
+ const routed = planTemplate.targetRoot && config.docsRoots.find(root => root.endsWith(planTemplate.targetRoot) || path.basename(root) === planTemplate.targetRoot);
59
+ const plansDir = routed || path.join(catchAllRoot(config), planTemplate.dir || '');
60
+ const typeDirs = [['plans', plansDir], ['prompts', promptDirectory(config)]].filter(([sub, target]) => {
61
+ const sibling = path.join(config.repoRoot, sub);
62
+ if (existsSync(target) || config.docsRoots.some(root => sibling === root || sibling.startsWith(root + path.sep)) || !existsSync(sibling)) return true;
63
+ // Match init's refusal to create a parallel type directory beside an
64
+ // already populated root-level plans/ or prompts/ directory.
65
+ return moves.some(move => path.dirname(move.target) === target) || !readdirSync(sibling, { withFileTypes: true }).some(entry => entry.isFile() && entry.name.endsWith('.md'));
66
+ });
67
+ const dirs = [...config.docsRoots, ...typeDirs.map(([, target]) => target)];
68
+ const files = existing ? [] : [config.configPath];
69
+ const markers = [];
70
+ for (const [i, dir] of dirs.entries()) {
71
+ // Existing configured symlink roots are valid for read commands, but setup
72
+ // does not extend them implicitly or publish generated files through them.
73
+ authorizeRepoGeneratedPath(dir, config, { kind: 'Setup folder' });
74
+ regularOrMissing(dir, true);
75
+ if (i < config.docsRoots.length) continue;
76
+ if ((!existsSync(dir) || readdirSync(dir).length === 0) && !moves.some(move => move.target.startsWith(dir + path.sep))) {
77
+ markers.push(path.join(dir, '.gitkeep'));
78
+ }
79
+ }
80
+ if (config.indexPath) files.push(config.indexPath);
81
+ files.push(...markers);
82
+ for (const file of files) {
83
+ authorizeRepoGeneratedPath(file, config, { kind: 'Setup scaffold destination' });
84
+ regularOrMissing(file);
85
+ }
86
+ const existingSample = path.join(plansDir, 'example-plan.md');
87
+ return { folders: [...new Set(dirs)], files, sample: existsSync(existingSample) ? existingSample : null, gitignore: choices.gitignore ? path.join(config.repoRoot, '.gitignore') : null };
88
+ }
89
+
90
+ function adoption(config, file, { allowMissingRoots = false } = {}) {
91
+ if (file === config.indexPath) throw new Error(`Cannot adopt the generated index: ${file}`);
92
+ authorizeManagedSource(file, config, { kind: 'Setup adoption source', allowMissingRoots });
93
+ const rel = path.relative(config.docsRoots.find(root => file.startsWith(root + path.sep)) ?? config.docsRoot, file);
94
+ if (rel.split(path.sep).includes(config.archiveDir)) throw new Error(`Cannot adopt an archived document: ${file}`);
95
+ let captured = '';
96
+ const saved = process.stdout.write;
97
+ try {
98
+ process.stdout.write = chunk => { captured += chunk; return true; };
99
+ runBulkTag([file, '--json'], config, { dryRun: true });
100
+ } finally { process.stdout.write = saved; }
101
+ const inferred = JSON.parse(captured).candidates[0];
102
+ const source = readFileSync(file, 'utf8');
103
+ const before = extractFrontmatter(source).frontmatter;
104
+ const parsed = parseSimpleFrontmatter(before);
105
+ const updates = { ...inferred?.updates };
106
+ const type = inferred?.newType ?? parsed.type;
107
+ const status = inferred?.newStatus ?? parsed.status;
108
+ const valid = config.typeStatuses.get(type) ?? config.validStatuses;
109
+ if (!config.validTypes.has(type) || !valid.has(status)) throw new Error(`Adoption has invalid type/status: ${file} (${type}/${status}). Tag it explicitly with runlist bulk-tag first.`);
110
+ // Adoption modifies the file today. This date is factual; its original
111
+ // creation date and authored summary/state are deliberately not invented.
112
+ if (!parsed.updated || (Array.isArray(parsed.updated) && !parsed.updated.length)) updates.updated = new Date().toISOString().slice(0, 10);
113
+ const serialized = Object.fromEntries(Object.entries(updates).map(([key, value]) => [key, /^[a-z][a-z0-9-]*$|^\d{4}-\d{2}-\d{2}$/.test(value) && !['true', 'false', 'null'].includes(value) ? value : JSON.stringify(value)]));
114
+ let content = source;
115
+ if (Object.keys(updates).length) {
116
+ const normalized = source.replace(/\r\n/g, '\n');
117
+ content = normalized.startsWith('---\n')
118
+ ? renderLifecycleMutation(normalized, serialized, null)
119
+ : `---\n${Object.entries(serialized).map(([key, value]) => `${key}: ${value}`).join('\n')}\n---\n${normalized}`;
120
+ if (source.includes('\r\n')) content = content.replace(/\n/g, '\r\n');
121
+ }
122
+ const doc = parseDocFile(file, config, { source: content });
123
+ if (doc.errors.length) throw new Error(`Selected adoption failed validation before writing: ${file}\n${doc.errors.map(issue => issue.message).join('\n')}\nResolve this document explicitly before setup.`);
124
+ return { path: file, candidate: Object.keys(updates).length ? { ...inferred, newType: type, newStatus: status, updates } : null,
125
+ beforeFrontmatter: before ?? '', afterFrontmatter: extractFrontmatter(content).frontmatter ?? '',
126
+ checks: { errors: doc.errors, warnings: doc.warnings }, source, content };
127
+ }
128
+
129
+ function inventory(config) {
130
+ return collectDocFiles(config).filter(file => {
131
+ const { frontmatter } = extractFrontmatter(readFileSync(file, 'utf8'));
132
+ const parsed = parseSimpleFrontmatter(frontmatter);
133
+ return !parsed.type || !parsed.status;
134
+ });
135
+ }
136
+
137
+ export async function setupWorker(request) {
138
+ const { operation, cwd, configPath, choices } = request;
139
+ let config = await resolveConfig(cwd, configPath || undefined);
140
+ const before = config;
141
+ const existing = Boolean(config.configPath && existsSync(config.configPath));
142
+ // Handoffs-only config: every stage before the rewrite sees the upgraded
143
+ // layout through overrides, so the preview is what applying will do. Its
144
+ // prompts are already under the config's root; plans and prompts an older
145
+ // runlist saved before any config (plans/, the root prompts/) are carried
146
+ // as a fresh setup carries them, under the roots the project had before any
147
+ // config, and before the config is rewritten.
148
+ const upgrade = existing && config.mode === 'handoffs' ? planHandoffsUpgrade(config) : null;
149
+ const carryFrom = upgrade ? await preConfigView(config.repoRoot) : before;
150
+ if (operation === 'carry') return applySetupCarry(request.moves, carryFrom, upgrade ? { expectedConfig: { path: config.configPath, content: request.expectedSource } } : {});
151
+ if (operation === 'recover') {
152
+ let recoveryError;
153
+ const pending = inspectTransactions(carryFrom.repoRoot, { config: carryFrom });
154
+ let recovered = [];
155
+ try { recovered = recoverAbandonedTransactions(carryFrom.repoRoot, { config: carryFrom }); }
156
+ catch (error) { recoveryError = error; }
157
+ const remaining = inspectTransactions(carryFrom.repoRoot, { config: carryFrom });
158
+ if (remaining.length || recoveryError) {
159
+ const advice = remaining.map(item => `${item.id}: ${transactionResolutionAdvice(item)}`).join('\n');
160
+ throw new Error(`Pre-config transaction recovery failed: ${advice || recoveryError.message}. ${upgrade ? 'The config was not changed.' : 'No config was written.'}`);
161
+ }
162
+ const movedSources = recovered.filter(item => item.result === 'rolled-forward').flatMap(item =>
163
+ pending.find(transaction => transaction.id === item.id)?.participants?.filter(participant =>
164
+ participant.label === 'source' && !existsSync(participant.path)).map(participant => participant.path) ?? []);
165
+ cleanupSetupSourceDirectories(carryFrom, movedSources);
166
+ return null;
167
+ }
168
+ if (operation === 'upgrade') {
169
+ if (!upgrade) throw new Error('The config is no longer a handoffs-only config; rerun setup.');
170
+ const snapshot = snapshotFile(config.configPath);
171
+ if (snapshot.content !== request.expectedSource) throw new Error('Config changed since preview; rerun setup.');
172
+ replaceSnapshot(snapshot, upgrade.content, { repoRoot: config.repoRoot });
173
+ return { path: config.configPath };
174
+ }
175
+ if (upgrade) config = await resolveConfig(cwd, configPath || undefined, { overrides: upgrade.overrides });
176
+ if (!existing) {
177
+ const root = path.resolve(cwd, choices.docsRoot || 'docs');
178
+ config = { ...config, repoRoot: cwd, configDir: cwd, configPath: path.join(cwd, 'runlist.config.mjs'), configFound: false,
179
+ docsRoot: root, docsRoots: [root], indexPath: path.join(root, 'docs.md'), docsRootPrefix: path.relative(cwd, root),
180
+ raw: { ...config.raw, root: choices.docsRoot || 'docs', promptTracking: choices.promptTracking,
181
+ templates: { ...config.raw.templates, prompt: { ...config.raw.templates?.prompt, ...SETUP_PROMPT_ROUTING } } }, promptTracking: choices.promptTracking };
182
+ }
183
+ config._execution = { ...config._execution, suppressSideEffects: true, gitStaleness: false };
184
+ const initArgv = [ ...(choices.gitignore ? ['--gitignore'] : []), ...(!existing ? ['--prompt-tracking', choices.promptTracking] : []) ];
185
+ if (operation === 'prepare') {
186
+ const carry = planSetupCarry(carryFrom, config, choices.carryPrompts);
187
+ // Links the carry leaves alone (Markdown runlist does not manage, and what
188
+ // stays in the root prompts/), listed for the user to update.
189
+ carry.references = unmanagedCarryReferences(carryFrom, carry.moves);
190
+ const layout = scaffolding(config, existing, choices, carry.moves);
191
+ let policy = null;
192
+ if (existing && choices.selectPromptTracking) {
193
+ policy = await runConfig(['set', 'promptTracking', choices.promptTracking], config, { dryRun: true });
194
+ config = { ...config, promptTracking: choices.promptTracking, raw: { ...config.raw, promptTracking: choices.promptTracking } };
195
+ }
196
+ // Init sees the proposed policy for ignore planning, but never edits the
197
+ // existing config. The narrow setter owns that separate apply stage.
198
+ await runInit(config.repoRoot, config, { argv: initArgv, dryRun: true, guidance: false, samplePlan: false, carryMoves: carry.moves, handoffsUpgrade: Boolean(upgrade), ...(!existing ? { docsRoot: choices.docsRoot } : {}) });
199
+ const untagged = inventory(config);
200
+ // What turning planning on brings into view: Markdown under docs/ that the
201
+ // handoffs config did not manage. A walk, not a read: outside the
202
+ // prompt folder nothing is opened (inside it, files are read for their
203
+ // type, as every handoffs-mode command does).
204
+ let newlyListed = null;
205
+ if (upgrade) {
206
+ const managed = new Set(collectDocFiles(before));
207
+ const files = collectDocFiles(config).filter(file => !managed.has(file));
208
+ newlyListed = { count: files.length, root: `${upgrade.root}/` };
209
+ }
210
+ const adoptions = [];
211
+ for (const selected of choices.adopt) {
212
+ const file = path.resolve(config.repoRoot, selected);
213
+ const { source, content, ...preview } = adoption(config, file, { allowMissingRoots: true });
214
+ adoptions.push(preview);
215
+ }
216
+ const plan = firstPlan(config, choices);
217
+ if (plan && carry.moves.some(move => move.target === plan.path)) throw new Error(`First plan collides with a pre-setup plan being carried over: ${plan.path}`);
218
+ const transactions = existing && !upgrade ? [] : inspectTransactions(carryFrom.repoRoot, { config: carryFrom });
219
+ return { layout, policy, adoptions, firstPlan: plan, untagged, carry, transactions,
220
+ upgrade: upgrade ? { source: upgrade.source, content: upgrade.content, promptRoot: upgrade.promptRoot, newlyListed } : null, skippedValidationHooks: ['validate', 'transformDoc'].filter(name => typeof config.hooks?.[name] === 'function') };
221
+ }
222
+ if (operation === 'init') {
223
+ // Check again after carry, including when there were no moves. Never let
224
+ // a retained, live, deferred or failed-manual manifest cross the root change.
225
+ if (!existing) await setupWorker({ ...request, operation: 'recover' });
226
+ // A policy change is applied first; this new process now resolves it.
227
+ // Setup's own carry has run (or was turned off), so init carries nothing.
228
+ await runInit(config.repoRoot, config, { argv: initArgv, guidance: false, samplePlan: false, carryMoves: [], handoffsUpgrade: Boolean(upgrade), ...(!existing ? { docsRoot: choices.docsRoot } : {}) });
229
+ } else if (operation === 'policy') {
230
+ return runConfig(['set', 'promptTracking', choices.promptTracking], config);
231
+ } else if (operation === 'adopt') {
232
+ const proposed = adoption(config, request.path);
233
+ if (proposed.source !== request.expectedSource) throw new Error(`Adoption source changed after preview: ${request.path}`);
234
+ const snapshot = snapshotFile(request.path);
235
+ if (snapshot.content !== proposed.source) throw new Error(`Adoption source changed during validation: ${request.path}`);
236
+ replaceSnapshot(snapshot, proposed.content, { repoRoot: config.repoRoot, testHooks: { beforeReplacePublish() {
237
+ authorizeManagedSource(request.path, config, { kind: 'Setup adoption source' });
238
+ } } });
239
+ return { path: request.path, checks: proposed.checks };
240
+ } else if (operation === 'plan') {
241
+ const plan = firstPlan(config, choices);
242
+ await runNew(plan.argv, config, { deferIndex: true });
243
+ const checked = parseDocFile(plan.path, config);
244
+ if (checked.errors.length || checked.warnings.length) throw new Error(`Created first plan has validation findings: ${[...checked.errors, ...checked.warnings].map(issue => issue.message).join('; ')}`);
245
+ return { path: plan.path, errors: checked.errors, warnings: checked.warnings };
246
+ } else if (operation === 'index') {
247
+ regenIndex(config, { throwOnError: true });
248
+ } else if (operation === 'check') {
249
+ const index = buildIndex(config, { invokeHooks: false, gitStaleness: false, autoHealIndex: false });
250
+ return { errors: index.errors, warnings: index.warnings, untagged: inventory(config), firstPlan: choices.firstPlan && existsSync(request.planPath) ? (() => {
251
+ const file = request.planPath;
252
+ const doc = parseDocFile(file, config);
253
+ return { path: file, errors: doc.errors, warnings: doc.warnings };
254
+ })() : null };
255
+ } else throw new Error(`Unknown setup operation: ${operation}`);
256
+ return null;
257
+ }