@codyswann/lisa 2.24.0 → 2.25.1

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 (154) hide show
  1. package/.claude-plugin/marketplace.json +12 -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/rules/config-resolution.md +32 -1
  6. package/plugins/lisa/skills/atlassian-access/SKILL.md +32 -1
  7. package/plugins/lisa/skills/notion-access/SKILL.md +32 -1
  8. package/plugins/lisa/skills/setup-atlassian/SKILL.md +32 -1
  9. package/plugins/lisa/skills/setup-linear/SKILL.md +32 -1
  10. package/plugins/lisa/skills/setup-notion/SKILL.md +32 -1
  11. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  21. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  23. package/plugins/lisa-wiki/.claude-plugin/plugin.json +8 -0
  24. package/plugins/lisa-wiki/.codex-plugin/plugin.json +32 -0
  25. package/plugins/lisa-wiki/ci/lisa-wiki-validate.yml +32 -0
  26. package/plugins/lisa-wiki/commands/add-ingest.md +6 -0
  27. package/plugins/lisa-wiki/commands/add-role.md +6 -0
  28. package/plugins/lisa-wiki/commands/doctor.md +6 -0
  29. package/plugins/lisa-wiki/commands/ingest.md +6 -0
  30. package/plugins/lisa-wiki/commands/lint.md +6 -0
  31. package/plugins/lisa-wiki/commands/migrate.md +6 -0
  32. package/plugins/lisa-wiki/commands/onboard-me.md +6 -0
  33. package/plugins/lisa-wiki/commands/query.md +6 -0
  34. package/plugins/lisa-wiki/commands/setup.md +6 -0
  35. package/plugins/lisa-wiki/schema/lisa-wiki-config.schema.json +118 -0
  36. package/plugins/lisa-wiki/schema/wiki-structure.schema.json +51 -0
  37. package/plugins/lisa-wiki/scripts/_wiki-lib.mjs +185 -0
  38. package/plugins/lisa-wiki/scripts/diff-guard.mjs +116 -0
  39. package/plugins/lisa-wiki/scripts/ingest-git.mjs +189 -0
  40. package/plugins/lisa-wiki/scripts/ingest-memory.mjs +130 -0
  41. package/plugins/lisa-wiki/scripts/ingest-roles.mjs +85 -0
  42. package/plugins/lisa-wiki/scripts/ingest_slack_channel.py +329 -0
  43. package/plugins/lisa-wiki/scripts/lint-wiki.mjs +320 -0
  44. package/plugins/lisa-wiki/scripts/mcp-doctor.mjs +72 -0
  45. package/plugins/lisa-wiki/scripts/render-contract.mjs +107 -0
  46. package/plugins/lisa-wiki/scripts/rewrite-refs.mjs +144 -0
  47. package/plugins/lisa-wiki/scripts/slack_oauth_user.py +179 -0
  48. package/plugins/lisa-wiki/scripts/validate-config.mjs +232 -0
  49. package/plugins/lisa-wiki/scripts/verify-migration.mjs +199 -0
  50. package/plugins/lisa-wiki/skills/lisa-wiki-add-ingest/SKILL.md +34 -0
  51. package/plugins/lisa-wiki/skills/lisa-wiki-add-role/SKILL.md +30 -0
  52. package/plugins/lisa-wiki/skills/lisa-wiki-connector-confluence/SKILL.md +25 -0
  53. package/plugins/lisa-wiki/skills/lisa-wiki-connector-docs/SKILL.md +30 -0
  54. package/plugins/lisa-wiki/skills/lisa-wiki-connector-git/SKILL.md +25 -0
  55. package/plugins/lisa-wiki/skills/lisa-wiki-connector-jira/SKILL.md +28 -0
  56. package/plugins/lisa-wiki/skills/lisa-wiki-connector-memory/SKILL.md +28 -0
  57. package/plugins/lisa-wiki/skills/lisa-wiki-connector-notion/SKILL.md +25 -0
  58. package/plugins/lisa-wiki/skills/lisa-wiki-connector-roles/SKILL.md +22 -0
  59. package/plugins/lisa-wiki/skills/lisa-wiki-connector-slack/SKILL.md +30 -0
  60. package/plugins/lisa-wiki/skills/lisa-wiki-connector-web/SKILL.md +23 -0
  61. package/plugins/lisa-wiki/skills/lisa-wiki-doctor/SKILL.md +47 -0
  62. package/plugins/lisa-wiki/skills/lisa-wiki-ingest/SKILL.md +43 -0
  63. package/plugins/lisa-wiki/skills/lisa-wiki-lint/SKILL.md +32 -0
  64. package/plugins/lisa-wiki/skills/lisa-wiki-migrate/SKILL.md +43 -0
  65. package/plugins/lisa-wiki/skills/lisa-wiki-onboard-me/SKILL.md +33 -0
  66. package/plugins/lisa-wiki/skills/lisa-wiki-query/SKILL.md +30 -0
  67. package/plugins/lisa-wiki/skills/lisa-wiki-setup/SKILL.md +45 -0
  68. package/plugins/lisa-wiki/skills/lisa-wiki-usage/SKILL.md +50 -0
  69. package/plugins/lisa-wiki/templates/agents/role-agent.claude.md +16 -0
  70. package/plugins/lisa-wiki/templates/agents/role-agent.codex.toml +15 -0
  71. package/plugins/lisa-wiki/templates/index.md +17 -0
  72. package/plugins/lisa-wiki/templates/llm-wiki-contract.md +60 -0
  73. package/plugins/lisa-wiki/templates/log.md +8 -0
  74. package/plugins/lisa-wiki/templates/page-types/architecture.md +18 -0
  75. package/plugins/lisa-wiki/templates/page-types/concept.md +18 -0
  76. package/plugins/lisa-wiki/templates/page-types/decision.md +18 -0
  77. package/plugins/lisa-wiki/templates/page-types/entity.md +19 -0
  78. package/plugins/lisa-wiki/templates/page-types/open-question.md +18 -0
  79. package/plugins/lisa-wiki/templates/page-types/playbook.md +18 -0
  80. package/plugins/lisa-wiki/templates/page-types/project.md +19 -0
  81. package/plugins/lisa-wiki/templates/page-types/requirement.md +19 -0
  82. package/plugins/lisa-wiki/templates/page-types/staff.md +26 -0
  83. package/plugins/lisa-wiki/templates/start-here.md +24 -0
  84. package/plugins/lisa-wiki/templates/state-readme.md +20 -0
  85. package/plugins/src/base/rules/config-resolution.md +32 -1
  86. package/plugins/src/base/skills/atlassian-access/SKILL.md +32 -1
  87. package/plugins/src/base/skills/notion-access/SKILL.md +32 -1
  88. package/plugins/src/base/skills/setup-atlassian/SKILL.md +32 -1
  89. package/plugins/src/base/skills/setup-linear/SKILL.md +32 -1
  90. package/plugins/src/base/skills/setup-notion/SKILL.md +32 -1
  91. package/plugins/src/wiki/.claude-plugin/plugin.json +6 -0
  92. package/plugins/src/wiki/ci/lisa-wiki-validate.yml +32 -0
  93. package/plugins/src/wiki/commands/add-ingest.md +6 -0
  94. package/plugins/src/wiki/commands/add-role.md +6 -0
  95. package/plugins/src/wiki/commands/doctor.md +6 -0
  96. package/plugins/src/wiki/commands/ingest.md +6 -0
  97. package/plugins/src/wiki/commands/lint.md +6 -0
  98. package/plugins/src/wiki/commands/migrate.md +6 -0
  99. package/plugins/src/wiki/commands/onboard-me.md +6 -0
  100. package/plugins/src/wiki/commands/query.md +6 -0
  101. package/plugins/src/wiki/commands/setup.md +6 -0
  102. package/plugins/src/wiki/schema/lisa-wiki-config.schema.json +118 -0
  103. package/plugins/src/wiki/schema/wiki-structure.schema.json +51 -0
  104. package/plugins/src/wiki/scripts/_wiki-lib.mjs +185 -0
  105. package/plugins/src/wiki/scripts/diff-guard.mjs +116 -0
  106. package/plugins/src/wiki/scripts/ingest-git.mjs +189 -0
  107. package/plugins/src/wiki/scripts/ingest-memory.mjs +130 -0
  108. package/plugins/src/wiki/scripts/ingest-roles.mjs +85 -0
  109. package/plugins/src/wiki/scripts/ingest_slack_channel.py +329 -0
  110. package/plugins/src/wiki/scripts/lint-wiki.mjs +320 -0
  111. package/plugins/src/wiki/scripts/mcp-doctor.mjs +72 -0
  112. package/plugins/src/wiki/scripts/render-contract.mjs +107 -0
  113. package/plugins/src/wiki/scripts/rewrite-refs.mjs +144 -0
  114. package/plugins/src/wiki/scripts/slack_oauth_user.py +179 -0
  115. package/plugins/src/wiki/scripts/validate-config.mjs +232 -0
  116. package/plugins/src/wiki/scripts/verify-migration.mjs +199 -0
  117. package/plugins/src/wiki/skills/lisa-wiki-add-ingest/SKILL.md +34 -0
  118. package/plugins/src/wiki/skills/lisa-wiki-add-role/SKILL.md +30 -0
  119. package/plugins/src/wiki/skills/lisa-wiki-connector-confluence/SKILL.md +25 -0
  120. package/plugins/src/wiki/skills/lisa-wiki-connector-docs/SKILL.md +30 -0
  121. package/plugins/src/wiki/skills/lisa-wiki-connector-git/SKILL.md +25 -0
  122. package/plugins/src/wiki/skills/lisa-wiki-connector-jira/SKILL.md +28 -0
  123. package/plugins/src/wiki/skills/lisa-wiki-connector-memory/SKILL.md +28 -0
  124. package/plugins/src/wiki/skills/lisa-wiki-connector-notion/SKILL.md +25 -0
  125. package/plugins/src/wiki/skills/lisa-wiki-connector-roles/SKILL.md +22 -0
  126. package/plugins/src/wiki/skills/lisa-wiki-connector-slack/SKILL.md +30 -0
  127. package/plugins/src/wiki/skills/lisa-wiki-connector-web/SKILL.md +23 -0
  128. package/plugins/src/wiki/skills/lisa-wiki-doctor/SKILL.md +47 -0
  129. package/plugins/src/wiki/skills/lisa-wiki-ingest/SKILL.md +43 -0
  130. package/plugins/src/wiki/skills/lisa-wiki-lint/SKILL.md +32 -0
  131. package/plugins/src/wiki/skills/lisa-wiki-migrate/SKILL.md +43 -0
  132. package/plugins/src/wiki/skills/lisa-wiki-onboard-me/SKILL.md +33 -0
  133. package/plugins/src/wiki/skills/lisa-wiki-query/SKILL.md +30 -0
  134. package/plugins/src/wiki/skills/lisa-wiki-setup/SKILL.md +45 -0
  135. package/plugins/src/wiki/skills/lisa-wiki-usage/SKILL.md +50 -0
  136. package/plugins/src/wiki/templates/agents/role-agent.claude.md +16 -0
  137. package/plugins/src/wiki/templates/agents/role-agent.codex.toml +15 -0
  138. package/plugins/src/wiki/templates/index.md +17 -0
  139. package/plugins/src/wiki/templates/llm-wiki-contract.md +60 -0
  140. package/plugins/src/wiki/templates/log.md +8 -0
  141. package/plugins/src/wiki/templates/page-types/architecture.md +18 -0
  142. package/plugins/src/wiki/templates/page-types/concept.md +18 -0
  143. package/plugins/src/wiki/templates/page-types/decision.md +18 -0
  144. package/plugins/src/wiki/templates/page-types/entity.md +19 -0
  145. package/plugins/src/wiki/templates/page-types/open-question.md +18 -0
  146. package/plugins/src/wiki/templates/page-types/playbook.md +18 -0
  147. package/plugins/src/wiki/templates/page-types/project.md +19 -0
  148. package/plugins/src/wiki/templates/page-types/requirement.md +19 -0
  149. package/plugins/src/wiki/templates/page-types/staff.md +26 -0
  150. package/plugins/src/wiki/templates/start-here.md +24 -0
  151. package/plugins/src/wiki/templates/state-readme.md +20 -0
  152. package/scripts/build-plugins.sh +29 -21
  153. package/scripts/check-plugins-sync.sh +38 -1
  154. package/scripts/generate-codex-plugin-artifacts.mjs +22 -0
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ingest-memory.mjs — PROJECT-SCOPED memory connector. Dependency-free.
4
+ *
5
+ * Ingests ONLY a project's own persisted memory into a sanitized source note.
6
+ * NEVER ingests global/unrelated memory: global Codex memory (~/.codex/memories) and
7
+ * the Codex Chronicle store are hard-refused. Claude per-project memory is inherently
8
+ * project-scoped; Codex memory is accepted only via an explicit project-scoped path
9
+ * (e.g. a per-project CODEX_HOME). Emits a proposed cursor; the kernel advances state.
10
+ *
11
+ * Usage:
12
+ * node ingest-memory.mjs --memory-dir <dir> [--config <p>] [--source-dir <dir>]
13
+ * [--state <file>] [--emit-meta <file>]
14
+ */
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import os from "node:os";
18
+ import { loadConfig, walkFiles, SECRET_PATTERNS } from "./_wiki-lib.mjs";
19
+
20
+ const argv = process.argv.slice(2);
21
+ const opt = (n, d) => {
22
+ const i = argv.indexOf(n);
23
+ return i !== -1 ? argv[i + 1] : d;
24
+ };
25
+ const memoryDir = opt("--memory-dir");
26
+ const sourceDir = path.resolve(opt("--source-dir", "wiki/sources/memory"));
27
+ const emitMeta = opt("--emit-meta");
28
+
29
+ const fail = m => {
30
+ console.error(`✗ ${m}`);
31
+ process.exit(1);
32
+ };
33
+ if (!memoryDir)
34
+ fail("--memory-dir is required (the PROJECT-SCOPED memory directory)");
35
+ const resolvedMem = path.resolve(memoryDir);
36
+
37
+ // Hard refusal of global / unrelated memory stores.
38
+ const globalCodex = path.resolve(os.homedir(), ".codex", "memories");
39
+ const chronicle = path.resolve(os.homedir(), ".codex", "memories_extensions");
40
+ if (
41
+ resolvedMem === globalCodex ||
42
+ resolvedMem.startsWith(globalCodex + path.sep) ||
43
+ resolvedMem.startsWith(chronicle + path.sep)
44
+ ) {
45
+ fail(
46
+ `refusing global Codex memory (${resolvedMem}). Memory ingestion is project-scoped only — never global/unrelated.`
47
+ );
48
+ }
49
+ if (!fs.existsSync(resolvedMem)) fail(`memory dir not found: ${resolvedMem}`);
50
+
51
+ // Prove the directory is PROJECT-scoped (don't just trust the caller): accept only the
52
+ // Claude per-project memory dir for THIS repo, a per-project CODEX_HOME memories dir, or a
53
+ // config-declared allowlist. Otherwise refuse — it could be another project's memory.
54
+ const repo = path.resolve(opt("--repo", "."));
55
+ const { config } = loadConfig(opt("--config"));
56
+ const allowedRoots = (config?.memory?.allowedRoots ?? []).map(p =>
57
+ path.resolve(p)
58
+ );
59
+ const claudeMem = path.resolve(
60
+ os.homedir(),
61
+ ".claude",
62
+ "projects",
63
+ repo.split(path.sep).join("-"),
64
+ "memory"
65
+ );
66
+ const codexHome = process.env.CODEX_HOME
67
+ ? path.resolve(process.env.CODEX_HOME)
68
+ : null;
69
+ const projectCodexMem =
70
+ codexHome && codexHome !== path.resolve(os.homedir(), ".codex")
71
+ ? path.join(codexHome, "memories")
72
+ : null;
73
+ const under = root =>
74
+ Boolean(root) &&
75
+ (resolvedMem === root || resolvedMem.startsWith(root + path.sep));
76
+ if (!(under(claudeMem) || under(projectCodexMem) || allowedRoots.some(under))) {
77
+ fail(
78
+ `--memory-dir is not provably project-scoped for repo ${repo}. Expected the Claude per-project dir (${claudeMem}), a per-project CODEX_HOME memories dir, or config.memory.allowedRoots. Refusing possibly-unrelated memory.`
79
+ );
80
+ }
81
+
82
+ const redact = t =>
83
+ SECRET_PATTERNS.reduce(
84
+ (acc, { re }) => acc.replace(new RegExp(re, "g"), "[REDACTED]"),
85
+ t
86
+ );
87
+ const mdFiles = walkFiles(resolvedMem, { ext: ".md" });
88
+ const date = new Date().toISOString().slice(0, 10);
89
+ const entries = mdFiles.map(f => {
90
+ const body = redact(fs.readFileSync(f, "utf8")).trim();
91
+ return `### ${path.basename(f)}\n\n${body}`;
92
+ });
93
+
94
+ const notePath = path.join(sourceDir, `${date}-memory.md`);
95
+ const note = `---
96
+ type: source
97
+ created: ${date}
98
+ updated: ${date}
99
+ related: []
100
+ sources: []
101
+ source_system: memory
102
+ sensitivity: internal
103
+ ---
104
+
105
+ # project-scoped memory (${date})
106
+
107
+ - Source: \`${resolvedMem}\` (project-scoped; global/Chronicle memory is never ingested)
108
+ - Files: ${mdFiles.length}
109
+
110
+ ${entries.join("\n\n") || "_(no memory files)_"}
111
+ `;
112
+
113
+ fs.mkdirSync(sourceDir, { recursive: true });
114
+ fs.writeFileSync(notePath, note);
115
+
116
+ const meta = {
117
+ connector: "memory",
118
+ profile: "project",
119
+ ranAt: new Date().toISOString(),
120
+ proposedCursor: { files: mdFiles.length, lastIngest: date },
121
+ sourceNotes: [path.relative(process.cwd(), notePath)],
122
+ };
123
+ if (emitMeta) {
124
+ fs.mkdirSync(path.dirname(emitMeta), { recursive: true });
125
+ fs.writeFileSync(emitMeta, `${JSON.stringify(meta, null, 2)}\n`);
126
+ }
127
+
128
+ console.log(
129
+ `✓ memory connector: ${mdFiles.length} file(s) → ${path.relative(process.cwd(), notePath)}`
130
+ );
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ingest-roles.mjs — roles connector. Dependency-free. Ingests the wiki's own staff
4
+ * roster (config.staff[] + wiki/staff/*.md) into a sanitized source note. Emits a
5
+ * proposed cursor; the kernel advances state. Does NOT run any subagent.
6
+ *
7
+ * Usage:
8
+ * node ingest-roles.mjs [--config <p>] [--wiki <root>] [--source-dir <dir>]
9
+ * [--state <file>] [--emit-meta <file>]
10
+ */
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { loadConfig, walkFiles } from "./_wiki-lib.mjs";
14
+
15
+ const argv = process.argv.slice(2);
16
+ const opt = (n, d) => {
17
+ const i = argv.indexOf(n);
18
+ return i !== -1 ? argv[i + 1] : d;
19
+ };
20
+ const { config } = loadConfig(opt("--config"));
21
+ const wikiRoot = path.resolve(opt("--wiki", config?.wikiRoot ?? "wiki"));
22
+ const sourceDir = path.resolve(
23
+ opt("--source-dir", path.join(wikiRoot, "sources", "roles"))
24
+ );
25
+ const emitMeta = opt("--emit-meta");
26
+
27
+ const staff = Array.isArray(config?.staff) ? config.staff : [];
28
+ const staffDir = path.join(wikiRoot, "staff");
29
+ const staffPages = fs.existsSync(staffDir)
30
+ ? walkFiles(staffDir, { ext: ".md" })
31
+ : [];
32
+ const date = new Date().toISOString().slice(0, 10);
33
+
34
+ const rosterRows = staff.length
35
+ ? staff
36
+ .map(
37
+ s =>
38
+ `- **${s.role}** (\`${s.id}\`)${s.expertise ? ` — ${s.expertise}` : ""}${s.owns?.categories ? ` · owns: ${s.owns.categories.join(", ")}` : ""}`
39
+ )
40
+ .join("\n")
41
+ : "_(no roles declared in config.staff[])_";
42
+
43
+ const notePath = path.join(sourceDir, `${date}-roles.md`);
44
+ const note = `---
45
+ type: source
46
+ created: ${date}
47
+ updated: ${date}
48
+ related: []
49
+ sources: []
50
+ source_system: roles
51
+ ---
52
+
53
+ # digital staff roster (${date})
54
+
55
+ Declared roles: ${staff.length}; staff doc pages: ${staffPages.length}.
56
+
57
+ ## Roles
58
+ ${rosterRows}
59
+
60
+ ## Staff pages
61
+ ${staffPages.length ? staffPages.map(p => `- \`${path.relative(wikiRoot, p)}\``).join("\n") : "_(none)_"}
62
+ `;
63
+
64
+ fs.mkdirSync(sourceDir, { recursive: true });
65
+ fs.writeFileSync(notePath, note);
66
+
67
+ const meta = {
68
+ connector: "roles",
69
+ profile: "project",
70
+ ranAt: new Date().toISOString(),
71
+ proposedCursor: {
72
+ roles: staff.length,
73
+ pages: staffPages.length,
74
+ lastIngest: date,
75
+ },
76
+ sourceNotes: [path.relative(process.cwd(), notePath)],
77
+ };
78
+ if (emitMeta) {
79
+ fs.mkdirSync(path.dirname(emitMeta), { recursive: true });
80
+ fs.writeFileSync(emitMeta, `${JSON.stringify(meta, null, 2)}\n`);
81
+ }
82
+
83
+ console.log(
84
+ `✓ roles connector: ${staff.length} role(s), ${staffPages.length} page(s) → ${path.relative(process.cwd(), notePath)}`
85
+ );
@@ -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())