@codyswann/lisa 2.24.0 → 2.25.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 (142) hide show
  1. package/.claude-plugin/marketplace.json +6 -0
  2. package/package.json +1 -1
  3. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  4. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  5. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  6. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  7. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  8. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  9. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  10. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  11. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa-wiki/.claude-plugin/plugin.json +8 -0
  18. package/plugins/lisa-wiki/.codex-plugin/plugin.json +32 -0
  19. package/plugins/lisa-wiki/ci/lisa-wiki-validate.yml +32 -0
  20. package/plugins/lisa-wiki/commands/add-ingest.md +6 -0
  21. package/plugins/lisa-wiki/commands/add-role.md +6 -0
  22. package/plugins/lisa-wiki/commands/doctor.md +6 -0
  23. package/plugins/lisa-wiki/commands/ingest.md +6 -0
  24. package/plugins/lisa-wiki/commands/lint.md +6 -0
  25. package/plugins/lisa-wiki/commands/migrate.md +6 -0
  26. package/plugins/lisa-wiki/commands/onboard-me.md +6 -0
  27. package/plugins/lisa-wiki/commands/query.md +6 -0
  28. package/plugins/lisa-wiki/commands/setup.md +6 -0
  29. package/plugins/lisa-wiki/schema/lisa-wiki-config.schema.json +118 -0
  30. package/plugins/lisa-wiki/schema/wiki-structure.schema.json +51 -0
  31. package/plugins/lisa-wiki/scripts/_wiki-lib.mjs +185 -0
  32. package/plugins/lisa-wiki/scripts/diff-guard.mjs +116 -0
  33. package/plugins/lisa-wiki/scripts/ingest-git.mjs +189 -0
  34. package/plugins/lisa-wiki/scripts/ingest-memory.mjs +130 -0
  35. package/plugins/lisa-wiki/scripts/ingest-roles.mjs +85 -0
  36. package/plugins/lisa-wiki/scripts/ingest_slack_channel.py +329 -0
  37. package/plugins/lisa-wiki/scripts/lint-wiki.mjs +320 -0
  38. package/plugins/lisa-wiki/scripts/mcp-doctor.mjs +72 -0
  39. package/plugins/lisa-wiki/scripts/render-contract.mjs +107 -0
  40. package/plugins/lisa-wiki/scripts/rewrite-refs.mjs +144 -0
  41. package/plugins/lisa-wiki/scripts/slack_oauth_user.py +179 -0
  42. package/plugins/lisa-wiki/scripts/validate-config.mjs +232 -0
  43. package/plugins/lisa-wiki/scripts/verify-migration.mjs +199 -0
  44. package/plugins/lisa-wiki/skills/lisa-wiki-add-ingest/SKILL.md +34 -0
  45. package/plugins/lisa-wiki/skills/lisa-wiki-add-role/SKILL.md +30 -0
  46. package/plugins/lisa-wiki/skills/lisa-wiki-connector-confluence/SKILL.md +25 -0
  47. package/plugins/lisa-wiki/skills/lisa-wiki-connector-docs/SKILL.md +30 -0
  48. package/plugins/lisa-wiki/skills/lisa-wiki-connector-git/SKILL.md +25 -0
  49. package/plugins/lisa-wiki/skills/lisa-wiki-connector-jira/SKILL.md +28 -0
  50. package/plugins/lisa-wiki/skills/lisa-wiki-connector-memory/SKILL.md +28 -0
  51. package/plugins/lisa-wiki/skills/lisa-wiki-connector-notion/SKILL.md +25 -0
  52. package/plugins/lisa-wiki/skills/lisa-wiki-connector-roles/SKILL.md +22 -0
  53. package/plugins/lisa-wiki/skills/lisa-wiki-connector-slack/SKILL.md +30 -0
  54. package/plugins/lisa-wiki/skills/lisa-wiki-connector-web/SKILL.md +23 -0
  55. package/plugins/lisa-wiki/skills/lisa-wiki-doctor/SKILL.md +47 -0
  56. package/plugins/lisa-wiki/skills/lisa-wiki-ingest/SKILL.md +43 -0
  57. package/plugins/lisa-wiki/skills/lisa-wiki-lint/SKILL.md +32 -0
  58. package/plugins/lisa-wiki/skills/lisa-wiki-migrate/SKILL.md +43 -0
  59. package/plugins/lisa-wiki/skills/lisa-wiki-onboard-me/SKILL.md +33 -0
  60. package/plugins/lisa-wiki/skills/lisa-wiki-query/SKILL.md +30 -0
  61. package/plugins/lisa-wiki/skills/lisa-wiki-setup/SKILL.md +45 -0
  62. package/plugins/lisa-wiki/skills/lisa-wiki-usage/SKILL.md +50 -0
  63. package/plugins/lisa-wiki/templates/agents/role-agent.claude.md +16 -0
  64. package/plugins/lisa-wiki/templates/agents/role-agent.codex.toml +15 -0
  65. package/plugins/lisa-wiki/templates/index.md +17 -0
  66. package/plugins/lisa-wiki/templates/llm-wiki-contract.md +60 -0
  67. package/plugins/lisa-wiki/templates/log.md +8 -0
  68. package/plugins/lisa-wiki/templates/page-types/architecture.md +18 -0
  69. package/plugins/lisa-wiki/templates/page-types/concept.md +18 -0
  70. package/plugins/lisa-wiki/templates/page-types/decision.md +18 -0
  71. package/plugins/lisa-wiki/templates/page-types/entity.md +19 -0
  72. package/plugins/lisa-wiki/templates/page-types/open-question.md +18 -0
  73. package/plugins/lisa-wiki/templates/page-types/playbook.md +18 -0
  74. package/plugins/lisa-wiki/templates/page-types/project.md +19 -0
  75. package/plugins/lisa-wiki/templates/page-types/requirement.md +19 -0
  76. package/plugins/lisa-wiki/templates/page-types/staff.md +26 -0
  77. package/plugins/lisa-wiki/templates/start-here.md +24 -0
  78. package/plugins/lisa-wiki/templates/state-readme.md +20 -0
  79. package/plugins/src/wiki/.claude-plugin/plugin.json +6 -0
  80. package/plugins/src/wiki/ci/lisa-wiki-validate.yml +32 -0
  81. package/plugins/src/wiki/commands/add-ingest.md +6 -0
  82. package/plugins/src/wiki/commands/add-role.md +6 -0
  83. package/plugins/src/wiki/commands/doctor.md +6 -0
  84. package/plugins/src/wiki/commands/ingest.md +6 -0
  85. package/plugins/src/wiki/commands/lint.md +6 -0
  86. package/plugins/src/wiki/commands/migrate.md +6 -0
  87. package/plugins/src/wiki/commands/onboard-me.md +6 -0
  88. package/plugins/src/wiki/commands/query.md +6 -0
  89. package/plugins/src/wiki/commands/setup.md +6 -0
  90. package/plugins/src/wiki/schema/lisa-wiki-config.schema.json +118 -0
  91. package/plugins/src/wiki/schema/wiki-structure.schema.json +51 -0
  92. package/plugins/src/wiki/scripts/_wiki-lib.mjs +185 -0
  93. package/plugins/src/wiki/scripts/diff-guard.mjs +116 -0
  94. package/plugins/src/wiki/scripts/ingest-git.mjs +189 -0
  95. package/plugins/src/wiki/scripts/ingest-memory.mjs +130 -0
  96. package/plugins/src/wiki/scripts/ingest-roles.mjs +85 -0
  97. package/plugins/src/wiki/scripts/ingest_slack_channel.py +329 -0
  98. package/plugins/src/wiki/scripts/lint-wiki.mjs +320 -0
  99. package/plugins/src/wiki/scripts/mcp-doctor.mjs +72 -0
  100. package/plugins/src/wiki/scripts/render-contract.mjs +107 -0
  101. package/plugins/src/wiki/scripts/rewrite-refs.mjs +144 -0
  102. package/plugins/src/wiki/scripts/slack_oauth_user.py +179 -0
  103. package/plugins/src/wiki/scripts/validate-config.mjs +232 -0
  104. package/plugins/src/wiki/scripts/verify-migration.mjs +199 -0
  105. package/plugins/src/wiki/skills/lisa-wiki-add-ingest/SKILL.md +34 -0
  106. package/plugins/src/wiki/skills/lisa-wiki-add-role/SKILL.md +30 -0
  107. package/plugins/src/wiki/skills/lisa-wiki-connector-confluence/SKILL.md +25 -0
  108. package/plugins/src/wiki/skills/lisa-wiki-connector-docs/SKILL.md +30 -0
  109. package/plugins/src/wiki/skills/lisa-wiki-connector-git/SKILL.md +25 -0
  110. package/plugins/src/wiki/skills/lisa-wiki-connector-jira/SKILL.md +28 -0
  111. package/plugins/src/wiki/skills/lisa-wiki-connector-memory/SKILL.md +28 -0
  112. package/plugins/src/wiki/skills/lisa-wiki-connector-notion/SKILL.md +25 -0
  113. package/plugins/src/wiki/skills/lisa-wiki-connector-roles/SKILL.md +22 -0
  114. package/plugins/src/wiki/skills/lisa-wiki-connector-slack/SKILL.md +30 -0
  115. package/plugins/src/wiki/skills/lisa-wiki-connector-web/SKILL.md +23 -0
  116. package/plugins/src/wiki/skills/lisa-wiki-doctor/SKILL.md +47 -0
  117. package/plugins/src/wiki/skills/lisa-wiki-ingest/SKILL.md +43 -0
  118. package/plugins/src/wiki/skills/lisa-wiki-lint/SKILL.md +32 -0
  119. package/plugins/src/wiki/skills/lisa-wiki-migrate/SKILL.md +43 -0
  120. package/plugins/src/wiki/skills/lisa-wiki-onboard-me/SKILL.md +33 -0
  121. package/plugins/src/wiki/skills/lisa-wiki-query/SKILL.md +30 -0
  122. package/plugins/src/wiki/skills/lisa-wiki-setup/SKILL.md +45 -0
  123. package/plugins/src/wiki/skills/lisa-wiki-usage/SKILL.md +50 -0
  124. package/plugins/src/wiki/templates/agents/role-agent.claude.md +16 -0
  125. package/plugins/src/wiki/templates/agents/role-agent.codex.toml +15 -0
  126. package/plugins/src/wiki/templates/index.md +17 -0
  127. package/plugins/src/wiki/templates/llm-wiki-contract.md +60 -0
  128. package/plugins/src/wiki/templates/log.md +8 -0
  129. package/plugins/src/wiki/templates/page-types/architecture.md +18 -0
  130. package/plugins/src/wiki/templates/page-types/concept.md +18 -0
  131. package/plugins/src/wiki/templates/page-types/decision.md +18 -0
  132. package/plugins/src/wiki/templates/page-types/entity.md +19 -0
  133. package/plugins/src/wiki/templates/page-types/open-question.md +18 -0
  134. package/plugins/src/wiki/templates/page-types/playbook.md +18 -0
  135. package/plugins/src/wiki/templates/page-types/project.md +19 -0
  136. package/plugins/src/wiki/templates/page-types/requirement.md +19 -0
  137. package/plugins/src/wiki/templates/page-types/staff.md +26 -0
  138. package/plugins/src/wiki/templates/start-here.md +24 -0
  139. package/plugins/src/wiki/templates/state-readme.md +20 -0
  140. package/scripts/build-plugins.sh +29 -21
  141. package/scripts/check-plugins-sync.sh +1 -1
  142. package/scripts/generate-codex-plugin-artifacts.mjs +22 -0
