kushi-agents 5.8.0 → 5.8.2

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
@@ -206,6 +206,8 @@ if (args.includes('--help') || args.includes('-h')) {
206
206
  --yes, -y Skip the project-root check
207
207
  --no-settings Skip .vscode/settings.json update (vscode workspace target only)
208
208
  --no-instructions Skip .github/copilot-instructions.md merge (vscode workspace target only)
209
+ --no-prompt Skip the post-install m365-auth quickstart (3 fields that
210
+ make 'kushi discover' fast). Also disabled by KUSHI_NO_PROMPT=1.
209
211
 
210
212
  WorkIQ (REQUIRED — Kushi cannot pull evidence without it):
211
213
  --with-workiq Auto-install WorkIQ via winget (Windows) / brew (macOS)
@@ -281,6 +283,7 @@ if (wantsVscode || wantsAllHosts || wantsUninstall) {
281
283
  uninstall: wantsUninstall,
282
284
  profile: getFlag('--profile'),
283
285
  includeWorkspace: !args.includes('--no-workspace'),
286
+ noPrompt: args.includes('--no-prompt'),
284
287
  }).catch((err) => {
285
288
  console.error(`\n ${err.message}\n`);
286
289
  process.exit(1);
@@ -300,6 +303,7 @@ if (wantsVscode || wantsAllHosts || wantsUninstall) {
300
303
  yes: args.includes('--yes') || args.includes('-y'),
301
304
  noSettings: args.includes('--no-settings'),
302
305
  noInstructions: args.includes('--no-instructions'),
306
+ noPrompt: args.includes('--no-prompt'),
303
307
  target,
304
308
  profile: getFlag('--profile'),
305
309
  withWorkiq: args.includes('--with-workiq'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kushi-agents",
3
- "version": "5.8.0",
3
+ "version": "5.8.2",
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": {
@@ -61,9 +61,7 @@ $script:RequiredFields = @{
61
61
  'm365-auth' = @(
62
62
  'm365Auth.defaultTenantId',
63
63
  'm365Auth.oneNote.defaultNotebookName',
64
- 'm365Auth.oneNote.defaultNotebookId',
65
64
  'm365Auth.oneNote.defaultLinkOwner',
66
- 'm365Auth.emailContext.folders',
67
65
  'm365Auth.sharePointContext.localProjectsRoot'
68
66
  )
69
67
  'project-evidence' = @(
@@ -49,7 +49,10 @@ function emit(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); }
49
49
  function describeScope(source, scope) {
50
50
  if (!scope || scope.enabled === false) return null;
51
51
  const parts = [];
52
- if (source === 'email' && scope.folders?.length) parts.push(`${scope.folders.length} folder(s)`);
52
+ if (source === 'email') {
53
+ if (scope.folders?.length) parts.push(`${scope.folders.length} folder(s)`);
54
+ else parts.push('default Inbox+subfolders');
55
+ }
53
56
  if (scope.dateFloor) parts.push(`from ${scope.dateFloor}`);
54
57
  if (source === 'onenote' && scope.notebookName) parts.push(`notebook "${scope.notebookName}"`);
55
58
  return parts.length ? 'scope: ' + parts.join(', ') : null;
@@ -82,13 +85,22 @@ function buildPrompt(source, projectName, scope = null) {
82
85
  // search, typically dropping query time from 60–180s to 5–15s.
83
86
  if (scope && scope.enabled !== false) {
84
87
  if (source === 'email') {
85
- if (Array.isArray(scope.folders) && scope.folders.length > 0) {
86
- lines.push('');
87
- lines.push('Restrict your search to ONLY these Outlook mail folders:');
88
- for (const f of scope.folders) {
89
- lines.push(` • "${f}"${scope.includeSubfolders ? ' (and all subfolders)' : ''}`);
90
- }
91
- lines.push('Do NOT scan any other mailbox folders.');
88
+ const folders = (Array.isArray(scope.folders) && scope.folders.length > 0)
89
+ ? scope.folders
90
+ : ['Inbox']; // safe default bounds the query so WorkIQ uses Graph filter, not mailbox-wide semantic search
91
+ const isDefault = !(Array.isArray(scope.folders) && scope.folders.length > 0);
92
+ lines.push('');
93
+ if (scope.fuzzy !== false) {
94
+ lines.push('Restrict your search to Outlook mail folders whose name CONTAINS any of these tokens (case-insensitive, fuzzy substring match — e.g. "FDE" matches "1. FDE", "01 FDE Active", "FDE-archive"):');
95
+ } else {
96
+ lines.push('Restrict your search to ONLY these Outlook mail folders (exact name match):');
97
+ }
98
+ for (const f of folders) {
99
+ lines.push(` • "${f}"${scope.includeSubfolders ? ' (and all subfolders)' : ''}`);
100
+ }
101
+ lines.push('Do NOT scan any other mailbox folders.');
102
+ if (isDefault) {
103
+ lines.push('(Note: no project-specific folders configured — defaulting to Inbox+subfolders. For faster, more accurate results, populate emailContext.folders in m365-auth.json.)');
92
104
  }
93
105
  if (scope.dateFloor) lines.push(`Only consider mail received on or after ${scope.dateFloor}.`);
94
106
  } else if (source === 'teams') {
@@ -62,11 +62,14 @@ export function scopeForSource(m365Auth, source) {
62
62
  case 'email': {
63
63
  const ec = m365Auth.emailContext || {};
64
64
  if (ec.enabled === false) return { enabled: false };
65
+ const mp = ec.matchingPolicy || {};
65
66
  return {
66
67
  enabled: true,
67
68
  folders: cleanArr(ec.folders),
68
69
  includeSubfolders: ec.includeSubfolders !== false,
69
70
  dateFloor: cleanStr(ec.dateFloor),
71
+ fuzzy: mp.alwaysFuzzy !== false,
72
+ matchMode: mp.mode || 'hybrid',
70
73
  };
71
74
  }
72
75
  case 'teams': {
package/src/main.mjs CHANGED
@@ -17,6 +17,7 @@ import { installRunnerDeps } from './install-runner-deps.mjs';
17
17
  import { mergeSettings } from './settings.mjs';
18
18
  import { mergeCopilotInstructions } from './copilot-instructions.mjs';
19
19
  import { seedConfig } from './seed-config.mjs';
20
+ import { runM365Quickstart } from './quickstart-m365.mjs';
20
21
  import { checkWorkIQ, pingWorkIQ, tryInstallWorkIQ } from './check-workiq.mjs';
21
22
  import {
22
23
  resolveProfile,
@@ -115,6 +116,15 @@ async function installVscode(options, resolved, version) {
115
116
  const seedRes = seedConfig(PKG_ROOT, fullDest);
116
117
  printSeedReport(seedRes, `${dest}/`);
117
118
 
119
+ // v5.8.1: prompt for the 3 fields that most affect discover speed.
120
+ // Skips silently when --no-prompt is set, when not running in a TTY, or
121
+ // when all 3 fields are already populated (re-install case).
122
+ const noPrompt = options.noPrompt || process.env.KUSHI_NO_PROMPT === '1';
123
+ const qs = await runM365Quickstart({ destRoot: fullDest, noPrompt });
124
+ if (qs.ran === false && qs.reason && !['already-populated', 'no-prompt-flag'].includes(qs.reason)) {
125
+ console.log(` Quickstart skipped: ${qs.reason}`);
126
+ }
127
+
118
128
  if (!options.noSettings) {
119
129
  const { created, keysAdded, keysUnchanged } = mergeSettings(
120
130
  projectRoot,
@@ -299,6 +299,7 @@ export async function runMultiHost(opts) {
299
299
  force: true,
300
300
  profile: opts.profile,
301
301
  skipWorkiqCheck: true,
302
+ noPrompt: opts.noPrompt,
302
303
  });
303
304
  } catch (err) {
304
305
  console.error(` Workspace install failed: ${err.message}`);
@@ -0,0 +1,177 @@
1
+ // src/quickstart-m365.mjs
2
+ // Post-install interactive prompt for the 3 fields that most affect kushi
3
+ // `discover` wall time (folders, dateFloor via lookback-days, OneNote notebook).
4
+ // Runs at the end of `installWorkspace()` when the destination just got a fresh
5
+ // m365-auth.json seed. Skips silently when not a TTY or when --no-prompt is set.
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ const SENTINELS = [/__FILL_ME_IN__/, /^<[A-Za-z][^>]*>$/, /^<auto>$/];
11
+ function looksLikeSentinel(s) {
12
+ if (!s) return true;
13
+ return SENTINELS.some((re) => re.test(s));
14
+ }
15
+
16
+ function isoDateNDaysAgo(n) {
17
+ const d = new Date();
18
+ d.setDate(d.getDate() - n);
19
+ return d.toISOString().slice(0, 10);
20
+ }
21
+
22
+ /**
23
+ * Run the 4-question quickstart. Returns a small report object:
24
+ * { ran: bool, reason?: string, file?: string, fields?: string[] }
25
+ *
26
+ * @param {object} opts
27
+ * @param {string} opts.destRoot - absolute path to .kushi/ (the workspace install root)
28
+ * @param {boolean} [opts.force] - overwrite existing non-empty values
29
+ * @param {boolean} [opts.noPrompt]- skip prompting (CI / scripted installs)
30
+ */
31
+ export async function runM365Quickstart({ destRoot, force = false, noPrompt = false } = {}) {
32
+ if (noPrompt) return { ran: false, reason: 'no-prompt-flag' };
33
+ if (!process.stdin.isTTY || !process.stderr.isTTY) return { ran: false, reason: 'not-a-tty' };
34
+
35
+ const target = path.join(destRoot, 'config', 'user', 'm365-auth.json');
36
+ const projectEvidenceTarget = path.join(destRoot, 'config', 'user', 'project-evidence.yml');
37
+ if (!fs.existsSync(target)) return { ran: false, reason: 'm365-auth-not-found' };
38
+
39
+ let parsed;
40
+ try { parsed = JSON.parse(fs.readFileSync(target, 'utf8')); }
41
+ catch (e) { return { ran: false, reason: `parse-error: ${e.message}` }; }
42
+ parsed.m365Auth ??= {};
43
+ const m = parsed.m365Auth;
44
+ m.emailContext ??= {};
45
+ m.teamsChatContext ??= {};
46
+ m.calendarContext ??= {};
47
+ m.oneNote ??= {};
48
+ m.sharePointContext ??= {};
49
+
50
+ // Skip silently if all 4 fields are already populated and --force not passed.
51
+ const foldersSet = Array.isArray(m.emailContext.folders) && m.emailContext.folders.length > 0;
52
+ const floorSet = !looksLikeSentinel(m.emailContext.dateFloor);
53
+ const notebookSet = !looksLikeSentinel(m.oneNote.defaultNotebookName);
54
+ const rootSet = !looksLikeSentinel(m.sharePointContext.localProjectsRoot);
55
+ if (foldersSet && floorSet && notebookSet && rootSet && !force) {
56
+ return { ran: false, reason: 'already-populated' };
57
+ }
58
+
59
+ const readline = await import('node:readline/promises');
60
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true });
61
+ const ask = async (q, def) => {
62
+ const ans = (await rl.question(`${q} ${def != null && def !== '' ? `[${def}]` : ''} > `)).trim();
63
+ return ans === '' ? def : ans;
64
+ };
65
+
66
+ process.stderr.write('\n ┌─ Quickstart: 4 questions that make `kushi discover` fast ──\n');
67
+ process.stderr.write(` │ Editing: ${target}\n`);
68
+ process.stderr.write(' │ Press Enter at any prompt to keep the shown default.\n');
69
+ process.stderr.write(' └────────────────────────────────────────────────────────────\n\n');
70
+
71
+ const fields = [];
72
+
73
+ // 1. Engagement root (where projects live on disk)
74
+ process.stderr.write(' [1/4] Where do your engagement project folders live on disk?\n');
75
+ process.stderr.write(' This is the parent folder containing each <project>/ directory.\n');
76
+ process.stderr.write(' Example: C:\\Users\\<you>\\OneDrive - Microsoft\\ISE\\Engagement Assets\n');
77
+ const curRoot = m.sharePointContext.localProjectsRoot || '';
78
+ const rootAns = await ask(' Engagement root', curRoot);
79
+ let engagementRoot = null;
80
+ if (rootAns) {
81
+ if (!fs.existsSync(rootAns)) {
82
+ process.stderr.write(` ⚠ Path does not exist: ${rootAns}\n`);
83
+ process.stderr.write(' Saving anyway — create it before your first bootstrap, or re-run --force to change.\n');
84
+ }
85
+ if (rootAns !== curRoot) {
86
+ m.sharePointContext.localProjectsRoot = rootAns;
87
+ if (m.sharePointContext.enabled == null) m.sharePointContext.enabled = true;
88
+ fields.push(`sharePointContext.localProjectsRoot=${rootAns}`);
89
+ }
90
+ engagementRoot = rootAns;
91
+ }
92
+
93
+ // 2. Email folders
94
+ process.stderr.write('\n [2/4] Which Outlook mail folders contain your project mail?\n');
95
+ process.stderr.write(' Examples:\n');
96
+ process.stderr.write(' • "1. FDE, 99. FDE Not Active" (Microsoft FDE consultant)\n');
97
+ process.stderr.write(' • "Inbox, Archive, Projects" (generic)\n');
98
+ process.stderr.write(' • "Inbox" (everything in inbox)\n');
99
+ const curFolders = Array.isArray(m.emailContext.folders) ? m.emailContext.folders.join(', ') : '';
100
+ const foldersDefault = curFolders || '1. FDE, 99. FDE Not Active';
101
+ const foldersAns = await ask(' Comma-separated folder names', foldersDefault);
102
+ if (foldersAns && foldersAns !== curFolders) {
103
+ const arr = foldersAns.split(',').map(s => s.trim()).filter(Boolean);
104
+ m.emailContext.folders = arr;
105
+ if (m.emailContext.includeSubfolders == null) m.emailContext.includeSubfolders = true;
106
+ if (m.emailContext.enabled == null) m.emailContext.enabled = true;
107
+ fields.push(`emailContext.folders=[${arr.join(', ')}]`);
108
+ } else if (!foldersAns) {
109
+ process.stderr.write(' (skipped — discover will scan the whole mailbox, which can take minutes per source)\n');
110
+ }
111
+
112
+ // 3. Look-back days
113
+ process.stderr.write('\n [3/4] How many days back should discover scan? (smaller = faster)\n');
114
+ process.stderr.write(' Recommended: 60 for active engagements, 90 for setup.\n');
115
+ const curFloor = m.emailContext.dateFloor;
116
+ const defaultDays = curFloor && /^\d{4}-\d{2}-\d{2}$/.test(curFloor)
117
+ ? Math.max(1, Math.round((Date.now() - Date.parse(curFloor)) / 86400000))
118
+ : 60;
119
+ const daysAns = await ask(' Look-back days', defaultDays);
120
+ const days = Number(daysAns);
121
+ if (Number.isFinite(days) && days > 0 && days <= 3650) {
122
+ const floor = isoDateNDaysAgo(days);
123
+ m.emailContext.dateFloor = floor;
124
+ m.teamsChatContext.dateFloor = floor;
125
+ m.calendarContext.dateFloor = floor;
126
+ fields.push(`dateFloor=${floor} (${days} days)`);
127
+ } else {
128
+ process.stderr.write(` ⚠ "${daysAns}" is not a valid number 1–3650 — skipping dateFloor.\n`);
129
+ }
130
+
131
+ // 4. OneNote notebook
132
+ process.stderr.write('\n [4/4] Which OneNote notebook holds your project notes?\n');
133
+ process.stderr.write(' Default for Microsoft consultants: "ISE Work"\n');
134
+ const curNb = m.oneNote.defaultNotebookName || '';
135
+ const nbAns = await ask(' Notebook name', curNb || 'ISE Work');
136
+ if (nbAns && nbAns !== curNb) {
137
+ m.oneNote.defaultNotebookName = nbAns;
138
+ if (m.oneNote.enabled == null) m.oneNote.enabled = true;
139
+ fields.push(`oneNote.defaultNotebookName="${nbAns}"`);
140
+ }
141
+
142
+ rl.close();
143
+
144
+ if (fields.length === 0) {
145
+ process.stderr.write('\n ⓘ No changes — m365-auth.json left as seeded. Edit it later to enable bounded discover.\n\n');
146
+ return { ran: true, reason: 'no-changes', file: target, fields: [] };
147
+ }
148
+ fs.writeFileSync(target, JSON.stringify(parsed, null, 2) + '\n');
149
+
150
+ // Mirror engagement root into project-evidence.yml#projects_root so bootstrap
151
+ // and the resolver agree. Best-effort: if file is missing or unparseable we
152
+ // skip silently — m365-auth is the canonical source for sharePoint scoping.
153
+ if (engagementRoot && fs.existsSync(projectEvidenceTarget)) {
154
+ try {
155
+ const txt = fs.readFileSync(projectEvidenceTarget, 'utf8');
156
+ // Simple line replacement to avoid pulling in YAML lib at install time.
157
+ // The seed file ships with `projects_root: <value>` on its own line.
158
+ if (/^projects_root:/m.test(txt)) {
159
+ const updated = txt.replace(/^projects_root:.*$/m, `projects_root: ${JSON.stringify(engagementRoot)}`);
160
+ if (updated !== txt) {
161
+ fs.writeFileSync(projectEvidenceTarget, updated);
162
+ fields.push(`project-evidence.yml#projects_root=${engagementRoot}`);
163
+ }
164
+ } else {
165
+ fs.appendFileSync(projectEvidenceTarget, `\nprojects_root: ${JSON.stringify(engagementRoot)}\n`);
166
+ fields.push(`project-evidence.yml#projects_root=${engagementRoot} (appended)`);
167
+ }
168
+ } catch (e) {
169
+ process.stderr.write(` ⚠ Could not update project-evidence.yml: ${e.message}\n`);
170
+ }
171
+ }
172
+
173
+ process.stderr.write(`\n ✓ Wrote ${fields.length} field(s):\n`);
174
+ for (const f of fields) process.stderr.write(` • ${f}\n`);
175
+ process.stderr.write('\n');
176
+ return { ran: true, file: target, fields };
177
+ }