@forwardimpact/libwiki 0.2.10 → 0.2.12

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.
@@ -1,7 +1,6 @@
1
- import fsAsync from "node:fs/promises";
2
1
  import path from "node:path";
3
2
  import { Writable } from "node:stream";
4
- import { Finder, emitFindingsText, runRules } from "@forwardimpact/libutil";
3
+ import { emitFindingsText, runRules } from "@forwardimpact/libutil";
5
4
  import {
6
5
  createAgentRunner,
7
6
  composeProfilePrompt,
@@ -9,56 +8,136 @@ import {
9
8
  } from "@forwardimpact/libeval";
10
9
  import { RULES } from "../audit/rules.js";
11
10
  import { buildContext, resolveScope } from "../audit/scopes.js";
11
+ import { currentDayIso } from "../util/clock.js";
12
+ import { resolveProjectRoot } from "../util/wiki-dir.js";
12
13
 
13
- /** Run the wiki audit and auto-fix findings via a Haiku-powered AgentRunner. */
14
- export async function runFixCommand(values, _args, _cli) {
15
- const finder = new Finder(fsAsync, { debug() {} }, process);
16
- const projectRoot = finder.findProjectRoot(process.cwd());
17
- const wikiRoot = values["wiki-root"] || path.join(projectRoot, "wiki");
18
- const today = values.today || new Date().toISOString().slice(0, 10);
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.
17
+ const MAX_ROUNDS = 3;
19
18
 
20
- const ctx = buildContext({ wikiRoot, today });
21
- const findings = runRules(RULES, ctx, { resolveScope });
19
+ /**
20
+ * Every rule governing a scope with an open finding, as `id — hint` lines.
21
+ * Handing the agent the full contract for the files it edits — not just the
22
+ * failing rules — stops it fixing one finding by breaking another (dropping
23
+ * the `**Last run**:` line, appending a section after `## Open Blockers`, …).
24
+ */
25
+ function invariantContract(findings) {
26
+ const scopes = new Set(
27
+ findings.map((f) => RULES.find((r) => r.id === f.id)?.scope),
28
+ );
29
+ return RULES.filter((r) => scopes.has(r.scope) && r.hint).map(
30
+ (r) => `- ${r.id} — ${r.hint}`,
31
+ );
32
+ }
22
33
 
23
- if (findings.length === 0) {
24
- process.stdout.write("nothing to fix\n");
25
- return;
34
+ /**
35
+ * The opening task: the findings, the invariant contract, and the two things
36
+ * the rule hints don't cover — where trimmed history goes, and to prefer a
37
+ * single Write.
38
+ */
39
+ function composeTask(findings, wikiRoot, projectRoot) {
40
+ return [
41
+ `Fix these wiki audit findings by editing files under ${wikiRoot}.`,
42
+ ``,
43
+ emitFindingsText(findings, { cwd: projectRoot }),
44
+ ``,
45
+ `All of these invariants must hold when you finish — never fix one finding`,
46
+ `by breaking another:`,
47
+ ...invariantContract(findings),
48
+ ``,
49
+ `Move history out of an over-budget summary into the agent's weekly-log`,
50
+ `file (wiki/<agent>-YYYY-Www.md), never a new summary section. Prefer a`,
51
+ `single Write over many Edits.`,
52
+ ].join("\n");
53
+ }
54
+
55
+ /** The resume task: the findings that survived the last edit. */
56
+ function composeFollowup(findings, projectRoot) {
57
+ return [
58
+ `The wiki still fails the audit. Remaining findings:`,
59
+ ``,
60
+ emitFindingsText(findings, { cwd: projectRoot }),
61
+ ``,
62
+ `Fix every one without breaking any invariant listed earlier.`,
63
+ ].join("\n");
64
+ }
65
+
66
+ /**
67
+ * Surface a round's agent error, if any. Returns true when it is fatal: a
68
+ * missing sessionId means the process never started (e.g. the SDK refused
69
+ * bypass-permissions as root), so there is nothing to resume. A turn-limit or
70
+ * transient error keeps its session and may have made partial progress, so it
71
+ * is noted but not fatal — the re-audit decides.
72
+ */
73
+ function isFatalError(result, round, err) {
74
+ if (!result.error) return false;
75
+ if (!result.sessionId) {
76
+ err(`fit-wiki fix: agent run failed: ${result.error.message}\n`);
77
+ return true;
26
78
  }
79
+ err(`fit-wiki fix: round ${round} agent error: ${result.error.message}\n`);
80
+ return false;
81
+ }
27
82
 
28
- const auditText = emitFindingsText(findings, { cwd: projectRoot });
29
- const redactor = createRedactor();
30
- const devNull = new Writable({
31
- write(_c, _e, cb) {
32
- cb();
33
- },
34
- });
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);
35
91
 
36
- const systemPrompt = composeProfilePrompt("technical-writer", {
37
- profilesDir: path.resolve(projectRoot, ".claude/agents"),
38
- });
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
+ });
39
97
 
