kushi-agents 4.2.3 → 4.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.
Files changed (41) hide show
  1. package/.github/copilot-instructions.kushi.md +4 -2
  2. package/package.json +2 -4
  3. package/plugin/agents/kushi.agent.md +7 -4
  4. package/plugin/instructions/azure-auth-patterns.instructions.md +2 -2
  5. package/plugin/instructions/bootstrap-status-format.instructions.md +24 -0
  6. package/plugin/instructions/engagement-root-resolution.instructions.md +3 -3
  7. package/plugin/instructions/identity-resolution.instructions.md +4 -4
  8. package/plugin/instructions/multi-user-shared-files.instructions.md +87 -0
  9. package/plugin/instructions/run-reports.instructions.md +1 -1
  10. package/plugin/instructions/side-by-side-config.instructions.md +23 -17
  11. package/plugin/instructions/tracking.instructions.md +1 -1
  12. package/plugin/instructions/workiq-only.instructions.md +2 -2
  13. package/plugin/lib/Get-KushiConfig.ps1 +109 -0
  14. package/plugin/prompts/aggregate.prompt.md +15 -4
  15. package/plugin/prompts/apply-ado.prompt.md +14 -5
  16. package/plugin/prompts/ask.prompt.md +12 -2
  17. package/plugin/prompts/bootstrap.prompt.md +27 -3
  18. package/plugin/prompts/consolidate.prompt.md +12 -2
  19. package/plugin/prompts/fde-intake.prompt.md +14 -5
  20. package/plugin/prompts/fde-report.prompt.md +15 -5
  21. package/plugin/prompts/fde-triage.prompt.md +14 -5
  22. package/plugin/prompts/propose-ado.prompt.md +14 -5
  23. package/plugin/prompts/refresh.prompt.md +12 -2
  24. package/plugin/prompts/state.prompt.md +11 -2
  25. package/plugin/prompts/status.prompt.md +11 -2
  26. package/plugin/reference-packs/README.md +1 -1
  27. package/plugin/skills/ask-project/SKILL.md +1 -1
  28. package/plugin/skills/bootstrap-project/SKILL.md +8 -6
  29. package/plugin/skills/intro/SKILL.md +2 -2
  30. package/plugin/skills/propose-ado-update/SKILL.md +1 -1
  31. package/plugin/templates/init/azuredevops.template.json +159 -0
  32. package/plugin/templates/init/dynamics365.template.json +412 -0
  33. package/{.github/config/m365-mutable.json.example → plugin/templates/init/m365-mutable.example.json} +1 -1
  34. package/plugin/templates/init/rsi-program-catalog.template.json +107 -0
  35. package/src/config-loader.mjs +69 -0
  36. package/src/constants.mjs +54 -18
  37. package/src/copy-assets.mjs +0 -76
  38. package/src/main.mjs +30 -26
  39. package/src/seed-config.mjs +88 -23
  40. package/src/seed-config.test.mjs +150 -0
  41. /package/{.github/config/m365-auth.json.example → plugin/templates/init/m365-auth.example.json} +0 -0
package/src/constants.mjs CHANGED
@@ -20,21 +20,69 @@ export const CLAWPILOT_SKILL_DEST = 'SKILL.md';
20
20
  export const PLUGIN_SOURCE_DIR = 'plugin';
21
21
 
22
22
  /** Asset subdirectories under plugin/ that get copied. */
23
- export const ASSET_DIRS = ['agents', 'instructions', 'prompts', 'skills', 'templates', 'reference-packs'];
23
+ export const ASSET_DIRS = ['agents', 'instructions', 'prompts', 'skills', 'templates', 'reference-packs', 'lib'];
24
24
 
