@forwardimpact/libwiki 0.1.2 → 0.1.5

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
@@ -1,7 +1,11 @@
1
1
  # libwiki
2
2
 
3
- Wiki lifecycle primitives for the Kata agent system. Provides cross-team memo
4
- delivery, agent roster discovery, and insertion-marker migration.
3
+ <!-- BEGIN:description Do not edit. Generated from package.json. -->
4
+
5
+ Wiki lifecycle primitives — stable memory for agent teams so coordination
6
+ persists across sessions.
7
+
8
+ <!-- END:description -->
5
9
 
6
10
  ## Getting Started
7
11
 
package/bin/fit-wiki.js CHANGED
@@ -101,8 +101,14 @@ const definition = {
101
101
  ],
102
102
  documentation: [
103
103
  {
104
- title: "Wiki Operations",
105
- url: "https://www.forwardimpact.team/docs/libraries/wiki-operations/index.md",
104
+ title: "Operate a Predictable Agent Team",
105
+ url: "https://www.forwardimpact.team/docs/libraries/predictable-team/index.md",
106
+ description:
107
+ "End-to-end guide to wiki memory, XmR charts, and team coordination.",
108
+ },
109
+ {
110
+ title: "Send a Memo or Update a Storyboard",
111
+ url: "https://www.forwardimpact.team/docs/libraries/predictable-team/wiki-operations/index.md",
106
112
  description:
107
113
  "Send cross-team memos, refresh storyboard charts, and sync the wiki.",
108
114
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@forwardimpact/libwiki",
3
- "version": "0.1.2",
4
- "description": "Wiki lifecycle primitives for the Kata agent system: cross-team memos, storyboard XmR chart refresh, wiki bootstrap, and git sync.",
3
+ "version": "0.1.5",
4
+ "description": "Wiki lifecycle primitives stable memory for agent teams so coordination persists across sessions.",
5
5
  "keywords": [
6
6
  "wiki",
7
7
  "memo",
@@ -17,16 +17,16 @@
17
17
  },
18
18
  "license": "Apache-2.0",
19
19
  "author": "D. Olsson <hi@senzilla.io>",
20
- "forwardimpact": {
21
- "capability": "agent-self-improvement",
22
- "needs": [
23
- "Send a cross-team memo to a teammate's wiki inbox",
24
- "Refresh XmR chart blocks inside a storyboard markdown file",
25
- "Bootstrap a wiki working tree for a Kata installation",
26
- "Push agent-authored wiki changes to the remote",
27
- "Pull remote wiki changes into the local working tree"
28
- ]
29
- },
20
+ "jobs": [
21
+ {
22
+ "user": "Empowered Engineers",
23
+ "goal": "Operate a Predictable Agent Team",
24
+ "trigger": "An agent finishes a session and its findings vanish because there is no shared memory to write them to.",
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.",
27
+ "competesWith": "git commit messages as memory; ephemeral conversation context; starting every session from scratch"
28
+ }
29
+ ],
30
30
  "type": "module",
31
31
  "main": "./src/index.js",
32
32
  "exports": {
@@ -46,6 +46,7 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@forwardimpact/libcli": "^0.1.0",
49
+ "@forwardimpact/libconfig": "^0.1.77",
49
50
  "@forwardimpact/libutil": "^0.1.0",
50
51
  "@forwardimpact/libxmr": "^1.1.0"
51
52
  },
@@ -2,6 +2,7 @@ import { readdirSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { BROADCAST_TARGET } from "./constants.js";
4
4
 
5
+ /** List all agent markdown files in the agents directory, returning agent names and summary paths. */
5
6
  export function listAgents(
6
7
  { agentsDir, wikiRoot },
7
8
  fs = { readdirSync, statSync },
@@ -2,13 +2,16 @@ import { readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { analyze, renderChart, MIN_POINTS } from "@forwardimpact/libxmr";
4
4
 
5
+ /** Error thrown when an XmR block cannot be rendered due to missing CSV or metric. */
5
6
  export class BlockRenderError extends Error {
7
+ /** Create a BlockRenderError with the given reason string. */
6
8
  constructor(reason) {
7
9
  super(reason);
8
10
  this.name = "BlockRenderError";
9
11
  }
10
12
  }
11
13
 
14
+ /** Render an XmR chart block for a metric by reading its CSV and producing markdown lines. */
12
15
  export function renderBlock({
13
16
  metric,
14
17
  csvPath,
@@ -3,10 +3,14 @@ import { spawnSync } from "node:child_process";
3
3
  import path from "node:path";
4
4
  import fsAsync from "node:fs/promises";
5
5
  import { Finder } from "@forwardimpact/libutil";
6
+ import { createScriptConfig } from "@forwardimpact/libconfig";
6
7
  import { WikiRepo } from "../wiki-repo.js";
7
8
  import { listSkills } from "../skill-roster.js";
8
9
 
9
- function deriveWikiUrl(parentDir) {
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. */
11
+ export function deriveWikiUrl(parentDir) {
12
+ if (process.env.FIT_WIKI_URL) return process.env.FIT_WIKI_URL;
13
+
10
14
  const r = spawnSync("git", ["-C", parentDir, "remote", "get-url", "origin"], {
11
15
  encoding: "utf-8",
12
16
  stdio: "pipe",
@@ -17,7 +21,8 @@ function deriveWikiUrl(parentDir) {
17
21
  return base + ".wiki.git";
18
22
  }
19
23
 
20
- export function runInitCommand(values, _args, cli) {
24
+ /** Clone the wiki if not already present (URL derived from origin remote), copy git identity from the parent repo, and create metric directories for each kata skill. */
25
+ export async function runInitCommand(values, _args, cli) {
21
26
  const logger = { debug() {} };
22
27
  const finder = new Finder(fsAsync, logger, process);
23
28
  const projectRoot = finder.findProjectRoot(process.cwd());
@@ -36,7 +41,12 @@ export function runInitCommand(values, _args, cli) {
36
41
  return;
37
42
  }
38
43
 
39
- const repo = new WikiRepo({ wikiDir, parentDir: projectRoot });
44
+ const config = await createScriptConfig("wiki");
45
+ const repo = new WikiRepo({
46
+ wikiDir,
47
+ parentDir: projectRoot,
48
+ resolveToken: () => config.ghToken(),
49
+ });
40
50
 
41
51
  const cloneResult = repo.ensureCloned(wikiUrl);
42
52
  if (!cloneResult.cloned) {
@@ -15,6 +15,38 @@ function writeAndCheck(summaryPath, sender, message, today) {
15
15
  process.stdout.write(`wrote ${result.path}\n`);
16
16
  }
17
17
 
18
+ function resolveTargetPath(wikiRoot, target) {
19
+ const summaryPath = path.join(wikiRoot, target + ".md");
20
+ const resolvedRoot = path.resolve(wikiRoot);
21
+ const resolvedTarget = path.resolve(summaryPath);
22
+ const relative = path.relative(resolvedRoot, resolvedTarget);
23
+ const escapesRoot =
24
+ relative === "" || relative.startsWith("..") || path.isAbsolute(relative);
25
+ return { summaryPath, escapesRoot };
26
+ }
27
+
28
+ function writeSingleTarget({ wikiRoot, target, sender, message, today, cli }) {
29
+ const { summaryPath, escapesRoot } = resolveTargetPath(wikiRoot, target);
30
+ if (escapesRoot) {
31
+ cli.usageError(`target escapes wiki root: ${target}`);
32
+ process.exit(2);
33
+ }
34
+ if (!existsSync(summaryPath)) {
35
+ cli.usageError(`target summary not found: ${summaryPath}`);
36
+ process.exit(2);
37
+ }
38
+ writeAndCheck(summaryPath, sender, message, today);
39
+ }
40
+
41
+ function writeBroadcast({ agentsDir, wikiRoot, sender, message, today }) {
42
+ const agents = listAgents({ agentsDir, wikiRoot });
43
+ for (const { agent, summaryPath } of agents) {
44
+ if (agent === sender) continue;
45
+ writeAndCheck(summaryPath, sender, message, today);
46
+ }
47
+ }
48
+
49
+ /** Write a memo to a target agent's summary file (or broadcast to all except the sender); sender is --from or LIBEVAL_AGENT_PROFILE env var. */
18
50
  export function runMemoCommand(values, _args, cli) {
19
51
  const sender = values.from || process.env.LIBEVAL_AGENT_PROFILE;
20
52
 
@@ -44,17 +76,21 @@ export function runMemoCommand(values, _args, cli) {
44
76
  const today = new Date().toISOString().slice(0, 10);
45
77
 
46
78
  if (values.to === BROADCAST_TARGET) {
47
- const agents = listAgents({ agentsDir, wikiRoot });
48
- for (const { agent, summaryPath } of agents) {
49
- if (agent === sender) continue;
50
- writeAndCheck(summaryPath, sender, values.message, today);
51
- }
79
+ writeBroadcast({
80
+ agentsDir,
81
+ wikiRoot,
82
+ sender,
83
+ message: values.message,
84
+ today,
85
+ });
52
86
  } else {
53
- const summaryPath = path.join(wikiRoot, values.to + ".md");
54
- if (!existsSync(summaryPath)) {
55
- cli.usageError(`target summary not found: ${summaryPath}`);
56
- process.exit(2);
57
- }
58
- writeAndCheck(summaryPath, sender, values.message, today);
87
+ writeSingleTarget({
88
+ wikiRoot,
89
+ target: values.to,
90
+ sender,
91
+ message: values.message,
92
+ today,
93
+ cli,
94
+ });
59
95
  }
60
96
  }
@@ -12,6 +12,7 @@ function currentStoryboardPath() {
12
12
  return `wiki/storyboard-${yyyy}-M${mm}.md`;
13
13
  }
14
14
 
15
+ /** Re-render all XmR chart blocks in a storyboard file by scanning markers and splicing updated content. */
15
16
  export function runRefreshCommand(values, args, cli) {
16
17
  const logger = { debug() {} };
17
18
  const finder = new Finder(fsAsync, logger, process);
@@ -1,15 +1,26 @@
1
1
  import path from "node:path";
2
2
  import fsAsync from "node:fs/promises";
3
3
  import { Finder } from "@forwardimpact/libutil";
4
+ import { createScriptConfig } from "@forwardimpact/libconfig";
4
5
  import { WikiRepo, WikiPullConflict } from "../wiki-repo.js";
5
6
 
6
- export function runPushCommand(values, _args, cli) {
7
+ async function buildRepo(values) {
7
8
  const logger = { debug() {} };
8
9
  const finder = new Finder(fsAsync, logger, process);
9
10
  const projectRoot = finder.findProjectRoot(process.cwd());
10
11
  const wikiDir = path.resolve(projectRoot, values["wiki-root"] ?? "wiki");
11
12
 
12
- const repo = new WikiRepo({ wikiDir, parentDir: projectRoot });
13
+ const config = await createScriptConfig("wiki");
14
+ return new WikiRepo({
15
+ wikiDir,
16
+ parentDir: projectRoot,
17
+ resolveToken: () => config.ghToken(),
18
+ });
19
+ }
20
+
21
+ /** Commit all wiki changes and push them to the remote wiki repository. */
22
+ export async function runPushCommand(values, _args, cli) {
23
+ const repo = await buildRepo(values);
13
24
  repo.inheritIdentity();
14
25
 
15
26
  const result = repo.commitAndPush("wiki: update from session");
@@ -20,13 +31,9 @@ export function runPushCommand(values, _args, cli) {
20
31
  }
21
32
  }
22
33
 
23
- export function runPullCommand(values, _args, cli) {
24
- const logger = { debug() {} };
25
- const finder = new Finder(fsAsync, logger, process);
26
- const projectRoot = finder.findProjectRoot(process.cwd());
27
- const wikiDir = path.resolve(projectRoot, values["wiki-root"] ?? "wiki");
28
-
29
- const repo = new WikiRepo({ wikiDir, parentDir: projectRoot });
34
+ /** Fetch and rebase the local wiki on origin/master; on rebase conflict, exit the process with code 1 and a message to resolve manually or push first. */
35
+ export async function runPullCommand(values, _args, cli) {
36
+ const repo = await buildRepo(values);
30
37
  repo.inheritIdentity();
31
38
 
32
39
  try {
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { MEMO_INBOX_MARKER, INBOX_HEADING } from "./constants.js";
3
3
  import { listAgents } from "./agent-roster.js";
4
4
 
5
+ /** Insert memo:inbox markers into agent summary files that have an inbox heading but no marker yet. */
5
6
  export function insertMarkers(
6
7
  { agentsDir, wikiRoot },
7
8
  fs = { readFileSync, writeFileSync },
@@ -1,6 +1,7 @@
1
1
  const OPEN_RE = /^<!--\s*xmr:([^:\s]+):([^\s]+)\s*-->\s*$/;
2
2
  const CLOSE_RE = /^<!--\s*\/xmr\s*-->\s*$/;
3
3
 
4
+ /** Scan text for paired xmr open/close HTML comment markers and return their line positions and metadata. */
4
5
  export function scanMarkers(text) {
5
6
  const lines = text.split("\n");
6
7
  const pairs = [];
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { MEMO_INBOX_MARKER } from "./constants.js";
3
3
 
4
+ /** Append a timestamped memo bullet below the inbox marker in an agent's summary file. */
4
5
  export function writeMemo(
5
6
  { summaryPath, sender, message, today },
6
7
  fs = { readFileSync, writeFileSync },
@@ -1,6 +1,7 @@
1
1
  import { readdirSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
+ /** List all kata-prefixed skill directory names under the skills directory, sorted alphabetically. */
4
5
  export function listSkills({ skillsDir }, fs = { readdirSync, statSync }) {
5
6
  const entries = fs.readdirSync(skillsDir);
6
7
  const skills = [];
package/src/wiki-repo.js CHANGED
@@ -3,7 +3,9 @@ import { spawnSync } from "node:child_process";
3
3
  const CREDENTIAL_HELPER_BODY =
4
4
  '!f() { echo username=x-access-token; echo "password=${GH_TOKEN:-$GITHUB_TOKEN}"; }; f';
5
5
 
6
+ /** Error thrown when a wiki pull encounters a rebase conflict that cannot be resolved automatically. */
6
7
  export class WikiPullConflict extends Error {
8
+ /** Create a WikiPullConflict with the stderr output from the failed rebase. */
7
9
  constructor(stderr) {
8
10
  super("rebase conflict on pull");
9
11
  this.name = "WikiPullConflict";
@@ -11,6 +13,7 @@ export class WikiPullConflict extends Error {
11
13
  }
12
14
  }
13
15
 
16
+ /** Prepend credential-helper config arguments to a git command when a token is available. */
14
17
  export function buildAuthArgs(args, token) {
15
18
  if (token) {
16
19
  return [
@@ -24,15 +27,38 @@ export function buildAuthArgs(args, token) {
24
27
  return [...args];
25
28
  }
26
29
 
30
+ /** Git operations wrapper for the GitHub wiki repository used as agent team memory. */
27
31
  export class WikiRepo {
28
32
  #wikiDir;
29
33
  #parentDir;
34
+ #resolveToken;
30
35
 
31
- constructor({ wikiDir, parentDir }) {
36
+ /**
37
+ * Create a WikiRepo targeting the given wiki directory and its parent project directory.
38
+ * @param {{ wikiDir: string, parentDir: string, resolveToken: () => string | null }} opts
39
+ * `resolveToken` is called lazily before each network operation. Return a
40
+ * GitHub token string to authenticate, or `null` to run anonymously. The
41
+ * callback owns the entire resolution policy — libwiki does not read
42
+ * `process.env` directly. Throws propagate to the caller so credential
43
+ * misconfiguration surfaces loudly. Commands typically pass
44
+ * `() => config.ghToken()` from `@forwardimpact/libconfig`.
45
+ */
46
+ constructor({ wikiDir, parentDir, resolveToken }) {
47
+ if (typeof wikiDir !== "string" || wikiDir === "") {
48
+ throw new TypeError("WikiRepo: wikiDir must be a non-empty string");
49
+ }
50
+ if (typeof parentDir !== "string" || parentDir === "") {
51
+ throw new TypeError("WikiRepo: parentDir must be a non-empty string");
52
+ }
53
+ if (typeof resolveToken !== "function") {
54
+ throw new TypeError("WikiRepo: resolveToken callback is required");
55
+ }
32
56
  this.#wikiDir = wikiDir;
33
57
  this.#parentDir = parentDir;
58
+ this.#resolveToken = resolveToken;
34
59
  }
35
60
 
61
+ /** Check whether the wiki directory is an initialized git repository. */
36
62
  isCloned() {
37
63
  const r = spawnSync(
38
64
  "git",
@@ -44,6 +70,7 @@ export class WikiRepo {
44
70
  return r.status === 0;
45
71
  }
46
72
 
73
+ /** Clone the wiki from the given URL if it is not already cloned. */
47
74
  ensureCloned(url) {
48
75
  if (this.isCloned()) return { cloned: true, reason: "already-cloned" };
49
76
  const r = this.#authGit(["clone", url, this.#wikiDir]);
@@ -56,6 +83,7 @@ export class WikiRepo {
56
83
  return { cloned: true, reason: "cloned" };
57
84
  }
58
85
 
86
+ /** Copy git user.name and user.email from the parent repository into the wiki repository. */
59
87
  inheritIdentity() {
60
88
  const name = this.#parentConfig("user.name");
61
89
  const email = this.#parentConfig("user.email");
@@ -63,15 +91,18 @@ export class WikiRepo {
63
91
  if (email) this.#git(["config", "user.email", email]);
64
92
  }
65
93
 
94
+ /** Fetch the latest master branch from the wiki remote using token auth if available. */
66
95
  fetch() {
67
96
  this.#authGit(["-C", this.#wikiDir, "fetch", "origin", "master"]);
68
97
  }
69
98
 
99
+ /** Return true if the wiki working tree has no uncommitted changes. */
70
100
  isClean() {
71
101
  const r = this.#git(["status", "--porcelain"]);
72
102
  return r.stdout.toString().trim() === "";
73
103
  }
74
104
 
105
+ /** Fetch and rebase on origin/master, throwing WikiPullConflict if the rebase fails. */
75
106
  pull() {
76
107
  this.fetch();
77
108
  const r = this.#git(["rebase", "origin/master"]);
@@ -81,10 +112,16 @@ export class WikiRepo {
81
112
  }
82
113
  }
83
114
 
115
+ /** Stage and commit any working-tree changes, then fetch, rebase on origin/master (falling back to a merge with -X ours if rebase fails), and push if HEAD is ahead of origin/master. The commit gate and the push gate are independent so a clean tree with local commits still pushes. */
84
116
  commitAndPush(message) {
85
- if (this.isClean()) return { pushed: false, reason: "clean" };
86
- this.#git(["add", "-A"]);
87
- this.#git(["commit", "-m", message]);
117
+ const hasWorkingTreeChanges = !this.isClean();
118
+ if (hasWorkingTreeChanges) {
119
+ this.#git(["add", "-A"]);
120
+ this.#git(["commit", "-m", message]);
121
+ }
122
+ if (!this.#hasCommitsAhead()) {
123
+ return { pushed: false, reason: "clean" };
124
+ }
88
125
  this.fetch();
89
126
  const rebase = this.#git(["rebase", "origin/master"]);
90
127
  if (rebase.status !== 0) {
@@ -95,6 +132,12 @@ export class WikiRepo {
95
132
  return { pushed: true, reason: "pushed" };
96
133
  }
97
134
 
135
+ #hasCommitsAhead() {
136
+ const r = this.#git(["rev-list", "--count", "origin/master..HEAD"]);
137
+ const count = parseInt(r.stdout?.toString().trim() || "0", 10);
138
+ return count > 0;
139
+ }
140
+
98
141
  #parentConfig(key) {
99
142
  const r = spawnSync(
100
143
  "git",
@@ -111,11 +154,14 @@ export class WikiRepo {
111
154
  }
112
155
 
113
156
  #authGit(args) {
114
- const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
157
+ const token = this.#resolveToken();
115
158
  const fullArgs = buildAuthArgs(args, token);
116
- return spawnSync("git", fullArgs, {
117
- stdio: "pipe",
118
- env: token ? process.env : undefined,
119
- });
159
+ // The credential helper body keeps `${GH_TOKEN:-$GITHUB_TOKEN}` literal so
160
+ // git's child shell expands it at auth time — the token never sits in argv.
161
+ // Inject the resolved token into the spawn env so the helper's lazy
162
+ // expansion finds it even when the resolver pulled from `.env` or
163
+ // `gh auth token` rather than the ambient process env.
164
+ const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
165
+ return spawnSync("git", fullArgs, { stdio: "pipe", env });
120
166
  }
121
167
  }