Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
d111802
docs: add versioned agent-client capability matrix and policy controls
roodboi Oct 12, 2026
58c485a
fix(mcp): harden run argv and tool input contract
roodboi Oct 12, 2026
c6c0db3
fix(env): harden legacy env key files and AES-GCM envelope parsing
roodboi Oct 12, 2026
de862d7
Merge remote-tracking branch 'origin/next' into w14-rework
roodboi Oct 12, 2026
dc88c6b
Merge remote-tracking branch 'origin/next' into w06-rework
roodboi Oct 12, 2026
ffeecce
fix(agents): generate capability matrix doc and parse all guidance co…
roodboi Oct 12, 2026
56bac3a
fix(mcp): make typed refusals value-free and keep unknown-tool in-band
roodboi Oct 12, 2026
12e80af
chore: merge origin/next into wf/w15-w5-6
roodboi Oct 12, 2026
cd76b26
test(env): pin AES-GCM IV, size and tag-length guards with discrimina…
roodboi Oct 12, 2026
bcf88ec
fix(init): stop writing repo-local agent files by default
roodboi Oct 12, 2026
c005262
fix(env): resolve --env base and default the same way on every command
roodboi Oct 12, 2026
6136946
fix(agents): count corrupted and unreadable integrations as drift
roodboi Oct 12, 2026
9854d1b
test(env): pin the console logger backend for CLI refusal tests
roodboi Oct 12, 2026
6e1ff63
test(mcp): assert closed ambiguous_project refusal in project-name suite
roodboi Oct 12, 2026
1f8b51e
chore(x01): temporary witness cost trace for hosted measurement
roodboi Oct 12, 2026
0911e6d
perf(storage): coalesce witness helper work per native command
roodboi Oct 12, 2026
eb9a678
chore(x01): revert temporary witness cost trace
roodboi Oct 12, 2026
7b2ccfc
test(e2e): restore the networks scenario budget after witness coalescing
roodboi Oct 12, 2026
fa7741c
Merge remote-tracking branch 'origin/next' into wf/x03-witness-probes
roodboi Oct 12, 2026
37f093a
perf(storage): make witness engine and executable fences cheaper
roodboi Oct 12, 2026
6a4e118
chore(x03): temporary witness probe trace for hosted measurement
roodboi Oct 12, 2026
2b11ed3
fix(storage): keep closed probe cancellation for direct engine-ID fences
roodboi Oct 12, 2026
a777bc4
test(storage): pin witness fence counts and refusal controls
roodboi Oct 12, 2026
994ddb8
chore(x03): revert temporary witness probe trace
roodboi Oct 12, 2026
3edbd54
feat(agents): package offline skill references and the HACK-P pattern…
roodboi Oct 12, 2026
726cc27
test(e2e): add agent-surface-freeze negative control for guidance writes
roodboi Oct 12, 2026
9b971fa
ci: add gated Bun crash experiment and read-only crash census
roodboi Oct 12, 2026
1342871
perf(storage): read engine identity directly for docker context selec…
roodboi Oct 12, 2026
c2ecf9c
chore(x04): temporary witness probe trace for hosted measurement
roodboi Oct 12, 2026
5cf9f0f
chore(x04): revert temporary witness probe trace
roodboi Oct 12, 2026
ebe0539
test(agents): cover reference generation and reference-style and HTML…
roodboi Oct 12, 2026
6b54980
test: keep the Bun crash loop tests free of real crash reports
roodboi Oct 12, 2026
7a05b84
Merge branch 'next' into wf/w15-w5-6
roodboi Oct 12, 2026
c00468c
Merge branch 'next' into wf/w06-w5-3
roodboi Oct 12, 2026
e9b1252
Merge branch 'next' into wf/w13-w5b-2
roodboi Oct 12, 2026
af786b5
Merge branch 'next' into wf/w10-w5b-0
roodboi Oct 12, 2026
9db4dba
Merge branch 'next' into wf/w14-w5-5
roodboi Oct 12, 2026
5a731cc
Merge remote-tracking branch 'origin/wf/w06-w5-3' into train/queue-pr…
roodboi Oct 12, 2026
38d9d7f
Merge remote-tracking branch 'origin/wf/w14-w5-5' into train/queue-pr…
roodboi Oct 12, 2026
d50e935
Merge remote-tracking branch 'origin/wf/w15-w5-6' into train/queue-pr…
roodboi Oct 12, 2026
8bab674
Merge remote-tracking branch 'origin/wf/w10-w5b-0' into train/queue-p…
roodboi Oct 12, 2026
26958c9
Merge remote-tracking branch 'origin/wf/w13-w5b-2' into train/queue-p…
roodboi Oct 12, 2026
07a1834
Merge remote-tracking branch 'origin/wf/w07-w6-3' into train/queue-pr…
roodboi Oct 12, 2026
a92e3ab
Merge remote-tracking branch 'origin/wf/w02-w6-1' into train/queue-pr…
roodboi Oct 12, 2026
3e261d5
Merge remote-tracking branch 'origin/wf/w05-w6-2' into train/queue-pr…
roodboi Oct 12, 2026
6b22e00
test(patterns): add validated HACK-P01 and HACK-P02 example fixtures
roodboi Oct 12, 2026
b07121f
feat(exec): warn value-free when a running service's env changed sinc…
roodboi Oct 12, 2026
9f20366
fix(env): write .hack/.env and the encrypted store atomically with 0600
roodboi Oct 12, 2026
42e7b3b
fix(mcp): separate log cutoffs from backend failures
roodboi Oct 12, 2026
e2c41a9
fix(mcp): align budget docs with log cutoffs and keep byte-cut failures
roodboi Oct 12, 2026
10b6d73
fix(agents): point the env matrix bullet at the offline env guide
roodboi Oct 12, 2026
77da332
Merge remote-tracking branch 'origin/wf/w13-w5b-2' into train/queue-p…
roodboi Oct 12, 2026
4e3acee
test(exec): cover failed targeted restart env digest handling
roodboi Oct 12, 2026
cb665d5
fix(env): scope the env-file symlink refusal doc to write paths
roodboi Oct 12, 2026
c2b41ff
chore: merge train/queue-preview into wf/y3-envfile-b6
roodboi Oct 12, 2026
d9a5627
chore: merge train/queue-preview into wf/y1-logs-b6
roodboi Oct 12, 2026
09b4e83
Merge remote-tracking branch 'origin/train/queue-preview' into wf/y4-…
roodboi Oct 12, 2026
5afaf19
Merge remote-tracking branch 'origin/train/queue-preview' into wf/y5-…
roodboi Oct 12, 2026
36cd272
test(e2e): keep Hack diagnostics out of exec-stale-env probe output
roodboi Oct 12, 2026
919de8b
test(e2e): reproduce dependency config-hash drift for hack run/exec
roodboi Oct 12, 2026
5078855
feat(doctor): add value-free env key strength diagnostic
roodboi Oct 12, 2026
5f8cb79
fix(logger): pass messages to gum after an end-of-options marker
roodboi Oct 12, 2026
0a26c64
Merge branch 'next' into wf/c5-confighash-b7
roodboi Oct 12, 2026
07413d0
Merge branch 'next' into wf/c4-keydiag-b7
roodboi Oct 12, 2026
6a043c0
Merge branch 'next' into wf/c6-gum-dash-b7
roodboi Oct 12, 2026
13165f9
Merge branch 'next' into wf/y4-patterns-b6
roodboi Oct 12, 2026
92c2ee1
Merge branch 'next' into wf/y5-staleenv-b6
roodboi Oct 12, 2026
4243d60
Merge branch 'next' into wf/y3-envfile-b6
roodboi Oct 12, 2026
c044445
Merge branch 'next' into wf/y1-logs-b6
roodboi Oct 12, 2026
989fcea
Merge branch 'wf/c6-gum-dash-b7' into wf/y3-envfile-b6
roodboi Oct 12, 2026
c6055dd
Merge branch 'wf/y3-envfile-b6' into wf/c4-keydiag-b7
roodboi Oct 12, 2026
6a754df
Merge branch 'wf/c4-keydiag-b7' into wf/y4-patterns-b6
roodboi Oct 12, 2026
ef04f02
Merge branch 'wf/y4-patterns-b6' into wf/c5-confighash-b7
roodboi Oct 12, 2026
b0cde14
Merge branch 'wf/c5-confighash-b7' into wf/y5-staleenv-b6
roodboi Oct 12, 2026
5a258e6
Merge branch 'wf/y5-staleenv-b6' into wf/y1-logs-b6
roodboi Oct 12, 2026
1086db9
Merge branch 'next' into wf/y1-logs-b6
roodboi Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions docs/guides/agent-first-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,15 @@ completed operations or delete persistent project data. These controls do not re
the number of servers for clients that remain connected.

