runlist 0.96.1 → 0.98.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.98.0**.
75
+ [Mac installer and release notes](https://github.com/reowens/runlist/releases/tag/desktop-v0.98.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,16 @@ 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.97.0 is published and installed locally.** It adds the `runlist show`
109
+ brief (outline, open boxes and `--section` reads with line numbers), counts
110
+ checklists outside code fences only, and trims session and subagent start-up
111
+ context. The desktop app's own code is unchanged from 0.96.1; it bundles the
112
+ 0.97.0 engine. The Mac installer is signed, notarized and verified; installation
113
+ retained the previous app for rollback. Open it with `rl desktop`. See the
114
+ [release validation](desktop/VALIDATION.md#current-mac-release--2026-10-22)
115
+ for checks and evidence. Full-app dialog accessibility remains an open finding;
116
+ fresh/minimum-host and controlled whole-app performance qualification remain
117
+ follow-up work.
117
118
 
118
119
  Verify an installed Mac app without opening it:
119
120
 
@@ -134,33 +135,22 @@ record completed results in durable plans before explicitly archiving the
134
135
  selected prompts. Historical nested-archive origin recovery stays deferred until
135
136
  a concrete case needs it.
136
137
 
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.
138
+ Remaining product work:
139
+
140
+ 1. Resolve full-app dialog accessibility and extend native offline, fresh/minimum-Mac
141
+ installation/upgrade/recovery and whole-app performance qualification.
142
+ 2. Finish the broader native-plan, identity/migration, history/verification,
143
+ scoped-relationship and advanced settings/diagnostics contracts. Reviewed
144
+ legacy plan status/archive operations already ship.
145
+ 3. Qualify additional local-commit filter, hook, signing and platform profiles.
146
+ Push remains deferred.
147
+ 4. Deploy and qualify an exact optional grepmax provider against an existing
148
+ index, after the separately recorded watcher/resource hold is resolved.
149
+ Compatibility fixtures pass; successful live retrieval remains unverified.
150
+ Local model chat remains deferred.
151
+ 5. Complete post-release agent-workflow measurement and assess the installed
152
+ OpenCode prompt-hook API. A Rust engine migration remains planned pending
153
+ performance evidence.
164
154
 
165
155
  The current Mac installer is linked in the release-version section above.
166
156
  CLI and desktop include the optional product yardstick.
package/bin/runlist.mjs CHANGED
@@ -263,6 +263,7 @@ View & Query:
263
263
  plans Live plans (excludes archived; --include-archived for all)
264
264
  app [document] [--port 5173] Local Markdown document library with reviewed saves and draft recovery
265
265
  use [<file-or-slug>] Open a doc by type: prompt → consume, plan → start, doc → read
266
+ (a doc over 500 lines prints its brief; --full prints it whole)
266
267
  baton [<plan>|<slug>] @<draft> Save a resume prompt; releases the plan + prints the commit when one is in-session
267
268
  prompts [list|show|archive|new] Prompt admin (list / peek / archive / save). Use \`runlist use\` to consume.
268
269
  stale Stale docs (preset)
@@ -285,7 +286,7 @@ Analyze:
285
286
  record <file> <action> --note <reason> Act on a native flag or decision
286
287
  decision <file> <id> --disposition <state> --note <reason> Record an outcome
287
288
  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
289
+ show <file...> [--section <h>] One document's card, outline and open boxes with line numbers; one section
289
290
  xref <file...> | --check [--flag] Cross-references: what names it, its decisions, its cited code
290
291
 
291
292
  Validate & Fix:
@@ -1571,18 +1572,41 @@ remain owned by their CLI sessions; coordinate/release them before a human save.
1571
1572
  Metadata and lifecycle changes continue through the CLI. Ctrl+C stops the app.
1572
1573
  --dry-run previews startup without opening a server or writing editor state.
1573
1574
  `,
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.`,
1575
+ show: `runlist show <file...> — a document in a few KB, read-only
1576
+
1577
+ runlist show <file> card (title, status, next step, blockers, related
1578
+ plans and docs, body links), then the outline and
1579
+ open boxes with line numbers, then the other
1580
+ frontmatter fields by name
1581
+ runlist show <file> --section "<heading>"
1582
+ that section, every line numbered, to the next
1583
+ heading at the same or a higher level
1584
+ --from <line> continue a section read that was cut
1585
+ --all every open box, not the first 30
1586
+ --card the card only
1587
+ --frontmatter the frontmatter as written, long values collapsed
1588
+ --json the same as data; a section read is
1589
+ { path, query, heading, from, to, next, rows, others }
1590
+
1591
+ Read a plan or long doc this way, then one section at a time, never whole. A
1592
+ heading matches case-insensitively: exact first, then as a prefix, then as a
1593
+ substring; other matches are named. A section read stops at 500 lines or
1594
+ 60 KB and prints the command that continues it. Headings and boxes inside code
1595
+ fences are ignored. Several files at once get a shorter outline each.
1596
+
1597
+ A phase heading shows its own boxes as [done/total], → marks the active phase
1598
+ (the one \`use\` would point at), and a marker its boxes contradict is named
1599
+ (marked shipped with boxes open, or todo with every box checked). Open boxes
1600
+ are grouped by phase, and the last line is the --section call for the active
1601
+ phase.
1602
+
1603
+ \`runlist use <doc>\` prints this brief for a doc over 500 lines or 60 KB;
1604
+ \`use <doc> --full\` prints the whole body.
1605
+
1606
+ A file is a path, filename or slug, as for \`use\`, and any Markdown file in
1607
+ the repo works. Nothing is claimed or written. With --json a file that is not
1608
+ found is { path, error } rather than an exit; a heading that matches nothing
1609
+ exits 1 and lists the headings.`,
1586
1610
 
1587
1611
  model: `runlist model — the local model behind summaries and lint
1588
1612
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runlist",
3
- "version": "0.96.1",
3
+ "version": "0.98.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.98.0",
4
4
  "description": "Runlist workflow skill, session orientation, and tool guard for Codex",
5
5
  "author": {
6
6
  "name": "Reo Owens"
@@ -12,6 +12,7 @@ Use this skill in repositories with `runlist.config.mjs` or `dotmd.config.mjs`.
12
12
  **Optional filing policy:** with `export const filing = true` (or `{ categoryDepth: 3 }`), `check` warns on missing/multiple/uncategorized homes, while `coverage` lists direct/inherited homes and hub:line evidence. A home is a status-bearing table row whose first cell links the plan; mentions and order lists are references. A child without its own row can inherit a home through explicit `parent_plan:` ancestors. Categories come from headings or the containing hub's filing category. Scoping retains full evidence; incomplete scans suppress absence findings. Neither command assigns homes or repairs rows. Enable the policy or retire an older custom check only with repository authorization. Available in CLI 0.93.0.
13
13
 
14
14
  - Orient with `runlist plans`. Use `runlist agent-context` for structured state; `runlist briefing` is the comprehensive view and can be large.
15
+ - Read a plan or long doc with `runlist show <file>` (card, outline and open boxes with line numbers, the active phase), then `runlist show <file> --section "<heading>"` one section at a time; never read a plan whole.
15
16
  - Start plan work with `runlist use <plan-file>`. It claims the plan, marks it `in-session`, and prints the card.
16
17
  - Change status with `runlist set <status> <file> [--note "why"]`; this runs validation, lifecycle hooks, ref repairs, and index updates. Never hand-edit a `status:` field.
17
18
  - Close a shipped plan with `runlist archive <file>`; use `partial`, `active`, `awaiting`, or `blocked` when those match the actual state. Valid statuses are repository and type specific: `runlist statuses list --type plan`.
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,174 @@
1
+ import { extractFrontmatter, normalizeEol } from './frontmatter.mjs';
2
+ import { detectMarker, findActivePhase, isPhaseHeading, matchSections, phaseMarkerConflict, phaseTally, walkChecklist, walkSections } 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
+ // A phase heading also carries `phase`: its marker, its own checklist tally,
34
+ // whether it is the active phase (the rule the `use` pickup card applies), and
35
+ // a contradiction between marker and boxes when there is one.
36
+ function headingsOf(body, bodyLineOffset, lastLine) {
37
+ const sections = walkSections(body);
38
+ const active = findActivePhase(sections);
39
+ return sections.map(s => {
40
+ const h = {
41
+ line: s.lineStart + bodyLineOffset,
42
+ end: Math.min(s.lineEnd + bodyLineOffset, lastLine),
43
+ level: s.level,
44
+ heading: s.heading,
45
+ };
46
+ if (isPhaseHeading(s)) {
47
+ const tally = phaseTally(s);
48
+ const conflict = phaseMarkerConflict(s);
49
+ h.phase = {
50
+ marker: detectMarker(s.heading),
51
+ done: tally.checked,
52
+ total: tally.total,
53
+ active: s === active,
54
+ conflict: conflict ? { declared: conflict.declared, implied: conflict.implied } : null,
55
+ };
56
+ }
57
+ return h;
58
+ });
59
+ }
60
+
61
+ // The phase heading a body line sits under, if any.
62
+ function phaseAt(headings, line) {
63
+ let found = null;
64
+ for (const h of headings) if (h.phase && h.line <= line && line <= h.end) found = h;
65
+ return found;
66
+ }
67
+
68
+ // Drop the deepest heading levels until the outline fits, then cut the tail.
69
+ function boundOutline(headings, cap) {
70
+ let shown = headings;
71
+ while (shown.length > cap) {
72
+ const deepest = Math.max(...shown.map(h => h.level));
73
+ if (deepest <= 1) break;
74
+ shown = shown.filter(h => h.level < deepest);
75
+ }
76
+ shown = shown.slice(0, cap);
77
+ return { shown, omitted: headings.length - shown.length };
78
+ }
79
+
80
+ // Frontmatter as written, with long values cut and long lists collapsed.
81
+ export function collapseFrontmatter(frontmatter, limits = LIMITS) {
82
+ if (!frontmatter) return [];
83
+ const out = [];
84
+ let listRun = 0;
85
+ let textRun = 0;
86
+ const flush = () => {
87
+ if (listRun > limits.listItems) out.push(` (+${listRun - limits.listItems} more)`);
88
+ if (textRun > limits.textLines) out.push(` (+${textRun - limits.textLines} more lines)`);
89
+ listRun = 0;
90
+ textRun = 0;
91
+ };
92
+ for (const line of frontmatter.split('\n')) {
93
+ if (/^\s+-\s/.test(line)) {
94
+ if (++listRun <= limits.listItems) out.push(cut(line, limits.width));
95
+ continue;
96
+ }
97
+ if (/^\s+\S/.test(line)) {
98
+ if (++textRun <= limits.textLines) out.push(cut(line, limits.width));
99
+ continue;
100
+ }
101
+ flush();
102
+ out.push(cut(line, limits.width));
103
+ }
104
+ flush();
105
+ return out;
106
+ }
107
+
108
+ /**
109
+ * The brief of one document's raw text: its size, a bounded outline and the
110
+ * open boxes. `all` lists every open box. Counts are of what sits outside code
111
+ * fences, so a template's example checklist is not reported as work.
112
+ */
113
+ export function buildBrief(raw, { all = false, limits = LIMITS } = {}) {
114
+ const text = normalizeEol(raw);
115
+ const { frontmatter, body, bodyLineOffset } = extractFrontmatter(text);
116
+ const lines = fileLines(text);
117
+ const headings = headingsOf(body, bodyLineOffset, lines.length);
118
+ const { shown, omitted } = boundOutline(headings, limits.outline);
119
+ const boxes = walkChecklist(body).map(b => ({ ...b, line: b.line + bodyLineOffset }));
120
+ const open = boxes.filter(b => b.open)
121
+ .map(({ line, text: t }) => ({ line, text: t, phase: phaseAt(headings, line)?.heading ?? null }));
122
+ const active = headings.find(h => h.phase?.active) ?? null;
123
+ const listed = all ? open : open.slice(0, limits.boxes);
124
+ return {
125
+ lines: lines.length,
126
+ bytes: Buffer.byteLength(text),
127
+ frontmatterLines: frontmatter ? bodyLineOffset : 0,
128
+ outline: shown,
129
+ outlineOmitted: omitted,
130
+ activePhase: active ? { line: active.line, heading: active.heading } : null,
131
+ boxes: { open: open.length, done: boxes.length - open.length },
132
+ open: listed,
133
+ openOmitted: open.length - listed.length,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * One section of a document by heading: its lines from `from` (default the
139
+ * heading) to the section's end, cut at `limits.sectionLines` lines or
140
+ * `limits.sectionBytes`. `next` is the first line not printed when cut.
141
+ * Returns { found: false, headings } when nothing matches.
142
+ */
143
+ export function readSection(raw, query, { from = null, limits = LIMITS } = {}) {
144
+ const text = normalizeEol(raw);
145
+ const { body, bodyLineOffset } = extractFrontmatter(text);
146
+ const lines = fileLines(text);
147
+ const headings = headingsOf(body, bodyLineOffset, lines.length);
148
+ const matches = matchSections(headings, query);
149
+ if (!matches.length) return { found: false, query, headings };
150
+
151
+ const [match, ...others] = matches;
152
+ const start = from == null ? match.line : Math.max(from, match.line);
153
+ if (start > match.end) {
154
+ return { found: true, past: true, query, heading: match, from: start, others };
155
+ }
156
+ const rows = [];
157
+ let bytes = 0;
158
+ for (let n = start; n <= match.end; n++) {
159
+ bytes += Buffer.byteLength(lines[n - 1]) + 1;
160
+ if (rows.length >= limits.sectionLines || (rows.length && bytes > limits.sectionBytes)) break;
161
+ rows.push({ line: n, text: lines[n - 1] });
162
+ }
163
+ const to = rows.length ? rows[rows.length - 1].line : start - 1;
164
+ return {
165
+ found: true,
166
+ query,
167
+ heading: match,
168
+ from: start,
169
+ to,
170
+ next: to < match.end ? to + 1 : null,
171
+ rows,
172
+ others,
173
+ };
174
+ }
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,169 @@ 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
+ // `[3/7]` for a phase with boxes, and the contradiction between its marker and
154
+ // its boxes when there is one.
155
+ function phaseNote(phase) {
156
+ if (!phase) return '';
157
+ let note = phase.total ? ` [${phase.done}/${phase.total}]` : '';
158
+ if (phase.conflict) {
159
+ note += phase.conflict.declared === 'shipped'
160
+ ? ` (marked shipped; ${phase.total - phase.done} open)`
161
+ : ' (marked todo; every box checked)';
162
+ }
163
+ return note;
164
+ }
165
+
166
+ // A heading as a --section query: trailing markers dropped, which the matcher
167
+ // ignores anyway, so the suggested command reads cleanly.
168
+ const sectionQuery = heading => heading.replace(/[^\w\s]+$/u, '').trim() || heading;
169
+
170
+ function renderBrief(card, brief) {
171
+ const out = [];
172
+ const width = String(brief.lines).length;
173
+ const kb = Math.max(1, Math.round(brief.bytes / 1024));
174
+ out.push('', `Outline: ${brief.lines.toLocaleString('en-US')} lines, ${kb} KB (line, heading)`);
175
+ if (!brief.outline.length) out.push(' (no headings)');
176
+ for (const h of brief.outline) {
177
+ const gutter = h.phase?.active ? '→ ' : ' ';
178
+ out.push(`${gutter}${pad(h.line, width)} ${cut(`${'#'.repeat(h.level)} ${h.heading}`)}${phaseNote(h.phase)}`);
179
+ }
180
+ if (brief.outlineOmitted) out.push(` (+${brief.outlineOmitted} deeper headings not shown; a --section read shows them)`);
181
+ const total = brief.boxes.open + brief.boxes.done;
182
+ if (total) {
183
+ out.push(`Open boxes: ${brief.boxes.open} of ${total} (line, text)`);
184
+ // Grouped under the phase each box sits in, so the list reads as what is
185
+ // left, phase by phase.
186
+ let group;
187
+ for (const b of brief.open) {
188
+ if (b.phase !== group && (b.phase || group)) out.push(` ${b.phase ? cut(b.phase) : '(not in a phase)'}`);
189
+ group = b.phase;
190
+ out.push(`${group ? ' ' : ' '}${pad(b.line, width)} ${cut(b.text)}`);
191
+ }
192
+ if (brief.openOmitted) out.push(` (+${brief.openOmitted} more: add --all)`);
193
+ }
194
+ if (brief.frontmatter) {
195
+ out.push(`Frontmatter (lines 1 to ${brief.frontmatterLines}):`, ...brief.frontmatter.map(l => ` ${l}`));
196
+ } else if (card.otherFields?.length) {
197
+ out.push(`Other fields: ${card.otherFields.join(', ')} (--frontmatter prints them)`);
198
+ }
199
+ return out;
200
+ }
201
+
79
202
  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]');
203
+ const opts = parseShowArgs(args);
204
+ if (!opts.targets.length) die('Usage: runlist show <file...> [--section "<heading>" [--from <line>]] [--all] [--card] [--frontmatter] [--json]');
205
+ if (opts.section != null) {
206
+ if (opts.targets.length > 1) die('--section reads one file; name one');
207
+ runSection(opts.targets[0], opts, config);
208
+ return;
209
+ }
210
+ const limits = opts.targets.length > 1 ? SURVEY_LIMITS : LIMITS;
83
211
  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' });
212
+ for (const t of opts.targets) {
213
+ const abs = resolveDocArg(t, config, { dieOnMiss: !opts.json });
214
+ if (!abs) { cards.push({ path: t, error: 'not found' }); continue; }
215
+ const card = showDoc(abs, config);
216
+ if (!opts.card) {
217
+ const raw = readFileSync(abs, 'utf8');
218
+ card.brief = buildBrief(raw, { all: opts.all, limits });
219
+ if (opts.frontmatter) card.brief.frontmatter = collapseFrontmatter(extractFrontmatter(normalizeEol(raw)).frontmatter, limits);
220
+ card.otherFields = otherFields(abs, config);
221
+ }
222
+ cards.push(card);
87
223
  }
88
- if (json) {
224
+ if (opts.json) {
89
225
  process.stdout.write(`${JSON.stringify(cards, null, 2)}\n`);
90
226
  return;
91
227
  }
92
228
  cards.forEach((c, n) => {
93
229
  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`);
230
+ const out = [`${c.title} (${c.status ?? 'no status'})`, c.path];
231
+ if (c.nextStep) out.push(`Next: ${c.nextStep}`);
232
+ for (const b of c.blockers) out.push(`Blocker: ${b}`);
233
+ // The brief's open-box line carries the same count, read outside code fences.
234
+ if (c.checklist?.total && !c.brief) out.push(`Checklist: ${c.checklist.completed} of ${c.checklist.total} done`);
235
+ for (const r of c.related) out.push(`${r.field}: ${r.path ?? `${r.ref} (missing)`}${r.status ? ` (${r.status})` : ''}`);
236
+ if (c.links.length) out.push(`Linked from the body: ${c.links.map(l => l.path).join(', ')}`);
237
+ if (c.brief) out.push(...renderBrief(c, c.brief));
238
+ process.stdout.write(out.join('\n') + '\n');
100
239
  });
