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.
- package/dist/adapters/claude-code.js +8 -2
- package/dist/adapters/codex-commit-check.d.ts +3 -2
- package/dist/adapters/codex-commit-check.js +37 -10
- package/dist/adapters/codex.js +10 -4
- package/dist/cli/commands/beat.d.ts +6 -1
- package/dist/cli/commands/beat.js +123 -76
- package/dist/cli/commands/fleet.js +64 -21
- package/dist/cli/commands/init.js +18 -7
- package/dist/cli/help.d.ts +1 -0
- package/dist/cli/help.js +10 -5
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/gates/baseline.d.ts +9 -0
- package/dist/gates/baseline.js +14 -3
- package/dist/gates/cache.js +1 -1
- package/dist/gates/review.js +15 -0
- package/dist/gates/run-gates.d.ts +18 -6
- package/dist/gates/run-gates.js +324 -204
- package/dist/gates/test-manifest.d.ts +8 -0
- package/dist/gates/test-manifest.js +151 -111
- package/dist/run/beat-lifecycle.d.ts +154 -0
- package/dist/run/beat-lifecycle.js +825 -0
- package/dist/run/daemon.js +18 -7
- package/dist/run/git-trust.d.ts +27 -0
- package/dist/run/git-trust.js +178 -0
- package/dist/run/git.js +34 -14
- package/dist/run/journal.js +15 -3
- package/dist/run/lease.d.ts +23 -0
- package/dist/run/lease.js +232 -38
- package/dist/run/merge.js +2 -2
- package/dist/run/stall.js +20 -1
- package/dist/run/supervision.d.ts +8 -0
- package/dist/run/supervision.js +24 -4
- package/dist/run/verification-job.d.ts +51 -0
- package/dist/run/verification-job.js +137 -0
- package/dist/tui/ink/fleet-app.d.ts +9 -0
- package/dist/tui/ink/fleet-app.js +209 -127
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +19 -10
- package/skills/tickmarkr-loop/SKILL.md +19 -10
- 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
|
|
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
|
|
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)
|
|
35
|
-
*
|
|
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) =>
|
|
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
|
|
56
|
-
&& await
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
200
|
-
*
|
|
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
|
|
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
|
}
|
package/dist/adapters/codex.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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.
|
|
26
|
-
//
|
|
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(`${
|
|
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 (
|
|
38
|
-
return
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
/**
|
|
62
|
-
function
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?? "");
|