dotmd-cli 0.60.0 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -13
- package/bin/dotmd.mjs +109 -8
- package/dotmd.config.example.mjs +21 -4
- package/package.json +1 -1
- package/src/baton.mjs +231 -0
- package/src/commands.mjs +2 -2
- package/src/completions.mjs +1 -1
- package/src/doctor.mjs +38 -0
- package/src/guard.mjs +84 -32
- package/src/health.mjs +55 -9
- package/src/hud.mjs +40 -18
- package/src/index.mjs +8 -1
- package/src/lifecycle.mjs +4 -1
- package/src/new.mjs +7 -1
- package/src/prompts.mjs +25 -1
- package/src/query.mjs +305 -46
- package/src/render.mjs +22 -3
- package/src/runlist.mjs +118 -0
- package/src/validate.mjs +29 -2
package/src/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/index.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
4
|
import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
|
|
5
5
|
import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn, die, resolveDocPath, suggestCandidates } from './util.mjs';
|
|
6
|
-
import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
|
|
6
|
+
import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, checkCoordinationHubExecutionMode, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
|
|
7
7
|
import { checkIndex } from './index-file.mjs';
|
|
8
8
|
import { checkClaudeCommands } from './claude-commands.mjs';
|
|
9
9
|
import { checkGlossaryConfig } from './glossary-check.mjs';
|
|
@@ -120,6 +120,13 @@ export function buildIndex(config, opts = {}) {
|
|
|
120
120
|
if (child) child.warnings.push(w);
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
const coordHubWarnings = checkCoordinationHubExecutionMode(transformedDocs, config);
|
|
124
|
+
warnings.push(...coordHubWarnings);
|
|
125
|
+
for (const w of coordHubWarnings) {
|
|
126
|
+
const hub = transformedDocs.find(d => d.path === w.path);
|
|
127
|
+
if (hub) hub.warnings.push(w);
|
|
128
|
+
}
|
|
129
|
+
|
|
123
130
|
const gitWarnings = checkGitStaleness(transformedDocs, config);
|
|
124
131
|
warnings.push(...gitWarnings);
|
|
125
132
|
|
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/query.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { getGitLastModifiedBatch } from './git.mjs';
|
|
|
7
7
|
import { extractFrontmatter } from './frontmatter.mjs';
|
|
8
8
|
import { summarizeDocBody } from './ai.mjs';
|
|
9
9
|
import { bold, dim, yellow, red, green, blue, magenta, cyan, brightYellow } from './color.mjs';
|
|
10
|
+
import { buildRunlistIndex, buildCoordinationIndex, hubLabel } from './runlist.mjs';
|
|
10
11
|
|
|
11
12
|
const STATUS_COLORS = {
|
|
12
13
|
'in-session': (s) => bold(red(s)),
|
|
@@ -92,7 +93,10 @@ export function runQuery(index, argv, config, opts = {}) {
|
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
if (opts.preset === 'plans' || opts.preset === 'prompts') {
|
|
95
|
-
|
|
96
|
+
// Runlist folding only applies to plans (prompts have no runlists).
|
|
97
|
+
const runlist = opts.preset === 'plans' ? buildRunlistIndex(index, config) : null;
|
|
98
|
+
const coordination = opts.preset === 'plans' ? buildCoordinationIndex(index, config) : null;
|
|
99
|
+
renderPlansOutput(docs, filters, config, { noun: opts.preset, runlist, coordination });
|
|
96
100
|
if (docs.length === 0) writeUnknownFilterValueHint(filters, index);
|
|
97
101
|
return;
|
|
98
102
|
}
|
|
@@ -101,6 +105,83 @@ export function runQuery(index, argv, config, opts = {}) {
|
|
|
101
105
|
if (docs.length === 0) writeUnknownFilterValueHint(filters, index);
|
|
102
106
|
}
|
|
103
107
|
|
|
108
|
+
// Sort comparator for the runlists dashboard. Default `age` puts the MOST STALE
|
|
109
|
+
// hub first — a triage lens (which nav-map has gone longest untouched?), echoing
|
|
110
|
+
// `dotmd modules --sort cleanup`. `recent` is the old newest-first order.
|
|
111
|
+
// Unknown-age hubs sort last in both directions so they never dominate.
|
|
112
|
+
const RUNLIST_SORTS = new Set(['age', 'recent', 'related', 'title', 'status']);
|
|
113
|
+
function runlistSorter(sort, coordination, config) {
|
|
114
|
+
const cmpAge = (a, b, dir) => {
|
|
115
|
+
const av = a.daysSinceUpdate, bv = b.daysSinceUpdate;
|
|
116
|
+
if (av == null && bv == null) return 0;
|
|
117
|
+
if (av == null) return 1;
|
|
118
|
+
if (bv == null) return -1;
|
|
119
|
+
return dir * (av - bv);
|
|
120
|
+
};
|
|
121
|
+
const related = (d) => coordination.get(d.path)?.childCount ?? 0;
|
|
122
|
+
const byLabel = (a, b) => hubLabel(a).localeCompare(hubLabel(b));
|
|
123
|
+
if (sort === 'recent') return (a, b) => cmpAge(a, b, 1) || byLabel(a, b);
|
|
124
|
+
if (sort === 'related') return (a, b) => related(b) - related(a) || cmpAge(a, b, -1) || byLabel(a, b);
|
|
125
|
+
if (sort === 'title') return byLabel;
|
|
126
|
+
if (sort === 'status') {
|
|
127
|
+
return (a, b) => {
|
|
128
|
+
const ai = config.statusOrder.indexOf(a.status), bi = config.statusOrder.indexOf(b.status);
|
|
129
|
+
const aIdx = ai === -1 ? Number.MAX_SAFE_INTEGER : ai, bIdx = bi === -1 ? Number.MAX_SAFE_INTEGER : bi;
|
|
130
|
+
return aIdx - bIdx || cmpAge(a, b, -1) || byLabel(a, b);
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return (a, b) => cmpAge(a, b, -1) || byLabel(a, b); // 'age' (default): most stale first
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// `dotmd runlists` — the dedicated coordination-hub dashboard: the `Runlists`
|
|
137
|
+
// section from `dotmd plans`, on its own, showing every hub (no leaf list, no
|
|
138
|
+
// cap by default — runlists are a small bounded set). `--limit N` caps it,
|
|
139
|
+
// `--sort age|recent|related|title|status` orders it (default `age`, most stale
|
|
140
|
+
// first), `--json` emits structured rows.
|
|
141
|
+
export function runRunlists(index, argv, config) {
|
|
142
|
+
const json = argv.includes('--json');
|
|
143
|
+
let limit = Infinity;
|
|
144
|
+
const li = argv.indexOf('--limit');
|
|
145
|
+
if (li >= 0 && argv[li + 1]) { const n = Number.parseInt(argv[li + 1], 10); if (Number.isFinite(n)) limit = n; }
|
|
146
|
+
const si = argv.indexOf('--sort');
|
|
147
|
+
const sortArg = si >= 0 && argv[si + 1] ? argv[si + 1] : 'age';
|
|
148
|
+
if (!RUNLIST_SORTS.has(sortArg)) die(`Unknown --sort '${sortArg}'. Use one of: ${[...RUNLIST_SORTS].join(', ')}.`);
|
|
149
|
+
|
|
150
|
+
const coordination = buildCoordinationIndex(index, config);
|
|
151
|
+
const archived = new Set([
|
|
152
|
+
...(config.lifecycle?.archiveStatuses ?? []),
|
|
153
|
+
...(config.lifecycle?.terminalStatuses ?? []),
|
|
154
|
+
]);
|
|
155
|
+
const hubs = index.docs
|
|
156
|
+
.filter(d => coordination.has(d.path) && !archived.has(d.status) && !isArchivedPath(d.path, config))
|
|
157
|
+
.sort(runlistSorter(sortArg, coordination, config));
|
|
158
|
+
|
|
159
|
+
if (json) {
|
|
160
|
+
const runlists = hubs.map(d => ({
|
|
161
|
+
path: d.path,
|
|
162
|
+
status: d.status,
|
|
163
|
+
title: d.title,
|
|
164
|
+
childCount: coordination.get(d.path)?.childCount ?? 0,
|
|
165
|
+
updated: d.updated,
|
|
166
|
+
nextStep: d.nextStep ?? null,
|
|
167
|
+
}));
|
|
168
|
+
process.stdout.write(JSON.stringify({ count: runlists.length, runlists }, null, 2) + '\n');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (hubs.length === 0) {
|
|
173
|
+
process.stdout.write('No runlists found. A runlist is a plan with `execution_mode: coordination` (or a `*-runlist` slug).\n');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const maxWidth = process.stdout.columns || 100;
|
|
178
|
+
const shown = hubs.slice(0, limit);
|
|
179
|
+
renderCoordinationSection(shown, coordination, maxWidth, hubs.length);
|
|
180
|
+
const hidden = hubs.length - shown.length;
|
|
181
|
+
if (hidden > 0) process.stdout.write(dim(` ${hidden} more · dotmd runlists --limit ${hubs.length}\n`));
|
|
182
|
+
process.stdout.write('\n');
|
|
183
|
+
}
|
|
184
|
+
|
|
104
185
|
// When a query returns nothing AND a value-shaped filter (currently --module)
|
|
105
186
|
// names a value that doesn't exist anywhere in the index, the empty result is
|
|
106
187
|
// almost certainly a typo rather than a combination miss. Surface a hint so
|
|
@@ -254,6 +335,10 @@ export function filterDocs(docs, filters, config) {
|
|
|
254
335
|
const s = d.status ?? 'unknown';
|
|
255
336
|
filters._statusCounts[s] = (filters._statusCounts[s] ?? 0) + 1;
|
|
256
337
|
}
|
|
338
|
+
// Keep a reference to the full pre-limit set so the plans header can
|
|
339
|
+
// reclassify runlist hubs out of the status breakdown (it needs per-doc
|
|
340
|
+
// identity, not just aggregate counts).
|
|
341
|
+
filters._matched = result;
|
|
257
342
|
return filters.all ? result : result.slice(0, filters.limit);
|
|
258
343
|
}
|
|
259
344
|
|
|
@@ -394,6 +479,16 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
|
|
|
394
479
|
return;
|
|
395
480
|
}
|
|
396
481
|
|
|
482
|
+
const maxWidth = process.stdout.columns || 100;
|
|
483
|
+
const grouped = filters.sort === 'status' || filters.group;
|
|
484
|
+
// Runlist treatment (sprint-hub folding + the coordination-hub section) is
|
|
485
|
+
// scoped to the flat triage view; grouped views keep their existing shape.
|
|
486
|
+
const runlist = !grouped ? opts.runlist : null;
|
|
487
|
+
const coordination = !grouped ? opts.coordination : null;
|
|
488
|
+
// A doc is a "runlist" for header/section purposes if it's either a
|
|
489
|
+
// frontmatter-`runlist:` sprint hub or an `execution_mode: coordination` hub.
|
|
490
|
+
const isHub = (p) => Boolean(runlist?.hubs.has(p) || coordination?.has(p));
|
|
491
|
+
|
|
397
492
|
// Summary line: middle-dot separator, ALWAYS based on the full pre-limit
|
|
398
493
|
// pipeline so the top-of-page numbers stay honest when --limit is applied.
|
|
399
494
|
const totalShown = docs.length;
|
|
@@ -403,9 +498,27 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
|
|
|
403
498
|
for (const d of docs) { counts[d.status] = (counts[d.status] ?? 0) + 1; }
|
|
404
499
|
return counts;
|
|
405
500
|
})();
|
|
406
|
-
//
|
|
407
|
-
|
|
408
|
-
|
|
501
|
+
// With runlists present, pull hubs out of the per-status breakdown into a
|
|
502
|
+
// dedicated `N runlist` bucket so a hub reads as an active *runlist*, not one
|
|
503
|
+
// more active plan. Needs per-doc identity, so recompute from the pre-limit
|
|
504
|
+
// matched set rather than the aggregate counts.
|
|
505
|
+
let hubCount = 0;
|
|
506
|
+
let counts;
|
|
507
|
+
if ((runlist?.hubs.size || coordination?.size) && filters._matched) {
|
|
508
|
+
const reclassed = {};
|
|
509
|
+
for (const d of filters._matched) {
|
|
510
|
+
if (isHub(d.path)) { hubCount += 1; continue; }
|
|
511
|
+
const s = d.status ?? 'unknown';
|
|
512
|
+
reclassed[s] = (reclassed[s] ?? 0) + 1;
|
|
513
|
+
}
|
|
514
|
+
counts = Object.entries(reclassed).sort((a, b) => b[1] - a[1]).map(([s, n]) => `${n} ${s}`);
|
|
515
|
+
} else {
|
|
516
|
+
counts = Object.entries(bySt).sort((a, b) => b[1] - a[1]).map(([s, n]) => `${n} ${s}`);
|
|
517
|
+
}
|
|
518
|
+
const headerParts = [];
|
|
519
|
+
if (hubCount) headerParts.push(`${hubCount} runlist${hubCount === 1 ? '' : 's'}`);
|
|
520
|
+
headerParts.push(...counts);
|
|
521
|
+
const header = `${totalAll} ${noun}${headerParts.length ? ' · ' + headerParts.join(' · ') : ''}`;
|
|
409
522
|
process.stdout.write(dim(header) + '\n');
|
|
410
523
|
|
|
411
524
|
// Active filter note
|
|
@@ -420,9 +533,6 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
|
|
|
420
533
|
if (filters.hasBlockers) activeFilters.push('has blockers');
|
|
421
534
|
if (activeFilters.length) process.stdout.write(dim(` filtered: ${activeFilters.join(' | ')}`) + '\n');
|
|
422
535
|
|
|
423
|
-
const maxWidth = process.stdout.columns || 100;
|
|
424
|
-
const grouped = filters.sort === 'status' || filters.group;
|
|
425
|
-
|
|
426
536
|
if (filters.group === 'module') {
|
|
427
537
|
process.stdout.write('\n');
|
|
428
538
|
renderPlansByGroup(docs, d => d.modules?.length ? d.modules : ['(none)'], filters, maxWidth);
|
|
@@ -447,12 +557,47 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
|
|
|
447
557
|
renderPlanRows(group, filters, maxWidth, { showTag: false });
|
|
448
558
|
}
|
|
449
559
|
} else {
|
|
450
|
-
//
|
|
560
|
+
// Flat triage view, "capped like leaves": work from the full pre-limit
|
|
561
|
+
// matched set and cap each kind independently. Leaf plans (+ frontmatter
|
|
562
|
+
// `runlist:` sprint hubs, which still fold inline) fill the main list;
|
|
563
|
+
// coordination hubs are lifted into their own `Runlists` section. Each
|
|
564
|
+
// section caps at `--limit` with its own "N more" footer; `--all` lifts
|
|
565
|
+
// both caps. The Runlists section is pinned — it shows whenever hubs exist,
|
|
566
|
+
// independent of how the leaf list fills up.
|
|
567
|
+
const matched = filters._matched ?? docs;
|
|
568
|
+
const coordAll = [];
|
|
569
|
+
const mainAll = [];
|
|
570
|
+
for (const d of matched) {
|
|
571
|
+
if (coordination?.has(d.path)) coordAll.push(d);
|
|
572
|
+
else mainAll.push(d);
|
|
573
|
+
}
|
|
574
|
+
const mainShown = filters.all ? mainAll : mainAll.slice(0, filters.limit);
|
|
575
|
+
const coordShown = filters.all ? coordAll : coordAll.slice(0, filters.limit);
|
|
576
|
+
|
|
451
577
|
process.stdout.write('\n');
|
|
452
|
-
|
|
578
|
+
if (mainShown.length) {
|
|
579
|
+
if (runlist?.hubs.size) renderTriageWithRunlists(mainShown, runlist, maxWidth);
|
|
580
|
+
else renderPlanRows(mainShown, filters, maxWidth, { showTag: true });
|
|
581
|
+
}
|
|
582
|
+
const mainHidden = mainAll.length - mainShown.length;
|
|
583
|
+
if (mainHidden > 0) {
|
|
584
|
+
process.stdout.write('\n');
|
|
585
|
+
process.stdout.write(dim(` ${mainHidden} more ${noun} · dotmd ${noun} --all · dotmd ${noun} status\n`));
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (coordShown.length) {
|
|
589
|
+
renderCoordinationSection(coordShown, coordination, maxWidth, coordAll.length);
|
|
590
|
+
const coordHidden = coordAll.length - coordShown.length;
|
|
591
|
+
if (coordHidden > 0) {
|
|
592
|
+
process.stdout.write(dim(` ${coordHidden} more runlists · dotmd ${noun} --all\n`));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
process.stdout.write('\n');
|
|
597
|
+
return;
|
|
453
598
|
}
|
|
454
599
|
|
|
455
|
-
// Footer when the result was capped
|
|
600
|
+
// Footer (grouped views) — emit when the result was capped.
|
|
456
601
|
const hidden = totalAll - totalShown;
|
|
457
602
|
if (hidden > 0) {
|
|
458
603
|
process.stdout.write('\n');
|
|
@@ -483,49 +628,163 @@ function renderPlansByGroup(docs, keyFn, filters, maxWidth) {
|
|
|
483
628
|
// next-step column when right-aligning tags.
|
|
484
629
|
const MAX_TAG_WIDTH = '[QUEUED-AFTER]'.length;
|
|
485
630
|
|
|
486
|
-
|
|
631
|
+
// Format a single triage row: `<indent><slug> <age> <pct> <next-step>` with
|
|
632
|
+
// an optional right-aligned tag. `indent` carries the left gutter (and, for
|
|
633
|
+
// runlist children, the `→` next-pickup marker), so its visible length must be
|
|
634
|
+
// stable across sibling rows for the slug column to align. `tag` overrides the
|
|
635
|
+
// status tag (used for the `[RUNLIST]` hub tag).
|
|
636
|
+
function formatPlanRow(doc, maxWidth, { slug, indent = ' ', maxSlug, showTag = false, tag } = {}) {
|
|
637
|
+
const slugCell = slug.padEnd(maxSlug ?? slug.length);
|
|
638
|
+
const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '—';
|
|
639
|
+
const ageStr = doc.daysSinceUpdate != null && doc.isStale ? red(age.padStart(4)) : dim(age.padStart(4));
|
|
640
|
+
|
|
641
|
+
// Compact percentage cell (always 5 chars: "100% " / " 99% " / " 5% " / " ")
|
|
642
|
+
let pctCell = ' ';
|
|
643
|
+
if (doc.checklist?.total) {
|
|
644
|
+
const pct = Math.round((doc.checklist.completed / doc.checklist.total) * 100);
|
|
645
|
+
pctCell = `${pct.toString().padStart(3)}% `;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const leftBlock = `${indent}${slugCell} ${ageStr} ${dim(pctCell)}`;
|
|
649
|
+
const leftLen = visibleLen(leftBlock);
|
|
650
|
+
|
|
651
|
+
// Next-step / blocker text. Budget = maxWidth - leftLen - separator - (tag column if shown).
|
|
652
|
+
let nextText = '';
|
|
653
|
+
if (doc.blockers?.length && doc.status === 'blocked') {
|
|
654
|
+
nextText = `blocked by ${doc.blockers.join('; ')}`;
|
|
655
|
+
} else if (doc.nextStep) {
|
|
656
|
+
nextText = doc.nextStep;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const tagBudget = showTag ? MAX_TAG_WIDTH + 2 : 0; // 2 = gap before tag
|
|
660
|
+
const nextBudget = Math.max(10, maxWidth - leftLen - 2 - tagBudget); // -2 for ` ` separator
|
|
661
|
+
let nextRendered = nextText;
|
|
662
|
+
if (nextText.length > nextBudget) nextRendered = nextText.slice(0, nextBudget - 3) + '...';
|
|
663
|
+
|
|
664
|
+
// Coloring for "blocked by" stays yellow.
|
|
665
|
+
if (doc.blockers?.length && doc.status === 'blocked') nextRendered = yellow(nextRendered);
|
|
666
|
+
|
|
667
|
+
let line = `${leftBlock} ${nextRendered}`;
|
|
668
|
+
if (showTag) {
|
|
669
|
+
// Pad next column to push tag to the right column boundary.
|
|
670
|
+
const consumed = visibleLen(line);
|
|
671
|
+
const targetCol = maxWidth - MAX_TAG_WIDTH;
|
|
672
|
+
const padCount = Math.max(2, targetCol - consumed);
|
|
673
|
+
line = `${line}${' '.repeat(padCount)}${tag ?? colorTag(doc.status)}`;
|
|
674
|
+
}
|
|
675
|
+
return line;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function renderPlanRows(group, _filters, maxWidth, opts = {}) {
|
|
487
679
|
const { showTag = false } = opts;
|
|
488
680
|
const maxSlug = Math.min(30, Math.max(...group.map(d => toSlug(d).length)));
|
|
489
|
-
|
|
490
681
|
for (const doc of group) {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
682
|
+
process.stdout.write(formatPlanRow(doc, maxWidth, { slug: toSlug(doc), maxSlug, showTag }) + '\n');
|
|
683
|
+
}
|
|
684
|
+
}
|
|
494
685
|
|
|
495
|
-
|
|
496
|
-
let pctCell = ' ';
|
|
497
|
-
if (doc.checklist?.total) {
|
|
498
|
-
const pct = Math.round((doc.checklist.completed / doc.checklist.total) * 100);
|
|
499
|
-
pctCell = `${pct.toString().padStart(3)}% `;
|
|
500
|
-
}
|
|
686
|
+
const RUNLIST_TAG = bold(cyan('[RUNLIST]'));
|
|
501
687
|
|
|
502
|
-
|
|
503
|
-
|
|
688
|
+
// Drop a hub's slug prefix off a child slug so a sprint's children read as
|
|
689
|
+
// `01-extract` rather than `auth-revamp-01-extract`. Falls back to the full
|
|
690
|
+
// slug when there's no shared prefix.
|
|
691
|
+
function stripHubPrefix(childSlug, hubSlug) {
|
|
692
|
+
return childSlug.startsWith(`${hubSlug}-`) ? childSlug.slice(hubSlug.length + 1) : childSlug;
|
|
693
|
+
}
|
|
504
694
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
695
|
+
// Flat triage view, runlist-aware. Standalone plans render as before; each hub
|
|
696
|
+
// becomes a `[RUNLIST]` header with its (filtered) children folded underneath
|
|
697
|
+
// in runlist order, the next pickup marked `→`. A child whose hub is absent
|
|
698
|
+
// from this filtered set (e.g. `--status active` hid the hub) renders
|
|
699
|
+
// standalone so it still surfaces. Render units sort by their most-recent
|
|
700
|
+
// member so an actively-worked sprint stays near the top.
|
|
701
|
+
function renderTriageWithRunlists(docs, runlist, maxWidth) {
|
|
702
|
+
const { hubs, childToHub } = runlist;
|
|
703
|
+
const docPathSet = new Set(docs.map(d => d.path));
|
|
704
|
+
|
|
705
|
+
const folded = new Set();
|
|
706
|
+
for (const d of docs) {
|
|
707
|
+
const hubPath = childToHub.get(d.path);
|
|
708
|
+
if (hubPath && docPathSet.has(hubPath)) folded.add(d.path);
|
|
709
|
+
}
|
|
512
710
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
711
|
+
const units = [];
|
|
712
|
+
for (const d of docs) {
|
|
713
|
+
if (folded.has(d.path)) continue; // emitted under its hub
|
|
714
|
+
const info = hubs.get(d.path);
|
|
715
|
+
if (info) {
|
|
716
|
+
const children = docs.filter(c => childToHub.get(c.path) === d.path);
|
|
717
|
+
const ages = [d.daysSinceUpdate, ...children.map(c => c.daysSinceUpdate)].filter(n => n != null);
|
|
718
|
+
units.push({ anchor: ages.length ? Math.min(...ages) : Infinity, kind: 'hub', hub: d, info, children });
|
|
719
|
+
} else {
|
|
720
|
+
units.push({ anchor: d.daysSinceUpdate ?? Infinity, kind: 'plan', doc: d });
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
// Stable sort by most-recent member ascending (docs arrive already sorted by
|
|
724
|
+
// `updated`, so equal anchors keep their incoming order).
|
|
725
|
+
units.sort((a, b) => a.anchor - b.anchor);
|
|
726
|
+
|
|
727
|
+
// Shared slug width for the left-most rows (standalone plans + hub headers).
|
|
728
|
+
const topSlugs = units.map(u => u.kind === 'hub' ? toSlug(u.hub) : toSlug(u.doc));
|
|
729
|
+
const topMaxSlug = Math.min(30, Math.max(...topSlugs.map(s => s.length)));
|
|
730
|
+
|
|
731
|
+
for (const u of units) {
|
|
732
|
+
if (u.kind === 'plan') {
|
|
733
|
+
process.stdout.write(formatPlanRow(u.doc, maxWidth, { slug: toSlug(u.doc), maxSlug: topMaxSlug, showTag: true }) + '\n');
|
|
734
|
+
} else {
|
|
735
|
+
renderHubBlock(u.hub, u.info, u.children, maxWidth, topMaxSlug);
|
|
528
736
|
}
|
|
529
|
-
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function renderHubBlock(hub, info, children, maxWidth, topMaxSlug) {
|
|
741
|
+
const hubSlug = toSlug(hub);
|
|
742
|
+
const nextDoc = info.nextChildPath ? info.children.find(c => c.path === info.nextChildPath)?.doc : null;
|
|
743
|
+
const nextLabel = nextDoc ? stripHubPrefix(toSlug(nextDoc), hubSlug) : null;
|
|
744
|
+
const descr = nextLabel
|
|
745
|
+
? `runlist · ${info.doneCount}/${info.total} · next → ${nextLabel}`
|
|
746
|
+
: `runlist · ${info.doneCount}/${info.total} · all archived`;
|
|
747
|
+
|
|
748
|
+
// Header row: hub slug + descriptor, with `[RUNLIST]` right-aligned like a tag.
|
|
749
|
+
const slugCell = hubSlug.padEnd(topMaxSlug);
|
|
750
|
+
let header = ` ${slugCell} ${dim(descr)}`;
|
|
751
|
+
const targetCol = maxWidth - MAX_TAG_WIDTH;
|
|
752
|
+
const pad = Math.max(2, targetCol - visibleLen(header));
|
|
753
|
+
header = `${header}${' '.repeat(pad)}${RUNLIST_TAG}`;
|
|
754
|
+
process.stdout.write(header + '\n');
|
|
755
|
+
|
|
756
|
+
if (children.length === 0) return;
|
|
757
|
+
const childMaxSlug = Math.min(28, Math.max(...children.map(c => stripHubPrefix(toSlug(c), hubSlug).length)));
|
|
758
|
+
for (const c of children) {
|
|
759
|
+
const isNext = c.path === info.nextChildPath;
|
|
760
|
+
const indent = isNext ? ` ${green('→')} ` : ' ';
|
|
761
|
+
process.stdout.write(formatPlanRow(c, maxWidth, {
|
|
762
|
+
slug: stripHubPrefix(toSlug(c), hubSlug), indent, maxSlug: childMaxSlug, showTag: true,
|
|
763
|
+
}) + '\n');
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Coordination hubs (prose-first runlists) render in their own compact section:
|
|
768
|
+
// label · age · rough related-cluster size · one-line descriptor. No fold, no
|
|
769
|
+
// per-row tag — the section header is the signal. The count is the resolved
|
|
770
|
+
// `related_plans:` cluster, which includes peer/parent runlists, so it's
|
|
771
|
+
// labelled `related` (not `plans`) to stay honest. Status shows only when it's
|
|
772
|
+
// not the expected `active` (e.g. a `partial` hub).
|
|
773
|
+
function renderCoordinationSection(coordDocs, coordination, maxWidth, total) {
|
|
774
|
+
process.stdout.write(`\n${bold(`Runlists (${total ?? coordDocs.length})`)}\n`);
|
|
775
|
+
const maxSlug = Math.min(34, Math.max(...coordDocs.map(d => hubLabel(d).length)));
|
|
776
|
+
for (const doc of coordDocs) {
|
|
777
|
+
const info = coordination.get(doc.path);
|
|
778
|
+
const slug = hubLabel(doc).padEnd(maxSlug);
|
|
779
|
+
const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '—';
|
|
780
|
+
const ageStr = doc.daysSinceUpdate != null && doc.isStale ? red(age.padStart(4)) : dim(age.padStart(4));
|
|
781
|
+
const count = info?.childCount ? `${String(info.childCount).padStart(2)} related` : ' ';
|
|
782
|
+
const statusTag = doc.status && doc.status !== 'active' ? ` ${colorTag(doc.status)}` : '';
|
|
783
|
+
const desc = (doc.nextStep || doc.currentState || doc.title || '').replace(/\s+/g, ' ').trim();
|
|
784
|
+
|
|
785
|
+
const left = ` ${slug} ${ageStr} ${dim(count)} `;
|
|
786
|
+
const budget = Math.max(10, maxWidth - visibleLen(left) - visibleLen(statusTag) - 2);
|
|
787
|
+
const descR = desc.length > budget ? desc.slice(0, budget - 3) + '...' : desc;
|
|
788
|
+
process.stdout.write(`${left}${dim(descR)}${statusTag}\n`);
|
|
530
789
|
}
|
|
531
790
|
}
|