@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libwiki",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "description": "Wiki lifecycle primitives — stable memory for agent teams so coordination persists across sessions.",
5
5
  "keywords": [
6
6
  "wiki",
@@ -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
- const HEADER_RE =
8
- /^\|\s*agent\s*\|\s*target\s*\|\s*branch\s*\|\s*pr\s*\|\s*claimed_at\s*\|\s*expires_at\s*\|\s*$/;
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 (HEADER_RE.test(line)) {
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 (HEADER_RE.test(lines[i])) headerIdx = i;
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;
@@ -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 { PRIORITY_HEADER_RE, WEEKLY_LOG_H1_RE } from "./scopes.js";
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(/^# (.+) — Summary$/)[1]);
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) => CLAIMS_HEADER_RE.test(s.text);
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; use `bunx fit-wiki log decision` to do this mechanically",
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: "flag",
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: "sealed parts should already be at-or-under the cap; if not, the rotation that produced this part needs investigation",
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: "flag",
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: "sealed parts should already be at-or-under the cap; if not, the rotation that produced this part needs investigation",
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(CLAIMS_HEADER_RE),
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(CLAIMS_SEPARATOR_RE),
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
  },
@@ -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 { PRIORITY_INDEX_HEADING } from "../constants.js";
5
-
6
- const SUMMARY_H1_RE = /^# [A-Z].* — Summary$/;
7
- const WEEKLY_LOG_NAME_RE = /^([a-z][a-z-]*)-(\d{4})-W(\d{2})\.md$/;
8
- const WEEKLY_LOG_PART_NAME_RE = /^([a-z][a-z-]*)-(\d{4})-W(\d{2})-part\d+\.md$/;
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 (loadStatus) and audited via the dedicated
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
- function loadMemory(wikiRoot, fs) {
94
- const filePath = path.join(wikiRoot, "MEMORY.md");
95
- const exists = fs.existsSync(filePath);
96
- return {
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 filePath = path.join(wikiRoot, `storyboard-${ym}.md`);
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
- path: filePath,
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: loadMemory(wikiRoot, fs),
249
- status: loadStatus(wikiRoot, fs),
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
+ }
@@ -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 { rotateIfOverBudget, weeklyLogPath } from "../weekly-log.js";
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
- 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;
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.rotated) {
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, res.toPath)}\n`,
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: weekly-log rotation only.
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
- rotateOverBudgetMainLogs(findings, {
232
- wikiRoot,
233
- today,
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");
@@ -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/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";
@@ -1,12 +1,9 @@
1
- // Markers tolerate optional trailing text after the tag (typically an inline
2
- // "Do not edit. Generated from fit-wiki refresh." notice), so an open or close
3
- // marker can carry its own warning without needing a separate notice line.
4
- const XMR_OPEN_RE = /^<!--\s*xmr:([^:\s]+):(\S+)(?:\s+[^>]*?)?\s*-->\s*$/;
5
- const ISSUE_OPEN_RE =
6
- /^<!--\s*(obstacles|experiments):(open|closed)(?::(\d+d))?(?:\s+[^>]*?)?\s*-->\s*$/;
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 { WEEKLY_LOG_LINE_BUDGET } from "./constants.js";
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 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) {
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
- * Rotate the current weekly log if next append would exceed the budget.
43
- * @returns {{rotated: boolean, fromPath: string, toPath?: string}}
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 { rotated: false, fromPath: filePath };
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 { rotated: false, fromPath: filePath };
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
- 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 };
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
  /**