@forwardimpact/libwiki 0.2.28 → 0.2.29

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.28",
3
+ "version": "0.2.29",
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,
@@ -15,208 +14,45 @@ import {
15
14
  SUMMARY_LINE_BUDGET,
16
15
  SUMMARY_WORD_BUDGET,
17
16
  WEEKLY_LOG_LINE_BUDGET,
18
- WEEKLY_LOG_SEAM_RE,
19
17
  WEEKLY_LOG_WORD_BUDGET,
20
18
  XMR_CLOSE_RE,
21
19
  XMR_OPEN_RE,
22
20
  } from "../constants.js";
23
- import {
24
- PRIORITY_HEADER_RE,
25
- SUMMARY_H1_RE,
26
- WEEKLY_LOG_H1_RE,
27
- } from "./scopes.js";
28
21
  import { CONFLICT_MARKER_RULE } from "./conflict-markers-rule.js";
22
+ import { PRIORITY_HEADER_RE, WEEKLY_LOG_H1_RE } from "./scopes.js";
23
+ import {
24
+ AGENT_H3_REQUIREMENTS,
25
+ allRequiredLines,
26
+ carryAgentMismatch,
27
+ carryEntryHasClearance,
28
+ columnCount,
29
+ containsLine,
30
+ decisionWithin5,
31
+ duplicateCsvRows,
32
+ exists,
33
+ expired,
34
+ fieldMatches,
35
+ firstH2Is,
36
+ firstLineMatches,
37
+ headingGrammarDrift,
38
+ ISO_DATE_RE,
39
+ lineBudget,
40
+ markersBalanced,
41
+ matches,
42
+ memoryExists,
43
+ memoryHasClaimsHeading,
44
+ memoryHasClaimsHeader,
45
+ memoryHasPriorityHeader,
46
+ nothingAfterH2,
47
+ PRIORITY_INDEX_HEADING_RE,
48
+ PRIORITY_SEPARATOR_RE,
49
+ storyboardExists,
50
+ summaryAgentMismatch,
51
+ weeklyAgentMismatch,
52
+ wordBudget,
53
+ } from "./rule-builders.js";
29
54
  import { STATUS_ROW_RULES } from "./status-row.js";
