@forwardimpact/libwiki 0.2.25 → 0.2.27

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.
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Fail-closed secret gate for the wiki push path. Runs gitleaks over the
3
+ * commit range a push introduces and reports a clean / finding /
4
+ * scanner-absent verdict. The wiki has no destination-side secret control (a
5
+ * GitHub Wiki repo runs no Actions and is excluded from GitHub
6
+ * secret-scanning), so this is the only place a content backstop can live.
7
+ *
8
+ * The module never throws on a scanner result: a missing or erroring scanner
9
+ * resolves to `scanner-absent` so the caller fails closed rather than treating
10
+ * an error as clean. Findings carry only a location (`file:line:rule`) — never
11
+ * the matched secret value, so an audit record built from them cannot itself
12
+ * leak.
13
+ */
14
+
15
+ import { isoTimestamp } from "@forwardimpact/libutil";
16
+ import { createLogger } from "@forwardimpact/libtelemetry";
17
+
18
+ /** The gitleaks binary name resolved on PATH; provisioning is an operator concern (see wiki-operations guide). */
19
+ const GITLEAKS = "gitleaks";
20
+
21
+ /**
22
+ * Scan the commit range a push introduces for secrets, fail closed.
23
+ *
24
+ * Probes `gitleaks version` first; an unresolvable binary short-circuits to
25
+ * `scanner-absent`. Then runs `gitleaks detect` over `range` expressed as
26
+ * `git log` options, reading the JSON report from stdout. Exit codes follow
27
+ * gitleaks' documented contract: `0` clean, `1` leaks found, any other
28
+ * non-zero an invocation error (treated as `scanner-absent` — fail closed, an
29
+ * error is never reported as clean).
30
+ *
31
+ * @param {object} args
32
+ * @param {import('@forwardimpact/libutil/runtime').Runtime} args.runtime - Provides `subprocess.run`.
33
+ * @param {string} args.wikiDir - The wiki clone directory to scan.
34
+ * @param {string} args.range - A `git log` range (e.g. `origin/master..HEAD`).
35
+ * @returns {Promise<{status: "clean"|"finding"|"scanner-absent", findings?: Array<{file: string, line: number, rule: string}>}>}
36
+ */
37
+ export async function scanPushWindow({ runtime, wikiDir, range }) {
38
+ const probe = await runtime.subprocess.run(GITLEAKS, ["version"], {
39
+ cwd: wikiDir,
40
+ });
41
+ if (probe.exitCode !== 0) return { status: "scanner-absent" };
42
+
43
+ const scan = await runtime.subprocess.run(
44
+ GITLEAKS,
45
+ [
46
+ "detect",
47
+ "--source",
48
+ wikiDir,
49
+ "--log-opts",
50
+ range,
51
+ "--report-format",
52
+ "json",
53
+ "--report-path",
54
+ "-",
55
+ ],
56
+ { cwd: wikiDir },
57
+ );
58
+
59
+ if (scan.exitCode === 0) return { status: "clean" };
60
+ if (scan.exitCode === 1) {
61
+ return { status: "finding", findings: parseFindings(scan.stdout) };
62
+ }
63
+ // Any other non-zero is an invocation/usage error, not a leak verdict:
64
+ // fail closed rather than risk reporting a broken scan as clean.
65
+ return { status: "scanner-absent" };
66
+ }
67
+
68
+ /**
69
+ * Parse a gitleaks JSON report into location-only findings. Reads only the
70
+ * file, line, and rule of each entry — never the matched secret value — so a
71
+ * record built from the result is secret-free by construction. A malformed or
72
+ * empty report yields an empty list.
73
+ *
74
+ * @param {string} stdout - The gitleaks JSON report.
75
+ * @returns {Array<{file: string, line: number, rule: string}>}
76
+ */
77
+ function parseFindings(stdout) {
78
+ let report;
79
+ try {
80
+ report = JSON.parse(stdout);
81
+ } catch {
82
+ return [];
83
+ }
84
+ if (!Array.isArray(report)) return [];
85
+ return report.map((entry) => ({
86
+ file: entry.File ?? "",
87
+ line: entry.StartLine ?? 0,
88
+ rule: entry.RuleID ?? "",
89
+ }));
90
+ }
91
+
92
+ /**
93
+ * Append one secret-free line to the wiki tree's `secret-overrides.log` and
94
+ * stage it (path-scoped) so it lands in the same push as the overridden
95
+ * content. The line records the override as a durable, inspectable audit
96
+ * trail: an ISO timestamp, the asserted operator identity (`git config
97
+ * user.email` — attribution of intent, NOT an authenticated identity), the
98
+ * override class, the reason, and for a finding its location. It never carries
99
+ * a matched secret value.
100
+ *
101
+ * @param {object} args
102
+ * @param {import('@forwardimpact/libutil/runtime').Runtime} args.runtime - Provides `fs` and `clock`.
103
+ * @param {import('@forwardimpact/libutil').GitClient} args.gitClient - Stages the log into the push.
104
+ * @param {string} args.wikiDir - The wiki clone directory.
105
+ * @param {"finding"|"scanner-absent"} args.klass - The override class.
106
+ * @param {string} args.reason - The operator-supplied reason for the override.
107
+ * @param {Array<{file: string, line: number, rule: string}>} [args.findings] - Locations for a finding override.
108
+ * @returns {Promise<{path: string}>} The relative path staged into the push.
109
+ */
110
+ export async function appendOverrideRecord({
111
+ runtime,
112
+ gitClient,
113
+ wikiDir,
114
+ klass,
115
+ reason,
116
+ findings = [],
117
+ }) {
118
+ const email =
119
+ (await gitClient.configGet("user.email", { cwd: wikiDir })) || "unknown";
120
+ const where =
121
+ klass === "finding"
122
+ ? findings.map((f) => `${f.file}:${f.line}:${f.rule}`).join(",") ||
123
+ "unspecified"
124
+ : "scanner-absent";
125
+ const ts = isoTimestamp(runtime.clock.now());
126
+ // Tab-separated, single line; the reason is collapsed so the record stays
127
+ // one inspectable row per override.
128
+ const line = `${ts}\t${email}\t${klass}\t${reason.replace(/\s+/g, " ").trim()}\t${where}\n`;
129
+ const logPath = `${wikiDir}/${OVERRIDE_LOG}`;
130
+ await runtime.fs.appendFile(logPath, line);
131
+ await gitClient.commitPaths(
132
+ `wiki: secret-gate override (${klass})`,
133
+ [OVERRIDE_LOG],
134
+ { cwd: wikiDir },
135
+ );
136
+ return { path: OVERRIDE_LOG };
137
+ }
138
+
139
+ /** The append-only audit log of break-glass overrides, in the wiki tree root. */
140
+ export const OVERRIDE_LOG = "secret-overrides.log";
141
+
142
+ /**
143
+ * Translate a `commitAndPush` security refusal into a command envelope,
144
+ * logging the cause and its break-glass procedure at error level (always
145
+ * surfaced, regardless of LOG_LEVEL). Returns `null` for any non-refusal
146
+ * result (clean / pushed / network "saved locally"), so a caller can fall
147
+ * through to its normal success handling. Shared by every command surface so
148
+ * the refusal message and exit code live in one place.
149
+ *
150
+ * @param {object} runtime - The runtime bag (the logger writes to `proc.stderr`).
151
+ * @param {{reason?: string, findings?: Array<{file: string, line: number, rule: string}>}} result - A `commitAndPush` result.
152
+ * @returns {{ok: false, code: 1}|null}
153
+ */
154
+ export function refusalEnvelope(runtime, result) {
155
+ if (result.reason === "secret-detected") {
156
+ const where = (result.findings ?? [])
157
+ .map((f) => `${f.file}:${f.line}:${f.rule}`)
158
+ .join(", ");
159
+ createLogger("wiki", runtime).error(
160
+ "push",
161
+ `push blocked: secret detected in wiki content${where ? ` (${where})` : ""}; ` +
162
+ "the push was not attempted. After confirming a false positive, set " +
163
+ "FIT_WIKI_SECRET_OVERRIDE to a reason to override (audited).",
164
+ );
165
+ return { ok: false, code: 1 };
166
+ }
167
+ if (result.reason === "scanner-unavailable") {
168
+ createLogger("wiki", runtime).error(
169
+ "push",
170
+ "push blocked: the secret scanner (gitleaks) is unavailable; the push " +
171
+ "was not attempted. Install gitleaks, or set FIT_WIKI_SCANNER_ABSENT_OK " +
172
+ "to a reason to override (audited).",
173
+ );
174
+ return { ok: false, code: 1 };
175
+ }
176
+ return null;
177
+ }
package/src/status.js CHANGED
@@ -1,19 +1,46 @@
1
- // STATUS.md row ids may carry a `/<unit>` suffix denoting a
2
- // per-migration-unit sub-row of a master spec (`1370/libutil`, …). The master
3
- // `NNNN` row advances only when every sub-row reads `plan implemented`.
1
+ // STATUS.md rows come in two kinds. A spec row's id is four digits with an
2
+ // optional `/<unit>` suffix denoting a per-migration-unit sub-row of a master
3
+ // spec (`1370/libutil`, …); the master `NNNN` row advances only when every
4
+ // sub-row reads `plan implemented`. An experiment row's id is `exp:<issue>`
5
+ // and the row carries four tab cells — `exp:<issue><TAB><state><TAB><pin>
6
+ // <TAB><plan-ref>` — keying the merge-gate approval path for a spec-less
7
+ // experiment PR. The `exp:` namespace cannot match the spec id's `^\d{4}`
8
+ // anchor, so the two kinds never collide for any issue-number width.
4
9
 
