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.
- package/README.md +24 -0
- package/bin/cli.mjs +50 -1
- package/package.json +2 -2
- package/plugin/agents/kushi.agent.md +2 -0
- package/plugin/instructions/global-wiki.instructions.md +79 -0
- package/plugin/instructions/multi-wiki-routing.instructions.md +117 -0
- package/plugin/skills/ask-project/SKILL.md +14 -0
- package/plugin/skills/global-wiki/.created-by-skill-creator +1 -0
- package/plugin/skills/global-wiki/SKILL.md +87 -0
- package/plugin/skills/global-wiki/evals/evals.json +43 -0
- package/plugin/skills/promote/.created-by-skill-creator +1 -0
- package/plugin/skills/promote/SKILL.md +125 -0
- package/plugin/skills/promote/evals/evals.json +35 -0
- package/plugin/skills/self-check/SKILL.md +4 -1
- package/plugin/skills/self-check/run.ps1 +63 -0
- package/plugin/skills/teach/SKILL.md +2 -0
- package/plugin/skills/teach/evals/evals.json +22 -0
- package/src/global-wiki-cli.mjs +158 -0
- package/src/global-wiki.mjs +503 -0
- package/src/global-wiki.test.mjs +135 -0
- package/src/promote.test.mjs +161 -0
|
@@ -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
|
+
});
|