@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.
- package/LICENSE +21 -201
- package/README.md +5 -3
- package/bin/fit-wiki.js +1 -1
- package/package.json +1 -1
- package/src/audit/admission.js +53 -0
- package/src/audit/conflict-markers-rule.js +24 -0
- package/src/audit/grammar.js +97 -0
- package/src/audit/rules.js +96 -5
- package/src/audit/scopes.js +121 -5
- package/src/audit/status-row.js +91 -6
- package/src/block-renderer.js +19 -5
- package/src/boot.js +39 -1
- package/src/cli-definition.js +17 -16
- package/src/commands/audit.js +6 -1
- package/src/commands/boot.js +7 -2
- package/src/commands/claim.js +138 -32
- package/src/commands/fix.js +13 -5
- package/src/commands/inbox.js +7 -8
- package/src/commands/init.js +6 -0
- package/src/commands/log.js +47 -25
- package/src/commands/memo.js +10 -9
- package/src/commands/refresh.js +1 -0
- package/src/commands/rotate.js +40 -19
- package/src/commands/sync.js +54 -6
- package/src/conflict-markers.js +78 -0
- package/src/constants.js +31 -1
- package/src/gitattributes.js +40 -0
- package/src/index.js +2 -1
- package/src/integrity.js +288 -0
- package/src/lane-files.js +62 -0
- package/src/marker-scanner.js +2 -0
- package/src/secret-gate.js +177 -0
- package/src/status.js +39 -12
- package/src/util/agent-flag.js +31 -0
- package/src/weekly-log.js +146 -37
- package/src/wiki-sync.js +528 -11
|
@@ -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
|
|
2
|
-
// per-migration-unit sub-row of a master
|
|
3
|
-
// `NNNN` row advances only when every
|
|
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
|
|
6
|
-
export const STATUS_ID_REGEX =
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
|
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 (
|
|
172
|
-
return finish([body], prologueResidue(body,
|
|
208
|
+
if (sections.length === 0) {
|
|
209
|
+
return finish([body], prologueResidue(body, budget));
|
|
173
210
|
}
|
|
174
211
|
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
*
|
|
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} [
|
|
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
|
-
|
|
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))
|
|
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 {
|
|
375
|
+
return {
|
|
376
|
+
status: "noop",
|
|
377
|
+
reason: "floor",
|
|
378
|
+
lines,
|
|
379
|
+
words,
|
|
380
|
+
fromPath: filePath,
|
|
381
|
+
};
|
|
290
382
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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(
|
|
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,
|