dotmd-cli 0.78.0 → 0.79.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -63
- package/assets/opencode/plugin.js +11 -6
- package/bin/dotmd.mjs +272 -285
- package/package.json +2 -2
- package/scripts/postinstall.mjs +4 -4
- package/src/atomic-mutation.mjs +1 -1
- package/src/baton.mjs +7 -7
- package/src/check-collapse.mjs +5 -5
- package/src/claude-commands.mjs +6 -2
- package/src/commands.mjs +4 -4
- package/src/config.mjs +2 -2
- package/src/deps.mjs +1 -1
- package/src/doctor.mjs +17 -17
- package/src/fix-membership.mjs +4 -2
- package/src/frontmatter-fix.mjs +1 -1
- package/src/git.mjs +1 -1
- package/src/glossary.mjs +3 -3
- package/src/graph.mjs +1 -1
- package/src/guard.mjs +8 -8
- package/src/health.mjs +2 -2
- package/src/hints.mjs +7 -7
- package/src/host-integration.mjs +41 -22
- package/src/hub-membership.mjs +1 -1
- package/src/hud.mjs +14 -14
- package/src/index-file.mjs +2 -2
- package/src/init.mjs +24 -24
- package/src/install.mjs +4 -4
- package/src/journal.mjs +40 -7
- package/src/lifecycle.mjs +17 -17
- package/src/lint.mjs +1 -1
- package/src/migrate-prompts.mjs +1 -1
- package/src/migrate-template.mjs +2 -2
- package/src/migrate.mjs +1 -1
- package/src/misuse-read.mjs +4 -5
- package/src/modules.mjs +3 -3
- package/src/new.mjs +11 -11
- package/src/output-identity.mjs +7 -2
- package/src/pickup-card.mjs +2 -2
- package/src/pickup.mjs +2 -2
- package/src/prompts.mjs +12 -12
- package/src/query.mjs +9 -9
- package/src/rename.mjs +2 -2
- package/src/render.mjs +20 -20
- package/src/roadmap.mjs +5 -5
- package/src/runlist.mjs +11 -11
- package/src/ship.mjs +2 -2
- package/src/skill-drift.mjs +19 -6
- package/src/statuses.mjs +16 -16
- package/src/summary.mjs +1 -1
- package/src/surfaces.mjs +1 -1
- package/src/sync-status.mjs +4 -4
- package/src/update.mjs +11 -11
- package/src/validate.mjs +9 -9
- package/src/watch.mjs +1 -1
package/src/host-integration.mjs
CHANGED
|
@@ -25,7 +25,13 @@ import { hostSessionSource } from './util.mjs';
|
|
|
25
25
|
import { readEnv, stateDir } from './naming.mjs';
|
|
26
26
|
import { planMarketplaceRepair } from './update.mjs';
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// New files carry the current banner. Every reader accepts both: files written
|
|
29
|
+
// by dotmd-cli 0.78.0 and earlier carry the legacy banner, and those must stay
|
|
30
|
+
// recognizable as generated forever, or an old install turns into a "foreign"
|
|
31
|
+
// file that install/update/remove refuse to touch.
|
|
32
|
+
export const GENERATED_MARKER = 'runlist-generated:';
|
|
33
|
+
export const LEGACY_GENERATED_MARKER = 'dotmd-generated:';
|
|
34
|
+
const GENERATED_MARKERS = [GENERATED_MARKER, LEGACY_GENERATED_MARKER];
|
|
29
35
|
const PLUGIN_FILENAME = 'dotmd.js';
|
|
30
36
|
const ASSET = path.resolve(fileURLToPath(import.meta.url), '..', '..', 'assets', 'opencode', 'plugin.js');
|
|
31
37
|
|
|
@@ -58,8 +64,8 @@ export function opencodePluginPath(opts = {}) {
|
|
|
58
64
|
function banner(version) {
|
|
59
65
|
return [
|
|
60
66
|
`// ${GENERATED_MARKER}${version}`,
|
|
61
|
-
'// Generated by `
|
|
62
|
-
'// Hand edits are overwritten — remove with `
|
|
67
|
+
'// Generated by `runlist install opencode`. Refreshed by `runlist update`.',
|
|
68
|
+
'// Hand edits are overwritten — remove with `runlist install opencode --remove`.',
|
|
63
69
|
'',
|
|
64
70
|
].join('\n');
|
|
65
71
|
}
|
|
@@ -68,31 +74,44 @@ export function renderOpencodePlugin(version) {
|
|
|
68
74
|
return banner(version) + readFileSync(ASSET, 'utf8');
|
|
69
75
|
}
|
|
70
76
|
|
|
71
|
-
// The
|
|
72
|
-
// or carries
|
|
73
|
-
// is never written or removed — same rule the retired
|
|
74
|
-
// followed.
|
|
75
|
-
export function
|
|
77
|
+
// The banner a generated file carries — { marker, version } — or null when the
|
|
78
|
+
// file is absent or carries neither banner. A file without a banner is the
|
|
79
|
+
// user's own and is never written or removed — same rule the retired
|
|
80
|
+
// slash-command scaffolding followed. The earliest banner in the file wins.
|
|
81
|
+
export function readGeneratedBanner(filePath) {
|
|
76
82
|
let contents;
|
|
77
83
|
try { contents = readFileSync(filePath, 'utf8'); } catch { return null; }
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
let found = null;
|
|
85
|
+
for (const marker of GENERATED_MARKERS) {
|
|
86
|
+
const at = contents.indexOf(marker);
|
|
87
|
+
if (at >= 0 && (found === null || at < found.at)) found = { marker, at };
|
|
88
|
+
}
|
|
89
|
+
if (!found) return null;
|
|
90
|
+
const rest = contents.slice(found.at + found.marker.length);
|
|
91
|
+
return { marker: found.marker, version: rest.split('\n', 1)[0].trim() || null };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function installedVersion(filePath) {
|
|
95
|
+
return readGeneratedBanner(filePath)?.version ?? null;
|
|
82
96
|
}
|
|
83
97
|
|
|
84
98
|
export function opencodeStatus(opts = {}) {
|
|
85
99
|
const { version, ...rest } = opts;
|
|
86
100
|
const filePath = opencodePluginPath(rest);
|
|
87
101
|
const exists = existsSync(filePath);
|
|
88
|
-
const
|
|
102
|
+
const generated = exists ? readGeneratedBanner(filePath) : null;
|
|
103
|
+
const installed = generated?.version ?? null;
|
|
104
|
+
const legacyBanner = generated?.marker === LEGACY_GENERATED_MARKER;
|
|
89
105
|
return {
|
|
90
106
|
path: filePath,
|
|
91
107
|
exists,
|
|
92
108
|
version: installed,
|
|
93
109
|
// Present but unmarked: the user put a `dotmd.js` there themselves.
|
|
94
110
|
foreign: exists && installed === null,
|
|
95
|
-
stale
|
|
111
|
+
// A legacy banner is stale even at the same version, so the next install or
|
|
112
|
+
// update rewrites it in place with the current banner.
|
|
113
|
+
stale: installed !== null && ((version !== undefined && installed !== version) || legacyBanner),
|
|
114
|
+
legacyBanner,
|
|
96
115
|
};
|
|
97
116
|
}
|
|
98
117
|
|
|
@@ -110,7 +129,7 @@ export function installOpencodePlugin(opts = {}) {
|
|
|
110
129
|
const { version, dryRun = false, force = false, ...rest } = opts;
|
|
111
130
|
const status = opencodeStatus({ version, ...rest });
|
|
112
131
|
if (status.foreign && !force) {
|
|
113
|
-
return { ...status, action: 'refused', reason: 'a dotmd.js without a
|
|
132
|
+
return { ...status, action: 'refused', reason: 'a dotmd.js without a generated banner is already there — it was not written by runlist' };
|
|
114
133
|
}
|
|
115
134
|
if (status.exists && !status.stale && !status.foreign) {
|
|
116
135
|
return { ...status, action: 'current' };
|
|
@@ -153,7 +172,7 @@ export function describeSessionIdentity(opts = {}) {
|
|
|
153
172
|
// still carries the old identity — say so rather than leave a warning
|
|
154
173
|
// marker with no explanation.
|
|
155
174
|
? 'restart OpenCode — this session predates the installed integration'
|
|
156
|
-
: '
|
|
175
|
+
: 'runlist install opencode — for a per-session identity');
|
|
157
176
|
} else if (source.scope === 'terminal') {
|
|
158
177
|
advice.push('every agent session in this terminal shares this id — set RUNLIST_SESSION_ID per session');
|
|
159
178
|
}
|
|
@@ -202,11 +221,11 @@ export function degradedIdentityNotice(repoRoot, opts = {}) {
|
|
|
202
221
|
|
|
203
222
|
const installed = opencodeStatus({ env, homedir, version }).exists;
|
|
204
223
|
const lines = installed
|
|
205
|
-
? ['[
|
|
224
|
+
? ['[runlist] This OpenCode session started before the runlist integration loaded, so its plan',
|
|
206
225
|
' ownership is still process-scoped. Restart OpenCode to pick it up.']
|
|
207
|
-
: ['[
|
|
226
|
+
: ['[runlist] OpenCode detected, runlist integration not installed — plan ownership is scoped to',
|
|
208
227
|
' the OpenCode process, so another session in it can release plans this one claimed,',
|
|
209
|
-
' and no session-start briefing runs. Fix once:
|
|
228
|
+
' and no session-start briefing runs. Fix once: runlist install opencode'];
|
|
210
229
|
|
|
211
230
|
if (!record) return lines.join('\n');
|
|
212
231
|
if (!repoRoot) return lines.join('\n');
|
|
@@ -251,7 +270,7 @@ export function claudeMarketplaceRefusalHint(marketplace = 'dotmd') {
|
|
|
251
270
|
// the side effects stay in the caller.
|
|
252
271
|
export function planClaudeInstall({ installed, hasClaude, remove = false } = {}) {
|
|
253
272
|
if (remove) {
|
|
254
|
-
if (!installed) return [{ kind: 'skip', reason: '
|
|
273
|
+
if (!installed) return [{ kind: 'skip', reason: 'runlist plugin is not installed' }];
|
|
255
274
|
return hasClaude
|
|
256
275
|
? [{ kind: 'run', cmd: ['claude', 'plugin', 'uninstall', installed.id] }]
|
|
257
276
|
: [{ kind: 'manual', lines: [`/plugin uninstall ${installed.id}`] }];
|
|
@@ -263,7 +282,7 @@ export function planClaudeInstall({ installed, hasClaude, remove = false } = {})
|
|
|
263
282
|
return planMarketplaceRepair(installed, { hasClaude, verb: 'update' })
|
|
264
283
|
.map(step => (step.kind === 'marketplace' || step.kind === 'plugin' ? { ...step, kind: 'run', step: step.kind } : step));
|
|
265
284
|
}
|
|
266
|
-
if (installed) return [{ kind: 'skip', reason: `
|
|
285
|
+
if (installed) return [{ kind: 'skip', reason: `runlist plugin already installed (${installed.version ?? 'unknown version'})` }];
|
|
267
286
|
// The marketplace has to be registered before the plugin resolves; adding one
|
|
268
287
|
// already present is a no-op, so this stays safe to re-run.
|
|
269
288
|
if (!hasClaude) {
|
|
@@ -280,7 +299,7 @@ export function removeOpencodePlugin(opts = {}) {
|
|
|
280
299
|
const status = opencodeStatus(rest);
|
|
281
300
|
if (!status.exists) return { ...status, action: 'absent' };
|
|
282
301
|
if (status.foreign && !force) {
|
|
283
|
-
return { ...status, action: 'refused', reason: 'not a
|
|
302
|
+
return { ...status, action: 'refused', reason: 'not a runlist-generated file' };
|
|
284
303
|
}
|
|
285
304
|
if (!dryRun) rmSync(status.path, { force: true });
|
|
286
305
|
return { ...status, action: 'removed' };
|
package/src/hub-membership.mjs
CHANGED
|
@@ -273,7 +273,7 @@ export function checkHubMembershipDrift(docs, config) {
|
|
|
273
273
|
warnings.push({
|
|
274
274
|
path: child.path,
|
|
275
275
|
level: 'warning',
|
|
276
|
-
message: `is ranked in the body order of \`${hub.path}\` (the list \`
|
|
276
|
+
message: `is ranked in the body order of \`${hub.path}\` (the list \`runlist runlist next\` walks) but has no \`parent_plan:\`. Add \`parent_plan: ${hub.path}\` so reverse-link tooling (pickup-card Related:, graph) stays consistent.`,
|
|
277
277
|
meta: { kind: BACKREF_KIND, hub: hub.path, source: 'body-order' },
|
|
278
278
|
});
|
|
279
279
|
}
|
package/src/hud.mjs
CHANGED
|
@@ -33,8 +33,8 @@ export function detectVersionDrift(env = process.env) {
|
|
|
33
33
|
const pluginVersion = JSON.parse(readFileSync(pj, 'utf8')).version;
|
|
34
34
|
const cmp = compareVersions(pluginVersion, pkg.version);
|
|
35
35
|
if (cmp === null || cmp === 0) return null;
|
|
36
|
-
if (cmp < 0) return `
|
|
37
|
-
return `
|
|
36
|
+
if (cmp < 0) return `runlist plugin ${pluginVersion} is behind the CLI ${pkg.version} — run \`runlist update\` then restart.`;
|
|
37
|
+
return `runlist CLI ${pkg.version} is behind the plugin ${pluginVersion} — run \`runlist update\` (or npm i -g dotmd-cli).`;
|
|
38
38
|
} catch {
|
|
39
39
|
return null;
|
|
40
40
|
}
|
|
@@ -155,9 +155,9 @@ const MISUSE_RECAP_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
155
155
|
const MISUSE_RECAP_THRESHOLD = 3;
|
|
156
156
|
|
|
157
157
|
const MISUSE_CORRECTIONS = {
|
|
158
|
-
'edit-status': 'never hand-edit `status:`; use `
|
|
159
|
-
'cat-prompt': 'consume prompts with `
|
|
160
|
-
'read-prompt': 'consume prompts with `
|
|
158
|
+
'edit-status': 'never hand-edit `status:`; use `runlist set <status> <file>`',
|
|
159
|
+
'cat-prompt': 'consume prompts with `runlist use <file>`; peek without consuming via `runlist prompts show <file>`',
|
|
160
|
+
'read-prompt': 'consume prompts with `runlist use <file>`; peek without consuming via `runlist prompts show <file>`',
|
|
161
161
|
'commit-prompt': 'saved prompts are session-local; never git add/commit them',
|
|
162
162
|
};
|
|
163
163
|
|
|
@@ -176,7 +176,7 @@ export function buildMisuseRecap(config, now = Date.now()) {
|
|
|
176
176
|
const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
177
177
|
if (!top || top[1] < MISUSE_RECAP_THRESHOLD) return null;
|
|
178
178
|
const [rule, count] = top;
|
|
179
|
-
const fix = MISUSE_CORRECTIONS[rule] ?? 'see `
|
|
179
|
+
const fix = MISUSE_CORRECTIONS[rule] ?? 'see `runlist misuse`';
|
|
180
180
|
return `sessions here tripped ${rule} ${count}× this week — ${fix}`;
|
|
181
181
|
}
|
|
182
182
|
|
|
@@ -235,15 +235,15 @@ export function buildHud(config) {
|
|
|
235
235
|
// lines: the verbs + the three wrong-moves the guard exists to stop, so the
|
|
236
236
|
// subagent self-corrects before the guard ever has to fire.
|
|
237
237
|
const SUBAGENT_PRIMER = [
|
|
238
|
-
'
|
|
238
|
+
'runlist manages this repo\'s plans/docs/prompts (markdown + YAML frontmatter).',
|
|
239
239
|
'Verbs: plans|briefing | query <filters> | use [<file>] | set <status> <file> | new <type> <slug> | archive <file>.',
|
|
240
|
-
'Do NOT: cat/read a docs/prompts/*.md (use `
|
|
241
|
-
'git add/commit a prompt (they are session-local, often gitignored); hand-edit a `status:` field (use `
|
|
240
|
+
'Do NOT: cat/read a docs/prompts/*.md (use `runlist use <file>` — archive/claim commits before at-most-once output);',
|
|
241
|
+
'git add/commit a prompt (they are session-local, often gitignored); hand-edit a `status:` field (use `runlist set`).',
|
|
242
242
|
].join('\n');
|
|
243
243
|
|
|
244
244
|
export function buildPlanStatusPrimer(config, { maxChars = 220 } = {}) {
|
|
245
245
|
const statuses = (resolveStatusMetadata(config).byType.plan ?? []).map(item => item.name);
|
|
246
|
-
const fallback = 'run `
|
|
246
|
+
const fallback = 'run `runlist statuses list --type plan`';
|
|
247
247
|
if (statuses.length === 0) return `Plan statuses unavailable; ${fallback}.`;
|
|
248
248
|
const prefix = 'Plan statuses: ';
|
|
249
249
|
const full = `${prefix}${statuses.join(', ')}`;
|
|
@@ -313,15 +313,15 @@ export function runHud(argv, config) {
|
|
|
313
313
|
// Global in-session counts never provide a fallback.
|
|
314
314
|
// The misuse recap stays for the same reason: a repeat-offense rule means
|
|
315
315
|
// the primer alone isn't landing, so name the habit to break.
|
|
316
|
-
process.stdout.write(dim('
|
|
316
|
+
process.stdout.write(dim('runlist: plans|briefing set <status> [<file>] new <type> <slug> use [<file>] archive <file> baton [<slug>] <@<file>|-> (save a resume prompt; releases the in-session plan if any) (use [no-arg] → oldest pending prompt)') + '\n');
|
|
317
317
|
process.stdout.write(dim(buildPlanStatusPrimer(config)) + '\n');
|
|
318
318
|
if (hud.owned && hud.owned.via === 'ownership') {
|
|
319
|
-
process.stdout.write(yellow(`[
|
|
319
|
+
process.stdout.write(yellow(`[runlist] in-session (yours): ${hud.owned.path} — continue it; hand off with \`runlist baton @/tmp/draft.md\` before stopping.`) + '\n');
|
|
320
320
|
}
|
|
321
321
|
if (hud.prompts.length > 0) {
|
|
322
322
|
const n = hud.prompts.length;
|
|
323
|
-
process.stdout.write(yellow(`[
|
|
323
|
+
process.stdout.write(yellow(`[runlist] ${n} pending prompt${n === 1 ? '' : 's'} queued for this session — unless the user asks for something else, start by running \`runlist use\` to consume the oldest (${hud.prompts[0]}) and act on it. Peek first: \`runlist prompts show <file>\`; list: \`runlist prompts\`.`) + '\n');
|
|
324
324
|
}
|
|
325
|
-
if (hud.misuseRecap) process.stdout.write(yellow(`[
|
|
325
|
+
if (hud.misuseRecap) process.stdout.write(yellow(`[runlist] ${hud.misuseRecap}`) + '\n');
|
|
326
326
|
if (drift) process.stdout.write(yellow(drift) + '\n');
|
|
327
327
|
}
|
package/src/index-file.mjs
CHANGED
|
@@ -72,7 +72,7 @@ function renderArchivedSection(docs, config, status) {
|
|
|
72
72
|
lines.push(`| [${escapeTable(doc.title)}](${linkPath}) | ${escapeTable(formatSnapshot(doc, config))} |`);
|
|
73
73
|
}
|
|
74
74
|
lines.push('');
|
|
75
|
-
lines.push('- Use `
|
|
75
|
+
lines.push('- Use `runlist list` or `runlist json` for the full inventory.');
|
|
76
76
|
|
|
77
77
|
return lines;
|
|
78
78
|
}
|
|
@@ -141,7 +141,7 @@ export function checkIndex(docs, config, opts = {}) {
|
|
|
141
141
|
const index = { docs };
|
|
142
142
|
const expected = renderIndexFile(index, config);
|
|
143
143
|
if (expected !== current) {
|
|
144
|
-
errors.push({ path: config.indexPath, level: 'error', message: 'Generated index block is stale. Run `
|
|
144
|
+
errors.push({ path: config.indexPath, level: 'error', message: 'Generated index block is stale. Run `runlist index`.' });
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
return { warnings, errors };
|
package/src/init.mjs
CHANGED
|
@@ -87,40 +87,40 @@ status: planned
|
|
|
87
87
|
created: ${today}
|
|
88
88
|
updated: ${today}
|
|
89
89
|
title: Example Plan
|
|
90
|
-
summary: A scaffolded sample showing what a
|
|
91
|
-
current_state: "Scaffolded by \`
|
|
90
|
+
summary: A scaffolded sample showing what a runlist plan looks like — safe to delete.
|
|
91
|
+
current_state: "Scaffolded by \`runlist init\` as a shape reference. Nothing here is real work."
|
|
92
92
|
next_step: "Delete this file, or edit it into your first real plan."
|
|
93
93
|
---
|
|
94
94
|
|
|
95
95
|
# Example Plan
|
|
96
96
|
|
|
97
|
-
> A scaffolded sample showing what a
|
|
97
|
+
> A scaffolded sample showing what a runlist plan looks like — safe to delete.
|
|
98
98
|
|
|
99
99
|
## Problem
|
|
100
100
|
|
|
101
|
-
\`
|
|
101
|
+
\`runlist init\` leaves this file behind so \`docs/plans/\` survives a clone (git cannot
|
|
102
102
|
track an empty directory) and so the frontmatter above has something to point at.
|
|
103
103
|
|
|
104
104
|
The fields that matter: \`status\` drives every listing, \`current_state\` and
|
|
105
|
-
\`next_step\` are what \`
|
|
106
|
-
Never hand-edit \`status:\` — \`
|
|
105
|
+
\`next_step\` are what \`runlist briefing\` reads out, and \`updated\` drives staleness.
|
|
106
|
+
Never hand-edit \`status:\` — \`runlist set <status> <file>\` writes it, validates it
|
|
107
107
|
against the type, and runs the lifecycle hooks.
|
|
108
108
|
|
|
109
109
|
## Phases
|
|
110
110
|
|
|
111
|
-
- [ ] Delete this file: \`
|
|
112
|
-
- [ ] Write a real one: \`
|
|
111
|
+
- [ ] Delete this file: \`runlist archive docs/plans/${SAMPLE_PLAN_NAME}\`
|
|
112
|
+
- [ ] Write a real one: \`runlist new plan <name>\`
|
|
113
113
|
|
|
114
114
|
## Version History
|
|
115
115
|
|
|
116
|
-
- Scaffolded by \`
|
|
116
|
+
- Scaffolded by \`runlist init\`.
|
|
117
117
|
`;
|
|
118
118
|
|
|
119
119
|
const STARTER_INDEX = `# Docs
|
|
120
120
|
|
|
121
121
|
<!-- GENERATED:dotmd:start -->
|
|
122
122
|
|
|
123
|
-
_No docs yet. Run \`
|
|
123
|
+
_No docs yet. Run \`runlist list\` after creating your first document._
|
|
124
124
|
|
|
125
125
|
<!-- GENERATED:dotmd:end -->
|
|
126
126
|
`;
|
|
@@ -332,7 +332,7 @@ export async function runInit(cwd, config, opts = {}) {
|
|
|
332
332
|
}
|
|
333
333
|
if (existsSync(subPath)) {
|
|
334
334
|
const detail = total > 0
|
|
335
|
-
? ` (${counts.withFrontmatter}
|
|
335
|
+
? ` (${counts.withFrontmatter} runlist-tracked, ${counts.withoutFrontmatter} plain .md)`
|
|
336
336
|
: '';
|
|
337
337
|
process.stdout.write(` ${dryTag}${dim('exists')} docs/${sub}/${detail}\n`);
|
|
338
338
|
} else {
|
|
@@ -373,7 +373,7 @@ export async function runInit(cwd, config, opts = {}) {
|
|
|
373
373
|
for (const sub of subs) {
|
|
374
374
|
process.stdout.write(` • move into docs/: mv ./${sub}/* docs/${sub}/ && rmdir ./${sub}\n`);
|
|
375
375
|
}
|
|
376
|
-
process.stdout.write(` • or use a flat layout — set in
|
|
376
|
+
process.stdout.write(` • or use a flat layout — set in runlist.config.mjs:\n`);
|
|
377
377
|
process.stdout.write(` export const root = [${subs.map(s => `'${s}'`).join(', ')}];\n`);
|
|
378
378
|
}
|
|
379
379
|
|
|
@@ -425,7 +425,7 @@ export async function runInit(cwd, config, opts = {}) {
|
|
|
425
425
|
});
|
|
426
426
|
// Exit 0 → ignored. Exit 1 → not ignored. Exit 128 → not in repo / git error.
|
|
427
427
|
if (probe.status === 0) {
|
|
428
|
-
process.stdout.write(`\n ${yellow('notice')} docs/ is gitignored — files
|
|
428
|
+
process.stdout.write(`\n ${yellow('notice')} docs/ is gitignored — files runlist manages will NOT be tracked.\n`);
|
|
429
429
|
process.stdout.write(` Add an exception to .gitignore so docs/ is tracked:\n`);
|
|
430
430
|
process.stdout.write(` !docs/\n`);
|
|
431
431
|
process.stdout.write(` Or run: echo '!docs/' >> .gitignore\n`);
|
|
@@ -438,7 +438,7 @@ export async function runInit(cwd, config, opts = {}) {
|
|
|
438
438
|
if (scan?.untaggedCount > 0) {
|
|
439
439
|
const n = scan.untaggedCount;
|
|
440
440
|
const noun = n === 1 ? 'file' : 'files';
|
|
441
|
-
process.stdout.write(`\n ${yellow('hint')} ${n} untagged .md ${noun} found — run \`
|
|
441
|
+
process.stdout.write(`\n ${yellow('hint')} ${n} untagged .md ${noun} found — run \`runlist bulk-tag --dry-run\` to preview tagging.\n`);
|
|
442
442
|
}
|
|
443
443
|
|
|
444
444
|
// Claude Code integration. dotmd no longer scaffolds per-repo
|
|
@@ -457,25 +457,25 @@ export async function runInit(cwd, config, opts = {}) {
|
|
|
457
457
|
const removed = removeGeneratedSlashCommands(cwd, { dryRun });
|
|
458
458
|
for (const r of removed) {
|
|
459
459
|
const verb = dryRun ? 'would remove' : 'removed';
|
|
460
|
-
process.stdout.write(` ${dryTag}${yellow('clean')} .claude/commands/${r.name} (retired — ${verb}; guidance ships via the
|
|
460
|
+
process.stdout.write(` ${dryTag}${yellow('clean')} .claude/commands/${r.name} (retired — ${verb}; guidance ships via the runlist plugin)\n`);
|
|
461
461
|
}
|
|
462
462
|
}
|
|
463
463
|
|
|
464
464
|
if (likelyClaudeUser) {
|
|
465
465
|
const sessionStart = detectSessionStartHook(cwd);
|
|
466
466
|
if (sessionStart.wired) {
|
|
467
|
-
process.stdout.write(` ${dim('exists')} ${sessionStart.file} (SessionStart hook for \`
|
|
467
|
+
process.stdout.write(` ${dim('exists')} ${sessionStart.file} (SessionStart hook for \`runlist hud\` already wired)\n`);
|
|
468
468
|
} else {
|
|
469
|
-
process.stdout.write(`\n ${yellow('hint')} install the
|
|
469
|
+
process.stdout.write(`\n ${yellow('hint')} install the runlist Claude Code plugin so its hooks + workflow skill\n`);
|
|
470
470
|
process.stdout.write(` travel to every session and subagent automatically:\n\n`);
|
|
471
471
|
process.stdout.write(` /plugin marketplace add reowens/dotmd\n`);
|
|
472
472
|
process.stdout.write(` /plugin install dotmd@dotmd\n\n`);
|
|
473
|
-
process.stdout.write(` The plugin's hooks call \`
|
|
473
|
+
process.stdout.write(` The plugin's hooks call \`runlist\` on your PATH, so install the CLI\n`);
|
|
474
474
|
process.stdout.write(` globally too — ${green('npm i -g dotmd-cli')} (a project devDependency won't power them).\n\n`);
|
|
475
|
-
process.stdout.write(` Or, without the plugin, wire \`
|
|
475
|
+
process.stdout.write(` Or, without the plugin, wire \`runlist hud\` at SessionStart by hand —\n`);
|
|
476
476
|
process.stdout.write(` add to .claude/settings.json (merge into any existing hooks):\n\n`);
|
|
477
477
|
process.stdout.write(` "hooks": { "SessionStart": [\n`);
|
|
478
|
-
process.stdout.write(` { "hooks": [{ "type": "command", "command": "
|
|
478
|
+
process.stdout.write(` { "hooks": [{ "type": "command", "command": "runlist hud" }] }\n`);
|
|
479
479
|
process.stdout.write(` ] }\n`);
|
|
480
480
|
}
|
|
481
481
|
}
|
|
@@ -505,8 +505,8 @@ export async function runInit(cwd, config, opts = {}) {
|
|
|
505
505
|
}
|
|
506
506
|
|
|
507
507
|
process.stdout.write(`\nReady. A few starting points:\n`);
|
|
508
|
-
process.stdout.write(`
|
|
509
|
-
process.stdout.write(`
|
|
510
|
-
process.stdout.write(`
|
|
511
|
-
process.stdout.write(`
|
|
508
|
+
process.stdout.write(` runlist new doc my-doc # scaffold a reference doc\n`);
|
|
509
|
+
process.stdout.write(` runlist new plan my-plan # scaffold an execution plan\n`);
|
|
510
|
+
process.stdout.write(` runlist list # see what you've got\n`);
|
|
511
|
+
process.stdout.write(` runlist hud # session-start triage\n\n`);
|
|
512
512
|
}
|
package/src/install.mjs
CHANGED
|
@@ -43,7 +43,7 @@ function reportStatus(json) {
|
|
|
43
43
|
return;
|
|
44
44
|
}
|
|
45
45
|
const { claude, opencode } = states;
|
|
46
|
-
process.stdout.write(`${bold('
|
|
46
|
+
process.stdout.write(`${bold('runlist host integrations')} ${dim(`CLI ${pkg.version}`)}\n\n`);
|
|
47
47
|
|
|
48
48
|
const claudeBroken = claude.installed && claude.marketplaceRegistered === false;
|
|
49
49
|
const claudeState = !claude.installed ? yellow('not installed')
|
|
@@ -60,8 +60,8 @@ function reportStatus(json) {
|
|
|
60
60
|
process.stdout.write(` ${dim(opencode.path)}\n`);
|
|
61
61
|
|
|
62
62
|
const todo = [];
|
|
63
|
-
if (!claude.installed || claudeBroken) todo.push('
|
|
64
|
-
if (!opencode.exists || opencode.stale) todo.push('
|
|
63
|
+
if (!claude.installed || claudeBroken) todo.push('runlist install claude');
|
|
64
|
+
if (!opencode.exists || opencode.stale) todo.push('runlist install opencode');
|
|
65
65
|
if (todo.length) {
|
|
66
66
|
process.stdout.write('\n');
|
|
67
67
|
for (const cmd of todo) process.stdout.write(`Run ${bold(cmd)}\n`);
|
|
@@ -143,7 +143,7 @@ function installOpencode(argv, dryRun, json) {
|
|
|
143
143
|
if (dryRun) return result;
|
|
144
144
|
process.stdout.write('\nRestart OpenCode to apply. New sessions then get:\n');
|
|
145
145
|
process.stdout.write(` ${dim('·')} a per-session ownership identity (one session can no longer release another's plan)\n`);
|
|
146
|
-
process.stdout.write(` ${dim('·')} the ${bold('
|
|
146
|
+
process.stdout.write(` ${dim('·')} the ${bold('runlist hud')} primer at session start, like Claude Code's SessionStart hook\n`);
|
|
147
147
|
process.stdout.write(dim('\nPlans already in-session were claimed under the old process-scoped identity;\n'));
|
|
148
148
|
process.stdout.write(dim('close them before restarting, or reclaim with --force afterwards.\n'));
|
|
149
149
|
return result;
|
package/src/journal.mjs
CHANGED
|
@@ -10,11 +10,17 @@ const ROTATE_SIZE_BYTES = 5 * 1024 * 1024;
|
|
|
10
10
|
const ROTATE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
11
11
|
const BACKUP_RETENTION_MS = ROTATE_AGE_MS;
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// New writes go to the runlist-* names. The dotmd-* names are what dotmd-cli
|
|
14
|
+
// 0.78.0 and earlier wrote, and what an older CLI still installed elsewhere on
|
|
15
|
+
// the machine keeps writing, so readers merge both and never rename or delete
|
|
16
|
+
// the legacy files (beyond the usual stale-schema and stale-backup pruning).
|
|
17
|
+
const ERROR_LOG_FILE = 'runlist-errors.log';
|
|
18
|
+
const ERROR_LOG_BACKUP = 'runlist-errors.log.1';
|
|
15
19
|
|
|
16
|
-
const MISUSE_LOG_FILE = '
|
|
17
|
-
const MISUSE_LOG_BACKUP = '
|
|
20
|
+
const MISUSE_LOG_FILE = 'runlist-misuse.log';
|
|
21
|
+
const MISUSE_LOG_BACKUP = 'runlist-misuse.log.1';
|
|
22
|
+
const LEGACY_MISUSE_LOG_FILE = 'dotmd-misuse.log';
|
|
23
|
+
const LEGACY_MISUSE_LOG_BACKUP = 'dotmd-misuse.log.1';
|
|
18
24
|
const TELEMETRY_SCHEMA = 2;
|
|
19
25
|
const REDACTED = '[redacted]';
|
|
20
26
|
const SENSITIVE_VALUE_FLAGS = new Set([
|
|
@@ -358,9 +364,12 @@ export function recordGuardEvent(event) {
|
|
|
358
364
|
}
|
|
359
365
|
}
|
|
360
366
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
367
|
+
// Every misuse log a reader should see: the current file, then the legacy one.
|
|
368
|
+
export function globalMisuseLogPaths() {
|
|
369
|
+
return [globalMisuseLogPath(), path.join(globalErrorLogDir(), LEGACY_MISUSE_LOG_FILE)];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function readLogLines(file) {
|
|
364
373
|
if (!existsSync(file)) return [];
|
|
365
374
|
let raw;
|
|
366
375
|
try { raw = readFileSync(file, 'utf8'); } catch { return []; }
|
|
@@ -371,3 +380,27 @@ export function readMisuseEntries() {
|
|
|
371
380
|
}
|
|
372
381
|
return out;
|
|
373
382
|
}
|
|
383
|
+
|
|
384
|
+
// Current and legacy entries, merged oldest-first by timestamp. The sort is
|
|
385
|
+
// stable, so entries with equal or unparseable timestamps keep file order.
|
|
386
|
+
export function readMisuseEntries() {
|
|
387
|
+
const dir = globalErrorLogDir();
|
|
388
|
+
const pairs = [
|
|
389
|
+
[globalMisuseLogPath(), globalMisuseLogBackupPath()],
|
|
390
|
+
[path.join(dir, LEGACY_MISUSE_LOG_FILE), path.join(dir, LEGACY_MISUSE_LOG_BACKUP)],
|
|
391
|
+
];
|
|
392
|
+
const entries = [];
|
|
393
|
+
for (const [file, backup] of pairs) {
|
|
394
|
+
purgeLegacyTelemetry(file, backup);
|
|
395
|
+
pruneStaleBackup(backup);
|
|
396
|
+
entries.push(...readLogLines(file));
|
|
397
|
+
}
|
|
398
|
+
const time = (entry) => {
|
|
399
|
+
const t = Date.parse(entry?.ts);
|
|
400
|
+
return Number.isNaN(t) ? 0 : t;
|
|
401
|
+
};
|
|
402
|
+
return entries
|
|
403
|
+
.map((entry, index) => ({ entry, index, t: time(entry) }))
|
|
404
|
+
.sort((a, b) => a.t - b.t || a.index - b.index)
|
|
405
|
+
.map(({ entry }) => entry);
|
|
406
|
+
}
|
package/src/lifecycle.mjs
CHANGED
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
|
|
36
36
|
export function renderLifecycleMutation(raw, updates, historyEntry, { createSection = false, bodyTransform = null } = {}) {
|
|
37
37
|
raw = normalizeEol(raw);
|
|
38
|
-
if (!raw.startsWith('---\n')) throw new Error('Document has no frontmatter block. Retrofit it with `
|
|
38
|
+
if (!raw.startsWith('---\n')) throw new Error('Document has no frontmatter block. Retrofit it with `runlist bulk-tag` first.');
|
|
39
39
|
const endMarker = raw.indexOf('\n---\n', 4);
|
|
40
40
|
if (endMarker === -1) throw new Error('Document has an unclosed frontmatter block.');
|
|
41
41
|
let frontmatter = raw.slice(4, endMarker);
|
|
@@ -192,7 +192,7 @@ export function regenIndex(config, options = {}) {
|
|
|
192
192
|
return true;
|
|
193
193
|
} catch (err) {
|
|
194
194
|
if (options.throwOnError) throw err;
|
|
195
|
-
warn(`Could not regenerate index (run \`
|
|
195
|
+
warn(`Could not regenerate index (run \`runlist index\`): ${err.message}`);
|
|
196
196
|
return false;
|
|
197
197
|
}
|
|
198
198
|
}
|
|
@@ -360,10 +360,10 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
360
360
|
let newStatus = argv[1];
|
|
361
361
|
|
|
362
362
|
if (!opts.suppressDeprecation) {
|
|
363
|
-
process.stderr.write(dim('`
|
|
363
|
+
process.stderr.write(dim('`runlist status <file> <status>` is deprecated; prefer `runlist set <status> [<file>]` (note: <status> first; <file> optional when a plan is in-session). Removed in a future major.\n'));
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
-
if (!input) { die('Usage:
|
|
366
|
+
if (!input) { die('Usage: runlist status <file> <new-status>'); }
|
|
367
367
|
|
|
368
368
|
let filePath = resolveDocArg(input, config);
|
|
369
369
|
const sourceAuthorization = authorizeManagedSource(filePath, config, { kind: 'Status source' });
|
|
@@ -394,7 +394,7 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
394
394
|
newStatus = await promptChoice('Which status?', effectiveOrder);
|
|
395
395
|
if (!newStatus) die('No status selected.');
|
|
396
396
|
} else {
|
|
397
|
-
die('Usage:
|
|
397
|
+
die('Usage: runlist status <file> <new-status>');
|
|
398
398
|
}
|
|
399
399
|
}
|
|
400
400
|
|
|
@@ -526,7 +526,7 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
526
526
|
// this so concurrent agents can do path-limited commits without pulling
|
|
527
527
|
// each other's uncommitted index changes into the staging area.
|
|
528
528
|
if (noIndex) {
|
|
529
|
-
process.stderr.write(dim('(index not regenerated — run `
|
|
529
|
+
process.stderr.write(dim('(index not regenerated — run `runlist index` to refresh)\n'));
|
|
530
530
|
} else if (!opts.deferIndex) {
|
|
531
531
|
regenIndex(config);
|
|
532
532
|
}
|
|
@@ -568,7 +568,7 @@ export async function startPlan(argv, config, opts = {}) {
|
|
|
568
568
|
|
|
569
569
|
// Interactive: pick from active/planned plans
|
|
570
570
|
if (!input) {
|
|
571
|
-
if (!isInteractive()) die('Usage:
|
|
571
|
+
if (!isInteractive()) die('Usage: runlist use <plan>');
|
|
572
572
|
const index = buildIndex(config);
|
|
573
573
|
const sessionId = authoritativeSessionId();
|
|
574
574
|
const candidates = pickupCandidates(index, config, sessionId);
|
|
@@ -672,7 +672,7 @@ export async function startPlan(argv, config, opts = {}) {
|
|
|
672
672
|
} else {
|
|
673
673
|
process.stderr.write(`${green('▶ Started')}: ${repoPath} (${oldStatus ?? 'unset'} → in-session)\n\n`);
|
|
674
674
|
if (fullBody) {
|
|
675
|
-
const header = `[
|
|
675
|
+
const header = `[runlist] in-session: ${repoPath} — close with: runlist set <status> ${repoPath}\n---\n`;
|
|
676
676
|
process.stdout.write(header);
|
|
677
677
|
const content = (body ?? '').trim();
|
|
678
678
|
if (content) process.stdout.write(content + '\n');
|
|
@@ -707,7 +707,7 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
707
707
|
}
|
|
708
708
|
const input = argv[0];
|
|
709
709
|
|
|
710
|
-
if (!input) { die('Usage:
|
|
710
|
+
if (!input) { die('Usage: runlist archive <file>'); }
|
|
711
711
|
|
|
712
712
|
let filePath = resolveDocArg(input, config);
|
|
713
713
|
const sourceAuthorization = authorizeManagedSource(filePath, config, { kind: 'Archive source' });
|
|
@@ -861,7 +861,7 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
861
861
|
if (selfRefsFixed) out.write('Updated references in archived file.\n');
|
|
862
862
|
if (updatedRefCount > 0) out.write(`Updated references in ${updatedRefCount} file(s).\n`);
|
|
863
863
|
if (config.indexPath && indexRegenerated) out.write('Index regenerated.\n');
|
|
864
|
-
if (config.indexPath && noIndex) out.write(dim('(index not regenerated — run `
|
|
864
|
+
if (config.indexPath && noIndex) out.write(dim('(index not regenerated — run `runlist index` to refresh)\n'));
|
|
865
865
|
|
|
866
866
|
const touched = [oldRepoPath, newRepoPath, ...refTouchedPaths];
|
|
867
867
|
if (config.indexPath && indexRegenerated) touched.push(config.indexPath);
|
|
@@ -931,7 +931,7 @@ export async function runSet(argv, config, opts = {}) {
|
|
|
931
931
|
const newStatus = argv[0];
|
|
932
932
|
let input = argv[1];
|
|
933
933
|
|
|
934
|
-
if (!newStatus) die('Usage:
|
|
934
|
+
if (!newStatus) die('Usage: runlist set <status> [<path>]');
|
|
935
935
|
let sessionId = null;
|
|
936
936
|
if (!input) {
|
|
937
937
|
sessionId = authoritativeSessionId();
|
|
@@ -1051,7 +1051,7 @@ export async function runSet(argv, config, opts = {}) {
|
|
|
1051
1051
|
// `@<draft-file>` stays an obvious metavariable: the old `@draft` read as
|
|
1052
1052
|
// a runnable path and got copied verbatim into failing batons. If it is
|
|
1053
1053
|
// copied anyway, readBodyInput recognizes the <…> form and says so.
|
|
1054
|
-
warn(`wrapping up? leave a baton so the next session picks up cleanly — write your resume notes to a file, then \`
|
|
1054
|
+
warn(`wrapping up? leave a baton so the next session picks up cleanly — write your resume notes to a file, then \`runlist baton ${path.basename(filePath, '.md')} @<draft-file>\` saves a resume prompt (no copy-paste into chat).`);
|
|
1055
1055
|
}
|
|
1056
1056
|
}
|
|
1057
1057
|
return result;
|
|
@@ -1063,7 +1063,7 @@ export function runBulkArchive(argv, config, opts = {}) {
|
|
|
1063
1063
|
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
1064
1064
|
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
1065
1065
|
const inputs = argv.filter(a => !a.startsWith('-'));
|
|
1066
|
-
if (inputs.length === 0) die('Usage:
|
|
1066
|
+
if (inputs.length === 0) die('Usage: runlist bulk archive <file1> <file2> ... or <glob>');
|
|
1067
1067
|
|
|
1068
1068
|
const allFiles = collectDocFiles(config);
|
|
1069
1069
|
const matched = [];
|
|
@@ -1124,7 +1124,7 @@ export function runBulkArchive(argv, config, opts = {}) {
|
|
|
1124
1124
|
catch (err) { indexError = err.message; }
|
|
1125
1125
|
if (config.indexPath && indexRegenerated) process.stdout.write('Index regenerated.\n');
|
|
1126
1126
|
} else if (config.indexPath) {
|
|
1127
|
-
process.stdout.write(dim('(index not regenerated — run `
|
|
1127
|
+
process.stdout.write(dim('(index not regenerated — run `runlist index` to refresh)\n'));
|
|
1128
1128
|
}
|
|
1129
1129
|
if (showFiles) {
|
|
1130
1130
|
const all = [...bulkTouched];
|
|
@@ -1248,8 +1248,8 @@ export function runTouch(argv, config, opts = {}) {
|
|
|
1248
1248
|
return;
|
|
1249
1249
|
}
|
|
1250
1250
|
|
|
1251
|
-
if (inputs.length > 1) die('Multiple files require `
|
|
1252
|
-
if (!input) { die('Usage:
|
|
1251
|
+
if (inputs.length > 1) die('Multiple files require `runlist touch --git <file...>`.');
|
|
1252
|
+
if (!input) { die('Usage: runlist touch <file>\n runlist touch --git Bulk-sync dates from git history'); }
|
|
1253
1253
|
|
|
1254
1254
|
let filePath = resolveDocArg(input, config);
|
|
1255
1255
|
filePath = authorizeManagedSource(filePath, config, { kind: 'Touch source' }).path;
|
|
@@ -1358,7 +1358,7 @@ export function updateFrontmatter(filePath, updates) {
|
|
|
1358
1358
|
// Name the remedy in the error: this is where every status verb lands when a
|
|
1359
1359
|
// doc was created outside dotmd, and "no frontmatter block" alone left
|
|
1360
1360
|
// sessions retrying other verbs instead of fixing the doc.
|
|
1361
|
-
if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block. Retrofit it first:
|
|
1361
|
+
if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block. Retrofit it first: runlist bulk-tag ${filePath} --type <type> --status <status>`);
|
|
1362
1362
|
|
|
1363
1363
|
const endMarker = raw.indexOf('\n---\n', 4);
|
|
1364
1364
|
if (endMarker === -1) throw new Error(`${filePath} has unclosed frontmatter block.`);
|
package/src/lint.mjs
CHANGED
|
@@ -218,7 +218,7 @@ export function runLint(argv, config, opts = {}) {
|
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
|
-
process.stdout.write(`\nRun ${bold('
|
|
221
|
+
process.stdout.write(`\nRun ${bold('runlist lint --fix')} to auto-fix.\n`);
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
if (nonFixable.length > 0) {
|
package/src/migrate-prompts.mjs
CHANGED
|
@@ -167,5 +167,5 @@ export function runMigratePrompts(argv, config, opts = {}) {
|
|
|
167
167
|
process.stdout.write(dim(` [${c.kind}] ${c.detail}\n`));
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
|
-
if (dryRun) process.stdout.write(`\nRun ${bold('
|
|
170
|
+
if (dryRun) process.stdout.write(`\nRun ${bold('runlist doctor --migrate-prompts')} without --dry-run to apply.\n`);
|
|
171
171
|
}
|