@forwardimpact/libwiki 0.2.15 → 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 +24 -50
- package/src/budget.js +25 -0
- package/src/commands/fix.js +85 -24
- package/src/commands/log.js +34 -5
- package/src/commands/rotate.js +40 -14
- package/src/constants.js +40 -0
- package/src/index.js +3 -0
- package/src/marker-scanner.js +6 -9
- package/src/weekly-log.js +366 -20
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
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { yearMonth } from "@forwardimpact/libutil";
|
|
3
3
|
import { parseClaims } from "../active-claims.js";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
import { countLines, countWords } from "../budget.js";
|
|
5
|
+
import {
|
|
6
|
+
PRIORITY_INDEX_HEADING,
|
|
7
|
+
WEEKLY_LOG_NAME_RE,
|
|
8
|
+
WEEKLY_LOG_PART_NAME_RE,
|
|
9
|
+
} from "../constants.js";
|
|
10
|
+
|
|
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$/;
|
|
9
14
|
export const WEEKLY_LOG_H1_RE =
|
|
10
|
-
/^# .* — \d{4}-W\d{2}(?: \(part \d+ of \d+\))?$/;
|
|
15
|
+
/^# (.*) — \d{4}-W\d{2}(?: \(part \d+ of \d+\))?$/;
|
|
11
16
|
export const PRIORITY_HEADER_RE =
|
|
12
17
|
/^\|\s*Item\s*\|\s*Agents\s*\|\s*Owner\s*\|\s*Status\s*\|\s*Added\s*\|/m;
|
|
13
18
|
|
|
@@ -28,25 +33,6 @@ function listMdFiles(wikiRoot, fs) {
|
|
|
28
33
|
.map((e) => path.join(wikiRoot, e));
|
|
29
34
|
}
|
|
30
35
|
|
|
31
|
-
function countLines(text) {
|
|
32
|
-
return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function countWords(text) {
|
|
36
|
-
let count = 0;
|
|
37
|
-
let inWord = false;
|
|
38
|
-
for (let i = 0; i < text.length; i++) {
|
|
39
|
-
const c = text.charCodeAt(i);
|
|
40
|
-
const isWs = c === 32 || c === 9 || c === 10 || c === 13;
|
|
41
|
-
if (isWs) inWord = false;
|
|
42
|
-
else if (!inWord) {
|
|
43
|
-
inWord = true;
|
|
44
|
-
count++;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return count;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
36
|
function loadFile(filePath, fs) {
|
|
51
37
|
const text = fs.readFileSync(filePath, "utf-8");
|
|
52
38
|
const fileLines = text.split("\n");
|
|
@@ -73,8 +59,8 @@ function loadFile(filePath, fs) {
|
|
|
73
59
|
function classifyFile(filePath, fs) {
|
|
74
60
|
const base = path.basename(filePath);
|
|
75
61
|
if (EXCLUDED_BASES.has(base)) return null;
|
|
76
|
-
// STATUS.md is loaded separately (
|
|
77
|
-
// `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.
|
|
78
64
|
if (base === "STATUS.md") return null;
|
|
79
65
|
if (NON_SUMMARY_PREFIXES.some((p) => base.startsWith(p))) return null;
|
|
80
66
|
if (WEEKLY_LOG_NAME_RE.test(base)) {
|
|
@@ -90,18 +76,10 @@ function classifyFile(filePath, fs) {
|
|
|
90
76
|
return { kind: "summary", subject };
|
|
91
77
|
}
|
|
92
78
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
path: filePath,
|
|
98
|
-
text: exists ? fs.readFileSync(filePath, "utf-8") : "",
|
|
99
|
-
exists,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function loadStatus(wikiRoot, fs) {
|
|
104
|
-
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) {
|
|
105
83
|
const exists = fs.existsSync(filePath);
|
|
106
84
|
return {
|
|
107
85
|
path: filePath,
|
|
@@ -142,17 +120,13 @@ function parseStatusRows(statusText) {
|
|
|
142
120
|
|
|
143
121
|
function loadStoryboard(wikiRoot, today, fs) {
|
|
144
122
|
const ym = yearMonth(today);
|
|
145
|
-
const
|
|
146
|
-
const exists = fs.existsSync(filePath);
|
|
147
|
-
const text = exists ? fs.readFileSync(filePath, "utf-8") : "";
|
|
123
|
+
const base = readOptional(path.join(wikiRoot, `storyboard-${ym}.md`), fs);
|
|
148
124
|
return {
|
|
149
|
-
|
|
150
|
-
text,
|
|
151
|
-
fileLines: text.split("\n"),
|
|
152
|
-
exists,
|
|
125
|
+
...base,
|
|
126
|
+
fileLines: base.text.split("\n"),
|
|
153
127
|
yearMonth: ym,
|
|
154
|
-
lines: countLines(text),
|
|
155
|
-
words: countWords(text),
|
|
128
|
+
lines: countLines(base.text),
|
|
129
|
+
words: countWords(base.text),
|
|
156
130
|
};
|
|
157
131
|
}
|
|
158
132
|
|
|
@@ -245,8 +219,8 @@ export function buildContext({ wikiRoot, today, fs }) {
|
|
|
245
219
|
wikiRoot,
|
|
246
220
|
today,
|
|
247
221
|
subjects,
|
|
248
|
-
memory:
|
|
249
|
-
status:
|
|
222
|
+
memory: readOptional(path.join(wikiRoot, "MEMORY.md"), fs),
|
|
223
|
+
status: readOptional(path.join(wikiRoot, "STATUS.md"), fs),
|
|
250
224
|
storyboard: loadStoryboard(wikiRoot, today, fs),
|
|
251
225
|
};
|
|
252
226
|
}
|
package/src/budget.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Canonical line- and word-counters for the budgeted wiki surfaces. The audit
|
|
2
|
+
// (`audit/scopes.js`) and the rotation primitive's bisecting seal
|
|
3
|
+
// (`weekly-log.js`) both import this one pair so a part the seal calls
|
|
4
|
+
// conforming cannot later be flagged by an audit counting differently.
|
|
5
|
+
|
|
6
|
+
/** Count lines, not counting a trailing newline as an empty final line. */
|
|
7
|
+
export function countLines(text) {
|
|
8
|
+
return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Count whitespace-delimited words. */
|
|
12
|
+
export function countWords(text) {
|
|
13
|
+
let count = 0;
|
|
14
|
+
let inWord = false;
|
|
15
|
+
for (let i = 0; i < text.length; i++) {
|
|
16
|
+
const c = text.charCodeAt(i);
|
|
17
|
+
const isWs = c === 32 || c === 9 || c === 10 || c === 13;
|
|
18
|
+
if (isWs) inWord = false;
|
|
19
|
+
else if (!inWord) {
|
|
20
|
+
inWord = true;
|
|
21
|
+
count++;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return count;
|
|
25
|
+
}
|
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
|
|
|
@@ -85,19 +89,12 @@ function composeFollowup(findings, projectRoot) {
|
|
|
85
89
|
* untouched (rotating it would force-seal a healthy current-week log instead);
|
|
86
90
|
* it survives the re-audit and is flagged for a human.
|
|
87
91
|
*/
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
];
|
|
95
|
-
const agentByPath = new Map(subjects.map((s) => [s.path, s.agentPrefix]));
|
|
96
|
-
for (const f of findings) {
|
|
97
|
-
if (classOf(f) !== "rotate") continue;
|
|
98
|
-
const agent = agentByPath.get(f.path);
|
|
99
|
-
if (!agent) continue;
|
|
100
|
-
if (weeklyLogPath(wikiRoot, agent, today) !== f.path) continue;
|
|
92
|
+
/**
|
|
93
|
+
* Seal one over-budget current-week main log. A failed seal leaves the source
|
|
94
|
+
* intact (the writer rolled back), so the re-audit re-flags it for a human.
|
|
95
|
+
*/
|
|
96
|
+
function sealMainLog(agent, { wikiRoot, today, projectRoot, fs, out, err }) {
|
|
97
|
+
try {
|
|
101
98
|
const res = rotateIfOverBudget(
|
|
102
99
|
wikiRoot,
|
|
103
100
|
agent,
|
|
@@ -106,12 +103,79 @@ function rotateOverBudgetMainLogs(
|
|
|
106
103
|
{ force: true },
|
|
107
104
|
fs,
|
|
108
105
|
);
|
|
109
|
-
if (res.
|
|
106
|
+
if (res.status !== "sealed" && res.status !== "incomplete") return;
|
|
107
|
+
for (const part of res.parts) {
|
|
108
|
+
out(
|
|
109
|
+
`rotated ${path.relative(projectRoot, res.fromPath)} -> ` +
|
|
110
|
+
`${path.relative(projectRoot, part)}\n`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
} catch (e) {
|
|
114
|
+
err(`fit-wiki fix: rotate failed for ${agent}: ${e.message}\n`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function rotateOverBudgetMainLogs(findings, deps) {
|
|
119
|
+
const { wikiRoot, today, fs } = deps;
|
|
120
|
+
const subjects = buildContext({ wikiRoot, today, fs }).subjects[
|
|
121
|
+
"weekly-log-main"
|
|
122
|
+
];
|
|
123
|
+
const agentByPath = new Map(subjects.map((s) => [s.path, s.agentPrefix]));
|
|
124
|
+
// A log over BOTH budgets yields two `rotate` findings with the same path
|
|
125
|
+
// (different rule ids); seal each path once, or the second force call would
|
|
126
|
+
// bisect the freshly-written fresh-main into a spurious near-empty part.
|
|
127
|
+
const sealed = new Set();
|
|
128
|
+
for (const f of findings) {
|
|
129
|
+
if (classOf(f) !== "rotate") continue;
|
|
130
|
+
const agent = agentByPath.get(f.path);
|
|
131
|
+
if (!agent || weeklyLogPath(wikiRoot, agent, today) !== f.path) continue;
|
|
132
|
+
if (sealed.has(f.path)) continue;
|
|
133
|
+
sealed.add(f.path);
|
|
134
|
+
sealMainLog(agent, deps);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
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
|
|
110
149
|
out(
|
|
111
150
|
`rotated ${path.relative(projectRoot, res.fromPath)} -> ` +
|
|
112
|
-
`${path.relative(projectRoot,
|
|
151
|
+
`${path.relative(projectRoot, part)}\n`,
|
|
113
152
|
);
|
|
114
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);
|
|
115
179
|
}
|
|
116
180
|
}
|
|
117
181
|
|
|
@@ -226,15 +290,12 @@ export async function runFixCommand(ctx) {
|
|
|
226
290
|
return { ok: true };
|
|
227
291
|
}
|
|
228
292
|
|
|
229
|
-
// 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.
|
|
230
295
|
if (findings.some((f) => classOf(f) === "rotate")) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
projectRoot,
|
|
235
|
-
fs,
|
|
236
|
-
out,
|
|
237
|
-
});
|
|
296
|
+
const rotateDeps = { wikiRoot, today, projectRoot, fs, out, err };
|
|
297
|
+
rotateOverBudgetMainLogs(findings, rotateDeps);
|
|
298
|
+
rebisectOverBudgetParts(findings, rotateDeps);
|
|
238
299
|
findings = audit();
|
|
239
300
|
if (findings.length === 0) {
|
|
240
301
|
out("fixed: wiki audit is clean\n");
|
package/src/commands/log.js
CHANGED
|
@@ -30,6 +30,36 @@ function lastDateHeading(text) {
|
|
|
30
30
|
return last;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Rotate before an append, never blocking it. A bisecting seal may now produce
|
|
35
|
+
* multiple parts; the append still proceeds against the fresh current file. An
|
|
36
|
+
* `incomplete` residue (a lone over-cap day-section sealed as its own part —
|
|
37
|
+
* never the live file) is surfaced to stderr but does not block the append. A
|
|
38
|
+
* thrown fs error is reported and swallowed: the writer rolled back, so the
|
|
39
|
+
* (intact) current file still receives the new entry.
|
|
40
|
+
*/
|
|
41
|
+
function rotateBeforeAppend(wikiRoot, agent, today, appendLines, runtime) {
|
|
42
|
+
try {
|
|
43
|
+
const res = rotateIfOverBudget(
|
|
44
|
+
wikiRoot,
|
|
45
|
+
agent,
|
|
46
|
+
today,
|
|
47
|
+
appendLines,
|
|
48
|
+
{},
|
|
49
|
+
runtime.fsSync,
|
|
50
|
+
);
|
|
51
|
+
if (res.status === "incomplete") {
|
|
52
|
+
runtime.proc.stderr.write(
|
|
53
|
+
`note: day-section ${res.residue.section} alone exceeds the budget ` +
|
|
54
|
+
`(${res.residue.lines} lines, ${res.residue.words} words); ` +
|
|
55
|
+
`sealed as ${res.residue.path} for manual recovery\n`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
} catch (e) {
|
|
59
|
+
runtime.proc.stderr.write(`note: rotation failed: ${e.message}\n`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
33
63
|
function runDecision(runtime, options) {
|
|
34
64
|
const ctx = commonContext(runtime, options);
|
|
35
65
|
if (ctx.error) return ctx.error;
|
|
@@ -53,7 +83,7 @@ function runDecision(runtime, options) {
|
|
|
53
83
|
"",
|
|
54
84
|
].join("\n");
|
|
55
85
|
const lineCount = body.split("\n").length;
|
|
56
|
-
|
|
86
|
+
rotateBeforeAppend(wikiRoot, agent, today, lineCount, runtime);
|
|
57
87
|
const target = weeklyLogPath(wikiRoot, agent, today);
|
|
58
88
|
appendEntry(target, body, agent, today, runtime.fsSync);
|
|
59
89
|
runtime.proc.stdout.write(`logged decision to ${target}\n`);
|
|
@@ -71,13 +101,12 @@ function runNote(runtime, options) {
|
|
|
71
101
|
const fieldBlock = `### ${options.field}\n\n${options.body}\n`;
|
|
72
102
|
// Conservative line budget: assume we'll prepend a date heading.
|
|
73
103
|
const withHeading = `## ${today}\n\n${fieldBlock}`;
|
|
74
|
-
|
|
104
|
+
rotateBeforeAppend(
|
|
75
105
|
wikiRoot,
|
|
76
106
|
agent,
|
|
77
107
|
today,
|
|
78
108
|
withHeading.split("\n").length,
|
|
79
|
-
|
|
80
|
-
runtime.fsSync,
|
|
109
|
+
runtime,
|
|
81
110
|
);
|
|
82
111
|
const target = weeklyLogPath(wikiRoot, agent, today);
|
|
83
112
|
// Append under the open entry if the file's last `## YYYY-MM-DD` is today;
|
|
@@ -97,7 +126,7 @@ function runDone(runtime, options) {
|
|
|
97
126
|
const { agent, wikiRoot, today } = ctx;
|
|
98
127
|
const body = `### Closed\n\nRun closed ${today}.\n`;
|
|
99
128
|
const lineCount = body.split("\n").length;
|
|
100
|
-
|
|
129
|
+
rotateBeforeAppend(wikiRoot, agent, today, lineCount, runtime);
|
|
101
130
|
const target = weeklyLogPath(wikiRoot, agent, today);
|
|
102
131
|
appendEntry(target, body, agent, today, runtime.fsSync);
|
|
103
132
|
runtime.proc.stdout.write(`closed entry in ${target}\n`);
|
package/src/commands/rotate.js
CHANGED
|
@@ -17,20 +17,46 @@ export function runRotateCommand(ctx) {
|
|
|
17
17
|
const wikiRoot = resolveWikiRoot(runtime, options);
|
|
18
18
|
const today = options.today || currentDayIso(runtime);
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
runtime.proc.stdout.write(
|
|
30
|
-
`rotated ${result.fromPath} → ${result.toPath}\n`,
|
|
20
|
+
let result;
|
|
21
|
+
try {
|
|
22
|
+
result = rotateIfOverBudget(
|
|
23
|
+
wikiRoot,
|
|
24
|
+
agent,
|
|
25
|
+
today,
|
|
26
|
+
0,
|
|
27
|
+
{ force: true },
|
|
28
|
+
runtime.fsSync,
|
|
31
29
|
);
|
|
32
|
-
}
|
|
33
|
-
runtime.proc.
|
|
30
|
+
} catch (e) {
|
|
31
|
+
runtime.proc.stderr.write(`rotate failed: ${e.message}\n`);
|
|
32
|
+
return { ok: false, code: 1 };
|
|
33
|
+
}
|
|
34
|
+
switch (result.status) {
|
|
35
|
+
case "noop":
|
|
36
|
+
runtime.proc.stdout.write(`no rotation needed for ${agent}\n`);
|
|
37
|
+
return { ok: true };
|
|
38
|
+
case "sealed":
|
|
39
|
+
for (const part of result.parts) {
|
|
40
|
+
runtime.proc.stdout.write(`sealed → ${part}\n`);
|
|
41
|
+
}
|
|
42
|
+
return { ok: true };
|
|
43
|
+
case "incomplete": {
|
|
44
|
+
for (const part of result.parts) {
|
|
45
|
+
runtime.proc.stdout.write(`sealed → ${part}\n`);
|
|
46
|
+
}
|
|
47
|
+
const { section, lines, words, path: residuePath } = result.residue;
|
|
48
|
+
runtime.proc.stderr.write(
|
|
49
|
+
`day-section ${section} alone exceeds the budget ` +
|
|
50
|
+
`(${lines} lines, ${words} words) and cannot be split at a day ` +
|
|
51
|
+
`seam: ${residuePath}\n` +
|
|
52
|
+
`recover it by hand — bisect the section at a finer seam ` +
|
|
53
|
+
`(see the memory protocol's manual-recovery convention)\n`,
|
|
54
|
+
);
|
|
55
|
+
return { ok: false, code: 1 };
|
|
56
|
+
}
|
|
57
|
+
default:
|
|
58
|
+
// Defensive: the tagged union is exhaustive above, so this is
|
|
59
|
+
// unreachable; kept so a future status can't fall through to no return.
|
|
60
|
+
return { ok: true };
|
|
34
61
|
}
|
|
35
|
-
return { ok: true };
|
|
36
62
|
}
|
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
|
@@ -32,8 +32,11 @@ export {
|
|
|
32
32
|
isoWeek,
|
|
33
33
|
weeklyLogPath,
|
|
34
34
|
rotateIfOverBudget,
|
|
35
|
+
rebisectOverBudgetPart,
|
|
36
|
+
bisectWeeklyLog,
|
|
35
37
|
appendEntry,
|
|
36
38
|
} from "./weekly-log.js";
|
|
37
39
|
export { buildDigest } from "./boot.js";
|
|
40
|
+
export { countLines, countWords } from "./budget.js";
|
|
38
41
|
export { RULES } from "./audit/rules.js";
|
|
39
42
|
export { resolveScope as resolveAuditScope } from "./audit/scopes.js";
|
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,6 +1,11 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { isoWeekString } from "@forwardimpact/libutil";
|
|
3
|
-
import {
|
|
3
|
+
import { countLines, countWords } from "./budget.js";
|
|
4
|
+
import {
|
|
5
|
+
WEEKLY_LOG_LINE_BUDGET,
|
|
6
|
+
WEEKLY_LOG_PART_NAME_RE,
|
|
7
|
+
WEEKLY_LOG_WORD_BUDGET,
|
|
8
|
+
} from "./constants.js";
|
|
4
9
|
|
|
5
10
|
// ISO week computation lives in libutil's calendar util (the one place a
|
|
6
11
|
// `new Date` is allowed); re-exported here for the existing public surface.
|
|
@@ -11,22 +16,27 @@ export function weeklyLogPath(wikiRoot, agent, today) {
|
|
|
11
16
|
return path.join(wikiRoot, `${agent}-${isoWeekString(today)}.md`);
|
|
12
17
|
}
|
|
13
18
|
|
|
14
|
-
function
|
|
15
|
-
if (text.length === 0) return 0;
|
|
16
|
-
let n = 0;
|
|
17
|
-
for (const ch of text) if (ch === "\n") n++;
|
|
18
|
-
if (!text.endsWith("\n")) n++;
|
|
19
|
-
return n;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function nextPartPath(filePath, fs) {
|
|
19
|
+
function partPathAt(filePath, n) {
|
|
23
20
|
const dir = path.dirname(filePath);
|
|
24
21
|
const base = path.basename(filePath, ".md");
|
|
25
|
-
let n = 1;
|
|
26
|
-
while (fs.existsSync(path.join(dir, `${base}-part${n}.md`))) n++;
|
|
27
22
|
return path.join(dir, `${base}-part${n}.md`);
|
|
28
23
|
}
|
|
29
24
|
|
|
25
|
+
// Find `count` part slots that are each verified free, skipping any occupied
|
|
26
|
+
// `-partN.md` (e.g. a numbering gap left by a manually deleted middle part).
|
|
27
|
+
// Every returned slot is unoccupied, so the seal never overwrites a pre-existing
|
|
28
|
+
// part on commit nor unlinks one on rollback.
|
|
29
|
+
function nextFreeSlots(filePath, count, fs) {
|
|
30
|
+
const slots = [];
|
|
31
|
+
let n = 1;
|
|
32
|
+
while (slots.length < count) {
|
|
33
|
+
const p = partPathAt(filePath, n);
|
|
34
|
+
if (!fs.existsSync(p)) slots.push(p);
|
|
35
|
+
n++;
|
|
36
|
+
}
|
|
37
|
+
return slots;
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
function agentTitle(agent) {
|
|
31
41
|
return agent
|
|
32
42
|
.split("-")
|
|
@@ -38,9 +48,220 @@ function defaultH1(agent, isoWeekStr) {
|
|
|
38
48
|
return `# ${agentTitle(agent)} — ${isoWeekStr}\n`;
|
|
39
49
|
}
|
|
40
50
|
|
|
51
|
+
/** Describe a lone over-cap day-section as a residue at its part index. */
|
|
52
|
+
function residueOf(sec, partIndex, measure) {
|
|
53
|
+
const { lines, words } = measure(sec.text);
|
|
54
|
+
return { section: sec.date, lines, words, partIndex };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* An over-cap prologue cannot merge with any day-section (adding content only
|
|
59
|
+
* grows it), so it always seals as its own part 0. Flag it up front as the
|
|
60
|
+
* first residue so it is never shipped silently over budget.
|
|
61
|
+
*/
|
|
62
|
+
function prologueResidue(prologue, { overBudget, measure }) {
|
|
63
|
+
if (prologue.length === 0 || !overBudget(prologue)) return null;
|
|
64
|
+
const { lines, words } = measure(prologue);
|
|
65
|
+
return { section: "prologue", lines, words, partIndex: 0 };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Greedily pack day-sections into part bodies under both budgets, the prologue
|
|
70
|
+
* riding with part 1. A chunk that alone exceeds a budget — a lone day-section
|
|
71
|
+
* or an over-cap prologue — is sealed as its own part and recorded as the
|
|
72
|
+
* (first) residue; packed runs and single sections are kept under both budgets,
|
|
73
|
+
* so the only over-cap part bodies are the ones `residue` accounts for.
|
|
74
|
+
* @param {Array<{date: string, text: string}>} sections
|
|
75
|
+
* @param {string} prologue - Content above the first seam; rides with part 1.
|
|
76
|
+
* @param {{overBudget: (s: string) => boolean, measure: (s: string) => {lines: number, words: number}}} budget
|
|
77
|
+
* @returns {{partBodies: string[], residue: null | {section: string, lines: number, words: number, partIndex: number}}}
|
|
78
|
+
*/
|
|
79
|
+
function packSections(sections, prologue, budget) {
|
|
80
|
+
const { overBudget, measure } = budget;
|
|
81
|
+
const partBodies = [];
|
|
82
|
+
// The prologue, when over budget, is always pushed first (part 0) — record it
|
|
83
|
+
// before packing so a later lone-section residue cannot displace it.
|
|
84
|
+
let residue = prologueResidue(prologue, budget);
|
|
85
|
+
let open = prologue; // body of the part currently being filled
|
|
86
|
+
let opened = prologue.length > 0;
|
|
87
|
+
const flush = () => {
|
|
88
|
+
if (opened) {
|
|
89
|
+
partBodies.push(open);
|
|
90
|
+
open = "";
|
|
91
|
+
opened = false;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
for (const sec of sections) {
|
|
95
|
+
if (overBudget(sec.text)) {
|
|
96
|
+
// Irreducible lone day-section: flush the open part, then seal it alone.
|
|
97
|
+
flush();
|
|
98
|
+
residue ??= residueOf(sec, partBodies.length, measure);
|
|
99
|
+
partBodies.push(sec.text);
|
|
100
|
+
} else if (!opened) {
|
|
101
|
+
open = sec.text;
|
|
102
|
+
opened = true;
|
|
103
|
+
} else if (overBudget(open + sec.text)) {
|
|
104
|
+
partBodies.push(open);
|
|
105
|
+
open = sec.text;
|
|
106
|
+
} else {
|
|
107
|
+
open += sec.text;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
flush();
|
|
111
|
+
return { partBodies, residue };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Split an over-budget weekly-log source at its `## YYYY-MM-DD` day-section
|
|
116
|
+
* seams into an ordered list of conforming parts. Pure — no I/O.
|
|
117
|
+
*
|
|
118
|
+
* The first line of `text` is the original H1; it is consumed and replaced by
|
|
119
|
+
* per-part H1s, never appearing in a part body. Everything after that first
|
|
120
|
+
* line is the body, sliced at the day-section seam byte offsets so that
|
|
121
|
+
* concatenating the parts' bodies reproduces the original body byte-for-byte.
|
|
122
|
+
* The prologue (any content above the first seam) rides with part 1. Sections
|
|
123
|
+
* are greedily packed left-to-right under both the line- and word-budget, with
|
|
124
|
+
* each candidate part measured H1-included so its own H1 is charged.
|
|
125
|
+
*
|
|
126
|
+
* When a single chunk alone exceeds a budget — a lone day-section, or the
|
|
127
|
+
* whole prologue when the source has no day-sections — it is sealed as its own
|
|
128
|
+
* (over-budget) part and named in `residue`; the rest still packs normally.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} text - The full weekly-log source (H1 + body).
|
|
131
|
+
* @param {string} agent - Agent profile id (e.g. "staff-engineer").
|
|
132
|
+
* @param {string} isoWeekStr - ISO week label (e.g. "2026-W21").
|
|
133
|
+
* @returns {{parts: Array<{h1: string, body: string}>, residue: null | {section: string, lines: number, words: number, partIndex: number}}}
|
|
134
|
+
*/
|
|
135
|
+
export function bisectWeeklyLog(text, agent, isoWeekStr) {
|
|
136
|
+
const nl = text.indexOf("\n");
|
|
137
|
+
const body = nl === -1 ? "" : text.slice(nl + 1);
|
|
138
|
+
const title = agentTitle(agent);
|
|
139
|
+
// `(part N of M)` costs the same 1 line and 4 word-tokens regardless of the
|
|
140
|
+
// digits in N/M, so a fixed template measures every part exactly without
|
|
141
|
+
// needing to know M before packing finishes.
|
|
142
|
+
const h1Template = `# ${title} — ${isoWeekStr} (part 1 of 1)`;
|
|
143
|
+
const measure = (chunk) => {
|
|
144
|
+
const rendered = `${h1Template}\n${chunk}`;
|
|
145
|
+
return { lines: countLines(rendered), words: countWords(rendered) };
|
|
146
|
+
};
|
|
147
|
+
const overBudget = (chunk) => {
|
|
148
|
+
const { lines, words } = measure(chunk);
|
|
149
|
+
return lines > WEEKLY_LOG_LINE_BUDGET || words > WEEKLY_LOG_WORD_BUDGET;
|
|
150
|
+
};
|
|
151
|
+
const partH1 = (n, m) => `# ${title} — ${isoWeekStr} (part ${n} of ${m})`;
|
|
152
|
+
const finish = (partBodies, residue) => {
|
|
153
|
+
const m = partBodies.length;
|
|
154
|
+
return {
|
|
155
|
+
parts: partBodies.map((b, i) => ({ h1: partH1(i + 1, m), body: b })),
|
|
156
|
+
residue,
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Locate the day-section seams (date at line-start, trailing suffix
|
|
161
|
+
// tolerated, e.g. `## 2026-05-19 (third activation)`).
|
|
162
|
+
const seamRe = /^## (\d{4}-\d{2}-\d{2})/gm;
|
|
163
|
+
const seams = [];
|
|
164
|
+
let match;
|
|
165
|
+
while ((match = seamRe.exec(body)) !== null) {
|
|
166
|
+
seams.push({ offset: match.index, date: match[1] });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Zero day-sections: the whole body is the prologue and its own single part,
|
|
170
|
+
// flagged as a residue when it alone exceeds a budget.
|
|
171
|
+
if (seams.length === 0) {
|
|
172
|
+
return finish([body], prologueResidue(body, { overBudget, measure }));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const prologue = body.slice(0, seams[0].offset);
|
|
176
|
+
const sections = seams.map((s, i) => ({
|
|
177
|
+
date: s.date,
|
|
178
|
+
text: body.slice(
|
|
179
|
+
s.offset,
|
|
180
|
+
i + 1 < seams.length ? seams[i + 1].offset : body.length,
|
|
181
|
+
),
|
|
182
|
+
}));
|
|
183
|
+
|
|
184
|
+
const { partBodies, residue } = packSections(sections, prologue, {
|
|
185
|
+
overBudget,
|
|
186
|
+
measure,
|
|
187
|
+
});
|
|
188
|
+
return finish(partBodies, residue);
|
|
189
|
+
}
|
|
190
|
+
|
|
41
191
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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.
|
|
199
|
+
*
|
|
200
|
+
* @param {Array<{path: string, content: string}>} leading - Committed first.
|
|
201
|
+
* @param {{path: string, content: string}} anchor - Committed last.
|
|
202
|
+
* @param {object} fs - Sync filesystem surface.
|
|
203
|
+
* @returns {string[]} The leading paths, in commit order.
|
|
204
|
+
*/
|
|
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
|
|
208
|
+
try {
|
|
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;
|
|
220
|
+
} catch (e) {
|
|
221
|
+
for (const p of committed) {
|
|
222
|
+
try {
|
|
223
|
+
fs.unlinkSync(p);
|
|
224
|
+
} catch {}
|
|
225
|
+
}
|
|
226
|
+
for (const t of temps) {
|
|
227
|
+
try {
|
|
228
|
+
fs.unlinkSync(t);
|
|
229
|
+
} catch {}
|
|
230
|
+
}
|
|
231
|
+
throw e;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
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
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Rotate the current weekly log, sealing an over-budget source into
|
|
259
|
+
* budget-conforming parts via a bisecting seal. Returns a tagged union:
|
|
260
|
+
* `{status:"noop"}` (no rotation needed), `{status:"sealed",parts}` (sealed
|
|
261
|
+
* into one-or-more conforming parts), or `{status:"incomplete",parts,residue}`
|
|
262
|
+
* (a lone day-section exceeds a budget and is named).
|
|
263
|
+
*
|
|
264
|
+
* @returns {{status: "noop"|"sealed"|"incomplete", fromPath: string, parts?: string[], residue?: {path: string, section: string, lines: number, words: number}}}
|
|
44
265
|
* @param {string} wikiRoot
|
|
45
266
|
* @param {string} agent
|
|
46
267
|
* @param {string} today - ISO date string.
|
|
@@ -58,16 +279,141 @@ export function rotateIfOverBudget(
|
|
|
58
279
|
) {
|
|
59
280
|
const filePath = weeklyLogPath(wikiRoot, agent, today);
|
|
60
281
|
const { force = false } = options;
|
|
61
|
-
if (!fs.existsSync(filePath)) return {
|
|
282
|
+
if (!fs.existsSync(filePath)) return { status: "noop", fromPath: filePath };
|
|
62
283
|
const text = fs.readFileSync(filePath, "utf-8");
|
|
63
284
|
const current = countLines(text);
|
|
64
285
|
if (!force && current + appendLines <= WEEKLY_LOG_LINE_BUDGET) {
|
|
65
|
-
return {
|
|
286
|
+
return { status: "noop", fromPath: filePath };
|
|
287
|
+
}
|
|
288
|
+
const isoWeekStr = isoWeekString(today);
|
|
289
|
+
const { parts, residue } = bisectWeeklyLog(text, agent, isoWeekStr);
|
|
290
|
+
const slots = atomicSeal(filePath, parts, agent, isoWeekStr, fs);
|
|
291
|
+
if (residue === null) {
|
|
292
|
+
return { status: "sealed", fromPath: filePath, parts: slots };
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
status: "incomplete",
|
|
296
|
+
fromPath: filePath,
|
|
297
|
+
parts: slots,
|
|
298
|
+
residue: {
|
|
299
|
+
path: slots[residue.partIndex],
|
|
300
|
+
section: residue.section,
|
|
301
|
+
lines: residue.lines,
|
|
302
|
+
words: residue.words,
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
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 };
|
|
66
405
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
+
};
|
|
71
417
|
}
|
|
72
418
|
|
|
73
419
|
/**
|