llm-wiki-kit 0.2.14 → 0.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,128 @@
1
+ import { isAbsolute, relative, sep } from 'path';
2
+ import { extractPathsFromText, hasSecretLikeText, isSensitivePath, redactText, summarizeForStorage } from './redaction.js';
3
+
4
+ export const EVIDENCE_PREFIXES = new Set(['file', 'cmd', 'raw', 'url']);
5
+ export const MAX_CMD_EVIDENCE_CHARS = 500;
6
+
7
+ export function normalizeEvidenceRefs(value) {
8
+ if (!Array.isArray(value)) return [];
9
+ const refs = value
10
+ .map((item) => summarizeForStorage(String(item || ''), 700))
11
+ .map((item) => item.replace(/\s+/g, ' ').trim())
12
+ .filter(Boolean);
13
+ return [...new Set(refs)];
14
+ }
15
+
16
+ export function parseEvidenceRef(ref) {
17
+ const text = String(ref || '').trim();
18
+ const match = text.match(/^([a-z]+):([\s\S]*)$/i);
19
+ if (!match) return { raw: text, prefix: '', value: text };
20
+ return {
21
+ raw: text,
22
+ prefix: match[1].toLowerCase(),
23
+ value: match[2].trim(),
24
+ };
25
+ }
26
+
27
+ export function parseEvidenceRefsField(value) {
28
+ const text = String(value || '').trim();
29
+ if (!text) return [];
30
+ try {
31
+ const parsed = JSON.parse(text);
32
+ if (Array.isArray(parsed)) return normalizeEvidenceRefs(parsed);
33
+ } catch {
34
+ // Fall through to legacy comma/text parsing.
35
+ }
36
+ return normalizeEvidenceRefs(text.split(',').map((item) => item.trim()));
37
+ }
38
+
39
+ export function frontmatterEvidenceRefs(refs) {
40
+ const normalized = normalizeEvidenceRefs(refs);
41
+ if (normalized.length === 0) return 'evidence_refs: []';
42
+ return ['evidence_refs:', ...normalized.map((ref) => ` - "${ref.replace(/"/g, '\\"')}"`)].join('\n');
43
+ }
44
+
45
+ function cleanCandidatePath(value) {
46
+ return String(value || '')
47
+ .replace(/^[-*]\s+/, '')
48
+ .replace(/^file:/i, '')
49
+ .replace(/[),.;:]+$/g, '')
50
+ .replace(/\\/g, '/')
51
+ .trim();
52
+ }
53
+
54
+ function isUsefulFileCandidate(value) {
55
+ const text = cleanCandidatePath(value);
56
+ if (!text || text.includes('://') || text.startsWith('cmd:') || text.startsWith('raw:')) return false;
57
+ if (isSensitivePath(text) || hasSecretLikeText(text)) return false;
58
+ return (
59
+ text.startsWith('llm-wiki/') ||
60
+ text.startsWith('src/') ||
61
+ text.startsWith('test/') ||
62
+ text.startsWith('docs/') ||
63
+ text.startsWith('bin/') ||
64
+ text.startsWith('examples/') ||
65
+ /^(?:README|AGENTS|CLAUDE|LICENSE|package(?:-lock)?|install)\.[A-Za-z0-9]+$/i.test(text) ||
66
+ /\.[A-Za-z0-9]{1,12}$/.test(text)
67
+ );
68
+ }
69
+
70
+ function relativeProjectPath(projectRoot, value) {
71
+ const cleaned = cleanCandidatePath(value);
72
+ if (!cleaned) return '';
73
+ if (projectRoot && cleaned.startsWith(projectRoot)) {
74
+ return relative(projectRoot, cleaned).split(sep).join('/');
75
+ }
76
+ return cleaned.replace(/^\.\//, '');
77
+ }
78
+
79
+ function addFileRefs(refs, projectRoot, text) {
80
+ for (const candidate of extractPathsFromText(text || '')) {
81
+ const rel = relativeProjectPath(projectRoot, candidate);
82
+ if (!isUsefulFileCandidate(rel)) continue;
83
+ if (isAbsolute(rel) || rel.split('/').includes('..')) continue;
84
+ refs.push(`file:${rel}`);
85
+ }
86
+ }
87
+
88
+ function decodeJsonString(value) {
89
+ try {
90
+ return JSON.parse(`"${value}"`);
91
+ } catch {
92
+ return value.replace(/\\"/g, '"');
93
+ }
94
+ }
95
+
96
+ function addCommandRefs(refs, text) {
97
+ const body = String(text || '');
98
+ const cmdRegex = /"cmd"\s*:\s*"((?:\\.|[^"\\])*)"/g;
99
+ let match = cmdRegex.exec(body);
100
+ while (match) {
101
+ const command = summarizeForStorage(decodeJsonString(match[1]).replace(/\s+/g, ' '), MAX_CMD_EVIDENCE_CHARS);
102
+ if (command && !hasSecretLikeText(command)) refs.push(`cmd:${command}`);
103
+ match = cmdRegex.exec(body);
104
+ }
105
+
106
+ for (const line of body.split(/\r?\n/)) {
107
+ const cleaned = line.replace(/^[-*]\s+/, '').trim();
108
+ const direct = cleaned.match(/^(?:Bash|Shell|Verification|Command):\s*(.+)$/i)?.[1];
109
+ const command = direct || (/^(?:node|npm|npx|git|llm-wiki|pytest|python|pnpm|yarn|vitest|jest|tsc)\b/.test(cleaned) ? cleaned : '');
110
+ if (!command) continue;
111
+ const safe = summarizeForStorage(command.replace(/\s+/g, ' '), MAX_CMD_EVIDENCE_CHARS);
112
+ if (safe && !hasSecretLikeText(safe)) refs.push(`cmd:${safe}`);
113
+ }
114
+ }
115
+
116
+ export function evidenceRefsFromEntry(entry, options = {}) {
117
+ const refs = [];
118
+ const projectRoot = options.projectRoot || '';
119
+ addFileRefs(refs, projectRoot, entry?.changedFiles);
120
+ addFileRefs(refs, projectRoot, entry?.work);
121
+ addCommandRefs(refs, entry?.verification);
122
+ addCommandRefs(refs, entry?.work);
123
+ return normalizeEvidenceRefs(refs).slice(0, options.limit || 20);
124
+ }
125
+
126
+ export function redactEvidenceRefs(refs) {
127
+ return normalizeEvidenceRefs(refs).map((ref) => redactText(ref, 700));
128
+ }
package/src/hook.js CHANGED
@@ -81,21 +81,35 @@ async function handleLegacyEagerStop(projectRoot, eventName, payload, entry) {
81
81
  }
