@forwardimpact/libwiki 0.2.32 → 0.2.34
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/package.json +1 -1
- package/src/commands/curate.js +138 -82
- package/src/commands/refresh.js +23 -9
- package/src/storyboard-skeleton.js +106 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/libwiki",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.34",
|
|
4
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",
|
package/src/commands/curate.js
CHANGED
|
@@ -16,24 +16,62 @@ const LABEL = {
|
|
|
16
16
|
};
|
|
17
17
|
const TITLE = "Wiki curation: shared-state audit findings";
|
|
18
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
|
+
|
|
19
25
|
/**
|
|
20
26
|
* Compose the issue body from the audit's JSON findings. The findings ride a
|
|
21
27
|
* fenced ```json block; the body is passed to `gh` via `--body-file` (a temp
|
|
22
28
|
* file), never argv, so untrusted finding text cannot be misread as a flag.
|
|
23
29
|
* @param {string} findingsJson
|
|
30
|
+
* @param {{shown?: number, total?: number}} [trunc]
|
|
24
31
|
* @returns {string}
|
|
25
32
|
*/
|
|
26
|
-
function buildBody(findingsJson) {
|
|
27
|
-
|
|
33
|
+
function buildBody(findingsJson, { shown, total } = {}) {
|
|
34
|
+
const lines = [
|
|
28
35
|
"Scheduled `curate-wiki` audit found shared-wiki violations.",
|
|
29
36
|
"",
|
|
30
37
|
"Owner: **technical-writer** (service these via the curation shift; the per-PR `wiki` gate no longer reads shared wiki state).",
|
|
31
38
|
"",
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 });
|
|
37
75
|
}
|
|
38
76
|
|
|
39
77
|
// Resolve the monorepo's `owner/repo` slug the way refresh.js/product-mix.js
|
|
@@ -60,35 +98,49 @@ async function resolveToken() {
|
|
|
60
98
|
}
|
|
61
99
|
|
|
62
100
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
|
|
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.
|
|
70
137
|
* @param {import("@forwardimpact/libcli").InvocationContext} ctx
|
|
138
|
+
* @param {string} body
|
|
139
|
+
* @param {ReturnType<typeof createLogger>} logger
|
|
71
140
|
* @returns {Promise<{ok: boolean}>}
|
|
72
141
|
*/
|
|
73
|
-
|
|
142
|
+
async function routeFindings(ctx, body, logger) {
|
|
74
143
|
const { runtime, gitClient } = ctx.deps;
|
|
75
|
-
const logger = createLogger("wiki", runtime);
|
|
76
|
-
const { findings } = auditWiki(ctx);
|
|
77
|
-
|
|
78
|
-
if (!findings.some((f) => f.level === "fail")) {
|
|
79
|
-
runtime.proc.stdout.write("wiki audit clean — no curation issue routed\n");
|
|
80
|
-
return { ok: true };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const body = buildBody(emitFindingsJson(findings));
|
|
84
|
-
|
|
85
|
-
if (ctx.options["dry-run"]) {
|
|
86
|
-
runtime.proc.stdout.write(
|
|
87
|
-
`[dry-run] would route findings to issue "${TITLE}":\n\n${body}`,
|
|
88
|
-
);
|
|
89
|
-
return { ok: true };
|
|
90
|
-
}
|
|
91
|
-
|
|
92
144
|
const cwd = resolveProjectRoot(runtime);
|
|
93
145
|
const repo =
|
|
94
146
|
ctx.options.repo || (await deriveRepo(gitClient, cwd, runtime.proc.env));
|
|
@@ -115,27 +167,7 @@ export async function runCurateCommand(ctx) {
|
|
|
115
167
|
{ cwd, env },
|
|
116
168
|
);
|
|
117
169
|
|
|
118
|
-
const
|
|
119
|
-
"gh",
|
|
120
|
-
[
|
|
121
|
-
"issue",
|
|
122
|
-
"list",
|
|
123
|
-
"--search",
|
|
124
|
-
`${TITLE} in:title`,
|
|
125
|
-
"--state",
|
|
126
|
-
"open",
|
|
127
|
-
"--json",
|
|
128
|
-
"number",
|
|
129
|
-
...repoArgs,
|
|
130
|
-
],
|
|
131
|
-
{ cwd, env },
|
|
132
|
-
);
|
|
133
|
-
let number = null;
|
|
134
|
-
try {
|
|
135
|
-
number = JSON.parse(list.stdout || "[]")[0]?.number ?? null;
|
|
136
|
-
} catch {
|
|
137
|
-
number = null;
|
|
138
|
-
}
|
|
170
|
+
const number = await findOpenIssue(runtime, repoArgs, { cwd, env });
|
|
139
171
|
|
|
140
172
|
// Pass the body through a temp file, not argv — robust to length and immune
|
|
141
173
|
// to finding text being read as a flag.
|
|
@@ -143,37 +175,28 @@ export async function runCurateCommand(ctx) {
|
|
|
143
175
|
const bodyFile = path.join(tmp, "wiki-curation-body.md");
|
|
144
176
|
runtime.fsSync.writeFileSync(bodyFile, body);
|
|
145
177
|
|
|
146
|
-
const
|
|
147
|
-
?
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
"gh",
|
|
161
|
-
[
|
|
162
|
-
"issue",
|
|
163
|
-
"create",
|
|
164
|
-
"--title",
|
|
165
|
-
TITLE,
|
|
166
|
-
"--body-file",
|
|
167
|
-
bodyFile,
|
|
168
|
-
"--label",
|
|
169
|
-
LABEL.name,
|
|
170
|
-
...repoArgs,
|
|
171
|
-
],
|
|
172
|
-
{ cwd, env },
|
|
173
|
-
);
|
|
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 });
|
|
174
192
|
|
|
175
193
|
if (result.exitCode !== 0) {
|
|
176
|
-
|
|
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
|
+
);
|
|
177
200
|
return { ok: false };
|
|
178
201
|
}
|
|
179
202
|
runtime.proc.stdout.write(
|
|
@@ -183,3 +206,36 @@ export async function runCurateCommand(ctx) {
|
|
|
183
206
|
);
|
|
184
207
|
return { ok: true };
|
|
185
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
|
+
}
|
package/src/commands/refresh.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
parseRepoSlug,
|
|
12
12
|
} from "../issue-list-renderer.js";
|
|
13
13
|
import { parseClaims, filterExpired, removeClaim } from "../active-claims.js";
|
|
14
|
+
import { renderStoryboardSkeleton } from "../storyboard-skeleton.js";
|
|
14
15
|
import { currentDayIso } from "../util/clock.js";
|
|
15
16
|
import { resolveProjectRoot, resolveWikiRoot } from "../util/wiki-dir.js";
|
|
16
17
|
|
|
@@ -90,9 +91,8 @@ function spliceBlock(lines, block, rendered) {
|
|
|
90
91
|
);
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
// A missing current-month storyboard
|
|
94
|
-
//
|
|
95
|
-
// deterministic refresh step exits cleanly instead of failing the job.
|
|
94
|
+
// A missing current-month storyboard is non-fatal: return null so the caller
|
|
95
|
+
// can create it (see createStoryboardSkeleton) rather than fail the job.
|
|
96
96
|
function readStoryboardOrNull(runtime, storyboardPath) {
|
|
97
97
|
try {
|
|
98
98
|
return runtime.fsSync.readFileSync(storyboardPath, "utf-8");
|
|
@@ -102,6 +102,20 @@ function readStoryboardOrNull(runtime, storyboardPath) {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// Create the current-month storyboard from the minimal skeleton when it does
|
|
106
|
+
// not exist. Refresh is the deterministic "freshen the wiki" step and runs
|
|
107
|
+
// before the session (kata-agent pre-run), so creating here guarantees the file
|
|
108
|
+
// is on disk before participants look for it — without any lead having a write
|
|
109
|
+
// tool. The skeleton carries the section structure and the generic issue-list
|
|
110
|
+
// markers; the render pass below fills them and participants seed metric blocks.
|
|
111
|
+
function createStoryboardSkeleton(runtime, storyboardPath, logger) {
|
|
112
|
+
const skeleton = renderStoryboardSkeleton(currentDayIso(runtime));
|
|
113
|
+
runtime.fsSync.mkdirSync(path.dirname(storyboardPath), { recursive: true });
|
|
114
|
+
runtime.fsSync.writeFileSync(storyboardPath, skeleton);
|
|
115
|
+
logger.info("refresh", `created storyboard at ${storyboardPath}`);
|
|
116
|
+
return skeleton;
|
|
117
|
+
}
|
|
118
|
+
|
|
105
119
|
// Drop every MEMORY.md `## Active Claims` row past its `expires_at`, writing the
|
|
106
120
|
// trimmed table back in place. Refresh is the deterministic "freshen the wiki"
|
|
107
121
|
// step, so clearing lapsed claims belongs here alongside the storyboard render;
|
|
@@ -142,11 +156,11 @@ export async function runRefreshCommand(ctx) {
|
|
|
142
156
|
projectRoot,
|
|
143
157
|
ctx.args["storyboard-path"] || currentStoryboardRelPath(runtime),
|
|
144
158
|
);
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
159
|
+
const existing = readStoryboardOrNull(runtime, storyboardPath);
|
|
160
|
+
const created = existing === null;
|
|
161
|
+
const text = created
|
|
162
|
+
? createStoryboardSkeleton(runtime, storyboardPath, logger)
|
|
163
|
+
: existing;
|
|
150
164
|
const blocks = scanMarkers(text, {
|
|
151
165
|
warn: (message) => logger.warn("refresh", message),
|
|
152
166
|
});
|
|
@@ -200,7 +214,7 @@ export async function runRefreshCommand(ctx) {
|
|
|
200
214
|
if (spliced) runtime.fsSync.writeFileSync(storyboardPath, lines.join("\n"));
|
|
201
215
|
if (options.format === "json") {
|
|
202
216
|
runtime.proc.stdout.write(
|
|
203
|
-
JSON.stringify({ blocks: blocks.length, spliced }) + "\n",
|
|
217
|
+
JSON.stringify({ blocks: blocks.length, spliced, created }) + "\n",
|
|
204
218
|
);
|
|
205
219
|
}
|
|
206
220
|
return { ok: true };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storyboard skeleton — the minimal, valid storyboard file `fit-wiki refresh`
|
|
3
|
+
* writes when the current-month board does not yet exist. It carries only the
|
|
4
|
+
* structural surface libwiki owns: the five Toyota Kata sections and the
|
|
5
|
+
* generic `obstacles`/`experiments` issue-list marker blocks that refresh
|
|
6
|
+
* renders from tracker state.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately *not* here: the per-agent `#### {metric}` XmR blocks. Their
|
|
9
|
+
* agent→metric grouping is per-installation curation libwiki cannot infer, so a
|
|
10
|
+
* participant seeds each missing marker pair (see the kata-session skill) and a
|
|
11
|
+
* later refresh renders it. Section budgets and authoring prose ("write the
|
|
12
|
+
* challenge here") stay in the skill's `storyboard-template.md`, the L4
|
|
13
|
+
* authoring layer — this skeleton is content-free scaffolding.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const MONTH_NAMES = [
|
|
17
|
+
"January",
|
|
18
|
+
"February",
|
|
19
|
+
"March",
|
|
20
|
+
"April",
|
|
21
|
+
"May",
|
|
22
|
+
"June",
|
|
23
|
+
"July",
|
|
24
|
+
"August",
|
|
25
|
+
"September",
|
|
26
|
+
"October",
|
|
27
|
+
"November",
|
|
28
|
+
"December",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
32
|
+
|
|
33
|
+
/** Whether `year` is a leap year under the Gregorian rule. */
|
|
34
|
+
function isLeapYear(year) {
|
|
35
|
+
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Last calendar day of the month containing `todayIso` (ISO `YYYY-MM-DD`).
|
|
40
|
+
* Pure integer/calendar math — no `Date`, so the module stays free of ambient
|
|
41
|
+
* time deps.
|
|
42
|
+
*/
|
|
43
|
+
function endOfMonthIso(todayIso) {
|
|
44
|
+
const [year, month] = todayIso.split("-").map(Number);
|
|
45
|
+
const lastDay =
|
|
46
|
+
month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month - 1];
|
|
47
|
+
return `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Render the minimal storyboard skeleton for the month containing `todayIso`.
|
|
52
|
+
* Pure — takes the day as an ISO string and returns markdown. The heading reads
|
|
53
|
+
* `# Storyboard — {YYYY} {Month}`; the marker blocks match the syntax the
|
|
54
|
+
* scanner (`marker-scanner.js`) and renderer (`commands/refresh.js`) expect.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} todayIso - ISO date string (`YYYY-MM-DD`).
|
|
57
|
+
* @returns {string} The skeleton markdown, newline-terminated.
|
|
58
|
+
*/
|
|
59
|
+
export function renderStoryboardSkeleton(todayIso) {
|
|
60
|
+
const [year, month] = todayIso.split("-").map(Number);
|
|
61
|
+
const monthName = MONTH_NAMES[month - 1];
|
|
62
|
+
return `# Storyboard — ${year} ${monthName}
|
|
63
|
+
|
|
64
|
+
## Challenge
|
|
65
|
+
|
|
66
|
+
> [product-manager sets this in the planning meeting.]
|
|
67
|
+
|
|
68
|
+
## Target Condition
|
|
69
|
+
|
|
70
|
+
**Due:** ${endOfMonthIso(todayIso)}
|
|
71
|
+
|
|
72
|
+
> [product-manager sets this in the planning meeting.]
|
|
73
|
+
|
|
74
|
+
## Current Condition
|
|
75
|
+
|
|
76
|
+
**Last updated:** ${todayIso}
|
|
77
|
+
|
|
78
|
+
### Headlines
|
|
79
|
+
|
|
80
|
+
None.
|
|
81
|
+
|
|
82
|
+
## Obstacles
|
|
83
|
+
|
|
84
|
+
### Active
|
|
85
|
+
|
|
86
|
+
<!-- obstacles:open Do not edit. Auto-generated. -->
|
|
87
|
+
<!-- /obstacles -->
|
|
88
|
+
|
|
89
|
+
### Concluded (last 7 days)
|
|
90
|
+
|
|
91
|
+
<!-- obstacles:closed Do not edit. Auto-generated. -->
|
|
92
|
+
<!-- /obstacles -->
|
|
93
|
+
|
|
94
|
+
## Experiments
|
|
95
|
+
|
|
96
|
+
### Active
|
|
97
|
+
|
|
98
|
+
<!-- experiments:open Do not edit. Auto-generated. -->
|
|
99
|
+
<!-- /experiments -->
|
|
100
|
+
|
|
101
|
+
### Concluded (last 7 days)
|
|
102
|
+
|
|
103
|
+
<!-- experiments:closed Do not edit. Auto-generated. -->
|
|
104
|
+
<!-- /experiments -->
|
|
105
|
+
`;
|
|
106
|
+
}
|