dotmd-cli 0.84.0 → 0.85.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
@@ -259,6 +259,29 @@ The register block is the fenced block whose first line carries `statusLine`.
259
259
  The row is the question plus `--answers` (what each answer leaves in place),
260
260
  which is required when a register is configured.
261
261
 
262
+ ## Flags
263
+
264
+ A flag is something someone found that the person should know about when they
265
+ come back: a plan that contradicts another, a decision open in one place and
266
+ ruled in another, a citation that no longer says what it claims. Any session,
267
+ person or check can add one, and no model is needed:
268
+
269
+ ```bash
270
+ runlist flag add docs/plans/auth.md:42 "says tokens expire in 1h; the spec says 24h" --severity problem
271
+ runlist flags # open flags, problems first, newest first
272
+ runlist flag accept F3 --note "real, owner agrees"
273
+ runlist flag reject F4 # not a problem; closed
274
+ runlist flag resolve F3 # fixed
275
+ runlist check --flag # the check's errors become flags, attributed to it
276
+ ```
277
+
278
+ Each flag keeps the text of the line it points at, so the list says when that
279
+ line has moved or changed since. A repeat of an open flag on the same place is
280
+ merged. The log is append-only, `.runlist/flags.jsonl` by default
281
+ (`export const flags = { file }` moves it), and triage is recorded as events
282
+ beside the flag, never over it. Every session start shows a count and the top
283
+ open flags.
284
+
262
285
  ## Safety Model
263
286
 
264
287
  - Mutation commands support `--dry-run` / `-n`.
