kushi-agents 5.9.0 → 5.9.1

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/bin/cli.mjs CHANGED
@@ -103,22 +103,47 @@ if (args.length > 0 && args[0] === 'lint') {
103
103
  process.exit(0);
104
104
  }
105
105
 
106
+ // ── one-shot wiki verb (v5.9.1+) ─────────────────────────────────────────────
107
+ // `kushi wiki` — deterministic do-the-wiki: resolve root, init if missing,
108
+ // print path + status + clickable file:// URL. Idempotent across
109
+ // "create wiki" / "do wiki" / "refresh wiki" / "update wiki".
110
+ if (args.length > 0 && args[0] === 'wiki') {
111
+ const cliMod = await import('../src/global-wiki-cli.mjs');
112
+ await cliMod.runWiki();
113
+ process.exit(0);
114
+ }
115
+
106
116
  // ── global verb (v5.3.0+) ────────────────────────────────────────────────────
107
117
  if (args.length > 0 && args[0] === 'global') {
108
118
  const sub = args[1] || '';
109
- const validSubs = ['init', 'status', 'ask', 'lint'];
119
+ const validSubs = ['init', 'status', 'ask', 'lint', 'migrate', 'set-root', 'show-root'];
110
120
  if (!validSubs.includes(sub)) {
111
- console.error('\n Usage: kushi global init Scaffold ~/.kushi-global/State/');
112
- console.error(' kushi global status Show counts + freshness');
113
- console.error(' kushi global ask <question> Ask the global wiki');
114
- console.error(' kushi global lint Lint the global wiki\n');
121
+ console.error('\n Usage: kushi global init Scaffold State/ at the resolved root');
122
+ console.error(' kushi global status Show counts + freshness');
123
+ console.error(' kushi global ask <question> Ask the global wiki');
124
+ console.error(' kushi global lint Lint the global wiki');
125
+ console.error(' kushi global show-root Show how the root path is resolved');
126
+ console.error(' kushi global set-root <path> [--scope workspace|home]');
127
+ console.error(' Persist root (workspace shared by default,');
128
+ console.error(' falls back to ~/.kushi/config.json)');
129
+ console.error(' kushi global migrate <new-path> Copy wiki to a new root + persist setting\n');
130
+ console.error(' Tip: Set the root to a OneDrive-synced SharePoint folder to share');
131
+ console.error(' the wiki across a team. See docs/how-to/wiki-on-sharepoint.md.\n');
115
132
  process.exit(1);
116
133
  }
117
- const { runGlobalInit, runGlobalStatus, runGlobalAsk, runGlobalLint } = await import('../src/global-wiki-cli.mjs');
118
- if (sub === 'init') await runGlobalInit();
119
- else if (sub === 'status') await runGlobalStatus();
120
- else if (sub === 'ask') await runGlobalAsk(args.slice(2).join(' '));
121
- else if (sub === 'lint') await runGlobalLint();
134
+ const cliMod = await import('../src/global-wiki-cli.mjs');
135
+ if (sub === 'init') await cliMod.runGlobalInit();
136
+ else if (sub === 'status') await cliMod.runGlobalStatus();
137
+ else if (sub === 'ask') await cliMod.runGlobalAsk(args.slice(2).join(' '));
138
+ else if (sub === 'lint') await cliMod.runGlobalLint();
139
+ else if (sub === 'migrate') await cliMod.runGlobalMigrate(args[2]);
140
+ else if (sub === 'set-root') {
141
+ const scopeIdx = args.indexOf('--scope');
142
+ const scope = scopeIdx > 0 && args[scopeIdx + 1] ? args[scopeIdx + 1] : null;
143
+ const target = args.find((a, i) => i >= 2 && !a.startsWith('--') && args[i - 1] !== '--scope');
144
+ await cliMod.runGlobalSetRoot(target, { scope });
145
+ }
146
+ else if (sub === 'show-root') await cliMod.runGlobalShowRoot();
122
147
  process.exit(0);
123
148
  }
124
149
 
