specguard-mcp 0.1.2 → 0.1.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.
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
 
@@ -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,52 @@ 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 now `add_repository`, below it reads the same `sgu_…` key and takes the
355
+ `full_name` this tool reports. Revoking keys and the rest of the user-scoped surface are still absent,
356
+ because their endpoints have not shipped: a tool here is a promise the agent will act on, so it waits
357
+ for the capability rather than the other way round.
358
+
359
+ ### `add_repository`
360
+
361
+ Registers a GitHub repository with SpecGuard for the person behind `SPECGUARD_USER_API_KEY`, and
362
+ returns the repository together with its **first CI API key** — the `sgk_…` key that repository's CI
363
+ will use to ingest runs, minted in the same call so a fresh registration is usable without a second
364
+ trip through the browser.
365
+
366
+ | argument | |
367
+ | --- | --- |
368
+ | `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 |
369
+
370
+ The body comes back as SpecGuard serves it: a `repository` block (`id`, `full_name`, `name`,
371
+ `registered_at` — deliberately the same four fields `get_repository_overview` serves in its own
372
+ `repository` block) and an `api_key` block (`name`, `token`, `hint`, `created_at`).
373
+
374
+ > ⚠️ **`api_key.token` is shown once and never again.** Nothing stores it and no endpoint can
375
+ > re-serve it. Capture it from this response — an agent should hand it straight to the person it is
376
+ > working for. A key that is lost is replaced from SpecGuard's API-keys page in a browser, not from
377
+ > here.
378
+
379
+ > ⚠️ **This tool is not idempotent, and it writes.** If the call exceeds `SPECGUARD_TIMEOUT_MS` the
380
+ > bridge gives up, but the registration may still have succeeded on the server — taking its one-time
381
+ > token into a response nobody received. The retry is then refused with `has already been taken`,
382
+ > which is the honest answer rather than a bug. Do not retry a timeout blindly; check
383
+ > `list_repositories`, and recover the key in the browser.
384
+
385
+ **It needs a current record of your GitHub permissions, and only a browser creates one.** SpecGuard
386
+ decides whether you may register a repository from a stored grant, and fails closed when that grant
387
+ is missing or stale — which is every person who has not signed in and connected GitHub recently. That
388
+ refusal arrives as SpecGuard's own sentence, verbatim, naming the fix: *sign in to SpecGuard in a
389
+ browser and reconnect GitHub, then try again*. No argument to this tool substitutes for it. The same
390
+ path carries the other refusals — a repository the SpecGuard GitHub App is not installed on, one you
391
+ do not administer, one already registered.
392
+
393
+ **The `org/repo` format is not re-checked here.** This bridge verifies only that you passed a
394
+ non-blank string; SpecGuard validates the name and refuses an unusable one in its own words. A second
395
+ format rule on this side would be free to drift from the one that actually decides, and would surface
396
+ as this bridge rejecting a name the platform would have accepted.
397
+
398
+ It reads `SPECGUARD_USER_API_KEY` (`sgu_…`), the same credential as `list_repositories` and a
399
+ different one from the `sgk_…` key `get_repository_overview` uses.
334
400
 
335
401
  ## How it works
336
402
 
@@ -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,39 @@ 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
+ * `postJson`, narrowed exactly as `getJsonObject` narrows `getJson`.
30
+ *
31
+ * The write path needs the same guard for the same reason, and the reason is not
32
+ * about reading: `ToolResult.structured` is a `Record<string, unknown>`, so a
33
+ * body that is an array or a bare scalar is not something a tool can pass
34
+ * through whichever verb fetched it. Shipping only the raw `postJson` would
35
+ * leave the first write tool to re-type the three-clause check and its sentence
36
+ * — which is precisely the duplication `getJsonObject`'s header says no tool
37
+ * should have to repeat.
38
+ *
39
+ * The pair is mirrored rather than collapsed for the reason the read pair is:
40
+ * `postJson` stays exported un-narrowed for an endpoint that legitimately
41
+ * answers with an array.
42
+ */
43
+ export declare function postJsonObject(api: ApiConfig, path: string, body: Record<string, unknown>, fetchImpl: typeof globalThis.fetch): Promise<Record<string, unknown>>;
11
44
  /**
12
45
  * `getJson`, narrowed to the object every tool here actually asks it for.
13
46
  *
@@ -14,7 +14,59 @@ export async function getJson(api, path, query, fetchImpl) {
14
14
  if (value !== undefined)
15
15
  url.searchParams.set(key, value);
16
16
  }
17
- const { response, body } = await fetchWithTimeout(url, api, fetchImpl);
17
+ return requestJson(url, api, fetchImpl, { method: "GET" });
18
+ }
19
+ /**
20
+ * `POST` with a JSON body — the write half of the transport, and deliberately
21
+ * the SAME function underneath.
22
+ *
23
+ * It shares `fetchWithTimeout` rather than standing beside it. The one-total-
24
+ * budget deadline, the explicit race, the `unref`'d timer, the abort and the
25
+ * "reached and stopped" vs "could not reach" split are the expensive part of
26
+ * this module and every argument for them is written above them — none of it is
27
+ * about the verb. A second transport re-deriving them is how the two come to
28
+ * disagree about what `SPECGUARD_TIMEOUT_MS` bounds, and the write path is the
29
+ * one where a call that never returns costs the most: the agent has already
30
+ * committed to a registration by the time it hangs.
31
+ *
32
+ * The body is serialized HERE rather than taken as a string, so no caller can
33
+ * send a body whose `Content-Type` says JSON and whose bytes are not.
34
+ */
35
+ export async function postJson(api, path, body, fetchImpl) {
36
+ return requestJson(new URL(`${api.endpoint}${path}`), api, fetchImpl, {
37
+ method: "POST",
38
+ body: JSON.stringify(body),
39
+ });
40
+ }
41
+ /**
42
+ * `postJson`, narrowed exactly as `getJsonObject` narrows `getJson`.
43
+ *
44
+ * The write path needs the same guard for the same reason, and the reason is not
45
+ * about reading: `ToolResult.structured` is a `Record<string, unknown>`, so a
46
+ * body that is an array or a bare scalar is not something a tool can pass
47
+ * through whichever verb fetched it. Shipping only the raw `postJson` would
48
+ * leave the first write tool to re-type the three-clause check and its sentence
49
+ * — which is precisely the duplication `getJsonObject`'s header says no tool
50
+ * should have to repeat.
51
+ *
52
+ * The pair is mirrored rather than collapsed for the reason the read pair is:
53
+ * `postJson` stays exported un-narrowed for an endpoint that legitimately
54
+ * answers with an array.
55
+ */
56
+ export async function postJsonObject(api, path, body, fetchImpl) {
57
+ return asJsonObject(await postJson(api, path, body, fetchImpl));
58
+ }
59
+ /**
60
+ * Everything both verbs do with a response, in one place.
61
+ *
62
+ * Extracted when the write path landed rather than copied into it: the status
63
+ * check, the "reached and refused" hand-off to `describeFailure` and the
64
+ * not-JSON sentence are identical for a `GET` and a `POST`, and the not-JSON
65
+ * sentence in particular is a diagnosis an operator acts on — a second copy is a
66
+ * second wording waiting to drift from this one.
67
+ */
68
+ async function requestJson(url, api, fetchImpl, request) {
69
+ const { response, body } = await fetchWithTimeout(url, api, fetchImpl, request);
18
70
  if (!response.ok)
19
71
  throw describeFailure(response.status, body, api);
20
72
  try {
@@ -26,6 +78,13 @@ export async function getJson(api, path, query, fetchImpl) {
26
78
  "or login page.", response.status);
27
79
  }
28
80
  }
81
+ /** The three-clause guard both `*JsonObject` narrowings share. */
82
+ function asJsonObject(body) {
83
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
84
+ throw new ApiError("SpecGuard returned a JSON value that was not an object.");
85
+ }
86
+ return body;
87
+ }
29
88
  /**
30
89
  * `getJson`, narrowed to the object every tool here actually asks it for.
31
90
  *
@@ -44,11 +103,7 @@ export async function getJson(api, path, query, fetchImpl) {
44
103
  * are the only legal body, it is that no tool re-types this guard.
45
104
  */
46
105
  export async function getJsonObject(api, path, query, fetchImpl) {
47
- const body = await getJson(api, path, query, fetchImpl);
48
- if (typeof body !== "object" || body === null || Array.isArray(body)) {
49
- throw new ApiError("SpecGuard returned a JSON value that was not an object.");
50
- }
51
- return body;
106
+ return asJsonObject(await getJson(api, path, query, fetchImpl));
52
107
  }
53
108
  /**
54
109
  * Tells "the deadline won the race" apart from any value a phase could produce.
@@ -89,7 +144,7 @@ const TIMED_OUT = Symbol("specguard-api deadline");
89
144
  * frame later, so there is no window in which the read is awaiting somewhere the
90
145
  * timer does not reach.
91
146
  */
92
- async function fetchWithTimeout(url, api, fetchImpl) {
147
+ async function fetchWithTimeout(url, api, fetchImpl, request) {
93
148
  const controller = new AbortController();
94
149
  let timer;
95
150
  const deadline = new Promise((resolve) => {
@@ -104,12 +159,17 @@ async function fetchWithTimeout(url, api, fetchImpl) {
104
159
  try {
105
160
  const response = await Promise.race([
106
161
  fetchImpl(url, {
107
- method: "GET",
162
+ method: request.method,
108
163
  headers: {
109
164
  Authorization: `Bearer ${api.apiKey}`,
110
165
  Accept: "application/json",
111
166
  "User-Agent": "specguard-mcp",
167
+ // Sent only when there IS a body. A `Content-Type` on a GET announces
168
+ // a payload that is not there, and some deployments and proxies treat
169
+ // that as a malformed request rather than as a harmless header.
170
+ ...(request.body === undefined ? {} : { "Content-Type": "application/json" }),
112
171
  },
172
+ ...(request.body === undefined ? {} : { body: request.body }),
113
173
  signal: controller.signal,
114
174
  }),
115
175
  deadline,
@@ -185,7 +245,58 @@ function describeFailure(status, body, api) {
185
245
  return new ApiError(`${api.endpoint} has no such endpoint (404). Check that ${api.endpointVariable} is the ` +
186
246
  "deployment's root URL, without a path.", status);
187
247
  }
248
+ if (status === 400) {
249
+ const message = badRequestMessage(body);
250
+ if (message !== undefined)
251
+ return new ApiError(message, status);
252
+ }
188
253
  return new ApiError(`SpecGuard answered ${status}${body.trim() === "" ? "" : `: ${body.trim().slice(0, 500)}`}`, status);
189
254
  }
255
+ /**
256
+ * The sentence SpecGuard already wrote, or nothing.
257
+ *
258
+ * `Api::BaseController#render_bad_request` is a CONTRACT, not an ad-hoc body:
259
+ * `{error:, message:, details:}`, where `details` carries every validation
260
+ * failure and `message` repeats the first "so a client that reads only the two
261
+ * conventional keys still learns which spec is at fault". Both callers of it on
262
+ * `origin/main` route here, so this branch serves the API surface rather than
263
+ * one tool.
264
+ *
265
+ * SURFACING IT IS THE OPPOSITE OF RESHAPING IT. The generic branch below turns
266
+ * the most useful sentence in this direction —
267
+ *
268
+ * "cannot be registered from an API key — SpecGuard has no current record of
269
+ * your GitHub permissions. Sign in to SpecGuard in a browser and reconnect
270
+ * GitHub, then try again."
271
+ *
272
+ * — into a JSON blob glued to "SpecGuard answered 400" and truncated at 500
273
+ * characters. That sentence names the operator's exact next move, and it is the
274
+ * MODAL first answer this endpoint gives: `GrantVerifier` fails closed on a
275
+ * missing or stale grant, which is every person who has not opened SpecGuard in
276
+ * a browser since the feature shipped. `:not_administered`, `:not_in_installation`
277
+ * and "has already been taken" arrive the same way. This branch does not author
278
+ * a sentence the way the 401 and 404 branches must — it stops DISCARDING one.
279
+ *
280
+ * Returns `undefined` rather than a fallback string, so the decision about what
281
+ * to say when the body is not that shape stays in one place. A 400 from
282
+ * somewhere that is not this contract — a proxy's HTML, a bare string, JSON
283
+ * whose `message` is absent or is not a string — still gets the generic
284
+ * sentence, which at least shows the operator what actually came back.
285
+ */
286
+ function badRequestMessage(body) {
287
+ let parsed;
288
+ try {
289
+ parsed = JSON.parse(body);
290
+ }
291
+ catch {
292
+ return undefined;
293
+ }
294
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
295
+ return undefined;
296
+ const message = parsed["message"];
297
+ if (typeof message !== "string" || message.trim() === "")
298
+ return undefined;
299
+ return `SpecGuard refused the request (400): ${message.trim()}`;
300
+ }
190
301
  export { requireApiConfig, requireUserApiConfig };
191
302
  //# sourceMappingURL=specguard-api.js.map