@forwardimpact/libwiki 0.1.3 → 0.2.0
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 +169 -26
- package/package.json +2 -1
- package/src/active-claims.js +196 -0
- package/src/boot.js +179 -0
- package/src/commands/audit.js +354 -0
- package/src/commands/boot.js +66 -0
- package/src/commands/claim.js +107 -0
- package/src/commands/inbox.js +180 -0
- package/src/commands/init.js +144 -21
- package/src/commands/log.js +102 -0
- package/src/commands/refresh.js +37 -13
- package/src/commands/rotate.js +25 -0
- package/src/commands/sync.js +15 -10
- package/src/constants.js +18 -0
- package/src/index.js +24 -0
- package/src/issue-list-renderer.js +69 -0
- package/src/marker-scanner.js +75 -28
- package/src/weekly-log.js +91 -0
- package/src/wiki-repo.js +45 -11
package/src/marker-scanner.js
CHANGED
|
@@ -1,43 +1,90 @@
|
|
|
1
|
-
const
|
|
2
|
-
const
|
|
1
|
+
const XMR_OPEN_RE = /^<!--\s*xmr:([^:\s]+):([^\s]+)\s*-->\s*$/;
|
|
2
|
+
const ISSUE_OPEN_RE =
|
|
3
|
+
/^<!--\s*(obstacles|experiments):(open|closed)(?::(\d+d))?\s*-->\s*$/;
|
|
4
|
+
const XMR_CLOSE_RE = /^<!--\s*\/xmr\s*-->\s*$/;
|
|
5
|
+
const ISSUE_CLOSE_RE = /^<!--\s*\/(obstacles|experiments)\s*-->\s*$/;
|
|
3
6
|
|
|
4
|
-
|
|
7
|
+
function openLabel(open) {
|
|
8
|
+
return open.kind === "xmr" ? open.metric : open.topic;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function warnDangling(open) {
|
|
12
|
+
process.stderr.write(
|
|
13
|
+
`dangling-marker ${openLabel(open)} at line ${open.openLine + 1}\n`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function tryOpen(line, i) {
|
|
18
|
+
const xmrMatch = line.match(XMR_OPEN_RE);
|
|
19
|
+
if (xmrMatch) {
|
|
20
|
+
return {
|
|
21
|
+
kind: "xmr",
|
|
22
|
+
metric: xmrMatch[1],
|
|
23
|
+
csvPath: xmrMatch[2],
|
|
24
|
+
openLine: i,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const issueMatch = line.match(ISSUE_OPEN_RE);
|
|
28
|
+
if (issueMatch) {
|
|
29
|
+
return {
|
|
30
|
+
kind: "issue-list",
|
|
31
|
+
topic: issueMatch[1],
|
|
32
|
+
state: issueMatch[2],
|
|
33
|
+
window: issueMatch[3] || null,
|
|
34
|
+
openLine: i,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function closePair(open, i) {
|
|
41
|
+
if (open.kind === "xmr") {
|
|
42
|
+
return {
|
|
43
|
+
kind: "xmr",
|
|
44
|
+
metric: open.metric,
|
|
45
|
+
csvPath: open.csvPath,
|
|
46
|
+
openLine: open.openLine,
|
|
47
|
+
closeLine: i,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
kind: "issue-list",
|
|
52
|
+
topic: open.topic,
|
|
53
|
+
state: open.state,
|
|
54
|
+
window: open.window,
|
|
55
|
+
openLine: open.openLine,
|
|
56
|
+
closeLine: i,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function matchClose(line, open) {
|
|
61
|
+
if (!open) return false;
|
|
62
|
+
if (open.kind === "xmr") return XMR_CLOSE_RE.test(line);
|
|
63
|
+
const m = line.match(ISSUE_CLOSE_RE);
|
|
64
|
+
return Boolean(m && open.kind === "issue-list" && open.topic === m[1]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Scan text for paired marker blocks (xmr or issue-list). Returns positions and metadata. */
|
|
5
68
|
export function scanMarkers(text) {
|
|
6
69
|
const lines = text.split("\n");
|
|
7
70
|
const pairs = [];
|
|
8
71
|
let open = null;
|
|
9
72
|
|
|
10
73
|
for (let i = 0; i < lines.length; i++) {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
);
|
|
17
|
-
}
|
|
18
|
-
open = { metric: openMatch[1], csvPath: openMatch[2], openLine: i };
|
|
74
|
+
const line = lines[i];
|
|
75
|
+
const newOpen = tryOpen(line, i);
|
|
76
|
+
if (newOpen) {
|
|
77
|
+
if (open) warnDangling(open);
|
|
78
|
+
open = newOpen;
|
|
19
79
|
continue;
|
|
20
80
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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;
|
|
81
|
+
if (matchClose(line, open)) {
|
|
82
|
+
pairs.push(closePair(open, i));
|
|
83
|
+
open = null;
|
|
33
84
|
}
|
|
34
85
|
}
|
|
35
86
|
|
|
36
|
-
if (open)
|
|
37
|
-
process.stderr.write(
|
|
38
|
-
`dangling-marker ${open.metric} at line ${open.openLine + 1}\n`,
|
|
39
|
-
);
|
|
40
|
-
}
|
|
87
|
+
if (open) warnDangling(open);
|
|
41
88
|
|
|
42
89
|
return pairs;
|
|
43
90
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, renameSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { WEEKLY_LOG_LINE_BUDGET } from "./constants.js";
|
|
4
|
+
|
|
5
|
+
/** Compute ISO 8601 year-week for a Date. Returns { year, week } where year is the ISO week-year (not necessarily the calendar year for edge weeks). */
|
|
6
|
+
export function isoWeek(date) {
|
|
7
|
+
const d = new Date(
|
|
8
|
+
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
|
|
9
|
+
);
|
|
10
|
+
// Thursday of week: ISO weeks are anchored on Thursday.
|
|
11
|
+
const day = d.getUTCDay() || 7;
|
|
12
|
+
d.setUTCDate(d.getUTCDate() + 4 - day);
|
|
13
|
+
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
14
|
+
const week = Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
|
|
15
|
+
return { year: d.getUTCFullYear(), week };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatIsoWeek(date) {
|
|
19
|
+
const { year, week } = isoWeek(date);
|
|
20
|
+
return `${year}-W${String(week).padStart(2, "0")}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Return the path of the current weekly log file for an agent. */
|
|
24
|
+
export function weeklyLogPath(wikiRoot, agent, today) {
|
|
25
|
+
const date = today instanceof Date ? today : new Date(today);
|
|
26
|
+
return path.join(wikiRoot, `${agent}-${formatIsoWeek(date)}.md`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function countLines(text) {
|
|
30
|
+
if (text.length === 0) return 0;
|
|
31
|
+
let n = 0;
|
|
32
|
+
for (const ch of text) if (ch === "\n") n++;
|
|
33
|
+
if (!text.endsWith("\n")) n++;
|
|
34
|
+
return n;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function nextPartPath(filePath) {
|
|
38
|
+
const dir = path.dirname(filePath);
|
|
39
|
+
const base = path.basename(filePath, ".md");
|
|
40
|
+
let n = 1;
|
|
41
|
+
while (existsSync(path.join(dir, `${base}-part${n}.md`))) n++;
|
|
42
|
+
return path.join(dir, `${base}-part${n}.md`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function agentTitle(agent) {
|
|
46
|
+
return agent
|
|
47
|
+
.split("-")
|
|
48
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
49
|
+
.join(" ");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function defaultH1(filePath, agent, isoWeekStr) {
|
|
53
|
+
return `# ${agentTitle(agent)} — ${isoWeekStr}\n`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Rotate the current weekly log if next append would exceed the budget. Returns { rotated, fromPath, toPath }. */
|
|
57
|
+
export function rotateIfOverBudget(
|
|
58
|
+
wikiRoot,
|
|
59
|
+
agent,
|
|
60
|
+
today,
|
|
61
|
+
appendLines = 0,
|
|
62
|
+
options = {},
|
|
63
|
+
) {
|
|
64
|
+
const filePath = weeklyLogPath(wikiRoot, agent, today);
|
|
65
|
+
const { force = false } = options;
|
|
66
|
+
if (!existsSync(filePath)) return { rotated: false, fromPath: filePath };
|
|
67
|
+
const text = readFileSync(filePath, "utf-8");
|
|
68
|
+
const current = countLines(text);
|
|
69
|
+
if (!force && current + appendLines <= WEEKLY_LOG_LINE_BUDGET) {
|
|
70
|
+
return { rotated: false, fromPath: filePath };
|
|
71
|
+
}
|
|
72
|
+
const toPath = nextPartPath(filePath);
|
|
73
|
+
renameSync(filePath, toPath);
|
|
74
|
+
const date = today instanceof Date ? today : new Date(today);
|
|
75
|
+
writeFileSync(filePath, defaultH1(filePath, agent, formatIsoWeek(date)));
|
|
76
|
+
return { rotated: true, fromPath: filePath, toPath };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Append a body to a weekly log file. Creates it with an H1 if missing. */
|
|
80
|
+
export function appendEntry(filePath, body, agent, today) {
|
|
81
|
+
const date = today instanceof Date ? today : new Date(today);
|
|
82
|
+
if (!existsSync(filePath)) {
|
|
83
|
+
writeFileSync(filePath, defaultH1(filePath, agent, formatIsoWeek(date)));
|
|
84
|
+
}
|
|
85
|
+
const text = readFileSync(filePath, "utf-8");
|
|
86
|
+
const separator = text.endsWith("\n") ? "\n" : "\n\n";
|
|
87
|
+
writeFileSync(
|
|
88
|
+
filePath,
|
|
89
|
+
text + separator + body + (body.endsWith("\n") ? "" : "\n"),
|
|
90
|
+
);
|
|
91
|
+
}
|
package/src/wiki-repo.js
CHANGED
|
@@ -31,11 +31,31 @@ export function buildAuthArgs(args, token) {
|
|
|
31
31
|
export class WikiRepo {
|
|
32
32
|
#wikiDir;
|
|
33
33
|
#parentDir;
|
|
34
|
+
#resolveToken;
|
|
34
35
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
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
|
+
}
|
|
37
56
|
this.#wikiDir = wikiDir;
|
|
38
57
|
this.#parentDir = parentDir;
|
|
58
|
+
this.#resolveToken = resolveToken;
|
|
39
59
|
}
|
|
40
60
|
|
|
41
61
|
/** Check whether the wiki directory is an initialized git repository. */
|
|
@@ -92,11 +112,16 @@ export class WikiRepo {
|
|
|
92
112
|
}
|
|
93
113
|
}
|
|
94
114
|
|
|
95
|
-
/** Stage
|
|
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. */
|
|
96
116
|
commitAndPush(message) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
+
}
|
|
100
125
|
this.fetch();
|
|
101
126
|
const rebase = this.#git(["rebase", "origin/master"]);
|
|
102
127
|
if (rebase.status !== 0) {
|
|
@@ -107,6 +132,12 @@ export class WikiRepo {
|
|
|
107
132
|
return { pushed: true, reason: "pushed" };
|
|
108
133
|
}
|
|
109
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
|
+
|
|
110
141
|
#parentConfig(key) {
|
|
111
142
|
const r = spawnSync(
|
|
112
143
|
"git",
|
|
@@ -123,11 +154,14 @@ export class WikiRepo {
|
|
|
123
154
|
}
|
|
124
155
|
|
|
125
156
|
#authGit(args) {
|
|
126
|
-
const token =
|
|
157
|
+
const token = this.#resolveToken();
|
|
127
158
|
const fullArgs = buildAuthArgs(args, token);
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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 });
|
|
132
166
|
}
|
|
133
167
|
}
|