@forwardimpact/libwiki 0.2.6 → 0.2.8

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 CHANGED
@@ -53,8 +53,8 @@ line budget would be exceeded.
53
53
  ### `claim` / `release` — coordinate work
54
54
 
55
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
56
+ npx fit-wiki claim --agent X --target spec-NNNN --branch claude/spec-NNNN
57
+ npx fit-wiki release --agent X --target spec-NNNN
58
58
  npx fit-wiki release --agent X --expired
59
59
  ```
60
60
 
package/bin/fit-wiki.js CHANGED
@@ -222,8 +222,8 @@ const definition = {
222
222
  examples: [
223
223
  "fit-wiki boot --agent staff-engineer",
224
224
  'fit-wiki log decision --agent staff-engineer --surveyed "..." --chosen "..." --rationale "..."',
225
- "fit-wiki claim --agent staff-engineer --target spec-1060 --branch claude/...",
226
- "fit-wiki release --agent staff-engineer --target spec-1060",
225
+ "fit-wiki claim --agent staff-engineer --target spec-NNNN --branch claude/...",
226
+ "fit-wiki release --agent staff-engineer --target spec-NNNN",
227
227
  "fit-wiki inbox list --agent staff-engineer",
228
228
  "fit-wiki rotate --agent staff-engineer",
229
229
  "fit-wiki audit",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libwiki",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Wiki lifecycle primitives — stable memory for agent teams so coordination persists across sessions.",
5
5
  "keywords": [
6
6
  "wiki",
@@ -4,6 +4,8 @@ import {
4
4
  DECISION_HEADING,
5
5
  MEMO_INBOX_MARKER,
6
6
  PRIORITY_INDEX_HEADING,
7
+ STORYBOARD_LINE_BUDGET,
8
+ STORYBOARD_WORD_BUDGET,
7
9
  SUMMARY_LINE_BUDGET,
8
10
  SUMMARY_WORD_BUDGET,
9
11
  WEEKLY_LOG_LINE_BUDGET,
@@ -23,11 +25,15 @@ const CLAIMS_HEADER_RE =
23
25
  const CLAIMS_SEPARATOR_RE =
24
26
  /^\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|/m;
25
27
  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
+ // Marker regexes (mirror of marker-scanner.js): tolerate optional trailing
29
+ // text inside the marker so an open marker can carry an inline notice like
30
+ // "Do not edit. Auto-generated." without breaking the audit's balance check.
31
+ const XMR_OPEN_RE = /^<!--\s*xmr:([^:\s]+):(\S+)(?:\s+[^>]*?)?\s*-->\s*$/;
32
+ const XMR_CLOSE_RE = /^<!--\s*\/xmr(?:\s+[^>]*?)?\s*-->\s*$/;
28
33
  const ISSUE_OPEN_RE =
29
- /^<!--\s*(obstacles|experiments):(open|closed)(?::(\d+d))?\s*-->\s*$/;
30
- const ISSUE_CLOSE_RE = /^<!--\s*\/(obstacles|experiments)\s*-->\s*$/;
34
+ /^<!--\s*(obstacles|experiments):(open|closed)(?::(\d+d))?(?:\s+[^>]*?)?\s*-->\s*$/;
35
+ const ISSUE_CLOSE_RE =
36
+ /^<!--\s*\/(obstacles|experiments)(?:\s+[^>]*?)?\s*-->\s*$/;
31
37
 
32
38
  // improvement-coach is the storyboard facilitator and carries no domain
33
39
  // metrics; only the five domain agents need their own H3.
@@ -182,15 +188,16 @@ export const RULES = [
182
188
  scope: "summary",
183
189
  severity: "fail",
184
190
  check: matches(/^\*\*Last run\*\*:/m),
185
- message: (s) => `sections: ${s.path} missing '**Last run**:' line`,
191
+ message: () => "Missing '**Last run**:' line",
192
+ hint: "add a '**Last run**: <date> — <one-line state>' line directly after the H1",
186
193
  },
187
194
  {
188
195
  id: "summary.first-h2-inbox",
189
196
  scope: "summary",
190
197
  severity: "fail",
191
198
  check: firstH2Is("Message Inbox"),
192
- message: (s, r) =>
193
- `sections: ${s.path} first H2 is '${r.observed}', expected 'Message Inbox'`,
199
+ message: (_s, r) => `First H2 is '${r.observed}', expected 'Message Inbox'`,
200
+ hint: "move '## Message Inbox' to be the first H2 in the file",
194
201
  },
195
202
  {
196
203
  id: "summary.memo-inbox-marker",
@@ -198,31 +205,32 @@ export const RULES = [
198
205
  severity: "fail",
199
206
  when: (s) => s.h2s.includes("Message Inbox"),
200
207
  check: containsLine(MEMO_INBOX_MARKER),
201
- message: (s) => `sections: ${s.path} missing <!-- memo:inbox --> marker`,
208
+ message: () => `Missing ${MEMO_INBOX_MARKER} marker`,
209
+ hint: "add the marker directly below the '## Message Inbox' heading so `fit-wiki memo` can find it",
202
210
  },
203
211
  {
204
212
  id: "summary.open-blockers-last",
205
213
  scope: "summary",
206
214
  severity: "fail",
207
215
  check: nothingAfterH2("Open Blockers"),
208
- message: (s, r) =>
209
- `sections: ${s.path} '${r.observed}' appears after 'Open Blockers'`,
216
+ message: (_s, r) => `'${r.observed}' appears after 'Open Blockers'`,
217
+ hint: "move '## Open Blockers' to the end of the file",
210
218
  },
211
219
  {
212
220
  id: "summary.line-budget",
213
221
  scope: "summary",
214
222
  severity: "fail",
215
223
  check: lineBudget(SUMMARY_LINE_BUDGET),
216
- message: (s, r) =>
217
- `budget: ${s.path} has ${r.value} lines (limit ${SUMMARY_LINE_BUDGET})`,
224
+ message: (_s, r) => `${r.value} lines (limit ${SUMMARY_LINE_BUDGET})`,
225
+ hint: "trim history into the weekly log; the summary holds settled state, not history",
218
226
  },
219
227
  {
220
228
  id: "summary.word-budget",
221
229
  scope: "summary",
222
230
  severity: "fail",
223
231
  check: wordBudget(SUMMARY_WORD_BUDGET),
224
- message: (s, r) =>
225
- `budget: ${s.path} has ${r.value} words (limit ${SUMMARY_WORD_BUDGET})`,
232
+ message: (_s, r) => `${r.value} words (limit ${SUMMARY_WORD_BUDGET})`,
233
+ hint: "trim history into the weekly log; the summary holds settled state, not history",
226
234
  },
227
235
  {
228
236
  id: "summary.h1-agent-matches-filename",
@@ -230,7 +238,8 @@ export const RULES = [
230
238
  severity: "fail",
231
239
  check: summaryAgentMismatch,
232
240
  message: (s, r) =>
233
- `sections: ${s.path} H1 title slug '${r.titleSlug}' does not match filename prefix '${s.agentPrefix}'`,
241
+ `H1 title slug '${r.titleSlug}' does not match filename prefix '${s.agentPrefix}'`,
242
+ hint: "rename either the H1 ('# <agent> — Summary') or the file so they agree",
234
243
  },
235
244
 
236
245
  // -- Weekly logs (main) --
@@ -240,23 +249,24 @@ export const RULES = [
240
249
  scope: "weekly-log-main",
241
250
  severity: "fail",
242
251
  check: firstLineMatches(WEEKLY_LOG_H1_RE),
243
- message: (s) => `weekly-log: ${s.path} missing valid H1 heading`,
252
+ message: () => "Missing valid H1 heading",
253
+ hint: "set the H1 to '# <agent> — YYYY-Www'",
244
254
  },
245
255
  {
246
256
  id: "weekly-log.line-budget",
247
257
  scope: "weekly-log-main",
248
258
  severity: "fail",
249
259
  check: lineBudget(WEEKLY_LOG_LINE_BUDGET),
250
- message: (s, r) =>
251
- `weekly-log: ${s.path} has ${r.value} lines (limit ${WEEKLY_LOG_LINE_BUDGET})`,
260
+ message: (_s, r) => `${r.value} lines (limit ${WEEKLY_LOG_LINE_BUDGET})`,
261
+ hint: "run `bunx fit-wiki rotate` to seal this file as a sealed part and start a fresh weekly log",
252
262
  },
253
263
  {
254
264
  id: "weekly-log.word-budget",
255
265
  scope: "weekly-log-main",
256
266
  severity: "fail",
257
267
  check: wordBudget(WEEKLY_LOG_WORD_BUDGET),
258
- message: (s, r) =>
259
- `weekly-log: ${s.path} has ${r.value} words (limit ${WEEKLY_LOG_WORD_BUDGET})`,
268
+ message: (_s, r) => `${r.value} words (limit ${WEEKLY_LOG_WORD_BUDGET})`,
269
+ hint: "run `bunx fit-wiki rotate` to seal this file as a sealed part and start a fresh weekly log",
260
270
  },
261
271
  {
262
272
  id: "weekly-log.h1-agent-matches-filename",
@@ -264,7 +274,8 @@ export const RULES = [
264
274
  severity: "fail",
265
275
  check: weeklyAgentMismatch,
266
276
  message: (s, r) =>
267
- `weekly-log: ${s.path} H1 title slug '${r.titleSlug}' does not match filename prefix '${s.agentPrefix}'`,
277
+ `H1 title slug '${r.titleSlug}' does not match filename prefix '${s.agentPrefix}'`,
278
+ hint: "rename either the H1 or the file so they agree",
268
279
  },
269
280
  {
270
281
  id: "decision-block.heading-within-5",
@@ -275,8 +286,8 @@ export const RULES = [
275
286
  requiredLine: DECISION_HEADING,
276
287
  stopRe: /^##\s/,
277
288
  }),
278
- message: (s, r) =>
279
- `decision-block: ${s.path}:${r.lineNo} entry lacks leading '### Decision'`,
289
+ message: () => "Entry lacks leading '### Decision'",
290
+ hint: "open each '## YYYY-MM-DD' entry with a '### Decision' subheading; use `bunx fit-wiki log decision` to do this mechanically",
280
291
  },
281
292
 
282
293
  // -- Weekly logs (sealed parts) --
@@ -286,23 +297,24 @@ export const RULES = [
286
297
  scope: "weekly-log-part",
287
298
  severity: "fail",
288
299
  check: firstLineMatches(WEEKLY_LOG_H1_RE),
289
- message: (s) => `weekly-log: ${s.path} missing valid H1 heading`,
300
+ message: () => "Missing valid H1 heading",
301
+ hint: "set the H1 to '# <agent> — YYYY-Www (part N of M)'",
290
302
  },
291
303
  {
292
304
  id: "weekly-log-part.line-budget",
293
305
  scope: "weekly-log-part",
294
306
  severity: "fail",
295
307
  check: lineBudget(WEEKLY_LOG_LINE_BUDGET),
296
- message: (s, r) =>
297
- `weekly-log: ${s.path} has ${r.value} lines (limit ${WEEKLY_LOG_LINE_BUDGET})`,
308
+ message: (_s, r) => `${r.value} lines (limit ${WEEKLY_LOG_LINE_BUDGET})`,
309
+ hint: "sealed parts should already be at-or-under the cap; if not, the rotation that produced this part needs investigation",
298
310
  },
299
311
  {
300
312
  id: "weekly-log-part.word-budget",
301
313
  scope: "weekly-log-part",
302
314
  severity: "fail",
303
315
  check: wordBudget(WEEKLY_LOG_WORD_BUDGET),
304
- message: (s, r) =>
305
- `weekly-log: ${s.path} has ${r.value} words (limit ${WEEKLY_LOG_WORD_BUDGET})`,
316
+ message: (_s, r) => `${r.value} words (limit ${WEEKLY_LOG_WORD_BUDGET})`,
317
+ hint: "sealed parts should already be at-or-under the cap; if not, the rotation that produced this part needs investigation",
306
318
  },
307
319
  {
308
320
  id: "weekly-log-part.h1-agent-matches-filename",
@@ -310,7 +322,8 @@ export const RULES = [
310
322
  severity: "fail",
311
323
  check: weeklyAgentMismatch,
312
324
  message: (s, r) =>
313
- `weekly-log: ${s.path} H1 title slug '${r.titleSlug}' does not match filename prefix '${s.agentPrefix}'`,
325
+ `H1 title slug '${r.titleSlug}' does not match filename prefix '${s.agentPrefix}'`,
326
+ hint: "rename either the H1 or the file so they agree",
314
327
  },
315
328
 
316
329
  // -- MEMORY.md --
@@ -320,7 +333,8 @@ export const RULES = [
320
333
  scope: "memory",
321
334
  severity: "fail",
322
335
  check: exists,
323
- message: (s) => `memory: ${s.path} not found`,
336
+ message: () => "MEMORY.md not found",
337
+ hint: "run `bunx fit-wiki init` to scaffold the canonical sections",
324
338
  },
325
339
  {
326
340
  id: "memory.priority-heading",
@@ -328,7 +342,8 @@ export const RULES = [
328
342
  severity: "fail",
329
343
  when: memoryExists,
330
344
  check: matches(PRIORITY_INDEX_HEADING_RE),
331
- message: () => `memory: missing '${PRIORITY_INDEX_HEADING}' heading`,
345
+ message: () => `Missing '${PRIORITY_INDEX_HEADING}' heading`,
346
+ hint: "add the heading before the cross-cutting priorities table",
332
347
  },
333
348
  {
334
349
  id: "memory.priority-table-header",
@@ -336,7 +351,8 @@ export const RULES = [
336
351
  severity: "fail",
337
352
  when: memoryExists,
338
353
  check: matches(PRIORITY_HEADER_RE),
339
- message: () => "memory: missing priority table header row",
354
+ message: () => "Missing priority table header row",
355
+ hint: "add '| Item | Agents | Owner | Status | Added |' under the priority heading",
340
356
  },
341
357
  {
342
358
  id: "memory.priority-separator-row",
@@ -344,8 +360,8 @@ export const RULES = [
344
360
  severity: "fail",
345
361
  when: memoryHasPriorityHeader,
346
362
  check: matches(PRIORITY_SEPARATOR_RE),
347
- message: () =>
348
- "memory: missing priority table separator row (| --- | --- | --- | --- | --- |)",
363
+ message: () => "Missing priority table separator row",
364
+ hint: "add '| --- | --- | --- | --- | --- |' directly below the header row",
349
365
  },
350
366
  {
351
367
  id: "memory.active-claims-table-header",
@@ -353,8 +369,8 @@ export const RULES = [
353
369
  severity: "fail",
354
370
  when: memoryHasClaimsHeading,
355
371
  check: matches(CLAIMS_HEADER_RE),
356
- message: () =>
357
- `active-claims: header mismatch (expected ${ACTIVE_CLAIMS_TABLE_HEADER})`,
372
+ message: () => `Active claims header mismatch`,
373
+ hint: `expected header row: '${ACTIVE_CLAIMS_TABLE_HEADER}'`,
358
374
  },
359
375
  {
360
376
  id: "memory.active-claims-separator-row",
@@ -362,8 +378,8 @@ export const RULES = [
362
378
  severity: "fail",
363
379
  when: memoryHasClaimsHeader,
364
380
  check: matches(CLAIMS_SEPARATOR_RE),
365
- message: () =>
366
- "active-claims: missing separator row (| --- | --- | --- | --- | --- | --- |)",
381
+ message: () => "Missing active-claims separator row",
382
+ hint: "add '| --- | --- | --- | --- | --- | --- |' directly below the claims header",
367
383
  },
368
384
 
369
385
  // -- Table rows --
@@ -373,32 +389,32 @@ export const RULES = [
373
389
  scope: "priority-row",
374
390
  severity: "fail",
375
391
  check: columnCount(5),
376
- message: (s, r) =>
377
- `priority-row: row at line ${s.lineNo} has ${r.actual} cells (expected ${r.expected})`,
392
+ message: (_s, r) => `${r.actual} cells (expected ${r.expected})`,
393
+ hint: "every priority row needs 5 cells: Item, Agents, Owner, Status, Added",
378
394
  },
379
395
  {
380
396
  id: "claims-row.claimed-at-format",
381
397
  scope: "claims-row",
382
398
  severity: "fail",
383
399
  check: fieldMatches("claimed_at", ISO_DATE_RE),
384
- message: (s, r) =>
385
- `active-claims: bad claimed_at '${r.value}' for ${s.agent}/${s.target}`,
400
+ message: (s, r) => `Bad claimed_at '${r.value}' for ${s.agent}/${s.target}`,
401
+ hint: "claimed_at must be ISO YYYY-MM-DD",
386
402
  },
387
403
  {
388
404
  id: "claims-row.expires-at-format",
389
405
  scope: "claims-row",
390
406
  severity: "fail",
391
407
  check: fieldMatches("expires_at", ISO_DATE_RE),
392
- message: (s, r) =>
393
- `active-claims: bad expires_at '${r.value}' for ${s.agent}/${s.target}`,
408
+ message: (s, r) => `Bad expires_at '${r.value}' for ${s.agent}/${s.target}`,
409
+ hint: "expires_at must be ISO YYYY-MM-DD",
394
410
  },
395
411
  {
396
412
  id: "expired-claim",
397
413
  scope: "claims-row",
398
414
  severity: "warn",
399
415
  check: expired,
400
- message: (s) =>
401
- `expired-claim: ${s.agent}/${s.target} expired ${s.expires_at}`,
416
+ message: (s) => `${s.agent}/${s.target} expired ${s.expires_at}`,
417
+ hint: "run `bunx fit-wiki release --expired` to clear expired claims",
402
418
  },
403
419
 
404
420
  // -- Storyboards --
@@ -408,8 +424,8 @@ export const RULES = [
408
424
  scope: "storyboard",
409
425
  severity: "fail",
410
426
  check: exists,
411
- message: (s) =>
412
- `storyboard: ${s.path} (current month ${s.yearMonth}) not found`,
427
+ message: (s) => `Current-month storyboard (${s.yearMonth}) not found`,
428
+ hint: "create it from `.claude/skills/kata-session/references/storyboard-template.md`",
413
429
  },
414
430
  {
415
431
  id: "storyboard.agent-h3-required",
@@ -417,7 +433,26 @@ export const RULES = [
417
433
  severity: "fail",
418
434
  when: storyboardExists,
419
435
  check: allRequiredLines(AGENT_H3_REQUIREMENTS),
420
- message: (s, r) => `storyboard: ${s.path} missing '### ${r.label}' H3`,
436
+ message: (_s, r) => `Missing '### ${r.label}' H3`,
437
+ hint: "every domain agent gets an H3 under '## Current Condition'",
438
+ },
439
+ {
440
+ id: "storyboard.line-budget",
441
+ scope: "storyboard",
442
+ severity: "fail",
443
+ when: storyboardExists,
444
+ check: lineBudget(STORYBOARD_LINE_BUDGET),
445
+ message: (_s, r) => `${r.value} lines (limit ${STORYBOARD_LINE_BUDGET})`,
446
+ hint: "see per-section word budgets in storyboard-template.md; retire prior-session Headlines/Notes/Next-review entries to weekly logs",
447
+ },
448
+ {
449
+ id: "storyboard.word-budget",
450
+ scope: "storyboard",
451
+ severity: "fail",
452
+ when: storyboardExists,
453
+ check: wordBudget(STORYBOARD_WORD_BUDGET),
454
+ message: (_s, r) => `${r.value} words (limit ${STORYBOARD_WORD_BUDGET})`,
455
+ hint: "see per-section word budgets in storyboard-template.md; retire prior-session Headlines/Notes/Next-review entries to weekly logs",
421
456
  },
422
457
  {
423
458
  id: "storyboard.markers-balanced.xmr",
@@ -429,8 +464,9 @@ export const RULES = [
429
464
  closeRe: XMR_CLOSE_RE,
430
465
  label: "xmr",
431
466
  }),
432
- message: (s, r) =>
433
- `storyboard: ${s.path}:${r.lineNo} ${r.reason} xmr marker${r.label ? ` (${r.label})` : ""}`,
467
+ message: (_s, r) =>
468
+ `${r.reason} xmr marker${r.label ? ` (${r.label})` : ""}`,
469
+ hint: "every '<!-- xmr:metric:csv -->' needs a matching '<!-- /xmr -->'",
434
470
  },
435
471
  {
436
472
  id: "storyboard.markers-balanced.issues",
@@ -442,8 +478,9 @@ export const RULES = [
442
478
  closeRe: ISSUE_CLOSE_RE,
443
479
  label: "issue-list",
444
480
  }),
445
- message: (s, r) =>
446
- `storyboard: ${s.path}:${r.lineNo} ${r.reason} issue-list marker${r.label ? ` (${r.label})` : ""}`,
481
+ message: (_s, r) =>
482
+ `${r.reason} issue-list marker${r.label ? ` (${r.label})` : ""}`,
483
+ hint: "every '<!-- obstacles:* -->' or '<!-- experiments:* -->' needs a matching close marker",
447
484
  },
448
485
 
449
486
  // -- Stray files --
@@ -453,7 +490,7 @@ export const RULES = [
453
490
  scope: "stray-file",
454
491
  severity: "fail",
455
492
  check: always,
456
- message: (s) =>
457
- `stray-file: ${s.path} does not match any known scope (summary, weekly log, or excluded prefix)`,
493
+ message: () => "Does not match any known scope",
494
+ hint: "rename to a recognized scope (summary, weekly log, weekly-log part) or remove the file",
458
495
  },
459
496
  ];
@@ -107,6 +107,8 @@ function loadStoryboard(wikiRoot, today) {
107
107
  fileLines: text.split("\n"),
108
108
  exists,
109
109
  yearMonth: `${yyyy}-M${mm}`,
110
+ lines: countLines(text),
111
+ words: countWords(text),
110
112
  };
111
113
  }
112
114
 
@@ -159,8 +161,12 @@ const SCOPE_RESOLVERS = {
159
161
  "weekly-log-part": (ctx) => ctx.subjects["weekly-log-part"],
160
162
  memory: (ctx) => [ctx.memory],
161
163
  "claims-row": (ctx) =>
162
- parseClaims(ctx.memory.text).map((c) => ({ path: null, ...c })),
163
- "priority-row": (ctx) => parsePriorityRows(ctx.memory.text),
164
+ parseClaims(ctx.memory.text).map((c) => ({ ...c, path: ctx.memory.path })),
165
+ "priority-row": (ctx) =>
166
+ parsePriorityRows(ctx.memory.text).map((r) => ({
167
+ ...r,
168
+ path: ctx.memory.path,
169
+ })),
164
170
  storyboard: (ctx) => [ctx.storyboard],
165
171
  "stray-file": (ctx) => ctx.subjects.stray,
166
172
  };
@@ -32,11 +32,8 @@ export function renderBlock({
32
32
  throw new BlockRenderError(`metric-not-found: ${metric}`);
33
33
  }
34
34
 
35
- const latestValue = m.latest?.value ?? m.values[m.values.length - 1] ?? "—";
36
- const status = m.status;
37
-
38
35
  let chartLines;
39
- if (status === "insufficient_data") {
36
+ if (m.status === "insufficient_data") {
40
37
  chartLines = [
41
38
  `Insufficient data: ${m.n} points (need at least ${MIN_POINTS}).`,
42
39
  ];
@@ -45,16 +42,12 @@ export function renderBlock({
45
42
  chartLines = chartText.split("\n");
46
43
  }
47
44
 
48
- const signalLine = formatSignals(m.signals);
49
-
50
45
  return [
51
- `**Latest:** ${latestValue} · **Status:** ${status}`,
52
- "",
53
46
  "```",
