@prism-d1/cli 1.7.1 → 1.9.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.
Files changed (53) hide show
  1. package/dist/assets/kirocrew-app/README.md +231 -0
  2. package/dist/assets/kirocrew-app/app.json +94 -0
  3. package/dist/assets/kirocrew-app/assets/icon.png +0 -0
  4. package/dist/assets/kirocrew-app/scripts/digest.py +263 -0
  5. package/dist/assets/kirocrew-app/scripts/page_model.py +298 -0
  6. package/dist/assets/kirocrew-app/scripts/page_routes.py +241 -0
  7. package/dist/assets/kirocrew-app/scripts/prism_cli_path.py +131 -0
  8. package/dist/assets/kirocrew-app/scripts/prism_mcp.py +28 -0
  9. package/dist/assets/kirocrew-app/skills/prism-fix-findings/SKILL.md +62 -0
  10. package/dist/assets/kirocrew-app/ui/index.mjs +269 -0
  11. package/dist/src/commands/_top/mcp.d.ts +7 -0
  12. package/dist/src/commands/_top/mcp.d.ts.map +1 -0
  13. package/dist/src/commands/_top/mcp.js +23 -0
  14. package/dist/src/commands/_top/mcp.js.map +1 -0
  15. package/dist/src/commands/_top/version.d.ts +9 -0
  16. package/dist/src/commands/_top/version.d.ts.map +1 -1
  17. package/dist/src/commands/_top/version.js +22 -1
  18. package/dist/src/commands/_top/version.js.map +1 -1
  19. package/dist/src/commands/enable/kirocrew.d.ts +82 -0
  20. package/dist/src/commands/enable/kirocrew.d.ts.map +1 -0
  21. package/dist/src/commands/enable/kirocrew.js +376 -0
  22. package/dist/src/commands/enable/kirocrew.js.map +1 -0
  23. package/dist/src/dashboard/commits.d.ts +2 -0
  24. package/dist/src/dashboard/commits.d.ts.map +1 -1
  25. package/dist/src/dashboard/commits.js +4 -1
  26. package/dist/src/dashboard/commits.js.map +1 -1
  27. package/dist/src/dashboard/repos.d.ts +1 -0
  28. package/dist/src/dashboard/repos.d.ts.map +1 -1
  29. package/dist/src/dashboard/repos.js +2 -1
  30. package/dist/src/dashboard/repos.js.map +1 -1
  31. package/dist/src/dashboard/setup.d.ts +6 -1
  32. package/dist/src/dashboard/setup.d.ts.map +1 -1
  33. package/dist/src/dashboard/setup.js +17 -0
  34. package/dist/src/dashboard/setup.js.map +1 -1
  35. package/dist/src/dashboard/usage.d.ts +4 -0
  36. package/dist/src/dashboard/usage.d.ts.map +1 -1
  37. package/dist/src/dashboard/usage.js +6 -1
  38. package/dist/src/dashboard/usage.js.map +1 -1
  39. package/dist/src/index.js +1 -1
  40. package/dist/src/index.js.map +1 -1
  41. package/dist/src/mcp/server.d.ts +7 -0
  42. package/dist/src/mcp/server.d.ts.map +1 -0
  43. package/dist/src/mcp/server.js +54 -0
  44. package/dist/src/mcp/server.js.map +1 -0
  45. package/dist/src/mcp/tools.d.ts +82 -0
  46. package/dist/src/mcp/tools.d.ts.map +1 -0
  47. package/dist/src/mcp/tools.js +410 -0
  48. package/dist/src/mcp/tools.js.map +1 -0
  49. package/dist/src/utils/kirocrew.d.ts +35 -0
  50. package/dist/src/utils/kirocrew.d.ts.map +1 -0
  51. package/dist/src/utils/kirocrew.js +65 -0
  52. package/dist/src/utils/kirocrew.js.map +1 -0
  53. package/package.json +3 -2
