@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libwiki",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "description": "Wiki lifecycle primitives — stable memory for agent teams so coordination persists across sessions.",
5
5
  "keywords": [
6
6
  "wiki",
@@ -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: "sealed parts should already be at-or-under the cap; if not, the rotation that produced this part needs investigation",
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: "sealed parts should already be at-or-under the cap; if not, the rotation that produced this part needs investigation",
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
  ];
@@ -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 so it is not treated
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
- const kind = SUMMARY_H1_RE.test(subject.firstLine) ? "summary" : "stray";
89
- return { kind, subject };
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
+ }
@@ -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
- function rotateOverBudgetMainLogs(
89
- findings,
90
- { wikiRoot, today, projectRoot, fs, out },
91
- ) {
92
- const subjects = buildContext({ wikiRoot, today, fs }).subjects[
93
- "weekly-log-main"
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.rotated) {
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, res.toPath)}\n`,
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) {
@@ -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
- rotateIfOverBudget(wikiRoot, agent, today, lineCount, {}, runtime.fsSync);
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
- rotateIfOverBudget(
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
- rotateIfOverBudget(wikiRoot, agent, today, lineCount, {}, runtime.fsSync);
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`);
@@ -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
- const result = rotateIfOverBudget(
21
- wikiRoot,
22
- agent,
23
- today,
24
- 0,
25
- { force: true },
26
- runtime.fsSync,
27
- );
28
- if (result.rotated) {
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
- } else {
33
- runtime.proc.stdout.write(`no rotation needed for ${agent}\n`);
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 { WEEKLY_LOG_LINE_BUDGET } from "./constants.js";
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 countLines(text) {
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
- * Rotate the current weekly log if next append would exceed the budget.
43
- * @returns {{rotated: boolean, fromPath: string, toPath?: string}}
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 { rotated: false, fromPath: filePath };
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 { rotated: false, fromPath: filePath };
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
- const toPath = nextPartPath(filePath, fs);
68
- fs.renameSync(filePath, toPath);
69
- fs.writeFileSync(filePath, defaultH1(agent, isoWeekString(today)));
70
- return { rotated: true, fromPath: filePath, toPath };
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
  /**