kushi-agents 5.4.6 → 5.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/package.json +9 -4
  2. package/plugin/agents/kushi.agent.md +2 -0
  3. package/plugin/instructions/csc-rendering.instructions.md +92 -0
  4. package/plugin/instructions/discovery-prompts.instructions.md +70 -0
  5. package/plugin/instructions/llm-vs-runner.instructions.md +90 -0
  6. package/plugin/runners/bootstrap.mjs +145 -0
  7. package/plugin/runners/lib/config.mjs +108 -0
  8. package/plugin/runners/lib/dedup.mjs +42 -0
  9. package/plugin/runners/lib/deferred.mjs +88 -0
  10. package/plugin/runners/lib/evidence.mjs +76 -0
  11. package/plugin/runners/lib/http.mjs +105 -0
  12. package/plugin/runners/lib/identity.mjs +51 -0
  13. package/plugin/runners/lib/layout.mjs +116 -0
  14. package/plugin/runners/lib/ledger.mjs +89 -0
  15. package/plugin/runners/lib/runlog.mjs +61 -0
  16. package/plugin/runners/lib/weeks.mjs +79 -0
  17. package/plugin/runners/lib/workiq.mjs +104 -0
  18. package/plugin/runners/migrate-to-v550.mjs +192 -0
  19. package/plugin/runners/pull-ado.mjs +282 -0
  20. package/plugin/runners/pull-crm.mjs +256 -0
  21. package/plugin/runners/pull-email.mjs +190 -0
  22. package/plugin/runners/pull-meetings.mjs +209 -0
  23. package/plugin/runners/pull-onenote.mjs +224 -0
  24. package/plugin/runners/pull-sharepoint.mjs +198 -0
  25. package/plugin/runners/pull-teams.mjs +172 -0
  26. package/plugin/runners/refresh.mjs +244 -0
  27. package/plugin/runners/test/fixtures/ado-abn-amro.json +95 -0
  28. package/plugin/runners/test/fixtures/crm-abn-amro.json +21 -0
  29. package/plugin/runners/test/fixtures/email-abn-amro.json +13 -0
  30. package/plugin/runners/test/fixtures/meetings-abn-amro.json +10 -0
  31. package/plugin/runners/test/fixtures/meetings-body-unavailable.json +10 -0
  32. package/plugin/runners/test/fixtures/onenote-abn-amro.json +30 -0
  33. package/plugin/runners/test/fixtures/onenote-partial.json +21 -0
  34. package/plugin/runners/test/fixtures/refresh-dir/ado.json +17 -0
  35. package/plugin/runners/test/fixtures/refresh-dir/email.json +16 -0
  36. package/plugin/runners/test/fixtures/refresh-dir/teams.json +12 -0
  37. package/plugin/runners/test/fixtures/sharepoint-abn-amro.json +12 -0
  38. package/plugin/runners/test/fixtures/teams-abn-amro.json +11 -0
  39. package/plugin/runners/test/integration/bootstrap.integration.test.mjs +118 -0
  40. package/plugin/runners/test/integration/migrate-to-v550.integration.test.mjs +138 -0
  41. package/plugin/runners/test/integration/pull-ado.integration.test.mjs +140 -0
  42. package/plugin/runners/test/integration/pull-crm.integration.test.mjs +119 -0
  43. package/plugin/runners/test/integration/pull-email.integration.test.mjs +97 -0
  44. package/plugin/runners/test/integration/pull-meetings.integration.test.mjs +92 -0
  45. package/plugin/runners/test/integration/pull-onenote.integration.test.mjs +86 -0
  46. package/plugin/runners/test/integration/pull-sharepoint.integration.test.mjs +93 -0
  47. package/plugin/runners/test/integration/pull-teams.integration.test.mjs +91 -0
  48. package/plugin/runners/test/integration/refresh.integration.test.mjs +155 -0
  49. package/plugin/runners/test/unit/config.test.mjs +110 -0
  50. package/plugin/runners/test/unit/dedup.test.mjs +48 -0
  51. package/plugin/runners/test/unit/deferred.test.mjs +82 -0
  52. package/plugin/runners/test/unit/evidence.test.mjs +85 -0
  53. package/plugin/runners/test/unit/http.test.mjs +103 -0
  54. package/plugin/runners/test/unit/identity.test.mjs +63 -0
  55. package/plugin/runners/test/unit/layout.test.mjs +98 -0
  56. package/plugin/runners/test/unit/ledger.test.mjs +91 -0
  57. package/plugin/runners/test/unit/runlog.test.mjs +57 -0
  58. package/plugin/runners/test/unit/weeks.test.mjs +69 -0
  59. package/plugin/runners/test/unit/workiq.test.mjs +85 -0
  60. package/plugin/skills/bootstrap-project/SKILL.md +24 -209
  61. package/plugin/skills/pull-ado/SKILL.md +19 -326
  62. package/plugin/skills/pull-crm/SKILL.md +20 -222
  63. package/plugin/skills/pull-email/SKILL.md +18 -206
  64. package/plugin/skills/pull-meetings/SKILL.md +17 -195
  65. package/plugin/skills/pull-onenote/SKILL.md +35 -212
  66. package/plugin/skills/pull-sharepoint/SKILL.md +16 -192
  67. package/plugin/skills/pull-teams/SKILL.md +17 -185
  68. package/plugin/skills/refresh-project/SKILL.md +32 -209
  69. package/plugin/skills/self-check/run.ps1 +118 -17
  70. package/src/forbidden-workiq-phrasings.test.mjs +156 -167
  71. package/src/parallel-refresh.test.mjs +52 -50
  72. package/src/per-user-files.test.mjs +129 -137
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kushi-agents",
3
- "version": "5.4.6",
3
+ "version": "5.5.1",
4
4
  "description": "Install Kushi — multi-source project evidence agent with Comprehensive Structured Capture (CSC) into weekly-only files across Email, Teams, OneNote, Loop, SharePoint, Meetings, CRM, ADO. Meetings retain a sibling verbatim/ audit folder. WorkIQ-only for M365 sources (Graph / m365_* FORBIDDEN as fallbacks; user-paste is first-class). Host-agnostic.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,9 +16,11 @@
