runlist 0.95.2 → 0.95.5

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.
package/README.md CHANGED
@@ -47,8 +47,8 @@ controls are experimental; live retrieval depends on a compatible external
47
47
  provider. Local chat and push are deferred.
48
48
 
49
49
  <!-- runlist:release:start -->
50
- CLI and desktop release version: **0.95.2**.
51
- [Mac installer and release notes](https://github.com/reowens/runlist/releases/tag/desktop-v0.95.2).
50
+ CLI and desktop release version: **0.95.5**.
51
+ [Mac installer and release notes](https://github.com/reowens/runlist/releases/tag/desktop-v0.95.5).
52
52
  Normal product releases publish both components; an installed Mac app is updated with rollback after it has quit.
53
53
  <!-- runlist:release:end -->
54
54
 
@@ -219,7 +219,14 @@ and managed scopes are reported rather than silently updating another scope.
219
219
  The release workflow uses the same scope selection and verifies every entry
220
220
  against the released version.
221
221
 
222
- Restart Claude Code, or run `/reload-plugins`, after a plugin update.
222
+ Updates also refresh already installed OpenCode and Codex integrations. After
223
+ upgrading the CLI, host installers run from the updated package so their version
224
+ and assets match. Codex verification checks the host's installed copy separately
225
+ from its marketplace source. Releases use these same checks and remain resumable
226
+ if an installed host fails to update. Uninstalled hosts are not added.
227
+
228
+ Restart the affected host sessions to load updated integrations; Claude Code also
229
+ supports `/reload-plugins`.
223
230
 
224
231
  ## Quick Start
225
232
 
package/bin/runlist.mjs CHANGED
@@ -293,7 +293,7 @@ Validate & Fix:
293
293
  doctor --transactions Report/clear wedged mutation transactions (run this if mutations refuse repo-wide)
294
294
  doctor --claims Report/release plan claims held by sessions that are gone ("busy in another session")
295
295
  self-check Project/version skew diagnostic (alias: doctor --project)
296
- lint [--fix] Check and auto-fix frontmatter issues
296
+ lint [files...] [--fix] Check and auto-fix frontmatter issues
297
297
  fix-refs [--dry-run] Auto-fix broken reference paths + body links
298
298
  refs <old> <new> [--fix] Report (or rewrite) code-root citations of a moved document
299
299
  refs repair [--fix] Same, for every archived document still cited at its old path
@@ -923,7 +923,7 @@ Modes:
923
923
  history; a "Migrated to v0.21 template" entry
924
924
  in their Version History would be misleading).
925
925
  --migrate-template --json Machine-readable result.
926
- --frontmatter-fix Auto-fix the long-frontmatter warnings that
926
+ --frontmatter-fix [path] Repair only that document when supplied; otherwise auto-fix the long-frontmatter warnings that
927
927
  \`runlist check\` flags: \`current_state\` >1500 chars
928
928
  or \`next_step\` >800 chars. Truncates the
929
929
  frontmatter field at the nearest sentence
@@ -1195,9 +1195,9 @@ Options:
1195
1195
  --summarize Generate AI summary using local model
1196
1196
  --model <name> Model to use (default: the one \`runlist model\` shows)`,
1197
1197
 
1198
- lint: `runlist lint [--fix] — check and auto-fix frontmatter issues
1198
+ lint: `runlist lint [files...] [--fix] — check and auto-fix frontmatter issues
1199
1199
 
1200
- Scans all docs for fixable problems:
1200
+ Scans the selected documents, or all docs when no file is supplied, for fixable problems:
1201
1201
  - Missing status (inferred via local AI model when available)
1202
1202
  - Missing updated date (set to today)
1203
1203
  - Status casing (e.g. Active → active)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runlist",
3
- "version": "0.95.2",
3
+ "version": "0.95.5",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runlist-codex",
3
- "version": "0.95.2",
3
+ "version": "0.95.5",
4
4
  "description": "Runlist workflow skill, session orientation, and tool guard for Codex",
5
5
  "author": {
6
6
  "name": "Reo Owens"
@@ -4,6 +4,8 @@ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, r
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { executableName } from './util.mjs';
7
9
 
8
10
  const SOURCE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'plugins', 'runlist-codex');
9
11
  const NAME = 'runlist-codex';
@@ -33,6 +35,26 @@ export function codexStatus({ version, homedir = os.homedir() } = {}) {
33
35
  return { ...paths, exists, foreign: exists && !installed, version: installed, stale: Boolean(installed && version && installed !== version) };
34
36
  }
35
37
 
38
+ // The marketplace source is not the plugin Codex loads. Ask the host about its
39
+ // installed copy rather than treating our generated source marker as proof.
40
+ export function installedCodexStatus({ homedir = os.homedir(), run = spawnSync } = {}) {
41
+ try {
42
+ const marketplace = marketplaceAt(codexPaths(homedir).marketplace).name;
43
+ const pluginId = `${NAME}@${marketplace}`;
44
+ const result = run(executableName('codex'), ['plugin', 'list', '--marketplace', marketplace, '--json'], {
45
+ encoding: 'utf8', shell: process.platform === 'win32', timeout: 30_000,
46
+ });
47
+ if (result.status !== 0) return { ok: false, reason: result.error?.message ?? `codex plugin list exited ${result.status ?? '?'}` };
48
+ const installed = JSON.parse(result.stdout).installed;
49
+ if (!Array.isArray(installed)) return { ok: false, reason: 'codex plugin list returned no installed plugin records' };
50
+ const plugin = installed.find(entry => entry.pluginId === pluginId && entry.installed === true);
51
+ return plugin?.version ? { ok: true, version: plugin.version, pluginId }
52
+ : { ok: false, reason: `${pluginId} is not installed in Codex` };
53
+ } catch (err) {
54
+ return { ok: false, reason: `cannot verify installed Codex plugin: ${err.message}` };
55
+ }
56
+ }
57
+
36
58
  export function installCodexPlugin({ version, homedir = os.homedir(), dryRun = false } = {}) {
37
59
  const sourceVersion = JSON.parse(readFileSync(path.join(SOURCE, '.codex-plugin', 'plugin.json'), 'utf8')).version;
38
60
  if (sourceVersion !== version) throw new Error(`Codex plugin source is ${sourceVersion}, CLI is ${version}`);
package/src/commands.mjs CHANGED
@@ -170,7 +170,7 @@ const definitions = [
170
170
  options: [value('--status'), value('--title'), value('--runlist'), flag('--coordination'), flag('--roadmap'), flag('--lite', '--minimal'), flag('--audit', '--findings'), value('--body', '--message'), value('--root'), flag('--show-files'), flag('--list-templates', '--list-types'), flag('--render-preview'), value('--question'), value('--answers'), value('--disposition')],
171
171
  dashPositionalsAfter: 1,
172
172
  })]),
