kushi-agents 5.7.6 → 5.8.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 +4 -0
- package/package.json +1 -1
- package/plugin/learnings/cross-cutting.md +36 -0
- package/plugin/runners/bootstrap.mjs +203 -2
- package/plugin/runners/discover.mjs +80 -18
- package/plugin/runners/lib/m365-auth.mjs +112 -0
- package/plugin/runners/test/unit/discover-scope.test.mjs +159 -0
- package/plugin/skills/bootstrap-project/SKILL.md +25 -2
- package/plugin/templates/init/m365-auth.example.json +88 -56
- package/plugin/templates/init/m365-auth.template.json +10 -1
- package/src/main.mjs +10 -0
- package/src/multi-host.mjs +1 -0
- package/src/quickstart-m365.mjs +129 -0
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.
|
|
3
|
+
"version": "5.8.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": {
|
|
@@ -4,6 +4,42 @@ Newest on top. Format defined in [`README.md`](./README.md). Use this file when
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
### 2026-05-29 — Bounded queries beat broad semantic search by 10×
|
|
8
|
+
|
|
9
|
+
**Symptom**: Even after v5.7.6 (5 min timeouts + honest "WorkIQ buffers stdout" heartbeat copy), discover still hit 7 × `WORKIQ_TIMEOUT` for some users — 21+ minutes per run with zero results. The host (GitHub Copilot Chat in particular) couldn't wait that long and bailed out with "discover hung" before the runner even returned its envelope.
|
|
10
|
+
|
|
11
|
+
**Root cause** (discovered while sketching v5.8.0): WorkIQ defaults to mailbox-wide / tenant-wide *semantic* search when given a free-text question like *"Find Outlook mail folders related to project X"*. With no folder list, no date window, and no notebook scope, it scans the entire mailbox/tenant via LLM-driven matching — which is unboundedly slow on large mailboxes. The config schema *already had* `emailContext.folders`, `dateFloor`, `includeSubfolders`, `oneNote.defaultNotebookName`, and `sharePointContext.localProjectsRoot` — but `discover.mjs` never read them. The runner built the same broad prompt for everyone, regardless of how much config the user had filled in.
|
|
12
|
+
|
|
13
|
+
**Insight**: WorkIQ has two query modes, gated by how specific your prompt is.
|
|
14
|
+
|
|
15
|
+
| Prompt shape | Mode | Wall time/source |
|
|
16
|
+
|----------------------------------------------------|------------------|------------------|
|
|
17
|
+
| "Find folders related to project X" | semantic search | 60–180s |
|
|
18
|
+
| "In folders ['1. FDE', '99. FDE Not Active'], find folders matching 'X' received after 2026-03-30" | Graph filter | 5–15s |
|
|
19
|
+
|
|
20
|
+
The user has the data to write the bounded prompt. Until v5.8.0, the runner just didn't.
|
|
21
|
+
|
|
22
|
+
**Fix shipped (v5.8.0, 2026-05-29)**:
|
|
23
|
+
|
|
24
|
+
1. **`plugin/runners/lib/m365-auth.mjs`** — new helper. `loadM365Auth()` probes the Get-KushiConfig path order (workspace user → workspace shared → ~/.copilot/m-skills/... → runner-relative). `scopeForSource(m365Auth, source)` returns per-source hint object (folders/dateFloor for email; dateFloor + chat/channel toggle for teams; notebookName for onenote; localProjectsRoot for sharepoint; null for crm/ado which use integrations.yml).
|
|
25
|
+
|
|
26
|
+
2. **`discover.mjs::buildPrompt`** rewritten to inject the scope. Email gets `Restrict your search to ONLY these Outlook mail folders: ...`, Teams gets `Constraints: only consider messages from 2026-03-30 onward`, OneNote gets `Restrict your search to OneNote notebook "ISE Work"`, etc. Empty scope → unscoped prompt (legacy fallback, non-breaking).
|
|
27
|
+
|
|
28
|
+
3. **`bootstrap.mjs --lookback-days N`** + **`--interactive`** — first-time-setup ergonomics. Without these, users had to hand-edit `.kushi/config/user/m365-auth.json` and many never did. `--interactive` walks them through the 3 fields that matter (folders, dateFloor, notebook) in <30 seconds. `--lookback-days 60` is the one-flag scripted equivalent.
|
|
29
|
+
|
|
30
|
+
4. **Templates updated** with concrete copy-paste examples — `m365-auth.example.json` now reads as a finished FDE-consultant config, not an abstract schema. `_alternative_examples` block shows the 3 most common mailbox layouts.
|
|
31
|
+
|
|
32
|
+
**Doctrine**: When a runner builds a WorkIQ prompt, **read the config first**. Every field in `m365-auth.json` that constrains the search space should be reflected in the prompt. If the field is empty, document that the prompt will run unscoped — never silently fall through. If config exists but isn't being used, that's a bug equivalent to ignoring user input.
|
|
33
|
+
|
|
34
|
+
**Test counts**: 293 → 302. New file: `plugin/runners/test/unit/discover-scope.test.mjs` (9 tests covering scope plumbing + sentinel stripping + disabled-in-config + log-line assertions).
|
|
35
|
+
|
|
36
|
+
**Don't repeat**:
|
|
37
|
+
- Don't ship a runner that ignores half its config schema.
|
|
38
|
+
- Don't assume WorkIQ "isn't fast enough" without checking if the prompt is bounded — broadness, not transport latency, is usually the cost.
|
|
39
|
+
- Don't make users hand-edit JSON for fields that meaningfully change perceived performance — provide an interactive path.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
7
43
|
### 2026-05-29 — WorkIQ stdout is fully-buffered through the Windows spawn chain
|
|
8
44
|
|
|
9
45
|
**Symptom**: After v5.7.5 added intelligent heartbeats, user re-ran discover. Heartbeats showed `...waiting for first byte (10s/180s, no output yet)` for 170+ seconds, then `✗ TIMEOUT after 180045ms (received 0 bytes before kill)`. Yet running `workiq.cmd ask -q "..."` directly from PowerShell returned 4 emails in 29 seconds.
|
|
@@ -28,20 +28,198 @@ import {
|
|
|
28
28
|
import { writeAtomic, pathExists } from './lib/evidence.mjs';
|
|
29
29
|
|
|
30
30
|
function parseArgs(argv) {
|
|
31
|
-
const args = { force: false, dryRun: false };
|
|
31
|
+
const args = { force: false, dryRun: false, lookbackDays: null, interactive: false };
|
|
32
32
|
for (let i = 0; i < argv.length; i++) {
|
|
33
33
|
const a = argv[i];
|
|
34
34
|
if (a === '--project') args.project = argv[++i];
|
|
35
35
|
else if (a === '--alias') args.alias = argv[++i];
|
|
36
36
|
else if (a === '--force') args.force = true;
|
|
37
37
|
else if (a === '--dry-run') args.dryRun = true;
|
|
38
|
+
else if (a === '--lookback-days') args.lookbackDays = Number(argv[++i]);
|
|
39
|
+
else if (a === '--interactive' || a === '-i') args.interactive = true;
|
|
38
40
|
else if (a === '--help' || a === '-h') args.help = true;
|
|
39
41
|
}
|
|
40
42
|
return args;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
function help() {
|
|
44
|
-
return
|
|
46
|
+
return [
|
|
47
|
+
'Usage: node bootstrap.mjs --project <P> --alias <A> [options]',
|
|
48
|
+
'',
|
|
49
|
+
'Options:',
|
|
50
|
+
' --interactive Prompt for the 3 fields that most affect discover speed',
|
|
51
|
+
' (email folders, look-back days, OneNote notebook) and',
|
|
52
|
+
' stamp them into .kushi/config/user/m365-auth.json. Non-',
|
|
53
|
+
' destructive: existing values are shown as defaults.',
|
|
54
|
+
' --lookback-days N Non-interactive: stamp dateFloor = today − N days into',
|
|
55
|
+
' emailContext / teamsChatContext / calendarContext.',
|
|
56
|
+
' Speeds up discover by ~10× because WorkIQ runs a Graph',
|
|
57
|
+
' filter instead of a mailbox-wide semantic search.',
|
|
58
|
+
' Recommended: 60–90.',
|
|
59
|
+
' --force Overwrite existing template files AND existing dateFloor.',
|
|
60
|
+
' --dry-run Print planned changes without writing.',
|
|
61
|
+
].join('\n');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Compute ISO date string (YYYY-MM-DD) for today − N days, in local time. */
|
|
65
|
+
function isoDateNDaysAgo(n) {
|
|
66
|
+
const d = new Date();
|
|
67
|
+
d.setDate(d.getDate() - n);
|
|
68
|
+
return d.toISOString().slice(0, 10);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Stamp a dateFloor (computed from --lookback-days) into the workspace's
|
|
73
|
+
* m365-auth.json under emailContext / teamsChatContext / calendarContext.
|
|
74
|
+
* Probes in Get-KushiConfig order: workspace .kushi/config/{user,shared}/.
|
|
75
|
+
* Non-destructive: skips fields that already have a non-empty, non-sentinel
|
|
76
|
+
* value unless --force is passed.
|
|
77
|
+
*
|
|
78
|
+
* @returns {Promise<{ updated: string|null, fields: string[], skipped: string[], reason?: string }>}
|
|
79
|
+
*/
|
|
80
|
+
async function stampDateFloor({ workspace, lookbackDays, force, dryRun }) {
|
|
81
|
+
const dateFloor = isoDateNDaysAgo(lookbackDays);
|
|
82
|
+
const candidates = [
|
|
83
|
+
path.join(workspace, '.kushi', 'config', 'user', 'm365-auth.json'),
|
|
84
|
+
path.join(workspace, '.kushi', 'config', 'shared', 'm365-auth.json'),
|
|
85
|
+
];
|
|
86
|
+
let target = null;
|
|
87
|
+
for (const p of candidates) {
|
|
88
|
+
if (await pathExists(p)) { target = p; break; }
|
|
89
|
+
}
|
|
90
|
+
if (!target) {
|
|
91
|
+
return { updated: null, fields: [], skipped: [], reason: 'm365-auth-not-found' };
|
|
92
|
+
}
|
|
93
|
+
const txt = await fs.readFile(target, 'utf8');
|
|
94
|
+
let parsed;
|
|
95
|
+
try { parsed = JSON.parse(txt); } catch (e) {
|
|
96
|
+
return { updated: null, fields: [], skipped: [], reason: `parse-error: ${e.message}` };
|
|
97
|
+
}
|
|
98
|
+
parsed.m365Auth ??= {};
|
|
99
|
+
const ctxs = [
|
|
100
|
+
['emailContext', parsed.m365Auth.emailContext ??= {}],
|
|
101
|
+
['teamsChatContext', parsed.m365Auth.teamsChatContext ??= {}],
|
|
102
|
+
['calendarContext', parsed.m365Auth.calendarContext ??= {}],
|
|
103
|
+
];
|
|
104
|
+
const fields = [];
|
|
105
|
+
const skipped = [];
|
|
106
|
+
const isSentinel = (s) => !s || s === '<auto>' || /__FILL_ME_IN__/.test(s) || /^<[A-Za-z][^>]*>$/.test(s);
|
|
107
|
+
for (const [name, ctx] of ctxs) {
|
|
108
|
+
const cur = ctx.dateFloor;
|
|
109
|
+
if (!isSentinel(cur) && !force) { skipped.push(`${name}.dateFloor=${cur}`); continue; }
|
|
110
|
+
ctx.dateFloor = dateFloor;
|
|
111
|
+
fields.push(`${name}.dateFloor`);
|
|
112
|
+
}
|
|
113
|
+
if (!dryRun && fields.length > 0) {
|
|
114
|
+
await writeAtomic(target, JSON.stringify(parsed, null, 2) + '\n', { skipIfUnchanged: true });
|
|
115
|
+
}
|
|
116
|
+
return { updated: target, fields, skipped, dateFloor };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Interactive setup for the 3 fields that most affect discover speed.
|
|
121
|
+
* Prompts on stderr (TTY only). Reads workspace m365-auth.json, shows current
|
|
122
|
+
* values as defaults, writes back atomically. Re-running is safe: each prompt
|
|
123
|
+
* defaults to the current value, so pressing Enter keeps everything.
|
|
124
|
+
*
|
|
125
|
+
* Fields:
|
|
126
|
+
* 1. emailContext.folders — comma-separated. Speeds up discover most.
|
|
127
|
+
* 2. lookback days → dateFloor — number. Stamped into 3 contexts.
|
|
128
|
+
* 3. oneNote.defaultNotebookName — string. Defaults to "ISE Work".
|
|
129
|
+
*
|
|
130
|
+
* @returns {Promise<{ updated: string|null, fields: string[], reason?: string }>}
|
|
131
|
+
*/
|
|
132
|
+
async function interactiveSetup({ workspace, dryRun }) {
|
|
133
|
+
// TTY check — interactive only makes sense with a real terminal.
|
|
134
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
135
|
+
return { updated: null, fields: [], reason: 'not-a-tty (use --lookback-days for non-interactive)' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const target = path.join(workspace, '.kushi', 'config', 'user', 'm365-auth.json');
|
|
139
|
+
if (!await pathExists(target)) {
|
|
140
|
+
return { updated: null, fields: [], reason: 'm365-auth-not-found' };
|
|
141
|
+
}
|
|
142
|
+
let parsed;
|
|
143
|
+
try { parsed = JSON.parse(await fs.readFile(target, 'utf8')); }
|
|
144
|
+
catch (e) { return { updated: null, fields: [], reason: `parse-error: ${e.message}` }; }
|
|
145
|
+
parsed.m365Auth ??= {};
|
|
146
|
+
const m = parsed.m365Auth;
|
|
147
|
+
m.emailContext ??= {};
|
|
148
|
+
m.teamsChatContext ??= {};
|
|
149
|
+
m.calendarContext ??= {};
|
|
150
|
+
m.oneNote ??= {};
|
|
151
|
+
|
|
152
|
+
const readline = await import('node:readline/promises');
|
|
153
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
154
|
+
const ask = async (q, def) => {
|
|
155
|
+
const ans = (await rl.question(`${q} ${def != null ? `[${def}]` : ''} > `)).trim();
|
|
156
|
+
return ans === '' ? def : ans;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const fields = [];
|
|
160
|
+
process.stderr.write('\n┌─ Kushi setup (3 prompts that make discover fast) ─\n');
|
|
161
|
+
process.stderr.write(`│ Editing: ${target}\n`);
|
|
162
|
+
process.stderr.write('│ Press Enter to keep the current value (shown in brackets).\n');
|
|
163
|
+
process.stderr.write('└──────────────────────────────────────────────────\n\n');
|
|
164
|
+
|
|
165
|
+
// 1. Email folders (REQUIRED for fast discover)
|
|
166
|
+
process.stderr.write('[1/3] Email folders to scope discover (REQUIRED for speed)\n');
|
|
167
|
+
process.stderr.write(' Examples: "1. FDE, 99. FDE Not Active" (Microsoft FDE consultant)\n');
|
|
168
|
+
process.stderr.write(' "Inbox, Archive, Projects" (generic)\n');
|
|
169
|
+
process.stderr.write(' "Inbox" (everything in inbox)\n');
|
|
170
|
+
const curFolders = Array.isArray(m.emailContext.folders) ? m.emailContext.folders.join(', ') : '';
|
|
171
|
+
const foldersAns = await ask(' Comma-separated folder names', curFolders || '(none — will scan whole mailbox, slow)');
|
|
172
|
+
if (foldersAns && foldersAns !== '(none — will scan whole mailbox, slow)') {
|
|
173
|
+
const arr = foldersAns.split(',').map(s => s.trim()).filter(Boolean);
|
|
174
|
+
m.emailContext.folders = arr;
|
|
175
|
+
if (m.emailContext.includeSubfolders == null) m.emailContext.includeSubfolders = true;
|
|
176
|
+
fields.push(`emailContext.folders=[${arr.join(', ')}]`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 2. Look-back days → dateFloor
|
|
180
|
+
process.stderr.write('\n[2/3] Look-back window (days back from today)\n');
|
|
181
|
+
process.stderr.write(' Stamps dateFloor into email/teams/calendar contexts.\n');
|
|
182
|
+
process.stderr.write(' Smaller = faster discover. Recommended: 60 (active) / 90 (setup).\n');
|
|
183
|
+
const curFloor = m.emailContext.dateFloor;
|
|
184
|
+
const defaultDays = curFloor && /^\d{4}-\d{2}-\d{2}$/.test(curFloor)
|
|
185
|
+
? Math.max(1, Math.round((Date.now() - Date.parse(curFloor)) / 86400000))
|
|
186
|
+
: 60;
|
|
187
|
+
const daysAns = await ask(' Look-back days', defaultDays);
|
|
188
|
+
const days = Number(daysAns);
|
|
189
|
+
if (Number.isFinite(days) && days > 0 && days <= 3650) {
|
|
190
|
+
const floor = isoDateNDaysAgo(days);
|
|
191
|
+
m.emailContext.dateFloor = floor;
|
|
192
|
+
m.teamsChatContext.dateFloor = floor;
|
|
193
|
+
m.calendarContext.dateFloor = floor;
|
|
194
|
+
fields.push(`dateFloor=${floor} (${days} days)`);
|
|
195
|
+
} else {
|
|
196
|
+
process.stderr.write(` ⚠ Invalid number "${daysAns}" — skipping dateFloor.\n`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 3. OneNote notebook
|
|
200
|
+
process.stderr.write('\n[3/3] OneNote notebook to scope discover (optional)\n');
|
|
201
|
+
process.stderr.write(' Default for Microsoft consultants: "ISE Work"\n');
|
|
202
|
+
const curNb = m.oneNote.defaultNotebookName || '';
|
|
203
|
+
const nbAns = await ask(' Notebook name', curNb || 'ISE Work');
|
|
204
|
+
if (nbAns && nbAns !== curNb) {
|
|
205
|
+
m.oneNote.defaultNotebookName = nbAns;
|
|
206
|
+
if (m.oneNote.enabled == null) m.oneNote.enabled = true;
|
|
207
|
+
fields.push(`oneNote.defaultNotebookName="${nbAns}"`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
rl.close();
|
|
211
|
+
|
|
212
|
+
if (fields.length === 0) {
|
|
213
|
+
process.stderr.write('\nNo changes.\n\n');
|
|
214
|
+
return { updated: null, fields: [], reason: 'no-changes' };
|
|
215
|
+
}
|
|
216
|
+
if (!dryRun) {
|
|
217
|
+
await writeAtomic(target, JSON.stringify(parsed, null, 2) + '\n', { skipIfUnchanged: true });
|
|
218
|
+
}
|
|
219
|
+
process.stderr.write(`\n✓ ${dryRun ? '[dry-run] would write' : 'wrote'} ${fields.length} field(s) to ${target}:\n`);
|
|
220
|
+
for (const f of fields) process.stderr.write(` • ${f}\n`);
|
|
221
|
+
process.stderr.write('\n');
|
|
222
|
+
return { updated: target, fields };
|
|
45
223
|
}
|
|
46
224
|
|
|
47
225
|
function emit(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); }
|
|
@@ -128,6 +306,27 @@ async function main() {
|
|
|
128
306
|
await ensureFile(path.join(aliasRoot(args.project, args.alias), 'external-links.local.yml'), YAML.stringify({ links: [] }), { dryRun: args.dryRun, force: args.force, log });
|
|
129
307
|
await ensureFile(path.join(aliasRoot(args.project, args.alias), '_ledger.yml'), YAML.stringify({ entries: {} }), { dryRun: args.dryRun, force: args.force, log });
|
|
130
308
|
|
|
309
|
+
// Optional: stamp dateFloor into workspace m365-auth.json (--lookback-days N).
|
|
310
|
+
// Workspace = current working directory (where the user runs bootstrap from).
|
|
311
|
+
let dateFloorReport = null;
|
|
312
|
+
if (Number.isFinite(args.lookbackDays) && args.lookbackDays > 0) {
|
|
313
|
+
dateFloorReport = await stampDateFloor({
|
|
314
|
+
workspace: process.cwd(),
|
|
315
|
+
lookbackDays: args.lookbackDays,
|
|
316
|
+
force: args.force,
|
|
317
|
+
dryRun: args.dryRun,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Optional: interactive m365-auth setup (--interactive). Prompts on stderr.
|
|
322
|
+
let interactiveReport = null;
|
|
323
|
+
if (args.interactive) {
|
|
324
|
+
interactiveReport = await interactiveSetup({
|
|
325
|
+
workspace: process.cwd(),
|
|
326
|
+
dryRun: args.dryRun,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
131
330
|
emit({
|
|
132
331
|
status: 'ok',
|
|
133
332
|
project: root,
|
|
@@ -135,6 +334,8 @@ async function main() {
|
|
|
135
334
|
created: log.created.map(p => path.relative(root, p) || '.'),
|
|
136
335
|
existed: log.existed.map(p => path.relative(root, p) || '.'),
|
|
137
336
|
dry_run: args.dryRun,
|
|
337
|
+
...(dateFloorReport ? { date_floor: dateFloorReport } : {}),
|
|
338
|
+
...(interactiveReport ? { interactive: interactiveReport } : {}),
|
|
138
339
|
});
|
|
139
340
|
return 0;
|
|
140
341
|
}
|
|
@@ -20,6 +20,7 @@ import YAML from 'yaml';
|
|
|
20
20
|
import { projectRoot, evidenceRoot, aliasRoot, projectSharedFile, userFile } from './lib/layout.mjs';
|
|
21
21
|
import { writeAtomic, pathExists } from './lib/evidence.mjs';
|
|
22
22
|
import { ask as workiqAsk, resolveWorkiqBin } from './lib/workiq.mjs';
|
|
23
|
+
import { loadM365Auth, scopeForSource } from './lib/m365-auth.mjs';
|
|
23
24
|
|
|
24
25
|
const ALL_SOURCES = ['email', 'teams', 'meetings', 'onenote', 'sharepoint', 'crm', 'ado'];
|
|
25
26
|
|
|
@@ -44,8 +45,18 @@ function help() {
|
|
|
44
45
|
|
|
45
46
|
function emit(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); }
|
|
46
47
|
|
|
48
|
+
/** Compact one-line description of the active scope for the run-log header. */
|
|
49
|
+
function describeScope(source, scope) {
|
|
50
|
+
if (!scope || scope.enabled === false) return null;
|
|
51
|
+
const parts = [];
|
|
52
|
+
if (source === 'email' && scope.folders?.length) parts.push(`${scope.folders.length} folder(s)`);
|
|
53
|
+
if (scope.dateFloor) parts.push(`from ${scope.dateFloor}`);
|
|
54
|
+
if (source === 'onenote' && scope.notebookName) parts.push(`notebook "${scope.notebookName}"`);
|
|
55
|
+
return parts.length ? 'scope: ' + parts.join(', ') : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
47
58
|
/** Deterministic prompt — asks WorkIQ for CSC blocks per source. */
|
|
48
|
-
function buildPrompt(source, projectName) {
|
|
59
|
+
function buildPrompt(source, projectName, scope = null) {
|
|
49
60
|
const intros = {
|
|
50
61
|
email: `Find Outlook mail folders related to project "${projectName}".`,
|
|
51
62
|
teams: `Find Microsoft Teams 1:1 and group chats related to project "${projectName}".`,
|
|
@@ -64,17 +75,57 @@ function buildPrompt(source, projectName) {
|
|
|
64
75
|
crm: 'request_id, incident_number, title, confidence',
|
|
65
76
|
ado: 'engagement_id, work_item_id, title, confidence',
|
|
66
77
|
};
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
+
const lines = [intros[source]];
|
|
79
|
+
|
|
80
|
+
// Inject scope hints from m365-auth.json. Bounded queries (folder list +
|
|
81
|
+
// date floor) let WorkIQ use Graph filters instead of mailbox-wide semantic
|
|
82
|
+
// search, typically dropping query time from 60–180s to 5–15s.
|
|
83
|
+
if (scope && scope.enabled !== false) {
|
|
84
|
+
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.');
|
|
92
|
+
}
|
|
93
|
+
if (scope.dateFloor) lines.push(`Only consider mail received on or after ${scope.dateFloor}.`);
|
|
94
|
+
} else if (source === 'teams') {
|
|
95
|
+
const conds = [];
|
|
96
|
+
if (scope.dateFloor) conds.push(`only consider messages from ${scope.dateFloor} onward`);
|
|
97
|
+
if (scope.includeChats === false) conds.push('skip 1:1/group chats; channel posts only');
|
|
98
|
+
if (scope.includeChannels === false) conds.push('skip channel posts; chats only');
|
|
99
|
+
if (conds.length) { lines.push(''); lines.push(`Constraints: ${conds.join('; ')}.`); }
|
|
100
|
+
} else if (source === 'meetings') {
|
|
101
|
+
if (scope.dateFloor) {
|
|
102
|
+
lines.push('');
|
|
103
|
+
lines.push(`Only consider meeting series with occurrences on or after ${scope.dateFloor}.`);
|
|
104
|
+
}
|
|
105
|
+
} else if (source === 'onenote') {
|
|
106
|
+
if (scope.notebookName) {
|
|
107
|
+
lines.push('');
|
|
108
|
+
lines.push(`Restrict your search to OneNote notebook "${scope.notebookName}".`);
|
|
109
|
+
}
|
|
110
|
+
} else if (source === 'sharepoint') {
|
|
111
|
+
if (scope.localProjectsRoot) {
|
|
112
|
+
lines.push('');
|
|
113
|
+
lines.push(`The user's local SharePoint sync root is "${scope.localProjectsRoot}". Match site URLs against subfolders if useful.`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
lines.push('');
|
|
119
|
+
lines.push('Return ONLY a list of structured-capture blocks of the form:');
|
|
120
|
+
lines.push('> [block: discovery]');
|
|
121
|
+
lines.push(`> source: ${source}`);
|
|
122
|
+
for (const f of fields[source].split(', ')) {
|
|
123
|
+
lines.push(`> ${f.split(' ')[0]}: <value>`);
|
|
124
|
+
}
|
|
125
|
+
lines.push('');
|
|
126
|
+
lines.push('One block per match. No prose. No commentary. If you find nothing, return an empty response.');
|
|
127
|
+
lines.push('Skip low-confidence matches.');
|
|
128
|
+
return lines.join('\n');
|
|
78
129
|
}
|
|
79
130
|
|
|
80
131
|
/** Pull discovery rows out of CSC blocks, scoped to source. */
|
|
@@ -201,12 +252,16 @@ async function main() {
|
|
|
201
252
|
let boundsDirty = false;
|
|
202
253
|
let integDirty = false;
|
|
203
254
|
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
// kill processes silent on output for 30–60s.
|
|
255
|
+
// Load m365-auth.json scope hints. Empty/missing config falls back to
|
|
256
|
+
// unscoped queries (legacy behavior, no breaking change).
|
|
257
|
+
const m365 = await loadM365Auth();
|
|
208
258
|
const log = (msg) => process.stderr.write(`[discover] ${msg}\n`);
|
|
209
259
|
log(`workiq: ${workiqBin}`);
|
|
260
|
+
if (m365.path) {
|
|
261
|
+
log(`m365-auth: ${m365.path}${m365.error ? ` (parse error: ${m365.error})` : ''}`);
|
|
262
|
+
} else {
|
|
263
|
+
log('m365-auth: not found — running unscoped queries (slower, broader)');
|
|
264
|
+
}
|
|
210
265
|
log(`sources: ${sourcesToRun.join(', ')} (timeout ${args.timeoutMs}ms each)`);
|
|
211
266
|
|
|
212
267
|
const total = sourcesToRun.length;
|
|
@@ -214,12 +269,19 @@ async function main() {
|
|
|
214
269
|
let idx = 0;
|
|
215
270
|
for (const source of sourcesToRun) {
|
|
216
271
|
idx++;
|
|
217
|
-
const
|
|
272
|
+
const scope = scopeForSource(m365.config, source);
|
|
273
|
+
if (scope?.enabled === false) {
|
|
274
|
+
log(`[${idx}/${total}] ${source}: disabled in m365-auth.json — skipping`);
|
|
275
|
+
sourceResults.push({ source, asked: false, found: 0, accepted: [], skipped_reason: 'disabled-in-config' });
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const prompt = buildPrompt(source, path.basename(root), scope);
|
|
218
279
|
let rows = [];
|
|
219
280
|
let asked = true;
|
|
220
281
|
let skipReason = null;
|
|
221
282
|
const t0 = Date.now();
|
|
222
|
-
|
|
283
|
+
const scopeNote = describeScope(source, scope);
|
|
284
|
+
log(`[${idx}/${total}] ${source}: querying workiq (${args.timeoutMs}ms budget${scopeNote ? ', ' + scopeNote : ''})...`);
|
|
223
285
|
const onHeartbeat = ({ elapsedMs, stdoutBytes }) => {
|
|
224
286
|
const sec = Math.round(elapsedMs / 1000);
|
|
225
287
|
const budget = Math.round(args.timeoutMs / 1000);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// plugin/runners/lib/m365-auth.mjs
|
|
2
|
+
// Load m365Auth config (shared/user/install probe order) and convert it into
|
|
3
|
+
// per-source scope hints that runners inject into WorkIQ prompts.
|
|
4
|
+
//
|
|
5
|
+
// Why scope hints matter: WorkIQ defaults to mailbox-wide / tenant-wide semantic
|
|
6
|
+
// search, which can take 60–180s per query. Anchoring queries to specific
|
|
7
|
+
// folders (e.g. "1. FDE") and a date floor turns them into bounded Graph
|
|
8
|
+
// filters and typically returns in 5–15s.
|
|
9
|
+
|
|
10
|
+
import { promises as fs } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve and load m365-auth.json. Probe order matches Get-KushiConfig.ps1.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} opts
|
|
20
|
+
* @param {string} [opts.workspace] - workspace root (default: cwd)
|
|
21
|
+
* @returns {Promise<{ config: object, path: string|null, error?: string }>}
|
|
22
|
+
* `config` is the `m365Auth` sub-object (always defined, may be empty).
|
|
23
|
+
*/
|
|
24
|
+
export async function loadM365Auth({ workspace = process.cwd() } = {}) {
|
|
25
|
+
const home = process.env.USERPROFILE || process.env.HOME || '';
|
|
26
|
+
// Runner installs in <workspace>/.kushi/runners/lib/, so two levels up = .kushi/.
|
|
27
|
+
const runnerInstallRoot = path.resolve(__dirname, '..', '..');
|
|
28
|
+
const candidates = [
|
|
29
|
+
path.join(workspace, '.kushi', 'config', 'user', 'm365-auth.json'),
|
|
30
|
+
path.join(workspace, '.kushi', 'config', 'shared', 'm365-auth.json'),
|
|
31
|
+
path.join(home, '.copilot', 'm-skills', 'kushi', 'config', 'user', 'm365-auth.json'),
|
|
32
|
+
path.join(home, '.copilot', 'm-skills', 'kushi', 'config', 'shared', 'm365-auth.json'),
|
|
33
|
+
path.join(runnerInstallRoot, 'config', 'user', 'm365-auth.json'),
|
|
34
|
+
path.join(runnerInstallRoot, 'config', 'shared', 'm365-auth.json'),
|
|
35
|
+
];
|
|
36
|
+
for (const p of candidates) {
|
|
37
|
+
try {
|
|
38
|
+
const txt = await fs.readFile(p, 'utf8');
|
|
39
|
+
const parsed = JSON.parse(txt);
|
|
40
|
+
return { config: parsed?.m365Auth || {}, path: p };
|
|
41
|
+
} catch (e) {
|
|
42
|
+
if (e.code === 'ENOENT') continue;
|
|
43
|
+
// Found but unparseable — return so callers can warn-and-fallback.
|
|
44
|
+
return { config: {}, path: p, error: e.message };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { config: {}, path: null };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build a per-source scope hint object from m365Auth config.
|
|
52
|
+
* Returns null when source is unscoped (crm, ado) — those use integrations.yml.
|
|
53
|
+
*
|
|
54
|
+
* Sentinels (`__FILL_ME_IN__`, `<...>`) are stripped from string fields.
|
|
55
|
+
*/
|
|
56
|
+
export function scopeForSource(m365Auth, source) {
|
|
57
|
+
if (!m365Auth || typeof m365Auth !== 'object') return null;
|
|
58
|
+
const cleanStr = (s) => (typeof s === 'string' && !looksLikeSentinel(s) ? s : null);
|
|
59
|
+
const cleanArr = (a) => (Array.isArray(a) ? a.map(cleanStr).filter(Boolean) : []);
|
|
60
|
+
|
|
61
|
+
switch (source) {
|
|
62
|
+
case 'email': {
|
|
63
|
+
const ec = m365Auth.emailContext || {};
|
|
64
|
+
if (ec.enabled === false) return { enabled: false };
|
|
65
|
+
return {
|
|
66
|
+
enabled: true,
|
|
67
|
+
folders: cleanArr(ec.folders),
|
|
68
|
+
includeSubfolders: ec.includeSubfolders !== false,
|
|
69
|
+
dateFloor: cleanStr(ec.dateFloor),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
case 'teams': {
|
|
73
|
+
const tc = m365Auth.teamsChatContext || {};
|
|
74
|
+
if (tc.enabled === false) return { enabled: false };
|
|
75
|
+
return {
|
|
76
|
+
enabled: true,
|
|
77
|
+
dateFloor: cleanStr(tc.dateFloor),
|
|
78
|
+
includeChats: tc.scope?.includeChats !== false,
|
|
79
|
+
includeChannels: tc.scope?.includeChannels !== false,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
case 'meetings': {
|
|
83
|
+
const cc = m365Auth.calendarContext || {};
|
|
84
|
+
if (cc.enabled === false) return { enabled: false };
|
|
85
|
+
return { enabled: true, dateFloor: cleanStr(cc.dateFloor) };
|
|
86
|
+
}
|
|
87
|
+
case 'onenote': {
|
|
88
|
+
const on = m365Auth.oneNote || {};
|
|
89
|
+
if (on.enabled === false) return { enabled: false };
|
|
90
|
+
return {
|
|
91
|
+
enabled: true,
|
|
92
|
+
notebookName: cleanStr(on.defaultNotebookName),
|
|
93
|
+
notebookId: cleanStr(on.defaultNotebookId),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
case 'sharepoint': {
|
|
97
|
+
const sp = m365Auth.sharePointContext || {};
|
|
98
|
+
if (sp.enabled === false) return { enabled: false };
|
|
99
|
+
return { enabled: true, localProjectsRoot: cleanStr(sp.localProjectsRoot) };
|
|
100
|
+
}
|
|
101
|
+
default:
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function looksLikeSentinel(s) {
|
|
107
|
+
if (!s) return true;
|
|
108
|
+
if (s === '<auto>') return true; // runtime-resolution marker, not a real value
|
|
109
|
+
if (/__FILL_ME_IN__/.test(s)) return true;
|
|
110
|
+
if (/^<[A-Za-z][^>]*>$/.test(s)) return true; // matches <TENANT_ID>, <ProjectA>, etc.
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// plugin/runners/test/unit/discover-scope.test.mjs
|
|
2
|
+
// v5.8.0 — assert that m365-auth.json scope hints flow into discover prompts.
|
|
3
|
+
// Tightening prompts (folder list, dateFloor, notebook name) drops query time
|
|
4
|
+
// from 60–180s to 5–15s by giving WorkIQ enough hints to run a bounded
|
|
5
|
+
// Graph filter instead of a mailbox-wide semantic search.
|
|
6
|
+
|
|
7
|
+
import { test } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { promises as fs } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
import { loadM365Auth, scopeForSource } from '../../lib/m365-auth.mjs';
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const RUNNER = path.resolve(__dirname, '..', '..', 'discover.mjs');
|
|
19
|
+
const BOOTSTRAP = path.resolve(__dirname, '..', '..', 'bootstrap.mjs');
|
|
20
|
+
|
|
21
|
+
async function makeWorkspaceWithM365(m365Json) {
|
|
22
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'kushi-scope-'));
|
|
23
|
+
const cfgDir = path.join(tmp, '.kushi', 'config', 'user');
|
|
24
|
+
await fs.mkdir(cfgDir, { recursive: true });
|
|
25
|
+
await fs.writeFile(path.join(cfgDir, 'm365-auth.json'), JSON.stringify(m365Json, null, 2));
|
|
26
|
+
return tmp;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test('scopeForSource: email pulls folders + dateFloor + includeSubfolders', () => {
|
|
30
|
+
const m365 = {
|
|
31
|
+
emailContext: {
|
|
32
|
+
enabled: true,
|
|
33
|
+
folders: ['1. FDE', '99. FDE Not Active'],
|
|
34
|
+
includeSubfolders: true,
|
|
35
|
+
dateFloor: '2026-03-01',
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const s = scopeForSource(m365, 'email');
|
|
39
|
+
assert.equal(s.enabled, true);
|
|
40
|
+
assert.deepEqual(s.folders, ['1. FDE', '99. FDE Not Active']);
|
|
41
|
+
assert.equal(s.includeSubfolders, true);
|
|
42
|
+
assert.equal(s.dateFloor, '2026-03-01');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('scopeForSource: strips sentinel placeholders from folders + dateFloor', () => {
|
|
46
|
+
const m365 = {
|
|
47
|
+
emailContext: {
|
|
48
|
+
enabled: true,
|
|
49
|
+
folders: ['Inbox', '__FILL_ME_IN__', '<ProjectA>'],
|
|
50
|
+
dateFloor: '__FILL_ME_IN__',
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
const s = scopeForSource(m365, 'email');
|
|
54
|
+
assert.deepEqual(s.folders, ['Inbox'], 'sentinels removed');
|
|
55
|
+
assert.equal(s.dateFloor, null, 'sentinel dateFloor nulled');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('scopeForSource: returns { enabled: false } when source disabled', () => {
|
|
59
|
+
assert.equal(scopeForSource({ emailContext: { enabled: false } }, 'email').enabled, false);
|
|
60
|
+
assert.equal(scopeForSource({ oneNote: { enabled: false } }, 'onenote').enabled, false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('scopeForSource: onenote + sharepoint + meetings + teams shapes', () => {
|
|
64
|
+
const m365 = {
|
|
65
|
+
oneNote: { enabled: true, defaultNotebookName: 'ISE Work', defaultNotebookId: '0-ABC123' },
|
|
66
|
+
sharePointContext: { enabled: true, localProjectsRoot: 'C:\\OneDrive\\Engagements' },
|
|
67
|
+
calendarContext: { enabled: true, dateFloor: '2026-03-01' },
|
|
68
|
+
teamsChatContext: {
|
|
69
|
+
enabled: true,
|
|
70
|
+
dateFloor: '2026-03-01',
|
|
71
|
+
scope: { includeChats: true, includeChannels: false },
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
assert.equal(scopeForSource(m365, 'onenote').notebookName, 'ISE Work');
|
|
75
|
+
assert.equal(scopeForSource(m365, 'onenote').notebookId, '0-ABC123');
|
|
76
|
+
assert.equal(scopeForSource(m365, 'sharepoint').localProjectsRoot, 'C:\\OneDrive\\Engagements');
|
|
77
|
+
assert.equal(scopeForSource(m365, 'meetings').dateFloor, '2026-03-01');
|
|
78
|
+
const t = scopeForSource(m365, 'teams');
|
|
79
|
+
assert.equal(t.dateFloor, '2026-03-01');
|
|
80
|
+
assert.equal(t.includeChats, true);
|
|
81
|
+
assert.equal(t.includeChannels, false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('scopeForSource: returns null for unscoped sources (crm, ado)', () => {
|
|
85
|
+
assert.equal(scopeForSource({}, 'crm'), null);
|
|
86
|
+
assert.equal(scopeForSource({}, 'ado'), null);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('loadM365Auth: returns empty config + null path when no file present', async () => {
|
|
90
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'kushi-scope-empty-'));
|
|
91
|
+
const r = await loadM365Auth({ workspace: tmp });
|
|
92
|
+
// Could find none, OR could fall through to ~/.copilot/m-skills/.../m365-auth.json
|
|
93
|
+
// if the developer has one. Either way, config must always be a dict.
|
|
94
|
+
assert.equal(typeof r.config, 'object');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('loadM365Auth: reads workspace .kushi/config/user/m365-auth.json', async () => {
|
|
98
|
+
const tmp = await makeWorkspaceWithM365({
|
|
99
|
+
m365Auth: {
|
|
100
|
+
emailContext: { enabled: true, folders: ['1. FDE'] },
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
const r = await loadM365Auth({ workspace: tmp });
|
|
104
|
+
assert.deepEqual(r.config.emailContext.folders, ['1. FDE']);
|
|
105
|
+
assert.match(r.path, /m365-auth\.json$/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('discover: prompt includes folder list when emailContext.folders is configured', async () => {
|
|
109
|
+
const tmp = await makeWorkspaceWithM365({
|
|
110
|
+
m365Auth: {
|
|
111
|
+
emailContext: { enabled: true, folders: ['1. FDE', '99. FDE Not Active'], includeSubfolders: true, dateFloor: '2026-03-01' },
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
// Bootstrap a project inside tmp so discover finds it.
|
|
115
|
+
const bs = spawnSync(process.execPath, [BOOTSTRAP, '--project', 'scope-proj', '--alias', 'alice'], { cwd: tmp, encoding: 'utf8' });
|
|
116
|
+
assert.equal(bs.status, 0, bs.stderr);
|
|
117
|
+
|
|
118
|
+
// Use a shim that echoes the exact prompt back so we can inspect it.
|
|
119
|
+
const shim = path.join(tmp, process.platform === 'win32' ? 'echo-shim.cmd' : 'echo-shim');
|
|
120
|
+
const shimBody = process.platform === 'win32'
|
|
121
|
+
? '@echo off\r\necho.\r\nexit /b 0\r\n' // empty stdout is fine; we only check stderr [discover] log
|
|
122
|
+
: '#!/bin/sh\necho ""\nexit 0\n';
|
|
123
|
+
await fs.writeFile(shim, shimBody);
|
|
124
|
+
if (process.platform !== 'win32') await fs.chmod(shim, 0o755);
|
|
125
|
+
|
|
126
|
+
const r = spawnSync(process.execPath, [RUNNER, '--project', 'scope-proj', '--alias', 'alice', '--source', 'email'], {
|
|
127
|
+
cwd: tmp, encoding: 'utf8', env: { ...process.env, KUSHI_WORKIQ_BIN: shim },
|
|
128
|
+
});
|
|
129
|
+
assert.equal(r.status, 0, r.stderr);
|
|
130
|
+
// The [discover] log must announce that m365-auth was found AND show scope summary.
|
|
131
|
+
assert.match(r.stderr, /m365-auth: .*m365-auth\.json/, `expected m365-auth path in log, got: ${r.stderr}`);
|
|
132
|
+
assert.match(r.stderr, /scope:.*2 folder\(s\).*from 2026-03-01/,
|
|
133
|
+
`expected scope summary in log, got: ${r.stderr}`);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('discover: source is skipped when m365-auth.json sets enabled=false', async () => {
|
|
137
|
+
const tmp = await makeWorkspaceWithM365({
|
|
138
|
+
m365Auth: { emailContext: { enabled: false } },
|
|
139
|
+
});
|
|
140
|
+
const bs = spawnSync(process.execPath, [BOOTSTRAP, '--project', 'disabled-proj', '--alias', 'alice'], { cwd: tmp, encoding: 'utf8' });
|
|
141
|
+
assert.equal(bs.status, 0, bs.stderr);
|
|
142
|
+
|
|
143
|
+
const shim = path.join(tmp, process.platform === 'win32' ? 'should-not-call.cmd' : 'should-not-call');
|
|
144
|
+
const shimBody = process.platform === 'win32'
|
|
145
|
+
? '@echo off\r\necho FAIL_SHOULD_NOT_BE_CALLED 1>&2\r\nexit /b 99\r\n'
|
|
146
|
+
: '#!/bin/sh\necho FAIL_SHOULD_NOT_BE_CALLED >&2\nexit 99\n';
|
|
147
|
+
await fs.writeFile(shim, shimBody);
|
|
148
|
+
if (process.platform !== 'win32') await fs.chmod(shim, 0o755);
|
|
149
|
+
|
|
150
|
+
const r = spawnSync(process.execPath, [RUNNER, '--project', 'disabled-proj', '--alias', 'alice', '--source', 'email'], {
|
|
151
|
+
cwd: tmp, encoding: 'utf8', env: { ...process.env, KUSHI_WORKIQ_BIN: shim },
|
|
152
|
+
});
|
|
153
|
+
assert.equal(r.status, 0, r.stderr);
|
|
154
|
+
const lastLine = r.stdout.trim().split('\n').filter(Boolean).pop();
|
|
155
|
+
const json = JSON.parse(lastLine);
|
|
156
|
+
assert.equal(json.sources[0].skipped_reason, 'disabled-in-config');
|
|
157
|
+
assert.equal(json.sources[0].asked, false);
|
|
158
|
+
assert.doesNotMatch(r.stderr, /FAIL_SHOULD_NOT_BE_CALLED/, 'shim must not have been called');
|
|
159
|
+
});
|
|
@@ -11,10 +11,33 @@ Delegates to the deterministic scaffold runner **`plugin/runners/bootstrap.mjs`*
|
|
|
11
11
|
## Invoke
|
|
12
12
|
|
|
13
13
|
```
|
|
14
|
-
node plugin/runners/bootstrap.mjs --project <P> --alias <A> [--force] [--dry-run]
|
|
14
|
+
node plugin/runners/bootstrap.mjs --project <P> --alias <A> [--interactive] [--lookback-days N] [--force] [--dry-run]
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
Stdout JSON: `{ status, project, alias, created[], existed[] }`. Idempotent — re-runs report `existed[]` and write nothing.
|
|
17
|
+
Stdout JSON: `{ status, project, alias, created[], existed[], date_floor?, interactive? }`. Idempotent — re-runs report `existed[]` and write nothing.
|
|
18
|
+
|
|
19
|
+
### `--interactive` (recommended for first-time users)
|
|
20
|
+
|
|
21
|
+
Prompts the user for the 3 fields that most affect discover speed:
|
|
22
|
+
1. **Email folders** — comma-separated. Inline FDE-style examples shown.
|
|
23
|
+
2. **Look-back days** — numeric. Stamped as `dateFloor` into email/teams/calendar contexts.
|
|
24
|
+
3. **OneNote notebook name** — defaults to `"ISE Work"` for Microsoft consultants.
|
|
25
|
+
|
|
26
|
+
Prompts on stderr (TTY only). Existing values are shown as defaults — pressing Enter keeps them. Refuses if not running in a TTY (use `--lookback-days` instead for non-interactive flows).
|
|
27
|
+
|
|
28
|
+
### `--lookback-days N` (non-interactive equivalent)
|
|
29
|
+
|
|
30
|
+
Stamps `dateFloor = today − N days` into the workspace's `m365-auth.json` under `emailContext`, `teamsChatContext`, and `calendarContext`. With a date floor set, WorkIQ runs a bounded Microsoft Graph filter (5–15s/source) instead of a mailbox-wide semantic search (60–180s/source). Recommended values: `60` for active engagements, `90` for setup/triage, `30` for tight focus.
|
|
31
|
+
|
|
32
|
+
Non-destructive: skips fields that already have a non-empty, non-sentinel `dateFloor` unless `--force` is also passed. The flag has no effect if `.kushi/config/user/m365-auth.json` does not exist (the `date_floor.reason` field will say `m365-auth-not-found`).
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
# Interactive (first-time setup, recommended):
|
|
36
|
+
node plugin/runners/bootstrap.mjs --project "C:\...\hca" --alias ushak --interactive
|
|
37
|
+
|
|
38
|
+
# Non-interactive (CI / scripted):
|
|
39
|
+
node plugin/runners/bootstrap.mjs --project "C:\...\hca" --alias ushak --lookback-days 60
|
|
40
|
+
```
|
|
18
41
|
|
|
19
42
|
## What gets scaffolded
|
|
20
43
|
|
|
@@ -1,56 +1,88 @@
|
|
|
1
|
-
{
|
|
2
|
-
"
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
},
|
|
16
|
-
"
|
|
17
|
-
"enabled": true,
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
],
|
|
52
|
-
"fallbackToBroaderSearchWhenAmbiguous": true
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
1
|
+
{
|
|
2
|
+
"_meta": {
|
|
3
|
+
"owner": "ushak@microsoft.com",
|
|
4
|
+
"purpose": "EXAMPLE — concrete values you can copy-paste into your own m365-auth.json. This file is shipped for reference only and is NOT loaded by kushi. Edit `.kushi/config/user/m365-auth.json` (the per-user config seeded by the installer) with the values from this example, then save.",
|
|
5
|
+
"audience": "Microsoft Industry Solutions consultants",
|
|
6
|
+
"schema_version": "1.0",
|
|
7
|
+
"last_reviewed": "2026-05-29"
|
|
8
|
+
},
|
|
9
|
+
"m365Auth": {
|
|
10
|
+
"defaultTenantId": "72f988bf-86f1-41af-91ab-2d7cd011db47",
|
|
11
|
+
"resources": {
|
|
12
|
+
"graph": "https://graph.microsoft.com",
|
|
13
|
+
"sharePoint": "https://microsoft-my.sharepoint.com",
|
|
14
|
+
"dataverse": "https://iscrm.crm.dynamics.com"
|
|
15
|
+
},
|
|
16
|
+
"oneNote": {
|
|
17
|
+
"enabled": true,
|
|
18
|
+
"defaultNotebookName": "ISE Work",
|
|
19
|
+
"defaultNotebookId": "",
|
|
20
|
+
"defaultSectionResolverUrl": "",
|
|
21
|
+
"defaultNotebookRootLink": "",
|
|
22
|
+
"defaultLinkOwner": "ushak@microsoft.com"
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
"_emailContext_HOWTO": "Setting `folders` is the single biggest discover/refresh speedup. List the 1–4 parent folders that hold ALL your project mail. With `includeSubfolders: true`, every folder under each one is included. WorkIQ then runs a bounded Graph filter (5–15s) instead of a mailbox-wide semantic search (60–180s). Pair with `dateFloor` for further speedup.",
|
|
26
|
+
"emailContext": {
|
|
27
|
+
"enabled": true,
|
|
28
|
+
"dateFloor": "2026-03-01",
|
|
29
|
+
"folders": [
|
|
30
|
+
"1. FDE",
|
|
31
|
+
"99. FDE Not Active"
|
|
32
|
+
],
|
|
33
|
+
"includeSubfolders": true,
|
|
34
|
+
"sourceCoverageLabel": "FDE consultant — active + archived engagement folders",
|
|
35
|
+
"matchingPolicy": {
|
|
36
|
+
"mode": "hybrid",
|
|
37
|
+
"rankingOrder": ["exact", "prefix", "contains"],
|
|
38
|
+
"minConfidenceForFolderScopedSearch": "high",
|
|
39
|
+
"fallbackToFullRootScanWhenAmbiguous": true,
|
|
40
|
+
"alwaysFuzzy": true
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
"_teamsChatContext_HOWTO": "Setting `dateFloor` keeps Teams discovery scoped to the active engagement window. 90 days back is a good default — older chats rarely yield new project boundaries.",
|
|
45
|
+
"teamsChatContext": {
|
|
46
|
+
"enabled": true,
|
|
47
|
+
"dateFloor": "2026-03-01",
|
|
48
|
+
"scope": { "includeChats": true, "includeChannels": true },
|
|
49
|
+
"matchingPolicy": {
|
|
50
|
+
"mode": "thread-first",
|
|
51
|
+
"rankingOrder": ["exact", "prefix", "contains"],
|
|
52
|
+
"fallbackToBroaderSearchWhenAmbiguous": true,
|
|
53
|
+
"alwaysFuzzy": true
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
"_calendarContext_HOWTO": "Setting `dateFloor` filters meeting series. 90 days back is the default; reduce to 30 days for tight engagements.",
|
|
58
|
+
"calendarContext": {
|
|
59
|
+
"enabled": true,
|
|
60
|
+
"dateFloor": "2026-03-01",
|
|
61
|
+
"matchingPolicy": {
|
|
62
|
+
"mode": "subject-and-body-keywords",
|
|
63
|
+
"rankingOrder": ["exact", "prefix", "contains"],
|
|
64
|
+
"alwaysFuzzy": true
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
"_sharePointContext_HOWTO": "`localProjectsRoot` should point at the local sync root for your engagement library. kushi uses it to match project folder names against on-disk paths.",
|
|
69
|
+
"sharePointContext": {
|
|
70
|
+
"enabled": true,
|
|
71
|
+
"localProjectsRoot": "C:\\Users\\ushak\\OneDrive - Microsoft\\ISE\\Engagement Assets",
|
|
72
|
+
"matchingPolicy": {
|
|
73
|
+
"mode": "fuzzy-folder-name",
|
|
74
|
+
"rankingOrder": ["exact", "prefix", "contains"],
|
|
75
|
+
"alwaysFuzzy": true
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
"_alternative_examples": {
|
|
81
|
+
"_comment": "Other realistic shapes. Pick the one that matches your mailbox layout — copy the `folders` array into the active config above.",
|
|
82
|
+
"single-project-team-member": ["Inbox/Northwind", "Archive/Northwind"],
|
|
83
|
+
"dedicated-project-folders": ["Projects/HCA", "Projects/AbnAmro", "Projects/JohnDeere"],
|
|
84
|
+
"everything-in-inbox": ["Inbox"],
|
|
85
|
+
"fde-consultant-active-only": ["1. FDE"],
|
|
86
|
+
"fde-consultant-active-plus-archive": ["1. FDE", "99. FDE Not Active"]
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -29,8 +29,14 @@
|
|
|
29
29
|
"emailContext": {
|
|
30
30
|
"enabled": true,
|
|
31
31
|
"dateFloor": "",
|
|
32
|
+
"_dateFloor_note": "ISO date 'YYYY-MM-DD'. Empty = no floor (slower). Recommended: set to 90 days ago. Example: \"2026-03-01\".",
|
|
32
33
|
"folders": [],
|
|
33
|
-
"_folders_note": "Empty array = scan the FULL mailbox (
|
|
34
|
+
"_folders_note": "Empty array = scan the FULL mailbox (slow, every run). For fast queries, list the parent folders that contain ALL your project mail. Subfolders included when includeSubfolders=true. Example for a Microsoft consultant: [\"1. FDE\", \"99. FDE Not Active\", \"Inbox\"].",
|
|
35
|
+
"_folders_examples": {
|
|
36
|
+
"fde-consultant": ["1. FDE", "99. FDE Not Active"],
|
|
37
|
+
"generic": ["Inbox", "Archive", "Projects"],
|
|
38
|
+
"single-project": ["Inbox/Northwind"]
|
|
39
|
+
},
|
|
34
40
|
"includeSubfolders": true,
|
|
35
41
|
"sourceCoverageLabel": "",
|
|
36
42
|
"matchingPolicy": {
|
|
@@ -44,6 +50,7 @@
|
|
|
44
50
|
"teamsChatContext": {
|
|
45
51
|
"enabled": true,
|
|
46
52
|
"dateFloor": "",
|
|
53
|
+
"_dateFloor_note": "ISO date 'YYYY-MM-DD'. Empty = no floor. Recommended: 90 days ago. Example: \"2026-03-01\".",
|
|
47
54
|
"scope": { "includeChats": true, "includeChannels": true },
|
|
48
55
|
"matchingPolicy": {
|
|
49
56
|
"mode": "thread-first",
|
|
@@ -54,6 +61,8 @@
|
|
|
54
61
|
},
|
|
55
62
|
"calendarContext": {
|
|
56
63
|
"enabled": true,
|
|
64
|
+
"dateFloor": "",
|
|
65
|
+
"_dateFloor_note": "ISO date 'YYYY-MM-DD'. Empty = no floor. Recommended: 90 days ago. Example: \"2026-03-01\".",
|
|
57
66
|
"matchingPolicy": {
|
|
58
67
|
"mode": "subject-and-body-keywords",
|
|
59
68
|
"rankingOrder": ["exact", "prefix", "contains"],
|
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,
|
package/src/multi-host.mjs
CHANGED
|
@@ -0,0 +1,129 @@
|
|
|
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 3-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
|
+
if (!fs.existsSync(target)) return { ran: false, reason: 'm365-auth-not-found' };
|
|
37
|
+
|
|
38
|
+
let parsed;
|
|
39
|
+
try { parsed = JSON.parse(fs.readFileSync(target, 'utf8')); }
|
|
40
|
+
catch (e) { return { ran: false, reason: `parse-error: ${e.message}` }; }
|
|
41
|
+
parsed.m365Auth ??= {};
|
|
42
|
+
const m = parsed.m365Auth;
|
|
43
|
+
m.emailContext ??= {};
|
|
44
|
+
m.teamsChatContext ??= {};
|
|
45
|
+
m.calendarContext ??= {};
|
|
46
|
+
m.oneNote ??= {};
|
|
47
|
+
|
|
48
|
+
// Skip silently if all 3 fields are already populated and --force not passed.
|
|
49
|
+
const foldersSet = Array.isArray(m.emailContext.folders) && m.emailContext.folders.length > 0;
|
|
50
|
+
const floorSet = !looksLikeSentinel(m.emailContext.dateFloor);
|
|
51
|
+
const notebookSet = !looksLikeSentinel(m.oneNote.defaultNotebookName);
|
|
52
|
+
if (foldersSet && floorSet && notebookSet && !force) {
|
|
53
|
+
return { ran: false, reason: 'already-populated' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const readline = await import('node:readline/promises');
|
|
57
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
58
|
+
const ask = async (q, def) => {
|
|
59
|
+
const ans = (await rl.question(`${q} ${def != null && def !== '' ? `[${def}]` : ''} > `)).trim();
|
|
60
|
+
return ans === '' ? def : ans;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
process.stderr.write('\n ┌─ Quickstart: 3 questions that make `kushi discover` fast ──\n');
|
|
64
|
+
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');
|
|
66
|
+
process.stderr.write(' └────────────────────────────────────────────────────────────\n\n');
|
|
67
|
+
|
|
68
|
+
const fields = [];
|
|
69
|
+
|
|
70
|
+
// 1. Email folders
|
|
71
|
+
process.stderr.write(' [1/3] Which Outlook mail folders contain your project mail?\n');
|
|
72
|
+
process.stderr.write(' Examples:\n');
|
|
73
|
+
process.stderr.write(' • "1. FDE, 99. FDE Not Active" (Microsoft FDE consultant)\n');
|
|
74
|
+
process.stderr.write(' • "Inbox, Archive, Projects" (generic)\n');
|
|
75
|
+
process.stderr.write(' • "Inbox" (everything in inbox)\n');
|
|
76
|
+
const curFolders = Array.isArray(m.emailContext.folders) ? m.emailContext.folders.join(', ') : '';
|
|
77
|
+
const foldersAns = await ask(' Comma-separated folder names', curFolders);
|
|
78
|
+
if (foldersAns && foldersAns !== curFolders) {
|
|
79
|
+
const arr = foldersAns.split(',').map(s => s.trim()).filter(Boolean);
|
|
80
|
+
m.emailContext.folders = arr;
|
|
81
|
+
if (m.emailContext.includeSubfolders == null) m.emailContext.includeSubfolders = true;
|
|
82
|
+
if (m.emailContext.enabled == null) m.emailContext.enabled = true;
|
|
83
|
+
fields.push(`emailContext.folders=[${arr.join(', ')}]`);
|
|
84
|
+
} else if (!foldersAns) {
|
|
85
|
+
process.stderr.write(' (skipped — discover will scan the whole mailbox, which can take minutes per source)\n');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 2. Look-back days
|
|
89
|
+
process.stderr.write('\n [2/3] How many days back should discover scan? (smaller = faster)\n');
|
|
90
|
+
process.stderr.write(' Recommended: 60 for active engagements, 90 for setup.\n');
|
|
91
|
+
const curFloor = m.emailContext.dateFloor;
|
|
92
|
+
const defaultDays = curFloor && /^\d{4}-\d{2}-\d{2}$/.test(curFloor)
|
|
93
|
+
? Math.max(1, Math.round((Date.now() - Date.parse(curFloor)) / 86400000))
|
|
94
|
+
: 60;
|
|
95
|
+
const daysAns = await ask(' Look-back days', defaultDays);
|
|
96
|
+
const days = Number(daysAns);
|
|
97
|
+
if (Number.isFinite(days) && days > 0 && days <= 3650) {
|
|
98
|
+
const floor = isoDateNDaysAgo(days);
|
|
99
|
+
m.emailContext.dateFloor = floor;
|
|
100
|
+
m.teamsChatContext.dateFloor = floor;
|
|
101
|
+
m.calendarContext.dateFloor = floor;
|
|
102
|
+
fields.push(`dateFloor=${floor} (${days} days)`);
|
|
103
|
+
} else {
|
|
104
|
+
process.stderr.write(` ⚠ "${daysAns}" is not a valid number 1–3650 — skipping dateFloor.\n`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 3. OneNote notebook
|
|
108
|
+
process.stderr.write('\n [3/3] Which OneNote notebook holds your project notes?\n');
|
|
109
|
+
process.stderr.write(' Default for Microsoft consultants: "ISE Work"\n');
|
|
110
|
+
const curNb = m.oneNote.defaultNotebookName || '';
|
|
111
|
+
const nbAns = await ask(' Notebook name', curNb || 'ISE Work');
|
|
112
|
+
if (nbAns && nbAns !== curNb) {
|
|
113
|
+
m.oneNote.defaultNotebookName = nbAns;
|
|
114
|
+
if (m.oneNote.enabled == null) m.oneNote.enabled = true;
|
|
115
|
+
fields.push(`oneNote.defaultNotebookName="${nbAns}"`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
rl.close();
|
|
119
|
+
|
|
120
|
+
if (fields.length === 0) {
|
|
121
|
+
process.stderr.write('\n ⓘ No changes — m365-auth.json left as seeded. Edit it later to enable bounded discover.\n\n');
|
|
122
|
+
return { ran: true, reason: 'no-changes', file: target, fields: [] };
|
|
123
|
+
}
|
|
124
|
+
fs.writeFileSync(target, JSON.stringify(parsed, null, 2) + '\n');
|
|
125
|
+
process.stderr.write(`\n ✓ Wrote ${fields.length} field(s) to ${target}:\n`);
|
|
126
|
+
for (const f of fields) process.stderr.write(` • ${f}\n`);
|
|
127
|
+
process.stderr.write('\n');
|
|
128
|
+
return { ran: true, file: target, fields };
|
|
129
|
+
}
|