agent-tempo 1.7.0-beta.2 → 1.7.0-beta.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/CLAUDE.md CHANGED
@@ -212,13 +212,15 @@ daemon worker notes, `npx ts-node` dev runner).
212
212
  - **Claude Code headless adapter** (`agent: 'claude-code-headless'`, #520): Headless adapter that drives sessions via the official `claude` CLI as a per-turn `claude -p --output-format stream-json` subprocess. The whole point: turns bill against the host's existing Claude Code subscription extra-usage credits (Pro / Max plans) rather than a Console workspace API key — the only ToS-clean way for a third-party tool to tap that pool. Requires the `claude` binary on PATH AND a logged-in Claude Code session (`claude auth login`); recruit pre-flight rejects with an actionable error otherwise. Tool surface is the union of full Claude Code built-ins (Bash / Read / Write / Edit / Glob / Grep / WebSearch / WebFetch) and the agent-tempo MCP surface — registered via inline `--mcp-config` so `claude` spawns `dist/server.js` as its own MCP child (no in-process bridge). Recruit knobs: `permissionMode` (default `'acceptEdits'`) or `dangerouslySkipPermissions: true` (mutually exclusive). Sessions resume across restart via the existing `sessionId` metadata field — the same UUID is shared with the interactive `claude-code` adapter (per-cwd JSONL is per-cwd, not per-adapter). See `src/adapters/claude-code-headless/` and `examples/ensembles/tempo-headless-jam.yaml`.
213
213
  - **Pi adapter** (`agent: 'pi'`, #632 / #666): Two modes. **(1) Interactive conductor** (#666): `agent-tempo up --agent pi --ensemble <name>` launches `pi` in a real terminal with the agent-tempo extension auto-loaded (`pi -e dist/pi/extension.js`); the Pi session self-bootstraps its Temporal workflow and attaches as a conductor/player — no separate recruiter step. From the TUI, `/recruit-conductor` relaunches the active ensemble's conductor — set `conductor.agent: pi` in that ensemble's lineup to make it a Pi conductor. Requires `@earendil-works/pi-coding-agent` on Node ≥ 22.19. Recommended: `ANTHROPIC_API_KEY` (without it the session falls back to Pi's own auth/default model). The `AGENT_TEMPO_*` env is auto-wired by `up`; power users can invoke the extension directly with `pi -e dist/pi/extension.js`. `--model provider/model` selector (e.g. `'github-copilot/gpt-4o'`) is a fast-follow. **(2) Headless player** (Phase 3a): `recruit` with `agent: 'pi'` — no terminal, no BaseAttachment; runs `createAgentSession` with an in-memory `SessionManager`; the module-scope singleton owns claim/heartbeat/tools/cue pump (MD-D). MD-C tool-access policy: `toolAccess: 'restricted'` (default — Bash/shell/exec HARD-BLOCKED) | `'standard'` (scoped Bash) | `'full'` (unsandboxed; requires `force: true`). `noExtensions: true` closes the S2 exec-tool-bypass gap. See `src/adapters/pi/` and `src/pi/headless.ts`.
214
214
  - **Mission-control Pi extension** (3f): An observer-only Pi extension that turns one interactive Pi TUI into a live ensemble board + operator controller. HTTP-drives the daemon — coarse ensemble view via `/v1/events/:ensemble` SSE + fine per-player tail via `/inner` (T3); operator controls (cue/pause/play/restart/destroy + gate arm/disarm/decide) POST to the daemon write surface using `AGENT_TEMPO_HTTP_ADMIN_TOKEN`. **Never claims attachment or registers as a player** — invisible to the ensemble. ~200ms render throttle from an in-memory `BoardModel`. Requires an interactive Pi session with `ctx.hasUI`. See `src/pi/mission-control/`.
215
+ - **Command-center planner** (#700 P2): An inbox-less interactive Pi session (the operator's planning seat) that routes questions to players via correlated `cue` tags (`[Q <questionId>]`). Players answer with the `respond` MCP tool, which parks the answer on the per-ensemble maestro Q&A mailbox (TTL 1h, 20-slot cap). The planner is woken by an `answer` SSE event when the answer lands (`docs/SSE-PROTOCOL.md` §6). `/handoff` cues hand active work to a conductor (a registered player with a Temporal inbox). See `docs/concepts.md` for the Q&A mechanics.
216
+ - **Guardrail policy** (`guardrailPolicy` recruit arg, #700 P2): Per-player posture for the operator gate on headless Pi players. Four values — `'autonomous'` (default; no gate), `'monitored'` (gate engaged; fail-open: auto-allow after 45 s), `'supervised'` (gate engaged; fail-closed: auto-deny after 300 s; **client-cooperative in P2, not tamper-proof**; daemon-side enforcement is P2.1 #44), `'observe-only'` (non-`low-risk` tools hard-blocked outright). Persisted on `SessionMetadata`; re-sourced at each `(re)attach` from `getMetadata`. See `docs/concepts.md` for operator gate mechanics.
215
217
  - **Mock adapter** (`agent: 'mock'`, dev mode only): Four modes: `echo` (echoes input), `scripted` (replays YAML scenario rules), `silent` (drains messages without replying — heartbeat-stale validation), `chaos` (probabilistic fail/crash injection via seeded PRNG). Only registered when `isDevMode()` is true; stripped from the npm tarball by `prepack`. See `src/adapters/mock/`.
216
218
  - **Saveable state** (#334, ADR 0011): Per-player curated state slots — the player itself decides what context survives a restart. Three MCP tools: `save_state` (owner-only write, max 4 slots × 32 KiB), `fetch_state` (read self or peer; audit identity recorded on each entry's `savedBy`), `clear_state` (owner-only). `restart` accepts `loadFromState: true | 'someKey'` to seed the new session from a saved-state slot instead of (or, with `transcript: 'replay'`, alongside) transcript replay. Saved-state delivery uses `from: 'self-restart'` as a stable system identity. Empty-slot fallback: graceful — falls through to transcript replay with a log line. See [docs/design/334-player-saveable-state.md](docs/design/334-player-saveable-state.md).
217
219
  - **Coat-check** (#318, ADR 0008): Per-ensemble transient content store on Maestro state. Solves the 100 KB cue body cap — stash a large artifact with `coat_check_put` (returns a ticket id) and attach the ticket to a `cue` via `attachmentTicket`; the recipient calls `coat_check_get` to pull the full body. Four MCP tools: `coat_check_put` (any player; max 32 KiB per entry, 20 slots per ensemble, TTL 7d default), `coat_check_get` (any player; bumps fetch-audit counters), `coat_check_list` (read-only survey; headers only, content omitted), `coat_check_evict` (owner or conductor). Saturation rejects with `CoatCheckSlotsFull` (no LRU eviction). See `src/tools/coat-check-*.ts` and [docs/adr/0008-coat-check-pattern.md](docs/adr/0008-coat-check-pattern.md).
218
220
  - **Lineup examples**: Six pre-built ensemble YAML files in `examples/ensembles/` — `tempo-big-band`, `tempo-dev-team`, `tempo-review-squad`, `tempo-jam-session`, `tempo-mock-jam` (dev-mode all-mock ensemble), `tempo-headless-jam` (#520 — all-`claude-code-headless` subscription-billed ensemble). Load with `agent-tempo up --lineup <name>` or the `load_lineup` tool.
219
221
  - **GitHub App identity** (`agent-tempo[bot]`): When a player writes to GitHub — issue comments, PR creation/merge, commits, labels, check runs — **use `./scripts/ensemble-gh`** instead of `gh`. The wrapper mints a short-lived installation token so the action is attributed to `agent-tempo[bot]`, not to the human maintainer, making the AI authorship visible. Plain `gh` is still correct for read-only local dev (`gh pr view`, `gh repo clone`, `gh auth status`). Every bot-authored comment/PR body must include the AI attribution footer documented in [docs/github-app.md](docs/github-app.md).
220
222
 
221
- See [docs/concepts.md](docs/concepts.md) for the full glossary (Adapter, Attachment phases, Restart, Detach/Destroy, Migrate, Broadcast, Recall, Schedule, Lineup, Quality Gate, Worktree, Stage, Hold/Release, Pause/Resume, Maestro, TempoClient, and more).
223
+ See [docs/concepts.md](docs/concepts.md) for the full glossary (Adapter, Attachment phases, Restart, Detach/Destroy, Migrate, Broadcast, Recall, Schedule, Lineup, Quality Gate, Worktree, Stage, Hold/Release, Pause/Resume, Maestro, TempoClient, Guardrail policy, Command-center planner, and more).
222
224
 
223
225
  ## Commit Convention
224
226
 
package/README.md CHANGED
@@ -277,7 +277,7 @@ From the TUI, `/recruit-conductor` relaunches the active ensemble's conductor
277
277
 
278
278
  **Prerequisites:** `@earendil-works/pi-coding-agent` on Node ≥ 22.19. Recommended: `ANTHROPIC_API_KEY` (without it the session falls back to Pi's own auth/default model).
279
279
 
280
- **Headless Pi players** — recruit as a background agent slot using `agent: 'pi'` (see [docs/design/pi-hardening-h1-h2-h3.md](docs/design/pi-hardening-h1-h2-h3.md)).
280
+ **Headless Pi players** — recruit as a background agent slot using `agent: 'pi'`. Pass `guardrailPolicy: 'monitored' | 'supervised' | 'observe-only'` to control the operator gate (default `'autonomous'` — no gate). See [docs/concepts.md](docs/concepts.md#command-center-and-player-supervision) for the gate mechanics and the supervised caveat.
281
281
 
282
282
  📖 [Pi integration reference → docs/design/pi-hardening-h1-h2-h3.md](docs/design/pi-hardening-h1-h2-h3.md)
283
283
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-tempo-dashboard",
3
3
  "private": true,
4
- "version": "1.7.0-beta.2",
4
+ "version": "1.7.0-beta.3",
5
5
  "type": "module",
6
6
  "description": "Web dashboard for agent-tempo. Bundled into the npm package; served by the daemon at /dashboard/*.",
7
7
  "scripts": {
@@ -504,6 +504,13 @@ function createOutboxActivities(client, config, ingestTokens, gate) {
504
504
  // REPLACES) means a restart re-mints and naturally revokes the stale
505
505
  // token. Injected into the subprocess env as AGENT_TEMPO_INGEST_TOKEN.
506
506
  const ingestToken = ingestTokens?.mint((0, config_1.sessionWorkflowId)(ensemble, targetName));
507
+ // #712 — record the DURABLE guardrail policy on the daemon gate at spawn
508
+ // (beside the ingest-token mint, same daemon process + lifecycle) so the
509
+ // gate's failMode cross-check is daemon-authoritative from the FIRST
510
+ // engagement — no lazy metadata query on the common path. Absent ⇒
511
+ // autonomous (the extension default). Mint REPLACES on restart; setPolicy
512
+ // likewise re-stamps the current durable posture (no silent downgrade).
513
+ gate?.setPolicy((0, config_1.sessionWorkflowId)(ensemble, targetName), guardrailPolicy ?? 'autonomous');
507
514
  const { pid } = (0, spawn_1.spawnPiHeadless)({
508
515
  name: targetName,
509
516
  ensemble,
@@ -1030,10 +1030,17 @@ async function up(opts) {
1030
1030
  else if (p.detail)
1031
1031
  out.dim(` Agent types already installed (${p.detail})`);
1032
1032
  }
1033
+ else if (p.step === 'search-attributes') {
1034
+ // #46 — ensureInfra registers SAs with `quiet: true` (the per-attribute
1035
+ // success lines are suppressed for the extension TUI path). The CLI
1036
+ // restores visibility with a one-line summary here. `detail` is
1037
+ // "N failed" only when registration failed (errors/permission warnings
1038
+ // still print from registerSearchAttributes regardless of `quiet`).
1039
+ out.check('Search attributes registered', !p.detail, p.detail);
1040
+ }
1033
1041
  else if (p.step === 'daemon') {
1034
1042
  out.check(p.status === 'ok' ? 'Worker daemon running' : 'Worker daemon started', true, p.detail);
1035
1043
  }
1036
- // 'search-attributes' logs per-attribute internally via registerSearchAttributes.
1037
1044
  },
1038
1045
  });
1039
1046
  }
@@ -1109,6 +1109,22 @@ function createTempoClientCore(client, opts = {}) {
1109
1109
  return null;
1110
1110
  }
1111
1111
  },
1112
+ async coatCheckPut(ensemble, input) {
1113
+ // #713 — thin wrapper over the maestro `coatCheckPut` Update so the daemon
1114
+ // HTTP route can stash on behalf of the inbox-less command-center planner.
1115
+ // Errors PROPAGATE (unlike getAnswer's tolerant catch): the workflow's
1116
+ // structured `CoatCheckSlotsFull` / `CoatCheckEntryTooLarge` failures must
1117
+ // reach the HTTP layer so it can map them to a 4xx instead of swallowing.
1118
+ const h = handle((0, config_1.maestroWorkflowId)(ensemble));
1119
+ return await h.executeUpdate(maestro_signals_1.coatCheckPutUpdate, { args: [input] });
1120
+ },
1121
+ async coatCheckGet(ensemble, input) {
1122
+ // #713 — redeem a ticket over the maestro `coatCheckGet` Update. `null` is
1123
+ // the workflow's normal "missing / expired / evicted" return (not an
1124
+ // error); genuine failures propagate to the HTTP layer.
1125
+ const h = handle((0, config_1.maestroWorkflowId)(ensemble));
1126
+ return await h.executeUpdate(maestro_signals_1.coatCheckGetUpdate, { args: [input] });
1127
+ },
1112
1128
  async isAnySessionHeld(ensemble) {
1113
1129
  // Scan the ensemble's sessions and check the per-session
1114
1130
  // `outboxLocked` query. The maestro session is skipped — it's the
@@ -4,7 +4,8 @@
4
4
  * Extracted from `src/tui/client.ts` so that non-TUI consumers (CLI, tests,
5
5
  * external integrations) can depend on the interface without pulling in Ink/React.
6
6
  */
7
- import type { AgentType, MaestroPlayerInfo, MaestroRelayMessage, HistoryEntry, Message, SentMessage, SessionMetadata, ScheduleEntry, QualityGate, StageEntry, WorktreeEntry, EnsembleChatResult, AttachmentInfo, HostInfo, AnswerEntry } from '../types';
7
+ import type { AgentType, MaestroPlayerInfo, MaestroRelayMessage, HistoryEntry, Message, SentMessage, SessionMetadata, ScheduleEntry, QualityGate, StageEntry, WorktreeEntry, EnsembleChatResult, AttachmentInfo, HostInfo, AnswerEntry, CoatCheckEntry } from '../types';
8
+ import type { CoatCheckPutInput, CoatCheckPutResult, CoatCheckGetInput } from '../workflows/maestro-signals';
8
9
  import type { RestoreOrphansSummary } from '../reconcile/orphans';
9
10
  import type { SubscribeOptions, TempoEvent } from '../http/event-types';
10
11
  /**
@@ -427,6 +428,23 @@ export interface TempoClientCore {
427
428
  * aggregate's outstanding-ask poll (which emits the `answer` SSE event).
428
429
  */
429
430
  getAnswer(ensemble: string, questionId: string): Promise<AnswerEntry | null>;
431
+ /**
432
+ * #713 — stash a large content body on the per-ensemble coat-check (#318) over
433
+ * a maestro Update. Thin client wrapper around `coatCheckPutUpdate` so the
434
+ * daemon HTTP route (`POST /v1/ensembles/:e/coat-check`) can let the inbox-less
435
+ * command-center planner park a plan and hand off a ticket. `putBy` is the
436
+ * audit identity (set by the caller — the HTTP layer stamps the operator).
437
+ * Throws the workflow's structured ApplicationFailure on saturation /
438
+ * oversize (`CoatCheckSlotsFull` / `CoatCheckEntryTooLarge`).
439
+ */
440
+ coatCheckPut(ensemble: string, input: CoatCheckPutInput): Promise<CoatCheckPutResult>;
441
+ /**
442
+ * #713 — redeem a coat-check ticket (#318) over a maestro Update. Returns the
443
+ * full entry (incl. content body) or `null` when the ticket is missing /
444
+ * expired / evicted. A successful redemption bumps the entry's fetch-audit
445
+ * counters (`fetchedBy` is the audit identity), so this is NOT a pure read.
446
+ */
447
+ coatCheckGet(ensemble: string, input: CoatCheckGetInput): Promise<CoatCheckEntry | null>;
430
448
  /** Disband an ensemble: terminate all sessions, scheduler, and maestro workflows. */
431
449
  disbandEnsemble(ensemble: string): Promise<{
432
450
  terminated: number;
package/dist/daemon.js CHANGED
@@ -74,6 +74,8 @@ const grpc_shutdown_guard_1 = require("./utils/grpc-shutdown-guard");
74
74
  const daemon_1 = require("./cli/daemon");
75
75
  const client_3 = require("./client");
76
76
  const orphans_1 = require("./reconcile/orphans");
77
+ const query_timeout_1 = require("./utils/query-timeout");
78
+ const signals_1 = require("./workflows/signals");
77
79
  const agent_types_1 = require("./ensemble/agent-types");
78
80
  const pre_flight_1 = require("./adapters/claude-code-headless/pre-flight");
79
81
  const daemon_adapter_versions_1 = require("./daemon-adapter-versions");
@@ -970,6 +972,24 @@ async function main() {
970
972
  const bootEpoch = Date.now();
971
973
  aggregateRunner = new AggregateRunner({ client: httpClient, bootEpoch });
972
974
  aggregateRunner.start();
975
+ // #712 — BOUNDED durable-policy resolver for the gate failMode cross-check.
976
+ // The gate is normally policy-populated at spawn (sync, no query); this is
977
+ // the rare-cache-miss fallback the ingest gate_pending path awaits. It MUST
978
+ // be bounded (utils/query-timeout) so it can never hang gate engagement. A
979
+ // successful query with an absent metadata field ⇒ 'autonomous' (a real,
980
+ // open posture). Timeout / error / workflow-gone ⇒ undefined ⇒ the route
981
+ // leaves the policy unresolved ⇒ open() enforces 'closed' (NO-FAIL-OPEN).
982
+ const reconcileClientForPolicy = reconcileClient;
983
+ const resolveGuardrailPolicy = async (workflowId) => {
984
+ try {
985
+ const handle = reconcileClientForPolicy.workflow.getHandle(workflowId);
986
+ const md = await (0, query_timeout_1.queryHandleWithTimeout)(handle, signals_1.getMetadataQuery);
987
+ return md?.guardrailPolicy ?? 'autonomous';
988
+ }
989
+ catch {
990
+ return undefined; // timeout / error / gone → indeterminate → open() enforces closed
991
+ }
992
+ };
973
993
  httpServerHandle = await startHttpServer({
974
994
  client: httpClient,
975
995
  namespace: config.temporalNamespace,
@@ -984,6 +1004,8 @@ async function main() {
984
1004
  // 3d MD-G — the same gate registry the worker's outbox auto-disarms on
985
1005
  // detach/destroy; the HTTP server serves arm/disarm/decide + resolution.
986
1006
  gate,
1007
+ // #712 — bounded durable-policy resolver for the failMode cross-check.
1008
+ resolveGuardrailPolicy,
987
1009
  });
988
1010
  log(`HTTP listening on http://${httpServerHandle.bindAddr}:${httpServerHandle.port}`);
989
1011
  log(`Aggregate poll loop running (bootEpoch=${bootEpoch})`);
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Daemon coat-check routes (#713 / #42) — HTTP surface over the per-ensemble
3
+ * coat-check store (#318, ADR 0008).
4
+ *
5
+ * POST /v1/ensembles/:e/coat-check { summary, content, contentType?, ttlMs? } → 200 { ticket, … }
6
+ * GET /v1/ensembles/:e/coat-check/:ticket → 200 { found, entry }
7
+ *
8
+ * **Why**: `coat_check_put` was MCP/Temporal-only, so the command-center planner
9
+ * (a mission-control Pi extension with NO Temporal inbox — it drives the daemon
10
+ * over HTTP) could only hand off plans INLINE on a cue. This route lets the
11
+ * planner park a plan and hand off a ticket instead, keeping the cue body lean.
12
+ *
13
+ * **Audit identity** is stamped by this layer as the operator (`maestro`) — the
14
+ * HTTP caller cannot supply `putBy` / `fetchedBy` (same anti-spoof posture as the
15
+ * MCP tools, where audit identity comes from `getPlayerId()`, never a caller arg).
16
+ *
17
+ * **Auth** (gated by the caller in server.ts):
18
+ * - `put` is a WRITE → Tier 2 (admin), consistent with `/ask` and the write surface.
19
+ * - `get` REDEEMS via a maestro Update that bumps fetch-audit counters — it is NOT
20
+ * a pure read, so it is also gated at Tier 2.
21
+ */
22
+ import type { IncomingMessage, ServerResponse } from 'http';
23
+ import type { TempoClient } from '../client/interface';
24
+ /**
25
+ * Operator audit identity for HTTP-sourced coat-check ops. The command-center
26
+ * planner has no player id; like the `/cue` + `/ask` routes (which write as the
27
+ * maestro), stashes/redeems are attributed to the maestro. NOT caller-supplied —
28
+ * a fixed constant so the HTTP surface can't spoof a peer's audit identity.
29
+ */
30
+ export declare const HTTP_COAT_CHECK_IDENTITY = "maestro";
31
+ /** A ticket is non-empty, ≤ cap, and URL/path-safe (it rides a GET path segment). */
32
+ export declare function isValidTicket(t: string | undefined): t is string;
33
+ /** POST /v1/ensembles/:e/coat-check — stash a content body, return a ticket. */
34
+ export declare function handleCoatCheckPut(req: IncomingMessage, res: ServerResponse, client: TempoClient, ensemble: string): Promise<void>;
35
+ /** GET /v1/ensembles/:e/coat-check/:ticket — redeem a ticket + pull the content. */
36
+ export declare function handleCoatCheckGet(res: ServerResponse, client: TempoClient, ensemble: string, ticket: string): Promise<void>;
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HTTP_COAT_CHECK_IDENTITY = void 0;
4
+ exports.isValidTicket = isValidTicket;
5
+ exports.handleCoatCheckPut = handleCoatCheckPut;
6
+ exports.handleCoatCheckGet = handleCoatCheckGet;
7
+ const responses_1 = require("./responses");
8
+ const body_1 = require("./body");
9
+ const validation_1 = require("../utils/validation");
10
+ const validation_2 = require("../utils/validation");
11
+ /**
12
+ * Operator audit identity for HTTP-sourced coat-check ops. The command-center
13
+ * planner has no player id; like the `/cue` + `/ask` routes (which write as the
14
+ * maestro), stashes/redeems are attributed to the maestro. NOT caller-supplied —
15
+ * a fixed constant so the HTTP surface can't spoof a peer's audit identity.
16
+ */
17
+ exports.HTTP_COAT_CHECK_IDENTITY = 'maestro';
18
+ /** A ticket is non-empty, ≤ cap, and URL/path-safe (it rides a GET path segment). */
19
+ function isValidTicket(t) {
20
+ return typeof t === 'string' && t.length > 0 && t.length <= validation_2.COAT_CHECK_TICKET_MAX && validation_2.COAT_CHECK_TICKET_REGEX.test(t);
21
+ }
22
+ /** POST /v1/ensembles/:e/coat-check — stash a content body, return a ticket. */
23
+ async function handleCoatCheckPut(req, res, client, ensemble) {
24
+ if ((0, validation_1.validateEnsembleName)(ensemble) !== null) {
25
+ return (0, responses_1.errorResponse)(res, 400, { error: 'invalid-ensemble-name', ensemble });
26
+ }
27
+ const body = await (0, body_1.readJsonBody)(req);
28
+ if (body === body_1.BODY_TOO_LARGE)
29
+ return (0, responses_1.errorResponse)(res, 413, { error: 'body-too-large', limit: body_1.WRITE_BODY_MAX });
30
+ if (body === body_1.BODY_INVALID_JSON)
31
+ return (0, responses_1.errorResponse)(res, 400, { error: 'invalid-json' });
32
+ const summary = (0, body_1.stringField)(body, 'summary');
33
+ const content = (0, body_1.stringField)(body, 'content');
34
+ if (!summary)
35
+ return (0, responses_1.errorResponse)(res, 400, { error: 'missing-field', field: 'summary' });
36
+ if (!content)
37
+ return (0, responses_1.errorResponse)(res, 400, { error: 'missing-field', field: 'content' });
38
+ if (summary.length > validation_2.COAT_CHECK_SUMMARY_MAX) {
39
+ return (0, responses_1.errorResponse)(res, 400, { error: 'summary-too-long', limit: validation_2.COAT_CHECK_SUMMARY_MAX });
40
+ }
41
+ // Byte length, not char length — the workflow validator caps on UTF-8 bytes.
42
+ if (Buffer.byteLength(content, 'utf8') > validation_2.COAT_CHECK_CONTENT_MAX) {
43
+ return (0, responses_1.errorResponse)(res, 413, { error: 'content-too-large', limit: validation_2.COAT_CHECK_CONTENT_MAX });
44
+ }
45
+ const contentType = (0, body_1.stringField)(body, 'contentType');
46
+ if (contentType !== undefined && contentType.length > validation_2.COAT_CHECK_CONTENT_TYPE_MAX) {
47
+ return (0, responses_1.errorResponse)(res, 400, { error: 'content-type-too-long', limit: validation_2.COAT_CHECK_CONTENT_TYPE_MAX });
48
+ }
49
+ let ttlMs;
50
+ if (body.ttlMs !== undefined) {
51
+ if (typeof body.ttlMs !== 'number' ||
52
+ !Number.isInteger(body.ttlMs) ||
53
+ body.ttlMs < validation_2.COAT_CHECK_TTL_MIN_MS ||
54
+ body.ttlMs > validation_2.COAT_CHECK_TTL_MAX_MS) {
55
+ return (0, responses_1.errorResponse)(res, 400, {
56
+ error: 'invalid-field', field: 'ttlMs', min: validation_2.COAT_CHECK_TTL_MIN_MS, max: validation_2.COAT_CHECK_TTL_MAX_MS,
57
+ });
58
+ }
59
+ ttlMs = body.ttlMs;
60
+ }
61
+ try {
62
+ const result = await client.coatCheckPut(ensemble, {
63
+ summary,
64
+ content,
65
+ ...(contentType !== undefined ? { contentType } : {}),
66
+ ...(ttlMs !== undefined ? { ttlMs } : {}),
67
+ putBy: exports.HTTP_COAT_CHECK_IDENTITY,
68
+ });
69
+ (0, responses_1.jsonResponse)(res, 200, { ok: true, ensemble, ...result });
70
+ }
71
+ catch (err) {
72
+ return mapCoatCheckError(res, ensemble, err);
73
+ }
74
+ }
75
+ /** GET /v1/ensembles/:e/coat-check/:ticket — redeem a ticket + pull the content. */
76
+ async function handleCoatCheckGet(res, client, ensemble, ticket) {
77
+ if ((0, validation_1.validateEnsembleName)(ensemble) !== null) {
78
+ return (0, responses_1.errorResponse)(res, 400, { error: 'invalid-ensemble-name', ensemble });
79
+ }
80
+ if (!isValidTicket(ticket)) {
81
+ return (0, responses_1.errorResponse)(res, 400, { error: 'invalid-ticket', ticket });
82
+ }
83
+ try {
84
+ const entry = await client.coatCheckGet(ensemble, { ticket, fetchedBy: exports.HTTP_COAT_CHECK_IDENTITY });
85
+ // Mirror the sibling `/answer` route: 200 + `found:false` for the common
86
+ // "ticket already gone" case (missing / expired / evicted), rather than 404.
87
+ (0, responses_1.jsonResponse)(res, 200, { ok: true, ensemble, ticket, found: entry !== null, entry });
88
+ }
89
+ catch (err) {
90
+ return mapCoatCheckError(res, ensemble, err);
91
+ }
92
+ }
93
+ /**
94
+ * Map a thrown coat-check error to an HTTP response. Recognises the maestro
95
+ * hub-not-running errors (→ 404) and the workflow's structured saturation /
96
+ * oversize ApplicationFailures (→ 409 / 413); anything else → 500.
97
+ */
98
+ function mapCoatCheckError(res, ensemble, err) {
99
+ const message = err instanceof Error ? err.message : String(err);
100
+ if (/no session found|no maestro|workflow not found/i.test(message)) {
101
+ return (0, responses_1.errorResponse)(res, 404, { error: 'session-not-found', ensemble, detail: message });
102
+ }
103
+ if (/CoatCheckSlotsFull/i.test(message)) {
104
+ return (0, responses_1.errorResponse)(res, 409, { error: 'coat-check-slots-full', ensemble, detail: message });
105
+ }
106
+ if (/CoatCheckEntryTooLarge/i.test(message)) {
107
+ return (0, responses_1.errorResponse)(res, 413, { error: 'coat-check-entry-too-large', ensemble, detail: message });
108
+ }
109
+ return (0, responses_1.errorResponse)(res, 500, { error: 'coat-check-failed', ensemble, detail: message });
110
+ }
@@ -42,7 +42,7 @@ exports.createGateAuditSink = createGateAuditSink;
42
42
  *
43
43
  * <AGENT_TEMPO_HOME>/gate-audit/<ensemble>/<workflowId>.jsonl
44
44
  *
45
- * Each {@link GateAuditRecord} (arm | disarm | decision) is one JSON line,
45
+ * Each {@link GateAuditRecord} (arm | disarm | decision | failmode-override) is one JSON line,
46
46
  * appended SYNCHRONOUSLY at the decision/posture-change point so the durable
47
47
  * record lands before the daemon hands back control (no buffering window where a
48
48
  * crash loses an allow/deny). The `ensemble` sidecar (not part of the locked
@@ -47,6 +47,7 @@
47
47
  * coordination bus). Nothing here is imported by `src/workflows/`.
48
48
  */
49
49
  import type { InnerFrame } from '../pi/inner-loop-publisher';
50
+ import type { GuardrailPolicy } from '../types';
50
51
  /**
51
52
  * MONITORED (fail-OPEN) timeout: a pending request auto-ALLOWS after this long
52
53
  * (R3, locked). The cheap-if-wrong fuse for an otherwise-autonomous agent the
@@ -75,6 +76,26 @@ export declare const GATE_CLOSED_DENY_MS = 300000;
75
76
  * (auto-ALLOW); `'closed'` = supervised (auto-DENY).
76
77
  */
77
78
  export type GateFailMode = 'open' | 'closed';
79
+ /**
80
+ * #712 — the daemon-AUTHORITATIVE fail posture for a player, derived from its
81
+ * DURABLE `guardrailPolicy` (the source of truth on SessionMetadata). The
82
+ * `gate_pending` frame's `failMode` claim is ADVISORY only: the daemon enforces
83
+ * THIS regardless, so an engaging agent can't self-downgrade a supervised player
84
+ * out of fail-closed by stamping `'open'`.
85
+ *
86
+ * Posture — **fail-CLOSED except an EXPLICITLY `monitored`/`autonomous` player**:
87
+ * - `monitored` → `'open'` (operator-armed observability gate; fail-OPEN is its point)
88
+ * - `autonomous` → `'open'` (never engages the gate on its own; if an operator
89
+ * arms it, it behaves as monitored → fail-open)
90
+ * - `supervised` → `'closed'`
91
+ * - `observe-only` → `'closed'` (most-restrictive no-act posture; if the client
92
+ * no-act block is ever bypassed and it reaches the
93
+ * gate, auto-ALLOW would be backwards — defense-in-depth)
94
+ * - `undefined` (policy not yet resolved — e.g. post-daemon-restart pre-reconcile,
95
+ * or a bounded metadata query that timed out/errored) → `'closed'`
96
+ * (NO-FAIL-OPEN: uncertainty resolves to the safe posture).
97
+ */
98
+ export declare function enforcedFailMode(policy: GuardrailPolicy | undefined): GateFailMode;
78
99
  /**
79
100
  * Terminal decision on a gated tool call. `auto-allow` = the open (monitored)
80
101
  * timeout fired; `auto-deny` = the closed (supervised) timeout fired. The two
@@ -100,10 +121,12 @@ export interface GateRequestMeta {
100
121
  */
101
122
  ensemble?: string;
102
123
  /**
103
- * Per-request fail posture (#700 / G). `'closed'` (supervised) → auto-DENY
104
- * after {@link GATE_CLOSED_DENY_MS}; `'open'` (monitored, **default**) →
105
- * auto-ALLOW after {@link GATE_AUTO_ALLOW_MS}. Carried on the `gate_pending`
106
- * frame from the agent's durable `guardrailPolicy`; omitted `'open'`.
124
+ * The engaging agent's ADVISORY failMode claim from the `gate_pending` frame
125
+ * (#700). **#712: advisory only** — {@link GateRegistry.open} ENFORCES the
126
+ * failMode from the player's durable policy ({@link enforcedFailMode}), not
127
+ * from this field. It is retained solely to DETECT a self-downgrade (frame
128
+ * claimed the weaker `open` while the policy enforces `closed` → a
129
+ * `failmode-override` audit record). Omitted ⇒ treated as the claim `'open'`.
107
130
  */
108
131
  failMode?: GateFailMode;
109
132
  }
@@ -133,6 +156,26 @@ export type GateAuditRecord = {
133
156
  workflowId: string;
134
157
  source: 'operator';
135
158
  operatorTokenHint?: string;
159
+ } | {
160
+ /**
161
+ * #712 — the daemon enforced a STRICTER failMode than the gate_pending
162
+ * frame claimed (frame `open`, policy-enforced `closed`). An
163
+ * override-detected OBSERVABILITY signal — NEUTRAL-FACTUAL: it could be a
164
+ * client bug, a stale client, or an injection self-downgrade attempt; the
165
+ * operator/security interprets. This is exactly the self-downgrade vector
166
+ * the #712 cross-check closes.
167
+ */
168
+ kind: 'failmode-override';
169
+ ts: string;
170
+ workflowId: string;
171
+ sessionId?: string;
172
+ requestId: string;
173
+ tool: string;
174
+ argsSummary: string;
175
+ claimedFailMode: GateFailMode;
176
+ enforcedFailMode: GateFailMode;
177
+ /** The durable policy that drove enforcement; `'unknown'` when unresolved. */
178
+ policy: GuardrailPolicy | 'unknown';
136
179
  };
137
180
  /**
138
181
  * Append-only audit sink (daemon wires the JSONL writer; tests inject a spy).
@@ -180,11 +223,28 @@ export declare class GateRegistry {
180
223
  disarm(workflowId: string, operatorTokenHint?: string): void;
181
224
  /** Whether the gate is currently armed for a player (the engagement predicate reads this). */
182
225
  isArmed(workflowId: string): boolean;
226
+ /**
227
+ * #712 — record the player's DURABLE `guardrailPolicy` so {@link open} can
228
+ * enforce its failMode authoritatively (frame claim ignored). Populated at
229
+ * spawn (beside the ingest-token mint), reconstructed at reconcile-on-boot, and
230
+ * lazily back-filled by the ingest route on a cache-miss. Cleared by
231
+ * {@link clearPlayer} (detach/destroy) like all per-player state.
232
+ */
233
+ setPolicy(workflowId: string, policy: GuardrailPolicy): void;
234
+ /** #712 — the recorded durable policy, or `undefined` if not yet resolved. */
235
+ getPolicy(workflowId: string): GuardrailPolicy | undefined;
183
236
  /**
184
237
  * Open (or return the existing) pending request for a gated tool call.
185
238
  * Idempotent on `requestId` — a retried open returns the existing entry so the
186
- * source can safely re-register before polling. Does NOT audit (the request
187
- * isn't a decision); the decision/auto-allow audit records carry tool+args.
239
+ * source can safely re-register before polling.
240
+ *
241
+ * #712 — DAEMON-AUTHORITATIVE failMode: the stored failMode is computed from the
242
+ * player's DURABLE policy ({@link enforcedFailMode}), NOT from `meta.failMode`
243
+ * (which is now an ADVISORY claim from the engaging agent). The route resolves +
244
+ * stores the policy (spawn / reconcile / lazy miss-resolve) before this call; an
245
+ * unresolved policy enforces `closed` (no-fail-open). When the frame claimed the
246
+ * weaker `open` but the policy enforces `closed`, a NEUTRAL-FACTUAL
247
+ * `failmode-override` audit record is emitted (the self-downgrade #712 closes).
188
248
  */
189
249
  open(workflowId: string, requestId: string, meta: GateRequestMeta): void;
190
250
  /**
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GateRegistry = exports.GATE_CLOSED_DENY_MS = exports.GATE_AUTO_ALLOW_MS = void 0;
4
+ exports.enforcedFailMode = enforcedFailMode;
4
5
  /**
5
6
  * MONITORED (fail-OPEN) timeout: a pending request auto-ALLOWS after this long
6
7
  * (R3, locked). The cheap-if-wrong fuse for an otherwise-autonomous agent the
@@ -21,6 +22,28 @@ exports.GATE_AUTO_ALLOW_MS = 45_000;
21
22
  * constant for closed.
22
23
  */
23
24
  exports.GATE_CLOSED_DENY_MS = 300_000;
25
+ /**
26
+ * #712 — the daemon-AUTHORITATIVE fail posture for a player, derived from its
27
+ * DURABLE `guardrailPolicy` (the source of truth on SessionMetadata). The
28
+ * `gate_pending` frame's `failMode` claim is ADVISORY only: the daemon enforces
29
+ * THIS regardless, so an engaging agent can't self-downgrade a supervised player
30
+ * out of fail-closed by stamping `'open'`.
31
+ *
32
+ * Posture — **fail-CLOSED except an EXPLICITLY `monitored`/`autonomous` player**:
33
+ * - `monitored` → `'open'` (operator-armed observability gate; fail-OPEN is its point)
34
+ * - `autonomous` → `'open'` (never engages the gate on its own; if an operator
35
+ * arms it, it behaves as monitored → fail-open)
36
+ * - `supervised` → `'closed'`
37
+ * - `observe-only` → `'closed'` (most-restrictive no-act posture; if the client
38
+ * no-act block is ever bypassed and it reaches the
39
+ * gate, auto-ALLOW would be backwards — defense-in-depth)
40
+ * - `undefined` (policy not yet resolved — e.g. post-daemon-restart pre-reconcile,
41
+ * or a bounded metadata query that timed out/errored) → `'closed'`
42
+ * (NO-FAIL-OPEN: uncertainty resolves to the safe posture).
43
+ */
44
+ function enforcedFailMode(policy) {
45
+ return policy === 'monitored' || policy === 'autonomous' ? 'open' : 'closed';
46
+ }
24
47
  /**
25
48
  * Per-daemon operator-gate registry, keyed by the player's fixed session
26
49
  * `workflowId`. One instance is constructed in the daemon and shared between the
@@ -77,12 +100,34 @@ class GateRegistry {
77
100
  isArmed(workflowId) {
78
101
  return this.gates.get(workflowId)?.armed ?? false;
79
102
  }
103
+ // ── #712 durable-policy source of truth (daemon-authoritative failMode) ──────
104
+ /**
105
+ * #712 — record the player's DURABLE `guardrailPolicy` so {@link open} can
106
+ * enforce its failMode authoritatively (frame claim ignored). Populated at
107
+ * spawn (beside the ingest-token mint), reconstructed at reconcile-on-boot, and
108
+ * lazily back-filled by the ingest route on a cache-miss. Cleared by
109
+ * {@link clearPlayer} (detach/destroy) like all per-player state.
110
+ */
111
+ setPolicy(workflowId, policy) {
112
+ this.gate(workflowId).policy = policy;
113
+ }
114
+ /** #712 — the recorded durable policy, or `undefined` if not yet resolved. */
115
+ getPolicy(workflowId) {
116
+ return this.gates.get(workflowId)?.policy;
117
+ }
80
118
  // ── Pending requests (source opens; operator decides; source polls) ─────────
81
119
  /**
82
120
  * Open (or return the existing) pending request for a gated tool call.
83
121
  * Idempotent on `requestId` — a retried open returns the existing entry so the
84
- * source can safely re-register before polling. Does NOT audit (the request
85
- * isn't a decision); the decision/auto-allow audit records carry tool+args.
122
+ * source can safely re-register before polling.
123
+ *
124
+ * #712 — DAEMON-AUTHORITATIVE failMode: the stored failMode is computed from the
125
+ * player's DURABLE policy ({@link enforcedFailMode}), NOT from `meta.failMode`
126
+ * (which is now an ADVISORY claim from the engaging agent). The route resolves +
127
+ * stores the policy (spawn / reconcile / lazy miss-resolve) before this call; an
128
+ * unresolved policy enforces `closed` (no-fail-open). When the frame claimed the
129
+ * weaker `open` but the policy enforces `closed`, a NEUTRAL-FACTUAL
130
+ * `failmode-override` audit record is emitted (the self-downgrade #712 closes).
86
131
  */
87
132
  open(workflowId, requestId, meta) {
88
133
  const g = this.gate(workflowId);
@@ -90,12 +135,28 @@ class GateRegistry {
90
135
  g.ensemble = meta.ensemble;
91
136
  if (g.pending.has(requestId))
92
137
  return;
138
+ const enforced = enforcedFailMode(g.policy);
139
+ const claimed = meta.failMode ?? 'open';
140
+ if (enforced === 'closed' && claimed === 'open') {
141
+ this.audit({
142
+ kind: 'failmode-override',
143
+ ts: this.nowIso(),
144
+ workflowId,
145
+ ...(meta.sessionId ? { sessionId: meta.sessionId } : {}),
146
+ requestId,
147
+ tool: meta.tool,
148
+ argsSummary: meta.argsSummary,
149
+ claimedFailMode: claimed,
150
+ enforcedFailMode: enforced,
151
+ policy: g.policy ?? 'unknown',
152
+ }, g.ensemble);
153
+ }
93
154
  g.pending.set(requestId, {
94
155
  tool: meta.tool,
95
156
  argsSummary: meta.argsSummary,
96
157
  sessionId: meta.sessionId,
97
158
  createdAt: this.now(),
98
- failMode: meta.failMode ?? 'open',
159
+ failMode: enforced,
99
160
  decision: null,
100
161
  source: null,
101
162
  });
@@ -24,6 +24,7 @@ import type { IncomingMessage, ServerResponse } from 'http';
24
24
  import type { InnerLoopRegistry } from './inner-loop';
25
25
  import type { IngestTokenRegistry } from './ingest-registry';
26
26
  import type { GateRegistry } from './gate-registry';
27
+ import type { GuardrailPolicy } from '../types';
27
28
  /** Header carrying the per-player ingest token (the source-plane credential). */
28
29
  export declare const INGEST_TOKEN_HEADER = "x-ingest-token";
29
30
  export interface InnerLoopDeps {
@@ -40,6 +41,17 @@ export interface InnerLoopDeps {
40
41
  * `gate_resolved` emission flows back via its injected publishToInner.
41
42
  */
42
43
  gate?: GateRegistry;
44
+ /**
45
+ * #712 — BOUNDED resolver for a player's DURABLE `guardrailPolicy` (daemon
46
+ * wires it to a `getMetadataQuery` behind `utils/query-timeout`). Used by the
47
+ * ingest gate_pending path ONLY on a {@link GateRegistry.getPolicy} cache-miss
48
+ * (the common path is spawn/reconcile-populated → sync, no query). Returns the
49
+ * resolved policy (absent metadata field ⇒ `'autonomous'`), or `undefined` when
50
+ * the query TIMES OUT / ERRORS / the workflow is gone — the caller then leaves
51
+ * the gate policy unresolved so `open()` enforces `closed` (NO-FAIL-OPEN). The
52
+ * resolver MUST be bounded so it can never hang gate engagement.
53
+ */
54
+ resolveGuardrailPolicy?: (workflowId: string) => Promise<GuardrailPolicy | undefined>;
43
55
  }
44
56
  /** True when the request originates from the same host (loopback). */
45
57
  export declare function isLoopbackRemote(req: IncomingMessage): boolean;
@@ -86,12 +86,24 @@ async function handleInnerIngest(req, res, deps, ensemble, playerId) {
86
86
  if (type === 'inner.gate_pending' && deps.gate) {
87
87
  const f = body;
88
88
  if (typeof f.requestId === 'string' && typeof f.tool === 'string') {
89
+ // #712 — ensure the gate knows the player's DURABLE guardrailPolicy BEFORE
90
+ // registering, so open() enforces failMode from policy (daemon-authoritative),
91
+ // not the frame's advisory claim. Fast path: already populated at spawn /
92
+ // reconcile (sync, no query). Miss (post-restart pre-reconcile): a BOUNDED
93
+ // getMetadataQuery; on success populate the gate. On timeout/error/gone the
94
+ // resolver returns undefined → we leave it unresolved → open() enforces
95
+ // 'closed' (NO-FAIL-OPEN).
96
+ if (deps.resolveGuardrailPolicy && deps.gate.getPolicy(workflowId) === undefined) {
97
+ const resolved = await deps.resolveGuardrailPolicy(workflowId);
98
+ if (resolved !== undefined)
99
+ deps.gate.setPolicy(workflowId, resolved);
100
+ }
89
101
  deps.gate.open(workflowId, f.requestId, {
90
102
  tool: f.tool,
91
103
  argsSummary: typeof f.argsSummary === 'string' ? f.argsSummary : '',
92
104
  ensemble,
93
- // #700 / G per-request fail posture from the agent's guardrailPolicy.
94
- // Only 'closed' is meaningful; anything else (incl. absent) 'open'.
105
+ // #712 ADVISORY claim only (open() enforces from policy); retained so a
106
+ // self-downgrade (frame 'open' vs policy-enforced 'closed') is auditable.
95
107
  failMode: f.failMode === 'closed' ? 'closed' : 'open',
96
108
  });
97
109
  }
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import * as http from 'http';
19
19
  import type { TempoClient } from '../client/interface';
20
+ import type { GuardrailPolicy } from '../types';
20
21
  import type { AggregateRunner } from './aggregate';
21
22
  import type { InnerLoopRegistry } from './inner-loop';
22
23
  import type { IngestTokenRegistry } from './ingest-registry';
@@ -106,6 +107,14 @@ export interface HttpServerOptions {
106
107
  * detach/destroy) — same singleton pattern as the inner-loop registries.
107
108
  */
108
109
  gate?: GateRegistry;
110
+ /**
111
+ * #712 — bounded resolver for a player's durable `guardrailPolicy`, used by the
112
+ * ingest gate_pending path on a {@link GateRegistry.getPolicy} cache-miss to
113
+ * keep the failMode cross-check daemon-authoritative. The daemon wires it to a
114
+ * `getMetadataQuery` behind `utils/query-timeout`; absent → the route skips the
115
+ * miss-resolve (an unresolved policy then enforces `closed`, no-fail-open).
116
+ */
117
+ resolveGuardrailPolicy?: (workflowId: string) => Promise<GuardrailPolicy | undefined>;
109
118
  }
110
119
  export interface HttpServerHandle {
111
120
  /** The actual port the server is listening on (after `.listen()` resolves). */
@@ -148,6 +157,8 @@ interface HandleContext {
148
157
  ingestTokens: IngestTokenRegistry | null;
149
158
  /** 3d MD-G operator-gate registry — null when unwired. */
150
159
  gate: GateRegistry | null;
160
+ /** #712 — bounded durable-policy resolver for the failMode cross-check — null when unwired. */
161
+ resolveGuardrailPolicy: ((workflowId: string) => Promise<GuardrailPolicy | undefined>) | null;
151
162
  }
152
163
  /**
153
164
  * Top-level request dispatcher — exported for unit tests that want to
@@ -65,6 +65,7 @@ const fixtures_1 = require("./fixtures");
65
65
  const writes_1 = require("./writes");
66
66
  const catalog_1 = require("./catalog");
67
67
  const qa_1 = require("./qa");
68
+ const coat_check_1 = require("./coat-check");
68
69
  const port_file_1 = require("./port-file");
69
70
  const responses_1 = require("./responses");
70
71
  const snapshot_1 = require("./snapshot");
@@ -170,6 +171,7 @@ async function startHttpServer(opts) {
170
171
  innerLoop: opts.innerLoop ?? null,
171
172
  ingestTokens: opts.ingestTokens ?? null,
172
173
  gate: opts.gate ?? null,
174
+ resolveGuardrailPolicy: opts.resolveGuardrailPolicy ?? null,
173
175
  }).catch((err) => {
174
176
  log('unhandled handler error:', err instanceof Error ? err.message : err);
175
177
  if (!res.headersSent) {
@@ -282,7 +284,7 @@ async function handle(req, res, ctx) {
282
284
  // reaches them regardless of the daemon's bind address. Only live when the
283
285
  // daemon wired the registries; else they fall through to the 404/405 path.
284
286
  if (ctx.innerLoop && ctx.ingestTokens) {
285
- const innerDeps = { innerLoop: ctx.innerLoop, ingestTokens: ctx.ingestTokens, ...(ctx.gate ? { gate: ctx.gate } : {}) };
287
+ const innerDeps = { innerLoop: ctx.innerLoop, ingestTokens: ctx.ingestTokens, ...(ctx.gate ? { gate: ctx.gate } : {}), ...(ctx.resolveGuardrailPolicy ? { resolveGuardrailPolicy: ctx.resolveGuardrailPolicy } : {}) };
286
288
  const ingestMatch = pathname.match(/^\/v1\/players\/([^/]+)\/([^/]+)\/inner\/ingest$/);
287
289
  if (ingestMatch) {
288
290
  if (method !== 'POST') {
@@ -405,6 +407,31 @@ async function handle(req, res, ctx) {
405
407
  }
406
408
  return (0, qa_1.handleAnswer)(res, ctx.client, ensemble, questionId);
407
409
  }
410
+ // #713 — coat-check HTTP routes. The POST (2-segment) MUST be matched BEFORE
411
+ // the generic writeMatch below, which would otherwise 404 `coat-check` as an
412
+ // unknown write action. Both are Tier 2 (admin): `put` is a write; `get`
413
+ // REDEEMS via a maestro Update that bumps fetch-audit counters (not a pure read).
414
+ const coatCheckPutMatch = pathname.match(/^\/v1\/ensembles\/([^/]+)\/coat-check$/);
415
+ if (coatCheckPutMatch) {
416
+ const ensemble = decodeURIComponent(coatCheckPutMatch[1]);
417
+ if (!gateTier(2))
418
+ return; // write
419
+ if (method !== 'POST') {
420
+ return (0, responses_1.errorResponse)(res, 405, { error: 'method-not-allowed' }, { Allow: 'POST, OPTIONS' });
421
+ }
422
+ return (0, coat_check_1.handleCoatCheckPut)(req, res, ctx.client, ensemble);
423
+ }
424
+ const coatCheckGetMatch = pathname.match(/^\/v1\/ensembles\/([^/]+)\/coat-check\/([^/]+)$/);
425
+ if (coatCheckGetMatch) {
426
+ const ensemble = decodeURIComponent(coatCheckGetMatch[1]);
427
+ const ticket = decodeURIComponent(coatCheckGetMatch[2]);
428
+ if (!gateTier(2))
429
+ return; // redeem mutates fetch-audit counters → Tier 2
430
+ if (method !== 'GET') {
431
+ return (0, responses_1.errorResponse)(res, 405, { error: 'method-not-allowed' }, { Allow: 'GET, OPTIONS' });
432
+ }
433
+ return (0, coat_check_1.handleCoatCheckGet)(res, ctx.client, ensemble, ticket);
434
+ }
408
435
  // Write surface (PR-7a of #340) — POST `/v1/ensembles/:ensemble/<action>`
409
436
  // Match BEFORE the GET-only method gate; everything else (POST to a
410
437
  // read endpoint, GET to a write endpoint) flows into the 405 fallback
@@ -36,6 +36,9 @@ export declare class MissionControlActions {
36
36
  get ready(): boolean;
37
37
  private baseUrl;
38
38
  private post;
39
+ /** POST and parse a JSON response body (bearer-authed). Used when the caller
40
+ * needs the response payload, not just success — e.g. the coat-check ticket. */
41
+ private postJson;
39
42
  /** GET a JSON body from the daemon (bearer-authed). Used by the read surface (#700 readAnswer). */
40
43
  private getJson;
41
44
  private ens;
@@ -90,6 +93,25 @@ export declare class MissionControlActions {
90
93
  * transport error) — the caller polls or waits for the SSE `answer` wake.
91
94
  */
92
95
  readAnswer(questionId: string): Promise<AnswerEntry | null>;
96
+ /**
97
+ * Stash a content body on the ensemble coat-check (`POST /v1/ensembles/:e/coat-check`)
98
+ * and return the ticket. Lets the inbox-less planner park a large plan and hand
99
+ * off a ticket instead of inlining it on a cue. NOTE (#713): the coat-check entry
100
+ * cap (32 KiB) is BELOW the 100 KB cue cap, so this keeps cues lean — it does NOT
101
+ * raise the handoff ceiling.
102
+ */
103
+ coatCheckPut(opts: {
104
+ summary: string;
105
+ content: string;
106
+ contentType?: string;
107
+ ttlMs?: number;
108
+ }): Promise<{
109
+ ok: true;
110
+ ticket: string;
111
+ } | {
112
+ ok: false;
113
+ error: string;
114
+ }>;
93
115
  gateArm(playerId: string): Promise<ActionResult>;
94
116
  gateDisarm(playerId: string): Promise<ActionResult>;
95
117
  gateDecide(playerId: string, requestId: string, decision: 'allow' | 'deny'): Promise<ActionResult>;
@@ -62,6 +62,31 @@ class MissionControlActions {
62
62
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
63
63
  }
64
64
  }
65
+ /** POST and parse a JSON response body (bearer-authed). Used when the caller
66
+ * needs the response payload, not just success — e.g. the coat-check ticket. */
67
+ async postJson(pathSuffix, body) {
68
+ if (!this.adminToken)
69
+ return { ok: false, error: `no admin token (set ${exports.ADMIN_TOKEN_ENV})` };
70
+ if (!this.fetchFn)
71
+ return { ok: false, error: 'no fetch transport available' };
72
+ const base = this.baseUrl();
73
+ if (base === null)
74
+ return { ok: false, error: 'daemon HTTP not reachable (no port)' };
75
+ try {
76
+ const res = await this.fetchFn(`${base}${pathSuffix}`, {
77
+ method: 'POST',
78
+ headers: { Authorization: `Bearer ${this.adminToken}`, 'Content-Type': 'application/json' },
79
+ body: JSON.stringify(body ?? {}),
80
+ });
81
+ const text = await res.text().catch(() => '');
82
+ if (res.status < 200 || res.status >= 300)
83
+ return { ok: false, error: `HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}` };
84
+ return { ok: true, data: JSON.parse(text) };
85
+ }
86
+ catch (err) {
87
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
88
+ }
89
+ }
65
90
  /** GET a JSON body from the daemon (bearer-authed). Used by the read surface (#700 readAnswer). */
66
91
  async getJson(pathSuffix) {
67
92
  if (!this.adminToken)
@@ -151,6 +176,18 @@ class MissionControlActions {
151
176
  const res = await this.getJson(`/v1/ensembles/${this.ens()}/answer/${encodeURIComponent(questionId)}`);
152
177
  return res.ok ? (res.data.answer ?? null) : null;
153
178
  }
179
+ // ── Coat-check surface (#713) ──
180
+ /**
181
+ * Stash a content body on the ensemble coat-check (`POST /v1/ensembles/:e/coat-check`)
182
+ * and return the ticket. Lets the inbox-less planner park a large plan and hand
183
+ * off a ticket instead of inlining it on a cue. NOTE (#713): the coat-check entry
184
+ * cap (32 KiB) is BELOW the 100 KB cue cap, so this keeps cues lean — it does NOT
185
+ * raise the handoff ceiling.
186
+ */
187
+ async coatCheckPut(opts) {
188
+ const res = await this.postJson(`/v1/ensembles/${this.ens()}/coat-check`, opts);
189
+ return res.ok ? { ok: true, ticket: res.data.ticket } : res;
190
+ }
154
191
  // ── Operator gate plane (T3) ──
155
192
  gateArm(playerId) {
156
193
  return this.post(`/v1/players/${this.player(playerId)}/gate-arm`, {});
@@ -1,5 +1,5 @@
1
1
  import { type BoardModel } from './board';
2
- import { MissionControlActions } from './actions';
2
+ import { MissionControlActions, type ActionResult } from './actions';
3
3
  import { type InfraProgress } from '../../cli/ensure-infra';
4
4
  import type { McExtensionAPI, McExtensionContext, McOutboundMessage, McMessageOptions } from './pi-ui';
5
5
  /**
@@ -93,6 +93,18 @@ export declare class Controller {
93
93
  /** Bounded poll of the answer mailbox (human `/ask` path). Resolves null on timeout. */
94
94
  private pollAnswer;
95
95
  cmdHandoff(args: string, ctx: McExtensionContext): Promise<void>;
96
+ /**
97
+ * #713 — hand a plan to `target`. Plans in the stash band `(8 KiB, 32 KiB]`
98
+ * are parked on the coat-check (`coatCheckPut`) and the cue carries only a
99
+ * short summary + the redeem instruction, keeping the cue body lean. Smaller
100
+ * plans cue inline; larger ones (> 32 KiB) ALSO cue inline up to the 100 KB cue
101
+ * cap — the coat-check entry cap is below the cue cap, so it can't hold them.
102
+ *
103
+ * Public so both the `/handoff` slash command and the planner `handoff` tool
104
+ * share one path. If a stash fails (saturation / transport), falls back to an
105
+ * inline cue so the handoff still lands (a ≤ 32 KiB plan always fits a cue).
106
+ */
107
+ handoffPlan(target: string, plan: string): Promise<ActionResult>;
96
108
  }
97
109
  /**
98
110
  * #700 P2 — register the planner LLM tools (the "think with tools" half) on an
@@ -66,8 +66,22 @@ const actions_1 = require("./actions");
66
66
  const inner_tail_1 = require("./inner-tail");
67
67
  const ensure_infra_1 = require("../../cli/ensure-infra");
68
68
  const zod_to_typebox_1 = require("../zod-to-typebox");
69
+ const validation_1 = require("../../utils/validation");
69
70
  /** The durable conductor a `/handoff` targets by default (matches catalog's conductorName default). */
70
71
  const DEFAULT_CONDUCTOR = 'conductor';
72
+ /**
73
+ * #713 — handoff stash band (UTF-8 bytes). A plan in `(MIN, MAX]` stashes to the
74
+ * coat-check so the cue body stays lean; below MIN an inline cue is fine; above
75
+ * MAX (the coat-check entry cap) we stay inline up to the 100 KB cue cap.
76
+ *
77
+ * HONEST FRAMING (#713): MAX === the coat-check entry cap (32 KiB), which is
78
+ * BELOW the 100 KB cue cap. Coat-check stashing keeps cues lean and lets the
79
+ * inbox-less planner park artifacts — it does NOT raise the handoff ceiling. A
80
+ * plan over 100 KB still cannot be handed off; that would need a coat-check cap
81
+ * increase (a separate decision with Temporal continue-as-new state implications).
82
+ */
83
+ const HANDOFF_STASH_MIN_BYTES = 8 * 1024;
84
+ const HANDOFF_STASH_MAX_BYTES = validation_1.COAT_CHECK_CONTENT_MAX;
71
85
  /** Bounded human-`/ask` poll: total wait + interval. The LLM `ask` tool yields instead (SSE wake). */
72
86
  const ASK_POLL_TIMEOUT_MS = 30_000;
73
87
  const ASK_POLL_INTERVAL_MS = 1_000;
@@ -370,9 +384,35 @@ class Controller {
370
384
  this.notify(ctx, 'Usage: /handoff <plan> — pushes the plan to the durable conductor.');
371
385
  return;
372
386
  }
373
- // Inline cue (a §5.4 brief is small markdown). Large-plan-via-coat-check is a
374
- // P2.1 follow-up (coat_check_put has no HTTP route yet).
375
- this.report(ctx, `handoff → ${DEFAULT_CONDUCTOR}`, await this.actions.cue(DEFAULT_CONDUCTOR, `[PLAN HANDOFF]\n${plan}`));
387
+ this.report(ctx, `handoff ${DEFAULT_CONDUCTOR}`, await this.handoffPlan(DEFAULT_CONDUCTOR, plan));
388
+ }
389
+ /**
390
+ * #713 — hand a plan to `target`. Plans in the stash band `(8 KiB, 32 KiB]`
391
+ * are parked on the coat-check (`coatCheckPut`) and the cue carries only a
392
+ * short summary + the redeem instruction, keeping the cue body lean. Smaller
393
+ * plans cue inline; larger ones (> 32 KiB) ALSO cue inline up to the 100 KB cue
394
+ * cap — the coat-check entry cap is below the cue cap, so it can't hold them.
395
+ *
396
+ * Public so both the `/handoff` slash command and the planner `handoff` tool
397
+ * share one path. If a stash fails (saturation / transport), falls back to an
398
+ * inline cue so the handoff still lands (a ≤ 32 KiB plan always fits a cue).
399
+ */
400
+ async handoffPlan(target, plan) {
401
+ const bytes = Buffer.byteLength(plan, 'utf8');
402
+ if (bytes > HANDOFF_STASH_MIN_BYTES && bytes <= HANDOFF_STASH_MAX_BYTES) {
403
+ const preview = plan.replace(/\s+/g, ' ').trim().slice(0, 160);
404
+ const stash = await this.actions.coatCheckPut({
405
+ summary: `[PLAN HANDOFF] ${preview}`,
406
+ content: plan,
407
+ contentType: 'text/markdown',
408
+ });
409
+ if (stash.ok) {
410
+ return this.actions.cue(target, `[PLAN HANDOFF] Full plan stashed on the coat-check (${bytes} bytes) — redeem with the ` +
411
+ `\`coat_check_get\` tool:\ncoat_check_get({ ticket: "${stash.ticket}" })`);
412
+ }
413
+ // Stash failed — fall through to inline (the plan fits the 100 KB cue cap).
414
+ }
415
+ return this.actions.cue(target, `[PLAN HANDOFF]\n${plan}`);
376
416
  }
377
417
  }
378
418
  exports.Controller = Controller;
@@ -413,7 +453,9 @@ function registerPlannerTools(pi, ctrl) {
413
453
  execute: async (_id, params) => {
414
454
  const { plan, to } = params;
415
455
  const target = to ?? DEFAULT_CONDUCTOR;
416
- const r = await ctrl.actions.cue(target, `[PLAN HANDOFF]\n${plan}`);
456
+ // #713 large plans stash to the coat-check (cue carries the ticket);
457
+ // small plans inline. Shared with the `/handoff` slash command.
458
+ const r = await ctrl.handoffPlan(target, plan);
417
459
  if (!r.ok)
418
460
  throw new Error(`handoff failed: ${r.error}`);
419
461
  return ok(`Plan handed off to ${target}.`);
package/dist/tui/index.js CHANGED
@@ -141,6 +141,8 @@ function createDummyClient() {
141
141
  isMaestroPaused: async () => false,
142
142
  isAnySessionHeld: async () => false,
143
143
  getAnswer: async () => null,
144
+ coatCheckPut: fail,
145
+ coatCheckGet: async () => null,
144
146
  getGates: async () => [],
145
147
  getStages: async () => [],
146
148
  getWorktrees: async () => [],
package/dist/types.d.ts CHANGED
@@ -238,16 +238,24 @@ export interface SessionMetadata {
238
238
  * the real posture on EVERY attach (across restart / migrate / re-attach), so
239
239
  * a previously-`supervised` agent stays supervised. (tempo-architect ruling.)
240
240
  *
241
- * **★ P2 scope — CLIENT-COOPERATIVE, not tamper-proof (conductor ruling).**
242
- * The agent stamps the per-request `failMode` from THIS policy in its own
243
- * extension (`src/pi/extension.ts`), so `supervised` is only as strong as the
244
- * agent HONORING its own policy exactly MD-C tool-access parity (also
245
- * client-enforced). It is NOT a hard security boundary against a compromised /
246
- * prompt-injected agent that stamps `'open'` to self-downgrade. The tamper-
247
- * proof form (the daemon cross-checks the request's `failMode` against this
248
- * durable policy and FORCES `closed` for a supervised player) is **P2.1**, and
249
- * is additive it reads `failMode` off the same per-request seam. Do not
250
- * describe `supervised` as a hard guarantee until daemon-side enforcement lands.
241
+ * **★ Enforcement scope (#712 claims EXACTLY this, no more).** The daemon
242
+ * enforces fail-closed for a `supervised` player's gate ENGAGEMENTS: the gate's
243
+ * `open()` cross-check derives the `failMode` from THIS durable policy (read by
244
+ * the daemon, populated at spawn + lazily resolved on a cache-miss), so the
245
+ * `failMode` an engaging agent stamps on its `gate_pending` frame can't
246
+ * self-downgrade a supervised player out of fail-closed (a `'open'` claim against
247
+ * a `supervised` policy is overridden to `closed` and a `failmode-override`
248
+ * audit record is written). This is **NOT a hard boundary** against an agent
249
+ * that SKIPS gate-engagement entirely (runs the tool without emitting a
250
+ * `gate_pending` frame) or bypasses the client-side MD-C exec-block — a true
251
+ * boundary requires spawn-time tool-restriction (the agent physically lacks the
252
+ * tools), tracked separately in **#715**.
253
+ *
254
+ * **Post-restart window:** on daemon restart the in-memory ingest tokens are
255
+ * invalidated, so existing players' gate engagements are rejected (403) until a
256
+ * re-spawn re-mints. In that window a `supervised` player's gate-client
257
+ * fail-closes on its own derived deadline (client-side safety holds), but the
258
+ * gate is NOT daemon-mediated — the #715 client-cooperative residual.
251
259
  */
252
260
  guardrailPolicy?: GuardrailPolicy;
253
261
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-tempo",
3
- "version": "1.7.0-beta.2",
3
+ "version": "1.7.0-beta.3",
4
4
  "description": "Many agents, one tempo. Durable coordination for multi-agent work via Temporal.",
5
5
  "keywords": [
6
6
  "mcp",