173
- command('lint', mutates('managed source sweep with --fix; otherwise read-only'), 'mutate', [form('', { options: [flag('--fix')] })]),
173
+ command('lint', mutates('selected managed sources with --fix; otherwise read-only'), 'mutate', [form('[files...]', { args: positionals(0, Infinity), options: [flag('--fix')] })]),
174
174
  command('rename', mutates('managed source, same-root destination, and rewrite sweep'), 'mutate', [form('<old> [new]', { args: positionals(1, 2), options: [flag('--show-files'), flag('--fix-refs'), flag('--strings')] })]),
175
175
  command('migrate', mutates('managed source sweep'), 'mutate', [form('<field> <old> <new> [files...]', { args: positionals(3, Infinity), options: [flag('--show-files')] })]),
176
176
  command('fix-refs', mutates('managed source sweep'), 'mutate', [form('', { options: [flag('--show-files')] })]),
package/src/doctor.mjs CHANGED
@@ -252,7 +252,7 @@ export function runDoctor(argv, config, opts = {}) {
252
252
  return;
253
253
  }
254
254
  if (argv.includes('--frontmatter-fix')) {
255
- runFrontmatterFix(config, opts);
255
+ runFrontmatterFix(config, { ...opts, files: argv.filter(arg => !arg.startsWith('-')) });
256
256
  return;
257
257
  }
