specguard-mcp 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +183 -17
  2. package/dist/bin/specguard-mcp.js +5 -0
  3. package/dist/bin/specguard-mcp.js.map +1 -1
  4. package/dist/src/support/run-command.d.ts +42 -0
  5. package/dist/src/support/run-command.js +124 -8
  6. package/dist/src/support/run-command.js.map +1 -1
  7. package/dist/src/support/specguard-api.d.ts +50 -0
  8. package/dist/src/support/specguard-api.js +148 -8
  9. package/dist/src/support/specguard-api.js.map +1 -1
  10. package/dist/src/support/teardown.d.ts +33 -0
  11. package/dist/src/support/teardown.js +56 -0
  12. package/dist/src/support/teardown.js.map +1 -0
  13. package/dist/src/tools/add-repository.d.ts +55 -0
  14. package/dist/src/tools/add-repository.js +114 -0
  15. package/dist/src/tools/add-repository.js.map +1 -0
  16. package/dist/src/tools/args.d.ts +20 -0
  17. package/dist/src/tools/args.js +30 -0
  18. package/dist/src/tools/args.js.map +1 -1
  19. package/dist/src/tools/create-repository-api-key.d.ts +26 -0
  20. package/dist/src/tools/create-repository-api-key.js +83 -0
  21. package/dist/src/tools/create-repository-api-key.js.map +1 -0
  22. package/dist/src/tools/index.d.ts +61 -7
  23. package/dist/src/tools/index.js +71 -7
  24. package/dist/src/tools/index.js.map +1 -1
  25. package/dist/src/tools/list-repositories.d.ts +14 -7
  26. package/dist/src/tools/list-repositories.js +14 -7
  27. package/dist/src/tools/list-repositories.js.map +1 -1
  28. package/dist/src/tools/registrable-repositories.d.ts +51 -0
  29. package/dist/src/tools/registrable-repositories.js +92 -0
  30. package/dist/src/tools/registrable-repositories.js.map +1 -0
  31. package/dist/src/tools/remove-repository.d.ts +33 -0
  32. package/dist/src/tools/remove-repository.js +81 -0
  33. package/dist/src/tools/remove-repository.js.map +1 -0
  34. package/dist/src/tools/repository-overview.js +33 -8
  35. package/dist/src/tools/repository-overview.js.map +1 -1
  36. package/dist/src/tools/revoke-repository-api-key.d.ts +29 -0
  37. package/dist/src/tools/revoke-repository-api-key.js +85 -0
  38. package/dist/src/tools/revoke-repository-api-key.js.map +1 -0
  39. package/package.json +1 -1
