@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.
- package/.claude-plugin/marketplace.json +12 -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/rules/config-resolution.md +32 -1
- package/plugins/lisa/skills/atlassian-access/SKILL.md +32 -1
- package/plugins/lisa/skills/notion-access/SKILL.md +32 -1
- package/plugins/lisa/skills/setup-atlassian/SKILL.md +32 -1
- package/plugins/lisa/skills/setup-linear/SKILL.md +32 -1
- package/plugins/lisa/skills/setup-notion/SKILL.md +32 -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/base/rules/config-resolution.md +32 -1
- package/plugins/src/base/skills/atlassian-access/SKILL.md +32 -1
- package/plugins/src/base/skills/notion-access/SKILL.md +32 -1
- package/plugins/src/base/skills/setup-atlassian/SKILL.md +32 -1
- package/plugins/src/base/skills/setup-linear/SKILL.md +32 -1
- package/plugins/src/base/skills/setup-notion/SKILL.md +32 -1
- 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 +38 -1
- 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())
|