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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dotmd-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.79.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",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"homepage": "https://github.com/reowens/dotmd#readme",
|
|
43
43
|
"scripts": {
|
|
44
|
-
"test": "node --test test/*.test.mjs",
|
|
44
|
+
"test": "node --import ./test/setup-env.mjs --test test/*.test.mjs",
|
|
45
45
|
"postinstall": "node scripts/postinstall.mjs",
|
|
46
46
|
"preversion": "node scripts/release-preflight.mjs && npm test",
|
|
47
47
|
"version": "node scripts/prepare-version-commit.mjs",
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -36,17 +36,17 @@ try {
|
|
|
36
36
|
|
|
37
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
|
-
process.stdout.write('
|
|
39
|
+
process.stdout.write('runlist: refreshed the Claude Code plugin — restart your session (or /reload-plugins) to apply.\n');
|
|
40
40
|
} else {
|
|
41
41
|
// The CLI just installed fresh, so only the plugin can be stale — point at
|
|
42
42
|
// the targeted refresh rather than the full `dotmd update` (CLI + plugin).
|
|
43
43
|
const nudge = hasClaude
|
|
44
|
-
? '
|
|
45
|
-
: '
|
|
44
|
+
? 'runlist CLI installed. Using the Claude Code plugin? Run `runlist update --plugin-only` to refresh it, then restart.'
|
|
45
|
+
: 'runlist CLI installed.';
|
|
46
46
|
process.stdout.write(`${nudge}\n`);
|
|
47
47
|
}
|
|
48
48
|
if (hasOpencode) {
|
|
49
|
-
process.stdout.write('
|
|
49
|
+
process.stdout.write('runlist: OpenCode detected — run `runlist install opencode` for per-session plan ownership and a session-start briefing.\n');
|
|
50
50
|
}
|
|
51
51
|
} catch {
|
|
52
52
|
// Best effort only — never break the install.
|
package/src/atomic-mutation.mjs
CHANGED
|
@@ -203,7 +203,7 @@ function transactionRepairMessage(manifestPath, manifest, reason) {
|
|
|
203
203
|
...(manifest.gitIndex?.retainedPaths ?? []),
|
|
204
204
|
].filter(Boolean);
|
|
205
205
|
return `${reason}\nTransaction recovery refused to guess. Manifest: ${manifestPath}\n` +
|
|
206
|
-
`Start with \`
|
|
206
|
+
`Start with \`runlist doctor --transactions\` — it reports this transaction's state and resolves it when the canonical files agree on one generation.\n` +
|
|
207
207
|
`If it reports the generations as ambiguous, inspect the canonical files and recovery artifacts, then restore one complete generation and remove the manifest:\n` +
|
|
208
208
|
`${artifacts.map(item => ` ${item}`).join('\n') || ' (no content artifacts)'}`;
|
|
209
209
|
}
|
package/src/baton.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, existsSync, writeFileSync, realpathSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
-
import { asString, toRepoPath, die, warn } from './util.mjs';
|
|
4
|
+
import { asString, toRepoPath, die, warn, isArchivedPath, resolveRefPath } from './util.mjs';
|
|
5
5
|
import { buildIndex, resolveDocArg } from './index.mjs';
|
|
6
6
|
import { preparePromptDocument, runNew, readBodyInput, readPipedBodyInput } from './new.mjs';
|
|
7
7
|
import { ensurePlanCompletionBeforeRelease, planHasPendingCompletion, runSet } from './lifecycle.mjs';
|
|
@@ -26,11 +26,42 @@ export function findOwnedPlan(config, index = null) {
|
|
|
26
26
|
return { plan: clean && owned.length === 1 ? owned[0] : null, via: clean && owned.length === 1 ? 'ownership' : null, inSession, owned, diagnostics: records.diagnostics ?? [] };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
// One line that says what is missing, then one example. Baton never drafts the
|
|
30
|
+
// resume itself: the session that did the work is the only one that knows the
|
|
31
|
+
// next decision, and a prompt assembled from frontmatter reads like a handoff
|
|
32
|
+
// while carrying nothing the plan doesn't already say.
|
|
33
|
+
const BODY_USAGE = `Nothing saved: baton needs the resume you wrote, passed as @<file> or - (stdin).
|
|
34
|
+
runlist baton [<plan-or-slug>] @/tmp/draft.md`;
|
|
35
|
+
|
|
36
|
+
// A handoff that lands beside a pending one leaves two prompts for the same
|
|
37
|
+
// work, and the next session picks whichever sorts first. Baton used to step to
|
|
38
|
+
// `resume-<x>-2` silently; it now refuses and names what is waiting, so the
|
|
39
|
+
// older prompt is consumed or archived on purpose. Plan mode also catches a
|
|
40
|
+
// pending prompt under a different name that links the same plan.
|
|
41
|
+
function pendingHandoffs(promptPath, planPath, config) {
|
|
42
|
+
const found = new Set();
|
|
43
|
+
if (existsSync(promptPath)) found.add(toRepoPath(promptPath, config.repoRoot));
|
|
44
|
+
if (planPath) {
|
|
45
|
+
const planReal = realpathSync(planPath);
|
|
46
|
+
const index = buildIndex(config, { fast: true, invokeHooks: false });
|
|
47
|
+
for (const doc of index.docs) {
|
|
48
|
+
if (doc.type !== 'prompt' || doc.status === 'archived' || isArchivedPath(doc.path, config)) continue;
|
|
49
|
+
const abs = path.resolve(config.repoRoot, doc.path);
|
|
50
|
+
let planRef;
|
|
51
|
+
try { planRef = asString(parseSimpleFrontmatter(extractFrontmatter(readFileSync(abs, 'utf8')).frontmatter).plan); }
|
|
52
|
+
catch { continue; }
|
|
53
|
+
const linked = planRef ? resolveRefPath(planRef, path.dirname(abs), config.repoRoot) : null;
|
|
54
|
+
if (linked && realpathSync(linked) === planReal) found.add(doc.path);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return [...found];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function refusePendingHandoff(pending) {
|
|
61
|
+
const lines = pending.map(p => ` ${p}`).join('\n');
|
|
62
|
+
const slug = path.basename(pending[0], '.md');
|
|
63
|
+
die(`Nothing saved: a handoff for this is already pending:\n${lines}\nUse it (\`runlist use ${slug}\`) or archive it (\`runlist prompts archive ${pending[0]}\`), then run baton again.`);
|
|
64
|
+
}
|
|
34
65
|
|
|
35
66
|
// Is this positional a filesystem reference (must resolve, typos die) or a
|
|
36
67
|
// bare word (may be a plan slug, may be a brand-new handoff name)?
|
|
@@ -56,7 +87,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
56
87
|
if (a === '--force') { force = true; continue; }
|
|
57
88
|
if (a === '--json') continue;
|
|
58
89
|
if (!a.startsWith('-') || a === '-' || a.startsWith('@')) { positionals.push(a); continue; }
|
|
59
|
-
die(`Unknown flag for \`
|
|
90
|
+
die(`Unknown flag for \`runlist baton\`: ${a}`);
|
|
60
91
|
}
|
|
61
92
|
|
|
62
93
|
let planArg = null;
|
|
@@ -107,13 +138,21 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
107
138
|
if (owned.plan) {
|
|
108
139
|
planPath = path.resolve(config.repoRoot, owned.plan.path);
|
|
109
140
|
} else if (owned.owned?.length > 1) {
|
|
110
|
-
die(`Multiple plans are owned by this session; pass one explicitly:\n${owned.owned.map(d => '
|
|
141
|
+
die(`Multiple plans are owned by this session; pass one explicitly:\n${owned.owned.map(d => ' runlist baton ' + d.path + ' @/tmp/draft.md').join('\n')}`);
|
|
111
142
|
} else {
|
|
112
143
|
const diagnostics = owned.diagnostics?.length ? `\nIgnored ownership records:\n${owned.diagnostics.map(d => ` ${d}`).join('\n')}` : '';
|
|
113
|
-
die(`No valid in-session plan is owned by this session, so baton needs a name for the resume prompt:\n
|
|
144
|
+
die(`No valid in-session plan is owned by this session, so baton needs a name for the resume prompt:\n runlist baton <slug> @/tmp/draft.md # saves resume-<slug>, touches nothing else\nHanding off a specific plan? runlist baton <plan-file> @/tmp/draft.md${diagnostics}`);
|
|
114
145
|
}
|
|
115
146
|
}
|
|
116
147
|
|
|
148
|
+
const nameBase = planPath ? path.basename(planPath, '.md') : promptSlug;
|
|
149
|
+
const slugBase = nameBase.startsWith('resume-') ? nameBase : `resume-${nameBase}`;
|
|
150
|
+
const refuseIfPending = () => {
|
|
151
|
+
const target = preparePromptDocument(slugBase, body, config, { dryRun: true });
|
|
152
|
+
const pending = pendingHandoffs(target.filePath, planPath, config);
|
|
153
|
+
if (pending.length) refusePendingHandoff(pending);
|
|
154
|
+
};
|
|
155
|
+
|
|
117
156
|
let repoPath = null;
|
|
118
157
|
let oldStatus = null;
|
|
119
158
|
let ownershipPath = null;
|
|
@@ -123,7 +162,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
123
162
|
const raw = readFileSync(planPath, 'utf8');
|
|
124
163
|
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
125
164
|
if (!fmRaw) {
|
|
126
|
-
die(`${repoPath} has no frontmatter block — baton can't flip its status.\nFix the doc first (\`
|
|
165
|
+
die(`${repoPath} has no frontmatter block — baton can't flip its status.\nFix the doc first (\`runlist bulk-tag ${repoPath} --type plan --status in-session\`), or save the prompt without a status flip: runlist baton ${path.basename(planPath, '.md')} @/tmp/draft.md`);
|
|
127
166
|
}
|
|
128
167
|
const fm = parseSimpleFrontmatter(fmRaw);
|
|
129
168
|
const docType = asString(fm.type);
|
|
@@ -137,21 +176,22 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
137
176
|
die(`Invalid status \`${status}\` for type \`${docType ?? 'plan'}\`\nValid: ${[...validStatuses].join(', ')}`);
|
|
138
177
|
}
|
|
139
178
|
if (status === 'in-session') {
|
|
140
|
-
die('`
|
|
179
|
+
die('`runlist baton --status in-session` contradicts baton release semantics. Choose active/paused/awaiting/partial/blocked.');
|
|
141
180
|
}
|
|
142
181
|
assertPlanMutationAuthorized(repoPath, config, { sessionId: authoritativeSessionId(), force });
|
|
182
|
+
// Before the plan-completion step: a refusal must leave nothing changed.
|
|
183
|
+
refuseIfPending();
|
|
143
184
|
ownershipPath = readPlanOwnership(repoPath, config)?.recordPath ?? null;
|
|
144
185
|
if (!dryRun) ensurePlanCompletionBeforeRelease(repoPath, config, { testHooks: opts.testHooks });
|
|
145
186
|
else if (planHasPendingCompletion(repoPath, config)) process.stderr.write(`${dim('[dry-run]')} Pending claim completion would block this release.\n`);
|
|
146
187
|
} else {
|
|
188
|
+
refuseIfPending();
|
|
147
189
|
if (statusFlag) warn(`--status ignored — no plan involved in this handoff (saving the prompt only).`);
|
|
148
190
|
if (note) warn(`--note ignored — no plan involved in this handoff (notes land in a plan's Version History).`);
|
|
149
191
|
}
|
|
150
192
|
|
|
151
193
|
// Plan mode publishes the already-stamped prompt, status/history update, and
|
|
152
194
|
// ownership release in one transaction. Slug mode has no plan transaction.
|
|
153
|
-
const nameBase = planPath ? path.basename(planPath, '.md') : promptSlug;
|
|
154
|
-
const slugBase = nameBase.startsWith('resume-') ? nameBase : `resume-${nameBase}`;
|
|
155
195
|
let createdSlug = null;
|
|
156
196
|
let archiveResult = null;
|
|
157
197
|
let statusChanged = false;
|
|
@@ -162,49 +202,45 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
162
202
|
if (json) process.stdout.write = chunk => { muted.push(String(chunk)); return true; };
|
|
163
203
|
try {
|
|
164
204
|
if (!planPath) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
catch (err) { if (!/File already exists/.test(String(err?.message))) throw err; }
|
|
205
|
+
const prepared = preparePromptDocument(slugBase, body, config, { dryRun });
|
|
206
|
+
try {
|
|
207
|
+
newResult = await runNew(['prompt', slugBase, '--body', body], config, { dryRun, deferIndex: true });
|
|
208
|
+
} catch (err) {
|
|
209
|
+
// Lost a race with another baton between the pending check and the write.
|
|
210
|
+
if (/File already exists/.test(String(err?.message))) refusePendingHandoff([prepared.repoPath]);
|
|
211
|
+
throw err;
|
|
174
212
|
}
|
|
213
|
+
createdSlug = slugBase;
|
|
214
|
+
promptRepoPath = prepared.repoPath;
|
|
175
215
|
} else {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
} catch (err) {
|
|
200
|
-
if (!/Destination already exists|File already exists/.test(String(err?.message))) throw err;
|
|
201
|
-
}
|
|
216
|
+
const prepared = preparePromptDocument(slugBase, body, config, { plan: repoPath, dryRun });
|
|
217
|
+
const setArgs = [status, planPath];
|
|
218
|
+
if (force) setArgs.push('--force');
|
|
219
|
+
if (note) setArgs.push('--note', note);
|
|
220
|
+
try {
|
|
221
|
+
if (dryRun) process.stdout.write(`${dim('[dry-run]')} Would create: ${prepared.repoPath}\n`);
|
|
222
|
+
archiveResult = await runSet(setArgs, config, {
|
|
223
|
+
dryRun,
|
|
224
|
+
viaBaton: true,
|
|
225
|
+
testHooks: opts.testHooks,
|
|
226
|
+
creations: dryRun ? [] : [{ path: prepared.filePath, content: prepared.content }],
|
|
227
|
+
deferIndex: true,
|
|
228
|
+
});
|
|
229
|
+
} catch (err) {
|
|
230
|
+
if (/Destination already exists|File already exists/.test(String(err?.message))) refusePendingHandoff([prepared.repoPath]);
|
|
231
|
+
throw err;
|
|
232
|
+
}
|
|
233
|
+
createdSlug = prepared.slug;
|
|
234
|
+
promptRepoPath = prepared.repoPath;
|
|
235
|
+
statusChanged = oldStatus !== status;
|
|
236
|
+
if (!dryRun) {
|
|
237
|
+
try { config.hooks.onNew?.({ path: prepared.repoPath, status: 'pending', title: prepared.slug, type: 'prompt' }); }
|
|
238
|
+
catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
|
|
202
239
|
}
|
|
203
240
|
}
|
|
204
241
|
} finally {
|
|
205
242
|
if (json) process.stdout.write = originalStdoutWrite;
|
|
206
243
|
}
|
|
207
|
-
if (!createdSlug) die(`Could not find a free prompt slug for ${slugBase} (tried ${slugBase}-2 … ${slugBase}-9).`);
|
|
208
244
|
|
|
209
245
|
// A release status can FILE the plan into a bucket (`lifecycle.filedStatuses`,
|
|
210
246
|
// e.g. paused → docs/plans/held/). The prompt is created inside the same
|
package/src/check-collapse.mjs
CHANGED
|
@@ -14,19 +14,19 @@ const CATEGORIES = [
|
|
|
14
14
|
key: 'updated-behind-git',
|
|
15
15
|
match: /^frontmatter `updated:.*` is behind git history/,
|
|
16
16
|
label: 'docs have `updated` behind git history',
|
|
17
|
-
fix: '
|
|
17
|
+
fix: 'runlist touch --git',
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
key: 'singular-module',
|
|
21
|
-
match: /^`module:` \(singular\) is deprecated.*Run `
|
|
21
|
+
match: /^`module:` \(singular\) is deprecated.*Run `runlist lint --fix`/,
|
|
22
22
|
label: 'docs use deprecated singular `module:`',
|
|
23
|
-
fix: '
|
|
23
|
+
fix: 'runlist lint --fix',
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
key: 'singular-surface',
|
|
27
|
-
match: /^`surface:` \(singular\) is deprecated.*Run `
|
|
27
|
+
match: /^`surface:` \(singular\) is deprecated.*Run `runlist lint --fix`/,
|
|
28
28
|
label: 'docs use deprecated singular `surface:`',
|
|
29
|
-
fix: '
|
|
29
|
+
fix: 'runlist lint --fix',
|
|
30
30
|
},
|
|
31
31
|
];
|
|
32
32
|
|
package/src/claude-commands.mjs
CHANGED
|
@@ -16,7 +16,10 @@ import path from 'node:path';
|
|
|
16
16
|
// NEVER touched. Every dotmd-stamped file is fair game, including legacy ones
|
|
17
17
|
// dotmd no longer generates (e.g. the old baton.md).
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
// Nothing writes these files any more, so only the legacy banner can exist in
|
|
20
|
+
// the wild; the current spelling is accepted too so a banner-gated teardown
|
|
21
|
+
// never depends on which name stamped the file.
|
|
22
|
+
const GENERATED_MARKERS = ['<!-- runlist-generated:', '<!-- dotmd-generated:'];
|
|
20
23
|
|
|
21
24
|
// The marker sits just below the YAML frontmatter Claude Code surfaces as the
|
|
22
25
|
// command description. That description can be long (the retired plans.md baked
|
|
@@ -25,7 +28,8 @@ const GENERATED_MARKER = '<!-- dotmd-generated:';
|
|
|
25
28
|
// are tiny command files, so reading them in full is cheap.
|
|
26
29
|
function isGeneratedCommandFile(filePath) {
|
|
27
30
|
try {
|
|
28
|
-
|
|
31
|
+
const contents = readFileSync(filePath, 'utf8');
|
|
32
|
+
return GENERATED_MARKERS.some(marker => contents.includes(marker));
|
|
29
33
|
} catch {
|
|
30
34
|
return false;
|
|
31
35
|
}
|
package/src/commands.mjs
CHANGED
|
@@ -92,16 +92,16 @@ const definitions = [
|
|
|
92
92
|
command('prompts', mutates('managed sources/destinations by subcommand'), 'workflow', [
|
|
93
93
|
form('', { options: [flag('--json'), value('--status'), flag('--include-archived'), value('--sort'), value('--limit'), flag('--all')] }),
|
|
94
94
|
form('list', { subcommands: ['list', 'status'], options: [flag('--json'), value('--status'), flag('--include-archived'), value('--sort'), value('--limit'), flag('--all')] }),
|
|
95
|
-
form('next', { subcommands: ['next'] }),
|
|
96
|
-
form('use [file]', { subcommands: ['use', 'resume'], args: positionals(0, 1), options: [flag('--no-index'), flag('--show-files'), flag('--force')] }),
|
|
95
|
+
form('next', { subcommands: ['next'], options: [flag('--no-claim')] }),
|
|
96
|
+
form('use [file]', { subcommands: ['use', 'resume'], args: positionals(0, 1), options: [flag('--no-index'), flag('--show-files'), flag('--force'), flag('--no-claim')] }),
|
|
97
97
|
form('show [file...]', { subcommands: ['show', 'peek'], args: positionals(0, Infinity), options: [flag('--json'), flag('--all'), value('--limit')] }),
|
|
98
98
|
form('archive <file>', { subcommands: ['archive'], args: positionals(1, 1), options: [flag('--no-index'), flag('--show-files')] }),
|
|
99
99
|
form('new <slug> [body...]', { subcommands: ['new'], args: positionals(1, Infinity), options: [value('--body', '--message'), value('--title'), value('--status')] }),
|
|
100
100
|
form('hold <file>', { subcommands: ['hold', 'shelve'], args: positionals(1, 1) }),
|
|
101
101
|
form('unhold <file>', { subcommands: ['unhold', 'unshelve'], args: positionals(1, 1) }),
|
|
102
102
|
], { aliases: ['prompt'] }),
|
|
103
|
-
command('use', mutates('managed source when starting/consuming; docs remain read-only'), 'workflow', [form('[file]', { args: positionals(0, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files'), flag('--force')] })]),
|
|
104
|
-
command('next', mutates('managed prompt source and same-root archive destination'), 'workflow', [form('', { options: [flag('--json'), flag('--no-index'), flag('--show-files'), flag('--force')] })]),
|
|
103
|
+
command('use', mutates('managed source when starting/consuming; docs remain read-only'), 'workflow', [form('[file]', { args: positionals(0, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files'), flag('--force'), flag('--no-claim')] })]),
|
|
104
|
+
command('next', mutates('managed prompt source and same-root archive destination'), 'workflow', [form('', { options: [flag('--json'), flag('--no-index'), flag('--show-files'), flag('--force'), flag('--no-claim')] })]),
|
|
105
105
|
command('baton', mutates('managed plan/prompt sources and managed prompt destination'), 'workflow', [form('[plan|slug] <@<file>|->', { args: positionals(0, 2), options: [value('--status'), value('--note'), value('--body', '--message'), flag('--force'), flag('--json')] })]),
|
|
106
106
|
command('runlist', mutates('managed hubs/children and managed scaffold destinations'), 'workflow', [
|
|
107
107
|
form('<hub>', { args: positionals(1, 1), options: [flag('--json')] }),
|
|
@@ -244,7 +244,7 @@ export function commandCompletionWords(name) {
|
|
|
244
244
|
export function commandUsage(name) {
|
|
245
245
|
const definition = commandDefinition(name);
|
|
246
246
|
if (!definition) return null;
|
|
247
|
-
return definition.forms.map(commandForm => `
|
|
247
|
+
return definition.forms.map(commandForm => `runlist ${definition.name}${commandForm.syntax ? ` ${commandForm.syntax}` : ''}`).join('\n');
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
function optionMap(forms) {
|
|
@@ -267,12 +267,12 @@ function scanArgs(name, argv, options, passthrough = false, dashPositionalsAfter
|
|
|
267
267
|
if (!option) {
|
|
268
268
|
if (passthrough) continue;
|
|
269
269
|
if (positional.length >= dashPositionalsAfter) { positional.push(arg); continue; }
|
|
270
|
-
throw new Error(`Unknown flag for \`
|
|
270
|
+
throw new Error(`Unknown flag for \`runlist ${name}\`: ${arg}`);
|
|
271
271
|
}
|
|
272
272
|
if (option.arity === 1) {
|
|
273
273
|
const next = argv[i + 1];
|
|
274
274
|
if (next === undefined || options.has(next)) {
|
|
275
|
-
throw new Error(`Missing value for \`${arg}\` in \`
|
|
275
|
+
throw new Error(`Missing value for \`${arg}\` in \`runlist ${name}\`.`);
|
|
276
276
|
}
|
|
277
277
|
i += 1;
|
|
278
278
|
} else if (option.arity === 'optional' && argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) {
|
|
@@ -317,7 +317,7 @@ export function validateCommandArgs(name, argv, { preset = false } = {}) {
|
|
|
317
317
|
const preliminary = scanArgs(canonical, normalized, allOptions, allPassthrough, dashPositionalsAfter);
|
|
318
318
|
const selected = definition.forms.find(commandForm => commandForm.subcommands.includes(preliminary[0]))
|
|
319
319
|
?? definition.forms.find(commandForm => commandForm.subcommands.length === 0);
|
|
320
|
-
if (!selected) throw new Error(`Unknown subcommand for \`
|
|
320
|
+
if (!selected) throw new Error(`Unknown subcommand for \`runlist ${canonical}\`: ${preliminary[0] ?? '(missing)'}`);
|
|
321
321
|
const selectedOptions = optionMap([selected]);
|
|
322
322
|
const positional = scanArgs(canonical, normalized, selectedOptions, selected.passthrough, selected.dashPositionalsAfter);
|
|
323
323
|
const args = selected.subcommands.includes(positional[0]) ? positional.slice(1) : positional;
|
package/src/config.mjs
CHANGED
|
@@ -218,10 +218,10 @@ function normalizeRichStatuses(config, userConfig) {
|
|
|
218
218
|
const skipStaleEffective = p.skipStale === true || quietImpliesSkipStale;
|
|
219
219
|
const skipWarningsEffective = p.skipWarnings === true || quietImpliesSkipWarnings;
|
|
220
220
|
if (skipStaleEffective && p.staleDays != null) {
|
|
221
|
-
warn(`
|
|
221
|
+
warn(`runlist config: status "${typeName}.${name}" has skipStale: true and staleDays: ${p.staleDays} — staleDays is ignored. Drop one to silence this warning.`);
|
|
222
222
|
}
|
|
223
223
|
if (skipWarningsEffective && p.requiresModule) {
|
|
224
|
-
warn(`
|
|
224
|
+
warn(`runlist config: status "${typeName}.${name}" has skipWarnings: true and requiresModule: true — the module requirement can never fire. Drop one to silence this warning.`);
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
if (p.archive && !derived.archiveStatuses.includes(name)) derived.archiveStatuses.push(name);
|
package/src/deps.mjs
CHANGED
|
@@ -252,7 +252,7 @@ function renderFlatJson(graph, forwardMap, reverseMap, docByPath) {
|
|
|
252
252
|
export function runUnblocks(argv, config) {
|
|
253
253
|
const input = argv.find(a => !a.startsWith('-'));
|
|
254
254
|
const json = argv.includes('--json');
|
|
255
|
-
if (!input) die('Usage:
|
|
255
|
+
if (!input) die('Usage: runlist unblocks <file>');
|
|
256
256
|
|
|
257
257
|
const filePath = resolveDocArg(input, config);
|
|
258
258
|
const repoPath = toRepoPath(filePath, config.repoRoot);
|
package/src/doctor.mjs
CHANGED
|
@@ -99,7 +99,7 @@ function runDoctorTransactions(argv, config, opts = {}) {
|
|
|
99
99
|
}
|
|
100
100
|
if (resolvable.length) {
|
|
101
101
|
process.stdout.write(`\n${resolvable.length} resolvable — the canonical files already agree on one generation.\n`);
|
|
102
|
-
process.stdout.write(dim('Run `
|
|
102
|
+
process.stdout.write(dim('Run `runlist doctor --transactions --apply` to clear them (no document content is touched).\n'));
|
|
103
103
|
}
|
|
104
104
|
const stuck = report.filter(item => !item.resolvable && !clearedIds.has(item.id) && (item.status === 'failed-manual' || !item.readable));
|
|
105
105
|
if (stuck.length) {
|
|
@@ -152,7 +152,7 @@ async function runDoctorClaims(argv, config, opts = {}) {
|
|
|
152
152
|
// status into. Deciding by existence here rather than by catching
|
|
153
153
|
// runSet's "File not found" keeps the bypass narrow and explicit.
|
|
154
154
|
if (existsSync(path.resolve(config.repoRoot, claim.plan))) {
|
|
155
|
-
await runSet(['active', claim.plan], config, { force: true, sessionId: operator, note: 'Claim released by `
|
|
155
|
+
await runSet(['active', claim.plan], config, { force: true, sessionId: operator, note: 'Claim released by `runlist doctor --claims` — the owning session was gone.' });
|
|
156
156
|
} else {
|
|
157
157
|
releaseVanishedPlanClaim(claim, config);
|
|
158
158
|
claim.vanished = true;
|
|
@@ -201,13 +201,13 @@ async function runDoctorClaims(argv, config, opts = {}) {
|
|
|
201
201
|
const pendingDead = dead.filter(claim => !releasedSet.has(claim.plan));
|
|
202
202
|
if (pendingDead.length) {
|
|
203
203
|
process.stdout.write(yellow(`\n${pendingDead.length} held by a session whose process is gone.\n`));
|
|
204
|
-
process.stdout.write(dim('Run `
|
|
204
|
+
process.stdout.write(dim('Run `runlist doctor --claims --apply` to release them.\n'));
|
|
205
205
|
}
|
|
206
206
|
const unjudgeable = claims.filter(claim =>
|
|
207
207
|
!claim.corrupt && claim.liveness !== 'dead' && !releasedSet.has(claim.plan));
|
|
208
208
|
if (unjudgeable.length && olderThanMs === null) {
|
|
209
209
|
process.stdout.write(dim(`\n${unjudgeable.length} cannot be judged from here — no owning process was recorded, or it is on another machine.\n`));
|
|
210
|
-
process.stdout.write(dim('If you know those sessions are over: `
|
|
210
|
+
process.stdout.write(dim('If you know those sessions are over: `runlist doctor --claims --apply --older-than 24h`.\n'));
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
213
|
|
|
@@ -228,10 +228,10 @@ function runDoctorSession(argv) {
|
|
|
228
228
|
for (const line of identity.advice) process.stdout.write(` → ${line}\n`);
|
|
229
229
|
|
|
230
230
|
process.stdout.write('\n' + bold('Host integration\n'));
|
|
231
|
-
if (oc.foreign) process.stdout.write(` ${yellow('!')} opencode: a dotmd.js
|
|
231
|
+
if (oc.foreign) process.stdout.write(` ${yellow('!')} opencode: a dotmd.js runlist did not write — ${oc.path}\n`);
|
|
232
232
|
else if (!oc.exists) process.stdout.write(` ${dim('·')} opencode: not installed — ${dim(oc.path)}\n`);
|
|
233
|
-
else process.stdout.write(` ${oc.stale ? yellow('!') : green('✓')} opencode: ${oc.version}${oc.stale ? ` (CLI is ${dotmdVersion()} — run \`
|
|
234
|
-
process.stdout.write(dim(' Claude Code ships as a plugin — `
|
|
233
|
+
else process.stdout.write(` ${oc.stale ? yellow('!') : green('✓')} opencode: ${oc.version}${oc.stale ? ` (CLI is ${dotmdVersion()} — run \`runlist update\`)` : ''}\n`);
|
|
234
|
+
process.stdout.write(dim(' Claude Code ships as a plugin — `runlist install` reports both hosts.\n'));
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
export function runDoctor(argv, config, opts = {}) {
|
|
@@ -273,7 +273,7 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
273
273
|
const modeNote = dryRun
|
|
274
274
|
? dim('[preview — run with --apply to write]')
|
|
275
275
|
: dim('[applying changes]');
|
|
276
|
-
process.stdout.write(bold('
|
|
276
|
+
process.stdout.write(bold('runlist doctor') + ' ' + modeNote + '\n\n');
|
|
277
277
|
if (dryRun) {
|
|
278
278
|
const skippedHooks = ['validate', 'transformDoc', 'formatSnapshot', 'renderCheck']
|
|
279
279
|
.filter(name => typeof config.hooks?.[name] === 'function');
|
|
@@ -335,7 +335,7 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
335
335
|
process.stdout.write('[dry-run] No retired slash-command files to remove.\n');
|
|
336
336
|
} else {
|
|
337
337
|
for (const r of wouldRemove) {
|
|
338
|
-
process.stdout.write(`[dry-run] Would remove retired .claude/commands/${r.name} (guidance now ships via the
|
|
338
|
+
process.stdout.write(`[dry-run] Would remove retired .claude/commands/${r.name} (guidance now ships via the runlist plugin).\n`);
|
|
339
339
|
}
|
|
340
340
|
}
|
|
341
341
|
} else {
|
|
@@ -344,7 +344,7 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
344
344
|
process.stdout.write('Nothing to clean up.\n');
|
|
345
345
|
} else {
|
|
346
346
|
for (const r of removed) {
|
|
347
|
-
process.stdout.write(`${green('Removed')} retired .claude/commands/${r.name} (guidance now ships via the
|
|
347
|
+
process.stdout.write(`${green('Removed')} retired .claude/commands/${r.name} (guidance now ships via the runlist plugin)\n`);
|
|
348
348
|
}
|
|
349
349
|
}
|
|
350
350
|
}
|
|
@@ -369,7 +369,7 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
369
369
|
process.stdout.write('\n' + bold('Session identity') + '\n');
|
|
370
370
|
process.stdout.write(`${yellow('!')} ${identity.summary}\n`);
|
|
371
371
|
for (const line of identity.advice) process.stdout.write(dim(` → ${line}\n`));
|
|
372
|
-
process.stdout.write(dim(' `
|
|
372
|
+
process.stdout.write(dim(' `runlist doctor --session` for the full picture.\n'));
|
|
373
373
|
}
|
|
374
374
|
}
|
|
375
375
|
|
|
@@ -387,7 +387,7 @@ function findDeprecatedCommandMentions(config) {
|
|
|
387
387
|
for (const filePath of docs) {
|
|
388
388
|
let raw = '';
|
|
389
389
|
try { raw = readFileSync(filePath, 'utf8'); } catch { continue; }
|
|
390
|
-
if (/\
|
|
390
|
+
if (/\b(?:dotmd|runlist|rl) status\b/.test(raw) || /\b(?:dotmd|runlist|rl) (pickup|unpickup|release|finish)\b/.test(raw)) {
|
|
391
391
|
matches.push(toRepoPath(filePath, config.repoRoot));
|
|
392
392
|
}
|
|
393
393
|
}
|
|
@@ -445,7 +445,7 @@ function runDoctorProject(config, { json = false } = {}) {
|
|
|
445
445
|
return result;
|
|
446
446
|
}
|
|
447
447
|
|
|
448
|
-
process.stdout.write(bold('
|
|
448
|
+
process.stdout.write(bold('runlist doctor --project') + '\n\n');
|
|
449
449
|
process.stdout.write(`- running CLI version: ${result.cliVersion ?? 'unknown'}\n`);
|
|
450
450
|
process.stdout.write(`- package dependency: ${result.packageDependency ?? '(none found)'}\n`);
|
|
451
451
|
process.stdout.write(`- stale Claude commands: ${claudeCommandWarnings.length}\n`);
|
|
@@ -456,18 +456,18 @@ function runDoctorProject(config, { json = false } = {}) {
|
|
|
456
456
|
process.stdout.write('- docs mentioning deprecated commands: 0\n');
|
|
457
457
|
}
|
|
458
458
|
if (docsWithoutFrontmatter.length) {
|
|
459
|
-
process.stdout.write(yellow(`- docs without a frontmatter block: ${docsWithoutFrontmatter.length} — every status verb (\`set\`, \`archive\`, \`baton\`) dies on these. Fix:
|
|
459
|
+
process.stdout.write(yellow(`- docs without a frontmatter block: ${docsWithoutFrontmatter.length} — every status verb (\`set\`, \`archive\`, \`baton\`) dies on these. Fix: runlist bulk-tag <file> --type <type> --status <status>`) + '\n');
|
|
460
460
|
for (const file of docsWithoutFrontmatter.slice(0, 10)) process.stdout.write(` - ${file}\n`);
|
|
461
461
|
} else {
|
|
462
462
|
process.stdout.write('- docs without a frontmatter block: 0\n');
|
|
463
463
|
}
|
|
464
464
|
if (planStatusGaps.length) {
|
|
465
|
-
process.stdout.write(yellow(`- plan status vocab missing: ${planStatusGaps.join(', ')} — \`
|
|
465
|
+
process.stdout.write(yellow(`- plan status vocab missing: ${planStatusGaps.join(', ')} — \`runlist use\` and \`runlist baton\` depend on these; add them to types.plan.statuses in runlist.config.mjs`) + '\n');
|
|
466
466
|
} else {
|
|
467
467
|
process.stdout.write('- plan status vocab: ok\n');
|
|
468
468
|
}
|
|
469
469
|
if (skillDriftWarnings.length) {
|
|
470
|
-
process.stdout.write(yellow(`- canonical workflow block: drifted — CLAUDE.md and the plugin SKILL.md teach different workflows. Reconcile the block between the \`
|
|
470
|
+
process.stdout.write(yellow(`- canonical workflow block: drifted — CLAUDE.md and the plugin SKILL.md teach different workflows. Reconcile the block between the \`runlist:canonical-workflow\` markers in both files.`) + '\n');
|
|
471
471
|
} else {
|
|
472
472
|
process.stdout.write('- canonical workflow block: in sync\n');
|
|
473
473
|
}
|
|
@@ -559,7 +559,7 @@ function runDoctorStatuses(config, { json = false } = {}) {
|
|
|
559
559
|
return;
|
|
560
560
|
}
|
|
561
561
|
|
|
562
|
-
process.stdout.write(bold('
|
|
562
|
+
process.stdout.write(bold('runlist doctor --statuses') + '\n\n');
|
|
563
563
|
|
|
564
564
|
if (suggestions.length === 0) {
|
|
565
565
|
process.stdout.write(`No overloaded status buckets detected (min bucket size: ${MIN_BUCKET_SIZE}).\n`);
|
package/src/fix-membership.mjs
CHANGED
|
@@ -21,7 +21,7 @@ function resolveHubArgs(args, docs) {
|
|
|
21
21
|
const [match] = matches;
|
|
22
22
|
if (!isHubDoc(match)) {
|
|
23
23
|
die(`${match.path} is not a hub — it has no \`runlist:\` and no \`execution_mode: coordination|roadmap\`. `
|
|
24
|
-
+ 'Run `
|
|
24
|
+
+ 'Run `runlist fix-membership` with no argument to sweep every hub.');
|
|
25
25
|
}
|
|
26
26
|
paths.add(match.path);
|
|
27
27
|
}
|
package/src/frontmatter-fix.mjs
CHANGED
|
@@ -61,7 +61,7 @@ export function runFrontmatterFix(config, opts = {}) {
|
|
|
61
61
|
|
|
62
62
|
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
63
63
|
const banner = dryRun ? dim(' [preview — run without --dry-run to write]') : '';
|
|
64
|
-
out.write(bold('
|
|
64
|
+
out.write(bold('runlist doctor --frontmatter-fix') + banner + '\n\n');
|
|
65
65
|
|
|
66
66
|
if (results.length === 0) {
|
|
67
67
|
out.write(green('No over-cap fields found.') + '\n');
|
package/src/git.mjs
CHANGED
|
@@ -36,7 +36,7 @@ function ensureGit() {
|
|
|
36
36
|
if (gitChecked) return;
|
|
37
37
|
const result = spawnSync('git', ['--version'], { encoding: 'utf8' });
|
|
38
38
|
if (result.error) {
|
|
39
|
-
throw new Error('git is not installed or not found in PATH.
|
|
39
|
+
throw new Error('git is not installed or not found in PATH. runlist requires git for this operation.');
|
|
40
40
|
}
|
|
41
41
|
gitChecked = true;
|
|
42
42
|
}
|
package/src/glossary.mjs
CHANGED
|
@@ -183,9 +183,9 @@ export function runGlossary(argv, config) {
|
|
|
183
183
|
const term = argv.find(a => !a.startsWith('-'));
|
|
184
184
|
|
|
185
185
|
const result = loadGlossary(config);
|
|
186
|
-
if (!result) die('No glossary configured. Add glossary: { path, section } to your
|
|
186
|
+
if (!result) die('No glossary configured. Add glossary: { path, section } to your runlist config.');
|
|
187
187
|
if (!result.found) {
|
|
188
|
-
die(`Glossary section "## ${result.section}" not found in ${result.path}. Add the section, or update glossary.section in
|
|
188
|
+
die(`Glossary section "## ${result.section}" not found in ${result.path}. Add the section, or update glossary.section in runlist.config.mjs.`);
|
|
189
189
|
}
|
|
190
190
|
if (result.entries.length === 0) {
|
|
191
191
|
die(`Glossary section "## ${result.section}" found in ${result.path} but contains no recognizable entries (expected markdown table or schema→UI bullets).`);
|
|
@@ -212,7 +212,7 @@ export function runGlossary(argv, config) {
|
|
|
212
212
|
return;
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
if (!term) die('Usage:
|
|
215
|
+
if (!term) die('Usage: runlist glossary <term> | --list | --json');
|
|
216
216
|
|
|
217
217
|
const matches = matchTerm(term, entries);
|
|
218
218
|
|
package/src/graph.mjs
CHANGED
|
@@ -208,7 +208,7 @@ function _renderGraphText(graph, config) {
|
|
|
208
208
|
export function renderGraphDot(graph, config) {
|
|
209
209
|
const { nodes, edges } = graph;
|
|
210
210
|
const lines = [];
|
|
211
|
-
lines.push('digraph
|
|
211
|
+
lines.push('digraph runlist {');
|
|
212
212
|
lines.push(' rankdir=LR;');
|
|
213
213
|
lines.push(' node [shape=box, style="rounded,filled", fontname="Helvetica"];');
|
|
214
214
|
lines.push('');
|