@forwardimpact/libwiki 0.2.14 → 0.2.16
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/audit/rules.js +2 -14
- package/src/audit/scopes.js +6 -25
- package/src/budget.js +25 -0
- package/src/commands/fix.js +32 -15
- package/src/commands/log.js +34 -5
- package/src/commands/rotate.js +40 -14
- package/src/index.js +2 -0
- package/src/weekly-log.js +237 -20
package/package.json
CHANGED
package/src/audit/rules.js
CHANGED
|
@@ -77,7 +77,6 @@ const columnCount = (expected) => (s) =>
|
|
|
77
77
|
|
|
78
78
|
const exists = (s) => (s.exists ? null : {});
|
|
79
79
|
const expired = (s, ctx) => (s.expires_at < ctx.today ? {} : null);
|
|
80
|
-
const always = () => ({});
|
|
81
80
|
|
|
82
81
|
function entryHasDecision(lines, startIdx, requiredLine, stopRe) {
|
|
83
82
|
let seen = 0;
|
|
@@ -311,7 +310,7 @@ export const RULES = [
|
|
|
311
310
|
remediation: "flag",
|
|
312
311
|
check: lineBudget(WEEKLY_LOG_LINE_BUDGET),
|
|
313
312
|
message: (_s, r) => `${r.value} lines (limit ${WEEKLY_LOG_LINE_BUDGET})`,
|
|
314
|
-
hint: "
|
|
313
|
+
hint: "the bisecting seal produces conforming parts, so an over-budget part means a hand-edited part or an irreducible single-day section — bisect it at a finer seam by hand",
|
|
315
314
|
},
|
|
316
315
|
{
|
|
317
316
|
id: "weekly-log-part.word-budget",
|
|
@@ -320,7 +319,7 @@ export const RULES = [
|
|
|
320
319
|
remediation: "flag",
|
|
321
320
|
check: wordBudget(WEEKLY_LOG_WORD_BUDGET),
|
|
322
321
|
message: (_s, r) => `${r.value} words (limit ${WEEKLY_LOG_WORD_BUDGET})`,
|
|
323
|
-
hint: "
|
|
322
|
+
hint: "the bisecting seal produces conforming parts, so an over-budget part means a hand-edited part or an irreducible single-day section — bisect it at a finer seam by hand",
|
|
324
323
|
},
|
|
325
324
|
{
|
|
326
325
|
id: "weekly-log-part.h1-agent-matches-filename",
|
|
@@ -492,15 +491,4 @@ export const RULES = [
|
|
|
492
491
|
// -- STATUS.md rows (per-migration-unit sub-row schema) --
|
|
493
492
|
|
|
494
493
|
...STATUS_ROW_RULES,
|
|
495
|
-
|
|
496
|
-
// -- Stray files --
|
|
497
|
-
|
|
498
|
-
{
|
|
499
|
-
id: "wiki.stray-file",
|
|
500
|
-
scope: "stray-file",
|
|
501
|
-
severity: "fail",
|
|
502
|
-
check: always,
|
|
503
|
-
message: () => "Does not match any known scope",
|
|
504
|
-
hint: "rename to a recognized scope (summary, weekly log, weekly-log part) or remove the file",
|
|
505
|
-
},
|
|
506
494
|
];
|
package/src/audit/scopes.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { yearMonth } from "@forwardimpact/libutil";
|
|
3
3
|
import { parseClaims } from "../active-claims.js";
|
|
4
|
+
import { countLines, countWords } from "../budget.js";
|
|
4
5
|
import { PRIORITY_INDEX_HEADING } from "../constants.js";
|
|
5
6
|
|
|
6
7
|
const SUMMARY_H1_RE = /^# [A-Z].* — Summary$/;
|
|
@@ -28,25 +29,6 @@ function listMdFiles(wikiRoot, fs) {
|
|
|
28
29
|
.map((e) => path.join(wikiRoot, e));
|
|
29
30
|
}
|
|
30
31
|
|
|
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
32
|
function loadFile(filePath, fs) {
|
|
51
33
|
const text = fs.readFileSync(filePath, "utf-8");
|
|
52
34
|
const fileLines = text.split("\n");
|
|
@@ -74,8 +56,7 @@ function classifyFile(filePath, fs) {
|
|
|
74
56
|
const base = path.basename(filePath);
|
|
75
57
|
if (EXCLUDED_BASES.has(base)) return null;
|
|
76
58
|
// STATUS.md is loaded separately (loadStatus) and audited via the dedicated
|
|
77
|
-
// `status-row` scope — skip the per-file classification
|
|
78
|
-
// as a stray file.
|
|
59
|
+
// `status-row` scope — skip the per-file classification.
|
|
79
60
|
if (base === "STATUS.md") return null;
|
|
80
61
|
if (NON_SUMMARY_PREFIXES.some((p) => base.startsWith(p))) return null;
|
|
81
62
|
if (WEEKLY_LOG_NAME_RE.test(base)) {
|
|
@@ -85,8 +66,10 @@ function classifyFile(filePath, fs) {
|
|
|
85
66
|
return { kind: "weekly-log-part", subject: loadFile(filePath, fs) };
|
|
86
67
|
}
|
|
87
68
|
const subject = loadFile(filePath, fs);
|
|
88
|
-
|
|
89
|
-
|
|
69
|
+
// Files that do not match a summary or weekly-log shape are left
|
|
70
|
+
// unclassified: stray files are not audited.
|
|
71
|
+
if (!SUMMARY_H1_RE.test(subject.firstLine)) return null;
|
|
72
|
+
return { kind: "summary", subject };
|
|
90
73
|
}
|
|
91
74
|
|
|
92
75
|
function loadMemory(wikiRoot, fs) {
|
|
@@ -216,7 +199,6 @@ const SCOPE_RESOLVERS = {
|
|
|
216
199
|
...r,
|
|
217
200
|
path: ctx.status.path,
|
|
218
201
|
})),
|
|
219
|
-
"stray-file": (ctx) => ctx.subjects.stray,
|
|
220
202
|
};
|
|
221
203
|
|
|
222
204
|
/** Resolve a scope key into the list of subjects the engine should iterate. */
|
|
@@ -236,7 +218,6 @@ export function buildContext({ wikiRoot, today, fs }) {
|
|
|
236
218
|
summary: [],
|
|
237
219
|
"weekly-log-main": [],
|
|
238
220
|
"weekly-log-part": [],
|
|
239
|
-
stray: [],
|
|
240
221
|
};
|
|
241
222
|
for (const file of listMdFiles(wikiRoot, fs)) {
|
|
242
223
|
const classified = classifyFile(file, fs);
|
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
|
@@ -85,19 +85,12 @@ function composeFollowup(findings, projectRoot) {
|
|
|
85
85
|
* untouched (rotating it would force-seal a healthy current-week log instead);
|
|
86
86
|
* it survives the re-audit and is flagged for a human.
|
|
87
87
|
*/
|
|
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;
|
|
88
|
+
/**
|
|
89
|
+
* Seal one over-budget current-week main log. A failed seal leaves the source
|
|
90
|
+
* intact (the writer rolled back), so the re-audit re-flags it for a human.
|
|
91
|
+
*/
|
|
92
|
+
function sealMainLog(agent, { wikiRoot, today, projectRoot, fs, out, err }) {
|
|
93
|
+
try {
|
|
101
94
|
const res = rotateIfOverBudget(
|
|
102
95
|
wikiRoot,
|
|
103
96
|
agent,
|
|
@@ -106,12 +99,35 @@ function rotateOverBudgetMainLogs(
|
|
|
106
99
|
{ force: true },
|
|
107
100
|
fs,
|
|
108
101
|
);
|
|
109
|
-
if (res.
|
|
102
|
+
if (res.status !== "sealed" && res.status !== "incomplete") return;
|
|
103
|
+
for (const part of res.parts) {
|
|
110
104
|
out(
|
|
111
105
|
`rotated ${path.relative(projectRoot, res.fromPath)} -> ` +
|
|
112
|
-
`${path.relative(projectRoot,
|
|
106
|
+
`${path.relative(projectRoot, part)}\n`,
|
|
113
107
|
);
|
|
114
108
|
}
|
|
109
|
+
} catch (e) {
|
|
110
|
+
err(`fit-wiki fix: rotate failed for ${agent}: ${e.message}\n`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function rotateOverBudgetMainLogs(findings, deps) {
|
|
115
|
+
const { wikiRoot, today, fs } = deps;
|
|
116
|
+
const subjects = buildContext({ wikiRoot, today, fs }).subjects[
|
|
117
|
+
"weekly-log-main"
|
|
118
|
+
];
|
|
119
|
+
const agentByPath = new Map(subjects.map((s) => [s.path, s.agentPrefix]));
|
|
120
|
+
// A log over BOTH budgets yields two `rotate` findings with the same path
|
|
121
|
+
// (different rule ids); seal each path once, or the second force call would
|
|
122
|
+
// bisect the freshly-written fresh-main into a spurious near-empty part.
|
|
123
|
+
const sealed = new Set();
|
|
124
|
+
for (const f of findings) {
|
|
125
|
+
if (classOf(f) !== "rotate") continue;
|
|
126
|
+
const agent = agentByPath.get(f.path);
|
|
127
|
+
if (!agent || weeklyLogPath(wikiRoot, agent, today) !== f.path) continue;
|
|
128
|
+
if (sealed.has(f.path)) continue;
|
|
129
|
+
sealed.add(f.path);
|
|
130
|
+
sealMainLog(agent, deps);
|
|
115
131
|
}
|
|
116
132
|
}
|
|
117
133
|
|
|
@@ -234,6 +250,7 @@ export async function runFixCommand(ctx) {
|
|
|
234
250
|
projectRoot,
|
|
235
251
|
fs,
|
|
236
252
|
out,
|
|
253
|
+
err,
|
|
237
254
|
});
|
|
238
255
|
findings = audit();
|
|
239
256
|
if (findings.length === 0) {
|
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/index.js
CHANGED
|
@@ -32,8 +32,10 @@ export {
|
|
|
32
32
|
isoWeek,
|
|
33
33
|
weeklyLogPath,
|
|
34
34
|
rotateIfOverBudget,
|
|
35
|
+
bisectWeeklyLog,
|
|
35
36
|
appendEntry,
|
|
36
37
|
} from "./weekly-log.js";
|
|
37
38
|
export { buildDigest } from "./boot.js";
|
|
39
|
+
export { countLines, countWords } from "./budget.js";
|
|
38
40
|
export { RULES } from "./audit/rules.js";
|
|
39
41
|
export { resolveScope as resolveAuditScope } from "./audit/scopes.js";
|
package/src/weekly-log.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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 { WEEKLY_LOG_LINE_BUDGET, WEEKLY_LOG_WORD_BUDGET } from "./constants.js";
|
|
4
5
|
|
|
5
6
|
// ISO week computation lives in libutil's calendar util (the one place a
|
|
6
7
|
// `new Date` is allowed); re-exported here for the existing public surface.
|
|
@@ -11,22 +12,27 @@ export function weeklyLogPath(wikiRoot, agent, today) {
|
|
|
11
12
|
return path.join(wikiRoot, `${agent}-${isoWeekString(today)}.md`);
|
|
12
13
|
}
|
|
13
14
|
|
|
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) {
|
|
15
|
+
function partPathAt(filePath, n) {
|
|
23
16
|
const dir = path.dirname(filePath);
|
|
24
17
|
const base = path.basename(filePath, ".md");
|
|
25
|
-
let n = 1;
|
|
26
|
-
while (fs.existsSync(path.join(dir, `${base}-part${n}.md`))) n++;
|
|
27
18
|
return path.join(dir, `${base}-part${n}.md`);
|
|
28
19
|
}
|
|
29
20
|
|
|
21
|
+
// Find `count` part slots that are each verified free, skipping any occupied
|
|
22
|
+
// `-partN.md` (e.g. a numbering gap left by a manually deleted middle part).
|
|
23
|
+
// Every returned slot is unoccupied, so the seal never overwrites a pre-existing
|
|
24
|
+
// part on commit nor unlinks one on rollback.
|
|
25
|
+
function nextFreeSlots(filePath, count, fs) {
|
|
26
|
+
const slots = [];
|
|
27
|
+
let n = 1;
|
|
28
|
+
while (slots.length < count) {
|
|
29
|
+
const p = partPathAt(filePath, n);
|
|
30
|
+
if (!fs.existsSync(p)) slots.push(p);
|
|
31
|
+
n++;
|
|
32
|
+
}
|
|
33
|
+
return slots;
|
|
34
|
+
}
|
|
35
|
+
|
|
30
36
|
function agentTitle(agent) {
|
|
31
37
|
return agent
|
|
32
38
|
.split("-")
|
|
@@ -38,9 +44,207 @@ function defaultH1(agent, isoWeekStr) {
|
|
|
38
44
|
return `# ${agentTitle(agent)} — ${isoWeekStr}\n`;
|
|
39
45
|
}
|
|
40
46
|
|
|
47
|
+
/** Describe a lone over-cap day-section as a residue at its part index. */
|
|
48
|
+
function residueOf(sec, partIndex, measure) {
|
|
49
|
+
const { lines, words } = measure(sec.text);
|
|
50
|
+
return { section: sec.date, lines, words, partIndex };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* An over-cap prologue cannot merge with any day-section (adding content only
|
|
55
|
+
* grows it), so it always seals as its own part 0. Flag it up front as the
|
|
56
|
+
* first residue so it is never shipped silently over budget.
|
|
57
|
+
*/
|
|
58
|
+
function prologueResidue(prologue, { overBudget, measure }) {
|
|
59
|
+
if (prologue.length === 0 || !overBudget(prologue)) return null;
|
|
60
|
+
const { lines, words } = measure(prologue);
|
|
61
|
+
return { section: "prologue", lines, words, partIndex: 0 };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Greedily pack day-sections into part bodies under both budgets, the prologue
|
|
66
|
+
* riding with part 1. A chunk that alone exceeds a budget — a lone day-section
|
|
67
|
+
* or an over-cap prologue — is sealed as its own part and recorded as the
|
|
68
|
+
* (first) residue; packed runs and single sections are kept under both budgets,
|
|
69
|
+
* so the only over-cap part bodies are the ones `residue` accounts for.
|
|
70
|
+
* @param {Array<{date: string, text: string}>} sections
|
|
71
|
+
* @param {string} prologue - Content above the first seam; rides with part 1.
|
|
72
|
+
* @param {{overBudget: (s: string) => boolean, measure: (s: string) => {lines: number, words: number}}} budget
|
|
73
|
+
* @returns {{partBodies: string[], residue: null | {section: string, lines: number, words: number, partIndex: number}}}
|
|
74
|
+
*/
|
|
75
|
+
function packSections(sections, prologue, budget) {
|
|
76
|
+
const { overBudget, measure } = budget;
|
|
77
|
+
const partBodies = [];
|
|
78
|
+
// The prologue, when over budget, is always pushed first (part 0) — record it
|
|
79
|
+
// before packing so a later lone-section residue cannot displace it.
|
|
80
|
+
let residue = prologueResidue(prologue, budget);
|
|
81
|
+
let open = prologue; // body of the part currently being filled
|
|
82
|
+
let opened = prologue.length > 0;
|
|
83
|
+
const flush = () => {
|
|
84
|
+
if (opened) {
|
|
85
|
+
partBodies.push(open);
|
|
86
|
+
open = "";
|
|
87
|
+
opened = false;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
for (const sec of sections) {
|
|
91
|
+
if (overBudget(sec.text)) {
|
|
92
|
+
// Irreducible lone day-section: flush the open part, then seal it alone.
|
|
93
|
+
flush();
|
|
94
|
+
residue ??= residueOf(sec, partBodies.length, measure);
|
|
95
|
+
partBodies.push(sec.text);
|
|
96
|
+
} else if (!opened) {
|
|
97
|
+
open = sec.text;
|
|
98
|
+
opened = true;
|
|
99
|
+
} else if (overBudget(open + sec.text)) {
|
|
100
|
+
partBodies.push(open);
|
|
101
|
+
open = sec.text;
|
|
102
|
+
} else {
|
|
103
|
+
open += sec.text;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
flush();
|
|
107
|
+
return { partBodies, residue };
|
|
108
|
+
}
|
|
109
|
+
|
|
41
110
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
111
|
+
* Split an over-budget weekly-log source at its `## YYYY-MM-DD` day-section
|
|
112
|
+
* seams into an ordered list of conforming parts. Pure — no I/O.
|
|
113
|
+
*
|
|
114
|
+
* The first line of `text` is the original H1; it is consumed and replaced by
|
|
115
|
+
* per-part H1s, never appearing in a part body. Everything after that first
|
|
116
|
+
* line is the body, sliced at the day-section seam byte offsets so that
|
|
117
|
+
* concatenating the parts' bodies reproduces the original body byte-for-byte.
|
|
118
|
+
* The prologue (any content above the first seam) rides with part 1. Sections
|
|
119
|
+
* are greedily packed left-to-right under both the line- and word-budget, with
|
|
120
|
+
* each candidate part measured H1-included so its own H1 is charged.
|
|
121
|
+
*
|
|
122
|
+
* When a single chunk alone exceeds a budget — a lone day-section, or the
|
|
123
|
+
* whole prologue when the source has no day-sections — it is sealed as its own
|
|
124
|
+
* (over-budget) part and named in `residue`; the rest still packs normally.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} text - The full weekly-log source (H1 + body).
|
|
127
|
+
* @param {string} agent - Agent profile id (e.g. "staff-engineer").
|
|
128
|
+
* @param {string} isoWeekStr - ISO week label (e.g. "2026-W21").
|
|
129
|
+
* @returns {{parts: Array<{h1: string, body: string}>, residue: null | {section: string, lines: number, words: number, partIndex: number}}}
|
|
130
|
+
*/
|
|
131
|
+
export function bisectWeeklyLog(text, agent, isoWeekStr) {
|
|
132
|
+
const nl = text.indexOf("\n");
|
|
133
|
+
const body = nl === -1 ? "" : text.slice(nl + 1);
|
|
134
|
+
const title = agentTitle(agent);
|
|
135
|
+
// `(part N of M)` costs the same 1 line and 4 word-tokens regardless of the
|
|
136
|
+
// digits in N/M, so a fixed template measures every part exactly without
|
|
137
|
+
// needing to know M before packing finishes.
|
|
138
|
+
const h1Template = `# ${title} — ${isoWeekStr} (part 1 of 1)`;
|
|
139
|
+
const measure = (chunk) => {
|
|
140
|
+
const rendered = `${h1Template}\n${chunk}`;
|
|
141
|
+
return { lines: countLines(rendered), words: countWords(rendered) };
|
|
142
|
+
};
|
|
143
|
+
const overBudget = (chunk) => {
|
|
144
|
+
const { lines, words } = measure(chunk);
|
|
145
|
+
return lines > WEEKLY_LOG_LINE_BUDGET || words > WEEKLY_LOG_WORD_BUDGET;
|
|
146
|
+
};
|
|
147
|
+
const partH1 = (n, m) => `# ${title} — ${isoWeekStr} (part ${n} of ${m})`;
|
|
148
|
+
const finish = (partBodies, residue) => {
|
|
149
|
+
const m = partBodies.length;
|
|
150
|
+
return {
|
|
151
|
+
parts: partBodies.map((b, i) => ({ h1: partH1(i + 1, m), body: b })),
|
|
152
|
+
residue,
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Locate the day-section seams (date at line-start, trailing suffix
|
|
157
|
+
// tolerated, e.g. `## 2026-05-19 (third activation)`).
|
|
158
|
+
const seamRe = /^## (\d{4}-\d{2}-\d{2})/gm;
|
|
159
|
+
const seams = [];
|
|
160
|
+
let match;
|
|
161
|
+
while ((match = seamRe.exec(body)) !== null) {
|
|
162
|
+
seams.push({ offset: match.index, date: match[1] });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Zero day-sections: the whole body is the prologue and its own single part,
|
|
166
|
+
// flagged as a residue when it alone exceeds a budget.
|
|
167
|
+
if (seams.length === 0) {
|
|
168
|
+
return finish([body], prologueResidue(body, { overBudget, measure }));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const prologue = body.slice(0, seams[0].offset);
|
|
172
|
+
const sections = seams.map((s, i) => ({
|
|
173
|
+
date: s.date,
|
|
174
|
+
text: body.slice(
|
|
175
|
+
s.offset,
|
|
176
|
+
i + 1 < seams.length ? seams[i + 1].offset : body.length,
|
|
177
|
+
),
|
|
178
|
+
}));
|
|
179
|
+
|
|
180
|
+
const { partBodies, residue } = packSections(sections, prologue, {
|
|
181
|
+
overBudget,
|
|
182
|
+
measure,
|
|
183
|
+
});
|
|
184
|
+
return finish(partBodies, residue);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Stage every part at its slot and the fresh-main body at temps, then commit
|
|
189
|
+
* by renaming each part onto its `-partN.md` slot and the fresh main over
|
|
190
|
+
* `filePath` as the single final step. On any failure before that last rename,
|
|
191
|
+
* unlink every committed slot and remaining temp this seal wrote and re-throw,
|
|
192
|
+
* so the source's path/contents/inode are untouched. Returns the produced slot
|
|
193
|
+
* paths in part order.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} filePath - The current weekly-log path.
|
|
196
|
+
* @param {Array<{h1: string, body: string}>} parts - Ordered parts to seal.
|
|
197
|
+
* @param {string} agent
|
|
198
|
+
* @param {string} isoWeekStr
|
|
199
|
+
* @param {object} fs - Sync filesystem surface.
|
|
200
|
+
* @returns {string[]} The `-partN.md` slot paths, in part order.
|
|
201
|
+
*/
|
|
202
|
+
function atomicSeal(filePath, parts, agent, isoWeekStr, fs) {
|
|
203
|
+
const slots = nextFreeSlots(filePath, parts.length, fs);
|
|
204
|
+
const mainTemp = `${filePath}.tmp`;
|
|
205
|
+
const temps = []; // temp paths this seal wrote, for rollback
|
|
206
|
+
const committed = []; // slots already renamed into place, for rollback
|
|
207
|
+
try {
|
|
208
|
+
// Stage every part and the fresh main at temp files.
|
|
209
|
+
slots.forEach((slot, i) => {
|
|
210
|
+
const tmp = `${slot}.tmp`;
|
|
211
|
+
fs.writeFileSync(tmp, `${parts[i].h1}\n${parts[i].body}`);
|
|
212
|
+
temps.push(tmp);
|
|
213
|
+
});
|
|
214
|
+
fs.writeFileSync(mainTemp, defaultH1(agent, isoWeekStr));
|
|
215
|
+
temps.push(mainTemp);
|
|
216
|
+
// Commit: parts onto their slots, then the fresh main as the final step.
|
|
217
|
+
slots.forEach((slot, i) => {
|
|
218
|
+
fs.renameSync(`${slot}.tmp`, slot);
|
|
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;
|
|
225
|
+
} catch (e) {
|
|
226
|
+
for (const slot of committed) {
|
|
227
|
+
try {
|
|
228
|
+
fs.unlinkSync(slot);
|
|
229
|
+
} catch {}
|
|
230
|
+
}
|
|
231
|
+
for (const tmp of temps) {
|
|
232
|
+
try {
|
|
233
|
+
fs.unlinkSync(tmp);
|
|
234
|
+
} catch {}
|
|
235
|
+
}
|
|
236
|
+
throw e;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Rotate the current weekly log, sealing an over-budget source into
|
|
242
|
+
* budget-conforming parts via a bisecting seal. Returns a tagged union:
|
|
243
|
+
* `{status:"noop"}` (no rotation needed), `{status:"sealed",parts}` (sealed
|
|
244
|
+
* into one-or-more conforming parts), or `{status:"incomplete",parts,residue}`
|
|
245
|
+
* (a lone day-section exceeds a budget and is named).
|
|
246
|
+
*
|
|
247
|
+
* @returns {{status: "noop"|"sealed"|"incomplete", fromPath: string, parts?: string[], residue?: {path: string, section: string, lines: number, words: number}}}
|
|
44
248
|
* @param {string} wikiRoot
|
|
45
249
|
* @param {string} agent
|
|
46
250
|
* @param {string} today - ISO date string.
|
|
@@ -58,16 +262,29 @@ export function rotateIfOverBudget(
|
|
|
58
262
|
) {
|
|
59
263
|
const filePath = weeklyLogPath(wikiRoot, agent, today);
|
|
60
264
|
const { force = false } = options;
|
|
61
|
-
if (!fs.existsSync(filePath)) return {
|
|
265
|
+
if (!fs.existsSync(filePath)) return { status: "noop", fromPath: filePath };
|
|
62
266
|
const text = fs.readFileSync(filePath, "utf-8");
|
|
63
267
|
const current = countLines(text);
|
|
64
268
|
if (!force && current + appendLines <= WEEKLY_LOG_LINE_BUDGET) {
|
|
65
|
-
return {
|
|
269
|
+
return { status: "noop", fromPath: filePath };
|
|
270
|
+
}
|
|
271
|
+
const isoWeekStr = isoWeekString(today);
|
|
272
|
+
const { parts, residue } = bisectWeeklyLog(text, agent, isoWeekStr);
|
|
273
|
+
const slots = atomicSeal(filePath, parts, agent, isoWeekStr, fs);
|
|
274
|
+
if (residue === null) {
|
|
275
|
+
return { status: "sealed", fromPath: filePath, parts: slots };
|
|
66
276
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
277
|
+
return {
|
|
278
|
+
status: "incomplete",
|
|
279
|
+
fromPath: filePath,
|
|
280
|
+
parts: slots,
|
|
281
|
+
residue: {
|
|
282
|
+
path: slots[residue.partIndex],
|
|
283
|
+
section: residue.section,
|
|
284
|
+
lines: residue.lines,
|
|
285
|
+
words: residue.words,
|
|
286
|
+
},
|
|
287
|
+
};
|
|
71
288
|
}
|
|
72
289
|
|
|
73
290
|
/**
|