25
25
  /**
26
26
  * User-editable config directory under the install destination.
27
- * Seeded on first install only; never overwritten on reinstall / --force / upgrade.
27
+ *
28
+ * v4.4.0 introduces a two-tier split inside config/:
29
+ * - shared/ → team-owned, safe to commit. Doctrine + team defaults.
30
+ * - user/ → per-contributor identity & local paths. Gitignored.
31
+ *
32
+ * Skills read configs via the Get-KushiConfig helper which checks
33
+ * user/ first, then shared/.
28
34
  */
29
35
  export const CONFIG_DIR = 'config';
36
+ export const CONFIG_SHARED_SUBDIR = 'shared';
37
+ export const CONFIG_USER_SUBDIR = 'user';
30
38
 
31
39
  /**
32
- * Files seeded into <dest>/config/ on first install only.
33
- * Each entry: { template: <path under plugin/templates/init/>, target: <name in config/> }
40
+ * Per-contributor config files (USER tier).
41
+ * Seeded only if absent; preserved on reinstall/upgrade.
42
+ * Written to <dest>/config/user/. Gitignored by installer.
34
43
  */
35
- export const CONFIG_SEED_FILES = [
44
+ export const CONFIG_USER_SEED_FILES = [
36
45
  { template: 'init/project-evidence.template.yml', target: 'project-evidence.yml' },
37
- { template: 'init/integrations.template.yml', target: 'integrations.yml' },
46
+ { template: 'init/m365-auth.template.json', target: 'm365-auth.json' },
47
+ { template: 'init/m365-mutable.template.json', target: 'm365-mutable.json' },
48
+ ];
49
+
50
+ /**
51
+ * Team-shared config files (SHARED tier, seed-once).
52
+ * Seeded only if absent; preserved on reinstall.
53
+ * Written to <dest>/config/shared/. Safe to commit.
54
+ */
55
+ export const CONFIG_SHARED_SEED_FILES = [
56
+ { template: 'init/integrations.template.yml', target: 'integrations.yml' },
57
+ ];
58
+
59
+ /**
60
+ * Doctrine config files (SHARED tier, overwrite-always).
61
+ * Versioned by Kushi; overwritten on every install. User hand-edits are lost.
62
+ * Written to <dest>/config/shared/.
63
+ */
64
+ export const CONFIG_DOCTRINE_FILES = [
65
+ { template: 'init/azuredevops.template.json', target: 'azuredevops.json' },
66
+ { template: 'init/dynamics365.template.json', target: 'dynamics365.json' },
67
+ { template: 'init/rsi-program-catalog.template.json', target: 'rsi-program-catalog.json' },
68
+ ];
69
+
70
+ /**
71
+ * Example/reference scaffolds (SHARED tier, overwrite-always).
72
+ * Written to <dest>/config/shared/.
73
+ */
74
+ export const CONFIG_EXAMPLE_FILES = [
75
+ { template: 'init/m365-auth.example.json', target: 'm365-auth.json.example' },
76
+ { template: 'init/m365-mutable.example.json', target: 'm365-mutable.json.example' },
77
+ ];
78
+
79
+ /**
80
+ * Lines written to <dest>/.gitignore (created if missing, appended otherwise).
81
+ * Marks the per-user tier so contributors don't accidentally commit identity/paths.
82
+ */
83
+ export const CONFIG_GITIGNORE_LINES = [
84
+ '# Kushi per-user config — never commit (created by kushi-agents installer)',
85
+ 'config/user/',
38
86
  ];
39
87
 
40
88
  /** Files to exclude from consumer installs (relative to plugin/). */
@@ -43,18 +91,6 @@ export const EXCLUDED_FILES = [];
43
91
  /** Directories to exclude from consumer installs (relative to plugin/). */
44
92
  export const EXCLUDED_DIRS = [];
45
93
 
46
- /** Files copied to the TARGET project's .github/ directory. */
47
- export const PROJECT_GITHUB_FILES = [];
48
-
49
- /** Directories copied to the TARGET project's .github/ directory. */
50
- export const PROJECT_GITHUB_DIRS = ['config'];
51
-
52
- /** Project-level .github files that must never be copied into consumer projects. */
53
- export const PROJECT_GITHUB_EXCLUDED_FILES = [
54
- 'config/m365-auth.json',
55
- 'config/m365-mutable.json'
56
- ];
57
-
58
94
  /**
59
95
  * Files / directories that indicate the cwd is a sane install target.
60
96
  *
@@ -5,9 +5,6 @@ import {
5
5
  EXCLUDED_FILES,
6
6
  EXCLUDED_DIRS,
7
7
  PLUGIN_SOURCE_DIR,
8
- PROJECT_GITHUB_FILES,
9
- PROJECT_GITHUB_DIRS,
10
- PROJECT_GITHUB_EXCLUDED_FILES,
11
8
  } from './constants.mjs';
12
9
 
13
10
  /**
@@ -97,31 +94,6 @@ function copyDirFiltered(src, dest, assetDir, relPrefix = '', includeFilter) {
97
94
  * @param {string} projectDir
98
95
  * @param {string} [relPrefix='']
99
96
  */
100
- function copyProjectDirFiltered(src, dest, projectDir, relPrefix = '') {
101
- fs.mkdirSync(dest, { recursive: true });
102
-
103
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
104
- const srcPath = path.join(src, entry.name);
105
- const destPath = path.join(dest, entry.name);
106
- const relPath = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
107
- const excludeKey = `${projectDir}/${relPath}`;
108
-
109
- if (entry.isDirectory()) {
110
- copyProjectDirFiltered(srcPath, destPath, projectDir, relPrefix ? `${relPrefix}/${entry.name}` : entry.name);
111
- continue;
112
- }
113
-
114
- if (PROJECT_GITHUB_EXCLUDED_FILES.includes(excludeKey)) continue;
115
-
116
- fs.cpSync(srcPath, destPath, { force: true });
117
- }
118
- }
119
-
120
- /**
121
- * Count files recursively in a directory.
122
- * @param {string} dir
123
- * @returns {number}
124
- */
125
97
  function countFiles(dir) {
126
98
  let count = 0;
127
99
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -133,51 +105,3 @@ function countFiles(dir) {
133
105
  }
134
106
  return count;
135
107
  }
