@forwardimpact/outpost 3.12.1 → 3.13.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.
Files changed (85) hide show
  1. package/package.json +3 -2
  2. package/src/kb-manager.js +58 -5
  3. package/src/kb-validator.js +762 -0
  4. package/src/outpost.js +73 -8
  5. package/templates/.claude/agents/chief-of-staff.md +14 -6
  6. package/templates/.claude/agents/concierge.md +9 -2
  7. package/templates/.claude/agents/head-hunter.md +9 -2
  8. package/templates/.claude/agents/librarian.md +11 -4
  9. package/templates/.claude/agents/postman.md +9 -2
  10. package/templates/.claude/agents/recruiter.md +9 -2
  11. package/templates/.claude/skills/anarlog-follow/SKILL.md +53 -29
  12. package/templates/.claude/skills/anarlog-follow/references/sessions.md +52 -0
  13. package/templates/.claude/skills/anarlog-process/SKILL.md +88 -45
  14. package/templates/.claude/skills/anarlog-process/references/extraction.md +19 -11
  15. package/templates/.claude/skills/anarlog-process/references/sessions.md +92 -58
  16. package/templates/.claude/skills/anarlog-process/scripts/scan.mjs +404 -163
  17. package/templates/.claude/skills/candidate-report/SKILL.md +12 -9
  18. package/templates/.claude/skills/changelog/SKILL.md +106 -56
  19. package/templates/.claude/skills/deck-create/SKILL.md +60 -2
  20. package/templates/.claude/skills/deck-review/SKILL.md +4 -1
  21. package/templates/.claude/skills/deck-summarize/SKILL.md +6 -2
  22. package/templates/.claude/skills/deck-summarize/references/brief-template.md +1 -1
  23. package/templates/.claude/skills/doc-collab/SKILL.md +16 -11
  24. package/templates/.claude/skills/doc-create/SKILL.md +6 -3
  25. package/templates/.claude/skills/draft-emails/SKILL.md +31 -25
  26. package/templates/.claude/skills/draft-emails/references/template.md +1 -1
  27. package/templates/.claude/skills/draft-emails/scripts/scan-emails.mjs +14 -8
  28. package/templates/.claude/skills/draft-emails/scripts/send-email.mjs +9 -3
  29. package/templates/.claude/skills/extract-entities/SKILL.md +17 -16
  30. package/templates/.claude/skills/extract-entities/references/TEMPLATES.md +2 -1
  31. package/templates/.claude/skills/extract-entities/references/conditions.md +6 -5
  32. package/templates/.claude/skills/extract-entities/references/links.md +34 -10
  33. package/templates/.claude/skills/extract-entities/references/recruitment.md +15 -10
  34. package/templates/.claude/skills/extract-entities/references/resolution.md +1 -1
  35. package/templates/.claude/skills/extract-entities/references/sources.md +1 -1
  36. package/templates/.claude/skills/extract-entities/references/templates-conditions.md +10 -4
  37. package/templates/.claude/skills/extract-entities/references/templates-people-orgs.md +29 -10
  38. package/templates/.claude/skills/extract-entities/references/templates-priorities.md +8 -2
  39. package/templates/.claude/skills/extract-entities/references/templates-projects-topics.md +19 -7
  40. package/templates/.claude/skills/meeting-prep/SKILL.md +23 -18
  41. package/templates/.claude/skills/organize-files/SKILL.md +3 -0
  42. package/templates/.claude/skills/person-identify/SKILL.md +58 -10
  43. package/templates/.claude/skills/person-identify/scripts/identify.sh +110 -11
  44. package/templates/.claude/skills/person-lookup/SKILL.md +8 -3
  45. package/templates/.claude/skills/person-lookup/scripts/lookup.sh +27 -1
  46. package/templates/.claude/skills/req-assess/SKILL.md +19 -11
  47. package/templates/.claude/skills/req-assess/references/interview-template.md +6 -1
  48. package/templates/.claude/skills/req-assess/references/panel-template.md +5 -1
  49. package/templates/.claude/skills/req-bundle/SKILL.md +164 -0
  50. package/templates/.claude/skills/req-bundle/references/matching.md +70 -0
  51. package/templates/.claude/skills/req-bundle/references/pdf-structure.md +74 -0
  52. package/templates/.claude/skills/req-bundle/scripts/split-bundle.mjs +377 -0
  53. package/templates/.claude/skills/req-decide/SKILL.md +26 -18
  54. package/templates/.claude/skills/req-decide/references/template.md +2 -2
  55. package/templates/.claude/skills/req-forget/SKILL.md +39 -22
  56. package/templates/.claude/skills/req-forget/references/classify.md +12 -10
  57. package/templates/.claude/skills/req-forget/references/locations.md +27 -22
  58. package/templates/.claude/skills/req-forget/references/report-template.md +23 -15
  59. package/templates/.claude/skills/req-scan/SKILL.md +6 -3
  60. package/templates/.claude/skills/req-scan/references/template.md +11 -1
  61. package/templates/.claude/skills/req-screen/SKILL.md +78 -34
  62. package/templates/.claude/skills/req-screen/references/rubric.md +38 -42
  63. package/templates/.claude/skills/req-screen/references/scoring.md +79 -0
  64. package/templates/.claude/skills/req-screen/references/template.md +26 -14
  65. package/templates/.claude/skills/req-track/SKILL.md +44 -44
  66. package/templates/.claude/skills/req-track/references/fields.md +18 -15
  67. package/templates/.claude/skills/req-track/references/overlays.md +42 -0
  68. package/templates/.claude/skills/req-track/references/signals.md +2 -2
  69. package/templates/.claude/skills/req-track/references/templates.md +28 -27
  70. package/templates/.claude/skills/req-workday/SKILL.md +49 -20
  71. package/templates/.claude/skills/req-workday/references/brief.md +81 -0
  72. package/templates/.claude/skills/req-workday/references/templates.md +6 -73
  73. package/templates/.claude/skills/req-workday/references/xlsx-format.md +19 -0
  74. package/templates/.claude/skills/req-workday/scripts/parse-workday.mjs +96 -20
  75. package/templates/.claude/skills/send-chat/SKILL.md +11 -11
  76. package/templates/.claude/skills/sync-apple-calendar/SKILL.md +3 -0
  77. package/templates/.claude/skills/sync-apple-mail/SKILL.md +3 -0
  78. package/templates/.claude/skills/sync-teams/SKILL.md +3 -0
  79. package/templates/.claude/skills/upstream-instructions/SKILL.md +8 -5
  80. package/templates/.claude/skills/upstream-instructions/references/examples.md +3 -3
  81. package/templates/CLAUDE.md +96 -87
  82. package/templates/MIGRATION.md +359 -0
  83. package/templates/registry.yaml +25 -0
  84. package/templates/.claude/skills/anarlog-follow/scripts/follow.mjs +0 -243
  85. package/templates/.claude/skills/anarlog-trim/SKILL.md +0 -186
