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/assets/opencode/plugin.js +23 -14
- package/bin/dotmd.mjs +37 -13
- package/bin/runlist.mjs +6 -0
- package/package.json +3 -2
- package/{dotmd.config.example.mjs → runlist.config.example.mjs} +17 -17
- package/scripts/postinstall.mjs +2 -2
- package/src/atomic-mutation.mjs +19 -17
- package/src/commands.mjs +1 -1
- package/src/completions.mjs +9 -9
- package/src/config-edit.mjs +6 -5
- package/src/config.mjs +2 -3
- package/src/extractors.mjs +3 -3
- package/src/git.mjs +6 -5
- package/src/guard.mjs +2 -1
- package/src/hints.mjs +3 -2
- package/src/host-integration.mjs +7 -6
- package/src/init.mjs +12 -10
- package/src/journal-read.mjs +1 -1
- package/src/journal.mjs +8 -8
- package/src/markdown-code-spans.mjs +47 -0
- package/src/naming.mjs +55 -0
- package/src/pickup.mjs +6 -5
- package/src/prompts.mjs +5 -2
- package/src/reference-planner.mjs +28 -48
- package/src/ship.mjs +2 -0
- package/src/state-migration.mjs +123 -0
- package/src/util.mjs +1 -0
- package/src/watch.mjs +1 -1
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,
|
|
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)
|
|
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,
|
|
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,
|
|
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)
|
|
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 (
|
|
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
|
|
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 (
|
|
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;
|
package/src/host-integration.mjs
CHANGED
|
@@ -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
|
-
? ['
|
|
141
|
-
: ['set
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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' && /\
|
|
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 = `//
|
|
50
|
-
// All exports are optional. See
|
|
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 = [`//
|
|
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, '
|
|
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')}
|
|
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')}
|
|
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')}
|
|
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:
|
|
380
|
+
// .gitignore: three rules.
|
|
381
381
|
//
|
|
382
|
-
// .
|
|
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
|
];
|
package/src/journal-read.mjs
CHANGED
|
@@ -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 `
|
|
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 (
|
|
122
|
-
if (
|
|
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,
|
|
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,
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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 \`
|
|
23
|
-
: 'No authoritative session identity. Set
|
|
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
|
|
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, '
|
|
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
|
|
334
|
-
|
|
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 = '
|
|
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
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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$/,
|