136
-
137
- /**
138
- * Copy project-level files (hooks, copilot-instructions) to the target
139
- * project's .github/ directory. Does not overwrite existing files.
140
- *
141
- * @param {string} sourcePkgDir – root of the npm package
142
- * @returns {{ copied: string[], skipped: string[] }}
143
- */
144
- export function copyProjectFiles(sourcePkgDir) {
145
- const projectGitHub = path.join(process.cwd(), '.github');
146
- const srcGitHub = path.join(sourcePkgDir, '.github');
147
-
148
- const copied = [];
149
- const skipped = [];
150
-
151
- for (const file of PROJECT_GITHUB_FILES) {
152
- const src = path.join(srcGitHub, file);
153
- const dest = path.join(projectGitHub, file);
154
-
155
- if (!fs.existsSync(src)) continue;
156
-
157
- if (fs.existsSync(dest)) {
158
- skipped.push(`.github/${file}`);
159
- continue;
160
- }
161
-
162
- fs.mkdirSync(path.dirname(dest), { recursive: true });
163
- fs.cpSync(src, dest);
164
- copied.push(`.github/${file}`);
165
- }
166
-
167
- for (const dir of PROJECT_GITHUB_DIRS) {
168
- const src = path.join(srcGitHub, dir);
169
- const dest = path.join(projectGitHub, dir);
170
-
171
- if (!fs.existsSync(src)) continue;
172
-
173
- if (fs.existsSync(dest)) {
174
- skipped.push(`.github/${dir}/`);
175
- continue;
176
- }
177
-
178
- copyProjectDirFiltered(src, dest, dir);
179
- copied.push(`.github/${dir}/`);
180
- }
181
-
182
- return { copied, skipped };
183
- }
package/src/main.mjs CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  PROJECT_MARKERS,
14
14
  } from './constants.mjs';
