@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,1132 @@
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 type { ProjectMemoryReviewFinding, ProjectMemoryReviewKind } from '@rarusoft/dendrite-memory';
23
+ import type { RawObservationCluster } from '@rarusoft/dendrite-memory';
24
+ import { translate } from './i18n.js';
25
+ import { pagePathFromSlug, type WikiLintFinding, type WikiLintRule, type WikiProposal } from './store.js';
26
+
27
+ export interface MaintenanceInboxRenderOptions {
28
+ reviewPageExists?: (reviewPath: string) => Promise<boolean>;
29
+ memoryFindings?: ProjectMemoryReviewFinding[];
30
+ observationClusters?: RawObservationCluster[];
31
+ }
32
+
33
+ export interface MaintenanceInboxActionHint {
34
+ id: string;
35
+ kind:
36
+ | 'read-review-page'
37
+ | 'refresh-review-pages'
38
+ | 'apply-proposal'
39
+ | 'archive-memory'
40
+ | 'draft-memory-promotion'
41
+ | 'apply-memory-promotion'
42
+ | 'promote-memory-to-skill'
43
+ | 'create-memory-from-cluster'
44
+ | 'read-wiki-page'
45
+ | 'check-proposals'
46
+ | 'rerun-lint'
47
+ | 'snooze-page-drift'
48
+ | 'insert-h1'
49
+ | 'archive-guidance-file'
50
+ | 'edit-page-summary';
51
+ label: string;
52
+ tool:
53
+ | 'wiki_read'
54
+ | 'wiki_write_proposals'
55
+ | 'wiki_apply_proposal'
56
+ | 'wiki_proposals'
57
+ | 'wiki_lint'
58
+ | 'memory_forget'
59
+ | 'memory_promote'
60
+ | 'memory_promote_skill'
61
+ | 'memory_remember'
62
+ | 'wiki_snooze_page_drift'
63
+ | 'wiki_insert_h1'
64
+ | 'wiki_archive_guidance'
65
+ | 'wiki_edit_summary';
66
+ arguments: Record<string, string | string[] | boolean | number>;
67
+ available: boolean;
68
+ reason?: string;
69
+ }
70
+
71
+ export interface MaintenanceProposalReviewMetadata {
72
+ rationale: string;
73
+ affectedPaths: string[];
74
+ beforeSnippet: string;
75
+ afterSnippet: string;
76
+ undoPath: string;
77
+ }
78
+
79
+ export interface MaintenanceInboxSnapshot {
80
+ status: {
81
+ proposalCount: number;
82
+ lintFindingCount: number;
83
+ memoryFindingCount: number;
84
+ observationClusterCount: number;
85
+ proposalGroups: Array<{ kind: WikiProposal['kind']; count: number }>;
86
+ lintRuleGroups: Array<{ bucket: LintBucket; bucketTitle: string; rule: WikiLintRule; count: number }>;
87
+ memoryKindGroups: Array<{ kind: ProjectMemoryReviewKind; title: string; count: number }>;
88
+ };
89
+ nextSteps: string[];
90
+ proposals: Array<{
91
+ kind: WikiProposal['kind'];
92
+ count: number;
93
+ items: Array<{
94
+ summary: string;
95
+ currentStateSummary: string;
96
+ afterApplySummary: string;
97
+ review: MaintenanceProposalReviewMetadata;
98
+ reviewSlug: string;
99
+ reviewPath: string;
100
+ reviewPageExists: boolean;
101
+ actions: MaintenanceInboxActionHint[];
102
+ }>;
103
+ }>;
104
+ lintBuckets: Array<{
105
+ bucket: LintBucket;
106
+ bucketTitle: string;
107
+ count: number;
108
+ rules: Array<{
109
+ rule: WikiLintRule;
110
+ count: number;
111
+ items: Array<{
112
+ slug: string;
113
+ path: string;
114
+ message: string;
115
+ actions: MaintenanceInboxActionHint[];
116
+ }>;
117
+ }>;
118
+ }>;
119
+ memoryBuckets: Array<{
120
+ kind: ProjectMemoryReviewKind;
121
+ title: string;
122
+ count: number;
123
+ items: Array<{
124
+ summary: string;
125
+ reason: string;
126
+ memoryIds: string[];
127
+ records: Array<{
128
+ id: string;
129
+ kind: string;
130
+ text: string;
131
+ recallCount: number;
132
+ updatedAt: string;
133
+ sources: string[];
134
+ relatedFiles: string[];
135
+ relatedPages: string[];
136
+ }>;
137
+ actions: MaintenanceInboxActionHint[];
138
+ }>;
139
+ }>;
140
+ observationClusters: Array<{
141
+ kind: RawObservationCluster['kind'];
142
+ target: string;
143
+ observationCount: number;
144
+ distinctSessionCount: number;
145
+ firstSeen: string;
146
+ lastSeen: string;
147
+ outcomeCounts: RawObservationCluster['outcomeCounts'];
148
+ synapticTag: RawObservationCluster['synapticTag'];
149
+ suggestedSourceLink: string;
150
+ actions: MaintenanceInboxActionHint[];
151
+ }>;
152
+ }
153
+
154
+ export interface ResolvedMaintenanceInboxAction {
155
+ action: MaintenanceInboxActionHint;
156
+ source:
157
+ | { type: 'proposal'; kind: WikiProposal['kind']; reviewSlug: string }
158
+ | { type: 'lint'; bucket: LintBucket; rule: WikiLintRule; path: string }
159
+ | { type: 'memory'; kind: ProjectMemoryReviewKind; memoryIds: string[] }
160
+ | { type: 'observation-cluster'; clusterKind: RawObservationCluster['kind']; target: string };
161
+ }
162
+
163
+ export async function buildMaintenanceInboxSnapshot(
164
+ findings: WikiLintFinding[],
165
+ proposals: WikiProposal[],
166
+ options: MaintenanceInboxRenderOptions = {}
167
+ ): Promise<MaintenanceInboxSnapshot> {
168
+ const reviewPageExists = options.reviewPageExists ?? (async () => false);
169
+ const memoryFindings = options.memoryFindings ?? [];
170
+ const observationClusters = options.observationClusters ?? [];
171
+ const proposalGroups = summarizeProposalKinds(proposals);
172
+ const lintRuleGroups = summarizeLintRules(findings).map(({ rule, count }) => ({
173
+ bucket: lintRuleBucket[rule],
174
+ bucketTitle: lintBucketTitles[lintRuleBucket[rule]],
175
+ rule,
176
+ count
177
+ }));
178
+ const memoryKindGroups = summarizeMemoryReviewKinds(memoryFindings).map(({ kind, count }) => ({
179
+ kind,
180
+ title: memoryReviewKindTitles[kind],
181
+ count
182
+ }));
183
+ const nextSteps = renderNextSteps(findings, proposals, memoryFindings, observationClusters).map((line) => line.replace(/^\-\s*/, ''));
184
+ const groupedProposals = groupBy(proposals, (proposal) => proposal.kind);
185
+ const groupedFindings = groupBy(findings, (finding) => lintRuleBucket[finding.rule]);
186
+ const groupedMemoryFindings = groupBy(memoryFindings, (finding) => finding.kind);
187
+
188
+ return {
189
+ status: {
190
+ proposalCount: proposals.length,
191
+ lintFindingCount: findings.length,
192
+ proposalGroups,
193
+ lintRuleGroups,
194
+ memoryFindingCount: memoryFindings.length,
195
+ memoryKindGroups,
196
+ observationClusterCount: observationClusters.length
197
+ },
198
+ nextSteps,
199
+ proposals: await Promise.all(
200
+ [...groupedProposals.keys()].sort().map(async (kind) => {
201
+ const items = (groupedProposals.get(kind) ?? []).sort((left, right) => left.summary.localeCompare(right.summary));
202
+ return {
203
+ kind,
204
+ count: items.length,
205
+ items: await Promise.all(
206
+ items.map(async (proposal) => {
207
+ const hasReviewPage = await reviewPageExists(proposal.reviewPath);
208
+ return {
209
+ summary: proposal.summary,
210
+ currentStateSummary: proposal.currentStateSummary,
211
+ afterApplySummary: proposal.afterApplySummary,
212
+ review: buildProposalReviewMetadata(proposal),
213
+ reviewSlug: proposal.reviewSlug,
214
+ reviewPath: proposal.reviewPath,
215
+ reviewPageExists: hasReviewPage,
216
+ actions: buildProposalActions(proposal.reviewSlug, hasReviewPage)
217
+ };
218
+ })
219
+ )
220
+ };
221
+ })
222
+ ),
223
+ lintBuckets: lintBucketOrder
224
+ .filter((bucket) => groupedFindings.has(bucket))
225
+ .map((bucket) => {
226
+ const bucketFindings = groupedFindings.get(bucket) ?? [];
227
+ const ruleGroups = groupBy(bucketFindings, (finding) => finding.rule);
228
+ const orderedRules = [...ruleGroups.keys()].sort((left, right) => {
229
+ const countDelta = (ruleGroups.get(right)?.length ?? 0) - (ruleGroups.get(left)?.length ?? 0);
230
+ return countDelta !== 0 ? countDelta : left.localeCompare(right);
231
+ });
232
+
233
+ return {
234
+ bucket,
235
+ bucketTitle: lintBucketTitles[bucket],
236
+ count: bucketFindings.length,
237
+ rules: orderedRules.map((rule) => ({
238
+ rule,
239
+ count: ruleGroups.get(rule)?.length ?? 0,
240
+ items: (ruleGroups.get(rule) ?? [])
241
+ .sort((left, right) => left.path.localeCompare(right.path))
242
+ .map((finding) => ({
243
+ slug: finding.slug,
244
+ path: finding.path,
245
+ message: finding.message,
246
+ actions: buildLintActions(finding)
247
+ }))
248
+ }))
249
+ };
250
+ })
251
+ ,
252
+ memoryBuckets: memoryReviewKindOrder
253
+ .filter((kind) => groupedMemoryFindings.has(kind))
254
+ .map((kind) => {
255
+ const bucketFindings = (groupedMemoryFindings.get(kind) ?? []).sort((left, right) => left.summary.localeCompare(right.summary));
256
+ return {
257
+ kind,
258
+ title: memoryReviewKindTitles[kind],
259
+ count: bucketFindings.length,
260
+ items: bucketFindings.map((finding) => ({
261
+ summary: finding.summary,
262
+ reason: finding.reason,
263
+ memoryIds: finding.memoryIds,
264
+ // Only include inferredScope when actually present so existing snapshot
265
+ // consumers that deep-compare against fixtures don't see an undefined field.
266
+ ...(finding.inferredScope ? { inferredScope: finding.inferredScope } : {}),
267
+ records: finding.records.map((record) => ({
268
+ id: record.id,
269
+ kind: record.kind,
270
+ text: record.text,
271
+ recallCount: record.recallCount,
272
+ updatedAt: record.updatedAt,
273
+ sources: record.sources.map((source) => `${source.kind}:${source.slug}`),
274
+ relatedFiles: record.relatedFiles,
275
+ relatedPages: record.relatedPages
276
+ })),
277
+ actions: buildMemoryActions(finding)
278
+ }))
279
+ };
280
+ }),
281
+ observationClusters: observationClusters.map((cluster) => ({
282
+ kind: cluster.kind,
283
+ target: cluster.target,
284
+ observationCount: cluster.observationCount,
285
+ distinctSessionCount: cluster.distinctSessionCount,
286
+ firstSeen: cluster.firstSeen,
287
+ lastSeen: cluster.lastSeen,
288
+ outcomeCounts: cluster.outcomeCounts,
289
+ synapticTag: cluster.synapticTag,
290
+ suggestedSourceLink: buildClusterSourceLink(cluster),
291
+ actions: buildObservationClusterActions(cluster)
292
+ }))
293
+ };
294
+ }
295
+
296
+ function buildObservationClusterActions(cluster: RawObservationCluster): MaintenanceInboxActionHint[] {
297
+ const sourceLink = buildClusterSourceLink(cluster);
298
+ const safeTarget = cluster.target.replace(/[^a-zA-Z0-9_./-]/g, '_').slice(0, 80) || 'unknown';
299
+ const id = `cluster:${cluster.kind}:${safeTarget}:create-memory-from-cluster`;
300
+ const text = buildClusterMemoryTemplate(cluster);
301
+ const args: Record<string, string | string[] | boolean> = {
302
+ text,
303
+ kind: 'lesson',
304
+ tags: ['from-observation-cluster'],
305
+ sources: [sourceLink]
306
+ };
307
+ if (cluster.kind === 'edit' || cluster.kind === 'read') {
308
+ args.relatedFiles = [cluster.target];
309
+ }
310
+ return [
311
+ {
312
+ id,
313
+ kind: 'create-memory-from-cluster',
314
+ label: `Create a draft memory from this cluster (${cluster.kind} ${cluster.target})`,
315
+ tool: 'memory_remember',
316
+ arguments: args,
317
+ available: true
318
+ }
319
+ ];
320
+ }
321
+
322
+ function buildClusterMemoryTemplate(cluster: RawObservationCluster): string {
323
+ const isFileCluster = cluster.kind === 'edit' || cluster.kind === 'read';
324
+ const considerationsHeader = translate('observation-cluster-template-considerations', {}).replace(
325
+ '{kindLabel}',
326
+ isFileCluster ? 'file' : 'target'
327
+ );
328
+ const optionsKey = isFileCluster
329
+ ? 'observation-cluster-template-options-edit-or-read'
330
+ : 'observation-cluster-template-options-default';
331
+
332
+ return [
333
+ '[draft from observation cluster — EDIT THIS TEXT before relying on it]',
334
+ translate('observation-cluster-template-header', {
335
+ kind: cluster.kind,
336
+ target: cluster.target,
337
+ observationCount: cluster.observationCount,
338
+ distinctSessionCount: cluster.distinctSessionCount,
339
+ lastSeen: cluster.lastSeen
340
+ }),
341
+ '',
342
+ considerationsHeader,
343
+ translate(optionsKey, {}),
344
+ '',
345
+ translate('observation-cluster-template-replace-instruction', {})
346
+ ].join('\n');
347
+ }
348
+
349
+ function buildClusterSourceLink(cluster: RawObservationCluster): string {
350
+ if (cluster.kind === 'edit' || cluster.kind === 'read') {
351
+ return `file:${cluster.target}`;
352
+ }
353
+ if (cluster.kind === 'command') {
354
+ return `command:${cluster.target}`;
355
+ }
356
+ return cluster.target;
357
+ }
358
+
359
+ export async function buildMaintenanceInboxPage(
360
+ findings: WikiLintFinding[],
361
+ proposals: WikiProposal[],
362
+ options: MaintenanceInboxRenderOptions = {}
363
+ ): Promise<string> {
364
+ // This page used to be a ~1,300-line auto-generated text dump of every active finding,
365
+ // duplicating what the interactive Review Board now shows with action buttons, previews,
366
+ // and verb-grouped triage. The dump was unreviewable at scale and surfaced no actionable
367
+ // affordances the operator could click.
368
+ //
369
+ // It now collapses to a thin counts-only stub that routes any stale bookmark or sidebar
370
+ // entry to `/review-board`. The structured `maintenance-inbox.json` artifact still carries
371
+ // the full grouped data — the board reads that JSON directly and is the authoritative
372
+ // surface. The unused renderers (renderProposalSection, renderLintSection,
373
+ // renderMemoryReviewSection, renderObservationClusterSection, etc.) are kept exported
374
+ // for callers that consume them programmatically.
375
+ const memoryFindings = options.memoryFindings ?? [];
376
+ const observationClusters = options.observationClusters ?? [];
377
+
378
+ return [
379
+ '# Maintenance Inbox',
380
+ '',
381
+ '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).',
382
+ '',
383
+ '## Right Now',
384
+ `- ${proposals.length} active proposal${proposals.length === 1 ? '' : 's'}`,
385
+ `- ${findings.length} active lint finding${findings.length === 1 ? '' : 's'}`,
386
+ `- ${memoryFindings.length} active memory review finding${memoryFindings.length === 1 ? '' : 's'}`,
387
+ `- ${observationClusters.length} active observation cluster${observationClusters.length === 1 ? '' : 's'}`,
388
+ '',
389
+ '**[→ 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.',
390
+ ''
391
+ ].join('\n');
392
+ }
393
+
394
+ function renderObservationClusterSection(clusters: RawObservationCluster[]): string[] {
395
+ if (clusters.length === 0) {
396
+ return [
397
+ 'No raw observation clusters have crossed the promotion threshold yet.',
398
+ '',
399
+ '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.'
400
+ ];
401
+ }
402
+ const lines: string[] = [
403
+ '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.',
404
+ '',
405
+ '| Tag | Kind | Target | Observations | Sessions (success/error/inconclusive) | Last Seen | Outcomes (ok/error/unknown) | Suggested Source |',
406
+ '|---|---|---|---:|---|---|---|---|'
407
+ ];
408
+ for (const cluster of clusters) {
409
+ const outcomes = `${cluster.outcomeCounts.ok}/${cluster.outcomeCounts.error}/${cluster.outcomeCounts.unknown}`;
410
+ const sessionMix = `${cluster.synapticTag.successSessionCount}/${cluster.synapticTag.errorSessionCount}/${cluster.synapticTag.inconclusiveSessionCount}`;
411
+ const tagBadge = renderSynapticTagBadge(cluster.synapticTag.synapticTag);
412
+ lines.push(
413
+ `| ${tagBadge} | \`${cluster.kind}\` | ${escapeCell(cluster.target)} | ${cluster.observationCount} | ${sessionMix} | ${cluster.lastSeen} | ${outcomes} | \`${buildClusterSourceLink(cluster)}\` |`
414
+ );
415
+ }
416
+ return lines;
417
+ }
418
+
419
+ function renderSynapticTagBadge(tag: RawObservationCluster['synapticTag']['synapticTag']): string {
420
+ switch (tag) {
421
+ case 'verified-success':
422
+ return '`verified-success`';
423
+ case 'likely-error':
424
+ return '`likely-error`';
425
+ case 'inconclusive':
426
+ return '`inconclusive`';
427
+ }
428
+ }
429
+
430
+ export async function findMaintenanceInboxAction(
431
+ actionId: string,
432
+ findings: WikiLintFinding[],
433
+ proposals: WikiProposal[],
434
+ options: MaintenanceInboxRenderOptions = {}
435
+ ): Promise<ResolvedMaintenanceInboxAction | undefined> {
436
+ const snapshot = await buildMaintenanceInboxSnapshot(findings, proposals, options);
437
+
438
+ for (const proposalGroup of snapshot.proposals) {
439
+ for (const item of proposalGroup.items) {
440
+ const action = item.actions.find((candidate) => candidate.id === actionId);
441
+ if (action) {
442
+ return {
443
+ action,
444
+ source: {
445
+ type: 'proposal',
446
+ kind: proposalGroup.kind,
447
+ reviewSlug: item.reviewSlug
448
+ }
449
+ };
450
+ }
451
+ }
452
+ }
453
+
454
+ for (const lintBucket of snapshot.lintBuckets) {
455
+ for (const ruleGroup of lintBucket.rules) {
456
+ for (const item of ruleGroup.items) {
457
+ const action = item.actions.find((candidate) => candidate.id === actionId);
458
+ if (action) {
459
+ return {
460
+ action,
461
+ source: {
462
+ type: 'lint',
463
+ bucket: lintBucket.bucket,
464
+ rule: ruleGroup.rule,
465
+ path: item.path
466
+ }
467
+ };
468
+ }
469
+ }
470
+ }
471
+ }
472
+
473
+ for (const memoryBucket of snapshot.memoryBuckets) {
474
+ for (const item of memoryBucket.items) {
475
+ const action = item.actions.find((candidate) => candidate.id === actionId);
476
+ if (action) {
477
+ return {
478
+ action,
479
+ source: {
480
+ type: 'memory',
481
+ kind: memoryBucket.kind,
482
+ memoryIds: item.memoryIds
483
+ }
484
+ };
485
+ }
486
+ }
487
+ }
488
+
489
+ for (const cluster of snapshot.observationClusters) {
490
+ const action = cluster.actions.find((candidate) => candidate.id === actionId);
491
+ if (action) {
492
+ return {
493
+ action,
494
+ source: {
495
+ type: 'observation-cluster',
496
+ clusterKind: cluster.kind,
497
+ target: cluster.target
498
+ }
499
+ };
500
+ }
501
+ }
502
+
503
+ return undefined;
504
+ }
505
+
506
+ function renderNextSteps(
507
+ findings: WikiLintFinding[],
508
+ proposals: WikiProposal[],
509
+ memoryFindings: ProjectMemoryReviewFinding[],
510
+ observationClusters: RawObservationCluster[] = []
511
+ ): string[] {
512
+ const steps = ['- Read [Proposal Workflow](./proposal-workflow.md) for the review and apply flow.'];
513
+
514
+ if (proposals.length > 0) {
515
+ steps.push('- Run `wiki_write_proposals` to materialize review pages under `docs/wiki/pending-review/`.');
516
+ steps.push('- Review the proposal group tables below and open any linked review pages before applying changes.');
517
+ } else {
518
+ steps.push('- No proposal pages need to be generated right now.');
519
+ }
520
+
521
+ if (findings.length > 0) {
522
+ steps.push('- Resolve the lint buckets below, starting with the `review-now` rules before the cleanup-only rules.');
523
+ steps.push('- Rerun `npm run wiki:refresh` or `npm run check` after fixes so the inbox reflects the current state.');
524
+ } else {
525
+ steps.push('- The lint queue is clear right now.');
526
+ }
527
+
528
+ if (memoryFindings.length > 0) {
529
+ steps.push('- Review stale, unsupported, and contradictory memories first, then archive or consolidate duplicates with `memory_forget` where appropriate.');
530
+ steps.push('- Promote repeated source-backed lessons into canonical wiki pages once the memory findings confirm they are stable enough to keep.');
531
+ } else {
532
+ steps.push('- The memory review queue is clear right now.');
533
+ }
534
+
535
+ if (observationClusters.length > 0) {
536
+ 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).');
537
+ steps.push('- Run `dendrite-wiki observations:list` to see the underlying raw observations behind any cluster.');
538
+ } else {
539
+ steps.push('- No raw observation clusters have crossed the promotion threshold yet.');
540
+ }
541
+
542
+ return steps;
543
+ }
544
+
545
+ function renderProposalSummarySection(
546
+ proposalCounts: Array<{ kind: WikiProposal['kind']; count: number }>
547
+ ): string[] {
548
+ if (proposalCounts.length === 0) {
549
+ return ['No active proposal groups.'];
550
+ }
551
+
552
+ return [
553
+ '| Kind | Count |',
554
+ '|---|---:|',
555
+ ...proposalCounts.map(({ kind, count }) => `| \`${kind}\` | ${count} |`)
556
+ ];
557
+ }
558
+
559
+ async function renderProposalSection(
560
+ proposals: WikiProposal[],
561
+ reviewPageExists: (reviewPath: string) => Promise<boolean>
562
+ ): Promise<string[]> {
563
+ if (proposals.length === 0) {
564
+ return ['No active proposals.'];
565
+ }
566
+
567
+ const lines: string[] = [];
568
+ const groupedProposals = groupBy(proposals, (proposal) => proposal.kind);
569
+
570
+ for (const kind of [...groupedProposals.keys()].sort()) {
571
+ const group = groupedProposals.get(kind) ?? [];
572
+ lines.push(`### \`${kind}\` (${group.length})`, '');
573
+ lines.push('| Summary | Rationale | Affected Paths | Current State | After Apply | Undo Path | Review Page |');
574
+ lines.push('|---|---|---|---|---|---|---|');
575
+
576
+ for (const proposal of group.sort((left, right) => left.summary.localeCompare(right.summary))) {
577
+ const review = buildProposalReviewMetadata(proposal);
578
+ lines.push(
579
+ `| ${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)} |`
580
+ );
581
+ }
582
+
583
+ lines.push('');
584
+ }
585
+
586
+ if (lines.at(-1) === '') {
587
+ lines.pop();
588
+ }
589
+
590
+ return lines;
591
+ }
592
+
593
+ function renderLintSummarySection(lintCounts: Array<{ rule: WikiLintRule; count: number }>): string[] {
594
+ if (lintCounts.length === 0) {
595
+ return ['No active lint groups.'];
596
+ }
597
+
598
+ return [
599
+ '| Bucket | Rule | Count |',
600
+ '|---|---|---:|',
601
+ ...lintCounts.map(
602
+ ({ rule, count }) => `| ${lintBucketTitles[lintRuleBucket[rule]]} | \`${rule}\` | ${count} |`
603
+ )
604
+ ];
605
+ }
606
+
607
+ function renderLintSection(findings: WikiLintFinding[]): string[] {
608
+ if (findings.length === 0) {
609
+ return ['No active lint findings.'];
610
+ }
611
+
612
+ const lines: string[] = [];
613
+ const groupedFindings = groupBy(findings, (finding) => lintRuleBucket[finding.rule]);
614
+
615
+ for (const bucket of lintBucketOrder.filter((candidate) => groupedFindings.has(candidate))) {
616
+ const bucketFindings = groupedFindings.get(bucket) ?? [];
617
+ lines.push(`### ${lintBucketTitles[bucket]} (${bucketFindings.length})`, '');
618
+
619
+ const ruleGroups = groupBy(bucketFindings, (finding) => finding.rule);
620
+ const orderedRules = [...ruleGroups.keys()].sort((left, right) => {
621
+ const countDelta = (ruleGroups.get(right)?.length ?? 0) - (ruleGroups.get(left)?.length ?? 0);
622
+ return countDelta !== 0 ? countDelta : left.localeCompare(right);
623
+ });
624
+
625
+ for (const rule of orderedRules) {
626
+ const ruleFindings = (ruleGroups.get(rule) ?? []).sort((left, right) => left.path.localeCompare(right.path));
627
+ lines.push(`#### \`${rule}\` (${ruleFindings.length})`, '');
628
+ lines.push('| Path | Message |');
629
+ lines.push('|---|---|');
630
+ lines.push(...ruleFindings.map((finding) => `| ${formatPathCell(finding.path)} | ${escapeCell(finding.message)} |`));
631
+ lines.push('');
632
+ }
633
+ }
634
+
635
+ if (lines.at(-1) === '') {
636
+ lines.pop();
637
+ }
638
+
639
+ return lines;
640
+ }
641
+
642
+ function renderMemoryReviewSummarySection(
643
+ memoryCounts: Array<{ kind: ProjectMemoryReviewKind; count: number }>
644
+ ): string[] {
645
+ if (memoryCounts.length === 0) {
646
+ return ['No active memory review groups.'];
647
+ }
648
+
649
+ return [
650
+ '| Kind | Count |',
651
+ '|---|---:|',
652
+ ...memoryCounts.map(({ kind, count }) => `| ${memoryReviewKindTitles[kind]} | ${count} |`)
653
+ ];
654
+ }
655
+
656
+ function renderMemoryReviewSection(memoryFindings: ProjectMemoryReviewFinding[]): string[] {
657
+ if (memoryFindings.length === 0) {
658
+ return ['No active memory review findings.'];
659
+ }
660
+
661
+ const lines: string[] = [];
662
+ const groupedFindings = groupBy(memoryFindings, (finding) => finding.kind);
663
+
664
+ for (const kind of memoryReviewKindOrder.filter((candidate) => groupedFindings.has(candidate))) {
665
+ const group = (groupedFindings.get(kind) ?? []).sort((left, right) => left.summary.localeCompare(right.summary));
666
+ lines.push(`### ${memoryReviewKindTitles[kind]} (${group.length})`, '');
667
+
668
+ for (const finding of group) {
669
+ lines.push(`#### ${escapeMarkdownForVue(finding.summary)}`, '');
670
+ lines.push(`**Why this surfaced:** ${finding.reason}`, '');
671
+ if (finding.memoryIds.length > 1) {
672
+ lines.push(`**Memory IDs covered:** ${finding.memoryIds.join(', ')}`, '');
673
+ }
674
+
675
+ for (const record of finding.records) {
676
+ lines.push(`- **Memory ID:** \`${record.id}\` (kind: \`${record.kind}\`, recalled ${record.recallCount}x)`);
677
+ if (record.sources.length > 0) {
678
+ lines.push(`- **Sources:** ${record.sources.map((source) => `\`${source.kind}:${source.slug}\``).join(', ')}`);
679
+ } else {
680
+ lines.push('- **Sources:** none');
681
+ }
682
+ if (record.relatedPages.length > 0) {
683
+ lines.push(`- **Related pages:** ${record.relatedPages.map((page) => `\`${page}\``).join(', ')}`);
684
+ }
685
+ if (record.relatedFiles.length > 0) {
686
+ lines.push(`- **Related files:** ${record.relatedFiles.map((file) => `\`${file}\``).join(', ')}`);
687
+ }
688
+ lines.push('', '> ' + escapeMarkdownForVue(record.text).replace(/\n/g, '\n> '), '');
689
+ }
690
+
691
+ const actions = buildMemoryActions(finding);
692
+ if (actions.length > 0) {
693
+ lines.push('**Actions:**', '');
694
+ for (const action of actions) {
695
+ if (action.available) {
696
+ lines.push(`- ${action.label} — run from the repo root:`);
697
+ lines.push('');
698
+ lines.push(' ```bash');
699
+ lines.push(` npm run wiki:action -- "${action.id}"`);
700
+ lines.push(' ```');
701
+ } else {
702
+ lines.push(`- ${action.label} (blocked${action.reason ? `: ${action.reason}` : ''})`);
703
+ }
704
+ }
705
+ 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.', '');
706
+ }
707
+ }
708
+ }
709
+
710
+ if (lines.at(-1) === '') {
711
+ lines.pop();
712
+ }
713
+
714
+ return lines;
715
+ }
716
+
717
+ async function formatReviewCell(
718
+ reviewSlug: string,
719
+ reviewPath: string,
720
+ reviewPageExists: (reviewPath: string) => Promise<boolean>
721
+ ): Promise<string> {
722
+ if (await reviewPageExists(reviewPath)) {
723
+ return `[${escapeCell(reviewSlug)}](./${reviewSlug}.md)`;
724
+ }
725
+
726
+ return `\`${escapeCell(reviewSlug)}\` (run \`wiki_write_proposals\`)`;
727
+ }
728
+
729
+ function formatPathCell(targetPath: string): string {
730
+ if (!targetPath.startsWith('docs/')) {
731
+ return `\`${escapeCell(targetPath)}\``;
732
+ }
733
+
734
+ const relativePath = path.posix.relative('docs/wiki', targetPath.replace(/\\/g, '/')) || '.';
735
+ return `[${escapeCell(targetPath)}](${relativePath})`;
736
+ }
737
+
738
+ function summarizeProposalKinds(
739
+ activeProposals: WikiProposal[]
740
+ ): Array<{ kind: WikiProposal['kind']; count: number }> {
741
+ return [...groupBy(activeProposals, (proposal) => proposal.kind).entries()]
742
+ .map(([kind, group]) => ({ kind, count: group.length }))
743
+ .sort((left, right) => right.count - left.count || left.kind.localeCompare(right.kind));
744
+ }
745
+
746
+ function summarizeLintRules(activeFindings: WikiLintFinding[]): Array<{ rule: WikiLintRule; count: number }> {
747
+ return [...groupBy(activeFindings, (finding) => finding.rule).entries()]
748
+ .map(([rule, group]) => ({ rule, count: group.length }))
749
+ .sort((left, right) => {
750
+ const bucketDelta = lintBucketOrder.indexOf(lintRuleBucket[left.rule]) - lintBucketOrder.indexOf(lintRuleBucket[right.rule]);
751
+ return bucketDelta !== 0 ? bucketDelta : right.count - left.count || left.rule.localeCompare(right.rule);
752
+ });
753
+ }
754
+
755
+ function summarizeMemoryReviewKinds(
756
+ activeFindings: ProjectMemoryReviewFinding[]
757
+ ): Array<{ kind: ProjectMemoryReviewKind; count: number }> {
758
+ return [...groupBy(activeFindings, (finding) => finding.kind).entries()]
759
+ .map(([kind, group]) => ({ kind, count: group.length }))
760
+ .sort((left, right) => memoryReviewKindOrder.indexOf(left.kind) - memoryReviewKindOrder.indexOf(right.kind));
761
+ }
762
+
763
+ function buildProposalActions(reviewSlug: string, reviewPageExists: boolean): MaintenanceInboxActionHint[] {
764
+ return [
765
+ {
766
+ id: buildProposalActionId(reviewSlug, 'refresh-review-pages'),
767
+ kind: 'refresh-review-pages',
768
+ label: 'Refresh review pages',
769
+ tool: 'wiki_write_proposals',
770
+ arguments: {},
771
+ available: true
772
+ },
773
+ {
774
+ id: buildProposalActionId(reviewSlug, 'read-review-page'),
775
+ kind: 'read-review-page',
776
+ label: 'Read review page',
777
+ tool: 'wiki_read',
778
+ arguments: { slug: reviewSlug },
779
+ available: reviewPageExists,
780
+ reason: reviewPageExists ? undefined : 'Run wiki_write_proposals first to materialize the pending-review page.'
781
+ },
782
+ {
783
+ id: buildProposalActionId(reviewSlug, 'apply-proposal'),
784
+ kind: 'apply-proposal',
785
+ label: 'Apply proposal',
786
+ tool: 'wiki_apply_proposal',
787
+ arguments: { reviewSlug },
788
+ available: true
789
+ }
790
+ ];
791
+ }
792
+
793
+ function buildProposalReviewMetadata(proposal: WikiProposal): MaintenanceProposalReviewMetadata {
794
+ if (proposal.kind === 'merge-guidance') {
795
+ return {
796
+ rationale: proposal.rationale,
797
+ affectedPaths: proposal.duplicatePaths,
798
+ beforeSnippet: proposal.currentStateSummary,
799
+ afterSnippet: proposal.afterApplySummary,
800
+ 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.`
801
+ };
802
+ }
803
+
804
+ return {
805
+ rationale: proposal.rationale,
806
+ affectedPaths: [proposal.guidancePath],
807
+ beforeSnippet: proposal.currentStateSummary,
808
+ afterSnippet: proposal.afterApplySummary,
809
+ undoPath: `Before committing, inspect the changed guidance file with git diff and restore ${proposal.guidancePath} from version control if the route is not wanted.`
810
+ };
811
+ }
812
+
813
+ function buildLintActions(finding: WikiLintFinding): MaintenanceInboxActionHint[] {
814
+ const actions: MaintenanceInboxActionHint[] = [
815
+ {
816
+ id: buildLintActionId(finding, 'rerun-lint'),
817
+ kind: 'rerun-lint',
818
+ label: 'Re-run lint',
819
+ tool: 'wiki_lint',
820
+ arguments: {},
821
+ available: true
822
+ }
823
+ ];
824
+ const wikiSlug = pathToWikiSlug(finding.path);
825
+
826
+ if (wikiSlug) {
827
+ actions.unshift({
828
+ id: buildLintActionId(finding, 'read-wiki-page'),
829
+ kind: 'read-wiki-page',
830
+ label: 'Read wiki page',
831
+ tool: 'wiki_read',
832
+ arguments: { slug: wikiSlug },
833
+ available: true
834
+ });
835
+ }
836
+
837
+ if (proposalRelatedLintRules.has(finding.rule)) {
838
+ actions.push({
839
+ id: buildLintActionId(finding, 'check-proposals'),
840
+ kind: 'check-proposals',
841
+ label: 'Check related proposals',
842
+ tool: 'wiki_proposals',
843
+ arguments: {},
844
+ available: true
845
+ });
846
+ }
847
+
848
+ // Rule-specific resolve actions. These are the buttons that actually CLOSE the finding
849
+ // without forcing the operator to leave the board and edit a file. Each is a deterministic
850
+ // single-click outcome — anything that needs editorial judgment (claim text, summary
851
+ // wording) deliberately stays a Read+Re-run pair so the operator can't auto-rubber-stamp it.
852
+ if (finding.rule === 'page-drift' && wikiSlug) {
853
+ actions.unshift({
854
+ id: buildLintActionId(finding, 'snooze-page-drift'),
855
+ kind: 'snooze-page-drift',
856
+ label: 'Snooze 30 days',
857
+ tool: 'wiki_snooze_page_drift',
858
+ arguments: { slug: wikiSlug, days: 30 },
859
+ available: true
860
+ });
861
+ // Edit-summary action: only consumed by the inline drift-resolver editor, which
862
+ // supplies the operator's draft via the bridge's narrow summaryDraft field. Stays
863
+ // available=true so the bridge dispatcher accepts it; the executor's runtime check
864
+ // (require non-empty newFirstParagraph) prevents an accidental empty rewrite if it
865
+ // is ever invoked without a draft. The Vue layer filters this kind out of the
866
+ // visible secondary-actions list so it never appears as a stray clickable button.
867
+ actions.push({
868
+ id: buildLintActionId(finding, 'edit-page-summary'),
869
+ kind: 'edit-page-summary',
870
+ label: 'Rewrite first paragraph',
871
+ tool: 'wiki_edit_summary',
872
+ arguments: { slug: wikiSlug, newFirstParagraph: '' },
873
+ available: true
874
+ });
875
+ }
876
+
877
+ if (finding.rule === 'missing-h1' && wikiSlug) {
878
+ actions.unshift({
879
+ id: buildLintActionId(finding, 'insert-h1'),
880
+ kind: 'insert-h1',
881
+ label: 'Insert H1 from slug',
882
+ tool: 'wiki_insert_h1',
883
+ arguments: { slug: wikiSlug },
884
+ available: true
885
+ });
886
+ }
887
+
888
+ if (finding.rule === 'dormant-skill') {
889
+ actions.unshift({
890
+ id: buildLintActionId(finding, 'archive-guidance-file'),
891
+ kind: 'archive-guidance-file',
892
+ label: 'Archive skill file',
893
+ tool: 'wiki_archive_guidance',
894
+ arguments: { path: finding.path },
895
+ available: true
896
+ });
897
+ }
898
+
899
+ return actions;
900
+ }
901
+
902
+ function buildMemoryActions(finding: ProjectMemoryReviewFinding): MaintenanceInboxActionHint[] {
903
+ if (finding.kind === 'stale' || finding.kind === 'unsupported') {
904
+ return finding.memoryIds.slice(0, 1).map((memoryId) => ({
905
+ id: buildMemoryActionId(finding, 'archive-memory', memoryId),
906
+ kind: 'archive-memory',
907
+ label: 'Archive memory',
908
+ tool: 'memory_forget',
909
+ arguments: {
910
+ id: memoryId,
911
+ mode: 'archive'
912
+ },
913
+ available: true
914
+ }));
915
+ }
916
+
917
+ if (finding.kind === 'growing') {
918
+ // Growing memories are healthy by default; the only manual action is the archive
919
+ // relief valve so the operator can retire something they recognize as junk without
920
+ // waiting for it to age into a stale flag.
921
+ return finding.memoryIds.slice(0, 1).map((memoryId) => ({
922
+ id: buildMemoryActionId(finding, 'archive-memory', memoryId),
923
+ kind: 'archive-memory',
924
+ label: 'Archive memory (manual)',
925
+ tool: 'memory_forget',
926
+ arguments: {
927
+ id: memoryId,
928
+ mode: 'archive'
929
+ },
930
+ available: true
931
+ }));
932
+ }
933
+
934
+ if (finding.kind === 'duplicate') {
935
+ const duplicateIds = finding.records.slice(1).map((record) => record.id);
936
+ const archiveIds = duplicateIds.length > 0 ? duplicateIds : finding.memoryIds.slice(1);
937
+
938
+ return archiveIds.map((memoryId) => ({
939
+ id: buildMemoryActionId(finding, 'archive-memory', memoryId),
940
+ kind: 'archive-memory',
941
+ label: 'Archive older duplicate',
942
+ tool: 'memory_forget',
943
+ arguments: {
944
+ id: memoryId,
945
+ mode: 'archive'
946
+ },
947
+ available: true
948
+ }));
949
+ }
950
+
951
+ if (finding.kind === 'skill-promotion-ready') {
952
+ // Each skill-promotion-ready finding wraps a single memory; use the first id and let
953
+ // the executor read the rest of the scope from the memory record itself. Passing no
954
+ // explicit scope here means promoteMemoryToSkill re-runs inferSkillScopeFromMemory at
955
+ // apply time — which is the same scope the inbox surfaced at finding time, and stays
956
+ // current if the memory was edited between review and apply.
957
+ const targetMemoryId = finding.memoryIds[0];
958
+ if (!targetMemoryId) {
959
+ return [];
960
+ }
961
+ return [
962
+ {
963
+ id: buildMemoryActionId(finding, 'promote-memory-to-skill', targetMemoryId),
964
+ kind: 'promote-memory-to-skill',
965
+ label: 'Promote to skill (inferred scope)',
966
+ tool: 'memory_promote_skill',
967
+ arguments: {
968
+ memoryId: targetMemoryId
969
+ },
970
+ available: true
971
+ },
972
+ {
973
+ id: buildMemoryActionId(finding, 'archive-memory', targetMemoryId),
974
+ kind: 'archive-memory',
975
+ label: 'Archive memory (decline promotion)',
976
+ tool: 'memory_forget',
977
+ arguments: {
978
+ id: targetMemoryId,
979
+ mode: 'archive'
980
+ },
981
+ available: true
982
+ }
983
+ ];
984
+ }
985
+
986
+ if (finding.kind !== 'promotion-ready') {
987
+ return [];
988
+ }
989
+
990
+ const applyAvailability = resolveMemoryPromotionAvailability(finding);
991
+
992
+ return [
993
+ {
994
+ id: buildMemoryActionId(finding, 'draft-memory-promotion'),
995
+ kind: 'draft-memory-promotion',
996
+ label: 'Draft promotion',
997
+ tool: 'memory_promote',
998
+ arguments: {
999
+ memoryIds: finding.memoryIds,
1000
+ mode: 'draft'
1001
+ },
1002
+ available: true
1003
+ },
1004
+ {
1005
+ id: buildMemoryActionId(finding, 'apply-memory-promotion'),
1006
+ kind: 'apply-memory-promotion',
1007
+ label: 'Apply promotion',
1008
+ tool: 'memory_promote',
1009
+ arguments: {
1010
+ memoryIds: finding.memoryIds,
1011
+ mode: 'apply'
1012
+ },
1013
+ available: applyAvailability.available,
1014
+ reason: applyAvailability.reason
1015
+ }
1016
+ ];
1017
+ }
1018
+
1019
+ function resolveMemoryPromotionAvailability(
1020
+ finding: ProjectMemoryReviewFinding
1021
+ ): Pick<MaintenanceInboxActionHint, 'available' | 'reason'> {
1022
+ // Use the same target-resolution logic the draft and apply paths use, so the inbox gate
1023
+ // matches the actual behavior. If the target page exists on disk, apply is safe.
1024
+ const targetSlug = resolvePromotionTargetSlug(finding.records);
1025
+
1026
+ try {
1027
+ statSync(pagePathFromSlug(targetSlug));
1028
+ return { available: true };
1029
+ } catch {
1030
+ return {
1031
+ available: false,
1032
+ reason: `The target wiki page ${targetSlug} does not exist yet. Draft the promotion first and create or choose a canonical target before applying it.`
1033
+ };
1034
+ }
1035
+ }
1036
+
1037
+ function pathToWikiSlug(targetPath: string): string | undefined {
1038
+ const normalizedPath = targetPath.replace(/\\/g, '/');
1039
+ const match = normalizedPath.match(/^docs\/wiki\/(.+)\.md$/);
1040
+ return match?.[1];
1041
+ }
1042
+
1043
+ function buildProposalActionId(
1044
+ reviewSlug: string,
1045
+ actionKind: 'refresh-review-pages' | 'read-review-page' | 'apply-proposal'
1046
+ ): string {
1047
+ return `proposal:${reviewSlug}:${actionKind}`;
1048
+ }
1049
+
1050
+ function buildLintActionId(
1051
+ finding: WikiLintFinding,
1052
+ actionKind:
1053
+ | 'read-wiki-page'
1054
+ | 'check-proposals'
1055
+ | 'rerun-lint'
1056
+ | 'snooze-page-drift'
1057
+ | 'insert-h1'
1058
+ | 'archive-guidance-file'
1059
+ | 'edit-page-summary'
1060
+ ): string {
1061
+ return `lint:${finding.rule}:${finding.path}:${actionKind}`;
1062
+ }
1063
+
1064
+ function buildMemoryActionId(
1065
+ finding: ProjectMemoryReviewFinding,
1066
+ actionKind: 'archive-memory' | 'draft-memory-promotion' | 'apply-memory-promotion' | 'promote-memory-to-skill',
1067
+ memoryId = finding.memoryIds.join('+')
1068
+ ): string {
1069
+ return `memory:${finding.kind}:${memoryId}:${actionKind}`;
1070
+ }
1071
+
1072
+ function groupBy<T, K>(items: T[], keySelector: (item: T) => K): Map<K, T[]> {
1073
+ const groups = new Map<K, T[]>();
1074
+
1075
+ for (const item of items) {
1076
+ const key = keySelector(item);
1077
+ const group = groups.get(key);
1078
+ if (group) {
1079
+ group.push(item);
1080
+ } else {
1081
+ groups.set(key, [item]);
1082
+ }
1083
+ }
1084
+
1085
+ return groups;
1086
+ }
1087
+
1088
+ function escapeCell(value: string): string {
1089
+ return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
1090
+ }
1091
+
1092
+ function escapeMarkdownForVue(value: string): string {
1093
+ return value.replace(/</g, '&lt;').replace(/>/g, '&gt;');
1094
+ }
1095
+
1096
+ const lintBucketOrder = ['review-now', 'cleanup'] as const;
1097
+ export type LintBucket = (typeof lintBucketOrder)[number];
1098
+
1099
+ const lintBucketTitles: Record<LintBucket, string> = {
1100
+ 'review-now': 'Review Now',
1101
+ cleanup: 'Cleanup Queue'
1102
+ };
1103
+
1104
+ const lintRuleBucket: Record<WikiLintRule, LintBucket> = {
1105
+ 'missing-h1': 'cleanup',
1106
+ 'missing-summary': 'cleanup',
1107
+ 'orphan-page': 'cleanup',
1108
+ 'stale-claim': 'review-now',
1109
+ 'unsupported-claim': 'review-now',
1110
+ 'dormant-skill': 'cleanup',
1111
+ 'oversized-guidance': 'cleanup',
1112
+ 'duplicate-guidance': 'cleanup',
1113
+ 'stale-guidance-reference': 'review-now',
1114
+ 'conflicting-guidance': 'review-now',
1115
+ 'unrouted-guidance': 'cleanup',
1116
+ 'page-drift': 'review-now',
1117
+ 'contradicts-shipped-memory': 'review-now'
1118
+ };
1119
+
1120
+ const proposalRelatedLintRules = new Set<WikiLintRule>(['duplicate-guidance', 'oversized-guidance']);
1121
+
1122
+ const memoryReviewKindOrder = ['stale', 'unsupported', 'duplicate', 'contradiction', 'promotion-ready', 'skill-promotion-ready', 'growing'] as const;
1123
+
1124
+ const memoryReviewKindTitles: Record<ProjectMemoryReviewKind, string> = {
1125
+ stale: 'Stale',
1126
+ unsupported: 'Unsupported',
1127
+ duplicate: 'Duplicate',
1128
+ contradiction: 'Contradiction',
1129
+ 'promotion-ready': 'Promotion Ready',
1130
+ 'skill-promotion-ready': 'Skill Promotion Ready',
1131
+ growing: 'Growing'
1132
+ };