MCP command results capture at most 8 MiB of combined stdout/stderr bytes before
text decoding. This also bounds log tails with oversized or newline-free messages.
Exceeding the budget stops the command and returns an error with
`outputTruncated: true`; parsed `data` is omitted so a prefix cannot masquerade as
a complete result. Run the equivalent CLI command when full output is needed.
The budget bounds captured bytes, not total process memory or JSON response size.
text decoding. For tools other than the log tools, exceeding the budget stops the
command and returns an error with `outputTruncated: true`; parsed `data` is omitted
so a prefix cannot masquerade as a complete result. The log tools
(`hack.project.logs.tail` and `hack.project.logs.snapshot`) use the same budget,
but reaching it is a cutoff: the result is `ok: true` with `outputTruncated: true`,
`data.cutoff` set to the `bytes` bound, and the events parsed before the cut. See
[MCP logs contract](../reference/mcp-logs.md). Run the equivalent CLI command when
full output is needed. The budget bounds captured bytes, not total process memory
or JSON response size.

Each MCP session captures its launch working directory and environment. Project
registry selection, command execution and audit-file locations use that snapshot;
Expand Down
1 change: 1 addition & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@
- [Extensions](./extensions.md)
- [Creating an extension](./guides/create-extension.md)
- [Integrations](./integrations.md)
- [MCP logs contract](./reference/mcp-logs.md) — cutoff versus backend failure for the MCP log tools, and the cursor/handle decision
102 changes: 102 additions & 0 deletions docs/reference/mcp-logs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# MCP logs contract

