@forwardimpact/libwiki 0.2.16 → 0.2.17
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/package.json +1 -1
- package/src/active-claims.js +6 -4
- package/src/audit/rules.js +21 -27
- package/src/audit/scopes.js +22 -30
- package/src/commands/fix.js +54 -10
- package/src/constants.js +40 -0
- package/src/index.js +1 -0
- package/src/marker-scanner.js +6 -9
- package/src/weekly-log.js +167 -38
package/package.json
CHANGED
package/src/active-claims.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ACTIVE_CLAIMS_HEADER_RE,
|
|
2
3
|
ACTIVE_CLAIMS_HEADING,
|
|
3
4
|
ACTIVE_CLAIMS_TABLE_HEADER,
|
|
4
5
|
ACTIVE_CLAIMS_TABLE_SEPARATOR,
|
|
5
6
|
} from "./constants.js";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
// The header matcher is shared with the audit (via constants.js); the loose
|
|
9
|
+
// separator and the 6-cell row parser stay local — they serve parsing, not the
|
|
10
|
+
// audit's column-count check.
|
|
9
11
|
const SEPARATOR_RE = /^\|\s*---\s*\|/;
|
|
10
12
|
const ROW_RE =
|
|
11
13
|
/^\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*$/;
|
|
@@ -47,7 +49,7 @@ function scanRowsBetween(lines, start, end) {
|
|
|
47
49
|
let seenSeparator = false;
|
|
48
50
|
for (let i = start; i < end; i++) {
|
|
49
51
|
const line = lines[i];
|
|
50
|
-
if (
|
|
52
|
+
if (ACTIVE_CLAIMS_HEADER_RE.test(line)) {
|
|
51
53
|
inTable = true;
|
|
52
54
|
continue;
|
|
53
55
|
}
|
|
@@ -96,7 +98,7 @@ function findTableIndices(lines, heading, sectionEnd) {
|
|
|
96
98
|
let headerIdx = -1;
|
|
97
99
|
let separatorIdx = -1;
|
|
98
100
|
for (let i = heading + 1; i < sectionEnd; i++) {
|
|
99
|
-
if (
|
|
101
|
+
if (ACTIVE_CLAIMS_HEADER_RE.test(lines[i])) headerIdx = i;
|
|
100
102
|
if (headerIdx !== -1 && SEPARATOR_RE.test(lines[i])) {
|
|
101
103
|
separatorIdx = i;
|
|
102
104
|
break;
|
package/src/audit/rules.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ACTIVE_CLAIMS_HEADER_RE,
|
|
2
3
|
ACTIVE_CLAIMS_HEADING,
|
|
4
|
+
ACTIVE_CLAIMS_SEPARATOR_RE,
|
|
3
5
|
ACTIVE_CLAIMS_TABLE_HEADER,
|
|
4
6
|
DECISION_HEADING,
|
|
7
|
+
ISSUE_CLOSE_RE,
|
|
8
|
+
ISSUE_OPEN_RE,
|
|
5
9
|
MEMO_INBOX_MARKER,
|
|
6
10
|
PRIORITY_INDEX_HEADING,
|
|
7
11
|
STORYBOARD_LINE_BUDGET,
|
|
@@ -10,8 +14,14 @@ import {
|
|
|
10
14
|
SUMMARY_WORD_BUDGET,
|
|
11
15
|
WEEKLY_LOG_LINE_BUDGET,
|
|
12
16
|
WEEKLY_LOG_WORD_BUDGET,
|
|
17
|
+
XMR_CLOSE_RE,
|
|
18
|
+
XMR_OPEN_RE,
|
|
13
19
|
} from "../constants.js";
|
|
14
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
PRIORITY_HEADER_RE,
|
|
22
|
+
SUMMARY_H1_RE,
|
|
23
|
+
WEEKLY_LOG_H1_RE,
|
|
24
|
+
} from "./scopes.js";
|
|
15
25
|
import { STATUS_ROW_RULES } from "./status-row.js";
|
|
16
26
|
|
|
17
27
|
const PRIORITY_INDEX_HEADING_RE = new RegExp(
|
|
@@ -21,20 +31,7 @@ const PRIORITY_INDEX_HEADING_RE = new RegExp(
|
|
|
21
31
|
const ACTIVE_CLAIMS_HEADING_RE = new RegExp(`^${ACTIVE_CLAIMS_HEADING}$`, "m");
|
|
22
32
|
const PRIORITY_SEPARATOR_RE =
|
|
23
33
|
/^\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|/m;
|
|
24
|
-
const CLAIMS_HEADER_RE =
|
|
25
|
-
/^\|\s*agent\s*\|\s*target\s*\|\s*branch\s*\|\s*pr\s*\|\s*claimed_at\s*\|\s*expires_at\s*\|/m;
|
|
26
|
-
const CLAIMS_SEPARATOR_RE =
|
|
27
|
-
/^\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|/m;
|
|
28
34
|
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
29
|
-
// Marker regexes (mirror of marker-scanner.js): tolerate optional trailing
|
|
30
|
-
// text inside the marker so an open marker can carry an inline notice like
|
|
31
|
-
// "Do not edit. Auto-generated." without breaking the audit's balance check.
|
|
32
|
-
const XMR_OPEN_RE = /^<!--\s*xmr:([^:\s]+):(\S+)(?:\s+[^>]*?)?\s*-->\s*$/;
|
|
33
|
-
const XMR_CLOSE_RE = /^<!--\s*\/xmr(?:\s+[^>]*?)?\s*-->\s*$/;
|
|
34
|
-
const ISSUE_OPEN_RE =
|
|
35
|
-
/^<!--\s*(obstacles|experiments):(open|closed)(?::(\d+d))?(?:\s+[^>]*?)?\s*-->\s*$/;
|
|
36
|
-
const ISSUE_CLOSE_RE =
|
|
37
|
-
/^<!--\s*\/(obstacles|experiments)(?:\s+[^>]*?)?\s*-->\s*$/;
|
|
38
35
|
|
|
39
36
|
// improvement-coach is the storyboard facilitator and carries no domain
|
|
40
37
|
// metrics; only the five domain agents need their own H3.
|
|
@@ -154,14 +151,12 @@ const slugify = (title) =>
|
|
|
154
151
|
.replace(/(^-|-$)/g, "");
|
|
155
152
|
|
|
156
153
|
const summaryAgentMismatch = (s) => {
|
|
157
|
-
const titleSlug = slugify(s.firstLine.match(
|
|
154
|
+
const titleSlug = slugify(s.firstLine.match(SUMMARY_H1_RE)[1]);
|
|
158
155
|
return titleSlug === s.agentPrefix ? null : { titleSlug };
|
|
159
156
|
};
|
|
160
157
|
|
|
161
158
|
const weeklyAgentMismatch = (s) => {
|
|
162
|
-
const m = s.firstLine.match(
|
|
163
|
-
/^# (.+) — \d{4}-W\d{2}(?: \(part \d+ of \d+\))?$/,
|
|
164
|
-
);
|
|
159
|
+
const m = s.firstLine.match(WEEKLY_LOG_H1_RE);
|
|
165
160
|
if (!m) return null;
|
|
166
161
|
const titleSlug = slugify(m[1]);
|
|
167
162
|
return titleSlug === s.agentPrefix ? null : { titleSlug };
|
|
@@ -177,7 +172,7 @@ const memoryHasPriorityHeader = (s) =>
|
|
|
177
172
|
s.exists && PRIORITY_HEADER_RE.test(s.text);
|
|
178
173
|
const memoryHasClaimsHeading = (s) =>
|
|
179
174
|
s.exists && ACTIVE_CLAIMS_HEADING_RE.test(s.text);
|
|
180
|
-
const memoryHasClaimsHeader = (s) =>
|
|
175
|
+
const memoryHasClaimsHeader = (s) => ACTIVE_CLAIMS_HEADER_RE.test(s.text);
|
|
181
176
|
const storyboardExists = (s) => s.exists;
|
|
182
177
|
|
|
183
178
|
export const RULES = [
|
|
@@ -283,14 +278,13 @@ export const RULES = [
|
|
|
283
278
|
id: "decision-block.heading-within-5",
|
|
284
279
|
scope: "weekly-log-main",
|
|
285
280
|
severity: "fail",
|
|
286
|
-
remediation: "flag",
|
|
287
281
|
check: decisionWithin5({
|
|
288
282
|
entryRe: /^## \d{4}-\d{2}-\d{2}(?:[\s(].*)?$/,
|
|
289
283
|
requiredLine: DECISION_HEADING,
|
|
290
284
|
stopRe: /^##\s/,
|
|
291
285
|
}),
|
|
292
286
|
message: () => "Entry lacks leading '### Decision'",
|
|
293
|
-
hint: "open each '## YYYY-MM-DD' entry with a '### Decision' subheading
|
|
287
|
+
hint: "open each '## YYYY-MM-DD' entry with a '### Decision' subheading that summarises the decision recorded in the entry's own narrative — do not invent rationale the entry does not support",
|
|
294
288
|
},
|
|
295
289
|
|
|
296
290
|
// -- Weekly logs (sealed parts) --
|
|
@@ -307,19 +301,19 @@ export const RULES = [
|
|
|
307
301
|
id: "weekly-log-part.line-budget",
|
|
308
302
|
scope: "weekly-log-part",
|
|
309
303
|
severity: "fail",
|
|
310
|
-
remediation: "
|
|
304
|
+
remediation: "rotate",
|
|
311
305
|
check: lineBudget(WEEKLY_LOG_LINE_BUDGET),
|
|
312
306
|
message: (_s, r) => `${r.value} lines (limit ${WEEKLY_LOG_LINE_BUDGET})`,
|
|
313
|
-
hint: "
|
|
307
|
+
hint: "`bunx fit-wiki fix` re-bisects an over-budget part with day-section seams; an irreducible single-day section that alone exceeds the budget must be split at a finer seam by hand",
|
|
314
308
|
},
|
|
315
309
|
{
|
|
316
310
|
id: "weekly-log-part.word-budget",
|
|
317
311
|
scope: "weekly-log-part",
|
|
318
312
|
severity: "fail",
|
|
319
|
-
remediation: "
|
|
313
|
+
remediation: "rotate",
|
|
320
314
|
check: wordBudget(WEEKLY_LOG_WORD_BUDGET),
|
|
321
315
|
message: (_s, r) => `${r.value} words (limit ${WEEKLY_LOG_WORD_BUDGET})`,
|
|
322
|
-
hint: "
|
|
316
|
+
hint: "`bunx fit-wiki fix` re-bisects an over-budget part with day-section seams; an irreducible single-day section that alone exceeds the budget must be split at a finer seam by hand",
|
|
323
317
|
},
|
|
324
318
|
{
|
|
325
319
|
id: "weekly-log-part.h1-agent-matches-filename",
|
|
@@ -373,7 +367,7 @@ export const RULES = [
|
|
|
373
367
|
scope: "memory",
|
|
374
368
|
severity: "fail",
|
|
375
369
|
when: memoryHasClaimsHeading,
|
|
376
|
-
check: matches(
|
|
370
|
+
check: matches(ACTIVE_CLAIMS_HEADER_RE),
|
|
377
371
|
message: () => `Active claims header mismatch`,
|
|
378
372
|
hint: `expected header row: '${ACTIVE_CLAIMS_TABLE_HEADER}'`,
|
|
379
373
|
},
|
|
@@ -382,7 +376,7 @@ export const RULES = [
|
|
|
382
376
|
scope: "memory",
|
|
383
377
|
severity: "fail",
|
|
384
378
|
when: memoryHasClaimsHeader,
|
|
385
|
-
check: matches(
|
|
379
|
+
check: matches(ACTIVE_CLAIMS_SEPARATOR_RE),
|
|
386
380
|
message: () => "Missing active-claims separator row",
|
|
387
381
|
hint: "add '| --- | --- | --- | --- | --- | --- |' directly below the claims header",
|
|
388
382
|
},
|
package/src/audit/scopes.js
CHANGED
|
@@ -2,13 +2,17 @@ import path from "node:path";
|
|
|
2
2
|
import { yearMonth } from "@forwardimpact/libutil";
|
|
3
3
|
import { parseClaims } from "../active-claims.js";
|
|
4
4
|
import { countLines, countWords } from "../budget.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
PRIORITY_INDEX_HEADING,
|
|
7
|
+
WEEKLY_LOG_NAME_RE,
|
|
8
|
+
WEEKLY_LOG_PART_NAME_RE,
|
|
9
|
+
} from "../constants.js";
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
11
|
+
// Capture the agent-title group so the same regex both classifies a file
|
|
12
|
+
// (`.test`) and yields the title for the agent-prefix audit (`.match[1]`).
|
|
13
|
+
export const SUMMARY_H1_RE = /^# ([A-Z].*) — Summary$/;
|
|
10
14
|
export const WEEKLY_LOG_H1_RE =
|
|
11
|
-
/^# .* — \d{4}-W\d{2}(?: \(part \d+ of \d+\))?$/;
|
|
15
|
+
/^# (.*) — \d{4}-W\d{2}(?: \(part \d+ of \d+\))?$/;
|
|
12
16
|
export const PRIORITY_HEADER_RE =
|
|
13
17
|
/^\|\s*Item\s*\|\s*Agents\s*\|\s*Owner\s*\|\s*Status\s*\|\s*Added\s*\|/m;
|
|
14
18
|
|
|
@@ -55,8 +59,8 @@ function loadFile(filePath, fs) {
|
|
|
55
59
|
function classifyFile(filePath, fs) {
|
|
56
60
|
const base = path.basename(filePath);
|
|
57
61
|
if (EXCLUDED_BASES.has(base)) return null;
|
|
58
|
-
// STATUS.md is loaded separately (
|
|
59
|
-
// `status-row` scope — skip the per-file classification.
|
|
62
|
+
// STATUS.md is loaded separately (readOptional in buildContext) and audited
|
|
63
|
+
// via the dedicated `status-row` scope — skip the per-file classification.
|
|
60
64
|
if (base === "STATUS.md") return null;
|
|
61
65
|
if (NON_SUMMARY_PREFIXES.some((p) => base.startsWith(p))) return null;
|
|
62
66
|
if (WEEKLY_LOG_NAME_RE.test(base)) {
|
|
@@ -72,18 +76,10 @@ function classifyFile(filePath, fs) {
|
|
|
72
76
|
return { kind: "summary", subject };
|
|
73
77
|
}
|
|
74
78
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
path: filePath,
|
|
80
|
-
text: exists ? fs.readFileSync(filePath, "utf-8") : "",
|
|
81
|
-
exists,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function loadStatus(wikiRoot, fs) {
|
|
86
|
-
const filePath = path.join(wikiRoot, "STATUS.md");
|
|
79
|
+
// Read a file if present; an absent file yields empty text so callers audit
|
|
80
|
+
// "missing" uniformly. The common { path, text, exists } shape backs the
|
|
81
|
+
// MEMORY.md, STATUS.md, and storyboard context loads.
|
|
82
|
+
function readOptional(filePath, fs) {
|
|
87
83
|
const exists = fs.existsSync(filePath);
|
|
88
84
|
return {
|
|
89
85
|
path: filePath,
|
|
@@ -124,17 +120,13 @@ function parseStatusRows(statusText) {
|
|
|
124
120
|
|
|
125
121
|
function loadStoryboard(wikiRoot, today, fs) {
|
|
126
122
|
const ym = yearMonth(today);
|
|
127
|
-
const
|
|
128
|
-
const exists = fs.existsSync(filePath);
|
|
129
|
-
const text = exists ? fs.readFileSync(filePath, "utf-8") : "";
|
|
123
|
+
const base = readOptional(path.join(wikiRoot, `storyboard-${ym}.md`), fs);
|
|
130
124
|
return {
|
|
131
|
-
|
|
132
|
-
text,
|
|
133
|
-
fileLines: text.split("\n"),
|
|
134
|
-
exists,
|
|
125
|
+
...base,
|
|
126
|
+
fileLines: base.text.split("\n"),
|
|
135
127
|
yearMonth: ym,
|
|
136
|
-
lines: countLines(text),
|
|
137
|
-
words: countWords(text),
|
|
128
|
+
lines: countLines(base.text),
|
|
129
|
+
words: countWords(base.text),
|
|
138
130
|
};
|
|
139
131
|
}
|
|
140
132
|
|
|
@@ -227,8 +219,8 @@ export function buildContext({ wikiRoot, today, fs }) {
|
|
|
227
219
|
wikiRoot,
|
|
228
220
|
today,
|
|
229
221
|
subjects,
|
|
230
|
-
memory:
|
|
231
|
-
status:
|
|
222
|
+
memory: readOptional(path.join(wikiRoot, "MEMORY.md"), fs),
|
|
223
|
+
status: readOptional(path.join(wikiRoot, "STATUS.md"), fs),
|
|
232
224
|
storyboard: loadStoryboard(wikiRoot, today, fs),
|
|
233
225
|
};
|
|
234
226
|
}
|
package/src/commands/fix.js
CHANGED
|
@@ -8,7 +8,11 @@ import {
|
|
|
8
8
|
} from "@forwardimpact/libeval";
|
|
9
9
|
import { RULES } from "../audit/rules.js";
|
|
10
10
|
import { buildContext, resolveScope } from "../audit/scopes.js";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
rotateIfOverBudget,
|
|
13
|
+
rebisectOverBudgetPart,
|
|
14
|
+
weeklyLogPath,
|
|
15
|
+
} from "../weekly-log.js";
|
|
12
16
|
import { currentDayIso } from "../util/clock.js";
|
|
13
17
|
import { resolveProjectRoot } from "../util/wiki-dir.js";
|
|
14
18
|
|
|
@@ -131,6 +135,50 @@ function rotateOverBudgetMainLogs(findings, deps) {
|
|
|
131
135
|
}
|
|
132
136
|
}
|
|
133
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Re-bisect one over-budget sealed part, logging each new sibling slot it
|
|
140
|
+
* produces (the reused source slot is not a new file). A failed reseal leaves
|
|
141
|
+
* the source intact (the writer rolled back), so the re-audit re-flags it.
|
|
142
|
+
*/
|
|
143
|
+
function resealPart(partPath, { fs, projectRoot, out, err }) {
|
|
144
|
+
try {
|
|
145
|
+
const res = rebisectOverBudgetPart(partPath, fs);
|
|
146
|
+
if (res.status !== "resealed" && res.status !== "incomplete") return;
|
|
147
|
+
for (const part of res.parts) {
|
|
148
|
+
if (part === res.fromPath) continue; // the reused source slot
|
|
149
|
+
out(
|
|
150
|
+
`rotated ${path.relative(projectRoot, res.fromPath)} -> ` +
|
|
151
|
+
`${path.relative(projectRoot, part)}\n`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
} catch (e) {
|
|
155
|
+
err(
|
|
156
|
+
`fit-wiki fix: rebisect failed for ` +
|
|
157
|
+
`${path.relative(projectRoot, partPath)}: ${e.message}\n`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Deterministic pass for over-budget sealed parts: re-bisect each at its
|
|
164
|
+
* day-section seams. A part with no splittable seam is left byte-identical (the
|
|
165
|
+
* re-audit re-flags it for a human). Agent and week come from the part filename,
|
|
166
|
+
* so no subject lookup is needed. Part findings are disjoint from the main-log
|
|
167
|
+
* findings `rotateOverBudgetMainLogs` handles.
|
|
168
|
+
*/
|
|
169
|
+
function rebisectOverBudgetParts(findings, deps) {
|
|
170
|
+
// A part over both budgets yields two findings with the same path; re-bisect
|
|
171
|
+
// each path once.
|
|
172
|
+
const done = new Set();
|
|
173
|
+
for (const f of findings) {
|
|
174
|
+
if (classOf(f) !== "rotate") continue;
|
|
175
|
+
if (!f.id.startsWith("weekly-log-part.")) continue;
|
|
176
|
+
if (done.has(f.path)) continue;
|
|
177
|
+
done.add(f.path);
|
|
178
|
+
resealPart(f.path, deps);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
134
182
|
/** Report findings that need human judgment — never auto-fixed. */
|
|
135
183
|
function reportFlags(err, flagFindings, projectRoot) {
|
|
136
184
|
err(
|
|
@@ -242,16 +290,12 @@ export async function runFixCommand(ctx) {
|
|
|
242
290
|
return { ok: true };
|
|
243
291
|
}
|
|
244
292
|
|
|
245
|
-
// Deterministic layer:
|
|
293
|
+
// Deterministic layer: seal over-budget main logs, then re-bisect over-budget
|
|
294
|
+
// sealed parts. Both are content-preserving — no agent, no history rewrite.
|
|
246
295
|
if (findings.some((f) => classOf(f) === "rotate")) {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
projectRoot,
|
|
251
|
-
fs,
|
|
252
|
-
out,
|
|
253
|
-
err,
|
|
254
|
-
});
|
|
296
|
+
const rotateDeps = { wikiRoot, today, projectRoot, fs, out, err };
|
|
297
|
+
rotateOverBudgetMainLogs(findings, rotateDeps);
|
|
298
|
+
rebisectOverBudgetParts(findings, rotateDeps);
|
|
255
299
|
findings = audit();
|
|
256
300
|
if (findings.length === 0) {
|
|
257
301
|
out("fixed: wiki audit is clean\n");
|
package/src/constants.js
CHANGED
|
@@ -8,6 +8,26 @@ export const ACTIVE_CLAIMS_TABLE_HEADER =
|
|
|
8
8
|
"| agent | target | branch | pr | claimed_at | expires_at |";
|
|
9
9
|
export const ACTIVE_CLAIMS_TABLE_SEPARATOR =
|
|
10
10
|
"| --- | --- | --- | --- | --- | --- |";
|
|
11
|
+
|
|
12
|
+
// Match a rendered pipe-table row (header or separator) line-anchored and
|
|
13
|
+
// whitespace-tolerant between cells. Deriving the matcher from the rendered
|
|
14
|
+
// literal keeps the claims parser (active-claims.js) and the audit
|
|
15
|
+
// (audit/rules.js) from drifting on the column set — one literal, one matcher.
|
|
16
|
+
function pipeRowRe(literal, flags) {
|
|
17
|
+
const cells = literal
|
|
18
|
+
.split("|")
|
|
19
|
+
.map((c) => c.trim())
|
|
20
|
+
.filter((c) => c !== "");
|
|
21
|
+
return new RegExp(`^\\|\\s*${cells.join("\\s*\\|\\s*")}\\s*\\|`, flags);
|
|
22
|
+
}
|
|
23
|
+
export const ACTIVE_CLAIMS_HEADER_RE = pipeRowRe(
|
|
24
|
+
ACTIVE_CLAIMS_TABLE_HEADER,
|
|
25
|
+
"m",
|
|
26
|
+
);
|
|
27
|
+
export const ACTIVE_CLAIMS_SEPARATOR_RE = pipeRowRe(
|
|
28
|
+
ACTIVE_CLAIMS_TABLE_SEPARATOR,
|
|
29
|
+
"m",
|
|
30
|
+
);
|
|
11
31
|
export const PRIORITY_INDEX_HEADING = "## Cross-Cutting Priorities";
|
|
12
32
|
export const PRIORITY_INDEX_TABLE_HEADER =
|
|
13
33
|
"| Item | Agents | Owner | Status | Added |";
|
|
@@ -24,3 +44,23 @@ export const WEEKLY_LOG_LINE_BUDGET = 496;
|
|
|
24
44
|
export const WEEKLY_LOG_WORD_BUDGET = 6400;
|
|
25
45
|
export const STORYBOARD_LINE_BUDGET = 496;
|
|
26
46
|
export const STORYBOARD_WORD_BUDGET = 6400;
|
|
47
|
+
|
|
48
|
+
// Weekly-log filename convention: `<agent>-YYYY-Www.md` for the live main log
|
|
49
|
+
// and `<agent>-YYYY-Www-partN.md` for a sealed part. Capture groups are
|
|
50
|
+
// agent / year / week. One home so the audit's file classifier
|
|
51
|
+
// (audit/scopes.js) and the part re-bisector (weekly-log.js) cannot drift.
|
|
52
|
+
export const WEEKLY_LOG_NAME_RE = /^([a-z][a-z-]*)-(\d{4})-W(\d{2})\.md$/;
|
|
53
|
+
export const WEEKLY_LOG_PART_NAME_RE =
|
|
54
|
+
/^([a-z][a-z-]*)-(\d{4})-W(\d{2})-part\d+\.md$/;
|
|
55
|
+
|
|
56
|
+
// Storyboard marker syntax. An open or close marker tolerates optional trailing
|
|
57
|
+
// text after the tag (typically an inline "Do not edit. Generated from fit-wiki
|
|
58
|
+
// refresh." notice). One home so the marker scanner (marker-scanner.js) and the
|
|
59
|
+
// audit's balance check (audit/rules.js) cannot drift on the syntax.
|
|
60
|
+
export const XMR_OPEN_RE =
|
|
61
|
+
/^<!--\s*xmr:([^:\s]+):(\S+)(?:\s+[^>]*?)?\s*-->\s*$/;
|
|
62
|
+
export const XMR_CLOSE_RE = /^<!--\s*\/xmr(?:\s+[^>]*?)?\s*-->\s*$/;
|
|
63
|
+
export const ISSUE_OPEN_RE =
|
|
64
|
+
/^<!--\s*(obstacles|experiments):(open|closed)(?::(\d+d))?(?:\s+[^>]*?)?\s*-->\s*$/;
|
|
65
|
+
export const ISSUE_CLOSE_RE =
|
|
66
|
+
/^<!--\s*\/(obstacles|experiments)(?:\s+[^>]*?)?\s*-->\s*$/;
|
package/src/index.js
CHANGED
package/src/marker-scanner.js
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const XMR_CLOSE_RE = /^<!--\s*\/xmr(?:\s+[^>]*?)?\s*-->\s*$/;
|
|
8
|
-
const ISSUE_CLOSE_RE =
|
|
9
|
-
/^<!--\s*\/(obstacles|experiments)(?:\s+[^>]*?)?\s*-->\s*$/;
|
|
1
|
+
import {
|
|
2
|
+
ISSUE_CLOSE_RE,
|
|
3
|
+
ISSUE_OPEN_RE,
|
|
4
|
+
XMR_CLOSE_RE,
|
|
5
|
+
XMR_OPEN_RE,
|
|
6
|
+
} from "./constants.js";
|
|
10
7
|
|
|
11
8
|
function openLabel(open) {
|
|
12
9
|
return open.kind === "xmr" ? open.metric : open.topic;
|
package/src/weekly-log.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { isoWeekString } from "@forwardimpact/libutil";
|
|
3
3
|
import { countLines, countWords } from "./budget.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
WEEKLY_LOG_LINE_BUDGET,
|
|
6
|
+
WEEKLY_LOG_PART_NAME_RE,
|
|
7
|
+
WEEKLY_LOG_WORD_BUDGET,
|
|
8
|
+
} from "./constants.js";
|
|
5
9
|
|
|
6
10
|
// ISO week computation lives in libutil's calendar util (the one place a
|
|
7
11
|
// `new Date` is allowed); re-exported here for the existing public surface.
|
|
@@ -185,58 +189,71 @@ export function bisectWeeklyLog(text, agent, isoWeekStr) {
|
|
|
185
189
|
}
|
|
186
190
|
|
|
187
191
|
/**
|
|
188
|
-
* Stage every
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* paths
|
|
192
|
+
* Stage every write to `${path}.tmp`, then commit by renaming each `leading`
|
|
193
|
+
* write onto its path (tracked for rollback) and the `anchor` write LAST — the
|
|
194
|
+
* single point of no return. The anchor is the live/source file: until its
|
|
195
|
+
* rename it still holds its original bytes, so a failure anywhere unlinks every
|
|
196
|
+
* committed leading path and remaining temp and re-throws, leaving the anchor's
|
|
197
|
+
* path/contents/inode untouched. Leading paths must be verified-free slots (a
|
|
198
|
+
* rollback unlinks them). Returns the leading paths, in order.
|
|
194
199
|
*
|
|
195
|
-
* @param {string}
|
|
196
|
-
* @param {
|
|
197
|
-
* @param {string} agent
|
|
198
|
-
* @param {string} isoWeekStr
|
|
200
|
+
* @param {Array<{path: string, content: string}>} leading - Committed first.
|
|
201
|
+
* @param {{path: string, content: string}} anchor - Committed last.
|
|
199
202
|
* @param {object} fs - Sync filesystem surface.
|
|
200
|
-
* @returns {string[]} The
|
|
203
|
+
* @returns {string[]} The leading paths, in commit order.
|
|
201
204
|
*/
|
|
202
|
-
function
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
const temps = []; // temp paths this seal wrote, for rollback
|
|
206
|
-
const committed = []; // slots already renamed into place, for rollback
|
|
205
|
+
function commitAtomic(leading, anchor, fs) {
|
|
206
|
+
const temps = []; // temp paths written but not yet renamed, for rollback
|
|
207
|
+
const committed = []; // leading paths already renamed into place, for rollback
|
|
207
208
|
try {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
committed.push(slot);
|
|
220
|
-
// The part temp is consumed by its rename; drop it from the rollback set.
|
|
221
|
-
temps.splice(temps.indexOf(`${slot}.tmp`), 1);
|
|
222
|
-
});
|
|
223
|
-
fs.renameSync(mainTemp, filePath);
|
|
224
|
-
return slots;
|
|
209
|
+
for (const w of [...leading, anchor]) {
|
|
210
|
+
fs.writeFileSync(`${w.path}.tmp`, w.content);
|
|
211
|
+
temps.push(`${w.path}.tmp`);
|
|
212
|
+
}
|
|
213
|
+
for (const w of leading) {
|
|
214
|
+
fs.renameSync(`${w.path}.tmp`, w.path);
|
|
215
|
+
committed.push(w.path);
|
|
216
|
+
temps.splice(temps.indexOf(`${w.path}.tmp`), 1);
|
|
217
|
+
}
|
|
218
|
+
fs.renameSync(`${anchor.path}.tmp`, anchor.path);
|
|
219
|
+
return committed;
|
|
225
220
|
} catch (e) {
|
|
226
|
-
for (const
|
|
221
|
+
for (const p of committed) {
|
|
227
222
|
try {
|
|
228
|
-
fs.unlinkSync(
|
|
223
|
+
fs.unlinkSync(p);
|
|
229
224
|
} catch {}
|
|
230
225
|
}
|
|
231
|
-
for (const
|
|
226
|
+
for (const t of temps) {
|
|
232
227
|
try {
|
|
233
|
-
fs.unlinkSync(
|
|
228
|
+
fs.unlinkSync(t);
|
|
234
229
|
} catch {}
|
|
235
230
|
}
|
|
236
231
|
throw e;
|
|
237
232
|
}
|
|
238
233
|
}
|
|
239
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Seal a bisected weekly log: write each part to a fresh `-partN.md` slot and a
|
|
237
|
+
* fresh empty main over `filePath` (the anchor, committed last). Returns the
|
|
238
|
+
* `-partN.md` slot paths in part order.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} filePath - The current weekly-log path.
|
|
241
|
+
* @param {Array<{h1: string, body: string}>} parts - Ordered parts to seal.
|
|
242
|
+
* @param {string} agent
|
|
243
|
+
* @param {string} isoWeekStr
|
|
244
|
+
* @param {object} fs - Sync filesystem surface.
|
|
245
|
+
* @returns {string[]} The `-partN.md` slot paths, in part order.
|
|
246
|
+
*/
|
|
247
|
+
function atomicSeal(filePath, parts, agent, isoWeekStr, fs) {
|
|
248
|
+
const slots = nextFreeSlots(filePath, parts.length, fs);
|
|
249
|
+
const leading = slots.map((path, i) => ({
|
|
250
|
+
path,
|
|
251
|
+
content: `${parts[i].h1}\n${parts[i].body}`,
|
|
252
|
+
}));
|
|
253
|
+
const anchor = { path: filePath, content: defaultH1(agent, isoWeekStr) };
|
|
254
|
+
return commitAtomic(leading, anchor, fs);
|
|
255
|
+
}
|
|
256
|
+
|
|
240
257
|
/**
|
|
241
258
|
* Rotate the current weekly log, sealing an over-budget source into
|
|
242
259
|
* budget-conforming parts via a bisecting seal. Returns a tagged union:
|
|
@@ -287,6 +304,118 @@ export function rotateIfOverBudget(
|
|
|
287
304
|
};
|
|
288
305
|
}
|
|
289
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Derive the agent, ISO week, and MAIN-log path from a sealed part's path. The
|
|
309
|
+
* week comes from the filename (a part may belong to a past week, not today),
|
|
310
|
+
* and the main-log path — not the part path — is what `nextFreeSlots` must base
|
|
311
|
+
* new sibling slots on. Returns null for a non-conforming filename. Shares
|
|
312
|
+
* WEEKLY_LOG_PART_NAME_RE with the audit's file classifier so the two cannot
|
|
313
|
+
* drift on the filename convention.
|
|
314
|
+
*/
|
|
315
|
+
function parsePartPath(partPath) {
|
|
316
|
+
const m = path.basename(partPath).match(WEEKLY_LOG_PART_NAME_RE);
|
|
317
|
+
if (!m) return null;
|
|
318
|
+
const [, agent, year, week] = m;
|
|
319
|
+
const isoWeekStr = `${year}-W${week}`;
|
|
320
|
+
return {
|
|
321
|
+
agent,
|
|
322
|
+
isoWeekStr,
|
|
323
|
+
mainLogPath: path.join(path.dirname(partPath), `${agent}-${isoWeekStr}.md`),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Reseal a re-bisected part: the first sub-part overwrites the source slot (the
|
|
329
|
+
* anchor, committed last) and the rest claim fresh sibling slots of the main-log
|
|
330
|
+
* path. `nextFreeSlots` skips occupied slots (including the source's own), so a
|
|
331
|
+
* commit never clobbers a sibling nor a rollback unlinks a pre-existing one.
|
|
332
|
+
* Returns `[partPath, ...newSlots]` in part order.
|
|
333
|
+
*/
|
|
334
|
+
function atomicResealPart(partPath, mainLogPath, parts, fs) {
|
|
335
|
+
const newSlots = nextFreeSlots(mainLogPath, parts.length - 1, fs);
|
|
336
|
+
const leading = newSlots.map((path, i) => ({
|
|
337
|
+
path,
|
|
338
|
+
content: `${parts[i + 1].h1}\n${parts[i + 1].body}`,
|
|
339
|
+
}));
|
|
340
|
+
const anchor = {
|
|
341
|
+
path: partPath,
|
|
342
|
+
content: `${parts[0].h1}\n${parts[0].body}`,
|
|
343
|
+
};
|
|
344
|
+
commitAtomic(leading, anchor, fs);
|
|
345
|
+
return [partPath, ...newSlots];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Re-bisect a single over-budget sealed weekly-log PART in place. Agent and ISO
|
|
350
|
+
* week come from the part filename. A part within both budgets is a noop; a part
|
|
351
|
+
* whose body cannot be reduced (a lone over-cap day-section or an over-cap
|
|
352
|
+
* zero-seam body) is left BYTE-IDENTICAL and reported `incomplete` with a
|
|
353
|
+
* residue, so the re-audit re-flags it for a human. Otherwise the first sub-part
|
|
354
|
+
* overwrites `partPath` (slot reused) and the remaining sub-parts land on fresh
|
|
355
|
+
* sibling slots, with full rollback (source untouched on any failure).
|
|
356
|
+
*
|
|
357
|
+
* The produced sub-parts carry `bisectWeeklyLog`'s `(part i of M)` H1s, where M
|
|
358
|
+
* is LOCAL to this part's split — not a global count of the week's parts.
|
|
359
|
+
* Sibling parts are never renumbered (the audit does not validate the numbers).
|
|
360
|
+
*
|
|
361
|
+
* @param {string} partPath - Absolute path to an `<agent>-YYYY-Www-partN.md`.
|
|
362
|
+
* @param {object} fs - Sync filesystem surface (`runtime.fsSync`).
|
|
363
|
+
* @returns {{status: "noop"|"resealed"|"incomplete", fromPath: string, parts?: string[], residue?: {path: string, section: string, lines: number, words: number}}}
|
|
364
|
+
*/
|
|
365
|
+
export function rebisectOverBudgetPart(partPath, fs) {
|
|
366
|
+
if (!fs.existsSync(partPath)) return { status: "noop", fromPath: partPath };
|
|
367
|
+
const parsed = parsePartPath(partPath);
|
|
368
|
+
if (!parsed) return { status: "noop", fromPath: partPath };
|
|
369
|
+
const text = fs.readFileSync(partPath, "utf-8");
|
|
370
|
+
const lines = countLines(text);
|
|
371
|
+
const words = countWords(text);
|
|
372
|
+
if (lines <= WEEKLY_LOG_LINE_BUDGET && words <= WEEKLY_LOG_WORD_BUDGET) {
|
|
373
|
+
return { status: "noop", fromPath: partPath };
|
|
374
|
+
}
|
|
375
|
+
const { parts, residue } = bisectWeeklyLog(
|
|
376
|
+
text,
|
|
377
|
+
parsed.agent,
|
|
378
|
+
parsed.isoWeekStr,
|
|
379
|
+
);
|
|
380
|
+
// A single produced part has no splittable seam: leave the file untouched and
|
|
381
|
+
// surface a residue (synthesised from the file when the bisector did not name
|
|
382
|
+
// one) so the caller's re-audit re-flags it.
|
|
383
|
+
if (parts.length === 1) {
|
|
384
|
+
const seam = text.match(/^## (\d{4}-\d{2}-\d{2})/m);
|
|
385
|
+
const r = residue ?? {
|
|
386
|
+
section: seam ? seam[1] : "prologue",
|
|
387
|
+
lines,
|
|
388
|
+
words,
|
|
389
|
+
};
|
|
390
|
+
return {
|
|
391
|
+
status: "incomplete",
|
|
392
|
+
fromPath: partPath,
|
|
393
|
+
parts: [partPath],
|
|
394
|
+
residue: {
|
|
395
|
+
path: partPath,
|
|
396
|
+
section: r.section,
|
|
397
|
+
lines: r.lines,
|
|
398
|
+
words: r.words,
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
const slots = atomicResealPart(partPath, parsed.mainLogPath, parts, fs);
|
|
403
|
+
if (residue === null) {
|
|
404
|
+
return { status: "resealed", fromPath: partPath, parts: slots };
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
status: "incomplete",
|
|
408
|
+
fromPath: partPath,
|
|
409
|
+
parts: slots,
|
|
410
|
+
residue: {
|
|
411
|
+
path: slots[residue.partIndex],
|
|
412
|
+
section: residue.section,
|
|
413
|
+
lines: residue.lines,
|
|
414
|
+
words: residue.words,
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
290
419
|
/**
|
|
291
420
|
* Append a body to a weekly log file. Creates it with an H1 if missing.
|
|
292
421
|
* @param {string} filePath
|