dotmd-cli 0.79.1 → 0.80.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 CHANGED
@@ -54,9 +54,10 @@ shell, and runlist reads it as a per-session identity automatically.
54
54
  /plugin install dotmd@dotmd
55
55
  ```
56
56
 
57
- The plugin provides SessionStart and SubagentStart orientation, a PreToolUse
58
- guard, the canonical workflow skill, and `/plans`, `/docs`, `/prompts`, and
59
- `/baton` commands.
57
+ The plugin provides SessionStart and SubagentStart orientation, a
58
+ UserPromptSubmit hint that gives the exact `runlist baton` form when you ask for
59
+ a handoff, a PreToolUse guard, the canonical workflow skill, and `/plans`,
60
+ `/docs`, `/prompts`, and `/baton` commands.
60
61
 
61
62
  ### OpenCode Plugin
62
63
 
package/bin/dotmd.mjs CHANGED
@@ -109,8 +109,9 @@ Common commands:
109
109
  set <status> [file] Transition status (start work, finish, archive — all via target status)
110
110
  new <type> <name> Create plan/doc/prompt (pipe stdin or @path for body)
111
111
  use [<file-or-slug>] Open a doc by type: prompt → consume, plan → start, doc → read
112
- baton [<plan>|<slug>] <@<file>|-> Save a resume prompt (+ release the plan, if one is in-session)
113
112
  (no file: consume oldest pending prompt)
113
+ baton [<plan>|<slug>] @<draft-file>
114
+ Save a resume prompt (+ release the plan, if one is in-session)
114
115
  archive <file> Close out a plan (status → archived, move, update refs)
115
116
 
116
117
  More help:
@@ -229,7 +230,7 @@ View & Query:
229
230
  grep <term> Keyword search incl. document bodies (query --keyword --body --all)
230
231
  plans Live plans (excludes archived; --include-archived for all)
231
232
  use [<file-or-slug>] Open a doc by type: prompt → consume, plan → start, doc → read
232
- baton [<plan>|<slug>] <@<file>|-> Save a resume prompt; releases the plan + prints the commit when one is in-session
233
+ baton [<plan>|<slug>] @<draft> Save a resume prompt; releases the plan + prints the commit when one is in-session
233
234
  prompts [list|show|archive|new] Prompt admin (list / peek / archive / save). Use \`runlist use\` to consume.
234
235
  stale Stale docs (preset)
235
236
  actionable Docs with next steps (preset)
@@ -917,49 +918,53 @@ Use --dry-run (-n) to preview without writing.`,
917
918
 
