dotmd-cli 0.84.0 → 0.86.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 +23 -0
- package/bin/dotmd.mjs +15 -0
- package/package.json +1 -1
- package/src/commands.mjs +11 -2
- package/src/config.mjs +2 -0
- package/src/flags.mjs +299 -0
- package/src/hud.mjs +27 -2
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
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';
|
|
@@ -9,7 +10,7 @@ import { buildIndex } from './index.mjs';
|
|
|
9
10
|
import { readJournalEntries, journalFilePath, readMisuseEntries } from './journal.mjs';
|
|
10
11
|
import { compareVersions } from './update.mjs';
|
|
11
12
|
import { findOwnedPlan } from './baton.mjs';
|
|
12
|
-
import { listOwnedPlans } from './pickup.mjs';
|
|
13
|
+
import { listOwnedPlans, surveyOwnershipClaims } from './pickup.mjs';
|
|
13
14
|
import { actionablePromptStatuses, comparePromptDocs, resolveStatusMetadata } from './status-metadata.mjs';
|
|
14
15
|
|
|
15
16
|
export { actionablePromptStatuses } from './status-metadata.mjs';
|
|
@@ -83,7 +84,22 @@ export function buildHudFast(config) {
|
|
|
83
84
|
const records = listOwnedPlans(config);
|
|
84
85
|
if (!records.diagnostics?.length && records.length === 1) owned = { path: records[0].plan, title: null, via: 'ownership' };
|
|
85
86
|
} catch { /* hud must not fail */ }
|
|
86
|
-
return { owned, prompts, misuseRecap: buildMisuseRecap(config) };
|
|
87
|
+
return { owned, prompts, misuseRecap: buildMisuseRecap(config), deadClaimsLine: deadClaimsHudLine(config) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A claim whose session is provably gone wedges `set`, `archive` and `baton` on
|
|
91
|
+
// that plan until someone releases it, and nothing at session start said so:
|
|
92
|
+
// three platform claims sat dead for up to 25 days, and the session that found
|
|
93
|
+
// them had to read `doctor --help` to learn the verb. Name them and the command.
|
|
94
|
+
export function deadClaimsHudLine(config, { top = 3 } = {}) {
|
|
95
|
+
let claims;
|
|
96
|
+
try { claims = surveyOwnershipClaims(config); } catch { return null; }
|
|
97
|
+
const dead = claims.filter(claim => !claim.corrupt && claim.liveness === 'dead');
|
|
98
|
+
if (!dead.length) return null;
|
|
99
|
+
const n = dead.length;
|
|
100
|
+
const shown = dead.slice(0, top).map(claim => claim.plan).join(', ');
|
|
101
|
+
const more = n > top ? `, +${n - top} more` : '';
|
|
102
|
+
return `[runlist] ${n} plan${n === 1 ? ' is' : 's are'} claimed by a session that is gone (${shown}${more}) — \`runlist doctor --claims --apply\` releases ${n === 1 ? 'it' : 'them'}; \`runlist doctor --claims\` lists every claim.`;
|
|
87
103
|
}
|
|
88
104
|
|
|
89
105
|
function findActionablePrompts(config, index) {
|
|
@@ -263,6 +279,8 @@ export function buildHud(config) {
|
|
|
263
279
|
fleet,
|
|
264
280
|
recentRejections,
|
|
265
281
|
misuseRecap,
|
|
282
|
+
deadClaims: (() => { try { return surveyOwnershipClaims(config).filter(c => !c.corrupt && c.liveness === 'dead').map(c => ({ plan: c.plan, sessionId: c.sessionId, since: c.since })); } catch { return []; } })(),
|
|
283
|
+
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
284
|
};
|
|
267
285
|
}
|
|
268
286
|
|
|
@@ -275,6 +293,7 @@ export function buildHud(config) {
|
|
|
275
293
|
const SUBAGENT_PRIMER = [
|
|
276
294
|
'runlist manages this repo\'s plans/docs/prompts (markdown + YAML frontmatter).',
|
|
277
295
|
'Verbs: plans|briefing | query <filters> | use [<file>] | set <status> <file> | new <type> <slug> | archive <file>.',
|
|
296
|
+
'Claims: `runlist doctor --claims` lists which session holds each plan; `--apply` releases the ones whose session is gone.',
|
|
278
297
|
'Do NOT: cat/read a docs/prompts/*.md (use `runlist use <file>` — archive/claim commits before at-most-once output);',
|
|
279
298
|
'git add/commit a prompt (they are session-local, often gitignored); hand-edit a `status:` field (use `runlist set`).',
|
|
280
299
|
].join('\n');
|
|
@@ -403,5 +422,11 @@ export function runHud(argv, config) {
|
|
|
403
422
|
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
423
|
}
|
|
405
424
|
if (hud.misuseRecap) process.stdout.write(yellow(`[runlist] ${hud.misuseRecap}`) + '\n');
|
|
425
|
+
if (hud.deadClaimsLine) process.stdout.write(yellow(hud.deadClaimsLine) + '\n');
|
|
426
|
+
// Open flags are the one piece of passive state printed here: the person
|
|
427
|
+
// asked that every session start knowing where things stand. It is worded
|
|
428
|
+
// as awareness, not an instruction, so no session treats it as its task.
|
|
429
|
+
const flagsLine = flagsHudLine(config);
|
|
430
|
+
if (flagsLine) process.stdout.write(yellow(flagsLine) + '\n');
|
|
406
431
|
if (drift) process.stdout.write(yellow(drift) + '\n');
|
|
407
432
|
}
|