runlist 0.97.0 → 0.98.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.97.0**.
75
- [Mac installer and release notes](https://github.com/reowens/runlist/releases/tag/desktop-v0.97.0).
74
+ CLI and desktop release version: **0.98.1**.
75
+ [Mac installer and release notes](https://github.com/reowens/runlist/releases/tag/desktop-v0.98.1).
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,11 +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
- **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)
108
+ **0.98.0 is published and installed locally.** `runlist show` briefs a plan
109
+ (outline, open boxes and `--section` reads with line numbers), marks each
110
+ phase's tally and the active phase, and groups open boxes by phase;
111
+ `runlist use <doc>` briefs a doc over 500 lines. The agent workflow says to
112
+ read plans that way. The desktop app's own code is unchanged from 0.96.1; it
113
+ bundles the 0.98.0 engine. The Mac installer is signed, notarized and verified; installation
114
+ retained the previous app for rollback. Open it with `rl desktop`. See the
115
+ [release validation](desktop/VALIDATION.md#current-mac-release--2026-10-22)
113
116
  for checks and evidence. Full-app dialog accessibility remains an open finding;
114
117
  fresh/minimum-host and controlled whole-app performance qualification remain
115
118
  follow-up work.
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)
@@ -1593,6 +1594,15 @@ substring; other matches are named. A section read stops at 500 lines or
1593
1594
  60 KB and prints the command that continues it. Headings and boxes inside code
1594
1595
  fences are ignored. Several files at once get a shorter outline each.
1595
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
+
1596
1606
  A file is a path, filename or slug, as for \`use\`, and any Markdown file in
1597
1607
  the repo works. Nothing is claimed or written. With --json a file that is not
1598
1608
  found is { path, error } rather than an exit; a heading that matches nothing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runlist",
3
- "version": "0.97.0",
3
+ "version": "0.98.1",
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.97.0",
3
+ "version": "0.98.1",
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/brief.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { extractFrontmatter, normalizeEol } from './frontmatter.mjs';
2
- import { walkSections, matchSections, walkChecklist } from './section.mjs';
2
+ import { detectMarker, findActivePhase, isPhaseHeading, matchSections, phaseMarkerConflict, phaseTally, walkChecklist, walkSections } from './section.mjs';
3
3
 
4
4
  // A document in a few KB, for an agent to read in place of the whole file: a
5
5
  // heading outline and the open checklist boxes, both with file line numbers,
@@ -16,7 +16,7 @@ export const LIMITS = {
16
16
  };
17
17
 
18
18
  // Several files at once is a survey; each one gets less.
19
- export const SURVEY_LIMITS = { ...LIMITS, outline: 20, boxes: 10 };
19
+ export const SURVEY_LIMITS = { ...LIMITS, outline: 20, boxes: 10, listItems: 4 };
20
20
 
21
21
  export const cut = (text, width = LIMITS.width) =>
22
22
  text.length > width ? `${text.slice(0, width - 3)}...` : text;
@@ -30,13 +30,39 @@ function fileLines(text) {
30
30
 
31
31
  // Headings with file coordinates: `line` is the heading, `end` the last line of
32
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.
33
36
  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
- }));
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;
40
66
  }
41
67
 
42
68
  // Drop the deepest heading levels until the outline fits, then cut the tail.
@@ -91,7 +117,9 @@ export function buildBrief(raw, { all = false, limits = LIMITS } = {}) {
91
117
  const headings = headingsOf(body, bodyLineOffset, lines.length);
92
118
  const { shown, omitted } = boundOutline(headings, limits.outline);
93
119
  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 }));
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;
95
123
  const listed = all ? open : open.slice(0, limits.boxes);
96
124
  return {
97
125
  lines: lines.length,
@@ -99,6 +127,7 @@ export function buildBrief(raw, { all = false, limits = LIMITS } = {}) {
99
127
  frontmatterLines: frontmatter ? bodyLineOffset : 0,
100
128
  outline: shown,
101
129
  outlineOmitted: omitted,
130
+ activePhase: active ? { line: active.line, heading: active.heading } : null,
102
131
  boxes: { open: open.length, done: boxes.length - open.length },
103
132
  open: listed,
104
133
  openOmitted: open.length - listed.length,
package/src/show.mjs CHANGED
@@ -79,6 +79,9 @@ export function showDoc(abs, config) {
79
79
  };
80
80
  }
81
81
 
82
+ // A long next step is cut in the brief, as the pickup card cuts it.
83
+ const NEXT_STEP_WIDTH = 300;
84
+
82
85
  // Fields the text card prints. Everything else non-empty is named on one line
83
86
  // so a reader knows it is there without printing the frontmatter.
84
87
  const CARD_FIELDS = new Set(['title', 'status', 'next_step', 'blockers']);
@@ -150,18 +153,45 @@ function runSection(target, opts, config) {
150
153
  process.stdout.write(out.join('\n') + '\n');
151
154
  }
152
155
 
156
+ // `[3/7]` for a phase with boxes, and the contradiction between its marker and
157
+ // its boxes when there is one.
158
+ function phaseNote(phase) {
159
+ if (!phase) return '';
160
+ let note = phase.total ? ` [${phase.done}/${phase.total}]` : '';
161
+ if (phase.conflict) {
162
+ note += phase.conflict.declared === 'shipped'
163
+ ? ` (marked shipped; ${phase.total - phase.done} open)`
164
+ : ' (marked todo; every box checked)';
165
+ }
166
+ return note;
167
+ }
168
+
169
+ // A heading as a --section query: trailing markers dropped, which the matcher
170
+ // ignores anyway, so the suggested command reads cleanly.
171
+ const sectionQuery = heading => heading.replace(/[^\w\s]+$/u, '').trim() || heading;
172
+
153
173
  function renderBrief(card, brief) {
154
174
  const out = [];
155
175
  const width = String(brief.lines).length;
156
176
  const kb = Math.max(1, Math.round(brief.bytes / 1024));
157
177
  out.push('', `Outline: ${brief.lines.toLocaleString('en-US')} lines, ${kb} KB (line, heading)`);
158
178
  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}`)}`);
179
+ for (const h of brief.outline) {
180
+ const gutter = h.phase?.active ? '→ ' : ' ';
181
+ out.push(`${gutter}${pad(h.line, width)} ${cut(`${'#'.repeat(h.level)} ${h.heading}`)}${phaseNote(h.phase)}`);
182
+ }
160
183
  if (brief.outlineOmitted) out.push(` (+${brief.outlineOmitted} deeper headings not shown; a --section read shows them)`);
161
184
  const total = brief.boxes.open + brief.boxes.done;
162
185
  if (total) {
163
186
  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)}`);
