kushi-agents 5.2.0 → 5.3.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.
@@ -0,0 +1,503 @@
1
+ // kushi v5.3.0 — global wiki + promote operation
2
+ //
3
+ // Implements:
4
+ // - resolveGlobalRoot() — honors $KUSHI_GLOBAL_ROOT
5
+ // - globalInit() — idempotent scaffold
6
+ // - globalStatus() — page counts + freshness
7
+ // - globalAsk() — search global answers/
8
+ // - globalLint() — lint global wiki (potential-customer-leak + standard classes)
9
+ // - detectIdentifiers() — privacy scan reused by promote
10
+ // - promote() — full project→global flow with redaction gate
11
+ //
12
+ // All filesystem mutation must be confined to the resolved global root or the
13
+ // supplied project root. Tests use $env:KUSHI_GLOBAL_ROOT='.testtmp/.kushi-global'
14
+ // — never the real ~/.kushi-global/.
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import os from 'node:os';
19
+
20
+ const SCAFFOLD_FILES = {
21
+ 'index.md': `---
22
+ kushi_state_page: true
23
+ scope: global
24
+ ---
25
+
26
+ # Global wiki — index
27
+
28
+ This is your personal cross-engagement knowledge base. It lives at the path
29
+ resolved by \`$KUSHI_GLOBAL_ROOT\` (or \`~/.kushi-global/State/\` by default)
30
+ and is structurally identical to a project \`State/\` wiki.
31
+
32
+ Pages are added here by **explicit promotion only** — run
33
+ \`kushi promote <project> <page-path>\` to move a curated page from a project's
34
+ State/ into this global wiki. Auto-promotion is not supported on purpose.
35
+
36
+ ## Sections
37
+
38
+ - [log.md](log.md) — reverse-chronological op log
39
+ - [conventions.md](conventions.md) — cross-engagement conventions
40
+ - [answers/](answers/) — promoted Q&A pages
41
+ - [reports/](reports/) — lint + status snapshots
42
+ - [_review-queue.md](_review-queue.md) — open privacy / contradiction items
43
+
44
+ ## See also
45
+
46
+ - \`plugin/instructions/global-wiki.instructions.md\`
47
+ - \`plugin/instructions/multi-wiki-routing.instructions.md\`
48
+ `,
49
+ 'log.md': `---
50
+ kushi_state_page: true
51
+ scope: global
52
+ ---
53
+
54
+ # Global wiki — log
55
+
56
+ Reverse-chronological. Same format as project \`log.md\`
57
+ (per \`log-format.instructions.md\`). Ops: \`global-init\`, \`promote-in\`,
58
+ \`global-lint\`, \`global-ask-fileback\`.
59
+ `,
60
+ 'conventions.md': `---
61
+ kushi_state_page: true
62
+ scope: global
63
+ ---
64
+
65
+ # Global conventions
66
+
67
+ Cross-engagement conventions you want kushi to apply across every project.
68
+ This is the \`scope: global\` analog of the per-project \`CLAUDE.md\`.
69
+ v5.3.0 documents the placeholder; runtime application across projects is
70
+ deferred to a future release (v5.4+).
71
+ `,
72
+ '_review-queue.md': `---
73
+ kushi_state_page: true
74
+ scope: global
75
+ ---
76
+
77
+ # Global review queue
78
+
79
+ Open items that need your attention. Populated by:
80
+
81
+ - \`kushi promote\` when it inserts \`[!warning] potential-customer-leak\` callouts.
82
+ - \`kushi global lint\` when it flags contradictions or stale claims.
83
+ `,
84
+ };
85
+
86
+ const SCAFFOLD_DIRS = ['answers', 'reports'];
87
+
88
+ // ───────────────────────────────────────────────────────────────────────────
89
+ // Resolution
90
+
91
+ export function resolveGlobalRoot(env = process.env) {
92
+ const override = env.KUSHI_GLOBAL_ROOT;
93
+ if (override && override.trim()) {
94
+ return path.resolve(expandTilde(override.trim(), env));
95
+ }
96
+ const home = env.USERPROFILE || env.HOME || os.homedir();
97
+ return path.join(home, '.kushi-global');
98
+ }
99
+
100
+ function expandTilde(p, env) {
101
+ if (p.startsWith('~')) {
102
+ const home = env.USERPROFILE || env.HOME || os.homedir();
103
+ return path.join(home, p.slice(1).replace(/^[\\/]/, ''));
104
+ }
105
+ return p;
106
+ }
107
+
108
+ function stateDir(root) {
109
+ return path.join(root, 'State');
110
+ }
111
+
112
+ // ───────────────────────────────────────────────────────────────────────────
113
+ // Init
114
+
115
+ export function globalInit({ root, now = new Date() } = {}) {
116
+ const globalRoot = root || resolveGlobalRoot();
117
+ const state = stateDir(globalRoot);
118
+ fs.mkdirSync(state, { recursive: true });
119
+
120
+ const created = [];
121
+ const skipped = [];
122
+
123
+ for (const dir of SCAFFOLD_DIRS) {
124
+ const d = path.join(state, dir);
125
+ if (!fs.existsSync(d)) {
126
+ fs.mkdirSync(d, { recursive: true });
127
+ created.push(`${dir}/`);
128
+ } else {
129
+ skipped.push(`${dir}/`);
130
+ }
131
+ }
132
+
133
+ for (const [name, body] of Object.entries(SCAFFOLD_FILES)) {
134
+ const f = path.join(state, name);
135
+ if (!fs.existsSync(f)) {
136
+ fs.writeFileSync(f, body);
137
+ created.push(name);
138
+ } else {
139
+ skipped.push(name);
140
+ }
141
+ }
142
+
143
+ appendLog(state, 'global-init', `Scaffold checked (${created.length} created, ${skipped.length} already present)`, now);
144
+
145
+ return { root: globalRoot, state, created, skipped };
146
+ }
147
+
148
+ // ───────────────────────────────────────────────────────────────────────────
149
+ // Status
150
+
151
+ export function globalStatus({ root } = {}) {
152
+ const globalRoot = root || resolveGlobalRoot();
153
+ const state = stateDir(globalRoot);
154
+ if (!fs.existsSync(state)) {
155
+ return { root: globalRoot, state, initialized: false };
156
+ }
157
+ const counts = {
158
+ pages: 0,
159
+ answers: 0,
160
+ reports: 0,
161
+ review_items: 0,
162
+ };
163
+ let newest = 0;
164
+ walk(state, (full, rel) => {
165
+ if (!rel.endsWith('.md')) return;
166
+ counts.pages += 1;
167
+ if (rel.startsWith('answers' + path.sep) || rel.startsWith('answers/')) counts.answers += 1;
168
+ if (rel.startsWith('reports' + path.sep) || rel.startsWith('reports/')) counts.reports += 1;
169
+ const stat = fs.statSync(full);
170
+ if (stat.mtimeMs > newest) newest = stat.mtimeMs;
171
+ });
172
+ const reviewPath = path.join(state, '_review-queue.md');
173
+ if (fs.existsSync(reviewPath)) {
174
+ const body = fs.readFileSync(reviewPath, 'utf8');
175
+ counts.review_items = (body.match(/^- \[ \]/gm) || []).length;
176
+ }
177
+ return {
178
+ root: globalRoot,
179
+ state,
180
+ initialized: true,
181
+ counts,
182
+ newest_iso: newest ? new Date(newest).toISOString() : null,
183
+ };
184
+ }
185
+
186
+ // ───────────────────────────────────────────────────────────────────────────
187
+ // Ask
188
+
189
+ export function globalAsk({ root, question } = {}) {
190
+ if (!question || !question.trim()) {
191
+ return { hits: [], message: 'no question provided' };
192
+ }
193
+ const globalRoot = root || resolveGlobalRoot();
194
+ const answers = path.join(stateDir(globalRoot), 'answers');
195
+ if (!fs.existsSync(answers)) {
196
+ return { hits: [], message: 'global wiki not initialized — run `kushi global init`' };
197
+ }
198
+
199
+ const terms = question
200
+ .toLowerCase()
201
+ .split(/[^a-z0-9]+/)
202
+ .filter((t) => t.length >= 3);
203
+ const hits = [];
204
+ for (const name of fs.readdirSync(answers)) {
205
+ if (!name.endsWith('.md')) continue;
206
+ const full = path.join(answers, name);
207
+ const body = fs.readFileSync(full, 'utf8').toLowerCase();
208
+ let score = 0;
209
+ for (const t of terms) {
210
+ if (body.includes(t)) score += 1;
211
+ }
212
+ if (score > 0) {
213
+ hits.push({ file: full, name, score, provenance: '[global]' });
214
+ }
215
+ }
216
+ hits.sort((a, b) => b.score - a.score);
217
+ return { hits, message: hits.length ? `${hits.length} match(es)` : 'no matches in global wiki' };
218
+ }
219
+
220
+ // ───────────────────────────────────────────────────────────────────────────
221
+ // Lint
222
+
223
+ export function globalLint({ root } = {}) {
224
+ const globalRoot = root || resolveGlobalRoot();
225
+ const state = stateDir(globalRoot);
226
+ if (!fs.existsSync(state)) {
227
+ return { initialized: false, findings: [] };
228
+ }
229
+ const findings = [];
230
+ walk(state, (full, rel) => {
231
+ if (!rel.endsWith('.md')) return;
232
+ const lines = fs.readFileSync(full, 'utf8').split(/\r?\n/);
233
+ lines.forEach((line, i) => {
234
+ if (/^>\s*\[!warning\]\s+potential-customer-leak/i.test(line)) {
235
+ findings.push({
236
+ class: 'potential-customer-leak',
237
+ severity: 'warning',
238
+ file: rel,
239
+ line: i + 1,
240
+ snippet: line.trim(),
241
+ fix: 'Resolve the redaction (rewrite the surrounding sentence) and remove the callout.',
242
+ });
243
+ }
244
+ if (/^>\s*\[!warning\]\s+Contradicted/i.test(line)) {
245
+ findings.push({
246
+ class: 'contradiction-flagged',
247
+ severity: 'warning',
248
+ file: rel,
249
+ line: i + 1,
250
+ snippet: line.trim(),
251
+ fix: 'Reconcile the contradiction per living-wiki.instructions.md.',
252
+ });
253
+ }
254
+ });
255
+ });
256
+ return { initialized: true, findings };
257
+ }
258
+
259
+ // ───────────────────────────────────────────────────────────────────────────
260
+ // Identifier detection (reused from schema-evolve-style heuristics)
261
+
262
+ export function detectIdentifiers(body, { project, alias, extraAliases = [] } = {}) {
263
+ const hits = [];
264
+ const lines = body.split(/\r?\n/);
265
+
266
+ const literals = new Set();
267
+ if (project) literals.add(project);
268
+ if (alias) literals.add(alias);
269
+ for (const x of extraAliases) if (x) literals.add(x);
270
+
271
+ for (const lit of literals) {
272
+ if (!lit || lit.length < 2) continue;
273
+ const re = new RegExp(`\\b${escapeRegex(lit)}\\b`, 'gi');
274
+ const lineNumbers = [];
275
+ lines.forEach((ln, i) => {
276
+ if (re.test(ln)) lineNumbers.push(i + 1);
277
+ re.lastIndex = 0;
278
+ });
279
+ if (lineNumbers.length) {
280
+ hits.push({ pattern: lit, kind: 'project-or-alias', count: lineNumbers.length, line_numbers: lineNumbers });
281
+ }
282
+ }
283
+
284
+ // Non-Microsoft email addresses
285
+ const emailRe = /\b[A-Za-z0-9._%+-]+@([A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g;
286
+ const emailHits = new Map();
287
+ lines.forEach((ln, i) => {
288
+ let m;
289
+ while ((m = emailRe.exec(ln)) !== null) {
290
+ const domain = m[1].toLowerCase();
291
+ if (domain.endsWith('microsoft.com') || domain.endsWith('@microsoft.com')) continue;
292
+ const addr = m[0];
293
+ if (!emailHits.has(addr)) emailHits.set(addr, []);
294
+ emailHits.get(addr).push(i + 1);
295
+ }
296
+ });
297
+ for (const [addr, nums] of emailHits) {
298
+ hits.push({ pattern: addr, kind: 'external-email', count: nums.length, line_numbers: nums });
299
+ }
300
+
301
+ return hits;
302
+ }
303
+
304
+ function escapeRegex(s) {
305
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
306
+ }
307
+
308
+ // ───────────────────────────────────────────────────────────────────────────
309
+ // Promote
310
+
311
+ export function promote({
312
+ project,
313
+ alias,
314
+ sourcePath,
315
+ projectRoot,
316
+ globalRoot,
317
+ force = false,
318
+ extraAliases = [],
319
+ now = new Date(),
320
+ } = {}) {
321
+ if (!project || !sourcePath || !projectRoot) {
322
+ throw new Error('promote: project, sourcePath, projectRoot are required');
323
+ }
324
+ const resolvedGlobal = globalRoot || resolveGlobalRoot();
325
+ const globalState = stateDir(resolvedGlobal);
326
+
327
+ // 1. Resolve source.
328
+ const absSource = path.isAbsolute(sourcePath) ? sourcePath : path.join(projectRoot, sourcePath);
329
+ if (!fs.existsSync(absSource)) {
330
+ throw new Error(`promote: source page not found: ${absSource}`);
331
+ }
332
+ const sourceBody = fs.readFileSync(absSource, 'utf8');
333
+
334
+ // 2. Identifier scan.
335
+ const aliasFromPath = alias || guessAliasFromPath(absSource, projectRoot);
336
+ const detections = detectIdentifiers(sourceBody, {
337
+ project,
338
+ alias: aliasFromPath,
339
+ extraAliases,
340
+ });
341
+
342
+ // 3. Refuse without --force.
343
+ if (detections.length > 0 && !force) {
344
+ return {
345
+ ok: false,
346
+ refused: true,
347
+ reason: 'identifier-detected',
348
+ detections,
349
+ hint: 'Re-run with --force after reviewing the redactions list.',
350
+ };
351
+ }
352
+
353
+ // 4. Build redacted body.
354
+ let redactedBody = sourceBody;
355
+ const redactionLabels = [];
356
+ for (const d of detections) {
357
+ const re = new RegExp(`\\b${escapeRegex(d.pattern)}\\b`, 'gi');
358
+ redactedBody = redactedBody.replace(re, '[REDACTED]');
359
+ redactionLabels.push(d.pattern);
360
+ }
361
+
362
+ // Strip any existing top-level frontmatter; we replace it.
363
+ const fmStripped = redactedBody.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
364
+
365
+ // 5. Compute slug + target path.
366
+ const sourceFileName = path.basename(absSource, '.md');
367
+ const slug = slugify(sourceFileName);
368
+ fs.mkdirSync(path.join(globalState, 'answers'), { recursive: true });
369
+ let targetName = `${slug}.md`;
370
+ let targetPath = path.join(globalState, 'answers', targetName);
371
+ let counter = 1;
372
+ while (fs.existsSync(targetPath)) {
373
+ targetName = `${slug}-${counter}.md`;
374
+ targetPath = path.join(globalState, 'answers', targetName);
375
+ counter += 1;
376
+ }
377
+
378
+ // 6. Write target with frontmatter.
379
+ const iso = now.toISOString();
380
+ const relSource = path.relative(projectRoot, absSource).replace(/\\/g, '/');
381
+ const promotedFrom = `${project}/${relSource}`;
382
+ const fm = [
383
+ '---',
384
+ 'kushi_state_page: true',
385
+ 'scope: global',
386
+ `promoted_from: "${promotedFrom}"`,
387
+ `promoted_at: "${iso}"`,
388
+ `redactions: [${redactionLabels.map((r) => JSON.stringify(r)).join(', ')}]`,
389
+ '---',
390
+ '',
391
+ ].join('\n');
392
+
393
+ let finalBody = fm + fmStripped;
394
+ if (redactionLabels.length > 0) {
395
+ const callout = [
396
+ '',
397
+ '> [!warning] potential-customer-leak',
398
+ `> This page was promoted with ${redactionLabels.length} redaction(s): ${redactionLabels.map((r) => `\`${r}\``).join(', ')}.`,
399
+ '> Review the [REDACTED] sites and rewrite surrounding context before sharing.',
400
+ '',
401
+ ].join('\n');
402
+ finalBody = finalBody.trimEnd() + '\n' + callout;
403
+ }
404
+ fs.writeFileSync(targetPath, finalBody);
405
+
406
+ // 7. Back-link in source.
407
+ const backLink = [
408
+ '',
409
+ '> [!info] Promoted to global wiki',
410
+ `> answers/${targetName} @ ${iso} · redactions: ${redactionLabels.length}`,
411
+ '',
412
+ ].join('\n');
413
+ // Avoid duplicate back-links if rerun: only append if not already present.
414
+ const sourceUpdated = sourceBody.includes(`answers/${targetName}`)
415
+ ? sourceBody
416
+ : sourceBody.trimEnd() + '\n' + backLink;
417
+ fs.writeFileSync(absSource, sourceUpdated);
418
+
419
+ // 8. Dual log.
420
+ appendLog(
421
+ findOrCreateProjectStateDir(absSource),
422
+ 'promote',
423
+ `Promoted ${slug} to global (redactions: ${redactionLabels.length})`,
424
+ now,
425
+ );
426
+ fs.mkdirSync(globalState, { recursive: true });
427
+ appendLog(globalState, 'promote-in', `Imported ${slug} from ${project} (redactions: ${redactionLabels.length})`, now);
428
+
429
+ return {
430
+ ok: true,
431
+ refused: false,
432
+ detections,
433
+ redactions: redactionLabels,
434
+ target: targetPath,
435
+ source: absSource,
436
+ slug,
437
+ promoted_at: iso,
438
+ };
439
+ }
440
+
441
+ // ───────────────────────────────────────────────────────────────────────────
442
+ // helpers
443
+
444
+ function appendLog(stateRoot, op, title, now) {
445
+ fs.mkdirSync(stateRoot, { recursive: true });
446
+ const logFile = path.join(stateRoot, 'log.md');
447
+ const stamp = now.toISOString().slice(0, 16).replace('T', ' ');
448
+ const entry = `\n## [${stamp}] ${op} | ${title}\n`;
449
+ if (!fs.existsSync(logFile)) {
450
+ fs.writeFileSync(
451
+ logFile,
452
+ `---\nkushi_state_page: true\n${stateRoot.includes('.kushi-global') ? 'scope: global\n' : ''}---\n\n# log\n${entry}`,
453
+ );
454
+ } else {
455
+ const existing = fs.readFileSync(logFile, 'utf8');
456
+ // Insert after first heading (newest-first).
457
+ const headingMatch = existing.match(/^#\s.+$/m);
458
+ if (headingMatch) {
459
+ const idx = existing.indexOf(headingMatch[0]) + headingMatch[0].length;
460
+ fs.writeFileSync(logFile, existing.slice(0, idx) + entry + existing.slice(idx));
461
+ } else {
462
+ fs.writeFileSync(logFile, existing + entry);
463
+ }
464
+ }
465
+ }
466
+
467
+ function slugify(s) {
468
+ return s
469
+ .toLowerCase()
470
+ .replace(/[^a-z0-9]+/g, '-')
471
+ .replace(/^-+|-+$/g, '')
472
+ .slice(0, 60) || 'page';
473
+ }
474
+
475
+ function guessAliasFromPath(absSource, projectRoot) {
476
+ const rel = path.relative(projectRoot, absSource).split(/[\\/]/);
477
+ const evidIdx = rel.indexOf('Evidence');
478
+ if (evidIdx !== -1 && rel.length > evidIdx + 1) return rel[evidIdx + 1];
479
+ return null;
480
+ }
481
+
482
+ function findOrCreateProjectStateDir(absSource) {
483
+ // Walk up looking for a directory whose basename is "State".
484
+ let dir = path.dirname(absSource);
485
+ while (dir && path.dirname(dir) !== dir) {
486
+ if (path.basename(dir) === 'State') return dir;
487
+ dir = path.dirname(dir);
488
+ }
489
+ // Fallback: same directory as the source.
490
+ return path.dirname(absSource);
491
+ }
492
+
493
+ function walk(root, visit) {
494
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
495
+ const full = path.join(root, entry.name);
496
+ const rel = path.relative(root, full);
497
+ if (entry.isDirectory()) {
498
+ walk(full, (f, r) => visit(f, path.join(rel, r)));
499
+ } else {
500
+ visit(full, rel);
501
+ }
502
+ }
503
+ }
@@ -0,0 +1,135 @@
1
+ // kushi v5.3.0 — global wiki core tests.
2
+ // MUST use $env:KUSHI_GLOBAL_ROOT pointing under .testtmp/ — never the real ~/.kushi-global/.
3
+
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import os from 'node:os';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ import {
12
+ resolveGlobalRoot,
13
+ globalInit,
14
+ globalStatus,
15
+ globalAsk,
16
+ globalLint,
17
+ } from './global-wiki.mjs';
18
+
19
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const TESTTMP = path.join(repoRoot, '.testtmp');
21
+
22
+ function freshRoot(label) {
23
+ const root = path.join(TESTTMP, `global-wiki-${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
24
+ fs.mkdirSync(root, { recursive: true });
25
+ return root;
26
+ }
27
+
28
+ function rmrf(p) {
29
+ if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
30
+ }
31
+
32
+ test('global-wiki: KUSHI_GLOBAL_ROOT env override is honored', () => {
33
+ const override = path.join(TESTTMP, 'global-wiki-env-override');
34
+ const result = resolveGlobalRoot({ KUSHI_GLOBAL_ROOT: override });
35
+ assert.equal(path.resolve(result), path.resolve(override), 'env override must win over ~/.kushi-global');
36
+
37
+ // Sanity: real homedir path must NOT match when override is set.
38
+ const real = resolveGlobalRoot({});
39
+ assert.notEqual(path.resolve(result), path.resolve(real), 'override must differ from real default');
40
+ // And default must point under the home directory.
41
+ const home = process.env.USERPROFILE || process.env.HOME || os.homedir();
42
+ assert.ok(real.startsWith(home), 'default must live under user home');
43
+ });
44
+
45
+ test('global-wiki: init scaffolds the Karpathy shape with scope: global frontmatter', () => {
46
+ const root = freshRoot('init');
47
+ try {
48
+ const result = globalInit({ root });
49
+ const state = path.join(root, 'State');
50
+
51
+ // Files
52
+ for (const f of ['index.md', 'log.md', 'conventions.md', '_review-queue.md']) {
53
+ assert.ok(fs.existsSync(path.join(state, f)), `must create State/${f}`);
54
+ const body = fs.readFileSync(path.join(state, f), 'utf8');
55
+ assert.match(body, /^---\nkushi_state_page: true\nscope: global\n---/, `${f} must have scope: global frontmatter`);
56
+ }
57
+ // Dirs
58
+ for (const d of ['answers', 'reports']) {
59
+ assert.ok(fs.existsSync(path.join(state, d)) && fs.statSync(path.join(state, d)).isDirectory(), `must create State/${d}/`);
60
+ }
61
+ // Returned shape
62
+ assert.equal(result.root, root);
63
+ assert.ok(result.created.length > 0, 'first run must report created entries');
64
+
65
+ // Idempotent: second run creates nothing new.
66
+ const second = globalInit({ root });
67
+ assert.equal(second.created.length, 0, 'second init must be no-op');
68
+ assert.ok(second.skipped.length > 0, 'second init must report skipped entries');
69
+
70
+ // log.md must have a global-init entry
71
+ const log = fs.readFileSync(path.join(state, 'log.md'), 'utf8');
72
+ assert.match(log, /global-init/, 'log must record global-init op');
73
+ } finally {
74
+ rmrf(root);
75
+ }
76
+ });
77
+
78
+ test('global-wiki: status reports counts after init', () => {
79
+ const root = freshRoot('status');
80
+ try {
81
+ // Pre-init: not initialized.
82
+ const pre = globalStatus({ root });
83
+ assert.equal(pre.initialized, false, 'pre-init status must report initialized=false');
84
+
85
+ globalInit({ root });
86
+ const post = globalStatus({ root });
87
+ assert.equal(post.initialized, true, 'post-init must report initialized=true');
88
+ assert.ok(typeof post.counts === 'object', 'must return counts');
89
+ assert.ok(post.counts.pages >= 4, `must count ≥4 pages (index, log, conventions, _review-queue); got ${post.counts.pages}`);
90
+ } finally {
91
+ rmrf(root);
92
+ }
93
+ });
94
+
95
+ test('global-wiki: ask searches answers/ and returns hits with [global] provenance', () => {
96
+ const root = freshRoot('ask');
97
+ try {
98
+ globalInit({ root });
99
+ const answers = path.join(root, 'State', 'answers');
100
+ fs.writeFileSync(
101
+ path.join(answers, 'confidence-ladder.md'),
102
+ '---\nkushi_state_page: true\nscope: global\n---\n\n# Confidence ladder\n\nUse strong / weak / hypothesis tiers when reporting findings.\n',
103
+ );
104
+
105
+ const hit = globalAsk({ root, question: 'what is the confidence ladder?' });
106
+ assert.ok(hit.hits.length >= 1, 'must find the seeded answer page');
107
+ assert.equal(hit.hits[0].provenance, '[global]', 'provenance must be [global]');
108
+ assert.match(hit.hits[0].name, /confidence-ladder/, 'top hit must be the seeded page');
109
+
110
+ const miss = globalAsk({ root, question: 'completely unrelated quantum chromodynamics' });
111
+ assert.equal(miss.hits.length, 0, 'unrelated question must return zero hits');
112
+ } finally {
113
+ rmrf(root);
114
+ }
115
+ });
116
+
117
+ test('global-wiki: lint flags potential-customer-leak callouts', () => {
118
+ const root = freshRoot('lint');
119
+ try {
120
+ globalInit({ root });
121
+ const answers = path.join(root, 'State', 'answers');
122
+ fs.writeFileSync(
123
+ path.join(answers, 'leaky.md'),
124
+ '---\nkushi_state_page: true\nscope: global\n---\n\n# Leaky\n\n> [!warning] potential-customer-leak\n> Needs follow-up.\n',
125
+ );
126
+
127
+ const r = globalLint({ root });
128
+ assert.equal(r.initialized, true);
129
+ const leaks = r.findings.filter((f) => f.class === 'potential-customer-leak');
130
+ assert.ok(leaks.length >= 1, 'lint must surface potential-customer-leak callout');
131
+ assert.match(leaks[0].file, /leaky\.md$/, 'finding must point at the leaky file');
132
+ } finally {
133
+ rmrf(root);
134
+ }
135
+ });