runlist 0.96.1 → 0.97.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -71,8 +71,8 @@ controls are experimental; live retrieval depends on a compatible external
71
71
  provider. Local chat and push are deferred.
72
72
 
73
73
  <!-- runlist:release:start -->
74
- CLI and desktop release version: **0.96.1**.
75
- [Mac installer and release notes](https://github.com/reowens/runlist/releases/tag/desktop-v0.96.1).
74
+ CLI and desktop release version: **0.97.0**.
75
+ [Mac installer and release notes](https://github.com/reowens/runlist/releases/tag/desktop-v0.97.0).
76
76
  Normal product releases publish both components; an installed Mac app is updated with rollback after it has quit.
77
77
  <!-- runlist:release:end -->
78
78
 
@@ -105,15 +105,14 @@ Host source, cache and registry copies are labelled separately; a cache entry
105
105
  alone does not prove which copy a running host has loaded. No package manager or
106
106
  host command runs to produce the `doctor --project` installation report.
107
107
 
108
- The local desktop snapshot passed packaged and installed checks, with
109
- its previous app retained for rollback. Open the installed app with `rl desktop`;
110
- native save/reset and status/archive journeys also passed a bounded review, with
111
- accessibility/confirmation findings recorded in
112
- [desktop validation](desktop/VALIDATION.md#native-workflow-review-2026-10-20).
113
- The installed review app includes the failed-status-preview confirmation fix.
114
- Native review verified disabled confirmation for a failed or missing review, then
115
- a successful pause at the original path. Dialog accessibility remains unresolved;
116
- see the [current investigation](desktop/VALIDATION.md#confirmation-fix-and-native-investigation-2026-10-20).
108
+ **0.96.1 is published and installed locally**, including the desktop usability,
109
+ draft recovery, handoff and failed-status-preview confirmation fixes. The Mac
110
+ installer is signed, notarized and verified; installation retained the previous
111
+ app for rollback. Open it with `rl desktop`. See the
112
+ [release validation](desktop/VALIDATION.md#current-mac-release--2026-10-21)
113
+ for checks and evidence. Full-app dialog accessibility remains an open finding;
114
+ fresh/minimum-host and controlled whole-app performance qualification remain
115
+ follow-up work.
117
116
 
118
117
  Verify an installed Mac app without opening it:
119
118
 
@@ -134,33 +133,22 @@ record completed results in durable plans before explicitly archiving the
134
133
  selected prompts. Historical nested-archive origin recovery stays deferred until
135
134
  a concrete case needs it.
136
135
 
137
- Deferred work awaiting selection:
138
-
139
- 1. Expand local-commit qualification to process filters, broader clean-filter
140
- command profiles, Windows, Linux x64/other Git versions and additional signing profiles.
141
- Configured commit hooks and noninteractive SSH/OpenPGP signing are supported
142
- in current source on the qualified macOS/Linux combinations. Push is deferred.
143
- 2. Optional grepmax live retrieval/coverage qualification is parked: the owner
144
- reports a watcher issue requiring a reboot. The last check saw gmax 0.26.65
145
- ready, but its installed bridge/daemon lacked Runlist's required document-search
146
- contract. Watcher recovery and compatibility must be verified before live
147
- qualification resumes; preserve the existing safety hold. See the
148
- current status and resume gates.
149
- The compatibility plan
150
- now has steps 1–4 passing against gmax 0.26.66 source and an extracted local
151
- npm candidate, including the actual private helper. Deployment and live
152
- checks remain separate gates.
153
- Large-checkout inventory,
154
- unavailable-state memory/latency and bridge cleanup have terminal evidence.
155
- The Runlist GUI/backend adapter and guarded protocol fixtures are included
156
- in GUI 0.94.0-rc.1. External provider deployment and successful live retrieval
157
- remain unverified. Optional local model chat is deferred.
158
- 3. Complete native plan lifecycle, legacy migration,
159
- settings/diagnostics. GUI yardstick and Filing reports are delivered in
160
- 0.94.0-rc.1. Stage filters/groups and guarded Stage editing are also delivered.
161
- 4. Finish platform qualification: a terminal-ready Windows host, Linux x64 and
162
- older distributions, real-version upgrades, fresh/minimum-version and Intel
163
- Macs, plus native rendering, focus, accessibility and whole-window memory.
136
+ Remaining product work:
137
+
138
+ 1. Resolve full-app dialog accessibility and extend native offline, fresh/minimum-Mac
139
+ installation/upgrade/recovery and whole-app performance qualification.
140
+ 2. Finish the broader native-plan, identity/migration, history/verification,
141
+ scoped-relationship and advanced settings/diagnostics contracts. Reviewed
142
+ legacy plan status/archive operations already ship.
143
+ 3. Qualify additional local-commit filter, hook, signing and platform profiles.
144
+ Push remains deferred.
145
+ 4. Deploy and qualify an exact optional grepmax provider against an existing
146
+ index, after the separately recorded watcher/resource hold is resolved.
147
+ Compatibility fixtures pass; successful live retrieval remains unverified.
148
+ Local model chat remains deferred.
149
+ 5. Complete post-release agent-workflow measurement and assess the installed
150
+ OpenCode prompt-hook API. A Rust engine migration remains planned pending
151
+ performance evidence.
164
152
 
165
153
  The current Mac installer is linked in the release-version section above.
166
154
  CLI and desktop include the optional product yardstick.
package/bin/runlist.mjs CHANGED
@@ -285,7 +285,7 @@ Analyze:
285
285
  record <file> <action> --note <reason> Act on a native flag or decision
286
286
  decision <file> <id> --disposition <state> --note <reason> Record an outcome
287
287
  decisions [doc|id] [--all|--check] Open and held decisions, read from the corpus
288
- show <file...> [--json] One document's card: status, next step, related plans and docs, links
288
+ show <file...> [--section <h>] One document's card, outline and open boxes with line numbers; one section
289
289
  xref <file...> | --check [--flag] Cross-references: what names it, its decisions, its cited code
290
290
 
291
291
  Validate & Fix:
@@ -1571,18 +1571,32 @@ remain owned by their CLI sessions; coordinate/release them before a human save.
1571
1571
  Metadata and lifecycle changes continue through the CLI. Ctrl+C stops the app.
1572
1572
  --dry-run previews startup without opening a server or writing editor state.
1573
1573
  `,
1574
- show: `runlist show <file...> — one document's card, read-only
1575
-
1576
- runlist show <file> title, status, next step, blockers, checklist,
1577
- related plans and docs, documents linked from the body
1578
- runlist show <a> <b> --json [{ path, type, title, status, summary, currentState,
1579
- nextStep, blockers, checklist, updated,
1580
- related: [{ field, ref, path, exists, title, status, type }],
1581
- links: [{ path, title, status, type }] }]
1582
-
1583
- A file is a path, filename or slug, as for \`use\`. Nothing is claimed or
1584
- written. With --json a file that is not found is { path, error } rather than
1585
- an exit.`,
1574
+ show: `runlist show <file...> — a document in a few KB, read-only
1575
+
1576
+ runlist show <file> card (title, status, next step, blockers, related
1577
+ plans and docs, body links), then the outline and
1578
+ open boxes with line numbers, then the other
1579
+ frontmatter fields by name
1580
+ runlist show <file> --section "<heading>"
1581
+ that section, every line numbered, to the next
1582
+ heading at the same or a higher level
1583
+ --from <line> continue a section read that was cut
1584
+ --all every open box, not the first 30
1585
+ --card the card only
1586
+ --frontmatter the frontmatter as written, long values collapsed
1587
+ --json the same as data; a section read is
1588
+ { path, query, heading, from, to, next, rows, others }
1589
+
1590
+ Read a plan or long doc this way, then one section at a time, never whole. A
1591
+ heading matches case-insensitively: exact first, then as a prefix, then as a
1592
+ substring; other matches are named. A section read stops at 500 lines or
1593
+ 60 KB and prints the command that continues it. Headings and boxes inside code
1594
+ fences are ignored. Several files at once get a shorter outline each.
1595
+
1596
+ A file is a path, filename or slug, as for \`use\`, and any Markdown file in
1597
+ the repo works. Nothing is claimed or written. With --json a file that is not
1598
+ found is { path, error } rather than an exit; a heading that matches nothing
1599
+ exits 1 and lists the headings.`,
1586
1600
 
1587
1601
  model: `runlist model — the local model behind summaries and lint
1588
1602
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runlist",
3
- "version": "0.96.1",
3
+ "version": "0.97.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runlist-codex",
3
- "version": "0.96.1",
3
+ "version": "0.97.0",
4
4
  "description": "Runlist workflow skill, session orientation, and tool guard for Codex",
5
5
  "author": {
6
6
  "name": "Reo Owens"
package/src/baton.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, existsSync, realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, warn, isArchivedPath, resolveRefPath, nowIso } from './util.mjs';
4
+ import { asString, toRepoPath, die, warn, isArchivedPath, resolveRefPath, nowIso, shellQuote } from './util.mjs';
5
5
  import { buildIndex, resolveDocArg } from './index.mjs';
6
6
  import { preparePromptDocument, runNew, readBodyInput, readPipedBodyInput, slugify } from './new.mjs';
7
7
  import { ensurePlanCompletionBeforeRelease, planHasPendingCompletion, renderLifecycleMutation, runSet } from './lifecycle.mjs';
@@ -82,11 +82,6 @@ function archivedCopyPath(promptPath, config) {
82
82
  return authorizeManagedDestination(candidate, config, { kind: 'Baton replacement archive' }).path;
83
83
  }
84
84
 
85
- function shellQuote(value) {
86
- const raw = String(value);
87
- return /^[A-Za-z0-9_./:@+-]+$/.test(raw) ? raw : `"${raw.replace(/["\\$`]/g, '\\$&')}"`;
88
- }
89
-
90
85
  function commitCommand(config, message, paths) {
91
86
  const fallback = ['git', 'commit', '-m', message, '--', ...paths];
92
87
  let argv = fallback;
package/src/brief.mjs ADDED
@@ -0,0 +1,145 @@
1
+ import { extractFrontmatter, normalizeEol } from './frontmatter.mjs';
2
+ import { walkSections, matchSections, walkChecklist } from './section.mjs';
3
+
4
+ // A document in a few KB, for an agent to read in place of the whole file: a
5
+ // heading outline and the open checklist boxes, both with file line numbers,
6
+ // and one section on request. Pure: callers read the file and render.
7
+
8
+ export const LIMITS = {
9
+ outline: 60, // headings shown; the deepest levels drop first
10
+ boxes: 30, // open boxes shown without --all
11
+ width: 140, // a heading, box or frontmatter line is cut to this
12
+ listItems: 8, // a frontmatter list longer than this is collapsed
13
+ textLines: 4, // and a multi-line frontmatter value cut to this many lines
14
+ sectionLines: 500, // lines of one section read
15
+ sectionBytes: 60 * 1024,
16
+ };
17
+
18
+ // Several files at once is a survey; each one gets less.
19
+ export const SURVEY_LIMITS = { ...LIMITS, outline: 20, boxes: 10 };
20
+
21
+ export const cut = (text, width = LIMITS.width) =>
22
+ text.length > width ? `${text.slice(0, width - 3)}...` : text;
23
+
24
+ // The file split into lines, without the empty one a final newline leaves.
25
+ function fileLines(text) {
26
+ const lines = text.split('\n');
27
+ if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
28
+ return lines;
29
+ }
30
+
31
+ // Headings with file coordinates: `line` is the heading, `end` the last line of
32
+ // its section (the line before the next heading at the same or a higher level).
33
+ function headingsOf(body, bodyLineOffset, lastLine) {
34
+ return walkSections(body).map(s => ({
35
+ line: s.lineStart + bodyLineOffset,
36
+ end: Math.min(s.lineEnd + bodyLineOffset, lastLine),
37
+ level: s.level,
38
+ heading: s.heading,
39
+ }));
40
+ }
41
+
42
+ // Drop the deepest heading levels until the outline fits, then cut the tail.
43
+ function boundOutline(headings, cap) {
44
+ let shown = headings;
45
+ while (shown.length > cap) {
46
+ const deepest = Math.max(...shown.map(h => h.level));
47
+ if (deepest <= 1) break;
48
+ shown = shown.filter(h => h.level < deepest);
49
+ }
50
+ shown = shown.slice(0, cap);
51
+ return { shown, omitted: headings.length - shown.length };
52
+ }
53
+
54
+ // Frontmatter as written, with long values cut and long lists collapsed.
55
+ export function collapseFrontmatter(frontmatter, limits = LIMITS) {
56
+ if (!frontmatter) return [];
57
+ const out = [];
58
+ let listRun = 0;
59
+ let textRun = 0;
60
+ const flush = () => {
61
+ if (listRun > limits.listItems) out.push(` (+${listRun - limits.listItems} more)`);
62
+ if (textRun > limits.textLines) out.push(` (+${textRun - limits.textLines} more lines)`);
63
+ listRun = 0;
64
+ textRun = 0;
65
+ };
66
+ for (const line of frontmatter.split('\n')) {
67
+ if (/^\s+-\s/.test(line)) {
68
+ if (++listRun <= limits.listItems) out.push(cut(line, limits.width));
69
+ continue;
70
+ }
71
+ if (/^\s+\S/.test(line)) {
72
+ if (++textRun <= limits.textLines) out.push(cut(line, limits.width));
73
+ continue;
74
+ }
75
+ flush();
76
+ out.push(cut(line, limits.width));
77
+ }
78
+ flush();
79
+ return out;
80
+ }
81
+
82
+ /**
83
+ * The brief of one document's raw text: its size, a bounded outline and the
84
+ * open boxes. `all` lists every open box. Counts are of what sits outside code
85
+ * fences, so a template's example checklist is not reported as work.
86
+ */
87
+ export function buildBrief(raw, { all = false, limits = LIMITS } = {}) {
88
+ const text = normalizeEol(raw);
89
+ const { frontmatter, body, bodyLineOffset } = extractFrontmatter(text);
90
+ const lines = fileLines(text);
91
+ const headings = headingsOf(body, bodyLineOffset, lines.length);
92
+ const { shown, omitted } = boundOutline(headings, limits.outline);
93
+ const boxes = walkChecklist(body).map(b => ({ ...b, line: b.line + bodyLineOffset }));
94
+ const open = boxes.filter(b => b.open).map(({ line, text: t }) => ({ line, text: t }));
95
+ const listed = all ? open : open.slice(0, limits.boxes);
96
+ return {
97
+ lines: lines.length,
98
+ bytes: Buffer.byteLength(text),
99
+ frontmatterLines: frontmatter ? bodyLineOffset : 0,
100
+ outline: shown,
101
+ outlineOmitted: omitted,
102
+ boxes: { open: open.length, done: boxes.length - open.length },
103
+ open: listed,
104
+ openOmitted: open.length - listed.length,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * One section of a document by heading: its lines from `from` (default the
110
+ * heading) to the section's end, cut at `limits.sectionLines` lines or
111
+ * `limits.sectionBytes`. `next` is the first line not printed when cut.
112
+ * Returns { found: false, headings } when nothing matches.
113
+ */
114
+ export function readSection(raw, query, { from = null, limits = LIMITS } = {}) {
115
+ const text = normalizeEol(raw);
116
+ const { body, bodyLineOffset } = extractFrontmatter(text);
117
+ const lines = fileLines(text);
118
+ const headings = headingsOf(body, bodyLineOffset, lines.length);
119
+ const matches = matchSections(headings, query);
120
+ if (!matches.length) return { found: false, query, headings };
121
+
122
+ const [match, ...others] = matches;
123
+ const start = from == null ? match.line : Math.max(from, match.line);
124
+ if (start > match.end) {
125
+ return { found: true, past: true, query, heading: match, from: start, others };
126
+ }
127
+ const rows = [];
128
+ let bytes = 0;
129
+ for (let n = start; n <= match.end; n++) {
130
+ bytes += Buffer.byteLength(lines[n - 1]) + 1;
131
+ if (rows.length >= limits.sectionLines || (rows.length && bytes > limits.sectionBytes)) break;
132
+ rows.push({ line: n, text: lines[n - 1] });
133
+ }
134
+ const to = rows.length ? rows[rows.length - 1].line : start - 1;
135
+ return {
136
+ found: true,
137
+ query,
138
+ heading: match,
139
+ from: start,
140
+ to,
141
+ next: to < match.end ? to + 1 : null,
142
+ rows,
143
+ others,
144
+ };
145
+ }
package/src/commands.mjs CHANGED
@@ -109,7 +109,7 @@ const definitions = [
109
109
  form('use <name>', { subcommands: ['use'], args: positionals(1, 1) }),
110
110
  form('cap <gb>', { subcommands: ['cap'], args: positionals(1, 1) }),
111
111
  ]),
112
- command('show', none, 'read', [form('<file...>', { args: positionals(1, Infinity), options: [flag('--json')] })]),
112
+ command('show', none, 'read', [form('<file...>', { args: positionals(1, Infinity), options: [flag('--json'), flag('--card'), flag('--all'), flag('--frontmatter'), value('--section', '-s'), value('--from')] })]),
113
113
  command('xref', mutates('the flags log under the state directory with --check --flag; otherwise read-only'), 'read', [
114
114
  form('<file...> | --check [--flag]', { args: positionals(0, Infinity), options: [flag('--json'), flag('--check'), flag('--flag')] }),
115
115
  ]),
@@ -1,4 +1,5 @@
1
1
  import { maskInlineCodeSpans } from './markdown-code-spans.mjs';
2
+ import { walkChecklist } from './section.mjs';
2
3
 
3
4
  export function extractFirstHeading(body) {
4
5
  return body.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? null;
@@ -80,22 +81,14 @@ export function extractBodyLinks(body) {
80
81
  return links;
81
82
  }
82
83
 
84
+ // Boxes inside a code fence are examples, not work, so they are not counted;
85
+ // `runlist show` lists the open ones by the same rule (walkChecklist).
83
86
  export function extractChecklistCounts(body) {
84
- const matches = [...body.matchAll(/^\s*[-*]\s+\[([ xX~])\]\s+/gm)];
85
- let completed = 0;
86
- let open = 0;
87
-
88
- for (const match of matches) {
89
- if (match[1].toLowerCase() === 'x') {
90
- completed += 1;
91
- } else {
92
- open += 1;
93
- }
94
- }
95
-
87
+ const boxes = walkChecklist(body);
88
+ const open = boxes.filter(b => b.open).length;
96
89
  return {
97
- completed,
90
+ completed: boxes.length - open,
98
91
  open,
99
- total: completed + open,
92
+ total: boxes.length,
100
93
  };
101
94
  }
package/src/flags.mjs CHANGED
@@ -221,13 +221,15 @@ function placeLabel(flag, config) {
221
221
  const severityLabel = s => (s === 'problem' ? red(s) : s === 'warn' ? yellow(s) : dim(s));
222
222
 
223
223
  // One line for the session-start banner, or null when nothing is open.
224
- export function flagsHudLine(config, { top = 3 } = {}) {
224
+ // A count for the session-start line; `runlist flags` lists them. Printing the
225
+ // first few flags' text put problems unrelated to the session's task in front
226
+ // of every session, cut mid-sentence.
227
+ export function flagsHudLine(config) {
225
228
  let open;
226
229
  try { open = openFlags(config); } catch { return null; }
227
230
  if (open.length === 0) return null;
228
231
  const problems = open.filter(f => f.severity === 'problem').length;
229
- const items = open.slice(0, top).map(f => `${f.id} ${f.line != null ? `${f.file}:${f.line}` : f.file} ${f.text.slice(0, 80)}`).join('; ');
230
- return `[runlist] ${open.length} open flag${open.length === 1 ? '' : 's'}${problems ? `, ${problems} problem${problems === 1 ? '' : 's'}` : ''}, for awareness: ${items}. List: \`runlist flags\`.`;
232
+ return `${open.length} open flag${open.length === 1 ? '' : 's'}${problems ? `, ${problems} problem${problems === 1 ? '' : 's'}` : ''}, for awareness: \`runlist flags\` lists them.`;
231
233
  }
232
234
 
233
235
  export function runFlags(argv, config) {
package/src/hud.mjs CHANGED
@@ -12,7 +12,7 @@ import { compareVersions } from './update.mjs';
12
12
  import { findOwnedPlan } from './baton.mjs';
13
13
  import { listOwnedPlans, surveyOwnershipClaims } from './pickup.mjs';
14
14
  import { handoffNotDoneLine, pendingHandoffsForSession } from './handoff-record.mjs';
15
- import { actionablePromptStatuses, comparePromptDocs, resolveStatusMetadata } from './status-metadata.mjs';
15
+ import { actionablePromptStatuses, comparePromptDocs } from './status-metadata.mjs';
16
16
 
17
17
  export { actionablePromptStatuses } from './status-metadata.mjs';
18
18
 
@@ -286,40 +286,18 @@ export function buildHud(config) {
286
286
  }
287
287
 
288
288
  // Subagent primer: a spawned subagent (Explore, Plan, general-purpose) starts
289
- // with ZERO project context and no SessionStart history — it has never seen the
290
- // command sheet the top-level session got. Without this, subagents reflexively
291
- // grep/cat/commit managed docs instead of using runlist. Keep it to a few dense
292
- // lines: the verbs + the three wrong-moves the guard exists to stop, so the
293
- // subagent self-corrects before the guard ever has to fire.
289
+ // with ZERO project context and no SessionStart history. Without this,
290
+ // subagents reflexively grep/cat/commit managed docs instead of using runlist.
291
+ // It fires on every SubagentStart and CwdChanged, so every byte here is paid
292
+ // again and again: the read verbs, the three wrong-moves the guard exists to
293
+ // stop, and a pointer for the rare child handoff. Statuses are not listed —
294
+ // `runlist set` names the valid ones when given a wrong one.
294
295
  const SUBAGENT_PRIMER = [
295
- 'runlist manages this repo\'s plans/docs/prompts (markdown + YAML frontmatter).',
296
- 'Verbs: plans|briefing | query <filters> | use [<file>] | set <status> <file> | new <type> <slug> | archive <file>.',
297
- 'Child handoff: `runlist baton --subagent <task-slug> @<draft-file>` saves subagent-<task-slug>. Add --parent resume-<name> when known to group it under its parent prompt. Use a task slug under a parent-owned plan; release only your own plan. Parent batons default to resume-<name>.',
298
- 'Child draft: `runlist baton --subagent --template --parent resume-<name>` prints the checkout’s child outline (built-in: Task, State, Next, Evidence and Boundaries). Carry parent_prompt in the draft or pass RUNLIST_PARENT_PROMPT into the child environment. Save full records at durable repo-relative paths. Repair old links with `runlist prompts link <child...> --parent resume-<name>`.',
299
- 'Claims: `runlist doctor --claims` lists which session holds each plan; `--apply` releases the ones whose session is gone.',
300
- 'Do NOT: cat/read a docs/prompts/*.md (use `runlist use <file>` — archive/claim commits before at-most-once output);',
301
- 'git add/commit a prompt (they are session-local, often gitignored); hand-edit a `status:` field (use `runlist set`).',
296
+ 'runlist manages this repo\'s plans/docs/prompts (markdown + YAML frontmatter). Read a plan or long doc with `runlist show <file>` (outline and open boxes with line numbers), then `runlist show <file> --section "<heading>"`, never whole. Orient: `runlist plans`.',
297
+ 'Do NOT: cat/read a docs/prompts/*.md (use `runlist use <file>`); git add/commit a prompt (session-local); hand-edit a `status:` field (use `runlist set <status> <file>`).',
298
+ 'Handing work back as a saved prompt: `runlist baton --subagent <task-slug> @<draft-file>` (`runlist help baton` for --parent and --template).',
302
299
  ].join('\n');
303
300
 
304
- export function buildPlanStatusPrimer(config, { maxChars = 220 } = {}) {
305
- const statuses = (resolveStatusMetadata(config).byType.plan ?? []).map(item => item.name);
306
- const fallback = 'run `runlist statuses list --type plan`';
307
- if (statuses.length === 0) return `Plan statuses unavailable; ${fallback}.`;
308
- const prefix = 'Plan statuses: ';
309
- const full = `${prefix}${statuses.join(', ')}`;
310
- if (full.length <= maxChars) return full;
311
-
312
- const shown = [];
313
- for (const status of statuses) {
314
- const omitted = statuses.length - shown.length - 1;
315
- const candidate = `${prefix}${[...shown, status].join(', ')}, ... (+${omitted}; ${fallback})`;
316
- if (candidate.length > maxChars) break;
317
- shown.push(status);
318
- }
319
- const omitted = statuses.length - shown.length;
320
- return `${prefix}${shown.join(', ')}${shown.length ? ', ' : ''}... (+${omitted}; ${fallback})`;
321
- }
322
-
323
301
  // The plugin's SessionStart/SubagentStart hooks fire in EVERY repo (it's enabled
324
302
  // globally), but the primer only helps where runlist is actually used. Gate on a
325
303
  // discovered config: `runlist init` writes runlist.config.mjs, so "has a config" is
@@ -415,14 +393,11 @@ export async function runPromptSubmitHud(config, { readStdin } = {}) {
415
393
  process.stdout.write(buildHandoffContext(config) + '\n');
416
394
  }
417
395
 
418
- // Names, not paths, and at most PROMPT_NAMES_SHOWN of them, so the line stays
419
- // one line however long the queue grows.
420
- const PROMPT_NAMES_SHOWN = 12;
396
+ // A count, not names: a queue of dozens printed a dozen names nobody asked
397
+ // about, and `runlist prompts` lists them when the user names one.
421
398
  export function pendingPromptsLine(prompts) {
422
399
  const n = prompts.length;
423
- const names = prompts.slice(0, PROMPT_NAMES_SHOWN).map(p => path.basename(p, '.md'));
424
- const more = n > names.length ? ` (+${n - names.length} more: \`runlist prompts\`)` : '';
425
- return `[runlist] ${n} pending prompt${n === 1 ? '' : 's'}: ${names.join(', ')}${more}. Resume one only when the user asks for it (\`runlist use <name>\`); never consume the oldest by default. Peek without consuming: \`runlist prompts show <name>\`.`;
400
+ return `${n} pending prompt${n === 1 ? '' : 's'}: resume one only when the user asks for it (\`runlist prompts\` lists them, \`runlist use <name>\` resumes one, \`runlist prompts show <name>\` peeks); never consume the oldest by default.`;
426
401
  }
427
402
 
428
403
  function isRunlistRepo(config) {
@@ -441,7 +416,6 @@ export function runHud(argv, config) {
441
416
  if (argv.includes('--subagent')) {
442
417
  if (!runlistRepo) return; // silent in repos that don't use runlist
443
418
  process.stdout.write(dim(SUBAGENT_PRIMER) + '\n');
444
- process.stdout.write(dim(buildPlanStatusPrimer(config)) + '\n');
445
419
  if (drift) process.stdout.write(yellow(drift) + '\n');
446
420
  return;
447
421
  }
@@ -458,26 +432,29 @@ export function runHud(argv, config) {
458
432
 
459
433
  const hud = buildHudFast(config);
460
434
 
461
- // SessionStart contract: the command primer, plus ONLY signals that carry a
462
- // direct instruction for this session. Passive state (error counts,
463
- // slash-command refresh notices, previous-self / fleet / recent-rejections)
464
- // stays suppressed — those nudged agents into phantom follow-up work (e.g.
465
- // "errors: 1" prompting a check run) and live in their proper commands and
466
- // `runlist hud --json`. Two signals ARE instructions and must print, because
467
- // the handoff loop dies without them (sessions were saving batons that no
468
- // next session ever picked up):
469
- // - pending prompts: earlier sessions queued work. The line lists them
470
- // and says to resume one only when the user asks. It used to say
471
- // "consume the oldest" unless told otherwise, but a new session nearly
435
+ // SessionStart contract: ONLY signals that carry a direct instruction for
436
+ // this session, and nothing at all in a clean repo. No command sheet and no
437
+ // status list: the runlist skill carries the verbs, and `runlist set` names
438
+ // the valid statuses when given a wrong one. Passive state (error counts,
439
+ // previous-self / fleet / recent-rejections) stays suppressed — it nudged
440
+ // agents into phantom follow-up work and lives in its own commands and
441
+ // `runlist hud --json`. What prints:
442
+ // - pending prompts, as a count: earlier sessions queued work, and the
443
+ // line says to resume one only when the user asks. A new session nearly
472
444
  // always has a task from the user, the hook cannot see it, and the
473
445
  // oldest prompt is rarely the useful one.
446
+ // - open flags, as a count: the person asked that every session start
447
+ // knowing where things stand. Worded as awareness, not an instruction.
474
448
  // - an in-session plan attributed to this sid via durable ownership: this
475
449
  // session (pre-compaction) owns it and should continue or hand it off.
476
450
  // Global in-session counts never provide a fallback.
477
- // The misuse recap stays for the same reason: a repeat-offense rule means
478
- // the primer alone isn't landing, so name the habit to break.
479
- 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)') + '\n');
480
- process.stdout.write(dim(buildPlanStatusPrimer(config)) + '\n');
451
+ // - an unfinished handoff, a repeat-offense misuse rule, and plans held by
452
+ // sessions that are gone: each names the command that clears it.
453
+ const queue = [];
454
+ if (hud.prompts.length > 0) queue.push(pendingPromptsLine(hud.prompts));
455
+ const flagsLine = flagsHudLine(config);
456
+ if (flagsLine) queue.push(flagsLine);
457
+ if (queue.length) process.stdout.write(yellow(`[runlist] ${queue.join(' ')}`) + '\n');
481
458
  if (hud.owned && hud.owned.via === 'ownership') {
482
459
  process.stdout.write(yellow(`[runlist] in-session (yours): ${hud.owned.path} — continue it; hand off with \`runlist baton @/tmp/draft.md\` before stopping.`) + '\n');
483
460
  }
@@ -486,16 +463,7 @@ export function runHud(argv, config) {
486
463
  try {
487
464
  for (const record of pendingHandoffsForSession(config)) process.stdout.write(yellow(`[runlist] ${handoffNotDoneLine(record, `runlist baton --done ${record.plan}`)}`) + '\n');
488
465
  } catch { /* advisory */ }
489
- if (hud.prompts.length > 0) {
490
- const n = hud.prompts.length;
491
- process.stdout.write(yellow(pendingPromptsLine(hud.prompts)) + '\n');
492
- }
493
466
  if (hud.misuseRecap) process.stdout.write(yellow(`[runlist] ${hud.misuseRecap}`) + '\n');
494
467
  if (hud.deadClaimsLine) process.stdout.write(yellow(hud.deadClaimsLine) + '\n');
495
- // Open flags are the one piece of passive state printed here: the person
496
- // asked that every session start knowing where things stand. It is worded
497
- // as awareness, not an instruction, so no session treats it as its task.
498
- const flagsLine = flagsHudLine(config);
499
- if (flagsLine) process.stdout.write(yellow(flagsLine) + '\n');
500
468
  if (drift) process.stdout.write(yellow(drift) + '\n');
501
469
  }
package/src/section.mjs CHANGED
@@ -3,26 +3,32 @@
3
3
  // and body-relative line numbers (1-indexed). Callers with stripped frontmatter
4
4
  // add extractFrontmatter().bodyLineOffset before exposing file coordinates.
5
5
 
6
- export function walkSections(body) {
7
- const lines = body.split('\n');
6
+ // Calls visit(line, index) for every line outside a fenced code block. A fence
7
+ // closes only on the character that opened it, so ``` inside ~~~ stays code.
8
+ function eachUnfencedLine(lines, visit) {
8
9
  const fenceRe = /^(`{3,}|~{3,})/;
9
- const headingRe = /^(#{1,6})\s+(.+?)\s*$/;
10
- const sections = [];
11
10
  let fenceChar = null;
12
- let h2Ancestor = null;
13
-
14
11
  for (let i = 0; i < lines.length; i++) {
15
- const line = lines[i];
16
- const fence = line.match(fenceRe);
12
+ const fence = lines[i].match(fenceRe);
17
13
  if (fence) {
18
14
  const tok = fence[1][0]; // ` or ~
19
15
  if (fenceChar === null) fenceChar = tok;
20
16
  else if (fenceChar === tok) fenceChar = null;
21
17
  continue;
22
18
  }
23
- if (fenceChar !== null) continue;
19
+ if (fenceChar === null) visit(lines[i], i);
20
+ }
21
+ }
22
+
23
+ export function walkSections(body) {
24
+ const lines = body.split('\n');
25
+ const headingRe = /^(#{1,6})\s+(.+?)\s*$/;
26
+ const sections = [];
27
+ let h2Ancestor = null;
28
+
29
+ eachUnfencedLine(lines, (line, i) => {
24
30
  const h = line.match(headingRe);
25
- if (!h) continue;
31
+ if (!h) return;
26
32
  const level = h[1].length;
27
33
  const heading = h[2];
28
34
  if (level === 1) h2Ancestor = null;
@@ -35,7 +41,7 @@ export function walkSections(body) {
35
41
  bodyLineStart: i + 2,
36
42
  });
37
43
  if (level === 2) h2Ancestor = heading;
38
- }
44
+ });
39
45
 
40
46
  for (let i = 0; i < sections.length; i++) {
41
47
  const next = sections.find((s, j) => j > i && s.level <= sections[i].level);
@@ -57,6 +63,35 @@ export function findSection(sections, name) {
57
63
  return sections.find(s => norm(s.heading) === target) ?? null;
58
64
  }
59
65
 
66
+ // Every section whose heading matches `query`, best first: exact, then prefix,
67
+ // then substring, document order within each. Case-insensitive; leading `#`s
68
+ // and trailing markers are ignored on both sides, as in findSection.
69
+ export function matchSections(sections, query) {
70
+ const norm = (s) => s.toLowerCase().replace(/^#+\s*/, '').replace(/[^\w\s]+$/, '').replace(/\s+/g, ' ').trim();
71
+ const q = norm(query);
72
+ if (!q) return [];
73
+ const tiers = [[], [], []];
74
+ for (const s of sections) {
75
+ const h = norm(s.heading);
76
+ if (h === q) tiers[0].push(s);
77
+ else if (h.startsWith(q)) tiers[1].push(s);
78
+ else if (h.includes(q)) tiers[2].push(s);
79
+ }
80
+ return tiers.flat();
81
+ }
82
+
83
+ // Checklist boxes outside code fences, with body-relative 1-indexed lines.
84
+ // `[ ]` and `[~]` are open, as in phaseTally and the index's checklist counts.
85
+ const BOX_RE = /^\s*[-*]\s+\[([ xX~])\](?:\s+(.*?))?\s*$/;
86
+ export function walkChecklist(body) {
87
+ const boxes = [];
88
+ eachUnfencedLine(body.split('\n'), (line, i) => {
89
+ const m = line.match(BOX_RE);
90
+ if (m) boxes.push({ line: i + 1, open: m[1].toLowerCase() !== 'x', text: m[2] ?? '' });
91
+ });
92
+ return boxes;
93
+ }
94
+
60
95
  // Status marker detection for phase headings. Returns one of:
61
96
  // 'shipped' | 'skipped' | 'in-progress' | 'blocked' | 'todo' | null
62
97
  // Glyphs are checked BEFORE prose, not interleaved with it. A glyph is a
package/src/show.mjs CHANGED
@@ -1,13 +1,16 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
+ import { extractFrontmatter, normalizeEol, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import { buildBrief, collapseFrontmatter, cut, LIMITS, readSection, SURVEY_LIMITS } from './brief.mjs';
4
5
  import { parseDocFile, resolveDocArg } from './index.mjs';
5
6
  import { resolveBodyLinkTarget } from './body-link.mjs';
6
- import { die, normalizeStringList, resolveRefPath, toRepoPath } from './util.mjs';
7
+ import { die, normalizeStringList, resolveRefPath, shellQuote, toRepoPath } from './util.mjs';
7
8
 
8
9
  // `runlist show <file...>` — one document's card as data: its title, status,
9
10
  // next step, blockers and checklist, the plans and docs its frontmatter names,
10
- // and the documents its body links to. Read-only; nothing is claimed.
11
+ // and the documents its body links to, followed by its brief (outline and open
12
+ // boxes with line numbers; src/brief.mjs). `--section` reads one section in
13
+ // place of the whole file. Read-only; nothing is claimed.
11
14
 
12
15
  // Frontmatter lists that name other documents. The configured reference fields
13
16
  // are read too; `related_docs` is read whether or not a repo configures it.
@@ -76,26 +79,139 @@ export function showDoc(abs, config) {
76
79
  };
77
80
  }
78
81
 
82
+ // Fields the text card prints. Everything else non-empty is named on one line
83
+ // so a reader knows it is there without printing the frontmatter.
84
+ const CARD_FIELDS = new Set(['title', 'status', 'next_step', 'blockers']);
85
+
86
+ function otherFields(abs, config) {
87
+ const raw = readFileSync(abs, 'utf8');
88
+ const fm = parseSimpleFrontmatter(extractFrontmatter(raw).frontmatter ?? '', []);
89
+ const shown = new Set([...CARD_FIELDS, ...relatedFields(config)]);
90
+ return Object.keys(fm).filter(k => !shown.has(k) && normalizeStringList(fm[k]).some(v => String(v).trim()));
91
+ }
92
+
93
+ function parseShowArgs(args) {
94
+ const opts = { json: false, card: false, all: false, frontmatter: false, section: null, from: null, targets: [] };
95
+ for (let i = 0; i < args.length; i++) {
96
+ const a = args[i];
97
+ if (a === '--json') opts.json = true;
98
+ else if (a === '--card') opts.card = true;
99
+ else if (a === '--all') opts.all = true;
100
+ else if (a === '--frontmatter') opts.frontmatter = true;
101
+ else if (a === '--section' || a === '-s') opts.section = args[++i] ?? die('--section needs a heading');
102
+ else if (a === '--from') opts.from = args[++i];
103
+ else if (a.startsWith('-')) die(`Unknown option for show: ${a}`);
104
+ else opts.targets.push(a);
105
+ }
106
+ if (opts.from != null) {
107
+ if (opts.section == null) die('--from continues a --section read; name the section too');
108
+ const n = Number(opts.from);
109
+ if (!Number.isInteger(n) || n < 1) die(`--from takes a line number, got: ${opts.from}`);
110
+ opts.from = n;
111
+ }
112
+ return opts;
113
+ }
114
+
115
+ const pad = (n, width) => String(n).padStart(width);
116
+
117
+ function runSection(target, opts, config) {
118
+ const abs = resolveDocArg(target, config);
119
+ const rel = toRepoPath(abs, config.repoRoot);
120
+ const r = readSection(readFileSync(abs, 'utf8'), opts.section, { from: opts.from });
121
+ const again = from => `runlist show ${shellQuote(rel)} --section ${shellQuote(opts.section)} --from ${from}`;
122
+ if (opts.json) {
123
+ const { headings, ...rest } = r;
124
+ const out = r.found
125
+ ? { path: rel, ...rest, ...(r.next ? { continue: again(r.next) } : {}) }
126
+ : { path: rel, error: 'no heading matches', query: r.query, headings: headings.map(({ line, level, heading }) => ({ line, level, heading })) };
127
+ process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
128
+ if (!r.found || r.past) process.exitCode = 1;
129
+ return;
130
+ }
131
+ if (!r.found) {
132
+ const list = r.headings.slice(0, 20).map(h => ` ${h.line} ${cut(`${'#'.repeat(h.level)} ${h.heading}`)}`);
133
+ if (r.headings.length > 20) list.push(` (+${r.headings.length - 20} more)`);
134
+ process.stderr.write([`No heading in ${rel} matches "${opts.section}". Headings:`, ...list].join('\n') + '\n');
135
+ process.exitCode = 1;
136
+ return;
137
+ }
138
+ const h = r.heading;
139
+ if (r.past) {
140
+ process.stderr.write(`${rel}: "${h.heading}" runs ${h.line} to ${h.end}; --from ${r.from} is past its end.\n`);
141
+ process.exitCode = 1;
142
+ return;
143
+ }
144
+ const width = String(r.to).length;
145
+ const span = r.from === h.line && !r.next ? `${h.line} to ${h.end}` : `${r.from} to ${r.to} of ${h.line} to ${h.end}`;
146
+ const out = [`${rel} lines ${span} (${'#'.repeat(h.level)} ${h.heading}):`];
147
+ for (const row of r.rows) out.push(`${pad(row.line, width)}\t${row.text}`);
148
+ if (r.next) out.push(`(cut at line ${r.to}; the section runs to ${h.end}. Next: ${again(r.next)})`);
149
+ if (r.others.length) out.push(`Also matching: ${r.others.map(o => `${o.line} ${cut(o.heading, 60)}`).join('; ')}`);
150
+ process.stdout.write(out.join('\n') + '\n');
151
+ }
152
+
153
+ function renderBrief(card, brief) {
154
+ const out = [];
155
+ const width = String(brief.lines).length;
156
+ const kb = Math.max(1, Math.round(brief.bytes / 1024));
157
+ out.push('', `Outline: ${brief.lines.toLocaleString('en-US')} lines, ${kb} KB (line, heading)`);
158
+ if (!brief.outline.length) out.push(' (no headings)');
159
+ for (const h of brief.outline) out.push(` ${pad(h.line, width)} ${cut(`${'#'.repeat(h.level)} ${h.heading}`)}`);
160
+ if (brief.outlineOmitted) out.push(` (+${brief.outlineOmitted} deeper headings not shown; a --section read shows them)`);
161
+ const total = brief.boxes.open + brief.boxes.done;
162
+ if (total) {
163
+ out.push(`Open boxes: ${brief.boxes.open} of ${total} (line, text)`);
164
+ for (const b of brief.open) out.push(` ${pad(b.line, width)} ${cut(b.text)}`);
165
+ if (brief.openOmitted) out.push(` (+${brief.openOmitted} more: add --all)`);
166
+ }
167
+ if (brief.frontmatter) {
168
+ out.push(`Frontmatter (lines 1 to ${brief.frontmatterLines}):`, ...brief.frontmatter.map(l => ` ${l}`));
169
+ } else if (card.otherFields?.length) {
170
+ out.push(`Other fields: ${card.otherFields.join(', ')} (--frontmatter prints them)`);
171
+ }
172
+ return out;
173
+ }
174
+
79
175
  export function runShow(args, config) {
80
- const json = args.includes('--json');
81
- const targets = args.filter(a => !a.startsWith('-'));
82
- if (!targets.length) die('Usage: runlist show <file...> [--json]');
176
+ const opts = parseShowArgs(args);
177
+ if (!opts.targets.length) die('Usage: runlist show <file...> [--section "<heading>" [--from <line>]] [--all] [--card] [--frontmatter] [--json]');
178
+ if (opts.section != null) {
179
+ if (opts.targets.length > 1) die('--section reads one file; name one');
180
+ runSection(opts.targets[0], opts, config);
181
+ return;
182
+ }
183
+ const limits = opts.targets.length > 1 ? SURVEY_LIMITS : LIMITS;
83
184
  const cards = [];
84
- for (const t of targets) {
85
- const abs = resolveDocArg(t, config, { dieOnMiss: !json });
86
- cards.push(abs ? showDoc(abs, config) : { path: t, error: 'not found' });
185
+ for (const t of opts.targets) {
186
+ const abs = resolveDocArg(t, config, { dieOnMiss: !opts.json });
187
+ if (!abs) { cards.push({ path: t, error: 'not found' }); continue; }
188
+ const card = showDoc(abs, config);
189
+ if (!opts.card) {
190
+ const raw = readFileSync(abs, 'utf8');
191
+ card.brief = buildBrief(raw, { all: opts.all, limits });
192
+ if (opts.frontmatter) card.brief.frontmatter = collapseFrontmatter(extractFrontmatter(normalizeEol(raw)).frontmatter, limits);
193
+ card.otherFields = otherFields(abs, config);
194
+ }
195
+ cards.push(card);
87
196
  }
88
- if (json) {
197
+ if (opts.json) {
89
198
  process.stdout.write(`${JSON.stringify(cards, null, 2)}\n`);
90
199
  return;
91
200
  }
92
201
  cards.forEach((c, n) => {
93
202
  if (n) process.stdout.write('\n');
94
- process.stdout.write(`${c.title} (${c.status ?? 'no status'})\n${c.path}\n`);
95
- if (c.nextStep) process.stdout.write(`Next: ${c.nextStep}\n`);
96
- for (const b of c.blockers) process.stdout.write(`Blocker: ${b}\n`);
97
- if (c.checklist?.total) process.stdout.write(`Checklist: ${c.checklist.completed} of ${c.checklist.total} done\n`);
98
- for (const r of c.related) process.stdout.write(`${r.field}: ${r.path ?? `${r.ref} (missing)`}${r.status ? ` (${r.status})` : ''}\n`);
99
- if (c.links.length) process.stdout.write(`Linked from the body: ${c.links.map(l => l.path).join(', ')}\n`);
203
+ const out = [`${c.title} (${c.status ?? 'no status'})`, c.path];
204
+ if (c.nextStep) out.push(`Next: ${c.nextStep}`);
205
+ for (const b of c.blockers) out.push(`Blocker: ${b}`);
206
+ // The brief's open-box line carries the same count, read outside code fences.
207
+ if (c.checklist?.total && !c.brief) out.push(`Checklist: ${c.checklist.completed} of ${c.checklist.total} done`);
208
+ for (const r of c.related) out.push(`${r.field}: ${r.path ?? `${r.ref} (missing)`}${r.status ? ` (${r.status})` : ''}`);
209
+ if (c.links.length) out.push(`Linked from the body: ${c.links.map(l => l.path).join(', ')}`);
210
+ if (c.brief) out.push(...renderBrief(c, c.brief));
211
+ process.stdout.write(out.join('\n') + '\n');
100
212
  });
213
+ if (!opts.card) {
214
+ const file = cards.length === 1 ? shellQuote(cards[0].path) : '<file>';
215
+ process.stdout.write(`\nOne section: runlist show ${file} --section "<heading>"\n`);
216
+ }
101
217
  }
package/src/util.mjs CHANGED
@@ -306,6 +306,13 @@ export function suggestCandidates(query, candidates, max = 3) {
306
306
  .map(([cand]) => cand);
307
307
  }
308
308
 
309
+ // One argument as it must be typed into a POSIX shell: bare when it is plainly
310
+ // safe, otherwise double-quoted with `"`, `\`, `$` and backtick escaped.
311
+ export function shellQuote(value) {
312
+ const raw = String(value);
313
+ return /^[A-Za-z0-9_./:@+-]+$/.test(raw) ? raw : `"${raw.replace(/["\\$`]/g, '\\$&')}"`;
314
+ }
315
+
309
316
  export function resolveDocPath(input, config) {
310
317
  if (!input) return null;
311
318
  if (path.isAbsolute(input)) return existsSync(input) ? input : null;