@forwardimpact/libwiki 0.2.16 → 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.16",
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: "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",
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: "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",
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
  },
@@ -2,13 +2,17 @@ import path from "node:path";
2
2
  import { yearMonth } from "@forwardimpact/libutil";
3
3
  import { parseClaims } from "../active-claims.js";
4
4
  import { countLines, countWords } from "../budget.js";
5
- import { PRIORITY_INDEX_HEADING } from "../constants.js";
5
+ import {
6
+ PRIORITY_INDEX_HEADING,
7
+ WEEKLY_LOG_NAME_RE,
8
+ WEEKLY_LOG_PART_NAME_RE,
9
+ } from "../constants.js";
6
10
 
7
- const SUMMARY_H1_RE = /^# [A-Z].* Summary$/;
8
- const WEEKLY_LOG_NAME_RE = /^([a-z][a-z-]*)-(\d{4})-W(\d{2})\.md$/;
9
- const WEEKLY_LOG_PART_NAME_RE = /^([a-z][a-z-]*)-(\d{4})-W(\d{2})-part\d+\.md$/;
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$/;
10
14
  export const WEEKLY_LOG_H1_RE =
11
- /^# .* — \d{4}-W\d{2}(?: \(part \d+ of \d+\))?$/;
15
+ /^# (.*) — \d{4}-W\d{2}(?: \(part \d+ of \d+\))?$/;
12
16
  export const PRIORITY_HEADER_RE =
13
17
  /^\|\s*Item\s*\|\s*Agents\s*\|\s*Owner\s*\|\s*Status\s*\|\s*Added\s*\|/m;
14
18
 
