@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,179 @@
1
+ #!/usr/bin/env python3
2
+ """Run a local Slack OAuth flow for a user token.
3
+
4
+ This helper intentionally requests user scopes, not bot scopes. It stores the
5
+ OAuth response in an ignored local file by default; do not commit that file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import base64
12
+ import json
13
+ import os
14
+ import secrets
15
+ import stat
16
+ import sys
17
+ import urllib.parse
18
+ import urllib.request
19
+ import webbrowser
20
+ from http.server import BaseHTTPRequestHandler, HTTPServer
21
+ from pathlib import Path
22
+
23
+
24
+ DEFAULT_SCOPES = ",".join(
25
+ [
26
+ "channels:read",
27
+ "channels:history",
28
+ "groups:read",
29
+ "groups:history",
30
+ "users:read",
31
+ "files:read",
32
+ ]
33
+ )
34
+
35
+
36
+ class OAuthHandler(BaseHTTPRequestHandler):
37
+ server: "OAuthServer"
38
+
39
+ def log_message(self, fmt: str, *args: object) -> None:
40
+ return
41
+
42
+ def do_GET(self) -> None: # noqa: N802
43
+ parsed = urllib.parse.urlparse(self.path)
44
+ params = urllib.parse.parse_qs(parsed.query)
45
+ state = params.get("state", [""])[0]
46
+ code = params.get("code", [""])[0]
47
+ error = params.get("error", [""])[0]
48
+
49
+ if error:
50
+ self.server.error = error
51
+ self._respond(400, f"Slack OAuth failed: {error}")
52
+ return
53
+
54
+ if state != self.server.expected_state:
55
+ self.server.error = "state_mismatch"
56
+ self._respond(400, "Slack OAuth failed: state mismatch.")
57
+ return
58
+
59
+ if not code:
60
+ self.server.error = "missing_code"
61
+ self._respond(400, "Slack OAuth failed: missing code.")
62
+ return
63
+
64
+ self.server.code = code
65
+ self._respond(200, "Slack OAuth complete. You can return to the terminal.")
66
+
67
+ def _respond(self, status: int, body: str) -> None:
68
+ data = body.encode("utf-8")
69
+ self.send_response(status)
70
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
71
+ self.send_header("Content-Length", str(len(data)))
72
+ self.end_headers()
73
+ self.wfile.write(data)
74
+
75
+
76
+ class OAuthServer(HTTPServer):
77
+ expected_state: str
78
+ code: str | None
79
+ error: str | None
80
+
81
+
82
+ def exchange_code(
83
+ *,
84
+ client_id: str,
85
+ client_secret: str,
86
+ code: str,
87
+ redirect_uri: str,
88
+ ) -> dict:
89
+ body = urllib.parse.urlencode(
90
+ {
91
+ "grant_type": "authorization_code",
92
+ "code": code,
93
+ "redirect_uri": redirect_uri,
94
+ }
95
+ ).encode("utf-8")
96
+ basic = base64.b64encode(f"{client_id}:{client_secret}".encode("utf-8")).decode("ascii")
97
+ request = urllib.request.Request(
98
+ "https://slack.com/api/oauth.v2.access",
99
+ data=body,
100
+ headers={
101
+ "Authorization": f"Basic {basic}",
102
+ "Content-Type": "application/x-www-form-urlencoded",
103
+ "Accept": "application/json",
104
+ },
105
+ method="POST",
106
+ )
107
+ with urllib.request.urlopen(request, timeout=30) as response:
108
+ payload = json.loads(response.read().decode("utf-8"))
109
+ if not payload.get("ok"):
110
+ raise RuntimeError(f"Slack token exchange failed: {payload}")
111
+ return payload
112
+
113
+
114
+ def main() -> int:
115
+ parser = argparse.ArgumentParser(description=__doc__)
116
+ parser.add_argument("--client-id", default=os.environ.get("SLACK_CLIENT_ID"))
117
+ parser.add_argument("--client-secret", default=os.environ.get("SLACK_CLIENT_SECRET"))
118
+ parser.add_argument("--redirect-uri", default="http://localhost:8765/slack/oauth/callback")
119
+ parser.add_argument("--scopes", default=os.environ.get("SLACK_USER_SCOPES", DEFAULT_SCOPES))
120
+ parser.add_argument("--output", default=".secrets/slack-user-token.json")
121
+ parser.add_argument("--no-open", action="store_true", help="Print the URL without opening a browser.")
122
+ args = parser.parse_args()
123
+
124
+ if not args.client_id or not args.client_secret:
125
+ parser.error("Provide --client-id/--client-secret or SLACK_CLIENT_ID/SLACK_CLIENT_SECRET.")
126
+
127
+ redirect = urllib.parse.urlparse(args.redirect_uri)
128
+ if redirect.hostname not in {"localhost", "127.0.0.1"}:
129
+ parser.error("This helper only starts a localhost callback server.")
130
+
131
+ state = secrets.token_urlsafe(32)
132
+ query = urllib.parse.urlencode(
133
+ {
134
+ "client_id": args.client_id,
135
+ "user_scope": args.scopes,
136
+ "redirect_uri": args.redirect_uri,
137
+ "state": state,
138
+ }
139
+ )
140
+ authorize_url = f"https://slack.com/oauth/v2/authorize?{query}"
141
+
142
+ server = OAuthServer((redirect.hostname or "localhost", redirect.port or 80), OAuthHandler)
143
+ server.expected_state = state
144
+ server.code = None
145
+ server.error = None
146
+
147
+ print("Open this URL to authorize Slack user-token access:")
148
+ print(authorize_url)
149
+ if not args.no_open:
150
+ webbrowser.open(authorize_url)
151
+
152
+ while server.code is None and server.error is None:
153
+ server.handle_request()
154
+
155
+ if server.error:
156
+ print(f"OAuth failed: {server.error}", file=sys.stderr)
157
+ return 1
158
+
159
+ payload = exchange_code(
160
+ client_id=args.client_id,
161
+ client_secret=args.client_secret,
162
+ code=server.code or "",
163
+ redirect_uri=args.redirect_uri,
164
+ )
165
+
166
+ output = Path(args.output)
167
+ output.parent.mkdir(parents=True, exist_ok=True)
168
+ output.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
169
+ output.chmod(stat.S_IRUSR | stat.S_IWUSR)
170
+
171
+ team = payload.get("team") or {}
172
+ print(f"Saved Slack OAuth response to {output}")
173
+ print(f"Team: {team.get('name') or team.get('id') or 'unknown'}")
174
+ print("Use it with: python3 scripts/ingest_slack_channel.py --token-file " + str(output) + " --channel '#channel-name'")
175
+ return 0
176
+
177
+
178
+ if __name__ == "__main__":
179
+ raise SystemExit(main())
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * validate-config.mjs — dependency-free validator for wiki/lisa-wiki.config.json.
4
+ *
5
+ * Enforces the constraints described in schema/lisa-wiki-config.schema.json without
6
+ * requiring a JSON-Schema runtime, so it is portable to any downstream repo that
7
+ * installs the lisa-wiki plugin (no ajv / node_modules assumptions).
8
+ *
9
+ * Usage: node validate-config.mjs [path-to-config]
10
+ * default path: wiki/lisa-wiki.config.json (relative to cwd)
11
+ * Exit code 0 = valid, 1 = invalid or unreadable.
12
+ */
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+
16
+ const MODES = ["embedded", "wrapper", "standalone", "subdir"];
17
+ const SIDE_EFFECTS = ["read-only-ingest", "repo-write", "external-write"];
18
+ const RETENTION = [
19
+ "raw-ok",
20
+ "sanitized-note-only",
21
+ "metadata-only",
22
+ "external-pointer-only",
23
+ ];
24
+ const SENSITIVITY = ["public", "internal", "confidential", "restricted"];
25
+ const SOURCE_LAYOUT = ["by-system", "by-category"];
26
+ const README_MODE = ["rich", "stub", "preserve"];
27
+
28
+ const configPath = path.resolve(
29
+ process.argv[2] ?? "wiki/lisa-wiki.config.json"
30
+ );
31
+ const errors = [];
32
+ const err = msg => errors.push(msg);
33
+
34
+ function isObject(v) {
35
+ return typeof v === "object" && v !== null && !Array.isArray(v);
36
+ }
37
+ function isStringArray(v) {
38
+ return Array.isArray(v) && v.every(x => typeof x === "string");
39
+ }
40
+ function checkEnum(value, allowed, label) {
41
+ if (value !== undefined && !allowed.includes(value)) {
42
+ err(`${label}: "${String(value)}" is not one of ${allowed.join(" | ")}`);
43
+ }
44
+ }
45
+ function checkType(value, type, label) {
46
+ if (value !== undefined && typeof value !== type) {
47
+ err(
48
+ `${label}: expected ${type}, got ${Array.isArray(value) ? "array" : typeof value}`
49
+ );
50
+ }
51
+ }
52
+
53
+ if (!fs.existsSync(configPath)) {
54
+ console.error(`✗ config not found: ${configPath}`);
55
+ process.exit(1);
56
+ }
57
+
58
+ let config;
59
+ try {
60
+ config = JSON.parse(fs.readFileSync(configPath, "utf8"));
61
+ } catch (e) {
62
+ console.error(`✗ config is not valid JSON: ${e.message}`);
63
+ process.exit(1);
64
+ }
65
+
66
+ if (!isObject(config)) {
67
+ console.error("✗ config must be a JSON object");
68
+ process.exit(1);
69
+ }
70
+
71
+ // Required
72
+ for (const key of ["schemaVersion", "org", "mode", "wikiRoot", "categories"]) {
73
+ if (config[key] === undefined) err(`missing required field: ${key}`);
74
+ }
75
+ checkType(config.schemaVersion, "string", "schemaVersion");
76
+ checkType(config.org, "string", "org");
77
+ checkType(config.displayName, "string", "displayName");
78
+ checkType(config.purpose, "string", "purpose");
79
+ checkEnum(config.mode, MODES, "mode");
80
+ checkType(config.wikiRoot, "string", "wikiRoot");
81
+ if (
82
+ typeof config.wikiRoot === "string" &&
83
+ (path.isAbsolute(config.wikiRoot) ||
84
+ config.wikiRoot.split(/[\\/]/).includes(".."))
85
+ ) {
86
+ err(
87
+ 'wikiRoot: must be a relative path inside the repo (no absolute paths, no ".." traversal)'
88
+ );
89
+ }
90
+ checkType(config.frontmatter, "boolean", "frontmatter");
91
+ if (
92
+ config.categories !== undefined &&
93
+ !(isStringArray(config.categories) && config.categories.length > 0)
94
+ ) {
95
+ err("categories: must be a non-empty array of strings");
96
+ }
97
+ checkEnum(config.sourceRetention, RETENTION, "sourceRetention");
98
+ checkType(config.contaminationTerms, "object", "contaminationTerms");
99
+ if (
100
+ config.contaminationTerms !== undefined &&
101
+ !isStringArray(config.contaminationTerms)
102
+ ) {
103
+ err("contaminationTerms: must be an array of strings");
104
+ }
105
+
106
+ if (config.sources !== undefined) {
107
+ if (!isObject(config.sources)) err("sources: must be an object");
108
+ else checkEnum(config.sources.layout, SOURCE_LAYOUT, "sources.layout");
109
+ }
110
+ if (config.git !== undefined) {
111
+ if (!isObject(config.git)) err("git: must be an object");
112
+ else {
113
+ checkType(config.git.prPerIngestion, "boolean", "git.prPerIngestion");
114
+ checkType(config.git.autoMerge, "boolean", "git.autoMerge");
115
+ checkType(config.git.targetBranch, "string", "git.targetBranch");
116
+ checkType(config.git.branchPrefix, "string", "git.branchPrefix");
117
+ }
118
+ }
119
+ if (config.readme !== undefined) {
120
+ if (!isObject(config.readme)) err("readme: must be an object");
121
+ else checkEnum(config.readme.mode, README_MODE, "readme.mode");
122
+ }
123
+ if (config.sensitivity !== undefined) {
124
+ if (!isObject(config.sensitivity)) err("sensitivity: must be an object");
125
+ else {
126
+ checkType(config.sensitivity.enabled, "boolean", "sensitivity.enabled");
127
+ checkEnum(config.sensitivity.default, SENSITIVITY, "sensitivity.default");
128
+ }
129
+ }
130
+ if (config.documentation !== undefined) {
131
+ if (!isObject(config.documentation)) err("documentation: must be an object");
132
+ else {
133
+ checkType(config.documentation.absorb, "boolean", "documentation.absorb");
134
+ if (
135
+ config.documentation.keepInPlace !== undefined &&
136
+ !isStringArray(config.documentation.keepInPlace)
137
+ ) {
138
+ err("documentation.keepInPlace: must be an array of strings");
139
+ }
140
+ }
141
+ }
142
+ if (config.onboarding !== undefined) {
143
+ if (!isObject(config.onboarding)) err("onboarding: must be an object");
144
+ else
145
+ checkType(
146
+ config.onboarding.allowAudienceNote,
147
+ "boolean",
148
+ "onboarding.allowAudienceNote"
149
+ );
150
+ }
151
+
152
+ if (config.connectors !== undefined) {
153
+ if (!isObject(config.connectors))
154
+ err("connectors: must be an object (name -> connector config)");
155
+ else {
156
+ for (const [name, c] of Object.entries(config.connectors)) {
157
+ if (!isObject(c)) {
158
+ err(`connectors.${name}: must be an object`);
159
+ continue;
160
+ }
161
+ checkType(c.enabled, "boolean", `connectors.${name}.enabled`);
162
+ if (c.sideEffects === undefined) {
163
+ err(
164
+ `connectors.${name}: missing required field "sideEffects" (every connector must declare its side-effect class so full ingest can skip external-write)`
165
+ );
166
+ } else {
167
+ checkEnum(
168
+ c.sideEffects,
169
+ SIDE_EFFECTS,
170
+ `connectors.${name}.sideEffects`
171
+ );
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ if (config.customConnectors !== undefined) {
178
+ if (!Array.isArray(config.customConnectors))
179
+ err("customConnectors: must be an array");
180
+ else {
181
+ config.customConnectors.forEach((c, i) => {
182
+ if (!isObject(c)) {
183
+ err(`customConnectors[${i}]: must be an object`);
184
+ return;
185
+ }
186
+ for (const k of ["name", "skill", "sourceSystem", "sideEffects"]) {
187
+ if (c[k] === undefined)
188
+ err(`customConnectors[${i}]: missing required field "${k}"`);
189
+ }
190
+ checkEnum(
191
+ c.sideEffects,
192
+ SIDE_EFFECTS,
193
+ `customConnectors[${i}].sideEffects`
194
+ );
195
+ });
196
+ }
197
+ }
198
+
199
+ if (config.staff !== undefined) {
200
+ if (!Array.isArray(config.staff)) err("staff: must be an array");
201
+ else {
202
+ config.staff.forEach((s, i) => {
203
+ if (!isObject(s)) {
204
+ err(`staff[${i}]: must be an object`);
205
+ return;
206
+ }
207
+ for (const k of ["id", "role"]) {
208
+ if (s[k] === undefined)
209
+ err(`staff[${i}]: missing required field "${k}"`);
210
+ }
211
+ checkEnum(s.sensitivity, SENSITIVITY, `staff[${i}].sensitivity`);
212
+ if (s.owns !== undefined) {
213
+ if (!isObject(s.owns)) err(`staff[${i}].owns: must be an object`);
214
+ else {
215
+ for (const ok of ["categories", "connectors", "skills"]) {
216
+ if (s.owns[ok] !== undefined && !isStringArray(s.owns[ok])) {
217
+ err(`staff[${i}].owns.${ok}: must be an array of strings`);
218
+ }
219
+ }
220
+ }
221
+ }
222
+ });
223
+ }
224
+ }
225
+
226
+ if (errors.length > 0) {
227
+ console.error(`✗ ${path.relative(process.cwd(), configPath)} is invalid:`);
228
+ for (const e of errors) console.error(` - ${e}`);
229
+ process.exit(1);
230
+ }
231
+
232
+ console.log(`✓ ${path.relative(process.cwd(), configPath)} is valid.`);
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * verify-migration.mjs — the deterministic half of /doctor. Dependency-free.
4
+ *
5
+ * Composes validate-config + lint-wiki into a grouped report and writes
6
+ * <wikiRoot>/state/migration/doctor-report.json with an overall verdict. Groups D
7
+ * (runtime surfaces) and E (functional smoke) are SKIPPED here — the lisa-wiki-doctor
8
+ * SKILL performs those and merges its results.
9
+ *
10
+ * Usage: node verify-migration.mjs [--wiki <root>] [--config <path>] [--migration]
11
+ * Exit 0 = READY or READY_WITH_WARNINGS, 1 = NOT_READY.
12
+ */
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { spawnSync } from "node:child_process";
16
+ import { fileURLToPath } from "node:url";
17
+ import { loadConfig } from "./_wiki-lib.mjs";
18
+
19
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
20
+ const argv = process.argv.slice(2);
21
+ const opt = n => {
22
+ const i = argv.indexOf(n);
23
+ return i !== -1 ? argv[i + 1] : undefined;
24
+ };
25
+ const migration = argv.includes("--migration");
26
+ const configPath = opt("--config");
27
+ const { config } = loadConfig(configPath);
28
+ const wikiRoot = path.resolve(opt("--wiki") ?? config?.wikiRoot ?? "wiki");
29
+
30
+ const groups = { A: [], B: [], C: [], D: [], E: [], F: [], G: [] };
31
+ const add = (g, id, status, message) => groups[g].push({ id, status, message });
32
+
33
+ function runNode(script, args) {
34
+ const res = spawnSync("node", [path.join(scriptDir, script), ...args], {
35
+ encoding: "utf8",
36
+ });
37
+ return {
38
+ status: res.status ?? 1,
39
+ stdout: res.stdout ?? "",
40
+ stderr: (res.stderr ?? "") + (res.error ? `\n${res.error.message}` : ""),
41
+ };
42
+ }
43
+
44
+ // --- A. structure & config ------------------------------------------------
45
+ const vc = runNode("validate-config.mjs", [
46
+ configPath ?? path.join(wikiRoot, "lisa-wiki.config.json"),
47
+ ]);
48
+ add(
49
+ "A",
50
+ "config-valid",
51
+ vc.status === 0 ? "PASS" : "FAIL",
52
+ vc.status === 0
53
+ ? "config validates"
54
+ : `config invalid: ${(vc.stderr || vc.stdout).trim().split("\n").slice(0, 6).join("; ")}`
55
+ );
56
+ add(
57
+ "A",
58
+ "schema-version",
59
+ config?.schemaVersion ? "PASS" : "FAIL",
60
+ config?.schemaVersion
61
+ ? `schemaVersion ${config.schemaVersion}`
62
+ : "schemaVersion missing"
63
+ );
64
+ add(
65
+ "A",
66
+ "readme-mode",
67
+ config?.readme?.mode ? "PASS" : "WARN",
68
+ config?.readme?.mode
69
+ ? `readme.mode ${config.readme.mode}`
70
+ : "readme.mode not recorded (asked by /setup)"
71
+ );
72
+ add(
73
+ "A",
74
+ "purpose",
75
+ config?.purpose ? "PASS" : "WARN",
76
+ config?.purpose
77
+ ? "purpose set"
78
+ : "purpose not set (asked by /setup; feeds onboarding + contract)"
79
+ );
80
+
81
+ // --- A/B. lint (structure -> A, everything else -> B) ---------------------
82
+ const lint = runNode("lint-wiki.mjs", [
83
+ "--wiki",
84
+ wikiRoot,
85
+ ...(configPath ? ["--config", configPath] : []),
86
+ "--json",
87
+ ]);
88
+ let lintReport;
89
+ try {
90
+ lintReport = JSON.parse(lint.stdout);
91
+ } catch {
92
+ add(
93
+ "B",
94
+ "lint-run",
95
+ "FAIL",
96
+ `lint-wiki did not produce JSON: ${(lint.stderr || lint.stdout).trim().slice(0, 200)}`
97
+ );
98
+ }
99
+ if (lintReport) {
100
+ const structureItems = lintReport.items.filter(i => i.group === "structure");
101
+ const otherItems = lintReport.items.filter(i => i.group !== "structure");
102
+ if (structureItems.length === 0)
103
+ add("A", "structure", "PASS", "structure conforms to the manifest");
104
+ for (const i of structureItems)
105
+ add(
106
+ "A",
107
+ `structure:${i.group}`,
108
+ i.status,
109
+ `${i.message}${i.file ? ` (${i.file})` : ""}`
110
+ );
111
+ if (otherItems.length === 0)
112
+ add("B", "integrity", "PASS", "no integrity/safety findings");
113
+ for (const i of otherItems)
114
+ add(
115
+ "B",
116
+ `${i.group}`,
117
+ i.status,
118
+ `${i.message}${i.file ? ` (${i.file})` : ""}`
119
+ );
120
+ }
121
+
122
+ // --- C/D/E/F/G: deterministic-light + delegated to the doctor skill --------
123
+ add(
124
+ "C",
125
+ "no-loss",
126
+ "SKIP",
127
+ "no-loss/parity needs the migration manifest from /migrate; dangling-link check is covered in B"
128
+ );
129
+ add(
130
+ "D",
131
+ "runtime",
132
+ "SKIP",
133
+ "runtime surfaces (commands/skills/subagents/MCP) verified by the lisa-wiki-doctor skill"
134
+ );
135
+ add(
136
+ "E",
137
+ "smoke",
138
+ "SKIP",
139
+ "functional smoke tests (ingest/query/lint/onboard) performed by the lisa-wiki-doctor skill"
140
+ );
141
+ add(
142
+ "F",
143
+ "mode",
144
+ config?.mode ? "PASS" : "FAIL",
145
+ config?.mode ? `mode: ${config.mode}; wikiRoot resolves` : "mode not set"
146
+ );
147
+ add(
148
+ "G",
149
+ "git-ci-dist",
150
+ "SKIP",
151
+ "git/CI/distribution checks performed by the lisa-wiki-doctor skill and CI"
152
+ );
153
+
154
+ // --- verdict --------------------------------------------------------------
155
+ const all = Object.values(groups).flat();
156
+ const isBlockingWarn = (item, group) =>
157
+ migration && (group === "A" || group === "B") && item.status === "WARN";
158
+ let verdict = "READY";
159
+ if (all.some(i => i.status === "FAIL")) verdict = "NOT_READY";
160
+ else if (
161
+ Object.entries(groups).some(([g, items]) =>
162
+ items.some(i => isBlockingWarn(i, g))
163
+ )
164
+ )
165
+ verdict = "NOT_READY";
166
+ else if (all.some(i => i.status === "WARN")) verdict = "READY_WITH_WARNINGS";
167
+
168
+ const reportObj = {
169
+ tool: "verify-migration",
170
+ deterministic: true,
171
+ wikiRoot: path.relative(process.cwd(), wikiRoot) || ".",
172
+ mode: config?.mode ?? null,
173
+ migration,
174
+ generatedAt: new Date().toISOString(),
175
+ verdict,
176
+ subset: "deterministic",
177
+ note: "Deterministic subset only. Groups C (no-loss/parity), D (runtime surfaces), E (functional smoke), and G (git/CI/distribution) are completed by the lisa-wiki-doctor skill, which merges its results into this report.",
178
+ groups,
179
+ };
180
+
181
+ const outPath = path.join(wikiRoot, "state", "migration", "doctor-report.json");
182
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
183
+ fs.writeFileSync(outPath, `${JSON.stringify(reportObj, null, 2)}\n`);
184
+
185
+ const counts = all.reduce(
186
+ (acc, i) => ((acc[i.status] = (acc[i.status] ?? 0) + 1), acc),
187
+ {}
188
+ );
189
+ console.log(
190
+ `verdict: ${verdict} (${["PASS", "WARN", "FAIL", "SKIP"].map(s => `${counts[s] ?? 0} ${s}`).join(", ")})`
191
+ );
192
+ console.log(`report → ${path.relative(process.cwd(), outPath)}`);
193
+ console.log(
194
+ " (deterministic subset — C/D/E/G completed by the /doctor skill)"
195
+ );
196
+ for (const i of all.filter(i => i.status === "FAIL" || i.status === "WARN")) {
197
+ console.log(` ${i.status === "FAIL" ? "✗" : "⚠"} [${i.id}] ${i.message}`);
198
+ }
199
+ process.exit(verdict === "NOT_READY" ? 1 : 0);
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: lisa-wiki-add-ingest
3
+ description: Scaffold a project-specific "front-door" ingest skill that does something unique (classify a source, fetch from a special system, stamp domain frontmatter) and then chains into /ingest. Use when a project needs a bespoke ingestion path that the core connectors do not cover — instead of forking the kernel. The generated skill enriches and delegates; the kernel still owns synthesis, index, log, verify, state, and PR.
4
+ ---
5
+
6
+ # lisa-wiki-add-ingest
7
+
8
+ Generate a thin, project-local front-door ingest skill so a project can extend ingestion **without
9
+ forking** the kernel. The front-door does only the unique part, then hands enriched parameters to
10
+ `/ingest`.
11
+
12
+ ## Workflow
13
+ 1. **Interview** the project: a short name; what the source is; which `wiki/sources/<sourceSystem>/`
14
+ bucket and page type/frontmatter it should produce; whether it merely *enriches/classifies* an
15
+ input or also *fetches* from an external system; and its **side-effect class**
16
+ (`read-only-ingest` | `repo-write` | `external-write`).
17
+ 2. **Generate** the front-door skill on both runtimes —
18
+ `.claude/skills/lisa-wiki-local-<name>/SKILL.md` and `.agents/skills/lisa-wiki-local-<name>/SKILL.md`.
19
+ Its body does the unique step, then **delegates to the `lisa-wiki-ingest` skill** (Claude facade
20
+ `/ingest`) passing the bucket/type/metadata. If it fetches, it writes only a sanitized source note
21
+ (+ run metadata) and lets the kernel do synthesis/index/log/verify/state/PR.
22
+ 3. **Register** it in `wiki/lisa-wiki.config.json` under `customConnectors`
23
+ (`{ name, skill, sourceSystem, stateFile, sideEffects }`). `/ingest` dispatches **only** to
24
+ registered names — no auto-discovery.
25
+
26
+ ## Rules (the front-door contract)
27
+ - A generated front-door writes **only** its source note + run metadata. It must not write synthesis
28
+ pages, `index.md`, `log.md`, or final state — the kernel does those, in order, after it returns.
29
+ - `external-write` front-doors require config opt-in **and** explicit per-run intent, and their PRs
30
+ never auto-merge.
31
+ - Side effects outside the declared class are a hard failure (enforced by the touched-file guard).
32
+
33
+ ## Related
34
+ `lisa-wiki-ingest` (what it chains into), `lisa-wiki-add-role`, `lisa-wiki-doctor`.
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: lisa-wiki-add-role
3
+ description: Scaffold a domain-expert "digital staff" role over the wiki — a dual-runtime subagent (Claude + Codex) plus a staff doc page — from a config.staff[] entry. Use when a project wants a role-scoped expert (e.g. Legal, Finance, Sales) whose knowledge is a slice of the wiki. The plugin only SETS UP the subagent; whether it is ever invoked, scheduled, or routed is out of scope.
4
+ ---
5
+
6
+ # lisa-wiki-add-role
7
+
8
+ Turn a role definition into two generated artifacts: a documentation page in the wiki and a runnable,
9
+ brain-pointed subagent on both runtimes. **Running the subagent (invocation, scheduling, Telegram /
10
+ agent-team routing, private notebooks) is out of scope** — this skill only creates it.
11
+
12
+ ## Workflow
13
+ 1. **Resolve the role** from a `config.staff[]` entry (or interview to add one): `id`, `role`,
14
+ `expertise`, `owns` (categories / connectors / skills), `sensitivity`.
15
+ 2. **Doc page:** generate `wiki/staff/<id>.md` describing the role, its owned domain, and who it
16
+ reports to. This page is wiki content and is itself ingestible (the `roles` connector).
17
+ 3. **Subagents (dual-runtime), rendered from the role-agent templates:**
18
+ - Claude: `.claude/agents/<id>.md`.
19
+ - Codex: `.codex/agents/<id>.toml` (keys `name`, `description`, `developer_instructions`; optional
20
+ `model`, `model_reasoning_effort`, `sandbox_mode`).
21
+ 4. **Brain-pointed, not baked:** the subagent's instructions say *"your domain is `wiki/<owned>/`;
22
+ `/query` it first, contribute via `/ingest`; stay in your lane."* It points at the live wiki so it
23
+ never goes stale. Only its one-line `description` is synthesized from the wiki at generation time.
24
+
25
+ ## Rules
26
+ - v1 instructs lane-keeping but does not *enforce* per-role write isolation (deferred).
27
+ - Setup seeds the starter roster by delegating here per `config.staff[]` entry.
28
+
29
+ ## Related
30
+ `lisa-wiki-setup` (seeds the roster), `lisa-wiki-add-ingest`, `lisa-wiki-onboard-me`.