dotmd-cli 0.60.0 → 0.62.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 +102 -13
- package/bin/dotmd.mjs +109 -8
- package/dotmd.config.example.mjs +21 -4
- package/package.json +1 -1
- package/src/baton.mjs +231 -0
- package/src/commands.mjs +2 -2
- package/src/completions.mjs +1 -1
- package/src/doctor.mjs +38 -0
- package/src/guard.mjs +84 -32
- package/src/health.mjs +55 -9
- package/src/hud.mjs +40 -18
- package/src/index.mjs +8 -1
- package/src/lifecycle.mjs +4 -1
- package/src/new.mjs +7 -1
- package/src/prompts.mjs +25 -1
- package/src/query.mjs +305 -46
- package/src/render.mjs +22 -3
- package/src/runlist.mjs +118 -0
- package/src/validate.mjs +29 -2
package/src/baton.mjs
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { readFileSync, fstatSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { asString, toRepoPath, die, warn, currentSessionId } from './util.mjs';
|
|
5
|
+
import { buildIndex, resolveDocArg } from './index.mjs';
|
|
6
|
+
import { readJournalEntries } from './journal.mjs';
|
|
7
|
+
import { runNew, readBodyInput } from './new.mjs';
|
|
8
|
+
import { runSet } from './lifecycle.mjs';
|
|
9
|
+
import { green, dim } from './color.mjs';
|
|
10
|
+
|
|
11
|
+
// `dotmd baton` is the one-command handoff: save the resume prompt AND release
|
|
12
|
+
// the plan in a single atomic-ish verb. It exists because the three-step skill
|
|
13
|
+
// version ("save prompt, pick a status, commit") kept expanding in practice —
|
|
14
|
+
// sessions turned closeout into repo triage, forgot the prompt body, or got
|
|
15
|
+
// tangled in what to commit. Baton does exactly one plan, one prompt, one
|
|
16
|
+
// status flip, and then *tells* the agent the exact commit command.
|
|
17
|
+
|
|
18
|
+
// Does a journal argv doc reference point at this index doc? References come
|
|
19
|
+
// from `use <x>` / `set in-session <x>` invocations, so they may be a repo
|
|
20
|
+
// path, a bare basename, or a slug without .md.
|
|
21
|
+
function matchesDocRef(doc, ref) {
|
|
22
|
+
if (typeof ref !== 'string' || !ref) return false;
|
|
23
|
+
const cleaned = ref.replace(/^\.\//, '');
|
|
24
|
+
if (doc.path === cleaned) return true;
|
|
25
|
+
const base = path.basename(doc.path, '.md');
|
|
26
|
+
if (cleaned === base || cleaned === `${base}.md`) return true;
|
|
27
|
+
return doc.path.endsWith(`/${cleaned}`) || doc.path.endsWith(`/${cleaned}.md`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Resolve which in-session plan belongs to THIS session. There is no checkout
|
|
31
|
+
// or lock — in-session is just frontmatter — so ownership is reconstructed
|
|
32
|
+
// from the per-repo journal: the last `use <plan>` / `set in-session <plan>`
|
|
33
|
+
// this sid ran whose target is still in-session. Falls back to "the only
|
|
34
|
+
// in-session plan" when the journal can't answer (disabled, or another tool
|
|
35
|
+
// flipped the status). Returns { plan, via, inSession }; plan is null when
|
|
36
|
+
// there's no defensible answer (caller decides how to ask).
|
|
37
|
+
export function findOwnedPlan(config, index = null) {
|
|
38
|
+
const idx = index ?? buildIndex(config);
|
|
39
|
+
const inSession = idx.docs.filter(d => d.type === 'plan' && d.status === 'in-session');
|
|
40
|
+
if (inSession.length === 0) return { plan: null, via: null, inSession };
|
|
41
|
+
|
|
42
|
+
const sid = currentSessionId();
|
|
43
|
+
let entries = [];
|
|
44
|
+
try { entries = readJournalEntries(config); } catch { entries = []; }
|
|
45
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
46
|
+
const e = entries[i];
|
|
47
|
+
if (e?.sid !== sid || !Array.isArray(e.argv) || (e.exit ?? 0) !== 0) continue;
|
|
48
|
+
const a = e.argv;
|
|
49
|
+
let ref = null;
|
|
50
|
+
if (a[0] === 'use') ref = a.slice(1).find(x => typeof x === 'string' && !x.startsWith('-'));
|
|
51
|
+
else if (a[0] === 'set' && a[1] === 'in-session') ref = a.slice(2).find(x => typeof x === 'string' && !x.startsWith('-'));
|
|
52
|
+
else if (a[0] === 'status' && a.includes('in-session')) ref = a.slice(1).find(x => typeof x === 'string' && !x.startsWith('-') && x !== 'in-session');
|
|
53
|
+
if (!ref) continue;
|
|
54
|
+
const doc = inSession.find(d => matchesDocRef(d, ref));
|
|
55
|
+
if (doc) return { plan: doc, via: 'journal', inSession };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (inSession.length === 1) return { plan: inSession[0], via: 'single-in-session', inSession };
|
|
59
|
+
return { plan: null, via: null, inSession };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const BODY_USAGE = `dotmd baton needs the resume draft as its body. Write 10–20 lines first — the next concrete decision plus any gotchas, NOT a recap of the plan — then:
|
|
63
|
+
dotmd baton @/tmp/draft.md # body from file (preferred)
|
|
64
|
+
cat /tmp/draft.md | dotmd baton # body from stdin
|
|
65
|
+
dotmd baton --message "..." # one-liner
|
|
66
|
+
No plan in-session? Name the handoff instead: dotmd baton <slug> @/tmp/draft.md`;
|
|
67
|
+
|
|
68
|
+
// Is this positional a filesystem reference (must resolve, typos die) or a
|
|
69
|
+
// bare word (may be a plan slug, may be a brand-new handoff name)?
|
|
70
|
+
function looksLikePath(arg) {
|
|
71
|
+
return arg.includes('/') || arg.endsWith('.md');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function runBaton(argv, config, opts = {}) {
|
|
75
|
+
const { dryRun } = opts;
|
|
76
|
+
|
|
77
|
+
let status = 'active';
|
|
78
|
+
let statusFlag = false;
|
|
79
|
+
let note = null;
|
|
80
|
+
let bodyFlag = null;
|
|
81
|
+
const positionals = [];
|
|
82
|
+
for (let i = 0; i < argv.length; i++) {
|
|
83
|
+
const a = argv[i];
|
|
84
|
+
if (a === '--status' && argv[i + 1]) { status = argv[++i]; statusFlag = true; continue; }
|
|
85
|
+
if (a === '--note' && argv[i + 1]) { note = argv[++i]; continue; }
|
|
86
|
+
if ((a === '--body' || a === '--message') && argv[i + 1]) { bodyFlag = argv[++i]; continue; }
|
|
87
|
+
if (!a.startsWith('-') || a === '-' || a.startsWith('@')) { positionals.push(a); continue; }
|
|
88
|
+
die(`Unknown flag for \`dotmd baton\`: ${a}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let planArg = null;
|
|
92
|
+
let bodyArg = null;
|
|
93
|
+
for (const p of positionals) {
|
|
94
|
+
if (p === '-' || p.startsWith('@')) { bodyArg = p; continue; }
|
|
95
|
+
if (!planArg) { planArg = p; continue; }
|
|
96
|
+
if (bodyArg === null) bodyArg = p; // trailing inline body
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Body FIRST — it's the common failure (`new prompt` without a body was the
|
|
100
|
+
// top real-world baton error), and nothing must mutate before it's secured.
|
|
101
|
+
let body = null;
|
|
102
|
+
if (bodyFlag !== null) body = bodyFlag;
|
|
103
|
+
else if (bodyArg !== null) body = readBodyInput(bodyArg);
|
|
104
|
+
else {
|
|
105
|
+
// Auto-consume piped/redirected stdin, same probe as `dotmd new`.
|
|
106
|
+
try {
|
|
107
|
+
const stat = fstatSync(0);
|
|
108
|
+
if (stat.isFIFO() || stat.isFile() || stat.isSocket()) {
|
|
109
|
+
const piped = readFileSync(0, 'utf8');
|
|
110
|
+
if (piped.length > 0) body = piped;
|
|
111
|
+
}
|
|
112
|
+
} catch { /* stdin not introspectable */ }
|
|
113
|
+
}
|
|
114
|
+
if (!body || !body.trim()) die(BODY_USAGE);
|
|
115
|
+
|
|
116
|
+
// Resolve what's being handed off. Two modes:
|
|
117
|
+
// plan mode — a plan is released alongside the prompt (one status flip).
|
|
118
|
+
// slug mode — no plan involved: "save a resume prompt for what I'm doing
|
|
119
|
+
// right now". The hallmark use ("update the docs and save a resume prompt
|
|
120
|
+
// for this") must work mid-anything, claimed plan or not — baton does
|
|
121
|
+
// nothing but save the prompt in this mode.
|
|
122
|
+
let planPath = null;
|
|
123
|
+
let promptSlug = null;
|
|
124
|
+
if (planArg) {
|
|
125
|
+
if (looksLikePath(planArg)) {
|
|
126
|
+
planPath = resolveDocArg(planArg, config); // typos die loudly — a mistyped path must not silently become a prompt name
|
|
127
|
+
} else {
|
|
128
|
+
// Bare word: a plan slug if it resolves to a plan, else a handoff name.
|
|
129
|
+
const resolved = resolveDocArg(planArg, config, { dieOnMiss: false });
|
|
130
|
+
let resolvedType = null;
|
|
131
|
+
if (resolved) {
|
|
132
|
+
try {
|
|
133
|
+
const { frontmatter: fmProbe } = extractFrontmatter(readFileSync(resolved, 'utf8'));
|
|
134
|
+
resolvedType = fmProbe ? asString(parseSimpleFrontmatter(fmProbe).type) : null;
|
|
135
|
+
} catch { resolvedType = null; }
|
|
136
|
+
}
|
|
137
|
+
if (resolved && resolvedType === 'plan') planPath = resolved;
|
|
138
|
+
else promptSlug = planArg;
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
const owned = findOwnedPlan(config);
|
|
142
|
+
if (owned.plan) {
|
|
143
|
+
planPath = path.resolve(config.repoRoot, owned.plan.path);
|
|
144
|
+
if (owned.via === 'single-in-session') {
|
|
145
|
+
process.stderr.write(dim(`Handing off the only in-session plan: ${owned.plan.path}\n`));
|
|
146
|
+
}
|
|
147
|
+
} else if (owned.inSession.length > 1) {
|
|
148
|
+
die(`Multiple plans are in-session and the journal can't tell which is this session's — pass yours explicitly:\n${owned.inSession.map(d => ' dotmd baton ' + d.path + ' @/tmp/draft.md').join('\n')}\nNot about a plan? Name the handoff instead: dotmd baton <slug> @/tmp/draft.md`);
|
|
149
|
+
} else {
|
|
150
|
+
die(`No in-session plan, so baton needs a name for the resume prompt:\n dotmd baton <slug> @/tmp/draft.md # saves resume-<slug>, touches nothing else\nHanding off a specific plan? dotmd baton <plan-file> @/tmp/draft.md`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let repoPath = null;
|
|
155
|
+
let oldStatus = null;
|
|
156
|
+
if (planPath) {
|
|
157
|
+
repoPath = toRepoPath(planPath, config.repoRoot);
|
|
158
|
+
const raw = readFileSync(planPath, 'utf8');
|
|
159
|
+
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
160
|
+
if (!fmRaw) {
|
|
161
|
+
die(`${repoPath} has no frontmatter block — baton can't flip its status.\nFix the doc first (\`dotmd bulk-tag ${repoPath} --type plan --status in-session\`), or save the prompt without a status flip: dotmd baton ${path.basename(planPath, '.md')} @/tmp/draft.md`);
|
|
162
|
+
}
|
|
163
|
+
const fm = parseSimpleFrontmatter(fmRaw);
|
|
164
|
+
const docType = asString(fm.type);
|
|
165
|
+
oldStatus = asString(fm.status) ?? 'unset';
|
|
166
|
+
if (docType && docType !== 'plan') warn(`${repoPath} has type '${docType}', not 'plan'.`);
|
|
167
|
+
|
|
168
|
+
// Validate the target status BEFORE creating the prompt so a bad --status
|
|
169
|
+
// doesn't leave a half-done handoff.
|
|
170
|
+
const validStatuses = config.typeStatuses?.get(docType ?? 'plan') ?? config.validStatuses;
|
|
171
|
+
if (validStatuses && validStatuses.size > 0 && !validStatuses.has(status)) {
|
|
172
|
+
die(`Invalid status \`${status}\` for type \`${docType ?? 'plan'}\`\nValid: ${[...validStatuses].join(', ')}`);
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
if (statusFlag) warn(`--status ignored — no plan involved in this handoff (saving the prompt only).`);
|
|
176
|
+
if (note) warn(`--note ignored — no plan involved in this handoff (notes land in a plan's Version History).`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 1. Save the resume prompt. Collision-safe: resume-<slug>, then -2, -3, …
|
|
180
|
+
// (a pending resume-<slug> from an earlier handoff must never block this one,
|
|
181
|
+
// and bodies are not mergeable).
|
|
182
|
+
const nameBase = planPath ? path.basename(planPath, '.md') : promptSlug;
|
|
183
|
+
const slugBase = nameBase.startsWith('resume-') ? nameBase : `resume-${nameBase}`;
|
|
184
|
+
let createdSlug = null;
|
|
185
|
+
for (let n = 1; n <= 9 && !createdSlug; n++) {
|
|
186
|
+
const slug = n === 1 ? slugBase : `${slugBase}-${n}`;
|
|
187
|
+
try {
|
|
188
|
+
await runNew(['prompt', slug, '--body', body], config, { dryRun });
|
|
189
|
+
createdSlug = slug;
|
|
190
|
+
} catch (err) {
|
|
191
|
+
if (!/File already exists/.test(String(err?.message))) throw err;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!createdSlug) die(`Could not find a free prompt slug for ${slugBase} (tried ${slugBase}-2 … ${slugBase}-9).`);
|
|
195
|
+
|
|
196
|
+
// 2. Release the plan — exactly one status flip. Skipped entirely in slug
|
|
197
|
+
// mode: with no plan involved there is nothing to release.
|
|
198
|
+
let archiveResult = null;
|
|
199
|
+
let statusChanged = false;
|
|
200
|
+
if (planPath) {
|
|
201
|
+
if (oldStatus === status) {
|
|
202
|
+
process.stderr.write(dim(`Plan already ${status}: ${repoPath} (no status change)\n`));
|
|
203
|
+
} else {
|
|
204
|
+
const setArgs = [status, planPath];
|
|
205
|
+
if (note) setArgs.push('--note', note);
|
|
206
|
+
archiveResult = await runSet(setArgs, config, { dryRun });
|
|
207
|
+
statusChanged = true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 3. Tell the agent exactly what to commit — and what NOT to. The prompt is
|
|
212
|
+
// session-local (often gitignored); only the plan's frontmatter change is
|
|
213
|
+
// repo state.
|
|
214
|
+
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
215
|
+
process.stderr.write(`\n${prefix}${green('✓ Baton passed')}: ${createdSlug} (the next session's hud surfaces it — nothing to paste into chat)\n`);
|
|
216
|
+
if (statusChanged) {
|
|
217
|
+
const newRepoPath = archiveResult?.newRepoPath ?? null;
|
|
218
|
+
const pathspec = newRepoPath && newRepoPath !== repoPath ? `${repoPath} ${newRepoPath}` : repoPath;
|
|
219
|
+
let gitignored = false;
|
|
220
|
+
try {
|
|
221
|
+
const { isGitIgnored } = await import('./git.mjs');
|
|
222
|
+
gitignored = isGitIgnored(planPath, config.repoRoot);
|
|
223
|
+
} catch { /* not a git repo — fall through to the hint */ }
|
|
224
|
+
if (gitignored) {
|
|
225
|
+
process.stderr.write(dim(`${repoPath} is gitignored — no commit needed.\n`));
|
|
226
|
+
} else {
|
|
227
|
+
process.stderr.write(`${prefix}Commit the plan's status change (keep the prompt OUT of the pathspec — it's session-local):\n`);
|
|
228
|
+
process.stderr.write(`${prefix} git commit -m "baton: ${path.basename(planPath, '.md')} ${oldStatus} → ${status}" -- ${pathspec}\n`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
package/src/commands.mjs
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
// templates points at a real command.
|
|
5
5
|
export const KNOWN_COMMANDS = [
|
|
6
6
|
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'agent-context', 'hud',
|
|
7
|
-
'focus', 'query', 'grep', 'plans', 'prompts', 'stale', 'actionable', 'index', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor', 'runlist',
|
|
7
|
+
'focus', 'query', 'grep', 'plans', 'prompts', 'stale', 'actionable', 'index', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor', 'runlist', 'runlists',
|
|
8
8
|
'unblocks', 'health', 'glossary', 'modules', 'module',
|
|
9
9
|
'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
|
|
10
10
|
'watch', 'diff', 'new', 'init', 'completions', 'statuses', 'journal',
|
|
11
11
|
'guard', 'misuse', 'update',
|
|
12
|
-
'ship', 'self-check',
|
|
12
|
+
'ship', 'self-check', 'baton',
|
|
13
13
|
];
|
package/src/completions.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { die } from './util.mjs';
|
|
|
2
2
|
|
|
3
3
|
const COMMANDS = [
|
|
4
4
|
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'unblocks', 'health', 'glossary', 'briefing', 'context', 'focus', 'query', 'grep',
|
|
5
|
-
'plans', 'stale', 'actionable', 'index', 'status', 'set', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
|
|
5
|
+
'plans', 'runlist', 'runlists', 'stale', 'actionable', 'index', 'status', 'set', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
|
|
6
6
|
'fix-refs', 'notion', 'export', 'summary', 'watch', 'diff', 'init', 'new', 'completions', 'journal',
|
|
7
7
|
];
|
|
8
8
|
|
package/src/doctor.mjs
CHANGED
|
@@ -155,6 +155,30 @@ function findDeprecatedCommandMentions(config) {
|
|
|
155
155
|
return matches;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
// Workflow-drift checks: configurations and docs that make the agent-facing
|
|
159
|
+
// verbs (`use`, `set`, `baton`) blow up at the worst moment — mid-handoff.
|
|
160
|
+
// Both failure modes came from real sessions: a repo whose plan vocab dropped
|
|
161
|
+
// `in-session` (every `dotmd use` died), and a repo full of docs without
|
|
162
|
+
// frontmatter blocks (every `dotmd set` died during closeout).
|
|
163
|
+
function findWorkflowDrift(config) {
|
|
164
|
+
const docsWithoutFrontmatter = [];
|
|
165
|
+
for (const filePath of collectDocFiles(config)) {
|
|
166
|
+
let raw = '';
|
|
167
|
+
try { raw = readFileSync(filePath, 'utf8'); } catch { continue; }
|
|
168
|
+
if (!raw.startsWith('---\n')) docsWithoutFrontmatter.push(toRepoPath(filePath, config.repoRoot));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const planStatusGaps = [];
|
|
172
|
+
const planStatuses = config.typeStatuses?.get('plan');
|
|
173
|
+
if (planStatuses && planStatuses.size > 0) {
|
|
174
|
+
for (const required of ['in-session', 'active']) {
|
|
175
|
+
if (!planStatuses.has(required)) planStatusGaps.push(required);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { docsWithoutFrontmatter, planStatusGaps };
|
|
180
|
+
}
|
|
181
|
+
|
|
158
182
|
function runDoctorProject(config, { json = false } = {}) {
|
|
159
183
|
const cliPackage = readJsonIfPresent(new URL('../package.json', import.meta.url));
|
|
160
184
|
const repoPackage = readJsonIfPresent(path.join(config.repoRoot, 'package.json'));
|
|
@@ -165,11 +189,14 @@ function runDoctorProject(config, { json = false } = {}) {
|
|
|
165
189
|
?? null;
|
|
166
190
|
const claudeCommandWarnings = checkClaudeCommands(config.repoRoot);
|
|
167
191
|
const deprecatedCommandMentions = findDeprecatedCommandMentions(config);
|
|
192
|
+
const { docsWithoutFrontmatter, planStatusGaps } = findWorkflowDrift(config);
|
|
168
193
|
const result = {
|
|
169
194
|
cliVersion: cliPackage?.version ?? null,
|
|
170
195
|
packageDependency: depVersion,
|
|
171
196
|
claudeCommandWarnings,
|
|
172
197
|
deprecatedCommandMentions,
|
|
198
|
+
docsWithoutFrontmatter,
|
|
199
|
+
planStatusGaps,
|
|
173
200
|
};
|
|
174
201
|
|
|
175
202
|
if (json) {
|
|
@@ -187,6 +214,17 @@ function runDoctorProject(config, { json = false } = {}) {
|
|
|
187
214
|
} else {
|
|
188
215
|
process.stdout.write('- docs mentioning deprecated commands: 0\n');
|
|
189
216
|
}
|
|
217
|
+
if (docsWithoutFrontmatter.length) {
|
|
218
|
+
process.stdout.write(yellow(`- docs without a frontmatter block: ${docsWithoutFrontmatter.length} — every status verb (\`set\`, \`archive\`, \`baton\`) dies on these. Fix: dotmd bulk-tag <file> --type <type> --status <status>`) + '\n');
|
|
219
|
+
for (const file of docsWithoutFrontmatter.slice(0, 10)) process.stdout.write(` - ${file}\n`);
|
|
220
|
+
} else {
|
|
221
|
+
process.stdout.write('- docs without a frontmatter block: 0\n');
|
|
222
|
+
}
|
|
223
|
+
if (planStatusGaps.length) {
|
|
224
|
+
process.stdout.write(yellow(`- plan status vocab missing: ${planStatusGaps.join(', ')} — \`dotmd use\` and \`dotmd baton\` depend on these; add them to types.plan.statuses in dotmd.config.mjs`) + '\n');
|
|
225
|
+
} else {
|
|
226
|
+
process.stdout.write('- plan status vocab: ok\n');
|
|
227
|
+
}
|
|
190
228
|
return result;
|
|
191
229
|
}
|
|
192
230
|
|
package/src/guard.mjs
CHANGED
|
@@ -58,6 +58,45 @@ function shellTokens(command) {
|
|
|
58
58
|
return command.split(/\s+/).map(t => t.replace(/^['"]|['"]$/g, '')).filter(Boolean);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// Drop heredoc bodies, keeping the command line that opens them. Heredoc
|
|
62
|
+
// bodies are document content — resume-prompt drafts routinely mention
|
|
63
|
+
// `docs/prompts/…` paths and even describe the guard's own rules, and none of
|
|
64
|
+
// that is the *command* doing anything.
|
|
65
|
+
function stripHeredocBodies(command) {
|
|
66
|
+
if (typeof command !== 'string' || !command.includes('<<')) return command;
|
|
67
|
+
const lines = command.split('\n');
|
|
68
|
+
const out = [];
|
|
69
|
+
let marker = null;
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
if (marker !== null) {
|
|
72
|
+
if (line.trim() === marker) marker = null;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
out.push(line);
|
|
76
|
+
const m = line.match(/<<-?\s*(['"]?)(\w+)\1/);
|
|
77
|
+
if (m) marker = m[2];
|
|
78
|
+
}
|
|
79
|
+
return out.join('\n');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Split a compound command into independently-evaluated segments. Each side of
|
|
83
|
+
// a pipe / && / || / ; / newline runs its own program, so a rule should only
|
|
84
|
+
// fire on the segment whose program actually touches the prompt — `dotmd check
|
|
85
|
+
// docs/prompts/x.md; git commit -- docs/plans/y.md` commits no prompt.
|
|
86
|
+
function shellSegments(command) {
|
|
87
|
+
return stripHeredocBodies(command)
|
|
88
|
+
.split(/\|\|?|&&|;|\n/)
|
|
89
|
+
.map(s => s.trim())
|
|
90
|
+
.filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Blank out quoted strings that contain whitespace — prose, not paths. A
|
|
94
|
+
// commit message like `-m "handoff saved to docs/prompts/x.md"` only *mentions*
|
|
95
|
+
// a prompt; `git add "docs/prompts/foo.md"` (no inner whitespace) survives.
|
|
96
|
+
function stripProseStrings(s) {
|
|
97
|
+
return s.replace(/"([^"]*)"|'([^']*)'/g, (m, d, q) => (/\s/.test(d ?? q ?? '') ? '""' : m));
|
|
98
|
+
}
|
|
99
|
+
|
|
61
100
|
// Decision level for the status-edit rules. Hand-editing `status:` has no
|
|
62
101
|
// legitimate variant — `dotmd set` is a complete substitute — so it denies by
|
|
63
102
|
// default. `guard: { deny: false }` in config drops it back to warn-only.
|
|
@@ -87,48 +126,57 @@ const STREAM_EDITOR_INPLACE = [
|
|
|
87
126
|
];
|
|
88
127
|
|
|
89
128
|
function evalBash(command, config, isIgnored) {
|
|
90
|
-
const
|
|
91
|
-
const promptTokens = tokens.filter(isPromptPath);
|
|
129
|
+
const segments = shellSegments(command);
|
|
92
130
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
131
|
+
for (const seg of segments) {
|
|
132
|
+
const segTokens = shellTokens(stripProseStrings(seg));
|
|
133
|
+
if (!segTokens.length) continue;
|
|
134
|
+
const cmd0 = path.basename(segTokens[0]);
|
|
135
|
+
const promptTokens = segTokens.filter(isPromptPath);
|
|
136
|
+
|
|
137
|
+
// Rule A — committing/adding a gitignored prompt. The exact failure the
|
|
138
|
+
// guard exists for: an agent reflexively `git add`s a session-local prompt
|
|
139
|
+
// that lives under a gitignored path, and the commit dies confusingly.
|
|
140
|
+
// Scoped to the git segment's own arguments: a prompt path in a sibling
|
|
141
|
+
// segment (`dotmd check docs/prompts/x.md; git commit …`) or inside a
|
|
142
|
+
// quoted commit message is a mention, not a commit.
|
|
143
|
+
if (cmd0 === 'git' && /^(add|commit|stage)$/.test(segTokens[1] ?? '') && promptTokens.length) {
|
|
144
|
+
const ignored = promptTokens.filter(p => isIgnored(p));
|
|
145
|
+
const targets = ignored.length ? ignored : promptTokens;
|
|
146
|
+
const ignoredNote = ignored.length
|
|
147
|
+
? ` ${ignored.join(', ')} is gitignored — it cannot be committed.`
|
|
148
|
+
: '';
|
|
149
|
+
return {
|
|
150
|
+
decision: 'deny',
|
|
151
|
+
rule: 'commit-prompt',
|
|
152
|
+
detail: command,
|
|
153
|
+
reason:
|
|
154
|
+
`Saved prompts (${targets.join(', ')}) are session-local dotmd artifacts, not source to commit.${ignoredNote} ` +
|
|
155
|
+
`Don't git add/commit them — commit your other changes without the prompt in the pathspec. ` +
|
|
156
|
+
`The next session consumes a prompt with \`dotmd use <file>\` (or \`dotmd use\` for the oldest pending), which prints the body and archives it atomically.`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
111
159
|
|
|
112
|
-
|
|
113
|
-
if (tokens.length) {
|
|
114
|
-
const cmd0 = path.basename(tokens[0]);
|
|
160
|
+
// Rule B — reading a prompt through the shell instead of consuming it.
|
|
115
161
|
if (SHELL_READERS.has(cmd0) && promptTokens.length) {
|
|
116
162
|
return {
|
|
117
163
|
decision: 'warn',
|
|
118
164
|
rule: 'cat-prompt',
|
|
119
165
|
detail: command,
|
|
120
166
|
reason:
|
|
121
|
-
`${promptTokens.join(', ')} is a saved dotmd prompt.
|
|
122
|
-
`
|
|
167
|
+
`${promptTokens.join(', ')} is a saved dotmd prompt. To start work from it, run \`dotmd use ${promptTokens[0]}\` — ` +
|
|
168
|
+
`it prints the body and archives the prompt in one atomic step (prevents double-consumption). ` +
|
|
169
|
+
`Just peeking or triaging (not consuming)? \`dotmd prompts show ${promptTokens[0]}\` reads it without archiving. Don't \`${cmd0}\` it directly.`,
|
|
123
170
|
};
|
|
124
171
|
}
|
|
125
172
|
}
|
|
126
173
|
|
|
127
174
|
// Rule C — in-place stream-editing `status:` in a managed doc. Same wrong-move
|
|
128
|
-
// as the Edit-tool rule, reached via the shell.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
175
|
+
// as the Edit-tool rule, reached via the shell. Heredoc bodies are document
|
|
176
|
+
// content (often prose *describing* these rules), not commands.
|
|
177
|
+
const stripped = stripHeredocBodies(command);
|
|
178
|
+
if (/status/.test(stripped) && STREAM_EDITOR_INPLACE.some(re => re.test(stripped))) {
|
|
179
|
+
const managed = shellTokens(stripped).filter(t => isManagedDoc(t, config));
|
|
132
180
|
if (managed.length) {
|
|
133
181
|
return editStatusResult(managed[0], config, command);
|
|
134
182
|
}
|
|
@@ -144,7 +192,8 @@ function evalRead(filePath) {
|
|
|
144
192
|
rule: 'read-prompt',
|
|
145
193
|
detail: filePath,
|
|
146
194
|
reason:
|
|
147
|
-
`${filePath} is a saved dotmd prompt.
|
|
195
|
+
`${filePath} is a saved dotmd prompt. To start work from it, run \`dotmd use ${filePath}\` — it prints the body and archives the prompt atomically so it can't be double-consumed. ` +
|
|
196
|
+
`Just peeking or triaging (not consuming)? \`dotmd prompts show ${filePath}\` reads it without archiving.`,
|
|
148
197
|
};
|
|
149
198
|
}
|
|
150
199
|
|
|
@@ -205,8 +254,11 @@ function readStdin() {
|
|
|
205
254
|
process.stdin.on('data', (c) => { data += c; });
|
|
206
255
|
process.stdin.on('end', () => resolve(data));
|
|
207
256
|
process.stdin.on('error', () => resolve(data));
|
|
208
|
-
// Don't hang the tool dispatch if stdin never closes.
|
|
209
|
-
|
|
257
|
+
// Don't hang the tool dispatch if stdin never closes. unref() so the
|
|
258
|
+
// timer can't hold the event loop open — without it every guard
|
|
259
|
+
// invocation lingered the full 2s AFTER answering, which added ~2s of
|
|
260
|
+
// dead latency to every guarded tool call in every session.
|
|
261
|
+
setTimeout(() => resolve(data), 2000).unref();
|
|
210
262
|
} catch {
|
|
211
263
|
resolve(data);
|
|
212
264
|
}
|
package/src/health.mjs
CHANGED
|
@@ -1,13 +1,32 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { buildIndex } from './index.mjs';
|
|
3
3
|
import { bold, dim, green, yellow, red } from './color.mjs';
|
|
4
|
+
import { buildCoordinationIndex, hubLabel } from './runlist.mjs';
|
|
5
|
+
import { isArchivedPath } from './util.mjs';
|
|
4
6
|
|
|
5
7
|
export function runHealth(argv, config) {
|
|
6
8
|
const json = argv.includes('--json');
|
|
7
9
|
const index = buildIndex(config);
|
|
8
10
|
|
|
9
11
|
// Only plans (type: plan or untyped docs in plans root)
|
|
10
|
-
const
|
|
12
|
+
const allPlans = index.docs.filter(d => d.type === 'plan' || (!d.type && d.root?.includes('plan')));
|
|
13
|
+
// Coordination hubs (prose-first runlists) are navigation maps, not execution
|
|
14
|
+
// units — they carry no checklist and skew active-plan aging — so lift the
|
|
15
|
+
// LIVE ones out of the pipeline + active set into a dedicated Runlists tally,
|
|
16
|
+
// mirroring `dotmd plans` / `dotmd runlists`. Archived hubs stay in `plans` so
|
|
17
|
+
// the archived/velocity counts are unchanged. No coordination hubs → `plans`
|
|
18
|
+
// equals the full set and every count below is identical to before.
|
|
19
|
+
const coordination = buildCoordinationIndex(index, config);
|
|
20
|
+
const closedStatuses = new Set([
|
|
21
|
+
...(config.lifecycle?.archiveStatuses ?? []),
|
|
22
|
+
...(config.lifecycle?.terminalStatuses ?? []),
|
|
23
|
+
]);
|
|
24
|
+
const isLiveHub = (d) => coordination.has(d.path) && !closedStatuses.has(d.status) && !isArchivedPath(d.path, config);
|
|
25
|
+
const runlistHubs = allPlans.filter(isLiveHub)
|
|
26
|
+
// Most stale first — health is an aging lens, and it matches `dotmd runlists`'
|
|
27
|
+
// default. Unknown-age hubs sort last so they never top the list.
|
|
28
|
+
.sort((a, b) => (b.daysSinceUpdate ?? -1) - (a.daysSinceUpdate ?? -1));
|
|
29
|
+
const plans = allPlans.filter(d => !isLiveHub(d));
|
|
11
30
|
const now = Date.now();
|
|
12
31
|
|
|
13
32
|
// Status distribution
|
|
@@ -68,24 +87,51 @@ export function runHealth(argv, config) {
|
|
|
68
87
|
ready: { count: readyPlans.length },
|
|
69
88
|
planned: { count: plannedPlans.length },
|
|
70
89
|
recentlyArchived: { count: recentlyArchived.length, last30d: recentlyArchived.map(d => path.basename(d.path, '.md')) },
|
|
90
|
+
runlists: { count: runlistHubs.length, hubs: runlistHubs.map(d => ({ path: d.path, title: d.title, status: d.status, childCount: coordination.get(d.path)?.childCount ?? 0 })) },
|
|
71
91
|
}, null, 2) + '\n');
|
|
72
92
|
return;
|
|
73
93
|
}
|
|
74
94
|
|
|
75
95
|
process.stdout.write(bold('Plan Health') + '\n\n');
|
|
76
96
|
|
|
77
|
-
// Pipeline
|
|
97
|
+
// Pipeline — ordered by the configured status vocab, then any present-but-
|
|
98
|
+
// unconfigured statuses (custom ones a repo defines, by count). Deriving from
|
|
99
|
+
// the live status set means in-session/partial/awaiting/etc. all show, and a
|
|
100
|
+
// dead status never leaves an empty row — unlike the old hand-kept list that
|
|
101
|
+
// drifted out of sync with the vocabulary.
|
|
78
102
|
process.stdout.write(bold('Pipeline:') + '\n');
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
103
|
+
const statusOrder = config.statusOrder ?? [];
|
|
104
|
+
const present = Object.keys(byStatus).filter(s => byStatus[s] > 0);
|
|
105
|
+
const ordered = [
|
|
106
|
+
...statusOrder.filter(s => present.includes(s)),
|
|
107
|
+
...present.filter(s => !statusOrder.includes(s)).sort((a, b) => byStatus[b] - byStatus[a]),
|
|
108
|
+
];
|
|
109
|
+
const pad = Math.max(10, ...ordered.map(s => s.length));
|
|
110
|
+
for (const s of ordered) {
|
|
111
|
+
const count = byStatus[s];
|
|
112
|
+
const bar = '█'.repeat(Math.min(count, 40));
|
|
113
|
+
process.stdout.write(` ${s.padEnd(pad)} ${String(count).padStart(4)} ${dim(bar)}\n`);
|
|
86
114
|
}
|
|
87
115
|
process.stdout.write('\n');
|
|
88
116
|
|
|
117
|
+
// Runlists (coordination hubs) — held out of the leaf-plan pipeline above and
|
|
118
|
+
// surfaced as their own tally so they don't inflate the active count. Newest
|
|
119
|
+
// first, mirroring `dotmd runlists`; capped with a "more" footer.
|
|
120
|
+
if (runlistHubs.length > 0) {
|
|
121
|
+
process.stdout.write(`${bold('Runlists:')} ${runlistHubs.length} ${dim('· dotmd runlists')}\n`);
|
|
122
|
+
for (const doc of runlistHubs.slice(0, 8)) {
|
|
123
|
+
const slug = hubLabel(doc).padEnd(28);
|
|
124
|
+
const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '?d';
|
|
125
|
+
const rel = coordination.get(doc.path)?.childCount;
|
|
126
|
+
const relStr = rel ? ` ${dim(`${rel} related`)}` : '';
|
|
127
|
+
process.stdout.write(` ${slug} ${dim(age.padStart(4))}${relStr}\n`);
|
|
128
|
+
}
|
|
129
|
+
if (runlistHubs.length > 8) {
|
|
130
|
+
process.stdout.write(` ${dim(`...and ${runlistHubs.length - 8} more`)}\n`);
|
|
131
|
+
}
|
|
132
|
+
process.stdout.write('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
89
135
|
// Active plan health
|
|
90
136
|
if (activePlans.length > 0) {
|
|
91
137
|
process.stdout.write(bold('Active plans:') + '\n');
|