kushi-agents 3.4.2 → 3.12.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.
Files changed (69) hide show
  1. package/README.md +33 -0
  2. package/package.json +15 -3
  3. package/plugin/agents/kushi.agent.md +155 -147
  4. package/plugin/instructions/ado-bootstrap-discovery.instructions.md +111 -0
  5. package/plugin/instructions/ado-engagement-tree.instructions.md +73 -0
  6. package/plugin/instructions/answer-from-evidence.instructions.md +1 -1
  7. package/plugin/instructions/auth-and-retry.instructions.md +51 -16
  8. package/plugin/instructions/azure-auth-patterns.instructions.md +13 -6
  9. package/plugin/instructions/bootstrap-status-format.instructions.md +113 -0
  10. package/plugin/instructions/capture-learnings.instructions.md +95 -0
  11. package/plugin/instructions/cleanup-on-resolution.instructions.md +69 -0
  12. package/plugin/instructions/crm-bootstrap-discovery.instructions.md +79 -0
  13. package/plugin/instructions/crm-internal-vs-confirmed.instructions.md +79 -0
  14. package/plugin/instructions/evidence-confidence-ladder.instructions.md +66 -0
  15. package/plugin/instructions/evidence-layout-canonical.instructions.md +115 -0
  16. package/plugin/instructions/evidence-thoroughness.instructions.md +82 -12
  17. package/plugin/instructions/full-view-gate.instructions.md +91 -0
  18. package/plugin/instructions/m365-id-registry.instructions.md +134 -0
  19. package/plugin/instructions/meetings-verbatim-required.instructions.md +176 -0
  20. package/plugin/instructions/run-reports.instructions.md +129 -0
  21. package/plugin/instructions/scope-boundaries.instructions.md +218 -0
  22. package/plugin/instructions/snapshot-vs-stream.instructions.md +2 -0
  23. package/plugin/instructions/update-ledger.instructions.md +132 -0
  24. package/plugin/instructions/verbatim-by-default.instructions.md +73 -0
  25. package/plugin/instructions/workiq-first.instructions.md +15 -31
  26. package/plugin/instructions/workiq-only.instructions.md +193 -0
  27. package/plugin/learnings/README.md +50 -0
  28. package/plugin/learnings/ado.md +45 -0
  29. package/plugin/learnings/crm.md +96 -0
  30. package/plugin/learnings/cross-cutting.md +36 -0
  31. package/plugin/learnings/email.md +33 -0
  32. package/plugin/learnings/meetings.md +30 -0
  33. package/plugin/learnings/misc.md +46 -0
  34. package/plugin/learnings/onenote.md +215 -0
  35. package/plugin/learnings/sharepoint.md +5 -0
  36. package/plugin/learnings/teams.md +5 -0
  37. package/plugin/plugin.json +22 -2
  38. package/plugin/prompts/apply-ado.prompt.md +14 -0
  39. package/plugin/prompts/propose-ado.prompt.md +12 -0
  40. package/plugin/reference-packs/fde/crm-field-manifest.md +165 -0
  41. package/plugin/skills/apply-ado-update/SKILL.md +125 -0
  42. package/plugin/skills/ask-project/SKILL.md +2 -0
  43. package/plugin/skills/bootstrap-project/SKILL.md +81 -3
  44. package/plugin/skills/propose-ado-update/SKILL.md +108 -0
  45. package/plugin/skills/pull-ado/SKILL.md +173 -23
  46. package/plugin/skills/pull-crm/SKILL.md +168 -15
  47. package/plugin/skills/pull-email/SKILL.md +139 -22
  48. package/plugin/skills/pull-meetings/SKILL.md +109 -25
  49. package/plugin/skills/pull-misc/README.md +84 -0
  50. package/plugin/skills/pull-misc/SKILL.md +257 -0
  51. package/plugin/skills/pull-misc/runner.mjs +280 -0
  52. package/plugin/skills/pull-onenote/README.md +90 -0
  53. package/plugin/skills/pull-onenote/SKILL.md +400 -51
  54. package/plugin/skills/pull-onenote/runner.mjs +356 -0
  55. package/plugin/skills/pull-onenote/scripts/recapture-section-url.mjs +295 -0
  56. package/plugin/skills/pull-onenote/write-snapshot.mjs +271 -0
  57. package/plugin/skills/pull-sharepoint/SKILL.md +44 -12
  58. package/plugin/skills/pull-teams/SKILL.md +40 -11
  59. package/plugin/skills/refresh-project/SKILL.md +33 -2
  60. package/plugin/skills/self-check/run.ps1 +186 -4
  61. package/plugin/templates/ado-update/discussion-comment.template.md +26 -0
  62. package/plugin/templates/ado-update/integrations-ado-writes.example.yml +49 -0
  63. package/plugin/templates/ado-update/proposed.template.md +78 -0
  64. package/plugin/templates/init/external-links.template.txt +30 -0
  65. package/plugin/templates/init/project-integrations.template.yml +57 -2
  66. package/plugin/templates/snapshot/meeting-verbatim.template.md +110 -0
  67. package/plugin/templates/snapshot/meetings-series-index.template.md +3 -1
  68. package/plugin/templates/snapshot/onenote-page.template.md +92 -23
  69. package/plugin/templates/weekly/meetings-stream.template.md +11 -6