@@ -1,57 +1,160 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Scan for unprocessed Anarlog sessions.
3
+ * Scan for unprocessed Anarlog meetings.
4
4
  *
5
- * This script compares session _memo.md and _summary.md files against the
6
- * graph_processed state file. It finds the sessions you must still process. It
7
- * reports each unprocessed session with a title, a date, and a content
8
- * preview.
5
+ * Anarlog stores meetings in a local SQLite database (app.db). The supported,
6
+ * format-agnostic way to read them is the bundled `anarlog-cli` the app's
7
+ * own AGENTS.md is explicit: use the CLI, do NOT crawl the filesystem or query
8
+ * SQLite directly. This script enumerates meetings via that CLI, compares them
9
+ * against the shared graph_processed state, and reports meetings that still
10
+ * need processing. Because it reads through the CLI it finds every meeting,
11
+ * including recent ones Anarlog has not exported to flat files yet.
12
+ *
13
+ * Older Anarlog versions exported each session to flat files
14
+ * (_memo.md / _summary.md) under sessions/{uuid}/. Those already-processed
15
+ * sessions are recorded in graph_processed under their file paths; this script
16
+ * treats them as frozen so they are not reprocessed. If the CLI is unavailable
17
+ * it falls back to scanning those flat files directly, so the skill keeps
18
+ * working however the data happens to be available.
9
19
  *
10
20
  * Usage:
