sdtk-brain-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,271 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { CliError, ValidationError } = require("./errors");
6
+ const { analyzePages } = require("./wiki-lint");
7
+ const {
8
+ assertWikiWorkspaceWritePath,
9
+ getWikiProvenanceSourcesPath,
10
+ getWikiRawSourcesPath,
11
+ getWikiReportsPath,
12
+ getWikiWorkspacePath,
13
+ resolveProjectPath,
14
+ } = require("./wiki-paths");
15
+
16
+ const REPORT_PREFIX = "discover-plan";
17
+
18
+ const CATEGORY_LABELS = {
19
+ missing_source_coverage: "Missing source coverage",
20
+ broken_internal_link_target: "Broken internal link target",
21
+ todo_open_questions_gaps: "TODO / Open Questions / Gaps",
22
+ weak_integration: "Weak integration",
23
+ stale_candidate_human_decision: "Stale candidate requiring human decision",
24
+ contradiction_candidate_source_verification: "Contradiction candidate requiring source verification",
25
+ raw_source_not_represented: "Raw source not represented in managed pages",
26
+ };
27
+
28
+ function todayStamp(date = new Date()) {
29
+ return date.toISOString().slice(0, 10);
30
+ }
31
+
32
+ function readJsonIfPresent(filePath, fallback = null) {
33
+ if (!fs.existsSync(filePath)) return fallback;
34
+ try {
35
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
36
+ } catch (error) {
37
+ return { __error: `Could not parse JSON at ${filePath}: ${error.message}` };
38
+ }
39
+ }
40
+
41
+ function asArray(value) {
42
+ return Array.isArray(value) ? value : [];
43
+ }
44
+
45
+ function toPosix(value) {
46
+ return String(value || "").replace(/\\/g, "/");
47
+ }
48
+
49
+ function priorityForCategory(category) {
50
+ if (
51
+ category === "missing_source_coverage" ||
52
+ category === "stale_candidate_human_decision" ||
53
+ category === "contradiction_candidate_source_verification"
54
+ ) {
55
+ return "high";
56
+ }
57
+ if (category === "broken_internal_link_target" || category === "raw_source_not_represented") {
58
+ return "medium";
59
+ }
60
+ return "normal";
61
+ }
62
+
63
+ function sourceTypeForCategory(category) {
64
+ if (category === "broken_internal_link_target") {
65
+ return "missing internal page/link";
66
+ }
67
+ if (category === "raw_source_not_represented") {
68
+ return "local document to inspect";
69
+ }
70
+ if (category === "missing_source_coverage") {
71
+ return "local source coverage review";
72
+ }
73
+ return "stakeholder/manual review need";
74
+ }
75
+
76
+ function nextActionForCategory(category) {
77
+ switch (category) {
78
+ case "missing_source_coverage":
79
+ return "Review whether the referenced local source should be restored, re-ingested, or intentionally retired.";
80
+ case "broken_internal_link_target":
81
+ return "Decide whether to create the missing managed page or update the local link target.";
82
+ case "todo_open_questions_gaps":
83
+ return "Resolve the marker through local source review before compile/apply work.";
84
+ case "weak_integration":
85
+ return "Add local context, source references, or relationships in a later compile/apply workflow.";
86
+ case "stale_candidate_human_decision":
87
+ return "Review stale evidence before any future prune/archive issue is considered.";
88
+ case "contradiction_candidate_source_verification":
89
+ return "Verify the conflicting statement against local authoritative sources.";
90
+ case "raw_source_not_represented":
91
+ return "Consider whether this raw source should feed a later compile/additive update plan.";
92
+ default:
93
+ return "Review locally.";
94
+ }
95
+ }
96
+
97
+ function addPlanItems(items, category, evidenceItems, evidenceSource) {
98
+ for (const evidence of evidenceItems) {
99
+ items.push({
100
+ category,
101
+ evidence,
102
+ evidenceSource,
103
+ suggestedSourceType: sourceTypeForCategory(category),
104
+ priority: priorityForCategory(category),
105
+ confidence: "heuristic",
106
+ safetyMode: "local-only; manual-review",
107
+ nextAction: nextActionForCategory(category),
108
+ });
109
+ }
110
+ }
111
+
112
+ function collectRawSourceGaps(projectPath) {
113
+ const rawPath = getWikiRawSourcesPath(projectPath);
114
+ const provenancePath = getWikiProvenanceSourcesPath(projectPath);
115
+ const raw = readJsonIfPresent(rawPath, { sources: [] });
116
+ const provenance = readJsonIfPresent(provenancePath, { sources: [] });
117
+ const represented = new Set(
118
+ asArray(provenance.sources)
119
+ .filter((record) => record && record.sourcePath)
120
+ .map((record) => toPosix(record.sourcePath))
121
+ );
122
+
123
+ return asArray(raw.sources)
124
+ .filter((record) => record && record.sourcePath)
125
+ .filter((record) => !represented.has(toPosix(record.sourcePath)))
126
+ .map((record) => `Raw source \`${toPosix(record.sourcePath)}\` is registered but not represented in active provenance.`);
127
+ }
128
+
129
+ function buildPlanItems(projectPath) {
130
+ const analysis = analyzePages(projectPath);
131
+ const findings = analysis.findings;
132
+ const items = [];
133
+
134
+ addPlanItems(items, "missing_source_coverage", findings.provenance || [], "lint.provenance");
135
+ addPlanItems(items, "broken_internal_link_target", findings.brokenLinks || [], "lint.brokenLinks");
136
+ addPlanItems(items, "todo_open_questions_gaps", findings.markers || [], "lint.markers");
137
+ addPlanItems(items, "weak_integration", [...(findings.orphans || []), ...(findings.downstream || []), ...(findings.thin || [])], "lint.integration");
138
+ addPlanItems(items, "stale_candidate_human_decision", findings.stale || [], "lint.stale");
139
+ addPlanItems(items, "contradiction_candidate_source_verification", findings.contradictions || [], "lint.contradictions");
140
+ addPlanItems(items, "raw_source_not_represented", collectRawSourceGaps(projectPath), "raw.sources");
141
+
142
+ return {
143
+ pageCount: analysis.pageCount,
144
+ items: items.map((item, index) => ({
145
+ ...item,
146
+ planItemId: `discover-${String(index + 1).padStart(3, "0")}`,
147
+ })),
148
+ };
149
+ }
150
+
151
+ function summarizeByCategory(items) {
152
+ const summary = Object.fromEntries(Object.keys(CATEGORY_LABELS).map((key) => [key, 0]));
153
+ for (const item of items) {
154
+ summary[item.category] = (summary[item.category] || 0) + 1;
155
+ }
156
+ return summary;
157
+ }
158
+
159
+ function renderReport({ projectPath, workspacePath, generatedAt, pageCount, items }) {
160
+ const summary = summarizeByCategory(items);
161
+ const lines = [
162
+ "# SDTK-BRAIN Discovery Plan",
163
+ "",
164
+ `Generated: ${generatedAt}`,
165
+ `Project path: ${projectPath}`,
166
+ `Workspace path: ${workspacePath}`,
167
+ `Pages scanned: ${pageCount}`,
168
+ `Plan items: ${items.length}`,
169
+ "",
170
+ "Plan-only and local-only: No source was fetched, ingested, compiled, applied, pruned, deleted, archived, or persisted as query history.",
171
+ "No web or network discovery is executed. Future web research requires a separate controller-approved opt-in issue.",
172
+ "",
173
+ "## Input Evidence References",
174
+ "",
175
+ "- Direct lint/gap analysis of `.brain/pages`",
176
+ "- `.brain/provenance/sources.json` when present",
177
+ "- `.brain/raw/sources.json` when present",
178
+ "- BK-119 gap taxonomy",
179
+ "",
180
+ "## Gap Category Summary",
181
+ "",
182
+ "| Gap category | Count |",
183
+ "|---|---:|",
184
+ ...Object.entries(CATEGORY_LABELS).map(([key, label]) => `| \`${key}\` - ${label} | ${summary[key] || 0} |`),
185
+ "",
186
+ "## Plan Items",
187
+ "",
188
+ ];
189
+
190
+ if (items.length === 0) {
191
+ lines.push("No discovery plan items were generated from current local evidence.");
192
+ } else {
193
+ for (const item of items) {
194
+ lines.push(`### ${item.planItemId}`);
195
+ lines.push("");
196
+ lines.push(`- Gap category: \`${item.category}\``);
197
+ lines.push(`- Evidence source: \`${item.evidenceSource}\``);
198
+ lines.push(`- Evidence: ${item.evidence}`);
199
+ lines.push(`- Suggested source type: ${item.suggestedSourceType}`);
200
+ lines.push(`- Priority: ${item.priority}`);
201
+ lines.push(`- Confidence: ${item.confidence}`);
202
+ lines.push(`- Safety mode: ${item.safetyMode}`);
203
+ lines.push(`- Next action: ${item.nextAction}`);
204
+ lines.push("");
205
+ }
206
+ }
207
+
208
+ lines.push("");
209
+ lines.push("## Deferred Actions");
210
+ lines.push("");
211
+ lines.push("Fetch, ingest, compile, apply, prune, delete, archive, Ask changes, query persistence, and release work are outside BK-125.");
212
+ lines.push("");
213
+ return `${lines.join("\n").trimEnd()}\n`;
214
+ }
215
+
216
+ function ensureExistingWorkspace(projectPath) {
217
+ const workspacePath = getWikiWorkspacePath(projectPath);
218
+ if (!fs.existsSync(workspacePath) || !fs.statSync(workspacePath).isDirectory()) {
219
+ throw new ValidationError(
220
+ `No SDTK-BRAIN workspace found at ${workspacePath}. Run "sdtk-brain init" or "sdtk-brain atlas build" first. No project files were changed.`
221
+ );
222
+ }
223
+ return workspacePath;
224
+ }
225
+
226
+ function runWikiDiscoverPlan({ projectPath }) {
227
+ const resolvedProjectPath = resolveProjectPath(projectPath || process.cwd());
228
+ if (!fs.existsSync(resolvedProjectPath) || !fs.statSync(resolvedProjectPath).isDirectory()) {
229
+ throw new ValidationError(`--project-path is not a valid directory: ${resolvedProjectPath}`);
230
+ }
231
+
232
+ try {
233
+ const workspacePath = ensureExistingWorkspace(resolvedProjectPath);
234
+ const reportsPath = getWikiReportsPath(resolvedProjectPath);
235
+ assertWikiWorkspaceWritePath(reportsPath, resolvedProjectPath);
236
+
237
+ const generatedAt = new Date().toISOString();
238
+ const result = buildPlanItems(resolvedProjectPath);
239
+ const reportPath = path.join(reportsPath, `${REPORT_PREFIX}-${todayStamp(new Date(generatedAt))}.md`);
240
+ assertWikiWorkspaceWritePath(reportPath, resolvedProjectPath);
241
+ fs.mkdirSync(reportsPath, { recursive: true });
242
+ fs.writeFileSync(
243
+ reportPath,
244
+ renderReport({
245
+ projectPath: resolvedProjectPath,
246
+ workspacePath,
247
+ generatedAt,
248
+ pageCount: result.pageCount,
249
+ items: result.items,
250
+ }),
251
+ "utf-8"
252
+ );
253
+
254
+ return {
255
+ reportPath,
256
+ pageCount: result.pageCount,
257
+ items: result.items,
258
+ summary: summarizeByCategory(result.items),
259
+ };
260
+ } catch (error) {
261
+ if (error instanceof CliError) throw error;
262
+ throw new CliError(`Failed to write SDTK-BRAIN discovery plan report: ${error.message}`);
263
+ }
264
+ }
265
+
266
+ module.exports = {
267
+ REPORT_PREFIX,
268
+ buildPlanItems,
269
+ renderReport,
270
+ runWikiDiscoverPlan,
271
+ };
@@ -0,0 +1,264 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { ValidationError } = require("./errors");
6
+ const {
7
+ assertWikiWorkspaceWritePath,
8
+ getPreferredWikiContentPath,
9
+ getWikiReportsPath,
10
+ isPathInsideOrEqual,
11
+ resolveProjectPath,
12
+ } = require("./wiki-paths");
13
+
14
+ const REPORT_PREFIX = "github-enrichment-review";
15
+
16
+ function toPosix(value) {
17
+ return String(value || "").replace(/\\/g, "/");
18
+ }
19
+
20
+ function todayStamp() {
21
+ return new Date().toISOString().slice(0, 10);
22
+ }
23
+
24
+ function parseFrontmatter(text) {
25
+ const source = String(text || "");
26
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
27
+ if (!match) return { fields: {}, body: source };
28
+ const fields = {};
29
+ for (const line of match[1].split(/\r?\n/)) {
30
+ const separator = line.indexOf(":");
31
+ if (separator === -1) continue;
32
+ const key = line.slice(0, separator).trim();
33
+ const value = line.slice(separator + 1).trim();
34
+ fields[key] = value.replace(/^["']|["']$/g, "");
35
+ }
36
+ return { fields, body: source.slice(match[0].length) };
37
+ }
38
+
39
+ function parseInlineList(value) {
40
+ const text = String(value || "").trim();
41
+ if (!text || text === "[]") return [];
42
+ const inner = text.startsWith("[") && text.endsWith("]") ? text.slice(1, -1) : text;
43
+ return inner
44
+ .split(",")
45
+ .map((item) => item.trim().replace(/^["']|["']$/g, ""))
46
+ .filter(Boolean);
47
+ }
48
+
49
+ function collectMarkdownFiles(rootPath) {
50
+ const files = [];
51
+ if (!fs.existsSync(rootPath)) return files;
52
+ function visit(current) {
53
+ const stat = fs.statSync(current);
54
+ if (stat.isDirectory()) {
55
+ for (const child of fs.readdirSync(current).sort()) {
56
+ visit(path.join(current, child));
57
+ }
58
+ return;
59
+ }
60
+ if (stat.isFile() && current.toLowerCase().endsWith(".md")) {
61
+ files.push(current);
62
+ }
63
+ }
64
+ visit(rootPath);
65
+ return files.sort((a, b) => toPosix(a).localeCompare(toPosix(b)));
66
+ }
67
+
68
+ function extractGithubRepos(text) {
69
+ const repos = [];
70
+ const seen = new Set();
71
+ const matcher = /https?:\/\/github\.com\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]+)/gi;
72
+ let match;
73
+ while ((match = matcher.exec(String(text || ""))) !== null) {
74
+ const owner = match[1];
75
+ const repo = match[2].replace(/[).,;:]+$/g, "").replace(/\.git$/i, "");
76
+ if (!repo || repo.includes("...")) continue;
77
+ const url = `https://github.com/${owner}/${repo}`;
78
+ const key = url.toLowerCase();
79
+ if (seen.has(key)) continue;
80
+ seen.add(key);
81
+ repos.push({ owner, repo, url });
82
+ }
83
+ return repos;
84
+ }
85
+
86
+ function firstSection(body, heading) {
87
+ const escaped = String(heading).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
88
+ const matcher = new RegExp(`^##\\s+${escaped}\\s*$([\\s\\S]*?)(?=^##\\s+|$)`, "im");
89
+ const match = String(body || "").match(matcher);
90
+ if (!match) return "";
91
+ return match[1].replace(/\s+/g, " ").trim();
92
+ }
93
+
94
+ function extractProvenanceRefs(body) {
95
+ const section = firstSection(body, "Provenance");
96
+ if (!section) return [];
97
+ return section
98
+ .split(/\s*-\s+/)
99
+ .map((item) => item.trim())
100
+ .filter((item) => item.startsWith("prov_"))
101
+ .slice(0, 12);
102
+ }
103
+
104
+ function recordFromPage(filePath, contentRoot) {
105
+ const text = fs.readFileSync(filePath, "utf-8");
106
+ const parsed = parseFrontmatter(text);
107
+ const fields = parsed.fields;
108
+ const repos = extractGithubRepos(text);
109
+ if (repos.length === 0) return [];
110
+
111
+ const relPath = toPosix(path.relative(contentRoot.path, filePath));
112
+ const type = String(fields.type || "");
113
+ const title = String(fields.title || path.basename(filePath, ".md"));
114
+ const tags = parseInlineList(fields.tags);
115
+ const sourceRefs = parseInlineList(fields.source_refs);
116
+ const summary = firstSection(parsed.body, "Summary");
117
+ const provenanceRefs = extractProvenanceRefs(parsed.body);
118
+
119
+ return repos.map((repo) => ({
120
+ record_type: "sdtk_wiki_github_enrichment_candidate",
121
+ status: "review_only_local_identity",
122
+ page_path: `${toPosix(contentRoot.relative)}/${relPath}`,
123
+ page_type: type || "unknown",
124
+ page_title: title,
125
+ repo_url: repo.url,
126
+ source_url: repo.url,
127
+ owner: repo.owner,
128
+ repo: repo.repo,
129
+ local_summary: summary || null,
130
+ local_topics: tags,
131
+ source_refs: sourceRefs,
132
+ provenance_refs: provenanceRefs,
133
+ confidence: fields.confidence || "unknown",
134
+ metadata: {
135
+ stars: null,
136
+ license: null,
137
+ description: null,
138
+ current_owner: null,
139
+ default_branch: null,
140
+ last_verified_at: null,
141
+ },
142
+ fetch_timestamp: null,
143
+ fetch_status: "not_fetched_local_review_mode",
144
+ failure_status: "not_fetched_local_review_mode",
145
+ failure_reason: "Network metadata was not requested in review-only local mode.",
146
+ enrichment_mode: "review",
147
+ mutation: "none",
148
+ }));
149
+ }
150
+
151
+ function uniqueRecords(records) {
152
+ const byRepo = new Map();
153
+ for (const record of records) {
154
+ const key = record.repo_url.toLowerCase();
155
+ const existing = byRepo.get(key);
156
+ if (!existing) {
157
+ byRepo.set(key, record);
158
+ continue;
159
+ }
160
+ existing.page_path = existing.page_path || record.page_path;
161
+ existing.local_topics = Array.from(new Set([...existing.local_topics, ...record.local_topics])).sort();
162
+ existing.source_refs = Array.from(new Set([...existing.source_refs, ...record.source_refs])).sort();
163
+ existing.provenance_refs = Array.from(new Set([...existing.provenance_refs, ...record.provenance_refs])).sort();
164
+ }
165
+ return [...byRepo.values()].sort((a, b) => a.repo_url.localeCompare(b.repo_url));
166
+ }
167
+
168
+ function renderMarkdownReport({ projectPath, reportJsonPath, records, contentRoot }) {
169
+ const lines = [
170
+ "# SDTK-BRAIN GitHub Enrichment Review",
171
+ "",
172
+ `Date: ${todayStamp()}`,
173
+ `Project root: \`${projectPath}\``,
174
+ `Wiki content root: \`${contentRoot.path}\``,
175
+ `Wiki content mode: \`${contentRoot.mode}\``,
176
+ `JSON report: \`${reportJsonPath}\``,
177
+ "",
178
+ "Report-only and non-destructive: this command does not fetch network metadata, rewrite pages, mutate sources, or touch `.brain-legacy-unused`.",
179
+ "Network-backed GitHub verification is deferred to a later controller-approved slice.",
180
+ "",
181
+ "## Summary",
182
+ "",
183
+ `- candidates: ${records.length}`,
184
+ "- source: github",
185
+ "- mode: review",
186
+ "- fetch status: not_fetched_local_review_mode",
187
+ "",
188
+ "## Candidates",
189
+ "",
190
+ "| Repo | Page | Confidence | Source refs | Fetch status | Pending metadata |",
191
+ "|---|---|---|---:|---|---|",
192
+ ];
193
+
194
+ for (const record of records) {
195
+ lines.push(
196
+ `| [${record.owner}/${record.repo}](${record.repo_url}) | \`${record.page_path}\` | ${record.confidence} | ${record.source_refs.length} | ${record.fetch_status} | stars, license, description, current owner |`
197
+ );
198
+ }
199
+ if (records.length === 0) {
200
+ lines.push("| none | - | - | 0 | - | - |");
201
+ }
202
+
203
+ lines.push(
204
+ "",
205
+ "## Review Notes",
206
+ "",
207
+ "- Treat every candidate as locally identified only until a future explicit network/enrichment apply issue verifies it.",
208
+ "- Do not copy stars, license, or activity claims into generated pages from this report unless those fields are later verified.",
209
+ ""
210
+ );
211
+ return lines.join("\n");
212
+ }
213
+
214
+ function runWikiGithubEnrichmentReview(options = {}) {
215
+ const projectPath = resolveProjectPath(options.projectPath || process.cwd());
216
+ if (!fs.existsSync(projectPath) || !fs.statSync(projectPath).isDirectory()) {
217
+ throw new ValidationError(`--project-path is not a valid directory: ${projectPath}`);
218
+ }
219
+
220
+ const contentRoot = getPreferredWikiContentPath(projectPath);
221
+ if (!isPathInsideOrEqual(contentRoot.path, projectPath)) {
222
+ throw new ValidationError("Refusing to read local wiki pages outside the project root.");
223
+ }
224
+ if (!fs.existsSync(contentRoot.path) || !fs.statSync(contentRoot.path).isDirectory()) {
225
+ throw new ValidationError(
226
+ `No SDTK-BRAIN local wiki found at ${contentRoot.path}. Run "sdtk-brain ingest <source-root>" and "sdtk-brain compile --mode safe --apply" first.`
227
+ );
228
+ }
229
+
230
+ const records = uniqueRecords(collectMarkdownFiles(contentRoot.path).flatMap((filePath) =>
231
+ recordFromPage(filePath, contentRoot)
232
+ ));
233
+
234
+ const reportsRoot = getWikiReportsPath(projectPath);
235
+ assertWikiWorkspaceWritePath(reportsRoot, projectPath);
236
+ fs.mkdirSync(reportsRoot, { recursive: true });
237
+ const jsonPath = path.join(reportsRoot, `${REPORT_PREFIX}-${todayStamp()}.json`);
238
+ const markdownPath = path.join(reportsRoot, `${REPORT_PREFIX}-${todayStamp()}.md`);
239
+ assertWikiWorkspaceWritePath(jsonPath, projectPath);
240
+ assertWikiWorkspaceWritePath(markdownPath, projectPath);
241
+
242
+ const payload = {
243
+ schema_version: 1,
244
+ record_type: "sdtk_wiki_github_enrichment_review",
245
+ generated_at: new Date().toISOString(),
246
+ project_path: projectPath,
247
+ wiki_content_path: contentRoot.path,
248
+ wiki_content_mode: contentRoot.mode,
249
+ source: "github",
250
+ mode: "review",
251
+ network_fetch: false,
252
+ mutation: "none",
253
+ candidate_count: records.length,
254
+ records,
255
+ };
256
+ fs.writeFileSync(jsonPath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
257
+ fs.writeFileSync(markdownPath, renderMarkdownReport({ projectPath, reportJsonPath: jsonPath, records, contentRoot }), "utf-8");
258
+
259
+ return { markdownPath, jsonPath, records, projectPath };
260
+ }
261
+
262
+ module.exports = {
263
+ runWikiGithubEnrichmentReview,
264
+ };