@@ -0,0 +1,257 @@
1
+ ---
2
+ name: "pull-misc"
3
+ version: "2.0.1"
4
+ description: "Pull miscellaneous evidence from a user-curated link list (<project>/external-links.txt). Handles links that don't fit the per-source pull-* skills: Loop pages, public web pages, learn.microsoft.com / docs sites, GitHub repos, local files, anything else linkable. Routes by type: delegated types (onenote, sharepoint, ado) are skipped because dedicated pull-* skills handle them. Per-link retry registry tracks last_status across runs."
5
+ ---
6
+
7
+ # Skill: pull-misc
8
+
9
+ > **v3.9.0 contracts** — This skill operates under six HARD-rule doctrines:
10
+ > - `verbatim-by-default.instructions.md` — full bodies by default; no preview-grade pulls accepted.
11
+ > - `m365-id-registry.instructions.md` — discover-once / consume-deterministically (per-link retry registry under `misc_links[]`).
12
+ > - `capture-learnings.instructions.md` — every fix/discovery is logged to `plugin/learnings/misc.md`.
13
+ > - `cleanup-on-resolution.instructions.md` — when a placeholder URL resolves, all stale unavailable-markers are upgraded in the same turn.
14
+ > - `run-reports.instructions.md` — every refresh writes a per-user report under `Evidence/<alias>/refresh-reports/`.
15
+ > - `thoroughness-detector.instructions.md` — the per-link retry registry IS this skill's thoroughness contract.
16
+
17
+ > **Canonical evidence layout** (HARD, kushi v3.12.1+): all artifacts produced by this skill MUST be written under `<project>/Evidence/<alias>/<source>/{snapshot,stream,...}/` — sibling folders under `<project>/` (e.g. `<project>/<source>-context/`, `<project>/<source>/`, `<project>/_Weekly Summaries/`) are FORBIDDEN. See `evidence-layout-canonical.instructions.md`.
18
+
19
+ Pulls **misc** evidence in two shapes per `snapshot-vs-stream.instructions.md`:
20
+
21
+ - **snapshot/** — full content per link — one file per link with last-fetched + verbatim body
22
+ - **stream/** — change events (HTTP `last-modified` header drift, git commit deltas where applicable, Loop edit signals via WorkIQ when available)
23
+
24
+ ## Tools (in order)
25
+
26
+ 1. **Playwright (browser-scrape, persisted profile)** — for `loop` links. Reuses `~/.copilot/playwright-profile/onenote/` because it's the same M365 cookie scope. Implementation: `plugin/skills/pull-misc/runner.mjs` branch `loop`.
27
+ 2. **HTTP fetch + Readability** — for `web` / `confluence` (anonymous) / `learn` / `docs` / `pdf` links. Implementation: `plugin/skills/pull-misc/runner.mjs` branch `web`.
28
+ 3. **Local file read** — for `file` links pointing at project-relative paths. Implementation: `plugin/skills/pull-misc/runner.mjs` branch `file`.
29
+ 4. **WorkIQ** — used only for stream/edit-event signals on `loop` links (Loop edit events surface in the M365 search index, same as OneNote stream events). NOT used for body retrieval — Loop bodies require the browser path.
30
+ 5. **Delegation** — `onenote`, `sharepoint`, `ado` are NOT pulled here; the runner records `captured_via: delegated` so the registry stays complete and the user can see which links flow through which skill. The actual capture is done by `pull-onenote`, `pull-sharepoint`, `pull-ado` respectively.
31
+
32
+ ## The link list (`<project>/external-links.txt`)
33
+
34
+ This file already exists in most projects (per memory: the External Links Context doctrine). pull-misc preserves the existing format **without breaking changes** and adds `loop` as a recognized type.
35
+
36
+ Format:
37
+
38
+ ```text
39
+ # Comments start with #
40
+ # One link per line:
41
+ <type>|<owner>|<title>|<url-or-path>|<notes>
42
+ ```
43
+
44
+ **Recognized types (v3.9.0):**
45
+
46
+ | Type | Routed to | Captured via |
47
+ |---|---|---|
48
+ | `onenote` | pull-onenote | delegated |
49
+ | `sharepoint` | pull-sharepoint | delegated |
50
+ | `ado` | pull-ado | delegated |
51
+ | `loop` **(new)** | pull-misc | browser (Playwright, OneNote profile) |
52
+ | `web` | pull-misc | http (fetch + readability) |
53
+ | `confluence` | pull-misc | http (anonymous) — auth'd Confluence not yet supported |
54
+ | `learn` | pull-misc | http |
55
+ | `docs` | pull-misc | http |
56
+ | `pdf` | pull-misc | http (binary download + text extract via pdfjs) |
57
+ | `github` | pull-misc | http (raw README + repo metadata) |
58
+ | `file` | pull-misc | file (local read; path is project-relative) |
59
+ | anything else | pull-misc | http (best-effort) |
60
+
61
+ **Placeholder URLs** matching `<PASTE_*_URL>` or `<TODO*>` are SKIPPED with `last_status: placeholder`. They surface in the run report so the user can fill them in.
62
+
63
+ ## Empirical contract
64
+
65
+ These three facts are HARD-rule:
66
+
67
+ 1. **The user's external-links.txt IS the source of truth for what counts as "misc evidence" for that project.** No fuzzy auto-discovery. If a link isn't in the file, it isn't pulled. Discovery is a separate (manual) step where the user paste links in.
68
+ 2. **Web fetches use HTTP first, browser only if HTTP fails or returns interactive shell.** SPAs that don't server-render (Loop, some Confluence, dashboards) drop to browser. Don't run a browser when curl works.
69
+ 3. **Loop pages MUST go through browser.** Same gap as OneNote — Loop has no Graph API for component content. The /loop skill already proved this; pull-misc reuses the same Playwright profile.
70
+
71
+ ## Pre-flight
72
+
73
+ ```pwsh
74
+ # A. Playwright profile (only required if external-links.txt contains loop links)
75
+ $prof = "$env:USERPROFILE\.copilot\playwright-profile\onenote"
76
+ if (-not (Test-Path $prof)) {
77
+ Write-Host "[pull-misc] Playwright profile not yet seeded — loop links will be skipped with last_status: auth-required."
78
+ Write-Host "[pull-misc] Run plugin/skills/pull-onenote/runner.mjs --bootstrap once to seed it."
79
+ }
80
+
81
+ # B. Node deps
82
+ $nodeDeps = @('playwright', 'jsdom', '@mozilla/readability')
83
+ # Runner checks at startup; if missing, exits with "missing-deps" status and lists them.
84
+ ```
85
+
86
+ ## Bootstrap discovery
87
+
88
+ For each project, the bootstrap step:
89
+
90
+ 1. **Locates `<project>/external-links.txt`.** If absent, write a starter template (same format as ABN AMRO has today) and mark `boundaries.misc.externalLinksPath` in `integrations.yml`.
91
+ 2. **Parses the file.** Skip comments, skip blank lines, skip placeholder URLs (`<PASTE_*_URL>`, `<TODO*>`).
92
+ 3. **Initializes `misc_links[]`** in `m365-mutable.json#knownSections.<projectKey>` with one entry per non-placeholder link, all `last_status: not-yet-attempted`.
93
+ 4. **Records** `boundaries.misc.linkCount` and `placeholderCount` to `bootstrap-status.md`.
94
+
95
+ ## Step A — enumerate (every refresh)
96
+
97
+ ```text
98
+ 1. Read external-links.txt; rebuild link inventory (in case the user added/removed links since last run).
99
+ 2. For each existing entry in misc_links[], match by (type, url) tuple — preserve attempts, last_attempt_at, snapshot_path.
100
+ 3. For new entries, create with last_status: not-yet-attempted.
101
+ 4. For deleted entries (in registry but no longer in file), mark last_status: removed and DO NOT delete the snapshot file (audit trail).
102
+ ```
103
+
104
+ ## Step B — fetch (per link)
105
+
106
+ The runner branches per `type`:
107
+
108
+ ### B.1 `loop` — browser
109
+
110
+ ```js
111
+ // pages.goto(loopUrl); detect login redirect → auth-required.
112
+ // Wait for primary canvas selector; for Loop, that's the Fluid container.
113
+ // Read text via document.body.innerText after settle.
114
+ ```
115
+
116
+ If login redirect detected: `last_status: auth-required`, `captured_via: browser`, exit early for this link.
117
+
118
+ ### B.2 `web` / `confluence` / `learn` / `docs` / `github` / unknown — HTTP
119
+
120
+ ```js
121
+ // fetch(url, { headers: { 'User-Agent': 'kushi-pull-misc/0.1' } });
122
+ // If 200 + HTML → run @mozilla/readability for main content extract.
123
+ // If 200 + text/* → use raw body.
124
+ // If 200 + application/pdf → buffer + pdfjs text extract.
125
+ // If 401/403 → last_status: auth-required (cannot resolve unattended for non-MS hosts).
126
+ // If 404 / DNS fail → last_status: fetch-failed with HTTP code.
127
+ // If 5xx → last_status: fetch-failed (transient — retry next refresh).
128
+ ```
129
+
130
+ ### B.3 `file` — local read
131
+
132
+ ```js
133
+ // path is project-relative; resolve under <engagement-root>/<project>/
134
+ // Read text; if binary, mark last_status: skipped-binary (use a different pipeline if needed).
135
+ ```
136
+
137
+ ### B.4 `onenote` / `sharepoint` / `ado` — delegate
138
+
139
+ Skip fetch. Write registry entry with `captured_via: delegated`, `delegated_to: pull-onenote|pull-sharepoint|pull-ado`. Per-skill captures already happen in their own pipelines.
140
+
141
+ ## Per-link retry registry
142
+
143
+ Stored at `m365-mutable.json#knownSections.<projectKey>.misc_links[]`. Schema:
144
+
145
+ ```jsonc
146
+ {
147
+ "type": "loop | web | learn | pdf | github | file | onenote | sharepoint | ado | ...",
148
+ "owner": "<as in external-links.txt>",
149
+ "title": "<as in external-links.txt>",
150
+ "url": "<as in external-links.txt>", // or path for type=file
151
+ "notes": "<as in external-links.txt>",
152
+ "last_status": "captured | placeholder | auth-required | fetch-failed | skipped-binary | removed | not-yet-attempted | delegated",
153
+ "captured_via": "browser | http | file | delegated",
154
+ "delegated_to": "pull-onenote | pull-sharepoint | pull-ado", // only when captured_via=delegated
155
+ "http_status": 200, // only for HTTP
156
+ "content_type": "text/html", // only for HTTP
157
+ "char_count": 12345,
158
+ "attempts": 3,
159
+ "last_attempt_at": "<ISO-8601>",
160
+ "captured_at": "<ISO-8601, only when last_status=captured>",
161
+ "snapshot_path": "Evidence/<alias>/misc/snapshot/<safe-type>__<safe-title>.md",
162
+ "etag": "<HTTP ETag if returned>", // for change detection
163
+ "last_modified_http":"<HTTP Last-Modified if returned>"
164
+ }
165
+ ```
166
+
167
+ ## Snapshot files
168
+
169
+ One file per non-delegated link at:
170
+
171
+ ```
172
+ <project>/Evidence/<alias>/misc/snapshot/<safe-type>__<safe-title>.md
173
+ ```
174
+
175
+ Schema:
176
+
177
+ ```yaml
178
+ ---
179
+ type: web
180
+ owner: "Microsoft"
181
+ title: "Microsoft Fabric Documentation Hub"
182
+ url: "https://learn.microsoft.com/fabric/"
183
+ last_status: captured
184
+ captured_via: http
185
+ http_status: 200
186
+ content_type: text/html
187
+ char_count: 18420
188
+ captured_at: 2026-05-14T04:35:00Z
189
+ schema: v0.1.0-misc
190
+ ---
191
+
192
+ # {title}
193
+
194
+ > Source: {url}
195
+ > Captured via {captured_via} ({captured_at})
196
+ > Owner: {owner}
197
+ > Type: {type}
198
+
199
+ ## AI Narrative Summary
200
+
201
+ {1-2 paragraph summary derived ONLY from the body text below — never fabricate, never extrapolate beyond the captured content}
202
+
203
+ ## Body (verbatim)
204
+
205
+ ```
206
+ {full extracted text}
207
+ ```
208
+ ```
209
+
210
+ ## Step C — stream
211
+
212
+ For supported types, emit edit events:
213
+
214
+ - `web` / `learn` / `docs` / `pdf`: compare current `etag` / `last_modified_http` to registry; if changed since last refresh, emit `{ts, type, title, url, change: 'updated'}`.
215
+ - `loop`: WorkIQ has Loop edit-event signals via search index — query when available. Otherwise infer from body diff.
216
+ - `file`: compare file mtime; emit if changed.
217
+ - `github`: query repo commits since last `captured_at`; emit per-commit events.
218
+ - `onenote` / `sharepoint` / `ado`: NOT emitted here (their respective pull-* skills handle stream events for their content).
219
+
220
+ ## Run report
221
+
222
+ Every refresh writes one block to `Evidence/<alias>/refresh-reports/<date>.md`:
223
+
224
+ ```markdown
225
+ ### pull-misc — <project>
226
+
227
+ - external-links.txt: <N> total links (<P> placeholders, <D> delegated, <M> handled here)
228
+ - captured: <C> / <M>
229
+ - auth-required: <A>
230
+ - fetch-failed: <F>
231
+ - placeholders to fill: <list of titles where url contains <PASTE_*_URL>>
232
+ - removed since last run: <list>
233
+ - new since last run: <list>
234
+ ```
235
+
236
+ ## Anti-patterns (HARD)
237
+
238
+ 1. ❌ Auto-discovering links by crawling project SharePoint or Teams chats. The user's `external-links.txt` IS the boundary.
239
+ 2. ❌ Storing only `url` (not `(type, url)` tuple) as the registry key. Two types may share a URL (e.g. an onenote URL flagged as `web` for ad-hoc fetch test).
240
+ 3. ❌ Silently dropping a link from the registry when it disappears from `external-links.txt`. Mark `removed` and keep the snapshot for audit.
241
+ 4. ❌ Pulling delegated types (onenote / sharepoint / ado). Those have dedicated skills. Double-pulling creates conflicting evidence.
242
+ 5. ❌ Persisting placeholder URLs as `fetch-failed`. They're `placeholder`, surfaced in the run report so the user knows to fill them in.
243
+ 6. ❌ Loop link without Playwright profile silently writes empty snapshot. Must mark `auth-required`.
244
+
245
+ ## Self-check enforcement
246
+
247
+ `plugin/skills/self-check/run.ps1` D-token contract for `pull-misc`: the SKILL.md must contain `external-links`, `misc_links`, `placeholder`, `delegated`. These prove the skill carries the v0.1.0 link-list pipeline + delegation contract.
248
+
249
+ ## Citation
250
+
251
+ Citations from misc evidence MUST follow:
252
+
253
+ ```
254
+ [source: misc/<type>/<safe-title> · YYYY-MM-DD]
255
+ ```
256
+
257
+ Example: `[source: misc/loop/ABN-Core-Team-Sync-2026-03-27 · 2026-05-14]`
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env node
2
+ // pull-misc runner — read external-links.txt, classify by type, route to runner branches.
3
+ // Per pull-misc SKILL.md v0.1.0 (kushi v3.9.0).
4
+ //
5
+ // Usage:
6
+ // node runner.mjs --project HCA --links-file "<path-to-external-links.txt>"
7
+ // node runner.mjs --project HCA --links-file "..." --headless
8
+ // node runner.mjs --project HCA --links-file "..." --types "loop,web" # only fetch some
9
+ // node runner.mjs --project HCA --links-file "..." --titles "ABN Workshop,..." # only specific titles
10
+ //
11
+ // Output is a single JSON object on stdout containing:
12
+ // { project, linksFile, links: [<perLinkRecord>...], runStatus, counts }
13
+
14
+ import fs from 'node:fs';
15
+ import fsp from 'node:fs/promises';
16
+ import path from 'node:path';
17
+ import os from 'node:os';
18
+ import process from 'node:process';
19
+
20
+ // ----- arg parse -----
21
+ const args = Object.fromEntries(
22
+ process.argv.slice(2).reduce((acc, cur, idx, arr) => {
23
+ if (cur.startsWith('--')) {
24
+ const key = cur.replace(/^--/, '');
25
+ const next = arr[idx + 1];
26
+ acc.push([key, next && !next.startsWith('--') ? next : true]);
27
+ }
28
+ return acc;
29
+ }, [])
30
+ );
31
+
32
+ const PROJECT = args.project || null;
33
+ const LINKS_FILE = args['links-file'] || null;
34
+ const ENGAGEMENT_ROOT = args['engagement-root'] || null;
35
+ const HEADLESS = !!args.headless;
36
+ const TYPES_FILTER = args.types ? String(args.types).split(',').map(s => s.trim().toLowerCase()) : null;
37
+ const TITLES_FILTER = args.titles ? String(args.titles).split(',').map(s => s.trim()) : null;
38
+ const TIMEOUT_MS = parseInt(args.timeout || '45000', 10);
39
+ const PROFILE_DIR = path.join(os.homedir(), '.copilot', 'playwright-profile', 'onenote');
40
+
41
+ if (!PROJECT || !LINKS_FILE) {
42
+ console.error('Usage: --project <name> --links-file <path> [--headless] [--types t1,t2] [--titles "t1,t2"]');
43
+ process.exit(2);
44
+ }
45
+
46
+ // ----- constants -----
47
+ const DELEGATED_TYPES = new Set(['onenote', 'sharepoint', 'ado']);
48
+ const BROWSER_TYPES = new Set(['loop']);
49
+ const PLACEHOLDER_RE = /<\s*(PASTE_[A-Z_]*_URL|TODO[^>]*)\s*>/i;
50
+
51
+ // ----- parse external-links.txt -----
52
+ function parseLinksFile(p) {
53
+ const txt = fs.readFileSync(p, 'utf-8');
54
+ const out = [];
55
+ for (const raw of txt.split(/\r?\n/)) {
56
+ const line = raw.trim();
57
+ if (!line || line.startsWith('#')) continue;
58
+ const parts = line.split('|').map(s => s.trim());
59
+ if (parts.length < 4) continue; // malformed
60
+ const [type, owner, title, url, ...noteParts] = parts;
61
+ out.push({
62
+ type: type.toLowerCase(),
63
+ owner,
64
+ title,
65
+ url,
66
+ notes: noteParts.join('|') || ''
67
+ });
68
+ }
69
+ return out;
70
+ }
71
+
72
+ function safeName(s) {
73
+ return s.replace(/[\\/:*?"<>|]/g, '-').replace(/\s+/g, '-').slice(0, 120);
74
+ }
75
+
76
+ function classify(link) {
77
+ if (PLACEHOLDER_RE.test(link.url)) return { route: 'placeholder' };
78
+ if (DELEGATED_TYPES.has(link.type)) return { route: 'delegated', delegated_to: `pull-${link.type}` };
79
+ if (BROWSER_TYPES.has(link.type)) return { route: 'browser' };
80
+ if (link.type === 'file') return { route: 'file' };
81
+ return { route: 'http' };
82
+ }
83
+
84
+ // ----- HTTP fetch branch -----
85
+ async function fetchHttp(url) {
86
+ try {
87
+ const res = await fetch(url, {
88
+ redirect: 'follow',
89
+ headers: { 'User-Agent': 'kushi-pull-misc/0.1', 'Accept': '*/*' },
90
+ signal: AbortSignal.timeout(TIMEOUT_MS)
91
+ });
92
+ const ct = res.headers.get('content-type') || '';
93
+ const etag = res.headers.get('etag') || null;
94
+ const lm = res.headers.get('last-modified') || null;
95
+
96
+ if (res.status === 401 || res.status === 403) {
97
+ return { last_status: 'auth-required', http_status: res.status, content_type: ct };
98
+ }
99
+ if (!res.ok) {
100
+ return { last_status: 'fetch-failed', http_status: res.status, content_type: ct };
101
+ }
102
+
103
+ if (ct.startsWith('application/pdf')) {
104
+ const buf = Buffer.from(await res.arrayBuffer());
105
+ // Defer text extract to a separate step (pdfjs not bundled to keep dep weight low).
106
+ return {
107
+ last_status: 'captured',
108
+ http_status: res.status,
109
+ content_type: ct,
110
+ etag, last_modified_http: lm,
111
+ body: `[binary PDF — ${buf.length} bytes; text extraction not yet implemented in v0.1]`,
112
+ char_count: 0
113
+ };
114
+ }
115
+
116
+ const body = await res.text();
117
+
118
+ // Heuristic: HTML → readability extract; plain → keep raw.
119
+ let extracted = body;
120
+ if (ct.includes('html')) {
121
+ try {
122
+ const { JSDOM } = await import('jsdom');
123
+ const { Readability } = await import('@mozilla/readability');
124
+ const dom = new JSDOM(body, { url });
125
+ const reader = new Readability(dom.window.document);
126
+ const article = reader.parse();
127
+ if (article && article.textContent && article.textContent.length > 100) {
128
+ extracted = `# ${article.title || ''}\n\n${article.textContent.trim()}`;
129
+ }
130
+ } catch (e) {
131
+ // jsdom or readability not available — fall through to raw
132
+ extracted = `[readability unavailable: ${e.message}]\n\n${body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()}`;
133
+ }
134
+ }
135
+
136
+ return {
137
+ last_status: 'captured',
138
+ http_status: res.status,
139
+ content_type: ct,
140
+ etag, last_modified_http: lm,
141
+ body: extracted,
142
+ char_count: extracted.length
143
+ };
144
+ } catch (e) {
145
+ return { last_status: 'fetch-failed', error: String(e.message || e) };
146
+ }
147
+ }
148
+
149
+ // ----- File read branch -----
150
+ async function readFileBranch(link) {
151
+ const root = ENGAGEMENT_ROOT || process.cwd();
152
+ const candidate1 = path.resolve(root, link.url);
153
+ const candidate2 = path.resolve(root, PROJECT, link.url);
154
+ const tryPath = fs.existsSync(candidate1) ? candidate1 : (fs.existsSync(candidate2) ? candidate2 : null);
155
+ if (!tryPath) return { last_status: 'fetch-failed', error: `not found at ${candidate1} or ${candidate2}` };
156
+ try {
157
+ const buf = await fsp.readFile(tryPath);
158
+ if (buf.includes(0)) return { last_status: 'skipped-binary', char_count: buf.length };
159
+ const txt = buf.toString('utf-8');
160
+ return { last_status: 'captured', body: txt, char_count: txt.length, resolved_path: tryPath };
161
+ } catch (e) {
162
+ return { last_status: 'fetch-failed', error: String(e.message || e) };
163
+ }
164
+ }
165
+
166
+ // ----- Browser (Loop) branch -----
167
+ async function fetchBrowser(link, ctx) {
168
+ const page = await ctx.newPage();
169
+ try {
170
+ const resp = await page.goto(link.url, { waitUntil: 'domcontentloaded', timeout: TIMEOUT_MS });
171
+ if (/login\.microsoftonline\.com|login\.live\.com/.test(page.url())) {
172
+ return { last_status: 'auth-required', captured_via: 'browser' };
173
+ }
174
+ // Wait for Loop content. Loop uses Fluid; the main content settles in a few seconds.
175
+ await page.waitForTimeout(4000);
176
+ // Try Loop-specific selectors first, then fallback to body
177
+ const body = await page.evaluate(() => {
178
+ const sels = [
179
+ '[data-loop-canvas]',
180
+ '.fui-FluentProvider',
181
+ '[role="main"]',
182
+ 'body'
183
+ ];
184
+ for (const s of sels) {
185
+ const el = document.querySelector(s);
186
+ if (el && el.innerText && el.innerText.length > 100) return el.innerText;
187
+ }
188
+ return document.body ? document.body.innerText : '';
189
+ });
190
+ if (!body || body.length < 50) {
191
+ return { last_status: 'fetch-failed', captured_via: 'browser', error: 'empty or near-empty body', char_count: body?.length || 0 };
192
+ }
193
+ return { last_status: 'captured', captured_via: 'browser', body, char_count: body.length };
194
+ } catch (e) {
195
+ return { last_status: 'fetch-failed', captured_via: 'browser', error: String(e.message || e) };
196
+ } finally {
197
+ await page.close().catch(() => {});
198
+ }
199
+ }
200
+
201
+ // ----- main -----
202
+ (async () => {
203
+ const links = parseLinksFile(LINKS_FILE);
204
+ const filtered = links.filter(l => {
205
+ if (TYPES_FILTER && !TYPES_FILTER.includes(l.type)) return false;
206
+ if (TITLES_FILTER && !TITLES_FILTER.some(t => l.title.includes(t))) return false;
207
+ return true;
208
+ });
209
+
210
+ const needsBrowser = filtered.some(l => classify(l).route === 'browser');
211
+ let ctx = null;
212
+ if (needsBrowser) {
213
+ if (!fs.existsSync(PROFILE_DIR)) {
214
+ console.error('[pull-misc] Playwright profile missing — loop links will be marked auth-required.');
215
+ } else {
216
+ const { chromium } = await import('playwright');
217
+ ctx = await chromium.launchPersistentContext(PROFILE_DIR, { headless: HEADLESS, viewport: { width: 1400, height: 900 } });
218
+ }
219
+ }
220
+
221
+ const results = [];
222
+ const now = new Date().toISOString();
223
+
224
+ for (const link of filtered) {
225
+ const cls = classify(link);
226
+ let rec = {
227
+ type: link.type,
228
+ owner: link.owner,
229
+ title: link.title,
230
+ url: link.url,
231
+ notes: link.notes,
232
+ attempts: 1,
233
+ last_attempt_at: now,
234
+ snapshot_path: `Evidence/<alias>/misc/snapshot/${safeName(link.type)}__${safeName(link.title)}.md`
235
+ };
236
+
237
+ if (cls.route === 'placeholder') {
238
+ Object.assign(rec, { last_status: 'placeholder', captured_via: null });
239
+ } else if (cls.route === 'delegated') {
240
+ Object.assign(rec, { last_status: 'delegated', captured_via: 'delegated', delegated_to: cls.delegated_to });
241
+ } else if (cls.route === 'http') {
242
+ const r = await fetchHttp(link.url);
243
+ Object.assign(rec, r, { captured_via: 'http' });
244
+ if (r.last_status === 'captured') rec.captured_at = now;
245
+ } else if (cls.route === 'file') {
246
+ const r = await readFileBranch(link);
247
+ Object.assign(rec, r, { captured_via: 'file' });
248
+ if (r.last_status === 'captured') rec.captured_at = now;
249
+ } else if (cls.route === 'browser') {
250
+ if (!ctx) {
251
+ Object.assign(rec, { last_status: 'auth-required', captured_via: 'browser', error: 'no playwright profile' });
252
+ } else {
253
+ const r = await fetchBrowser(link, ctx);
254
+ Object.assign(rec, r);
255
+ if (r.last_status === 'captured') rec.captured_at = now;
256
+ }
257
+ }
258
+ results.push(rec);
259
+ }
260
+
261
+ if (ctx) await ctx.close();
262
+
263
+ const counts = {
264
+ total: results.length,
265
+ captured: results.filter(r => r.last_status === 'captured').length,
266
+ placeholder: results.filter(r => r.last_status === 'placeholder').length,
267
+ delegated: results.filter(r => r.last_status === 'delegated').length,
268
+ auth_required: results.filter(r => r.last_status === 'auth-required').length,
269
+ fetch_failed: results.filter(r => r.last_status === 'fetch-failed').length,
270
+ skipped_binary: results.filter(r => r.last_status === 'skipped-binary').length
271
+ };
272
+ const runStatus = counts.auth_required > 0 ? 'auth-required'
273
+ : counts.fetch_failed > 0 ? 'partial'
274
+ : 'ok';
275
+
276
+ console.log(JSON.stringify({ project: PROJECT, linksFile: LINKS_FILE, runStatus, counts, links: results }, null, 2));
277
+ })().catch(e => {
278
+ console.error('[pull-misc] runner crashed:', e);
279
+ process.exit(1);
280
+ });
@@ -0,0 +1,90 @@
1
+ # pull-onenote — runner README
2
+
3
+ ## What this is
4
+
5
+ A Playwright-driven browser scraper for OneNote-for-Web that returns verbatim page bodies for an entire section. Used by the `pull-onenote` skill (v2.6.0+) as the PRIMARY capture path.
6
+
7
+ ## One-time setup
8
+
9
+ ```pwsh
10
+ # 1. Install Node + Playwright if you don't have them
11
+ npm install -g playwright
12
+ npx playwright install chromium
13
+
14
+ # 2. Seed the persistent browser profile (interactive — one-time)
15
+ node plugin/skills/pull-onenote/runner.mjs --bootstrap
16
+ ```
17
+
18
+ When the bootstrap command opens a Chromium window:
19
+ - Sign in with your work account at `onenote.cloud.microsoft`.
20
+ - Check **Keep me signed in**.
21
+ - Wait for the OneNote home page to load.
22
+ - Close the window.
23
+
24
+ The profile is persisted at `~/.copilot/playwright-profile/onenote/`. Subsequent runs reuse it.
25
+
26
+ ## Per-section runs
27
+
28
+ ```pwsh
29
+ node plugin/skills/pull-onenote/runner.mjs `
30
+ --project "HCA" `
31
+ --section-url "https://microsoft-my.sharepoint-df.com/personal/<upn>/_layouts/15/Doc.aspx?sourcedoc=%7B<notebookSourceDoc>%7D&action=edit&wd=target%28<sectionName>.one%7C<sectionFileId>%2F%29" `
32
+ --headless
33
+ ```
34
+
35
+ The runner emits a single JSON object on stdout with this shape:
36
+
37
+ ```json
38
+ {
39
+ "project": "HCA",
40
+ "sectionUrl": "...",
41
+ "runStatus": "ok | auth-required | partial",
42
+ "exitedEarly": false,
43
+ "authRequiredCount": 0,
44
+ "pages": [
45
+ {
46
+ "title": "4/3 - HCA with Jay and Martin",
47
+ "pos": 12, "total": 16,
48
+ "webPageId": "78aac9b5-0629-4daa-ada3-f2436cb2381c",
49
+ "last_status": "captured | short-suspect | auth-required | browser-error",
50
+ "captured_via": "browser",
51
+ "body": "...verbatim text...",
52
+ "bodyLen": 6904,
53
+ "capturedAt": "2026-05-14T03:55:00Z"
54
+ }
55
+ ]
56
+ }
57
+ ```
58
+
59
+ The skill driver merges this into `m365-mutable.json#knownSections.<projectKey>.one_pages[]` and writes the snapshot pages.
60
+
61
+ ## Filtering
62
+
63
+ For retry-only runs, pass `--titles` with a comma-separated list:
64
+
65
+ ```pwsh
66
+ node runner.mjs --project HCA --section-url "..." --titles "4/3 - HCA with Jay and Martin,5/7" --headless
67
+ ```
68
+
69
+ ## Scheduled / unattended runs
70
+
71
+ For Monday-9am-style schedules, always pass `--headless`. The runner detects redirects to `login.microsoftonline.com` and exits early with `runStatus: "auth-required"` so the driver can mark all queued pages as `auth-required` and surface in the run report.
72
+
73
+ There is currently no way to satisfy a Conditional Access / MFA challenge unattended. When that happens (rough estimate: every 1–4 weeks under a Microsoft tenant CA policy), do one interactive `--bootstrap` to refresh the profile and the next scheduled run resumes silently.
74
+
75
+ ## Constructing the section URL
76
+
77
+ Once a project's `knownSections` entry is populated in `m365-mutable.json`, the URL is:
78
+
79
+ ```
80
+ <one_notebookSpoBaseUrl>/_layouts/15/Doc.aspx?sourcedoc=%7B<one_notebookSourceDoc>%7D&action=edit&wd=target(<one_sectionName>%7C<one_sectionFileId>%2F)
81
+ ```
82
+
83
+ URL-encode the section name; double-encode the pipe (`%7C`) and trailing slash (`%2F`).
84
+
85
+ ## Troubleshooting
86
+
87
+ - **Picker shown every run.** First-run only, and only if multiple work accounts are listed. After picking once it sticks.
88
+ - **"OneNote canvas frame did not attach."** Almost always auth — check the headless output's `runStatus`.
89
+ - **Bodies look short.** The `last_status: short-suspect` flag is for this. Verify in OneNote desktop; if genuinely sparse, accept; otherwise re-run.
90
+ - **Pages enumerate fine but no `webPageId` returned.** The `wd=target(...)` URL fragment shape may have changed. Update `parseWebPageIdFromUrl` regex in `runner.mjs`.