@forwardimpact/libwiki 0.2.28 → 0.2.30

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/README.md CHANGED
@@ -61,7 +61,8 @@ npx fit-wiki release --expired
61
61
  ```
62
62
 
63
63
  Maintains the `## Active Claims` table in `MEMORY.md`. Duplicates refused;
64
- row absent means settled.
64
+ row absent means settled. `expires_at` defaults to `claimed_at + 1 day` — a
65
+ claim is a short-lived "shipping this now" assertion, not a lease.
65
66
 
66
67
  ### `inbox` — triage memos
67
68
 
@@ -119,7 +120,9 @@ npx fit-wiki refresh [storyboard-path]
119
120
 
120
121
  Re-renders `<!-- xmr:metric:csv-path -->` and `<!-- obstacles:open[:Nd] -->`
121
122
  marker blocks inside a storyboard from their backing CSV / GitHub state.
122
- Default path: `wiki/storyboard-YYYY-MMM.md` for the current month.
123
+ Default path: `wiki/storyboard-YYYY-MMM.md` for the current month. Also sweeps
124
+ every expired row from `MEMORY.md ## Active Claims` as part of the same
125
+ deterministic refresh.
123
126
 
124
127
  ### `init` / `push` / `pull` — wiki working tree
125
128
 
package/bin/fit-wiki.js CHANGED
@@ -4,6 +4,7 @@ import "@forwardimpact/libpreflight/node22";
4
4
 
5
5
  import { createDefaultRuntime } from "@forwardimpact/libutil/runtime";
6
6
  import { GitClient } from "@forwardimpact/libutil/git-client";
7
+ import { GhClient } from "@forwardimpact/libutil/gh-client";
7
8
  import { createScriptConfig } from "@forwardimpact/libconfig";
8
9
  import { createCli } from "@forwardimpact/libcli";
9
10
  import { createLogger } from "@forwardimpact/libtelemetry";
@@ -19,6 +20,10 @@ import { createDefinition } from "../src/cli-definition.js";
19
20
  // (and its config-backed token resolver); the rest run against the local tree.
20
21
  const NEEDS_WIKI_SYNC = new Set(["claim", "release", "push", "pull", "init"]);
21
22
 
23
+ // The ledger command reads and writes the allocation-anchor surface (the
24
+ // obstacle issue's comments) over the GitHub API, so it needs a GhClient.
25
+ const NEEDS_GH_CLIENT = new Set(["ledger"]);
26
+
22
27
  async function main() {
23
28
  const runtime = createDefaultRuntime();
24
29
  const definition = createDefinition();
@@ -72,8 +77,13 @@ async function main() {
72
77
  });
73
78
  }
74
79
 
80
+ let ghClient;
81
+ if (NEEDS_GH_CLIENT.has(command)) {
82
+ ghClient = new GhClient({ runtime });
83
+ }
84
+
75
85
  const result = await cli.dispatch(parsed, {
76
- deps: { runtime, wikiSync, gitClient },
86
+ deps: { runtime, wikiSync, gitClient, ghClient },
77
87
  });
78
88
 
79
89
  const envelope = result ?? { ok: true };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libwiki",
3
- "version": "0.2.28",
3
+ "version": "0.2.30",
4
4
  "description": "Wiki lifecycle primitives — stable memory for agent teams so coordination persists across sessions.",