5
- /** Matches a status-row id: four digits, optionally a `/<unit>` suffix. */
6
- export const STATUS_ID_REGEX = /^\d{4}(\/[a-z0-9-]+)?$/;
10
+ /** Matches a status-row id: a four-digit spec id (optional `/<unit>`) or `exp:<issue>`. */
11
+ export const STATUS_ID_REGEX = /^(\d{4}(\/[a-z0-9-]+)?|exp:\d+)$/;
7
12
 
8
13
  /**
9
- * Parse a status-row id into its master spec id and optional unit suffix.
10
- * @param {string} id - The id field of a STATUS.md row.
11
- * @returns {{ specId: string, unit: string|null }|null} Parsed parts, or null
12
- * when the id does not match {@link STATUS_ID_REGEX}.
14
+ * Classify a status-row id into its kind and parts. Experiment rows are
15
+ * identified by an `exp:` id together with a four-cell row; the optional
16
+ * `cells` array supplies that count (a bare `exp:` id without four cells is
17
+ * not a valid row and yields null).
18
+ * @param {string} id - The id field (cell 0) of a STATUS.md row.
19
+ * @param {string[]} [cells] - The full tab-separated cells of the row, when
20
+ * available. Required to classify an experiment row.
21
+ * @returns {(
22
+ * {kind: "spec", specId: string, unit: string|null} |
23
+ * {kind: "experiment", issue: string, state: string, pin: string, planRef: string} |
24
+ * null
25
+ * )} Parsed parts, or null when the id/row does not match a known kind.
13
26
  */
