@forwardimpact/libwiki 0.2.24 → 0.2.26

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.
@@ -13,6 +13,7 @@ import {
13
13
  SUMMARY_LINE_BUDGET,
14
14
  SUMMARY_WORD_BUDGET,
15
15
  WEEKLY_LOG_LINE_BUDGET,
16
+ WEEKLY_LOG_SEAM_RE,
16
17
  WEEKLY_LOG_WORD_BUDGET,
17
18
  XMR_CLOSE_RE,
18
19
  XMR_OPEN_RE,
@@ -22,6 +23,7 @@ import {
22
23
  SUMMARY_H1_RE,
23
24
  WEEKLY_LOG_H1_RE,
24
25
  } from "./scopes.js";
26
+ import { CONFLICT_MARKER_RULE } from "./conflict-markers-rule.js";
25
27
  import { STATUS_ROW_RULES } from "./status-row.js";
26
28
 
27
29
  const PRIORITY_INDEX_HEADING_RE = new RegExp(
@@ -105,6 +107,22 @@ const decisionWithin5 =
105
107
  return offenders.length === 0 ? null : offenders;
106
108
  };
107
109
 
110
+ // Flag entry-shaped `## ` headings that the rotation seam-finder would skip —
111
+ // the grammar-drift that degrades a whole file to one unsplittable prologue and
112
+ // is otherwise silent (the decision-block rule matches only dated headings).
113
+ // Uses the same WEEKLY_LOG_SEAM_RE the seam-finder uses, so the flagged set is
114
+ // exactly the complement of the rotatable set.
115
+ const headingGrammarDrift = (s) => {
116
+ const offenders = [];
117
+ for (let i = 0; i < s.fileLines.length; i++) {
118
+ const line = s.fileLines[i];
119
+ if (/^## /.test(line) && !WEEKLY_LOG_SEAM_RE.test(line)) {
120
+ offenders.push({ lineNo: i + 1, observed: line.trim() });
121
+ }
122
+ }
123
+ return offenders.length === 0 ? null : offenders;
124
+ };
125
+
108
126
  function scanMarkers(fileLines, openRe, closeRe, label) {
109
127
  const openings = [];
110
128
  const findings = [];
@@ -170,6 +188,25 @@ const AGENT_H3_REQUIREMENTS = STORYBOARD_DOMAIN_AGENTS.map((agent) => ({
170
188
  pattern: new RegExp(`^### ${agent}(\\s|$|—|-)`),
171
189
  }));
172
190
 
191
+ // -- Metrics CSV duplicate rows --
192
+
193
+ // Report every data line byte-identical to an earlier data line in the same
194
+ // CSV. Line 1 is the header (positionally) and blank lines are skipped; the
195
+ // header is never a duplicate subject. Keying on exact line equality gives the
196
+ // spec's exit path for free: any column edit (run id or note) on one row makes
197
+ // the pair non-identical and stops the finding firing.
198
+ const duplicateCsvRows = (s) => {
199
+ const seen = new Set();
200
+ const findings = [];
201
+ s.rows.forEach((text, i) => {
202
+ const lineNo = i + 1;
203
+ if (lineNo === 1 || text.trim() === "") return;
204
+ if (seen.has(text)) findings.push({ lineNo });
205
+ else seen.add(text);
206
+ });
207
+ return findings.length === 0 ? null : findings;
208
+ };
209
+
173
210
  const memoryExists = (s) => s.exists;
174
211
  const memoryHasPriorityHeader = (s) =>
175
212
  s.exists && PRIORITY_HEADER_RE.test(s.text);
@@ -257,7 +294,8 @@ export const RULES = [
257
294
  remediation: "rotate",
258
295
  check: lineBudget(WEEKLY_LOG_LINE_BUDGET),
259
296
  message: (_s, r) => `${r.value} lines (limit ${WEEKLY_LOG_LINE_BUDGET})`,
260
- hint: "run `bunx fit-wiki rotate --agent <agent>` (agent = this filename's prefix) to seal this file as a sealed part and start a fresh weekly log",
297
+ hint: (s) =>
298
+ `run \`bunx fit-wiki rotate --agent ${s.agentPrefix}\` to seal this file as a sealed part and start a fresh weekly log`,
261
299
  },
262
300
  {
263
301
  id: "weekly-log.word-budget",
@@ -266,7 +304,8 @@ export const RULES = [
266
304
  remediation: "rotate",
267
305
  check: wordBudget(WEEKLY_LOG_WORD_BUDGET),
268
306
  message: (_s, r) => `${r.value} words (limit ${WEEKLY_LOG_WORD_BUDGET})`,
269
- hint: "run `bunx fit-wiki rotate --agent <agent>` (agent = this filename's prefix) to seal this file as a sealed part and start a fresh weekly log",
307
+ hint: (s) =>
308
+ `run \`bunx fit-wiki rotate --agent ${s.agentPrefix}\` to seal this file as a sealed part and start a fresh weekly log`,
270
309
  },
271
310
  {
272
311
  id: "weekly-log.h1-agent-matches-filename",
@@ -277,6 +316,15 @@ export const RULES = [
277
316
  `H1 title slug '${r.titleSlug}' does not match filename prefix '${s.agentPrefix}'`,
278
317
  hint: "rename either the H1 or the file so they agree",
279
318
  },
319
+ {
320
+ id: "weekly-log.heading-grammar",
321
+ scope: "weekly-log-main",
322
+ severity: "fail",
323
+ check: headingGrammarDrift,
324
+ message: (_s, r) =>
325
+ `Entry heading '${r.observed}' does not match the dated grammar`,
326
+ hint: "weekly-log entry headings must be '## YYYY-MM-DD'; open entries with `fit-wiki log decision/note`, which emit a conforming heading the rotation seam-finder can split",
327
+ },
280
328
  {
281
329
  id: "decision-block.heading-within-5",
282
330
  scope: "weekly-log-main",
@@ -290,7 +338,7 @@ export const RULES = [
290
338
  r.nearMiss
291
339
  ? `Entry opens with '${r.nearMiss}'; the heading must be exactly '${DECISION_HEADING}' — move the suffix into the body`
292
340
  : `Entry lacks a line that is exactly '${DECISION_HEADING}'`,
293
- hint: `open each '## YYYY-MM-DD' entry with a line containing exactly '${DECISION_HEADING}' (no suffix — the check is an exact match); put the one-line summary in the body below it, drawn from the entry's own narrative — do not invent rationale the entry does not support`,
341
+ hint: `open each '## YYYY-MM-DD' entry with \`fit-wiki log decision\`, which emits a line containing exactly '${DECISION_HEADING}' (no suffix — the check is an exact match); put the one-line summary in the body below it, drawn from the entry's own narrative — do not invent rationale the entry does not support`,
294
342
  },
295
343
 
296
344
  // -- Weekly logs (sealed parts) --
@@ -303,6 +351,15 @@ export const RULES = [
303
351
  message: () => "Missing valid H1 heading",
304
352
  hint: "set the H1 to '# <agent> — YYYY-Www (part N of M)'",
305
353
  },
354
+ {
355
+ id: "weekly-log-part.heading-grammar",
356
+ scope: "weekly-log-part",
357
+ severity: "fail",
358
+ check: headingGrammarDrift,
359
+ message: (_s, r) =>
360
+ `Entry heading '${r.observed}' does not match the dated grammar`,
361
+ hint: "weekly-log entry headings must be '## YYYY-MM-DD'; open entries with `fit-wiki log decision/note`, which emit a conforming heading the rotation seam-finder can split",
362
+ },
306
363
  {
307
364
  id: "weekly-log-part.line-budget",
308
365
  scope: "weekly-log-part",
@@ -310,7 +367,7 @@ export const RULES = [
310
367
  remediation: "rotate",
311
368
  check: lineBudget(WEEKLY_LOG_LINE_BUDGET),
312
369
  message: (_s, r) => `${r.value} lines (limit ${WEEKLY_LOG_LINE_BUDGET})`,
313
- hint: "`bunx fit-wiki fix` re-bisects an over-budget part with day-section seams; an irreducible single-day section that alone exceeds the budget must be split at a finer seam by hand",
370
+ hint: "`bunx fit-wiki fix` re-bisects an over-budget part at its day-section seams and, for a lone over-cap day, at its '### ' block seams; only a single '### ' block that alone exceeds the budget remains for a human to shorten",
314
371
  },
315
372
  {
316
373
  id: "weekly-log-part.word-budget",
@@ -319,7 +376,7 @@ export const RULES = [
319
376
  remediation: "rotate",
320
377
  check: wordBudget(WEEKLY_LOG_WORD_BUDGET),
321
378
  message: (_s, r) => `${r.value} words (limit ${WEEKLY_LOG_WORD_BUDGET})`,
322
- hint: "`bunx fit-wiki fix` re-bisects an over-budget part with day-section seams; an irreducible single-day section that alone exceeds the budget must be split at a finer seam by hand",
379
+ hint: "`bunx fit-wiki fix` re-bisects an over-budget part at its day-section seams and, for a lone over-cap day, at its '### ' block seams; only a single '### ' block that alone exceeds the budget remains for a human to shorten",
323
380
  },
324
381
  {
325
382
  id: "weekly-log-part.h1-agent-matches-filename",
@@ -488,7 +545,41 @@ export const RULES = [
488
545
  hint: "every '<!-- obstacles:* -->' or '<!-- experiments:* -->' needs a matching close marker",
489
546
  },
490
547
 
548
+ // -- Metrics CSVs (union merge keeps both sides on concurrent appends;
549
+ // exact-duplicate rows are surfaced here, never silently removed) --
550
+
551
+ {
552
+ id: "metrics-csv.duplicate-row",
553
+ scope: "metrics-csv",
554
+ severity: "fail",
555
+ check: duplicateCsvRows,
556
+ message: (_s, r) =>
557
+ `Duplicate metrics row at line ${r.lineNo} (exact match of an earlier row)`,
558
+ hint: "remove the surplus row, or differentiate a genuinely-distinct measurement by editing its run id or note so the rows are no longer identical",
559
+ },
560
+
491
561
  // -- STATUS.md rows (per-migration-unit sub-row schema) --
492
562
 
493
563
  ...STATUS_ROW_RULES,
564
+
565
+ // -- Conflict markers (structural; all audited surfaces) --
566
+
567
+ CONFLICT_MARKER_RULE,
568
+
569
+ // -- Filename admission --
570
+
571
+ // The `admission` resolver yields one subject per git-tracked path the
572
+ // filename grammar rejects, so the check always fires. Flag-for-human: a
573
+ // wrong automated move or delete destroys memory, so `fix` routes this to the
574
+ // human report (any non-`agent` remediation class does) and never touches the
575
+ // file.
576
+ {
577
+ id: "admission.not-in-grammar",
578
+ scope: "admission",
579
+ severity: "fail",
580
+ remediation: "flag",
581
+ check: () => ({}),
582
+ message: (s) => `${s.relPath} matches no wiki filename grammar class`,
583
+ hint: "rename to an admitted class, or extend the Wiki Filename Grammar section in memory-protocol.md and audit/grammar.js together (the single admission path)",
584
+ },
494
585
  ];
@@ -7,6 +7,8 @@ import {
7
7
  WEEKLY_LOG_NAME_RE,
8
8
  WEEKLY_LOG_PART_NAME_RE,
9
9
  } from "../constants.js";
10
+ import { listAdmissionPaths } from "./admission.js";
11
+ import { classifyPath, rootSummaryStem } from "./grammar.js";
10
12
 
11
13
  // Capture the agent-title group so the same regex both classifies a file
12
14
  // (`.test`) and yields the title for the agent-prefix audit (`.match[1]`).
@@ -33,6 +35,34 @@ function listMdFiles(wikiRoot, fs) {
33
35
  .map((e) => path.join(wikiRoot, e));
34
36
  }
35
37
 
38
+ // Recursively collect every *.csv under `<wikiRoot>/metrics/`. The real layout
39
+ // is `metrics/<skill>/<year>.csv` (two levels), so the walk recurses rather
40
+ // than assuming a fixed depth. Uses readdirSync + statSync (rather than
41
+ // `withFileTypes` Dirents) so it runs unchanged under the in-memory mock fs.
42
+ function listCsvFiles(wikiRoot, fs) {
43
+ const metricsRoot = path.join(wikiRoot, "metrics");
44
+ if (!fs.existsSync(metricsRoot)) return [];
45
+ const found = [];
46
+ const walk = (dir) => {
47
+ for (const name of fs.readdirSync(dir)) {
48
+ const full = path.join(dir, name);
49
+ if (fs.statSync(full).isDirectory()) walk(full);
50
+ else if (name.endsWith(".csv")) found.push(full);
51
+ }
52
+ };
53
+ walk(metricsRoot);
54
+ return found;
55
+ }
56
+
57
+ // Load a metrics CSV as an audit subject: `rows` is the array of line strings,
58
+ // so a rule indexes `rows[i]` (a string) and `i + 1` is its line number.
59
+ function loadCsv(filePath, fs) {
60
+ return {
61
+ path: filePath,
62
+ rows: fs.readFileSync(filePath, "utf-8").split("\n"),
63
+ };
64
+ }
65
+
36
66
  function loadFile(filePath, fs) {
37
67
  const text = fs.readFileSync(filePath, "utf-8");
38
68
  const fileLines = text.split("\n");
@@ -177,6 +207,7 @@ const SCOPE_RESOLVERS = {
177
207
  summary: (ctx) => ctx.subjects.summary,
178
208
  "weekly-log-main": (ctx) => ctx.subjects["weekly-log-main"],
179
209
  "weekly-log-part": (ctx) => ctx.subjects["weekly-log-part"],
210
+ "metrics-csv": (ctx) => ctx.subjects["metrics-csv"],
180
211
  memory: (ctx) => [ctx.memory],
181
212
  "claims-row": (ctx) =>
182
213
  parseClaims(ctx.memory.text).map((c) => ({ ...c, path: ctx.memory.path })),
@@ -191,8 +222,54 @@ const SCOPE_RESOLVERS = {
191
222
  ...r,
192
223
  path: ctx.status.path,
193
224
  })),
225
+ "conflict-scan": (ctx) => conflictScanSubjects(ctx),
226
+ admission: (ctx) =>
227
+ ctx.admission.paths
228
+ .filter((p) => classifyPath(p, ctx.admission) === "rejected")
229
+ .map((relPath) => ({
230
+ path: path.join(ctx.wikiRoot, relPath),
231
+ relPath,
232
+ })),
194
233
  };
195
234
 
235
+ // Normalize every audited surface into a uniform `{ path, text, fenceExempt }`
236
+ // subject for the conflict-marker scan. The per-file subjects (summaries,
237
+ // weekly logs and sealed parts, storyboard) carry `fileLines`; MEMORY.md and
238
+ // STATUS.md carry `text` (readOptional shape). `fenceExempt` is true for prose
239
+ // surfaces, where a fence quotes content, and false for STATUS.md, whose fenced
240
+ // rows are data — a marker there is never legitimate (per-surface fence
241
+ // contract). Files absent from disk (missing MEMORY/STATUS/storyboard) yield
242
+ // empty text and produce no findings.
243
+ function conflictScanSubjects(ctx) {
244
+ const subjects = [];
245
+ const fileScopes = ["summary", "weekly-log-main", "weekly-log-part"];
246
+ for (const scope of fileScopes) {
247
+ for (const s of ctx.subjects[scope]) {
248
+ subjects.push({
249
+ path: s.path,
250
+ text: s.fileLines.join("\n"),
251
+ fenceExempt: true,
252
+ });
253
+ }
254
+ }
255
+ subjects.push({
256
+ path: ctx.storyboard.path,
257
+ text: ctx.storyboard.text,
258
+ fenceExempt: true,
259
+ });
260
+ subjects.push({
261
+ path: ctx.memory.path,
262
+ text: ctx.memory.text,
263
+ fenceExempt: true,
264
+ });
265
+ subjects.push({
266
+ path: ctx.status.path,
267
+ text: ctx.status.text,
268
+ fenceExempt: false,
269
+ });
270
+ return subjects;
271
+ }
272
+
196
273
  /** Resolve a scope key into the list of subjects the engine should iterate. */
197
274
  export function resolveScope(scopeKey, ctx) {
198
275
  const resolver = SCOPE_RESOLVERS[scopeKey];
@@ -200,21 +277,48 @@ export function resolveScope(scopeKey, ctx) {
200
277
  return resolver(ctx);
201
278
  }
202
279
 
280
+ /**
281
+ * Build the admission slice: the tracked-file universe plus the
282
+ * `rootSummaryAgents` set that gates `<agent>/` sidecar directories. The agent
283
+ * set is derived first (a root-level summary-class file's stem) so the
284
+ * `admission` scope can classify sidecar directories against it.
285
+ *
286
+ * Returns the empty universe when `subprocess` is absent — callers that only
287
+ * read `.subjects` (the rotation pre-pass) skip the git read and the tree walk
288
+ * entirely, and produce no `admission` findings.
289
+ */
290
+ function buildAdmission(wikiRoot, fs, subprocess) {
291
+ if (!subprocess) return { paths: [], rootSummaryAgents: new Set() };
292
+ const paths = listAdmissionPaths({ wikiRoot, fs, subprocess });
293
+ const rootSummaryAgents = new Set();
294
+ for (const p of paths) {
295
+ if (p.includes("/")) continue; // root-level files only
296
+ const stem = rootSummaryStem(p);
297
+ if (stem) rootSummaryAgents.add(stem);
298
+ }
299
+ return { paths, rootSummaryAgents };
300
+ }
301
+
203
302
  /**
204
303
  * Build the audit context: classifies and loads every wiki file once.
205
- * @param {{wikiRoot: string, today: string, fs: object}} options
206
- * `fs` is the sync filesystem surface (`runtime.fsSync`).
304
+ * @param {{wikiRoot: string, today: string, fs: object, subprocess: object}} options
305
+ * `fs` is the sync filesystem surface (`runtime.fsSync`); `subprocess` is
306
+ * `runtime.subprocess` (its `runSync` backs the admission scope's git read).
207
307
  */
208
- export function buildContext({ wikiRoot, today, fs }) {
308
+ export function buildContext({ wikiRoot, today, fs, subprocess }) {
209
309
  const subjects = {
210
310
  summary: [],
211
311
  "weekly-log-main": [],
212
312
  "weekly-log-part": [],
313
+ "metrics-csv": [],
213
314
  };
214
315
  for (const file of listMdFiles(wikiRoot, fs)) {
215
316
  const classified = classifyFile(file, fs);
216
317
  if (classified) subjects[classified.kind].push(classified.subject);
217
318
  }
319
+ for (const file of listCsvFiles(wikiRoot, fs)) {
320
+ subjects["metrics-csv"].push(loadCsv(file, fs));
321
+ }
218
322
  return {
219
323
  wikiRoot,
220
324
  today,
@@ -222,5 +326,6 @@ export function buildContext({ wikiRoot, today, fs }) {
222
326
  memory: readOptional(path.join(wikiRoot, "MEMORY.md"), fs),
223
327
  status: readOptional(path.join(wikiRoot, "STATUS.md"), fs),
224
328
  storyboard: loadStoryboard(wikiRoot, today, fs),
329
+ admission: buildAdmission(wikiRoot, fs, subprocess),
225
330
  };
226
331
  }
@@ -13,10 +13,17 @@ export class BlockRenderError extends Error {
13
13
  /**
14
14
  * Render an XmR chart block for a metric by reading its CSV and producing
15
15
  * markdown lines.
16
- * @param {{metric: string, csvPath: string, projectRoot: string, fs: object}} options
17
- * `fs` is the sync filesystem surface (`runtime.fsSync`).
16
+ * @param {{metric: string, csvPath: string, projectRoot: string, fs: object, priorReadAnchor?: string|null}} options
17
+ * `fs` is the sync filesystem surface (`runtime.fsSync`). `priorReadAnchor`,
18
+ * when supplied, stamps per-signal provenance surfaced in the Signals line.
18
19
  */
19
- export function renderBlock({ metric, csvPath, projectRoot, fs }) {
20
+ export function renderBlock({
21
+ metric,
22
+ csvPath,
23
+ projectRoot,
24
+ fs,
25
+ priorReadAnchor,
26
+ }) {
20
27
  const fullPath = path.resolve(projectRoot, csvPath);
21
28
  let csvText;
22
29
  try {
@@ -24,7 +31,7 @@ export function renderBlock({ metric, csvPath, projectRoot, fs }) {
24
31
  } catch {
25
32
  throw new BlockRenderError(`csv-not-found: ${csvPath}`);
26
33
  }
27
- const report = analyze(csvText);
34
+ const report = analyze(csvText, { priorReadAnchor });
28
35
 
29
36
  const m = report.metrics.find((entry) => entry.metric === metric);
30
37
  if (!m) {
@@ -50,11 +57,18 @@ export function renderBlock({ metric, csvPath, projectRoot, fs }) {
50
57
  ];
51
58
  }
52
59
 
60
+ // Annotate each fired rule with its records' provenance when present, so a
61
+ // storyboard reader distinguishes recomputation-revealed signals from
62
+ // new-point signals at the cell. Without a prior-read anchor, provenance is
63
+ // absent and the line renders bare rule names exactly as before.
53
64
  function formatSignals(signals) {
54
65
  if (!signals) return "—";
55
66
  const fired = [];
56
67
  for (const rule of ["xRule1", "xRule2", "xRule3", "mrRule1"]) {
57
- if (signals[rule]?.length > 0) fired.push(rule);
68
+ const recs = signals[rule];
69
+ if (!recs?.length) continue;
70
+ const tags = [...new Set(recs.map((r) => r.provenance).filter(Boolean))];
71
+ fired.push(tags.length ? `${rule} (${tags.join(", ")})` : rule);
58
72
  }
59
73
  return fired.length > 0 ? fired.join(", ") : "—";
60
74
  }
package/src/boot.js CHANGED
@@ -1,7 +1,16 @@
1
1
  import path from "node:path";
2
2
  import { yearMonth } from "@forwardimpact/libutil";
3
3
  import { parseClaims, filterExpired } from "./active-claims.js";
4
- import { MEMO_INBOX_MARKER, PRIORITY_INDEX_HEADING } from "./constants.js";
4
+ import { countLines, countWords } from "./budget.js";
5
+ import {
6
+ MEMO_INBOX_MARKER,
7
+ PRIORITY_INDEX_HEADING,
8
+ SUMMARY_LINE_BUDGET,
9
+ SUMMARY_WORD_BUDGET,
10
+ WEEKLY_LOG_LINE_BUDGET,
11
+ WEEKLY_LOG_WORD_BUDGET,
12
+ } from "./constants.js";
13
+ import { weeklyLogPath } from "./weekly-log.js";
5
14
 
6
15
  function readIfExists(fs, filePath) {
7
16
  if (!fs.existsSync(filePath)) return null;
@@ -133,6 +142,24 @@ function countInbox(text) {
133
142
  return n;
134
143
  }
135
144
 
145
+ /**
146
+ * Remaining budget for a budgeted surface: current value, cap, and headroom for
147
+ * both the line and word budget. An absent file (empty text) reports zero usage
148
+ * and full headroom so a writer sees the ceiling before composing.
149
+ */
150
+ function headroom(text, lineCap, wordCap) {
151
+ const lines = countLines(text);
152
+ const words = countWords(text);
153
+ return {
154
+ words,
155
+ lines,
156
+ word_cap: wordCap,
157
+ line_cap: lineCap,
158
+ words_remaining: wordCap - words,
159
+ lines_remaining: lineCap - lines,
160
+ };
161
+ }
162
+
136
163
  function mapPriority(r) {
137
164
  return { item: r.item, status: r.status, added: r.added, link: r.link };
138
165
  }
@@ -162,6 +189,7 @@ export function buildDigest({ wikiRoot, agent, today, fs }) {
162
189
  const summaryText = readIfExists(fs, summaryPath);
163
190
  const memoryText = readIfExists(fs, memoryPath);
164
191
  const storyboardText = readIfExists(fs, storyboardPath);
192
+ const weeklyLogText = readIfExists(fs, weeklyLogPath(wikiRoot, agent, today));
165
193
 
166
194
  const { active } = filterExpired(parseClaims(memoryText ?? ""), today);
167
195
  const { owned, cross } = splitPriorities(
@@ -176,6 +204,16 @@ export function buildDigest({ wikiRoot, agent, today, fs }) {
176
204
  claims: active.map(mapClaim),
177
205
  storyboard_items: parseStoryboardItems(storyboardText ?? "", agent),
178
206
  inbox_count: countInbox(summaryText),
207
+ summary_headroom: headroom(
208
+ summaryText ?? "",
209
+ SUMMARY_LINE_BUDGET,
210
+ SUMMARY_WORD_BUDGET,
211
+ ),
212
+ weekly_log_headroom: headroom(
213
+ weeklyLogText ?? "",
214
+ WEEKLY_LOG_LINE_BUDGET,
215
+ WEEKLY_LOG_WORD_BUDGET,
216
+ ),
179
217
  storyboard_path: fs.existsSync(storyboardPath)
180
218
  ? path.relative(path.dirname(wikiRoot) || ".", storyboardPath)
181
219
  : "",
@@ -11,18 +11,17 @@ import { runAuditCommand } from "./commands/audit.js";
11
11
  import { runFixCommand } from "./commands/fix.js";
12
12
 
13
13
  /**
14
- * Build the `fit-wiki` libcli definition. The agent/sender defaults read the
15
- * injected `env` rather than the ambient `process.env` so this module carries
16
- * no ambient dependency; the bin shim passes `runtime.proc.env`. The version is
17
- * resolved by libcli's `createCli` from the bin's `packageJsonUrl`. Each
18
- * subcommand carries a `handler` and (for subcommand-bearing commands)
19
- * `args`/`argsUsage` so `cli.dispatch` can route to the per-command handler
20
- * with a frozen `ctx`.
14
+ * Build the `fit-wiki` libcli definition. Agent identity is never resolved from
15
+ * the environment: agent-scoped subcommands require an explicit `--agent`
16
+ * (`--from` for `memo`) and fail closed without it, so this module carries no
17
+ * ambient agent identity. The version is resolved by libcli's `createCli` from
18
+ * the bin's `packageJsonUrl`. Each subcommand carries a `handler` and (for
19
+ * subcommand-bearing commands) `args`/`argsUsage` so `cli.dispatch` can route to
20
+ * the per-command handler with a frozen `ctx`.
21
21
  *
22
- * @param {Record<string, string>} env - The process env (`runtime.proc.env`).
23
22
  * @returns {object} The libcli definition.
24
23
  */
25
- export function createDefinition(env) {
24
+ export function createDefinition() {
26
25
  const wikiRootOpt = {
27
26
  "wiki-root": {
28
27
  type: "string",
@@ -33,9 +32,7 @@ export function createDefinition(env) {
33
32
  const agentOpt = {
34
33
  agent: {
35
34
  type: "string",
36
- description:
37
- "Agent name (falls back to LIBEVAL_AGENT_PROFILE, then staff-engineer)",
38
- default: env.LIBEVAL_AGENT_PROFILE || "staff-engineer",
35
+ description: "Agent name (required; no environment fallback)",
39
36
  },
40
37
  };
41
38
 
@@ -148,12 +145,17 @@ export function createDefinition(env) {
148
145
  },
149
146
  {
150
147
  name: "rotate",
151
- description: "Force-rotate the current weekly log to a sealed part",
148
+ description: "Rotate the current weekly log to a sealed part",
152
149
  handler: runRotateCommand,
153
150
  options: {
154
151
  ...agentOpt,
155
152
  ...wikiRootOpt,
156
153
  ...todayOpt,
154
+ force: {
155
+ type: "boolean",
156
+ description:
157
+ "Seal even an under-budget log (the header-only floor still holds)",
158
+ },
157
159
  },
158
160
  },
159
161
  {
@@ -188,8 +190,7 @@ export function createDefinition(env) {
188
190
  from: {
189
191
  type: "string",
190
192
  description:
191
- "Sender agent name (falls back to LIBEVAL_AGENT_PROFILE env var)",
192
- default: env.LIBEVAL_AGENT_PROFILE,
193
+ "Sender agent name (required; no environment fallback)",
193
194
  },
194
195
  to: {
195
196
  type: "string",
@@ -239,7 +240,7 @@ export function createDefinition(env) {
239
240
  name: "pull",
240
241
  description: "Pull remote wiki changes into the local working tree",
241
242
  handler: runPullCommand,
242
- options: { ...wikiRootOpt },
243
+ options: { ...agentOpt, ...wikiRootOpt, ...todayOpt },
243
244
  },
244
245
  ],
245
246
  globalOptions: {
@@ -17,7 +17,12 @@ export function runAuditCommand(ctx) {
17
17
  const wikiRoot = options["wiki-root"] || path.join(projectRoot, "wiki");
18
18
  const today = options.today || currentDayIso(runtime);
19
19
 
20
- const auditCtx = buildContext({ wikiRoot, today, fs: runtime.fsSync });
20
+ const auditCtx = buildContext({
21
+ wikiRoot,
22
+ today,
23
+ fs: runtime.fsSync,
24
+ subprocess: runtime.subprocess,
25
+ });
21
26
  const findings = runRules(RULES, auditCtx, { resolveScope });
22
27
 
23
28
  runtime.proc.stdout.write(
@@ -1,5 +1,6 @@
1
1
  import { buildDigest } from "../boot.js";
2
2
  import { currentDayIso } from "../util/clock.js";
3
+ import { requireAgentFlag } from "../util/agent-flag.js";
3
4
  import { resolveWikiRoot } from "../util/wiki-dir.js";
4
5
 
5
6
  function renderMarkdown(digest) {
@@ -43,8 +44,12 @@ function renderMarkdown(digest) {
43
44
  export function runBootCommand(ctx) {
44
45
  const { runtime } = ctx.deps;
45
46
  const options = ctx.options;
46
- const agent =
47
- options.agent || runtime.proc.env.LIBEVAL_AGENT_PROFILE || "staff-engineer";
47
+ const resolved = requireAgentFlag(options, {
48
+ command: "boot",
49
+ example: "fit-wiki boot --agent staff-engineer",
50
+ });
51
+ if (!resolved.ok) return resolved;
52
+ const agent = resolved.agent;
48
53
 
49
54
  const wikiRoot = resolveWikiRoot(runtime, options);
50
55
  const today = options.today || currentDayIso(runtime);