@forwardimpact/libwiki 0.2.25 → 0.2.27

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.
@@ -2,11 +2,14 @@ import path from "node:path";
2
2
  import { yearMonth } from "@forwardimpact/libutil";
3
3
  import { parseClaims } from "../active-claims.js";
4
4
  import { countLines, countWords } from "../budget.js";
5
+ import { parseStatusRowId } from "../status.js";
5
6
  import {
6
7
  PRIORITY_INDEX_HEADING,
7
8
  WEEKLY_LOG_NAME_RE,
8
9
  WEEKLY_LOG_PART_NAME_RE,
9
10
  } from "../constants.js";
11
+ import { listAdmissionPaths } from "./admission.js";
12
+ import { classifyPath, rootSummaryStem } from "./grammar.js";
10
13
 
11
14
  // Capture the agent-title group so the same regex both classifies a file
12
15
  // (`.test`) and yields the title for the agent-prefix audit (`.match[1]`).
@@ -33,6 +36,34 @@ function listMdFiles(wikiRoot, fs) {
33
36
  .map((e) => path.join(wikiRoot, e));
34
37
  }
35
38
 
39
+ // Recursively collect every *.csv under `<wikiRoot>/metrics/`. The real layout
40
+ // is `metrics/<skill>/<year>.csv` (two levels), so the walk recurses rather
41
+ // than assuming a fixed depth. Uses readdirSync + statSync (rather than
42
+ // `withFileTypes` Dirents) so it runs unchanged under the in-memory mock fs.
43
+ function listCsvFiles(wikiRoot, fs) {
44
+ const metricsRoot = path.join(wikiRoot, "metrics");
45
+ if (!fs.existsSync(metricsRoot)) return [];
46
+ const found = [];
47
+ const walk = (dir) => {
48
+ for (const name of fs.readdirSync(dir)) {
49
+ const full = path.join(dir, name);
50
+ if (fs.statSync(full).isDirectory()) walk(full);
51
+ else if (name.endsWith(".csv")) found.push(full);
52
+ }
53
+ };
54
+ walk(metricsRoot);
55
+ return found;
56
+ }
57
+
58
+ // Load a metrics CSV as an audit subject: `rows` is the array of line strings,
59
+ // so a rule indexes `rows[i]` (a string) and `i + 1` is its line number.
60
+ function loadCsv(filePath, fs) {
61
+ return {
62
+ path: filePath,
63
+ rows: fs.readFileSync(filePath, "utf-8").split("\n"),
64
+ };
65
+ }
66
+
36
67
  function loadFile(filePath, fs) {
37
68
  const text = fs.readFileSync(filePath, "utf-8");
38
69
  const fileLines = text.split("\n");
@@ -90,9 +121,12 @@ function readOptional(filePath, fs) {
90
121
 
91
122
  /**
92
123
  * Parse the rows inside STATUS.md's fenced block into audit subjects. Lines
93
- * outside the ``` fence (header prose) and blank lines are skipped.
124
+ * outside the ``` fence (header prose) and blank lines are skipped. Each row
125
+ * carries a `kind` from {@link parseStatusRowId} (`"spec"`, `"experiment"`, or
126
+ * `null` for an unrecognized id); spec-shaped rules read the positional
127
+ * `id`/`phase`/`status` fields, experiment rules read `cells`.
94
128
  * @param {string} statusText - The full STATUS.md contents.
95
- * @returns {Array<{lineNo: number, text: string, cells: string[], id: string, phase: string, status: string}>}
129
+ * @returns {Array<{lineNo: number, text: string, cells: string[], id: string, phase: string, status: string, kind: string|null}>}
96
130
  */
97
131
  function parseStatusRows(statusText) {
98
132
  const lines = statusText.split("\n");
@@ -106,6 +140,12 @@ function parseStatusRows(statusText) {
106
140
  }
107
141
  if (!inFence || line.trim() === "") continue;
108
142
  const cells = line.split("\t");
143
+ // Classify by id prefix so a malformed `exp:` row (e.g. wrong cell count)
144
+ // is still routed to the experiment rules, which flag it — rather than
145
+ // slipping through the spec-shaped rules. parseStatusRowId returns the
146
+ // structured fields only for a well-formed row; the rules read `cells`.
147
+ const isExp = typeof cells[0] === "string" && cells[0].startsWith("exp:");
148
+ const parsed = parseStatusRowId(cells[0], cells);
109
149
  rows.push({
110
150
  lineNo: i + 1,
111
151
  text: line,
@@ -113,6 +153,7 @@ function parseStatusRows(statusText) {
113
153
  id: cells[0],
114
154
  phase: cells[1],
115
155
  status: cells[2],
156
+ kind: isExp ? "experiment" : parsed ? parsed.kind : null,
116
157
  });
117
158
  }
118
159
  return rows;
@@ -177,6 +218,7 @@ const SCOPE_RESOLVERS = {
177
218
  summary: (ctx) => ctx.subjects.summary,
178
219
  "weekly-log-main": (ctx) => ctx.subjects["weekly-log-main"],
179
220
  "weekly-log-part": (ctx) => ctx.subjects["weekly-log-part"],
221
+ "metrics-csv": (ctx) => ctx.subjects["metrics-csv"],
180
222
  memory: (ctx) => [ctx.memory],
181
223
  "claims-row": (ctx) =>
182
224
  parseClaims(ctx.memory.text).map((c) => ({ ...c, path: ctx.memory.path })),
@@ -191,8 +233,54 @@ const SCOPE_RESOLVERS = {
191
233
  ...r,
192
234
  path: ctx.status.path,
193
235
  })),
236
+ "conflict-scan": (ctx) => conflictScanSubjects(ctx),
237
+ admission: (ctx) =>
238
+ ctx.admission.paths
239
+ .filter((p) => classifyPath(p, ctx.admission) === "rejected")
240
+ .map((relPath) => ({
241
+ path: path.join(ctx.wikiRoot, relPath),
242
+ relPath,
243
+ })),
194
244
  };
195
245
 
246
+ // Normalize every audited surface into a uniform `{ path, text, fenceExempt }`
247
+ // subject for the conflict-marker scan. The per-file subjects (summaries,
248
+ // weekly logs and sealed parts, storyboard) carry `fileLines`; MEMORY.md and
249
+ // STATUS.md carry `text` (readOptional shape). `fenceExempt` is true for prose
250
+ // surfaces, where a fence quotes content, and false for STATUS.md, whose fenced
251
+ // rows are data — a marker there is never legitimate (per-surface fence
252
+ // contract). Files absent from disk (missing MEMORY/STATUS/storyboard) yield
253
+ // empty text and produce no findings.
254
+ function conflictScanSubjects(ctx) {
255
+ const subjects = [];
256
+ const fileScopes = ["summary", "weekly-log-main", "weekly-log-part"];
257
+ for (const scope of fileScopes) {
258
+ for (const s of ctx.subjects[scope]) {
259
+ subjects.push({
260
+ path: s.path,
261
+ text: s.fileLines.join("\n"),
262
+ fenceExempt: true,
263
+ });
264
+ }
265
+ }
266
+ subjects.push({
267
+ path: ctx.storyboard.path,
268
+ text: ctx.storyboard.text,
269
+ fenceExempt: true,
270
+ });
271
+ subjects.push({
272
+ path: ctx.memory.path,
273
+ text: ctx.memory.text,
274
+ fenceExempt: true,
275
+ });
276
+ subjects.push({
277
+ path: ctx.status.path,
278
+ text: ctx.status.text,
279
+ fenceExempt: false,
280
+ });
281
+ return subjects;
282
+ }
283
+
196
284
  /** Resolve a scope key into the list of subjects the engine should iterate. */
197
285
  export function resolveScope(scopeKey, ctx) {
198
286
  const resolver = SCOPE_RESOLVERS[scopeKey];
@@ -200,21 +288,48 @@ export function resolveScope(scopeKey, ctx) {
200
288
  return resolver(ctx);
201
289
  }
202
290
 
291
+ /**
292
+ * Build the admission slice: the tracked-file universe plus the
293
+ * `rootSummaryAgents` set that gates `<agent>/` sidecar directories. The agent
294
+ * set is derived first (a root-level summary-class file's stem) so the
295
+ * `admission` scope can classify sidecar directories against it.
296
+ *
297
+ * Returns the empty universe when `subprocess` is absent — callers that only
298
+ * read `.subjects` (the rotation pre-pass) skip the git read and the tree walk
299
+ * entirely, and produce no `admission` findings.
300
+ */
301
+ function buildAdmission(wikiRoot, fs, subprocess) {
302
+ if (!subprocess) return { paths: [], rootSummaryAgents: new Set() };
303
+ const paths = listAdmissionPaths({ wikiRoot, fs, subprocess });
304
+ const rootSummaryAgents = new Set();
305
+ for (const p of paths) {
306
+ if (p.includes("/")) continue; // root-level files only
307
+ const stem = rootSummaryStem(p);
308
+ if (stem) rootSummaryAgents.add(stem);
309
+ }
310
+ return { paths, rootSummaryAgents };
311
+ }
312
+
203
313
  /**
204
314
  * 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`).
315
+ * @param {{wikiRoot: string, today: string, fs: object, subprocess: object}} options
316
+ * `fs` is the sync filesystem surface (`runtime.fsSync`); `subprocess` is
317
+ * `runtime.subprocess` (its `runSync` backs the admission scope's git read).
207
318
  */
208
- export function buildContext({ wikiRoot, today, fs }) {
319
+ export function buildContext({ wikiRoot, today, fs, subprocess }) {
209
320
  const subjects = {
210
321
  summary: [],
211
322
  "weekly-log-main": [],
212
323
  "weekly-log-part": [],
324
+ "metrics-csv": [],
213
325
  };
214
326
  for (const file of listMdFiles(wikiRoot, fs)) {
215
327
  const classified = classifyFile(file, fs);
216
328
  if (classified) subjects[classified.kind].push(classified.subject);
217
329
  }
330
+ for (const file of listCsvFiles(wikiRoot, fs)) {
331
+ subjects["metrics-csv"].push(loadCsv(file, fs));
332
+ }
218
333
  return {
219
334
  wikiRoot,
220
335
  today,
@@ -222,5 +337,6 @@ export function buildContext({ wikiRoot, today, fs }) {
222
337
  memory: readOptional(path.join(wikiRoot, "MEMORY.md"), fs),
223
338
  status: readOptional(path.join(wikiRoot, "STATUS.md"), fs),
224
339
  storyboard: loadStoryboard(wikiRoot, today, fs),
340
+ admission: buildAdmission(wikiRoot, fs, subprocess),
225
341
  };
226
342
  }
@@ -1,24 +1,37 @@
1
1
  import { STATUS_ID_REGEX } from "../status.js";
2
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 }`.
3
+ // Validate every row inside wiki/STATUS.md's code fence. Rows are resolved by
4
+ // the `status-row` scope in scopes.js; each subject carries
5
+ // `{ cells, id, phase, status, kind, text }`. Two row kinds share the fence:
6
+ //
7
+ // spec `{id}<TAB>{phase}<TAB>{status}` — three cells
8
+ // experiment `exp:{issue}<TAB>{state}<TAB>{pin}<TAB>{plan-ref}` — four cells
9
+ //
10
+ // Spec-shaped rules run for every non-experiment row (`kind !== "experiment"`,
11
+ // which includes an unrecognized id so a malformed id still flags). Experiment
12
+ // rules run only for `kind === "experiment"`.
6
13
 
7
14
  const PHASES = new Set(["spec", "design", "plan"]);
8
15
  const STATUSES = new Set(["draft", "approved", "implemented", "cancelled"]);
16
+ const EXP_STATES = new Set(["registered", "approved", "cancelled"]);
17
+ const PIN_RE = /^[0-9a-f]{40}$/;
9
18
 
10
- const hasThreeCells = (s) => s.cells.length === 3;
19
+ const isSpecShaped = (s) => s.kind !== "experiment";
20
+ const isExperiment = (s) => s.kind === "experiment";
21
+ const hasThreeCells = (s) => isSpecShaped(s) && s.cells.length === 3;
22
+ const hasFourCells = (s) => isExperiment(s) && s.cells.length === 4;
11
23
 
12
24
  export const STATUS_ROW_RULES = [
13
25
  {
14
26
  id: "status-row.shape",
15
27
  scope: "status-row",
16
28
  severity: "fail",
29
+ when: isSpecShaped,
17
30
  check: (s) =>
18
- hasThreeCells(s) ? null : { actual: s.cells.length, text: s.text },
31
+ s.cells.length === 3 ? null : { actual: s.cells.length, text: s.text },
19
32
  message: (_s, r) =>
20
33
  `${r.actual} tab-separated field(s), expected 3: "${r.text}"`,
21
- hint: "each STATUS row is `{id}<TAB>{phase}<TAB>{status}`",
34
+ hint: "each spec STATUS row is `{id}<TAB>{phase}<TAB>{status}`",
22
35
  },
23
36
  {
24
37
  id: "status-row.id-format",
@@ -48,4 +61,76 @@ export const STATUS_ROW_RULES = [
48
61
  `Bad status '${r.status}' (expected draft|approved|implemented|cancelled)`,
49
62
  hint: "status is one of draft, approved, implemented, cancelled",
50
63
  },
64
+ {
65
+ id: "status-row.exp-shape",
66
+ scope: "status-row",
67
+ severity: "fail",
68
+ when: isExperiment,
69
+ check: (s) =>
70
+ s.cells.length === 4 ? null : { actual: s.cells.length, text: s.text },
71
+ message: (_s, r) =>
72
+ `${r.actual} tab-separated field(s), expected 4: "${r.text}"`,
73
+ hint: "each experiment row is `exp:{issue}<TAB>{state}<TAB>{pin}<TAB>{plan-ref}`",
74
+ },
75
+ {
76
+ // An experiment-kind row is classified by its `exp:` id prefix
77
+ // (scopes.js), so the spec `id-format` rule is skipped for it; this rule
78
+ // enforces the `exp:\d+` id so a non-numeric issue (e.g. `exp:abc`) flags
79
+ // rather than auditing clean — keeping the audit aligned with
80
+ // STATUS_ID_REGEX / parseStatusRowId.
81
+ id: "status-row.exp-id-format",
82
+ scope: "status-row",
83
+ severity: "fail",
84
+ when: isExperiment,
85
+ check: (s) => (/^exp:\d+$/.test(s.id) ? null : { id: s.id }),
86
+ message: (_s, r) => `Bad experiment id '${r.id}' (expected exp:NNN)`,
87
+ hint: "an experiment id is `exp:` followed by the issue number",
88
+ },
89
+ {
90
+ id: "status-row.exp-state",
91
+ scope: "status-row",
92
+ severity: "fail",
93
+ when: hasFourCells,
94
+ check: (s) => (EXP_STATES.has(s.cells[1]) ? null : { state: s.cells[1] }),
95
+ message: (_s, r) =>
96
+ `Bad experiment state '${r.state}' (expected registered|approved|cancelled)`,
97
+ hint: "experiment state is one of registered, approved, cancelled",
98
+ },
99
+ {
100
+ id: "status-row.exp-pin",
101
+ scope: "status-row",
102
+ severity: "fail",
103
+ when: hasFourCells,
104
+ // The pin is decidable per state, with no "ever approved" inference: a
105
+ // `registered` row has no pin (`-`); an `approved` row pins the 40-hex
106
+ // head; a `cancelled` row may carry the retained pin or `-` (it may or may
107
+ // not have been approved before cancellation), so both are accepted.
108
+ check: (s) => {
109
+ const [, state, pin] = s.cells;
110
+ if (state === "registered") {
111
+ return pin === "-" ? null : { state, pin, want: "-" };
112
+ }
113
+ if (state === "approved") {
114
+ return PIN_RE.test(pin) ? null : { state, pin, want: "a 40-hex SHA" };
115
+ }
116
+ if (state === "cancelled") {
117
+ return pin === "-" || PIN_RE.test(pin)
118
+ ? null
119
+ : { state, pin, want: "`-` or a 40-hex SHA" };
120
+ }
121
+ return null; // bad state already flagged by exp-state
122
+ },
123
+ message: (_s, r) =>
124
+ `Bad pin '${r.pin}' for state '${r.state}' (expected ${r.want})`,
125
+ hint: "registered pins `-`; approved pins a 40-hex SHA; cancelled pins either",
126
+ },
127
+ {
128
+ id: "status-row.exp-planref",
129
+ scope: "status-row",
130
+ severity: "fail",
131
+ when: hasFourCells,
132
+ check: (s) => (/^#\d+$/.test(s.cells[3]) ? null : { planRef: s.cells[3] }),
133
+ message: (_s, r) => `Bad plan-ref '${r.planRef}' (expected #NNN)`,
134
+ hint: "the plan-ref names the issue carrying the execution plan, e.g. #NNN",
135
+ },
51
136
  ];
@@ -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);