kushi-agents 4.7.4 → 4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kushi-agents",
3
- "version": "4.7.4",
3
+ "version": "4.8.1",
4
4
  "description": "Install Kushi — multi-source project evidence agent with snapshot+stream capture across Email, Teams, OneNote, Loop, SharePoint, Meetings, CRM, ADO. WorkIQ-only for M365 sources (Graph / m365_* FORBIDDEN as fallbacks; user-paste is first-class). Host-agnostic.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,6 +42,7 @@
42
42
  "license": "MIT",
43
43
  "scripts": {
44
44
  "test": "node --test src/check-workiq.test.mjs src/seed-config.test.mjs src/sanitize-workiq-input.test.mjs src/detect-vertex-repo.test.mjs src/vertex-validate.test.mjs src/emit-vertex.e2e.test.mjs src/config-root-resolve.test.mjs src/forbidden-workiq-phrasings.test.mjs",
45
+ "test:integration:bootstrap": "node src/bootstrap-dryrun.integration.test.mjs",
45
46
  "smoke": "node scripts/smoke.mjs",
46
47
  "prepublishOnly": "npm test && npm run smoke"
47
48
  },
@@ -0,0 +1,130 @@
1
+ ---
2
+ applyTo: "**/skills/bootstrap-project/**, **/skills/pull-email/**, **/skills/pull-teams/**, **/skills/pull-meetings/**, **/skills/pull-sharepoint/**, **/skills/refresh-project/**"
3
+ description: "Customer-hint discovery sweep — bootstrap MUST attempt WorkIQ-driven discovery for every source whose boundary is empty before declaring blocked-config. Mirrors crm-bootstrap-discovery + loop-bootstrap-discovery pattern. Kushi v4.8.0+."
4
+ ---
5
+
6
+ # Customer-hint discovery sweep (HARD RULE — kushi v4.8.0+)
7
+
8
+ ## The defect this rule exists to prevent
9
+
10
+ Bootstrap runs that scaffold an empty `<project>/integrations.yml#boundaries.*` and immediately declare every source `blocked-config` — without ever asking WorkIQ "who/what mentions this customer?". The result: a fresh project bootstrap finishes with 0 evidence pulled, the user is told to hand-populate mailboxes / chat IDs / channel IDs / meeting join URLs / SharePoint sites, and the entire value proposition of "bootstrap a project from a customer hint" collapses to a config form. Discovered 2026-05-26 on the HCA bootstrap — every source row read `blocked-config` despite extensive HCA email / Teams / meeting history in the tenant.
11
+
12
+ ## The rule
13
+
14
+ Before `bootstrap-project` (or `refresh-project` on a first pull) is allowed to write `last_status: blocked-config` for **email, teams, meetings, or sharepoint**, the per-source customer-hint discovery sweep defined in the matching doctrine MUST be attempted with the customer hint + lookback window:
15
+
16
+ | Source | Doctrine file (mandatory) |
17
+ |---|---|
18
+ | email | `email-bootstrap-discovery.instructions.md` |
19
+ | teams | `teams-bootstrap-discovery.instructions.md` |
20
+ | meetings | `meetings-bootstrap-discovery.instructions.md` |
21
+ | sharepoint | `sharepoint-bootstrap-discovery.instructions.md` |
22
+ | onenote | `bootstrap-project/SKILL.md#step-4a` (already shipped v4.7.x — display-name driven) |
23
+ | loop | `loop-bootstrap-discovery.instructions.md` (already shipped v4.6.0) |
24
+ | crm | `crm-bootstrap-discovery.instructions.md` (already shipped v3.11.0) |
25
+ | ado | `ado-bootstrap-discovery.instructions.md` |
26
+
27
+ `blocked-config` is ONLY legitimate when:
28
+
29
+ 1. **The sweep ran** and returned 0 candidates → status is `unresolved` (sweep succeeded but no hits), not `blocked-config`. Use `discovery-empty` annotation in the per-source notes.
30
+ 2. **The sweep COULD not run** because a prerequisite is genuinely missing (e.g. CRM/ADO need `<workspace>/.kushi/config/shared/integrations.yml` populated, SharePoint local-folder enumeration needs a local OneDrive sync path the customer hint cannot infer). In that case, `blocked-config` is correct, and the per-source `next_step` MUST cite the specific missing config field.
31
+
32
+ Any other path that writes `blocked-config` without attempting the sweep is a **defect**.
33
+
34
+ ## Required inputs
35
+
36
+ - `<customer-hint>` — verbatim string the user provided at bootstrap invocation (e.g. `HCA`). Captured by `bootstrap-project` Step 0/1 and persisted to `<project>/bootstrap-status.md` under `Customer Hint:`. Used VERBATIM in v4.8.0 — no fuzzy expansion (deferred to v4.9.0).
37
+ - `<lookback-days>` — defaults to **90** for discovery (longer than the 30-day pull window so historical chats / series still surface). Configurable via `<workspace>/.kushi/config/user/m365-mutable.json#bootstrap.discoveryLookbackDays`.
38
+ - `<project>` — engagement name (already resolved).
39
+ - `<alias>` — current contributor.
40
+
41
+ ## Required outputs
42
+
43
+ For each source whose sweep runs:
44
+
45
+ 1. **Append discovered IDs / URLs / paths to `<engagement-root>/<project>/integrations.yml#boundaries.<source>.<key>`** as plain strings (existing pull-* skills consume strings — do NOT change the array element type). Idempotent: deduplicate by exact-string equality.
46
+
47
+ 2. **Write a sidecar discovery record to `<engagement-root>/<project>/Evidence/_discovery/<YYYY-MM-DD>_<source>_discovery.yml`** with the per-row metadata (`discovered_by`, `discovered_at`, `needs_review`, `query`, `request_id`, `confidence`). Schema:
48
+
49
+ ```yaml
50
+ source: email | teams | meetings | sharepoint
51
+ project: <project>
52
+ customer_hint: '<hint>'
53
+ lookback_days: 90
54
+ discovered_at: '<ISO-8601>'
55
+ discovered_by: <alias>
56
+ query: '<exact WorkIQ prompt>'
57
+ workiq_request_id: '<request-id from response>'
58
+ total_candidates_found: <N>
59
+ candidates_persisted: <M> # ≤ 10 after cap
60
+ candidates_deferred: <N - M> # written to OPEN-QUESTIONS-DRAFT.md
61
+ results:
62
+ - value: '<string written to boundary>'
63
+ label: '<human-readable label>'
64
+ confidence: high | medium | low
65
+ needs_review: true
66
+ ```
67
+
68
+ 3. **If `total_candidates_found > 10`**, persist the top 10 by recency to the boundary and append the remaining N–10 to `<project>/OPEN-QUESTIONS-DRAFT.md` under `## Discovery sweep — candidates over cap` with one row per candidate.
69
+
70
+ 4. **Add a `## Discovery Sweep Results` section to `<project>/bootstrap-status.md`** after `## Context Artifact Status`. Table shape:
71
+
72
+ ```
73
+ ## Discovery Sweep Results
74
+
75
+ | Source | Hint | Query attempted | Candidates found | Persisted | Deferred | Discovered by |
76
+ |---|---|---|---|---|---|---|
77
+ | email | HCA | "In my Inbox..." | 7 | 7 | 0 | ushak |
78
+ | teams | HCA | "In my Teams chats..." | 14 | 10 | 4 | ushak |
79
+ | meetings | HCA | "In my calendar..." | 3 | 3 | 0 | ushak |
80
+ | sharepoint | HCA | "In my SharePoint sites..." | 0 | 0 | 0 | ushak |
81
+ ```
82
+
83
+ ## Behavior matrix (per source)
84
+
85
+ | Sweep result | `boundaries.<source>.<key>` written? | `last_status` | `retry_signal` | Open Questions written? |
86
+ |---|---|---|---|---|
87
+ | 0 candidates | no | `unresolved` (annotated `discovery-empty`) | `user-action` | yes — "Discovery sweep for `<source>` returned 0 hits for `<hint>`; widen hint or seed `boundaries.<source>.<key>` manually" |
88
+ | 1–10 candidates | yes (all) | `completed-with-coverage-gaps` (because rows are `needs_review`) | `watch` | only if any row low-confidence |
89
+ | > 10 candidates | yes (top 10 by recency) | `completed-with-coverage-gaps` | `watch` | yes — "Discovery sweep returned >10 candidates; review the deferred list" |
90
+ | **WorkIQ punted — no surface for this source** (sharepoint sites, teams channels — v4.8.1 empirical) | no | `unresolved` (annotated `discovery-empty-no-workiq-surface`) | `user-action` | yes — cite that this source has no known WorkIQ discovery surface and direct the user to manual configuration (the per-source doctrine has the exact wording) |
91
+ | Sweep query failed (WorkIQ error / classified per `fallback-status-reporting.instructions.md`) | no | `deferred` (write marker per `deferred-retry-on-workiq-fail.instructions.md`) | `retry` | no — next refresh will drain |
92
+ | Prerequisite genuinely missing (e.g. CRM shared config empty) | no | `blocked-config` | `user-action` | yes — cite specific missing field |
93
+
94
+ ## Multi-contributor safety
95
+
96
+ When the boundary already has rows from another alias (or the sidecar `Evidence/_discovery/` already has entries):
97
+
98
+ 1. **Append-only** — the new alias's sweep adds rows that are not already present (dedupe by exact-string equality on the boundary value). Never remove or rewrite another alias's row.
99
+ 2. **Sidecar file is per-source-per-date** — multiple contributors on the same day produce separate sidecar files (`2026-05-26_email_discovery-ushak.yml`, `2026-05-26_email_discovery-stand.yml`). Bootstrap-status's Discovery Sweep Results table shows one row per source × alias.
100
+ 3. **`Discovered by` column in bootstrap-status's per-source Context Artifact Status row** cites the most recent discovering alias. Preserve other aliases' rows in `## Contributors who have bootstrapped this project` per `multi-user-shared-files.instructions.md`.
101
+
102
+ ## Rerun behavior
103
+
104
+ When the user re-runs bootstrap on a project that already has populated boundaries:
105
+
106
+ | Boundary state | Sweep behavior |
107
+ |---|---|
108
+ | Empty | Run sweep (full discovery). |
109
+ | Has rows, none `needs_review: true` (all confirmed) | **Skip sweep.** Boundary is gospel — do not re-discover. |
110
+ | Has rows, some `needs_review: true` (sidecar shows confidence < high) | Run sweep. Merge new candidates by ID. Promote previously-discovered candidates from `needs_review: true` → `false` only if user has manually confirmed (i.e. removed the `needs_review` flag from the sidecar). |
111
+ | User passes `--force-rediscover` | Run sweep regardless of state. Merge new candidates; never delete user-confirmed rows. |
112
+
113
+ ## Forbidden behaviors
114
+
115
+ 1. **Declaring `blocked-config` without running the sweep first** for email/teams/meetings/sharepoint — see "The defect" above. CRM/ADO have their own mandates in `crm-bootstrap-discovery` / `ado-bootstrap-discovery`.
116
+ 2. **Auto-narrowing the customer hint** ("HCA" → "HCA Healthcare Inc"). Use the hint verbatim. Smart-expansion is v4.9.0.
117
+ 3. **Discovering across all sources in one mega-query.** Each source has its own narrow WorkIQ prompt (see per-source doctrines). Mega-queries punt to Graph and return empty.
118
+ 4. **Inferring local OneDrive sync paths from the hint** for SharePoint `local_folders[]`. Discovery populates `site_urls[]` only.
119
+ 5. **Calling Graph / `m365_*` directly for discovery.** Per `workiq-only.instructions.md` the four sources covered here use WorkIQ exclusively. The only allowed `m365_*` exceptions remain `m365_list_chat_messages` (parallel structured dump per pull-teams) and the per-source carve-outs already named in their pull-* SKILLs.
120
+
121
+ ## References
122
+
123
+ - `crm-bootstrap-discovery.instructions.md` — the original "must-attempt-before-declaring-disabled" doctrine; this file mirrors its pattern for the WorkIQ-driven sources.
124
+ - `loop-bootstrap-discovery.instructions.md` — Loop-specific discovery + registry shape.
125
+ - `scope-boundaries.instructions.md` — the broader partial-determinism contract; this doctrine is the discovery-time enabler that makes boundaries achievable.
126
+ - `workiq-only.instructions.md` — what discovery is NOT allowed to call (Graph, m365_* for content).
127
+ - `status-taxonomy.instructions.md` — the closed-set status vocabulary; `unresolved` + `discovery-empty` annotation vs. `blocked-config`.
128
+ - `fallback-status-reporting.instructions.md` — how to classify WorkIQ punts during the sweep.
129
+ - `multi-user-shared-files.instructions.md` — append-only rules for the boundary file.
130
+ - `bootstrap-status-format.instructions.md` — where `## Discovery Sweep Results` slots in the report.
@@ -0,0 +1,105 @@
1
+ ---
2
+ applyTo: "**/skills/bootstrap-project/**, **/skills/pull-email/**, **/skills/refresh-project/**"
3
+ description: "Outlook mail folder discovery — single approved WorkIQ phrasing that scans recent Inbox/subfolder contents for the customer hint, ranks folders by hit-density, writes top candidates into mailboxes[]. Source-specific subset of customer-hint-discovery."
4
+ ---
5
+
6
+ # Email bootstrap discovery (kushi v4.8.0+)
7
+
8
+ Governed by `customer-hint-discovery.instructions.md` — read that file first for the orchestration contract, rerun rules, multi-contributor merge behavior, and behavior matrix.
9
+
10
+ ## What this sweep populates
11
+
12
+ | Boundary key | Element shape | Example |
13
+ |---|---|---|
14
+ | `boundaries.email.mailboxes[]` | string — mail folder path relative to mailbox root | `"Inbox"`, `"Inbox/HCA"`, `"FDE/HCA Intake"` |
15
+
16
+ Optional narrowing fields (`sender_domains[]`, `subject_keywords[]`) are NOT populated by the sweep — they are user-supplied narrowing.
17
+
18
+ ## Approved WorkIQ query (the ONLY shape that returns this data)
19
+
20
+ Issued ONCE per bootstrap, per project:
21
+
22
+ ```
23
+ workiq ask -q "In my Outlook mail folders, find the top mail folders that contain emails mentioning '<HINT>' received in the last <N> days. Return a flat table with: folder path (from mailbox root), message count, most recent received date. Sort by message count descending. Do not summarize. Do not truncate. Flat table only."
24
+ ```
25
+
26
+ Substitution rules:
27
+
28
+ - `<HINT>` = the verbatim customer hint from `bootstrap-status.md#Customer Hint` (e.g. `HCA`).
29
+ - `<N>` = `m365-mutable.json#bootstrap.discoveryLookbackDays` (default 90).
30
+
31
+ The phrasing is **natural-language by folder content** — empirically the only shape that returns folder paths. WorkIQ punts on any other shape (see Forbidden phrasings).
32
+
33
+ ## Forbidden phrasings (will fail empirically — do NOT emit)
34
+
35
+ | Forbidden phrasing | Why it fails |
36
+ |---|---|
37
+ | `"List all my Outlook mail folders. Return folder name and folderId for each."` | Enumerate-verb on the folder space punts to `m365_list_mail_folders` / Graph and returns Graph-Explorer guidance instead of data. |
38
+ | `"Search Microsoft 365 for mail folders matching '<hint>'."` | Structured-search verb routes to summary mode, not folder data. |
39
+ | `"What is the folder ID for the '<name>' folder in my mailbox?"` | ID-lookup question punts to Graph. |
40
+ | `"Get the mail folder hierarchy from my Outlook account."` | Hierarchy verb returns prose, not folder paths. |
41
+ | `"$filter=displayName eq '<name>'"` (OData syntax embedded in the query) | Filter syntax fails — WorkIQ does not pass through OData. |
42
+
43
+ ## Parsing the response
44
+
45
+ WorkIQ returns a markdown table. Parse rows where `folder path` is non-empty and `message count >= 1`:
46
+
47
+ 1. Trim each folder path (no leading/trailing slashes).
48
+ 2. Deduplicate against existing `boundaries.email.mailboxes[]`.
49
+ 3. Cap at top 10 by `message count`.
50
+ 4. The remainder (if any) goes to `<project>/OPEN-QUESTIONS-DRAFT.md` per the orchestration doctrine.
51
+ 5. Confidence ranking:
52
+ - `high` — folder path explicitly contains the hint (case-insensitive substring), e.g. `Inbox/HCA Intake` for hint `HCA`.
53
+ - `medium` — message count ≥ 10 AND folder is a known well-known root (`Inbox`, `Sent Items`, `Archive`).
54
+ - `low` — everything else.
55
+
56
+ ## Sidecar file shape
57
+
58
+ Written to `<engagement-root>/<project>/Evidence/_discovery/<YYYY-MM-DD>_email_discovery-<alias>.yml`:
59
+
60
+ ```yaml
61
+ source: email
62
+ project: '<project>'
63
+ customer_hint: '<HINT>'
64
+ lookback_days: 90
65
+ discovered_at: '<ISO-8601>'
66
+ discovered_by: '<alias>'
67
+ query: 'In my Outlook mail folders, find the top mail folders that contain emails mentioning ''<HINT>'' received in the last 90 days. ...'
68
+ workiq_request_id: '<request-id>'
69
+ total_candidates_found: 7
70
+ candidates_persisted: 7
71
+ candidates_deferred: 0
72
+ results:
73
+ - value: 'Inbox/HCA Intake'
74
+ label: 'Inbox/HCA Intake (245 messages, last 2026-05-25)'
75
+ confidence: high
76
+ needs_review: true
77
+ - value: 'Inbox'
78
+ label: 'Inbox (87 messages, last 2026-05-26)'
79
+ confidence: medium
80
+ needs_review: true
81
+ ```
82
+
83
+ `needs_review: true` is set on EVERY row by default. The user clears it manually after confirming the folder.
84
+
85
+ ## Bootstrap-status row
86
+
87
+ The Discovery Sweep Results table row (per `customer-hint-discovery.instructions.md`):
88
+
89
+ ```
90
+ | email | <HINT> | "In my Outlook mail folders..." | 7 | 7 | 0 | <alias> |
91
+ ```
92
+
93
+ ## When this sweep does NOT run
94
+
95
+ - The user already populated `boundaries.email.mailboxes[]` with at least one entry that does NOT carry `needs_review: true` in the sidecar — boundary is gospel; skip sweep.
96
+ - The active profile disables email (`m365-mutable.json#sources.email.enabled = false`) — write `last_status: not-applicable`.
97
+ - WorkIQ itself is unreachable (signed out, EULA pending, CLI missing) — write `last_status: blocked-auth`, retry_signal `user-action`. Do NOT write `blocked-config`.
98
+
99
+ ## References
100
+
101
+ - `customer-hint-discovery.instructions.md` — orchestration contract.
102
+ - `pull-email/SKILL.md` — what consumes `boundaries.email.mailboxes[]` (folder fast-path + root fallback).
103
+ - `workiq-only.instructions.md` — why m365_list_mail_folders / Graph are forbidden.
104
+ - `deferred-retry-on-workiq-fail.instructions.md` — marker shape when sweep returns a WorkIQ error.
105
+ - `status-taxonomy.instructions.md` — `unresolved` vs. `blocked-config` distinction.
@@ -0,0 +1,104 @@
1
+ ---
2
+ applyTo: "**/skills/bootstrap-project/**, **/skills/pull-meetings/**, **/skills/refresh-project/**"
3
+ description: "Recurring meeting series resolution — subject-scoped WorkIQ query spanning past 30 days + forward 30 days; collapses occurrence join-urls to a single series url; cross-references discovered Teams chat ids to find implied series. Writes series_join_urls[]. Source-specific subset of customer-hint-discovery."
4
+ ---
5
+
6
+ # Meetings bootstrap discovery (kushi v4.8.0+)
7
+
8
+ Governed by `customer-hint-discovery.instructions.md` — read that file first for the orchestration contract.
9
+
10
+ ## What this sweep populates
11
+
12
+ | Boundary key | Element shape | Example |
13
+ |---|---|---|
14
+ | `boundaries.meetings.series_join_urls[]` | string — Teams meeting URL. Two accepted shapes (v4.8.1+): (1) `meetup-join` URL `https://teams.microsoft.com/l/meetup-join/...` if returned, (2) `meeting/details?eventId=` URL `https://teams.microsoft.com/l/meeting/details?eventId=...` — empirically the form WorkIQ returns. Pull-meetings accepts both via the eventId resolver. | `"https://teams.microsoft.com/l/meeting/details?eventId=AAMkAD..."` |
15
+
16
+ Optional `organizer_emails[]` is NOT populated by the sweep (user narrowing only).
17
+
18
+ ## Approved WorkIQ query (the ONLY shape that returns this data)
19
+
20
+ Issued ONCE per bootstrap, per project:
21
+
22
+ ```
23
+ workiq ask -q "In my Outlook calendar, find the recurring meeting series and one-off meetings whose subject mentions '<HINT>' and that have at least one occurrence in the last <N> days OR the next 30 days. Return a flat table with: subject, organizer name, organizer email, recurrence pattern (single | daily | weekly | other), Teams meeting join URL or meeting details URL, most recent occurrence date. Do not summarize. Do not truncate. Flat table only."
24
+ ```
25
+
26
+ **v4.8.1 empirical finding:** WorkIQ rarely returns the `meetup-join` URL form — it returns `meeting/details?eventId=...` URLs instead (the calendar-event-ID-anchored details URL). Both forms uniquely identify the meeting/series; pull-meetings v2.x+ accepts either and resolves the join URL on demand.
27
+
28
+ Substitution rules:
29
+
30
+ - `<HINT>` = verbatim customer hint.
31
+ - `<N>` = `m365-mutable.json#bootstrap.discoveryLookbackDays` (default 90).
32
+
33
+ The look-forward 30-day window catches series whose previous occurrence was outside the lookback but next occurrence is imminent — common for new engagements where the customer hint first appears in upcoming meetings.
34
+
35
+ This phrasing — **natural-language by subject + organizer + join URL request** — is empirically the only shape that returns calendar series data. Other phrasings punt.
36
+
37
+ ## Forbidden phrasings (will fail empirically — do NOT emit)
38
+
39
+ | Forbidden phrasing | Why it fails |
40
+ |---|---|
41
+ | `"List all my Teams meetings."` / `"List my upcoming meetings."` | Bulk enumerate punts to `m365_list_meetings` / `m365_list_events` / Graph calendar API. |
42
+ | `"Get the calendar event ID for the meeting titled '<subject>'."` | ID-lookup punts to Graph. |
43
+ | `"Search Microsoft 365 calendar for events matching '<hint>'."` | Structured-search verb routes to summary mode. |
44
+ | `"What meetings did I have last week?"` | Vague time question routes to summary, not a structured table. |
45
+ | `"$filter=subject eq '<subject>'"` (OData) | Filter syntax fails. |
46
+
47
+ ## Parsing the response
48
+
49
+ 1. Extract any URL column. Accept rows whose URL matches EITHER pattern (v4.8.1):
50
+ - `^https?://teams\.microsoft\.com/l/meetup-join/` — preferred form.
51
+ - `^https?://teams\.microsoft\.com/l/meeting/details\?eventId=` — empirical form WorkIQ returns. Persist as-is; pull-meetings resolves the join URL via eventId lookup.
52
+ If the row says `"Not available in source"` or similar AND no URL is present in any other cell of the row, log the row to OPEN-QUESTIONS-DRAFT.md under `## Meetings discovered without join URLs` (subject + organizer + recurrence) so the user can supply the URL manually. Do NOT persist a placeholder.
53
+ 2. **Collapse recurring series:** for rows with `recurrence pattern != single`, the URL of any occurrence is the canonical series URL (Outlook reuses one URL per series). Deduplicate by URL.
54
+ 3. Deduplicate against existing `boundaries.meetings.series_join_urls[]`.
55
+ 4. Cap at top 10 by `most recent occurrence date` (descending).
56
+ 5. Confidence ranking:
57
+ - `high` — subject contains the hint (case-insensitive substring) AND recurrence is `daily` / `weekly` (active recurring series).
58
+ - `medium` — subject contains the hint, single occurrence.
59
+ - `low` — match was on organizer email/domain only (rare — the query is subject-scoped, but if WorkIQ returns inferred matches, flag them).
60
+
61
+ ## Sidecar file shape
62
+
63
+ Written to `<engagement-root>/<project>/Evidence/_discovery/<YYYY-MM-DD>_meetings_discovery-<alias>.yml`. Schema per `email-bootstrap-discovery.instructions.md` § Sidecar file shape, with `source: meetings`.
64
+
65
+ Additional field for meetings sidecar:
66
+
67
+ ```yaml
68
+ results:
69
+ - value: 'https://teams.microsoft.com/l/meetup-join/19%3a...'
70
+ label: 'HCA Weekly Sync (Weekly Tue 10:00 ET; organizer: jdoe@hcahealthcare.com)'
71
+ recurrence: weekly
72
+ organizer_email: 'jdoe@hcahealthcare.com'
73
+ most_recent_occurrence: '2026-05-21'
74
+ confidence: high
75
+ needs_review: true
76
+ ```
77
+
78
+ ## Bootstrap-status row
79
+
80
+ ```
81
+ | meetings | <HINT> | "In my Outlook calendar..." | 3 | 3 | 0 | <alias> |
82
+ ```
83
+
84
+ ## When this sweep does NOT run
85
+
86
+ - `boundaries.meetings.series_join_urls[]` is populated and contains no `needs_review` rows.
87
+ - The active profile disables meetings.
88
+ - WorkIQ unreachable — `blocked-auth`.
89
+
90
+ ## Cross-reference with teams sweep
91
+
92
+ After both sweeps complete, the orchestrator MAY cross-reference: each meeting's join URL implies a chat-id (Teams creates one chat per recurring meeting). If the meeting sweep persists a join URL whose implied chat-id is NOT already in `boundaries.teams.chat_ids[]`, log a note in `OPEN-QUESTIONS-DRAFT.md`:
93
+
94
+ > Discovery cross-reference: meeting `<subject>` (`<joinUrl>`) implies a Teams chat that is not in `boundaries.teams.chat_ids[]`. Consider adding the chat manually if you want chat-side evidence captured alongside the meeting.
95
+
96
+ This is informational only — the orchestrator does NOT auto-add the chat-id (the meeting-implied chat-id format is not directly recoverable from the join URL without a separate Graph call, which is forbidden).
97
+
98
+ ## References
99
+
100
+ - `customer-hint-discovery.instructions.md` — orchestration contract.
101
+ - `pull-meetings/SKILL.md` — what consumes `series_join_urls[]`.
102
+ - `workiq-only.instructions.md` — `m365_list_meetings`, `m365_list_events`, Graph calendar API all forbidden.
103
+ - `meetings-verbatim-required.instructions.md` — what happens after the boundary is populated.
104
+ - `status-taxonomy.instructions.md` — `unresolved` vs. `blocked-config`.
@@ -0,0 +1,110 @@
1
+ ---
2
+ applyTo: "**/skills/bootstrap-project/**, **/skills/pull-sharepoint/**, **/skills/refresh-project/**"
3
+ description: "SharePoint site URL resolution — title/URL-substring WorkIQ phrasing that finds team sites mentioning the customer hint; opens a companion Open Question for the user to supply machine-local OneDrive sync paths. Writes site_urls[] only — local_folders[] stays user-supplied. Source-specific subset of customer-hint-discovery."
4
+ ---
5
+
6
+ # SharePoint bootstrap discovery (kushi v4.8.0+)
7
+
8
+ Governed by `customer-hint-discovery.instructions.md` — read that file first for the orchestration contract.
9
+
10
+ ## Empirical finding (kushi v4.8.1) — WorkIQ has no SharePoint site-inventory surface
11
+
12
+ Validated against the live WorkIQ surface on 2026-05-26 with hint `HCA`: WorkIQ returns a hard punt — *"the sources do not contain site inventory or SharePoint site properties; I cannot construct the requested table without fabricating data (which I will not do per policy)."* This is consistent — tenant-wide SharePoint site enumeration requires Graph admin endpoints that WorkIQ does not expose.
13
+
14
+ **Therefore:**
15
+
16
+ - The sweep below is retained as a **best-effort** opt-in. It MUST be attempted (so the doctrine self-validates if WorkIQ ever gains a site-inventory surface) but bootstrap MUST NOT block on it and MUST NOT write `last_status: blocked-config` when it returns 0 or a punt response.
17
+ - **The reliable path is `local_folders[]` — user-supplied OneDrive sync paths.** The companion Open Question (below) is the PRIMARY UX for SharePoint bootstrap.
18
+ - When the sweep returns a punt response (recognizable by classifier-keywords `cannot construct`, `fabricating data`, `no site inventory`, `routing to Graph`), classify as `discovery-empty-no-workiq-surface` (NOT `blocked-config`).
19
+
20
+ ## What this sweep populates — and what it does NOT
21
+
22
+ | Boundary key | Populated by sweep? | Why |
23
+ |---|---|---|
24
+ | `boundaries.sharepoint.site_urls[]` | **YES** | WorkIQ can enumerate sites by hint in site title or description. |
25
+ | `boundaries.sharepoint.local_folders[]` | **NO** | OneDrive sync paths are local-machine state; the customer hint cannot infer the user's local folder layout. User-supplied only. |
26
+ | `boundaries.sharepoint.drive_ids[]` | NO | Graph-direct Drive IDs — out of scope for WorkIQ-only discovery. |
27
+
28
+ ## Approved WorkIQ query (the ONLY shape that returns this data)
29
+
30
+ Issued ONCE per bootstrap, per project:
31
+
32
+ ```
33
+ workiq ask -q "In the SharePoint sites I have access to, find sites whose site title, site description, or URL mentions '<HINT>'. Return a flat table with: site title, full site URL, default document library name, site description, most recent activity date. Do not summarize. Do not truncate. Flat table only."
34
+ ```
35
+
36
+ Substitution rules:
37
+
38
+ - `<HINT>` = verbatim customer hint.
39
+ - No lookback parameter — site discovery is not time-windowed (sites are durable).
40
+
41
+ This phrasing — **natural-language by site title/URL** — is empirically the only shape that returns SharePoint site enumerations. Other phrasings punt to Graph or summary mode.
42
+
43
+ ## Forbidden phrasings (will fail empirically — do NOT emit)
44
+
45
+ | Forbidden phrasing | Why it fails |
46
+ |---|---|
47
+ | `"List all SharePoint sites in my tenant."` | Tenant-wide enumerate is privileged and routes to Graph admin endpoints; fails for non-admin users. |
48
+ | `"List the SharePoint sites I am a member of. Return siteId for each."` | ID-lookup phrasing punts to `m365_*` / Graph. |
49
+ | `"Search SharePoint for sites matching '<hint>'."` | Structured-search verb routes to summary mode. |
50
+ | `"What is the site ID for '<name>'?"` | ID-lookup punts to Graph. |
51
+ | `"List the document libraries in site '<url>'."` | Drive-enumeration verb routes to Graph `/drives`. |
52
+ | `"$filter=title eq '<name>'"` (OData) | Filter syntax fails. |
53
+
54
+ ## Parsing the response
55
+
56
+ WorkIQ commonly punts on this query (see Empirical finding above). Apply this classifier BEFORE attempting to parse a table:
57
+
58
+ 1. **Punt detection** — if the response body contains any of: `cannot construct`, `fabricating data`, `no site inventory`, `routing to Graph`, `Graph admin endpoint`, `SharePoint Admin`, OR the body has no markdown table at all → classify as `discovery-empty-no-workiq-surface`, write `last_status: unresolved` (NOT `blocked-config`), proceed to the companion Open Question. Do NOT attempt further parsing.
59
+ 2. If a table IS present, extract `full site URL` column — keep rows whose URL matches `^https?://[^/]+\.sharepoint\.com/sites/`.
60
+ 3. Normalize: strip query strings and trailing slashes.
61
+ 4. Deduplicate against existing `boundaries.sharepoint.site_urls[]`.
62
+ 5. Cap at top 10 by `most recent activity date` (descending). If activity date is missing, fall back to alphabetical site title.
63
+ 6. Confidence ranking:
64
+ - `high` — site title contains the hint (case-insensitive substring).
65
+ - `medium` — site URL slug contains the hint (e.g. `/sites/HCA-Engagement` for hint `HCA`).
66
+ - `low` — match was on site description only.
67
+
68
+ ## Sidecar file shape
69
+
70
+ Written to `<engagement-root>/<project>/Evidence/_discovery/<YYYY-MM-DD>_sharepoint_discovery-<alias>.yml`. Schema per `email-bootstrap-discovery.instructions.md` § Sidecar file shape, with `source: sharepoint`.
71
+
72
+ ```yaml
73
+ results:
74
+ - value: 'https://contoso.sharepoint.com/sites/HCA-Engagement'
75
+ label: 'HCA Engagement (last activity 2026-05-24; default library: Documents)'
76
+ site_title: 'HCA Engagement'
77
+ default_library: 'Documents'
78
+ most_recent_activity: '2026-05-24'
79
+ confidence: high
80
+ needs_review: true
81
+ ```
82
+
83
+ ## Bootstrap-status row
84
+
85
+ ```
86
+ | sharepoint | <HINT> | "In the SharePoint sites I have access to..." | 2 | 2 | 0 | <alias> |
87
+ ```
88
+
89
+ ## Companion follow-up — local folder hint
90
+
91
+ After the sweep completes and at least one site URL is persisted, write a one-line follow-up to `<project>/OPEN-QUESTIONS-DRAFT.md`:
92
+
93
+ > SharePoint sweep discovered N site URLs. To enable local-synced walks (filename + mtime + change events without WorkIQ round-trips), add the matching local OneDrive sync path(s) to `boundaries.sharepoint.local_folders[]` in `<project>/integrations.yml`. The sync path is typically `C:\Users\<you>\<TenantName>\<Site Name> - <Library Name>` on Windows. The sweep cannot infer this — it is machine-local state.
94
+
95
+ If the sweep returned 0 site URLs AND `boundaries.sharepoint.local_folders[]` is also empty, the source is `unresolved` and the Open Question is:
96
+
97
+ > SharePoint discovery returned no sites for `<HINT>` and no local synced folder is configured. To enable, either widen the customer hint, populate `boundaries.sharepoint.site_urls[]` manually with the engagement's SharePoint URL, or add a local synced folder to `boundaries.sharepoint.local_folders[]`.
98
+
99
+ ## When this sweep does NOT run
100
+
101
+ - `boundaries.sharepoint.site_urls[]` is populated and contains no `needs_review` rows.
102
+ - The active profile disables sharepoint.
103
+ - WorkIQ unreachable — `blocked-auth`.
104
+
105
+ ## References
106
+
107
+ - `customer-hint-discovery.instructions.md` — orchestration contract.
108
+ - `pull-sharepoint/SKILL.md` — what consumes `site_urls[]` (and the always-allowed local folder walk).
109
+ - `workiq-only.instructions.md` — `m365_search_files`, `m365_list_files`, Graph `/drives/*` forbidden for content; local folder walk is ALWAYS allowed because it is a filesystem read.
110
+ - `status-taxonomy.instructions.md` — `unresolved` vs. `blocked-config`.
@@ -20,7 +20,7 @@ Every place a kushi skill renders a per-source or per-task status MUST use a val
20
20
  | `blocked-config` | A required configuration value (per-project + global both empty) prevented querying. | `<source>-config-missing` or `<source>-boundary-missing` per `scope-boundaries.instructions.md` Rule 3. |