5
5
  "keywords": [
6
6
  "wiki",
@@ -0,0 +1,247 @@
1
+ import {
2
+ ACTIVE_CLAIMS_HEADER_RE,
3
+ ACTIVE_CLAIMS_HEADING,
4
+ CARRY_CLEARANCE_MARKER_RE,
5
+ CARRY_SURFACE_H1_RE,
6
+ PRIORITY_INDEX_HEADING,
7
+ WEEKLY_LOG_SEAM_RE,
8
+ } from "../constants.js";
9
+ import {
10
+ PRIORITY_HEADER_RE,
11
+ SUMMARY_H1_RE,
12
+ WEEKLY_LOG_H1_RE,
13
+ } from "./scopes.js";
14
+
15
+ // Check builders and the derived matchers they share, extracted from rules.js
16
+ // so the rule table stays under the per-file line cap. Each builder takes a
17
+ // subject (plus optional ctx) and returns null | finding | finding[]. rules.js
18
+ // imports exactly the symbols its rule table references; the pure constants in
19
+ // constants.js/scopes.js it still imports straight from source.
20
+
21
+ export const PRIORITY_INDEX_HEADING_RE = new RegExp(
22
+ `^${PRIORITY_INDEX_HEADING}$`,
23
+ "m",
24
+ );
25
+ const ACTIVE_CLAIMS_HEADING_RE = new RegExp(`^${ACTIVE_CLAIMS_HEADING}$`, "m");
26
+ export const PRIORITY_SEPARATOR_RE =
27
+ /^\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|/m;
28
+ export const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
29
+
30
+ // improvement-coach is the storyboard facilitator and carries no domain
31
+ // metrics; only the five domain agents need their own H3.
32
+ const STORYBOARD_DOMAIN_AGENTS = [
33
+ "product-manager",
34
+ "release-engineer",
35
+ "security-engineer",
36
+ "staff-engineer",
37
+ "technical-writer",
38
+ ];
39
+
40
+ // -- Check builders: subject (+ ctx) → null | finding | finding[] --
41
+
42
+ export const matches = (pattern) => (s) => (pattern.test(s.text) ? null : {});
43
+ export const firstLineMatches = (pattern) => (s) =>
44
+ pattern.test(s.firstLine) ? null : {};
45
+ export const containsLine = (needle) => (s) =>
46
+ s.fileLines.some((l) => l.trim() === needle) ? null : {};
47
+
48
+ export const lineBudget = (limit) => (s) =>
49
+ s.lines > limit ? { value: s.lines } : null;
50
+ export const wordBudget = (limit) => (s) =>
51
+ s.words > limit ? { value: s.words } : null;
52
+
53
+ export const firstH2Is = (expected) => (s) =>
54
+ s.h2s.length === 0 || s.h2s[0] === expected ? null : { observed: s.h2s[0] };
55
+
56
+ export const nothingAfterH2 = (marker) => (s) => {
57
+ const idx = s.h2s.indexOf(marker);
58
+ if (idx === -1) return null;
59
+ const after = s.h2s.slice(idx + 1);
60
+ return after.length === 0 ? null : after.map((h) => ({ observed: h }));
61
+ };
62
+
63
+ export const fieldMatches = (name, pattern) => (s) =>
64
+ pattern.test(s[name]) ? null : { value: s[name] };
65
+
66
+ export const columnCount = (expected) => (s) =>
67
+ s.cells.length === expected ? null : { actual: s.cells.length, expected };
68
+
69
+ export const exists = (s) => (s.exists ? null : {});
70
+ export const expired = (s, ctx) => (s.expires_at < ctx.today ? {} : null);
71
+
72
+ // The heading must equal `requiredLine` exactly — a suffixed variant like
73
+ // "### Decision — <summary>" does not satisfy it, but is reported as a
74
+ // near miss so the writer fixes the heading instead of hunting for a
75
+ // "missing" line that is right there.
76
+ function entryHasDecision(lines, startIdx, requiredLine, stopRe) {
77
+ let seen = 0;
78
+ let nearMiss = null;
79
+ for (let j = startIdx + 1; j < lines.length && seen < 5; j++) {
80
+ const ln = lines[j].trim();
81
+ if (ln === "") continue;
82
+ seen++;
83
+ if (ln === requiredLine) return { found: true };
84
+ if (nearMiss === null && ln.startsWith(requiredLine)) nearMiss = ln;
85
+ if (stopRe.test(lines[j])) break;
86
+ }
87
+ return { found: false, nearMiss };
88
+ }
89
+
90
+ export const decisionWithin5 =
91
+ ({ entryRe, requiredLine, stopRe }) =>
92
+ (s) => {
93
+ const offenders = [];
94
+ for (let i = 0; i < s.fileLines.length; i++) {
95
+ if (!entryRe.test(s.fileLines[i])) continue;
96
+ const res = entryHasDecision(s.fileLines, i, requiredLine, stopRe);
97
+ if (!res.found) offenders.push({ lineNo: i + 1, nearMiss: res.nearMiss });
98
+ }
99
+ return offenders.length === 0 ? null : offenders;
100
+ };
101
+
102
+ // Flag entry-shaped `## ` headings that the rotation seam-finder would skip —
103
+ // the grammar-drift that degrades a whole file to one unsplittable prologue and
104
+ // is otherwise silent (the decision-block rule matches only dated headings).
105
+ // Uses the same WEEKLY_LOG_SEAM_RE the seam-finder uses, so the flagged set is
106
+ // exactly the complement of the rotatable set.
107
+ export const headingGrammarDrift = (s) => {
108
+ const offenders = [];
109
+ for (let i = 0; i < s.fileLines.length; i++) {
110
+ const line = s.fileLines[i];
111
+ if (/^## /.test(line) && !WEEKLY_LOG_SEAM_RE.test(line)) {
112
+ offenders.push({ lineNo: i + 1, observed: line.trim() });
113
+ }
114
+ }
115
+ return offenders.length === 0 ? null : offenders;
116
+ };
117
+
118
+ function scanMarkers(fileLines, openRe, closeRe, label) {
119
+ const openings = [];
120
+ const findings = [];
121
+ for (let i = 0; i < fileLines.length; i++) {
122
+ const openMatch = fileLines[i].match(openRe);
123
+ if (openMatch) {
124
+ openings.push({ label: openMatch[1] || label, lineNo: i + 1 });
125
+ } else if (closeRe.test(fileLines[i])) {
126
+ if (openings.length > 0) openings.pop();
127
+ else findings.push({ lineNo: i + 1, reason: "unpaired-close" });
128
+ }
129
+ }
130
+ for (const open of openings) {
131
+ findings.push({
132
+ lineNo: open.lineNo,
133
+ reason: "dangling-open",
134
+ label: open.label,
135
+ });
136
+ }
137
+ return findings;
138
+ }
139
+
140
+ export const markersBalanced =
141
+ ({ openRe, closeRe, label }) =>
142
+ (s) => {
143
+ const findings = scanMarkers(s.fileLines, openRe, closeRe, label);
144
+ return findings.length === 0 ? null : findings;
145
+ };
146
+
147
+ export const allRequiredLines = (required) => (s) => {
148
+ const findings = [];
149
+ for (const r of required) {
150
+ if (!s.fileLines.some((l) => r.pattern.test(l))) {
151
+ findings.push({ label: r.label });
152
+ }
153
+ }
154
+ return findings.length === 0 ? null : findings;
155
+ };
156
+
157
+ // -- H1 → filename agent prefix --
158
+
159
+ const slugify = (title) =>
160
+ title
161
+ .trim()
162
+ .toLowerCase()
163
+ .replace(/[^a-z0-9]+/g, "-")
164
+ .replace(/(^-|-$)/g, "");
165
+
166
+ export const summaryAgentMismatch = (s) => {
167
+ const titleSlug = slugify(s.firstLine.match(SUMMARY_H1_RE)[1]);
168
+ return titleSlug === s.agentPrefix ? null : { titleSlug };
169
+ };
170
+
171
+ export const weeklyAgentMismatch = (s) => {
172
+ const m = s.firstLine.match(WEEKLY_LOG_H1_RE);
173
+ if (!m) return null;
174
+ const titleSlug = slugify(m[1]);
175
+ return titleSlug === s.agentPrefix ? null : { titleSlug };
176
+ };
177
+
178
+ // Carry surface: the H1 agent slug (`# <agent> — Carries`) must agree with the
179
+ // filename prefix (`<agent>-carries.md`), the carry analogue of the summary /
180
+ // weekly-log agreement rules. The H1 is the slug form already, so slugify is a
181
+ // no-op for well-formed files; it normalises a stray capitalisation otherwise.
182
+ export const carryAgentMismatch = (s) => {
183
+ const m = s.firstLine.match(CARRY_SURFACE_H1_RE);
184
+ if (!m) return null;
185
+ const titleSlug = slugify(m[1]);
186
+ return titleSlug === s.agentPrefix ? null : { titleSlug };
187
+ };
188
+
189
+ // Each Carry entry is an H3 block; every block must name a clearance trigger
190
+ // (the `**Carry-clearance:**` marker). Walk the file's H3 boundaries and emit
191
+ // one finding per block missing the marker — the finding[]-returning shape
192
+ // `nothingAfterH2` uses.
193
+ export const carryEntryHasClearance = (s) => {
194
+ const offenders = [];
195
+ let blockStart = -1;
196
+ let hasMarker = false;
197
+ const close = () => {
198
+ if (blockStart !== -1 && !hasMarker) {
199
+ offenders.push({ lineNo: blockStart + 1 });
200
+ }
201
+ };
202
+ for (let i = 0; i < s.fileLines.length; i++) {
203
+ const line = s.fileLines[i];
204
+ if (/^### /.test(line)) {
205
+ close();
206
+ blockStart = i;
207
+ hasMarker = false;
208
+ } else if (blockStart !== -1 && CARRY_CLEARANCE_MARKER_RE.test(line)) {
209
+ hasMarker = true;
210
+ }
211
+ }
212
+ close();
213
+ return offenders.length === 0 ? null : offenders;
214
+ };
215
+
216
+ export const AGENT_H3_REQUIREMENTS = STORYBOARD_DOMAIN_AGENTS.map((agent) => ({
217
+ label: agent,
218
+ pattern: new RegExp(`^### ${agent}(\\s|$|—|-)`),
219
+ }));
220
+
221
+ // -- Metrics CSV duplicate rows --
222
+
223
+ // Report every data line byte-identical to an earlier data line in the same
224
+ // CSV. Line 1 is the header (positionally) and blank lines are skipped; the
225
+ // header is never a duplicate subject. Keying on exact line equality gives the
226
+ // spec's exit path for free: any column edit (run id or note) on one row makes
227
+ // the pair non-identical and stops the finding firing.
228
+ export const duplicateCsvRows = (s) => {
229
+ const seen = new Set();
230
+ const findings = [];
231
+ s.rows.forEach((text, i) => {
232
+ const lineNo = i + 1;
233
+ if (lineNo === 1 || text.trim() === "") return;
234
+ if (seen.has(text)) findings.push({ lineNo });
235
+ else seen.add(text);
236
+ });
237
+ return findings.length === 0 ? null : findings;
238
+ };
239
+
240
+ export const memoryExists = (s) => s.exists;
241
+ export const memoryHasPriorityHeader = (s) =>
242
+ s.exists && PRIORITY_HEADER_RE.test(s.text);
243
+ export const memoryHasClaimsHeading = (s) =>
244
+ s.exists && ACTIVE_CLAIMS_HEADING_RE.test(s.text);
245
+ export const memoryHasClaimsHeader = (s) =>
246
+ ACTIVE_CLAIMS_HEADER_RE.test(s.text);
247
+ export const storyboardExists = (s) => s.exists;
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  ACTIVE_CLAIMS_HEADER_RE,
3
- ACTIVE_CLAIMS_HEADING,
4
3
  ACTIVE_CLAIMS_SEPARATOR_RE,
5
4
  ACTIVE_CLAIMS_TABLE_HEADER,
6
5
  AGENT_EXPERIMENTS_CLOSE_RE,
@@ -9,213 +8,65 @@ import {
9
8
  ISSUE_CLOSE_RE,
10
9
  ISSUE_OPEN_RE,
11
10
  MEMO_INBOX_MARKER,
11
+ MEMORY_LINE_BUDGET,
12
+ MEMORY_WORD_BUDGET,
12
13
  PRIORITY_INDEX_HEADING,
13
14
  STORYBOARD_LINE_BUDGET,
14
15
  STORYBOARD_WORD_BUDGET,
15
16
  SUMMARY_LINE_BUDGET,
16
17
  SUMMARY_WORD_BUDGET,
17
18
  WEEKLY_LOG_LINE_BUDGET,
18
- WEEKLY_LOG_SEAM_RE,
19
19
  WEEKLY_LOG_WORD_BUDGET,
20
20
  XMR_CLOSE_RE,
21
21
  XMR_OPEN_RE,
22
22
  } from "../constants.js";
23
- import {
24
- PRIORITY_HEADER_RE,
25
- SUMMARY_H1_RE,
26
- WEEKLY_LOG_H1_RE,
27
- } from "./scopes.js";
28
23
  import { CONFLICT_MARKER_RULE } from "./conflict-markers-rule.js";
24
+ import { PRIORITY_HEADER_RE, WEEKLY_LOG_H1_RE } from "./scopes.js";
25
+ import {
26
+ AGENT_H3_REQUIREMENTS,
27
+ allRequiredLines,
28
+ carryAgentMismatch,
29
+ carryEntryHasClearance,
30
+ columnCount,
31
+ containsLine,
32
+ decisionWithin5,
33
+ duplicateCsvRows,
34
+ exists,
35
+ expired,
36
+ fieldMatches,
37
+ firstH2Is,
38
+ firstLineMatches,
39
+ headingGrammarDrift,
40
+ ISO_DATE_RE,
41
+ lineBudget,
42
+ markersBalanced,
43
+ matches,
44
+ memoryExists,
45
+ memoryHasClaimsHeading,
46
+ memoryHasClaimsHeader,
47
+ memoryHasPriorityHeader,
48
+ nothingAfterH2,
49
+ PRIORITY_INDEX_HEADING_RE,
50
+ PRIORITY_SEPARATOR_RE,
51
+ storyboardExists,
52
+ summaryAgentMismatch,
53
+ weeklyAgentMismatch,
54
+ wordBudget,
55
+ } from "./rule-builders.js";
29
56
  import { STATUS_ROW_RULES } from "./status-row.js";
30
57
 
31
- const PRIORITY_INDEX_HEADING_RE = new RegExp(
32
- `^${PRIORITY_INDEX_HEADING}$`,
33
- "m",
34
- );
35
- const ACTIVE_CLAIMS_HEADING_RE = new RegExp(`^${ACTIVE_CLAIMS_HEADING}$`, "m");
36
- const PRIORITY_SEPARATOR_RE =
37
- /^\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|/m;
38
- const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
39
-
40
- // improvement-coach is the storyboard facilitator and carries no domain
41
- // metrics; only the five domain agents need their own H3.
42
- const STORYBOARD_DOMAIN_AGENTS = [
43
- "product-manager",
44
- "release-engineer",
45
- "security-engineer",
46
- "staff-engineer",
47
- "technical-writer",
48
- ];
49
-
50
- // -- Check builders: subject (+ ctx) → null | finding | finding[] --
51
-
52
- const matches = (pattern) => (s) => (pattern.test(s.text) ? null : {});
53
- const firstLineMatches = (pattern) => (s) =>
54
- pattern.test(s.firstLine) ? null : {};
55
- const containsLine = (needle) => (s) =>
56
- s.fileLines.some((l) => l.trim() === needle) ? null : {};
57
-
58
- const lineBudget = (limit) => (s) =>
59
- s.lines > limit ? { value: s.lines } : null;
60
- const wordBudget = (limit) => (s) =>
61
- s.words > limit ? { value: s.words } : null;
62
-
63
- const firstH2Is = (expected) => (s) =>
64
- s.h2s.length === 0 || s.h2s[0] === expected ? null : { observed: s.h2s[0] };
65
-
66
- const nothingAfterH2 = (marker) => (s) => {
67
- const idx = s.h2s.indexOf(marker);
68
- if (idx === -1) return null;
69
- const after = s.h2s.slice(idx + 1);
70
- return after.length === 0 ? null : after.map((h) => ({ observed: h }));
71
- };
72
-
73
- const fieldMatches = (name, pattern) => (s) =>
74
- pattern.test(s[name]) ? null : { value: s[name] };
75
-
76
- const columnCount = (expected) => (s) =>
77
- s.cells.length === expected ? null : { actual: s.cells.length, expected };
78
-
79
- const exists = (s) => (s.exists ? null : {});
80
- const expired = (s, ctx) => (s.expires_at < ctx.today ? {} : null);
81
-
82
- // The heading must equal `requiredLine` exactly — a suffixed variant like
83
- // "### Decision — <summary>" does not satisfy it, but is reported as a
84
- // near miss so the writer fixes the heading instead of hunting for a
85
- // "missing" line that is right there.
86
- function entryHasDecision(lines, startIdx, requiredLine, stopRe) {
87
- let seen = 0;
88
- let nearMiss = null;
89
- for (let j = startIdx + 1; j < lines.length && seen < 5; j++) {
90
- const ln = lines[j].trim();
91
- if (ln === "") continue;
92
- seen++;
93
- if (ln === requiredLine) return { found: true };
94
- if (nearMiss === null && ln.startsWith(requiredLine)) nearMiss = ln;
95
- if (stopRe.test(lines[j])) break;
96
- }
97
- return { found: false, nearMiss };
98
- }
99
-
100
- const decisionWithin5 =
101
- ({ entryRe, requiredLine, stopRe }) =>
102
- (s) => {
103
- const offenders = [];
104
- for (let i = 0; i < s.fileLines.length; i++) {
105
- if (!entryRe.test(s.fileLines[i])) continue;
106
- const res = entryHasDecision(s.fileLines, i, requiredLine, stopRe);
107
- if (!res.found) offenders.push({ lineNo: i + 1, nearMiss: res.nearMiss });
108
- }
109
- return offenders.length === 0 ? null : offenders;
110
- };
111
-
112
- // Flag entry-shaped `## ` headings that the rotation seam-finder would skip —
113
- // the grammar-drift that degrades a whole file to one unsplittable prologue and
114
- // is otherwise silent (the decision-block rule matches only dated headings).
115
- // Uses the same WEEKLY_LOG_SEAM_RE the seam-finder uses, so the flagged set is
116
- // exactly the complement of the rotatable set.
117
- const headingGrammarDrift = (s) => {
118
- const offenders = [];
119
- for (let i = 0; i < s.fileLines.length; i++) {
120
- const line = s.fileLines[i];
121
- if (/^## /.test(line) && !WEEKLY_LOG_SEAM_RE.test(line)) {
122
- offenders.push({ lineNo: i + 1, observed: line.trim() });
123
- }
124
- }
125
- return offenders.length === 0 ? null : offenders;
126
- };
127
-
128
- function scanMarkers(fileLines, openRe, closeRe, label) {
129
- const openings = [];
130
- const findings = [];
131
- for (let i = 0; i < fileLines.length; i++) {
132
- const openMatch = fileLines[i].match(openRe);
133
- if (openMatch) {
134
- openings.push({ label: openMatch[1] || label, lineNo: i + 1 });
135
- } else if (closeRe.test(fileLines[i])) {
136
- if (openings.length > 0) openings.pop();
137
- else findings.push({ lineNo: i + 1, reason: "unpaired-close" });
138
- }
139
- }
140
- for (const open of openings) {
141
- findings.push({
142
- lineNo: open.lineNo,
143
- reason: "dangling-open",
144
- label: open.label,
145
- });
146
- }
147
- return findings;
148
- }
149
-
150
- const markersBalanced =
151
- ({ openRe, closeRe, label }) =>
152
- (s) => {
153
- const findings = scanMarkers(s.fileLines, openRe, closeRe, label);
154
- return findings.length === 0 ? null : findings;
155
- };
156
-
157
- const allRequiredLines = (required) => (s) => {
158
- const findings = [];
159
- for (const r of required) {
160
- if (!s.fileLines.some((l) => r.pattern.test(l))) {
161
- findings.push({ label: r.label });
162
- }
163
- }
164
- return findings.length === 0 ? null : findings;
165
- };
166
-
167
- // -- H1 → filename agent prefix --
168
-
169
- const slugify = (title) =>
170
- title
171
- .trim()
172
- .toLowerCase()
173
- .replace(/[^a-z0-9]+/g, "-")
174
- .replace(/(^-|-$)/g, "");
175
-
176
- const summaryAgentMismatch = (s) => {
177
- const titleSlug = slugify(s.firstLine.match(SUMMARY_H1_RE)[1]);
178
- return titleSlug === s.agentPrefix ? null : { titleSlug };
179
- };
180
-
181
- const weeklyAgentMismatch = (s) => {
182
- const m = s.firstLine.match(WEEKLY_LOG_H1_RE);
183
- if (!m) return null;
184
- const titleSlug = slugify(m[1]);
185
- return titleSlug === s.agentPrefix ? null : { titleSlug };
186
- };
187
-
188
- const AGENT_H3_REQUIREMENTS = STORYBOARD_DOMAIN_AGENTS.map((agent) => ({
189
- label: agent,
190
- pattern: new RegExp(`^### ${agent}(\\s|$|—|-)`),
191
- }));
192
-
193
- // -- Metrics CSV duplicate rows --
194
-
195
- // Report every data line byte-identical to an earlier data line in the same
196
- // CSV. Line 1 is the header (positionally) and blank lines are skipped; the
197
- // header is never a duplicate subject. Keying on exact line equality gives the
198
- // spec's exit path for free: any column edit (run id or note) on one row makes
199
- // the pair non-identical and stops the finding firing.
200
- const duplicateCsvRows = (s) => {
201
- const seen = new Set();
202
- const findings = [];
203
- s.rows.forEach((text, i) => {
204
- const lineNo = i + 1;
205
- if (lineNo === 1 || text.trim() === "") return;
206
- if (seen.has(text)) findings.push({ lineNo });
207
- else seen.add(text);
208
- });
209
- return findings.length === 0 ? null : findings;
210
- };
211
-
212
- const memoryExists = (s) => s.exists;
213
- const memoryHasPriorityHeader = (s) =>
214
- s.exists && PRIORITY_HEADER_RE.test(s.text);
215
- const memoryHasClaimsHeading = (s) =>
216
- s.exists && ACTIVE_CLAIMS_HEADING_RE.test(s.text);
217
- const memoryHasClaimsHeader = (s) => ACTIVE_CLAIMS_HEADER_RE.test(s.text);
218
- const storyboardExists = (s) => s.exists;
58
+ // The budget predicates the post-landing pre-push gate re-runs over the
59
+ // outgoing tree. Naming the ids here keeps the gate selecting rules by
60
+ // membership of this set, so a future predicate change to any of these
61
+ // rules flows through the gate with no gate-code change.
62
+ export const BUDGET_RULE_IDS = new Set([
63
+ "summary.line-budget",
64
+ "summary.word-budget",
65
+ "weekly-log.line-budget",
66
+ "weekly-log.word-budget",
67
+ "weekly-log-part.line-budget",
68
+ "weekly-log-part.word-budget",
69
+ ]);
219
70
 
220
71
  export const RULES = [
221
72
  // -- Summary files --
@@ -390,6 +241,30 @@ export const RULES = [
390
241
  hint: "rename either the H1 or the file so they agree",
391
242
  },
392
243
 
244
+ // -- Carry surfaces --
245
+ // No `h1-shape` rule: unlike the weekly logs (classified on filename alone),
246
+ // the carry classifier (scopes.js) requires the Carry H1 before assigning the
247
+ // scope, so a malformed-H1 file is left unclassified rather than reaching an
248
+ // h1-shape rule. The two rules below are the reachable, failable set (SC #2).
249
+
250
+ {
251
+ id: "carry-surface.h1-agent-matches-filename",
252
+ scope: "carry-surface",
253
+ severity: "fail",
254
+ check: carryAgentMismatch,
255
+ message: (s, r) =>
256
+ `H1 title slug '${r.titleSlug}' does not match filename prefix '${s.agentPrefix}'`,
257
+ hint: "rename either the H1 ('# <agent> — Carries') or the file so they agree",
258
+ },
259
+ {
260
+ id: "carry-surface.entry-has-clearance",
261
+ scope: "carry-surface",
262
+ severity: "fail",
263
+ check: carryEntryHasClearance,
264
+ message: () => "Carry entry lacks a '**Carry-clearance:**' trigger line",
265
+ hint: "every '### ' Carry entry must name its clearance trigger with a '**Carry-clearance:**' line so the surface stays enumerable at boot",
266
+ },
267
+
393
268
  // -- MEMORY.md --
394
269
 
395
270
  {
@@ -400,6 +275,24 @@ export const RULES = [
400
275
  message: () => "MEMORY.md not found",
401
276
  hint: "run `bunx fit-wiki init` to scaffold the canonical sections",
402
277
  },
278
+ {
279
+ id: "memory.line-budget",
280
+ scope: "memory",
281
+ severity: "fail",
282
+ when: memoryExists,
283
+ check: lineBudget(MEMORY_LINE_BUDGET),
284
+ message: (_s, r) => `${r.value} lines (limit ${MEMORY_LINE_BUDGET})`,
285
+ hint: "MEMORY.md holds settled cross-cutting state, not history; release settled claims, prune stale priority rows, and move event-by-event detail to the relevant ledger page or weekly log",
286
+ },
287
+ {
288
+ id: "memory.word-budget",
289
+ scope: "memory",
290
+ severity: "fail",
291
+ when: memoryExists,
292
+ check: wordBudget(MEMORY_WORD_BUDGET),
293
+ message: (_s, r) => `${r.value} words (limit ${MEMORY_WORD_BUDGET})`,
294
+ hint: "MEMORY.md holds settled cross-cutting state, not history; release settled claims, prune stale priority rows, and move event-by-event detail to the relevant ledger page or weekly log",
295
+ },
403
296
  {
404
297
  id: "memory.priority-heading",
405
298
  scope: "memory",
@@ -478,7 +371,7 @@ export const RULES = [
478
371
  severity: "warn",
479
372
  check: expired,
480
373
  message: (s) => `${s.agent}/${s.target} expired ${s.expires_at}`,
481
- hint: "run `bunx fit-wiki release --expired` to clear expired claims",
374
+ hint: "run `bunx fit-wiki refresh` (or `release --expired`) to clear expired claims",
482
375
  },
483
376
 
484
377
  // -- Storyboards --