@forwardimpact/libwiki 0.2.11 → 0.2.13

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/bin/fit-wiki.js CHANGED
@@ -2,303 +2,66 @@
2
2
 
3
3
  import "@forwardimpact/libpreflight/node22";
4
4
 
5
- import { readFileSync } from "node:fs";
5
+ import { createDefaultRuntime } from "@forwardimpact/libutil/runtime";
6
+ import { GitClient } from "@forwardimpact/libutil/git-client";
7
+ import { createScriptConfig } from "@forwardimpact/libconfig";
6
8
  import { createCli } from "@forwardimpact/libcli";
9
+ import { WikiSync } from "../src/wiki-sync.js";
10
+ import { resolveProjectRoot, resolveWikiRoot } from "../src/util/wiki-dir.js";
11
+ import { createDefinition } from "../src/cli-definition.js";
7
12
 
8
- import { runMemoCommand } from "../src/commands/memo.js";
9
- import { runRefreshCommand } from "../src/commands/refresh.js";
10
- import { runInitCommand } from "../src/commands/init.js";
11
- import { runPushCommand, runPullCommand } from "../src/commands/sync.js";
12
- import { runBootCommand } from "../src/commands/boot.js";
13
- import { runLogCommand } from "../src/commands/log.js";
14
- import { runClaimCommand, runReleaseCommand } from "../src/commands/claim.js";
15
- import { runInboxCommand } from "../src/commands/inbox.js";
16
- import { runRotateCommand } from "../src/commands/rotate.js";
17
- import { runAuditCommand } from "../src/commands/audit.js";
18
- import { runFixCommand } from "../src/commands/fix.js";
19
-
20
- const { version: VERSION } = JSON.parse(
21
- readFileSync(new URL("../package.json", import.meta.url), "utf8"),
22
- );
23
-
24
- const wikiRootOpt = {
25
- "wiki-root": {
26
- type: "string",
27
- description: "Override wiki root directory (default: wiki)",
28
- },
29
- };
30
-
31
- const agentOpt = {
32
- agent: {
33
- type: "string",
34
- description:
35
- "Agent name (falls back to LIBEVAL_AGENT_PROFILE, then staff-engineer)",
36
- default: process.env.LIBEVAL_AGENT_PROFILE || "staff-engineer",
37
- },
38
- };
39
-
40
- const todayOpt = {
41
- today: {
42
- type: "string",
43
- description: "Override today's ISO date (testing)",
44
- },
45
- };
46
-
47
- const definition = {
48
- name: "fit-wiki",
49
- version: VERSION,
50
- description: "Wiki lifecycle management for the Kata agent system",
51
- commands: [
52
- {
53
- name: "boot",
54
- description:
55
- "Print on-boot digest (priorities, claims, storyboard items) as JSON",
56
- options: {
57
- ...agentOpt,
58
- ...wikiRootOpt,
59
- ...todayOpt,
60
- format: {
61
- type: "string",
62
- description: "Output format: json (default) or markdown",
63
- },
64
- },
65
- },
66
- {
67
- name: "log",
68
- description:
69
- "Append a decision/note/done entry to the current weekly log",
70
- args: "[subcommand]",
71
- options: {
72
- ...agentOpt,
73
- ...wikiRootOpt,
74
- ...todayOpt,
75
- surveyed: {
76
- type: "string",
77
- description: "Decision: routing levels surveyed",
78
- },
79
- chosen: { type: "string", description: "Decision: chosen action" },
80
- rationale: { type: "string", description: "Decision: rationale" },
81
- alternatives: { type: "string", description: "Decision: alternatives" },
82
- field: { type: "string", description: "Note: field heading" },
83
- body: { type: "string", description: "Note: field body" },
84
- },
85
- },
86
- {
87
- name: "claim",
88
- description:
89
- "Claim a target in MEMORY.md ## Active Claims (refuses duplicates)",
90
- options: {
91
- ...agentOpt,
92
- ...wikiRootOpt,
93
- ...todayOpt,
94
- target: {
95
- type: "string",
96
- description: "What is being claimed (spec id, PR id, etc.)",
97
- },
98
- branch: { type: "string", description: "Branch carrying the work" },
99
- pr: { type: "string", description: "Optional PR id" },
100
- "expires-at": {
101
- type: "string",
102
- description: "Override expiry ISO date (default claim+7d)",
103
- },
104
- },
105
- },
106
- {
107
- name: "release",
108
- description: "Release a claim (or all expired claims with --expired)",
109
- options: {
110
- ...agentOpt,
111
- ...wikiRootOpt,
112
- ...todayOpt,
113
- target: { type: "string", description: "Target to release" },
114
- expired: {
115
- type: "boolean",
116
- description: "Release every row past expires_at",
117
- },
118
- },
119
- },
120
- {
121
- name: "inbox",
122
- description: "Triage the agent's Message Inbox (list/ack/promote/drop)",
123
- args: "[subcommand]",
124
- options: {
125
- ...agentOpt,
126
- ...wikiRootOpt,
127
- ...todayOpt,
128
- index: {
129
- type: "string",
130
- description: "Bullet index (0-based) for ack/promote/drop",
131
- },
132
- owner: {
133
- type: "string",
134
- description: "Owner field when promoting (default: --agent)",
135
- },
136
- },
137
- },
138
- {
139
- name: "rotate",
140
- description: "Force-rotate the current weekly log to a sealed part",
141
- options: {
142
- ...agentOpt,
143
- ...wikiRootOpt,
144
- ...todayOpt,
145
- },
146
- },
147
- {
148
- name: "audit",
149
- description:
150
- "Audit the wiki against the declarative rule catalogue (line and word budgets, headings, decision blocks, storyboards, claims)",
151
- options: {
152
- ...wikiRootOpt,
153
- ...todayOpt,
154
- format: {
155
- type: "string",
156
- description: "Output format: text (default) or json",
157
- },
158
- },
159
- },
160
- {
161
- name: "fix",
162
- description:
163
- "Auto-fix wiki audit findings using an AI agent (technical-writer, Haiku)",
164
- options: {
165
- ...wikiRootOpt,
166
- ...todayOpt,
167
- },
168
- },
169
- {
170
- name: "memo",
171
- description: "Send a cross-team memo into a teammate's Message Inbox",
172
- options: {
173
- from: {
174
- type: "string",
175
- description:
176
- "Sender agent name (falls back to LIBEVAL_AGENT_PROFILE env var)",
177
- default: process.env.LIBEVAL_AGENT_PROFILE,
178
- },
179
- to: {
180
- type: "string",
181
- description:
182
- 'Target agent name, or "all" to broadcast (sender is skipped)',
183
- },
184
- message: {
185
- type: "string",
186
- description: "Memo text",
187
- },
188
- ...wikiRootOpt,
189
- },
190
- },
191
- {
192
- name: "refresh",
193
- description:
194
- "Regenerate XmR and obstacle/experiment marker blocks in a storyboard",
195
- args: "[storyboard-path]",
196
- options: {
197
- format: {
198
- type: "string",
199
- description: "Output format: (default off) or json",
200
- },
201
- },
202
- },
203
- {
204
- name: "init",
205
- description: "Bootstrap a wiki working tree and scaffold Active Claims",
206
- options: {
207
- ...wikiRootOpt,
208
- "skills-dir": {
209
- type: "string",
210
- description: "Override skills directory (default: .claude/skills)",
211
- },
212
- },
213
- },
214
- {
215
- name: "push",
216
- description: "Commit and push local wiki changes to the remote",
217
- options: { ...wikiRootOpt },
218
- },
219
- {
220
- name: "pull",
221
- description: "Pull remote wiki changes into the local working tree",
222
- options: { ...wikiRootOpt },
223
- },
224
- ],
225
- globalOptions: {
226
- help: { type: "boolean", short: "h", description: "Show this help" },
227
- version: { type: "boolean", description: "Show version" },
228
- json: {
229
- type: "boolean",
230
- description: "Render --help output as JSON",
231
- },
232
- },
233
- examples: [
234
- "fit-wiki boot --agent staff-engineer",
235
- 'fit-wiki log decision --agent staff-engineer --surveyed "..." --chosen "..." --rationale "..."',
236
- "fit-wiki claim --agent staff-engineer --target spec-NNNN --branch claude/...",
237
- "fit-wiki release --agent staff-engineer --target spec-NNNN",
238
- "fit-wiki inbox list --agent staff-engineer",
239
- "fit-wiki rotate --agent staff-engineer",
240
- "fit-wiki audit",
241
- "fit-wiki fix",
242
- 'fit-wiki memo --from staff-engineer --to security-engineer --message "audit d642ff0c"',
243
- "fit-wiki refresh",
244
- "fit-wiki init",
245
- "fit-wiki push",
246
- "fit-wiki pull",
247
- ],
248
- documentation: [
249
- {
250
- title: "Operate a Predictable Agent Team",
251
- url: "https://www.forwardimpact.team/docs/libraries/predictable-team/index.md",
252
- description:
253
- "End-to-end guide to wiki memory, XmR charts, and team coordination.",
254
- },
255
- {
256
- title: "Send a Memo or Update a Storyboard",
257
- url: "https://www.forwardimpact.team/docs/libraries/predictable-team/wiki-operations/index.md",
258
- description:
259
- "Send cross-team memos, refresh storyboard charts, and sync the wiki.",
260
- },
261
- ],
262
- };
263
-
264
- const cli = createCli(definition);
265
-
266
- const COMMANDS = {
267
- boot: runBootCommand,
268
- log: runLogCommand,
269
- claim: runClaimCommand,
270
- release: runReleaseCommand,
271
- inbox: runInboxCommand,
272
- rotate: runRotateCommand,
273
- audit: runAuditCommand,
274
- fix: runFixCommand,
275
- memo: runMemoCommand,
276
- refresh: runRefreshCommand,
277
- init: runInitCommand,
278
- push: runPushCommand,
279
- pull: runPullCommand,
280
- };
13
+ // Commands that mutate or sync the remote wiki need a constructed WikiSync
14
+ // (and its config-backed token resolver); the rest run against the local tree.
15
+ const NEEDS_WIKI_SYNC = new Set(["claim", "release", "push", "pull", "init"]);
281
16
 
