dotmd-cli 0.77.3 → 0.79.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/README.md +70 -60
- package/assets/opencode/plugin.js +45 -8
- package/bin/dotmd.mjs +274 -283
- package/package.json +2 -2
- package/scripts/postinstall.mjs +4 -4
- package/src/atomic-mutation.mjs +1 -1
- package/src/baton.mjs +86 -50
- package/src/check-collapse.mjs +5 -5
- package/src/claude-commands.mjs +6 -2
- package/src/commands.mjs +8 -8
- package/src/config.mjs +2 -2
- package/src/deps.mjs +1 -1
- package/src/doctor.mjs +17 -17
- package/src/fix-membership.mjs +1 -1
- 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 +13 -10
- 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 +15 -12
- package/src/output-identity.mjs +7 -2
- package/src/pickup-card.mjs +2 -2
- package/src/pickup.mjs +2 -2
- package/src/prompts.mjs +18 -15
- 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/use.mjs +6 -2
- package/src/validate.mjs +9 -9
- package/src/watch.mjs +1 -1
package/src/guard.mjs
CHANGED
|
@@ -223,8 +223,11 @@ function editStatusResult(target, config, detail) {
|
|
|
223
223
|
rule: 'edit-status',
|
|
224
224
|
detail,
|
|
225
225
|
reason:
|
|
226
|
-
|
|
227
|
-
|
|
226
|
+
// Names the canonical executable: a session that follows this message
|
|
227
|
+
// should land on the current name, not the legacy alias.
|
|
228
|
+
`Looks like a hand-edit of the \`status:\` field in ${target}. Use \`runlist set <status> ${target}\` instead — ` +
|
|
229
|
+
`it validates the status against this doc's type, runs lifecycle hooks, fixes refs, and keeps the index in sync. Direct edits skip all of that. ` +
|
|
230
|
+
`Creating a doc? \`runlist new <type> <name> --status <status>\` writes the status in the same step.`,
|
|
228
231
|
};
|
|
229
232
|
}
|
|
230
233
|
|
|
@@ -266,9 +269,9 @@ function evalBash(command, config, inspectGitPaths, baseCwd) {
|
|
|
266
269
|
rule: 'commit-prompt',
|
|
267
270
|
detail: `git ${git.subcommand} ${targets.join(' ')}`,
|
|
268
271
|
reason:
|
|
269
|
-
`Saved prompts (${targets.join(', ')}) are session-local
|
|
272
|
+
`Saved prompts (${targets.join(', ')}) are session-local runlist artifacts, not source to commit. ` +
|
|
270
273
|
`Don't git add/commit them — commit your other changes without the prompt in the pathspec. ` +
|
|
271
|
-
`The next session consumes a prompt with \`
|
|
274
|
+
`The next session consumes a prompt with \`runlist use <file>\` (or \`runlist use\` for the oldest pending), which prints the body and archives it atomically.`,
|
|
272
275
|
};
|
|
273
276
|
}
|
|
274
277
|
}
|
|
@@ -280,9 +283,9 @@ function evalBash(command, config, inspectGitPaths, baseCwd) {
|
|
|
280
283
|
rule: 'cat-prompt',
|
|
281
284
|
detail: `${cmd0} ${promptTokens.join(' ')}`,
|
|
282
285
|
reason:
|
|
283
|
-
`${promptTokens.join(', ')} is a saved
|
|
286
|
+
`${promptTokens.join(', ')} is a saved runlist prompt. To start work from it, run \`runlist use ${promptTokens[0]}\` — ` +
|
|
284
287
|
`it commits archive/claim before at-most-once body output (prevents double-consumption). ` +
|
|
285
|
-
`Just peeking or triaging (not consuming)? \`
|
|
288
|
+
`Just peeking or triaging (not consuming)? \`runlist prompts show ${promptTokens[0]}\` reads it without archiving. Don't \`${cmd0}\` it directly.`,
|
|
286
289
|
};
|
|
287
290
|
}
|
|
288
291
|
|
|
@@ -305,9 +308,9 @@ function evalRead(filePath, config) {
|
|
|
305
308
|
rule: 'read-prompt',
|
|
306
309
|
detail: filePath,
|
|
307
310
|
reason:
|
|
308
|
-
`${filePath} is a saved
|
|
309
|
-
`Just peeking or triaging (not consuming)? \`
|
|
310
|
-
`Surveying the whole queue? \`
|
|
311
|
+
`${filePath} is a saved runlist prompt. To start work from it, run \`runlist use ${filePath}\` — it commits archive/claim before at-most-once body output so it can't be double-consumed. ` +
|
|
312
|
+
`Just peeking or triaging (not consuming)? \`runlist prompts show ${filePath}\` reads it without archiving. ` +
|
|
313
|
+
`Surveying the whole queue? \`runlist prompts show --all\` peeks every pending prompt in one call — don't Read them file by file.`,
|
|
311
314
|
};
|
|
312
315
|
}
|
|
313
316
|
|
|
@@ -393,7 +396,7 @@ function emit(result) {
|
|
|
393
396
|
hookSpecificOutput.permissionDecisionReason = result.reason;
|
|
394
397
|
} else {
|
|
395
398
|
// warn — allow the call but teach the agent the dotmd-native path.
|
|
396
|
-
hookSpecificOutput.additionalContext = `[
|
|
399
|
+
hookSpecificOutput.additionalContext = `[runlist] ${result.reason}`;
|
|
397
400
|
}
|
|
398
401
|
process.stdout.write(JSON.stringify({ hookSpecificOutput }) + '\n');
|
|
399
402
|
}
|
package/src/health.mjs
CHANGED
|
@@ -123,7 +123,7 @@ export function runHealth(argv, config) {
|
|
|
123
123
|
|
|
124
124
|
// Roadmaps (tier-3) — pinned above Runlists with the recursive grand total.
|
|
125
125
|
if (roadmapHubs.length > 0) {
|
|
126
|
-
process.stdout.write(`${bold('Roadmaps:')} ${roadmapHubs.length} ${dim('·
|
|
126
|
+
process.stdout.write(`${bold('Roadmaps:')} ${roadmapHubs.length} ${dim('· runlist roadmap')}\n`);
|
|
127
127
|
for (const doc of roadmapHubs.slice(0, 8)) {
|
|
128
128
|
const slug = hubLabel(doc).padEnd(28);
|
|
129
129
|
const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '?d';
|
|
@@ -142,7 +142,7 @@ export function runHealth(argv, config) {
|
|
|
142
142
|
// surfaced as their own tally so they don't inflate the active count. Newest
|
|
143
143
|
// first, mirroring `dotmd runlists`; capped with a "more" footer.
|
|
144
144
|
if (runlistHubs.length > 0) {
|
|
145
|
-
process.stdout.write(`${bold('Runlists:')} ${runlistHubs.length} ${dim('·
|
|
145
|
+
process.stdout.write(`${bold('Runlists:')} ${runlistHubs.length} ${dim('· runlist runlists')}\n`);
|
|
146
146
|
for (const doc of runlistHubs.slice(0, 8)) {
|
|
147
147
|
const slug = hubLabel(doc).padEnd(28);
|
|
148
148
|
const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '?d';
|
package/src/hints.mjs
CHANGED
|
@@ -17,32 +17,32 @@ const TEMPLATES = [
|
|
|
17
17
|
{
|
|
18
18
|
match: /Too many arguments|Usage:/i,
|
|
19
19
|
hint: ({ count, argv }) =>
|
|
20
|
-
`${count}× the same shape on \`${argv[0]} ${argv[1] ?? ''}\` in this session. Run \`
|
|
20
|
+
`${count}× the same shape on \`${argv[0]} ${argv[1] ?? ''}\` in this session. Run \`runlist ${argv[0]} --help\` for the expected positional args.`,
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
match: /Already (consumed|archived)/i,
|
|
24
24
|
hint: ({ count, argv }) =>
|
|
25
|
-
`${count}× attempts to use a path that is already archived. Use \`
|
|
25
|
+
`${count}× attempts to use a path that is already archived. Use \`runlist prompts list\` to see what is actually pending, or \`runlist next\` for the oldest live prompt.`,
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
28
|
match: /No pending prompts/i,
|
|
29
29
|
hint: ({ count }) =>
|
|
30
|
-
`${count}× \`
|
|
30
|
+
`${count}× \`runlist next\` with no pending prompts in the queue. Either queue one with \`runlist new prompt <slug> "..."\` or pass an explicit prompt file to \`runlist use\`.`,
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
match: /Unknown command/i,
|
|
34
34
|
hint: ({ count }) =>
|
|
35
|
-
`${count}× the same unknown command. Run \`
|
|
35
|
+
`${count}× the same unknown command. Run \`runlist --help\` to list available commands; the dispatch already prints a did-you-mean for close misses.`,
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
match: /File not found|does not resolve/i,
|
|
39
39
|
hint: ({ count, argv }) =>
|
|
40
|
-
`${count}× pointing at a path that doesn't exist. Confirm the file with \`
|
|
40
|
+
`${count}× pointing at a path that doesn't exist. Confirm the file with \`runlist query\` or \`runlist plans\` — paths resolve relative to repo root or doc roots, not the cwd.`,
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
match: /Unknown status|Unknown surface/i,
|
|
44
44
|
hint: ({ count }) =>
|
|
45
|
-
`${count}× rejected by the taxonomy validator. Run \`
|
|
45
|
+
`${count}× rejected by the taxonomy validator. Run \`runlist statuses list\` or \`runlist surfaces\` to print the valid values for this project.`,
|
|
46
46
|
},
|
|
47
47
|
];
|
|
48
48
|
|
|
@@ -122,7 +122,7 @@ export function findRepeatFailureHint(failingArgv, config) {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
return `${count}× the same failing shape on \`${failingArgv[0]}\` in this session (last attempt ${ageMin}m ago). Check the args — \`
|
|
125
|
+
return `${count}× the same failing shape on \`${failingArgv[0]}\` in this session (last attempt ${ageMin}m ago). Check the args — \`runlist ${failingArgv[0]} --help\` shows what's expected.`;
|
|
126
126
|
} catch {
|
|
127
127
|
return null;
|
|
128
128
|
}
|
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
|
+
}
|