runlist 0.99.1 → 0.100.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/GETTING_STARTED.md +249 -0
  2. package/README.md +433 -51
  3. package/bin/runlist.mjs +404 -53
  4. package/package.json +3 -1
  5. package/plugins/runlist-codex/.codex-plugin/plugin.json +1 -1
  6. package/plugins/runlist-codex/bin/consume-prompt.mjs +45 -0
  7. package/plugins/runlist-codex/bin/runlist-hook +29 -11
  8. package/plugins/runlist-codex/bin/session-start.mjs +16 -0
  9. package/plugins/runlist-codex/bin/subagent-start.mjs +16 -0
  10. package/plugins/runlist-codex/hooks/hooks.json +11 -0
  11. package/plugins/runlist-codex/skills/runlist/SKILL.md +27 -11
  12. package/plugins/runlist-codex/skills/runlist-setup/SKILL.md +109 -0
  13. package/runlist.config.example.mjs +6 -0
  14. package/src/agent-context.mjs +8 -2
  15. package/src/agent-hosts.mjs +305 -0
  16. package/src/app-create.mjs +11 -1
  17. package/src/atomic-mutation.mjs +140 -35
  18. package/src/baton.mjs +127 -33
  19. package/src/codex-integration.mjs +57 -7
  20. package/src/commands.mjs +92 -38
  21. package/src/config-command.mjs +115 -0
  22. package/src/config-setting.mjs +163 -0
  23. package/src/config.mjs +57 -12
  24. package/src/doctor-hosts.mjs +192 -0
  25. package/src/doctor.mjs +16 -6
  26. package/src/export.mjs +11 -0
  27. package/src/git.mjs +58 -2
  28. package/src/guard.mjs +41 -13
  29. package/src/handoff-record.mjs +290 -30
  30. package/src/handoffs-help.mjs +143 -0
  31. package/src/handoffs-mode.mjs +486 -0
  32. package/src/handoffs-scope.mjs +98 -0
  33. package/src/help.mjs +72 -0
  34. package/src/host-integration.mjs +7 -1
  35. package/src/hud.mjs +174 -29
  36. package/src/index.mjs +11 -4
  37. package/src/init.mjs +255 -160
  38. package/src/install.mjs +35 -18
  39. package/src/installation.mjs +110 -27
  40. package/src/journal.mjs +2 -2
  41. package/src/lifecycle.mjs +22 -11
  42. package/src/managed-path.mjs +68 -4
  43. package/src/migrate.mjs +7 -0
  44. package/src/naming.mjs +26 -0
  45. package/src/new.mjs +202 -91
  46. package/src/pickup-card.mjs +1 -1
  47. package/src/plan-changes.mjs +49 -0
  48. package/src/prompt-handoff.mjs +16 -0
  49. package/src/prompts.mjs +101 -27
  50. package/src/rename.mjs +13 -0
  51. package/src/setup-carry.mjs +291 -0
  52. package/src/setup-waiting.mjs +20 -0
  53. package/src/setup-worker.mjs +257 -0
  54. package/src/setup.mjs +404 -0
  55. package/src/statuses.mjs +5 -2
  56. package/src/update.mjs +239 -79
  57. package/src/use.mjs +4 -2
  58. package/src/validate.mjs +7 -0
