@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
@@ -0,0 +1,842 @@
1
+ /**
2
+ * Maintenance inbox builder — synthesizes the unified queue of operator-actionable findings.
3
+ *
4
+ * Aggregates `wiki_lint` findings, project-memory review findings (skill-promotion-ready,
5
+ * stale, contradicting), pending wiki proposals, and recurring raw-observation clusters
6
+ * into a single ranked list. Each entry carries one or more typed actions (`apply`,
7
+ * `archive`, `snooze`, `promote`, `forget`, `quiet`) that the Review Board UI binds to
8
+ * its verb-grouped tabs. Rendering the inbox to markdown produces `docs/wiki/maintenance-
9
+ * inbox.md` (a human-browseable view) and a JSON twin under `docs/public/maintenance-
10
+ * inbox.json` (consumed by the in-browser Review Board).
11
+ *
12
+ * Operator-facing template strings (cluster summaries, ritual reminders) flow through the
13
+ * i18n table in `./i18n.ts` so non-English operator-facing copy can be localized without
14
+ * touching the inbox logic. Storage rules ensure memory bodies, wiki pages, and claims
15
+ * stay English-only — i18n is for messages addressed to humans, not durable knowledge.
16
+ */
17
+ import { statSync } from 'node:fs';
18
+ import path from 'node:path';
19
+ // Side-effect import: registers WikiCanonicalTarget on the brain DI surface.
20
+ import './canonical-target.js';
21
+ import { resolvePromotionTargetSlug } from '@rarusoft/dendrite-memory';
22
+ import { translate } from './i18n.js';
23
+ import { pagePathFromSlug } from './store.js';
24
+ export async function buildMaintenanceInboxSnapshot(findings, proposals, options = {}) {
25
+ const reviewPageExists = options.reviewPageExists ?? (async () => false);
26
+ const memoryFindings = options.memoryFindings ?? [];
27
+ const observationClusters = options.observationClusters ?? [];
28
+ const proposalGroups = summarizeProposalKinds(proposals);
29
+ const lintRuleGroups = summarizeLintRules(findings).map(({ rule, count }) => ({
30
+ bucket: lintRuleBucket[rule],
31
+ bucketTitle: lintBucketTitles[lintRuleBucket[rule]],
32
+ rule,
33
+ count
34
+ }));
35
+ const memoryKindGroups = summarizeMemoryReviewKinds(memoryFindings).map(({ kind, count }) => ({
36
+ kind,
37
+ title: memoryReviewKindTitles[kind],
38
+ count
39
+ }));
40
+ const nextSteps = renderNextSteps(findings, proposals, memoryFindings, observationClusters).map((line) => line.replace(/^\-\s*/, ''));
41
+ const groupedProposals = groupBy(proposals, (proposal) => proposal.kind);
42
+ const groupedFindings = groupBy(findings, (finding) => lintRuleBucket[finding.rule]);
43
+ const groupedMemoryFindings = groupBy(memoryFindings, (finding) => finding.kind);
44
+ return {
45
+ status: {
46
+ proposalCount: proposals.length,
47
+ lintFindingCount: findings.length,
48
+ proposalGroups,
49
+ lintRuleGroups,
50
+ memoryFindingCount: memoryFindings.length,
51
+ memoryKindGroups,
52
+ observationClusterCount: observationClusters.length
53
+ },
54
+ nextSteps,
55
+ proposals: await Promise.all([...groupedProposals.keys()].sort().map(async (kind) => {
56
+ const items = (groupedProposals.get(kind) ?? []).sort((left, right) => left.summary.localeCompare(right.summary));
57
+ return {
58
+ kind,
59
+ count: items.length,
60
+ items: await Promise.all(items.map(async (proposal) => {
61
+ const hasReviewPage = await reviewPageExists(proposal.reviewPath);
62
+ return {
63
+ summary: proposal.summary,
64
+ currentStateSummary: proposal.currentStateSummary,
65
+ afterApplySummary: proposal.afterApplySummary,
66
+ review: buildProposalReviewMetadata(proposal),
67
+ reviewSlug: proposal.reviewSlug,
68
+ reviewPath: proposal.reviewPath,
69
+ reviewPageExists: hasReviewPage,
70
+ actions: buildProposalActions(proposal.reviewSlug, hasReviewPage)
71
+ };
72
+ }))
73
+ };
74
+ })),
75
+ lintBuckets: lintBucketOrder
76
+ .filter((bucket) => groupedFindings.has(bucket))
77
+ .map((bucket) => {
78
+ const bucketFindings = groupedFindings.get(bucket) ?? [];
79
+ const ruleGroups = groupBy(bucketFindings, (finding) => finding.rule);
80
+ const orderedRules = [...ruleGroups.keys()].sort((left, right) => {
81
+ const countDelta = (ruleGroups.get(right)?.length ?? 0) - (ruleGroups.get(left)?.length ?? 0);
82
+ return countDelta !== 0 ? countDelta : left.localeCompare(right);
83
+ });
84
+ return {
85
+ bucket,
86
+ bucketTitle: lintBucketTitles[bucket],
87
+ count: bucketFindings.length,
88
+ rules: orderedRules.map((rule) => ({
89
+ rule,
90
+ count: ruleGroups.get(rule)?.length ?? 0,
91
+ items: (ruleGroups.get(rule) ?? [])
92
+ .sort((left, right) => left.path.localeCompare(right.path))
93
+ .map((finding) => ({
94
+ slug: finding.slug,
95
+ path: finding.path,
96
+ message: finding.message,
97
+ actions: buildLintActions(finding)
98
+ }))
99
+ }))
100
+ };
101
+ }),
102
+ memoryBuckets: memoryReviewKindOrder
103
+ .filter((kind) => groupedMemoryFindings.has(kind))
104
+ .map((kind) => {
105
+ const bucketFindings = (groupedMemoryFindings.get(kind) ?? []).sort((left, right) => left.summary.localeCompare(right.summary));
106
+ return {
107
+ kind,
108
+ title: memoryReviewKindTitles[kind],
109
+ count: bucketFindings.length,
110
+ items: bucketFindings.map((finding) => ({
111
+ summary: finding.summary,
112
+ reason: finding.reason,
113
+ memoryIds: finding.memoryIds,
114
+ // Only include inferredScope when actually present so existing snapshot
115
+ // consumers that deep-compare against fixtures don't see an undefined field.
116
+ ...(finding.inferredScope ? { inferredScope: finding.inferredScope } : {}),
117
+ records: finding.records.map((record) => ({
118
+ id: record.id,
119
+ kind: record.kind,
120
+ text: record.text,
121
+ recallCount: record.recallCount,
122
+ updatedAt: record.updatedAt,
123
+ sources: record.sources.map((source) => `${source.kind}:${source.slug}`),
124
+ relatedFiles: record.relatedFiles,
125
+ relatedPages: record.relatedPages
126
+ })),
127
+ actions: buildMemoryActions(finding)
128
+ }))
129
+ };
130
+ }),
131
+ observationClusters: observationClusters.map((cluster) => ({
132
+ kind: cluster.kind,
133
+ target: cluster.target,
134
+ observationCount: cluster.observationCount,
135
+ distinctSessionCount: cluster.distinctSessionCount,
136
+ firstSeen: cluster.firstSeen,
137
+ lastSeen: cluster.lastSeen,
138
+ outcomeCounts: cluster.outcomeCounts,
139
+ synapticTag: cluster.synapticTag,
140
+ suggestedSourceLink: buildClusterSourceLink(cluster),
141
+ actions: buildObservationClusterActions(cluster)
142
+ }))
143
+ };
144
+ }
145
+ function buildObservationClusterActions(cluster) {
146
+ const sourceLink = buildClusterSourceLink(cluster);
147
+ const safeTarget = cluster.target.replace(/[^a-zA-Z0-9_./-]/g, '_').slice(0, 80) || 'unknown';
148
+ const id = `cluster:${cluster.kind}:${safeTarget}:create-memory-from-cluster`;
149
+ const text = buildClusterMemoryTemplate(cluster);
150
+ const args = {
151
+ text,
152
+ kind: 'lesson',
153
+ tags: ['from-observation-cluster'],
154
+ sources: [sourceLink]
155
+ };
156
+ if (cluster.kind === 'edit' || cluster.kind === 'read') {
157
+ args.relatedFiles = [cluster.target];
158
+ }
159
+ return [
160
+ {
161
+ id,
162
+ kind: 'create-memory-from-cluster',
163
+ label: `Create a draft memory from this cluster (${cluster.kind} ${cluster.target})`,
164
+ tool: 'memory_remember',
165
+ arguments: args,
166
+ available: true
167
+ }
168
+ ];
169
+ }
170
+ function buildClusterMemoryTemplate(cluster) {
171
+ const isFileCluster = cluster.kind === 'edit' || cluster.kind === 'read';
172
+ const considerationsHeader = translate('observation-cluster-template-considerations', {}).replace('{kindLabel}', isFileCluster ? 'file' : 'target');
173
+ const optionsKey = isFileCluster
174
+ ? 'observation-cluster-template-options-edit-or-read'
175
+ : 'observation-cluster-template-options-default';
176
+ return [
177
+ '[draft from observation cluster — EDIT THIS TEXT before relying on it]',
178
+ translate('observation-cluster-template-header', {
179
+ kind: cluster.kind,
180
+ target: cluster.target,
181
+ observationCount: cluster.observationCount,
182
+ distinctSessionCount: cluster.distinctSessionCount,
183
+ lastSeen: cluster.lastSeen
184
+ }),
185
+ '',
186
+ considerationsHeader,
187
+ translate(optionsKey, {}),
188
+ '',
189
+ translate('observation-cluster-template-replace-instruction', {})
190
+ ].join('\n');
191
+ }
192
+ function buildClusterSourceLink(cluster) {
193
+ if (cluster.kind === 'edit' || cluster.kind === 'read') {
194
+ return `file:${cluster.target}`;
195
+ }
196
+ if (cluster.kind === 'command') {
197
+ return `command:${cluster.target}`;
198
+ }
199
+ return cluster.target;
200
+ }
201
+ export async function buildMaintenanceInboxPage(findings, proposals, options = {}) {
202
+ // This page used to be a ~1,300-line auto-generated text dump of every active finding,
203
+ // duplicating what the interactive Review Board now shows with action buttons, previews,
204
+ // and verb-grouped triage. The dump was unreviewable at scale and surfaced no actionable
205
+ // affordances the operator could click.
206
+ //
207
+ // It now collapses to a thin counts-only stub that routes any stale bookmark or sidebar
208
+ // entry to `/review-board`. The structured `maintenance-inbox.json` artifact still carries
209
+ // the full grouped data — the board reads that JSON directly and is the authoritative
210
+ // surface. The unused renderers (renderProposalSection, renderLintSection,
211
+ // renderMemoryReviewSection, renderObservationClusterSection, etc.) are kept exported
212
+ // for callers that consume them programmatically.
213
+ const memoryFindings = options.memoryFindings ?? [];
214
+ const observationClusters = options.observationClusters ?? [];
215
+ return [
216
+ '# Maintenance Inbox',
217
+ '',
218
+ 'The interactive maintenance surface lives at **[Review Board](/review-board)**. It shows the same findings this page used to dump as text, plus per-item previews, action buttons, and verb-grouped triage (Promote / Reconcile / Quiet).',
219
+ '',
220
+ '## Right Now',
221
+ `- ${proposals.length} active proposal${proposals.length === 1 ? '' : 's'}`,
222
+ `- ${findings.length} active lint finding${findings.length === 1 ? '' : 's'}`,
223
+ `- ${memoryFindings.length} active memory review finding${memoryFindings.length === 1 ? '' : 's'}`,
224
+ `- ${observationClusters.length} active observation cluster${observationClusters.length === 1 ? '' : 's'}`,
225
+ '',
226
+ '**[→ Open the Review Board](/review-board)** to act on these. The structured snapshot powering both surfaces lives at `docs/public/maintenance-inbox.json`; CLI consumers can run `npx dendrite-wiki wiki:action -- "<action-id>"` against any item id from that file.',
227
+ ''
228
+ ].join('\n');
229
+ }
230
+ function renderObservationClusterSection(clusters) {
231
+ if (clusters.length === 0) {
232
+ return [
233
+ 'No raw observation clusters have crossed the promotion threshold yet.',
234
+ '',
235
+ 'Clusters are detected from `local-data/raw-observations.jsonl` (captured automatically by the PostToolUse hook). A cluster surfaces here when the same `(kind, target)` pair recurs at least 3 times across at least 2 distinct sessions.'
236
+ ];
237
+ }
238
+ const lines = [
239
+ 'Each row is a recurring `(kind, target)` activity pattern that may deserve a curated memory. Rows are sorted with **verified-success** clusters first so reviewer attention compounds on learned-and-verified knowledge — clusters from sessions that ran a passing test/build/typecheck command. **likely-error** clusters (sessions that ended in errors without a successful verification) sort to the bottom. Use `memory_remember` with the `Suggested Source` link below to capture why this target keeps coming up.',
240
+ '',
241
+ '| Tag | Kind | Target | Observations | Sessions (success/error/inconclusive) | Last Seen | Outcomes (ok/error/unknown) | Suggested Source |',
242
+ '|---|---|---|---:|---|---|---|---|'
243
+ ];
244
+ for (const cluster of clusters) {
245
+ const outcomes = `${cluster.outcomeCounts.ok}/${cluster.outcomeCounts.error}/${cluster.outcomeCounts.unknown}`;
246
+ const sessionMix = `${cluster.synapticTag.successSessionCount}/${cluster.synapticTag.errorSessionCount}/${cluster.synapticTag.inconclusiveSessionCount}`;
247
+ const tagBadge = renderSynapticTagBadge(cluster.synapticTag.synapticTag);
248
+ lines.push(`| ${tagBadge} | \`${cluster.kind}\` | ${escapeCell(cluster.target)} | ${cluster.observationCount} | ${sessionMix} | ${cluster.lastSeen} | ${outcomes} | \`${buildClusterSourceLink(cluster)}\` |`);
249
+ }
250
+ return lines;
251
+ }
252
+ function renderSynapticTagBadge(tag) {
253
+ switch (tag) {
254
+ case 'verified-success':
255
+ return '`verified-success`';
256
+ case 'likely-error':
257
+ return '`likely-error`';
258
+ case 'inconclusive':
259
+ return '`inconclusive`';
260
+ }
261
+ }
262
+ export async function findMaintenanceInboxAction(actionId, findings, proposals, options = {}) {
263
+ const snapshot = await buildMaintenanceInboxSnapshot(findings, proposals, options);
264
+ for (const proposalGroup of snapshot.proposals) {
265
+ for (const item of proposalGroup.items) {
266
+ const action = item.actions.find((candidate) => candidate.id === actionId);
267
+ if (action) {
268
+ return {
269
+ action,
270
+ source: {
271
+ type: 'proposal',
272
+ kind: proposalGroup.kind,
273
+ reviewSlug: item.reviewSlug
274
+ }
275
+ };
276
+ }
277
+ }
278
+ }
279
+ for (const lintBucket of snapshot.lintBuckets) {
280
+ for (const ruleGroup of lintBucket.rules) {
281
+ for (const item of ruleGroup.items) {
282
+ const action = item.actions.find((candidate) => candidate.id === actionId);
283
+ if (action) {
284
+ return {
285
+ action,
286
+ source: {
287
+ type: 'lint',
288
+ bucket: lintBucket.bucket,
289
+ rule: ruleGroup.rule,
290
+ path: item.path
291
+ }
292
+ };
293
+ }
294
+ }
295
+ }
296
+ }
297
+ for (const memoryBucket of snapshot.memoryBuckets) {
298
+ for (const item of memoryBucket.items) {
299
+ const action = item.actions.find((candidate) => candidate.id === actionId);
300
+ if (action) {
301
+ return {
302
+ action,
303
+ source: {
304
+ type: 'memory',
305
+ kind: memoryBucket.kind,
306
+ memoryIds: item.memoryIds
307
+ }
308
+ };
309
+ }
310
+ }
311
+ }
312
+ for (const cluster of snapshot.observationClusters) {
313
+ const action = cluster.actions.find((candidate) => candidate.id === actionId);
314
+ if (action) {
315
+ return {
316
+ action,
317
+ source: {
318
+ type: 'observation-cluster',
319
+ clusterKind: cluster.kind,
320
+ target: cluster.target
321
+ }
322
+ };
323
+ }
324
+ }
325
+ return undefined;
326
+ }
327
+ function renderNextSteps(findings, proposals, memoryFindings, observationClusters = []) {
328
+ const steps = ['- Read [Proposal Workflow](./proposal-workflow.md) for the review and apply flow.'];
329
+ if (proposals.length > 0) {
330
+ steps.push('- Run `wiki_write_proposals` to materialize review pages under `docs/wiki/pending-review/`.');
331
+ steps.push('- Review the proposal group tables below and open any linked review pages before applying changes.');
332
+ }
333
+ else {
334
+ steps.push('- No proposal pages need to be generated right now.');
335
+ }
336
+ if (findings.length > 0) {
337
+ steps.push('- Resolve the lint buckets below, starting with the `review-now` rules before the cleanup-only rules.');
338
+ steps.push('- Rerun `npm run wiki:refresh` or `npm run check` after fixes so the inbox reflects the current state.');
339
+ }
340
+ else {
341
+ steps.push('- The lint queue is clear right now.');
342
+ }
343
+ if (memoryFindings.length > 0) {
344
+ steps.push('- Review stale, unsupported, and contradictory memories first, then archive or consolidate duplicates with `memory_forget` where appropriate.');
345
+ steps.push('- Promote repeated source-backed lessons into canonical wiki pages once the memory findings confirm they are stable enough to keep.');
346
+ }
347
+ else {
348
+ steps.push('- The memory review queue is clear right now.');
349
+ }
350
+ if (observationClusters.length > 0) {
351
+ steps.push('- Inspect the observation clusters below; for each one, decide whether the recurring activity deserves a curated `memory_remember` capture (lesson, fact, or skill).');
352
+ steps.push('- Run `dendrite-wiki observations:list` to see the underlying raw observations behind any cluster.');
353
+ }
354
+ else {
355
+ steps.push('- No raw observation clusters have crossed the promotion threshold yet.');
356
+ }
357
+ return steps;
358
+ }
359
+ function renderProposalSummarySection(proposalCounts) {
360
+ if (proposalCounts.length === 0) {
361
+ return ['No active proposal groups.'];
362
+ }
363
+ return [
364
+ '| Kind | Count |',
365
+ '|---|---:|',
366
+ ...proposalCounts.map(({ kind, count }) => `| \`${kind}\` | ${count} |`)
367
+ ];
368
+ }
369
+ async function renderProposalSection(proposals, reviewPageExists) {
370
+ if (proposals.length === 0) {
371
+ return ['No active proposals.'];
372
+ }
373
+ const lines = [];
374
+ const groupedProposals = groupBy(proposals, (proposal) => proposal.kind);
375
+ for (const kind of [...groupedProposals.keys()].sort()) {
376
+ const group = groupedProposals.get(kind) ?? [];
377
+ lines.push(`### \`${kind}\` (${group.length})`, '');
378
+ lines.push('| Summary | Rationale | Affected Paths | Current State | After Apply | Undo Path | Review Page |');
379
+ lines.push('|---|---|---|---|---|---|---|');
380
+ for (const proposal of group.sort((left, right) => left.summary.localeCompare(right.summary))) {
381
+ const review = buildProposalReviewMetadata(proposal);
382
+ lines.push(`| ${escapeCell(proposal.summary)} | ${escapeCell(review.rationale)} | ${escapeCell(review.affectedPaths.join(', '))} | ${escapeCell(review.beforeSnippet)} | ${escapeCell(review.afterSnippet)} | ${escapeCell(review.undoPath)} | ${await formatReviewCell(proposal.reviewSlug, proposal.reviewPath, reviewPageExists)} |`);
383
+ }
384
+ lines.push('');
385
+ }
386
+ if (lines.at(-1) === '') {
387
+ lines.pop();
388
+ }
389
+ return lines;
390
+ }
391
+ function renderLintSummarySection(lintCounts) {
392
+ if (lintCounts.length === 0) {
393
+ return ['No active lint groups.'];
394
+ }
395
+ return [
396
+ '| Bucket | Rule | Count |',
397
+ '|---|---|---:|',
398
+ ...lintCounts.map(({ rule, count }) => `| ${lintBucketTitles[lintRuleBucket[rule]]} | \`${rule}\` | ${count} |`)
399
+ ];
400
+ }
401
+ function renderLintSection(findings) {
402
+ if (findings.length === 0) {
403
+ return ['No active lint findings.'];
404
+ }
405
+ const lines = [];
406
+ const groupedFindings = groupBy(findings, (finding) => lintRuleBucket[finding.rule]);
407
+ for (const bucket of lintBucketOrder.filter((candidate) => groupedFindings.has(candidate))) {
408
+ const bucketFindings = groupedFindings.get(bucket) ?? [];
409
+ lines.push(`### ${lintBucketTitles[bucket]} (${bucketFindings.length})`, '');
410
+ const ruleGroups = groupBy(bucketFindings, (finding) => finding.rule);
411
+ const orderedRules = [...ruleGroups.keys()].sort((left, right) => {
412
+ const countDelta = (ruleGroups.get(right)?.length ?? 0) - (ruleGroups.get(left)?.length ?? 0);
413
+ return countDelta !== 0 ? countDelta : left.localeCompare(right);
414
+ });
415
+ for (const rule of orderedRules) {
416
+ const ruleFindings = (ruleGroups.get(rule) ?? []).sort((left, right) => left.path.localeCompare(right.path));
417
+ lines.push(`#### \`${rule}\` (${ruleFindings.length})`, '');
418
+ lines.push('| Path | Message |');
419
+ lines.push('|---|---|');
420
+ lines.push(...ruleFindings.map((finding) => `| ${formatPathCell(finding.path)} | ${escapeCell(finding.message)} |`));
421
+ lines.push('');
422
+ }
423
+ }
424
+ if (lines.at(-1) === '') {
425
+ lines.pop();
426
+ }
427
+ return lines;
428
+ }
429
+ function renderMemoryReviewSummarySection(memoryCounts) {
430
+ if (memoryCounts.length === 0) {
431
+ return ['No active memory review groups.'];
432
+ }
433
+ return [
434
+ '| Kind | Count |',
435
+ '|---|---:|',
436
+ ...memoryCounts.map(({ kind, count }) => `| ${memoryReviewKindTitles[kind]} | ${count} |`)
437
+ ];
438
+ }
439
+ function renderMemoryReviewSection(memoryFindings) {
440
+ if (memoryFindings.length === 0) {
441
+ return ['No active memory review findings.'];
442
+ }
443
+ const lines = [];
444
+ const groupedFindings = groupBy(memoryFindings, (finding) => finding.kind);
445
+ for (const kind of memoryReviewKindOrder.filter((candidate) => groupedFindings.has(candidate))) {
446
+ const group = (groupedFindings.get(kind) ?? []).sort((left, right) => left.summary.localeCompare(right.summary));
447
+ lines.push(`### ${memoryReviewKindTitles[kind]} (${group.length})`, '');
448
+ for (const finding of group) {
449
+ lines.push(`#### ${escapeMarkdownForVue(finding.summary)}`, '');
450
+ lines.push(`**Why this surfaced:** ${finding.reason}`, '');
451
+ if (finding.memoryIds.length > 1) {
452
+ lines.push(`**Memory IDs covered:** ${finding.memoryIds.join(', ')}`, '');
453
+ }
454
+ for (const record of finding.records) {
455
+ lines.push(`- **Memory ID:** \`${record.id}\` (kind: \`${record.kind}\`, recalled ${record.recallCount}x)`);
456
+ if (record.sources.length > 0) {
457
+ lines.push(`- **Sources:** ${record.sources.map((source) => `\`${source.kind}:${source.slug}\``).join(', ')}`);
458
+ }
459
+ else {
460
+ lines.push('- **Sources:** none');
461
+ }
462
+ if (record.relatedPages.length > 0) {
463
+ lines.push(`- **Related pages:** ${record.relatedPages.map((page) => `\`${page}\``).join(', ')}`);
464
+ }
465
+ if (record.relatedFiles.length > 0) {
466
+ lines.push(`- **Related files:** ${record.relatedFiles.map((file) => `\`${file}\``).join(', ')}`);
467
+ }
468
+ lines.push('', '> ' + escapeMarkdownForVue(record.text).replace(/\n/g, '\n> '), '');
469
+ }
470
+ const actions = buildMemoryActions(finding);
471
+ if (actions.length > 0) {
472
+ lines.push('**Actions:**', '');
473
+ for (const action of actions) {
474
+ if (action.available) {
475
+ lines.push(`- ${action.label} — run from the repo root:`);
476
+ lines.push('');
477
+ lines.push(' ```bash');
478
+ lines.push(` npm run wiki:action -- "${action.id}"`);
479
+ lines.push(' ```');
480
+ }
481
+ else {
482
+ lines.push(`- ${action.label} (blocked${action.reason ? `: ${action.reason}` : ''})`);
483
+ }
484
+ }
485
+ lines.push('', 'Or click **Run now** for any of these on the [Maintenance Review](./maintenance-review.md) page once `npm run review-bridge` is running. Apply actions ask for confirmation.', '');
486
+ }
487
+ }
488
+ }
489
+ if (lines.at(-1) === '') {
490
+ lines.pop();
491
+ }
492
+ return lines;
493
+ }
494
+ async function formatReviewCell(reviewSlug, reviewPath, reviewPageExists) {
495
+ if (await reviewPageExists(reviewPath)) {
496
+ return `[${escapeCell(reviewSlug)}](./${reviewSlug}.md)`;
497
+ }
498
+ return `\`${escapeCell(reviewSlug)}\` (run \`wiki_write_proposals\`)`;
499
+ }
500
+ function formatPathCell(targetPath) {
501
+ if (!targetPath.startsWith('docs/')) {
502
+ return `\`${escapeCell(targetPath)}\``;
503
+ }
504
+ const relativePath = path.posix.relative('docs/wiki', targetPath.replace(/\\/g, '/')) || '.';
505
+ return `[${escapeCell(targetPath)}](${relativePath})`;
506
+ }
507
+ function summarizeProposalKinds(activeProposals) {
508
+ return [...groupBy(activeProposals, (proposal) => proposal.kind).entries()]
509
+ .map(([kind, group]) => ({ kind, count: group.length }))
510
+ .sort((left, right) => right.count - left.count || left.kind.localeCompare(right.kind));
511
+ }
512
+ function summarizeLintRules(activeFindings) {
513
+ return [...groupBy(activeFindings, (finding) => finding.rule).entries()]
514
+ .map(([rule, group]) => ({ rule, count: group.length }))
515
+ .sort((left, right) => {
516
+ const bucketDelta = lintBucketOrder.indexOf(lintRuleBucket[left.rule]) - lintBucketOrder.indexOf(lintRuleBucket[right.rule]);
517
+ return bucketDelta !== 0 ? bucketDelta : right.count - left.count || left.rule.localeCompare(right.rule);
518
+ });
519
+ }
520
+ function summarizeMemoryReviewKinds(activeFindings) {
521
+ return [...groupBy(activeFindings, (finding) => finding.kind).entries()]
522
+ .map(([kind, group]) => ({ kind, count: group.length }))
523
+ .sort((left, right) => memoryReviewKindOrder.indexOf(left.kind) - memoryReviewKindOrder.indexOf(right.kind));
524
+ }
525
+ function buildProposalActions(reviewSlug, reviewPageExists) {
526
+ return [
527
+ {
528
+ id: buildProposalActionId(reviewSlug, 'refresh-review-pages'),
529
+ kind: 'refresh-review-pages',
530
+ label: 'Refresh review pages',
531
+ tool: 'wiki_write_proposals',
532
+ arguments: {},
533
+ available: true
534
+ },
535
+ {
536
+ id: buildProposalActionId(reviewSlug, 'read-review-page'),
537
+ kind: 'read-review-page',
538
+ label: 'Read review page',
539
+ tool: 'wiki_read',
540
+ arguments: { slug: reviewSlug },
541
+ available: reviewPageExists,
542
+ reason: reviewPageExists ? undefined : 'Run wiki_write_proposals first to materialize the pending-review page.'
543
+ },
544
+ {
545
+ id: buildProposalActionId(reviewSlug, 'apply-proposal'),
546
+ kind: 'apply-proposal',
547
+ label: 'Apply proposal',
548
+ tool: 'wiki_apply_proposal',
549
+ arguments: { reviewSlug },
550
+ available: true
551
+ }
552
+ ];
553
+ }
554
+ function buildProposalReviewMetadata(proposal) {
555
+ if (proposal.kind === 'merge-guidance') {
556
+ return {
557
+ rationale: proposal.rationale,
558
+ affectedPaths: proposal.duplicatePaths,
559
+ beforeSnippet: proposal.currentStateSummary,
560
+ afterSnippet: proposal.afterApplySummary,
561
+ undoPath: `Before committing, inspect the changed duplicate files with git diff and restore ${proposal.duplicatePaths.join(', ')} from version control if the merge is not wanted.`
562
+ };
563
+ }
564
+ return {
565
+ rationale: proposal.rationale,
566
+ affectedPaths: [proposal.guidancePath],
567
+ beforeSnippet: proposal.currentStateSummary,
568
+ afterSnippet: proposal.afterApplySummary,
569
+ undoPath: `Before committing, inspect the changed guidance file with git diff and restore ${proposal.guidancePath} from version control if the route is not wanted.`
570
+ };
571
+ }
572
+ function buildLintActions(finding) {
573
+ const actions = [
574
+ {
575
+ id: buildLintActionId(finding, 'rerun-lint'),
576
+ kind: 'rerun-lint',
577
+ label: 'Re-run lint',
578
+ tool: 'wiki_lint',
579
+ arguments: {},
580
+ available: true
581
+ }
582
+ ];
583
+ const wikiSlug = pathToWikiSlug(finding.path);
584
+ if (wikiSlug) {
585
+ actions.unshift({
586
+ id: buildLintActionId(finding, 'read-wiki-page'),
587
+ kind: 'read-wiki-page',
588
+ label: 'Read wiki page',
589
+ tool: 'wiki_read',
590
+ arguments: { slug: wikiSlug },
591
+ available: true
592
+ });
593
+ }
594
+ if (proposalRelatedLintRules.has(finding.rule)) {
595
+ actions.push({
596
+ id: buildLintActionId(finding, 'check-proposals'),
597
+ kind: 'check-proposals',
598
+ label: 'Check related proposals',
599
+ tool: 'wiki_proposals',
600
+ arguments: {},
601
+ available: true
602
+ });
603
+ }
604
+ // Rule-specific resolve actions. These are the buttons that actually CLOSE the finding
605
+ // without forcing the operator to leave the board and edit a file. Each is a deterministic
606
+ // single-click outcome — anything that needs editorial judgment (claim text, summary
607
+ // wording) deliberately stays a Read+Re-run pair so the operator can't auto-rubber-stamp it.
608
+ if (finding.rule === 'page-drift' && wikiSlug) {
609
+ actions.unshift({
610
+ id: buildLintActionId(finding, 'snooze-page-drift'),
611
+ kind: 'snooze-page-drift',
612
+ label: 'Snooze 30 days',
613
+ tool: 'wiki_snooze_page_drift',
614
+ arguments: { slug: wikiSlug, days: 30 },
615
+ available: true
616
+ });
617
+ // Edit-summary action: only consumed by the inline drift-resolver editor, which
618
+ // supplies the operator's draft via the bridge's narrow summaryDraft field. Stays
619
+ // available=true so the bridge dispatcher accepts it; the executor's runtime check
620
+ // (require non-empty newFirstParagraph) prevents an accidental empty rewrite if it
621
+ // is ever invoked without a draft. The Vue layer filters this kind out of the
622
+ // visible secondary-actions list so it never appears as a stray clickable button.
623
+ actions.push({
624
+ id: buildLintActionId(finding, 'edit-page-summary'),
625
+ kind: 'edit-page-summary',
626
+ label: 'Rewrite first paragraph',
627
+ tool: 'wiki_edit_summary',
628
+ arguments: { slug: wikiSlug, newFirstParagraph: '' },
629
+ available: true
630
+ });
631
+ }
632
+ if (finding.rule === 'missing-h1' && wikiSlug) {
633
+ actions.unshift({
634
+ id: buildLintActionId(finding, 'insert-h1'),
635
+ kind: 'insert-h1',
636
+ label: 'Insert H1 from slug',
637
+ tool: 'wiki_insert_h1',
638
+ arguments: { slug: wikiSlug },
639
+ available: true
640
+ });
641
+ }
642
+ if (finding.rule === 'dormant-skill') {
643
+ actions.unshift({
644
+ id: buildLintActionId(finding, 'archive-guidance-file'),
645
+ kind: 'archive-guidance-file',
646
+ label: 'Archive skill file',
647
+ tool: 'wiki_archive_guidance',
648
+ arguments: { path: finding.path },
649
+ available: true
650
+ });
651
+ }
652
+ return actions;
653
+ }
654
+ function buildMemoryActions(finding) {
655
+ if (finding.kind === 'stale' || finding.kind === 'unsupported') {
656
+ return finding.memoryIds.slice(0, 1).map((memoryId) => ({
657
+ id: buildMemoryActionId(finding, 'archive-memory', memoryId),
658
+ kind: 'archive-memory',
659
+ label: 'Archive memory',
660
+ tool: 'memory_forget',
661
+ arguments: {
662
+ id: memoryId,
663
+ mode: 'archive'
664
+ },
665
+ available: true
666
+ }));
667
+ }
668
+ if (finding.kind === 'growing') {
669
+ // Growing memories are healthy by default; the only manual action is the archive
670
+ // relief valve so the operator can retire something they recognize as junk without
671
+ // waiting for it to age into a stale flag.
672
+ return finding.memoryIds.slice(0, 1).map((memoryId) => ({
673
+ id: buildMemoryActionId(finding, 'archive-memory', memoryId),
674
+ kind: 'archive-memory',
675
+ label: 'Archive memory (manual)',
676
+ tool: 'memory_forget',
677
+ arguments: {
678
+ id: memoryId,
679
+ mode: 'archive'
680
+ },
681
+ available: true
682
+ }));
683
+ }
684
+ if (finding.kind === 'duplicate') {
685
+ const duplicateIds = finding.records.slice(1).map((record) => record.id);
686
+ const archiveIds = duplicateIds.length > 0 ? duplicateIds : finding.memoryIds.slice(1);
687
+ return archiveIds.map((memoryId) => ({
688
+ id: buildMemoryActionId(finding, 'archive-memory', memoryId),
689
+ kind: 'archive-memory',
690
+ label: 'Archive older duplicate',
691
+ tool: 'memory_forget',
692
+ arguments: {
693
+ id: memoryId,
694
+ mode: 'archive'
695
+ },
696
+ available: true
697
+ }));
698
+ }
699
+ if (finding.kind === 'skill-promotion-ready') {
700
+ // Each skill-promotion-ready finding wraps a single memory; use the first id and let
701
+ // the executor read the rest of the scope from the memory record itself. Passing no
702
+ // explicit scope here means promoteMemoryToSkill re-runs inferSkillScopeFromMemory at
703
+ // apply time — which is the same scope the inbox surfaced at finding time, and stays
704
+ // current if the memory was edited between review and apply.
705
+ const targetMemoryId = finding.memoryIds[0];
706
+ if (!targetMemoryId) {
707
+ return [];
708
+ }
709
+ return [
710
+ {
711
+ id: buildMemoryActionId(finding, 'promote-memory-to-skill', targetMemoryId),
712
+ kind: 'promote-memory-to-skill',
713
+ label: 'Promote to skill (inferred scope)',
714
+ tool: 'memory_promote_skill',
715
+ arguments: {
716
+ memoryId: targetMemoryId
717
+ },
718
+ available: true
719
+ },
720
+ {
721
+ id: buildMemoryActionId(finding, 'archive-memory', targetMemoryId),
722
+ kind: 'archive-memory',
723
+ label: 'Archive memory (decline promotion)',
724
+ tool: 'memory_forget',
725
+ arguments: {
726
+ id: targetMemoryId,
727
+ mode: 'archive'
728
+ },
729
+ available: true
730
+ }
731
+ ];
732
+ }
733
+ if (finding.kind !== 'promotion-ready') {
734
+ return [];
735
+ }
736
+ const applyAvailability = resolveMemoryPromotionAvailability(finding);
737
+ return [
738
+ {
739
+ id: buildMemoryActionId(finding, 'draft-memory-promotion'),
740
+ kind: 'draft-memory-promotion',
741
+ label: 'Draft promotion',
742
+ tool: 'memory_promote',
743
+ arguments: {
744
+ memoryIds: finding.memoryIds,
745
+ mode: 'draft'
746
+ },
747
+ available: true
748
+ },
749
+ {
750
+ id: buildMemoryActionId(finding, 'apply-memory-promotion'),
751
+ kind: 'apply-memory-promotion',
752
+ label: 'Apply promotion',
753
+ tool: 'memory_promote',
754
+ arguments: {
755
+ memoryIds: finding.memoryIds,
756
+ mode: 'apply'
757
+ },
758
+ available: applyAvailability.available,
759
+ reason: applyAvailability.reason
760
+ }
761
+ ];
762
+ }
763
+ function resolveMemoryPromotionAvailability(finding) {
764
+ // Use the same target-resolution logic the draft and apply paths use, so the inbox gate
765
+ // matches the actual behavior. If the target page exists on disk, apply is safe.
766
+ const targetSlug = resolvePromotionTargetSlug(finding.records);
767
+ try {
768
+ statSync(pagePathFromSlug(targetSlug));
769
+ return { available: true };
770
+ }
771
+ catch {
772
+ return {
773
+ available: false,
774
+ reason: `The target wiki page ${targetSlug} does not exist yet. Draft the promotion first and create or choose a canonical target before applying it.`
775
+ };
776
+ }
777
+ }
778
+ function pathToWikiSlug(targetPath) {
779
+ const normalizedPath = targetPath.replace(/\\/g, '/');
780
+ const match = normalizedPath.match(/^docs\/wiki\/(.+)\.md$/);
781
+ return match?.[1];
782
+ }
783
+ function buildProposalActionId(reviewSlug, actionKind) {
784
+ return `proposal:${reviewSlug}:${actionKind}`;
785
+ }
786
+ function buildLintActionId(finding, actionKind) {
787
+ return `lint:${finding.rule}:${finding.path}:${actionKind}`;
788
+ }
789
+ function buildMemoryActionId(finding, actionKind, memoryId = finding.memoryIds.join('+')) {
790
+ return `memory:${finding.kind}:${memoryId}:${actionKind}`;
791
+ }
792
+ function groupBy(items, keySelector) {
793
+ const groups = new Map();
794
+ for (const item of items) {
795
+ const key = keySelector(item);
796
+ const group = groups.get(key);
797
+ if (group) {
798
+ group.push(item);
799
+ }
800
+ else {
801
+ groups.set(key, [item]);
802
+ }
803
+ }
804
+ return groups;
805
+ }
806
+ function escapeCell(value) {
807
+ return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
808
+ }
809
+ function escapeMarkdownForVue(value) {
810
+ return value.replace(/</g, '&lt;').replace(/>/g, '&gt;');
811
+ }
812
+ const lintBucketOrder = ['review-now', 'cleanup'];
813
+ const lintBucketTitles = {
814
+ 'review-now': 'Review Now',
815
+ cleanup: 'Cleanup Queue'
816
+ };
817
+ const lintRuleBucket = {
818
+ 'missing-h1': 'cleanup',
819
+ 'missing-summary': 'cleanup',
820
+ 'orphan-page': 'cleanup',
821
+ 'stale-claim': 'review-now',
822
+ 'unsupported-claim': 'review-now',
823
+ 'dormant-skill': 'cleanup',
824
+ 'oversized-guidance': 'cleanup',
825
+ 'duplicate-guidance': 'cleanup',
826
+ 'stale-guidance-reference': 'review-now',
827
+ 'conflicting-guidance': 'review-now',
828
+ 'unrouted-guidance': 'cleanup',
829
+ 'page-drift': 'review-now',
830
+ 'contradicts-shipped-memory': 'review-now'
831
+ };
832
+ const proposalRelatedLintRules = new Set(['duplicate-guidance', 'oversized-guidance']);
833
+ const memoryReviewKindOrder = ['stale', 'unsupported', 'duplicate', 'contradiction', 'promotion-ready', 'skill-promotion-ready', 'growing'];
834
+ const memoryReviewKindTitles = {
835
+ stale: 'Stale',
836
+ unsupported: 'Unsupported',
837
+ duplicate: 'Duplicate',
838
+ contradiction: 'Contradiction',
839
+ 'promotion-ready': 'Promotion Ready',
840
+ 'skill-promotion-ready': 'Skill Promotion Ready',
841
+ growing: 'Growing'
842
+ };