This page covers the `hack.project.logs.tail` and `hack.project.logs.snapshot`
MCP tools. Both tools run `hack logs --json` once per call and return the stream
events they collected. When a shell is available, use `hack logs` directly.

## Outcomes

Each call ends in exactly one outcome.

| Outcome | `ok` | `isError` | `data.cutoff` | `error` |
| --- | --- | --- | --- | --- |
| Complete: the backend ended the stream and exited 0 | `true` | absent | `null` | absent |
| Cutoff: the server stopped at one of its bounds | `true` | absent | set | absent |
| Backend failure | `false` | `true` | `null` | `backend_error` |

A cutoff is a success. The collected events are valid. The stream simply
continued past the bound. `data.cutoff` is `{ "bound": ..., "limit": ... }`:

- `events`: `maxEvents` log events were collected (tail only). Only
`type: "log"` events count. `start`, `heartbeat`, `error` and `end` events
are returned in `data.events` but do not use up the limit.
- `time`: the collection window ended. For tail this is `maxMs`. For snapshot
it is the fixed 120 second command timeout.
- `bytes`: stdout plus stderr reached the 8 MiB capture limit.
`outputTruncated` is `true`, and `data` still carries every event parsed
before the cut. The cut never splits a UTF-8 code point. A trailing partial
line can be present in `stdout`, but it is never parsed as an event.