11
- * node scripts/scan.mjs List unprocessed sessions
12
- * node scripts/scan.mjs --changed Also detect changed (re-edited) sessions
13
- * node scripts/scan.mjs --json Output as JSON
14
- * node scripts/scan.mjs --count Just print the count
21
+ * node scripts/scan.mjs List unprocessed meetings
22
+ * node scripts/scan.mjs --changed Also detect changed (re-edited) meetings
23
+ * node scripts/scan.mjs --json Output as JSON
24
+ * node scripts/scan.mjs --count Just print the count
25
+ * node scripts/scan.mjs --limit N Max meetings to display (default: 20)
26
+ * node scripts/scan.mjs --legacy Force the flat-file scan (fallback)
27
+ * node scripts/scan.mjs mark <id>... Mark meetings processed (after writing notes)
28
+ * node scripts/scan.mjs cli-path Print the resolved anarlog-cli path
29
+ *
30
+ * Env overrides:
31
+ * ANARLOG_CLI Path to the anarlog-cli binary.
32
+ * OUTPOST_STATE_FILE Path to the graph_processed TSV (for testing).
15
33
  */
16
34
 
35
+ import { execFileSync } from "node:child_process";
17
36
  import { createHash } from "node:crypto";
18
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
19
- import { join } from "node:path";
37
+ import {
38
+ existsSync,
39
+ mkdirSync,
40
+ readFileSync,
41
+ readdirSync,
42
+ statSync,
43
+ writeFileSync,
44
+ } from "node:fs";
20
45
  import { homedir } from "node:os";
46
+ import { dirname, join } from "node:path";
21
47
 
22
48
  const HOME = homedir();
23
49
  const SESSIONS_DIR = join(HOME, "Library/Application Support/anarlog/sessions");
24
- const STATE_FILE = join(HOME, ".cache/fit/outpost/state/graph_processed");
50
+ const STATE_FILE =
51
+ process.env.OUTPOST_STATE_FILE ||
52
+ join(HOME, ".cache/fit/outpost/state/graph_processed");
53
+
54
+ // --- Args ---
25
55
 
26
56
  if (process.argv.includes("-h") || process.argv.includes("--help")) {
27
- console.log(`scan — find unprocessed Anarlog sessions
57
+ console.log(`scan — find unprocessed Anarlog meetings
28
58
 
29
59
  Usage:
30
60
  node scripts/scan.mjs [options]
61
+ node scripts/scan.mjs mark <id> [<id>…] Mark meetings processed
62
+ node scripts/scan.mjs cli-path Print the resolved anarlog-cli path
31
63
 
32
64
  Options:
33
- --changed Also detect sessions whose memo/summary hash changed
34
- --json Output as a JSON array
35
- --count Just print the unprocessed count (for scripts)
36
- --limit N Max sessions to display (default: 20)
65
+ --changed Also detect meetings whose note/summary content has changed
66
+ --json Output as JSON array
67
+ --count Just print the unprocessed count (for scripting)
68
+ --limit N Max meetings to display (default: 20)
69
+ --legacy Force the flat-file scan even if the CLI is available
37
70
  -h, --help Show this help message
38
71
 
39
- Sessions dir: ~/Library/Application Support/anarlog/sessions/
40
- State file: ~/.cache/fit/outpost/state/graph_processed`);
72
+ Reads meetings through anarlog-cli (falls back to flat files under
73
+ ~/Library/Application Support/anarlog/sessions/ if the CLI is unavailable).
74
+ State file: ${STATE_FILE}`);
41
75
  process.exit(0);
42
76
  }
43
77
 
44
78
  const args = process.argv.slice(2);
79
+ const subcommand = args[0] && !args[0].startsWith("-") ? args[0] : null;
45
80
  const detectChanged = args.includes("--changed");
46
81
  const jsonOutput = args.includes("--json");
47
82
  const countOnly = args.includes("--count");
83
+ const forceLegacy = args.includes("--legacy");
48
84
  const limitIdx = args.indexOf("--limit");
49
85
  const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) || 20 : 20;
50
86
 
