dotmd-cli 0.76.8 → 0.77.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.
package/README.md CHANGED
@@ -20,6 +20,10 @@ npm install -D dotmd-cli # project scripts via node_modules/.bin
20
20
  npx dotmd-cli init # try it without installing
21
21
  ```
22
22
 
23
+ `runlist` is the canonical executable, `rl` is its short convenience alias, and
24
+ `dotmd` remains supported during the compatibility window. All three invoke the
25
+ same CLI; examples below retain `dotmd` while the public package identity does.
26
+
23
27
  Maintainer release automation is POSIX-only because it uses Bash and POSIX
24
28
  command-line tools. The published Node.js CLI remains cross-platform.
25
29
 
@@ -28,25 +28,30 @@ import { execFile } from 'node:child_process';
28
28
  const PRIMER_TTL_MS = 60_000;
29
29
  const PRIMER_TIMEOUT_MS = 5_000;
30
30
 
31
- function dotmdExecutable() {
32
- return process.platform === 'win32' ? 'dotmd.cmd' : 'dotmd';
31
+ function cliExecutables() {
32
+ return process.platform === 'win32' ? ['runlist.cmd', 'dotmd.cmd'] : ['runlist', 'dotmd'];
33
33
  }
34
34
 
35
35
  function runHud(directory) {
36
36
  return new Promise(resolve => {
37
37
  let settled = false;
38
38
  const done = value => { if (!settled) { settled = true; resolve(value); } };
39
- try {
40
- execFile(dotmdExecutable(), ['hud'], {
41
- cwd: directory,
42
- timeout: PRIMER_TIMEOUT_MS,
43
- windowsHide: true,
44
- env: { ...process.env, NO_COLOR: '1' },
45
- }, (error, stdout) => done(error ? '' : (stdout ?? '').trim()));
46
- } catch {
47
- // `dotmd` not on PATH, spawn refused — no primer, no noise.
48
- done('');
49
- }
39
+ const candidates = cliExecutables();
40
+ const attempt = index => {
41
+ if (index >= candidates.length) { done(''); return; }
42
+ try {
43
+ execFile(candidates[index], ['hud'], {
44
+ cwd: directory,
45
+ timeout: PRIMER_TIMEOUT_MS,
46
+ windowsHide: true,
47
+ env: { ...process.env, NO_COLOR: '1' },
48
+ }, (error, stdout) => {
49
+ if (error?.code === 'ENOENT') attempt(index + 1);
50
+ else done(error ? '' : (stdout ?? '').trim());
51
+ });
52
+ } catch { attempt(index + 1); }
53
+ };
54
+ attempt(0);
50
55
  });
51
56
  }
52
57
 
@@ -76,10 +81,14 @@ export default async function dotmdOpencodePlugin({ directory }) {
76
81
  // available to a tool shell.
77
82
  'shell.env': async (input, output) => {
78
83
  try {
79
- if (input?.sessionID) output.env.DOTMD_SESSION_ID = `opencode:${input.sessionID}`;
84
+ if (input?.sessionID) {
85
+ output.env.RUNLIST_SESSION_ID = `opencode:${input.sessionID}`;
86
+ output.env.DOTMD_SESSION_ID = `opencode:${input.sessionID}`;
87
+ }
80
88
  // The OpenCode server process hosts the session and outlives every tool
81
89
  // shell, so it is the process whose liveness answers "is this claim's
82
90
  // owner still there?" — `dotmd doctor --claims` probes exactly this.
91
+ output.env.RUNLIST_SESSION_PID = String(process.pid);
83
92
  output.env.DOTMD_SESSION_PID = String(process.pid);
84
93
  } catch { /* never break a shell over this */ }
85
94
  },
package/bin/dotmd.mjs CHANGED
@@ -138,7 +138,7 @@ Rules:
138
138
 
139
139
  \`guard: { deny: false }\` in dotmd.config.mjs drops edit-status back to
140
140
  warn-only. Every catch is appended to the cross-repo misuse log. Disable the
141
- guard entirely with DOTMD_GUARD=0. Read the log with \`dotmd misuse\`; when one
141
+ guard entirely with RUNLIST_GUARD=0. Read the log with \`dotmd misuse\`; when one
142
142
  rule trips ≥3× in 7 days in a repo, \`dotmd hud\` opens the next session there
143
143
  with a one-line recap naming the habit to break.`,
144
144
 
@@ -278,7 +278,7 @@ Setup:
278
278
  watch [command] Re-run a command on file changes
279
279
  completions <shell> Shell completion script (bash, zsh)
280
280
  journal [--tail N|--errors|--by-command|--session id|--since iso|--json]
281
- View opt-in JSONL command journal (enable: DOTMD_JOURNAL=1 or journal: true)
281
+ View opt-in JSONL command journal (enable: RUNLIST_JOURNAL=1 or journal: true)
282
282
 
283
283
  Global Options:
284
284
  --config <path> Explicit config file path
@@ -400,10 +400,10 @@ invocation appends one JSONL line to .dotmd/journal.jsonl with argv, exit
400
400
  code, elapsed ms, session id, and (on error) a single-line err message.
401
401
 
402
402
  Enable:
403
- - env: DOTMD_JOURNAL=1
403
+ - env: RUNLIST_JOURNAL=1
404
404
  - config: \`export const journal = true;\` in dotmd.config.mjs
405
405
 
406
- The env var beats config (DOTMD_JOURNAL=0 forces off). The journal is
406
+ The env var beats config (RUNLIST_JOURNAL=0 forces off). The journal is
407
407
  default-off so non-agent users don't pay the size/PII cost.
408
408
 
409
409
  Reader options:
@@ -421,7 +421,7 @@ Storage:
421
421
  rotation or pruned after the retention window.
422
422
 
423
423
  Examples:
424
- DOTMD_JOURNAL=1 dotmd plans
424
+ RUNLIST_JOURNAL=1 dotmd plans
425
425
  dotmd journal --tail 5
426
426
  dotmd journal --errors
427
427
  dotmd journal --by-command
@@ -506,7 +506,7 @@ Ambiguous slugs error with the candidate list instead of guessing.
506
506
  When the path is omitted, exactly one plan must be owned by this session.
507
507
  Claude Code session IDs are recognized automatically, as is OpenCode (via
508
508
  OPENCODE_PID — per OpenCode process, not per session). Other hosts must set
509
- DOTMD_SESSION_ID; anonymous ownership mutations fail closed.
509
+ RUNLIST_SESSION_ID; anonymous ownership mutations fail closed.
510
510
  Pickup hooks use at-least-once delivery with a stable operationId; hook side
511
511
  effects must deduplicate that ID.
512
512
 
@@ -1442,6 +1442,20 @@ Pass file paths as positional args to scope to those files only; otherwise
1442
1442
  the whole docs tree is scanned.`,
1443
1443
  };
1444
1444
 
1445
+ // Help presents the new product name while the compatibility package and
1446
+ // plugin still use their old registry identities. Protect those identifiers
1447
+ // from the display-only command-name rewrite until the package cutover.
1448
+ function canonicalHelp(text) {
1449
+ return String(text)
1450
+ .replaceAll('dotmd-cli', '\u0000PACKAGE\u0000')
1451
+ .replaceAll('dotmd@dotmd', '\u0000PLUGIN\u0000')
1452
+ .replaceAll('reowens/dotmd', '\u0000REPOSITORY\u0000')
1453
+ .replace(/\bdotmd\b/g, 'runlist')
1454
+ .replaceAll('\u0000PACKAGE\u0000', 'dotmd-cli')
1455
+ .replaceAll('\u0000PLUGIN\u0000', 'dotmd@dotmd')
1456
+ .replaceAll('\u0000REPOSITORY\u0000', 'reowens/dotmd');
1457
+ }
1458
+
1445
1459
  const GLOBAL_VALUE_OPTIONS = new Set(['--config', '--root', '--type']);
1446
1460
  const GLOBAL_BOOLEAN_OPTIONS = new Set(['--dry-run', '-n', '--verbose']);
1447
1461
 