@@ -0,0 +1,163 @@
1
+ // A conservative lexical editor for one named, immutable string export. This
2
+ // does not evaluate JavaScript or print it back out: only the literal's byte
3
+ // span changes. Unlike the status editor, groups may contain regex literals.
4
+ // Ambiguous lexical contexts are refused rather than guessed.
5
+ const VALUES = ['untracked', 'tracked', 'local'];
6
+
7
+ function manual(reason) {
8
+ throw new Error(`${reason} Edit promptTracking manually in your config using one direct declaration, for example: export const promptTracking = 'untracked'; Allowed values: 'untracked', 'tracked', 'local'. Then rerun runlist config show. No config changes were made.`);
9
+ }
10
+
11
+ const REGEX_PREFIX = new Set([
12
+ 'return', 'throw', 'yield', 'await', 'case', 'delete', 'void', 'typeof',
13
+ 'instanceof', 'in', 'of', 'new', 'else', 'do', 'default',
14
+ ]);
15
+
16
+ function stringEnd(text, start) {
17
+ const quote = text[start];
18
+ for (let i = start + 1; i < text.length; i++) {
19
+ if (text[i] === '\\') { i++; continue; }
20
+ if (text[i] === quote) return i + 1;
21
+ if (quote === '`' && text[i] === '$' && text[i + 1] === '{') {
22
+ i = groupEnd(text, i + 1) - 1;
23
+ }
24
+ }
25
+ manual('Unterminated string.');
26
+ }
27
+
28
+ function regexEnd(text, start) {
29
+ let characterClass = false;
30
+ for (let i = start + 1; i < text.length; i++) {
31
+ if (text[i] === '\\') { i++; continue; }
32
+ if (text[i] === '[') characterClass = true;
33
+ if (text[i] === ']') characterClass = false;
34
+ if (text[i] === '/' && !characterClass) {
35
+ i++;
36
+ while (/[A-Za-z]/.test(text[i] ?? '')) i++;
37
+ return i;
38
+ }
39
+ if (text[i] === '\n' || text[i] === '\r') break;
40
+ }
41
+ manual('Cannot safely locate a regular expression.');
42
+ }
43
+
44
+ function triviaEnd(text, start) {
45
+ let i = start;
46
+ while (i < text.length) {
47
+ if (/\s/.test(text[i])) { i++; continue; }
48
+ if (text.startsWith('//', i) || (i === 0 && text.startsWith('#!', i))) {
49
+ const end = text.indexOf('\n', i);
50
+ i = end < 0 ? text.length : end + 1;
51
+ continue;
52
+ }
53
+ if (text.startsWith('/*', i)) {
54
+ const end = text.indexOf('*/', i + 2);
55
+ if (end < 0) manual('Unterminated comment.');
56
+ i = end + 2;
57
+ continue;
58
+ }
59
+ break;
60
+ }
61
+ return i;
62
+ }
63
+
64
+ function tokenAt(text, start, previous) {
65
+ const c = text[start];
66
+ let end;
67
+ let kind = 'punctuation';
68
+ if (c === "'" || c === '"' || c === '`') {
69
+ end = stringEnd(text, start);
70
+ kind = c === '`' ? 'template' : 'string';
71
+ } else if ('{[('.includes(c)) {
72
+ end = groupEnd(text, start);
73
+ kind = 'group';
74
+ } else if (c === '/') {
75
+ // A slash after a closing group needs statement/expression grammar to
76
+ // disambiguate (if (...) /re/ vs fn(...) / divisor). Refuse that rare case.
77
+ if (previous?.kind === 'group') manual('A slash after a bracket group is ambiguous to the setting editor.');
78
+ const expressionEnd = previous && (
79
+ ['string', 'template', 'regex', 'number'].includes(previous.kind)
80
+ || (previous.kind === 'identifier' && !REGEX_PREFIX.has(previous.raw))
81
+ || ['++', '--', '.', '?.'].includes(previous.raw)
82
+ );
83
+ end = expressionEnd ? start + (text[start + 1] === '=' ? 2 : 1) : regexEnd(text, start);
84
+ if (!expressionEnd) kind = 'regex';
85
+ } else if (/[A-Za-z_$]/.test(c)) {
86
+ end = start + 1;
87
+ while (/[A-Za-z0-9_$]/.test(text[end] ?? '')) end++;
88
+ kind = 'identifier';
89
+ } else if (/[0-9]/.test(c)) {
90
+ end = start + 1;
91
+ while (/[A-Za-z0-9_.]/.test(text[end] ?? '')) end++;
92
+ kind = 'number';
93
+ } else {
94
+ if (c === '\\' || c.charCodeAt(0) > 127) manual('Escaped or Unicode identifiers need a manual setting edit.');
95
+ end = start + 1;
96
+ if (['++', '--', '=>', '?.'].includes(text.slice(start, start + 2))) end++;
97
+ }
98
+ return { start, end, raw: text.slice(start, end), kind };
99
+ }
100
+
101
+ function groupEnd(text, start) {
102
+ const close = { '{': '}', '[': ']', '(': ')' }[text[start]];
103
+ let i = start + 1;
104
+ let previous;
105
+ while ((i = triviaEnd(text, i)) < text.length) {
106
+ if (text[i] === close) return i + 1;
107
+ if ('}])'.includes(text[i])) manual('Unbalanced brackets.');
108
+ previous = tokenAt(text, i, previous);
109
+ i = previous.end;
110
+ }
111
+ manual('Unterminated bracket group.');
112
+ }
113
+
114
+ export function renderPromptTrackingSetting(content, value) {
115
+ if (!VALUES.includes(value)) throw new Error("promptTracking must be 'untracked', 'tracked', or 'local'.");
116
+ const tokens = [];
117
+ let i = 0;
118
+ while ((i = triviaEnd(content, i)) < content.length) {
119
+ const token = tokenAt(content, i, tokens.at(-1));
120
+ tokens.push(token);
121
+ i = token.end;
122
+ }
123
+ let literal;
124
+ for (let n = 0; n < tokens.length; n++) {
125
+ const token = tokens[n];
126
+ if (token.raw === 'export') {
127
+ const next = tokens[n + 1];
128
+ if (next?.raw === '*' || next?.raw === 'default' || next?.raw.startsWith('{')) {
129
+ manual('Export lists, reexports, and default exports are outside the supported setting shape.');
130
+ }
131
+ }
132
+ if (token.raw !== 'promptTracking') continue;
133
+ // Requiring every top-level occurrence to be this binding also refuses
134
+ // mutable assignments, aliases, and multi-declaration bindings.
135
+ if (tokens[n - 2]?.raw !== 'export' || tokens[n - 1]?.raw !== 'const' || tokens[n + 1]?.raw !== '=') {
136
+ manual('promptTracking is not a simple named const export.');
137
+ }
138
+ if (literal) manual('Multiple promptTracking declarations are ambiguous.');
139
+ const candidate = tokens[n + 2];
140
+ if (candidate?.kind !== 'string' || !VALUES.includes(candidate.raw.slice(1, -1))) {
141
+ manual('promptTracking uses a computed or unsupported value.');
142
+ }
143
+ const after = tokens[n + 3];
144
+ // Semicolonless declarations are safe only at EOF or before another
145
+ // explicit module declaration, separated by a newline.
146
+ if (after && after.raw !== ';' && !(
147
+ ['export', 'import'].includes(after.raw) && /[\r\n]/.test(content.slice(candidate.end, after.start))
148
+ )) manual('promptTracking has a computed initializer or ambiguous declaration boundary.');
149
+ literal = candidate;
150
+ }
151
+ if (literal) {
152
+ const quote = literal.raw[0];
153
+ return {
154
+ content: content.slice(0, literal.start) + quote + value + quote + content.slice(literal.end),
155
+ previousValue: literal.raw.slice(1, -1),
156
+ };
157
+ }
158
+ const newline = content.includes('\r\n') ? '\r\n' : '\n';
159
+ return {
160
+ content: content + (content && !content.endsWith('\n') ? newline : '') + `export const promptTracking = '${value}';${newline}`,
161
+ previousValue: 'local',
162
+ };
163
+ }
package/src/config.mjs CHANGED
@@ -19,7 +19,14 @@ const REPLACE_KEYS = new Set([
19
19
  ]);
