@forwardimpact/libwiki 0.2.12 → 0.2.14

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.12",
3
+ "version": "0.2.14",
4
4
  "description": "Wiki lifecycle primitives — stable memory for agent teams so coordination persists across sessions.",
5
5
  "keywords": [
6
6
  "wiki",
@@ -257,6 +257,7 @@ export const RULES = [
257
257
  id: "weekly-log.line-budget",
258
258
  scope: "weekly-log-main",
259
259
  severity: "fail",
260
+ remediation: "rotate",
260
261
  check: lineBudget(WEEKLY_LOG_LINE_BUDGET),
261
262
  message: (_s, r) => `${r.value} lines (limit ${WEEKLY_LOG_LINE_BUDGET})`,
262
263
  hint: "run `bunx fit-wiki rotate` to seal this file as a sealed part and start a fresh weekly log",
@@ -265,6 +266,7 @@ export const RULES = [
265
266
  id: "weekly-log.word-budget",
266
267
  scope: "weekly-log-main",
267
268
  severity: "fail",
269
+ remediation: "rotate",
268
270
  check: wordBudget(WEEKLY_LOG_WORD_BUDGET),
269
271
  message: (_s, r) => `${r.value} words (limit ${WEEKLY_LOG_WORD_BUDGET})`,
270
272
  hint: "run `bunx fit-wiki rotate` to seal this file as a sealed part and start a fresh weekly log",
@@ -282,6 +284,7 @@ export const RULES = [
282
284
  id: "decision-block.heading-within-5",
283
285
  scope: "weekly-log-main",
284
286
  severity: "fail",
287
+ remediation: "flag",
285
288
  check: decisionWithin5({
286
289
  entryRe: /^## \d{4}-\d{2}-\d{2}(?:[\s(].*)?$/,
287
290
  requiredLine: DECISION_HEADING,
@@ -305,6 +308,7 @@ export const RULES = [
305
308
  id: "weekly-log-part.line-budget",
306
309
  scope: "weekly-log-part",
307
310
  severity: "fail",
311
+ remediation: "flag",
308
312
  check: lineBudget(WEEKLY_LOG_LINE_BUDGET),
309
313
  message: (_s, r) => `${r.value} lines (limit ${WEEKLY_LOG_LINE_BUDGET})`,
310
314
  hint: "sealed parts should already be at-or-under the cap; if not, the rotation that produced this part needs investigation",
@@ -313,6 +317,7 @@ export const RULES = [
313
317
  id: "weekly-log-part.word-budget",
314
318
  scope: "weekly-log-part",
315
319
  severity: "fail",
320
+ remediation: "flag",
316
321
  check: wordBudget(WEEKLY_LOG_WORD_BUDGET),
317
322
  message: (_s, r) => `${r.value} words (limit ${WEEKLY_LOG_WORD_BUDGET})`,
318
323
  hint: "sealed parts should already be at-or-under the cap; if not, the rotation that produced this part needs investigation",
@@ -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,64 +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"),
158
+ runtime,
116
159
  }),
117
- redactor: createRedactor(),
160
+ redactor: createRedactor({ runtime }),
118
161
  });
162
+ }
119
163
 
120
- // The audit is the verdict, not the agent's self-report: run, re-audit, and
121
- // resume the session on whatever still fails until clean or out of rounds.
122
- // Resuming also extends the turn budget for a trim too large for one round.
123
- 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 = [];
124
174
  for (let round = 0; round < MAX_ROUNDS; round++) {
125
175
  const result =
126
176
  round === 0 ? await runner.run(task) : await runner.resume(task);
127
177
  if (result.text) out(result.text + "\n");
128
178
  if (isFatalError(result, round, err)) return { ok: false, code: 1 };
129
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
+ });
130
238
  findings = audit();
131
239
  if (findings.length === 0) {
132
240
  out("fixed: wiki audit is clean\n");
133
241
  return { ok: true, code: 0 };
134
242
  }
135
- task = composeFollowup(findings, projectRoot);
136
243
  }
137
244
 
138
- err(
139
- `fit-wiki fix: ${findings.length} finding(s) remain after ${MAX_ROUNDS} round(s):\n` +
140
- emitFindingsText(findings, { cwd: projectRoot }),
141
- );
142
- 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
+ });
143
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
  */