@@ -0,0 +1,329 @@
1
+ #!/usr/bin/env python3
2
+ """Ingest a Slack channel with a Slack user token.
3
+
4
+ The script writes normalized source notes under wiki/sources/slack/ and keeps a
5
+ per-channel cursor under wiki/state/slack/. It uses a user token (`xoxp-...`) so
6
+ access follows the authorizing user's Slack visibility.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import datetime as dt
13
+ import json
14
+ import os
15
+ import re
16
+ import time
17
+ import urllib.error
18
+ import urllib.parse
19
+ import urllib.request
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+
24
+ TOKEN_PATTERNS = [
25
+ re.compile(r"xox[pbar]-[A-Za-z0-9-]+"),
26
+ re.compile(r"(?i)bearer\s+[A-Za-z0-9._~+/=-]{20,}"),
27
+ re.compile(r"AKIA[0-9A-Z]{16}"),
28
+ re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.S),
29
+ ]
30
+
31
+
32
+ def utc_now() -> dt.datetime:
33
+ return dt.datetime.now(dt.UTC).replace(microsecond=0)
34
+
35
+
36
+ def iso_from_ts(ts: str) -> str:
37
+ seconds = float(ts)
38
+ return dt.datetime.fromtimestamp(seconds, dt.UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
39
+
40
+
41
+ def ts_from_input(value: str | None) -> str | None:
42
+ if not value:
43
+ return None
44
+ if re.fullmatch(r"\d+(?:\.\d+)?", value):
45
+ return value
46
+ parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
47
+ return f"{parsed.timestamp():.6f}"
48
+
49
+
50
+ def redact(text: str) -> str:
51
+ out = text
52
+ for pattern in TOKEN_PATTERNS:
53
+ out = pattern.sub("[REDACTED]", out)
54
+ return out
55
+
56
+
57
+ def load_token(args: argparse.Namespace) -> str:
58
+ if args.token:
59
+ return args.token
60
+ if args.token_file:
61
+ payload = json.loads(Path(args.token_file).read_text(encoding="utf-8"))
62
+ token = payload.get("access_token") or (payload.get("authed_user") or {}).get("access_token")
63
+ if token:
64
+ return token
65
+ env_token = os.environ.get("SLACK_USER_TOKEN")
66
+ if env_token:
67
+ return env_token
68
+ raise SystemExit("Provide --token, --token-file, or SLACK_USER_TOKEN.")
69
+
70
+
71
+ class SlackClient:
72
+ def __init__(self, token: str) -> None:
73
+ self.token = token
74
+
75
+ def call(self, method: str, params: dict[str, Any] | None = None, retries: int = 5) -> dict[str, Any]:
76
+ params = params or {}
77
+ body = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None}).encode("utf-8")
78
+ request = urllib.request.Request(
79
+ f"https://slack.com/api/{method}",
80
+ data=body,
81
+ headers={
82
+ "Authorization": f"Bearer {self.token}",
83
+ "Content-Type": "application/x-www-form-urlencoded",
84
+ "Accept": "application/json",
85
+ },
86
+ method="POST",
87
+ )
88
+ try:
89
+ with urllib.request.urlopen(request, timeout=60) as response:
90
+ payload = json.loads(response.read().decode("utf-8"))
91
+ except urllib.error.HTTPError as error:
92
+ if error.code == 429 and retries > 0:
93
+ retry_after = int(error.headers.get("Retry-After", "60"))
94
+ time.sleep(retry_after)
95
+ return self.call(method, params, retries - 1)
96
+ raise
97
+ if not payload.get("ok"):
98
+ raise RuntimeError(f"Slack API {method} failed: {payload}")
99
+ return payload
100
+
101
+
102
+ def resolve_channel(client: SlackClient, channel: str) -> dict[str, Any]:
103
+ if re.fullmatch(r"[CGD][A-Z0-9]+", channel):
104
+ info = client.call("conversations.info", {"channel": channel})
105
+ return info["channel"]
106
+
107
+ wanted = channel.lstrip("#")
108
+ cursor = None
109
+ while True:
110
+ payload = client.call(
111
+ "conversations.list",
112
+ {
113
+ "types": "public_channel,private_channel",
114
+ "exclude_archived": "false",
115
+ "limit": 1000,
116
+ "cursor": cursor,
117
+ },
118
+ )
119
+ for item in payload.get("channels", []):
120
+ if item.get("name") == wanted:
121
+ return item
122
+ cursor = (payload.get("response_metadata") or {}).get("next_cursor")
123
+ if not cursor:
124
+ break
125
+ raise SystemExit(f"Could not resolve Slack channel {channel!r}.")
126
+
127
+
128
+ def fetch_history(
129
+ client: SlackClient,
130
+ channel_id: str,
131
+ oldest: str | None,
132
+ latest: str | None,
133
+ page_limit: int | None,
134
+ limit: int,
135
+ ) -> list[dict[str, Any]]:
136
+ messages: list[dict[str, Any]] = []
137
+ cursor = None
138
+ pages = 0
139
+ while True:
140
+ payload = client.call(
141
+ "conversations.history",
142
+ {
143
+ "channel": channel_id,
144
+ "oldest": oldest,
145
+ "latest": latest,
146
+ "inclusive": "false",
147
+ "limit": limit,
148
+ "cursor": cursor,
149
+ },
150
+ )
151
+ messages.extend(payload.get("messages", []))
152
+ pages += 1
153
+ cursor = (payload.get("response_metadata") or {}).get("next_cursor")
154
+ if not cursor or (page_limit and pages >= page_limit):
155
+ break
156
+ return sorted(messages, key=lambda item: float(item.get("ts", "0")))
157
+
158
+
159
+ def fetch_replies(client: SlackClient, channel_id: str, thread_ts: str) -> list[dict[str, Any]]:
160
+ replies: list[dict[str, Any]] = []
161
+ cursor = None
162
+ while True:
163
+ payload = client.call(
164
+ "conversations.replies",
165
+ {"channel": channel_id, "ts": thread_ts, "limit": 1000, "cursor": cursor},
166
+ )
167
+ replies.extend(payload.get("messages", []))
168
+ cursor = (payload.get("response_metadata") or {}).get("next_cursor")
169
+ if not cursor:
170
+ break
171
+ return [reply for reply in replies if reply.get("ts") != thread_ts]
172
+
173
+
174
+ def render_message(message: dict[str, Any], user_map: dict[str, str] | None = None) -> list[str]:
175
+ ts = message.get("ts", "")
176
+ user = message.get("user") or message.get("bot_id") or message.get("username") or "unknown"
177
+ if user_map and user in user_map:
178
+ user = f"{user_map[user]} ({user})"
179
+ lines = [f"### {iso_from_ts(ts)} - {user}", "", f"- ts: `{ts}`"]
180
+ if message.get("thread_ts") and message.get("thread_ts") != ts:
181
+ lines.append(f"- thread_ts: `{message['thread_ts']}`")
182
+ if message.get("permalink"):
183
+ lines.append(f"- permalink: {message['permalink']}")
184
+ text = redact(message.get("text") or "")
185
+ lines.extend(["", "```text", text, "```", ""])
186
+ return lines
187
+
188
+
189
+ def main() -> int:
190
+ parser = argparse.ArgumentParser(description=__doc__)
191
+ parser.add_argument("--channel", required=True, help="Channel ID or #channel-name.")
192
+ parser.add_argument("--token", help="Slack user token. Prefer SLACK_USER_TOKEN or --token-file.")
193
+ parser.add_argument("--token-file", help="JSON file from scripts/slack_oauth_user.py.")
194
+ parser.add_argument("--oldest", help="Slack ts or ISO timestamp. Defaults to previous state with overlap.")
195
+ parser.add_argument("--latest", help="Slack ts or ISO timestamp.")
196
+ parser.add_argument("--limit", type=int, default=200, help="Slack page size.")
197
+ parser.add_argument("--page-limit", type=int, help="Stop after this many history pages.")
198
+ parser.add_argument("--no-threads", action="store_true", help="Skip thread replies.")
199
+ parser.add_argument("--thread-lookback-days", type=int, default=14)
200
+ parser.add_argument("--source-dir", default="wiki/sources/slack")
201
+ parser.add_argument("--state-dir", default="wiki/state/slack",
202
+ help="Read-only: prior cursor is read here for windowing. Final state is advanced by the kernel.")
203
+ parser.add_argument("--config", help="Path to wiki/lisa-wiki.config.json for the Slack tenant guard.")
204
+ parser.add_argument("--emit-meta", help="Write the PROPOSED cursor here; the kernel advances final state after verification.")
205
+ parser.add_argument("--title", help="Optional source-note title.")
206
+ args = parser.parse_args()
207
+
208
+ token = load_token(args)
209
+ client = SlackClient(token)
210
+
211
+ # Tenant guard: verify the authorized Slack workspace matches config before ingesting.
212
+ # (external-write does not exempt tenant guards.)
213
+ if args.config:
214
+ try:
215
+ cfg = json.loads(Path(args.config).read_text(encoding="utf-8"))
216
+ except Exception:
217
+ cfg = {}
218
+ guard = (((cfg.get("connectors") or {}).get("slack") or {}).get("tenantGuard")) or {}
219
+ if guard:
220
+ ident = client.call("auth.test")
221
+ want_team = guard.get("teamId") or guard.get("team_id")
222
+ want_url = guard.get("url")
223
+ if want_team and ident.get("team_id") != want_team:
224
+ raise SystemExit(f"Slack tenant guard: team_id {ident.get('team_id')!r} != configured {want_team!r}; aborting.")
225
+ if want_url and want_url not in (ident.get("url") or ""):
226
+ raise SystemExit(f"Slack tenant guard: workspace url {ident.get('url')!r} != configured {want_url!r}; aborting.")
227
+
228
+ channel = resolve_channel(client, args.channel)
229
+ channel_id = channel["id"]
230
+ channel_name = channel.get("name") or channel_id
231
+
232
+ state_dir = Path(args.state_dir)
233
+ state_dir.mkdir(parents=True, exist_ok=True)
234
+ state_path = state_dir / f"{channel_id}.json"
235
+ previous_state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {}
236
+
237
+ oldest = ts_from_input(args.oldest)
238
+ if oldest is None and previous_state.get("latest_message_ts"):
239
+ overlap_seconds = args.thread_lookback_days * 24 * 60 * 60
240
+ oldest_float = max(0.0, float(previous_state["latest_message_ts"]) - overlap_seconds)
241
+ oldest = f"{oldest_float:.6f}"
242
+ latest = ts_from_input(args.latest)
243
+
244
+ messages = fetch_history(client, channel_id, oldest, latest, args.page_limit, args.limit)
245
+ reply_count = 0
246
+ if not args.no_threads:
247
+ for message in messages:
248
+ if message.get("reply_count") and message.get("thread_ts", message.get("ts")) == message.get("ts"):
249
+ replies = fetch_replies(client, channel_id, message["ts"])
250
+ message["ingested_replies"] = sorted(replies, key=lambda item: float(item.get("ts", "0")))
251
+ reply_count += len(replies)
252
+
253
+ now = utc_now()
254
+ stamp = now.strftime("%Y-%m-%d-%H%M%S")
255
+ source_dir = Path(args.source_dir)
256
+ source_dir.mkdir(parents=True, exist_ok=True)
257
+ safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "-", channel_name).strip("-") or channel_id
258
+ source_path = source_dir / f"{stamp}-{safe_name}-{channel_id}.md"
259
+ title = args.title or f"Slack Channel Ingest - #{channel_name}"
260
+
261
+ lines = [
262
+ "---",
263
+ "type: source",
264
+ f"created: {now.date()}",
265
+ f"updated: {now.date()}",
266
+ "source_system: slack",
267
+ f"channel_id: {channel_id}",
268
+ f"channel_name: {channel_name}",
269
+ "sources: []",
270
+ "---",
271
+ "",
272
+ f"# {title}",
273
+ "",
274
+ f"- Ingested at: `{now.isoformat().replace('+00:00', 'Z')}`",
275
+ f"- Channel: `#{channel_name}` (`{channel_id}`)",
276
+ f"- Oldest cursor: `{oldest or '0'}`",
277
+ f"- Latest cursor: `{latest or 'now'}`",
278
+ f"- Messages: `{len(messages)}`",
279
+ f"- Thread replies: `{reply_count}`",
280
+ "",
281
+ "## Messages",
282
+ "",
283
+ ]
284
+ for message in messages:
285
+ lines.extend(render_message(message))
286
+ replies = message.get("ingested_replies") or []
287
+ if replies:
288
+ lines.extend(["#### Thread Replies", ""])
289
+ for reply in replies:
290
+ lines.extend(render_message(reply))
291
+
292
+ source_path.write_text("\n".join(lines), encoding="utf-8")
293
+
294
+ latest_ts = previous_state.get("latest_message_ts")
295
+ if messages:
296
+ latest_ts = max([message["ts"] for message in messages] + ([latest_ts] if latest_ts else []), key=float)
297
+
298
+ notes = previous_state.get("source_notes") or []
299
+ source_note = str(source_path)
300
+ if source_note not in notes:
301
+ notes.append(source_note)
302
+
303
+ # Per the connector contract, the connector does NOT advance final state — it emits a
304
+ # PROPOSED cursor and the kernel writes wiki/state/slack/<channel>.json after verification.
305
+ proposed_cursor = {
306
+ "connector": "slack",
307
+ "channel_id": channel_id,
308
+ "channel_name": channel_name,
309
+ "ran_at": now.isoformat().replace("+00:00", "Z"),
310
+ "latest_message_ts": latest_ts,
311
+ "latest_message_at": iso_from_ts(latest_ts) if latest_ts else None,
312
+ "last_message_count": len(messages),
313
+ "last_thread_reply_count": reply_count,
314
+ "source_notes": notes,
315
+ }
316
+
317
+ print(f"Wrote {source_path}")
318
+ if args.emit_meta:
319
+ meta_path = Path(args.emit_meta)
320
+ meta_path.parent.mkdir(parents=True, exist_ok=True)
321
+ meta_path.write_text(json.dumps({"proposedCursor": proposed_cursor}, indent=2) + "\n", encoding="utf-8")
322
+ print(f"Emitted proposed cursor {meta_path} (kernel advances final state after verification)")
323
+ else:
324
+ print("No --emit-meta given; proposed cursor not persisted (final state is advanced by the kernel).")
325
+ return 0
326
+
327
+
328
+ if __name__ == "__main__":
329
+ raise SystemExit(main())
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lint-wiki.mjs — deterministic, dependency-free integrity checker for a lisa-wiki.
4
+ *
5
+ * Read-only: it reports findings, it never modifies the wiki. Default mode is
6
+ * "warning" (exit 1 only on FAIL items); `--strict` (hard-enforcement) also fails
7
+ * on WARN items.
8
+ *
9
+ * Usage: node lint-wiki.mjs [--wiki <wikiRoot>] [--config <path>] [--strict] [--json]
10
+ * Exit 0 = clean (for the active mode), 1 = blocking findings.
11
+ */
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import {
16
+ loadConfig,
17
+ loadStructure,
18
+ pluginRootFrom,
19
+ walkFiles,
20
+ parseFrontmatter,
21
+ extractMarkdownLinks,
22
+ extractCitations,
23
+ SECRET_PATTERNS,
24
+ TEXT_EXTS,
25
+ makeReport,
26
+ } from "./_wiki-lib.mjs";
27
+
28
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
29
+ const pluginRoot = pluginRootFrom(scriptDir);
30
+
31
+ const argv = process.argv.slice(2);
32
+ const flag = name => argv.includes(name);
33
+ const opt = name => {
34
+ const i = argv.indexOf(name);
35
+ return i !== -1 ? argv[i + 1] : undefined;
36
+ };
37
+ const strict = flag("--strict");
38
+ const asJson = flag("--json");
39
+
40
+ const { config } = loadConfig(opt("--config"));
41
+ const structure = loadStructure(pluginRoot) ?? {};
42
+ const wikiRoot = path.resolve(opt("--wiki") ?? config?.wikiRoot ?? "wiki");
43
+ const report = makeReport();
44
+
45
+ if (!config) {
46
+ report.add(
47
+ "config",
48
+ "not-loaded",
49
+ "WARN",
50
+ "config not found/invalid; using structure-manifest defaults"
51
+ );
52
+ }
53
+
54
+ if (!fs.existsSync(wikiRoot)) {
55
+ console.error(`✗ wiki root not found: ${wikiRoot}`);
56
+ process.exit(1);
57
+ }
58
+
59
+ const rel = p => path.relative(process.cwd(), p);
60
+ const wrel = p => path.relative(wikiRoot, p);
61
+ const categories = config?.categories ?? structure.categoryDirs?.default ?? [];
62
+ const frontmatterRequired = config?.frontmatter !== false;
63
+
64
+ const allMd = walkFiles(wikiRoot, { ext: ".md" });
65
+ const allFiles = walkFiles(wikiRoot);
66
+ const exists = p => fs.existsSync(p);
67
+ const isUnder = (p, dir) => {
68
+ const r = path.relative(path.join(wikiRoot, dir), p);
69
+ return r !== "" && !r.startsWith("..") && !path.isAbsolute(r);
70
+ };
71
+ const isSynthesisPage = p => categories.some(c => isUnder(p, c));
72
+ const isSourceNote = p => isUnder(p, "sources");
73
+
74
+ // --- A. structure conformance ---------------------------------------------
75
+ for (const f of structure.requiredFiles ?? []) {
76
+ report.add(
77
+ "structure",
78
+ `required-file:${f}`,
79
+ exists(path.join(wikiRoot, f)) ? "PASS" : "FAIL",
80
+ exists(path.join(wikiRoot, f))
81
+ ? `present: ${f}`
82
+ : `missing required file: ${f}`
83
+ );
84
+ }
85
+ for (const d of structure.requiredDirs ?? []) {
86
+ const ok =
87
+ fs.existsSync(path.join(wikiRoot, d)) &&
88
+ fs.statSync(path.join(wikiRoot, d)).isDirectory();
89
+ report.add(
90
+ "structure",
91
+ `required-dir:${d}`,
92
+ ok ? "PASS" : "FAIL",
93
+ ok ? `present: ${d}/` : `missing required dir: ${d}/`
94
+ );
95
+ }
96
+
97
+ // --- B. frontmatter on synthesis pages + source notes ---------------------
98
+ if (frontmatterRequired) {
99
+ for (const f of allMd) {
100
+ if (!isSynthesisPage(f) && !isSourceNote(f)) continue;
101
+ const fm = parseFrontmatter(fs.readFileSync(f, "utf8"));
102
+ if (!fm.has) {
103
+ report.add("frontmatter", "missing", "WARN", `no frontmatter`, wrel(f));
104
+ } else {
105
+ const missing = ["type", "created", "updated"].filter(
106
+ k => !fm.keys.includes(k)
107
+ );
108
+ if (missing.length) {
109
+ report.add(
110
+ "frontmatter",
111
+ "incomplete",
112
+ "WARN",
113
+ `frontmatter missing keys: ${missing.join(", ")}`,
114
+ wrel(f)
115
+ );
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ // --- C/D. links: index coverage, broken links, citations ------------------
122
+ const indexPath = path.join(wikiRoot, "index.md");
123
+ const indexText = exists(indexPath) ? fs.readFileSync(indexPath, "utf8") : "";
124
+ const indexTargets = new Set(
125
+ extractMarkdownLinks(indexText).map(t => path.resolve(wikiRoot, t))
126
+ );
127
+ // dangling index links
128
+ for (const t of indexTargets) {
129
+ if (t.endsWith(".md") && !exists(t)) {
130
+ report.add(
131
+ "links",
132
+ "index-dangling",
133
+ "FAIL",
134
+ `index links to a missing page: ${wrel(t)}`,
135
+ "index.md"
136
+ );
137
+ }
138
+ }
139
+ // pages missing from index
140
+ for (const f of allMd) {
141
+ if (!isSynthesisPage(f)) continue;
142
+ if (!indexTargets.has(f)) {
143
+ report.add(
144
+ "index",
145
+ "page-missing",
146
+ "WARN",
147
+ `synthesis page not linked from index.md`,
148
+ wrel(f)
149
+ );
150
+ }
151
+ }
152
+ // broken internal links + citations across all md; build link graph for orphans.
153
+ // A link resolves if it exists relative to the file, OR as a wiki-root-relative /
154
+ // repo-root-relative / "wiki/"-prefixed form (reduces false positives).
155
+ const wikiBase = path.basename(wikiRoot);
156
+ const linkResolution = (f, target) => {
157
+ const primary = path.resolve(path.dirname(f), target);
158
+ const stripped = target
159
+ .replace(/^\/+/, "")
160
+ .replace(new RegExp(`^${wikiBase}/`), "");
161
+ const cands = [
162
+ primary,
163
+ path.resolve(wikiRoot, target),
164
+ path.resolve(wikiRoot, "..", target),
165
+ path.resolve(wikiRoot, stripped),
166
+ ];
167
+ return { primary, hit: cands.find(exists) };
168
+ };
169
+ const linkedTo = new Set();
170
+ for (const f of allMd) {
171
+ const text = fs.readFileSync(f, "utf8");
172
+ for (const target of extractMarkdownLinks(text)) {
173
+ const { primary, hit } = linkResolution(f, target);
174
+ if (target.endsWith(".md") || primary.endsWith(".md")) {
175
+ linkedTo.add(hit ?? primary);
176
+ if (!hit) {
177
+ report.add(
178
+ "links",
179
+ "broken",
180
+ "FAIL",
181
+ `broken link → ${target}`,
182
+ wrel(f)
183
+ );
184
+ }
185
+ } else if (!hit) {
186
+ report.add(
187
+ "links",
188
+ "broken-asset",
189
+ "WARN",
190
+ `link to missing path → ${target}`,
191
+ wrel(f)
192
+ );
193
+ }
194
+ }
195
+ for (const cite of extractCitations(text)) {
196
+ const candidates = [
197
+ path.resolve(wikiRoot, cite),
198
+ path.resolve(wikiRoot, "..", cite),
199
+ path.resolve(path.dirname(f), cite),
200
+ ];
201
+ if (!candidates.some(exists)) {
202
+ report.add(
203
+ "links",
204
+ "citation-unresolved",
205
+ "WARN",
206
+ `citation path not found → ${cite}`,
207
+ wrel(f)
208
+ );
209
+ }
210
+ }
211
+ }
212
+
213
+ // --- E. orphan pages ------------------------------------------------------
214
+ for (const f of allMd) {
215
+ if (!isSynthesisPage(f)) continue;
216
+ if (!indexTargets.has(f) && !linkedTo.has(f)) {
217
+ report.add(
218
+ "orphans",
219
+ "orphan",
220
+ "WARN",
221
+ `page is unreferenced (not in index, not linked)`,
222
+ wrel(f)
223
+ );
224
+ }
225
+ }
226
+
227
+ // --- F. log non-empty -----------------------------------------------------
228
+ const logPath = path.join(wikiRoot, "log.md");
229
+ if (exists(logPath)) {
230
+ // Accept the canonical table row (| YYYY-MM-DD | ...) AND the legacy heading
231
+ // formats (## YYYY-MM-DD or ## [YYYY-MM-DD]) tolerated during migration.
232
+ const rows = fs
233
+ .readFileSync(logPath, "utf8")
234
+ .split("\n")
235
+ .filter(
236
+ l =>
237
+ /^\|\s*\d{4}-\d{2}-\d{2}\s*\|/.test(l) ||
238
+ /^##\s*\[?\d{4}-\d{2}-\d{2}/.test(l)
239
+ );
240
+ report.add(
241
+ "log",
242
+ "non-empty",
243
+ rows.length > 0 ? "PASS" : "WARN",
244
+ rows.length > 0
245
+ ? `${rows.length} log entr${rows.length === 1 ? "y" : "ies"}`
246
+ : "log.md has no dated entries"
247
+ );
248
+ }
249
+
250
+ // --- G. secret + contamination + binaries ---------------------------------
251
+ const terms = (config?.contaminationTerms ?? []).filter(Boolean);
252
+ for (const f of allFiles) {
253
+ const ext = path.extname(f);
254
+ if (!TEXT_EXTS.has(ext) && path.basename(f) !== ".gitkeep") {
255
+ report.add(
256
+ "binaries",
257
+ "stray",
258
+ "WARN",
259
+ `non-text file under wiki`,
260
+ wrel(f)
261
+ );
262
+ continue;
263
+ }
264
+ let text;
265
+ try {
266
+ text = fs.readFileSync(f, "utf8");
267
+ } catch {
268
+ continue;
269
+ }
270
+ for (const { name, re } of SECRET_PATTERNS) {
271
+ if (re.test(text))
272
+ report.add("secrets", "leak", "FAIL", `possible ${name}`, wrel(f));
273
+ }
274
+ // The config file legitimately lists the contamination terms it scans for; don't flag it.
275
+ if (path.basename(f) !== "lisa-wiki.config.json") {
276
+ for (const term of terms) {
277
+ if (text.toLowerCase().includes(term.toLowerCase())) {
278
+ report.add(
279
+ "contamination",
280
+ "term",
281
+ "FAIL",
282
+ `contamination term "${term}"`,
283
+ wrel(f)
284
+ );
285
+ }
286
+ }
287
+ }
288
+ }
289
+
290
+ // --- output + verdict -----------------------------------------------------
291
+ const fails = report.items.filter(i => i.status === "FAIL");
292
+ const warns = report.items.filter(i => i.status === "WARN");
293
+ const blocking = strict ? fails.length + warns.length : fails.length;
294
+
295
+ if (asJson) {
296
+ console.log(
297
+ JSON.stringify(
298
+ {
299
+ wikiRoot: rel(wikiRoot),
300
+ strict,
301
+ fails: fails.length,
302
+ warns: warns.length,
303
+ items: report.items.filter(i => i.status !== "PASS"),
304
+ },
305
+ null,
306
+ 2
307
+ )
308
+ );
309
+ } else {
310
+ for (const i of report.items.filter(i => i.status !== "PASS")) {
311
+ console.log(
312
+ `${i.status === "FAIL" ? "✗" : "⚠"} [${i.group}] ${i.message}${i.file ? ` (${i.file})` : ""}`
313
+ );
314
+ }
315
+ console.log(
316
+ `\n${fails.length} fail, ${warns.length} warn${strict ? " (strict: warnings block)" : ""} — ${blocking === 0 ? "OK" : "BLOCKING"}`
317
+ );
318
+ }
319
+
320
+ process.exit(blocking === 0 ? 0 : 1);