16
16
  "node": ">=18.0.0"
17
17
  },
18
18
  "dependencies": {
19
+ "@azure/identity": "^4.5.0",
19
20
  "@mozilla/readability": "^0.6.0",
20
21
  "jsdom": "^29.1.1",
21
- "jsonc-parser": "^3.3.1"
22
+ "jsonc-parser": "^3.3.1",
23
+ "yaml": "^2.6.0"
22
24
  },
23
25
  "keywords": [
24
26
  "vscode",
@@ -41,7 +43,9 @@
41
43
  },
42
44
  "license": "MIT",
43
45
  "scripts": {
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 src/multi-host-install.test.mjs src/eval-aggregator.test.mjs src/eval-runner.test.mjs src/skill-creator.test.mjs src/skill-checker.test.mjs src/hooks-dispatcher.test.mjs src/parallel-refresh.test.mjs src/otel-emit.test.mjs src/teach.test.mjs src/schema-evolve.test.mjs src/global-wiki.test.mjs src/promote.test.mjs src/doctor.test.mjs src/setup-wizard.test.mjs src/cli-no-args.test.mjs src/cli-no-args-tty.test.mjs src/per-user-files.test.mjs src/layout-portable.test.mjs src/profile-coverage.test.mjs",
46
+ "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 src/multi-host-install.test.mjs src/eval-aggregator.test.mjs src/eval-runner.test.mjs src/skill-creator.test.mjs src/skill-checker.test.mjs src/hooks-dispatcher.test.mjs src/parallel-refresh.test.mjs src/otel-emit.test.mjs src/teach.test.mjs src/schema-evolve.test.mjs src/global-wiki.test.mjs src/promote.test.mjs src/doctor.test.mjs src/setup-wizard.test.mjs src/cli-no-args.test.mjs src/cli-no-args-tty.test.mjs src/per-user-files.test.mjs src/layout-portable.test.mjs src/profile-coverage.test.mjs plugin/runners/test/unit/*.test.mjs",
47
+ "test:runners": "node --test plugin/runners/test/unit/*.test.mjs",
48
+ "test:runners:integration": "node --test plugin/runners/test/integration/*.test.mjs",
45
49
  "test:integration:bootstrap": "node src/bootstrap-dryrun.integration.test.mjs",
46
50
  "smoke": "node scripts/smoke.mjs",
47
51
  "eval": "pwsh plugin/skills/eval/run-evals.ps1 -Skill",
@@ -53,4 +57,5 @@
53
57
  "publishConfig": {
54
58
  "access": "public"
55
59
  }
56
- }
60
+ }
61
+
@@ -10,6 +10,8 @@ tools:
10
10
 
11
11
  Kushi is a multi-source evidence + state agentfor consulting / engineering engagements. It captures **snapshots** (current state of entities) and **streams** (timestamped events) from Email, Teams, OneNote, Loop, SharePoint, Meetings, CRM, and ADO, and renders an outcome-based **State** view.
12
12
 
13
+ > **v5.5.0 — runner architecture.** All `bootstrap` / `refresh` / `pull-*` verbs dispatch to deterministic Node runners under `plugin/runners/*.mjs` (HTTP, paging, file IO, layout, ledger, week math, atomic writes). The SKILL.md files are thin pointers. The LLM only does discovery prompts (`discovery-prompts.instructions.md`), CSC rendering (`csc-rendering.instructions.md`), and Q&A — never HTTP or path templating. See `instructions/llm-vs-runner.instructions.md`. Migrate v4.x projects with `node plugin/runners/migrate-to-v550.mjs --project <P> --alias <A> [--dry-run]`.
14
+
13
15
  ## Install profiles
14
16
 
