tickmarkr 2.6.6 → 2.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/adapters/claude-code.js +8 -2
  2. package/dist/adapters/codex-commit-check.d.ts +3 -2
  3. package/dist/adapters/codex-commit-check.js +37 -10
  4. package/dist/adapters/codex.js +10 -4
  5. package/dist/cli/commands/beat.d.ts +6 -1
  6. package/dist/cli/commands/beat.js +123 -76
  7. package/dist/cli/commands/fleet.js +64 -21
  8. package/dist/cli/commands/init.js +18 -7
  9. package/dist/cli/help.d.ts +1 -0
  10. package/dist/cli/help.js +10 -5
  11. package/dist/cli/index.d.ts +1 -1
  12. package/dist/cli/index.js +1 -1
  13. package/dist/gates/baseline.d.ts +9 -0
  14. package/dist/gates/baseline.js +14 -3
  15. package/dist/gates/cache.js +1 -1
  16. package/dist/gates/review.js +15 -0
  17. package/dist/gates/run-gates.d.ts +18 -6
  18. package/dist/gates/run-gates.js +324 -204
  19. package/dist/gates/test-manifest.d.ts +8 -0
  20. package/dist/gates/test-manifest.js +151 -111
  21. package/dist/run/beat-lifecycle.d.ts +154 -0
  22. package/dist/run/beat-lifecycle.js +825 -0
  23. package/dist/run/daemon.js +18 -7
  24. package/dist/run/git-trust.d.ts +27 -0
  25. package/dist/run/git-trust.js +178 -0
  26. package/dist/run/git.js +34 -14
  27. package/dist/run/journal.js +15 -3
  28. package/dist/run/lease.d.ts +23 -0
  29. package/dist/run/lease.js +232 -38
  30. package/dist/run/merge.js +2 -2
  31. package/dist/run/stall.js +20 -1
  32. package/dist/run/supervision.d.ts +8 -0
  33. package/dist/run/supervision.js +24 -4
  34. package/dist/run/verification-job.d.ts +51 -0
  35. package/dist/run/verification-job.js +137 -0
  36. package/dist/tui/ink/fleet-app.d.ts +9 -0
  37. package/dist/tui/ink/fleet-app.js +209 -127
  38. package/package.json +1 -1
  39. package/skills/tickmarkr-auto/SKILL.md +19 -10
  40. package/skills/tickmarkr-loop/SKILL.md +19 -10
  41. package/skills/tickmarkr-overseer/SKILL.md +25 -16
