@forwardimpact/libwiki 0.2.31 → 0.2.33

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/README.md CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  <!-- BEGIN:description — Do not edit. Generated from package.json. -->
4
4
 
5
- Wiki lifecycle primitives — stable memory for agent teams so coordination
6
- persists across sessions.
5
+ Wiki lifecycle for agent teams — persistent memory, declarative integrity
6
+ audits, and a collision ledger so coordination survives across sessions and
7
+ parallel work.
7
8
 
8
9
  <!-- END:description -->
9
10
 
@@ -157,3 +158,5 @@ import {
157
158
 
158
159
  - [Operate a Predictable Agent Team](https://www.forwardimpact.team/docs/libraries/predictable-team/index.md)
159
160
  - [Send a Memo or Update a Storyboard](https://www.forwardimpact.team/docs/libraries/predictable-team/wiki-operations/index.md)
161
+ - [Audit and Auto-Fix the Wiki](https://www.forwardimpact.team/docs/libraries/predictable-team/wiki-integrity/index.md)
162
+ - [Allocate Collision-Ledger Entries for Parallel Work](https://www.forwardimpact.team/docs/libraries/predictable-team/collision-ledger/index.md)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@forwardimpact/libwiki",
3
- "version": "0.2.31",
4
- "description": "Wiki lifecycle primitives — stable memory for agent teams so coordination persists across sessions.",
3
+ "version": "0.2.33",
4
+ "description": "Wiki lifecycle for agent teams — persistent memory, declarative integrity audits, and a collision ledger so coordination survives across sessions and parallel work.",
5
5
  "keywords": [
6
6
  "wiki",
7
7
  "memo",
@@ -23,7 +23,7 @@
23
23
  "goal": "Operate a Predictable Agent Team",
24
24
  "trigger": "An agent finishes a session and its findings vanish because there is no shared memory to write them to.",
25
25
  "bigHire": "give agent teams stable memory that persists across sessions.",
26
- "littleHire": "send a memo or update a storyboard without managing the wiki infrastructure.",
26
+ "littleHire": "send a memo, run an integrity audit, or refresh a storyboard without managing the wiki infrastructure.",
27
27
  "competesWith": "git commit messages as memory; ephemeral conversation context; starting every session from scratch"
28
28
  }
29
29
  ],
@@ -9,6 +9,7 @@ import { runClaimCommand, runReleaseCommand } from "./commands/claim.js";
9
9
  import { runInboxCommand } from "./commands/inbox.js";
10
10
  import { runRotateCommand } from "./commands/rotate.js";
11
11
  import { runAuditCommand } from "./commands/audit.js";
12
+ import { runCurateCommand } from "./commands/curate.js";
12
13
  import { runFixCommand } from "./commands/fix.js";
13
14
  import { runLedgerCommand } from "./commands/ledger.js";
14
15
 
@@ -174,6 +175,25 @@ export function createDefinition() {
174
175
  },
175
176
  },
176
177
  },
178
+ {
179
+ name: "curate",
180
+ description:
181
+ "Audit the shared wiki and route any findings to the single wiki-curation issue (create or comment), addressed to the technical-writer",
182
+ handler: runCurateCommand,
183
+ options: {
184
+ ...wikiRootOpt,
185
+ ...todayOpt,
186
+ repo: {
187
+ type: "string",
188
+ description: "owner/repo slug (default: origin remote)",
189
+ },
190
+ "dry-run": {
191
+ type: "boolean",
192
+ description:
193
+ "Print the issue body and intended action without calling gh",
194
+ },
195
+ },
196
+ },
177
197
  {
178
198
  name: "fix",
179
199
  description:
@@ -335,6 +355,7 @@ export function createDefinition() {
335
355
  "fit-wiki inbox list --agent staff-engineer",
336
356
  "fit-wiki rotate --agent staff-engineer",
337
357
  "fit-wiki audit",
358
+ "fit-wiki curate",
338
359
  "fit-wiki fix",
339
360
  'fit-wiki memo --from staff-engineer --to security-engineer --message "audit d642ff0c"',
340
361
  "fit-wiki refresh",
@@ -354,7 +375,19 @@ export function createDefinition() {
354
375
  title: "Send a Memo or Update a Storyboard",
355
376
  url: "https://www.forwardimpact.team/docs/libraries/predictable-team/wiki-operations/index.md",
356
377
  description:
357
- "Send cross-team memos, refresh storyboard charts, and sync the wiki.",
378
+ "Send cross-team memos, refresh storyboard charts, sync the wiki, and record the product-mix metric.",
379
+ },
380
+ {
381
+ title: "Audit and Auto-Fix the Wiki",
382
+ url: "https://www.forwardimpact.team/docs/libraries/predictable-team/wiki-integrity/index.md",
383
+ description:
384
+ "Check the wiki against the rule catalogue, auto-fix what is safe, and flag the rest for a human.",
385
+ },
386
+ {
387
+ title: "Allocate Collision-Ledger Entries for Parallel Work",
388
+ url: "https://www.forwardimpact.team/docs/libraries/predictable-team/collision-ledger/index.md",
389
+ description:
390
+ "Assign stable, collision-free ids to parallel work and rebuild the ledger projections.",
358
391
  },
359
392
  ],
360
393
  };
@@ -9,8 +9,14 @@ import { buildContext, resolveScope } from "../audit/scopes.js";
9
9
  import { currentDayIso } from "../util/clock.js";
10
10
  import { resolveProjectRoot } from "../util/wiki-dir.js";
11
11
 
12
- /** Run the wiki audit and emit findings. JSON via --format json. */
13
- export function runAuditCommand(ctx) {
12
+ /**
13
+ * Run the wiki audit and return its findings plus the resolved project root.
14
+ * Shared by `runAuditCommand` (emits them) and `runCurateCommand` (routes
15
+ * them to an issue) so the two cannot drift.
16
+ * @param {import("@forwardimpact/libcli").InvocationContext} ctx
17
+ * @returns {{ findings: object[], projectRoot: string }}
18
+ */
19
+ export function auditWiki(ctx) {
14
20
  const { runtime } = ctx.deps;
15
21
  const options = ctx.options;
16
22
  const projectRoot = resolveProjectRoot(runtime);
@@ -23,7 +29,14 @@ export function runAuditCommand(ctx) {
23
29
  fs: runtime.fsSync,
24
30
  subprocess: runtime.subprocess,
25
31
  });
26
- const findings = runRules(RULES, auditCtx, { resolveScope });
32
+ return { findings: runRules(RULES, auditCtx, { resolveScope }), projectRoot };
33
+ }
34
+
35
+ /** Run the wiki audit and emit findings. JSON via --format json. */
36
+ export function runAuditCommand(ctx) {
37
+ const { runtime } = ctx.deps;
38
+ const options = ctx.options;
39
+ const { findings, projectRoot } = auditWiki(ctx);
27
40
 
28
41
  runtime.proc.stdout.write(
29
42
  options.format === "json"
@@ -0,0 +1,241 @@
1
+ import path from "node:path";
2
+ import { emitFindingsJson } from "@forwardimpact/libutil";
3
+ import { createLogger } from "@forwardimpact/libtelemetry";
4
+ import { createScriptConfig } from "@forwardimpact/libconfig";
5
+ import { parseRepoSlug } from "../issue-list-renderer.js";
6
+ import { resolveProjectRoot } from "../util/wiki-dir.js";
7
+ import { auditWiki } from "./audit.js";
8
+
9
+ // The routing contract: a single open issue, addressed to the technical-writer,
10
+ // holding the audit findings. The title is matched verbatim on every run so a
11
+ // dirty wiki appends to one issue rather than opening a new one each day.
12
+ const LABEL = {
13
+ name: "wiki-curation",
14
+ color: "BFD4F2",
15
+ description: "Shared-wiki audit findings from scheduled curation",
16
+ };
17
+ const TITLE = "Wiki curation: shared-state audit findings";
18
+
19
+ // GitHub rejects an issue or comment body over 65536 characters. Keep the whole
20
+ // body under a margin below that so the preamble, JSON fence, and truncation
21
+ // notice always fit. When the findings overflow, the body carries the first N
22
+ // that fit plus a count; the full list stays reproducible via `fit-wiki audit`.
23
+ const MAX_BODY = 65000;
24
+
25
+ /**
26
+ * Compose the issue body from the audit's JSON findings. The findings ride a
27
+ * fenced ```json block; the body is passed to `gh` via `--body-file` (a temp
28
+ * file), never argv, so untrusted finding text cannot be misread as a flag.
29
+ * @param {string} findingsJson
30
+ * @param {{shown?: number, total?: number}} [trunc]
31
+ * @returns {string}
32
+ */
33
+ function buildBody(findingsJson, { shown, total } = {}) {
34
+ const lines = [
35
+ "Scheduled `curate-wiki` audit found shared-wiki violations.",
36
+ "",
37
+ "Owner: **technical-writer** (service these via the curation shift; the per-PR `wiki` gate no longer reads shared wiki state).",
38
+ "",
39
+ ];
40
+ if (total != null && shown != null && shown < total) {
41
+ lines.push(
42
+ `Showing ${shown} of ${total} findings — the body was truncated to fit GitHub's comment limit. Run \`fit-wiki audit\` for the full list.`,
43
+ "",
44
+ );
45
+ }
46
+ lines.push("```json", findingsJson, "```", "");
47
+ return lines.join("\n");
48
+ }
49
+
50
+ /**
51
+ * Build the largest postable body for the audit findings. The full findings
52
+ * usually fit; when they don't, keep the first N `fail` findings that stay
53
+ * under GitHub's body limit and label the body as truncated. The shrink steps
54
+ * down proportionally to the overflow, so it converges in a couple of passes.
55
+ * @param {{level: string}[]} findings
56
+ * @returns {string}
57
+ */
58
+ function fitBody(findings) {
59
+ const total = findings.filter((f) => f.level === "fail").length;
60
+ const full = buildBody(emitFindingsJson(findings), { shown: total, total });
61
+ if (full.length <= MAX_BODY) return full;
62
+
63
+ const failures = findings.filter((f) => f.level === "fail");
64
+ let shown = failures.length;
65
+ while (shown > 0) {
66
+ const body = buildBody(emitFindingsJson(failures.slice(0, shown)), {
67
+ shown,
68
+ total,
69
+ });
70
+ if (body.length <= MAX_BODY) return body;
71
+ const next = Math.floor((shown * MAX_BODY) / body.length);
72
+ shown = next < shown ? next : shown - 1;
73
+ }
74
+ return buildBody(emitFindingsJson([]), { shown: 0, total });
75
+ }
76
+
77
+ // Resolve the monorepo's `owner/repo` slug the way refresh.js/product-mix.js
78
+ // do: an explicit FIT_GH_REPO override (sandbox proxy URLs), else the origin
79
+ // remote parsed via the injected git client. Null lets `gh` fall back to its
80
+ // own cwd resolution.
81
+ async function deriveRepo(gitClient, cwd, env) {
82
+ if (env.FIT_GH_REPO) return env.FIT_GH_REPO;
83
+ if (!gitClient) return null;
84
+ try {
85
+ return parseRepoSlug(await gitClient.remoteGetUrl("origin", { cwd }));
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ // A missing token is non-fatal: `gh` may still resolve ambient auth.
92
+ async function resolveToken() {
93
+ try {
94
+ return (await createScriptConfig("wiki")).ghToken();
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Find the open `wiki-curation` issue by its verbatim title, or null. Any
102
+ * parse failure or empty result is treated as "no issue" (create path).
103
+ * @param {import("@forwardimpact/libcli").InvocationContext["deps"]["runtime"]} runtime
104
+ * @param {string[]} repoArgs
105
+ * @param {{cwd: string, env: object}} opts
106
+ * @returns {Promise<number|null>}
107
+ */
108
+ async function findOpenIssue(runtime, repoArgs, opts) {
109
+ const list = await runtime.subprocess.run(
110
+ "gh",
111
+ [
112
+ "issue",
113
+ "list",
114
+ "--search",
115
+ `${TITLE} in:title`,
116
+ "--state",
117
+ "open",
118
+ "--json",
119
+ "number",
120
+ ...repoArgs,
121
+ ],
122
+ opts,
123
+ );
124
+ try {
125
+ return JSON.parse(list.stdout || "[]")[0]?.number ?? null;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Route the composed body to the single `wiki-curation` issue: ensure the
133
+ * label, find the open issue by title, then comment on it or create it. The
134
+ * body goes through a temp file (never argv) so untrusted finding text cannot
135
+ * be read as a flag. On a `gh` failure the reason is logged and `ok:false`
136
+ * returned so the caller exits non-zero.
137
+ * @param {import("@forwardimpact/libcli").InvocationContext} ctx
138
+ * @param {string} body
139
+ * @param {ReturnType<typeof createLogger>} logger
140
+ * @returns {Promise<{ok: boolean}>}
141
+ */
142
+ async function routeFindings(ctx, body, logger) {
143
+ const { runtime, gitClient } = ctx.deps;
144
+ const cwd = resolveProjectRoot(runtime);
145
+ const repo =
146
+ ctx.options.repo || (await deriveRepo(gitClient, cwd, runtime.proc.env));
147
+ const token = await resolveToken();
148
+ const env = token
149
+ ? { ...runtime.proc.env, GH_TOKEN: token }
150
+ : runtime.proc.env;
151
+ const repoArgs = repo ? ["--repo", repo] : [];
152
+
153
+ // Ensure the label exists; a re-create on an existing label exits non-zero,
154
+ // which is expected and ignored.
155
+ await runtime.subprocess.run(
156
+ "gh",
157
+ [
158
+ "label",
159
+ "create",
160
+ LABEL.name,
161
+ "--color",
162
+ LABEL.color,
163
+ "--description",
164
+ LABEL.description,
165
+ ...repoArgs,
166
+ ],
167
+ { cwd, env },
168
+ );
169
+
170
+ const number = await findOpenIssue(runtime, repoArgs, { cwd, env });
171
+
172
+ // Pass the body through a temp file, not argv — robust to length and immune
173
+ // to finding text being read as a flag.
174
+ const tmp = runtime.proc.env.RUNNER_TEMP || runtime.proc.env.TMPDIR || "/tmp";
175
+ const bodyFile = path.join(tmp, "wiki-curation-body.md");
176
+ runtime.fsSync.writeFileSync(bodyFile, body);
177
+
178
+ const args = number
179
+ ? ["issue", "comment", String(number), "--body-file", bodyFile, ...repoArgs]
180
+ : [
181
+ "issue",
182
+ "create",
183
+ "--title",
184
+ TITLE,
185
+ "--body-file",
186
+ bodyFile,
187
+ "--label",
188
+ LABEL.name,
189
+ ...repoArgs,
190
+ ];
191
+ const result = await runtime.subprocess.run("gh", args, { cwd, env });
192
+
193
+ if (result.exitCode !== 0) {
194
+ const detail = (result.stderr || result.stdout || "").trim();
195
+ const action = number ? "comment" : "create";
196
+ logger.warn(
197
+ "curate",
198
+ `gh issue ${action} failed${detail ? `: ${detail}` : ""}`,
199
+ );
200
+ return { ok: false };
201
+ }
202
+ runtime.proc.stdout.write(
203
+ number
204
+ ? `Commented curation findings on issue #${number}\n`
205
+ : "Opened a new wiki-curation issue\n",
206
+ );
207
+ return { ok: true };
208
+ }
209
+
210
+ /**
211
+ * Audit the shared wiki and, when it is dirty, route the findings to the
212
+ * single `wiki-curation` issue (create or comment) addressed to the
213
+ * technical-writer. This is the SOLE home of the shared-wiki audit verdict; the
214
+ * per-PR `wiki` gate no longer reads live wiki state. A clean wiki routes
215
+ * nothing. The label/search/create-or-comment logic lives here, not in the
216
+ * workflow, so the curation step is one CLI call.
217
+ *
218
+ * @param {import("@forwardimpact/libcli").InvocationContext} ctx
219
+ * @returns {Promise<{ok: boolean}>}
220
+ */
221
+ export async function runCurateCommand(ctx) {
222
+ const { runtime } = ctx.deps;
223
+ const logger = createLogger("wiki", runtime);
224
+ const { findings } = auditWiki(ctx);
225
+
226
+ if (!findings.some((f) => f.level === "fail")) {
227
+ runtime.proc.stdout.write("wiki audit clean — no curation issue routed\n");
228
+ return { ok: true };
229
+ }
230
+
231
+ const body = fitBody(findings);
232
+
233
+ if (ctx.options["dry-run"]) {
234
+ runtime.proc.stdout.write(
235
+ `[dry-run] would route findings to issue "${TITLE}":\n\n${body}`,
236
+ );
237
+ return { ok: true };
238
+ }
239
+
240
+ return routeFindings(ctx, body, logger);
241
+ }