@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.
- package/README.md +79 -0
- package/dist/api-extractor/extract.js +269 -0
- package/dist/api-extractor/language-extractor.js +15 -0
- package/dist/api-extractor/python-extractor.js +358 -0
- package/dist/api-extractor/render.js +195 -0
- package/dist/api-extractor/tree-sitter-extractor.js +1079 -0
- package/dist/api-extractor/types.js +11 -0
- package/dist/api-extractor/typescript-extractor.js +50 -0
- package/dist/api-extractor/walk.js +178 -0
- package/dist/api-reference.js +438 -0
- package/dist/benchmark-events.js +129 -0
- package/dist/benchmark.js +270 -0
- package/dist/binder-export.js +381 -0
- package/dist/canonical-target.js +168 -0
- package/dist/chart-insert.js +377 -0
- package/dist/chart-prompts.js +414 -0
- package/dist/context-cache.js +98 -0
- package/dist/contradicts-shipped-memory.js +232 -0
- package/dist/diff-context.js +142 -0
- package/dist/doctor.js +220 -0
- package/dist/generated-docs.js +219 -0
- package/dist/i18n.js +71 -0
- package/dist/index.js +49 -0
- package/dist/librarian.js +255 -0
- package/dist/maintenance-actions.js +244 -0
- package/dist/maintenance-inbox.js +842 -0
- package/dist/maintenance-runner.js +62 -0
- package/dist/page-drift.js +225 -0
- package/dist/page-inbox.js +168 -0
- package/dist/report-export.js +339 -0
- package/dist/review-bridge.js +1386 -0
- package/dist/search-index.js +199 -0
- package/dist/store.js +1617 -0
- package/dist/telemetry-defaults.js +44 -0
- package/dist/telemetry-report.js +263 -0
- package/dist/telemetry.js +544 -0
- package/dist/wiki-synthesis.js +901 -0
- package/package.json +35 -0
- package/src/api-extractor/extract.ts +333 -0
- package/src/api-extractor/language-extractor.ts +37 -0
- package/src/api-extractor/python-extractor.ts +380 -0
- package/src/api-extractor/render.ts +267 -0
- package/src/api-extractor/tree-sitter-extractor.ts +1210 -0
- package/src/api-extractor/types.ts +41 -0
- package/src/api-extractor/typescript-extractor.ts +56 -0
- package/src/api-extractor/walk.ts +209 -0
- package/src/api-reference.ts +552 -0
- package/src/benchmark-events.ts +216 -0
- package/src/benchmark.ts +376 -0
- package/src/binder-export.ts +437 -0
- package/src/canonical-target.ts +192 -0
- package/src/chart-insert.ts +478 -0
- package/src/chart-prompts.ts +417 -0
- package/src/context-cache.ts +129 -0
- package/src/contradicts-shipped-memory.ts +311 -0
- package/src/diff-context.ts +187 -0
- package/src/doctor.ts +260 -0
- package/src/generated-docs.ts +316 -0
- package/src/i18n.ts +106 -0
- package/src/index.ts +59 -0
- package/src/librarian.ts +331 -0
- package/src/maintenance-actions.ts +314 -0
- package/src/maintenance-inbox.ts +1132 -0
- package/src/maintenance-runner.ts +85 -0
- package/src/page-drift.ts +292 -0
- package/src/page-inbox.ts +254 -0
- package/src/report-export.ts +392 -0
- package/src/review-bridge.ts +1729 -0
- package/src/search-index.ts +266 -0
- package/src/store.ts +2171 -0
- package/src/telemetry-defaults.ts +50 -0
- package/src/telemetry-report.ts +365 -0
- package/src/telemetry.ts +757 -0
- 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, '<').replace(/>/g, '>');
|
|
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
|
+
};
|