@forwardimpact/libwiki 0.1.1 → 0.1.3
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 +6 -2
- package/bin/fit-wiki.js +61 -3
- package/package.json +16 -10
- package/src/agent-roster.js +1 -0
- package/src/block-renderer.js +68 -0
- package/src/commands/init.js +56 -0
- package/src/commands/memo.js +47 -11
- package/src/commands/refresh.js +56 -0
- package/src/commands/sync.js +46 -0
- package/src/index.js +4 -0
- package/src/marker-migrator.js +1 -0
- package/src/marker-scanner.js +43 -0
- package/src/memo-writer.js +1 -0
- package/src/skill-roster.js +19 -0
- package/src/wiki-repo.js +133 -0
package/README.md
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# libwiki
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
@@ -4,6 +4,9 @@ import { readFileSync } from "node:fs";
|
|
|
4
4
|
import { createCli } from "@forwardimpact/libcli";
|
|
5
5
|
|
|
6
6
|
import { runMemoCommand } from "../src/commands/memo.js";
|
|
7
|
+
import { runRefreshCommand } from "../src/commands/refresh.js";
|
|
8
|
+
import { runInitCommand } from "../src/commands/init.js";
|
|
9
|
+
import { runPushCommand, runPullCommand } from "../src/commands/sync.js";
|
|
7
10
|
|
|
8
11
|
const { version: VERSION } = JSON.parse(
|
|
9
12
|
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
@@ -38,6 +41,46 @@ const definition = {
|
|
|
38
41
|
},
|
|
39
42
|
},
|
|
40
43
|
},
|
|
44
|
+
{
|
|
45
|
+
name: "refresh",
|
|
46
|
+
description:
|
|
47
|
+
"Regenerate XmR chart blocks inside a storyboard markdown file",
|
|
48
|
+
args: "[storyboard-path]",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "init",
|
|
52
|
+
description: "Bootstrap a wiki working tree for a Kata installation",
|
|
53
|
+
options: {
|
|
54
|
+
"wiki-root": {
|
|
55
|
+
type: "string",
|
|
56
|
+
description: "Override wiki root directory (default: wiki)",
|
|
57
|
+
},
|
|
58
|
+
"skills-dir": {
|
|
59
|
+
type: "string",
|
|
60
|
+
description: "Override skills directory (default: .claude/skills)",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "push",
|
|
66
|
+
description: "Commit and push local wiki changes to the remote",
|
|
67
|
+
options: {
|
|
68
|
+
"wiki-root": {
|
|
69
|
+
type: "string",
|
|
70
|
+
description: "Override wiki root directory (default: wiki)",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: "pull",
|
|
76
|
+
description: "Pull remote wiki changes into the local working tree",
|
|
77
|
+
options: {
|
|
78
|
+
"wiki-root": {
|
|
79
|
+
type: "string",
|
|
80
|
+
description: "Override wiki root directory (default: wiki)",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
41
84
|
],
|
|
42
85
|
globalOptions: {
|
|
43
86
|
help: { type: "boolean", short: "h", description: "Show this help" },
|
|
@@ -50,13 +93,24 @@ const definition = {
|
|
|
50
93
|
examples: [
|
|
51
94
|
'fit-wiki memo --from staff-engineer --to security-engineer --message "audit d642ff0c"',
|
|
52
95
|
'fit-wiki memo --from technical-writer --to all --message "new XmR baseline"',
|
|
96
|
+
"fit-wiki refresh",
|
|
97
|
+
"fit-wiki refresh wiki/storyboard-2026-M05.md",
|
|
98
|
+
"fit-wiki init",
|
|
99
|
+
"fit-wiki push",
|
|
100
|
+
"fit-wiki pull",
|
|
53
101
|
],
|
|
54
102
|
documentation: [
|
|
55
103
|
{
|
|
56
|
-
title: "
|
|
57
|
-
url: "https://www.forwardimpact.team/docs/libraries/
|
|
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",
|
|
58
112
|
description:
|
|
59
|
-
"Send cross-team memos,
|
|
113
|
+
"Send cross-team memos, refresh storyboard charts, and sync the wiki.",
|
|
60
114
|
},
|
|
61
115
|
],
|
|
62
116
|
};
|
|
@@ -65,6 +119,10 @@ const cli = createCli(definition);
|
|
|
65
119
|
|
|
66
120
|
const COMMANDS = {
|
|
67
121
|
memo: runMemoCommand,
|
|
122
|
+
refresh: runRefreshCommand,
|
|
123
|
+
init: runInitCommand,
|
|
124
|
+
push: runPushCommand,
|
|
125
|
+
pull: runPullCommand,
|
|
68
126
|
};
|
|
69
127
|
|
|
70
128
|
function main() {
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/libwiki",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Wiki lifecycle primitives
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Wiki lifecycle primitives — stable memory for agent teams so coordination persists across sessions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"wiki",
|
|
7
7
|
"memo",
|
|
8
|
-
"
|
|
8
|
+
"storyboard",
|
|
9
|
+
"xmr",
|
|
9
10
|
"agent"
|
|
10
11
|
],
|
|
11
12
|
"homepage": "https://www.forwardimpact.team",
|
|
@@ -16,12 +17,16 @@
|
|
|
16
17
|
},
|
|
17
18
|
"license": "Apache-2.0",
|
|
18
19
|
"author": "D. Olsson <hi@senzilla.io>",
|
|
19
|
-
"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
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
|
+
],
|
|
25
30
|
"type": "module",
|
|
26
31
|
"main": "./src/index.js",
|
|
27
32
|
"exports": {
|
|
@@ -41,7 +46,8 @@
|
|
|
41
46
|
},
|
|
42
47
|
"dependencies": {
|
|
43
48
|
"@forwardimpact/libcli": "^0.1.0",
|
|
44
|
-
"@forwardimpact/libutil": "^0.1.0"
|
|
49
|
+
"@forwardimpact/libutil": "^0.1.0",
|
|
50
|
+
"@forwardimpact/libxmr": "^1.1.0"
|
|
45
51
|
},
|
|
46
52
|
"devDependencies": {
|
|
47
53
|
"@forwardimpact/libharness": "^0.1.5"
|
package/src/agent-roster.js
CHANGED
|
@@ -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 },
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { analyze, renderChart, MIN_POINTS } from "@forwardimpact/libxmr";
|
|
4
|
+
|
|
5
|
+
/** Error thrown when an XmR block cannot be rendered due to missing CSV or metric. */
|
|
6
|
+
export class BlockRenderError extends Error {
|
|
7
|
+
/** Create a BlockRenderError with the given reason string. */
|
|
8
|
+
constructor(reason) {
|
|
9
|
+
super(reason);
|
|
10
|
+
this.name = "BlockRenderError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Render an XmR chart block for a metric by reading its CSV and producing markdown lines. */
|
|
15
|
+
export function renderBlock({
|
|
16
|
+
metric,
|
|
17
|
+
csvPath,
|
|
18
|
+
projectRoot,
|
|
19
|
+
fs = { readFileSync },
|
|
20
|
+
}) {
|
|
21
|
+
const fullPath = path.resolve(projectRoot, csvPath);
|
|
22
|
+
let csvText;
|
|
23
|
+
try {
|
|
24
|
+
csvText = fs.readFileSync(fullPath, "utf-8");
|
|
25
|
+
} catch {
|
|
26
|
+
throw new BlockRenderError(`csv-not-found: ${csvPath}`);
|
|
27
|
+
}
|
|
28
|
+
const report = analyze(csvText);
|
|
29
|
+
|
|
30
|
+
const m = report.metrics.find((entry) => entry.metric === metric);
|
|
31
|
+
if (!m) {
|
|
32
|
+
throw new BlockRenderError(`metric-not-found: ${metric}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const latestValue = m.latest?.value ?? m.values[m.values.length - 1] ?? "—";
|
|
36
|
+
const status = m.status;
|
|
37
|
+
|
|
38
|
+
let chartLines;
|
|
39
|
+
if (status === "insufficient_data") {
|
|
40
|
+
chartLines = [
|
|
41
|
+
`Insufficient data: ${m.n} points (need at least ${MIN_POINTS}).`,
|
|
42
|
+
];
|
|
43
|
+
} else {
|
|
44
|
+
const chartText = renderChart(m.values, m.stats, m.signals);
|
|
45
|
+
chartLines = chartText.split("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const signalLine = formatSignals(m.signals);
|
|
49
|
+
|
|
50
|
+
return [
|
|
51
|
+
`**Latest:** ${latestValue} · **Status:** ${status}`,
|
|
52
|
+
"",
|
|
53
|
+
"```",
|
|
54
|
+
...chartLines,
|
|
55
|
+
"```",
|
|
56
|
+
"",
|
|
57
|
+
`**Signals:** ${signalLine}`,
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatSignals(signals) {
|
|
62
|
+
if (!signals) return "—";
|
|
63
|
+
const fired = [];
|
|
64
|
+
for (const rule of ["xRule1", "xRule2", "xRule3", "mrRule1"]) {
|
|
65
|
+
if (signals[rule]?.length > 0) fired.push(rule);
|
|
66
|
+
}
|
|
67
|
+
return fired.length > 0 ? fired.join(", ") : "—";
|
|
68
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import fsAsync from "node:fs/promises";
|
|
5
|
+
import { Finder } from "@forwardimpact/libutil";
|
|
6
|
+
import { WikiRepo } from "../wiki-repo.js";
|
|
7
|
+
import { listSkills } from "../skill-roster.js";
|
|
8
|
+
|
|
9
|
+
function deriveWikiUrl(parentDir) {
|
|
10
|
+
const r = spawnSync("git", ["-C", parentDir, "remote", "get-url", "origin"], {
|
|
11
|
+
encoding: "utf-8",
|
|
12
|
+
stdio: "pipe",
|
|
13
|
+
});
|
|
14
|
+
if (r.status !== 0) return null;
|
|
15
|
+
const origin = r.stdout.trim();
|
|
16
|
+
const base = origin.replace(/\.git$/, "");
|
|
17
|
+
return base + ".wiki.git";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 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. */
|
|
21
|
+
export function runInitCommand(values, _args, cli) {
|
|
22
|
+
const logger = { debug() {} };
|
|
23
|
+
const finder = new Finder(fsAsync, logger, process);
|
|
24
|
+
const projectRoot = finder.findProjectRoot(process.cwd());
|
|
25
|
+
|
|
26
|
+
const wikiDir = path.resolve(projectRoot, values["wiki-root"] ?? "wiki");
|
|
27
|
+
const skillsDir = path.resolve(
|
|
28
|
+
projectRoot,
|
|
29
|
+
values["skills-dir"] ?? path.join(".claude", "skills"),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const wikiUrl = deriveWikiUrl(projectRoot);
|
|
33
|
+
if (!wikiUrl) {
|
|
34
|
+
process.stderr.write(
|
|
35
|
+
"init: could not determine wiki URL from origin remote\n",
|
|
36
|
+
);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const repo = new WikiRepo({ wikiDir, parentDir: projectRoot });
|
|
41
|
+
|
|
42
|
+
const cloneResult = repo.ensureCloned(wikiUrl);
|
|
43
|
+
if (!cloneResult.cloned) {
|
|
44
|
+
process.stderr.write("init: could not clone wiki, skipping\n");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
repo.inheritIdentity();
|
|
49
|
+
|
|
50
|
+
const skills = listSkills({ skillsDir });
|
|
51
|
+
for (const slug of skills) {
|
|
52
|
+
mkdirSync(path.join(wikiDir, "metrics", slug), { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
process.stdout.write(`init: wiki ready at ${wikiDir}\n`);
|
|
56
|
+
}
|
package/src/commands/memo.js
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
79
|
+
writeBroadcast({
|
|
80
|
+
agentsDir,
|
|
81
|
+
wikiRoot,
|
|
82
|
+
sender,
|
|
83
|
+
message: values.message,
|
|
84
|
+
today,
|
|
85
|
+
});
|
|
52
86
|
} else {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
87
|
+
writeSingleTarget({
|
|
88
|
+
wikiRoot,
|
|
89
|
+
target: values.to,
|
|
90
|
+
sender,
|
|
91
|
+
message: values.message,
|
|
92
|
+
today,
|
|
93
|
+
cli,
|
|
94
|
+
});
|
|
59
95
|
}
|
|
60
96
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fsAsync from "node:fs/promises";
|
|
4
|
+
import { Finder } from "@forwardimpact/libutil";
|
|
5
|
+
import { scanMarkers } from "../marker-scanner.js";
|
|
6
|
+
import { renderBlock, BlockRenderError } from "../block-renderer.js";
|
|
7
|
+
|
|
8
|
+
function currentStoryboardPath() {
|
|
9
|
+
const now = new Date();
|
|
10
|
+
const yyyy = now.getFullYear();
|
|
11
|
+
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
|
12
|
+
return `wiki/storyboard-${yyyy}-M${mm}.md`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Re-render all XmR chart blocks in a storyboard file by scanning markers and splicing updated content. */
|
|
16
|
+
export function runRefreshCommand(values, args, cli) {
|
|
17
|
+
const logger = { debug() {} };
|
|
18
|
+
const finder = new Finder(fsAsync, logger, process);
|
|
19
|
+
const projectRoot = finder.findProjectRoot(process.cwd());
|
|
20
|
+
|
|
21
|
+
const storyboardPath = path.resolve(
|
|
22
|
+
projectRoot,
|
|
23
|
+
args[0] || currentStoryboardPath(),
|
|
24
|
+
);
|
|
25
|
+
const text = readFileSync(storyboardPath, "utf-8");
|
|
26
|
+
const blocks = scanMarkers(text);
|
|
27
|
+
|
|
28
|
+
if (blocks.length === 0) return;
|
|
29
|
+
|
|
30
|
+
const lines = text.split("\n");
|
|
31
|
+
let spliced = false;
|
|
32
|
+
|
|
33
|
+
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
34
|
+
const block = blocks[i];
|
|
35
|
+
try {
|
|
36
|
+
const rendered = renderBlock({
|
|
37
|
+
metric: block.metric,
|
|
38
|
+
csvPath: block.csvPath,
|
|
39
|
+
projectRoot,
|
|
40
|
+
});
|
|
41
|
+
lines.splice(
|
|
42
|
+
block.openLine + 1,
|
|
43
|
+
block.closeLine - block.openLine - 1,
|
|
44
|
+
...rendered,
|
|
45
|
+
);
|
|
46
|
+
spliced = true;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (!(err instanceof BlockRenderError)) throw err;
|
|
49
|
+
process.stderr.write(
|
|
50
|
+
`refresh-error ${storyboardPath}:${block.openLine + 1} ${err.message}\n`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (spliced) writeFileSync(storyboardPath, lines.join("\n"));
|
|
56
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fsAsync from "node:fs/promises";
|
|
3
|
+
import { Finder } from "@forwardimpact/libutil";
|
|
4
|
+
import { WikiRepo, WikiPullConflict } from "../wiki-repo.js";
|
|
5
|
+
|
|
6
|
+
/** Commit all wiki changes and push them to the remote wiki repository. */
|
|
7
|
+
export function runPushCommand(values, _args, cli) {
|
|
8
|
+
const logger = { debug() {} };
|
|
9
|
+
const finder = new Finder(fsAsync, logger, process);
|
|
10
|
+
const projectRoot = finder.findProjectRoot(process.cwd());
|
|
11
|
+
const wikiDir = path.resolve(projectRoot, values["wiki-root"] ?? "wiki");
|
|
12
|
+
|
|
13
|
+
const repo = new WikiRepo({ wikiDir, parentDir: projectRoot });
|
|
14
|
+
repo.inheritIdentity();
|
|
15
|
+
|
|
16
|
+
const result = repo.commitAndPush("wiki: update from session");
|
|
17
|
+
if (result.pushed) {
|
|
18
|
+
process.stdout.write("push: committed and pushed\n");
|
|
19
|
+
} else {
|
|
20
|
+
process.stdout.write("push: nothing to push\n");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 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. */
|
|
25
|
+
export function runPullCommand(values, _args, cli) {
|
|
26
|
+
const logger = { debug() {} };
|
|
27
|
+
const finder = new Finder(fsAsync, logger, process);
|
|
28
|
+
const projectRoot = finder.findProjectRoot(process.cwd());
|
|
29
|
+
const wikiDir = path.resolve(projectRoot, values["wiki-root"] ?? "wiki");
|
|
30
|
+
|
|
31
|
+
const repo = new WikiRepo({ wikiDir, parentDir: projectRoot });
|
|
32
|
+
repo.inheritIdentity();
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
repo.pull();
|
|
36
|
+
process.stdout.write("pull: up to date\n");
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (err instanceof WikiPullConflict) {
|
|
39
|
+
process.stderr.write(
|
|
40
|
+
"fit-wiki pull: rebase conflict — local divergence detected; resolve manually or push first\n",
|
|
41
|
+
);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/index.js
CHANGED
|
@@ -6,3 +6,7 @@ export {
|
|
|
6
6
|
INBOX_HEADING,
|
|
7
7
|
BROADCAST_TARGET,
|
|
8
8
|
} from "./constants.js";
|
|
9
|
+
export { scanMarkers } from "./marker-scanner.js";
|
|
10
|
+
export { renderBlock } from "./block-renderer.js";
|
|
11
|
+
export { WikiRepo } from "./wiki-repo.js";
|
|
12
|
+
export { listSkills } from "./skill-roster.js";
|
package/src/marker-migrator.js
CHANGED
|
@@ -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 },
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const OPEN_RE = /^<!--\s*xmr:([^:\s]+):([^\s]+)\s*-->\s*$/;
|
|
2
|
+
const CLOSE_RE = /^<!--\s*\/xmr\s*-->\s*$/;
|
|
3
|
+
|
|
4
|
+
/** Scan text for paired xmr open/close HTML comment markers and return their line positions and metadata. */
|
|
5
|
+
export function scanMarkers(text) {
|
|
6
|
+
const lines = text.split("\n");
|
|
7
|
+
const pairs = [];
|
|
8
|
+
let open = null;
|
|
9
|
+
|
|
10
|
+
for (let i = 0; i < lines.length; i++) {
|
|
11
|
+
const openMatch = lines[i].match(OPEN_RE);
|
|
12
|
+
if (openMatch) {
|
|
13
|
+
if (open) {
|
|
14
|
+
process.stderr.write(
|
|
15
|
+
`dangling-marker ${open.metric} at line ${open.openLine + 1}\n`,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
open = { metric: openMatch[1], csvPath: openMatch[2], openLine: i };
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (CLOSE_RE.test(lines[i])) {
|
|
23
|
+
if (open) {
|
|
24
|
+
pairs.push({
|
|
25
|
+
metric: open.metric,
|
|
26
|
+
csvPath: open.csvPath,
|
|
27
|
+
openLine: open.openLine,
|
|
28
|
+
closeLine: i,
|
|
29
|
+
});
|
|
30
|
+
open = null;
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (open) {
|
|
37
|
+
process.stderr.write(
|
|
38
|
+
`dangling-marker ${open.metric} at line ${open.openLine + 1}\n`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return pairs;
|
|
43
|
+
}
|
package/src/memo-writer.js
CHANGED
|
@@ -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 },
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/** List all kata-prefixed skill directory names under the skills directory, sorted alphabetically. */
|
|
5
|
+
export function listSkills({ skillsDir }, fs = { readdirSync, statSync }) {
|
|
6
|
+
const entries = fs.readdirSync(skillsDir);
|
|
7
|
+
const skills = [];
|
|
8
|
+
|
|
9
|
+
for (const entry of entries) {
|
|
10
|
+
if (entry.startsWith(".")) continue;
|
|
11
|
+
if (!entry.startsWith("kata-")) continue;
|
|
12
|
+
const fullPath = path.join(skillsDir, entry);
|
|
13
|
+
const stat = fs.statSync(fullPath);
|
|
14
|
+
if (!stat.isDirectory()) continue;
|
|
15
|
+
skills.push(entry);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return skills.sort();
|
|
19
|
+
}
|
package/src/wiki-repo.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const CREDENTIAL_HELPER_BODY =
|
|
4
|
+
'!f() { echo username=x-access-token; echo "password=${GH_TOKEN:-$GITHUB_TOKEN}"; }; f';
|
|
5
|
+
|
|
6
|
+
/** Error thrown when a wiki pull encounters a rebase conflict that cannot be resolved automatically. */
|
|
7
|
+
export class WikiPullConflict extends Error {
|
|
8
|
+
/** Create a WikiPullConflict with the stderr output from the failed rebase. */
|
|
9
|
+
constructor(stderr) {
|
|
10
|
+
super("rebase conflict on pull");
|
|
11
|
+
this.name = "WikiPullConflict";
|
|
12
|
+
this.stderr = stderr;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Prepend credential-helper config arguments to a git command when a token is available. */
|
|
17
|
+
export function buildAuthArgs(args, token) {
|
|
18
|
+
if (token) {
|
|
19
|
+
return [
|
|
20
|
+
"-c",
|
|
21
|
+
"credential.helper=",
|
|
22
|
+
"-c",
|
|
23
|
+
`credential.helper=${CREDENTIAL_HELPER_BODY}`,
|
|
24
|
+
...args,
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
return [...args];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Git operations wrapper for the GitHub wiki repository used as agent team memory. */
|
|
31
|
+
export class WikiRepo {
|
|
32
|
+
#wikiDir;
|
|
33
|
+
#parentDir;
|
|
34
|
+
|
|
35
|
+
/** Create a WikiRepo targeting the given wiki directory and its parent project directory. */
|
|
36
|
+
constructor({ wikiDir, parentDir }) {
|
|
37
|
+
this.#wikiDir = wikiDir;
|
|
38
|
+
this.#parentDir = parentDir;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Check whether the wiki directory is an initialized git repository. */
|
|
42
|
+
isCloned() {
|
|
43
|
+
const r = spawnSync(
|
|
44
|
+
"git",
|
|
45
|
+
["-C", this.#wikiDir, "rev-parse", "--git-dir"],
|
|
46
|
+
{
|
|
47
|
+
stdio: "pipe",
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
return r.status === 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Clone the wiki from the given URL if it is not already cloned. */
|
|
54
|
+
ensureCloned(url) {
|
|
55
|
+
if (this.isCloned()) return { cloned: true, reason: "already-cloned" };
|
|
56
|
+
const r = this.#authGit(["clone", url, this.#wikiDir]);
|
|
57
|
+
if (r.status !== 0) {
|
|
58
|
+
return {
|
|
59
|
+
cloned: false,
|
|
60
|
+
reason: r.stderr?.toString().trim() || "clone failed",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return { cloned: true, reason: "cloned" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Copy git user.name and user.email from the parent repository into the wiki repository. */
|
|
67
|
+
inheritIdentity() {
|
|
68
|
+
const name = this.#parentConfig("user.name");
|
|
69
|
+
const email = this.#parentConfig("user.email");
|
|
70
|
+
if (name) this.#git(["config", "user.name", name]);
|
|
71
|
+
if (email) this.#git(["config", "user.email", email]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Fetch the latest master branch from the wiki remote using token auth if available. */
|
|
75
|
+
fetch() {
|
|
76
|
+
this.#authGit(["-C", this.#wikiDir, "fetch", "origin", "master"]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Return true if the wiki working tree has no uncommitted changes. */
|
|
80
|
+
isClean() {
|
|
81
|
+
const r = this.#git(["status", "--porcelain"]);
|
|
82
|
+
return r.stdout.toString().trim() === "";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Fetch and rebase on origin/master, throwing WikiPullConflict if the rebase fails. */
|
|
86
|
+
pull() {
|
|
87
|
+
this.fetch();
|
|
88
|
+
const r = this.#git(["rebase", "origin/master"]);
|
|
89
|
+
if (r.status !== 0) {
|
|
90
|
+
this.#git(["rebase", "--abort"]);
|
|
91
|
+
throw new WikiPullConflict(r.stderr?.toString().trim() || "");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Stage all changes, commit with the given message, fetch, rebase on origin/master (falling back to a merge with -X ours if rebase fails), and push. */
|
|
96
|
+
commitAndPush(message) {
|
|
97
|
+
if (this.isClean()) return { pushed: false, reason: "clean" };
|
|
98
|
+
this.#git(["add", "-A"]);
|
|
99
|
+
this.#git(["commit", "-m", message]);
|
|
100
|
+
this.fetch();
|
|
101
|
+
const rebase = this.#git(["rebase", "origin/master"]);
|
|
102
|
+
if (rebase.status !== 0) {
|
|
103
|
+
this.#git(["rebase", "--abort"]);
|
|
104
|
+
this.#git(["merge", "origin/master", "-X", "ours", "--no-edit"]);
|
|
105
|
+
}
|
|
106
|
+
this.#authGit(["-C", this.#wikiDir, "push", "origin", "master"]);
|
|
107
|
+
return { pushed: true, reason: "pushed" };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#parentConfig(key) {
|
|
111
|
+
const r = spawnSync(
|
|
112
|
+
"git",
|
|
113
|
+
["-C", this.#parentDir, "config", "--get", key],
|
|
114
|
+
{
|
|
115
|
+
stdio: "pipe",
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
return r.status === 0 ? r.stdout.toString().trim() : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#git(args) {
|
|
122
|
+
return spawnSync("git", ["-C", this.#wikiDir, ...args], { stdio: "pipe" });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#authGit(args) {
|
|
126
|
+
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
|
127
|
+
const fullArgs = buildAuthArgs(args, token);
|
|
128
|
+
return spawnSync("git", fullArgs, {
|
|
129
|
+
stdio: "pipe",
|
|
130
|
+
env: token ? process.env : undefined,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|