agent-standup 0.20.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zaida-3dO
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 ADDED
@@ -0,0 +1,322 @@
1
+ <p align="right"><a href="https://github.com/Zaida-3dO/agent-standup/releases/latest"><img src="https://img.shields.io/github/v/release/Zaida-3dO/agent-standup?label=latest%20version&logo=github&logoColor=white" alt="Latest version"></a> <a href="https://github.com/Zaida-3dO/agent-standup/actions/workflows/release.yml"><img src="https://img.shields.io/github/commits-since/Zaida-3dO/agent-standup/latest?label=unreleased%20commits&logo=git&logoColor=white" alt="Unreleased commits"></a> <a href="https://github.com/Zaida-3dO/agent-standup/actions/workflows/release.yml"><img src="https://img.shields.io/github/actions/workflow/status/Zaida-3dO/agent-standup/release.yml?label=latest%20build%20status&logo=githubactions&logoColor=white" alt="Latest build status"></a></p>
2
+
3
+ # Agent Standup
4
+
5
+ A task tracker for AI coding agents: a database, a rules engine every change goes
6
+ through, an MCP so agents can talk to it, a CLI for the parts MCP can't do, and a
7
+ web front end.
8
+
9
+ **The point:** the rules live in the backend and are _enforced_ rather than
10
+ requested. An agent can't skip a step, because the server refuses the change.
11
+
12
+ ## Docs
13
+
14
+ Everything is in [`docs/plans/`](docs/plans/):
15
+
16
+ | Doc | What it is |
17
+ | ----------------------------------------- | ------------------------------------------------ |
18
+ | [PLAN.md](docs/plans/PLAN.md) | The readable plan — how it works, in plain terms |
19
+ | [SCHEMA.md](docs/plans/SCHEMA.md) | Tables, config, MCP tools, HTTP endpoints |
20
+ | [DECISIONS.md](docs/plans/DECISIONS.md) | Every decision with its reasoning |
21
+ | [MILESTONES.md](docs/plans/MILESTONES.md) | The work, broken into pull requests, in order |
22
+
23
+ ## Stack
24
+
25
+ Next.js (front end and API in one bundle) · Prisma · Postgres. The image is built
26
+ in CI, pushed to GHCR, and **pulled** wherever it runs — never built on the deploy
27
+ host, no bind mounts.
28
+
29
+ ## Local development
30
+
31
+ Requires Node 24 and a reachable Postgres. **Docker is one way to get that Postgres, not a
32
+ requirement of the app** — `npm run db:up` is a convenience wrapper around `docker compose up -d db`
33
+ and is the only thing in the repo that shells out to Docker. Nothing under `src/` touches it. The app
34
+ reads one connection string, so a natively installed Postgres (Postgres.app, Homebrew, a distribution
35
+ package, or a userspace `initdb`) works identically: point `DATABASE_URL` at it and skip `db:up`.
36
+
37
+ ```bash
38
+ cp .env.example .env # fill in DATABASE_URL etc.
39
+ npm install
40
+ npm run db:up # OPTIONAL — starts local Postgres in Docker on a non-default port.
41
+ # Skip it if you already have a Postgres; just set DATABASE_URL.
42
+ npx prisma migrate deploy # apply the committed migrations
43
+ npx prisma generate
44
+ npm run dev # http://localhost:3000
45
+ ```
46
+
47
+ `PORT` and `HOSTNAME` control what the server listens on, so `http://localhost:3000` above is the
48
+ default rather than a fixed address — see [Configuration](#configuration).
49
+
50
+ ### Configuration
51
+
52
+ Only what must be known before the process can reach a database is an
53
+ environment variable — `DATABASE_URL`, plus `HOSTNAME` and `PORT` for what
54
+ interface and port the server listens on. `.env.example` lists these and the
55
+ handful of others that are genuinely bootstrap (the local Postgres readiness
56
+ wait, the disposable shadow database the migration drift check uses).
57
+
58
+ Authentication is the other bootstrap value: `STANDUP_TOKENS` holds one
59
+ bearer token per machine, and clients present theirs as `STANDUP_TOKEN`.
60
+ It has no default — with it unset the server refuses every authenticated
61
+ call, which is deliberate (a gate that switched itself off when its
62
+ configuration was missing would be open exactly when a deployment had gone
63
+ wrong). It is an environment variable rather than a setting for the same
64
+ reason as the rest of this list, plus one specific to it: settings are
65
+ served to the front end and printed by the command line, with no redaction
66
+ path, so a credential cannot live there.
67
+
68
+ **The front end needs one of those tokens too, and it must be its own.** A
69
+ browser is not a machine: it holds no configuration, and anything handed to a
70
+ page is readable by whoever opens the developer tools, which would make
71
+ revoking one machine's access meaningless. So the browser is never given a
72
+ credential. It calls `/api/ui/*`, a server-side route that attaches the token
73
+ for the machine named `browser` (override with `STANDUP_BROWSER_MACHINE`) and
74
+ forwards to the same authenticated handlers every other client reaches — so
75
+ the call is authenticated by the ordinary gate rather than exempted from it,
76
+ and the token stays in the server process. Configure one alongside the rest:
77
+
78
+ ```
79
+ STANDUP_TOKENS=browser:TOKEN-A,laptop:TOKEN-B
80
+ ```
81
+
82
+ With no token configured for that machine the front end serves a 503 saying
83
+ so, rather than falling back to calling the API without one.
84
+
85
+ Everything else is a setting: typed, defaulted in code, and readable and
86
+ writable once the app is running, from `/settings` in the front end or
87
+ `standup config set` on the command line. A fresh database boots fully
88
+ working with no settings configured at all — each one has a default. Setting
89
+ an old environment variable that has moved into settings does nothing; a
90
+ startup check catches this — it fails immediately in development, and logs
91
+ loudly (without stopping the process) in production.
92
+
93
+ Useful scripts:
94
+
95
+ | Command | What it does |
96
+ | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
97
+ | `npm run dev` | Next.js dev server |
98
+ | `npm run build` / `npm start` | Production build / run it |
99
+ | `npm run typecheck` | `tsc --noEmit` |
100
+ | `npm run lint` / `npm run format` | ESLint / Prettier (`:check` variants exist for CI) |
101
+ | `npm test` | Vitest, wrapped so a failing run cannot look green — it also fails on the printed summary, so an empty or failing run is caught even through a pipe. **Use this one.** |
102
+ | `npm run test:raw` | Bare `vitest run`. Careful: `... \| tail` reports the pipe's exit status, not vitest's |
103
+ | `npm run db:migrate` | Create/apply a dev migration (`prisma migrate dev`) |
104
+ | `npm run db:deploy` | Apply committed migrations without prompting (`prisma migrate deploy`) |
105
+ | `npm run db:check-drift` | Fail if `schema.prisma` and `prisma/migrations` disagree — needs `SHADOW_DATABASE_URL` pointed at an empty, disposable Postgres |
106
+ | `npm run db:studio` | Prisma Studio |
107
+
108
+ The initial baseline migration (the whole schema in one shot — see
109
+ [`SCHEMA.md`](docs/plans/SCHEMA.md)) lives in `prisma/migrations/`. CI applies it to a
110
+ throwaway Postgres on every run and fails if `schema.prisma` and the migration history
111
+ have drifted apart.
112
+
113
+ ## Deployment
114
+
115
+ The image is built by [`.github/workflows/release.yml`](.github/workflows/release.yml)
116
+ on a version tag or manual dispatch, and pushed to `ghcr.io/<owner>/agent-standup`
117
+ tagged `latest` and the version. The package is public, so pulling it needs no
118
+ registry credential. Wherever it runs, pull and run it with
119
+ [`docker-compose.prod.yml`](docker-compose.prod.yml):
120
+
121
+ ```bash
122
+ GHCR_IMAGE=ghcr.io/<owner>/agent-standup:latest
123
+ DATABASE_URL=postgres://user:password@host:5432/agent_standup
124
+ docker compose --env-file .env.production -f docker-compose.prod.yml pull
125
+ docker compose --env-file .env.production -f docker-compose.prod.yml up -d
126
+ ```
127
+
128
+ `docker-compose.prod.yml` has no `build:` block and no bind mounts by design — it
129
+ only ever pulls. It ships a health check on `GET /api/health` (liveness only —
130
+ deliberately doesn't touch the database, so a slow DB doesn't make the process
131
+ report unhealthy).
132
+
133
+ **Two probes, answering two different questions.** Point each consumer at the
134
+ one it actually needs, because giving either the other's answer is wrong in a
135
+ way that is quiet:
136
+
137
+ | Endpoint | Asks | Reads the database | For |
138
+ | ------------- | --------------------- | ------------------ | --------------------------------------------------------- |
139
+ | `/api/health` | Is this process alive | No | Restart policies — a container that has stopped serving |
140
+ | `/api/ready` | Can I use this yet | Yes | Deployment gates, `depends_on` conditions, load balancers |
141
+
142
+ A process whose Postgres is still starting is **alive and not ready**, which
143
+ is normal and common. Report that as unhealthy and a restart policy kills a
144
+ container that was about to work; report it as ready and a load balancer
145
+ sends traffic to a process that cannot serve it.
146
+
147
+ `/api/ready` answers `200` when it can query the database and no migration is
148
+ half-applied, and `503` otherwise, with a body carrying the migration counts:
149
+ _connected but two migrations behind_ and _migrated and ready_ are different
150
+ answers, and only one is safe to send traffic to. Both probes are
151
+ unauthenticated — the things that ask them run before an installation is
152
+ configured and hold no credential — and both report only booleans and counts.
153
+
154
+ ### Many machines, one server
155
+
156
+ The schema is built for a fleet: `machines` is a first-class entity, work is
157
+ claimed per session, and `assignments` records which machine holds what. A
158
+ single-host compose file is the simplest deployment of that design, not the
159
+ limit of it — the usual shape is **one server and its database, and a client
160
+ on every machine doing the work.**
161
+
162
+ **A remote client talks to the API. It never opens a connection to the
163
+ database.** This is the one deployment rule worth stating outright, because
164
+ the alternative is available and looks equivalent from the outside:
165
+
166
+ - Every rule this product enforces — a merge needing an approving review at
167
+ tip, a completion needing a structured summary, a transition needing an
168
+ approved plan — is **application code in the service layer.** Postgres
169
+ does not know those rules exist and cannot be taught them: _allowed only
170
+ with an approving review at tip_ is conditional on state a grant cannot
171
+ evaluate.
172
+ - So a client on `DATABASE_URL` does not defeat those checks; it never
173
+ reaches the code that performs them. An item can land in `merged` with no
174
+ commit, no review and no summary, and nothing in the system is wrong about
175
+ anything — the rules were simply never consulted.
176
+ - **Database-level permissions are not a substitute.** A restricted role can
177
+ refuse a write to a table. It cannot express the condition above, which is
178
+ the one that matters.
179
+
180
+ Point each machine at the server and give it its own token:
181
+
182
+ ```bash
183
+ STANDUP_URL=https://standup.example.internal
184
+ STANDUP_TOKEN=<this machine's token>
185
+ ```
186
+
187
+ Both the command line and the MCP client use the API when `STANDUP_URL` is
188
+ set. `DATABASE_URL` belongs to the server alone; a client that has one is
189
+ configured as though it were the server.
190
+
191
+ Tokens are per machine rather than one shared secret, which buys two things:
192
+ a machine can be revoked without rotating every other machine's
193
+ configuration, and the actor a client declares stops being an unverified
194
+ self-report — the server knows which machine presented the token, so an
195
+ attributed write means something.
196
+
197
+ ### The liveness sweep has to be run by something
198
+
199
+ **A deployment that never runs the sweep leaks claims that can never be handed
200
+ back.** A session takes ownership of an item by claiming it; if that session
201
+ crashes rather than releasing, the claim outlives it and every later claim on
202
+ that item is refused as already-held. The liveness sweep is what notices — it
203
+ ages quiet sessions, releases what died, and escalates what is stuck — and it
204
+ runs only when something invokes it. Measured on an installation running without
205
+ one: the first manual sweep released **174** stale claims that had been sitting
206
+ for three days, every one of them blocking ownership of its item.
207
+
208
+ **Running it is the deployment's job, and this compose file ships nothing to do
209
+ it.** The application deliberately has no internal timer. It runs as a bundle
210
+ that may be one replica or several, so a timer inside it fires once _per
211
+ replica_ — a multiple of the intended rate on a scaled deployment, or not at all
212
+ if the replica holding it is the one that restarted — and neither mistake
213
+ produces any output to notice. Invoke it from outside the process, where there
214
+ is exactly one of whatever you choose.
215
+
216
+ Either surface works, and nothing in the application distinguishes the callers:
217
+
218
+ ```bash
219
+ # Host cron, every five minutes — over HTTP:
220
+ */5 * * * * curl -fsS -X POST -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/sweep >/dev/null
221
+
222
+ # …or over the command line, which reports what it released:
223
+ */5 * * * * standup sweep --json
224
+ ```
225
+
226
+ `POST /api/sweep` authenticates like every other route, so a scheduler calling
227
+ it needs a token in `STANDUP_TOKENS` the same as any machine. The endpoint is
228
+ `POST` rather than `GET` on purpose: it writes, and a `GET` that releases other
229
+ sessions' claims is one a crawler or a browser prefetch will invoke without
230
+ anyone asking it to. It takes no input, so an empty body is fine.
231
+
232
+ **Worth knowing before you automate it.** A timer reclaims on the strength of a
233
+ liveness signal that may not be written — heartbeats are optional, and the
234
+ process check is what usually answers — so a session that claims an item and
235
+ then works for half an hour can look the same as one that crashed. Reclaiming at
236
+ the point of contention, when another session actually wants that item, is a
237
+ safer place to be wrong than a fixed tick. Escalation is the part that genuinely
238
+ needs a push, because nobody is reading by definition.
239
+
240
+ ### Postgres
241
+
242
+ This app needs its own Postgres reachable via `DATABASE_URL`. Prefer a
243
+ **dedicated Postgres instance** over adding a database to one that already
244
+ serves another app — it keeps credentials, backups, and version upgrades
245
+ independent, and the cost of one more small container is low. Only share an
246
+ existing instance if there's a specific reason to (e.g. a hosting limit on
247
+ how many database services are allowed).
248
+
249
+ If Postgres runs as its own container next to this one, order startup with
250
+ `depends_on: condition: service_healthy` — the entrypoint runs
251
+ `prisma migrate deploy` at boot, which opens a real database connection even
252
+ when there are zero pending migrations (expect and ignore
253
+ `No migration found in prisma/migrations` until the baseline migration
254
+ ships — see `MILESTONES.md`). Give Postgres's own health check a generous
255
+ `start_period`: a cold first boot (`initdb` plus the official image's own
256
+ internal restart) can take noticeably longer than a short window allows,
257
+ which can make `depends_on` give up right before Postgres would have come up
258
+ healthy on its own.
259
+
260
+ ### Deploying alongside other services
261
+
262
+ Some hosts run several unrelated apps under one shared Docker Compose
263
+ project rather than one compose file per app — a shared `.env` holding
264
+ per-service location/config variables, one compose file defining every
265
+ service, sub-folders per service holding data only. If that's the target,
266
+ fold this app's service block (and a Postgres block per the section above)
267
+ into the shared file instead of running `docker-compose.prod.yml` standalone
268
+ — the service definitions are the same either way, only which file they live
269
+ in changes. In that setup:
270
+
271
+ - **Back up the shared compose file first**, before editing it.
272
+ - **Never run a bare `up`, `down`, or `restart` with no service names** in a
273
+ directory that already has other services running from that file — always
274
+ name the services you mean to affect explicitly, e.g.
275
+ `docker compose up -d agent-standup agent-standup-db`. An unscoped command
276
+ recreates (or stops) everything the file defines, not just what you're
277
+ deploying.
278
+ - **Pick a host port that isn't already in use** — check what the shared
279
+ compose file and the host's listening ports already claim before adding
280
+ `APP_PORT`.
281
+ - Keep real secrets (the generated `DATABASE_URL` password, etc.) only in
282
+ that host's own `.env` — never copied into this repo.
283
+
284
+ ## What is built
285
+
286
+ The service layer holds **69 registered operations** (`src/lib/service/registry.ts`). Every rule
287
+ lives there, so an adapter is a thin shell over one service call and adds no rule of its own —
288
+ which is what makes a refusal the same refusal whichever way in you came.
289
+
290
+ **The four adapters do not all expose the same set, and the difference is worth knowing before you
291
+ pick one.** MCP derives its tools from the registry and so carries 66 of the 69, declining three by
292
+ written waiver (`src/lib/adapters/waivers.ts`). The command line routes 46 and the web API 49,
293
+ because each maps operations through its own table and those tables lag the registry — `service_info`
294
+ and `describe_tool`, for instance, are reachable from MCP and the command line but have no HTTP
295
+ route. Ask a running instance rather than taking any of this on trust:
296
+
297
+ ```bash
298
+ standup service info --json # the operation catalogue, and the limits a caller must respect
299
+ standup --help # every noun and verb, built from the command table itself
300
+ ```
301
+
302
+ | Surface | What it is |
303
+ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
304
+ | **Web API** | 45 operations over JSON routes under `src/app/api` — items, claims, transitions, artifacts, events, settings, admin entities. Three further routes are not part of that surface: a liveness check, the MCP transport below, and one that serves the hook script itself |
305
+ | **MCP** | The agent-facing surface, over streamable HTTP (`/api/mcp`) and over stdio. Tools are derived from the operation registry, so there is no second list to forget an operation in |
306
+ | **Command line** | `standup <noun> <verb>`, 46 operations, on either of two bindings — over HTTP against a server, or `--direct` against `DATABASE_URL` in-process |
307
+ | **Front end** | The board, an item detail view, a since-your-last-visit ledger, a settings editor and an admin section |
308
+
309
+ An item minted through the product walks the full state machine on service calls alone —
310
+ `plan_review → executing → in_review → merged` — because the artifacts each transition guard reads
311
+ are writable through the service. The rules are enforced in the service layer, so a refusal is the
312
+ same refusal on every surface: a missing approving review at tip, a claim already held, or a
313
+ completion with no structured summary is rejected identically whether it arrived from an agent, a
314
+ terminal or the API.
315
+
316
+ The schema ships as one baseline migration, and a one-time bulk import (`docs/plans/BACKFILL.md`)
317
+ loads a backlog held in an external file-based store.
318
+
319
+ **Where the edges are.** [`MILESTONES.md`](docs/plans/MILESTONES.md) is the honest inventory: it
320
+ carries every row with its status, and the queue is worked in dependency order rather than
321
+ front-to-back. One limit is worth knowing before deploying: the liveness sweep only runs when
322
+ something invokes it — see above, because claims leak while nothing does.
@@ -0,0 +1,296 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ HOOK_EXIT,
4
+ captureContextFor,
5
+ createHttpFlush,
6
+ fileAppendCounter,
7
+ fileSpool,
8
+ flushSpool,
9
+ parseHookPayload,
10
+ readNudgeContext,
11
+ readSessionStatus,
12
+ readStopContext,
13
+ runHook,
14
+ spoolEvent,
15
+ spoolPath
16
+ } from "../chunk-N7G677FC.js";
17
+ import {
18
+ SHIPPED_HOOK_PROTOCOL_VERSION,
19
+ buildCaptures
20
+ } from "../chunk-VBXNDGOD.js";
21
+
22
+ // src/lib/hook/ask-http.ts
23
+ var DEFAULT_TIMEOUT_MS = 5e3;
24
+ function property(value, key) {
25
+ return typeof value === "object" && value !== null ? value[key] : void 0;
26
+ }
27
+ function readFinding(value) {
28
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
29
+ const record = value;
30
+ const id = record.id;
31
+ const level = record.level;
32
+ const phase = record.phase;
33
+ const messages = record.messages;
34
+ const plain = typeof messages === "object" && messages !== null ? messages.plain : void 0;
35
+ const prominent = typeof messages === "object" && messages !== null ? messages.prominent : void 0;
36
+ if (typeof id !== "string" || id.length === 0) return void 0;
37
+ if (typeof level !== "string" || level.length === 0) return void 0;
38
+ if (typeof phase !== "string" || phase.length === 0) return void 0;
39
+ if (typeof plain !== "string") return void 0;
40
+ return {
41
+ id,
42
+ // `source` and `audience` are not read by anything on this side of the
43
+ // wire — `buildCaptures` never asks for either — so they are carried
44
+ // through when present rather than invented when absent. A cast rather
45
+ // than a validated enum: an unrecognised value here changes nothing a
46
+ // capture writes, so rejecting the whole finding over it would lose
47
+ // real data to protect a property nobody reads.
48
+ source: typeof record.source === "string" ? record.source : "builtin",
49
+ phase,
50
+ audience: typeof record.audience === "string" ? record.audience : "agent",
51
+ level,
52
+ timing: typeof record.timing === "string" ? record.timing : "immediate",
53
+ messages: { plain, prominent: typeof prominent === "string" ? prominent : plain }
54
+ };
55
+ }
56
+ function readFindings(value) {
57
+ if (!Array.isArray(value)) return void 0;
58
+ const findings = [];
59
+ for (const entry of value) {
60
+ const finding = readFinding(entry);
61
+ if (finding !== void 0) findings.push(finding);
62
+ }
63
+ return findings;
64
+ }
65
+ function createHttpAsk({
66
+ baseUrl,
67
+ fetch,
68
+ timeoutMs = DEFAULT_TIMEOUT_MS,
69
+ timeoutSignal = defaultTimeoutSignal
70
+ }) {
71
+ const url = `${baseUrl.replace(/\/+$/, "")}/api/hook`;
72
+ return async function askServer(event) {
73
+ const signal = timeoutSignal(timeoutMs);
74
+ let response;
75
+ try {
76
+ response = await fetch(url, {
77
+ method: "POST",
78
+ headers: { "content-type": "application/json" },
79
+ body: JSON.stringify({
80
+ eventType: event.eventType,
81
+ sessionId: event.sessionId,
82
+ ...event.tool === void 0 ? {} : { tool: event.tool },
83
+ ...event.command === void 0 ? {} : { command: event.command },
84
+ ...event.toolResult === void 0 ? {} : { toolResult: event.toolResult }
85
+ }),
86
+ ...signal === void 0 ? {} : { signal }
87
+ });
88
+ } catch {
89
+ return void 0;
90
+ }
91
+ if (!response.ok) return void 0;
92
+ let body;
93
+ try {
94
+ body = await response.json();
95
+ } catch {
96
+ return void 0;
97
+ }
98
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return void 0;
99
+ const rawDecision = property(body, "decision");
100
+ const reason = property(body, "reason");
101
+ const enforcement = readSessionStatus(property(body, "enforcement"));
102
+ const stop = readStopContext(property(body, "stop"));
103
+ const nudge = readNudgeContext(property(body, "nudge"));
104
+ const findings = readFindings(property(body, "findings"));
105
+ return {
106
+ // The one string that refuses. Everything else — including a value
107
+ // this build does not recognise — is an allow.
108
+ decision: rawDecision === "block" ? "block" : "allow",
109
+ ...typeof reason === "string" && reason.length > 0 ? { reason } : {},
110
+ ...enforcement === void 0 ? {} : { enforcement },
111
+ ...stop === void 0 ? {} : { stop },
112
+ ...nudge === void 0 ? {} : { nudge },
113
+ ...findings === void 0 ? {} : { findings }
114
+ };
115
+ };
116
+ }
117
+ function defaultTimeoutSignal(ms) {
118
+ return typeof AbortSignal?.timeout === "function" ? AbortSignal.timeout(ms) : void 0;
119
+ }
120
+
121
+ // src/lib/hook/record-intervention-http.ts
122
+ var DEFAULT_RECORD_TIMEOUT_MS = 1500;
123
+ function toWireBatch(batch) {
124
+ return {
125
+ sessionId: batch.sessionId,
126
+ ...batch.rootSessionId === void 0 ? {} : { rootSessionId: batch.rootSessionId },
127
+ captures: batch.captures.map((capture) => ({
128
+ entryId: capture.entryId,
129
+ outcome: capture.outcome,
130
+ level: capture.level,
131
+ phase: capture.phase,
132
+ ...capture.itemId === void 0 ? {} : { itemId: capture.itemId },
133
+ ...capture.tool === void 0 ? {} : { tool: capture.tool },
134
+ ...capture.command === void 0 ? {} : { command: capture.command },
135
+ ...capture.message === void 0 ? {} : { message: capture.message },
136
+ ...capture.overrideReason === void 0 ? {} : { overrideReason: capture.overrideReason }
137
+ }))
138
+ };
139
+ }
140
+ function readRecordedFirings(body) {
141
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return [];
142
+ const recorded = body.recorded;
143
+ if (!Array.isArray(recorded)) return [];
144
+ return recorded.flatMap((entry) => {
145
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return [];
146
+ const row = entry;
147
+ const id = row.id;
148
+ const entryId = row.entryId;
149
+ if (typeof id !== "string" || id.trim() === "") return [];
150
+ if (typeof entryId !== "string" || entryId.trim() === "") return [];
151
+ return [{ id: id.trim(), entryId: entryId.trim() }];
152
+ });
153
+ }
154
+ function createRecordInterventionHttp(options) {
155
+ const timeoutMs = options.timeoutMs ?? DEFAULT_RECORD_TIMEOUT_MS;
156
+ const makeSignal = options.timeoutSignal ?? ((ms) => typeof AbortSignal?.timeout === "function" ? AbortSignal.timeout(ms) : void 0);
157
+ return async (batch) => {
158
+ if (batch.captures.length === 0) return { ok: true, recorded: [] };
159
+ const signal = makeSignal(timeoutMs);
160
+ try {
161
+ const response = await options.fetch(
162
+ `${options.baseUrl.replace(/\/+$/, "")}/api/interventions`,
163
+ {
164
+ method: "POST",
165
+ headers: {
166
+ "content-type": "application/json",
167
+ ...options.token === void 0 || options.token === "" ? {} : { authorization: `Bearer ${options.token}` }
168
+ },
169
+ body: JSON.stringify(toWireBatch(batch)),
170
+ ...signal === void 0 ? {} : { signal }
171
+ }
172
+ );
173
+ if (!response.ok) return { ok: false, recorded: [] };
174
+ try {
175
+ return { ok: true, recorded: readRecordedFirings(await response.json()) };
176
+ } catch {
177
+ return { ok: true, recorded: [] };
178
+ }
179
+ } catch {
180
+ return { ok: false, recorded: [] };
181
+ }
182
+ };
183
+ }
184
+
185
+ // src/lib/hook/protocol.ts
186
+ var HOOK_PROTOCOL_VERSION = SHIPPED_HOOK_PROTOCOL_VERSION;
187
+
188
+ // src/lib/hook/build-stamp.ts
189
+ var UNSTAMPED = "unstamped";
190
+ var HOOK_BUILD_COMMIT = typeof __STANDUP_HOOK_BUILD_COMMIT__ === "string" ? __STANDUP_HOOK_BUILD_COMMIT__ : UNSTAMPED;
191
+ function isStamped(commit) {
192
+ return commit !== UNSTAMPED && commit.trim() !== "";
193
+ }
194
+ function formatBuildStamp(commit) {
195
+ return isStamped(commit) ? commit : UNSTAMPED;
196
+ }
197
+
198
+ // src/bin/standup-hook.ts
199
+ async function readStdin() {
200
+ const chunks = [];
201
+ for await (const chunk of process.stdin) {
202
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
203
+ }
204
+ return Buffer.concat(chunks).toString("utf-8");
205
+ }
206
+ async function main() {
207
+ const env = process.env;
208
+ if (process.argv.includes("--protocol-version")) {
209
+ process.stdout.write(`${HOOK_PROTOCOL_VERSION}
210
+ `);
211
+ return HOOK_EXIT.ALLOW;
212
+ }
213
+ if (process.argv.includes("--build-commit")) {
214
+ process.stdout.write(`${formatBuildStamp(HOOK_BUILD_COMMIT)}
215
+ `);
216
+ return HOOK_EXIT.ALLOW;
217
+ }
218
+ const baseUrl = env.STANDUP_URL?.trim();
219
+ const askServer = baseUrl === void 0 || baseUrl === "" ? async () => void 0 : createHttpAsk({ baseUrl, fetch: globalThis.fetch });
220
+ const stdin = await readStdin();
221
+ const now = Date.now();
222
+ const rendered = await runHook({
223
+ stdin,
224
+ askServer,
225
+ now,
226
+ // MILESTONES.md #128's capture loop. Fires only when `runHook` has
227
+ // findings to report — see its own header for why this is a callback
228
+ // rather than a return field. With no server configured there is
229
+ // nowhere to send a capture either, so this is `undefined` in exactly
230
+ // the case `askServer` above already degrades to "no answer" for.
231
+ ...baseUrl === void 0 || baseUrl === "" ? {} : { onFindings: recordFindings(baseUrl, env) }
232
+ });
233
+ if (rendered.stdout !== "") process.stdout.write(rendered.stdout);
234
+ if (rendered.stderr !== "") process.stderr.write(rendered.stderr);
235
+ const spool = fileSpool(spoolPath(env));
236
+ spoolEvent(stdin, spool, now, { appendCounter: fileAppendCounter(spoolPath(env)) });
237
+ if (isStop(stdin)) await drain(spool, baseUrl, env);
238
+ return rendered.exitCode;
239
+ }
240
+ function isStop(stdin) {
241
+ const parsed = parseHookPayload(stdin);
242
+ return parsed.ok && parsed.event.eventType === "Stop";
243
+ }
244
+ async function drain(spool, baseUrl, env) {
245
+ if (baseUrl === void 0 || baseUrl === "") return;
246
+ try {
247
+ const text = spool.read();
248
+ if (text === void 0 || text.length === 0) return;
249
+ const result = await flushSpool({
250
+ spoolText: text,
251
+ send: createHttpFlush({
252
+ baseUrl,
253
+ fetch: globalThis.fetch,
254
+ // The ingest authenticates unconditionally, so a tokenless flush is
255
+ // a permanent `401` and the spool would fill to its ceiling in
256
+ // silence. Read from the environment for the same reason the URL
257
+ // is: this script is configured by the thing that installs it.
258
+ ...env.STANDUP_TOKEN === void 0 || env.STANDUP_TOKEN.trim() === "" ? {} : { token: env.STANDUP_TOKEN.trim() },
259
+ ...flushTimeoutMs(env) === void 0 ? {} : { timeoutMs: flushTimeoutMs(env) }
260
+ })
261
+ });
262
+ if (result.sent === 0 && result.dropped === 0 && result.skipped === 0) return;
263
+ spool.replace(result.remaining);
264
+ } catch {
265
+ }
266
+ }
267
+ function flushTimeoutMs(env) {
268
+ const raw = env.STANDUP_FLUSH_TIMEOUT_MS?.trim();
269
+ if (raw === void 0 || raw === "") return void 0;
270
+ const parsed = Number(raw);
271
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
272
+ }
273
+ function recordFindings(baseUrl, env) {
274
+ const send = createRecordInterventionHttp({
275
+ baseUrl,
276
+ fetch: globalThis.fetch,
277
+ ...env.STANDUP_TOKEN === void 0 || env.STANDUP_TOKEN.trim() === "" ? {} : { token: env.STANDUP_TOKEN.trim() }
278
+ });
279
+ return async (report) => {
280
+ try {
281
+ const captures = buildCaptures(report.findings, captureContextFor(report));
282
+ if (captures.length === 0) return;
283
+ await send({ sessionId: report.event.sessionId, captures });
284
+ } catch {
285
+ }
286
+ };
287
+ }
288
+ try {
289
+ process.exitCode = await main();
290
+ } catch (cause) {
291
+ process.stderr.write(
292
+ `the hook failed unexpectedly (${cause instanceof Error ? cause.name : "unknown error"}) and allows rather than refusing a command it never examined
293
+ `
294
+ );
295
+ process.exitCode = HOOK_EXIT.ALLOW;
296
+ }