dotmd-cli 0.79.1 → 0.81.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 +4 -3
- package/bin/dotmd.mjs +96 -67
- package/package.json +1 -1
- package/src/baton.mjs +36 -3
- package/src/commands.mjs +1 -1
- package/src/git.mjs +15 -3
- package/src/guard.mjs +2 -2
- package/src/hud.mjs +85 -5
- package/src/lifecycle.mjs +9 -1
- package/src/new.mjs +88 -12
- package/src/prompts.mjs +14 -4
- package/src/util.mjs +48 -2
- package/src/validate.mjs +13 -2
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
|
|
58
|
-
|
|
59
|
-
`/
|
|
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>]
|
|
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
|
-
|
|
921
|
-
plan
|
|
922
|
-
doc
|
|
923
|
-
prompt
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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
|
|
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
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
-
|
|
1204
|
-
|
|
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
|
|
1221
|
-
runlist baton checkout-fixes @/tmp/draft.md
|
|
1222
|
-
|
|
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
|
-
|
|
1227
|
-
|
|
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
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
|
|
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
|
|
@@ -156,6 +161,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
156
161
|
let repoPath = null;
|
|
157
162
|
let oldStatus = null;
|
|
158
163
|
let ownershipPath = null;
|
|
164
|
+
let planGuard = null;
|
|
159
165
|
if (planPath) {
|
|
160
166
|
planPath = authorizeManagedSource(planPath, config, { kind: 'Baton plan source' }).path;
|
|
161
167
|
repoPath = toRepoPath(planPath, config.repoRoot);
|
|
@@ -178,7 +184,29 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
178
184
|
if (status === 'in-session') {
|
|
179
185
|
die('`runlist baton --status in-session` contradicts baton release semantics. Choose active/paused/awaiting/partial/blocked.');
|
|
180
186
|
}
|
|
181
|
-
|
|
187
|
+
const sessionId = authoritativeSessionId();
|
|
188
|
+
const ownership = assertPlanMutationAuthorized(repoPath, config, { sessionId, force });
|
|
189
|
+
const ownedHere = ownership?.state === 'owned' && ownership.sessionId === sessionId;
|
|
190
|
+
// Baton's release only means something when there is a claim to release. A
|
|
191
|
+
// plan that is neither in-session nor owned here carries a status someone
|
|
192
|
+
// chose on purpose (`awaiting`, `blocked`, a repo's own `awaiting-testing`),
|
|
193
|
+
// and flipping it to the default `active` as a side effect of saving a
|
|
194
|
+
// prompt overwrites that reason — silently, since baton's headline is the
|
|
195
|
+
// prompt it saved. That is not hypothetical: a prompt-refresh pass named a
|
|
196
|
+
// plan slug, got a status flip it never asked for, and had to notice and
|
|
197
|
+
// undo it. So the prompt still lands with its `plan:` link, and the plan's
|
|
198
|
+
// status stays put unless the caller states the transition with --status.
|
|
199
|
+
const currentStatus = asString(fm.status);
|
|
200
|
+
if (!statusFlag && oldStatus !== 'in-session' && !ownedHere) {
|
|
201
|
+
if (!currentStatus || (validStatuses?.size > 0 && !validStatuses.has(currentStatus))) {
|
|
202
|
+
die(`${repoPath} is not in-session and its status (\`${oldStatus}\`) is not one baton can leave in place.\n`
|
|
203
|
+
+ `Say what it should become: runlist baton ${repoPath} @<draft-file> --status <status>\n`
|
|
204
|
+
+ `Or save the prompt without touching the plan: runlist baton ${path.basename(planPath, '.md')} @<draft-file>`);
|
|
205
|
+
}
|
|
206
|
+
status = currentStatus;
|
|
207
|
+
planGuard = { path: planPath, expectedContent: raw };
|
|
208
|
+
if (note) warn('--note ignored — the plan\'s status is unchanged, so there is no transition to record.');
|
|
209
|
+
}
|
|
182
210
|
// Before the plan-completion step: a refusal must leave nothing changed.
|
|
183
211
|
refuseIfPending();
|
|
184
212
|
ownershipPath = readPlanOwnership(repoPath, config)?.recordPath ?? null;
|
|
@@ -216,7 +244,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
216
244
|
const prepared = preparePromptDocument(slugBase, body, config, { plan: repoPath, dryRun });
|
|
217
245
|
const setArgs = [status, planPath];
|
|
218
246
|
if (force) setArgs.push('--force');
|
|
219
|
-
if (note) setArgs.push('--note', note);
|
|
247
|
+
if (note && !planGuard) setArgs.push('--note', note);
|
|
220
248
|
try {
|
|
221
249
|
if (dryRun) process.stdout.write(`${dim('[dry-run]')} Would create: ${prepared.repoPath}\n`);
|
|
222
250
|
archiveResult = await runSet(setArgs, config, {
|
|
@@ -224,6 +252,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
224
252
|
viaBaton: true,
|
|
225
253
|
testHooks: opts.testHooks,
|
|
226
254
|
creations: dryRun ? [] : [{ path: prepared.filePath, content: prepared.content }],
|
|
255
|
+
guards: planGuard && !dryRun ? [planGuard] : [],
|
|
227
256
|
deferIndex: true,
|
|
228
257
|
});
|
|
229
258
|
} catch (err) {
|
|
@@ -292,6 +321,10 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
292
321
|
return operationResult;
|
|
293
322
|
}
|
|
294
323
|
process.stderr.write(`\n${prefix}${green('✓ Baton passed')}: ${createdSlug} (the next session's hud surfaces it — nothing to paste into chat)\n`);
|
|
324
|
+
if (planGuard) {
|
|
325
|
+
process.stderr.write(dim(`${repoPath} left at \`${oldStatus}\` — it was not in-session, so there was no claim to release.\n`));
|
|
326
|
+
process.stderr.write(dim(`Meant to change it? runlist set <status> ${repoPath}\n`));
|
|
327
|
+
}
|
|
295
328
|
if (statusChanged) {
|
|
296
329
|
const pathspec = operationResult.repositoryFiles.join(' ');
|
|
297
330
|
let gitignored = false;
|
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/git.mjs
CHANGED
|
@@ -122,8 +122,7 @@ export function getGitLastModifiedBatch(repoRoot, relPaths, options = {}) {
|
|
|
122
122
|
assertSafeGitPaths(paths);
|
|
123
123
|
if (paths.length === 0) return { dates: new Map(), commits: new Map(), history: new Map(), complete: true, reason: null };
|
|
124
124
|
// Full-tree callers can supply a small set of configured root pathspecs for
|
|
125
|
-
// diff extraction.
|
|
126
|
-
// so excluded or unrelated documents cannot consume the commit bound.
|
|
125
|
+
// diff extraction.
|
|
127
126
|
const scanPaths = options.pathspecs?.length ? [...new Set(options.pathspecs)] : paths;
|
|
128
127
|
assertSafeGitPaths(scanPaths);
|
|
129
128
|
const expectedPaths = new Set(paths);
|
|
@@ -133,10 +132,23 @@ export function getGitLastModifiedBatch(repoRoot, relPaths, options = {}) {
|
|
|
133
132
|
const commitsByPath = new Map();
|
|
134
133
|
const history = new Map();
|
|
135
134
|
let reason = null;
|
|
135
|
+
// Revision selection uses the root pathspecs when the caller supplied them,
|
|
136
|
+
// not the full requested list: Git matches every pathspec against every
|
|
137
|
+
// commit's diff, so a full-tree scan of a ~5k-doc corpus spent ~14s here
|
|
138
|
+
// walking history 5k pathspecs at a time (the same walk costs ~0.25s against
|
|
139
|
+
// one root). The window can only widen, never narrow, per requested path —
|
|
140
|
+
// every requested path lives under a root, so a commit touching one matches
|
|
141
|
+
// the root too, and the window stays a newest-first prefix of history. What
|
|
142
|
+
// it costs is depth: commits touching only non-requested docs now consume the
|
|
143
|
+
// bound, so a path whose latest commit falls outside the window resolves to
|
|
144
|
+
// no date at all rather than to a stale one. That case is already accounted
|
|
145
|
+
// for below — an unresolved TRACKED path keeps `commit-limit`, which callers
|
|
146
|
+
// surface as "Git metadata is incomplete".
|
|
147
|
+
const revisionPathspecs = scanPaths.length < paths.length ? scanPaths : paths;
|
|
136
148
|
const revisions = spawnSync('git', ['rev-list', '--stdin', `--max-count=${maxCommits + 1}`, revision], {
|
|
137
149
|
cwd: repoRoot,
|
|
138
150
|
encoding: 'utf8',
|
|
139
|
-
input: `--\n${
|
|
151
|
+
input: `--\n${revisionPathspecs.map(literalGitPathspec).join('\n')}\n`,
|
|
140
152
|
maxBuffer,
|
|
141
153
|
});
|
|
142
154
|
if (revisions.error?.code === 'ENOBUFS') {
|
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
|
|
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
|
|
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>]
|
|
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/lifecycle.mjs
CHANGED
|
@@ -417,7 +417,15 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
417
417
|
|
|
418
418
|
if (oldStatus === newStatus) {
|
|
419
419
|
if (!dryRun && (opts.additionalUpdates?.length || opts.creations?.length)) {
|
|
420
|
-
|
|
420
|
+
// Guards belong here as much as on the mutating paths below: a caller that
|
|
421
|
+
// asked for the status it already has (baton's prompt-only handoff) decided
|
|
422
|
+
// that from this file's status and then writes only its creations, so the
|
|
423
|
+
// read and the write need something holding the gap.
|
|
424
|
+
mutateFileSet({
|
|
425
|
+
updates: opts.additionalUpdates ?? [],
|
|
426
|
+
creations: opts.creations ?? [],
|
|
427
|
+
guards: opts.guards ?? [],
|
|
428
|
+
}, {
|
|
421
429
|
repoRoot: config.repoRoot,
|
|
422
430
|
testHooks: opts.testHooks,
|
|
423
431
|
});
|
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.
|
|
356
|
-
const piped =
|
|
357
|
-
return piped
|
|
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
|
-
|
|
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
|
-
|
|
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];
|
package/src/prompts.mjs
CHANGED
|
@@ -56,7 +56,13 @@ export async function runPrompts(argv, config, opts = {}) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function runPromptsList(argv, config, opts = {}) {
|
|
59
|
-
|
|
59
|
+
// Listing prompts renders slug / status / updated / target — nothing any
|
|
60
|
+
// validating pass produces, and `runQuery` never reads warnings either. A
|
|
61
|
+
// full `buildIndex` here parsed every body in the repo through the ref,
|
|
62
|
+
// git-staleness and hub passes (~25s on a 5k-doc corpus) to print ten lines,
|
|
63
|
+
// and then `pendingPromptsOldestFirst` scanned the tree a second time. One
|
|
64
|
+
// fast index, shared, on the same terms hud reads on.
|
|
65
|
+
const index = buildIndex(config, { fast: true, invokeHooks: false });
|
|
60
66
|
const hasStatusFlag = argv.includes('--status');
|
|
61
67
|
const includeArchived = argv.includes('--include-archived');
|
|
62
68
|
const sub = argv[0];
|
|
@@ -87,7 +93,7 @@ function runPromptsList(argv, config, opts = {}) {
|
|
|
87
93
|
}
|
|
88
94
|
|
|
89
95
|
function renderPromptQueueList(index, config) {
|
|
90
|
-
const queue = pendingPromptsOldestFirst(config);
|
|
96
|
+
const queue = pendingPromptsOldestFirst(config, index);
|
|
91
97
|
const queuedPaths = new Set(queue.map(q => q.doc.path));
|
|
92
98
|
const others = index.docs
|
|
93
99
|
.filter(d => d.type === 'prompt' && !queuedPaths.has(d.path) && !isArchivedPath(d.path, config) && d.status !== 'archived')
|
|
@@ -175,8 +181,12 @@ function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived })
|
|
|
175
181
|
}
|
|
176
182
|
}
|
|
177
183
|
|
|
178
|
-
|
|
179
|
-
|
|
184
|
+
// `index` lets a caller that already has a fast index (the prompt listings)
|
|
185
|
+
// share it rather than re-walking the tree. It must be a full index of the
|
|
186
|
+
// repo: the filter below narrows it, so a pre-filtered one would drop prompts
|
|
187
|
+
// from the queue.
|
|
188
|
+
export function pendingPromptsOldestFirst(config, index = null) {
|
|
189
|
+
index = index ?? buildIndex(config, { fast: true, invokeHooks: false });
|
|
180
190
|
const actionable = actionablePromptStatuses(config);
|
|
181
191
|
const prompts = index.docs.filter(d =>
|
|
182
192
|
d.type === 'prompt'
|
package/src/util.mjs
CHANGED
|
@@ -221,6 +221,48 @@ export function levenshtein(a, b) {
|
|
|
221
221
|
return matrix[b.length][a.length];
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
// Edit distance, but only as far as the caller cares. Returns the distance when
|
|
225
|
+
// it is <= limit and null when it provably exceeds it. The suggester asks
|
|
226
|
+
// "within 3 edits?" of every candidate in the index, and the full matrix
|
|
227
|
+
// computes an exact answer it then throws away: ~900 cells and two allocations
|
|
228
|
+
// per pair, several hundred thousand pairs per unresolved reference on a large
|
|
229
|
+
// corpus. Only cells within `limit` of the diagonal can hold a value <= limit,
|
|
230
|
+
// so this walks that band in two flat rows and abandons a row whose cheapest
|
|
231
|
+
// cell is already past the limit. Distances <= limit are identical to
|
|
232
|
+
// `levenshtein`'s — test/util.test.mjs checks that against it directly.
|
|
233
|
+
export function levenshteinWithin(a, b, limit) {
|
|
234
|
+
if (Math.abs(a.length - b.length) > limit) return null;
|
|
235
|
+
if (a.length === 0) return b.length;
|
|
236
|
+
if (b.length === 0) return a.length;
|
|
237
|
+
const over = limit + 1;
|
|
238
|
+
let prev = new Int32Array(a.length + 2).fill(over);
|
|
239
|
+
let cur = new Int32Array(a.length + 2).fill(over);
|
|
240
|
+
for (let j = 0; j <= Math.min(a.length, limit); j++) prev[j] = j;
|
|
241
|
+
for (let i = 1; i <= b.length; i++) {
|
|
242
|
+
const lo = Math.max(1, i - limit);
|
|
243
|
+
const hi = Math.min(a.length, i + limit);
|
|
244
|
+
// Reset the cells this row will write plus the two the next row reads as
|
|
245
|
+
// band neighbours, so nothing stale survives the row swap.
|
|
246
|
+
cur.fill(over, Math.max(0, lo - 1), hi + 2);
|
|
247
|
+
cur[0] = i <= limit ? i : over;
|
|
248
|
+
let rowMin = over;
|
|
249
|
+
for (let j = lo; j <= hi; j++) {
|
|
250
|
+
const substitution = prev[j - 1] + (b.charCodeAt(i - 1) === a.charCodeAt(j - 1) ? 0 : 1);
|
|
251
|
+
const deletion = prev[j] + 1;
|
|
252
|
+
const insertion = cur[j - 1] + 1;
|
|
253
|
+
let best = substitution < deletion ? substitution : deletion;
|
|
254
|
+
if (insertion < best) best = insertion;
|
|
255
|
+
if (best > limit) best = over;
|
|
256
|
+
cur[j] = best;
|
|
257
|
+
if (best < rowMin) rowMin = best;
|
|
258
|
+
}
|
|
259
|
+
if (rowMin > limit) return null;
|
|
260
|
+
const swap = prev; prev = cur; cur = swap;
|
|
261
|
+
}
|
|
262
|
+
const distance = prev[a.length];
|
|
263
|
+
return distance <= limit ? distance : null;
|
|
264
|
+
}
|
|
265
|
+
|
|
224
266
|
// Top-N candidates from a list, ranked for "did you mean" hints. Substring
|
|
225
267
|
// match wins (cheap and intent-revealing for typos that share a prefix or
|
|
226
268
|
// stem); Levenshtein distance ≤3 catches transpositions and small edits.
|
|
@@ -246,8 +288,12 @@ export function suggestCandidates(query, candidates, max = 3) {
|
|
|
246
288
|
if (!cand || scored.has(cand)) continue;
|
|
247
289
|
const candLower = String(cand).toLowerCase();
|
|
248
290
|
if (candLower === lower) continue;
|
|
249
|
-
|
|
250
|
-
|
|
291
|
+
// The candidate list here is every basename AND every repo path in the
|
|
292
|
+
// index, so on a large corpus this loop ran hundreds of thousands of
|
|
293
|
+
// distance computations per unresolved reference. Only "within 3 edits"
|
|
294
|
+
// matters, so it asks for exactly that.
|
|
295
|
+
const dist = levenshteinWithin(lower, candLower, 3);
|
|
296
|
+
if (dist !== null) scored.set(cand, 1000 + dist);
|
|
251
297
|
}
|
|
252
298
|
}
|
|
253
299
|
|
package/src/validate.mjs
CHANGED
|
@@ -338,14 +338,25 @@ function candidatePathsForType(docs, type) {
|
|
|
338
338
|
// can't see siblings). Filters candidates by ref-field type when the field
|
|
339
339
|
// name implies one (e.g. `related_plans` → plans only).
|
|
340
340
|
export function enrichRefErrorSuggestions(docs, config) {
|
|
341
|
+
// One candidate list per inferred type, not per broken ref. The list is a
|
|
342
|
+
// filter plus a ~2-per-doc Set build over the whole index, and it does not
|
|
343
|
+
// depend on the entry — rebuilding it for each entry made this pass quadratic
|
|
344
|
+
// in (docs × broken refs), which on a 4.8k-doc corpus with a couple hundred
|
|
345
|
+
// unresolved refs was ~9s of every `check`, `briefing` and `plans` run.
|
|
346
|
+
const candidateCache = new Map();
|
|
347
|
+
const candidatesFor = (type) => {
|
|
348
|
+
const key = type ?? '';
|
|
349
|
+
if (!candidateCache.has(key)) candidateCache.set(key, candidatePathsForType(docs, type));
|
|
350
|
+
return candidateCache.get(key);
|
|
351
|
+
};
|
|
352
|
+
|
|
341
353
|
const enrich = (entry) => {
|
|
342
354
|
if (!entry?.meta || !['ref-resolution', 'body-link-resolution'].includes(entry.meta.kind)) return;
|
|
343
355
|
if (entry.meta.kind === 'body-link-resolution'
|
|
344
356
|
&& (entry.meta.targetKind !== 'document' || entry.meta.reason !== 'missing')) return;
|
|
345
357
|
if (entry._suggested) return;
|
|
346
358
|
const inferred = inferRefFieldType(entry.meta.field);
|
|
347
|
-
const
|
|
348
|
-
const suggestions = suggestCandidates(path.basename(entry.meta.relPath), candidates);
|
|
359
|
+
const suggestions = suggestCandidates(path.basename(entry.meta.relPath), candidatesFor(inferred));
|
|
349
360
|
entry._suggested = true;
|
|
350
361
|
if (suggestions.length === 0) return;
|
|
351
362
|
entry.message = `${entry.message} Did you mean: ${suggestions.join(', ')}?`;
|