51
- // --- Load state ---
87
+ // --- CLI discovery ---
88
+
89
+ /** Resolve the anarlog-cli binary: env override, app bundle, then PATH. */
90
+ function resolveCli() {
91
+ const candidates = [];
92
+ if (process.env.ANARLOG_CLI) candidates.push(process.env.ANARLOG_CLI);
93
+ candidates.push("/Applications/Anarlog.app/Contents/MacOS/anarlog-cli");
94
+ for (const name of ["anarlog-cli", "anarlog"]) {
95
+ try {
96
+ const p = execFileSync("/usr/bin/which", [name], {
97
+ encoding: "utf8",
98
+ }).trim();
99
+ if (p) candidates.push(p);
100
+ } catch {
101
+ /* not on PATH */
102
+ }
103
+ }
104
+ for (const c of candidates) {
105
+ if (c && existsSync(c)) return c;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /** Run an anarlog-cli command and return its parsed `data` payload. */
111
+ function runCli(cli, cliArgs) {
112
+ const out = execFileSync(cli, cliArgs, {
113
+ encoding: "utf8",
114
+ maxBuffer: 128 * 1024 * 1024,
115
+ });
116
+ return JSON.parse(out).data;
117
+ }
118
+
119
+ /** True if the CLI can open the database read-only. */
120
+ function cliReady(cli) {
121
+ try {
122
+ const d = runCli(cli, ["--json", "doctor"]);
123
+ return !!(d && d.ready && d.database && d.database.exists);
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ /** Enumerate every meeting via the CLI, paging through the full list. */
130
+ function listAllMeetings(cli) {
131
+ const all = [];
132
+ const pageSize = 200;
133
+ let offset = 0;
134
+ for (;;) {
135
+ const page = runCli(cli, [
136
+ "--json",
137
+ "meetings",
138
+ "list",
139
+ "--limit",
140
+ String(pageSize),
141
+ "--offset",
142
+ String(offset),
143
+ ]);
144
+ if (!Array.isArray(page) || page.length === 0) break;
145
+ all.push(...page);
146
+ if (page.length < pageSize) break;
147
+ offset += pageSize;
148
+ }
149
+ return all;
150
+ }
52
151
 
53
- const state = new Map();
54
- if (existsSync(STATE_FILE)) {
152
+ // --- State ---
153
+
154
+ /** Load the graph_processed TSV into a Map of {key → hash}. */
155
+ function loadState() {
156
+ const state = new Map();
157
+ if (!existsSync(STATE_FILE)) return state;
55
158
  const text = readFileSync(STATE_FILE, "utf8");
56
159
  for (const line of text.split("\n")) {
57
160
  if (!line) continue;
@@ -59,186 +162,324 @@ if (existsSync(STATE_FILE)) {
59
162
  if (idx === -1) continue;
60
163
  state.set(line.slice(0, idx), line.slice(idx + 1));
61
164
  }
165
+ return state;
62
166
  }
63
167
 
64
- /**
65
- * Compute the SHA-256 hash of the file contents.
66
- */
67
- function fileHash(filePath) {
68
- return createHash("sha256").update(readFileSync(filePath)).digest("hex");
168
+ /** Write the state Map back to graph_processed (sorted, TSV). */
169
+ function saveState(state) {
170
+ mkdirSync(dirname(STATE_FILE), { recursive: true });
171
+ const entries = [...state.entries()].sort((a, b) => a[0].localeCompare(b[0]));
172
+ const text = entries.length
173
+ ? entries.map(([k, h]) => `${k}\t${h}`).join("\n") + "\n"
174
+ : "";
175
+ writeFileSync(STATE_FILE, text);
69
176
  }
70
177
 
71
- /**
72
- * Check whether the script must process a file (new or changed).
73
- */
74
- function needsProcessing(filePath) {
75
- const storedHash = state.get(filePath);
76
- if (!storedHash) return { needed: true, reason: "new" };
77
- if (detectChanged) {
78
- const currentHash = fileHash(filePath);
79
- if (currentHash !== storedHash) return { needed: true, reason: "changed" };
80
- }
81
- return { needed: false, reason: null };
178
+ const sha256 = (s) => createHash("sha256").update(s).digest("hex");
179
+
180
+ // State keys. New meetings use one synthetic key per session; legacy sessions
181
+ // are keyed by the flat-file paths the old pipeline recorded.
182
+ const synthKey = (id) => `anarlog://${id}`;
183
+ const legacyMemoKey = (id) => join(SESSIONS_DIR, id, "_memo.md");
184
+ const legacySummaryKey = (id) => join(SESSIONS_DIR, id, "_summary.md");
185
+ const hasLegacyKey = (state, id) =>
186
+ state.has(legacyMemoKey(id)) || state.has(legacySummaryKey(id));
187
+
188
+ // --- Content helpers ---
189
+
190
+ /** A note body is empty if it is blank, a spacer, or only its title heading. */
191
+ function noteBody(markdown) {
192
+ const body = (markdown || "")
193
+ .replace(/^#\s+.*$/m, "") // drop the leading title heading
194
+ .replace(/&nbsp;/g, "")
195
+ .trim();
196
+ return body;
197
+ }
198
+
199
+ /** Collapse markdown to a one-line preview. */
200
+ function preview(markdown) {
201
+ return (markdown || "")
202
+ .replace(/^#+\s*/gm, "")
203
+ .replace(/[*_>`]/g, "")
204
+ .replace(/\s+/g, " ")
205
+ .trim()
206
+ .slice(0, 150);
82
207
  }
83
208
 
84
209
  /**
85
- * Extract the title and the date from a memo file.
210
+ * Read a meeting's processable content via the CLI.
211
+ * Returns { note, summary, combined, hasContent, participants, actionItems }.
86
212
  */
87
- function parseMemo(memoPath) {
88
- try {
89
- const content = readFileSync(memoPath, "utf8");
213
+ function meetingContent(cli, id) {
214
+ const d = runCli(cli, ["--json", "meetings", "get", id]) || {};
215
+ const rawNote = (d.note && d.note.markdown) || "";
216
+ const note = noteBody(rawNote) ? rawNote.trim() : "";
217
+ const summary = (d.summaries || [])
218
+ .map((s) => (s.markdown || "").trim())
219
+ .filter(Boolean)
220
+ .join("\n\n");
221
+ return {
222
+ title: d.title || "",
223
+ createdAt: d.created_at || "",
224
+ note,
225
+ summary,
226
+ combined: `${note}\n---\n${summary}`,
227
+ hasContent: Boolean(note || summary),
228
+ participants: (d.participants || []).length,
229
+ actionItems: (d.action_items || []).length,
230
+ };
231
+ }
90
232
 
91
- // Skip empty/whitespace-only memos
92
- const body = content.replace(/---[\s\S]*?---/, "").trim();
93
- if (!body || body === "&nbsp;") return null;
233
+ // --- CLI scan ---
94
234
 
95
- // Extract title from first H1
96
- const titleMatch = content.match(/^#\s+(.+)/m);
97
- const title = titleMatch ? titleMatch[1].trim() : null;
235
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: per-meeting skip/change/legacy triage in one pass
236
+ function scanCli(cli) {
237
+ const state = loadState();
238
+ const meetings = listAllMeetings(cli);
239
+ const unprocessed = [];
98
240
 
99
- // Extract date from content or fall back to file mtime
100
- const dateMatch = content.match(/\d{4}-\d{2}-\d{2}/);
101
- let date = dateMatch ? dateMatch[0] : null;
241
+ for (const m of meetings) {
242
+ const id = m.id;
102
243
 
103
- if (!date) {
104
- const stat = statSync(memoPath);
105
- date = stat.mtime.toISOString().slice(0, 10);
106
- }
244
+ // Legacy sessions were handled under the old flat-file regime — frozen.
245
+ if (hasLegacyKey(state, id)) continue;
107
246
 
108
- return { title, date, preview: body.slice(0, 150).replace(/\n/g, " ") };
109
- } catch {
110
- return null;
111
- }
112
- }
247
+ const stored = state.get(synthKey(id));
248
+ // Already marked and we aren't looking for edits → skip without a fetch.
249
+ if (stored && !detectChanged) continue;
113
250
 
114
- /**
115
- * Read _meta.json for session metadata.
116
- */
117
- function readMeta(sessionDir) {
118
- const metaPath = join(sessionDir, "_meta.json");
119
- if (!existsSync(metaPath)) return null;
120
- try {
121
- return JSON.parse(readFileSync(metaPath, "utf8"));
122
- } catch {
123
- return null;
251
+ let c;
252
+ try {
253
+ c = meetingContent(cli, id);
254
+ } catch {
255
+ continue; // unreadable meeting — skip rather than crash the scan
256
+ }
257
+ if (!c.hasContent) continue; // empty/onboarding/test session → skip
258
+
259
+ const hash = sha256(c.combined);
260
+ let reason;
261
+ if (!stored) reason = "new";
262
+ else if (stored !== hash) reason = "changed";
263
+ else continue; // marked and unchanged
264
+
265
+ const sources = [];
266
+ if (c.note) sources.push("note");
267
+ if (c.summary) sources.push("summary");
268
+
269
+ unprocessed.push({
270
+ id,
271
+ title: c.title || m.title || id.slice(0, 8),
272
+ date: (m.created_at || c.createdAt || "").slice(0, 10),
273
+ sources,
274
+ reason,
275
+ participants: c.participants,
276
+ actionItems: c.actionItems,
277
+ preview: preview(c.summary || c.note),
278
+ });
124
279
  }
125
- }
126
280
 
127
- // --- Scan sessions ---
128
-
129
- if (!existsSync(SESSIONS_DIR)) {
130
- console.error(`Anarlog sessions directory not found: ${SESSIONS_DIR}`);
131
- process.exit(1);
281
+ unprocessed.sort((a, b) => (b.date || "").localeCompare(a.date || ""));
282
+ return { unprocessed, total: meetings.length, mode: "cli" };
132
283
  }
133
284
 
134
- const sessionIds = readdirSync(SESSIONS_DIR);
135
- const unprocessed = [];
136
- let totalWithMemos = 0;
137
- let processedCount = 0;
285
+ // --- Legacy flat-file scan (fallback when the CLI is unavailable) ---
138
286
 
139
- for (const uuid of sessionIds) {
140
- const sessionPath = join(SESSIONS_DIR, uuid);
141
- const stat = statSync(sessionPath, { throwIfNoEntry: false });
142
- if (!stat || !stat.isDirectory()) continue;
287
+ function needsFile(state, filePath) {
288
+ const stored = state.get(filePath);
289
+ if (!stored) return { needed: true, reason: "new" };
290
+ if (detectChanged) {
291
+ const current = sha256(readFileSync(filePath, "utf8"));
292
+ if (current !== stored) return { needed: true, reason: "changed" };
293
+ }
294
+ return { needed: false, reason: null };
295
+ }
143
296
 
144
- const memoPath = join(sessionPath, "_memo.md");
145
- const summaryPath = join(sessionPath, "_summary.md");
146
- const hasMemo = existsSync(memoPath);
147
- const hasSummary = existsSync(summaryPath);
297
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: verbatim legacy flat-file scan kept as the fallback path
298
+ function scanLegacy() {
299
+ const state = loadState();
300
+ if (!existsSync(SESSIONS_DIR)) {
301
+ console.error(`Anarlog sessions directory not found: ${SESSIONS_DIR}`);
302
+ console.error("The anarlog-cli was also not found — nothing to scan.");
303
+ process.exit(1);
304
+ }
148
305
 
149
- if (!hasMemo && !hasSummary) continue;
306
+ const unprocessed = [];
307
+ let total = 0;
308
+
309
+ for (const uuid of readdirSync(SESSIONS_DIR)) {
310
+ const dir = join(SESSIONS_DIR, uuid);
311
+ const st = statSync(dir, { throwIfNoEntry: false });
312
+ if (!st || !st.isDirectory()) continue;
313
+
314
+ const memoPath = join(dir, "_memo.md");
315
+ const summaryPath = join(dir, "_summary.md");
316
+ const hasMemo = existsSync(memoPath);
317
+ const hasSummary = existsSync(summaryPath);
318
+ if (!hasMemo && !hasSummary) continue;
319
+ total++;
320
+
321
+ const memoCheck = hasMemo
322
+ ? needsFile(state, memoPath)
323
+ : { needed: false, reason: null };
324
+ const summaryCheck = hasSummary
325
+ ? needsFile(state, summaryPath)
326
+ : { needed: false, reason: null };
327
+ if (!memoCheck.needed && !summaryCheck.needed) continue;
328
+
329
+ let memoText = "";
330
+ if (hasMemo) {
331
+ const content = readFileSync(memoPath, "utf8");
332
+ const body = content.replace(/---[\s\S]*?---/, "").trim();
333
+ if (!body || body === "&nbsp;") {
334
+ if (!hasSummary) continue; // empty memo, no summary → skip
335
+ } else {
336
+ memoText = content;
337
+ }
338
+ }
150
339
 
151
- totalWithMemos++;
340
+ let title = null;
341
+ let date = null;
342
+ const titleMatch = memoText.match(/^#\s+(.+)/m);
343
+ if (titleMatch) title = titleMatch[1].trim();
344
+ const dateMatch = memoText.match(/\d{4}-\d{2}-\d{2}/);
345
+ if (dateMatch) date = dateMatch[0];
346
+ if ((!title || !date) && existsSync(join(dir, "_meta.json"))) {
347
+ try {
348
+ const meta = JSON.parse(readFileSync(join(dir, "_meta.json"), "utf8"));
349
+ title = title || meta.title;
350
+ date = date || (meta.created_at ? meta.created_at.slice(0, 10) : null);
351
+ } catch {
352
+ /* ignore */
353
+ }
354
+ }
355
+ date = date || statSync(dir).mtime.toISOString().slice(0, 10);
356
+
357
+ const sources = [];
358
+ if (hasMemo && memoText) sources.push("note");
359
+ if (hasSummary) sources.push("summary");
360
+
361
+ unprocessed.push({
362
+ id: uuid,
363
+ title: title || uuid.slice(0, 8),
364
+ date,
365
+ sources,
366
+ reason: memoCheck.reason || summaryCheck.reason,
367
+ preview: preview(memoText.replace(/---[\s\S]*?---/, "")),
368
+ memoPath: hasMemo ? memoPath : null,
369
+ summaryPath: hasSummary ? summaryPath : null,
370
+ });
371
+ }
152
372
 
153
- // Check memo
154
- const memoCheck = hasMemo
155
- ? needsProcessing(memoPath)
156
- : { needed: false, reason: null };
373
+ unprocessed.sort((a, b) => (b.date || "").localeCompare(a.date || ""));
374
+ return { unprocessed, total, mode: "legacy" };
375
+ }
157
376
 
158
- // Check summary
159
- const summaryCheck = hasSummary
160
- ? needsProcessing(summaryPath)
161
- : { needed: false, reason: null };
377
+ // --- mark ---
162
378
 
163
- if (!memoCheck.needed && !summaryCheck.needed) {
164
- processedCount++;
165
- continue;
379
+ function doMark(cli, ids) {
380
+ if (!cli) {
381
+ console.error("mark requires anarlog-cli, which was not found.");
382
+ console.error(
383
+ "For legacy flat-file sessions, use extract-entities/scripts/state.mjs update <path> instead.",
384
+ );
385
+ process.exit(1);
166
386
  }
167
-
168
- // Parse memo for display info
169
- const memo = hasMemo ? parseMemo(memoPath) : null;
170
- if (hasMemo && !memo) {
171
- // Empty memo, no summary → skip
172
- if (!hasSummary) continue;
387
+ if (ids.length === 0) {
388
+ console.error("Usage: scan.mjs mark <session-id> [<session-id>…]");
389
+ process.exit(1);
173
390
  }
391
+ const state = loadState();
392
+ let marked = 0;
393
+ for (const id of ids) {
394
+ let c;
395
+ try {
396
+ c = meetingContent(cli, id);
397
+ } catch (e) {
398
+ console.error(`skip ${id}: ${e.message}`);
399
+ continue;
400
+ }
401
+ if (!c.hasContent) {
402
+ console.error(`skip ${id}: no note or summary content`);
403
+ continue;
404
+ }
405
+ state.set(synthKey(id), sha256(c.combined));
406
+ marked++;
407
+ console.error(`marked ${id} — ${c.title}`);
408
+ }
409
+ saveState(state);
410
+ console.log(`Marked ${marked} meeting(s) processed in ${STATE_FILE}`);
411
+ }
174
412
 
175
- // Read meta for title fallback
176
- const meta = readMeta(sessionPath);
177
-
178
- const title = memo?.title || meta?.title || uuid.slice(0, 8);
179
- const date =
180
- memo?.date ||
181
- (meta?.created_at ? meta.created_at.slice(0, 10) : null) ||
182
- statSync(sessionPath).mtime.toISOString().slice(0, 10);
413
+ // --- Output ---
183
414
 
184
- unprocessed.push({
185
- uuid,
186
- title,
187
- date,
188
- hasMemo,
189
- hasSummary,
190
- memoReason: memoCheck.reason,
191
- summaryReason: summaryCheck.reason,
192
- preview: memo?.preview || "(summary only)",
193
- memoPath: hasMemo ? memoPath : null,
194
- summaryPath: hasSummary ? summaryPath : null,
195
- });
196
- }
415
+ function output(result) {
416
+ const { unprocessed, total, mode } = result;
197
417
 
198
- // Sort by date descending (newest first)
199
- unprocessed.sort((a, b) => b.date.localeCompare(a.date));
418
+ if (countOnly) {
419
+ console.log(unprocessed.length);
420
+ return;
421
+ }
422
+ if (jsonOutput) {
423
+ console.log(JSON.stringify(unprocessed.slice(0, limit), null, 2));
424
+ return;
425
+ }
200
426
 
201
- // --- Output ---
427
+ console.log(
428
+ `Meetings: ${total} total, ${unprocessed.length} unprocessed (${mode} scan)`,
429
+ );
430
+ if (unprocessed.length === 0) {
431
+ console.log("\nAll meetings are up to date.");
432
+ return;
433
+ }
202
434
 
203
- if (countOnly) {
204
- console.log(unprocessed.length);
205
- process.exit(0);
435
+ console.log("");
436
+ for (const s of unprocessed.slice(0, limit)) {
437
+ const extra = [];
438
+ if (s.participants) extra.push(`${s.participants}p`);
439
+ if (s.actionItems) extra.push(`${s.actionItems} action items`);
440
+ console.log(
441
+ `${s.date} | ${s.title} | ${s.sources.join("+")} | ${s.reason}${
442
+ extra.length ? ` | ${extra.join(", ")}` : ""
443
+ }`,
444
+ );
445
+ console.log(` ${s.id}`);
446
+ if (s.preview) console.log(` ${s.preview.slice(0, 100)}…`);
447
+ }
448
+ if (unprocessed.length > limit) {
449
+ console.log(`\n... and ${unprocessed.length - limit} more`);
450
+ }
206
451
  }
207
452
 
208
- if (jsonOutput) {
209
- console.log(JSON.stringify(unprocessed.slice(0, limit), null, 2));
210
- process.exit(0);
211
- }
453
+ // --- Main ---
212
454
 
213
- // Formatted output
214
- console.log(
215
- `Sessions: ${totalWithMemos} total, ${processedCount} processed, ${unprocessed.length} unprocessed`,
216
- );
455
+ const cli = resolveCli();
217
456
 
218
- if (unprocessed.length === 0) {
219
- console.log("\nAll sessions are up to date.");
220
- process.exit(0);
457
+ if (subcommand === "cli-path") {
458
+ if (cli) {
459
+ console.log(cli);
460
+ process.exit(0);
461
+ }
462
+ console.error("anarlog-cli not found");
463
+ process.exit(1);
221
464
  }
222
465
 
223
- console.log("");
224
- const display = unprocessed.slice(0, limit);
225
- for (const s of display) {
226
- const flags = [];
227
- if (s.memoReason) flags.push(`memo:${s.memoReason}`);
228
- if (s.summaryReason) flags.push(`summary:${s.summaryReason}`);
229
- const sources = [];
230
- if (s.hasMemo) sources.push("memo");
231
- if (s.hasSummary) sources.push("summary");
232
-
233
- console.log(
234
- `${s.date} | ${s.title} | ${sources.join("+")} | ${flags.join(", ")}`,
466
+ if (subcommand === "mark") {
467
+ doMark(
468
+ cli,
469
+ args.slice(1).filter((a) => !a.startsWith("-")),
235
470
  );
236
- console.log(` ${s.uuid}`);
237
- if (s.preview && s.preview !== "(summary only)") {
238
- console.log(` ${s.preview.slice(0, 100)}…`);
239
- }
471
+ process.exit(0);
240
472
  }
241
473
 
242
- if (unprocessed.length > limit) {
243
- console.log(`\n... and ${unprocessed.length - limit} more`);
474
+ let result;
475
+ if (cli && !forceLegacy && cliReady(cli)) {
476
+ result = scanCli(cli);
477
+ } else {
478
+ if (!forceLegacy && !cli) {
479
+ console.error(
480
+ "anarlog-cli not found — falling back to legacy flat-file scan.\n",
481
+ );
482
+ }
483
+ result = scanLegacy();
244
484
  }
485
+ output(result);