258
258
  if (argv.includes('--transactions')) {
@@ -0,0 +1,54 @@
1
+ // Condense cycles before propagating outer categories through local hub indexes.
2
+ export function resolveHubCategories(hubs, rows) {
3
+ const next = new Map(hubs.map(hub => [hub, new Set()]));
4
+ const prev = new Map(hubs.map(hub => [hub, new Set()]));
5
+ for (const child of hubs) for (const row of rows.get(child) ?? []) {
6
+ if (!next.has(row.hub)) continue;
7
+ next.get(row.hub).add(child);
8
+ prev.get(child).add(row.hub);
9
+ }
10
+ const seen = new Set(), order = [];
11
+ for (const hub of hubs) {
12
+ if (seen.has(hub)) continue;
13
+ seen.add(hub);
14
+ const stack = [[hub, next.get(hub).values()]];
15
+ while (stack.length) {
16
+ const [node, children] = stack.at(-1), child = children.next();
17
+ if (child.done) { order.push(node); stack.pop(); }
18
+ else if (!seen.has(child.value)) {
19
+ seen.add(child.value);
20
+ stack.push([child.value, next.get(child.value).values()]);
21
+ }
22
+ }
23
+ }
24
+ const component = new Map(), groups = [];
25
+ for (const hub of order.reverse()) {
26
+ if (component.has(hub)) continue;
27
+ const id = groups.length, stack = [hub];
28
+ groups.push({ categories: new Set(), fallback: new Set(), children: new Map(), incoming: 0 });
29
+ component.set(hub, id);
30
+ while (stack.length) for (const parent of prev.get(stack.pop())) {
31
+ if (!component.has(parent)) { component.set(parent, id); stack.push(parent); }
32
+ }
33
+ }
34
+ for (const child of hubs) for (const row of rows.get(child) ?? []) {
35
+ if (!component.has(row.hub)) continue;
36
+ const from = component.get(row.hub), to = component.get(child);
37
+ if (from === to) { if (row.category) groups[to].fallback.add(row.category); continue; }
38
+ if (!groups[from].children.has(to)) {
39
+ groups[from].children.set(to, new Set());
40
+ groups[to].incoming++;
41
+ }
42
+ if (row.category) groups[from].children.get(to).add(row.category);
43
+ }
44
+ const queue = groups.flatMap((group, id) => group.incoming === 0 ? [id] : []);
45
+ for (let i = 0; i < queue.length; i++) {
46
+ const group = groups[queue[i]];
47
+ if (!group.categories.size) for (const label of group.fallback) group.categories.add(label);
48
+ for (const [child, labels] of group.children) {
49
+ for (const label of group.categories.size ? group.categories : labels) groups[child].categories.add(label);
50
+ if (--groups[child].incoming === 0) queue.push(child);
51
+ }
52
+ }
53
+ return new Map(hubs.map(hub => [hub, groups[component.get(hub)].categories]));
54
+ }
package/src/filing.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { resolveHubCategories } from './filing-categories.mjs';
1
2
  import { readFileSync } from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { extractFrontmatter } from './frontmatter.mjs';
@@ -167,26 +168,8 @@ export function assembleFilingCoverage(docs, config, preparedRows, parentTargets
167
168
 
168
169
  // Explicit first-cell hub index rows pass categories to the hub's plan rows,
169
170
  // including indexes without status columns. Hub titles are not guessed.
170
- const hubCategories = new Map();
171
171
  const liveHubs = docs.filter(doc => isHubDoc(doc) && !closed(doc, config));
172
- for (const hub of liveHubs) hubCategories.set(hub.path, new Set());
173
- const dependents = new Map();
174
- const queue = [];
175
- const addCategory = (hub, category) => {
176
- const categories = hubCategories.get(hub);
177
- if (categories && !categories.has(category)) { categories.add(category); queue.push([hub, category]); }
178
- };
179
- for (const hub of liveHubs) for (const row of categoryRows.get(hub.path) ?? []) {
180
- if (row.category) addCategory(hub.path, row.category);
181
- else {
182
- if (!dependents.has(row.hub)) dependents.set(row.hub, new Set());
183
- dependents.get(row.hub).add(hub.path);
184
- }
185
- }
186
- for (let i = 0; i < queue.length; i++) {
187
- const [hub, category] = queue[i];
188
- for (const dependent of dependents.get(hub) ?? []) addCategory(dependent, category);
189
- }
172
+ const hubCategories = resolveHubCategories(liveHubs.map(hub => hub.path), categoryRows);
190
173
  for (const rows of homes.values()) {
191
174
  // Metadata reads can finish in any order. Keep CLI/GUI evidence and home
192
175
  // pagination stable by source path and physical line, retaining duplicates.
@@ -1,9 +1,8 @@
1
- import { readFileSync, writeFileSync } from 'node:fs';
1
+ import { readFileSync } from 'node:fs';
2
2
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
3
  import { asString, toRepoPath, escapeRegex, warn } from './util.mjs';
4
- import { collectDocFiles } from './index.mjs';
4
+ import { resolveRepairSources, publishRepairs } from './repair-sources.mjs';
5
5
  import { bold, green, dim } from './color.mjs';
6
- import { authorizeManagedSweep } from './managed-path.mjs';
7
6
 
8
7
  // Caps must stay in lockstep with the warnings emitted by validatePlanShape in
9
8
  // src/validate.mjs — that's where the user first sees these numbers. Targets
@@ -16,8 +15,8 @@ const FIELDS = [
16
15
 
17
16
  export function runFrontmatterFix(config, opts = {}) {
18
17
  const { dryRun, out = process.stdout } = opts;
19
- const allFiles = collectDocFiles(config);
20
- authorizeManagedSweep(allFiles, config, { kind: 'Frontmatter fix source' });
18
+ const allFiles = resolveRepairSources(opts.files ?? [], config);
19
+ const repairs = [];
21
20
  const results = [];
22
21
 
23
22
  for (const filePath of allFiles) {
@@ -52,13 +51,16 @@ export function runFrontmatterFix(config, opts = {}) {
52
51
  newBody = insertOrAppendSection(newBody, op.heading, op.tail);
53
52
  }
54
53
 
55
- if (!dryRun) {
56
- writeFileSync(filePath, `---\n${newFm}\n---\n${newBody}`, 'utf8');
57
- try { config.hooks.onLint?.({ path: toRepoPath(filePath, config.repoRoot), fixes: ops.map(o => ({ field: o.field, type: 'frontmatter-fix' })) }); } catch (err) { warn(`Hook 'onLint' threw: ${err.message}`); }
58
- }
54
+ repairs.push({ path: filePath, expectedContent: raw, content: `---\n${newFm}\n---\n${newBody}` });
59
55
  results.push({ filePath, repoPath: toRepoPath(filePath, config.repoRoot), ops });
60
56
  }
61
57
 
58
+ publishRepairs(repairs, config, opts);
59
+ if (!dryRun) for (const result of results) {
60
+ try { config.hooks.onLint?.({ path: result.repoPath, fixes: result.ops.map(op => ({ field: op.field, type: 'frontmatter-fix' })) }); }
61
+ catch (err) { warn(`Hook 'onLint' threw: ${err.message}`); }
62
+ }
63
+
62
64
  const prefix = dryRun ? dim('[dry-run] ') : '';
63
65
  const banner = dryRun ? dim(' [preview — run without --dry-run to write]') : '';
64
66
  out.write(bold('runlist doctor --frontmatter-fix') + banner + '\n\n');
package/src/install.mjs CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  } from './host-integration.mjs';
10
10
  import { LEGACY_PLUGIN_ID, readInstalledPlugin, readInstalledPluginRecords } from './update.mjs';
11
11
  import { executableName, which } from './util.mjs';
12
- import { codexStatus, installCodexPlugin } from './codex-integration.mjs';
12
+ import { codexStatus, installCodexPlugin, installedCodexStatus } from './codex-integration.mjs';
13
13
 
14
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
15
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
@@ -111,9 +111,12 @@ function installCodex(argv, dryRun, json) {
111
111
  stdio: json ? 'pipe' : 'inherit', encoding: json ? 'utf8' : undefined,
112
112
  shell: process.platform === 'win32',
113
113
  });
114
- if (json) process.stdout.write(JSON.stringify({ ...result, pluginAdd: added.status === 0 ? 'installed' : 'failed', pluginAddExit: added.status }, null, 2) + '\n');
115
- if (added.status !== 0) {
116
- process.stderr.write(`Codex plugin source is ready; finish installation with: codex plugin add ${selector}\n`);
114
+ const installed = added.status === 0 ? installedCodexStatus() : { ok: false, reason: added.error?.message ?? `codex plugin add exited ${added.status ?? '?'}` };
115
+ const verified = installed.ok && installed.version === pkg.version;
116
+ if (json) process.stdout.write(JSON.stringify({ ...result, pluginAdd: verified ? 'installed' : 'failed', pluginAddExit: added.status,
117
+ installedVersion: installed.version ?? null, ...(!verified ? { reason: installed.reason ?? `Codex has ${installed.version}, expected ${pkg.version}` } : {}) }, null, 2) + '\n');
118
+ if (!verified) {
119
+ process.stderr.write(`Codex plugin installation is unverified: ${installed.reason ?? `has ${installed.version}, expected ${pkg.version}`}. Retry: codex plugin add ${selector}\n`);
117
120
  process.exitCode = 1;
118
121
  } else if (!json) {
119
122
  process.stdout.write('Start a new Codex thread and review/trust the plugin hooks to enable the primer and guard.\n');
package/src/lint.mjs CHANGED
@@ -1,12 +1,13 @@
1
- import { readFileSync, writeFileSync } from 'node:fs';
1
+ import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
4
4
  import { asString, toRepoPath, escapeRegex, warn } from './util.mjs';
5
- import { buildIndex, collectDocFiles } from './index.mjs';
6
- import { updateFrontmatter } from './lifecycle.mjs';
5
+ import { buildIndex } from './index.mjs';
6
+ import { resolveRepairSources, publishRepairs } from './repair-sources.mjs';
7
+ import { renderLifecycleMutation } from './lifecycle.mjs';
7
8
  import { runModel } from './ai.mjs';
8
9
  import { bold, green, yellow, dim } from './color.mjs';
9
- import { authorizeManagedSweep, findLexicalDocsRoot } from './managed-path.mjs';
10
+ import { findLexicalDocsRoot } from './managed-path.mjs';
10
11
 
11
12
  const KEY_RENAMES = {
12
13
  nextStep: 'next_step',
@@ -69,7 +70,8 @@ function formatYamlScalar(value) {
69
70
  export function runLint(argv, config, opts = {}) {
70
71
  const { dryRun } = opts;
71
72
  const fix = argv.includes('--fix');
72
- const allFiles = collectDocFiles(config);
73
+ const allFiles = resolveRepairSources(argv.filter(arg => !arg.startsWith('-')), config);
74
+ const selectedPaths = new Set(allFiles.map(file => toRepoPath(file, config.repoRoot)));
73
75
  const fixable = [];
74
76
 
75
77
  for (const filePath of allFiles) {
@@ -164,7 +166,7 @@ export function runLint(argv, config, opts = {}) {
164
166
  }
165
167
 
166
168
  if (fixes.length > 0) {
167
- fixable.push({ filePath, repoPath, fixes });
169
+ fixable.push({ filePath, repoPath, fixes, source: raw });
168
170
  }
169
171
  }
170
172
 
@@ -187,6 +189,7 @@ export function runLint(argv, config, opts = {}) {
187
189
  }
188
190
  }
189
191
  nonFixable = [...index.errors, ...index.warnings].filter(issue => {
192
+ if (!selectedPaths.has(issue.path)) return false;
190
193
  if (issue.message.includes('Missing frontmatter `status`') && fixablePaths.has(issue.path)) return false;
191
194
  const dep = issue.message.match(/^`(module|surface):` \(singular\) is deprecated/);
192
195
  if (dep && fixableSingularKeys.has(`${issue.path}::${dep[1]}`)) return false;
@@ -235,11 +238,12 @@ export function runLint(argv, config, opts = {}) {
235
238
  }
236
239
 
237
240
  // Fix mode
238
- authorizeManagedSweep(fixable.map(item => item.filePath), config, { kind: 'Lint fix source' });
241
+ const repairs = [];
239
242
  const prefix = dryRun ? dim('[dry-run] ') : '';
240
243
  let totalFixes = 0;
241
244
 
242
- for (const { filePath, repoPath, fixes } of fixable) {
245
+ for (const { filePath, repoPath, fixes, source } of fixable) {
246
+ let raw = source;
243
247
  const updates = {};
244
248
  const keyRenames = [];
245
249
  let needsEofFix = false;
@@ -258,101 +262,94 @@ export function runLint(argv, config, opts = {}) {
258
262
  singularToPlural.push(f);
259
263
  } else if (f.type === 'drop-singular') {
260
264
  dropSingular.push(f);
261
- } else {
265
+ } else if (f.type !== 'infer-status') {
262
266
  updates[f.field] = f.newValue;
263
267
  }
264
268
  }
265
269
 
266
- if (!dryRun) {
267
- // Apply infer-status fixes via AI
268
- for (const f of fixes.filter(f => f.type === 'infer-status')) {
269
- const raw = readFileSync(filePath, 'utf8');
270
- const { frontmatter: fmInfer } = extractFrontmatter(raw);
271
- const parsedInfer = parseSimpleFrontmatter(fmInfer);
272
- const inferType = asString(parsedInfer.type);
273
- const inferTypeSet = inferType ? config.typeStatuses?.get(inferType) : null;
274
- const statusList = inferTypeSet ? [...inferTypeSet].join(', ') : config.statusOrder.join(', ');
275
- const { body } = extractFrontmatter(raw);
276
- const prompt = `Given this markdown document, classify it into exactly one of these statuses: ${statusList}.\nReply with ONLY the status word, nothing else.\n\nFile: ${repoPath}\n\n${(body ?? '').slice(0, 4000)}`;
277
- const result = runModel(prompt, { maxTokens: 10 });
278
- const suggested = result?.trim().toLowerCase().split(/\s+/)[0];
279
- const inferValid = inferTypeSet ?? config.validStatuses;
280
- if (suggested && inferValid.has(suggested)) {
281
- updateFrontmatter(filePath, { status: suggested });
282
- f.newValue = suggested;
283
- }
270
+ // Apply infer-status fixes via AI
271
+ for (const f of dryRun ? [] : fixes.filter(f => f.type === 'infer-status')) {
272
+ const { frontmatter: fmInfer } = extractFrontmatter(raw);
273
+ const parsedInfer = parseSimpleFrontmatter(fmInfer);
274
+ const inferType = asString(parsedInfer.type);
275
+ const inferTypeSet = inferType ? config.typeStatuses?.get(inferType) : null;
276
+ const statusList = inferTypeSet ? [...inferTypeSet].join(', ') : config.statusOrder.join(', ');
277
+ const { body } = extractFrontmatter(raw);
278
+ const prompt = `Given this markdown document, classify it into exactly one of these statuses: ${statusList}.\nReply with ONLY the status word, nothing else.\n\nFile: ${repoPath}\n\n${(body ?? '').slice(0, 4000)}`;
279
+ const result = runModel(prompt, { maxTokens: 10 });
280
+ const suggested = result?.trim().toLowerCase().split(/\s+/)[0];
281
+ const inferValid = inferTypeSet ?? config.validStatuses;
282
+ if (suggested && inferValid.has(suggested)) {
283
+ updates.status = suggested;
284
+ f.newValue = suggested;
284
285
  }
286
+ }
285
287
 
286
- // Apply singular-to-plural fixes (module/surface → modules/surfaces array).
287
- // Removes the singular key block; merges its value(s) into the plural array,
288
- // or creates the plural block if absent. Duplicates are skipped.
289
- for (const sa of singularToPlural) {
290
- let raw = readFileSync(filePath, 'utf8');
291
- const { frontmatter: fm } = extractFrontmatter(raw);
292
- const parsedFm = parseSimpleFrontmatter(fm);
293
- const pluralValue = parsedFm[sa.pluralKey];
294
- const existing = Array.isArray(pluralValue)
295
- ? pluralValue.filter(value => typeof value === 'string' && value.trim())
296
- : asString(pluralValue) ? [asString(pluralValue)] : [];
297
- const merged = [...new Set([...sa.newValue, ...existing])];
298
- const comments = [...frontmatterFieldComments(fm, sa.field), ...frontmatterFieldComments(fm, sa.pluralKey)];
299
- let newFm = replaceFrontmatterFieldBlock(fm, sa.field);
300
- const pluralLines = [sa.pluralKey + ':', ...comments.map(comment => ` ${comment}`), ...merged.map(v => ` - ${formatYamlScalar(v)}`)];
301
- const pluralBlock = pluralLines.join('\n');
302
- if (Object.prototype.hasOwnProperty.call(parsedFm, sa.pluralKey)) {
303
- newFm = replaceFrontmatterFieldBlock(newFm, sa.pluralKey, pluralBlock);
304
- } else {
305
- newFm = `${newFm.trim()}\n${pluralBlock}`;
306
- }
307
- raw = replaceFrontmatter(raw, newFm.trim());
308
- writeFileSync(filePath, raw, 'utf8');
288
+ // Apply singular-to-plural fixes (module/surface → modules/surfaces array).
289
+ // Removes the singular key block; merges its value(s) into the plural array,
290
+ // or creates the plural block if absent. Duplicates are skipped.
291
+ for (const sa of singularToPlural) {
292
+ const { frontmatter: fm } = extractFrontmatter(raw);
293
+ const parsedFm = parseSimpleFrontmatter(fm);
294
+ const pluralValue = parsedFm[sa.pluralKey];
295
+ const existing = Array.isArray(pluralValue)
296
+ ? pluralValue.filter(value => typeof value === 'string' && value.trim())
297
+ : asString(pluralValue) ? [asString(pluralValue)] : [];
298
+ const merged = [...new Set([...sa.newValue, ...existing])];
299
+ const comments = [...frontmatterFieldComments(fm, sa.field), ...frontmatterFieldComments(fm, sa.pluralKey)];
300
+ let newFm = replaceFrontmatterFieldBlock(fm, sa.field);
301
+ const pluralLines = [sa.pluralKey + ':', ...comments.map(comment => ` ${comment}`), ...merged.map(v => ` - ${formatYamlScalar(v)}`)];
302
+ const pluralBlock = pluralLines.join('\n');
303
+ if (Object.prototype.hasOwnProperty.call(parsedFm, sa.pluralKey)) {
304
+ newFm = replaceFrontmatterFieldBlock(newFm, sa.pluralKey, pluralBlock);
305
+ } else {
306
+ newFm = `${newFm.trim()}\n${pluralBlock}`;
309
307
  }
308
+ raw = replaceFrontmatter(raw, newFm.trim());
309
+ }
310
310
 
311
- // Drop empty deprecated singular keys. The value is known-empty (the fix
312
- // is only emitted for `[]`/`''`), so this matches the bare `key:` line
313
- // exactly — never a line carrying content — and removes it.
314
- for (const ds of dropSingular) {
315
- let raw = readFileSync(filePath, 'utf8');
316
- const { frontmatter: fm } = extractFrontmatter(raw);
317
- const comments = frontmatterFieldComments(fm, ds.field);
318
- const newFm = replaceFrontmatterFieldBlock(fm, ds.field, comments.join('\n'));
319
- raw = replaceFrontmatter(raw, newFm.trim());
320
- writeFileSync(filePath, raw, 'utf8');
321
- }
311
+ // Drop empty deprecated singular keys. The value is known-empty (the fix
312
+ // is only emitted for `[]`/`''`), so this matches the bare `key:` line
313
+ // exactly — never a line carrying content — and removes it.
314
+ for (const ds of dropSingular) {
315
+ const { frontmatter: fm } = extractFrontmatter(raw);
316
+ const comments = frontmatterFieldComments(fm, ds.field);
317
+ const newFm = replaceFrontmatterFieldBlock(fm, ds.field, comments.join('\n'));
318
+ raw = replaceFrontmatter(raw, newFm.trim());
319
+ }
322
320
 
323
- // Apply key renames and trim fixes via raw string manipulation
324
- if (keyRenames.length > 0 || trimFixes.length > 0) {
325
- let raw = readFileSync(filePath, 'utf8');
326
- const { frontmatter: fm } = extractFrontmatter(raw);
327
- let newFm = fm;
328
- for (const kr of keyRenames) {
329
- const regex = new RegExp(`^${escapeRegex(kr.oldValue)}:`, 'm');
330
- newFm = newFm.replace(regex, `${kr.newValue}:`);
331
- }
332
- for (const tf of trimFixes) {
333
- const regex = new RegExp(`^(${escapeRegex(tf.field)}:)${escapeRegex(tf.oldValue)}$`, 'm');
334
- newFm = newFm.replace(regex, `$1${tf.newValue}`);
335
- }
336
- if (newFm !== fm) {
337
- raw = replaceFrontmatter(raw, newFm);
338
- writeFileSync(filePath, raw, 'utf8');
339
- }
321
+ // Apply key renames and trim fixes via raw string manipulation
322
+ if (keyRenames.length > 0 || trimFixes.length > 0) {
323
+ const { frontmatter: fm } = extractFrontmatter(raw);
324
+ let newFm = fm;
325
+ for (const kr of keyRenames) {
326
+ const regex = new RegExp(`^${escapeRegex(kr.oldValue)}:`, 'm');
327
+ newFm = newFm.replace(regex, `${kr.newValue}:`);
340
328
  }
341
-
342
- // Apply value updates via updateFrontmatter
343
- if (Object.keys(updates).length > 0) {
344
- updateFrontmatter(filePath, updates);
329
+ for (const tf of trimFixes) {
330
+ const regex = new RegExp(`^(${escapeRegex(tf.field)}:)${escapeRegex(tf.oldValue)}$`, 'm');
331
+ newFm = newFm.replace(regex, `$1${tf.newValue}`);
345
332
  }
346
-
347
- // EOF fix
348
- if (needsEofFix) {
349
- const current = readFileSync(filePath, 'utf8');
350
- if (!current.endsWith('\n')) {
351
- writeFileSync(filePath, current + '\n', 'utf8');
352
- }
333
+ if (newFm !== fm) {
334
+ raw = replaceFrontmatter(raw, newFm);
353
335
  }
354
336
  }
355
337
 
338
+ // Apply value updates to the prepared source
339
+ if (Object.keys(updates).length > 0) {
340
+ raw = renderLifecycleMutation(raw, updates, null);
341
+ }
342
+
343
+ // EOF fix
344
+ if (needsEofFix) {
345
+ if (!raw.endsWith('\n')) raw += '\n';
346
+ }
347
+
348
+ repairs.push({ path: filePath, expectedContent: source, content: raw });
349
+ }
350
+ publishRepairs(repairs, config, opts);
351
+
352
+ for (const { repoPath, fixes } of fixable) {
356
353
  process.stdout.write(`${prefix}${green('Fixed')}: ${repoPath} (${fixes.length} issue${fixes.length > 1 ? 's' : ''})\n`);
357
354
  for (const f of fixes) {
358
355
  if (f.type === 'rename-key') {
@@ -0,0 +1,23 @@
1
+ import { collectDocFiles, resolveDocArg } from './index.mjs';
2
+ import { authorizeManagedSweep } from './managed-path.mjs';
3
+ import { assertPlanMutationAuthorized, ownershipPathFor, availableSessionId } from './pickup.mjs';
4
+ import { mutateFileSet } from './atomic-mutation.mjs';
5
+
6
+ export function resolveRepairSources(inputs, config) {
7
+ const files = inputs.length ? [...new Set(inputs.map(input => resolveDocArg(input, config)))] : collectDocFiles(config);
8
+ authorizeManagedSweep(files, config, { kind: 'Repair source' });
9
+ return files;
10
+ }
11
+
12
+ export function publishRepairs(repairs, config, opts = {}) {
13
+ const changed = repairs.filter(item => item.content !== item.expectedContent);
14
+ const guards = changed.map(item => {
15
+ const ownership = assertPlanMutationAuthorized(item.path, config, { sessionId: availableSessionId() });
16
+ return ownership ? { path: ownership.recordPath, expectedContent: ownership.raw }
17
+ : { path: ownershipPathFor(item.path, config), absent: true };
18
+ });
19
+ if (opts.dryRun || !changed.length) return;
20
+ mutateFileSet({ updates: changed.map(item => ({ path: item.path,
21
+ expectedContent: item.expectedContent, render: () => item.content })), guards },
22
+ { repoRoot: config.repoRoot, testHooks: opts.testHooks });
23
+ }
package/src/section.mjs CHANGED
@@ -154,12 +154,21 @@ const PHASE_COMPOUND_COMMENTARY = new RegExp(
154
154
  'i',
155
155
  );
156
156
 
157
+ // Dated execution receipts describe an attempt or acceptance record, not a work phase.
158
+ const PHASE_DATED_RECEIPT = new RegExp(
159
+ `^${PHASE_DECORATION}phase\\s+\\S+\\s+` +
160
+ `(?:execution|completed[- ]work\\s+record|personal\\s+billing\\s+acceptance|portal\\s+attempt)` +
161
+ `\\s*,\\s*(?:closed\\s+)?(?:[A-Z][a-z]+\\s+\\d{1,2},\\s+\\d{4}|\\d{4}-\\d{2}-\\d{2})\\b`,
162
+ 'i',
163
+ );
164
+
157
165
  export function isPhaseHeading(section) {
158
166
  if (section.level !== 3) return false;
159
167
  if (FILES_MANIFEST_ANCESTOR.test(section.h2Ancestor ?? '')) return false;
160
168
  return PHASE_LEAD.test(section.heading)
161
169
  && !PHASE_COMMENTARY.test(section.heading)
162
- && !PHASE_COMPOUND_COMMENTARY.test(section.heading);
170
+ && !PHASE_COMPOUND_COMMENTARY.test(section.heading)
171
+ && !PHASE_DATED_RECEIPT.test(section.heading);
163
172
  }
164
173
 
165
174
  // A phase's OWN checklist — the boxes directly under its heading, stopping at
package/src/update.mjs CHANGED
@@ -5,8 +5,8 @@ import os from 'node:os';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { green, dim, yellow } from './color.mjs';
7
7
  import { executableName, which } from './util.mjs';
8
- import { CLAUDE_MARKETPLACE, claudeMarketplaceRefusalHint, installOpencodePlugin, opencodeStatus } from './host-integration.mjs';
9
- import { codexStatus } from './codex-integration.mjs';
8
+ import { CLAUDE_MARKETPLACE, claudeMarketplaceRefusalHint, opencodeStatus } from './host-integration.mjs';
9
+ import { codexStatus, installedCodexStatus } from './codex-integration.mjs';
10
10
  import { desktopLaunch } from './desktop.mjs';
11
11
  import { compareDesktopVersions, installedDesktopVersion } from './desktop-install.mjs';
12
12
 
@@ -203,10 +203,10 @@ export function planUpdate(opts, ctx) {
203
203
  // the moment the CLI moves on. Refresh it here — but only if it is already
204
204
  // installed. `update` keeps hosts in lockstep; it never adopts a new one,
205
205
  // which stays the job of the explicit `runlist install`.
206
- if (ctx.opencode?.exists && ctx.opencode.stale) {
207
- steps.push({ kind: 'opencode', path: ctx.opencode.path });
208
- } else if (ctx.opencode?.foreign) {
206
+ if (ctx.opencode?.foreign) {
209
207
  steps.push({ kind: 'skip', reason: `${ctx.opencode.path} was not written by runlist — leaving it alone` });
208
+ } else if (ctx.opencode?.exists && (ctx.opencode.stale || (!opts.pluginOnly && ctx.hasNpm))) {
209
+ steps.push({ kind: 'opencode', cmd: ['runlist', 'install', 'opencode'] });
210
210
  }
211
211
  if (ctx.codex?.foreign) {
212
212
  steps.push({ kind: 'skip', reason: `${ctx.codex.plugin} was not written by runlist — leaving it alone` });
@@ -220,6 +220,28 @@ export function planUpdate(opts, ctx) {
220
220
  return steps;
221
221
  }
222
222
 
223
+ export function verifyManagedHostUpdates(before, after, expectedVersion) {
224
+ const failures = [];
225
+ for (const host of ['opencode', 'codex']) {
226
+ if (!before[host]?.exists || before[host].foreign) continue;
227
+ const current = after[host];
228
+ if (!current?.exists || current.foreign || current.version !== expectedVersion || current.stale) {
229
+ failures.push(`${host} integration has ${current?.version ?? 'no managed installation'}, expected ${expectedVersion}`);
230
+ }
231
+ if (host === 'codex' && (!current?.installed?.ok || current.installed.version !== expectedVersion)) {
232
+ failures.push(`Codex installed plugin ${current?.installed?.reason ?? `has ${current?.installed?.version ?? 'unknown'}, expected ${expectedVersion}`}`);
233
+ }
234
+ }
235
+ return failures;
236
+ }
237
+
238
+ function currentCodexStatus(version) {
239
+ const source = codexStatus({ version });
240
+ if (!source.exists || source.foreign) return source;
241
+ const installed = which('codex') ? installedCodexStatus() : { ok: false, reason: 'codex CLI not found; installed plugin is unverified' };
242
+ return { ...source, installed, stale: source.stale || !installed.ok || installed.version !== version };
243
+ }
244
+
223
245
  export async function runUpdate(argv, _config, opts = {}) {
224
246
  const check = argv.includes('--check');
225
247
  const cliOnly = argv.includes('--cli-only');
@@ -252,10 +274,10 @@ export async function runUpdate(argv, _config, opts = {}) {
252
274
  if (!oc.exists) process.stdout.write(dim('runlist opencode: not installed\n'));
253
275
  else if (oc.foreign) process.stdout.write(`runlist opencode: ${yellow('unmanaged file — not written by runlist')}\n`);
254
276
  else process.stdout.write(`runlist opencode: ${oc.version} ${oc.stale ? yellow('behind — run `runlist update`') : green('in sync')}\n`);
255
- const cx = codexStatus({ version: pkg.version });
277
+ const cx = currentCodexStatus(pkg.version);
256
278
  if (!cx.exists) process.stdout.write(dim('runlist codex: not installed\n'));
257
279
  else if (cx.foreign) process.stdout.write(`runlist codex: ${yellow('unmanaged plugin directory')}\n`);
258
- else process.stdout.write(`runlist codex: ${cx.version} ${cx.stale ? yellow('behind — run `runlist update`') : green('in sync')}\n`);
280
+ else process.stdout.write(`runlist codex: source ${cx.version}; installed ${cx.installed?.version ?? 'unverified'} ${cx.stale ? yellow('out of sync — run `runlist update`') : green('in sync')}\n`);
259
281
  if (desktop) {
260
282
  const version = await installedDesktopVersion(desktop);
261
283
  process.stdout.write(`runlist desktop: ${version} ${compareDesktopVersions(version, pkg.version) < 0 ? yellow('behind — quit Runlist and run `runlist update`') : green('in sync')}\n`);
@@ -264,13 +286,12 @@ export async function runUpdate(argv, _config, opts = {}) {
264
286
  }
265
287
 
266
288
  const opencode = opencodeStatus({ version: pkg.version });
267
- const codex = codexStatus({ version: pkg.version });
289
+ const codex = currentCodexStatus(pkg.version);
268
290
  const steps = planUpdate({ cliOnly, pluginOnly }, { plugin, legacyPlugin, opencode, codex, desktop, hasClaude: which('claude'), hasNpm: which('npm') });
269
291
  if (opts.dryRun) {
270
292
  for (const step of steps) {
271
293
  if (step.kind === 'skip') process.stdout.write(dim(`[dry-run] skip: ${step.reason}\n`));
272
294
  else if (step.kind === 'manual') for (const line of step.lines) process.stdout.write(dim(`[dry-run] Run from a session: ${line}\n`));
273
- else if (step.kind === 'opencode') process.stdout.write(dim(`[dry-run] Would refresh: ${step.path}\n`));
274
295
  else if (step.kind === 'refusal') process.stdout.write(yellow(`[dry-run] Refused: ${step.reason}\n`));
275
296
  else process.stdout.write(dim(`[dry-run] Would run: ${step.cmd.join(' ')}${step.cwd ? ` (in ${step.cwd})` : ''}\n`));
276
297
  }
@@ -304,18 +325,17 @@ export async function runUpdate(argv, _config, opts = {}) {
304
325
  process.stdout.write(dim(`skip: ${s.cmd.join(' ')} — the ${s.needs} step it depends on failed\n`));
305
326
  continue;
306
327
  }
307
- if (s.kind === 'opencode') {
308
- const result = installOpencodePlugin({ version: pkg.version });
309
- process.stdout.write(dim(`refreshed opencode integration → ${pkg.version} ${result.path}\n`));
310
- ran = true;
311
- continue;
312
- }
313
328
  if (s.reason) process.stdout.write(dim(`${s.reason}\n`));
314
329
  process.stdout.write(dim(`$ ${s.cmd.join(' ')}${s.cwd ? ` (in ${s.cwd})` : ''}\n`));
315
- const r = spawnSync(executableName(s.cmd[0]), s.cmd.slice(1), {
330
+ // npm can replace this package during the CLI step. A new Node process
331
+ // reads the new installers/assets; this process still holds the old modules.
332
+ // Use this installation's entry point, not another runlist earlier on PATH.
333
+ const ownCli = s.cmd[0] === 'runlist';
334
+ const r = spawnSync(ownCli ? process.execPath : executableName(s.cmd[0]),
335
+ ownCli ? [path.join(__dirname, '..', 'bin', 'runlist.mjs'), ...s.cmd.slice(1)] : s.cmd.slice(1), {
316
336
  stdio: 'inherit',
317
337
  ...(s.cwd ? { cwd: s.cwd } : {}),
318
- shell: process.platform === 'win32',
338
+ shell: !ownCli && process.platform === 'win32',
319
339
  });
320
340
  ran = true;
321
341
  if (r.status !== 0) {
@@ -325,17 +345,27 @@ export async function runUpdate(argv, _config, opts = {}) {
325
345
  if (s.kind === 'marketplace') for (const line of claudeMarketplaceRefusalHint(plugin?.marketplace)) process.stdout.write(yellow(`${line}\n`));
326
346
  }
327
347
  }
328
- if (plugin?.id === DEFAULT_PLUGIN_ID && steps.some(step => step.kind === 'plugin') && !failedKinds.has('plugin') && !failedKinds.has('marketplace')) {
348
+ const currentVersion = opts.expectedVersion ?? JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
349
+ if (!cliOnly) {
350
+ const after = { opencode: opencodeStatus({ version: currentVersion }), codex: currentCodexStatus(currentVersion) };
351
+ for (const reason of verifyManagedHostUpdates({ opencode, codex }, after, currentVersion)) {
352
+ failures.push({ reason });
353
+ process.stdout.write(yellow(`${reason}\n`));
354
+ }
355
+ }
356
+ if (!cliOnly && plugin?.id === DEFAULT_PLUGIN_ID && !failedKinds.has('plugin') && !failedKinds.has('marketplace')) {
329
357
  // npm may have replaced this package while this process was running.
330
- const currentVersion = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
331
- const verified = verifyPluginScopeUpdates(plugin, readInstalledPluginRecords({ id: plugin.id }), { minimumVersion: currentVersion });
358
+ const verified = verifyPluginScopeUpdates(plugin, readInstalledPluginRecords({ id: plugin.id }),
359
+ opts.expectedVersion ? { expectedVersion: currentVersion } : { minimumVersion: currentVersion });
332
360
  if (!verified.ok) {
333
361
  failures.push({ reason: verified.reason });
334
362
  process.stdout.write(yellow(`${verified.reason}\n`));
335
363
  }
336
364
  }
365
+ if (!cliOnly && legacyPlugin && readInstalledPluginRecords({ id: LEGACY_PLUGIN_ID })) {
366
+ failures.push({ reason: `legacy Claude plugin ${LEGACY_PLUGIN_ID} remains installed; migration is incomplete` });
367
+ }
337
368
  if (desktop && steps.some(step => step.kind === 'desktop') && !failedKinds.has('desktop')) {
338
- const currentVersion = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
339
369
  if (compareDesktopVersions(await installedDesktopVersion(desktop), currentVersion) < 0) {
340
370
  failures.push({ reason: 'The matching desktop installer is not published yet; retry `runlist update` after the product release completes.' });
341
371
  }
@@ -343,12 +373,12 @@ export async function runUpdate(argv, _config, opts = {}) {
343
373
  if (failures.length) {
344
374
  process.stdout.write(yellow(`\n${failures.length === 1 ? '1 step' : `${failures.length} steps`} failed: ${failures.map(s => s.cmd ? `${s.cmd.join(' ')}${s.cwd ? ` (in ${s.cwd})` : ''}` : s.reason).join('; ')}\n`));
345
375
  if (ran && failures.length < steps.filter(s => s.kind !== 'skip' && s.kind !== 'manual').length) {
346
- process.stdout.write(dim('the other steps completed; restart your Claude Code session (or /reload-plugins) to apply them.\n'));
376
+ process.stdout.write(dim('the other steps completed; restart the affected host sessions to load the updated integrations.\n'));
347
377
  }
348
378
  process.exitCode = 1;
349
379
  return;
350
380
  }
351
381
  if (ran) {
352
- process.stdout.write(green('\n✓ restart your Claude Code session (or /reload-plugins) to apply.\n'));
382
+ process.stdout.write(green('\n✓ restart the affected host sessions to load the updated integrations.\n'));
353
383
  }
354
384
  }