40
- const { query } = await import("@anthropic-ai/claude-agent-sdk");
98
+ let findings = audit();
99
+ if (findings.length === 0) {
100
+ out("nothing to fix\n");
101
+ return { ok: true };
102
+ }
41
103
 
104
+ const query =
105
+ ctx.deps.query ?? (await import("@anthropic-ai/claude-agent-sdk")).query;
42
106
  const runner = createAgentRunner({
43
107
  cwd: projectRoot,
44
108
  query,
45
- output: devNull,
109
+ output: new Writable({ write: (_c, _e, cb) => cb() }),
46
110
  model: "claude-haiku-4-5-20251001",
47
- maxTurns: 15,
111
+ maxTurns: 30,
48
112
  allowedTools: ["Read", "Write", "Edit"],
49
113
  settingSources: ["project"],
50
- systemPrompt,
51
- redactor,
114
+ systemPrompt: composeProfilePrompt("technical-writer", {
115
+ profilesDir: path.resolve(projectRoot, ".claude/agents"),
116
+ }),
117
+ redactor: createRedactor(),
52
118
  });
53
119
 
54
- const task = [
55
- `Fix these wiki audit findings.`,
56
- `The wiki root is ${wikiRoot}.`,
57
- ``,
58
- auditText,
59
- ].join("\n");
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);
124
+ for (let round = 0; round < MAX_ROUNDS; round++) {
125
+ const result =
126
+ round === 0 ? await runner.run(task) : await runner.resume(task);
127
+ if (result.text) out(result.text + "\n");
128
+ if (isFatalError(result, round, err)) return { ok: false, code: 1 };
129
+
130
+ findings = audit();
131
+ if (findings.length === 0) {
132
+ out("fixed: wiki audit is clean\n");
133
+ return { ok: true, code: 0 };
134
+ }
135
+ task = composeFollowup(findings, projectRoot);
136
+ }
60
137
 
61
- const result = await runner.run(task);
62
- if (result.text) process.stdout.write(result.text + "\n");
63
- process.exit(result.success ? 0 : 1);
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 };
64
143
  }
@@ -1,26 +1,20 @@
1
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
- import fsAsync from "node:fs/promises";
3
1
  import path from "node:path";
4
- import { Finder } from "@forwardimpact/libutil";
5
2
  import {
6
3
  MEMO_INBOX_MARKER,
7
4
  PRIORITY_INDEX_HEADING,
8
5
  PRIORITY_INDEX_TABLE_HEADER,
9
6
  } from "../constants.js";
7
+ import { currentDayIso } from "../util/clock.js";
8
+ import { resolveWikiRoot } from "../util/wiki-dir.js";
10
9
 
