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/install.mjs CHANGED
@@ -9,12 +9,13 @@ import {
9
9
  } from './host-integration.mjs';
10
10
  import { LEGACY_PLUGIN_ID, readInstalledPlugin, readInstalledPluginRecords } from './update.mjs';
11
11
  import { executableName, which } from './util.mjs';
12
- import { installationReport, formatInstallationReport } from './installation.mjs';
12
+ import { installationReport, formatInstallationReport, persistentCliOnPath, PERSISTENT_CLI_WARNING } from './installation.mjs';
13
13
  import { codexStatus, installCodexPlugin, installedCodexStatus } from './codex-integration.mjs';
14
14
 
15
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
16
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
17
17
 
18
+ const CLAUDE_CODE_SETUP_URL = 'https://code.claude.com/docs/en/setup';
18
19
  const HOSTS = ['claude', 'codex', 'opencode'];
19
20
  const ALIASES = { 'claude-code': 'claude', claudecode: 'claude' };
20
21
 
@@ -85,20 +86,23 @@ function reportStatus(json, config) {
85
86
  }
86
87
  }
87
88
 
88
- function installCodex(argv, dryRun, json) {
89
+ function installCodex(argv, dryRun, json, prerequisites) {
89
90
  if (argv.includes('--remove')) {
90
91
  process.stderr.write('Codex removal is managed by `codex plugin remove`; the personal marketplace source is left available.\n');
91
92
  process.exitCode = 1;
92
93
  return;
93
94
  }
94
- const result = installCodexPlugin({ version: pkg.version, dryRun });
95
+ const result = { ...installCodexPlugin({ version: pkg.version, dryRun }), ...prerequisites };
95
96
  if (result.action === 'refused') {
96
97
  if (json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
97
98
  else process.stderr.write(`refused: ${result.reason}\n`);
98
99
  process.exitCode = 1;
99
100
  return;
100
101
  }
101
- if (!json) process.stdout.write(`${dryRun ? '[dry-run] ' : ''}${result.action} codex integration (${pkg.version}) at ${result.plugin}\n`);
102
+ if (!json) {
103
+ const action = dryRun ? ({ installed: 'Would install', updated: 'Would update', current: 'Already current' }[result.action] ?? result.action) : result.action;
104
+ process.stdout.write(`${dryRun ? '[dry-run] ' : ''}${action} codex integration (${pkg.version}) at ${result.plugin}\n`);
105
+ }
102
106
  if (dryRun) {
103
107
  if (json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
104
108
  return;
@@ -125,11 +129,11 @@ function installCodex(argv, dryRun, json) {
125
129
  }
126
130
  }
127
131
 
128
- function installClaude(argv, dryRun, json) {
132
+ function installClaude(argv, dryRun, json, opts = {}, prerequisites = {}) {
129
133
  const remove = argv.includes('--remove');
130
- const steps = planClaudeInstall({ installed: readInstalledPlugin(), legacyInstalled: readInstalledPluginRecords({ id: LEGACY_PLUGIN_ID }), hasClaude: which('claude'), remove });
134
+ const steps = planClaudeInstall({ installed: readInstalledPlugin(), legacyInstalled: readInstalledPluginRecords({ id: LEGACY_PLUGIN_ID, ...(process.env.CLAUDE_CONFIG_DIR ? { configDir: process.env.CLAUDE_CONFIG_DIR } : {}) }), hasClaude: opts.claudeBin || which('claude'), remove });
131
135
  if (json) {
132
- process.stdout.write(JSON.stringify({ host: 'claude', dryRun, steps }, null, 2) + '\n');
136
+ process.stdout.write(JSON.stringify({ host: 'claude', dryRun, steps, ...prerequisites }, null, 2) + '\n');
133
137
  return;
134
138
  }
135
139
  let ran = false;
@@ -141,6 +145,17 @@ function installClaude(argv, dryRun, json) {
141
145
  continue;
142
146
  }
143
147
  if (step.kind === 'manual') {
148
+ if (step.lines.some(line => line.startsWith('/plugin install'))) {
149
+ // Newcomers mostly land here from Claude Desktop with no `claude` command, so lead with
150
+ // the documented terminal form. Desktop's plugin browser only lists marketplaces that are
151
+ // already registered, so it is where to find runlist afterwards, not where to add it.
152
+ process.stdout.write(`${step.reason ? `${step.reason}. ` : ''}${yellow('The claude CLI is not on PATH.')} Install the Claude Code CLI (${CLAUDE_CODE_SETUP_URL}), then run in a terminal:\n`);
153
+ for (const line of step.lines) process.stdout.write(` ${bold(line.replace(/^\/plugin /, 'claude plugin '))}\n`);
154
+ process.stdout.write('Or run these from inside a Claude Code session:\n');
155
+ for (const line of step.lines) process.stdout.write(` ${bold(line)}\n`);
156
+ process.stdout.write(dim('Claude Desktop\'s Code tab reads the same plugin settings as the terminal. Once the marketplace is added, runlist appears under Code tab → + → Add plugins.\n'));
157
+ continue;
158
+ }
144
159
  process.stdout.write(`${yellow(step.reason ?? 'claude CLI not on PATH')} — run these from a Claude Code session:\n`);
145
160
  for (const line of step.lines) process.stdout.write(` ${bold(line)}\n`);
146
161
  continue;
@@ -148,8 +163,8 @@ function installClaude(argv, dryRun, json) {
148
163
  if (step.reason) process.stdout.write(`${step.reason}\n`);
149
164
  if (dryRun) { process.stdout.write(dim(`[dry-run] Would run: ${step.cmd.join(' ')}${step.cwd ? ` (in ${step.cwd})` : ''}\n`)); continue; }
150
165
  process.stdout.write(dim(`$ ${step.cmd.join(' ')}${step.cwd ? ` (in ${step.cwd})` : ''}\n`));
151
- const result = spawnSync(executableName(step.cmd[0]), step.cmd.slice(1), {
152
- stdio: 'inherit', shell: process.platform === 'win32',
166
+ const result = spawnSync(opts.claudeBin || executableName(step.cmd[0]), step.cmd.slice(1), {
167
+ stdio: 'inherit', shell: process.platform === 'win32' && !opts.claudeBin,
153
168
  ...(step.cwd ? { cwd: step.cwd } : {}),
154
169
  });
155
170
  ran = true;
@@ -167,12 +182,12 @@ function installClaude(argv, dryRun, json) {
167
182
  if (ran && !remove && !process.exitCode) process.stdout.write(green('\n✓ restart Claude Code (or /reload-plugins) to apply.\n'));
168
183
  }
169
184
 
170
- function installOpencode(argv, dryRun, json) {
185
+ function installOpencode(argv, dryRun, json, prerequisites) {
171
186
  const force = argv.includes('--force');
172
187
  const dir = flagValue(argv, '--path');
173
- const result = argv.includes('--remove')
188
+ const result = { ...(argv.includes('--remove')
174
189
  ? removeOpencodePlugin({ dryRun, force, dir })
175
- : installOpencodePlugin({ version: pkg.version, dryRun, force, dir });
190
+ : installOpencodePlugin({ version: pkg.version, dryRun, force, dir })), ...prerequisites };
176
191
 
177
192
  if (json) {
178
193
  process.stdout.write(JSON.stringify(result, null, 2) + '\n');
@@ -197,13 +212,12 @@ function installOpencode(argv, dryRun, json) {
197
212
  process.stdout.write(dim('Restart OpenCode to apply. Sessions fall back to a process-scoped identity.\n'));
198
213
  return result;
199
214
  default:
200
- process.stdout.write(`${prefix}${green('✓')} ${result.action} opencode integration (${pkg.version})\n${dim(result.path)}\n`);
215
+ process.stdout.write(`${prefix}${dryRun ? ({ installed: 'Would install', updated: 'Would update' }[result.action] ?? result.action) : `${green('✓')} ${result.action}`} opencode integration (${pkg.version})\n${dim(result.path)}\n`);
201
216
  if (dryRun) return result;
202
217
  process.stdout.write('\nRestart OpenCode to apply. New sessions then get:\n');
203
218
  process.stdout.write(` ${dim('·')} a per-session ownership identity (one session can no longer release another's plan)\n`);
204
219
  process.stdout.write(` ${dim('·')} the ${bold('runlist hud')} primer at session start, like Claude Code's SessionStart hook\n`);
205
- process.stdout.write(dim('\nPlans already in-session were claimed under the old process-scoped identity;\n'));
206
- process.stdout.write(dim('close them before restarting, or reclaim with --force afterwards.\n'));
220
+ process.stdout.write(dim('\nIf plans were claimed under a previous process-scoped identity, close them before restarting or explicitly reclaim them afterwards.\n'));
207
221
  return result;
208
222
  }
209
223
  }
@@ -221,7 +235,10 @@ export function runInstall(argv, _config, opts = {}) {
221
235
  }
222
236
 
223
237
  const dryRun = Boolean(opts.dryRun);
224
- if (host === 'claude') { installClaude(argv, dryRun, json); return; }
225
- if (host === 'codex') { installCodex(argv, dryRun, json); return; }
226
- installOpencode(argv, dryRun, json);
238
+ const persistentCliMissing = !argv.includes('--remove') && !persistentCliOnPath().available;
239
+ const prerequisites = persistentCliMissing ? { persistentCliMissing: true } : {};
240
+ if (persistentCliMissing && !json) process.stderr.write(`Warning: ${PERSISTENT_CLI_WARNING}\n`);
241
+ if (host === 'claude') { installClaude(argv, dryRun, json, opts, prerequisites); return; }
242
+ if (host === 'codex') { installCodex(argv, dryRun, json, prerequisites); return; }
243
+ installOpencode(argv, dryRun, json, prerequisites);
227
244
  }
@@ -1,5 +1,4 @@
1
- import { createHash } from 'node:crypto';
2
- import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
1
+ import { accessSync, constants, existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
3
2
  import { createRequire } from 'node:module';
4
3
  import os from 'node:os';
5
4
  import path from 'node:path';
@@ -7,38 +6,122 @@ import { fileURLToPath } from 'node:url';
7
6
  import { desktopLaunch } from './desktop.mjs';
8
7
  import { codexStatus } from './codex-integration.mjs';
9
8
  import { opencodeStatus } from './host-integration.mjs';
9
+ import { sourceFingerprint } from './agent-hosts.mjs';
10
+ import { LEGACY_PRODUCT_NAME, PRODUCT_NAME } from './naming.mjs';
11
+ export { sourceFingerprint } from './agent-hosts.mjs';
10
12
 
11
13
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
14
  function json(file) { try { return JSON.parse(readFileSync(file,'utf8')); } catch { return null; } }
13
15
  function resolved(file) { try { return realpathSync(file); } catch { return null; } }
14
- function executable(name, env) {
15
- const suffixes = process.platform === 'win32' ? ['', '.cmd', '.exe'] : [''];
16
+ function executable(name, env, { cwd = process.cwd(), runnable = false, accept = () => true } = {}) {
17
+ const suffixes = process.platform === 'win32' ? ['', ...(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';')] : [''];
16
18
  for (const dir of (env.PATH ?? '').split(path.delimiter).filter(Boolean)) for (const suffix of suffixes) {
17
- const file = path.resolve(dir,name+suffix);
18
- try { if (lstatSync(file).isFile() || lstatSync(file).isSymbolicLink()) return file; } catch {}
19
+ const file = path.resolve(cwd, dir, name + suffix);
20
+ try {
21
+ if (!accept(file)) continue;
22
+ if (runnable) {
23
+ if (!statSync(file).isFile()) continue;
24
+ accessSync(file, constants.X_OK);
25
+ return file;
26
+ }
27
+ if (lstatSync(file).isFile() || lstatSync(file).isSymbolicLink()) return file;
28
+ } catch {}
19
29
  }
20
30
  return null;
21
31
  }
22
32
 
23
- // Hash only the shared engine inputs, never plans, state, credentials or runtime
24
- // binaries. Ignore the release label so a local candidate can prove code parity.
25
- export function sourceFingerprint(root, entries = ['package.json','src','bin','assets','desktop/helper.mjs','desktop/trust.mjs','desktop/line-framer.mjs']) {
26
- try {
27
- const files=[],hash=createHash('sha256');let bytes=0;
28
- const visit = name => {
29
- const file=path.join(root,name),stat=lstatSync(file);
30
- if(stat.isSymbolicLink())throw new Error('symlink');
31
- if(stat.isDirectory()) { for(const child of readdirSync(file).sort())visit(`${name}/${child}`); }
32
- else if(stat.isFile()) {
33
- bytes+=stat.size;if(bytes>64*1024*1024||files.length>=2000)throw new Error('limit');
34
- let content=readFileSync(file);
35
- if(name==='package.json'){const pkg=JSON.parse(content);delete pkg.version;content=Buffer.from(JSON.stringify(pkg));}
36
- hash.update(name+'\0').update(createHash('sha256').update(content).digest());files.push(name);
33
+ export const PERSISTENT_CLI_WARNING = 'Host hooks need a globally installed `runlist` on PATH and will not run without one. A temporary npx/one-shot copy does not count; run `npm install -g runlist`, then restart/reload the host.';
34
+
35
+ export function isOneShotCliPath(file) {
36
+ const value = file.replace(/\\/g, '/');
37
+ return /(?:^|\/)_npx\/[^/]+\/node_modules(?:\/|$)/.test(value)
38
+ || /(?:^|\/)dlx\/(?:[a-z2-7]{26}|[a-f0-9]{32,64})\/[^/]+\/node_modules(?:\/|$)/i.test(value)
39
+ || /(?:^|\/)tmp\/dlx-[0-9]+\/node_modules(?:\/|$)/.test(value)
40
+ || /(?:^|\/)bunx-[0-9]+-[^/]+\/node_modules(?:\/|$)/.test(value)
41
+ || /(?:^|\/)\.bunx-[0-9]+\/[^/]+\/node_modules(?:\/|$)/.test(value);
42
+ }
43
+
44
+ export function persistentCliOnPath({ env = process.env, cwd = process.cwd() } = {}) {
45
+ const normalize = file => {
46
+ const value = path.resolve(cwd, file).replace(/\\/g, '/');
47
+ return process.platform === 'win32' ? value.toLowerCase() : value;
48
+ };
49
+ const injectedBins = new Set();
50
+ const excludeBin = bin => {
51
+ injectedBins.add(normalize(bin));
52
+ injectedBins.add(normalize(resolved(bin) || bin));
53
+ };
54
+ // Evidence: npm 11.20.0, offline npx --package <local tarball> <probe>,
55
+ // exported npm_command=exec, npm_lifecycle_event=npx and prepended
56
+ // <cache>/_npx/<hash>/node_modules/.bin, then project/ancestor bins.
57
+ // libnpmexec/lib/index.js and @npmcli/run-script/lib/set-path.js confirm
58
+ // those shapes. Also observed with pnpm 11.17.0 and a local probe tarball:
59
+ // $XDG_CACHE_HOME/pnpm/dlx/<hash>/<id>/node_modules/.bin on PATH.
60
+ // pnpm v11.17.0 pnpm11/exec/commands/src/dlx.ts builds that shape under
61
+ // any configured cacheDir, using a 32-hex key (v10.11.0 uses 64).
62
+ // Its config/reader/src/dirs.ts defaults to pnpm-cache on Windows.
63
+ // v9.15.9 uses the same layout with a 26-character base32 cache key,
64
+ // also observed with a local runlist tarball in an isolated HOME/cache.
65
+ // pnpm v8.15.9 exec/plugin-commands-script-runners/src/dlx.ts instead
66
+ // prepends <storeDir>/tmp/dlx-<pid>/node_modules/.bin. Source-verified:
67
+ // https://github.com/pnpm/pnpm/blob/v11.17.0/pnpm11/exec/commands/src/dlx.ts
68
+ // https://github.com/pnpm/pnpm/blob/v9.15.9/exec/plugin-commands-script-runners/src/dlx.ts
69
+ // https://github.com/pnpm/pnpm/blob/v8.15.9/exec/plugin-commands-script-runners/src/dlx.ts
70
+ // Source-inferred (not runtime-tested here): Bun bun-v1.3.3's
71
+ // src/cli/bunx_command.zig uses <tmp>/bunx-<uid>-<package>/node_modules/.bin;
72
+ // current src/runtime/cli/bunx_command.rs also uses
73
+ // <cache-or-tmp>/.bunx-<uid>/<package>/node_modules/.bin on Unix.
74
+ // Match whole path segments under these roots, including resolved links.
75
+ // These are one-shot launchers regardless of npm env markers.
76
+ // npm also injects project/ancestor bins,
77
+ // even for local devDependencies; exclude those while under npm.
78
+ // Do not infer persistence from the launcher.
79
+ if (env.npm_command || env.npm_execpath || env.npm_lifecycle_event) {
80
+ for (const start of [cwd, env.INIT_CWD, env.npm_config_local_prefix, env.npm_package_json && path.dirname(env.npm_package_json)].filter(Boolean)) {
81
+ let dir = path.resolve(cwd, start);
82
+ for (;;) {
83
+ const bin = path.join(dir, 'node_modules', '.bin');
84
+ excludeBin(bin);
85
+ const parent = path.dirname(dir);
86
+ if (parent === dir) break;
87
+ dir = parent;
37
88
  }
38
- };
39
- for(const name of [...entries].sort())if(existsSync(path.join(root,name)))visit(name);
40
- return files.length ? {state:'readable',sha256:hash.digest('hex'),files:files.length} : {state:'unavailable'};
41
- } catch { return {state:'unreadable'}; }
89
+ }
90
+ }
91
+ // Yarn 4.9.2 scriptUtils.ts: executePackageAccessibleBinary and
92
+ // makeScriptEnv create a separate <tmp>/xfs-<id> wrapper folder, prepend
93
+ // it to PATH and export BERRY_BIN_FOLDER. run/exec use it too. The wrappers
94
+ // are regular files; their realpaths do not reveal the dlx package folder.
95
+ // Observed with a local probe tarball and source-verified at
96
+ // a9edb7777f04ba16f51503ef6775325b353b67cc:
97
+ // https://github.com/yarnpkg/berry/blob/a9edb7777f04ba16f51503ef6775325b353b67cc/packages/yarnpkg-core/sources/scriptUtils.ts
98
+ // Nested Yarn calls retain outer wrapper folders on PATH while replacing
99
+ // BERRY_BIN_FOLDER (observed with a devDependency via `yarn run ybin`, whose
100
+ // script calls `yarn runlist`). xfs.ts creates xfs-<8 hex> under os.tmpdir():
101
+ // https://github.com/yarnpkg/berry/blob/a9edb7777f04ba16f51503ef6775325b353b67cc/packages/yarnpkg-fslib/sources/xfs.ts
102
+ // Exclude all xfs wrapper folders under the real temp root
103
+ // only while Yarn's marker is set; an ordinary shell may use such a folder.
104
+ if (env.BERRY_BIN_FOLDER) {
105
+ excludeBin(env.BERRY_BIN_FOLDER);
106
+ const tmp = normalize(resolved(os.tmpdir()) || os.tmpdir());
107
+ for (const bin of (env.PATH ?? '').split(path.delimiter).filter(Boolean)) {
108
+ const realBin = resolved(path.resolve(cwd, bin)) || path.resolve(cwd, bin);
109
+ if (normalize(path.dirname(realBin)) === tmp && /^xfs-[0-9a-f]{8}$/.test(path.basename(realBin))) excludeBin(bin);
110
+ }
111
+ }
112
+ const inCache = file => isOneShotCliPath(normalize(file));
113
+ const accept = file => {
114
+ const dir = path.dirname(file), realDir = resolved(dir) || dir;
115
+ const realFile = resolved(file) || file;
116
+ return !injectedBins.has(normalize(dir)) && !injectedBins.has(normalize(realDir))
117
+ && !injectedBins.has(normalize(path.dirname(realFile)))
118
+ && !inCache(dir) && !inCache(realDir) && !inCache(realFile);
119
+ };
120
+ for (const name of [PRODUCT_NAME, LEGACY_PRODUCT_NAME]) {
121
+ const file = executable(name, env, { cwd, runnable: true, accept });
122
+ if (file) return { available: true, path: file };
123
+ }
124
+ return { available: false, path: null };
42
125
  }
43
126
 
44
127
  function identity(root) {
@@ -49,8 +132,8 @@ function identity(root) {
49
132
  }
50
133
 
51
134
  // Shared with update; reading a host registry does not activate or modify it.
52
- export function readInstalledPluginRecords({home=os.homedir(),id}={}) {
53
- const plugins=json(path.join(home,'.claude/plugins/installed_plugins.json'))?.plugins??{};
135
+ export function readInstalledPluginRecords({home=os.homedir(),configDir=path.join(home,'.claude'),id}={}) {
136
+ const plugins=json(path.join(configDir,'plugins/installed_plugins.json'))?.plugins??{};
54
137
  id ??= plugins['runlist@runlist']?'runlist@runlist':plugins['dotmd@dotmd']?'dotmd@dotmd':Object.keys(plugins).find(k=>/^(?:runlist|dotmd)@/.test(k));
55
138
  if(!id||!plugins[id])return null;
56
139
  return {id,entries:(Array.isArray(plugins[id])?plugins[id]:[plugins[id]]).filter(Boolean)};
@@ -79,7 +162,7 @@ export function installationReport(config={}, {root=packageRoot,home=os.homedir(
79
162
  const cache=path.join(home,'.codex/plugins/cache',market,'runlist-codex');
80
163
  let cached=[];
81
164
  try {cached=readdirSync(cache).slice(0,20).map(version=>({path:path.join(cache,version),version,role:'cached copy; active host copy unverified'}));}catch{}
82
- const pluginFiles=['skills','commands','hooks','.claude-plugin','.codex-plugin'];
165
+ const pluginFiles=['skills','commands','hooks','hooks.json','bin','.claude-plugin','.codex-plugin'];
83
166
  const pluginCopy = (file,version,role,source) => {
84
167
  const fingerprint=file?sourceFingerprint(file,pluginFiles):{state:'unavailable'},expected=sourceFingerprint(source,pluginFiles);
85
168
  return {path:file,version:version??null,role,fingerprint,sameSource:fingerprint.sha256&&expected.sha256?fingerprint.sha256===expected.sha256:null};
package/src/journal.mjs CHANGED
@@ -27,7 +27,7 @@ const REDACTED = '[redacted]';
27
27
  const SENSITIVE_VALUE_FLAGS = new Set([
28
28
  '--body', '--message', '--note', '--reason', '--question', '--answers', '--choice', '--title',
29
29
  '--token', '--password', '--passphrase', '--secret', '--api-key', '--apikey',
30
- '--auth', '--authorization', '--cookie', '--header',
30
+ '--auth', '--authorization', '--cookie', '--header', '--current-state', '--next-step',
31
31
  ]);
32
32
 
33
33
  function sanitizeCredentialShapes(value) {
@@ -120,7 +120,7 @@ export function sanitizeTelemetryText(value, secrets = []) {
120
120
  text = text.split(secret).join(REDACTED);
121
121
  }
122
122
  return sanitizeCredentialShapes(text)
123
- .replace(/((?:--body|--message|--note|--reason|--token|--password|--passphrase|--secret|--api-key|--apikey|--auth|--authorization|--cookie|--header)(?:=|\s+))([^\s]+)/gi, `$1${REDACTED}`)
123
+ .replace(/((?:--body|--message|--note|--reason|--token|--password|--passphrase|--secret|--api-key|--apikey|--auth|--authorization|--cookie|--header|--current-state|--next-step)(?:=|\s+))([^\s]+)/gi, `$1${REDACTED}`)
124
124
  .replace(/(git\s+commit\b[^\n]*?(?:\s-m|\s--message)(?:=|\s+))((?:"[^"]*")|(?:'[^']*')|[^\s]+)/gi, `$1${REDACTED}`)
125
125
  .replace(/((?:sed|perl|g?awk)\b[^\n]*?\s(?:-e\s+)?)((?:"[^"]*")|(?:'[^']*'))/gi, `$1${REDACTED}`);
126
126
  }
package/src/lifecycle.mjs CHANGED
@@ -33,6 +33,8 @@ import {
33
33
  updateOwnershipOperation,
34
34
  validatedClaimOperation,
35
35
  } from './pickup.mjs';
36
+ import { assertInsideHandoffs } from './handoffs-scope.mjs';
37
+ import { LEGACY_PROMPT_DIR, PROMPT_DIR, rootIsWorkingFolder } from './naming.mjs';
36
38
 
37
39
  export function renderLifecycleMutation(raw, updates, historyEntry, { createSection = false, bodyTransform = null } = {}) {
38
40
  raw = normalizeEol(raw);
@@ -165,20 +167,24 @@ export function updateFrontmatterAtomic(filePath, updates, config, options = {})
165
167
  });
166
168
  }
167
169
 
168
- function defaultTypeDir(docType, config) {
169
- if (docType === 'plan') return 'plans';
170
- if (docType === 'prompt') return 'prompts';
170
+ // Before any config the root is the working folder, so prompts sit two levels
171
+ // down in docs/prompts/; one an older build saved in a root prompts/ still files
172
+ // beside itself until it is carried over (src/naming.mjs).
173
+ function defaultTypeDirs(docType, config) {
174
+ if (docType === 'plan') return ['plans'];
175
+ if (docType === 'prompt') return rootIsWorkingFolder(config) ? [PROMPT_DIR, LEGACY_PROMPT_DIR] : ['prompts'];
171
176
  const templateDir = config.raw?.templates?.[docType]?.dir;
172
- return typeof templateDir === 'string' && templateDir ? templateDir : null;
177
+ return typeof templateDir === 'string' && templateDir ? [templateDir] : [];
173
178
  }
174
179
 
175
180
  function findFilingRoot(filePath, fileRoot, docType, config) {
176
- const dirName = defaultTypeDir(docType, config);
177
- if (!dirName) return fileRoot;
178
- if (path.basename(fileRoot) === dirName) return fileRoot;
179
-
180
181
  const relSegments = path.relative(fileRoot, filePath).split(path.sep);
181
- if (relSegments[0] === dirName) return path.join(fileRoot, dirName);
182
+ for (const dirName of defaultTypeDirs(docType, config)) {
183
+ // A template's own dir is one folder name, as it always was.
184
+ const segments = dirName === PROMPT_DIR ? dirName.split('/') : [dirName];
185
+ if (fileRoot.split(path.sep).slice(-segments.length).join('/') === dirName) return fileRoot;
186
+ if (segments.every((segment, i) => relSegments[i] === segment)) return path.join(fileRoot, ...segments);
187
+ }
182
188
  return fileRoot;
183
189
  }
184
190
 
@@ -188,7 +194,7 @@ function findFilingRoot(filePath, fileRoot, docType, config) {
188
194
  // prompt churn doesn't bury plans/docs in the shared <root>/<archiveDir>.
189
195
  // Everything else archives under fileRoot. Used by both runStatus (set
190
196
  // archived) and runArchive so the two paths stay in lockstep.
191
- function archiveBaseFor(filePath, fileRoot, docType, config) {
197
+ export function archiveBaseFor(filePath, fileRoot, docType, config) {
192
198
  const nest = config.lifecycle?.archiveNestedTypes?.has(docType) ?? false;
193
199
  return nest ? findFilingRoot(filePath, fileRoot, docType, config) : fileRoot;
194
200
  }
@@ -378,7 +384,9 @@ export async function runStatus(argv, config, opts = {}) {
378
384
  let newStatus = argv[1];
379
385
 
380
386
  if (!opts.suppressDeprecation) {
381
- process.stderr.write(dim('`runlist status <file> <status>` is deprecated; prefer `runlist set <status> [<file>]` (note: <status> first; <file> optional when a plan is in-session). Removed in a future major.\n'));
387
+ process.stderr.write(dim(config.mode === 'handoffs'
388
+ ? '`runlist status <file> <status>` is deprecated; prefer `runlist set <status> <prompt>` (note: <status> first). Removed in a future major.\n'
389
+ : '`runlist status <file> <status>` is deprecated; prefer `runlist set <status> [<file>]` (note: <status> first; <file> optional when a plan is in-session). Removed in a future major.\n'));
382
390
  }
383
391
 
384
392
  if (!input) { die('Usage: runlist status <file> <new-status>'); }
@@ -979,6 +987,8 @@ export async function runSet(argv, config, opts = {}) {
979
987
 
980
988
  if (!newStatus) die('Usage: runlist set <status> [<path>]');
981
989
  let sessionId = null;
990
+ // Where planning is off there is no claimed plan to default to.
991
+ if (!input && config.mode === 'handoffs') die(`Name the prompt to change: runlist set ${newStatus} <prompt>`);
982
992
  if (!input) {
983
993
  sessionId = authoritativeSessionId();
984
994
  const owned = listOwnedPlans(config, sessionId);
@@ -1119,6 +1129,7 @@ export function runBulkArchive(argv, config, opts = {}) {
1119
1129
  const matched = [];
1120
1130
 
1121
1131
  for (const input of inputs) {
1132
+ assertInsideHandoffs(input, config);
1122
1133
  const filePath = resolveDocPath(input, config);
1123
1134
  if (filePath) {
1124
1135
  matched.push(filePath);
@@ -1,5 +1,7 @@
1
1
  import { existsSync, lstatSync, realpathSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { managesFile, outsideHandoffsLine } from './handoffs-scope.mjs';
4
+ import { LEGACY_PROMPT_DIR, PROMPT_DIR } from './naming.mjs';
3
5
 
4
6
  // These checks are authorization preflights, not filesystem transactions. A
5
7
  // concurrent actor can still replace a checked ancestor before the later write
@@ -38,13 +40,20 @@ function lexicalRootsFor(config) {
38
40
  }));
39
41
  }
40
42
 
41
- function rootsFor(config) {
43
+ function rootsFor(config, { allowMissingRoots = false } = {}) {
42
44
  return lexicalRootsFor(config).map(root => {
43
45
  const { configuredPath, lexicalPath } = root;
44
46
  let canonicalPath;
45
47
  try {
46
48
  canonicalPath = realpathSync(lexicalPath);
47
49
  } catch (err) {
50
+ if (allowMissingRoots && err.code === 'ENOENT') {
51
+ const ancestor = nearestExistingAncestor(lexicalPath, [root], 'Configured docs root');
52
+ let existing = lexicalPath;
53
+ while (!existsSync(existing)) existing = path.dirname(existing);
54
+ canonicalPath = path.resolve(ancestor, path.relative(existing, lexicalPath));
55
+ return { configuredPath, lexicalPath, canonicalPath };
56
+ }
48
57
  throw new Error(`Configured docs root cannot be resolved: ${lexicalPath} (${err.message})`);
49
58
  }
50
59
  return { configuredPath, lexicalPath, canonicalPath };
@@ -95,8 +104,8 @@ export function findLexicalDocsRoot(input, config) {
95
104
  return lexicalOwner(path.resolve(input), lexicalRootsFor(config))?.lexicalPath ?? null;
96
105
  }
97
106
 
98
- export function authorizeManagedSource(input, config, { kind = 'Managed mutation source' } = {}) {
99
- const roots = rootsFor(config);
107
+ export function authorizeManagedSource(input, config, { kind = 'Managed mutation source', allowMissingRoots = false } = {}) {
108
+ const roots = rootsFor(config, { allowMissingRoots });
100
109
  const lexicalPath = path.resolve(input);
101
110
  markdown(lexicalPath, kind, roots);
102
111
 
@@ -116,6 +125,9 @@ export function authorizeManagedSource(input, config, { kind = 'Managed mutation
116
125
  if (!statSync(canonicalPath).isFile()) {
117
126
  throw new Error(`${kind} is not a file: ${lexicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
118
127
  }
128
+ if (!managesFile(canonicalPath, config)) {
129
+ throw new Error(`${kind}: ${outsideHandoffsLine(path.relative(config.repoRoot, lexicalPath).split(path.sep).join('/'))}`);
130
+ }
119
131
 
120
132
  const lexical = lexicalOwner(lexicalPath, roots);
121
133
  if (lexical && !aliasContainment(lexical.canonicalPath, canonicalPath).contained) {
@@ -137,10 +149,62 @@ export function authorizeManagedSource(input, config, { kind = 'Managed mutation
137
149
  return { path: managedPath, canonicalPath, root };
138
150
  }
139
151
 
140
- export function authorizeManagedDestination(input, config, { root: requiredRoot = null, kind = 'Managed mutation destination' } = {}) {
152
+ // One prompt location (src/naming.mjs): before any config, and where
153
+ // planning is off, nothing is created in the root prompts/ an older runlist
154
+ // saved prompts in. Its prompts are carried out to docs/prompts/ instead.
155
+ // Transaction recovery restoring a carry's source is not a new file.
156
+ // Whether `file` is in the root prompts/, by its path or by where its folders
157
+ // physically lead: a docs/ that is a symlink to prompts/ puts docs/prompts/
158
+ // inside it. Configured planning projects keep the prompt folder they chose,
159
+ // so callers ask only before any config or where planning is off.
160
+ export function inLegacyPromptFolder(file, repoRoot) {
161
+ const top = path.resolve(repoRoot);
162
+ const legacy = path.join(top, LEGACY_PROMPT_DIR);
163
+ const lexical = path.resolve(file);
164
+ if (contains(legacy, lexical)) return true;
165
+ let existing = lexical;
166
+ const rest = [];
167
+ while (!existsSync(existing)) {
168
+ const parent = path.dirname(existing);
169
+ if (parent === existing) return false;
170
+ rest.unshift(path.basename(existing));
171
+ existing = parent;
172
+ }
173
+ try {
174
+ const physical = path.join(realpathSync(existing), ...rest);
175
+ const physicalLegacy = existsSync(legacy) ? realpathSync(legacy) : path.join(realpathSync(top), LEGACY_PROMPT_DIR);
176
+ return contains(physicalLegacy, physical);
177
+ } catch { return false; }
178
+ }
179
+
180
+ export function legacyPromptFolderMessage(file, repoRoot, kind) {
181
+ const relative = path.relative(repoRoot, path.resolve(file)).split(path.sep).join('/') || '.';
182
+ return `${kind}: ${relative} ${legacyPromptFolderPlace(file, repoRoot)}, where runlist no longer writes; prompts live in ${PROMPT_DIR}/.`;
183
+ }
184
+
185
+ export function legacyPromptFolderPlace(file, repoRoot) {
186
+ return contains(path.join(path.resolve(repoRoot), LEGACY_PROMPT_DIR), path.resolve(file))
187
+ ? `is in ${LEGACY_PROMPT_DIR}/ at the project root`
188
+ : `leads into ${LEGACY_PROMPT_DIR}/ at the project root through a symlinked folder`;
189
+ }
190
+
191
+ function assertNotLegacyPromptFolder(lexicalPath, config, kind) {
192
+ if (config.configFound && config.mode !== 'handoffs') return;
193
+ if (!inLegacyPromptFolder(lexicalPath, config.repoRoot)) return;
194
+ throw new Error(legacyPromptFolderMessage(lexicalPath, config.repoRoot, kind));
195
+ }
196
+
197
+ // Files runlist writes outside the document roots (exports): before any
198
+ // config and where planning is off, never into the root prompts/.
199
+ export function assertOutsideLegacyPromptFolder(file, config, kind) {
200
+ assertNotLegacyPromptFolder(path.resolve(file), config, kind);
201
+ }
202
+
203
+ export function authorizeManagedDestination(input, config, { root: requiredRoot = null, kind = 'Managed mutation destination', recovery = false } = {}) {
141
204
  const roots = rootsFor(config);
142
205
  const lexicalPath = path.resolve(input);
143
206
  markdown(lexicalPath, kind, roots);
207
+ if (!recovery) assertNotLegacyPromptFolder(lexicalPath, config, kind);
144
208
 
145
209
  const root = requiredRoot
146
210
  ? roots.find(candidate => candidate.lexicalPath === requiredRoot.lexicalPath && candidate.canonicalPath === requiredRoot.canonicalPath)
package/src/migrate.mjs CHANGED
@@ -6,6 +6,7 @@ import { collectDocFiles } from './index.mjs';
6
6
  import { updateFrontmatter } from './lifecycle.mjs';
7
7
  import { bold, green, dim } from './color.mjs';
8
8
  import { authorizeManagedSweep } from './managed-path.mjs';
9
+ import { assertInsideHandoffs, isHandoffsMode, planningRefusal } from './handoffs-scope.mjs';
9
10
 
10
11
  export function runMigrate(argv, config, opts = {}) {
11
12
  const { dryRun } = opts;
@@ -25,6 +26,11 @@ export function runMigrate(argv, config, opts = {}) {
25
26
  if (!field || !oldValue || !newValue) {
26
27
  die('Usage: runlist migrate <field> <old-value> <new-value> [files...]');
27
28
  }
29
+ // A handoffs-only project manages prompts and nothing else: a prompt given
30
+ // another type would drop out of every listing (src/handoffs-scope.mjs).
31
+ if (isHandoffsMode(config) && field === 'type' && newValue !== 'prompt') {
32
+ die(planningRefusal(`\`runlist migrate type ${oldValue} ${newValue}\``));
33
+ }
28
34
 
29
35
  const allFiles = collectDocFiles(config);
30
36
 
@@ -34,6 +40,7 @@ export function runMigrate(argv, config, opts = {}) {
34
40
  const matched = [];
35
41
  const unresolved = [];
36
42
  for (const input of fileArgs) {
43
+ assertInsideHandoffs(input, config);
37
44
  const filePath = resolveDocPath(input, config);
38
45
  if (filePath) {
39
46
  matched.push(filePath);
package/src/naming.mjs CHANGED
@@ -22,6 +22,32 @@ export const CONFIG_FILENAMES = [
22
22
  'dotmd.config.js',
23
23
  ];
24
24
 
25
+ // Where runlist writes prompts before any config, in a handoffs-only project
26
+ // and in a project `runlist init` starts. Builds before that saved them in a
27
+ // root prompts/ when there was no config; that folder is now only a source to
28
+ // carry them from (src/setup-carry.mjs), never a place anything new is
29
+ // written. A configured planning project keeps the prompt folder it chose.
30
+ export const PROMPT_DIR = 'docs/prompts';
31
+ export const LEGACY_PROMPT_DIR = 'prompts';
32
+
33
+ // Prompt statuses older builds wrote and later dropped: a prompt saved with
34
+ // one is still one runlist saved (carried like any other), and `check` names
35
+ // the verb that settles it rather than calling the status unknown.
36
+ export const RETIRED_PROMPT_STATUSES = new Set(['held', 'shelved', 'claimed']);
37
+
38
+ // The prompt routing a config written by `runlist setup` spells out, so its
39
+ // prompts go to <docs root>/prompts/ whatever the root is called. Without it
40
+ // the built-in routing reads a root whose name ends in "prompts" (notes/prompts,
41
+ // review-prompts) as the prompt folder itself, as configured projects rely on.
42
+ export const SETUP_PROMPT_ROUTING = Object.freeze({ dir: 'prompts', targetRoot: null });
43
+
44
+ // Before any config the docs root is the working folder itself, so prompts sit
45
+ // two levels down in it. A layout projected for a config about to be written
46
+ // (init, setup) has a docs root of its own and keeps the usual prompts/ in it.
47
+ export function rootIsWorkingFolder(config) {
48
+ return !config.configFound && Boolean(config.configDir) && path.resolve(config.docsRoot) === path.resolve(config.configDir);
49
+ }
50
+
25
51
  export const ENV_PREFIX = 'RUNLIST_';
26
52
  export const LEGACY_ENV_PREFIX = 'DOTMD_';
27
53