282
17
  async function main() {
283
- const parsed = cli.parse(process.argv.slice(2));
284
- if (!parsed) process.exit(0);
285
-
286
- const { values, positionals } = parsed;
287
-
18
+ const runtime = createDefaultRuntime();
19
+ const { version } = JSON.parse(
20
+ runtime.fsSync.readFileSync(
21
+ new URL("../package.json", import.meta.url),
22
+ "utf8",
23
+ ),
24
+ );
25
+ const definition = createDefinition(runtime.proc.env, version);
26
+ const cli = createCli(definition, { runtime });
27
+
28
+ const parsed = cli.parse(runtime.proc.argv.slice(2));
29
+ if (!parsed) return runtime.proc.exit(0); // --help / --version already printed
30
+
31
+ const { positionals } = parsed;
288
32
  if (positionals.length === 0) {
289
33
  cli.showHelp();
290
- process.exit(0);
34
+ return runtime.proc.exit(0);
291
35
  }
292
36
 
293
- const [command, ...args] = positionals;
294
- const handler = COMMANDS[command];
295
-
296
- if (!handler) {
37
+ const command = positionals[0];
38
+ if (!definition.commands.some((c) => c.name === command)) {
297
39
  cli.usageError(`unknown command "${command}"`);
298
- process.exit(2);
40
+ return runtime.proc.exit(2);
299
41
  }
300
42
 
301
- await handler(values, args, cli);
43
+ const gitClient = new GitClient({ runtime });
44
+ let wikiSync;
45
+ if (NEEDS_WIKI_SYNC.has(command)) {
46
+ const projectRoot = resolveProjectRoot(runtime);
47
+ const wikiDir = resolveWikiRoot(runtime, parsed.values);
48
+ const config = await createScriptConfig("wiki");
49
+ wikiSync = new WikiSync({
50
+ runtime,
51
+ gitClient,
52
+ wikiDir,
53
+ parentDir: projectRoot,
54
+ resolveToken: () => config.ghToken(),
55
+ });
56
+ }
57
+
58
+ const result = await cli.dispatch(parsed, {
59
+ deps: { runtime, wikiSync, gitClient },
60
+ });
61
+
62
+ const envelope = result ?? { ok: true };
63
+ if (!envelope.ok && envelope.error) cli.usageError(envelope.error);
64
+ runtime.proc.exit(envelope.ok ? 0 : (envelope.code ?? 1));
302
65
  }
303
66
 
304
67
  main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libwiki",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "description": "Wiki lifecycle primitives — stable memory for agent teams so coordination persists across sessions.",
5
5
  "keywords": [
6
6
  "wiki",
@@ -1,12 +1,13 @@
1
- import { readdirSync, statSync } from "node:fs";
2
1
  import path from "node:path";
3
2
  import { BROADCAST_TARGET } from "./constants.js";
4
3
 
5
- /** List all agent markdown files in the agents directory, returning agent names and summary paths. */
6
- export function listAgents(
7
- { agentsDir, wikiRoot },
8
- fs = { readdirSync, statSync },
9
- ) {
4
+ /**
5
+ * List all agent markdown files in the agents directory, returning agent names
6
+ * and summary paths.
7
+ * @param {{agentsDir: string, wikiRoot: string}} dirs
8
+ * @param {object} fs - Sync filesystem surface (`runtime.fsSync`).
9
+ */
10
+ export function listAgents({ agentsDir, wikiRoot }, fs) {
10
11
  const entries = fs.readdirSync(agentsDir);
11
12
  const agents = [];
12
13
 
@@ -12,6 +12,7 @@ import {
12
12
  WEEKLY_LOG_WORD_BUDGET,
13
13
  } from "../constants.js";
14
14
  import { PRIORITY_HEADER_RE, WEEKLY_LOG_H1_RE } from "./scopes.js";
15
+ import { STATUS_ROW_RULES } from "./status-row.js";
15
16
 
16
17
  const PRIORITY_INDEX_HEADING_RE = new RegExp(
17
18
  `^${PRIORITY_INDEX_HEADING}$`,
@@ -483,6 +484,10 @@ export const RULES = [
483
484
  hint: "every '<!-- obstacles:* -->' or '<!-- experiments:* -->' needs a matching close marker",
484
485
  },
485
486
 
487
+ // -- STATUS.md rows (per-migration-unit sub-row schema) --
488
+
489
+ ...STATUS_ROW_RULES,
490
+
486
491
  // -- Stray files --
487
492
 
488
493
  {
@@ -1,5 +1,5 @@
1
- import { readFileSync, existsSync, readdirSync } from "node:fs";
2
1
  import path from "node:path";
2
+ import { yearMonth } from "@forwardimpact/libutil";
3
3
  import { parseClaims } from "../active-claims.js";
4
4
  import { PRIORITY_INDEX_HEADING } from "../constants.js";
5
5
 
@@ -11,7 +11,7 @@ export const WEEKLY_LOG_H1_RE =
11
11
  export const PRIORITY_HEADER_RE =
12
12
  /^\|\s*Item\s*\|\s*Agents\s*\|\s*Owner\s*\|\s*Status\s*\|\s*Added\s*\|/m;
13
13
 
14
- const EXCLUDED_BASES = new Set(["MEMORY.md", "Home.md", "STATUS.md"]);
14
+ const EXCLUDED_BASES = new Set(["MEMORY.md", "Home.md"]);
15
15
  const NON_SUMMARY_PREFIXES = [
16
16
  "storyboard-",
17
17
  "downstream-",
@@ -20,9 +20,10 @@ const NON_SUMMARY_PREFIXES = [
20
20
  "fit-trace-",
21
21
  ];
22
22
 
23
- function listMdFiles(wikiRoot) {
24
- if (!existsSync(wikiRoot)) return [];
25
- return readdirSync(wikiRoot)
23
+ function listMdFiles(wikiRoot, fs) {
24
+ if (!fs.existsSync(wikiRoot)) return [];
25
+ return fs
26
+ .readdirSync(wikiRoot)
26
27
  .filter((e) => e.endsWith(".md"))
27
28
  .map((e) => path.join(wikiRoot, e));
28
29
  }
@@ -46,8 +47,8 @@ function countWords(text) {
46
47
  return count;
47
48
  }
48
49
 
49
- function loadFile(filePath) {
50
- const text = readFileSync(filePath, "utf-8");
50
+ function loadFile(filePath, fs) {
51
+ const text = fs.readFileSync(filePath, "utf-8");
51
52
  const fileLines = text.split("\n");
52
53
  const h2s = [];
53
54
  for (const line of fileLines) {
@@ -69,44 +70,86 @@ function loadFile(filePath) {
69
70
  };
70
71
  }
71
72
 
72
- function classifyFile(filePath) {
73
+ function classifyFile(filePath, fs) {
73
74
  const base = path.basename(filePath);
74
75
  if (EXCLUDED_BASES.has(base)) return null;
76
+ // STATUS.md is loaded separately (loadStatus) and audited via the dedicated
77
+ // `status-row` scope — skip the per-file classification so it is not treated
78
+ // as a stray file.
79
+ if (base === "STATUS.md") return null;
75
80
  if (NON_SUMMARY_PREFIXES.some((p) => base.startsWith(p))) return null;
76
81
  if (WEEKLY_LOG_NAME_RE.test(base)) {
77
- return { kind: "weekly-log-main", subject: loadFile(filePath) };
82
+ return { kind: "weekly-log-main", subject: loadFile(filePath, fs) };
78
83
  }
79
84
  if (WEEKLY_LOG_PART_NAME_RE.test(base)) {
80
- return { kind: "weekly-log-part", subject: loadFile(filePath) };
85
+ return { kind: "weekly-log-part", subject: loadFile(filePath, fs) };
81
86
  }
82
- const subject = loadFile(filePath);
87
+ const subject = loadFile(filePath, fs);
83
88
  const kind = SUMMARY_H1_RE.test(subject.firstLine) ? "summary" : "stray";
84
89
  return { kind, subject };
85
90
  }
86
91
 
87
- function loadMemory(wikiRoot) {
92
+ function loadMemory(wikiRoot, fs) {
88
93
  const filePath = path.join(wikiRoot, "MEMORY.md");
89
- const exists = existsSync(filePath);
94
+ const exists = fs.existsSync(filePath);
90
95
  return {
91
96
  path: filePath,
92
- text: exists ? readFileSync(filePath, "utf-8") : "",
97
+ text: exists ? fs.readFileSync(filePath, "utf-8") : "",
93
98
  exists,
94
99
  };
95
100
  }
96
101
 
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") : "";
102
+ function loadStatus(wikiRoot, fs) {
103
+ const filePath = path.join(wikiRoot, "STATUS.md");
104
+ const exists = fs.existsSync(filePath);
105
+ return {
106
+ path: filePath,
107
+ text: exists ? fs.readFileSync(filePath, "utf-8") : "",
108
+ exists,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Parse the rows inside STATUS.md's fenced block into audit subjects. Lines
114
+ * outside the ``` fence (header prose) and blank lines are skipped.
115
+ * @param {string} statusText - The full STATUS.md contents.
116
+ * @returns {Array<{lineNo: number, text: string, cells: string[], id: string, phase: string, status: string}>}
117
+ */
118
+ function parseStatusRows(statusText) {
119
+ const lines = statusText.split("\n");
120
+ const rows = [];
121
+ let inFence = false;
122
+ for (let i = 0; i < lines.length; i++) {
123
+ const line = lines[i];
124
+ if (line.trimStart().startsWith("```")) {
125
+ inFence = !inFence;
126
+ continue;
127
+ }
128
+ if (!inFence || line.trim() === "") continue;
129
+ const cells = line.split("\t");
130
+ rows.push({
131
+ lineNo: i + 1,
132
+ text: line,
133
+ cells,
134
+ id: cells[0],
135
+ phase: cells[1],
136
+ status: cells[2],
137
+ });
138
+ }
139
+ return rows;
140
+ }
141
+
142
+ function loadStoryboard(wikiRoot, today, fs) {
143
+ const ym = yearMonth(today);
144
+ const filePath = path.join(wikiRoot, `storyboard-${ym}.md`);
145
+ const exists = fs.existsSync(filePath);
146
+ const text = exists ? fs.readFileSync(filePath, "utf-8") : "";
104
147
  return {
105
148
  path: filePath,
106
149
  text,
107
150
  fileLines: text.split("\n"),
108
151
  exists,
109
- yearMonth: `${yyyy}-M${mm}`,
152
+ yearMonth: ym,
110
153
  lines: countLines(text),
111
154
  words: countWords(text),
112
155
  };
@@ -168,6 +211,11 @@ const SCOPE_RESOLVERS = {
168
211
  path: ctx.memory.path,
169
212
  })),
170
213
  storyboard: (ctx) => [ctx.storyboard],
214
+ "status-row": (ctx) =>
215
+ parseStatusRows(ctx.status.text).map((r) => ({
216
+ ...r,
217
+ path: ctx.status.path,
218
+ })),
171
219
  "stray-file": (ctx) => ctx.subjects.stray,
172
220
  };
173
221
 
@@ -178,23 +226,28 @@ export function resolveScope(scopeKey, ctx) {
178
226
  return resolver(ctx);
179
227
  }
180
228
 
181
- /** Build the audit context: classifies and loads every wiki file once. */
182
- export function buildContext({ wikiRoot, today }) {
229
+ /**
230
+ * Build the audit context: classifies and loads every wiki file once.
231
+ * @param {{wikiRoot: string, today: string, fs: object}} options
232
+ * `fs` is the sync filesystem surface (`runtime.fsSync`).
233
+ */
234
+ export function buildContext({ wikiRoot, today, fs }) {
183
235
  const subjects = {
184
236
  summary: [],
185
237
  "weekly-log-main": [],
186
238
  "weekly-log-part": [],
187
239
  stray: [],
188
240
  };
189
- for (const file of listMdFiles(wikiRoot)) {
190
- const classified = classifyFile(file);
241
+ for (const file of listMdFiles(wikiRoot, fs)) {
242
+ const classified = classifyFile(file, fs);
191
243
  if (classified) subjects[classified.kind].push(classified.subject);
192
244
  }
193
245
  return {
194
246
  wikiRoot,
195
247
  today,
196
248
  subjects,
197
- memory: loadMemory(wikiRoot),
198
- storyboard: loadStoryboard(wikiRoot, today),
249
+ memory: loadMemory(wikiRoot, fs),
250
+ status: loadStatus(wikiRoot, fs),
251
+ storyboard: loadStoryboard(wikiRoot, today, fs),
199
252
  };
200
253
  }
@@ -0,0 +1,51 @@
1
+ import { STATUS_ID_REGEX } from "../status.js";
2
+
3
+ // Validate every row inside wiki/STATUS.md's code fence against the
4
+ // `{id}<TAB>{phase}<TAB>{status}` shape. Rows are resolved by the `status-row`
5
+ // scope in scopes.js; each subject carries `{ cells, id, phase, status, text }`.
6
+
7
+ const PHASES = new Set(["spec", "design", "plan"]);
8
+ const STATUSES = new Set(["draft", "approved", "implemented", "cancelled"]);
9
+
10
+ const hasThreeCells = (s) => s.cells.length === 3;
11
+
12
+ export const STATUS_ROW_RULES = [
13
+ {
14
+ id: "status-row.shape",
15
+ scope: "status-row",
16
+ severity: "fail",
17
+ check: (s) =>
18
+ hasThreeCells(s) ? null : { actual: s.cells.length, text: s.text },
19
+ message: (_s, r) =>
20
+ `${r.actual} tab-separated field(s), expected 3: "${r.text}"`,
21
+ hint: "each STATUS row is `{id}<TAB>{phase}<TAB>{status}`",
22
+ },
23
+ {
24
+ id: "status-row.id-format",
25
+ scope: "status-row",
26
+ severity: "fail",
27
+ when: hasThreeCells,
28
+ check: (s) => (STATUS_ID_REGEX.test(s.id) ? null : { id: s.id }),
29
+ message: (_s, r) => `Bad id '${r.id}' (expected ^\\d{4}(/[a-z0-9-]+)?$)`,
30
+ hint: "spec ids are four digits; a sub-row appends `/<unit>` (e.g. 1370/libutil)",
31
+ },
32
+ {
33
+ id: "status-row.phase",
34
+ scope: "status-row",
35
+ severity: "fail",
36
+ when: hasThreeCells,
37
+ check: (s) => (PHASES.has(s.phase) ? null : { phase: s.phase }),
38
+ message: (_s, r) => `Bad phase '${r.phase}' (expected spec|design|plan)`,
39
+ hint: "phase is one of spec, design, plan",
40
+ },
41
+ {
42
+ id: "status-row.status",
43
+ scope: "status-row",
44
+ severity: "fail",
45
+ when: hasThreeCells,
46
+ check: (s) => (STATUSES.has(s.status) ? null : { status: s.status }),
47
+ message: (_s, r) =>
48
+ `Bad status '${r.status}' (expected draft|approved|implemented|cancelled)`,
49
+ hint: "status is one of draft, approved, implemented, cancelled",
50
+ },
51
+ ];
@@ -1,4 +1,3 @@
1
- import { readFileSync } from "node:fs";
2
1
  import path from "node:path";
3
2
  import { analyze, renderChart, MIN_POINTS } from "@forwardimpact/libxmr";
4
3
 
@@ -11,13 +10,13 @@ export class BlockRenderError extends Error {
11
10
  }
12
11
  }
13
12
 
14
- /** Render an XmR chart block for a metric by reading its CSV and producing markdown lines. */
15
- export function renderBlock({
16
- metric,
17
- csvPath,
18
- projectRoot,
19
- fs = { readFileSync },
20
- }) {
13
+ /**
14
+ * Render an XmR chart block for a metric by reading its CSV and producing
15
+ * markdown lines.
16
+ * @param {{metric: string, csvPath: string, projectRoot: string, fs: object}} options
17
+ * `fs` is the sync filesystem surface (`runtime.fsSync`).
18
+ */
19
+ export function renderBlock({ metric, csvPath, projectRoot, fs }) {
21
20
  const fullPath = path.resolve(projectRoot, csvPath);
22
21
  let csvText;
23
22
  try {