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.
Files changed (58) hide show
  1. package/GETTING_STARTED.md +249 -0
  2. package/README.md +414 -46
  3. package/bin/runlist.mjs +402 -51
  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
@@ -1,6 +1,7 @@
1
1
  // Codex's default personal marketplace is discovered without a CLI registration.
2
2
  // The npm package carries this plugin so installation does not need a Git checkout.
3
- import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { createHash } from 'node:crypto';
4
5
  import os from 'node:os';
5
6
  import path from 'node:path';
6
7
  import { fileURLToPath } from 'node:url';
@@ -11,6 +12,35 @@ const SOURCE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..',
11
12
  const NAME = 'runlist-codex';
12
13
  const MARKER = '.runlist-generated.json';
13
14
 
15
+ // Compare the whole distributed tree, including hook scripts and manifests.
16
+ // The ownership marker is metadata, never an asset or proof of source parity.
17
+ export function codexAssetFingerprint(root) {
18
+ const hash = createHash('sha256');
19
+ let files = 0, bytes = 0;
20
+ function visit(dir, prefix = '', depth = 0) {
21
+ if (depth > 32) throw new Error('asset depth limit');
22
+ for (const name of readdirSync(dir).sort()) {
23
+ if (!prefix && name === MARKER) continue;
24
+ const relative = prefix ? `${prefix}/${name}` : name;
25
+ const file = path.join(dir, name);
26
+ const stat = lstatSync(file);
27
+ if (++files > 2000 || (bytes += stat.size) > 64 * 1024 * 1024) throw new Error('asset inventory limit');
28
+ const kind = stat.isSymbolicLink() ? 'link' : stat.isDirectory() ? 'dir' : stat.isFile() ? 'file' : 'other';
29
+ hash.update(JSON.stringify([relative, kind, kind === 'file' ? stat.mode & 0o111 : null]) + '\n');
30
+ if (kind === 'dir') visit(file, relative, depth + 1);
31
+ else if (kind === 'link') hash.update(JSON.stringify(readlinkSync(file)) + '\n');
32
+ else if (kind === 'file') hash.update(readFileSync(file));
33
+ }
34
+ }
35
+ try { visit(root); return hash.digest('hex'); } catch { return null; }
36
+ }
37
+
38
+ export function codexAssetParity(installedPath, sourcePath = SOURCE) {
39
+ const expected = codexAssetFingerprint(sourcePath);
40
+ const actual = codexAssetFingerprint(installedPath);
41
+ return { state: !expected || !actual ? 'unknown' : expected === actual ? 'match' : 'mismatch', expected, actual };
42
+ }
43
+
14
44
  export function codexPaths(homedir = os.homedir()) {
15
45
  const root = path.join(homedir, '.agents', 'plugins');
16
46
  // The implicit personal marketplace file lives under ~/.agents/plugins,
@@ -32,24 +62,44 @@ export function codexStatus({ version, homedir = os.homedir() } = {}) {
32
62
  let installed = null;
33
63
  try { installed = JSON.parse(readFileSync(path.join(paths.plugin, MARKER), 'utf8')).version; } catch { /* absent or foreign */ }
34
64
  const exists = existsSync(paths.plugin);
35
- return { ...paths, exists, foreign: exists && !installed, version: installed, stale: Boolean(installed && version && installed !== version) };
65
+ const sourceParity = installed ? codexAssetParity(paths.plugin) : { state: 'unknown' };
66
+ let marketplaceState = existsSync(paths.marketplace) ? 'unreadable' : 'missing';
67
+ let marketplaceRegistered = false, foreignRegistration = false;
68
+ try {
69
+ const marketplace = marketplaceAt(paths.marketplace);
70
+ const entry = marketplace.plugins.find(item => item?.name === NAME);
71
+ marketplaceState = existsSync(paths.marketplace) ? 'readable' : 'missing';
72
+ marketplaceRegistered = entry?.source?.source === 'local' && entry.source.path === `./plugins/${NAME}`;
73
+ foreignRegistration = Boolean(entry && !marketplaceRegistered);
74
+ } catch { /* An unreadable registration cannot establish a managed source. */ }
75
+ return { ...paths, exists, foreign: (exists && !installed) || foreignRegistration, version: installed, sourceParity,
76
+ marketplaceState, marketplaceRegistered,
77
+ stale: Boolean(installed && ((version && installed !== version) || sourceParity.state !== 'match' || !marketplaceRegistered)) };
36
78
  }
37
79
 
38
80
  // The marketplace source is not the plugin Codex loads. Ask the host about its
39
81
  // installed copy rather than treating our generated source marker as proof.
40
- export function installedCodexStatus({ homedir = os.homedir(), run = spawnSync } = {}) {
82
+ export function installedCodexStatus({ homedir = os.homedir(), run = spawnSync, env = process.env, binary, offline = false } = {}) {
83
+ if (offline) return { ok: false, state: 'unknown', loaded: 'unknown', reason: 'offline inventory cannot attest the host installed copy or loaded session' };
41
84
  try {
42
85
  const marketplace = marketplaceAt(codexPaths(homedir).marketplace).name;
43
86
  const pluginId = `${NAME}@${marketplace}`;
44
- const result = run(executableName('codex'), ['plugin', 'list', '--marketplace', marketplace, '--json'], {
45
- encoding: 'utf8', shell: process.platform === 'win32', timeout: 30_000,
87
+ const result = run(binary || executableName('codex'), ['plugin', 'list', '--marketplace', marketplace, '--json'], {
88
+ encoding: 'utf8', shell: process.platform === 'win32' && (!binary || /\.(?:cmd|bat)$/i.test(binary)), timeout: 30_000, env: { ...env, HOME: homedir, USERPROFILE: homedir },
46
89
  });
47
90
  if (result.status !== 0) return { ok: false, reason: result.error?.message ?? `codex plugin list exited ${result.status ?? '?'}` };
48
91
  const installed = JSON.parse(result.stdout).installed;
49
92
  if (!Array.isArray(installed)) return { ok: false, reason: 'codex plugin list returned no installed plugin records' };
50
93
  const plugin = installed.find(entry => entry.pluginId === pluginId && entry.installed === true);
51
- return plugin?.version ? { ok: true, version: plugin.version, pluginId }
52
- : { ok: false, reason: `${pluginId} is not installed in Codex` };
94
+ if (!plugin?.version) return { ok: false, reason: `${pluginId} is not installed in Codex` };
95
+ // Host versions do not establish byte parity. Inspect a host-provided path
96
+ // when this CLI supports one, otherwise retain the explicit unknown state.
97
+ const installedPath = plugin.installPath ?? plugin.installedPath ?? plugin.pluginPath ?? plugin.path;
98
+ const sourceParity = typeof installedPath === 'string' && path.isAbsolute(installedPath)
99
+ ? codexAssetParity(installedPath, codexPaths(homedir).plugin) : { state: 'unknown' };
100
+ return { ok: sourceParity.state !== 'mismatch', version: plugin.version, pluginId, sourceParity, loaded: 'unknown',
101
+ ...(sourceParity.state === 'mismatch' ? { reason: 'installed Codex plugin assets differ from the managed source' } : {}),
102
+ ...(typeof installedPath === 'string' && path.isAbsolute(installedPath) ? { installedPath } : {}) };
53
103
  } catch (err) {
54
104
  return { ok: false, reason: `cannot verify installed Codex plugin: ${err.message}` };
55
105
  }
package/src/commands.mjs CHANGED
@@ -7,8 +7,9 @@ const flag = (...names) => Object.freeze({ names: Object.freeze(names), arity: 0
7
7
  const value = (...names) => Object.freeze({ names: Object.freeze(names), arity: 1 });
8
8
  const optionalValue = (...names) => Object.freeze({ names: Object.freeze(names), arity: 'optional' });
9
9
  const positionals = (min = 0, max = min) => Object.freeze({ min, max });
10
- const form = (syntax, { subcommands = [], args = positionals(), options = [], passthrough = false, dashPositionalsAfter = Infinity } = {}) => Object.freeze({
10
+ const form = (syntax, { subcommands = [], args = positionals(), options = [], passthrough = false, dashPositionalsAfter = Infinity, planning } = {}) => Object.freeze({
11
11
  syntax,
12
+ planning,
12
13
  subcommands: Object.freeze(subcommands),
13
14
  positionals: args,
14
15
  options: Object.freeze(options),
@@ -47,12 +48,29 @@ const command = (name, policy, group, forms, extra = {}) => Object.freeze({
47
48
  forms: Object.freeze(forms),
48
49
  aliases: Object.freeze(extra.aliases ?? []),
49
50
  visibility: extra.visibility ?? 'public',
51
+ planning: extra.planning ?? null,
50
52
  });
51
53
 
54
+ // `planning` marks a command (or one form of it) that only means something
55
+ // with planning on. In a handoffs-only project (src/handoffs-mode.mjs) the
56
+ // dispatcher stops it before it runs: a 'view' prints the planning-off line
57
+ // and succeeds, an 'action' refuses. A new plan or doc command opts in here;
58
+ // test/handoffs-planning-gate.test.mjs walks this registry.
59
+ const VIEW = Object.freeze({ planning: 'view' });
60
+ const ACTION = Object.freeze({ planning: 'action' });
61
+
52
62
  const definitions = [
53
63
  command('help', none, 'setup', [form('[topic]', { args: positionals(0, 1) })]),
54
64
  command('completions', none, 'setup', [form('<bash|zsh>', { args: positionals(1, 1) })]),
55
- command('init', mutates('repository setup paths and config files'), 'setup', [form('', { options: [flag('--force')] })]),
65
+ command('init', mutates('repository setup paths and config files; ignore rules only by explicit opt-in'), 'setup', [form('', { options: [flag('--force'), flag('--gitignore'), value('--prompt-tracking')] })], ACTION),
66
+ command('setup', mutates('explicit project onboarding choices; noninteractive preview unless --yes; host installation only when selected'), 'setup', [form('', {
67
+ options: [flag('--yes'), flag('--no-carry-prompts'), value('--docs-root'), value('--prompt-tracking'), flag('--gitignore'), value('--host'), value('--first-plan'), value('--current-state'), value('--next-step'), value('--adopt'), flag('--json')],
68
+ })]),
69
+ command('config', mutates('validated promptTracking export only; show/get are read-only'), 'setup', [
70
+ form('show', { subcommands: ['show'], options: [flag('--json')] }),
71
+ form('get <key>', { subcommands: ['get'], args: positionals(1, 1), options: [flag('--json')] }),
72
+ form('set <key> <value>', { subcommands: ['set'], args: positionals(2, 2), options: [flag('--json')] }),
73
+ ]),
56
74
  command('watch', mutates('proxy: child command policy applies'), 'setup', [form('[command...]', { args: positionals(0, Infinity), passthrough: true })]),
57
75
  command('desktop', mutates('explicit desktop installation; otherwise launch only'), 'setup', [
58
76
  form('[--app <application>]', {options:[value('--app')]}),
@@ -62,34 +80,34 @@ const definitions = [
62
80
 
63
81
  command('list', none, 'read', [form('', { options: [flag('--json'), flag('--verbose')] })]),
64
82
  command('json', none, 'read', [form('')]),
65
- command('coverage', none, 'read', [form('', { options: [flag('--json')] })]),
83
+ command('coverage', none, 'read', [form('', { options: [flag('--json')] })], VIEW),
66
84
  command('yardstick', mutates('explicit owner assessments and existing archive workflow; show is read-only'), 'mutate', [
67
- form('show [plan]', { subcommands: ['show'], args: positionals(0, 1), options: [flag('--json')] }),
85
+ form('show [plan]', { planning: 'view', subcommands: ['show'], args: positionals(0, 1), options: [flag('--json')] }),
68
86
  form('mark <plan> serves', { subcommands: ['mark'], args: positionals(2, 2), options: [value('--reason'), flag('--json'), flag('--no-index')] }),
69
87
  form('close <plan> --reason <text>', { subcommands: ['close'], args: positionals(1, 1), options: [value('--reason'), flag('--json'), flag('--no-index')] }),
70
88
  form('fold <plan> --into <plan> --reason <text>', { subcommands: ['fold'], args: positionals(1, 1), options: [value('--into'), value('--reason'), flag('--json'), flag('--no-index')] }),
71
89
  form('clear <plan>', { subcommands: ['clear'], args: positionals(1, 1), options: [flag('--json'), flag('--no-index')] }),
72
- ]),
73
- command('stats', none, 'read', [form('', { options: [flag('--json')] })]),
74
- command('graph', none, 'read', [form('', { options: [flag('--dot'), flag('--json'), value('--status'), value('--module'), value('--surface')] })]),
75
- command('deps', none, 'read', [form('[file]', { args: positionals(0, 1), options: [flag('--json'), value('--depth')] })]),
90
+ ], ACTION),
91
+ command('stats', none, 'read', [form('', { options: [flag('--json')] })], VIEW),
92
+ command('graph', none, 'read', [form('', { options: [flag('--dot'), flag('--json'), value('--status'), value('--module'), value('--surface')] })], VIEW),
93
+ command('deps', none, 'read', [form('[file]', { args: positionals(0, 1), options: [flag('--json'), value('--depth')] })], VIEW),
76
94
  command('briefing', none, 'read', [form('', { options: [flag('--json')] })]),
77
95
  command('context', none, 'read', [form('', { options: [flag('--json'), flag('--compact'), flag('--summarize'), value('--model'), value('--sections'), value('--max-bytes')] })]),
78
96
  command('agent-context', none, 'read', [form('', { options: [flag('--json'), value('--sections'), value('--max-bytes')] })]),
79
97
  command('hud', none, 'read', [form('', { options: [flag('--json'), flag('--subagent'), flag('--prompt-submit')] })]),
80
- command('focus', none, 'read', [form('[status]', { args: positionals(0, 1), options: [flag('--json')] })]),
98
+ command('focus', none, 'read', [form('[status]', { args: positionals(0, 1), options: [flag('--json')] })], VIEW),
81
99
  command('query', none, 'read', [form('[terms...]', { args: positionals(0, Infinity), options: QUERY_OPTIONS })]),
82
100
  command('grep', none, 'read', [form('<term> [terms...]', { args: positionals(1, Infinity), options: QUERY_OPTIONS })]),
83
101
  command('plans', none, 'read', [
84
102
  form('[terms...]', { args: positionals(0, Infinity), options: QUERY_OPTIONS }),
85
103
  form('status [terms...]', { subcommands: ['status'], args: positionals(0, Infinity), options: QUERY_OPTIONS }),
86
- ]),
87
- command('runlists', none, 'read', [form('', { options: [flag('--json'), value('--limit'), value('--sort')] })]),
88
- command('roadmaps', none, 'read', [form('', { options: [flag('--json')] })]),
104
+ ], VIEW),
105
+ command('runlists', none, 'read', [form('', { options: [flag('--json'), value('--limit'), value('--sort')] })], VIEW),
106
+ command('roadmaps', none, 'read', [form('', { options: [flag('--json')] })], VIEW),
89
107
  command('diff', none, 'read', [form('[file]', { args: positionals(0, 1), options: [flag('--stat'), value('--since'), flag('--summarize'), value('--model')] })]),
90
108
  command('summary', none, 'read', [form('<file>', { args: positionals(1, 1), options: [value('--model'), value('--max-tokens'), flag('--json')] })]),
91
- command('unblocks', none, 'read', [form('<file>', { args: positionals(1, 1), options: [flag('--json')] })]),
92
- command('health', none, 'read', [form('', { options: [flag('--json')] })]),
109
+ command('unblocks', none, 'read', [form('<file>', { args: positionals(1, 1), options: [flag('--json')] })], VIEW),
110
+ command('health', none, 'read', [form('', { options: [flag('--json')] })], VIEW),
93
111
  command('flags', none, 'read', [form('', { options: [flag('--all'), flag('--json')] })]),
94
112
  command('flag', mutates('the flags log under the state directory'), 'mutate', [
95
113
  form('add <file[:line]> <text...>', { subcommands: ['add'], args: positionals(2, Infinity), options: [value('--severity'), value('--by')] }),
@@ -99,7 +117,7 @@ const definitions = [
99
117
  form('show <id>', { subcommands: ['show'], args: positionals(1, 1) }),
100
118
  form('sync <check-name> [findings]', { subcommands: ['sync'], args: positionals(1, 2), dashPositionalsAfter: 1 }),
101
119
  ]),
102
- command('glossary', none, 'read', [form('[term]', { args: positionals(0, 1), options: [flag('--list'), flag('--json')] })]),
120
+ command('glossary', none, 'read', [form('[term]', { args: positionals(0, 1), options: [flag('--list'), flag('--json')] })], VIEW),
103
121
  command('model', mutates('~/.runlist/model.json and the local model server'), 'read', [
104
122
  form('', { options: [flag('--json')] }),
105
123
  form('status', { subcommands: ['status'], options: [flag('--json')] }),
@@ -112,21 +130,21 @@ const definitions = [
112
130
  command('show', none, 'read', [form('<file...>', { args: positionals(1, Infinity), options: [flag('--json'), flag('--card'), flag('--all'), flag('--frontmatter'), value('--section', '-s'), value('--from')] })]),
113
131
  command('xref', mutates('the flags log under the state directory with --check --flag; otherwise read-only'), 'read', [
114
132
  form('<file...> | --check [--flag]', { args: positionals(0, Infinity), options: [flag('--json'), flag('--check'), flag('--flag')] }),
115
- ]),
116
- command('record', mutates('native flag or decision through the shared domain writer'), 'mutate', [form('<file> <action>', { args: positionals(2, 2), options: [value('--note'), value('--option'), value('--expected-revision'), flag('--json')] })]),
117
- command('decision', mutates('selected decision source through the source editor'), 'mutate', [form('<file> <id>', { args: positionals(2, 2), options: [value('--disposition'), value('--note'), value('--choice'), value('--expected-revision'), flag('--json')] })]),
118
- command('decisions', none, 'read', [form('[doc-or-id]', { args: positionals(0, 1), options: [flag('--all'), flag('--json'), flag('--check')] })]),
119
- command('modules', none, 'read', [form('', { options: [value('--sort'), value('--limit'), flag('--all'), flag('--json')] })]),
120
- command('module', none, 'read', [form('<name>', { args: positionals(1, 1), options: [value('--sort'), flag('--json')] })]),
121
- command('surfaces', none, 'read', [form('', { options: [flag('--json')] })]),
133
+ ], VIEW),
134
+ command('record', mutates('native flag or decision through the shared domain writer'), 'mutate', [form('<file> <action>', { args: positionals(2, 2), options: [value('--note'), value('--option'), value('--expected-revision'), flag('--json')] })], ACTION),
135
+ command('decision', mutates('selected decision source through the source editor'), 'mutate', [form('<file> <id>', { args: positionals(2, 2), options: [value('--disposition'), value('--note'), value('--choice'), value('--expected-revision'), flag('--json')] })], ACTION),
136
+ command('decisions', none, 'read', [form('[doc-or-id]', { args: positionals(0, 1), options: [flag('--all'), flag('--json'), flag('--check')] })], VIEW),
137
+ command('modules', none, 'read', [form('', { options: [value('--sort'), value('--limit'), flag('--all'), flag('--json')] })], VIEW),
138
+ command('module', none, 'read', [form('<name>', { args: positionals(1, 1), options: [value('--sort'), flag('--json')] })], VIEW),
139
+ command('surfaces', none, 'read', [form('', { options: [flag('--json')] })], VIEW),
122
140
  command('journal', none, 'read', [form('', { options: [value('--tail'), flag('--errors'), value('--session'), value('--since'), flag('--by-command'), flag('--help-topics'), flag('--json')] })]),
123
141
  command('errors', none, 'read', [form('', { options: [flag('--json'), flag('--by-family'), value('--limit'), value('--tail'), value('--repo')] })]),
124
142
  command('misuse', none, 'read', [form('', { options: [flag('--json'), value('--tail'), flag('--by-rule'), value('--repo')] })]),
125
143
 
126
144
  command('roadmap', mutates('managed source when `next`; otherwise read-only'), 'workflow', [
127
145
  form('[hub]', { args: positionals(0, 1), options: [flag('--json')] }),
128
- form('next [hub]', { subcommands: ['next'], args: positionals(0, 1), options: [flag('--json'), flag('--full'), flag('--no-index')] }),
129
- ]),
146
+ form('next [hub]', { planning: 'action', subcommands: ['next'], args: positionals(0, 1), options: [flag('--json'), flag('--full'), flag('--no-index')] }),
147
+ ], VIEW),
130
148
  command('prompts', mutates('managed sources/destinations by subcommand'), 'workflow', [
131
149
  form('', { options: [flag('--json'), value('--status'), flag('--include-archived'), value('--sort'), value('--limit'), flag('--all')] }),
132
150
  form('list', { subcommands: ['list', 'status'], options: [flag('--json'), value('--status'), flag('--include-archived'), value('--sort'), value('--limit'), flag('--all')] }),
@@ -141,18 +159,18 @@ const definitions = [
141
159
  ], { aliases: ['prompt'] }),
142
160
  command('use', mutates('managed source when starting/consuming; docs remain read-only'), 'workflow', [form('[file]', { args: positionals(0, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files'), flag('--force'), flag('--no-claim')] })]),
143
161
  command('next', mutates('managed prompt source and same-root archive destination'), 'workflow', [form('', { options: [flag('--json'), flag('--no-index'), flag('--show-files'), flag('--force'), flag('--no-claim')] })]),
144
- command('baton', mutates('managed plan/prompt sources and managed prompt destination'), 'workflow', [form('[plan|slug] <@<file>|->', { args: positionals(0, 2), options: [value('--status'), value('--note'), value('--body', '--message'), value('--parent'), flag('--template'), flag('--force'), flag('--replace'), flag('--done'), flag('--subagent'), flag('--json')] })]),
162
+ command('baton', mutates('managed plan/prompt sources and managed prompt destination'), 'workflow', [form('[plan|slug] <@<file>|->', { args: positionals(0, 2), options: [value('--status'), value('--note'), value('--body', '--message'), value('--parent'), flag('--template'), flag('--force'), flag('--replace'), flag('--done'), value('--next-step'), value('--current-state'), flag('--subagent'), flag('--json')] })]),
145
163
  command('runlist', mutates('managed hubs/children and managed scaffold destinations'), 'workflow', [
146
164
  form('<hub>', { args: positionals(1, 1), options: [flag('--json')] }),
147
- form('next <hub>', { subcommands: ['next'], args: positionals(1, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files')] }),
148
- form('add <hub> <child...>', { subcommands: ['add'], args: positionals(2, Infinity), options: [flag('--json')] }),
149
- form('remove <hub> <child...>', { subcommands: ['remove'], args: positionals(2, Infinity), options: [flag('--json'), flag('--clear-parent')] }),
150
- form('reorder <hub> <child...>', { subcommands: ['reorder'], args: positionals(2, Infinity), options: [flag('--json'), value('--before'), value('--after')] }),
151
- ]),
165
+ form('next <hub>', { planning: 'action', subcommands: ['next'], args: positionals(1, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files')] }),
166
+ form('add <hub> <child...>', { planning: 'action', subcommands: ['add'], args: positionals(2, Infinity), options: [flag('--json')] }),
167
+ form('remove <hub> <child...>', { planning: 'action', subcommands: ['remove'], args: positionals(2, Infinity), options: [flag('--json'), flag('--clear-parent')] }),
168
+ form('reorder <hub> <child...>', { planning: 'action', subcommands: ['reorder'], args: positionals(2, Infinity), options: [flag('--json'), value('--before'), value('--after')] }),
169
+ ], VIEW),
152
170
 
153
171
  command('export', mutates('external output path intentionally unrestricted'), 'mutate', [form('[file]', { args: positionals(0, 1), options: [value('--format'), value('--output'), value('--status'), value('--module'), value('--root'), value('--type')] })]),
154
172
  command('guard', mutates('global misuse log; no managed document writes'), 'mutate', [form('')]),
155
- command('update', mutates('global CLI and plugin installation state'), 'mutate', [form('', { options: [flag('--check'), flag('--cli-only'), flag('--plugin-only')] })]),
173
+ command('update', mutates('global CLI and installed host integrations; --check is offline and read-only'), 'mutate', [form('', { options: [flag('--check'), flag('--json'), flag('--cli-only'), flag('--plugin-only'), value('--host'), value('--claude-config'), value('--claude-bin')] })]),
156
174
  command('install', mutates('agent-host plugin directory outside the repo'), 'mutate', [
157
175
  form('[host]', { args: positionals(0, 1), options: [flag('--remove'), flag('--force'), flag('--json'), value('--path')] }),
158
176
  ]),
@@ -162,9 +180,9 @@ const definitions = [
162
180
  command('archive', mutates('managed source and same-root destination'), 'mutate', [form('<file>', { args: positionals(1, 1), options: [...LIFECYCLE_OPTIONS, flag('--closeout-template'), flag('--fix-refs'), flag('--strings')] })]),
163
181
  command('bulk', mutates('managed source sweep; archive destinations preserve roots'), 'mutate', [
164
182
  form('archive <files...>', { subcommands: ['archive'], args: positionals(1, Infinity), options: [flag('--json'), flag('--no-index'), flag('--show-files')] }),
165
- form('tag [files...]', { subcommands: ['tag'], args: positionals(0, Infinity), options: [value('--type'), value('--status'), flag('--json')] }),
183
+ form('tag [files...]', { planning: 'action', subcommands: ['tag'], args: positionals(0, Infinity), options: [value('--type'), value('--status'), flag('--json')] }),
166
184
  ]),
167
- command('bulk-tag', mutates('managed source sweep'), 'mutate', [form('[files...]', { args: positionals(0, Infinity), options: [value('--type'), value('--status'), flag('--json')] })]),
185
+ command('bulk-tag', mutates('managed source sweep'), 'mutate', [form('[files...]', { args: positionals(0, Infinity), options: [value('--type'), value('--status'), flag('--json')] })], ACTION),
168
186
  command('touch', mutates('managed source or managed source sweep'), 'mutate', [form('[file...]', { args: positionals(0, Infinity), options: [flag('--git')] })]),
169
187
  command('new', mutates('managed document destination; external body input unrestricted'), 'mutate', [form('[type] <name> [body...]', {
170
188
  args: positionals(0, Infinity),
@@ -179,9 +197,9 @@ const definitions = [
179
197
  form('repair', { subcommands: ['repair'], options: [flag('--fix'), flag('--strings')] }),
180
198
  form('<old> <new>', { args: positionals(2, 2), options: [flag('--fix'), flag('--strings')] }),
181
199
  ]),
182
- command('fix-membership', mutates('managed source sweep'), 'mutate', [form('[hubs...]', { args: positionals(0, Infinity), options: [flag('--json')] })]),
183
- command('sync-status', mutates('managed source sweep'), 'mutate', [form('[hubs...]', { args: positionals(0, Infinity), options: [flag('--adopt'), flag('--json')] })]),
184
- command('doctor', mutates('managed sweeps, repo index, and maintenance config paths by mode'), 'mutate', [form('[path]', { args: positionals(0, 1), options: [flag('--apply', '--yes'), flag('--statuses'), optionalValue('--migrate-template'), flag('--migrate-prompts'), flag('--frontmatter-fix'), flag('--project'), flag('--prompts'), flag('--transactions'), flag('--claims'), flag('--session'), value('--older-than'), flag('--json'), flag('--include-archived')] })]),
200
+ command('fix-membership', mutates('managed source sweep'), 'mutate', [form('[hubs...]', { args: positionals(0, Infinity), options: [flag('--json')] })], ACTION),
201
+ command('sync-status', mutates('managed source sweep'), 'mutate', [form('[hubs...]', { args: positionals(0, Infinity), options: [flag('--adopt'), flag('--json')] })], ACTION),
202
+ command('doctor', mutates('managed sweeps and maintenance by mode; --hosts reports read-only or applies explicitly selected host repairs'), 'mutate', [form('[path]', { args: positionals(0, 1), options: [flag('--apply', '--yes'), flag('--statuses'), optionalValue('--migrate-template'), flag('--migrate-prompts'), flag('--frontmatter-fix'), flag('--project'), flag('--prompts'), flag('--transactions'), flag('--claims'), flag('--session'), flag('--hosts'), value('--repair'), value('--claude-config'), value('--claude-bin'), value('--older-than'), flag('--json'), flag('--include-archived')] })]),
185
203
  command('statuses', mutates('project config path; document scan is read-only'), 'mutate', [
186
204
  form('list', { subcommands: ['list'], options: [value('--type'), flag('--json')] }),
187
205
  form('add <name>', { subcommands: ['add'], args: positionals(1, 1), options: STATUS_PROPERTY_OPTIONS }),
@@ -200,8 +218,8 @@ const definitions = [
200
218
  command('finish', none, 'removed', [form('[args...]', { args: positionals(0, Infinity) })], { visibility: 'removed' }),
201
219
  command('handoff', none, 'removed', [form('[args...]', { args: positionals(0, Infinity) })], { visibility: 'removed' }),
202
220
 
203
- command('stale', none, 'preset', [form('[terms...]', { args: positionals(0, Infinity), options: QUERY_OPTIONS })]),
204
- command('actionable', none, 'preset', [form('[terms...]', { args: positionals(0, Infinity), options: QUERY_OPTIONS })]),
221
+ command('stale', none, 'preset', [form('[terms...]', { args: positionals(0, Infinity), options: QUERY_OPTIONS })], VIEW),
222
+ command('actionable', none, 'preset', [form('[terms...]', { args: positionals(0, Infinity), options: QUERY_OPTIONS })], VIEW),
205
223
  ];
206
224
 
207
225
  function schemaErrors(schema) {
@@ -213,6 +231,9 @@ function schemaErrors(schema) {
213
231
  names.add(entry.name);
214
232
  if (!entry.group) errors.push(`${entry.name}: missing help group`);
215
233
  if (!entry.forms.length) errors.push(`${entry.name}: missing forms`);
234
+ for (const planning of [entry.planning, ...entry.forms.map(commandForm => commandForm.planning)]) {
235
+ if (planning != null && planning !== 'view' && planning !== 'action') errors.push(`${entry.name}: planning must be 'view' or 'action'`);
236
+ }
216
237
  for (const alias of entry.aliases) {
217
238
  if (!alias || names.has(alias) || aliases.has(alias)) errors.push(`${entry.name}: duplicate alias ${alias}`);
218
239
  aliases.add(alias);
@@ -310,6 +331,11 @@ function scanArgs(name, argv, options, passthrough = false, dashPositionalsAfter
310
331
  if (!option) {
311
332
  if (passthrough) continue;
312
333
  if (positional.length >= dashPositionalsAfter) { positional.push(arg); continue; }
334
+ if (name === 'show' && arg === '--lines') {
335
+ throw new Error('Unknown flag for `runlist show`: --lines\n\n'
336
+ + 'Drop --lines. Continue with `runlist show <file> --section "<heading>" --from <line>`.\n'
337
+ + 'Section output is bounded automatically; --from is an absolute file line.');
338
+ }
313
339
  throw new Error(`Unknown flag for \`runlist ${name}\`: ${arg}`);
314
340
  }
315
341
  if (option.arity === 1) {
@@ -325,6 +351,34 @@ function scanArgs(name, argv, options, passthrough = false, dashPositionalsAfter
325
351
  return positional;
326
352
  }
327
353
 
354
+ // Help selects a form without requiring the arguments needed to execute it.
355
+ export function commandHelpContext(name, argv) {
356
+ const definition = commandDefinition(name);
357
+ if (!definition) return null;
358
+ const options = optionMap(definition.forms);
359
+ for (const option of GLOBAL_OPTIONS) {
360
+ for (const alias of option.names) if (!options.has(alias)) options.set(alias, option);
361
+ }
362
+ const positional = scanArgs(definition.name, argv, options);
363
+ const selected = definition.forms.find(entry => entry.subcommands.includes(positional[0]));
364
+ return { positional, subcommand: selected?.subcommands[0] ?? null };
365
+ }
366
+
367
+ // Whether a command, in the form these arguments select, needs planning on:
368
+ // `{ requirement: 'view' | 'action', subcommand }`, or null (see VIEW/ACTION
369
+ // above). Read before argument validation, so a planning command missing its
370
+ // arguments still hears that planning is off rather than its usage.
371
+ export function commandPlanning(name, argv) {
372
+ const definition = commandDefinition(name);
373
+ if (!definition) return null;
374
+ let subcommand = null;
375
+ try { subcommand = commandHelpContext(definition.name, normalizeCommandArgs(definition.name, argv)).subcommand; } catch { /* unknown flag: the command's own form */ }
376
+ const selected = definition.forms.find(entry => entry.subcommands.includes(subcommand))
377
+ ?? definition.forms.find(entry => entry.subcommands.length === 0);
378
+ const requirement = selected?.planning ?? definition.planning;
379
+ return requirement ? { requirement, subcommand } : null;
380
+ }
381
+
328
382
  export function normalizeCommandArgs(name, argv) {
329
383
  const canonical = canonicalCommand(name);
330
384
  if (canonical !== 'roadmap') return [...argv];
@@ -0,0 +1,115 @@
1
+ import { existsSync, lstatSync, realpathSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { assertSnapshotCurrent, replaceSnapshot, snapshotFile } from './atomic-mutation.mjs';
5
+ import { renderPromptTrackingSetting } from './config-setting.mjs';
6
+ import { promptDirectory, promptRouting } from './new.mjs';
7
+
8
+ const KEY = 'promptTracking';
9
+ const VALUES = ['untracked', 'tracked', 'local'];
10
+ const READABLE = Object.freeze([KEY, 'mode', 'root', 'docsRoots', 'index.path', 'archiveDir', 'promptDirectory']);
11
+ const USAGE = 'Usage: runlist config show [--json] | get <key> [--json] | set promptTracking <untracked|tracked|local> [--json]';
12
+
13
+ function inspectSettings(config) {
14
+ const source = key => config.settingSources?.[key] ?? 'unknown';
15
+ return {
16
+ promptTracking: { value: config.promptTracking, source: config.promptTrackingSource },
17
+ mode: { value: config.mode ?? 'plans', source: source('mode') },
18
+ root: { value: config.raw?.root ?? '.', source: source('root') },
19
+ docsRoots: { value: config.docsRoots ?? [config.docsRoot], source: 'derived' },
20
+ 'index.path': { value: config.indexPath ?? null, source: source('index.path') },
21
+ archiveDir: { value: config.archiveDir, source: source('archiveDir') },
22
+ // Markdown template overrides change content, not routing. Inspect only
23
+ // layout so a broken content template cannot prevent settings inspection.
24
+ promptDirectory: { value: promptDirectory(config, promptRouting(config)), source: 'derived' },
25
+ };
26
+ }
27
+
28
+ function manualSetting(key, configPath) {
29
+ const file = configPath ?? 'runlist.config.mjs (create it with runlist setup)';
30
+ if (key === 'mode') return `A handoffs-only project turns planning on with runlist setup, which previews the change to ${file} first.`;
31
+ if (key === 'root' || key === 'docsRoots') return `Edit the root export in ${file}, review dependent index/template paths, then run runlist init --dry-run and runlist check. A new project can choose --docs-root during setup. Existing files are not moved automatically.`;
32
+ if (key === 'promptDirectory') return `Edit templates.prompt.dir or templates.prompt.targetRoot and the root export in ${file}, then inspect runlist config get promptDirectory. Existing prompts are not moved automatically.`;
33
+ return `Edit ${key === 'index.path' ? 'the index.path property (or legacy readme.path)' : 'the archiveDir export'} in ${file}, then run runlist init --dry-run and runlist check. Existing files are not moved automatically.`;
34
+ }
35
+
36
+ function checkSyntax(content) {
37
+ try {
38
+ execFileSync(process.execPath, ['--input-type=module', '--check'], { input: content, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
39
+ } catch {
40
+ throw new Error("Config is not a supported JavaScript module. Edit promptTracking manually in your config; no changes were made.");
41
+ }
42
+ }
43
+
44
+ function assertRegularConfig(configPath, repoRoot) {
45
+ const resolved = path.resolve(configPath);
46
+ // Replacing a symlink would sever it. Refuse symlinked parent paths as well
47
+ // so the loaded source and the publication path cannot silently diverge.
48
+ const boundary = path.resolve(repoRoot);
49
+ for (let cursor = resolved; ; cursor = path.dirname(cursor)) {
50
+ if (lstatSync(cursor).isSymbolicLink()) throw new Error(`Config path uses a symlink: ${cursor}. Edit the config manually; no changes were made.`);
51
+ if (cursor === boundary || path.dirname(cursor) === cursor) break;
52
+ }
53
+ if (!lstatSync(resolved).isFile()) throw new Error(`Config path is not a regular file: ${resolved}`);
54
+ return realpathSync(resolved);
55
+ }
56
+
57
+ export async function runConfig(argv, config, { dryRun = false, out = process.stdout, testHooks } = {}) {
58
+ if (argv.filter(arg => arg === '--json').length > 1 || argv.some(arg => arg.startsWith('-') && arg !== '--json')) throw new Error(USAGE);
59
+ const json = argv.includes('--json');
60
+ const args = argv.filter(arg => arg !== '--json');
61
+ const [action, key, value] = args;
62
+ if (!['show', 'get', 'set'].includes(action) || args.length !== ({ show: 1, get: 2, set: 3 })[action]) throw new Error(USAGE);
63
+ if (action !== 'show' && !READABLE.includes(key)) throw new Error(`Unsupported config key '${key}'. Readable settings: ${READABLE.join(', ')}. Editable setting: ${KEY}. Edit other exports manually in ${config.configPath ?? 'runlist.config.mjs (create it with runlist setup)'}.`);
64
+ const configPath = config.configPath ?? null;
65
+ if (action === 'set' && key !== KEY) throw new Error(`${key} is read-only in the CLI. ${manualSetting(key, configPath)} No changes were made.`);
66
+ const settings = action === 'set'
67
+ ? { [KEY]: { value: config.promptTracking, source: config.promptTrackingSource } }
68
+ : inspectSettings(config);
69
+ const setting = { key: key ?? KEY, ...settings[key ?? KEY], configPath };
70
+ let result;
71
+ if (action === 'show') {
72
+ result = { supportedSettings: [KEY], readableSettings: READABLE, editableSettings: [KEY], settings, configPath };
73
+ } else if (action === 'get') {
74
+ result = { supportedSettings: [KEY], readableSettings: READABLE, editableSettings: [KEY], ...setting };
75
+ } else {
76
+ if (!VALUES.includes(value)) throw new Error("promptTracking must be 'untracked', 'tracked', or 'local'.");
77
+ if (!configPath || !config.configFound || !existsSync(configPath)) throw new Error('No project config found. Run runlist setup before config set.');
78
+ const file = assertRegularConfig(configPath, config.repoRoot);
79
+ const snapshot = snapshotFile(file);
80
+ checkSyntax(snapshot.content);
81
+ const edit = renderPromptTrackingSetting(snapshot.content, value);
82
+ checkSyntax(edit.content);
83
+ const changed = edit.content !== snapshot.content;
84
+ result = { action, supportedSettings: [KEY], key: KEY, previousValue: edit.previousValue, value, source: 'config', configPath, changed, dryRun };
85
+ if (!dryRun && changed) {
86
+ replaceSnapshot(snapshot, edit.content, {
87
+ repoRoot: config.repoRoot,
88
+ testHooks: {
89
+ ...testHooks,
90
+ beforeReplacePublish(info) {
91
+ testHooks?.beforeReplacePublish?.(info);
92
+ assertRegularConfig(configPath, config.repoRoot);
93
+ assertSnapshotCurrent(snapshot);
94
+ },
95
+ },
96
+ });
97
+ }
98
+ }
99
+ if (json) out.write(`${JSON.stringify(result, null, 2)}\n`);
100
+ else {
101
+ out.write(`Editable setting: ${KEY}\n`);
102
+ out.write(`Config path: ${configPath ?? '(none; run runlist setup to create one)'}\n`);
103
+ if (action === 'show') {
104
+ for (const [name, entry] of Object.entries(settings)) out.write(`${name}: ${JSON.stringify(entry.value)} (source: ${entry.source}${name === KEY ? '; editable' : '; read-only'})\n`);
105
+ } else {
106
+ if (action === 'set') out.write(`${dryRun ? '[dry-run] Would set' : changedLabel(result.changed)} ${KEY} = ${value}\n`);
107
+ out.write(`Effective value${action === 'set' && dryRun ? ' after proposed change' : ''}: ${action === 'set' ? value : JSON.stringify(setting.value)}\n`);
108
+ out.write(`Source: ${action === 'set' ? 'config' : setting.source}\n`);
109
+ }
110
+ if ((action === 'show' || key === KEY) && (action === 'set' ? value : settings.promptTracking.value) === 'untracked') out.write('Leave pending prompts untracked to reduce commit churn. git add -A can still stage them.\n');
111
+ }
112
+ return result;
113
+ }
114
+
115
+ function changedLabel(changed) { return changed ? 'Set' : 'Already set'; }