kushi-agents 4.4.4 → 4.7.4
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/README.md +3 -0
- package/package.json +4 -4
- package/plugin/agents/kushi.agent.md +29 -15
- package/plugin/config/studios.json +37 -0
- package/plugin/config/studios.schema.json +45 -0
- package/plugin/instructions/auth-and-retry.instructions.md +268 -1
- package/plugin/instructions/engagement-root-resolution.instructions.md +5 -1
- package/plugin/instructions/evidence-thoroughness.instructions.md +103 -1
- package/plugin/instructions/fuzzy-disambiguation.instructions.md +97 -0
- package/plugin/instructions/identity-resolution.instructions.md +76 -0
- package/plugin/instructions/issue-recovery.instructions.md +58 -0
- package/plugin/instructions/kushi-config-root.instructions.md +66 -0
- package/plugin/instructions/loop-bootstrap-discovery.instructions.md +105 -0
- package/plugin/instructions/m365-id-registry.instructions.md +1 -1
- package/plugin/instructions/onedrive-pin-policy.instructions.md +132 -0
- package/plugin/instructions/per-source-verification-gate.instructions.md +193 -0
- package/plugin/instructions/sharepoint-to-onedrive-sync.instructions.md +116 -0
- package/plugin/instructions/status-color-rule.instructions.md +62 -0
- package/plugin/instructions/studio-registry.instructions.md +48 -0
- package/plugin/instructions/update-ledger.instructions.md +1 -1
- package/plugin/instructions/verbatim-by-default.instructions.md +1 -1
- package/plugin/instructions/vertex-emit.instructions.md +120 -0
- package/plugin/instructions/workiq-input-sanitization.instructions.md +43 -0
- package/plugin/instructions/workiq-onenote-query-shape.instructions.md +79 -0
- package/plugin/instructions/workiq-only.instructions.md +13 -7
- package/plugin/learnings/loop.md +11 -0
- package/plugin/learnings/onenote.md +27 -1
- package/plugin/lib/Get-KushiConfig.ps1 +22 -9
- package/plugin/lib/detect-vertex-repo.mjs +96 -0
- package/plugin/lib/render-vertex.mjs +249 -0
- package/plugin/lib/sanitize-workiq-input.mjs +72 -0
- package/plugin/lib/studio-registry.mjs +39 -0
- package/plugin/lib/vertex-validate.mjs +121 -0
- package/plugin/plugin.json +13 -6
- package/plugin/prompts/bootstrap.prompt.md +9 -7
- package/plugin/prompts/emit-vertex.prompt.md +33 -0
- package/plugin/prompts/setup.prompt.md +1 -1
- package/plugin/prompts/vertex-link.prompt.md +27 -0
- package/plugin/skills/aggregate-project/SKILL.md +24 -2
- package/plugin/skills/apply-ado-update/SKILL.md +9 -4
- package/plugin/skills/ask-project/SKILL.md +4 -0
- package/plugin/skills/bootstrap-project/SKILL.md +67 -37
- package/plugin/skills/consolidate-evidence/SKILL.md +5 -1
- package/plugin/skills/emit-vertex/README.md +37 -0
- package/plugin/skills/emit-vertex/SKILL.md +173 -0
- package/plugin/skills/intro/SKILL.md +2 -0
- package/plugin/skills/propose-ado-update/SKILL.md +8 -3
- package/plugin/skills/pull-ado/SKILL.md +11 -1
- package/plugin/skills/pull-crm/SKILL.md +12 -2
- package/plugin/skills/pull-email/SKILL.md +11 -1
- package/plugin/skills/pull-loop/README.md +64 -0
- package/plugin/skills/pull-loop/SKILL.md +180 -0
- package/plugin/skills/pull-loop/runner.mjs +261 -0
- package/plugin/skills/pull-loop/write-snapshot.mjs +181 -0
- package/plugin/skills/pull-meetings/SKILL.md +11 -1
- package/plugin/skills/pull-misc/README.md +4 -4
- package/plugin/skills/pull-misc/SKILL.md +18 -12
- package/plugin/skills/pull-onenote/SKILL.md +71 -19
- package/plugin/skills/pull-sharepoint/SKILL.md +11 -2
- package/plugin/skills/pull-teams/SKILL.md +11 -2
- package/plugin/skills/refresh-project/SKILL.md +38 -7
- package/plugin/skills/self-check/SKILL.md +14 -1
- package/plugin/skills/self-check/run.ps1 +442 -20
- package/plugin/skills/setup/SKILL.md +289 -86
- package/plugin/skills/vertex-link/SKILL.md +143 -0
- package/plugin/templates/init/m365-auth.template.json +10 -4
- package/plugin/templates/init/project-evidence.template.yml +4 -1
- package/plugin/templates/init/project-integrations.template.yml +5 -0
- package/plugin/templates/snapshot/ado-item.template.md +1 -1
- package/plugin/templates/snapshot/crm-record.template.md +1 -1
- package/plugin/templates/snapshot/meetings-series-index.template.md +1 -1
- package/plugin/templates/snapshot/onenote-page.template.md +1 -1
- package/plugin/templates/snapshot/sharepoint-file.template.md +1 -1
- package/plugin/templates/snapshot/sharepoint-tree.template.md +1 -1
- package/plugin/templates/snapshot/teams-roster.template.md +1 -1
- package/plugin/templates/weekly/ado-stream.template.md +1 -1
- package/plugin/templates/weekly/crm-stream.template.md +1 -1
- package/plugin/templates/weekly/email-stream.template.md +1 -1
- package/plugin/templates/weekly/meetings-stream.template.md +1 -1
- package/plugin/templates/weekly/onenote-stream.template.md +1 -1
- package/plugin/templates/weekly/sharepoint-stream.template.md +1 -1
- package/plugin/templates/weekly/teams-stream.template.md +1 -1
- package/src/check-workiq.mjs +48 -15
- package/src/config-loader.mjs +71 -13
- package/src/config-root-resolve.test.mjs +137 -0
- package/src/detect-vertex-repo.test.mjs +128 -0
- package/src/emit-vertex.e2e.test.mjs +308 -0
- package/src/forbidden-workiq-phrasings.test.mjs +111 -0
- package/src/sanitize-workiq-input.test.mjs +45 -0
- package/src/vertex-validate.test.mjs +142 -0
- package/plugin/instructions/az-auth-conditional.instructions.md +0 -39
- package/plugin/instructions/azure-auth-patterns.instructions.md +0 -233
- package/plugin/instructions/thoroughness-detector.instructions.md +0 -105
- package/plugin/instructions/workiq-first.instructions.md +0 -31
|
@@ -19,7 +19,7 @@ Pulls **email** evidence in two shapes per `snapshot-vs-stream.instructions.md`:
|
|
|
19
19
|
- **snapshot/** — (none — emails ARE events, no snapshot)
|
|
20
20
|
- **stream/** — messages with full body, sender, recipients, attachments, reply chain — grouped by thread
|
|
21
21
|
|
|
22
|
-
Thoroughness per `evidence-thoroughness.instructions.md`; runtime detector + auto-retry + paste-prompt per `thoroughness
|
|
22
|
+
Thoroughness per `evidence-thoroughness.instructions.md`; runtime detector + auto-retry + paste-prompt per `evidence-thoroughness.instructions.md`. Citations per `citation-ledger.instructions.md`. Side-by-side mutable hints written immediately on discovery per `side-by-side-config.instructions.md`.
|
|
23
23
|
|
|
24
24
|
## Inputs
|
|
25
25
|
|
|
@@ -190,3 +190,13 @@ The pre-Step-C anti-summary doctrine + the Step-C AI Narrative Summary per threa
|
|
|
190
190
|
- Enumeration step (Step A) failed → write `_index/<date>_message-index.md` with failure marker, log to run-log errors, do NOT proceed to per-message loop.
|
|
191
191
|
- Per-message fetch failed → record marker, continue.
|
|
192
192
|
- All paths failed → write evidence file with `❌ all paths failed` marker, log to run-log errors, continue with rest of run.
|
|
193
|
+
|
|
194
|
+
## References (v4.4.7)
|
|
195
|
+
|
|
196
|
+
- Name → ID resolution follows ..\..\instructions\fuzzy-disambiguation.instructions.md (universal fuzzy contract).
|
|
197
|
+
- After this pull completes, the per-source verification gate runs: ..\..\instructions\per-source-verification-gate.instructions.md (retry once, then write FOLLOW-UPS.md).
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
## Issue Recovery
|
|
201
|
+
|
|
202
|
+
When this skill exposes a reusable defect (auth pattern, doctrine gap, layout mismatch), apply the [Issue Recovery Rule](../../instructions/issue-recovery.instructions.md): fix the smallest correct repo-owned artifact first, prefer durable fixes over per-run workarounds, then re-run the narrowest failed check. Do NOT use memory as a substitute for correcting the workflow surface.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# pull-loop runner
|
|
2
|
+
|
|
3
|
+
Pulls Microsoft Loop evidence (workspaces + pages) using Playwright with the persisted M365 profile. WorkIQ is used for discovery + page-edit stream; Playwright is the PRIMARY body-capture path.
|
|
4
|
+
|
|
5
|
+
See `SKILL.md` for the full doctrine.
|
|
6
|
+
|
|
7
|
+
## One-time setup
|
|
8
|
+
|
|
9
|
+
Loop reuses the OneNote-for-Web Playwright profile. If you've already run the OneNote bootstrap, you're done.
|
|
10
|
+
|
|
11
|
+
```pwsh
|
|
12
|
+
cd <kushi-repo-root>
|
|
13
|
+
node plugin/skills/pull-onenote/runner.mjs --bootstrap
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The bootstrap signs you into OneNote-for-Web and visits SharePoint surfaces — the same profile cookies authenticate Loop-for-Web.
|
|
17
|
+
|
|
18
|
+
## Pre-flight (check reachability)
|
|
19
|
+
|
|
20
|
+
```pwsh
|
|
21
|
+
cd <kushi-repo-root>
|
|
22
|
+
node plugin/skills/pull-loop/runner.mjs --preflight
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Exits `0` on success, `3` on auth-required (re-run the OneNote bootstrap).
|
|
26
|
+
|
|
27
|
+
## Per-workspace run (recommended — use the wrapper)
|
|
28
|
+
|
|
29
|
+
```pwsh
|
|
30
|
+
cd <kushi-repo-root>
|
|
31
|
+
node plugin/skills/pull-loop/write-snapshot.mjs `
|
|
32
|
+
--project "<project>" `
|
|
33
|
+
--workspace-url "https://<tenant>.loop.microsoft.com/loop/p/<workspaceId>/_workspace" `
|
|
34
|
+
--engagement-root "<engagement-root>" `
|
|
35
|
+
--alias <your-alias>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The wrapper enumerates registered pages, scrapes each, writes the canonical snapshot layout, upserts `m365-mutable.json#knownSections.<project>.loop.workspaces[].loop_pages[]`, and emits a run report.
|
|
39
|
+
|
|
40
|
+
## Per-page run
|
|
41
|
+
|
|
42
|
+
```pwsh
|
|
43
|
+
node plugin/skills/pull-loop/runner.mjs `
|
|
44
|
+
--project "<project>" `
|
|
45
|
+
--page-url "https://<tenant>.loop.microsoft.com/loop/p/<workspaceId>/<pageId>"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Output structure
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
<engagement-root>/<project>/Evidence/<alias>/loop/
|
|
52
|
+
├── snapshot/
|
|
53
|
+
│ └── <workspace-slug>/
|
|
54
|
+
│ ├── _workspace.md
|
|
55
|
+
│ └── <page-slug>.md
|
|
56
|
+
└── refresh-reports/
|
|
57
|
+
└── <timestamp>_loop.md
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Notes
|
|
61
|
+
|
|
62
|
+
- **Profile location.** v4.6.0+ writes to `~/.kushi/playwright-profile/m365/`. Older installs at `~/.kushi/playwright-profile/onenote/` are still read as a fallback.
|
|
63
|
+
- **Synthesized URLs are forbidden.** Only canonical Loop URLs (observed in WorkIQ responses or registered during setup) may be passed to the runner. See `plugin/learnings/loop.md`.
|
|
64
|
+
- **Standalone Loop components** embedded in Teams/OneNote/Email are captured by their host source's pull skill, NOT by `pull-loop`.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "pull-loop"
|
|
3
|
+
version: "1.0.0"
|
|
4
|
+
description: "Pull Microsoft Loop evidence (snapshot: full page bodies; stream: page-edit events). Workspaces + pages are registered during setup per loop-bootstrap-discovery.instructions.md. Browser-scrape (Playwright with persisted M365 profile) is the PRIMARY capture path because fluid-framework page bodies are not reliably returned by Graph/Search. WorkIQ remains the fallback when the profile is auth-expired AND is used for discovery + page-edit metadata. Reuses the OneNote-for-Web Playwright profile."
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Skill: pull-loop
|
|
8
|
+
|
|
9
|
+
> **Doctrine contracts** — This skill operates under the standard pull-* doctrine set:
|
|
10
|
+
> - `verbatim-by-default.instructions.md` — full page bodies by default; no preview-grade pulls.
|
|
11
|
+
> - `m365-id-registry.instructions.md` — discover-once / consume-deterministically (workspaceId + pageId schema).
|
|
12
|
+
> - `loop-bootstrap-discovery.instructions.md` — workspace/page registration shape + flow.
|
|
13
|
+
> - `capture-learnings.instructions.md` — every Loop-specific fix logs to `plugin/learnings/loop.md`.
|
|
14
|
+
> - `cleanup-on-resolution.instructions.md` — when a page resolves, all stale `unresolved` / `page-body-unavailable` markers are upgraded in the same turn.
|
|
15
|
+
> - `run-reports.instructions.md` — every refresh writes a per-user report under `Evidence/<alias>/refresh-reports/`.
|
|
16
|
+
> - `evidence-thoroughness.instructions.md` — runtime detector + auto-retry; partial body = failure not "ok".
|
|
17
|
+
> - `evidence-layout-canonical.instructions.md` — all artifacts under `<project>/Evidence/<alias>/loop/{snapshot,stream}/`.
|
|
18
|
+
> - `per-source-verification-gate.instructions.md` — gate §2 loop row + §2a canonical kind.
|
|
19
|
+
> - `issue-recovery.instructions.md` — apply when discovery or capture exposes a doctrine gap.
|
|
20
|
+
|
|
21
|
+
Pulls **loop** evidence in two shapes per `snapshot-vs-stream.instructions.md`:
|
|
22
|
+
|
|
23
|
+
- **snapshot/** — full Loop page bodies — one file per registered page with `lastModified` + verbatim body
|
|
24
|
+
- **stream/** — page-edit events (who edited what, when) since last watermark — one file per ISO week in the window
|
|
25
|
+
|
|
26
|
+
Standalone Loop components embedded in Teams/OneNote/Email are NOT captured here — they are captured by their host source's pull skill to avoid double-counting. See `loop-bootstrap-discovery.instructions.md` §Surface model.
|
|
27
|
+
|
|
28
|
+
## Tools (in order)
|
|
29
|
+
|
|
30
|
+
1. **Playwright (browser-scrape, persisted M365 profile)** — PRIMARY. Drives Loop-for-Web to render each registered page and read the verbatim body. Profile at `~/.kushi/playwright-profile/m365/` (falls back to `~/.kushi/playwright-profile/onenote/` for older installs — both are valid M365-wide profiles). Implementation: `plugin/skills/pull-loop/runner.mjs`.
|
|
31
|
+
2. **WorkIQ** — FALLBACK for body capture when Playwright profile is auth-expired (`auth-required`). PRIMARY for the **stream** shape (page-edit metadata) and for discovery during setup. Always cite `m365-id-registry` doctrine when invoking.
|
|
32
|
+
3. **Host (m365_*)** — not used. Graph Loop API is preview-only and incomplete; do not call directly.
|
|
33
|
+
|
|
34
|
+
## Canonical CLI invocations
|
|
35
|
+
|
|
36
|
+
### Bootstrap (one-time per machine)
|
|
37
|
+
|
|
38
|
+
Loop reuses the OneNote Playwright profile. Run the OneNote bootstrap if you haven't already:
|
|
39
|
+
|
|
40
|
+
```pwsh
|
|
41
|
+
cd <kushi-repo-root>
|
|
42
|
+
node plugin/skills/pull-onenote/runner.mjs --bootstrap
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The bootstrap signs you into OneNote-for-Web, then visits SharePoint surfaces. The same profile authenticates Loop-for-Web — no separate Loop bootstrap is needed.
|
|
46
|
+
|
|
47
|
+
### Pre-flight (check profile + reachability)
|
|
48
|
+
|
|
49
|
+
```pwsh
|
|
50
|
+
cd <kushi-repo-root>
|
|
51
|
+
node plugin/skills/pull-loop/runner.mjs --preflight
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Exits `0` on success, `3` on `auth-required` (re-run the OneNote bootstrap).
|
|
55
|
+
|
|
56
|
+
### Scrape a workspace (production)
|
|
57
|
+
|
|
58
|
+
Use the wrapper, not the bare runner. The wrapper writes the snapshot in the canonical layout, upserts the per-user mutable registry, and emits a run report:
|
|
59
|
+
|
|
60
|
+
```pwsh
|
|
61
|
+
cd <kushi-repo-root>
|
|
62
|
+
node plugin/skills/pull-loop/write-snapshot.mjs `
|
|
63
|
+
--project "<project>" `
|
|
64
|
+
--workspace-url "<workspace-url-from-m365-mutable.json>" `
|
|
65
|
+
--engagement-root "<engagement-root>" `
|
|
66
|
+
--alias <your-alias>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The wrapper:
|
|
70
|
+
|
|
71
|
+
1. Loads `m365-mutable.json#knownSections.<project>.loop` for the workspace.
|
|
72
|
+
2. Enumerates registered `loop_pages[]`.
|
|
73
|
+
3. Invokes `runner.mjs` once per page (or once per workspace with `--titles` filter for partial runs).
|
|
74
|
+
4. Writes snapshot files at canonical paths.
|
|
75
|
+
5. Upserts each `loop_pages[].last_status` + `last_captured_at` + `captured_via`.
|
|
76
|
+
6. Emits `refresh-reports/<timestamp>_loop.md`.
|
|
77
|
+
|
|
78
|
+
## Output structure (per `evidence-layout-canonical.instructions.md`)
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
<engagement-root>/<project>/Evidence/<alias>/loop/
|
|
82
|
+
├── snapshot/
|
|
83
|
+
│ ├── <workspace-slug>/
|
|
84
|
+
│ │ ├── _workspace.md <- workspace metadata (title, owner, page list)
|
|
85
|
+
│ │ ├── <page-slug>.md <- one per page; verbatim body
|
|
86
|
+
│ │ └── ...
|
|
87
|
+
│ └── _index.md <- workspaces enumerated this run
|
|
88
|
+
└── stream/
|
|
89
|
+
└── YYYY-MM-DD_loop-stream.md <- per-ISO-week page-edit events
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Every artifact carries the frontmatter required by `per-source-verification-gate.instructions.md` §2a:
|
|
93
|
+
|
|
94
|
+
```yaml
|
|
95
|
+
---
|
|
96
|
+
source: loop
|
|
97
|
+
project: <project>
|
|
98
|
+
workspace_id: <id>
|
|
99
|
+
page_id: <id>
|
|
100
|
+
captured_at: <ISO-8601>
|
|
101
|
+
captured_via: playwright | workiq
|
|
102
|
+
evidence_source_kind: page-body | page-body-unavailable | tree-only
|
|
103
|
+
---
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`evidence_source_kind` values (canonical → fallbacks):
|
|
107
|
+
|
|
108
|
+
- `page-body` — Playwright rendered the page and the full body was captured. **Canonical.**
|
|
109
|
+
- `page-body-unavailable` — Playwright reached the page but body was empty / fluid container did not render (page is a stub or component-only). Coverage notes MUST explain.
|
|
110
|
+
- `tree-only` — Only workspace metadata + page-list was captured (no page bodies). Used when WorkIQ-only path runs because Playwright auth is expired.
|
|
111
|
+
|
|
112
|
+
## Pre-flight gates (run before EVERY scrape)
|
|
113
|
+
|
|
114
|
+
Per `loop-bootstrap-discovery.instructions.md`:
|
|
115
|
+
|
|
116
|
+
| Pre-flight | Failure action |
|
|
117
|
+
|---|---|
|
|
118
|
+
| **A. Workspaces registered** for the resolved project | Refuse to run. Instruct user to run `@Kushi setup --reconfigure`. |
|
|
119
|
+
| **B. Pages registered** OR `loop_pages_status: to-be-enumerated` | Run page enumeration via WorkIQ first; on enumerate-fail, write FOLLOW-UPS.md per the gate. |
|
|
120
|
+
| **C. Playwright profile exists** at `~/.kushi/playwright-profile/m365/` or `.../onenote/` | Refuse; instruct: `node plugin/skills/pull-onenote/runner.mjs --bootstrap`. |
|
|
121
|
+
| **D. URLs are canonical** (matches Loop URL grammar; not synthesized) | Refuse + log to learnings/loop.md per `issue-recovery.instructions.md`. |
|
|
122
|
+
|
|
123
|
+
## Verification gate (per `per-source-verification-gate.instructions.md`)
|
|
124
|
+
|
|
125
|
+
After every `pull-loop` run, the gate verifies:
|
|
126
|
+
|
|
127
|
+
- Snapshot shape: at least one `snapshot/<workspace-slug>/<page-slug>.md` ≥ 200 bytes per registered page, OR a documented `page-body-unavailable` annotation in coverage notes.
|
|
128
|
+
- Stream shape: `stream/YYYY-MM-DD_loop-stream.md` covers every ISO week in the run window OR is `.empty` with reason.
|
|
129
|
+
- Registry consistency: every `loop_pages[].last_status` was updated this run (no stale `unresolved` from previous runs unless the page truly remains unresolved).
|
|
130
|
+
- `evidence_source_kind` annotation present on every written artifact.
|
|
131
|
+
- Citation: spot-check 3 random assertions per `citation-ledger.instructions.md`.
|
|
132
|
+
|
|
133
|
+
## Boundary contract
|
|
134
|
+
|
|
135
|
+
Per `loop-bootstrap-discovery.instructions.md` §Boundary:
|
|
136
|
+
|
|
137
|
+
```yaml
|
|
138
|
+
# <project>/integrations.yml
|
|
139
|
+
boundaries:
|
|
140
|
+
loop:
|
|
141
|
+
workspace_ids: [] # REQUIRED — empty = source disabled (never silently widens)
|
|
142
|
+
page_ids: [] # optional — pin specific pages
|
|
143
|
+
include_components: false
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## How users invoke this
|
|
147
|
+
|
|
148
|
+
Direct invocation is uncommon — this skill is normally dispatched by:
|
|
149
|
+
|
|
150
|
+
- `@Kushi bootstrap <project>` (full per-source loop)
|
|
151
|
+
- `@Kushi refresh <project>` (per-source loop)
|
|
152
|
+
- `@Kushi aggregate <project>` (pull-only loop)
|
|
153
|
+
|
|
154
|
+
Direct:
|
|
155
|
+
|
|
156
|
+
```text
|
|
157
|
+
@Kushi pull loop <project>
|
|
158
|
+
@Kushi pull loop <project> last 14 days
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## What this skill never does
|
|
162
|
+
|
|
163
|
+
- Never captures standalone Loop components from Teams/OneNote/Email — those belong to their host source (avoids double-counting).
|
|
164
|
+
- Never falls back to Graph (Loop Graph API is preview-only and incomplete) — see `deferred-retry-on-workiq-fail.instructions.md`.
|
|
165
|
+
- Never synthesizes a `pageUrl` from `workspaceId + pageId` — only URLs observed in WorkIQ responses or registered during setup are valid. See `issue-recovery.instructions.md` for why (mirrors OneNote learnings).
|
|
166
|
+
- Never sends outbound messages.
|
|
167
|
+
- Never reads/writes outside the resolved project's folder.
|
|
168
|
+
|
|
169
|
+
## References
|
|
170
|
+
|
|
171
|
+
- `../../instructions/loop-bootstrap-discovery.instructions.md` — workspace/page registration.
|
|
172
|
+
- `../../instructions/per-source-verification-gate.instructions.md` — gate §2 + §2a.
|
|
173
|
+
- `../../instructions/fuzzy-disambiguation.instructions.md` — workspace title → ID resolution.
|
|
174
|
+
- `../../instructions/issue-recovery.instructions.md` — Issue Recovery Rule applies when discovery or capture exposes a doctrine gap; fix the smallest correct repo-owned artifact first; never use memory as a substitute.
|
|
175
|
+
- `../../learnings/loop.md` — Loop-specific fixes accumulated across runs.
|
|
176
|
+
- `docs/concepts/loop-source.md` — user-facing companion doc.
|
|
177
|
+
|
|
178
|
+
## Issue Recovery
|
|
179
|
+
|
|
180
|
+
When this skill exposes a reusable defect (auth pattern, doctrine gap, layout mismatch), apply the [Issue Recovery Rule](../../instructions/issue-recovery.instructions.md): fix the smallest correct repo-owned artifact first, prefer durable fixes over per-run workarounds, then re-run the narrowest failed check. Do NOT use memory as a substitute for correcting the workflow surface.
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pull-loop runner — browser-scrape via Playwright with the persisted M365 profile.
|
|
3
|
+
// Per pull-loop SKILL.md v1.0.0 (kushi v4.6.0).
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// node runner.mjs --preflight # check reachability; exits 0/3
|
|
7
|
+
// node runner.mjs --project <p> --workspace-url <url> # enumerate + fetch all pages
|
|
8
|
+
// node runner.mjs --project <p> --page-url <url> # fetch a single page
|
|
9
|
+
// node runner.mjs --project <p> --workspace-url <url> --titles "a,b" # partial filter by page title
|
|
10
|
+
// node runner.mjs --project <p> --workspace-url <url> --headless # scheduled / unattended
|
|
11
|
+
//
|
|
12
|
+
// Output is a single JSON object on stdout:
|
|
13
|
+
// {
|
|
14
|
+
// project, workspaceUrl,
|
|
15
|
+
// pages: [{ pageId, pageUrl, pageTitle, last_status, captured_via, body, bodyLen, capturedAt, evidence_source_kind }],
|
|
16
|
+
// runStatus: 'ok' | 'auth-required' | 'workspace-unavailable' | 'partial',
|
|
17
|
+
// authRequiredCount, exitedEarly, preflight: { ok, reason?, detail? }
|
|
18
|
+
// }
|
|
19
|
+
//
|
|
20
|
+
// runStatus 'workspace-unavailable' means Loop-for-Web itself failed to render. The driver
|
|
21
|
+
// MUST surface it as a clear diagnostic and skip the run rather than retry blindly.
|
|
22
|
+
//
|
|
23
|
+
// The driver is responsible for:
|
|
24
|
+
// - merging per-page records into m365-mutable.json#knownSections.<projectKey>.loop.workspaces[].loop_pages[]
|
|
25
|
+
// - writing snapshot pages/*.md
|
|
26
|
+
// - writing the run report
|
|
27
|
+
|
|
28
|
+
import { chromium } from 'playwright';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import os from 'node:os';
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import process from 'node:process';
|
|
33
|
+
|
|
34
|
+
const args = Object.fromEntries(
|
|
35
|
+
process.argv.slice(2).reduce((acc, cur, idx, arr) => {
|
|
36
|
+
if (cur.startsWith('--')) {
|
|
37
|
+
const key = cur.replace(/^--/, '');
|
|
38
|
+
const next = arr[idx + 1];
|
|
39
|
+
acc.push([key, next && !next.startsWith('--') ? next : true]);
|
|
40
|
+
}
|
|
41
|
+
return acc;
|
|
42
|
+
}, [])
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// Profile resolution: prefer the M365-wide profile; fall back to the OneNote profile
|
|
46
|
+
// (which is M365-wide in older installs).
|
|
47
|
+
function resolveProfileDir() {
|
|
48
|
+
const m365 = path.join(os.homedir(), '.kushi', 'playwright-profile', 'm365');
|
|
49
|
+
const onenote = path.join(os.homedir(), '.kushi', 'playwright-profile', 'onenote');
|
|
50
|
+
if (fs.existsSync(m365)) return m365;
|
|
51
|
+
if (fs.existsSync(onenote)) return onenote;
|
|
52
|
+
return m365; // default for fresh installs (will be created on first bootstrap)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const PROFILE_DIR = resolveProfileDir();
|
|
56
|
+
const HEADLESS = !!args.headless;
|
|
57
|
+
const PREFLIGHT = !!args.preflight;
|
|
58
|
+
const PROJECT = args.project || null;
|
|
59
|
+
const WORKSPACE_URL = args['workspace-url'] || null;
|
|
60
|
+
const PAGE_URL = args['page-url'] || null;
|
|
61
|
+
const TITLES_FILTER = args.titles ? String(args.titles).split(',').map(s => s.trim()).filter(Boolean) : null;
|
|
62
|
+
const TIMEOUT_MS = parseInt(args.timeout || '60000', 10);
|
|
63
|
+
const SETTLE_MS = parseInt(args.settle || '3000', 10);
|
|
64
|
+
const PREFLIGHT_TIMEOUT_MS = parseInt(args['preflight-timeout'] || '25000', 10);
|
|
65
|
+
|
|
66
|
+
if (!PREFLIGHT && !PROJECT) {
|
|
67
|
+
console.error('Usage: --project <name> (--workspace-url <url> | --page-url <url>) [--headless] [--titles "t1,t2"]');
|
|
68
|
+
console.error(' --preflight (check Loop-for-Web is reachable; exits 0/3)');
|
|
69
|
+
process.exit(2);
|
|
70
|
+
}
|
|
71
|
+
if (!PREFLIGHT && !WORKSPACE_URL && !PAGE_URL) {
|
|
72
|
+
console.error('Either --workspace-url or --page-url is required.');
|
|
73
|
+
process.exit(2);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Loop URL grammar — per loop-bootstrap-discovery.instructions.md §Surface model.
|
|
77
|
+
// We accept canonical Loop URLs only; synthesized URLs (constructed from IDs) are FORBIDDEN.
|
|
78
|
+
function isCanonicalLoopUrl(u) {
|
|
79
|
+
if (!u) return false;
|
|
80
|
+
try {
|
|
81
|
+
const url = new URL(u);
|
|
82
|
+
const host = url.hostname.toLowerCase();
|
|
83
|
+
if (!host.endsWith('.loop.microsoft.com') && host !== 'loop.cloud.microsoft' && !host.endsWith('.fluidpreview.office.net')) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
} catch { return false; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function waitForAuthOrLoopShell(page, timeoutMs) {
|
|
91
|
+
// Loop-for-Web renders inside an iframe with role=main or a #fluid-container surface.
|
|
92
|
+
// We detect either Loop chrome rendering (success) or an Office sign-in redirect (auth-required).
|
|
93
|
+
const start = Date.now();
|
|
94
|
+
while (Date.now() - start < timeoutMs) {
|
|
95
|
+
const url = page.url();
|
|
96
|
+
if (/login\.microsoftonline\.com|login\.live\.com|login\.cloud\.microsoft/.test(url)) {
|
|
97
|
+
return { ok: false, reason: 'auth-required', detail: `redirected to ${url}` };
|
|
98
|
+
}
|
|
99
|
+
const hasShell = await page.evaluate(() => {
|
|
100
|
+
return !!(document.querySelector('[role="main"]') ||
|
|
101
|
+
document.querySelector('#fluid-container') ||
|
|
102
|
+
document.querySelector('[data-automationid="loop-page"]') ||
|
|
103
|
+
document.querySelector('[class*="LoopPage"]'));
|
|
104
|
+
}).catch(() => false);
|
|
105
|
+
if (hasShell) return { ok: true };
|
|
106
|
+
await page.waitForTimeout(500);
|
|
107
|
+
}
|
|
108
|
+
return { ok: false, reason: 'render-timeout', detail: `no Loop shell in ${timeoutMs}ms` };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function extractPageBody(page) {
|
|
112
|
+
// Loop pages render rich content as a Fluent UI surface. Body extraction strategy:
|
|
113
|
+
// 1. Prefer [data-automationid="loop-page-body"] or the main editor surface.
|
|
114
|
+
// 2. Fall back to [role="main"] textContent.
|
|
115
|
+
// 3. Capture the title from <h1> or document.title.
|
|
116
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
117
|
+
return await page.evaluate(() => {
|
|
118
|
+
function textOf(el) {
|
|
119
|
+
if (!el) return '';
|
|
120
|
+
return (el.innerText || el.textContent || '').replace(/\u00a0/g, ' ').trim();
|
|
121
|
+
}
|
|
122
|
+
const candidates = [
|
|
123
|
+
document.querySelector('[data-automationid="loop-page-body"]'),
|
|
124
|
+
document.querySelector('[data-automationid="loop-page"]'),
|
|
125
|
+
document.querySelector('[class*="LoopPageBody"]'),
|
|
126
|
+
document.querySelector('[role="main"]')
|
|
127
|
+
].filter(Boolean);
|
|
128
|
+
const bodyEl = candidates[0] || document.body;
|
|
129
|
+
const title = textOf(document.querySelector('h1')) || document.title || '';
|
|
130
|
+
const body = textOf(bodyEl);
|
|
131
|
+
return { title, body, bodyLen: body.length };
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function enumeratePages(page) {
|
|
136
|
+
// From a workspace _workspace surface, extract the page list (titles + URLs).
|
|
137
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
138
|
+
return await page.evaluate(() => {
|
|
139
|
+
const links = Array.from(document.querySelectorAll('a[href*="/loop/p/"]'));
|
|
140
|
+
const out = [];
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
for (const a of links) {
|
|
143
|
+
const href = a.href;
|
|
144
|
+
if (seen.has(href)) continue;
|
|
145
|
+
seen.add(href);
|
|
146
|
+
// Skip the _workspace surface itself.
|
|
147
|
+
if (/\/_workspace(\?|$)/.test(href)) continue;
|
|
148
|
+
const title = (a.innerText || a.textContent || '').trim();
|
|
149
|
+
out.push({ pageUrl: href, pageTitle: title });
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
(async () => {
|
|
156
|
+
let context;
|
|
157
|
+
try {
|
|
158
|
+
context = await chromium.launchPersistentContext(PROFILE_DIR, {
|
|
159
|
+
headless: HEADLESS,
|
|
160
|
+
viewport: { width: 1400, height: 900 }
|
|
161
|
+
});
|
|
162
|
+
} catch (e) {
|
|
163
|
+
console.log(JSON.stringify({
|
|
164
|
+
project: PROJECT, workspaceUrl: WORKSPACE_URL, pageUrl: PAGE_URL,
|
|
165
|
+
pages: [], runStatus: 'auth-required',
|
|
166
|
+
preflight: { ok: false, reason: 'profile-launch-failed', detail: String(e?.message || e) }
|
|
167
|
+
}));
|
|
168
|
+
process.exit(3);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const page = await context.newPage();
|
|
172
|
+
|
|
173
|
+
if (PREFLIGHT) {
|
|
174
|
+
try {
|
|
175
|
+
await page.goto('https://loop.microsoft.com/', { timeout: PREFLIGHT_TIMEOUT_MS });
|
|
176
|
+
const shell = await waitForAuthOrLoopShell(page, PREFLIGHT_TIMEOUT_MS);
|
|
177
|
+
console.log(JSON.stringify({ preflight: shell, runStatus: shell.ok ? 'ok' : 'auth-required' }));
|
|
178
|
+
await context.close();
|
|
179
|
+
process.exit(shell.ok ? 0 : 3);
|
|
180
|
+
} catch (e) {
|
|
181
|
+
console.log(JSON.stringify({ preflight: { ok: false, reason: 'navigate-failed', detail: String(e?.message || e) }, runStatus: 'auth-required' }));
|
|
182
|
+
await context.close();
|
|
183
|
+
process.exit(3);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Body capture path
|
|
188
|
+
if (PAGE_URL) {
|
|
189
|
+
if (!isCanonicalLoopUrl(PAGE_URL)) {
|
|
190
|
+
console.log(JSON.stringify({ project: PROJECT, pages: [], runStatus: 'partial', preflight: { ok: false, reason: 'non-canonical-url', detail: PAGE_URL } }));
|
|
191
|
+
await context.close(); process.exit(2);
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
await page.goto(PAGE_URL, { timeout: TIMEOUT_MS });
|
|
195
|
+
const shell = await waitForAuthOrLoopShell(page, TIMEOUT_MS);
|
|
196
|
+
if (!shell.ok) {
|
|
197
|
+
console.log(JSON.stringify({ project: PROJECT, pageUrl: PAGE_URL, pages: [], runStatus: shell.reason === 'auth-required' ? 'auth-required' : 'workspace-unavailable', preflight: shell }));
|
|
198
|
+
await context.close(); process.exit(shell.reason === 'auth-required' ? 3 : 4);
|
|
199
|
+
}
|
|
200
|
+
const extracted = await extractPageBody(page);
|
|
201
|
+
const evidence_source_kind = extracted.bodyLen > 0 ? 'page-body' : 'page-body-unavailable';
|
|
202
|
+
console.log(JSON.stringify({
|
|
203
|
+
project: PROJECT, pageUrl: PAGE_URL,
|
|
204
|
+
pages: [{ pageUrl: PAGE_URL, pageTitle: extracted.title, body: extracted.body, bodyLen: extracted.bodyLen, last_status: extracted.bodyLen > 0 ? 'ok' : 'page-body-unavailable', captured_via: 'playwright', capturedAt: new Date().toISOString(), evidence_source_kind }],
|
|
205
|
+
runStatus: extracted.bodyLen > 0 ? 'ok' : 'partial'
|
|
206
|
+
}));
|
|
207
|
+
await context.close(); process.exit(0);
|
|
208
|
+
} catch (e) {
|
|
209
|
+
console.log(JSON.stringify({ project: PROJECT, pageUrl: PAGE_URL, pages: [], runStatus: 'workspace-unavailable', preflight: { ok: false, reason: 'navigate-failed', detail: String(e?.message || e) } }));
|
|
210
|
+
await context.close(); process.exit(4);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Workspace enumerate + fetch all pages
|
|
215
|
+
if (!isCanonicalLoopUrl(WORKSPACE_URL)) {
|
|
216
|
+
console.log(JSON.stringify({ project: PROJECT, workspaceUrl: WORKSPACE_URL, pages: [], runStatus: 'partial', preflight: { ok: false, reason: 'non-canonical-url', detail: WORKSPACE_URL } }));
|
|
217
|
+
await context.close(); process.exit(2);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
await page.goto(WORKSPACE_URL, { timeout: TIMEOUT_MS });
|
|
222
|
+
const shell = await waitForAuthOrLoopShell(page, TIMEOUT_MS);
|
|
223
|
+
if (!shell.ok) {
|
|
224
|
+
console.log(JSON.stringify({ project: PROJECT, workspaceUrl: WORKSPACE_URL, pages: [], runStatus: shell.reason === 'auth-required' ? 'auth-required' : 'workspace-unavailable', preflight: shell }));
|
|
225
|
+
await context.close(); process.exit(shell.reason === 'auth-required' ? 3 : 4);
|
|
226
|
+
}
|
|
227
|
+
const found = await enumeratePages(page);
|
|
228
|
+
const filtered = TITLES_FILTER ? found.filter(p => TITLES_FILTER.some(t => p.pageTitle.toLowerCase().includes(t.toLowerCase()))) : found;
|
|
229
|
+
const out = [];
|
|
230
|
+
let authRequiredCount = 0;
|
|
231
|
+
for (const p of filtered) {
|
|
232
|
+
try {
|
|
233
|
+
await page.goto(p.pageUrl, { timeout: TIMEOUT_MS });
|
|
234
|
+
const ps = await waitForAuthOrLoopShell(page, Math.min(TIMEOUT_MS, 30000));
|
|
235
|
+
if (!ps.ok) {
|
|
236
|
+
if (ps.reason === 'auth-required') authRequiredCount += 1;
|
|
237
|
+
out.push({ pageUrl: p.pageUrl, pageTitle: p.pageTitle, body: '', bodyLen: 0, last_status: ps.reason, captured_via: 'playwright', capturedAt: new Date().toISOString(), evidence_source_kind: 'page-body-unavailable' });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const ext = await extractPageBody(page);
|
|
241
|
+
out.push({
|
|
242
|
+
pageUrl: p.pageUrl, pageTitle: ext.title || p.pageTitle,
|
|
243
|
+
body: ext.body, bodyLen: ext.bodyLen,
|
|
244
|
+
last_status: ext.bodyLen > 0 ? 'ok' : 'page-body-unavailable',
|
|
245
|
+
captured_via: 'playwright',
|
|
246
|
+
capturedAt: new Date().toISOString(),
|
|
247
|
+
evidence_source_kind: ext.bodyLen > 0 ? 'page-body' : 'page-body-unavailable'
|
|
248
|
+
});
|
|
249
|
+
} catch (e) {
|
|
250
|
+
out.push({ pageUrl: p.pageUrl, pageTitle: p.pageTitle, body: '', bodyLen: 0, last_status: 'fetch-error', captured_via: 'playwright', capturedAt: new Date().toISOString(), evidence_source_kind: 'page-body-unavailable', error: String(e?.message || e) });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const okCount = out.filter(p => p.last_status === 'ok').length;
|
|
254
|
+
const runStatus = authRequiredCount > 0 ? 'partial' : (okCount === out.length && out.length > 0 ? 'ok' : (okCount > 0 ? 'partial' : 'workspace-unavailable'));
|
|
255
|
+
console.log(JSON.stringify({ project: PROJECT, workspaceUrl: WORKSPACE_URL, pages: out, runStatus, authRequiredCount }));
|
|
256
|
+
await context.close(); process.exit(0);
|
|
257
|
+
} catch (e) {
|
|
258
|
+
console.log(JSON.stringify({ project: PROJECT, workspaceUrl: WORKSPACE_URL, pages: [], runStatus: 'workspace-unavailable', preflight: { ok: false, reason: 'navigate-failed', detail: String(e?.message || e) } }));
|
|
259
|
+
await context.close(); process.exit(4);
|
|
260
|
+
}
|
|
261
|
+
})();
|