kushi-agents 5.7.1 → 5.7.3

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 CHANGED
@@ -260,6 +260,7 @@ npx kushi-agents --clawpilot # Clawpilot only
260
260
  npx kushi-agents --vscode # VS Code Chat only
261
261
 
262
262
  # Install to BOTH at once (auto-detects what's present + targets both)
263
+ # v5.7.1+: when run from inside a project dir, also refreshes <cwd>/.kushi/.
263
264
  npx kushi-agents --all-hosts
264
265
 
265
266
  # Uninstall
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kushi-agents",
3
- "version": "5.7.1",
3
+ "version": "5.7.3",
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": {
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "license": "MIT",
45
45
  "scripts": {
46
- "test": "node --test src/check-workiq.test.mjs src/seed-config.test.mjs src/sanitize-workiq-input.test.mjs src/detect-vertex-repo.test.mjs src/vertex-validate.test.mjs src/emit-vertex.e2e.test.mjs src/config-root-resolve.test.mjs src/forbidden-workiq-phrasings.test.mjs src/multi-host-install.test.mjs src/eval-aggregator.test.mjs src/eval-runner.test.mjs src/skill-creator.test.mjs src/skill-checker.test.mjs src/hooks-dispatcher.test.mjs src/parallel-refresh.test.mjs src/otel-emit.test.mjs src/teach.test.mjs src/schema-evolve.test.mjs src/global-wiki.test.mjs src/promote.test.mjs src/doctor.test.mjs src/setup-wizard.test.mjs src/cli-no-args.test.mjs src/cli-no-args-tty.test.mjs src/per-user-files.test.mjs src/layout-portable.test.mjs src/profile-coverage.test.mjs plugin/runners/test/unit/*.test.mjs",
46
+ "test": "node --test src/check-workiq.test.mjs src/seed-config.test.mjs src/sanitize-workiq-input.test.mjs src/detect-vertex-repo.test.mjs src/vertex-validate.test.mjs src/emit-vertex.e2e.test.mjs src/config-root-resolve.test.mjs src/forbidden-workiq-phrasings.test.mjs src/multi-host-install.test.mjs src/eval-aggregator.test.mjs src/eval-runner.test.mjs src/skill-creator.test.mjs src/skill-checker.test.mjs src/hooks-dispatcher.test.mjs src/parallel-refresh.test.mjs src/otel-emit.test.mjs src/teach.test.mjs src/schema-evolve.test.mjs src/global-wiki.test.mjs src/promote.test.mjs src/doctor.test.mjs src/setup-wizard.test.mjs src/cli-no-args.test.mjs src/cli-no-args-tty.test.mjs src/per-user-files.test.mjs src/layout-portable.test.mjs src/profile-coverage.test.mjs src/get-kushi-config.test.mjs src/seed-config-derived.test.mjs plugin/runners/test/unit/*.test.mjs",
47
47
  "test:runners": "node --test plugin/runners/test/unit/*.test.mjs",
48
48
  "test:runners:integration": "node --test plugin/runners/test/integration/*.test.mjs",
49
49
  "test:integration:bootstrap": "node src/bootstrap-dryrun.integration.test.mjs",
@@ -4,6 +4,89 @@ Newest on top. Format defined in [`README.md`](./README.md). Use this file when
4
4
 
5
5
  ---
6
6
 
7
+ ### 2026-05-29 — `<auto>` placeholder check was blocking fresh installs
8
+
9
+ **Symptom**: After a clean `npx kushi-agents@latest --all-hosts` install, the very first command users tried (`& '.\.kushi\lib\Get-KushiConfig.ps1' -Name 'm365-auth'`) threw a placeholder error citing `<auto>`. But `<auto>` is the explicit "Kushi will resolve this at runtime from identity / OS / WorkIQ" sentinel, by design — not an unfilled placeholder.
10
+
11
+ **Root cause**: `<auto>` was wrongly listed alongside `__FILL_ME_IN__` in the blocking-sentinels array of `Get-KushiConfig.ps1`. That conflated two different concepts:
12
+
13
+ - **Hard-blocking placeholders** (`__FILL_ME_IN__`, `<TENANT_ID>`, `<engagement-root>`, `<ProjectA>`): values that genuinely cannot be derived without user input. These should block.
14
+ - **Runtime-resolution sentinels** (`<auto>`, `<YYYY-MM-DD>`): values that Kushi *can* derive at use-time from `os.userInfo()`, `Date.now()`, or WorkIQ whoami. These should NOT block; they're a feature flag for lazy fill.
15
+
16
+ **Fix shipped (v5.7.3, 2026-05-29)**:
17
+
18
+ 1. **Removed `<auto>` from blocking sentinels** in `plugin/lib/Get-KushiConfig.ps1`. Added a comment explaining why so future contributors don't add it back.
19
+ 2. **Added `applyDerivedDefaults()` to `src/seed-config.mjs`** — runs at install time on freshly-seeded user/ files (never touches preserved/edited copies):
20
+ - `m365-auth.json#_meta.last_reviewed` ← today (YYYY-MM-DD)
21
+ - `m365-auth.json#_meta.owner` ← `${os.userInfo().username}@microsoft.com`
22
+ - `m365-auth.json#m365Auth.sharePointContext.localProjectsRoot` ← auto-detected from `$env:OneDriveCommercial`, `~/OneDrive - Microsoft/ISE/Engagement Assets`, or other `OneDrive - <Tenant>/ISE/Engagement Assets` paths if exactly one exists
23
+ - `project-evidence.yml#projects_root` ← same OneDrive auto-detection
24
+ 3. **Install now prints what was auto-filled** under `Config.user/ auto-filled defaults:` so users can see exactly what Kushi figured out and what they still need to edit.
25
+ 4. **Six new unit tests** in `src/seed-config-derived.test.mjs` + an integration test in `src/get-kushi-config.test.mjs` that does a real install and asserts `Get-KushiConfig.ps1 -Name m365-auth` passes (or fails ONLY with `__FILL_ME_IN__`, never `<auto>`).
26
+
27
+ **Lesson**: When a config schema mixes hard-blocking placeholders with explicit "auto-resolve" sentinels, keep them in *separate lists*. Conflating them in one validation pass is a footgun: every new lazy-fill marker becomes a fresh-install bug. Also: install-time derivation is cheaper than runtime resolution — fill what you can mechanically (today's date, OS username, env vars, well-known paths) before a single user prompt is needed.
28
+
29
+ ---
30
+
31
+ ### 2026-05-29 — `[switch]` parameter collides with same-name local var in PowerShell
32
+
33
+ **Symptom**: Calling `.\.kushi\lib\Get-KushiConfig.ps1 -Name 'm365-auth' -Raw` threw at the **call site**:
34
+
35
+ ```
36
+ Get-KushiConfig.ps1: Cannot convert the "{ ...full file contents... }" value of type "System.String" to type "System.Management.Automation.SwitchParameter".
37
+ ```
38
+
39
+ The error wrapped the entire file content as the failed-coercion value — confusing, because the user's invocation only passed `-Name '...' -Raw` (no positional value).
40
+
41
+ **Root cause**: PowerShell variables are **case-insensitive**. The script declared `[switch] $Raw` as a parameter, then used a local `$raw = Get-Content -LiteralPath $resolved -Raw` to read the file. Those are the *same variable*. The string assignment to the typed switch parameter triggered SwitchParameter coercion, which fails on any non-empty string. Crucially, the error is reported at the *call site* (the `&` invocation line), not at the assignment line — so the symptom looked like a parameter-binding bug.
42
+
43
+ **Fix shipped (v5.7.2, 2026-05-29)**: Renamed the local var to `$rawText` everywhere in `Get-KushiConfig.ps1`. Audited all other `[switch]` params in `plugin/lib/*.ps1` for the same collision pattern — none found.
44
+
45
+ **Why it wasn't caught**: 280 unit tests cover only `.mjs` runners. `Get-KushiConfig.ps1` and other shipped `.ps1` libs had **zero** automated test coverage. Added `src/get-kushi-config.test.mjs` (3 tests) that installs kushi into a temp dir and invokes the live shipped script via `pwsh`, asserting no SwitchParameter coercion error in stderr. Now wired into `npm test` (283 tests).
46
+
47
+ **Lesson**: Any `.ps1` file shipped to users needs *integration* tests that invoke the real script via `pwsh`, not just unit tests of the JS that calls it. Avoid using `[switch]` parameter names that collide with common local variable names (`$raw`, `$path`, `$name`, `$content`) — PS case-insensitivity makes the collision invisible until something assigns a value.
48
+
49
+ ---
50
+
51
+ ### 2026-05-29 — Two issues that silently broke `bootstrap` end-to-end on Windows
52
+
53
+ **Symptom**: After v5.7.0 shipped the auto-chain (bootstrap → discover → refresh), users ran the chain on Windows and saw discover return `skipped_reason: EINVAL` for every source, then refresh exit 0 with zero pulls. The runner *appeared* to be working — `status: ok`, no error message — but no boundaries were filled.
54
+
55
+ **Root causes** (two stacked):
56
+
57
+ 1. **Node 20.12+ refuses to spawn `.cmd`/`.bat` files without `shell:true`** (CVE-2024-27980 hardening). `lib/workiq.mjs` was using `spawn(exe, args, { shell: false })` and the Windows workiq distribution ships as a `workiq.cmd` shim. Every spawn returned `EINVAL` immediately. The runner caught it and recorded `skipped_reason: EINVAL` per-source, but didn't escalate — it looked like 7 individual source-not-available skips, not one runtime-level failure.
58
+ 2. **`resolveWorkiqBin()` only checked `~/.copilot/bin/workiq.cmd`**. Users who installed WorkIQ via `npm i -g @microsoft/workiq` had it at `C:\Users\<u>\AppData\Roaming\npm\workiq.cmd` — invisible to kushi, even though `workiq --version` worked fine in their shell.
59
+
60
+ **Fixes shipped (v5.7.1, 2026-05-29)**:
61
+
62
+ 1. **`lib/workiq.mjs` `runProcess()` routes `.cmd`/`.bat` through `cmd.exe`** with `windowsVerbatimArguments: true`. Verbatim args preserve quoting in long CSC prompts.
63
+ 2. **`resolveWorkiqBin()` searches PATH first** (with `PATHEXT`-aware `whichSync`), falls back to `~/.copilot/bin/`. So `npm`-installed workiq is now picked up automatically.
64
+ 3. **Lesson for future runners**: a per-source `skipped_reason` in JSON output isn't a substitute for a top-level error. If every source returns the SAME error code, that's a runtime failure, not 7 missing-source signals. Consider adding a `runtime_error` envelope check in discover that flips the top-level `status` to `error` if ≥N sources skip with the same `EXXX` code.
65
+
66
+ **Discovered during**: Soak test of v5.7.0 in `kushi-wp` workspace — discover printed `{"status":"ok",...}` with all 7 sources `skipped_reason: EINVAL`, refresh ran clean, but no boundary was actually filled. Symptom looked like "WorkIQ has nothing for this project" until the user ran `node -e "spawn(...)"` to repro the EINVAL.
67
+
68
+ ---
69
+
70
+ ### 2026-05-29 — Two-command install (`--all-hosts` then workspace) is the wrong default
71
+
72
+ **Symptom**: User runs:
73
+
74
+ ```
75
+ npx kushi-agents@latest --all-hosts --profile full
76
+ cd <repo-root>-wp
77
+ npx kushi-agents@latest --profile full
78
+ ```
79
+
80
+ …and asks: *"this is weird running both. is there a better way?"*
81
+
82
+ **Root cause**: The CLI dispatched on the presence of host flags. `--all-hosts` took the `runMultiHost()` code path which did host-only installs. `npx kushi-agents` (no flag) took the legacy `main.mjs` workspace-install path. The two paths were mutually exclusive, even though the user's mental model was *"install Kushi everywhere"* — both globally and in the current repo.
83
+
84
+ **Fix shipped (v5.7.1)**: After the host loop in `runMultiHost()`, detect if cwd is a recognized project (uses the existing `findProjectMarker()` from `main.mjs` — `.git`, `package.json`, `.kushi/`, `Evidence/`, etc.). If yes, also call `main({ target: 'vscode', yes: true, force: true })` to refresh the workspace install. Suppress with `--no-workspace`. From a non-project directory, prints `Workspace install skipped` and only does host install.
85
+
86
+ **Result**: `npx kushi-agents@latest --all-hosts --profile full` from inside a repo now does the entire install in one shot. Help text + `docs/getting-started/install.md` updated to recommend the unified form first; legacy two-command form preserved in a `<details>` block for users on shared dev machines.
87
+
88
+ ---
89
+
7
90
  ### 2026-05-29 — `bootstrap → "fill the templates" → refresh` is the wrong default
8
91
 
9
92
  **Symptom**: User runs `bootstrap Northwind`. Runner scaffolds folders + empty `boundaries.yml` + empty `integrations.yml`. SKILL.md tells the agent to reply *"now fill these two files and run refresh"*. User runs `refresh` immediately; refresh emits a configuration-blocked report and exits 0 with zero pulls. User's reaction: *"we know we need to fill them, why did the tool stop and ask? is this not a methodical do?"*
@@ -79,7 +79,10 @@ $script:RequiredFields = @{
79
79
  # Sentinel substrings that indicate "still a placeholder."
80
80
  $script:Sentinels = @(
81
81
  '__FILL_ME_IN__',
82
- '<auto>',
82
+ # NOTE: '<auto>' is intentionally NOT in this list. It is a runtime-resolution
83
+ # marker meaning "Kushi will derive this from identity/env at use time" — not
84
+ # an unfilled placeholder. Callers that need a concrete value should resolve
85
+ # <auto> themselves (e.g. via WorkIQ whoami or os.userInfo()).
83
86
  '<TENANT_ID>',
84
87
  '<NOTEBOOK_ID>',
85
88
  '<NOTEBOOK_NAME>',
@@ -87,7 +90,9 @@ $script:Sentinels = @(
87
90
  '<SP_LOCAL_ROOT>',
88
91
  '<your-alias>',
89
92
  '<Your Full Name>',
90
- 'your.email@example.com'
93
+ 'your.email@example.com',
94
+ '<engagement-root>',
95
+ '<ProjectA>'
91
96
  )
92
97
 
93
98
  function Test-Sentinel {
@@ -180,12 +185,12 @@ Run ``npx kushi-agents@latest`` (vscode) or ``npx kushi-agents@latest --clawpilo
180
185
 
181
186
  if ($Path) { return $resolved }
182
187
 
183
- $raw = Get-Content -LiteralPath $resolved -Raw
188
+ $rawText = Get-Content -LiteralPath $resolved -Raw
184
189
 
185
190
  if (-not $AllowPlaceholders) {
186
191
  $sentinelHit = $false
187
192
  foreach ($s in $script:Sentinels) {
188
- if ($raw -like "*$s*") { $sentinelHit = $true; break }
193
+ if ($rawText -like "*$s*") { $sentinelHit = $true; break }
189
194
  }
190
195
  if ($sentinelHit) {
191
196
  throw @"
@@ -195,16 +200,16 @@ Edit the file with your actual values, or pass -AllowPlaceholders to bypass this
195
200
  }
196
201
  }
197
202
 
198
- if ($Raw) { return $raw }
203
+ if ($Raw) { return $rawText }
199
204
 
200
205
  $parsed = switch ($ext) {
201
206
  'json' {
202
- $raw | ConvertFrom-Json -Depth 100
207
+ $rawText | ConvertFrom-Json -Depth 100
203
208
  }
204
209
  { $_ -in 'yml','yaml' } {
205
210
  if (Get-Module -ListAvailable -Name 'powershell-yaml') {
206
211
  Import-Module powershell-yaml -ErrorAction Stop
207
- ConvertFrom-Yaml -Yaml $raw
212
+ ConvertFrom-Yaml -Yaml $rawText
208
213
  } else {
209
214
  Write-Warning "powershell-yaml not installed; required-field validation skipped. Install with: Install-Module powershell-yaml -Scope CurrentUser"
210
215
  $null
@@ -223,5 +228,5 @@ Edit the file with your actual values, or pass -AllowPlaceholders to bypass this
223
228
  }
224
229
  }
225
230
 
226
- if ($null -eq $parsed) { return $raw }
231
+ if ($null -eq $parsed) { return $rawText }
227
232
  return $parsed
@@ -0,0 +1,92 @@
1
+ // Regression tests for plugin/lib/Get-KushiConfig.ps1.
2
+ //
3
+ // History: v5.7.1 had a SwitchParameter coercion bug. The script's
4
+ // [switch] $Raw parameter collided with a local $raw = Get-Content variable
5
+ // (PowerShell variables are case-insensitive). Assigning a string into the
6
+ // typed switch parameter caused "Cannot convert String to SwitchParameter"
7
+ // at the call site whenever the script was invoked from outside its own
8
+ // scope.
9
+ //
10
+ // These tests install kushi into a temp dir, then invoke the live shipped
11
+ // Get-KushiConfig.ps1 with various flag combinations and assert that the
12
+ // invocation succeeds with no error stream output.
13
+
14
+ import { test } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import { spawnSync } from 'node:child_process';
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
23
+ const REPO = path.resolve(HERE, '..');
24
+
25
+ function makeTmp(prefix) {
26
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
27
+ }
28
+
29
+ function freshInstall() {
30
+ const cwd = makeTmp('kushi-getconfig-');
31
+ const r = spawnSync(process.execPath, [path.join(REPO, 'bin', 'cli.mjs'), '--profile', 'full', '--skip-workiq-check', '--yes'], {
32
+ cwd, encoding: 'utf-8', shell: false,
33
+ });
34
+ assert.equal(r.status, 0, `installer failed: ${r.stderr || r.stdout}`);
35
+ return cwd;
36
+ }
37
+
38
+ function pwsh(cwd, args) {
39
+ return spawnSync('pwsh', ['-NoProfile', '-Command', ...args], {
40
+ cwd, encoding: 'utf-8', shell: false,
41
+ });
42
+ }
43
+
44
+ test('Get-KushiConfig.ps1 -Name m365-auth -Raw does not raise SwitchParameter coercion error', () => {
45
+ const cwd = freshInstall();
46
+ const script = path.join(cwd, '.kushi', 'lib', 'Get-KushiConfig.ps1');
47
+ const r = pwsh(cwd, [`& '${script}' -Name 'm365-auth' -Raw -AllowPlaceholders | Out-Null; if ($?) { 'OK' } else { 'FAIL' }`]);
48
+ assert.equal(r.status, 0, `pwsh exited ${r.status}; stderr=${r.stderr}`);
49
+ assert.match(r.stdout, /OK/, `expected OK, got stdout=${r.stdout} stderr=${r.stderr}`);
50
+ assert.doesNotMatch(r.stderr, /SwitchParameter/i, `SwitchParameter coercion error leaked: ${r.stderr}`);
51
+ });
52
+
53
+ test('Get-KushiConfig.ps1 -Name project-evidence -AllowPlaceholders -Raw works on fresh install', () => {
54
+ const cwd = freshInstall();
55
+ const script = path.join(cwd, '.kushi', 'lib', 'Get-KushiConfig.ps1');
56
+ const r = pwsh(cwd, [`& '${script}' -Name 'project-evidence' -AllowPlaceholders -Raw | Out-Null; if ($?) { 'OK' } else { 'FAIL' }`]);
57
+ assert.equal(r.status, 0, `pwsh exited ${r.status}; stderr=${r.stderr}`);
58
+ assert.match(r.stdout, /OK/, `expected OK, got stdout=${r.stdout} stderr=${r.stderr}`);
59
+ assert.doesNotMatch(r.stderr, /SwitchParameter/i, `SwitchParameter coercion error leaked: ${r.stderr}`);
60
+ });
61
+
62
+ test('Get-KushiConfig.ps1 -Path returns absolute resolved path', () => {
63
+ const cwd = freshInstall();
64
+ const script = path.join(cwd, '.kushi', 'lib', 'Get-KushiConfig.ps1');
65
+ const r = pwsh(cwd, [`& '${script}' -Name 'm365-auth' -Path`]);
66
+ assert.equal(r.status, 0, `pwsh exited ${r.status}; stderr=${r.stderr}`);
67
+ assert.match(r.stdout, /m365-auth\.json/, `expected resolved path, got stdout=${r.stdout}`);
68
+ assert.doesNotMatch(r.stderr, /SwitchParameter/i);
69
+ });
70
+
71
+ test('Get-KushiConfig.ps1 -Name m365-auth (no -AllowPlaceholders) passes on fresh install with auto-filled defaults', () => {
72
+ // Regression for: bootstrap rejected fresh installs because the template's
73
+ // <auto>, <YYYY-MM-DD>, and __FILL_ME_IN__ markers all triggered the
74
+ // placeholder check. Fix: <auto> removed from blocking sentinels (it's a
75
+ // runtime-resolve marker), and seed-config now mechanically fills
76
+ // last_reviewed (today), _meta.owner (from $env:USER), and localProjectsRoot
77
+ // (auto-detected from OneDrive) at install time.
78
+ const cwd = freshInstall();
79
+ const script = path.join(cwd, '.kushi', 'lib', 'Get-KushiConfig.ps1');
80
+ const r = pwsh(cwd, [`& '${script}' -Name 'm365-auth' -Raw | Out-Null; if ($?) { 'OK' } else { 'FAIL' }`]);
81
+ // Note: localProjectsRoot may still be __FILL_ME_IN__ on machines with no OneDrive,
82
+ // so accept either (a) clean pass, or (b) a placeholder error that mentions ONLY
83
+ // __FILL_ME_IN__ (not <auto>, not <YYYY-MM-DD>).
84
+ if (r.stdout.includes('OK')) {
85
+ assert.doesNotMatch(r.stderr, /SwitchParameter/i);
86
+ return;
87
+ }
88
+ // If it failed, the only acceptable reason is genuine __FILL_ME_IN__ on machines
89
+ // without an auto-detectable OneDrive root.
90
+ assert.match(r.stderr, /__FILL_ME_IN__/, `expected only __FILL_ME_IN__ blocker, got: ${r.stderr}`);
91
+ assert.doesNotMatch(r.stderr, /<auto>/, `<auto> must NOT block — it is a runtime-resolve marker`);
92
+ });
package/src/main.mjs CHANGED
@@ -231,6 +231,10 @@ function printSeedReport(r, dispDestSlash) {
231
231
  for (const f of r.seededUser) console.log(` - ${f} -> seeded (edit before first bootstrap)`);
232
232
  for (const f of r.preservedUser) console.log(` - ${f} -> preserved (already exists)`);
233
233
  }
234
+ if (r.derived && r.derived.length) {
235
+ console.log(` auto-filled defaults:`);
236
+ for (const d of r.derived) console.log(` - ${d.file}#${d.field} -> ${d.value}`);
237
+ }
234
238
  if (r.gitignore === 'created' || r.gitignore === 'appended') {
235
239
  console.log(` .gitignore ${r.gitignore === 'created' ? 'created' : 'appended'} -> config/user/ excluded`);
236
240
  }
@@ -0,0 +1,117 @@
1
+ // Tests for applyDerivedDefaults() in seed-config.mjs.
2
+ //
3
+ // Verifies that mechanically-derivable fields (today's date, OS-derived
4
+ // identity, OneDrive auto-detection) are filled at install time so a fresh
5
+ // install passes the Get-KushiConfig.ps1 placeholder check without manual
6
+ // editing or `@Kushi setup` rituals.
7
+
8
+ import { test } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { seedConfig } from './seed-config.mjs';
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ const PKG = path.resolve(__dirname, '..');
18
+
19
+ function tmp() {
20
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'kushi-derived-'));
21
+ }
22
+
23
+ test('applyDerivedDefaults: fills last_reviewed with today (m365-auth.json)', () => {
24
+ const dest = tmp();
25
+ try {
26
+ const r = seedConfig(PKG, dest);
27
+ assert.ok(r.seededUser.includes('m365-auth.json'));
28
+
29
+ const cfgPath = path.join(dest, 'config', 'user', 'm365-auth.json');
30
+ const obj = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
31
+ const today = new Date().toISOString().slice(0, 10);
32
+ assert.equal(obj._meta.last_reviewed, today, 'last_reviewed should be today');
33
+ assert.notEqual(obj._meta.last_reviewed, '<YYYY-MM-DD>', 'placeholder should be replaced');
34
+
35
+ assert.ok(r.derived.some((d) => d.field === '_meta.last_reviewed'), 'derived list should record last_reviewed');
36
+ } finally {
37
+ fs.rmSync(dest, { recursive: true, force: true });
38
+ }
39
+ });
40
+
41
+ test('applyDerivedDefaults: fills _meta.owner from OS username', () => {
42
+ const dest = tmp();
43
+ try {
44
+ seedConfig(PKG, dest);
45
+ const cfgPath = path.join(dest, 'config', 'user', 'm365-auth.json');
46
+ const obj = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
47
+ const expectedUser = (os.userInfo().username || '').toLowerCase().replace(/[^a-z0-9._-]/g, '');
48
+ assert.equal(obj._meta.owner, `${expectedUser}@microsoft.com`);
49
+ assert.notEqual(obj._meta.owner, '<alias>@microsoft.com');
50
+ } finally {
51
+ fs.rmSync(dest, { recursive: true, force: true });
52
+ }
53
+ });
54
+
55
+ test('applyDerivedDefaults: leaves <auto> identity markers alone (resolved at runtime)', () => {
56
+ const dest = tmp();
57
+ try {
58
+ seedConfig(PKG, dest);
59
+ const cfgPath = path.join(dest, 'config', 'user', 'm365-auth.json');
60
+ const obj = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
61
+ // <auto> in defaultLinkOwner is a runtime-resolution sentinel; seed-config
62
+ // must NOT preempt it (callers like pull-onenote resolve via WorkIQ whoami).
63
+ assert.equal(obj.m365Auth.oneNote.defaultLinkOwner, '<auto>');
64
+ } finally {
65
+ fs.rmSync(dest, { recursive: true, force: true });
66
+ }
67
+ });
68
+
69
+ test('applyDerivedDefaults: produces valid JSON (no syntax errors)', () => {
70
+ const dest = tmp();
71
+ try {
72
+ seedConfig(PKG, dest);
73
+ const cfgPath = path.join(dest, 'config', 'user', 'm365-auth.json');
74
+ const txt = fs.readFileSync(cfgPath, 'utf8');
75
+ assert.doesNotThrow(() => JSON.parse(txt), 'seeded m365-auth.json must be parseable');
76
+ assert.ok(txt.endsWith('\n'), 'should end with newline');
77
+ } finally {
78
+ fs.rmSync(dest, { recursive: true, force: true });
79
+ }
80
+ });
81
+
82
+ test('applyDerivedDefaults: project-evidence.yml stays parseable as YAML', () => {
83
+ const dest = tmp();
84
+ try {
85
+ seedConfig(PKG, dest);
86
+ const cfgPath = path.join(dest, 'config', 'user', 'project-evidence.yml');
87
+ const txt = fs.readFileSync(cfgPath, 'utf8');
88
+ // Sanity: top-level keys still present
89
+ assert.match(txt, /^alias:/m);
90
+ assert.match(txt, /^email:/m);
91
+ assert.match(txt, /^projects_root:/m);
92
+ } finally {
93
+ fs.rmSync(dest, { recursive: true, force: true });
94
+ }
95
+ });
96
+
97
+ test('applyDerivedDefaults: does not re-derive on reinstall when files preserved', () => {
98
+ const dest = tmp();
99
+ try {
100
+ seedConfig(PKG, dest);
101
+ const cfgPath = path.join(dest, 'config', 'user', 'm365-auth.json');
102
+ // Edit the seeded file as a user would
103
+ const obj = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
104
+ obj._meta.last_reviewed = '2020-01-01';
105
+ fs.writeFileSync(cfgPath, JSON.stringify(obj, null, 2) + '\n');
106
+
107
+ // Reinstall — file is preserved, derived must not re-run on it
108
+ const r2 = seedConfig(PKG, dest);
109
+ assert.ok(r2.preservedUser.includes('m365-auth.json'));
110
+ assert.equal(r2.derived.length, 0, 'derived should be empty when no files were freshly seeded');
111
+
112
+ const after = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
113
+ assert.equal(after._meta.last_reviewed, '2020-01-01', 'user-edited value preserved');
114
+ } finally {
115
+ fs.rmSync(dest, { recursive: true, force: true });
116
+ }
117
+ });
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
3
4
  import {
4
5
  CONFIG_DIR,
@@ -86,12 +87,92 @@ export function seedConfig(sourcePkgDir, destAbsolute) {
86
87
  overwriteAlways(CONFIG_DOCTRINE_FILES, sharedDir, result.doctrine);
87
88
  overwriteAlways(CONFIG_EXAMPLE_FILES, sharedDir, result.examples);
88
89
 
90
+ // Post-process freshly-seeded user/ files: mechanically derive defaults
91
+ // (today's date, OneDrive root, OS-derived identity) so a brand-new install
92
+ // can be used immediately without manual editing or `@Kushi setup` rituals.
93
+ // Only runs on files we just seeded — never touches user-edited copies.
94
+ result.derived = applyDerivedDefaults(userDir, result.seededUser);
95
+
89
96
  // .gitignore at <dest>/.gitignore — append our marker block if not already present
90
97
  result.gitignore = ensureGitignore(destAbsolute, CONFIG_GITIGNORE_LINES);
91
98
 
92
99
  return result;
93
100
  }
94
101
 
102
+ /**
103
+ * Mechanically derive defaults for freshly-seeded user-config files.
104
+ * Only modifies files in `seededFiles` (i.e. just-created in this run).
105
+ * Returns a list of {file, field, value} for the seed report.
106
+ */
107
+ export function applyDerivedDefaults(userDir, seededFiles) {
108
+ const today = new Date().toISOString().slice(0, 10);
109
+ const username = (os.userInfo().username || '').toLowerCase().replace(/[^a-z0-9._-]/g, '');
110
+ const oneDriveRoot = detectOneDriveProjectsRoot();
111
+ const filled = [];
112
+
113
+ if (seededFiles.includes('m365-auth.json')) {
114
+ const f = path.join(userDir, 'm365-auth.json');
115
+ try {
116
+ const txt = fs.readFileSync(f, 'utf8');
117
+ const obj = JSON.parse(txt);
118
+ if (obj?._meta?.last_reviewed === '<YYYY-MM-DD>') {
119
+ obj._meta.last_reviewed = today; filled.push({ file: 'm365-auth.json', field: '_meta.last_reviewed', value: today });
120
+ }
121
+ if (username && obj?._meta?.owner === '<alias>@microsoft.com') {
122
+ obj._meta.owner = `${username}@microsoft.com`; filled.push({ file: 'm365-auth.json', field: '_meta.owner', value: obj._meta.owner });
123
+ }
124
+ if (oneDriveRoot && obj?.m365Auth?.sharePointContext?.localProjectsRoot === '__FILL_ME_IN__') {
125
+ obj.m365Auth.sharePointContext.localProjectsRoot = oneDriveRoot;
126
+ filled.push({ file: 'm365-auth.json', field: 'm365Auth.sharePointContext.localProjectsRoot', value: oneDriveRoot });
127
+ }
128
+ fs.writeFileSync(f, JSON.stringify(obj, null, 2) + '\n', 'utf8');
129
+ } catch { /* leave file as-is on parse error */ }
130
+ }
131
+
132
+ if (seededFiles.includes('project-evidence.yml')) {
133
+ const f = path.join(userDir, 'project-evidence.yml');
134
+ try {
135
+ let txt = fs.readFileSync(f, 'utf8');
136
+ if (oneDriveRoot && txt.includes("projects_root: '<engagement-root>'")) {
137
+ txt = txt.replace("projects_root: '<engagement-root>'", `projects_root: '${oneDriveRoot}'`);
138
+ filled.push({ file: 'project-evidence.yml', field: 'projects_root', value: oneDriveRoot });
139
+ }
140
+ fs.writeFileSync(f, txt, 'utf8');
141
+ } catch { /* leave file as-is on read error */ }
142
+ }
143
+
144
+ return filled;
145
+ }
146
+
147
+ /**
148
+ * Best-effort detection of the user's "Engagement Assets" parent folder.
149
+ * Probes (in order):
150
+ * 1. $env:OneDriveCommercial/ISE/Engagement Assets (Windows env-var)
151
+ * 2. ~/OneDrive - Microsoft/ISE/Engagement Assets (canonical Microsoft consultant path)
152
+ * 3. ~/OneDrive - <Tenant>/ISE/Engagement Assets (single OneDrive-* folder match)
153
+ * Returns the first existing path or null.
154
+ */
155
+ function detectOneDriveProjectsRoot() {
156
+ const candidates = [];
157
+ if (process.env.OneDriveCommercial) {
158
+ candidates.push(path.join(process.env.OneDriveCommercial, 'ISE', 'Engagement Assets'));
159
+ }
160
+ candidates.push(path.join(os.homedir(), 'OneDrive - Microsoft', 'ISE', 'Engagement Assets'));
161
+ // Probe other OneDrive - * tenants (e.g. for non-Microsoft consultants)
162
+ try {
163
+ const home = os.homedir();
164
+ for (const entry of fs.readdirSync(home, { withFileTypes: true })) {
165
+ if (entry.isDirectory() && entry.name.startsWith('OneDrive - ') && entry.name !== 'OneDrive - Microsoft') {
166
+ candidates.push(path.join(home, entry.name, 'ISE', 'Engagement Assets'));
167
+ }
168
+ }
169
+ } catch { /* homedir not readable - skip */ }
170
+ for (const p of candidates) {
171
+ try { if (fs.statSync(p).isDirectory()) return p; } catch { /* not present */ }
172
+ }
173
+ return null;
174
+ }
175
+
95
176
  function ensureGitignore(destAbsolute, lines) {
96
177
  const target = path.join(destAbsolute, '.gitignore');
97
178
  const marker = lines[0];