@forwardimpact/libwiki 0.2.24 → 0.2.26

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/src/weekly-log.js CHANGED
@@ -4,9 +4,14 @@ import { countLines, countWords } from "./budget.js";
4
4
  import {
5
5
  WEEKLY_LOG_LINE_BUDGET,
6
6
  WEEKLY_LOG_PART_NAME_RE,
7
+ WEEKLY_LOG_SEAM_RE,
7
8
  WEEKLY_LOG_WORD_BUDGET,
8
9
  } from "./constants.js";
9
10
 
11
+ // Block seam inside a day-section: a `### ` heading at line start. The finer
12
+ // grain rotation falls to when a lone day-section alone exceeds a budget.
13
+ const BLOCK_SEAM_RE = /^### /;
14
+
10
15
  // ISO week computation lives in libutil's calendar util (the one place a
11
16
  // `new Date` is allowed); re-exported here for the existing public surface.
12
17
  export { isoWeek } from "@forwardimpact/libutil";
@@ -48,10 +53,11 @@ function defaultH1(agent, isoWeekStr) {
48
53
  return `# ${agentTitle(agent)} — ${isoWeekStr}\n`;
49
54
  }
50
55
 
51
- /** Describe a lone over-cap day-section as a residue at its part index. */
56
+ /** Describe a lone over-cap section as a residue at its part index. The
57
+ * section label is a day date (`## ` seam) or a block heading (`### ` seam). */
52
58
  function residueOf(sec, partIndex, measure) {
53
59
  const { lines, words } = measure(sec.text);
54
- return { section: sec.date, lines, words, partIndex };
60
+ return { section: sec.label, lines, words, partIndex };
55
61
  }
56
62
 