30
55
 
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;
219
-
220
56
  export const RULES = [
221
57
  // -- Summary files --
222
58
 
@@ -390,6 +226,30 @@ export const RULES = [
390
226
  hint: "rename either the H1 or the file so they agree",
391
227
  },
392
228
 
229
+ // -- Carry surfaces --
230
+ // No `h1-shape` rule: unlike the weekly logs (classified on filename alone),
231
+ // the carry classifier (scopes.js) requires the Carry H1 before assigning the
232
+ // scope, so a malformed-H1 file is left unclassified rather than reaching an
233
+ // h1-shape rule. The two rules below are the reachable, failable set (SC #2).
234
+
235
+ {
236
+ id: "carry-surface.h1-agent-matches-filename",
237
+ scope: "carry-surface",
238
+ severity: "fail",
239
+ check: carryAgentMismatch,
240
+ message: (s, r) =>
241
+ `H1 title slug '${r.titleSlug}' does not match filename prefix '${s.agentPrefix}'`,
242
+ hint: "rename either the H1 ('# <agent> — Carries') or the file so they agree",
243
+ },
244
+ {
245
+ id: "carry-surface.entry-has-clearance",
246
+ scope: "carry-surface",
247
+ severity: "fail",
248
+ check: carryEntryHasClearance,
249
+ message: () => "Carry entry lacks a '**Carry-clearance:**' trigger line",
250
+ hint: "every '### ' Carry entry must name its clearance trigger with a '**Carry-clearance:**' line so the surface stays enumerable at boot",
251
+ },
252
+
393
253
  // -- MEMORY.md --
394
254
 
395
255
  {
@@ -4,6 +4,8 @@ import { parseClaims } from "../active-claims.js";
4
4
  import { countLines, countWords } from "../budget.js";
5
5
  import { parseStatusRowId } from "../status.js";
6
6
  import {
7
+ CARRY_SURFACE_H1_RE,
8
+ CARRY_SURFACE_NAME_RE,
7
9
  PRIORITY_INDEX_HEADING,
8
10
  WEEKLY_LOG_NAME_RE,
9
11
  WEEKLY_LOG_PART_NAME_RE,
@@ -75,6 +77,11 @@ function loadFile(filePath, fs) {
75
77
  const base = path.basename(filePath);
76
78
  const weekMatch =
77
79
  base.match(WEEKLY_LOG_NAME_RE) || base.match(WEEKLY_LOG_PART_NAME_RE);
80
+ const carryMatch = base.match(CARRY_SURFACE_NAME_RE);
81
+ let agentPrefix;
82
+ if (weekMatch) agentPrefix = weekMatch[1];
83
+ else if (carryMatch) agentPrefix = carryMatch[1];
84
+ else agentPrefix = base.replace(/\.md$/, "");
78
85
  return {
79
86
  path: filePath,
80
87
  text,
@@ -83,7 +90,7 @@ function loadFile(filePath, fs) {
83
90
  h2s,
84
91
  lines: countLines(text),
85
92
  words: countWords(text),
86
- agentPrefix: weekMatch ? weekMatch[1] : base.replace(/\.md$/, ""),
93
+ agentPrefix,
87
94
  };
88
95
  }
89
96
 
@@ -101,6 +108,17 @@ function classifyFile(filePath, fs) {
101
108
  return { kind: "weekly-log-part", subject: loadFile(filePath, fs) };
102
109
  }
103
110
  const subject = loadFile(filePath, fs);
111
+ // Carry surface: a `<agent>-carries.md` whose H1 matches the Carry H1 RE.
112
+ // Both axes must match (filename prefix and H1), mirroring the summary
113
+ // classifier. The two H1 REs end in distinct literals (`— Carries` vs
114
+ // `— Summary`) so the branches cannot cross-capture regardless of order;
115
+ // a name-match + H1-miss is left unclassified, like a malformed summary.
116
+ if (CARRY_SURFACE_NAME_RE.test(base)) {
117
+ if (CARRY_SURFACE_H1_RE.test(subject.firstLine)) {
118
+ return { kind: "carry-surface", subject };
119
+ }
120
+ return null;
121
+ }
104
122
  // Files that do not match a summary or weekly-log shape are left
105
123
  // unclassified: stray files are not audited.
106
124
  if (!SUMMARY_H1_RE.test(subject.firstLine)) return null;
@@ -219,6 +237,7 @@ const SCOPE_RESOLVERS = {
219
237
  "weekly-log-main": (ctx) => ctx.subjects["weekly-log-main"],
220
238
  "weekly-log-part": (ctx) => ctx.subjects["weekly-log-part"],
221
239
  "metrics-csv": (ctx) => ctx.subjects["metrics-csv"],
240
+ "carry-surface": (ctx) => ctx.subjects["carry-surface"],
222
241
  memory: (ctx) => [ctx.memory],
223
242
  "claims-row": (ctx) =>
224
243
  parseClaims(ctx.memory.text).map((c) => ({ ...c, path: ctx.memory.path })),
@@ -322,6 +341,7 @@ export function buildContext({ wikiRoot, today, fs, subprocess }) {
322
341
  "weekly-log-main": [],
323
342
  "weekly-log-part": [],
324
343
  "metrics-csv": [],
344
+ "carry-surface": [],
325
345
  };
326
346
  for (const file of listMdFiles(wikiRoot, fs)) {
327
347
  const classified = classifyFile(file, fs);
package/src/constants.js CHANGED
@@ -80,6 +80,17 @@ export const WEEKLY_LOG_SEAM_RE = /^## (\d{4}-\d{2}-\d{2})/;
80
80
  // than this delimit sessions in the wiki history. 30 minutes.
81
81
  export const SESSION_GAP_MS = 30 * 60 * 1000;
82
82
 
83
+ // Carry-surface filename and H1 convention: `<agent>-carries.md` with an H1
84
+ // `# <agent> — Carries`. The name capture group is the agent prefix (used by
85
+ // the H1↔filename agreement rule). A Carry entry names its clearance trigger
86
+ // with the `**Carry-clearance:**` marker — the existing live convention in
87
+ // `wiki/release-engineer.md § Message Inbox`, preserved verbatim so the
88
+ // migration relocates without re-marking. One home so the audit's classifier
89
+ // (audit/scopes.js) and rules (audit/rules.js) cannot drift on the syntax.
90
+ export const CARRY_SURFACE_NAME_RE = /^(.+)-carries\.md$/;
91
+ export const CARRY_SURFACE_H1_RE = /^# (.+) — Carries$/;
92
+ export const CARRY_CLEARANCE_MARKER_RE = /\*\*Carry-clearance:\*\*/;
93
+
83
94
  // Storyboard marker syntax. An open or close marker tolerates optional trailing
84
95
  // text after the tag (typically an inline "Do not edit. Generated from fit-wiki
85
96
  // refresh." notice). One home so the marker scanner (marker-scanner.js) and the