@codyswann/lisa 2.102.0 → 2.103.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.
package/package.json CHANGED
@@ -82,7 +82,7 @@
82
82
  "lodash": ">=4.18.1"
83
83
  },
84
84
  "name": "@codyswann/lisa",
85
- "version": "2.102.0",
85
+ "version": "2.103.0",
86
86
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
87
87
  "main": "dist/index.js",
88
88
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.102.0",
3
+ "version": "2.103.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.102.0",
3
+ "version": "2.103.0",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.102.0",
3
+ "version": "2.103.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.102.0",
3
+ "version": "2.103.0",
4
4
  "description": "AWS CDK-specific Lisa plugin.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.102.0",
3
+ "version": "2.103.0",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.102.0",
3
+ "version": "2.103.0",
4
4
  "description": "Expo and React Native-specific skills, agents, rules, and MCP servers.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.102.0",
3
+ "version": "2.103.0",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.102.0",
3
+ "version": "2.103.0",
4
4
  "description": "Harper/Fabric-specific Lisa rules for TypeScript component apps.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.102.0",
3
+ "version": "2.103.0",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.102.0",
3
+ "version": "2.103.0",
4
4
  "description": "NestJS-specific skills and migration write-protection hooks.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.102.0",
3
+ "version": "2.103.0",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.102.0",
3
+ "version": "2.103.0",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.102.0",
3
+ "version": "2.103.0",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.102.0",
3
+ "version": "2.103.0",
4
4
  "description": "Ruby on Rails-specific skills and hooks for RuboCop and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.102.0",
3
+ "version": "2.103.0",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.102.0",
3
+ "version": "2.103.0",
4
4
  "description": "TypeScript-specific hooks for formatting, linting, and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.102.0",
3
+ "version": "2.103.0",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.102.0",
3
+ "version": "2.103.0",
4
4
  "description": "Distributable LLM Wiki kernel — ingest, query, lint, and maintain a git-native markdown knowledge base across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wiki-status.mjs — read-only Lisa wiki source freshness report.