54
47
  ...chartLines,
55
48
  "```",
56
49
  "",
57
- `**Signals:** ${signalLine}`,
50
+ `**Signals:** ${formatSignals(m.signals)}`,
58
51
  ];
59
52
  }
60
53
 
@@ -1,10 +1,13 @@
1
1
  import fsAsync from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { Finder } from "@forwardimpact/libutil";
4
- import { runAudit } from "../audit/engine.js";
3
+ import {
4
+ Finder,
5
+ emitFindingsJson,
6
+ emitFindingsText,
7
+ runRules,
8
+ } from "@forwardimpact/libutil";
5
9
  import { RULES } from "../audit/rules.js";
6
- import { buildContext } from "../audit/scopes.js";
7
- import { emitJson, emitText } from "../audit/format.js";
10
+ import { buildContext, resolveScope } from "../audit/scopes.js";
8
11
 
9
12
  /** Run the wiki audit and emit findings. JSON via --format json. */
10
13
  export function runAuditCommand(values, _args, _cli) {
@@ -14,10 +17,15 @@ export function runAuditCommand(values, _args, _cli) {
14
17
  const today = values.today || new Date().toISOString().slice(0, 10);
15
18
 
16
19
  const ctx = buildContext({ wikiRoot, today });
17
- const findings = runAudit(RULES, ctx);
20
+ const findings = runRules(RULES, ctx, { resolveScope });
18
21
 
19
22
  process.stdout.write(
20
- values.format === "json" ? emitJson(findings) : emitText(findings),
23
+ values.format === "json"
24
+ ? emitFindingsJson(findings)
25
+ : emitFindingsText(findings, {
26
+ cwd: projectRoot,
27
+ passMessage: "wiki audit passed",
28
+ }),
21
29
  );
22
30
 
23
31
  if (findings.some((f) => f.level === "fail")) process.exit(1);
@@ -1,10 +1,12 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
2
3
  import path from "node:path";
3
4
  import fsAsync from "node:fs/promises";
4
5
  import { Finder } from "@forwardimpact/libutil";
6
+ import { createScriptConfig } from "@forwardimpact/libconfig";
5
7
  import { scanMarkers } from "../marker-scanner.js";
6
8
  import { renderBlock, BlockRenderError } from "../block-renderer.js";
7
- import { renderIssueList } from "../issue-list-renderer.js";
9
+ import { renderIssueList, parseRepoSlug } from "../issue-list-renderer.js";
8
10
 
9
11
  function currentStoryboardPath() {
10
12
  const now = new Date();
@@ -13,7 +15,17 @@ function currentStoryboardPath() {
13
15
  return `wiki/storyboard-${yyyy}-M${mm}.md`;
14
16
  }
15
17
 
16
- function renderForBlock(block, projectRoot) {
18
+ function deriveParentRepo(parentDir) {
19
+ if (process.env.FIT_GH_REPO) return process.env.FIT_GH_REPO;
20
+ const r = spawnSync("git", ["-C", parentDir, "remote", "get-url", "origin"], {
21
+ encoding: "utf-8",
22
+ stdio: "pipe",
23
+ });
24
+ if (r.status !== 0) return null;
25
+ return parseRepoSlug(r.stdout);
26
+ }
27
+
28
+ function renderForBlock(block, projectRoot, ghContext) {
17
29
  if (block.kind === "xmr") {
18
30
  return renderBlock({
19
31
  metric: block.metric,
@@ -26,6 +38,9 @@ function renderForBlock(block, projectRoot) {
26
38
  topic: block.topic,
27
39
  state: block.state,
28
40
  window: block.window,
41
+ cwd: ghContext.cwd,
42
+ repo: ghContext.repo,
43
+ token: ghContext.token,
29
44
  });
30
45
  }
31
46
  return null;
@@ -40,7 +55,7 @@ function spliceBlock(lines, block, rendered) {
40
55
  }
41
56
 
42
57
  /** Re-render XmR chart blocks and issue-list blocks in a storyboard file. */
43
- export function runRefreshCommand(values, args, _cli) {
58
+ export async function runRefreshCommand(values, args, _cli) {
44
59
  const logger = { debug() {} };
45
60
  const finder = new Finder(fsAsync, logger, process);
46
61
  const projectRoot = finder.findProjectRoot(process.cwd());
@@ -53,13 +68,33 @@ export function runRefreshCommand(values, args, _cli) {
53
68
  const blocks = scanMarkers(text);
54
69
  if (blocks.length === 0) return;
55
70
 
71
+ const config = await createScriptConfig("wiki");
72
+ let token = null;
73
+ try {
74
+ token = config.ghToken();
75
+ } catch {
76
+ // Missing token is non-fatal; issue-list renders will fail with a stderr
77
+ // warning and the block will collapse to the notice line.
78
+ }
79
+ // Spawn `gh` from the project root so it resolves the monorepo's origin
80
+ // instead of whatever git context the caller's cwd happens to be in (the
81
+ // wiki sibling repo, a subagent worktree, a service dir, etc.). Also
82
+ // resolve an explicit owner/repo slug so `gh` works when origin has been
83
+ // rewritten to a proxy URL (sandbox environments) — `FIT_GH_REPO` env
84
+ // overrides the parsed origin.
85
+ const ghContext = {
86
+ cwd: projectRoot,
87
+ repo: deriveParentRepo(projectRoot),
88
+ token,
89
+ };
90
+
56
91
  const lines = text.split("\n");
57
92
  let spliced = false;
58
93
 
59
94
  for (let i = blocks.length - 1; i >= 0; i--) {
60
95
  const block = blocks[i];
61
96
  try {
62
- const rendered = renderForBlock(block, projectRoot);
97
+ const rendered = renderForBlock(block, projectRoot, ghContext);
63
98
  if (!rendered) continue;
64
99
  spliceBlock(lines, block, rendered);
65
100
  spliced = true;
package/src/constants.js CHANGED
@@ -13,10 +13,14 @@ export const PRIORITY_INDEX_TABLE_HEADER =
13
13
  "| Item | Agents | Owner | Status | Added |";
14
14
  export const DECISION_HEADING = "### Decision";
15
15
 
16
- // Cap derivation: ≤2.5% of a 1M-token context window = 25k tokens;
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.
16
+ // Unified budgets for the three audited surfaces (summary, weekly-log,
17
+ // storyboard). They share the same numeric limits today so the
18
+ // context-tax floor is symmetric across surfaces; each surface keeps
19
+ // its own audit rule pair so the limits can diverge later if the
20
+ // context-tax model says one surface should be looser or tighter.
21
+ export const SUMMARY_LINE_BUDGET = 496;
22
+ export const SUMMARY_WORD_BUDGET = 2048;
19
23
  export const WEEKLY_LOG_LINE_BUDGET = 496;
20
- export const SUMMARY_LINE_BUDGET = 72;
21
24
  export const WEEKLY_LOG_WORD_BUDGET = 6400;
22
- export const SUMMARY_WORD_BUDGET = 12800;
25
+ export const STORYBOARD_LINE_BUDGET = 496;
26
+ export const STORYBOARD_WORD_BUDGET = 6400;
package/src/index.js CHANGED
@@ -35,5 +35,5 @@ export {
35
35
  appendEntry,
36
36
  } from "./weekly-log.js";
37
37
  export { buildDigest } from "./boot.js";
38
- export { runAudit } from "./audit/engine.js";
39
38
  export { RULES } from "./audit/rules.js";
39
+ export { resolveScope as resolveAuditScope } from "./audit/scopes.js";
@@ -1,9 +1,14 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
 
3
- function defaultGh(args) {
3
+ function defaultGh(args, options) {
4
+ const env = options?.token
5
+ ? { ...process.env, GH_TOKEN: options.token }
6
+ : undefined;
4
7
  return spawnSync("gh", args, {
5
8
  encoding: "utf-8",
6
9
  stdio: ["ignore", "pipe", "pipe"],
10
+ cwd: options?.cwd,
11
+ env,
7
12
  });
8
13
  }
9
14
 
@@ -13,18 +18,30 @@ function daysAgo(today, n) {
13
18
  return d.toISOString().slice(0, 10);
14
19
  }
15
20
 
16
- /** Render an issue-list block for an obstacles/experiments marker. Returns markdown lines. */
21
+ /** Parse `owner/repo` from a git origin URL. Tolerates http(s), ssh, and proxy-rewritten URLs (e.g. `http://host/git/owner/repo`) by taking the last two path segments after stripping `.git`. Returns null when nothing parseable is found. */
22
+ export function parseRepoSlug(originUrl) {
23
+ if (!originUrl) return null;
24
+ const stripped = originUrl.trim().replace(/\.git$/, "");
25
+ const match = stripped.match(/([^/:]+)\/([^/:]+)$/);
26
+ if (!match) return null;
27
+ return `${match[1]}/${match[2]}`;
28
+ }
29
+
30
+ /** Render an issue-list block for an obstacles/experiments marker. Returns markdown lines. `cwd` should be the parent monorepo's project root so `gh` resolves the correct origin; `repo` is an explicit `owner/name` slug used when the origin remote is unparseable by `gh` (e.g. sandbox proxy URLs); `token` is the resolved GH token (e.g. via `Config.ghToken()`). */
17
31
  export function renderIssueList({
18
32
  topic,
19
33
  state,
20
34
  window,
35
+ cwd,
36
+ repo,
37
+ token,
21
38
  today = new Date(),
22
39
  gh = defaultGh,
23
40
  }) {
24
41
  const ghState = state === "closed" ? "closed" : "open";
25
- const result = gh([
26
- "issue",
27
- "list",
42
+ const args = ["issue", "list"];
43
+ if (repo) args.push("--repo", repo);
44
+ args.push(
28
45
  "--label",
29
46
  topic.replace(/s$/, ""),
30
47
  "--state",
@@ -33,7 +50,8 @@ export function renderIssueList({
33
50
  "number,title,labels,closedAt",
34
51
  "--limit",
35
52
  "100",
36
- ]);
53
+ );
54
+ const result = gh(args, { cwd, token });
37
55
  if (result.status !== 0) {
38
56
  process.stderr.write(
39
57
  `refresh: gh issue list failed for ${topic}:${state}\n`,
@@ -62,8 +80,7 @@ export function renderIssueList({
62
80
 
63
81
  const lines = [];
64
82
  for (const issue of issues) {
65
- const tag = topic === "experiments" ? "Exp" : "Obs";
66
- lines.push(`- **${tag} #${issue.number} — ${issue.title}**`);
83
+ lines.push(`- #${issue.number} ${issue.title}`);
67
84
  }
68
85
  return lines;
69
86
  }
@@ -1,8 +1,12 @@
1
- const XMR_OPEN_RE = /^<!--\s*xmr:([^:\s]+):([^\s]+)\s*-->\s*$/;
1
+ // Markers tolerate optional trailing text after the tag (typically an inline
2
+ // "Do not edit. Generated from fit-wiki refresh." notice), so an open or close
3
+ // marker can carry its own warning without needing a separate notice line.
4
+ const XMR_OPEN_RE = /^<!--\s*xmr:([^:\s]+):(\S+)(?:\s+[^>]*?)?\s*-->\s*$/;
2
5
  const ISSUE_OPEN_RE =
3
- /^<!--\s*(obstacles|experiments):(open|closed)(?::(\d+d))?\s*-->\s*$/;
4
- const XMR_CLOSE_RE = /^<!--\s*\/xmr\s*-->\s*$/;
5
- const ISSUE_CLOSE_RE = /^<!--\s*\/(obstacles|experiments)\s*-->\s*$/;
6
+ /^<!--\s*(obstacles|experiments):(open|closed)(?::(\d+d))?(?:\s+[^>]*?)?\s*-->\s*$/;
7
+ const XMR_CLOSE_RE = /^<!--\s*\/xmr(?:\s+[^>]*?)?\s*-->\s*$/;
8
+ const ISSUE_CLOSE_RE =
9
+ /^<!--\s*\/(obstacles|experiments)(?:\s+[^>]*?)?\s*-->\s*$/;
6
10
 
7
11
  function openLabel(open) {
8
12
  return open.kind === "xmr" ? open.metric : open.topic;
@@ -1,36 +0,0 @@
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
- }
@@ -1,39 +0,0 @@
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
- }