@forwardimpact/libwiki 0.2.10 → 0.2.12

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,18 +1,23 @@
1
- import { existsSync } from "node:fs";
2
- import fsAsync from "node:fs/promises";
3
1
  import path from "node:path";
4
- import { Finder } from "@forwardimpact/libutil";
5
2
  import { writeMemo } from "../memo-writer.js";
6
3
  import { listAgents } from "../agent-roster.js";
7
4
  import { BROADCAST_TARGET } from "../constants.js";
5
+ import { currentDayIso } from "../util/clock.js";
6
+ import { resolveProjectRoot } from "../util/wiki-dir.js";
8
7
 
9
- function writeAndCheck(summaryPath, sender, message, today) {
10
- const result = writeMemo({ summaryPath, sender, message, today });
8
+ function writeAndCheck(runtime, summaryPath, sender, message, today) {
9
+ const result = writeMemo(
10
+ { summaryPath, sender, message, today },
11
+ runtime.fsSync,
12
+ );
11
13
  if (!result.written) {
12
- process.stderr.write(`summary lacks memo:inbox marker: ${result.path}\n`);
13
- process.exit(2);
14
+ runtime.proc.stderr.write(
15
+ `summary lacks memo:inbox marker: ${result.path}\n`,
16
+ );
17
+ return { ok: false, code: 2 };
14
18
  }
15
- process.stdout.write(`wrote ${result.path}\n`);
19
+ runtime.proc.stdout.write(`wrote ${result.path}\n`);
20
+ return { ok: true };
16
21
  }
17
22
 
18
23
  function resolveTargetPath(wikiRoot, target) {
@@ -25,72 +30,76 @@ function resolveTargetPath(wikiRoot, target) {
25
30
  return { summaryPath, escapesRoot };
26
31
  }
27
32
 
28
- function writeSingleTarget({ wikiRoot, target, sender, message, today, cli }) {
33
+ function writeSingleTarget(
34
+ runtime,
35
+ { wikiRoot, target, sender, message, today },
36
+ ) {
29
37
  const { summaryPath, escapesRoot } = resolveTargetPath(wikiRoot, target);
30
38
  if (escapesRoot) {
31
- cli.usageError(`target escapes wiki root: ${target}`);
32
- process.exit(2);
39
+ return { ok: false, code: 2, error: `target escapes wiki root: ${target}` };
33
40
  }
34
- if (!existsSync(summaryPath)) {
35
- cli.usageError(`target summary not found: ${summaryPath}`);
36
- process.exit(2);
41
+ if (!runtime.fsSync.existsSync(summaryPath)) {
42
+ return {
43
+ ok: false,
44
+ code: 2,
45
+ error: `target summary not found: ${summaryPath}`,
46
+ };
37
47
  }
38
- writeAndCheck(summaryPath, sender, message, today);
48
+ return writeAndCheck(runtime, summaryPath, sender, message, today);
39
49
  }
40
50
 
41
- function writeBroadcast({ agentsDir, wikiRoot, sender, message, today }) {
42
- const agents = listAgents({ agentsDir, wikiRoot });
51
+ function writeBroadcast(
52
+ runtime,
53
+ { agentsDir, wikiRoot, sender, message, today },
54
+ ) {
55
+ const agents = listAgents({ agentsDir, wikiRoot }, runtime.fsSync);
43
56
  for (const { agent, summaryPath } of agents) {
44
57
  if (agent === sender) continue;
45
- writeAndCheck(summaryPath, sender, message, today);
58
+ const result = writeAndCheck(runtime, summaryPath, sender, message, today);
59
+ if (!result.ok) return result;
46
60
  }
61
+ return { ok: true };
47
62
  }
48
63
 
49
64
  /** 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. */
50
- export function runMemoCommand(values, _args, cli) {
51
- const sender = values.from || process.env.LIBEVAL_AGENT_PROFILE;
65
+ export function runMemoCommand(ctx) {
66
+ const { runtime } = ctx.deps;
67
+ const options = ctx.options;
68
+ const sender = options.from || runtime.proc.env.LIBEVAL_AGENT_PROFILE;
52
69
 
53
70
  if (!sender) {
54
- cli.usageError(
55
- "memo requires --from <sender> or LIBEVAL_AGENT_PROFILE env var",
56
- );
57
- process.exit(2);
71
+ return {
72
+ ok: false,
73
+ code: 2,
74
+ error: "memo requires --from <sender> or LIBEVAL_AGENT_PROFILE env var",
75
+ };
58
76
  }
59
-
60
- if (!values.to) {
61
- cli.usageError("memo requires --to <target|all>");
62
- process.exit(2);
77
+ if (!options.to) {
78
+ return { ok: false, code: 2, error: "memo requires --to <target|all>" };
63
79
  }
64
-
65
- if (!values.message) {
66
- cli.usageError("memo requires --message <text>");
67
- process.exit(2);
80
+ if (!options.message) {
81
+ return { ok: false, code: 2, error: "memo requires --message <text>" };
68
82
  }
69
83
 
70
- const logger = { debug() {} };
71
- const finder = new Finder(fsAsync, logger, process);
72
- const projectRoot = finder.findProjectRoot(process.cwd());
73
-
74
- const wikiRoot = values["wiki-root"] || path.join(projectRoot, "wiki");
84
+ const projectRoot = resolveProjectRoot(runtime);
85
+ const wikiRoot = options["wiki-root"] || path.join(projectRoot, "wiki");
75
86
  const agentsDir = path.join(projectRoot, ".claude", "agents");
76
- const today = new Date().toISOString().slice(0, 10);
87
+ const today = currentDayIso(runtime);
77
88
 
78
- if (values.to === BROADCAST_TARGET) {
79
- writeBroadcast({
89
+ if (options.to === BROADCAST_TARGET) {
90
+ return writeBroadcast(runtime, {
80
91
  agentsDir,
81
92
  wikiRoot,
82
93
  sender,
83
- message: values.message,
84
- today,
85
- });
86
- } else {
87
- writeSingleTarget({
88
- wikiRoot,
89
- target: values.to,
90
- sender,
91
- message: values.message,
94
+ message: options.message,
92
95
  today,
93
- cli,
94
96
  });
95
97
  }
98
+ return writeSingleTarget(runtime, {
99
+ wikiRoot,
100
+ target: options.to,
101
+ sender,
102
+ message: options.message,
103
+ today,
104
+ });
96
105
  }
@@ -1,36 +1,33 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
2
- import { spawnSync } from "node:child_process";
3
1
  import path from "node:path";
4
- import fsAsync from "node:fs/promises";
5
- import { Finder } from "@forwardimpact/libutil";
2
+ import { yearMonth } from "@forwardimpact/libutil";
6
3
  import { createScriptConfig } from "@forwardimpact/libconfig";
7
4
  import { scanMarkers } from "../marker-scanner.js";
8
5
  import { renderBlock, BlockRenderError } from "../block-renderer.js";
9
6
  import { renderIssueList, parseRepoSlug } from "../issue-list-renderer.js";
10
- import { createDefaultIo } from "../io.js";
7
+ import { currentDayIso } from "../util/clock.js";
8
+ import { resolveProjectRoot } from "../util/wiki-dir.js";
11
9
 
12
- function currentStoryboardPath(now = new Date()) {
13
- const yyyy = now.getFullYear();
14
- const mm = String(now.getMonth() + 1).padStart(2, "0");
15
- return `wiki/storyboard-${yyyy}-M${mm}.md`;
10
+ function currentStoryboardRelPath(runtime) {
11
+ return `wiki/storyboard-${yearMonth(currentDayIso(runtime))}.md`;
16
12
  }
17
13
 
18
- function deriveParentRepo(parentDir, env) {
14
+ async function deriveParentRepo(gitClient, parentDir, env) {
19
15
  if (env.FIT_GH_REPO) return env.FIT_GH_REPO;
20
- const r = spawnSync("git", ["-C", parentDir, "remote", "get-url", "origin"], {
21
- encoding: "utf-8",
22
- stdio: "pipe",
23
- });
24
- if (r.status !== 0) return null;
25
- return parseRepoSlug(r.stdout);
16
+ try {
17
+ const url = await gitClient.remoteGetUrl("origin", { cwd: parentDir });
18
+ return parseRepoSlug(url);
19
+ } catch {
20
+ return null;
21
+ }
26
22
  }
27
23
 
28
- function renderForBlock(block, projectRoot, ghContext) {
24
+ async function renderForBlock(block, projectRoot, ghContext, runtime) {
29
25
  if (block.kind === "xmr") {
30
26
  return renderBlock({
31
27
  metric: block.metric,
32
28
  csvPath: block.csvPath,
33
29
  projectRoot,
30
+ fs: runtime.fsSync,
34
31
  });
35
32
  }
36
33
  if (block.kind === "issue-list") {
@@ -41,6 +38,8 @@ function renderForBlock(block, projectRoot, ghContext) {
41
38
  cwd: ghContext.cwd,
42
39
  repo: ghContext.repo,
43
40
  token: ghContext.token,
41
+ today: currentDayIso(runtime),
42
+ runtime,
44
43
  });
45
44
  }
46
45
  return null;
@@ -55,23 +54,20 @@ function spliceBlock(lines, block, rendered) {
55
54
  }
56
55
 
57
56
  /** Re-render XmR chart blocks and issue-list blocks in a storyboard file. */
58
- export async function runRefreshCommand(
59
- values,
60
- args,
61
- _cli,
62
- io = createDefaultIo(),
63
- ) {
64
- const logger = { debug() {} };
65
- const finder = new Finder(fsAsync, logger, { cwd: io.cwd });
66
- const projectRoot = finder.findProjectRoot(io.cwd());
57
+ export async function runRefreshCommand(ctx) {
58
+ const { runtime, gitClient } = ctx.deps;
59
+ const options = ctx.options;
60
+ const projectRoot = resolveProjectRoot(runtime);
67
61
 
68
62
  const storyboardPath = path.resolve(
69
63
  projectRoot,
70
- args[0] || currentStoryboardPath(),
64
+ ctx.args["storyboard-path"] || currentStoryboardRelPath(runtime),
71
65
  );
72
- const text = readFileSync(storyboardPath, "utf-8");
73
- const blocks = scanMarkers(text);
74
- if (blocks.length === 0) return;
66
+ const text = runtime.fsSync.readFileSync(storyboardPath, "utf-8");
67
+ const blocks = scanMarkers(text, {
68
+ warn: (message) => runtime.proc.stderr.write(message),
69
+ });
70
+ if (blocks.length === 0) return { ok: true };
75
71
 
76
72
  const config = await createScriptConfig("wiki");
77
73
  let token = null;
@@ -89,7 +85,7 @@ export async function runRefreshCommand(
89
85
  // overrides the parsed origin.
90
86
  const ghContext = {
91
87
  cwd: projectRoot,
92
- repo: deriveParentRepo(projectRoot, io.env),
88
+ repo: await deriveParentRepo(gitClient, projectRoot, runtime.proc.env),
93
89
  token,
94
90
  };
95
91
 
@@ -99,20 +95,28 @@ export async function runRefreshCommand(
99
95
  for (let i = blocks.length - 1; i >= 0; i--) {
100
96
  const block = blocks[i];
101
97
  try {
102
- const rendered = renderForBlock(block, projectRoot, ghContext);
98
+ const rendered = await renderForBlock(
99
+ block,
100
+ projectRoot,
101
+ ghContext,
102
+ runtime,
103
+ );
103
104
  if (!rendered) continue;
104
105
  spliceBlock(lines, block, rendered);
105
106
  spliced = true;
106
107
  } catch (err) {
107
108
  if (!(err instanceof BlockRenderError)) throw err;
108
- io.stderr(
109
+ runtime.proc.stderr.write(
109
110
  `refresh-error ${storyboardPath}:${block.openLine + 1} ${err.message}\n`,
110
111
  );
111
112
  }
112
113
  }
113
114
 
114
- if (spliced) writeFileSync(storyboardPath, lines.join("\n"));
115
- if (values && values.format === "json") {
116
- io.stdout(JSON.stringify({ blocks: blocks.length, spliced }) + "\n");
115
+ if (spliced) runtime.fsSync.writeFileSync(storyboardPath, lines.join("\n"));
116
+ if (options.format === "json") {
117
+ runtime.proc.stdout.write(
118
+ JSON.stringify({ blocks: blocks.length, spliced }) + "\n",
119
+ );
117
120
  }
121
+ return { ok: true };
118
122
  }
@@ -1,25 +1,36 @@
1
- import fsAsync from "node:fs/promises";
2
- import path from "node:path";
3
- import { Finder } from "@forwardimpact/libutil";
4
1
  import { rotateIfOverBudget } from "../weekly-log.js";
2
+ import { currentDayIso } from "../util/clock.js";
3
+ import { resolveWikiRoot } from "../util/wiki-dir.js";
5
4
 
6
5
  /** 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;
6
+ export function runRotateCommand(ctx) {
7
+ const { runtime } = ctx.deps;
8
+ const options = ctx.options;
9
+ const agent = options.agent || runtime.proc.env.LIBEVAL_AGENT_PROFILE;
9
10
  if (!agent) {
10
- cli.usageError("rotate requires --agent or LIBEVAL_AGENT_PROFILE");
11
- process.exit(2);
11
+ return {
12
+ ok: false,
13
+ code: 2,
14
+ error: "rotate requires --agent or LIBEVAL_AGENT_PROFILE",
15
+ };
12
16
  }
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);
17
+ const wikiRoot = resolveWikiRoot(runtime, options);
18
+ const today = options.today || currentDayIso(runtime);
18
19
 
19
- const result = rotateIfOverBudget(wikiRoot, agent, today, 0, { force: true });
20
+ const result = rotateIfOverBudget(
21
+ wikiRoot,
22
+ agent,
23
+ today,
24
+ 0,
25
+ { force: true },
26
+ runtime.fsSync,
27
+ );
20
28
  if (result.rotated) {
21
- process.stdout.write(`rotated ${result.fromPath} → ${result.toPath}\n`);
29
+ runtime.proc.stdout.write(
30
+ `rotated ${result.fromPath} → ${result.toPath}\n`,
31
+ );
22
32
  } else {
23
- process.stdout.write(`no rotation needed for ${agent}\n`);
33
+ runtime.proc.stdout.write(`no rotation needed for ${agent}\n`);
24
34
  }
35
+ return { ok: true };
25
36
  }
@@ -1,50 +1,34 @@
1
- import path from "node:path";
2
- import fsAsync from "node:fs/promises";
3
- import { Finder } from "@forwardimpact/libutil";
4
- import { createScriptConfig } from "@forwardimpact/libconfig";
5
- import { WikiRepo, WikiPullConflict } from "../wiki-repo.js";
6
-
7
- async function buildRepo(values) {
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 config = await createScriptConfig("wiki");
14
- return new WikiRepo({
15
- wikiDir,
16
- parentDir: projectRoot,
17
- resolveToken: () => config.ghToken(),
18
- });
19
- }
1
+ import { WikiPullConflict } from "../wiki-sync.js";
20
2
 
21
3
  /** 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);
24
- repo.inheritIdentity();
4
+ export async function runPushCommand(ctx) {
5
+ const { runtime, wikiSync } = ctx.deps;
6
+ await wikiSync.inheritIdentity();
25
7
 
26
- const result = repo.commitAndPush("wiki: update from session");
8
+ const result = await wikiSync.commitAndPush("wiki: update from session");
27
9
  if (result.pushed) {
28
- process.stdout.write("push: committed and pushed\n");
10
+ runtime.proc.stdout.write("push: committed and pushed\n");
29
11
  } else {
30
- process.stdout.write("push: nothing to push\n");
12
+ runtime.proc.stdout.write("push: nothing to push\n");
31
13
  }
14
+ return { ok: true };
32
15
  }
33
16
 
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);
37
- repo.inheritIdentity();
17
+ /** Fetch and rebase the local wiki on origin/master; on rebase conflict, return a non-zero envelope with a message to resolve manually or push first. */
18
+ export async function runPullCommand(ctx) {
19
+ const { runtime, wikiSync } = ctx.deps;
20
+ await wikiSync.inheritIdentity();
38
21
 
39
22
  try {
40
- repo.pull();
41
- process.stdout.write("pull: up to date\n");
23
+ await wikiSync.pull();
24
+ runtime.proc.stdout.write("pull: up to date\n");
25
+ return { ok: true };
42
26
  } catch (err) {
43
27
  if (err instanceof WikiPullConflict) {
44
- process.stderr.write(
28
+ runtime.proc.stderr.write(
45
29
  "fit-wiki pull: rebase conflict — local divergence detected; resolve manually or push first\n",
46
30
  );
47
- process.exit(1);
31
+ return { ok: false, code: 1 };
48
32
  }
49
33
  throw err;
50
34
  }
package/src/index.js CHANGED
@@ -20,7 +20,7 @@ export {
20
20
  export { scanMarkers } from "./marker-scanner.js";
21
21
  export { renderBlock } from "./block-renderer.js";
22
22
  export { renderIssueList } from "./issue-list-renderer.js";
23
- export { WikiRepo } from "./wiki-repo.js";
23
+ export { WikiSync, WikiPullConflict } from "./wiki-sync.js";
24
24
  export { listSkills } from "./skill-roster.js";
25
25
  export {
26
26
  parseClaims,
@@ -1,22 +1,4 @@
1
- import { spawnSync } from "node:child_process";
2
-
3
- function defaultGh(args, options) {
4
- const env = options?.token
5
- ? { ...process.env, GH_TOKEN: options.token }
6
- : undefined;
7
- return spawnSync("gh", args, {
8
- encoding: "utf-8",
9
- stdio: ["ignore", "pipe", "pipe"],
10
- cwd: options?.cwd,
11
- env,
12
- });
13
- }
14
-
15
- function daysAgo(today, n) {
16
- const d = today instanceof Date ? new Date(today.getTime()) : new Date(today);
17
- d.setUTCDate(d.getUTCDate() - n);
18
- return d.toISOString().slice(0, 10);
19
- }
1
+ import { addDays } from "@forwardimpact/libutil";
20
2
 
21
3
  /** Parse `owner/repo` from a git origin URL. Tolerates http(s), ssh, and proxy-rewritten URLs (e.g. `http://host/git/owner/repo`) by taking the last two path segments after stripping `.git`. Returns null when nothing parseable is found. */
22
4
  export function parseRepoSlug(originUrl) {
@@ -27,16 +9,34 @@ export function parseRepoSlug(originUrl) {
27
9
  return `${match[1]}/${match[2]}`;
28
10
  }
29
11
 
30
- /** Render an issue-list block for an obstacles/experiments marker. Returns markdown lines. `cwd` should be the parent monorepo's project root so `gh` resolves the correct origin; `repo` is an explicit `owner/name` slug used when the origin remote is unparseable by `gh` (e.g. sandbox proxy URLs); `token` is the resolved GH token (e.g. via `Config.ghToken()`). */
31
- export function renderIssueList({
12
+ /**
13
+ * Render an issue-list block for an obstacles/experiments marker. Returns
14
+ * markdown lines. `cwd` should be the parent monorepo's project root so `gh`
15
+ * resolves the correct origin; `repo` is an explicit `owner/name` slug used when
16
+ * the origin remote is unparseable by `gh` (e.g. sandbox proxy URLs); `token`
17
+ * is the resolved GH token (e.g. via `Config.ghToken()`). The `gh` command runs
18
+ * through `runtime.subprocess`, and stderr warnings through `runtime.proc`.
19
+ *
20
+ * @param {object} options
21
+ * @param {string} options.topic
22
+ * @param {string} options.state
23
+ * @param {string|null} options.window
24
+ * @param {string} options.cwd
25
+ * @param {string} [options.repo]
26
+ * @param {string} [options.token]
27
+ * @param {string} options.today - ISO date string used for the closed-window cutoff.
28
+ * @param {import('@forwardimpact/libutil/runtime').Runtime} options.runtime
29
+ * @returns {Promise<string[]>}
30
+ */
31
+ export async function renderIssueList({
32
32
  topic,
33
33
  state,
34
34
  window,
35
35
  cwd,
36
36
  repo,
37
37
  token,
38
- today = new Date(),
39
- gh = defaultGh,
38
+ today,
39
+ runtime,
40
40
  }) {
41
41
  const ghState = state === "closed" ? "closed" : "open";
42
42
  const args = ["issue", "list"];
@@ -51,9 +51,10 @@ export function renderIssueList({
51
51
  "--limit",
52
52
  "100",
53
53
  );
54
- const result = gh(args, { cwd, token });
55
- if (result.status !== 0) {
56
- process.stderr.write(
54
+ const env = token ? { ...runtime.proc.env, GH_TOKEN: token } : undefined;
55
+ const result = await runtime.subprocess.run("gh", args, { cwd, env });
56
+ if (result.exitCode !== 0) {
57
+ runtime.proc.stderr.write(
57
58
  `refresh: gh issue list failed for ${topic}:${state}\n`,
58
59
  );
59
60
  return [];
@@ -62,7 +63,7 @@ export function renderIssueList({
62
63
  try {
63
64
  issues = JSON.parse(result.stdout || "[]");
64
65
  } catch {
65
- process.stderr.write(
66
+ runtime.proc.stderr.write(
66
67
  `refresh: gh issue list JSON parse failed for ${topic}:${state}\n`,
67
68
  );
68
69
  return [];
@@ -72,7 +73,7 @@ export function renderIssueList({
72
73
  const windowDays = window
73
74
  ? Number.parseInt(window.replace("d", ""), 10)
74
75
  : 7;
75
- const cutoff = daysAgo(today, windowDays);
76
+ const cutoff = addDays(today, -windowDays);
76
77
  issues = issues.filter(
77
78
  (i) => i.closedAt && i.closedAt.slice(0, 10) >= cutoff,
78
79
  );
@@ -1,13 +1,14 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
2
1
  import { MEMO_INBOX_MARKER, INBOX_HEADING } from "./constants.js";
3
2
  import { listAgents } from "./agent-roster.js";
4
3
 
5
- /** Insert memo:inbox markers into agent summary files that have an inbox heading but no marker yet. */
6
- export function insertMarkers(
7
- { agentsDir, wikiRoot },
8
- fs = { readFileSync, writeFileSync },
9
- ) {
10
- const agents = listAgents({ agentsDir, wikiRoot });
4
+ /**
5
+ * Insert memo:inbox markers into agent summary files that have an inbox heading
6
+ * but no marker yet.
7
+ * @param {{agentsDir: string, wikiRoot: string}} dirs
8
+ * @param {object} fs - Sync filesystem surface (`runtime.fsSync`).
9
+ */
10
+ export function insertMarkers({ agentsDir, wikiRoot }, fs) {
11
+ const agents = listAgents({ agentsDir, wikiRoot }, fs);
11
12
  const inserted = [];
12
13
  const skipped = [];
13
14
  const errors = [];
@@ -12,10 +12,8 @@ function openLabel(open) {
12
12
  return open.kind === "xmr" ? open.metric : open.topic;
13
13
  }
14
14
 
15
- function warnDangling(open) {
16
- process.stderr.write(
17
- `dangling-marker ${openLabel(open)} at line ${open.openLine + 1}\n`,
18
- );
15
+ function warnDangling(open, warn) {
16
+ warn(`dangling-marker ${openLabel(open)} at line ${open.openLine + 1}\n`);
19
17
  }
20
18
 
21
19
  function tryOpen(line, i) {
@@ -68,8 +66,15 @@ function matchClose(line, open) {
68
66
  return Boolean(m && open.kind === "issue-list" && open.topic === m[1]);
69
67
  }
70
68
 
71
- /** Scan text for paired marker blocks (xmr or issue-list). Returns positions and metadata. */
72
- export function scanMarkers(text) {
69
+ /**
70
+ * Scan text for paired marker blocks (xmr or issue-list). Returns positions and
71
+ * metadata. Dangling open markers are reported through the injected `warn`
72
+ * callback (default: discard) instead of writing to the process directly.
73
+ * @param {string} text - The storyboard text to scan.
74
+ * @param {{warn?: (message: string) => void}} [options]
75
+ * @returns {Array<object>} The paired marker blocks.
76
+ */
77
+ export function scanMarkers(text, { warn = () => {} } = {}) {
73
78
  const lines = text.split("\n");
74
79
  const pairs = [];
75
80
  let open = null;
@@ -78,7 +83,7 @@ export function scanMarkers(text) {
78
83
  const line = lines[i];
79
84
  const newOpen = tryOpen(line, i);
80
85
  if (newOpen) {
81
- if (open) warnDangling(open);
86
+ if (open) warnDangling(open, warn);
82
87
  open = newOpen;
83
88
  continue;
84
89
  }
@@ -88,7 +93,7 @@ export function scanMarkers(text) {
88
93
  }
89
94
  }
90
95
 
91
- if (open) warnDangling(open);
96
+ if (open) warnDangling(open, warn);
92
97
 
93
98
  return pairs;
94
99
  }
@@ -1,11 +1,12 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
2
1
  import { MEMO_INBOX_MARKER } from "./constants.js";
3
2
 
4
- /** Append a timestamped memo bullet below the inbox marker in an agent's summary file. */
5
- export function writeMemo(
6
- { summaryPath, sender, message, today },
7
- fs = { readFileSync, writeFileSync },
8
- ) {
3
+ /**
4
+ * Append a timestamped memo bullet below the inbox marker in an agent's summary
5
+ * file.
6
+ * @param {{summaryPath: string, sender: string, message: string, today: string}} memo
7
+ * @param {object} fs - Sync filesystem surface (`runtime.fsSync`).
8
+ */
9
+ export function writeMemo({ summaryPath, sender, message, today }, fs) {
9
10
  const content = fs.readFileSync(summaryPath, "utf-8");
10
11
  const lines = content.split("\n");
11
12
 
@@ -1,8 +1,12 @@
1
- import { readdirSync, statSync } from "node:fs";
2
1
  import path from "node:path";
3
2
 
4
- /** List all kata-prefixed skill directory names under the skills directory, sorted alphabetically. */
5
- export function listSkills({ skillsDir }, fs = { readdirSync, statSync }) {
3
+ /**
4
+ * List all kata-prefixed skill directory names under the skills directory,
5
+ * sorted alphabetically.
6
+ * @param {{skillsDir: string}} dirs
7
+ * @param {object} fs - Sync filesystem surface (`runtime.fsSync`).
8
+ */
9
+ export function listSkills({ skillsDir }, fs) {
6
10
  const entries = fs.readdirSync(skillsDir);
7
11
  const skills = [];
8
12
 
package/src/status.js ADDED
@@ -0,0 +1,19 @@
1
+ // STATUS.md row ids may carry a `/<unit>` suffix denoting a
2
+ // per-migration-unit sub-row of a master spec (`1370/libutil`, …). The master
3
+ // `NNNN` row advances only when every sub-row reads `plan implemented`.
4
+
5
+ /** Matches a status-row id: four digits, optionally a `/<unit>` suffix. */
6
+ export const STATUS_ID_REGEX = /^\d{4}(\/[a-z0-9-]+)?$/;
7
+
8
+ /**
9
+ * Parse a status-row id into its master spec id and optional unit suffix.
10
+ * @param {string} id - The id field of a STATUS.md row.
11
+ * @returns {{ specId: string, unit: string|null }|null} Parsed parts, or null
12
+ * when the id does not match {@link STATUS_ID_REGEX}.
13
+ */
14
+ export function parseStatusRowId(id) {
15
+ if (typeof id !== "string" || !STATUS_ID_REGEX.test(id)) return null;
16
+ const slash = id.indexOf("/");
17
+ if (slash === -1) return { specId: id, unit: null };
18
+ return { specId: id.slice(0, slash), unit: id.slice(slash + 1) };
19
+ }
@@ -0,0 +1,13 @@
1
+ import { isoDate } from "@forwardimpact/libutil";
2
+
3
+ /**
4
+ * Today's ISO calendar date (`YYYY-MM-DD`) read from the injected clock.
5
+ * Commands that previously inlined `new Date().toISOString().slice(0, 10)` (or
6
+ * libwiki's `io.today()`) call this instead so the wall-clock read flows
7
+ * through `runtime.clock`.
8
+ * @param {import('@forwardimpact/libutil/runtime').Runtime} runtime
9
+ * @returns {string}
10
+ */
11
+ export function currentDayIso(runtime) {
12
+ return isoDate(runtime.clock.now());
13
+ }