dotmd-cli 0.59.0 → 0.61.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/bin/dotmd.mjs +118 -12
- 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 +4 -2
- package/src/config.mjs +5 -0
- package/src/deps.mjs +4 -6
- package/src/diff.mjs +4 -7
- package/src/doctor.mjs +38 -0
- package/src/guard.mjs +156 -48
- package/src/hud.mjs +74 -15
- package/src/index.mjs +49 -1
- package/src/lifecycle.mjs +77 -47
- package/src/new.mjs +7 -1
- package/src/prompts.mjs +25 -1
- package/src/query.mjs +68 -6
- package/src/rename.mjs +3 -6
- package/src/render.mjs +13 -3
- package/src/runlist.mjs +5 -28
- package/src/summary.mjs +3 -3
- package/src/use.mjs +7 -2
- package/src/validate.mjs +2 -2
package/src/diff.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
3
|
-
import { asString, toRepoPath,
|
|
3
|
+
import { asString, toRepoPath, die, warn } from './util.mjs';
|
|
4
4
|
import { gitDiffSince } from './git.mjs';
|
|
5
|
-
import { buildIndex } from './index.mjs';
|
|
5
|
+
import { buildIndex, resolveDocArg } from './index.mjs';
|
|
6
6
|
import { summarizeDiffText, DEFAULT_MODEL } from './ai.mjs';
|
|
7
|
-
import { bold, dim
|
|
7
|
+
import { bold, dim } from './color.mjs';
|
|
8
8
|
|
|
9
9
|
export function runDiff(argv, config) {
|
|
10
10
|
// Parse flags
|
|
@@ -24,10 +24,7 @@ export function runDiff(argv, config) {
|
|
|
24
24
|
|
|
25
25
|
if (file) {
|
|
26
26
|
// Single file mode
|
|
27
|
-
const filePath =
|
|
28
|
-
if (!filePath) {
|
|
29
|
-
die(`File not found: ${file}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
|
|
30
|
-
}
|
|
27
|
+
const filePath = resolveDocArg(file, config);
|
|
31
28
|
|
|
32
29
|
const raw = readFileSync(filePath, 'utf8');
|
|
33
30
|
const { frontmatter } = extractFrontmatter(raw);
|
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
|
@@ -16,12 +16,14 @@ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'),
|
|
|
16
16
|
//
|
|
17
17
|
// Two decision levels:
|
|
18
18
|
// 'deny' — block the call and feed the reason back to the model. Reserved for
|
|
19
|
-
// moves that are guaranteed-wrong
|
|
20
|
-
// would fail anyway)
|
|
19
|
+
// moves that are guaranteed-wrong: committing a gitignored prompt (it
|
|
20
|
+
// would fail anyway) and hand-editing a `status:` field (`dotmd set`
|
|
21
|
+
// is a complete substitute; config `guard: { deny: false }` drops the
|
|
22
|
+
// status rules back to warn).
|
|
21
23
|
// 'warn' — let the call proceed but inject teaching context so the agent learns
|
|
22
24
|
// the dotmd-native command. Used for soft mistakes (cat/Read of a
|
|
23
|
-
// prompt
|
|
24
|
-
//
|
|
25
|
+
// prompt) where a human might legitimately do it; we nudge rather
|
|
26
|
+
// than block.
|
|
25
27
|
|
|
26
28
|
const SHELL_READERS = new Set(['cat', 'less', 'more', 'head', 'tail', 'bat', 'view', 'open']);
|
|
27
29
|
|
|
@@ -56,44 +58,130 @@ function shellTokens(command) {
|
|
|
56
58
|
return command.split(/\s+/).map(t => t.replace(/^['"]|['"]$/g, '')).filter(Boolean);
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
reason:
|
|
77
|
-
`Saved prompts (${targets.join(', ')}) are session-local dotmd artifacts, not source to commit.${ignoredNote} ` +
|
|
78
|
-
`Don't git add/commit them. 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.`,
|
|
79
|
-
};
|
|
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];
|
|
80
78
|
}
|
|
79
|
+
return out.join('\n');
|
|
80
|
+
}
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
+
|
|
100
|
+
// Decision level for the status-edit rules. Hand-editing `status:` has no
|
|
101
|
+
// legitimate variant — `dotmd set` is a complete substitute — so it denies by
|
|
102
|
+
// default. `guard: { deny: false }` in config drops it back to warn-only.
|
|
103
|
+
function editStatusDecision(config) {
|
|
104
|
+
return config?.guard?.deny === false ? 'warn' : 'deny';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function editStatusResult(target, config, detail) {
|
|
108
|
+
return {
|
|
109
|
+
decision: editStatusDecision(config),
|
|
110
|
+
rule: 'edit-status',
|
|
111
|
+
detail,
|
|
112
|
+
reason:
|
|
113
|
+
`Looks like a hand-edit of the \`status:\` field in ${target}. Use \`dotmd set <status> ${target}\` instead — ` +
|
|
114
|
+
`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.`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// In-place stream editors (`sed -i`, `perl -pi`, `awk -i inplace`) are the
|
|
119
|
+
// shell-side bypass of the Edit-tool status guard. Only the command text
|
|
120
|
+
// before any heredoc marker is scanned — heredoc bodies are document content
|
|
121
|
+
// (often prose *describing* these rules), not commands.
|
|
122
|
+
const STREAM_EDITOR_INPLACE = [
|
|
123
|
+
/\bsed\b[^|;&<>]*\s-i/,
|
|
124
|
+
/\bperl\b[^|;&<>]*\s-[a-zA-Z]*i/,
|
|
125
|
+
/\bg?awk\b[^|;&<>]*\binplace\b/,
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
function evalBash(command, config, isIgnored) {
|
|
129
|
+
const segments = shellSegments(command);
|
|
130
|
+
|
|
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
|
+
}
|
|
159
|
+
|
|
160
|
+
// Rule B — reading a prompt through the shell instead of consuming it.
|
|
85
161
|
if (SHELL_READERS.has(cmd0) && promptTokens.length) {
|
|
86
162
|
return {
|
|
87
163
|
decision: 'warn',
|
|
88
164
|
rule: 'cat-prompt',
|
|
89
165
|
detail: command,
|
|
90
166
|
reason:
|
|
91
|
-
`${promptTokens.join(', ')} is a saved dotmd prompt.
|
|
92
|
-
`
|
|
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.`,
|
|
93
170
|
};
|
|
94
171
|
}
|
|
95
172
|
}
|
|
96
173
|
|
|
174
|
+
// Rule C — in-place stream-editing `status:` in a managed doc. Same wrong-move
|
|
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));
|
|
180
|
+
if (managed.length) {
|
|
181
|
+
return editStatusResult(managed[0], config, command);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
97
185
|
return null;
|
|
98
186
|
}
|
|
99
187
|
|
|
@@ -104,27 +192,44 @@ function evalRead(filePath) {
|
|
|
104
192
|
rule: 'read-prompt',
|
|
105
193
|
detail: filePath,
|
|
106
194
|
reason:
|
|
107
|
-
`${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.`,
|
|
108
197
|
};
|
|
109
198
|
}
|
|
110
199
|
|
|
111
|
-
|
|
200
|
+
// Every `status:` line in a snippet, normalized for comparison.
|
|
201
|
+
function statusLines(s) {
|
|
202
|
+
if (typeof s !== 'string') return [];
|
|
203
|
+
return (s.match(/^[ \t]*status[ \t]*:[^\n]*/gm) ?? []).map(l => l.trim());
|
|
204
|
+
}
|
|
112
205
|
|
|
113
|
-
|
|
206
|
+
// Only fire when the edit actually CHANGES a `status:` line. An edit whose
|
|
207
|
+
// old/new strings both carry the same `status:` line is using it as anchor
|
|
208
|
+
// context (e.g. adding a `summary:` field above it) — warning on those taught
|
|
209
|
+
// sessions to ignore the rule (the health-repo repeat offenses were exactly
|
|
210
|
+
// this false positive).
|
|
211
|
+
function evalEdit(input, config, deps = {}) {
|
|
114
212
|
const filePath = input?.file_path;
|
|
115
213
|
if (!isManagedDoc(filePath, config)) return null;
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
214
|
+
|
|
215
|
+
const pairs = [];
|
|
216
|
+
const newStr = input?.new_string ?? input?.new_str;
|
|
217
|
+
if (typeof newStr === 'string') pairs.push([input?.old_string ?? input?.old_str ?? '', newStr]);
|
|
218
|
+
for (const e of Array.isArray(input?.edits) ? input.edits : []) {
|
|
219
|
+
if (typeof e?.new_string === 'string') pairs.push([e.old_string ?? '', e.new_string]);
|
|
220
|
+
}
|
|
221
|
+
if (typeof input?.content === 'string') {
|
|
222
|
+
// Write replaces the whole file — diff against what's on disk. An
|
|
223
|
+
// unreadable/missing target is doc creation, not a status edit.
|
|
224
|
+
const readFile = deps.readFile ?? ((p) => readFileSync(p, 'utf8'));
|
|
225
|
+
let existing;
|
|
226
|
+
try { existing = readFile(filePath); } catch { existing = null; }
|
|
227
|
+
if (typeof existing === 'string') pairs.push([existing, input.content]);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const changed = pairs.some(([oldS, newS]) => statusLines(oldS).join('\n') !== statusLines(newS).join('\n'));
|
|
231
|
+
if (!changed) return null;
|
|
232
|
+
return editStatusResult(filePath, config, filePath);
|
|
128
233
|
}
|
|
129
234
|
|
|
130
235
|
// Pure evaluation — `deps.isIgnored(path) -> bool` is injected so tests don't
|
|
@@ -137,7 +242,7 @@ export function evaluateGuard(payload, config, deps = {}) {
|
|
|
137
242
|
|
|
138
243
|
if (tool === 'Bash') return evalBash(input.command || '', config, isIgnored);
|
|
139
244
|
if (tool === 'Read') return evalRead(input.file_path || '');
|
|
140
|
-
if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') return evalEdit(input, config);
|
|
245
|
+
if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') return evalEdit(input, config, deps);
|
|
141
246
|
return null;
|
|
142
247
|
}
|
|
143
248
|
|
|
@@ -149,8 +254,11 @@ function readStdin() {
|
|
|
149
254
|
process.stdin.on('data', (c) => { data += c; });
|
|
150
255
|
process.stdin.on('end', () => resolve(data));
|
|
151
256
|
process.stdin.on('error', () => resolve(data));
|
|
152
|
-
// Don't hang the tool dispatch if stdin never closes.
|
|
153
|
-
|
|
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();
|
|
154
262
|
} catch {
|
|
155
263
|
resolve(data);
|
|
156
264
|
}
|
package/src/hud.mjs
CHANGED
|
@@ -6,8 +6,9 @@ import { asString, toRepoPath, currentSessionId } from './util.mjs';
|
|
|
6
6
|
import { dim, yellow } from './color.mjs';
|
|
7
7
|
import { buildIndex } from './index.mjs';
|
|
8
8
|
import { refreshStaleSlashCommands } from './claude-commands.mjs';
|
|
9
|
-
import { readJournalEntries, journalFilePath } from './journal.mjs';
|
|
9
|
+
import { readJournalEntries, journalFilePath, readMisuseEntries } from './journal.mjs';
|
|
10
10
|
import { compareVersions } from './update.mjs';
|
|
11
|
+
import { findOwnedPlan } from './baton.mjs';
|
|
11
12
|
|
|
12
13
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
14
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -51,6 +52,8 @@ export function actionablePromptStatuses(config) {
|
|
|
51
52
|
return new Set(['pending']);
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
// Returns repo paths, oldest-created first — the same order no-arg `dotmd use`
|
|
56
|
+
// consumes them, so prompts[0] is always "the one you'd pick up next".
|
|
54
57
|
function findActionablePrompts(config) {
|
|
55
58
|
const roots = config.docsRoots || (config.docsRoot ? [config.docsRoot] : []);
|
|
56
59
|
const archiveDir = config.archiveDir || 'archived';
|
|
@@ -80,11 +83,13 @@ function findActionablePrompts(config) {
|
|
|
80
83
|
const fm = parseSimpleFrontmatter(frontmatter);
|
|
81
84
|
if (asString(fm.type) !== 'prompt') continue;
|
|
82
85
|
if (!actionable.has(asString(fm.status))) continue;
|
|
83
|
-
found.push(toRepoPath(filePath, config.repoRoot));
|
|
86
|
+
found.push({ path: toRepoPath(filePath, config.repoRoot), created: asString(fm.created) ?? '' });
|
|
84
87
|
}
|
|
85
88
|
}
|
|
86
89
|
|
|
87
|
-
return found
|
|
90
|
+
return found
|
|
91
|
+
.sort((a, b) => a.created.localeCompare(b.created) || a.path.localeCompare(b.path))
|
|
92
|
+
.map(p => p.path);
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
// F17b: hud reads journal. Three additive sections, gated on
|
|
@@ -191,6 +196,39 @@ export function buildJournalSections(config, now = Date.now()) {
|
|
|
191
196
|
return { previousSelf, fleet, recentRejections };
|
|
192
197
|
}
|
|
193
198
|
|
|
199
|
+
// Misuse recap: when sessions in THIS repo keep tripping the same guard rule,
|
|
200
|
+
// say so once at SessionStart — the shipped self-correcting-hints pattern
|
|
201
|
+
// pointed at repeat offenses. One line, only for the top rule, only past the
|
|
202
|
+
// threshold; silent otherwise.
|
|
203
|
+
const MISUSE_RECAP_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
204
|
+
const MISUSE_RECAP_THRESHOLD = 3;
|
|
205
|
+
|
|
206
|
+
const MISUSE_CORRECTIONS = {
|
|
207
|
+
'edit-status': 'never hand-edit `status:`; use `dotmd set <status> <file>`',
|
|
208
|
+
'cat-prompt': 'consume prompts with `dotmd use <file>`; peek without consuming via `dotmd prompts show <file>`',
|
|
209
|
+
'read-prompt': 'consume prompts with `dotmd use <file>`; peek without consuming via `dotmd prompts show <file>`',
|
|
210
|
+
'commit-prompt': 'saved prompts are session-local; never git add/commit them',
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export function buildMisuseRecap(config, now = Date.now()) {
|
|
214
|
+
let entries;
|
|
215
|
+
try { entries = readMisuseEntries(); } catch { return null; }
|
|
216
|
+
if (!entries.length) return null;
|
|
217
|
+
const cutoff = now - MISUSE_RECAP_WINDOW_MS;
|
|
218
|
+
const counts = new Map();
|
|
219
|
+
for (const e of entries) {
|
|
220
|
+
if (!e?.rule || (e.repo || '') !== config.repoRoot) continue;
|
|
221
|
+
const t = new Date(e.ts).getTime();
|
|
222
|
+
if (!Number.isFinite(t) || t < cutoff) continue;
|
|
223
|
+
counts.set(e.rule, (counts.get(e.rule) ?? 0) + 1);
|
|
224
|
+
}
|
|
225
|
+
const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
226
|
+
if (!top || top[1] < MISUSE_RECAP_THRESHOLD) return null;
|
|
227
|
+
const [rule, count] = top;
|
|
228
|
+
const fix = MISUSE_CORRECTIONS[rule] ?? 'see `dotmd misuse`';
|
|
229
|
+
return `sessions here tripped ${rule} ${count}× this week — ${fix}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
194
232
|
export function buildHud(config) {
|
|
195
233
|
const prompts = findActionablePrompts(config);
|
|
196
234
|
|
|
@@ -202,6 +240,10 @@ export function buildHud(config) {
|
|
|
202
240
|
// SessionStart for platform-scale corpora. Per-file validation + checkIndex
|
|
203
241
|
// still run, so the error count matches `dotmd check`'s.
|
|
204
242
|
let errors = 0;
|
|
243
|
+
// `owned` answers "which plan is THIS session's?" for programmatic callers
|
|
244
|
+
// (the baton flow reads it) — derived from the journal, falling back to the
|
|
245
|
+
// only in-session plan. Null when there's no defensible answer.
|
|
246
|
+
let owned = null;
|
|
205
247
|
try {
|
|
206
248
|
// `autoHealIndex: true` mirrors `dotmd check` — drift from non-regen
|
|
207
249
|
// mutation paths (`lint --fix`, direct file edits, etc.) heals silently
|
|
@@ -209,11 +251,14 @@ export function buildHud(config) {
|
|
|
209
251
|
// spurious "Run `dotmd index`" error in the hud error count.
|
|
210
252
|
const index = buildIndex(config, { errorsOnly: true, autoHealIndex: true });
|
|
211
253
|
errors = index.errors.length;
|
|
254
|
+
const o = findOwnedPlan(config, index);
|
|
255
|
+
if (o.plan) owned = { path: o.plan.path, title: o.plan.title ?? null, via: o.via };
|
|
212
256
|
} catch { /* swallow — bad config shouldn't break the SessionStart hook */ }
|
|
213
257
|
|
|
214
258
|
const { previousSelf, fleet, recentRejections } = buildJournalSections(config);
|
|
259
|
+
const misuseRecap = buildMisuseRecap(config);
|
|
215
260
|
|
|
216
|
-
return { prompts, errors, previousSelf, fleet, recentRejections };
|
|
261
|
+
return { owned, prompts, errors, previousSelf, fleet, recentRejections, misuseRecap };
|
|
217
262
|
}
|
|
218
263
|
|
|
219
264
|
// Subagent primer: a spawned subagent (Explore, Plan, general-purpose) starts
|
|
@@ -277,16 +322,30 @@ export function runHud(argv, config) {
|
|
|
277
322
|
return;
|
|
278
323
|
}
|
|
279
324
|
|
|
280
|
-
// SessionStart contract:
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
325
|
+
// SessionStart contract: the command primer, plus ONLY signals that carry a
|
|
326
|
+
// direct instruction for this session. Passive state (error counts,
|
|
327
|
+
// slash-command refresh notices, previous-self / fleet / recent-rejections)
|
|
328
|
+
// stays suppressed — those nudged agents into phantom follow-up work (e.g.
|
|
329
|
+
// "errors: 1" prompting a check run) and live in their proper commands and
|
|
330
|
+
// `dotmd hud --json`. Two signals ARE instructions and must print, because
|
|
331
|
+
// the handoff loop dies without them (sessions were saving batons that no
|
|
332
|
+
// next session ever picked up):
|
|
333
|
+
// - pending prompts: the previous session queued work for THIS one;
|
|
334
|
+
// consuming it is the very next action.
|
|
335
|
+
// - an in-session plan attributed to this sid via the journal: this
|
|
336
|
+
// session (pre-compaction) owns it and should continue or hand it off.
|
|
337
|
+
// The single-in-session fallback is deliberately NOT printed — at
|
|
338
|
+
// SessionStart that plan likely belongs to another live session.
|
|
339
|
+
// The misuse recap stays for the same reason: a repeat-offense rule means
|
|
340
|
+
// the primer alone isn't landing, so name the habit to break.
|
|
341
|
+
process.stdout.write(dim('dotmd: plans|briefing set <status> [<file>] new <type> <slug> use [<file>] archive <file> baton [<slug>] <@draft|-> (save a resume prompt; releases the in-session plan if any) (use [no-arg] → oldest pending prompt)') + '\n');
|
|
342
|
+
if (hud.owned && hud.owned.via === 'journal') {
|
|
343
|
+
process.stdout.write(yellow(`[dotmd] in-session (yours): ${hud.owned.path} — continue it; hand off with \`dotmd baton @/tmp/draft.md\` before stopping.`) + '\n');
|
|
344
|
+
}
|
|
345
|
+
if (hud.prompts.length > 0) {
|
|
346
|
+
const n = hud.prompts.length;
|
|
347
|
+
process.stdout.write(yellow(`[dotmd] ${n} pending prompt${n === 1 ? '' : 's'} queued for this session — unless the user asks for something else, start by running \`dotmd use\` to consume the oldest (${hud.prompts[0]}) and act on it. Peek first: \`dotmd prompts show <file>\`; list: \`dotmd prompts\`.`) + '\n');
|
|
348
|
+
}
|
|
349
|
+
if (hud.misuseRecap) process.stdout.write(yellow(`[dotmd] ${hud.misuseRecap}`) + '\n');
|
|
291
350
|
if (drift) process.stdout.write(yellow(drift) + '\n');
|
|
292
351
|
}
|
package/src/index.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { readdirSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
4
|
import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
|
|
5
|
-
import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn } from './util.mjs';
|
|
5
|
+
import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn, die, resolveDocPath, suggestCandidates } from './util.mjs';
|
|
6
6
|
import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
|
|
7
7
|
import { checkIndex } from './index-file.mjs';
|
|
8
8
|
import { checkClaudeCommands } from './claude-commands.mjs';
|
|
@@ -152,6 +152,54 @@ export function collectDocFiles(config) {
|
|
|
152
152
|
return files.sort((a, b) => a.localeCompare(b));
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
// Shared resolver for CLI file arguments — the single path every file-taking
|
|
156
|
+
// verb (`use`, `set`, `archive`, `touch`, `rename`, …) funnels through so
|
|
157
|
+
// bare slugs behave identically everywhere. Tries, in order: exact path
|
|
158
|
+
// (the resolveDocPath fast path), `<input>.md`, then a unique basename match
|
|
159
|
+
// across all doc roots. An ambiguous basename dies listing the candidates
|
|
160
|
+
// rather than guessing — a wrong auto-resolved mutation is worse than a
|
|
161
|
+
// retry. A full miss dies with did-you-mean suggestions drawn from the doc
|
|
162
|
+
// corpus; pass { dieOnMiss: false } to get null instead and keep a custom
|
|
163
|
+
// fallback at the call site.
|
|
164
|
+
export function resolveDocArg(input, config, { dieOnMiss = true } = {}) {
|
|
165
|
+
if (!input) return null;
|
|
166
|
+
const direct = resolveDocPath(input, config);
|
|
167
|
+
if (direct) return direct;
|
|
168
|
+
if (!input.endsWith('.md')) {
|
|
169
|
+
const withExt = resolveDocPath(input + '.md', config);
|
|
170
|
+
if (withExt) return withExt;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const slug = input.replace(/\.md$/, '');
|
|
174
|
+
const files = collectDocFiles(config);
|
|
175
|
+
const byBasename = files.filter(f => path.basename(f, '.md') === slug);
|
|
176
|
+
if (byBasename.length === 1) return byBasename[0];
|
|
177
|
+
if (byBasename.length > 1) {
|
|
178
|
+
die(`Multiple docs match "${input}" by basename:\n${byBasename.map(f => ' ' + toRepoPath(f, config.repoRoot)).join('\n')}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!dieOnMiss) return null;
|
|
182
|
+
die(docArgMissMessage(input, config, files));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// `File not found` + the searched roots + up-to-3 did-you-mean candidates
|
|
186
|
+
// matched on basename and printed as repo-relative paths. Exported so verbs
|
|
187
|
+
// with their own resolution (e.g. interactive pickers) can reuse the message.
|
|
188
|
+
export function docArgMissMessage(input, config, files = collectDocFiles(config)) {
|
|
189
|
+
const roots = config.docsRoots || [config.docsRoot];
|
|
190
|
+
const searched = [toRepoPath(config.repoRoot, config.repoRoot) || '.', ...roots.map(r => toRepoPath(r, config.repoRoot))].join(', ');
|
|
191
|
+
const slug = String(input).split('/').pop().replace(/\.md$/, '');
|
|
192
|
+
const pathsByBase = new Map();
|
|
193
|
+
for (const f of files) {
|
|
194
|
+
const base = path.basename(f, '.md');
|
|
195
|
+
if (!pathsByBase.has(base)) pathsByBase.set(base, toRepoPath(f, config.repoRoot));
|
|
196
|
+
}
|
|
197
|
+
const hits = suggestCandidates(slug, [...pathsByBase.keys()]);
|
|
198
|
+
let msg = `File not found: ${input}\nSearched: ${searched}`;
|
|
199
|
+
if (hits.length) msg += `\nDid you mean: ${hits.map(b => pathsByBase.get(b)).join(', ')}?`;
|
|
200
|
+
return msg;
|
|
201
|
+
}
|
|
202
|
+
|
|
155
203
|
function walkMarkdownFiles(directory, files, excludedDirs, skipPaths, seen = new Set()) {
|
|
156
204
|
let entries;
|
|
157
205
|
try {
|