4
+ *
5
+ * Usage: node wiki-status.mjs [--wiki <wikiRoot>] [--config <path>] [--json]
6
+ * Exit 0 = report rendered. Missing wiki/config are represented in the report.
7
+ */
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { loadConfig, walkFiles } from "./_wiki-lib.mjs";
12
+
13
+ const DAY_MS = 24 * 60 * 60 * 1000;
14
+ const DEFAULT_STALE_AFTER_DAYS = 7;
15
+
16
+ const argv = process.argv.slice(2);
17
+ const flag = name => argv.includes(name);
18
+ const opt = name => {
19
+ const i = argv.indexOf(name);
20
+ return i !== -1 ? argv[i + 1] : undefined;
21
+ };
22
+
23
+ function relToCwd(filePath) {
24
+ return path.relative(process.cwd(), filePath).replaceAll(path.sep, "/");
25
+ }
26
+
27
+ function normalizeWikiPath(filePath) {
28
+ return filePath.replaceAll("\\", "/").replace(/^\.?\//, "");
29
+ }
30
+
31
+ function parseDate(value) {
32
+ if (!value || typeof value !== "string") return undefined;
33
+ const date = new Date(value);
34
+ return Number.isNaN(date.getTime()) ? undefined : date;
35
+ }
36
+
37
+ function newestDate(values) {
38
+ return values
39
+ .map(parseDate)
40
+ .filter(Boolean)
41
+ .sort((a, b) => b.getTime() - a.getTime())[0];
42
+ }
43
+
44
+ function readTextSafe(filePath) {
45
+ try {
46
+ return fs.readFileSync(filePath, "utf8");
47
+ } catch {
48
+ return "";
49
+ }
50
+ }
51
+
52
+ function readJsonSafe(filePath) {
53
+ try {
54
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ function enabledConnectors(config) {
61
+ const connectors = config?.connectors ?? {};
62
+ return Object.entries(connectors)
63
+ .filter(([, connectorConfig]) => connectorConfig?.enabled !== false)
64
+ .filter(
65
+ ([, connectorConfig]) => connectorConfig?.sideEffects !== "external-write"
66
+ )
67
+ .map(([name, connectorConfig]) => ({
68
+ name,
69
+ sideEffects: connectorConfig?.sideEffects ?? "unknown",
70
+ }))
71
+ .sort((a, b) => a.name.localeCompare(b.name));
72
+ }
73
+
74
+ function logSections(logText) {
75
+ const lines = logText.split("\n");
76
+ const sections = [];
77
+ let current;
78
+
79
+ for (const line of lines) {
80
+ const heading = line.match(/^##\s+(\d{4}-\d{2}-\d{2})(?:\s+-\s+(.*))?$/);
81
+ if (heading) {
82
+ if (current) sections.push(current);
83
+ current = {
84
+ date: heading[1],
85
+ title: heading[2] ?? "",
86
+ lines: [line],
87
+ };
88
+ continue;
89
+ }
90
+ if (current) current.lines.push(line);
91
+ }
92
+
93
+ if (current) sections.push(current);
94
+ return sections;
95
+ }
96
+
97
+ function connectorLogFacts(logText, connectorName) {
98
+ const sections = logSections(logText);
99
+ const escaped = connectorName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
100
+ const quoted = new RegExp(`\`${escaped}\``, "i");
101
+ const bare = new RegExp(`\\b${escaped}\\b`, "i");
102
+
103
+ for (const section of [...sections].reverse()) {
104
+ const lines = section.lines.filter(
105
+ line => quoted.test(line) || bare.test(line)
106
+ );
107
+ if (lines.length === 0) continue;
108
+
109
+ const lower = lines.join("\n").toLowerCase();
110
+ const status = lower.includes("skipped")
111
+ ? "skipped"
112
+ : lower.includes("blocked") || lower.includes("failed")
113
+ ? "blocked"
114
+ : lower.includes("ingested") || lower.includes("refreshed")
115
+ ? "ingested"
116
+ : "observed";
117
+
118
+ return {
119
+ date: section.date,
120
+ status,
121
+ reason: lines.map(line => line.replace(/^-\s*/, "").trim()).join(" "),
122
+ };
123
+ }
124
+
125
+ return undefined;
126
+ }
127
+
128
+ function stateFacts(wikiRoot, connectorName) {
129
+ const stateDir = path.join(wikiRoot, "state", connectorName);
130
+ const files = walkFiles(stateDir, { ext: ".json" });
131
+ const states = files
132
+ .map(filePath => ({ filePath, data: readJsonSafe(filePath) }))
133
+ .filter(item => item.data);
134
+ const dated = states
135
+ .map(item => ({
136
+ ...item,
137
+ date:
138
+ parseDate(item.data.ingested_at) ??
139
+ parseDate(item.data.cursor?.lastIngest) ??
140
+ parseDate(item.data.updated_at),
141
+ }))
142
+ .sort((a, b) => (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0));
143
+ const latest = dated[0] ?? states[0];
144
+ const sourceNotes = new Set();
145
+
146
+ for (const item of states) {
147
+ const notes = item.data.source_notes ?? item.data.sourceNotes ?? [];
148
+ if (Array.isArray(notes)) {
149
+ for (const note of notes) {
150
+ if (typeof note === "string") sourceNotes.add(normalizeWikiPath(note));
151
+ }
152
+ }
153
+ if (typeof item.data.source_note === "string") {
154
+ sourceNotes.add(normalizeWikiPath(item.data.source_note));
155
+ }
156
+ }
157
+
158
+ return {
159
+ stateFiles: files.map(filePath => relToCwd(filePath)),
160
+ latestObservedAt: latest?.date?.toISOString() ?? latest?.data?.ingested_at,
161
+ sourceNotes: [...sourceNotes],
162
+ };
163
+ }
164
+
165
+ function sourceFacts(wikiRoot, connectorName, configuredSourceNotes) {
166
+ const sourceDir = path.join(wikiRoot, "sources", connectorName);
167
+ const markdownFiles = walkFiles(sourceDir, { ext: ".md" }).map(relToCwd);
168
+ const wikiParent = path.dirname(wikiRoot);
169
+ const sourceNoteExists = note =>
170
+ fs.existsSync(path.resolve(note)) ||
171
+ fs.existsSync(path.resolve(wikiParent, note));
172
+ const existingConfigured = configuredSourceNotes.filter(note =>
173
+ sourceNoteExists(note)
174
+ );
175
+
176
+ return {
177
+ sourceNotes:
178
+ existingConfigured.length > 0
179
+ ? existingConfigured
180
+ : markdownFiles.map(normalizeWikiPath),
181
+ };
182
+ }
183
+
184
+ function nextActionFor(verdict, connectorName, reason) {
185
+ if (verdict === "fresh") return "No action needed.";
186
+ if (verdict === "skipped") {
187
+ return reason?.toLowerCase().includes("project-scoped memory")
188
+ ? "Provide project-scoped memory for this repo, or accept the expected skip."
189
+ : `Review the skip reason, then run /lisa-wiki:ingest --source ${connectorName} when available.`;
190
+ }
191
+ if (verdict === "blocked") {
192
+ return `Resolve the blocker, then run /lisa-wiki:ingest --source ${connectorName}.`;
193
+ }
194
+ return `Run /lisa-wiki:ingest --source ${connectorName}.`;
195
+ }
196
+
197
+ export function createWikiFreshnessReport({
198
+ configPath = "wiki/lisa-wiki.config.json",
199
+ wikiRoot,
200
+ now = new Date(),
201
+ staleAfterDays = DEFAULT_STALE_AFTER_DAYS,
202
+ } = {}) {
203
+ const { config, configPath: resolvedConfigPath } = loadConfig(configPath);
204
+ const resolvedWikiRoot = path.resolve(wikiRoot ?? config?.wikiRoot ?? "wiki");
205
+ const logPath = path.join(resolvedWikiRoot, "log.md");
206
+ const logText = readTextSafe(logPath);
207
+ const connectors = enabledConnectors(config);
208
+ const staleAfterMs = staleAfterDays * DAY_MS;
209
+
210
+ const items = connectors.map(connector => {
211
+ const state = stateFacts(resolvedWikiRoot, connector.name);
212
+ const sources = sourceFacts(
213
+ resolvedWikiRoot,
214
+ connector.name,
215
+ state.sourceNotes
216
+ );
217
+ const log = connectorLogFacts(logText, connector.name);
218
+ const observedDate =
219
+ newestDate([state.latestObservedAt, log?.date]) ??
220
+ newestDate(
221
+ sources.sourceNotes.map(note => note.match(/\d{4}-\d{2}-\d{2}/)?.[0])
222
+ );
223
+ const hasState = state.stateFiles.length > 0;
224
+ const hasSourceNotes = sources.sourceNotes.length > 0;
225
+ const isStale =
226
+ observedDate && now.getTime() - observedDate.getTime() > staleAfterMs;
227
+
228
+ const verdict =
229
+ log?.status === "blocked"
230
+ ? "blocked"
231
+ : log?.status === "skipped" && !hasState && !hasSourceNotes
232
+ ? "skipped"
233
+ : !hasState && !hasSourceNotes
234
+ ? "never_ingested"
235
+ : !hasState || !hasSourceNotes || isStale
236
+ ? "stale"
237
+ : "fresh";
238
+
239
+ const evidence = [
240
+ ...sources.sourceNotes,
241
+ ...state.stateFiles,
242
+ fs.existsSync(logPath) ? relToCwd(logPath) : undefined,
243
+ ].filter(Boolean);
244
+
245
+ return {
246
+ connector: connector.name,
247
+ sideEffects: connector.sideEffects,
248
+ verdict,
249
+ evidence,
250
+ lastObserved: observedDate?.toISOString().slice(0, 10) ?? "unknown",
251
+ reason: verdict === "fresh" ? "" : (log?.reason ?? ""),
252
+ nextAction: nextActionFor(verdict, connector.name, log?.reason),
253
+ };
254
+ });
255
+
256
+ return {
257
+ generatedAt: now.toISOString(),
258
+ configPath: relToCwd(resolvedConfigPath),
259
+ wikiRoot: relToCwd(resolvedWikiRoot),
260
+ items,
261
+ };
262
+ }
263
+
264
+ export function renderWikiFreshnessReport(report) {
265
+ const lines = [
266
+ "# Lisa wiki source freshness",
267
+ "",
268
+ `Generated: ${report.generatedAt}`,
269
+ `Config: ${report.configPath}`,
270
+ `Wiki root: ${report.wikiRoot}`,
271
+ "",
272
+ "| Connector | Verdict | Last observed | Evidence | Next action |",
273
+ "| --- | --- | --- | --- | --- |",
274
+ ];
275
+
276
+ for (const item of report.items) {
277
+ const evidence =
278
+ item.evidence.length > 0
279
+ ? item.evidence.slice(0, 3).join("<br>")
280
+ : "none";
281
+ lines.push(
282
+ `| ${item.connector} | ${item.verdict} | ${item.lastObserved} | ${evidence} | ${item.nextAction} |`
283
+ );
284
+ if (item.reason && item.verdict !== "fresh") {
285
+ lines.push(
286
+ `| ${item.connector} | reason | ${item.lastObserved} | log | ${item.reason} |`
287
+ );
288
+ }
289
+ }
290
+
291
+ lines.push(
292
+ "",
293
+ "Integrity follow-up: run /lisa-wiki:lint separately for broken links, stale claims, or structure issues."
294
+ );
295
+
296
+ return `${lines.join("\n")}\n`;
297
+ }
298
+
299
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
300
+ const report = createWikiFreshnessReport({
301
+ configPath: opt("--config") ?? "wiki/lisa-wiki.config.json",
302
+ wikiRoot: opt("--wiki"),
303
+ });
304
+
305
+ if (flag("--json")) {
306
+ console.log(JSON.stringify(report, null, 2));
307
+ } else {
308
+ process.stdout.write(renderWikiFreshnessReport(report));
309
+ }
310
+ }
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wiki-status.mjs — read-only Lisa wiki source freshness report.
4
+ *
5
+ * Usage: node wiki-status.mjs [--wiki <wikiRoot>] [--config <path>] [--json]
6
+ * Exit 0 = report rendered. Missing wiki/config are represented in the report.
7
+ */
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { loadConfig, walkFiles } from "./_wiki-lib.mjs";
12
+
13
+ const DAY_MS = 24 * 60 * 60 * 1000;
14
+ const DEFAULT_STALE_AFTER_DAYS = 7;
15
+
16
+ const argv = process.argv.slice(2);
17
+ const flag = name => argv.includes(name);
18
+ const opt = name => {
19
+ const i = argv.indexOf(name);
20
+ return i !== -1 ? argv[i + 1] : undefined;
21
+ };
22
+
23
+ function relToCwd(filePath) {
24
+ return path.relative(process.cwd(), filePath).replaceAll(path.sep, "/");
25
+ }
26
+
27
+ function normalizeWikiPath(filePath) {
28
+ return filePath.replaceAll("\\", "/").replace(/^\.?\//, "");
29
+ }
30
+
31
+ function parseDate(value) {
32
+ if (!value || typeof value !== "string") return undefined;
33
+ const date = new Date(value);
34
+ return Number.isNaN(date.getTime()) ? undefined : date;
35
+ }
36
+
37
+ function newestDate(values) {
38
+ return values
39
+ .map(parseDate)
40
+ .filter(Boolean)
41
+ .sort((a, b) => b.getTime() - a.getTime())[0];
42
+ }
43
+
44
+ function readTextSafe(filePath) {
45
+ try {
46
+ return fs.readFileSync(filePath, "utf8");
47
+ } catch {
48
+ return "";
49
+ }
50
+ }
51
+
52
+ function readJsonSafe(filePath) {
53
+ try {
54
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ function enabledConnectors(config) {
61
+ const connectors = config?.connectors ?? {};
62
+ return Object.entries(connectors)
63
+ .filter(([, connectorConfig]) => connectorConfig?.enabled !== false)
64
+ .filter(
65
+ ([, connectorConfig]) => connectorConfig?.sideEffects !== "external-write"
66
+ )
67
+ .map(([name, connectorConfig]) => ({
68
+ name,
69
+ sideEffects: connectorConfig?.sideEffects ?? "unknown",
70
+ }))
71
+ .sort((a, b) => a.name.localeCompare(b.name));
72
+ }
73
+
74
+ function logSections(logText) {
75
+ const lines = logText.split("\n");
76
+ const sections = [];
77
+ let current;
78
+
79
+ for (const line of lines) {
80
+ const heading = line.match(/^##\s+(\d{4}-\d{2}-\d{2})(?:\s+-\s+(.*))?$/);
81
+ if (heading) {
82
+ if (current) sections.push(current);
83
+ current = {
84
+ date: heading[1],
85
+ title: heading[2] ?? "",
86
+ lines: [line],
87
+ };
88
+ continue;
89
+ }
90
+ if (current) current.lines.push(line);
91
+ }
92
+
93
+ if (current) sections.push(current);
94
+ return sections;
95
+ }
96
+
97
+ function connectorLogFacts(logText, connectorName) {
98
+ const sections = logSections(logText);
99
+ const escaped = connectorName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
100
+ const quoted = new RegExp(`\`${escaped}\``, "i");
101
+ const bare = new RegExp(`\\b${escaped}\\b`, "i");
102
+
103
+ for (const section of [...sections].reverse()) {
104
+ const lines = section.lines.filter(
105
+ line => quoted.test(line) || bare.test(line)
106
+ );
107
+ if (lines.length === 0) continue;
108
+
109
+ const lower = lines.join("\n").toLowerCase();
110
+ const status = lower.includes("skipped")
111
+ ? "skipped"
112
+ : lower.includes("blocked") || lower.includes("failed")
113
+ ? "blocked"
114
+ : lower.includes("ingested") || lower.includes("refreshed")
115
+ ? "ingested"
116
+ : "observed";
117
+
118
+ return {
119
+ date: section.date,
120
+ status,
121
+ reason: lines.map(line => line.replace(/^-\s*/, "").trim()).join(" "),
122
+ };
123
+ }
124
+
125
+ return undefined;
126
+ }
127
+
128
+ function stateFacts(wikiRoot, connectorName) {
129
+ const stateDir = path.join(wikiRoot, "state", connectorName);
130
+ const files = walkFiles(stateDir, { ext: ".json" });
131
+ const states = files
132
+ .map(filePath => ({ filePath, data: readJsonSafe(filePath) }))
133
+ .filter(item => item.data);
134
+ const dated = states
135
+ .map(item => ({
136
+ ...item,
137
+ date:
138
+ parseDate(item.data.ingested_at) ??
139
+ parseDate(item.data.cursor?.lastIngest) ??
140
+ parseDate(item.data.updated_at),
141
+ }))
142
+ .sort((a, b) => (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0));
143
+ const latest = dated[0] ?? states[0];
144
+ const sourceNotes = new Set();
145
+
146
+ for (const item of states) {
147
+ const notes = item.data.source_notes ?? item.data.sourceNotes ?? [];
148
+ if (Array.isArray(notes)) {
149
+ for (const note of notes) {
150
+ if (typeof note === "string") sourceNotes.add(normalizeWikiPath(note));
151
+ }
152
+ }
153
+ if (typeof item.data.source_note === "string") {
154
+ sourceNotes.add(normalizeWikiPath(item.data.source_note));
155
+ }
156
+ }
157
+
158
+ return {
159
+ stateFiles: files.map(filePath => relToCwd(filePath)),
160
+ latestObservedAt: latest?.date?.toISOString() ?? latest?.data?.ingested_at,
161
+ sourceNotes: [...sourceNotes],
162
+ };
163
+ }
164
+
165
+ function sourceFacts(wikiRoot, connectorName, configuredSourceNotes) {
166
+ const sourceDir = path.join(wikiRoot, "sources", connectorName);
167
+ const markdownFiles = walkFiles(sourceDir, { ext: ".md" }).map(relToCwd);
168
+ const wikiParent = path.dirname(wikiRoot);
169
+ const sourceNoteExists = note =>
170
+ fs.existsSync(path.resolve(note)) ||
171
+ fs.existsSync(path.resolve(wikiParent, note));
172
+ const existingConfigured = configuredSourceNotes.filter(note =>
173
+ sourceNoteExists(note)
174
+ );
175
+
176
+ return {
177
+ sourceNotes:
178
+ existingConfigured.length > 0
179
+ ? existingConfigured
180
+ : markdownFiles.map(normalizeWikiPath),
181
+ };
182
+ }
183
+
184
+ function nextActionFor(verdict, connectorName, reason) {
185
+ if (verdict === "fresh") return "No action needed.";
186
+ if (verdict === "skipped") {
187
+ return reason?.toLowerCase().includes("project-scoped memory")
188
+ ? "Provide project-scoped memory for this repo, or accept the expected skip."
189
+ : `Review the skip reason, then run /lisa-wiki:ingest --source ${connectorName} when available.`;
190
+ }
191
+ if (verdict === "blocked") {
192
+ return `Resolve the blocker, then run /lisa-wiki:ingest --source ${connectorName}.`;
193
+ }
194
+ return `Run /lisa-wiki:ingest --source ${connectorName}.`;
195
+ }
196
+
197
+ export function createWikiFreshnessReport({
198
+ configPath = "wiki/lisa-wiki.config.json",
199
+ wikiRoot,
200
+ now = new Date(),
201
+ staleAfterDays = DEFAULT_STALE_AFTER_DAYS,
202
+ } = {}) {
203
+ const { config, configPath: resolvedConfigPath } = loadConfig(configPath);
204
+ const resolvedWikiRoot = path.resolve(wikiRoot ?? config?.wikiRoot ?? "wiki");
205
+ const logPath = path.join(resolvedWikiRoot, "log.md");
206
+ const logText = readTextSafe(logPath);
207
+ const connectors = enabledConnectors(config);
208
+ const staleAfterMs = staleAfterDays * DAY_MS;
209
+
210
+ const items = connectors.map(connector => {
211
+ const state = stateFacts(resolvedWikiRoot, connector.name);
212
+ const sources = sourceFacts(
213
+ resolvedWikiRoot,
214
+ connector.name,
215
+ state.sourceNotes
216
+ );
217
+ const log = connectorLogFacts(logText, connector.name);
218
+ const observedDate =
219
+ newestDate([state.latestObservedAt, log?.date]) ??
220
+ newestDate(
221
+ sources.sourceNotes.map(note => note.match(/\d{4}-\d{2}-\d{2}/)?.[0])
222
+ );
223
+ const hasState = state.stateFiles.length > 0;
224
+ const hasSourceNotes = sources.sourceNotes.length > 0;
225
+ const isStale =
226
+ observedDate && now.getTime() - observedDate.getTime() > staleAfterMs;
227
+
228
+ const verdict =
229
+ log?.status === "blocked"
230
+ ? "blocked"
231
+ : log?.status === "skipped" && !hasState && !hasSourceNotes
232
+ ? "skipped"
233
+ : !hasState && !hasSourceNotes
234
+ ? "never_ingested"
235
+ : !hasState || !hasSourceNotes || isStale
236
+ ? "stale"
237
+ : "fresh";
238
+
239
+ const evidence = [
240
+ ...sources.sourceNotes,
241
+ ...state.stateFiles,
242
+ fs.existsSync(logPath) ? relToCwd(logPath) : undefined,
243
+ ].filter(Boolean);
244
+
245
+ return {
246
+ connector: connector.name,
247
+ sideEffects: connector.sideEffects,
248
+ verdict,
249
+ evidence,
250
+ lastObserved: observedDate?.toISOString().slice(0, 10) ?? "unknown",
251
+ reason: verdict === "fresh" ? "" : (log?.reason ?? ""),
252
+ nextAction: nextActionFor(verdict, connector.name, log?.reason),
253
+ };
254
+ });
255
+
256
+ return {
257
+ generatedAt: now.toISOString(),
258
+ configPath: relToCwd(resolvedConfigPath),
259
+ wikiRoot: relToCwd(resolvedWikiRoot),
260
+ items,
261
+ };
262
+ }
263
+
264
+ export function renderWikiFreshnessReport(report) {
265
+ const lines = [
266
+ "# Lisa wiki source freshness",
267
+ "",
268
+ `Generated: ${report.generatedAt}`,
269
+ `Config: ${report.configPath}`,
270
+ `Wiki root: ${report.wikiRoot}`,
271
+ "",
272
+ "| Connector | Verdict | Last observed | Evidence | Next action |",
273
+ "| --- | --- | --- | --- | --- |",
274
+ ];
275
+
276
+ for (const item of report.items) {
277
+ const evidence =
278
+ item.evidence.length > 0
279
+ ? item.evidence.slice(0, 3).join("<br>")
280
+ : "none";
281
+ lines.push(
282
+ `| ${item.connector} | ${item.verdict} | ${item.lastObserved} | ${evidence} | ${item.nextAction} |`
283
+ );
284
+ if (item.reason && item.verdict !== "fresh") {
285
+ lines.push(
286
+ `| ${item.connector} | reason | ${item.lastObserved} | log | ${item.reason} |`
287
+ );
288
+ }
289
+ }
290
+
291
+ lines.push(
292
+ "",
293
+ "Integrity follow-up: run /lisa-wiki:lint separately for broken links, stale claims, or structure issues."
294
+ );
295
+
296
+ return `${lines.join("\n")}\n`;
297
+ }
298
+
299
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
300
+ const report = createWikiFreshnessReport({
301
+ configPath: opt("--config") ?? "wiki/lisa-wiki.config.json",
302
+ wikiRoot: opt("--wiki"),
303
+ });
304
+
305
+ if (flag("--json")) {
306
+ console.log(JSON.stringify(report, null, 2));
307
+ } else {
308
+ process.stdout.write(renderWikiFreshnessReport(report));
309
+ }
310
+ }