@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 +5 -2
- package/bin/fit-wiki.js +11 -1
- package/package.json +1 -1
- package/src/audit/rule-builders.js +247 -0
- package/src/audit/rules.js +89 -196
- package/src/audit/scopes.js +34 -2
- package/src/budget-gate.js +193 -0
- package/src/cli-definition.js +53 -3
- package/src/commands/claim.js +3 -1
- package/src/commands/ledger.js +208 -0
- package/src/commands/refresh.js +32 -2
- package/src/commands/sync.js +6 -1
- package/src/constants.js +18 -5
- package/src/ledger/anchor.js +96 -0
- package/src/ledger/projection.js +242 -0
- package/src/ledger/reader.js +45 -0
- package/src/wiki-sync.js +167 -32
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
|
@@ -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;
|
package/src/audit/rules.js
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 --
|