@rarusoft/dendrite-wiki 0.1.0-alpha.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.
Files changed (74) hide show
  1. package/README.md +79 -0
  2. package/dist/api-extractor/extract.js +269 -0
  3. package/dist/api-extractor/language-extractor.js +15 -0
  4. package/dist/api-extractor/python-extractor.js +358 -0
  5. package/dist/api-extractor/render.js +195 -0
  6. package/dist/api-extractor/tree-sitter-extractor.js +1079 -0
  7. package/dist/api-extractor/types.js +11 -0
  8. package/dist/api-extractor/typescript-extractor.js +50 -0
  9. package/dist/api-extractor/walk.js +178 -0
  10. package/dist/api-reference.js +438 -0
  11. package/dist/benchmark-events.js +129 -0
  12. package/dist/benchmark.js +270 -0
  13. package/dist/binder-export.js +381 -0
  14. package/dist/canonical-target.js +168 -0
  15. package/dist/chart-insert.js +377 -0
  16. package/dist/chart-prompts.js +414 -0
  17. package/dist/context-cache.js +98 -0
  18. package/dist/contradicts-shipped-memory.js +232 -0
  19. package/dist/diff-context.js +142 -0
  20. package/dist/doctor.js +220 -0
  21. package/dist/generated-docs.js +219 -0
  22. package/dist/i18n.js +71 -0
  23. package/dist/index.js +49 -0
  24. package/dist/librarian.js +255 -0
  25. package/dist/maintenance-actions.js +244 -0
  26. package/dist/maintenance-inbox.js +842 -0
  27. package/dist/maintenance-runner.js +62 -0
  28. package/dist/page-drift.js +225 -0
  29. package/dist/page-inbox.js +168 -0
  30. package/dist/report-export.js +339 -0
  31. package/dist/review-bridge.js +1386 -0
  32. package/dist/search-index.js +199 -0
  33. package/dist/store.js +1617 -0
  34. package/dist/telemetry-defaults.js +44 -0
  35. package/dist/telemetry-report.js +263 -0
  36. package/dist/telemetry.js +544 -0
  37. package/dist/wiki-synthesis.js +901 -0
  38. package/package.json +35 -0
  39. package/src/api-extractor/extract.ts +333 -0
  40. package/src/api-extractor/language-extractor.ts +37 -0
  41. package/src/api-extractor/python-extractor.ts +380 -0
  42. package/src/api-extractor/render.ts +267 -0
  43. package/src/api-extractor/tree-sitter-extractor.ts +1210 -0
  44. package/src/api-extractor/types.ts +41 -0
  45. package/src/api-extractor/typescript-extractor.ts +56 -0
  46. package/src/api-extractor/walk.ts +209 -0
  47. package/src/api-reference.ts +552 -0
  48. package/src/benchmark-events.ts +216 -0
  49. package/src/benchmark.ts +376 -0
  50. package/src/binder-export.ts +437 -0
  51. package/src/canonical-target.ts +192 -0
  52. package/src/chart-insert.ts +478 -0
  53. package/src/chart-prompts.ts +417 -0
  54. package/src/context-cache.ts +129 -0
  55. package/src/contradicts-shipped-memory.ts +311 -0
  56. package/src/diff-context.ts +187 -0
  57. package/src/doctor.ts +260 -0
  58. package/src/generated-docs.ts +316 -0
  59. package/src/i18n.ts +106 -0
  60. package/src/index.ts +59 -0
  61. package/src/librarian.ts +331 -0
  62. package/src/maintenance-actions.ts +314 -0
  63. package/src/maintenance-inbox.ts +1132 -0
  64. package/src/maintenance-runner.ts +85 -0
  65. package/src/page-drift.ts +292 -0
  66. package/src/page-inbox.ts +254 -0
  67. package/src/report-export.ts +392 -0
  68. package/src/review-bridge.ts +1729 -0
  69. package/src/search-index.ts +266 -0
  70. package/src/store.ts +2171 -0
  71. package/src/telemetry-defaults.ts +50 -0
  72. package/src/telemetry-report.ts +365 -0
  73. package/src/telemetry.ts +757 -0
  74. package/src/wiki-synthesis.ts +1307 -0
