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
@@ -0,0 +1,305 @@
1
+ // Offline host inventory. No host executable is invoked, and settings readers
2
+ // intentionally retain only enabledPlugins (never environment or prompt data).
3
+ import { createHash } from 'node:crypto';
4
+ import { accessSync, constants, closeSync, existsSync, lstatSync, openSync, readFileSync, readSync, readdirSync, realpathSync, statSync } from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+ const PLUGIN_FILES = ['skills', 'commands', 'hooks', 'hooks.json', 'bin', '.claude-plugin', '.codex-plugin'];
11
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
12
+ const real = file => { try { return realpathSync(file); } catch { return path.resolve(file); } };
13
+ function readJson(file) {
14
+ try {
15
+ const stat = statSync(file);
16
+ if (!stat.isFile() || stat.size > 4 * 1024 * 1024) return { state: 'unreadable', data: null };
17
+ const data = JSON.parse(readFileSync(file, 'utf8'));
18
+ return object(data) ? { state: 'readable', data } : { state: 'unreadable', data: null };
19
+ } catch (error) { return { state: error.code === 'ENOENT' ? 'missing' : 'unreadable', data: null }; }
20
+ }
21
+
22
+ // Engine hashes exclude private documents and release labels. Callers can pass
23
+ // PLUGIN_FILES to include both hook layouts and the plugin manifests.
24
+ export function sourceFingerprint(root, entries = ['package.json', 'src', 'bin', 'assets', 'desktop/helper.mjs', 'desktop/trust.mjs', 'desktop/line-framer.mjs']) {
25
+ try {
26
+ const files = [], hash = createHash('sha256'); let bytes = 0;
27
+ const visit = (name, depth = 0) => {
28
+ if (depth > 32 || files.length >= 2000) throw new Error('limit');
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}`, depth + 1);
32
+ else if (stat.isFile()) {
33
+ bytes += stat.size; if (bytes > 64 * 1024 * 1024) 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);
37
+ }
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' }; }
42
+ }
43
+ export const pluginFingerprint = root => root ? sourceFingerprint(root, PLUGIN_FILES) : { state: 'unavailable' };
44
+ const same = (a, b) => a?.sha256 && b?.sha256 ? a.sha256 === b.sha256 : null;
45
+ const safeEntry = entry => Object.fromEntries(['scope', 'projectPath', 'installPath', 'version'].filter(key => typeof entry?.[key] === 'string').map(key => [key, entry[key]]));
46
+ const pluginId = id => /^(?:runlist|dotmd)@/.test(id);
47
+ const profilePriority = source => ['home', 'default', 'environment', 'explicit'].indexOf(source);
48
+ const profileSource = sources => [...sources].sort((a, b) => profilePriority(b) - profilePriority(a))[0];
49
+
50
+ function enabledAt(configDir) {
51
+ const { state, data } = readJson(path.join(configDir, 'settings.json'));
52
+ return { configDir, state, enabledPlugins: object(data?.enabledPlugins)
53
+ ? Object.fromEntries(Object.entries(data.enabledPlugins).filter(([id, enabled]) => pluginId(id) && typeof enabled === 'boolean')) : {} };
54
+ }
55
+
56
+ /** Bounded profile discovery, deduplicated by physical plugin store. Aliases
57
+ * retain independent settings because a shared store does not imply activation. */
58
+ export function discoverClaudeProfiles({ home = os.homedir(), env = process.env, configDirs = [] } = {}) {
59
+ home = path.resolve(home);
60
+ const choices = new Map();
61
+ const add = (dir, source) => { if (typeof dir !== 'string' || !dir.trim()) return;
62
+ dir = path.resolve(dir); if (!choices.has(dir)) choices.set(dir, []); choices.get(dir).push(source); };
63
+ add(path.join(home, '.claude'), 'default');
64
+ add(env.CLAUDE_CONFIG_DIR, 'environment');
65
+ try { for (const entry of readdirSync(home, { withFileTypes: true }))
66
+ if (/^\.claude-.+/.test(entry.name) && (entry.isDirectory() || entry.isSymbolicLink())) add(path.join(home, entry.name), 'home'); } catch {}
67
+ for (const dir of configDirs) { if (!path.isAbsolute(dir)) throw new Error('Claude config directories must be absolute'); add(dir, 'explicit'); }
68
+ const stores = new Map();
69
+ for (const [configDir, sources] of choices) {
70
+ const pluginsDir = path.join(configDir, 'plugins'), storeKey = real(pluginsDir);
71
+ if (stores.has(storeKey)) {
72
+ const profile = stores.get(storeKey); profile.configDirs.push(configDir); profile.settings.push(enabledAt(configDir));
73
+ profile.sources.push(...sources);
74
+ const source = profileSource(sources);
75
+ if (profilePriority(source) > profilePriority(profile.source)) Object.assign(profile, {
76
+ configDir, pluginsDir, source, registryPath: path.join(pluginsDir, 'installed_plugins.json'),
77
+ knownMarketplacesPath: path.join(pluginsDir, 'known_marketplaces.json'),
78
+ });
79
+ continue;
80
+ }
81
+ const registryPath = path.join(pluginsDir, 'installed_plugins.json'), registry = readJson(registryPath);
82
+ if (registry.state === 'readable' && !object(registry.data.plugins)) registry.state = 'unreadable';
83
+ const knownMarketplacesPath = path.join(pluginsDir, 'known_marketplaces.json'), known = readJson(knownMarketplacesPath);
84
+ const plugins = Object.entries(object(registry.data?.plugins) ? registry.data.plugins : {}).filter(([id]) => pluginId(id)).map(([id, records]) => {
85
+ const marketplace = id.split('@')[1] || null;
86
+ return { id, entries: (Array.isArray(records) ? records : [records]).filter(object).map(safeEntry), marketplace,
87
+ marketplaceRegistered: known.state === 'unreadable' ? null : Boolean(known.data?.[marketplace]) };
88
+ });
89
+ const preferred = plugins.find(p => p.id === 'runlist@runlist') ?? plugins.find(p => p.id === 'dotmd@dotmd') ?? plugins[0] ?? null;
90
+ stores.set(storeKey, { configDir, configDirs: [configDir], pluginsDir, storeKey, registryPath, registryState: registry.state,
91
+ knownMarketplacesPath, knownMarketplacesState: known.state, source: profileSource(sources), sources: [...sources],
92
+ installed: preferred ? { ...preferred, version: preferred.entries[0]?.version ?? null } : null,
93
+ plugins, settings: [enabledAt(configDir)] });
94
+ }
95
+ return [...stores.values()];
96
+ }
97
+
98
+ function executableCandidate(file, source, platform) {
99
+ try {
100
+ const stat = statSync(file); if (!stat.isFile()) return null;
101
+ if (platform !== 'win32') accessSync(file, constants.X_OK);
102
+ const buffer = Buffer.alloc(512); let fd;
103
+ try { fd = openSync(file, 'r'); readSync(fd, buffer, 0, buffer.length, 0); } finally { if (fd !== undefined) closeSync(fd); }
104
+ const magic = buffer.subarray(0, 4).toString('hex');
105
+ const native = ['7f454c46', 'feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe', 'bebafeca'].includes(magic) || buffer.subarray(0, 2).toString() === 'MZ';
106
+ const symlink = lstatSync(file).isSymbolicLink();
107
+ return { path: path.resolve(file), resolvedPath: real(file), kind: native ? (symlink ? 'native-symlink' : 'native') : (symlink ? 'script-symlink' : 'wrapper'), source,
108
+ provenance: { state: 'unknown', reason: 'filesystem executable inventory does not attest package origin or version' } };
109
+ } catch { return null; }
110
+ }
111
+
112
+ /** PATH order is retained; known native Claude and explicit paths stay visible.
113
+ * No attempt is made to infer a wrapper's profile by reading its script body. */
114
+ export function discoverHostExecutables({ home = os.homedir(), env = process.env, claudeBin, platform = process.platform } = {}) {
115
+ const result = {}, delimiter = platform === 'win32' ? ';' : path.delimiter;
116
+ for (const host of ['claude', 'codex', 'opencode']) {
117
+ const candidates = [], seen = new Set();
118
+ const add = (file, source) => { const candidate = executableCandidate(file, source, platform);
119
+ if (candidate && !seen.has(candidate.path)) { seen.add(candidate.path); candidates.push(candidate); } };
120
+ if (host === 'claude' && claudeBin) add(claudeBin, 'explicit');
121
+ for (const dir of (env.PATH ?? '').split(delimiter).filter(Boolean))
122
+ for (const suffix of platform === 'win32' ? ['', '.exe', '.cmd', '.bat'] : ['']) add(path.resolve(dir, host + suffix), 'PATH');
123
+ if (host === 'claude') add(path.join(home, '.local', 'bin', 'claude'), 'native-location');
124
+ const selected = host === 'claude' && claudeBin ? candidates.find(c => c.path === path.resolve(claudeBin)) ?? null
125
+ : candidates.find(c => c.source === 'PATH') ?? candidates[0] ?? null;
126
+ result[host] = { selected, candidates, explicit: host === 'claude' && Boolean(claudeBin),
127
+ selectionState: selected ? 'selected' : claudeBin && host === 'claude' ? 'unavailable' : 'missing', profileMapping: 'unknown' };
128
+ }
129
+ return result;
130
+ }
131
+
132
+ function officialCatalog(profile, plugin) {
133
+ const known = readJson(profile.knownMarketplacesPath), market = known.data?.[plugin.marketplace];
134
+ const source = market?.source;
135
+ const official = plugin.marketplace === 'runlist' && ((source?.source === 'github' && source.repo === 'reowens/runlist')
136
+ || (source?.source === 'git' && /^https:\/\/github\.com\/reowens\/runlist(?:\.git)?\/?$/.test(source.url ?? '')));
137
+ const location = typeof market?.installLocation === 'string' && path.isAbsolute(market.installLocation) ? market.installLocation : null;
138
+ const catalog = location ? readJson(path.join(location, '.claude-plugin', 'marketplace.json')) : { state: 'missing' };
139
+ const item = official && catalog.data?.name === 'runlist' && Array.isArray(catalog.data.plugins)
140
+ ? catalog.data.plugins.find(p => p?.name === plugin.id.split('@')[0]) : null;
141
+ let pluginPath = null;
142
+ if (typeof item?.source === 'string' && !path.isAbsolute(item.source)) {
143
+ const candidate = path.resolve(location, item.source), relative = path.relative(real(location), real(candidate));
144
+ if (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) pluginPath = candidate;
145
+ }
146
+ const fingerprint = pluginFingerprint(pluginPath);
147
+ return { state: !official ? 'unknown' : pluginPath ? fingerprint.state === 'readable' ? 'available' : fingerprint.state === 'unreadable' ? 'unreadable' : 'missing'
148
+ : catalog.state === 'unreadable' ? 'unreadable' : 'unknown',
149
+ provenance: official ? 'official' : object(source) ? 'foreign' : 'unknown',
150
+ official, marketplace: plugin.marketplace, path: pluginPath, fingerprint,
151
+ version: pluginPath ? readJson(path.join(pluginPath, '.claude-plugin', 'plugin.json')).data?.version ?? item?.version ?? null : null };
152
+ }
153
+
154
+ function claudeReport(profiles, executables, root) {
155
+ const candidate = pluginFingerprint(path.join(root, 'plugins', 'runlist'));
156
+ const reported = profiles.map(profile => {
157
+ const plugins = profile.plugins.map(plugin => {
158
+ const catalog = officialCatalog(profile, plugin);
159
+ const entries = plugin.entries.map(entry => {
160
+ const absolute = typeof entry.installPath === 'string' && path.isAbsolute(entry.installPath);
161
+ const fingerprint = pluginFingerprint(absolute ? entry.installPath : null), sameSource = same(fingerprint, catalog.fingerprint);
162
+ return { ...entry, fingerprint, sameSource, sameCandidate: same(fingerprint, candidate),
163
+ state: !absolute ? 'unknown' : !existsSync(entry.installPath) ? 'missing' : sameSource === false ? 'stale' : sameSource === true ? 'current' : 'unknown' };
164
+ });
165
+ return { ...plugin, catalog, expectedVersion: catalog.version, entries, state: catalog.provenance === 'foreign' ? 'foreign' : plugin.marketplaceRegistered === false ? 'stale'
166
+ : entries.some(e => e.state === 'missing' || e.state === 'stale') ? 'stale' : entries.length && entries.every(e => e.state === 'current') ? 'current' : 'unknown' };
167
+ });
168
+ const state = profile.registryState === 'unreadable' ? 'unknown' : plugins.some(p => p.state === 'foreign') ? 'foreign' : !plugins.length ? 'missing'
169
+ : plugins.some(p => p.state === 'stale') ? 'stale' : plugins.every(p => p.state === 'current') ? 'current' : 'unknown';
170
+ // A native binary paired explicitly with a profile is safe to target. PATH
171
+ // wrappers can silently override CLAUDE_CONFIG_DIR and are never inferred.
172
+ const native = executables.selected?.kind.startsWith('native');
173
+ const unreadableSettings = profile.settings.some(settings => settings.configDir === profile.configDir && settings.state === 'unreadable');
174
+ const nativeTargets = new Set(executables.candidates.filter(c => c.kind.startsWith('native')).map(c => c.resolvedPath));
175
+ const targetable = Boolean(native && state !== 'foreign' && profile.registryState !== 'unreadable'
176
+ && profile.knownMarketplacesState !== 'unreadable' && !unreadableSettings && (executables.explicit || nativeTargets.size === 1));
177
+ const reason = state === 'foreign' ? 'preserve plugin records from a foreign marketplace'
178
+ : profile.registryState === 'unreadable' || profile.knownMarketplacesState === 'unreadable' || unreadableSettings ? 'registry, marketplace or settings data is unreadable; do not replace unknown records'
179
+ : !targetable ? 'select a native --claude-bin and absolute --claude-config; wrapper or binary profile mapping is unknown'
180
+ : 'explicit profile environment required';
181
+ const saved = profile.settings.flatMap(settings => Object.values(settings.enabledPlugins));
182
+ const savedEnablement = saved.includes(true) && saved.includes(false) ? 'mixed' : saved.includes(false) ? 'disabled' : saved.includes(true) ? 'enabled' : 'unknown';
183
+ return { ...profile, plugins, state, savedEnablement, loaded: 'unknown', repair: { host: 'claude', configDir: profile.configDir, storeKey: profile.storeKey,
184
+ executable: executables.selected?.path ?? null, action: state === 'missing' ? 'install' : 'refresh', targetable,
185
+ reason } };
186
+ });
187
+ return { state: reported.some(p => p.state === 'foreign') ? 'foreign' : reported.some(p => p.state === 'stale') ? 'stale'
188
+ : reported.every(p => p.state === 'missing') ? 'missing' : reported.every(p => p.state === 'current') ? 'current' : 'unknown',
189
+ executables, profiles: reported, candidate: { path: path.join(root, 'plugins', 'runlist'), fingerprint: candidate }, loaded: 'unknown',
190
+ requirements: ['Reload plugins or restart Claude Code after repair.', 'Review enabledPlugins and host trust; registry records do not attest activation.'] };
191
+ }
192
+
193
+ function codexReport(home, env, root, executables) {
194
+ const marketplacePath = path.join(home, '.agents/plugins/marketplace.json'), marketplace = readJson(marketplacePath);
195
+ if (marketplace.state === 'readable' && (typeof marketplace.data.name !== 'string' || !Array.isArray(marketplace.data.plugins))) marketplace.state = 'unreadable';
196
+ const item = Array.isArray(marketplace.data?.plugins) ? marketplace.data.plugins.find(p => p?.name === 'runlist-codex') : null;
197
+ const expectedPath = path.join(home, 'plugins/runlist-codex');
198
+ const sourcePath = item?.source?.source === 'local' && typeof item.source.path === 'string' ? path.resolve(home, item.source.path) : expectedPath;
199
+ const marker = readJson(path.join(sourcePath, '.runlist-generated.json')), fingerprint = pluginFingerprint(sourcePath);
200
+ const candidate = pluginFingerprint(path.join(root, 'plugins/runlist-codex'));
201
+ const foreign = existsSync(sourcePath) && (marker.state !== 'readable' || typeof marker.data?.version !== 'string');
202
+ const registered = Boolean(item), expectedSource = item?.source?.source === 'local' && sourcePath === expectedPath;
203
+ const sourceState = !existsSync(sourcePath) ? 'missing' : foreign || registered && !expectedSource ? 'foreign'
204
+ : marketplace.state === 'unreadable' ? 'unknown' : !registered || same(fingerprint, candidate) === false ? 'stale'
205
+ : same(fingerprint, candidate) === true ? 'current' : 'unknown';
206
+ const codexHome = env.CODEX_HOME ? path.resolve(env.CODEX_HOME) : path.join(home, '.codex');
207
+ const cacheRoot = path.join(codexHome, 'plugins/cache'), cached = [];
208
+ try { for (const market of readdirSync(cacheRoot).slice(0, 100)) {
209
+ const pluginRoot = path.join(cacheRoot, market, 'runlist-codex');
210
+ try { for (const version of readdirSync(pluginRoot).slice(0, 100)) {
211
+ const file = path.join(pluginRoot, version), fp = pluginFingerprint(file);
212
+ const sameSource = same(fp, fingerprint);
213
+ cached.push({ path: file, version, marketplace: market, role: 'cached copy; not an installed or loaded attestation', fingerprint: fp,
214
+ state: sameSource === true ? 'current' : sameSource === false ? 'stale' : 'unknown', sameSource, sameCandidate: same(fp, candidate) });
215
+ } } catch {}
216
+ } } catch {}
217
+ return { state: sourceState, stateBasis: 'managed marketplace source relative to the selected candidate; installed and loaded states remain unknown', executables, configDir: codexHome, cacheRoot,
218
+ marketplace: { path: marketplacePath, state: marketplace.state, name: marketplace.data?.name ?? null, registered, expectedSource },
219
+ source: { path: sourcePath, state: sourceState, foreign, version: marker.data?.version ?? null, fingerprint, sameCandidate: same(fingerprint, candidate) },
220
+ cached, installed: { state: 'unknown', reason: 'offline cache inventory does not attest host installation' }, loaded: 'unknown',
221
+ repair: { host: 'codex', action: sourceState === 'missing' ? 'install' : 'refresh', targetable: !foreign && (!registered || expectedSource) && marketplace.state !== 'unreadable',
222
+ reason: foreign ? 'preserve unmarked source' : registered && !expectedSource ? 'preserve marketplace entry targeting another source'
223
+ : marketplace.state === 'unreadable' ? 'marketplace data is unreadable; preserve unknown entries'
224
+ : 'refresh managed marketplace source; host installation still requires verification' },
225
+ requirements: ['Start a new Codex thread after repair.', 'Review and trust plugin hooks in Codex; cached copies do not prove activation.'] };
226
+ }
227
+
228
+ function opencodeReport(home, env, root, executables) {
229
+ const expectedVersion = readJson(path.join(root, 'package.json')).data?.version ?? null;
230
+ const configDir = env.OPENCODE_CONFIG_DIR?.trim() ? path.resolve(env.OPENCODE_CONFIG_DIR) : path.join(env.XDG_CONFIG_HOME ? path.resolve(env.XDG_CONFIG_HOME) : path.join(home, '.config'), 'opencode');
231
+ const candidatePath = path.join(root, 'assets/opencode/plugin.js');
232
+ let candidate = null; try { candidate = readFileSync(candidatePath, 'utf8'); } catch {}
233
+ const files = [];
234
+ for (const directory of ['plugin', 'plugins']) for (const name of ['runlist.js', 'dotmd.js']) {
235
+ const file = path.join(configDir, directory, name); if (!existsSync(file)) continue;
236
+ let body; try { const stat = statSync(file); if (!stat.isFile() || stat.size > 4 * 1024 * 1024) throw new Error('limit'); body = readFileSync(file, 'utf8'); } catch { files.push({ path: file, state: 'unknown', foreign: true }); continue; }
237
+ const marker = /^\/\/ (runlist-generated:|dotmd-generated:)([^\r\n]*)/.exec(body);
238
+ const expected = marker && candidate !== null ? [
239
+ `// ${marker[1]}${expectedVersion ?? marker[2]}`,
240
+ '// Generated by `runlist install opencode`. Refreshed by `runlist update`.',
241
+ '// Hand edits are overwritten — remove with `runlist install opencode --remove`.', '',
242
+ ].join('\n') + candidate : null;
243
+ const sameSource = expected !== null ? body === expected : null;
244
+ const legacy = name === 'dotmd.js' || marker?.[1] === 'dotmd-generated:';
245
+ files.push({ path: file, version: marker?.[2] ?? null, foreign: !marker, legacy, sameSource,
246
+ state: !marker ? 'foreign' : legacy || sameSource === false ? 'stale' : sameSource === true ? 'current' : 'unknown' });
247
+ }
248
+ const state = files.some(f => f.state === 'foreign') ? 'foreign' : !files.length ? 'missing' : files.some(f => f.state === 'stale') ? 'stale' : files.every(f => f.state === 'current') ? 'current' : 'unknown';
249
+ return { state, configDir, executables, files, expectedVersion, loaded: 'unknown', candidate: { path: candidatePath },
250
+ stateBasis: 'managed file compared with the current CLI candidate; checkout differences are not a published-release attestation',
251
+ repair: { host: 'opencode', action: state === 'missing' ? 'install' : 'refresh', targetable: !files.some(f => f.foreign), reason: 'preserve unmarked files; refresh only generated content' },
252
+ requirements: ['Restart OpenCode after repair; generated file presence does not attest live loading.'] };
253
+ }
254
+
255
+ /** Unified, serializable inventory. config is accepted for caller compatibility;
256
+ * host inventory never imports or requires a project configuration. */
257
+ export function agentHostReport(config = {}, { root = PACKAGE_ROOT, home = os.homedir(), env = process.env, claudeConfigDirs = [], claudeBin, platform = process.platform } = {}) {
258
+ const executables = discoverHostExecutables({ home, env, claudeBin, platform });
259
+ const profiles = discoverClaudeProfiles({ home, env, configDirs: claudeConfigDirs });
260
+ const pkg = readJson(path.join(root, 'package.json'));
261
+ return { schemaVersion: 1, readOnly: true, candidate: { path: root, version: pkg.data?.version ?? null, kind: existsSync(path.join(root, '.git')) ? 'checkout' : 'package' },
262
+ executables, hosts: { claude: claudeReport(profiles, executables.claude, root), codex: codexReport(home, env, root, executables.codex),
263
+ opencode: opencodeReport(home, env, root, executables.opencode) },
264
+ warnings: ['Loaded live-session state is unknown without host attestation.', 'Local candidate differences alone do not prove a published Claude install is broken.'] };
265
+ }
266
+
267
+ export function formatAgentHostReport(report) {
268
+ const lines = [`Agent hosts (${report.readOnly === false ? 'after selected repairs' : 'read-only'}) · candidate ${report.candidate.version ?? 'unknown'} · ${report.candidate.kind} · ${report.candidate.path}`];
269
+ const parity = value => value === true ? 'matches' : value === false ? 'differs' : 'unknown';
270
+ for (const host of ['claude', 'codex', 'opencode']) {
271
+ const item = report.hosts[host], inventory = item.executables;
272
+ lines.push(`${host}: ${item.state} · loaded ${item.loaded}`,
273
+ ` Selected executable: ${inventory.selected?.path ?? 'not found'}${inventory.selected ? ` (${inventory.selected.kind})` : ''}`);
274
+ for (const candidate of inventory.candidates) lines.push(` Executable candidate: ${candidate.path} → ${candidate.resolvedPath} · ${candidate.kind} · ${candidate.source} · provenance ${candidate.provenance.state}`);
275
+ if (host === 'claude') for (const profile of item.profiles) {
276
+ lines.push(` Profiles: ${profile.configDirs.join(' ↔ ')} · ${profile.state} · saved enablement ${profile.savedEnablement} · source ${profile.sources.join(', ')} · registry ${profile.registryState}`,
277
+ ` Plugin store: ${profile.storeKey} · marketplaces ${profile.knownMarketplacesState}`);
278
+ for (const settings of profile.settings) {
279
+ const enabled = Object.entries(settings.enabledPlugins).map(([id, value]) => `${id}=${value}`).join(', ');
280
+ lines.push(` Enabled settings: ${settings.configDir} · ${settings.state} · ${enabled || 'no saved runlist enablement'}`);
281
+ }
282
+ for (const plugin of profile.plugins) {
283
+ lines.push(` Plugin: ${plugin.id} · ${plugin.state} · expected published version ${plugin.expectedVersion ?? 'unknown'} · marketplace registered ${plugin.marketplaceRegistered ?? 'unknown'}`,
284
+ ` Catalog: ${plugin.catalog.state} · provenance ${plugin.catalog.provenance}${plugin.catalog.path ? ` · ${plugin.catalog.path}` : ''}`);
285
+ for (const entry of plugin.entries) lines.push(` ${entry.scope ?? 'unknown scope'}${entry.projectPath ? ` (${entry.projectPath})` : ''} · ${entry.version ?? 'unknown version'} · ${entry.installPath ?? 'unknown path'} · ${entry.state} · catalog source ${parity(entry.sameSource)} · local candidate ${parity(entry.sameCandidate)}`);
286
+ }
287
+ lines.push(` Repair: ${profile.repair.targetable ? profile.repair.action : 'requires explicit targeting'} · ${profile.repair.reason}`);
288
+ }
289
+ if (host === 'codex') {
290
+ lines.push(` Personal marketplace: ${item.marketplace.path} · ${item.marketplace.state} · registered ${item.marketplace.registered}`,
291
+ ` Marketplace source: ${item.source.path} · ${item.source.state} · local candidate ${parity(item.source.sameCandidate)}`,
292
+ ` State basis: ${item.stateBasis}`,
293
+ ` Active Codex profile: ${item.configDir} · cache ${item.cacheRoot}`,
294
+ ` Installed record: ${item.installed.state} · ${item.installed.reason}`);
295
+ for (const cached of item.cached) lines.push(` Cached copy: ${cached.path} · ${cached.version} · marketplace source ${parity(cached.sameSource)} · ${cached.role}`);
296
+ }
297
+ if (host === 'opencode') {
298
+ lines.push(` Config directory: ${item.configDir} · expected version ${item.expectedVersion ?? 'unknown'}`, ` State basis: ${item.stateBasis}`);
299
+ for (const file of item.files) lines.push(` Generated integration: ${file.path} · ${file.state} · ${file.version ?? 'unknown version'} · candidate source ${parity(file.sameSource)}`);
300
+ }
301
+ for (const requirement of item.requirements) lines.push(` ${requirement}`);
302
+ }
303
+ for (const warning of report.warnings) lines.push(warning);
304
+ return lines.join('\n') + '\n';
305
+ }
@@ -11,6 +11,7 @@ import {SourceEditError, assertPrivateEditorStorage, sourceRevision} from './sou
11
11
  import {stateDir} from './naming.mjs';