11
- function projectRoot() {
12
- const logger = { debug() {} };
13
- const finder = new Finder(fsAsync, logger, process);
14
- return finder.findProjectRoot(process.cwd());
15
- }
16
-
17
- function paths(values) {
18
- const root = projectRoot();
19
- const wikiRoot = values["wiki-root"] || path.join(root, "wiki");
20
- const agent = values.agent || process.env.LIBEVAL_AGENT_PROFILE;
10
+ function paths(runtime, options) {
11
+ const wikiRoot = resolveWikiRoot(runtime, options);
12
+ const agent = options.agent || runtime.proc.env.LIBEVAL_AGENT_PROFILE;
21
13
  if (!agent) {
22
- process.stderr.write("inbox requires --agent or LIBEVAL_AGENT_PROFILE\n");
23
- process.exit(2);
14
+ runtime.proc.stderr.write(
15
+ "inbox requires --agent or LIBEVAL_AGENT_PROFILE\n",
16
+ );
17
+ return { error: { ok: false, code: 2 } };
24
18
  }
25
19
  return {
26
20
  summaryPath: path.join(wikiRoot, `${agent}.md`),
@@ -53,33 +47,39 @@ function removeBulletAt(lines, idx) {
53
47
  return lines;
54
48
  }
55
49
 
56
- function listCmd(values) {
57
- const { summaryPath } = paths(values);
58
- if (!existsSync(summaryPath)) {
59
- process.stdout.write(JSON.stringify({ bullets: [] }) + "\n");
60
- return;
50
+ function listCmd(runtime, options) {
51
+ const p = paths(runtime, options);
52
+ if (p.error) return p.error;
53
+ const { summaryPath } = p;
54
+ if (!runtime.fsSync.existsSync(summaryPath)) {
55
+ runtime.proc.stdout.write(JSON.stringify({ bullets: [] }) + "\n");
56
+ return { ok: true };
61
57
  }
62
- const text = readFileSync(summaryPath, "utf-8");
58
+ const text = runtime.fsSync.readFileSync(summaryPath, "utf-8");
63
59
  const { bullets } = readInboxBullets(text);
64
- process.stdout.write(JSON.stringify({ bullets }, null, 2) + "\n");
60
+ runtime.proc.stdout.write(JSON.stringify({ bullets }, null, 2) + "\n");
61
+ return { ok: true };
65
62
  }
66
63
 
67
- function ackOrDropCmd(values, _kind) {
68
- const { summaryPath } = paths(values);
69
- const idx = Number.parseInt(values.index ?? "", 10);
64
+ function ackOrDropCmd(runtime, options) {
65
+ const p = paths(runtime, options);
66
+ if (p.error) return p.error;
67
+ const { summaryPath } = p;
68
+ const idx = Number.parseInt(options.index ?? "", 10);
70
69
  if (!Number.isInteger(idx) || idx < 0) {
71
- process.stderr.write("inbox requires --index <n>\n");
72
- process.exit(2);
70
+ runtime.proc.stderr.write("inbox requires --index <n>\n");
71
+ return { ok: false, code: 2 };
73
72
  }
74
- const text = readFileSync(summaryPath, "utf-8");
73
+ const text = runtime.fsSync.readFileSync(summaryPath, "utf-8");
75
74
  const { lines, bulletIdxs } = readInboxBullets(text);
76
75
  if (idx >= bulletIdxs.length) {
77
- process.stderr.write(`no bullet at index ${idx}\n`);
78
- process.exit(2);
76
+ runtime.proc.stderr.write(`no bullet at index ${idx}\n`);
77
+ return { ok: false, code: 2 };
79
78
  }
80
79
  removeBulletAt(lines, bulletIdxs[idx]);
81
- writeFileSync(summaryPath, lines.join("\n"));
82
- process.stdout.write(`removed inbox bullet ${idx}\n`);
80
+ runtime.fsSync.writeFileSync(summaryPath, lines.join("\n"));
81
+ runtime.proc.stdout.write(`removed inbox bullet ${idx}\n`);
82
+ return { ok: true };
83
83
  }
84
84
 
85
85
  function appendPriorityRow(memoryText, { item, agents, owner, status, added }) {
@@ -128,28 +128,30 @@ function appendPriorityRow(memoryText, { item, agents, owner, status, added }) {
128
128
  return lines.join("\n");
129
129
  }
130
130
 
131
- function promoteCmd(values) {
132
- const { summaryPath, memoryPath, agent } = paths(values);
133
- const idx = Number.parseInt(values.index ?? "", 10);
131
+ function promoteCmd(runtime, options) {
132
+ const p = paths(runtime, options);
133
+ if (p.error) return p.error;
134
+ const { summaryPath, memoryPath, agent } = p;
135
+ const idx = Number.parseInt(options.index ?? "", 10);
134
136
  if (!Number.isInteger(idx) || idx < 0) {
135
- process.stderr.write("inbox promote requires --index <n>\n");
136
- process.exit(2);
137
+ runtime.proc.stderr.write("inbox promote requires --index <n>\n");
138
+ return { ok: false, code: 2 };
137
139
  }
138
- const text = readFileSync(summaryPath, "utf-8");
140
+ const text = runtime.fsSync.readFileSync(summaryPath, "utf-8");
139
141
  const { lines, bullets, bulletIdxs } = readInboxBullets(text);
140
142
  if (idx >= bullets.length) {
141
- process.stderr.write(`no bullet at index ${idx}\n`);
142
- process.exit(2);
143
+ runtime.proc.stderr.write(`no bullet at index ${idx}\n`);
144
+ return { ok: false, code: 2 };
143
145
  }
144
146
  const bulletText = bullets[idx].replace(/^[-*]\s+/, "");
145
147
  removeBulletAt(lines, bulletIdxs[idx]);
146
- writeFileSync(summaryPath, lines.join("\n"));
148
+ runtime.fsSync.writeFileSync(summaryPath, lines.join("\n"));
147
149
 
148
- const memText = existsSync(memoryPath)
149
- ? readFileSync(memoryPath, "utf-8")
150
+ const memText = runtime.fsSync.existsSync(memoryPath)
151
+ ? runtime.fsSync.readFileSync(memoryPath, "utf-8")
150
152
  : "";
151
- const today = values.today || new Date().toISOString().slice(0, 10);
152
- const owner = values.owner || agent;
153
+ const today = options.today || currentDayIso(runtime);
154
+ const owner = options.owner || agent;
153
155
  const promoted = appendPriorityRow(memText, {
154
156
  item: bulletText,
155
157
  agents: agent,
@@ -157,24 +159,29 @@ function promoteCmd(values) {
157
159
  status: "active",
158
160
  added: today,
159
161
  });
160
- writeFileSync(memoryPath, promoted);
161
- process.stdout.write(`promoted inbox bullet ${idx} to priorities\n`);
162
+ runtime.fsSync.writeFileSync(memoryPath, promoted);
163
+ runtime.proc.stdout.write(`promoted inbox bullet ${idx} to priorities\n`);
164
+ return { ok: true };
162
165
  }
163
166
 
164
167
  const SUBS = {
165
168
  list: listCmd,
166
- ack: (v) => ackOrDropCmd(v, "ack"),
167
- drop: (v) => ackOrDropCmd(v, "drop"),
169
+ ack: ackOrDropCmd,
170
+ drop: ackOrDropCmd,
168
171
  promote: promoteCmd,
169
172
  };
170
173
 
171
174
  /** Dispatch `inbox {list|ack|promote|drop}` to the matching sub-handler. */
172
- export function runInboxCommand(values, args, cli) {
173
- const sub = args[0];
175
+ export function runInboxCommand(ctx) {
176
+ const { runtime } = ctx.deps;
177
+ const sub = ctx.args.subcommand;
174
178
  const handler = SUBS[sub];
175
179
  if (!handler) {
176
- cli.usageError("inbox requires subcommand: list | ack | promote | drop");
177
- process.exit(2);
180
+ return {
181
+ ok: false,
182
+ code: 2,
183
+ error: "inbox requires subcommand: list | ack | promote | drop",
184
+ };
178
185
  }
179
- handler(values);
186
+ return handler(runtime, ctx.options);
180
187
  }
@@ -1,12 +1,6 @@
1
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
2
- import { spawnSync } from "node:child_process";
3
1
  import path from "node:path";
4
- import fsAsync from "node:fs/promises";
5
- import { Finder } from "@forwardimpact/libutil";
6
- import { createScriptConfig } from "@forwardimpact/libconfig";
7
- import { WikiRepo } from "../wiki-repo.js";
8
2
  import { listSkills } from "../skill-roster.js";
9
- import { createDefaultIo } from "../io.js";
3
+ import { resolveProjectRoot, resolveWikiRoot } from "../util/wiki-dir.js";
10
4
  import {
11
5
  ACTIVE_CLAIMS_HEADING,
12
6
  ACTIVE_CLAIMS_TABLE_HEADER,
@@ -14,22 +8,21 @@ import {
14
8
  } from "../constants.js";
15
9
 
16
10
  /** Resolve the wiki clone URL. Honors the FIT_WIKI_URL env var as an explicit override (for sandboxed environments where `origin` is rewritten to a local proxy that does not serve wiki repos); otherwise derives the URL by appending `.wiki.git` to the parent repo's `origin` remote. */
17
- export function deriveWikiUrl(parentDir, env = process.env) {
11
+ export async function deriveWikiUrl(gitClient, parentDir, env) {
18
12
  if (env.FIT_WIKI_URL) return env.FIT_WIKI_URL;
19
-
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
- const origin = r.stdout.trim();
26
- const base = origin.replace(/\.git$/, "");
27
- return base + ".wiki.git";
13
+ try {
14
+ const origin = await gitClient.remoteGetUrl("origin", { cwd: parentDir });
15
+ if (!origin) return null;
16
+ const base = origin.replace(/\.git$/, "");
17
+ return base + ".wiki.git";
18
+ } catch {
19
+ return null;
20
+ }
28
21
  }
29
22
 
30
- function scaffoldActiveClaims(memoryPath) {
31
- if (!existsSync(memoryPath)) return false;
32
- const text = readFileSync(memoryPath, "utf-8");
23
+ function scaffoldActiveClaims(runtime, memoryPath) {
24
+ if (!runtime.fsSync.existsSync(memoryPath)) return false;
25
+ const text = runtime.fsSync.readFileSync(memoryPath, "utf-8");
33
26
  if (new RegExp(`^${ACTIVE_CLAIMS_HEADING}$`, "m").test(text)) return false;
34
27
 
35
28
  const block = [
@@ -48,65 +41,66 @@ function scaffoldActiveClaims(memoryPath) {
48
41
  const lines = text.split("\n");
49
42
  const storyboardIdx = lines.findIndex((l) => l.trim() === "## Storyboard");
50
43
  if (storyboardIdx === -1) {
51
- writeFileSync(memoryPath, text.replace(/\n*$/, "") + "\n" + block + "\n");
44
+ runtime.fsSync.writeFileSync(
45
+ memoryPath,
46
+ text.replace(/\n*$/, "") + "\n" + block + "\n",
47
+ );
52
48
  return true;
53
49
  }
54
50
  lines.splice(storyboardIdx, 0, ...block.split("\n"), "");
55
- writeFileSync(memoryPath, lines.join("\n"));
51
+ runtime.fsSync.writeFileSync(memoryPath, lines.join("\n"));
56
52
  return true;
57
53
  }
58
54
 
59
- async function maybeCloneWiki(projectRoot, wikiDir, io) {
60
- const wikiUrl = deriveWikiUrl(projectRoot, io.env);
55
+ async function maybeCloneWiki(wikiSync, gitClient, projectRoot, runtime) {
56
+ const wikiUrl = await deriveWikiUrl(gitClient, projectRoot, runtime.proc.env);
61
57
  if (!wikiUrl) {
62
- io.stderr("init: could not determine wiki URL from origin remote\n");
58
+ runtime.proc.stderr.write(
59
+ "init: could not determine wiki URL from origin remote\n",
60
+ );
63
61
  return;
64
62
  }
65
- const config = await createScriptConfig("wiki");
66
- const repo = new WikiRepo({
67
- wikiDir,
68
- parentDir: projectRoot,
69
- resolveToken: () => config.ghToken(),
70
- });
71
- const cloneResult = repo.ensureCloned(wikiUrl);
63
+ const cloneResult = await wikiSync.ensureCloned(wikiUrl);
72
64
  if (cloneResult.cloned) {
73
- repo.inheritIdentity();
65
+ await wikiSync.inheritIdentity();
74
66
  } else {
75
- io.stderr("init: could not clone wiki, continuing with local-only steps\n");
67
+ runtime.proc.stderr.write(
68
+ "init: could not clone wiki, continuing with local-only steps\n",
69
+ );
76
70
  }
77
71
  }
78
72
 
79
73
  /** Clone the wiki if not already present, scaffold Active Claims in MEMORY.md, and create per-skill metric directories. */
80
- export async function runInitCommand(
81
- values,
82
- _args,
83
- _cli,
84
- io = createDefaultIo(),
85
- ) {
86
- const logger = { debug() {} };
87
- const finder = new Finder(fsAsync, logger, { cwd: io.cwd });
88
- const projectRoot = finder.findProjectRoot(io.cwd());
74
+ export async function runInitCommand(ctx) {
75
+ const { runtime, wikiSync, gitClient } = ctx.deps;
76
+ const options = ctx.options;
77
+ const projectRoot = resolveProjectRoot(runtime);
89
78
 
90
- const wikiDir = path.resolve(projectRoot, values["wiki-root"] ?? "wiki");
79
+ const wikiDir = resolveWikiRoot(runtime, options);
91
80
  const skillsDir = path.resolve(
92
81
  projectRoot,
93
- values["skills-dir"] ?? path.join(".claude", "skills"),
82
+ options["skills-dir"] ?? path.join(".claude", "skills"),
94
83
  );
95
84
 
96
- await maybeCloneWiki(projectRoot, wikiDir, io);
85
+ await maybeCloneWiki(wikiSync, gitClient, projectRoot, runtime);
97
86
 
98
- if (existsSync(skillsDir)) {
99
- for (const slug of listSkills({ skillsDir })) {
100
- mkdirSync(path.join(wikiDir, "metrics", slug), { recursive: true });
87
+ if (runtime.fsSync.existsSync(skillsDir)) {
88
+ for (const slug of listSkills({ skillsDir }, runtime.fsSync)) {
89
+ runtime.fsSync.mkdirSync(path.join(wikiDir, "metrics", slug), {
90
+ recursive: true,
91
+ });
101
92
  }
102
93
  }
103
94
 
104
- if (existsSync(wikiDir)) {
95
+ if (runtime.fsSync.existsSync(wikiDir)) {
105
96
  const memoryPath = path.join(wikiDir, "MEMORY.md");
106
- if (scaffoldActiveClaims(memoryPath)) {
107
- io.stdout(`init: scaffolded ${ACTIVE_CLAIMS_HEADING} in ${memoryPath}\n`);
97
+ if (scaffoldActiveClaims(runtime, memoryPath)) {
98
+ runtime.proc.stdout.write(
99
+ `init: scaffolded ${ACTIVE_CLAIMS_HEADING} in ${memoryPath}\n`,
100
+ );
108
101
  }
109
102
  }
110
103
 
111
- io.stdout(`init: wiki ready at ${wikiDir}\n`);
104
+ runtime.proc.stdout.write(`init: wiki ready at ${wikiDir}\n`);
105
+ return { ok: true };
112
106
  }
@@ -1,31 +1,22 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import fsAsync from "node:fs/promises";
3
- import path from "node:path";
4
- import { Finder } from "@forwardimpact/libutil";
5
1
  import {
6
2
  weeklyLogPath,
7
3
  rotateIfOverBudget,
8
4
  appendEntry,
9
5
  } from "../weekly-log.js";
10
6
  import { DECISION_HEADING } from "../constants.js";
11
- import { createDefaultIo } from "../io.js";
7
+ import { currentDayIso } from "../util/clock.js";
8
+ import { resolveWikiRoot } from "../util/wiki-dir.js";
12
9
 
13
- function projectRootForCommand(io) {
14
- const logger = { debug() {} };
15
- const finder = new Finder(fsAsync, logger, { cwd: io.cwd });
16
- return finder.findProjectRoot(io.cwd());
17
- }
18
-
19
- function commonContext(values, io) {
20
- const agent = values.agent || io.env.LIBEVAL_AGENT_PROFILE;
10
+ function commonContext(runtime, options) {
11
+ const agent = options.agent || runtime.proc.env.LIBEVAL_AGENT_PROFILE;
21
12
  if (!agent) {
22
- io.stderr("log requires --agent <name> or LIBEVAL_AGENT_PROFILE env var\n");
23
- io.exit(2);
24
- return null;
13
+ runtime.proc.stderr.write(
14
+ "log requires --agent <name> or LIBEVAL_AGENT_PROFILE env var\n",
15
+ );
16
+ return { error: { ok: false, code: 2 } };
25
17
  }
26
- const projectRoot = projectRootForCommand(io);
27
- const wikiRoot = values["wiki-root"] || path.join(projectRoot, "wiki");
28
- const today = values.today || io.today();
18
+ const wikiRoot = resolveWikiRoot(runtime, options);
19
+ const today = options.today || currentDayIso(runtime);
29
20
  return { agent, wikiRoot, today };
30
21
  }
31
22
 
@@ -39,14 +30,14 @@ function lastDateHeading(text) {
39
30
  return last;
40
31
  }
41
32
 
42
- function runDecision(values, io) {
43
- const ctx = commonContext(values, io);
44
- if (!ctx) return;
33
+ function runDecision(runtime, options) {
34
+ const ctx = commonContext(runtime, options);
35
+ if (ctx.error) return ctx.error;
45
36
  const { agent, wikiRoot, today } = ctx;
46
- const surveyed = values.surveyed || "—";
47
- const chosen = values.chosen || "—";
48
- const rationale = values.rationale || "—";
49
- const alternatives = values.alternatives || "—";
37
+ const surveyed = options.surveyed || "—";
38
+ const chosen = options.chosen || "—";
39
+ const rationale = options.rationale || "—";
40
+ const alternatives = options.alternatives || "—";
50
41
  const body = [
51
42
  `## ${today}`,
52
43
  "",
@@ -62,55 +53,70 @@ function runDecision(values, io) {
62
53
  "",
63
54
  ].join("\n");
64
55
  const lineCount = body.split("\n").length;
65
- rotateIfOverBudget(wikiRoot, agent, today, lineCount);
56
+ rotateIfOverBudget(wikiRoot, agent, today, lineCount, {}, runtime.fsSync);
66
57
  const target = weeklyLogPath(wikiRoot, agent, today);
67
- appendEntry(target, body, agent, today);
68
- io.stdout(`logged decision to ${target}\n`);
58
+ appendEntry(target, body, agent, today, runtime.fsSync);
59
+ runtime.proc.stdout.write(`logged decision to ${target}\n`);
60
+ return { ok: true };
69
61
  }
70
62
 
71
- function runNote(values, io) {
72
- const ctx = commonContext(values, io);
73
- if (!ctx) return;
63
+ function runNote(runtime, options) {
64
+ const ctx = commonContext(runtime, options);
65
+ if (ctx.error) return ctx.error;
74
66
  const { agent, wikiRoot, today } = ctx;
75
- if (!values.field || !values.body) {
76
- io.stderr("log note requires --field and --body\n");
77
- io.exit(2);
78
- return;
67
+ if (!options.field || !options.body) {
68
+ runtime.proc.stderr.write("log note requires --field and --body\n");
69
+ return { ok: false, code: 2 };
79
70
  }
80
- const fieldBlock = `### ${values.field}\n\n${values.body}\n`;
71
+ const fieldBlock = `### ${options.field}\n\n${options.body}\n`;
81
72
  // Conservative line budget: assume we'll prepend a date heading.
82
73
  const withHeading = `## ${today}\n\n${fieldBlock}`;
83
- rotateIfOverBudget(wikiRoot, agent, today, withHeading.split("\n").length);
74
+ rotateIfOverBudget(
75
+ wikiRoot,
76
+ agent,
77
+ today,
78
+ withHeading.split("\n").length,
79
+ {},
80
+ runtime.fsSync,
81
+ );
84
82
  const target = weeklyLogPath(wikiRoot, agent, today);
85
83
  // Append under the open entry if the file's last `## YYYY-MM-DD` is today;
86
84
  // otherwise open a new entry by prepending a date heading.
87
- const existing = existsSync(target) ? readFileSync(target, "utf-8") : "";
85
+ const existing = runtime.fsSync.existsSync(target)
86
+ ? runtime.fsSync.readFileSync(target, "utf-8")
87
+ : "";
88
88
  const body = lastDateHeading(existing) === today ? fieldBlock : withHeading;
89
- appendEntry(target, body, agent, today);
90
- io.stdout(`logged note to ${target}\n`);
89
+ appendEntry(target, body, agent, today, runtime.fsSync);
90
+ runtime.proc.stdout.write(`logged note to ${target}\n`);
91
+ return { ok: true };
91
92
  }
92
93
 
93
- function runDone(values, io) {
94
- const ctx = commonContext(values, io);
95
- if (!ctx) return;
94
+ function runDone(runtime, options) {
95
+ const ctx = commonContext(runtime, options);
96
+ if (ctx.error) return ctx.error;
96
97
  const { agent, wikiRoot, today } = ctx;
97
98
  const body = `### Closed\n\nRun closed ${today}.\n`;
98
99
  const lineCount = body.split("\n").length;
99
- rotateIfOverBudget(wikiRoot, agent, today, lineCount);
100
+ rotateIfOverBudget(wikiRoot, agent, today, lineCount, {}, runtime.fsSync);
100
101
  const target = weeklyLogPath(wikiRoot, agent, today);
101
- appendEntry(target, body, agent, today);
102
- io.stdout(`closed entry in ${target}\n`);
102
+ appendEntry(target, body, agent, today, runtime.fsSync);
103
+ runtime.proc.stdout.write(`closed entry in ${target}\n`);
104
+ return { ok: true };
103
105
  }
104
106
 
105
107
  const SUBS = { decision: runDecision, note: runNote, done: runDone };
106
108
 
107
109
  /** Dispatch `log {decision|note|done}` to the matching sub-handler. */
108
- export function runLogCommand(values, args, cli, io = createDefaultIo()) {
109
- const sub = args[0];
110
+ export function runLogCommand(ctx) {
111
+ const { runtime } = ctx.deps;
112
+ const sub = ctx.args.subcommand;
110
113
  const handler = SUBS[sub];
111
114
  if (!handler) {
112
- cli.usageError("log requires subcommand: decision | note | done");
113
- return io.exit(2);
115
+ return {
116
+ ok: false,
117
+ code: 2,
118
+ error: "log requires subcommand: decision | note | done",
119
+ };
114
120
  }
115
- handler(values, io);
121
+ return handler(runtime, ctx.options);
116
122
  }