After a cutoff, the server stopped the child process, so `exitCode` is often
non-zero. Use `ok` and `data.cutoff` to read the result, not `exitCode`.
`data.stop_reason` (tail only) keeps its earlier values: `eof`, `max_events`,
`timeout`, `output_limit`, `cancelled` or `interrupted`.

A backend failure is not a truncated success. It is reported when the stream
contains an `error` event or an `end` event with a reason other than `eof` or
`closed`, or when the child exits non-zero at the end of the stream. The error
is closed: `code` is `E_UNEXPECTED`, `reason` is `backend_error`, and `message`
is fixed text that never repeats backend output, caller input or the query.
Raw `stdout` and `stderr` are still returned, as they are for other tools.

Failure evidence counts only before the server stops the child. A child that
writes `error` or `end` events, or exits non-zero, in response to the server's
own stop signal still produces a cutoff. The byte bound is an exception for
events: every retained byte was written before the child was signalled, and
nothing after the cut is parsed, so an `error` or failing `end` event in the
returned prefix is still a backend failure. For the `events` and `time` bounds,
an `error` event that the backend wrote just before the stop but that the server
read after it is reported as a cutoff. The server cannot tell it apart from an
event written in response to the stop.

## Reconnects and repeated calls

The server keeps no log state between calls. Every call starts a new backend
stream:

- A repeated call does not replay events from an earlier call, and it does not
continue after the earlier call's last event.
- A cancelled request, or a session that closes mid-call, stops the backend
process group. The interrupted call's events are not delivered later, to this
session or to a reconnected one.

To continue a stream approximately, call again with `since` set to the last
event's `ts`. `since` selects the Loki backend and cannot be combined with
`compose`. Expect overlap at the boundary and remove duplicates on the client.

## Decision: no handles, cursors or idempotency keys (no-go)

Status: no-go for now (HACK-1134). This decision adds no cursor implementation.

We considered three additions:

- A stream handle, so a later call can attach to a running tail.
- A resume cursor, so a later call can continue after the last returned event.
- An idempotency key, so a retried call returns the same result.

Reasons for no-go:

1. **The backends have no exact cursor.** Hack's compose path accepts only
`tail`, and `docker compose logs` itself offers only timestamp filters that
can repeat or skip lines with the same timestamp. The lifecycle log
companion is a file tail. Loki selects by time range, and its timestamps
are not unique. A cursor would promise gapless, duplicate-free resume that
Hack cannot deliver, least of all for the default compose backend.
2. **A handle needs state that does not survive the common reconnect.** A
stdio MCP client reconnects by starting a new `hack mcp serve` process, so
in-memory handles are lost. Keeping them on disk under `HACK_HOME` would add
retained log data, plus ownership, expiry and cleanup rules. Logs can hold
secrets, so that retention is a risk.
3. **Idempotency adds nothing to a read.** Both tools are read-only and
annotated `readOnlyHint`. A retry is already safe. It returns current data
rather than a replay, and that is the documented behavior above.
4. **Bounded calls already cover the need.** Agents call tail with a small
window, read `data.cutoff`, and call again with `since` when they need more.

Revisit this decision (go) only when all of these are true:

- A backend exposes a stable, monotonic position, such as a daemon-owned log
index.
- Hack has a daemon-owned buffer with defined retention, ownership and secret
handling.
- A real agent workflow shows that `since` overlap is not good enough.
139 changes: 139 additions & 0 deletions src/mcp/logs-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* Result contract for the MCP `hack.project.logs.tail` and
* `hack.project.logs.snapshot` tools.
*
* A collection ends in exactly one outcome:
* - `complete`: the backend reached end of stream and exited 0.
* - `cutoff`: the MCP server stopped collection at one of its own bounds. This
* is a success; `data.cutoff` names the bound and its effective limit.
* - `backend_error`: the log backend reported failure (a stream `error` event,
* a failing `end` event, or a non-zero exit the server did not cause). This
* is a closed error, never a truncated success.
* - `cancelled`: the request was aborted or the session closed; nothing is
* kept for a later request (see docs/reference/mcp-logs.md).
*
* Failure evidence counts only while collection is still running. Once the
* server stops the command for a bound, a later `error`/`end` event or a
* signal exit status is a consequence of that stop, not a backend failure.
* The byte bound is the exception for events: the retained prefix was written
* before the child was signalled and nothing after the cut is parsed, so an
* `error` or failing `end` event inside that prefix is still a failure.
*/