240
+ if (!opts.card) {
241
+ const active = cards.length === 1 ? cards[0].brief?.activePhase : null;
242
+ const file = cards.length === 1 ? shellQuote(cards[0].path) : '<file>';
243
+ process.stdout.write(active
244
+ ? `\nNext: runlist show ${file} --section ${shellQuote(sectionQuery(active.heading))}\n`
245
+ : `\nOne section: runlist show ${file} --section "<heading>"\n`);
246
+ }
101
247
  }
package/src/use.mjs CHANGED
@@ -5,13 +5,15 @@ import { asString, die, resolveDocPath, toRepoPath } from './util.mjs';
5
5
  import { consumePrompt, pendingPromptsOldestFirst, resolvePromptInput } from './prompts.mjs';
6
6
  import { startPlan } from './lifecycle.mjs';
7
7
  import { resolveDocArg } from './index.mjs';
8
+ import { LIMITS } from './brief.mjs';
9
+ import { runShow } from './show.mjs';
8
10
 
9
11
  // Top-level `runlist use [file]` — the single "start engaging with this doc"
10
12
  // verb. Dispatches by the target doc's `type:` so agents don't have to know
11
13
  // the verb-per-type rule:
12
14
  // - prompt → print body + archive (one-shot consume)
13
15
  // - plan → set status in-session + print the plan card
