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/setup.mjs
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
import { transactionResolutionAdvice } from './atomic-mutation.mjs';
|
|
7
|
+
import { which } from './util.mjs';
|
|
8
|
+
import { LEGACY_PROMPT_DIR, PROMPT_DIR } from './naming.mjs';
|
|
9
|
+
import { inLegacyPromptFolder, legacyPromptFolderPlace } from './managed-path.mjs';
|
|
10
|
+
import { persistentCliOnPath, PERSISTENT_CLI_WARNING } from './installation.mjs';
|
|
11
|
+
|
|
12
|
+
const workerUrl = new URL('./setup-worker.mjs', import.meta.url).href;
|
|
13
|
+
const bin = fileURLToPath(new URL('../bin/runlist.mjs', import.meta.url));
|
|
14
|
+
const policies = ['untracked', 'tracked', 'local'];
|
|
15
|
+
const hosts = ['none', 'claude', 'codex', 'opencode'];
|
|
16
|
+
const valueFlags = new Map([
|
|
17
|
+
['--docs-root', 'docsRoot'], ['--prompt-tracking', 'promptTracking'], ['--host', 'host'],
|
|
18
|
+
['--first-plan', 'firstPlan'], ['--current-state', 'currentState'], ['--next-step', 'nextStep'], ['--adopt', 'adopt'],
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
function parse(argv, config) {
|
|
22
|
+
const choices = { docsRoot: null, promptTracking: config.configFound ? config.promptTracking : 'untracked',
|
|
23
|
+
selectPromptTracking: false, gitignore: false, host: 'none', firstPlan: null, currentState: null, nextStep: null, adopt: [], carryPrompts: true };
|
|
24
|
+
const flags = { yes: false, json: false };
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const [flag, ...equals] = argv[i].split('=');
|
|
28
|
+
if (flag !== '--adopt' && seen.has(flag)) throw new Error(`Repeated setup flag: ${flag}`);
|
|
29
|
+
seen.add(flag);
|
|
30
|
+
if (flag === '--no-carry-prompts' && !equals.length) { choices.carryPrompts = false; continue; }
|
|
31
|
+
if (['--yes', '--json', '--gitignore'].includes(flag) && !equals.length) {
|
|
32
|
+
if (flag === '--gitignore') choices.gitignore = true;
|
|
33
|
+
else flags[flag.slice(2)] = true;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const key = valueFlags.get(flag);
|
|
37
|
+
if (!key) throw new Error(`Unknown setup flag: ${argv[i]}`);
|
|
38
|
+
const value = equals.length ? equals.join('=') : argv[++i];
|
|
39
|
+
if (!value || value.startsWith('--')) throw new Error(`${flag} needs a value.`);
|
|
40
|
+
if (key === 'adopt') choices.adopt.push(value);
|
|
41
|
+
else choices[key] = value;
|
|
42
|
+
if (key === 'promptTracking') choices.selectPromptTracking = true;
|
|
43
|
+
}
|
|
44
|
+
return { choices, flags, seen };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A handoffs-only config (written by the first baton) is upgraded, not
|
|
48
|
+
// preserved: setup rewrites that config's mode and root lines, widening the
|
|
49
|
+
// root from docs/prompts to docs so the prompts keep their folder
|
|
50
|
+
// (src/handoffs-mode.mjs).
|
|
51
|
+
function validate(choices, config, existing, upgrade = false, cwd = config.repoRoot) {
|
|
52
|
+
if (!policies.includes(choices.promptTracking)) throw new Error('--prompt-tracking must be untracked, tracked, or local.');
|
|
53
|
+
if (!hosts.includes(choices.host)) throw new Error('--host must be none, claude, codex, or opencode.');
|
|
54
|
+
if (choices.docsRoot !== null) {
|
|
55
|
+
if (!choices.docsRoot.trim() || path.isAbsolute(choices.docsRoot) || /[\r\n\0\\]/.test(choices.docsRoot) || choices.docsRoot.split('/').includes('..')) {
|
|
56
|
+
throw new Error('--docs-root must be a relative project path without traversal.');
|
|
57
|
+
}
|
|
58
|
+
if (existing && !upgrade && ((config.docsRoots ?? [config.docsRoot]).length !== 1 || path.resolve(config.repoRoot, choices.docsRoot) !== config.docsRoot)) {
|
|
59
|
+
throw new Error(`Existing configured roots are preserved. Edit the root export manually in ${config.configPath}, review the resulting layout with runlist init --dry-run, then rerun setup without --docs-root.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (upgrade && choices.docsRoot !== null && path.posix.normalize(choices.docsRoot).replace(/\/$/, '') !== 'docs') {
|
|
63
|
+
throw new Error(`This project's prompts are in ${PROMPT_DIR}/ and stay there when planning is turned on, so the docs root becomes docs/, the folder that holds them; --docs-root ${choices.docsRoot} would leave them outside it. Rerun setup without --docs-root.`);
|
|
64
|
+
}
|
|
65
|
+
if (!existing || upgrade) choices.docsRoot ??= 'docs';
|
|
66
|
+
// A new docs root saves prompts in <docs root>/prompts/, which may not be
|
|
67
|
+
// in the root prompts/, by path or through a symlinked folder (one prompt
|
|
68
|
+
// location, src/naming.mjs): `.`, `prompts` and anything below it refuse
|
|
69
|
+
// before anything is scaffolded.
|
|
70
|
+
const promptFolder = existing ? null : path.join(path.resolve(cwd, choices.docsRoot), 'prompts');
|
|
71
|
+
if (promptFolder && inLegacyPromptFolder(promptFolder, cwd)) {
|
|
72
|
+
const relative = path.relative(cwd, promptFolder).split(path.sep).join('/');
|
|
73
|
+
const where = relative === LEGACY_PROMPT_DIR ? `${LEGACY_PROMPT_DIR}/ at the project root` : `${relative}/, which ${legacyPromptFolderPlace(promptFolder, cwd)}`;
|
|
74
|
+
throw new Error(`--docs-root ${choices.docsRoot} would save prompts in ${where}; runlist no longer writes there. Choose another docs folder (the default, docs, keeps prompts in ${PROMPT_DIR}/).`);
|
|
75
|
+
}
|
|
76
|
+
if (choices.adopt.length > 100) throw new Error('Setup accepts at most 100 explicitly selected adoption files per run.');
|
|
77
|
+
choices.adopt = [...new Set(choices.adopt)];
|
|
78
|
+
for (const file of choices.adopt) {
|
|
79
|
+
if (/[\0\r\n*?\[\]]/.test(file)) throw new Error(`--adopt needs an exact Markdown file path, without globs: ${file}`);
|
|
80
|
+
}
|
|
81
|
+
if (choices.firstPlan) {
|
|
82
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(choices.firstPlan) || choices.firstPlan.length > 100) {
|
|
83
|
+
throw new Error('--first-plan needs a lowercase slug of at most 100 characters (letters, numbers, hyphens).');
|
|
84
|
+
}
|
|
85
|
+
for (const [key, flag, cap] of [['currentState', '--current-state', 1500], ['nextStep', '--next-step', 800]]) {
|
|
86
|
+
const value = choices[key]?.trim();
|
|
87
|
+
if (!value || value.length < 8 || !/[\p{L}\p{N}]/u.test(value) || /^(?:todo|tbd|n\/?a|unknown|placeholder|fill me|none|test)[.!?\s]*$/i.test(value)) {
|
|
88
|
+
throw new Error(`${flag} needs meaningful user-supplied text for --first-plan (at least 8 characters).`);
|
|
89
|
+
}
|
|
90
|
+
if (value.length > cap || /[\0\x01-\x08\x0b\x0c\x0e-\x1f]/.test(value)) throw new Error(`${flag} exceeds its ${cap} character cap or contains control characters.`);
|
|
91
|
+
choices[key] = value;
|
|
92
|
+
}
|
|
93
|
+
} else if (choices.currentState !== null || choices.nextStep !== null) throw new Error('--current-state and --next-step require --first-plan.');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function runWorker(request) {
|
|
97
|
+
const script = `import {readFileSync} from 'node:fs';
|
|
98
|
+
import {setupWorker} from ${JSON.stringify(workerUrl)};
|
|
99
|
+
const request=JSON.parse(readFileSync(0,'utf8'));
|
|
100
|
+
const write=process.stdout.write.bind(process.stdout); let output='';
|
|
101
|
+
process.stdout.write=chunk=>{output+=chunk;return true;};
|
|
102
|
+
try { const data=await setupWorker(request); write(JSON.stringify({ok:!process.exitCode,data,output,error:process.exitCode?'Command reported failure':null})); }
|
|
103
|
+
catch(error) { write(JSON.stringify({ok:false,output,error:error.message})); process.exitCode=1; }`;
|
|
104
|
+
const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
|
|
105
|
+
cwd: request.cwd, input: JSON.stringify(request), encoding: 'utf8', timeout: 120000, maxBuffer: 16 * 1024 * 1024,
|
|
106
|
+
env: { ...process.env, RUNLIST_JOURNAL: '0', DOTMD_JOURNAL: '0', NO_COLOR: '1', FORCE_COLOR: '0' },
|
|
107
|
+
});
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(result.stdout);
|
|
110
|
+
if (!parsed.ok || result.status !== 0) {
|
|
111
|
+
const error = new Error(parsed.error || result.stderr || `Setup ${request.operation} exited ${result.status}`);
|
|
112
|
+
error.output = parsed.output;
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
return parsed;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error instanceof SyntaxError) throw new Error(result.error?.message || result.stderr || `Setup ${request.operation} produced an invalid report (exit ${result.status}).`);
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function runHost({ host, dryRun, cwd, configPath }) {
|
|
123
|
+
const result = spawnSync(process.execPath, [bin, ...(configPath ? ['--config', configPath] : []), 'install', host, ...(dryRun ? ['--dry-run'] : [])], {
|
|
124
|
+
cwd, encoding: 'utf8', timeout: 120000, maxBuffer: 4 * 1024 * 1024,
|
|
125
|
+
env: { ...process.env, RUNLIST_JOURNAL: '0', DOTMD_JOURNAL: '0', NO_COLOR: '1', FORCE_COLOR: '0' },
|
|
126
|
+
});
|
|
127
|
+
// Setup owns this prerequisite in Outstanding, including JSON. Do not also
|
|
128
|
+
// bury the installer's copy in a completed host action's captured output.
|
|
129
|
+
const stderr = (result.error?.message || result.stderr || '').replace(`Warning: ${PERSISTENT_CLI_WARNING}\n`, '');
|
|
130
|
+
return { available: which(host), status: result.status, stdout: result.stdout || '', stderr };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function checkHostCli(report, cwd) {
|
|
134
|
+
const missing = !persistentCliOnPath({ cwd }).available;
|
|
135
|
+
if (missing) report.checks.host.persistentCliMissing = true;
|
|
136
|
+
else delete report.checks.host.persistentCliMissing;
|
|
137
|
+
report.outstanding = report.outstanding.filter(item => item.id !== 'persistent-cli');
|
|
138
|
+
if (missing) report.outstanding.push({ id: 'persistent-cli', description: PERSISTENT_CLI_WARNING });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// One readline iterator retains queued answers and handles EOF at every prompt,
|
|
142
|
+
// including confirmation. Creating a fresh interface per question can hang on EOF.
|
|
143
|
+
function terminal(input, output) {
|
|
144
|
+
const rl = createInterface({ input, terminal: false });
|
|
145
|
+
const lines = rl[Symbol.asyncIterator]();
|
|
146
|
+
return { close: () => rl.close(), async ask(question) {
|
|
147
|
+
output.write(question);
|
|
148
|
+
const { value, done } = await lines.next();
|
|
149
|
+
if (done || /^(?:cancel|quit)$/i.test(value.trim())) return null;
|
|
150
|
+
return value.trim();
|
|
151
|
+
} };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function collect(choices, seen, existing, ask) {
|
|
155
|
+
const answer = async (flag, question, key, fallback) => {
|
|
156
|
+
if (seen.has(flag)) return;
|
|
157
|
+
const value = await ask(question);
|
|
158
|
+
if (value === null) throw new Error('SETUP_CANCELLED');
|
|
159
|
+
choices[key] = value || fallback;
|
|
160
|
+
if (flag === '--prompt-tracking' && value) choices.selectPromptTracking = true;
|
|
161
|
+
};
|
|
162
|
+
if (!existing) await answer('--docs-root', 'Docs root [docs]: ', 'docsRoot', 'docs');
|
|
163
|
+
await answer('--prompt-tracking', `Prompt tracking: untracked/tracked/local [${choices.promptTracking}]: `, 'promptTracking', choices.promptTracking);
|
|
164
|
+
if (!seen.has('--gitignore')) {
|
|
165
|
+
const value = await ask('Add .gitignore rules for runtime state and non-tracked prompts? [y/N]: ');
|
|
166
|
+
if (value === null) throw new Error('SETUP_CANCELLED');
|
|
167
|
+
if (value && !/^(?:y|yes|n|no)$/i.test(value)) throw new Error('Choose yes or no for .gitignore edits.');
|
|
168
|
+
choices.gitignore = /^(?:y|yes)$/i.test(value);
|
|
169
|
+
}
|
|
170
|
+
await answer('--host', 'Install a host integration: none/claude/codex/opencode [none]: ', 'host', 'none');
|
|
171
|
+
await answer('--first-plan', 'First plan slug (blank to skip): ', 'firstPlan', null);
|
|
172
|
+
if (choices.firstPlan) {
|
|
173
|
+
await answer('--current-state', 'Current state (user-supplied, cap 1500 characters): ', 'currentState', null);
|
|
174
|
+
await answer('--next-step', 'Next step (user-supplied, cap 800 characters): ', 'nextStep', null);
|
|
175
|
+
}
|
|
176
|
+
if (!seen.has('--adopt')) for (let i = 0; i <= 100; i++) {
|
|
177
|
+
const value = await ask('Existing Markdown file to adopt (exact path, blank to finish): ');
|
|
178
|
+
if (value === null) throw new Error('SETUP_CANCELLED');
|
|
179
|
+
if (!value) break;
|
|
180
|
+
choices.adopt.push(value);
|
|
181
|
+
if (i === 100) throw new Error('Setup accepts at most 100 adoption files.');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function render(report, out, json) {
|
|
186
|
+
if (json) { out.write(`${JSON.stringify(report, null, 2)}\n`); return; }
|
|
187
|
+
out.write(`Setup ${report.status} (${report.mode})\n`);
|
|
188
|
+
let carryHeading = false;
|
|
189
|
+
for (const action of report.mode === 'preview' ? report.actions : []) {
|
|
190
|
+
if (action.id.startsWith('carry:') && !carryHeading) { out.write('\nCarry over prompts and plans saved before setup\n'); carryHeading = true; }
|
|
191
|
+
out.write(`\n${action.description}${action.path ? `: ${action.path}` : ''}\n`);
|
|
192
|
+
if (action.preview) out.write(action.preview.endsWith('\n') ? action.preview : `${action.preview}\n`);
|
|
193
|
+
}
|
|
194
|
+
// Notes are information, not work left to do: shown after the counted sections and
|
|
195
|
+
// only when there are any, so a clean setup never reads as unfinished.
|
|
196
|
+
for (const category of ['completed', 'outstanding', 'failed', 'notes']) {
|
|
197
|
+
if (category === 'notes' && !report.notes.length) continue;
|
|
198
|
+
out.write(`\n${category[0].toUpperCase() + category.slice(1)}: ${report[category].length}\n`);
|
|
199
|
+
for (const item of report[category]) {
|
|
200
|
+
out.write(` ${item.description}${item.path ? `: ${item.path}` : ''}\n`);
|
|
201
|
+
for (const file of item.paths ?? []) out.write(` ${file}\n`);
|
|
202
|
+
if (category === 'failed' && item.output) out.write(item.output.endsWith('\n') ? item.output : `${item.output}\n`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Guided, bounded setup. Extra IO/runner options are for isolated integration tests. */
|
|
208
|
+
export async function runSetup(argv, config, options = {}) {
|
|
209
|
+
const { dryRun = false, input = process.stdin, out = process.stdout, prompts = process.stderr, hostRunner = runHost } = options;
|
|
210
|
+
const report = { mode: argv.includes('--yes') && !dryRun ? 'apply' : 'preview', status: 'preview', choices: null, actions: [], checks: { firstPlan: null, project: null, host: null }, completed: [], outstanding: [], notes: [], failed: [] };
|
|
211
|
+
let json = argv.includes('--json');
|
|
212
|
+
let session;
|
|
213
|
+
let applying = false;
|
|
214
|
+
try {
|
|
215
|
+
const parsed = parse(argv, config);
|
|
216
|
+
const { choices, flags, seen } = parsed;
|
|
217
|
+
json = flags.json;
|
|
218
|
+
report.choices = choices;
|
|
219
|
+
const existing = Boolean(config.configPath && existsSync(config.configPath));
|
|
220
|
+
if (config.configPath && !existing) throw new Error(`The selected config does not exist: ${config.configPath}. Setup will not redirect initialization to a different config. Create the selected config manually, or rerun without --config for the standard fresh-project config.`);
|
|
221
|
+
const cwd = existing ? config.repoRoot : (options.cwd ?? process.cwd());
|
|
222
|
+
const upgrade = existing && config.mode === 'handoffs';
|
|
223
|
+
const interactive = options.interactive ?? Boolean(input.isTTY && prompts.isTTY);
|
|
224
|
+
if (!flags.yes && !dryRun && !json && interactive) {
|
|
225
|
+
session = options.ask ? { ask: options.ask, close() {} } : terminal(input, prompts);
|
|
226
|
+
await collect(choices, seen, existing, session.ask);
|
|
227
|
+
}
|
|
228
|
+
validate(choices, config, existing, upgrade, cwd);
|
|
229
|
+
const configPath = existing ? config.configPath : null;
|
|
230
|
+
const request = { cwd, configPath, choices };
|
|
231
|
+
const configSnapshot = existing ? readFileSync(configPath, 'utf8') : null;
|
|
232
|
+
const prepared = runWorker({ ...request, operation: 'prepare' });
|
|
233
|
+
const plan = prepared.data;
|
|
234
|
+
if (plan.skippedValidationHooks.length) report.outstanding.push({ description: `Custom ${plan.skippedValidationHooks.join('/')} hooks were not executed by setup validation; run runlist check to execute the project's custom checks` });
|
|
235
|
+
const adoptionSnapshots = plan.adoptions.map(item => [item.path, readFileSync(item.path, 'utf8')]);
|
|
236
|
+
let carryMoves = plan.carry.moves;
|
|
237
|
+
if (plan.upgrade) report.actions.push({ id: 'upgrade', description: `Turn planning on: rewrite mode and root in the handoffs config (prompts stay in ${plan.upgrade.promptRoot}/)`, path: configPath,
|
|
238
|
+
preview: `Config before:\n${plan.upgrade.source}Config after:\n${plan.upgrade.content}` });
|
|
239
|
+
if (plan.upgrade?.newlyListed?.count) {
|
|
240
|
+
const { count, root } = plan.upgrade.newlyListed;
|
|
241
|
+
const files = count === 1
|
|
242
|
+
? '1 existing Markdown file there will be checked, and listed as untagged if it has no runlist frontmatter; adopt it with `--adopt <path>` or leave it'
|
|
243
|
+
: `${count} existing Markdown files there will be checked, and any without runlist frontmatter listed as untagged; adopt them with \`--adopt <path>\` or leave them`;
|
|
244
|
+
report.actions.push({ id: 'upgrade-info', description: `Planning brings ${root} under runlist: ${files}`, count });
|
|
245
|
+
}
|
|
246
|
+
report.actions.push({ id: 'init', description: 'Initialize missing scaffolding; preserve configured roots, JavaScript and hooks', path: configPath || path.join(cwd, 'runlist.config.mjs'), preview: prepared.output, ...plan.layout });
|
|
247
|
+
for (const move of carryMoves) report.actions.push({ id: `carry:${move.from}`, description: `move ${move.from} → ${move.to}`, from: move.from, to: move.to, type: move.type });
|
|
248
|
+
report.outstanding.push(...plan.carry.outstanding);
|
|
249
|
+
report.outstanding.push(...plan.transactions.map(item => ({ id: `transaction:${item.id}`, description: `Outstanding transaction ${item.id}; setup must finish pre-config recovery before writing config. ${transactionResolutionAdvice(item)}`, path: item.manifestPath })));
|
|
250
|
+
const carryNotice = () => `notice: files remain outside the configured roots${!choices.carryPrompts ? `; carry-over was disabled.${report.mode === 'preview' ? ' Before applying, rerun runlist setup without --no-carry-prompts to carry them.' : ''} After a config is written, rerunning setup does not carry these files; include their folders in the config root export to keep using them without moving files` : '; include their folders in the root export to keep using them without moving files'}`;
|
|
251
|
+
// The root prompts/ older builds saved prompts in is never a root to add
|
|
252
|
+
// (one prompt location, src/naming.mjs): what stays there is left alone.
|
|
253
|
+
const legacyNotice = () => `notice: left untouched in ${LEGACY_PROMPT_DIR}/, which runlist does not read or write once set up (prompts live in ${PROMPT_DIR}/)${!choices.carryPrompts ? `; carry-over was disabled.${report.mode === 'preview' ? ' Before applying, rerun runlist setup without --no-carry-prompts to carry them.' : ''} After a config is written, rerunning setup does not carry these files` : ''}`;
|
|
254
|
+
let remainingNotice = null;
|
|
255
|
+
let legacyRemaining = null;
|
|
256
|
+
let referencesNotice = null;
|
|
257
|
+
const noteRemaining = carry => {
|
|
258
|
+
report.notes = report.notes.filter(item => item !== remainingNotice && item !== legacyRemaining && item !== referencesNotice);
|
|
259
|
+
const remainingPaths = carry.remaining.map(file => path.relative(cwd, file).split(path.sep).join('/'));
|
|
260
|
+
const inLegacyFolder = file => file.startsWith(`${LEGACY_PROMPT_DIR}/`);
|
|
261
|
+
remainingNotice = remainingPaths.some(file => !inLegacyFolder(file)) ? { description: carryNotice(), paths: remainingPaths.filter(file => !inLegacyFolder(file)) } : null;
|
|
262
|
+
legacyRemaining = remainingPaths.some(inLegacyFolder) ? { description: legacyNotice(), paths: remainingPaths.filter(inLegacyFolder) } : null;
|
|
263
|
+
referencesNotice = carry.references?.length ? { description: 'notice: links in files runlist does not manage are left as they are; update them yourself',
|
|
264
|
+
paths: carry.references.map(item => `${item.file} links to ${item.from}; update it to ${item.to}`) } : null;
|
|
265
|
+
report.notes.unshift(...[remainingNotice, legacyRemaining, referencesNotice].filter(Boolean));
|
|
266
|
+
};
|
|
267
|
+
noteRemaining(plan.carry);
|
|
268
|
+
if (plan.layout.sample) report.notes.push({ description: 'An existing example sample is preserved unchanged; archive or edit it explicitly when ready', path: plan.layout.sample });
|
|
269
|
+
if (plan.policy) report.actions.push({ id: 'policy', description: `Set promptTracking to ${choices.promptTracking} using the narrow config setter`, path: configPath });
|
|
270
|
+
else report.actions.push({ id: 'policy-info', description: `Prompt tracking: ${choices.promptTracking}${existing ? (config.promptTrackingSource === 'legacy-default' ? ' (legacy compatibility default preserved; choose --prompt-tracking explicitly)' : ' (preserve existing choice)') : ' (new project default/selection)'}` });
|
|
271
|
+
report.actions.push({ id: 'ignore-info', description: choices.gitignore ? 'Apply only the .gitignore additions listed in the scaffold preview' : 'Leave ignore files unchanged' });
|
|
272
|
+
for (const adoption of plan.adoptions) report.actions.push({ id: `adopt:${adoption.path}`, description: adoption.candidate ? `Adopt selected file: ${JSON.stringify(adoption.candidate.updates)}` : 'Selected file already tagged; leave unchanged', path: adoption.path,
|
|
273
|
+
preview: `Frontmatter before:\n${adoption.beforeFrontmatter || '(none)'}\nFrontmatter after:\n${adoption.afterFrontmatter}\n`, checks: adoption.checks });
|
|
274
|
+
report.checks.adoptions = plan.adoptions.map(item => ({ path: item.path, ...item.checks }));
|
|
275
|
+
for (const adoption of plan.adoptions) if (adoption.checks.warnings.length) report.outstanding.push({ description: `${adoption.checks.warnings.length} validation warning(s) in selected adoption; authored summary/state were not invented. Run runlist check for details`, path: adoption.path });
|
|
276
|
+
if (plan.firstPlan) {
|
|
277
|
+
report.actions.push({ id: 'plan', description: 'Create first plan from supplied current_state and next_step', path: plan.firstPlan.path, preview: plan.firstPlan.body });
|
|
278
|
+
report.checks.firstPlan = { status: 'preflight-passed', ...plan.firstPlan.checks, path: plan.firstPlan.path };
|
|
279
|
+
}
|
|
280
|
+
if (choices.host !== 'none') {
|
|
281
|
+
const host = await hostRunner({ host: choices.host, dryRun: true, cwd, configPath });
|
|
282
|
+
report.checks.host = { host: choices.host, available: host.available, previewExitCode: host.status };
|
|
283
|
+
checkHostCli(report, cwd);
|
|
284
|
+
report.actions.push({ id: 'host', description: `Installer dry-run for selected ${choices.host} host; no integration was installed by this preview`, preview: `${host.stdout || ''}${host.stderr || ''}` });
|
|
285
|
+
if (host.status !== 0) report.outstanding.push({ description: `Host preview reported exit ${host.status}; installation may be unavailable or refused` });
|
|
286
|
+
if (!host.available) report.outstanding.push({ description: `${choices.host} CLI is unavailable; install/activation cannot be verified` });
|
|
287
|
+
} else report.actions.push({ id: 'host-info', description: 'Host integration: none' });
|
|
288
|
+
let untaggedItem = null;
|
|
289
|
+
const noteUntagged = untagged => {
|
|
290
|
+
report.outstanding = report.outstanding.filter(item => item !== untaggedItem);
|
|
291
|
+
const untouched = untagged.filter(file => !plan.adoptions.some(item => item.path === file));
|
|
292
|
+
untaggedItem = untouched.length ? { description: `${untouched.length} pre-existing untagged Markdown file(s) remain untouched; select exact paths with --adopt`, paths: untouched } : null;
|
|
293
|
+
if (untaggedItem) report.outstanding.push(untaggedItem);
|
|
294
|
+
};
|
|
295
|
+
noteUntagged(plan.untagged);
|
|
296
|
+
if (choices.promptTracking === 'untracked') report.notes.push({ description: 'Leave pending prompts untracked to reduce commit churn; git add -A can still stage prompts that are not ignored' });
|
|
297
|
+
if (plan.layout.files.includes(config.indexPath) || !existing || plan.upgrade) report.actions.push({ id: 'index', description: 'Regenerate the configured generated index after selected changes', path: plan.layout.files.find(file => file === config.indexPath) || path.join(cwd, choices.docsRoot, 'docs.md') });
|
|
298
|
+
if (dryRun || (!flags.yes && !session)) {
|
|
299
|
+
report.outstanding.push({ description: 'Preview only; rerun with --yes and the selected flags to apply' });
|
|
300
|
+
render(report, out, json);
|
|
301
|
+
return report;
|
|
302
|
+
}
|
|
303
|
+
if (session) {
|
|
304
|
+
render(report, out, false);
|
|
305
|
+
const confirm = await session.ask('Apply this concrete setup preview? [y/N]: ');
|
|
306
|
+
if (!/^(?:y|yes)$/i.test(confirm ?? '')) {
|
|
307
|
+
report.status = 'cancelled';
|
|
308
|
+
report.outstanding.push({ description: 'Setup cancelled; no changes were made' });
|
|
309
|
+
render(report, out, json);
|
|
310
|
+
return report;
|
|
311
|
+
}
|
|
312
|
+
// Confirmation approves these bytes and paths. Refuse changes while the
|
|
313
|
+
// user was reviewing rather than silently applying a different preview.
|
|
314
|
+
const refreshed = runWorker({ ...request, operation: 'prepare' });
|
|
315
|
+
if (JSON.stringify(refreshed) !== JSON.stringify(prepared)) throw new Error('Setup sources changed during review; rerun to review a fresh preview.');
|
|
316
|
+
}
|
|
317
|
+
if (existing && readFileSync(configPath, 'utf8') !== configSnapshot) throw new Error('Config changed since preview; rerun setup.');
|
|
318
|
+
for (const [file, content] of adoptionSnapshots) if (readFileSync(file, 'utf8') !== content) throw new Error(`Adoption source changed since preview: ${file}`);
|
|
319
|
+
applying = true;
|
|
320
|
+
report.mode = 'apply';
|
|
321
|
+
if (remainingNotice) remainingNotice.description = carryNotice();
|
|
322
|
+
if (legacyRemaining) legacyRemaining.description = legacyNotice();
|
|
323
|
+
// Each stage preflights again in a fresh process. This is deliberately a
|
|
324
|
+
// sequence of honest operations, not an advertised multi-stage transaction.
|
|
325
|
+
const steps = [ ...(plan.upgrade ? ['upgrade'] : []), ...(plan.policy ? ['policy'] : []), 'init', ...plan.adoptions.filter(item => item.candidate).map(item => item.path), ...(plan.firstPlan ? ['plan'] : []), ...(report.actions.some(action => action.id === 'index') ? ['index'] : []) ];
|
|
326
|
+
if (carryMoves.length) steps.unshift('carry');
|
|
327
|
+
if (!existing || plan.upgrade) steps.unshift('recover');
|
|
328
|
+
for (const [stepIndex, step] of steps.entries()) {
|
|
329
|
+
const adoption = plan.adoptions.find(item => item.path === step);
|
|
330
|
+
try {
|
|
331
|
+
const result = runWorker({ ...request, operation: adoption ? 'adopt' : step, ...(step === 'carry' ? { moves: carryMoves, expectedSource: plan.upgrade?.source } : {}), ...(step === 'upgrade' ? { expectedSource: plan.upgrade.source } : {}), ...(adoption ? { path: step, expectedSource: adoptionSnapshots.find(([file]) => file === step)[1] } : {}) });
|
|
332
|
+
if (step === 'recover') {
|
|
333
|
+
report.outstanding = report.outstanding.filter(item => !item.id?.startsWith('transaction:'));
|
|
334
|
+
// A move an earlier command left unfinished is finished or undone
|
|
335
|
+
// now, so the carry is planned again from the files as recovery
|
|
336
|
+
// left them, within this run.
|
|
337
|
+
const refreshed = runWorker({ ...request, operation: 'prepare' });
|
|
338
|
+
if (JSON.stringify(refreshed.data.carry) !== JSON.stringify(plan.carry)) {
|
|
339
|
+
report.outstanding = report.outstanding.filter(item => !plan.carry.outstanding.includes(item));
|
|
340
|
+
report.actions = report.actions.filter(item => !item.id.startsWith('carry:'));
|
|
341
|
+
plan.carry = refreshed.data.carry;
|
|
342
|
+
carryMoves = plan.carry.moves;
|
|
343
|
+
report.outstanding.push(...plan.carry.outstanding);
|
|
344
|
+
for (const move of carryMoves) report.actions.push({ id: `carry:${move.from}`, description: `move ${move.from} → ${move.to}`, from: move.from, to: move.to, type: move.type });
|
|
345
|
+
noteRemaining(plan.carry);
|
|
346
|
+
noteUntagged(refreshed.data.untagged);
|
|
347
|
+
const carryAt = steps.indexOf('carry');
|
|
348
|
+
if (carryMoves.length && carryAt === -1) steps.splice(stepIndex + 1, 0, 'carry');
|
|
349
|
+
else if (!carryMoves.length && carryAt !== -1) steps.splice(carryAt, 1);
|
|
350
|
+
}
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (step === 'carry') {
|
|
354
|
+
for (const move of result.data.completed) report.completed.push({ ...report.actions.find(item => item.id === `carry:${move.from}`) });
|
|
355
|
+
if (result.data.error) throw new Error(result.data.error);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const action = report.actions.find(item => item.id === (adoption ? `adopt:${step}` : step));
|
|
359
|
+
const { preview, ...completedAction } = action;
|
|
360
|
+
report.completed.push({ ...completedAction, output: result.output });
|
|
361
|
+
} catch (error) {
|
|
362
|
+
// Recovery may change the verdict (active → complete/failed-manual).
|
|
363
|
+
// Its failure carries the fresh doctor's advice; discard stale preview advice.
|
|
364
|
+
if (step === 'recover') report.outstanding = report.outstanding.filter(item => !item.id?.startsWith('transaction:'));
|
|
365
|
+
report.failed.push({ description: `${adoption ? 'Adoption' : step} failed: ${error.message}`, ...(adoption ? { path: step } : {}), output: error.output || '' });
|
|
366
|
+
report.outstanding.push({ description: 'The failed stage may have partial effects; earlier completed stages remain applied. Setup is not a multi-stage transaction.' });
|
|
367
|
+
for (const remaining of steps.slice(stepIndex + 1)) {
|
|
368
|
+
const action = report.actions.find(item => item.id === remaining || item.id === `adopt:${remaining}`);
|
|
369
|
+
report.outstanding.push({ description: `Not attempted: ${action?.description || remaining}`, ...(action?.path ? { path: action.path } : {}) });
|
|
370
|
+
}
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (choices.host !== 'none') {
|
|
375
|
+
if (report.failed.length) report.outstanding.push({ description: `Host install for ${choices.host} was not attempted after project setup failed` });
|
|
376
|
+
else if (!report.checks.host.available) report.failed.push({ description: `${choices.host} CLI is unavailable; host install was not attempted` });
|
|
377
|
+
else {
|
|
378
|
+
checkHostCli(report, cwd);
|
|
379
|
+
const host = await hostRunner({ host: choices.host, dryRun: false, cwd, configPath: configPath || path.join(cwd, 'runlist.config.mjs') });
|
|
380
|
+
if (host.status !== 0) report.failed.push({ description: `${choices.host} install failed (exit ${host.status})`, output: `${host.stdout || ''}${host.stderr || ''}` });
|
|
381
|
+
else report.completed.push({ id: 'host', description: `${choices.host} install verb completed`, output: `${host.stdout || ''}${host.stderr || ''}` });
|
|
382
|
+
report.outstanding.push({ description: `Reload/restart ${choices.host} (for Codex, start a new thread and review/trust hooks); live host activation remains unverified` });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (!report.failed.length) try {
|
|
386
|
+
const checked = runWorker({ ...request, operation: 'check', ...(plan.firstPlan ? { planPath: plan.firstPlan.path } : {}) }).data;
|
|
387
|
+
report.checks.project = { status: 'checked', scope: 'whole project; built-in validation only, custom hooks not executed', errors: checked.errors, warnings: checked.warnings, untagged: checked.untagged };
|
|
388
|
+
if (checked.firstPlan) report.checks.firstPlan = { status: checked.firstPlan.errors.length || checked.firstPlan.warnings.length ? 'findings' : 'passed', ...checked.firstPlan };
|
|
389
|
+
if (checked.errors.length || checked.warnings.length) report.outstanding.push({ description: `Whole-project validation: ${checked.errors.length} error(s), ${checked.warnings.length} warning(s); run runlist check for details. These findings are separate from the selected first plan and adoption checks` });
|
|
390
|
+
} catch (error) { report.failed.push({ description: `Project validation failed: ${error.message}` }); }
|
|
391
|
+
report.status = report.failed.length ? 'partial' : report.checks.project?.errors.length ? 'attention' : 'completed';
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (error.message === 'SETUP_CANCELLED') {
|
|
394
|
+
report.status = 'cancelled';
|
|
395
|
+
report.outstanding.push({ description: 'Setup cancelled or input ended; no changes were made' });
|
|
396
|
+
} else {
|
|
397
|
+
report.status = report.completed.length ? 'partial' : 'failed';
|
|
398
|
+
report.failed.push({ description: `${applying ? 'Setup' : 'Preflight'} failed: ${error.message}` });
|
|
399
|
+
}
|
|
400
|
+
} finally { session?.close(); }
|
|
401
|
+
if (report.failed.length || report.status === 'attention') process.exitCode = 1;
|
|
402
|
+
render(report, out, json);
|
|
403
|
+
return report;
|
|
404
|
+
}
|
package/src/statuses.mjs
CHANGED
|
@@ -58,7 +58,10 @@ export async function runStatuses(argv, config, opts = {}) {
|
|
|
58
58
|
|
|
59
59
|
function runListStatuses(args, config) {
|
|
60
60
|
const flags = parseFlags(args, { allowProps: false });
|
|
61
|
-
|
|
61
|
+
// Where planning is off, prompts are the one type there is (the dispatcher
|
|
62
|
+
// refuses any other --type there).
|
|
63
|
+
const types = flags.type ? flags.type.split(',').map(t => t.trim()).filter(Boolean)
|
|
64
|
+
: config.mode === 'handoffs' ? ['prompt'] : [...config.validTypes];
|
|
62
65
|
|
|
63
66
|
// For each type, derive a flag table from config.raw.
|
|
64
67
|
const out = { types: {} };
|
|
@@ -581,7 +584,7 @@ function lastNewlineIndexBefore(content, pos) {
|
|
|
581
584
|
|
|
582
585
|
function requireConfigPath(config) {
|
|
583
586
|
if (!config.configPath || !existsSync(config.configPath)) {
|
|
584
|
-
die(`No runlist.config.mjs found in ${process.cwd()} — run \`runlist
|
|
587
|
+
die(`No runlist.config.mjs found in ${process.cwd()} — run \`runlist setup\` first.`);
|
|
585
588
|
}
|
|
586
589
|
}
|
|
587
590
|
|