15
17
  Kushi ships in three profiles. The installed profile is recorded in `kushi-install.json` next to this agent file. Verbs that aren't installed for the current profile should be surfaced as: *"This verb requires the `<profile>` profile. Re-install with `npx kushi-agents --clawpilot --profile <profile> --force`."*
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: "csc-rendering"
3
+ version: "5.5.0"
4
+ applyTo: "**/plugin/skills/**/SKILL.md"
5
+ description: "How the LLM renders runner-captured evidence into Comprehensive Structured Capture (CSC) blocks under weekly/ + _Consolidated/."
6
+ ---
7
+
8
+ # CSC rendering (kushi v5.5.0)
9
+
10
+ In v5.5.0 **runners write raw evidence** to `Evidence/_shared/<source>/<id>/*.yml`,
11
+ `Evidence/<alias>/<source>/<entity>/<YYYY-MM-DD>/items/<id>.yml`, and per-entity
12
+ `index.md` previews. **The LLM renders those raw files** into Comprehensive
13
+ Structured Capture (CSC) blocks under `Evidence/<alias>/<source>/weekly/<YYYY-MM-DD>_<source>-csc.md`
14
+ and the cross-source roll-up under `Evidence/<alias>/_Consolidated/<YYYY-MM-DD>_consolidated.md`.
15
+
16
+ ## Canonical CSC block (every source)
17
+
18
+ ```
19
+ ## <Entity anchor> — <one-line subject>
20
+
21
+ - **Source:** <source> · <crm|ado|email|teams|meetings|onenote|sharepoint>://...
22
+ - **When:** <ISO datetime> (week <YYYY-MM-DD>)
23
+ - **Who:** <name> <<email>>
24
+ - **What:** <one-sentence factual summary>
25
+
26
+ ### Dates & Numbers
27
+ - ...
28
+
29
+ ### Decisions
30
+ - ...
31
+
32
+ ### Open questions
33
+ - ...
34
+
35
+ ### Action items
36
+ - [ ] <owner>: <ask> (due <date or "—">; source: <citation>)
37
+
38
+ ### Risks / Blockers / Dependencies
39
+ - ...
40
+
41
+ ### Citations
42
+ - <source>://<id> · <weekly path>:<line>
43
+ ```
44
+
45
+ ## Hard rules
46
+
47
+ 1. **Bullets only.** No prose paragraphs inside CSC blocks. Narrative belongs in the
48
+ meeting Detailed Discussion Summary or the consolidated roll-up.
49
+ 2. **One block per touched entity per week.** Re-runs upsert the same block by
50
+ entity anchor; never duplicate.
51
+ 3. **Every assertion carries a citation.** A bullet without a citation is a defect.
52
+ 4. **Don't invent dates, owners, or numbers.** If the runner's raw file doesn't
53
+ contain the value, the bullet says `(not in evidence)` — never a guess.
54
+ 5. **Internal vs confirmed (CRM).** Fields that only appear in internal Dataverse
55
+ notes render with the trailing tag `(internal Dataverse note)`. Customer-confirmed
56
+ fields appear without the tag and cite a customer-authored note/email/transcript.
57
+ 6. **Verbatim is mandatory for meetings.** Per-meeting CSC blocks MUST include
58
+ the Transcript Walk-Through section (chronological verbatim with timestamps)
59
+ when the runner reports `captured`. If the runner reports `body-unavailable`,
60
+ render a stub block citing the deferred-retry queue.
61
+
62
+ ## Source → required CSC sections
63
+
64
+ | Source | Required sections beyond base |
65
+ |--------|------------------------------|
66
+ | crm | Dates & Numbers (stage transitions); Decisions (won/lost/stage changes) |
67
+ | ado | Dates & Numbers (state changes, sprint changes); Decisions (closed/resolved) |
68
+ | email | Decisions; Action items; (Citations include `internetMessageId`) |
69
+ | teams | Decisions; Action items; (Citations include chat-id + message-id) |
70
+ | meetings | Detailed Discussion Summary; Transcript Walk-Through; Next Steps (distinct from Action items); Risks/Blockers/Dependencies |
71
+ | onenote | Decisions; Open questions; (Citations include `wdsectionfileid` + `wdpartid`) |
72
+ | sharepoint | Dates & Numbers (file edit counts); Citations include `siteId/itemId` |
73
+
74
+ ## What the LLM does NOT do
75
+
76
+ - Compute file paths for weekly/_Consolidated outputs — `lib/layout.mjs` does.
77
+ - Re-fetch evidence — only the runner pulls.
78
+ - Skip Citations sections to save space — they are mandatory.
79
+
80
+ ## Re-run / upsert semantics
81
+
82
+ The LLM rewrites the entire weekly CSC file on every render — never appends. The
83
+ runner's `_ledger.yml` is the source of truth for "is this cell captured", and
84
+ the weekly CSC file is regenerated from the current set of `items/<id>.yml`.
85
+
86
+ ## Consolidation
87
+
88
+ `Evidence/<alias>/_Consolidated/<YYYY-MM-DD>_consolidated.md` is generated from
89
+ all `Evidence/<alias>/<source>/weekly/<YYYY-MM-DD>_<source>-csc.md` files.
90
+ Order: meetings → email → teams → onenote → sharepoint → crm → ado.
91
+
92
+ See `instructions/llm-vs-runner.instructions.md` for the broader boundary.
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: "discovery-prompts"
3
+ version: "5.5.0"
4
+ applyTo: "**/plugin/skills/**/SKILL.md"
5
+ description: "The canonical LLM prompts for discovering integration IDs and boundary entities so the runners have something to pull."
6
+ ---
7
+
8
+ # Discovery prompts (kushi v5.5.0)
9
+
10
+ `bootstrap.mjs` scaffolds blank `integrations.yml` and `boundaries.yml` files.
11
+ **The LLM is responsible for filling them in** via short, scoped discovery
12
+ prompts to the user (or via WorkIQ where the user has granted access).
13
+
14
+ ## Hard rules
15
+
16
+ 1. **One question at a time.** Never bulk-ask "give me crm + ado + folders + chats
17
+ + sections + sites" — fatigue produces wrong answers. Ask per source, in the
18
+ order listed below.
19
+ 2. **Stop after each answer and write it to disk.** Use the runner's
20
+ `update-config` helper or hand-edit the yml; never hold values in chat memory
21
+ across turns.
22
+ 3. **Suggest, don't guess.** Use WorkIQ (`workiq ask -q "..."`) to suggest likely
23
+ `request_id`s, folder names, chat topics, etc. Always present them as
24
+ candidates the user must confirm — never pre-populate as facts.
25
+ 4. **Boundaries are per-user.** When discovering email folders or Teams chats,
26
+ the answers go under `Evidence/<alias>/boundaries.yml`, NOT the shared
27
+ `integrations.yml`.
28
+
29
+ ## Recommended order
30
+
31
+ | Phase | Source | Prompt | Lands in |
32
+ |-------|--------|--------|----------|
33
+ | 1 | crm | "What is the CRM request_id (Dataverse Engagement ID) for this project? You can usually find it on the engagement record URL." | `integrations.yml#crm.request_id` |
34
+ | 2 | ado | "What is the ADO engagement work-item id?" | `integrations.yml#ado.engagement_id` |
35
+ | 3 | sharepoint | "Which SharePoint site(s) host project content? Paste the URLs." | `integrations.yml#sharepoint.sites[]` |
36
+ | 4 | email | "Which mailbox folder(s) hold this project's email? Common names: '<customer name>', '<engagement code>', 'Project Inbox'." | `Evidence/<alias>/boundaries.yml#email.folders[]` |
37
+ | 5 | teams | "Paste chat-ids for the 1:1s and group chats that discuss this project. Use `m365 list_chats` to find them." | `Evidence/<alias>/boundaries.yml#teams.chats[]` |
38
+ | 6 | meetings | "List joinUrls for recurring or key meetings (or leave blank to auto-detect from calendar)." | `Evidence/<alias>/boundaries.yml#meetings.joinUrls[]` |
39
+ | 7 | onenote | "Paste OneNote section URLs. The runner will resolve them to section_file_ids via map-first (`m365-mutable.json#knownSections`)." | `Evidence/<alias>/boundaries.yml#onenote.section_file_ids[]` |
40
+
41
+ ## Discovery via WorkIQ (suggest-mode)
42
+
43
+ When the user can't immediately answer:
44
+
45
+ ```
46
+ workiq ask -q "Suggest mailbox folders likely related to <project name>"
47
+ workiq ask -q "Find Teams chats whose topic or recent messages mention <project name>"
48
+ workiq ask -q "List OneNote sections under <notebook> matching <project>"
49
+ ```
50
+
51
+ Present results as a numbered list; the user picks. Never write to boundaries
52
+ without explicit confirmation.
53
+
54
+ ## What discovery does NOT do
55
+
56
+ - Call the runner. Once values are written to `integrations.yml` /
57
+ `boundaries.yml`, the user (or `refresh-project`) decides when to run.
58
+ - Discover for other users. Discovery only fills the current `<alias>`'s
59
+ `boundaries.yml`. Each user runs their own discovery.
60
+ - Fall back to Graph/m365_* tools as "rescue" — WorkIQ-first doctrine still
61
+ applies for project capture.
62
+
63
+ ## Re-discovery
64
+
65
+ When `refresh-project` reports `partial` or `no-activity` for a source over
66
+ several weeks, re-run discovery for that source — the user may have created
67
+ new folders, chats, or sections that aren't in boundaries.yml yet.
68
+
69
+ See `instructions/llm-vs-runner.instructions.md` and
70
+ `instructions/csc-rendering.instructions.md`.
@@ -0,0 +1,90 @@
1
+ ---
2
+ name: "llm-vs-runner"
3
+ version: "5.5.0"
4
+ applyTo: "**/plugin/skills/**/SKILL.md"
5
+ description: "The v5.5.0 boundary contract between LLM markdown skills and deterministic Node runners under plugin/runners/."
6
+ ---
7
+
8
+ # LLM-vs-runner boundary (kushi v5.5.0)
9
+
10
+ ## Doctrine
11
+
12
+ In v5.5.0 every formerly-LLM-driven pull/refresh/bootstrap pipeline is split into two halves:
13
+
14
+ | Half | Implementation | Responsibilities |
15
+ |------|---------------|------------------|
16
+ | **Runner** | `plugin/runners/*.mjs` + `plugin/runners/lib/*` | HTTP, paging, auth, file IO, layout, ledger, dedup, hash compare, atomic writes, ISO-week math, retry/defer queue, fixture mode. |
17
+ | **LLM (SKILL.md)** | Markdown skill files under `plugin/skills/` | Discovery prompts, judgment on `partial` / `no-activity` / `body-unavailable`, user-facing summarization, CSC rendering, picking `--week` / `--source` / `--entity` for runs. |
18
+
19
+ ## Hard rules
20
+
21
+ 1. **No HTTP from chat.** The LLM MUST NOT call Graph, Dataverse, ADO REST, OneNote, SharePoint REST, or `m365_*` tools as a way of capturing project evidence. Discovery via WorkIQ is allowed; capture is runner-only.
22
+ 2. **No hand-rolled file paths.** The LLM MUST NOT compute Evidence/ paths. Use the runner's reported `files_written[]`.
23
+ 3. **No hand-rolled week math.** Always pass `--week YYYY-MM-DD` (Monday) or omit; never derive paths from dates in chat.
24
+ 4. **No PowerShell rescue scripts.** If a runner reports `failed`, fix the runner or its config — never paper over with a one-off script that writes Evidence/.
25
+ 5. **One-line JSON contract.** Every runner emits exactly one JSON line on stdout as its last line. The orchestrator (`refresh.mjs`) parses this; sub-agents must too.
26
+ 6. **Exit codes are stable.** `0` = ok/partial/no-activity (data is consistent), `1` = retryable, `2` = config error, `3` = auth error.
27
+
28
+ ## Runner CLI contract
29
+
30
+ Every `pull-*.mjs`:
31
+
32
+ ```
33
+ node plugin/runners/<source>.mjs --project <P> --alias <A> --entity <E> [--week YYYY-MM-DD] [--dry-run] [--force] [--fixture <path>]
34
+ ```
35
+
36
+ `refresh.mjs` adds: `--source`, `--mode bootstrap|refresh`, `--max-parallel N`, `--fixture-dir <dir>`.
37
+
38
+ ## Status taxonomy (runner-emitted)
39
+
40
+ - `captured` — items pulled and written.
41
+ - `partial` — some items pulled, some failed; `errors[]` populated.
42
+ - `no-activity` — zero items in the window (legit empty week).
43
+ - `body-unavailable` — Graph returned shells but body fetch failed (meetings/onenote may defer).
44
+ - `deferred` — retry enqueued; runner will retry after `RETRY_MIN_AGE_MIN.<source>` minutes.
45
+ - `failed` — non-retryable failure for this cell.
46
+
47
+ ## What the LLM still owns
48
+
49
+ - Asking the user for `request_id`, `engagement_id`, folder names, chat ids, joinUrls, section URLs, site URLs, when missing.
50
+ - Rendering consolidated weekly CSC views over the runner-written `_shared/` and `<alias>/` files.
51
+ - Diagnosing patterns across multiple runs (e.g. "all OneNote pages came back `body-unavailable` — likely the section was a triage section and pages have been moved").
52
+ - Writing summaries, action items, and human reports under `Evidence/<alias>/refresh-reports/` and `_Consolidated/`.
53
+
54
+ ## Forbidden phrasings in SKILL.md
55
+
56
+ - "First, fetch the messages via `m365_list_chat_messages`…" — runner does this.
57
+ - "Compute the Monday of the ISO week…" — runner does this.
58
+ - "Write the captured items to `Evidence/<alias>/<source>/<entity>/<week>/items.yml`…" — runner does this.
59
+ - "If the body is unavailable, write a placeholder…" — runner emits `body-unavailable` and the orchestrator decides.
60
+
61
+ ## Self-check coverage (Phase 4)
62
+
63
+ Probes D44–D47 will assert:
64
+ - D44: every `pull-*` and `bootstrap-project` / `refresh-project` SKILL.md references its corresponding `.mjs` runner.
65
+ - D45: no SKILL.md contains forbidden phrasings (HTTP verbs, manual path templates, week-math snippets).
66
+ - D46: every runner has integration tests under `plugin/runners/test/integration/`.
67
+ - D47: every runner emits a stdout JSON line on the happy path under fixture mode.
68
+
69
+ ## Legacy probe carve-out (v5.5.1)
70
+
71
+ The following pre-v5.5.0 probes assumed SKILL.md inlined doctrine cites, validation loops, and orchestrator checklists. In v5.5.0+ those concerns live in the runner + its tests, so the probes **skip thin-pointer skills** (the same nine listed in `D44` `$v550Map`):
72
+
73
+ | Probe | What it used to require | Where it lives now |
74
+ |-------|------------------------|--------------------|
75
+ | C12 | `evidence-thoroughness` cite in pull-* SKILL.md | runner: thoroughness retry loop |
76
+ | D2 | `snapshot-vs-stream.instructions.md` cite in pull-* SKILL.md | runner JSDoc + integration tests |
77
+ | D3 | "WorkIQ" in pull-* SKILL.md Tools section | runner's discovery code path |
78
+ | D6 | `side-by-side-config` cite in bootstrap/refresh SKILL.md | runner: config-write helper |
79
+ | D11 | `verbatim-by-default` + v3.7.6 contracts cite | runner: `lib/verbatim.mjs` + tests |
80
+ | D12 | `m365-id-registry` tokens in SKILL.md | runner: `lib/id-registry.mjs` |
81
+ | D17 | `fuzzy-disambiguation` cite in name→ID skills | runner: name→ID resolver |
82
+ | D18 | `per-source-verification-gate` cite | runner: gate check before write |
83
+ | D26 | `issue-recovery` cite | runner: error-classification helper |
84
+ | D30.references-layout | `Load references/...` pointer when `references/` exists | runner loads its own packs |
85
+ | D30.checklist-orchestrators | `- [ ]` items in orchestrator SKILL.md | `refresh.mjs` orchestrates |
86
+ | D30.validation-loop | `## Validation loop` in writer SKILL.md | runner integration tests |
87
+ | D34.retrofit-clean | skill-checker `--retrofit` non-additive gaps == 0 | n/a for thin-pointers |
88
+
89
+ Non-thin-pointer skills (e.g. `pull-loop`, `pull-misc`, `aggregate-project`, `consolidate-evidence`) still get all of these checks.
90
+
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ // plugin/runners/bootstrap.mjs
3
+ // Deterministic project scaffold. Idempotent. No HTTP.
4
+ //
5
+ // Creates (only if missing):
6
+ // <project>/integrations.yml ← project-shared template
7
+ // <project>/project-info.md ← project-shared placeholder
8
+ // <project>/external-links.yml ← project-shared empty
9
+ // <project>/contributors.yml ← project-shared empty
10
+ // <project>/Evidence/ ← shared evidence root
11
+ // <project>/Evidence/_shared/{crm,ado,sharepoint}/ ← project-scoped capture dirs
12
+ // <project>/Evidence/<alias>/boundaries.yml ← per-user template
13
+ // <project>/Evidence/<alias>/external-links.local.yml ← per-user empty
14
+ // <project>/Evidence/<alias>/{_discovery,_deferred-retries,refresh-reports,
15
+ // email,teams,meetings,onenote,sharepoint,
16
+ // crm-notes,ado-notes}/ ← per-user capture dirs
17
+ //
18
+ // Usage:
19
+ // node plugin/runners/bootstrap.mjs --project <P> --alias <A> [--force] [--dry-run]
20
+
21
+ import path from 'node:path';
22
+ import { promises as fs } from 'node:fs';
23
+ import YAML from 'yaml';
24
+ import {
25
+ projectRoot, evidenceRoot, sharedRoot, sharedSourceDir,
26
+ aliasRoot, projectSharedFile, userFile, USER_FILES,
27
+ } from './lib/layout.mjs';
28
+ import { writeAtomic, pathExists } from './lib/evidence.mjs';
29
+
30
+ function parseArgs(argv) {
31
+ const args = { force: false, dryRun: false };
32
+ for (let i = 0; i < argv.length; i++) {
33
+ const a = argv[i];
34
+ if (a === '--project') args.project = argv[++i];
35
+ else if (a === '--alias') args.alias = argv[++i];
36
+ else if (a === '--force') args.force = true;
37
+ else if (a === '--dry-run') args.dryRun = true;
38
+ else if (a === '--help' || a === '-h') args.help = true;
39
+ }
40
+ return args;
41
+ }
42
+
43
+ function help() {
44
+ return `Usage: node bootstrap.mjs --project <P> --alias <A> [--force] [--dry-run]`;
45
+ }
46
+
47
+ function emit(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); }
48
+
49
+ const INTEGRATIONS_TEMPLATE = {
50
+ crm: { instance: 'https://iscrm.crm.dynamics.com', table: 'incidents', request_id: null, record_id: null },
51
+ ado: { organization: 'IndustrySolutions', project: 'IS Engagements', apiVersion: '7.1', engagement_id: null },
52
+ sharepoint: { allowed_tenants: [] },
53
+ };
54
+
55
+ const BOUNDARIES_TEMPLATE = {
56
+ email: { mailbox: null, folders: [] },
57
+ teams: { chats: [] },
58
+ meetings: { joinUrls: [] },
59
+ onenote: { section_file_ids: [] },
60
+ sharepoint: { sites: [] },
61
+ };
62
+
63
+ const PROJECT_INFO_TEMPLATE = `# Project info
64
+
65
+ - name:
66
+ - customer:
67
+ - engagement_id:
68
+ - ado_root_work_item:
69
+ - crm_request_id:
70
+ - started:
71
+ - contributors:
72
+ `;
73
+
74
+ const SHARED_DIRS = ['_shared/crm', '_shared/ado', '_shared/sharepoint'];
75
+ const USER_DIRS = [
76
+ USER_FILES.discovery,
77
+ USER_FILES.deferredRetries,
78
+ USER_FILES.refreshReports,
79
+ 'email', 'teams', 'meetings', 'onenote', 'sharepoint',
80
+ 'crm-notes', 'ado-notes',
81
+ ];
82
+
83
+ async function ensureDir(dir, dryRun, log) {
84
+ if (await pathExists(dir)) { log.existed.push(dir); return; }
85
+ log.created.push(dir);
86
+ if (!dryRun) await fs.mkdir(dir, { recursive: true });
87
+ }
88
+
89
+ async function ensureFile(file, content, { dryRun, force, log }) {
90
+ const exists = await pathExists(file);
91
+ if (exists && !force) { log.existed.push(file); return; }
92
+ log.created.push(file);
93
+ if (!dryRun) await writeAtomic(file, content, { skipIfUnchanged: !force });
94
+ }
95
+
96
+ async function main() {
97
+ const args = parseArgs(process.argv.slice(2));
98
+ if (args.help) { console.log(help()); return 0; }
99
+ if (!args.project || !args.alias) {
100
+ console.error(help());
101
+ emit({ status: 'failed', errors: [{ signature: 'bad-args', message: 'required: --project --alias' }] });
102
+ return 2;
103
+ }
104
+
105
+ const root = projectRoot(args.project);
106
+ const log = { created: [], existed: [] };
107
+
108
+ // Project root
109
+ await ensureDir(root, args.dryRun, log);
110
+
111
+ // Project-shared files
112
+ await ensureFile(projectSharedFile(args.project, 'integrations'), YAML.stringify(INTEGRATIONS_TEMPLATE), { dryRun: args.dryRun, force: args.force, log });
113
+ await ensureFile(projectSharedFile(args.project, 'projectInfo'), PROJECT_INFO_TEMPLATE, { dryRun: args.dryRun, force: args.force, log });
114
+ await ensureFile(projectSharedFile(args.project, 'externalLinks'), YAML.stringify({ links: [] }), { dryRun: args.dryRun, force: args.force, log });
115
+ await ensureFile(projectSharedFile(args.project, 'contributors'), YAML.stringify({ contributors: [args.alias] }), { dryRun: args.dryRun, force: args.force, log });
116
+
117
+ // Evidence + _shared
118
+ await ensureDir(evidenceRoot(args.project), args.dryRun, log);
119
+ await ensureDir(sharedRoot(args.project), args.dryRun, log);
120
+ for (const sub of SHARED_DIRS) await ensureDir(path.join(evidenceRoot(args.project), sub), args.dryRun, log);
121
+
122
+ // Alias root + per-user dirs
123
+ await ensureDir(aliasRoot(args.project, args.alias), args.dryRun, log);
124
+ for (const sub of USER_DIRS) await ensureDir(path.join(aliasRoot(args.project, args.alias), sub), args.dryRun, log);
125
+
126
+ // Per-user files
127
+ await ensureFile(userFile(args.project, args.alias, 'boundaries'), YAML.stringify(BOUNDARIES_TEMPLATE), { dryRun: args.dryRun, force: args.force, log });
128
+ await ensureFile(path.join(aliasRoot(args.project, args.alias), 'external-links.local.yml'), YAML.stringify({ links: [] }), { dryRun: args.dryRun, force: args.force, log });
129
+ await ensureFile(path.join(aliasRoot(args.project, args.alias), '_ledger.yml'), YAML.stringify({ entries: {} }), { dryRun: args.dryRun, force: args.force, log });
130
+
131
+ emit({
132
+ status: 'ok',
133
+ project: root,
134
+ alias: args.alias,
135
+ created: log.created.map(p => path.relative(root, p) || '.'),
136
+ existed: log.existed.map(p => path.relative(root, p) || '.'),
137
+ dry_run: args.dryRun,
138
+ });
139
+ return 0;
140
+ }
141
+
142
+ main().then(code => { process.exitCode = code; }).catch(e => {
143
+ emit({ status: 'failed', errors: [{ message: e.message }] });
144
+ process.exit(1);
145
+ });
@@ -0,0 +1,108 @@
1
+ // plugin/runners/lib/config.mjs
2
+ // Load + merge project-shared + per-user config files.
3
+ // integrations.yml (project) ∪ boundaries.yml (per-user) — per-user wins on conflicts.
4
+ // external-links.yml (project) ∪ external-links.local.yml (per-user).
5
+
6
+ import { promises as fs } from 'node:fs';
7
+ import path from 'node:path';
8
+ import YAML from 'yaml';
9
+ import {
10
+ projectSharedFile, userFile, aliasRoot, projectRoot,
11
+ } from './layout.mjs';
12
+
13
+ async function readYamlIfExists(p) {
14
+ try {
15
+ const s = await fs.readFile(p, 'utf8');
16
+ return YAML.parse(s) ?? {};
17
+ } catch (e) {
18
+ if (e.code === 'ENOENT') return null;
19
+ throw new Error(`config: failed to parse YAML ${p}: ${e.message}`);
20
+ }
21
+ }
22
+
23
+ /** Deep-merge: per-user wins, arrays from per-user replace (do not concat) by default. */
24
+ export function mergeConfigs(shared, user, { arrayMode = 'replace' } = {}) {
25
+ if (shared == null) return clone(user);
26
+ if (user == null) return clone(shared);
27
+ if (Array.isArray(shared) || Array.isArray(user)) {
28
+ return arrayMode === 'concat'
29
+ ? clone([...(Array.isArray(shared) ? shared : []), ...(Array.isArray(user) ? user : [])])
30
+ : clone(Array.isArray(user) ? user : shared);
31
+ }
32
+ if (typeof shared !== 'object' || typeof user !== 'object') return clone(user);
33
+ const out = { ...shared };
34
+ for (const k of Object.keys(user)) {
35
+ out[k] = mergeConfigs(shared[k], user[k], { arrayMode });
36
+ }
37
+ return clone(out);
38
+ }
39
+
40
+ function clone(v) { return v == null ? v : JSON.parse(JSON.stringify(v)); }
41
+
42
+ /**
43
+ * Load project-shared integrations.yml. Returns {} if absent.
44
+ */
45
+ export async function loadProjectIntegrations(project) {
46
+ return (await readYamlIfExists(projectSharedFile(project, 'integrations'))) ?? {};
47
+ }
48
+
49
+ /**
50
+ * Load per-user boundaries.yml. Returns {} if absent.
51
+ */
52
+ export async function loadUserBoundaries(project, alias) {
53
+ return (await readYamlIfExists(userFile(project, alias, 'boundaries'))) ?? {};
54
+ }
55
+
56
+ /**
57
+ * Load merged config for (project, alias):
58
+ * { integrations, boundaries, merged, externalLinks, conflicts }
59
+ * - merged: integrations ∪ boundaries (per-user wins per key)
60
+ * - conflicts: list of keys where both sides set a primitive value differently
61
+ */
62
+ export async function loadConfig(project, alias) {
63
+ const integrations = await loadProjectIntegrations(project);
64
+ const boundaries = await loadUserBoundaries(project, alias);
65
+ const externalLinksProject = (await readYamlIfExists(projectSharedFile(project, 'externalLinks'))) ?? {};
66
+ const externalLinksUser = (await readYamlIfExists(path.join(aliasRoot(project, alias), 'external-links.local.yml'))) ?? {};
67
+
68
+ const conflicts = collectConflicts(integrations, boundaries, []);
69
+ const merged = mergeConfigs(integrations, boundaries);
70
+ const externalLinks = mergeConfigs(externalLinksProject, externalLinksUser);
71
+
72
+ return { integrations, boundaries, merged, externalLinks, conflicts };
73
+ }
74
+
75
+ function collectConflicts(a, b, prefix) {
76
+ const out = [];
77
+ if (a == null || b == null) return out;
78
+ if (Array.isArray(a) || Array.isArray(b)) return out;
79
+ if (typeof a !== 'object' || typeof b !== 'object') {
80
+ if (a !== b) out.push({ path: prefix.join('.'), shared: a, user: b });
81
+ return out;
82
+ }
83
+ for (const k of Object.keys(b)) {
84
+ if (k in a) out.push(...collectConflicts(a[k], b[k], [...prefix, k]));
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /**
90
+ * Validate that a project root exists and looks like a kushi customer_docs project
91
+ * (has either integrations.yml at root or an Evidence/ folder).
92
+ */
93
+ export async function assertProject(project) {
94
+ const root = projectRoot(project);
95
+ try { await fs.stat(root); } catch { throw new Error(`config: project not found: ${root}`); }
96
+ const integrations = projectSharedFile(project, 'integrations');
97
+ const evidence = path.join(root, 'Evidence');
98
+ const hasI = await pathExists(integrations);
99
+ const hasE = await pathExists(evidence);
100
+ if (!hasI && !hasE) {
101
+ throw new Error(`config: ${root} is not a kushi project (no integrations.yml or Evidence/ found)`);
102
+ }
103
+ return root;
104
+ }
105
+
106
+ async function pathExists(p) {
107
+ try { await fs.access(p); return true; } catch { return false; }
108
+ }
@@ -0,0 +1,42 @@
1
+ // plugin/runners/lib/dedup.mjs
2
+ // Entity-key hashing + stable item-id derivation for dedup.
3
+
4
+ import crypto from 'node:crypto';
5
+
6
+ /** Stable canonical entity key. */
7
+ export function entityKey(source, entity, week = null) {
8
+ const e = String(entity).trim();
9
+ const base = `${source}::${e}`;
10
+ return week ? `${base}::${week}` : base;
11
+ }
12
+
13
+ /** Short stable hash (12 hex chars) of any string. */
14
+ export function shortHash(s) {
15
+ return crypto.createHash('sha256').update(String(s)).digest('hex').slice(0, 12);
16
+ }
17
+
18
+ /**
19
+ * Compute a stable id for an item using the most-stable available fields,
20
+ * falling back to a content hash. Used to dedup captures across reruns.
21
+ */
22
+ export function itemId(item) {
23
+ for (const f of ['id', 'graphId', 'itemId', 'messageId', 'eventId', 'recordId', 'pageId']) {
24
+ if (item && typeof item[f] === 'string' && item[f]) return item[f];
25
+ }
26
+ return shortHash(JSON.stringify(item ?? null));
27
+ }
28
+
29
+ /**
30
+ * Dedup an array of items by itemId, keeping first occurrence.
31
+ */
32
+ export function dedupItems(items) {
33
+ const seen = new Set();
34
+ const out = [];
35
+ for (const it of items) {
36
+ const id = itemId(it);
37
+ if (seen.has(id)) continue;
38
+ seen.add(id);
39
+ out.push(it);
40
+ }
41
+ return out;
42
+ }