kushi-agents 3.4.2 → 3.13.0

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.
Files changed (73) hide show
  1. package/.github/copilot-instructions.kushi.md +38 -0
  2. package/README.md +33 -0
  3. package/bin/cli.mjs +2 -0
  4. package/package.json +17 -4
  5. package/plugin/agents/kushi.agent.md +155 -147
  6. package/plugin/instructions/ado-bootstrap-discovery.instructions.md +111 -0
  7. package/plugin/instructions/ado-engagement-tree.instructions.md +73 -0
  8. package/plugin/instructions/answer-from-evidence.instructions.md +1 -1
  9. package/plugin/instructions/auth-and-retry.instructions.md +51 -16
  10. package/plugin/instructions/azure-auth-patterns.instructions.md +13 -6
  11. package/plugin/instructions/bootstrap-status-format.instructions.md +113 -0
  12. package/plugin/instructions/capture-learnings.instructions.md +95 -0
  13. package/plugin/instructions/cleanup-on-resolution.instructions.md +69 -0
  14. package/plugin/instructions/crm-bootstrap-discovery.instructions.md +79 -0
  15. package/plugin/instructions/crm-internal-vs-confirmed.instructions.md +79 -0
  16. package/plugin/instructions/evidence-confidence-ladder.instructions.md +66 -0
  17. package/plugin/instructions/evidence-layout-canonical.instructions.md +115 -0
  18. package/plugin/instructions/evidence-thoroughness.instructions.md +82 -12
  19. package/plugin/instructions/full-view-gate.instructions.md +91 -0
  20. package/plugin/instructions/m365-id-registry.instructions.md +134 -0
  21. package/plugin/instructions/meetings-verbatim-required.instructions.md +176 -0
  22. package/plugin/instructions/run-reports.instructions.md +129 -0
  23. package/plugin/instructions/scope-boundaries.instructions.md +218 -0
  24. package/plugin/instructions/snapshot-vs-stream.instructions.md +2 -0
  25. package/plugin/instructions/update-ledger.instructions.md +132 -0
  26. package/plugin/instructions/verbatim-by-default.instructions.md +73 -0
  27. package/plugin/instructions/workiq-first.instructions.md +15 -31
  28. package/plugin/instructions/workiq-only.instructions.md +193 -0
  29. package/plugin/learnings/README.md +50 -0
  30. package/plugin/learnings/ado.md +45 -0
  31. package/plugin/learnings/crm.md +96 -0
  32. package/plugin/learnings/cross-cutting.md +36 -0
  33. package/plugin/learnings/email.md +33 -0
  34. package/plugin/learnings/meetings.md +30 -0
  35. package/plugin/learnings/misc.md +46 -0
  36. package/plugin/learnings/onenote.md +215 -0
  37. package/plugin/learnings/sharepoint.md +5 -0
  38. package/plugin/learnings/teams.md +5 -0
  39. package/plugin/plugin.json +22 -2
  40. package/plugin/prompts/apply-ado.prompt.md +14 -0
  41. package/plugin/prompts/propose-ado.prompt.md +12 -0
  42. package/plugin/reference-packs/fde/crm-field-manifest.md +165 -0
  43. package/plugin/skills/apply-ado-update/SKILL.md +125 -0
  44. package/plugin/skills/ask-project/SKILL.md +2 -0
  45. package/plugin/skills/bootstrap-project/SKILL.md +81 -3
  46. package/plugin/skills/propose-ado-update/SKILL.md +108 -0
  47. package/plugin/skills/pull-ado/SKILL.md +173 -23
  48. package/plugin/skills/pull-crm/SKILL.md +168 -15
  49. package/plugin/skills/pull-email/SKILL.md +139 -22
  50. package/plugin/skills/pull-meetings/SKILL.md +109 -25
  51. package/plugin/skills/pull-misc/README.md +84 -0
  52. package/plugin/skills/pull-misc/SKILL.md +257 -0
  53. package/plugin/skills/pull-misc/runner.mjs +280 -0
  54. package/plugin/skills/pull-onenote/README.md +90 -0
  55. package/plugin/skills/pull-onenote/SKILL.md +400 -51
  56. package/plugin/skills/pull-onenote/runner.mjs +356 -0
  57. package/plugin/skills/pull-onenote/scripts/recapture-section-url.mjs +295 -0
  58. package/plugin/skills/pull-onenote/write-snapshot.mjs +271 -0
  59. package/plugin/skills/pull-sharepoint/SKILL.md +44 -12
  60. package/plugin/skills/pull-teams/SKILL.md +40 -11
  61. package/plugin/skills/refresh-project/SKILL.md +33 -2
  62. package/plugin/skills/self-check/run.ps1 +186 -4
  63. package/plugin/templates/ado-update/discussion-comment.template.md +26 -0
  64. package/plugin/templates/ado-update/integrations-ado-writes.example.yml +49 -0
  65. package/plugin/templates/ado-update/proposed.template.md +78 -0
  66. package/plugin/templates/init/external-links.template.txt +30 -0
  67. package/plugin/templates/init/project-integrations.template.yml +57 -2
  68. package/plugin/templates/snapshot/meeting-verbatim.template.md +110 -0
  69. package/plugin/templates/snapshot/meetings-series-index.template.md +3 -1
  70. package/plugin/templates/snapshot/onenote-page.template.md +92 -23
  71. package/plugin/templates/weekly/meetings-stream.template.md +11 -6
  72. package/src/copilot-instructions.mjs +80 -0
  73. package/src/main.mjs +18 -1
