diff --git a/AGENTS.md b/AGENTS.md index cd14f7751f..d9ffc420e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -263,6 +263,33 @@ These apply to any AI harness. ## Agent operational practices +- **Heartbeat-via-commit is required for autonomous-loop + ticks.** Every tick produces EITHER (a) a substantive + commit (substrate edit / PR / row / rule / memo) carrying + a full AgencySignature v1 trailer block per + `docs/research/2026-04-26-gemini-deep-think-agencysignature-commit-attribution-convention-validation-and-refinement.md` + §10 (auditable via + `bun tools/hygiene/audit-agencysignature-main-tip.ts`), + OR (b) a lightweight heartbeat record on the + `agent-heartbeats` branch via + `./tools/agent-heartbeats/write-heartbeat.ts` with no args + (composes with `src/Core.TypeScript/zeta-id/zeta-id.ts` 128-bit + ZetaID + `registry/categories.yaml` Heartbeat = category 3). + Heartbeat default branch bypasses the 4 main-targeting rulesets + (Branch Safety / CI Gate / Default / Review Policy) so per-tick + push succeeds without PR overhead and without showing up as + accidental velocity in the PR queue. ZetaID filename uniqueness + guarantees no collision across concurrent agents. Neither (a) + nor (b) + no named-dependency present = the Standing-by failure + mode per + `.claude/rules/holding-without-named-dependency-is-standing-by-failure.md` + (N=6 brief-ack forced escalation). The narrative self-model + counter is unreliable (2026-05-27 empirical anchor: an + autonomous-loop instance emitted 100+ single-word "Quiet." + responses without the counter firing because the agent cannot + count itself; the externalized counter must read git log over + the agent-heartbeats branch + per-commit AgencySignature trailer + presence on origin/main to fire reliably). - When an agent finds a drift between spec and code, the **spec might be wrong, not the code**. Check both. Spec bugs surface as formal-verification diff --git a/docs/agent-heartbeats/README.md b/docs/agent-heartbeats/README.md new file mode 100644 index 0000000000..9f3e8db397 --- /dev/null +++ b/docs/agent-heartbeats/README.md @@ -0,0 +1,175 @@ +# Agent heartbeats + +ZetaID-named heartbeat records, one per autonomous-loop tick that +produces no other substantive commit. Externalizes the brief-ack +counter for `.claude/rules/holding-without-named-dependency-is-standing-by-failure.md` +per the discipline named in CLAUDE.md "Heartbeat-via-commit = +externalized idle counter" (PR #5451). + +## Layout + +```text +docs/agent-heartbeats////
/.md +``` + +- `` = roster-name per `.claude/rules/agent-roster-reference-card.md` + (folder-name only; the ZetaID `persona` bit-field is a separate + numeric slot per `registry/personas.yaml` role-refs; the writer + packs the operator-supplied `--persona-slot` int into the ID and + uses the operator-supplied `--persona-name` for the folder name — + they are deliberately decoupled because the registry uses neutral + role-refs and the folder uses operator-friendly roster names) +- `` = 32-char zero-padded hex of the 128-bit ZetaID with + `category = 3` (Heartbeat per `registry/categories.yaml`) +- Practically collision-free for autonomous-loop scale: 32-bit + randomness + 48-bit ms timestamp + 8-bit persona slot in the ZetaID + gives ~10⁻¹⁰ collision probability for two concurrent agents writing + within the same millisecond — sufficient for tick cadence, not a + cryptographic guarantee + +## Writing + +**Stupid-simple (per operator 2026-05-27 "just works" direction)**: + +```bash +./tools/agent-heartbeats/write-heartbeat.ts +``` + +Zero params. Writes locally + pushes to the `agent-heartbeats` +branch via REST. Defaults: persona-slot=2, persona-name=otto, +authority=TrustedAgent, momentum=Normal, disposition=bounded-wait, +push=true, branch=agent-heartbeats. Each can be overridden via env +var (`ZETA_AGENT_PERSONA_NAME=alexa ...`) or CLI flag. The TS file +is `chmod +x` with `#!/usr/bin/env bun` shebang so direct invocation +works; `bun tools/agent-heartbeats/write-heartbeat.ts` also works +for explicit-runtime invocation. + +**Why `agent-heartbeats` branch by default**: the 4 active rulesets +on the repo (Branch Safety / CI Gate / Default / Review Policy) all +target `~DEFAULT_BRANCH` only — non-default branches are unprotected, +so direct-push to `agent-heartbeats` succeeds without per-folder +carve-outs. Heartbeats on this branch don't show up as PRs (no +accidental velocity in the PR queue) and don't pollute main commit +log. Lookups via `git log agent-heartbeats -- docs/agent-heartbeats//...`. + +**Full flag form**: + +```bash +./tools/agent-heartbeats/write-heartbeat.ts \ + [--persona-slot ] \ + [--persona-name ] \ + [--authority HumanVerified|TrustedAgent|Standard|BestEffort|Simulated|Raw] \ + [--momentum Background|Normal|Elevated|High|Critical|Raw] \ + [--named-dep "PR #NNNN "] \ + [--disposition bounded-wait|decomposing|committed-substrate|chose-free-time] \ + [--parent-pr NNNN] \ + [--push|--no-push] [--write-local|--no-write-local] \ + [--branch ] [--repo owner/name] +``` + +**Default behavior summary** (push semantics + write-local semantics): + +| Flags | push | writeLocal | Effect | Use case | +|---|---|---|---|---| +| (none) | true | false | REST push only; no local file | Autonomous tick; safe on dirty branches | +| `--write-local` | true | true | Both | Operator wants local copy too | +| `--no-push` | false | true | Local file only | Testing / diagnostic | +| `--no-push --no-write-local` | false | false | Nothing (exits 2) | Pointless; rejected | + +Push details: REST git-data API (blob → tree → commit → ref). The +REST step touches NO local git state — no index read/write, no +working-tree mutation, no current-branch dependency. Safe on dirty +branches with staged/unstaged work because the REST path doesn't +look at local git at all. The OPTIONAL `--write-local` step is the +only thing that touches the local worktree (writes one new untracked +`.md` file at the heartbeat path); default is `--no-write-local` +when pushing to keep dirty branches untouched. + +Target is `--branch` (default `agent-heartbeats`; pass `main` only if +operator has configured per-folder branch-protection exclusion). +Retries up to 5x on non-fast-forward (peer-agent push race window). +ZetaID-unique filenames give practical no-collision across concurrent +agents at autonomous-loop scale (see Layout note above for the +probability bound). + +## Push direct-to-main convention + +Per operator 2026-05-27: heartbeats target this folder for direct-to-main +push WITHOUT PR gating. Two operator-side options for the branch- +protection carve-out: + +**Option A — Folder path exclusion (this convention)**: + +Configure branch protection rules on `main` to EXCLUDE +`docs/agent-heartbeats/**` from required-PR + required-checks +enforcement. Per-tick heartbeat commits push directly. ZetaID +filenames guarantee no overlap across concurrent agents. + +**Option B — Separate branch**: + +Create a long-lived `agent-heartbeats` branch with NO protection +rules. Agents push directly to that branch. Lookups query via +`git log agent-heartbeats -- docs/agent-heartbeats//...`. +Main history stays clean of per-tick noise. + +Either option requires operator-side GitHub config; the writer tool +and folder convention are the same either way. The repo body picks +at deployment time; tooling is branch-name-agnostic. + +## Grep-based lookup (ZetaID bit-field indexing) + +Operator 2026-05-27: *"the ids are for easy lookup based many +different bit id indexes built into the bits themselves so we can +grep for things later"*. + +Bit fields within the ZetaID encode lookup dimensions: + +- bits 0-31 — randomness (collision-prevention; not for lookup) +- bits 35-42 — location (256 slots; route-fabric indexing) +- bits 43-50 — momentum (256 slots; criticality indexing) +- bits 51-58 — persona (256 slots; agent indexing) +- bits 59-63 — authority (32 slots; trust-tier indexing) +- bit 64 — firefly (V1: NoDirective=1) +- bits 65-68 — category (16 slots; **3 = Heartbeat**) +- bits 70-74 — chromosome (32 slots; trajectory indexing) +- bits 75-122 — timestamp (48-bit ms; temporal indexing) +- bits 123-127 — version (32 slots) + +Lookup queries can grep on hex patterns matching specific bit +positions. The writer tool emits 32-char zero-padded hex for +greppability. + +## Schema + +Each heartbeat file uses YAML frontmatter: + +```yaml +--- +zetaid: <32-char-hex> +category: 3 # Heartbeat per registry/categories.yaml +agent: +persona-slot: +timestamp: +authority: +momentum: +chromosome: +location: +firefly: NoDirective +disposition: +named-dep: "" +parent-pr: +--- + +Heartbeat from agent at . +``` + +## Composes with + +- `src/Core.TypeScript/zeta-id/zeta-id.ts` — 128-bit pack/unpack +- `registry/categories.yaml` — Heartbeat = category 3 +- `registry/personas.yaml` — persona slot enum (role-refs) +- `tools/hygiene/audit-agencysignature-main-tip.ts` — sibling discipline at substantive-commit scope +- CLAUDE.md "Heartbeat-via-commit" bullet (PR #5451) — discipline-level statement +- `.claude/rules/holding-without-named-dependency-is-standing-by-failure.md` — the rule this folder mechanizes +- `.claude/rules/agent-roster-reference-card.md` — persona-name canonical roster +- B-0858 backlog row — the substrate-engineering parent diff --git a/docs/agent-heartbeats/otto/2026/05/27/080cf34dbc457007a013000803955b96.md b/docs/agent-heartbeats/otto/2026/05/27/080cf34dbc457007a013000803955b96.md new file mode 100644 index 0000000000..eae334a413 --- /dev/null +++ b/docs/agent-heartbeats/otto/2026/05/27/080cf34dbc457007a013000803955b96.md @@ -0,0 +1,17 @@ +--- +zetaid: 080cf34dbc457007a013000803955b96 +category: 3 # Heartbeat per registry/categories.yaml +agent: otto +persona-slot: 2 +timestamp: 2026-05-27T13:54:56.302Z +authority: TrustedAgent +momentum: Normal +chromosome: 0 +location: 1 +firefly: NoDirective +disposition: committed-substrate +named-dep: "PR #5450 build-iso CI (long-running)" +parent-pr: 5450 +--- + +Heartbeat 080cf34dbc457007a013000803955b96 from agent otto at 2026-05-27T13:54:56.302Z. diff --git a/tools/agent-heartbeats/write-heartbeat.test.ts b/tools/agent-heartbeats/write-heartbeat.test.ts new file mode 100644 index 0000000000..86746e6c90 --- /dev/null +++ b/tools/agent-heartbeats/write-heartbeat.test.ts @@ -0,0 +1,232 @@ +// tools/agent-heartbeats/write-heartbeat.test.ts — B-0858.3 heartbeat-writer tests. + +import { describe, expect, it } from "bun:test"; +import { parseArgs, buildHeartbeatObservation, zetaIdToHex, heartbeatPath, heartbeatRepoRelPath, renderHeartbeat } from "./write-heartbeat"; +import { pack, DEFAULT_ENV } from "../../src/Core.TypeScript/zeta-id/zeta-id"; + +// Empty env for tests — exclude any harness-set ZETA_AGENT_* + disable +// auto-push (tests do not have network). +const TEST_ENV = { ZETA_AGENT_HEARTBEAT_NO_PUSH: "1" } as NodeJS.ProcessEnv; +const baseArgs: string[] = []; + +describe("parseArgs", () => { + it("zero args + empty env returns built-in defaults (stupid-simple per operator)", () => { + const r = parseArgs([], TEST_ENV); + if ("error" in r) throw new Error(r.error); + expect(r.personaSlot).toBe(2); + expect(r.personaName).toBe("otto"); + expect(r.authority).toBe("TrustedAgent"); + expect(r.momentum).toBe("Normal"); + expect(r.disposition).toBe("bounded-wait"); + expect(r.push).toBe(false); // disabled via TEST_ENV + expect(r.repo).toBe("Lucent-Financial-Group/Zeta"); + expect(r.branch).toBe("agent-heartbeats"); // default per operator 2026-05-27 + }); + + it("env vars override built-in defaults", () => { + const r = parseArgs([], { + ZETA_AGENT_PERSONA_SLOT: "5", + ZETA_AGENT_PERSONA_NAME: "alexa", + ZETA_AGENT_AUTHORITY: "Standard", + ZETA_AGENT_HEARTBEAT_NO_PUSH: "1", + }); + if ("error" in r) throw new Error(r.error); + expect(r.personaSlot).toBe(5); + expect(r.personaName).toBe("alexa"); + expect(r.authority).toBe("Standard"); + }); + + it("CLI flag overrides env var", () => { + const r = parseArgs(["--persona-name", "riven"], { + ZETA_AGENT_PERSONA_NAME: "alexa", + ZETA_AGENT_HEARTBEAT_NO_PUSH: "1", + }); + if ("error" in r) throw new Error(r.error); + expect(r.personaName).toBe("riven"); + }); + + it("accepts all flags", () => { + const r = parseArgs([ + ...baseArgs, + "--authority", "Standard", + "--momentum", "Elevated", + "--chromosome", "7", + "--location", "2", + "--named-dep", "PR #5450 build-iso", + "--disposition", "committed-substrate", + "--parent-pr", "5450", + "--dry-run", + "--no-push", + ], TEST_ENV); + if ("error" in r) throw new Error(r.error); + expect(r.authority).toBe("Standard"); + expect(r.momentum).toBe("Elevated"); + expect(r.chromosome).toBe(7); + expect(r.location).toBe(2); + expect(r.namedDep).toBe("PR #5450 build-iso"); + expect(r.disposition).toBe("committed-substrate"); + expect(r.parentPr).toBe(5450); + expect(r.dryRun).toBe(true); + expect(r.push).toBe(false); + }); + + it("--push overrides ZETA_AGENT_HEARTBEAT_NO_PUSH=1", () => { + const r = parseArgs(["--push"], TEST_ENV); + if ("error" in r) throw new Error(r.error); + expect(r.push).toBe(true); + }); + + it("rejects out-of-range persona-slot", () => { + expect("error" in parseArgs(["--persona-slot", "256"], TEST_ENV)).toBe(true); + expect("error" in parseArgs(["--persona-slot", "-1"], TEST_ENV)).toBe(true); + }); + + it("rejects invalid persona-name (non-kebab-case)", () => { + expect("error" in parseArgs(["--persona-name", "Otto"], TEST_ENV)).toBe(true); + expect("error" in parseArgs(["--persona-name", "otto_cli"], TEST_ENV)).toBe(true); + expect("error" in parseArgs(["--persona-name", "../etc"], TEST_ENV)).toBe(true); + }); + + it("rejects unknown flag", () => { + expect("error" in parseArgs(["--bogus"], TEST_ENV)).toBe(true); + }); + + it("rejects non-integer numeric flags (NaN guard)", () => { + expect("error" in parseArgs(["--chromosome", "foo"], TEST_ENV)).toBe(true); + expect("error" in parseArgs(["--persona-slot", "abc"], TEST_ENV)).toBe(true); + expect("error" in parseArgs(["--location", "1.5"], TEST_ENV)).toBe(true); + expect("error" in parseArgs(["--parent-pr", "x"], TEST_ENV)).toBe(true); + }); + + it("rejects unknown authority/momentum tags", () => { + expect("error" in parseArgs(["--authority", "Bogus"], TEST_ENV)).toBe(true); + expect("error" in parseArgs(["--momentum", "Hyper"], TEST_ENV)).toBe(true); + }); + + it("accepts known authority + momentum enum values", () => { + for (const auth of ["HumanVerified", "TrustedAgent", "Standard", "BestEffort", "Simulated", "Raw"]) { + const r = parseArgs(["--authority", auth], TEST_ENV); + expect("error" in r).toBe(false); + } + for (const mom of ["Background", "Normal", "Elevated", "High", "Critical", "Raw"]) { + const r = parseArgs(["--momentum", mom], TEST_ENV); + expect("error" in r).toBe(false); + } + }); + + it("writeLocal default: push=true → false; push=false → true", () => { + const pushArgs = parseArgs(["--push"], TEST_ENV); + if ("error" in pushArgs) throw new Error(pushArgs.error); + expect(pushArgs.push).toBe(true); + expect(pushArgs.writeLocal).toBe(false); // safe on dirty branches + + const noPushArgs = parseArgs([], TEST_ENV); // TEST_ENV sets NO_PUSH=1 + if ("error" in noPushArgs) throw new Error(noPushArgs.error); + expect(noPushArgs.push).toBe(false); + expect(noPushArgs.writeLocal).toBe(true); // else nothing happens + }); + + it("--write-local explicit override", () => { + const r = parseArgs(["--push", "--write-local"], TEST_ENV); + if ("error" in r) throw new Error(r.error); + expect(r.push).toBe(true); + expect(r.writeLocal).toBe(true); + }); + + it("--no-write-local explicit override", () => { + const r = parseArgs(["--no-push", "--no-write-local"], TEST_ENV); + if ("error" in r) throw new Error(r.error); + expect(r.push).toBe(false); + expect(r.writeLocal).toBe(false); + }); +}); + +describe("heartbeatRepoRelPath", () => { + it("uses POSIX separators regardless of host OS", () => { + const ts = Date.UTC(2026, 4, 27, 13, 30, 0); + const p = heartbeatRepoRelPath("otto", ts, "abc123"); + expect(p).toBe("docs/agent-heartbeats/otto/2026/05/27/abc123.md"); + expect(p.includes("\\")).toBe(false); + }); +}); + +describe("buildHeartbeatObservation", () => { + it("sets category=3 (Heartbeat per registry)", () => { + const args = parseArgs(baseArgs, TEST_ENV); + if ("error" in args) throw new Error(args.error); + const obs = buildHeartbeatObservation(args, 1234567890); + expect((obs as any).category).toBe(3); + expect((obs as any).firefly).toBe(1); + expect((obs as any).version).toBe(1); + expect((obs as any).persona).toBe(2); + }); +}); + +describe("zetaIdToHex", () => { + it("pads to 32 hex chars", () => { + expect(zetaIdToHex(0n)).toHaveLength(32); + expect(zetaIdToHex(0n)).toBe("0".repeat(32)); + expect(zetaIdToHex(0xFFn)).toBe("0".repeat(30) + "ff"); + }); + + it("renders large bigint correctly", () => { + const big = (1n << 127n) | 0xdeadbeefn; + const hex = zetaIdToHex(big); + expect(hex).toHaveLength(32); + expect(hex.endsWith("deadbeef")).toBe(true); + expect(hex.startsWith("8")).toBe(true); + }); +}); + +describe("heartbeatPath", () => { + it("builds YYYY/MM/DD path with hex filename", () => { + const ts = Date.UTC(2026, 4, 27, 13, 30, 0); // month 0-indexed (4=May) + const path = heartbeatPath("/repo", "otto", ts, "abc123"); + expect(path).toBe("/repo/docs/agent-heartbeats/otto/2026/05/27/abc123.md"); + }); + + it("pads month and day", () => { + const ts = Date.UTC(2026, 0, 5, 0, 0, 0); // 2026-01-05 + const path = heartbeatPath("/repo", "otto", ts, "x"); + expect(path).toBe("/repo/docs/agent-heartbeats/otto/2026/01/05/x.md"); + }); +}); + +describe("renderHeartbeat", () => { + it("produces valid frontmatter with required fields", () => { + const args = parseArgs([...baseArgs, "--named-dep", "PR #5450 CI", "--parent-pr", "5450"], TEST_ENV); + if ("error" in args) throw new Error(args.error); + const body = renderHeartbeat(args, "abc", 1779168600000); + expect(body).toContain("---"); + expect(body).toContain("zetaid: abc"); + expect(body).toContain("category: 3"); + expect(body).toContain("agent: otto"); + expect(body).toContain("persona-slot: 2"); + expect(body).toContain("named-dep: \"PR #5450 CI\""); + expect(body).toContain("parent-pr: 5450"); + expect(body).toContain("firefly: NoDirective"); + }); + + it("omits optional fields when not provided", () => { + const args = parseArgs(baseArgs, TEST_ENV); + if ("error" in args) throw new Error(args.error); + const body = renderHeartbeat(args, "abc", 1779168600000); + expect(body).not.toContain("named-dep:"); + expect(body).not.toContain("parent-pr:"); + }); +}); + +describe("end-to-end pack with DEFAULT_ENV", () => { + it("packs without throwing; category bit field round-trips", () => { + const args = parseArgs(baseArgs, TEST_ENV); + if ("error" in args) throw new Error(args.error); + const obs = buildHeartbeatObservation(args, Date.now()); + const id = pack(obs, DEFAULT_ENV); + expect(typeof id).toBe("bigint"); + const hex = zetaIdToHex(id); + expect(hex).toHaveLength(32); + // Category bits at offset 65 width 4 = bits 65..68 inclusive + const categoryBits = (id >> 65n) & 0xFn; + expect(Number(categoryBits)).toBe(3); + }); +}); diff --git a/tools/agent-heartbeats/write-heartbeat.ts b/tools/agent-heartbeats/write-heartbeat.ts new file mode 100755 index 0000000000..23253e6e9d --- /dev/null +++ b/tools/agent-heartbeats/write-heartbeat.ts @@ -0,0 +1,393 @@ +#!/usr/bin/env bun +// tools/agent-heartbeats/write-heartbeat.ts — B-0858.3 heartbeat writer. +// +// Composes existing substrate: +// - src/Core.TypeScript/zeta-id/zeta-id.ts (B-0666 ZetaID v1; pack/unpack) +// - registry/categories.yaml (Category=3=Heartbeat) +// - registry/personas.yaml (role-ref slots) +// - tools/hygiene/audit-agencysignature-main-tip.ts (AgencySignature audit) +// - CLAUDE.md "Heartbeat-via-commit = externalized idle counter" bullet (PR #5451) +// +// Per operator 2026-05-27: heartbeats use ZetaID category=3; bit-field +// grep indexing extracts subsets by persona/authority/momentum/etc. +// +// Per operator 2026-05-27 follow-up: heartbeats can go in a folder OR +// branch that skips CI + branch protection. This writer targets the +// FOLDER convention (docs/agent-heartbeats//YYYY/MM/DD/.md). +// Operator-side branch-protection path-exclusion is required to allow +// direct-to-main push without PR gating; see README at folder root for +// the alternative branch-based pattern. +// +// Usage: +// bun tools/agent-heartbeats/write-heartbeat.ts \ +// --persona-slot 2 --persona-name otto \ +// [--authority TrustedAgent] [--momentum Normal] \ +// [--chromosome 0] [--location 1] \ +// --named-dep "PR #5450 build-iso completion (~5min ETA)" \ +// --disposition bounded-wait \ +// [--parent-pr 5450] +// +// Exit codes: +// 0 success (heartbeat written; path printed to stdout) +// 2 arg-parse error +// 3 local write failure +// 4 REST push failure (--push only) + +import { writeFileSync, mkdirSync } from "node:fs"; +import { posix } from "node:path"; +import { join, dirname } from "node:path"; +import { spawnSync } from "node:child_process"; +import { pack, DEFAULT_ENV } from "../../src/Core.TypeScript/zeta-id/zeta-id"; +import type { ZetaObservation, Authority, Momentum } from "../../src/Core.TypeScript/zeta-id/types"; + +interface Args { + readonly personaSlot: number; + readonly personaName: string; + readonly authority: Authority["type"]; + readonly momentum: Momentum["type"]; + readonly chromosome: number; + readonly location: number; + readonly namedDep: string | null; + readonly disposition: string; + readonly parentPr: number | null; + readonly repoRoot: string; + readonly dryRun: boolean; + readonly push: boolean; + readonly writeLocal: boolean; // when push=true, default false to keep dirty branches clean + readonly repo: string; // "owner/name" for REST push (default Lucent-Financial-Group/Zeta) + readonly branch: string; // target branch (HARDCODED default "agent-heartbeats") +} + +/** + * Stupid-simple defaults per operator 2026-05-27: "in a perfect world you + * don't even need to pass parametrs it just works". Env-var fallback for + * every field; sensible defaults for autonomous-loop common case. Each + * field can be overridden by CLI flag for non-default scenarios. + * + * Env-var precedence: CLI flag > env var > built-in default. + */ +/** Strict int parse — returns null for NaN / non-integer / out-of-range. */ +function parseIntStrict(s: string): number | null { + const n = Number(s); + return Number.isInteger(n) ? n : null; +} + +const KNOWN_AUTHORITIES: ReadonlyArray = [ + "HumanVerified", "TrustedAgent", "Standard", "BestEffort", "Simulated", "Raw", +]; +const KNOWN_MOMENTUM: ReadonlyArray = [ + "Background", "Normal", "Elevated", "High", "Critical", "Raw", +]; + +export function parseArgs(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): Args | { readonly error: string } { + // Defaults derived from env vars (set once per agent session by harness). + // parseIntStrict catches NaN from non-numeric env values; falls through to default. + let personaSlot = env.ZETA_AGENT_PERSONA_SLOT ? (parseIntStrict(env.ZETA_AGENT_PERSONA_SLOT) ?? 2) : 2; + let personaName = env.ZETA_AGENT_PERSONA_NAME ?? "otto"; + let authority: Authority["type"] = (env.ZETA_AGENT_AUTHORITY as Authority["type"]) ?? "TrustedAgent"; + let momentum: Momentum["type"] = (env.ZETA_AGENT_MOMENTUM as Momentum["type"]) ?? "Normal"; + let chromosome = env.ZETA_AGENT_CHROMOSOME ? (parseIntStrict(env.ZETA_AGENT_CHROMOSOME) ?? 0) : 0; + let location = env.ZETA_AGENT_LOCATION ? (parseIntStrict(env.ZETA_AGENT_LOCATION) ?? 1) : 1; + let namedDep: string | null = env.ZETA_AGENT_NAMED_DEP ?? null; + let disposition = env.ZETA_AGENT_DISPOSITION ?? "bounded-wait"; + let parentPr: number | null = env.ZETA_AGENT_PARENT_PR ? parseIntStrict(env.ZETA_AGENT_PARENT_PR) : null; + let repoRoot = env.ZETA_AGENT_REPO_ROOT ?? process.cwd(); + let dryRun = false; + // Default --push=TRUE per operator 2026-05-27 "stupid simple" direction: + // perfect-world heartbeat is local-write + remote-push together. Tests + + // diagnostic runs opt-OUT via --no-push. + let push = env.ZETA_AGENT_HEARTBEAT_NO_PUSH !== "1"; + // Default writeLocal=FALSE when pushing (operator 2026-05-27): safe on + // dirty branches because no local file is created in the current + // worktree. REST push is the source of truth; --write-local opts in + // when operator wants the local copy too (for in-worktree grep). + // With --no-push, writeLocal flips to TRUE (else nothing happens). + let writeLocalExplicit: boolean | null = null; // null=auto (push→false, no-push→true) + let repo = env.ZETA_AGENT_REPO ?? "Lucent-Financial-Group/Zeta"; + // HARDCODED default branch is "agent-heartbeats" (operator 2026-05-27 + // "hard code the correct branch name for heartbeats into the script for + // it's defualt"): bypasses the 4 main-only rulesets (Branch Safety / + // CI Gate / Default / Review Policy) — direct-push succeeds without any + // ruleset modification; non-PR (no accidental velocity); main commit log + // stays clean. Override via ZETA_AGENT_BRANCH env var or --branch CLI. + let branch = env.ZETA_AGENT_BRANCH ?? "agent-heartbeats"; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + const next = (): string => { + if (i + 1 >= argv.length) throw new Error(`${arg} requires a value`); + return argv[++i]!; + }; + try { + if (arg === "--persona-slot") { const v = parseIntStrict(next()); if (v === null) return { error: "--persona-slot must be integer" }; personaSlot = v; } + else if (arg === "--persona-name") personaName = next(); + else if (arg === "--authority") authority = next() as Authority["type"]; + else if (arg === "--momentum") momentum = next() as Momentum["type"]; + else if (arg === "--chromosome") { const v = parseIntStrict(next()); if (v === null) return { error: "--chromosome must be integer" }; chromosome = v; } + else if (arg === "--location") { const v = parseIntStrict(next()); if (v === null) return { error: "--location must be integer" }; location = v; } + else if (arg === "--named-dep") namedDep = next(); + else if (arg === "--disposition") disposition = next(); + else if (arg === "--parent-pr") { const v = parseIntStrict(next()); if (v === null) return { error: "--parent-pr must be integer" }; parentPr = v; } + else if (arg === "--repo-root") repoRoot = next(); + else if (arg === "--dry-run") dryRun = true; + else if (arg === "--push") push = true; + else if (arg === "--no-push") push = false; + else if (arg === "--write-local") writeLocalExplicit = true; + else if (arg === "--no-write-local") writeLocalExplicit = false; + else if (arg === "--repo") repo = next(); + else if (arg === "--branch") branch = next(); + else return { error: `unknown flag: ${arg}` }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + } + if (personaSlot < 0 || personaSlot > 255) return { error: "persona-slot must be 0..255 (set ZETA_AGENT_PERSONA_SLOT or --persona-slot)" }; + if (!/^[a-z][a-z0-9-]*$/.test(personaName)) return { error: "persona-name must match /^[a-z][a-z0-9-]*$/ (set ZETA_AGENT_PERSONA_NAME or --persona-name)" }; + if (chromosome < 0 || chromosome > 31) return { error: "--chromosome must be 0..31" }; + if (location < 0 || location > 255) return { error: "--location must be 0..255" }; + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) return { error: "--repo must match owner/name" }; + if (!KNOWN_AUTHORITIES.includes(authority)) return { error: `--authority must be one of ${KNOWN_AUTHORITIES.join(",")}` }; + if (!KNOWN_MOMENTUM.includes(momentum)) return { error: `--momentum must be one of ${KNOWN_MOMENTUM.join(",")}` }; + // Default writeLocal: push=true → false (dirty-branch-safe); push=false → true (else nothing happens) + const writeLocal = writeLocalExplicit ?? !push; + return { personaSlot, personaName, authority, momentum, chromosome, location, namedDep, disposition, parentPr, repoRoot, dryRun, push, writeLocal, repo, branch }; +} + +/** + * Push a single heartbeat file direct-to-main via GitHub REST git-data API. + * Bypasses local git entirely — no staged/unstaged files touched, no working-tree + * mutation. ZetaID-unique filenames guarantee no concurrent-agent collision; the + * REST PATCH ref step retries on non-fast-forward (peer agent pushed between + * blob+tree+commit creation and ref update). + * + * Per .claude/rules/refresh-world-model-poll-pr-gate.md REST git-data API + * bypass discipline (B-0615 pattern). + */ +export function pushHeartbeatViaRest( + repo: string, + branch: string, + filePath: string, // repo-relative path (e.g., "docs/agent-heartbeats/otto/2026/05/27/abc.md") + fileContent: string, + commitMessage: string, + maxRetries = 5, +): { readonly ok: { readonly commitSha: string; readonly url: string } } | { readonly error: string } { + function gh(args: string[], input?: string): { status: number; stdout: string; stderr: string } { + const result = spawnSync("gh", args, { + input, + encoding: "utf8", + maxBuffer: 4 * 1024 * 1024, + }); + return { status: result.status ?? -1, stdout: result.stdout, stderr: result.stderr }; + } + + // Step 1: create blob from file content + // eslint-disable-next-line sonarjs/no-os-command-from-path + const blobReq = gh( + ["api", "-X", "POST", `repos/${repo}/git/blobs`, "--input", "-"], + JSON.stringify({ content: fileContent, encoding: "utf-8" }), + ); + if (blobReq.status !== 0) return { error: `blob create failed: ${blobReq.stderr || blobReq.stdout}` }; + let blobSha: string; + try { + blobSha = JSON.parse(blobReq.stdout).sha; + } catch (err) { + return { error: `blob response parse failed: ${err instanceof Error ? err.message : String(err)}` }; + } + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + // Step 2: get parent ref + commit + tree + // eslint-disable-next-line sonarjs/no-os-command-from-path + const refReq = gh(["api", `repos/${repo}/git/ref/heads/${branch}`]); + if (refReq.status !== 0) return { error: `ref read failed: ${refReq.stderr || refReq.stdout}` }; + let parentCommitSha: string; + try { + parentCommitSha = JSON.parse(refReq.stdout).object.sha; + } catch (err) { + return { error: `ref response parse failed: ${err instanceof Error ? err.message : String(err)}` }; + } + + // eslint-disable-next-line sonarjs/no-os-command-from-path + const commitReq = gh(["api", `repos/${repo}/git/commits/${parentCommitSha}`]); + if (commitReq.status !== 0) return { error: `commit read failed: ${commitReq.stderr || commitReq.stdout}` }; + let parentTreeSha: string; + try { + parentTreeSha = JSON.parse(commitReq.stdout).tree.sha; + } catch (err) { + return { error: `commit response parse failed: ${err instanceof Error ? err.message : String(err)}` }; + } + + // Step 3: create tree (base=parent_tree; one new entry) + // eslint-disable-next-line sonarjs/no-os-command-from-path + const treeReq = gh( + ["api", "-X", "POST", `repos/${repo}/git/trees`, "--input", "-"], + JSON.stringify({ + base_tree: parentTreeSha, + tree: [{ path: filePath, mode: "100644", type: "blob", sha: blobSha }], + }), + ); + if (treeReq.status !== 0) return { error: `tree create failed: ${treeReq.stderr || treeReq.stdout}` }; + let newTreeSha: string; + try { + newTreeSha = JSON.parse(treeReq.stdout).sha; + } catch (err) { + return { error: `tree response parse failed: ${err instanceof Error ? err.message : String(err)}` }; + } + + // Step 4: create commit + // eslint-disable-next-line sonarjs/no-os-command-from-path + const newCommitReq = gh( + ["api", "-X", "POST", `repos/${repo}/git/commits`, "--input", "-"], + JSON.stringify({ + message: commitMessage, + tree: newTreeSha, + parents: [parentCommitSha], + }), + ); + if (newCommitReq.status !== 0) return { error: `commit create failed: ${newCommitReq.stderr || newCommitReq.stdout}` }; + let newCommitSha: string; + let commitUrl: string; + try { + const parsed = JSON.parse(newCommitReq.stdout); + newCommitSha = parsed.sha; + commitUrl = parsed.html_url; + } catch (err) { + return { error: `commit-create response parse failed: ${err instanceof Error ? err.message : String(err)}` }; + } + + // Step 5: fast-forward ref (PATCH refs/heads/) + // eslint-disable-next-line sonarjs/no-os-command-from-path + const refUpdateReq = gh( + ["api", "-X", "PATCH", `repos/${repo}/git/refs/heads/${branch}`, "--input", "-"], + JSON.stringify({ sha: newCommitSha, force: false }), + ); + if (refUpdateReq.status === 0) { + return { ok: { commitSha: newCommitSha, url: commitUrl } }; + } + // Non-fast-forward → peer agent pushed; retry steps 2-5 with fresh parent + if (attempt < maxRetries) continue; + return { error: `ref update failed after ${maxRetries} attempts: ${refUpdateReq.stderr || refUpdateReq.stdout}` }; + } + return { error: `ref update exhausted ${maxRetries} retries` }; +} + +/** Build the ZetaObservation for a heartbeat (category=Heartbeat=3, firefly=NoDirective=1, version=1). */ +export function buildHeartbeatObservation(args: Args, timestampMs: number): ZetaObservation { + return { + version: 1, + timestamp: timestampMs as ZetaObservation["timestamp"], + chromosome: args.chromosome, + category: 3, // Heartbeat per registry/categories.yaml + firefly: 1, // NoDirective per registry/firefly-cases.yaml V1 case-of-one + authority: { type: args.authority }, + persona: args.personaSlot, + momentum: { type: args.momentum }, + location: args.location, + } as unknown as ZetaObservation; +} + +/** Format ZetaId bigint as 32-char zero-padded hex (collision-friendly filename). */ +export function zetaIdToHex(id: bigint): string { + return id.toString(16).padStart(32, "0"); +} + +/** Compose heartbeat file path: docs/agent-heartbeats//YYYY/MM/DD/.md */ +export function heartbeatPath(repoRoot: string, personaName: string, timestampMs: number, idHex: string): string { + const d = new Date(timestampMs); + const yyyy = d.getUTCFullYear().toString(); + const mm = (d.getUTCMonth() + 1).toString().padStart(2, "0"); + const dd = d.getUTCDate().toString().padStart(2, "0"); + return join(repoRoot, "docs", "agent-heartbeats", personaName, yyyy, mm, dd, `${idHex}.md`); +} + +/** Render the heartbeat markdown body. */ +export function renderHeartbeat(args: Args, idHex: string, timestampMs: number): string { + const ts = new Date(timestampMs).toISOString(); + const lines = [ + `---`, + `zetaid: ${idHex}`, + `category: 3 # Heartbeat per registry/categories.yaml`, + `agent: ${args.personaName}`, + `persona-slot: ${args.personaSlot}`, + `timestamp: ${ts}`, + `authority: ${args.authority}`, + `momentum: ${args.momentum}`, + `chromosome: ${args.chromosome}`, + `location: ${args.location}`, + `firefly: NoDirective`, + `disposition: ${args.disposition}`, + ]; + if (args.namedDep) lines.push(`named-dep: ${JSON.stringify(args.namedDep)}`); + if (args.parentPr !== null) lines.push(`parent-pr: ${args.parentPr}`); + lines.push(`---`); + lines.push(``); + lines.push(`Heartbeat ${idHex} from agent ${args.personaName} at ${ts}.`); + return lines.join("\n") + "\n"; +} + +/** Build the repo-relative path for REST (always POSIX separators regardless of host OS). */ +export function heartbeatRepoRelPath(personaName: string, timestampMs: number, idHex: string): string { + const d = new Date(timestampMs); + const yyyy = d.getUTCFullYear().toString(); + const mm = (d.getUTCMonth() + 1).toString().padStart(2, "0"); + const dd = d.getUTCDate().toString().padStart(2, "0"); + // posix.join guarantees forward-slashes even on Windows hosts + return posix.join("docs", "agent-heartbeats", personaName, yyyy, mm, dd, `${idHex}.md`); +} + +async function main(): Promise { + const argv = process.argv.slice(2); + const parsed = parseArgs(argv); + if ("error" in parsed) { + console.error(`write-heartbeat: ${parsed.error}`); + return 2; + } + const timestampMs = Date.now(); + const obs = buildHeartbeatObservation(parsed, timestampMs); + const id = pack(obs, DEFAULT_ENV); + const idHex = zetaIdToHex(id); + const localPath = heartbeatPath(parsed.repoRoot, parsed.personaName, timestampMs, idHex); + const repoRelPath = heartbeatRepoRelPath(parsed.personaName, timestampMs, idHex); + const body = renderHeartbeat(parsed, idHex, timestampMs); + if (parsed.dryRun) { + console.log(`DRY RUN — id ${idHex}`); + if (parsed.writeLocal) console.log(`DRY RUN — would write local: ${localPath}`); + if (parsed.push) console.log(`DRY RUN — would push: ${parsed.repo} branch=${parsed.branch} path=${repoRelPath}`); + if (!parsed.writeLocal && !parsed.push) console.log(`DRY RUN — both --no-write-local + --no-push = no-op`); + return 0; + } + if (parsed.writeLocal) { + try { + mkdirSync(dirname(localPath), { recursive: true }); + writeFileSync(localPath, body); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`write-heartbeat: local write failed: ${msg}`); + return 3; + } + console.log(localPath); + } + if (parsed.push) { + // REST git-data API push (blob → tree → commit → ref). The REST step + // touches NO local git state — no index read/write, no working-tree + // mutation, no current-branch dependency. Safe on dirty branches with + // staged/unstaged work because nothing local is touched by this step. + // ZetaID filename uniqueness prevents concurrent-agent collision; 5x + // retry on non-fast-forward handles race window between parent-ref + // read + ref update. + const commitMsg = `heartbeat(${parsed.personaName}): ${idHex} (${parsed.disposition}${parsed.parentPr !== null ? `; PR #${parsed.parentPr}` : ""})`; + const result = pushHeartbeatViaRest(parsed.repo, parsed.branch, repoRelPath, body, commitMsg); + if ("error" in result) { + console.error(`write-heartbeat: REST push failed: ${result.error}`); + return 4; + } + console.log(`pushed: ${result.ok.url}`); + } + if (!parsed.writeLocal && !parsed.push) { + console.error(`write-heartbeat: both --no-write-local + --no-push = no-op (this is probably not what you wanted)`); + return 2; + } + return 0; +} + +if (import.meta.main) { + main().then((code) => process.exit(code)); +}