82
82
  }
83
83
 
84
+ function durableQueueReason(classification) {
85
+ if (classification.kind === 'explicit-durable') {
86
+ return 'Explicit durable documentation request did not have a detected durable wiki update.';
87
+ }
88
+ return 'Suggested durable wiki candidate did not have a detected durable wiki update.';
89
+ }
90
+
91
+ function durableFollowUp(entry, classification) {
92
+ if (!classification.suggestDurable) return entry.followUp;
93
+ const message = 'This turn is a durable wiki candidate. After the current answer, merge reusable facts into an existing durable wiki page or mark the maintenance item skipped.';
94
+ const current = String(entry.followUp || '').trim();
95
+ if (!current || current === '(not captured)') return message;
96
+ if (current.includes(message)) return current;
97
+ return `${current}\n${message}`;
98
+ }
99
+
84
100
  async function handleAnswerFirstStop(projectRoot, eventName, payload, entry) {
85
101
  const classification = classifyTurn(entry, eventName);
86
102
  if (classification.archive) {
87
103
  const archiveEntry = {
88
104
  ...entry,
89
- followUp: classification.suggestDurable
90
- ? 'This turn may be worth preserving. If the user approves, merge it into an existing durable wiki page.'
91
- : entry.followUp,
105
+ followUp: durableFollowUp(entry, classification),
92
106
  };
93
107
  const liveQaPath = await appendLiveQa(projectRoot, archiveEntry);
94
108
  if (classification.queueIfMissingDurable && !hasDetectedDurableWikiChange(entry)) {
95
109
  await recordMaintenanceForEntry(projectRoot, entry, {
96
110
  source: liveQaPath,
97
111
  eventName,
98
- reason: 'Explicit durable documentation request did not have a detected durable wiki update.',
112
+ reason: durableQueueReason(classification),
99
113
  }).catch(() => {});
100
114
  }
101
115
  await appendWikiLog(projectRoot, `captured ${eventName}; archive=${relative(projectRoot, liveQaPath)}; classification=${classification.kind}`);
@@ -1,6 +1,7 @@
1
1
  import { readdir, unlink } from 'fs/promises';
2
2
  import { join, relative } from 'path';
3
- import { appendText, exists, kitDataDir, readJson, readText, sha256, writeTextIfMissing } from './fs-utils.js';
3
+ import { evidenceRefsFromEntry, normalizeEvidenceRefs, parseEvidenceRefsField } from './evidence.js';
4
+ import { appendText, exists, kitDataDir, readJson, readText, sha256, writeText, writeTextIfMissing } from './fs-utils.js';
4
5
  import { classifyTurn, isMaintenanceRelatedQuery } from './capture-policy.js';
5
6
  import { redactText, summarizeForStorage } from './redaction.js';
6
7
  import { buildEntryFromTurnState, hasRecoverableTurnState } from './state.js';
@@ -24,7 +25,7 @@ function queueHeader() {
24
25
  '',
25
26
  'Candidates to merge into durable wiki pages. Hooks only create candidates; the active agent reviews and merges them into existing durable wiki documents.',
26
27
  '',
27
- 'Status values: pending, done, skipped.',
28
+ 'Status values: pending, approved, done, skipped.',
28
29
  '',
29
30
  ].join('\n');
30
31
  }
@@ -70,18 +71,51 @@ function itemId(projectRoot, source, entry) {
70
71
  }
71
72
 
72
73
  function itemBlock(item) {
73
- return [
74
+ const fields = {
75
+ ...(item.fields || {}),
76
+ id: item.id,
77
+ created_at: item.created_at,
78
+ last_seen_at: item.last_seen_at,
79
+ source: item.source,
80
+ suggested_target: item.suggested_target,
81
+ target: item.target,
82
+ reviewed_at: item.reviewed_at,
83
+ review_note: item.review_note,
84
+ evidence_refs: normalizeEvidenceRefs(item.evidence_refs).length > 0
85
+ ? JSON.stringify(normalizeEvidenceRefs(item.evidence_refs))
86
+ : item.fields?.evidence_refs,
87
+ reason: item.reason,
88
+ result_missing: item.result_missing ? 'true' : 'false',
89
+ };
90
+ const order = [
91
+ 'id',
92
+ 'created_at',
93
+ 'last_seen_at',
94
+ 'source',
95
+ 'suggested_target',
96
+ 'target',
97
+ 'reviewed_at',
98
+ 'review_note',
99
+ 'evidence_refs',
100
+ 'reason',
101
+ 'result_missing',
102
+ ];
103
+ const lines = [
74
104
  `## ${item.status || 'pending'} - ${item.topic || 'maintenance item'}`,
75
105
  '',
76
- `- id: ${item.id}`,
77
- `- created_at: ${item.created_at}`,
78
- `- last_seen_at: ${item.last_seen_at}`,
79
- `- source: ${item.source}`,
80
- `- suggested_target: ${item.suggested_target}`,
81
- `- reason: ${item.reason}`,
82
- `- result_missing: ${item.result_missing ? 'true' : 'false'}`,
83
- '',
84
- ].join('\n');
106
+ ];
107
+ const emitted = new Set();
108
+ for (const key of order) {
109
+ if (fields[key] === undefined || fields[key] === '') continue;
110
+ lines.push(`- ${key}: ${fields[key]}`);
111
+ emitted.add(key);
112
+ }
113
+ for (const key of Object.keys(fields).sort()) {
114
+ if (emitted.has(key) || fields[key] === undefined || fields[key] === '') continue;
115
+ lines.push(`- ${key}: ${fields[key]}`);
116
+ }
117
+ lines.push('');
118
+ return lines.join('\n');
85
119
  }
86
120
 
87
121
  export async function readMaintenanceQueue(projectRoot) {
@@ -96,7 +130,7 @@ export async function readMaintenanceQueue(projectRoot) {
96
130
  };
97
131
 
98
132
  for (const line of text.split(/\r?\n/)) {
99
- const header = line.match(/^##\s+(pending|done|skipped)\s+-\s*(.*)$/);
133
+ const header = line.match(/^##\s+(pending|approved|done|skipped)\s+-\s*(.*)$/);
100
134
  if (header) {
101
135
  pushCurrent();
102
136
  current = {
@@ -107,7 +141,7 @@ export async function readMaintenanceQueue(projectRoot) {
107
141
  continue;
108
142
  }
109
143
  if (!current) continue;
110
- const field = line.match(/^-\s+([a-z_]+):\s*(.*)$/);
144
+ const field = line.match(/^-\s+([A-Za-z0-9_-]+):\s*(.*)$/);
111
145
  if (field) {
112
146
  current.fields[field[1]] = field[2].trim();
113
147
  }
@@ -119,19 +153,35 @@ export async function readMaintenanceQueue(projectRoot) {
119
153
  exists: await exists(path),
120
154
  items: items.map((item) => ({
121
155
  ...item.fields,
156
+ fields: item.fields,
122
157
  status: item.status,
123
158
  topic: item.topic,
159
+ evidence_refs: parseEvidenceRefsField(item.fields.evidence_refs),
160
+ evidenceRefs: parseEvidenceRefsField(item.fields.evidence_refs),
124
161
  result_missing: String(item.fields.result_missing || '').toLowerCase() === 'true',
125
162
  })),
126
163
  };
127
164
  }
128
165
 
166
+ async function writeMaintenanceQueue(projectRoot, items) {
167
+ const content = [
168
+ queueHeader().trimEnd(),
169
+ '',
170
+ ...items.map((item) => itemBlock(item).trimEnd()),
171
+ '',
172
+ ].join('\n');
173
+ const path = queuePath(projectRoot);
174
+ await writeText(path, content);
175
+ return path;
176
+ }
177
+
129
178
  export function summarizeMaintenanceQueue(queue, options = {}) {
130
179
  const staleDays = options.staleDays ?? DEFAULT_STALE_PENDING_DAYS;
131
180
  const pendingLimit = options.pendingLimit ?? DEFAULT_PENDING_LIMIT;
132
181
  const reviewPendingLimit = options.reviewPendingLimit ?? DEFAULT_REVIEW_PENDING_LIMIT;
133
182
  const reviewIntervalDays = options.reviewIntervalDays ?? DEFAULT_REVIEW_INTERVAL_DAYS;
134
183
  const pending = queue.items.filter((item) => item.status === 'pending');
184
+ const approved = queue.items.filter((item) => item.status === 'approved');
135
185
  const staleCutoff = Date.now() - staleDays * 24 * 60 * 60 * 1000;
136
186
  const stalePending = pending.filter((item) => {
137
187
  const time = Date.parse(item.created_at || item.last_seen_at || '');
@@ -143,6 +193,7 @@ export function summarizeMaintenanceQueue(queue, options = {}) {
143
193
  .filter(Number.isFinite);
144
194
  const lastReviewMs = reviewTimes.length > 0 ? Math.max(...reviewTimes) : null;
145
195
  const reviewReasons = [];
196
+ if (approved.length > 0) reviewReasons.push(`approved queue has ${approved.length} item(s) ready for durable wiki merge`);
146
197
  if (pending.length >= reviewPendingLimit) reviewReasons.push(`pending queue has ${pending.length} items (threshold ${reviewPendingLimit})`);
147
198
  if (stalePending.length > 0) reviewReasons.push(`${stalePending.length} pending item(s) older than ${staleDays} days`);
148
199
  if (pending.some((item) => item.result_missing)) reviewReasons.push('pending recovered turn state needs review');
@@ -153,9 +204,11 @@ export function summarizeMaintenanceQueue(queue, options = {}) {
153
204
  path: queue.path,
154
205
  exists: queue.exists,
155
206
  items: queue.items,
207
+ approved,
156
208
  pending,
157
209
  done: queue.items.filter((item) => item.status === 'done'),
158
210
  skipped: queue.items.filter((item) => item.status === 'skipped'),
211
+ approvedCount: approved.length,
159
212
  pendingCount: pending.length,
160
213
  doneCount: queue.items.filter((item) => item.status === 'done').length,
161
214
  skippedCount: queue.items.filter((item) => item.status === 'skipped').length,
@@ -246,6 +299,47 @@ export async function appendMaintenanceItem(projectRoot, item) {
246
299
  return { created: true, path, id: item.id };
247
300
  }
248
301
 
302
+ export async function updateMaintenanceItem(projectRoot, id, action, options = {}) {
303
+ const allowed = new Set(['approve', 'done', 'skip']);
304
+ if (!allowed.has(action)) throw new Error(`unsupported maintenance action: ${action}`);
305
+ const queue = await readMaintenanceQueue(projectRoot);
306
+ const index = queue.items.findIndex((item) => item.id === id);
307
+ if (index === -1) throw new Error(`maintenance item not found: ${id}`);
308
+ if ((action === 'approve' || action === 'done') && !options.target && !queue.items[index].target) {
309
+ throw new Error(`--target is required for maintenance --${action}`);
310
+ }
311
+ const now = nowIso();
312
+ const current = queue.items[index];
313
+ const next = {
314
+ ...current,
315
+ fields: { ...(current.fields || {}) },
316
+ status: action === 'approve' ? 'approved' : (action === 'done' ? 'done' : 'skipped'),
317
+ last_seen_at: now,
318
+ reviewed_at: now,
319
+ target: options.target || current.target || '',
320
+ review_note: sanitizeField(options.note || current.review_note || '', 500),
321
+ };
322
+ if (next.target) next.fields.target = next.target;
323
+ next.fields.last_seen_at = next.last_seen_at;
324
+ next.fields.reviewed_at = next.reviewed_at;
325
+ if (next.review_note) next.fields.review_note = next.review_note;
326
+ const refs = normalizeEvidenceRefs(options.evidence_refs || current.evidence_refs || current.evidenceRefs || []);
327
+ if (refs.length > 0) {
328
+ next.evidence_refs = refs;
329
+ next.fields.evidence_refs = JSON.stringify(refs);
330
+ }
331
+ queue.items[index] = next;
332
+ await writeMaintenanceQueue(projectRoot, queue.items);
333
+ return {
334
+ workspace: projectRoot,
335
+ path: queue.path,
336
+ id,
337
+ status: next.status,
338
+ target: next.target || null,
339
+ reviewed_at: next.reviewed_at,
340
+ };
341
+ }
342
+
249
343
  export async function recordMaintenanceForEntry(projectRoot, entry, options = {}) {
250
344
  const source = relativeSource(projectRoot, options.source || options.queryPath || options.decisionPath || options.liveQaPath || '');
251
345
  if (!source && !options.resultMissing) return { created: false, reason: 'missing-source' };
@@ -259,6 +353,7 @@ export async function recordMaintenanceForEntry(projectRoot, entry, options = {}
259
353
  last_seen_at: created,
260
354
  source,
261
355
  suggested_target: options.suggestedTarget || inferSuggestedTarget(entry, source),
356
+ evidence_refs: evidenceRefsFromEntry(entry, { projectRoot }),
262
357
  reason: sanitizeField(options.reason || `Captured ${options.eventName || 'turn'} needs durable wiki review.`, 300),
263
358
  result_missing: Boolean(options.resultMissing),
264
359
  };
@@ -307,37 +402,55 @@ export async function recoverStaleTurnStates(projectRoot, options = {}) {
307
402
  return output;
308
403
  }
309
404
 
405
+ function isDurablePromotionCandidate(item) {
406
+ if (item?.status === 'approved') return true;
407
+ const text = `${item?.reason || ''} ${item?.fields?.reason || ''}`.toLowerCase();
408
+ return /suggested durable wiki candidate|precompact durable candidate|explicit durable documentation|durable wiki review/.test(text);
409
+ }
410
+
411
+ function shouldSurfacePromptMaintenance(summary, query, reviewCandidates) {
412
+ if (isMaintenanceRelatedQuery(query || '', reviewCandidates)) return true;
413
+ if ((summary.approvedCount || 0) > 0) return true;
414
+ if (reviewCandidates.some(isDurablePromotionCandidate)) return true;
415
+ if ((summary.pendingCount || 0) >= (summary.reviewPendingLimit || DEFAULT_REVIEW_PENDING_LIMIT)) return true;
416
+ if ((summary.stalePendingCount || 0) > 0) return true;
417
+ return reviewCandidates.some((item) => item.result_missing);
418
+ }
419
+
310
420
  export function formatMaintenanceContext(summary, options = {}) {
311
421
  if (!summary.reviewDue) return '';
312
422
  const language = options.language === 'ko' ? 'ko' : 'en';
313
423
  const eventName = options.eventName || '';
314
424
  const defaultLimit = eventName === 'SessionStart' || eventName === 'InstructionsLoaded' ? 1 : 5;
315
425
  const limit = options.limit || defaultLimit;
316
- let pending = summary.pending.slice(0, limit);
426
+ const reviewCandidates = [...(summary.approved || []), ...(summary.pending || [])];
427
+ let pending = reviewCandidates.slice(0, limit);
317
428
 
318
429
  if (eventName === 'UserPromptSubmit') {
319
- if (!isMaintenanceRelatedQuery(options.query || '', summary.pending)) return '';
320
- pending = summary.pending.slice(0, 1);
430
+ if (!shouldSurfacePromptMaintenance(summary, options.query || '', reviewCandidates)) return '';
431
+ pending = reviewCandidates.slice(0, 1);
321
432
  } else if (eventName !== 'SessionStart' && eventName !== 'InstructionsLoaded') {
322
433
  return '';
323
434
  }
324
435
 
436
+ const reviewCount = (summary.approvedCount || 0) + (summary.pendingCount || 0);
325
437
  const lines = language === 'ko'
326
438
  ? [
327
439
  'LLM Wiki maintenance status:',
328
440
  `- review due: yes (${(summary.reviewReasons || []).slice(0, 2).join('; ') || 'periodic review threshold met'}).`,
329
- `- pending review items: ${summary.pendingCount}. 현재 요청이 우선이며, 관련 있을 때만 durable wiki 정리에 사용한다.`,
441
+ `- approved/pending review items: ${reviewCount}. 현재 요청이 우선이며, 관련 있거나 세션 종료 전 여유가 있으면 durable wiki 병합하거나 skipped로 표시한다.`,
330
442
  ]
331
443
  : [
332
444
  'LLM Wiki maintenance status:',
333
445
  `- review due: yes (${(summary.reviewReasons || []).slice(0, 2).join('; ') || 'periodic review threshold met'}).`,
334
- `- pending review items: ${summary.pendingCount}. The current request comes first; use this only when it is relevant to durable wiki cleanup.`,
446
+ `- approved/pending review items: ${reviewCount}. The current request comes first; when relevant or before ending the session, merge candidates into durable wiki pages or mark them skipped.`,
335
447
  ];
336
448
  for (const item of pending) {
337
- lines.push(`- ${item.topic || item.id}: ${item.suggested_target}; source=${item.source}${item.result_missing ? '; result missing' : ''}`);
449
+ const target = item.target || item.suggested_target;
450
+ lines.push(`- ${item.status}: ${item.topic || item.id}: ${target}; source=${item.source}${item.result_missing ? '; result missing' : ''}`);
338
451
  }
339
- if (summary.pending.length > pending.length) {
340
- lines.push(`- ${summary.pending.length - pending.length} more pending item(s) available in llm-wiki/outputs/maintenance/queue.md.`);
452
+ if (reviewCandidates.length > pending.length) {
453
+ lines.push(`- ${reviewCandidates.length - pending.length} more review item(s) available in llm-wiki/outputs/maintenance/queue.md.`);
341
454
  }
342
455
  return lines.join('\n');
343
456
  }
@@ -346,6 +459,7 @@ export function formatMaintenanceResult(summary) {
346
459
  const lines = [
347
460
  'llm-wiki maintenance',
348
461
  `- queue: ${summary.path}`,
462
+ `- approved: ${summary.approvedCount || 0}`,
349
463
  `- pending: ${summary.pendingCount}`,
350
464
  `- stale pending: ${summary.stalePendingCount || 0}`,
351
465
  `- done: ${summary.doneCount}`,
@@ -355,6 +469,12 @@ export function formatMaintenanceResult(summary) {
355
469
  if ((summary.reviewReasons || []).length > 0) {
356
470
  lines.push(`- review reasons: ${summary.reviewReasons.join('; ')}`);
357
471
  }
472
+ if ((summary.approved || []).length > 0) {
473
+ lines.push('', 'Approved:');
474
+ for (const item of summary.approved.slice(0, 10)) {
475
+ lines.push(`- ${item.topic || item.id}: ${item.source} -> ${item.target || item.suggested_target}`);
476
+ }
477
+ }
358
478
  if (summary.pending.length > 0) {
359
479
  lines.push('', 'Pending:');
360
480
  for (const item of summary.pending.slice(0, 10)) {
@@ -254,7 +254,7 @@ function legacyProcedureSignals(name) {
254
254
  const common = [/^# .+ Procedure/m];
255
255
  const byName = {
256
256
  'ingest.md': [/^# Ingest Procedure/m, /wiki\/memory\.md/, /raw\/inbox|raw\/sources/],
257
- 'query.md': [/^# Query Procedure/m, /llm-wiki context/, /Save reusable answers|reusable answers/],
257
+ 'query.md': [/^# Query Procedure/m, /llm-wiki context/, /Save reusable answers|reusable answers|Merge reusable facts|reusable facts/],
258
258
  'lint.md': [/^# Lint Procedure/m, /llm-wiki lint/, /stale pages|orphan pages/],
259
259
  'security.md': [/^# Security Procedure/m, /Redact authentication values|token|private keys/, /raw transcript capture/],
260
260
  };
@@ -359,12 +359,19 @@ function hasKitManagedSignal(text, descriptor) {
359
359
  return false;
360
360
  }
361
361
 
362
+ function hasPatchableGeneratedSignal(text, descriptor) {
363
+ if (!hasLegacyGeneratedSignal(text, descriptor)) return false;
364
+ if (String(descriptor.id || '').startsWith('procedure-') && /^##\s+/m.test(text)) return false;
365
+ return true;
366
+ }
367
+
362
368
  function canPatchDescriptor(state, descriptor, currentText, fileExists = true) {
363
369
  if (descriptor.mode === 'create-only') return !fileExists;
364
370
  if (descriptor.mode === 'marker') return replaceMarkedBlock(currentText, descriptor.content) !== null;
365
371
  return isRecordedManaged(state, descriptor, currentText) ||
366
372
  isKnownGeneratedContent(currentText, descriptor) ||
367
- isLegacyGeneratedContent(currentText, descriptor);
373
+ isLegacyGeneratedContent(currentText, descriptor) ||
374
+ hasPatchableGeneratedSignal(currentText, descriptor);
368
375
  }
369
376
 
370
377
  function desiredTextForDescriptor(descriptor, currentText, fileExists = true) {
package/src/project.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  writeTextIfMissing,
10
10
  } from './fs-utils.js';
11
11
  import { LLM_WIKI_DIRS } from './constants.js';
12
+ import { evidenceRefsFromEntry, frontmatterEvidenceRefs } from './evidence.js';
12
13
  import { formatMaintenanceContext, maintenanceSummary } from './maintenance.js';
13
14
  import { normalizeForStorage, redactText, summarizeForStorage } from './redaction.js';
14
15
  import { gitignore, indexPage, llmWikiAgents, logPage, memoryPage, procedure, rootAgentsPolicy } from './templates.js';
@@ -107,7 +108,8 @@ export async function writeQueryPage(projectRoot, entry) {
107
108
  const slug = slugify(entry.question, 'query');
108
109
  const path = join(projectRoot, 'llm-wiki', 'wiki', 'queries', `${day}-${slug}.md`);
109
110
  if (await exists(path)) return path;
110
- const content = `---\ntitle: "${entry.topic || slug}"\ntype: "query"\nsource_ids: []\nstatus: "draft"\nlast_updated: "${day}"\nconfidence: "medium"\nmemory_type: "episodic"\nimportance: 2\nlast_verified: "unknown"\nsupersedes: []\nsuperseded_by: []\n---\n\n# ${entry.topic || entry.question.slice(0, 80)}\n\n## Question\n${entry.question}\n\n## Answer Summary\n${entry.result || '(not captured)'}\n\n## Work Notes\n${entry.work || '(not captured)'}\n\n## Verification\n${entry.verification || '(not captured)'}\n\n## Related Pages\n- [[index]]\n\n## Change Log\n- ${day}: Captured automatically by llm-wiki-kit hook.\n`;
111
+ const evidenceRefs = frontmatterEvidenceRefs(evidenceRefsFromEntry(entry, { projectRoot }));
112
+ const content = `---\ntitle: "${entry.topic || slug}"\ntype: "query"\nsource_ids: []\n${evidenceRefs}\nstatus: "draft"\nlast_updated: "${day}"\nconfidence: "medium"\nmemory_type: "episodic"\nimportance: 2\nlast_verified: "unknown"\nsupersedes: []\nsuperseded_by: []\n---\n\n# ${entry.topic || entry.question.slice(0, 80)}\n\n## Question\n${entry.question}\n\n## Answer Summary\n${entry.result || '(not captured)'}\n\n## Work Notes\n${entry.work || '(not captured)'}\n\n## Verification\n${entry.verification || '(not captured)'}\n\n## Related Pages\n- [[index]]\n\n## Change Log\n- ${day}: Captured automatically by llm-wiki-kit hook.\n`;
111
113
  await writeTextIfMissing(path, redactText(content, 12000));
112
114
  return path;
113
115
  }
@@ -120,7 +122,8 @@ export async function writeDecisionPage(projectRoot, entry) {
120
122
  const slug = slugify(entry.topic || entry.question || 'decision', 'decision');
121
123
  const path = join(projectRoot, 'llm-wiki', 'wiki', 'decisions', `${day}-${slug}.md`);
122
124
  if (await exists(path)) return path;
123
- const content = `---\ntitle: "${entry.topic || slug}"\ntype: "decision"\nsource_ids: []\nstatus: "draft"\nlast_updated: "${day}"\nconfidence: "medium"\nmemory_type: "semantic"\nimportance: 4\nlast_verified: "unknown"\nsupersedes: []\nsuperseded_by: []\n---\n\n# ${entry.topic || 'Decision'}\n\n## Decision\n${entry.result || '(captured from assistant response; review needed)'}\n\n## Context\n${entry.question || '(not captured)'}\n\n## Evidence\n${entry.work || '(not captured)'}\n\n## Verification\n${entry.verification || '(not captured)'}\n\n## Open Questions\n${entry.followUp || '(none captured)'}\n\n## Change Log\n- ${day}: Captured automatically by llm-wiki-kit hook.\n`;
125
+ const evidenceRefs = frontmatterEvidenceRefs(evidenceRefsFromEntry(entry, { projectRoot }));
126
+ const content = `---\ntitle: "${entry.topic || slug}"\ntype: "decision"\nsource_ids: []\n${evidenceRefs}\nstatus: "draft"\nlast_updated: "${day}"\nconfidence: "medium"\nmemory_type: "semantic"\nimportance: 4\nlast_verified: "unknown"\nsupersedes: []\nsuperseded_by: []\n---\n\n# ${entry.topic || 'Decision'}\n\n## Decision\n${entry.result || '(captured from assistant response; review needed)'}\n\n## Context\n${entry.question || '(not captured)'}\n\n## Evidence\n${entry.work || '(not captured)'}\n\n## Verification\n${entry.verification || '(not captured)'}\n\n## Open Questions\n${entry.followUp || '(none captured)'}\n\n## Change Log\n- ${day}: Captured automatically by llm-wiki-kit hook.\n`;
124
127
  await writeTextIfMissing(path, redactText(content, 12000));
125
128
  return path;
126
129
  }
package/src/templates.js CHANGED
@@ -16,12 +16,21 @@ This repository uses llm-wiki-kit as a hook-first living Markdown wiki for Codex
16
16
  - Keep \`llm-wiki/wiki/memory.md\` short. Link to important documents instead of copying long explanations.
17
17
  - Use hook-injected context when helpful, but answer the current user request first. Use \`llm-wiki context\`, \`llm-wiki lint\`, and \`llm-wiki consolidate\` only as agent maintenance helpers.
18
18
  - Hooks safely store redacted raw envelopes and work/decision-focused live Q&A. Simple answers, status checks, and keyword-only turns should not be promoted to live Q&A or durable wiki by default.
19
- - Hooks may queue durable cleanup candidates in \`llm-wiki/outputs/maintenance/queue.md\` at stop/start boundaries. Treat maintenance as a soft agent-side reminder; merge pending items only when relevant and mark them \`done\` or \`skipped\`.
19
+ - Hooks may queue durable cleanup candidates in \`llm-wiki/outputs/maintenance/queue.md\` at stop/start boundaries. Treat maintenance as a soft agent-side reminder; merge surfaced candidates when relevant or before ending the session, then mark them \`done\` or \`skipped\`.
20
20
  - Before creating new wiki pages, search existing pages and update the right one when possible. Reusable facts should not stay only in chunked \`outputs/questions/\`.
21
- - Store one-off work or decision records in \`llm-wiki/outputs/questions/YYYY-MM-DD/live-qa-001.md\` when needed. Merge reusable knowledge into \`wiki/architecture/\`, \`wiki/debugging/\`, \`wiki/decisions/\`, \`wiki/concepts/\`, or \`procedures/\` when approved or clearly important.
21
+ - Store one-off work or decision records in \`llm-wiki/outputs/questions/YYYY-MM-DD/live-qa-001.md\` when needed. Merge reusable knowledge into \`wiki/architecture/\`, \`wiki/debugging/\`, \`wiki/decisions/\`, \`wiki/concepts/\`, or \`procedures/\` when approved, hook-suggested, or clearly important.
22
22
  - Record verification commands, evidence files, and uncertainty. Mark inference explicitly and preserve contradictions in Open Questions or Contradictions.
23
23
  - Never store credentials, tokens, passwords, private keys, or raw \`.env\` contents. Store only redacted summaries when needed.
24
24
 
25
+ ### llm-wiki-kit Implementation Plans
26
+
27
+ - When implementing an approved llm-wiki-kit plan, read the complete plan first and treat it as the implementation source of truth.
28
+ - A valid plan must include history/context, lifecycle intent, exact source paths, test strategy, release verification, and commit/push requirements.
29
+ - Do not implement feature lists in isolation. Each feature must be integrated into the hook-first, answer-first, local Markdown knowledge lifecycle.
30
+ - \`llms.txt\`/exports are not passive artifacts; they are agent onboarding, handoff, retrieval-eval, and external-consumption manifests.
31
+ - \`evidence_refs\`, maintenance review, context ranking explanations, export, and eval must share the same durable wiki visibility policy.
32
+ - Never store npm tokens, WinRM credentials, private keys, raw \`.env\`, or full raw transcripts in wiki, logs, generated exports, or commits.
33
+
25
34
  <!-- llm-wiki-kit:end -->
26
35
  `;
27
36
  }
@@ -55,13 +64,23 @@ These rules replace older OMX/OMC/\`omx_wiki/\` rules for this project.
55
64
  - Important claims should include at least one of: \`source_ids\`, file paths, or verification commands.
56
65
  - When durable knowledge appears, search existing \`wiki/\` pages before creating a new page.
57
66
  - Do not promote simple answers, status checks, keyword-only replies, or one-off chat into live Q&A, \`wiki/queries\`, or maintenance.
58
- - Merge reusable knowledge into \`wiki/architecture/\`, \`wiki/debugging/\`, \`wiki/decisions/\`, \`wiki/concepts/\`, or \`procedures/\` based on importance and user consent flow.
59
- - Review \`outputs/maintenance/queue.md\` pending items only when related to the current request or when review is due. Merge into existing durable wiki pages, then mark items \`done\` or \`skipped\`.
60
- - Periodic maintenance is agent review, not automatic editing. Show only short reminders at \`SessionStart\`/\`InstructionsLoaded\`, and only show prompt-time reminders for wiki/maintenance-related user prompts.
67
+ - Hooks may flag work/decision turns as durable wiki candidates when they contain reusable architecture, debugging, policy, procedure, or decision signals. Treat those as review prompts, not automatic page creation.
68
+ - Merge reusable knowledge into \`wiki/architecture/\`, \`wiki/debugging/\`, \`wiki/decisions/\`, \`wiki/concepts/\`, or \`procedures/\` based on importance, hook suggestions, and user consent flow.
69
+ - Review \`outputs/maintenance/queue.md\` pending items when surfaced by hook context, related to the current request, or review is due. Merge into existing durable wiki pages, then mark items \`done\` or \`skipped\`.
70
+ - Periodic maintenance is agent review, not automatic editing. Show only short reminders at \`SessionStart\`/\`InstructionsLoaded\`, and compact prompt-time reminders when durable candidates or review thresholds need agent attention.
61
71
  - Keep \`wiki/memory.md\` short. Use links to current state and important documents instead of long explanations.
62
72
  - Preserve contradictions in \`Contradictions\` or \`Open Questions\`; do not overwrite them silently.
63
73
  - Do not store credentials, tokens, passwords, private keys, or raw \`.env\` contents in wiki.
64
74
 
75
+ ### llm-wiki-kit Implementation Plans
76
+
77
+ - When implementing an approved llm-wiki-kit plan, read the complete plan first and treat it as the implementation source of truth.
78
+ - A valid plan must include history/context, lifecycle intent, exact source paths, test strategy, release verification, and commit/push requirements.
79
+ - Do not implement feature lists in isolation. Each feature must be integrated into the hook-first, answer-first, local Markdown knowledge lifecycle.
80
+ - \`llms.txt\`/exports are not passive artifacts; they are agent onboarding, handoff, retrieval-eval, and external-consumption manifests.
81
+ - \`evidence_refs\`, maintenance review, context ranking explanations, export, and eval must share the same durable wiki visibility policy.
82
+ - Never store npm tokens, WinRM credentials, private keys, raw \`.env\`, or full raw transcripts in wiki, logs, generated exports, or commits.
83
+
65
84
  ## Page Format
66
85
  Use YAML frontmatter when creating wiki pages:
67
86
 
@@ -70,6 +89,7 @@ Use YAML frontmatter when creating wiki pages:
70
89
  title: ""
71
90
  type: "source | concept | entity | decision | architecture | debugging | context | query | session-log | convention"
72
91
  source_ids: []
92
+ evidence_refs: []
73
93
  status: "draft | reviewed | stale | archived"
74
94
  last_updated: "YYYY-MM-DD"
75
95
  confidence: "high | medium | low"
@@ -86,7 +106,7 @@ superseded_by: []
86
106
  - query: use hook-injected context when useful, but answer the current request first. Manual commands are diagnostics, not required daily workflow.
87
107
  - lint: agent helper for user-requested or wiki maintenance work. Do not run it every turn.
88
108
  - consolidate: safely refresh generated blocks in \`memory.md\`/\`index.md\`. Do not overwrite handwritten sections or curated document bodies.
89
- - maintenance: \`outputs/maintenance/queue.md\` stores stop/start cleanup candidates. Review and update it only when it does not delay the current user request.
109
+ - maintenance: \`outputs/maintenance/queue.md\` stores stop/start cleanup candidates. Review surfaced durable candidates without delaying the current user request, then mark them \`done\` or \`skipped\`.
90
110
  `;
91
111
  }
92
112
 
@@ -131,6 +151,7 @@ export function memoryPage() {
131
151
  title: "LLM Wiki Memory"
132
152
  type: "context"
133
153
  source_ids: []
154
+ evidence_refs: []
134
155
  status: "draft"
135
156
  last_updated: "unknown"
136
157
  confidence: "medium"
@@ -178,7 +199,7 @@ export function procedure(name) {
178
199
  3. Create or update \`wiki/sources/<slug>.md\` only when a source summary is useful.
179
200
  4. Before creating a new page, look for related concept/entity/decision/architecture/debugging/context pages and update those first.
180
201
  5. Do not create duplicates. Merge facts into existing pages when they already exist.
181
- 6. Do not promote simple answers or one-off chat into durable wiki.
202
+ 6. Do not promote simple answers or one-off chat into durable wiki, but do review hook-suggested durable candidates for reusable facts.
182
203
  7. Important claims should record source references, confidence, memory type, importance, and verification status.
183
204
  8. When durable entry points change, update \`wiki/memory.md\` briefly or use \`llm-wiki consolidate\`.
184
205
  9. Add a short entry to \`wiki/log.md\` for meaningful wiki changes.
@@ -191,8 +212,8 @@ export function procedure(name) {
191
212
  4. Inspect raw sources only when exact evidence matters.
192
213
  5. Separate verified facts from inference.
193
214
  6. Use \`llm-wiki context "<query>"\` only when manual inspection is useful. Add \`--include-episodic\` only for historical query/context pages, and \`--include-archived\` only for archived/superseded pages.
194
- 7. Do not promote one-off answers, status checks, or keyword-only replies to durable wiki or live Q&A. Archive only work turns with tool evidence, changed-file evidence, verification, or structured decision/debugging conclusions.
195
- 8. Merge reusable facts into \`wiki/architecture/\`, \`wiki/debugging/\`, \`wiki/decisions/\`, \`wiki/concepts/\`, or \`procedures/\` based on importance and user consent flow.
215
+ 7. Do not promote one-off answers, status checks, or keyword-only replies to durable wiki or live Q&A. Archive only work turns with tool evidence, changed-file evidence, verification, structured decision/debugging conclusions, or hook-suggested durable candidates.
216
+ 8. Merge reusable facts into \`wiki/architecture/\`, \`wiki/debugging/\`, \`wiki/decisions/\`, \`wiki/concepts/\`, or \`procedures/\` based on importance, hook suggestions, and user consent flow.
196
217
  `,
197
218
  'lint.md': `# Lint Procedure
198
219
 
@@ -204,7 +225,7 @@ Periodic maintenance is an agent-side task, not a per-turn user command. When ne
204
225
 
205
226
  1. \`llm-wiki lint --workspace <project>\`
206
227
  2. \`llm-wiki maintenance --workspace <project>\`
207
- 3. Merge pending items into existing durable pages, then mark them \`done\` or \`skipped\`.
228
+ 3. Merge pending or hook-suggested durable candidates into existing durable pages when appropriate, then mark them \`done\` or \`skipped\`.
208
229
  4. \`llm-wiki consolidate --workspace <project> --dry-run\`
209
230
  5. Run \`llm-wiki consolidate --workspace <project>\` when the dry run is acceptable.
210
231