20
20
 
21
21
  const DEFAULTS = {
22
+ // 'plans' (the full plan/doc/prompt system) or 'handoffs': session handoffs
23
+ // only, written by the first `runlist baton` in a repository with no config.
24
+ // Handoffs mode pairs with a `root` that is the prompt folder itself, so the
25
+ // scan never reaches the project's own docs; this flag only changes wording
26
+ // and refuses plan verbs. `runlist setup` flips it to 'plans'.
27
+ mode: 'plans',
22
28
  root: '.',
29
+ promptTracking: 'untracked',
23
30
  archiveDir: 'archived',
24
31
  excludeDirs: [],
25
32
  externalBodyLinkRoots: [],
@@ -474,24 +481,46 @@ export function locateConfig(cwd, explicitConfigPath) {
474
481
  return {configPath,configDir,repoRoot};
475
482
  }
476
483
 
477
- export async function resolveConfig(cwd, explicitConfigPath) {
478
- const {configPath,configDir,repoRoot} = locateConfig(cwd, explicitConfigPath);
484
+ // `options.exports` stands in for a config file that does not exist yet (a
485
+ // dry-run of the handoffs config baton would create); `options.overrides`
486
+ // replaces exports of the file that does (setup previewing a planning upgrade).
487
+ // Both are internal: neither writes anything.
488
+ export async function resolveConfig(cwd, explicitConfigPath, options = {}) {
489
+ const located = locateConfig(cwd, explicitConfigPath);
490
+ const virtual = Boolean(options.exports && explicitConfigPath);
491
+ const {configPath} = located;
492
+ let {configDir, repoRoot} = located;
493
+ if (virtual) {
494
+ configDir = path.dirname(configPath);
495
+ repoRoot = locateConfig(configDir).repoRoot;
496
+ }
479
497
 
480
498
  let userConfig = {};
481
499
  let hooks = {};
482
-
483
- if (configPath && existsSync(configPath)) {
484
- const configUrl = pathToFileURL(configPath).href;
485
- let mod;
486
- try {
487
- mod = await import(configUrl);
488
- } catch (err) {
489
- die('Failed to load config: ' + configPath + '\n' + err.message + '\nCheck for syntax errors in your config file.');
500
+ const fileExists = Boolean(configPath && existsSync(configPath));
501
+ let promptTrackingSource = fileExists || virtual ? 'legacy-default' : 'default';
502
+
503
+ if (fileExists || virtual) {
504
+ let mod = options.exports;
505
+ if (!virtual) {
506
+ const configUrl = pathToFileURL(configPath).href;
507
+ try {
508
+ mod = await import(configUrl);
509
+ } catch (err) {
510
+ die('Failed to load config: ' + configPath + '\n' + err.message + '\nCheck for syntax errors in your config file.');
511
+ }
490
512
  }
513
+ if (options.overrides) mod = { ...mod, ...options.overrides };
491
514
 
492
515
  for (const [key, value] of Object.entries(mod)) {
493
516
  if (key === 'default') continue;
494
- if (typeof value === 'function') {
517
+ if (key === 'promptTracking') {
518
+ if (!['untracked', 'tracked', 'local'].includes(value)) {
519
+ throw new Error(`Config: promptTracking must be 'untracked', 'tracked', or 'local' (${configPath}).`);
520
+ }
521
+ userConfig[key] = value;
522
+ promptTrackingSource = 'config';
523
+ } else if (typeof value === 'function') {
495
524
  hooks[key] = value;
496
525
  } else {
497
526
  userConfig[key] = value;
@@ -500,12 +529,14 @@ export async function resolveConfig(cwd, explicitConfigPath) {
500
529
  }
501
530
 
502
531
  // Backwards compat: `readme` config key maps to `index`
503
- if (userConfig.readme && !userConfig.index) {
532
+ const legacyIndex = Boolean(userConfig.readme && !userConfig.index);
533
+ if (legacyIndex) {
504
534
  userConfig.index = userConfig.readme;
505
535
  delete userConfig.readme;
506
536
  }
507
537
 
508
538
  const config = deepMerge(DEFAULTS, userConfig);
539
+ if (promptTrackingSource === 'legacy-default') config.promptTracking = 'local';
509
540
  // Replacement links remain repairable even with custom reference-field lists
510
541
  // or after the repository turns off goal display.
511
542
  config.referenceFields.unidirectional = [...new Set([
@@ -633,6 +664,10 @@ export async function resolveConfig(cwd, explicitConfigPath) {
633
664
  }
634
665
  }
635
666
 
667
+ let mode = 'plans';
668
+ if (config.mode === 'handoffs' || config.mode === 'plans') mode = config.mode;
669
+ else earlyWarnings.push(`Config: mode must be 'plans' or 'handoffs'; ignoring \`${config.mode}\`.`);
670
+
636
671
  const configWarnings = [...earlyWarnings, ...validateConfig(userConfig, config, validStatuses, indexPath)];
637
672
 
638
673
  return {
@@ -644,6 +679,16 @@ export async function resolveConfig(cwd, explicitConfigPath) {
644
679
  configDir,
645
680
  configPath: configPath ?? null,
646
681
  configFound: Boolean(configPath),
682
+ mode,
683
+ planning: mode !== 'handoffs',
684
+ promptTracking: config.promptTracking,
685
+ promptTrackingSource,
686
+ settingSources: {
687
+ mode: Object.hasOwn(userConfig, 'mode') ? 'config' : 'default',
688
+ root: Object.hasOwn(userConfig, 'root') ? 'config' : 'default',
689
+ 'index.path': Object.hasOwn(userConfig.index ?? {}, 'path') ? (legacyIndex ? 'legacy-readme' : 'config') : 'default',
690
+ archiveDir: Object.hasOwn(userConfig, 'archiveDir') ? 'config' : 'default',
691
+ },
647
692
  archiveDir: config.archiveDir,
648
693
  excludeDirs: new Set(config.excludeDirs),
649
694
  externalBodyLinkRoots: Array.isArray(config.externalBodyLinkRoots) ? config.externalBodyLinkRoots : [],
@@ -0,0 +1,192 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import * as inventory from './agent-hosts.mjs';
5
+ import { shellQuote } from './util.mjs';
6
+ import { sanitizeTelemetryText } from './journal.mjs';
7
+ import { installationReport } from './installation.mjs';
8
+
9
+ const HOSTS = ['claude', 'codex', 'opencode'];
10
+ const valueFlags = new Set(['--repair', '--claude-config', '--claude-bin']);
11
+ const flags = new Set(['--hosts', '--json', '--apply', '--yes']);
12
+
13
+ export function hostDoctorOptions(argv, { dryRun = false } = {}) {
14
+ const options = { repairs: [], claudeConfigDirs: [], claudeBin: undefined, json: false, apply: false, dryRun };
15
+ for (let i = 0; i < argv.length; i++) {
16
+ const arg = argv[i];
17
+ if (flags.has(arg)) {
18
+ if (arg === '--json') options.json = true;
19
+ if (arg === '--apply' || arg === '--yes') options.apply = true;
20
+ continue;
21
+ }
22
+ if (!valueFlags.has(arg) || !argv[i + 1] || argv[i + 1].startsWith('--')) {
23
+ throw new Error(`Unsupported host diagnostic argument: ${arg}. Use doctor --hosts [--repair <host>] [--apply] [--json].`);
24
+ }
25
+ const value = argv[++i];
26
+ if (arg === '--repair') {
27
+ if (!HOSTS.includes(value)) throw new Error('--repair must name claude, codex, or opencode. Repeat it to select more hosts.');
28
+ options.repairs.push(value);
29
+ } else if (arg === '--claude-config') {
30
+ if (!path.isAbsolute(value)) throw new Error('--claude-config requires an absolute profile directory.');
31
+ options.claudeConfigDirs.push(path.resolve(value));
32
+ } else {
33
+ if (options.claudeBin) throw new Error('--claude-bin may be supplied once.');
34
+ options.claudeBin = path.resolve(value);
35
+ }
36
+ }
37
+ options.repairs = [...new Set(options.repairs)];
38
+ options.claudeConfigDirs = [...new Set(options.claudeConfigDirs)];
39
+ if (options.apply && !options.repairs.length) throw new Error('doctor --hosts --apply requires explicitly selected --repair <host> targets.');
40
+ return options;
41
+ }
42
+
43
+ function commandFor(action, options) {
44
+ const argv = ['runlist', 'doctor', '--hosts', '--repair', action.host, '--apply'];
45
+ if (action.configDir) argv.push('--claude-config', action.configDir);
46
+ if (options.claudeBin) argv.push('--claude-bin', options.claudeBin);
47
+ return argv;
48
+ }
49
+
50
+ export function planHostRepairs(report, options) {
51
+ const actions = [];
52
+ for (const host of options.repairs) {
53
+ const info = report.hosts[host], executable = report.executables[host]?.selected;
54
+ if (host === 'claude') {
55
+ const profiles = info.profiles.filter(profile => !options.claudeConfigDirs.length
56
+ || options.claudeConfigDirs.some(dir => (profile.configDirs ?? [profile.configDir]).includes(dir)));
57
+ for (const profile of profiles) {
58
+ const configDir = options.claudeConfigDirs.find(dir => (profile.configDirs ?? [profile.configDir]).includes(dir)) ?? profile.configDir;
59
+ const blocked = !executable ? 'Claude executable unavailable; install/select the intended host binary first.'
60
+ : !profile.repair.targetable ? profile.repair.reason
61
+ : !profile.installed && profiles.length > 1 && !options.claudeConfigDirs.length
62
+ ? 'Select --claude-config explicitly to install into an additional missing profile.' : null;
63
+ const action = { host, id: `claude:${profile.storeKey}`, configDir, storeKey: profile.storeKey,
64
+ profileDirs: profile.configDirs ?? [profile.configDir], executable: executable?.path, kind: profile.installed ? 'update' : 'install',
65
+ status: blocked ? 'blocked' : 'planned', reason: blocked,
66
+ description: `${profile.installed ? 'Refresh installed' : 'Install missing'} Claude integration in ${configDir}; shared store updated once` };
67
+ if (profile.state === 'current') {
68
+ action.kind = 'none'; action.status = 'current';
69
+ action.reason = null;
70
+ action.description = `Claude files already match the registered catalog in ${configDir}; saved enablement and live loading are separate`;
71
+ if (!executable) action.remedy = 'The Claude executable is unavailable; install/select the host separately to check activation.';
72
+ }
73
+ if (blocked && action.kind !== 'none') {
74
+ const native = [...new Map((report.executables.claude.candidates ?? []).filter(candidate => candidate.kind.startsWith('native'))
75
+ .map(candidate => [candidate.resolvedPath ?? candidate.path, candidate])).values()];
76
+ const safeSource = profile.state !== 'foreign' && profile.registryState !== 'unreadable' && profile.knownMarketplacesState !== 'unreadable'
77
+ && !profile.settings?.some(settings => settings.configDir === configDir && settings.state === 'unreadable');
78
+ action.nextCommands = safeSource ? native.map(candidate => ['runlist', 'doctor', '--hosts', '--repair', 'claude', '--claude-config', configDir, '--claude-bin', candidate.path]) : [];
79
+ action.remedy = action.nextCommands.length ? 'Preview with the intended native binary and profile below; these commands do not apply changes.'
80
+ : 'Inspect the registry/marketplace/settings and intended profile with the host plugin manager; restore readable, managed records before retrying. No automatic replacement is offered.';
81
+ } else if (action.kind !== 'none') action.command = commandFor(action, options);
82
+ actions.push(action);
83
+ }
84
+ if (!profiles.length) actions.push({ host, id: 'claude:unresolved', status: 'blocked', reason: 'No selected Claude profile could be resolved.' });
85
+ } else {
86
+ const blocked = !executable ? `${host} executable unavailable; install the host separately first.`
87
+ : !info.repair.targetable ? info.repair.reason : null;
88
+ const action = { host, id: host, kind: info.state === 'missing' ? 'install' : 'update',
89
+ status: blocked ? 'blocked' : 'planned', reason: blocked,
90
+ description: `${info.state === 'missing' ? 'Install missing' : 'Refresh managed'} ${host} integration; preserve unmarked files` };
91
+ if (host === 'opencode' && info.state === 'current') {
92
+ action.kind = 'none'; action.status = 'current'; action.reason = null;
93
+ action.description = 'OpenCode generated file already matches this CLI candidate; no file repair is needed. Live loading remains unknown.';
94
+ }
95
+ if (host === 'codex' && info.state === 'current') action.description = 'Verify the installed Codex copy; the marketplace source already matches this CLI candidate';
96
+ if (action.status === 'blocked') action.remedy = 'Keep the unmarked/unreadable content. Inspect it and use the host plugin manager or move your own file aside before explicitly installing a managed integration; nothing is overwritten automatically.';
97
+ else if (action.kind !== 'none') action.command = commandFor(action, options);
98
+ actions.push(action);
99
+ }
100
+ }
101
+ return actions;
102
+ }
103
+
104
+ function runRepair(action, { options, config, env }) {
105
+ // Reuse the existing verbs in a fresh process without importing project JS.
106
+ // User values travel on stdin as data, never interpolated into shell code.
107
+ const script = `import { readFileSync } from 'node:fs';
108
+ import { runInstall } from ${JSON.stringify(new URL('./install.mjs', import.meta.url).href)};
109
+ import { runUpdate } from ${JSON.stringify(new URL('./update.mjs', import.meta.url).href)};
110
+ const request = JSON.parse(readFileSync(0, 'utf8'));
111
+ if (request.kind === 'install') await runInstall([request.host], request.config, { claudeBin: request.claudeBin });
112
+ else await runUpdate(request.argv, request.config);`;
113
+ const argv = ['--host', action.host];
114
+ if (action.configDir) argv.push('--claude-config', action.configDir);
115
+ if (options.claudeBin) argv.push('--claude-bin', options.claudeBin);
116
+ return spawnSync(process.execPath, ['--input-type=module', '-e', script], {
117
+ cwd: config.repoRoot, input: JSON.stringify({ kind: action.kind, host: action.host, argv,
118
+ config: { repoRoot: config.repoRoot }, claudeBin: action.host === 'claude' ? options.claudeBin ?? action.executable : undefined }),
119
+ encoding: 'utf8', timeout: 180_000, maxBuffer: 4 * 1024 * 1024,
120
+ env: { ...env, ...(action.configDir ? { CLAUDE_CONFIG_DIR: action.configDir } : {}), RUNLIST_JOURNAL: '0', DOTMD_JOURNAL: '0' },
121
+ });
122
+ }
123
+
124
+ function targetState(report, action) {
125
+ if (action.host !== 'claude') return report.hosts[action.host].state;
126
+ return report.hosts.claude.profiles.find(profile => (profile.configDirs ?? [profile.configDir]).includes(action.configDir))?.state ?? 'unknown';
127
+ }
128
+
129
+ function render(report, out, json) {
130
+ if (json) { out.write(`${JSON.stringify(report, null, 2)}\n`); return; }
131
+ const { cli, node, updateEffect } = report.runtime;
132
+ out.write(`CLI launcher: ${cli.launcher?.path ?? 'unknown'} → ${cli.launcher?.resolvedPath ?? 'unknown'}\n`);
133
+ out.write(`Node: ${node.version} · ${node.path} · prefix ${node.prefix}\nnpm on PATH: ${node.npm ?? 'not found'}\n`);
134
+ out.write(`Update scope: ${updateEffect}\n\n`);
135
+ const format = inventory.formatAgentHostReport;
136
+ if (format) out.write(format(report));
137
+ else {
138
+ out.write(`runlist host health — CLI ${report.candidate.version ?? 'unknown'} (${report.candidate.kind})\n`);
139
+ for (const host of HOSTS) out.write(`${host}: ${report.hosts[host].state}; live loading unknown\n`);
140
+ }
141
+ if (!report.repairs.selectedHosts.length) {
142
+ out.write('\nRepairs are explicit: runlist doctor --hosts --repair <host> previews; add --apply to execute.\n');
143
+ return;
144
+ }
145
+ out.write(`\nRepairs: ${report.repairs.mode}\n`);
146
+ for (const action of report.repairs.actions) {
147
+ out.write(` ${action.status}: ${action.description ?? action.id}${action.reason ? ` — ${action.reason}` : ''}\n`);
148
+ if (action.command) out.write(` ${action.command.map(shellQuote).join(' ')}\n`);
149
+ if (action.remedy) out.write(` ${action.remedy}\n`);
150
+ for (const command of action.nextCommands ?? []) out.write(` ${command.map(shellQuote).join(' ')}\n`);
151
+ }
152
+ for (const item of report.repairs.outstanding) out.write(` Outstanding: ${item}\n`);
153
+ if (report.repairs.failed.length) out.write(`Failed repairs: ${report.repairs.failed.length}; completed: ${report.repairs.completed.length}. Other selected hosts were still attempted.\n`);
154
+ }
155
+
156
+ export async function runDoctorHosts(argv, config = { repoRoot: process.cwd() }, opts = {}) {
157
+ const options = hostDoctorOptions(argv, opts), env = opts.env ?? process.env;
158
+ const reportOptions = { home: opts.home ?? os.homedir(), env, ...(opts.root ? { root: opts.root } : {}),
159
+ claudeConfigDirs: options.claudeConfigDirs, claudeBin: options.claudeBin };
160
+ const read = opts.inventory ?? inventory.agentHostReport;
161
+ const before = read(config, reportOptions);
162
+ const installation = installationReport(config, reportOptions);
163
+ const runtime = { cli: installation.cli, node: installation.node, project: installation.project, updateEffect: installation.updateEffect };
164
+ const actions = planHostRepairs(before, options);
165
+ const repairs = { mode: options.apply && !options.dryRun ? 'apply' : options.repairs.length ? 'preview' : 'none',
166
+ selectedHosts: options.repairs, actions, completed: [], failed: [], outstanding: [] };
167
+ let report = { ...before, runtime, repairs };
168
+ let attempted = false;
169
+ if (repairs.mode === 'apply') {
170
+ for (const action of actions) {
171
+ if (action.status === 'blocked') { repairs.failed.push({ id: action.id, reason: action.reason }); continue; }
172
+ if (action.kind === 'none') { repairs.completed.push({ id: action.id, host: action.host, sourceState: 'current', action: 'no change needed', installedOrLoaded: 'not attested by filesystem inventory' }); continue; }
173
+ try {
174
+ attempted = true;
175
+ const result = await (opts.run ?? runRepair)(action, { options, config, env });
176
+ action.output = sanitizeTelemetryText(`${result.stdout ?? ''}${result.stderr ?? ''}${result.error?.message ?? ''}`);
177
+ if (result.status !== 0) throw new Error(`Repair command exited ${result.status ?? 'unknown'}${result.error ? `: ${result.error.message}` : ''}`);
178
+ const after = read(config, reportOptions), state = targetState(after, action);
179
+ action.sourceState = state;
180
+ if (['missing', 'stale', 'foreign'].includes(state)) throw new Error(`Repair command exited successfully but source state remains ${state}.`);
181
+ action.status = 'completed';
182
+ repairs.completed.push({ id: action.id, host: action.host, sourceState: state, installedOrLoaded: 'not attested by filesystem inventory' });
183
+ if (state === 'unknown') repairs.outstanding.push(`${action.host}: command completed but source parity remains unknown; inspect the host's installed copy.`);
184
+ } catch (error) { action.status = 'failed'; action.reason = error.message; repairs.failed.push({ id: action.id, reason: error.message }); }
185
+ }
186
+ report = { ...read(config, reportOptions), readOnly: !attempted, runtime, repairs };
187
+ if (repairs.failed.length) process.exitCode = 1;
188
+ for (const host of options.repairs) repairs.outstanding.push(...(report.hosts[host].requirements ?? [`Restart/reload ${host}; live activation remains unknown.`]));
189
+ }
190
+ render(report, opts.out ?? process.stdout, options.json);
191
+ return report;
192
+ }
package/src/doctor.mjs CHANGED
@@ -85,7 +85,8 @@ function runDoctorTransactions(argv, config, opts = {}) {
85
85
  const mark = clearedIds.has(item.id) ? green('✓') : blocking ? yellow('!') : dim('·');
86
86
  const note = clearedIds.has(item.id)
87
87
  ? 'cleared'
88
- : item.resolvable ? 'resolvable' : (item.reason ?? item.status);
88
+ : item.ownerLiveness === 'live' || item.lockHeld ? 'in progress; rerun later once it finishes'
89
+ : item.resolvable ? 'resolvable' : (item.reason ?? item.status);
89
90
  process.stdout.write(` ${mark} ${item.id} [${item.status}] ${item.operation} — owner ${item.ownerLiveness}, ${note}\n`);
90
91
  for (const participant of item.participants) {
91
92
  process.stdout.write(dim(` ${participant.state.padEnd(9)} ${toRepoPath(participant.path, config.repoRoot)}\n`));
@@ -97,15 +98,17 @@ function runDoctorTransactions(argv, config, opts = {}) {
97
98
 
98
99
  const resolvable = report.filter(item => item.resolvable && !clearedIds.has(item.id));
99
100
  if (cleared.length) {
100
- process.stdout.write(green(`\n✓ Cleared ${cleared.length} transaction${cleared.length === 1 ? '' : 's'} whose files already agreed on one generation.\n`));
101
+ process.stdout.write(green(`\n✓ Cleared ${cleared.length} transaction${cleared.length === 1 ? '' : 's'} with verified recovery or cleanup evidence.\n`));
101
102
  }
102
103
  if (resolvable.length) {
103
- process.stdout.write(`\n${resolvable.length} resolvable — the canonical files already agree on one generation.\n`);
104
+ process.stdout.write(`\n${resolvable.length} resolvable — recovery or cleanup evidence is verified.\n`);
104
105
  process.stdout.write(dim('Run `runlist doctor --transactions --apply` to clear them (no document content is touched).\n'));
105
106
  }
106
- const stuck = report.filter(item => !item.resolvable && !clearedIds.has(item.id) && (item.status === 'failed-manual' || !item.readable));
107
+ const inProgress = report.filter(item => item.ownerLiveness === 'live' || item.lockHeld);
108
+ if (inProgress.length) process.stdout.write(dim(`\n${inProgress.length} in progress — another runlist process is mid-move; rerun later once it finishes.\n`));
109
+ const stuck = report.filter(item => !item.resolvable && !clearedIds.has(item.id) && !inProgress.includes(item));
107
110
  if (stuck.length) {
108
- process.stdout.write(yellow(`\n${stuck.length} need manual review — generations disagree, so clearing them could lose work.\n`));
111
+ process.stdout.write(yellow(`\n${stuck.length} need manual review — generations or retained artifacts could not be verified.\n`));
109
112
  process.stdout.write(dim('Inspect the participant paths above against the artifacts in each transaction directory.\n'));
110
113
  }
111
114
  }
@@ -171,7 +174,7 @@ async function runDoctorClaims(argv, config, opts = {}) {
171
174
  return;
172
175
  }
173
176
  if (claims.length === 0) {
174
- process.stdout.write(green('✓') + ' No plans are claimed — nothing can be wedged by ownership.\n');
177
+ process.stdout.write(green('✓') + (config.mode === 'handoffs' ? ' Nothing is claimed.\n' : ' No plans are claimed — nothing can be wedged by ownership.\n'));
175
178
  return;
176
179
  }
177
180
 
@@ -287,6 +290,7 @@ export function runDoctor(argv, config, opts = {}) {
287
290
  ? dim('[preview — run with --apply to write]')
288
291
  : dim('[applying changes]');
289
292
  process.stdout.write(bold('runlist doctor') + ' ' + modeNote + '\n\n');
293
+ process.stdout.write(dim('Agent integrations: runlist doctor --hosts (read-only); select --repair <host> to preview host repairs.\n\n'));
290
294
  if (dryRun) {
291
295
  const skippedHooks = ['validate', 'transformDoc', 'formatSnapshot', 'renderCheck']
292
296
  .filter(name => typeof config.hooks?.[name] === 'function');
@@ -447,6 +451,11 @@ function runDoctorProject(config, { json = false } = {}) {
447
451
  const skillDriftWarnings = checkSkillDrift(config);
448
452
  const result = {
449
453
  cliVersion: cliPackage?.version ?? null,
454
+ promptTracking: {
455
+ value: config.promptTracking ?? 'local',
456
+ source: config.promptTrackingSource ?? 'legacy-default',
457
+ configPath: config.configPath ?? null,
458
+ },
450
459
  installations: installationReport(config),
451
460
  packageDependency: depVersion,
452
461
  claudeCommandWarnings,
@@ -465,6 +474,7 @@ function runDoctorProject(config, { json = false } = {}) {
465
474
  process.stdout.write(formatInstallationReport(result.installations)+'\n');
466
475
  process.stdout.write(`- running CLI version: ${result.cliVersion ?? 'unknown'}\n`);
467
476
  process.stdout.write(`- package dependency: ${result.packageDependency ?? '(none found)'}\n`);
477
+ process.stdout.write(`- prompt tracking: ${result.promptTracking.value} (source: ${result.promptTracking.source}) — inspect/change with runlist config; ignore files and Git state are unchanged\n`);
468
478
  process.stdout.write(`- stale Claude commands: ${claudeCommandWarnings.length}\n`);
469
479
  if (deprecatedCommandMentions.length) {
470
480
  process.stdout.write(`- docs mentioning deprecated commands: ${deprecatedCommandMentions.length}\n`);
package/src/export.mjs CHANGED
@@ -5,6 +5,8 @@ import { buildIndex } from './index.mjs';
5
5
  import { buildGraph } from './graph.mjs';
6
6
  import { resolveDocPath, resolveRefPath, toRepoPath, capitalize, die } from './util.mjs';
7
7
  import { allocateOutputIdentities } from './output-identity.mjs';
8
+ import { assertInsideHandoffs } from './handoffs-scope.mjs';
9
+ import { assertOutsideLegacyPromptFolder } from './managed-path.mjs';
8
10
 
9
11
  export function runExport(argv, config, opts = {}) {
10
12
  const positional = [];
@@ -28,6 +30,14 @@ export function runExport(argv, config, opts = {}) {
28
30
  positional.push(argv[i]);
29
31
  }
30
32
 
33
+ // Before any config and where planning is off, nothing is written into the
34
+ // root prompts/ (src/naming.mjs), an export file or folder included.
35
+ const destination = output ?? (format === 'html' ? (existsSync('dotmd-export') ? 'dotmd-export' : 'runlist-export') : null);
36
+ if (destination) {
37
+ try { assertOutsideLegacyPromptFolder(destination, config, 'Export output'); }
38
+ catch (err) { die(err.message); }
39
+ }
40
+
31
41
  if (!['md', 'html', 'json'].includes(format)) {
32
42
  die(`Invalid format: ${format}\nValid: md, html, json`);
33
43
  }
@@ -37,6 +47,7 @@ export function runExport(argv, config, opts = {}) {
37
47
 
38
48
  if (positional[0]) {
39
49
  // Single doc + deps mode
50
+ assertInsideHandoffs(positional[0], config);
40
51
  const filePath = resolveDocPath(positional[0], config);
41
52
  if (!filePath) die(`File not found: ${positional[0]}`);
42
53
  const repoPath = toRepoPath(filePath, config.repoRoot);
package/src/git.mjs CHANGED
@@ -41,7 +41,7 @@ function ensureGit() {
41
41
  gitChecked = true;
42
42
  }
43
43
 
44
- function literalGitPathspec(filePath) {
44
+ export function literalGitPathspec(filePath) {
45
45
  return filePath === '.' ? ':(top,literal)' : `:(top,literal)${filePath}`;
46
46
  }
47
47
 
@@ -508,13 +508,69 @@ export function gitMv(source, target, repoRoot) {
508
508
 
509
509
  export function isTracked(source, repoRoot) {
510
510
  const relSource = path.isAbsolute(source) ? path.relative(repoRoot, source) : source;
511
- const result = spawnSync('git', ['ls-files', '--error-unmatch', '--', relSource], {
511
+ const result = spawnSync('git', ['ls-files', '--error-unmatch', '--', literalGitPathspec(relSource)], {
512
512
  cwd: repoRoot,
513
513
  encoding: 'utf8',
514
514
  });
515
515
  return result.status === 0;
516
516
  }
517
517
 
518
+ // A path-limited commit needs both sides of a staged move: the old path is
519
+ // absent from the index but still in HEAD. Never offer to add that missing
520
+ // path, or commit a missing path that Git has never known about.
521
+ export function gitCommitPaths(paths, repoRoot, options = {}) {
522
+ const unique = [...new Set(paths)];
523
+ if (!unique.length) return { paths: [], add: [] };
524
+ const maxPathsPerBatch = boundedPositiveInteger(options.maxPathsPerBatch, DEFAULT_GIT_METADATA_PATH_BATCH, 'maxPathsPerBatch');
525
+ const runGit = options.runGit ?? spawnSync;
526
+ const spawnOptions = { cwd: repoRoot, encoding: 'utf8' };
527
+ // On incomplete classification, preserve the original plain commit hint.
528
+ // Partial results must never drop a tracked move source or propose staging.
529
+ const fallback = () => ({ paths: unique, add: [] });
530
+ try {
531
+ assertSafeGitPaths(unique);
532
+ const indexed = new Set();
533
+ for (let offset = 0; offset < unique.length; offset += maxPathsPerBatch) {
534
+ const batch = unique.slice(offset, offset + maxPathsPerBatch);
535
+ const listed = runGit('git', ['ls-files', '-z', '--', ...batch.map(literalGitPathspec)], spawnOptions);
536
+ if (listed.error || listed.status !== 0) return fallback();
537
+ for (const file of listed.stdout.split('\0').filter(Boolean)) indexed.add(file);
538
+ }
539
+ const missing = new Set(unique.filter(file => !indexed.has(file) && !existsSync(path.resolve(repoRoot, file))));
540
+ const inHead = new Set();
541
+ if (missing.size) {
542
+ const missingPaths = [...missing];
543
+ for (let offset = 0; offset < missingPaths.length; offset += maxPathsPerBatch) {
544
+ const batch = missingPaths.slice(offset, offset + maxPathsPerBatch);
545
+ const head = runGit('git', ['ls-tree', '-r', '--name-only', '-z', 'HEAD', '--', ...batch.map(literalGitPathspec)], spawnOptions);
546
+ if (head.error || head.status !== 0) {
547
+ // An unborn HEAD is normal; other failures cannot prove paths absent.
548
+ const revision = runGit('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], spawnOptions);
549
+ if (!head.error && !revision.error && revision.status === 1) break;
550
+ return fallback();
551
+ }
552
+ for (const file of head.stdout.split('\0').filter(Boolean)) inHead.add(file);
553
+ }
554
+ }
555
+ const candidates = unique.filter(file => !indexed.has(file) && !missing.has(file));
556
+ const ignored = new Set();
557
+ if (candidates.length) {
558
+ // NUL input preserves filename boundaries and treats brackets literally;
559
+ // check-ignore still parses leading ':' pathspec magic.
560
+ const result = runGit('git', ['check-ignore', '--stdin', '-z'], {
561
+ ...spawnOptions, input: candidates.join('\0') + '\0',
562
+ });
563
+ if (result.error || ![0, 1].includes(result.status)) return fallback();
564
+ if (result.status === 0) for (const file of result.stdout.split('\0').filter(Boolean)) ignored.add(file);
565
+ }
566
+ const add = candidates.filter(file => !ignored.has(file));
567
+ const committable = new Set([...indexed, ...inHead, ...add]);
568
+ return { paths: unique.filter(file => committable.has(file)), add };
569
+ } catch {
570
+ return fallback();
571
+ }
572
+ }
573
+
518
574
  export function gitIndexLocations(repoRoot) {
519
575
  const result = spawnSync('git', ['rev-parse', '--git-dir'], { cwd: repoRoot, encoding: 'utf8' });
520
576
  if (result.error || result.status !== 0) throw new Error(`Could not locate Git directory: ${result.error?.message || result.stderr.trim()}`);