runlist 0.0.0 → 0.94.1

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.
Files changed (166) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1114 -6
  3. package/assets/app/app.css +283 -0
  4. package/assets/app/app.mjs +381 -0
  5. package/assets/app/block-editor.mjs +182 -0
  6. package/assets/app/block-model.mjs +52 -0
  7. package/assets/app/document-create.mjs +86 -0
  8. package/assets/app/document-lifecycle.mjs +36 -0
  9. package/assets/app/document-stages.mjs +22 -0
  10. package/assets/app/document-views.mjs +33 -0
  11. package/assets/app/document-yardstick.mjs +258 -0
  12. package/assets/app/editor-navigation.mjs +51 -0
  13. package/assets/app/filing-navigation.mjs +138 -0
  14. package/assets/app/git-changes.mjs +80 -0
  15. package/assets/app/git-commit.mjs +46 -0
  16. package/assets/app/index.html +70 -0
  17. package/assets/app/library-navigation.mjs +93 -0
  18. package/assets/app/outline.mjs +18 -0
  19. package/assets/app/quick-navigation.mjs +52 -0
  20. package/assets/app/record-navigation.mjs +78 -0
  21. package/assets/app/recovery-center.mjs +53 -0
  22. package/assets/app/semantic-search.mjs +14 -0
  23. package/assets/app/settings.mjs +15 -0
  24. package/assets/app/shared.mjs +104 -0
  25. package/assets/app/stage-source.mjs +75 -0
  26. package/assets/app/template-editor.mjs +47 -0
  27. package/assets/app/theme.mjs +25 -0
  28. package/assets/app/transport.mjs +43 -0
  29. package/assets/contracts/source-editing-v1.md +232 -0
  30. package/assets/opencode/plugin.js +162 -0
  31. package/assets/schemas/runlist-record-v1.schema.json +912 -0
  32. package/bin/dotmd.mjs +5 -0
  33. package/bin/runlist.mjs +2714 -0
  34. package/package.json +53 -6
  35. package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
  36. package/plugins/runlist-codex/bin/runlist-hook +86 -0
  37. package/plugins/runlist-codex/hooks/hooks.json +16 -0
  38. package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
  39. package/runlist.config.example.mjs +370 -0
  40. package/scripts/postinstall.mjs +56 -0
  41. package/src/agent-context.mjs +201 -0
  42. package/src/ai.mjs +30 -0
  43. package/src/app-create.mjs +169 -0
  44. package/src/app-filing.mjs +139 -0
  45. package/src/app-git-commits.mjs +48 -0
  46. package/src/app-git.mjs +180 -0
  47. package/src/app-library.mjs +152 -0
  48. package/src/app-lifecycle-worker.mjs +12 -0
  49. package/src/app-lifecycle.mjs +79 -0
  50. package/src/app-records.mjs +134 -0
  51. package/src/app-search.mjs +80 -0
  52. package/src/app-semantic-search.mjs +105 -0
  53. package/src/app-service.mjs +208 -0
  54. package/src/app-yardstick.mjs +269 -0
  55. package/src/app.mjs +102 -0
  56. package/src/atomic-mutation.mjs +1736 -0
  57. package/src/baton.mjs +435 -0
  58. package/src/body-link.mjs +48 -0
  59. package/src/bulk-tag.mjs +174 -0
  60. package/src/check-collapse.mjs +75 -0
  61. package/src/claude-commands.mjs +75 -0
  62. package/src/cli-editor.mjs +8 -0
  63. package/src/code-refs.mjs +545 -0
  64. package/src/codex-integration.mjs +81 -0
  65. package/src/color.mjs +14 -0
  66. package/src/commands.mjs +374 -0
  67. package/src/completions.mjs +93 -0
  68. package/src/config-edit.mjs +606 -0
  69. package/src/config.mjs +705 -0
  70. package/src/decision-action.mjs +58 -0
  71. package/src/decision.mjs +220 -0
  72. package/src/decisions.mjs +787 -0
  73. package/src/deps.mjs +317 -0
  74. package/src/desktop-install.mjs +158 -0
  75. package/src/desktop-runtime.mjs +4 -0
  76. package/src/desktop.mjs +25 -0
  77. package/src/diff.mjs +101 -0
  78. package/src/doctor.mjs +591 -0
  79. package/src/durable-rename.mjs +55 -0
  80. package/src/errors-read.mjs +44 -0
  81. package/src/export.mjs +496 -0
  82. package/src/extractors.mjs +101 -0
  83. package/src/filing.mjs +255 -0
  84. package/src/fix-membership.mjs +134 -0
  85. package/src/fix-refs.mjs +174 -0
  86. package/src/flags.mjs +318 -0
  87. package/src/frontmatter-fix.mjs +195 -0
  88. package/src/frontmatter.mjs +252 -0
  89. package/src/git-commit-filters.mjs +32 -0
  90. package/src/git-commit-protocol.mjs +198 -0
  91. package/src/git-commit-signing.mjs +75 -0
  92. package/src/git-commit-store.mjs +26 -0
  93. package/src/git-commit-support.mjs +16 -0
  94. package/src/git-commit-worker.mjs +32 -0
  95. package/src/git.mjs +909 -0
  96. package/src/glossary-check.mjs +35 -0
  97. package/src/glossary.mjs +243 -0
  98. package/src/graph.mjs +298 -0
  99. package/src/guard.mjs +488 -0
  100. package/src/health.mjs +212 -0
  101. package/src/hints.mjs +129 -0
  102. package/src/host-integration.mjs +339 -0
  103. package/src/hub-membership.mjs +284 -0
  104. package/src/hub.mjs +292 -0
  105. package/src/hud.mjs +436 -0
  106. package/src/index-file.mjs +163 -0
  107. package/src/index.mjs +478 -0
  108. package/src/init.mjs +512 -0
  109. package/src/install.mjs +222 -0
  110. package/src/journal-read.mjs +116 -0
  111. package/src/journal.mjs +472 -0
  112. package/src/lifecycle.mjs +1444 -0
  113. package/src/lint.mjs +386 -0
  114. package/src/managed-path.mjs +192 -0
  115. package/src/markdown-code-spans.mjs +58 -0
  116. package/src/migrate-prompts.mjs +171 -0
  117. package/src/migrate-template.mjs +170 -0
  118. package/src/migrate.mjs +90 -0
  119. package/src/misuse-read.mjs +61 -0
  120. package/src/model-request.mjs +33 -0
  121. package/src/model.mjs +523 -0
  122. package/src/modules.mjs +355 -0
  123. package/src/naming.mjs +55 -0
  124. package/src/native-action.mjs +59 -0
  125. package/src/native-record.mjs +244 -0
  126. package/src/new.mjs +1321 -0
  127. package/src/output-identity.mjs +111 -0
  128. package/src/parent-plan.mjs +58 -0
  129. package/src/parse-cache.mjs +99 -0
  130. package/src/path-containment.mjs +15 -0
  131. package/src/pickup-card.mjs +293 -0
  132. package/src/pickup.mjs +671 -0
  133. package/src/prompt.mjs +25 -0
  134. package/src/prompts.mjs +585 -0
  135. package/src/query.mjs +977 -0
  136. package/src/record-action.mjs +11 -0
  137. package/src/record-create.mjs +82 -0
  138. package/src/reference-planner.mjs +388 -0
  139. package/src/reference-values.mjs +11 -0
  140. package/src/rename.mjs +107 -0
  141. package/src/render.mjs +678 -0
  142. package/src/roadmap.mjs +184 -0
  143. package/src/runlist.mjs +944 -0
  144. package/src/section.mjs +225 -0
  145. package/src/semantic-search-client.mjs +58 -0
  146. package/src/ship.mjs +166 -0
  147. package/src/show.mjs +101 -0
  148. package/src/skill-drift.mjs +98 -0
  149. package/src/source-editor.mjs +491 -0
  150. package/src/stages.mjs +73 -0
  151. package/src/state-migration.mjs +123 -0
  152. package/src/stats.mjs +200 -0
  153. package/src/status-metadata.mjs +87 -0
  154. package/src/statuses.mjs +721 -0
  155. package/src/summary.mjs +74 -0
  156. package/src/surfaces.mjs +28 -0
  157. package/src/sync-status.mjs +316 -0
  158. package/src/template-store.mjs +62 -0
  159. package/src/update.mjs +338 -0
  160. package/src/use.mjs +58 -0
  161. package/src/util.mjs +337 -0
  162. package/src/validate.mjs +794 -0
  163. package/src/watch.mjs +56 -0
  164. package/src/xref.mjs +406 -0
  165. package/src/yardstick-command.mjs +178 -0
  166. package/src/yardstick.mjs +112 -0
