runlist 0.0.0 → 0.94.1

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 (166) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1114 -6
  3. package/assets/app/app.css +283 -0
  4. package/assets/app/app.mjs +381 -0
  5. package/assets/app/block-editor.mjs +182 -0
  6. package/assets/app/block-model.mjs +52 -0
  7. package/assets/app/document-create.mjs +86 -0
  8. package/assets/app/document-lifecycle.mjs +36 -0
  9. package/assets/app/document-stages.mjs +22 -0
  10. package/assets/app/document-views.mjs +33 -0
  11. package/assets/app/document-yardstick.mjs +258 -0
  12. package/assets/app/editor-navigation.mjs +51 -0
  13. package/assets/app/filing-navigation.mjs +138 -0
  14. package/assets/app/git-changes.mjs +80 -0
  15. package/assets/app/git-commit.mjs +46 -0
  16. package/assets/app/index.html +70 -0
  17. package/assets/app/library-navigation.mjs +93 -0
  18. package/assets/app/outline.mjs +18 -0
  19. package/assets/app/quick-navigation.mjs +52 -0
  20. package/assets/app/record-navigation.mjs +78 -0
  21. package/assets/app/recovery-center.mjs +53 -0
  22. package/assets/app/semantic-search.mjs +14 -0
  23. package/assets/app/settings.mjs +15 -0
  24. package/assets/app/shared.mjs +104 -0
  25. package/assets/app/stage-source.mjs +75 -0
  26. package/assets/app/template-editor.mjs +47 -0
  27. package/assets/app/theme.mjs +25 -0
  28. package/assets/app/transport.mjs +43 -0
  29. package/assets/contracts/source-editing-v1.md +232 -0
  30. package/assets/opencode/plugin.js +162 -0
  31. package/assets/schemas/runlist-record-v1.schema.json +912 -0
  32. package/bin/dotmd.mjs +5 -0
  33. package/bin/runlist.mjs +2714 -0
  34. package/package.json +53 -6
  35. package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
  36. package/plugins/runlist-codex/bin/runlist-hook +86 -0
  37. package/plugins/runlist-codex/hooks/hooks.json +16 -0
  38. package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
  39. package/runlist.config.example.mjs +370 -0
  40. package/scripts/postinstall.mjs +56 -0
  41. package/src/agent-context.mjs +201 -0
  42. package/src/ai.mjs +30 -0
  43. package/src/app-create.mjs +169 -0
  44. package/src/app-filing.mjs +139 -0
  45. package/src/app-git-commits.mjs +48 -0
  46. package/src/app-git.mjs +180 -0
  47. package/src/app-library.mjs +152 -0
  48. package/src/app-lifecycle-worker.mjs +12 -0
  49. package/src/app-lifecycle.mjs +79 -0
  50. package/src/app-records.mjs +134 -0
  51. package/src/app-search.mjs +80 -0
  52. package/src/app-semantic-search.mjs +105 -0
  53. package/src/app-service.mjs +208 -0
  54. package/src/app-yardstick.mjs +269 -0
  55. package/src/app.mjs +102 -0
  56. package/src/atomic-mutation.mjs +1736 -0
  57. package/src/baton.mjs +435 -0
  58. package/src/body-link.mjs +48 -0
  59. package/src/bulk-tag.mjs +174 -0
  60. package/src/check-collapse.mjs +75 -0
  61. package/src/claude-commands.mjs +75 -0
  62. package/src/cli-editor.mjs +8 -0
  63. package/src/code-refs.mjs +545 -0
  64. package/src/codex-integration.mjs +81 -0
  65. package/src/color.mjs +14 -0
  66. package/src/commands.mjs +374 -0
  67. package/src/completions.mjs +93 -0
  68. package/src/config-edit.mjs +606 -0
  69. package/src/config.mjs +705 -0
  70. package/src/decision-action.mjs +58 -0
  71. package/src/decision.mjs +220 -0
  72. package/src/decisions.mjs +787 -0
  73. package/src/deps.mjs +317 -0
  74. package/src/desktop-install.mjs +158 -0
  75. package/src/desktop-runtime.mjs +4 -0
  76. package/src/desktop.mjs +25 -0
  77. package/src/diff.mjs +101 -0
  78. package/src/doctor.mjs +591 -0
  79. package/src/durable-rename.mjs +55 -0
  80. package/src/errors-read.mjs +44 -0
  81. package/src/export.mjs +496 -0
  82. package/src/extractors.mjs +101 -0
  83. package/src/filing.mjs +255 -0
  84. package/src/fix-membership.mjs +134 -0
  85. package/src/fix-refs.mjs +174 -0
  86. package/src/flags.mjs +318 -0
  87. package/src/frontmatter-fix.mjs +195 -0
  88. package/src/frontmatter.mjs +252 -0
  89. package/src/git-commit-filters.mjs +32 -0
  90. package/src/git-commit-protocol.mjs +198 -0
  91. package/src/git-commit-signing.mjs +75 -0
  92. package/src/git-commit-store.mjs +26 -0
  93. package/src/git-commit-support.mjs +16 -0
  94. package/src/git-commit-worker.mjs +32 -0
  95. package/src/git.mjs +909 -0
  96. package/src/glossary-check.mjs +35 -0
  97. package/src/glossary.mjs +243 -0
  98. package/src/graph.mjs +298 -0
  99. package/src/guard.mjs +488 -0
  100. package/src/health.mjs +212 -0
  101. package/src/hints.mjs +129 -0
  102. package/src/host-integration.mjs +339 -0
  103. package/src/hub-membership.mjs +284 -0
  104. package/src/hub.mjs +292 -0
  105. package/src/hud.mjs +436 -0
  106. package/src/index-file.mjs +163 -0
  107. package/src/index.mjs +478 -0
  108. package/src/init.mjs +512 -0
  109. package/src/install.mjs +222 -0
  110. package/src/journal-read.mjs +116 -0
  111. package/src/journal.mjs +472 -0
  112. package/src/lifecycle.mjs +1444 -0
  113. package/src/lint.mjs +386 -0
  114. package/src/managed-path.mjs +192 -0
  115. package/src/markdown-code-spans.mjs +58 -0
  116. package/src/migrate-prompts.mjs +171 -0
  117. package/src/migrate-template.mjs +170 -0
  118. package/src/migrate.mjs +90 -0
  119. package/src/misuse-read.mjs +61 -0
  120. package/src/model-request.mjs +33 -0
  121. package/src/model.mjs +523 -0
  122. package/src/modules.mjs +355 -0
  123. package/src/naming.mjs +55 -0
  124. package/src/native-action.mjs +59 -0
  125. package/src/native-record.mjs +244 -0
  126. package/src/new.mjs +1321 -0
  127. package/src/output-identity.mjs +111 -0
  128. package/src/parent-plan.mjs +58 -0
  129. package/src/parse-cache.mjs +99 -0
  130. package/src/path-containment.mjs +15 -0
  131. package/src/pickup-card.mjs +293 -0
  132. package/src/pickup.mjs +671 -0
  133. package/src/prompt.mjs +25 -0
  134. package/src/prompts.mjs +585 -0
  135. package/src/query.mjs +977 -0
  136. package/src/record-action.mjs +11 -0
  137. package/src/record-create.mjs +82 -0
  138. package/src/reference-planner.mjs +388 -0
  139. package/src/reference-values.mjs +11 -0
  140. package/src/rename.mjs +107 -0
  141. package/src/render.mjs +678 -0
  142. package/src/roadmap.mjs +184 -0
  143. package/src/runlist.mjs +944 -0
  144. package/src/section.mjs +225 -0
  145. package/src/semantic-search-client.mjs +58 -0
  146. package/src/ship.mjs +166 -0
  147. package/src/show.mjs +101 -0
  148. package/src/skill-drift.mjs +98 -0
  149. package/src/source-editor.mjs +491 -0
  150. package/src/stages.mjs +73 -0
  151. package/src/state-migration.mjs +123 -0
  152. package/src/stats.mjs +200 -0
  153. package/src/status-metadata.mjs +87 -0
  154. package/src/statuses.mjs +721 -0
  155. package/src/summary.mjs +74 -0
  156. package/src/surfaces.mjs +28 -0
  157. package/src/sync-status.mjs +316 -0
  158. package/src/template-store.mjs +62 -0
  159. package/src/update.mjs +338 -0
  160. package/src/use.mjs +58 -0
  161. package/src/util.mjs +337 -0
  162. package/src/validate.mjs +794 -0
  163. package/src/watch.mjs +56 -0
  164. package/src/xref.mjs +406 -0
  165. package/src/yardstick-command.mjs +178 -0
  166. package/src/yardstick.mjs +112 -0