21
21
  | `blocked-permission` | Auth succeeded but the principal lacks access to the target. | HTTP 403 / `accessDenied` / SharePoint `UnauthorizedAccessException` on the canonical API. |
22
22
  | `blocked-throttled` | Service throttled the request; no narrower query succeeded. | `tooManyRequests`, `More than 3 retries performed`, `high demand` per `auth-and-retry §3`. |
23
- | `unresolved` | Discovery returned no matches inside the configured boundary. | All resolution-order steps returned 0 candidates; user has not yet picked or widened. |
23
+ | `unresolved` | Discovery returned no matches inside the configured boundary. | All resolution-order steps returned 0 candidates; user has not yet picked or widened. Includes the kushi v4.8.0+ `discovery-empty` annotation when a customer-hint discovery sweep ran (per `customer-hint-discovery.instructions.md`) and returned 0 hits — distinct from `blocked-config` (sweep was never attempted because a prerequisite was genuinely missing). |
24
24
  | `deferred` | WorkIQ failed after doubled-strict retry; deferred-retry marker written. | Per `deferred-retry-on-workiq-fail.instructions.md`. Next refresh drains the queue. |
25
25
  | `not-applicable` | Source is not configured for this project. | Source enabled=false or boundary list empty by design (e.g. SharePoint when project has no team site). |