@@ -0,0 +1,231 @@
1
+ # PRISM app for Kiro Crew (v0.2)
2
+
3
+ A [Kiro Crew](https://github.com/kirodotdev/KiroCrew) app that brings your local PRISM data into
4
+ Kiro Crew. It adds:
5
+
6
+ - **Page `PRISM`** in the sidebar: spend today and this month, top models and projects, the
7
+ machine setup checklist, the repos with codewatch (newest commit first, with their findings), a
8
+ sandboxed view of each repo's scan report, and when the daily digest last ran and what it sent.
9
+ See [The page](#the-page).
10
+ - **MCP server `prism`**: the tools `prism_status`, `prism_findings`, `prism_spend`, `prism_report`
11
+ and `prism_setup`. The app does not implement them. `scripts/prism_mcp.py` finds your
12
+ `prism-cli` and replaces itself with `prism-cli mcp` (an argv list, no shell). See
13
+ [MCP server](../USER_GUIDE.md#mcp-server-prism-cli-mcp) for what each tool returns.
14
+ - **Skill `prism-fix-findings`**: before an agent finishes or pushes a code change in a repo with
15
+ codewatch, it calls `prism_findings`, fixes the real issues in at most 2 rounds, and then reports
16
+ what remains. It treats finding text as untrusted reviewer-model output. It never pushes, never
17
+ disables hooks and never edits `.prism/results/`. When a person asks, it only reports.
18
+ - **Cron `prism-daily-digest`**: a script cron, so it makes no model call. It runs daily at 09:00
19
+ in the gateway machine's local time zone and notifies only when something changed (see
20
+ [How the digest decides to notify](#how-the-digest-decides-to-notify)).
21
+
22
+ ## Requirements
23
+
24
+ - Kiro Crew 0.12.1 or later (`minKiroCrewVersion`; script crons in an app manifest and
25
+ `"timezone": "local"` were checked against 0.12.1). Linux or macOS.
26
+ - `python3` on the gateway's PATH. The scripts use only the standard library.
27
+ - [prism-cli](https://www.npmjs.com/package/@prism-d1/cli) 1.8.0 or later
28
+ (`npm install -g @prism-d1/cli`). The MCP server needs `prism-cli mcp`, which is new in 1.8.0.
29
+
30
+ ### Finding prism-cli
31
+
32
+ The gateway and kiro-cli often start without nvm's folder on PATH. The app looks for `prism-cli`
33
+ the same way the editor extension does, without running a shell, in this order:
34
+
35
+ 1. `cliPath` in the app's `data/config.json`. It must be an absolute path to an executable file.
36
+ 2. `PATH`. Relative entries are skipped.
37
+ 3. `$NVM_BIN`, then `~/.nvm/versions/node/*/bin`: the version that `~/.nvm/alias/default` names
38
+ first, then the newest.
39
+ 4. Volta (`$VOLTA_HOME/bin` or `~/.volta/bin`), `~/.local/bin`, `~/.npm-global/bin`, then
40
+ `$NPM_CONFIG_PREFIX/bin`.
41
+
42
+ Before it runs `prism-cli`, the app puts that folder first on `PATH`, so the CLI's
43
+ `#!/usr/bin/env node` line finds the `node` installed next to it.
44
+
45
+ ## Install
46
+
47
+ With prism-cli 1.9.0 or later, which bundles this app:
48
+
49
+ ```bash
50
+ npm i -g @prism-d1/cli && prism-cli enable kirocrew
51
+ ```
52
+
53
+ `prism-cli enable kirocrew` prints its plan (install, update or already current, with the
54
+ versions) and asks before it changes anything. It copies the bundled app to
55
+ `~/.prism/kirocrew-app`, then runs `kirocrew app install`, or `kirocrew app update prism --source`
56
+ when the app is already installed (also from another folder, such as a clone), and
57
+ `kirocrew app enable prism` on a first install. It never installs Kiro Crew. `--dry-run` prints
58
+ the plan only, `--status` shows the installed and bundled versions, the source folder, the API
59
+ grants and the digest cron, and `--uninstall` removes the app. After a prism-cli upgrade,
60
+ `prism-cli version` says when the bundled app is newer than the installed one.
61
+
62
+ From a local folder instead (a clone, for development):
63
+
64
+ ```bash
65
+ git clone https://github.com/aws-samples/sample-prism-d1-velocity.git
66
+ kirocrew app install "$PWD/sample-prism-d1-velocity/kirocrew-app"
67
+ kirocrew app enable prism
68
+ ```
69
+
70
+ Kiro Crew copies the folder into its apps directory (`~/.kiro/crew/apps/prism/`). Later edits to
71
+ the folder do nothing until you update the app. The app's data stays in
72
+ `~/.kiro/crew/apps/prism/data/` across updates.
73
+
74
+ Kiro Crew registers nothing from a third-party app until you trust it. If you enable the app from
75
+ the dashboard's App Store, it asks you to trust this one app. `kirocrew app enable` refuses with
76
+ `app_execution_denied` until you have trusted the app, either there or under
77
+ **Settings → Security → Third-party apps**.
78
+
79
+ What you are trusting. After you enable it, the app can:
80
+
81
+ - run `python3 scripts/prism_mcp.py` as an MCP server in your agent sessions. That process runs
82
+ your `prism-cli mcp`, which reads the dashboard's local data under your home directory
83
+ (codewatch results in your repos, the codeburn usage cache and setup checks). It makes no
84
+ network or AWS calls. Kiro Crew asks you to approve each tool call, because the app
85
+ auto-approves none.
86
+ - run `scripts/digest.py` once a day without a model. It runs `prism-cli dashboard --json`, which
87
+ scans git repos up to 3 levels under your home directory. It writes only
88
+ `digest-state.json` in the app's data folder, and it sends a Kiro Crew notification when
89
+ something changed.
90
+ - give your agents the `prism-fix-findings` skill.
91
+ - run `scripts/page_routes.py` inside the gateway process (`backend.hooks.routes`). These are the
92
+ page's two read-only routes. While the page is open, they run `prism-cli dashboard --json`,
93
+ `prism-cli --version`, `git log -1` in up to 30 repos, and `prism-cli codewatch report`. Each
94
+ report is written to `data/reports/` in the app's data folder and deleted as soon as it is read.
95
+ Any file older than a day that is left over is removed on the next report request. Like every
96
+ in-gateway app code, these routes run with the gateway's privileges.
97
+
98
+ The manifest declares `cron`, `storage` and `api: ["/api/apps/prism/*"]` (the page may call only
99
+ this app's own routes), and sets `network: false`. The app opens no port and runs no backend
100
+ process. It has no install hooks, no `spawn` and no session control.
101
+
102
+ ### Updating from v0.1
103
+
104
+ ```bash
105
+ kirocrew app update prism --source <path-to>/sample-prism-d1-velocity/kirocrew-app
106
+ ```
107
+
108
+ v0.2 adds the `api` permission for the page. Per the App Kit, an update that adds an `api` entry
109
+ keeps the app enabled but withholds the new entry until you approve it on the app's detail page
110
+ (**Apps → PRISM**). Until you approve it, the page shows an API error. If the page answers 404 after
111
+ the update, the gateway has not wired the new routes yet: disable and enable the app once, or
112
+ restart the gateway.
113
+
114
+ ## Settings
115
+
116
+ The app reads `~/.kiro/crew/apps/prism/data/config.json`, which is optional:
117
+
118
+ ```json
119
+ { "spendThreshold": 25, "cliPath": "/absolute/path/to/prism-cli" }
120
+ ```
121
+
122
+ | Key | Default | Meaning |
123
+ |-----|---------|---------|
124
+ | `spendThreshold` | off | Notify once a day when today's AI spend (codeburn, in its currency) is above this number. Remove the key, or set it to `null` or `0`, to turn it off. |
125
+ | `cliPath` | not set | Use this `prism-cli` instead of searching for one. |
126
+
127
+ To change the schedule, edit `prism-daily-digest` on the dashboard's **Schedule** page. Your edit
128
+ is kept across app updates unless a later version of the app changes the schedule itself.
129
+
130
+ ## The page
131
+
132
+ The **PRISM** sidebar page shows the same data as the editor extension's machine mode:
133
+
134
+ - spend today and this month (calls, sessions), sync mode, prism-cli version, and when the data
135
+ and the usage cache were taken;
136
+ - top models and top projects this month (project folder names only);
137
+ - the machine setup checklist, with fix commands you can copy (the page never runs them);
138
+ - the repos with codewatch, newest last commit first (`git log -1` in at most 30 repos, 4 at a
139
+ time; a repo whose commit is unknown sorts by its last review). Each repo shows a badge from its
140
+ latest review (red `N high`, amber `N med`, muted `N low`, green `clean`, or `no review`; the row's
141
+ left border uses the same color), whether its hooks are on, its last commit, the latest review's
142
+ counts and up to 20 findings, plus an "N more" line. Times on the page are relative ("3 h ago",
143
+ with the full local time as a tooltip) and update while the page is open;
144
+ - **Open report**: the scan report of the review covering the repo's last commit, or the latest
145
+ review when none covers it. The page says which. You can also download the report;
146
+ - **Last digest**: when the digest cron last ran and the last message it sent;
147
+ - **Refresh**, plus an automatic refresh every 5 minutes while the page is visible.
148
+
149
+ How it talks to its backend. The page is `ui/index.mjs`, an ES module that the dashboard loads.
150
+ It calls `GET /api/apps/prism/data` and `GET /api/apps/prism/report?repoIndex=N` through the App
151
+ SDK's `useAppApi()`, which allows only the paths in `permissions.api`. The gateway serves those
152
+ routes from `scripts/page_routes.py` inside its own process. Its token middleware authenticates
153
+ every request (a dashboard session, or an app token scoped to `permissions.api`; loopback is not
154
+ exempt). Each handler then also requires the dashboard owner (`ctx.require_owner`), or this app's
155
+ own token, and refuses everything else.
156
+
157
+ Security:
158
+
159
+ - The page sends only indexes. The backend resolves an index against its own last result and
160
+ refuses any value that is not a plain number inside that list.
161
+ - Recorded text (repo names, commit subjects, finding messages written by a reviewer model) is
162
+ rendered only as React text, never as HTML.
163
+ - The report HTML is untrusted. It is shown in an `<iframe sandbox="">` (no scripts, an opaque
164
+ origin, no forms or popups), and the backend adds a CSP that blocks script and every remote load.
165
+ - Usage comes from the dashboard's 5-minute cache. When nothing is cached for today, the page does
166
+ not start a codeburn export. It says so and offers a **Load now** button that does. When the cache
167
+ is stale, the backend returns as soon as `dashboard --json` prints and lets the CLI's background
168
+ refresh finish on its own.
169
+
170
+ ## How the digest decides to notify
171
+
172
+ Each run reads `prism-cli dashboard --json` (with no `--root`, so it scans your whole home
173
+ directory) and compares it with the previous run's `data/digest-state.json`. It sends one message
174
+ that lists the changes below, then saves the new state. If delivery fails, the state is not saved,
175
+ so the next run reports the same changes again:
176
+
177
+ - **New high findings.** For each repo, the high findings in the latest codewatch review of each
178
+ branch that were not there last time. A finding is identified by branch, file and rule, so the
179
+ same issue reviewed again on a later push is not counted as new. The message gives the repo name
180
+ (never its path), the count, and the review's short commit and branch. It also includes the first
181
+ finding's text, cut to 120 characters on one line. That text comes from a reviewer model and is
182
+ only displayed, never acted on.
183
+ - **Spend.** Today's spend is above `spendThreshold`. This is sent at most once per day.
184
+ - **Setup regressions.** A machine setup item, or a repo's codewatch setup, went from `ok` to
185
+ `warn` or `missing`.
186
+
187
+ When nothing changed, the run is silent. The first run, and any run that finds the state file
188
+ missing or unreadable, saves a baseline and sends nothing. If `prism-cli` cannot be found or fails,
189
+ the run ends in an error, which the Schedule page shows, and the saved state is left as it was.
190
+
191
+ To see what a run would send without Kiro Crew:
192
+
193
+ ```bash
194
+ python3 kirocrew-app/scripts/digest.py --state-dir "$(mktemp -d)" # prints the message, or why it was quiet
195
+ ```
196
+
197
+ `--state-dir DIR` is required and keeps a manual run away from the cron's own state. `--payload FILE`
198
+ reads a saved `dashboard --json` output instead of running `prism-cli`.
199
+
200
+ ## Uninstall
201
+
202
+ ```bash
203
+ prism-cli enable kirocrew --uninstall # kirocrew app uninstall prism, then removes ~/.prism/kirocrew-app
204
+ ```
205
+
206
+ Or with Kiro Crew directly:
207
+
208
+ ```bash
209
+ kirocrew app disable prism
210
+ kirocrew app uninstall prism
211
+ ```
212
+
213
+ Disabling the app removes its cron job, MCP server and skill. By default, uninstalling keeps
214
+ `~/.kiro/crew/apps/prism/data/`. Delete that folder yourself if you want the state gone.
215
+
216
+ ## Development
217
+
218
+ ```bash
219
+ python3 -m unittest discover -s kirocrew-app/tests
220
+ node --test kirocrew-app/tests/page.test.mjs
221
+ ```
222
+
223
+ The tests cover the digest's diff logic, the `prism-cli` lookup, the page model (sorting, caps,
224
+ index checks, report arguments and fallback, digest state) and the page's rendering with fixture
225
+ data. They also check `app.json` against the
226
+ [App Kit manifest reference](https://github.com/kirodotdev/KiroCrew/blob/main/docs/app-kit/manifest-reference.md).
227
+ When Kiro Crew is installed, they also run Kiro Crew's own manifest validator. CI runs them in
228
+ `.github/workflows/kirocrew-app.yml`.
229
+
230
+ Not in v0.2: Windows support (`os.execve` on an npm `.cmd` shim needs a shell), and delivery to a
231
+ channel other than Kiro Crew's default notification.
@@ -0,0 +1,94 @@
1
+ {
2
+ "name": "prism",
3
+ "version": "0.2.2",
4
+ "displayName": "PRISM",
5
+ "description": "Codewatch findings, AI spend and setup status from your local prism-cli for Kiro Crew agents, and a daily digest that speaks only when something changed.",
6
+ "author": "aws-samples",
7
+ "license": "MIT",
8
+ "minKiroCrewVersion": "0.12.1",
9
+ "iconPath": "assets/icon.png",
10
+ "tags": ["code-review", "developer-tools", "code-quality"],
11
+ "highlights": [
12
+ "PRISM page: spend, setup checklist, repos with codewatch newest commit first, their findings and a sandboxed scan report, all read-only",
13
+ "The prism MCP server: prism_status, prism_findings, prism_spend, prism_report and prism_setup, served by your own prism-cli mcp",
14
+ "Finds prism-cli installed with nvm or Volta even when Kiro Crew's PATH does not include it",
15
+ "prism-fix-findings skill: an agent checks the latest codewatch review before it finishes, fixes real issues in at most 2 rounds and reports the rest",
16
+ "prism-daily-digest: a script cron with no model call that notifies only on a new high finding, spend over your threshold, or a setup item that broke"
17
+ ],
18
+ "useCases": [
19
+ "When you start the day, open the PRISM page to see which repos got new codewatch findings since their last commit and open the scan report of the one that needs attention",
20
+ "When an agent finishes a code change in a repo with codewatch, the prism-fix-findings skill has it call prism_findings and fix the real high and medium findings before it reports done",
21
+ "When you want to know what codewatch flagged across your repos, ask a chat to call prism_findings or prism_status instead of opening the dashboard",
22
+ "When you want a heads-up without watching a page, let prism-daily-digest run at 09:00 and tell you only about new high findings, a setup regression, or spend over your threshold"
23
+ ],
24
+ "configuration": [
25
+ "Install with prism-cli 1.9.0 or later: npm install -g @prism-d1/cli && prism-cli enable kirocrew (or install this folder; see the README)",
26
+ "Trust the app when you enable it; Kiro Crew registers nothing from a third-party app until you do",
27
+ "Optional: set spendThreshold and cliPath in the app's data/config.json (see the README)"
28
+ ],
29
+ "links": [
30
+ { "label": "README", "url": "https://github.com/aws-samples/sample-prism-d1-velocity/blob/main/kirocrew-app/README.md" },
31
+ { "label": "PRISM user guide", "url": "https://github.com/aws-samples/sample-prism-d1-velocity/blob/main/USER_GUIDE.md" }
32
+ ],
33
+ "skills": ["skills/prism-fix-findings"],
34
+ "ui": {
35
+ "entry": "index.mjs",
36
+ "pages": [
37
+ { "route": "/apps/prism", "label": "PRISM", "icon": "ScanSearch" }
38
+ ]
39
+ },
40
+ "backend": {
41
+ "hooks": {
42
+ "routes": "scripts.page_routes:register_routes"
43
+ }
44
+ },
45
+ "mcpServers": {
46
+ "prism": {
47
+ "command": "python3",
48
+ "args": ["scripts/prism_mcp.py"]
49
+ }
50
+ },
51
+ "crons": [
52
+ {
53
+ "name": "prism-daily-digest",
54
+ "cron_expr": "0 9 * * *",
55
+ "timezone": "local",
56
+ "strict_schedule": true,
57
+ "script": "scripts/digest.py:run",
58
+ "message": "PRISM daily digest (script, no model call)",
59
+ "persistent_session": false,
60
+ "timeout": 120,
61
+ "timeout_secs": 130
62
+ }
63
+ ],
64
+ "permissions": {
65
+ "api": ["/api/apps/prism/*"],
66
+ "cron": true,
67
+ "storage": true,
68
+ "network": false
69
+ },
70
+ "dependencies": {
71
+ "commands": ["python3"],
72
+ "optionalCommands": ["prism-cli"]
73
+ },
74
+ "platform": {
75
+ "os": ["macos", "linux"]
76
+ },
77
+ "setup": {
78
+ "configSchema": {
79
+ "type": "object",
80
+ "additionalProperties": false,
81
+ "properties": {
82
+ "spendThreshold": {
83
+ "type": ["number", "null"],
84
+ "exclusiveMinimum": 0,
85
+ "description": "Notify once a day when today's AI spend (codeburn, in its currency) is above this. Unset or null: off."
86
+ },
87
+ "cliPath": {
88
+ "type": ["string", "null"],
89
+ "description": "Absolute path to prism-cli, when it is not found on PATH or in the nvm, Volta, ~/.local/bin or npm prefix folders."
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,263 @@
1
+ """Daily PRISM digest: a script cron (no model call).
2
+
3
+ Runs `prism-cli dashboard --json`, compares it with the state the previous
4
+ run saved in the app's data folder, and notifies only when something changed:
5
+
6
+ - a new high finding in a repo's latest codewatch review,
7
+ - today's AI spend above `spendThreshold` (data/config.json; off by default),
8
+ at most once per day,
9
+ - a setup item that went from ok to warn or missing.
10
+
11
+ First run, or a state file that cannot be read: save a baseline, no message.
12
+ Finding text is written by a reviewer model: it is shown shortened on one
13
+ line and never acted on.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import datetime as _dt
19
+ import json
20
+ import os
21
+ import re
22
+ import subprocess
23
+ import sys
24
+ import threading
25
+ from pathlib import Path
26
+ from typing import Any, Callable, Dict, List, Optional, Tuple
27
+
28
+ from prism_cli_path import app_data_dir, cli_env, load_config, resolve_cli
29
+
30
+ STATE_FILE = "digest-state.json"
31
+ STATE_SCHEMA = 1
32
+ DASHBOARD_TIMEOUT_S = 80 # until the document is printed; plus REFRESH_GRACE_S stays under the cron's 120 s timeout
33
+ REFRESH_GRACE_S = 20 # time left for the CLI's codeburn refresh after it printed
34
+ FINDING_CHARS = 120
35
+ NAME_CHARS = 60
36
+ SHORT_SHA = 7
37
+ CURRENCY_CHARS = 8
38
+ MAX_REPOS_LISTED = 10
39
+ BAD_SETUP = ("warn", "missing")
40
+ _CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2066-\u2069]")
41
+
42
+
43
+ def clean(text: Any, limit: int) -> str:
44
+ """One plain line: control and bidi characters dropped, whitespace collapsed, cut to `limit`."""
45
+ s = " ".join(_CONTROL.sub(" ", str(text or "")).split())
46
+ return s if len(s) <= limit else s[: limit - 1].rstrip() + "…"
47
+
48
+
49
+ def _fingerprint(f: Dict[str, Any]) -> str:
50
+ # No commit or line: the same issue re-reviewed on a later push is not new.
51
+ return "|".join(str(f.get(k) or "") for k in ("branch", "file", "rule"))
52
+
53
+
54
+ def _repo_name(repo: Dict[str, Any]) -> str:
55
+ # The dashboard's label is already unique: it adds a short parent path when two repos share a name.
56
+ return str(repo.get("label") or repo.get("name") or "")
57
+
58
+
59
+ def snapshot(payload: Dict[str, Any]) -> Dict[str, Any]:
60
+ """The parts of the dashboard payload the digest compares."""
61
+ highs: Dict[str, List[str]] = {}
62
+ setup: Dict[str, str] = {}
63
+ for item in (payload.get("machine") or {}).get("setup") or []:
64
+ if item.get("id"):
65
+ setup["machine:" + str(item["id"])] = str(item.get("status") or "")
66
+ for repo in payload.get("repos") or []:
67
+ name = _repo_name(repo)
68
+ if not name:
69
+ continue
70
+ fps = sorted({_fingerprint(f) for f in (repo.get("codewatch") or {}).get("findings") or [] if f.get("severity") == "high"})
71
+ if fps:
72
+ highs[name] = fps
73
+ status = (repo.get("setup") or {}).get("status")
74
+ if status:
75
+ setup["repo:" + name] = str(status)
76
+ return {"highs": highs, "setup": setup}
77
+
78
+
79
+ def _setup_label(key: str, payload: Dict[str, Any]) -> str:
80
+ kind, _, ident = key.partition(":")
81
+ if kind == "machine":
82
+ for item in (payload.get("machine") or {}).get("setup") or []:
83
+ if str(item.get("id")) == ident:
84
+ return clean(item.get("label") or ident, NAME_CHARS)
85
+ return clean(ident, NAME_CHARS)
86
+ return "codewatch in " + clean(ident, NAME_CHARS)
87
+
88
+
89
+ def _new_high_lines(prev: Dict[str, List[str]], payload: Dict[str, Any]) -> List[str]:
90
+ lines: List[str] = []
91
+ for repo in payload.get("repos") or []:
92
+ name = _repo_name(repo)
93
+ if not name:
94
+ continue # never stored in state, so it would look new on every run
95
+ seen = set(prev.get(name) or [])
96
+ # One line per branch: each branch's latest review has its own commit.
97
+ by_branch: Dict[str, Dict[str, Dict[str, Any]]] = {}
98
+ for f in (repo.get("codewatch") or {}).get("findings") or []:
99
+ if f.get("severity") == "high" and _fingerprint(f) not in seen:
100
+ by_branch.setdefault(str(f.get("branch") or "?"), {}).setdefault(_fingerprint(f), f)
101
+ for branch, new in sorted(by_branch.items()):
102
+ first = next(iter(new.values()))
103
+ commit = clean(first.get("commit") or "?", NAME_CHARS)[:SHORT_SHA]
104
+ n = len(new)
105
+ lines.append(
106
+ f"- {clean(name, NAME_CHARS)}: {n} new high finding{'s' if n != 1 else ''} "
107
+ f"(review of {commit} on {clean(branch, NAME_CHARS)}). First: {clean(first.get('message') or first.get('rule'), FINDING_CHARS)}"
108
+ )
109
+ if len(lines) > MAX_REPOS_LISTED:
110
+ lines = lines[:MAX_REPOS_LISTED] + [f"- and {len(lines) - MAX_REPOS_LISTED} more entries"]
111
+ return lines
112
+
113
+
114
+ def evaluate(
115
+ prev: Optional[Dict[str, Any]],
116
+ payload: Dict[str, Any],
117
+ threshold: Optional[float],
118
+ today: str,
119
+ ) -> Tuple[Optional[str], Dict[str, Any]]:
120
+ """(message or None, state to save). `prev` None means first run or unreadable state: baseline only."""
121
+ snap = snapshot(payload)
122
+ state: Dict[str, Any] = {"schema": STATE_SCHEMA, **snap, "spendAlertDate": None,
123
+ "lastNotifiedAt": None, "lastMessage": None}
124
+ if prev is None:
125
+ return None, state
126
+ state["spendAlertDate"] = prev.get("spendAlertDate")
127
+ # Shown on the app's page ("Last digest").
128
+ state["lastNotifiedAt"] = prev.get("lastNotifiedAt")
129
+ state["lastMessage"] = prev.get("lastMessage")
130
+
131
+ lines = _new_high_lines(prev.get("highs") or {}, payload)
132
+
133
+ usage = payload.get("usage") or {}
134
+ cost = (usage.get("today") or {}).get("cost") if usage.get("status") == "ok" else None
135
+ if threshold and isinstance(cost, (int, float)) and cost > threshold and prev.get("spendAlertDate") != today:
136
+ cur = clean(usage.get("currency") or "USD", CURRENCY_CHARS)
137
+ lines.append(f"- AI spend today is {cost:.2f} {cur}, above your threshold of {threshold:.2f} {cur}.")
138
+ state["spendAlertDate"] = today
139
+
140
+ prev_setup = prev.get("setup") or {}
141
+ for key, status in sorted(snap["setup"].items()):
142
+ if prev_setup.get(key) == "ok" and status in BAD_SETUP:
143
+ lines.append(f"- Setup: {_setup_label(key, payload)} went from ok to {status}.")
144
+
145
+ if not lines:
146
+ return None, state
147
+ return "PRISM daily digest\n" + "\n".join(lines), state
148
+
149
+
150
+ def read_state(path: Path) -> Optional[Dict[str, Any]]:
151
+ """The saved state, or None when it is missing or not a state this version wrote."""
152
+ try:
153
+ data = json.loads(path.read_text(encoding="utf-8"))
154
+ except (OSError, ValueError):
155
+ return None
156
+ if not isinstance(data, dict) or data.get("schema") != STATE_SCHEMA:
157
+ return None
158
+ if not isinstance(data.get("highs"), dict) or not isinstance(data.get("setup"), dict):
159
+ return None
160
+ return data
161
+
162
+
163
+ def write_state(path: Path, state: Dict[str, Any]) -> None:
164
+ path.parent.mkdir(parents=True, exist_ok=True)
165
+ tmp = path.with_name(path.name + ".tmp")
166
+ tmp.write_text(json.dumps(state, indent=1, sort_keys=True) + "\n", encoding="utf-8")
167
+ os.replace(tmp, path)
168
+
169
+
170
+ def spend_threshold(cfg: Dict[str, Any]) -> Optional[float]:
171
+ v = cfg.get("spendThreshold")
172
+ if isinstance(v, bool) or not isinstance(v, (int, float)) or v <= 0:
173
+ return None
174
+ return float(v)
175
+
176
+
177
+ def run_dashboard(cfg: Dict[str, Any]) -> Dict[str, Any]:
178
+ setting = cfg.get("cliPath")
179
+ cli, reason = resolve_cli(setting if isinstance(setting, str) else None, os.environ)
180
+ if not cli:
181
+ raise RuntimeError(reason)
182
+ # No --root: the CLI scans the home directory, as the dashboard does.
183
+ # With a stale usage cache the CLI prints the document, then waits for its codeburn refresh;
184
+ # read only up to the closing line, give the refresh a moment, then stop the child, so that
185
+ # wait cannot push this run past the cron's timeout.
186
+ proc = subprocess.Popen([cli, "dashboard", "--json"], cwd=str(Path.home()), env=cli_env(cli, os.environ),
187
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
188
+ lines: List[bytes] = []
189
+
190
+ def read_doc() -> None:
191
+ assert proc.stdout is not None
192
+ for line in proc.stdout:
193
+ lines.append(line)
194
+ if line == b"}\n": # the document is pretty-printed; this closes it
195
+ return
196
+
197
+ try:
198
+ reader = threading.Thread(target=read_doc, daemon=True)
199
+ reader.start()
200
+ reader.join(DASHBOARD_TIMEOUT_S) # a deadline of our own, under the cron's 120 s kill
201
+ if not reader.is_alive():
202
+ try:
203
+ proc.wait(timeout=REFRESH_GRACE_S)
204
+ except subprocess.TimeoutExpired:
205
+ pass
206
+ finally:
207
+ if proc.poll() is None:
208
+ proc.kill()
209
+ proc.wait()
210
+ if not lines or lines[-1] != b"}\n":
211
+ raise RuntimeError(f"prism-cli dashboard --json gave no complete document (exit {proc.returncode})")
212
+ payload = json.loads(b"".join(lines).decode("utf-8"))
213
+ if not isinstance(payload, dict) or not str(payload.get("schema", "")).startswith("prism.dashboard."):
214
+ raise RuntimeError("prism-cli dashboard --json returned an unexpected document")
215
+ return payload
216
+
217
+
218
+ def digest_once(
219
+ data_dir: Path,
220
+ payload: Optional[Dict[str, Any]] = None,
221
+ deliver: Optional[Callable[[str], Any]] = None,
222
+ ) -> Tuple[Optional[str], bool]:
223
+ """Run one digest. Returns (message or None, baseline_only).
224
+
225
+ The state is saved only after `deliver(msg)` returns, so a failed
226
+ delivery is retried on the next run instead of being lost.
227
+ """
228
+ cfg = load_config(data_dir)
229
+ if payload is None:
230
+ payload = run_dashboard(cfg)
231
+ path = data_dir / STATE_FILE
232
+ prev = read_state(path)
233
+ msg, state = evaluate(prev, payload, spend_threshold(cfg), _dt.date.today().isoformat())
234
+ stamp = _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")
235
+ if msg and deliver:
236
+ deliver(msg)
237
+ state["lastRunAt"] = stamp
238
+ if msg:
239
+ state["lastNotifiedAt"], state["lastMessage"] = stamp, msg
240
+ write_state(path, state)
241
+ return msg, prev is None
242
+
243
+
244
+ def run(ctx: Any) -> None:
245
+ """Cron entry point (app.json `scripts/digest.py:run`). ctx.notify raises when delivery fails."""
246
+ digest_once(app_data_dir(), deliver=ctx.notify)
247
+
248
+
249
+ def main(argv: Optional[List[str]] = None) -> int:
250
+ ap = argparse.ArgumentParser(description="Run the PRISM digest once and print what it would send.")
251
+ # Required, so a manual run never advances the cron's own state with changes it did not deliver.
252
+ ap.add_argument("--state-dir", required=True, help="Folder for digest-state.json and config.json; use a scratch folder, not the app's data folder")
253
+ ap.add_argument("--payload", help="Read the dashboard JSON from this file instead of running prism-cli")
254
+ a = ap.parse_args(argv)
255
+ data_dir = Path(a.state_dir)
256
+ payload = json.loads(Path(a.payload).read_text(encoding="utf-8")) if a.payload else None
257
+ msg, baseline = digest_once(data_dir, payload)
258
+ print(msg if msg else ("baseline saved, no notification" if baseline else "no change, no notification"))
259
+ return 0
260
+
261
+
262
+ if __name__ == "__main__":
263
+ sys.exit(main())