@@ -0,0 +1,339 @@
1
+ // Installing runlist's integration into agent hosts other than Claude Code.
2
+ //
3
+ // Claude Code gets its integration through a real plugin (`plugins/runlist/`,
4
+ // installed by `claude plugin`). OpenCode has no equivalent registry, but it
5
+ // auto-discovers plugin files: it globs `{plugin,plugins}/*.{ts,js}` under the
6
+ // global config dir and under a project's `.opencode/`, with no config entry
7
+ // needed. So the integration ships as one generated file.
8
+ //
9
+ // It goes in the GLOBAL config dir, not per-repo. runlist retired per-repo
10
+ // generated scaffolding once already (`.claude/commands`, see
11
+ // claude-commands.mjs) because a file copied into every repo drifts from the
12
+ // CLI that wrote it. One global file covers every repo and is refreshed by
13
+ // `runlist update` in lockstep with the CLI.
14
+ //
15
+ // Writing outside the repo is why this is an explicit verb. `runlist doctor`
16
+ // reports that the integration is missing and names the command; it never
17
+ // installs. Passive commands touch nothing.
18
+
19
+ import { createHash } from 'node:crypto';
20
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { hostSessionSource } from './util.mjs';
25
+ import { readEnv, stateDir } from './naming.mjs';
26
+ import { LEGACY_PLUGIN_ID, planMarketplaceRepair } from './update.mjs';
27
+
28
+ // New files carry the current banner. Every reader accepts both: files written
29
+ // by dotmd-cli 0.78.0 and earlier carry the legacy banner, and those must stay
30
+ // recognizable as generated forever, or an old install turns into a "foreign"
31
+ // file that install/update/remove refuse to touch.
32
+ export const GENERATED_MARKER = 'runlist-generated:';
33
+ export const LEGACY_GENERATED_MARKER = 'dotmd-generated:';
34
+ const GENERATED_MARKERS = [GENERATED_MARKER, LEGACY_GENERATED_MARKER];
35
+ const PLUGIN_FILENAME = 'runlist.js';
36
+ const LEGACY_PLUGIN_FILENAME = 'dotmd.js';
37
+ const ASSET = path.resolve(fileURLToPath(import.meta.url), '..', '..', 'assets', 'opencode', 'plugin.js');
38
+
39
+ // OpenCode's own resolution order for its global config directory. Verified
40
+ // against the shipped binary: `OPENCODE_CONFIG_DIR` is an explicit override it
41
+ // consults directly, and the default is the XDG location.
42
+ export function opencodeConfigDir(env = process.env, homedir = os.homedir()) {
43
+ const explicit = env.OPENCODE_CONFIG_DIR?.trim();
44
+ if (explicit) return path.resolve(explicit);
45
+ const xdg = env.XDG_CONFIG_HOME?.trim();
46
+ return xdg ? path.join(path.resolve(xdg), 'opencode') : path.join(homedir, '.config', 'opencode');
47
+ }
48
+
49
+ // Both spellings are globbed, so an existing one is adopted rather than
50
+ // creating a second plugin directory beside the user's own plugins.
51
+ export function opencodePluginDir(configDir) {
52
+ for (const name of ['plugin', 'plugins']) {
53
+ const candidate = path.join(configDir, name);
54
+ if (existsSync(candidate)) return candidate;
55
+ }
56
+ return path.join(configDir, 'plugin');
57
+ }
58
+
59
+ export function opencodePluginPath(opts = {}) {
60
+ const { env = process.env, homedir = os.homedir(), dir } = opts;
61
+ if (dir) return path.join(path.resolve(dir), PLUGIN_FILENAME);
62
+ return path.join(opencodePluginDir(opencodeConfigDir(env, homedir)), PLUGIN_FILENAME);
63
+ }
64
+
65
+ function legacyOpencodePluginPath(opts = {}) {
66
+ const { env = process.env, homedir = os.homedir(), dir } = opts;
67
+ if (dir) return path.join(path.resolve(dir), LEGACY_PLUGIN_FILENAME);
68
+ return path.join(opencodePluginDir(opencodeConfigDir(env, homedir)), LEGACY_PLUGIN_FILENAME);
69
+ }
70
+
71
+ function banner(version) {
72
+ return [
73
+ `// ${GENERATED_MARKER}${version}`,
74
+ '// Generated by `runlist install opencode`. Refreshed by `runlist update`.',
75
+ '// Hand edits are overwritten — remove with `runlist install opencode --remove`.',
76
+ '',
77
+ ].join('\n');
78
+ }
79
+
80
+ export function renderOpencodePlugin(version) {
81
+ return banner(version) + readFileSync(ASSET, 'utf8');
82
+ }
83
+
84
+ // The banner a generated file carries — { marker, version } — or null when the
85
+ // file is absent or carries neither banner. A file without a banner is the
86
+ // user's own and is never written or removed — same rule the retired
87
+ // slash-command scaffolding followed. The earliest banner in the file wins.
88
+ export function readGeneratedBanner(filePath) {
89
+ let contents;
90
+ try { contents = readFileSync(filePath, 'utf8'); } catch { return null; }
91
+ let found = null;
92
+ for (const marker of GENERATED_MARKERS) {
93
+ const at = contents.indexOf(marker);
94
+ if (at >= 0 && (found === null || at < found.at)) found = { marker, at };
95
+ }
96
+ if (!found) return null;
97
+ const rest = contents.slice(found.at + found.marker.length);
98
+ return { marker: found.marker, version: rest.split('\n', 1)[0].trim() || null };
99
+ }
100
+
101
+ export function installedVersion(filePath) {
102
+ return readGeneratedBanner(filePath)?.version ?? null;
103
+ }
104
+
105
+ export function opencodeStatus(opts = {}) {
106
+ const { version, ...rest } = opts;
107
+ const filePath = opencodePluginPath(rest);
108
+ const legacyPath = legacyOpencodePluginPath(rest);
109
+ const currentExists = existsSync(filePath);
110
+ const oldExists = existsSync(legacyPath);
111
+ const generated = currentExists ? readGeneratedBanner(filePath) : null;
112
+ const oldGenerated = oldExists ? readGeneratedBanner(legacyPath) : null;
113
+ const installed = generated?.version ?? oldGenerated?.version ?? null;
114
+ const legacyGenerated = Boolean(oldGenerated);
115
+ const legacyBanner = generated?.marker === LEGACY_GENERATED_MARKER || oldGenerated?.marker === LEGACY_GENERATED_MARKER;
116
+ return {
117
+ path: filePath,
118
+ legacyPath,
119
+ exists: currentExists || oldExists,
120
+ version: installed,
121
+ // Generated legacy files migrate to runlist.js. Unmarked files remain user-owned.
122
+ foreign: (currentExists && !generated) || (!currentExists && oldExists && !oldGenerated),
123
+ foreignPath: currentExists && !generated ? filePath : oldExists && !oldGenerated ? legacyPath : null,
124
+ stale: installed !== null && ((version !== undefined && installed !== version) || legacyBanner || legacyGenerated),
125
+ legacyBanner,
126
+ legacyGenerated,
127
+ };
128
+ }
129
+
130
+ // Is OpenCode plausibly in use on this machine? Used only to decide whether to
131
+ // mention the integration — never to install anything.
132
+ export function opencodeDetected(opts = {}) {
133
+ const { env = process.env, homedir = os.homedir() } = opts;
134
+ if (env.OPENCODE || env.OPENCODE_PID) return true;
135
+ const configDir = opencodeConfigDir(env, homedir);
136
+ if (!existsSync(configDir)) return false;
137
+ try { return readdirSync(configDir).length > 0; } catch { return false; }
138
+ }
139
+
140
+ export function installOpencodePlugin(opts = {}) {
141
+ const { version, dryRun = false, force = false, ...rest } = opts;
142
+ const status = opencodeStatus({ version, ...rest });
143
+ if (status.foreign && !force) {
144
+ return { ...status, action: 'refused', reason: 'an OpenCode plugin file without a generated banner is already there — it was not written by runlist' };
145
+ }
146
+ if (status.exists && !status.stale && !status.foreign) {
147
+ return { ...status, action: 'current' };
148
+ }
149
+ const action = status.exists ? 'updated' : 'installed';
150
+ if (!dryRun) {
151
+ mkdirSync(path.dirname(status.path), { recursive: true });
152
+ writeFileSync(status.path, renderOpencodePlugin(version), 'utf8');
153
+ if (status.legacyGenerated) rmSync(status.legacyPath);
154
+ }
155
+ return { ...status, action, version };
156
+ }
157
+
158
+ // What identity does runlist actually see here, and can it tell this session
159
+ // apart from its siblings? This report exists because the OpenCode breakage was
160
+ // invisible until a verb failed: runlist looked for variables OpenCode never set,
161
+ // and nothing surfaced that until `use` refused to run. Anyone on any host can
162
+ // now check what runlist resolved instead of discovering it through a failure.
163
+ export function describeSessionIdentity(opts = {}) {
164
+ const { env = process.env, homedir = os.homedir(), version } = opts;
165
+ const source = hostSessionSource(env);
166
+ const opencode = opencodeStatus({ env, homedir, version });
167
+ const underOpencode = Boolean(env.OPENCODE || env.OPENCODE_PID);
168
+
169
+ if (!source) {
170
+ return {
171
+ id: null, scope: 'none', source: null, host: null,
172
+ summary: 'no session identity — `use`, `set`, `baton` and `archive` fail closed',
173
+ advice: underOpencode
174
+ ? ['runlist install opencode', 'or set RUNLIST_SESSION_ID for this shell']
175
+ : ['set RUNLIST_SESSION_ID for this shell or host session', 'known hosts: runlist install'],
176
+ };
177
+ }
178
+
179
+ const advice = [];
180
+ // The distinction that matters: a process- or terminal-scoped id is shared by
181
+ // every session under it, so those sessions can release each other's plans.
182
+ if (source.scope === 'process' && source.host === 'OpenCode') {
183
+ advice.push(opencode.exists
184
+ // The plugin is there but this shell was spawned before it loaded, so it
185
+ // still carries the old identity — say so rather than leave a warning
186
+ // marker with no explanation.
187
+ ? 'restart OpenCode — this session predates the installed integration'
188
+ : 'runlist install opencode — for a per-session identity');
189
+ } else if (source.scope === 'terminal') {
190
+ advice.push('every agent session in this terminal shares this id — set RUNLIST_SESSION_ID per session');
191
+ }
192
+ return {
193
+ id: source.id,
194
+ scope: source.scope,
195
+ source: source.variable,
196
+ host: source.host,
197
+ summary: source.scope === 'session'
198
+ ? `per-session identity from ${source.variable}`
199
+ : `${source.scope}-scoped identity from ${source.variable} — sessions sharing it can release each other's plans`,
200
+ advice,
201
+ };
202
+ }
203
+
204
+ // --- Telling the session it is running degraded -----------------------------
205
+ //
206
+ // Every other surface here has to be sought out: `doctor`, `install`, the npm
207
+ // postinstall line. But the degraded mode is SILENT — with the OPENCODE_PID
208
+ // fallback in place, `use`/`set`/`baton` all work, they just share one identity
209
+ // across every session in the OpenCode process. Nobody goes looking for a
210
+ // diagnostic about a thing that appears to work, so this comes to them.
211
+ //
212
+ // Warning, not error. Failing closed would undo the fallback that unblocked
213
+ // OpenCode in the first place, and punish a setup that is working.
214
+ //
215
+ // Once per session per repo, via a marker under the gitignored .runlist/. Silent
216
+ // under RUNLIST_NO_HINTS=1, the switch the repeat-failure hints already use.
217
+ const NOTICE_DIR = 'notices';
218
+
219
+ function noticeMarkerPath(repoRoot, key) {
220
+ const digest = createHash('sha256').update(key).digest('hex').slice(0, 32);
221
+ return path.join(stateDir(repoRoot), NOTICE_DIR, `${digest}`);
222
+ }
223
+
224
+ // Returns the notice text the first time it applies in a session, then null.
225
+ // `record: false` answers without consuming the once-per-session budget.
226
+ export function degradedIdentityNotice(repoRoot, opts = {}) {
227
+ const { env = process.env, homedir = os.homedir(), version, record = true } = opts;
228
+ if (readEnv('NO_HINTS', env) === '1') return null;
229
+ // Cheap env check first: a user on any other host pays nothing for this.
230
+ if (!env.OPENCODE && !env.OPENCODE_PID) return null;
231
+
232
+ const identity = describeSessionIdentity({ env, homedir, version });
233
+ if (identity.scope === 'session') return null;
234
+
235
+ const installed = opencodeStatus({ env, homedir, version }).exists;
236
+ const lines = installed
237
+ ? ['[runlist] This OpenCode session started before the runlist integration loaded, so its plan',
238
+ ' ownership is still process-scoped. Restart OpenCode to pick it up.']
239
+ : ['[runlist] OpenCode detected, runlist integration not installed — plan ownership is scoped to',
240
+ ' the OpenCode process, so another session in it can release plans this one claimed,',
241
+ ' and no session-start briefing runs. Fix once: runlist install opencode'];
242
+
243
+ if (!record) return lines.join('\n');
244
+ if (!repoRoot) return lines.join('\n');
245
+ const marker = noticeMarkerPath(repoRoot, `opencode:${identity.id}:${installed ? 'restart' : 'install'}`);
246
+ if (existsSync(marker)) return null;
247
+ try {
248
+ mkdirSync(path.dirname(marker), { recursive: true });
249
+ writeFileSync(marker, '', 'utf8');
250
+ } catch {
251
+ // Unwritable .runlist: say it once anyway rather than stay silent about a
252
+ // real ownership weakness. Worst case it repeats.
253
+ }
254
+ return lines.join('\n');
255
+ }
256
+
257
+ // --- Claude Code -----------------------------------------------------------
258
+ //
259
+ // Claude Code has a real plugin registry, so runlist drives `claude plugin`
260
+ // rather than writing files. What it lacked was any CLI path to the FIRST
261
+ // install: `runlist update` deliberately skips the plugin step when nothing is
262
+ // installed ("runlist plugin not installed"), and the README's two slash commands
263
+ // only work from inside a session. So a user who installed the CLI from npm had
264
+ // no way to discover, from the CLI, that the plugin exists.
265
+
266
+ export const CLAUDE_MARKETPLACE = 'reowens/runlist';
267
+ export const CLAUDE_PLUGIN_ID = 'runlist@runlist';
268
+
269
+ // Why `claude plugin marketplace add` can refuse a marketplace that is not
270
+ // even registered: settings.json may still DECLARE it (extraKnownMarketplaces),
271
+ // and Claude rejects an add whose source differs from that declaration in any
272
+ // fetch-shaping field. runlist never writes that declaration and will not edit
273
+ // it, so the most it can do is name the field.
274
+ export function claudeMarketplaceRefusalHint(marketplace = 'runlist') {
275
+ return [
276
+ `Claude refused to register marketplace "${marketplace}". If ~/.claude/settings.json declares it under`,
277
+ `extraKnownMarketplaces, its source must be exactly {"source":"github","repo":"${CLAUDE_MARKETPLACE}"} —`,
278
+ 'a stray "path", "ref" or header there makes the add fail and the installed plugin fail to load.',
279
+ ];
280
+ }
281
+
282
+ // Pure planner, mirroring planUpdate: the orchestration is unit-testable and
283
+ // the side effects stay in the caller.
284
+ export function planClaudeInstall({ installed, legacyInstalled, hasClaude, remove = false } = {}) {
285
+ if (remove) {
286
+ if (!installed) return [{ kind: 'skip', reason: 'runlist plugin is not installed' }];
287
+ const ids = installed.id === LEGACY_PLUGIN_ID || !legacyInstalled
288
+ ? [installed.id] : [installed.id, LEGACY_PLUGIN_ID];
289
+ return hasClaude
290
+ ? ids.map(id => ({ kind: 'run', cmd: ['claude', 'plugin', 'uninstall', id] }))
291
+ : [{ kind: 'manual', lines: ids.map(id => `/plugin uninstall ${id}`) }];
292
+ }
293
+ if (installed?.id === LEGACY_PLUGIN_ID) {
294
+ const lines = [`/plugin marketplace add ${CLAUDE_MARKETPLACE}`, `/plugin install ${CLAUDE_PLUGIN_ID}`, `/plugin uninstall ${LEGACY_PLUGIN_ID}`];
295
+ return hasClaude
296
+ ? [
297
+ { kind: 'run', cmd: ['claude', 'plugin', 'marketplace', 'add', CLAUDE_MARKETPLACE] },
298
+ { kind: 'run', cmd: ['claude', 'plugin', 'install', CLAUDE_PLUGIN_ID] },
299
+ { kind: 'run', cmd: ['claude', 'plugin', 'uninstall', LEGACY_PLUGIN_ID] },
300
+ ]
301
+ : [{ kind: 'manual', reason: 'Migrate the legacy Claude plugin', lines }];
302
+ }
303
+ if (installed?.id === CLAUDE_PLUGIN_ID && legacyInstalled) {
304
+ return hasClaude
305
+ ? [{ kind: 'run', cmd: ['claude', 'plugin', 'uninstall', LEGACY_PLUGIN_ID] }]
306
+ : [{ kind: 'manual', reason: 'Remove the legacy Claude plugin', lines: [`/plugin uninstall ${LEGACY_PLUGIN_ID}`] }];
307
+ }
308
+ // An install record whose marketplace registration is gone is not an
309
+ // installed plugin — Claude lists it as "failed to load". Skipping here with
310
+ // "already installed" left the user with no runlist verb that could repair it.
311
+ if (installed?.marketplaceRegistered === false) {
312
+ return planMarketplaceRepair(installed, { hasClaude, verb: 'update' })
313
+ .map(step => (step.kind === 'marketplace' || step.kind === 'plugin' ? { ...step, kind: 'run', step: step.kind } : step));
314
+ }
315
+ if (installed) return [{ kind: 'skip', reason: `runlist plugin already installed (${installed.version ?? 'unknown version'})` }];
316
+ // The marketplace has to be registered before the plugin resolves; adding one
317
+ // already present is a no-op, so this stays safe to re-run.
318
+ if (!hasClaude) {
319
+ return [{ kind: 'manual', lines: [`/plugin marketplace add ${CLAUDE_MARKETPLACE}`, `/plugin install ${CLAUDE_PLUGIN_ID}`] }];
320
+ }
321
+ return [
322
+ { kind: 'run', cmd: ['claude', 'plugin', 'marketplace', 'add', CLAUDE_MARKETPLACE] },
323
+ { kind: 'run', cmd: ['claude', 'plugin', 'install', CLAUDE_PLUGIN_ID] },
324
+ ];
325
+ }
326
+
327
+ export function removeOpencodePlugin(opts = {}) {
328
+ const { dryRun = false, force = false, ...rest } = opts;
329
+ const status = opencodeStatus(rest);
330
+ if (!status.exists) return { ...status, action: 'absent' };
331
+ if (status.foreign && !force) {
332
+ return { ...status, action: 'refused', reason: 'not a runlist-generated file' };
333
+ }
334
+ if (!dryRun) {
335
+ rmSync(status.path, { force: true });
336
+ if (status.legacyGenerated || force) rmSync(status.legacyPath, { force: true });
337
+ }
338
+ return { ...status, action: 'removed' };
339
+ }
@@ -0,0 +1,284 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import { asString, normalizeStringList, resolveRefPath, toRepoPath } from './util.mjs';
5
+ import { detectBodyRunlistRefs, isHubDoc } from './hub.mjs';
6
+ import { resolveBodyLinkTarget } from './body-link.mjs';
7
+
8
+ // Membership drift: a hub's list of children and the plans that claim it via
9
+ // `parent_plan:` are two halves of one relationship, and either half can go
10
+ // stale on its own. runlist already checks one direction — `checkRunlistBackPointers`
11
+ // warns when a `runlist:` child lacks the back-ref. These are the two arrows it
12
+ // doesn't cover.
13
+ //
14
+ // Membership only. The row is NEVER generated — same constraint as the status
15
+ // guard: the prose beside a row is why the row exists.
16
+ //
17
+ // ── What counts as a membership claim (measured, not assumed) ───────────────
18
+ //
19
+ // A body table row is NOT one. runlist's own estate has an aggregator hub whose
20
+ // ranked queue draws plans from three other programs, and whose other tables row
21
+ // children purely to say "related" — the same pointer-row shape the status guard
22
+ // already declines to judge. Treating every rowed link as membership would flood
23
+ // correct hubs.
24
+ //
25
+ // What IS a claim: `runlist:` (frontmatter order) and the hub's BODY ORDER
26
+ // (`## Ranked queue` / `## Order of operations`) — the list `runlist runlist next`
27
+ // actually walks. A plan there is one this hub would hand a session.
28
+ //
29
+ // ── Why "points at a different hub" is deliberately silent ──────────────────
30
+ //
31
+ // The scaffolded plan wanted a warning when a rowed child "moved to another
32
+ // hub". Measured against a real estate, sharing is legitimate and common: an
33
+ // aggregator hub ranks plans whose `parent_plan:` names the program hub that
34
+ // owns them, and demanding exclusivity would fire on every one of those rows
35
+ // with no fix that doesn't break the other hub's claim. So the guard fires only
36
+ // on the unambiguous half — a ranked plan claiming NO parent at all.
37
+
38
+ const ORPHAN_KIND = 'hub-membership-orphan';
39
+ const BACKREF_KIND = 'hub-membership-backref';
40
+
41
+ function quietDoc(status, type, config) {
42
+ return (config.lifecycle?.isTerminal?.(status, type)
43
+ ?? config.lifecycle?.terminalStatuses?.has(status))
44
+ || config.lifecycle?.skipsWarnings(status, type);
45
+ }
46
+
47
+ function configuredRunlist(parsed, config) {
48
+ const fields = [
49
+ ...(config.referenceFields?.bidirectional ?? []),
50
+ ...(config.referenceFields?.unidirectional ?? []),
51
+ ];
52
+ if (!fields.includes('runlist')) return { paths: [], directions: [] };
53
+ const paths = [];
54
+ const directions = [];
55
+ for (const entry of normalizeStringList(parsed.runlist)) {
56
+ const oneWay = entry.match(/^>\s*(.+)$/);
57
+ paths.push(oneWay ? oneWay[1].trim() : entry);
58
+ directions.push(oneWay ? 'one-way' : 'two-way');
59
+ }
60
+ return { paths, directions };
61
+ }
62
+
63
+ // One shared definition of mechanically repairable membership evidence. It
64
+ // reads the exact hub/child bytes it returns so callers can bind those snapshots
65
+ // into an atomic mutation: frontmatter `runlist:` (except `>` one-way entries)
66
+ // plus the body order read by `runlist runlist next`. Ordinary body links remain
67
+ // pointers, and an existing parent — same or different — is never a candidate.
68
+ export function collectMembershipBackrefCandidates(docs, config, { hubPaths = null } = {}) {
69
+ const byPath = new Map(docs.map(doc => [doc.path, doc]));
70
+ const evidence = new Map();
71
+
72
+ const resolve = (ref, dir) => {
73
+ const abs = resolveRefPath(String(ref).replace(/#.*$/, ''), dir, config.repoRoot);
74
+ return abs ? byPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
75
+ };
76
+
77
+ for (const indexedHub of docs) {
78
+ if (hubPaths && !hubPaths.has(indexedHub.path)) continue;
79
+ // The index is the discovery snapshot. Re-read only docs it identified as
80
+ // hubs; current bytes below can still demote one safely, while a doc that
81
+ // became a hub after indexing is simply discovered on the next run.
82
+ if (!isHubDoc(indexedHub)) continue;
83
+ const hubAbs = path.join(config.repoRoot, indexedHub.path);
84
+ let hubRaw;
85
+ let hubFrontmatter;
86
+ let body;
87
+ try {
88
+ hubRaw = readFileSync(hubAbs, 'utf8');
89
+ ({ frontmatter: hubFrontmatter, body } = extractFrontmatter(hubRaw));
90
+ } catch { continue; }
91
+ const parsedHub = parseSimpleFrontmatter(hubFrontmatter);
92
+ const runlist = configuredRunlist(parsedHub, config);
93
+ const currentHub = {
94
+ ...indexedHub,
95
+ type: asString(parsedHub.type) ?? null,
96
+ status: asString(parsedHub.status) ?? null,
97
+ executionMode: asString(parsedHub.execution_mode) ?? null,
98
+ refFields: { ...indexedHub.refFields, runlist: runlist.paths },
99
+ refFieldDirections: { ...indexedHub.refFieldDirections, runlist: runlist.directions },
100
+ };
101
+ if (!isHubDoc(currentHub) || quietDoc(currentHub.status, currentHub.type, config)) continue;
102
+
103
+ const hubDir = path.dirname(hubAbs);
104
+ const runlistTargets = new Map();
105
+ for (let index = 0; index < runlist.paths.length; index++) {
106
+ const child = resolve(runlist.paths[index], hubDir);
107
+ if (!child || child.path === currentHub.path) continue;
108
+ runlistTargets.set(child.path, runlist.directions[index]);
109
+ if (runlist.directions[index] === 'one-way') continue;
110
+ addEvidence(child, 'frontmatter-runlist', runlist.paths[index]);
111
+ }
112
+ for (const ref of detectBodyRunlistRefs(body)) {
113
+ const child = resolve(ref, hubDir);
114
+ if (!child || child.path === currentHub.path) continue;
115
+ // A frontmatter entry owns this pair. A `>` entry is an explicit opt-out;
116
+ // a normal entry was already recorded above. Either way, body order must
117
+ // not create a second or contradictory authorization.
118
+ if (runlistTargets.has(child.path)) continue;
119
+ addEvidence(child, 'body-order', ref);
120
+ }
121
+
122
+ function addEvidence(indexedChild, source, ref) {
123
+ const childAbs = path.join(config.repoRoot, indexedChild.path);
124
+ let childRaw;
125
+ let childFrontmatter;
126
+ try {
127
+ childRaw = readFileSync(childAbs, 'utf8');
128
+ ({ frontmatter: childFrontmatter } = extractFrontmatter(childRaw));
129
+ } catch { return; }
130
+ const parsedChild = parseSimpleFrontmatter(childFrontmatter);
131
+ const childType = asString(parsedChild.type) ?? null;
132
+ const childStatus = asString(parsedChild.status) ?? null;
133
+ if (quietDoc(childStatus, childType, config)) return;
134
+ // Preserve the validator's brownfield behavior: an untyped legacy doc is
135
+ // still plan-like enough to diagnose. The writer applies the stricter
136
+ // type: plan boundary before it mutates anything.
137
+ if (childType && childType !== 'plan') return;
138
+ const childRunlist = configuredRunlist(parsedChild, config);
139
+ const currentChild = {
140
+ ...indexedChild,
141
+ type: childType,
142
+ status: childStatus,
143
+ executionMode: asString(parsedChild.execution_mode) ?? null,
144
+ refFields: { ...indexedChild.refFields, runlist: childRunlist.paths },
145
+ };
146
+ if (isHubDoc(currentChild)) return;
147
+ if (normalizeStringList(parsedChild.parent_plan).length > 0) return;
148
+
149
+ const key = `${indexedChild.path}\0${currentHub.path}`;
150
+ const existing = evidence.get(key);
151
+ if (existing) {
152
+ if (!existing.sources.includes(source)) existing.sources.push(source);
153
+ if (!existing.refs.includes(ref)) existing.refs.push(ref);
154
+ return;
155
+ }
156
+ evidence.set(key, {
157
+ child: indexedChild,
158
+ hub: currentHub,
159
+ childPath: indexedChild.path,
160
+ hubPath: currentHub.path,
161
+ childAbs,
162
+ hubAbs,
163
+ childRaw,
164
+ childType,
165
+ hubRaw,
166
+ sources: [source],
167
+ refs: [ref],
168
+ });
169
+ }
170
+ }
171
+
172
+ const byChild = new Map();
173
+ for (const item of evidence.values()) {
174
+ if (!byChild.has(item.childPath)) byChild.set(item.childPath, []);
175
+ byChild.get(item.childPath).push(item);
176
+ }
177
+ const candidates = [];
178
+ const ambiguous = [];
179
+ for (const [childPath, items] of byChild) {
180
+ if (items.length === 1) candidates.push(items[0]);
181
+ else ambiguous.push({ childPath, hubPaths: items.map(item => item.hubPath).sort(), evidence: items });
182
+ }
183
+ candidates.sort((a, b) => a.childPath.localeCompare(b.childPath));
184
+ ambiguous.sort((a, b) => a.childPath.localeCompare(b.childPath));
185
+ return { candidates, ambiguous, evidence: [...evidence.values()] };
186
+ }
187
+
188
+ export function checkHubMembershipDrift(docs, config) {
189
+ const warnings = [];
190
+ // Per-doc: warning suppression is type-scoped, so a status name quiet for one
191
+ // type stays loud for another that declared the same name.
192
+ const quiet = (d) => (config.lifecycle?.isTerminal?.(d.status, d.type)
193
+ ?? config.lifecycle?.terminalStatuses?.has(d.status))
194
+ || config.lifecycle?.skipsWarnings(d.status, d.type);
195
+ const byPath = new Map(docs.map(doc => [doc.path, doc]));
196
+ const refFields = [
197
+ ...(config.referenceFields?.bidirectional ?? []),
198
+ ...(config.referenceFields?.unidirectional ?? []),
199
+ ];
200
+
201
+ const dirOf = (doc) => path.dirname(path.join(config.repoRoot, doc.path));
202
+ const resolve = (ref, dir) => {
203
+ const abs = resolveRefPath(String(ref).replace(/#.*$/, ''), dir, config.repoRoot);
204
+ return abs ? byPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
205
+ };
206
+ const resolveBody = (link, dir) => {
207
+ if (link.targetKind && link.targetKind !== 'document') return null;
208
+ const result = resolveBodyLinkTarget(link.href, dir, config.repoRoot);
209
+ return result.ok ? byPath.get(toRepoPath(result.path, config.repoRoot)) ?? null : null;
210
+ };
211
+
212
+ // Everything a hub says about other docs: every configured reference field
213
+ // plus every body link. Config-driven rather than a hardcoded field list, so a
214
+ // repo that renames its reference fields keeps working.
215
+ const knownTo = (hub) => {
216
+ const dir = dirOf(hub);
217
+ const known = new Set();
218
+ for (const field of refFields) {
219
+ for (const ref of (hub.refFields?.[field] ?? [])) {
220
+ const target = resolve(ref, dir);
221
+ if (target) known.add(target.path);
222
+ }
223
+ }
224
+ for (const link of (hub.bodyLinks ?? [])) {
225
+ const target = resolveBody(link, dir);
226
+ if (target) known.add(target.path);
227
+ }
228
+ return known;
229
+ };
230
+
231
+ // ── Arrow 1: a claim only the child makes ────────────────────────────────
232
+ // The plan says `parent_plan: <hub>` and the hub references it NOWHERE — not
233
+ // in a reference field, not as a body link. One side of the relationship
234
+ // silently dropped the other, and every hub view (fold, rollup, next-pickup)
235
+ // is computed from the hub's side, so the child is invisible where it thinks
236
+ // it lives. Warns on the HUB: the hub's list is the half that lost the entry.
237
+ const knownCache = new Map();
238
+ for (const child of docs) {
239
+ if (quiet(child)) continue;
240
+ const parents = child.refFields?.parent_plan ?? [];
241
+ if (parents.length === 0) continue;
242
+ const dir = dirOf(child);
243
+ for (const ref of parents) {
244
+ const hub = resolve(ref, dir);
245
+ // Only hubs are asked to carry a membership list. A plain plan named as a
246
+ // parent rows nothing, and demanding a link back there would be a new
247
+ // opinion rather than a drift check.
248
+ if (!hub || hub.path === child.path || !isHubDoc(hub)) continue;
249
+ if (quiet(hub)) continue;
250
+ if (!knownCache.has(hub.path)) knownCache.set(hub.path, knownTo(hub));
251
+ if (knownCache.get(hub.path).has(child.path)) continue;
252
+ warnings.push({
253
+ path: hub.path,
254
+ level: 'warning',
255
+ message: `\`${child.path}\` claims \`parent_plan: ${ref}\` but this hub references it nowhere — no reference field, no body link. Add it to the hub's list, or fix the child's \`parent_plan:\`. A membership only one side records is invisible to every hub view (fold, rollup, next-pickup), which all read the hub's half.`,
256
+ meta: { kind: ORPHAN_KIND, child: child.path, ref },
257
+ });
258
+ }
259
+ }
260
+
261
+ // ── Arrow 2: a claim only the hub makes ──────────────────────────────────
262
+ // The hub's BODY ORDER ranks a plan — the list `runlist runlist next <hub>`
263
+ // walks, so this hub would hand a session that plan — and the plan carries no
264
+ // `parent_plan:` at all. This is the same finding `checkRunlistBackPointers`
265
+ // makes for frontmatter `runlist:` children, extended to the body-order hubs
266
+ // it can't see; children already covered there are skipped, so one missing
267
+ // back-ref is never reported twice. Warns on the CHILD, matching that check:
268
+ // it's the file that needs the edit.
269
+ const { evidence } = collectMembershipBackrefCandidates(docs, config);
270
+ for (const item of evidence) {
271
+ if (!item.sources.includes('body-order')) continue;
272
+ const { child, hub } = item;
273
+ warnings.push({
274
+ path: child.path,
275
+ level: 'warning',
276
+ message: `is ranked in the body order of \`${hub.path}\` (used by \`runlist runlist next\`) but has no \`parent_plan:\`. Add \`parent_plan: ${hub.path}\` so reverse-link tooling (pickup-card Related:, graph) stays consistent.`,
277
+ meta: { kind: BACKREF_KIND, hub: hub.path, source: 'body-order' },
278
+ });
279
+ }
280
+
281
+ return warnings;
282
+ }
283
+
284
+ export const HUB_MEMBERSHIP_KINDS = Object.freeze({ orphan: ORPHAN_KIND, backref: BACKREF_KIND });