@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.
@@ -0,0 +1,354 @@
1
+ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
2
+ import fsAsync from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { Finder } from "@forwardimpact/libutil";
5
+ import {
6
+ ACTIVE_CLAIMS_HEADING,
7
+ ACTIVE_CLAIMS_TABLE_HEADER,
8
+ CUTOVER_ISO_WEEK,
9
+ DECISION_HEADING,
10
+ MEMO_INBOX_MARKER,
11
+ PRIORITY_INDEX_HEADING,
12
+ SUMMARY_LINE_BUDGET,
13
+ WEEKLY_LOG_LINE_BUDGET,
14
+ } from "../constants.js";
15
+ import { parseClaims, filterExpired } from "../active-claims.js";
16
+
17
+ const SUMMARY_H1_RE = /^# [A-Z].* — Summary$/;
18
+ const WEEKLY_LOG_NAME_RE = /^[a-z-]+-(\d{4})-W(\d{2})\.md$/;
19
+ const WEEKLY_LOG_PART_NAME_RE = /^[a-z-]+-(\d{4})-W(\d{2})-part\d+\.md$/;
20
+ const WEEKLY_LOG_H1_RE = /^# .* — \d{4}-W\d{2}(?: \(part \d+ of \d+\))?$/;
21
+ const ENTRY_RE = /^## \d{4}-\d{2}-\d{2}(?:[\s(].*)?$/;
22
+
23
+ const EXCLUDED_SUMMARY_BASES = new Set(["MEMORY.md", "Home.md", "STATUS.md"]);
24
+ const NON_SUMMARY_PREFIXES = [
25
+ "storyboard-",
26
+ "downstream-",
27
+ "memory-protocol-",
28
+ "kata-interview-",
29
+ "fit-trace-",
30
+ ];
31
+
32
+ function hasNonSummaryPrefix(base) {
33
+ return NON_SUMMARY_PREFIXES.some((p) => base.startsWith(p));
34
+ }
35
+
36
+ function isSummaryFile(wikiRoot, filePath) {
37
+ const rel = path.relative(wikiRoot, filePath);
38
+ if (rel.includes(path.sep) || !rel.endsWith(".md")) return false;
39
+ const base = path.basename(rel);
40
+ if (EXCLUDED_SUMMARY_BASES.has(base)) return false;
41
+ if (hasNonSummaryPrefix(base)) return false;
42
+ if (WEEKLY_LOG_NAME_RE.test(base) || WEEKLY_LOG_PART_NAME_RE.test(base)) {
43
+ return false;
44
+ }
45
+ try {
46
+ const text = readFileSync(filePath, "utf-8");
47
+ const firstLine = text.split("\n").find((l) => l.trim() !== "");
48
+ return Boolean(firstLine && SUMMARY_H1_RE.test(firstLine));
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ function listMdFiles(wikiRoot) {
55
+ if (!existsSync(wikiRoot)) return [];
56
+ return readdirSync(wikiRoot)
57
+ .filter((e) => e.endsWith(".md"))
58
+ .map((e) => path.join(wikiRoot, e))
59
+ .filter((f) => {
60
+ try {
61
+ return statSync(f).isFile();
62
+ } catch {
63
+ return false;
64
+ }
65
+ });
66
+ }
67
+
68
+ function countLines(text) {
69
+ return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
70
+ }
71
+
72
+ function compareIsoWeek(a, b) {
73
+ return a.localeCompare(b);
74
+ }
75
+
76
+ function isoWeekForFile(base) {
77
+ const match =
78
+ base.match(WEEKLY_LOG_NAME_RE) || base.match(WEEKLY_LOG_PART_NAME_RE);
79
+ if (!match) return null;
80
+ return `${match[1]}-W${match[2]}`;
81
+ }
82
+
83
+ function pushFail(findings, level, message) {
84
+ findings.push({ level, message });
85
+ }
86
+
87
+ function inGraceWindow(graceUntil, today) {
88
+ return Boolean(graceUntil && graceUntil >= today);
89
+ }
90
+
91
+ function checkSummaryStructure(f, text, fileLines, h2s, findings) {
92
+ const firstLine = fileLines.find((l) => l.trim() !== "");
93
+ if (!firstLine || !SUMMARY_H1_RE.test(firstLine)) {
94
+ pushFail(findings, "fail", `sections: ${f} missing H1 '# ... — Summary'`);
95
+ }
96
+ if (!/^\*\*Last run\*\*:/m.test(text)) {
97
+ pushFail(findings, "fail", `sections: ${f} missing '**Last run**:' line`);
98
+ }
99
+ if (h2s.length > 0 && h2s[0] !== "Message Inbox") {
100
+ pushFail(
101
+ findings,
102
+ "fail",
103
+ `sections: ${f} first H2 is '${h2s[0]}', expected 'Message Inbox'`,
104
+ );
105
+ }
106
+ if (h2s.indexOf("Message Inbox") !== -1) {
107
+ const markerIdx = fileLines.findIndex(
108
+ (l) => l.trim() === MEMO_INBOX_MARKER,
109
+ );
110
+ if (markerIdx === -1) {
111
+ pushFail(
112
+ findings,
113
+ "fail",
114
+ `sections: ${f} missing <!-- memo:inbox --> marker`,
115
+ );
116
+ }
117
+ }
118
+ }
119
+
120
+ function checkSummaryOrdering(f, h2s, findings) {
121
+ let seenBlockers = false;
122
+ for (const h of h2s) {
123
+ if (h === "Open Blockers") {
124
+ seenBlockers = true;
125
+ continue;
126
+ }
127
+ if (seenBlockers) {
128
+ pushFail(
129
+ findings,
130
+ "fail",
131
+ `sections: ${f} '${h}' appears after 'Open Blockers'`,
132
+ );
133
+ }
134
+ }
135
+ }
136
+
137
+ function checkSummaryFile(f, findings, grace) {
138
+ const text = readFileSync(f, "utf-8");
139
+ const lines = countLines(text);
140
+ if (lines > SUMMARY_LINE_BUDGET) {
141
+ pushFail(
142
+ findings,
143
+ grace ? "warn" : "fail",
144
+ `budget: ${f} has ${lines} lines (limit ${SUMMARY_LINE_BUDGET})`,
145
+ );
146
+ }
147
+ const fileLines = text.split("\n");
148
+ const h2s = [];
149
+ for (const line of fileLines) {
150
+ const m = line.match(/^## (.+)$/);
151
+ if (m) h2s.push(m[1].trim());
152
+ }
153
+ checkSummaryStructure(f, text, fileLines, h2s, findings);
154
+ checkSummaryOrdering(f, h2s, findings);
155
+ }
156
+
157
+ function checkSummaries(wikiRoot, files, findings, options) {
158
+ const grace = inGraceWindow(options.graceUntil, options.today);
159
+ for (const f of files) {
160
+ if (!isSummaryFile(wikiRoot, f)) continue;
161
+ checkSummaryFile(f, findings, grace);
162
+ }
163
+ }
164
+
165
+ function entryHasDecision(allLines, startIdx) {
166
+ let nonBlankSeen = 0;
167
+ for (let j = startIdx + 1; j < allLines.length && nonBlankSeen < 5; j++) {
168
+ const ln = allLines[j];
169
+ if (ln.trim() === "") continue;
170
+ nonBlankSeen++;
171
+ if (ln.trim() === DECISION_HEADING) return true;
172
+ if (/^##\s/.test(ln)) return false;
173
+ }
174
+ return false;
175
+ }
176
+
177
+ function checkDecisionBlocks(f, text, findings, grace) {
178
+ const allLines = text.split("\n");
179
+ for (let i = 0; i < allLines.length; i++) {
180
+ if (!ENTRY_RE.test(allLines[i])) continue;
181
+ if (entryHasDecision(allLines, i)) continue;
182
+ pushFail(
183
+ findings,
184
+ grace ? "warn" : "fail",
185
+ `decision-block: ${f}:${i + 1} entry lacks leading '### Decision'`,
186
+ );
187
+ }
188
+ }
189
+
190
+ function checkWeeklyLogFile(f, base, findings, grace) {
191
+ const isMain = WEEKLY_LOG_NAME_RE.test(base);
192
+ const isPart = WEEKLY_LOG_PART_NAME_RE.test(base);
193
+ if (!isMain && !isPart) return;
194
+ const text = readFileSync(f, "utf-8");
195
+ const firstLine = text.split("\n").find((l) => l.trim() !== "");
196
+ if (!firstLine || !WEEKLY_LOG_H1_RE.test(firstLine)) {
197
+ pushFail(findings, "fail", `weekly-log: ${f} missing valid H1 heading`);
198
+ }
199
+ const week = isoWeekForFile(base);
200
+ const postCutover = week && compareIsoWeek(week, CUTOVER_ISO_WEEK) >= 0;
201
+ const lines = countLines(text);
202
+ if (postCutover && lines > WEEKLY_LOG_LINE_BUDGET) {
203
+ pushFail(
204
+ findings,
205
+ "fail",
206
+ `weekly-log: ${f} has ${lines} lines (limit ${WEEKLY_LOG_LINE_BUDGET}, post-cutover)`,
207
+ );
208
+ }
209
+ if (isMain && postCutover) {
210
+ checkDecisionBlocks(f, text, findings, grace);
211
+ }
212
+ }
213
+
214
+ function checkWeeklyLogs(_wikiRoot, files, findings, options) {
215
+ const grace = inGraceWindow(options.graceUntil, options.today);
216
+ for (const f of files) {
217
+ checkWeeklyLogFile(f, path.basename(f), findings, grace);
218
+ }
219
+ }
220
+
221
+ function checkPriorityIndex(wikiRoot, findings) {
222
+ const memPath = path.join(wikiRoot, "MEMORY.md");
223
+ if (!existsSync(memPath)) {
224
+ pushFail(findings, "fail", `memory: ${memPath} not found`);
225
+ return;
226
+ }
227
+ const text = readFileSync(memPath, "utf-8");
228
+ if (!new RegExp(`^${PRIORITY_INDEX_HEADING}$`, "m").test(text)) {
229
+ pushFail(
230
+ findings,
231
+ "fail",
232
+ "memory: missing '## Cross-Cutting Priorities' heading",
233
+ );
234
+ }
235
+ const headerRe =
236
+ /^\|\s*Item\s*\|\s*Agents\s*\|\s*Owner\s*\|\s*Status\s*\|\s*Added\s*\|/m;
237
+ if (!headerRe.test(text)) {
238
+ pushFail(findings, "fail", "memory: missing priority table header row");
239
+ }
240
+ }
241
+
242
+ function findActiveClaimsHeader(lines, headingIdx) {
243
+ for (let i = headingIdx + 1; i < lines.length; i++) {
244
+ if (/^## /.test(lines[i])) return -1;
245
+ if (lines[i].startsWith("|") && /agent/.test(lines[i])) return i;
246
+ }
247
+ return -1;
248
+ }
249
+
250
+ function checkActiveClaimsRows(claims, findings, today) {
251
+ for (const c of claims) {
252
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(c.expires_at)) {
253
+ pushFail(
254
+ findings,
255
+ "fail",
256
+ `active-claims: bad expires_at '${c.expires_at}' for ${c.agent}/${c.target}`,
257
+ );
258
+ }
259
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(c.claimed_at)) {
260
+ pushFail(
261
+ findings,
262
+ "fail",
263
+ `active-claims: bad claimed_at '${c.claimed_at}' for ${c.agent}/${c.target}`,
264
+ );
265
+ }
266
+ }
267
+ const { expired } = filterExpired(claims, today);
268
+ for (const c of expired) {
269
+ pushFail(
270
+ findings,
271
+ "warn",
272
+ `expired-claim: ${c.agent}/${c.target} expired ${c.expires_at}`,
273
+ );
274
+ }
275
+ }
276
+
277
+ function checkActiveClaims(wikiRoot, findings, options) {
278
+ const memPath = path.join(wikiRoot, "MEMORY.md");
279
+ if (!existsSync(memPath)) return;
280
+ const text = readFileSync(memPath, "utf-8");
281
+ if (!new RegExp(`^${ACTIVE_CLAIMS_HEADING}$`, "m").test(text)) return;
282
+ const lines = text.split("\n");
283
+ const headingIdx = lines.findIndex((l) => l.trim() === ACTIVE_CLAIMS_HEADING);
284
+ const headerIdx = findActiveClaimsHeader(lines, headingIdx);
285
+ if (
286
+ headerIdx === -1 ||
287
+ lines[headerIdx].trim() !== ACTIVE_CLAIMS_TABLE_HEADER
288
+ ) {
289
+ pushFail(
290
+ findings,
291
+ "fail",
292
+ `active-claims: header mismatch (expected ${ACTIVE_CLAIMS_TABLE_HEADER})`,
293
+ );
294
+ }
295
+ checkActiveClaimsRows(parseClaims(text), findings, options.today);
296
+ }
297
+
298
+ function emitText(failures, warnings) {
299
+ for (const w of warnings) process.stdout.write(`WARN ${w.message}\n`);
300
+ for (const f of failures) process.stdout.write(`FAIL ${f.message}\n`);
301
+ if (failures.length === 0) {
302
+ process.stdout.write("RESULT: pass\n");
303
+ } else {
304
+ process.stdout.write(`RESULT: fail (${failures.length} checks failed)\n`);
305
+ }
306
+ }
307
+
308
+ function emitJson(failures, warnings, graceUntil, today) {
309
+ process.stdout.write(
310
+ JSON.stringify(
311
+ {
312
+ result: failures.length === 0 ? "pass" : "fail",
313
+ failures,
314
+ warnings,
315
+ grace_active: inGraceWindow(graceUntil, today),
316
+ grace_until: graceUntil,
317
+ },
318
+ null,
319
+ 2,
320
+ ) + "\n",
321
+ );
322
+ }
323
+
324
+ /** Run the wiki audit and emit findings. JSON via --format json. */
325
+ export function runAuditCommand(values, _args, _cli) {
326
+ const logger = { debug() {} };
327
+ const finder = new Finder(fsAsync, logger, process);
328
+ const projectRoot = finder.findProjectRoot(process.cwd());
329
+ const wikiRoot = values["wiki-root"] || path.join(projectRoot, "wiki");
330
+ const today = values.today || new Date().toISOString().slice(0, 10);
331
+ const graceUntil = process.env.FIT_WIKI_AUDIT_GRACE_UNTIL || null;
332
+ const legacyOnly = !!values["legacy-only"];
333
+
334
+ const findings = [];
335
+ const files = listMdFiles(wikiRoot);
336
+
337
+ checkSummaries(wikiRoot, files, findings, { graceUntil, today });
338
+ checkWeeklyLogs(wikiRoot, files, findings, { graceUntil, today });
339
+ checkPriorityIndex(wikiRoot, findings);
340
+ if (!legacyOnly) {
341
+ checkActiveClaims(wikiRoot, findings, { today });
342
+ }
343
+
344
+ const failures = findings.filter((f) => f.level === "fail");
345
+ const warnings = findings.filter((f) => f.level === "warn");
346
+
347
+ if ((values.format || "text") === "json") {
348
+ emitJson(failures, warnings, graceUntil, today);
349
+ } else {
350
+ emitText(failures, warnings);
351
+ }
352
+
353
+ if (failures.length > 0) process.exit(1);
354
+ }
@@ -0,0 +1,66 @@
1
+ import fsAsync from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { Finder } from "@forwardimpact/libutil";
4
+ import { buildDigest } from "../boot.js";
5
+
6
+ function renderMarkdown(digest) {
7
+ const lines = [];
8
+ lines.push("# Boot Digest");
9
+ lines.push("");
10
+ lines.push(`**Summary:** ${digest.summary || "(none)"}`);
11
+ lines.push("");
12
+ lines.push("## Owned priorities");
13
+ if (digest.owned_priorities.length === 0) lines.push("- (none)");
14
+ for (const p of digest.owned_priorities) {
15
+ lines.push(`- ${p.item} — ${p.status} (added ${p.added})`);
16
+ }
17
+ lines.push("");
18
+ lines.push("## Cross-cutting priorities");
19
+ if (digest.cross_cutting.length === 0) lines.push("- (none)");
20
+ for (const p of digest.cross_cutting) {
21
+ lines.push(`- ${p.item} — ${p.status} (added ${p.added})`);
22
+ }
23
+ lines.push("");
24
+ lines.push("## Active claims");
25
+ if (digest.claims.length === 0) lines.push("- (none)");
26
+ for (const c of digest.claims) {
27
+ lines.push(
28
+ `- ${c.agent}: ${c.target} (branch ${c.branch}, expires ${c.expires_at})`,
29
+ );
30
+ }
31
+ lines.push("");
32
+ lines.push("## Storyboard items");
33
+ if (digest.storyboard_items.length === 0) lines.push("- (none)");
34
+ for (const s of digest.storyboard_items) {
35
+ lines.push(`- ${s.threshold}`);
36
+ }
37
+ lines.push("");
38
+ lines.push(`**Inbox count:** ${digest.inbox_count}`);
39
+ lines.push(`**Storyboard path:** ${digest.storyboard_path || "(none)"}`);
40
+ return lines.join("\n");
41
+ }
42
+
43
+ /** Print the on-boot digest for the calling agent. JSON by default; --format markdown renders prose. */
44
+ export function runBootCommand(values, _args, cli) {
45
+ const agent = values.agent || process.env.LIBEVAL_AGENT_PROFILE;
46
+ if (!agent) {
47
+ cli.usageError(
48
+ "boot requires --agent <name> or LIBEVAL_AGENT_PROFILE env var",
49
+ );
50
+ process.exit(2);
51
+ }
52
+
53
+ const logger = { debug() {} };
54
+ const finder = new Finder(fsAsync, logger, process);
55
+ const projectRoot = finder.findProjectRoot(process.cwd());
56
+ const wikiRoot = values["wiki-root"] || path.join(projectRoot, "wiki");
57
+ const today = values.today || new Date().toISOString().slice(0, 10);
58
+
59
+ const digest = buildDigest({ wikiRoot, agent, today });
60
+
61
+ if ((values.format || "json") === "markdown") {
62
+ process.stdout.write(renderMarkdown(digest) + "\n");
63
+ } else {
64
+ process.stdout.write(JSON.stringify(digest, null, 2) + "\n");
65
+ }
66
+ }
@@ -0,0 +1,107 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import fsAsync from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { Finder } from "@forwardimpact/libutil";
5
+ import {
6
+ appendClaim,
7
+ removeClaim,
8
+ parseClaims,
9
+ filterExpired,
10
+ } from "../active-claims.js";
11
+
12
+ function projectRoot() {
13
+ const logger = { debug() {} };
14
+ const finder = new Finder(fsAsync, logger, process);
15
+ return finder.findProjectRoot(process.cwd());
16
+ }
17
+
18
+ function memoryPath(values) {
19
+ const root = projectRoot();
20
+ const wikiRoot = values["wiki-root"] || path.join(root, "wiki");
21
+ return path.join(wikiRoot, "MEMORY.md");
22
+ }
23
+
24
+ function readMemory(memPath) {
25
+ if (!existsSync(memPath)) return "";
26
+ return readFileSync(memPath, "utf-8");
27
+ }
28
+
29
+ function addDays(today, n) {
30
+ const d = new Date(today);
31
+ d.setUTCDate(d.getUTCDate() + n);
32
+ return d.toISOString().slice(0, 10);
33
+ }
34
+
35
+ /** Insert a row into MEMORY.md `## Active Claims`. Refuses if (agent, target) already present. */
36
+ export function runClaimCommand(values, _args, cli) {
37
+ const agent = values.agent || process.env.LIBEVAL_AGENT_PROFILE;
38
+ if (!agent) {
39
+ cli.usageError("claim requires --agent or LIBEVAL_AGENT_PROFILE");
40
+ process.exit(2);
41
+ }
42
+ if (!values.target || !values.branch) {
43
+ cli.usageError("claim requires --target and --branch");
44
+ process.exit(2);
45
+ }
46
+ const today = values.today || new Date().toISOString().slice(0, 10);
47
+ const expires = values["expires-at"] || addDays(today, 7);
48
+ const memPath = memoryPath(values);
49
+ const text = readMemory(memPath);
50
+ const result = appendClaim(text, {
51
+ agent,
52
+ target: values.target,
53
+ branch: values.branch,
54
+ pr: values.pr || null,
55
+ claimed_at: today,
56
+ expires_at: expires,
57
+ });
58
+ if (!result.inserted) {
59
+ process.stderr.write(
60
+ `claim already exists for ${agent}/${values.target}\n`,
61
+ );
62
+ process.exit(2);
63
+ }
64
+ writeFileSync(memPath, result.text);
65
+ process.stdout.write(`claimed ${values.target} (expires ${expires})\n`);
66
+ }
67
+
68
+ /** Remove a claim row. `--expired` cleans every row past expires_at. */
69
+ export function runReleaseCommand(values, _args, cli) {
70
+ const memPath = memoryPath(values);
71
+ const text = readMemory(memPath);
72
+
73
+ if (values.expired) {
74
+ const today = values.today || new Date().toISOString().slice(0, 10);
75
+ const claims = parseClaims(text);
76
+ const { expired } = filterExpired(claims, today);
77
+ let current = text;
78
+ let count = 0;
79
+ for (const c of expired) {
80
+ const result = removeClaim(current, { agent: c.agent, target: c.target });
81
+ if (result.removed) {
82
+ current = result.text;
83
+ count++;
84
+ }
85
+ }
86
+ writeFileSync(memPath, current);
87
+ process.stdout.write(`released ${count} expired claim(s)\n`);
88
+ return;
89
+ }
90
+
91
+ const agent = values.agent || process.env.LIBEVAL_AGENT_PROFILE;
92
+ if (!agent) {
93
+ cli.usageError("release requires --agent or --expired");
94
+ process.exit(2);
95
+ }
96
+ if (!values.target) {
97
+ cli.usageError("release requires --target (or --expired)");
98
+ process.exit(2);
99
+ }
100
+ const result = removeClaim(text, { agent, target: values.target });
101
+ writeFileSync(memPath, result.text);
102
+ if (!result.removed) {
103
+ process.stdout.write(`no matching claim for ${agent}/${values.target}\n`);
104
+ } else {
105
+ process.stdout.write(`released ${values.target}\n`);
106
+ }
107
+ }
@@ -0,0 +1,180 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import fsAsync from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { Finder } from "@forwardimpact/libutil";
5
+ import {
6
+ MEMO_INBOX_MARKER,
7
+ PRIORITY_INDEX_HEADING,
8
+ PRIORITY_INDEX_TABLE_HEADER,
9
+ } from "../constants.js";
10
+
11
+ function projectRoot() {
12
+ const logger = { debug() {} };
13
+ const finder = new Finder(fsAsync, logger, process);
14
+ return finder.findProjectRoot(process.cwd());
15
+ }
16
+
17
+ function paths(values) {
18
+ const root = projectRoot();
19
+ const wikiRoot = values["wiki-root"] || path.join(root, "wiki");
20
+ const agent = values.agent || process.env.LIBEVAL_AGENT_PROFILE;
21
+ if (!agent) {
22
+ process.stderr.write("inbox requires --agent or LIBEVAL_AGENT_PROFILE\n");
23
+ process.exit(2);
24
+ }
25
+ return {
26
+ summaryPath: path.join(wikiRoot, `${agent}.md`),
27
+ memoryPath: path.join(wikiRoot, "MEMORY.md"),
28
+ agent,
29
+ };
30
+ }
31
+
32
+ function readInboxBullets(text) {
33
+ const lines = text.split("\n");
34
+ const markerIdx = lines.findIndex((l) => l.trim() === MEMO_INBOX_MARKER);
35
+ if (markerIdx === -1)
36
+ return { lines, markerIdx, bullets: [], bulletIdxs: [] };
37
+ const bullets = [];
38
+ const bulletIdxs = [];
39
+ for (let i = markerIdx + 1; i < lines.length; i++) {
40
+ const line = lines[i];
41
+ if (line.trim() === "") continue;
42
+ if (/^##\s/.test(line)) break;
43
+ if (!line.startsWith("-")) break;
44
+ if (/\*No new messages\.\*/.test(line)) continue;
45
+ bullets.push(line);
46
+ bulletIdxs.push(i);
47
+ }
48
+ return { lines, markerIdx, bullets, bulletIdxs };
49
+ }
50
+
51
+ function removeBulletAt(lines, idx) {
52
+ lines.splice(idx, 1);
53
+ return lines;
54
+ }
55
+
56
+ function listCmd(values) {
57
+ const { summaryPath } = paths(values);
58
+ if (!existsSync(summaryPath)) {
59
+ process.stdout.write(JSON.stringify({ bullets: [] }) + "\n");
60
+ return;
61
+ }
62
+ const text = readFileSync(summaryPath, "utf-8");
63
+ const { bullets } = readInboxBullets(text);
64
+ process.stdout.write(JSON.stringify({ bullets }, null, 2) + "\n");
65
+ }
66
+
67
+ function ackOrDropCmd(values, _kind) {
68
+ const { summaryPath } = paths(values);
69
+ const idx = Number.parseInt(values.index ?? "", 10);
70
+ if (!Number.isInteger(idx) || idx < 0) {
71
+ process.stderr.write("inbox requires --index <n>\n");
72
+ process.exit(2);
73
+ }
74
+ const text = readFileSync(summaryPath, "utf-8");
75
+ const { lines, bulletIdxs } = readInboxBullets(text);
76
+ if (idx >= bulletIdxs.length) {
77
+ process.stderr.write(`no bullet at index ${idx}\n`);
78
+ process.exit(2);
79
+ }
80
+ removeBulletAt(lines, bulletIdxs[idx]);
81
+ writeFileSync(summaryPath, lines.join("\n"));
82
+ process.stdout.write(`removed inbox bullet ${idx}\n`);
83
+ }
84
+
85
+ function appendPriorityRow(memoryText, { item, agents, owner, status, added }) {
86
+ const lines = memoryText.split("\n");
87
+ let headingIdx = -1;
88
+ for (let i = 0; i < lines.length; i++) {
89
+ if (lines[i].trim() === PRIORITY_INDEX_HEADING) {
90
+ headingIdx = i;
91
+ break;
92
+ }
93
+ }
94
+ if (headingIdx === -1) {
95
+ const block = [
96
+ "",
97
+ PRIORITY_INDEX_HEADING,
98
+ "",
99
+ PRIORITY_INDEX_TABLE_HEADER,
100
+ "| --- | --- | --- | --- | --- |",
101
+ `| ${item} | ${agents} | ${owner} | ${status} | ${added} |`,
102
+ "",
103
+ ];
104
+ return memoryText.replace(/\n*$/, "") + "\n" + block.join("\n");
105
+ }
106
+ // Find last data row.
107
+ let sepIdx = -1;
108
+ for (let i = headingIdx + 1; i < lines.length; i++) {
109
+ if (/^\|\s*---/.test(lines[i])) {
110
+ sepIdx = i;
111
+ break;
112
+ }
113
+ if (/^## /.test(lines[i])) break;
114
+ }
115
+ if (sepIdx === -1) {
116
+ return memoryText;
117
+ }
118
+ let lastRowIdx = sepIdx;
119
+ for (let i = sepIdx + 1; i < lines.length; i++) {
120
+ if (!lines[i].startsWith("|")) break;
121
+ lastRowIdx = i;
122
+ }
123
+ lines.splice(
124
+ lastRowIdx + 1,
125
+ 0,
126
+ `| ${item} | ${agents} | ${owner} | ${status} | ${added} |`,
127
+ );
128
+ return lines.join("\n");
129
+ }
130
+
131
+ function promoteCmd(values) {
132
+ const { summaryPath, memoryPath, agent } = paths(values);
133
+ const idx = Number.parseInt(values.index ?? "", 10);
134
+ if (!Number.isInteger(idx) || idx < 0) {
135
+ process.stderr.write("inbox promote requires --index <n>\n");
136
+ process.exit(2);
137
+ }
138
+ const text = readFileSync(summaryPath, "utf-8");
139
+ const { lines, bullets, bulletIdxs } = readInboxBullets(text);
140
+ if (idx >= bullets.length) {
141
+ process.stderr.write(`no bullet at index ${idx}\n`);
142
+ process.exit(2);
143
+ }
144
+ const bulletText = bullets[idx].replace(/^[-*]\s+/, "");
145
+ removeBulletAt(lines, bulletIdxs[idx]);
146
+ writeFileSync(summaryPath, lines.join("\n"));
147
+
148
+ const memText = existsSync(memoryPath)
149
+ ? readFileSync(memoryPath, "utf-8")
150
+ : "";
151
+ const today = values.today || new Date().toISOString().slice(0, 10);
152
+ const owner = values.owner || agent;
153
+ const promoted = appendPriorityRow(memText, {
154
+ item: bulletText,
155
+ agents: agent,
156
+ owner,
157
+ status: "active",
158
+ added: today,
159
+ });
160
+ writeFileSync(memoryPath, promoted);
161
+ process.stdout.write(`promoted inbox bullet ${idx} to priorities\n`);
162
+ }
163
+
164
+ const SUBS = {
165
+ list: listCmd,
166
+ ack: (v) => ackOrDropCmd(v, "ack"),
167
+ drop: (v) => ackOrDropCmd(v, "drop"),
168
+ promote: promoteCmd,
169
+ };
170
+
171
+ /** Dispatch `inbox {list|ack|promote|drop}` to the matching sub-handler. */
172
+ export function runInboxCommand(values, args, cli) {
173
+ const sub = args[0];
174
+ const handler = SUBS[sub];
175
+ if (!handler) {
176
+ cli.usageError("inbox requires subcommand: list | ack | promote | drop");
177
+ process.exit(2);
178
+ }
179
+ handler(values);
180
+ }