918
919
  new: `runlist new <type> <name> [body] — create a new document
919
920
 
920
- Types and their default destinations:
921
- plan docs/plans/<slug>.md (build-up template: Problem → Phases → Closeout)
922
- doc docs/<slug>.md (build-up lite: Overview → Version History → Related)
923
- prompt docs/prompts/<slug>.md (saved prompt to seed a future session — body required)
924
-
925
- \`<type>\` can be omitted; defaults to \`doc\`.
926
- \`<name>\` is slugified for the filename.
927
-
928
- Body input (all built-in types — required for prompt, optional for plan/doc):
929
- piped stdin Auto-consumed when stdin is piped/redirected (no flag needed)
930
- @path Read body from a file
931
- - Explicit stdin marker (equivalent to piped stdin)
932
- --body "<text>" Explicit inline body (alias: --message)
933
- <text> Inline body as 3rd positional
934
-
935
- Tip for agents: prefer piped stdin or \`@path\` for multi-line bodies. Inline
936
- bodies put the entire content on the bash command line, which (a) breaks
937
- under shell quoting for backticks/dollar-signs and (b) trips PreToolUse hooks
938
- that scan command strings for forbidden literals (destructive-git patterns,
939
- etc.). \`cat /tmp/foo.md | runlist new …\` and \`@/tmp/foo.md\` both sidestep both.
940
-
941
- For plan/doc, a single-section body lands under the type's first scaffolded
942
- section (e.g. \`## Problem\` for plans). If the body already authors
943
- \`## Section\` headings start-to-finish, the scaffold short-circuits and only
944
- the title + your body is emitted — no duplicated empty outline below
945
- (since 0.36.1).
921
+ Usage (write the draft to a file first):
922
+ runlist new plan <slug> @/tmp/draft.md # plan from a draft
923
+ runlist new doc <slug> @/tmp/draft.md # reference doc from a draft
924
+ runlist new prompt <slug> @/tmp/draft.md # saved prompt (body required)
925
+ runlist new plan <slug> # empty scaffold to fill in
926
+
927
+ What the draft does:
928
+ - Draft with its own \`## \` headings → it IS the body. Only a \`# Title\` is
929
+ added if missing; the template's outline is not appended. This holds for a
930
+ repo's own plan/doc template too.
931
+ - Draft with no \`## \` headings → lands in the template's first section
932
+ (\`## Problem\` for plans).
933
+ - Draft opening with a \`---\` frontmatter block → those keys replace the
934
+ scaffold's (status, surfaces, modules, current_state, next_step, …), so no
935
+ frontmatter edit is needed afterwards. \`type:\` is fixed by <type>.
936
+ To change status later, use \`runlist set <status> <file>\`.
937
+
938
+ The repo's own types, starting statuses and folders are listed at the end of
939
+ this help when it runs inside a runlist repo.
940
+
941
+ Body input (required for prompt, optional for plan/doc):
942
+ @path Read body from a file (preferred)
943
+ - Read stdin explicitly (heredoc: \`runlist new … - <<'EOF'\`)
944
+ piped stdin Read when something is piped or redirected in
945
+ --body "<text>" Inline body (alias: --message), one-liners only
946
+ <text> Inline body as 3rd positional, one-liners only
947
+
948
+ Inline bodies put the whole content on the command line, which breaks on
949
+ backticks and dollar signs and trips hooks that scan commands; use @path.
950
+
951
+ \`<type>\` can be omitted; defaults to \`doc\`. \`<name>\` is slugified for the
952
+ filename; a name with a \`/\` is read relative to the repo (or to --root).
946
953
 
947
954
  Examples:
948
- runlist new plan auth-revamp
955
+ runlist new plan auth-revamp @/tmp/auth-revamp.md
949
956
  runlist new prompt resume-foo @/tmp/draft.md
950
- cat /tmp/draft.md | runlist new prompt resume-foo
951
- runlist new prompt resume-foo <<'EOF'
952
- multi-line
953
- prompt body
954
- EOF
955
- runlist new prompt cleanup-tomorrow "look at remaining lint warnings"
956
- runlist new plan full-spec <<'EOF'
957
+ runlist new plan full-spec - <<'EOF'
958
+ ---
959
+ status: planned
960
+ next_step: Phase 1, extract the token store.
961
+ ---
957
962
  ## Problem
958
963
  …
959
964
  ## Phases
960
965
  …
961
966
  EOF
962
- runlist new plan auth-revamp "Investigation findings before scoping…"
967
+ runlist new prompt cleanup-tomorrow "look at remaining lint warnings"
963
968
 
964
969
  Scaffolding runlists (plans only):
965
970
  --runlist <a,b,c> Create a sprint runlist hub plus one child plan per slug.
@@ -1182,31 +1187,15 @@ Examples:
1182
1187
 
1183
1188
  baton: `runlist baton — save a resume prompt for whatever you're doing (and release the plan, if there is one)
1184
1189
 
