@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.
@@ -1,12 +1,21 @@
1
- import { mkdirSync } from "node:fs";
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
2
2
  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";
9
+ import {
10
+ ACTIVE_CLAIMS_HEADING,
11
+ ACTIVE_CLAIMS_TABLE_HEADER,
12
+ ACTIVE_CLAIMS_TABLE_SEPARATOR,
13
+ } from "../constants.js";
14
+
15
+ /** 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. */
16
+ export function deriveWikiUrl(parentDir) {
17
+ if (process.env.FIT_WIKI_URL) return process.env.FIT_WIKI_URL;
8
18
 
9
- function deriveWikiUrl(parentDir) {
10
19
  const r = spawnSync("git", ["-C", parentDir, "remote", "get-url", "origin"], {
11
20
  encoding: "utf-8",
12
21
  stdio: "pipe",
@@ -17,18 +26,96 @@ function deriveWikiUrl(parentDir) {
17
26
  return base + ".wiki.git";
18
27
  }
19
28
 
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());
29
+ function scaffoldActiveClaims(memoryPath) {
30
+ if (!existsSync(memoryPath)) return false;
31
+ const text = readFileSync(memoryPath, "utf-8");
32
+ if (new RegExp(`^${ACTIVE_CLAIMS_HEADING}$`, "m").test(text)) return false;
25
33
 
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
- );
34
+ const block = [
35
+ "",
36
+ ACTIVE_CLAIMS_HEADING,
37
+ "",
38
+ "In-flight work claimed by an agent. Row present = active; row absent = settled.",
39
+ "Writers: `fit-wiki claim`, `fit-wiki release`. Reader: `fit-wiki boot`.",
40
+ "",
41
+ ACTIVE_CLAIMS_TABLE_HEADER,
42
+ ACTIVE_CLAIMS_TABLE_SEPARATOR,
43
+ "| *None* | — | — | — | — | — |",
44
+ "",
45
+ ].join("\n");
46
+
47
+ const lines = text.split("\n");
48
+ const storyboardIdx = lines.findIndex((l) => l.trim() === "## Storyboard");
49
+ if (storyboardIdx === -1) {
50
+ writeFileSync(memoryPath, text.replace(/\n*$/, "") + "\n" + block + "\n");
51
+ return true;
52
+ }
53
+ lines.splice(storyboardIdx, 0, ...block.split("\n"), "");
54
+ writeFileSync(memoryPath, lines.join("\n"));
55
+ return true;
56
+ }
57
+
58
+ const AUDIT_HOOK_COMMAND = "bunx fit-wiki audit";
31
59
 
60
+ function readSettings(settingsPath) {
61
+ if (!existsSync(settingsPath)) return {};
62
+ try {
63
+ return JSON.parse(readFileSync(settingsPath, "utf-8"));
64
+ } catch {
65
+ process.stderr.write(
66
+ `init: ${settingsPath} is not valid JSON; skipping stop-hook install\n`,
67
+ );
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function hasAuditHook(settings) {
73
+ if (!settings.hooks || !Array.isArray(settings.hooks.Stop)) return false;
74
+ for (const group of settings.hooks.Stop) {
75
+ if (!group || !Array.isArray(group.hooks)) continue;
76
+ if (
77
+ group.hooks.some(
78
+ (h) =>
79
+ h &&
80
+ typeof h.command === "string" &&
81
+ h.command.includes("fit-wiki audit"),
82
+ )
83
+ ) {
84
+ return true;
85
+ }
86
+ }
87
+ return false;
88
+ }
89
+
90
+ function addAuditHook(settings) {
91
+ if (!settings.hooks) settings.hooks = {};
92
+ if (!Array.isArray(settings.hooks.Stop)) settings.hooks.Stop = [];
93
+ if (settings.hooks.Stop.length === 0) {
94
+ settings.hooks.Stop.push({
95
+ hooks: [{ type: "command", command: AUDIT_HOOK_COMMAND }],
96
+ });
97
+ return;
98
+ }
99
+ if (!Array.isArray(settings.hooks.Stop[0].hooks)) {
100
+ settings.hooks.Stop[0].hooks = [];
101
+ }
102
+ settings.hooks.Stop[0].hooks.push({
103
+ type: "command",
104
+ command: AUDIT_HOOK_COMMAND,
105
+ });
106
+ }
107
+
108
+ function installStopHook(settingsPath) {
109
+ const settings = readSettings(settingsPath);
110
+ if (settings === null) return false;
111
+ if (hasAuditHook(settings)) return false;
112
+ addAuditHook(settings);
113
+ mkdirSync(path.dirname(settingsPath), { recursive: true });
114
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
115
+ return true;
116
+ }
117
+
118
+ async function maybeCloneWiki(projectRoot, wikiDir) {
32
119
  const wikiUrl = deriveWikiUrl(projectRoot);
33
120
  if (!wikiUrl) {
34
121
  process.stderr.write(
@@ -36,20 +123,56 @@ export function runInitCommand(values, _args, cli) {
36
123
  );
37
124
  return;
38
125
  }
126
+ const config = await createScriptConfig("wiki");
127
+ const repo = new WikiRepo({
128
+ wikiDir,
129
+ parentDir: projectRoot,
130
+ resolveToken: () => config.ghToken(),
131
+ });
132
+ const cloneResult = repo.ensureCloned(wikiUrl);
133
+ if (cloneResult.cloned) {
134
+ repo.inheritIdentity();
135
+ } else {
136
+ process.stderr.write(
137
+ "init: could not clone wiki, continuing with local-only steps\n",
138
+ );
139
+ }
140
+ }
141
+
142
+ /** Clone the wiki if not already present, scaffold Active Claims in MEMORY.md, install the audit Stop-hook, and create per-skill metric directories. */
143
+ export async function runInitCommand(values, _args, _cli) {
144
+ const logger = { debug() {} };
145
+ const finder = new Finder(fsAsync, logger, process);
146
+ const projectRoot = finder.findProjectRoot(process.cwd());
147
+
148
+ const wikiDir = path.resolve(projectRoot, values["wiki-root"] ?? "wiki");
149
+ const skillsDir = path.resolve(
150
+ projectRoot,
151
+ values["skills-dir"] ?? path.join(".claude", "skills"),
152
+ );
153
+ const settingsPath = path.resolve(projectRoot, ".claude", "settings.json");
39
154
 
40
- const repo = new WikiRepo({ wikiDir, parentDir: projectRoot });
155
+ await maybeCloneWiki(projectRoot, wikiDir);
41
156
 
42
- const cloneResult = repo.ensureCloned(wikiUrl);
43
- if (!cloneResult.cloned) {
44
- process.stderr.write("init: could not clone wiki, skipping\n");
45
- return;
157
+ if (existsSync(skillsDir)) {
158
+ for (const slug of listSkills({ skillsDir })) {
159
+ mkdirSync(path.join(wikiDir, "metrics", slug), { recursive: true });
160
+ }
46
161
  }
47
162
 
48
- repo.inheritIdentity();
163
+ if (existsSync(wikiDir)) {
164
+ const memoryPath = path.join(wikiDir, "MEMORY.md");
165
+ if (scaffoldActiveClaims(memoryPath)) {
166
+ process.stdout.write(
167
+ `init: scaffolded ${ACTIVE_CLAIMS_HEADING} in ${memoryPath}\n`,
168
+ );
169
+ }
170
+ }
49
171
 
50
- const skills = listSkills({ skillsDir });
51
- for (const slug of skills) {
52
- mkdirSync(path.join(wikiDir, "metrics", slug), { recursive: true });
172
+ if (installStopHook(settingsPath)) {
173
+ process.stdout.write(
174
+ `init: installed Stop-hook audit entry in ${settingsPath}\n`,
175
+ );
53
176
  }
54
177
 
55
178
  process.stdout.write(`init: wiki ready at ${wikiDir}\n`);
@@ -0,0 +1,102 @@
1
+ import fsAsync from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { Finder } from "@forwardimpact/libutil";
4
+ import {
5
+ weeklyLogPath,
6
+ rotateIfOverBudget,
7
+ appendEntry,
8
+ } from "../weekly-log.js";
9
+ import { DECISION_HEADING } from "../constants.js";
10
+
11
+ function projectRootForCommand() {
12
+ const logger = { debug() {} };
13
+ const finder = new Finder(fsAsync, logger, process);
14
+ return finder.findProjectRoot(process.cwd());
15
+ }
16
+
17
+ function commonContext(values) {
18
+ const agent = values.agent || process.env.LIBEVAL_AGENT_PROFILE;
19
+ if (!agent) {
20
+ process.stderr.write(
21
+ "log requires --agent <name> or LIBEVAL_AGENT_PROFILE env var\n",
22
+ );
23
+ process.exit(2);
24
+ }
25
+ const projectRoot = projectRootForCommand();
26
+ const wikiRoot = values["wiki-root"] || path.join(projectRoot, "wiki");
27
+ const today = values.today || new Date().toISOString().slice(0, 10);
28
+ return { agent, wikiRoot, today };
29
+ }
30
+
31
+ function ensureDateHeading(body, today) {
32
+ const heading = `## ${today}`;
33
+ if (body.startsWith(heading) || body.startsWith("## ")) return body;
34
+ return `${heading}\n\n${body}`;
35
+ }
36
+
37
+ function runDecision(values) {
38
+ const { agent, wikiRoot, today } = commonContext(values);
39
+ const surveyed = values.surveyed || "—";
40
+ const chosen = values.chosen || "—";
41
+ const rationale = values.rationale || "—";
42
+ const alternatives = values.alternatives || "—";
43
+ const body = [
44
+ `## ${today}`,
45
+ "",
46
+ DECISION_HEADING,
47
+ "",
48
+ `**Surveyed:** ${surveyed}`,
49
+ "",
50
+ `**Alternatives:** ${alternatives}`,
51
+ "",
52
+ `**Chosen:** ${chosen}`,
53
+ "",
54
+ `**Rationale:** ${rationale}`,
55
+ "",
56
+ ].join("\n");
57
+ const lineCount = body.split("\n").length;
58
+ rotateIfOverBudget(wikiRoot, agent, today, lineCount);
59
+ const target = weeklyLogPath(wikiRoot, agent, today);
60
+ appendEntry(target, body, agent, today);
61
+ process.stdout.write(`logged decision to ${target}\n`);
62
+ }
63
+
64
+ function runNote(values) {
65
+ const { agent, wikiRoot, today } = commonContext(values);
66
+ if (!values.field || !values.body) {
67
+ process.stderr.write("log note requires --field and --body\n");
68
+ process.exit(2);
69
+ }
70
+ const body = ensureDateHeading(
71
+ `### ${values.field}\n\n${values.body}\n`,
72
+ today,
73
+ );
74
+ const lineCount = body.split("\n").length;
75
+ rotateIfOverBudget(wikiRoot, agent, today, lineCount);
76
+ const target = weeklyLogPath(wikiRoot, agent, today);
77
+ appendEntry(target, body, agent, today);
78
+ process.stdout.write(`logged note to ${target}\n`);
79
+ }
80
+
81
+ function runDone(values) {
82
+ const { agent, wikiRoot, today } = commonContext(values);
83
+ const body = `### Closed\n\nRun closed ${today}.\n`;
84
+ const lineCount = body.split("\n").length;
85
+ rotateIfOverBudget(wikiRoot, agent, today, lineCount);
86
+ const target = weeklyLogPath(wikiRoot, agent, today);
87
+ appendEntry(target, body, agent, today);
88
+ process.stdout.write(`closed entry in ${target}\n`);
89
+ }
90
+
91
+ const SUBS = { decision: runDecision, note: runNote, done: runDone };
92
+
93
+ /** Dispatch `log {decision|note|done}` to the matching sub-handler. */
94
+ export function runLogCommand(values, args, cli) {
95
+ const sub = args[0];
96
+ const handler = SUBS[sub];
97
+ if (!handler) {
98
+ cli.usageError("log requires subcommand: decision | note | done");
99
+ process.exit(2);
100
+ }
101
+ handler(values);
102
+ }
@@ -4,6 +4,7 @@ import fsAsync from "node:fs/promises";
4
4
  import { Finder } from "@forwardimpact/libutil";
5
5
  import { scanMarkers } from "../marker-scanner.js";
6
6
  import { renderBlock, BlockRenderError } from "../block-renderer.js";
7
+ import { renderIssueList } from "../issue-list-renderer.js";
7
8
 
8
9
  function currentStoryboardPath() {
9
10
  const now = new Date();
@@ -12,8 +13,34 @@ function currentStoryboardPath() {
12
13
  return `wiki/storyboard-${yyyy}-M${mm}.md`;
13
14
  }
14
15
 
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) {
16
+ function renderForBlock(block, projectRoot) {
17
+ if (block.kind === "xmr") {
18
+ return renderBlock({
19
+ metric: block.metric,
20
+ csvPath: block.csvPath,
21
+ projectRoot,
22
+ });
23
+ }
24
+ if (block.kind === "issue-list") {
25
+ return renderIssueList({
26
+ topic: block.topic,
27
+ state: block.state,
28
+ window: block.window,
29
+ });
30
+ }
31
+ return null;
32
+ }
33
+
34
+ function spliceBlock(lines, block, rendered) {
35
+ lines.splice(
36
+ block.openLine + 1,
37
+ block.closeLine - block.openLine - 1,
38
+ ...rendered,
39
+ );
40
+ }
41
+
42
+ /** Re-render XmR chart blocks and issue-list blocks in a storyboard file. */
43
+ export function runRefreshCommand(values, args, _cli) {
17
44
  const logger = { debug() {} };
18
45
  const finder = new Finder(fsAsync, logger, process);
19
46
  const projectRoot = finder.findProjectRoot(process.cwd());
@@ -24,7 +51,6 @@ export function runRefreshCommand(values, args, cli) {
24
51
  );
25
52
  const text = readFileSync(storyboardPath, "utf-8");
26
53
  const blocks = scanMarkers(text);
27
-
28
54
  if (blocks.length === 0) return;
29
55
 
30
56
  const lines = text.split("\n");
@@ -33,16 +59,9 @@ export function runRefreshCommand(values, args, cli) {
33
59
  for (let i = blocks.length - 1; i >= 0; i--) {
34
60
  const block = blocks[i];
35
61
  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
- );
62
+ const rendered = renderForBlock(block, projectRoot);
63
+ if (!rendered) continue;
64
+ spliceBlock(lines, block, rendered);
46
65
  spliced = true;
47
66
  } catch (err) {
48
67
  if (!(err instanceof BlockRenderError)) throw err;
@@ -53,4 +72,9 @@ export function runRefreshCommand(values, args, cli) {
53
72
  }
54
73
 
55
74
  if (spliced) writeFileSync(storyboardPath, lines.join("\n"));
75
+ if (values && values.format === "json") {
76
+ process.stdout.write(
77
+ JSON.stringify({ blocks: blocks.length, spliced }) + "\n",
78
+ );
79
+ }
56
80
  }
@@ -0,0 +1,25 @@
1
+ import fsAsync from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { Finder } from "@forwardimpact/libutil";
4
+ import { rotateIfOverBudget } from "../weekly-log.js";
5
+
6
+ /** Force-rotate the current weekly log to a sealed part file. */
7
+ export function runRotateCommand(values, _args, cli) {
8
+ const agent = values.agent || process.env.LIBEVAL_AGENT_PROFILE;
9
+ if (!agent) {
10
+ cli.usageError("rotate requires --agent or LIBEVAL_AGENT_PROFILE");
11
+ process.exit(2);
12
+ }
13
+ const logger = { debug() {} };
14
+ const finder = new Finder(fsAsync, logger, process);
15
+ const projectRoot = finder.findProjectRoot(process.cwd());
16
+ const wikiRoot = values["wiki-root"] || path.join(projectRoot, "wiki");
17
+ const today = values.today || new Date().toISOString().slice(0, 10);
18
+
19
+ const result = rotateIfOverBudget(wikiRoot, agent, today, 0, { force: true });
20
+ if (result.rotated) {
21
+ process.stdout.write(`rotated ${result.fromPath} → ${result.toPath}\n`);
22
+ } else {
23
+ process.stdout.write(`no rotation needed for ${agent}\n`);
24
+ }
25
+ }
@@ -1,16 +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
- /** Commit all wiki changes and push them to the remote wiki repository. */
7
- export function runPushCommand(values, _args, cli) {
7
+ async function buildRepo(values) {
8
8
  const logger = { debug() {} };
9
9
  const finder = new Finder(fsAsync, logger, process);
10
10
  const projectRoot = finder.findProjectRoot(process.cwd());
11
11
  const wikiDir = path.resolve(projectRoot, values["wiki-root"] ?? "wiki");
12
12
 
13
- 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);
14
24
  repo.inheritIdentity();
15
25
 
16
26
  const result = repo.commitAndPush("wiki: update from session");
@@ -22,13 +32,8 @@ export function runPushCommand(values, _args, cli) {
22
32
  }
23
33
 
24
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. */
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 });
35
+ export async function runPullCommand(values, _args, cli) {
36
+ const repo = await buildRepo(values);
32
37
  repo.inheritIdentity();
33
38
 
34
39
  try {
package/src/constants.js CHANGED
@@ -1,3 +1,21 @@
1
1
  export const MEMO_INBOX_MARKER = "<!-- memo:inbox -->";
2
2
  export const INBOX_HEADING = "## Message Inbox";
3
3
  export const BROADCAST_TARGET = "all";
4
+
5
+ export const MEMORY_FILE = "MEMORY.md";
6
+ export const ACTIVE_CLAIMS_HEADING = "## Active Claims";
7
+ export const ACTIVE_CLAIMS_TABLE_HEADER =
8
+ "| agent | target | branch | pr | claimed_at | expires_at |";
9
+ export const ACTIVE_CLAIMS_TABLE_SEPARATOR =
10
+ "| --- | --- | --- | --- | --- | --- |";
11
+ export const PRIORITY_INDEX_HEADING = "## Cross-Cutting Priorities";
12
+ export const PRIORITY_INDEX_TABLE_HEADER =
13
+ "| Item | Agents | Owner | Status | Added |";
14
+ export const DECISION_HEADING = "### Decision";
15
+
16
+ // Cap derivation: ≤2.5% of a 1M-token context window = 25k tokens;
17
+ // ≈42 tokens/line empirical proxy → ~500 lines. See spec 1060
18
+ // design-a.md § Decision area 2 for the full anchor.
19
+ export const WEEKLY_LOG_LINE_BUDGET = 500;
20
+ export const SUMMARY_LINE_BUDGET = 80;
21
+ export const CUTOVER_ISO_WEEK = "2026-W23";
package/src/index.js CHANGED
@@ -5,8 +5,32 @@ export {
5
5
  MEMO_INBOX_MARKER,
6
6
  INBOX_HEADING,
7
7
  BROADCAST_TARGET,
8
+ MEMORY_FILE,
9
+ ACTIVE_CLAIMS_HEADING,
10
+ ACTIVE_CLAIMS_TABLE_HEADER,
11
+ ACTIVE_CLAIMS_TABLE_SEPARATOR,
12
+ PRIORITY_INDEX_HEADING,
13
+ PRIORITY_INDEX_TABLE_HEADER,
14
+ DECISION_HEADING,
15
+ WEEKLY_LOG_LINE_BUDGET,
16
+ SUMMARY_LINE_BUDGET,
17
+ CUTOVER_ISO_WEEK,
8
18
  } from "./constants.js";
9
19
  export { scanMarkers } from "./marker-scanner.js";
10
20
  export { renderBlock } from "./block-renderer.js";
21
+ export { renderIssueList } from "./issue-list-renderer.js";
11
22
  export { WikiRepo } from "./wiki-repo.js";
12
23
  export { listSkills } from "./skill-roster.js";
24
+ export {
25
+ parseClaims,
26
+ appendClaim,
27
+ removeClaim,
28
+ filterExpired,
29
+ } from "./active-claims.js";
30
+ export {
31
+ isoWeek,
32
+ weeklyLogPath,
33
+ rotateIfOverBudget,
34
+ appendEntry,
35
+ } from "./weekly-log.js";
36
+ export { buildDigest } from "./boot.js";
@@ -0,0 +1,69 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ function defaultGh(args) {
4
+ return spawnSync("gh", args, {
5
+ encoding: "utf-8",
6
+ stdio: ["ignore", "pipe", "pipe"],
7
+ });
8
+ }
9
+
10
+ function daysAgo(today, n) {
11
+ const d = today instanceof Date ? new Date(today.getTime()) : new Date(today);
12
+ d.setUTCDate(d.getUTCDate() - n);
13
+ return d.toISOString().slice(0, 10);
14
+ }
15
+
16
+ /** Render an issue-list block for an obstacles/experiments marker. Returns markdown lines. */
17
+ export function renderIssueList({
18
+ topic,
19
+ state,
20
+ window,
21
+ today = new Date(),
22
+ gh = defaultGh,
23
+ }) {
24
+ const ghState = state === "closed" ? "closed" : "open";
25
+ const result = gh([
26
+ "issue",
27
+ "list",
28
+ "--label",
29
+ topic.replace(/s$/, ""),
30
+ "--state",
31
+ ghState,
32
+ "--json",
33
+ "number,title,labels,closedAt",
34
+ "--limit",
35
+ "100",
36
+ ]);
37
+ if (result.status !== 0) {
38
+ process.stderr.write(
39
+ `refresh: gh issue list failed for ${topic}:${state}\n`,
40
+ );
41
+ return [];
42
+ }
43
+ let issues;
44
+ try {
45
+ issues = JSON.parse(result.stdout || "[]");
46
+ } catch {
47
+ process.stderr.write(
48
+ `refresh: gh issue list JSON parse failed for ${topic}:${state}\n`,
49
+ );
50
+ return [];
51
+ }
52
+
53
+ if (state === "closed") {
54
+ const windowDays = window
55
+ ? Number.parseInt(window.replace("d", ""), 10)
56
+ : 7;
57
+ const cutoff = daysAgo(today, windowDays);
58
+ issues = issues.filter(
59
+ (i) => i.closedAt && i.closedAt.slice(0, 10) >= cutoff,
60
+ );
61
+ }
62
+
63
+ const lines = [];
64
+ for (const issue of issues) {
65
+ const tag = topic === "experiments" ? "Exp" : "Obs";
66
+ lines.push(`- **${tag} #${issue.number} — ${issue.title}**`);
67
+ }
68
+ return lines;
69
+ }