15
15
  import { promptForDestination } from './prompt.mjs';
16
- import { copyAssets, copyProjectFiles } from './copy-assets.mjs';
16
+ import { copyAssets } from './copy-assets.mjs';
17
17
  import { mergeSettings } from './settings.mjs';
18
18
  import { mergeCopilotInstructions } from './copilot-instructions.mjs';
19
19
  import { seedConfig } from './seed-config.mjs';
@@ -134,12 +134,8 @@ async function installVscode(options, resolved, version) {
134
134
  const manifestPath = writeInstalledManifest(fullDest, resolved, version);
135
135
  console.log(` - kushi-install.json (profile manifest)`);
136
136
 
137
- const { seeded, preserved } = seedConfig(PKG_ROOT, fullDest);
138
- if (seeded.length > 0 || preserved.length > 0) {
139
- console.log(`\n Config (.kushi/config/ — user-editable, preserved across upgrades):`);
140
- for (const f of seeded) console.log(` - ${f} -> seeded from template`);
141
- for (const f of preserved) console.log(` - ${f} -> preserved (already exists)`);
142
- }
137
+ const seedRes = seedConfig(PKG_ROOT, fullDest);
138
+ printSeedReport(seedRes, `${dest}/`);
143
139
 
144
140
  if (!options.noSettings) {
145
141
  const { created, keysAdded, keysUnchanged } = mergeSettings(
@@ -176,19 +172,6 @@ async function installVscode(options, resolved, version) {
176
172
  console.log('\n Skipped .github/copilot-instructions.md (--no-instructions)');
177
173
  }
178
174
 
179
- const { copied: projCopied, skipped: projSkipped } = copyProjectFiles(PKG_ROOT);
180
- if (projCopied.length > 0) {
181
- console.log('\n Copied project files to .github/');
182
- for (const f of projCopied) {
183
- console.log(` - ${f}`);
184
- }
185
- }
186
- if (projSkipped.length > 0) {
187
- for (const f of projSkipped) {
188
- console.log(` - ${f} -> unchanged (already exists)`);
189
- }
190
- }
191
-
192
175
  printPostInstall(resolved, '@Kushi');
193
176
  }
194
177
 
@@ -233,12 +216,8 @@ async function installClawpilot(options, resolved, version) {
233
216
  writeInstalledManifest(fullDest, resolved, version);
234
217
  console.log(` - kushi-install.json (profile manifest)`);
235
218
 
236
- const { seeded, preserved } = seedConfig(PKG_ROOT, fullDest);
237
- if (seeded.length > 0 || preserved.length > 0) {
238
- console.log(`\n Config (${displayDest}\\config\\ — user-editable, preserved across upgrades):`);
239
- for (const f of seeded) console.log(` - ${f} -> seeded from template`);
240
- for (const f of preserved) console.log(` - ${f} -> preserved (already exists)`);
241
- }
219
+ const seedRes = seedConfig(PKG_ROOT, fullDest);
220
+ printSeedReport(seedRes, `${displayDest}\\`);
242
221
 
243
222
  // Mirror agents/kushi.agent.md as top-level SKILL.md for Clawpilot discovery.
244
223
  const agentSrc = path.join(PKG_ROOT, PLUGIN_SOURCE_DIR, CLAWPILOT_AGENT_SOURCE);
@@ -253,6 +232,31 @@ async function installClawpilot(options, resolved, version) {
253
232
  printPostInstall(resolved, 'kushi');
254
233
  }
255
234
 
235
+ function printSeedReport(r, dispDestSlash) {
236
+ const total =
237
+ r.seededUser.length + r.seededShared.length +
238
+ r.preservedUser.length + r.preservedShared.length +
239
+ r.doctrine.length + r.examples.length;
240
+ if (total === 0 && r.gitignore === 'unchanged') return;
241
+
242
+ console.log(`\n Config (${dispDestSlash}config/):`);
243
+ if (r.seededShared.length || r.preservedShared.length || r.doctrine.length || r.examples.length) {
244
+ console.log(` shared/ (team-owned, safe to commit)`);
245
+ for (const f of r.seededShared) console.log(` - ${f} -> seeded (team default; preserved on reinstall)`);
246
+ for (const f of r.preservedShared) console.log(` - ${f} -> preserved (already exists)`);
247
+ for (const f of r.doctrine) console.log(` - ${f} -> updated (Kushi-versioned doctrine; do not hand-edit)`);
248
+ for (const f of r.examples) console.log(` - ${f} -> refreshed (reference scaffold)`);
249
+ }
250
+ if (r.seededUser.length || r.preservedUser.length) {
251
+ console.log(` user/ (per-contributor; gitignored)`);
252
+ for (const f of r.seededUser) console.log(` - ${f} -> seeded (edit before first bootstrap)`);
253
+ for (const f of r.preservedUser) console.log(` - ${f} -> preserved (already exists)`);
254
+ }
255
+ if (r.gitignore === 'created' || r.gitignore === 'appended') {
256
+ console.log(` .gitignore ${r.gitignore === 'created' ? 'created' : 'appended'} -> config/user/ excluded`);
257
+ }
258
+ }
259
+
256
260
  function printPostInstall(resolved, prefix) {
257
261
  console.log(`\n Done. Profile "${resolved.profile}" installed. Available verbs:`);
258
262
  for (const verb of resolved.verbs) {
@@ -2,43 +2,108 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import {
4
4
  CONFIG_DIR,
5
- CONFIG_SEED_FILES,
5
+ CONFIG_SHARED_SUBDIR,
6
+ CONFIG_USER_SUBDIR,
7
+ CONFIG_USER_SEED_FILES,
8
+ CONFIG_SHARED_SEED_FILES,
9
+ CONFIG_DOCTRINE_FILES,
10
+ CONFIG_EXAMPLE_FILES,
11
+ CONFIG_GITIGNORE_LINES,
6
12
  PLUGIN_SOURCE_DIR,
7
13
  } from './constants.mjs';
8
14
 
9
15
  /**
10
- * Seed <dest>/config/ with user-editable config files on FIRST INSTALL ONLY.
16
+ * Seed <dest>/config/ with config files using a two-tier shared/ + user/ layout.
11
17
  *
12
- * Rules:
13
- * - If <dest>/config/<file> already exists skip (never overwrite).
14
- * - Behavior is identical regardless of --force; config/ is user territory.
15
- * - Returns a per-file result so the installer can summarize what happened.
18
+ * <dest>/config/
19
+ * shared/ ← team-owned, safe to commit (doctrine + team defaults + .example scaffolds)
20
+ * user/ ← per-contributor identity & local paths (gitignored)
21
+ *
22
+ * Policies:
23
+ * - USER SEED (user/, seed-once): copied only if absent. Preserved on reinstall.
24
+ * - SHARED SEED (shared/, seed-once): copied only if absent. Preserved on reinstall.
25
+ * - DOCTRINE (shared/, overwrite-always): always rewritten — Kushi-versioned.
26
+ * - EXAMPLES (shared/, overwrite-always): always rewritten — reference scaffolds.
27
+ *
28
+ * Also writes <dest>/.gitignore with `config/user/` to prevent accidental commits.
16
29
  *
17
30
  * @param {string} sourcePkgDir – root of the npm package
18
31
  * @param {string} destAbsolute – absolute install destination (e.g. <workspace>/.kushi)
19
- * @returns {{ seeded: string[], preserved: string[] }}
32
+ * @returns {{
33
+ * seededUser: string[], // user/ files freshly created
34
+ * seededShared: string[], // shared/ seed-once files freshly created
35
+ * preservedUser: string[], // user/ files left untouched (already existed)
36
+ * preservedShared: string[],// shared/ seed-once files left untouched
37
+ * doctrine: string[], // shared/ doctrine files written (always)
38
+ * examples: string[], // shared/ .example files written (always)
39
+ * gitignore: 'created' | 'appended' | 'unchanged' | 'skipped',
40
+ * }}
20
41
  */
21
42
  export function seedConfig(sourcePkgDir, destAbsolute) {
22
- const configDir = path.join(destAbsolute, CONFIG_DIR);
23
- fs.mkdirSync(configDir, { recursive: true });
24
-
25
- const seeded = [];
26
- const preserved = [];
43
+ const sharedDir = path.join(destAbsolute, CONFIG_DIR, CONFIG_SHARED_SUBDIR);
44
+ const userDir = path.join(destAbsolute, CONFIG_DIR, CONFIG_USER_SUBDIR);
45
+ fs.mkdirSync(sharedDir, { recursive: true });
46
+ fs.mkdirSync(userDir, { recursive: true });
27
47
 
28
- for (const { template, target } of CONFIG_SEED_FILES) {
29
- const src = path.join(sourcePkgDir, PLUGIN_SOURCE_DIR, 'templates', template);
30
- const dst = path.join(configDir, target);
48
+ const result = {
49
+ seededUser: [],
50
+ seededShared: [],
51
+ preservedUser: [],
52
+ preservedShared: [],
53
+ doctrine: [],
54
+ examples: [],
55
+ gitignore: 'unchanged',
56
+ };
31
57
 
32
- if (!fs.existsSync(src)) continue;
58
+ const seedOnce = (specs, destDir, seededList, preservedList) => {
59
+ for (const { template, target } of specs) {
60
+ const src = path.join(sourcePkgDir, PLUGIN_SOURCE_DIR, 'templates', template);
61
+ const dst = path.join(destDir, target);
62
+ if (!fs.existsSync(src)) continue;
63
+ if (fs.existsSync(dst)) {
64
+ preservedList.push(target);
65
+ continue;
66
+ }
67
+ fs.cpSync(src, dst);
68
+ seededList.push(target);
69
+ }
70
+ };
33
71
 
34
- if (fs.existsSync(dst)) {
35
- preserved.push(target);
36
- continue;
72
+ const overwriteAlways = (specs, destDir, list) => {
73
+ for (const { template, target } of specs) {
74
+ const src = path.join(sourcePkgDir, PLUGIN_SOURCE_DIR, 'templates', template);
75
+ const dst = path.join(destDir, target);
76
+ if (!fs.existsSync(src)) continue;
77
+ fs.cpSync(src, dst, { force: true });
78
+ list.push(target);
37
79
  }
80
+ };
38
81
 
39
- fs.cpSync(src, dst);
40
- seeded.push(target);
41
- }
82
+ // user/ tier
83
+ seedOnce(CONFIG_USER_SEED_FILES, userDir, result.seededUser, result.preservedUser);
84
+ // shared/ tier
85
+ seedOnce(CONFIG_SHARED_SEED_FILES, sharedDir, result.seededShared, result.preservedShared);
86
+ overwriteAlways(CONFIG_DOCTRINE_FILES, sharedDir, result.doctrine);
87
+ overwriteAlways(CONFIG_EXAMPLE_FILES, sharedDir, result.examples);
42
88
 
43
- return { seeded, preserved };
89
+ // .gitignore at <dest>/.gitignore — append our marker block if not already present
90
+ result.gitignore = ensureGitignore(destAbsolute, CONFIG_GITIGNORE_LINES);
91
+
92
+ return result;
93
+ }
94
+
95
+ function ensureGitignore(destAbsolute, lines) {
96
+ const target = path.join(destAbsolute, '.gitignore');
97
+ const marker = lines[0];
98
+ const block = lines.join('\n') + '\n';
99
+
100
+ if (!fs.existsSync(target)) {
101
+ fs.writeFileSync(target, block, 'utf8');
102
+ return 'created';
103
+ }
104
+ const existing = fs.readFileSync(target, 'utf8');
105
+ if (existing.includes(marker)) return 'unchanged';
106
+ const sep = existing.endsWith('\n') ? '\n' : '\n\n';
107
+ fs.writeFileSync(target, existing + sep + block, 'utf8');
108
+ return 'appended';
44
109
  }
@@ -0,0 +1,150 @@
1
+ // Tests for seed-config.mjs — verifies the two-tier shared/ + user/ seeding.
2
+
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { seedConfig } from './seed-config.mjs';
10
+ import {
11
+ CONFIG_DOCTRINE_FILES,
12
+ CONFIG_EXAMPLE_FILES,
13
+ CONFIG_USER_SEED_FILES,
14
+ CONFIG_SHARED_SEED_FILES,
15
+ } from './constants.mjs';
16
+ import { loadKushiConfig, resolveKushiConfigPath } from './config-loader.mjs';
17
+
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+ const PKG = path.resolve(__dirname, '..');
20
+
21
+ function tmp() {
22
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'kushi-seed-'));
23
+ }
24
+
25
+ test('seedConfig: fresh install populates shared/ and user/ correctly', () => {
26
+ const dest = tmp();
27
+ try {
28
+ const r = seedConfig(PKG, dest);
29
+
30
+ const userTargets = CONFIG_USER_SEED_FILES.map((f) => f.target);
31
+ const sharedTargets = CONFIG_SHARED_SEED_FILES.map((f) => f.target);
32
+ const doctrineTargets = CONFIG_DOCTRINE_FILES.map((f) => f.target);
33
+ const exampleTargets = CONFIG_EXAMPLE_FILES.map((f) => f.target);
34
+
35
+ for (const t of userTargets) assert.ok(r.seededUser.includes(t), `seededUser includes ${t}`);
36
+ for (const t of sharedTargets) assert.ok(r.seededShared.includes(t), `seededShared includes ${t}`);
37
+ for (const t of doctrineTargets) assert.ok(r.doctrine.includes(t), `doctrine includes ${t}`);
38
+ for (const t of exampleTargets) assert.ok(r.examples.includes(t), `examples includes ${t}`);
39
+
40
+ for (const t of userTargets) {
41
+ assert.ok(fs.existsSync(path.join(dest, 'config', 'user', t)), `user/${t} on disk`);
42
+ }
43
+ for (const t of [...sharedTargets, ...doctrineTargets, ...exampleTargets]) {
44
+ assert.ok(fs.existsSync(path.join(dest, 'config', 'shared', t)), `shared/${t} on disk`);
45
+ }
46
+
47
+ assert.equal(r.gitignore, 'created');
48
+ const gi = fs.readFileSync(path.join(dest, '.gitignore'), 'utf8');
49
+ assert.ok(gi.includes('config/user/'), '.gitignore contains config/user/');
50
+ } finally {
51
+ fs.rmSync(dest, { recursive: true, force: true });
52
+ }
53
+ });
54
+
55
+ test('seedConfig: re-run preserves user/ and shared/ seed-once files', () => {
56
+ const dest = tmp();
57
+ try {
58
+ seedConfig(PKG, dest);
59
+ const userFile = path.join(dest, 'config', 'user', 'project-evidence.yml');
60
+ const sharedSeed = path.join(dest, 'config', 'shared', 'integrations.yml');
61
+ fs.writeFileSync(userFile, 'alias: edited-by-user\n');
62
+ fs.writeFileSync(sharedSeed, '# team-edited\n');
63
+
64
+ const r2 = seedConfig(PKG, dest);
65
+
66
+ assert.ok(r2.preservedUser.includes('project-evidence.yml'));
67
+ assert.ok(r2.preservedShared.includes('integrations.yml'));
68
+ assert.equal(fs.readFileSync(userFile, 'utf8'), 'alias: edited-by-user\n');
69
+ assert.equal(fs.readFileSync(sharedSeed, 'utf8'), '# team-edited\n');
70
+ assert.equal(r2.gitignore, 'unchanged');
71
+ } finally {
72
+ fs.rmSync(dest, { recursive: true, force: true });
73
+ }
74
+ });
75
+
76
+ test('seedConfig: doctrine and examples are overwritten on every install', () => {
77
+ const dest = tmp();
78
+ try {
79
+ seedConfig(PKG, dest);
80
+ const doc = path.join(dest, 'config', 'shared', 'azuredevops.json');
81
+ const ex = path.join(dest, 'config', 'shared', 'm365-auth.json.example');
82
+ fs.writeFileSync(doc, '{"tampered":true}');
83
+ fs.writeFileSync(ex, '{}');
84
+
85
+ const r2 = seedConfig(PKG, dest);
86
+
87
+ assert.ok(r2.doctrine.includes('azuredevops.json'));
88
+ assert.ok(r2.examples.includes('m365-auth.json.example'));
89
+ const docRe = fs.readFileSync(doc, 'utf8');
90
+ const exRe = fs.readFileSync(ex, 'utf8');
91
+ assert.ok(!docRe.includes('tampered'), 'doctrine restored');
92
+ assert.ok(exRe.length > 10, 'example restored');
93
+ } finally {
94
+ fs.rmSync(dest, { recursive: true, force: true });
95
+ }
96
+ });
97
+
98
+ test('seedConfig: .gitignore is appended when file exists without marker', () => {
99
+ const dest = tmp();
100
+ try {
101
+ fs.writeFileSync(path.join(dest, '.gitignore'), '# pre-existing\nnode_modules/\n');
102
+ const r = seedConfig(PKG, dest);
103
+ assert.equal(r.gitignore, 'appended');
104
+ const gi = fs.readFileSync(path.join(dest, '.gitignore'), 'utf8');
105
+ assert.ok(gi.includes('node_modules/'), 'preserved existing content');
106
+ assert.ok(gi.includes('config/user/'), 'appended Kushi marker');
107
+ } finally {
108
+ fs.rmSync(dest, { recursive: true, force: true });
109
+ }
110
+ });
111
+
112
+ test('config-loader: resolves user/ before shared/', () => {
113
+ const workspace = tmp();
114
+ const dest = path.join(workspace, '.kushi');
115
+ try {
116
+ seedConfig(PKG, dest);
117
+ const sharedResolved = resolveKushiConfigPath('azuredevops', { workspace });
118
+ assert.ok(sharedResolved.includes(path.join('config', 'shared', 'azuredevops.json')));
119
+
120
+ const userShadow = path.join(dest, 'config', 'user', 'azuredevops.json');
121
+ fs.writeFileSync(userShadow, '{"override":true}');
122
+ const userResolved = resolveKushiConfigPath('azuredevops', { workspace });
123
+ assert.equal(userResolved, userShadow);
124
+ } finally {
125
+ fs.rmSync(workspace, { recursive: true, force: true });
126
+ }
127
+ });
128
+
129
+ test('config-loader: throws on placeholder by default; -allowPlaceholders bypasses', () => {
130
+ const workspace = tmp();
131
+ const dest = path.join(workspace, '.kushi');
132
+ try {
133
+ seedConfig(PKG, dest);
134
+ assert.throws(() => loadKushiConfig('project-evidence', { workspace, raw: true }),
135
+ /placeholder/i);
136
+ const txt = loadKushiConfig('project-evidence', { workspace, raw: true, allowPlaceholders: true });
137
+ assert.equal(typeof txt, 'string');
138
+ } finally {
139
+ fs.rmSync(workspace, { recursive: true, force: true });
140
+ }
141
+ });
142
+
143
+ test('config-loader: throws with actionable message when missing', () => {
144
+ const dest = tmp();
145
+ try {
146
+ assert.throws(() => loadKushiConfig('nonexistent-config', { workspace: dest }), /not found/i);
147
+ } finally {
148
+ fs.rmSync(dest, { recursive: true, force: true });
149
+ }
150
+ });