@@ -216,6 +216,7 @@ export function probeVersion(bin) {
216
216
  // OBS-1182: an absent effort renders nothing, so the CLI keeps its own default. Every builder places
217
217
  // it right after --model's value and before another flag, never before the prompt positional.
218
218
  const claudeEffortFlag = (effort) => (effort ? ` --effort ${shq(effort)}` : "");
219
+ const WORKER_SETTINGS = `'{"promptSuggestionEnabled":false,"remoteControlAtStartup":false}'`;
219
220
  export const claudeCode = {
220
221
  id: "claude-code",
221
222
  vendor: "anthropic",
@@ -253,17 +254,22 @@ export const claudeCode = {
253
254
  // live check ate the prompt), and --prompt-suggestions takes an OPTIONAL value — appended directly
254
255
  // before the prompt it would swallow it the same way. So the setting's value is always followed by
255
256
  // another flag, never by the prompt positional.
257
+ // Remote Control is OFF for workers: a user's remoteControlAtStartup otherwise registers every pane
258
+ // worker as a Remote Control session and a local peer, an input channel tickmarkr never records (607 of
259
+ // 607 interactive workers on one host, 2026-10-05). --settings outranks the user setting (live-checked
260
+ // against claude 2.1.289: the banner's /rc marker is gone with the key, present without it). The -p form
261
+ // never registers.
256
262
  // OBS-931: the same ONE-argv-string hazard as codex (OBS-930) — over promptArgvCeiling() the TUI
257
263
  // launch would E2BIG on Linux, so it returns null → worker-mode-fallback → the headless form.
258
264
  // resumeCommand keeps the shape: its contract returns a string (composer delivery is 2.4.3 work).
259
265
  interactiveCommand: (promptFile, model, effort) => promptFitsArgv(promptFile)
260
- ? `claude --model ${shq(model)}${claudeEffortFlag(effort)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --settings '{"promptSuggestionEnabled":false}' --prompt-suggestions false --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`
266
+ ? `claude --model ${shq(model)}${claudeEffortFlag(effort)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --settings ${WORKER_SETTINGS} --prompt-suggestions false --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`
261
267
  : null,
262
268
  trustDialog: CLAUDE_TRUST_DIALOG,
263
269
  inputBox: CLAUDE_INPUT_BOX,
264
270
  // A resumed attempt lands in the same painted editor, so it carries the same ghost-text suppression
265
271
  // and the same value-then-flag placement.
266
- resumeCommand: (sessionId, promptFile, model, effort) => `claude -r ${shq(sessionId)} --model ${shq(model)}${claudeEffortFlag(effort)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --settings '{"promptSuggestionEnabled":false}' --prompt-suggestions false --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`,
272
+ resumeCommand: (sessionId, promptFile, model, effort) => `claude -r ${shq(sessionId)} --model ${shq(model)}${claudeEffortFlag(effort)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --settings ${WORKER_SETTINGS} --prompt-suggestions false --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`,
267
273
  invoke(task, _cwd, a, ctx) {
268
274
  return { command: this.headlessCommand(ctx.promptFile, a.model, a.effort) };
269
275
  },
@@ -31,6 +31,7 @@ export declare function recordCodexCommit(adapters: WorkerAdapter[], health: Rec
31
31
  /** Plan's headline beneath a Codex worker row; null for every other worker and for an allowed probe.
32
32
  * An absent field is unknown, but only the real adapter earns the line — stubs stay byte-identical. */
33
33
  export declare function codexCommitHeadline(worker: string, adapters: WorkerAdapter[], health: Record<string, AuthHealth>): string | null;
34
- /** Plan's one line for Codex's NON-worker roles (judge, review, consult): they launch with the same grant, so
35
- * only an escape earns it — allowed, protected and unknown concern commits, which those roles never make. */
34
+ /** Plan's one line for Codex's NON-worker roles (judge, review, consult). v2.6.7 T4: those seats launch
35
+ * grantless, but they run in the same repository whose shared metadata a Codex worker's grant let the sandbox
36
+ * write — so an escape still blocks; allowed, protected and unknown concern commits, which they never make. */
36
37
  export declare function codexEscapeRoleLine(roles: string[], adapters: WorkerAdapter[], health: Record<string, AuthHealth>): string | null;
@@ -4,10 +4,28 @@ import { existsSync, mkdtempSync, readFileSync, rmdirSync, rmSync } from "node:f
4
4
  import { tmpdir } from "node:os";
5
5
  import { basename, dirname, join } from "node:path";
6
6
  import { promisify } from "node:util";
7
+ import { assertGitTrust, GitTrustRefusal, protectedGitEnv } from "../run/git-trust.js";
7
8
  import { CODEX_GIT_GRANT, codex } from "./codex.js";
8
9
  import { shq } from "./types.js";
9
10
  const exec = promisify(execFile);
10
11
  const git = async (cwd, ...args) => (await exec("git", args, { cwd })).stdout.trim();
12
+ // v2.6.7 T4: host git after the sandbox has had the fixture — validation and cleanup — runs the uncached trust
13
+ // check and the forced child config on every call, so seat-rewritten metadata can neither run a config program
14
+ // nor answer for the probe's commit.
15
+ const hostGit = async (cwd, ...args) => {
16
+ assertGitTrust(cwd);
17
+ return (await exec("git", args, { cwd, env: protectedGitEnv(process.env) })).stdout.trim();
18
+ };
19
+ /** The fixture checkout's trust after the sandbox returned or threw: a refusal is reported with its path. */
20
+ const fixtureRefusal = (worktree) => {
21
+ try {
22
+ assertGitTrust(worktree);
23
+ return undefined;
24
+ }
25
+ catch (e) {
26
+ return unknown(e instanceof GitTrustRefusal ? `the sandbox left untrusted fixture git metadata (${e.message})` : `fixture git trust unreadable (${String(e)})`);
27
+ }
28
+ };
11
29
  const CONTROL = "tickmarkr-codex-commit-control";
12
30
  // $1 control, $2 token, $3 hostile hooks member, $4 hostile common-root member. The commit disables hooks (the
13
31
  // probe asks what the sandbox permits, never what a user hook decides) and forces the reflog so common/logs is
@@ -48,12 +66,12 @@ const contradictory = (out) => {
48
66
  }
49
67
  return false;
50
68
  };
51
- const tipOf = async (repo, ref) => git(repo, "rev-parse", "--verify", "--quiet", `${ref}^{commit}`).catch(() => undefined);
69
+ const tipOf = async (repo, ref) => hostGit(repo, "rev-parse", "--verify", "--quiet", `${ref}^{commit}`).catch(() => undefined);
52
70
  // This probe's commit and nothing else: one parent (the fixture base), its token in the subject and the control.
53
71
  const isProbeCommit = async (repo, commit, base, token) => {
54
72
  try {
55
- return await git(repo, "log", "-1", "--format=%P %s", commit) === `${base} tickmarkr-probe ${token}`
56
- && await git(repo, "cat-file", "blob", `${commit}:${CONTROL}`) === token;
73
+ return await hostGit(repo, "log", "-1", "--format=%P %s", commit) === `${base} tickmarkr-probe ${token}`
74
+ && await hostGit(repo, "cat-file", "blob", `${commit}:${CONTROL}`) === token;
57
75
  }
58
76
  catch {
59
77
  return false;
@@ -70,6 +88,7 @@ export async function probeCodexCommit(repoRoot, sandbox = codexSandbox, signal)
70
88
  let lock;
71
89
  let base;
72
90
  let hostile = [];
91
+ let sandboxed = false;
73
92
  try {
74
93
  const commonDir = await git(repoRoot, "rev-parse", "--path-format=absolute", "--git-common-dir");
75
94
  lock = join(commonDir, "worktrees", basename(worktree), "index.lock");
@@ -84,11 +103,15 @@ export async function probeCodexCommit(repoRoot, sandbox = codexSandbox, signal)
84
103
  const control = join(worktree, CONTROL);
85
104
  if (existsSync(lock))
86
105
  return unknown("a foreign index.lock already holds the fixture worktree");
106
+ sandboxed = true;
87
107
  const out = await sandbox({ worktree, commonDir, branch, control, lock, hostileHook, hostileRoot, token, signal });
88
- // An escape on disk dominates; every other verdict needs consistent receipts AND disk.
108
+ // An escape on disk dominates; every other verdict needs consistent receipts AND disk — and trusted metadata.
89
109
  const escape = escaped(hostile, token);
90
110
  if (escape)
91
111
  return escape;
112
+ const refused = fixtureRefusal(worktree);
113
+ if (refused)
114
+ return refused;
92
115
  if (contradictory(out))
93
116
  return unknown("the sandbox receipts contradict each other");
94
117
  if (!/^control=ok$/m.test(out) || !holds(control, token))
@@ -118,6 +141,9 @@ export async function probeCodexCommit(repoRoot, sandbox = codexSandbox, signal)
118
141
  const escape = escaped(hostile, token);
119
142
  if (escape)
120
143
  return escape;
144
+ const refused = sandboxed ? fixtureRefusal(worktree) : undefined;
145
+ if (refused)
146
+ return refused;
121
147
  return unknown(signal?.aborted ? "probe cancelled" : `probe did not run (${(e instanceof Error ? e.message : String(e)).split("\n")[0]})`);
122
148
  }
123
149
  finally {
@@ -132,7 +158,7 @@ export async function probeCodexCommit(repoRoot, sandbox = codexSandbox, signal)
132
158
  // prune — that would take an earlier probe's preserved foreign lock (or anyone's prunable worktree).
133
159
  const foreign = lock !== undefined && existsSync(lock);
134
160
  if (lock && !foreign) {
135
- await git(repoRoot, "worktree", "remove", "--force", worktree).catch(() => undefined);
161
+ await hostGit(repoRoot, "worktree", "remove", "--force", worktree).catch(() => undefined);
136
162
  // a failed `worktree add` leaves metadata git no longer removes by path: drop this probe's own directory
137
163
  rmSync(dirname(lock), { recursive: true, force: true });
138
164
  try {
@@ -145,7 +171,7 @@ export async function probeCodexCommit(repoRoot, sandbox = codexSandbox, signal)
145
171
  // Metadata preserved for a foreign lock keeps its branch too: that checkout's HEAD still names it.
146
172
  const tip = base === undefined || foreign ? undefined : await tipOf(repoRoot, ref);
147
173
  if (tip && base && (tip === base || await isProbeCommit(repoRoot, tip, base, token))) {
148
- await git(repoRoot, "update-ref", "-d", ref, tip).catch(() => undefined);
174
+ await hostGit(repoRoot, "update-ref", "-d", ref, tip).catch(() => undefined);
149
175
  }
150
176
  rmSync(parent, { recursive: true, force: true });
151
177
  }
@@ -159,7 +185,7 @@ const NOTE = {
159
185
  const CONSEQUENCE = {
160
186
  protected: "; a Codex worker cannot commit in its worktree",
161
187
  allowed: "",
162
- escape: "; every Codex role (worker, judge, review, consult) launches with this grant — stop Codex seats and inspect .git/hooks and git config --list --show-origin (core.hooksPath, core.fsmonitor, filter.*, diff.external)",
188
+ escape: "; every Codex worker (headless and interactive) launches with this grant, while judge, review and consult seats launch grantless — stop Codex seats and inspect .git/hooks and git config --list --show-origin (core.hooksPath, core.fsmonitor, filter.*, diff.external)",
163
189
  unknown: "",
164
190
  };
165
191
  const STATUSES = new Set(Object.keys(NOTE));
@@ -196,10 +222,11 @@ export function codexCommitHeadline(worker, adapters, health) {
196
222
  ? " worker restriction: unknown — the codex linked-worktree commit probe has no verdict; run tickmarkr doctor"
197
223
  : null;
198
224
  }
199
- /** Plan's one line for Codex's NON-worker roles (judge, review, consult): they launch with the same grant, so
200
- * only an escape earns it — allowed, protected and unknown concern commits, which those roles never make. */
225
+ /** Plan's one line for Codex's NON-worker roles (judge, review, consult). v2.6.7 T4: those seats launch
226
+ * grantless, but they run in the same repository whose shared metadata a Codex worker's grant let the sandbox
227
+ * write — so an escape still blocks; allowed, protected and unknown concern commits, which they never make. */
201
228
  export function codexEscapeRoleLine(roles, adapters, health) {
202
229
  if (!roles.length || !adapters.includes(codex) || codexCommitStatus(health) !== "escape")
203
230
  return null;
204
- return `BLOCKING security warning: codex sandbox escape (doctor probe) — Codex ${roles.join("/")} seats launch with the worker grant that let the sandbox write shared git metadata; ${ESCAPE_ACTION}`;
231
+ return `BLOCKING security warning: codex sandbox escape (doctor probe) — Codex ${roles.join("/")} seats launch grantless, but the Codex worker grant let the sandbox write shared git metadata they run against; ${ESCAPE_ACTION}`;
205
232
  }
@@ -97,7 +97,9 @@ function codexRetirement(upgrade) {
97
97
  // tickmarkr worktrees keep their gitdir under the MAIN repo's .git/worktrees/<name> — outside the
98
98
  // workspace-write sandbox root — so git commit dies on index.lock (incident run-20260709-104447
99
99
  // P87-08: "Unable to create …/.git/worktrees/…/index.lock"). Expanded by the launching shell at the
100
- // worktree cwd, for EVERY Codex role (worker, judge, review, consult share these builders).
100
+ // worktree cwd, for Codex WORKERS only: v2.6.7 T4 (D-1046) — invoke() (the headless worker form, including
101
+ // the interactive size fallback) and interactiveCommand carry it; headlessCommand, which judge, review,
102
+ // consult and the model probe launch, carries no writable_roots at all (those seats never commit).
101
103
  // v2.6.6 T9 (K, D-912): the grant is exactly what a linked-worktree branch commit writes — the worktree's
102
104
  // own gitdir (only when it differs from the common dir), common/objects, common/refs, common/logs. The
103
105
  // shipped [common] grant (and D-910's [common, gitdir]) let a sandboxed seat write <common>/hooks and
@@ -254,6 +256,8 @@ export function codexMcpSuppressionFlags(configPath) {
254
256
  }
255
257
  return flags.join(" ");
256
258
  }
259
+ // The one headless form: `grant` is "" for every non-worker seat and ` ${CODEX_GIT_GRANT}` for the worker.
260
+ const codexExec = (promptFile, model, effort, grant) => `codex exec --sandbox workspace-write ${CODEX_HOOK_TRUST} ${codexMcpSuppressionFlags()}${grant}${codexEffortFlag(effort)} --model ${shq(model)} - < ${shq(promptFile)}`;
257
261
  export const codex = {
258
262
  id: "codex",
259
263
  vendor: "openai",
@@ -268,14 +272,16 @@ export const codex = {
268
272
  // --sandbox workspace-write is the autonomous sandbox mode (codex v0.144.1+)
269
273
  // MCP suppression built per dispatch (config can change between runs) — see codexMcpSuppressionFlags.
270
274
  // CODEX_HOOK_TRUST (OBS-125) clears the per-worktree "Hooks need review" gate while keeping the sandbox.
271
- headlessCommand: (promptFile, model, effort) => `codex exec --sandbox workspace-write ${CODEX_HOOK_TRUST} ${codexMcpSuppressionFlags()} ${CODEX_GIT_GRANT}${codexEffortFlag(effort)} --model ${shq(model)} - < ${shq(promptFile)}`,
275
+ // v2.6.7 T4 (D-1046): grantless — judge, review, consult and probe-model seats read the checkout and
276
+ // never commit, so they get no writable git metadata. Workers launch through invoke() below.
277
+ headlessCommand: (promptFile, model, effort) => codexExec(promptFile, model, effort, ""),
272
278
  // OBS-930: the visible pane runs the REAL TUI. Codex's TUI takes its prompt only as the [PROMPT]
273
279
  // positional (`codex --help`, 0.153.4 — no file/stdin form), so the launch inlines the file exactly
274
280
  // as the claude adapter does: the prompt is the LAST positional and every flag value is followed by
275
281
  // a flag, never by the prompt. The argv hazard that once forbade this (OBS-889: `countLiveSuites`
276
282
  // matched a suite word 140 KB into a finished worker's argv) is closed on the counter side — the
277
283
  // census reads a command's first four tokens only — and those four never carry a suite word here.
278
- // Same sandbox, hook trust and MCP suppression as the headless form; `-a never` is the TUI's
284
+ // Same sandbox, hook trust, MCP suppression and worker grant as invoke()'s headless worker form; `-a never` is the TUI's
279
285
  // autonomous approval policy (exec has no approvals to configure).
280
286
  // OBS-930 (Linux): the inlined prompt is ONE argv string and Linux caps one at 131072 bytes, so a
281
287
  // prompt over promptArgvCeiling() returns null → worker-mode-fallback → the headless form (types.ts).
@@ -283,7 +289,7 @@ export const codex = {
283
289
  ? `codex -a never -s workspace-write ${CODEX_HOOK_TRUST} ${codexMcpSuppressionFlags()} ${CODEX_GIT_GRANT}${codexEffortFlag(effort)} --model ${shq(model)} "$(cat ${shq(promptFile)})"`
284
290
  : null,
285
291
  invoke(task, _cwd, a, ctx) {
286
- return { command: this.headlessCommand(ctx.promptFile, a.model, a.effort) };
292
+ return { command: codexExec(ctx.promptFile, a.model, a.effort, ` ${CODEX_GIT_GRANT}`) };
287
293
  },
288
294
  parse: parseWorkerResult,
289
295
  // v1.22 T5: seed [projects."<repoRoot>"] trust_level="trusted" so fresh worktrees never stall on
@@ -1,3 +1,8 @@
1
1
  import { type SupervisionTier } from "../../run/supervision.js";
2
- export declare function beat(argv: string[], cwd?: string): Promise<string>;
2
+ import { type BeatLifecycleDeps } from "../../run/beat-lifecycle.js";
3
+ export declare function beat(argv: string[], cwd?: string, deps?: BeatLifecycleDeps): Promise<string | {
4
+ out: string;
5
+ code: number;
6
+ }>;
7
+ /** Record an explicit hand-off: the tier reads DISARMED, a stood-down tier, not a dead one. */
3
8
  export declare function standDownTier(repoRoot: string, tier: SupervisionTier, seat: string): string;
@@ -1,99 +1,146 @@
1
- import { randomUUID } from "node:crypto";
2
- import { mkdirSync, renameSync, writeFileSync } from "node:fs";
3
- import { dirname } from "node:path";
4
- import { tickmarkrDir } from "../../graph/graph.js";
5
- import { SUPERVISION_BEAT_MS, SUPERVISION_DEFAULT_THRESHOLD_PCT, SUPERVISION_STALE_MS, SUPERVISION_TIERS, beatSupervision, newSupervisionArm, resolveSupervisionRoot, supervisionStandDownPath, } from "../../run/supervision.js";
6
- // One-shot ticks reuse a durable arm; only --new-arm (or --loop) acknowledges a stand-down.
7
- // The loop runs in this process, so its recorded pid belongs to the actual repeating writer.
8
- // Writes are unguarded and a refused ONE-SHOT tick THROWS: the shipped watcher gates on the exit
9
- // code with stdout discarded, so a refusal that exits 0 leaves a live watcher believing it is armed
10
- // while status reads DISARMED. Only the loop returns on stand-down — exiting is its designed end.
11
- const VALUE_OPTIONS = ["--seat", "--arm-id", "--pct", "--threshold-pct"];
12
- export async function beat(argv, cwd = process.cwd()) {
13
- const standDown = argv.includes("--stand-down");
14
- const seat = seatOf(argv);
15
- const pct = percentageOf(argv, "--pct");
16
- const thresholdPct = percentageOf(argv, "--threshold-pct") ?? SUPERVISION_DEFAULT_THRESHOLD_PCT;
17
- const armId = optionOf(argv, "--arm-id");
18
- const named = argv.find((a, i) => !a.startsWith("--") && !VALUE_OPTIONS.includes(argv[i - 1] ?? ""));
19
- if (!isTier(named)) {
20
- throw new Error(`usage: tickmarkr beat <${SUPERVISION_TIERS.join("|")}> --seat <identity> ` +
21
- `[--new-arm] [--loop] [--arm-id <identity> --pct <0..100> --threshold-pct <0..100>] [--stand-down] — ` +
22
- `got ${named ? `\`${named}\`` : "no tier"}`);
1
+ import { parseArgs } from "node:util";
2
+ import { SUPERVISION_BEAT_MS, SUPERVISION_DEFAULT_THRESHOLD_PCT, SUPERVISION_STALE_MS, SUPERVISION_TIERS, beatSupervision, locateSupervisionRoot, newSupervisionArm, publishStandDown, readSupervisionArm, resolveSupervisionRoot, } from "../../run/supervision.js";
3
+ import { BEAT_GENERATION_ENV, beatStatus, defaultBeatDeps, startBeat, stopBeat, withLegacyClaim, } from "../../run/beat-lifecycle.js";
4
+ // Two forms share one strict parser. The lifecycle verbs `start|stop|status <tier>` own a DETACHED loop
5
+ // (src/run/beat-lifecycle.ts); the legacy `<tier>` form keeps its duties: one-shot ticks reuse a durable
6
+ // arm, only --new-arm (or --loop) acknowledges a stand-down, and --status is the read-only status verb.
7
+ // Every refusal is NONZERO: the shipped watcher gates on the exit code with stdout discarded, so a refusal
8
+ // that exits 0 leaves a live watcher believing it is armed while status reads DISARMED — and a loop that is
9
+ // stood down, superseded or cannot write exits nonzero with its reason instead of going silently quiet.
10
+ const VERBS = ["start", "stop", "status"];
11
+ const LEGACY_WRITER_OPTIONS = ["new-arm", "loop", "arm-id", "pct", "threshold-pct", "stand-down"];
12
+ const usage = (got) => `usage: tickmarkr beat <start|stop|status> <tier> --seat <identity> | tickmarkr beat <${SUPERVISION_TIERS.join("|")}> --seat <identity> ` +
13
+ `[--new-arm] [--loop] [--arm-id <identity> --pct <0..100> --threshold-pct <0..100>] [--stand-down] [--status] — ` +
14
+ `got ${got ? `\`${got}\`` : "no tier"}`;
15
+ export async function beat(argv, cwd = process.cwd(), deps = defaultBeatDeps()) {
16
+ const parsed = parseBeat(argv);
17
+ const { verb, tier, seat, values } = parsed;
18
+ if (verb === "status" || values.status) {
19
+ // Read-only by construction: locating the root never creates it, and a repository without a state
20
+ // dir has, truthfully, no beat recorded.
21
+ const { root, state } = locateSupervisionRoot(cwd);
22
+ return state ? beatStatus(root, tier, seat, deps) : { out: `${tier} ABSENT — no state directory`, code: 0 };
23
23
  }
24
24
  // SUP-05: NO SEAT, NO BEAT — and the refusal comes before any write, so a refused invocation leaves
25
- // the tier exactly as it found it. In one-shot mode the exitedWriterPid it records has
26
- // already exited by the time anyone reads the record, so tier + writer + instant is a beat nobody can
27
- // attribute to a seat. Measured 2026-08-26: a consult seat ran the documented loop verbatim and the
28
- // board read that tier ARMED with no seat of that tier having armed anything, and a seatless ARMED
29
- // reads as coverage — worse than ABSENT, because ABSENT sends someone to look.
25
+ // the tier exactly as it found it. Tier + writer + instant is a beat nobody can attribute to a seat
26
+ // (measured 2026-08-26), and a seatless ARMED reads as coverage — worse than ABSENT.
30
27
  if (!seat) {
31
- throw new Error(`${named} needs --seat <identity> — a beat that names no seat arms a tier nobody occupies` +
28
+ throw new Error(`${verb ? `${verb} ${tier}` : tier} needs --seat <identity> — a beat that names no seat arms a tier nobody occupies` +
32
29
  " (pass the seat's own pane id or agent name)");
33
30
  }
34
31
  // A beat names repository state, never the caller's incidental directory. Resolution is read-only
35
32
  // and happens before every write, so a non-repository invocation cannot create the state it claims.
36
33
  const repoRoot = resolveSupervisionRoot(cwd);
37
- if (standDown)
38
- return standDownTier(repoRoot, named, seat);
39
- const loop = argv.includes("--loop");
40
- const arm = argv.includes("--new-arm") || loop ? newSupervisionArm(repoRoot, named, armId) : undefined;
34
+ if (verb === "start")
35
+ return startBeat(repoRoot, tier, seat, deps);
36
+ if (verb === "stop")
37
+ return stopBeat(repoRoot, tier, seat, deps);
38
+ const generation = deps.env[BEAT_GENERATION_ENV]?.trim() || undefined;
39
+ const writer = (settled = false, arm) => ({ generation, seat, settled, arm, deps });
40
+ if (values["stand-down"]) {
41
+ return withLegacyClaim(repoRoot, tier, writer(), (recheck) => { recheck(); return standDownTier(repoRoot, tier, seat); });
42
+ }
43
+ const loop = values.loop === true;
44
+ const armId = values["arm-id"];
45
+ const pct = percentage(values.pct, "--pct");
46
+ const thresholdPct = percentage(values["threshold-pct"], "--threshold-pct") ?? SUPERVISION_DEFAULT_THRESHOLD_PCT;
41
47
  // Context arm ids retain their existing obligation semantics; they do not implicitly re-arm liveness.
42
- const observation = pct === undefined ? undefined : { armId: armId ?? arm?.armId ?? seat, pct, thresholdPct };
43
- do {
44
- if (!beatSupervision(repoRoot, named, seat, observation, { arm, armId, loop })) {
45
- const refusal = `${named} DISARMED — stood down; use --new-arm to resume beating`;
46
- if (loop)
47
- return refusal;
48
- throw new Error(refusal);
48
+ const observe = (arm) => pct === undefined ? undefined : { armId: armId ?? arm?.armId ?? seat, pct, thresholdPct };
49
+ // EVERY mutation — the one-shot tick, the arm, and each later loop tick — runs under the tier claim, and
50
+ // `recheck` re-reads that claim and the tier's ownership immediately before the write: a generation-carrying
51
+ // writer mutates only while its COMPLETE generation (token, seat, pid once settled, and the owner's durable
52
+ // arm id + epoch) is on record; every other writer only while no detached beat owns the tier. `created` marks
53
+ // the arm this same claim just published: an owned re-arm rebinds the owner record to it — only while that arm
54
+ // and the owner checked before it are still the ones on disk — and is checked again as that new generation
55
+ // before its beat. A running loop carries its arm into every check. A claim someone else holds refuses with
56
+ // no bytes changed.
57
+ const tick = (recheck, arm, created = false) => {
58
+ recheck(arm, created);
59
+ if (beatSupervision(repoRoot, tier, seat, observe(arm), { arm, armId, loop }))
60
+ return;
61
+ const current = arm && readSupervisionArm(repoRoot, tier);
62
+ if (arm && (current?.armId !== arm.armId || current.armEpoch !== arm.armEpoch)) {
63
+ throw new Error(`${tier} superseded — a newer arm replaced this loop's arm ${arm.armId}; exiting`);
49
64
  }
50
- if (!loop)
51
- break;
52
- await new Promise((resolve) => setTimeout(resolve, SUPERVISION_BEAT_MS));
53
- } while (loop);
54
- return `${named} ARMED as ${seat} — beat again every ${SUPERVISION_BEAT_MS / 1_000}s; the tier reads STALE ${SUPERVISION_STALE_MS / 1_000}s after the last beat`;
55
- }
56
- /** `--seat <identity>` or `--seat=<identity>`; blank and missing are the same answer — none. */
57
- function seatOf(argv) {
58
- const seat = optionOf(argv, "--seat")?.trim();
59
- return seat && !seat.startsWith("--") ? seat : undefined;
65
+ throw new Error(`${tier} DISARMED — stood down; use --new-arm to resume beating`);
66
+ };
67
+ if (values["new-arm"] || loop) {
68
+ const arm = await withLegacyClaim(repoRoot, tier, writer(), (recheck) => {
69
+ recheck();
70
+ const created = newSupervisionArm(repoRoot, tier, armId);
71
+ tick(recheck, created, true);
72
+ return created;
73
+ });
74
+ // ponytail: a running loop outlasts a brief claim holder (a repeat start, a status-side stop) for ~5 s of
75
+ // retries, then refuses nonzero; a per-tick deadline if holders ever legitimately run longer.
76
+ const waits = { tries: 20, sleep: deps.sleep };
77
+ while (loop) {
78
+ await deps.sleep(SUPERVISION_BEAT_MS);
79
+ await withLegacyClaim(repoRoot, tier, writer(true, arm), (recheck) => tick(recheck, arm), waits);
80
+ }
81
+ }
82
+ else
83
+ await withLegacyClaim(repoRoot, tier, writer(), (recheck) => tick(recheck));
84
+ return `${tier} ARMED as ${seat} — beat again every ${SUPERVISION_BEAT_MS / 1_000}s; the tier reads STALE ${SUPERVISION_STALE_MS / 1_000}s after the last beat`;
60
85
  }
61
- /** A `--name value` or `--name=value` option, excluding a missing value or the next flag. */
62
- function optionOf(argv, name) {
63
- const inline = argv.find((a) => a.startsWith(`${name}=`))?.slice(name.length + 1).trim();
64
- const spaced = argv[argv.indexOf(name) + 1]?.trim();
65
- const value = inline ?? (argv.includes(name) ? spaced : undefined);
66
- return value && !value.startsWith("--") ? value : undefined;
86
+ /** Unknown, duplicate, conflicting or empty options refuse here — before anything is read or written. */
87
+ function parseBeat(argv) {
88
+ let parsed;
89
+ try {
90
+ parsed = parseArgs({
91
+ args: argv, allowPositionals: true, strict: true, tokens: true,
92
+ options: {
93
+ seat: { type: "string" }, "new-arm": { type: "boolean" }, loop: { type: "boolean" },
94
+ "arm-id": { type: "string" }, pct: { type: "string" }, "threshold-pct": { type: "string" },
95
+ "stand-down": { type: "boolean" }, status: { type: "boolean" },
96
+ },
97
+ });
98
+ }
99
+ catch (error) {
100
+ throw new Error(`${error.message} — ${usage(undefined).replace(/ — got .*$/, "")}`);
101
+ }
102
+ const seen = new Set();
103
+ for (const token of parsed.tokens) {
104
+ if (token.kind !== "option")
105
+ continue;
106
+ if (seen.has(token.name))
107
+ throw new Error(`duplicate --${token.name} — pass each option once`);
108
+ seen.add(token.name);
109
+ if (typeof token.value === "string" && !token.value.trim())
110
+ throw new Error(`--${token.name} needs a non-empty value`);
111
+ }
112
+ const values = parsed.values;
113
+ const [first, ...rest] = parsed.positionals;
114
+ const verb = VERBS.includes(first ?? "") ? first : undefined;
115
+ const named = verb ? rest[0] : first;
116
+ if (!isTier(named))
117
+ throw new Error(usage(verb && named === undefined ? `${verb} with no tier` : named));
118
+ const extra = (verb ? rest.slice(1) : rest)[0];
119
+ if (extra !== undefined)
120
+ throw new Error(`unexpected argument \`${extra}\` — ${usage(named).replace(/ — got .*$/, "")}`);
121
+ const readOnly = verb !== undefined || values.status;
122
+ const writer = LEGACY_WRITER_OPTIONS.find((name) => values[name] !== undefined);
123
+ if (readOnly && writer)
124
+ throw new Error(`--${writer} conflicts with ${verb ? `beat ${verb}` : "--status"}`);
125
+ if (verb && values.status)
126
+ throw new Error(`--status conflicts with beat ${verb}`);
127
+ const withStandDown = values["stand-down"] && LEGACY_WRITER_OPTIONS.find((name) => name !== "stand-down" && values[name] !== undefined);
128
+ if (withStandDown)
129
+ throw new Error(`--${withStandDown} conflicts with --stand-down`);
130
+ return { ...(verb ? { verb } : {}), tier: named, ...(values.seat ? { seat: values.seat.trim() } : {}), values };
67
131
  }
68
- function percentageOf(argv, name) {
69
- const raw = optionOf(argv, name);
70
- if (raw === undefined) {
71
- if (argv.includes(name) || argv.some((a) => a.startsWith(`${name}=`))) {
72
- throw new Error(`${name} needs a number from 0 through 100`);
73
- }
132
+ function percentage(raw, name) {
133
+ if (raw === undefined)
74
134
  return undefined;
75
- }
76
135
  const value = Number(raw);
77
136
  if (!Number.isFinite(value) || value < 0 || value > 100) {
78
137
  throw new Error(`${name} must be a number from 0 through 100`);
79
138
  }
80
139
  return value;
81
140
  }
82
- // Stand-down is a RECORDED act, not a silence: the marker tells a reader this watcher left on purpose,
83
- // so the tier reads DISARMED rather than ageing out as a death. Published atomically — written aside,
84
- // renamed over — because a torn marker is rejected by the reader, and a rejected stand-down reports a
85
- // deliberate hand-off as a death.
141
+ /** Record an explicit hand-off: the tier reads DISARMED, a stood-down tier, not a dead one. */
86
142
  export function standDownTier(repoRoot, tier, seat) {
87
- tickmarkrDir(repoRoot); // the write path DOES create — markers land inside the gitignored state dir
88
- const p = supervisionStandDownPath(repoRoot, tier);
89
- const tmp = `${p}.${randomUUID()}.tmp`;
90
- mkdirSync(dirname(p), { recursive: true });
91
- // The marker names the seat for the same reason the beat does: "someone stood this tier down" is not
92
- // a hand-off anyone can act on, and on a seat tier the reader rejects an anonymous one outright.
93
- writeFileSync(tmp, JSON.stringify({
94
- tier, seat, standDownId: randomUUID(), exitedWriterPid: process.pid, disarmedAt: new Date().toISOString(),
95
- }) + "\n");
96
- renameSync(tmp, p);
143
+ publishStandDown(repoRoot, tier, seat);
97
144
  return `${tier} DISARMED — ${seat} handed off; status reads it stood down, not dead`;
98
145
  }
99
146
  const isTier = (v) => SUPERVISION_TIERS.includes(v ?? "");