dotmd-cli 0.83.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 +57 -0
- package/bin/dotmd.mjs +30 -0
- package/package.json +1 -1
- package/src/commands.mjs +11 -2
- package/src/config.mjs +12 -0
- package/src/decision.mjs +220 -0
- package/src/flags.mjs +299 -0
- package/src/hud.mjs +7 -0
- package/src/new.mjs +49 -0
package/README.md
CHANGED
|
@@ -213,6 +213,9 @@ runlist new plan platform-work --coordination
|
|
|
213
213
|
runlist runlists
|
|
214
214
|
```
|
|
215
215
|
|
|
216
|
+
`runlist new hub platform-work` makes the same coordination hub; add
|
|
217
|
+
`--runlist a,b,c` or `--roadmap` for the other two shapes.
|
|
218
|
+
|
|
216
219
|
For progress across several runlists, create a roadmap:
|
|
217
220
|
|
|
218
221
|
```bash
|
|
@@ -225,6 +228,60 @@ Roadmaps roll up progress recursively and choose the first startable plan across
|
|
|
225
228
|
their child runlists. Runlists and roadmaps are held out of actionable plan
|
|
226
229
|
counts so dashboards do not double-count their children.
|
|
227
230
|
|
|
231
|
+
## Decisions
|
|
232
|
+
|
|
233
|
+
A decision is an entry in its plan, not a document of its own. Write the record
|
|
234
|
+
(the situation, what exists today, what each answer leaves in place) to a file
|
|
235
|
+
and add it:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
runlist new decision auth-revamp --question "Which token store?" @record.md
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
It takes the next id (`D1`, `D2`, …) that no decision item in that plan uses,
|
|
242
|
+
lands at the end of the plan's top-level decisions section (created before
|
|
243
|
+
`## Version History` when there is none) with a `Disposition: OPEN.` line, and
|
|
244
|
+
refuses an empty record. `--disposition held` parks it instead.
|
|
245
|
+
|
|
246
|
+
A corpus that indexes its decisions in one register numbers them in one
|
|
247
|
+
sequence. Name the register, and the id follows the highest the register or the
|
|
248
|
+
plan uses, and the register gets the entry's row in the same locked write:
|
|
249
|
+
|
|
250
|
+
```js
|
|
251
|
+
export const decisions = {
|
|
252
|
+
section: 'Decisions',
|
|
253
|
+
prefix: 'D',
|
|
254
|
+
register: { file: 'docs/plans/register.md', statusLine: 'waiting on you:' },
|
|
255
|
+
};
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
The register block is the fenced block whose first line carries `statusLine`.
|
|
259
|
+
The row is the question plus `--answers` (what each answer leaves in place),
|
|
260
|
+
which is required when a register is configured.
|
|
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
|
+
|
|
228
285
|
## Safety Model
|
|
229
286
|
|
|
230
287
|
- Mutation commands support `--dry-run` / `-n`.
|
package/bin/dotmd.mjs
CHANGED
|
@@ -1001,6 +1001,21 @@ Examples:
|
|
|
1001
1001
|
EOF
|
|
1002
1002
|
runlist new prompt cleanup-tomorrow "look at remaining lint warnings"
|
|
1003
1003
|
|
|
1004
|
+
Hubs and decisions:
|
|
1005
|
+
runlist new hub <slug> # coordination hub (a plan)
|
|
1006
|
+
runlist new hub <slug> --runlist a,b,c # sprint hub plus child plans
|
|
1007
|
+
runlist new hub <slug> --roadmap # roadmap hub
|
|
1008
|
+
runlist new decision <plan> --question "<question>" [--answers "<…>"] @record.md
|
|
1009
|
+
Adds the next numbered entry to the plan's top-level
|
|
1010
|
+
decisions section with a \`Disposition: OPEN.\` line (or
|
|
1011
|
+
\`--disposition held\`) and the record, which is required;
|
|
1012
|
+
a plan with no such section gets \`## Decisions\`. With a
|
|
1013
|
+
register in config, the id is numbered across the corpus
|
|
1014
|
+
and the register gets its row, question plus --answers
|
|
1015
|
+
(required then), in the same locked write. Config:
|
|
1016
|
+
\`export const decisions = { section, prefix,
|
|
1017
|
+
register: { file, statusLine } }\`.
|
|
1018
|
+
|
|
1004
1019
|
Scaffolding runlists (plans only):
|
|
1005
1020
|
--runlist <a,b,c> Create a sprint runlist hub plus one child plan per slug.
|
|
1006
1021
|
The hub carries \`runlist: [<hub>-01-a.md, <hub>-02-b.md, …]\`
|
|
@@ -1880,6 +1895,8 @@ async function main() {
|
|
|
1880
1895
|
if (command === 'deps') { const { runDeps } = await import('../src/deps.mjs'); runDeps(restArgs, config); return; }
|
|
1881
1896
|
if (command === 'unblocks') { const { runUnblocks } = await import('../src/deps.mjs'); runUnblocks(restArgs, config); return; }
|
|
1882
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; }
|
|
1883
1900
|
if (command === 'glossary') { const { runGlossary } = await import('../src/glossary.mjs'); runGlossary(restArgs, config); return; }
|
|
1884
1901
|
if (command === 'export') { const { runExport } = await import('../src/export.mjs'); runExport(restArgs, config, { dryRun, root: rootArg, type: typeArg }); return; }
|
|
1885
1902
|
|
|
@@ -1999,6 +2016,17 @@ async function main() {
|
|
|
1999
2016
|
? ['validate', 'transformDoc', 'formatSnapshot', 'renderCheck']
|
|
2000
2017
|
.filter(name => typeof config.hooks?.[name] === 'function')
|
|
2001
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
|
+
};
|
|
2002
2030
|
const checkJson = (checkIndex) => {
|
|
2003
2031
|
const builtInPassed = checkIndex.errors.length === 0;
|
|
2004
2032
|
const complete = skippedCheckHooks.length === 0;
|
|
@@ -2053,6 +2081,7 @@ async function main() {
|
|
|
2053
2081
|
applyIndexFilters(freshIndex);
|
|
2054
2082
|
applyPathScopeToIndex(freshIndex, config, checkTargets);
|
|
2055
2083
|
applyFloor(freshIndex);
|
|
2084
|
+
await flagCheckErrors(freshIndex);
|
|
2056
2085
|
if (args.includes('--json')) {
|
|
2057
2086
|
process.stdout.write(JSON.stringify(checkJson(freshIndex), null, 2) + '\n');
|
|
2058
2087
|
} else {
|
|
@@ -2065,6 +2094,7 @@ async function main() {
|
|
|
2065
2094
|
|
|
2066
2095
|
applyPathScopeToIndex(index, config, checkTargets);
|
|
2067
2096
|
applyFloor(index);
|
|
2097
|
+
await flagCheckErrors(index);
|
|
2068
2098
|
|
|
2069
2099
|
if (args.includes('--json')) {
|
|
2070
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
|
@@ -137,6 +137,10 @@ const DEFAULTS = {
|
|
|
137
137
|
templates: {},
|
|
138
138
|
|
|
139
139
|
glossary: null,
|
|
140
|
+
// `runlist new decision`: { section, prefix, register: { file, statusLine } }.
|
|
141
|
+
decisions: null,
|
|
142
|
+
// `runlist flag` / `runlist flags`: { file } moves the log from .runlist/flags.jsonl.
|
|
143
|
+
flags: null,
|
|
140
144
|
|
|
141
145
|
// Opt-in JSONL command journal at .dotmd/journal.jsonl. Default off — agents
|
|
142
146
|
// and users who want usage observability flip this on (or set RUNLIST_JOURNAL=1).
|
|
@@ -417,6 +421,14 @@ function validateConfig(userConfig, config, validStatuses, indexPath) {
|
|
|
417
421
|
}
|
|
418
422
|
}
|
|
419
423
|
|
|
424
|
+
if (userConfig.decisions != null) {
|
|
425
|
+
const d = userConfig.decisions;
|
|
426
|
+
if (typeof d !== 'object' || Array.isArray(d)) warnings.push('Config: decisions must be an object.');
|
|
427
|
+
else if (d.register != null && (typeof d.register?.file !== 'string' || typeof d.register?.statusLine !== 'string')) {
|
|
428
|
+
warnings.push('Config: decisions.register needs a file and a statusLine.');
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
420
432
|
// Unknown top-level user config keys
|
|
421
433
|
for (const key of Object.keys(userConfig)) {
|
|
422
434
|
if (!VALID_CONFIG_KEYS.has(key)) {
|
package/src/decision.mjs
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { mutateFileSet } from './atomic-mutation.mjs';
|
|
4
|
+
import { authorizeManagedSource } from './managed-path.mjs';
|
|
5
|
+
import { walkSections } from './section.mjs';
|
|
6
|
+
import { die, nowIso, resolveDocPath, toRepoPath } from './util.mjs';
|
|
7
|
+
import { green, dim } from './color.mjs';
|
|
8
|
+
|
|
9
|
+
// `runlist new decision <plan> --question "…" @record.md`
|
|
10
|
+
//
|
|
11
|
+
// A decision is not a document of its own: it is an entry in the owning plan's
|
|
12
|
+
// decisions section, carrying an id, a disposition and a written record. This
|
|
13
|
+
// adds one, numbered after the highest id of its prefix the plan (and the
|
|
14
|
+
// register, when there is one) already uses, at the end of the plan's top-level
|
|
15
|
+
// decisions section, creating `## Decisions` when the plan has none. A
|
|
16
|
+
// decisions heading nested inside a workstream is that workstream's record and
|
|
17
|
+
// is left alone.
|
|
18
|
+
//
|
|
19
|
+
// Config (`runlist.config.mjs`), all optional:
|
|
20
|
+
// export const decisions = {
|
|
21
|
+
// section: 'Decisions', // the heading the entry lands under
|
|
22
|
+
// prefix: 'D', // what a new id is written under
|
|
23
|
+
// register: { file: 'docs/plans/register.md', statusLine: 'waiting on you:' },
|
|
24
|
+
// };
|
|
25
|
+
// With a register, ids are one sequence across the corpus: the next id is
|
|
26
|
+
// numbered after the highest the register or the plan uses, and the register
|
|
27
|
+
// gets the entry's index row in the same locked write as the plan. Without
|
|
28
|
+
// one, ids are plan-local.
|
|
29
|
+
|
|
30
|
+
const DISPOSITIONS = new Set(['open', 'held']);
|
|
31
|
+
const DECISION_HEADING = /\bdecisions?\b/i;
|
|
32
|
+
|
|
33
|
+
function splitDoc(raw) {
|
|
34
|
+
if (!raw.startsWith('---\n')) return null;
|
|
35
|
+
const end = raw.indexOf('\n---\n', 4);
|
|
36
|
+
if (end === -1) return null;
|
|
37
|
+
return { frontmatter: raw.slice(4, end), body: raw.slice(end + 5) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stripFences(text) {
|
|
41
|
+
return text.replace(/^(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\1[^\n]*$/gm, '');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const escapeRe = text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
45
|
+
|
|
46
|
+
// Ids are read where a decision item starts (a heading, a list or bold lead,
|
|
47
|
+
// a table row or a register row), never from prose, where the same shape is
|
|
48
|
+
// as likely to be a job code or a citation of another plan's decision.
|
|
49
|
+
function highestId(text, prefix) {
|
|
50
|
+
const re = new RegExp(`^\\s*(?:#{1,6}\\s+|[-*]\\s+(?:\\[[ xX]\\]\\s+)?|\\|\\s*)?\\**${escapeRe(prefix)}(\\d{1,4})\\b`);
|
|
51
|
+
let max = 0;
|
|
52
|
+
for (const line of text.split('\n')) {
|
|
53
|
+
const m = line.match(re);
|
|
54
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
55
|
+
}
|
|
56
|
+
return max;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function nextDecisionId(body, prefix, register = null) {
|
|
60
|
+
let max = highestId(stripFences(body), prefix);
|
|
61
|
+
if (register) {
|
|
62
|
+
const rows = register.rows.map(row => row.match(new RegExp(`^${escapeRe(prefix)}(\\d{1,4})\\b`))?.[1]);
|
|
63
|
+
for (const n of rows) if (n) max = Math.max(max, Number(n));
|
|
64
|
+
}
|
|
65
|
+
return `${prefix}${max + 1}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The register block: a fence whose first line carries the configured status
|
|
69
|
+
// line. Returns its rows and where the closing fence sits, or null.
|
|
70
|
+
export function findRegister(text, statusLine) {
|
|
71
|
+
const lines = text.split('\n');
|
|
72
|
+
const wanted = statusLine.trim().toLowerCase();
|
|
73
|
+
for (let i = 0; i < lines.length; i++) {
|
|
74
|
+
const open = lines[i].match(/^(`{3,}|~{3,})/);
|
|
75
|
+
if (!open) continue;
|
|
76
|
+
let close = i + 1;
|
|
77
|
+
while (close < lines.length && !lines[close].startsWith(open[1])) close++;
|
|
78
|
+
if (close >= lines.length) return null;
|
|
79
|
+
const first = (lines[i + 1] ?? '').trim().toLowerCase();
|
|
80
|
+
if (first.includes(wanted)) {
|
|
81
|
+
return { closeLine: close, rows: lines.slice(i + 2, close).filter(line => line.trim()) };
|
|
82
|
+
}
|
|
83
|
+
i = close;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function insertRegisterRow(text, register, row) {
|
|
89
|
+
const lines = text.split('\n');
|
|
90
|
+
let at = register.closeLine;
|
|
91
|
+
while (at > 0 && lines[at - 1].trim() === '') at--;
|
|
92
|
+
lines.splice(at, 0, row);
|
|
93
|
+
return lines.join('\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Returns the new body and where the entry went. Pure, for tests.
|
|
97
|
+
export function insertDecision(body, { id, question, disposition, record, section: heading = 'Decisions' }) {
|
|
98
|
+
const lines = body.split('\n');
|
|
99
|
+
const sections = walkSections(body);
|
|
100
|
+
const named = s => s.heading.replace(/[^\w\s]+$/, '').trim().toLowerCase() === heading.toLowerCase();
|
|
101
|
+
const section = sections.find(s => s.level === 2 && named(s))
|
|
102
|
+
?? sections.find(s => s.level === 2 && DECISION_HEADING.test(s.heading));
|
|
103
|
+
const level = section ? Math.min(section.level + 1, 6) : 3;
|
|
104
|
+
const entry = [
|
|
105
|
+
`${'#'.repeat(level)} ${id} ${question.trim()}`,
|
|
106
|
+
'',
|
|
107
|
+
`Disposition: ${disposition.toUpperCase()}.`,
|
|
108
|
+
'',
|
|
109
|
+
record.trim(),
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
if (section) {
|
|
113
|
+
// After the section's last non-blank line, subsections included.
|
|
114
|
+
let at = section.lineEnd;
|
|
115
|
+
while (at > section.lineStart && lines[at - 1].trim() === '') at--;
|
|
116
|
+
const tail = at < lines.length && lines[at].trim() !== '' ? [''] : [];
|
|
117
|
+
lines.splice(at, 0, '', ...entry, ...tail);
|
|
118
|
+
return { body: lines.join('\n'), placement: `under \`${section.heading}\``, heading: section.heading };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const block = [`## ${heading}`, '', ...entry];
|
|
122
|
+
const before = sections.find(s => s.level === 2 && /^(version history|closeout)\b/i.test(s.heading));
|
|
123
|
+
if (before) {
|
|
124
|
+
lines.splice(before.lineStart - 1, 0, ...block, '');
|
|
125
|
+
return { body: lines.join('\n'), placement: `in a new \`## ${heading}\` before \`${before.heading}\``, heading };
|
|
126
|
+
}
|
|
127
|
+
const trimmed = body.replace(/\n+$/, '');
|
|
128
|
+
return { body: `${trimmed}\n\n${block.join('\n')}\n`, placement: `in a new \`## ${heading}\` at the end`, heading };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function runNewDecision({ planArg, question, disposition, record, answers = null }, config, { dryRun = false } = {}) {
|
|
132
|
+
const usage = 'Usage: runlist new decision <plan> --question "<the question>" @record.md';
|
|
133
|
+
if (!planArg) die(`${usage}\nThe plan is the file whose decisions section gets the entry.`);
|
|
134
|
+
if (!question || !question.trim()) die(`--question is required.\n${usage}`);
|
|
135
|
+
if (/\n/.test(question)) die('--question is one line; the record carries the rest.');
|
|
136
|
+
if (!record || !record.trim()) {
|
|
137
|
+
die('A decision needs its record: the situation, what exists today, and what each answer leaves in place.\n'
|
|
138
|
+
+ `Write it to a file and pass @path, pipe it in, or pass --body "...".\n${usage}`);
|
|
139
|
+
}
|
|
140
|
+
disposition = (disposition ?? 'open').toLowerCase();
|
|
141
|
+
if (!DISPOSITIONS.has(disposition)) {
|
|
142
|
+
die(`--disposition must be open or held; a ruled or closed decision is edited in place, not added.`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const resolved = resolveDocPath(planArg, config)
|
|
146
|
+
?? (planArg.endsWith('.md') ? null : resolveDocPath(`${planArg}.md`, config))
|
|
147
|
+
?? (planArg.includes('/') ? null : resolveDocPath(path.join('plans', `${planArg}.md`), config));
|
|
148
|
+
if (!resolved) die(`No such plan: ${planArg}`);
|
|
149
|
+
const planPath = authorizeManagedSource(resolved, config, { kind: 'Decision target' }).path;
|
|
150
|
+
const planRepoPath = toRepoPath(planPath, config.repoRoot);
|
|
151
|
+
|
|
152
|
+
const settings = config.raw?.decisions ?? {};
|
|
153
|
+
const prefix = settings.prefix ?? 'D';
|
|
154
|
+
const section = settings.section ?? 'Decisions';
|
|
155
|
+
let registerPath = null;
|
|
156
|
+
if (settings.register?.file) {
|
|
157
|
+
if (!settings.register.statusLine) die('decisions.register needs a statusLine: the first line of the register block.');
|
|
158
|
+
const registerResolved = resolveDocPath(settings.register.file, config);
|
|
159
|
+
if (!registerResolved) die(`The decisions register in config does not exist: ${settings.register.file}`);
|
|
160
|
+
registerPath = authorizeManagedSource(registerResolved, config, { kind: 'Decision register' }).path;
|
|
161
|
+
}
|
|
162
|
+
const sameFile = registerPath === planPath;
|
|
163
|
+
if (registerPath && (!answers || !answers.trim())) {
|
|
164
|
+
die('--answers is required with a register: the row says what each answer leaves in place and what it costs.\n'
|
|
165
|
+
+ `${usage} --answers "Yes: … No: …"`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const today = nowIso();
|
|
169
|
+
const planRaw = readFileSync(planPath, 'utf8');
|
|
170
|
+
const registerRaw = registerPath && !sameFile ? readFileSync(registerPath, 'utf8') : null;
|
|
171
|
+
|
|
172
|
+
const planDoc = splitDoc(planRaw.replace(/\r\n/g, '\n'));
|
|
173
|
+
if (!planDoc) die(`${planRepoPath} has no frontmatter block; runlist only adds decisions to managed documents.`);
|
|
174
|
+
const registerText = sameFile ? planDoc.body : registerRaw?.replace(/\r\n/g, '\n');
|
|
175
|
+
const register = registerPath ? findRegister(registerText, settings.register.statusLine) : null;
|
|
176
|
+
if (registerPath && !register) {
|
|
177
|
+
die(`${toRepoPath(registerPath, config.repoRoot)} has no block whose first line carries "${settings.register.statusLine}".`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const id = nextDecisionId(planDoc.body, prefix, register);
|
|
181
|
+
const inserted = insertDecision(planDoc.body, { id, question, disposition, record, section });
|
|
182
|
+
let planBody = inserted.body;
|
|
183
|
+
let row = null;
|
|
184
|
+
if (register && sameFile) {
|
|
185
|
+
row = registerRow({ id, disposition, today, question, answers, heading: inserted.heading, link: path.basename(planPath) });
|
|
186
|
+
planBody = insertRegisterRow(planBody, findRegister(planBody, settings.register.statusLine), row);
|
|
187
|
+
}
|
|
188
|
+
const bump = fm => (/^updated:/m.test(fm) ? fm.replace(/^updated:.*$/m, `updated: ${today}`) : fm);
|
|
189
|
+
const planOut = `---\n${bump(planDoc.frontmatter)}\n---\n${planBody}`;
|
|
190
|
+
|
|
191
|
+
const updates = [{ path: planPath, expectedContent: planRaw, content: planOut }];
|
|
192
|
+
if (register && !sameFile) {
|
|
193
|
+
const link = path.relative(path.dirname(registerPath), planPath).split(path.sep).join('/');
|
|
194
|
+
row = registerRow({ id, disposition, today, question, answers, heading: inserted.heading, link });
|
|
195
|
+
const registerOut = insertRegisterRow(registerText, register, row);
|
|
196
|
+
const registerDoc = splitDoc(registerOut);
|
|
197
|
+
updates.push({
|
|
198
|
+
path: registerPath,
|
|
199
|
+
expectedContent: registerRaw,
|
|
200
|
+
content: registerDoc ? `---\n${bump(registerDoc.frontmatter)}\n---\n${registerDoc.body}` : registerOut,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const registerRepoPath = registerPath ? toRepoPath(registerPath, config.repoRoot) : null;
|
|
205
|
+
if (dryRun) {
|
|
206
|
+
process.stdout.write(`[dry-run] Would add ${id} to ${planRepoPath} ${inserted.placement}\n`);
|
|
207
|
+
if (row) process.stdout.write(`[dry-run] Would add its row to the register in ${registerRepoPath}:\n ${row}\n`);
|
|
208
|
+
return { id, row };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
mutateFileSet({ updates }, { repoRoot: config.repoRoot });
|
|
212
|
+
process.stdout.write(`${green('Added')} ${id} to ${planRepoPath} ${dim(inserted.placement)}\n`);
|
|
213
|
+
if (row) process.stdout.write(`${green('Added')} its row to the register in ${registerRepoPath}\n`);
|
|
214
|
+
return { id, row };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function registerRow({ id, disposition, today, question, answers, heading, link }) {
|
|
218
|
+
const text = `${question.trim()} ${answers.trim()}`;
|
|
219
|
+
return `${id} ${disposition.toUpperCase()} ${today.slice(0, 10)}: ${text} Record: [${link} § ${heading} ${id}](${link}).`;
|
|
220
|
+
}
|
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
|
}
|
package/src/new.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, readFileSync, mkdirSync, fstatSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { runNewDecision } from './decision.mjs';
|
|
5
6
|
import { toRepoPath, die, warn, nowIso, emitFilesFooter } from './util.mjs';
|
|
6
7
|
import { green, dim, bold } from './color.mjs';
|
|
7
8
|
import { isInteractive, promptText } from './prompt.mjs';
|
|
@@ -652,9 +653,14 @@ next_step:
|
|
|
652
653
|
export async function runNew(argv, config, opts = {}) {
|
|
653
654
|
const { dryRun } = opts;
|
|
654
655
|
|
|
656
|
+
if (argv.find(a => !a.startsWith('-')) === 'decision') return runNewDecisionArgs(argv, config, opts);
|
|
657
|
+
|
|
655
658
|
const knownTypes = new Set(Object.keys(BUILTIN_TEMPLATES));
|
|
656
659
|
// Also include any custom templates from config
|
|
657
660
|
for (const k of Object.keys(config.raw?.templates ?? {})) knownTypes.add(k);
|
|
661
|
+
// `hub` is a plan with a hub body; `decision` is handled above.
|
|
662
|
+
knownTypes.add('hub');
|
|
663
|
+
knownTypes.add('decision');
|
|
658
664
|
|
|
659
665
|
const hasNameForBody = args => {
|
|
660
666
|
if (args.length >= 2 && knownTypes.has(args[0])) return true;
|
|
@@ -713,11 +719,27 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
713
719
|
name = positional[1];
|
|
714
720
|
if (positional.length > 2) bodyArg = positional.slice(2).join(' ');
|
|
715
721
|
} else {
|
|
722
|
+
// `new <name> <inline body>` is a doc with the type left out, but a single
|
|
723
|
+
// bare word in the body slot is almost always a mistyped type and a slug
|
|
724
|
+
// (`new decison foo`), which used to create `<type-name>.md` with the slug
|
|
725
|
+
// as its whole body.
|
|
726
|
+
const [first, second] = positional;
|
|
727
|
+
if (positional.length === 2 && !/[/\\]|\.md$/.test(first) && /^[A-Za-z][\w.-]*$/.test(second)) {
|
|
728
|
+
die(`Unknown type \`${first}\`. Types: ${[...knownTypes].join(', ')}.\n`
|
|
729
|
+
+ `For a doc named ${first} with "${second}" as its body, run: runlist new doc ${first} "${second}"`);
|
|
730
|
+
}
|
|
716
731
|
typeName = 'doc';
|
|
717
732
|
name = positional[0];
|
|
718
733
|
if (positional.length > 1) bodyArg = positional.slice(1).join(' ');
|
|
719
734
|
}
|
|
720
735
|
|
|
736
|
+
// A hub is a plan with a hub body. Coordination is the default shape, being
|
|
737
|
+
// the one a hub without children or tiers has.
|
|
738
|
+
if (typeName === 'hub') {
|
|
739
|
+
typeName = 'plan';
|
|
740
|
+
if (runlistArg === null && !roadmap) coordination = true;
|
|
741
|
+
}
|
|
742
|
+
|
|
721
743
|
if (!name) {
|
|
722
744
|
if (isInteractive()) {
|
|
723
745
|
name = await promptText(`${typeName} name: `);
|
|
@@ -1145,6 +1167,8 @@ export function newHelpForRepo(config) {
|
|
|
1145
1167
|
if (statuses.length) rows.push(` ${''.padEnd(width)} statuses: ${statuses.join(', ')}`);
|
|
1146
1168
|
}
|
|
1147
1169
|
const roots = (config.docsRoots ?? [config.docsRoot]).map(r => path.basename(r));
|
|
1170
|
+
rows.push(` ${'hub'.padEnd(width)} → a plan with a hub body (coordination unless --runlist or --roadmap)`);
|
|
1171
|
+
rows.push(` ${'decision'.padEnd(width)} → an entry in an existing plan's decisions section`);
|
|
1148
1172
|
return `This repo:
|
|
1149
1173
|
${rows.join('\n')}
|
|
1150
1174
|
roots (for --root): ${roots.join(', ')}`;
|
|
@@ -1206,4 +1230,29 @@ function listTemplates(config) {
|
|
|
1206
1230
|
if (desc) process.stdout.write(` ${dim(desc)}\n`);
|
|
1207
1231
|
process.stdout.write('\n');
|
|
1208
1232
|
}
|
|
1233
|
+
process.stdout.write(` hub\n ${dim('A plan with a hub body: coordination by default, --runlist or --roadmap for the others.')}\n\n`);
|
|
1234
|
+
process.stdout.write(` decision\n ${dim('An entry in an existing plan\'s decisions section: runlist new decision <plan> --question "…" @record.md')}\n\n`);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function runNewDecisionArgs(argv, config, opts) {
|
|
1238
|
+
const positional = [];
|
|
1239
|
+
let question = null;
|
|
1240
|
+
let disposition = null;
|
|
1241
|
+
let answers = null;
|
|
1242
|
+
let bodyFlag = null;
|
|
1243
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1244
|
+
const a = argv[i];
|
|
1245
|
+
if (a === '--question' && argv[i + 1] !== undefined) { question = argv[++i]; continue; }
|
|
1246
|
+
if (a === '--disposition' && argv[i + 1] !== undefined) { disposition = argv[++i]; continue; }
|
|
1247
|
+
if (a === '--answers' && argv[i + 1] !== undefined) { answers = argv[++i]; continue; }
|
|
1248
|
+
if ((a === '--body' || a === '--message') && argv[i + 1] !== undefined) { bodyFlag = argv[++i]; continue; }
|
|
1249
|
+
if (a === '--config' || a === '--root') { i++; continue; }
|
|
1250
|
+
if (!a.startsWith('-') || a === '-') positional.push(a);
|
|
1251
|
+
}
|
|
1252
|
+
const [, planArg, ...rest] = positional;
|
|
1253
|
+
let record = null;
|
|
1254
|
+
if (bodyFlag !== null) record = readBodyInput(bodyFlag);
|
|
1255
|
+
else if (rest.length) record = readBodyInput(rest.join(' '));
|
|
1256
|
+
else record = readPipedBodyInput();
|
|
1257
|
+
return runNewDecision({ planArg, question, disposition, record, answers }, config, { dryRun: opts.dryRun });
|
|
1209
1258
|
}
|