@@ -1512,7 +1526,7 @@ async function main() {
1512
1526
  // Tolerate accidentally pasting the command prefix twice, while leaving all
1513
1527
  // remaining arguments to the normal `use` grammar and path validation.
1514
1528
  if (command === 'use') {
1515
- while (restArgs[0] === 'dotmd' && restArgs[1] === 'use') restArgs = restArgs.slice(2);
1529
+ while ((restArgs[0] === 'runlist' || restArgs[0] === 'dotmd') && restArgs[1] === 'use') restArgs = restArgs.slice(2);
1516
1530
  }
1517
1531
 
1518
1532
  // Reconstruct the active global flags for proxy commands (e.g. `watch`) that
@@ -1559,12 +1573,12 @@ async function main() {
1559
1573
  const topic = restArgs[0];
1560
1574
  if (topic) {
1561
1575
  const key = `help:${topic}`;
1562
- if (HELP[key]) { process.stdout.write(`${HELP[key]}\n`); return; }
1563
- if (HELP[topic]) { process.stdout.write(`${HELP[topic]}\n`); return; }
1576
+ if (HELP[key]) { process.stdout.write(`${canonicalHelp(HELP[key])}\n`); return; }
1577
+ if (HELP[topic]) { process.stdout.write(`${canonicalHelp(HELP[topic])}\n`); return; }
1564
1578
  process.stderr.write(`Unknown help topic: ${topic}\n\nAvailable topics: all, statuses\nPer-command help: dotmd <cmd> --help\n`);
1565
1579
  process.exit(1);
1566
1580
  }
1567
- process.stdout.write(`${HELP._main}\n`);
1581
+ process.stdout.write(`${canonicalHelp(HELP._main)}\n`);
1568
1582
  return;
1569
1583
  }
1570
1584
 
@@ -1588,7 +1602,7 @@ async function main() {
1588
1602
  // Per-command help
1589
1603
  if (args.includes('--help') || args.includes('-h')) {
1590
1604
  requireCommandPolicy(command, dispatchPolicy);
1591
- process.stdout.write(`${HELP[command] ?? commandUsage(command)}\n`);
1605
+ process.stdout.write(`${canonicalHelp(HELP[command] ?? commandUsage(command))}\n`);
1592
1606
  return;
1593
1607
  }
1594
1608
 
@@ -1623,11 +1637,21 @@ async function main() {
1623
1637
  if (notice) process.stderr.write(`${notice}\n`);
1624
1638
  }
1625
1639
 
1626
- const suppressSideEffects = effectiveDryRun || command === 'hud' || passiveMachineContext;
1640
+ const suppressSideEffects = effectiveDryRun || command === 'hud' || command === 'guard' || passiveMachineContext;
1627
1641
  Object.defineProperty(config, '_execution', {
1628
1642
  value: { dryRun, passive: command === 'hud' || passiveMachineContext, suppressSideEffects, gitStaleness },
1629
1643
  enumerable: false,
1630
1644
  });
1645
+ if (!suppressSideEffects) {
1646
+ const { migrateStateDirectory } = await import('../src/state-migration.mjs');
1647
+ const { LEGACY_STATE_DIR, STATE_DIR } = await import('../src/naming.mjs');
1648
+ const migration = migrateStateDirectory(config.repoRoot);
1649
+ if (migration.status === 'migrated') {
1650
+ process.stderr.write(`[runlist] migrated ${LEGACY_STATE_DIR}/ → ${STATE_DIR}/ (session state moved; repository files unchanged)\n`);
1651
+ } else if (migration.status === 'refused') {
1652
+ warn(migration.message);
1653
+ }
1654
+ }
1631
1655
  // Unknown names may still be user-defined query presets. Every built-in
1632
1656
  // dispatcher branch, including mutators above the shared index path, must be
1633
1657
  // present in the centralized command policy registry.
@@ -2240,7 +2264,7 @@ main()
2240
2264
  let out = err.message;
2241
2265
  // F17c: append a repeat-failure tip when the journal shows this same shape
2242
2266
  // has already failed in this session within the lookup window. Lookup is
2243
- // a no-op when the journal is disabled or DOTMD_NO_HINTS=1.
2267
+ // a no-op when the journal is disabled or RUNLIST_NO_HINTS=1.
2244
2268
  try {
2245
2269
  const hint = findRepeatFailureHint(sanitizeTelemetryArgv(_invocationArgs), _resolvedConfig);
2246
2270
  if (hint) out = `${out}\n\nTip: ${hint}`;
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Canonical executable. The implementation remains shared with the retired
4
+ // `dotmd` alias during the compatibility window so both commands behave
5
+ // identically and cannot drift.
6
+ await import('./dotmd.mjs');
package/package.json CHANGED
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.76.8",
3
+ "version": "0.77.1",
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",
7
7
  "bin": {
8
+ "runlist": "bin/runlist.mjs",
9
+ "rl": "bin/runlist.mjs",
8
10
  "dotmd": "bin/dotmd.mjs"
9
11
  },
10
12
  "exports": {
@@ -15,7 +17,7 @@
15
17
  "src/",
16
18
  "assets/",
17
19
  "scripts/postinstall.mjs",
18
- "dotmd.config.example.mjs"
20
+ "runlist.config.example.mjs"
19
21
  ],
20
22
  "keywords": [
21
23
  "markdown",
@@ -1,4 +1,4 @@
1
- // dotmd.config.mjs — document management configuration
1
+ // runlist.config.mjs — document management configuration
2
2
  // All exports are optional. Omitted values use built-in defaults.
3
3
  // Place this file at the root of your project.
4
4
 
@@ -7,17 +7,17 @@
7
7
  // Directory containing your markdown docs (relative to this config file)
8
8
  export const root = 'docs';
9
9
 
10
- // Subdirectory for archived docs (used by `dotmd archive` and `dotmd status`)
10
+ // Subdirectory for archived docs (used by `runlist archive` and `runlist status`)
11
11
  export const archiveDir = 'archived';
12
12
 
13
13
  // Directories to skip when scanning
14
14
  export const excludeDirs = ['evidence'];
15
15
 
16
- // Floor under the scan surface. `dotmd check` fails when it scans fewer docs than
16
+ // Floor under the scan surface. `runlist check` fails when it scans fewer docs than
17
17
  // this, so a broken root or an over-eager exclude can't read as a clean estate —
18
18
  // zero errors and zero docs look identical otherwise. Off when unset. Set it well
19
19
  // below your real count (round down hard); raise it as the corpus grows.
20
- // Override for one run with `dotmd check --min-docs <n>`; skipped for path-scoped
20
+ // Override for one run with `runlist check --min-docs <n>`; skipped for path-scoped
21
21
  // checks, which are deliberate subsets.
22
22
  // export const minDocs = 500;
23
23
 
@@ -44,7 +44,7 @@ export const excludeDirs = ['evidence'];
44
44
  // `terminal` and `quiet` are orthogonal. Mark a status `terminal` only when it represents closure
45
45
  // (excluded from active-work scope). Use `quiet` for noise suppression without closure semantics.
46
46
  //
47
- // Contradiction check (since 0.36.2): dotmd `warn()`s at config-load when a status combines
47
+ // Contradiction check (since 0.36.2): runlist `warn()`s at config-load when a status combines
48
48
  // `skipStale: true` with a `staleDays` value (the number is silently ignored) or
49
49
  // `skipWarnings: true` with `requiresModule: true` (the module requirement can never fire).
50
50
  // The same check applies via the `quiet: true` sugar. Drop one of the conflicting fields to silence.
@@ -80,8 +80,8 @@ export const excludeDirs = ['evidence'];
80
80
  // },
81
81
  // },
82
82
  // prompt: {
83
- // // Saved prompts that seed future Claude sessions. `dotmd hud` surfaces
84
- // // pending prompts on session start; `dotmd prompts next` claims the oldest.
83
+ // // Saved prompts that seed future Claude sessions. `runlist hud` surfaces
84
+ // // pending prompts on session start; `runlist prompts next` claims the oldest.
85
85
  // statuses: {
86
86
  // 'pending': { context: 'expanded', staleDays: 30 },
87
87
  // 'held': { context: 'counted', quiet: true }, // saved but not next: hidden from hud/briefing, skipped by no-arg `use`
@@ -132,7 +132,7 @@ export const statuses = {
132
132
  // no `types` definition. With rich-form types, the runtime derives these from
133
133
  // per-status `terminal` / `archive` / `skipStale` / `skipWarnings` / `quiet`
134
134
  // flags. An explicit `lifecycle` export sitting alongside rich-form types will
135
- // SILENTLY OVERRIDE the per-status flags — `dotmd statuses` will warn you
135
+ // SILENTLY OVERRIDE the per-status flags — `runlist statuses` will warn you
136
136
  // before writing into a config in that state.
137
137
  //
138
138
  // export const lifecycle = {
@@ -155,13 +155,13 @@ export const taxonomy = {
155
155
  // Index file generation — remove this section to disable
156
156
  export const index = {
157
157
  path: 'docs/docs.md',
158
- startMarker: '<!-- GENERATED:dotmd:start -->',
159
- endMarker: '<!-- GENERATED:dotmd:end -->',
158
+ startMarker: '<!-- GENERATED:runlist:start -->',
159
+ endMarker: '<!-- GENERATED:runlist:end -->',
160
160
  snapshot: 'status', // default; use 'state' to include live current_state text
161
161
  archivedLimit: 8,
162
162
  };
163
163
 
164
- // Context briefing layout (`dotmd context`)
164
+ // Context briefing layout (`runlist context`)
165
165
  export const context = {
166
166
  expanded: ['active'],
167
167
  listed: ['ready', 'planned'],
@@ -170,7 +170,7 @@ export const context = {
170
170
  recentStatuses: ['active', 'ready', 'planned'],
171
171
  recentLimit: 10,
172
172
  truncateNextStep: 80,
173
- // Cap on slugs shown in the "Stale: …" tail before "…and N more (run `dotmd stale`)"
173
+ // Cap on slugs shown in the "Stale: …" tail before "…and N more (run `runlist stale`)"
174
174
  // takes over (since 0.36.2). Raise on wide terminals; lower for tighter briefings.
175
175
  staleTailLimit: 8,
176
176
  };
@@ -198,13 +198,13 @@ export const presets = {
198
198
  };
199
199
 
200
200
  // ─── Templates ───────────────────────────────────────────────────────────────
201
- // Define new types or override builtins. `dotmd new <type> <name>` looks here first.
201
+ // Define new types or override builtins. `runlist new <type> <name>` looks here first.
202
202
  //
203
203
  // Properties:
204
- // description: string — shown in `dotmd new --list-types`
204
+ // description: string — shown in `runlist new --list-types`
205
205
  // defaultStatus: string — initial status if `--status` not passed
206
206
  // acceptsBody: boolean — allow body input (inline / --body / @file / piped stdin).
207
- // REQUIRED if you want `cat draft.md | dotmd new <type> <slug>` (or @path,
207
+ // REQUIRED if you want `cat draft.md | runlist new <type> <slug>` (or @path,
208
208
  // --body, heredoc) to work. Your `body` fn must also interpolate the input,
209
209
  // e.g. `${ctx?.bodyInput?.trim() ?? ''}`. See the body-acceptance guard below.
210
210
  // requiresBody: boolean — error if no body input (implies acceptsBody; see `prompt` builtin)
@@ -221,13 +221,13 @@ export const presets = {
221
221
  //
222
222
  // Body-acceptance guard (the #1 custom-template gotcha):
223
223
  // When you override a builtin and supply your OWN `body` fn that NEVER references
224
- // `bodyInput`, dotmd assumes the fn would silently discard piped input — so it strips
224
+ // `bodyInput`, runlist assumes the fn would silently discard piped input — so it strips
225
225
  // the inherited `acceptsBody`/`requiresBody` and rejects body input with a fail-fast
226
226
  // error. Two ways to keep piped/@path/heredoc bodies working in a custom template:
227
227
  // 1. interpolate `${ctx?.bodyInput?.trim() ?? ''}` somewhere in your `body` fn, OR
228
228
  // 2. set `acceptsBody: true` explicitly (do BOTH if you want the input to actually land).
229
229
  // A `body: (t) =>` that ignores `ctx` is the classic trap — it scaffolds fine but
230
- // `dotmd new <type> <slug> < draft.md` errors until you wire in `bodyInput`.
230
+ // `runlist new <type> <slug> < draft.md` errors until you wire in `bodyInput`.
231
231
  //
232
232
  // Custom type example — adds a `spike` type that lives in the `spikes` root (or
233
233
  // under `docs/spikes/` in single-root layouts):
@@ -6,7 +6,7 @@
6
6
  // - Only on GLOBAL installs (`npm i -g`). A project devDep / CI / Docker
7
7
  // install must never touch a user's Claude Code state.
8
8
  // - Default: just print a one-line nudge. A CLI install silently mutating the
9
- // agent's plugin cache is surprising; opt in with DOTMD_AUTO_PLUGIN_UPDATE=1
9
+ // agent's plugin cache is surprising; opt in with RUNLIST_AUTO_PLUGIN_UPDATE=1
10
10
  // to actually run the refresh.
11
11
  // - NEVER fail the install: everything is swallowed and we always exit 0. A
12
12
  // nonzero postinstall would break `npm i -g dotmd-cli`.
@@ -34,7 +34,7 @@ try {
34
34
  } catch { return false; }
35
35
  })();
36
36
 
37
- if (process.env.DOTMD_AUTO_PLUGIN_UPDATE === '1' && hasClaude) {
37
+ if ((process.env.RUNLIST_AUTO_PLUGIN_UPDATE ?? process.env.DOTMD_AUTO_PLUGIN_UPDATE) === '1' && hasClaude) {
38
38
  spawnSync('claude', ['plugin', 'update', 'dotmd@dotmd'], { stdio: 'ignore', timeout: 60000 });
39
39
  process.stdout.write('dotmd: refreshed the Claude Code plugin — restart your session (or /reload-plugins) to apply.\n');
40
40
  } else {
@@ -23,6 +23,7 @@ import os from 'node:os';
23
23
  import path from 'node:path';
24
24
  import { captureGitIndexGeneration, gitIndexProvablyUnpublished, gitIndexPublicationRaced, reclaimPreparedGitIndex, restoreGitIndexCas, sameGitIndexGeneration, stageMovePathsCas } from './git.mjs';
25
25
  import { authorizeManagedDestination, authorizeManagedSource, authorizeRepoGeneratedPath } from './managed-path.mjs';
26
+ import { ARTIFACT_PREFIX, LEGACY_ARTIFACT_PREFIX, LEGACY_STATE_DIR, STATE_DIR, isOwnedArtifact, stateDir } from './naming.mjs';
26
27
  import { commitRename } from './durable-rename.mjs';
27
28
 
28
29
  const sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
@@ -85,7 +86,7 @@ export class MutationConflictError extends Error {
85
86
  constructor(message) {
86
87
  super(message);
87
88
  this.name = 'MutationConflictError';
88
- this.code = 'DOTMD_MUTATION_CONFLICT';
89
+ this.code = 'RUNLIST_MUTATION_CONFLICT';
89
90
  }
90
91
  }
91
92
 
@@ -93,7 +94,7 @@ export class MutationLockError extends Error {
93
94
  constructor(message) {
94
95
  super(message);
95
96
  this.name = 'MutationLockError';
96
- this.code = 'DOTMD_MUTATION_LOCK_TIMEOUT';
97
+ this.code = 'RUNLIST_MUTATION_LOCK_TIMEOUT';
97
98
  }
98
99
  }
99
100
 
@@ -117,7 +118,7 @@ function safeGeneratedPath(filePath, repoRoot, options, kind) {
117
118
  }
118
119
 
119
120
  function transactionRoot(repoRoot, options = {}) {
120
- return safeGeneratedPath(path.join(path.resolve(repoRoot), '.dotmd', 'transactions'), repoRoot, options, 'Transaction root');
121
+ return safeGeneratedPath(path.join(stateDir(repoRoot), 'transactions'), repoRoot, options, 'Transaction root');
121
122
  }
122
123
 
123
124
  function durableJson(filePath, value, options = {}) {
@@ -237,7 +238,7 @@ function validatePreparedGitIndex(prepared, label, directory) {
237
238
  }
238
239
  validateGitSnapshot(prepared.generation, `${label}.generation`);
239
240
  validateTransactionArtifact(prepared.path, directory, label);
240
- if (path.dirname(prepared.tempPath) !== path.dirname(prepared.generation.indexPath) || !path.basename(prepared.tempPath).startsWith('.dotmd-index-')) {
241
+ if (path.dirname(prepared.tempPath) !== path.dirname(prepared.generation.indexPath) || !isOwnedArtifact(path.basename(prepared.tempPath), 'index')) {
241
242
  throw new MutationConflictError(`Unsafe prepared Git index path in ${label}.`);
242
243
  }
243
244
  if ((prepared.generation.exists && (prepared.hash !== prepared.generation.hash || prepared.size !== prepared.generation.size))
@@ -289,7 +290,7 @@ function validateParticipantPath(participant, repoRoot, options, label) {
289
290
  throw new MutationConflictError(`Invalid transaction participant ${label}.`);
290
291
  }
291
292
  const txRoot = transactionRoot(repoRoot, options);
292
- const lockRoot = safeGeneratedPath(path.join(repoRoot, '.dotmd', 'locks'), repoRoot, options, 'Lock root');
293
+ const lockRoot = safeGeneratedPath(path.join(stateDir(repoRoot), 'locks'), repoRoot, options, 'Lock root');
293
294
  if (contained(txRoot, participant.path) || contained(lockRoot, participant.path)) {
294
295
  throw new MutationConflictError(`Transaction participant targets transaction/lock state: ${participant.path}`);
295
296
  }
@@ -299,7 +300,7 @@ function validateParticipantPath(participant, repoRoot, options, label) {
299
300
  } else {
300
301
  const authorized = safeGeneratedPath(participant.path, repoRoot, options, 'Transaction generated participant');
301
302
  if (options.config) {
302
- const ownershipRoot = path.join(path.resolve(repoRoot), '.dotmd', 'ownership');
303
+ const ownershipRoot = path.join(stateDir(repoRoot), 'ownership');
303
304
  if (!contained(ownershipRoot, authorized)) throw new MutationConflictError(`Generated transaction participant is outside session ownership state: ${participant.path}`);
304
305
  if (participant.label !== 'ownership') throw new MutationConflictError(`Session-generated participant must be classified as ownership: ${participant.path}`);
305
306
  }
@@ -349,8 +350,9 @@ function validateManifest(manifest, manifestPath, directory, repoRoot, options)
349
350
  if (!participant || !contained(createdDirectory.path, participant.path) || createdDirectory.path === participant.path) {
350
351
  throw new MutationConflictError(`Transaction-created directory is not bound to a destination participant: ${createdDirectory.path}`);
351
352
  }
352
- const expectedMarker = path.join(createdDirectory.path, `.dotmd-transaction-${manifest.id}`);
353
- if (createdDirectory.marker !== expectedMarker || createdDirectory.token !== manifest.directoryToken) {
353
+ const expectedMarkers = [ARTIFACT_PREFIX, LEGACY_ARTIFACT_PREFIX]
354
+ .map(prefix => path.join(createdDirectory.path, `${prefix}transaction-${manifest.id}`));
355
+ if (!expectedMarkers.includes(createdDirectory.marker) || createdDirectory.token !== manifest.directoryToken) {
354
356
  throw new MutationConflictError(`Transaction-created directory marker binding mismatch: ${createdDirectory.path}`);
355
357
  }
356
358
  if (!['intended', 'created', 'removing', 'removed'].includes(createdDirectory.markerState)) throw new MutationConflictError(`Invalid transaction directory marker state: ${createdDirectory.path}`);
@@ -358,7 +360,7 @@ function validateManifest(manifest, manifestPath, directory, repoRoot, options)
358
360
  throw new MutationConflictError(`Invalid transaction-created directory identity: ${createdDirectory.path}`);
359
361
  }
360
362
  if (createdDirectory.markerState !== 'intended' && createdDirectory.identity === null) throw new MutationConflictError(`Transaction-created directory state lacks an identity: ${createdDirectory.path}`);
361
- if (participant.policy === 'managed' && options.config) authorizeManagedDestination(path.join(createdDirectory.path, '.dotmd-directory-check.md'), options.config, { kind: 'Transaction-created directory' });
363
+ if (participant.policy === 'managed' && options.config) authorizeManagedDestination(path.join(createdDirectory.path, `${ARTIFACT_PREFIX}directory-check.md`), options.config, { kind: 'Transaction-created directory' });
362
364
  else safeGeneratedPath(createdDirectory.path, repoRoot, options, 'Transaction-created directory');
363
365
  }
364
366
  const createdPaths = new Set(manifest.createdDirectories.map(item => item.path));
@@ -752,7 +754,7 @@ function ensureTransactionDirectory(directory, transaction, options, participant
752
754
  const intent = {
753
755
  path: item,
754
756
  participantPath: path.resolve(participantPath),
755
- marker: path.join(item, `.dotmd-transaction-${transaction.manifest.id}`),
757
+ marker: path.join(item, `${ARTIFACT_PREFIX}transaction-${transaction.manifest.id}`),
756
758
  token: transaction.manifest.directoryToken,
757
759
  markerState: 'intended',
758
760
  identity: null,
@@ -850,9 +852,9 @@ function setMoveManifestPhase(transaction, phase, options, detail = null) {
850
852
  }
851
853
 
852
854
  function companionPhase(item, fallback) {
853
- return item.label === 'ownership' || item.path.includes(`${path.sep}.dotmd${path.sep}ownership${path.sep}`)
854
- ? 'ownership-publication'
855
- : fallback;
855
+ const ownershipPath = [STATE_DIR, LEGACY_STATE_DIR]
856
+ .some(dir => item.path.includes(`${path.sep}${dir}${path.sep}ownership${path.sep}`));
857
+ return item.label === 'ownership' || ownershipPath ? 'ownership-publication' : fallback;
856
858
  }
857
859
 
858
860
  function statIdentity(stat) {
@@ -919,7 +921,7 @@ function canonicalPath(filePath) {
919
921
 
920
922
  function lockPathFor(canonical, repoRoot) {
921
923
  const key = createHash('sha256').update(canonical).digest('hex');
922
- return path.join(path.resolve(repoRoot), '.dotmd', 'locks', `${key}.lock`);
924
+ return path.join(stateDir(repoRoot), 'locks', `${key}.lock`);
923
925
  }
924
926
 
925
927
  function ownerDescription(lockPath) {
@@ -978,7 +980,7 @@ export function withPathLocks(filePaths, options, callback) {
978
980
  const { repoRoot, timeoutMs = MUTATION_LOCK_TIMEOUT_MS, retryMs = 20 } = options;
979
981
  if (!repoRoot) throw new Error('withPathLocks requires repoRoot.');
980
982
  const canonicals = [...new Set(filePaths.map(canonicalPath))].sort();
981
- const lockRoot = safeGeneratedPath(path.join(path.resolve(repoRoot), '.dotmd', 'locks'), repoRoot, options, 'Lock root');
983
+ const lockRoot = safeGeneratedPath(path.join(stateDir(repoRoot), 'locks'), repoRoot, options, 'Lock root');
982
984
  ensureDirectoryDurable(lockRoot, options, 'lock-root-create');
983
985
  const acquired = [];
984
986
  const deadline = Date.now() + timeoutMs;
@@ -1059,7 +1061,7 @@ export function withPathLocks(filePaths, options, callback) {
1059
1061
 
1060
1062
  function tempPathFor(filePath) {
1061
1063
  const base = path.basename(filePath);
1062
- return path.join(path.dirname(filePath), `.${base}.dotmd-tmp-${process.pid}-${Date.now()}-${tempSequence++}`);
1064
+ return path.join(path.dirname(filePath), `.${base}${ARTIFACT_PREFIX}tmp-${process.pid}-${Date.now()}-${tempSequence++}`);
1063
1065
  }
1064
1066
 
1065
1067
  function fsyncDirectory(dirPath, options = {}, phase = 'directory') {
@@ -1110,7 +1112,7 @@ function committedGeneration(prepared, finalPath) {
1110
1112
  }
1111
1113
 
1112
1114
  function recoveryArtifact(filePath, content, mode, label) {
1113
- const artifact = path.join(path.dirname(filePath), `.${path.basename(filePath)}.dotmd-recovery-${label}-${randomUUID()}`);
1115
+ const artifact = path.join(path.dirname(filePath), `.${path.basename(filePath)}${ARTIFACT_PREFIX}recovery-${label}-${randomUUID()}`);
1114
1116
  const temp = writeCompleteTemp(artifact, content, mode);
1115
1117
  renameSync(temp.path, artifact);
1116
1118
  fsyncDirectory(path.dirname(artifact));
package/src/commands.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // Canonical command grammar. Execution stays in bin/dotmd.mjs; this schema owns
1
+ // Canonical command grammar. Execution starts at bin/runlist.mjs; this schema owns
2
2
  // names, aliases, visibility, options, positional arity, help groups, and policy.
3
3
  const none = Object.freeze({ mutation: 'none', pathPolicy: 'read-only' });
4
4
  const mutates = (pathPolicy) => Object.freeze({ mutation: 'conditional', pathPolicy });
@@ -7,9 +7,9 @@ const COMMAND_WORDS = Object.freeze(Object.fromEntries(
7
7
  ));
8
8
 
9
9
  function bashCompletion() {
10
- return `# dotmd bash completion
11
- # Add to ~/.bashrc: eval "$(dotmd completions bash)"
12
- _dotmd() {
10
+ return `# runlist bash completion
11
+ # Add to ~/.bashrc: eval "$(runlist completions bash)"
12
+ _runlist() {
13
13
  local cur cmd expect_value
14
14
  cur="\${COMP_WORDS[COMP_CWORD]}"
15
15
  cmd=""
@@ -39,13 +39,13 @@ ${Object.entries(COMMAND_WORDS).map(([command, words]) =>
39
39
  *) COMPREPLY=( $(compgen -W "${GLOBAL_FLAGS.join(' ')}" -- "$cur") ) ;;
40
40
  esac
41
41
  }
42
- complete -F _dotmd dotmd`;
42
+ complete -F _runlist runlist rl dotmd`;
43
43
  }
44
44
 
45
45
  function zshCompletion() {
46
- return `# dotmd zsh completion
47
- # Add to ~/.zshrc: eval "$(dotmd completions zsh)"
48
- _dotmd() {
46
+ return `# runlist zsh completion
47
+ # Add to ~/.zshrc: eval "$(runlist completions zsh)"
48
+ _runlist() {
49
49
  local -a commands global_flags
50
50
  commands=(
51
51
  ${COMPLETION_COMMANDS.map(command => ` '${command}'`).join('\n')}
@@ -81,12 +81,12 @@ ${Object.entries(COMMAND_WORDS).map(([command, words]) =>
81
81
 
82
82
  _describe 'flag' global_flags
83
83
  }
84
- compdef _dotmd dotmd`;
84
+ compdef _runlist runlist rl dotmd`;
85
85
  }
86
86
 
87
87
  export function runCompletions(argv) {
88
88
  const shell = argv[0];
89
- if (!shell) die('Usage: dotmd completions <bash|zsh>');
89
+ if (!shell) die('Usage: runlist completions <bash|zsh>');
90
90
  if (shell === 'bash') process.stdout.write(bashCompletion() + '\n');
91
91
  else if (shell === 'zsh') process.stdout.write(zshCompletion() + '\n');
92
92
  else die(`Unsupported shell: ${shell}\nSupported: bash, zsh`);
@@ -1,5 +1,5 @@
1
1
  // Line-based, brace-aware editor for the `types.<typename>.statuses` block in
2
- // dotmd.config.mjs. Edits are scoped to single-line status entries; we refuse
2
+ // runlist config. Edits are scoped to single-line status entries; we refuse
3
3
  // (with an actionable error) on multi-line entries, array form, or anything
4
4
  // outside our supported shape. Atomic write contract:
5
5
  //
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
19
19
  import { pathToFileURL } from 'node:url';
20
+ import { ARTIFACT_PREFIX } from './naming.mjs';
20
21
  import { resolveConfig } from './config.mjs';
21
22
  import { commitRename } from './durable-rename.mjs';
22
23
 
@@ -230,7 +231,7 @@ function skipToNextProperty(content, start, end) {
230
231
  export function parseStatusesBlock(content, typeName) {
231
232
  const types = locateTypesBlock(content);
232
233
  if (!types) {
233
- throw new ConfigEditError('Your dotmd.config.mjs does not define a `types` block — there is nothing for `dotmd statuses` to edit. Add a `types: {...}` export to opt in to per-project status taxonomy. See dotmd.config.example.mjs for the rich-form template.');
234
+ throw new ConfigEditError('Your runlist config does not define a `types` block — there is nothing for `runlist statuses` to edit. Add a `types: {...}` export to opt in to per-project status taxonomy. See runlist.config.example.mjs for the rich-form template.');
234
235
  }
235
236
  const typeProp = findChildProperty(content, types.start, types.end, typeName);
236
237
  if (!typeProp) {
@@ -530,7 +531,7 @@ export function replaceEntry(content, parsed, name, newLine) {
530
531
  throw new ConfigEditError(`Status '${name}' is not defined for this type.`);
531
532
  }
532
533
  if (target.multiLine) {
533
- throw new ConfigEditError(`Status '${name}' spans multiple lines; this CLI only edits single-line entries. Edit dotmd.config.mjs by hand.`);
534
+ throw new ConfigEditError(`Status '${name}' spans multiple lines; this CLI only edits single-line entries. Edit the runlist config by hand.`);
534
535
  }
535
536
  return content.slice(0, target.lineStart) + newLine + content.slice(target.lineEnd);
536
537
  }
@@ -542,7 +543,7 @@ export function deleteEntry(content, parsed, name) {
542
543
  throw new ConfigEditError(`Status '${name}' is not defined for this type.`);
543
544
  }
544
545
  if (target.multiLine) {
545
- throw new ConfigEditError(`Status '${name}' spans multiple lines; delete it by hand in dotmd.config.mjs.`);
546
+ throw new ConfigEditError(`Status '${name}' spans multiple lines; delete it by hand in the runlist config.`);
546
547
  }
547
548
  return content.slice(0, target.lineStart) + content.slice(target.lineEnd);
548
549
  }
@@ -566,7 +567,7 @@ export function inferIndent(content, parsed) {
566
567
  export async function writeConfigAtomic(configPath, newContent, cwd) {
567
568
  // Node only imports .mjs/.js/.cjs, so the temp must keep a JS extension.
568
569
  // Sibling file in the same dir → atomic renameSync within one filesystem.
569
- const tmpPath = configPath.replace(/(\.[^.]+)$/, `.dotmd-edit-${process.pid}-${Date.now()}$1`);
570
+ const tmpPath = configPath.replace(/(\.[^.]+)$/, `${ARTIFACT_PREFIX}edit-${process.pid}-${Date.now()}$1`);
570
571
  writeFileSync(tmpPath, newContent, 'utf8');
571
572
 
572
573
  try {
package/src/config.mjs CHANGED
@@ -2,8 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { die, warn } from './util.mjs';
5
-
6
- const CONFIG_FILENAMES = ['dotmd.config.mjs', '.dotmd.config.mjs', 'dotmd.config.js'];
5
+ import { CONFIG_FILENAMES } from './naming.mjs';
7
6
 
8
7
  // Keys where user config replaces defaults entirely (not deep-merged).
9
8
  // These are flat maps or config sections where the user's version is authoritative —
@@ -116,7 +115,7 @@ const DEFAULTS = {
116
115
  glossary: null,
117
116
 
118
117
  // Opt-in JSONL command journal at .dotmd/journal.jsonl. Default off — agents
119
- // and users who want usage observability flip this on (or set DOTMD_JOURNAL=1).
118
+ // and users who want usage observability flip this on (or set RUNLIST_JOURNAL=1).
120
119
  journal: false,
121
120
 
122
121
  // PreToolUse guard behavior. `deny: false` drops the status-edit rules from
package/src/git.mjs CHANGED
@@ -3,6 +3,7 @@ import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, lstatSync, openS
3
3
  import path from 'node:path';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
5
  import { commitRename } from './durable-rename.mjs';
6
+ import { ARTIFACT_PREFIX, isOwnedArtifact } from './naming.mjs';
6
7
 
7
8
  // Best-effort `git check-ignore` for a path. Returns true only when git
8
9
  // definitively reports the path is ignored; any failure (not a repo, git
@@ -579,7 +580,7 @@ function prepareMoveIndex(source, target, repoRoot, before, options = {}) {
579
580
  const paths = [source, target].map(candidate => path.isAbsolute(candidate) ? path.relative(repoRoot, candidate) : candidate);
580
581
  assertSafeGitPaths(paths);
581
582
  const { indexDir } = gitIndexLocations(repoRoot);
582
- const preparedPath = path.join(indexDir, `.dotmd-index-${process.pid}-${randomUUID()}`);
583
+ const preparedPath = path.join(indexDir, `${ARTIFACT_PREFIX}index-${process.pid}-${randomUUID()}`);
583
584
  const artifactPath = options.artifactPath ?? preparedPath;
584
585
  writeGeneration(artifactPath, before);
585
586
  let seed = { ...describePrepared(artifactPath, before), state: 'preparing', tempPath: preparedPath, work: null };
@@ -690,7 +691,7 @@ function publishIndexGeneration(repoRoot, expected, desired, prepared, testHooks
690
691
  function publishIndexGenerationLocked(repoRoot, expected, desired, prepared, testHooks, notePublished) {
691
692
  const { indexPath, indexDir, lockPath } = gitIndexLocations(repoRoot);
692
693
  if (expected.indexPath !== indexPath || desired.indexPath !== indexPath) throw new Error('Selected Git index changed since the transaction snapshot; recovery refused to target a different index.');
693
- if (!prepared || prepared.state !== 'prepared' || path.dirname(prepared.tempPath) !== indexDir || !path.basename(prepared.tempPath).startsWith('.dotmd-index-') || !preparedMatches(prepared)) throw new Error('Prepared Git index ownership could not be verified.');
694
+ if (!prepared || prepared.state !== 'prepared' || path.dirname(prepared.tempPath) !== indexDir || !isOwnedArtifact(path.basename(prepared.tempPath), 'index') || !preparedMatches(prepared)) throw new Error('Prepared Git index ownership could not be verified.');
694
695
  if ((desired.exists && (prepared.hash !== desired.hash || prepared.size !== desired.size || prepared.mode !== desired.mode)) || (!desired.exists && prepared.size !== 0)) throw new Error('Prepared Git index artifact does not match the desired generation.');
695
696
  const publication = prepared.work ?? prepared;
696
697
  if (!preparedMatches(publication) || (desired.exists && (publication.hash !== desired.hash || publication.size !== desired.size || publication.mode !== desired.mode)) || (!desired.exists && publication.size !== 0)) throw new Error('Selected-directory Git working index does not match the desired generation.');
@@ -766,12 +767,12 @@ export function stageMovePathsCas(source, target, repoRoot, before, options = {}
766
767
 
767
768
  export function restoreGitIndexCas(before, ownedAfter, repoRoot, options = {}) {
768
769
  const { indexDir } = gitIndexLocations(repoRoot);
769
- const preparedPath = options.artifactPath ?? path.join(indexDir, `.dotmd-index-restore-${process.pid}-${randomUUID()}`);
770
+ const preparedPath = options.artifactPath ?? path.join(indexDir, `${ARTIFACT_PREFIX}index-restore-${process.pid}-${randomUUID()}`);
770
771
  let artifact = null;
771
772
  try {
772
773
  writeGeneration(preparedPath, before);
773
774
  const prepared = before;
774
- const tempPath = path.join(indexDir, `.dotmd-index-restore-${process.pid}-${randomUUID()}`);
775
+ const tempPath = path.join(indexDir, `${ARTIFACT_PREFIX}index-restore-${process.pid}-${randomUUID()}`);
775
776
  artifact = { ...describePrepared(preparedPath, prepared), state: 'preparing', tempPath, work: null };
776
777
  options.testHooks?.afterGitRestoreArtifact?.({ before, ownedAfter, prepared: artifact });
777
778
  writeGeneration(tempPath, before);
@@ -802,7 +803,7 @@ export function reclaimPreparedGitIndex(manifestGitIndex, repoRoot, options = {}
802
803
  const retainedPaths = [];
803
804
  const { indexPath, indexDir, lockPath } = gitIndexLocations(repoRoot);
804
805
  if (manifestGitIndex.before?.indexPath !== indexPath || prepared.generation?.indexPath !== indexPath) throw new Error('Recovery environment selects a different Git index than the abandoned transaction.');
805
- if (path.dirname(prepared.tempPath) !== indexDir || !path.basename(prepared.tempPath).startsWith('.dotmd-index-')) throw new Error('Abandoned prepared Git index path is unsafe.');
806
+ if (path.dirname(prepared.tempPath) !== indexDir || !isOwnedArtifact(path.basename(prepared.tempPath), 'index')) throw new Error('Abandoned prepared Git index path is unsafe.');
806
807
  if (!existsSync(prepared.path)) {
807
808
  // The lock is only ever ours as a hard link to the publication inode, so a
808
809
  // lock that does not carry that inode cannot be ours — it belongs to a live
package/src/guard.mjs CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { inspectGitCommandPaths } from './git.mjs';
5
5
  import { recordGuardEvent } from './journal.mjs';
6
+ import { readEnv } from './naming.mjs';
6
7
 
7
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
9
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
@@ -348,7 +349,7 @@ function evalEdit(input, config, deps = {}) {
348
349
  // Pure evaluation — `deps.isIgnored(path) -> bool` is injected so tests don't
349
350
  // need a real git tree. Returns null (no opinion) or a result object.
350
351
  export function evaluateGuard(payload, config, deps = {}) {
351
- if (process.env.DOTMD_GUARD === '0') return null;
352
+ if (readEnv('GUARD') === '0') return null;
352
353
  if (!config?.configFound) return null;
353
354
  const tool = payload?.tool_name;
354
355
  const input = payload?.tool_input || {};
package/src/hints.mjs CHANGED
@@ -1,13 +1,14 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readJournalEntries, isJournalEnabled, journalFilePath } from './journal.mjs';
3
3
  import { currentSessionId } from './util.mjs';
4
+ import { readEnv } from './naming.mjs';
4
5
 
5
6
  // F17c: repeat-failure hints. When an agent runs the same broken invocation
6
7
  // twice in the same session within HINT_WINDOW_MS, the second die() output is
7
8
  // suffixed with a Tip: paragraph informed by the prior failure's recorded
8
9
  // stderr. First failures stay terse — don't punish humans typing a command
9
10
  // for the first time. The lookup is skipped cleanly when the journal is
10
- // disabled or DOTMD_NO_HINTS=1, so this costs nothing for non-opt-in users.
11
+ // disabled or RUNLIST_NO_HINTS=1, so this costs nothing for non-opt-in users.
11
12
 
12
13
  const HINT_WINDOW_MS = 10 * 60 * 1000;
13
14
  const OVERLAP_THRESHOLD = 0.75;
@@ -83,7 +84,7 @@ function jaccard(a, b) {
83
84
  // journal must never break the error-reporting path.
84
85
  export function findRepeatFailureHint(failingArgv, config) {
85
86
  try {
86
- if (process.env.DOTMD_NO_HINTS === '1') return null;
87
+ if (readEnv('NO_HINTS') === '1') return null;
87
88
  if (!config) return null;
88
89
  if (!isJournalEnabled(config)) return null;
89
90
  if (!existsSync(journalFilePath(config))) return null;
@@ -22,6 +22,7 @@ import os from 'node:os';
22
22
  import path from 'node:path';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  import { hostSessionSource } from './util.mjs';
25
+ import { readEnv, stateDir } from './naming.mjs';
25
26
 
26
27
  export const GENERATED_MARKER = 'dotmd-generated:';
27
28
  const PLUGIN_FILENAME = 'dotmd.js';
@@ -137,8 +138,8 @@ export function describeSessionIdentity(opts = {}) {
137
138
  id: null, scope: 'none', source: null, host: null,
138
139
  summary: 'no session identity — `use`, `set`, `baton` and `archive` fail closed',
139
140
  advice: underOpencode
140
- ? ['dotmd install opencode', 'or set DOTMD_SESSION_ID for this shell']
141
- : ['set DOTMD_SESSION_ID for this shell or host session', 'known hosts: dotmd install'],
141
+ ? ['runlist install opencode', 'or set RUNLIST_SESSION_ID for this shell']
142
+ : ['set RUNLIST_SESSION_ID for this shell or host session', 'known hosts: runlist install'],
142
143
  };
143
144
  }
144
145
 
@@ -153,7 +154,7 @@ export function describeSessionIdentity(opts = {}) {
153
154
  ? 'restart OpenCode — this session predates the installed integration'
154
155
  : 'dotmd install opencode — for a per-session identity');
155
156
  } else if (source.scope === 'terminal') {
156
- advice.push('every agent session in this terminal shares this id — set DOTMD_SESSION_ID per session');
157
+ advice.push('every agent session in this terminal shares this id — set RUNLIST_SESSION_ID per session');
157
158
  }
158
159
  return {
159
160
  id: source.id,
@@ -179,19 +180,19 @@ export function describeSessionIdentity(opts = {}) {
179
180
  // OpenCode in the first place, and punish a setup that is working.
180
181
  //
181
182
  // Once per session per repo, via a marker under the gitignored .dotmd/. Silent
182
- // under DOTMD_NO_HINTS=1, the switch the repeat-failure hints already use.
183
+ // under RUNLIST_NO_HINTS=1, the switch the repeat-failure hints already use.
183
184
  const NOTICE_DIR = 'notices';
184
185
 
185
186
  function noticeMarkerPath(repoRoot, key) {
186
187
  const digest = createHash('sha256').update(key).digest('hex').slice(0, 32);
187
- return path.join(path.resolve(repoRoot), '.dotmd', NOTICE_DIR, `${digest}`);
188
+ return path.join(stateDir(repoRoot), NOTICE_DIR, `${digest}`);
188
189
  }
189
190
 
190
191
  // Returns the notice text the first time it applies in a session, then null.
191
192
  // `record: false` answers without consuming the once-per-session budget.
192
193
  export function degradedIdentityNotice(repoRoot, opts = {}) {
193
194
  const { env = process.env, homedir = os.homedir(), version, record = true } = opts;
194
- if (env.DOTMD_NO_HINTS === '1') return null;
195
+ if (readEnv('NO_HINTS', env) === '1') return null;
195
196
  // Cheap env check first: a user on any other host pays nothing for this.
196
197
  if (!env.OPENCODE && !env.OPENCODE_PID) return null;
197
198
 
package/src/init.mjs CHANGED
@@ -36,7 +36,7 @@ function detectSessionStartHook(cwd) {
36
36
  for (const entry of sessionStart) {
37
37
  const inner = Array.isArray(entry?.hooks) ? entry.hooks : [];
38
38
  for (const hook of inner) {
39
- if (typeof hook?.command === 'string' && /\bdotmd\s+hud\b/.test(hook.command)) {
39
+ if (typeof hook?.command === 'string' && /\b(?:runlist|dotmd)\s+hud\b/.test(hook.command)) {
40
40
  const rel = file.startsWith(cwd) ? path.relative(cwd, file) : file;
41
41
  return { wired: true, file: rel };
42
42
  }
@@ -46,8 +46,8 @@ function detectSessionStartHook(cwd) {
46
46
  return { wired: false };
47
47
  }
48
48
 
49
- const STARTER_CONFIG = `// dotmd.config.mjs — document management configuration
50
- // All exports are optional. See dotmd.config.example.mjs for full reference.
49
+ const STARTER_CONFIG = `// runlist.config.mjs — document management configuration
50
+ // All exports are optional. See runlist.config.example.mjs for full reference.
51
51
 
52
52
  export const root = 'docs';
53
53
 
@@ -212,7 +212,7 @@ const KNOWN_STALE_DAYS = {
212
212
  };
213
213
 
214
214
  function generateDetectedConfig(scan, rootPath) {
215
- const lines = [`// dotmd.config.mjs — auto-detected from ${scan.docCount} existing docs`, ''];
215
+ const lines = [`// runlist.config.mjs — auto-detected from ${scan.docCount} existing docs`, ''];
216
216
  lines.push(`export const root = '${rootPath}';`);
217
217
  lines.push('');
218
218
 
@@ -274,7 +274,7 @@ function generateDetectedConfig(scan, rootPath) {
274
274
 
275
275
  export async function runInit(cwd, config, opts = {}) {
276
276
  const { dryRun = false } = opts;
277
- const configPath = path.join(cwd, 'dotmd.config.mjs');
277
+ const configPath = path.join(cwd, 'runlist.config.mjs');
278
278
  const docsDir = path.join(cwd, 'docs');
279
279
  const indexPath = path.join(docsDir, 'docs.md');
280
280
 
@@ -288,14 +288,14 @@ export async function runInit(cwd, config, opts = {}) {
288
288
  const scan = existsSync(docsDir) ? scanExistingDocs(docsDir) : null;
289
289
 
290
290
  if (existsSync(configPath)) {
291
- process.stdout.write(` ${dryTag}${dim('exists')} dotmd.config.mjs\n`);
291
+ process.stdout.write(` ${dryTag}${dim('exists')} ${path.basename(config.configPath)}\n`);
292
292
  } else {
293
293
  if (scan && scan.docCount > 0) {
294
294
  if (!dryRun) writeFileSync(configPath, generateDetectedConfig(scan, 'docs'), 'utf8');
295
- process.stdout.write(` ${dryTag}${green('create')} dotmd.config.mjs (detected ${scan.docCount} docs)\n`);
295
+ process.stdout.write(` ${dryTag}${green('create')} runlist.config.mjs (detected ${scan.docCount} docs)\n`);
296
296
  } else {
297
297
  if (!dryRun) writeFileSync(configPath, STARTER_CONFIG, 'utf8');
298
- process.stdout.write(` ${dryTag}${green('create')} dotmd.config.mjs\n`);
298
+ process.stdout.write(` ${dryTag}${green('create')} runlist.config.mjs\n`);
299
299
  }
300
300
  }
301
301
 
@@ -377,9 +377,10 @@ export async function runInit(cwd, config, opts = {}) {
377
377
  process.stdout.write(` export const root = [${subs.map(s => `'${s}'`).join(', ')}];\n`);
378
378
  }
379
379
 
380
- // .gitignore: two rules.
380
+ // .gitignore: three rules.
381
381
  //
382
- // .dotmd/ — session ownership records
382
+ // .runlist/ — session ownership records
383
+ // .dotmd/ — legacy state during the compatibility window
383
384
  // <docs>/prompts/*.md — the LIVE saved-prompt queue
384
385
  //
385
386
  // The second one is load-bearing. Saved prompts are session-local by design and
@@ -392,6 +393,7 @@ export async function runInit(cwd, config, opts = {}) {
392
393
  const docsRel = path.relative(cwd, docsDir).split(path.sep).join('/');
393
394
  const promptsIgnore = `/${docsRel ? `${docsRel}/` : ''}prompts/*.md`;
394
395
  const ignoreRules = [
396
+ { line: '.runlist/', accepts: (l) => l === '.runlist/' || l === '.runlist' },
395
397
  { line: '.dotmd/', accepts: (l) => l === '.dotmd/' || l === '.dotmd' },
396
398
  { line: promptsIgnore, accepts: (l) => l === promptsIgnore || l === promptsIgnore.slice(1) },
397
399
  ];
@@ -32,7 +32,7 @@ export function runJournal(argv, config) {
32
32
  if (!existsSync(file)) {
33
33
  if (!isJournalEnabled(config)) {
34
34
  process.stderr.write(
35
- 'Journal is opt-in. Enable with `DOTMD_JOURNAL=1` (env) or `journal: true` (in dotmd.config.mjs).\n',
35
+ 'Journal is opt-in. Enable with `RUNLIST_JOURNAL=1` (env) or `journal: true` (in runlist.config.mjs).\n',
36
36
  );
37
37
  return;
38
38
  }
package/src/journal.mjs CHANGED
@@ -2,8 +2,8 @@ import { existsSync, mkdirSync, appendFileSync, statSync, renameSync, readFileSy
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { currentSessionId } from './util.mjs';
5
+ import { readEnv, stateDir } from './naming.mjs';
5
6
 
6
- const JOURNAL_DIR = '.dotmd';
7
7
  const JOURNAL_FILE = 'journal.jsonl';
8
8
  const JOURNAL_BACKUP = 'journal.jsonl.1';
9
9
  const ROTATE_SIZE_BYTES = 5 * 1024 * 1024;
@@ -118,17 +118,17 @@ export function sanitizeTelemetryText(value, secrets = []) {
118
118
  }
119
119
 
120
120
  export function isJournalEnabled(config) {
121
- if (process.env.DOTMD_JOURNAL === '1') return true;
122
- if (process.env.DOTMD_JOURNAL === '0') return false;
121
+ if (readEnv('JOURNAL') === '1') return true;
122
+ if (readEnv('JOURNAL') === '0') return false;
123
123
  return config?.journal === true;
124
124
  }
125
125
 
126
126
  export function journalFilePath(config) {
127
- return path.join(config.repoRoot, JOURNAL_DIR, JOURNAL_FILE);
127
+ return path.join(stateDir(config.repoRoot), JOURNAL_FILE);
128
128
  }
129
129
 
130
130
  export function journalBackupPath(config) {
131
- return path.join(config.repoRoot, JOURNAL_DIR, JOURNAL_BACKUP);
131
+ return path.join(stateDir(config.repoRoot), JOURNAL_BACKUP);
132
132
  }
133
133
 
134
134
  function firstEntry(file) {
@@ -206,7 +206,7 @@ export function appendJournalEntry(config, entry) {
206
206
  ...(Array.isArray(entry?.argv) ? { argv: sanitized.argv } : {}),
207
207
  ...(entry?.err ? { err: sanitizeTelemetryText(entry.err, sanitized.secrets) } : {}),
208
208
  };
209
- const dir = path.join(config.repoRoot, JOURNAL_DIR);
209
+ const dir = stateDir(config.repoRoot);
210
210
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
211
211
  const file = journalFilePath(config);
212
212
  maybeRotate(file, journalBackupPath(config), safeEntry);
@@ -267,11 +267,11 @@ export function recordCliInvocation({ config, startMs, args, err, version }) {
267
267
 
268
268
  // Global error log: always-on, cross-repo, captured per failed invocation.
269
269
  // Independent of `isJournalEnabled` so silent failures stop disappearing.
270
- // DOTMD_ERROR_LOG_DIR overrides the default location (for tests, or for
270
+ // RUNLIST_ERROR_LOG_DIR overrides the default location (for tests, or for
271
271
  // users who want the log somewhere other than ~/.claude/logs).
272
272
 
273
273
  export function globalErrorLogDir() {
274
- return process.env.DOTMD_ERROR_LOG_DIR || path.join(os.homedir(), '.claude', 'logs');
274
+ return readEnv('ERROR_LOG_DIR') || path.join(os.homedir(), '.claude', 'logs');
275
275
  }
276
276
 
277
277
  export function globalErrorLogPath() {
package/src/naming.mjs ADDED
@@ -0,0 +1,55 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // Canonical product-owned names and the spellings accepted during migration.
5
+ // Writers use the current names. Readers accept both so a new build can safely
6
+ // recover work left by an older build. Migration is intentionally one-way.
7
+ export const PRODUCT_NAME = 'runlist';
8
+ export const LEGACY_PRODUCT_NAME = 'dotmd';
9
+
10
+ export const STATE_DIR = '.runlist';
11
+ export const LEGACY_STATE_DIR = '.dotmd';
12
+
13
+ export const ARTIFACT_PREFIX = '.runlist-';
14
+ export const LEGACY_ARTIFACT_PREFIX = '.dotmd-';
15
+
16
+ export const CONFIG_FILENAMES = [
17
+ 'runlist.config.mjs',
18
+ '.runlist.config.mjs',
19
+ 'runlist.config.js',
20
+ 'dotmd.config.mjs',
21
+ '.dotmd.config.mjs',
22
+ 'dotmd.config.js',
23
+ ];
24
+
25
+ export const ENV_PREFIX = 'RUNLIST_';
26
+ export const LEGACY_ENV_PREFIX = 'DOTMD_';
27
+
28
+ // Prefer current state, but let an explicitly refused migration keep operating
29
+ // against the untouched legacy directory.
30
+ export function stateDir(repoRoot) {
31
+ const root = path.resolve(repoRoot);
32
+ const current = path.join(root, STATE_DIR);
33
+ if (existsSync(current)) return current;
34
+ const legacy = path.join(root, LEGACY_STATE_DIR);
35
+ if (existsSync(legacy)) return legacy;
36
+ return current;
37
+ }
38
+
39
+ export function isOwnedArtifact(basename, kind = '') {
40
+ const suffix = kind ? `${kind}-` : '';
41
+ return basename.startsWith(`${ARTIFACT_PREFIX}${suffix}`)
42
+ || basename.startsWith(`${LEGACY_ARTIFACT_PREFIX}${suffix}`);
43
+ }
44
+
45
+ export function isSidecarArtifact(basename, kind = '') {
46
+ const suffix = kind ? `${kind}-` : '';
47
+ return basename.includes(`${ARTIFACT_PREFIX}${suffix}`)
48
+ || basename.includes(`${LEGACY_ARTIFACT_PREFIX}${suffix}`);
49
+ }
50
+
51
+ export function readEnv(name, env = process.env) {
52
+ const current = env[`${ENV_PREFIX}${name}`];
53
+ if (current !== undefined) return current;
54
+ return env[`${LEGACY_ENV_PREFIX}${name}`];
55
+ }
package/src/pickup.mjs CHANGED
@@ -6,6 +6,7 @@ import os from 'node:os';
6
6
  import { currentProcessOwner, mutateFileSet, processOwnerLiveness, processStartIdentity, replaceSnapshot, snapshotFile, withPathLocks } from './atomic-mutation.mjs';
7
7
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
8
8
  import { asString, hostSessionId, relTime } from './util.mjs';
9
+ import { readEnv, stateDir } from './naming.mjs';
9
10
 
10
11
  export const OWNERSHIP_SCHEMA = 2;
11
12
  export const HOOK_DELIVERY_LEASE_MS = 30_000;
@@ -13,14 +14,14 @@ export const HOOK_DELIVERY_LEASE_MS = 30_000;
13
14
  export function authoritativeSessionId(env = process.env) {
14
15
  const id = hostSessionId(env);
15
16
  if (id) return id;
16
- // Name the host when we can recognize it. The generic "set DOTMD_SESSION_ID"
17
+ // Name the host when we can recognize it. The generic "set RUNLIST_SESSION_ID"
17
18
  // is the fallback of last resort, and a poor one to reach for first: exported
18
19
  // from a shell profile it gives every session in that shell ONE id, which is
19
20
  // the collision the ownership record exists to prevent.
20
21
  const host = env.OPENCODE || env.OPENCODE_PID ? 'opencode' : null;
21
22
  throw new Error(host
22
- ? `No authoritative session identity. Run \`dotmd install ${host}\` to give each ${host} session its own, or set DOTMD_SESSION_ID for this shell.`
23
- : 'No authoritative session identity. Set DOTMD_SESSION_ID for this shell or host session, or see `dotmd install` for supported hosts.');
23
+ ? `No authoritative session identity. Run \`runlist install ${host}\` to give each ${host} session its own, or set RUNLIST_SESSION_ID for this shell.`
24
+ : 'No authoritative session identity. Set RUNLIST_SESSION_ID for this shell or host session, or see `runlist install` for supported hosts.');
24
25
  }
25
26
 
26
27
  export function availableSessionId(env = process.env) {
@@ -38,7 +39,7 @@ export function availableSessionId(env = process.env) {
38
39
  // `OPENCODE_PID` is the OpenCode server process, which is exactly that harness:
39
40
  // it hosts the session and outlives every tool shell it spawns.
40
41
  export function sessionProcessOwner(env = process.env) {
41
- const raw = (env.DOTMD_SESSION_PID ?? env.CLAUDE_PID ?? env.OPENCODE_PID)?.trim();
42
+ const raw = (readEnv('SESSION_PID', env) ?? env.CLAUDE_PID ?? env.OPENCODE_PID)?.trim();
42
43
  const pid = Number(raw);
43
44
  if (!raw || !Number.isInteger(pid) || pid <= 0) return null;
44
45
  return {
@@ -116,7 +117,7 @@ export function classifyPlanPickup(facts) {
116
117
  }
117
118
 
118
119
  function ownershipRoot(config) {
119
- return path.join(config.repoRoot, '.dotmd', 'ownership');
120
+ return path.join(stateDir(config.repoRoot), 'ownership');
120
121
  }
121
122
 
122
123
  export function canonicalPlanIdentity(filePath, config) {
package/src/prompts.mjs CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  readPlanOwnership,
17
17
  } from './pickup.mjs';
18
18
  import { actionablePromptStatuses, comparePromptDocs } from './status-metadata.mjs';
19
+ import { LEGACY_STATE_DIR, STATE_DIR } from './naming.mjs';
19
20
 
20
21
  // `resume` is an alias for `use` — agents reach for "resume" when continuing a
21
22
  // session; `use` reads as internal mechanics. Both names stay valid; the
@@ -330,8 +331,10 @@ export async function consumePrompt(filePath, config, opts) {
330
331
  ...(linkedClaim?.planChanged ? [linkedClaim.repoPath] : []),
331
332
  ...(archiveResult?.referencePaths ?? []),
332
333
  ].filter(Boolean).map(normalize);
333
- const ownershipResultPaths = resultPaths.filter(candidate => candidate.startsWith('.dotmd/ownership/'));
334
- const repositoryFiles = [...new Set(resultPaths.filter(candidate => !candidate.startsWith('.dotmd/ownership/')))];
334
+ const isOwnershipPath = candidate => [STATE_DIR, LEGACY_STATE_DIR]
335
+ .some(dir => candidate.startsWith(`${dir}/ownership/`));
336
+ const ownershipResultPaths = resultPaths.filter(isOwnershipPath);
337
+ const repositoryFiles = [...new Set(resultPaths.filter(candidate => !isOwnershipPath(candidate)))];
335
338
  const sessionFiles = [...new Set([
336
339
  repoPath,
337
340
  consumedPath,
@@ -37,7 +37,7 @@ export class AmbiguousReferenceError extends Error {
37
37
  constructor(token, sourcePath, local, repository) {
38
38
  super(`Ambiguous reference '${token}' in ${sourcePath}: document-relative resolves to ${local}, repository-relative resolves to ${repository}. Use an explicit unambiguous path.`);
39
39
  this.name = 'AmbiguousReferenceError';
40
- this.code = 'DOTMD_AMBIGUOUS_REFERENCE';
40
+ this.code = 'RUNLIST_AMBIGUOUS_REFERENCE';
41
41
  }
42
42
  }
43
43
 
package/src/ship.mjs CHANGED
@@ -20,6 +20,8 @@ const ALLOWLIST_PATTERNS = [
20
20
  /^plugins\//,
21
21
  /^\.claude-plugin\//,
22
22
  /^\.claude\/commands\//,
23
+ /^runlist\.config\.example\.mjs$/,
24
+ /^runlist\.config\.mjs$/,
23
25
  /^dotmd\.config\.example\.mjs$/,
24
26
  /^dotmd\.config\.mjs$/,
25
27
  /^package(?:-lock)?\.json$/,
@@ -0,0 +1,123 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ renameSync,
7
+ rmSync,
8
+ rmdirSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from 'node:fs';
12
+ import path from 'node:path';
13
+ import { processOwnerLiveness } from './atomic-mutation.mjs';
14
+ import { LEGACY_STATE_DIR, STATE_DIR } from './naming.mjs';
15
+
16
+ export { stateDir } from './naming.mjs';
17
+
18
+ const MIGRATION_MARKER = 'migrated-from-dotmd.json';
19
+ const DROP_ENTRIES = new Set(['handoffs']);
20
+ const TERMINAL_TRANSACTION_STATUSES = new Set(['committed', 'rolled-back', 'failed-manual']);
21
+
22
+ function heldLocks(lockRoot) {
23
+ if (!existsSync(lockRoot)) return [];
24
+ let entries;
25
+ try { entries = readdirSync(lockRoot); }
26
+ catch { return [{ lock: path.basename(lockRoot), status: 'unreadable-directory' }]; }
27
+ const held = [];
28
+ for (const entry of entries) {
29
+ const ownerPath = path.join(lockRoot, entry, 'owner.json');
30
+ if (!existsSync(ownerPath)) {
31
+ held.push({ lock: entry, status: 'owner-missing' });
32
+ continue;
33
+ }
34
+ let owner;
35
+ try { owner = JSON.parse(readFileSync(ownerPath, 'utf8')); }
36
+ catch {
37
+ held.push({ lock: entry, status: 'owner-unreadable' });
38
+ continue;
39
+ }
40
+ if (processOwnerLiveness(owner) !== 'dead') {
41
+ held.push({ lock: entry, status: 'held-or-unverifiable', pid: owner.pid, hostname: owner.hostname });
42
+ }
43
+ }
44
+ return held;
45
+ }
46
+
47
+ function unresolvedTransactions(transactionRoot) {
48
+ if (!existsSync(transactionRoot)) return [];
49
+ let entries;
50
+ try { entries = readdirSync(transactionRoot); }
51
+ catch { return [{ id: path.basename(transactionRoot), status: 'unreadable-directory' }]; }
52
+ const open = [];
53
+ for (const entry of entries) {
54
+ const manifestPath = path.join(transactionRoot, entry, 'manifest.json');
55
+ if (!existsSync(manifestPath)) {
56
+ open.push({ id: entry, status: 'manifest-missing' });
57
+ continue;
58
+ }
59
+ let manifest;
60
+ try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); }
61
+ catch { open.push({ id: entry, status: 'unreadable' }); continue; }
62
+ if (!TERMINAL_TRANSACTION_STATUSES.has(manifest.status)) {
63
+ open.push({ id: entry, status: manifest.status ?? 'unknown' });
64
+ }
65
+ }
66
+ return open;
67
+ }
68
+
69
+ function moveEntry(from, to) {
70
+ if (existsSync(to)) {
71
+ if (statSync(to).isDirectory() && statSync(from).isDirectory()) {
72
+ for (const child of readdirSync(from)) moveEntry(path.join(from, child), path.join(to, child));
73
+ rmSync(from, { recursive: true, force: true });
74
+ return;
75
+ }
76
+ // Current state wins an exact-name collision. The legacy copy is stale by
77
+ // definition and retaining both would make the next migration ambiguous.
78
+ rmSync(from, { recursive: true, force: true });
79
+ return;
80
+ }
81
+ renameSync(from, to);
82
+ }
83
+
84
+ export function migrateStateDirectory(repoRoot, options = {}) {
85
+ const root = path.resolve(repoRoot);
86
+ const from = path.join(root, LEGACY_STATE_DIR);
87
+ const to = path.join(root, STATE_DIR);
88
+ if (!existsSync(from)) return { status: 'noop', reason: 'nothing-to-migrate', from, to };
89
+
90
+ const held = heldLocks(path.join(from, 'locks'));
91
+ if (held.length) {
92
+ return {
93
+ status: 'refused', reason: 'locks-held', detail: held, from, to,
94
+ message: `Legacy state has ${held.length} lock${held.length === 1 ? '' : 's'} that ${held.length === 1 ? 'is' : 'are'} not provably abandoned. State migration refused; retry after active work finishes or inspect with \`runlist doctor --transactions\`.`,
95
+ };
96
+ }
97
+
98
+ const open = unresolvedTransactions(path.join(from, 'transactions'));
99
+ if (open.length) {
100
+ return {
101
+ status: 'refused', reason: 'transactions-unresolved', detail: open, from, to,
102
+ message: `${open.length} transaction${open.length === 1 ? ' is' : 's are'} not terminal (${open.map(item => `${item.id}: ${item.status}`).join(', ')}). State migration refused. Resolve with \`runlist doctor --transactions --apply\`, then retry.`,
103
+ };
104
+ }
105
+
106
+ if (options.dryRun) return { status: 'noop', reason: 'dry-run', from, to };
107
+
108
+ mkdirSync(to, { recursive: true });
109
+ for (const entry of readdirSync(from)) {
110
+ if (DROP_ENTRIES.has(entry)) {
111
+ rmSync(path.join(from, entry), { recursive: true, force: true });
112
+ continue;
113
+ }
114
+ moveEntry(path.join(from, entry), path.join(to, entry));
115
+ }
116
+ try { if (readdirSync(from).length === 0) rmdirSync(from); } catch {}
117
+
118
+ writeFileSync(
119
+ path.join(to, MIGRATION_MARKER),
120
+ JSON.stringify({ from: LEGACY_STATE_DIR, to: STATE_DIR, migratedAt: new Date().toISOString() }, null, 2) + '\n',
121
+ );
122
+ return { status: 'migrated', from, to };
123
+ }
package/src/util.mjs CHANGED
@@ -22,6 +22,7 @@ import { dim } from './color.mjs';
22
22
  // tell two of them apart. `dotmd doctor --session` reports that distinction, and
23
23
  // it is the whole reason `dotmd install opencode` exists.
24
24
  const SESSION_ID_SOURCES = [
25
+ { variable: 'RUNLIST_SESSION_ID', prefix: null, scope: 'session', host: 'explicit override' },
25
26
  { variable: 'DOTMD_SESSION_ID', prefix: null, scope: 'session', host: 'explicit override' },
26
27
  { variable: 'CLAUDE_CODE_SESSION_ID', prefix: null, scope: 'session', host: 'Claude Code' },
27
28
  { variable: 'CLAUDE_SESSION_ID', prefix: null, scope: 'session', host: 'Claude Code' },
package/src/watch.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
5
5
  import { dim } from './color.mjs';
6
6
 
7
7
  export function watchCliPath(moduleUrl = import.meta.url) {
8
- return path.resolve(path.dirname(fileURLToPath(moduleUrl)), '..', 'bin', 'dotmd.mjs');
8
+ return path.resolve(path.dirname(fileURLToPath(moduleUrl)), '..', 'bin', 'runlist.mjs');
9
9
  }
10
10
 
11
11
  export function runWatch(argv, config) {