@@ -262,6 +287,11 @@ if (args.includes('--help') || args.includes('-h')) {
262
287
  lint <project> Run wiki-lint checks on State/
263
288
 
264
289
  Workspace lifecycle (v5.9.0+):
290
+ wiki One-shot: resolve global wiki root, scaffold
291
+ if missing, print status + clickable file://
292
+ link to the wiki index. Idempotent —
293
+ "do wiki" / "refresh wiki" / "create wiki"
294
+ all map here.
265
295
  uninstall [--keep-config] Remove <cwd>/.kushi/ (preserves Evidence/, State/).
266
296
  --keep-config preserves config/user/ identity files.
267
297
  upgrade npm i -g kushi-agents@latest then re-seed assets
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kushi-agents",
3
- "version": "5.9.0",
3
+ "version": "5.9.1",
4
4
  "description": "Install Kushi — multi-source project evidence agent with Comprehensive Structured Capture (CSC) into weekly-only files across Email, Teams, OneNote, Loop, SharePoint, Meetings, CRM, ADO. Meetings retain a sibling verbatim/ audit folder. WorkIQ-only for M365 sources (Graph / m365_* FORBIDDEN as fallbacks; user-paste is first-class). Host-agnostic.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,8 +17,13 @@ The global wiki is a personal, cross-engagement knowledge base that lives **outs
17
17
  ### 1. Location
18
18
 
19
19
  - **Default:** `~/.kushi-global/State/` (`$HOME` on POSIX, `$env:USERPROFILE` on Windows).
20
- - **Override:** `$KUSHI_GLOBAL_ROOT` environment variable (absolute path; tilde-expanded). Tests MUST set this to `.testtmp/.kushi-global/` — never touch the real path.
21
- - The global root is **per-user**, never per-project, never per-host.
20
+ - **Resolution chain (v5.9.1+, first non-empty wins):**
21
+ 1. `$KUSHI_GLOBAL_ROOT` env var (absolute path; tilde-expanded). Tests MUST set this to `.testtmp/.kushi-global/` never touch the real path.
22
+ 2. **`<workspace>/.kushi/config/shared/kushi.yml` → `globalRoot`** (workspace-shared, team-shareable, committed)
23
+ 3. `~/.kushi/config.json` → `globalRoot` (per-user, per-machine)
24
+ 4. Default `~/.kushi-global/`
25
+ - The global root is **per-user OR per-team** (workspace-shared scope), never per-project, never per-host.
26
+ - Inspect with `kushi global show-root`. Set with `kushi global set-root <path> [--scope workspace|home]` (default scope = workspace if a `.kushi/` is found above cwd, else home).
22
27
 
23
28
  ### 2. Shape
24
29
 
@@ -1,12 +1,21 @@
1
1
  ---
2
2
  name: "global-wiki"
3
3
  version: "1.0.0"
4
- description: "USE WHEN the user says 'init global wiki', 'kushi global init/status/ask/lint', 'show me my global wiki', or wants to manage the cross-engagement knowledge base at ~/.kushi-global/State/. DO NOT USE for promoting individual pages (use promote) or for project-scoped Q&A (use ask-project). Capability: scaffold + status + ask + lint over the per-user global wiki; honors $KUSHI_GLOBAL_ROOT for tests."
4
+ description: "USE WHEN the user says 'init global wiki', 'kushi global init/status/ask/lint/show-root/set-root', 'show me my global wiki', or wants to manage the cross-engagement knowledge base. DO NOT USE for promoting individual pages (use promote) or for project-scoped Q&A (use ask-project). Capability: scaffold + status + ask + lint + show-root + set-root over the global wiki; resolves location via 4-tier chain (env > workspace shared > home config > default ~/.kushi-global/State/)."
5
5
  ---
6
6
 
7
7
  # Skill: global-wiki
8
8
 
9
- Manages the per-user global wiki at `$KUSHI_GLOBAL_ROOT` (default `~/.kushi-global/State/`). Structurally identical to a project `State/` wiki but tagged `scope: global` in every page's frontmatter. Holds cross-engagement patterns that consultants want to keep across projects.
9
+ Manages the global wiki at the resolved root (default `~/.kushi-global/State/`). Structurally identical to a project `State/` wiki but tagged `scope: global` in every page's frontmatter. Holds cross-engagement patterns that consultants want to keep across projects.
10
+
11
+ **Location resolution (v5.9.1+, first non-empty wins):**
12
+
13
+ 1. `$KUSHI_GLOBAL_ROOT` env var (override; ideal for tests + temp redirection)
14
+ 2. `<workspace>/.kushi/config/shared/kushi.yml → globalRoot` (team-shareable, committed)
15
+ 3. `~/.kushi/config.json → globalRoot` (per-user, per-machine)
16
+ 4. Default `~/.kushi-global/`
17
+
18
+ Use `kushi global show-root` to inspect the chain.
10
19
 
11
20
  See `plugin/instructions/global-wiki.instructions.md` for the full doctrine and `multi-wiki-routing.instructions.md` for how readers + writers route between project and global.
12
21
 
@@ -27,14 +36,14 @@ See `plugin/instructions/global-wiki.instructions.md` for the full doctrine and
27
36
 
28
37
  ## Step checklist
29
38
 
30
- - [ ] Step 1 — Resolve `$KUSHI_GLOBAL_ROOT` (env override) or fall back to `~/.kushi-global/`.
39
+ - [ ] Step 1 — Resolve the global root via the 4-tier chain (env workspace shared home config → default).
31
40
  - [ ] Step 2 — Dispatch to the requested sub-operation.
32
41
  - [ ] Step 3 — Print a one-line summary + paths.
33
42
  - [ ] Step 4 — Append a `log.md` entry on init / lint / ask-fileback.
34
43
 
35
44
  ### Step 1 — Resolve global root
36
45
 
37
- Read `$env:KUSHI_GLOBAL_ROOT`. If unset, default to `$env:USERPROFILE/.kushi-global/` on Windows or `$HOME/.kushi-global/` on POSIX. Tests MUST set `$env:KUSHI_GLOBAL_ROOT='.testtmp/.kushi-global'` and NEVER touch the real path.
46
+ Resolve via 4-tier chain: `$KUSHI_GLOBAL_ROOT` `<workspace>/.kushi/config/shared/kushi.yml#globalRoot` `~/.kushi/config.json#globalRoot` default `~/.kushi-global/`. Tests MUST set `$env:KUSHI_GLOBAL_ROOT='.testtmp/.kushi-global'` and NEVER touch the real path. See `src/global-wiki.mjs#resolveGlobalRoot` for the canonical implementation.
38
47
 
39
48
  ### Step 2 — Sub-operations
40
49
 
@@ -0,0 +1,27 @@
1
+ # Kushi — shared workspace settings.
2
+ #
3
+ # Seeded by the installer on first install. Never overwritten on upgrade
4
+ # (unless you pass --force). Safe to commit — these are team-wide settings,
5
+ # not per-contributor identity.
6
+ #
7
+ # Resolution order (first non-empty wins):
8
+ # 1. $env:KUSHI_GLOBAL_ROOT (env override)
9
+ # 2. <workspace>/.kushi/config/shared/kushi.yml → globalRoot (THIS FILE)
10
+ # 3. ~/.kushi/config.json → globalRoot (home fallback)
11
+ # 4. ~/.kushi-global/ (default)
12
+ #
13
+ # Edit with: kushi global set-root <path>
14
+ # Or hand-edit this file directly.
15
+
16
+ # Global wiki root.
17
+ #
18
+ # Path to the team's shared kushi global wiki. State/ is created underneath.
19
+ #
20
+ # null → fall back to the home config file or the default ~/.kushi-global/.
21
+ # path → use this directory.
22
+ #
23
+ # Tip: set this to a OneDrive-synced SharePoint folder so every contributor
24
+ # on the team picks up the same wiki path automatically (when they pull the
25
+ # repo, this file already names the synced location).
26
+ # See docs/how-to/wiki-on-sharepoint.md.
27
+ globalRoot: null
@@ -0,0 +1,20 @@
1
+ {
2
+ "_meta": {
3
+ "purpose": "Per-user, per-machine kushi settings. Lives at ~/.kushi/config.json.",
4
+ "scope": "machine-user",
5
+ "version_managed_by": "kushi-agents npm install (seed-once)",
6
+ "edit_with": "kushi global set-root <path> (or edit this file directly)"
7
+ },
8
+
9
+ "globalRoot": null,
10
+
11
+ "_docs": {
12
+ "globalRoot": [
13
+ "Path to the global wiki root. State/ is created underneath.",
14
+ "null → kushi falls back to ~/.kushi-global/",
15
+ "Tip → set this to a OneDrive-synced SharePoint folder to share the wiki",
16
+ " across a team. See docs/how-to/wiki-on-sharepoint.md.",
17
+ "Override (per-shell): set $env:KUSHI_GLOBAL_ROOT — env always wins."
18
+ ]
19
+ }
20
+ }
package/src/constants.mjs CHANGED
@@ -92,6 +92,7 @@ export const CONFIG_USER_SEED_FILES = [
92
92
  */
93
93
  export const CONFIG_SHARED_SEED_FILES = [
94
94
  { template: 'init/integrations.template.yml', target: 'integrations.yml' },
95
+ { template: 'init/kushi.template.yml', target: 'kushi.yml' },
95
96
  ];
96
97
 
97
98
  /**
@@ -4,10 +4,60 @@
4
4
 
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
- import { globalInit, globalStatus, globalAsk, globalLint, promote, resolveGlobalRoot } from './global-wiki.mjs';
7
+ import os from 'node:os';
8
+ import { spawnSync } from 'node:child_process';
9
+ import {
10
+ globalInit, globalStatus, globalAsk, globalLint, promote,
11
+ resolveGlobalRoot, explainGlobalRoot, writeUserConfig, userConfigPath,
12
+ findWorkspaceSharedConfig, writeWorkspaceSharedConfig,
13
+ } from './global-wiki.mjs';
8
14
 
9
- export async function runGlobalInit() {
10
- const result = globalInit();
15
+ /**
16
+ * Persist the global wiki root.
17
+ * - If we're inside a workspace (a .kushi/config/shared/ exists somewhere
18
+ * above cwd), write to <workspace>/.kushi/config/shared/kushi.yml. This
19
+ * is the team-shared, committed location — the right answer when the
20
+ * whole team should pick up the same wiki path.
21
+ * - Otherwise, fall back to the per-user home file ~/.kushi/config.json.
22
+ * - On Windows, also setx KUSHI_GLOBAL_ROOT as a courtesy for tools that
23
+ * read the env var directly. The config file is the source of truth.
24
+ *
25
+ * Set `scope: 'home'` or `scope: 'workspace'` to force one tier.
26
+ */
27
+ function persistGlobalRootEnv(rootPath, { scope = 'auto', cwd = process.cwd() } = {}) {
28
+ const target = path.resolve(rootPath);
29
+ let where;
30
+ let chosenScope;
31
+
32
+ const wsFile = findWorkspaceSharedConfig(cwd);
33
+ if (scope === 'workspace' || (scope === 'auto' && wsFile)) {
34
+ if (!wsFile) {
35
+ return { method: 'workspace', ok: false, message: `No .kushi/config/shared/kushi.yml found above ${cwd}. Run install in this workspace first, or use --scope home.` };
36
+ }
37
+ writeWorkspaceSharedConfig(wsFile, { globalRoot: target });
38
+ where = wsFile; chosenScope = 'workspace';
39
+ } else {
40
+ const cfg = writeUserConfig({ globalRoot: target });
41
+ where = cfg.path; chosenScope = 'home';
42
+ }
43
+
44
+ let envNote = '';
45
+ if (process.platform === 'win32') {
46
+ const r = spawnSync('setx', ['KUSHI_GLOBAL_ROOT', target], { encoding: 'utf8' });
47
+ envNote = r.status === 0
48
+ ? ' (also set $env:KUSHI_GLOBAL_ROOT for User scope — visible in new shells)'
49
+ : '';
50
+ }
51
+ return {
52
+ method: 'config-file',
53
+ scope: chosenScope,
54
+ ok: true,
55
+ message: `Wrote globalRoot to ${where}.${envNote}`,
56
+ };
57
+ }
58
+
59
+ export async function runGlobalInit({ root, persistEnv = false } = {}) {
60
+ const result = globalInit(root ? { root } : {});
11
61
  console.log('');
12
62
  console.log(` Global wiki root: ${result.root}`);
13
63
  console.log(` State dir : ${result.state}`);
@@ -17,11 +67,188 @@ export async function runGlobalInit() {
17
67
  if (result.skipped.length) {
18
68
  console.log(` Skipped (${result.skipped.length} already present)`);
19
69
  }
70
+ if (persistEnv && root) {
71
+ const env = persistGlobalRootEnv(result.root);
72
+ console.log('');
73
+ console.log(` ${env.ok ? '✓' : '⚠'} ${env.message}`);
74
+ }
20
75
  console.log('');
21
76
  console.log(" Next: 'kushi global status' or 'kushi promote <project> <page>'");
22
77
  console.log('');
23
78
  }
24
79
 
80
+ /**
81
+ * Migrate the existing global wiki from current root to a new path.
82
+ * Copies State/ contents, then persists KUSHI_GLOBAL_ROOT to the new location.
83
+ * Does NOT delete the old root — user can remove it manually after verifying.
84
+ */
85
+ export async function runGlobalMigrate(newRootArg, { persistEnv = true } = {}) {
86
+ if (!newRootArg || !newRootArg.trim()) {
87
+ console.error('\n Usage: kushi global migrate <new-root-path>\n');
88
+ console.error(' Example (OneDrive-synced SharePoint):\n');
89
+ console.error(' kushi global migrate "C:\\Users\\you\\Microsoft\\TeamWiki - Documents\\KushiWiki"\n');
90
+ process.exitCode = 1;
91
+ return;
92
+ }
93
+ const newRoot = path.resolve(newRootArg.trim());
94
+ const oldRoot = resolveGlobalRoot();
95
+ console.log('');
96
+ console.log(` Migrating global wiki:`);
97
+ console.log(` from: ${oldRoot}`);
98
+ console.log(` to : ${newRoot}`);
99
+ console.log('');
100
+
101
+ const oldState = path.join(oldRoot, 'State');
102
+ if (!fs.existsSync(oldState)) {
103
+ console.log(` ⚠ No existing State/ at ${oldState} — initializing fresh at ${newRoot}.`);
104
+ const result = globalInit({ root: newRoot });
105
+ console.log(` ✓ Created ${result.created.length} file(s) at ${result.state}`);
106
+ } else {
107
+ fs.mkdirSync(newRoot, { recursive: true });
108
+ const newState = path.join(newRoot, 'State');
109
+ let copied = 0, skipped = 0;
110
+ const copyTree = (src, dst) => {
111
+ fs.mkdirSync(dst, { recursive: true });
112
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
113
+ const sp = path.join(src, entry.name);
114
+ const dp = path.join(dst, entry.name);
115
+ if (entry.isDirectory()) copyTree(sp, dp);
116
+ else if (entry.isFile()) {
117
+ if (fs.existsSync(dp)) { skipped++; continue; }
118
+ fs.copyFileSync(sp, dp);
119
+ copied++;
120
+ }
121
+ }
122
+ };
123
+ copyTree(oldState, newState);
124
+ console.log(` ✓ Copied ${copied} file(s); skipped ${skipped} that already existed at target.`);
125
+ }
126
+
127
+ if (persistEnv) {
128
+ const env = persistGlobalRootEnv(newRoot);
129
+ console.log('');
130
+ console.log(` ${env.ok ? '✓' : '⚠'} ${env.message}`);
131
+ }
132
+
133
+ console.log('');
134
+ console.log(` Verify with: kushi global status`);
135
+ console.log(` Old root preserved at: ${oldRoot}`);
136
+ console.log(` Once verified, you can delete the old State/ folder there.`);
137
+ console.log('');
138
+ }
139
+
140
+ export async function runGlobalSetRoot(newRootArg, opts = {}) {
141
+ if (!newRootArg || !newRootArg.trim()) {
142
+ console.error('\n Usage: kushi global set-root <path> [--scope workspace|home]\n');
143
+ console.error(' Persists the global wiki root.');
144
+ console.error(' Default scope = workspace if a .kushi/ is found above cwd, else home.');
145
+ console.error(' Effect is immediate — no shell restart needed.\n');
146
+ process.exitCode = 1;
147
+ return;
148
+ }
149
+ const newRoot = path.resolve(newRootArg.trim());
150
+ const env = persistGlobalRootEnv(newRoot, { scope: opts.scope || 'auto' });
151
+ console.log('');
152
+ console.log(` Target: ${newRoot}`);
153
+ console.log(` Scope : ${env.scope || '(none)'}`);
154
+ console.log(` ${env.ok ? '✓' : '⚠'} ${env.message}`);
155
+ if (!fs.existsSync(path.join(newRoot, 'State'))) {
156
+ console.log('');
157
+ console.log(` Note: ${newRoot}/State/ does not exist yet. Run \`kushi global init\` to create it.`);
158
+ }
159
+ console.log('');
160
+ }
161
+
162
+ /**
163
+ * Show the resolution chain for the global wiki root: env var → config file → default.
164
+ * Helps users figure out why kushi is reading/writing where it is.
165
+ */
166
+ export async function runGlobalShowRoot() {
167
+ const explain = explainGlobalRoot();
168
+ console.log('');
169
+ console.log(` Resolved global root: ${explain.resolved}`);
170
+ console.log(` Winning source : ${explain.winner ? explain.winner.source : 'default'}`);
171
+ console.log('');
172
+ console.log(' Resolution chain (first non-empty wins):');
173
+ for (const s of explain.sources) {
174
+ const mark = explain.winner && explain.winner.source === s.source ? '→' : ' ';
175
+ const val = s.value ? s.value : '(unset)';
176
+ console.log(` ${mark} [${s.source}] ${s.key}`);
177
+ console.log(` ${val}`);
178
+ }
179
+ console.log('');
180
+ console.log(` Config file (home): ${userConfigPath()}`);
181
+ console.log(' Change with: kushi global set-root <path> (workspace shared if available, else home)');
182
+ console.log(' kushi global set-root <path> --scope home');
183
+ console.log(' Or override: $env:KUSHI_GLOBAL_ROOT = "..." (env var wins for current shell)');
184
+ console.log('');
185
+ }
186
+
187
+
188
+ function expandIfTilde(p) {
189
+ if (typeof p !== 'string') return p;
190
+ if (p.startsWith('~')) {
191
+ const home = process.env.USERPROFILE || process.env.HOME || os.homedir();
192
+ return path.join(home, p.slice(1).replace(/^[\\/]/, ''));
193
+ }
194
+ return p;
195
+ }
196
+
197
+ /**
198
+ * One-shot deterministic "do the wiki" verb.
199
+ *
200
+ * - Resolves the global root via the 4-tier chain (env > workspace shared > home > default).
201
+ * - If the State/ scaffold is missing, runs `globalInit` to create it.
202
+ * - Prints the resolved root, winning source, status counts, and a clickable
203
+ * `file://` URL so the user can open the wiki immediately.
204
+ * - Idempotent: safe to re-run on every "do wiki" / "refresh wiki" / "create wiki".
205
+ */
206
+ export async function runWiki() {
207
+ const explain = explainGlobalRoot();
208
+ const winner = explain.winner;
209
+ const root = winner ? expandIfTilde(winner.value) : explain.resolved;
210
+ const stateDir = path.join(root, 'State');
211
+ const initiallyMissing = !fs.existsSync(stateDir);
212
+
213
+ console.log('');
214
+ console.log(` Wiki root : ${root}`);
215
+ console.log(` Winning source : ${winner ? winner.source : 'default'}`);
216
+
217
+ let initResult = null;
218
+ if (initiallyMissing) {
219
+ initResult = globalInit({ root });
220
+ console.log(` Scaffold : created ${initResult.created.length} file(s)`);
221
+ } else {
222
+ initResult = globalInit({ root });
223
+ if (initResult.created.length) {
224
+ console.log(` Scaffold : top-up — added ${initResult.created.length} missing file(s)`);
225
+ } else {
226
+ console.log(` Scaffold : already present (${initResult.skipped.length} files intact)`);
227
+ }
228
+ }
229
+
230
+ const status = globalStatus({ root });
231
+ console.log('');
232
+ console.log(` Pages : ${status.counts.pages}`);
233
+ console.log(` Answers : ${status.counts.answers}`);
234
+ console.log(` Reports : ${status.counts.reports}`);
235
+ console.log(` Open review : ${status.counts.review_items}`);
236
+ if (status.newest_iso) console.log(` Newest entry : ${status.newest_iso}`);
237
+
238
+ const indexFile = path.join(stateDir, 'index.md');
239
+ const fileUrl = 'file:///' + indexFile.replace(/\\/g, '/').replace(/^\/+/, '');
240
+ console.log('');
241
+ console.log(` Open the wiki : ${fileUrl}`);
242
+ console.log(` Local path : ${indexFile}`);
243
+ console.log('');
244
+ console.log(' Next:');
245
+ console.log(' kushi global ask <question> Ask the wiki');
246
+ console.log(' kushi promote <project> <page> Promote a project page');
247
+ console.log(' kushi global lint Privacy + freshness scan');
248
+ console.log(' kushi global show-root Inspect the resolution chain');
249
+ console.log('');
250
+ }
251
+
25
252
  export async function runGlobalStatus() {
26
253
  const status = globalStatus();
27
254
  console.log('');
@@ -85,18 +85,150 @@ Open items that need your attention. Populated by:
85
85
 
86
86
  const SCAFFOLD_DIRS = ['answers', 'reports'];
87
87
 
88
+ // ───────────────────────────────────────────────────────────────────────────
89
+ // Per-user config file
90
+ //
91
+ // Kushi has TWO levels of config that can carry settings like `globalRoot`:
92
+ //
93
+ // workspace/shared : <cwd>/.kushi/config/shared/kushi.yml
94
+ // Team-shared (committed). Seeded by installer.
95
+ // Found by walking up from cwd looking for .kushi/.
96
+ //
97
+ // home (per-user) : ~/.kushi/config.json
98
+ // Last-resort fallback when not inside a workspace.
99
+ // Also seeded by installer.
100
+ //
101
+ // Resolution order for the global wiki root:
102
+ // 1. $KUSHI_GLOBAL_ROOT (env override; tests, ad-hoc redirects)
103
+ // 2. workspace shared kushi.yml (team-shared persistent setting)
104
+ // 3. ~/.kushi/config.json (per-machine persistent setting)
105
+ // 4. ~/.kushi-global/ (default)
106
+ //
107
+ // The shared file follows the same seed-once contract as integrations.yml:
108
+ // preserved on every reinstall unless --force.
109
+
110
+ export function userConfigPath(env = process.env) {
111
+ const home = env.USERPROFILE || env.HOME || os.homedir();
112
+ return path.join(home, '.kushi', 'config.json');
113
+ }
114
+
115
+ export function readUserConfig(env = process.env) {
116
+ const f = userConfigPath(env);
117
+ if (!fs.existsSync(f)) return {};
118
+ try { return JSON.parse(fs.readFileSync(f, 'utf8')) || {}; }
119
+ catch { return {}; }
120
+ }
121
+
122
+ export function writeUserConfig(patch, env = process.env) {
123
+ const f = userConfigPath(env);
124
+ fs.mkdirSync(path.dirname(f), { recursive: true });
125
+ const cur = readUserConfig(env);
126
+ const next = { ...cur, ...patch };
127
+ fs.writeFileSync(f, JSON.stringify(next, null, 2) + '\n', 'utf8');
128
+ return { path: f, config: next };
129
+ }
130
+
131
+ /**
132
+ * Walk up from cwd to find the nearest workspace `.kushi/config/shared/kushi.yml`.
133
+ * Returns absolute path or null.
134
+ */
135
+ export function findWorkspaceSharedConfig(startDir = process.cwd()) {
136
+ let dir = path.resolve(startDir);
137
+ const root = path.parse(dir).root;
138
+ while (true) {
139
+ const candidate = path.join(dir, '.kushi', 'config', 'shared', 'kushi.yml');
140
+ if (fs.existsSync(candidate)) return candidate;
141
+ if (dir === root) return null;
142
+ const parent = path.dirname(dir);
143
+ if (parent === dir) return null;
144
+ dir = parent;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Tiny tolerant reader for the shared kushi.yml file. Only reads top-level
150
+ * scalar keys (globalRoot today). Avoids adding a YAML dep to src/.
151
+ *
152
+ * globalRoot: 'C:\path\to\wiki' → "C:\\path\\to\\wiki"
153
+ * globalRoot: null → null (treated as unset)
154
+ * globalRoot: ~ → null (yaml shorthand for null)
155
+ * globalRoot: → null (empty value)
156
+ */
157
+ export function readWorkspaceSharedConfig(file) {
158
+ if (!file || !fs.existsSync(file)) return {};
159
+ const txt = fs.readFileSync(file, 'utf8');
160
+ const obj = {};
161
+ for (const rawLine of txt.split(/\r?\n/)) {
162
+ const line = rawLine.replace(/\s+#.*$/, '').trim();
163
+ if (!line || line.startsWith('#')) continue;
164
+ const m = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
165
+ if (!m) continue;
166
+ const key = m[1];
167
+ let val = m[2].trim();
168
+ if (val === '' || val === '~' || val.toLowerCase() === 'null') { obj[key] = null; continue; }
169
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
170
+ val = val.slice(1, -1);
171
+ }
172
+ obj[key] = val;
173
+ }
174
+ return obj;
175
+ }
176
+
177
+ export function writeWorkspaceSharedConfig(file, patch) {
178
+ let txt = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
179
+ for (const [k, v] of Object.entries(patch)) {
180
+ const valStr = (v === null || v === undefined || v === '')
181
+ ? 'null'
182
+ : (/^[\w./\\:~+\-]+$/.test(String(v)) ? String(v) : `'${String(v).replace(/'/g, "''")}'`);
183
+ const re = new RegExp(`^${k}\\s*:.*$`, 'm');
184
+ if (re.test(txt)) txt = txt.replace(re, `${k}: ${valStr}`);
185
+ else txt += (txt.endsWith('\n') ? '' : '\n') + `${k}: ${valStr}\n`;
186
+ }
187
+ fs.mkdirSync(path.dirname(file), { recursive: true });
188
+ fs.writeFileSync(file, txt, 'utf8');
189
+ return { path: file };
190
+ }
191
+
88
192
  // ───────────────────────────────────────────────────────────────────────────
89
193
  // Resolution
90
194
 
91
- export function resolveGlobalRoot(env = process.env) {
195
+ export function resolveGlobalRoot(env = process.env, opts = {}) {
92
196
  const override = env.KUSHI_GLOBAL_ROOT;
93
197
  if (override && override.trim()) {
94
198
  return path.resolve(expandTilde(override.trim(), env));
95
199
  }
200
+ const wsFile = findWorkspaceSharedConfig(opts.cwd || process.cwd());
201
+ if (wsFile) {
202
+ const ws = readWorkspaceSharedConfig(wsFile);
203
+ if (ws.globalRoot && typeof ws.globalRoot === 'string' && ws.globalRoot.trim()) {
204
+ return path.resolve(expandTilde(ws.globalRoot.trim(), env));
205
+ }
206
+ }
207
+ const cfg = readUserConfig(env);
208
+ if (cfg.globalRoot && typeof cfg.globalRoot === 'string' && cfg.globalRoot.trim()) {
209
+ return path.resolve(expandTilde(cfg.globalRoot.trim(), env));
210
+ }
96
211
  const home = env.USERPROFILE || env.HOME || os.homedir();
97
212
  return path.join(home, '.kushi-global');
98
213
  }
99
214
 
215
+ /**
216
+ * Returns the full resolution chain so `kushi global show-root` can display it.
217
+ */
218
+ export function explainGlobalRoot(env = process.env, opts = {}) {
219
+ const home = env.USERPROFILE || env.HOME || os.homedir();
220
+ const wsFile = findWorkspaceSharedConfig(opts.cwd || process.cwd());
221
+ const wsVal = wsFile ? (readWorkspaceSharedConfig(wsFile).globalRoot || null) : null;
222
+ const sources = [
223
+ { source: 'env', key: 'KUSHI_GLOBAL_ROOT', value: env.KUSHI_GLOBAL_ROOT || null },
224
+ { source: 'workspace', key: wsFile ? `${wsFile} → globalRoot` : '(no workspace .kushi/config/shared/kushi.yml found)', value: wsVal, file: wsFile },
225
+ { source: 'home', key: `${userConfigPath(env)} → globalRoot`, value: readUserConfig(env).globalRoot || null },
226
+ { source: 'default', key: '~/.kushi-global', value: path.join(home, '.kushi-global') },
227
+ ];
228
+ const winner = sources.find(s => s.value && String(s.value).trim());
229
+ return { resolved: resolveGlobalRoot(env, opts), winner, sources };
230
+ }
231
+
100
232
  function expandTilde(p, env) {
101
233
  if (p.startsWith('~')) {
102
234
  const home = env.USERPROFILE || env.HOME || os.homedir();
@@ -99,6 +99,34 @@ export function seedConfig(sourcePkgDir, destAbsolute) {
99
99
  return result;
100
100
  }
101
101
 
102
+ /**
103
+ * Seed ~/.kushi/config.json (per-user, per-machine settings) using the same
104
+ * seed-once contract as workspace user/ files: copied only if absent, preserved
105
+ * on reinstall unless `force: true` is passed.
106
+ *
107
+ * - Lives at <home>/.kushi/config.json (NOT inside a workspace .kushi/).
108
+ * - Template ships at plugin/templates/user-config.json.
109
+ * - Currently holds `globalRoot` (path to the global wiki). Designed to grow.
110
+ *
111
+ * @param {string} sourcePkgDir – root of the npm package
112
+ * @param {{ force?: boolean, home?: string }} [opts]
113
+ * @returns {{ path: string, action: 'seeded'|'preserved'|'overwritten'|'no-template' }}
114
+ */
115
+ export function seedGlobalUserConfig(sourcePkgDir, opts = {}) {
116
+ const home = opts.home || os.homedir();
117
+ const dst = path.join(home, '.kushi', 'config.json');
118
+ const src = path.join(sourcePkgDir, PLUGIN_SOURCE_DIR, 'templates', 'user-config.json');
119
+ if (!fs.existsSync(src)) return { path: dst, action: 'no-template' };
120
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
121
+
122
+ const exists = fs.existsSync(dst);
123
+ if (exists && !opts.force) {
124
+ return { path: dst, action: 'preserved' };
125
+ }
126
+ fs.cpSync(src, dst, { force: true });
127
+ return { path: dst, action: exists ? 'overwritten' : 'seeded' };
128
+ }
129
+
102
130
  /**
103
131
  * Mechanically derive defaults for freshly-seeded user-config files.
104
132
  * Only modifies files in `seededFiles` (i.e. just-created in this run).
@@ -73,6 +73,17 @@ export async function runSetupWizard({ args = [] } = {}) {
73
73
  const root = (await p.ask(' Where is your engagement root?', detectedRoot, 'KUSHI_WIZARD_ROOT')) || detectedRoot;
74
74
  const hosts = (await p.ask(' Install for clawpilot / vscode / both?', detectedHosts, 'KUSHI_WIZARD_HOSTS')) || detectedHosts;
75
75
  const wantGlobal = (await p.ask(' Enable global wiki at ~/.kushi-global/?', 'y', 'KUSHI_WIZARD_GLOBAL')) || 'y';
76
+ let globalRootChoice = '';
77
+ if (/^y/i.test(wantGlobal)) {
78
+ const envRoot = process.env.KUSHI_GLOBAL_ROOT || '';
79
+ const defaultRoot = envRoot || '(default ~/.kushi-global/)';
80
+ globalRootChoice = (await p.ask(
81
+ ' Global wiki path? (Enter for default; or a OneDrive-synced SharePoint folder for team sharing)',
82
+ defaultRoot,
83
+ 'KUSHI_WIZARD_GLOBAL_ROOT',
84
+ )) || defaultRoot;
85
+ if (globalRootChoice === '(default ~/.kushi-global/)') globalRootChoice = '';
86
+ }
76
87
  const wantWorkspace = (await p.ask(
77
88
  ` Scaffold current directory (${cwd}) as a kushi workspace?${cwdHasKushi ? ' (already present)' : ''}`,
78
89
  defaultWorkspace,
@@ -85,6 +96,7 @@ export async function runSetupWizard({ args = [] } = {}) {
85
96
  engagementRoot: root,
86
97
  hosts: hosts.toLowerCase(),
87
98
  globalWiki: /^y/i.test(wantGlobal),
99
+ globalWikiRoot: globalRootChoice ? globalRootChoice.trim() : '',
88
100
  workspaceInstall: /^y/i.test(wantWorkspace),
89
101
  workspacePath: cwd,
90
102
  };
@@ -128,7 +140,7 @@ export async function runSetupWizard({ args = [] } = {}) {
128
140
  if (answers.globalWiki) {
129
141
  try {
130
142
  const { runGlobalInit } = await import('./global-wiki-cli.mjs');
131
- await runGlobalInit();
143
+ await runGlobalInit({ root: answers.globalWikiRoot || undefined, persistEnv: !!answers.globalWikiRoot });
132
144
  } catch (err) {
133
145
  console.error(` global init skipped: ${err.message}`);
134
146
  }