@agentspec/claude-plugin 0.1.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.
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "agentspec",
3
+ "displayName": "AgentSpec",
4
+ "version": "0.1.0",
5
+ "description": "Live spec↔runtime bridge, manifest-driven policy enforcement, and a health status line for Claude Code — driven by your agent.yaml.",
6
+ "author": { "name": "AgentSpec", "url": "https://agentspec.io" },
7
+ "homepage": "https://agentspec.io",
8
+ "license": "Apache-2.0",
9
+ "keywords": ["agentspec", "governance", "observability", "hooks"],
10
+ "hooks": "./hooks/hooks.json",
11
+ "mcpServers": "./.mcp.json"
12
+ }
package/.mcp.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "agentspec": {
4
+ "command": "npx",
5
+ "args": ["-y", "@agentspec/mcp"]
6
+ }
7
+ }
8
+ }
package/LICENSE ADDED
@@ -0,0 +1,183 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship made available under
36
+ the License, as indicated by a copyright notice that is included in
37
+ or attached to the work (an example is provided in the Appendix below).
38
+
39
+ "Derivative Works" shall mean any work, whether in Source or Object
40
+ form, that is based on (or derived from) the Work and for which the
41
+ editorial revisions, annotations, elaborations, or other modifications
42
+ represent, as a whole, an original work of authorship. For the purposes
43
+ of this License, Derivative Works shall not include works that remain
44
+ separable from, or merely link (or bind by name) to the interfaces of,
45
+ the Work and Derivative Works thereof.
46
+
47
+ "Contribution" shall mean, as submitted to the Licensor for inclusion
48
+ in the Work by the copyright owner or by an individual or Legal Entity
49
+ authorized to submit on behalf of the copyright owner. For the purposes
50
+ of this definition, "submit" means any form of electronic, verbal, or
51
+ written communication sent to the Licensor or its representatives,
52
+ including but not limited to communication on electronic mailing lists,
53
+ source code control systems, and issue tracking systems that are managed
54
+ by, or on behalf of, the Licensor for the purpose of discussing and
55
+ improving the Work, but excluding communication that is conspicuously
56
+ marked or designated in writing by the copyright owner as "Not a
57
+ Contribution."
58
+
59
+ "Contributor" shall mean Licensor and any Legal Entity on behalf of
60
+ whom a Contribution has been received by the Licensor and included
61
+ within the Work.
62
+
63
+ 2. Grant of Copyright License. Subject to the terms and conditions of
64
+ this License, each Contributor hereby grants to You a perpetual,
65
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
66
+ copyright license to reproduce, prepare Derivative Works of,
67
+ publicly display, publicly perform, sublicense, and distribute the
68
+ Work and such Derivative Works in Source or Object form.
69
+
70
+ 3. Grant of Patent License. Subject to the terms and conditions of
71
+ this License, each Contributor hereby grants to You a perpetual,
72
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
73
+ (except as stated in this section) patent license to make, have made,
74
+ use, offer to sell, sell, import, and otherwise transfer the Work,
75
+ where such license applies only to those patent claims licensable
76
+ by such Contributor that are necessarily infringed by their
77
+ Contribution(s) alone or by the combination of their Contribution(s)
78
+ with the Work to which such Contribution(s) was submitted. If You
79
+ institute patent litigation against any entity (including a cross-claim
80
+ or counterclaim in a lawsuit) alleging that the Work or any
81
+ Contribution embodied within the Work constitutes direct or
82
+ contributory patent infringement, then any patent licenses granted
83
+ to You under this License for that Work shall terminate as of the
84
+ date such litigation is filed.
85
+
86
+ 4. Redistribution. You may reproduce and distribute copies of the
87
+ Work or Derivative Works thereof in any medium, with or without
88
+ modifications, and in Source or Object form, provided that You
89
+ meet the following conditions:
90
+
91
+ (a) You must give any other recipients of the Work or Derivative
92
+ Works a copy of this License; and
93
+
94
+ (b) You must cause any modified files to carry prominent notices
95
+ stating that You changed the files; and
96
+
97
+ (c) You must retain, in the Source form of any Derivative Works
98
+ that You distribute, all copyright, patent, trademark, and
99
+ attribution notices from the Source form of the Work,
100
+ excluding those notices that do not pertain to any part of
101
+ the Derivative Works; and
102
+
103
+ (d) If the Work includes a "NOTICE" text file as part of its
104
+ distribution, You must include a readable copy of the
105
+ attribution notices contained within such NOTICE file, in
106
+ at least one of the following places: within a NOTICE text
107
+ file distributed as part of the Derivative Works; within
108
+ the Source form or documentation, if provided along with the
109
+ Derivative Works; or, within a display generated by the
110
+ Derivative Works, if and wherever such third-party notices
111
+ normally appear. The contents of the NOTICE file are for
112
+ informational purposes only and do not modify the License.
113
+ You may add Your own attribution notices within Derivative
114
+ Works that You distribute, alongside or as an addendum to
115
+ the NOTICE text from the Work, provided that such additional
116
+ attribution notices cannot be construed as modifying the License.
117
+
118
+ You may add Your own license statement for Your modifications and
119
+ may provide additional grant of rights to use, copy, modify, merge,
120
+ publish, distribute, sublicense, and/or sell copies of the
121
+ Derivative Works, as these terms may be expanded or clarified.
122
+
123
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
124
+ any Contribution intentionally submitted for inclusion in the Work
125
+ by You to the Licensor shall be under the terms and conditions of
126
+ this License, without any additional terms or conditions.
127
+ Notwithstanding the above, nothing herein shall supersede or modify
128
+ the terms of any separate license agreement you may have executed
129
+ with Licensor regarding such Contributions.
130
+
131
+ 6. Trademarks. This License does not grant permission to use the trade
132
+ names, trademarks, service marks, or product names of the Licensor,
133
+ except as required for reasonable and customary use in describing the
134
+ origin of the Work and reproducing the content of the NOTICE file.
135
+
136
+ 7. Disclaimer of Warranty. Unless required by applicable law or
137
+ agreed to in writing, Licensor provides the Work (and each
138
+ Contributor provides its Contributions) on an "AS IS" BASIS,
139
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
140
+ implied, including, without limitation, any warranties or conditions
141
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
142
+ PARTICULAR PURPOSE. You are solely responsible for determining the
143
+ appropriateness of using or reproducing the Work and assume any
144
+ risks associated with Your exercise of permissions under this License.
145
+
146
+ 8. Limitation of Liability. In no event and under no legal theory,
147
+ whether in tort (including negligence), contract, or otherwise,
148
+ unless required by applicable law (such as deliberate and grossly
149
+ negligent acts) or agreed to in writing, shall any Contributor be
150
+ liable to You for damages, including any direct, indirect, special,
151
+ incidental, or exemplary damages of any character arising as a
152
+ result of this License or out of the use or inability to use the
153
+ Work (including but not limited to damages for loss of goodwill,
154
+ work stoppage, computer failure or malfunction, or all other
155
+ commercial damages or losses), even if such Contributor has been
156
+ advised of the possibility of such damages.
157
+
158
+ 9. Accepting Warranty or Liability. While redistributing the Work or
159
+ Derivative Works thereof, You may choose to offer, and charge a fee
160
+ for, acceptance of support, warranty, indemnity, or other liability
161
+ obligations and/or rights consistent with this License. However, in
162
+ accepting such obligations, You may offer such conditions only on
163
+ Your own behalf and on Your sole responsibility, not on behalf of
164
+ any other Contributor, and only if You agree to indemnify, defend,
165
+ and hold each Contributor harmless for any liability incurred by,
166
+ or claims asserted against, such Contributor by reason of your
167
+ accepting any such warranty or additional liability.
168
+
169
+ END OF TERMS AND CONDITIONS
170
+
171
+ Copyright 2026 Sallah Kokaina
172
+
173
+ Licensed under the Apache License, Version 2.0 (the "License");
174
+ you may not use this file except in compliance with the License.
175
+ You may obtain a copy of the License at
176
+
177
+ http://www.apache.org/licenses/LICENSE-2.0
178
+
179
+ Unless required by applicable law or agreed to in writing, software
180
+ distributed under the License is distributed on an "AS IS" BASIS,
181
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
182
+ implied. See the License for the specific language governing
183
+ permissions and limitations under the License.
package/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # @agentspec/claude-plugin
2
+
3
+ A [Claude Code](https://code.claude.com/docs/en/claude-code) plugin that makes a
4
+ Claude Code session **live-observed and policy-governed by AgentSpec — with no
5
+ fork of Claude Code**. Your `agent.yaml` becomes the source of truth; the plugin
6
+ rides Claude Code's documented extension surface (hooks + MCP + statusline).
7
+
8
+ ## What it does
9
+
10
+ | Capability | Mechanism |
11
+ |---|---|
12
+ | **Live spec↔runtime gap** | A per-user bridge daemon serves `GET /agentspec/health`. The AgentSpec sidecar control-plane polls it and flips `/gap` · `/explore` · `/health/ready` to **live** mode (`source: agent-sdk`) — the exact endpoint the sidecar expects from an SDK-integrated agent. |
13
+ | **Policy enforcement** (opt-in) | With `AGENTSPEC_ENFORCE` set, a `PreToolUse` hook denies `Task` spawns whose `subagent_type` isn't in `spec.subagents`, denies tools not in `spec.tools`, and asks-for-approval on `destructiveHint` tools when `spec.humanInTheLoop` requires it. **Off by default** — see [Enforcement](#enforcement-opt-in). |
14
+ | **Live tool/health status** | `PreToolUse`/`PostToolUse`/`SubagentStop` hooks feed the bridge, which synthesizes the `tool` health category from the real event stream. |
15
+ | **Status line** | `agentspec-statusline` renders `● healthy · gap 3 · tools 11/12` from cached daemon data. |
16
+ | **Self-query** | Bundles the AgentSpec MCP server so Claude can call `agentspec_gap`/`agentspec_audit` in-session. |
17
+
18
+ ## How it works
19
+
20
+ ```
21
+ claude (closed binary)
22
+ │ hooks (SessionStart / PreToolUse / PostToolUse / SubagentStop / SessionEnd)
23
+
24
+ agentspec-bridge daemon (127.0.0.1, per-user, lockfile-guarded)
25
+ ├─ :8000 GET /agentspec/health ← the sidecar polls this (the "hinge")
26
+ │ POST /events ← hook observation sink
27
+ │ POST /decide ← enforcement decision (from agent.yaml)
28
+ └─ :4001 control-plane → /gap /explore /health/ready (live)
29
+ ```
30
+
31
+ The bridge sets `UPSTREAM_URL=http://127.0.0.1:8000` **before** importing the
32
+ sidecar control-plane (`@agentspec/sidecar/embed`), so the control-plane's
33
+ `probeAgent()` targets the bridge and serves live data. Claude Code is never
34
+ introspected — the bridge reconstructs runtime state from config + the hook
35
+ event stream.
36
+
37
+ ## Quick start
38
+
39
+ ```bash
40
+ # 1. Derive agent.yaml from your .claude/ config (deterministic, no LLM):
41
+ agentspec scan --dir . --profile claude-code
42
+
43
+ # 2. Install + enable the plugin.
44
+ # Published: npm i -g @agentspec/claude-plugin && /plugin marketplace add agents-oss/agentspec && /plugin install agentspec
45
+ # Local/dev: pnpm -F @agentspec/claude-plugin build && (cd packages/claude-plugin && npm link)
46
+ # /plugin marketplace add /abs/path/to/agentspec && /plugin install agentspec
47
+ # (or commit .claude/settings.json → { "enabledPlugins": { "agentspec": true }, "statusLine": { "type": "command", "command": "agentspec-statusline" } })
48
+
49
+ # 3. Start a session. SessionStart backgrounds the bridge; the sidecar shows live state:
50
+ curl 127.0.0.1:4001/gap # → { "source": "agent-sdk", ... }
51
+ ```
52
+
53
+ > **Distribution status:** the repo ships `.claude-plugin/marketplace.json`, so the local/dev route works today. `npm i -g @agentspec/claude-plugin` requires the first npm publish (tracked in `task.md`).
54
+
55
+ The daemon is a per-user background process (like a language server): the
56
+ `SessionStart` hook does an idempotent ensure-running via a lockfile under
57
+ `.claude/agentspec/`, it binds **loopback only**, and it is left running across
58
+ sessions.
59
+
60
+ ## Enforcement (opt-in)
61
+
62
+ The plugin is **observe-only by default** — it records events, serves health, and
63
+ drives the status line, but never blocks a tool call. Turn gates on with
64
+ `AGENTSPEC_ENFORCE` (a comma list) in your environment or `.claude/settings.json`
65
+ `env`:
66
+
67
+ | `AGENTSPEC_ENFORCE` | Effect |
68
+ |---|---|
69
+ | *(unset)* | Observe only. Every tool call is allowed. |
70
+ | `subagents` | Deny `Task` spawns not declared in `spec.subagents`. |
71
+ | `tools` | Deny tools not in `spec.tools`; ask-for-approval on `destructiveHint` tools when `spec.humanInTheLoop` requires it. |
72
+ | `tools,subagents` | Both gates. |
73
+
74
+ Why opt-in: `scan --profile claude-code` builds `spec.tools` from
75
+ `.claude/settings.json` `permissions.allow`, which is Claude Code's *auto-approve*
76
+ list — not an exhaustive capability list. Enforcing it by default would deny
77
+ tools the session would normally permit. Likewise, **unspecified** `spec.subagents`
78
+ (the scan default when there is no `.claude/agents/`) means *unrestricted*; only an
79
+ **explicit** `spec.subagents: []` means "deny all delegation."
80
+
81
+ ## Fidelity note
82
+
83
+ Infra checks (`env`/`model`/`mcp`/`memory`/`service`) are real reachability
84
+ probes from the SDK. The `tool` category is reconstructed from the hook stream:
85
+ a tool that ran is `pass`/`fail` by its outcome; a declared tool not yet invoked
86
+ this session is `skip` (no behavioral evidence yet — the same blind spot the
87
+ in-process Reporter has for a registered-but-uncalled tool).
88
+
89
+ ## Layout
90
+
91
+ ```
92
+ .claude-plugin/plugin.json plugin manifest (references hooks + mcp)
93
+ hooks/hooks.json + *.mjs lifecycle hooks (dependency-free, fail-open)
94
+ .mcp.json registers @agentspec/mcp for the session
95
+ src/policy.ts enforcement decision (pure, tested)
96
+ src/event-log.ts hook event → tool observations (pure, tested)
97
+ src/health-overlay.ts synthesize the `tool` category (pure, tested)
98
+ src/bridge.ts the daemon (bin: agentspec-bridge)
99
+ src/statusline.ts the status line (bin: agentspec-statusline)
100
+ ```
package/dist/bridge.js ADDED
@@ -0,0 +1,375 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/bridge.ts
4
+ import { createServer } from "http";
5
+ import { spawn } from "child_process";
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "fs";
7
+ import { join, resolve } from "path";
8
+ import { fileURLToPath } from "url";
9
+ import { loadManifest, runHealthCheck } from "@agentspec/sdk";
10
+
11
+ // src/slugify.ts
12
+ function slugify(s) {
13
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
14
+ }
15
+
16
+ // src/event-log.ts
17
+ function mcpServerOf(toolName) {
18
+ if (!toolName.startsWith("mcp__")) return void 0;
19
+ const parts = toolName.split("__");
20
+ return parts[1] || void 0;
21
+ }
22
+ var EventLog = class {
23
+ events = [];
24
+ /** Slugified tool name → best-known outcome this session. */
25
+ toolOutcomes = /* @__PURE__ */ new Map();
26
+ /** Slugified MCP server name → observed as reachable. */
27
+ mcpSeen = /* @__PURE__ */ new Set();
28
+ record(ev) {
29
+ this.events.push(ev);
30
+ if (!ev.toolName) return;
31
+ const key = slugify(ev.toolName);
32
+ if (ev.event === "PreToolUse") {
33
+ if (!this.toolOutcomes.has(key)) this.toolOutcomes.set(key, "pass");
34
+ } else if (ev.event === "PostToolUse") {
35
+ this.toolOutcomes.set(key, ev.ok === false ? "fail" : "pass");
36
+ }
37
+ const server = ev.mcpServer ?? mcpServerOf(ev.toolName);
38
+ if (server) this.mcpSeen.add(slugify(server));
39
+ }
40
+ /** Slugified-tool → 'pass'|'fail' for every tool observed this session. */
41
+ toolObservations() {
42
+ return Object.fromEntries(this.toolOutcomes);
43
+ }
44
+ /** Slugified MCP server names observed as reachable this session. */
45
+ observedMcpServers() {
46
+ return [...this.mcpSeen];
47
+ }
48
+ size() {
49
+ return this.events.length;
50
+ }
51
+ };
52
+
53
+ // src/health-overlay.ts
54
+ function toolCheck(name, outcome) {
55
+ if (outcome === "pass") {
56
+ return { id: `tool:${name}`, category: "tool", status: "pass", severity: "info" };
57
+ }
58
+ if (outcome === "fail") {
59
+ return {
60
+ id: `tool:${name}`,
61
+ category: "tool",
62
+ status: "fail",
63
+ severity: "warning",
64
+ message: "Tool call errored this session"
65
+ };
66
+ }
67
+ return {
68
+ id: `tool:${name}`,
69
+ category: "tool",
70
+ status: "skip",
71
+ severity: "info",
72
+ message: "Declared but not yet invoked this session"
73
+ };
74
+ }
75
+ function recount(checks) {
76
+ return {
77
+ passed: checks.filter((c) => c.status === "pass").length,
78
+ failed: checks.filter((c) => c.status === "fail").length,
79
+ warnings: checks.filter((c) => c.status === "warn").length,
80
+ skipped: checks.filter((c) => c.status === "skip").length
81
+ };
82
+ }
83
+ function overallStatus(checks) {
84
+ const hasError = checks.some((c) => c.status === "fail" && c.severity === "error");
85
+ const hasWarning = checks.some((c) => c.status === "fail" && c.severity === "warning") || checks.some((c) => c.status === "warn");
86
+ return hasError ? "unhealthy" : hasWarning ? "degraded" : "healthy";
87
+ }
88
+ function buildLiveReport(base, manifest, observations) {
89
+ const toolChecks = (manifest.spec.tools ?? []).map((t) => {
90
+ const name = slugify(t.name);
91
+ return toolCheck(name, observations[name]);
92
+ });
93
+ const checks = [...base.checks, ...toolChecks];
94
+ return {
95
+ ...base,
96
+ checks,
97
+ status: overallStatus(checks),
98
+ summary: recount(checks)
99
+ };
100
+ }
101
+
102
+ // src/policy.ts
103
+ var ALLOW = { decision: "allow" };
104
+ function declaredToolNames(manifest) {
105
+ return (manifest.spec.tools ?? []).map((t) => slugify(t.name));
106
+ }
107
+ function requiresApprovalBeforeDestructive(manifest) {
108
+ return (manifest.spec.humanInTheLoop?.approvalRequired ?? []).includes("before-destructive-tool");
109
+ }
110
+ function decideTask(manifest, event) {
111
+ const subagents = manifest.spec.subagents;
112
+ if (subagents === void 0) return ALLOW;
113
+ const allowed = subagents.map((s) => slugify(s.name));
114
+ if (allowed.length === 0) {
115
+ return {
116
+ decision: "deny",
117
+ reason: "spec.subagents is explicitly empty \u2014 this agent declares no delegation. Task calls are not permitted."
118
+ };
119
+ }
120
+ const requested = event.tool_input?.["subagent_type"];
121
+ if (typeof requested !== "string") {
122
+ return {
123
+ decision: "deny",
124
+ reason: `Task call has no subagent_type \u2014 cannot verify against spec.subagents (allowed: ${allowed.join(", ")}).`
125
+ };
126
+ }
127
+ if (!allowed.includes(slugify(requested))) {
128
+ return {
129
+ decision: "deny",
130
+ reason: `Subagent "${requested}" is not declared in spec.subagents (allowed: ${allowed.join(", ")}).`
131
+ };
132
+ }
133
+ return ALLOW;
134
+ }
135
+ function decideTool(manifest, event) {
136
+ const declared = declaredToolNames(manifest);
137
+ if (declared.length === 0) return ALLOW;
138
+ const name = slugify(event.tool_name);
139
+ if (!declared.includes(name)) {
140
+ return {
141
+ decision: "deny",
142
+ reason: `Tool "${event.tool_name}" is not declared in spec.tools.`
143
+ };
144
+ }
145
+ const tool = (manifest.spec.tools ?? []).find((t) => slugify(t.name) === name);
146
+ if (tool?.annotations?.destructiveHint && requiresApprovalBeforeDestructive(manifest)) {
147
+ return {
148
+ decision: "ask",
149
+ reason: `Tool "${event.tool_name}" is marked destructive and spec.humanInTheLoop requires approval before destructive tools.`
150
+ };
151
+ }
152
+ return ALLOW;
153
+ }
154
+ function decide(manifest, event, enforce = /* @__PURE__ */ new Set()) {
155
+ if (event.tool_name === "Task") {
156
+ return enforce.has("subagents") ? decideTask(manifest, event) : ALLOW;
157
+ }
158
+ return enforce.has("tools") ? decideTool(manifest, event) : ALLOW;
159
+ }
160
+ function parseEnforce(raw) {
161
+ const gates = /* @__PURE__ */ new Set();
162
+ for (const token of (raw ?? "").split(",").map((t) => t.trim().toLowerCase())) {
163
+ if (token === "tools" || token === "subagents") gates.add(token);
164
+ }
165
+ return gates;
166
+ }
167
+
168
+ // src/bridge.ts
169
+ var BRIDGE_HOST = "127.0.0.1";
170
+ var BRIDGE_PORT = Number(process.env["AGENTSPEC_BRIDGE_PORT"] ?? 8e3);
171
+ var CONTROL_PLANE_PORT = Number(process.env["AGENTSPEC_CONTROL_PLANE_PORT"] ?? 4001);
172
+ var HEALTH_REFRESH_MS = 3e4;
173
+ function projectDir() {
174
+ return resolve(process.env["AGENTSPEC_PROJECT_DIR"] ?? process.cwd());
175
+ }
176
+ function stateDir() {
177
+ return join(projectDir(), ".claude", "agentspec");
178
+ }
179
+ function manifestPath() {
180
+ return process.env["AGENTSPEC_MANIFEST"] ?? join(projectDir(), "agent.yaml");
181
+ }
182
+ function pidFile() {
183
+ return join(stateDir(), "bridge.pid");
184
+ }
185
+ function lastReportFile() {
186
+ return join(stateDir(), "last-report.json");
187
+ }
188
+ function isRunning() {
189
+ const f = pidFile();
190
+ if (!existsSync(f)) return null;
191
+ try {
192
+ const pid = Number(JSON.parse(readFileSync(f, "utf-8")).pid);
193
+ process.kill(pid, 0);
194
+ return pid;
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+ function writeLock() {
200
+ mkdirSync(stateDir(), { recursive: true });
201
+ writeFileSync(
202
+ pidFile(),
203
+ JSON.stringify({ pid: process.pid, bridgePort: BRIDGE_PORT, controlPlanePort: CONTROL_PLANE_PORT })
204
+ );
205
+ }
206
+ function clearLock() {
207
+ try {
208
+ rmSync(pidFile());
209
+ } catch {
210
+ }
211
+ }
212
+ var HealthCache = class {
213
+ constructor(manifest, baseDir, events) {
214
+ this.manifest = manifest;
215
+ this.baseDir = baseDir;
216
+ this.events = events;
217
+ }
218
+ base = null;
219
+ start() {
220
+ void this.refresh();
221
+ const t = setInterval(() => void this.refresh(), HEALTH_REFRESH_MS);
222
+ if (t.unref) t.unref();
223
+ }
224
+ async refresh() {
225
+ try {
226
+ this.base = await runHealthCheck(this.manifest, { baseDir: this.baseDir });
227
+ } catch {
228
+ }
229
+ }
230
+ /** Live report = cached infra checks + synthesized tool checks from hooks. */
231
+ async live() {
232
+ if (!this.base) await this.refresh();
233
+ const base = this.base ?? {
234
+ agentName: this.manifest.metadata.name,
235
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
236
+ status: "degraded",
237
+ summary: { passed: 0, failed: 0, warnings: 1, skipped: 0 },
238
+ checks: []
239
+ };
240
+ const report = buildLiveReport(base, this.manifest, this.events.toolObservations());
241
+ try {
242
+ writeFileSync(lastReportFile(), JSON.stringify(report));
243
+ } catch {
244
+ }
245
+ return report;
246
+ }
247
+ };
248
+ function sendJson(res, code, body) {
249
+ const payload = JSON.stringify(body);
250
+ res.writeHead(code, { "Content-Type": "application/json" });
251
+ res.end(payload);
252
+ }
253
+ async function readBody(req) {
254
+ const chunks = [];
255
+ for await (const c of req) chunks.push(c);
256
+ if (chunks.length === 0) return {};
257
+ try {
258
+ return JSON.parse(Buffer.concat(chunks).toString("utf-8"));
259
+ } catch {
260
+ return {};
261
+ }
262
+ }
263
+ function isToolFailure(output) {
264
+ if (output.isError === true) return true;
265
+ return typeof output.exit_code === "number" && output.exit_code !== 0;
266
+ }
267
+ function buildBridgeServer(manifest, cache, events, enforce) {
268
+ return createServer((req, res) => {
269
+ void handle(req, res).catch((err) => sendJson(res, 500, { error: String(err) }));
270
+ });
271
+ async function handle(req, res) {
272
+ const url = req.url ?? "/";
273
+ if (req.method === "GET" && url === "/agentspec/health") {
274
+ return sendJson(res, 200, await cache.live());
275
+ }
276
+ if (req.method === "POST" && url === "/events") {
277
+ const body = await readBody(req);
278
+ const ev = String(body.event ?? "PreToolUse");
279
+ events.record({
280
+ event: ev === "PostToolUse" || ev === "SubagentStop" ? ev : "PreToolUse",
281
+ toolName: body.tool_name,
282
+ // A result is a failure only if explicitly signalled — isError, or a
283
+ // present non-zero exit code. Most tools (Read/Edit/MCP) report neither
284
+ // an exit_code nor isError on success, so absence ⇒ success.
285
+ ok: body.tool_output ? !isToolFailure(body.tool_output) : void 0,
286
+ ts: Date.now()
287
+ });
288
+ return sendJson(res, 200, { ok: true, events: events.size() });
289
+ }
290
+ if (req.method === "POST" && url === "/decide") {
291
+ const body = await readBody(req);
292
+ return sendJson(
293
+ res,
294
+ 200,
295
+ decide(manifest, { tool_name: body.tool_name, tool_input: body.tool_input }, enforce)
296
+ );
297
+ }
298
+ sendJson(res, 404, { error: "not found" });
299
+ }
300
+ }
301
+ function cmdEnsure() {
302
+ const pid = isRunning();
303
+ if (pid) {
304
+ process.stdout.write(`agentspec-bridge already running (pid ${pid})
305
+ `);
306
+ return;
307
+ }
308
+ const self = fileURLToPath(import.meta.url);
309
+ const child = spawn(process.execPath, [self, "start"], {
310
+ detached: true,
311
+ stdio: "ignore",
312
+ env: { ...process.env, AGENTSPEC_PROJECT_DIR: projectDir() }
313
+ });
314
+ child.unref();
315
+ process.stdout.write(`agentspec-bridge started (pid ${child.pid})
316
+ `);
317
+ }
318
+ async function cmdStart() {
319
+ if (isRunning()) {
320
+ process.stderr.write("agentspec-bridge already running\n");
321
+ process.exit(0);
322
+ }
323
+ if (!existsSync(manifestPath())) {
324
+ process.stderr.write(
325
+ `agentspec-bridge: no manifest at ${manifestPath()}. Run: agentspec scan --dir . --profile claude-code
326
+ `
327
+ );
328
+ process.exit(1);
329
+ }
330
+ const { manifest } = loadManifest(manifestPath());
331
+ const events = new EventLog();
332
+ const cache = new HealthCache(manifest, projectDir(), events);
333
+ cache.start();
334
+ process.env["UPSTREAM_URL"] = `http://${BRIDGE_HOST}:${BRIDGE_PORT}`;
335
+ const { buildControlPlaneApp, AuditRing } = await import("@agentspec/sidecar/embed");
336
+ const cp = await buildControlPlaneApp(manifest, new AuditRing(), { startedAt: Date.now() });
337
+ const enforce = parseEnforce(process.env["AGENTSPEC_ENFORCE"]);
338
+ const bridge = buildBridgeServer(manifest, cache, events, enforce);
339
+ await new Promise((r) => bridge.listen(BRIDGE_PORT, BRIDGE_HOST, r));
340
+ await cp.listen({ port: CONTROL_PLANE_PORT, host: BRIDGE_HOST });
341
+ writeLock();
342
+ process.stdout.write(
343
+ `agentspec-bridge listening \u2014 health http://${BRIDGE_HOST}:${BRIDGE_PORT}/agentspec/health, control-plane http://${BRIDGE_HOST}:${CONTROL_PLANE_PORT}
344
+ `
345
+ );
346
+ const shutdown = async () => {
347
+ clearLock();
348
+ await new Promise((r) => bridge.close(() => r()));
349
+ await cp.close();
350
+ process.exit(0);
351
+ };
352
+ process.once("SIGTERM", () => void shutdown());
353
+ process.once("SIGINT", () => void shutdown());
354
+ }
355
+ function cmdStop() {
356
+ const pid = isRunning();
357
+ if (!pid) {
358
+ process.stdout.write("agentspec-bridge not running\n");
359
+ clearLock();
360
+ return;
361
+ }
362
+ process.kill(pid, "SIGTERM");
363
+ process.stdout.write(`agentspec-bridge stopped (pid ${pid})
364
+ `);
365
+ }
366
+ var sub = process.argv[2] ?? "ensure";
367
+ if (sub === "ensure") cmdEnsure();
368
+ else if (sub === "start") void cmdStart();
369
+ else if (sub === "stop") cmdStop();
370
+ else {
371
+ process.stderr.write(`unknown subcommand "${sub}" (use: ensure | start | stop)
372
+ `);
373
+ process.exit(1);
374
+ }
375
+ //# sourceMappingURL=bridge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/bridge.ts","../src/slugify.ts","../src/event-log.ts","../src/health-overlay.ts","../src/policy.ts"],"sourcesContent":["/**\n * agentspec-bridge — the per-user local daemon (plan Part D / Part 4).\n *\n * Runs two loopback servers in one process:\n * :8000 (UPSTREAM_URL) bridge — GET /agentspec/health, POST /events, POST /decide\n * :4001 (control-plane) sidecar — GET /gap /explore /health/ready (reused as-is)\n *\n * The control-plane's probeAgent() polls http://127.0.0.1:8000/agentspec/health\n * (agent-probe.ts) and, because the bridge serves a valid HealthReport, flips\n * every route to live mode (source: 'agent-sdk'). Claude Code is never touched.\n *\n * IMPORTANT (plan C2): UPSTREAM_URL must be set BEFORE the sidecar control-plane\n * module is imported — config.ts reads it at module load. We set env, then\n * dynamic-import '@agentspec/sidecar/embed'.\n *\n * Subcommands:\n * ensure — idempotent: spawn the daemon detached if not already running\n * start — run the servers in the foreground (invoked by `ensure`)\n * stop — terminate a running daemon via its pidfile\n */\n\nimport { createServer, type IncomingMessage, type ServerResponse } from 'node:http'\nimport { spawn } from 'node:child_process'\nimport { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'\nimport { join, resolve } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { loadManifest, runHealthCheck, type HealthReport, type AgentSpecManifest } from '@agentspec/sdk'\nimport { EventLog } from './event-log.js'\nimport { buildLiveReport } from './health-overlay.js'\nimport { decide, parseEnforce, type EnforceGate, type ToolCallEvent } from './policy.js'\n\n// ── Config ──────────────────────────────────────────────────────────────────────\n\nconst BRIDGE_HOST = '127.0.0.1'\nconst BRIDGE_PORT = Number(process.env['AGENTSPEC_BRIDGE_PORT'] ?? 8000)\nconst CONTROL_PLANE_PORT = Number(process.env['AGENTSPEC_CONTROL_PLANE_PORT'] ?? 4001)\nconst HEALTH_REFRESH_MS = 30_000\n\nfunction projectDir(): string {\n return resolve(process.env['AGENTSPEC_PROJECT_DIR'] ?? process.cwd())\n}\nfunction stateDir(): string {\n return join(projectDir(), '.claude', 'agentspec')\n}\nfunction manifestPath(): string {\n return process.env['AGENTSPEC_MANIFEST'] ?? join(projectDir(), 'agent.yaml')\n}\nfunction pidFile(): string {\n return join(stateDir(), 'bridge.pid')\n}\nfunction lastReportFile(): string {\n return join(stateDir(), 'last-report.json')\n}\n\n// ── Lockfile ──────────────────────────────────────────────────────────────────\n\nfunction isRunning(): number | null {\n const f = pidFile()\n if (!existsSync(f)) return null\n try {\n const pid = Number(JSON.parse(readFileSync(f, 'utf-8')).pid)\n process.kill(pid, 0) // throws if the process is gone\n return pid\n } catch {\n return null\n }\n}\n\nfunction writeLock(): void {\n mkdirSync(stateDir(), { recursive: true })\n writeFileSync(\n pidFile(),\n JSON.stringify({ pid: process.pid, bridgePort: BRIDGE_PORT, controlPlanePort: CONTROL_PLANE_PORT }),\n )\n}\n\nfunction clearLock(): void {\n try {\n rmSync(pidFile())\n } catch {\n /* already gone */\n }\n}\n\n// ── Health cache (mirrors the Reporter's background-refresh pattern) ─────────────\n\nclass HealthCache {\n private base: HealthReport | null = null\n\n constructor(\n private readonly manifest: AgentSpecManifest,\n private readonly baseDir: string,\n private readonly events: EventLog,\n ) {}\n\n start(): void {\n void this.refresh()\n const t = setInterval(() => void this.refresh(), HEALTH_REFRESH_MS)\n if (t.unref) t.unref()\n }\n\n private async refresh(): Promise<void> {\n try {\n this.base = await runHealthCheck(this.manifest, { baseDir: this.baseDir })\n } catch {\n /* keep the previous base on error */\n }\n }\n\n /** Live report = cached infra checks + synthesized tool checks from hooks. */\n async live(): Promise<HealthReport> {\n if (!this.base) await this.refresh()\n const base = this.base ?? {\n agentName: this.manifest.metadata.name,\n timestamp: new Date().toISOString(),\n status: 'degraded' as const,\n summary: { passed: 0, failed: 0, warnings: 1, skipped: 0 },\n checks: [],\n }\n const report = buildLiveReport(base, this.manifest, this.events.toolObservations())\n try {\n writeFileSync(lastReportFile(), JSON.stringify(report))\n } catch {\n /* best-effort persistence for the statusline */\n }\n return report\n }\n}\n\n// ── HTTP helpers ────────────────────────────────────────────────────────────────\n\nfunction sendJson(res: ServerResponse, code: number, body: unknown): void {\n const payload = JSON.stringify(body)\n res.writeHead(code, { 'Content-Type': 'application/json' })\n res.end(payload)\n}\n\nasync function readBody(req: IncomingMessage): Promise<unknown> {\n const chunks: Buffer[] = []\n for await (const c of req) chunks.push(c as Buffer)\n if (chunks.length === 0) return {}\n try {\n return JSON.parse(Buffer.concat(chunks).toString('utf-8'))\n } catch {\n return {}\n }\n}\n\n/** A PostToolUse result is a failure only if explicitly signalled. */\nfunction isToolFailure(output: { exit_code?: number; isError?: boolean }): boolean {\n if (output.isError === true) return true\n return typeof output.exit_code === 'number' && output.exit_code !== 0\n}\n\n// ── Bridge server ────────────────────────────────────────────────────────────────\n\nfunction buildBridgeServer(\n manifest: AgentSpecManifest,\n cache: HealthCache,\n events: EventLog,\n enforce: ReadonlySet<EnforceGate>,\n): ReturnType<typeof createServer> {\n return createServer((req, res) => {\n void handle(req, res).catch((err) => sendJson(res, 500, { error: String(err) }))\n })\n\n async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {\n const url = req.url ?? '/'\n\n // The hinge the sidecar polls.\n if (req.method === 'GET' && url === '/agentspec/health') {\n return sendJson(res, 200, await cache.live())\n }\n\n // Hook event sink (observation).\n if (req.method === 'POST' && url === '/events') {\n const body = (await readBody(req)) as {\n event?: string\n tool_name?: string\n tool_output?: { exit_code?: number; isError?: boolean }\n }\n const ev = String(body.event ?? 'PreToolUse')\n events.record({\n event: ev === 'PostToolUse' || ev === 'SubagentStop' ? ev : 'PreToolUse',\n toolName: body.tool_name,\n // A result is a failure only if explicitly signalled — isError, or a\n // present non-zero exit code. Most tools (Read/Edit/MCP) report neither\n // an exit_code nor isError on success, so absence ⇒ success.\n ok: body.tool_output ? !isToolFailure(body.tool_output) : undefined,\n ts: Date.now(),\n })\n return sendJson(res, 200, { ok: true, events: events.size() })\n }\n\n // Enforcement decision (the PreToolUse hook calls this and echoes the verdict).\n if (req.method === 'POST' && url === '/decide') {\n const body = (await readBody(req)) as ToolCallEvent\n return sendJson(\n res,\n 200,\n decide(manifest, { tool_name: body.tool_name, tool_input: body.tool_input }, enforce),\n )\n }\n\n sendJson(res, 404, { error: 'not found' })\n }\n}\n\n// ── Subcommands ──────────────────────────────────────────────────────────────────\n\nfunction cmdEnsure(): void {\n const pid = isRunning()\n if (pid) {\n process.stdout.write(`agentspec-bridge already running (pid ${pid})\\n`)\n return\n }\n const self = fileURLToPath(import.meta.url)\n const child = spawn(process.execPath, [self, 'start'], {\n detached: true,\n stdio: 'ignore',\n env: { ...process.env, AGENTSPEC_PROJECT_DIR: projectDir() },\n })\n child.unref()\n process.stdout.write(`agentspec-bridge started (pid ${child.pid})\\n`)\n}\n\nasync function cmdStart(): Promise<void> {\n if (isRunning()) {\n process.stderr.write('agentspec-bridge already running\\n')\n process.exit(0)\n }\n if (!existsSync(manifestPath())) {\n process.stderr.write(\n `agentspec-bridge: no manifest at ${manifestPath()}. ` +\n `Run: agentspec scan --dir . --profile claude-code\\n`,\n )\n process.exit(1)\n }\n\n const { manifest } = loadManifest(manifestPath())\n const events = new EventLog()\n const cache = new HealthCache(manifest, projectDir(), events)\n cache.start()\n\n // Point the control-plane at the bridge BEFORE importing it (C2).\n process.env['UPSTREAM_URL'] = `http://${BRIDGE_HOST}:${BRIDGE_PORT}`\n const { buildControlPlaneApp, AuditRing } = await import('@agentspec/sidecar/embed')\n const cp = await buildControlPlaneApp(manifest, new AuditRing(), { startedAt: Date.now() })\n\n // Enforcement is opt-in via AGENTSPEC_ENFORCE (e.g. \"tools,subagents\").\n // Unset ⇒ observe-only: /decide always allows.\n const enforce = parseEnforce(process.env['AGENTSPEC_ENFORCE'])\n const bridge = buildBridgeServer(manifest, cache, events, enforce)\n await new Promise<void>((r) => bridge.listen(BRIDGE_PORT, BRIDGE_HOST, r))\n await cp.listen({ port: CONTROL_PLANE_PORT, host: BRIDGE_HOST })\n\n writeLock()\n process.stdout.write(\n `agentspec-bridge listening — health http://${BRIDGE_HOST}:${BRIDGE_PORT}/agentspec/health, ` +\n `control-plane http://${BRIDGE_HOST}:${CONTROL_PLANE_PORT}\\n`,\n )\n\n const shutdown = async (): Promise<void> => {\n clearLock()\n await new Promise<void>((r) => bridge.close(() => r()))\n await cp.close()\n process.exit(0)\n }\n process.once('SIGTERM', () => void shutdown())\n process.once('SIGINT', () => void shutdown())\n}\n\nfunction cmdStop(): void {\n const pid = isRunning()\n if (!pid) {\n process.stdout.write('agentspec-bridge not running\\n')\n clearLock()\n return\n }\n process.kill(pid, 'SIGTERM')\n process.stdout.write(`agentspec-bridge stopped (pid ${pid})\\n`)\n}\n\n// ── Entry ─────────────────────────────────────────────────────────────────────\n\nconst sub = process.argv[2] ?? 'ensure'\nif (sub === 'ensure') cmdEnsure()\nelse if (sub === 'start') void cmdStart()\nelse if (sub === 'stop') cmdStop()\nelse {\n process.stderr.write(`unknown subcommand \"${sub}\" (use: ensure | start | stop)\\n`)\n process.exit(1)\n}\n","/**\n * Lowercase slug: `a-z`, `0-9`, hyphens only. Mirrors the CLI scan builder's\n * slugify (`packages/cli/src/commands/scan-builder.ts:257`) so tool/subagent\n * names produced by `scan --profile claude-code` match what this plugin's\n * policy gate compares against. Kept local to avoid a plugin→cli dependency.\n *\n * \"Read\" → \"read\"\n * \"mcp__github__create_issue\" → \"mcp-github-create-issue\"\n * \"code-reviewer\" → \"code-reviewer\"\n */\nexport function slugify(s: string): string {\n return s\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/-+/g, '-')\n .replace(/^-+|-+$/g, '')\n}\n","/**\n * In-memory record of the live Claude Code hook event stream.\n *\n * Hooks are the behavioral ground truth (plan §3.1): every real tool call fires\n * PreToolUse (tool_name + tool_input) and PostToolUse (tool_output). This log\n * accumulates those events and derives, per declared tool, whether it has\n * actually run this session and whether it succeeded — the input the health\n * overlay turns into `tool`-category checks.\n *\n * Pure data structure — no I/O — unit-tested directly.\n */\n\nimport { slugify } from './slugify.js'\n\nexport type ToolOutcome = 'pass' | 'fail'\n\nexport interface RecordedEvent {\n event: 'PreToolUse' | 'PostToolUse' | 'SubagentStop'\n toolName?: string\n /** PostToolUse: whether the call succeeded (derived from tool_output). */\n ok?: boolean\n /** MCP server names observed via mcp__server__* tool calls. */\n mcpServer?: string\n ts: number\n}\n\n/** Extract the MCP server name from an `mcp__server__tool` tool name, if any. */\nfunction mcpServerOf(toolName: string): string | undefined {\n if (!toolName.startsWith('mcp__')) return undefined\n const parts = toolName.split('__')\n return parts[1] || undefined\n}\n\nexport class EventLog {\n private readonly events: RecordedEvent[] = []\n /** Slugified tool name → best-known outcome this session. */\n private readonly toolOutcomes = new Map<string, ToolOutcome>()\n /** Slugified MCP server name → observed as reachable. */\n private readonly mcpSeen = new Set<string>()\n\n record(ev: RecordedEvent): void {\n this.events.push(ev)\n if (!ev.toolName) return\n\n const key = slugify(ev.toolName)\n if (ev.event === 'PreToolUse') {\n // Seen firing, outcome not yet known — only downgrade if nothing recorded.\n if (!this.toolOutcomes.has(key)) this.toolOutcomes.set(key, 'pass')\n } else if (ev.event === 'PostToolUse') {\n this.toolOutcomes.set(key, ev.ok === false ? 'fail' : 'pass')\n }\n\n const server = ev.mcpServer ?? mcpServerOf(ev.toolName)\n if (server) this.mcpSeen.add(slugify(server))\n }\n\n /** Slugified-tool → 'pass'|'fail' for every tool observed this session. */\n toolObservations(): Record<string, ToolOutcome> {\n return Object.fromEntries(this.toolOutcomes)\n }\n\n /** Slugified MCP server names observed as reachable this session. */\n observedMcpServers(): string[] {\n return [...this.mcpSeen]\n }\n\n size(): number {\n return this.events.length\n }\n}\n","/**\n * Overlay live hook observations onto a base HealthReport to synthesize the\n * `tool` category the generic runHealthCheck() does not emit (plan C4).\n *\n * runHealthCheck (sdk/src/health/index.ts) covers env/model/mcp/memory/service/\n * subagent/eval. The agent-side Reporter is the only thing that normally adds\n * `tool` checks (reporter.ts:247). Here the bridge reconstructs them from the\n * declared tool list plus what the hook stream actually observed firing:\n * - observed 'pass' → tool ran and succeeded → pass\n * - observed 'fail' → tool ran and errored → fail\n * - not observed → declared but not invoked this run → skip (honest: no\n * behavioral evidence yet — same blind spot the real\n * Reporter has for a registered-but-uncalled tool)\n *\n * Pure function — unit-tested directly.\n */\n\nimport type { HealthReport, HealthCheck } from '@agentspec/sdk'\nimport type { AgentSpecManifest } from '@agentspec/sdk'\nimport type { ToolOutcome } from './event-log.js'\nimport { slugify } from './slugify.js'\n\nfunction toolCheck(name: string, outcome: ToolOutcome | undefined): HealthCheck {\n if (outcome === 'pass') {\n return { id: `tool:${name}`, category: 'tool', status: 'pass', severity: 'info' }\n }\n if (outcome === 'fail') {\n return {\n id: `tool:${name}`,\n category: 'tool',\n status: 'fail',\n severity: 'warning',\n message: 'Tool call errored this session',\n }\n }\n return {\n id: `tool:${name}`,\n category: 'tool',\n status: 'skip',\n severity: 'info',\n message: 'Declared but not yet invoked this session',\n }\n}\n\nfunction recount(checks: HealthCheck[]): HealthReport['summary'] {\n return {\n passed: checks.filter((c) => c.status === 'pass').length,\n failed: checks.filter((c) => c.status === 'fail').length,\n warnings: checks.filter((c) => c.status === 'warn').length,\n skipped: checks.filter((c) => c.status === 'skip').length,\n }\n}\n\nfunction overallStatus(checks: HealthCheck[]): HealthReport['status'] {\n const hasError = checks.some((c) => c.status === 'fail' && c.severity === 'error')\n const hasWarning =\n checks.some((c) => c.status === 'fail' && c.severity === 'warning') ||\n checks.some((c) => c.status === 'warn')\n return hasError ? 'unhealthy' : hasWarning ? 'degraded' : 'healthy'\n}\n\n/**\n * Return a new HealthReport with `tool`-category checks appended and the summary\n * / status recomputed over the combined set. The base report is not mutated.\n */\nexport function buildLiveReport(\n base: HealthReport,\n manifest: AgentSpecManifest,\n observations: Record<string, ToolOutcome>,\n): HealthReport {\n const toolChecks = (manifest.spec.tools ?? []).map((t) => {\n const name = slugify(t.name)\n return toolCheck(name, observations[name])\n })\n\n const checks = [...base.checks, ...toolChecks]\n return {\n ...base,\n checks,\n status: overallStatus(checks),\n summary: recount(checks),\n }\n}\n","/**\n * Enforcement policy: turn the declarative agent.yaml into an allow/deny/ask\n * decision for a Claude Code tool call.\n *\n * The manifest is purely declarative (no permission block in the schema) — this\n * module is the enforcement layer the plan describes. Enforcement is OPT-IN: the\n * caller passes the set of gates it wants active (from AGENTSPEC_ENFORCE). With\n * no gate enabled — the default — every decision is `allow` and the plugin only\n * observes. This keeps the out-of-box `scan → enable` path from bricking a\n * normal session (review findings H1/H2).\n *\n * - Subagent gate (opt-in `subagents`): a `Task` spawn is denied unless its\n * subagent_type is in spec.subagents. `spec.subagents` UNSPECIFIED (what\n * `scan` produces when there is no .claude/agents/) ⇒ unrestricted; an\n * EXPLICIT `spec.subagents: []` ⇒ deny all delegation (a deliberate choice).\n * - Tool gate (opt-in `tools`): a tool is denied unless it is in spec.tools.\n * Empty/absent spec.tools ⇒ no allow-list declared ⇒ unrestricted (allow).\n * - Destructive gate (within the tool gate): a declared tool with\n * annotations.destructiveHint asks for approval when\n * humanInTheLoop.approvalRequired includes \"before-destructive-tool\"\n * (schema: manifest.schema.ts:523).\n *\n * Pure function — no I/O — so it is unit-tested directly (see __tests__).\n */\n\nimport type { AgentSpecManifest } from '@agentspec/sdk'\nimport { slugify } from './slugify.js'\n\nexport type PermissionDecision = 'allow' | 'deny' | 'ask'\n\n/** Gates that enforcement can activate (parsed from AGENTSPEC_ENFORCE). */\nexport type EnforceGate = 'tools' | 'subagents'\n\nexport interface PolicyDecision {\n decision: PermissionDecision\n /** Present for deny/ask — surfaced to the model as the reason. */\n reason?: string\n}\n\nexport interface ToolCallEvent {\n tool_name: string\n tool_input?: Record<string, unknown>\n}\n\nconst ALLOW: PolicyDecision = { decision: 'allow' }\n\n// ── Private helpers ────────────────────────────────────────────────────────────\n\nfunction declaredToolNames(manifest: AgentSpecManifest): string[] {\n return (manifest.spec.tools ?? []).map((t) => slugify(t.name))\n}\n\nfunction requiresApprovalBeforeDestructive(manifest: AgentSpecManifest): boolean {\n return (manifest.spec.humanInTheLoop?.approvalRequired ?? []).includes('before-destructive-tool')\n}\n\nfunction decideTask(manifest: AgentSpecManifest, event: ToolCallEvent): PolicyDecision {\n const subagents = manifest.spec.subagents\n // Unspecified (no spec.subagents key) ⇒ not declaring a delegation policy ⇒\n // unrestricted. Only an explicit empty array is a deliberate \"deny all\".\n if (subagents === undefined) return ALLOW\n\n const allowed = subagents.map((s) => slugify(s.name))\n if (allowed.length === 0) {\n return {\n decision: 'deny',\n reason: 'spec.subagents is explicitly empty — this agent declares no delegation. Task calls are not permitted.',\n }\n }\n const requested = event.tool_input?.['subagent_type']\n // Missing or non-string subagent_type can't be matched against the allow-list,\n // so it must not pass the gate (otherwise it silently bypasses enforcement).\n if (typeof requested !== 'string') {\n return {\n decision: 'deny',\n reason: `Task call has no subagent_type — cannot verify against spec.subagents (allowed: ${allowed.join(', ')}).`,\n }\n }\n if (!allowed.includes(slugify(requested))) {\n return {\n decision: 'deny',\n reason: `Subagent \"${requested}\" is not declared in spec.subagents (allowed: ${allowed.join(', ')}).`,\n }\n }\n return ALLOW\n}\n\nfunction decideTool(manifest: AgentSpecManifest, event: ToolCallEvent): PolicyDecision {\n const declared = declaredToolNames(manifest)\n // No allow-list declared → do not restrict.\n if (declared.length === 0) return ALLOW\n\n const name = slugify(event.tool_name)\n if (!declared.includes(name)) {\n return {\n decision: 'deny',\n reason: `Tool \"${event.tool_name}\" is not declared in spec.tools.`,\n }\n }\n\n const tool = (manifest.spec.tools ?? []).find((t) => slugify(t.name) === name)\n if (tool?.annotations?.destructiveHint && requiresApprovalBeforeDestructive(manifest)) {\n return {\n decision: 'ask',\n reason: `Tool \"${event.tool_name}\" is marked destructive and spec.humanInTheLoop requires approval before destructive tools.`,\n }\n }\n return ALLOW\n}\n\n// ── Public orchestrator ────────────────────────────────────────────────────────\n\n/**\n * Decide whether a Claude Code tool call is permitted by the manifest.\n * `Task` (subagent spawn) is routed to the subagent gate; everything else to\n * the tool gate. Each gate runs only when the caller opted into it via\n * `enforce`; a disabled gate always allows (observe-only). Default: no gate.\n */\nexport function decide(\n manifest: AgentSpecManifest,\n event: ToolCallEvent,\n enforce: ReadonlySet<EnforceGate> = new Set(),\n): PolicyDecision {\n if (event.tool_name === 'Task') {\n return enforce.has('subagents') ? decideTask(manifest, event) : ALLOW\n }\n return enforce.has('tools') ? decideTool(manifest, event) : ALLOW\n}\n\n/** Parse an AGENTSPEC_ENFORCE value (\"tools,subagents\") into a gate set. */\nexport function parseEnforce(raw: string | undefined): Set<EnforceGate> {\n const gates = new Set<EnforceGate>()\n for (const token of (raw ?? '').split(',').map((t) => t.trim().toLowerCase())) {\n if (token === 'tools' || token === 'subagents') gates.add(token)\n }\n return gates\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/statusline.ts
4
+ var CONTROL_PLANE = `http://127.0.0.1:${process.env["AGENTSPEC_CONTROL_PLANE_PORT"] ?? 4001}`;
5
+ var BRIDGE = `http://127.0.0.1:${process.env["AGENTSPEC_BRIDGE_PORT"] ?? 8e3}`;
6
+ var FETCH_TIMEOUT_MS = 1200;
7
+ async function readStdin() {
8
+ const chunks = [];
9
+ for await (const c of process.stdin) chunks.push(c);
10
+ if (chunks.length === 0) return {};
11
+ try {
12
+ return JSON.parse(Buffer.concat(chunks).toString("utf-8"));
13
+ } catch {
14
+ return {};
15
+ }
16
+ }
17
+ async function getJson(url) {
18
+ try {
19
+ const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
20
+ if (!res.ok) return null;
21
+ return await res.json();
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+ function healthDot(status) {
27
+ if (status === "healthy") return "\u25CF healthy";
28
+ if (status === "degraded") return "\u25D0 degraded";
29
+ if (status === "unhealthy") return "\u25CB unhealthy";
30
+ return "\u25CB offline";
31
+ }
32
+ function toolSegment(report) {
33
+ if (!report) return null;
34
+ const toolChecks = report.checks.filter((c) => c.category === "tool");
35
+ if (toolChecks.length === 0) return null;
36
+ const live = toolChecks.filter((c) => c.status === "pass").length;
37
+ return `tools ${live}/${toolChecks.length}`;
38
+ }
39
+ async function main() {
40
+ const session = await readStdin();
41
+ const [report, gap] = await Promise.all([
42
+ getJson(`${BRIDGE}/agentspec/health`),
43
+ getJson(`${CONTROL_PLANE}/gap`)
44
+ ]);
45
+ const segments = [healthDot(report?.status)];
46
+ if (gap?.issues) segments.push(`gap ${gap.issues.length}`);
47
+ const tools = toolSegment(report);
48
+ if (tools) segments.push(tools);
49
+ if (session.model?.display_name) segments.push(session.model.display_name);
50
+ if (typeof session.cost?.total_cost_usd === "number") {
51
+ segments.push(`$${session.cost.total_cost_usd.toFixed(3)}`);
52
+ }
53
+ process.stdout.write(`AgentSpec ${segments.join(" \xB7 ")}`);
54
+ }
55
+ void main();
56
+ //# sourceMappingURL=statusline.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/statusline.ts"],"sourcesContent":["/**\n * agentspec-statusline — the Claude Code statusLine command (plan Part 5).\n *\n * Claude Code invokes this on each refresh with the session JSON on stdin and\n * renders stdout verbatim as the bottom status bar. MUST be fast: it only READS\n * already-computed data from the local daemon (control-plane :4001), never\n * recomputes health or audit itself.\n *\n * Output example:\n * ● healthy · gap 3 · tools 11/12\n */\n\nimport type { HealthReport } from '@agentspec/sdk'\n\nconst CONTROL_PLANE = `http://127.0.0.1:${process.env['AGENTSPEC_CONTROL_PLANE_PORT'] ?? 4001}`\n// The bridge's own health endpoint is always HTTP 200 (the control-plane's\n// /health/ready returns 503 when unhealthy, which we don't want to drop).\nconst BRIDGE = `http://127.0.0.1:${process.env['AGENTSPEC_BRIDGE_PORT'] ?? 8000}`\nconst FETCH_TIMEOUT_MS = 1200\n\ninterface SessionInput {\n model?: { display_name?: string }\n cost?: { total_cost_usd?: number }\n}\n\nasync function readStdin(): Promise<SessionInput> {\n const chunks: Buffer[] = []\n for await (const c of process.stdin) chunks.push(c as Buffer)\n if (chunks.length === 0) return {}\n try {\n return JSON.parse(Buffer.concat(chunks).toString('utf-8')) as SessionInput\n } catch {\n return {}\n }\n}\n\nasync function getJson<T>(url: string): Promise<T | null> {\n try {\n const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })\n if (!res.ok) return null\n return (await res.json()) as T\n } catch {\n return null\n }\n}\n\nfunction healthDot(status: string | undefined): string {\n if (status === 'healthy') return '● healthy'\n if (status === 'degraded') return '◐ degraded'\n if (status === 'unhealthy') return '○ unhealthy'\n return '○ offline'\n}\n\nfunction toolSegment(report: HealthReport | null): string | null {\n if (!report) return null\n const toolChecks = report.checks.filter((c) => c.category === 'tool')\n if (toolChecks.length === 0) return null\n const live = toolChecks.filter((c) => c.status === 'pass').length\n return `tools ${live}/${toolChecks.length}`\n}\n\nasync function main(): Promise<void> {\n const session = await readStdin()\n const [report, gap] = await Promise.all([\n getJson<HealthReport>(`${BRIDGE}/agentspec/health`),\n getJson<{ issues?: unknown[] }>(`${CONTROL_PLANE}/gap`),\n ])\n\n const segments: string[] = [healthDot(report?.status)]\n\n if (gap?.issues) segments.push(`gap ${gap.issues.length}`)\n const tools = toolSegment(report)\n if (tools) segments.push(tools)\n if (session.model?.display_name) segments.push(session.model.display_name)\n if (typeof session.cost?.total_cost_usd === 'number') {\n segments.push(`$${session.cost.total_cost_usd.toFixed(3)}`)\n }\n\n process.stdout.write(`AgentSpec ${segments.join(' · ')}`)\n}\n\nvoid main()\n"],"mappings":";;;AAcA,IAAM,gBAAgB,oBAAoB,QAAQ,IAAI,8BAA8B,KAAK,IAAI;AAG7F,IAAM,SAAS,oBAAoB,QAAQ,IAAI,uBAAuB,KAAK,GAAI;AAC/E,IAAM,mBAAmB;AAOzB,eAAe,YAAmC;AAChD,QAAM,SAAmB,CAAC;AAC1B,mBAAiB,KAAK,QAAQ,MAAO,QAAO,KAAK,CAAW;AAC5D,MAAI,OAAO,WAAW,EAAG,QAAO,CAAC;AACjC,MAAI;AACF,WAAO,KAAK,MAAM,OAAO,OAAO,MAAM,EAAE,SAAS,OAAO,CAAC;AAAA,EAC3D,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AAEA,eAAe,QAAW,KAAgC;AACxD,MAAI;AACF,UAAM,MAAM,MAAM,MAAM,KAAK,EAAE,QAAQ,YAAY,QAAQ,gBAAgB,EAAE,CAAC;AAC9E,QAAI,CAAC,IAAI,GAAI,QAAO;AACpB,WAAQ,MAAM,IAAI,KAAK;AAAA,EACzB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,UAAU,QAAoC;AACrD,MAAI,WAAW,UAAW,QAAO;AACjC,MAAI,WAAW,WAAY,QAAO;AAClC,MAAI,WAAW,YAAa,QAAO;AACnC,SAAO;AACT;AAEA,SAAS,YAAY,QAA4C;AAC/D,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,aAAa,OAAO,OAAO,OAAO,CAAC,MAAM,EAAE,aAAa,MAAM;AACpE,MAAI,WAAW,WAAW,EAAG,QAAO;AACpC,QAAM,OAAO,WAAW,OAAO,CAAC,MAAM,EAAE,WAAW,MAAM,EAAE;AAC3D,SAAO,SAAS,IAAI,IAAI,WAAW,MAAM;AAC3C;AAEA,eAAe,OAAsB;AACnC,QAAM,UAAU,MAAM,UAAU;AAChC,QAAM,CAAC,QAAQ,GAAG,IAAI,MAAM,QAAQ,IAAI;AAAA,IACtC,QAAsB,GAAG,MAAM,mBAAmB;AAAA,IAClD,QAAgC,GAAG,aAAa,MAAM;AAAA,EACxD,CAAC;AAED,QAAM,WAAqB,CAAC,UAAU,QAAQ,MAAM,CAAC;AAErD,MAAI,KAAK,OAAQ,UAAS,KAAK,OAAO,IAAI,OAAO,MAAM,EAAE;AACzD,QAAM,QAAQ,YAAY,MAAM;AAChC,MAAI,MAAO,UAAS,KAAK,KAAK;AAC9B,MAAI,QAAQ,OAAO,aAAc,UAAS,KAAK,QAAQ,MAAM,YAAY;AACzE,MAAI,OAAO,QAAQ,MAAM,mBAAmB,UAAU;AACpD,aAAS,KAAK,IAAI,QAAQ,KAAK,eAAe,QAAQ,CAAC,CAAC,EAAE;AAAA,EAC5D;AAEA,UAAQ,OAAO,MAAM,aAAa,SAAS,KAAK,QAAK,CAAC,EAAE;AAC1D;AAEA,KAAK,KAAK;","names":[]}
@@ -0,0 +1,64 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "matcher": "startup|resume",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs\"",
10
+ "timeout": 30
11
+ }
12
+ ]
13
+ }
14
+ ],
15
+ "PreToolUse": [
16
+ {
17
+ "matcher": "*",
18
+ "hooks": [
19
+ {
20
+ "type": "command",
21
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-tool-use.mjs\"",
22
+ "timeout": 10
23
+ }
24
+ ]
25
+ }
26
+ ],
27
+ "PostToolUse": [
28
+ {
29
+ "matcher": "*",
30
+ "hooks": [
31
+ {
32
+ "type": "command",
33
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/post-tool-use.mjs\"",
34
+ "timeout": 10
35
+ }
36
+ ]
37
+ }
38
+ ],
39
+ "SubagentStop": [
40
+ {
41
+ "matcher": "",
42
+ "hooks": [
43
+ {
44
+ "type": "command",
45
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-stop.mjs\"",
46
+ "timeout": 10
47
+ }
48
+ ]
49
+ }
50
+ ],
51
+ "SessionEnd": [
52
+ {
53
+ "matcher": "",
54
+ "hooks": [
55
+ {
56
+ "type": "command",
57
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-end.mjs\"",
58
+ "timeout": 10
59
+ }
60
+ ]
61
+ }
62
+ ]
63
+ }
64
+ }
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PostToolUse hook — behavioral observation. Reports the tool's outcome to the
4
+ * bridge so it can mark the `tool` (and mcp) health checks live. Fails silent.
5
+ */
6
+ const PORT = process.env.AGENTSPEC_BRIDGE_PORT ?? 8000
7
+ const BASE = `http://127.0.0.1:${PORT}`
8
+
9
+ async function readStdin() {
10
+ const chunks = []
11
+ for await (const c of process.stdin) chunks.push(c)
12
+ try {
13
+ return JSON.parse(Buffer.concat(chunks).toString('utf-8'))
14
+ } catch {
15
+ return {}
16
+ }
17
+ }
18
+
19
+ async function main() {
20
+ const input = await readStdin()
21
+ try {
22
+ await fetch(`${BASE}/events`, {
23
+ method: 'POST',
24
+ headers: { 'Content-Type': 'application/json' },
25
+ body: JSON.stringify({
26
+ event: 'PostToolUse',
27
+ tool_name: input.tool_name,
28
+ tool_output: input.tool_output,
29
+ }),
30
+ signal: AbortSignal.timeout(1500),
31
+ })
32
+ } catch {
33
+ /* fail silent */
34
+ }
35
+ process.exit(0)
36
+ }
37
+
38
+ main().catch(() => process.exit(0))
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse hook — enforcement + observation.
4
+ *
5
+ * Posts the tool call to the bridge for observation, then asks the bridge to
6
+ * decide against agent.yaml (subagent gate, tool allow-list, destructive gate).
7
+ * Emits Claude Code's permission JSON for deny/ask; stays silent (normal flow)
8
+ * for allow. Fails OPEN if the bridge is unreachable — never blocks on error.
9
+ */
10
+ const PORT = process.env.AGENTSPEC_BRIDGE_PORT ?? 8000
11
+ const BASE = `http://127.0.0.1:${PORT}`
12
+ const TIMEOUT = 1500
13
+
14
+ async function post(path, body) {
15
+ const res = await fetch(`${BASE}${path}`, {
16
+ method: 'POST',
17
+ headers: { 'Content-Type': 'application/json' },
18
+ body: JSON.stringify(body),
19
+ signal: AbortSignal.timeout(TIMEOUT),
20
+ })
21
+ return res.ok ? res.json() : null
22
+ }
23
+
24
+ async function readStdin() {
25
+ const chunks = []
26
+ for await (const c of process.stdin) chunks.push(c)
27
+ try {
28
+ return JSON.parse(Buffer.concat(chunks).toString('utf-8'))
29
+ } catch {
30
+ return {}
31
+ }
32
+ }
33
+
34
+ async function main() {
35
+ const input = await readStdin()
36
+ const toolName = input.tool_name
37
+ const toolInput = input.tool_input ?? {}
38
+
39
+ // Observation (best-effort).
40
+ post('/events', { event: 'PreToolUse', tool_name: toolName }).catch(() => {})
41
+
42
+ // Enforcement.
43
+ let verdict = null
44
+ try {
45
+ verdict = await post('/decide', { tool_name: toolName, tool_input: toolInput })
46
+ } catch {
47
+ verdict = null // fail open
48
+ }
49
+
50
+ if (verdict && (verdict.decision === 'deny' || verdict.decision === 'ask')) {
51
+ process.stdout.write(
52
+ JSON.stringify({
53
+ hookSpecificOutput: {
54
+ hookEventName: 'PreToolUse',
55
+ permissionDecision: verdict.decision,
56
+ permissionDecisionReason: verdict.reason ?? 'Blocked by AgentSpec manifest policy',
57
+ },
58
+ }),
59
+ )
60
+ }
61
+ process.exit(0)
62
+ }
63
+
64
+ main().catch(() => process.exit(0))
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SessionEnd hook — flush point. By default the per-user daemon is left running
4
+ * (it behaves like a language server across sessions). Stretch: when
5
+ * AGENTSPEC_URL + AGENTSPEC_KEY are set, the bridge's push mode reports a final
6
+ * heartbeat to the operator; that wiring lives in the daemon, so this hook is a
7
+ * no-op placeholder that always exits 0.
8
+ */
9
+ async function main() {
10
+ for await (const _ of process.stdin) { /* drain */ }
11
+ process.exit(0)
12
+ }
13
+ main().catch(() => process.exit(0))
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SessionStart hook — idempotently ensure the agentspec-bridge daemon is up.
4
+ *
5
+ * Backgrounds the daemon (lockfile-guarded inside `agentspec-bridge ensure`) and
6
+ * returns immediately. If no agent.yaml exists yet, advises the user to scan
7
+ * rather than starting a daemon with nothing to serve. Always exits 0 — a
8
+ * governance plugin must never block a session from starting.
9
+ */
10
+ import { existsSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { spawn } from 'node:child_process'
13
+
14
+ async function main() {
15
+ // Drain stdin (SessionStart payload) but we don't need it.
16
+ for await (const _ of process.stdin) { /* ignore */ }
17
+
18
+ const cwd = process.cwd()
19
+ const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT ?? join(cwd, 'node_modules', '@agentspec', 'claude-plugin')
20
+
21
+ if (!existsSync(join(cwd, 'agent.yaml'))) {
22
+ process.stderr.write(
23
+ '[agentspec] No agent.yaml found — run `agentspec scan --dir . --profile claude-code` to enable live governance.\n',
24
+ )
25
+ process.exit(0)
26
+ }
27
+
28
+ const child = spawn(process.execPath, [join(pluginRoot, 'dist', 'bridge.js'), 'ensure'], {
29
+ detached: true,
30
+ stdio: 'ignore',
31
+ env: { ...process.env, AGENTSPEC_PROJECT_DIR: cwd },
32
+ })
33
+ child.unref()
34
+ process.exit(0)
35
+ }
36
+
37
+ main().catch(() => process.exit(0))
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SubagentStop hook — records subagent completion so the bridge can reflect the
4
+ * `subagent` category as live. Fails silent.
5
+ */
6
+ const PORT = process.env.AGENTSPEC_BRIDGE_PORT ?? 8000
7
+
8
+ async function readStdin() {
9
+ const chunks = []
10
+ for await (const c of process.stdin) chunks.push(c)
11
+ try {
12
+ return JSON.parse(Buffer.concat(chunks).toString('utf-8'))
13
+ } catch {
14
+ return {}
15
+ }
16
+ }
17
+
18
+ async function main() {
19
+ const input = await readStdin()
20
+ try {
21
+ await fetch(`http://127.0.0.1:${PORT}/events`, {
22
+ method: 'POST',
23
+ headers: { 'Content-Type': 'application/json' },
24
+ body: JSON.stringify({ event: 'SubagentStop', tool_name: input.tool_name }),
25
+ signal: AbortSignal.timeout(1500),
26
+ })
27
+ } catch {
28
+ /* fail silent */
29
+ }
30
+ process.exit(0)
31
+ }
32
+
33
+ main().catch(() => process.exit(0))
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@agentspec/claude-plugin",
3
+ "version": "0.1.0",
4
+ "description": "AgentSpec plugin for Claude Code — live spec↔runtime bridge, policy enforcement, and status line, with no fork of Claude Code",
5
+ "author": "Sallah Kokaina <sallah.kokaina@gmail.com>",
6
+ "license": "Apache-2.0",
7
+ "homepage": "https://agentspec.io",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/agents-oss/agentspec.git",
11
+ "directory": "packages/claude-plugin"
12
+ },
13
+ "type": "module",
14
+ "bin": {
15
+ "agentspec-bridge": "dist/bridge.js",
16
+ "agentspec-statusline": "dist/statusline.js"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "hooks",
21
+ ".mcp.json",
22
+ ".claude-plugin"
23
+ ],
24
+ "dependencies": {
25
+ "@agentspec/sdk": "0.3.0",
26
+ "@agentspec/sidecar": "0.1.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^20.17.0",
30
+ "tsup": "^8.3.5",
31
+ "typescript": "^5.7.2",
32
+ "vitest": "^2.1.8"
33
+ },
34
+ "keywords": [
35
+ "agentspec",
36
+ "claude-code",
37
+ "plugin",
38
+ "hooks",
39
+ "ai-agents"
40
+ ],
41
+ "scripts": {
42
+ "build": "tsup",
43
+ "dev": "tsup --watch",
44
+ "test": "vitest run",
45
+ "test:watch": "vitest",
46
+ "typecheck": "tsc --noEmit",
47
+ "lint": "tsc --noEmit",
48
+ "clean": "rm -rf dist"
49
+ }
50
+ }