dotmd-cli 0.76.8 → 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/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/naming.mjs +55 -0
- package/src/pickup.mjs +6 -5
- package/src/prompts.mjs +5 -2
- package/src/reference-planner.mjs +1 -1
- 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
|
@@ -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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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)
|
|
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
|
|
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:
|
|
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:
|
|
403
|
+
- env: RUNLIST_JOURNAL=1
|
|
404
404
|
- config: \`export const journal = true;\` in dotmd.config.mjs
|
|
405
405
|
|
|
406
|
-
The env var beats config (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}`;
|
package/bin/runlist.mjs
ADDED
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dotmd-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.77.0",
|
|
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",
|
|
8
9
|
"dotmd": "bin/dotmd.mjs"
|
|
9
10
|
},
|
|
10
11
|
"exports": {
|
|
@@ -15,7 +16,7 @@
|
|
|
15
16
|
"src/",
|
|
16
17
|
"assets/",
|
|
17
18
|
"scripts/postinstall.mjs",
|
|
18
|
-
"
|
|
19
|
+
"runlist.config.example.mjs"
|
|
19
20
|
],
|
|
20
21
|
"keywords": [
|
|
21
22
|
"markdown",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
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 `
|
|
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. `
|
|
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 `
|
|
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):
|
|
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. `
|
|
84
|
-
// // pending prompts on session start; `
|
|
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 — `
|
|
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:
|
|
159
|
-
endMarker: '<!-- GENERATED:
|
|
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 (`
|
|
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 `
|
|
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. `
|
|
201
|
+
// Define new types or override builtins. `runlist new <type> <name>` looks here first.
|
|
202
202
|
//
|
|
203
203
|
// Properties:
|
|
204
|
-
// description: string — shown in `
|
|
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 |
|
|
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`,
|
|
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
|
-
// `
|
|
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):
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -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
|
|
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 {
|
package/src/atomic-mutation.mjs
CHANGED
|
@@ -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 = '
|
|
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 = '
|
|
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(
|
|
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)
|
|
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, '
|
|
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(
|
|
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
|
|
353
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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(
|
|
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(
|
|
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}
|
|
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)}
|
|
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
|
|
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 });
|
package/src/completions.mjs
CHANGED
|
@@ -7,9 +7,9 @@ const COMMAND_WORDS = Object.freeze(Object.fromEntries(
|
|
|
7
7
|
));
|
|
8
8
|
|
|
9
9
|
function bashCompletion() {
|
|
10
|
-
return `#
|
|
11
|
-
# Add to ~/.bashrc: eval "$(
|
|
12
|
-
|
|
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
|
|
42
|
+
complete -F _runlist runlist dotmd`;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
function zshCompletion() {
|
|
46
|
-
return `#
|
|
47
|
-
# Add to ~/.zshrc: eval "$(
|
|
48
|
-
|
|
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
|
|
84
|
+
compdef _runlist runlist dotmd`;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
export function runCompletions(argv) {
|
|
88
88
|
const shell = argv[0];
|
|
89
|
-
if (!shell) die('Usage:
|
|
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`);
|
package/src/config-edit.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Line-based, brace-aware editor for the `types.<typename>.statuses` block in
|
|
2
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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(/(\.[^.]+)$/,
|
|
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
|
|
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,
|
|
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() {
|
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,
|
|
@@ -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 = '
|
|
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', '
|
|
8
|
+
return path.resolve(path.dirname(fileURLToPath(moduleUrl)), '..', 'bin', 'runlist.mjs');
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export function runWatch(argv, config) {
|