kushi-agents 5.2.0 → 5.4.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,161 @@
1
+ // kushi v5.3.0 — promote operation tests.
2
+ // MUST use $env:KUSHI_GLOBAL_ROOT 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 { fileURLToPath } from 'node:url';
9
+
10
+ import { detectIdentifiers, promote, globalInit } from './global-wiki.mjs';
11
+
12
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
13
+ const TESTTMP = path.join(repoRoot, '.testtmp');
14
+
15
+ function makeFixtureProject(label, body) {
16
+ const root = path.join(TESTTMP, `promote-${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
17
+ const evidence = path.join(root, 'projectroot', 'Evidence', 'acme', 'State', 'answers');
18
+ fs.mkdirSync(evidence, { recursive: true });
19
+ const page = path.join(evidence, '2026-05-27_confidence-ladder.md');
20
+ fs.writeFileSync(page, body);
21
+ return {
22
+ tmpRoot: root,
23
+ projectRoot: path.join(root, 'projectroot'),
24
+ globalRoot: path.join(root, 'global'),
25
+ sourcePath: page,
26
+ relSource: path.relative(path.join(root, 'projectroot'), page),
27
+ };
28
+ }
29
+
30
+ function rmrf(p) {
31
+ if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
32
+ }
33
+
34
+ test('promote: detectIdentifiers flags project name, alias, and external email', () => {
35
+ const body = [
36
+ '# notes',
37
+ 'Acme is shipping in Q3 with help from lead@acme.com.',
38
+ 'acme stakeholder pinged us; also internal kushikd@microsoft.com (ok).',
39
+ 'Re-pinged Acme on Friday.',
40
+ ].join('\n');
41
+
42
+ const hits = detectIdentifiers(body, { project: 'acme', alias: 'acme' });
43
+ const literal = hits.find((h) => h.kind === 'project-or-alias');
44
+ assert.ok(literal, 'must flag project literal');
45
+ assert.ok(literal.count >= 3, `expected ≥3 acme hits, got ${literal.count}`);
46
+
47
+ const emails = hits.filter((h) => h.kind === 'external-email');
48
+ assert.equal(emails.length, 1, 'must flag exactly the external email');
49
+ assert.equal(emails[0].pattern, 'lead@acme.com');
50
+
51
+ const internalLeak = hits.find((h) => h.pattern && h.pattern.endsWith('@microsoft.com'));
52
+ assert.equal(internalLeak, undefined, '@microsoft.com must NOT be flagged');
53
+ });
54
+
55
+ test('promote: refuses without --force when identifiers are detected', () => {
56
+ const fx = makeFixtureProject(
57
+ 'refuse',
58
+ '---\nkushi_state_page: true\n---\n\n# Acme confidence ladder\n\nAcme uses strong/weak/hypothesis tiers. Contact lead@acme.com.\n',
59
+ );
60
+ try {
61
+ globalInit({ root: fx.globalRoot });
62
+ const r = promote({
63
+ project: 'acme',
64
+ sourcePath: fx.sourcePath,
65
+ projectRoot: fx.projectRoot,
66
+ globalRoot: fx.globalRoot,
67
+ });
68
+ assert.equal(r.ok, false, 'must refuse');
69
+ assert.equal(r.refused, true);
70
+ assert.ok(r.detections.length >= 1, 'must surface detections');
71
+ // No target file written under answers/.
72
+ const answers = path.join(fx.globalRoot, 'State', 'answers');
73
+ const files = fs.existsSync(answers) ? fs.readdirSync(answers) : [];
74
+ assert.equal(files.length, 0, 'refusal must leave global answers/ empty');
75
+ } finally {
76
+ rmrf(fx.tmpRoot);
77
+ }
78
+ });
79
+
80
+ test('promote: --force writes redacted target, privacy callout, back-link, and dual log', () => {
81
+ const fx = makeFixtureProject(
82
+ 'force',
83
+ '---\nkushi_state_page: true\n---\n\n# Acme confidence ladder\n\nAcme uses strong/weak/hypothesis tiers. Contact lead@acme.com.\n',
84
+ );
85
+ try {
86
+ globalInit({ root: fx.globalRoot });
87
+ const r = promote({
88
+ project: 'acme',
89
+ sourcePath: fx.sourcePath,
90
+ projectRoot: fx.projectRoot,
91
+ globalRoot: fx.globalRoot,
92
+ force: true,
93
+ now: new Date('2026-05-27T12:00:00Z'),
94
+ });
95
+
96
+ assert.equal(r.ok, true, 'forced promote must succeed');
97
+ assert.ok(r.target && fs.existsSync(r.target), 'target file must exist');
98
+ assert.ok(r.redactions.length >= 2, `expected ≥2 redactions, got ${r.redactions.length}`);
99
+
100
+ const targetBody = fs.readFileSync(r.target, 'utf8');
101
+ assert.match(targetBody, /^---\n/, 'target must start with frontmatter');
102
+ assert.match(targetBody, /scope: global/, 'target frontmatter must mark scope: global');
103
+ assert.match(targetBody, /promoted_from: "acme\//, 'target must record promoted_from');
104
+ assert.match(targetBody, /promoted_at:/, 'target must record promoted_at');
105
+ assert.match(targetBody, /\[REDACTED\]/, 'target body must contain [REDACTED]');
106
+ // Strip frontmatter AND the privacy callout block (which legitimately echoes the redacted labels) before scanning prose.
107
+ const prose = targetBody
108
+ .replace(/^---\n[\s\S]*?\n---\n/, '')
109
+ .replace(/> \[!warning\] potential-customer-leak[\s\S]*$/m, '');
110
+ assert.doesNotMatch(prose, /lead@acme\.com/, 'external email must be redacted in prose');
111
+ assert.doesNotMatch(prose, /\bAcme\b/i, 'project literal must be redacted in prose');
112
+ assert.match(targetBody, /> \[!warning\] potential-customer-leak/, 'target must carry privacy callout');
113
+
114
+ // Source must gain back-link callout.
115
+ const sourceBody = fs.readFileSync(fx.sourcePath, 'utf8');
116
+ assert.match(sourceBody, /> \[!info\] Promoted to global wiki/, 'source must carry back-link callout');
117
+ assert.match(sourceBody, /answers\/[a-z0-9-]+\.md/, 'back-link must reference the global slug');
118
+
119
+ // Dual log: both project State/log.md and global State/log.md updated.
120
+ // Project log lives at <projectRoot>/Evidence/acme/State/log.md
121
+ const projLog = path.join(fx.projectRoot, 'Evidence', 'acme', 'State', 'log.md');
122
+ assert.ok(fs.existsSync(projLog), 'project log must exist after promote');
123
+ assert.match(fs.readFileSync(projLog, 'utf8'), /promote\b/, 'project log must record promote op');
124
+
125
+ const globalLog = path.join(fx.globalRoot, 'State', 'log.md');
126
+ assert.match(fs.readFileSync(globalLog, 'utf8'), /promote-in/, 'global log must record promote-in op');
127
+ } finally {
128
+ rmrf(fx.tmpRoot);
129
+ }
130
+ });
131
+
132
+ test('promote: clean page (no identifiers) promotes without --force and emits no privacy callout', () => {
133
+ const fx = makeFixtureProject(
134
+ 'clean',
135
+ '---\nkushi_state_page: true\n---\n\n# Confidence ladder pattern\n\nUse strong / weak / hypothesis tiers when reporting findings. Contact teammate@microsoft.com.\n',
136
+ );
137
+ try {
138
+ globalInit({ root: fx.globalRoot });
139
+ const r = promote({
140
+ project: 'acme',
141
+ sourcePath: fx.sourcePath,
142
+ projectRoot: fx.projectRoot,
143
+ globalRoot: fx.globalRoot,
144
+ // NOTE: source path lives under Evidence/acme/, so alias guess = 'acme'.
145
+ // Body never mentions 'acme' or 'Acme' literally, and @microsoft.com is allowed.
146
+ });
147
+ assert.equal(r.ok, true, 'clean page must promote without --force');
148
+ assert.equal(r.refused, false);
149
+ assert.equal(r.redactions.length, 0, 'clean page must produce zero redactions');
150
+
151
+ const targetBody = fs.readFileSync(r.target, 'utf8');
152
+ assert.doesNotMatch(
153
+ targetBody,
154
+ /> \[!warning\] potential-customer-leak/,
155
+ 'clean promote must NOT emit a privacy callout',
156
+ );
157
+ assert.match(targetBody, /redactions: \[\]/, 'frontmatter must record empty redactions array');
158
+ } finally {
159
+ rmrf(fx.tmpRoot);
160
+ }
161
+ });
@@ -0,0 +1,133 @@
1
+ // kushi v5.4.0 — interactive setup wizard.
2
+ // Non-interactive overrides (used by tests + CI):
3
+ // KUSHI_WIZARD_ROOT — engagement root path
4
+ // KUSHI_WIZARD_HOSTS — comma list: clawpilot,vscode (or "both")
5
+ // KUSHI_WIZARD_GLOBAL — "y" or "n"
6
+ // KUSHI_INSTALL_ROOT — override ~/.copilot/ for install target (test isolation)
7
+ // KUSHI_SKIP_INSTALL — "1" to skip the actual install step (tests)
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import os from 'node:os';
12
+ import readline from 'node:readline';
13
+
14
+ function detectEngagementRoot() {
15
+ const home = process.env.USERPROFILE || process.env.HOME || os.homedir();
16
+ // Look for the canonical "ISE\Engagement Assets" path under any OneDrive*.
17
+ try {
18
+ const entries = fs.readdirSync(home, { withFileTypes: true });
19
+ for (const e of entries) {
20
+ if (!e.isDirectory()) continue;
21
+ if (!/^OneDrive/i.test(e.name)) continue;
22
+ const candidate = path.join(home, e.name, 'ISE', 'Engagement Assets');
23
+ if (fs.existsSync(candidate)) return candidate;
24
+ }
25
+ } catch {}
26
+ return path.join(home, 'Engagement Assets');
27
+ }
28
+
29
+ function detectHosts() {
30
+ const home = process.env.USERPROFILE || process.env.HOME || os.homedir();
31
+ const installRoot = process.env.KUSHI_INSTALL_ROOT || home;
32
+ const claw = fs.existsSync(path.join(installRoot, '.copilot'));
33
+ const vsc = fs.existsSync(path.join(installRoot, '.vscode'));
34
+ if (claw && vsc) return 'both';
35
+ if (vsc) return 'vscode';
36
+ return 'clawpilot';
37
+ }
38
+
39
+ function makePrompter() {
40
+ const fromEnv = (key) => {
41
+ const v = process.env[key];
42
+ return (typeof v === 'string' && v.length > 0) ? v : null;
43
+ };
44
+ if (fromEnv('KUSHI_WIZARD_ROOT') || fromEnv('KUSHI_WIZARD_HOSTS') || fromEnv('KUSHI_WIZARD_GLOBAL')) {
45
+ return { interactive: false, ask: async (_q, _def, envKey) => fromEnv(envKey) };
46
+ }
47
+ if (!process.stdin.isTTY) {
48
+ return { interactive: false, ask: async (_q, def) => def };
49
+ }
50
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
51
+ return {
52
+ interactive: true,
53
+ ask: (q, def) => new Promise((res) => {
54
+ rl.question(`${q} [${def}]: `, (a) => res(a.trim() || def));
55
+ }),
56
+ close: () => rl.close(),
57
+ };
58
+ }
59
+
60
+ export async function runSetupWizard({ args = [] } = {}) {
61
+ const detectedRoot = detectEngagementRoot();
62
+ const detectedHosts = detectHosts();
63
+ const p = makePrompter();
64
+
65
+ console.log('');
66
+ console.log(' kushi setup wizard — 3 questions, then I install.');
67
+ console.log('');
68
+
69
+ const root = (await p.ask(' Where is your engagement root?', detectedRoot, 'KUSHI_WIZARD_ROOT')) || detectedRoot;
70
+ const hosts = (await p.ask(' Install for clawpilot / vscode / both?', detectedHosts, 'KUSHI_WIZARD_HOSTS')) || detectedHosts;
71
+ const wantGlobal = (await p.ask(' Enable global wiki at ~/.kushi-global/?', 'y', 'KUSHI_WIZARD_GLOBAL')) || 'y';
72
+
73
+ if (p.close) p.close();
74
+
75
+ const answers = {
76
+ engagementRoot: root,
77
+ hosts: hosts.toLowerCase(),
78
+ globalWiki: /^y/i.test(wantGlobal),
79
+ };
80
+
81
+ console.log('');
82
+ console.log(' Answers:');
83
+ console.log(` engagement root: ${answers.engagementRoot}`);
84
+ console.log(` hosts: ${answers.hosts}`);
85
+ console.log(` global wiki: ${answers.globalWiki ? 'yes' : 'no'}`);
86
+ console.log('');
87
+
88
+ if (process.env.KUSHI_SKIP_INSTALL === '1') {
89
+ console.log(' (KUSHI_SKIP_INSTALL=1 — skipping actual install; wizard complete.)');
90
+ console.log('');
91
+ console.log(' Next steps:');
92
+ console.log(' kushi doctor');
93
+ console.log(' kushi bootstrap <project>');
94
+ console.log('');
95
+ return answers;
96
+ }
97
+
98
+ // Dispatch to multi-host install.
99
+ const { runMultiHost } = await import('./multi-host.mjs');
100
+ const hostList = [];
101
+ if (answers.hosts === 'both' || answers.hosts === 'all') {
102
+ hostList.push('clawpilot', 'vscode');
103
+ } else if (answers.hosts === 'clawpilot' || answers.hosts === 'vscode') {
104
+ hostList.push(answers.hosts);
105
+ } else {
106
+ hostList.push('clawpilot');
107
+ }
108
+
109
+ try {
110
+ await runMultiHost({ hosts: hostList, all: false, uninstall: false, profile: undefined });
111
+ } catch (err) {
112
+ console.error(` install failed: ${err.message}`);
113
+ throw err;
114
+ }
115
+
116
+ if (answers.globalWiki) {
117
+ try {
118
+ const { runGlobalInit } = await import('./global-wiki-cli.mjs');
119
+ await runGlobalInit();
120
+ } catch (err) {
121
+ console.error(` global init skipped: ${err.message}`);
122
+ }
123
+ }
124
+
125
+ console.log('');
126
+ console.log(' ✅ wizard complete — next steps:');
127
+ console.log(' kushi doctor');
128
+ console.log(' kushi bootstrap <project>');
129
+ console.log(' kushi ask <project> "..."');
130
+ console.log('');
131
+
132
+ return answers;
133
+ }
@@ -0,0 +1,74 @@
1
+ // kushi v5.4.0 — setup-wizard tests.
2
+ // All cases run sequentially inside a single test() to avoid process.env races
3
+ // between async tests (which would otherwise risk hitting the real installer).
4
+
5
+ import test from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ import { runSetupWizard } from './setup-wizard.mjs';
12
+
13
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
14
+ const TESTTMP = path.join(repoRoot, '.testtmp');
15
+ if (!fs.existsSync(TESTTMP)) fs.mkdirSync(TESTTMP, { recursive: true });
16
+
17
+ async function withEnv(envOverrides, fn) {
18
+ const saved = {};
19
+ for (const k of Object.keys(envOverrides)) {
20
+ saved[k] = process.env[k];
21
+ if (envOverrides[k] === null) delete process.env[k];
22
+ else process.env[k] = envOverrides[k];
23
+ }
24
+ try { return await fn(); }
25
+ finally {
26
+ for (const k of Object.keys(saved)) {
27
+ if (saved[k] === undefined) delete process.env[k];
28
+ else process.env[k] = saved[k];
29
+ }
30
+ }
31
+ }
32
+
33
+ test('setup-wizard: env-driven cases (sequential to avoid env race)', async (t) => {
34
+ await t.test('case 1: env overrides resolve all three prompts', async () => {
35
+ const root = path.join(TESTTMP, `wizard-root-${Date.now()}-1`);
36
+ fs.mkdirSync(root, { recursive: true });
37
+ const result = await withEnv({
38
+ KUSHI_WIZARD_ROOT: root,
39
+ KUSHI_WIZARD_HOSTS: 'clawpilot',
40
+ KUSHI_WIZARD_GLOBAL: 'n',
41
+ KUSHI_SKIP_INSTALL: '1',
42
+ }, () => runSetupWizard());
43
+ assert.equal(result.engagementRoot, root, 'root from env');
44
+ assert.equal(result.hosts, 'clawpilot', 'host from env');
45
+ assert.equal(result.globalWiki, false, 'global wiki off');
46
+ });
47
+
48
+ await t.test('case 2: "both" hosts retained in answers', async () => {
49
+ const root = path.join(TESTTMP, `wizard-root-${Date.now()}-2`);
50
+ fs.mkdirSync(root, { recursive: true });
51
+ const result = await withEnv({
52
+ KUSHI_WIZARD_ROOT: root,
53
+ KUSHI_WIZARD_HOSTS: 'both',
54
+ KUSHI_WIZARD_GLOBAL: 'n',
55
+ KUSHI_SKIP_INSTALL: '1',
56
+ }, () => runSetupWizard());
57
+ assert.equal(result.hosts, 'both');
58
+ });
59
+
60
+ await t.test('case 3: isolated install root + global wiki yes', async () => {
61
+ const installRoot = path.join(TESTTMP, `wizard-install-root-${Date.now()}-3`);
62
+ fs.mkdirSync(path.join(installRoot, '.copilot'), { recursive: true });
63
+ const result = await withEnv({
64
+ KUSHI_WIZARD_ROOT: installRoot,
65
+ KUSHI_WIZARD_HOSTS: 'clawpilot',
66
+ KUSHI_WIZARD_GLOBAL: 'y',
67
+ KUSHI_SKIP_INSTALL: '1',
68
+ KUSHI_INSTALL_ROOT: installRoot,
69
+ }, () => runSetupWizard());
70
+ assert.equal(result.hosts, 'clawpilot');
71
+ assert.equal(result.globalWiki, true);
72
+ assert.equal(result.engagementRoot, installRoot);
73
+ });
74
+ });