26
26
  | `no-run-history` | Source has never been pulled for this project. | Used in backfill rows when creating status artifacts retroactively. |
@@ -0,0 +1,114 @@
1
+ ---
2
+ applyTo: "**/skills/bootstrap-project/**, **/skills/pull-teams/**, **/skills/refresh-project/**"
3
+ description: "Teams chat-id + channel-id resolution — two paired WorkIQ phrasings that surface 1:1 and group chats plus joined-team channels whose messages reference the customer hint. Writes chat_ids[] and channel_ids[] separately. Source-specific subset of customer-hint-discovery."
4
+ ---
5
+
6
+ # Teams bootstrap discovery (kushi v4.8.0+)
7
+
8
+ Governed by `customer-hint-discovery.instructions.md` — read that file first for the orchestration contract, rerun rules, multi-contributor merge behavior, and behavior matrix.
9
+
10
+ ## What this sweep populates
11
+
12
+ | Boundary key | Element shape | Example |
13
+ |---|---|---|
14
+ | `boundaries.teams.chat_ids[]` | string — Teams chat ID (Graph thread-id format) | `"19:abc...@thread.v2"` |
15
+ | `boundaries.teams.channel_ids[]` | string — `<teamId>:<channelId>` composite | `"abc-team-guid:19:def@thread.tacv2"` |
16
+
17
+ ## Empirical findings (kushi v4.8.1 — what WorkIQ actually returns)
18
+
19
+ Validated against the live WorkIQ surface on 2026-05-26 with hint `HCA`:
20
+
21
+ - **Query 1 (chats) — works**, but WorkIQ returns `chat ID = N/A` (the column is present but empty). The chat ID MUST be extracted from the per-row message permalink URL — see "Parsing the response" below.
22
+ - **Query 2 (channels) — DOES NOT WORK.** WorkIQ has no Teams channel-enumeration surface. Every empirical attempt either returned empty or reclassified channel hits as chats with `N/A` IDs. The query is retained below as a `best-effort` opt-in only — bootstrap MUST NOT block on it and MUST NOT write `last_status: blocked-config` when it returns 0. Manual user-supplied `channel_ids[]` is the only reliable path.
23
+
24
+ ## Approved WorkIQ queries (the ONLY shapes that may return this data)
25
+
26
+ Two narrow queries — issued in sequence (chats first, then channels). Each issued ONCE per bootstrap, per project.
27
+
28
+ ### Query 1 — Chats (REQUIRED, primary path)
29
+
30
+ ```
31
+ workiq ask -q "In my Microsoft Teams 1:1 and group chats, find chats where the topic or any member's name or email mentions '<HINT>' and that had at least one message in the last <N> days. Return a flat table with: chat topic, chat ID, all member display names, all member emails, most recent message date. Do not summarize. Do not truncate. Flat table only."
32
+ ```
33
+
34
+ ### Query 2 — Channels (BEST-EFFORT only; v4.8.1 empirical: returns nothing usable)
35
+
36
+ ```
37
+ workiq ask -q "In the Microsoft Teams I have joined, find channels whose channel display name or parent team display name mentions '<HINT>'. Return a flat table with: team name, team ID, channel name, channel ID, channel type. Do not summarize. Do not truncate. Flat table only."
38
+ ```
39
+
40
+ Substitution rules:
41
+
42
+ - `<HINT>` = verbatim customer hint.
43
+ - `<N>` = `m365-mutable.json#bootstrap.discoveryLookbackDays` (default 90).
44
+
45
+ The phrasings are **natural-language by chat/channel content** — empirically the only shapes that return chat/channel data. WorkIQ punts on any other shape.
46
+
47
+ ## Forbidden phrasings (will fail empirically — do NOT emit)
48
+
49
+ | Forbidden phrasing | Why it fails |
50
+ |---|---|
51
+ | `"List all my Teams chats. Return chat ID and topic for each."` | Enumerate-verb punts to `m365_list_chats` / Graph; returns Graph-Explorer guidance instead of data. |
52
+ | `"List the channels in every team I am a member of."` | Bulk enumerate punts to Graph beta endpoints. |
53
+ | `"What is the chat ID for the chat with '<name>'?"` | ID-lookup question — punts to Graph. |
54
+ | `"Search Microsoft 365 Teams for chats matching '<hint>'."` | Structured-search verb routes to summary mode. |
55
+ | `"Get the Teams chat hierarchy for my account."` | Hierarchy verb returns prose. |
56
+ | `"Filter my chats where topic contains '<hint>'."` | Filter-verb routes to OData/Graph and fails. |
57
+
58
+ ## Parsing the response
59
+
60
+ For each query, WorkIQ returns a markdown table.
61
+
62
+ ### Chat parsing (Query 1)
63
+
64
+ WorkIQ returns the `chat ID` column populated as `N/A` (empirical, v4.8.1). The canonical chat ID lives in the per-row message permalink URL. Extract it as follows:
65
+
66
+ 1. For each row, scan all hyperlink URLs in the `Most Recent Message Date` (and any other) cell. The shape is:
67
+ `https://teams.microsoft.com/l/message/<chatId>/<messageId>?context=...`
68
+ where `<chatId>` matches `^19:[^/]+@thread\.(v2|skype|tacv2|spaces)$`.
69
+ 2. Extract the first matching `<chatId>`. URL-decode any `%3a` → `:` and `%40` → `@`.
70
+ 3. If the URL form is `19:48ed1b82-...-..._...@unq.gbl.spaces/...` (1:1 chat), that IS the chat id — keep as-is.
71
+ 4. Deduplicate against existing `boundaries.teams.chat_ids[]`.
72
+ 5. Cap at top 10 by `most recent message date` (descending).
73
+ 6. Confidence ranking:
74
+ - `high` — `chat topic` explicitly contains the hint (case-insensitive substring).
75
+ - `medium` — any member's email domain matches a known customer domain (heuristic: hint appears as substring in domain), e.g. hint `HCA` + member `@hcahealthcare.com`.
76
+ - `low` — match was on a member's display name only.
77
+
78
+ **If no message permalink URL is found in a row**, that row CANNOT be persisted to `boundaries.teams.chat_ids[]` — log it to OPEN-QUESTIONS-DRAFT.md under `## Teams chats discovered without IDs` with the topic + members so the user can supply the chat id manually.
79
+
80
+ ### Channel parsing (Query 2)
81
+
82
+ **v4.8.1 empirical finding:** WorkIQ does not surface true Teams channel data. Every attempted query either returned empty or reclassified channel hits as 1:1/group chats with `N/A` IDs. Treat any output from Query 2 as informational:
83
+
84
+ 1. If the response is empty or every row has `team ID = N/A` AND `channel ID = N/A`: write `last_status: unresolved` with annotation `discovery-empty-no-workiq-surface` (NOT `blocked-config`). Open Question: *"Teams channel-id discovery has no working WorkIQ surface as of kushi v4.8.1. If this engagement uses Teams channels (not chats), populate `boundaries.teams.channel_ids[]` manually with the `<teamId>:<channelId>` composite — found in the channel URL under Teams ⟶ ⋯ ⟶ Get link to channel."*
85
+ 2. If a row HAS both `team ID` and `channel ID` populated (rare — happens only when WorkIQ has cached channel metadata from a recent direct probe): build composite `<teamId>:<channelId>`, dedupe, cap at 10.
86
+ 3. The cross-reference with Query 1 chats is informational only — do NOT auto-convert a chat-id into a channel-id.
87
+
88
+ ## Sidecar file shape
89
+
90
+ Two sidecar files (one per query). Written to `<engagement-root>/<project>/Evidence/_discovery/<YYYY-MM-DD>_teams-chats_discovery-<alias>.yml` and `<YYYY-MM-DD>_teams-channels_discovery-<alias>.yml`.
91
+
92
+ Schema is the same as `email-bootstrap-discovery.instructions.md` § Sidecar file shape, with `source: teams-chats` or `source: teams-channels`.
93
+
94
+ ## Bootstrap-status rows
95
+
96
+ Two rows in the Discovery Sweep Results table:
97
+
98
+ ```
99
+ | teams-chats | <HINT> | "In my Microsoft Teams 1:1 and group chats..." | 14 | 10 | 4 | <alias> |
100
+ | teams-channels | <HINT> | "In the Microsoft Teams I have joined..." | 2 | 2 | 0 | <alias> |
101
+ ```
102
+
103
+ ## When this sweep does NOT run
104
+
105
+ - Both `boundaries.teams.chat_ids[]` AND `boundaries.teams.channel_ids[]` are populated and contain no `needs_review` rows.
106
+ - The active profile disables teams.
107
+ - WorkIQ unreachable — write `blocked-auth`.
108
+
109
+ ## References
110
+
111
+ - `customer-hint-discovery.instructions.md` — orchestration contract.
112
+ - `pull-teams/SKILL.md` — what consumes `chat_ids[]` and `channel_ids[]`.
113
+ - `workiq-only.instructions.md` — `m365_list_chats` / `m365_search_chats` forbidden.
114
+ - `status-taxonomy.instructions.md` — `unresolved` vs. `blocked-config`.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: "bootstrap-project"
3
- version: "2.3.3"
4
- description: "First-time setup for a project: machine preflight, side-by-side config, engagement-root + project resolution, initial 30d snapshot+stream pull across all enabled sources. Verbatim-by-default per `verbatim-by-default.instructions.md` — every enabled source dispatched, no silent skips. CRM bootstrap discovery REQUIRED to run live Dataverse 4-step resolution before declaring disabled per `crm-bootstrap-discovery.instructions.md` (v2.3.0). Writes per-user refresh report per `run-reports.instructions.md`. Cleans stale no-match notes on resolution per `cleanup-on-resolution.instructions.md`. Builds State/ only on `full` profile. Idempotent."
3
+ version: "2.4.0"
4
+ description: "First-time setup for a project: machine preflight, side-by-side config, engagement-root + project resolution, customer-hint discovery sweep across all WorkIQ-driven sources (kushi v4.8.0+, per customer-hint-discovery.instructions.md), initial 30d snapshot+stream pull across all enabled sources. Verbatim-by-default per `verbatim-by-default.instructions.md` — every enabled source dispatched, no silent skips. Discovery sweep MANDATORY before declaring blocked-config for email/teams/meetings/sharepoint per customer-hint-discovery.instructions.md. CRM bootstrap discovery REQUIRED per `crm-bootstrap-discovery.instructions.md` (v2.3.0). Writes per-user refresh report per `run-reports.instructions.md`. Cleans stale no-match notes on resolution per `cleanup-on-resolution.instructions.md`. Builds State/ only on `full` profile. Idempotent."
5
5
  ---
