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
package/src/update.mjs CHANGED
@@ -1,16 +1,17 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { green, dim, yellow } from './color.mjs';
7
- import { executableName, which } from './util.mjs';
7
+ import { executableName } from './util.mjs';
8
8
  import { CLAUDE_MARKETPLACE, claudeMarketplaceRefusalHint, opencodeStatus } from './host-integration.mjs';
9
9
  import { codexStatus, installedCodexStatus } from './codex-integration.mjs';
10
10
  import { desktopLaunch } from './desktop.mjs';
11
- import { installationReport, formatInstallationReport, readInstalledPluginRecords } from './installation.mjs';
11
+ import { installationReport, formatInstallationReport, readInstalledPluginRecords, sourceFingerprint } from './installation.mjs';
12
12
  export { readInstalledPluginRecords } from './installation.mjs';
13
13
  import { compareDesktopVersions, installedDesktopVersion } from './desktop-install.mjs';
14
+ import { agentHostReport, discoverClaudeProfiles, discoverHostExecutables } from './agent-hosts.mjs';
14
15
 
15
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
17
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
@@ -39,7 +40,9 @@ export function compareVersions(a, b) {
39
40
  // is registered here — an install record alone is not an installed plugin.
40
41
  export function readKnownMarketplaces(opts = {}) {
41
42
  const home = opts.home || os.homedir();
42
- const file = path.join(home, '.claude', 'plugins', 'known_marketplaces.json');
43
+ const env = opts.env ?? (opts.home ? {} : process.env);
44
+ const configDir = opts.configDir ?? (env.CLAUDE_CONFIG_DIR?.trim() || path.join(home, '.claude'));
45
+ const file = path.join(configDir, 'plugins', 'known_marketplaces.json');
43
46
  try {
44
47
  const parsed = JSON.parse(readFileSync(file, 'utf8'));
45
48
  return parsed && typeof parsed === 'object' ? parsed : {};
@@ -55,10 +58,12 @@ export function readKnownMarketplaces(opts = {}) {
55
58
  // that "installed", so `runlist install claude` skipped the one repair it owns
56
59
  // and `runlist update` ran a `plugin update` that could only fail.
57
60
  export function readInstalledPlugin(opts = {}) {
58
- const records = readInstalledPluginRecords(opts);
61
+ const env = opts.env ?? (opts.home ? {} : process.env);
62
+ const configDir = opts.configDir ?? (env.CLAUDE_CONFIG_DIR?.trim() || path.join(opts.home ?? os.homedir(), '.claude'));
63
+ const records = readInstalledPluginRecords({ ...opts, configDir });
59
64
  if (!records) return null;
60
65
  const marketplace = records.id.split('@')[1] || null;
61
- const known = readKnownMarketplaces(opts);
66
+ const known = readKnownMarketplaces({ ...opts, configDir });
62
67
  return {
63
68
  id: records.id,
64
69
  entries: records.entries,
@@ -156,8 +161,14 @@ export function planUpdate(opts, ctx) {
156
161
  } else if (ctx.plugin.id === LEGACY_PLUGIN_ID) {
157
162
  if (ctx.hasClaude) {
158
163
  steps.push({ kind: 'marketplace', cmd: ['claude', 'plugin', 'marketplace', 'add', CLAUDE_MARKETPLACE] });
159
- steps.push({ kind: 'plugin', needs: 'marketplace', cmd: ['claude', 'plugin', 'install', DEFAULT_PLUGIN_ID] });
160
- steps.push({ kind: 'legacy', needs: 'plugin', cmd: ['claude', 'plugin', 'uninstall', LEGACY_PLUGIN_ID] });
164
+ const scopes = planPluginUpdates(ctx.plugin);
165
+ for (const step of scopes) {
166
+ if (step.kind === 'refusal') { steps.push(step); continue; }
167
+ steps.push({ ...step, needs: 'marketplace', cmd: ['claude', 'plugin', 'install', DEFAULT_PLUGIN_ID, ...step.cmd.slice(4)] });
168
+ }
169
+ for (const step of scopes) if (step.kind !== 'refusal') {
170
+ steps.push({ ...step, kind: 'legacy', needs: 'plugin', cmd: ['claude', 'plugin', 'uninstall', LEGACY_PLUGIN_ID, ...step.cmd.slice(4)] });
171
+ }
161
172
  } else {
162
173
  steps.push({ kind: 'manual', reason: 'Claude CLI not found', lines: [
163
174
  `/plugin marketplace add ${CLAUDE_MARKETPLACE}`,
@@ -170,12 +181,14 @@ export function planUpdate(opts, ctx) {
170
181
  } else if (!ctx.hasClaude) {
171
182
  steps.push({ kind: 'skip', reason: `claude CLI not found — run \`/plugin update ${ctx.plugin.id}\` from a session instead` });
172
183
  } else {
173
- steps.push(...planPluginUpdates(ctx.plugin));
184
+ if (ctx.officialMarketplace) steps.push({ kind: 'marketplace', cmd: ['claude', 'plugin', 'marketplace', 'update', ctx.plugin.marketplace] });
185
+ steps.push(...planPluginUpdates(ctx.plugin).map(step => ctx.officialMarketplace ? { ...step, needs: 'marketplace' } : step));
174
186
  }
175
187
  if (ctx.legacyPlugin && ctx.plugin?.id !== LEGACY_PLUGIN_ID) {
176
- steps.push(ctx.hasClaude
177
- ? { kind: 'legacy', needs: 'plugin', cmd: ['claude', 'plugin', 'uninstall', LEGACY_PLUGIN_ID] }
178
- : { kind: 'manual', reason: 'Legacy Claude plugin also installed', lines: [`/plugin uninstall ${LEGACY_PLUGIN_ID}`] });
188
+ if (ctx.hasClaude) for (const step of planPluginUpdates(ctx.legacyPlugin)) {
189
+ steps.push(step.kind === 'refusal' ? step : { ...step, kind: 'legacy', needs: 'plugin', cmd: ['claude', 'plugin', 'uninstall', LEGACY_PLUGIN_ID, ...step.cmd.slice(4)] });
190
+ }
191
+ else steps.push({ kind: 'manual', reason: 'Legacy Claude plugin also installed', lines: [`/plugin uninstall ${LEGACY_PLUGIN_ID}`] });
179
192
  }
180
193
  // The OpenCode integration is a file runlist wrote, so it goes stale silently
181
194
  // the moment the CLI moves on. Refresh it here — but only if it is already
@@ -183,12 +196,12 @@ export function planUpdate(opts, ctx) {
183
196
  // which stays the job of the explicit `runlist install`.
184
197
  if (ctx.opencode?.foreign) {
185
198
  steps.push({ kind: 'skip', reason: `${ctx.opencode.path} was not written by runlist — leaving it alone` });
186
- } else if (ctx.opencode?.exists && (ctx.opencode.stale || (!opts.pluginOnly && ctx.hasNpm))) {
199
+ } else if (ctx.opencode?.exists && (ctx.refreshHosts || ctx.opencode.stale || (!opts.pluginOnly && ctx.hasNpm))) {
187
200
  steps.push({ kind: 'opencode', cmd: ['runlist', 'install', 'opencode'] });
188
201
  }
189
202
  if (ctx.codex?.foreign) {
190
203
  steps.push({ kind: 'skip', reason: `${ctx.codex.plugin} was not written by runlist — leaving it alone` });
191
- } else if (ctx.codex?.exists && (ctx.codex.stale || (!opts.pluginOnly && ctx.hasNpm))) {
204
+ } else if (ctx.codex?.exists && (ctx.refreshHosts || ctx.codex.stale || (!opts.pluginOnly && ctx.hasNpm))) {
192
205
  steps.push({ kind: 'codex', cmd: ['runlist', 'install', 'codex'] });
193
206
  }
194
207
  }
@@ -213,68 +226,202 @@ export function verifyManagedHostUpdates(before, after, expectedVersion) {
213
226
  return failures;
214
227
  }
215
228
 
216
- function currentCodexStatus(version) {
217
- const source = codexStatus({ version });
229
+ function currentCodexStatus(version, { home = os.homedir(), env = process.env, run = spawnSync, binary } = {}) {
230
+ const source = codexStatus({ version, homedir: home });
218
231
  if (!source.exists || source.foreign) return source;
219
- const installed = which('codex') ? installedCodexStatus() : { ok: false, reason: 'codex CLI not found; installed plugin is unverified' };
232
+ const installed = binary ? installedCodexStatus({ homedir: home, env, run, binary }) : { ok: false, reason: 'codex CLI not found; installed plugin is unverified' };
220
233
  return { ...source, installed, stale: source.stale || !installed.ok || installed.version !== version };
221
234
  }
222
235
 
223
- export async function runUpdate(argv, _config, opts = {}) {
224
- const check = argv.includes('--check');
225
- const cliOnly = argv.includes('--cli-only');
226
- const pluginOnly = argv.includes('--plugin-only');
227
- const plugin = readInstalledPlugin();
228
- const legacyPlugin = readInstalledPluginRecords({ id: LEGACY_PLUGIN_ID });
229
- const desktop = process.platform === 'darwin' && process.arch === 'arm64' ? desktopLaunch([]).app : null;
236
+ function flagValues(argv, flag) {
237
+ const values = [];
238
+ for (let i = 0; i < argv.length; i++) {
239
+ if (argv[i].startsWith(`${flag}=`)) values.push(argv[i].slice(flag.length + 1));
240
+ else if (argv[i] === flag) {
241
+ if (!argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error(`${flag} requires a value`);
242
+ values.push(argv[++i]);
243
+ }
244
+ }
245
+ if (values.some(value => !value.trim())) throw new Error(`${flag} requires a value`);
246
+ return values;
247
+ }
248
+
249
+ export function updateOptions(argv, opts = {}) {
250
+ const hosts = flagValues(argv, '--host');
251
+ if (hosts.length > 1) throw new Error('--host may be supplied once');
252
+ if (argv.includes('--json') && !argv.includes('--check')) throw new Error('update --json is available with --check; use update --check --json for offline inspection.');
253
+ const host = hosts[0] ?? opts.host;
254
+ if (host && !['claude', 'codex', 'opencode'].includes(host)) throw new Error('--host must be claude, codex, or opencode');
255
+ const cliOnly = argv.includes('--cli-only') || Boolean(opts.cliOnly);
256
+ if (host && cliOnly) throw new Error('--cli-only conflicts with --host');
257
+ const configDirs = flagValues(argv, '--claude-config');
258
+ const claudeConfigDirs = configDirs.length ? configDirs : opts.claudeConfigDirs ?? [];
259
+ if (claudeConfigDirs.some(dir => !path.isAbsolute(dir))) throw new Error('--claude-config requires an absolute profile directory');
260
+ const binaries = flagValues(argv, '--claude-bin');
261
+ if (binaries.length > 1) throw new Error('--claude-bin may be supplied once');
262
+ return { host, cliOnly, pluginOnly: Boolean(host || argv.includes('--plugin-only') || opts.pluginOnly),
263
+ claudeConfigDirs: claudeConfigDirs.map(dir => path.resolve(dir)), claudeBin: binaries[0] ?? opts.claudeBin,
264
+ check: argv.includes('--check'), json: argv.includes('--json'), dryRun: Boolean(opts.dryRun || argv.includes('--dry-run')) };
265
+ }
266
+
267
+ function inventoryNpm(env) {
268
+ const suffixes = process.platform === 'win32' ? ['', '.cmd', '.exe'] : [''];
269
+ return (env.PATH ?? '').split(path.delimiter).filter(Boolean).some(dir => suffixes.some(suffix => existsSync(path.join(dir, `npm${suffix}`))));
270
+ }
271
+
272
+ const officialMarketplace = market => (market?.source?.source === 'github' && market.source.repo === CLAUDE_MARKETPLACE)
273
+ || (market?.source?.source === 'git' && /^https:\/\/github\.com\/reowens\/runlist(?:\.git)?\/?$/.test(market.source.url ?? ''));
230
274
 
275
+ // Published catalog parity is separate from local checkout parity. An unpublished
276
+ // checkout must never turn a perfectly valid published plugin into a failure.
277
+ export function publishedClaudePluginStatus(plugin, profile) {
278
+ const market = readKnownMarketplaces({ configDir: profile.configDir })[plugin.marketplace];
279
+ if (!officialMarketplace(market) || !path.isAbsolute(market.installLocation ?? '')) return { state: 'unknown' };
280
+ try {
281
+ const catalog = JSON.parse(readFileSync(path.join(market.installLocation, '.claude-plugin', 'marketplace.json'), 'utf8'));
282
+ if (catalog.name !== plugin.marketplace) return { state: 'unknown' };
283
+ const entry = catalog.plugins?.find(item => item.name === plugin.id.split('@')[0]);
284
+ if (typeof entry?.source !== 'string' || path.isAbsolute(entry.source)) return { state: 'unknown' };
285
+ const root = path.resolve(market.installLocation, entry.source);
286
+ const relative = path.relative(realpathSync(market.installLocation), realpathSync(root));
287
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return { state: 'unknown' };
288
+ const manifest = JSON.parse(readFileSync(path.join(root, '.claude-plugin', 'plugin.json'), 'utf8'));
289
+ const assets = ['commands', 'skills', 'hooks', 'hooks.json', '.claude-plugin'];
290
+ const expected = sourceFingerprint(root, assets);
291
+ const copies = plugin.entries.map(item => {
292
+ const actual = item.installPath ? sourceFingerprint(item.installPath, assets) : null;
293
+ return { scope: pluginScopeLabel(item), state: !expected.sha256 || !actual?.sha256 ? 'unknown' : expected.sha256 === actual.sha256 ? 'match' : 'mismatch' };
294
+ });
295
+ return { state: copies.some(copy => copy.state === 'mismatch') ? 'mismatch' : copies.length && copies.every(copy => copy.state === 'match') ? 'match' : 'unknown', version: parseVer(manifest.version) ? manifest.version : null, copies };
296
+ } catch { return { state: 'unknown' }; }
297
+ }
298
+
299
+ // Keep physical-store writes deduplicated while retaining every profile for the
300
+ // caller's fresh verification. Explicit selectors never fall back to ~/.claude.
301
+ export function planClaudeProfileUpdates(profiles, inventory) {
302
+ const steps = [], seen = new Set();
303
+ const selected = inventory?.selected;
304
+ const binary = typeof selected === 'string' ? selected : selected?.path;
305
+ const candidate = typeof selected === 'object' ? selected : inventory?.candidates?.find(item => item.path === binary);
306
+ for (const profile of profiles) {
307
+ if (profile.registryState === 'unreadable') {
308
+ steps.push({ kind: 'refusal', reason: `${profile.registryPath}: Claude registry unreadable; no profile update attempted` });
309
+ continue;
310
+ }
311
+ if (profile.knownMarketplacesState === 'unreadable' || profile.settings?.some(settings => settings.configDir === profile.configDir && settings.state === 'unreadable')) {
312
+ steps.push({ kind: 'refusal', reason: `${profile.configDir}: Claude marketplace/settings data is unreadable; preserve it and resolve it before updating` });
313
+ continue;
314
+ }
315
+ const plugin = profile.installed;
316
+ if (!plugin) { steps.push({ kind: 'skip', reason: `runlist plugin not installed in ${profile.configDir} — skipping plugin update` }); continue; }
317
+ if (seen.has(profile.storeKey)) continue;
318
+ seen.add(profile.storeKey);
319
+ if (inventory?.explicit && !binary) {
320
+ steps.push({ kind: 'refusal', reason: `selected --claude-bin is unavailable; no update attempted for ${profile.configDir}` });
321
+ continue;
322
+ }
323
+ // A wrapper may hard-code a different CLAUDE_CONFIG_DIR. Merely exporting
324
+ // the variable is not proof that it will target the selected profile.
325
+ if (binary && (!candidate?.kind?.startsWith('native') || (!inventory.explicit && inventory.candidates?.filter(item => item.kind.startsWith('native')).length > 1))) {
326
+ steps.push({ kind: 'refusal', reason: `cannot establish wrapper-to-profile mapping for ${binary} and ${profile.configDir}; use --claude-bin with a native executable path and --claude-config ${profile.configDir}` });
327
+ continue;
328
+ }
329
+ const market = readKnownMarketplaces({ configDir: profile.configDir })[plugin.marketplace];
330
+ const legacyPlugin = profile.plugins?.find(item => item.id === LEGACY_PLUGIN_ID);
331
+ for (const step of planUpdate({ pluginOnly: true }, { plugin, legacyPlugin, hasClaude: binary, officialMarketplace: officialMarketplace(market) })) {
332
+ steps.push({ ...step, profile: profile.configDir, dependencyKey: profile.storeKey,
333
+ ...(step.cmd?.[0] === 'claude' ? { cmd: [binary || 'claude', ...step.cmd.slice(1)] } : {}) });
334
+ }
335
+ }
336
+ return steps;
337
+ }
338
+
339
+ export async function runUpdate(argv, _config, opts = {}) {
340
+ const options = updateOptions(argv, opts);
341
+ const { check, cliOnly, pluginOnly, host, claudeConfigDirs, claudeBin, dryRun } = options;
342
+ const home = opts.home ?? os.homedir(), env = opts.env ?? process.env;
343
+ const run = opts.run ?? spawnSync;
344
+ const inventory = opts.executables ?? discoverHostExecutables({ home, env, claudeBin });
345
+ const discovered = discoverClaudeProfiles({ home, env, configDirs: claudeConfigDirs });
346
+ const profiles = claudeConfigDirs.length ? discovered.flatMap(profile => {
347
+ const selected = claudeConfigDirs.find(dir => (profile.configDirs ?? [profile.configDir]).includes(dir));
348
+ return selected ? [{ ...profile, configDir: selected }] : [];
349
+ }) : discovered;
350
+ const reportOptions = { home, env, claudeConfigDirs, claudeBin };
351
+ // Offline checks and previews do not invoke even a host's read-only CLI:
352
+ // wrappers and host startup can perform network or settings writes.
231
353
  if (check) {
232
- process.stdout.write(formatInstallationReport(installationReport(_config)));
233
- process.stdout.write(`runlist CLI: ${pkg.version}\n`);
234
- if (plugin) {
235
- if (plugin.id === LEGACY_PLUGIN_ID || (legacyPlugin && plugin.id !== LEGACY_PLUGIN_ID)) {
236
- process.stdout.write(yellow(` legacy Claude plugin ${LEGACY_PLUGIN_ID} is installed — run \`runlist update --plugin-only\` to migrate.\n`));
354
+ const installation = installationReport(_config, reportOptions);
355
+ const hosts = agentHostReport(_config, reportOptions);
356
+ const managedSources = { codex: codexStatus({ version: pkg.version, homedir: home }), opencode: opencodeStatus({ version: pkg.version, homedir: home, env }) };
357
+ const report = { ...installation, hostReport: hosts, managedSources, selection: { host: host ?? null, claudeConfigDirs },
358
+ readOnly: true, offline: true, publishedAvailability: 'not-queried-offline' };
359
+ if (options.json) process.stdout.write(JSON.stringify(report, null, 2) + '\n');
360
+ else {
361
+ process.stdout.write('Offline inspection: local installed/catalog files only; newer published versions were not queried.\n');
362
+ process.stdout.write(formatInstallationReport(installation).split('\n').filter(line => !/^(?:claude|codex|opencode):/.test(line)).join('\n'));
363
+ process.stdout.write(`runlist CLI: ${pkg.version}\n`);
364
+ for (const profile of profiles) {
365
+ if (!profile.installed) process.stdout.write(`runlist plugin: not installed (${profile.configDir})\n`);
366
+ for (const plugin of profile.plugins) {
367
+ for (const entry of plugin.entries) {
368
+ const cmp = compareVersions(entry.version, pkg.version);
369
+ const version = cmp === 0 ? 'version matches CLI' : cmp === null ? 'version unknown' : cmp < 0 ? 'behind CLI' : 'ahead of CLI';
370
+ process.stdout.write(`runlist plugin: ${entry.version ?? '?'} (${plugin.id}; ${pluginScopeLabel(entry)}; ${profile.configDir}) ${version}; published source ${publishedClaudePluginStatus(plugin, profile).state}\n`);
371
+ }
372
+ if (plugin.marketplaceRegistered === false) process.stdout.write(` marketplace "${plugin.marketplace}" is not registered; host activation is unknown\n`);
373
+ }
237
374
  }
238
- for (const entry of plugin.entries) {
239
- const cmp = compareVersions(entry.version, pkg.version);
240
- const tag = cmp === 0 ? green('version matches')
241
- : cmp === null ? dim('(unknown)')
242
- : cmp < 0 ? yellow('behind — run `runlist update`')
243
- : yellow('ahead — CLI is behind');
244
- process.stdout.write(`runlist plugin: ${entry.version ?? '?'} (${plugin.id}; ${pluginScopeLabel(entry)}) ${tag}\n`);
375
+ for (const name of ['claude', 'codex', 'opencode']) {
376
+ const info = hosts.hosts[name];
377
+ process.stdout.write(`${name}: ${info.state}; loaded live session unknown\n`);
378
+ for (const candidate of inventory[name]?.candidates ?? []) process.stdout.write(` executable: ${candidate.path} → ${candidate.resolvedPath} (${candidate.kind})\n`);
379
+ for (const requirement of info.requirements ?? []) process.stdout.write(` ${requirement}\n`);
245
380
  }
246
- if (plugin.marketplaceRegistered === false) {
247
- process.stdout.write(yellow(` marketplace "${plugin.marketplace}" is not registered — the plugin fails to load; run \`runlist install claude\`\n`));
248
- }
249
- } else {
250
- process.stdout.write(dim('runlist plugin: not installed — `runlist install claude`\n'));
381
+ for (const [name, source] of Object.entries(managedSources)) process.stdout.write(`${name} managed source: ${source.exists ? source.foreign ? 'foreign' : source.sourceParity.state : 'missing'}; host installed copy and loaded session require separate attestation\n`);
382
+ for (const warning of hosts.warnings) process.stdout.write(`${warning}\n`);
251
383
  }
252
- const oc = opencodeStatus({ version: pkg.version });
253
- if (!oc.exists) process.stdout.write(dim('runlist opencode: not installed\n'));
254
- else if (oc.foreign) process.stdout.write(`runlist opencode: ${yellow('unmanaged file — not written by runlist')}\n`);
255
- else process.stdout.write(`runlist opencode: ${oc.version} ${oc.stale ? yellow('behind — run `runlist update`') : green('version matches')}\n`);
256
- const cx = currentCodexStatus(pkg.version);
257
- if (!cx.exists) process.stdout.write(dim('runlist codex: not installed\n'));
258
- else if (cx.foreign) process.stdout.write(`runlist codex: ${yellow('unmanaged plugin directory')}\n`);
259
- else process.stdout.write(`runlist codex: source ${cx.version}; installed ${cx.installed?.version ?? 'unverified'} ${cx.stale ? yellow('out of sync — run `runlist update`') : green('version matches')}\n`);
260
- if (desktop) {
261
- const version = await installedDesktopVersion(desktop);
262
- process.stdout.write(`runlist desktop: ${version} ${compareDesktopVersions(version, pkg.version) < 0 ? yellow('behind — quit Runlist and run `runlist update`') : dim('version current; see source comparison above')}\n`);
263
- }
264
- return;
384
+ return report;
265
385
  }
266
386
 
267
- const opencode = opencodeStatus({ version: pkg.version });
268
- const codex = currentCodexStatus(pkg.version);
269
- const steps = planUpdate({ cliOnly, pluginOnly }, { plugin, legacyPlugin, opencode, codex, desktop, hasClaude: which('claude'), hasNpm: which('npm') });
270
- if (opts.dryRun) {
387
+ const selected = name => !host || host === name;
388
+ if (!cliOnly) {
389
+ const source = agentHostReport(_config, reportOptions).candidate;
390
+ process.stdout.write(`CLI candidate: ${source.path} (${source.kind}, version ${source.version ?? 'unknown'}). `
391
+ + (source.kind === 'checkout' ? 'This may contain unpublished assets; candidate parity is not a published-release attestation.\n' : 'Managed assets are compared with this CLI package.\n'));
392
+ process.stdout.write('Claude updates use registered marketplace catalogs; Codex/OpenCode managed assets come from this CLI installation.\n');
393
+ if (!pluginOnly) process.stdout.write('The CLI update may replace this installation before host installers read its assets.\n');
394
+ }
395
+ const opencode = !cliOnly && selected('opencode') ? opencodeStatus({ version: pkg.version, homedir: home, env }) : null;
396
+ const codex = !cliOnly && selected('codex') ? (dryRun ? codexStatus({ version: pkg.version, homedir: home })
397
+ : currentCodexStatus(pkg.version, { home, env, run, binary: inventory.codex.selected?.path })) : null;
398
+ const desktop = !host && !pluginOnly && process.platform === 'darwin' && process.arch === 'arm64' ? desktopLaunch([], { home, env }).app : null;
399
+ // Keep the existing all-host planner; replace only its single-default-profile
400
+ // Claude plan with the discovered stores and their explicit environments.
401
+ const steps = planUpdate({ cliOnly, pluginOnly }, { opencode, codex, desktop, refreshHosts: Boolean(host || dryRun), hasNpm: inventoryNpm(env) })
402
+ .filter(step => !step.reason?.startsWith('runlist plugin not installed'));
403
+ if (!cliOnly) for (const name of ['codex', 'opencode']) {
404
+ if (selected(name) && !(name === 'codex' ? codex : opencode)?.exists) steps.push({ kind: 'skip', reason: `runlist ${name} integration not installed — skipping ${name} update` });
405
+ }
406
+ if (!cliOnly && selected('claude')) {
407
+ const claudeSteps = planClaudeProfileUpdates(profiles, inventory.claude, { home, explicit: claudeConfigDirs.length > 0 });
408
+ const index = steps.findIndex(step => step.kind !== 'cli');
409
+ steps.splice(index < 0 ? steps.length : index, 0, ...claudeSteps);
410
+ }
411
+ if (dryRun) {
412
+ for (const name of ['codex', 'opencode']) {
413
+ const state = name === 'codex' ? codex : opencode;
414
+ if (selected(name) && state?.exists) process.stdout.write(`[dry-run] ${name} managed source: ${state.foreign ? 'foreign (preserved)' : state.stale ? 'stale → current CLI candidate' : 'current (installed-copy verification may still be needed)'}\n`);
415
+ }
416
+ const skipped = host && steps.every(step => step.kind === 'skip');
417
+ if (skipped) process.stdout.write(`[dry-run] Requested ${host} integration is absent or not writable; no update is planned. Use an explicit install/repair if desired.\n`);
271
418
  for (const step of steps) {
272
419
  if (step.kind === 'skip') process.stdout.write(dim(`[dry-run] skip: ${step.reason}\n`));
273
420
  else if (step.kind === 'manual') for (const line of step.lines) process.stdout.write(dim(`[dry-run] Run from a session: ${line}\n`));
274
421
  else if (step.kind === 'refusal') process.stdout.write(yellow(`[dry-run] Refused: ${step.reason}\n`));
275
- else process.stdout.write(dim(`[dry-run] Would run: ${step.cmd.join(' ')}${step.cwd ? ` (in ${step.cwd})` : ''}\n`));
422
+ else process.stdout.write(dim(`[dry-run] Would run: ${step.cmd.join(' ')}${step.cwd ? ` (in ${step.cwd})` : ''}${step.profile ? ` [CLAUDE_CONFIG_DIR=${step.profile}]` : ''}\n`));
276
423
  }
277
- return;
424
+ return { steps, dryRun: true, status: skipped ? 'skipped' : 'preview' };
278
425
  }
279
426
  // The hosts are independent: a failed `claude plugin update` says nothing
280
427
  // about the OpenCode file, so every step runs and the failures are reported
@@ -284,10 +431,11 @@ export async function runUpdate(argv, _config, opts = {}) {
284
431
  let ran = false;
285
432
  const failures = [];
286
433
  const failedKinds = new Set();
434
+ const refreshedStores = new Set();
287
435
  for (const s of steps) {
288
436
  if (s.kind === 'refusal') {
289
437
  failures.push(s);
290
- failedKinds.add('plugin');
438
+ failedKinds.add(`${s.dependencyKey ?? ''}:plugin`);
291
439
  process.stdout.write(yellow(`${s.reason}\n`));
292
440
  continue;
293
441
  }
@@ -300,7 +448,7 @@ export async function runUpdate(argv, _config, opts = {}) {
300
448
  for (const line of s.lines) process.stdout.write(` ${line}\n`);
301
449
  continue;
302
450
  }
303
- if (s.needs && failedKinds.has(s.needs)) {
451
+ if (s.needs && failedKinds.has(`${s.dependencyKey ?? ''}:${s.needs}`)) {
304
452
  process.stdout.write(dim(`skip: ${s.cmd.join(' ')} — the ${s.needs} step it depends on failed\n`));
305
453
  continue;
306
454
  }
@@ -310,41 +458,52 @@ export async function runUpdate(argv, _config, opts = {}) {
310
458
  // reads the new installers/assets; this process still holds the old modules.
311
459
  // Use this installation's entry point, not another runlist earlier on PATH.
312
460
  const ownCli = s.cmd[0] === 'runlist';
313
- const r = spawnSync(ownCli ? process.execPath : executableName(s.cmd[0]),
461
+ const r = run(ownCli ? process.execPath : path.isAbsolute(s.cmd[0]) ? s.cmd[0] : executableName(s.cmd[0]),
314
462
  ownCli ? [path.join(__dirname, '..', 'bin', 'runlist.mjs'), ...s.cmd.slice(1)] : s.cmd.slice(1), {
315
463
  stdio: 'inherit',
464
+ env: { ...env, HOME: home, USERPROFILE: home, ...(s.profile ? { CLAUDE_CONFIG_DIR: s.profile } : {}) },
316
465
  ...(s.cwd ? { cwd: s.cwd } : {}),
317
- shell: !ownCli && process.platform === 'win32',
466
+ shell: !ownCli && process.platform === 'win32' && (!path.isAbsolute(s.cmd[0]) || /\.(?:cmd|bat)$/i.test(s.cmd[0])),
318
467
  });
319
468
  ran = true;
320
469
  if (r.status !== 0) {
321
- failedKinds.add(s.kind);
470
+ failedKinds.add(`${s.dependencyKey ?? ''}:${s.kind}`);
322
471
  failures.push(s);
323
472
  process.stdout.write(yellow(`(${s.cmd[0]} ${r.error ? `failed: ${r.error.message}` : `exited ${r.status ?? '?'}`})\n`));
324
- if (s.kind === 'marketplace') for (const line of claudeMarketplaceRefusalHint(plugin?.marketplace)) process.stdout.write(yellow(`${line}\n`));
325
- }
473
+ if (s.kind === 'marketplace') for (const line of claudeMarketplaceRefusalHint('runlist')) process.stdout.write(yellow(`${line}\n`));
474
+ } else if (s.kind === 'marketplace' && s.dependencyKey) refreshedStores.add(s.dependencyKey);
326
475
  }
327
476
  const currentVersion = opts.expectedVersion ?? JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
328
477
  if (!cliOnly) {
329
- const after = { opencode: opencodeStatus({ version: currentVersion }), codex: currentCodexStatus(currentVersion) };
478
+ const after = { opencode: opencode && opencodeStatus({ version: currentVersion, homedir: home, env }),
479
+ codex: codex && currentCodexStatus(currentVersion, { home, env, run, binary: inventory.codex.selected?.path }) };
330
480
  for (const reason of verifyManagedHostUpdates({ opencode, codex }, after, currentVersion)) {
331
481
  failures.push({ reason });
332
482
  process.stdout.write(yellow(`${reason}\n`));
333
483
  }
334
484
  }
335
- if (!cliOnly && plugin?.id === DEFAULT_PLUGIN_ID && !failedKinds.has('plugin') && !failedKinds.has('marketplace')) {
336
- // npm may have replaced this package while this process was running.
337
- const verified = verifyPluginScopeUpdates(plugin, readInstalledPluginRecords({ id: plugin.id }),
338
- opts.expectedVersion ? { expectedVersion: currentVersion } : { minimumVersion: currentVersion });
485
+ if (!cliOnly && selected('claude')) for (const profile of profiles) {
486
+ if (!profile.installed) continue;
487
+ // Verify every registry/scope, even when another store or command failed.
488
+ // Shared-store aliases remain separately visible in the inventory report.
489
+ const plugin = profile.installed;
490
+ const after = readInstalledPlugin({ home, configDir: profile.configDir, env });
491
+ const published = after ? publishedClaudePluginStatus(after, profile) : { state: 'unknown' };
492
+ const verified = verifyPluginScopeUpdates(plugin.id === LEGACY_PLUGIN_ID ? { ...plugin, id: DEFAULT_PLUGIN_ID } : plugin, after, opts.expectedVersion ? { expectedVersion: currentVersion }
493
+ : published.version ? { expectedVersion: published.version } : {});
339
494
  if (!verified.ok) {
340
- failures.push({ reason: verified.reason });
341
- process.stdout.write(yellow(`${verified.reason}\n`));
495
+ const reason = `${profile.configDir}: ${verified.reason}`;
496
+ failures.push({ reason }); process.stdout.write(yellow(`${reason}\n`));
497
+ }
498
+ if (published.state === 'mismatch') {
499
+ const reason = `${profile.configDir}: installed Claude assets differ from the ${refreshedStores.has(profile.storeKey) ? 'refreshed ' : ''}registered official marketplace catalog`;
500
+ failures.push({ reason }); process.stdout.write(yellow(`${reason}\n`));
501
+ } else if (published.state === 'unknown') process.stdout.write(dim(`${profile.configDir}: published Claude source parity unknown; loaded session unknown\n`));
502
+ if (profile.plugins.some(item => item.id === LEGACY_PLUGIN_ID) && readInstalledPluginRecords({ home, configDir: profile.configDir, id: LEGACY_PLUGIN_ID })) {
503
+ failures.push({ reason: `${profile.configDir}: legacy Claude plugin ${LEGACY_PLUGIN_ID} remains installed; migration is incomplete` });
342
504
  }
343
505
  }
344
- if (!cliOnly && legacyPlugin && readInstalledPluginRecords({ id: LEGACY_PLUGIN_ID })) {
345
- failures.push({ reason: `legacy Claude plugin ${LEGACY_PLUGIN_ID} remains installed; migration is incomplete` });
346
- }
347
- if (desktop && steps.some(step => step.kind === 'desktop') && !failedKinds.has('desktop')) {
506
+ if (desktop && steps.some(step => step.kind === 'desktop') && !failedKinds.has(':desktop')) {
348
507
  if (compareDesktopVersions(await installedDesktopVersion(desktop), currentVersion) < 0) {
349
508
  failures.push({ reason: 'The matching desktop installer is not published yet; retry `runlist update` after the product release completes.' });
350
509
  }
@@ -355,9 +514,10 @@ export async function runUpdate(argv, _config, opts = {}) {
355
514
  process.stdout.write(dim('the other steps completed; restart the affected host sessions to load the updated integrations.\n'));
356
515
  }
357
516
  process.exitCode = 1;
358
- return;
517
+ return { ok: false, failures, steps };
359
518
  }
360
519
  if (ran) {
361
520
  process.stdout.write(green('\n✓ restart the affected host sessions to load the updated integrations.\n'));
362
521
  }
522
+ return { ok: true, steps };
363
523
  }
package/src/use.mjs CHANGED
@@ -5,6 +5,7 @@ import { asString, die, resolveDocPath, toRepoPath } from './util.mjs';
5
5
  import { consumePrompt, pendingPromptsOldestFirst, resolvePromptInput } from './prompts.mjs';
6
6
  import { startPlan } from './lifecycle.mjs';
7
7
  import { resolveDocArg } from './index.mjs';
8
+ import { managesFile } from './handoffs-scope.mjs';
8
9
  import { LIMITS } from './brief.mjs';
9
10
  import { runShow } from './show.mjs';
10
11
 
@@ -33,8 +34,9 @@ export async function runUse(argv, config, opts = {}) {
33
34
  // collision — consuming a prompt is the more common intent), then the
34
35
  // shared resolver for plan/doc slugs, which dies with did-you-mean on miss.
35
36
  const cwdPath = path.resolve(process.cwd(), positional);
36
- const filePath = resolveDocPath(positional, config)
37
- ?? (existsSync(cwdPath) ? cwdPath : null)
37
+ const managed = file => (file && managesFile(file, config) ? file : null);
38
+ const filePath = managed(resolveDocPath(positional, config))
39
+ ?? (existsSync(cwdPath) ? managed(cwdPath) : null)
38
40
  ?? resolvePromptInput(positional, config, { dieOnMiss: false })
39
41
  ?? resolveDocArg(positional, config);
40
42
 
package/src/validate.mjs CHANGED
@@ -6,6 +6,7 @@ import { detectMarker, isPhaseHeading, phaseMarkerConflict, walkSections } from
6
6
  import { resolveBodyLinkTarget } from './body-link.mjs';
7
7
  import { getStageDefinitions, readPlanStage } from './stages.mjs';
8
8
  import { assessmentIssues } from './yardstick.mjs';
9
+ import { RETIRED_PROMPT_STATUSES } from './naming.mjs';
9
10
 
10
11
  const NOW = new Date();
11
12
 
@@ -98,6 +99,12 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
98
99
 
99
100
  if (!doc.status) {
100
101
  doc.errors.push({ path: doc.path, level: 'error', message: 'Missing frontmatter `status`.' });
102
+ } else if (doc.type === 'prompt' && RETIRED_PROMPT_STATUSES.has(doc.status) && !isValidStatus(doc.status, doc.root, config, doc.type)) {
103
+ // Saved by an older build, carried with its status unchanged.
104
+ const remedy = doc.status === 'claimed'
105
+ ? `\`runlist prompts archive ${doc.path}\` sets it aside`
106
+ : `\`runlist set pending ${doc.path}\` makes it pending again, or \`runlist prompts archive ${doc.path}\` sets it aside`;
107
+ doc.warnings.push({ path: doc.path, level: 'warning', message: `Retired prompt status \`${doc.status}\`, from an older runlist: ${remedy}.` });
101
108
  } else if (!isValidStatus(doc.status, doc.root, config, doc.type)) {
102
109
  const typeSet = doc.type && config.typeStatuses?.get(doc.type);
103
110
  const rootSet = config.rootValidStatuses?.get(doc.root);