@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.
- package/.claude-plugin/marketplace.json +6 -0
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +8 -0
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +32 -0
- package/plugins/lisa-wiki/ci/lisa-wiki-validate.yml +32 -0
- package/plugins/lisa-wiki/commands/add-ingest.md +6 -0
- package/plugins/lisa-wiki/commands/add-role.md +6 -0
- package/plugins/lisa-wiki/commands/doctor.md +6 -0
- package/plugins/lisa-wiki/commands/ingest.md +6 -0
- package/plugins/lisa-wiki/commands/lint.md +6 -0
- package/plugins/lisa-wiki/commands/migrate.md +6 -0
- package/plugins/lisa-wiki/commands/onboard-me.md +6 -0
- package/plugins/lisa-wiki/commands/query.md +6 -0
- package/plugins/lisa-wiki/commands/setup.md +6 -0
- package/plugins/lisa-wiki/schema/lisa-wiki-config.schema.json +118 -0
- package/plugins/lisa-wiki/schema/wiki-structure.schema.json +51 -0
- package/plugins/lisa-wiki/scripts/_wiki-lib.mjs +185 -0
- package/plugins/lisa-wiki/scripts/diff-guard.mjs +116 -0
- package/plugins/lisa-wiki/scripts/ingest-git.mjs +189 -0
- package/plugins/lisa-wiki/scripts/ingest-memory.mjs +130 -0
- package/plugins/lisa-wiki/scripts/ingest-roles.mjs +85 -0
- package/plugins/lisa-wiki/scripts/ingest_slack_channel.py +329 -0
- package/plugins/lisa-wiki/scripts/lint-wiki.mjs +320 -0
- package/plugins/lisa-wiki/scripts/mcp-doctor.mjs +72 -0
- package/plugins/lisa-wiki/scripts/render-contract.mjs +107 -0
- package/plugins/lisa-wiki/scripts/rewrite-refs.mjs +144 -0
- package/plugins/lisa-wiki/scripts/slack_oauth_user.py +179 -0
- package/plugins/lisa-wiki/scripts/validate-config.mjs +232 -0
- package/plugins/lisa-wiki/scripts/verify-migration.mjs +199 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-add-ingest/SKILL.md +34 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-add-role/SKILL.md +30 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-connector-confluence/SKILL.md +25 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-connector-docs/SKILL.md +30 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-connector-git/SKILL.md +25 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-connector-jira/SKILL.md +28 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-connector-memory/SKILL.md +28 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-connector-notion/SKILL.md +25 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-connector-roles/SKILL.md +22 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-connector-slack/SKILL.md +30 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-connector-web/SKILL.md +23 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-doctor/SKILL.md +47 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-ingest/SKILL.md +43 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-lint/SKILL.md +32 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-migrate/SKILL.md +43 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-onboard-me/SKILL.md +33 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-query/SKILL.md +30 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-setup/SKILL.md +45 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-usage/SKILL.md +50 -0
- package/plugins/lisa-wiki/templates/agents/role-agent.claude.md +16 -0
- package/plugins/lisa-wiki/templates/agents/role-agent.codex.toml +15 -0
- package/plugins/lisa-wiki/templates/index.md +17 -0
- package/plugins/lisa-wiki/templates/llm-wiki-contract.md +60 -0
- package/plugins/lisa-wiki/templates/log.md +8 -0
- package/plugins/lisa-wiki/templates/page-types/architecture.md +18 -0
- package/plugins/lisa-wiki/templates/page-types/concept.md +18 -0
- package/plugins/lisa-wiki/templates/page-types/decision.md +18 -0
- package/plugins/lisa-wiki/templates/page-types/entity.md +19 -0
- package/plugins/lisa-wiki/templates/page-types/open-question.md +18 -0
- package/plugins/lisa-wiki/templates/page-types/playbook.md +18 -0
- package/plugins/lisa-wiki/templates/page-types/project.md +19 -0
- package/plugins/lisa-wiki/templates/page-types/requirement.md +19 -0
- package/plugins/lisa-wiki/templates/page-types/staff.md +26 -0
- package/plugins/lisa-wiki/templates/start-here.md +24 -0
- package/plugins/lisa-wiki/templates/state-readme.md +20 -0
- package/plugins/src/wiki/.claude-plugin/plugin.json +6 -0
- package/plugins/src/wiki/ci/lisa-wiki-validate.yml +32 -0
- package/plugins/src/wiki/commands/add-ingest.md +6 -0
- package/plugins/src/wiki/commands/add-role.md +6 -0
- package/plugins/src/wiki/commands/doctor.md +6 -0
- package/plugins/src/wiki/commands/ingest.md +6 -0
- package/plugins/src/wiki/commands/lint.md +6 -0
- package/plugins/src/wiki/commands/migrate.md +6 -0
- package/plugins/src/wiki/commands/onboard-me.md +6 -0
- package/plugins/src/wiki/commands/query.md +6 -0
- package/plugins/src/wiki/commands/setup.md +6 -0
- package/plugins/src/wiki/schema/lisa-wiki-config.schema.json +118 -0
- package/plugins/src/wiki/schema/wiki-structure.schema.json +51 -0
- package/plugins/src/wiki/scripts/_wiki-lib.mjs +185 -0
- package/plugins/src/wiki/scripts/diff-guard.mjs +116 -0
- package/plugins/src/wiki/scripts/ingest-git.mjs +189 -0
- package/plugins/src/wiki/scripts/ingest-memory.mjs +130 -0
- package/plugins/src/wiki/scripts/ingest-roles.mjs +85 -0
- package/plugins/src/wiki/scripts/ingest_slack_channel.py +329 -0
- package/plugins/src/wiki/scripts/lint-wiki.mjs +320 -0
- package/plugins/src/wiki/scripts/mcp-doctor.mjs +72 -0
- package/plugins/src/wiki/scripts/render-contract.mjs +107 -0
- package/plugins/src/wiki/scripts/rewrite-refs.mjs +144 -0
- package/plugins/src/wiki/scripts/slack_oauth_user.py +179 -0
- package/plugins/src/wiki/scripts/validate-config.mjs +232 -0
- package/plugins/src/wiki/scripts/verify-migration.mjs +199 -0
- package/plugins/src/wiki/skills/lisa-wiki-add-ingest/SKILL.md +34 -0
- package/plugins/src/wiki/skills/lisa-wiki-add-role/SKILL.md +30 -0
- package/plugins/src/wiki/skills/lisa-wiki-connector-confluence/SKILL.md +25 -0
- package/plugins/src/wiki/skills/lisa-wiki-connector-docs/SKILL.md +30 -0
- package/plugins/src/wiki/skills/lisa-wiki-connector-git/SKILL.md +25 -0
- package/plugins/src/wiki/skills/lisa-wiki-connector-jira/SKILL.md +28 -0
- package/plugins/src/wiki/skills/lisa-wiki-connector-memory/SKILL.md +28 -0
- package/plugins/src/wiki/skills/lisa-wiki-connector-notion/SKILL.md +25 -0
- package/plugins/src/wiki/skills/lisa-wiki-connector-roles/SKILL.md +22 -0
- package/plugins/src/wiki/skills/lisa-wiki-connector-slack/SKILL.md +30 -0
- package/plugins/src/wiki/skills/lisa-wiki-connector-web/SKILL.md +23 -0
- package/plugins/src/wiki/skills/lisa-wiki-doctor/SKILL.md +47 -0
- package/plugins/src/wiki/skills/lisa-wiki-ingest/SKILL.md +43 -0
- package/plugins/src/wiki/skills/lisa-wiki-lint/SKILL.md +32 -0
- package/plugins/src/wiki/skills/lisa-wiki-migrate/SKILL.md +43 -0
- package/plugins/src/wiki/skills/lisa-wiki-onboard-me/SKILL.md +33 -0
- package/plugins/src/wiki/skills/lisa-wiki-query/SKILL.md +30 -0
- package/plugins/src/wiki/skills/lisa-wiki-setup/SKILL.md +45 -0
- package/plugins/src/wiki/skills/lisa-wiki-usage/SKILL.md +50 -0
- package/plugins/src/wiki/templates/agents/role-agent.claude.md +16 -0
- package/plugins/src/wiki/templates/agents/role-agent.codex.toml +15 -0
- package/plugins/src/wiki/templates/index.md +17 -0
- package/plugins/src/wiki/templates/llm-wiki-contract.md +60 -0
- package/plugins/src/wiki/templates/log.md +8 -0
- package/plugins/src/wiki/templates/page-types/architecture.md +18 -0
- package/plugins/src/wiki/templates/page-types/concept.md +18 -0
- package/plugins/src/wiki/templates/page-types/decision.md +18 -0
- package/plugins/src/wiki/templates/page-types/entity.md +19 -0
- package/plugins/src/wiki/templates/page-types/open-question.md +18 -0
- package/plugins/src/wiki/templates/page-types/playbook.md +18 -0
- package/plugins/src/wiki/templates/page-types/project.md +19 -0
- package/plugins/src/wiki/templates/page-types/requirement.md +19 -0
- package/plugins/src/wiki/templates/page-types/staff.md +26 -0
- package/plugins/src/wiki/templates/start-here.md +24 -0
- package/plugins/src/wiki/templates/state-readme.md +20 -0
- package/scripts/build-plugins.sh +29 -21
- package/scripts/check-plugins-sync.sh +1 -1
- 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);
|