@forwardimpact/libwiki 0.2.3 → 0.2.5
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 +136 -11
- package/bin/fit-wiki.js +1 -6
- package/package.json +1 -1
- package/src/audit/engine.js +36 -0
- package/src/audit/format.js +39 -0
- package/src/audit/rules.js +459 -0
- package/src/audit/scopes.js +194 -0
- package/src/commands/audit.js +11 -341
- package/src/constants.js +6 -5
- package/src/index.js +4 -1
package/README.md
CHANGED
|
@@ -7,23 +7,148 @@ persists across sessions.
|
|
|
7
7
|
|
|
8
8
|
<!-- END:description -->
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
A wiki under `wiki/` holds each agent's running state: per-agent summaries,
|
|
11
|
+
weekly logs, shared memory (priorities and active claims), and monthly
|
|
12
|
+
storyboards. `libwiki` keeps that wiki coherent across sessions — agents boot
|
|
13
|
+
from it, write decisions back, send memos to each other, and audit the
|
|
14
|
+
result against a declarative rule set.
|
|
15
|
+
|
|
16
|
+
The primary interface is the `fit-wiki` CLI. The library also exposes a few
|
|
17
|
+
helpers for programmatic use.
|
|
18
|
+
|
|
19
|
+
## Getting started
|
|
11
20
|
|
|
12
21
|
```sh
|
|
13
|
-
npx fit-wiki
|
|
14
|
-
npx fit-wiki
|
|
22
|
+
npx fit-wiki init
|
|
23
|
+
npx fit-wiki boot --agent staff-engineer
|
|
24
|
+
npx fit-wiki audit
|
|
15
25
|
```
|
|
16
26
|
|
|
17
|
-
|
|
18
|
-
|
|
27
|
+
## CLI
|
|
28
|
+
|
|
29
|
+
Every command accepts `--wiki-root` (default `wiki/`) and `--today` (default
|
|
30
|
+
today, ISO date). Agent commands take `--agent <name>` or read
|
|
31
|
+
`LIBEVAL_AGENT_PROFILE` from the environment.
|
|
32
|
+
|
|
33
|
+
### `boot` — start a session
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
npx fit-wiki boot --agent staff-engineer [--format json|markdown]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Print the on-boot digest for the agent: own priorities, cross-cutting
|
|
40
|
+
priorities, active claims, storyboard items, inbox count.
|
|
41
|
+
|
|
42
|
+
### `log` — record decisions, notes, done
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
npx fit-wiki log decision --agent X --surveyed "..." --chosen "..." --rationale "..."
|
|
46
|
+
npx fit-wiki log note --agent X --field "PR Status" --body "merged"
|
|
47
|
+
npx fit-wiki log done --agent X
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Appends to `wiki/<agent>-YYYY-WVV.md`. Auto-rotates to `*-partN.md` when the
|
|
51
|
+
line budget would be exceeded.
|
|
52
|
+
|
|
53
|
+
### `claim` / `release` — coordinate work
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
npx fit-wiki claim --agent X --target spec-1060 --branch claude/spec-1060
|
|
57
|
+
npx fit-wiki release --agent X --target spec-1060
|
|
58
|
+
npx fit-wiki release --agent X --expired
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Maintains the `## Active Claims` table in `MEMORY.md`. Duplicates refused;
|
|
62
|
+
row absent means settled.
|
|
63
|
+
|
|
64
|
+
### `inbox` — triage memos
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
npx fit-wiki inbox list --agent X
|
|
68
|
+
npx fit-wiki inbox ack --agent X --index 0
|
|
69
|
+
npx fit-wiki inbox promote --agent X --index 0 [--owner X]
|
|
70
|
+
npx fit-wiki inbox drop --agent X --index 0
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Reads bullets under the `<!-- memo:inbox -->` marker in the agent's summary.
|
|
74
|
+
`promote` moves a bullet into the cross-cutting priorities table.
|
|
75
|
+
|
|
76
|
+
### `memo` — cross-team coordination
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
npx fit-wiki memo --from X --to Y --message "audit d642ff0c"
|
|
80
|
+
npx fit-wiki memo --from X --to all --message "new XmR baseline"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Inserts a bullet `- YYYY-MM-DD from **X**: ...` after the recipient's
|
|
84
|
+
`<!-- memo:inbox -->` marker.
|
|
85
|
+
|
|
86
|
+
### `audit` — verify wiki state
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
npx fit-wiki audit [--format text|json]
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Runs a declarative catalogue of rules across the wiki. Exits 0 on pass, 1
|
|
93
|
+
on any failure. Text output: `WARN ...` and `FAIL ...` lines plus a
|
|
94
|
+
`RESULT: ...` trailer. JSON output:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{ "result": "pass|fail", "failures": [...], "warnings": [...] }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Each finding carries a stable `id` for filtering. The catalogue lives in
|
|
101
|
+
`src/audit/rules.js` — adding a rule is one literal.
|
|
102
|
+
|
|
103
|
+
### `rotate` — force a part split
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
npx fit-wiki rotate --agent X
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Renames the current weekly log to the next `-partN.md` and starts a fresh
|
|
110
|
+
main file.
|
|
111
|
+
|
|
112
|
+
### `refresh` — re-render storyboard blocks
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
npx fit-wiki refresh [storyboard-path]
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Re-renders `<!-- xmr:metric:csv-path -->` and `<!-- obstacles:open[:Nd] -->`
|
|
119
|
+
marker blocks inside a storyboard from their backing CSV / GitHub state.
|
|
120
|
+
Default path: `wiki/storyboard-YYYY-MMM.md` for the current month.
|
|
121
|
+
|
|
122
|
+
### `init` / `push` / `pull` — wiki working tree
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
npx fit-wiki init [--wiki-root wiki] [--skills-dir .claude/skills]
|
|
126
|
+
npx fit-wiki push
|
|
127
|
+
npx fit-wiki pull
|
|
19
128
|
```
|
|
20
129
|
|
|
21
|
-
|
|
130
|
+
`init` clones the wiki repo if missing, scaffolds Active Claims in
|
|
131
|
+
`MEMORY.md`, and creates `wiki/metrics/<skill>/` directories. `push` and
|
|
132
|
+
`pull` are thin wrappers over `git` with conflict handling.
|
|
133
|
+
|
|
134
|
+
## Programmatic API
|
|
22
135
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
136
|
+
```js
|
|
137
|
+
import {
|
|
138
|
+
writeMemo, listAgents, insertMarkers, runAudit, RULES,
|
|
139
|
+
} from "@forwardimpact/libwiki";
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- `writeMemo({ summaryPath, sender, message, today })` — append a memo
|
|
143
|
+
bullet after the `<!-- memo:inbox -->` marker.
|
|
26
144
|
- `listAgents({ agentsDir, wikiRoot })` — discover agents from
|
|
27
145
|
`.claude/agents/*.md` and derive wiki summary paths.
|
|
28
|
-
- `insertMarkers({ agentsDir, wikiRoot })` — idempotent insertion of the
|
|
29
|
-
marker into existing summaries.
|
|
146
|
+
- `insertMarkers({ agentsDir, wikiRoot })` — idempotent insertion of the
|
|
147
|
+
memo marker into existing summaries.
|
|
148
|
+
- `runAudit(rules, ctx)` — pure audit engine: `(rules, ctx) → findings[]`.
|
|
149
|
+
- `RULES` — the audit rule catalogue (one literal per rule).
|
|
150
|
+
|
|
151
|
+
## Documentation
|
|
152
|
+
|
|
153
|
+
- [Operate a Predictable Agent Team](https://www.forwardimpact.team/docs/libraries/predictable-team/index.md)
|
|
154
|
+
- [Send a Memo or Update a Storyboard](https://www.forwardimpact.team/docs/libraries/predictable-team/wiki-operations/index.md)
|
package/bin/fit-wiki.js
CHANGED
|
@@ -143,7 +143,7 @@ const definition = {
|
|
|
143
143
|
{
|
|
144
144
|
name: "audit",
|
|
145
145
|
description:
|
|
146
|
-
"Audit the wiki against the
|
|
146
|
+
"Audit the wiki against the declarative rule catalogue (line and word budgets, headings, decision blocks, storyboards, claims)",
|
|
147
147
|
options: {
|
|
148
148
|
...wikiRootOpt,
|
|
149
149
|
...todayOpt,
|
|
@@ -151,11 +151,6 @@ const definition = {
|
|
|
151
151
|
type: "string",
|
|
152
152
|
description: "Output format: text (default) or json",
|
|
153
153
|
},
|
|
154
|
-
"legacy-only": {
|
|
155
|
-
type: "boolean",
|
|
156
|
-
description:
|
|
157
|
-
"Run only the checks the legacy wiki-audit.sh carried (parity mode)",
|
|
158
|
-
},
|
|
159
154
|
},
|
|
160
155
|
},
|
|
161
156
|
{
|
package/package.json
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { resolveScope } from "./scopes.js";
|
|
2
|
+
|
|
3
|
+
function groupByScope(rules) {
|
|
4
|
+
const groups = new Map();
|
|
5
|
+
for (const rule of rules) {
|
|
6
|
+
if (!groups.has(rule.scope)) groups.set(rule.scope, []);
|
|
7
|
+
groups.get(rule.scope).push(rule);
|
|
8
|
+
}
|
|
9
|
+
return groups;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function applyRule(rule, subject, ctx) {
|
|
13
|
+
if (rule.when && !rule.when(subject, ctx)) return [];
|
|
14
|
+
const result = rule.check(subject, ctx);
|
|
15
|
+
if (result == null) return [];
|
|
16
|
+
const items = Array.isArray(result) ? result : [result];
|
|
17
|
+
return items.map((item) => ({
|
|
18
|
+
id: rule.id,
|
|
19
|
+
level: rule.severity,
|
|
20
|
+
path: subject.path ?? null,
|
|
21
|
+
message: rule.message(subject, item, ctx),
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Apply the declarative rule catalogue to the wiki context. */
|
|
26
|
+
export function runAudit(rules, ctx) {
|
|
27
|
+
const findings = [];
|
|
28
|
+
for (const [scopeKey, scopeRules] of groupByScope(rules)) {
|
|
29
|
+
for (const subject of resolveScope(scopeKey, ctx)) {
|
|
30
|
+
for (const rule of scopeRules) {
|
|
31
|
+
findings.push(...applyRule(rule, subject, ctx));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return findings;
|
|
36
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
function partition(findings) {
|
|
2
|
+
const failures = [];
|
|
3
|
+
const warnings = [];
|
|
4
|
+
for (const f of findings) {
|
|
5
|
+
if (f.level === "warn") warnings.push(f);
|
|
6
|
+
else failures.push(f);
|
|
7
|
+
}
|
|
8
|
+
return { failures, warnings };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Render findings as `WARN`/`FAIL` lines followed by a `RESULT:` trailer. */
|
|
12
|
+
export function emitText(findings) {
|
|
13
|
+
const { failures, warnings } = partition(findings);
|
|
14
|
+
const lines = [];
|
|
15
|
+
for (const w of warnings) lines.push(`WARN ${w.message}`);
|
|
16
|
+
for (const f of failures) lines.push(`FAIL ${f.message}`);
|
|
17
|
+
lines.push(
|
|
18
|
+
failures.length === 0
|
|
19
|
+
? "RESULT: pass"
|
|
20
|
+
: `RESULT: fail (${failures.length} checks failed)`,
|
|
21
|
+
);
|
|
22
|
+
return lines.join("\n") + "\n";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Render findings as a JSON document. */
|
|
26
|
+
export function emitJson(findings) {
|
|
27
|
+
const { failures, warnings } = partition(findings);
|
|
28
|
+
return (
|
|
29
|
+
JSON.stringify(
|
|
30
|
+
{
|
|
31
|
+
result: failures.length === 0 ? "pass" : "fail",
|
|
32
|
+
failures,
|
|
33
|
+
warnings,
|
|
34
|
+
},
|
|
35
|
+
null,
|
|
36
|
+
2,
|
|
37
|
+
) + "\n"
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ACTIVE_CLAIMS_HEADING,
|
|
3
|
+
ACTIVE_CLAIMS_TABLE_HEADER,
|
|
4
|
+
DECISION_HEADING,
|
|
5
|
+
MEMO_INBOX_MARKER,
|
|
6
|
+
PRIORITY_INDEX_HEADING,
|
|
7
|
+
SUMMARY_LINE_BUDGET,
|
|
8
|
+
SUMMARY_WORD_BUDGET,
|
|
9
|
+
WEEKLY_LOG_LINE_BUDGET,
|
|
10
|
+
WEEKLY_LOG_WORD_BUDGET,
|
|
11
|
+
} from "../constants.js";
|
|
12
|
+
import { PRIORITY_HEADER_RE, WEEKLY_LOG_H1_RE } from "./scopes.js";
|
|
13
|
+
|
|
14
|
+
const PRIORITY_INDEX_HEADING_RE = new RegExp(
|
|
15
|
+
`^${PRIORITY_INDEX_HEADING}$`,
|
|
16
|
+
"m",
|
|
17
|
+
);
|
|
18
|
+
const ACTIVE_CLAIMS_HEADING_RE = new RegExp(`^${ACTIVE_CLAIMS_HEADING}$`, "m");
|
|
19
|
+
const PRIORITY_SEPARATOR_RE =
|
|
20
|
+
/^\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|/m;
|
|
21
|
+
const CLAIMS_HEADER_RE =
|
|
22
|
+
/^\|\s*agent\s*\|\s*target\s*\|\s*branch\s*\|\s*pr\s*\|\s*claimed_at\s*\|\s*expires_at\s*\|/m;
|
|
23
|
+
const CLAIMS_SEPARATOR_RE =
|
|
24
|
+
/^\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|/m;
|
|
25
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
26
|
+
const XMR_OPEN_RE = /^<!--\s*xmr:([^:\s]+):([^\s]+)\s*-->\s*$/;
|
|
27
|
+
const XMR_CLOSE_RE = /^<!--\s*\/xmr\s*-->\s*$/;
|
|
28
|
+
const ISSUE_OPEN_RE =
|
|
29
|
+
/^<!--\s*(obstacles|experiments):(open|closed)(?::(\d+d))?\s*-->\s*$/;
|
|
30
|
+
const ISSUE_CLOSE_RE = /^<!--\s*\/(obstacles|experiments)\s*-->\s*$/;
|
|
31
|
+
|
|
32
|
+
// improvement-coach is the storyboard facilitator and carries no domain
|
|
33
|
+
// metrics; only the five domain agents need their own H3.
|
|
34
|
+
const STORYBOARD_DOMAIN_AGENTS = [
|
|
35
|
+
"product-manager",
|
|
36
|
+
"release-engineer",
|
|
37
|
+
"security-engineer",
|
|
38
|
+
"staff-engineer",
|
|
39
|
+
"technical-writer",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// -- Check builders: subject (+ ctx) → null | finding | finding[] --
|
|
43
|
+
|
|
44
|
+
const matches = (pattern) => (s) => (pattern.test(s.text) ? null : {});
|
|
45
|
+
const firstLineMatches = (pattern) => (s) =>
|
|
46
|
+
pattern.test(s.firstLine) ? null : {};
|
|
47
|
+
const containsLine = (needle) => (s) =>
|
|
48
|
+
s.fileLines.some((l) => l.trim() === needle) ? null : {};
|
|
49
|
+
|
|
50
|
+
const lineBudget = (limit) => (s) =>
|
|
51
|
+
s.lines > limit ? { value: s.lines } : null;
|
|
52
|
+
const wordBudget = (limit) => (s) =>
|
|
53
|
+
s.words > limit ? { value: s.words } : null;
|
|
54
|
+
|
|
55
|
+
const firstH2Is = (expected) => (s) =>
|
|
56
|
+
s.h2s.length === 0 || s.h2s[0] === expected ? null : { observed: s.h2s[0] };
|
|
57
|
+
|
|
58
|
+
const nothingAfterH2 = (marker) => (s) => {
|
|
59
|
+
const idx = s.h2s.indexOf(marker);
|
|
60
|
+
if (idx === -1) return null;
|
|
61
|
+
const after = s.h2s.slice(idx + 1);
|
|
62
|
+
return after.length === 0 ? null : after.map((h) => ({ observed: h }));
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const fieldMatches = (name, pattern) => (s) =>
|
|
66
|
+
pattern.test(s[name]) ? null : { value: s[name] };
|
|
67
|
+
|
|
68
|
+
const columnCount = (expected) => (s) =>
|
|
69
|
+
s.cells.length === expected ? null : { actual: s.cells.length, expected };
|
|
70
|
+
|
|
71
|
+
const exists = (s) => (s.exists ? null : {});
|
|
72
|
+
const expired = (s, ctx) => (s.expires_at < ctx.today ? {} : null);
|
|
73
|
+
const always = () => ({});
|
|
74
|
+
|
|
75
|
+
function entryHasDecision(lines, startIdx, requiredLine, stopRe) {
|
|
76
|
+
let seen = 0;
|
|
77
|
+
for (let j = startIdx + 1; j < lines.length && seen < 5; j++) {
|
|
78
|
+
const ln = lines[j].trim();
|
|
79
|
+
if (ln === "") continue;
|
|
80
|
+
seen++;
|
|
81
|
+
if (ln === requiredLine) return true;
|
|
82
|
+
if (stopRe.test(lines[j])) return false;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const decisionWithin5 =
|
|
88
|
+
({ entryRe, requiredLine, stopRe }) =>
|
|
89
|
+
(s) => {
|
|
90
|
+
const offenders = [];
|
|
91
|
+
for (let i = 0; i < s.fileLines.length; i++) {
|
|
92
|
+
if (
|
|
93
|
+
entryRe.test(s.fileLines[i]) &&
|
|
94
|
+
!entryHasDecision(s.fileLines, i, requiredLine, stopRe)
|
|
95
|
+
) {
|
|
96
|
+
offenders.push({ lineNo: i + 1 });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return offenders.length === 0 ? null : offenders;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
function scanMarkers(fileLines, openRe, closeRe, label) {
|
|
103
|
+
const openings = [];
|
|
104
|
+
const findings = [];
|
|
105
|
+
for (let i = 0; i < fileLines.length; i++) {
|
|
106
|
+
const openMatch = fileLines[i].match(openRe);
|
|
107
|
+
if (openMatch) {
|
|
108
|
+
openings.push({ label: openMatch[1] || label, lineNo: i + 1 });
|
|
109
|
+
} else if (closeRe.test(fileLines[i])) {
|
|
110
|
+
if (openings.length > 0) openings.pop();
|
|
111
|
+
else findings.push({ lineNo: i + 1, reason: "unpaired-close" });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const open of openings) {
|
|
115
|
+
findings.push({
|
|
116
|
+
lineNo: open.lineNo,
|
|
117
|
+
reason: "dangling-open",
|
|
118
|
+
label: open.label,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return findings;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const markersBalanced =
|
|
125
|
+
({ openRe, closeRe, label }) =>
|
|
126
|
+
(s) => {
|
|
127
|
+
const findings = scanMarkers(s.fileLines, openRe, closeRe, label);
|
|
128
|
+
return findings.length === 0 ? null : findings;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const allRequiredLines = (required) => (s) => {
|
|
132
|
+
const findings = [];
|
|
133
|
+
for (const r of required) {
|
|
134
|
+
if (!s.fileLines.some((l) => r.pattern.test(l))) {
|
|
135
|
+
findings.push({ label: r.label });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return findings.length === 0 ? null : findings;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// -- H1 → filename agent prefix --
|
|
142
|
+
|
|
143
|
+
const slugify = (title) =>
|
|
144
|
+
title
|
|
145
|
+
.trim()
|
|
146
|
+
.toLowerCase()
|
|
147
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
148
|
+
.replace(/(^-|-$)/g, "");
|
|
149
|
+
|
|
150
|
+
const summaryAgentMismatch = (s) => {
|
|
151
|
+
const titleSlug = slugify(s.firstLine.match(/^# (.+) — Summary$/)[1]);
|
|
152
|
+
return titleSlug === s.agentPrefix ? null : { titleSlug };
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const weeklyAgentMismatch = (s) => {
|
|
156
|
+
const m = s.firstLine.match(
|
|
157
|
+
/^# (.+) — \d{4}-W\d{2}(?: \(part \d+ of \d+\))?$/,
|
|
158
|
+
);
|
|
159
|
+
if (!m) return null;
|
|
160
|
+
const titleSlug = slugify(m[1]);
|
|
161
|
+
return titleSlug === s.agentPrefix ? null : { titleSlug };
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const AGENT_H3_REQUIREMENTS = STORYBOARD_DOMAIN_AGENTS.map((agent) => ({
|
|
165
|
+
label: agent,
|
|
166
|
+
pattern: new RegExp(`^### ${agent}(\\s|$|—|-)`),
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
const memoryExists = (s) => s.exists;
|
|
170
|
+
const memoryHasPriorityHeader = (s) =>
|
|
171
|
+
s.exists && PRIORITY_HEADER_RE.test(s.text);
|
|
172
|
+
const memoryHasClaimsHeading = (s) =>
|
|
173
|
+
s.exists && ACTIVE_CLAIMS_HEADING_RE.test(s.text);
|
|
174
|
+
const memoryHasClaimsHeader = (s) => CLAIMS_HEADER_RE.test(s.text);
|
|
175
|
+
const storyboardExists = (s) => s.exists;
|
|
176
|
+
|
|
177
|
+
export const RULES = [
|
|
178
|
+
// -- Summary files --
|
|
179
|
+
|
|
180
|
+
{
|
|
181
|
+
id: "summary.last-run-marker",
|
|
182
|
+
scope: "summary",
|
|
183
|
+
severity: "fail",
|
|
184
|
+
check: matches(/^\*\*Last run\*\*:/m),
|
|
185
|
+
message: (s) => `sections: ${s.path} missing '**Last run**:' line`,
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: "summary.first-h2-inbox",
|
|
189
|
+
scope: "summary",
|
|
190
|
+
severity: "fail",
|
|
191
|
+
check: firstH2Is("Message Inbox"),
|
|
192
|
+
message: (s, r) =>
|
|
193
|
+
`sections: ${s.path} first H2 is '${r.observed}', expected 'Message Inbox'`,
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
id: "summary.memo-inbox-marker",
|
|
197
|
+
scope: "summary",
|
|
198
|
+
severity: "fail",
|
|
199
|
+
when: (s) => s.h2s.includes("Message Inbox"),
|
|
200
|
+
check: containsLine(MEMO_INBOX_MARKER),
|
|
201
|
+
message: (s) => `sections: ${s.path} missing <!-- memo:inbox --> marker`,
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
id: "summary.open-blockers-last",
|
|
205
|
+
scope: "summary",
|
|
206
|
+
severity: "fail",
|
|
207
|
+
check: nothingAfterH2("Open Blockers"),
|
|
208
|
+
message: (s, r) =>
|
|
209
|
+
`sections: ${s.path} '${r.observed}' appears after 'Open Blockers'`,
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
id: "summary.line-budget",
|
|
213
|
+
scope: "summary",
|
|
214
|
+
severity: "fail",
|
|
215
|
+
check: lineBudget(SUMMARY_LINE_BUDGET),
|
|
216
|
+
message: (s, r) =>
|
|
217
|
+
`budget: ${s.path} has ${r.value} lines (limit ${SUMMARY_LINE_BUDGET})`,
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
id: "summary.word-budget",
|
|
221
|
+
scope: "summary",
|
|
222
|
+
severity: "fail",
|
|
223
|
+
check: wordBudget(SUMMARY_WORD_BUDGET),
|
|
224
|
+
message: (s, r) =>
|
|
225
|
+
`budget: ${s.path} has ${r.value} words (limit ${SUMMARY_WORD_BUDGET})`,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
id: "summary.h1-agent-matches-filename",
|
|
229
|
+
scope: "summary",
|
|
230
|
+
severity: "fail",
|
|
231
|
+
check: summaryAgentMismatch,
|
|
232
|
+
message: (s, r) =>
|
|
233
|
+
`sections: ${s.path} H1 title slug '${r.titleSlug}' does not match filename prefix '${s.agentPrefix}'`,
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
// -- Weekly logs (main) --
|
|
237
|
+
|
|
238
|
+
{
|
|
239
|
+
id: "weekly-log.h1-shape",
|
|
240
|
+
scope: "weekly-log-main",
|
|
241
|
+
severity: "fail",
|
|
242
|
+
check: firstLineMatches(WEEKLY_LOG_H1_RE),
|
|
243
|
+
message: (s) => `weekly-log: ${s.path} missing valid H1 heading`,
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
id: "weekly-log.line-budget",
|
|
247
|
+
scope: "weekly-log-main",
|
|
248
|
+
severity: "fail",
|
|
249
|
+
check: lineBudget(WEEKLY_LOG_LINE_BUDGET),
|
|
250
|
+
message: (s, r) =>
|
|
251
|
+
`weekly-log: ${s.path} has ${r.value} lines (limit ${WEEKLY_LOG_LINE_BUDGET})`,
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
id: "weekly-log.word-budget",
|
|
255
|
+
scope: "weekly-log-main",
|
|
256
|
+
severity: "fail",
|
|
257
|
+
check: wordBudget(WEEKLY_LOG_WORD_BUDGET),
|
|
258
|
+
message: (s, r) =>
|
|
259
|
+
`weekly-log: ${s.path} has ${r.value} words (limit ${WEEKLY_LOG_WORD_BUDGET})`,
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
id: "weekly-log.h1-agent-matches-filename",
|
|
263
|
+
scope: "weekly-log-main",
|
|
264
|
+
severity: "fail",
|
|
265
|
+
check: weeklyAgentMismatch,
|
|
266
|
+
message: (s, r) =>
|
|
267
|
+
`weekly-log: ${s.path} H1 title slug '${r.titleSlug}' does not match filename prefix '${s.agentPrefix}'`,
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
id: "decision-block.heading-within-5",
|
|
271
|
+
scope: "weekly-log-main",
|
|
272
|
+
severity: "fail",
|
|
273
|
+
check: decisionWithin5({
|
|
274
|
+
entryRe: /^## \d{4}-\d{2}-\d{2}(?:[\s(].*)?$/,
|
|
275
|
+
requiredLine: DECISION_HEADING,
|
|
276
|
+
stopRe: /^##\s/,
|
|
277
|
+
}),
|
|
278
|
+
message: (s, r) =>
|
|
279
|
+
`decision-block: ${s.path}:${r.lineNo} entry lacks leading '### Decision'`,
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
// -- Weekly logs (sealed parts) --
|
|
283
|
+
|
|
284
|
+
{
|
|
285
|
+
id: "weekly-log-part.h1-shape",
|
|
286
|
+
scope: "weekly-log-part",
|
|
287
|
+
severity: "fail",
|
|
288
|
+
check: firstLineMatches(WEEKLY_LOG_H1_RE),
|
|
289
|
+
message: (s) => `weekly-log: ${s.path} missing valid H1 heading`,
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
id: "weekly-log-part.line-budget",
|
|
293
|
+
scope: "weekly-log-part",
|
|
294
|
+
severity: "fail",
|
|
295
|
+
check: lineBudget(WEEKLY_LOG_LINE_BUDGET),
|
|
296
|
+
message: (s, r) =>
|
|
297
|
+
`weekly-log: ${s.path} has ${r.value} lines (limit ${WEEKLY_LOG_LINE_BUDGET})`,
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
id: "weekly-log-part.word-budget",
|
|
301
|
+
scope: "weekly-log-part",
|
|
302
|
+
severity: "fail",
|
|
303
|
+
check: wordBudget(WEEKLY_LOG_WORD_BUDGET),
|
|
304
|
+
message: (s, r) =>
|
|
305
|
+
`weekly-log: ${s.path} has ${r.value} words (limit ${WEEKLY_LOG_WORD_BUDGET})`,
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
id: "weekly-log-part.h1-agent-matches-filename",
|
|
309
|
+
scope: "weekly-log-part",
|
|
310
|
+
severity: "fail",
|
|
311
|
+
check: weeklyAgentMismatch,
|
|
312
|
+
message: (s, r) =>
|
|
313
|
+
`weekly-log: ${s.path} H1 title slug '${r.titleSlug}' does not match filename prefix '${s.agentPrefix}'`,
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
// -- MEMORY.md --
|
|
317
|
+
|
|
318
|
+
{
|
|
319
|
+
id: "memory.file-exists",
|
|
320
|
+
scope: "memory",
|
|
321
|
+
severity: "fail",
|
|
322
|
+
check: exists,
|
|
323
|
+
message: (s) => `memory: ${s.path} not found`,
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
id: "memory.priority-heading",
|
|
327
|
+
scope: "memory",
|
|
328
|
+
severity: "fail",
|
|
329
|
+
when: memoryExists,
|
|
330
|
+
check: matches(PRIORITY_INDEX_HEADING_RE),
|
|
331
|
+
message: () => `memory: missing '${PRIORITY_INDEX_HEADING}' heading`,
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
id: "memory.priority-table-header",
|
|
335
|
+
scope: "memory",
|
|
336
|
+
severity: "fail",
|
|
337
|
+
when: memoryExists,
|
|
338
|
+
check: matches(PRIORITY_HEADER_RE),
|
|
339
|
+
message: () => "memory: missing priority table header row",
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
id: "memory.priority-separator-row",
|
|
343
|
+
scope: "memory",
|
|
344
|
+
severity: "fail",
|
|
345
|
+
when: memoryHasPriorityHeader,
|
|
346
|
+
check: matches(PRIORITY_SEPARATOR_RE),
|
|
347
|
+
message: () =>
|
|
348
|
+
"memory: missing priority table separator row (| --- | --- | --- | --- | --- |)",
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
id: "memory.active-claims-table-header",
|
|
352
|
+
scope: "memory",
|
|
353
|
+
severity: "fail",
|
|
354
|
+
when: memoryHasClaimsHeading,
|
|
355
|
+
check: matches(CLAIMS_HEADER_RE),
|
|
356
|
+
message: () =>
|
|
357
|
+
`active-claims: header mismatch (expected ${ACTIVE_CLAIMS_TABLE_HEADER})`,
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
id: "memory.active-claims-separator-row",
|
|
361
|
+
scope: "memory",
|
|
362
|
+
severity: "fail",
|
|
363
|
+
when: memoryHasClaimsHeader,
|
|
364
|
+
check: matches(CLAIMS_SEPARATOR_RE),
|
|
365
|
+
message: () =>
|
|
366
|
+
"active-claims: missing separator row (| --- | --- | --- | --- | --- | --- |)",
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
// -- Table rows --
|
|
370
|
+
|
|
371
|
+
{
|
|
372
|
+
id: "priority-row.column-count",
|
|
373
|
+
scope: "priority-row",
|
|
374
|
+
severity: "fail",
|
|
375
|
+
check: columnCount(5),
|
|
376
|
+
message: (s, r) =>
|
|
377
|
+
`priority-row: row at line ${s.lineNo} has ${r.actual} cells (expected ${r.expected})`,
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
id: "claims-row.claimed-at-format",
|
|
381
|
+
scope: "claims-row",
|
|
382
|
+
severity: "fail",
|
|
383
|
+
check: fieldMatches("claimed_at", ISO_DATE_RE),
|
|
384
|
+
message: (s, r) =>
|
|
385
|
+
`active-claims: bad claimed_at '${r.value}' for ${s.agent}/${s.target}`,
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
id: "claims-row.expires-at-format",
|
|
389
|
+
scope: "claims-row",
|
|
390
|
+
severity: "fail",
|
|
391
|
+
check: fieldMatches("expires_at", ISO_DATE_RE),
|
|
392
|
+
message: (s, r) =>
|
|
393
|
+
`active-claims: bad expires_at '${r.value}' for ${s.agent}/${s.target}`,
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
id: "expired-claim",
|
|
397
|
+
scope: "claims-row",
|
|
398
|
+
severity: "warn",
|
|
399
|
+
check: expired,
|
|
400
|
+
message: (s) =>
|
|
401
|
+
`expired-claim: ${s.agent}/${s.target} expired ${s.expires_at}`,
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
// -- Storyboards --
|
|
405
|
+
|
|
406
|
+
{
|
|
407
|
+
id: "storyboard.current-month-exists",
|
|
408
|
+
scope: "storyboard",
|
|
409
|
+
severity: "fail",
|
|
410
|
+
check: exists,
|
|
411
|
+
message: (s) =>
|
|
412
|
+
`storyboard: ${s.path} (current month ${s.yearMonth}) not found`,
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
id: "storyboard.agent-h3-required",
|
|
416
|
+
scope: "storyboard",
|
|
417
|
+
severity: "fail",
|
|
418
|
+
when: storyboardExists,
|
|
419
|
+
check: allRequiredLines(AGENT_H3_REQUIREMENTS),
|
|
420
|
+
message: (s, r) => `storyboard: ${s.path} missing '### ${r.label}' H3`,
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
id: "storyboard.markers-balanced.xmr",
|
|
424
|
+
scope: "storyboard",
|
|
425
|
+
severity: "fail",
|
|
426
|
+
when: storyboardExists,
|
|
427
|
+
check: markersBalanced({
|
|
428
|
+
openRe: XMR_OPEN_RE,
|
|
429
|
+
closeRe: XMR_CLOSE_RE,
|
|
430
|
+
label: "xmr",
|
|
431
|
+
}),
|
|
432
|
+
message: (s, r) =>
|
|
433
|
+
`storyboard: ${s.path}:${r.lineNo} ${r.reason} xmr marker${r.label ? ` (${r.label})` : ""}`,
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
id: "storyboard.markers-balanced.issues",
|
|
437
|
+
scope: "storyboard",
|
|
438
|
+
severity: "fail",
|
|
439
|
+
when: storyboardExists,
|
|
440
|
+
check: markersBalanced({
|
|
441
|
+
openRe: ISSUE_OPEN_RE,
|
|
442
|
+
closeRe: ISSUE_CLOSE_RE,
|
|
443
|
+
label: "issue-list",
|
|
444
|
+
}),
|
|
445
|
+
message: (s, r) =>
|
|
446
|
+
`storyboard: ${s.path}:${r.lineNo} ${r.reason} issue-list marker${r.label ? ` (${r.label})` : ""}`,
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
// -- Stray files --
|
|
450
|
+
|
|
451
|
+
{
|
|
452
|
+
id: "wiki.stray-file",
|
|
453
|
+
scope: "stray-file",
|
|
454
|
+
severity: "fail",
|
|
455
|
+
check: always,
|
|
456
|
+
message: (s) =>
|
|
457
|
+
`stray-file: ${s.path} does not match any known scope (summary, weekly log, or excluded prefix)`,
|
|
458
|
+
},
|
|
459
|
+
];
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseClaims } from "../active-claims.js";
|
|
4
|
+
import { PRIORITY_INDEX_HEADING } from "../constants.js";
|
|
5
|
+
|
|
6
|
+
const SUMMARY_H1_RE = /^# [A-Z].* — Summary$/;
|
|
7
|
+
const WEEKLY_LOG_NAME_RE = /^([a-z][a-z-]*)-(\d{4})-W(\d{2})\.md$/;
|
|
8
|
+
const WEEKLY_LOG_PART_NAME_RE = /^([a-z][a-z-]*)-(\d{4})-W(\d{2})-part\d+\.md$/;
|
|
9
|
+
export const WEEKLY_LOG_H1_RE =
|
|
10
|
+
/^# .* — \d{4}-W\d{2}(?: \(part \d+ of \d+\))?$/;
|
|
11
|
+
export const PRIORITY_HEADER_RE =
|
|
12
|
+
/^\|\s*Item\s*\|\s*Agents\s*\|\s*Owner\s*\|\s*Status\s*\|\s*Added\s*\|/m;
|
|
13
|
+
|
|
14
|
+
const EXCLUDED_BASES = new Set(["MEMORY.md", "Home.md", "STATUS.md"]);
|
|
15
|
+
const NON_SUMMARY_PREFIXES = [
|
|
16
|
+
"storyboard-",
|
|
17
|
+
"downstream-",
|
|
18
|
+
"memory-protocol-",
|
|
19
|
+
"kata-interview-",
|
|
20
|
+
"fit-trace-",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function listMdFiles(wikiRoot) {
|
|
24
|
+
if (!existsSync(wikiRoot)) return [];
|
|
25
|
+
return readdirSync(wikiRoot)
|
|
26
|
+
.filter((e) => e.endsWith(".md"))
|
|
27
|
+
.map((e) => path.join(wikiRoot, e));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function countLines(text) {
|
|
31
|
+
return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function countWords(text) {
|
|
35
|
+
let count = 0;
|
|
36
|
+
let inWord = false;
|
|
37
|
+
for (let i = 0; i < text.length; i++) {
|
|
38
|
+
const c = text.charCodeAt(i);
|
|
39
|
+
const isWs = c === 32 || c === 9 || c === 10 || c === 13;
|
|
40
|
+
if (isWs) inWord = false;
|
|
41
|
+
else if (!inWord) {
|
|
42
|
+
inWord = true;
|
|
43
|
+
count++;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return count;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function loadFile(filePath) {
|
|
50
|
+
const text = readFileSync(filePath, "utf-8");
|
|
51
|
+
const fileLines = text.split("\n");
|
|
52
|
+
const h2s = [];
|
|
53
|
+
for (const line of fileLines) {
|
|
54
|
+
const m = line.match(/^## (.+)$/);
|
|
55
|
+
if (m) h2s.push(m[1].trim());
|
|
56
|
+
}
|
|
57
|
+
const base = path.basename(filePath);
|
|
58
|
+
const weekMatch =
|
|
59
|
+
base.match(WEEKLY_LOG_NAME_RE) || base.match(WEEKLY_LOG_PART_NAME_RE);
|
|
60
|
+
return {
|
|
61
|
+
path: filePath,
|
|
62
|
+
text,
|
|
63
|
+
fileLines,
|
|
64
|
+
firstLine: fileLines.find((l) => l.trim() !== "") || "",
|
|
65
|
+
h2s,
|
|
66
|
+
lines: countLines(text),
|
|
67
|
+
words: countWords(text),
|
|
68
|
+
agentPrefix: weekMatch ? weekMatch[1] : base.replace(/\.md$/, ""),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function classifyFile(filePath) {
|
|
73
|
+
const base = path.basename(filePath);
|
|
74
|
+
if (EXCLUDED_BASES.has(base)) return null;
|
|
75
|
+
if (NON_SUMMARY_PREFIXES.some((p) => base.startsWith(p))) return null;
|
|
76
|
+
if (WEEKLY_LOG_NAME_RE.test(base)) {
|
|
77
|
+
return { kind: "weekly-log-main", subject: loadFile(filePath) };
|
|
78
|
+
}
|
|
79
|
+
if (WEEKLY_LOG_PART_NAME_RE.test(base)) {
|
|
80
|
+
return { kind: "weekly-log-part", subject: loadFile(filePath) };
|
|
81
|
+
}
|
|
82
|
+
const subject = loadFile(filePath);
|
|
83
|
+
const kind = SUMMARY_H1_RE.test(subject.firstLine) ? "summary" : "stray";
|
|
84
|
+
return { kind, subject };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function loadMemory(wikiRoot) {
|
|
88
|
+
const filePath = path.join(wikiRoot, "MEMORY.md");
|
|
89
|
+
const exists = existsSync(filePath);
|
|
90
|
+
return {
|
|
91
|
+
path: filePath,
|
|
92
|
+
text: exists ? readFileSync(filePath, "utf-8") : "",
|
|
93
|
+
exists,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function loadStoryboard(wikiRoot, today) {
|
|
98
|
+
const date = new Date(today);
|
|
99
|
+
const yyyy = date.getUTCFullYear();
|
|
100
|
+
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
101
|
+
const filePath = path.join(wikiRoot, `storyboard-${yyyy}-M${mm}.md`);
|
|
102
|
+
const exists = existsSync(filePath);
|
|
103
|
+
const text = exists ? readFileSync(filePath, "utf-8") : "";
|
|
104
|
+
return {
|
|
105
|
+
path: filePath,
|
|
106
|
+
text,
|
|
107
|
+
fileLines: text.split("\n"),
|
|
108
|
+
exists,
|
|
109
|
+
yearMonth: `${yyyy}-M${mm}`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function priorityTableBounds(lines) {
|
|
114
|
+
const start = lines.findIndex((l) => l.trim() === PRIORITY_INDEX_HEADING);
|
|
115
|
+
if (start === -1) return null;
|
|
116
|
+
let end = lines.length;
|
|
117
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
118
|
+
if (/^## /.test(lines[i])) {
|
|
119
|
+
end = i;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { start, end };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseTableRow(line, lineNo) {
|
|
127
|
+
const cells = line
|
|
128
|
+
.split("|")
|
|
129
|
+
.slice(1, -1)
|
|
130
|
+
.map((c) => c.trim());
|
|
131
|
+
if (cells.length === 0 || cells[0] === "*None*") return null;
|
|
132
|
+
return { path: null, lineNo, cells };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parsePriorityRows(memoryText) {
|
|
136
|
+
const lines = memoryText.split("\n");
|
|
137
|
+
const bounds = priorityTableBounds(lines);
|
|
138
|
+
if (bounds === null) return [];
|
|
139
|
+
const rows = [];
|
|
140
|
+
let inTable = false;
|
|
141
|
+
let seenSep = false;
|
|
142
|
+
for (let i = bounds.start + 1; i < bounds.end; i++) {
|
|
143
|
+
const line = lines[i];
|
|
144
|
+
if (PRIORITY_HEADER_RE.test(line)) {
|
|
145
|
+
inTable = true;
|
|
146
|
+
} else if (inTable && /^\|\s*---/.test(line)) {
|
|
147
|
+
seenSep = true;
|
|
148
|
+
} else if (inTable && seenSep && line.startsWith("|")) {
|
|
149
|
+
const row = parseTableRow(line, i + 1);
|
|
150
|
+
if (row) rows.push(row);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return rows;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const SCOPE_RESOLVERS = {
|
|
157
|
+
summary: (ctx) => ctx.subjects.summary,
|
|
158
|
+
"weekly-log-main": (ctx) => ctx.subjects["weekly-log-main"],
|
|
159
|
+
"weekly-log-part": (ctx) => ctx.subjects["weekly-log-part"],
|
|
160
|
+
memory: (ctx) => [ctx.memory],
|
|
161
|
+
"claims-row": (ctx) =>
|
|
162
|
+
parseClaims(ctx.memory.text).map((c) => ({ path: null, ...c })),
|
|
163
|
+
"priority-row": (ctx) => parsePriorityRows(ctx.memory.text),
|
|
164
|
+
storyboard: (ctx) => [ctx.storyboard],
|
|
165
|
+
"stray-file": (ctx) => ctx.subjects.stray,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/** Resolve a scope key into the list of subjects the engine should iterate. */
|
|
169
|
+
export function resolveScope(scopeKey, ctx) {
|
|
170
|
+
const resolver = SCOPE_RESOLVERS[scopeKey];
|
|
171
|
+
if (!resolver) throw new Error(`unknown scope: ${scopeKey}`);
|
|
172
|
+
return resolver(ctx);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Build the audit context: classifies and loads every wiki file once. */
|
|
176
|
+
export function buildContext({ wikiRoot, today }) {
|
|
177
|
+
const subjects = {
|
|
178
|
+
summary: [],
|
|
179
|
+
"weekly-log-main": [],
|
|
180
|
+
"weekly-log-part": [],
|
|
181
|
+
stray: [],
|
|
182
|
+
};
|
|
183
|
+
for (const file of listMdFiles(wikiRoot)) {
|
|
184
|
+
const classified = classifyFile(file);
|
|
185
|
+
if (classified) subjects[classified.kind].push(classified.subject);
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
wikiRoot,
|
|
189
|
+
today,
|
|
190
|
+
subjects,
|
|
191
|
+
memory: loadMemory(wikiRoot),
|
|
192
|
+
storyboard: loadStoryboard(wikiRoot, today),
|
|
193
|
+
};
|
|
194
|
+
}
|
package/src/commands/audit.js
CHANGED
|
@@ -1,354 +1,24 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
2
1
|
import fsAsync from "node:fs/promises";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
import { Finder } from "@forwardimpact/libutil";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
DECISION_HEADING,
|
|
10
|
-
MEMO_INBOX_MARKER,
|
|
11
|
-
PRIORITY_INDEX_HEADING,
|
|
12
|
-
SUMMARY_LINE_BUDGET,
|
|
13
|
-
WEEKLY_LOG_LINE_BUDGET,
|
|
14
|
-
} from "../constants.js";
|
|
15
|
-
import { parseClaims, filterExpired } from "../active-claims.js";
|
|
16
|
-
|
|
17
|
-
const SUMMARY_H1_RE = /^# [A-Z].* — Summary$/;
|
|
18
|
-
const WEEKLY_LOG_NAME_RE = /^[a-z-]+-(\d{4})-W(\d{2})\.md$/;
|
|
19
|
-
const WEEKLY_LOG_PART_NAME_RE = /^[a-z-]+-(\d{4})-W(\d{2})-part\d+\.md$/;
|
|
20
|
-
const WEEKLY_LOG_H1_RE = /^# .* — \d{4}-W\d{2}(?: \(part \d+ of \d+\))?$/;
|
|
21
|
-
const ENTRY_RE = /^## \d{4}-\d{2}-\d{2}(?:[\s(].*)?$/;
|
|
22
|
-
|
|
23
|
-
const EXCLUDED_SUMMARY_BASES = new Set(["MEMORY.md", "Home.md", "STATUS.md"]);
|
|
24
|
-
const NON_SUMMARY_PREFIXES = [
|
|
25
|
-
"storyboard-",
|
|
26
|
-
"downstream-",
|
|
27
|
-
"memory-protocol-",
|
|
28
|
-
"kata-interview-",
|
|
29
|
-
"fit-trace-",
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
function hasNonSummaryPrefix(base) {
|
|
33
|
-
return NON_SUMMARY_PREFIXES.some((p) => base.startsWith(p));
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function isSummaryFile(wikiRoot, filePath) {
|
|
37
|
-
const rel = path.relative(wikiRoot, filePath);
|
|
38
|
-
if (rel.includes(path.sep) || !rel.endsWith(".md")) return false;
|
|
39
|
-
const base = path.basename(rel);
|
|
40
|
-
if (EXCLUDED_SUMMARY_BASES.has(base)) return false;
|
|
41
|
-
if (hasNonSummaryPrefix(base)) return false;
|
|
42
|
-
if (WEEKLY_LOG_NAME_RE.test(base) || WEEKLY_LOG_PART_NAME_RE.test(base)) {
|
|
43
|
-
return false;
|
|
44
|
-
}
|
|
45
|
-
try {
|
|
46
|
-
const text = readFileSync(filePath, "utf-8");
|
|
47
|
-
const firstLine = text.split("\n").find((l) => l.trim() !== "");
|
|
48
|
-
return Boolean(firstLine && SUMMARY_H1_RE.test(firstLine));
|
|
49
|
-
} catch {
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function listMdFiles(wikiRoot) {
|
|
55
|
-
if (!existsSync(wikiRoot)) return [];
|
|
56
|
-
return readdirSync(wikiRoot)
|
|
57
|
-
.filter((e) => e.endsWith(".md"))
|
|
58
|
-
.map((e) => path.join(wikiRoot, e))
|
|
59
|
-
.filter((f) => {
|
|
60
|
-
try {
|
|
61
|
-
return statSync(f).isFile();
|
|
62
|
-
} catch {
|
|
63
|
-
return false;
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function countLines(text) {
|
|
69
|
-
return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function compareIsoWeek(a, b) {
|
|
73
|
-
return a.localeCompare(b);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function isoWeekForFile(base) {
|
|
77
|
-
const match =
|
|
78
|
-
base.match(WEEKLY_LOG_NAME_RE) || base.match(WEEKLY_LOG_PART_NAME_RE);
|
|
79
|
-
if (!match) return null;
|
|
80
|
-
return `${match[1]}-W${match[2]}`;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function pushFail(findings, level, message) {
|
|
84
|
-
findings.push({ level, message });
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function inGraceWindow(graceUntil, today) {
|
|
88
|
-
return Boolean(graceUntil && graceUntil >= today);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function checkSummaryStructure(f, text, fileLines, h2s, findings) {
|
|
92
|
-
const firstLine = fileLines.find((l) => l.trim() !== "");
|
|
93
|
-
if (!firstLine || !SUMMARY_H1_RE.test(firstLine)) {
|
|
94
|
-
pushFail(findings, "fail", `sections: ${f} missing H1 '# ... — Summary'`);
|
|
95
|
-
}
|
|
96
|
-
if (!/^\*\*Last run\*\*:/m.test(text)) {
|
|
97
|
-
pushFail(findings, "fail", `sections: ${f} missing '**Last run**:' line`);
|
|
98
|
-
}
|
|
99
|
-
if (h2s.length > 0 && h2s[0] !== "Message Inbox") {
|
|
100
|
-
pushFail(
|
|
101
|
-
findings,
|
|
102
|
-
"fail",
|
|
103
|
-
`sections: ${f} first H2 is '${h2s[0]}', expected 'Message Inbox'`,
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
if (h2s.indexOf("Message Inbox") !== -1) {
|
|
107
|
-
const markerIdx = fileLines.findIndex(
|
|
108
|
-
(l) => l.trim() === MEMO_INBOX_MARKER,
|
|
109
|
-
);
|
|
110
|
-
if (markerIdx === -1) {
|
|
111
|
-
pushFail(
|
|
112
|
-
findings,
|
|
113
|
-
"fail",
|
|
114
|
-
`sections: ${f} missing <!-- memo:inbox --> marker`,
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function checkSummaryOrdering(f, h2s, findings) {
|
|
121
|
-
let seenBlockers = false;
|
|
122
|
-
for (const h of h2s) {
|
|
123
|
-
if (h === "Open Blockers") {
|
|
124
|
-
seenBlockers = true;
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
127
|
-
if (seenBlockers) {
|
|
128
|
-
pushFail(
|
|
129
|
-
findings,
|
|
130
|
-
"fail",
|
|
131
|
-
`sections: ${f} '${h}' appears after 'Open Blockers'`,
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function checkSummaryFile(f, findings, grace) {
|
|
138
|
-
const text = readFileSync(f, "utf-8");
|
|
139
|
-
const lines = countLines(text);
|
|
140
|
-
if (lines > SUMMARY_LINE_BUDGET) {
|
|
141
|
-
pushFail(
|
|
142
|
-
findings,
|
|
143
|
-
grace ? "warn" : "fail",
|
|
144
|
-
`budget: ${f} has ${lines} lines (limit ${SUMMARY_LINE_BUDGET})`,
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
const fileLines = text.split("\n");
|
|
148
|
-
const h2s = [];
|
|
149
|
-
for (const line of fileLines) {
|
|
150
|
-
const m = line.match(/^## (.+)$/);
|
|
151
|
-
if (m) h2s.push(m[1].trim());
|
|
152
|
-
}
|
|
153
|
-
checkSummaryStructure(f, text, fileLines, h2s, findings);
|
|
154
|
-
checkSummaryOrdering(f, h2s, findings);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function checkSummaries(wikiRoot, files, findings, options) {
|
|
158
|
-
const grace = inGraceWindow(options.graceUntil, options.today);
|
|
159
|
-
for (const f of files) {
|
|
160
|
-
if (!isSummaryFile(wikiRoot, f)) continue;
|
|
161
|
-
checkSummaryFile(f, findings, grace);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function entryHasDecision(allLines, startIdx) {
|
|
166
|
-
let nonBlankSeen = 0;
|
|
167
|
-
for (let j = startIdx + 1; j < allLines.length && nonBlankSeen < 5; j++) {
|
|
168
|
-
const ln = allLines[j];
|
|
169
|
-
if (ln.trim() === "") continue;
|
|
170
|
-
nonBlankSeen++;
|
|
171
|
-
if (ln.trim() === DECISION_HEADING) return true;
|
|
172
|
-
if (/^##\s/.test(ln)) return false;
|
|
173
|
-
}
|
|
174
|
-
return false;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function checkDecisionBlocks(f, text, findings, grace) {
|
|
178
|
-
const allLines = text.split("\n");
|
|
179
|
-
for (let i = 0; i < allLines.length; i++) {
|
|
180
|
-
if (!ENTRY_RE.test(allLines[i])) continue;
|
|
181
|
-
if (entryHasDecision(allLines, i)) continue;
|
|
182
|
-
pushFail(
|
|
183
|
-
findings,
|
|
184
|
-
grace ? "warn" : "fail",
|
|
185
|
-
`decision-block: ${f}:${i + 1} entry lacks leading '### Decision'`,
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function checkWeeklyLogFile(f, base, findings, grace) {
|
|
191
|
-
const isMain = WEEKLY_LOG_NAME_RE.test(base);
|
|
192
|
-
const isPart = WEEKLY_LOG_PART_NAME_RE.test(base);
|
|
193
|
-
if (!isMain && !isPart) return;
|
|
194
|
-
const text = readFileSync(f, "utf-8");
|
|
195
|
-
const firstLine = text.split("\n").find((l) => l.trim() !== "");
|
|
196
|
-
if (!firstLine || !WEEKLY_LOG_H1_RE.test(firstLine)) {
|
|
197
|
-
pushFail(findings, "fail", `weekly-log: ${f} missing valid H1 heading`);
|
|
198
|
-
}
|
|
199
|
-
const week = isoWeekForFile(base);
|
|
200
|
-
const postCutover = week && compareIsoWeek(week, CUTOVER_ISO_WEEK) >= 0;
|
|
201
|
-
const lines = countLines(text);
|
|
202
|
-
if (postCutover && lines > WEEKLY_LOG_LINE_BUDGET) {
|
|
203
|
-
pushFail(
|
|
204
|
-
findings,
|
|
205
|
-
"fail",
|
|
206
|
-
`weekly-log: ${f} has ${lines} lines (limit ${WEEKLY_LOG_LINE_BUDGET}, post-cutover)`,
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
if (isMain && postCutover) {
|
|
210
|
-
checkDecisionBlocks(f, text, findings, grace);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function checkWeeklyLogs(_wikiRoot, files, findings, options) {
|
|
215
|
-
const grace = inGraceWindow(options.graceUntil, options.today);
|
|
216
|
-
for (const f of files) {
|
|
217
|
-
checkWeeklyLogFile(f, path.basename(f), findings, grace);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function checkPriorityIndex(wikiRoot, findings) {
|
|
222
|
-
const memPath = path.join(wikiRoot, "MEMORY.md");
|
|
223
|
-
if (!existsSync(memPath)) {
|
|
224
|
-
pushFail(findings, "fail", `memory: ${memPath} not found`);
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
const text = readFileSync(memPath, "utf-8");
|
|
228
|
-
if (!new RegExp(`^${PRIORITY_INDEX_HEADING}$`, "m").test(text)) {
|
|
229
|
-
pushFail(
|
|
230
|
-
findings,
|
|
231
|
-
"fail",
|
|
232
|
-
"memory: missing '## Cross-Cutting Priorities' heading",
|
|
233
|
-
);
|
|
234
|
-
}
|
|
235
|
-
const headerRe =
|
|
236
|
-
/^\|\s*Item\s*\|\s*Agents\s*\|\s*Owner\s*\|\s*Status\s*\|\s*Added\s*\|/m;
|
|
237
|
-
if (!headerRe.test(text)) {
|
|
238
|
-
pushFail(findings, "fail", "memory: missing priority table header row");
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function findActiveClaimsHeader(lines, headingIdx) {
|
|
243
|
-
for (let i = headingIdx + 1; i < lines.length; i++) {
|
|
244
|
-
if (/^## /.test(lines[i])) return -1;
|
|
245
|
-
if (lines[i].startsWith("|") && /agent/.test(lines[i])) return i;
|
|
246
|
-
}
|
|
247
|
-
return -1;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function checkActiveClaimsRows(claims, findings, today) {
|
|
251
|
-
for (const c of claims) {
|
|
252
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(c.expires_at)) {
|
|
253
|
-
pushFail(
|
|
254
|
-
findings,
|
|
255
|
-
"fail",
|
|
256
|
-
`active-claims: bad expires_at '${c.expires_at}' for ${c.agent}/${c.target}`,
|
|
257
|
-
);
|
|
258
|
-
}
|
|
259
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(c.claimed_at)) {
|
|
260
|
-
pushFail(
|
|
261
|
-
findings,
|
|
262
|
-
"fail",
|
|
263
|
-
`active-claims: bad claimed_at '${c.claimed_at}' for ${c.agent}/${c.target}`,
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
const { expired } = filterExpired(claims, today);
|
|
268
|
-
for (const c of expired) {
|
|
269
|
-
pushFail(
|
|
270
|
-
findings,
|
|
271
|
-
"warn",
|
|
272
|
-
`expired-claim: ${c.agent}/${c.target} expired ${c.expires_at}`,
|
|
273
|
-
);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function checkActiveClaims(wikiRoot, findings, options) {
|
|
278
|
-
const memPath = path.join(wikiRoot, "MEMORY.md");
|
|
279
|
-
if (!existsSync(memPath)) return;
|
|
280
|
-
const text = readFileSync(memPath, "utf-8");
|
|
281
|
-
if (!new RegExp(`^${ACTIVE_CLAIMS_HEADING}$`, "m").test(text)) return;
|
|
282
|
-
const lines = text.split("\n");
|
|
283
|
-
const headingIdx = lines.findIndex((l) => l.trim() === ACTIVE_CLAIMS_HEADING);
|
|
284
|
-
const headerIdx = findActiveClaimsHeader(lines, headingIdx);
|
|
285
|
-
if (
|
|
286
|
-
headerIdx === -1 ||
|
|
287
|
-
lines[headerIdx].trim() !== ACTIVE_CLAIMS_TABLE_HEADER
|
|
288
|
-
) {
|
|
289
|
-
pushFail(
|
|
290
|
-
findings,
|
|
291
|
-
"fail",
|
|
292
|
-
`active-claims: header mismatch (expected ${ACTIVE_CLAIMS_TABLE_HEADER})`,
|
|
293
|
-
);
|
|
294
|
-
}
|
|
295
|
-
checkActiveClaimsRows(parseClaims(text), findings, options.today);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
function emitText(failures, warnings) {
|
|
299
|
-
for (const w of warnings) process.stdout.write(`WARN ${w.message}\n`);
|
|
300
|
-
for (const f of failures) process.stdout.write(`FAIL ${f.message}\n`);
|
|
301
|
-
if (failures.length === 0) {
|
|
302
|
-
process.stdout.write("RESULT: pass\n");
|
|
303
|
-
} else {
|
|
304
|
-
process.stdout.write(`RESULT: fail (${failures.length} checks failed)\n`);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function emitJson(failures, warnings, graceUntil, today) {
|
|
309
|
-
process.stdout.write(
|
|
310
|
-
JSON.stringify(
|
|
311
|
-
{
|
|
312
|
-
result: failures.length === 0 ? "pass" : "fail",
|
|
313
|
-
failures,
|
|
314
|
-
warnings,
|
|
315
|
-
grace_active: inGraceWindow(graceUntil, today),
|
|
316
|
-
grace_until: graceUntil,
|
|
317
|
-
},
|
|
318
|
-
null,
|
|
319
|
-
2,
|
|
320
|
-
) + "\n",
|
|
321
|
-
);
|
|
322
|
-
}
|
|
4
|
+
import { runAudit } from "../audit/engine.js";
|
|
5
|
+
import { RULES } from "../audit/rules.js";
|
|
6
|
+
import { buildContext } from "../audit/scopes.js";
|
|
7
|
+
import { emitJson, emitText } from "../audit/format.js";
|
|
323
8
|
|
|
324
9
|
/** Run the wiki audit and emit findings. JSON via --format json. */
|
|
325
10
|
export function runAuditCommand(values, _args, _cli) {
|
|
326
|
-
const
|
|
327
|
-
const finder = new Finder(fsAsync, logger, process);
|
|
11
|
+
const finder = new Finder(fsAsync, { debug() {} }, process);
|
|
328
12
|
const projectRoot = finder.findProjectRoot(process.cwd());
|
|
329
13
|
const wikiRoot = values["wiki-root"] || path.join(projectRoot, "wiki");
|
|
330
14
|
const today = values.today || new Date().toISOString().slice(0, 10);
|
|
331
|
-
const graceUntil = process.env.FIT_WIKI_AUDIT_GRACE_UNTIL || null;
|
|
332
|
-
const legacyOnly = !!values["legacy-only"];
|
|
333
|
-
|
|
334
|
-
const findings = [];
|
|
335
|
-
const files = listMdFiles(wikiRoot);
|
|
336
15
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
checkPriorityIndex(wikiRoot, findings);
|
|
340
|
-
if (!legacyOnly) {
|
|
341
|
-
checkActiveClaims(wikiRoot, findings, { today });
|
|
342
|
-
}
|
|
16
|
+
const ctx = buildContext({ wikiRoot, today });
|
|
17
|
+
const findings = runAudit(RULES, ctx);
|
|
343
18
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
if ((values.format || "text") === "json") {
|
|
348
|
-
emitJson(failures, warnings, graceUntil, today);
|
|
349
|
-
} else {
|
|
350
|
-
emitText(failures, warnings);
|
|
351
|
-
}
|
|
19
|
+
process.stdout.write(
|
|
20
|
+
values.format === "json" ? emitJson(findings) : emitText(findings),
|
|
21
|
+
);
|
|
352
22
|
|
|
353
|
-
if (
|
|
23
|
+
if (findings.some((f) => f.level === "fail")) process.exit(1);
|
|
354
24
|
}
|
package/src/constants.js
CHANGED
|
@@ -14,8 +14,9 @@ export const PRIORITY_INDEX_TABLE_HEADER =
|
|
|
14
14
|
export const DECISION_HEADING = "### Decision";
|
|
15
15
|
|
|
16
16
|
// Cap derivation: ≤2.5% of a 1M-token context window = 25k tokens;
|
|
17
|
-
// ≈42 tokens/line empirical proxy
|
|
18
|
-
// design-a.md § Decision area 2 for the full anchor.
|
|
19
|
-
export const WEEKLY_LOG_LINE_BUDGET =
|
|
20
|
-
export const SUMMARY_LINE_BUDGET =
|
|
21
|
-
export const
|
|
17
|
+
// converted via ≈42 tokens/line empirical proxy, then 64-aligned.
|
|
18
|
+
// See spec 1060 design-a.md § Decision area 2 for the full anchor.
|
|
19
|
+
export const WEEKLY_LOG_LINE_BUDGET = 496;
|
|
20
|
+
export const SUMMARY_LINE_BUDGET = 72;
|
|
21
|
+
export const WEEKLY_LOG_WORD_BUDGET = 6400;
|
|
22
|
+
export const SUMMARY_WORD_BUDGET = 12800;
|
package/src/index.js
CHANGED
|
@@ -14,7 +14,8 @@ export {
|
|
|
14
14
|
DECISION_HEADING,
|
|
15
15
|
WEEKLY_LOG_LINE_BUDGET,
|
|
16
16
|
SUMMARY_LINE_BUDGET,
|
|
17
|
-
|
|
17
|
+
WEEKLY_LOG_WORD_BUDGET,
|
|
18
|
+
SUMMARY_WORD_BUDGET,
|
|
18
19
|
} from "./constants.js";
|
|
19
20
|
export { scanMarkers } from "./marker-scanner.js";
|
|
20
21
|
export { renderBlock } from "./block-renderer.js";
|
|
@@ -34,3 +35,5 @@ export {
|
|
|
34
35
|
appendEntry,
|
|
35
36
|
} from "./weekly-log.js";
|
|
36
37
|
export { buildDigest } from "./boot.js";
|
|
38
|
+
export { runAudit } from "./audit/engine.js";
|
|
39
|
+
export { RULES } from "./audit/rules.js";
|