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
|
@@ -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/extractors.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { maskInlineCodeSpans } from './markdown-code-spans.mjs';
|
|
2
|
+
|
|
1
3
|
export function extractFirstHeading(body) {
|
|
2
4
|
return body.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? null;
|
|
3
5
|
}
|
|
@@ -49,9 +51,7 @@ export function extractBodyLinks(body) {
|
|
|
49
51
|
// every one of them was also never checked for breakage. Masking to same-length
|
|
50
52
|
// filler keeps the link matchable while still neutralizing a link that is
|
|
51
53
|
// itself inside code (`[fake](x.md)` stays unmatched), and preserves offsets.
|
|
52
|
-
const stripped = body
|
|
53
|
-
.replace(/^```[\s\S]*?^```/gm, '')
|
|
54
|
-
.replace(/`[^`]+`/g, match => 'x'.repeat(match.length));
|
|
54
|
+
const stripped = maskInlineCodeSpans(body.replace(/^```[\s\S]*?^```/gm, ''));
|
|
55
55
|
const links = [];
|
|
56
56
|
// Supported inline destinations are a whitespace-free token (with Markdown
|
|
57
57
|
// backslash escapes) or an angle-bracket destination, plus an optional
|