runlist 0.95.3 → 0.95.6
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 +2 -2
- package/bin/runlist.mjs +4 -4
- package/package.json +1 -1
- package/plugins/runlist-codex/.codex-plugin/plugin.json +1 -1
- package/src/commands.mjs +1 -1
- package/src/doctor.mjs +1 -1
- package/src/extractors.mjs +1 -1
- package/src/filing-categories.mjs +54 -0
- package/src/filing.mjs +2 -19
- package/src/frontmatter-fix.mjs +11 -9
- package/src/index.mjs +3 -1
- package/src/lint.mjs +86 -89
- package/src/parse-cache.mjs +1 -1
- package/src/repair-sources.mjs +23 -0
- package/src/section.mjs +20 -4
- package/src/validate.mjs +2 -2
package/README.md
CHANGED
|
@@ -47,8 +47,8 @@ controls are experimental; live retrieval depends on a compatible external
|
|
|
47
47
|
provider. Local chat and push are deferred.
|
|
48
48
|
|
|
49
49
|
<!-- runlist:release:start -->
|
|
50
|
-
CLI and desktop release version: **0.95.
|
|
51
|
-
[Mac installer and release notes](https://github.com/reowens/runlist/releases/tag/desktop-v0.95.
|
|
50
|
+
CLI and desktop release version: **0.95.6**.
|
|
51
|
+
[Mac installer and release notes](https://github.com/reowens/runlist/releases/tag/desktop-v0.95.6).
|
|
52
52
|
Normal product releases publish both components; an installed Mac app is updated with rollback after it has quit.
|
|
53
53
|
<!-- runlist:release:end -->
|
|
54
54
|
|
package/bin/runlist.mjs
CHANGED
|
@@ -293,7 +293,7 @@ Validate & Fix:
|
|
|
293
293
|
doctor --transactions Report/clear wedged mutation transactions (run this if mutations refuse repo-wide)
|
|
294
294
|
doctor --claims Report/release plan claims held by sessions that are gone ("busy in another session")
|
|
295
295
|
self-check Project/version skew diagnostic (alias: doctor --project)
|
|
296
|
-
lint [--fix] Check and auto-fix frontmatter issues
|
|
296
|
+
lint [files...] [--fix] Check and auto-fix frontmatter issues
|
|
297
297
|
fix-refs [--dry-run] Auto-fix broken reference paths + body links
|
|
298
298
|
refs <old> <new> [--fix] Report (or rewrite) code-root citations of a moved document
|
|
299
299
|
refs repair [--fix] Same, for every archived document still cited at its old path
|
|
@@ -923,7 +923,7 @@ Modes:
|
|
|
923
923
|
history; a "Migrated to v0.21 template" entry
|
|
924
924
|
in their Version History would be misleading).
|
|
925
925
|
--migrate-template --json Machine-readable result.
|
|
926
|
-
--frontmatter-fix
|
|
926
|
+
--frontmatter-fix [path] Repair only that document when supplied; otherwise auto-fix the long-frontmatter warnings that
|
|
927
927
|
\`runlist check\` flags: \`current_state\` >1500 chars
|
|
928
928
|
or \`next_step\` >800 chars. Truncates the
|
|
929
929
|
frontmatter field at the nearest sentence
|
|
@@ -1195,9 +1195,9 @@ Options:
|
|
|
1195
1195
|
--summarize Generate AI summary using local model
|
|
1196
1196
|
--model <name> Model to use (default: the one \`runlist model\` shows)`,
|
|
1197
1197
|
|
|
1198
|
-
lint: `runlist lint [--fix] — check and auto-fix frontmatter issues
|
|
1198
|
+
lint: `runlist lint [files...] [--fix] — check and auto-fix frontmatter issues
|
|
1199
1199
|
|
|
1200
|
-
Scans all docs for fixable problems:
|
|
1200
|
+
Scans the selected documents, or all docs when no file is supplied, for fixable problems:
|
|
1201
1201
|
- Missing status (inferred via local AI model when available)
|
|
1202
1202
|
- Missing updated date (set to today)
|
|
1203
1203
|
- Status casing (e.g. Active → active)
|
package/package.json
CHANGED
package/src/commands.mjs
CHANGED
|
@@ -170,7 +170,7 @@ const definitions = [
|
|
|
170
170
|
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'), flag('--render-preview'), value('--question'), value('--answers'), value('--disposition')],
|
|
171
171
|
dashPositionalsAfter: 1,
|
|
172
172
|
})]),
|
|
173
|
-
command('lint', mutates('managed
|
|
173
|
+
command('lint', mutates('selected managed sources with --fix; otherwise read-only'), 'mutate', [form('[files...]', { args: positionals(0, Infinity), options: [flag('--fix')] })]),
|
|
174
174
|
command('rename', mutates('managed source, same-root destination, and rewrite sweep'), 'mutate', [form('<old> [new]', { args: positionals(1, 2), options: [flag('--show-files'), flag('--fix-refs'), flag('--strings')] })]),
|
|
175
175
|
command('migrate', mutates('managed source sweep'), 'mutate', [form('<field> <old> <new> [files...]', { args: positionals(3, Infinity), options: [flag('--show-files')] })]),
|
|
176
176
|
command('fix-refs', mutates('managed source sweep'), 'mutate', [form('', { options: [flag('--show-files')] })]),
|
package/src/doctor.mjs
CHANGED
|
@@ -252,7 +252,7 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
252
252
|
return;
|
|
253
253
|
}
|
|
254
254
|
if (argv.includes('--frontmatter-fix')) {
|
|
255
|
-
runFrontmatterFix(config, opts);
|
|
255
|
+
runFrontmatterFix(config, { ...opts, files: argv.filter(arg => !arg.startsWith('-')) });
|
|
256
256
|
return;
|
|
257
257
|
}
|
|
258
258
|
if (argv.includes('--transactions')) {
|
package/src/extractors.mjs
CHANGED
|
@@ -81,7 +81,7 @@ export function extractBodyLinks(body) {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
export function extractChecklistCounts(body) {
|
|
84
|
-
const matches = [...body.matchAll(/^\s*[-*]\s+\[([ xX])\]\s+/gm)];
|
|
84
|
+
const matches = [...body.matchAll(/^\s*[-*]\s+\[([ xX~])\]\s+/gm)];
|
|
85
85
|
let completed = 0;
|
|
86
86
|
let open = 0;
|
|
87
87
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Condense cycles before propagating outer categories through local hub indexes.
|
|
2
|
+
export function resolveHubCategories(hubs, rows) {
|
|
3
|
+
const next = new Map(hubs.map(hub => [hub, new Set()]));
|
|
4
|
+
const prev = new Map(hubs.map(hub => [hub, new Set()]));
|
|
5
|
+
for (const child of hubs) for (const row of rows.get(child) ?? []) {
|
|
6
|
+
if (!next.has(row.hub)) continue;
|
|
7
|
+
next.get(row.hub).add(child);
|
|
8
|
+
prev.get(child).add(row.hub);
|
|
9
|
+
}
|
|
10
|
+
const seen = new Set(), order = [];
|
|
11
|
+
for (const hub of hubs) {
|
|
12
|
+
if (seen.has(hub)) continue;
|
|
13
|
+
seen.add(hub);
|
|
14
|
+
const stack = [[hub, next.get(hub).values()]];
|
|
15
|
+
while (stack.length) {
|
|
16
|
+
const [node, children] = stack.at(-1), child = children.next();
|
|
17
|
+
if (child.done) { order.push(node); stack.pop(); }
|
|
18
|
+
else if (!seen.has(child.value)) {
|
|
19
|
+
seen.add(child.value);
|
|
20
|
+
stack.push([child.value, next.get(child.value).values()]);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const component = new Map(), groups = [];
|
|
25
|
+
for (const hub of order.reverse()) {
|
|
26
|
+
if (component.has(hub)) continue;
|
|
27
|
+
const id = groups.length, stack = [hub];
|
|
28
|
+
groups.push({ categories: new Set(), fallback: new Set(), children: new Map(), incoming: 0 });
|
|
29
|
+
component.set(hub, id);
|
|
30
|
+
while (stack.length) for (const parent of prev.get(stack.pop())) {
|
|
31
|
+
if (!component.has(parent)) { component.set(parent, id); stack.push(parent); }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const child of hubs) for (const row of rows.get(child) ?? []) {
|
|
35
|
+
if (!component.has(row.hub)) continue;
|
|
36
|
+
const from = component.get(row.hub), to = component.get(child);
|
|
37
|
+
if (from === to) { if (row.category) groups[to].fallback.add(row.category); continue; }
|
|
38
|
+
if (!groups[from].children.has(to)) {
|
|
39
|
+
groups[from].children.set(to, new Set());
|
|
40
|
+
groups[to].incoming++;
|
|
41
|
+
}
|
|
42
|
+
if (row.category) groups[from].children.get(to).add(row.category);
|
|
43
|
+
}
|
|
44
|
+
const queue = groups.flatMap((group, id) => group.incoming === 0 ? [id] : []);
|
|
45
|
+
for (let i = 0; i < queue.length; i++) {
|
|
46
|
+
const group = groups[queue[i]];
|
|
47
|
+
if (!group.categories.size) for (const label of group.fallback) group.categories.add(label);
|
|
48
|
+
for (const [child, labels] of group.children) {
|
|
49
|
+
for (const label of group.categories.size ? group.categories : labels) groups[child].categories.add(label);
|
|
50
|
+
if (--groups[child].incoming === 0) queue.push(child);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return new Map(hubs.map(hub => [hub, groups[component.get(hub)].categories]));
|
|
54
|
+
}
|
package/src/filing.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveHubCategories } from './filing-categories.mjs';
|
|
1
2
|
import { readFileSync } from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { extractFrontmatter } from './frontmatter.mjs';
|
|
@@ -167,26 +168,8 @@ export function assembleFilingCoverage(docs, config, preparedRows, parentTargets
|
|
|
167
168
|
|
|
168
169
|
// Explicit first-cell hub index rows pass categories to the hub's plan rows,
|
|
169
170
|
// including indexes without status columns. Hub titles are not guessed.
|
|
170
|
-
const hubCategories = new Map();
|
|
171
171
|
const liveHubs = docs.filter(doc => isHubDoc(doc) && !closed(doc, config));
|
|
172
|
-
|
|
173
|
-
const dependents = new Map();
|
|
174
|
-
const queue = [];
|
|
175
|
-
const addCategory = (hub, category) => {
|
|
176
|
-
const categories = hubCategories.get(hub);
|
|
177
|
-
if (categories && !categories.has(category)) { categories.add(category); queue.push([hub, category]); }
|
|
178
|
-
};
|
|
179
|
-
for (const hub of liveHubs) for (const row of categoryRows.get(hub.path) ?? []) {
|
|
180
|
-
if (row.category) addCategory(hub.path, row.category);
|
|
181
|
-
else {
|
|
182
|
-
if (!dependents.has(row.hub)) dependents.set(row.hub, new Set());
|
|
183
|
-
dependents.get(row.hub).add(hub.path);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
for (let i = 0; i < queue.length; i++) {
|
|
187
|
-
const [hub, category] = queue[i];
|
|
188
|
-
for (const dependent of dependents.get(hub) ?? []) addCategory(dependent, category);
|
|
189
|
-
}
|
|
172
|
+
const hubCategories = resolveHubCategories(liveHubs.map(hub => hub.path), categoryRows);
|
|
190
173
|
for (const rows of homes.values()) {
|
|
191
174
|
// Metadata reads can finish in any order. Keep CLI/GUI evidence and home
|
|
192
175
|
// pagination stable by source path and physical line, retaining duplicates.
|
package/src/frontmatter-fix.mjs
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { readFileSync
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
3
3
|
import { asString, toRepoPath, escapeRegex, warn } from './util.mjs';
|
|
4
|
-
import {
|
|
4
|
+
import { resolveRepairSources, publishRepairs } from './repair-sources.mjs';
|
|
5
5
|
import { bold, green, dim } from './color.mjs';
|
|
6
|
-
import { authorizeManagedSweep } from './managed-path.mjs';
|
|
7
6
|
|
|
8
7
|
// Caps must stay in lockstep with the warnings emitted by validatePlanShape in
|
|
9
8
|
// src/validate.mjs — that's where the user first sees these numbers. Targets
|
|
@@ -16,8 +15,8 @@ const FIELDS = [
|
|
|
16
15
|
|
|
17
16
|
export function runFrontmatterFix(config, opts = {}) {
|
|
18
17
|
const { dryRun, out = process.stdout } = opts;
|
|
19
|
-
const allFiles =
|
|
20
|
-
|
|
18
|
+
const allFiles = resolveRepairSources(opts.files ?? [], config);
|
|
19
|
+
const repairs = [];
|
|
21
20
|
const results = [];
|
|
22
21
|
|
|
23
22
|
for (const filePath of allFiles) {
|
|
@@ -52,13 +51,16 @@ export function runFrontmatterFix(config, opts = {}) {
|
|
|
52
51
|
newBody = insertOrAppendSection(newBody, op.heading, op.tail);
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
|
|
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
|
-
}
|
|
54
|
+
repairs.push({ path: filePath, expectedContent: raw, content: `---\n${newFm}\n---\n${newBody}` });
|
|
59
55
|
results.push({ filePath, repoPath: toRepoPath(filePath, config.repoRoot), ops });
|
|
60
56
|
}
|
|
61
57
|
|
|
58
|
+
publishRepairs(repairs, config, opts);
|
|
59
|
+
if (!dryRun) for (const result of results) {
|
|
60
|
+
try { config.hooks.onLint?.({ path: result.repoPath, fixes: result.ops.map(op => ({ field: op.field, type: 'frontmatter-fix' })) }); }
|
|
61
|
+
catch (err) { warn(`Hook 'onLint' threw: ${err.message}`); }
|
|
62
|
+
}
|
|
63
|
+
|
|
62
64
|
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
63
65
|
const banner = dryRun ? dim(' [preview — run without --dry-run to write]') : '';
|
|
64
66
|
out.write(bold('runlist doctor --frontmatter-fix') + banner + '\n\n');
|
package/src/index.mjs
CHANGED
|
@@ -335,12 +335,14 @@ export function parseDocFile(filePath, config, opts = {}) {
|
|
|
335
335
|
if (text && text.shipsMetadataVersion !== 1) text = null;
|
|
336
336
|
// Validation reads the body itself, so only a fast build can skip the read.
|
|
337
337
|
let body = null;
|
|
338
|
+
let bodyLineOffset = 0;
|
|
338
339
|
let rawSource = null;
|
|
339
340
|
if (!text || !fast) {
|
|
340
341
|
const raw = opts.source ?? readFileSync(filePath, 'utf8');
|
|
341
342
|
rawSource = raw;
|
|
342
343
|
const extracted = extractFrontmatter(raw);
|
|
343
344
|
body = extracted.body;
|
|
345
|
+
bodyLineOffset = extracted.bodyLineOffset;
|
|
344
346
|
// A file rewritten between the stat and the read no longer matches its stamp.
|
|
345
347
|
if (text && Buffer.byteLength(raw) !== stampSize(stamp)) text = null;
|
|
346
348
|
if (!text) {
|
|
@@ -471,7 +473,7 @@ export function parseDocFile(filePath, config, opts = {}) {
|
|
|
471
473
|
return doc;
|
|
472
474
|
}
|
|
473
475
|
validateDoc(doc, parsedFrontmatter, headingTitle, config);
|
|
474
|
-
validatePlanShape(doc, body, parsedFrontmatter, config);
|
|
476
|
+
validatePlanShape(doc, body, parsedFrontmatter, config, bodyLineOffset);
|
|
475
477
|
validateDocShape(doc, body, parsedFrontmatter, config);
|
|
476
478
|
}
|
|
477
479
|
return doc;
|
package/src/lint.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { readFileSync
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
|
|
4
4
|
import { asString, toRepoPath, escapeRegex, warn } from './util.mjs';
|
|
5
|
-
import { buildIndex
|
|
6
|
-
import {
|
|
5
|
+
import { buildIndex } from './index.mjs';
|
|
6
|
+
import { resolveRepairSources, publishRepairs } from './repair-sources.mjs';
|
|
7
|
+
import { renderLifecycleMutation } from './lifecycle.mjs';
|
|
7
8
|
import { runModel } from './ai.mjs';
|
|
8
9
|
import { bold, green, yellow, dim } from './color.mjs';
|
|
9
|
-
import {
|
|
10
|
+
import { findLexicalDocsRoot } from './managed-path.mjs';
|
|
10
11
|
|
|
11
12
|
const KEY_RENAMES = {
|
|
12
13
|
nextStep: 'next_step',
|
|
@@ -69,7 +70,8 @@ function formatYamlScalar(value) {
|
|
|
69
70
|
export function runLint(argv, config, opts = {}) {
|
|
70
71
|
const { dryRun } = opts;
|
|
71
72
|
const fix = argv.includes('--fix');
|
|
72
|
-
const allFiles =
|
|
73
|
+
const allFiles = resolveRepairSources(argv.filter(arg => !arg.startsWith('-')), config);
|
|
74
|
+
const selectedPaths = new Set(allFiles.map(file => toRepoPath(file, config.repoRoot)));
|
|
73
75
|
const fixable = [];
|
|
74
76
|
|
|
75
77
|
for (const filePath of allFiles) {
|
|
@@ -164,7 +166,7 @@ export function runLint(argv, config, opts = {}) {
|
|
|
164
166
|
}
|
|
165
167
|
|
|
166
168
|
if (fixes.length > 0) {
|
|
167
|
-
fixable.push({ filePath, repoPath, fixes });
|
|
169
|
+
fixable.push({ filePath, repoPath, fixes, source: raw });
|
|
168
170
|
}
|
|
169
171
|
}
|
|
170
172
|
|
|
@@ -187,6 +189,7 @@ export function runLint(argv, config, opts = {}) {
|
|
|
187
189
|
}
|
|
188
190
|
}
|
|
189
191
|
nonFixable = [...index.errors, ...index.warnings].filter(issue => {
|
|
192
|
+
if (!selectedPaths.has(issue.path)) return false;
|
|
190
193
|
if (issue.message.includes('Missing frontmatter `status`') && fixablePaths.has(issue.path)) return false;
|
|
191
194
|
const dep = issue.message.match(/^`(module|surface):` \(singular\) is deprecated/);
|
|
192
195
|
if (dep && fixableSingularKeys.has(`${issue.path}::${dep[1]}`)) return false;
|
|
@@ -235,11 +238,12 @@ export function runLint(argv, config, opts = {}) {
|
|
|
235
238
|
}
|
|
236
239
|
|
|
237
240
|
// Fix mode
|
|
238
|
-
|
|
241
|
+
const repairs = [];
|
|
239
242
|
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
240
243
|
let totalFixes = 0;
|
|
241
244
|
|
|
242
|
-
for (const { filePath, repoPath, fixes } of fixable) {
|
|
245
|
+
for (const { filePath, repoPath, fixes, source } of fixable) {
|
|
246
|
+
let raw = source;
|
|
243
247
|
const updates = {};
|
|
244
248
|
const keyRenames = [];
|
|
245
249
|
let needsEofFix = false;
|
|
@@ -258,101 +262,94 @@ export function runLint(argv, config, opts = {}) {
|
|
|
258
262
|
singularToPlural.push(f);
|
|
259
263
|
} else if (f.type === 'drop-singular') {
|
|
260
264
|
dropSingular.push(f);
|
|
261
|
-
} else {
|
|
265
|
+
} else if (f.type !== 'infer-status') {
|
|
262
266
|
updates[f.field] = f.newValue;
|
|
263
267
|
}
|
|
264
268
|
}
|
|
265
269
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
updateFrontmatter(filePath, { status: suggested });
|
|
282
|
-
f.newValue = suggested;
|
|
283
|
-
}
|
|
270
|
+
// Apply infer-status fixes via AI
|
|
271
|
+
for (const f of dryRun ? [] : fixes.filter(f => f.type === 'infer-status')) {
|
|
272
|
+
const { frontmatter: fmInfer } = extractFrontmatter(raw);
|
|
273
|
+
const parsedInfer = parseSimpleFrontmatter(fmInfer);
|
|
274
|
+
const inferType = asString(parsedInfer.type);
|
|
275
|
+
const inferTypeSet = inferType ? config.typeStatuses?.get(inferType) : null;
|
|
276
|
+
const statusList = inferTypeSet ? [...inferTypeSet].join(', ') : config.statusOrder.join(', ');
|
|
277
|
+
const { body } = extractFrontmatter(raw);
|
|
278
|
+
const prompt = `Given this markdown document, classify it into exactly one of these statuses: ${statusList}.\nReply with ONLY the status word, nothing else.\n\nFile: ${repoPath}\n\n${(body ?? '').slice(0, 4000)}`;
|
|
279
|
+
const result = runModel(prompt, { maxTokens: 10 });
|
|
280
|
+
const suggested = result?.trim().toLowerCase().split(/\s+/)[0];
|
|
281
|
+
const inferValid = inferTypeSet ?? config.validStatuses;
|
|
282
|
+
if (suggested && inferValid.has(suggested)) {
|
|
283
|
+
updates.status = suggested;
|
|
284
|
+
f.newValue = suggested;
|
|
284
285
|
}
|
|
286
|
+
}
|
|
285
287
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
newFm = `${newFm.trim()}\n${pluralBlock}`;
|
|
306
|
-
}
|
|
307
|
-
raw = replaceFrontmatter(raw, newFm.trim());
|
|
308
|
-
writeFileSync(filePath, raw, 'utf8');
|
|
288
|
+
// Apply singular-to-plural fixes (module/surface → modules/surfaces array).
|
|
289
|
+
// Removes the singular key block; merges its value(s) into the plural array,
|
|
290
|
+
// or creates the plural block if absent. Duplicates are skipped.
|
|
291
|
+
for (const sa of singularToPlural) {
|
|
292
|
+
const { frontmatter: fm } = extractFrontmatter(raw);
|
|
293
|
+
const parsedFm = parseSimpleFrontmatter(fm);
|
|
294
|
+
const pluralValue = parsedFm[sa.pluralKey];
|
|
295
|
+
const existing = Array.isArray(pluralValue)
|
|
296
|
+
? pluralValue.filter(value => typeof value === 'string' && value.trim())
|
|
297
|
+
: asString(pluralValue) ? [asString(pluralValue)] : [];
|
|
298
|
+
const merged = [...new Set([...sa.newValue, ...existing])];
|
|
299
|
+
const comments = [...frontmatterFieldComments(fm, sa.field), ...frontmatterFieldComments(fm, sa.pluralKey)];
|
|
300
|
+
let newFm = replaceFrontmatterFieldBlock(fm, sa.field);
|
|
301
|
+
const pluralLines = [sa.pluralKey + ':', ...comments.map(comment => ` ${comment}`), ...merged.map(v => ` - ${formatYamlScalar(v)}`)];
|
|
302
|
+
const pluralBlock = pluralLines.join('\n');
|
|
303
|
+
if (Object.prototype.hasOwnProperty.call(parsedFm, sa.pluralKey)) {
|
|
304
|
+
newFm = replaceFrontmatterFieldBlock(newFm, sa.pluralKey, pluralBlock);
|
|
305
|
+
} else {
|
|
306
|
+
newFm = `${newFm.trim()}\n${pluralBlock}`;
|
|
309
307
|
}
|
|
308
|
+
raw = replaceFrontmatter(raw, newFm.trim());
|
|
309
|
+
}
|
|
310
310
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
writeFileSync(filePath, raw, 'utf8');
|
|
321
|
-
}
|
|
311
|
+
// Drop empty deprecated singular keys. The value is known-empty (the fix
|
|
312
|
+
// is only emitted for `[]`/`''`), so this matches the bare `key:` line
|
|
313
|
+
// exactly — never a line carrying content — and removes it.
|
|
314
|
+
for (const ds of dropSingular) {
|
|
315
|
+
const { frontmatter: fm } = extractFrontmatter(raw);
|
|
316
|
+
const comments = frontmatterFieldComments(fm, ds.field);
|
|
317
|
+
const newFm = replaceFrontmatterFieldBlock(fm, ds.field, comments.join('\n'));
|
|
318
|
+
raw = replaceFrontmatter(raw, newFm.trim());
|
|
319
|
+
}
|
|
322
320
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
newFm = newFm.replace(regex, `${kr.newValue}:`);
|
|
331
|
-
}
|
|
332
|
-
for (const tf of trimFixes) {
|
|
333
|
-
const regex = new RegExp(`^(${escapeRegex(tf.field)}:)${escapeRegex(tf.oldValue)}$`, 'm');
|
|
334
|
-
newFm = newFm.replace(regex, `$1${tf.newValue}`);
|
|
335
|
-
}
|
|
336
|
-
if (newFm !== fm) {
|
|
337
|
-
raw = replaceFrontmatter(raw, newFm);
|
|
338
|
-
writeFileSync(filePath, raw, 'utf8');
|
|
339
|
-
}
|
|
321
|
+
// Apply key renames and trim fixes via raw string manipulation
|
|
322
|
+
if (keyRenames.length > 0 || trimFixes.length > 0) {
|
|
323
|
+
const { frontmatter: fm } = extractFrontmatter(raw);
|
|
324
|
+
let newFm = fm;
|
|
325
|
+
for (const kr of keyRenames) {
|
|
326
|
+
const regex = new RegExp(`^${escapeRegex(kr.oldValue)}:`, 'm');
|
|
327
|
+
newFm = newFm.replace(regex, `${kr.newValue}:`);
|
|
340
328
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
updateFrontmatter(filePath, updates);
|
|
329
|
+
for (const tf of trimFixes) {
|
|
330
|
+
const regex = new RegExp(`^(${escapeRegex(tf.field)}:)${escapeRegex(tf.oldValue)}$`, 'm');
|
|
331
|
+
newFm = newFm.replace(regex, `$1${tf.newValue}`);
|
|
345
332
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (needsEofFix) {
|
|
349
|
-
const current = readFileSync(filePath, 'utf8');
|
|
350
|
-
if (!current.endsWith('\n')) {
|
|
351
|
-
writeFileSync(filePath, current + '\n', 'utf8');
|
|
352
|
-
}
|
|
333
|
+
if (newFm !== fm) {
|
|
334
|
+
raw = replaceFrontmatter(raw, newFm);
|
|
353
335
|
}
|
|
354
336
|
}
|
|
355
337
|
|
|
338
|
+
// Apply value updates to the prepared source
|
|
339
|
+
if (Object.keys(updates).length > 0) {
|
|
340
|
+
raw = renderLifecycleMutation(raw, updates, null);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// EOF fix
|
|
344
|
+
if (needsEofFix) {
|
|
345
|
+
if (!raw.endsWith('\n')) raw += '\n';
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
repairs.push({ path: filePath, expectedContent: source, content: raw });
|
|
349
|
+
}
|
|
350
|
+
publishRepairs(repairs, config, opts);
|
|
351
|
+
|
|
352
|
+
for (const { repoPath, fixes } of fixable) {
|
|
356
353
|
process.stdout.write(`${prefix}${green('Fixed')}: ${repoPath} (${fixes.length} issue${fixes.length > 1 ? 's' : ''})\n`);
|
|
357
354
|
for (const f of fixes) {
|
|
358
355
|
if (f.type === 'rename-key') {
|
package/src/parse-cache.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { readEnv, stateDir } from './naming.mjs';
|
|
|
17
17
|
// a missing, unreadable or foreign-version file is treated as empty, a failed
|
|
18
18
|
// write is dropped, and `RUNLIST_NO_PARSE_CACHE=1` turns it off.
|
|
19
19
|
|
|
20
|
-
const SCHEMA =
|
|
20
|
+
const SCHEMA = 2;
|
|
21
21
|
const FILE_NAME = 'parse-cache';
|
|
22
22
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
23
|
const VERSION = (() => {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { collectDocFiles, resolveDocArg } from './index.mjs';
|
|
2
|
+
import { authorizeManagedSweep } from './managed-path.mjs';
|
|
3
|
+
import { assertPlanMutationAuthorized, ownershipPathFor, availableSessionId } from './pickup.mjs';
|
|
4
|
+
import { mutateFileSet } from './atomic-mutation.mjs';
|
|
5
|
+
|
|
6
|
+
export function resolveRepairSources(inputs, config) {
|
|
7
|
+
const files = inputs.length ? [...new Set(inputs.map(input => resolveDocArg(input, config)))] : collectDocFiles(config);
|
|
8
|
+
authorizeManagedSweep(files, config, { kind: 'Repair source' });
|
|
9
|
+
return files;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function publishRepairs(repairs, config, opts = {}) {
|
|
13
|
+
const changed = repairs.filter(item => item.content !== item.expectedContent);
|
|
14
|
+
const guards = changed.map(item => {
|
|
15
|
+
const ownership = assertPlanMutationAuthorized(item.path, config, { sessionId: availableSessionId() });
|
|
16
|
+
return ownership ? { path: ownership.recordPath, expectedContent: ownership.raw }
|
|
17
|
+
: { path: ownershipPathFor(item.path, config), absent: true };
|
|
18
|
+
});
|
|
19
|
+
if (opts.dryRun || !changed.length) return;
|
|
20
|
+
mutateFileSet({ updates: changed.map(item => ({ path: item.path,
|
|
21
|
+
expectedContent: item.expectedContent, render: () => item.content })), guards },
|
|
22
|
+
{ repoRoot: config.repoRoot, testHooks: opts.testHooks });
|
|
23
|
+
}
|
package/src/section.mjs
CHANGED
|
@@ -73,7 +73,7 @@ export function findSection(sections, name) {
|
|
|
73
73
|
const GLYPH_PATTERNS = [
|
|
74
74
|
{ kind: 'shipped', re: /(✅|☑|✔|✓)/ },
|
|
75
75
|
{ kind: 'skipped', re: /(⏭)/ },
|
|
76
|
-
{ kind: 'in-progress', re: /(
|
|
76
|
+
{ kind: 'in-progress', re: /(🟡|🟨|🔄)/ },
|
|
77
77
|
{ kind: 'blocked', re: /(🚧|🔴)/ },
|
|
78
78
|
{ kind: 'todo', re: /(⬜|⬛|◻|☐)/ },
|
|
79
79
|
];
|
|
@@ -115,7 +115,7 @@ export function detectMarker(heading) {
|
|
|
115
115
|
// and the space, is not \s, and ends the run — so a heading led by the
|
|
116
116
|
// emoji-presentation form was not a phase heading at all, while the bare
|
|
117
117
|
// codepoint was. Reported by the owner, 2026-08-16.
|
|
118
|
-
const PHASE_DECORATION = String.raw`[\s>*_~\`#-]*(?:[
|
|
118
|
+
const PHASE_DECORATION = String.raw`[\s>*_~\`#-]*(?:[✅🚧⬜🟡🟨⏭☑✔✓◻☐⬛🔴🔄][︎️]?\s*)*`;
|
|
119
119
|
const PHASE_LEAD = new RegExp(`^${PHASE_DECORATION}phase\\b`, 'i');
|
|
120
120
|
const FILES_MANIFEST_ANCESTOR = new RegExp(`^${PHASE_DECORATION}files\\b`, 'i');
|
|
121
121
|
|
|
@@ -154,12 +154,28 @@ const PHASE_COMPOUND_COMMENTARY = new RegExp(
|
|
|
154
154
|
'i',
|
|
155
155
|
);
|
|
156
156
|
|
|
157
|
+
// Dated execution receipts describe an attempt or acceptance record, not a work phase.
|
|
158
|
+
const PHASE_DATED_RECEIPT = new RegExp(
|
|
159
|
+
`^${PHASE_DECORATION}phase\\s+\\S+\\s+` +
|
|
160
|
+
`(?:execution|completed[- ]work\\s+record|personal\\s+billing\\s+acceptance|portal\\s+attempt|` +
|
|
161
|
+
`receipts?|state|verification|build|shaping\\s+walk)` +
|
|
162
|
+
`\\s*,\\s*(?:closed\\s+)?(?:[A-Z][a-z]+\\s+\\d{1,2},\\s+\\d{4}|\\d{4}-\\d{2}-\\d{2})\\b`,
|
|
163
|
+
'i',
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const PHASE_RECORDED_MECHANICS = new RegExp(
|
|
167
|
+
`^${PHASE_DECORATION}phase\\s+\\S+\\s+mechanics\\s*,\\s*recorded\\b`,
|
|
168
|
+
'i',
|
|
169
|
+
);
|
|
170
|
+
|
|
157
171
|
export function isPhaseHeading(section) {
|
|
158
172
|
if (section.level !== 3) return false;
|
|
159
173
|
if (FILES_MANIFEST_ANCESTOR.test(section.h2Ancestor ?? '')) return false;
|
|
160
174
|
return PHASE_LEAD.test(section.heading)
|
|
161
175
|
&& !PHASE_COMMENTARY.test(section.heading)
|
|
162
|
-
&& !PHASE_COMPOUND_COMMENTARY.test(section.heading)
|
|
176
|
+
&& !PHASE_COMPOUND_COMMENTARY.test(section.heading)
|
|
177
|
+
&& !PHASE_DATED_RECEIPT.test(section.heading)
|
|
178
|
+
&& !PHASE_RECORDED_MECHANICS.test(section.heading);
|
|
163
179
|
}
|
|
164
180
|
|
|
165
181
|
// A phase's OWN checklist — the boxes directly under its heading, stopping at
|
|
@@ -170,7 +186,7 @@ export function phaseTally(section) {
|
|
|
170
186
|
for (const line of String(section?.body ?? '').split('\n')) {
|
|
171
187
|
if (/^#{1,6}\s/.test(line)) break;
|
|
172
188
|
if (/^\s*[-*]\s*\[x\]/i.test(line)) checked++;
|
|
173
|
-
else if (/^\s*[-*]\s*\[ \]/.test(line)) unchecked++;
|
|
189
|
+
else if (/^\s*[-*]\s*\[[ ~]\]/.test(line)) unchecked++;
|
|
174
190
|
}
|
|
175
191
|
return { checked, unchecked, total: checked + unchecked };
|
|
176
192
|
}
|
package/src/validate.mjs
CHANGED
|
@@ -626,7 +626,7 @@ export function checkGitStaleness(docs, config, options = {}) {
|
|
|
626
626
|
|
|
627
627
|
// Plan-shape lint: soft warnings on convention drift. Plan-only.
|
|
628
628
|
// Body is the unparsed plan body (everything after the closing `---`).
|
|
629
|
-
export function validatePlanShape(doc, body, frontmatter, config) {
|
|
629
|
+
export function validatePlanShape(doc, body, frontmatter, config, bodyLineOffset = 0) {
|
|
630
630
|
if (doc.type !== 'plan') return;
|
|
631
631
|
// Skip plans in terminal/archive statuses (closed work shouldn't generate noise)
|
|
632
632
|
if (config.lifecycle.terminalStatuses.has(doc.status) || config.lifecycle.archiveStatuses.has(doc.status)) return;
|
|
@@ -707,7 +707,7 @@ export function validatePlanShape(doc, body, frontmatter, config) {
|
|
|
707
707
|
doc.warnings.push({
|
|
708
708
|
path: doc.path,
|
|
709
709
|
level: 'warning',
|
|
710
|
-
message: `Phase marker contradicts its own checklist (line ${phase.lineStart}): \`${phase.heading.trim()}\` is marked ${conflict.declared}, but ${conflict.checked}/${conflict.total} boxes are checked — that reads as ${conflict.implied}. Update whichever half is stale.`,
|
|
710
|
+
message: `Phase marker contradicts its own checklist (line ${bodyLineOffset + phase.lineStart}): \`${phase.heading.trim()}\` is marked ${conflict.declared}, but ${conflict.checked}/${conflict.total} boxes are checked — that reads as ${conflict.implied}. Update whichever half is stale.`,
|
|
711
711
|
});
|
|
712
712
|
}
|
|
713
713
|
}
|