@litterthanlit/hypher-cli 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,6 +5,7 @@ Pilot · not yet recorded. The bridge between coding agents and Hypher project m
5
5
  - `hypher init` connects this computer (one browser approval), installs a pinned copy under `~/.hypher/`, and adds Claude Code hooks and Codex hooks (Codex asks once to trust them: `/hooks`). It keeps M0a's `hypher` sign-in entry wherever it finds one.
6
6
  - `hypher doctor` checks each piece and prints the fix for anything red.
7
7
  - `hypher resume` prints the note for this repository; `hypher seal` saves a handoff.
8
+ - `hypher seal --evidence "<command>=<exit code>"` (repeatable, split at the last `=`) reports a check you ran. Hypher binds each to the working tree at the save and labels it an agent report (only agent reports are accepted until hooks capture checks in Baton M3); the next note says whether that tree still matches. It never runs the command. Only the command text, exit code, tree hash, and time leave the machine, never output. Against a server that does not accept evidence yet, the handoff saves without it and says so.
8
9
  - `hypher mcp` is the bridge for any MCP client: `resume`, `seal`, and `acknowledge`, with no IDs.
9
10
  - `hypher uninstall` removes exactly what `init` added and revokes the key.
10
11
  - `init` also puts `~/.hypher/bin` on PATH with one marked block in your shell's startup file (`~/.zshenv` for zsh, `~/.bash_profile` or `~/.bashrc` for bash, a `conf.d` file for fish), so `hypher` runs by name in new terminals and in agents' shells. `--no-path` skips it; hooks and the MCP bridge use the full path either way.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litterthanlit/hypher-cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Hypher bridge for coding agents: hooks, a local outbox, and a truth check against the working tree. Pilot, not yet recorded.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -35,6 +35,7 @@ const OPTIONS = {
35
35
  completed: { type: "string", multiple: true },
36
36
  unverified: { type: "string", multiple: true },
37
37
  blocker: { type: "string", multiple: true },
38
+ evidence: { type: "string", multiple: true },
38
39
  source: { type: "string" },
39
40
  agent: { type: "string" },
40
41
  session: { type: "string" },
@@ -52,7 +53,8 @@ Usage: hypher <command>
52
53
  doctor Check every piece and print the fix for anything red
53
54
  status What is installed; --last shows exactly what was last sent
54
55
  resume Print the handoff note for this repository and its truth check
55
- seal Save a handoff: --next "…" [--decision "…" --why "…" [--replaces "…"]] or JSON on stdin
56
+ seal Save a handoff: --next "…" [--decision "…" --why "…" [--replaces "…"]]
57
+ [--evidence "<command>=<exit code>": a check you ran] or JSON on stdin
56
58
  ack Acknowledge the handoff this session read
57
59
  mcp Run the stdio MCP bridge: resume, seal, acknowledge (for any MCP client)
58
60
  uninstall Remove exactly what init added and revoke the key
@@ -14,6 +14,7 @@ import { createClient, HypherRejected, HypherUnavailable } from "../api.mjs";
14
14
  import { CLI_VERSION } from "../env.mjs";
15
15
  import { detectAgent, resumeHere, sealHere } from "../session.mjs";
16
16
  import { acknowledgeHere } from "./ack.mjs";
17
+ import { evidenceInput } from "./seal.mjs";
17
18
 
18
19
  const SUPPORTED_PROTOCOLS = ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"];
19
20
 
@@ -40,7 +41,7 @@ export const LOCAL_TOOLS = [
40
41
  {
41
42
  name: "seal",
42
43
  title: "Save to Hypher",
43
- description: "Call before you finish work that changed files, or when asked to save. Saves a handoff for the next agent from what you know: the decisions that changed and why, and the next action. The bridge adds repository, branch, commit, and tree identity and carries earlier decisions forward. Sends only this text, file names, and commit identity; never code. Needs no IDs. The first save in a repository also needs goal, and so does a save after the work moved on from the note's goal.",
44
+ description: "Call before you finish work that changed files, or when asked to save. Saves a handoff for the next agent from what you know: the decisions that changed and why, and the next action. The bridge adds repository, branch, commit, and tree identity and carries earlier decisions forward. To report checks, pass evidence: only commands you actually ran, with their exit codes. Hypher records them against the current tree as an agent report; it never runs them. Sends only this text, file names, and commit identity; never code or command output. Needs no IDs. The first save in a repository also needs goal, and so does a save after the work moved on from the note's goal.",
44
45
  inputSchema: {
45
46
  type: "object",
46
47
  properties: {
@@ -63,6 +64,18 @@ export const LOCAL_TOOLS = [
63
64
  completed: LIST("Work you finished (reported, not verified)."),
64
65
  unverified: LIST("Work that still needs checking."),
65
66
  blockers: LIST("What blocks the next action, if anything."),
67
+ evidence: {
68
+ type: "array",
69
+ description: "Checks you ran in this session: report only commands you actually ran, with their exit codes. Hypher records them against the current tree as an agent report and never runs them. The command text only, never its output. A check against an older tree is flagged for the next agent. At most 8.",
70
+ items: {
71
+ type: "object",
72
+ properties: {
73
+ command: { type: "string", description: "The command as you ran it, on one line." },
74
+ exitCode: { type: "integer", minimum: 0, maximum: 255, description: "Its exit code." },
75
+ },
76
+ required: ["command", "exitCode"],
77
+ },
78
+ },
66
79
  cwd: CWD_PROPERTY,
67
80
  },
68
81
  required: ["nextAction"],
@@ -171,6 +184,7 @@ export async function callLocalTool(name, args, session) {
171
184
  completed: asList(args?.completed),
172
185
  unverified: asList(args?.unverified),
173
186
  blockers: asList(args?.blockers),
187
+ evidence: evidenceInput(args?.evidence),
174
188
  };
175
189
  const result = await sealHere({ env: session.env, cwd, seal, agent });
176
190
  return textResult(result.message, !result.ok);
@@ -5,15 +5,37 @@
5
5
  *
6
6
  * hypher seal --next "Fix rounding" --decision "Guest checkout only" --why "…" --replaces D1
7
7
  * hypher seal '{"nextAction":"…","decisions":[{"decision":"…","why":"…"}]}'
8
+ * hypher seal --next "Fix rounding" --evidence "npm test=0" --evidence "npm run lint=1"
8
9
  * echo '{"nextAction":"…"}' | hypher seal --stdin
9
10
  *
10
11
  * The JSON fields are the MCP `seal` tool's: nextAction, goal, decisions
11
- * [{ decision, why, replaces }], constraints, completed, unverified, blockers.
12
+ * [{ decision, why, replaces }], constraints, completed, unverified, blockers,
13
+ * evidence [{ command, exitCode }]: the checks the agent ran. Hypher records each
14
+ * against the current tree as an agent report; it never runs the command.
12
15
  */
13
16
  import { detectAgent, sealHere } from "../session.mjs";
14
17
  import { readWorktreeState } from "../state.mjs";
15
18
  import { repoIdentity } from "../../vendor/hypher-git.mjs";
16
19
 
20
+ /** `--evidence "<command>=<exit code>"`: split at the last `=`, so a command may hold its own. */
21
+ export function parseEvidenceFlag(value) {
22
+ const text = String(value ?? "");
23
+ const at = text.lastIndexOf("=");
24
+ const command = at < 0 ? "" : text.slice(0, at).trim();
25
+ const code = at < 0 ? "" : text.slice(at + 1).trim();
26
+ if (!command) return { error: `--evidence "${text}" needs the form "<command>=<exit code>", for example --evidence "npm test=0".` };
27
+ if (!/^\d{1,3}$/.test(code) || Number(code) > 255) {
28
+ return { error: `--evidence "${command}": the exit code "${code}" must be a whole number from 0 to 255.` };
29
+ }
30
+ return { evidence: { command, exitCode: Number(code) } };
31
+ }
32
+
33
+ /** The evidence array from JSON or MCP arguments: only `command` and `exitCode` are read. */
34
+ export function evidenceInput(value) {
35
+ if (!Array.isArray(value)) return value;
36
+ return value.map((item) => ({ command: item?.command, exitCode: item?.exitCode }));
37
+ }
38
+
17
39
  /** Pair each --why and --replaces with the --decision before it. */
18
40
  export function sealFromTokens(tokens) {
19
41
  const seal = { decisions: [] };
@@ -23,7 +45,11 @@ export function sealFromTokens(tokens) {
23
45
  const value = token.value ?? "";
24
46
  if (token.name === "goal") seal.goal = value;
25
47
  else if (token.name === "next") seal.nextAction = value;
26
- else if (token.name === "decision") seal.decisions.push({ decision: value, why: "" });
48
+ else if (token.name === "evidence") {
49
+ const parsed = parseEvidenceFlag(value);
50
+ if (parsed.error) return { error: parsed.error };
51
+ (seal.evidence ??= []).push(parsed.evidence);
52
+ } else if (token.name === "decision") seal.decisions.push({ decision: value, why: "" });
27
53
  else if (token.name === "why" || token.name === "replaces") {
28
54
  const last = seal.decisions[seal.decisions.length - 1];
29
55
  if (!last) return { error: `--${token.name} must follow a --decision.` };
@@ -55,6 +81,7 @@ export function sealFromJson(parsed) {
55
81
  completed: list(parsed.completed),
56
82
  unverified: list(parsed.unverified),
57
83
  blockers: list(parsed.blockers),
84
+ evidence: evidenceInput(parsed.evidence),
58
85
  },
59
86
  };
60
87
  }
package/src/env.mjs CHANGED
@@ -7,7 +7,7 @@ import os from "node:os";
7
7
  import path from "node:path";
8
8
 
9
9
  // Keep equal to package.json; a test checks it.
10
- export const CLI_VERSION = "0.1.6";
10
+ export const CLI_VERSION = "0.1.7";
11
11
  export const DEFAULT_API_BASE = "https://www.hypher.app";
12
12
  export const INTEGRATIONS_URL = "https://hypher.app/app/settings/integrations";
13
13
  export const API_KEYS_URL = "https://hypher.app/app/settings/api-keys";
@@ -52,10 +52,10 @@ function sealRequest(firstHandoff) {
52
52
  const goal = firstHandoff ? ' --goal "<the goal>"' : "";
53
53
  return [
54
54
  "Hypher: the working tree changed since the last handoff, and no one saved why.",
55
- "Before you stop, record the decisions that changed and the next action for the next agent:",
55
+ "Before you stop, record the decisions that changed, the next action for the next agent, and the checks you ran with their exit codes:",
56
56
  `call the \`seal\` tool of the hypher MCP server, or, only if that tool is not available, run \`hypher seal${goal} --decision "<a decision that changed>" --why "<why>" --next "<the next action>"\` in the terminal.`,
57
57
  firstHandoff ? "This is the first handoff here, so give the goal too." : "If the work moved on from the note's goal, give the new goal too (goal, or --goal \"…\").",
58
- "Add one --decision/--why pair per changed decision (--replaces D1 names one it replaces), or none if no decision changed; --next is always needed.",
58
+ "Add one --decision/--why pair per changed decision (--replaces D1 names one it replaces), or none if no decision changed; --next is always needed. Report only checks you ran (evidence, or --evidence \"<command>=<exit code>\").",
59
59
  "It sends only your text, file names, and commit identity. Hypher asks once; if you cannot save now, say so and stop.",
60
60
  ].join(" ");
61
61
  }
package/src/session.mjs CHANGED
@@ -117,6 +117,8 @@ export async function resumeHere({ env, cwd, destination, sessionId, now = Date.
117
117
  from: prepared.sourceAgent,
118
118
  savedAt,
119
119
  verdict,
120
+ // Each reported check says whether its tree is the tree now.
121
+ currentTree: verdict?.current?.tree ?? snapshot.tree,
120
122
  checkpoint: shownCheckpoint,
121
123
  acknowledge: ACK_HINT,
122
124
  });
@@ -141,9 +143,19 @@ export async function resumeHere({ env, cwd, destination, sessionId, now = Date.
141
143
  };
142
144
  }
143
145
 
146
+ /** Said once when an older server refuses the `evidence` field and the handoff goes without it. */
147
+ export const NO_EVIDENCE_LINE = "This Hypher server does not accept evidence yet; the handoff was saved without it.";
148
+
149
+ /** True when a refusal is the server rejecting the `evidence` field itself, not a bad entry. */
150
+ export function refusesEvidenceField(text) {
151
+ const message = String(text ?? "");
152
+ return /\bevidence\b/i.test(message) && /not allowed|extra field|not in the validator|unknown (?:field|key)|unexpected/i.test(message);
153
+ }
154
+
144
155
  /**
145
156
  * Save a handoff from what the agent knows: decisions that changed and why,
146
- * and the next action. Based on the revision this session was handed.
157
+ * the next action, and the checks it ran (command and exit code, bound to the
158
+ * tree at this save). Based on the revision this session was handed.
147
159
  * @param {{ env: NodeJS.ProcessEnv, cwd: string, seal: object, agent: string, now?: number }} options
148
160
  */
149
161
  export async function sealHere({ env, cwd, seal, agent, now = Date.now() }) {
@@ -164,11 +176,21 @@ export async function sealHere({ env, cwd, seal, agent, now = Date.now() }) {
164
176
  }
165
177
  const prior = state.proposal ? parseHandoffProposal(state.proposal, { allowPlaceholders: true }) : null;
166
178
  const iso = new Date(now).toISOString().slice(0, 16).replace("T", " ");
179
+ // Checks are bound to the working-tree hash at this save. Without one, the handoff still
180
+ // saves; the checks are left out and the agent is told.
181
+ const lines = [];
182
+ let toSeal = seal;
183
+ if (Array.isArray(seal.evidence) && seal.evidence.length && !snapshot.tree) {
184
+ toSeal = { ...seal, evidence: undefined };
185
+ lines.push("The checks were not recorded: Hypher could not read the working tree hash.");
186
+ }
167
187
  const built = buildSealProposal({
168
188
  prior: prior?.ok ? prior.value : null,
169
- seal,
189
+ seal: toSeal,
170
190
  repo: snapshot.currentRepo,
171
191
  sessionRef: `${agent} session on ${snapshot.branch}, ${iso} UTC`,
192
+ tree: snapshot.tree,
193
+ now,
172
194
  });
173
195
  if (!built.ok) return { ok: false, code: "invalid", message: built.error };
174
196
 
@@ -178,28 +200,41 @@ export async function sealHere({ env, cwd, seal, agent, now = Date.now() }) {
178
200
  ? { ok: false, code: "sandboxed", message: SANDBOX_LINE }
179
201
  : { ok: false, code: "not-connected", message: "This computer is not connected. Run `hypher init`." };
180
202
  }
181
- const request = {
182
- kind: "handoff",
183
- source: agent,
184
- projectId: link.projectId,
185
- proposal: built.proposal,
186
- expectedBaseRevision: state.baseRevision,
187
- };
188
- // Same base and same request: same key, so a retry dedupes. A change that
189
- // returns to earlier content has a newer base, so it is not a duplicate.
190
- const idempotencyKey = `seal-${sha256(`${state.baseRevision}\n${canonicalJson(request)}`).slice(0, 40)}`;
191
- let result;
192
- try {
193
- result = (await client.tool("post_agent_event", { ...request, idempotencyKey })).structured;
194
- } catch (error) {
195
- if (error instanceof HypherUnavailable) {
196
- return inCodexSandbox(env)
197
- ? { ok: false, code: "sandboxed", message: SANDBOX_LINE }
198
- : { ok: false, code: "unavailable", message: "Hypher unavailable; nothing was saved. Run `hypher doctor`, then seal again." };
203
+ /** One save request. Returns the server's answer, or the failure to hand back. */
204
+ async function send(proposal) {
205
+ const request = {
206
+ kind: "handoff",
207
+ source: agent,
208
+ projectId: link.projectId,
209
+ proposal,
210
+ expectedBaseRevision: state.baseRevision,
211
+ };
212
+ // Same base and same request: same key, so a retry dedupes. A change that
213
+ // returns to earlier content has a newer base, so it is not a duplicate.
214
+ const idempotencyKey = `seal-${sha256(`${state.baseRevision}\n${canonicalJson(request)}`).slice(0, 40)}`;
215
+ try {
216
+ return { result: (await client.tool("post_agent_event", { ...request, idempotencyKey })).structured };
217
+ } catch (error) {
218
+ if (error instanceof HypherUnavailable) {
219
+ return { failure: inCodexSandbox(env)
220
+ ? { ok: false, code: "sandboxed", message: SANDBOX_LINE }
221
+ : { ok: false, code: "unavailable", message: "Hypher unavailable; nothing was saved. Run `hypher doctor`, then seal again." } };
222
+ }
223
+ if (error instanceof HypherRejected) return { failure: { ok: false, code: "rejected", message: error.message } };
224
+ throw error;
199
225
  }
200
- if (error instanceof HypherRejected) return { ok: false, code: "rejected", message: error.message };
201
- throw error;
202
226
  }
227
+ let saved = built.proposal;
228
+ let sent = await send(saved);
229
+ // A server that predates evidence refuses the field itself. Once, without it.
230
+ if (saved.evidence && (refusesEvidenceField(sent.failure?.message) || (sent.result?.ok === false && refusesEvidenceField(sent.result.error)))) {
231
+ const { evidence: _evidence, ...withoutEvidence } = saved;
232
+ saved = withoutEvidence;
233
+ sent = await send(saved);
234
+ lines.push(NO_EVIDENCE_LINE);
235
+ }
236
+ if (sent.failure) return sent.failure;
237
+ const result = sent.result;
203
238
  if (!result?.ok) {
204
239
  if (result?.code === "stale-revision") {
205
240
  return { ok: false, code: "stale", message: `Hypher has a newer handoff (revision ${result.headRevision}) than the one this session read (revision ${state.baseRevision}). Run \`hypher resume\`, read it, and seal again.` };
@@ -211,7 +246,7 @@ export async function sealHere({ env, cwd, seal, agent, now = Date.now() }) {
211
246
  updateWorktreeState(env, snapshot.root, (current) => withBranchState({
212
247
  ...current,
213
248
  baseRevision: result.revision,
214
- proposal: built.proposal,
249
+ proposal: saved,
215
250
  agent,
216
251
  }, snapshot.branch, { sealTree: tree }));
217
252
  return {
@@ -219,7 +254,7 @@ export async function sealHere({ env, cwd, seal, agent, now = Date.now() }) {
219
254
  code: result.code,
220
255
  revision: result.revision,
221
256
  projectName: link.projectName,
222
- message: `Saved handoff revision ${result.revision} for "${link.projectName}". Memory did not transfer code.`,
257
+ message: [`Saved handoff revision ${result.revision} for "${link.projectName}". Memory did not transfer code.`, ...lines].join("\n"),
223
258
  };
224
259
  }
225
260
 
@@ -89,6 +89,27 @@ export function describeCheckpoint(checkpoint, now) {
89
89
  const files = checkpoint.changedFiles.length ? `changed: ${fileList(checkpoint.changedFiles)}` : "no uncommitted changes";
90
90
  return `Last checkpoint: ${formatAge(now - checkpoint.capturedAt)} ago from ${noteText(checkpoint.source, 40)} (${noteText(checkpoint.event, 40)}), not sealed · branch ${noteText(checkpoint.branch, 200)} · ${files}.`;
91
91
  }
92
+ /** A code span that survives backticks inside the text. */
93
+ function codeSpan(text) {
94
+ const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
95
+ const fence = "`".repeat(longest + 1);
96
+ return longest > 0 ? `${fence} ${text} ${fence}` : `${fence}${text}${fence}`;
97
+ }
98
+ /**
99
+ * One reported check: the command, its exit code, who reported it, how old it is, and
100
+ * whether the tree it ran against is the tree now. Hypher never runs the command; a check
101
+ * against an older tree says to run it again.
102
+ */
103
+ export function describeEvidence(item, now, currentTree, max = 280) {
104
+ // The validator refuses `hook` until Baton M3; the wording waits for it.
105
+ const who = item.capturedBy === "hook" ? "captured by a hook" : "agent report";
106
+ const tree = !currentTree
107
+ ? "tree not checked here"
108
+ : currentTree === item.tree
109
+ ? "same tree as now"
110
+ : "the tree changed since; run it again before relying on it";
111
+ return `${codeSpan(noteText(item.command, max))} exit ${item.exitCode}, ${who}, ${formatAge(now - item.capturedAt)} ago, ${tree}.`;
112
+ }
92
113
  function joinItems(items, empty, max = 280) {
93
114
  const cleaned = items.map((item) => noteText(item, max)).filter(Boolean);
94
115
  return cleaned.length ? cleaned.map((item) => (/[.!?…]$/.test(item) ? item : `${item}.`)).join(" ") : empty;
@@ -122,6 +143,9 @@ function sections(input, max, listLimit) {
122
143
  `Completed (agent-reported, not verified): ${joinItems(limit(proposal.completed), "none.", max)}${more(proposal.completed)}`,
123
144
  `Unverified: ${joinItems(limit(proposal.unverified), "none.", max)}${more(proposal.unverified)}`,
124
145
  `Blockers: ${joinItems(limit(proposal.blockers), "none.", max)}${more(proposal.blockers)}`,
146
+ ...(proposal.evidence?.length
147
+ ? [`Evidence: ${limit(proposal.evidence).map((item) => describeEvidence(item, input.now, input.currentTree, max)).join(" ")}${more(proposal.evidence)}`]
148
+ : []),
125
149
  ...(input.checkpoint ? [describeCheckpoint(input.checkpoint, input.now)] : []),
126
150
  `Sources: ${limit(proposal.sources).map((source) => {
127
151
  const kind = source.kind === "capture" ? "project capture" : source.kind === "agent_event" ? "stored agent report" : "agent report";
@@ -59,6 +59,46 @@ function mergeUnique(prior, added) {
59
59
  }
60
60
  return merged;
61
61
  }
62
+ const MAX_EVIDENCE_COMMAND = 300;
63
+ const TREE_HASH = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
64
+ /**
65
+ * The checks this save reports, bound to the tree at the save, as agent
66
+ * reports. The order given is kept; a command listed twice keeps its last entry.
67
+ */
68
+ function evidenceFromSeal(raw, tree, now) {
69
+ if (raw === undefined || raw === null)
70
+ return { ok: true, items: [] };
71
+ if (!Array.isArray(raw))
72
+ return { ok: false, error: "evidence must be a list of { command, exitCode }." };
73
+ const reported = [];
74
+ for (const item of raw) {
75
+ const entry = (item ?? {});
76
+ const command = clean(entry.command);
77
+ if (!command)
78
+ return { ok: false, error: "Each evidence entry needs a command (in a shell, --evidence \"<command>=<exit code>\")." };
79
+ if (/[\r\n\u2028\u2029]/.test(command))
80
+ return { ok: false, error: `Evidence command "${command.replace(/\s+/g, " ").slice(0, 40)}…" must be one line.` };
81
+ if (command.length > MAX_EVIDENCE_COMMAND) {
82
+ return { ok: false, error: `Evidence command "${command.slice(0, 40)}…" is ${command.length} characters; the limit is ${MAX_EVIDENCE_COMMAND}.` };
83
+ }
84
+ const exitCode = entry.exitCode;
85
+ if (typeof exitCode !== "number" || !Number.isInteger(exitCode) || exitCode < 0 || exitCode > 255) {
86
+ return { ok: false, error: `The exit code for evidence "${command}" must be a whole number from 0 to 255.` };
87
+ }
88
+ reported.push({ command, exitCode });
89
+ }
90
+ if (reported.length === 0)
91
+ return { ok: true, items: [] };
92
+ if (!tree || !TREE_HASH.test(tree)) {
93
+ return { ok: false, error: "Hypher could not read the working tree, so it cannot record the checks against it." };
94
+ }
95
+ const seen = new Set();
96
+ const latest = [...reported].reverse().filter((item) => !seen.has(item.command) && seen.add(item.command)).reverse();
97
+ return {
98
+ ok: true,
99
+ items: latest.map((item) => ({ command: item.command, exitCode: item.exitCode, tree, capturedBy: "agent", capturedAt: now })),
100
+ };
101
+ }
62
102
  function tooMany(field, count, hint) {
63
103
  return `A handoff keeps at most ${MAX_HANDOFF_ITEMS} ${field}; this one would have ${count}. ${hint}`;
64
104
  }
@@ -130,6 +170,18 @@ export function buildSealProposal(input) {
130
170
  // save kept "finish CONTRIBUTING.md" and its stale lines). Decisions and constraints still carry.
131
171
  const goalChanged = Boolean(prior && newGoal && newGoal !== prior.goal);
132
172
  const listOrPrior = (value, fallback) => cleanList(value) ?? (goalChanged ? [] : fallback);
173
+ // Evidence follows the goal rule: kept while the goal holds, dropped when it changes. A newer
174
+ // entry for a command replaces the older one. This save's entries come first, then the earlier
175
+ // ones, so the list runs newest first; beyond eight, the oldest go.
176
+ const reported = evidenceFromSeal(seal.evidence, input.tree, input.now ?? Date.now());
177
+ if (!reported.ok)
178
+ return reported;
179
+ if (reported.items.length > MAX_HANDOFF_ITEMS) {
180
+ return { ok: false, error: tooMany("evidence entries", reported.items.length, "Report the checks the next agent needs.") };
181
+ }
182
+ const reportedCommands = new Set(reported.items.map((item) => item.command));
183
+ const carried = goalChanged ? [] : (prior?.evidence ?? []).filter((item) => !reportedCommands.has(item.command));
184
+ const evidence = [...reported.items, ...carried].slice(0, MAX_HANDOFF_ITEMS);
133
185
  const proposal = {
134
186
  schemaVersion: HANDOFF_PROPOSAL_SCHEMA_VERSION,
135
187
  goal,
@@ -141,6 +193,7 @@ export function buildSealProposal(input) {
141
193
  nextAction,
142
194
  sources,
143
195
  repo: input.repo,
196
+ ...(evidence.length ? { evidence } : {}),
144
197
  };
145
198
  for (const field of ["completed", "unverified", "blockers"]) {
146
199
  if (proposal[field].length > MAX_HANDOFF_ITEMS) {
@@ -13,6 +13,7 @@ const MAX_BRANCH = 200;
13
13
  const MAX_COMMIT = 80;
14
14
  const MAX_DESTINATION = 80;
15
15
  const MAX_REASON = 500;
16
+ const MAX_COMMAND = 300;
16
17
  const PROPOSAL_KEYS = new Set([
17
18
  "schemaVersion",
18
19
  "goal",
@@ -24,7 +25,9 @@ const PROPOSAL_KEYS = new Set([
24
25
  "nextAction",
25
26
  "sources",
26
27
  "repo",
28
+ "evidence",
27
29
  ]);
30
+ const EVIDENCE_KEYS = new Set(["command", "exitCode", "tree", "capturedBy", "capturedAt"]);
28
31
  export const HANDOFF_PROPOSAL_JSON_SCHEMA = {
29
32
  type: "object",
30
33
  additionalProperties: false,
@@ -93,6 +96,23 @@ export const HANDOFF_PROPOSAL_JSON_SCHEMA = {
93
96
  },
94
97
  description: "Working-tree metadata. This snapshot does not include file contents.",
95
98
  },
99
+ evidence: {
100
+ type: "array",
101
+ maxItems: MAX_ITEMS,
102
+ description: "Optional. Checks you ran, newest first: the command text and its exit code, bound to the working-tree hash at the save. An agent report; Hypher does not run it. Never include output or file contents.",
103
+ items: {
104
+ type: "object",
105
+ additionalProperties: false,
106
+ required: ["command", "exitCode", "tree", "capturedBy", "capturedAt"],
107
+ properties: {
108
+ command: { type: "string", maxLength: MAX_COMMAND, description: "One line." },
109
+ exitCode: { type: "integer", minimum: 0, maximum: 255 },
110
+ tree: { type: "string", pattern: "^([0-9a-f]{40}|[0-9a-f]{64})$", description: "Working-tree hash at the save: the same hash as repo.dirtyFingerprint, without the tree: prefix, and also when the tree is clean." },
111
+ capturedBy: { type: "string", enum: ["agent"] },
112
+ capturedAt: { type: "integer", minimum: 1, description: "Milliseconds since the epoch." },
113
+ },
114
+ },
115
+ },
96
116
  },
97
117
  };
98
118
  function isObject(value) {
@@ -156,6 +176,40 @@ export function parseRepoSnapshot(value) {
156
176
  }
157
177
  return { ok: true, value: result };
158
178
  }
179
+ const TREE_HASH = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
180
+ export function parseEvidence(value) {
181
+ if (!Array.isArray(value))
182
+ return { ok: false, error: "evidence must be an array" };
183
+ if (value.length > MAX_ITEMS)
184
+ return { ok: false, error: "evidence has too many items" };
185
+ const items = [];
186
+ for (const item of value) {
187
+ if (!isObject(item))
188
+ return { ok: false, error: "evidence must be objects" };
189
+ const extra = unknownKey(item, EVIDENCE_KEYS);
190
+ if (extra)
191
+ return { ok: false, error: `evidence.${extra} is not allowed` };
192
+ const command = shortText(item.command, "evidence.command", MAX_COMMAND);
193
+ if (!command.ok)
194
+ return command;
195
+ if (/[\r\n\u2028\u2029]/.test(command.value))
196
+ return { ok: false, error: "evidence.command must be one line" };
197
+ if (typeof item.exitCode !== "number" || !Number.isInteger(item.exitCode) || item.exitCode < 0 || item.exitCode > 255) {
198
+ return { ok: false, error: "evidence.exitCode must be a whole number from 0 to 255" };
199
+ }
200
+ if (typeof item.tree !== "string" || !TREE_HASH.test(item.tree)) {
201
+ return { ok: false, error: "evidence.tree must be a 40 or 64 character lowercase hex hash" };
202
+ }
203
+ if (item.capturedBy !== "agent") {
204
+ return { ok: false, error: "evidence.capturedBy must be agent" };
205
+ }
206
+ if (typeof item.capturedAt !== "number" || !Number.isInteger(item.capturedAt) || item.capturedAt < 1) {
207
+ return { ok: false, error: "evidence.capturedAt must be a positive whole number of milliseconds" };
208
+ }
209
+ items.push({ command: command.value, exitCode: item.exitCode, tree: item.tree, capturedBy: item.capturedBy, capturedAt: item.capturedAt });
210
+ }
211
+ return { ok: true, value: items };
212
+ }
159
213
  const PLACEHOLDER_PREFIX = "FILL:";
160
214
  function isPlaceholder(value) {
161
215
  return typeof value === "string" && value.trim().startsWith(PLACEHOLDER_PREFIX);
@@ -192,6 +246,8 @@ export function findHandoffPlaceholder(proposal) {
192
246
  if (isPlaceholder(proposal.repo[field]))
193
247
  return `repo.${field}`;
194
248
  }
249
+ if (proposal.evidence?.some((item) => isPlaceholder(item.command)))
250
+ return "evidence.command";
195
251
  return null;
196
252
  }
197
253
  /**
@@ -322,6 +378,13 @@ export function parseHandoffProposal(value, options = {}) {
322
378
  const repo = parseRepoSnapshot(value.repo);
323
379
  if (!repo.ok)
324
380
  return repo;
381
+ let evidence;
382
+ if (value.evidence !== undefined) {
383
+ const parsedEvidence = parseEvidence(value.evidence);
384
+ if (!parsedEvidence.ok)
385
+ return parsedEvidence;
386
+ evidence = parsedEvidence.value;
387
+ }
325
388
  const proposal = {
326
389
  schemaVersion: 1,
327
390
  goal: goal.value,
@@ -333,6 +396,7 @@ export function parseHandoffProposal(value, options = {}) {
333
396
  nextAction: nextAction.value,
334
397
  sources,
335
398
  repo: repo.value,
399
+ ...(evidence !== undefined ? { evidence } : {}),
336
400
  };
337
401
  if (!options.allowPlaceholders) {
338
402
  const placeholder = findHandoffPlaceholder(proposal);
@@ -354,6 +418,9 @@ export function renderHandoffPacket(proposal) {
354
418
  proposal.completed.length ? `Agent-reported completed work: ${proposal.completed.join("; ")}` : "Agent-reported completed work: none",
355
419
  proposal.unverified.length ? `Unverified: ${proposal.unverified.join("; ")}` : "Unverified: none",
356
420
  proposal.blockers.length ? `Blockers: ${proposal.blockers.join("; ")}` : "Blockers: none",
421
+ ...(proposal.evidence?.length
422
+ ? [`Evidence (agent reports against the tree at the save; Hypher did not run them): ${proposal.evidence.map((item) => `${item.command} exited ${item.exitCode}`).join("; ")}`]
423
+ : []),
357
424
  `Next action: ${proposal.nextAction}`,
358
425
  "Sources:",
359
426
  ...proposal.sources.map((item) => `- ${item.kind === "capture" ? "Project capture" : item.kind === "agent_event" ? "Stored agent report" : "Agent-reported"}: ${item.label ? `${item.label}: ` : ""}${item.ref}`),