@@ -0,0 +1,356 @@
1
+ #!/usr/bin/env node
2
+ // pull-onenote runner — browser-scrape via Playwright with persisted profile.
3
+ // Per pull-onenote SKILL.md v2.6.0 (kushi v3.8.0).
4
+ //
5
+ // Usage:
6
+ // node runner.mjs --bootstrap # first-time interactive sign-in only
7
+ // node runner.mjs --preflight # check OneNote-for-Web is reachable; exits 0/3
8
+ // node runner.mjs --project HCA --section-url "<deep-link>" # full enumerate + fetch
9
+ // node runner.mjs --project HCA --section-url "..." --headless # for scheduled / unattended runs
10
+ // node runner.mjs --project HCA --section-url "..." --titles "4/3 - HCA with Jay and Martin,5/7"
11
+ //
12
+ // Output is a single JSON object on stdout containing:
13
+ // { project, sectionUrl, pages: [{ title, pos, total, webPageId, last_status, captured_via, body, bodyLen, capturedAt }],
14
+ // runStatus: 'ok' | 'auth-required' | 'notebook-unavailable' | 'partial',
15
+ // authRequiredCount, exitedEarly, preflight: { ok, reason?, detail? } }
16
+ //
17
+ // runStatus 'notebook-unavailable' means OneNote-for-Web itself failed to render
18
+ // (Sorry-we-ran-into-a-problem dialog, or root surface never loaded). This is a
19
+ // service/notebook-side failure, NOT an auth problem — the kushi driver MUST surface
20
+ // it as a clear diagnostic and skip the run rather than retry blindly. See SKILL.md §A.4.
21
+ //
22
+ // The skill driver (PowerShell or Clawpilot itself) is responsible for:
23
+ // - merging the per-page records into m365-mutable.json#knownSections.<projectKey>.one_pages[]
24
+ // - writing the snapshot pages/*.md files
25
+ // - writing the run report
26
+
27
+ import { chromium } from 'playwright';
28
+ import path from 'node:path';
29
+ import os from 'node:os';
30
+ import process from 'node:process';
31
+
32
+ const args = Object.fromEntries(
33
+ process.argv.slice(2).reduce((acc, cur, idx, arr) => {
34
+ if (cur.startsWith('--')) {
35
+ const key = cur.replace(/^--/, '');
36
+ const next = arr[idx + 1];
37
+ acc.push([key, next && !next.startsWith('--') ? next : true]);
38
+ }
39
+ return acc;
40
+ }, [])
41
+ );
42
+
43
+ const PROFILE_DIR = path.join(os.homedir(), '.copilot', 'playwright-profile', 'onenote');
44
+ const HEADLESS = !!args.headless;
45
+ const BOOTSTRAP = !!args.bootstrap;
46
+ const PREFLIGHT = !!args.preflight;
47
+ const SKIP_PREFLIGHT = !!args['skip-preflight'];
48
+ const PROJECT = args.project || null;
49
+ const SECTION_URL = args['section-url'] || null;
50
+ const TITLES_FILTER = args.titles ? String(args.titles).split(',').map(s => s.trim()) : null;
51
+ const TIMEOUT_MS = parseInt(args.timeout || '60000', 10);
52
+ const SETTLE_MS = parseInt(args.settle || '2500', 10);
53
+ const PREFLIGHT_TIMEOUT_MS = parseInt(args['preflight-timeout'] || '25000', 10);
54
+
55
+ if (!BOOTSTRAP && !PREFLIGHT && (!PROJECT || !SECTION_URL)) {
56
+ console.error('Usage: --project <name> --section-url <url> [--headless] [--titles "t1,t2"]');
57
+ console.error(' --bootstrap (first-time interactive sign-in)');
58
+ console.error(' --preflight (check OneNote-for-Web is reachable; exits 0/3)');
59
+ process.exit(2);
60
+ }
61
+
62
+ function isLoginRedirect(url) {
63
+ return /login\.microsoftonline\.com|login\.live\.com/.test(url);
64
+ }
65
+
66
+ // kushi v3.11.0 — pre-flight gate for OneNote-for-Web reachability.
67
+ // Distinguishes three end-states at the OneNote SaaS root:
68
+ // ok — notebook list / account chrome rendered → safe to navigate to section
69
+ // auth-required — bounced to login.microsoftonline.com
70
+ // onenote-web-unavailable — "Sorry, we ran into a problem" or render-timeout
71
+ // We must distinguish #3 from #2 because retrying with fresh auth won't fix it —
72
+ // the user has to recover the notebook (open in OneNote desktop, force sync, wait).
73
+ async function preflightOneNoteWeb(page, timeoutMs) {
74
+ const ERROR_DIALOG_RE = /Sorry, we ran into a problem|Something went wrong|We couldn.?t open|There was a problem|This notebook can.?t be opened/i;
75
+ try {
76
+ await page.goto('https://onenote.cloud.microsoft/', { timeout: timeoutMs });
77
+ } catch (e) {
78
+ return { ok: false, reason: 'onenote-web-unavailable', detail: `navigation failed: ${e.message}` };
79
+ }
80
+ const deadline = Date.now() + timeoutMs;
81
+ while (Date.now() < deadline) {
82
+ if (isLoginRedirect(page.url())) {
83
+ return { ok: false, reason: 'auth-required', detail: `redirected to ${page.url()}` };
84
+ }
85
+ // Error dialog text (rendered in any frame).
86
+ for (const f of page.frames()) {
87
+ try {
88
+ const txt = await f.evaluate(() => document.body ? document.body.innerText : '');
89
+ if (txt && ERROR_DIALOG_RE.test(txt)) {
90
+ const m = txt.match(ERROR_DIALOG_RE);
91
+ return { ok: false, reason: 'onenote-web-unavailable', detail: `error dialog: "${m[0]}"` };
92
+ }
93
+ } catch (e) { /* frame detached, ignore */ }
94
+ }
95
+ // Success indicators — any one is sufficient.
96
+ const ready = await page.$('[aria-label*="Account manager" i], [data-automationid="NotebookList"], button[aria-label*="notebook" i], iframe[src*="onenoteframe.aspx"]');
97
+ if (ready) return { ok: true };
98
+ await page.waitForTimeout(500);
99
+ }
100
+ return { ok: false, reason: 'onenote-web-unavailable', detail: `timed out after ${timeoutMs}ms — neither sign-in, error dialog, nor notebook chrome rendered` };
101
+ }
102
+
103
+ function parseWebPageIdFromUrl(url) {
104
+ // wd=target(SectionName|sectionFileId/PageTitle|webPageId/)
105
+ const m = decodeURIComponent(url).match(/\|([0-9a-f-]{36})\/\)/i);
106
+ return m ? m[1] : null;
107
+ }
108
+
109
+ (async () => {
110
+ // v3.10.1: use the user's installed Edge (Intune-compliant) instead of Playwright's
111
+ // vanilla Chromium. Microsoft tenant Conditional Access blocks non-managed Chromium
112
+ // with "You can't get there from here" — only Edge (or compliant Chrome with extension)
113
+ // is permitted. Edge channel = Playwright drives the local Edge install with full
114
+ // Intune trust. Empirically validated 2026-05-14 against AGCO + HCA.
115
+ const ctx = await chromium.launchPersistentContext(PROFILE_DIR, {
116
+ headless: HEADLESS && !BOOTSTRAP,
117
+ channel: 'msedge',
118
+ viewport: { width: 1400, height: 900 },
119
+ args: ['--disable-blink-features=AutomationControlled']
120
+ });
121
+ const page = ctx.pages()[0] || await ctx.newPage();
122
+
123
+ if (BOOTSTRAP) {
124
+ // v3.10.1: walk both the OneNote-for-Web SaaS surface AND a SharePoint personal-site URL
125
+ // so the persisted profile carries cookies for BOTH cookie domains. The runner's section
126
+ // URLs (Doc.aspx) live on sharepoint(-df).com — those cookies are not set by visiting
127
+ // onenote.cloud.microsoft alone.
128
+ const SPO_HOSTS_TO_SEED = [
129
+ 'https://microsoft-my.sharepoint.com/',
130
+ 'https://microsoft-my.sharepoint-df.com/'
131
+ ];
132
+ await page.goto('https://onenote.cloud.microsoft/');
133
+ console.error('[bootstrap] Step 1/2: Sign in to OneNote-for-Web. After your name appears top-right, do NOT close the window — wait for the next prompt.');
134
+ // Wait for an actual sign-in indicator (notebook chrome / account manager), NOT just the URL —
135
+ // the URL matches instantly because we just navigated TO onenote.cloud.microsoft, which made
136
+ // the prior implementation skip the sign-in wait entirely. Up to 5 min for the user to sign in.
137
+ try {
138
+ await page.waitForSelector(
139
+ '[aria-label*="Account manager" i], [data-automationid="NotebookList"], button[aria-label*="notebook" i], iframe[src*="onenoteframe.aspx"]',
140
+ { timeout: 5 * 60 * 1000 }
141
+ );
142
+ await page.waitForTimeout(2000);
143
+ console.error('[bootstrap] Sign-in detected (OneNote chrome rendered).');
144
+ } catch (e) {
145
+ console.error('[bootstrap] WARNING: sign-in indicator not detected within 5 minutes — cookies may not be valid.');
146
+ }
147
+
148
+ // Now seed SharePoint cookies by visiting the personal-site root for each tenant variant.
149
+ // If the user has a personal site at that host, SSO will complete silently (cookies set).
150
+ // If the host doesn't exist for the tenant, the page will 404 — harmless.
151
+ for (const host of SPO_HOSTS_TO_SEED) {
152
+ try {
153
+ console.error(`[bootstrap] Step 2/2: Seeding SharePoint cookies at ${host}`);
154
+ await page.goto(host, { timeout: 60000 });
155
+ await page.waitForTimeout(4000);
156
+ } catch (e) {
157
+ console.error(`[bootstrap] (skipped ${host}: ${e.message})`);
158
+ }
159
+ }
160
+ console.error('[bootstrap] Both surfaces visited. Close the browser window when ready.');
161
+ try {
162
+ await page.waitForEvent('close', { timeout: 5 * 60 * 1000 });
163
+ } catch (e) { /* timeout = user left window open; we still saved cookies on each visit */ }
164
+ console.error('[bootstrap] Profile saved at: ' + PROFILE_DIR);
165
+ await ctx.close();
166
+ process.exit(0);
167
+ }
168
+
169
+ // --preflight standalone mode: just probe the OneNote-for-Web root and exit.
170
+ // Exit codes: 0 = ok, 3 = onenote-web-unavailable, 4 = auth-required, 1 = unexpected.
171
+ if (PREFLIGHT) {
172
+ const pf = await preflightOneNoteWeb(page, PREFLIGHT_TIMEOUT_MS);
173
+ console.log(JSON.stringify({ preflight: pf }, null, 2));
174
+ await ctx.close();
175
+ if (pf.ok) process.exit(0);
176
+ if (pf.reason === 'auth-required') process.exit(4);
177
+ if (pf.reason === 'onenote-web-unavailable') process.exit(3);
178
+ process.exit(1);
179
+ }
180
+
181
+ let runStatus = 'ok';
182
+ let exitedEarly = false;
183
+ let authRequiredCount = 0;
184
+ const out = { project: PROJECT, sectionUrl: SECTION_URL, pages: [] };
185
+
186
+ // kushi v3.11.0 — inline pre-flight before navigating to the section URL.
187
+ // Catches the "OneNote-for-Web is broken / notebook won't open" state EARLY,
188
+ // so we don't waste TIMEOUT_MS waiting for a canvas frame that will never attach
189
+ // and then mislabel the failure as auth-required.
190
+ const pf = SKIP_PREFLIGHT ? { ok: true, skipped: true } : await preflightOneNoteWeb(page, PREFLIGHT_TIMEOUT_MS);
191
+ out.preflight = pf;
192
+ if (!pf.ok) {
193
+ const rs = pf.reason === 'auth-required' ? 'auth-required' : 'notebook-unavailable';
194
+ console.log(JSON.stringify({
195
+ ...out,
196
+ runStatus: rs,
197
+ exitedEarly: true,
198
+ error: `pre-flight ${pf.reason}: ${pf.detail}`
199
+ }, null, 2));
200
+ await ctx.close();
201
+ process.exit(0);
202
+ }
203
+
204
+ try {
205
+ await page.goto(SECTION_URL, { timeout: TIMEOUT_MS });
206
+
207
+ // Account picker handling — one quick attempt to pick the only listed account.
208
+ try {
209
+ await page.waitForSelector('button:has-text("Sign in with"), iframe[name="WebApplicationFrame"]', { timeout: 15000 });
210
+ const pickerBtn = await page.$('button:has-text("Sign in with")');
211
+ if (pickerBtn) await pickerBtn.click();
212
+ } catch (e) { /* no picker */ }
213
+
214
+ // Wait for the OneNote canvas frame to attach. Concurrently watch for the
215
+ // "Sorry, we ran into a problem" dialog so we can fail fast with the correct
216
+ // diagnostic (notebook-unavailable, NOT auth-required).
217
+ const SECTION_ERROR_RE = /Sorry, we ran into a problem|Something went wrong|We couldn.?t open|This notebook can.?t be opened|There was a problem/i;
218
+ let wac = null;
219
+ let sectionErrorDetail = null;
220
+ const deadline = Date.now() + TIMEOUT_MS;
221
+ while (Date.now() < deadline) {
222
+ wac = page.frames().find(f => f.url().includes('onenoteframe.aspx'));
223
+ if (wac) break;
224
+ if (isLoginRedirect(page.url())) {
225
+ runStatus = 'auth-required';
226
+ exitedEarly = true;
227
+ break;
228
+ }
229
+ // Probe every frame for the section-load error dialog.
230
+ for (const f of page.frames()) {
231
+ try {
232
+ const txt = await f.evaluate(() => document.body ? document.body.innerText : '');
233
+ if (txt && SECTION_ERROR_RE.test(txt)) {
234
+ const m = txt.match(SECTION_ERROR_RE);
235
+ sectionErrorDetail = `"${m[0]}" at ${f.url() || page.url()}`;
236
+ break;
237
+ }
238
+ } catch (e) { /* detached */ }
239
+ }
240
+ if (sectionErrorDetail) {
241
+ runStatus = 'notebook-unavailable';
242
+ exitedEarly = true;
243
+ break;
244
+ }
245
+ await page.waitForTimeout(500);
246
+ }
247
+ if (!wac) {
248
+ if (sectionErrorDetail) {
249
+ console.log(JSON.stringify({
250
+ ...out,
251
+ runStatus: 'notebook-unavailable',
252
+ exitedEarly: true,
253
+ error: `OneNote-for-Web returned an error dialog instead of rendering the section: ${sectionErrorDetail}. This is NOT auth-required — recover the notebook (open in OneNote desktop, force sync) or re-capture one_sectionWebUrl from the address bar.`
254
+ }, null, 2));
255
+ await ctx.close();
256
+ process.exit(0);
257
+ }
258
+ if (!exitedEarly) runStatus = 'auth-required';
259
+ console.log(JSON.stringify({ ...out, runStatus, exitedEarly: true, error: 'OneNote canvas frame did not attach (likely auth required).' }, null, 2));
260
+ await ctx.close();
261
+ process.exit(0);
262
+ }
263
+
264
+ // Wait for the page-rail to render. Honor --timeout (default 60s) instead of 30s
265
+ // hardcoded — large notebooks (e.g. AGCO with many sections) can be slow to enumerate.
266
+ // Single-page sections render as "<title>, Page. Selected." (no "page N of M") so we
267
+ // accept either format.
268
+ await wac.waitForFunction(() =>
269
+ Array.from(document.querySelectorAll('[aria-label]')).some(n => {
270
+ const l = n.getAttribute('aria-label') || '';
271
+ return /, page \d+ of \d+, Page\./.test(l) || /, Page\. Selected\./.test(l);
272
+ })
273
+ , null, { timeout: TIMEOUT_MS });
274
+
275
+ // Step A — enumerate
276
+ const pages = await wac.evaluate(() => {
277
+ const out = [];
278
+ const seen = new Set();
279
+ for (const n of document.querySelectorAll('[aria-label]')) {
280
+ const label = n.getAttribute('aria-label') || '';
281
+ let m = label.match(/^(.*?), page (\d+) of (\d+), Page/);
282
+ if (m) {
283
+ const key = `${m[1]}|${m[2]}`;
284
+ if (!seen.has(key)) { seen.add(key); out.push({ title: m[1], pos: parseInt(m[2]), total: parseInt(m[3]) }); }
285
+ continue;
286
+ }
287
+ m = label.match(/^(.*?), Page\. Selected\./);
288
+ if (m) {
289
+ const key = `${m[1]}|1`;
290
+ if (!seen.has(key)) { seen.add(key); out.push({ title: m[1], pos: 1, total: 1 }); }
291
+ }
292
+ }
293
+ return out;
294
+ });
295
+
296
+ const queue = TITLES_FILTER ? pages.filter(p => TITLES_FILTER.includes(p.title)) : pages;
297
+
298
+ // Step B — per-page navigate + extract
299
+ for (const p of queue) {
300
+ let captured_via = 'browser';
301
+ let last_status = 'captured';
302
+ let webPageId = null;
303
+ let body = '';
304
+
305
+ try {
306
+ await wac.evaluate((title) => {
307
+ const tgt = Array.from(document.querySelectorAll('[aria-label]'))
308
+ .find(n => (n.getAttribute('aria-label') || '').startsWith(title + ', page'));
309
+ if (tgt) tgt.click();
310
+ }, p.title);
311
+ await page.waitForTimeout(SETTLE_MS);
312
+
313
+ if (isLoginRedirect(page.url())) {
314
+ runStatus = 'auth-required';
315
+ exitedEarly = true;
316
+ last_status = 'auth-required';
317
+ authRequiredCount++;
318
+ out.pages.push({ ...p, webPageId, last_status, captured_via, body: '', bodyLen: 0, capturedAt: new Date().toISOString() });
319
+ break;
320
+ }
321
+
322
+ webPageId = parseWebPageIdFromUrl(page.url());
323
+ body = await wac.evaluate(() => {
324
+ const node = document.querySelector('#PageContentWrapper')
325
+ || document.querySelector('.Page')
326
+ || document.querySelector('[role="main"]');
327
+ return node ? node.innerText : '';
328
+ });
329
+
330
+ if (!body || body.length < 50) last_status = 'short-suspect';
331
+ } catch (e) {
332
+ last_status = 'browser-error';
333
+ runStatus = runStatus === 'ok' ? 'partial' : runStatus;
334
+ }
335
+
336
+ out.pages.push({
337
+ ...p,
338
+ webPageId,
339
+ last_status,
340
+ captured_via,
341
+ body,
342
+ bodyLen: body.length,
343
+ capturedAt: new Date().toISOString()
344
+ });
345
+ }
346
+ } catch (e) {
347
+ runStatus = isLoginRedirect(page.url()) ? 'auth-required' : 'partial';
348
+ out.error = String(e && e.message || e);
349
+ }
350
+
351
+ out.runStatus = runStatus;
352
+ out.exitedEarly = exitedEarly;
353
+ out.authRequiredCount = authRequiredCount;
354
+ console.log(JSON.stringify(out, null, 2));
355
+ await ctx.close();
356
+ })().catch(e => { console.error(e); process.exit(1); });
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env node
2
+ // recapture-section-url.mjs
3
+ //
4
+ // Usage:
5
+ // node recapture-section-url.mjs --project <name> --engagement-root <path> [--url <url>] [--check]
6
+ //
7
+ // Purpose:
8
+ // Fill / repair the browser-required OneNote registry fields for a project so
9
+ // pull-onenote's Playwright runner can navigate directly to the section URL.
10
+ //
11
+ // Browser-required keys (all must be present):
12
+ // - one_sectionName (e.g. "AGCO.one")
13
+ // - one_sectionFileId (GUID inside wd=target(...|<GUID>/))
14
+ // - one_notebookSourceDoc (GUID inside sourcedoc={<GUID>})
15
+ // - one_notebookSpoBaseUrl (https://<host>/personal/<upn>)
16
+ // - one_sectionWebUrl (full Doc.aspx URL — regenerated)
17
+ //
18
+ // Modes:
19
+ // --check : exit 0 if all five present, 1 if missing. Prints JSON status.
20
+ // --url U : non-interactive parse + persist (used when caller already has the URL).
21
+ // default : interactive — prompt user to paste the URL on stdin.
22
+ //
23
+ // Doctrine:
24
+ // This is the canonical "self-heal browser fields" path used by both
25
+ // bootstrap-project (Step 4a) and refresh-project (Step 2 OneNote pre-dispatch).
26
+ // It is NOT re-discovery — wdsectionfileid is preserved if already present and
27
+ // only the browser-side fields are added/repaired.
28
+
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import readline from 'node:readline';
32
+
33
+ const args = Object.fromEntries(process.argv.slice(2).reduce((acc, a, i, arr) => {
34
+ if (a.startsWith('--')) {
35
+ const k = a.slice(2);
36
+ const v = (arr[i + 1] && !arr[i + 1].startsWith('--')) ? arr[i + 1] : 'true';
37
+ acc.push([k, v]);
38
+ }
39
+ return acc;
40
+ }, []));
41
+
42
+ const PROJECT = args.project;
43
+ const ENGAGEMENT_ROOT = args['engagement-root'];
44
+ const URL_ARG = args.url && args.url !== 'true' ? args.url : null;
45
+ const CHECK_ONLY = args.check === 'true';
46
+
47
+ if (!PROJECT || !ENGAGEMENT_ROOT) {
48
+ console.error('Usage: --project <name> --engagement-root <path> [--url <url>] [--check]');
49
+ process.exit(2);
50
+ }
51
+
52
+ const REG_PATH = path.join(ENGAGEMENT_ROOT, '.project-evidence', 'm365', 'm365-mutable.json');
53
+ if (!fs.existsSync(REG_PATH)) {
54
+ console.error(`[recapture-section-url] Registry not found: ${REG_PATH}`);
55
+ process.exit(3);
56
+ }
57
+
58
+ const REQUIRED_KEYS = [
59
+ 'one_sectionName',
60
+ 'one_sectionFileId',
61
+ 'one_notebookSourceDoc',
62
+ 'one_notebookSpoBaseUrl',
63
+ 'one_sectionWebUrl'
64
+ ];
65
+
66
+ function loadRegistry() {
67
+ return JSON.parse(fs.readFileSync(REG_PATH, 'utf8'));
68
+ }
69
+
70
+ function findEntry(reg, project) {
71
+ const sections = reg?.m365Mutable?.knownSections || {};
72
+ // exact > prefix > contains, case-insensitive
73
+ const keys = Object.keys(sections);
74
+ const lower = project.toLowerCase();
75
+ let key = keys.find(k => k.toLowerCase() === lower)
76
+ || keys.find(k => k.toLowerCase().startsWith(lower))
77
+ || keys.find(k => k.toLowerCase().includes(lower));
78
+ if (!key) return { key: null, entry: null };
79
+ return { key, entry: sections[key] };
80
+ }
81
+
82
+ function completeness(entry) {
83
+ const missing = REQUIRED_KEYS.filter(k => !entry || !entry[k] || String(entry[k]).trim() === '');
84
+ return { complete: missing.length === 0, missing };
85
+ }
86
+
87
+ // HARD RULE (kushi v3.10.2+): one_sectionWebUrl MUST be the URL the user actually copied
88
+ // from the OneNote-for-Web address bar. There is NO reliable formula to synthesize it.
89
+ // Two formulas were tried and both failed:
90
+ // - wd=target(<name>|<fileId>/) → "Sorry, we ran into a problem"
91
+ // - wd=target(/<name>/) → "Sorry, we ran into a problem" for some sections
92
+ // Even when the section name and notebook GUIDs are correct, OneNote's routing requires
93
+ // query params we cannot reverse-engineer (likely a wd token or session-bound parameter).
94
+ // HCA's URL works because the user opened it once and copied it; AGCO's must do the same.
95
+ //
96
+ // Auto-heal MAY inherit notebook-level fields from siblings (one_notebookSourceDoc,
97
+ // one_notebookSpoBaseUrl, one_notebookName) — these are identical across all sections
98
+ // in the same notebook. But one_sectionWebUrl MUST NOT be synthesized — the gate must
99
+ // fall through to the interactive paste prompt.
100
+
101
+ function tryAutoHeal(reg, key, entry) {
102
+ // Inherit ONLY notebook-level fields (sourceDoc, spoBaseUrl, name). The section URL
103
+ // itself cannot be inherited or synthesized — it must be user-pasted.
104
+ let sourcedocGuid = entry.one_notebookSourceDoc;
105
+ let baseUrl = entry.one_notebookSpoBaseUrl;
106
+ let notebookName = entry.one_notebookName;
107
+ let inheritedFrom = null;
108
+
109
+ if (!sourcedocGuid || !baseUrl) {
110
+ const sections = reg.m365Mutable.knownSections;
111
+ for (const [siblingKey, sibling] of Object.entries(sections)) {
112
+ if (siblingKey === key) continue;
113
+ if (!sibling.one_notebookSourceDoc || !sibling.one_notebookSpoBaseUrl) continue;
114
+ if (sibling.one_confidence !== 'high') continue;
115
+ sourcedocGuid = sourcedocGuid || sibling.one_notebookSourceDoc;
116
+ baseUrl = baseUrl || sibling.one_notebookSpoBaseUrl;
117
+ notebookName = notebookName || sibling.one_notebookName;
118
+ inheritedFrom = siblingKey;
119
+ break;
120
+ }
121
+ }
122
+
123
+ if (!sourcedocGuid || !baseUrl) return null;
124
+
125
+ // Return notebook-level fields ONLY. Caller must still prompt for one_sectionWebUrl
126
+ // unless the entry already has one (preserved from a prior --url paste).
127
+ return {
128
+ fields: {
129
+ one_notebookSourceDoc: sourcedocGuid,
130
+ one_notebookSpoBaseUrl: baseUrl,
131
+ one_notebookName: notebookName || null
132
+ },
133
+ inheritedFrom,
134
+ stillNeedsPaste: !entry.one_sectionWebUrl
135
+ };
136
+ }
137
+
138
+ function parseUrl(rawUrl) {
139
+ // Decode in case of %-escaped wd= and sourcedoc= fragments.
140
+ let url;
141
+ try {
142
+ url = new URL(rawUrl);
143
+ } catch (e) {
144
+ throw new Error('Not a valid URL.');
145
+ }
146
+ if (!/sharepoint(-df)?\.com/i.test(url.host)) {
147
+ throw new Error('URL host does not look like SharePoint (must contain sharepoint.com or sharepoint-df.com).');
148
+ }
149
+ if (!/Doc\.aspx$/i.test(url.pathname)) {
150
+ throw new Error('URL path must end with Doc.aspx (open the section in OneNote-for-Web and copy the address bar URL).');
151
+ }
152
+
153
+ const sourcedoc = url.searchParams.get('sourcedoc');
154
+ if (!sourcedoc) throw new Error('URL is missing the sourcedoc query parameter.');
155
+ const sourcedocGuid = sourcedoc.replace(/[{}]/g, '').trim();
156
+ if (!/^[0-9a-f-]{36}$/i.test(sourcedocGuid)) throw new Error(`sourcedoc is not a GUID: ${sourcedoc}`);
157
+
158
+ const wdRaw = url.searchParams.get('wd');
159
+ if (!wdRaw) throw new Error('URL is missing the wd query parameter (open the section, not just the notebook).');
160
+ const wdDecoded = decodeURIComponent(wdRaw);
161
+ // Accept either form — both are valid wd= shapes that OneNote emits at different times.
162
+ // Form 1: target(<name>|<GUID>/...)
163
+ // Form 2: target(/<name>/)
164
+ let sectionName = null;
165
+ let sectionFileId = null;
166
+ let m1 = wdDecoded.match(/^target\(([^|)/]+)\|([0-9a-f-]{36})/i);
167
+ if (m1) {
168
+ sectionName = m1[1];
169
+ sectionFileId = m1[2];
170
+ } else {
171
+ let m2 = wdDecoded.match(/^target\(\/([^/)]+)\//i);
172
+ if (m2) sectionName = m2[1];
173
+ }
174
+ if (!sectionName) throw new Error(`wd target could not be parsed: ${wdDecoded}`);
175
+
176
+ const personalMatch = url.pathname.match(/^(\/personal\/[^/]+)/i);
177
+ if (!personalMatch) throw new Error('URL pathname does not contain /personal/<upn> — is this a personal OneDrive notebook?');
178
+ const baseUrl = `${url.origin}${personalMatch[1]}`;
179
+
180
+ // PRESERVE the user's URL verbatim — no reformulation. This is the URL the runner
181
+ // navigates to. Empirically, only user-captured URLs route correctly in OneNote-for-Web.
182
+ return {
183
+ one_sectionName: sectionName,
184
+ one_sectionFileId: sectionFileId, // may be null if user pasted form 2; that's OK
185
+ one_notebookSourceDoc: sourcedocGuid,
186
+ one_notebookSpoBaseUrl: baseUrl,
187
+ one_sectionWebUrl: rawUrl
188
+ };
189
+ }
190
+
191
+ function persist(reg, key, fields) {
192
+ const sections = reg.m365Mutable.knownSections;
193
+ if (!sections[key]) sections[key] = {};
194
+ const entry = sections[key];
195
+ for (const [k, v] of Object.entries(fields)) {
196
+ entry[k] = v;
197
+ }
198
+ entry.one_discoveredOn = new Date().toISOString().slice(0, 10);
199
+ entry.one_confidence = 'high';
200
+ // Bump file metadata.
201
+ reg.m365Mutable.metadata.lastUpdated = new Date().toISOString().slice(0, 10);
202
+ reg.m365Mutable.metadata.lastUpdatedAt = new Date().toISOString();
203
+ reg.m365Mutable.metadata.lastUpdatedBy = 'kushi-recapture-section-url v0.1.0';
204
+ fs.writeFileSync(REG_PATH, JSON.stringify(reg, null, 2) + '\n', 'utf8');
205
+ }
206
+
207
+ async function promptUrl() {
208
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
209
+ console.error('');
210
+ console.error(`[recapture-section-url] Browser-required OneNote fields are missing for project "${PROJECT}".`);
211
+ console.error('');
212
+ console.error('To fix this:');
213
+ console.error(' 1. Open https://onenote.cloud.microsoft/ in your normal browser (Edge).');
214
+ console.error(' 2. Open the project notebook and CLICK INTO the project section (e.g. <Project>.one).');
215
+ console.error(' 3. Copy the entire URL from the address bar.');
216
+ console.error(' 4. Paste it here and press Enter.');
217
+ console.error('');
218
+ return new Promise(resolve => {
219
+ rl.question('Paste OneNote section URL: ', answer => {
220
+ rl.close();
221
+ resolve(answer.trim());
222
+ });
223
+ });
224
+ }
225
+
226
+ (async () => {
227
+ const reg = loadRegistry();
228
+ const { key, entry } = findEntry(reg, PROJECT);
229
+
230
+ if (!key) {
231
+ if (CHECK_ONLY) {
232
+ console.log(JSON.stringify({ status: 'no-entry', project: PROJECT, missing: REQUIRED_KEYS }));
233
+ process.exit(1);
234
+ }
235
+ console.error(`[recapture-section-url] No knownSections entry for "${PROJECT}". Run bootstrap-project first.`);
236
+ process.exit(4);
237
+ }
238
+
239
+ const status = completeness(entry);
240
+ if (CHECK_ONLY) {
241
+ console.log(JSON.stringify({ status: status.complete ? 'ok' : 'incomplete', project: key, missing: status.missing }));
242
+ process.exit(status.complete ? 0 : 1);
243
+ }
244
+
245
+ if (status.complete && !URL_ARG) {
246
+ console.error(`[recapture-section-url] Project "${key}" already has all browser-required fields. Nothing to do.`);
247
+ console.log(JSON.stringify({ status: 'ok', project: key, missing: [] }));
248
+ process.exit(0);
249
+ }
250
+
251
+ // Auto-heal path: if we have wdsectionfileid + section name and a sibling project shares the notebook,
252
+ // infer notebookSourceDoc + spoBaseUrl from that sibling. No human input required.
253
+ if (!URL_ARG) {
254
+ const auto = tryAutoHeal(reg, key, entry);
255
+ if (auto) {
256
+ // Cross-check existing one_sectionWebUrl: if it differs from the canonical we'd write, warn (we'll overwrite).
257
+ if (entry.one_sectionWebUrl && entry.one_sectionWebUrl !== auto.fields.one_sectionWebUrl) {
258
+ console.error(`[recapture-section-url] Replacing stale one_sectionWebUrl with canonical form (auto-healed from sibling "${auto.inheritedFrom}").`);
259
+ } else {
260
+ console.error(`[recapture-section-url] Auto-healing browser fields for "${key}" using shared-notebook pattern (sibling: "${auto.inheritedFrom}").`);
261
+ }
262
+ persist(reg, key, auto.fields);
263
+ console.error(`[recapture-section-url] ✅ Persisted ${Object.keys(auto.fields).length} browser fields for "${key}" → m365-mutable.json#knownSections.${key}`);
264
+ console.log(JSON.stringify({ status: 'persisted', project: key, mode: 'auto-heal', inheritedFrom: auto.inheritedFrom, fields: auto.fields }));
265
+ process.exit(0);
266
+ }
267
+ }
268
+
269
+ let rawUrl = URL_ARG;
270
+ if (!rawUrl) {
271
+ rawUrl = await promptUrl();
272
+ if (!rawUrl) {
273
+ console.error('[recapture-section-url] No URL provided. Aborting.');
274
+ process.exit(5);
275
+ }
276
+ }
277
+
278
+ let fields;
279
+ try {
280
+ fields = parseUrl(rawUrl);
281
+ } catch (e) {
282
+ console.error(`[recapture-section-url] Could not parse URL: ${e.message}`);
283
+ process.exit(6);
284
+ }
285
+
286
+ // Cross-check: if registry already had a one_sectionFileId, warn (don't block) on mismatch.
287
+ if (entry.one_sectionFileId && entry.one_sectionFileId !== fields.one_sectionFileId) {
288
+ console.error(`[recapture-section-url] WARNING: existing one_sectionFileId (${entry.one_sectionFileId}) differs from URL (${fields.one_sectionFileId}). Overwriting with URL value (browser path is canonical).`);
289
+ }
290
+
291
+ persist(reg, key, fields);
292
+ console.error(`[recapture-section-url] ✅ Persisted browser fields for "${key}" → m365-mutable.json#knownSections.${key}`);
293
+ console.log(JSON.stringify({ status: 'persisted', project: key, fields }));
294
+ process.exit(0);
295
+ })();