57
63
  /**
@@ -93,7 +99,7 @@ function packSections(sections, prologue, budget) {
93
99
  };
94
100
  for (const sec of sections) {
95
101
  if (overBudget(sec.text)) {
96
- // Irreducible lone day-section: flush the open part, then seal it alone.
102
+ // Irreducible lone section: flush the open part, then seal it alone.
97
103
  flush();
98
104
  residue ??= residueOf(sec, partBodies.length, measure);
99
105
  partBodies.push(sec.text);
@@ -111,6 +117,37 @@ function packSections(sections, prologue, budget) {
111
117
  return { partBodies, residue };
112
118
  }
113
119
 
120
+ /**
121
+ * Slice `body` at `seamRe` seam offsets into `{label, text}` sections, the
122
+ * label being the seam's full matched heading line (date or `### ` heading).
123
+ * Returns `{prologue, sections}` where the prologue is everything above the
124
+ * first seam (the whole body when there are no seams). Concatenating the
125
+ * prologue and every section's text reproduces `body` byte-for-byte.
126
+ */
127
+ function findSections(body, seamRe) {
128
+ const re = new RegExp(seamRe.source, "gm");
129
+ const seams = [];
130
+ let match;
131
+ while ((match = re.exec(body)) !== null) {
132
+ const eol = body.indexOf("\n", match.index);
133
+ const headingLine = body.slice(match.index, eol === -1 ? body.length : eol);
134
+ // A captured group (the day seam's date) labels the section; otherwise the
135
+ // full heading line (a `### ` block heading) is the label.
136
+ const label = match[1] ?? headingLine;
137
+ seams.push({ offset: match.index, label });
138
+ }
139
+ if (seams.length === 0) return { prologue: body, sections: [] };
140
+ const prologue = body.slice(0, seams[0].offset);
141
+ const sections = seams.map((s, i) => ({
142
+ label: s.label,
143
+ text: body.slice(
144
+ s.offset,
145
+ i + 1 < seams.length ? seams[i + 1].offset : body.length,
146
+ ),
147
+ }));
148
+ return { prologue, sections };
149
+ }
150
+
114
151
  /**
115
152
  * Split an over-budget weekly-log source at its `## YYYY-MM-DD` day-section
116
153
  * seams into an ordered list of conforming parts. Pure — no I/O.
@@ -123,9 +160,13 @@ function packSections(sections, prologue, budget) {
123
160
  * are greedily packed left-to-right under both the line- and word-budget, with
124
161
  * each candidate part measured H1-included so its own H1 is charged.
125
162
  *
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.
163
+ * When a lone day-section alone exceeds a budget, it is re-bisected at its
164
+ * `### ` block seams (one grain finer) and the resulting block-parts replace it,
165
+ * so a single over-cap day no longer forces a hand-split. Only a single `### `
166
+ * block that alone exceeds a budget — or an over-cap seamless prologue — remains
167
+ * an irreducible residue: it is sealed as its own (over-budget) part and named
168
+ * in `residue` (the residue's `section` then names the block heading, not a
169
+ * date); the rest still packs normally.
129
170
  *
130
171
  * @param {string} text - The full weekly-log source (H1 + body).
131
172
  * @param {string} agent - Agent profile id (e.g. "staff-engineer").
@@ -157,37 +198,68 @@ export function bisectWeeklyLog(text, agent, isoWeekStr) {
157
198
  };
158
199
  };
159
200
 
201
+ const budget = { overBudget, measure };
160
202
  // Locate the day-section seams (date at line-start, trailing suffix
161
203
  // 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
- }
204
+ const { prologue, sections } = findSections(body, WEEKLY_LOG_SEAM_RE);
168
205
 
169
206
  // Zero day-sections: the whole body is the prologue and its own single part,
170
207
  // flagged as a residue when it alone exceeds a budget.
171
- if (seams.length === 0) {
172
- return finish([body], prologueResidue(body, { overBudget, measure }));
208
+ if (sections.length === 0) {
209
+ return finish([body], prologueResidue(body, budget));
173
210
  }
174
211
 
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
- });
212
+ const { partBodies, residue } = packSections(sections, prologue, budget);
213
+ // A lone day-section that alone exceeds a budget is the only residue
214
+ // `packSections` can produce here (the prologue rides part 1). Re-bisect that
215
+ // day at its `### ` block seams and splice the block-parts in for it; the
216
+ // surfaced residue becomes the inner one (an over-cap block) or null.
217
+ if (residue !== null) {
218
+ const sub = resplitDaySection(partBodies, residue, budget);
219
+ return finish(sub.partBodies, sub.residue);
220
+ }
188
221
  return finish(partBodies, residue);
189
222
  }
190
223
 
224
+ /**
225
+ * Re-bisect the lone over-cap day-section that `packSections` flagged as the
226
+ * residue: split that part's body at its `### ` block seams and splice the
227
+ * resulting block-bodies into `partBodies` in its place. The day-section's body
228
+ * carries its own `## ` heading as a prologue above the first `### ` block, so
229
+ * that heading rides the first block-part. Returns the spliced `partBodies` and
230
+ * the inner residue (a single over-cap `### ` block, or null when every block
231
+ * now conforms), re-indexed to its position in the spliced list.
232
+ */
233
+ function resplitDaySection(partBodies, residue, budget) {
234
+ const dayBody = partBodies[residue.partIndex];
235
+ // Only a day-section (its body opens with a `## ` heading) is re-split at the
236
+ // block grain; an over-cap seamless prologue has no day to descend into and
237
+ // stays the terminal residue.
238
+ if (!dayBody.startsWith("## ")) return { partBodies, residue };
239
+ const { prologue, sections } = findSections(dayBody, BLOCK_SEAM_RE);
240
+ // No `### ` block seam inside the day: nothing finer to cut — keep the day as
241
+ // the irreducible residue (criterion 3's terminal case at the day grain).
242
+ if (sections.length === 0) return { partBodies, residue };
243
+ const { partBodies: blockBodies, residue: blockResidue } = packSections(
244
+ sections,
245
+ prologue,
246
+ budget,
247
+ );
248
+ const spliced = [
249
+ ...partBodies.slice(0, residue.partIndex),
250
+ ...blockBodies,
251
+ ...partBodies.slice(residue.partIndex + 1),
252
+ ];
253
+ if (blockResidue === null) return { partBodies: spliced, residue: null };
254
+ return {
255
+ partBodies: spliced,
256
+ residue: {
257
+ ...blockResidue,
258
+ partIndex: residue.partIndex + blockResidue.partIndex,
259
+ },
260
+ };
261
+ }
262
+
191
263
  /**
192
264
  * Stage every write to `${path}.tmp`, then commit by renaming each `leading`
193
265
  * write onto its path (tracked for rollback) and the `anchor` write LAST — the
@@ -261,11 +333,20 @@ function atomicSeal(filePath, parts, agent, isoWeekStr, fs) {
261
333
  * into one-or-more conforming parts), or `{status:"incomplete",parts,residue}`
262
334
  * (a lone day-section exceeds a budget and is named).
263
335
  *
264
- * @returns {{status: "noop"|"sealed"|"incomplete", fromPath: string, parts?: string[], residue?: {path: string, section: string, lines: number, words: number}}}
336
+ * A `noop` return carries a `reason` — `"missing"` (no file; no size measured),
337
+ * `"floor"` (header-only/empty body; nothing to seal), or `"under-budget"`
338
+ * (under both budgets without `--force`) — plus the measured `lines`/`words`
339
+ * for the two reasons that read the file, so the CLI guard need not re-read it.
340
+ * "Over budget" is decided here over *either* budget (lines or words), so a
341
+ * caller never needs `force: true` to seal a word-over/line-under log.
342
+ *
343
+ * @returns {{status: "noop"|"sealed"|"incomplete", reason?: "missing"|"floor"|"under-budget", lines?: number, words?: number, fromPath: string, parts?: string[], residue?: {path: string, section: string, lines: number, words: number}}}
265
344
  * @param {string} wikiRoot
266
345
  * @param {string} agent
267
346
  * @param {string} today - ISO date string.
268
- * @param {number} [appendLines=0]
347
+ * @param {{lines?: number, words?: number}} [delta={}] - Projected post-append
348
+ * line/word delta; the trigger fires when the current file plus this delta
349
+ * would breach either budget. Force-rotate callers pass `{}`.
269
350
  * @param {{force?: boolean}} [options]
270
351
  * @param {object} fs - Sync filesystem surface (`runtime.fsSync`).
271
352
  */
