kushi-agents 4.8.0 → 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.8.0",
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
  },
@@ -87,6 +87,7 @@ For each source whose sweep runs:
87
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
88
  | 1–10 candidates | yes (all) | `completed-with-coverage-gaps` (because rows are `needs_review`) | `watch` | only if any row low-confidence |
89
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) |
90
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 |
91
92
  | Prerequisite genuinely missing (e.g. CRM shared config empty) | no | `blocked-config` | `user-action` | yes — cite specific missing field |
92
93
 
@@ -11,7 +11,7 @@ Governed by `customer-hint-discovery.instructions.md` — read that file first f
11
11
 
12
12
  | Boundary key | Element shape | Example |
13
13
  |---|---|---|
14
- | `boundaries.meetings.series_join_urls[]` | string — Teams meeting join URL | `"https://teams.microsoft.com/l/meetup-join/19%3a..."` |
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
15
 
16
16
  Optional `organizer_emails[]` is NOT populated by the sweep (user narrowing only).
17
17
 
@@ -20,9 +20,11 @@ Optional `organizer_emails[]` is NOT populated by the sweep (user narrowing only
20
20
  Issued ONCE per bootstrap, per project:
21
21
 
22
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, most recent occurrence date. Do not summarize. Do not truncate. Flat table only."
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
24
  ```
25
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
+
26
28
  Substitution rules:
27
29
 
28
30
  - `<HINT>` = verbatim customer hint.
@@ -44,8 +46,11 @@ This phrasing — **natural-language by subject + organizer + join URL request**
44
46
 
45
47
  ## Parsing the response
46
48
 
47
- 1. Extract `Teams meeting join URL` column keep only rows whose URL matches `^https?://teams\.microsoft\.com/l/meetup-join/`.
48
- 2. **Collapse recurring series:** for rows with `recurrence pattern != single`, the join URL of any occurrence is the canonical series URL (Outlook reuses one URL per series). Deduplicate by URL.
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.
49
54
  3. Deduplicate against existing `boundaries.meetings.series_join_urls[]`.
50
55
  4. Cap at top 10 by `most recent occurrence date` (descending).
51
56
  5. Confidence ranking:
@@ -7,6 +7,16 @@ description: "SharePoint site URL resolution — title/URL-substring WorkIQ phra
7
7
 
8
8
  Governed by `customer-hint-discovery.instructions.md` — read that file first for the orchestration contract.
9
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
+
10
20
  ## What this sweep populates — and what it does NOT
11
21
 
12
22
  | Boundary key | Populated by sweep? | Why |
@@ -43,11 +53,14 @@ This phrasing — **natural-language by site title/URL** — is empirically the
43
53
 
44
54
  ## Parsing the response
45
55
 
46
- 1. Extract `full site URL` column keep rows whose URL matches `^https?://[^/]+\.sharepoint\.com/sites/`.
47
- 2. Normalize: strip query strings and trailing slashes.
48
- 3. Deduplicate against existing `boundaries.sharepoint.site_urls[]`.
49
- 4. Cap at top 10 by `most recent activity date` (descending). If activity date is missing, fall back to alphabetical site title.
50
- 5. Confidence ranking:
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:
51
64
  - `high` — site title contains the hint (case-insensitive substring).
52
65
  - `medium` — site URL slug contains the hint (e.g. `/sites/HCA-Engagement` for hint `HCA`).
53
66
  - `low` — match was on site description only.
@@ -14,17 +14,24 @@ Governed by `customer-hint-discovery.instructions.md` — read that file first f
14
14
  | `boundaries.teams.chat_ids[]` | string — Teams chat ID (Graph thread-id format) | `"19:abc...@thread.v2"` |
15
15
  | `boundaries.teams.channel_ids[]` | string — `<teamId>:<channelId>` composite | `"abc-team-guid:19:def@thread.tacv2"` |
16
16
 
17
- ## Approved WorkIQ queries (the ONLY shapes that return this data)
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)
18
25
 
19
26
  Two narrow queries — issued in sequence (chats first, then channels). Each issued ONCE per bootstrap, per project.
20
27
 
21
- ### Query 1 — Chats
28
+ ### Query 1 — Chats (REQUIRED, primary path)
22
29
 
23
30
  ```
24
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."
25
32
  ```
26
33
 
27
- ### Query 2 — Channels
34
+ ### Query 2 — Channels (BEST-EFFORT only; v4.8.1 empirical: returns nothing usable)
28
35
 
29
36
  ```
30
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."
@@ -54,23 +61,29 @@ For each query, WorkIQ returns a markdown table.
54
61
 
55
62
  ### Chat parsing (Query 1)
56
63
 
57
- 1. Extract `chat ID` column keep only rows with non-empty IDs matching the `^19:.+@thread\.(v2|skype|tacv2)$` pattern.
58
- 2. Deduplicate against existing `boundaries.teams.chat_ids[]`.
59
- 3. Cap at top 10 by `most recent message date` (descending).
60
- 4. Confidence ranking:
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:
61
74
  - `high` — `chat topic` explicitly contains the hint (case-insensitive substring).
62
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`.
63
76
  - `low` — match was on a member's display name only.
64
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
+
65
80
  ### Channel parsing (Query 2)
66
81
 
67
- 1. Build composite `<teamId>:<channelId>` for each row.
68
- 2. Deduplicate against existing `boundaries.teams.channel_ids[]`.
69
- 3. Cap at top 10 by channels rarely exceed 10; if they do, prefer channels whose `team name` contains the hint over channels whose `channel name` contains the hint.
70
- 4. Confidence ranking:
71
- - `high`team name contains the hint.
72
- - `medium` — channel name contains the hint (e.g. `General` channel inside a hint-matched team).
73
- - `low` — neither, but inference was made via member list (rare).
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.
74
87
 
75
88
  ## Sidecar file shape
76
89
 
@@ -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();