12
12
  import {parseNativeRecord} from './native-record.mjs';
13
13
  import {recordSettings,recordSettingsPath,recordSettingsText,renderNewRecord,assertRecordTrackable} from './record-create.mjs';
14
+ import {isHandoffsMode,PLANNING_OFF} from './handoffs-scope.mjs';
14
15
 
15
16
  const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
16
17
  const fail=(code,message)=>{throw new SourceEditError(code,message);};
@@ -66,13 +67,17 @@ export function createAppDocuments({config,actor,library}) {
66
67
  const guards=()=>{const current=config.configPath?hash(readFileSync(config.configPath)):null;if(current!==initialConfig)fail('creation-review-stale','Configuration changed. Reopen the checkout before creating a document.');return {templates:readTemplateStore(config).revision,config:current};};
67
68
  async function options(){
68
69
  const catalog=appDocumentTemplates(config),roots=(config.docsRoots??[config.docsRoot]).map(root=>path.relative(config.repoRoot,root).split(path.sep).join('/'));
69
- const templates=catalog.templates.filter(t=>config.validTypes.has(t.type)).map(t=>{
70
+ // Where planning is off (a handoffs-only project) only prompts are
71
+ // created, and the record folders belong to plans.
72
+ const handoffs=isHandoffsMode(config);
73
+ const templates=catalog.templates.filter(t=>config.validTypes.has(t.type)&&(!handoffs||t.type==='prompt')).map(t=>{
70
74
  const target=t.type==='plan'?(config.docsRoots??[config.docsRoot]).find(r=>path.basename(r)==='plans'):null;
71
75
  const folder=target??(t.type==='plan'?path.join(catchAllRoot(config),'plans'):catchAllRoot(config));
72
76
  const statuses=[...(config.typeStatuses?.get(t.type)??config.validStatuses)].filter(s=>s!=='in-session'&&!config.lifecycle.archiveStatuses.has(s));
73
77
  return {...t,folder:path.relative(config.repoRoot,folder).split(path.sep).join('/'),statuses,defaultStatus:statuses.includes(t.defaultStatus)?t.defaultStatus:statuses[0]};
74
78
  });
75
79
  let records=null,recordError=null;
80
+ if(handoffs)return {...catalog,roots,templates,records,recordError:PLANNING_OFF};
76
81
  try{
77
82
  records=recordSettings(config,safe,destination);
78
83
  if(records.needsSetup){const existing=(await library?.all()??[]).flatMap(row=>row.recordRepositoryId?[row.recordRepositoryId]:[]);records=recordSettings(config,safe,destination,existing);}
@@ -83,6 +88,10 @@ export function createAppDocuments({config,actor,library}) {
83
88
  function publicReview(value){const parsed=value.native?parseNativeRecord(value.source):null;return {operationId:value.id,state:value.state,path:value.path,source:value.source,revision:value.revision,diagnostics:value.diagnostics,...(parsed?{record:parsed.record,setup:value.setup??null}:{})};}
84
89
  async function preview(context,request) {
85
90
  const who=actor(context);if(who.kind!=='human'||!who.id)fail('forbidden','Document creation requires a local human.');
91
+ if(isHandoffsMode(config)){
92
+ const known=appDocumentTemplates(config).templates.find(t=>t.name===request.template);
93
+ if(['flag','decision'].includes(request.template)||(known&&known.type!=='prompt'))fail('planning-off',PLANNING_OFF);
94
+ }
86
95
  const catalog=await options(),template=catalog.templates.find(t=>t.name===request.template);
87
96
  if(!template)fail('invalid-template',catalog.recordError??'Choose an available document or record template.');
88
97
  if(typeof request.title!=='string'||!request.title.trim()||request.title.length>200||/[\r\n\0]/.test(request.title))fail('invalid-request','Give the document a single-line title, up to 200 characters.');
@@ -123,6 +132,7 @@ export function createAppDocuments({config,actor,library}) {
123
132
  }
124
133
  function commit(context,{operationId}) {
125
134
  const prior=read(operationId);if(!prior)fail('operation-missing','Review the document before creating it.');own(context,prior);
135
+ if(isHandoffsMode(config)&&prior.state!=='committed'&&(prior.native||parseSimpleFrontmatter(extractFrontmatter(prior.source).frontmatter).type!=='prompt'))fail('planning-off',PLANNING_OFF);
126
136
  return withPathLocks([location(operationId),destination(prior.path),...(prior.native?[safe(recordSettingsPath(config))]:[])],{repoRoot:config.repoRoot},()=>{
127
137
  const value=read(operationId);own(context,value);const file=destination(value.path);
128
138
  if(value.state==='discarded')fail('creation-discarded','This review was discarded. Prepare a new document.');