6
6
 
7
7
  # Skill: bootstrap-project
@@ -114,9 +114,46 @@ The `State/` subtree is created **only on `full` profile**. On `standard`, only
114
114
 
115
115
  **Pin hook (v4.5.0):** immediately after scaffold, per `onedrive-pin-policy.instructions.md`, extend the OneDrive pin set to cover the new folders — `<project>/integrations.yml`, `<project>/bootstrap-status.md` (after Step 7 writes it), `<project>/Evidence/<alias>/`, `<project>/Evidence/_Consolidated/` (when created), and `<project>/State/` (when scaffolded). Idempotent + additive — never unpins. Skips silently on Linux or when OneDrive isn't running. This keeps every contributor's own slice always-on-device while leaving other contributors' evidence cloud-only.
116
116
 
117
+ ### Step 3.5 — Customer-hint discovery sweep (REQUIRED, kushi v4.8.0+)
118
+
119
+ Per `customer-hint-discovery.instructions.md` — **before the boundaries gate runs in Step 4**, bootstrap MUST attempt a WorkIQ-driven discovery sweep for every source whose boundary is currently empty AND whose WorkIQ-driven discovery doctrine exists. This prevents the silent-skip defect where bootstrap scaffolds empty boundaries and immediately writes every source as `blocked-config` without ever asking WorkIQ what mentions the customer.
120
+
121
+ For each source in the table below, if `<engagement-root>/<project>/integrations.yml#boundaries.<source>.<required-key>` is empty (or contains only `needs_review: true` rows from a prior incomplete sweep), dispatch the sweep using the customer hint from Step 1 + `<discoveryLookbackDays>` (default 90, configurable via `m365-mutable.json#bootstrap.discoveryLookbackDays`):
122
+
123
+ | Source | Sweep doctrine | Populates | Required-key gate |
124
+ |---|---|---|---|
125
+ | email | `email-bootstrap-discovery.instructions.md` | `boundaries.email.mailboxes[]` | `mailboxes` empty |
126
+ | teams | `teams-bootstrap-discovery.instructions.md` | `boundaries.teams.chat_ids[]` + `channel_ids[]` | BOTH empty |
127
+ | meetings | `meetings-bootstrap-discovery.instructions.md` | `boundaries.meetings.series_join_urls[]` | `series_join_urls` empty |
128
+ | sharepoint | `sharepoint-bootstrap-discovery.instructions.md` | `boundaries.sharepoint.site_urls[]` | `site_urls` empty AND `local_folders` empty |
129
+ | onenote | existing Step 4a (display-name driven, v4.7.x) | `boundaries.onenote.section_file_ids[]` + `section_group_ids[]` | both empty |
130
+ | loop | `loop-bootstrap-discovery.instructions.md` (v4.6.0) | `boundaries.loop.workspace_ids[]` | empty |
131
+ | crm | `crm-bootstrap-discovery.instructions.md` (v3.11.0) | `boundaries.crm.record_ids[]` + `request_ids[]` | both empty AND `crm:` shared block populated |
132
+ | ado | `ado-bootstrap-discovery.instructions.md` | `boundaries.ado.area_paths[]` | empty AND `ado:` shared block populated |
133
+
134
+ For each sweep:
135
+
136
+ 1. Read the per-source doctrine for the exact approved WorkIQ query shape.
137
+ 2. Issue ONE WorkIQ ask per source per project (chats + channels are TWO asks under the same teams doctrine).
138
+ 3. Parse the response per the doctrine's parsing rules.
139
+ 4. Cap at 10 candidates by recency (or per-doctrine ordering).
140
+ 5. Append discovered values to `<project>/integrations.yml#boundaries.<source>.<key>` as plain strings (deduplicate by exact-string equality).
141
+ 6. Write sidecar `<project>/Evidence/_discovery/<YYYY-MM-DD>_<source>_discovery-<alias>.yml` with per-row metadata (`discovered_by`, `discovered_at`, `needs_review: true`, `confidence`, `query`, `workiq_request_id`).
142
+ 7. If `> 10` candidates: append the remainder to `<project>/OPEN-QUESTIONS-DRAFT.md` under `## Discovery sweep — candidates over cap`.
143
+ 8. If `0` candidates: write `last_status: unresolved` (NOT `blocked-config`) and append a one-line widen-hint suggestion to Open Questions.
144
+ 9. If WorkIQ errors: write a `deferred-retry` marker per `deferred-retry-on-workiq-fail.instructions.md` and set `last_status: deferred`. Do NOT skip ahead to `blocked-config`.
145
+
146
+ Run sweeps **in parallel** where possible (they're independent WorkIQ asks). Total wall time should be ≤ 5× single-ask latency.
147
+
148
+ **Rerun rule** — if the boundary already has at least one row that is NOT `needs_review: true` in the sidecar (user manually confirmed), the sweep is **skipped** for that source. Boundary is gospel. Pass `--force-rediscover` to override.
149
+
150
+ **Forbidden:** declaring `last_status: blocked-config` for email/teams/meetings/sharepoint without first running this sweep. That is a defect per `customer-hint-discovery.instructions.md` § The rule. `blocked-config` is only legitimate when a prerequisite is genuinely missing (CRM/ADO shared config, SharePoint when both `site_urls` AND `local_folders` are empty AND the sweep returned 0).
151
+
152
+ After all sweeps complete, write the `## Discovery Sweep Results` table to `<project>/bootstrap-status.md` per `customer-hint-discovery.instructions.md` § Required outputs (4).
153
+
117
154
  ### Step 4 — Initial pull (last 30 days)
118
155
 
119
- **Boundaries gate** (kushi v3.7.0+, per `scope-boundaries.instructions.md`): before dispatching any `pull-*`, read `<engagement-root>/<project>/integrations.yml#boundaries` and verify each enabled source has its required boundary key populated. For sources where bootstrap can auto-populate from existing `m365-mutable.json` discovery hints (e.g. a previously-discovered `section_file_id` lands in `boundaries.onenote.section_file_ids`), do so and continue. For sources where the boundary cannot be auto-populated, write the source as **disabled** in `integrations.yml` and add a one-liner to `<project>/OPEN-QUESTIONS-DRAFT.md` (or `State/09_open-questions.md` on `full` profile) asking the user to fill the boundary and re-enable.
156
+ **Boundaries gate** (kushi v3.7.0+, per `scope-boundaries.instructions.md`): before dispatching any `pull-*`, read `<engagement-root>/<project>/integrations.yml#boundaries` and verify each enabled source has its required boundary key populated. After Step 3.5, many of these should now contain discovered rows (annotated `needs_review: true` in their sidecars). For sources where the sweep ran but returned 0 candidates, the status is `unresolved` (not `blocked-config`) add a one-liner to `<project>/OPEN-QUESTIONS-DRAFT.md` asking the user to widen the hint or manually seed the boundary. For sources where the sweep COULD NOT run because a prerequisite is genuinely missing (CRM/ADO shared connection block empty; SharePoint local-folder discovery), `blocked-config` is correct and the `next_step` MUST cite the specific missing field.
120
157
 
121
158
  For CRM and ADO additionally verify the shared connection block exists in `<workspace>/.kushi/config/shared/integrations.yml` (`crm:` block with `environmentUrl` + `tenantId`, OR `ado:` block with `organization` + `project`) with non-placeholder values; if missing, prompt the user to fill those two/four fields directly (no separate template files — they live in `templates/init/integrations.template.yml`) and park in Open Questions with the path. **Do NOT auto-improvise** by inferring a tenant/org or by narrating CRM evidence from email — both are explicit anti-patterns in v3.7.0.
122
159
 
@@ -43,6 +43,10 @@ This skill REFUSES to query unless `<engagement-root>/<project>/integrations.yml
43
43
  - `boundaries.email.subject_keywords` — optional narrowing.
44
44
  - `boundaries.date_window_days` — defaults to 30 if absent.
45
45
 
46
+ ## Bootstrap discovery (kushi v4.8.0+, per `customer-hint-discovery.instructions.md`)
47
+
48
+ When `bootstrap-project` runs with `boundaries.email.mailboxes[]` empty, it MUST attempt the WorkIQ customer-hint discovery sweep before declaring `blocked-config`. The full doctrine — approved query phrasing, forbidden phrasings, parsing rules, confidence ranking, sidecar shape, cap-at-10 behavior, and rerun rules — lives in `email-bootstrap-discovery.instructions.md`. Declaring `last_status: blocked-config` for email without first running that sweep is a defect.
49
+
46
50
  Every WorkIQ ask MUST be scoped to those mailboxes + (if set) sender_domains + subject_keywords. Empty hits inside the boundary → write Coverage Notes citing the limiting key; do NOT widen the scope. (`m365_search_emails` / `m365_list_emails` / any Graph call is FORBIDDEN per `workiq-only.instructions.md`; on WorkIQ failure, write a deferred-retry marker per `deferred-retry-on-workiq-fail.instructions.md` and continue.)
47
51
 
48
52
  Refusal message when boundary is missing:
@@ -52,6 +52,10 @@ This skill REFUSES to query unless `<engagement-root>/<project>/integrations.yml
52
52
  - `boundaries.meetings.organizer_emails` — optional additional filter.
53
53
  - `boundaries.date_window_days` — defaults to 30 if absent.
54
54
 
55
+ ## Bootstrap discovery (kushi v4.8.0+, per `customer-hint-discovery.instructions.md`)
56
+
57
+ When `bootstrap-project` runs with `boundaries.meetings.series_join_urls[]` empty, it MUST attempt the WorkIQ customer-hint discovery sweep before declaring `blocked-config`. The full doctrine — approved WorkIQ query for meeting series by subject, forbidden phrasings, recurring-series URL collapse, confidence ranking, sidecar shape, cap-at-10 behavior, cross-reference-with-teams-sweep hint, and rerun rules — lives in `meetings-bootstrap-discovery.instructions.md`. Declaring `last_status: blocked-config` for meetings without first running that sweep is a defect.
58
+
55
59
  The pre-existing `subjectKeywords` / `knownSeries` discovery loop is now a **bootstrap-time aid only** — it helps populate `boundaries.meetings.series_join_urls` once. At pull time, only meetings whose `joinUrl` is in the boundary list are processed.
56
60
 
57
61
  Refusal message when boundary is missing:
@@ -46,6 +46,10 @@ This skill REFUSES to query unless `<engagement-root>/<project>/integrations.yml
46
46
  - `boundaries.sharepoint.drive_ids` — optional Graph-direct access.
47
47
  - `boundaries.date_window_days` — defaults to 30 if absent.
48
48
 
49
+ ## Bootstrap discovery (kushi v4.8.0+, per `customer-hint-discovery.instructions.md`)
50
+
51
+ When `bootstrap-project` runs with BOTH `boundaries.sharepoint.site_urls[]` AND `local_folders[]` empty, it MUST attempt the WorkIQ customer-hint discovery sweep before declaring `blocked-config`. The full doctrine — approved WorkIQ query for sites by title/description/URL, forbidden phrasings, confidence ranking, sidecar shape, cap-at-10 behavior, companion local-folder follow-up Open Question, and rerun rules — lives in `sharepoint-bootstrap-discovery.instructions.md`. The sweep populates `site_urls[]` only — `local_folders[]` remains user-supplied because the customer hint cannot infer machine-local OneDrive sync paths. Declaring `last_status: blocked-config` for sharepoint when both arrays are empty without first running that sweep is a defect.
52
+
49
53
  The walk is **strictly inclusive** — only paths/sites/drives in the boundary are walked. There is no auto-discovery of additional folders. `path_includes`/`path_excludes` from `.settings.yml` further narrow inside the boundary; they cannot widen it.
50
54
 
51
55
  Refusal message when boundary is missing:
@@ -62,6 +62,10 @@ This skill REFUSES to query unless `<engagement-root>/<project>/integrations.yml
62
62
  - `boundaries.teams.channel_ids` — REQUIRED for any channel evidence (empty = no channel pulls).
63
63
  - `boundaries.date_window_days` — defaults to 30 if absent.
64
64
 
65
+ ## Bootstrap discovery (kushi v4.8.0+, per `customer-hint-discovery.instructions.md`)
66
+
67
+ When `bootstrap-project` runs with `boundaries.teams.chat_ids[]` AND `channel_ids[]` empty, it MUST attempt the WorkIQ customer-hint discovery sweep before declaring `blocked-config`. The full doctrine — two narrow WorkIQ queries (one for chats, one for channels), approved/forbidden phrasings, parsing rules, confidence ranking, sidecar shape, cap-at-10 behavior, and rerun rules — lives in `teams-bootstrap-discovery.instructions.md`. Declaring `last_status: blocked-config` for teams without first running both sweeps is a defect.
68
+
65
69
  There is **no fuzzy chat discovery** in v3.7.0+. The pre-existing `chatHints` / `participantHints` are now treated as **discovery aids during bootstrap only** — they help the user populate `boundaries.teams.chat_ids` once, and after that they are ignored at pull time.
66
70
 
67
71
  Refusal message when boundary is missing:
@@ -0,0 +1,235 @@
1
+ // Integration test: validate that each per-source discovery doctrine's approved WorkIQ query
2
+ // actually returns usable data against the live WorkIQ surface. OPT-IN — not run by `npm test`.
3
+ //
4
+ // Run with: KUSHI_DRYRUN_HINT=HCA node src/bootstrap-dryrun.integration.test.mjs
5
+ // or: npm run test:integration:bootstrap
6
+ //
7
+ // This is the gate that would have caught the v4.8.0 shipping defects (teams-channels punt,
8
+ // sharepoint sites punt, meetings join URL not surfaced, teams chat IDs returned as N/A).
9
+
10
+ import { execSync, spawnSync } from 'node:child_process';
11
+ import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
12
+ import { dirname, join, resolve } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const repoRoot = resolve(__dirname, '..');
17
+ const HINT = process.env.KUSHI_DRYRUN_HINT || 'HCA';
18
+ const WORKIQ = process.env.KUSHI_WORKIQ_BIN || 'C:\\Users\\ushak\\.copilot\\bin\\workiq.cmd';
19
+ const LOOKBACK = Number(process.env.KUSHI_DRYRUN_LOOKBACK || 90);
20
+
21
+ // Source -> { doctrine file, approved-query extraction rule, classifier }
22
+ const SOURCES = [
23
+ {
24
+ id: 'email',
25
+ doctrine: 'email-bootstrap-discovery.instructions.md',
26
+ // Doctrine has ONE ```\nworkiq ask -q "..."\n``` block — the approved query.
27
+ expects: 'table with folder path column populated',
28
+ classify(stdout) {
29
+ if (/cannot construct|fabricating data|no .* inventory|routing to Graph/i.test(stdout)) return 'punt';
30
+ const lines = stdout.split(/\r?\n/).filter(l => /^\|/.test(l));
31
+ if (lines.length < 3) return 'empty';
32
+ // Header + separator + ≥1 data row. Look for folder path column (non-empty).
33
+ const dataRows = lines.slice(2);
34
+ const folderRows = dataRows.filter(l => {
35
+ const cols = l.split('|').map(c => c.trim()).filter(c => c.length > 0);
36
+ return cols[0] && cols[0] !== 'N/A' && !/^-+$/.test(cols[0]);
37
+ });
38
+ return folderRows.length > 0 ? `ok (${folderRows.length} folders)` : 'empty';
39
+ },
40
+ },
41
+ {
42
+ id: 'teams-chats',
43
+ doctrine: 'teams-bootstrap-discovery.instructions.md',
44
+ queryIndex: 0, // first ```workiq ask block
45
+ expects: 'table with chat topic + extractable chat ID from message URL',
46
+ classify(stdout) {
47
+ if (/cannot construct|fabricating data|no .* inventory/i.test(stdout)) return 'punt';
48
+ const lines = stdout.split(/\r?\n/).filter(l => /^\|/.test(l));
49
+ if (lines.length < 3) return 'empty';
50
+ // Need chat topic non-empty AND at least one row with extractable chat ID from a URL
51
+ const dataRows = lines.slice(2);
52
+ const withTopic = dataRows.filter(l => {
53
+ const cols = l.split('|').map(c => c.trim());
54
+ return cols[1] && cols[1] !== 'N/A';
55
+ });
56
+ const extractableIds = stdout.match(/19:[^/)\s"']+@thread\.(v2|skype|tacv2|spaces)/g) || [];
57
+ if (withTopic.length === 0) return 'empty';
58
+ return `ok (${withTopic.length} chats, ${extractableIds.length} extractable thread IDs in response)`;
59
+ },
60
+ },
61
+ {
62
+ id: 'teams-channels',
63
+ doctrine: 'teams-bootstrap-discovery.instructions.md',
64
+ queryIndex: 1, // second ```workiq ask block
65
+ expects: 'KNOWN-EMPTY (v4.8.1 empirical: WorkIQ has no Teams channel-enumeration surface)',
66
+ classify(stdout) {
67
+ if (/cannot construct|fabricating data|no .* inventory|Graph-backed|not a channel/i.test(stdout)) return 'punt (as expected — no WorkIQ surface)';
68
+ const lines = stdout.split(/\r?\n/).filter(l => /^\|/.test(l));
69
+ if (lines.length < 3) return 'empty (as expected)';
70
+ const dataRows = lines.slice(2);
71
+ const withChannelId = dataRows.filter(l => {
72
+ const cols = l.split('|').map(c => c.trim());
73
+ // team ID col index 2, channel ID col index 4 in the doctrine table
74
+ return cols[2] && cols[2] !== 'N/A' && cols[4] && cols[4] !== 'N/A';
75
+ });
76
+ return withChannelId.length > 0
77
+ ? `unexpected-ok (${withChannelId.length} channels with IDs — UPDATE doctrine!)`
78
+ : 'punt (as expected — no WorkIQ surface)';
79
+ },
80
+ expectedClassification: /^punt|^empty/,
81
+ },
82
+ {
83
+ id: 'meetings',
84
+ doctrine: 'meetings-bootstrap-discovery.instructions.md',
85
+ expects: 'table with subject + meeting URL (meetup-join OR meeting/details?eventId)',
86
+ classify(stdout) {
87
+ if (/cannot construct|fabricating data|no .* inventory/i.test(stdout)) return 'punt';
88
+ const lines = stdout.split(/\r?\n/).filter(l => /^\|/.test(l));
89
+ if (lines.length < 3) return 'empty';
90
+ const dataRows = lines.slice(2);
91
+ const withSubject = dataRows.filter(l => {
92
+ const cols = l.split('|').map(c => c.trim());
93
+ return cols[1] && cols[1] !== 'N/A';
94
+ });
95
+ const withJoinUrl = dataRows.filter(l => /teams\.microsoft\.com\/l\/(meetup-join|meeting\/details)/i.test(l));
96
+ if (withSubject.length === 0) return 'empty';
97
+ return `ok (${withSubject.length} meetings, ${withJoinUrl.length} with extractable URLs)`;
98
+ },
99
+ },
100
+ {
101
+ id: 'sharepoint',
102
+ doctrine: 'sharepoint-bootstrap-discovery.instructions.md',
103
+ expects: 'KNOWN-EMPTY (v4.8.1 empirical: WorkIQ has no SharePoint site-inventory surface)',
104
+ classify(stdout) {
105
+ if (/cannot construct|fabricating data|no .* inventory|routing to Graph|SharePoint Admin/i.test(stdout)) return 'punt (as expected — no WorkIQ surface)';
106
+ const lines = stdout.split(/\r?\n/).filter(l => /^\|/.test(l));
107
+ if (lines.length < 3) return 'empty (as expected)';
108
+ const dataRows = lines.slice(2);
109
+ const withUrl = dataRows.filter(l => {
110
+ const cols = l.split('|').map(c => c.trim());
111
+ // cols[2] is Full Site URL column. Must be a real /sites/ or /teams/ collection URL,
112
+ // not a personal mysite (-my.sharepoint.com) or a citation in another column.
113
+ const url = cols[2] || '';
114
+ return /^https:\/\/[^/]*sharepoint\.com\/(sites|teams)\//i.test(url);
115
+ });
116
+ return withUrl.length > 0
117
+ ? `unexpected-ok (${withUrl.length} sites — UPDATE doctrine!)`
118
+ : 'punt (as expected — no usable WorkIQ surface)';
119
+ },
120
+ expectedClassification: /^punt|^empty/,
121
+ },
122
+ ];
123
+
124
+ function extractApprovedQuery(doctrineFile, queryIndex = 0) {
125
+ const text = readFileSync(join(repoRoot, 'plugin', 'instructions', doctrineFile), 'utf8');
126
+ // Find every ``` fenced block containing `workiq ask -q "..."`. Capture the quoted query.
127
+ const re = /```[^\n]*\n([\s\S]*?)\n```/g;
128
+ const queries = [];
129
+ let m;
130
+ while ((m = re.exec(text)) !== null) {
131
+ const block = m[1];
132
+ const qMatch = block.match(/workiq\s+ask\s+-q\s+"([\s\S]+?)"/);
133
+ if (qMatch) queries.push(qMatch[1]);
134
+ }
135
+ if (queries.length <= queryIndex) {
136
+ throw new Error(`Doctrine ${doctrineFile} does not contain query #${queryIndex} (found ${queries.length})`);
137
+ }
138
+ return queries[queryIndex];
139
+ }
140
+
141
+ function substituteHint(query, hint, lookback) {
142
+ return query
143
+ .replace(/'<HINT>'/g, `'${hint}'`)
144
+ .replace(/<HINT>/g, hint)
145
+ .replace(/<N>/g, String(lookback));
146
+ }
147
+
148
+ function runWorkIQ(query) {
149
+ const t0 = Date.now();
150
+ // cmd.exe quote-handling: spawn without shell, use .cmd directly via shell escape, OR
151
+ // pass the full command as a single argv[0] with shell:true. Easier: spawn workiq directly
152
+ // (cmd shim works with shell:false on Windows when the path ends in .cmd via the runtime).
153
+ // We pre-escape the query to survive cmd's parser: wrap in "...", escape internal " as "".
154
+ const escaped = `"${query.replace(/"/g, '""')}"`;
155
+ const res = spawnSync(WORKIQ, ['ask', '-q', escaped], {
156
+ encoding: 'utf8',
157
+ maxBuffer: 50 * 1024 * 1024,
158
+ shell: true,
159
+ timeout: 240_000,
160
+ windowsVerbatimArguments: true,
161
+ });
162
+ // Strip ANSI color codes — WorkIQ emits them and they break ^| line classifiers.
163
+ const ansi = /\x1b\[[0-9;]*m/g;
164
+ const raw = (res.stdout || '') + (res.stderr || '');
165
+ return {
166
+ stdout: raw.replace(ansi, ''),
167
+ exitCode: res.status,
168
+ elapsedMs: Date.now() - t0,
169
+ };
170
+ }
171
+
172
+ function main() {
173
+ if (!existsSync(WORKIQ)) {
174
+ console.error(`✗ workiq binary not found at ${WORKIQ}. Set KUSHI_WORKIQ_BIN or install workiq.`);
175
+ process.exit(2);
176
+ }
177
+
178
+ console.log(`# kushi bootstrap-dryrun integration test`);
179
+ console.log(`hint=${HINT} lookback=${LOOKBACK}d workiq=${WORKIQ}`);
180
+ console.log(``);
181
+
182
+ const outDir = join(repoRoot, '.dryrun-output');
183
+ mkdirSync(outDir, { recursive: true });
184
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
185
+
186
+ const results = [];
187
+ let failures = 0;
188
+
189
+ for (const src of SOURCES) {
190
+ process.stdout.write(`▶ ${src.id.padEnd(16)} `);
191
+ let query;
192
+ try {
193
+ query = substituteHint(extractApprovedQuery(src.doctrine, src.queryIndex || 0), HINT, LOOKBACK);
194
+ } catch (e) {
195
+ console.log(`extract-failed: ${e.message}`);
196
+ failures++;
197
+ results.push({ source: src.id, status: `extract-failed: ${e.message}`, elapsedMs: 0 });
198
+ continue;
199
+ }
200
+ let { stdout, exitCode, elapsedMs } = runWorkIQ(query);
201
+ // Retry once on transient WorkIQ HTTP/2 / protocol / 5xx errors.
202
+ const transient = /HttpProtocolError|HTTP\/2 server reset|INTERNAL_ERROR|503|502|504|ECONNRESET|ETIMEDOUT/i;
203
+ if (transient.test(stdout)) {
204
+ process.stdout.write('(transient, retry) ');
205
+ const retry = runWorkIQ(query);
206
+ stdout = retry.stdout;
207
+ exitCode = retry.exitCode;
208
+ elapsedMs += retry.elapsedMs;
209
+ }
210
+ const classification = src.classify(stdout);
211
+ const expectedRe = src.expectedClassification || /^ok/;
212
+ const pass = expectedRe.test(classification);
213
+ console.log(`${pass ? '✓' : '✗'} ${classification} (${elapsedMs}ms, exit=${exitCode})`);
214
+ if (!pass) {
215
+ failures++;
216
+ console.log(` expects: ${src.expects}`);
217
+ console.log(` query : ${query.slice(0, 120)}...`);
218
+ }
219
+ results.push({ source: src.id, classification, expects: src.expects, pass, elapsedMs, exitCode });
220
+ // Save raw response for debugging
221
+ writeFileSync(join(outDir, `${ts}_${src.id}.txt`), `# query\n${query}\n\n# response\n${stdout}\n`, 'utf8');
222
+ }
223
+
224
+ console.log('');
225
+ console.log(`Outputs saved to: ${outDir}`);
226
+ console.log(`Result: ${results.length - failures}/${results.length} passed.`);
227
+
228
+ if (failures > 0) {
229
+ console.log('');
230
+ console.log('FAILED — review .dryrun-output/ files. If WorkIQ surface changed, update the doctrine and re-run.');
231
+ process.exit(1);
232
+ }
233
+ }
234
+
235
+ main();
@@ -27,22 +27,41 @@ const allowlistFiles = new Set([
27
27
  'plugin/instructions/workiq-onenote-query-shape.instructions.md',
28
28
  // Anti-pattern lists in the registry doctrine also quote them.
29
29
  'plugin/instructions/m365-id-registry.instructions.md',
30
+ // v4.8.0 — customer-hint discovery doctrines MUST quote their forbidden phrasings.
31
+ 'plugin/instructions/customer-hint-discovery.instructions.md',
32
+ 'plugin/instructions/email-bootstrap-discovery.instructions.md',
33
+ 'plugin/instructions/teams-bootstrap-discovery.instructions.md',
34
+ 'plugin/instructions/meetings-bootstrap-discovery.instructions.md',
35
+ 'plugin/instructions/sharepoint-bootstrap-discovery.instructions.md',
30
36
  // Historical record.
31
37
  'plugin/learnings/onenote.md',
32
38
  'CHANGELOG.md',
33
39
  ].map(p => p.replaceAll('/', path.sep)));
34
40
 
35
- // Forbidden phrasings. Use lowercase substring match these patterns empirically fail in WorkIQ
36
- // regardless of capitalization or surrounding sentence structure.
41
+ // Forbidden phrasings. Each entry tags the doctrine file that MUST quote it (to forbid it).
37
42
  const forbiddenPhrasings = [
38
- { phrase: 'list my onenote notebooks', why: 'WorkIQ has no notebook-inventory endpoint; punts to Graph Explorer.' },
39
- { phrase: 'what is the onenote notebook id', why: 'WorkIQ has no notebook-ID lookup; punts to Graph Explorer.' },
40
- { phrase: 'list sections in onenote notebook', why: 'WorkIQ has no bulk-section enumeration surface.' },
41
- { phrase: 'search microsoft 365 onenote for sections matching', why: 'Structured-field enumeration query; routes WorkIQ to summary mode (v3.7.9 finding).' },
42
- { phrase: 'wdsectionfileid =', why: 'Filter-syntax query routes WorkIQ to summary mode; "OneNote internal properties not exposed as searchable fields" refusal.' },
43
- // Note: bare "wdsectionfileid=" (no space) appears legitimately in URL fragments like
44
- // "...&wdpartid={GUID}&wdsectionfileid={GUID}" those are RESPONSE shapes, not query syntax.
45
- // Only the space-equals form (`wdsectionfileid = <id>`) is a forbidden filter expression.
43
+ // OneNote (v4.7.x doctrine)
44
+ { phrase: 'list my onenote notebooks', doctrine: 'workiq-onenote-query-shape', why: 'WorkIQ has no notebook-inventory endpoint; punts to Graph Explorer.' },
45
+ { phrase: 'what is the onenote notebook id', doctrine: 'workiq-onenote-query-shape', why: 'WorkIQ has no notebook-ID lookup; punts to Graph Explorer.' },
46
+ { phrase: 'list sections in onenote notebook', doctrine: 'workiq-onenote-query-shape', why: 'WorkIQ has no bulk-section enumeration surface.' },
47
+ { phrase: 'search microsoft 365 onenote for sections matching', doctrine: 'workiq-onenote-query-shape', why: 'Structured-field enumeration query; routes WorkIQ to summary mode (v3.7.9 finding).' },
48
+ { phrase: 'wdsectionfileid =', doctrine: 'workiq-onenote-query-shape', why: 'Filter-syntax query routes WorkIQ to summary mode; "OneNote internal properties not exposed as searchable fields" refusal.' },
49
+ // Note: bare "wdsectionfileid=" (no space) appears legitimately in URL fragments — see Doc.aspx URLs in response shapes.
50
+
51
+ // Customer-hint discovery sweep (v4.8.0 doctrines) — bulk-enumerate / ID-lookup / structured-search
52
+ // phrasings that empirically punt to Graph or summary mode. Each lives in its source-specific doctrine.
53
+ { phrase: 'list all my outlook mail folders', doctrine: 'email-bootstrap-discovery', why: 'Bulk enumerate punts to m365_list_mail_folders / Graph; returns Graph-Explorer guidance.' },
54
+ { phrase: 'search microsoft 365 for mail folders matching', doctrine: 'email-bootstrap-discovery', why: 'Structured-search verb routes WorkIQ to summary mode.' },
55
+ { phrase: 'what is the folder id for the', doctrine: 'email-bootstrap-discovery', why: 'Folder ID-lookup punts to Graph.' },
56
+ { phrase: 'list all my teams chats', doctrine: 'teams-bootstrap-discovery', why: 'Bulk enumerate punts to m365_list_chats / Graph.' },
57
+ { phrase: 'what is the chat id for the chat with', doctrine: 'teams-bootstrap-discovery', why: 'Chat ID-lookup punts to Graph.' },
58
+ { phrase: 'search microsoft 365 teams for chats matching', doctrine: 'teams-bootstrap-discovery', why: 'Structured-search verb routes to summary mode.' },
59
+ { phrase: 'list all my teams meetings', doctrine: 'meetings-bootstrap-discovery', why: 'Bulk calendar enumerate punts to m365_list_meetings / m365_list_events.' },
60
+ { phrase: 'get the calendar event id for the meeting', doctrine: 'meetings-bootstrap-discovery', why: 'Event ID-lookup punts to Graph.' },
61
+ { phrase: 'search microsoft 365 calendar for events matching', doctrine: 'meetings-bootstrap-discovery', why: 'Structured-search verb routes to summary mode.' },
62
+ { phrase: 'list all sharepoint sites in my tenant', doctrine: 'sharepoint-bootstrap-discovery', why: 'Tenant-wide enumerate is privileged; routes to Graph admin endpoints.' },
63
+ { phrase: 'what is the site id for', doctrine: 'sharepoint-bootstrap-discovery', why: 'Site ID-lookup punts to Graph.' },
64
+ { phrase: 'search sharepoint for sites matching', doctrine: 'sharepoint-bootstrap-discovery', why: 'Structured-search verb routes to summary mode.' },
46
65
  ];
47
66
 
48
67
  function walkMarkdown(dir, out = []) {
@@ -81,15 +100,23 @@ test('forbidden WorkIQ phrasings do not appear in skill/prompt/agent files', ()
81
100
  );
82
101
  });
83
102
 
84
- test('the doctrine file itself exists and lists the forbidden phrasings', () => {
85
- const doctrinePath = path.join(repoRoot, 'plugin', 'instructions', 'workiq-onenote-query-shape.instructions.md');
86
- assert.ok(fs.existsSync(doctrinePath), 'workiq-onenote-query-shape.instructions.md must exist');
87
- const text = fs.readFileSync(doctrinePath, 'utf8').toLowerCase();
88
- for (const { phrase } of forbiddenPhrasings) {
89
- assert.ok(
90
- text.includes(phrase),
91
- `Doctrine file MUST quote forbidden phrasing "${phrase}" to forbid it.`
92
- );
103
+ test('each forbidden phrasing is quoted in its home doctrine file (so the doctrine actually forbids what it claims to)', () => {
104
+ const doctrineDir = path.join(repoRoot, 'plugin', 'instructions');
105
+ // Group phrasings by their home doctrine and assert each doctrine file contains all of its phrasings.
106
+ const grouped = forbiddenPhrasings.reduce((acc, { phrase, doctrine }) => {
107
+ (acc[doctrine] ??= []).push(phrase);
108
+ return acc;
109
+ }, {});
110
+ for (const [doctrine, phrases] of Object.entries(grouped)) {
111
+ const file = path.join(doctrineDir, `${doctrine}.instructions.md`);
112
+ assert.ok(fs.existsSync(file), `Doctrine file MUST exist: ${doctrine}.instructions.md`);
113
+ const text = fs.readFileSync(file, 'utf8').toLowerCase();
114
+ for (const phrase of phrases) {
115
+ assert.ok(
116
+ text.includes(phrase),
117
+ `Doctrine file ${doctrine}.instructions.md MUST quote forbidden phrasing "${phrase}" to forbid it.`
118
+ );
119
+ }
93
120
  }
94
121
  });
95
122
 
@@ -109,3 +136,32 @@ test('pull-onenote SKILL.md cites the new doctrine and lists WorkIQ as PRIMARY',
109
136
  'pull-onenote/SKILL.md must label Playwright as opt-in / recovery / fallback'
110
137
  );
111
138
  });
139
+
140
+ test('customer-hint discovery doctrines exist and each per-source pull SKILL.md cites its doctrine (v4.8.0+)', () => {
141
+ const requiredDoctrines = [
142
+ 'plugin/instructions/customer-hint-discovery.instructions.md',
143
+ 'plugin/instructions/email-bootstrap-discovery.instructions.md',
144
+ 'plugin/instructions/teams-bootstrap-discovery.instructions.md',
145
+ 'plugin/instructions/meetings-bootstrap-discovery.instructions.md',
146
+ 'plugin/instructions/sharepoint-bootstrap-discovery.instructions.md',
147
+ ];
148
+ for (const rel of requiredDoctrines) {
149
+ const full = path.join(repoRoot, rel);
150
+ assert.ok(fs.existsSync(full), `Doctrine MUST exist: ${rel}`);
151
+ }
152
+ const skillCitations = [
153
+ { skill: 'plugin/skills/pull-email/SKILL.md', cites: 'email-bootstrap-discovery.instructions.md' },
154
+ { skill: 'plugin/skills/pull-teams/SKILL.md', cites: 'teams-bootstrap-discovery.instructions.md' },
155
+ { skill: 'plugin/skills/pull-meetings/SKILL.md', cites: 'meetings-bootstrap-discovery.instructions.md' },
156
+ { skill: 'plugin/skills/pull-sharepoint/SKILL.md', cites: 'sharepoint-bootstrap-discovery.instructions.md' },
157
+ ];
158
+ for (const { skill, cites } of skillCitations) {
159
+ const text = fs.readFileSync(path.join(repoRoot, skill), 'utf8');
160
+ assert.ok(text.includes(cites), `${skill} must cite ${cites}`);
161
+ assert.ok(text.includes('Bootstrap discovery'), `${skill} must have a ## Bootstrap discovery section`);
162
+ }
163
+ // Orchestration doctrine MUST appear in bootstrap-project SKILL.md
164
+ const bootstrap = fs.readFileSync(path.join(repoRoot, 'plugin/skills/bootstrap-project/SKILL.md'), 'utf8');
165
+ assert.ok(bootstrap.includes('customer-hint-discovery.instructions.md'), 'bootstrap-project SKILL.md must cite customer-hint-discovery.instructions.md');
166
+ assert.ok(/Step 3\.5/.test(bootstrap), 'bootstrap-project SKILL.md must define Step 3.5 (customer-hint discovery sweep)');
167
+ });