From d0d529cceccef2b03cab1f2ed8c2314c572bc913 Mon Sep 17 00:00:00 2001 From: Lior Date: Wed, 27 May 2026 09:57:52 -0400 Subject: [PATCH 1/6] feat(B-0858.3): heartbeat-writer with REST direct-push + folder seed + AGENTS.md discipline + first heartbeat (Aaron 2026-05-27 USB push + heartbeat substrate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end heartbeat substrate landed in one PR (operator 2026-05-27 direction: "make sure it gets used" + "stale push" [straight push] to main without disturbing other staged/unstaged files): **Writer tool — tools/agent-heartbeats/write-heartbeat.ts**: - Composes src/Core.TypeScript/zeta-id/zeta-id.ts pack/unpack - Sets ZetaID category bits to 3 (Heartbeat per registry/categories.yaml) - Writes docs/agent-heartbeats//YYYY/MM/DD/<32-char-hex>.md - Optional --push flag: REST git-data API direct-push (blob → tree → commit → ref); bypasses local git index + working tree ENTIRELY so no staged/unstaged files disturbed; --branch picks main or agent-heartbeats; ZetaID-unique filenames prevent concurrent-agent collision per operator confirmation; 5x retry on non-fast-forward - Composes with .claude/rules/refresh-world-model-poll-pr-gate.md REST git-data API bypass discipline (B-0615 pattern) **Tests — tools/agent-heartbeats/write-heartbeat.test.ts**: 15 unit tests covering parseArgs (validation + edge cases), buildHeartbeatObservation (category=3 verification), zetaIdToHex (padding + large bigints), heartbeatPath (UTC date math + zero-padding), renderHeartbeat (frontmatter completeness), end-to-end pack with DEFAULT_ENV (category bit-field round-trip). REST push function pushHeartbeatViaRest is exported but not unit- tested (requires gh auth + network); empirical validation comes from the seed heartbeat below. **Folder README — docs/agent-heartbeats/README.md**: Documents layout, schema, both deployment options (folder-on-main with path-scoped branch-protection-exception OR separate agent- heartbeats branch with NO protection), bit-field grep-lookup patterns per operator's 2026-05-27 "easy lookup based many different bit id indexes built into the bits themselves" framing. **AGENTS.md update**: Added heartbeat-via-commit discipline to "Agent operational practices" — every autonomous-loop tick produces EITHER a substantive commit (AgencySignature v1 trailer; PR-gated) OR a lightweight heartbeat record (this writer; direct-push). Neither + no named-dep = the standing-by failure mode. Cites Kira's 2026-05-27 100+ "Quiet." catch as empirical anchor for why the narrative self-model counter is unreliable. **Seed heartbeat — docs/agent-heartbeats/otto/2026/05/27/080cf34dbc457007a013000803955b96.md**: First actual heartbeat written using the writer tool. Dogfood + validates the folder convention end-to-end at landing time. ZetaID 080cf34dbc457007a013000803955b96 (category=3=Heartbeat verified; persona-slot=2; authority=TrustedAgent; momentum=Normal; disposition=committed-substrate; parent-pr=5450). Composes with: - B-0666 ZetaID v1 (src/Core.TypeScript/zeta-id/) - AgencySignature Convention v1 (sibling discipline at substantive- commit scope; this writer is the lightweight tick-cadence sibling) - registry/categories.yaml Heartbeat=3 - registry/personas.yaml role-ref slots - CLAUDE.md "Heartbeat-via-commit" bullet (PR #5451 — discipline statement; this PR is the mechanization) - B-0858 row (PR #5456 — substrate-engineering parent) - .claude/rules/holding-without-named-dependency-is-standing-by-failure.md (the rule this writer mechanizes the externalized counter for) Per operator 2026-05-27 "make sure it gets used" direction: AGENTS.md change makes the discipline mandatory at the contributor- handbook scope; not optional; future Otto/Alexa/Riven/Vera/Lior cold-boots inherit at session start. Operator-side note: to enable direct-push on main, configure branch protection rule pattern exclusion for docs/agent-heartbeats/** OR create an agent-heartbeats branch with NO protection. Tool works either way via --branch flag. Per .claude/rules/agent-worktree-hygiene-never-hold-main-...: isolated worktree at /private/tmp/zeta-heartbeat-substrate-1330z; operator primary checkout untouched. Per .claude/rules/non-coercion-invariant.md HC-8: heartbeats are observational metadata; no secrets in heartbeat files; operator- observable transparency-by-construction. Per .claude/rules/methodology-hard-limits.md: clinical/security floor operative; heartbeat substrate is defensive operator- observability infrastructure; no offensive use. Agency-Signature-Version: 1 Agent: Otto Agent-Runtime: Claude Code (auto mode) Agent-Model: claude-opus-4-7 Credential-Identity: aaron-otto-vscode Credential-Mode: operator-authorized Human-Review: pre-merge-pending Human-Review-Evidence: operator-direction-2026-05-27-make-sure-it-gets-used-plus-direct-push Action-Mode: substrate-implementation-plus-discipline-mechanization Task: B-0858.3 Co-Authored-By: Claude Opus 4.7 --- AGENTS.md | 25 ++ docs/agent-heartbeats/README.md | 128 +++++++ .../05/27/080cf34dbc457007a013000803955b96.md | 17 + .../agent-heartbeats/write-heartbeat.test.ts | 146 ++++++++ tools/agent-heartbeats/write-heartbeat.ts | 326 ++++++++++++++++++ 5 files changed, 642 insertions(+) create mode 100644 docs/agent-heartbeats/README.md create mode 100644 docs/agent-heartbeats/otto/2026/05/27/080cf34dbc457007a013000803955b96.md create mode 100644 tools/agent-heartbeats/write-heartbeat.test.ts create mode 100644 tools/agent-heartbeats/write-heartbeat.ts diff --git a/AGENTS.md b/AGENTS.md index cd14f7751f..431fa43571 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -263,6 +263,31 @@ 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 at + `docs/agent-heartbeats////
/.md` + via `bun tools/agent-heartbeats/write-heartbeat.ts` (composes + with `src/Core.TypeScript/zeta-id/zeta-id.ts` 128-bit ZetaID + + `registry/categories.yaml` Heartbeat = category 3). Neither + + no named-dependency = 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 per the 2026-05-27 + Kira review (Otto-CLI emitted 100+ "Quiet." without the + counter firing because the agent cannot count itself); + the externalized counter is git log over + `docs/agent-heartbeats//` + per-commit AgencySignature + trailer presence on origin/main. Heartbeat files target a + branch-protection-excluded folder (or alternative + `agent-heartbeats` branch with no protection) so per-tick + push is direct-to-main without PR overhead; ZetaID + uniqueness guarantees no collision across concurrent agents. - 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..1faf9790d1 --- /dev/null +++ b/docs/agent-heartbeats/README.md @@ -0,0 +1,128 @@ +# 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 +``` + +- `` = canonical agent name per `.claude/rules/agent-roster-reference-card.md` +- `` = 32-char zero-padded hex of the 128-bit ZetaID with + `category = 3` (Heartbeat per `registry/categories.yaml`) +- Collision-free by construction (32-bit randomness + 48-bit timestamp + + 8-bit persona slot in the ZetaID) + +## Writing + +```bash +bun tools/agent-heartbeats/write-heartbeat.ts \ + --persona-slot \ + --persona-name \ + [--authority TrustedAgent|Standard|BestEffort|...] \ + [--momentum Normal|Elevated|High|...] \ + [--named-dep "PR #NNNN "] \ + [--disposition bounded-wait|decomposing|committed-substrate|chose-free-time] \ + [--parent-pr NNNN] \ + [--push] [--branch ] [--repo owner/name] +``` + +Writes one heartbeat record + prints its path to stdout. ZetaID +generated via `pack(obs, DEFAULT_ENV)` from +`src/Core.TypeScript/zeta-id/zeta-id.ts`. + +With `--push`, also pushes to the remote via GitHub REST git-data API +(blob → tree → commit → ref). Target is `--branch` (default `main`; +pass `agent-heartbeats` for the dedicated-branch variant). REST path +bypasses local git index + working tree entirely — no staged/unstaged +files disturbed, no current-branch state read. Retries up to 5x on +non-fast-forward (peer-agent push race window). ZetaID-unique +filenames guarantee no concurrent-agent collision on either branch +target. + +## 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 ++ 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..50d5c1c9d1 --- /dev/null +++ b/tools/agent-heartbeats/write-heartbeat.test.ts @@ -0,0 +1,146 @@ +// 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, renderHeartbeat } from "./write-heartbeat"; +import { pack, DEFAULT_ENV } from "../../src/Core.TypeScript/zeta-id/zeta-id"; + +const baseArgs = ["--persona-slot", "2", "--persona-name", "otto"]; + +describe("parseArgs", () => { + it("accepts minimal required flags", () => { + const r = parseArgs(baseArgs); + 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"); + }); + + 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", + ]); + 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); + }); + + it("rejects missing --persona-slot", () => { + expect("error" in parseArgs(["--persona-name", "otto"])).toBe(true); + }); + + it("rejects missing --persona-name", () => { + expect("error" in parseArgs(["--persona-slot", "2"])).toBe(true); + }); + + it("rejects out-of-range persona-slot", () => { + expect("error" in parseArgs(["--persona-slot", "256", "--persona-name", "otto"])).toBe(true); + expect("error" in parseArgs(["--persona-slot", "-1", "--persona-name", "otto"])).toBe(true); + }); + + it("rejects invalid persona-name (non-kebab-case)", () => { + expect("error" in parseArgs(["--persona-slot", "2", "--persona-name", "Otto"])).toBe(true); + expect("error" in parseArgs(["--persona-slot", "2", "--persona-name", "otto_cli"])).toBe(true); + expect("error" in parseArgs(["--persona-slot", "2", "--persona-name", "../etc"])).toBe(true); + }); + + it("rejects unknown flag", () => { + expect("error" in parseArgs(["--bogus"])).toBe(true); + }); +}); + +describe("buildHeartbeatObservation", () => { + it("sets category=3 (Heartbeat per registry)", () => { + const args = parseArgs(baseArgs); + 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"]); + 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); + 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); + 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 100644 index 0000000000..cce208b236 --- /dev/null +++ b/tools/agent-heartbeats/write-heartbeat.ts @@ -0,0 +1,326 @@ +#!/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 write failure + +import { writeFileSync, mkdirSync } from "node:fs"; +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 repo: string; // "owner/name" for REST push (default Lucent-Financial-Group/Zeta) + readonly branch: string; // target branch (default "main") +} + +export function parseArgs(argv: readonly string[]): Args | { readonly error: string } { + let personaSlot: number | null = null; + let personaName: string | null = null; + let authority: Authority["type"] = "TrustedAgent"; + let momentum: Momentum["type"] = "Normal"; + let chromosome = 0; + let location = 1; + let namedDep: string | null = null; + let disposition = "bounded-wait"; + let parentPr: number | null = null; + let repoRoot = process.cwd(); + let dryRun = false; + let push = false; + let repo = "Lucent-Financial-Group/Zeta"; + let branch = "main"; + 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") personaSlot = parseInt(next(), 10); + 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") chromosome = parseInt(next(), 10); + else if (arg === "--location") location = parseInt(next(), 10); + else if (arg === "--named-dep") namedDep = next(); + else if (arg === "--disposition") disposition = next(); + else if (arg === "--parent-pr") parentPr = parseInt(next(), 10); + else if (arg === "--repo-root") repoRoot = next(); + else if (arg === "--dry-run") dryRun = true; + else if (arg === "--push") push = true; + 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 === null) return { error: "--persona-slot required" }; + if (personaName === null) return { error: "--persona-name required" }; + if (personaSlot < 0 || personaSlot > 255) return { error: "--persona-slot must be 0..255" }; + if (!/^[a-z][a-z0-9-]*$/.test(personaName)) return { error: "--persona-name must match /^[a-z][a-z0-9-]*$/" }; + 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" }; + return { personaSlot, personaName, authority, momentum, chromosome, location, namedDep, disposition, parentPr, repoRoot, dryRun, push, 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"; +} + +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 path = heartbeatPath(parsed.repoRoot, parsed.personaName, timestampMs, idHex); + const body = renderHeartbeat(parsed, idHex, timestampMs); + if (parsed.dryRun) { + console.log(`DRY RUN — would write:\n ${path}\n${"-".repeat(40)}\n${body}`); + if (parsed.push) console.log(`DRY RUN — would push to ${parsed.repo} branch ${parsed.branch}`); + return 0; + } + try { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, body); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`write-heartbeat: local write failed: ${msg}`); + return 3; + } + console.log(path); + if (parsed.push) { + // Direct-to-main push via REST git-data API: bypasses local git index + + // working tree entirely (no staged/unstaged files disturbed). ZetaID + // filename uniqueness prevents concurrent-agent collision; non-FF retry + // handles race window between parent-ref read + ref update. + const repoRelPath = `docs/agent-heartbeats/${parsed.personaName}/${path.split("/").slice(-4).join("/")}`; + 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}`); + } + return 0; +} + +if (import.meta.main) { + main().then((code) => process.exit(code)); +} From ac2804fb18651462eaed66703be283fb61bc5604 Mon Sep 17 00:00:00 2001 From: Lior Date: Wed, 27 May 2026 10:00:20 -0400 Subject: [PATCH 2/6] =?UTF-8?q?fix(B-0858.3=20simplicity):=20zero-param=20?= =?UTF-8?q?defaults=20+=20env-var=20fallback=20+=20push=3Dtrue=20default?= =?UTF-8?q?=20=E2=80=94=20operator=202026-05-27=20"stupid=20simple=20for?= =?UTF-8?q?=20agents=20to=20get=20it=20right"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator direction: "heartbeats should be the simplest possible for agents to get it right i'm desiging it for you, in a perfect world you don't even need to pass parametrs it just works and gatheres the data you need for the commit and push. It should be stupid simple for agents to get it right." Refactored parseArgs to provide built-in defaults for every field + env-var override layer + CLI-flag final override. Each option has 3 sources in precedence order: CLI flag > env var > built-in default. **Built-in defaults (zero-param invocation)**: - persona-slot: 2 (FireflyCoherence) - persona-name: "otto" - authority: TrustedAgent (autonomous-loop default) - momentum: Normal - chromosome: 0 (MetaCoherence) - location: 1 (EastUS_VA1) - named-dep: null (no default; optional) - disposition: "bounded-wait" - parent-pr: null - repo: "Lucent-Financial-Group/Zeta" - branch: "main" - push: TRUE (perfect-world local+remote together) **Env-var fallback** (set once per agent session by harness): - ZETA_AGENT_PERSONA_SLOT / _PERSONA_NAME / _AUTHORITY / _MOMENTUM / _CHROMOSOME / _LOCATION / _NAMED_DEP / _DISPOSITION / _PARENT_PR / _REPO_ROOT / _REPO / _BRANCH - ZETA_AGENT_HEARTBEAT_NO_PUSH=1 disables push (for tests + diagnostic runs without network) **Stupid-simple invocation** (after harness sets env once): bun tools/agent-heartbeats/write-heartbeat.ts Writes locally + pushes to main. ZetaID auto-generated; AgencySignature trailer on commit; no params needed. **CLI flags still work** for overrides + non-default scenarios. New --no-push flag for tests + diagnostic runs. **Tests updated**: 16 passing (up from 15). New coverage: - zero-args + empty env → built-in defaults - env-var override behavior - CLI-flag overrides env-var (precedence chain) - --push overrides ZETA_AGENT_HEARTBEAT_NO_PUSH=1 All prior tests adapted to pass TEST_ENV with NO_PUSH=1 (no network in unit tests). Per .claude/rules/non-coercion-invariant.md HC-8: operator authority preserved; defaults are reasonable for autonomous loop; every choice overridable; no coercion into specific values. Per .claude/rules/holding-without-named-dependency-is-standing-by-failure.md: this commit IS heartbeat-via-commit work; AgencySignature trailer below. Agency-Signature-Version: 1 Agent: Otto Agent-Runtime: Claude Code (auto mode) Agent-Model: claude-opus-4-7 Credential-Identity: aaron-otto-vscode Credential-Mode: operator-authorized Human-Review: pre-merge-pending Human-Review-Evidence: operator-direction-2026-05-27-stupid-simple-zero-param-heartbeat Action-Mode: substrate-fix-fwd-ux Task: B-0858.3 Co-Authored-By: Claude Opus 4.7 --- .../agent-heartbeats/write-heartbeat.test.ts | 68 +++++++++++++------ tools/agent-heartbeats/write-heartbeat.ts | 47 ++++++++----- 2 files changed, 77 insertions(+), 38 deletions(-) diff --git a/tools/agent-heartbeats/write-heartbeat.test.ts b/tools/agent-heartbeats/write-heartbeat.test.ts index 50d5c1c9d1..9a3ffe980d 100644 --- a/tools/agent-heartbeats/write-heartbeat.test.ts +++ b/tools/agent-heartbeats/write-heartbeat.test.ts @@ -4,17 +4,45 @@ import { describe, expect, it } from "bun:test"; import { parseArgs, buildHeartbeatObservation, zetaIdToHex, heartbeatPath, renderHeartbeat } from "./write-heartbeat"; import { pack, DEFAULT_ENV } from "../../src/Core.TypeScript/zeta-id/zeta-id"; -const baseArgs = ["--persona-slot", "2", "--persona-name", "otto"]; +// 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("accepts minimal required flags", () => { - const r = parseArgs(baseArgs); + 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("main"); + }); + + 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", () => { @@ -28,7 +56,8 @@ describe("parseArgs", () => { "--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"); @@ -38,35 +67,34 @@ describe("parseArgs", () => { expect(r.disposition).toBe("committed-substrate"); expect(r.parentPr).toBe(5450); expect(r.dryRun).toBe(true); + expect(r.push).toBe(false); }); - it("rejects missing --persona-slot", () => { - expect("error" in parseArgs(["--persona-name", "otto"])).toBe(true); - }); - - it("rejects missing --persona-name", () => { - expect("error" in parseArgs(["--persona-slot", "2"])).toBe(true); + 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", "--persona-name", "otto"])).toBe(true); - expect("error" in parseArgs(["--persona-slot", "-1", "--persona-name", "otto"])).toBe(true); + 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-slot", "2", "--persona-name", "Otto"])).toBe(true); - expect("error" in parseArgs(["--persona-slot", "2", "--persona-name", "otto_cli"])).toBe(true); - expect("error" in parseArgs(["--persona-slot", "2", "--persona-name", "../etc"])).toBe(true); + 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"])).toBe(true); + expect("error" in parseArgs(["--bogus"], TEST_ENV)).toBe(true); }); }); describe("buildHeartbeatObservation", () => { it("sets category=3 (Heartbeat per registry)", () => { - const args = parseArgs(baseArgs); + 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); @@ -108,7 +136,7 @@ describe("heartbeatPath", () => { describe("renderHeartbeat", () => { it("produces valid frontmatter with required fields", () => { - const args = parseArgs([...baseArgs, "--named-dep", "PR #5450 CI", "--parent-pr", "5450"]); + 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("---"); @@ -122,7 +150,7 @@ describe("renderHeartbeat", () => { }); it("omits optional fields when not provided", () => { - const args = parseArgs(baseArgs); + 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:"); @@ -132,7 +160,7 @@ describe("renderHeartbeat", () => { describe("end-to-end pack with DEFAULT_ENV", () => { it("packs without throwing; category bit field round-trips", () => { - const args = parseArgs(baseArgs); + 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); diff --git a/tools/agent-heartbeats/write-heartbeat.ts b/tools/agent-heartbeats/write-heartbeat.ts index cce208b236..5a2aa01bc4 100644 --- a/tools/agent-heartbeats/write-heartbeat.ts +++ b/tools/agent-heartbeats/write-heartbeat.ts @@ -55,21 +55,33 @@ interface Args { readonly branch: string; // target branch (default "main") } -export function parseArgs(argv: readonly string[]): Args | { readonly error: string } { - let personaSlot: number | null = null; - let personaName: string | null = null; - let authority: Authority["type"] = "TrustedAgent"; - let momentum: Momentum["type"] = "Normal"; - let chromosome = 0; - let location = 1; - let namedDep: string | null = null; - let disposition = "bounded-wait"; - let parentPr: number | null = null; - let repoRoot = process.cwd(); +/** + * 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. + */ +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) + let personaSlot = env.ZETA_AGENT_PERSONA_SLOT ? parseInt(env.ZETA_AGENT_PERSONA_SLOT, 10) : 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 ? parseInt(env.ZETA_AGENT_CHROMOSOME, 10) : 0; + let location = env.ZETA_AGENT_LOCATION ? parseInt(env.ZETA_AGENT_LOCATION, 10) : 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 ? parseInt(env.ZETA_AGENT_PARENT_PR, 10) : null; + let repoRoot = env.ZETA_AGENT_REPO_ROOT ?? process.cwd(); let dryRun = false; - let push = false; - let repo = "Lucent-Financial-Group/Zeta"; - let branch = "main"; + // 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"; + let repo = env.ZETA_AGENT_REPO ?? "Lucent-Financial-Group/Zeta"; + let branch = env.ZETA_AGENT_BRANCH ?? "main"; for (let i = 0; i < argv.length; i++) { const arg = argv[i]!; const next = (): string => { @@ -89,6 +101,7 @@ export function parseArgs(argv: readonly string[]): Args | { readonly error: str 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 === "--repo") repo = next(); else if (arg === "--branch") branch = next(); else return { error: `unknown flag: ${arg}` }; @@ -96,10 +109,8 @@ export function parseArgs(argv: readonly string[]): Args | { readonly error: str return { error: err instanceof Error ? err.message : String(err) }; } } - if (personaSlot === null) return { error: "--persona-slot required" }; - if (personaName === null) return { error: "--persona-name required" }; - if (personaSlot < 0 || personaSlot > 255) return { error: "--persona-slot must be 0..255" }; - if (!/^[a-z][a-z0-9-]*$/.test(personaName)) return { error: "--persona-name must match /^[a-z][a-z0-9-]*$/" }; + 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" }; From de0b5d790272613c15670e6e38e487498a3ee09a Mon Sep 17 00:00:00 2001 From: Lior Date: Wed, 27 May 2026 10:02:14 -0400 Subject: [PATCH 3/6] =?UTF-8?q?fix(B-0858.3=20exec-bit):=20chmod=20+x=20wr?= =?UTF-8?q?ite-heartbeat.ts=20+=20README=20direct-invocation=20form=20?= =?UTF-8?q?=E2=80=94=20operator=202026-05-27=20"even=20simpler"=20shell=20?= =?UTF-8?q?entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator direction: "you could even make the .ts file have a # shell header that points to bun as the executore if you want to make it even simipler to make sure agents get it right. ../scratch i think does this for it's ts file or ../SQLSharp but ts should call other ts via ts not assuming the exec bit status of the ts file, this is only for agent simplicity." Shebang already present at line 1 (#!/usr/bin/env bun). Missing piece was the exec bit: chmod +x tools/agent-heartbeats/write-heartbeat.ts Verified: ./tools/agent-heartbeats/write-heartbeat.ts --no-push --dry-run runs directly without `bun` prefix. README updated with the stupid-simple form first: ./tools/agent-heartbeats/write-heartbeat.ts # zero params; just works Plus full flag form documented for non-default scenarios. Operator-specified constraint preserved: TS→TS calls within the codebase still use proper TS import statements (not exec-bit assumptions). The shebang+exec is ONLY for agent shell-invocation simplicity at the top-level entry. write-heartbeat.ts's internal imports from ../../src/Core.TypeScript/zeta-id/ remain proper ESM imports — no shell out, no exec-bit dependence between TS files. Per .claude/rules/non-coercion-invariant.md HC-8: operator authority over agent UX preserved; shell-shebang lowers agent friction without changing semantics. Agency-Signature-Version: 1 Agent: Otto Agent-Runtime: Claude Code (auto mode) Agent-Model: claude-opus-4-7 Credential-Identity: aaron-otto-vscode Credential-Mode: operator-authorized Human-Review: pre-merge-pending Human-Review-Evidence: operator-direction-2026-05-27-even-simpler-shell-shebang-entry Action-Mode: substrate-fix-fwd-ux Task: B-0858.3 Co-Authored-By: Claude Opus 4.7 --- docs/agent-heartbeats/README.md | 24 +++++++++++++++++++---- tools/agent-heartbeats/write-heartbeat.ts | 0 2 files changed, 20 insertions(+), 4 deletions(-) mode change 100644 => 100755 tools/agent-heartbeats/write-heartbeat.ts diff --git a/docs/agent-heartbeats/README.md b/docs/agent-heartbeats/README.md index 1faf9790d1..11495ec4fb 100644 --- a/docs/agent-heartbeats/README.md +++ b/docs/agent-heartbeats/README.md @@ -20,16 +20,32 @@ docs/agent-heartbeats////
/.md ## Writing +**Stupid-simple (per operator 2026-05-27 "just works" direction)**: + +```bash +./tools/agent-heartbeats/write-heartbeat.ts +``` + +Zero params. Writes locally + pushes to main via REST. Defaults: +persona-slot=2, persona-name=otto, authority=TrustedAgent, +momentum=Normal, disposition=bounded-wait, push=true. 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. + +**Full flag form**: + ```bash -bun tools/agent-heartbeats/write-heartbeat.ts \ - --persona-slot \ - --persona-name \ +./tools/agent-heartbeats/write-heartbeat.ts \ + [--persona-slot ] \ + [--persona-name ] \ [--authority TrustedAgent|Standard|BestEffort|...] \ [--momentum Normal|Elevated|High|...] \ [--named-dep "PR #NNNN "] \ [--disposition bounded-wait|decomposing|committed-substrate|chose-free-time] \ [--parent-pr NNNN] \ - [--push] [--branch ] [--repo owner/name] + [--push|--no-push] [--branch ] [--repo owner/name] ``` Writes one heartbeat record + prints its path to stdout. ZetaID diff --git a/tools/agent-heartbeats/write-heartbeat.ts b/tools/agent-heartbeats/write-heartbeat.ts old mode 100644 new mode 100755 From 33721c6af59a466423dd9790737a4167a20f15d4 Mon Sep 17 00:00:00 2001 From: Lior Date: Wed, 27 May 2026 10:06:30 -0400 Subject: [PATCH 4/6] =?UTF-8?q?fix(B-0858.3=20CI):=20MD032=20=E2=80=94=20"?= =?UTF-8?q?+=20folder=20convention"=20interpreted=20as=20list=20marker;=20?= =?UTF-8?q?rewrite=20as=20"and=20folder=20convention"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markdownlint failure on PR #5464: line 85 of docs/agent-heartbeats/README.md had "Either option requires operator-side GitHub config; the writer tool\n+ folder convention are the same either way." The leading "+" on line 85 parsed as an unordered-list marker (MD032 needs blank-line-before-list). Fix: rewrite to "Either option requires operator-side GitHub config; the writer tool and folder convention are the same either way." Same meaning, no list-marker ambiguity. Local re-lint: `bunx markdownlint-cli2 'docs/agent-heartbeats/**/*.md' AGENTS.md` returns clean. Agency-Signature-Version: 1 Agent: Otto Agent-Runtime: Claude Code (auto mode) Agent-Model: claude-opus-4-7 Credential-Identity: aaron-otto-vscode Credential-Mode: operator-authorized Human-Review: pre-merge-pending Human-Review-Evidence: ci-markdownlint-md032-on-pr-5464 Action-Mode: substrate-fix-fwd-ci Task: B-0858.3 Co-Authored-By: Claude Opus 4.7 --- docs/agent-heartbeats/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/agent-heartbeats/README.md b/docs/agent-heartbeats/README.md index 11495ec4fb..5c12c96c0e 100644 --- a/docs/agent-heartbeats/README.md +++ b/docs/agent-heartbeats/README.md @@ -82,8 +82,8 @@ rules. Agents push directly to that branch. Lookups query via Main history stays clean of per-tick noise. Either option requires operator-side GitHub config; the writer tool -+ folder convention are the same either way. The repo body picks at -deployment time; tooling is branch-name-agnostic. +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) From fcb9dacd98fc440b8d99c3d2f21027444775b907 Mon Sep 17 00:00:00 2001 From: Lior Date: Wed, 27 May 2026 10:07:29 -0400 Subject: [PATCH 5/6] =?UTF-8?q?feat(B-0858.3):=20default=20branch=3Dagent-?= =?UTF-8?q?heartbeats=20=E2=80=94=20bypasses=204=20main-only=20rulesets=20?= =?UTF-8?q?(Branch=20Safety/CI=20Gate/Default/Review=20Policy);=20heartbea?= =?UTF-8?q?ts=20not=20PRs=20so=20no=20accidental=20velocity=20(operator=20?= =?UTF-8?q?2026-05-27=20+=20agent-heartbeats=20branch=20created=20via=20RE?= =?UTF-8?q?ST)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator 2026-05-27: "this way hearbeats wont be PR so they won't show up as accidental velocity too" + "you control branch exclustions from a point of view of protect yourself zeta is your memories so you can turn it off too where it makes sense, i think we've made this heartbeat folder/branch safe for branch exclusions." Verified: 4 active branch rulesets all target ~DEFAULT_BRANCH (main) only: - Branch Safety (16189060): deletion + non_fast_forward + required_linear_history - CI Gate (16134995): required_status_checks - Default (15256879): empty - Review Policy (16168181): copilot_code_review + pull_request Non-main branches inherit ZERO protection. Created agent-heartbeats branch via REST POST refs/heads (initial SHA = main HEAD); verified direct-push works end-to-end (commit 0c74d5cad). Changes: - write-heartbeat.ts default branch: "main" → "agent-heartbeats" (overridable via ZETA_AGENT_BRANCH env var or --branch CLI flag) - write-heartbeat.test.ts assertion updated for new default - README documents the choice: per-tick heartbeats don't appear in PR queue (no accidental velocity) and don't pollute main commit log; lookups via `git log agent-heartbeats -- ...` Empirical anchor: heartbeat 080cf34e13ab8807a01300085fbabab3 pushed direct to agent-heartbeats at commit 0c74d5cad7294204078bdf80567cc64400ad0f70 without any ruleset interference. Same persona-slot=2 + same hex layout + ZetaID uniqueness guarantees no collision across concurrent agents. Per operator 2026-05-27 "Zeta is your memories so you can turn it off too where it makes sense": this commit IS the self-applied branch- protection-exclusion at the Zeta-as-memories scope. Heartbeat substrate is observational metadata, not load-bearing factory logic; exempting it from PR/CI gating is operationally honest. Agency-Signature-Version: 1 Agent: Otto Agent-Runtime: Claude Code (auto mode) Agent-Model: claude-opus-4-7 Credential-Identity: aaron-otto-vscode Credential-Mode: operator-authorized Human-Review: pre-merge-pending Human-Review-Evidence: operator-direction-2026-05-27-heartbeats-not-pr-no-accidental-velocity-plus-self-applied-branch-exclusion Action-Mode: substrate-fix-fwd-architecture Task: B-0858.3 Co-Authored-By: Claude Opus 4.7 --- docs/agent-heartbeats/README.md | 23 +++++++++++++------ .../agent-heartbeats/write-heartbeat.test.ts | 2 +- tools/agent-heartbeats/write-heartbeat.ts | 7 +++++- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/docs/agent-heartbeats/README.md b/docs/agent-heartbeats/README.md index 5c12c96c0e..83a71a6ebf 100644 --- a/docs/agent-heartbeats/README.md +++ b/docs/agent-heartbeats/README.md @@ -26,13 +26,22 @@ docs/agent-heartbeats////
/.md ./tools/agent-heartbeats/write-heartbeat.ts ``` -Zero params. Writes locally + pushes to main via REST. Defaults: -persona-slot=2, persona-name=otto, authority=TrustedAgent, -momentum=Normal, disposition=bounded-wait, push=true. 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. +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**: diff --git a/tools/agent-heartbeats/write-heartbeat.test.ts b/tools/agent-heartbeats/write-heartbeat.test.ts index 9a3ffe980d..c4f61ff38b 100644 --- a/tools/agent-heartbeats/write-heartbeat.test.ts +++ b/tools/agent-heartbeats/write-heartbeat.test.ts @@ -20,7 +20,7 @@ describe("parseArgs", () => { 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("main"); + expect(r.branch).toBe("agent-heartbeats"); // default per operator 2026-05-27 }); it("env vars override built-in defaults", () => { diff --git a/tools/agent-heartbeats/write-heartbeat.ts b/tools/agent-heartbeats/write-heartbeat.ts index 5a2aa01bc4..f7f3ec6a97 100755 --- a/tools/agent-heartbeats/write-heartbeat.ts +++ b/tools/agent-heartbeats/write-heartbeat.ts @@ -81,7 +81,12 @@ export function parseArgs(argv: readonly string[], env: NodeJS.ProcessEnv = proc // diagnostic runs opt-OUT via --no-push. let push = env.ZETA_AGENT_HEARTBEAT_NO_PUSH !== "1"; let repo = env.ZETA_AGENT_REPO ?? "Lucent-Financial-Group/Zeta"; - let branch = env.ZETA_AGENT_BRANCH ?? "main"; + // Default branch is "agent-heartbeats" (operator 2026-05-27): keeps + // per-tick heartbeat noise off main + bypasses the 4 main-only rulesets + // (Branch Safety / CI Gate / Default / Review Policy) without needing + // per-folder exclusions. Heartbeats on this branch don't show up as + // accidental velocity in PR queue or main commit log. + let branch = env.ZETA_AGENT_BRANCH ?? "agent-heartbeats"; for (let i = 0; i < argv.length; i++) { const arg = argv[i]!; const next = (): string => { From 78659ba4d724d0ca799736810e99955a8672feab Mon Sep 17 00:00:00 2001 From: Lior Date: Wed, 27 May 2026 10:12:04 -0400 Subject: [PATCH 6/6] fix(B-0858.3): dirty-branch-safe default (writeLocal=false when pushing) + 14 Copilot findings (NaN guard, auth/momentum enum, posix path, exit codes, AGENTS.md anonymization + + wrap, README accuracy, persona-folder decoupling, collision-prob softening) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator 2026-05-27 direction: "make sure it works even when your branch is dirty and you are working on another branch with inflight uncheckout or staged work so it can switch or isolate and push just that file to the heartbeat branch" + "we should hard code the correct branch name for heartbeats into the script for it's defualt if you don't pass it" + "this way hearbeats wont be PR so they won't show up as accidental velocity too". **Dirty-branch safety — empirically verified**: Default writeLocal flag flipped to false-when-pushing. The REST git- data API push step already touched no local git state (no index, no worktree mutation, no current-branch dependency). The only thing that touched the worktree was the optional local write step; now disabled by default when pushing. Empirical test (this PR's worktree): 7 uncommitted files in feat branch BEFORE invocation → `./tools/agent-heartbeats/write-heartbeat.ts` zero-args succeeded → push commit 4e98039984a103ad40f5faec548d7e1b7c846713 on agent-heartbeats branch → 7 uncommitted files AFTER (no new file created in worktree). Operator's exact requirement met. `--write-local` opts back in to also write locally (for in-worktree grep/lookup). `--no-push` flips writeLocal default to true (else nothing happens). `--no-push --no-write-local` exits 2 (no-op). **14 Copilot findings on PR #5464 resolved**: 1. P1 NaN parseInt: added parseIntStrict; NaN/non-integer/non-finite inputs return null + flag-specific error 2. P1 authority/momentum string assertions: validated against KNOWN_AUTHORITIES + KNOWN_MOMENTUM enum arrays before pack() 3. P2 exit codes header out of sync: added "4 REST push failure" 4. P1 platform-dependent path.split("/"): replaced with posix.join() in new heartbeatRepoRelPath() helper; always forward-slashes regardless of host OS 5. P2 pushHeartbeatViaRest no tests: noted as integration test (requires gh auth + network; dogfooded extensively this session) 6. P2 test path assertions POSIX-specific: heartbeatPath uses path.join (host-dependent) for local I/O; tests pass on POSIX; heartbeatRepoRelPath uses posix.join for the REST path which is what crosses the wire 7. P1 persona-folder vs registry mismatch: README sharpened to document the deliberate decoupling (folder=operator-friendly roster name; ZetaID persona-slot=role-ref registry slot; the writer takes both as separate args) 8. P2 "collision-free by construction" too strong: rewrote to "practically collision-free for autonomous-loop scale" with probability bound (~10⁻¹⁰ for two concurrent same-ms pushes) 9. P1 seed heartbeat persona-slot mismatch: documented decoupling in README (#7); seed file persona-slot=2 is the FireflyCoherence registry slot used as the autonomous-loop default, while persona-name=otto is the operator-friendly roster name — they identify different things on purpose 10. P2 AGENTS.md "+" wrap as list marker: rewrote bullet to avoid leading "+" on wrapped line; uses parenthetical instead 11. P1 AGENTS.md persona-name attribution ("Kira", "Otto-CLI"): rewrote to "2026-05-27 empirical anchor: an autonomous-loop instance emitted 100+ ..." with no named-persona attribution per `.claude/rules/agent-roster-reference-card.md` carve-out discipline + `docs/AGENT-BEST-PRACTICES.md` no-name rule 12. P1 worktree-dirty vs README claim: FIXED architecturally via writeLocal=false default; README rewritten to accurately describe both push step (truly bypasses local git) + write- local step (optional; explicitly named when it touches worktree) 13. P1 README inaccuracy: same as #12 14. P2 eslint-disable rationale: each `// eslint-disable-next-line sonarjs/no-os-command-from-path` already had context comments in surrounding code; verified compliance **Test suite expansion**: 23 tests pass (up from 16). New coverage: - parseIntStrict NaN rejection per numeric flag - KNOWN_AUTHORITIES + KNOWN_MOMENTUM enum validation - writeLocal default semantics (push=true→false; push=false→true) - --write-local + --no-write-local explicit overrides - heartbeatRepoRelPath POSIX-separator guarantee Per .claude/rules/blocked-green-ci-investigate-threads.md verify-then-fix: read each Copilot finding, decide P0/P1/P2, fix in bundle. Per .claude/rules/non-coercion-invariant.md HC-8: operator authority preserved; dirty-branch-safe default respects operator's other-branch work. Per .claude/rules/agent-roster-reference-card.md: AGENTS.md rewritten to use neutral role-ref language outside the explicit roster-mapping carve-out. Agency-Signature-Version: 1 Agent: Otto Agent-Runtime: Claude Code (auto mode) Agent-Model: claude-opus-4-7 Credential-Identity: aaron-otto-vscode Credential-Mode: operator-authorized Human-Review: pre-merge-pending Human-Review-Evidence: copilot-14-findings-on-pr-5464-plus-operator-direction-2026-05-27-dirty-branch-safety Action-Mode: substrate-fix-fwd-architecture-plus-correctness Task: B-0858.3 Co-Authored-By: Claude Opus 4.7 --- AGENTS.md | 36 +++--- docs/agent-heartbeats/README.md | 58 ++++++--- .../agent-heartbeats/write-heartbeat.test.ts | 60 ++++++++- tools/agent-heartbeats/write-heartbeat.ts | 117 +++++++++++++----- 4 files changed, 202 insertions(+), 69 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 431fa43571..d9ffc420e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -270,24 +270,26 @@ These apply to any AI harness. `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 at - `docs/agent-heartbeats////
/.md` - via `bun tools/agent-heartbeats/write-heartbeat.ts` (composes - with `src/Core.TypeScript/zeta-id/zeta-id.ts` 128-bit ZetaID - + `registry/categories.yaml` Heartbeat = category 3). Neither - + no named-dependency = the Standing-by failure mode per + 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 per the 2026-05-27 - Kira review (Otto-CLI emitted 100+ "Quiet." without the - counter firing because the agent cannot count itself); - the externalized counter is git log over - `docs/agent-heartbeats//` + per-commit AgencySignature - trailer presence on origin/main. Heartbeat files target a - branch-protection-excluded folder (or alternative - `agent-heartbeats` branch with no protection) so per-tick - push is direct-to-main without PR overhead; ZetaID - uniqueness guarantees no collision across concurrent agents. + (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 index 83a71a6ebf..9f3e8db397 100644 --- a/docs/agent-heartbeats/README.md +++ b/docs/agent-heartbeats/README.md @@ -12,11 +12,20 @@ externalized idle counter" (PR #5451). docs/agent-heartbeats////
/.md ``` -- `` = canonical agent name per `.claude/rules/agent-roster-reference-card.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`) -- Collision-free by construction (32-bit randomness + 48-bit timestamp - + 8-bit persona slot in the ZetaID) +- 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 @@ -49,26 +58,39 @@ log. Lookups via `git log agent-heartbeats -- docs/agent-heartbeats//.. ./tools/agent-heartbeats/write-heartbeat.ts \ [--persona-slot ] \ [--persona-name ] \ - [--authority TrustedAgent|Standard|BestEffort|...] \ - [--momentum Normal|Elevated|High|...] \ + [--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] [--branch ] [--repo owner/name] + [--push|--no-push] [--write-local|--no-write-local] \ + [--branch ] [--repo owner/name] ``` -Writes one heartbeat record + prints its path to stdout. ZetaID -generated via `pack(obs, DEFAULT_ENV)` from -`src/Core.TypeScript/zeta-id/zeta-id.ts`. - -With `--push`, also pushes to the remote via GitHub REST git-data API -(blob → tree → commit → ref). Target is `--branch` (default `main`; -pass `agent-heartbeats` for the dedicated-branch variant). REST path -bypasses local git index + working tree entirely — no staged/unstaged -files disturbed, no current-branch state read. Retries up to 5x on -non-fast-forward (peer-agent push race window). ZetaID-unique -filenames guarantee no concurrent-agent collision on either branch -target. +**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 diff --git a/tools/agent-heartbeats/write-heartbeat.test.ts b/tools/agent-heartbeats/write-heartbeat.test.ts index c4f61ff38b..86746e6c90 100644 --- a/tools/agent-heartbeats/write-heartbeat.test.ts +++ b/tools/agent-heartbeats/write-heartbeat.test.ts @@ -1,7 +1,7 @@ // 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, renderHeartbeat } from "./write-heartbeat"; +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 @@ -90,6 +90,64 @@ describe("parseArgs", () => { 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", () => { diff --git a/tools/agent-heartbeats/write-heartbeat.ts b/tools/agent-heartbeats/write-heartbeat.ts index f7f3ec6a97..23253e6e9d 100755 --- a/tools/agent-heartbeats/write-heartbeat.ts +++ b/tools/agent-heartbeats/write-heartbeat.ts @@ -30,9 +30,11 @@ // Exit codes: // 0 success (heartbeat written; path printed to stdout) // 2 arg-parse error -// 3 write failure +// 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"; @@ -51,8 +53,9 @@ interface Args { 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 (default "main") + readonly branch: string; // target branch (HARDCODED default "agent-heartbeats") } /** @@ -63,29 +66,50 @@ interface Args { * * 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) - let personaSlot = env.ZETA_AGENT_PERSONA_SLOT ? parseInt(env.ZETA_AGENT_PERSONA_SLOT, 10) : 2; + // 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 ? parseInt(env.ZETA_AGENT_CHROMOSOME, 10) : 0; - let location = env.ZETA_AGENT_LOCATION ? parseInt(env.ZETA_AGENT_LOCATION, 10) : 1; + 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 ? parseInt(env.ZETA_AGENT_PARENT_PR, 10) : null; + 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"; - // Default branch is "agent-heartbeats" (operator 2026-05-27): keeps - // per-tick heartbeat noise off main + bypasses the 4 main-only rulesets - // (Branch Safety / CI Gate / Default / Review Policy) without needing - // per-folder exclusions. Heartbeats on this branch don't show up as - // accidental velocity in PR queue or main commit log. + // 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]!; @@ -94,19 +118,21 @@ export function parseArgs(argv: readonly string[], env: NodeJS.ProcessEnv = proc return argv[++i]!; }; try { - if (arg === "--persona-slot") personaSlot = parseInt(next(), 10); + 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") chromosome = parseInt(next(), 10); - else if (arg === "--location") location = parseInt(next(), 10); + 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") parentPr = parseInt(next(), 10); + 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}` }; @@ -119,7 +145,11 @@ export function parseArgs(argv: readonly string[], env: NodeJS.ProcessEnv = proc 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" }; - return { personaSlot, personaName, authority, momentum, chromosome, location, namedDep, disposition, parentPr, repoRoot, dryRun, push, repo, branch }; + 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 }; } /** @@ -293,6 +323,16 @@ export function renderHeartbeat(args: Args, idHex: string, timestampMs: number): 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); @@ -304,28 +344,35 @@ async function main(): Promise { const obs = buildHeartbeatObservation(parsed, timestampMs); const id = pack(obs, DEFAULT_ENV); const idHex = zetaIdToHex(id); - const path = heartbeatPath(parsed.repoRoot, parsed.personaName, timestampMs, idHex); + 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 — would write:\n ${path}\n${"-".repeat(40)}\n${body}`); - if (parsed.push) console.log(`DRY RUN — would push to ${parsed.repo} branch ${parsed.branch}`); + 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; } - try { - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, body); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error(`write-heartbeat: local write failed: ${msg}`); - return 3; + 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); } - console.log(path); if (parsed.push) { - // Direct-to-main push via REST git-data API: bypasses local git index + - // working tree entirely (no staged/unstaged files disturbed). ZetaID - // filename uniqueness prevents concurrent-agent collision; non-FF retry - // handles race window between parent-ref read + ref update. - const repoRelPath = `docs/agent-heartbeats/${parsed.personaName}/${path.split("/").slice(-4).join("/")}`; + // 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) { @@ -334,6 +381,10 @@ async function main(): Promise { } 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; }