14
- // - doc → print body (read-only)
16
+ // - doc → print body (read-only); a long doc prints its brief, --full the body
15
17
  // With no argument, consumes the oldest pending prompt.
16
18
  export async function runUse(argv, config, opts = {}) {
17
19
  const positional = argv.find(a => !a.startsWith('-'));
@@ -51,8 +53,17 @@ export async function runUse(argv, config, opts = {}) {
51
53
  }
52
54
  // Anything else (doc, untyped, custom): print the body. The frontmatter
53
55
  // already names everything an agent needs to know about lifecycle for that
54
- // type, so the verb stays a no-op read for non-actionable types.
56
+ // type, so the verb stays a no-op read for non-actionable types. A doc past
57
+ // the section-read caps prints its brief instead: printing a 2,000-line spec
58
+ // whole is the read `runlist show` exists to replace.
59
+ const rel = toRepoPath(filePath, config.repoRoot);
60
+ const lines = raw.split('\n').length - (raw.endsWith('\n') ? 1 : 0);
61
+ if (!argv.includes('--full') && (lines > LIMITS.sectionLines || Buffer.byteLength(raw) > LIMITS.sectionBytes)) {
62
+ runShow([rel], config);
63
+ process.stderr.write(`${rel} (${type ?? 'untyped'}) is ${lines} lines, so this is its brief. \`runlist use ${rel} --full\` prints it whole.\n`);
64
+ return;
65
+ }
55
66
  process.stdout.write(raw);
56
67
  if (!raw.endsWith('\n')) process.stdout.write('\n');
57
- process.stderr.write(`${toRepoPath(filePath, config.repoRoot)} (${type ?? 'untyped'})\n`);
68
+ process.stderr.write(`${rel} (${type ?? 'untyped'})\n`);
58
69
  }
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;