package/README.md CHANGED
@@ -11,9 +11,9 @@ its prompt.
11
11
  SpecGuard is built [primarily for AI coding agents](https://github.com/yatfa-ai/specguard); this
12
12
  bridge is how an agent reaches it without scraping a web UI.
13
13
 
14
- > **Status: bootstrap.** Three tools ship today, each wrapping a capability that already exists. The
15
- > toolset **grows gradually** — see [Adding a tool](#adding-a-tool). It is not published to npm yet;
16
- > install from a checkout.
14
+ > **Status: bootstrap.** A small set of tools ships today, each wrapping a capability that already
15
+ > exists — see [The tools](#the-tools) for what is in it. The toolset **grows gradually** — see
16
+ > [Adding a tool](#adding-a-tool). It is not published to npm yet; install from a checkout.
17
17
 
18
18
  ## Install
19
19
 
@@ -32,9 +32,9 @@ refuses to boot and takes the tools that needed no configuration down with it.
32
32
 
33
33
  | Variable | Needed by | Default | What it is |
34
34
  | --- | --- | --- | --- |
35
- | `SPECGUARD_ENDPOINT` | `get_repository_overview`, `list_repositories` | — | your SpecGuard instance's root URL, **including the scheme** — e.g. `https://specguard.example.com`, or `http://localhost:3000`. A value with no scheme is refused by name (`SPECGUARD_ENDPOINT is not a usable URL: "sg.example.com"`) rather than surfacing later as an opaque failure. `SPECGUARD_URL` is accepted as an alias, and is the name every message uses when it is the one you set. A blank value counts as unset, so leaving `SPECGUARD_ENDPOINT` empty in a templated config falls through to `SPECGUARD_URL` instead of suppressing it |
35
+ | `SPECGUARD_ENDPOINT` | `get_repository_overview`, `list_repositories`, `add_repository`, `registrable_repositories` | — | your SpecGuard instance's root URL, **including the scheme** — e.g. `https://specguard.example.com`, or `http://localhost:3000`. A value with no scheme is refused by name (`SPECGUARD_ENDPOINT is not a usable URL: "sg.example.com"`) rather than surfacing later as an opaque failure. `SPECGUARD_URL` is accepted as an alias, and is the name every message uses when it is the one you set. A blank value counts as unset, so leaving `SPECGUARD_ENDPOINT` empty in a templated config falls through to `SPECGUARD_URL` instead of suppressing it |
36
36
  | `SPECGUARD_API_KEY` | `get_repository_overview` | — | an agent/CI API key (`sgk_…`) issued by that deployment |
37
- | `SPECGUARD_USER_API_KEY` | `list_repositories` | — | a **user** API key (`sgu_…`), minted from that deployment's account page. A different credential from the one above, not a second place to put the same value: SpecGuard decides which of them a request may use from the token's prefix, before it reads anything, and answers `401` for the other one. Set whichever your tools need — both, if you use both |
37
+ | `SPECGUARD_USER_API_KEY` | `list_repositories`, `add_repository`, `registrable_repositories`, `remove_repository`, `create_repository_api_key`, `revoke_repository_api_key` | — | a **user** API key (`sgu_…`), minted from that deployment's account page. A different credential from the one above, not a second place to put the same value: SpecGuard decides which of them a request may use from the token's prefix, before it reads anything, and answers `401` for the other one. Set whichever your tools need — both, if you use both |
38
38
  | `SPECGUARD_LINT_COMMAND` | `lint_intent_annotations` | `specguard-lint` | the command that runs the linter. Most Ruby projects need `bundle exec specguard-lint` |
39
39
  | `SPECGUARD_TIMEOUT_MS` | HTTP tools | `30000` | how long a call to SpecGuard may take |
40
40
 
@@ -109,7 +109,7 @@ branch window rather than between the last two runs.
109
109
  | `repeated_description` | open ONE repeated description and list the examples that all share it |
110
110
  | `unstable_test` | open ONE flaky test and list its outcome run by run across the window, newest run first (needs `branch`) |
111
111
  | `commit_sha` | anchor the answer on ONE named run instead of the repository's newest one — every run-grain block moves with it, `history` does not |
112
- | `unannotated_examples` | `true` to list the individual tests SpecGuard cannot see — the examples behind the annotated ratio — and, in the same answer, which areas carry the most of them |
112
+ | `unannotated_examples` | `true` to list the individual tests carrying no `@intent` — the examples behind the annotated ratio, each labelled with what SpecGuard reads of it — and, in the same answer, which areas carry the most of them |
113
113
 
114
114
  `branch` narrows `history` only — `latest_run` always names the repository's newest run, which on a
115
115
  busy repo may be on another branch. That is a property of the endpoint, not of this bridge — and
@@ -210,9 +210,23 @@ description carried by two examples in one run contributes two, so `rows` is not
210
210
  and its length is not the window's `run_count`.
211
211
 
212
212
  `annotated_ratio` is the product's adoption metric and it was the one population on this endpoint
213
- you could not walk down: the dashboard prints *"SpecGuard cannot see the other N tests"* and could
213
+ you could not walk down: the dashboard printed *"SpecGuard cannot see the other N tests"* and could
214
214
  not name one of them either, so an agent told to raise annotation coverage learned how far it had to
215
- go and not a single test to annotate. `unannotated_examples` is that rung. It is the one argument
215
+ go and not a single test to annotate. `unannotated_examples` is that rung.
216
+
217
+ **Unannotated is not the same as unreadable, and the difference is on every response.** A test
218
+ called `Invoice#total sums the line items` has an entity, an action and a behavior in its own
219
+ description, so SpecGuard reads it whether or not anybody annotated it.
220
+ `latest_run.intent_readings` splits the run's examples into `authored` (an `@intent` a human wrote),
221
+ `derived` (read from the description) and `unreadable` (neither), with the `recorded` population
222
+ they were counted from — no flag to pass. **`unreadable` is the only figure on this endpoint that
223
+ means tests SpecGuard can say nothing about.** `total_specs - annotated_specs` is annotation debt,
224
+ which on a suite that has never been annotated is the whole suite and almost all of it readable;
225
+ never render that subtraction as blindness. A derived reading is genuinely weaker than an authored
226
+ one — no preconditions, a behavior written for a test runner's output rather than declared, and a
227
+ layer inferred from the directory — so report it as inferred and never as equivalent. And
228
+ `authored` never replaces `annotated_ratio`: "how much of this suite has a human-written intent" is
229
+ still that figure, off the run's own counters. It is the one argument
216
230
  here that is a **flag rather than a name** — pass `true`, not a value — because it opens a
217
231
  *population* rather than a pick: `total_specs` minus `annotated_specs` is a subtraction, and a
218
232
  subtraction has no line to name. Which population is still yours to choose: sent alone the flag
@@ -220,8 +234,10 @@ opens the whole run, and sent **together with** `spec_file` or `spec_directory`
220
234
  file, that area, or the AND of the two — those two keep opening their own blocks as well, so
221
235
  narrowing this one is additional rather than instead. `latest_run.unannotated_examples` opens with
222
236
  up to 100 of the unannotated examples **of whatever you asked for** (`name`, `file_path`,
223
- `line_number`, `spec_file_path` each — four fields, not the per-example drill-ins' six), plus that
224
- same population's own `recorded_count`, the `limit` the row list was cut at, and
237
+ `line_number`, `spec_file_path`, `reading` and `derived_intent` each — six fields, and not the
238
+ per-example drill-ins' six: no `duration_seconds` and no `outcome`), plus that
239
+ same population's own `recorded_count`, `derived_count` and `unreadable_count`, the `limit` the row
240
+ list was cut at, and
225
241
  `spec_file`/`spec_directory` **echoed back** as the server read them — `null` for each one you did
226
242
  not send. Read the echo before the count: the **worklist's** `recorded_count` — and only that one,
227
243
  because the map below deliberately does not narrow — is the figure you would reconcile against
@@ -235,13 +251,20 @@ That one ask opens **two** blocks, each in its own grain: `latest_run.unannotate
235
251
  *which tests* to go and annotate, and `latest_run.unannotated_directories` for *where the debt is* —
236
252
  the run's annotation debt rolled up by code area, which is what you pick the next `spec_directory`
237
253
  narrowing **from**. Both come from the one flag; there is no second argument to send and no new
238
- value. The map's rows carry `path`, `unannotated_count` and the `recorded_count` that area was
239
- counted against (the operands, never a fraction), plus `directory_count` **every** area the run
254
+ value. Each worklist row carries `reading` `"derived"` or `"unreadable"` and `derived_intent`,
255
+ the `entity`/`action`/`behavior` SpecGuard got from the description or `null`; the **unreadable rows
256
+ come first**, so the 100-row cap cannot hide them. The map's rows carry `path`,
257
+ `unannotated_count`, the `recorded_count` that area was counted against, and the same three-way
258
+ split — `authored_count`, `derived_count` and `unreadable_count`, which sum to `recorded_count`
259
+ while the last two sum to `unannotated_count` (the operands, never a fraction), plus
260
+ `directory_count` — **every** area the run
240
261
  touched, not every area with debt, and not `rows.size` — and its **own** `limit`, which is **10 and
241
262
  not the worklist's 100**. Two caps under one ask, and the difference is the kind of list: 100 caps a
242
263
  *worklist* to work through, 10 caps a *ranking* to pick from. The orders differ for the same reason —
243
- the worklist is file-navigable, the map is ranked `unannotated_count` descending with `path` as a
244
- tiebreak only. A fully-annotated area is a real **row** with `unannotated_count: 0`, never an
264
+ the worklist is file-navigable within each reading, the map is ranked `unreadable_count` descending,
265
+ then `unannotated_count` descending, with `path` as a tiebreak only the areas SpecGuard cannot
266
+ read lead, because a ten-row ranking led by debt on an unannotated suite is a ranking by area size
267
+ and the dark corners never surface. A fully-annotated area is a real **row** with `unannotated_count: 0`, never an
245
268
  omission; those rows sort last *collectively*, so on a run with more areas than the cap they are cut
246
269
  and never seen, but on a run inside the cap they *are* listed and listed is correct. So `rows.size` is
247
270
  not a count of areas *with* debt — read each row's `unannotated_count`. Both blocks are at run grain,
@@ -328,9 +351,152 @@ decides which table is consulted before any of them is read — so the two are n
328
351
  setting one does not stand in for the other. Every message this tool produces names the variable
329
352
  *it* reads, so a `401` here never sends you to check the key `get_repository_overview` uses.
330
353
 
331
- Registering a repository, revoking keys and the rest of the user-scoped surface are not here yet.
332
- `POST /api/v1/repositories` exists on the platform; what this bridge does not yet have is a way to
333
- send a request body, and that arrives with the first tool that writes.
354
+ Registering a repository is `add_repository`, below it reads the same `sgu_…` key and takes the
355
+ `full_name` this tool reports. Removal and the key lifecycle (`remove_repository`,
356
+ `create_repository_api_key`, `revoke_repository_api_key`) are below too, on the same key; a tool
357
+ here is a promise the agent will act on, so each waits for the capability rather than the other way
358
+ round.
359
+
360
+ ### `add_repository`
361
+
362
+ Registers a GitHub repository with SpecGuard for the person behind `SPECGUARD_USER_API_KEY`, and
363
+ returns the repository together with its **first CI API key** — the `sgk_…` key that repository's CI
364
+ will use to ingest runs, minted in the same call so a fresh registration is usable without a second
365
+ trip through the browser.
366
+
367
+ | argument | |
368
+ | --- | --- |
369
+ | `full_name` | the repository to register, as `org/repo` (for example `acme/billing`) — the same handle `list_repositories` reports. Not a URL, not a bare repository name |
370
+
371
+ The body comes back as SpecGuard serves it: a `repository` block (`id`, `full_name`, `name`,
372
+ `registered_at` — deliberately the same four fields `get_repository_overview` serves in its own
373
+ `repository` block) and an `api_key` block (`name`, `token`, `hint`, `created_at`).
374
+
375
+ > ⚠️ **`api_key.token` is shown once and never again.** Nothing stores it and no endpoint can
376
+ > re-serve it. Capture it from this response — an agent should hand it straight to the person it is
377
+ > working for. A key that is lost is replaced from SpecGuard's API-keys page in a browser, not from
378
+ > here.
379
+
380
+ > ⚠️ **This tool is not idempotent, and it writes.** If the call exceeds `SPECGUARD_TIMEOUT_MS` the
381
+ > bridge gives up, but the registration may still have succeeded on the server — taking its one-time
382
+ > token into a response nobody received. The retry is then refused with `has already been taken`,
383
+ > which is the honest answer rather than a bug. Do not retry a timeout blindly; check
384
+ > `list_repositories`, and recover the key in the browser.
385
+
386
+ **It needs a current record of your GitHub permissions, and only a browser creates one.** SpecGuard
387
+ decides whether you may register a repository from a stored grant, and fails closed when that grant
388
+ is missing or stale — which is every person who has not signed in and connected GitHub recently. That
389
+ refusal arrives as SpecGuard's own sentence, verbatim, naming the fix: *sign in to SpecGuard in a
390
+ browser and reconnect GitHub, then try again*. No argument to this tool substitutes for it. The same
391
+ path carries the other refusals — a repository the SpecGuard GitHub App is not installed on, one you
392
+ do not administer, one already registered.
393
+
394
+ **The `org/repo` format is not re-checked here.** This bridge verifies only that you passed a
395
+ non-blank string; SpecGuard validates the name and refuses an unusable one in its own words. A second
396
+ format rule on this side would be free to drift from the one that actually decides, and would surface
397
+ as this bridge rejecting a name the platform would have accepted.
398
+
399
+ It reads `SPECGUARD_USER_API_KEY` (`sgu_…`), the same credential as `list_repositories` and a
400
+ different one from the `sgk_…` key `get_repository_overview` uses.
401
+
402
+ ### `registrable_repositories`
403
+
404
+ Lists the GitHub repositories the person behind `SPECGUARD_USER_API_KEY` **could** register with
405
+ SpecGuard — the set the registration gate would consult, read out loud in advance, so an agent can
406
+ pick a `full_name` for `add_repository` from a real answer rather than by guessing. `list_repositories`
407
+ reports what *is* registered; this reports what could be, and the two answer different questions.
408
+
409
+ The body comes back as SpecGuard serves it: `{"repositories": […]}` with each entry carrying
410
+ `full_name` and `registered`, ordered by `full_name` ascending, plus a `grant` block (`captured_at`,
411
+ `expires_at`, `stale`) describing the stored record of this person's GitHub permissions.
412
+
413
+ **`registered` is asked globally, not just of your own repositories.** An entry marked
414
+ `registered: true` was registered by *somebody* — possibly someone else — and a POST naming it will
415
+ be refused with `has already been taken`. That is exactly why entries are *marked* rather than
416
+ excluded: a reading scoped to what you can open would send you at a name nobody can register.
417
+
418
+ **A name appearing here is not a promise the write will succeed.** This is the set the gate would
419
+ consult at the moment of the read; the repository may be registered by someone else between this
420
+ call and your POST.
421
+
422
+ **A missing or stale grant is not an error — it is the modal first answer.** SpecGuard fails closed
423
+ when it has no current record of your GitHub permissions, which is every person who has not opened
424
+ SpecGuard in a browser recently. The call then answers `403` with SpecGuard's own sentence naming
425
+ the fix: *sign in to SpecGuard in a browser and reconnect GitHub, then try again*. On that refusal
426
+ the body still carries `grant`, and it distinguishes the two cases: `grant: null` means there never
427
+ was one (first-time setup), a populated grant with `stale: true` means an existing connection lapsed
428
+ — same remedy, very different urgency. Read it before telling the person what to do.
429
+
430
+ **This tool takes no arguments** — the credential is the whole of the scope. The endpoint takes no
431
+ parameters; which repositories are in the answer is decided by SpecGuard from the person the key
432
+ speaks for.
433
+
434
+ It reads `SPECGUARD_USER_API_KEY` (`sgu_…`), the same credential as `list_repositories` and
435
+ `add_repository` and a different one from the `sgk_…` key `get_repository_overview` uses.
436
+
437
+ ### `remove_repository`
438
+
439
+ Removes a repository from SpecGuard — and with it **every key, run and intent on it**. This is the
440
+ destructive gesture in this toolset: irreversible, no undo, and a `204` means the repository and its
441
+ history are gone short of re-registering from scratch. Confirm with the user before calling.
442
+
443
+ | argument | |
444
+ | --- | --- |
445
+ | `repository_id` | the repository to remove — its numeric `id`, as `add_repository` returns and `list_repositories` reports, not the `org/repo` handle |
446
+
447
+ Authorization is the `repo.delete` capability at **either surface** — an owner, or a member granted
448
+ `repo.delete`, may remove the repository. A member without it is refused `403` with SpecGuard's own
449
+ sentence, verbatim. The repository's CI keys stop authenticating the moment it succeeds.
450
+
451
+ It reads `SPECGUARD_USER_API_KEY` (`sgu_…`), the same credential as `list_repositories` and
452
+ `add_repository` and a different one from the `sgk_…` key `get_repository_overview` uses.
453
+
454
+ ### `create_repository_api_key`
455
+
456
+ Mints a new CI API key (`sgk_…`) for a SpecGuard repository and returns it alongside the
457
+ repository's existing keys. Minting does not disturb existing keys — each key on a repository
458
+ authenticates independently until revoked.
459
+
460
+ | argument | |
461
+ | --- | --- |
462
+ | `repository_id` | the repository to mint the key for — its numeric `id`, as `add_repository` returns and `list_repositories` reports, not the `org/repo` handle |
463
+ | `name` | an optional label for the key; omit it to let SpecGuard use its default name |
464
+
465
+ The body comes back as SpecGuard serves it: an `api_key` block (`name`, `token`, `hint`,
466
+ `created_at`) — the same shape `add_repository` serves.
467
+
468
+ > ⚠️ **`api_key.token` is shown once and never again.** Nothing stores it and no endpoint can
469
+ > re-serve it. Hand it to the person you are working for in your reply. If it is dropped, the
470
+ > recovery is minting another key with this same tool — the platform has no regenerate — then
471
+ > revoking the orphaned one with `revoke_repository_api_key`.
472
+
473
+ Authorization is the `keys_manage` capability; a member without it is refused `403` with SpecGuard's
474
+ own sentence, verbatim.
475
+
476
+ It reads `SPECGUARD_USER_API_KEY` (`sgu_…`), the same credential as `list_repositories` and
477
+ `add_repository` and a different one from the `sgk_…` key `get_repository_overview` uses.
478
+
479
+ ### `revoke_repository_api_key`
480
+
481
+ Revokes one CI API key on a SpecGuard repository. The key stops authenticating immediately; every
482
+ **other** key on the repository keeps working, so CI keeps ingesting if it holds a surviving key.
483
+
484
+ | argument | |
485
+ | --- | --- |
486
+ | `repository_id` | the repository the key belongs to — its numeric `id`, as `add_repository` returns and `list_repositories` reports, not the `org/repo` handle |
487
+ | `key_id` | the id of the key to revoke, as served in the `api_key` block of `add_repository` or `create_repository_api_key` |
488
+
489
+ The `key_id` is scoped to `repository_id`: a key id belonging to a different repository is refused
490
+ `404`, never a cross-repository delete. Authorization is the `keys_manage` capability; a member
491
+ without it is refused `403` with SpecGuard's own sentence, verbatim.
492
+
493
+ **Key rotation is mint-then-revoke, in that order.** The platform has no regenerate, so mint a
494
+ replacement with `create_repository_api_key` and deploy it BEFORE revoking the old one — revoke
495
+ first and the repository's CI is locked out until a human mints a new key in a browser. A `204`
496
+ means the key is revoked.
497
+
498
+ It reads `SPECGUARD_USER_API_KEY` (`sgu_…`), the same credential as `list_repositories` and
499
+ `add_repository` and a different one from the `sgk_…` key `get_repository_overview` uses.
334
500
 
335
501
  ## How it works
336
502
 
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { createServer } from "../src/server.js";
4
+ import { installTeardown } from "../src/support/teardown.js";
4
5
  /**
5
6
  * The stdio entrypoint — the only place a transport is named.
6
7
  *
@@ -21,6 +22,10 @@ import { createServer } from "../src/server.js";
21
22
  * it.
22
23
  */
23
24
  async function main() {
25
+ // Installed BEFORE the transport is connected, so there is no window in which
26
+ // the server is accepting tool calls — and therefore spawning runs — with no
27
+ // handler to clean them up.
28
+ installTeardown();
24
29
  const server = createServer();
25
30
  await server.connect(new StdioServerTransport());
26
31
  process.stderr.write("specguard-mcp: ready on stdio\n");
@@ -1 +1 @@
1
- {"version":3,"file":"specguard-mcp.js","sourceRoot":"","sources":["../../bin/specguard-mcp.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAEhD;;;;;;;;;;;;;;;;;;GAkBG;AACH,KAAK,UAAU,IAAI;IACjB,MAAM,MAAM,GAAG,YAAY,EAAE,CAAC;IAC9B,MAAM,MAAM,CAAC,OAAO,CAAC,IAAI,oBAAoB,EAAE,CAAC,CAAC;IACjD,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,iCAAiC,CAAC,CAAC;AAC1D,CAAC;AAED,4EAA4E;AAC5E,gFAAgF;AAChF,0EAA0E;AAC1E,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;IAC9B,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,yBAAyB,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CACnG,CAAC;IACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"specguard-mcp.js","sourceRoot":"","sources":["../../bin/specguard-mcp.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAE7D;;;;;;;;;;;;;;;;;;GAkBG;AACH,KAAK,UAAU,IAAI;IACjB,8EAA8E;IAC9E,6EAA6E;IAC7E,4BAA4B;IAC5B,eAAe,EAAE,CAAC;IAElB,MAAM,MAAM,GAAG,YAAY,EAAE,CAAC;IAC9B,MAAM,MAAM,CAAC,OAAO,CAAC,IAAI,oBAAoB,EAAE,CAAC,CAAC;IACjD,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,iCAAiC,CAAC,CAAC;AAC1D,CAAC;AAED,4EAA4E;AAC5E,gFAAgF;AAChF,0EAA0E;AAC1E,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;IAC9B,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,yBAAyB,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CACnG,CAAC;IACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
@@ -68,6 +68,48 @@ export declare const DEFAULT_COMMAND_TIMEOUT_MS = 120000;
68
68
  * descriptor costs a truncated tail instead of a call that never returns.
69
69
  */
70
70
  export declare const EXIT_CLOSE_GRACE_MS = 1000;
71
+ /**
72
+ * The pids of every run currently spawned and not yet reaped.
73
+ *
74
+ * Exported for the teardown handler's diagnostics. A PROJECTION, not the set:
75
+ * it drops entries whose spawn never produced a process, because a `undefined`
76
+ * pid is not something a diagnostic can name or a caller can signal.
77
+ *
78
+ * That filter makes it the WRONG observer for asserting the set does not leak —
79
+ * see `outstandingRunCount`.
80
+ */
81
+ export declare function outstandingRunPids(): readonly number[];
82
+ /**
83
+ * How many runs are registered, counting those whose spawn produced no pid.
84
+ *
85
+ * The unfiltered companion to `outstandingRunPids`, and the one a leak test must
86
+ * use. The distinction is not pedantic: the spawn-failure path registers a child
87
+ * whose `pid` is `undefined`, so it is invisible to `outstandingRunPids` BY
88
+ * EXACTLY THE PROPERTY THAT MAKES IT A LEAK. A test asserting that path through
89
+ * the pid projection holds whether or not the deregistration happens, and would
90
+ * stay green if a refactor dropped it.
91
+ *
92
+ * The leak is memory-only — `killRun` returns `false` for an undefined pid, so a
93
+ * stranded entry is skipped by the drain rather than mis-signalled — but it is
94
+ * one entry per failed spawn for the life of the server, and it is only ever
95
+ * observable from outside. Hence a reader of the set itself.
96
+ */
97
+ export declare function outstandingRunCount(): number;
98
+ /**
99
+ * Kills every run still in flight, and answers how many it signalled.
100
+ *
101
+ * The teardown path. SYNCHRONOUS AND UNBOUNDED BY NOTHING — it sends signals and
102
+ * returns, it never waits for a child to die. That is deliberate: this runs from
103
+ * a SIGINT/SIGTERM handler, where anything that waits is something that can hang
104
+ * the shutdown it was supposed to perform. SIGKILL is not refusable, so there is
105
+ * no acknowledgement worth waiting for.
106
+ *
107
+ * It reuses `killRun` rather than re-deriving the kill, so the group-vs-child
108
+ * fallback and the spawn-failure guard have exactly one implementation. The set
109
+ * is snapshotted before iterating because `killRun` can drive an `exit` that
110
+ * mutates it.
111
+ */
112
+ export declare function killOutstandingRuns(): number;
71
113
  /**
72
114
  * Runs a program with an argument LIST, never through a shell.
73
115
  *
@@ -15,6 +15,95 @@ export const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
15
15
  * descriptor costs a truncated tail instead of a call that never returns.
16
16
  */
17
17
  export const EXIT_CLOSE_GRACE_MS = 1_000;
18
+ /**
19
+ * The runs that are spawned and not yet reaped.
20
+ *
21
+ * This exists because `detached: true` below buys the reach of the timeout kill
22
+ * and PAYS for it: a detached child is in a new session, outside this server's
23
+ * controlling terminal, so a signal aimed at OUR group — an interactive Ctrl-C,
24
+ * a supervisor's `kill -- -PGID` — no longer reaches a lint run in flight. The
25
+ * run is then orphaned, and orphaned WITHOUT A DEADLINE: the 120s ceiling is a
26
+ * parent-side `setTimeout`, so killing the parent destroys the only thing that
27
+ * was going to stop it. On a 20k-example suite that is a full lint's worth of
28
+ * CPU held by a process attached to nothing. `killOutstandingRuns` is the
29
+ * teardown path that closes it, and this set is what tells it whom to signal.
30
+ *
31
+ * MEMBERSHIP MEANS "NOT YET REAPED", and that is load-bearing rather than
32
+ * descriptive: `killRun` signals a raw negated pid, which has no liveness check
33
+ * of its own, so a stale entry is a SIGKILL aimed at whatever recycled that pid.
34
+ * Entries are therefore removed on the child's own `exit` — the reap — and NOT
35
+ * at settle, which on the grace-backstop path happens up to EXIT_CLOSE_GRACE_MS
36
+ * later. Deleting at settle would leave exactly the window in which a drain
37
+ * would signal a freed pid.
38
+ *
39
+ * The accepted consequence is the one this file already takes at `killRun`: a
40
+ * straggler outliving an already-exited child is not killed at teardown. That
41
+ * trade is deliberate and is not widened here.
42
+ */
43
+ const liveChildren = new Set();
44
+ /**
45
+ * The pids of every run currently spawned and not yet reaped.
46
+ *
47
+ * Exported for the teardown handler's diagnostics. A PROJECTION, not the set:
48
+ * it drops entries whose spawn never produced a process, because a `undefined`
49
+ * pid is not something a diagnostic can name or a caller can signal.
50
+ *
51
+ * That filter makes it the WRONG observer for asserting the set does not leak —
52
+ * see `outstandingRunCount`.
53
+ */
54
+ export function outstandingRunPids() {
55
+ const pids = [];
56
+ for (const child of liveChildren) {
57
+ if (child.pid !== undefined)
58
+ pids.push(child.pid);
59
+ }
60
+ return pids;
61
+ }
62
+ /**
63
+ * How many runs are registered, counting those whose spawn produced no pid.
64
+ *
65
+ * The unfiltered companion to `outstandingRunPids`, and the one a leak test must
66
+ * use. The distinction is not pedantic: the spawn-failure path registers a child
67
+ * whose `pid` is `undefined`, so it is invisible to `outstandingRunPids` BY
68
+ * EXACTLY THE PROPERTY THAT MAKES IT A LEAK. A test asserting that path through
69
+ * the pid projection holds whether or not the deregistration happens, and would
70
+ * stay green if a refactor dropped it.
71
+ *
72
+ * The leak is memory-only — `killRun` returns `false` for an undefined pid, so a
73
+ * stranded entry is skipped by the drain rather than mis-signalled — but it is
74
+ * one entry per failed spawn for the life of the server, and it is only ever
75
+ * observable from outside. Hence a reader of the set itself.
76
+ */
77
+ export function outstandingRunCount() {
78
+ return liveChildren.size;
79
+ }
80
+ /**
81
+ * Kills every run still in flight, and answers how many it signalled.
82
+ *
83
+ * The teardown path. SYNCHRONOUS AND UNBOUNDED BY NOTHING — it sends signals and
84
+ * returns, it never waits for a child to die. That is deliberate: this runs from
85
+ * a SIGINT/SIGTERM handler, where anything that waits is something that can hang
86
+ * the shutdown it was supposed to perform. SIGKILL is not refusable, so there is
87
+ * no acknowledgement worth waiting for.
88
+ *
89
+ * It reuses `killRun` rather than re-deriving the kill, so the group-vs-child
90
+ * fallback and the spawn-failure guard have exactly one implementation. The set
91
+ * is snapshotted before iterating because `killRun` can drive an `exit` that
92
+ * mutates it.
93
+ */
94
+ export function killOutstandingRuns() {
95
+ const children = [...liveChildren];
96
+ let signalled = 0;
97
+ for (const child of children) {
98
+ // Removed FIRST, so the entry is gone even if the kill throws. A registry
99
+ // that kept a child it had already tried to kill would hand a second drain
100
+ // the same stale pid.
101
+ liveChildren.delete(child);
102
+ if (killRun(child))
103
+ signalled += 1;
104
+ }
105
+ return signalled;
106
+ }
18
107
  /**
19
108
  * Runs a program with an argument LIST, never through a shell.
20
109
  *
@@ -128,6 +217,11 @@ export const runCommand = (argv, options = {}) => {
128
217
  child.stdout.on("data", (chunk) => stdout.push(chunk));
129
218
  child.stderr.on("data", (chunk) => stderr.push(chunk));
130
219
  child.on("error", (error) => {
220
+ // The spawn-failure path: there is no process, and `child.pid` is
221
+ // `undefined`, so this entry can never be a legitimate kill target. Dropped
222
+ // here because `exit` does not always follow an `error` — leaving it would
223
+ // strand an unkillable entry in the registry for the life of the server.
224
+ liveChildren.delete(child);
131
225
  finish(() => reject(new CommandError(describeSpawnFailure(program, error, options))));
132
226
  });
133
227
  /**
@@ -153,6 +247,20 @@ export const runCommand = (argv, options = {}) => {
153
247
  // on we hold a real `code`/`signal`, so whatever the timer may still find
154
248
  // alive in the process group, this is not a run that produced no verdict.
155
249
  exited = true;
250
+ // Deregistered HERE — at the reap, alongside `exited`, and deliberately not
251
+ // at the settle below. The two are not the same instant: on the grace
252
+ // backstop `exit` fires and `settleWith` follows up to EXIT_CLOSE_GRACE_MS
253
+ // later, so a registry keyed on settle would hold a child whose pid the
254
+ // kernel has already freed, and a teardown drain landing in that window
255
+ // would fire `process.kill(-pid)` at whatever now owns that number. That
256
+ // is the unrecoverable, aimed-at-a-stranger hazard `killRun` documents and
257
+ // refuses to pay; membership must mean "not yet reaped" so its precondition
258
+ // holds by construction.
259
+ //
260
+ // Placed BEFORE the `settled` early-return for the same reason: the
261
+ // already-settled path is a reap too, and returning first would leak the
262
+ // entry.
263
+ liveChildren.delete(child);
156
264
  if (settled)
157
265
  return;
158
266
  graceTimer = setTimeout(() => {
@@ -224,7 +332,7 @@ function killRun(child) {
224
332
  * promise losing the child's type.
225
333
  */
226
334
  function spawnChild(program, args, options) {
227
- return spawn(program, [...args], {
335
+ const child = spawn(program, [...args], {
228
336
  cwd: options.cwd,
229
337
  stdio: ["ignore", "pipe", "pipe"],
230
338
  // Explicitly off. Stated rather than defaulted, because this is the line
@@ -240,15 +348,23 @@ function spawnChild(program, args, options) {
240
348
  // that detaching buys the reach of the kill and PAYS for it here: a new
241
349
  // group is also a new session, outside this server's controlling terminal,
242
350
  // so a signal aimed at OUR group — an interactive Ctrl-C, a supervisor's
243
- // `kill -- -PGID` — no longer reaches a lint run in flight. Nothing in
244
- // `bin/specguard-mcp.ts` installs a SIGINT/SIGTERM handler to kill
245
- // outstanding children at teardown, so such a run is now orphaned where it
246
- // would previously have died alongside us. Worth it, because the failure
247
- // being traded away is an agent that never returns rather than a stray
248
- // process but it is a trade, and a teardown handler is what would close
249
- // it.
351
+ // `kill -- -PGID` — no longer reaches a lint run in flight. Such a run would
352
+ // be orphaned where it would previously have died alongside us, and orphaned
353
+ // without a deadline, since the ceiling above is a parent-side timer.
354
+ //
355
+ // That is the debt this line used to carry, and it is now PAID rather than
356
+ // merely disclosed: the run is registered in `liveChildren` below, and
357
+ // `bin/specguard-mcp.ts` installs SIGINT/SIGTERM handlers that call
358
+ // `killOutstandingRuns` before exiting. The trade the kill's reach was
359
+ // bought with is closed; do not remove either half without restoring the
360
+ // other.
250
361
  detached: true,
251
362
  });
363
+ // Registered at the single spawn call site, so a run cannot enter the world
364
+ // unregistered. Removed again on the child's own `exit` — see `liveChildren`
365
+ // for why the reap, and not the settle, is the moment that matters.
366
+ liveChildren.add(child);
367
+ return child;
252
368
  }
253
369
  /**
254
370
  * Which thing failed to run — asked rather than assumed.
@@ -1 +1 @@
1
- {"version":3,"file":"run-command.js","sourceRoot":"","sources":["../../../src/support/run-command.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"run-command.js","sourceRoot":"","sources":["../../../src/support/run-command.ts"],"names":[],"mappings":"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"}
@@ -8,6 +8,56 @@ import { requireApiConfig, requireUserApiConfig, type ApiConfig } from "../confi
8
8
  * second place for the permission model to be got wrong.
9
9
  */
10
10
  export declare function getJson(api: ApiConfig, path: string, query: Record<string, string | undefined>, fetchImpl: typeof globalThis.fetch): Promise<unknown>;
11
+ /**
12
+ * `POST` with a JSON body — the write half of the transport, and deliberately
13
+ * the SAME function underneath.
14
+ *
15
+ * It shares `fetchWithTimeout` rather than standing beside it. The one-total-
16
+ * budget deadline, the explicit race, the `unref`'d timer, the abort and the
17
+ * "reached and stopped" vs "could not reach" split are the expensive part of
18
+ * this module and every argument for them is written above them — none of it is
19
+ * about the verb. A second transport re-deriving them is how the two come to
20
+ * disagree about what `SPECGUARD_TIMEOUT_MS` bounds, and the write path is the
21
+ * one where a call that never returns costs the most: the agent has already
22
+ * committed to a registration by the time it hangs.
23
+ *
24
+ * The body is serialized HERE rather than taken as a string, so no caller can
25
+ * send a body whose `Content-Type` says JSON and whose bytes are not.
26
+ */
27
+ export declare function postJson(api: ApiConfig, path: string, body: Record<string, unknown>, fetchImpl: typeof globalThis.fetch): Promise<unknown>;
28
+ /**
29
+ * `DELETE` — the destructive half of the transport, and deliberately the SAME
30
+ * function underneath `postJson` rather than beside it, for the reason
31
+ * `postJson`'s header states: everything expensive about this module is about
32
+ * the deadline, not the verb.
33
+ *
34
+ * Returns the RAW BODY TEXT rather than a parsed value, because the endpoints
35
+ * this serves answer `204` with NO body at all — the one response in the `sgu_`
36
+ * surface that is deliberately not JSON. `requestJson` JSON-parses every 2xx it
37
+ * sees, so routing a `204` through it would turn a successful delete into
38
+ * "answered 204 but the body was not JSON" — the trap this verb specifically
39
+ * introduces, and the reason the DELETE path has its own success handling
40
+ * instead of sharing `requestJson`'s. The status check and the
41
+ * "reached and refused" hand-off to `describeFailure` are still shared
42
+ * verbatim: only what happens to a SUCCESS body differs.
43
+ */
44
+ export declare function deleteJson(api: ApiConfig, path: string, fetchImpl: typeof globalThis.fetch): Promise<string>;
45
+ /**
46
+ * `postJson`, narrowed exactly as `getJsonObject` narrows `getJson`.
47
+ *
48
+ * The write path needs the same guard for the same reason, and the reason is not
49
+ * about reading: `ToolResult.structured` is a `Record<string, unknown>`, so a
50
+ * body that is an array or a bare scalar is not something a tool can pass
51
+ * through whichever verb fetched it. Shipping only the raw `postJson` would
52
+ * leave the first write tool to re-type the three-clause check and its sentence
53
+ * — which is precisely the duplication `getJsonObject`'s header says no tool
54
+ * should have to repeat.
55
+ *
56
+ * The pair is mirrored rather than collapsed for the reason the read pair is:
57
+ * `postJson` stays exported un-narrowed for an endpoint that legitimately
58
+ * answers with an array.
59
+ */
60
+ export declare function postJsonObject(api: ApiConfig, path: string, body: Record<string, unknown>, fetchImpl: typeof globalThis.fetch): Promise<Record<string, unknown>>;
11
61
  /**
12
62
  * `getJson`, narrowed to the object every tool here actually asks it for.
13
63
  *