/** Why collection stopped, as reported in `data.stop_reason`. */
export type McpLogStopReason =
| "eof"
| "max_events"
| "timeout"
| "output_limit"
| "cancelled"
| "interrupted";

/** The bound that ended a successful, incomplete collection. */
export type McpLogCutoff = {
/** `events` counts `type: "log"` events only; `bytes` is the shared stdout
* plus stderr capture budget; `time` is the collection window. */
readonly bound: "events" | "bytes" | "time";
readonly limit: number;
};

export type McpLogOutcome =
| { readonly kind: "complete" }
| { readonly kind: "cutoff"; readonly cutoff: McpLogCutoff }
| { readonly kind: "backend_error" }
| { readonly kind: "cancelled" };

/** Closed post-spawn failure. The message is fixed and never repeats backend
* output, caller input or query text. */
export const MCP_LOG_BACKEND_ERROR = Object.freeze({
code: "E_UNEXPECTED",
reason: "backend_error",
message: "Log backend failed. Run 'hack logs' for details.",
} as const);

/** End reasons written by the CLI's JSON stream for a normal end of stream. */
const CLEAN_END_REASONS: ReadonlySet<string> = new Set(["eof", "closed"]);

/** Classify one parsed `hack logs --json` stream event. */
export function observeLogStreamEvent(event: Record<string, unknown>): {
readonly isLog: boolean;
readonly backendFailure: boolean;
} {
const type = event.type;
if (type === "log") {
return { isLog: true, backendFailure: false };
}
if (type === "error") {
return { isLog: false, backendFailure: true };
}
if (type === "end") {
const reason = event.reason;
return {
isLog: false,
backendFailure:
typeof reason === "string" && !CLEAN_END_REASONS.has(reason),
};
}
return { isLog: false, backendFailure: false };
}

/**
* Decide the outcome of a finished collection. Backend failure evidence wins
* over a cutoff; a non-zero exit after a server-initiated stop is ignored.
*/
export function classifyLogCollection(opts: {
readonly stopReason: McpLogStopReason;
readonly exitCode: number;
readonly backendFailureObserved: boolean;
readonly limits: {
readonly maxEvents?: number;
readonly timeMs: number;
readonly bytes: number;
};
}): McpLogOutcome {
if (opts.backendFailureObserved) {
return { kind: "backend_error" };
}
switch (opts.stopReason) {
case "cancelled":
case "interrupted":
return { kind: "cancelled" };
case "max_events":
return {
kind: "cutoff",
cutoff: { bound: "events", limit: opts.limits.maxEvents ?? 0 },
};
case "timeout":
return {
kind: "cutoff",
cutoff: { bound: "time", limit: opts.limits.timeMs },
};
case "output_limit":
return {
kind: "cutoff",
cutoff: { bound: "bytes", limit: opts.limits.bytes },
};
case "eof":
return opts.exitCode === 0
? { kind: "complete" }
: { kind: "backend_error" };
default: {
const unreachable: never = opts.stopReason;
return unreachable;
}
}
}

/** Narrow a lifetime stop reason to the closed set the logs contract reports. */
export function toLogStopReason(reason: string): McpLogStopReason {
switch (reason) {
case "max_events":
case "timeout":
case "output_limit":
case "cancelled":
case "interrupted":
return reason;
default:
return "interrupted";
}
}
Loading
Loading