@forwardimpact/libwiki 0.3.0 → 0.3.2
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 +53 -33
- package/package.json +1 -1
- package/src/active-claims.js +5 -5
- package/src/agent-roster.js +2 -2
- package/src/audit/admission.js +14 -11
- package/src/audit/conflict-markers-rule.js +9 -9
- package/src/audit/grammar.js +21 -18
- package/src/audit/rule-builders.js +20 -19
- package/src/audit/rules.js +30 -27
- package/src/audit/scopes.js +34 -33
- package/src/audit/status-row.js +14 -15
- package/src/block-renderer.js +5 -4
- package/src/boot.js +10 -8
- package/src/budget-gate.js +39 -36
- package/src/budget.js +3 -3
- package/src/cli-definition.js +23 -20
- package/src/commands/audit.js +3 -3
- package/src/commands/boot.js +1 -1
- package/src/commands/claim.js +44 -38
- package/src/commands/curate.js +34 -31
- package/src/commands/fix.js +68 -64
- package/src/commands/inbox.js +1 -1
- package/src/commands/init.js +12 -7
- package/src/commands/ledger.js +11 -11
- package/src/commands/log.js +19 -17
- package/src/commands/memo.js +4 -1
- package/src/commands/product-mix.js +16 -15
- package/src/commands/refresh.js +25 -22
- package/src/commands/rotate.js +9 -8
- package/src/commands/sync.js +26 -17
- package/src/conflict-markers.js +21 -21
- package/src/constants.js +37 -33
- package/src/gitattributes.js +10 -9
- package/src/integrity.js +29 -27
- package/src/issue-list-renderer.js +24 -16
- package/src/lane-files.js +11 -10
- package/src/ledger/anchor.js +6 -6
- package/src/ledger/projection.js +35 -32
- package/src/ledger/reader.js +4 -4
- package/src/marker-scanner.js +3 -2
- package/src/sanitize.js +12 -11
- package/src/secret-gate.js +41 -40
- package/src/status.js +12 -11
- package/src/storyboard-skeleton.js +20 -18
- package/src/util/agent-flag.js +8 -8
- package/src/util/clock.js +1 -1
- package/src/util/wiki-dir.js +7 -7
- package/src/weekly-log.js +115 -101
- package/src/wiki-sync.js +393 -361
package/src/audit/rules.js
CHANGED
|
@@ -56,9 +56,9 @@ import {
|
|
|
56
56
|
import { STATUS_ROW_RULES } from "./status-row.js";
|
|
57
57
|
|
|
58
58
|
// The budget predicates the post-landing pre-push gate re-runs over the
|
|
59
|
-
// outgoing tree.
|
|
60
|
-
// membership of
|
|
61
|
-
// rules flows through the gate with no gate-code change.
|
|
59
|
+
// outgoing tree. This set names the ids, so the gate selects rules by
|
|
60
|
+
// membership of the set. A future predicate change to any of these
|
|
61
|
+
// rules then flows through the gate with no gate-code change.
|
|
62
62
|
export const BUDGET_RULE_IDS = new Set([
|
|
63
63
|
"summary.line-budget",
|
|
64
64
|
"summary.word-budget",
|
|
@@ -110,7 +110,7 @@ export const RULES = [
|
|
|
110
110
|
severity: "fail",
|
|
111
111
|
check: lineBudget(SUMMARY_LINE_BUDGET),
|
|
112
112
|
message: (_s, r) => `${r.value} lines (limit ${SUMMARY_LINE_BUDGET})`,
|
|
113
|
-
hint: "trim history into the weekly log
|
|
113
|
+
hint: "trim history into the weekly log. the summary holds settled state. it does not hold history",
|
|
114
114
|
},
|
|
115
115
|
{
|
|
116
116
|
id: "summary.word-budget",
|
|
@@ -118,7 +118,7 @@ export const RULES = [
|
|
|
118
118
|
severity: "fail",
|
|
119
119
|
check: wordBudget(SUMMARY_WORD_BUDGET),
|
|
120
120
|
message: (_s, r) => `${r.value} words (limit ${SUMMARY_WORD_BUDGET})`,
|
|
121
|
-
hint: "trim history into the weekly log
|
|
121
|
+
hint: "trim history into the weekly log. the summary holds settled state. it does not hold history",
|
|
122
122
|
},
|
|
123
123
|
{
|
|
124
124
|
id: "summary.h1-agent-matches-filename",
|
|
@@ -176,7 +176,7 @@ export const RULES = [
|
|
|
176
176
|
check: headingGrammarDrift,
|
|
177
177
|
message: (_s, r) =>
|
|
178
178
|
`Entry heading '${r.observed}' does not match the dated grammar`,
|
|
179
|
-
hint: "weekly-log entry headings must be '## YYYY-MM-DD'
|
|
179
|
+
hint: "weekly-log entry headings must be '## YYYY-MM-DD'. open entries with `gemba-wiki log decision/note`, which emit a heading that conforms and that the rotation seam-finder can split",
|
|
180
180
|
},
|
|
181
181
|
{
|
|
182
182
|
id: "decision-block.heading-within-5",
|
|
@@ -189,9 +189,9 @@ export const RULES = [
|
|
|
189
189
|
}),
|
|
190
190
|
message: (_s, r) =>
|
|
191
191
|
r.nearMiss
|
|
192
|
-
? `Entry opens with '${r.nearMiss}'
|
|
192
|
+
? `Entry opens with '${r.nearMiss}'. The heading must be exactly '${DECISION_HEADING}'. Move the suffix into the body`
|
|
193
193
|
: `Entry lacks a line that is exactly '${DECISION_HEADING}'`,
|
|
194
|
-
hint: `open each '## YYYY-MM-DD' entry with \`gemba-wiki log decision
|
|
194
|
+
hint: `open each '## YYYY-MM-DD' entry with \`gemba-wiki log decision\`. it emits a line that contains exactly '${DECISION_HEADING}' (no suffix, because the check is an exact match). put the one-line summary in the body below it, drawn from the entry's own narrative. do not invent rationale the entry does not support`,
|
|
195
195
|
},
|
|
196
196
|
|
|
197
197
|
// -- Weekly logs (sealed parts) --
|
|
@@ -211,7 +211,7 @@ export const RULES = [
|
|
|
211
211
|
check: headingGrammarDrift,
|
|
212
212
|
message: (_s, r) =>
|
|
213
213
|
`Entry heading '${r.observed}' does not match the dated grammar`,
|
|
214
|
-
hint: "weekly-log entry headings must be '## YYYY-MM-DD'
|
|
214
|
+
hint: "weekly-log entry headings must be '## YYYY-MM-DD'. open entries with `gemba-wiki log decision/note`, which emit a heading that conforms and that the rotation seam-finder can split",
|
|
215
215
|
},
|
|
216
216
|
{
|
|
217
217
|
id: "weekly-log-part.line-budget",
|
|
@@ -220,7 +220,7 @@ export const RULES = [
|
|
|
220
220
|
remediation: "rotate",
|
|
221
221
|
check: lineBudget(WEEKLY_LOG_LINE_BUDGET),
|
|
222
222
|
message: (_s, r) => `${r.value} lines (limit ${WEEKLY_LOG_LINE_BUDGET})`,
|
|
223
|
-
hint: "`bunx gemba-wiki fix` re-bisects an over-budget part at its day-section seams and, for a lone over-cap day, at its '### ' block seams
|
|
223
|
+
hint: "`bunx gemba-wiki fix` re-bisects an over-budget part at its day-section seams and, for a lone over-cap day, at its '### ' block seams. only a single '### ' block that alone exceeds the budget remains for a human to shorten",
|
|
224
224
|
},
|
|
225
225
|
{
|
|
226
226
|
id: "weekly-log-part.word-budget",
|
|
@@ -229,7 +229,7 @@ export const RULES = [
|
|
|
229
229
|
remediation: "rotate",
|
|
230
230
|
check: wordBudget(WEEKLY_LOG_WORD_BUDGET),
|
|
231
231
|
message: (_s, r) => `${r.value} words (limit ${WEEKLY_LOG_WORD_BUDGET})`,
|
|
232
|
-
hint: "`bunx gemba-wiki fix` re-bisects an over-budget part at its day-section seams and, for a lone over-cap day, at its '### ' block seams
|
|
232
|
+
hint: "`bunx gemba-wiki fix` re-bisects an over-budget part at its day-section seams and, for a lone over-cap day, at its '### ' block seams. only a single '### ' block that alone exceeds the budget remains for a human to shorten",
|
|
233
233
|
},
|
|
234
234
|
{
|
|
235
235
|
id: "weekly-log-part.h1-agent-matches-filename",
|
|
@@ -242,10 +242,11 @@ export const RULES = [
|
|
|
242
242
|
},
|
|
243
243
|
|
|
244
244
|
// -- Carry surfaces --
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
// h1-shape rule. The two rules
|
|
245
|
+
// There is no `h1-shape` rule. The classifier classifies a weekly log on
|
|
246
|
+
// filename alone. The carry classifier (scopes.js) instead requires the
|
|
247
|
+
// Carry H1 before it assigns the scope. So it leaves a malformed-H1 file
|
|
248
|
+
// unclassified, and that file never reaches an h1-shape rule. The two rules
|
|
249
|
+
// below are the reachable, failable set (SC #2).
|
|
249
250
|
|
|
250
251
|
{
|
|
251
252
|
id: "carry-surface.h1-agent-matches-filename",
|
|
@@ -282,7 +283,7 @@ export const RULES = [
|
|
|
282
283
|
when: memoryExists,
|
|
283
284
|
check: lineBudget(MEMORY_LINE_BUDGET),
|
|
284
285
|
message: (_s, r) => `${r.value} lines (limit ${MEMORY_LINE_BUDGET})`,
|
|
285
|
-
hint: "MEMORY.md holds settled cross-cutting state
|
|
286
|
+
hint: "MEMORY.md holds settled cross-cutting state. it does not hold history. release settled claims, prune stale priority rows, and move event-by-event detail to the relevant ledger page or weekly log",
|
|
286
287
|
},
|
|
287
288
|
{
|
|
288
289
|
id: "memory.word-budget",
|
|
@@ -291,7 +292,7 @@ export const RULES = [
|
|
|
291
292
|
when: memoryExists,
|
|
292
293
|
check: wordBudget(MEMORY_WORD_BUDGET),
|
|
293
294
|
message: (_s, r) => `${r.value} words (limit ${MEMORY_WORD_BUDGET})`,
|
|
294
|
-
hint: "MEMORY.md holds settled cross-cutting state
|
|
295
|
+
hint: "MEMORY.md holds settled cross-cutting state. it does not hold history. release settled claims, prune stale priority rows, and move event-by-event detail to the relevant ledger page or weekly log",
|
|
295
296
|
},
|
|
296
297
|
{
|
|
297
298
|
id: "memory.priority-heading",
|
|
@@ -400,7 +401,7 @@ export const RULES = [
|
|
|
400
401
|
when: storyboardExists,
|
|
401
402
|
check: lineBudget(STORYBOARD_LINE_BUDGET),
|
|
402
403
|
message: (_s, r) => `${r.value} lines (limit ${STORYBOARD_LINE_BUDGET})`,
|
|
403
|
-
hint: "see per-section word budgets in storyboard-template.md
|
|
404
|
+
hint: "see per-section word budgets in storyboard-template.md. retire prior-session Headlines/Notes/Next-review entries to weekly logs",
|
|
404
405
|
},
|
|
405
406
|
{
|
|
406
407
|
id: "storyboard.word-budget",
|
|
@@ -409,7 +410,7 @@ export const RULES = [
|
|
|
409
410
|
when: storyboardExists,
|
|
410
411
|
check: wordBudget(STORYBOARD_WORD_BUDGET),
|
|
411
412
|
message: (_s, r) => `${r.value} words (limit ${STORYBOARD_WORD_BUDGET})`,
|
|
412
|
-
hint: "see per-section word budgets in storyboard-template.md
|
|
413
|
+
hint: "see per-section word budgets in storyboard-template.md. retire prior-session Headlines/Notes/Next-review entries to weekly logs",
|
|
413
414
|
},
|
|
414
415
|
{
|
|
415
416
|
id: "storyboard.markers-balanced.xmr",
|
|
@@ -454,8 +455,9 @@ export const RULES = [
|
|
|
454
455
|
hint: "every '<!-- agent-experiments -->' needs a matching '<!-- /agent-experiments -->'",
|
|
455
456
|
},
|
|
456
457
|
|
|
457
|
-
// -- Metrics CSVs (union merge keeps both sides on concurrent appends
|
|
458
|
-
// exact-duplicate rows
|
|
458
|
+
// -- Metrics CSVs (union merge keeps both sides on concurrent appends. The
|
|
459
|
+
// audit surfaces exact-duplicate rows here. It never silently removes
|
|
460
|
+
// them) --
|
|
459
461
|
|
|
460
462
|
{
|
|
461
463
|
id: "metrics-csv.duplicate-row",
|
|
@@ -464,7 +466,7 @@ export const RULES = [
|
|
|
464
466
|
check: duplicateCsvRows,
|
|
465
467
|
message: (_s, r) =>
|
|
466
468
|
`Duplicate metrics row at line ${r.lineNo} (exact match of an earlier row)`,
|
|
467
|
-
hint: "remove the surplus row, or differentiate a genuinely-distinct measurement
|
|
469
|
+
hint: "remove the surplus row, or differentiate a genuinely-distinct measurement. edit its run id or note so the rows are no longer identical",
|
|
468
470
|
},
|
|
469
471
|
|
|
470
472
|
// -- STATUS.md rows (per-migration-unit sub-row schema) --
|
|
@@ -478,17 +480,18 @@ export const RULES = [
|
|
|
478
480
|
// -- Filename admission --
|
|
479
481
|
|
|
480
482
|
// The `admission` resolver yields one subject per git-tracked path the
|
|
481
|
-
// filename grammar rejects, so the check always fires.
|
|
482
|
-
// wrong automated move or delete destroys memory, so
|
|
483
|
-
//
|
|
484
|
-
//
|
|
483
|
+
// filename grammar rejects, so the check always fires. This finding is a
|
|
484
|
+
// flag for a human. A wrong automated move or delete destroys memory, so
|
|
485
|
+
// `fix` routes it to the human report and never touches the file. Any
|
|
486
|
+
// non-`agent` remediation class does the same.
|
|
485
487
|
{
|
|
486
488
|
id: "admission.not-in-grammar",
|
|
487
489
|
scope: "admission",
|
|
488
490
|
severity: "fail",
|
|
489
491
|
remediation: "flag",
|
|
490
492
|
check: () => ({}),
|
|
491
|
-
message: (s) =>
|
|
493
|
+
message: (s) =>
|
|
494
|
+
`${s.relPath} matches no class in the wiki filename grammar`,
|
|
492
495
|
hint: "rename to an admitted class, or extend the Wiki Filename Grammar section in memory-protocol.md and audit/grammar.js together (the single admission path)",
|
|
493
496
|
},
|
|
494
497
|
];
|
package/src/audit/scopes.js
CHANGED
|
@@ -39,8 +39,8 @@ function listMdFiles(wikiRoot, fs) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
// Recursively collect every *.csv under `<wikiRoot>/metrics/`. The real layout
|
|
42
|
-
// is `metrics/<skill>/<year>.csv` (two levels), so the walk recurses
|
|
43
|
-
//
|
|
42
|
+
// is `metrics/<skill>/<year>.csv` (two levels), so the walk recurses and does
|
|
43
|
+
// not assume a fixed depth. The walk uses readdirSync + statSync (rather than
|
|
44
44
|
// `withFileTypes` Dirents) so it runs unchanged under the in-memory mock fs.
|
|
45
45
|
function listCsvFiles(wikiRoot, fs) {
|
|
46
46
|
const metricsRoot = path.join(wikiRoot, "metrics");
|
|
@@ -57,7 +57,7 @@ function listCsvFiles(wikiRoot, fs) {
|
|
|
57
57
|
return found;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
// Load a metrics CSV as an audit subject
|
|
60
|
+
// Load a metrics CSV as an audit subject. `rows` is the array of line strings,
|
|
61
61
|
// so a rule indexes `rows[i]` (a string) and `i + 1` is its line number.
|
|
62
62
|
function loadCsv(filePath, fs) {
|
|
63
63
|
return {
|
|
@@ -97,8 +97,8 @@ function loadFile(filePath, fs) {
|
|
|
97
97
|
function classifyFile(filePath, fs) {
|
|
98
98
|
const base = path.basename(filePath);
|
|
99
99
|
if (EXCLUDED_BASES.has(base)) return null;
|
|
100
|
-
// STATUS.md
|
|
101
|
-
//
|
|
100
|
+
// buildContext loads STATUS.md separately with readOptional. The dedicated
|
|
101
|
+
// `status-row` scope audits it. So skip the per-file classification.
|
|
102
102
|
if (base === "STATUS.md") return null;
|
|
103
103
|
if (NON_SUMMARY_PREFIXES.some((p) => base.startsWith(p))) return null;
|
|
104
104
|
if (WEEKLY_LOG_NAME_RE.test(base)) {
|
|
@@ -109,23 +109,23 @@ function classifyFile(filePath, fs) {
|
|
|
109
109
|
}
|
|
110
110
|
const subject = loadFile(filePath, fs);
|
|
111
111
|
// Carry surface: a `<agent>-carries.md` whose H1 matches the Carry H1 RE.
|
|
112
|
-
// Both axes must match (filename prefix and H1),
|
|
113
|
-
// classifier. The two H1 REs end in distinct literals (`— Carries`
|
|
114
|
-
// `— Summary`) so the branches cannot cross-capture regardless of order
|
|
115
|
-
//
|
|
112
|
+
// Both axes must match (filename prefix and H1), like the summary
|
|
113
|
+
// classifier. The two H1 REs end in distinct literals (`— Carries` and
|
|
114
|
+
// `— Summary`) so the branches cannot cross-capture regardless of order.
|
|
115
|
+
// A name-match with an H1-miss stays unclassified, like a malformed summary.
|
|
116
116
|
if (CARRY_SURFACE_NAME_RE.test(base)) {
|
|
117
117
|
if (CARRY_SURFACE_H1_RE.test(subject.firstLine)) {
|
|
118
118
|
return { kind: "carry-surface", subject };
|
|
119
119
|
}
|
|
120
120
|
return null;
|
|
121
121
|
}
|
|
122
|
-
//
|
|
123
|
-
// unclassified
|
|
122
|
+
// A file that does not match a summary or weekly-log shape stays
|
|
123
|
+
// unclassified. The audit skips stray files.
|
|
124
124
|
if (!SUMMARY_H1_RE.test(subject.firstLine)) return null;
|
|
125
125
|
return { kind: "summary", subject };
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
// Read a file if present
|
|
128
|
+
// Read a file if present. An absent file yields empty text so callers audit
|
|
129
129
|
// "missing" uniformly. The common { path, text, exists } shape backs the
|
|
130
130
|
// MEMORY.md, STATUS.md, and storyboard context loads.
|
|
131
131
|
function readOptional(filePath, fs) {
|
|
@@ -139,7 +139,8 @@ function readOptional(filePath, fs) {
|
|
|
139
139
|
|
|
140
140
|
// MEMORY.md carries the same line/word budget rules as the prose surfaces, so
|
|
141
141
|
// its subject needs the `lines`/`words` counters those check builders read.
|
|
142
|
-
//
|
|
142
|
+
// The loader counts them off the canonical budget.js pair, like every other
|
|
143
|
+
// budgeted surface.
|
|
143
144
|
function loadMemory(filePath, fs) {
|
|
144
145
|
const base = readOptional(filePath, fs);
|
|
145
146
|
return {
|
|
@@ -150,11 +151,11 @@ function loadMemory(filePath, fs) {
|
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
/**
|
|
153
|
-
* Parse the rows inside STATUS.md's fenced block into audit subjects.
|
|
154
|
-
* outside the ``` fence (header prose) and blank lines
|
|
155
|
-
* carries a `kind` from {@link parseStatusRowId} (`"spec"`,
|
|
156
|
-
* `null` for an unrecognized id)
|
|
157
|
-
* `id`/`phase`/`status` fields
|
|
154
|
+
* Parse the rows inside STATUS.md's fenced block into audit subjects. The
|
|
155
|
+
* parser skips lines outside the ``` fence (header prose) and blank lines.
|
|
156
|
+
* Each row carries a `kind` from {@link parseStatusRowId} (`"spec"`,
|
|
157
|
+
* `"experiment"`, or `null` for an unrecognized id). Spec-shaped rules read
|
|
158
|
+
* the positional `id`/`phase`/`status` fields. Experiment rules read `cells`.
|
|
158
159
|
* @param {string} statusText - The full STATUS.md contents.
|
|
159
160
|
* @returns {Array<{lineNo: number, text: string, cells: string[], id: string, phase: string, status: string, kind: string|null}>}
|
|
160
161
|
*/
|
|
@@ -171,9 +172,9 @@ function parseStatusRows(statusText) {
|
|
|
171
172
|
if (!inFence || line.trim() === "") continue;
|
|
172
173
|
const cells = line.split("\t");
|
|
173
174
|
// Classify by id prefix so a malformed `exp:` row (e.g. wrong cell count)
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
175
|
+
// still routes to the experiment rules, which flag it. It does not slip
|
|
176
|
+
// through the spec-shaped rules. parseStatusRowId returns the structured
|
|
177
|
+
// fields only for a well-formed row. The rules read `cells`.
|
|
177
178
|
const isExp = typeof cells[0] === "string" && cells[0].startsWith("exp:");
|
|
178
179
|
const parsed = parseStatusRowId(cells[0], cells);
|
|
179
180
|
rows.push({
|
|
@@ -276,12 +277,12 @@ const SCOPE_RESOLVERS = {
|
|
|
276
277
|
|
|
277
278
|
// Normalize every audited surface into a uniform `{ path, text, fenceExempt }`
|
|
278
279
|
// subject for the conflict-marker scan. The per-file subjects (summaries,
|
|
279
|
-
// weekly logs and sealed parts, storyboard) carry `fileLines
|
|
280
|
+
// weekly logs and sealed parts, storyboard) carry `fileLines`. MEMORY.md and
|
|
280
281
|
// STATUS.md carry `text` (readOptional shape). `fenceExempt` is true for prose
|
|
281
|
-
// surfaces, where a fence quotes content
|
|
282
|
-
// rows are data
|
|
283
|
-
// contract).
|
|
284
|
-
// empty text and
|
|
282
|
+
// surfaces, where a fence quotes content. It is false for STATUS.md, whose
|
|
283
|
+
// fenced rows are data. A marker there is never legitimate (per-surface fence
|
|
284
|
+
// contract). A file absent from disk (no MEMORY/STATUS/storyboard) yields
|
|
285
|
+
// empty text and produces no findings.
|
|
285
286
|
function conflictScanSubjects(ctx) {
|
|
286
287
|
const subjects = [];
|
|
287
288
|
const fileScopes = ["summary", "weekly-log-main", "weekly-log-part"];
|
|
@@ -320,12 +321,12 @@ export function resolveScope(scopeKey, ctx) {
|
|
|
320
321
|
}
|
|
321
322
|
|
|
322
323
|
/**
|
|
323
|
-
* Build the admission slice
|
|
324
|
-
* `rootSummaryAgents` set that gates `<agent>/` sidecar directories. The
|
|
325
|
-
*
|
|
326
|
-
* `admission` scope can classify sidecar directories against it.
|
|
324
|
+
* Build the admission slice. It holds the tracked-file universe plus the
|
|
325
|
+
* `rootSummaryAgents` set that gates `<agent>/` sidecar directories. The
|
|
326
|
+
* function derives the agent set first (a root-level summary-class file's
|
|
327
|
+
* stem) so the `admission` scope can classify sidecar directories against it.
|
|
327
328
|
*
|
|
328
|
-
* Returns the empty universe when `subprocess` is absent
|
|
329
|
+
* Returns the empty universe when `subprocess` is absent. Callers that only
|
|
329
330
|
* read `.subjects` (the rotation pre-pass) skip the git read and the tree walk
|
|
330
331
|
* entirely, and produce no `admission` findings.
|
|
331
332
|
*/
|
|
@@ -342,9 +343,9 @@ function buildAdmission(wikiRoot, fs, subprocess) {
|
|
|
342
343
|
}
|
|
343
344
|
|
|
344
345
|
/**
|
|
345
|
-
* Build the audit context
|
|
346
|
+
* Build the audit context. It classifies and loads every wiki file once.
|
|
346
347
|
* @param {{wikiRoot: string, today: string, fs: object, subprocess: object}} options
|
|
347
|
-
* `fs` is the sync filesystem surface (`runtime.fsSync`)
|
|
348
|
+
* `fs` is the sync filesystem surface (`runtime.fsSync`). `subprocess` is
|
|
348
349
|
* `runtime.subprocess` (its `runSync` backs the admission scope's git read).
|
|
349
350
|
*/
|
|
350
351
|
export function buildContext({ wikiRoot, today, fs, subprocess }) {
|
package/src/audit/status-row.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { STATUS_ID_REGEX } from "../status.js";
|
|
2
2
|
|
|
3
|
-
// Validate every row inside wiki/STATUS.md's code fence.
|
|
4
|
-
//
|
|
3
|
+
// Validate every row inside wiki/STATUS.md's code fence. The `status-row`
|
|
4
|
+
// scope in scopes.js resolves the rows. Each subject carries
|
|
5
5
|
// `{ cells, id, phase, status, kind, text }`. Two row kinds share the fence:
|
|
6
6
|
//
|
|
7
7
|
// spec `{id}<TAB>{phase}<TAB>{status}` — three cells
|
|
@@ -40,7 +40,7 @@ export const STATUS_ROW_RULES = [
|
|
|
40
40
|
when: hasThreeCells,
|
|
41
41
|
check: (s) => (STATUS_ID_REGEX.test(s.id) ? null : { id: s.id }),
|
|
42
42
|
message: (_s, r) => `Bad id '${r.id}' (expected ^\\d{4}(/[a-z0-9-]+)?$)`,
|
|
43
|
-
hint: "spec ids are four digits
|
|
43
|
+
hint: "spec ids are four digits. a sub-row appends `/<unit>` (e.g. 1370/libutil)",
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
id: "status-row.phase",
|
|
@@ -73,11 +73,10 @@ export const STATUS_ROW_RULES = [
|
|
|
73
73
|
hint: "each experiment row is `exp:{issue}<TAB>{state}<TAB>{pin}<TAB>{plan-ref}`",
|
|
74
74
|
},
|
|
75
75
|
{
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
// STATUS_ID_REGEX / parseStatusRowId.
|
|
76
|
+
// scopes.js classifies an experiment-kind row by its `exp:` id prefix, so
|
|
77
|
+
// the spec `id-format` rule skips it. This rule enforces the `exp:\d+` id,
|
|
78
|
+
// so a non-numeric issue (e.g. `exp:abc`) flags and does not audit clean.
|
|
79
|
+
// That keeps the audit aligned with STATUS_ID_REGEX / parseStatusRowId.
|
|
81
80
|
id: "status-row.exp-id-format",
|
|
82
81
|
scope: "status-row",
|
|
83
82
|
severity: "fail",
|
|
@@ -101,10 +100,10 @@ export const STATUS_ROW_RULES = [
|
|
|
101
100
|
scope: "status-row",
|
|
102
101
|
severity: "fail",
|
|
103
102
|
when: hasFourCells,
|
|
104
|
-
// The pin is decidable per state, with no "ever approved" inference
|
|
105
|
-
// `registered` row has no pin (`-`)
|
|
106
|
-
// head
|
|
107
|
-
// not
|
|
103
|
+
// The pin is decidable per state, with no "ever approved" inference. A
|
|
104
|
+
// `registered` row has no pin (`-`). An `approved` row pins the 40-hex
|
|
105
|
+
// head. A `cancelled` row may carry the retained pin or `-`, because it
|
|
106
|
+
// may or may not pass through `approved` first. So the check accepts both.
|
|
108
107
|
check: (s) => {
|
|
109
108
|
const [, state, pin] = s.cells;
|
|
110
109
|
if (state === "registered") {
|
|
@@ -118,11 +117,11 @@ export const STATUS_ROW_RULES = [
|
|
|
118
117
|
? null
|
|
119
118
|
: { state, pin, want: "`-` or a 40-hex SHA" };
|
|
120
119
|
}
|
|
121
|
-
return null; //
|
|
120
|
+
return null; // exp-state already flags a bad state
|
|
122
121
|
},
|
|
123
122
|
message: (_s, r) =>
|
|
124
123
|
`Bad pin '${r.pin}' for state '${r.state}' (expected ${r.want})`,
|
|
125
|
-
hint: "registered pins
|
|
124
|
+
hint: "registered pins `-`. approved pins a 40-hex SHA. cancelled pins either",
|
|
126
125
|
},
|
|
127
126
|
{
|
|
128
127
|
id: "status-row.exp-planref",
|
|
@@ -131,6 +130,6 @@ export const STATUS_ROW_RULES = [
|
|
|
131
130
|
when: hasFourCells,
|
|
132
131
|
check: (s) => (/^#\d+$/.test(s.cells[3]) ? null : { planRef: s.cells[3] }),
|
|
133
132
|
message: (_s, r) => `Bad plan-ref '${r.planRef}' (expected #NNN)`,
|
|
134
|
-
hint: "the plan-ref names the issue
|
|
133
|
+
hint: "the plan-ref names the issue that carries the execution plan, e.g. #NNN",
|
|
135
134
|
},
|
|
136
135
|
];
|
package/src/block-renderer.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { analyze, renderChart, MIN_POINTS } from "@forwardimpact/libxmr";
|
|
3
3
|
|
|
4
|
-
/** Error
|
|
4
|
+
/** Error the renderer throws when an XmR block has no CSV or no metric. */
|
|
5
5
|
export class BlockRenderError extends Error {
|
|
6
6
|
/** Create a BlockRenderError with the given reason string. */
|
|
7
7
|
constructor(reason) {
|
|
@@ -11,11 +11,12 @@ export class BlockRenderError extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* Render an XmR chart block for a metric
|
|
14
|
+
* Render an XmR chart block for a metric. Read its CSV and produce the
|
|
15
15
|
* markdown lines.
|
|
16
16
|
* @param {{metric: string, csvPath: string, projectRoot: string, fs: object, priorReadAnchor?: string|null}} options
|
|
17
|
-
* `fs` is the sync filesystem surface (`runtime.fsSync`). `priorReadAnchor
|
|
18
|
-
*
|
|
17
|
+
* `fs` is the sync filesystem surface (`runtime.fsSync`). `priorReadAnchor`
|
|
18
|
+
* stamps per-signal provenance when the caller supplies it. The Signals line
|
|
19
|
+
* surfaces that provenance.
|
|
19
20
|
*/
|
|
20
21
|
export function renderBlock({
|
|
21
22
|
metric,
|
package/src/boot.js
CHANGED
|
@@ -130,10 +130,11 @@ function bulletItem(threshold, agent) {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
// Advance the agent-section scan for one storyboard line that is NOT inside the
|
|
133
|
-
// materialized block. Returns the next `inAgent` state
|
|
134
|
-
//
|
|
135
|
-
// scan
|
|
136
|
-
// would run past the agent sections and misattribute
|
|
133
|
+
// materialized block. Returns the next `inAgent` state. When the scan finds an
|
|
134
|
+
// h3 bullet for the agent that boots, it pushes that item. An h2 ends the
|
|
135
|
+
// agent-section scan, because team-wide sections follow the last agent h3.
|
|
136
|
+
// Without this the scan would run past the agent sections and misattribute
|
|
137
|
+
// team-wide bullets.
|
|
137
138
|
function scanAgentLine(line, agent, inAgent, items) {
|
|
138
139
|
if (/^## /.test(line)) return false;
|
|
139
140
|
const h3Match = line.match(/^### (.+)$/);
|
|
@@ -152,8 +153,9 @@ function parseStoryboardItems(text, agent) {
|
|
|
152
153
|
let inBlock = false;
|
|
153
154
|
for (const line of text.split("\n")) {
|
|
154
155
|
// The materialized block carries `- #N [agent] …` bullets that the agent
|
|
155
|
-
// scan must never capture
|
|
156
|
-
// (Without it the legacy scan double-counted these as the last agent's
|
|
156
|
+
// scan must never capture. Track it so the bullet loop skips inside it.
|
|
157
|
+
// (Without it the legacy scan double-counted these as the last agent's
|
|
158
|
+
// bullets.)
|
|
157
159
|
if (AGENT_EXPERIMENTS_OPEN_RE.test(line)) {
|
|
158
160
|
inBlock = true;
|
|
159
161
|
inAgent = false;
|
|
@@ -204,7 +206,7 @@ function countInbox(text) {
|
|
|
204
206
|
/**
|
|
205
207
|
* Remaining budget for a budgeted surface: current value, cap, and headroom for
|
|
206
208
|
* both the line and word budget. An absent file (empty text) reports zero usage
|
|
207
|
-
* and full headroom so a writer sees the ceiling before
|
|
209
|
+
* and full headroom so a writer sees the ceiling before they compose.
|
|
208
210
|
*/
|
|
209
211
|
function headroom(text, lineCap, wordCap) {
|
|
210
212
|
const lines = countLines(text);
|
|
@@ -237,7 +239,7 @@ function mapClaim(c) {
|
|
|
237
239
|
/**
|
|
238
240
|
* Build the boot digest JSON object.
|
|
239
241
|
* @param {{wikiRoot: string, agent: string, today: string, fs: object}} options
|
|
240
|
-
* `fs` is the sync filesystem surface (`runtime.fsSync`)
|
|
242
|
+
* `fs` is the sync filesystem surface (`runtime.fsSync`). `today` is an ISO
|
|
241
243
|
* date string.
|
|
242
244
|
*/
|
|
243
245
|
export function buildDigest({ wikiRoot, agent, today, fs }) {
|
package/src/budget-gate.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// Post-landing, pre-push budget re-validation on the size (word/line) axis.
|
|
2
2
|
//
|
|
3
3
|
// The wiki landing flow re-runs the audit's budget predicates over the
|
|
4
|
-
// outgoing tree between landing and push
|
|
4
|
+
// outgoing tree between landing and push. It refuses a push that introduces
|
|
5
5
|
// or deepens a per-file budget breach this writer's push would publish. The
|
|
6
|
-
// gate reuses the audit's budget rules by reference
|
|
7
|
-
// objects named by `BUDGET_RULE_IDS
|
|
8
|
-
// over-cap predicate) plus the same `countWords` / `countLines` the audit
|
|
9
|
-
// builds its subjects from. It never re-defines a budget
|
|
10
|
-
// the `runRules` engine
|
|
11
|
-
// cap
|
|
6
|
+
// gate reuses the audit's budget rules by reference. It resolves the rule
|
|
7
|
+
// objects named by `BUDGET_RULE_IDS`. It then calls each rule's own `check`
|
|
8
|
+
// (the over-cap predicate) plus the same `countWords` / `countLines` the audit
|
|
9
|
+
// builds its subjects from. It never re-defines a budget. It never routes
|
|
10
|
+
// through the `runRules` engine, which drops the numeric value and emits
|
|
11
|
+
// nothing under cap. It never edits. It refuses, which keeps commits local.
|
|
12
12
|
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { BUDGET_RULE_IDS, RULES } from "./audit/rules.js";
|
|
@@ -16,9 +16,10 @@ import { buildContext, resolveScope } from "./audit/scopes.js";
|
|
|
16
16
|
import { countLines, countWords } from "./budget.js";
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Resolve `BUDGET_RULE_IDS` to their rule objects in `RULES
|
|
20
|
-
* the count axis its id implies.
|
|
21
|
-
* so a rule rename surfaces here
|
|
19
|
+
* Resolve `BUDGET_RULE_IDS` to their rule objects in `RULES`. Tag each one with
|
|
20
|
+
* the count axis its id implies. It throws if a named id is missing from
|
|
21
|
+
* `RULES`, so a rule rename surfaces here and does not silently drop a
|
|
22
|
+
* predicate.
|
|
22
23
|
* @returns {Array<{id: string, scope: string, axis: 'words'|'lines', check: Function}>}
|
|
23
24
|
*/
|
|
24
25
|
export function budgetRules() {
|
|
@@ -36,10 +37,10 @@ export function budgetRules() {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
|
-
* Enumerate which wiki files are budgeted
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
* Enumerate which wiki files are budgeted. Reuse the audit's classification.
|
|
41
|
+
* Subjects carry an absolute `path`. Reduce each one to the `<file>` half of
|
|
42
|
+
* `git show <ref>:<file>`, relative to `wikiRoot`. This function reads no count
|
|
43
|
+
* off the working-dir subject. It reads only the file identity and its scope.
|
|
43
44
|
* @param {object} ctx - An audit context from `buildContext`.
|
|
44
45
|
* @param {string} wikiRoot - The wiki clone directory the paths are relative to.
|
|
45
46
|
* @returns {Array<{relPath: string, scope: string}>}
|
|
@@ -56,12 +57,12 @@ export function budgetedFiles(ctx, wikiRoot) {
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
|
-
* Measure the budget predicates for the tree at `ref`.
|
|
60
|
-
* file's blob
|
|
61
|
-
* counters,
|
|
60
|
+
* Measure the budget predicates for the tree at `ref`. Read each budgeted
|
|
61
|
+
* file's blob through the cwd-bound `showFile`. Count it once with the audit's
|
|
62
|
+
* counters. Then, for every budget rule on that file's scope, record the axis
|
|
62
63
|
* value and whether the rule's own `check` flags it over cap. An absent path
|
|
63
|
-
* at the ref counts as 0
|
|
64
|
-
* posture
|
|
64
|
+
* at the ref counts as 0. This matches the audit's "missing counts as empty"
|
|
65
|
+
* posture. An unreadable ref makes `showFile` throw, and the throw propagates.
|
|
65
66
|
* @param {(ref: string, file: string) => Promise<string|null>} showFile
|
|
66
67
|
* @param {string} ref - The tree-ish to measure (e.g. "HEAD", a SHA).
|
|
67
68
|
* @param {Array<{relPath: string, scope: string}>} budgeted
|
|
@@ -88,15 +89,16 @@ export async function measureRef(showFile, ref, budgeted) {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
/**
|
|
91
|
-
* Compare the outgoing tree against the two push-input baselines
|
|
92
|
+
* Compare the outgoing tree against the two push-input baselines. Return the
|
|
92
93
|
* per-file/per-predicate refusal delta. For each (file, rule) the baseline is
|
|
93
|
-
* the worse (higher) of the session-base and origin-tip values
|
|
94
|
-
*
|
|
95
|
-
* cap AND strictly exceeds that baseline
|
|
96
|
-
* a foreign breach the writer did not worsen passes.
|
|
97
|
-
* file listed in `exemptSummaryFiles`
|
|
98
|
-
* memo-delivery seam
|
|
99
|
-
* enforce a contradiction
|
|
94
|
+
* the worse (higher) of the session-base and origin-tip values. An absent
|
|
95
|
+
* measurement counts as 0. A predicate refuses iff the outgoing value is over
|
|
96
|
+
* cap AND strictly exceeds that baseline. So equal-or-better states pass, and
|
|
97
|
+
* a foreign breach the writer did not worsen passes. The gate surfaces a
|
|
98
|
+
* `summary.*` breach on a file listed in `exemptSummaryFiles` instead of
|
|
99
|
+
* refusing it. Those files are the memo-delivery seam. A block on a delivery
|
|
100
|
+
* into deficient headroom would enforce a contradiction. The memo-headroom
|
|
101
|
+
* measures exist to resolve that contradiction.
|
|
100
102
|
*
|
|
101
103
|
* @param {object} args
|
|
102
104
|
* @param {Map<string, Map<string, {value: number, overCap: boolean}>>} args.outgoing
|
|
@@ -137,14 +139,14 @@ export function revalidateBudgets({
|
|
|
137
139
|
}
|
|
138
140
|
|
|
139
141
|
/**
|
|
140
|
-
* Run the gate end to end over the outgoing tree.
|
|
141
|
-
*
|
|
142
|
-
* and the two push-input baselines through the one `measureRef` path
|
|
143
|
-
*
|
|
144
|
-
* `showFile` throw, which aborts the gate WITHOUT refusing
|
|
145
|
-
* refuses a regression it can prove
|
|
146
|
-
* proceeds
|
|
147
|
-
* a foreign pre-existing breach.
|
|
142
|
+
* Run the gate end to end over the outgoing tree. Build the audit context.
|
|
143
|
+
* Enumerate the budgeted files. Measure the committed `HEAD` (what publishes)
|
|
144
|
+
* and the two push-input baselines through the one `measureRef` path. Then
|
|
145
|
+
* compute the per-file/per-predicate delta. An unreadable baseline ref makes
|
|
146
|
+
* `showFile` throw, which aborts the gate WITHOUT refusing. The gate only
|
|
147
|
+
* refuses a regression it can prove. So a read failure surfaces and the push
|
|
148
|
+
* proceeds. The gate does not fabricate a value-0 baseline that would wrongly
|
|
149
|
+
* block a foreign pre-existing breach.
|
|
148
150
|
*
|
|
149
151
|
* @param {object} args
|
|
150
152
|
* @param {(ref: string, file: string) => Promise<string|null>} args.showFile
|
|
@@ -181,7 +183,8 @@ export async function runBudgetGate({
|
|
|
181
183
|
}
|
|
182
184
|
originTip = await measureRef(showFile, originRef, budgeted);
|
|
183
185
|
} catch {
|
|
184
|
-
// Cannot prove a regression (unreadable ref) ⇒ do not refuse
|
|
186
|
+
// Cannot prove a regression (unreadable ref) ⇒ do not refuse. This is the
|
|
187
|
+
// fail-visible posture.
|
|
185
188
|
return { refusals: [], surfaced: [] };
|
|
186
189
|
}
|
|
187
190
|
return revalidateBudgets({
|
package/src/budget.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Canonical line- and word-counters for the budgeted wiki surfaces. The audit
|
|
2
2
|
// (`audit/scopes.js`) and the rotation primitive's bisecting seal
|
|
3
|
-
// (`weekly-log.js`) both import this one pair
|
|
4
|
-
//
|
|
3
|
+
// (`weekly-log.js`) both import this one pair. So no audit that counts
|
|
4
|
+
// differently can later flag a part the seal accepts as conforming.
|
|
5
5
|
|
|
6
|
-
/** Count lines
|
|
6
|
+
/** Count lines. Do not count a trailing newline as an empty final line. */
|
|
7
7
|
export function countLines(text) {
|
|
8
8
|
return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
9
9
|
}
|