1185
- The "save a resume prompt" verb. Works mid-anything:
1186
-
1187
- Plan mode (a plan is in-session, or you pass one):
1188
- The following publish in one atomic cooperating transaction:
1189
- 1. A resume prompt named resume-<plan-slug> (collision-safe: -2, -3, …),
1190
- stamped with a plan: link so consuming it re-claims the plan (see \`runlist
1191
- use\`). The prompt is session-local — the next session's hud surfaces it;
1192
- never paste resume text into chat.
1193
- 2. Releases the plan: one status flip, in-session → active by default
1194
- (--status to override, --note to record why in ## Version History).
1195
- 3. Defers the shared generated index and prints exact repository-only commit
1196
- guidance. Prompt and ownership records stay session-local and OUT of the
1197
- pathspec.
1198
- Which plan? Pass it explicitly, or baton resolves exactly one plan owned by
1199
- this authoritative session. Journal entries and global in-session counts never
1200
- grant ownership. A live pickup-hook delivery lease blocks release and force
1201
- takeover; hooks are at-least-once and deduplicate the stable operationId.
1190
+ Usage (write the resume to a file first, then pick the form that matches):
1191
+ runlist baton @/tmp/draft.md # a plan is in-session: save resume-<plan-slug>, release the plan
1192
+ runlist baton <plan-file> @/tmp/draft.md # hand off a named plan
1193
+ runlist baton <slug> @/tmp/draft.md # no plan: save resume-<slug>, change nothing else
1194
+ The resume can also come from stdin (\`-\`, or a pipe) or --message "..." for one-liners.
1195
+ Baton prints the prompt name it saved and, in plan mode, the git commit to run.
1202
1196
 
1203
- Slug mode (no plan involved — "save a resume prompt for this"):
1204
- runlist baton <slug> @/tmp/draft.md → saves resume-<slug>, touches NOTHING
1205
- else: no status changes, no commit, no plan required. Reference any relevant
1206
- plans/docs inside the draft body.
1207
-
1208
- Usage:
1209
- runlist baton [<plan-file> | <slug>] [@<draft-file> | - | --message "..."]
1197
+ The resume (10–20 lines): the next concrete decision plus any gotchas, and the
1198
+ paths of the plans/docs it concerns — not a recap of the plan body.
1210
1199
 
1211
1200
  Options:
1212
1201
  --status <s> Target status for the plan (default: active; plan mode only)
@@ -1217,14 +1206,34 @@ Options:
1217
1206
  --dry-run, -n Preview without writing
1218
1207
 
1219
1208
  Examples:
1220
- runlist baton @/tmp/draft.md # owned plan, body from file
1221
- runlist baton checkout-fixes @/tmp/draft.md # no plan: just save resume-checkout-fixes
1222
- cat /tmp/draft.md | runlist baton # body from stdin
1223
- runlist baton docs/plans/auth.md @/tmp/draft.md # explicit plan
1209
+ runlist baton @/tmp/draft.md
1210
+ runlist baton checkout-fixes @/tmp/draft.md
1211
+ runlist baton docs/plans/auth.md @/tmp/draft.md
1224
1212
  runlist baton --status paused --note "blocked on review" @/tmp/d.md
1213
+ cat /tmp/draft.md | runlist baton
1214
+
1215
+ Plan mode (a plan is in-session, or you pass one) publishes in one atomic
1216
+ cooperating transaction:
1217
+ 1. A resume prompt named resume-<plan-slug>, stamped with a plan: link so
1218
+ consuming it re-claims the plan (see \`runlist use\`). The prompt is
1219
+ session-local — the next session's hud surfaces it; never paste resume text
1220
+ into chat.
1221
+ 2. Releases the plan: one status flip, in-session → active by default.
1222
+ 3. Defers the shared generated index and prints exact repository-only commit
1223
+ guidance. Prompt and ownership records stay session-local and OUT of the
1224
+ pathspec.
1225
+ Which plan? Pass it explicitly, or baton resolves exactly one plan owned by
1226
+ this authoritative session. Journal entries and global in-session counts never
1227
+ grant ownership. A live pickup-hook delivery lease blocks release and force
1228
+ takeover; hooks are at-least-once and deduplicate the stable operationId.
1229
+
1230
+ Slug mode (no plan involved) saves resume-<slug> and touches nothing else: no
1231
+ status change, no commit. A bare word that names a plan is treated as that plan.
1225
1232
 
1226
- Write the draft FIRST (10–20 lines): the next concrete decision plus any
1227
- gotchas — not a recap of the plan body.`,
1233
+ Baton saves nothing while a handoff for the same work is pending (resume-<name>,
1234
+ or a pending prompt linked to the plan): consume or archive it, then re-run.
1235
+ With no @file, \`-\` or --message, baton reads stdin only when something is piped
1236
+ in; an open pipe that sends nothing is given up on after a moment.`,
1228
1237
 
1229
1238
  stale: `runlist stale — list stale documents
1230
1239
 
@@ -1600,6 +1609,17 @@ async function main() {
1600
1609
  if (args.includes('--help') || args.includes('-h')) {
1601
1610
  requireCommandPolicy(command, dispatchPolicy);
1602
1611
  process.stdout.write(`${HELP[command] ?? commandUsage(command)}\n`);
1612
+ if (command === 'new') {
1613
+ // Best effort: outside a runlist repo, or with a broken config, the
1614
+ // static help above is the whole answer.
1615
+ try {
1616
+ const repoConfig = await resolveConfig(process.cwd(), explicitConfig);
1617
+ if (repoConfig?.configFound !== false) {
1618
+ const { newHelpForRepo } = await import('../src/new.mjs');
1619
+ process.stdout.write(`\n${newHelpForRepo(repoConfig)}\n`);
1620
+ }
1621
+ } catch { /* static help already printed */ }
1622
+ }
1603
1623
  return;
1604
1624
  }
1605
1625
 
@@ -1619,6 +1639,9 @@ async function main() {
1619
1639
  process.stdout.write('{}\n');
1620
1640
  return;
1621
1641
  }
1642
+ // A prompt hook runs on every user message; a broken config must not
1643
+ // turn each one into an error.
1644
+ if (command === 'hud' && restArgs.includes('--prompt-submit')) return;
1622
1645
  throw err;
1623
1646
  }
1624
1647
  _resolvedConfig = config;
@@ -1820,6 +1843,12 @@ async function main() {
1820
1843
  if (command === 'export') { const { runExport } = await import('../src/export.mjs'); runExport(restArgs, config, { dryRun, root: rootArg, type: typeArg }); return; }
1821
1844
 
1822
1845
  // Lifecycle commands
1846
+ if (command === 'hud' && restArgs.includes('--prompt-submit')) {
1847
+ const { runPromptSubmitHud } = await import('../src/hud.mjs');
1848
+ const { readHookStdin } = await import('../src/guard.mjs');
1849
+ await runPromptSubmitHud(config, { readStdin: readHookStdin });
1850
+ return;
1851
+ }
1823
1852
  if (command === 'hud') { const { runHud } = await import('../src/hud.mjs'); runHud(restArgs, config); return; }
1824
1853
  if (command === 'guard') { const { runGuard } = await import('../src/guard.mjs'); await runGuard(restArgs, config, { dryRun }); return; }
1825
1854
  if (command === 'update') { const { runUpdate } = await import('../src/update.mjs'); runUpdate(restArgs, config, { dryRun }); return; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.79.1",
3
+ "version": "0.80.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/baton.mjs CHANGED
@@ -30,8 +30,13 @@ export function findOwnedPlan(config, index = null) {
30
30
  // resume itself: the session that did the work is the only one that knows the
31
31
  // next decision, and a prompt assembled from frontmatter reads like a handoff
32
32
  // while carrying nothing the plan doesn't already say.
33
+ // The three forms are listed because this is the message a bare `runlist baton`
34
+ // prints, and an agent unsure of the shape should be able to act on it without
35
+ // a trip through --help.
33
36
  const BODY_USAGE = `Nothing saved: baton needs the resume you wrote, passed as @<file> or - (stdin).
34
- runlist baton [<plan-or-slug>] @/tmp/draft.md`;
37
+ runlist baton @/tmp/draft.md # hand off the plan this session owns
38
+ runlist baton <plan-file> @/tmp/draft.md # hand off a named plan
39
+ runlist baton <slug> @/tmp/draft.md # no plan: save resume-<slug>, change nothing else`;
35
40
 
36
41
  // A handoff that lands beside a pending one leaves two prompts for the same
37
42
  // work, and the next session picks whichever sorts first. Baton used to step to
package/src/commands.mjs CHANGED
@@ -64,7 +64,7 @@ const definitions = [
64
64
  command('briefing', none, 'read', [form('', { options: [flag('--json')] })]),
65
65
  command('context', none, 'read', [form('', { options: [flag('--json'), flag('--compact'), flag('--summarize'), value('--model')] })]),
66
66
  command('agent-context', none, 'read', [form('', { options: [flag('--json')] })]),
67
- command('hud', none, 'read', [form('', { options: [flag('--json'), flag('--subagent')] })]),
67
+ command('hud', none, 'read', [form('', { options: [flag('--json'), flag('--subagent'), flag('--prompt-submit')] })]),
68
68
  command('focus', none, 'read', [form('[status]', { args: positionals(0, 1), options: [flag('--json')] })]),
69
69
  command('query', none, 'read', [form('[terms...]', { args: positionals(0, Infinity), options: QUERY_OPTIONS })]),
70
70
  command('grep', none, 'read', [form('<term> [terms...]', { args: positionals(1, Infinity), options: QUERY_OPTIONS })]),
package/src/guard.mjs CHANGED
@@ -365,7 +365,7 @@ export function evaluateGuard(payload, config, deps = {}) {
365
365
  return null;
366
366
  }
367
367
 
368
- function readStdin() {
368
+ export function readHookStdin() {
369
369
  return new Promise((resolve) => {
370
370
  let data = '';
371
371
  try {
@@ -404,7 +404,7 @@ function emit(result) {
404
404
  export async function runGuard(argv, config, opts = {}) {
405
405
  let payload = {};
406
406
  try {
407
- const raw = await readStdin();
407
+ const raw = await readHookStdin();
408
408
  if (raw && raw.trim()) payload = JSON.parse(raw);
409
409
  } catch {
410
410
  payload = {};
package/src/hud.mjs CHANGED
@@ -1,12 +1,15 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { currentSessionId, isArchivedPath, relTime } from './util.mjs';
4
+ import { asString, currentSessionId, isArchivedPath, relTime, toRepoPath } from './util.mjs';
5
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
6
+ import { promptDirectory } from './new.mjs';
5
7
  import { dim, yellow } from './color.mjs';
6
8
  import { buildIndex } from './index.mjs';
7
9
  import { readJournalEntries, journalFilePath, readMisuseEntries } from './journal.mjs';
8
10
  import { compareVersions } from './update.mjs';
9
11
  import { findOwnedPlan } from './baton.mjs';
12
+ import { listOwnedPlans } from './pickup.mjs';
10
13
  import { actionablePromptStatuses, comparePromptDocs, resolveStatusMetadata } from './status-metadata.mjs';
11
14
 
12
15
  export { actionablePromptStatuses } from './status-metadata.mjs';
@@ -48,6 +51,41 @@ export function detectVersionDrift(env = process.env) {
48
51
  // status surfaced too, without needing a code change.
49
52
  // Returns repo paths, oldest-created first — the same order no-arg `dotmd use`
50
53
  // consumes them, so prompts[0] is always "the one you'd pick up next".
54
+ // The same answer from the prompt directory alone, reading frontmatter only.
55
+ // The full index read every body in the repo; at ~5k docs that took 10s and the
56
+ // SessionStart hook's 5s timeout killed hud before it printed anything.
57
+ export function findActionablePromptsInPromptDir(config) {
58
+ const actionable = actionablePromptStatuses(config);
59
+ const dir = promptDirectory(config);
60
+ if (!existsSync(dir)) return [];
61
+ const docs = [];
62
+ for (const entry of readdirSync(dir, { withFileTypes: true, recursive: true })) {
63
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
64
+ const abs = path.join(entry.parentPath ?? entry.path, entry.name);
65
+ const repoPath = toRepoPath(abs, config.repoRoot);
66
+ if (isArchivedPath(repoPath, config)) continue;
67
+ let fm;
68
+ try { fm = parseSimpleFrontmatter(extractFrontmatter(readFileSync(abs, 'utf8')).frontmatter ?? ''); }
69
+ catch { continue; }
70
+ const status = asString(fm.status);
71
+ if (asString(fm.type) !== 'prompt' || !actionable.has(status)) continue;
72
+ docs.push({ path: repoPath, status, created: asString(fm.created) || null, updated: asString(fm.updated) || null });
73
+ }
74
+ return docs.sort(comparePromptDocs).map(doc => doc.path);
75
+ }
76
+
77
+ // Text-mode hud: pending prompts and this session's plan, without the index.
78
+ export function buildHudFast(config) {
79
+ let prompts = [];
80
+ let owned = null;
81
+ try { prompts = findActionablePromptsInPromptDir(config); } catch { /* hud must not fail */ }
82
+ try {
83
+ const records = listOwnedPlans(config);
84
+ if (!records.diagnostics?.length && records.length === 1) owned = { path: records[0].plan, title: null, via: 'ownership' };
85
+ } catch { /* hud must not fail */ }
86
+ return { owned, prompts, misuseRecap: buildMisuseRecap(config) };
87
+ }
88
+
51
89
  function findActionablePrompts(config, index) {
52
90
  const actionable = actionablePromptStatuses(config);
53
91
  return index.docs
@@ -266,6 +304,47 @@ export function buildPlanStatusPrimer(config, { maxChars = 220 } = {}) {
266
304
  // the zero-false-positive signal for "this is a dotmd repo." A bare docs/ dir is
267
305
  // deliberately NOT enough — too many repos have one. In a non-dotmd repo the hook
268
306
  // then contributes nothing to the session: no primer, no index build, no heal.
307
+ // UserPromptSubmit: when the user asks for a baton, tell the session the exact
308
+ // form for its situation before it goes looking. Sessions ran `baton --help`
309
+ // before nearly every baton even with the form in CLAUDE.md, because the right
310
+ // form depends on whether this session owns a plan, which only runlist knows.
311
+ const HANDOFF_ASK = /\bbaton\b|\bhand[\s-]?off\b|\bresume[\s-]prompt\b|\bsave (?:a |the )?resume\b|\bpick (?:it|this|that) (?:back )?up (?:next time|later|tomorrow)\b/i;
312
+
313
+ export function isHandoffAsk(prompt) {
314
+ return typeof prompt === 'string' && HANDOFF_ASK.test(prompt);
315
+ }
316
+
317
+ export function buildHandoffContext(config) {
318
+ // Ownership records name their plans and are checked against those files
319
+ // alone; building the index here cost 9s in a repo of ~5k docs, past the
320
+ // hook's timeout.
321
+ let ownedPaths = [];
322
+ try {
323
+ const records = listOwnedPlans(config);
324
+ if (!records.diagnostics?.length) ownedPaths = records.map(record => record.plan);
325
+ } catch { /* fall through to the no-plan form */ }
326
+ const draft = 'Write the resume first (the next concrete decision, any gotchas, the plan/doc paths it concerns; not a recap) to a file in your scratchpad.';
327
+ const tail = 'Baton prints the prompt name it saved; tell the user that name. No `--help` needed.';
328
+ if (ownedPaths.length === 1) {
329
+ return `[runlist] Baton: this session owns ${ownedPaths[0]}. ${draft} Then run \`runlist baton @<file>\`: it saves the prompt, releases the plan to active (\`--status paused|awaiting|partial|blocked\` and \`--note "why"\` if that fits better), and prints the commit to run. ${tail}`;
330
+ }
331
+ if (ownedPaths.length > 1) {
332
+ return `[runlist] Baton: this session owns ${ownedPaths.length} plans (${ownedPaths.join(', ')}), so name the one to hand off. ${draft} Then run \`runlist baton <plan-file> @<file>\`. ${tail}`;
333
+ }
334
+ return `[runlist] Baton: this session owns no plan. ${draft} Then run \`runlist baton <slug> @<file>\` (saves resume-<slug>, changes nothing else), or \`runlist baton <plan-file> @<file>\` to hand off a plan by path. ${tail}`;
335
+ }
336
+
337
+ export async function runPromptSubmitHud(config, { readStdin } = {}) {
338
+ if (!isDotmdRepo(config)) return;
339
+ let prompt = '';
340
+ try {
341
+ const raw = await readStdin();
342
+ if (raw && raw.trim()) prompt = JSON.parse(raw).prompt ?? '';
343
+ } catch { return; }
344
+ if (!isHandoffAsk(prompt)) return;
345
+ process.stdout.write(buildHandoffContext(config) + '\n');
346
+ }
347
+
269
348
  function isDotmdRepo(config) {
270
349
  return Boolean(config?.configFound);
271
350
  }
@@ -291,13 +370,14 @@ export function runHud(argv, config) {
291
370
  // the session. Skip the index build, slash-heal, primer, and drift line.
292
371
  if (!dotmdRepo && !json) return;
293
372
 
294
- const hud = buildHud(config);
295
-
296
373
  if (json) {
374
+ const hud = buildHud(config);
297
375
  process.stdout.write(JSON.stringify({ ...hud, drift: drift ?? null }, null, 2) + '\n');
298
376
  return;
299
377
  }
300
378
 
379
+ const hud = buildHudFast(config);
380
+
301
381
  // SessionStart contract: the command primer, plus ONLY signals that carry a
302
382
  // direct instruction for this session. Passive state (error counts,
303
383
  // slash-command refresh notices, previous-self / fleet / recent-rejections)
@@ -313,7 +393,7 @@ export function runHud(argv, config) {
313
393
  // Global in-session counts never provide a fallback.
314
394
  // The misuse recap stays for the same reason: a repeat-offense rule means
315
395
  // the primer alone isn't landing, so name the habit to break.
316
- process.stdout.write(dim('runlist: plans|briefing set <status> [<file>] new <type> <slug> use [<file>] archive <file> baton [<slug>] <@<file>|-> (save a resume prompt; releases the in-session plan if any) (use [no-arg] → oldest pending prompt)') + '\n');
396
+ process.stdout.write(dim('runlist: plans|briefing set <status> [<file>] new <type> <slug> use [<file>] archive <file> baton [<plan-or-slug>] @<draft-file> (save a resume prompt; releases the in-session plan if any) (use [no-arg] → oldest pending prompt)') + '\n');
317
397
  process.stdout.write(dim(buildPlanStatusPrimer(config)) + '\n');
318
398
  if (hud.owned && hud.owned.via === 'ownership') {
319
399
  process.stdout.write(yellow(`[runlist] in-session (yours): ${hud.owned.path} — continue it; hand off with \`runlist baton @/tmp/draft.md\` before stopping.`) + '\n');
package/src/new.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from 'node:child_process';
1
2
  import { existsSync, readFileSync, mkdirSync, fstatSync } from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
@@ -348,13 +349,43 @@ export function readBodyInput(source) {
348
349
  return source;
349
350
  }
350
351
 
352
+ // An agent's shell can hand a command a pipe that is open and never written or
353
+ // closed (Claude Code does for chained and backgrounded commands), so a plain
354
+ // blocking read of an implicit pipe hangs until the tool times out: a bare
355
+ // `runlist baton` sat there for two minutes in the platform transcripts. A
356
+ // child reads the pipe instead and gives up when the first byte doesn't arrive
357
+ // within the window; once data starts it waits for EOF, so a slow producer that
358
+ // has begun writing is never cut off.
359
+ export const PIPED_STDIN_IDLE_MS = 1500;
360
+ const STDIN_SILENT_EXIT = 3;
361
+ const PIPE_READER = `let d='',got=false;` +
362
+ `const t=setTimeout(()=>{if(!got)process.exit(${STDIN_SILENT_EXIT})},Number(process.argv[1]));` +
363
+ `process.stdin.setEncoding('utf8');` +
364
+ `process.stdin.on('data',c=>{got=true;d+=c});` +
365
+ `process.stdin.on('end',()=>{clearTimeout(t);process.stdout.write(d)});`;
366
+
367
+ function readPipeUnlessSilent() {
368
+ const r = spawnSync(process.execPath, ['-e', PIPE_READER, String(PIPED_STDIN_IDLE_MS)], {
369
+ stdio: [0, 'pipe', 'ignore'],
370
+ encoding: 'utf8',
371
+ maxBuffer: 64 * 1024 * 1024,
372
+ windowsHide: true,
373
+ });
374
+ if (r.status !== 0) return null;
375
+ return r.stdout;
376
+ }
377
+
351
378
  export function readPipedBodyInput() {
352
379
  try {
353
380
  const stat = fstatSync(0);
381
+ if (stat.isFile()) {
382
+ const redirected = readFileSync(0, 'utf8');
383
+ return redirected.length > 0 ? redirected : null;
384
+ }
354
385
  const isWindowsPipe = process.platform === 'win32' && !process.stdin.isTTY;
355
- if (stat.isFIFO() || stat.isFile() || stat.isSocket() || isWindowsPipe) {
356
- const piped = readFileSync(0, 'utf8');
357
- return piped.length > 0 ? piped : null;
386
+ if (stat.isFIFO() || stat.isSocket() || isWindowsPipe) {
387
+ const piped = readPipeUnlessSilent();
388
+ return piped ? piped : null;
358
389
  }
359
390
  } catch { /* stdin not introspectable */ }
360
391
  return null;
@@ -368,6 +399,18 @@ export function titleize(s) {
368
399
  return s.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
369
400
  }
370
401
 
402
+ // Where saved prompts are written: the prompt template's targetRoot when a
403
+ // root matches it, else the primary root plus the template's `dir`.
404
+ export function promptDirectory(config, template = resolveTemplate('prompt', config)) {
405
+ let targetRoot = config.docsRoot;
406
+ let routed = false;
407
+ if (template.targetRoot) {
408
+ const match = (config.docsRoots || [config.docsRoot]).find(root => root.endsWith(template.targetRoot) || path.basename(root) === template.targetRoot);
409
+ if (match) { targetRoot = match; routed = true; }
410
+ }
411
+ return template.dir && !routed ? path.join(targetRoot, template.dir) : targetRoot;
412
+ }
413
+
371
414
  export function preparePromptDocument(name, bodyInput, config, { plan = null, dryRun = false } = {}) {
372
415
  const template = resolveTemplate('prompt', config);
373
416
  const typeStatuses = config.typeStatuses?.get('prompt');
@@ -377,14 +420,7 @@ export function preparePromptDocument(name, bodyInput, config, { plan = null, dr
377
420
  if (!bodyInput?.trim()) die('`prompt` template requires a body.');
378
421
  const slug = slugify(path.basename(name, '.md'));
379
422
  const title = titleize(path.basename(name, '.md'));
380
- let targetRoot = config.docsRoot;
381
- let routed = false;
382
- if (template.targetRoot) {
383
- const match = (config.docsRoots || [config.docsRoot]).find(root => root.endsWith(template.targetRoot) || path.basename(root) === template.targetRoot);
384
- if (match) { targetRoot = match; routed = true; }
385
- }
386
- const baseDir = template.dir && !routed ? path.join(targetRoot, template.dir) : targetRoot;
387
- const filePath = path.join(baseDir, `${slug}.md`);
423
+ const filePath = path.join(promptDirectory(config, template), `${slug}.md`);
388
424
  authorizeManagedDestination(filePath, config, { kind: 'Baton prompt destination' });
389
425
  if (dryRun) return { slug, filePath, repoPath: toRepoPath(filePath, config.repoRoot), content: null };
390
426
 
@@ -989,7 +1025,13 @@ export async function runNew(argv, config, opts = {}) {
989
1025
  if (isRoadmap) fm = mergeBodyFrontmatter(fm, { execution_mode: 'roadmap' }, typeName);
990
1026
  let body;
991
1027
  const variantBody = isRunlistHub || isCoordinationHub || isRoadmap || isLite || isAudit;
992
- const authored = variantBody ? fullBodyShortcut(docTitle, bodyInput) : null;
1028
+ // A repo that overrides the plan or doc template keeps the built-in promise
1029
+ // that an authored body replaces the outline. Without this, the override's
1030
+ // body fn dropped the draft into its first slot and appended its own
1031
+ // sections after it, so a draft opening `## Problem` came out with two, and
1032
+ // sessions trimmed the tail by hand. Other custom types render as written.
1033
+ const overridesShortcutType = template._overridesBuiltin && (typeName === 'plan' || typeName === 'doc');
1034
+ const authored = variantBody || overridesShortcutType ? fullBodyShortcut(docTitle, bodyInput) : null;
993
1035
  if (authored !== null) body = authored;
994
1036
  else if (isRunlistHub) body = runlistHubBody(docTitle, slug, runlistChildren, bodyInput, today);
995
1037
  else if (isCoordinationHub) body = coordinationHubBody(docTitle, bodyInput, today);
@@ -1074,6 +1116,40 @@ export async function runNew(argv, config, opts = {}) {
1074
1116
  };
1075
1117
  }
1076
1118
 
1119
+ // The part of `new --help` only the repo can answer: which types exist here,
1120
+ // what status each starts at, which statuses are valid, and where each lands.
1121
+ // Sessions otherwise read the config file to find out.
1122
+ export function newHelpForRepo(config) {
1123
+ // Only types with a template: a status-only type can't be created by `new`.
1124
+ const types = new Set([...Object.keys(BUILTIN_TEMPLATES), ...Object.keys(config.raw?.templates ?? {})]);
1125
+ const rows = [];
1126
+ const width = Math.max(...[...types].map(t => t.length));
1127
+ for (const typeName of types) {
1128
+ const template = resolveTemplate(typeName, config);
1129
+ if (!template) continue;
1130
+ const statuses = [...(config.typeStatuses?.get(typeName) ?? [])];
1131
+ const tmplDefault = typeof template === 'object' ? template.defaultStatus : null;
1132
+ const start = tmplDefault && (!statuses.length || statuses.includes(tmplDefault)) ? tmplDefault : (statuses[0] ?? tmplDefault ?? 'active');
1133
+ const roots = config.docsRoots ?? [config.docsRoot];
1134
+ // Mirrors runNew: a matching targetRoot wins, else the catch-all root plus
1135
+ // the template's `dir`.
1136
+ const typeRoot = typeof template === 'object' && template.targetRoot
1137
+ ? roots.find(r => r.endsWith(template.targetRoot) || path.basename(r) === template.targetRoot)
1138
+ : null;
1139
+ const dest = typeRoot ?? (typeof template === 'object' && template.dir
1140
+ ? path.join(catchAllRoot(config), template.dir)
1141
+ : catchAllRoot(config));
1142
+ const where = toRepoPath(dest, config.repoRoot) + '/';
1143
+ const custom = Object.prototype.hasOwnProperty.call(config.raw?.templates ?? {}, typeName) ? ' (this repo\'s template)' : '';
1144
+ rows.push(` ${typeName.padEnd(width)} → ${where}<slug>.md, starts ${start}${custom}`);
1145
+ if (statuses.length) rows.push(` ${''.padEnd(width)} statuses: ${statuses.join(', ')}`);
1146
+ }
1147
+ const roots = (config.docsRoots ?? [config.docsRoot]).map(r => path.basename(r));
1148
+ return `This repo:
1149
+ ${rows.join('\n')}
1150
+ roots (for --root): ${roots.join(', ')}`;
1151
+ }
1152
+
1077
1153
  function resolveTemplate(name, config) {
1078
1154
  const configTemplates = config.raw?.templates ?? {};
1079
1155
  const override = configTemplates[name];