dotmd-cli 0.76.7 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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() {
@@ -0,0 +1,47 @@
1
+ function maskRange(value, start, end) {
2
+ return value.slice(0, start)
3
+ + value.slice(start, end).replace(/[^\n]/g, 'x')
4
+ + value.slice(end);
5
+ }
6
+
7
+ // Markdown code spans close only on a backtick run of the same length as their
8
+ // opener. The mask is deliberately the same length as the source so consumers
9
+ // can inspect neutralized Markdown while applying any edits at source offsets.
10
+ // `state` lets the conservative multiline behavior used by the reference
11
+ // rewriter survive: after an unmatched opener, nothing is treated as prose
12
+ // until a compatible closer appears.
13
+ export function maskInlineCodeLine(line, state = { run: null }) {
14
+ const ranges = [];
15
+ const runs = [...line.matchAll(/`+/g)];
16
+ let index = 0;
17
+
18
+ if (state.run !== null) {
19
+ const closingIndex = runs.findIndex(candidate => candidate[0].length === state.run);
20
+ if (closingIndex === -1) return maskRange(line, 0, line.length);
21
+ const closing = runs[closingIndex];
22
+ ranges.push([0, closing.index + closing[0].length]);
23
+ index = closingIndex + 1;
24
+ state.run = null;
25
+ }
26
+
27
+ for (; index < runs.length; index++) {
28
+ const opening = runs[index];
29
+ const closingIndex = runs.findIndex((candidate, candidateIndex) =>
30
+ candidateIndex > index && candidate[0].length === opening[0].length);
31
+ if (closingIndex === -1) {
32
+ ranges.push([opening.index, line.length]);
33
+ state.run = opening[0].length;
34
+ break;
35
+ }
36
+ const closing = runs[closingIndex];
37
+ ranges.push([opening.index, closing.index + closing[0].length]);
38
+ index = closingIndex;
39
+ }
40
+
41
+ return ranges.reduceRight((masked, [start, end]) => maskRange(masked, start, end), line);
42
+ }
43
+
44
+ export function maskInlineCodeSpans(value) {
45
+ const state = { run: null };
46
+ return value.split('\n').map(line => maskInlineCodeLine(line, state)).join('\n');
47
+ }
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,
@@ -1,6 +1,7 @@
1
1
  import { realpathSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter } from './frontmatter.mjs';
4
+ import { maskInlineCodeLine } from './markdown-code-spans.mjs';
4
5
 
5
6
  function slash(value) { return value.split(path.sep).join('/'); }
6
7
 
@@ -36,7 +37,7 @@ export class AmbiguousReferenceError extends Error {
36
37
  constructor(token, sourcePath, local, repository) {
37
38
  super(`Ambiguous reference '${token}' in ${sourcePath}: document-relative resolves to ${local}, repository-relative resolves to ${repository}. Use an explicit unambiguous path.`);
38
39
  this.name = 'AmbiguousReferenceError';
39
- this.code = 'DOTMD_AMBIGUOUS_REFERENCE';
40
+ this.code = 'RUNLIST_AMBIGUOUS_REFERENCE';
40
41
  }
41
42
  }
42
43
 
@@ -233,29 +234,37 @@ function rewriteDestination(raw, args) {
233
234
  return parsed.angle ? `<${rendered}>` : rendered;
234
235
  }
235
236
 
236
- function rewriteMarkdownSegment(segment, args) {
237
- // Inline links: angle destinations, escaped whitespace, optional titles.
238
- let next = segment.replace(/(\[[^\]]*\]\(\s*)(<[^>\n]+>|(?:\\.|[^\s()])+)(\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?(\s*\))/g,
239
- (match, prefix, destination, title = '', close, offset) => {
240
- let escapes = 0;
241
- for (let index = offset - 1; index >= 0 && segment[index] === '\\'; index--) escapes++;
242
- if (escapes % 2 === 1) return match;
243
- const rewritten = rewriteDestination(destination, args);
244
- return rewritten === destination ? match : `${prefix}${rewritten}${title}${close}`;
245
- });
237
+ function rewriteMarkdownLine(line, masked, args) {
238
+ const edits = [];
239
+ const inline = /(\[[^\]]*\]\(\s*)(<[^>\n]+>|(?:\\.|[^\s()])+)(\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?(\s*\))/g;
240
+ let match;
241
+ while ((match = inline.exec(masked)) !== null) {
242
+ let escapes = 0;
243
+ for (let index = match.index - 1; index >= 0 && line[index] === '\\'; index--) escapes++;
244
+ if (escapes % 2 === 1) continue;
245
+ const start = match.index + match[1].length;
246
+ const destination = line.slice(start, start + match[2].length);
247
+ const rewritten = rewriteDestination(destination, args);
248
+ if (rewritten !== destination) edits.push({ start, end: start + destination.length, value: rewritten });
249
+ }
250
+
246
251
  // Reference definitions preserve labels, spacing, destinations, and titles.
247
- next = next.replace(/^(\s{0,3}\[[^\]]+\]:\s*)(<[^>\n]+>|(?:\\.|[^\s])+)(.*)$/,
248
- (match, prefix, destination, tail) => {
249
- const rewritten = rewriteDestination(destination, args);
250
- return rewritten === destination ? match : `${prefix}${rewritten}${tail}`;
251
- });
252
- return next;
252
+ const definition = /^(\s{0,3}\[[^\]]+\]:\s*)(<[^>\n]+>|(?:\\.|[^\s])+)(.*)$/.exec(masked);
253
+ if (definition) {
254
+ const start = definition[1].length;
255
+ const destination = line.slice(start, start + definition[2].length);
256
+ const rewritten = rewriteDestination(destination, args);
257
+ if (rewritten !== destination) edits.push({ start, end: start + destination.length, value: rewritten });
258
+ }
259
+
260
+ return edits.sort((left, right) => right.start - left.start)
261
+ .reduce((next, edit) => next.slice(0, edit.start) + edit.value + next.slice(edit.end), line);
253
262
  }
254
263
 
255
264
  function rewriteMarkdown(body, args) {
256
265
  const lines = body.split('\n');
257
266
  let fence = null;
258
- let inlineRun = null;
267
+ const inlineState = { run: null };
259
268
  let indentedCode = false;
260
269
  let htmlBlock = null;
261
270
  const containerContext = line => {
@@ -317,36 +326,7 @@ function rewriteMarkdown(body, args) {
317
326
  fence = { char: opener[1][0], length: opener[1].length, prefixes: container.prefixes };
318
327
  return line;
319
328
  }
320
- let output = '';
321
- let cursor = 0;
322
- const runs = [...line.matchAll(/`+/g)];
323
- let index = 0;
324
- if (inlineRun !== null) {
325
- const closingIndex = runs.findIndex(candidate => candidate[0].length === inlineRun);
326
- if (closingIndex === -1) return line;
327
- const closing = runs[closingIndex];
328
- output += line.slice(0, closing.index + closing[0].length);
329
- cursor = closing.index + closing[0].length;
330
- index = closingIndex + 1;
331
- inlineRun = null;
332
- }
333
- for (; index < runs.length; index++) {
334
- const opening = runs[index];
335
- const closingIndex = runs.findIndex((candidate, candidateIndex) => candidateIndex > index && candidate[0].length === opening[0].length);
336
- if (closingIndex === -1) {
337
- output += rewriteMarkdownSegment(line.slice(cursor, opening.index), args);
338
- output += line.slice(opening.index);
339
- inlineRun = opening[0].length;
340
- cursor = line.length;
341
- break;
342
- }
343
- const closing = runs[closingIndex];
344
- output += rewriteMarkdownSegment(line.slice(cursor, opening.index), args);
345
- output += line.slice(opening.index, closing.index + closing[0].length);
346
- cursor = closing.index + closing[0].length;
347
- index = closingIndex;
348
- }
349
- return output + rewriteMarkdownSegment(line.slice(cursor), args);
329
+ return rewriteMarkdownLine(line, maskInlineCodeLine(line, inlineState), args);
350
330
  }).join('\n');
351
331
  }
352
332
 
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$/,