kushi-agents 5.4.6 → 5.5.0
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 +7 -3
- package/plugin/agents/kushi.agent.md +2 -0
- package/plugin/instructions/csc-rendering.instructions.md +92 -0
- package/plugin/instructions/discovery-prompts.instructions.md +70 -0
- package/plugin/instructions/llm-vs-runner.instructions.md +67 -0
- package/plugin/runners/bootstrap.mjs +145 -0
- package/plugin/runners/lib/config.mjs +108 -0
- package/plugin/runners/lib/dedup.mjs +42 -0
- package/plugin/runners/lib/deferred.mjs +88 -0
- package/plugin/runners/lib/evidence.mjs +76 -0
- package/plugin/runners/lib/http.mjs +105 -0
- package/plugin/runners/lib/identity.mjs +51 -0
- package/plugin/runners/lib/layout.mjs +116 -0
- package/plugin/runners/lib/ledger.mjs +89 -0
- package/plugin/runners/lib/runlog.mjs +61 -0
- package/plugin/runners/lib/weeks.mjs +79 -0
- package/plugin/runners/lib/workiq.mjs +104 -0
- package/plugin/runners/migrate-to-v550.mjs +192 -0
- package/plugin/runners/pull-ado.mjs +282 -0
- package/plugin/runners/pull-crm.mjs +256 -0
- package/plugin/runners/pull-email.mjs +190 -0
- package/plugin/runners/pull-meetings.mjs +209 -0
- package/plugin/runners/pull-onenote.mjs +224 -0
- package/plugin/runners/pull-sharepoint.mjs +198 -0
- package/plugin/runners/pull-teams.mjs +172 -0
- package/plugin/runners/refresh.mjs +244 -0
- package/plugin/runners/test/fixtures/ado-abn-amro.json +95 -0
- package/plugin/runners/test/fixtures/crm-abn-amro.json +21 -0
- package/plugin/runners/test/fixtures/email-abn-amro.json +13 -0
- package/plugin/runners/test/fixtures/meetings-abn-amro.json +10 -0
- package/plugin/runners/test/fixtures/meetings-body-unavailable.json +10 -0
- package/plugin/runners/test/fixtures/onenote-abn-amro.json +30 -0
- package/plugin/runners/test/fixtures/onenote-partial.json +21 -0
- package/plugin/runners/test/fixtures/refresh-dir/ado.json +17 -0
- package/plugin/runners/test/fixtures/refresh-dir/email.json +16 -0
- package/plugin/runners/test/fixtures/refresh-dir/teams.json +12 -0
- package/plugin/runners/test/fixtures/sharepoint-abn-amro.json +12 -0
- package/plugin/runners/test/fixtures/teams-abn-amro.json +11 -0
- package/plugin/runners/test/integration/bootstrap.integration.test.mjs +118 -0
- package/plugin/runners/test/integration/migrate-to-v550.integration.test.mjs +138 -0
- package/plugin/runners/test/integration/pull-ado.integration.test.mjs +140 -0
- package/plugin/runners/test/integration/pull-crm.integration.test.mjs +119 -0
- package/plugin/runners/test/integration/pull-email.integration.test.mjs +97 -0
- package/plugin/runners/test/integration/pull-meetings.integration.test.mjs +92 -0
- package/plugin/runners/test/integration/pull-onenote.integration.test.mjs +86 -0
- package/plugin/runners/test/integration/pull-sharepoint.integration.test.mjs +93 -0
- package/plugin/runners/test/integration/pull-teams.integration.test.mjs +91 -0
- package/plugin/runners/test/integration/refresh.integration.test.mjs +155 -0
- package/plugin/runners/test/unit/config.test.mjs +110 -0
- package/plugin/runners/test/unit/dedup.test.mjs +48 -0
- package/plugin/runners/test/unit/deferred.test.mjs +82 -0
- package/plugin/runners/test/unit/evidence.test.mjs +85 -0
- package/plugin/runners/test/unit/http.test.mjs +103 -0
- package/plugin/runners/test/unit/identity.test.mjs +63 -0
- package/plugin/runners/test/unit/layout.test.mjs +98 -0
- package/plugin/runners/test/unit/ledger.test.mjs +91 -0
- package/plugin/runners/test/unit/runlog.test.mjs +57 -0
- package/plugin/runners/test/unit/weeks.test.mjs +69 -0
- package/plugin/runners/test/unit/workiq.test.mjs +85 -0
- package/plugin/skills/bootstrap-project/SKILL.md +24 -209
- package/plugin/skills/pull-ado/SKILL.md +19 -326
- package/plugin/skills/pull-crm/SKILL.md +20 -222
- package/plugin/skills/pull-email/SKILL.md +18 -206
- package/plugin/skills/pull-meetings/SKILL.md +17 -195
- package/plugin/skills/pull-onenote/SKILL.md +35 -212
- package/plugin/skills/pull-sharepoint/SKILL.md +16 -192
- package/plugin/skills/pull-teams/SKILL.md +17 -185
- package/plugin/skills/refresh-project/SKILL.md +32 -209
- package/plugin/skills/self-check/run.ps1 +85 -0
- package/src/forbidden-workiq-phrasings.test.mjs +156 -167
- package/src/parallel-refresh.test.mjs +52 -50
- 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.
|
|
3
|
+
"version": "5.5.0",
|
|
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",
|
|
@@ -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,67 @@
|
|
|
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.
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// plugin/runners/lib/deferred.mjs
|
|
2
|
+
// Deferred retry queue at Evidence/<alias>/_deferred-retries/<source>/<entity-hash>.yml
|
|
3
|
+
// OneNote BODY_UNAVAILABLE does NOT enqueue — handled by re-resolving wdsectionfileid in pull-onenote.mjs.
|
|
4
|
+
|
|
5
|
+
import { promises as fs } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import YAML from 'yaml';
|
|
8
|
+
import { aliasRoot, USER_FILES } from './layout.mjs';
|
|
9
|
+
import { shortHash } from './dedup.mjs';
|
|
10
|
+
import { writeAtomic } from './evidence.mjs';
|
|
11
|
+
|
|
12
|
+
/** Default retry windows by source (minutes). */
|
|
13
|
+
export const RETRY_MIN_AGE_MIN = {
|
|
14
|
+
meetings: 240, // 4h — transcripts often take hours to materialize
|
|
15
|
+
email: 60,
|
|
16
|
+
teams: 60,
|
|
17
|
+
sharepoint: 60,
|
|
18
|
+
crm: 60,
|
|
19
|
+
ado: 60,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function deferredRoot(project, alias) {
|
|
23
|
+
return path.join(aliasRoot(project, alias), USER_FILES.deferredRetries);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function deferredPath(project, alias, source, entity) {
|
|
27
|
+
const h = shortHash(`${source}::${entity}`);
|
|
28
|
+
return path.join(deferredRoot(project, alias), source, `${h}.yml`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Enqueue a deferred retry record. Idempotent: overwrites the per-cell record.
|
|
33
|
+
* Forbidden for OneNote BODY_UNAVAILABLE — caller must use pull-onenote re-resolution instead.
|
|
34
|
+
*/
|
|
35
|
+
export async function enqueue(project, alias, { source, entity, weekStart = null, reason, signature, retryAfterMin = null, extra = {} }) {
|
|
36
|
+
if (source === 'onenote' && signature === 'body-unavailable') {
|
|
37
|
+
throw new Error('deferred: OneNote body-unavailable must trigger wdsectionfileid re-resolution, not deferred retry');
|
|
38
|
+
}
|
|
39
|
+
const minAge = retryAfterMin ?? RETRY_MIN_AGE_MIN[source] ?? 60;
|
|
40
|
+
const now = new Date();
|
|
41
|
+
const retryNotBefore = new Date(now.getTime() + minAge * 60_000).toISOString();
|
|
42
|
+
const record = {
|
|
43
|
+
source, entity, week_start: weekStart,
|
|
44
|
+
signature: signature ?? reason ?? 'unspecified',
|
|
45
|
+
reason: reason ?? signature ?? 'unspecified',
|
|
46
|
+
deferred_at: now.toISOString(),
|
|
47
|
+
retry_not_before: retryNotBefore,
|
|
48
|
+
attempts: ((extra.attempts ?? 0) | 0) + 1,
|
|
49
|
+
...extra,
|
|
50
|
+
};
|
|
51
|
+
const p = deferredPath(project, alias, source, entity);
|
|
52
|
+
await writeAtomic(p, YAML.stringify(record), { skipIfUnchanged: false });
|
|
53
|
+
return { path: p, record };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Clear a deferred retry record (after successful capture). */
|
|
57
|
+
export async function clear(project, alias, source, entity) {
|
|
58
|
+
const p = deferredPath(project, alias, source, entity);
|
|
59
|
+
try { await fs.unlink(p); return true; } catch (e) {
|
|
60
|
+
if (e.code === 'ENOENT') return false;
|
|
61
|
+
throw e;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** List all pending deferred retries. Optionally filter by source. */
|
|
66
|
+
export async function listPending(project, alias, { source = null, now = new Date() } = {}) {
|
|
67
|
+
const root = deferredRoot(project, alias);
|
|
68
|
+
const out = [];
|
|
69
|
+
let sourceDirs;
|
|
70
|
+
try { sourceDirs = await fs.readdir(root); }
|
|
71
|
+
catch (e) { if (e.code === 'ENOENT') return out; throw e; }
|
|
72
|
+
for (const s of sourceDirs) {
|
|
73
|
+
if (source && s !== source) continue;
|
|
74
|
+
const dir = path.join(root, s);
|
|
75
|
+
let files;
|
|
76
|
+
try { files = await fs.readdir(dir); } catch { continue; }
|
|
77
|
+
for (const f of files) {
|
|
78
|
+
if (!f.endsWith('.yml')) continue;
|
|
79
|
+
const p = path.join(dir, f);
|
|
80
|
+
try {
|
|
81
|
+
const data = YAML.parse(await fs.readFile(p, 'utf8')) ?? {};
|
|
82
|
+
const eligible = !data.retry_not_before || new Date(data.retry_not_before) <= now;
|
|
83
|
+
out.push({ path: p, source: s, eligible, ...data });
|
|
84
|
+
} catch { /* skip malformed */ }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|