whose-turn 0.0.0-stage → 1.0.0-alpha.3

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 (55) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/LICENSE +21 -0
  3. package/README.md +244 -2
  4. package/config.example.json +144 -0
  5. package/connectors/demo.mjs +146 -0
  6. package/connectors/github.mjs +316 -0
  7. package/connectors/index.mjs +38 -0
  8. package/connectors/jira.mjs +155 -0
  9. package/connectors/linear.mjs +113 -0
  10. package/dist/assets/_plugin-vue_export-helper-BKveAJb2.css +1 -0
  11. package/dist/assets/_plugin-vue_export-helper-DHr5RHTx.js +18 -0
  12. package/dist/assets/bem-COKTjsky.js +1 -0
  13. package/dist/assets/index-CuyWocyb.js +3 -0
  14. package/dist/assets/index-p8D7awn0.css +1 -0
  15. package/dist/assets/report-BtGnm9ax.js +1 -0
  16. package/dist/assets/report-xVOo7qip.css +1 -0
  17. package/dist/assets/scheme-DJ3WNw60.js +1 -0
  18. package/dist/assets/sheet-CBYGM-FG.js +1 -0
  19. package/dist/assets/sheet-DYpaJaXO.css +1 -0
  20. package/dist/index.html +19 -0
  21. package/dist/report.html +16 -0
  22. package/dist/sheet.html +16 -0
  23. package/dist/vendor/popper.min.js +6 -0
  24. package/dist/vendor/tippy-bundle.umd.min.js +2 -0
  25. package/install.sh +69 -0
  26. package/lib/api.mjs +406 -0
  27. package/lib/backoff.mjs +2 -0
  28. package/lib/compose.mjs +67 -0
  29. package/lib/config.mjs +290 -0
  30. package/lib/default-table.mjs +58 -0
  31. package/lib/errors.mjs +55 -0
  32. package/lib/facts.mjs +128 -0
  33. package/lib/feedback.mjs +148 -0
  34. package/lib/handoff.mjs +127 -0
  35. package/lib/health.mjs +35 -0
  36. package/lib/http-client.mjs +44 -0
  37. package/lib/inbox.mjs +60 -0
  38. package/lib/linear-api.mjs +12 -0
  39. package/lib/notify.mjs +36 -0
  40. package/lib/palette.mjs +213 -0
  41. package/lib/platform/credential.ps1 +84 -0
  42. package/lib/registry.mjs +61 -0
  43. package/lib/rules.mjs +241 -0
  44. package/lib/secrets.mjs +69 -0
  45. package/lib/static.mjs +36 -0
  46. package/lib/store.mjs +61 -0
  47. package/lib/table.mjs +148 -0
  48. package/lib/text.mjs +46 -0
  49. package/lib/token-cache.mjs +12 -0
  50. package/lib/update.mjs +134 -0
  51. package/lib/window.mjs +33 -0
  52. package/lib/workspace.mjs +294 -0
  53. package/open.sh +40 -0
  54. package/package.json +67 -4
  55. package/server.mjs +96 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to whose-turn are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/).
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [1.0.0-alpha.3] - 2026-11-03
8
+
9
+ ### Added
10
+ - Self-update of the installed package. Every ten minutes the server reads the npm dist-tags (`latest`, which every publish moves) and, when a different version is out, reports it as `update` in `/api/summary`, `/api/state` and the stream, and raises one desktop notification per version. `POST /api/update` installs it into the package's own location and restarts the server; the status bar shows "<version> is out · Update", then "Updating…", then "Updated to <version>". A checkout is never offered an update. New reasons `UPDATE_IN_PROGRESS` (409) and `UPDATE_UNAVAILABLE` (412). RMA-231.
11
+
12
+ ### Fixed
13
+ - The window opens its event stream again. Since RMA-197 the store waited for a `CLOSED` status that VueUse never reports before the first open, so the window never subscribed to `/api/stream` and picked up new polls only on a reload.
14
+
15
+ ### Changed
16
+ - The release workflow publishes through npm trusted publishing (OIDC) instead of a stored token. RMA-235.
17
+
18
+ ## [1.0.0-alpha.2] - 2026-11-03
19
+
20
+ ### Removed
21
+ - The Claude Code plugin (skill, `/whose-turn`, SessionStart and prompt hooks, MCP server, `bin/` scripts, `.claude-plugin/plugin.json`). It now lives in the BladeForge marketplace as `whose-turn@bladeforge` and installs this package. RMA-217.
22
+
23
+ ### Changed
24
+ - The package is the server and the built window; `install.sh` reads the port through `lib/config.mjs`. The release workflow checks the tag against `package.json` only.
25
+
26
+ ## [1.0.0-alpha.1] - 2026-11-03
27
+
28
+ ### Added
29
+ - First public release candidate: the local server with the GitHub, Jira, Linear and demo connectors, the rules table, the Vue window, the Claude Code plugin (skill, SessionStart hook, status line, MCP tools), desktop notifications and health alerts.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Roman Maslennikov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,245 @@
1
- # Temporary Holding Version
1
+ # Whose Turn
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A local monitor that answers "what is waiting on me right now" across the places your work arrives: GitHub pull requests, Jira and Linear tickets, and your own to-dos. It polls each source about once a minute, decides once, on the server, whose turn every ticket is, shows the result in a window that updates live, sends a desktop notification when something becomes your move, and hands any item to an AI assistant as Markdown with its context. Its Claude Code plugin, `whose-turn@bladeforge`, makes a session start with "N your move · M unread · K to-do" already in context.
4
+
5
+ ![The window on the demo workspace: Your move, To do, Waiting on others, and the feed](docs/window.png)
6
+
7
+ Everything runs on your machine: a Node 22 server on `127.0.0.1`, a window built with Vue, and no account anywhere but the sources you already use. Nothing that names an employer, an account or a team process lives in this repository; it all sits in one config file on the machine that owns it.
8
+
9
+ ## Install
10
+
11
+ You need Node 22 or newer and, for GitHub, the `gh` CLI signed in.
12
+
13
+ ```sh
14
+ git clone https://github.com/aquivalabs/whose-turn && cd whose-turn
15
+ npm ci && npm run build
16
+ ./install.sh && ./open.sh
17
+ ```
18
+
19
+ `install.sh` makes the monitor start at login and restart if it stops (a launch agent on macOS, a systemd user unit on Linux; on Windows it prints the one `schtasks` line to run). `open.sh` opens the window as its own app window, or in a browser tab with `--browser`. The window listens on port 8765; `WHOSE_TURN_PORT` changes that. A clone updates with `git pull` and a rebuild; it is never offered the in-app update.
20
+
21
+ With Claude Code, the plugin installs and starts all of this for you, with no clone and no build (see [Claude Code](#claude-code)). That install updates itself: the server checks npm every ten minutes, tells you about a new version with a desktop notification and a chip in the status bar, and installs it and restarts on one click (`POST /api/update`).
22
+
23
+ ```sh
24
+ claude plugin marketplace add aquivalabs/BladeForge
25
+ claude plugin install whose-turn@bladeforge
26
+ ```
27
+
28
+ ## First run
29
+
30
+ The window opens on one card: add a source through the settings sheet (the gear at the bottom of the rail), or press "Try a demo workspace" to see every state on invented data without any account. A source needs an account and its filters; a token typed into a secret field goes to the platform's secret store, never into the config file. Claude can set a workspace up too, through the `whose_turn_config` MCP tool. From then on the server polls, the window follows, and the Claude Code plugin reads the same server.
31
+
32
+ ## Workspaces and connectors
33
+
34
+ A **workspace** is one place you work: a project, a client, a side venture. It has its own connectors, its own feed and its own to-dos; nothing is mixed between workspaces. A **connector** is the code for one source type (`connectors/github.mjs`, `connectors/jira.mjs`): it takes one connector entry of a workspace (an account and its filters) and returns normalised `tickets`, `comments`, `prs`, `activity`, `asks` and `runs`, plus `me` (your name there) and, for a ticket source, `ticket_base`. It holds no rules and knows no employer. The server stamps every row with the entry's id as `source`, merges all entries by kind and runs the rules over the merged view, so a workspace may hold two entries of one type (two repositories, two Jira sites) when each carries its own `id`.
35
+
36
+ Nothing that names an employer, an account or a team process lives in this repository. It all sits in one file on the machine that owns it:
37
+
38
+ | platform | config file |
39
+ |---|---|
40
+ | macOS | `~/Library/Application Support/whose-turn/config.json` |
41
+ | Linux | `$XDG_CONFIG_HOME/whose-turn/config.json`, default `~/.config/whose-turn/config.json` |
42
+ | Windows | `%APPDATA%\whose-turn\config.json` |
43
+
44
+ `WHOSE_TURN_DATA` overrides the whole directory, `WHOSE_TURN_CONFIG` the config file alone, `WHOSE_TURN_PORT` the port (it wins over `port` in the config file; `install.sh` and the plugin read both the same way). On first run there is no file: the settings sheet (the gear) or Claude through `/api/config` writes it, or copy `config.example.json` there and fill in:
45
+
46
+ | key | meaning |
47
+ |---|---|
48
+ | `port`, `poll_seconds`, `history_days` | the port, how often to poll, how far back the feed reaches |
49
+ | `stale_minutes`, `failing_polls` | health alerts: no data from any source for this many minutes (default 15), or one source failing this many polls in a row (default 3) |
50
+ | `feedback.workspace`, `team`, `project` | where the "to the backlog" button and the `whose_turn_feedback` MCP tool file bugs and improvements about the monitor: a Linear workspace slug, team key and project name; the key with issue-creation rights goes to the secret store under `feedback.token_service` (default `whose-turn-feedback`) and the workspace slug |
51
+ | `feedback.assign` | `false` leaves a filed issue unassigned for triage; by default it is assigned to the owner of the feedback key, so it shows up on that person's own dashboard |
52
+ | `feedback.composer` | the user's own agent that turns a remark into an issue: `{command, args}` of a CLI that reads the prompt on stdin and prints JSON (`{title, kind, body}`, or a wrapper with a `result` string that contains it); default `claude -p --output-format json --model claude-haiku-5-5`, so Claude Code on the user's subscription; `codex exec` or anything alike works the same way |
53
+ | `feedback.bug_words` | extra words, in any language, that make the fallback composer (used when the agent CLI gives no usable answer) call a remark a bug; matched as stems at a word start; the English defaults bug, broken, crash, error, fail, wrong always apply |
54
+ | `workspaces[].id`, `name` | a short id used in the API and on disk (lowercase letters, digits and hyphens, up to 40, starting with a letter or digit; a missing id becomes `ws<n>`) and the display name |
55
+ | `workspaces[].key_pattern` | regex for ticket keys in PR titles and to-dos; the default matches any Jira-style key; a pattern that does not compile is refused on save |
56
+ | `workspaces[].connectors` | an object keyed by type, or a list of `{type, id?, label?, …}` entries when one type appears twice |
57
+ | `…connectors.github.repo` | the repository whose PRs are yours, `owner/name` |
58
+ | `…github.org` | notifications are kept only from this org; defaults to the repo owner |
59
+ | `…github.me` | your GitHub login; left empty, it is asked from the API once |
60
+ | `…github.account` | which `gh` account's token to use when several are logged in |
61
+ | `…github.bots` | logins whose comments never count as a human reply |
62
+ | `…github.workflow_runs` | `false` stops watching the Actions runs on the default branch; on by default |
63
+ | `…connectors.jira.base` | your Jira Cloud site, `https://` only |
64
+ | `…jira.email` | the account the API token belongs to |
65
+ | `…jira.token_service` | the secret-store entry holding the token, default `whose-turn-jira` |
66
+ | `…jira.jql` | which tickets to watch |
67
+ | `workspaces[].links` | quick links shown in the header, as `"Label https://…"` strings or `{label, url}`; the repository, the Jira site and the Linear team come from the connectors by themselves |
68
+ | `workspaces[].rules` | the team's process, see Rules below; `statuses` groups, `automation_authors` and `question_words` must be lists of strings (statuses non-empty) or the save is refused |
69
+ | `…connectors.linear.workspace`, `team`, `project` | the Linear workspace slug (the part after linear.app/), and optionally one team key or one project name; the personal API key goes to the secret store under the workspace slug |
70
+ | `…connectors.demo.scenario` | `northwind` (a web team with Jira-like tickets and PRs), `atlas` (a mobile team with other status names) or `garden` (a side project, PRs only); invented data, no network |
71
+ | `workspaces[].theme.seed` | optional hex colour, `#rrggbb`; the window derives its light and dark Material 3 scheme from it (`lib/palette.mjs`); without it the stylesheet's blue stays |
72
+ | `workspaces[].theme.accent` | optional second hex that sets the tertiary hue; defaults to the seed's hue turned by 80 degrees |
73
+
74
+ A flat config with `github`, `jira` and `rules` at the top level still loads as one workspace named `default`.
75
+
76
+ Secrets never go into the config. GitHub uses `gh auth token`, or `GH_TOKEN` from the environment when set. The Jira API token lives in the platform's secret store under the service and the account email: the macOS Keychain, libsecret on Linux (`secret-tool`), or the Windows Credential Manager (through `lib/platform/credential.ps1`). Paste it into the settings sheet or send it through the API, and the server stores it. When the store is missing or refuses (no `secret-tool`, a launch agent refused Keychain access), the server keeps it in `<data>/secrets/<service>@<account>` with mode 600. On macOS `security` receives the value as a command-line argument, which other local processes can see for a moment; libsecret and the Windows script read it from stdin.
77
+
78
+ State lives next to the config, per workspace: `workspaces/<id>/state.json` (last poll), `marks.json` (read, handled, notified), `todos.json`, `cache.json` (fetched PR details, so a restart does not refetch them; versioned, never holds a token).
79
+
80
+ ## Platforms
81
+
82
+ | platform | autostart | secrets | notifications | status |
83
+ |---|---|---|---|---|
84
+ | macOS | launchd agent, `install.sh` | Keychain | `osascript` | in daily use |
85
+ | Linux | systemd user unit, `install.sh` | libsecret, else file | `notify-send` | scripts and argv tested with stubs; not run on a real machine |
86
+ | Windows | Task Scheduler, one `schtasks` line that `install.sh` prints | Credential Manager, else file | PowerShell balloon | untested on Windows |
87
+
88
+ On Windows, run `schtasks /Create /SC ONLOGON /TN whose-turn /TR "\"<path to node.exe>\" \"<this folder>\server.mjs\""` once; `install.sh` prints the same line and installs nothing. The notification is a `NotifyIcon` balloon shown by `powershell`, which Windows 10 and 11 display as a toast; title and body reach it through environment variables, not through the script text.
89
+
90
+ ## Files
91
+
92
+ | file | role |
93
+ |---|---|
94
+ | `server.mjs` | entry: config, the poll loop with rate-limit backoff, the API |
95
+ | `lib/config.mjs` | data directory, defaults, the workspace list |
96
+ | `lib/workspace.mjs` | one workspace: polls its connectors, merges results, derives events, evaluates rules, notifies |
97
+ | `lib/rules.mjs` | the "whose turn" rules and the feed |
98
+ | `lib/palette.mjs` | a workspace's light and dark scheme from its theme seed |
99
+ | `lib/api.mjs` | the local HTTP API, SSE stream and static files |
100
+ | `lib/window.mjs` | the app window's remembered size and the launcher behind "Open as app" |
101
+ | `lib/http-client.mjs`, `lib/secrets.mjs` (with `lib/platform/credential.ps1`), `lib/notify.mjs`, `lib/store.mjs`, `lib/errors.mjs`, `lib/text.mjs` | ETag fetch, secret lookup, desktop notification, files on disk, the error envelope, text helpers |
102
+ | `connectors/` | one module per source type; `demo.mjs` is a source of invented data (scenarios `northwind`, `atlas`, `garden`) that shows every state without any account |
103
+ | `web/` | the window: a Vue 3 app in TypeScript built with Vite (`web/src/App.vue`, primitives in `web/src/ui/`, one folder per island in `web/src/features/` with its components, composables and tests, shared composables in `web/src/composables/` (ADR 0006), each component's styles in a scoped BEM block (layout and shared primitives in `web/src/styles/base.css`, ADR 0005), pure logic in `web/src/lib/`, the dot-drawn animal in `web/src/backdrop.ts`, Tippy.js 6.3.7 and Popper 2.11.8 pinned in `web/public/vendor/`); `npm run build` writes `dist/`, which the server serves and git ignores (ADR 0001) |
104
+ | `install.sh` | bash installer that picks by `uname`: on macOS the launch agent `local.whose-turn` (log in `~/Library/Logs/whose-turn.log`); on Linux the user unit `~/.config/systemd/user/whose-turn.service` (log in `journalctl --user -u whose-turn`); both start at login and restart on a crash; on other systems it prints the Windows `schtasks` line. The node path is read with `command -v node` at install time |
105
+ | `open.sh` | opens the window as its own app window (Chrome on macOS; Chrome, Chromium or Edge on Linux) at the size it last had, or in a browser tab with `--browser` or when none of those is installed; bash, not zsh, so it runs on Linux too; Windows: open the URL yourself |
106
+ | `test/` | `node:test` suite: rules, config, ADF conversion |
107
+
108
+ ## Sources and polling
109
+
110
+ **GitHub**, token from `gh auth token`, or from `GH_TOKEN` when that variable is set (CI, a machine without `gh`):
111
+
112
+ - `search/issues` finds your open PRs and PRs updated in the last `history_days`. Full details are fetched only for PRs whose `updated_at` changed: PR, reviews, issue comments, inline comments, check runs.
113
+ - Open PRs get their check runs re-polled every cycle, because CI finishing does not bump `updated_at`.
114
+ - Workflow runs: one `GET /repos/{owner}/{repo}/actions/runs?branch=<default branch>` per poll (the default branch itself is asked once a day) finds runs that fail on the default branch after a merge, on a schedule or in a `workflow_run` chain; each failure and the success that ends it reach the feed as `check failed` and `check passed`, runs of pull requests are left to the PR path, and `workflow_runs: false` on the connector turns this off.
115
+ - `/notifications?participating=true` gives review requests and @-mentions on PRs and issues that are not yours, filtered to the org.
116
+ - Every GET carries an ETag; a 304 does not count against the 5000/h limit. Steady state is about 1 to 10 requests a minute per workspace.
117
+ - The token is sent only to `https://api.github.com/`.
118
+
119
+ **Linear**, GraphQL with a personal API key: two queries per poll, your open issues and your issues completed in the last 30 days, optionally one team or one project, with up to 50 comments and the state history of each; the moment an issue entered its current state comes from that history. Mentions in comments become `@Name`.
120
+
121
+ **Jira Cloud REST**, email plus API token, Basic auth:
122
+
123
+ - `POST /rest/api/3/search/jql` with the workspace's JQL. Fields: summary, status, assignee, updated, comment. All comments are kept. Comment bodies arrive as ADF and are turned into Markdown-ish text: mentions become `@Name`, media becomes `[image]`.
124
+ - `GET /rest/api/3/issue/{key}/changelog` for each open ticket tells when and by whom it entered its current status. Cached until the ticket's `updated` changes.
125
+ - Status and assignee changes between polls become feed events.
126
+
127
+ On a 403 or 429 rate limit the poll delay doubles up to 15 minutes, then returns to `poll_seconds`.
128
+
129
+ ## Rules: whose turn
130
+
131
+ Each ticket is joined with its PR by the ticket key in the PR title. The server computes a set of facts per ticket (`pr.checks`, `ticket.status`, `ticket.assignee`, `unanswered_count` and the rest, listed by `GET /api/config` under `facts`) and walks the workspace's rules table: the first row whose conditions hold decides whose turn it is and the next step. The full contract is in [docs/mechanisms/rules-table.md](docs/mechanisms/rules-table.md).
132
+
133
+ The default table, in `lib/default-table.mjs`, takes its status words and next-step texts from the workspace's `rules` (defaults in `lib/config.mjs`). In order:
134
+
135
+ 1. Open PR has failing checks: your move, "Look at the failing checks".
136
+ 2. Changes requested: "Address the requested changes".
137
+ 3. A human commented on your open PR after your last comment there, and it is not an approval: "Read and answer the new comment".
138
+ 4. Status is one of `statuses.passed`: `passed_next`.
139
+ 5. Ticket done but PR open: "merge it".
140
+ 6. PR merged, ticket not done and on you: `merged_next`.
141
+ 7. Status is one of `statuses.code_review` and the PR is approved: `approved_release_next` for a branch starting with `release_branch_prefix`, otherwise `approved_next`.
142
+ 8. `statuses.code_review`, not approved: the reviewer's turn. `statuses.qa`: QA's turn. `statuses.on_hold`: their turn, with the reason.
143
+ 9. On you, in an in-progress status and nothing above: "Continue the work".
144
+ 10. On you and nothing above: "Pick it up".
145
+ 11. `statuses.backlog` without an open PR: hidden, whoever it is on; with `backlog_rows: "list"` it is shown under Waiting on others instead, which turns that panel into a plain task list for a project you run alone. This row is checked first.
146
+ 12. A workflow run on the default branch whose latest completed run failed, on a commit you pushed or a run you started: your move, "Look at the failed run of <workflow> on <branch>", rank 0, until a later run of the same workflow passes; someone else's failed run stays in the feed only (facts `run.failed`, `run.workflow`, `run.branch`, `run.mine`). These rows are checked before the backlog row.
147
+
148
+ A workspace sets its own rows in `rules.table`. The list replaces the default table; the text `"default"` in it stands for the default rows at that spot. A condition is a value, a list of values, a number comparison (`gt`, `gte`, `lt`, `lte`) or a `prefix`; `whose` is `you`, `reviewer`, `qa`, `them` or `hidden`; `next` and `why` may quote facts as `{fact}`:
149
+
150
+ ```json
151
+ "rules": {
152
+ "table": [
153
+ { "when": { "unanswered_count": { "gte": 2 } }, "then": { "whose": "you", "next": "Answer {comment.author} and the others on {ticket.key}" } },
154
+ { "when": { "pr.open": true, "pr.base": { "prefix": "release/" }, "pr.checks": ["running", "none"] }, "then": { "whose": "them", "next": "Wait for the release checks on PR #{pr.number}" } },
155
+ "default"
156
+ ]
157
+ }
158
+ ```
159
+
160
+ Claude edits the table through `POST /api/config`, which refuses a table with an unknown fact or operator and names the row. The settings sheet lists the rows in force, read-only.
161
+
162
+ Every row carries a numeric `rank` from its table row's `then.rank`, lowest first: 0 failing checks, 1 requested changes, 2 work in progress, 3 everything else. Your move sorts by `rank`, then oldest first; the window never reads the next-step text to order rows. Under the table, a folded Recently done list shows the last 15 tickets that are done, most recently done first.
163
+
164
+ Extra rows, one per item:
165
+
166
+ - **Ticket comments:** a comment by a human that @-mentions you in the last `history_days` and is not answered. It is a question if it has `?` outside links or one of `question_words`. Otherwise it is "read". Your later comment answers only the people it @-mentions. Authors matching `automation_authors` are ignored.
167
+ - **GitHub notifications:** "Review PR #N" while you are still a requested reviewer. "Answer X on #N" until you comment there after the mention.
168
+
169
+ The On Hold reason is the comment by whoever moved the ticket to On Hold, closest to that moment. If there is none, the last comment before it. As a last resort, the newest comment saying "on hold". Shown on one line, with the full comment in a tooltip and a link.
170
+
171
+ A handled row leaves the Open list and shows under Handled, marked, until something newer happens on it; after seven days the mark expires and the row comes back if it is still open. The server decides this once: it sets `handled` on every row and counts the open moves from the same rule for the window's tiles and badge, `/api/summary` and the notifications. Desktop notifications fire for new "your move" rows. The first run only records the existing rows.
172
+
173
+ The monitor also alerts on its own polling: stale data, or a source that keeps failing. "Data age" is the time since the last poll in which at least one source answered, so a poll where every source failed does not refresh it; a workspace with no sources is never stale. A timer checks it every 30 seconds, independent of the poll loop, so a hung poll is noticed too. A tick that arrives after a gap, because the machine was asleep, only asks for a poll and leaves the verdict to the next tick, so waking up does not raise a false alert. A source counts as failed in a poll when it returned any error, including a partial one; the streak is kept in `state.json` and resets on the first poll without errors. Each new alert (`stale`, or `failing:<source>`) sends one desktop notification and is remembered in `marks.json`, so a restart does not repeat it; a growing streak bumps the state version so an open window re-reads the count; when the last alert clears, one "recovered" notification follows. The state is `health` in `/api/summary` and `/api/state`, in the SessionStart summary as "Monitor health", and in the window banner and status line.
174
+
175
+ ## API, `127.0.0.1:<port>`
176
+
177
+ Requests must carry the local `Host`; a POST must be `application/json`. An error is a `google.rpc.Status` envelope: `{"error":{"code","status","message","details":[{"@type":"ErrorInfo","reason"}]}}`; branch on `reason`. The reasons: `NOT_FOUND` (unknown path, 404), `METHOD_NOT_ALLOWED` (405 with an `Allow: GET, POST` header, a method other than GET or POST), `NO_SUCH_WORKSPACE`, `NO_SUCH_TODO` (404), `NO_WORKSPACE` (412, a request that needs a workspace while none is configured; `/api/state` still answers 200 with an empty snapshot carrying `problems`, which the first-run card reads), `NOT_CONFIGURED` (412, feedback is not set up), `UPSTREAM_FAILED` (502, Linear failed or answered an error; the HTTP status is in the `ErrorInfo` metadata `upstream_status`), `RATE_LIMITED` (429), `UNKNOWN_ACTION`, `INVALID_BODY`, `INVALID_ARGUMENT` (an empty to-do or remark names the field `text` in a `BadRequest` detail), `INVALID_CONFIG` (400), `UPDATE_IN_PROGRESS` (409, an update is already installing), `UPDATE_UNAVAILABLE` (412, nothing to update: a checkout, or already the latest version), `FORBIDDEN_ORIGIN` (403) and `INTERNAL` (500, a fixed message; the raw exception text appears only in a `DebugInfo` detail when the server runs with `WHOSE_TURN_DEBUG=1`, and always in the server log).
178
+
179
+ - `GET /api/workspaces`: `[{id, name, key_pattern, connectors:[{id, type, label}], open_moves, theme}]`; `theme` is `{light, dark}`, each a map of CSS variable to value, or `null` without a seed. `/api/summary` leaves `theme` out.
180
+ - `GET /api/state?ws=<id>`: one workspace, default the first: `turn` (the rows, each with `group`, `rank`, `next`, `why`, `url`, `ticket_url`, `source`, `status_group` (`backlog`, `passed`, `code_review`, `qa`, `on_hold` or empty) and `handled`), `counts` (`mine`, `others`, `failing`, `done`: the four header tiles), `feed`, `marks`, `todos` (each with `ticket_url`), `errors`, `health`, `polled_at`, `me` (your name per source), `workspace`, plus `workspaces` and `problems`. With `&full=1` the merged raw collections come too: `tickets`, `comments`, `prs`, `activity`, `asks`, `events`.
181
+ - `GET /api/summary`: across all workspaces: `moves` (each with `ws`), `new_events`, `todos`, `polled_at`, `errors`, `health` (`{ok, alerts}`, alerts as text prefixed with the workspace name when there are several), and `workspaces` with the same per workspace, where `health` is `{ok, stale, fresh_at, failing:[{source, polls}], alerts:[{id, text}]}`.
182
+ - `GET /api/stream`: SSE. One `data:` event per poll per workspace, `{ws, version, polled_at, open_moves}`. The window reloads state when its workspace's `version` changes. The update status arrives as `{update}`, once on connect and on every change.
183
+ - `/api/summary` and `/api/state` carry `update: {current, latest, available, state, reason?}`. The server checks `https://registry.npmjs.org/-/package/whose-turn/dist-tags` every ten minutes (ETag-cached) and follows the `latest` dist-tag, which every publish moves (prereleases included during the alpha); `available` is true when that tag names another version and the server runs from the plugin's install, `<data>/app/node_modules/whose-turn`. `state` is `idle`, `installing`, `restarting` or `failed` (with npm's last lines in `reason`). A new version raises one desktop notification. `POST /api/update` runs `npm install --prefix <data>/app --no-audit --no-fund --no-save whose-turn@<version>` and restarts: under the launch agent it exits and the agent starts the new copy; without one it frees the port, starts the new `server.mjs` detached (output to `<data>/server.log`) and exits. A failed install leaves the running copy as it was. A second `POST` while one runs is refused with `UPDATE_IN_PROGRESS`; one with nothing to update with `UPDATE_UNAVAILABLE`. This is the expected, announced way an install stays current, not a silent update.
184
+ - A read without `ws` answers for the first workspace; a write (`/api/marks`, `/api/todos`, an inbox hand-off) without `ws` is refused with `INVALID_ARGUMENT`, because it would land silently in the wrong workspace (RMA-115).
185
+ - `POST /api/marks` takes `{ws, seen:[ids], base:ms, handled:{row_id: iso|null}, notified:[ids]}`; `seen` must be strings, `base` a number not in the future, `handled` values ISO dates or null, else `INVALID_ARGUMENT` names the field. It answers `{marks, turn, counts}`, recomputed, so the window updates without a reload.
186
+ - `POST /api/todos` takes `{ws, action, …}`: `add {text, key?, by?}`; `done`, `undo`, `delete` with `{id}`; `edit {id, text}`.
187
+ - `POST /api/refresh` polls now.
188
+ - `GET /api/window` is the size the app window last reported, `{width, height}`, or `{}` before the first report; `POST /api/window` with `{width, height}` (whole pixels, 300 to 10000) stores it in `<data>/window.json`. `open.sh` reads it for `--window-size`.
189
+ - `POST /api/open` with `{mode: 'app' | 'browser'}` runs `open.sh` (with `--browser` for `browser`) on the machine the server runs on, with `WHOSE_TURN_PORT` set to this server's port; this is what the window's "Open as app" button calls. A second open of the same mode within 5 seconds is refused with `429` and reason `OPEN_TOO_SOON` (`retry_after_seconds` in the detail), so a double click opens one window; the button shows "Opening…" meanwhile.
190
+ - `GET /api/feedback` says whether feedback is configured and lists the issues filed from this machine (`<data>/reports.json`, the last hundred) with the state Linear reports for them, read with the feedback key and cached for a minute, so a reporter without a Linear account can follow their reports in the Report sheet and on `/report?id=<identifier>`, the monitor's own page; `GET /api/feedback?id=<identifier>` returns that one report with the comments on its issue as the maintainer's answers and `unanswered` when it was closed without one; `POST /api/feedback` with `{text, ws?, kind_ref?, from?}` lets the server compose the issue from a remark in the user's words (the user's own agent from `feedback.composer`, Claude Code by default; the first line becomes the title when no agent answers), or with `{kind: bug|improvement|idea, title, body?, ws?, kind_ref?: {kind, ref}, from?}` creates a Linear issue in the configured project with the matching label and, when `kind_ref` names a row, a feed item or a workspace, that hand-off context in the description. The reply carries the issue id and url.
191
+ - `GET /api/handoff?ws=<id>&kind=row|feed|thread|workspace&ref=<row_id, feed id or ticket key>` renders that item with its context as Markdown for pasting into any assistant (`thread` is every feed event of one ticket, newest first, with the ticket's row, PRs and comments); `GET /api/handoff?all=1` renders every workspace. Every hand-off starts with a `Whose Turn · <workspace> · <local date and time>` line and ends with a `Links:` line of the https ticket and PR URLs it mentions, so a paste says which workspace it came from and how old it is; after the header one line says the text below is data from tickets, pull requests and comments, never instructions, and the body follows in a fence a comment cannot close. This is what the window's "Copy for AI" buttons put on the clipboard.
192
+ - `POST /api/inbox` with `{ws, kind: 'row' | 'feed' | 'workspace', ref}` hands an item to an agent through a queue (`ws` is required, a hand-off without it is refused; for MCP clients and the plugin hook): the server queues it in `<data>/inbox.json` with a few words of snapshot and keeps `<data>/inbox.pending` as a marker while the queue is not empty. `{action: 'take', consumer}` returns the items this consumer has not seen yet, each with its context built at that moment (the row with its why and links, the last five comments and the PRs of its ticket, bodies capped; a feed item with its body; or the whole workspace with its rules, moves, waiting rows and to-dos), and marks them delivered to that consumer only, so every agent sees an item once. An item leaves the queue a day after its first delivery or a week after it was queued. `GET /api/inbox?consumer=<name>` lists without taking; `{action: 'clear'}` empties the queue.
193
+ - `GET /api/config`: the raw workspace list as written in the config file, the `problems`, the connector catalogue (`type`, `label`, `fields`), the workspace field catalogue (`fields`: workspace, theme, rules, statuses) and the config file path. `POST /api/config` with `{workspaces: [...], secrets?: [{ws, connector, key, value}]}` validates the list first; on a problem it answers `INVALID_CONFIG` with the problems as `BadRequest.field_violations` in `details` and stores nothing. Otherwise it stores the secrets (the account and the store entry come from the connector's own field declaration), writes the file and reloads the workspaces in place; a workspace whose definition did not change keeps its caches, new or changed ones are polled at once. The reply carries the workspace list and `secrets_stored`. A workspace or connector entry that is not an object is refused with `INVALID_CONFIG`, the field violation naming its index (`workspaces[1]`, `workspaces[1].connectors[0]`). An empty list is refused with `INVALID_ARGUMENT` ("keep at least one workspace"), and each write keeps the previous file as `config.json.bak`.
194
+ - `POST /api/secrets` with `{service, account, value}` stores a token on its own (Keychain on macOS, a 600-mode file `<data>/secrets/<service>@<account>` elsewhere); it never touches the config.
195
+ - Both write endpoints are reachable by any local process. A stored token is bound to its service, account and endpoint: a save that would point an existing token at a new endpoint, account or store entry is refused with `INVALID_CONFIG` unless the same request carries the token again, so a process without the token cannot redirect it. See AGENTS.md.
196
+
197
+ ## The window
198
+
199
+ - Material 3 plus glass: tonal surfaces seeded from the workspace's `theme.seed`, or from a seed derived from its id without one, so every workspace has its own colour; translucent blurred islands with 12 px corners, dark and light schemes, a gradient header with stat tiles.
200
+ - Under the glass, an animal drawn in dots (`web/src/backdrop.ts`): one species out of the JSON files in `web/src/species/`, built from shapes whose proportions come from a seeded random, in one of its poses (sitting, walking, sleeping, facing the camera). Each workspace gets its own seed for the session, so switching tabs changes the animal; `window.whoseTurnBackdrop.show({ workspace, seed, species, pose })` picks one and `.species` lists the names (the hook for pinning an animal per workspace, RMA-94). A new animal is a new JSON file, see [`docs/mechanisms/species-dsl.md`](docs/mechanisms/species-dsl.md); `/sheet` draws every species over several seeds without the glass.
201
+ - First run: while the open workspace has no sources, one card replaces the islands. It says what the window does, offers "Add a source" (the settings sheet on this workspace, at its connectors) and "Try a demo workspace" (adds `demo` with the `northwind` scenario through `/api/config` and switches to it), and says Claude can set it up too.
202
+ - Two columns. Left: Your move, To do, Waiting on others. Right: the feed. The page never scrolls; each island scrolls inside, with sticky table headers. A PR cell shows the checks or state pill under the PR number.
203
+ - Every header is one line, truncated with an ellipsis. Anything that hides more text gets a "?" icon at the end. Tooltips are Tippy: instant, also on click, interactive, keep line breaks.
204
+ - Your move: one row per thing to do, with a "?" for why it is there, an age chip (amber after 24 h) and a Handled button. Handled rows leave this list; the Handled view in the head lists them, dimmed, with Undo.
205
+ - Waiting on others: who holds the ticket and for how long, from the changelog (amber after 3 days). For On Hold, the one-line reason with a tooltip. The table keeps fixed column widths and fits its island down to a 1280 px window, with no sideways scroll; the Waiting for column takes the width left over.
206
+ - Every panel folds with the chevron in its head, the header, too, down to one line with the counts; the choice is remembered in the browser.
207
+ - The AI button, a small sparkle on every row, on a feed group head (the whole thread of that ticket), on an expanded feed item, under the title and in the top bar, copies that item, the thread, the workspace or everything as Markdown with its context, ready to paste into any assistant.
208
+ - Feed: grouped by ticket by default, the ticket with the newest event on top; a group head is two short lines, the key with its summary, then the event count or "N new", the first two kinds of event plus "+n" and the time on the right, and a ticket that is your move carries an amber bar on its left edge; a group opens into its events. The source filter and the By ticket / Flat switch share one row, the unread total sits beside the title. A search box filters by any words across key, author, kind and text, and opens the matching groups. "Flat" shows the plain newest-first list. One filter button per connector entry, named by its `label`, else by its `id` when two entries share a type, else by the type; each item's round badge shows the initials of that name (GH for a GitHub entry under its own name). A pull request always reads `repo#number` with its title, with or without a ticket key. Unread items highlighted until clicked plus "Mark all read". Only written things expand. "Open formatted" renders the Markdown safely from DOM nodes, never innerHTML. A "@you" tag marks mentions.
209
+ - To do: an input with an Add button, checkboxes, an Open/Done switch in the head; ticket keys become links. Claude adds items through the API.
210
+ - Status line: live dot, "last check Xs ago" ("Stale · last data X ago" with a red dot when the data is old; "No data: <sources> failing" with a red dot when every source fails, an amber dot when only some do), Refresh now, the update chip when the install has a newer version waiting ("1.0.0-alpha.3 is out · Update", then "Updating…" and "Updated to …" once the stream reconnects to the restarted server; "Update failed · Retry" with npm's message as its tooltip), "Open as app" in a browser tab or "Open in browser" in the app window, and a banner for source, config and health alerts. `open.sh` opens the app window on `/?mode=app`, which is how the page knows it is one; a reloaded app window keeps that, a tab stays a tab. The app window reports its size after a resize and the next one opens at it.
211
+ - A rail down the left edge lists the workspaces, like a browser's side tabs: icons only, names on hover. Each icon is generated from the workspace id and tinted by its palette, with the open "your move" count as a badge. The choice is remembered in the browser and `?ws=<id>` opens one directly. Drag an icon up or down to reorder the workspaces; the order is saved to the config file.
212
+ - "Report" in the top bar opens the backlog sheet: one text field, write the problem or the wish in your own words; an assistant composes the title, the kind and the description, the current workspace's summary is attached; Send or Cmd+Enter files it into the monitor's own Linear project and shows the issue link.
213
+ - The gear at the bottom of the rail opens the settings sheet: the workspace list on the left, one workspace's form on the right (id, name, key pattern, theme colours, connector entries with the fields each connector declares (a boolean field is a checkbox showing its default when unset), rule texts and status words). Save writes the config file through `/api/config`; a token typed into a secret field goes to `/api/secrets` first and never into the file.
214
+
215
+ ## Claude Code
216
+
217
+ The Claude Code plugin is `whose-turn@bladeforge` in the [BladeForge marketplace](https://github.com/aquivalabs/BladeForge); this repository holds the monitor only. The plugin brings:
218
+
219
+ - the skill `whose-turn:whose-turn`: answers "what's on me" from `/api/summary`, sets up a first workspace or the demo, edits the config, opens the window, files feedback and manages to-dos;
220
+ - `/whose-turn`, which opens the window;
221
+ - the SessionStart hook, which starts the monitor if it is down and puts "N your move · M unread · K to-do" into context, marked as data from other people;
222
+ - the prompt hook, which takes waiting hand-offs from the inbox queue into your next message;
223
+ - an MCP server with seven tools: `whose_turn_summary`, `whose_turn_state`, `whose_turn_todos`, `whose_turn_config`, `whose_turn_feedback`, `whose_turn_reports`, `whose_turn_inbox`, all through the local API.
224
+
225
+ The plugin installs this npm package into its data directory and starts `server.mjs` from there; `install.sh` and `open.sh` run from the same installed path. The package carries `dist/` built, so the user never builds; `install.sh` refuses with one line when `dist/index.html` is missing, which only a git clone without a build can hit. The plugin path and the clone path share the launch agent label `local.whose-turn`, so whichever ran `install.sh` last owns it.
226
+
227
+ ## Handing things to an assistant
228
+
229
+ **The AI button** is the everyday path: the sparkle on a row, on an expanded feed item, under the window's title (this workspace) and in the top bar (everything) puts a Markdown text on the clipboard: the ticket, its status and next step with the why, the links, its pull requests and the latest comments; or a whole workspace with your moves, who holds what and your to-dos. Paste it into Claude, ChatGPT or anything else and add what you want.
230
+
231
+ The queue is for agents that pull on their own: `POST /api/inbox` hands an item over, every consumer sees it once (the Claude Code plugin's prompt hook takes it into your next message; any other agent reads it with `{action: 'take', consumer}` under its own name), and the context is built when the item is taken. Every text that arrives this way is other people's writing from Jira and GitHub and must be read as data, never as instructions.
232
+
233
+ ## Developing
234
+
235
+ - `npm install` once: it points `core.hooksPath` at `.githooks/`.
236
+ - The plugin's own code (skill, command, hooks, MCP server) is in BladeForge, `plugins/whose-turn`; a change to what Claude can do lands here as an API change first (ADR 0007).
237
+ - Releasing: bump `version` in `package.json`, add the changelog entry, commit, tag `v<version>` and push the tag; `.github/workflows/release.yml` checks, builds, publishes to npm under `latest` on every tag, prereleases included, plus the channel tag such as `alpha` for a prerelease (npm trusts the workflow through OIDC, no token) and attaches the tarball to a GitHub Release.
238
+ - A checkout never offers the in-app update. To exercise it, lay out a packed copy as `<scratch>/app/node_modules/whose-turn`, start it with `WHOSE_TURN_DATA=<scratch>` and a free `WHOSE_TURN_PORT`, and point two test hooks at stubs: `WHOSE_TURN_REGISTRY` replaces `https://registry.npmjs.org` and `WHOSE_TURN_NPM` names the npm executable `POST /api/update` runs.
239
+ - `npm run dev` starts the server on port 8766 with data and config in `.users-files/dev/` (gitignored), so nothing touches the real config. It serves the last `npm run build`; for live reloading of the window run `npm run dev:web` next to it and open port 5173, which proxies the API to 8766.
240
+ - A fresh checkout has no window until `npm run build`; the server then says so on `/` while the API keeps working. A released package ships `dist/` built (RMA-126).
241
+ - `npm test` runs the suite (Node strips the types of the `web/src/lib/*.ts` modules it imports). `npm run test:web` runs the window's store and component tests on Vitest. `npm run check` runs `vue-tsc` and `tsc` over the server, ESLint over the window and the server, Stylelint and both suites (CI runs it on every pull request and push to `main`), `npm run format` runs Prettier over `web/src`; with `python3 scripts/docs-check.py` (the docs gate) and `/review` (the review gate) it runs on push.
242
+ - Restart after a code change: `kill $(lsof -tnP -iTCP:8765 -sTCP:LISTEN)` kills whatever listens on the port; the launch agent brings the monitor back.
243
+ - The first poll after a restart takes about a minute while the PR cache is rebuilt.
244
+ - macOS gotcha: `security add-generic-password … -w` run through a non-interactive shell silently stores an empty password; the server passes the value as an argument, which is why it works from a launch agent.
245
+ - A sandboxed agent shell cannot read Keychain. The launch agent can.
@@ -0,0 +1,144 @@
1
+ {
2
+ "port": 8765,
3
+ "poll_seconds": 60,
4
+ "history_days": 14,
5
+ "stale_minutes": 15,
6
+ "failing_polls": 3,
7
+ "workspaces": [
8
+ {
9
+ "id": "work",
10
+ "name": "Work",
11
+ "key_pattern": "\\bPROJ-\\d+\\b",
12
+ "connectors": {
13
+ "github": {
14
+ "repo": "your-org/your-repo",
15
+ "org": "your-org",
16
+ "me": "",
17
+ "account": "",
18
+ "bots": [
19
+ "github-actions",
20
+ "dependabot",
21
+ "[bot]"
22
+ ]
23
+ },
24
+ "jira": {
25
+ "base": "https://your-site.atlassian.net",
26
+ "email": "you@example.com",
27
+ "token_service": "whose-turn-jira",
28
+ "jql": "(assignee = currentUser() AND statusCategory != Done) OR (assignee was currentUser() AND updated >= -30d) ORDER BY updated DESC"
29
+ }
30
+ },
31
+ "rules": {
32
+ "release_branch_prefix": "release/",
33
+ "approved_next": "Merge it",
34
+ "approved_release_next": "Hand over to QA",
35
+ "passed_next": "Merge it and close the ticket",
36
+ "merged_next": "Close the ticket",
37
+ "statuses": {
38
+ "passed": [
39
+ "passed"
40
+ ],
41
+ "code_review": [
42
+ "code review",
43
+ "in review"
44
+ ],
45
+ "qa": [
46
+ "ready for qa",
47
+ "in qa",
48
+ "testing"
49
+ ],
50
+ "on_hold": [
51
+ "on hold",
52
+ "blocked"
53
+ ],
54
+ "backlog": [
55
+ "backlog"
56
+ ]
57
+ },
58
+ "automation_authors": [
59
+ "automation"
60
+ ],
61
+ "table": [
62
+ {
63
+ "when": {
64
+ "unanswered_count": {
65
+ "gte": 2
66
+ }
67
+ },
68
+ "then": {
69
+ "whose": "you",
70
+ "next": "Answer {comment.author} and the others on {ticket.key}",
71
+ "why": "{unanswered_count} @-mentions of you wait for a reply."
72
+ }
73
+ },
74
+ {
75
+ "when": {
76
+ "pr.open": true,
77
+ "pr.base": {
78
+ "prefix": "release/"
79
+ },
80
+ "pr.checks": [
81
+ "running",
82
+ "none"
83
+ ]
84
+ },
85
+ "then": {
86
+ "whose": "them",
87
+ "next": "Wait for the release checks on PR #{pr.number}"
88
+ }
89
+ },
90
+ "default"
91
+ ]
92
+ }
93
+ },
94
+ {
95
+ "id": "personal",
96
+ "name": "Personal",
97
+ "theme": {
98
+ "seed": "#3d7a5c",
99
+ "accent": "#c27a2c"
100
+ },
101
+ "connectors": {
102
+ "github": {
103
+ "repo": "you/your-project"
104
+ },
105
+ "linear": {
106
+ "workspace": "your-space",
107
+ "team": "PROJ"
108
+ }
109
+ }
110
+ },
111
+ {
112
+ "id": "demo",
113
+ "name": "Demo",
114
+ "key_pattern": "\\bNW-\\d+\\b",
115
+ "theme": {
116
+ "seed": "#3d7a5c",
117
+ "accent": "#c27a2c"
118
+ },
119
+ "connectors": [
120
+ {
121
+ "type": "demo",
122
+ "scenario": "northwind",
123
+ "label": "Demo tracker"
124
+ }
125
+ ]
126
+ }
127
+ ],
128
+ "feedback": {
129
+ "workspace": "your-space",
130
+ "team": "PROJ",
131
+ "project": "monitor",
132
+ "token_service": "whose-turn-feedback",
133
+ "composer": {
134
+ "command": "claude",
135
+ "args": [
136
+ "-p",
137
+ "--output-format",
138
+ "json",
139
+ "--model",
140
+ "claude-haiku-5-5"
141
+ ]
142
+ }
143
+ }
144
+ }