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 +26 -38
- package/bin/runlist.mjs +27 -13
- package/package.json +1 -1
- package/plugins/runlist-codex/.codex-plugin/plugin.json +1 -1
- package/src/baton.mjs +1 -6
- package/src/brief.mjs +145 -0
- package/src/commands.mjs +1 -1
- package/src/extractors.mjs +7 -14
- package/src/flags.mjs +5 -3
- package/src/hud.mjs +31 -63
- package/src/section.mjs +46 -11
- package/src/show.mjs +132 -16
- package/src/util.mjs +7 -0
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.
|
|
75
|
-
[Mac installer and release notes](https://github.com/reowens/runlist/releases/tag/desktop-v0.
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
[
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
1.
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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...> [--
|
|
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...> —
|
|
1575
|
-
|
|
1576
|
-
runlist show <file> title, status, next step, blockers,
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
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
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
|
]),
|
package/src/extractors.mjs
CHANGED
|
@@ -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
|
|
85
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
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
|
-
'
|
|
297
|
-
'
|
|
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
|
-
//
|
|
419
|
-
//
|
|
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
|
-
|
|
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:
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
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
|
-
//
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
81
|
-
|
|
82
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
if (c.nextStep)
|
|
96
|
-
for (const b of c.blockers)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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;
|