14
- export function parseStatusRowId(id) {
27
+ export function parseStatusRowId(id, cells) {
15
28
  if (typeof id !== "string" || !STATUS_ID_REGEX.test(id)) return null;
29
+ if (id.startsWith("exp:")) {
30
+ if (!Array.isArray(cells) || cells.length !== 4) return null;
31
+ return {
32
+ kind: "experiment",
33
+ issue: id.slice("exp:".length),
34
+ state: cells[1],
35
+ pin: cells[2],
36
+ planRef: cells[3],
37
+ };
38
+ }
16
39
  const slash = id.indexOf("/");
17
- if (slash === -1) return { specId: id, unit: null };
18
- return { specId: id.slice(0, slash), unit: id.slice(slash + 1) };
40
+ if (slash === -1) return { kind: "spec", specId: id, unit: null };
41
+ return {
42
+ kind: "spec",
43
+ specId: id.slice(0, slash),
44
+ unit: id.slice(slash + 1),
45
+ };
19
46
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Resolve the required agent flag from frozen CLI options. Pure — reads no
3
+ * filesystem and no environment, so it runs before any state change. Returns
4
+ * `{ ok: true, agent }` when the flag is present, or
5
+ * `{ ok: false, code: 2, error }` when it is missing, where `error` names the
6
+ * missing flag and shows a corrected example invocation. The error never
7
+ * mentions an environment variable: `libwiki` carries no ambient agent
8
+ * identity, so there is no fallback to offer.
9
+ *
10
+ * @param {Record<string, unknown>} options - The frozen `ctx.options`.
11
+ * @param {{ command: string, flag?: string, example: string }} spec
12
+ * `command` names the failing subcommand; `flag` is the option key prefix
13
+ * (`--agent` by default, `--from` for `memo`); `example` is a correct
14
+ * invocation shown verbatim in the error.
15
+ * @returns {{ ok: true, agent: string } | { ok: false, code: 2, error: string }}
16
+ */
17
+ export function requireAgentFlag(
18
+ options,
19
+ { command, flag = "--agent", example },
20
+ ) {
21
+ const key = flag === "--from" ? "from" : "agent";
22
+ const agent = options[key];
23
+ if (!agent) {
24
+ return {
25
+ ok: false,
26
+ code: 2,
27
+ error: `${command} requires ${flag} <name>; e.g. ${example}`,
28
+ };
29
+ }
30
+ return { ok: true, agent };
31
+ }
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,