@forwardimpact/libwiki 0.2.13 → 0.2.15

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
@@ -16,14 +16,11 @@ const NEEDS_WIKI_SYNC = new Set(["claim", "release", "push", "pull", "init"]);
16
16
 
17
17
  async function main() {
18
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 });
19
+ const definition = createDefinition(runtime.proc.env);
20
+ const cli = createCli(definition, {
21
+ runtime,
22
+ packageJsonUrl: new URL("../package.json", import.meta.url),
23
+ });
27
24
 
28
25
  const parsed = cli.parse(runtime.proc.argv.slice(2));
29
26
  if (!parsed) return runtime.proc.exit(0); // --help / --version already printed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libwiki",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "Wiki lifecycle primitives — stable memory for agent teams so coordination persists across sessions.",
5
5
  "keywords": [
6
6
  "wiki",
@@ -77,7 +77,6 @@ const columnCount = (expected) => (s) =>
77
77
 
78
78
  const exists = (s) => (s.exists ? null : {});
79
79
  const expired = (s, ctx) => (s.expires_at < ctx.today ? {} : null);
80
- const always = () => ({});
81
80
 
82
81
  function entryHasDecision(lines, startIdx, requiredLine, stopRe) {
83
82
  let seen = 0;
@@ -257,6 +256,7 @@ export const RULES = [
257
256
  id: "weekly-log.line-budget",
258
257
  scope: "weekly-log-main",
259
258
  severity: "fail",
259
+ remediation: "rotate",
260
260
  check: lineBudget(WEEKLY_LOG_LINE_BUDGET),
261
261
  message: (_s, r) => `${r.value} lines (limit ${WEEKLY_LOG_LINE_BUDGET})`,
262
262
  hint: "run `bunx fit-wiki rotate` to seal this file as a sealed part and start a fresh weekly log",
@@ -265,6 +265,7 @@ export const RULES = [
265
265
  id: "weekly-log.word-budget",
266
266
  scope: "weekly-log-main",
267
267
  severity: "fail",
268
+ remediation: "rotate",
268
269
  check: wordBudget(WEEKLY_LOG_WORD_BUDGET),
269
270
  message: (_s, r) => `${r.value} words (limit ${WEEKLY_LOG_WORD_BUDGET})`,
270
271
  hint: "run `bunx fit-wiki rotate` to seal this file as a sealed part and start a fresh weekly log",
@@ -282,6 +283,7 @@ export const RULES = [
282
283
  id: "decision-block.heading-within-5",
283
284
  scope: "weekly-log-main",
284
285
  severity: "fail",
286
+ remediation: "flag",
285
287
  check: decisionWithin5({
286
288
  entryRe: /^## \d{4}-\d{2}-\d{2}(?:[\s(].*)?$/,
287
289
  requiredLine: DECISION_HEADING,
@@ -305,6 +307,7 @@ export const RULES = [
305
307
  id: "weekly-log-part.line-budget",
306
308
  scope: "weekly-log-part",
307
309
  severity: "fail",
310
+ remediation: "flag",
308
311
  check: lineBudget(WEEKLY_LOG_LINE_BUDGET),
309
312
  message: (_s, r) => `${r.value} lines (limit ${WEEKLY_LOG_LINE_BUDGET})`,
310
313
  hint: "sealed parts should already be at-or-under the cap; if not, the rotation that produced this part needs investigation",
@@ -313,6 +316,7 @@ export const RULES = [
313
316
  id: "weekly-log-part.word-budget",
314
317
  scope: "weekly-log-part",
315
318
  severity: "fail",
319
+ remediation: "flag",
316
320
  check: wordBudget(WEEKLY_LOG_WORD_BUDGET),
317
321
  message: (_s, r) => `${r.value} words (limit ${WEEKLY_LOG_WORD_BUDGET})`,
318
322
  hint: "sealed parts should already be at-or-under the cap; if not, the rotation that produced this part needs investigation",
@@ -487,15 +491,4 @@ export const RULES = [
487
491
  // -- STATUS.md rows (per-migration-unit sub-row schema) --
488
492
 
489
493
  ...STATUS_ROW_RULES,
490
-
491
- // -- Stray files --
492
-
493
- {
494
- id: "wiki.stray-file",
495
- scope: "stray-file",
496
- severity: "fail",
497
- check: always,
498
- message: () => "Does not match any known scope",
499
- hint: "rename to a recognized scope (summary, weekly log, weekly-log part) or remove the file",
500
- },
501
494
  ];
@@ -74,8 +74,7 @@ function classifyFile(filePath, fs) {
74
74
  const base = path.basename(filePath);
75
75
  if (EXCLUDED_BASES.has(base)) return null;
76
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.
77
+ // `status-row` scope — skip the per-file classification.
79
78
  if (base === "STATUS.md") return null;
80
79
  if (NON_SUMMARY_PREFIXES.some((p) => base.startsWith(p))) return null;
81
80
  if (WEEKLY_LOG_NAME_RE.test(base)) {
@@ -85,8 +84,10 @@ function classifyFile(filePath, fs) {
85
84
  return { kind: "weekly-log-part", subject: loadFile(filePath, fs) };
86
85
  }
87
86
  const subject = loadFile(filePath, fs);
88
- const kind = SUMMARY_H1_RE.test(subject.firstLine) ? "summary" : "stray";
89
- return { kind, subject };
87
+ // Files that do not match a summary or weekly-log shape are left
88
+ // unclassified: stray files are not audited.
89
+ if (!SUMMARY_H1_RE.test(subject.firstLine)) return null;
90
+ return { kind: "summary", subject };
90
91
  }
91
92
 
92
93
  function loadMemory(wikiRoot, fs) {
@@ -216,7 +217,6 @@ const SCOPE_RESOLVERS = {
216
217
  ...r,
217
218
  path: ctx.status.path,
218
219
  })),
219
- "stray-file": (ctx) => ctx.subjects.stray,
220
220
  };
221
221
 
222
222
  /** Resolve a scope key into the list of subjects the engine should iterate. */
@@ -236,7 +236,6 @@ export function buildContext({ wikiRoot, today, fs }) {
236
236
  summary: [],
237
237
  "weekly-log-main": [],
238
238
  "weekly-log-part": [],
239
- stray: [],
240
239
  };
241
240
  for (const file of listMdFiles(wikiRoot, fs)) {
242
241
  const classified = classifyFile(file, fs);
@@ -13,16 +13,16 @@ import { runFixCommand } from "./commands/fix.js";
13
13
  /**
14
14
  * Build the `fit-wiki` libcli definition. The agent/sender defaults read the
15
15
  * injected `env` rather than the ambient `process.env` so this module carries
16
- * no ambient dependency; the bin shim passes `runtime.proc.env` and the
17
- * package version. Each subcommand carries a `handler` and (for subcommand-
18
- * bearing commands) `args`/`argsUsage` so `cli.dispatch` can route to the
19
- * per-command handler with a frozen `ctx`.
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`.
20
21
  *
21
22
  * @param {Record<string, string>} env - The process env (`runtime.proc.env`).
22
- * @param {string} version - The package version string.
23
23
  * @returns {object} The libcli definition.
24
24
  */
25
- export function createDefinition(env, version) {
25
+ export function createDefinition(env) {
26
26
  const wikiRootOpt = {
27
27
  "wiki-root": {
28
28
  type: "string",
@@ -48,7 +48,6 @@ export function createDefinition(env, version) {
48
48
 
49
49
  return {
50
50
  name: "fit-wiki",
51
- version,
52
51
  description: "Wiki lifecycle management for the Kata agent system",
53
52
  commands: [
54
53
  {
@@ -174,7 +173,7 @@ export function createDefinition(env, version) {
174
173
  {
175
174
  name: "fix",
176
175
  description:
177
- "Auto-fix wiki audit findings using an AI agent (technical-writer, Haiku)",
176
+ "Auto-fix wiki audit findings: rotate weekly logs, fix the rest with an AI agent (technical-writer, Haiku), flag the unresolvable",
178
177
  handler: runFixCommand,
179
178
  options: {
180
179
  ...wikiRootOpt,
@@ -8,14 +8,25 @@ import {
8
8
  } from "@forwardimpact/libeval";
9
9
  import { RULES } from "../audit/rules.js";
10
10
  import { buildContext, resolveScope } from "../audit/scopes.js";
11
+ import { rotateIfOverBudget, weeklyLogPath } from "../weekly-log.js";
11
12
  import { currentDayIso } from "../util/clock.js";
12
13
  import { resolveProjectRoot } from "../util/wiki-dir.js";
13
14
 
14
- // The agent edits, we re-audit, and resume on whatever still fails. Cap the
15
- // rounds so a finding the agent cannot resolve (e.g. a budget needing
16
- // `fit-wiki rotate`, which it has no Bash to run) fails loudly, not forever.
15
+ // Pipeline: audit deterministic rotation (the one fix needing a file seal the
16
+ // agent can't do) re-audit → Haiku agent on the prose-judgment residual
17
+ // flag what neither should touch. MAX_ROUNDS still caps the agent loop so an
18
+ // unresolvable agent-class finding fails loudly rather than spinning forever.
17
19
  const MAX_ROUNDS = 3;
18
20
 
21
+ /**
22
+ * A finding's remediation class, from the declarative rule. Rules without a
23
+ * `remediation` field default to `"agent"`: the Haiku agent handles all
24
+ * prose-judgment fixes (summary trims, section order, MEMORY.md prose).
25
+ */
26
+ function classOf(finding) {
27
+ return RULES.find((r) => r.id === finding.id)?.remediation ?? "agent";
28
+ }
29
+
19
30
  /**
20
31
  * Every rule governing a scope with an open finding, as `id — hint` lines.
21
32
  * Handing the agent the full contract for the files it edits — not just the
@@ -63,6 +74,56 @@ function composeFollowup(findings, projectRoot) {
63
74
  ].join("\n");
64
75
  }
65
76
 
77
+ /**
78
+ * Deterministic pre-pass: seal every over-budget current-week weekly-log main
79
+ * file via `rotateIfOverBudget`. The agent name comes from the audit's own
80
+ * subjects (keyed by path) — no filename parsing. `force: true` rotates even a
81
+ * word-over/line-under file.
82
+ *
83
+ * `rotateIfOverBudget` always seals the agent's *current-week* log, so we only
84
+ * call it when the finding IS that file. A prior-week over-budget main is left
85
+ * untouched (rotating it would force-seal a healthy current-week log instead);
86
+ * it survives the re-audit and is flagged for a human.
87
+ */
88
+ function rotateOverBudgetMainLogs(
89
+ findings,
90
+ { wikiRoot, today, projectRoot, fs, out },
91
+ ) {
92
+ const subjects = buildContext({ wikiRoot, today, fs }).subjects[
93
+ "weekly-log-main"
94
+ ];
95
+ const agentByPath = new Map(subjects.map((s) => [s.path, s.agentPrefix]));
96
+ for (const f of findings) {
97
+ if (classOf(f) !== "rotate") continue;
98
+ const agent = agentByPath.get(f.path);
99
+ if (!agent) continue;
100
+ if (weeklyLogPath(wikiRoot, agent, today) !== f.path) continue;
101
+ const res = rotateIfOverBudget(
102
+ wikiRoot,
103
+ agent,
104
+ today,
105
+ 0,
106
+ { force: true },
107
+ fs,
108
+ );
109
+ if (res.rotated) {
110
+ out(
111
+ `rotated ${path.relative(projectRoot, res.fromPath)} -> ` +
112
+ `${path.relative(projectRoot, res.toPath)}\n`,
113
+ );
114
+ }
115
+ }
116
+ }
117
+
118
+ /** Report findings that need human judgment — never auto-fixed. */
119
+ function reportFlags(err, flagFindings, projectRoot) {
120
+ err(
121
+ `fit-wiki fix: ${flagFindings.length} finding(s) need human judgment ` +
122
+ `(not auto-fixable):\n` +
123
+ emitFindingsText(flagFindings, { cwd: projectRoot }),
124
+ );
125
+ }
126
+
66
127
  /**
67
128
  * Surface a round's agent error, if any. Returns true when it is fatal: a
68
129
  * missing sessionId means the process never started (e.g. the SDK refused
@@ -80,65 +141,123 @@ function isFatalError(result, round, err) {
80
141
  return false;
81
142
  }
82
143
 
83
- /** Run the wiki audit and auto-fix findings via a Haiku-powered AgentRunner. */
84
- export async function runFixCommand(ctx) {
85
- const { runtime } = ctx.deps;
86
- const projectRoot = resolveProjectRoot(runtime);
87
- const wikiRoot = ctx.options["wiki-root"] || path.join(projectRoot, "wiki");
88
- const today = ctx.options.today || currentDayIso(runtime);
89
- const out = (s) => runtime.proc.stdout.write(s);
90
- const err = (s) => runtime.proc.stderr.write(s);
91
-
92
- // The agent's edits change the result, so re-read and re-audit each round.
93
- const audit = () =>
94
- runRules(RULES, buildContext({ wikiRoot, today, fs: runtime.fsSync }), {
95
- resolveScope,
96
- });
97
-
98
- let findings = audit();
99
- if (findings.length === 0) {
100
- out("nothing to fix\n");
101
- return { ok: true };
102
- }
103
-
144
+ /** Build the Haiku technical-writer runner for prose-judgment fixes. */
145
+ async function buildFixRunner(ctx, projectRoot, runtime) {
104
146
  const query =
105
147
  ctx.deps.query ?? (await import("@anthropic-ai/claude-agent-sdk")).query;
106
- const runner = createAgentRunner({
148
+ return createAgentRunner({
107
149
  cwd: projectRoot,
108
150
  query,
109
151
  output: new Writable({ write: (_c, _e, cb) => cb() }),
110
152
  model: "claude-haiku-4-5-20251001",
111
153
  maxTurns: 30,
112
- allowedTools: ["Read", "Write", "Edit"],
154
+ allowedTools: ["Read", "Glob", "Write", "Edit"],
113
155
  settingSources: ["project"],
114
156
  systemPrompt: composeProfilePrompt("technical-writer", {
115
157
  profilesDir: path.resolve(projectRoot, ".claude/agents"),
116
158
  runtime,
117
159
  }),
118
- redactor: createRedactor(),
160
+ redactor: createRedactor({ runtime }),
119
161
  });
162
+ }
120
163
 
121
- // The audit is the verdict, not the agent's self-report: run, re-audit, and
122
- // resume the session on whatever still fails until clean or out of rounds.
123
- // Resuming also extends the turn budget for a trim too large for one round.
124
- let task = composeTask(findings, wikiRoot, projectRoot);
164
+ /**
165
+ * Run the agent on the prose-judgment findings, re-auditing each round until
166
+ * clean, flag-only, or MAX_ROUNDS is exhausted. The audit is the verdict, not
167
+ * the agent's self-report; resuming extends the turn budget for a trim too
168
+ * large for one round.
169
+ */
170
+ async function runAgentRounds(runner, agentFindings, deps) {
171
+ const { wikiRoot, projectRoot, audit, partition, out, err } = deps;
172
+ let task = composeTask(agentFindings, wikiRoot, projectRoot);
173
+ let flagFindings = [];
125
174
  for (let round = 0; round < MAX_ROUNDS; round++) {
126
175
  const result =
127
176
  round === 0 ? await runner.run(task) : await runner.resume(task);
128
177
  if (result.text) out(result.text + "\n");
129
178
  if (isFatalError(result, round, err)) return { ok: false, code: 1 };
130
179
 
180
+ ({ agentFindings, flagFindings } = partition(audit()));
181
+ if (agentFindings.length === 0) {
182
+ if (flagFindings.length === 0) {
183
+ out("fixed: wiki audit is clean\n");
184
+ return { ok: true, code: 0 };
185
+ }
186
+ reportFlags(err, flagFindings, projectRoot);
187
+ return { ok: false, code: 2 };
188
+ }
189
+ task = composeFollowup(agentFindings, projectRoot);
190
+ }
191
+
192
+ err(
193
+ `fit-wiki fix: ${agentFindings.length} finding(s) remain after ` +
194
+ `${MAX_ROUNDS} round(s):\n` +
195
+ emitFindingsText(agentFindings, { cwd: projectRoot }),
196
+ );
197
+ if (flagFindings.length > 0) reportFlags(err, flagFindings, projectRoot);
198
+ return { ok: false, code: 1 };
199
+ }
200
+
201
+ /** Run the wiki audit and auto-fix findings: rotate, then agent, then flag. */
202
+ export async function runFixCommand(ctx) {
203
+ const { runtime } = ctx.deps;
204
+ const fs = runtime.fsSync;
205
+ const projectRoot = resolveProjectRoot(runtime);
206
+ const wikiRoot = ctx.options["wiki-root"] || path.join(projectRoot, "wiki");
207
+ const today = ctx.options.today || currentDayIso(runtime);
208
+ const out = (s) => runtime.proc.stdout.write(s);
209
+ const err = (s) => runtime.proc.stderr.write(s);
210
+
211
+ // The agent's edits change the result, so re-read and re-audit each round.
212
+ const audit = () =>
213
+ runRules(RULES, buildContext({ wikiRoot, today, fs }), { resolveScope });
214
+ // The agent only ever gets prose-judgment (`agent`-class) findings. A
215
+ // `rotate` finding that survived the pre-pass (e.g. a prior-week log) is
216
+ // unfixable by the agent — and trimming append-only history to satisfy a
217
+ // budget would corrupt it — so it joins the flag set for a human.
218
+ const partition = (found) => ({
219
+ agentFindings: found.filter((f) => classOf(f) === "agent"),
220
+ flagFindings: found.filter((f) => classOf(f) !== "agent"),
221
+ });
222
+
223
+ let findings = audit();
224
+ if (findings.length === 0) {
225
+ out("nothing to fix\n");
226
+ return { ok: true };
227
+ }
228
+
229
+ // Deterministic layer: weekly-log rotation only.
230
+ if (findings.some((f) => classOf(f) === "rotate")) {
231
+ rotateOverBudgetMainLogs(findings, {
232
+ wikiRoot,
233
+ today,
234
+ projectRoot,
235
+ fs,
236
+ out,
237
+ });
131
238
  findings = audit();
132
239
  if (findings.length === 0) {
133
240
  out("fixed: wiki audit is clean\n");
134
241
  return { ok: true, code: 0 };
135
242
  }
136
- task = composeFollowup(findings, projectRoot);
137
243
  }
138
244
 
139
- err(
140
- `fit-wiki fix: ${findings.length} finding(s) remain after ${MAX_ROUNDS} round(s):\n` +
141
- emitFindingsText(findings, { cwd: projectRoot }),
142
- );
143
- return { ok: false, code: 1 };
245
+ // Residual: agent-class goes to the writer; everything else (flag, plus any
246
+ // rotate finding the deterministic pass could not handle) needs a human.
247
+ const { agentFindings, flagFindings } = partition(findings);
248
+ if (agentFindings.length === 0) {
249
+ reportFlags(err, flagFindings, projectRoot);
250
+ return { ok: false, code: 2 };
251
+ }
252
+
253
+ // Constructed only now, so a rotation-only or flag-only run never spawns it.
254
+ const runner = await buildFixRunner(ctx, projectRoot, runtime);
255
+ return runAgentRounds(runner, agentFindings, {
256
+ wikiRoot,
257
+ projectRoot,
258
+ audit,
259
+ partition,
260
+ out,
261
+ err,
262
+ });
144
263
  }
@@ -53,6 +53,18 @@ function spliceBlock(lines, block, rendered) {
53
53
  );
54
54
  }
55
55
 
56
+ // A missing current-month storyboard (e.g. a coaching run early in the month,
57
+ // before the storyboard meeting created it) is non-fatal: return null so the
58
+ // deterministic refresh step exits cleanly instead of failing the job.
59
+ function readStoryboardOrNull(runtime, storyboardPath) {
60
+ try {
61
+ return runtime.fsSync.readFileSync(storyboardPath, "utf-8");
62
+ } catch (err) {
63
+ if (err.code !== "ENOENT") throw err;
64
+ return null;
65
+ }
66
+ }
67
+
56
68
  /** Re-render XmR chart blocks and issue-list blocks in a storyboard file. */
57
69
  export async function runRefreshCommand(ctx) {
58
70
  const { runtime, gitClient } = ctx.deps;
@@ -63,7 +75,11 @@ export async function runRefreshCommand(ctx) {
63
75
  projectRoot,
64
76
  ctx.args["storyboard-path"] || currentStoryboardRelPath(runtime),
65
77
  );
66
- const text = runtime.fsSync.readFileSync(storyboardPath, "utf-8");
78
+ const text = readStoryboardOrNull(runtime, storyboardPath);
79
+ if (text === null) {
80
+ runtime.proc.stderr.write(`refresh: no storyboard at ${storyboardPath}\n`);
81
+ return { ok: true };
82
+ }
67
83
  const blocks = scanMarkers(text, {
68
84
  warn: (message) => runtime.proc.stderr.write(message),
69
85
  });
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  /**
4
4
  * Find the project root by upward `package.json` discovery from the current
5
5
  * working directory, using the injected `runtime.finder` (the one canonical
6
- * Finder SC9 keeps `new Finder(...)` inside libutil).
6
+ * Finder, constructed only inside libutil).
7
7
  * @param {import('@forwardimpact/libutil/runtime').Runtime} runtime
8
8
  * @returns {string}
9
9
  */