runlist 0.0.0 → 0.94.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1114 -6
- package/assets/app/app.css +283 -0
- package/assets/app/app.mjs +381 -0
- package/assets/app/block-editor.mjs +182 -0
- package/assets/app/block-model.mjs +52 -0
- package/assets/app/document-create.mjs +86 -0
- package/assets/app/document-lifecycle.mjs +36 -0
- package/assets/app/document-stages.mjs +22 -0
- package/assets/app/document-views.mjs +33 -0
- package/assets/app/document-yardstick.mjs +258 -0
- package/assets/app/editor-navigation.mjs +51 -0
- package/assets/app/filing-navigation.mjs +138 -0
- package/assets/app/git-changes.mjs +80 -0
- package/assets/app/git-commit.mjs +46 -0
- package/assets/app/index.html +70 -0
- package/assets/app/library-navigation.mjs +93 -0
- package/assets/app/outline.mjs +18 -0
- package/assets/app/quick-navigation.mjs +52 -0
- package/assets/app/record-navigation.mjs +78 -0
- package/assets/app/recovery-center.mjs +53 -0
- package/assets/app/semantic-search.mjs +14 -0
- package/assets/app/settings.mjs +15 -0
- package/assets/app/shared.mjs +104 -0
- package/assets/app/stage-source.mjs +75 -0
- package/assets/app/template-editor.mjs +47 -0
- package/assets/app/theme.mjs +25 -0
- package/assets/app/transport.mjs +43 -0
- package/assets/contracts/source-editing-v1.md +232 -0
- package/assets/opencode/plugin.js +162 -0
- package/assets/schemas/runlist-record-v1.schema.json +912 -0
- package/bin/dotmd.mjs +5 -0
- package/bin/runlist.mjs +2714 -0
- package/package.json +53 -6
- package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
- package/plugins/runlist-codex/bin/runlist-hook +86 -0
- package/plugins/runlist-codex/hooks/hooks.json +16 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
- package/runlist.config.example.mjs +370 -0
- package/scripts/postinstall.mjs +56 -0
- package/src/agent-context.mjs +201 -0
- package/src/ai.mjs +30 -0
- package/src/app-create.mjs +169 -0
- package/src/app-filing.mjs +139 -0
- package/src/app-git-commits.mjs +48 -0
- package/src/app-git.mjs +180 -0
- package/src/app-library.mjs +152 -0
- package/src/app-lifecycle-worker.mjs +12 -0
- package/src/app-lifecycle.mjs +79 -0
- package/src/app-records.mjs +134 -0
- package/src/app-search.mjs +80 -0
- package/src/app-semantic-search.mjs +105 -0
- package/src/app-service.mjs +208 -0
- package/src/app-yardstick.mjs +269 -0
- package/src/app.mjs +102 -0
- package/src/atomic-mutation.mjs +1736 -0
- package/src/baton.mjs +435 -0
- package/src/body-link.mjs +48 -0
- package/src/bulk-tag.mjs +174 -0
- package/src/check-collapse.mjs +75 -0
- package/src/claude-commands.mjs +75 -0
- package/src/cli-editor.mjs +8 -0
- package/src/code-refs.mjs +545 -0
- package/src/codex-integration.mjs +81 -0
- package/src/color.mjs +14 -0
- package/src/commands.mjs +374 -0
- package/src/completions.mjs +93 -0
- package/src/config-edit.mjs +606 -0
- package/src/config.mjs +705 -0
- package/src/decision-action.mjs +58 -0
- package/src/decision.mjs +220 -0
- package/src/decisions.mjs +787 -0
- package/src/deps.mjs +317 -0
- package/src/desktop-install.mjs +158 -0
- package/src/desktop-runtime.mjs +4 -0
- package/src/desktop.mjs +25 -0
- package/src/diff.mjs +101 -0
- package/src/doctor.mjs +591 -0
- package/src/durable-rename.mjs +55 -0
- package/src/errors-read.mjs +44 -0
- package/src/export.mjs +496 -0
- package/src/extractors.mjs +101 -0
- package/src/filing.mjs +255 -0
- package/src/fix-membership.mjs +134 -0
- package/src/fix-refs.mjs +174 -0
- package/src/flags.mjs +318 -0
- package/src/frontmatter-fix.mjs +195 -0
- package/src/frontmatter.mjs +252 -0
- package/src/git-commit-filters.mjs +32 -0
- package/src/git-commit-protocol.mjs +198 -0
- package/src/git-commit-signing.mjs +75 -0
- package/src/git-commit-store.mjs +26 -0
- package/src/git-commit-support.mjs +16 -0
- package/src/git-commit-worker.mjs +32 -0
- package/src/git.mjs +909 -0
- package/src/glossary-check.mjs +35 -0
- package/src/glossary.mjs +243 -0
- package/src/graph.mjs +298 -0
- package/src/guard.mjs +488 -0
- package/src/health.mjs +212 -0
- package/src/hints.mjs +129 -0
- package/src/host-integration.mjs +339 -0
- package/src/hub-membership.mjs +284 -0
- package/src/hub.mjs +292 -0
- package/src/hud.mjs +436 -0
- package/src/index-file.mjs +163 -0
- package/src/index.mjs +478 -0
- package/src/init.mjs +512 -0
- package/src/install.mjs +222 -0
- package/src/journal-read.mjs +116 -0
- package/src/journal.mjs +472 -0
- package/src/lifecycle.mjs +1444 -0
- package/src/lint.mjs +386 -0
- package/src/managed-path.mjs +192 -0
- package/src/markdown-code-spans.mjs +58 -0
- package/src/migrate-prompts.mjs +171 -0
- package/src/migrate-template.mjs +170 -0
- package/src/migrate.mjs +90 -0
- package/src/misuse-read.mjs +61 -0
- package/src/model-request.mjs +33 -0
- package/src/model.mjs +523 -0
- package/src/modules.mjs +355 -0
- package/src/naming.mjs +55 -0
- package/src/native-action.mjs +59 -0
- package/src/native-record.mjs +244 -0
- package/src/new.mjs +1321 -0
- package/src/output-identity.mjs +111 -0
- package/src/parent-plan.mjs +58 -0
- package/src/parse-cache.mjs +99 -0
- package/src/path-containment.mjs +15 -0
- package/src/pickup-card.mjs +293 -0
- package/src/pickup.mjs +671 -0
- package/src/prompt.mjs +25 -0
- package/src/prompts.mjs +585 -0
- package/src/query.mjs +977 -0
- package/src/record-action.mjs +11 -0
- package/src/record-create.mjs +82 -0
- package/src/reference-planner.mjs +388 -0
- package/src/reference-values.mjs +11 -0
- package/src/rename.mjs +107 -0
- package/src/render.mjs +678 -0
- package/src/roadmap.mjs +184 -0
- package/src/runlist.mjs +944 -0
- package/src/section.mjs +225 -0
- package/src/semantic-search-client.mjs +58 -0
- package/src/ship.mjs +166 -0
- package/src/show.mjs +101 -0
- package/src/skill-drift.mjs +98 -0
- package/src/source-editor.mjs +491 -0
- package/src/stages.mjs +73 -0
- package/src/state-migration.mjs +123 -0
- package/src/stats.mjs +200 -0
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +721 -0
- package/src/summary.mjs +74 -0
- package/src/surfaces.mjs +28 -0
- package/src/sync-status.mjs +316 -0
- package/src/template-store.mjs +62 -0
- package/src/update.mjs +338 -0
- package/src/use.mjs +58 -0
- package/src/util.mjs +337 -0
- package/src/validate.mjs +794 -0
- package/src/watch.mjs +56 -0
- package/src/xref.mjs +406 -0
- package/src/yardstick-command.mjs +178 -0
- package/src/yardstick.mjs +112 -0
package/src/flags.mjs
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { withPathLocks } from './atomic-mutation.mjs';
|
|
6
|
+
import { stateDir } from './naming.mjs';
|
|
7
|
+
import { die, hostSessionSource, nowIso, toRepoPath } from './util.mjs';
|
|
8
|
+
import { bold, dim, green, red, yellow } from './color.mjs';
|
|
9
|
+
|
|
10
|
+
// Flags: what anyone found that the person should know when they come back —
|
|
11
|
+
// a plan contradicting another, a decision open in one place and ruled in
|
|
12
|
+
// another, a citation that no longer says what it claims. A session, a person
|
|
13
|
+
// or a check adds one; no model is needed. A later pass may build context onto
|
|
14
|
+
// an open flag, but the flag stands on what its author wrote.
|
|
15
|
+
//
|
|
16
|
+
// Storage is an append-only event log (`add`, `accept`, `reject`, `resolve`),
|
|
17
|
+
// one JSON object per line, so nothing is overwritten and the triage record
|
|
18
|
+
// survives as written. The current state of a flag is derived from its events.
|
|
19
|
+
// Default path `.runlist/flags.jsonl`; `export const flags = { file }` moves it.
|
|
20
|
+
|
|
21
|
+
export const SEVERITIES = ['problem', 'warn', 'info'];
|
|
22
|
+
const TRIAGE = new Set(['accept', 'reject', 'resolve']);
|
|
23
|
+
const QUOTE_MAX = 200;
|
|
24
|
+
|
|
25
|
+
export function flagsFile(config) {
|
|
26
|
+
const configured = config.raw?.flags?.file;
|
|
27
|
+
return configured ? path.resolve(config.repoRoot, configured) : path.join(stateDir(config.repoRoot), 'flags.jsonl');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readFlagEvents(file) {
|
|
31
|
+
if (!existsSync(file)) return [];
|
|
32
|
+
const events = [];
|
|
33
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
34
|
+
if (!line.trim()) continue;
|
|
35
|
+
try { events.push(JSON.parse(line)); } catch { /* a torn line is skipped, never fatal */ }
|
|
36
|
+
}
|
|
37
|
+
return events;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function deriveFlags(events) {
|
|
41
|
+
const flags = new Map();
|
|
42
|
+
for (const e of events) {
|
|
43
|
+
if (e.event === 'add' && e.id && !flags.has(e.id)) {
|
|
44
|
+
flags.set(e.id, { ...e, state: 'open', triage: null, history: [] });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const flag = flags.get(e.id);
|
|
48
|
+
if (!flag || !TRIAGE.has(e.event)) continue;
|
|
49
|
+
flag.history.push({ event: e.event, at: e.at, by: e.by, note: e.note ?? null });
|
|
50
|
+
if (e.event === 'accept') flag.triage = 'accepted';
|
|
51
|
+
if (e.event === 'reject') { flag.triage = 'rejected'; flag.state = 'closed'; }
|
|
52
|
+
if (e.event === 'resolve') flag.state = 'resolved';
|
|
53
|
+
}
|
|
54
|
+
return [...flags.values()];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function nextId(flags) {
|
|
58
|
+
let max = 0;
|
|
59
|
+
for (const f of flags) max = Math.max(max, Number(String(f.id).replace(/^F/, '')) || 0);
|
|
60
|
+
return `F${max + 1}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// `--by check:<name>`, `--by model:<name>`, `--by person:<name>`; otherwise the
|
|
64
|
+
// session the environment names, or the person at the keyboard.
|
|
65
|
+
export function resolveAuthor(byArg, env = process.env) {
|
|
66
|
+
if (byArg) {
|
|
67
|
+
const m = byArg.match(/^(session|person|check|model):(.+)$/);
|
|
68
|
+
if (!m) die('--by is <kind>:<name>, where kind is session, person, check or model.');
|
|
69
|
+
return { kind: m[1], name: m[2].trim() };
|
|
70
|
+
}
|
|
71
|
+
const source = hostSessionSource(env);
|
|
72
|
+
if (source?.scope === 'session') return { kind: 'session', name: source.host, session: source.id };
|
|
73
|
+
return { kind: 'person', name: os.userInfo().username };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function normalizeText(text) {
|
|
77
|
+
return text.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// `docs/plans/x.md:12` → file and line; the file must exist in the repo and
|
|
81
|
+
// the line must be inside it. The line's text is kept, so a later read can say
|
|
82
|
+
// whether the place still says what was flagged.
|
|
83
|
+
export function resolvePlace(place, config) {
|
|
84
|
+
const m = place.match(/^(.*?)(?::(\d+))?$/);
|
|
85
|
+
const rel = m[1];
|
|
86
|
+
const line = m[2] ? Number(m[2]) : null;
|
|
87
|
+
const abs = path.resolve(config.repoRoot, rel);
|
|
88
|
+
if (!existsSync(abs)) die(`No such file: ${rel}`);
|
|
89
|
+
const repoPath = toRepoPath(abs, config.repoRoot);
|
|
90
|
+
if (repoPath.startsWith('..')) die(`${rel} is outside the repository.`);
|
|
91
|
+
let quote = null;
|
|
92
|
+
if (line !== null) {
|
|
93
|
+
const lines = readFileSync(abs, 'utf8').split('\n');
|
|
94
|
+
if (line < 1 || line > lines.length) die(`${repoPath} has ${lines.length} lines; there is no line ${line}.`);
|
|
95
|
+
quote = lines[line - 1].trim().slice(0, QUOTE_MAX);
|
|
96
|
+
}
|
|
97
|
+
return { file: repoPath, line, quote };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Where the flagged text is now: unchanged, moved to another line, or gone.
|
|
101
|
+
export function locateFlag(flag, config) {
|
|
102
|
+
if (flag.line == null) return { status: existsSync(path.resolve(config.repoRoot, flag.file)) ? 'here' : 'gone' };
|
|
103
|
+
let lines;
|
|
104
|
+
try { lines = readFileSync(path.resolve(config.repoRoot, flag.file), 'utf8').split('\n'); }
|
|
105
|
+
catch { return { status: 'gone' }; }
|
|
106
|
+
if ((lines[flag.line - 1] ?? '').trim().slice(0, QUOTE_MAX) === flag.quote) return { status: 'here', line: flag.line };
|
|
107
|
+
if (flag.quote) {
|
|
108
|
+
const at = lines.findIndex(l => l.trim().slice(0, QUOTE_MAX) === flag.quote);
|
|
109
|
+
if (at !== -1) return { status: 'moved', line: at + 1 };
|
|
110
|
+
}
|
|
111
|
+
return { status: 'changed' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function withFlagsLock(config, fn) {
|
|
115
|
+
const file = flagsFile(config);
|
|
116
|
+
const dir = path.dirname(file);
|
|
117
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
118
|
+
return withPathLocks([file], { repoRoot: config.repoRoot }, () => fn(file));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function append(file, event) {
|
|
122
|
+
const existing=existsSync(file)?readFileSync(file,'utf8'):'';
|
|
123
|
+
const prefix=existing&&!existing.endsWith('\n')?'\n':'';
|
|
124
|
+
const fd=openSync(file,'a');try{appendFileSync(fd,`${prefix}${JSON.stringify(event)}\n`);fsyncSync(fd);}finally{closeSync(fd);}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function addFlag(config, { place, text, severity = 'warn', by = null }) {
|
|
128
|
+
if (!text || !text.trim()) die('A flag says what is wrong: runlist flag add <file[:line]> "<what is wrong>"');
|
|
129
|
+
if (!SEVERITIES.includes(severity)) die(`--severity is one of ${SEVERITIES.join(', ')}.`);
|
|
130
|
+
const where = resolvePlace(place, config);
|
|
131
|
+
const author = typeof by === 'object' && by ? by : resolveAuthor(by);
|
|
132
|
+
return withFlagsLock(config, file => {
|
|
133
|
+
const flags = deriveFlags(readFlagEvents(file));
|
|
134
|
+
const key = normalizeText(text);
|
|
135
|
+
const same = flags.find(f => f.state === 'open' && f.file === where.file && f.line === where.line && normalizeText(f.text) === key);
|
|
136
|
+
if (same) return { flag: same, added: false };
|
|
137
|
+
const event = { event: 'add', id: nextId(flags), at: nowIso(), ...where, text: text.trim(), severity, by: author };
|
|
138
|
+
append(file, event);
|
|
139
|
+
return { flag: { ...event, state: 'open', triage: null, history: [] }, added: true };
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function flagRevision(flag) { return `sha256:${createHash('sha256').update(JSON.stringify(flag)).digest('hex')}`; }
|
|
144
|
+
function flagError(code,message){const error=new Error(message);error.code=code;throw error;}
|
|
145
|
+
export function triageFlag(config, { id, event, note = null, by = null, expectedRevision, operationId = null }) {
|
|
146
|
+
if(!TRIAGE.has(event))flagError('flag-invalid-action','Choose accept, reject or resolve.');
|
|
147
|
+
if(operationId&&!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(operationId))flagError('flag-invalid-operation','A valid operation UUID is required.');
|
|
148
|
+
const author = typeof by === 'object' && by ? by : resolveAuthor(by);
|
|
149
|
+
const requestHash=createHash('sha256').update(JSON.stringify({id,event,note,author,expectedRevision})).digest('hex');
|
|
150
|
+
return withFlagsLock(config, file => {
|
|
151
|
+
const events=readFlagEvents(file),flags=deriveFlags(events),prior=operationId&&events.find(e=>e.operationId===operationId);
|
|
152
|
+
if(prior){if(prior.requestHash!==requestHash)flagError('flag-operation-reused','This operation ID belongs to another reviewed request.');return {...flags.find(f=>f.id===id),replayed:true};}
|
|
153
|
+
const flag = flags.find(f => f.id === id);
|
|
154
|
+
if (!flag) die(`No flag ${id}.`);
|
|
155
|
+
if(expectedRevision!==undefined&&expectedRevision!==flagRevision(flag))flagError('flag-revision-conflict','This flag changed. Reload it and review the current history before retrying.');
|
|
156
|
+
if (flag.state !== 'open') die(`${id} is already ${flag.state === 'closed' ? 'rejected' : flag.state}.`);
|
|
157
|
+
const entry={event,id,at:nowIso(),by:author,...(note?{note}:{}),...(operationId?{operationId,requestHash}:{})};
|
|
158
|
+
append(file,entry);
|
|
159
|
+
return {...deriveFlags([...events,entry]).find(f=>f.id===id),replayed:false};
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function openFlags(config) {
|
|
164
|
+
const rank = f => SEVERITIES.indexOf(f.severity);
|
|
165
|
+
return deriveFlags(readFlagEvents(flagsFile(config)))
|
|
166
|
+
.filter(f => f.state === 'open')
|
|
167
|
+
.sort((a, b) => rank(a) - rank(b) || String(b.at).localeCompare(String(a.at)));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A check's flags follow what the check reports: each error it reports is
|
|
171
|
+
// flagged once, and a flag it raised earlier that it no longer reports is
|
|
172
|
+
// resolved only when the caller explicitly confirms complete scan coverage.
|
|
173
|
+
// Without that confirmation a partial/failed scan may add observations, but
|
|
174
|
+
// absence from its findings is not evidence that an earlier flag was fixed.
|
|
175
|
+
// A finding is matched to its open flag on file and text, not line: a check
|
|
176
|
+
// reports the same problem at a new line after an edit above it, and that is
|
|
177
|
+
// still one flag.
|
|
178
|
+
export function syncCheckFlags(config, checkName, findings, { complete = false, files = null } = {}) {
|
|
179
|
+
const by = { kind: 'check', name: checkName };
|
|
180
|
+
const keyOf = f => `${f.file}\0${normalizeText(f.text)}`;
|
|
181
|
+
const current = new Set(findings.map(keyOf));
|
|
182
|
+
const open = new Set(openFlags(config).filter(f => f.by?.kind === 'check' && f.by?.name === checkName).map(keyOf));
|
|
183
|
+
let added = 0;
|
|
184
|
+
let resolved = 0;
|
|
185
|
+
let additionsComplete = true;
|
|
186
|
+
for (const finding of findings) {
|
|
187
|
+
if (open.has(keyOf(finding))) continue;
|
|
188
|
+
if (!existsSync(path.resolve(config.repoRoot, finding.file))) { additionsComplete = false; continue; }
|
|
189
|
+
const place = finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
|
190
|
+
const severity = SEVERITIES.includes(finding.severity) ? finding.severity : 'problem';
|
|
191
|
+
try {
|
|
192
|
+
if (addFlag(config, { place, text: finding.text, severity, by }).added) { added++; open.add(keyOf(finding)); }
|
|
193
|
+
} catch { additionsComplete = false; /* retry on the next complete scan */ }
|
|
194
|
+
}
|
|
195
|
+
if (!complete || !additionsComplete) return { added, resolved };
|
|
196
|
+
const covered = files === null ? null : new Set(files);
|
|
197
|
+
for (const flag of openFlags(config)) {
|
|
198
|
+
if (flag.by?.kind !== 'check' || flag.by?.name !== checkName) continue;
|
|
199
|
+
if (covered && !covered.has(flag.file)) continue;
|
|
200
|
+
if (current.has(`${flag.file}\0${normalizeText(flag.text)}`)) continue;
|
|
201
|
+
triageFlag(config, { id: flag.id, event: 'resolve', note: `no longer reported by ${checkName}`, by });
|
|
202
|
+
resolved++;
|
|
203
|
+
}
|
|
204
|
+
return { added, resolved };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function authorLabel(by) {
|
|
208
|
+
if (!by) return 'unknown';
|
|
209
|
+
return by.kind === 'session' ? `${by.name} session ${String(by.session ?? '').slice(0, 8)}` : `${by.kind} ${by.name}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function placeLabel(flag, config) {
|
|
213
|
+
const where = locateFlag(flag, config);
|
|
214
|
+
const base = flag.line != null ? `${flag.file}:${flag.line}` : flag.file;
|
|
215
|
+
if (where.status === 'moved') return `${base} ${dim(`(now line ${where.line})`)}`;
|
|
216
|
+
if (where.status === 'changed') return `${base} ${yellow('(text there has changed)')}`;
|
|
217
|
+
if (where.status === 'gone') return `${base} ${yellow('(file gone)')}`;
|
|
218
|
+
return base;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const severityLabel = s => (s === 'problem' ? red(s) : s === 'warn' ? yellow(s) : dim(s));
|
|
222
|
+
|
|
223
|
+
// One line for the session-start banner, or null when nothing is open.
|
|
224
|
+
export function flagsHudLine(config, { top = 3 } = {}) {
|
|
225
|
+
let open;
|
|
226
|
+
try { open = openFlags(config); } catch { return null; }
|
|
227
|
+
if (open.length === 0) return null;
|
|
228
|
+
const problems = open.filter(f => f.severity === 'problem').length;
|
|
229
|
+
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('; ');
|
|
230
|
+
return `[runlist] ${open.length} open flag${open.length === 1 ? '' : 's'}${problems ? `, ${problems} problem${problems === 1 ? '' : 's'}` : ''}, for awareness: ${items}. List: \`runlist flags\`.`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function runFlags(argv, config) {
|
|
234
|
+
const json = argv.includes('--json');
|
|
235
|
+
const all = argv.includes('--all');
|
|
236
|
+
const events = readFlagEvents(flagsFile(config));
|
|
237
|
+
const flags = deriveFlags(events)
|
|
238
|
+
.filter(f => all || f.state === 'open')
|
|
239
|
+
.sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity) || String(b.at).localeCompare(String(a.at)));
|
|
240
|
+
if (json) {
|
|
241
|
+
process.stdout.write(`${JSON.stringify(flags.map(f => ({ ...f, location: locateFlag(f, config) })), null, 2)}\n`);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (flags.length === 0) {
|
|
245
|
+
process.stdout.write(dim(all ? 'No flags.\n' : 'No open flags.\n'));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
for (const f of flags) {
|
|
249
|
+
const state = f.state === 'open' ? (f.triage === 'accepted' ? green('accepted') : '') : dim(f.state === 'closed' ? 'rejected' : f.state);
|
|
250
|
+
process.stdout.write(`${bold(f.id)} ${severityLabel(f.severity)} ${placeLabel(f, config)} ${state}\n`);
|
|
251
|
+
process.stdout.write(` ${f.text}\n`);
|
|
252
|
+
process.stdout.write(dim(` ${authorLabel(f.by)}, ${f.at}`) + '\n');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function runFlag(argv, config) {
|
|
257
|
+
const [sub, ...rest] = argv;
|
|
258
|
+
const positional = [];
|
|
259
|
+
let severity;
|
|
260
|
+
let by = null;
|
|
261
|
+
let note = null;
|
|
262
|
+
for (let i = 0; i < rest.length; i++) {
|
|
263
|
+
const a = rest[i];
|
|
264
|
+
if (a === '--severity' && rest[i + 1]) { severity = rest[++i]; continue; }
|
|
265
|
+
if (a === '--by' && rest[i + 1]) { by = rest[++i]; continue; }
|
|
266
|
+
if (a === '--note' && rest[i + 1]) { note = rest[++i]; continue; }
|
|
267
|
+
if (a === '--config') { i++; continue; }
|
|
268
|
+
if (a.startsWith('-')) continue;
|
|
269
|
+
positional.push(a);
|
|
270
|
+
}
|
|
271
|
+
const usage = 'Usage: runlist flag add <file[:line]> "<what is wrong>" [--severity problem|warn|info]\n'
|
|
272
|
+
+ ' runlist flag accept|reject|resolve <id> [--note "..."]\n'
|
|
273
|
+
+ ' runlist flag show <id>\n'
|
|
274
|
+
+ ' runlist flag sync <check-name> [@findings.json | -]';
|
|
275
|
+
|
|
276
|
+
if (sub === 'add') {
|
|
277
|
+
const [place, ...words] = positional;
|
|
278
|
+
if (!place) die(usage);
|
|
279
|
+
const { flag, added } = addFlag(config, { place, text: words.join(' '), severity, by });
|
|
280
|
+
process.stdout.write(added ? `${green('Flagged')} ${flag.id} ${placeLabel(flag, config)}\n` : `${dim('Already open as')} ${flag.id}\n`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (TRIAGE.has(sub)) {
|
|
284
|
+
const [id] = positional;
|
|
285
|
+
if (!id) die(usage);
|
|
286
|
+
triageFlag(config, { id, event: sub, note, by });
|
|
287
|
+
process.stdout.write(`${green({ accept: 'Accepted', reject: 'Rejected', resolve: 'Resolved' }[sub])} ${id}\n`);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (sub === 'sync') {
|
|
291
|
+
// A check outside runlist hands over everything it reports now, as a JSON
|
|
292
|
+
// array of { file, line?, text, severity? }; the list follows it.
|
|
293
|
+
const [name, source] = positional;
|
|
294
|
+
if (!name) die('Usage: runlist flag sync <check-name> [@findings.json | -] (JSON array of { file, line?, text, severity? })');
|
|
295
|
+
let raw;
|
|
296
|
+
try { raw = source && source !== '-' ? readFileSync(source.replace(/^@/, ''), 'utf8') : readFileSync(0, 'utf8'); }
|
|
297
|
+
catch (err) { die(`Could not read the findings: ${err.message}`); }
|
|
298
|
+
let findings;
|
|
299
|
+
try { findings = JSON.parse(raw); } catch { die('The findings are not valid JSON.'); }
|
|
300
|
+
if (!Array.isArray(findings) || findings.some(f => typeof f?.file !== 'string' || typeof f?.text !== 'string')) {
|
|
301
|
+
die('The findings are a JSON array of { file, line?, text, severity? }.');
|
|
302
|
+
}
|
|
303
|
+
// `flag sync` supplies a complete snapshot for this named external check.
|
|
304
|
+
const { added, resolved } = syncCheckFlags(config, name, findings, { complete: true });
|
|
305
|
+
process.stdout.write(`flags from ${name}: ${added} added, ${resolved} resolved\n`);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (sub === 'show') {
|
|
309
|
+
const flag = deriveFlags(readFlagEvents(flagsFile(config))).find(f => f.id === positional[0]);
|
|
310
|
+
if (!flag) die(`No flag ${positional[0] ?? ''}.`);
|
|
311
|
+
process.stdout.write(`${bold(flag.id)} ${severityLabel(flag.severity)} ${placeLabel(flag, config)}\n ${flag.text}\n`);
|
|
312
|
+
if (flag.quote) process.stdout.write(dim(` flagged line: ${flag.quote}`) + '\n');
|
|
313
|
+
process.stdout.write(dim(` raised by ${authorLabel(flag.by)}, ${flag.at}`) + '\n');
|
|
314
|
+
for (const h of flag.history) process.stdout.write(dim(` ${h.event} by ${authorLabel(h.by)}, ${h.at}${h.note ? `: ${h.note}` : ''}`) + '\n');
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
die(usage);
|
|
318
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
3
|
+
import { asString, toRepoPath, escapeRegex, warn } from './util.mjs';
|
|
4
|
+
import { collectDocFiles } from './index.mjs';
|
|
5
|
+
import { bold, green, dim } from './color.mjs';
|
|
6
|
+
import { authorizeManagedSweep } from './managed-path.mjs';
|
|
7
|
+
|
|
8
|
+
// Caps must stay in lockstep with the warnings emitted by validatePlanShape in
|
|
9
|
+
// src/validate.mjs — that's where the user first sees these numbers. Targets
|
|
10
|
+
// are deliberately under the cap so a fix-then-edit cycle doesn't reintroduce
|
|
11
|
+
// the warning on the next few-word touch-up.
|
|
12
|
+
const FIELDS = [
|
|
13
|
+
{ name: 'current_state', cap: 1500, target: 1200, heading: '## Current State' },
|
|
14
|
+
{ name: 'next_step', cap: 800, target: 600, heading: '## Next Step' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function runFrontmatterFix(config, opts = {}) {
|
|
18
|
+
const { dryRun, out = process.stdout } = opts;
|
|
19
|
+
const allFiles = collectDocFiles(config);
|
|
20
|
+
authorizeManagedSweep(allFiles, config, { kind: 'Frontmatter fix source' });
|
|
21
|
+
const results = [];
|
|
22
|
+
|
|
23
|
+
for (const filePath of allFiles) {
|
|
24
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
25
|
+
const { frontmatter: fm, body } = extractFrontmatter(raw);
|
|
26
|
+
if (!fm) continue;
|
|
27
|
+
const parsed = parseSimpleFrontmatter(fm);
|
|
28
|
+
const docType = asString(parsed.type);
|
|
29
|
+
// The warnings only fire for type: plan (validatePlanShape). Untyped docs
|
|
30
|
+
// skip the warning too, so skip them here as well — auto-injecting a
|
|
31
|
+
// `## Current State` into a non-plan doc would be surprising.
|
|
32
|
+
if (docType !== 'plan') continue;
|
|
33
|
+
const status = asString(parsed.status);
|
|
34
|
+
if (config.lifecycle.terminalStatuses.has(status)
|
|
35
|
+
|| config.lifecycle.archiveStatuses.has(status)
|
|
36
|
+
|| config.lifecycle.skipsWarnings(status, docType)) continue;
|
|
37
|
+
|
|
38
|
+
const ops = [];
|
|
39
|
+
for (const { name, cap, target, heading } of FIELDS) {
|
|
40
|
+
const value = asString(parsed[name]);
|
|
41
|
+
if (!value || value.length <= cap) continue;
|
|
42
|
+
const { head, tail } = splitAtBoundary(value, target);
|
|
43
|
+
if (!tail) continue;
|
|
44
|
+
ops.push({ field: name, heading, before: value.length, head, tail });
|
|
45
|
+
}
|
|
46
|
+
if (ops.length === 0) continue;
|
|
47
|
+
|
|
48
|
+
let newFm = fm;
|
|
49
|
+
let newBody = body;
|
|
50
|
+
for (const op of ops) {
|
|
51
|
+
newFm = replaceFrontmatterField(newFm, op.field, op.head);
|
|
52
|
+
newBody = insertOrAppendSection(newBody, op.heading, op.tail);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!dryRun) {
|
|
56
|
+
writeFileSync(filePath, `---\n${newFm}\n---\n${newBody}`, 'utf8');
|
|
57
|
+
try { config.hooks.onLint?.({ path: toRepoPath(filePath, config.repoRoot), fixes: ops.map(o => ({ field: o.field, type: 'frontmatter-fix' })) }); } catch (err) { warn(`Hook 'onLint' threw: ${err.message}`); }
|
|
58
|
+
}
|
|
59
|
+
results.push({ filePath, repoPath: toRepoPath(filePath, config.repoRoot), ops });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
63
|
+
const banner = dryRun ? dim(' [preview — run without --dry-run to write]') : '';
|
|
64
|
+
out.write(bold('runlist doctor --frontmatter-fix') + banner + '\n\n');
|
|
65
|
+
|
|
66
|
+
if (results.length === 0) {
|
|
67
|
+
out.write(green('No over-cap fields found.') + '\n');
|
|
68
|
+
return { results };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
out.write(`${results.length} file(s) with over-cap fields:\n\n`);
|
|
72
|
+
for (const r of results) {
|
|
73
|
+
out.write(` ${r.repoPath}\n`);
|
|
74
|
+
for (const op of r.ops) {
|
|
75
|
+
const moved = op.before - op.head.length;
|
|
76
|
+
out.write(dim(` ${prefix}${op.field}: ${op.before} → ${op.head.length} chars (moved ${moved} to \`${op.heading}\`)\n`));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
out.write(`\n${prefix}${green(dryRun ? 'Would fix' : 'Fixed')}: ${results.length} file(s)\n`);
|
|
80
|
+
return { results };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Splits at the last sentence-ending punctuation (`.!?` + space/newline/EOS)
|
|
84
|
+
// within `target` chars. If no good sentence boundary lands in the back half of
|
|
85
|
+
// the window, falls back to the last whitespace. If even that fails, a hard
|
|
86
|
+
// cut at `target` — the result still parses; readability just suffers.
|
|
87
|
+
export function splitAtBoundary(value, target) {
|
|
88
|
+
if (value.length <= target) return { head: value, tail: '' };
|
|
89
|
+
|
|
90
|
+
const windowStr = value.slice(0, target);
|
|
91
|
+
// Sentence boundary: greedy match capturing everything up to the last `.!?`
|
|
92
|
+
// followed by whitespace or end-of-string inside the window.
|
|
93
|
+
const sentenceRe = /^[\s\S]*[.!?](?=\s|$)/;
|
|
94
|
+
const sentenceMatch = windowStr.match(sentenceRe);
|
|
95
|
+
if (sentenceMatch && sentenceMatch[0].length >= Math.floor(target / 2)) {
|
|
96
|
+
const splitIdx = sentenceMatch[0].length;
|
|
97
|
+
return {
|
|
98
|
+
head: value.slice(0, splitIdx).trim(),
|
|
99
|
+
tail: value.slice(splitIdx).trim(),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const wsIdx = Math.max(windowStr.lastIndexOf(' '), windowStr.lastIndexOf('\n'));
|
|
104
|
+
if (wsIdx >= Math.floor(target / 2)) {
|
|
105
|
+
return {
|
|
106
|
+
head: value.slice(0, wsIdx).trim(),
|
|
107
|
+
tail: value.slice(wsIdx + 1).trim(),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
head: value.slice(0, target).trim(),
|
|
113
|
+
tail: value.slice(target).trim(),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Replace a single frontmatter scalar with a folded block scalar (`key: >\n …`).
|
|
118
|
+
// Folded form is safe regardless of YAML-special chars in the value (colons,
|
|
119
|
+
// quotes, leading dashes) and matches what `parseSimpleFrontmatter` already
|
|
120
|
+
// reads. Consumes the existing key's continuation block if it was multi-line.
|
|
121
|
+
export function replaceFrontmatterField(fm, key, newValue) {
|
|
122
|
+
const lines = fm.split('\n');
|
|
123
|
+
const out = [];
|
|
124
|
+
let i = 0;
|
|
125
|
+
let replaced = false;
|
|
126
|
+
|
|
127
|
+
while (i < lines.length) {
|
|
128
|
+
const line = lines[i];
|
|
129
|
+
const keyRe = new RegExp(`^${escapeRegex(key)}:(.*)$`);
|
|
130
|
+
const match = !replaced && line.match(keyRe);
|
|
131
|
+
if (match) {
|
|
132
|
+
const rest = match[1].trim();
|
|
133
|
+
const isBlock = /^[>|][-+]?\s*$/.test(rest);
|
|
134
|
+
i++;
|
|
135
|
+
if (isBlock || rest === '') {
|
|
136
|
+
// Consume only scalar continuation lines. Top-level comments and other
|
|
137
|
+
// YAML syntax belong to the frontmatter, not to this field.
|
|
138
|
+
while (i < lines.length && (lines[i].trim() === '' || /^[ \t]/.test(lines[i]))) i++;
|
|
139
|
+
}
|
|
140
|
+
const folded = foldBlockScalar(newValue);
|
|
141
|
+
out.push(`${key}: >`);
|
|
142
|
+
for (const fLine of folded) out.push(` ${fLine}`);
|
|
143
|
+
replaced = true;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
out.push(line);
|
|
147
|
+
i++;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!replaced) {
|
|
151
|
+
const folded = foldBlockScalar(newValue);
|
|
152
|
+
out.push(`${key}: >`);
|
|
153
|
+
for (const fLine of folded) out.push(` ${fLine}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return out.join('\n');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Collapse whitespace into a single line — folded block scalar joins on a
|
|
160
|
+
// single space anyway, so writing one wide line keeps the diff tight and
|
|
161
|
+
// round-trips identically.
|
|
162
|
+
function foldBlockScalar(value) {
|
|
163
|
+
const single = value.replace(/\s+/g, ' ').trim();
|
|
164
|
+
return [single];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Ensure a `## <heading>` section exists in the body and contains `content`.
|
|
168
|
+
// If the section is present (case-insensitive heading match), append the new
|
|
169
|
+
// content to its end. If absent, insert a new section just before the first
|
|
170
|
+
// H2 (so it lands above other content sections) — or append at end if no H2
|
|
171
|
+
// exists.
|
|
172
|
+
export function insertOrAppendSection(body, heading, content) {
|
|
173
|
+
const headingPattern = new RegExp(`^${escapeRegex(heading)}\\s*$`, 'mi');
|
|
174
|
+
const existing = body.match(headingPattern);
|
|
175
|
+
if (existing && existing.index !== undefined) {
|
|
176
|
+
const startIdx = existing.index + existing[0].length;
|
|
177
|
+
const after = body.slice(startIdx);
|
|
178
|
+
const nextHeaderRel = after.search(/\n#{1,2}\s+/);
|
|
179
|
+
const sectionEnd = nextHeaderRel >= 0 ? startIdx + nextHeaderRel : body.length;
|
|
180
|
+
const before = body.slice(0, sectionEnd).replace(/\s+$/, '');
|
|
181
|
+
const rest = body.slice(sectionEnd);
|
|
182
|
+
return `${before}\n\n${content}\n${rest.startsWith('\n') ? rest : '\n' + rest}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const firstH2 = body.match(/^##\s+/m);
|
|
186
|
+
if (firstH2 && firstH2.index !== undefined) {
|
|
187
|
+
const insertIdx = firstH2.index;
|
|
188
|
+
const before = body.slice(0, insertIdx).replace(/\s+$/, '');
|
|
189
|
+
const rest = body.slice(insertIdx);
|
|
190
|
+
return `${before}\n\n${heading}\n\n${content}\n\n${rest}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const trimmed = body.replace(/\s+$/, '');
|
|
194
|
+
return `${trimmed}\n\n${heading}\n\n${content}\n`;
|
|
195
|
+
}
|