package/src/hud.mjs ADDED
@@ -0,0 +1,436 @@
1
+ import { flagsHudLine, openFlags } from './flags.mjs';
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { asString, currentSessionId, isArchivedPath, relTime, toRepoPath } from './util.mjs';
6
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
7
+ import { promptDirectory } from './new.mjs';
8
+ import { dim, yellow } from './color.mjs';
9
+ import { buildIndex } from './index.mjs';
10
+ import { readJournalEntries, journalFilePath, readMisuseEntries } from './journal.mjs';
11
+ import { compareVersions } from './update.mjs';
12
+ import { findOwnedPlan } from './baton.mjs';
13
+ import { listOwnedPlans, surveyOwnershipClaims } from './pickup.mjs';
14
+ import { actionablePromptStatuses, comparePromptDocs, resolveStatusMetadata } from './status-metadata.mjs';
15
+
16
+ export { actionablePromptStatuses } from './status-metadata.mjs';
17
+
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
20
+
21
+ // Detect when the running plugin's bundled version disagrees with this CLI's
22
+ // version. Since every release bumps both in lockstep, a mismatch means exactly
23
+ // one channel is behind. Network-free: the plugin's hook sets CLAUDE_PLUGIN_ROOT
24
+ // to the plugin dir, whose plugin.json carries its version. Gated to the
25
+ // version-keyed *cache* install — a directory-source plugin tracks content live
26
+ // and its version label lags benignly, so we don't nag local dev. Returns a
27
+ // one-line notice or null (silent when in sync). Surfaces to the agent because
28
+ // hud output is injected as SessionStart/SubagentStart context.
29
+ export function detectVersionDrift(env = process.env) {
30
+ try {
31
+ const root = env.CLAUDE_PLUGIN_ROOT;
32
+ if (!root) return null;
33
+ const cacheSeg = `${path.sep}plugins${path.sep}cache${path.sep}`;
34
+ if (!root.includes(cacheSeg)) return null;
35
+ const pj = path.join(root, '.claude-plugin', 'plugin.json');
36
+ if (!existsSync(pj)) return null;
37
+ const pluginVersion = JSON.parse(readFileSync(pj, 'utf8')).version;
38
+ const cmp = compareVersions(pluginVersion, pkg.version);
39
+ if (cmp === null || cmp === 0) return null;
40
+ if (cmp < 0) return `runlist plugin ${pluginVersion} is behind the CLI ${pkg.version} — run \`runlist update\` then restart.`;
41
+ return `runlist CLI ${pkg.version} is behind the plugin ${pluginVersion} — run \`runlist update\` (or npm i -g runlist).`;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ // Statuses that count as "actionable" for a prompt are derived from config:
48
+ // types.prompt.context.expanded (the statuses the user wants prominently shown).
49
+ // Falls back to ['pending'] when no prompt type is configured (defensive default
50
+ // for stripped-down configs). This means a user who customizes
51
+ // types.prompt.statuses to add e.g. `urgent: { context: 'expanded' }` gets that
52
+ // status surfaced too, without needing a code change.
53
+ // Returns repo paths, oldest-created first — the same order no-arg `runlist use`
54
+ // consumes them, so prompts[0] is always "the one you'd pick up next".
55
+ // The same answer from the prompt directory alone, reading frontmatter only.
56
+ // The full index read every body in the repo; at ~5k docs that took 10s and the
57
+ // SessionStart hook's 5s timeout killed hud before it printed anything.
58
+ export function findActionablePromptsInPromptDir(config) {
59
+ const actionable = actionablePromptStatuses(config);
60
+ const dir = promptDirectory(config);
61
+ if (!existsSync(dir)) return [];
62
+ const docs = [];
63
+ for (const entry of readdirSync(dir, { withFileTypes: true, recursive: true })) {
64
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
65
+ const abs = path.join(entry.parentPath ?? entry.path, entry.name);
66
+ const repoPath = toRepoPath(abs, config.repoRoot);
67
+ if (isArchivedPath(repoPath, config)) continue;
68
+ let fm;
69
+ try { fm = parseSimpleFrontmatter(extractFrontmatter(readFileSync(abs, 'utf8')).frontmatter ?? ''); }
70
+ catch { continue; }
71
+ const status = asString(fm.status);
72
+ if (asString(fm.type) !== 'prompt' || !actionable.has(status)) continue;
73
+ docs.push({ path: repoPath, status, created: asString(fm.created) || null, updated: asString(fm.updated) || null });
74
+ }
75
+ return docs.sort(comparePromptDocs).map(doc => doc.path);
76
+ }
77
+
78
+ // Text-mode hud: pending prompts and this session's plan, without the index.
79
+ export function buildHudFast(config) {
80
+ let prompts = [];
81
+ let owned = null;
82
+ try { prompts = findActionablePromptsInPromptDir(config); } catch { /* hud must not fail */ }
83
+ try {
84
+ const records = listOwnedPlans(config);
85
+ if (!records.diagnostics?.length && records.length === 1) owned = { path: records[0].plan, title: null, via: 'ownership' };
86
+ } catch { /* hud must not fail */ }
87
+ return { owned, prompts, misuseRecap: buildMisuseRecap(config), deadClaimsLine: deadClaimsHudLine(config) };
88
+ }
89
+
90
+ // A claim whose session is provably gone wedges `set`, `archive` and `baton` on
91
+ // that plan until someone releases it, and nothing at session start said so:
92
+ // three platform claims sat dead for up to 25 days, and the session that found
93
+ // them had to read `doctor --help` to learn the verb. Name them and the command.
94
+ export function deadClaimsHudLine(config, { top = 3 } = {}) {
95
+ let claims;
96
+ try { claims = surveyOwnershipClaims(config); } catch { return null; }
97
+ const dead = claims.filter(claim => !claim.corrupt && claim.liveness === 'dead');
98
+ if (!dead.length) return null;
99
+ const n = dead.length;
100
+ const shown = dead.slice(0, top).map(claim => claim.plan).join(', ');
101
+ const more = n > top ? `, +${n - top} more` : '';
102
+ return `[runlist] ${n} plan${n === 1 ? ' is' : 's are'} claimed by a session that is gone (${shown}${more}) — \`runlist doctor --claims --apply\` releases ${n === 1 ? 'it' : 'them'}; \`runlist doctor --claims\` lists every claim.`;
103
+ }
104
+
105
+ function findActionablePrompts(config, index) {
106
+ const actionable = actionablePromptStatuses(config);
107
+ return index.docs
108
+ .filter(doc => doc.type === 'prompt' && actionable.has(doc.status) && !isArchivedPath(doc.path, config))
109
+ .sort(comparePromptDocs)
110
+ .map(doc => doc.path);
111
+ }
112
+
113
+ // F17b: hud reads journal. Three additive sections, gated on
114
+ // existsSync(journalFilePath). Silent-when-clean — sections are omitted when
115
+ // they have nothing to say. Caps keep hud single-screen even when the journal
116
+ // is dense.
117
+
118
+ const PREVIOUS_SELF_CAP = 3;
119
+ const FLEET_CAP = 5;
120
+ const REJECTIONS_CAP = 3;
121
+ const FLEET_WINDOW_MS = 24 * 60 * 60 * 1000;
122
+ const REJECTIONS_WINDOW_MS = 60 * 60 * 1000;
123
+
124
+ // Coarse error-class for rejection grouping. Most runlist die() messages follow
125
+ // `<class>: <variable detail>` (e.g. "File not found: docs/foo.md", "Already
126
+ // archived: docs/plans/x.md", "Too many arguments to status"). Take the chunk
127
+ // before the first colon, cap at 6 words, normalize whitespace. Cheap;
128
+ // good-enough until a proper taxonomy emerges from real journal data.
129
+ function errorClass(err) {
130
+ if (typeof err !== 'string') return '';
131
+ const flat = err.replace(/\s+/g, ' ').trim();
132
+ if (!flat) return '';
133
+ const prefix = flat.split(':')[0];
134
+ return prefix.split(' ').slice(0, 6).join(' ');
135
+ }
136
+
137
+ export function buildJournalSections(config, now = Date.now()) {
138
+ const journalFile = journalFilePath(config);
139
+ if (!existsSync(journalFile)) return { previousSelf: [], fleet: [], recentRejections: [] };
140
+
141
+ let entries;
142
+ try { entries = readJournalEntries(config); }
143
+ catch { return { previousSelf: [], fleet: [], recentRejections: [] }; }
144
+ if (!entries.length) return { previousSelf: [], fleet: [], recentRejections: [] };
145
+
146
+ const sid = currentSessionId();
147
+
148
+ // 1. Previous self: this sid's last N entries (excluding the current
149
+ // invocation, which is recorded only at process exit so it isn't in the
150
+ // file yet). Newest-first.
151
+ const previousSelf = entries
152
+ .filter(e => e?.sid === sid)
153
+ .slice(-PREVIOUS_SELF_CAP)
154
+ .reverse()
155
+ .map(e => ({
156
+ argv: Array.isArray(e.argv) ? e.argv : [],
157
+ exit: e.exit ?? 0,
158
+ ts: e.ts,
159
+ ago: relTime(e.ts, now),
160
+ }));
161
+
162
+ // 2. Fleet: per-other-sid summary for entries in the last 24h.
163
+ const fleetCutoff = now - FLEET_WINDOW_MS;
164
+ const bySid = new Map();
165
+ for (const e of entries) {
166
+ if (!e?.sid || e.sid === sid) continue;
167
+ const t = new Date(e.ts).getTime();
168
+ if (!Number.isFinite(t) || t < fleetCutoff) continue;
169
+ if (!bySid.has(e.sid)) bySid.set(e.sid, { count: 0, lastTs: 0 });
170
+ const row = bySid.get(e.sid);
171
+ row.count++;
172
+ if (t > row.lastTs) row.lastTs = t;
173
+ }
174
+ const fleet = [...bySid.entries()].map(([otherSid, row]) => {
175
+ return {
176
+ sid: otherSid,
177
+ cmds: row.count,
178
+ lastAgo: relTime(new Date(row.lastTs).toISOString(), now),
179
+ };
180
+ }).sort((a, b) => b.cmds - a.cmds).slice(0, FLEET_CAP);
181
+
182
+ // 3. Recent rejections: top error-class groups for exit!=0 entries in the
183
+ // last hour. Group key = `${cmd} :: ${errClass}`.
184
+ const rejCutoff = now - REJECTIONS_WINDOW_MS;
185
+ const groups = new Map();
186
+ for (const e of entries) {
187
+ if ((e?.exit ?? 0) === 0) continue;
188
+ const t = new Date(e.ts).getTime();
189
+ if (!Number.isFinite(t) || t < rejCutoff) continue;
190
+ const cmd = e.argv?.[0] ?? '(none)';
191
+ const cls = errorClass(e.err);
192
+ if (!cls) continue;
193
+ const key = `${cmd} :: ${cls}`;
194
+ if (!groups.has(key)) groups.set(key, { cmd, cls, count: 0 });
195
+ groups.get(key).count++;
196
+ }
197
+ const recentRejections = [...groups.values()]
198
+ .sort((a, b) => b.count - a.count)
199
+ .slice(0, REJECTIONS_CAP);
200
+
201
+ return { previousSelf, fleet, recentRejections };
202
+ }
203
+
204
+ // Misuse recap: when sessions in THIS repo keep tripping the same guard rule,
205
+ // say so once at SessionStart — the shipped self-correcting-hints pattern
206
+ // pointed at repeat offenses. One line, only for the top rule, only past the
207
+ // threshold; silent otherwise.
208
+ const MISUSE_RECAP_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
209
+ const MISUSE_RECAP_THRESHOLD = 3;
210
+
211
+ const MISUSE_CORRECTIONS = {
212
+ 'edit-status': 'never hand-edit `status:`; use `runlist set <status> <file>`',
213
+ 'cat-prompt': 'consume prompts with `runlist use <file>`; peek without consuming via `runlist prompts show <file>`',
214
+ 'read-prompt': 'consume prompts with `runlist use <file>`; peek without consuming via `runlist prompts show <file>`',
215
+ 'commit-prompt': 'saved prompts are session-local; never git add/commit them',
216
+ };
217
+
218
+ export function buildMisuseRecap(config, now = Date.now()) {
219
+ let entries;
220
+ try { entries = readMisuseEntries(); } catch { return null; }
221
+ if (!entries.length) return null;
222
+ const cutoff = now - MISUSE_RECAP_WINDOW_MS;
223
+ const counts = new Map();
224
+ for (const e of entries) {
225
+ if (!e?.rule || (e.repo || '') !== config.repoRoot) continue;
226
+ const t = new Date(e.ts).getTime();
227
+ if (!Number.isFinite(t) || t < cutoff) continue;
228
+ counts.set(e.rule, (counts.get(e.rule) ?? 0) + 1);
229
+ }
230
+ const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
231
+ if (!top || top[1] < MISUSE_RECAP_THRESHOLD) return null;
232
+ const [rule, count] = top;
233
+ const fix = MISUSE_CORRECTIONS[rule] ?? 'see `runlist misuse`';
234
+ return `sessions here tripped ${rule} ${count}× this week — ${fix}`;
235
+ }
236
+
237
+ export function buildHud(config) {
238
+ let prompts = [];
239
+ const skippedValidationHooks = ['validate', 'transformDoc', 'formatSnapshot']
240
+ .filter(name => typeof config.hooks?.[name] === 'function');
241
+
242
+ // Validation error count — hud's "silent when clean" contract should treat
243
+ // `check` errors as not-clean. Without this, a SessionStart hook firing hud
244
+ // can leave the agent with no visible signal that a check is failing.
245
+ // `errorsOnly: true` skips warning-only cross-doc passes (git staleness,
246
+ // bidirectional refs, claude-commands) that hud never reads. Built-in
247
+ // per-file validation + checkIndex still run; user hooks are deliberately
248
+ // suppressed because SessionStart is passive.
249
+ let builtInErrors = 0;
250
+ // `owned` answers "which plan is THIS session's?" for programmatic callers
251
+ // (the baton flow reads it) — derived only from a valid durable ownership
252
+ // record. Null when ownership is absent, stale, corrupt, or ambiguous.
253
+ let owned = null;
254
+ try {
255
+ const index = buildIndex(config, {
256
+ errorsOnly: true,
257
+ autoHealIndex: false,
258
+ invokeHooks: false,
259
+ });
260
+ prompts = findActionablePrompts(config, index);
261
+ builtInErrors = index.errors.length;
262
+ const o = findOwnedPlan(config, index);
263
+ if (o.plan) owned = { path: o.plan.path, title: o.plan.title ?? null, via: o.via };
264
+ } catch { /* swallow — bad config shouldn't break the SessionStart hook */ }
265
+
266
+ const { previousSelf, fleet, recentRejections } = buildJournalSections(config);
267
+ const misuseRecap = buildMisuseRecap(config);
268
+
269
+ const validationComplete = skippedValidationHooks.length === 0;
270
+ return {
271
+ owned,
272
+ prompts,
273
+ errors: validationComplete ? builtInErrors : null,
274
+ ...(validationComplete ? {} : {
275
+ builtInErrors,
276
+ validationPreview: { status: 'built-in-only', skippedHooks: skippedValidationHooks },
277
+ }),
278
+ previousSelf,
279
+ fleet,
280
+ recentRejections,
281
+ misuseRecap,
282
+ deadClaims: (() => { try { return surveyOwnershipClaims(config).filter(c => !c.corrupt && c.liveness === 'dead').map(c => ({ plan: c.plan, sessionId: c.sessionId, since: c.since })); } catch { return []; } })(),
283
+ flags: (() => { try { return openFlags(config).map(f => ({ id: f.id, severity: f.severity, file: f.file, line: f.line, text: f.text })); } catch { return []; } })(),
284
+ };
285
+ }
286
+
287
+ // Subagent primer: a spawned subagent (Explore, Plan, general-purpose) starts
288
+ // with ZERO project context and no SessionStart history — it has never seen the
289
+ // command sheet the top-level session got. Without this, subagents reflexively
290
+ // grep/cat/commit managed docs instead of using runlist. Keep it to a few dense
291
+ // lines: the verbs + the three wrong-moves the guard exists to stop, so the
292
+ // subagent self-corrects before the guard ever has to fire.
293
+ const SUBAGENT_PRIMER = [
294
+ 'runlist manages this repo\'s plans/docs/prompts (markdown + YAML frontmatter).',
295
+ 'Verbs: plans|briefing | query <filters> | use [<file>] | set <status> <file> | new <type> <slug> | archive <file>.',
296
+ 'Claims: `runlist doctor --claims` lists which session holds each plan; `--apply` releases the ones whose session is gone.',
297
+ 'Do NOT: cat/read a docs/prompts/*.md (use `runlist use <file>` — archive/claim commits before at-most-once output);',
298
+ 'git add/commit a prompt (they are session-local, often gitignored); hand-edit a `status:` field (use `runlist set`).',
299
+ ].join('\n');
300
+
301
+ export function buildPlanStatusPrimer(config, { maxChars = 220 } = {}) {
302
+ const statuses = (resolveStatusMetadata(config).byType.plan ?? []).map(item => item.name);
303
+ const fallback = 'run `runlist statuses list --type plan`';
304
+ if (statuses.length === 0) return `Plan statuses unavailable; ${fallback}.`;
305
+ const prefix = 'Plan statuses: ';
306
+ const full = `${prefix}${statuses.join(', ')}`;
307
+ if (full.length <= maxChars) return full;
308
+
309
+ const shown = [];
310
+ for (const status of statuses) {
311
+ const omitted = statuses.length - shown.length - 1;
312
+ const candidate = `${prefix}${[...shown, status].join(', ')}, ... (+${omitted}; ${fallback})`;
313
+ if (candidate.length > maxChars) break;
314
+ shown.push(status);
315
+ }
316
+ const omitted = statuses.length - shown.length;
317
+ return `${prefix}${shown.join(', ')}${shown.length ? ', ' : ''}... (+${omitted}; ${fallback})`;
318
+ }
319
+
320
+ // The plugin's SessionStart/SubagentStart hooks fire in EVERY repo (it's enabled
321
+ // globally), but the primer only helps where runlist is actually used. Gate on a
322
+ // discovered config: `runlist init` writes runlist.config.mjs, so "has a config" is
323
+ // the zero-false-positive signal for "this is a runlist repo." A bare docs/ dir is
324
+ // deliberately NOT enough — too many repos have one. In a non-runlist repo the hook
325
+ // then contributes nothing to the session: no primer, no index build, no heal.
326
+ // UserPromptSubmit: when the user asks for a baton, tell the session the exact
327
+ // form for its situation before it goes looking. Sessions ran `baton --help`
328
+ // before nearly every baton even with the form in CLAUDE.md, because the right
329
+ // form depends on whether this session owns a plan, which only runlist knows.
330
+ 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;
331
+
332
+ // The harness hands background-task events to UserPromptSubmit as if typed.
333
+ // A finished agent's report that names its handoff file is not a request.
334
+ const SYSTEM_EVENT = /<task-notification>|\[SYSTEM NOTIFICATION\b/;
335
+
336
+ export function isHandoffAsk(prompt) {
337
+ return typeof prompt === 'string' && !SYSTEM_EVENT.test(prompt) && HANDOFF_ASK.test(prompt);
338
+ }
339
+
340
+ export function buildHandoffContext(config) {
341
+ // Ownership records name their plans and are checked against those files
342
+ // alone; building the index here cost 9s in a repo of ~5k docs, past the
343
+ // hook's timeout.
344
+ let ownedPaths = [];
345
+ try {
346
+ const records = listOwnedPlans(config);
347
+ if (!records.diagnostics?.length) ownedPaths = records.map(record => record.plan);
348
+ } catch { /* fall through to the no-plan form */ }
349
+ 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.';
350
+ const tail = 'Baton prints the prompt name it saved; tell the user that name. No `--help` needed.';
351
+ if (ownedPaths.length === 1) {
352
+ 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}`;
353
+ }
354
+ if (ownedPaths.length > 1) {
355
+ 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}`;
356
+ }
357
+ 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}`;
358
+ }
359
+
360
+ export async function runPromptSubmitHud(config, { readStdin } = {}) {
361
+ if (!isRunlistRepo(config)) return;
362
+ let prompt = '';
363
+ try {
364
+ const raw = await readStdin();
365
+ if (raw && raw.trim()) prompt = JSON.parse(raw).prompt ?? '';
366
+ } catch { return; }
367
+ if (!isHandoffAsk(prompt)) return;
368
+ process.stdout.write(buildHandoffContext(config) + '\n');
369
+ }
370
+
371
+ function isRunlistRepo(config) {
372
+ return Boolean(config?.configFound);
373
+ }
374
+
375
+ export function runHud(argv, config) {
376
+ const json = argv.includes('--json');
377
+
378
+ const drift = detectVersionDrift();
379
+ const runlistRepo = isRunlistRepo(config);
380
+
381
+ // SubagentStart hook entry point — emit the compact primer and return. No
382
+ // index build, no journal read, no slash-command heal: a subagent doesn't
383
+ // need the operator-facing machinery, just the verbs and the guardrails.
384
+ if (argv.includes('--subagent')) {
385
+ if (!runlistRepo) return; // silent in repos that don't use runlist
386
+ process.stdout.write(dim(SUBAGENT_PRIMER) + '\n');
387
+ process.stdout.write(dim(buildPlanStatusPrimer(config)) + '\n');
388
+ if (drift) process.stdout.write(yellow(drift) + '\n');
389
+ return;
390
+ }
391
+
392
+ // Non-runlist repo, and not a programmatic --json caller → contribute nothing to
393
+ // the session. Skip the index build, slash-heal, primer, and drift line.
394
+ if (!runlistRepo && !json) return;
395
+
396
+ if (json) {
397
+ const hud = buildHud(config);
398
+ process.stdout.write(JSON.stringify({ ...hud, drift: drift ?? null }, null, 2) + '\n');
399
+ return;
400
+ }
401
+
402
+ const hud = buildHudFast(config);
403
+
404
+ // SessionStart contract: the command primer, plus ONLY signals that carry a
405
+ // direct instruction for this session. Passive state (error counts,
406
+ // slash-command refresh notices, previous-self / fleet / recent-rejections)
407
+ // stays suppressed — those nudged agents into phantom follow-up work (e.g.
408
+ // "errors: 1" prompting a check run) and live in their proper commands and
409
+ // `runlist hud --json`. Two signals ARE instructions and must print, because
410
+ // the handoff loop dies without them (sessions were saving batons that no
411
+ // next session ever picked up):
412
+ // - pending prompts: the previous session queued work for THIS one;
413
+ // consuming it is the very next action.
414
+ // - an in-session plan attributed to this sid via durable ownership: this
415
+ // session (pre-compaction) owns it and should continue or hand it off.
416
+ // Global in-session counts never provide a fallback.
417
+ // The misuse recap stays for the same reason: a repeat-offense rule means
418
+ // the primer alone isn't landing, so name the habit to break.
419
+ 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');
420
+ process.stdout.write(dim(buildPlanStatusPrimer(config)) + '\n');
421
+ if (hud.owned && hud.owned.via === 'ownership') {
422
+ process.stdout.write(yellow(`[runlist] in-session (yours): ${hud.owned.path} — continue it; hand off with \`runlist baton @/tmp/draft.md\` before stopping.`) + '\n');
423
+ }
424
+ if (hud.prompts.length > 0) {
425
+ const n = hud.prompts.length;
426
+ process.stdout.write(yellow(`[runlist] ${n} pending prompt${n === 1 ? '' : 's'} queued for this session — unless the user asks for something else, start by running \`runlist use\` to consume the oldest (${hud.prompts[0]}) and act on it. Peek first: \`runlist prompts show <file>\`; list: \`runlist prompts\`.`) + '\n');
427
+ }
428
+ if (hud.misuseRecap) process.stdout.write(yellow(`[runlist] ${hud.misuseRecap}`) + '\n');
429
+ if (hud.deadClaimsLine) process.stdout.write(yellow(hud.deadClaimsLine) + '\n');
430
+ // Open flags are the one piece of passive state printed here: the person
431
+ // asked that every session start knowing where things stand. It is worded
432
+ // as awareness, not an instruction, so no session treats it as its task.
433
+ const flagsLine = flagsHudLine(config);
434
+ if (flagsLine) process.stdout.write(yellow(flagsLine) + '\n');
435
+ if (drift) process.stdout.write(yellow(drift) + '\n');
436
+ }
@@ -0,0 +1,163 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { capitalize, escapeTable } from './util.mjs';
4
+ import { formatSnapshot } from './render.mjs';
5
+ import { authorizeRepoGeneratedPath } from './managed-path.mjs';
6
+ import { mutateFile } from './atomic-mutation.mjs';
7
+
8
+ function indexMarkers(current, config) {
9
+ const start = config.indexStartMarker;
10
+ const end = config.indexEndMarker;
11
+ if (current.includes(start) && current.includes(end)) return { start, end };
12
+ // A config that omitted markers may point at an index from an older release.
13
+ // Keep updating that block in place; newly initialized indexes use runlist.
14
+ if (start === '<!-- GENERATED:runlist:start -->' && end === '<!-- GENERATED:runlist:end -->'
15
+ && current.includes('<!-- GENERATED:dotmd:start -->') && current.includes('<!-- GENERATED:dotmd:end -->')) {
16
+ return { start: '<!-- GENERATED:dotmd:start -->', end: '<!-- GENERATED:dotmd:end -->' };
17
+ }
18
+ return { start, end };
19
+ }
20
+
21
+ export function renderIndexFile(index, config, current = readFileSync(config.indexPath, 'utf8')) {
22
+ const markers = indexMarkers(current, config);
23
+ const start = current.indexOf(markers.start);
24
+ const end = current.indexOf(markers.end);
25
+
26
+ if (start === -1 || end === -1 || end < start) {
27
+ throw new Error(`${config.indexPath} is missing generated block markers.`);
28
+ }
29
+
30
+ const before = current.slice(0, start + markers.start.length);
31
+ const after = current.slice(end);
32
+ const generated = `\n\n${renderGeneratedBlock(index, config)}\n`;
33
+ return `${before}${generated}${after}`;
34
+ }
35
+
36
+ function renderGeneratedBlock(index, config) {
37
+ const lines = [];
38
+ const indexDir = config.indexPath ? path.dirname(path.relative(config.repoRoot, config.indexPath)).split(path.sep).join('/') : '';
39
+ const snapshotMode = config.indexSnapshot ?? 'status';
40
+
41
+ for (const status of config.statusOrder) {
42
+ const docs = index.docs.filter(doc => doc.status === status);
43
+ if (docs.length === 0) continue;
44
+
45
+ if (config.lifecycle.archiveStatuses.has(status)) {
46
+ lines.push(...renderArchivedSection(docs, config, status));
47
+ lines.push('');
48
+ continue;
49
+ }
50
+
51
+ lines.push(`## ${capitalize(status)}`);
52
+ lines.push('');
53
+ lines.push(...snapshotHeader(snapshotMode));
54
+ for (const doc of docs) {
55
+ const snapshot = renderIndexSnapshot(doc, config, snapshotMode);
56
+ const linkPath = indexDir ? path.relative(indexDir, doc.path).split(path.sep).join('/') : doc.path;
57
+ lines.push(`| [${escapeTable(doc.title)}](${linkPath}) | ${escapeTable(snapshot)} |`);
58
+ }
59
+ lines.push('');
60
+ }
61
+
62
+ return lines.join('\n').trimEnd();
63
+ }
64
+
65
+ function renderArchivedSection(docs, config, status) {
66
+ const lines = [];
67
+ const limit = config.archivedHighlightLimit;
68
+ const indexDir = config.indexPath ? path.dirname(path.relative(config.repoRoot, config.indexPath)).split(path.sep).join('/') : '';
69
+ const highlights = docs
70
+ .filter(doc => doc.currentState && doc.currentState !== 'No current_state set')
71
+ .sort((a, b) => {
72
+ const aUpdated = a.updated ?? '';
73
+ const bUpdated = b.updated ?? '';
74
+ return bUpdated.localeCompare(aUpdated);
75
+ })
76
+ .slice(0, limit);
77
+
78
+ lines.push(`## ${capitalize(status)}`);
79
+ lines.push('');
80
+ lines.push(`${capitalize(status)} docs are indexed by the CLI/JSON output. Showing ${highlights.length} recent or high-signal highlights out of ${docs.length} ${status} docs:`);
81
+ lines.push('');
82
+ lines.push('| Doc | Status Snapshot |');
83
+ lines.push('|-----|-----------------|');
84
+ for (const doc of highlights) {
85
+ const linkPath = indexDir ? path.relative(indexDir, doc.path).split(path.sep).join('/') : doc.path;
86
+ lines.push(`| [${escapeTable(doc.title)}](${linkPath}) | ${escapeTable(formatSnapshot(doc, config))} |`);
87
+ }
88
+ lines.push('');
89
+ lines.push('- Use `runlist list` or `runlist json` for the full inventory.');
90
+
91
+ return lines;
92
+ }
93
+
94
+ function snapshotHeader(snapshotMode) {
95
+ if (snapshotMode === 'state') return ['| Doc | Status Snapshot |', '|-----|-----------------|'];
96
+ return ['| Doc | Status |', '|-----|--------|'];
97
+ }
98
+
99
+ function renderIndexSnapshot(doc, config, snapshotMode) {
100
+ if (snapshotMode === 'state') return formatSnapshot(doc, config);
101
+ return capitalize(doc.status ?? 'unknown');
102
+ }
103
+
104
+ export function writeRenderedIndex(indexOrFactory, config, options = {}) {
105
+ authorizeRepoGeneratedPath(config.indexPath, config, { kind: 'Generated index destination' });
106
+ mutateFile(config.indexPath, { repoRoot: config.repoRoot, ...options }, current => {
107
+ const index = typeof indexOrFactory === 'function' ? indexOrFactory() : indexOrFactory;
108
+ return renderIndexFile(index, config, current);
109
+ });
110
+ }
111
+
112
+ // `autoHeal: true` rewrites the index in place when drift is detected and
113
+ // downgrades the result to a warning ("Auto-regenerated stale index block").
114
+ // Drift happens when frontmatter changes (status/title/current_state/module)
115
+ // arrive via paths that don't call `regenIndex` — direct file edits, `lint
116
+ // --fix`, `frontmatter-fix`, `bulk-tag`, etc. The README block is fully
117
+ // generated content; treating drift as an error forced the user to run
118
+ // `runlist index` themselves every session. Callers inside `buildIndex` pass
119
+ // `autoHeal: true` because the docs there are always the canonical full set
120
+ // (filtering happens later in the CLI dispatcher). Direct callers with a
121
+ // filtered/synthetic docs list omit it to keep the old error semantics —
122
+ // auto-overwriting from a partial doc list would clobber valid content.
123
+ export function checkIndex(docs, config, opts = {}) {
124
+ const { autoHeal = false, rebuildDocs = null, testHooks = null } = opts;
125
+ const warnings = [];
126
+ const errors = [];
127
+
128
+ if (!config.indexPath) return { warnings, errors };
129
+
130
+ if (autoHeal) {
131
+ try {
132
+ authorizeRepoGeneratedPath(config.indexPath, config, { kind: 'Generated index destination' });
133
+ const result = mutateFile(config.indexPath, { repoRoot: config.repoRoot, testHooks }, currentContent => {
134
+ const currentDocs = rebuildDocs ? rebuildDocs() : docs;
135
+ return renderIndexFile({ docs: currentDocs }, config, currentContent);
136
+ });
137
+ if (result.changed) {
138
+ warnings.push({ path: config.indexPath, level: 'warning', message: 'Auto-regenerated stale index block.' });
139
+ }
140
+ } catch (err) {
141
+ errors.push({ path: config.indexPath, level: 'error', message: `Could not auto-regenerate stale index block: ${err.message}` });
142
+ }
143
+ return { warnings, errors };
144
+ }
145
+
146
+ const current = readFileSync(config.indexPath, 'utf8');
147
+ const markers = indexMarkers(current, config);
148
+ const start = current.indexOf(markers.start);
149
+ const end = current.indexOf(markers.end);
150
+
151
+ if (start === -1 || end === -1 || end < start) {
152
+ errors.push({ path: config.indexPath, level: 'error', message: 'Missing generated index block markers.' });
153
+ return { warnings, errors };
154
+ }
155
+
156
+ const index = { docs };
157
+ const expected = renderIndexFile(index, config);
158
+ if (expected !== current) {
159
+ errors.push({ path: config.indexPath, level: 'error', message: 'Generated index block is stale. Run `runlist index`.' });
160
+ }
161
+
162
+ return { warnings, errors };
163
+ }