@jentrix/runner 0.5.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 ADDED
@@ -0,0 +1,385 @@
1
+ # Jentrix reference agents
2
+
3
+ ## Install the self-hosted workflow runner
4
+
5
+ `agents/` is the single distributable runner artifact; its bundle contains the
6
+ existing workflow runner and both provider adapters. Install it beside the
7
+ matching Jentrix CLI (Node 20+):
8
+
9
+ ```bash
10
+ npm install -g @jentrix/cli@0.4.0 @jentrix/runner@0.4.0
11
+ jentrix login
12
+ claude auth login # and/or: codex login
13
+ jentrix runner doctor
14
+ jentrix runner setup --workspace my-workspace --runtime claude # or: codex
15
+ jentrix runner up # foreground
16
+ jentrix runner up --once # drain, then exit
17
+ ```
18
+
19
+ Setup is preview-first and never installs a provider CLI, automates provider
20
+ login, publishes a release, resolves an approval, or launches work. It writes
21
+ `~/.config/stacks/runner.json` atomically at mode `0600`, using the existing
22
+ worker enrollment/bootstrap/attestation identities. The file contains revocable
23
+ credentials: do not copy or log it. Claude/Codex account identity is not printed
24
+ or stored, probes make no model call, and orchestration credentials are scrubbed
25
+ from provider child environments. See the [CLI local runner guide](../docs/cli.md#local-workflow-runner)
26
+ for every blocker and the protocol compatibility matrix.
27
+ Packaged polling binds the inherited webhook listener to loopback; SIGINT/SIGTERM
28
+ stop new claims, drain active work, and exit without changing runner config.
29
+
30
+ Small agents that operate Jentrix through the **same public surface any third-party agent gets** — same auth, scopes, and contracts. They exist to dogfood the agent platform end-to-end (P4.2) and double as living documentation: if a platform contract breaks, these break. Most (`standup`, `bug-triage`, `crm-follow-up`, `workflow-runner`) drive the **MCP tool surface** directly. One — `cli-standup` — is the deliberate exception: it drives the [`jentrix` CLI](../docs/cli.md) from **bash only** (no `mcp__jentrix__*` tools), proving the CLI is a first-class, low-context agent substrate over that same surface.
31
+
32
+ They run on either of two interchangeable runtimes (same prompts, same MCP server, same attribution badges — the platform is runtime-neutral):
33
+
34
+ | `STACKS_AGENT_PROVIDER` | Runtime | Auth |
35
+ | --- | --- | --- |
36
+ | `claude` (default) | [Claude Agent SDK](https://code.claude.com/docs/en/agent-sdk/typescript) | `ANTHROPIC_API_KEY` |
37
+ | `codex` | [OpenAI Codex SDK](https://developers.openai.com/codex/sdk) | `codex login` — your **ChatGPT subscription**, no API key |
38
+
39
+ For the MCP-direct agents, the Codex runtime injects the Jentrix MCP server per-run via config overrides (your `~/.codex/config.toml` is never edited) and runs with the shell tool disabled and a read-only sandbox, so both runtimes see the same world: Jentrix tools only. **The `cli-standup` agent is the exception** — it deliberately injects no MCP server and, on Codex, keeps the shell enabled under a `workspace-write` sandbox (it needs a shell to run `jentrix`); its own section below covers that runtime and its security model in full.
40
+
41
+ This is a **standalone package** — it is not part of the app's dependency tree, build, or CI. Install and run it separately:
42
+
43
+ ```bash
44
+ cd agents
45
+ pnpm install
46
+ cp .env.example .env # fill in STACKS_TOKEN (+ ANTHROPIC_API_KEY, or set STACKS_AGENT_PROVIDER=codex)
47
+ ```
48
+
49
+ `tsx` loads nothing automatically — export the env vars (`set -a; source .env; set +a`) or run via `node --env-file=.env --import tsx <agent>.ts`. The two `workflow-runner` scripts are the exception: they load `.env`, stamp `STACKS_APP_REVISION` from the checkout's current Git HEAD on every launch, and derive `STACKS_SCHEMA_REVISION` from this checkout's newest `prisma/migrations` directory at start-up — so maintain **neither** value in `.env`. Set one only to pin a rollback. The runner also refuses to start when a configured checkout cannot ground a stage (parked on a `harness/` branch, detached, behind upstream, or the wrong repo); `STACKS_RUNNER_ALLOW_STALE_CHECKOUT=1` overrides the first two. Both stamps and the control plane's `/api/health` verdict are logged in the first few lines of start-up output.
50
+
51
+ ## Setup: mint a token
52
+
53
+ On `/account/tokens`, create a PAT per agent with least privilege:
54
+
55
+ | Agent | Scopes | Identity suggestion |
56
+ | --- | --- | --- |
57
+ | `standup` | `read` | 📋 Standup Bot |
58
+ | `cli-standup` | `read` | 📋 CLI Standup Bot |
59
+ | `bug-triage` | `read`, `write` | 🐛 Triage Bot |
60
+ | `crm-follow-up` | `read`, `write` | 🤝 Follow-up Bot |
61
+ | `workflow-runner` (runner) | `read`, `write`, `admin` | 🛠️ Runner |
62
+ | `workflow-runner` (planner) | `read`, `write` | 🧠 Planner |
63
+ | `workflow-runner` (reviewer) | `read`, `write` | 🔎 Reviewer |
64
+
65
+ Pin each token to one workspace, and set the **agent display name + emoji** — every action the agent takes is then badged with that identity in activity feeds and comments (P4.1), and `/account/tokens` shows its daily usage (P2.8).
66
+
67
+ > **`workflow-runner` auth is changing (M12.4b).** The three runner/planner/reviewer PATs above are the **deprecated** standing-PAT path. Prefer `STACKS_RUNNER_MODE=self-hosted` (the M11.1 bootstrap + claim-URL flow — one-time bootstrap token, short-lived role tokens) or, on the Jentrix-operated plane, `managed` (workload identity, zero standing PATs). See [Authentication — no standing PATs](#authentication--no-standing-pats-on-the-managed-path-m124b-age-174) below. The scopes column still describes the least privilege each role's short-lived token carries.
68
+
69
+ ## The agents
70
+
71
+ ### `pnpm bug-triage` — webhook-driven triage (long-running server)
72
+
73
+ Closes the full agent loop: **webhook out → agent → MCP tools in**.
74
+
75
+ 1. In workspace settings, create a webhook for `task.created` pointing at this server (default `:8787`; use a tunnel for a deployed Jentrix). Put its secret in `STACKS_WEBHOOK_SECRET`.
76
+ 2. The receiver verifies the HMAC signature (`X-Stacks-Signature`, timestamp-bound — the exact scheme documented in CLAUDE.md §P2.6), acks fast, and queues one agent run per created task.
77
+ 3. The agent: `get_task` → skips non-BUGS tasks → infers and sets severity (`update_bug_report`) → searches for duplicates (`search_tasks`, P3.1) and links them (`add_task_link` `DUPLICATES`/`RELATES_TO`, P1.2) → asks for repro steps in a comment when missing.
78
+
79
+ Webhook retries can redeliver — the comment uses an `idempotencyKey` derived from the delivery id (P2.4), so a retry replays instead of double-commenting.
80
+
81
+ ### `pnpm crm-follow-up` — scheduled pipeline sweep (cron)
82
+
83
+ Walks CRM boards via `get_board_snapshot`, finds deals with a close date within 7 days (or past) and no *human* activity in 72h (`list_activity` with the `source` filter), and posts a clearly-marked **draft** follow-up comment on up to 5 deals — the human stays in the loop and sends the actual email. Idempotency keys are stable per deal per day, so re-running the cron never duplicates drafts.
84
+
85
+ ```cron
86
+ 0 7 * * * cd /path/to/agents && pnpm crm-follow-up
87
+ ```
88
+
89
+ ### `pnpm standup` — daily digest (cron, read-only)
90
+
91
+ Diffs the last 24h of `list_activity`, querying humans (`source: "ui"`) and agents (`source: "mcp"`) separately, and emits a short markdown digest — stdout by default, Slack when `SLACK_WEBHOOK_URL` is set. Runs safely on a `read`-only token: the server rejects any mutation it could be talked into.
92
+
93
+ ```cron
94
+ 0 8 * * 1-5 cd /path/to/agents && pnpm standup
95
+ ```
96
+
97
+ ### `pnpm cli-standup` — the same digest, bash-only (CLI instead of MCP tools)
98
+
99
+ The bash-only counterpart of `standup`, and the proof that the [`jentrix` CLI](../docs/cli.md) is a first-class agent substrate. Same job — a read-only 24h digest splitting humans (`source: "ui"`) from agents (`source: "mcp"`) — but the runtime gets **Bash and nothing else**: no `mcp__jentrix__*` tools. It operates Jentrix purely through the `jentrix` binary on its PATH, discovering the surface lazily (`jentrix --help`, `stacks <group> --help`) and piping `--json` output through `jq`, instead of carrying the full tool surface in context.
100
+
101
+ Setup:
102
+
103
+ 1. **Install the CLI** so `jentrix` is on PATH (see [docs/cli.md](../docs/cli.md#install)):
104
+ ```bash
105
+ npm install -g @jentrix/cli
106
+ # or from a checkout: cd ../cli && pnpm install && pnpm pack && npm install -g ./jentrix-cli-*.tgz
107
+ jentrix --version # confirms it's on PATH
108
+ ```
109
+ 2. **Env** (same names as every agent): a **read-only** `STACKS_TOKEN` and `STACKS_MCP_URL`. The CLI reads these directly — the agent never passes `--token`/`--url` and never prints the token.
110
+ ```bash
111
+ export STACKS_TOKEN=tm_your_read_scoped_token
112
+ export STACKS_MCP_URL=http://localhost:3000/api/mcp
113
+ ```
114
+ 3. **Run** on either runtime:
115
+ ```bash
116
+ pnpm cli-standup # Claude runtime (ANTHROPIC_API_KEY / Claude login)
117
+ STACKS_AGENT_PROVIDER=codex pnpm cli-standup # OpenAI Codex runtime — see the caveat below
118
+ ```
119
+
120
+ ```cron
121
+ 0 8 * * 1-5 cd /path/to/agents && pnpm cli-standup
122
+ ```
123
+
124
+ **Security model — the boundary is the server-side read-scoped token.** This is the one thing to get right, so state it plainly: the load-bearing boundary is the **read-scoped `STACKS_TOKEN`, enforced server-side**. Because the agent authenticates with a token that carries only the `read` scope, the Jentrix server rejects *every* mutation the shell could ever be talked into — a write attempt returns a `FORBIDDEN` envelope (CLI exit 3), no matter what command runs (P2.1, token scopes). The digest never needs to write anything, so this scope is sufficient *and* it is the safety guarantee. For a stronger confinement, also **workspace-pin the token** (`ApiToken.workspaceId`) so even reads are limited to one workspace. Mint a dedicated token for this agent — do not reuse a broader one.
125
+
126
+ **The on-host command guard is best-effort defense-in-depth, not a sound sandbox.** On the Claude runtime, `tools: ["Bash"]` offers *only* the shell (no Read/Write/Edit/WebSearch) and a `canUseTool` guard (`bashReadOnlyGuard`) inspects every Bash command, allowing only what *looks like* a read-only `stacks … --json | jq` pipeline: an allow-list of read-only heads (`jentrix`, `jq`, `echo`, `cat`, `grep`, `head`, `sort`, `wc`, …), and best-effort rejection of the obvious write/exfil surfaces — output redirection, `curl`/`wget`/`nc`, `rm`/`mv`/`cp`/`tee`, `node`/`git`/arbitrary interpreters, unquoted command substitution, the write-capable text tools `sed`/`awk`/`perl`/`find`/`xargs`, `sort -o`, the config-writing `jentrix login`/`jentrix logout`, and — importantly — any `--url`/`--token` override (which would point the CLI, carrying the injected token, at an arbitrary host). This raises the on-host bar above "just the prompt" and keeps the agent honest, and it is careful *not* to over-block legitimate reads (jq comparison operators like `>`/`<` and jq's own internal `|` are allowed, because the agent must actually work) — the allow and deny cases are unit-tested in `cli-standup.smoke.test.ts`. But parsing arbitrary shell from a string is not soundly decidable, so **this guard is explicitly not a security boundary**: a determined prompt-injection could still craft a command that reads Jentrix data (within the token's scope) and attempts to exfiltrate it. That residual risk is precisely why the agent runs on a *dedicated, read-scoped, ideally workspace-pinned* token — the blast radius of any such attempt is bounded to reading data that token can already see, and it can change nothing.
127
+
128
+ **The Codex runtime is weaker still — a trusted-operator convenience.** The tools-only agents run Codex with the shell *disabled* and a `read-only` sandbox, but the CLI agent *needs* the shell to run `jentrix` and *needs* network for `jentrix` to reach the server — and Codex's `read-only` sandbox disables network. So the Codex CLI agent runs the shell under a **`workspace-write` sandbox with `networkAccessEnabled`** (writes jailed to a throwaway scratch cwd). Codex exposes no per-command `canUseTool` hook, so there is *no* on-host command guard at all on this runtime: the read-scoped token still blocks any Jentrix *mutation* and the jail blocks writes outside the scratch dir, but a networked shell that inherits the process env could read Jentrix data (within scope) and exfiltrate it or leak env values. **For an untrusted or least-privilege deployment, use the Claude runtime** (Bash-only + the best-effort guard) or the MCP `standup` agent; treat the Codex CLI path as a trusted-operator convenience, and always with a dedicated read-scoped token.
129
+
130
+ <!-- BEGIN GENERATED: mcp-tool-count:agent-token-cost -->
131
+ **Token cost — CLI vs MCP standup (measured on this repo's 242-tool surface).** The MCP `standup` agent injects **every** tool definition into the model's context up front: name + description + JSON input-schema + annotations for all 242 tools. The CLI `cli-standup` agent carries **no** tool schemas — only its system prompt — and pays for discovery at *run* time via `jentrix --help` (only for the commands it actually uses). Measured from the checked-in artifacts (bytes → tokens at ~3.7 chars/token, the ratio being the point):
132
+
133
+ | Context loaded up front | Bytes | ≈ Tokens |
134
+ | --- | --- | --- |
135
+ | MCP agent — all 242 tool definitions | 387 KB | ~105k |
136
+ | CLI agent — `CLI_STANDUP_SYSTEM_PROMPT` only | 2 KB | ~510 |
137
+
138
+ That is a **~205× smaller** Jentrix-specific up-front context.
139
+ <!-- END GENERATED: mcp-tool-count:agent-token-cost -->
140
+
141
+ Reproduce the two numbers directly:
142
+
143
+ ```bash
144
+ # MCP: bytes of the tool definitions an MCP client injects (name+description+inputSchema+annotations)
145
+ node -e 'const s=require("../cli/surface.json");let b=0;for(const t of s.tools)b+=JSON.stringify({name:t.name,description:t.description,inputSchema:t.inputSchema,annotations:t.annotations}).length;console.log(b,"bytes ≈",Math.round(b/3.7),"tokens")'
146
+ # CLI: bytes of the agent's system prompt (its entire Jentrix-specific context)
147
+ node -e 'const s=require("fs").readFileSync("cli-standup.ts","utf8").match(/CLI_STANDUP_SYSTEM_PROMPT = `([\s\S]*?)`;/)[1];console.log(s.length,"bytes ≈",Math.round(s.length/3.7),"tokens")'
148
+ ```
149
+
150
+ At run time the CLI agent instead spends a few `--help` round-trips — `jentrix --help` ≈ 860 tokens, a group's `--help` ≈ 60–100, a leaf's `--help` (e.g. `jentrix activity list --help`) ≈ 1,500 — so a session touching three or four commands spends ~2–3 K discovery tokens *for the commands it uses*, versus the full surface shown above loaded unconditionally. The trade-off is latency (extra round-trips) for a dramatically smaller, more focused context — the low-context, lazy-discovery mode the CLI was built to enable.
151
+
152
+ ### `pnpm workflow-runner` — review-loop driver (M7 + harness, long-running server)
153
+
154
+ Drives the **plan → review → execute → review** loop ([docs/agent-workflows.md](../docs/agent-workflows.md)) and — when a turn belongs to a **Harness Automation stage** ([docs/harness.md](../docs/harness.md)) — the harness execution protocol. Packaged startup uses setup-written durable role bindings; the server's claimed release route must match before a model turn. The old `STACKS_AGENT_PROVIDER` startup remains for one explicitly deprecated compatibility window and logs a warning. Three separate short-lived tokens preserve role authority:
155
+
156
+ 1. In workspace settings, create a webhook for `agent.handoff` pointing at this server (default `:8788`). Put its secret in `STACKS_WEBHOOK_SECRET`.
157
+ 2. On each signed handoff it **claims** the current turn with the **runner principal** (`claim_agent_job` → `runId`), then spawns the selected Claude or Codex runtime with that role's own token and the `stacks-review-loop` skill, passing the `runId`.
158
+ 3. It **heartbeats** the run (runner principal) while the session works, and calls **`fail_agent_job`** on a provider error so the job blocks for a human instead of stalling.
159
+
160
+ Three principals, by design (claim ≠ submit principal — the turn-guard checks the *submitting* token): **runner** (`read,write,admin`), **planner** (`read,write`, also executes), **reviewer** (`read,write`). Human turns (the pre-execution and final-acceptance gates) are not actioned here — a person resolves them in the Control Tower. Handoffs are serialized per process so retried deliveries never race a claim.
161
+
162
+ #### Authentication — no standing PATs on the managed path (M12.4b, AGE-174)
163
+
164
+ The runner never reads a standing PAT directly. Role tokens are provisioned by a mode-selected provider (`lib/runner-auth.ts`) and **refreshed at every drive**, so a long-running server that authenticates with short-lived tokens never presents an expired one. Select the mode with `STACKS_RUNNER_MODE`:
165
+
166
+ | `STACKS_RUNNER_MODE` | Who runs it | How it authenticates | Env |
167
+ | --- | --- | --- | --- |
168
+ | `managed` | the Jentrix-operated plane | **workload identity + per-run capability tokens** (M12.4a) — the worker holds a short-lived, auto-rotated SVID and trades it for run/workspace/role-scoped capability tokens at the control-plane exchange. **Zero standing PATs.** | `STACKS_TOKEN_EXCHANGE_URL`, `STACKS_WORKLOAD_SVID`, `STACKS_RUN_ID`, `STACKS_WORKSPACE_ID` |
169
+ | `self-hosted` | a runner on your own box | the **M11.1 bootstrap + claim-URL flow**: a one-time `STACKS_BOOTSTRAP_TOKEN` is redeemed at a single-use, short-TTL claim URL for short-lived role tokens (spent once, then refreshed with a rotating refresh credential). **No standing PAT.** | `STACKS_BOOTSTRAP_TOKEN`, `STACKS_CLAIM_URL` |
170
+
171
+ `STACKS_RUNNER_MODE` is **required** — the pre-M12.4 standing-PAT path
172
+ (`STACKS_RUNNER_TOKEN` / `STACKS_PLANNER_TOKEN` / `STACKS_REVIEWER_TOKEN`)
173
+ was deleted (D4, 2026-07); those env vars are never read.
174
+
175
+ Create a self-hosted bootstrap with an existing workspace-admin bearer. The
176
+ planner and reviewer must already be the harness agents, each with exactly one
177
+ non-revoked linked API-token row (an expired row is accepted as a rotation
178
+ anchor; no raw standing bearer is required):
179
+
180
+ ```bash
181
+ curl -X POST "$STACKS_APP_URL/api/agents/bootstrap" \
182
+ -H "Authorization: Bearer $STACKS_ADMIN_TOKEN" \
183
+ -H "Content-Type: application/json" \
184
+ -d '{"workspaceId":"...","plannerAgentId":"...","reviewerAgentId":"...","runnerEvidenceTier":"INTEGRATION_LOCAL"}'
185
+ ```
186
+
187
+ The response shows `bootstrapToken` once plus its `claimUrl`. Set those as
188
+ `STACKS_BOOTSTRAP_TOKEN` and `STACKS_CLAIM_URL`, then start the runner with
189
+ `STACKS_RUNNER_MODE=self-hosted`. The bootstrap expires after 15 minutes and is
190
+ single-use. Role bearers last one hour; one rotating refresh credential keeps
191
+ them current for 24 hours. Issuing the bootstrap immediately invalidates the old
192
+ raw planner and reviewer PAT values while preserving their database ids and
193
+ agent links.
194
+
195
+ The rotating refresh credential is persisted to `.runner-auth-state.json`
196
+ (override with `STACKS_AUTH_STATE_PATH`; gitignored, written 0600) so a
197
+ respawned once-mode process resumes the chain instead of re-presenting the spent
198
+ bootstrap. **You never need to delete that file by hand** (AGE-922): a refresh
199
+ credential the server rejects with a 401 is dropped from disk AND from memory,
200
+ and the claim retries once on the `STACKS_BOOTSTRAP_TOKEN` currently in your
201
+ env — so minting a fresh grant is sufficient on its own. A 401 that survives
202
+ that fallback means the bootstrap itself is spent, expired, or revoked, and the
203
+ error says so.
204
+
205
+ `runnerEvidenceTier` is `INTEGRATION_LOCAL` — the only tier a self-hosted
206
+ grant can mint (the local `PROD_TOPOLOGY_LOCAL` kind/Calico/gVisor lane was
207
+ deleted in 2026-07; the next tier up is `MANAGED_STAGING`, which is a managed
208
+ substrate, not a laptop cluster). The ceiling is stored on the single-use
209
+ grant and copied to the short-lived runner token, so setting a runner
210
+ environment variable cannot upgrade its authority.
211
+
212
+ The **runner never reads a standing-PAT env var** — the deprecated legacy provider was deleted outright (D4, 2026-07). The managed provider (`lib/managed-auth.ts`) additionally fails closed (`StandingPatOnManagedPathError`) the instant a standing PAT is present in its environment, and a grep test (`runner-auth.smoke.test.ts`) fails if any managed-path module so much as names one.
213
+
214
+ **Codex model is pinned.** The Codex runtime reads `~/.codex/config.toml` as its base config, so the runner always pins the model + reasoning effort explicitly (`STACKS_CODEX_MODEL`, default `gpt-5.5`; `STACKS_CODEX_EFFORT`, default `medium`) — an interactive `codex` model switch on the host must never silently re-model an unattended role. Every claim records the pinned model on the run (`claim_agent_job.model`), so the ledger answers which model planned, executed, or reviewed each round.
215
+
216
+ **Codex checkout permissions follow the role.** Review turns receive the stage checkout read-only, so the reviewer verifies claims against the actual tree without modifying it. Executor turns receive workspace-write access only to their isolated harness worktree so they can implement the stage. Tools-only turns stay read-only with the shell disabled. The Codex child environment is scrubbed of the orchestrator's other credentials; the per-turn bearer is the only Jentrix credential it receives.
217
+
218
+ **Poll mode (webhook-less).** A deployed Jentrix cannot deliver webhooks to a localhost runner — the outbound SSRF guard rightly refuses private destinations — so without a public tunnel the webhook leg can never fire. Set `STACKS_POLL_MS` (e.g. `15000`) and the runner drives the SAME loop by polling instead: each tick lists the agent-turn phases and submits pending PLANNER/REVIEWER turns to the same bounded agent-turn pool the webhook path uses (the full job view is re-read fresh before each drive; `claim_agent_job`'s server-side CAS is the double-claim guard), and watches executor-driven + FINAL_ACCEPTANCE-parked harness jobs for the post-validation commit boundary on the dedicated commit pool (so a commit-gate wait never blocks the reviewer turn that unblocks it). With no `STACKS_WEBHOOK_SECRET` configured the webhook endpoint answers 503; webhook and poll modes can also run together.
219
+
220
+ For single-run or production bring-up, set `STACKS_POLL_HARNESS_RUN_ID` as well.
221
+ The poller then fails closed to that exact harness for both agent turns and commit
222
+ gate watches; generic jobs and historical runs in the workspace are ignored.
223
+
224
+ **Bounded concurrency + shared job-id dedup (Concurrent Harness Execution, Stage 4).** The runner drives work through **two bounded pools** (`lib/runner-pool.ts`) instead of a single serialized promise chain:
225
+
226
+ - **`STACKS_RUNNER_MAX_CONCURRENT_JOBS`** (default **1**, hard cap **16**) bounds planner/reviewer/executor **agent turns**.
227
+ - **`STACKS_RUNNER_MAX_CONCURRENT_COMMITS`** (default **1**, hard cap **8**) bounds the post-validation **commit/push continuations** on a **dedicated** pool, so a freshly-approved commit is never queued behind a long model turn — the low-latency commit path is preserved even when the agent-turn pool is full. A commit continuation runs in its own **job-scoped worktree** (see *Per-job worktrees*) and can therefore run concurrently with another job's agent turn (different dirs/branches, no shared-index or branch-switch race).
228
+
229
+ Each value **parses to a finite documented maximum** (`parseBoundedMax`): an unset / non-integer / `< 1` value falls back to the default (never unbounded), and anything above the hard cap is clamped down. Both the **webhook and the poll input submit to the SAME two pool objects**, so the queued/running **job-id dedup registry spans both inputs** — a duplicate webhook delivery, a retried delivery, or a racing poll drive for one job **collapses before consuming a slot** (`submit()` returns false; `claim_agent_job`'s server-side CAS stays the final ownership guard). A slot is released when the invocation settles **or** when the per-task **watchdog** (`WATCHDOG_MS`) detaches a hung provider session; a watchdog-detached job **stays attempt-guarded in the registry** (a same-jobId re-delivery stays collapsed, so it can never share the orphan's worktree — a retry mints a *new* jobId → a *new* worktree), while the orphan's late writes lose to the superseding claim server-side. At `STACKS_RUNNER_MAX_CONCURRENT_JOBS=2`, two same-repo jobs each get their own worktree and commit on their own resolved branches; a third job waits until a slot frees, and observed concurrency never exceeds the bound. The runner logs a `[runner-pool]` telemetry line on every enqueue/dedupe/start/settle reporting **both queue depths, both active slot counts, both registry sizes, the oldest in-flight job age, the claim-to-execution (queue) latency, and the branch-ownership-conflict count**. Proven end-to-end in `runner-pool.smoke.test.ts`.
230
+
231
+ #### Harness stage execution (H10.6)
232
+
233
+ The `agent.handoff` webhook carries only the `jobId`, so on an **execution** turn the runner polls `get_agent_job` and routes to the harness protocol when the job's `harness` block is present (a `stage_execution` job). A harness stage rides the M7 plan → review → execute → review loop, so the runner has **two** phases (`lib/harness-runner.ts`):
234
+
235
+ **Execution turn** (`driveHarnessStage`, on the `EXECUTING` handoff — it does **not** commit, because the local commit is only authorizable once the stage is validated):
236
+
237
+ - runs the **executor** session in the stage's **per-job worktree** (see *Per-job worktrees* below — never the shared checkout; the run's repo → `STACKS_RUNNER_REPOS`; an **unmapped repo aborts** before any agent/git/credential action — the shell-capable path is bounded to configured checkouts),
238
+ - **executes the stage plan's declared `verificationCommands` itself** in an ephemeral managed container (HAI S3 — the stage worktree is the only writable host mount, the environment is an explicit non-secret allowlist, network egress is always denied, commands run one at a time, and each has `verificationTimeoutMs`, default 15 min). Set a repo-compatible, preferably digest-pinned `STACKS_VERIFICATION_IMAGE` (`STACKS_SANDBOX_IMAGE` is the fallback); the runner starts a real preflight container **before claiming the turn**, so missing Docker/image support burns zero model turns. The image must contain the lockfile-pinned pnpm release and dependency tree: the sealed manifest points Corepack at that cache and disables pnpm 11's `verifyDepsBeforeRun=install` auto-install so verification cannot drift to or hang on the denied registry. Each captured output is submitted as a `TEST_EVIDENCE` artifact **with a runner attestation** (command, exit code, timeout flag, output hashes, evidence tier — default `INTEGRATION_LOCAL`, capped server-side by the runner token's `ApiToken.maxEvidenceTier` ceiling) bound to the diff capture's checksum, via the **runner token** (the claiming identity — the server rejects any other issuer). The executor's prose is **never** test evidence; if a declared command is not run, the structured report says so (`testsNotRun`) and the stage honestly fails its gate,
239
+ - captures the complete diff—including non-ignored untracked files, via temporary intent-to-add entries that are removed immediately—as a **`submit_harness_artifact`** `DIFF_OR_PATCH` artifact and links everything in **`submit_harness_stage_result`** (changed files, branch, diff lines, the honestly-populated `commandsRun`/`testsRun`/`testsNotRun`/`risks` — **no** commit yet), so the validation gate has its required evidence kinds (the `EXECUTION_SUMMARY` is written by `submit_harness_stage_result` itself), and
240
+ - satisfies the generic **`submit_execution_result`** so the M7 state machine advances to the reviewer turn.
241
+
242
+ Two things every execution turn gets regardless of what the stage declares:
243
+
244
+ - **The harness-flow gate.** `pnpm test:harness-flow` (`HARNESS_FLOW_GATE_COMMAND`) is appended to the declared commands as the **last** one, so every executor job proves the machinery carrying it still converges — sweep-only liveness, decision-table totality, review-round/park accounting. It runs through the same attested path as any other command: real exit code, runner attestation, and a failure landing in `risks`/`testsNotRun` for the validation gate and the reviewer. A stage that already declares it is not run twice.
245
+ - **Project context** (`lib/project-context.ts`), injected into the planner, executor, *and* reviewer prompts: `CLAUDE.md` named as the contract the review is judged against (Claude legs also load it natively — `settingSources: ['project']`; `AGENTS.md` symlinks to it for the Codex legs), plus the base-branch commits the checkout does **not** have. A worktree is cut from its base ref once and never rebased, so parallel work on the same repo is otherwise invisible to every role. Best-effort: a git failure degrades to the conventions block and never costs a turn.
246
+
247
+ Local verification caps at `INTEGRATION_LOCAL` — there is no local lane for a
248
+ stronger tier (the 2026-07 `PROD_TOPOLOGY_LOCAL` kind/Calico/gVisor experiment
249
+ was deleted; the next real tier is `MANAGED_STAGING`, a managed substrate).
250
+ A stage plan demanding a stronger tier is rejected at decomposition and parked
251
+ at launch (F2), never burned against the attest-time ceiling.
252
+
253
+ **Commit boundary** (`driveHarnessStageCommit` → `driveHarnessCommit`). A stage is validated *later* than its execution turn — after the reviewer and the human `FINAL_ACCEPTANCE` gate — so Jentrix emits **`harness.stage_validated`** when the commit boundary is ready. The runner subscribes to that webhook (alongside `agent.handoff`) and drives the commit boundary then (it also runs it opportunistically right after the execution turn when an ALLOW policy auto-approved the gate). This phase has no active M7 turn, so it does not claim/heartbeat — it freeze-probes via `get_harness`. It:
254
+
255
+ - **authorizes** the local commit with **`authorize_harness_local_commit`** *before* `git commit` (a `repo:commit` permission check that honors freeze/leases/policy — it mints **no** credential and approves **no** gate; it throws on a non-validated/frozen/blocked stage). The runner passes the **resolved branch + the pre-commit changed paths + diff size** so branch-scoped leases and the `high_risk_change` policy evaluate the actual target *before* the commit,
256
+ - reports the commit SHA + the committed files via **`submit_harness_stage_result`** (with the claimed `jobId` so the report binds to this attempt — the local-commit evidence the boundary needs to advance to `COMMITTED`), and
257
+ - after the commit boundary authorizes push/PR (the stage reaches `COMMITTED`), brokers a **single-use** `repo:push` / `repo:pr_create` credential through the injected `brokerPushCredential` seam, performs the `git push`, and opens the PR through the injected `createPullRequest` seam — **never a standing raw repo token**. A pending commit gate or a blocked stage **stops the runner** (it never forces or self-approves a human gate).
258
+
259
+ **Freeze** is honored by this external process: `heartbeat_agent_job` returns `{ frozen: true }` when the run is frozen, and the runner **aborts the in-flight subprocess** and performs no further submit / commit-evidence / credential / push / PR until resume (it re-checks freeze at every boundary, including after each credential broker and before each external git write).
260
+
261
+ The reference runner ships `brokerPushCredential` and `createPullRequest` as **fail-closed no-ops** (they return no token / no PR): the MCP boundary deliberately never hands out raw push material (`request_credential_grant` returns a grant *handle*, not a token), so a tools-only reference runner has no standing repo token — when no broker is wired the stage simply stays `COMMITTED` on Jentrix and the governed delivery path (or a human) performs the externally-visible write. The smoke test wires a stubbed single-use broker + PR opener to exercise the **full** push/PR handoff; a real deployment wires those seams to its brokered `repo:push` / `repo:pr_create` source and PR-create API call.
262
+
263
+ #### Per-job worktrees (Concurrent Harness Execution, Stage 3)
264
+
265
+ Each stage attempt (`AgentJob`) executes in its **own native-Git worktree** derived from the run's checkout — the executor's working directory, from which the diff/test evidence is captured, which a same-job revision turn reuses, and which the **reviewer receives read-only**. The mechanics live in the dependency-free S1 helper (`packages/execution/src/worktree.ts`); the runner's policy layer is `lib/harness-worktree.ts` (wired in `workflow-runner.ts`, faked in the smoke tests):
266
+
267
+ - **Branch source.** A worktree is created on the stage branch from the stage's `executionBaseRef` (never the checkout HEAD — a missing ref is an actionable error, fetch only that ref through the broker), or checks out the existing stage-branch tip on a retry; a restart **adopts** the existing worktree only when Git metadata and the sibling ownership marker agree, else it fails closed.
268
+ - **Ownership before a retry.** Because the null/legacy fallback branch `harness/{taskKey}-stage-{number}` and the `{slug}` stage-id substitution let legacy, null, custom, and same-anchor runs resolve to the **same** branch, the runner never assumes a branch template is globally unique. Before reusing a stage branch it prunes, lists worktrees, resolves the owner marker, and reads **fresh Jentrix state**: a live/current owner → an actionable conflict naming the owning branch/job; a confirmed-superseded owner (lease taken over, or a newer stage job replaced it and the old job has no active run or pending commit) → the old worktree is **detached** (files preserved, marker stamped `superseded-detached`) and the branch reused; unprovable → fail closed, nothing reset/renamed/stolen.
269
+ - **Per-repo bootstrap + ignored config.** `STACKS_RUNNER_REPOS` stays back-compatible with string values *and* accepts an operator object `{ cwd, bootstrap: [[argv]], copyIgnoredFiles, worktreeRoot }`. Bootstrap argv run **after** containment/ownership validation and are idempotent (may re-run on adoption). `copyIgnoredFiles` copies **only** allowlisted repo-relative files that `git check-ignore` confirms are ignored, owner-only, never logged/artifacted, deleted with the worktree; **nothing is copied by default**.
270
+ - **Cleanup + operator listing.** A worktree is removed only on a safe terminal Jentrix state with no active run (`git clean -fdX` ignored output → `git worktree remove` → prune); it **survives** freeze, `PRE_EXECUTION`/`FINAL_ACCEPTANCE`/`COMMIT` gates, same-job revisions, and a process restart. A source-dirty or orphaned worktree is retained and reported. Run `pnpm harness-worktrees` to list runner-owned worktrees with job/stage/age/source-cleanliness (and local paths) before any manual removal — those absolute paths are printed to stdout only, never persisted into Jentrix.
271
+
272
+ ## Scripts (not agents)
273
+
274
+ Not everything in `scripts/` drives a model. `harness-template.ts` is a plain
275
+ CI client: argv in, one MCP `tools/call` out.
276
+
277
+ ### `scripts/harness-template.ts` — the harness-template CI client (M18.3 R8)
278
+
279
+ Drives the harness-template MCP tools non-interactively, so a pipeline can
280
+ validate, publish, preview and launch a reusable harness pipeline without a
281
+ browser. It is a **thin shell, not a second API** — the server does the
282
+ authorization, the validation, the optimistic-concurrency check and the launch
283
+ freeze, and everything decidable without I/O lives in the pure
284
+ `scripts/harness-template-core.ts`. A packaged operator CLI binary is an
285
+ explicit non-goal: MCP is the scripting surface.
286
+
287
+ Run it as `pnpm harness-template <command>` (or `npx tsx
288
+ scripts/harness-template.ts`, or `pnpm --dir agents harness-template` from the
289
+ repo root).
290
+
291
+ The whole file is covered, entrypoint included. `main(argv, deps)` is exported
292
+ and every dependency on the outside world — env, file read/write, stdout/stderr,
293
+ the MCP connection — is injectable, so the app's
294
+ `tests/cli/harness-template-client.test.ts` drives the SHIPPED executable
295
+ against the real in-process MCP server: exit codes, the token bootstrap, the
296
+ `--output` write, the session close, the envelope on stderr. Importing the
297
+ module runs nothing (an `isEntrypoint` guard), which is both what makes that
298
+ possible and what the suite asserts. The root `pnpm typecheck` follows the same
299
+ import, so this file is type-checked by the app gate as well as by
300
+ `pnpm --dir agents typecheck`.
301
+
302
+ ```bash
303
+ export STACKS_TOKEN=tm_… # a PAT with the admin scope
304
+ export STACKS_MCP_URL=https://tm.jentrix.ai/api/mcp # optional; this is the default
305
+
306
+ pnpm harness-template list --workspace ws_123
307
+ npx tsx scripts/harness-template.ts list --workspace ws_123
308
+ npx tsx scripts/harness-template.ts validate --template ht_123
309
+ npx tsx scripts/harness-template.ts publish --template ht_123 \
310
+ --expected-updated-at "$(npx tsx scripts/harness-template.ts get --template ht_123 --json | jq -r .template.updatedAt)"
311
+ npx tsx scripts/harness-template.ts preview --template ht_123 --repo acme/app --json
312
+ npx tsx scripts/harness-template.ts launch --template ht_123 --repo acme/app --dry-run \
313
+ --expected-checksum "$CHECKSUM" --idempotency-key "$GITHUB_RUN_ID"
314
+ ```
315
+
316
+ Commands: `list`, `get`, `validate`, `publish`, `export`, `import`, `preview`,
317
+ `launch`. `--json` emits the raw structured result for a downstream `jq`;
318
+ `--dry-run` on `preview`/`launch` selects the INERT launch (identical rows plus
319
+ a linked `SimulationRun`, every execution leg refused at the claim).
320
+
321
+ Three properties matter more than the flag list:
322
+
323
+ - **No auth flag exists.** The token comes from `STACKS_TOKEN` only. An
324
+ argument is visible in shell history, in `ps`, and in the CI log of every job
325
+ that echoes its command line.
326
+ - **Secret-shaped argv is refused before it connects** (PRD §11 R8: "raw
327
+ provider keys must not appear in arguments"). The refusal names the flag and
328
+ the remedy, and never echoes the value back into the log. A template
329
+ references credentials by ALIAS, bound once to a pre-created credential
330
+ record; the authoritative refusal is still server-side, where the ops core
331
+ runs the platform redactor over every definition it stores.
332
+ - **Exit codes gate a pipeline honestly.** `0` success, `1` server refusal (its
333
+ structured envelope printed verbatim to stderr), `2` bad invocation, `3` the
334
+ call succeeded and the ANSWER was no — `validate` found errors that block
335
+ publishing, or `preview` found blockers that refuse the launch. Without that
336
+ third code, `validate && publish` would publish exactly what validation
337
+ refused.
338
+
339
+ `publish` requires `--expected-updated-at` and every create-style command
340
+ requires `--idempotency-key`, because the MCP boundary requires them: a CI job
341
+ retries, and two pipelines edit the same template without seeing each other.
342
+ The client checks first only so a misconfigured job learns without a round
343
+ trip; it can never grant what the server refuses. Full tool reference:
344
+ `harness-specs/13-mcp-tool-reference.md` § Harness templates.
345
+
346
+ ## Platform features these exercise
347
+
348
+ | Feature | Where |
349
+ | --- | --- |
350
+ | Scoped PATs + workspace pinning (P2.1) | all three (least-privilege table above) |
351
+ | Structured `{ error: { code, hint } }` envelopes (P0.4) | system prompts tell the agent to follow hints |
352
+ | Idempotency keys (P2.4) | bug-triage (per delivery), crm-follow-up (per deal/day) |
353
+ | Orientation + search tools (P2.3, P3.1) | `get_board_snapshot`, `search_tasks` |
354
+ | Cross-kind task links (P1.2) | bug-triage duplicate detection |
355
+ | Outbound webhooks + HMAC (P2.6) | bug-triage receiver |
356
+ | Activity source attribution (P2.8) | standup + crm-follow-up `source` filters |
357
+ | Agent identity badges (P4.1) | token displayName/emoji on every mutation |
358
+ | CLI as an agent substrate (Jentrix CLI plan) | cli-standup — bash + `jentrix`, lazy `--help` discovery, read-scope-only |
359
+ | Harness templates over MCP (M18.3 R8) | `scripts/harness-template.ts` — one ops core, driven from CI |
360
+
361
+ ## Sanity check
362
+
363
+ `pnpm typecheck` type-checks the package. `pnpm test` runs the deterministic smoke tests on Node's built-in test runner via `tsx` — no real Claude/Codex/GitHub, so they pass in CI with no agent providers:
364
+
365
+ - **`harness-runner.smoke.test.ts`** drives a fake repo with mocked MCP calls, a mocked agent turn, mocked git, and a stubbed credential broker. It proves a stage executes and records evidence + commit, that a **frozen heartbeat stops all work before any submit/commit/credential/push**, and that the push/PR handoff uses a single-use brokered credential (never a standing repo token).
366
+ - **`scripts/harness-template.ts`** is the exception that proves the rule: it drives no model, so it is covered from the app side instead. `pnpm test tests/cli/harness-template-client.test.ts` (in the repo root) imports the executable, drives `main(argv, deps)` against the real in-process MCP server, and asserts the `harness-template` script wiring in this package.json.
367
+ - **`cli-standup.smoke.test.ts`** constructs the CLI agent's per-provider runtime config (no live model call) and asserts the restriction directly: the Claude runtime offers **only** the `Bash` built-in and wires no MCP server; the Codex runtime keeps the shell but jails writes to a scratch cwd with network on; and the system prompt teaches `--help` discovery + `--json`/jq rather than guessing flags.
368
+ - **`pnpm test:sealed`** is the same suite MINUS `harness-worktree.smoke.test.ts`, for the managed sealed verification lane (AGE-906). Those 15 tests build real git checkouts (`git()` / `makeCheckout`), and the sealed lane can never satisfy them: it runs a **tar copy** of the worktree, whose `.git` is a pointer file into the host repo, and the verification image ships no `git` binary — so they fail `spawnSync git ENOENT`. That is the same hazard `checkCommandLaneFeasibility`'s F3 rule already refuses for `git` *verification commands*, one level down (a suite spawning git internally). Measured sealed: **211 pass, 15 fail, all 15 in that one file**; `test:sealed` is 211/211. Installing git in the image would convert `ENOENT` into a subtler wrong answer, not a pass — the repo state genuinely is not there. Keep running the full `pnpm test` in CI and locally, where git and a real checkout exist.
369
+ - **`runner-auth.smoke.test.ts`** proves the M12.4b PAT retirement: the **managed plane drives a full harness stage using only workload-identity-minted capability tokens** (every MCP call carries a `cap_…` token, never a `tm_` PAT, with zero standing PATs in the env); a **grep asserts no managed-path module names a standing-PAT env var**; the managed provider **fails closed** if a standing PAT leaks into its environment; the **self-hosted bootstrap + claim-URL** flow spends the one-time bootstrap token exactly once then refreshes; and standing PATs **never select an auth path** (D4 — an unset mode fails closed even with PATs present).
370
+
371
+ For a live smoke test, point `.env` at a dev server (`pnpm dev` in the repo root, seeded via `pnpm db:seed`), mint a token with the dev helper, and run `pnpm standup`:
372
+
373
+ ```bash
374
+ TOKEN=$(curl -s -X POST localhost:3000/api/dev/mcp-token \
375
+ -H 'content-type: application/json' \
376
+ -d '{"name":"standup dev","displayName":"Standup Bot","emoji":"📋"}' | jq -r .token)
377
+ STACKS_TOKEN=$TOKEN pnpm standup # Claude runtime
378
+ STACKS_TOKEN=$TOKEN STACKS_AGENT_PROVIDER=codex pnpm standup # ChatGPT subscription
379
+ ```
380
+
381
+ The bash-only `cli-standup` needs the `jentrix` CLI on PATH (install it per [docs/cli.md](../docs/cli.md#install)); with the same dev token exported it runs the identical digest through the CLI:
382
+
383
+ ```bash
384
+ STACKS_TOKEN=$TOKEN pnpm cli-standup # discovers commands via `jentrix --help`, no MCP tools
385
+ ```
@@ -0,0 +1,7 @@
1
+ // lib/version.ts
2
+ var RUNNER_VERSION = "0.5.3";
3
+
4
+ export {
5
+ RUNNER_VERSION
6
+ };
7
+ //# sourceMappingURL=chunk-A6NTXRVS.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../lib/version.ts"],
4
+ "sourcesContent": ["export const RUNNER_VERSION = \"0.5.3\";\n"],
5
+ "mappings": ";AAAO,IAAM,iBAAiB;",
6
+ "names": []
7
+ }
@@ -0,0 +1,184 @@
1
+ // lib/runner-config.ts
2
+ import { constants, promises as fs } from "node:fs";
3
+ import { dirname, isAbsolute, join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ var RUNNER_CONFIG_SCHEMA_VERSION = 1;
6
+ function runnerConfigPath(env = process.env, home = homedir()) {
7
+ const override = env.STACKS_RUNNER_CONFIG?.trim();
8
+ if (override) {
9
+ if (!isAbsolute(override)) {
10
+ throw new Error(
11
+ "RUNNER_CONFIG_INVALID: STACKS_RUNNER_CONFIG must be absolute."
12
+ );
13
+ }
14
+ return override;
15
+ }
16
+ return join(home, ".config", "stacks", "runner.json");
17
+ }
18
+ function isRecord(value) {
19
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
20
+ }
21
+ function validateConfig(value) {
22
+ if (!isRecord(value) || value.schemaVersion !== RUNNER_CONFIG_SCHEMA_VERSION) {
23
+ throw new Error(
24
+ "RUNNER_CONFIG_INVALID: unsupported or missing config schema version."
25
+ );
26
+ }
27
+ const required = [
28
+ "protocolVersion",
29
+ "runnerVersion",
30
+ "jentrixBaseUrl",
31
+ "mcpUrl",
32
+ "workspaceId",
33
+ "workerId",
34
+ "heartbeatToken",
35
+ "bootstrapToken",
36
+ "claimUrl"
37
+ ];
38
+ for (const key of required) {
39
+ if (key === "protocolVersion" && typeof value[key] !== "number" || key !== "protocolVersion" && (typeof value[key] !== "string" || String(value[key]).length === 0)) {
40
+ throw new Error(`RUNNER_CONFIG_INVALID: config field ${key} is missing.`);
41
+ }
42
+ }
43
+ if (!isRecord(value.executables) || !isRecord(value.roleBindings)) {
44
+ throw new Error(
45
+ "RUNNER_CONFIG_INVALID: executable paths or role bindings are missing."
46
+ );
47
+ }
48
+ for (const [key, path] of Object.entries(value.executables)) {
49
+ if (key !== "claude" && key !== "codex" || typeof path !== "string" || !isAbsolute(path)) {
50
+ throw new Error(
51
+ "RUNNER_CONFIG_INVALID: executable paths must be absolute."
52
+ );
53
+ }
54
+ }
55
+ for (const role of ["planner", "executor", "reviewer"]) {
56
+ const binding = value.roleBindings[role];
57
+ if (!isRecord(binding)) {
58
+ throw new Error(
59
+ `RUNNER_CONFIG_INVALID: ${role} role binding is missing.`
60
+ );
61
+ }
62
+ for (const key of [
63
+ "agentId",
64
+ "runtimeAdapterKey",
65
+ "provider",
66
+ "providerConnectionId",
67
+ "model",
68
+ "source"
69
+ ]) {
70
+ if (typeof binding[key] !== "string" || binding[key].length === 0) {
71
+ throw new Error(`RUNNER_CONFIG_INVALID: ${role}.${key} is missing.`);
72
+ }
73
+ }
74
+ if (binding.runtimeAdapterKey !== "claude" && binding.runtimeAdapterKey !== "codex") {
75
+ throw new Error(
76
+ `RUNNER_CONFIG_INVALID: ${role} names an unknown runtime adapter.`
77
+ );
78
+ }
79
+ if (binding.source !== "active_release" && binding.source !== "worker_draft") {
80
+ throw new Error(`RUNNER_CONFIG_INVALID: ${role}.source is invalid.`);
81
+ }
82
+ if (typeof binding.requiresClaimCredential !== "boolean") {
83
+ throw new Error(
84
+ `RUNNER_CONFIG_INVALID: ${role}.requiresClaimCredential is missing.`
85
+ );
86
+ }
87
+ }
88
+ return value;
89
+ }
90
+ async function assertPrivate(path) {
91
+ if (process.platform === "win32") return;
92
+ const mode = (await fs.stat(path)).mode & 511;
93
+ if ((mode & 63) !== 0) {
94
+ throw new Error(
95
+ `RUNNER_CONFIG_INVALID: ${path} must be mode 0600; run: chmod 600 ${JSON.stringify(path)}`
96
+ );
97
+ }
98
+ }
99
+ async function readRunnerConfig(path = runnerConfigPath()) {
100
+ await assertPrivate(path);
101
+ let parsed;
102
+ try {
103
+ parsed = JSON.parse(await fs.readFile(path, "utf8"));
104
+ } catch (error) {
105
+ if (error instanceof SyntaxError) {
106
+ throw new Error(`RUNNER_CONFIG_INVALID: ${path} is not valid JSON.`);
107
+ }
108
+ throw error;
109
+ }
110
+ return validateConfig(parsed);
111
+ }
112
+ async function runnerConfigSummary(path = runnerConfigPath()) {
113
+ try {
114
+ const config = await readRunnerConfig(path);
115
+ return {
116
+ path,
117
+ configured: true,
118
+ workspaceId: config.workspaceId,
119
+ workerId: config.workerId,
120
+ blocker: null
121
+ };
122
+ } catch (error) {
123
+ const code = error?.code;
124
+ if (code === "ENOENT") {
125
+ return {
126
+ path,
127
+ configured: false,
128
+ workspaceId: null,
129
+ workerId: null,
130
+ blocker: {
131
+ code: "BOOTSTRAP_REQUIRED",
132
+ message: "runner setup has not been applied.",
133
+ nextCommand: "jentrix runner setup"
134
+ }
135
+ };
136
+ }
137
+ return {
138
+ path,
139
+ configured: false,
140
+ workspaceId: null,
141
+ workerId: null,
142
+ blocker: {
143
+ code: "RUNNER_CONFIG_INVALID",
144
+ message: error instanceof Error ? error.message : "runner config is invalid.",
145
+ nextCommand: "jentrix runner setup"
146
+ }
147
+ };
148
+ }
149
+ }
150
+ async function writeRunnerConfigAtomic(config, path = runnerConfigPath()) {
151
+ validateConfig(config);
152
+ const dir = dirname(path);
153
+ await fs.mkdir(dir, { recursive: true, mode: 448 });
154
+ if (process.platform !== "win32") await fs.chmod(dir, 448);
155
+ const temp = `${path}.tmp-${process.pid}-${Date.now()}`;
156
+ try {
157
+ const handle = await fs.open(
158
+ temp,
159
+ constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY,
160
+ 384
161
+ );
162
+ try {
163
+ await handle.writeFile(`${JSON.stringify(config, null, 2)}
164
+ `, "utf8");
165
+ await handle.sync();
166
+ } finally {
167
+ await handle.close();
168
+ }
169
+ if (process.platform !== "win32") await fs.chmod(temp, 384);
170
+ await fs.rename(temp, path);
171
+ } catch (error) {
172
+ await fs.unlink(temp).catch(() => void 0);
173
+ throw error;
174
+ }
175
+ }
176
+
177
+ export {
178
+ RUNNER_CONFIG_SCHEMA_VERSION,
179
+ runnerConfigPath,
180
+ readRunnerConfig,
181
+ runnerConfigSummary,
182
+ writeRunnerConfigAtomic
183
+ };
184
+ //# sourceMappingURL=chunk-N2UNVXHV.js.map