@@ -55,8 +59,8 @@ function loadFile(filePath, fs) {
55
59
  function classifyFile(filePath, fs) {
56
60
  const base = path.basename(filePath);
57
61
  if (EXCLUDED_BASES.has(base)) return null;
58
- // STATUS.md is loaded separately (loadStatus) and audited via the dedicated
59
- // `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.
60
64
  if (base === "STATUS.md") return null;
61
65
  if (NON_SUMMARY_PREFIXES.some((p) => base.startsWith(p))) return null;
62
66
  if (WEEKLY_LOG_NAME_RE.test(base)) {
@@ -72,18 +76,10 @@ function classifyFile(filePath, fs) {
72
76
  return { kind: "summary", subject };
73
77
  }
74
78
 
75
- function loadMemory(wikiRoot, fs) {
76
- const filePath = path.join(wikiRoot, "MEMORY.md");
77
- const exists = fs.existsSync(filePath);
78
- return {
79
- path: filePath,
80
- text: exists ? fs.readFileSync(filePath, "utf-8") : "",
81
- exists,
82
- };
83
- }
84
-
85
- function loadStatus(wikiRoot, fs) {
86
- 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) {
87
83
  const exists = fs.existsSync(filePath);
88
84
  return {
89
85
  path: filePath,
@@ -124,17 +120,13 @@ function parseStatusRows(statusText) {
124
120
 
125
121
  function loadStoryboard(wikiRoot, today, fs) {
126
122
  const ym = yearMonth(today);
127
- const filePath = path.join(wikiRoot, `storyboard-${ym}.md`);
128
- const exists = fs.existsSync(filePath);
129
- const text = exists ? fs.readFileSync(filePath, "utf-8") : "";
123
+ const base = readOptional(path.join(wikiRoot, `storyboard-${ym}.md`), fs);
130
124
  return {
131
- path: filePath,
132
- text,
133
- fileLines: text.split("\n"),
134
- exists,
125
+ ...base,
126
+ fileLines: base.text.split("\n"),
135
127
  yearMonth: ym,
136
- lines: countLines(text),
137
- words: countWords(text),
128
+ lines: countLines(base.text),
129
+ words: countWords(base.text),
138
130
  };
139
131
  }
140
132
 
@@ -227,8 +219,8 @@ export function buildContext({ wikiRoot, today, fs }) {
227
219
  wikiRoot,
228
220
  today,
229
221
  subjects,
230
- memory: loadMemory(wikiRoot, fs),
231
- status: loadStatus(wikiRoot, fs),
222
+ memory: readOptional(path.join(wikiRoot, "MEMORY.md"), fs),
223
+ status: readOptional(path.join(wikiRoot, "STATUS.md"), fs),
232
224
  storyboard: loadStoryboard(wikiRoot, today, fs),
233
225
  };
234
226
  }
@@ -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
 
@@ -131,6 +135,50 @@ function rotateOverBudgetMainLogs(findings, deps) {
131
135
  }
132
136
  }
133
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
149
+ out(
150
+ `rotated ${path.relative(projectRoot, res.fromPath)} -> ` +
151
+ `${path.relative(projectRoot, part)}\n`,
152
+ );
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);
179
+ }
180
+ }
181
+
134
182
  /** Report findings that need human judgment — never auto-fixed. */
135
183
  function reportFlags(err, flagFindings, projectRoot) {
136
184
  err(
@@ -242,16 +290,12 @@ export async function runFixCommand(ctx) {
242
290
  return { ok: true };
243
291
  }
244
292
 
245
- // 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.
246
295
  if (findings.some((f) => classOf(f) === "rotate")) {
247
- rotateOverBudgetMainLogs(findings, {
248
- wikiRoot,
249
- today,
250
- projectRoot,
251
- fs,
252
- out,
253
- err,
254
- });
296
+ const rotateDeps = { wikiRoot, today, projectRoot, fs, out, err };
297
+ rotateOverBudgetMainLogs(findings, rotateDeps);
298
+ rebisectOverBudgetParts(findings, rotateDeps);
255
299
  findings = audit();
256
300
  if (findings.length === 0) {
257
301
  out("fixed: wiki audit is clean\n");
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,6 +32,7 @@ export {
32
32
  isoWeek,
33
33
  weeklyLogPath,
34
34
  rotateIfOverBudget,
35
+ rebisectOverBudgetPart,
35
36
  bisectWeeklyLog,
36
37
  appendEntry,
37
38
  } from "./weekly-log.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,7 +1,11 @@
1
1
  import path from "node:path";
2
2
  import { isoWeekString } from "@forwardimpact/libutil";
3
3
  import { countLines, countWords } from "./budget.js";
4
- import { WEEKLY_LOG_LINE_BUDGET, WEEKLY_LOG_WORD_BUDGET } from "./constants.js";
4
+ import {
5
+ WEEKLY_LOG_LINE_BUDGET,
6
+ WEEKLY_LOG_PART_NAME_RE,
7
+ WEEKLY_LOG_WORD_BUDGET,
8
+ } from "./constants.js";
5
9
 
6
10
  // ISO week computation lives in libutil's calendar util (the one place a
7
11
  // `new Date` is allowed); re-exported here for the existing public surface.
@@ -185,58 +189,71 @@ export function bisectWeeklyLog(text, agent, isoWeekStr) {
185
189
  }
186
190
 
187
191
  /**
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.
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.
194
199
  *
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
200
+ * @param {Array<{path: string, content: string}>} leading - Committed first.
201
+ * @param {{path: string, content: string}} anchor - Committed last.
199
202
  * @param {object} fs - Sync filesystem surface.
200
- * @returns {string[]} The `-partN.md` slot paths, in part order.
203
+ * @returns {string[]} The leading paths, in commit order.
201
204
  */
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
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
207
208
  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;
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;
225
220
  } catch (e) {
226
- for (const slot of committed) {
221
+ for (const p of committed) {
227
222
  try {
228
- fs.unlinkSync(slot);
223
+ fs.unlinkSync(p);
229
224
  } catch {}
230
225
  }
231
- for (const tmp of temps) {
226
+ for (const t of temps) {
232
227
  try {
233
- fs.unlinkSync(tmp);
228
+ fs.unlinkSync(t);
234
229
  } catch {}
235
230
  }
236
231
  throw e;
237
232
  }
238
233
  }
239
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
+
240
257
  /**
241
258
  * Rotate the current weekly log, sealing an over-budget source into
242
259
  * budget-conforming parts via a bisecting seal. Returns a tagged union:
@@ -287,6 +304,118 @@ export function rotateIfOverBudget(
287
304
  };
288
305
  }
289
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 };
405
+ }
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
+ };
417
+ }
418
+
290
419
  /**
291
420
  * Append a body to a weekly log file. Creates it with an H1 if missing.
292
421
  * @param {string} filePath