187
+ // Grouped under the phase each box sits in, so the list reads as what is
188
+ // left, phase by phase.
189
+ let group;
190
+ for (const b of brief.open) {
191
+ if (b.phase !== group && (b.phase || group)) out.push(` ${b.phase ? cut(b.phase) : '(not in a phase)'}`);
192
+ group = b.phase;
193
+ out.push(`${group ? ' ' : ' '}${pad(b.line, width)} ${cut(b.text)}`);
194
+ }
165
195
  if (brief.openOmitted) out.push(` (+${brief.openOmitted} more: add --all)`);
166
196
  }
167
197
  if (brief.frontmatter) {
@@ -201,17 +231,33 @@ export function runShow(args, config) {
201
231
  cards.forEach((c, n) => {
202
232
  if (n) process.stdout.write('\n');
203
233
  const out = [`${c.title} (${c.status ?? 'no status'})`, c.path];
204
- if (c.nextStep) out.push(`Next: ${c.nextStep}`);
234
+ if (c.nextStep) out.push(`Next: ${c.brief ? cut(c.nextStep, NEXT_STEP_WIDTH) : c.nextStep}`);
205
235
  for (const b of c.blockers) out.push(`Blocker: ${b}`);
206
236
  // The brief's open-box line carries the same count, read outside code fences.
207
237
  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(', ')}`);
238
+ // With the brief, each related field and the body links show their first
239
+ // few entries and a count: a hub's dozens of related plans outweighed the
240
+ // brief itself. --card and --json list every one.
241
+ const cap = c.brief ? limits.listItems : Infinity;
242
+ const perField = new Map();
243
+ for (const r of c.related) {
244
+ const n = (perField.get(r.field) ?? 0) + 1;
245
+ perField.set(r.field, n);
246
+ if (n <= cap) out.push(`${r.field}: ${r.path ?? `${r.ref} (missing)`}${r.status ? ` (${r.status})` : ''}`);
247
+ }
248
+ for (const [field, n] of perField) if (n > cap) out.push(`${field}: +${n - cap} more (--card lists all)`);
249
+ if (c.links.length) {
250
+ const shown = c.links.slice(0, cap).map(l => l.path).join(', ');
251
+ out.push(`Linked from the body: ${shown}${c.links.length > cap ? ` (+${c.links.length - cap} more)` : ''}`);
252
+ }
210
253
  if (c.brief) out.push(...renderBrief(c, c.brief));
211
254
  process.stdout.write(out.join('\n') + '\n');
212
255
  });
213
256
  if (!opts.card) {
257
+ const active = cards.length === 1 ? cards[0].brief?.activePhase : null;
214
258
  const file = cards.length === 1 ? shellQuote(cards[0].path) : '<file>';
215
- process.stdout.write(`\nOne section: runlist show ${file} --section "<heading>"\n`);
259
+ process.stdout.write(active
260
+ ? `\nNext: runlist show ${file} --section ${shellQuote(sectionQuery(active.heading))}\n`
261
+ : `\nOne section: runlist show ${file} --section "<heading>"\n`);
216
262
  }
217
263
  }
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
  }