package/src/doctor.ts ADDED
@@ -0,0 +1,260 @@
1
+ /**
2
+ * `dendrite-wiki doctor` — project-health audit.
3
+ *
4
+ * Aggregates findings from every health-relevant subsystem into one ranked list with
5
+ * severities (`critical`, `warning`, `info`): missing required files, stale benchmark
6
+ * snapshots, accumulated wiki lint findings, contested or unsupported memories, missing
7
+ * telemetry config when sharing is opt-in, etc. The CLI prints a human report by default
8
+ * and a structured `--json` output for scripted health checks.
9
+ *
10
+ * The doctor exits 1 on any `critical` finding so it integrates cleanly with CI gates and
11
+ * pre-commit hooks. Most findings are advisory and live as `warning` so the doctor stays
12
+ * useful without becoming a nag.
13
+ */
14
+ import { promises as fs } from 'node:fs';
15
+ import path from 'node:path';
16
+ import { readBenchmarkHistory } from './benchmark.js';
17
+ import { reviewProjectMemories, type ProjectMemoryReviewFinding } from '@rarusoft/dendrite-memory';
18
+ import { lintWikiPages, listWikiPages, listWikiProposals } from './store.js';
19
+ import { writeTelemetryStatusArtifact } from './telemetry.js';
20
+
21
+ export type DoctorSeverity = 'critical' | 'warning' | 'info';
22
+
23
+ export interface DoctorFinding {
24
+ severity: DoctorSeverity;
25
+ rule: string;
26
+ title: string;
27
+ detail: string;
28
+ fix?: string;
29
+ }
30
+
31
+ export interface DoctorReport {
32
+ generatedAt: string;
33
+ root: string;
34
+ findings: DoctorFinding[];
35
+ counts: {
36
+ critical: number;
37
+ warning: number;
38
+ info: number;
39
+ };
40
+ status: 'healthy' | 'warnings' | 'critical';
41
+ }
42
+
43
+ export async function runDoctor(options: { root?: string } = {}): Promise<DoctorReport> {
44
+ const root = path.resolve(options.root ?? process.cwd());
45
+ const findings: DoctorFinding[] = [];
46
+
47
+ // Critical filesystem checks first — if these fail, deeper checks may throw.
48
+ const wikiDirExists = await pathExists(path.join(root, 'docs', 'wiki'));
49
+ if (!wikiDirExists) {
50
+ findings.push({
51
+ severity: 'critical',
52
+ rule: 'no-wiki-directory',
53
+ title: 'Wiki directory is missing.',
54
+ detail: 'Dendrite expects markdown pages under docs/wiki/. The agent has nothing to read or update.',
55
+ fix: 'npx dendrite-wiki init'
56
+ });
57
+ }
58
+
59
+ const indexExists = await pathExists(path.join(root, 'docs', 'index.md'));
60
+ if (!indexExists) {
61
+ findings.push({
62
+ severity: 'critical',
63
+ rule: 'no-index-page',
64
+ title: 'docs/index.md is missing.',
65
+ detail: 'Agents are instructed to read docs/index.md first. Without it, orientation breaks.',
66
+ fix: 'npx dendrite-wiki init'
67
+ });
68
+ }
69
+
70
+ const mcpClientPaths = [
71
+ { client: 'Claude Code', file: '.mcp.json' },
72
+ { client: 'VS Code / Copilot', file: '.vscode/mcp.json' },
73
+ { client: 'Cursor', file: '.cursor/mcp.json' },
74
+ { client: 'Codex', file: '.codex/config.toml' },
75
+ { client: 'Continue', file: '.continue/mcpServers/dendrite-wiki-mcp.json' }
76
+ ];
77
+ const presentClients: string[] = [];
78
+ for (const entry of mcpClientPaths) {
79
+ if (await pathExists(path.join(root, entry.file))) {
80
+ presentClients.push(entry.client);
81
+ }
82
+ }
83
+ if (presentClients.length === 0) {
84
+ findings.push({
85
+ severity: 'critical',
86
+ rule: 'no-mcp-client-config',
87
+ title: 'No MCP client config files found.',
88
+ detail: 'No editor or agent client knows how to launch the MCP server. The agent cannot reach Dendrite.',
89
+ fix: 'npx dendrite-wiki init --profile claude (or --profile cursor / copilot-vscode / codex / continue)'
90
+ });
91
+ } else {
92
+ findings.push({
93
+ severity: 'info',
94
+ rule: 'mcp-clients-configured',
95
+ title: `${presentClients.length} MCP client config${presentClients.length === 1 ? '' : 's'} present.`,
96
+ detail: `Configured: ${presentClients.join(', ')}.`
97
+ });
98
+ }
99
+
100
+ // If the basic skeleton is broken, skip deeper checks to avoid noisy errors.
101
+ const skeletonOk = wikiDirExists && indexExists;
102
+
103
+ if (skeletonOk) {
104
+ const [pages, lintFindings, proposals, memoryReview, history, telemetryStatus] = await Promise.all([
105
+ listWikiPages().catch(() => []),
106
+ lintWikiPages().catch(() => []),
107
+ listWikiProposals().catch(() => []),
108
+ reviewProjectMemories().catch(() => ({ findings: [] as ProjectMemoryReviewFinding[] })),
109
+ readBenchmarkHistory(root).catch(() => null),
110
+ writeTelemetryStatusArtifact(root).catch(() => null)
111
+ ]);
112
+
113
+ if (lintFindings.length > 0) {
114
+ findings.push({
115
+ severity: 'warning',
116
+ rule: 'lint-findings-present',
117
+ title: `${lintFindings.length} wiki lint finding${lintFindings.length === 1 ? '' : 's'}.`,
118
+ detail: 'The wiki has open hygiene issues (oversized guidance, stale claims, orphan pages, etc.). Review the maintenance inbox to triage.',
119
+ fix: 'Open docs/wiki/maintenance-inbox.md or run `npx dendrite-wiki benchmark:snapshot` to refresh state.'
120
+ });
121
+ }
122
+
123
+ if (proposals.length > 0) {
124
+ findings.push({
125
+ severity: 'warning',
126
+ rule: 'pending-proposals',
127
+ title: `${proposals.length} pending maintenance proposal${proposals.length === 1 ? '' : 's'}.`,
128
+ detail: 'Generated guidance cleanup proposals are waiting for review.',
129
+ fix: 'Open docs/wiki/maintenance-review.md in the browser, or call wiki_apply_proposal for low-risk items.'
130
+ });
131
+ }
132
+
133
+ const contradictionFindings = memoryReview.findings.filter((f) => f.kind === 'contradiction');
134
+ if (contradictionFindings.length > 0) {
135
+ findings.push({
136
+ severity: 'warning',
137
+ rule: 'memory-contradictions',
138
+ title: `${contradictionFindings.length} memory contradiction group${contradictionFindings.length === 1 ? '' : 's'} detected.`,
139
+ detail: 'Two or more memories disagree. The agent may be acting on inconsistent project truth.',
140
+ fix: 'Open the Maintenance Review board in the browser to inspect and resolve.'
141
+ });
142
+ }
143
+
144
+ if (history && history.snapshots.length > 0) {
145
+ const latest = history.latest && history.latest.timestamp ? history.latest : history.snapshots.at(-1);
146
+ if (latest && latest.timestamp) {
147
+ const ageDays = Math.floor((Date.now() - new Date(latest.timestamp).getTime()) / (1000 * 60 * 60 * 24));
148
+ if (ageDays > 14) {
149
+ findings.push({
150
+ severity: 'warning',
151
+ rule: 'stale-benchmark',
152
+ title: `Last benchmark snapshot is ${ageDays} days old.`,
153
+ detail: 'Benchmarks should be captured at session boundaries to detect drift. Stale snapshots make trend lines meaningless.',
154
+ fix: 'npx dendrite-wiki benchmark:snapshot --label session-end'
155
+ });
156
+ }
157
+ }
158
+ } else {
159
+ findings.push({
160
+ severity: 'warning',
161
+ rule: 'no-benchmark-history',
162
+ title: 'No benchmark snapshots have been captured.',
163
+ detail: 'Without baseline snapshots there is no way to measure whether Dendrite is helping the project over time.',
164
+ fix: 'npx dendrite-wiki benchmark:snapshot --label baseline'
165
+ });
166
+ }
167
+
168
+ const projectLogPath = path.join(root, 'docs', 'wiki', 'project-log.md');
169
+ if (await pathExists(projectLogPath)) {
170
+ const projectLogStat = await fs.stat(projectLogPath);
171
+ const logAgeDays = Math.floor((Date.now() - projectLogStat.mtime.getTime()) / (1000 * 60 * 60 * 24));
172
+ if (logAgeDays > 7) {
173
+ findings.push({
174
+ severity: 'warning',
175
+ rule: 'stale-project-log',
176
+ title: `project-log.md was last touched ${logAgeDays} days ago.`,
177
+ detail: 'No meaningful work has been logged in the past week. The project log is the chronological record agents read for context.',
178
+ fix: 'Append an entry via wiki_log when meaningful work happens.'
179
+ });
180
+ }
181
+ }
182
+
183
+ if (!(await pathExists(path.join(root, '.git')))) {
184
+ findings.push({
185
+ severity: 'warning',
186
+ rule: 'no-git-repository',
187
+ title: 'No .git/ directory found.',
188
+ detail: 'Dendrite assumes git for diff-based review. Without git, audit and rollback are weakened.',
189
+ fix: 'git init'
190
+ });
191
+ }
192
+
193
+ findings.push({
194
+ severity: 'info',
195
+ rule: 'project-stats',
196
+ title: `${pages.length} wiki page${pages.length === 1 ? '' : 's'} total.`,
197
+ detail: `${lintFindings.length} lint finding${lintFindings.length === 1 ? '' : 's'}, ${proposals.length} proposal${proposals.length === 1 ? '' : 's'}, ${memoryReview.findings.length} memory finding${memoryReview.findings.length === 1 ? '' : 's'}. Telemetry: ${telemetryStatus?.sharingMode ?? 'unknown'}.`
198
+ });
199
+ }
200
+
201
+ const counts = {
202
+ critical: findings.filter((f) => f.severity === 'critical').length,
203
+ warning: findings.filter((f) => f.severity === 'warning').length,
204
+ info: findings.filter((f) => f.severity === 'info').length
205
+ };
206
+ const status: DoctorReport['status'] = counts.critical > 0 ? 'critical' : counts.warning > 0 ? 'warnings' : 'healthy';
207
+
208
+ return {
209
+ generatedAt: new Date().toISOString(),
210
+ root,
211
+ findings,
212
+ counts,
213
+ status
214
+ };
215
+ }
216
+
217
+ export function formatDoctorReport(report: DoctorReport): string {
218
+ const lines: string[] = [];
219
+ const statusEmoji = report.status === 'healthy' ? 'OK' : report.status === 'warnings' ? 'WARN' : 'FAIL';
220
+ lines.push(`Dendrite Doctor — ${statusEmoji}`);
221
+ lines.push(`Project: ${report.root}`);
222
+ lines.push(`Critical: ${report.counts.critical} Warnings: ${report.counts.warning} Info: ${report.counts.info}`);
223
+ lines.push('');
224
+
225
+ const sections: Array<{ severity: DoctorSeverity; label: string }> = [
226
+ { severity: 'critical', label: 'CRITICAL' },
227
+ { severity: 'warning', label: 'WARNING' },
228
+ { severity: 'info', label: 'INFO' }
229
+ ];
230
+
231
+ for (const section of sections) {
232
+ const sectionFindings = report.findings.filter((f) => f.severity === section.severity);
233
+ if (sectionFindings.length === 0) continue;
234
+
235
+ lines.push(`[${section.label}]`);
236
+ for (const finding of sectionFindings) {
237
+ lines.push(` ${finding.title}`);
238
+ lines.push(` ${finding.detail}`);
239
+ if (finding.fix) {
240
+ lines.push(` Fix: ${finding.fix}`);
241
+ }
242
+ lines.push('');
243
+ }
244
+ }
245
+
246
+ if (report.findings.length === 0) {
247
+ lines.push('No findings — Dendrite is healthy.');
248
+ }
249
+
250
+ return lines.join('\n');
251
+ }
252
+
253
+ async function pathExists(target: string): Promise<boolean> {
254
+ try {
255
+ await fs.access(target);
256
+ return true;
257
+ } catch {
258
+ return false;
259
+ }
260
+ }
@@ -0,0 +1,316 @@
1
+ /**
2
+ * The wiki refresh orchestrator — `npm run wiki:refresh` entry point.
3
+ *
4
+ * Rebuilds every deterministic derived view in the project: the catalog block in
5
+ * `docs/index.md`, `docs/wiki/maintenance-inbox.md` (and its JSON twin), the recent raw
6
+ * observation stream, the guidance-lifecycle table, the wiki search index (JSON +
7
+ * SQLite FTS5), and — since A5 of the API reference roadmap — the entire `docs/wiki/api/`
8
+ * tree via `refreshApiReference()` from `./api-reference.ts`.
9
+ *
10
+ * The order matters: API reference generation runs first so the page catalog and search
11
+ * index built later in the same call see the fresh generated pages. Every write goes
12
+ * through `writeIfChanged` so untouched files don't bump mtime, which keeps `npm run check`
13
+ * idempotent across repeated runs.
14
+ *
15
+ * This module deliberately does NOT write technical narrative pages like architecture.md.
16
+ * It only rebuilds derived views that map cleanly from primary data; humans own the prose.
17
+ */
18
+ import { promises as fs } from 'node:fs';
19
+ import path from 'node:path';
20
+ import { refreshApiReference } from './api-reference.js';
21
+ import { reviewProjectMemories } from '@rarusoft/dendrite-memory';
22
+ import {
23
+ buildWikiGraphSnapshot,
24
+ extractWikiClaims,
25
+ lintWikiPages,
26
+ listGuidanceLifecycle,
27
+ listWikiPages,
28
+ listWikiProposals,
29
+ readWikiPage,
30
+ searchWikiPages,
31
+ type WikiPageSummary
32
+ } from './store.js';
33
+ import { buildMaintenanceInboxPage, buildMaintenanceInboxSnapshot } from './maintenance-inbox.js';
34
+ import { detectRawObservationClusters, readRawObservations } from '@rarusoft/dendrite-memory';
35
+ import type { ExecutedMaintenanceAction } from './maintenance-actions.js';
36
+
37
+ const indexPath = path.resolve(process.cwd(), 'docs', 'index.md');
38
+ const maintenanceInboxPath = path.resolve(process.cwd(), 'docs', 'wiki', 'maintenance-inbox.md');
39
+ const maintenanceInboxDataPath = path.resolve(process.cwd(), 'docs', 'public', 'maintenance-inbox.json');
40
+ const observationStreamDataPath = path.resolve(process.cwd(), 'docs', 'public', 'raw-observations-recent.json');
41
+ const observationStreamRecentSampleSize = 200;
42
+ const guidanceLifecyclePath = path.resolve(process.cwd(), 'docs', 'wiki', 'guidance-lifecycle.md');
43
+ const guidanceLifecycleDataPath = path.resolve(process.cwd(), 'docs', 'public', 'guidance-lifecycle.json');
44
+ const maintenanceActionResultPath = path.resolve(process.cwd(), 'docs', 'public', 'maintenance-action-result.json');
45
+ const wikiSearchIndexPath = path.resolve(process.cwd(), 'docs', 'public', 'wiki-search-index.json');
46
+ const sqliteSearchIndexPath = path.resolve(process.cwd(), process.env.DENDRITE_WIKI_DATA_DIR ?? 'local-data', 'wiki-search.sqlite');
47
+ const markerStart = '<!-- WIKI_CATALOG_START -->';
48
+ const markerEnd = '<!-- WIKI_CATALOG_END -->';
49
+
50
+ export interface MaintenanceActionArtifact {
51
+ ranAt: string;
52
+ refreshedPageCount: number;
53
+ audit: {
54
+ artifactPath: string;
55
+ changedPaths: string[];
56
+ projectLogEntry?: string;
57
+ undoPath: string;
58
+ };
59
+ execution: ExecutedMaintenanceAction;
60
+ }
61
+
62
+ export async function refreshGeneratedWikiDocs(): Promise<{ pageCount: number }> {
63
+ // Regenerate the API reference first so listWikiPages() and the lint pass below see the
64
+ // current set of generated pages. Projects without a `src/` directory get a harmless
65
+ // empty-result no-op (walkProjectSources returns []). Generation failures escalate so
66
+ // `npm run check` surfaces real breakage rather than silently shipping a stale catalog.
67
+ await refreshApiReference();
68
+
69
+ const [findings, proposals, memoryReview, observationClusters] = await Promise.all([
70
+ lintWikiPages(),
71
+ listWikiProposals(),
72
+ reviewProjectMemories(),
73
+ detectRawObservationClusters()
74
+ ]);
75
+ const index = await fs.readFile(indexPath, 'utf8');
76
+ const indexEol = detectEol(index);
77
+ const maintenanceInbox = await fs.readFile(maintenanceInboxPath, 'utf8').catch(() => '');
78
+ const maintenanceInboxEol = '\n';
79
+ const reviewPageExists = async (reviewPath: string) => {
80
+ try {
81
+ await fs.access(path.resolve(process.cwd(), reviewPath));
82
+ return true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ };
87
+
88
+ const nextMaintenanceInbox = normalizeEol(
89
+ await buildMaintenanceInboxPage(findings, proposals, {
90
+ reviewPageExists,
91
+ memoryFindings: memoryReview.findings,
92
+ observationClusters
93
+ }),
94
+ maintenanceInboxEol
95
+ );
96
+ await writeIfChanged(maintenanceInboxPath, maintenanceInbox, nextMaintenanceInbox);
97
+
98
+ const maintenanceInboxData = await fs.readFile(maintenanceInboxDataPath, 'utf8').catch(() => '');
99
+ const nextMaintenanceInboxData = ensureTrailingEol(
100
+ JSON.stringify(
101
+ await buildMaintenanceInboxSnapshot(findings, proposals, {
102
+ reviewPageExists,
103
+ memoryFindings: memoryReview.findings,
104
+ observationClusters
105
+ }),
106
+ null,
107
+ 2
108
+ ),
109
+ '\n'
110
+ );
111
+ await writeIfChanged(maintenanceInboxDataPath, maintenanceInboxData, nextMaintenanceInboxData);
112
+
113
+ const observationStreamData = await fs.readFile(observationStreamDataPath, 'utf8').catch(() => '');
114
+ const recentObservations = await readRawObservations({ limit: observationStreamRecentSampleSize });
115
+ const nextObservationStreamData = ensureTrailingEol(
116
+ JSON.stringify(
117
+ {
118
+ schemaVersion: 1,
119
+ generatedAt: new Date().toISOString(),
120
+ sampleSize: observationStreamRecentSampleSize,
121
+ observationCount: recentObservations.length,
122
+ clusterCount: observationClusters.length,
123
+ observations: recentObservations
124
+ },
125
+ null,
126
+ 2
127
+ ),
128
+ '\n'
129
+ );
130
+ await writeIfChanged(observationStreamDataPath, observationStreamData, nextObservationStreamData);
131
+
132
+ const guidanceLifecycle = await listGuidanceLifecycle();
133
+ const currentGuidanceLifecycle = await fs.readFile(guidanceLifecyclePath, 'utf8').catch(() => '');
134
+ const nextGuidanceLifecycle = normalizeEol(buildGuidanceLifecyclePage(guidanceLifecycle), '\n');
135
+ await writeIfChanged(guidanceLifecyclePath, currentGuidanceLifecycle, nextGuidanceLifecycle);
136
+
137
+ const guidanceLifecycleData = await fs.readFile(guidanceLifecycleDataPath, 'utf8').catch(() => '');
138
+ const nextGuidanceLifecycleData = ensureTrailingEol(JSON.stringify({ guidance: guidanceLifecycle }, null, 2), '\n');
139
+ await writeIfChanged(guidanceLifecycleDataPath, guidanceLifecycleData, nextGuidanceLifecycleData);
140
+
141
+ const pages = await listWikiPages();
142
+
143
+ const searchIndexData = await fs.readFile(wikiSearchIndexPath, 'utf8').catch(() => '');
144
+ const nextSearchIndexData = ensureTrailingEol(
145
+ JSON.stringify(
146
+ {
147
+ graph: await buildWikiGraphSnapshot(),
148
+ sampleSearch: await searchWikiPages('project wiki')
149
+ },
150
+ null,
151
+ 2
152
+ ),
153
+ '\n'
154
+ );
155
+ await writeIfChanged(wikiSearchIndexPath, searchIndexData, nextSearchIndexData);
156
+ await writeSqliteSearchIndex(sqliteSearchIndexPath, pages);
157
+
158
+ const catalog = normalizeEol(
159
+ [
160
+ markerStart,
161
+ '',
162
+ '| Page | Slug |',
163
+ '|---|---|',
164
+ ...pages.map((page) => `| [${page.title}](./wiki/${page.slug}.md) | \`${page.slug}\` |`),
165
+ '',
166
+ markerEnd
167
+ ].join('\n'),
168
+ indexEol
169
+ );
170
+
171
+ let nextIndex = index;
172
+ if (nextIndex.includes(markerStart) && nextIndex.includes(markerEnd)) {
173
+ nextIndex = nextIndex.replace(new RegExp(`${markerStart}[\\s\\S]*?${markerEnd}`), catalog);
174
+ } else {
175
+ nextIndex += `${indexEol}${indexEol}## Generated Catalog${indexEol}${indexEol}${catalog}${indexEol}`;
176
+ }
177
+
178
+ await writeIfChanged(indexPath, index, ensureTrailingEol(nextIndex, indexEol));
179
+ return { pageCount: pages.length };
180
+ }
181
+
182
+ function buildGuidanceLifecyclePage(guidance: Awaited<ReturnType<typeof listGuidanceLifecycle>>): string {
183
+ const statusCounts = [...new Map(guidance.map((item) => [item.status, guidance.filter((candidate) => candidate.status === item.status).length])).entries()]
184
+ .sort(([left], [right]) => left.localeCompare(right));
185
+
186
+ return [
187
+ '# Guidance Lifecycle',
188
+ '',
189
+ 'This generated page shows active, dormant, superseded, and pending-review guidance files from the current project.',
190
+ '',
191
+ '## Status',
192
+ guidance.length > 0 ? `- Guidance files: ${guidance.length}` : '- Guidance files: none.',
193
+ statusCounts.length > 0
194
+ ? `- Status groups: ${statusCounts.map(([status, count]) => `\`${status}\` (${count})`).join(', ')}`
195
+ : '- Status groups: none.',
196
+ '',
197
+ '## Lifecycle Table',
198
+ guidance.length === 0
199
+ ? 'No guidance files found.'
200
+ : [
201
+ '| Path | Kind | Status | Review | Archive Target | Linked From | Reason |',
202
+ '|---|---|---|---|---|---|---|',
203
+ ...guidance.map((item) => `| ${escapeTableCell(item.path)} | \`${item.kind}\` | \`${item.status}\` | ${item.reviewStatus ?? 'none'} | ${escapeTableCell(item.archiveTarget ?? '')} | ${escapeTableCell(item.linkedFrom.join(', ') || 'none')} | ${escapeTableCell(item.reason)} |`)
204
+ ].join('\n'),
205
+ ''
206
+ ].join('\n');
207
+ }
208
+
209
+ function escapeTableCell(value: string): string {
210
+ return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
211
+ }
212
+
213
+ export async function writeLatestMaintenanceActionArtifact(artifact: MaintenanceActionArtifact): Promise<void> {
214
+ const currentContent = await fs.readFile(maintenanceActionResultPath, 'utf8').catch(() => '');
215
+ const nextContent = ensureTrailingEol(JSON.stringify(artifact, null, 2), '\n');
216
+ await writeIfChanged(maintenanceActionResultPath, currentContent, nextContent);
217
+ }
218
+
219
+ function detectEol(content: string): string {
220
+ return content.includes('\r\n') ? '\r\n' : '\n';
221
+ }
222
+
223
+ function normalizeEol(content: string, eol: string): string {
224
+ return content.replace(/\r?\n/g, eol);
225
+ }
226
+
227
+ function ensureTrailingEol(content: string, eol: string): string {
228
+ const withoutTrailingEol = content.replace(/(?:\r?\n)+$/g, '');
229
+ return `${withoutTrailingEol}${eol}`;
230
+ }
231
+
232
+ function extractSummaryParagraph(content: string): string {
233
+ const lines = content.split(/\r?\n/);
234
+ const h1Index = lines.findIndex((line) => /^#\s+\S+/.test(line));
235
+ const bodyLines = lines.slice(h1Index === -1 ? 0 : h1Index + 1);
236
+
237
+ for (const line of bodyLines) {
238
+ const trimmed = line.trim();
239
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('|') || trimmed.startsWith('- ') || /^\d+\.\s/.test(trimmed)) {
240
+ continue;
241
+ }
242
+ return trimmed;
243
+ }
244
+
245
+ return '';
246
+ }
247
+
248
+ async function writeIfChanged(filePath: string, currentContent: string, nextContent: string): Promise<void> {
249
+ if (currentContent === nextContent) {
250
+ return;
251
+ }
252
+
253
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
254
+ await fs.writeFile(filePath, nextContent, 'utf8');
255
+ }
256
+
257
+ async function writeSqliteSearchIndex(filePath: string, pages: WikiPageSummary[]): Promise<void> {
258
+ const sqliteModule = await loadNodeSqliteModule();
259
+ if (!sqliteModule) {
260
+ return;
261
+ }
262
+
263
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
264
+ await fs.rm(filePath, { force: true });
265
+
266
+ const database = new sqliteModule.DatabaseSync(filePath);
267
+ try {
268
+ database.exec([
269
+ 'CREATE VIRTUAL TABLE pages_fts USING fts5(slug, title, path, summary, content);',
270
+ 'CREATE VIRTUAL TABLE claims_fts USING fts5(page_slug, status, text, sources);',
271
+ 'CREATE TABLE graph_edges(source_slug TEXT NOT NULL, target_slug TEXT NOT NULL);'
272
+ ].join('\n'));
273
+
274
+ const pageByPath = new Map(pages.map((page) => [page.path, page.slug]));
275
+ const insertPage = database.prepare('INSERT INTO pages_fts(slug, title, path, summary, content) VALUES (?, ?, ?, ?, ?)');
276
+ const insertClaim = database.prepare('INSERT INTO claims_fts(page_slug, status, text, sources) VALUES (?, ?, ?, ?)');
277
+ const insertEdge = database.prepare('INSERT INTO graph_edges(source_slug, target_slug) VALUES (?, ?)');
278
+
279
+ for (const page of pages) {
280
+ const content = await readWikiPage(page.slug);
281
+ insertPage.run(page.slug, page.title, page.path, extractSummaryParagraph(content) || page.title, content);
282
+
283
+ for (const claim of extractWikiClaims(page.slug, content, pageByPath)) {
284
+ insertClaim.run(claim.pageSlug, claim.status, claim.text, claim.sources.map((source) => source.slug).join(' '));
285
+ for (const source of claim.sources) {
286
+ insertEdge.run(page.slug, source.slug);
287
+ }
288
+ }
289
+ }
290
+ } finally {
291
+ database.close();
292
+ }
293
+ }
294
+
295
+ async function loadNodeSqliteModule(): Promise<
296
+ | {
297
+ DatabaseSync: new (filePath: string) => {
298
+ exec: (sql: string) => void;
299
+ prepare: (sql: string) => { run: (...values: unknown[]) => void };
300
+ close: () => void;
301
+ };
302
+ }
303
+ | undefined
304
+ > {
305
+ const dynamicImport = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise<unknown>;
306
+ return dynamicImport('node:sqlite').catch(() => undefined) as Promise<
307
+ | {
308
+ DatabaseSync: new (filePath: string) => {
309
+ exec: (sql: string) => void;
310
+ prepare: (sql: string) => { run: (...values: unknown[]) => void };
311
+ close: () => void;
312
+ };
313
+ }
314
+ | undefined
315
+ >;
316
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,106 @@
1
+ // C7 slice 2: per-language modes for agent-facing strings.
2
+ //
3
+ // This is intentionally minimal: a single string-table per language code, resolved at call
4
+ // time via the DENDRITE_LANG env var. The default is English. The framework is the
5
+ // deliverable here; translations ship as forks or follow-up PRs once a real operator asks
6
+ // for one.
7
+ //
8
+ // Storage rules stay English-only: memory bodies, wiki pages, claims, and project-log
9
+ // entries are never translated by this module. Only operator-facing message text (cluster
10
+ // templates, ritual reminders, hook output) is routed through the i18n table.
11
+ //
12
+ // Adding a new language:
13
+ // 1. Pick an ISO 639-1 code (zh, ja, es, fr, de, etc.).
14
+ // 2. Add an entry to the `translations` map below with the message keys you want to
15
+ // localize. Missing keys fall back to English automatically.
16
+ // 3. Document the new code in docs/wiki/competitive-feature-roadmap.md (phase C7).
17
+
18
+ export type DendriteLangCode = string;
19
+
20
+ export type DendriteI18nKey =
21
+ | 'observation-cluster-template-header'
22
+ | 'observation-cluster-template-considerations'
23
+ | 'observation-cluster-template-options-edit-or-read'
24
+ | 'observation-cluster-template-options-default'
25
+ | 'observation-cluster-template-replace-instruction';
26
+
27
+ export interface DendriteI18nMessageBundle {
28
+ // Each entry is a function from interpolation values to string so callers can pass in
29
+ // dynamic values (counts, paths) without forcing eager substitution at load time.
30
+ [key: string]: (values: Record<string, string | number>) => string;
31
+ }
32
+
33
+ const englishMessages: DendriteI18nMessageBundle = {
34
+ 'observation-cluster-template-header': ({ kind, target, observationCount, distinctSessionCount, lastSeen }) =>
35
+ `Recurring activity detected: ${kind} on ${target} (${observationCount} observation${observationCount === 1 ? '' : 's'} across ${distinctSessionCount} session${distinctSessionCount === 1 ? '' : 's'}, last seen ${lastSeen}).`,
36
+ 'observation-cluster-template-considerations': () =>
37
+ 'Consider documenting why this {kindLabel} keeps coming up — for example:',
38
+ 'observation-cluster-template-options-edit-or-read': () =>
39
+ [
40
+ '- a setup or onboarding gotcha future agents should know about',
41
+ '- a refactoring target that has accumulated repeated edits',
42
+ '- a frequently-broken integration or test',
43
+ '- a workflow pattern worth promoting to a scope-bound skill'
44
+ ].join('\n'),
45
+ 'observation-cluster-template-options-default': () =>
46
+ [
47
+ '- a setup or onboarding gotcha future agents should know about',
48
+ '- a refactoring target that has accumulated repeated activity',
49
+ '- a frequently-broken integration or test',
50
+ '- a workflow pattern worth promoting to a scope-bound skill'
51
+ ].join('\n'),
52
+ 'observation-cluster-template-replace-instruction': () =>
53
+ 'Replace this template text with the actual lesson, then optionally promote to a skill via memory_promote_skill once the lesson has been recalled enough times.'
54
+ };
55
+
56
+ // Sample non-English bundle — Spanish — so the routing has a real second path to test.
57
+ // Operators adding more languages should follow this exact shape.
58
+ const spanishMessages: DendriteI18nMessageBundle = {
59
+ 'observation-cluster-template-header': ({ kind, target, observationCount, distinctSessionCount, lastSeen }) =>
60
+ `Actividad recurrente detectada: ${kind} en ${target} (${observationCount} observación${observationCount === 1 ? '' : 'es'} en ${distinctSessionCount} sesión${distinctSessionCount === 1 ? '' : 'es'}, vista por última vez ${lastSeen}).`,
61
+ 'observation-cluster-template-replace-instruction': () =>
62
+ 'Reemplaza este texto plantilla con la lección real y luego promociónalo a una skill mediante memory_promote_skill una vez que la lección haya sido recordada suficientes veces.'
63
+ };
64
+
65
+ const translations: Record<string, DendriteI18nMessageBundle> = {
66
+ en: englishMessages,
67
+ es: spanishMessages
68
+ };
69
+
70
+ export function resolveDendriteLang(env: NodeJS.ProcessEnv = process.env): DendriteLangCode {
71
+ const raw = (env.DENDRITE_LANG ?? '').trim().toLowerCase();
72
+ if (!raw) {
73
+ return 'en';
74
+ }
75
+ // Accept full BCP-47 codes like en-US by stripping to the primary subtag.
76
+ return raw.split('-')[0];
77
+ }
78
+
79
+ export function translate(
80
+ key: DendriteI18nKey,
81
+ values: Record<string, string | number> = {},
82
+ options: { lang?: DendriteLangCode } = {}
83
+ ): string {
84
+ const lang = options.lang ?? resolveDendriteLang();
85
+ const bundle = translations[lang];
86
+ const fallback = translations.en;
87
+
88
+ const localized = bundle?.[key];
89
+ if (localized) {
90
+ return localized(values);
91
+ }
92
+
93
+ const fallbackEntry = fallback?.[key];
94
+ if (fallbackEntry) {
95
+ return fallbackEntry(values);
96
+ }
97
+
98
+ // Never throw on a missing key — agent-facing surfaces would prefer to render the key
99
+ // than fail. Operators can grep for any literal key in output to catch missing entries.
100
+ return key;
101
+ }
102
+
103
+ // Public helper used in tests + by anyone who wants to inspect available languages.
104
+ export function listAvailableDendriteLangs(): DendriteLangCode[] {
105
+ return Object.keys(translations).sort();
106
+ }