kushi-agents 5.8.1 → 5.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kushi-agents",
3
- "version": "5.8.1",
3
+ "version": "5.8.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": {
@@ -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': {
@@ -59,7 +59,11 @@ export async function ask(prompt, { bin, timeoutMs = 120_000, env = process.env,
59
59
  err.code = 'WORKIQ_NOT_FOUND';
60
60
  throw err;
61
61
  }
62
- const { stdout, stderr, exitCode } = await runProcess(exe, ['ask', '-q', prompt], { timeoutMs, env, onHeartbeat, heartbeatMs });
62
+ // Pass prompt via stdin instead of `-q "<prompt>"` argument. Multi-line prompts
63
+ // with embedded quotes get mangled by cmd.exe's argument parser even with
64
+ // windowsVerbatimArguments, causing workiq to receive corrupted input and
65
+ // hang indefinitely. stdin is a clean byte channel.
66
+ const { stdout, stderr, exitCode } = await runProcess(exe, ['ask'], { timeoutMs, env, onHeartbeat, heartbeatMs, stdin: prompt });
63
67
  if (exitCode !== 0) {
64
68
  const err = new Error(`workiq exited ${exitCode}: ${stderr.slice(0, 1000)}`);
65
69
  err.code = 'WORKIQ_EXIT_NONZERO';
@@ -117,7 +121,7 @@ function parseKvLines(s) {
117
121
 
118
122
  async function pathExists(p) { try { await fs.access(p); return true; } catch { return false; } }
119
123
 
120
- function runProcess(exe, args, { timeoutMs, env, onHeartbeat = null, heartbeatMs = 10_000 }) {
124
+ function runProcess(exe, args, { timeoutMs, env, onHeartbeat = null, heartbeatMs = 10_000, stdin = null }) {
121
125
  return new Promise((resolve, reject) => {
122
126
  // Node 20.12+ refuses to spawn .cmd/.bat on Windows without shell:true
123
127
  // (CVE-2024-27980). Detect and route through cmd.exe explicitly with
@@ -133,6 +137,10 @@ function runProcess(exe, args, { timeoutMs, env, onHeartbeat = null, heartbeatMs
133
137
  spawnOpts.windowsVerbatimArguments = true;
134
138
  }
135
139
  const child = spawn(spawnExe, spawnArgs, spawnOpts);
140
+ if (stdin != null) {
141
+ try { child.stdin.write(stdin); child.stdin.end(); }
142
+ catch (e) { /* if stdin already closed, child error handler will catch */ }
143
+ }
136
144
  let stdout = '';
137
145
  let stderr = '';
138
146
  let stdoutBytes = 0;
@@ -20,7 +20,7 @@ function isoDateNDaysAgo(n) {
20
20
  }
21
21
 
22
22
  /**
23
- * Run the 3-question quickstart. Returns a small report object:
23
+ * Run the 4-question quickstart. Returns a small report object:
24
24
  * { ran: bool, reason?: string, file?: string, fields?: string[] }
25
25
  *
26
26
  * @param {object} opts
@@ -33,6 +33,7 @@ export async function runM365Quickstart({ destRoot, force = false, noPrompt = fa
33
33
  if (!process.stdin.isTTY || !process.stderr.isTTY) return { ran: false, reason: 'not-a-tty' };
34
34
 
35
35
  const target = path.join(destRoot, 'config', 'user', 'm365-auth.json');
36
+ const projectEvidenceTarget = path.join(destRoot, 'config', 'user', 'project-evidence.yml');
36
37
  if (!fs.existsSync(target)) return { ran: false, reason: 'm365-auth-not-found' };
37
38
 
38
39
  let parsed;
@@ -44,12 +45,14 @@ export async function runM365Quickstart({ destRoot, force = false, noPrompt = fa
44
45
  m.teamsChatContext ??= {};
45
46
  m.calendarContext ??= {};
46
47
  m.oneNote ??= {};
48
+ m.sharePointContext ??= {};
47
49
 
48
- // Skip silently if all 3 fields are already populated and --force not passed.
50
+ // Skip silently if all 4 fields are already populated and --force not passed.
49
51
  const foldersSet = Array.isArray(m.emailContext.folders) && m.emailContext.folders.length > 0;
50
52
  const floorSet = !looksLikeSentinel(m.emailContext.dateFloor);
51
53
  const notebookSet = !looksLikeSentinel(m.oneNote.defaultNotebookName);
52
- if (foldersSet && floorSet && notebookSet && !force) {
54
+ const rootSet = !looksLikeSentinel(m.sharePointContext.localProjectsRoot);
55
+ if (foldersSet && floorSet && notebookSet && rootSet && !force) {
53
56
  return { ran: false, reason: 'already-populated' };
54
57
  }
55
58
 
@@ -60,21 +63,42 @@ export async function runM365Quickstart({ destRoot, force = false, noPrompt = fa
60
63
  return ans === '' ? def : ans;
61
64
  };
62
65
 
63
- process.stderr.write('\n ┌─ Quickstart: 3 questions that make `kushi discover` fast ──\n');
66
+ process.stderr.write('\n ┌─ Quickstart: 4 questions that make `kushi discover` fast ──\n');
64
67
  process.stderr.write(` │ Editing: ${target}\n`);
65
- process.stderr.write(' │ Press Enter at any prompt to skip (you can always edit the file later).\n');
68
+ process.stderr.write(' │ Press Enter at any prompt to keep the shown default.\n');
66
69
  process.stderr.write(' └────────────────────────────────────────────────────────────\n\n');
67
70
 
68
71
  const fields = [];
69
72
 
70
- // 1. Email folders
71
- process.stderr.write(' [1/3] Which Outlook mail folders contain your project mail?\n');
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');
72
95
  process.stderr.write(' Examples:\n');
73
96
  process.stderr.write(' • "1. FDE, 99. FDE Not Active" (Microsoft FDE consultant)\n');
74
97
  process.stderr.write(' • "Inbox, Archive, Projects" (generic)\n');
75
98
  process.stderr.write(' • "Inbox" (everything in inbox)\n');
76
99
  const curFolders = Array.isArray(m.emailContext.folders) ? m.emailContext.folders.join(', ') : '';
77
- const foldersAns = await ask(' Comma-separated folder names', curFolders);
100
+ const foldersDefault = curFolders || '1. FDE, 99. FDE Not Active';
101
+ const foldersAns = await ask(' Comma-separated folder names', foldersDefault);
78
102
  if (foldersAns && foldersAns !== curFolders) {
79
103
  const arr = foldersAns.split(',').map(s => s.trim()).filter(Boolean);
80
104
  m.emailContext.folders = arr;
@@ -85,8 +109,8 @@ export async function runM365Quickstart({ destRoot, force = false, noPrompt = fa
85
109
  process.stderr.write(' (skipped — discover will scan the whole mailbox, which can take minutes per source)\n');
86
110
  }
87
111
 
88
- // 2. Look-back days
89
- process.stderr.write('\n [2/3] How many days back should discover scan? (smaller = faster)\n');
112
+ // 3. Look-back days
113
+ process.stderr.write('\n [3/4] How many days back should discover scan? (smaller = faster)\n');
90
114
  process.stderr.write(' Recommended: 60 for active engagements, 90 for setup.\n');
91
115
  const curFloor = m.emailContext.dateFloor;
92
116
  const defaultDays = curFloor && /^\d{4}-\d{2}-\d{2}$/.test(curFloor)
@@ -104,8 +128,8 @@ export async function runM365Quickstart({ destRoot, force = false, noPrompt = fa
104
128
  process.stderr.write(` ⚠ "${daysAns}" is not a valid number 1–3650 — skipping dateFloor.\n`);
105
129
  }
106
130
 
107
- // 3. OneNote notebook
108
- process.stderr.write('\n [3/3] Which OneNote notebook holds your project notes?\n');
131
+ // 4. OneNote notebook
132
+ process.stderr.write('\n [4/4] Which OneNote notebook holds your project notes?\n');
109
133
  process.stderr.write(' Default for Microsoft consultants: "ISE Work"\n');
110
134
  const curNb = m.oneNote.defaultNotebookName || '';
111
135
  const nbAns = await ask(' Notebook name', curNb || 'ISE Work');
@@ -122,7 +146,31 @@ export async function runM365Quickstart({ destRoot, force = false, noPrompt = fa
122
146
  return { ran: true, reason: 'no-changes', file: target, fields: [] };
123
147
  }
124
148
  fs.writeFileSync(target, JSON.stringify(parsed, null, 2) + '\n');
125
- process.stderr.write(`\n ✓ Wrote ${fields.length} field(s) to ${target}:\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`);
126
174
  for (const f of fields) process.stderr.write(` • ${f}\n`);
127
175
  process.stderr.write('\n');
128
176
  return { ran: true, file: target, fields };