@@ -273,24 +354,52 @@ export function rotateIfOverBudget(
273
354
  wikiRoot,
274
355
  agent,
275
356
  today,
276
- appendLines = 0,
357
+ delta = {},
277
358
  options = {},
278
359
  fs,
279
360
  ) {
280
361
  const filePath = weeklyLogPath(wikiRoot, agent, today);
281
362
  const { force = false } = options;
282
- if (!fs.existsSync(filePath)) return { status: "noop", fromPath: filePath };
363
+ if (!fs.existsSync(filePath)) {
364
+ return { status: "noop", reason: "missing", fromPath: filePath };
365
+ }
283
366
  const text = fs.readFileSync(filePath, "utf-8");
367
+ const lines = countLines(text);
368
+ const words = countWords(text);
284
369
  // A header-only (or empty) log has nothing to seal. Without this floor,
285
370
  // force-rotating a freshly-reset main would mint an empty `(part 1 of 1)`
286
- // file and reset the main again — once per invocation, forever.
371
+ // file and reset the main again — once per invocation, forever. The floor
372
+ // holds even under `--force`, so it is checked before the force branch.
287
373
  const nl = text.indexOf("\n");
288
374
  if ((nl === -1 ? "" : text.slice(nl + 1)).trim() === "") {
289
- return { status: "noop", fromPath: filePath };
375
+ return {
376
+ status: "noop",
377
+ reason: "floor",
378
+ lines,
379
+ words,
380
+ fromPath: filePath,
381
+ };
290
382
  }
291
- const current = countLines(text);
292
- if (!force && current + appendLines <= WEEKLY_LOG_LINE_BUDGET) {
293
- return { status: "noop", fromPath: filePath };
383
+ // Over either budget (lines or words), decided once here in core: a
384
+ // word-over/line-under log seals without `--force`. The projection folds in
385
+ // the caller's append delta so a pre-append rotate fires on the post-append
386
+ // size. The `noop`/`under-budget` arm carries the measured size so the CLI
387
+ // handler can report the resolved target without re-reading the file.
388
+ const { lines: dLines = 0, words: dWords = 0 } = delta;
389
+ const projectedLines = lines + dLines;
390
+ const projectedWords = words + dWords;
391
+ if (
392
+ !force &&
393
+ projectedLines <= WEEKLY_LOG_LINE_BUDGET &&
394
+ projectedWords <= WEEKLY_LOG_WORD_BUDGET
395
+ ) {
396
+ return {
397
+ status: "noop",
398
+ reason: "under-budget",
399
+ lines,
400
+ words,
401
+ fromPath: filePath,
402
+ };
294
403
  }
295
404
  const isoWeekStr = isoWeekString(today);
296
405
  const { parts, residue } = bisectWeeklyLog(text, agent, isoWeekStr);
@@ -388,7 +497,7 @@ export function rebisectOverBudgetPart(partPath, fs) {
388
497
  // surface a residue (synthesised from the file when the bisector did not name
389
498
  // one) so the caller's re-audit re-flags it.
390
499
  if (parts.length === 1) {
391
- const seam = text.match(/^## (\d{4}-\d{2}-\d{2})/m);
500
+ const seam = text.match(new RegExp(WEEKLY_LOG_SEAM_RE.source, "m"));
392
501
  const r = residue ?? {
393
502
  section: seam ? seam[1] : "prologue",
394
503
  lines,