dotmd-cli 0.60.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 +58 -3
- package/dotmd.config.example.mjs +21 -4
- package/package.json +1 -1
- package/src/baton.mjs +231 -0
- package/src/commands.mjs +1 -1
- package/src/doctor.mjs +38 -0
- package/src/guard.mjs +84 -32
- package/src/hud.mjs +40 -18
- package/src/lifecycle.mjs +4 -1
- package/src/new.mjs +7 -1
- package/src/prompts.mjs +25 -1
- package/src/validate.mjs +2 -2
package/bin/dotmd.mjs
CHANGED
|
@@ -36,6 +36,8 @@ const FLAG_SPECS = {
|
|
|
36
36
|
context: { flags: new Set(['--json', '--compact', '--summarize', '--model']), values: new Set(['--model']) },
|
|
37
37
|
'agent-context': { flags: new Set(['--json']), values: new Set() },
|
|
38
38
|
hud: { flags: new Set(['--json', '--subagent']), values: new Set() },
|
|
39
|
+
// '-' is the stdin marker (a positional, not a flag) — listed so validation lets it through.
|
|
40
|
+
baton: { flags: new Set(['--status', '--note', '--body', '--message', '--dry-run', '-n', '-']), values: new Set(['--status', '--note', '--body', '--message']) },
|
|
39
41
|
guard: { flags: new Set(), values: new Set() },
|
|
40
42
|
misuse: { flags: new Set(['--json', '--tail', '--by-rule', '--repo']), values: new Set(['--tail', '--repo']) },
|
|
41
43
|
update: { flags: new Set(['--check', '--cli-only', '--plugin-only']), values: new Set() },
|
|
@@ -45,7 +47,7 @@ const FLAG_SPECS = {
|
|
|
45
47
|
prompts: {
|
|
46
48
|
flags: new Set(['--json', '--status', '--include-archived', '--sort', '--limit', '--all', '--no-index', '--show-files', '--body', '--message', '--title']),
|
|
47
49
|
values: new Set(['--status', '--sort', '--limit', '--body', '--message', '--title']),
|
|
48
|
-
subcommands: new Set(['list', 'next', 'use', 'resume', 'archive', 'new', 'hold', 'unhold', 'shelve', 'unshelve', 'status']),
|
|
50
|
+
subcommands: new Set(['list', 'next', 'use', 'resume', 'show', 'peek', 'archive', 'new', 'hold', 'unhold', 'shelve', 'unshelve', 'status']),
|
|
49
51
|
},
|
|
50
52
|
};
|
|
51
53
|
|
|
@@ -133,6 +135,7 @@ Common commands:
|
|
|
133
135
|
set <status> [file] Transition status (start work, finish, archive — all via target status)
|
|
134
136
|
new <type> <name> Create plan/doc/prompt (pipe stdin or @path for body)
|
|
135
137
|
use [<file-or-slug>] Open a doc by type: prompt → consume, plan → start, doc → read
|
|
138
|
+
baton [<plan>|<slug>] <@draft|-> Save a resume prompt (+ release the plan, if one is in-session)
|
|
136
139
|
(no file: consume oldest pending prompt)
|
|
137
140
|
archive <file> Close out a plan (status → archived, move, update refs)
|
|
138
141
|
|
|
@@ -203,7 +206,8 @@ View & Query:
|
|
|
203
206
|
grep <term> Keyword search incl. document bodies (query --keyword --body --all)
|
|
204
207
|
plans Live plans (excludes archived; --include-archived for all)
|
|
205
208
|
use [<file-or-slug>] Open a doc by type: prompt → consume, plan → start, doc → read
|
|
206
|
-
|
|
209
|
+
baton [<plan>|<slug>] <@draft|-> Save a resume prompt; releases the plan + prints the commit when one is in-session
|
|
210
|
+
prompts [list|show|archive|new|hold] Prompt admin (list / peek / archive / save / hold). Use \`dotmd use\` to consume.
|
|
207
211
|
stale Stale docs (preset)
|
|
208
212
|
actionable Docs with next steps (preset)
|
|
209
213
|
|
|
@@ -603,7 +607,8 @@ Recommended SessionStart hook (in ~/.claude/settings.json):
|
|
|
603
607
|
"SessionStart": [{ "hooks": [{ "type": "command", "command": "dotmd hud", "timeout": 5 }] }]
|
|
604
608
|
|
|
605
609
|
Options:
|
|
606
|
-
--json Output as JSON ({ owned,
|
|
610
|
+
--json Output as JSON ({ owned, prompts, errors, previousSelf,
|
|
611
|
+
fleet, recentRejections, misuseRecap, drift })`,
|
|
607
612
|
|
|
608
613
|
briefing: `dotmd briefing — compact summary for session start
|
|
609
614
|
|
|
@@ -993,6 +998,8 @@ Subcommands:
|
|
|
993
998
|
targets the named prompt instead of picking oldest)
|
|
994
999
|
resume <file-or-slug> Alias for \`use\` — same behavior, easier name
|
|
995
1000
|
when continuing a session
|
|
1001
|
+
show <file-or-slug> Read-only peek: print the body WITHOUT consuming
|
|
1002
|
+
(triage). \`peek\` is an alias.
|
|
996
1003
|
archive <file-or-slug> Archive a prompt without printing its body
|
|
997
1004
|
hold <file-or-slug> Park a prompt (status → held) under prompts/held/:
|
|
998
1005
|
kept in list, hidden from hud/briefing pending
|
|
@@ -1023,10 +1030,50 @@ Examples:
|
|
|
1023
1030
|
claude "$(dotmd prompts resume resume-foo)" # \`resume\` is an alias for \`use\`
|
|
1024
1031
|
dotmd prompt list # singular alias for \`dotmd prompts list\`
|
|
1025
1032
|
|
|
1033
|
+
dotmd prompts show resume-foo # peek without consuming (triage)
|
|
1026
1034
|
dotmd prompts next --dry-run # preview without consuming
|
|
1027
1035
|
dotmd prompts archive old-thing
|
|
1028
1036
|
dotmd prompts new my-prompt "Body text here"`,
|
|
1029
1037
|
|
|
1038
|
+
baton: `dotmd baton — save a resume prompt for whatever you're doing (and release the plan, if there is one)
|
|
1039
|
+
|
|
1040
|
+
The "save a resume prompt" verb. Works mid-anything:
|
|
1041
|
+
|
|
1042
|
+
Plan mode (a plan is in-session, or you pass one):
|
|
1043
|
+
1. Saves a resume prompt named resume-<plan-slug> (collision-safe: -2, -3, …).
|
|
1044
|
+
The prompt is session-local — the next session's hud surfaces it; never
|
|
1045
|
+
paste resume text into chat.
|
|
1046
|
+
2. Releases the plan: one status flip, in-session → active by default
|
|
1047
|
+
(--status to override, --note to record why in ## Version History).
|
|
1048
|
+
3. Prints the exact \`git commit\` for the plan's frontmatter change — the
|
|
1049
|
+
prompt stays OUT of the pathspec (it's session-local, often gitignored).
|
|
1050
|
+
Which plan? Pass it explicitly, or baton resolves the one THIS session marked
|
|
1051
|
+
in-session (via the journal), falling back to the only in-session plan.
|
|
1052
|
+
|
|
1053
|
+
Slug mode (no plan involved — "save a resume prompt for this"):
|
|
1054
|
+
dotmd baton <slug> @/tmp/draft.md → saves resume-<slug>, touches NOTHING
|
|
1055
|
+
else: no status changes, no commit, no plan required. Reference any relevant
|
|
1056
|
+
plans/docs inside the draft body.
|
|
1057
|
+
|
|
1058
|
+
Usage:
|
|
1059
|
+
dotmd baton [<plan-file> | <slug>] [@draft.md | - | --message "..."]
|
|
1060
|
+
|
|
1061
|
+
Options:
|
|
1062
|
+
--status <s> Target status for the plan (default: active; plan mode only)
|
|
1063
|
+
--note "why" Append the reason to ## Version History (plan mode only)
|
|
1064
|
+
--message / --body Inline body (one-liners; prefer @path or stdin)
|
|
1065
|
+
--dry-run, -n Preview without writing
|
|
1066
|
+
|
|
1067
|
+
Examples:
|
|
1068
|
+
dotmd baton @/tmp/draft.md # owned plan, body from file
|
|
1069
|
+
dotmd baton checkout-fixes @/tmp/draft.md # no plan: just save resume-checkout-fixes
|
|
1070
|
+
cat /tmp/draft.md | dotmd baton # body from stdin
|
|
1071
|
+
dotmd baton docs/plans/auth.md @/tmp/draft.md # explicit plan
|
|
1072
|
+
dotmd baton --status paused --note "blocked on review" @/tmp/d.md
|
|
1073
|
+
|
|
1074
|
+
Write the draft FIRST (10–20 lines): the next concrete decision plus any
|
|
1075
|
+
gotchas — not a recap of the plan body.`,
|
|
1076
|
+
|
|
1030
1077
|
stale: `dotmd stale — list stale documents
|
|
1031
1078
|
|
|
1032
1079
|
Shows docs that haven't been updated within their staleness threshold.
|
|
@@ -1339,6 +1386,14 @@ async function main() {
|
|
|
1339
1386
|
await runUse(restArgs, config, { dryRun });
|
|
1340
1387
|
return;
|
|
1341
1388
|
}
|
|
1389
|
+
// `dotmd baton [plan] <@draft|->` — the one-command handoff: save the resume
|
|
1390
|
+
// prompt, release the plan (one status flip), print the exact commit. See
|
|
1391
|
+
// src/baton.mjs for why this is a single verb and not a skill choreography.
|
|
1392
|
+
if (command === 'baton') {
|
|
1393
|
+
const { runBaton } = await import('../src/baton.mjs');
|
|
1394
|
+
await runBaton(restArgs, config, { dryRun });
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1342
1397
|
// `dotmd next` is a top-level alias for `dotmd use` with no arg — consume
|
|
1343
1398
|
// the oldest pending prompt. Wired separately so agents who reach for the
|
|
1344
1399
|
// literal verb "next" don't bounce off an Unknown-command. Any positional
|
package/dotmd.config.example.mjs
CHANGED
|
@@ -193,7 +193,11 @@ export const presets = {
|
|
|
193
193
|
// Properties:
|
|
194
194
|
// description: string — shown in `dotmd new --list-types`
|
|
195
195
|
// defaultStatus: string — initial status if `--status` not passed
|
|
196
|
-
//
|
|
196
|
+
// acceptsBody: boolean — allow body input (inline / --body / @file / piped stdin).
|
|
197
|
+
// REQUIRED if you want `cat draft.md | dotmd new <type> <slug>` (or @path,
|
|
198
|
+
// --body, heredoc) to work. Your `body` fn must also interpolate the input,
|
|
199
|
+
// e.g. `${ctx?.bodyInput?.trim() ?? ''}`. See the body-acceptance guard below.
|
|
200
|
+
// requiresBody: boolean — error if no body input (implies acceptsBody; see `prompt` builtin)
|
|
197
201
|
// targetRoot: string — name (basename or suffix) of the root this type lives in.
|
|
198
202
|
// In flat-array `root` configs (e.g. ['docs/plans', 'docs/prompts']),
|
|
199
203
|
// the new doc lands in the matching root. Falls back to `config.docsRoot`
|
|
@@ -203,7 +207,17 @@ export const presets = {
|
|
|
203
207
|
// under `docsRoot='docs'`, `dir` puts files in `docs/plans/` and `docs/prompts/`;
|
|
204
208
|
// under flat-array roots, `targetRoot` routes directly to the type-specific root.
|
|
205
209
|
// frontmatter: (status, isoTime, ctx) => string
|
|
206
|
-
// body: (title, ctx) => string
|
|
210
|
+
// body: (title, ctx) => string — slot user-supplied body via `ctx.bodyInput`
|
|
211
|
+
//
|
|
212
|
+
// Body-acceptance guard (the #1 custom-template gotcha):
|
|
213
|
+
// When you override a builtin and supply your OWN `body` fn that NEVER references
|
|
214
|
+
// `bodyInput`, dotmd assumes the fn would silently discard piped input — so it strips
|
|
215
|
+
// the inherited `acceptsBody`/`requiresBody` and rejects body input with a fail-fast
|
|
216
|
+
// error. Two ways to keep piped/@path/heredoc bodies working in a custom template:
|
|
217
|
+
// 1. interpolate `${ctx?.bodyInput?.trim() ?? ''}` somewhere in your `body` fn, OR
|
|
218
|
+
// 2. set `acceptsBody: true` explicitly (do BOTH if you want the input to actually land).
|
|
219
|
+
// A `body: (t) =>` that ignores `ctx` is the classic trap — it scaffolds fine but
|
|
220
|
+
// `dotmd new <type> <slug> < draft.md` errors until you wire in `bodyInput`.
|
|
207
221
|
//
|
|
208
222
|
// Custom type example — adds a `spike` type that lives in the `spikes` root (or
|
|
209
223
|
// under `docs/spikes/` in single-root layouts):
|
|
@@ -218,8 +232,11 @@ export const presets = {
|
|
|
218
232
|
// },
|
|
219
233
|
//
|
|
220
234
|
// // Override a builtin (e.g. project-specific prompt frontmatter shape).
|
|
221
|
-
// //
|
|
222
|
-
// // `defaultStatus`, `requiresBody`,
|
|
235
|
+
// // Overrides shallow-merge OVER the builtin: any property you omit is inherited
|
|
236
|
+
// // (`targetRoot`, `dir`, `defaultStatus`, `requiresBody`, …), and anything you declare
|
|
237
|
+
// // wins. EXCEPTION: if you supply your own `body` fn that doesn't reference `bodyInput`,
|
|
238
|
+
// // the inherited `acceptsBody`/`requiresBody` are dropped (see the guard above) — so
|
|
239
|
+
// // re-declare `acceptsBody: true` and wire in `ctx.bodyInput` if you want piped bodies.
|
|
223
240
|
// // prompt: {
|
|
224
241
|
// // description: 'Project resume prompt',
|
|
225
242
|
// // defaultStatus: 'pending',
|
package/package.json
CHANGED
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
|
@@ -9,5 +9,5 @@ export const KNOWN_COMMANDS = [
|
|
|
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/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/hud.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { buildIndex } from './index.mjs';
|
|
|
8
8
|
import { refreshStaleSlashCommands } from './claude-commands.mjs';
|
|
9
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
|
|
@@ -200,8 +205,8 @@ const MISUSE_RECAP_THRESHOLD = 3;
|
|
|
200
205
|
|
|
201
206
|
const MISUSE_CORRECTIONS = {
|
|
202
207
|
'edit-status': 'never hand-edit `status:`; use `dotmd set <status> <file>`',
|
|
203
|
-
'cat-prompt': '
|
|
204
|
-
'read-prompt': '
|
|
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>`',
|
|
205
210
|
'commit-prompt': 'saved prompts are session-local; never git add/commit them',
|
|
206
211
|
};
|
|
207
212
|
|
|
@@ -235,6 +240,10 @@ export function buildHud(config) {
|
|
|
235
240
|
// SessionStart for platform-scale corpora. Per-file validation + checkIndex
|
|
236
241
|
// still run, so the error count matches `dotmd check`'s.
|
|
237
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;
|
|
238
247
|
try {
|
|
239
248
|
// `autoHealIndex: true` mirrors `dotmd check` — drift from non-regen
|
|
240
249
|
// mutation paths (`lint --fix`, direct file edits, etc.) heals silently
|
|
@@ -242,12 +251,14 @@ export function buildHud(config) {
|
|
|
242
251
|
// spurious "Run `dotmd index`" error in the hud error count.
|
|
243
252
|
const index = buildIndex(config, { errorsOnly: true, autoHealIndex: true });
|
|
244
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 };
|
|
245
256
|
} catch { /* swallow — bad config shouldn't break the SessionStart hook */ }
|
|
246
257
|
|
|
247
258
|
const { previousSelf, fleet, recentRejections } = buildJournalSections(config);
|
|
248
259
|
const misuseRecap = buildMisuseRecap(config);
|
|
249
260
|
|
|
250
|
-
return { prompts, errors, previousSelf, fleet, recentRejections, misuseRecap };
|
|
261
|
+
return { owned, prompts, errors, previousSelf, fleet, recentRejections, misuseRecap };
|
|
251
262
|
}
|
|
252
263
|
|
|
253
264
|
// Subagent primer: a spawned subagent (Explore, Plan, general-purpose) starts
|
|
@@ -311,19 +322,30 @@ export function runHud(argv, config) {
|
|
|
311
322
|
return;
|
|
312
323
|
}
|
|
313
324
|
|
|
314
|
-
// SessionStart contract:
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
|
|
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
|
+
}
|
|
327
349
|
if (hud.misuseRecap) process.stdout.write(yellow(`[dotmd] ${hud.misuseRecap}`) + '\n');
|
|
328
350
|
if (drift) process.stdout.write(yellow(drift) + '\n');
|
|
329
351
|
}
|
package/src/lifecycle.mjs
CHANGED
|
@@ -928,7 +928,10 @@ export function appendVersionHistory(filePath, entry, { createSection = false }
|
|
|
928
928
|
|
|
929
929
|
export function updateFrontmatter(filePath, updates) {
|
|
930
930
|
const raw = readFileSync(filePath, 'utf8');
|
|
931
|
-
|
|
931
|
+
// Name the remedy in the error: this is where every status verb lands when a
|
|
932
|
+
// doc was created outside dotmd, and "no frontmatter block" alone left
|
|
933
|
+
// sessions retrying other verbs instead of fixing the doc.
|
|
934
|
+
if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block. Retrofit it first: dotmd bulk-tag ${filePath} --type <type> --status <status>`);
|
|
932
935
|
|
|
933
936
|
const endMarker = raw.indexOf('\n---\n', 4);
|
|
934
937
|
if (endMarker === -1) throw new Error(`${filePath} has unclosed frontmatter block.`);
|
package/src/new.mjs
CHANGED
|
@@ -256,7 +256,7 @@ function mergeBodyFrontmatter(scaffoldFm, overrides, cliType) {
|
|
|
256
256
|
return fm;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
-
function readBodyInput(source) {
|
|
259
|
+
export function readBodyInput(source) {
|
|
260
260
|
if (source === '-') {
|
|
261
261
|
try { return readFileSync(0, 'utf8'); } catch (err) { die(`Could not read body from stdin: ${err.message}`); }
|
|
262
262
|
}
|
|
@@ -548,6 +548,12 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
548
548
|
if (typeName === 'prompt') {
|
|
549
549
|
process.stdout.write(dim('Session-local — no need to commit. The next session runs `dotmd use` (or `dotmd use ' + repoPath + '`) to consume it.\n'));
|
|
550
550
|
}
|
|
551
|
+
// Teach the field-length contract at the moment the fields get written —
|
|
552
|
+
// learning it from a cap warning later sends sessions into hand-trim /
|
|
553
|
+
// re-check loops.
|
|
554
|
+
if (typeName === 'plan') {
|
|
555
|
+
process.stdout.write(dim('current_state = 2-4 sentence summary (cap 1500 chars); next_step = 1-2 sentence pointer (cap 800). Detail goes in the body, not frontmatter.\n'));
|
|
556
|
+
}
|
|
551
557
|
try {
|
|
552
558
|
const { isGitIgnored } = await import('./git.mjs');
|
|
553
559
|
if (isGitIgnored(filePath, config.repoRoot)) {
|
package/src/prompts.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { green, dim } from './color.mjs';
|
|
|
11
11
|
// `resume` is an alias for `use` — agents reach for "resume" when continuing a
|
|
12
12
|
// session; `use` reads as internal mechanics. Both names stay valid; the
|
|
13
13
|
// canonical output ("Consumed: …") is unchanged.
|
|
14
|
-
const SUBCOMMANDS = new Set(['list', 'next', 'use', 'resume', 'archive', 'new', 'hold', 'unhold', 'shelve', 'unshelve']);
|
|
14
|
+
const SUBCOMMANDS = new Set(['list', 'next', 'use', 'resume', 'show', 'peek', 'archive', 'new', 'hold', 'unhold', 'shelve', 'unshelve']);
|
|
15
15
|
|
|
16
16
|
export async function runPrompts(argv, config, opts = {}) {
|
|
17
17
|
const sub = argv[0];
|
|
@@ -26,6 +26,8 @@ export async function runPrompts(argv, config, opts = {}) {
|
|
|
26
26
|
case 'next': return runPromptsNext(rest, config, opts);
|
|
27
27
|
case 'use': return runPromptsUse(rest, config, opts);
|
|
28
28
|
case 'resume': return runPromptsUse(rest, config, opts);
|
|
29
|
+
case 'show': return runPromptsShow(rest, config);
|
|
30
|
+
case 'peek': return runPromptsShow(rest, config);
|
|
29
31
|
case 'archive': return runPromptsArchive(rest, config, opts);
|
|
30
32
|
case 'new': return runPromptsNew(rest, config, opts);
|
|
31
33
|
case 'hold': return runPromptsHold(rest, config, opts);
|
|
@@ -283,6 +285,28 @@ export function consumePrompt(filePath, config, opts) {
|
|
|
283
285
|
process.stderr.write(`${green('✓ Consumed')}: ${consumedPath}\n`);
|
|
284
286
|
}
|
|
285
287
|
|
|
288
|
+
// Read-only peek: print the body WITHOUT consuming. The sanctioned triage path
|
|
289
|
+
// — surveying pending prompts must not archive them (that's `use`'s job), and
|
|
290
|
+
// it must not require raw cat/Read (which the guard warns about).
|
|
291
|
+
function runPromptsShow(argv, config) {
|
|
292
|
+
const input = argv.find(a => !a.startsWith('-'));
|
|
293
|
+
if (!input) die('Usage: dotmd prompts show <file-or-slug>');
|
|
294
|
+
const filePath = resolvePromptInput(input, config);
|
|
295
|
+
|
|
296
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
297
|
+
const { frontmatter, body } = extractFrontmatter(raw);
|
|
298
|
+
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
299
|
+
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
300
|
+
if (asString(parsed.type) !== 'prompt') {
|
|
301
|
+
die(`Not a prompt (type: ${asString(parsed.type) ?? 'unknown'}): ${repoPath}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const status = asString(parsed.status) ?? 'unknown';
|
|
305
|
+
process.stderr.write(dim(`${repoPath} [${status}] — read-only peek; \`dotmd use ${repoPath}\` to consume\n`));
|
|
306
|
+
process.stdout.write(body);
|
|
307
|
+
if (!body.endsWith('\n')) process.stdout.write('\n');
|
|
308
|
+
}
|
|
309
|
+
|
|
286
310
|
function runPromptsArchive(argv, config, opts = {}) {
|
|
287
311
|
const input = argv.find(a => !a.startsWith('-'));
|
|
288
312
|
if (!input) die('Usage: dotmd prompts archive <file-or-slug>');
|
package/src/validate.mjs
CHANGED
|
@@ -475,7 +475,7 @@ export function validatePlanShape(doc, body, frontmatter, config) {
|
|
|
475
475
|
doc.warnings.push({
|
|
476
476
|
path: doc.path,
|
|
477
477
|
level: 'warning',
|
|
478
|
-
message: `\`next_step\` is ${nextStep.length} chars (cap: 800).
|
|
478
|
+
message: `\`next_step\` is ${nextStep.length} chars (cap: 800). One mechanical fix: \`dotmd doctor --frontmatter-fix\` (moves the overflow into the body) — do NOT hand-trim or re-run check in a loop. Going forward, write next_step as a 1-2 sentence pointer; detail goes in the body.`,
|
|
479
479
|
});
|
|
480
480
|
}
|
|
481
481
|
|
|
@@ -488,7 +488,7 @@ export function validatePlanShape(doc, body, frontmatter, config) {
|
|
|
488
488
|
doc.warnings.push({
|
|
489
489
|
path: doc.path,
|
|
490
490
|
level: 'warning',
|
|
491
|
-
message: `\`current_state\` is ${currentState.length} chars (cap: 1500).
|
|
491
|
+
message: `\`current_state\` is ${currentState.length} chars (cap: 1500). One mechanical fix: \`dotmd doctor --frontmatter-fix\` (moves the overflow into the body) — do NOT hand-trim or re-run check in a loop. Going forward, write current_state as a 2-4 sentence summary; detail goes in the body.`,
|
|
492
492
|
});
|
|
493
493
|
}
|
|
494
494
|
|