package/bin/dotmd.mjs CHANGED
@@ -1895,6 +1895,8 @@ async function main() {
1895
1895
  if (command === 'deps') { const { runDeps } = await import('../src/deps.mjs'); runDeps(restArgs, config); return; }
1896
1896
  if (command === 'unblocks') { const { runUnblocks } = await import('../src/deps.mjs'); runUnblocks(restArgs, config); return; }
1897
1897
  if (command === 'health') { const { runHealth } = await import('../src/health.mjs'); runHealth(restArgs, config); return; }
1898
+ if (command === 'flags') { const { runFlags } = await import('../src/flags.mjs'); runFlags(restArgs, config); return; }
1899
+ if (command === 'flag') { const { runFlag } = await import('../src/flags.mjs'); runFlag(restArgs, config); return; }
1898
1900
  if (command === 'glossary') { const { runGlossary } = await import('../src/glossary.mjs'); runGlossary(restArgs, config); return; }
1899
1901
  if (command === 'export') { const { runExport } = await import('../src/export.mjs'); runExport(restArgs, config, { dryRun, root: rootArg, type: typeArg }); return; }
1900
1902
 
@@ -2014,6 +2016,17 @@ async function main() {
2014
2016
  ? ['validate', 'transformDoc', 'formatSnapshot', 'renderCheck']
2015
2017
  .filter(name => typeof config.hooks?.[name] === 'function')
2016
2018
  : [];
2019
+ // `--flag` puts each error on the flags list, attributed to this check, and
2020
+ // resolves the check's earlier flags it no longer reports. Whole-repo runs
2021
+ // only: a scoped run cannot tell a fixed error from one outside its scope.
2022
+ const flagCheckErrors = async (checkIndex) => {
2023
+ if (!args.includes('--flag') || dryRun) return;
2024
+ if (checkTargets.length > 0) die('`--flag` runs on the whole repository; drop the path arguments.');
2025
+ const { syncCheckFlags } = await import('../src/flags.mjs');
2026
+ const findings = checkIndex.errors.filter(e => e.path).map(e => ({ file: e.path, text: e.message }));
2027
+ const { added, resolved } = syncCheckFlags(config, 'runlist check', findings);
2028
+ process.stderr.write(`flags: ${added} added, ${resolved} resolved\n`);
2029
+ };
2017
2030
  const checkJson = (checkIndex) => {
2018
2031
  const builtInPassed = checkIndex.errors.length === 0;
2019
2032
  const complete = skippedCheckHooks.length === 0;
@@ -2068,6 +2081,7 @@ async function main() {
2068
2081
  applyIndexFilters(freshIndex);
2069
2082
  applyPathScopeToIndex(freshIndex, config, checkTargets);
2070
2083
  applyFloor(freshIndex);
2084
+ await flagCheckErrors(freshIndex);
2071
2085
  if (args.includes('--json')) {
2072
2086
  process.stdout.write(JSON.stringify(checkJson(freshIndex), null, 2) + '\n');
2073
2087
  } else {
@@ -2080,6 +2094,7 @@ async function main() {
2080
2094
 
2081
2095
  applyPathScopeToIndex(index, config, checkTargets);
2082
2096
  applyFloor(index);
2097
+ await flagCheckErrors(index);
2083
2098
 
2084
2099
  if (args.includes('--json')) {
2085
2100
  process.stdout.write(JSON.stringify(checkJson(index), null, 2) + '\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.84.0",
3
+ "version": "0.85.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",
package/src/commands.mjs CHANGED
@@ -78,6 +78,15 @@ const definitions = [
78
78
  command('summary', none, 'read', [form('<file>', { args: positionals(1, 1), options: [value('--model'), value('--max-tokens'), flag('--json')] })]),
79
79
  command('unblocks', none, 'read', [form('<file>', { args: positionals(1, 1), options: [flag('--json')] })]),
80
80
  command('health', none, 'read', [form('', { options: [flag('--json')] })]),
81
+ command('flags', none, 'read', [form('', { options: [flag('--all'), flag('--json')] })]),
82
+ command('flag', mutates('the flags log under the state directory'), 'mutate', [
83
+ form('add <file[:line]> <text...>', { subcommands: ['add'], args: positionals(2, Infinity), options: [value('--severity'), value('--by')] }),
84
+ form('accept <id>', { subcommands: ['accept'], args: positionals(1, 1), options: [value('--note'), value('--by')] }),
85
+ form('reject <id>', { subcommands: ['reject'], args: positionals(1, 1), options: [value('--note'), value('--by')] }),
86
+ form('resolve <id>', { subcommands: ['resolve'], args: positionals(1, 1), options: [value('--note'), value('--by')] }),
87
+ form('show <id>', { subcommands: ['show'], args: positionals(1, 1) }),
88
+ form('sync <check-name> [findings]', { subcommands: ['sync'], args: positionals(1, 2), dashPositionalsAfter: 1 }),
89
+ ]),
81
90
  command('glossary', none, 'read', [form('[term]', { args: positionals(0, 1), options: [flag('--list'), flag('--json')] })]),
82
91
  command('modules', none, 'read', [form('', { options: [value('--sort'), value('--limit'), flag('--all'), flag('--json')] })]),
83
92
  command('module', none, 'read', [form('<name>', { args: positionals(1, 1), options: [value('--sort'), flag('--json')] })]),
@@ -129,7 +138,7 @@ const definitions = [
129
138
  command('touch', mutates('managed source or managed source sweep'), 'mutate', [form('[file...]', { args: positionals(0, Infinity), options: [flag('--git')] })]),
130
139
  command('new', mutates('managed document destination; external body input unrestricted'), 'mutate', [form('[type] <name> [body...]', {
131
140
  args: positionals(0, Infinity),
132
- options: [value('--status'), value('--title'), value('--runlist'), flag('--coordination'), flag('--roadmap'), flag('--lite', '--minimal'), flag('--audit', '--findings'), value('--body', '--message'), value('--root'), flag('--show-files'), flag('--list-templates', '--list-types')],
141
+ options: [value('--status'), value('--title'), value('--runlist'), flag('--coordination'), flag('--roadmap'), flag('--lite', '--minimal'), flag('--audit', '--findings'), value('--body', '--message'), value('--root'), flag('--show-files'), flag('--list-templates', '--list-types'), value('--question'), value('--answers'), value('--disposition')],
133
142
  dashPositionalsAfter: 1,
134
143
  })]),
135
144
  command('lint', mutates('managed source sweep with --fix; otherwise read-only'), 'mutate', [form('', { options: [flag('--fix')] })]),
@@ -151,7 +160,7 @@ const definitions = [
151
160
  form('migrate <type>', { subcommands: ['migrate'], args: positionals(1, 1), options: [flag('--yes', '-y'), flag('--json'), flag('--ignore-lifecycle-override')] }),
152
161
  form('', { options: [value('--type'), flag('--json')] }),
153
162
  ]),
154
- command('check', mutates('managed fix sweeps and repo-generated index; otherwise validation'), 'mutate', [form('[paths...]', { args: positionals(0, Infinity), options: [flag('--fix'), flag('--errors-only'), flag('--no-collapse'), flag('--json'), flag('--verbose'), value('--min-docs')] })]),
163
+ command('check', mutates('managed fix sweeps and repo-generated index; otherwise validation'), 'mutate', [form('[paths...]', { args: positionals(0, Infinity), options: [flag('--fix'), flag('--errors-only'), flag('--no-collapse'), flag('--json'), flag('--verbose'), value('--min-docs'), flag('--flag')] })]),
155
164
  command('index', mutates('repo-generated index destination; --print is read-only'), 'mutate', [form('', { options: [flag('--print')] })]),
156
165
 
157
166
  command('self-check', none, 'internal', [form('', { options: [flag('--json')] })], { visibility: 'internal' }),
package/src/config.mjs CHANGED
@@ -139,6 +139,8 @@ const DEFAULTS = {
139
139
  glossary: null,
140
140
  // `runlist new decision`: { section, prefix, register: { file, statusLine } }.
141
141
  decisions: null,
142
+ // `runlist flag` / `runlist flags`: { file } moves the log from .runlist/flags.jsonl.
143
+ flags: null,
142
144
 
143
145
  // Opt-in JSONL command journal at .dotmd/journal.jsonl. Default off — agents
144
146
  // and users who want usage observability flip this on (or set RUNLIST_JOURNAL=1).
package/src/flags.mjs ADDED
@@ -0,0 +1,299 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { withPathLocks } from './atomic-mutation.mjs';
5
+ import { stateDir } from './naming.mjs';
6
+ import { die, hostSessionSource, nowIso, toRepoPath } from './util.mjs';
7
+ import { bold, dim, green, red, yellow } from './color.mjs';
8
+
9
+ // Flags: what anyone found that the person should know when they come back —
10
+ // a plan contradicting another, a decision open in one place and ruled in
11
+ // another, a citation that no longer says what it claims. A session, a person
12
+ // or a check adds one; no model is needed. A later pass may build context onto
13
+ // an open flag, but the flag stands on what its author wrote.
14
+ //
15
+ // Storage is an append-only event log (`add`, `accept`, `reject`, `resolve`),
16
+ // one JSON object per line, so nothing is overwritten and the triage record
17
+ // survives as written. The current state of a flag is derived from its events.
18
+ // Default path `.runlist/flags.jsonl`; `export const flags = { file }` moves it.
19
+
20
+ export const SEVERITIES = ['problem', 'warn', 'info'];
21
+ const TRIAGE = new Set(['accept', 'reject', 'resolve']);
22
+ const QUOTE_MAX = 200;
23
+
24
+ export function flagsFile(config) {
25
+ const configured = config.raw?.flags?.file;
26
+ return configured ? path.resolve(config.repoRoot, configured) : path.join(stateDir(config.repoRoot), 'flags.jsonl');
27
+ }
28
+
29
+ export function readFlagEvents(file) {
30
+ if (!existsSync(file)) return [];
31
+ const events = [];
32
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
33
+ if (!line.trim()) continue;
34
+ try { events.push(JSON.parse(line)); } catch { /* a torn line is skipped, never fatal */ }
35
+ }
36
+ return events;
37
+ }
38
+
39
+ export function deriveFlags(events) {
40
+ const flags = new Map();
41
+ for (const e of events) {
42
+ if (e.event === 'add' && e.id && !flags.has(e.id)) {
43
+ flags.set(e.id, { ...e, state: 'open', triage: null, history: [] });
44
+ continue;
45
+ }
46
+ const flag = flags.get(e.id);
47
+ if (!flag || !TRIAGE.has(e.event)) continue;
48
+ flag.history.push({ event: e.event, at: e.at, by: e.by, note: e.note ?? null });
49
+ if (e.event === 'accept') flag.triage = 'accepted';
50
+ if (e.event === 'reject') { flag.triage = 'rejected'; flag.state = 'closed'; }
51
+ if (e.event === 'resolve') flag.state = 'resolved';
52
+ }
53
+ return [...flags.values()];
54
+ }
55
+
56
+ function nextId(flags) {
57
+ let max = 0;
58
+ for (const f of flags) max = Math.max(max, Number(String(f.id).replace(/^F/, '')) || 0);
59
+ return `F${max + 1}`;
60
+ }
61
+
62
+ // `--by check:<name>`, `--by model:<name>`, `--by person:<name>`; otherwise the
63
+ // session the environment names, or the person at the keyboard.
64
+ export function resolveAuthor(byArg, env = process.env) {
65
+ if (byArg) {
66
+ const m = byArg.match(/^(session|person|check|model):(.+)$/);
67
+ if (!m) die('--by is <kind>:<name>, where kind is session, person, check or model.');
68
+ return { kind: m[1], name: m[2].trim() };
69
+ }
70
+ const source = hostSessionSource(env);
71
+ if (source?.scope === 'session') return { kind: 'session', name: source.host, session: source.id };
72
+ return { kind: 'person', name: os.userInfo().username };
73
+ }
74
+
75
+ export function normalizeText(text) {
76
+ return text.replace(/\s+/g, ' ').trim().toLowerCase();
77
+ }
78
+
79
+ // `docs/plans/x.md:12` → file and line; the file must exist in the repo and
80
+ // the line must be inside it. The line's text is kept, so a later read can say
81
+ // whether the place still says what was flagged.
82
+ export function resolvePlace(place, config) {
83
+ const m = place.match(/^(.*?)(?::(\d+))?$/);
84
+ const rel = m[1];
85
+ const line = m[2] ? Number(m[2]) : null;
86
+ const abs = path.resolve(config.repoRoot, rel);
87
+ if (!existsSync(abs)) die(`No such file: ${rel}`);
88
+ const repoPath = toRepoPath(abs, config.repoRoot);
89
+ if (repoPath.startsWith('..')) die(`${rel} is outside the repository.`);
90
+ let quote = null;
91
+ if (line !== null) {
92
+ const lines = readFileSync(abs, 'utf8').split('\n');
93
+ if (line < 1 || line > lines.length) die(`${repoPath} has ${lines.length} lines; there is no line ${line}.`);
94
+ quote = lines[line - 1].trim().slice(0, QUOTE_MAX);
95
+ }
96
+ return { file: repoPath, line, quote };
97
+ }
98
+
99
+ // Where the flagged text is now: unchanged, moved to another line, or gone.
100
+ export function locateFlag(flag, config) {
101
+ if (flag.line == null) return { status: existsSync(path.resolve(config.repoRoot, flag.file)) ? 'here' : 'gone' };
102
+ let lines;
103
+ try { lines = readFileSync(path.resolve(config.repoRoot, flag.file), 'utf8').split('\n'); }
104
+ catch { return { status: 'gone' }; }
105
+ if ((lines[flag.line - 1] ?? '').trim().slice(0, QUOTE_MAX) === flag.quote) return { status: 'here', line: flag.line };
106
+ if (flag.quote) {
107
+ const at = lines.findIndex(l => l.trim().slice(0, QUOTE_MAX) === flag.quote);
108
+ if (at !== -1) return { status: 'moved', line: at + 1 };
109
+ }
110
+ return { status: 'changed' };
111
+ }
112
+
113
+ function withFlagsLock(config, fn) {
114
+ const file = flagsFile(config);
115
+ const dir = path.dirname(file);
116
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
117
+ return withPathLocks([file], { repoRoot: config.repoRoot }, () => fn(file));
118
+ }
119
+
120
+ function append(file, event) {
121
+ appendFileSync(file, `${JSON.stringify(event)}\n`, { flag: 'a' });
122
+ }
123
+
124
+ export function addFlag(config, { place, text, severity = 'warn', by = null }) {
125
+ if (!text || !text.trim()) die('A flag says what is wrong: runlist flag add <file[:line]> "<what is wrong>"');
126
+ if (!SEVERITIES.includes(severity)) die(`--severity is one of ${SEVERITIES.join(', ')}.`);
127
+ const where = resolvePlace(place, config);
128
+ const author = typeof by === 'object' && by ? by : resolveAuthor(by);
129
+ return withFlagsLock(config, file => {
130
+ const flags = deriveFlags(readFlagEvents(file));
131
+ const key = normalizeText(text);
132
+ const same = flags.find(f => f.state === 'open' && f.file === where.file && f.line === where.line && normalizeText(f.text) === key);
133
+ if (same) return { flag: same, added: false };
134
+ const event = { event: 'add', id: nextId(flags), at: nowIso(), ...where, text: text.trim(), severity, by: author };
135
+ append(file, event);
136
+ return { flag: { ...event, state: 'open', triage: null, history: [] }, added: true };
137
+ });
138
+ }
139
+
140
+ export function triageFlag(config, { id, event, note = null, by = null }) {
141
+ const author = typeof by === 'object' && by ? by : resolveAuthor(by);
142
+ return withFlagsLock(config, file => {
143
+ const flag = deriveFlags(readFlagEvents(file)).find(f => f.id === id);
144
+ if (!flag) die(`No flag ${id}.`);
145
+ if (flag.state !== 'open') die(`${id} is already ${flag.state === 'closed' ? 'rejected' : flag.state}.`);
146
+ append(file, { event, id, at: nowIso(), by: author, ...(note ? { note } : {}) });
147
+ return flag;
148
+ });
149
+ }
150
+
151
+ export function openFlags(config) {
152
+ const rank = f => SEVERITIES.indexOf(f.severity);
153
+ return deriveFlags(readFlagEvents(flagsFile(config)))
154
+ .filter(f => f.state === 'open')
155
+ .sort((a, b) => rank(a) - rank(b) || String(b.at).localeCompare(String(a.at)));
156
+ }
157
+
158
+ // A check's flags follow what the check reports: each error it reports is
159
+ // flagged once, and a flag it raised earlier that it no longer reports is
160
+ // resolved, so the list never holds a problem the check has stopped seeing.
161
+ // A finding is matched to its open flag on file and text, not line: a check
162
+ // reports the same problem at a new line after an edit above it, and that is
163
+ // still one flag.
164
+ export function syncCheckFlags(config, checkName, findings) {
165
+ const by = { kind: 'check', name: checkName };
166
+ const keyOf = f => `${f.file}\0${normalizeText(f.text)}`;
167
+ const current = new Set(findings.map(keyOf));
168
+ const open = new Set(openFlags(config).filter(f => f.by?.kind === 'check' && f.by?.name === checkName).map(keyOf));
169
+ let added = 0;
170
+ let resolved = 0;
171
+ for (const finding of findings) {
172
+ if (open.has(keyOf(finding))) continue;
173
+ if (!existsSync(path.resolve(config.repoRoot, finding.file))) continue;
174
+ const place = finding.line ? `${finding.file}:${finding.line}` : finding.file;
175
+ const severity = SEVERITIES.includes(finding.severity) ? finding.severity : 'problem';
176
+ try {
177
+ if (addFlag(config, { place, text: finding.text, severity, by }).added) { added++; open.add(keyOf(finding)); }
178
+ } catch { /* a line the file no longer has: the next run reports it again */ }
179
+ }
180
+ for (const flag of openFlags(config)) {
181
+ if (flag.by?.kind !== 'check' || flag.by?.name !== checkName) continue;
182
+ if (current.has(`${flag.file}\0${normalizeText(flag.text)}`)) continue;
183
+ triageFlag(config, { id: flag.id, event: 'resolve', note: `no longer reported by ${checkName}`, by });
184
+ resolved++;
185
+ }
186
+ return { added, resolved };
187
+ }
188
+
189
+ function authorLabel(by) {
190
+ if (!by) return 'unknown';
191
+ return by.kind === 'session' ? `${by.name} session ${String(by.session ?? '').slice(0, 8)}` : `${by.kind} ${by.name}`;
192
+ }
193
+
194
+ function placeLabel(flag, config) {
195
+ const where = locateFlag(flag, config);
196
+ const base = flag.line != null ? `${flag.file}:${flag.line}` : flag.file;
197
+ if (where.status === 'moved') return `${base} ${dim(`(now line ${where.line})`)}`;
198
+ if (where.status === 'changed') return `${base} ${yellow('(text there has changed)')}`;
199
+ if (where.status === 'gone') return `${base} ${yellow('(file gone)')}`;
200
+ return base;
201
+ }
202
+
203
+ const severityLabel = s => (s === 'problem' ? red(s) : s === 'warn' ? yellow(s) : dim(s));
204
+
205
+ // One line for the session-start banner, or null when nothing is open.
206
+ export function flagsHudLine(config, { top = 3 } = {}) {
207
+ let open;
208
+ try { open = openFlags(config); } catch { return null; }
209
+ if (open.length === 0) return null;
210
+ const problems = open.filter(f => f.severity === 'problem').length;
211
+ 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('; ');
212
+ return `[runlist] ${open.length} open flag${open.length === 1 ? '' : 's'}${problems ? `, ${problems} problem${problems === 1 ? '' : 's'}` : ''}, for awareness: ${items}. List: \`runlist flags\`.`;
213
+ }
214
+
215
+ export function runFlags(argv, config) {
216
+ const json = argv.includes('--json');
217
+ const all = argv.includes('--all');
218
+ const events = readFlagEvents(flagsFile(config));
219
+ const flags = deriveFlags(events)
220
+ .filter(f => all || f.state === 'open')
221
+ .sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity) || String(b.at).localeCompare(String(a.at)));
222
+ if (json) {
223
+ process.stdout.write(`${JSON.stringify(flags.map(f => ({ ...f, location: locateFlag(f, config) })), null, 2)}\n`);
224
+ return;
225
+ }
226
+ if (flags.length === 0) {
227
+ process.stdout.write(dim(all ? 'No flags.\n' : 'No open flags.\n'));
228
+ return;
229
+ }
230
+ for (const f of flags) {
231
+ const state = f.state === 'open' ? (f.triage === 'accepted' ? green('accepted') : '') : dim(f.state === 'closed' ? 'rejected' : f.state);
232
+ process.stdout.write(`${bold(f.id)} ${severityLabel(f.severity)} ${placeLabel(f, config)} ${state}\n`);
233
+ process.stdout.write(` ${f.text}\n`);
234
+ process.stdout.write(dim(` ${authorLabel(f.by)}, ${f.at}`) + '\n');
235
+ }
236
+ }
237
+
238
+ export function runFlag(argv, config) {
239
+ const [sub, ...rest] = argv;
240
+ const positional = [];
241
+ let severity;
242
+ let by = null;
243
+ let note = null;
244
+ for (let i = 0; i < rest.length; i++) {
245
+ const a = rest[i];
246
+ if (a === '--severity' && rest[i + 1]) { severity = rest[++i]; continue; }
247
+ if (a === '--by' && rest[i + 1]) { by = rest[++i]; continue; }
248
+ if (a === '--note' && rest[i + 1]) { note = rest[++i]; continue; }
249
+ if (a === '--config') { i++; continue; }
250
+ if (a.startsWith('-')) continue;
251
+ positional.push(a);
252
+ }
253
+ const usage = 'Usage: runlist flag add <file[:line]> "<what is wrong>" [--severity problem|warn|info]\n'
254
+ + ' runlist flag accept|reject|resolve <id> [--note "..."]\n'
255
+ + ' runlist flag show <id>\n'
256
+ + ' runlist flag sync <check-name> [@findings.json | -]';
257
+
258
+ if (sub === 'add') {
259
+ const [place, ...words] = positional;
260
+ if (!place) die(usage);
261
+ const { flag, added } = addFlag(config, { place, text: words.join(' '), severity, by });
262
+ process.stdout.write(added ? `${green('Flagged')} ${flag.id} ${placeLabel(flag, config)}\n` : `${dim('Already open as')} ${flag.id}\n`);
263
+ return;
264
+ }
265
+ if (TRIAGE.has(sub)) {
266
+ const [id] = positional;
267
+ if (!id) die(usage);
268
+ triageFlag(config, { id, event: sub, note, by });
269
+ process.stdout.write(`${green({ accept: 'Accepted', reject: 'Rejected', resolve: 'Resolved' }[sub])} ${id}\n`);
270
+ return;
271
+ }
272
+ if (sub === 'sync') {
273
+ // A check outside runlist hands over everything it reports now, as a JSON
274
+ // array of { file, line?, text, severity? }; the list follows it.
275
+ const [name, source] = positional;
276
+ if (!name) die('Usage: runlist flag sync <check-name> [@findings.json | -] (JSON array of { file, line?, text, severity? })');
277
+ let raw;
278
+ try { raw = source && source !== '-' ? readFileSync(source.replace(/^@/, ''), 'utf8') : readFileSync(0, 'utf8'); }
279
+ catch (err) { die(`Could not read the findings: ${err.message}`); }
280
+ let findings;
281
+ try { findings = JSON.parse(raw); } catch { die('The findings are not valid JSON.'); }
282
+ if (!Array.isArray(findings) || findings.some(f => typeof f?.file !== 'string' || typeof f?.text !== 'string')) {
283
+ die('The findings are a JSON array of { file, line?, text, severity? }.');
284
+ }
285
+ const { added, resolved } = syncCheckFlags(config, name, findings);
286
+ process.stdout.write(`flags from ${name}: ${added} added, ${resolved} resolved\n`);
287
+ return;
288
+ }
289
+ if (sub === 'show') {
290
+ const flag = deriveFlags(readFlagEvents(flagsFile(config))).find(f => f.id === positional[0]);
291
+ if (!flag) die(`No flag ${positional[0] ?? ''}.`);
292
+ process.stdout.write(`${bold(flag.id)} ${severityLabel(flag.severity)} ${placeLabel(flag, config)}\n ${flag.text}\n`);
293
+ if (flag.quote) process.stdout.write(dim(` flagged line: ${flag.quote}`) + '\n');
294
+ process.stdout.write(dim(` raised by ${authorLabel(flag.by)}, ${flag.at}`) + '\n');
295
+ for (const h of flag.history) process.stdout.write(dim(` ${h.event} by ${authorLabel(h.by)}, ${h.at}${h.note ? `: ${h.note}` : ''}`) + '\n');
296
+ return;
297
+ }
298
+ die(usage);
299
+ }
package/src/hud.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { flagsHudLine, openFlags } from './flags.mjs';
1
2
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
@@ -263,6 +264,7 @@ export function buildHud(config) {
263
264
  fleet,
264
265
  recentRejections,
265
266
  misuseRecap,
267
+ flags: (() => { try { return openFlags(config).map(f => ({ id: f.id, severity: f.severity, file: f.file, line: f.line, text: f.text })); } catch { return []; } })(),
266
268
  };
267
269
  }
268
270
 
@@ -403,5 +405,10 @@ export function runHud(argv, config) {
403
405
  process.stdout.write(yellow(`[runlist] ${n} pending prompt${n === 1 ? '' : 's'} queued for this session — unless the user asks for something else, start by running \`runlist use\` to consume the oldest (${hud.prompts[0]}) and act on it. Peek first: \`runlist prompts show <file>\`; list: \`runlist prompts\`.`) + '\n');
404
406
  }
405
407
  if (hud.misuseRecap) process.stdout.write(yellow(`[runlist] ${hud.misuseRecap}`) + '\n');
408
+ // Open flags are the one piece of passive state printed here: the person
409
+ // asked that every session start knowing where things stand. It is worded
410
+ // as awareness, not an instruction, so no session treats it as its task.
411
+ const flagsLine = flagsHudLine(config);
412
+ if (flagsLine) process.stdout.write(yellow(flagsLine) + '\n');
406
413
  if (drift) process.stdout.write(yellow(drift) + '\n');
407
414
  }