tickmarkr 2.8.4 → 2.8.6
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 +3 -2
- package/dist/adapters/codex-commit-check.d.ts +4 -3
- package/dist/adapters/codex-commit-check.js +44 -29
- package/dist/adapters/codex.d.ts +2 -2
- package/dist/adapters/codex.js +38 -32
- package/dist/adapters/cursor-agent.js +2 -1
- package/dist/adapters/grok.js +2 -1
- package/dist/adapters/kimi.js +11 -9
- package/dist/adapters/model-lints.js +4 -3
- package/dist/adapters/opencode.js +2 -1
- package/dist/adapters/pi.js +3 -2
- package/dist/adapters/qwen.js +2 -1
- package/dist/adapters/registry.js +2 -1
- package/dist/adapters/types.d.ts +10 -1
- package/dist/adapters/types.js +18 -3
- package/dist/cli/commands/approve.js +9 -28
- package/dist/cli/commands/doctor.d.ts +49 -5
- package/dist/cli/commands/doctor.js +956 -94
- package/dist/cli/commands/fleet.js +11 -3
- package/dist/cli/commands/init.js +27 -19
- package/dist/cli/commands/status.js +9 -6
- package/dist/cli/commands/verify.js +12 -3
- package/dist/compile/gsd.js +4 -3
- package/dist/compile/index.js +4 -1
- package/dist/compile/native.js +8 -6
- package/dist/config/config.d.ts +15 -0
- package/dist/config/config.js +67 -5
- package/dist/drivers/host-env.d.ts +3 -0
- package/dist/drivers/host-env.js +47 -0
- package/dist/drivers/orca.js +3 -1
- package/dist/drivers/subprocess.d.ts +1 -1
- package/dist/drivers/subprocess.js +4 -3
- package/dist/gates/baseline.d.ts +1 -1
- package/dist/gates/baseline.js +4 -3
- package/dist/gates/cache.js +2 -4
- package/dist/graph/schema.js +3 -1
- package/dist/run/daemon.js +54 -4
- package/dist/run/git-trust.d.ts +11 -0
- package/dist/run/git-trust.js +1 -1
- package/dist/run/git.d.ts +13 -1
- package/dist/run/git.js +34 -14
- package/dist/run/journal.d.ts +15 -1
- package/dist/run/journal.js +83 -17
- package/dist/run/lock.d.ts +5 -0
- package/dist/run/lock.js +202 -9
- package/dist/run/protocol.d.ts +1 -1
- package/dist/tui/cockpit/live-store.js +12 -2
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +65 -21
- package/skills/tickmarkr-loop/SKILL.md +65 -21
- package/skills/tickmarkr-overseer/SKILL.md +88 -32
package/dist/run/lock.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { closeSync, constants, fstatSync, linkSync, lstatSync, mkdtempSync, openSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { closeSync, constants, fstatSync, linkSync, lstatSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { tickmarkrDir, stateDirName } from "../graph/graph.js";
|
|
5
|
+
import { GitTrustRefusal, trustedCommonDir } from "./git-trust.js";
|
|
5
6
|
import { parseRunId } from "./journal.js";
|
|
6
7
|
// HARD-01/02: coarse per-run advisory lock over .tickmarkr/graph.json. LOCK-02: the lock is created by
|
|
7
8
|
// the link(2) idiom — write the full payload to graph.lock.<pid>.tmp, then linkSync(tmp, lockPath),
|
|
@@ -17,16 +18,31 @@ export const STALE_MS = 60_000; // 6× heartbeat headroom; PITFALLS floor is ≥
|
|
|
17
18
|
// T-10-01: the payload is a trust boundary — parse with zod, fail closed on garbage. Only
|
|
18
19
|
// pid/runId/startedAt are ever read; anything else is ignored (info-disclosure surface).
|
|
19
20
|
const PayloadSchema = z.object({ pid: z.number().int().positive(), runId: z.string(), startedAt: z.number() });
|
|
21
|
+
// Queue row 194 (D-2102): graph.lock lives per CHECKOUT, so a run in a linked worktree (Claude Desktop
|
|
22
|
+
// nests each session under <repo>/.claude/worktrees/) never saw a run in the main checkout. The daemon's
|
|
23
|
+
// acquire therefore also takes this REPOSITORY RUN CLAIM at the trusted common git dir — the runner
|
|
24
|
+
// lease's anchor — by the same link(2) idiom and the same decision table (shouldRefuse). compile's two
|
|
25
|
+
// holders take graph.lock only: it guards that checkout's own graph, never the repository's run.
|
|
26
|
+
const ClaimSchema = PayloadSchema.extend({ checkout: z.string() });
|
|
27
|
+
const MarkerSchema = z.object({ pid: z.number().int().positive(), startedAt: z.number() });
|
|
28
|
+
const CLAIM_FILE = "tickmarkr-run.claim";
|
|
29
|
+
const COMPILE_HOLDERS = new Set(["compile", "compile-refusal"]);
|
|
30
|
+
const NO_CHECKOUT = "no checkout encloses this directory"; // git-trust's refusal detail outside any checkout
|
|
20
31
|
const lockPath = (repoRoot) => join(tickmarkrDir(repoRoot), "graph.lock");
|
|
21
32
|
const approvalSerializationPath = (repoRoot) => join(tickmarkrDir(repoRoot), "approval.lock");
|
|
22
33
|
let heartbeat;
|
|
23
34
|
let heldPath;
|
|
35
|
+
const heldClaims = new Map(); // graph.lock path → its repository claim
|
|
24
36
|
let approvalSequence = 0;
|
|
25
37
|
// Best-effort release if the daemon exits without hitting its finally (crash mid-body). NO
|
|
26
38
|
// SIGINT/SIGTERM handlers — those change kill semantics and leak listeners across the test
|
|
27
39
|
// suite's many runDaemon calls; signal-death is exactly what stale-reclaim exists for.
|
|
28
|
-
process.once("exit", () => {
|
|
29
|
-
|
|
40
|
+
process.once("exit", () => {
|
|
41
|
+
for (const claim of heldClaims.values())
|
|
42
|
+
unlinkClaimIfOurs(claim);
|
|
43
|
+
if (heldPath)
|
|
44
|
+
unlinkIfOurs(heldPath);
|
|
45
|
+
});
|
|
30
46
|
// LOCK-04: the ONE decision table. Both acquireRunLock and isRunLockLive consume this — the two
|
|
31
47
|
// hand-maintained copies of the rule cannot drift. The table changes HERE, once. previewUnlock /
|
|
32
48
|
// commitUnlock (below) read the SAME underlying snapshot shape with their own eligibility rules
|
|
@@ -65,16 +81,17 @@ export function isPidLive(pid) {
|
|
|
65
81
|
}
|
|
66
82
|
}
|
|
67
83
|
// statSync throws ENOENT when no lock exists — callers treat that as "not held".
|
|
68
|
-
function inspect(p) {
|
|
84
|
+
function inspect(p, schema = PayloadSchema) {
|
|
69
85
|
const st = statSync(p); // single stat: both the heartbeat mtime and the reclaim-guard inode
|
|
70
86
|
const mtimeMs = st.mtimeMs;
|
|
71
87
|
const expired = Date.now() - mtimeMs > STALE_MS;
|
|
72
|
-
const parsed =
|
|
88
|
+
const parsed = schema.safeParse(readPayload(p));
|
|
73
89
|
const garbage = !parsed.success; // LOCK-01: its own state — shouldRefuse refuses it unconditionally; only `tickmarkr unlock` removes it
|
|
74
90
|
const pid = parsed.success ? parsed.data.pid : undefined;
|
|
75
91
|
// harmless fallback for the garbage row — garbage short-circuits shouldRefuse before this is read
|
|
76
92
|
const dead = pid === undefined ? true : !isPidLive(pid);
|
|
77
|
-
|
|
93
|
+
const checkout = parsed.success ? parsed.data.checkout : undefined;
|
|
94
|
+
return { pid, runId: parsed.success ? parsed.data.runId : undefined, startedAt: parsed.success ? parsed.data.startedAt : undefined, checkout, garbage, dead, expired, mtimeMs, ino: st.ino };
|
|
78
95
|
}
|
|
79
96
|
function readPayload(p) {
|
|
80
97
|
try {
|
|
@@ -92,7 +109,177 @@ function unlinkIfOurs(p) {
|
|
|
92
109
|
}
|
|
93
110
|
catch { /* ENOENT — nothing to release */ }
|
|
94
111
|
}
|
|
112
|
+
function unlinkClaimIfOurs(claim) {
|
|
113
|
+
try {
|
|
114
|
+
const parsed = ClaimSchema.safeParse(readPayload(claim.path));
|
|
115
|
+
if (parsed.success && parsed.data.pid === process.pid && parsed.data.checkout === claim.checkout)
|
|
116
|
+
unlinkSync(claim.path);
|
|
117
|
+
}
|
|
118
|
+
catch { /* ENOENT — nothing to release */ }
|
|
119
|
+
}
|
|
120
|
+
/** The repository run claim for the checkout enclosing `repoRoot`; undefined when no checkout encloses it
|
|
121
|
+
* (graph.lock alone guards there, as before). Any other git trust refusal propagates: fail closed. */
|
|
122
|
+
export function repositoryClaimPath(repoRoot) {
|
|
123
|
+
try {
|
|
124
|
+
return join(trustedCommonDir(repoRoot), CLAIM_FILE);
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
if (e instanceof GitTrustRefusal && e.detail === NO_CHECKOUT)
|
|
128
|
+
return undefined;
|
|
129
|
+
throw e;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// The case table: a live holder from ANY other checkout of this repository refuses naming its checkout,
|
|
133
|
+
// run id and pid; a provably dead holder is reclaimed, then the taker re-serializes through link(2);
|
|
134
|
+
// garbage refuses and is removed by hand only. A holder in another repository never meets this path: its
|
|
135
|
+
// common dir — and so its claim — differs.
|
|
136
|
+
function takeRepositoryClaim(repoRoot, runId) {
|
|
137
|
+
const p = repositoryClaimPath(repoRoot);
|
|
138
|
+
if (!p)
|
|
139
|
+
return;
|
|
140
|
+
const checkout = realpathSync(repoRoot);
|
|
141
|
+
while (!linkExclusive(p, { pid: process.pid, runId, startedAt: Date.now(), checkout })) {
|
|
142
|
+
const held = readClaim(p);
|
|
143
|
+
if (!held)
|
|
144
|
+
continue; // released between our link and our read
|
|
145
|
+
if (shouldRefuse(held.insp))
|
|
146
|
+
refuseClaim(p, held.insp);
|
|
147
|
+
reclaimDeadClaim(p, held.id);
|
|
148
|
+
}
|
|
149
|
+
heldClaims.set(lockPath(repoRoot), { path: p, checkout });
|
|
150
|
+
// While this live claim is the claim, every reclaim marker is keyed to a claim gone for good — moot,
|
|
151
|
+
// guarding nothing — so the taker sweeps them all; a reclaimer that died holding one strands nothing.
|
|
152
|
+
const prefix = `${basename(p)}.reclaim.`;
|
|
153
|
+
try {
|
|
154
|
+
for (const name of readdirSync(dirname(p)))
|
|
155
|
+
if (name.startsWith(prefix))
|
|
156
|
+
try {
|
|
157
|
+
unlinkSync(join(dirname(p), name));
|
|
158
|
+
}
|
|
159
|
+
catch { /* another taker swept it */ }
|
|
160
|
+
}
|
|
161
|
+
catch { /* best-effort: a later taker sweeps what is left */ }
|
|
162
|
+
}
|
|
163
|
+
// link(2) exclusive create of a complete payload: true when created, false when `p` already exists.
|
|
164
|
+
function linkExclusive(p, payload) {
|
|
165
|
+
const tmp = join(dirname(p), `${CLAIM_FILE}.${process.pid}.tmp`); // one per process: the claim and its markers share it
|
|
166
|
+
try {
|
|
167
|
+
unlinkSync(tmp);
|
|
168
|
+
}
|
|
169
|
+
catch { /* best-effort clean of our own stray tmp from a prior SIGKILL */ }
|
|
170
|
+
writeFileSync(tmp, JSON.stringify(payload));
|
|
171
|
+
try {
|
|
172
|
+
linkSync(tmp, p);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
if (e.code === "EEXIST")
|
|
177
|
+
return false;
|
|
178
|
+
throw e;
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
unlinkSync(tmp);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// A claim's identity is its inode, pid and start time: a claim is never rewritten in place, so an identity
|
|
185
|
+
// once replaced never returns. undefined when no claim exists.
|
|
186
|
+
function readClaim(p) {
|
|
187
|
+
try {
|
|
188
|
+
const insp = inspect(p, ClaimSchema);
|
|
189
|
+
return { insp, id: `${insp.ino}-${insp.pid}-${insp.startedAt}` };
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
if (e.code === "ENOENT")
|
|
193
|
+
return undefined;
|
|
194
|
+
throw e;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// The reclaim runs under an exclusive RECLAIM MARKER beside the claim, keyed to the dead claim's identity:
|
|
198
|
+
// two takers that both saw one dead claim could otherwise each pass a re-stat, and the later unlink would
|
|
199
|
+
// delete the earlier taker's live replacement. Under the marker the claim is re-read, and only that same
|
|
200
|
+
// dead claim is removed. A live marker owner refuses the other taker — a concurrent reclaimer is a run
|
|
201
|
+
// starting in this repository. A marker whose owner died inside the reclaim is STRANDED: the next taker
|
|
202
|
+
// passes to the next generation, which only one taker creates — a dead owner never removes its marker, so
|
|
203
|
+
// two live owners never hold one identity at once — and recovers with no hand removal.
|
|
204
|
+
// ponytail: a stranded marker whose dead pid is reused by an unrelated live process reads live and refuses
|
|
205
|
+
// until the next successful taker sweeps it or it is removed by hand — the fail-closed direction, as graph.lock.
|
|
206
|
+
function reclaimDeadClaim(p, id) {
|
|
207
|
+
let marker = `${p}.reclaim.${id}.0`;
|
|
208
|
+
for (let gen = 0; !linkExclusive(marker, { pid: process.pid, startedAt: Date.now() });) {
|
|
209
|
+
if (markerStranded(p, marker))
|
|
210
|
+
marker = `${p}.reclaim.${id}.${++gen}`;
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const now = readClaim(p);
|
|
214
|
+
if (now?.id !== id)
|
|
215
|
+
return; // replaced since our read: the caller re-serializes through link(2)
|
|
216
|
+
if (shouldRefuse(now.insp))
|
|
217
|
+
refuseClaim(p, now.insp);
|
|
218
|
+
try {
|
|
219
|
+
unlinkSync(p);
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
if (e.code !== "ENOENT")
|
|
223
|
+
throw e;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
try {
|
|
228
|
+
unlinkSync(marker);
|
|
229
|
+
}
|
|
230
|
+
catch { /* swept as moot by the claim's next taker */ }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// true when `marker`'s owner provably died holding it: dead, and the same bytes on the same inode after the
|
|
234
|
+
// death proof. false when it went or changed under the read (that generation is retried). A live owner
|
|
235
|
+
// refuses; a garbage marker refuses for removal by hand.
|
|
236
|
+
function markerStranded(p, marker) {
|
|
237
|
+
const read = () => {
|
|
238
|
+
try {
|
|
239
|
+
return { ino: statSync(marker).ino, bytes: readFileSync(marker, "utf8") };
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
if (e.code === "ENOENT")
|
|
243
|
+
return undefined;
|
|
244
|
+
throw e;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
const before = read();
|
|
248
|
+
if (!before)
|
|
249
|
+
return false;
|
|
250
|
+
let owner;
|
|
251
|
+
try {
|
|
252
|
+
owner = MarkerSchema.parse(JSON.parse(before.bytes));
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
throw new Error(`reclaim marker ${marker} holds an unreadable/garbage payload — refusing to reclaim repository run claim ${p}; once no tickmarkr run is starting in this repository, remove ${marker} by hand`);
|
|
256
|
+
}
|
|
257
|
+
if (isPidLive(owner.pid))
|
|
258
|
+
throw new Error(`repository run claim ${p} is being reclaimed from its dead holder by another tickmarkr process (pid ${owner.pid}) — retry; if none is starting in this repository, remove ${marker} by hand`);
|
|
259
|
+
const after = read();
|
|
260
|
+
return after !== undefined && after.ino === before.ino && after.bytes === before.bytes;
|
|
261
|
+
}
|
|
262
|
+
function refuseClaim(p, insp) {
|
|
263
|
+
if (insp.garbage)
|
|
264
|
+
throw new Error(`repository run claim ${p} holds an unreadable/garbage payload — refusing to reclaim it; once no tickmarkr run is live in this repository, remove ${p} by hand`);
|
|
265
|
+
throw new Error(`repository run claim ${p} held by pid ${insp.pid} (run ${insp.runId}) in checkout ${insp.checkout} — one live tickmarkr run per repository across its main checkout and linked worktrees; wait for that run to end`);
|
|
266
|
+
}
|
|
267
|
+
// Run and resume (runDaemon) take graph.lock, then the repository claim; a claim refusal releases the
|
|
268
|
+
// graph.lock just taken, before any journal exists. compile's holders take graph.lock alone.
|
|
95
269
|
export function acquireRunLock(repoRoot, runId) {
|
|
270
|
+
const acquired = acquireGraphLock(repoRoot, runId);
|
|
271
|
+
if (COMPILE_HOLDERS.has(runId))
|
|
272
|
+
return acquired;
|
|
273
|
+
try {
|
|
274
|
+
takeRepositoryClaim(repoRoot, runId);
|
|
275
|
+
}
|
|
276
|
+
catch (e) {
|
|
277
|
+
releaseRunLock(repoRoot);
|
|
278
|
+
throw e;
|
|
279
|
+
}
|
|
280
|
+
return acquired;
|
|
281
|
+
}
|
|
282
|
+
function acquireGraphLock(repoRoot, runId) {
|
|
96
283
|
const p = lockPath(repoRoot);
|
|
97
284
|
const tmp = join(tickmarkrDir(repoRoot), `graph.lock.${process.pid}.tmp`);
|
|
98
285
|
try {
|
|
@@ -127,7 +314,7 @@ export function acquireRunLock(repoRoot, runId) {
|
|
|
127
314
|
// Single-machine tool — this is the ceiling.
|
|
128
315
|
const st = statSync(p); // re-stat immediately before removing — abort if replaced since inspect()
|
|
129
316
|
if (st.ino !== insp.ino || st.mtimeMs !== insp.mtimeMs)
|
|
130
|
-
return
|
|
317
|
+
return acquireGraphLock(repoRoot, runId); // loser path
|
|
131
318
|
try {
|
|
132
319
|
unlinkSync(p);
|
|
133
320
|
}
|
|
@@ -135,7 +322,7 @@ export function acquireRunLock(repoRoot, runId) {
|
|
|
135
322
|
if (e2.code !== "ENOENT")
|
|
136
323
|
throw e2;
|
|
137
324
|
} // successor already took it
|
|
138
|
-
const again =
|
|
325
|
+
const again = acquireGraphLock(repoRoot, runId); // re-serializes two stealers through linkSync
|
|
139
326
|
return { reclaimed: { pid: pid ?? -1, mtimeMs }, ...again };
|
|
140
327
|
}
|
|
141
328
|
const stateDir = stateDirName(repoRoot);
|
|
@@ -151,6 +338,12 @@ export function releaseRunLock(repoRoot) {
|
|
|
151
338
|
clearInterval(heartbeat);
|
|
152
339
|
heartbeat = undefined;
|
|
153
340
|
}
|
|
341
|
+
// the claim goes first: released after graph.lock, a successor run in this checkout would meet our claim
|
|
342
|
+
const claim = heldClaims.get(lockPath(repoRoot));
|
|
343
|
+
if (claim) {
|
|
344
|
+
unlinkClaimIfOurs(claim);
|
|
345
|
+
heldClaims.delete(lockPath(repoRoot));
|
|
346
|
+
}
|
|
154
347
|
unlinkIfOurs(lockPath(repoRoot)); // never delete a reclaiming successor's lock — only ours
|
|
155
348
|
heldPath = undefined;
|
|
156
349
|
}
|
package/dist/run/protocol.d.ts
CHANGED
|
@@ -75,8 +75,8 @@ export declare const GateEvidenceReceiptSchema: z.ZodObject<{
|
|
|
75
75
|
termination: z.ZodObject<{
|
|
76
76
|
kind: z.ZodEnum<{
|
|
77
77
|
unknown: "unknown";
|
|
78
|
-
timeout: "timeout";
|
|
79
78
|
signal: "signal";
|
|
79
|
+
timeout: "timeout";
|
|
80
80
|
"not-started": "not-started";
|
|
81
81
|
exit: "exit";
|
|
82
82
|
}>;
|
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { closeSync, fstatSync, openSync, readSync, statSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { graphPath, stateDirName } from "../../graph/graph.js";
|
|
5
|
+
import { repoOverlayPath } from "../../config/config.js";
|
|
5
6
|
import { validateGraph } from "../../graph/schema.js";
|
|
6
7
|
import { foldOwedChecks, parseJournalText, parseRunId } from "../../run/journal.js";
|
|
7
8
|
import { isPidLive, STALE_MS } from "../../run/lock.js";
|
|
@@ -329,7 +330,16 @@ export function createLiveStore(options) {
|
|
|
329
330
|
reset: () => { fold = new OperatorStateFold(); }, record: record => fold.apply(record),
|
|
330
331
|
});
|
|
331
332
|
const graph = new JsonSource(graphPath(options.cwd), raw => validateGraph(JSON.parse(raw)), STORE_LIMITS.graphBytes);
|
|
332
|
-
|
|
333
|
+
// Queue row 195 / D-2102: the cockpit's live config source follows the ONE repo-layer path rule, re-resolved
|
|
334
|
+
// on every observation so an own overlay created or removed, or a main file
|
|
335
|
+
// appearing, moves the source; an explicit configPath stays fixed.
|
|
336
|
+
let config;
|
|
337
|
+
const readConfig = (at) => {
|
|
338
|
+
const path = options.configPath ?? repoOverlayPath(options.cwd);
|
|
339
|
+
if (config?.path !== path)
|
|
340
|
+
config = new JsonSource(path, raw => raw);
|
|
341
|
+
return config.read(at);
|
|
342
|
+
};
|
|
333
343
|
const cache = new JsonSource(options.cachePath ?? join(state, "doctor.json"), JSON.parse);
|
|
334
344
|
const lock = new JsonSource(join(state, "graph.lock"), raw => {
|
|
335
345
|
const v = JSON.parse(raw);
|
|
@@ -379,7 +389,7 @@ export function createLiveStore(options) {
|
|
|
379
389
|
owed = owedOf(journal);
|
|
380
390
|
}
|
|
381
391
|
const graphReading = graph.read(observedAt);
|
|
382
|
-
const configReading =
|
|
392
|
+
const configReading = readConfig(observedAt);
|
|
383
393
|
const cacheReading = cache.read(observedAt);
|
|
384
394
|
const owner = lock.read(observedAt);
|
|
385
395
|
const alive = owner.status === "readable" && owner.value ? (options.isDaemonAlive ?? isPidLive)(owner.value.pid) : undefined;
|
package/package.json
CHANGED
|
@@ -20,18 +20,50 @@ Keep `name: tickmarkr-auto` and the installed links to canonical `skills/` intac
|
|
|
20
20
|
|
|
21
21
|
## Two-tier by default — role check before the loop
|
|
22
22
|
|
|
23
|
-
When working in a multi-agent terminal environment, decide your role before starting:
|
|
23
|
+
When working in a multi-agent terminal environment (herdr or Orca), decide your role before starting:
|
|
24
24
|
|
|
25
25
|
- **Orchestrator:** your session was started to execute the mission. Rename your own tab/pane `ORCH · <version>` (short labels: ≤20 chars, `ROLE · token`) and run the loop below.
|
|
26
26
|
- **Supervisor with a live orchestrator:** do not start a second run. Relay the mission to the existing orchestrator with a [verified handoff](#verified-handoffs-agent-to-agent-messaging), then supervise it as OVERSEER.
|
|
27
27
|
- **Primary session without an orchestrator:** rename your own tab `OVERSEER · <version>` and your agent `overseer`, spawn one child orchestration session with your host's launch form, label its tab `ORCH · <version>` and name its agent, give it the mission and these rules verbatim, then supervise it. Do not drive a duplicate single-tier run yourself. Before spawning, confirm any PREVIOUS orchestrator has stood down (monitors stopped, input box empty — dim ghost-text suggestions are UI, not queued input; ANSI-verify before alarming) and close its tab — the journal, records, and ledger hold the story; scrollback is disposable.
|
|
28
|
-
- **
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
- **On herdr (`HERDR_ENV=1`)**: **spawning on current herdr is two-step** — the one-shot `agent start --cwd/--tab/--no-focus` form was removed in the herdr CLI redesign and now fails with `unknown option` (OBS-138). First create the pane: `herdr tab create --workspace <ws> --cwd <repo> --label "ORCH · <version>"` (tab create does not steal focus unless `--focus` is passed; parse `result.root_pane.pane_id` from its JSON), then start the agent in it:
|
|
29
|
+
- **Claude Code:** `herdr agent start orchestrator --kind claude --pane <root-pane-id> -- --permission-mode bypassPermissions`
|
|
30
|
+
- **Codex:** `herdr agent start orchestrator --kind codex --pane <root-pane-id> -- --dangerously-bypass-approvals-and-sandbox` — the unsandboxed flag is REQUIRED, not optional: codex's `workspace-write` sandbox keeps `.git` refs read-only, so a sandboxed orchestrator's `tickmarkr run` dies at integration-branch creation (`git worktree add` cannot lock the ref). Do not downgrade this flag; the herdr pane and repo scope are the containment.
|
|
31
|
+
- **Auxiliary seats run as the CLI's interactive TUI in their visible pane — never headless** (`claude -p` / `codex exec`): headless buffers output until exit so the pane renders idle for the entire run, is blind to SessionStart hook errors (a broken and a fixed hook both return green), and gives a stall watcher no midpoint — silent-time equals lifetime. Headless is for exit-code probes only (a quota check that wants `rc`), never for work anyone must watch.
|
|
32
|
+
- **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: spawn it with `orca terminal create --worktree path:<repo> --title "ORCH · <version>" --command "<agent-cmd>" --json` and parse `result.terminal.handle` from the create receipt. **Auxiliary seats run as the CLI's interactive TUI in their visible terminal — never headless**, for the reasons the herdr branch gives.
|
|
31
33
|
- **Auxiliary agents you spawn (consultants, reviewers, scouts) follow the same forms.** Never launch a claude session in plan mode or default permission mode for autonomous work — both stall on per-command approval prompts nobody is watching; claude is always `--permission-mode bypassPermissions` (tickmarkr's own adapter uses exactly this for workers, judges, and consults). A read-only codex consultant may use `--sandbox read-only`; any codex session that must touch git needs the unsandboxed flag above.
|
|
32
|
-
- **Auxiliary seats run as the CLI's interactive TUI in their visible pane — never headless** (`claude -p` / `codex exec`): headless buffers output until exit so the pane renders idle for the entire run, is blind to SessionStart hook errors (a broken and a fixed hook both return green), and gives a stall watcher no midpoint — silent-time equals lifetime. Headless is for exit-code probes only (a quota check that wants `rc`), never for work anyone must watch.
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
With neither herdr nor Orca there is no role to decide and no seat to spawn: follow [On a plain host (neither herdr nor Orca)](#on-a-plain-host-neither-herdr-nor-orca) and run the loop directly, single-tier.
|
|
36
|
+
|
|
37
|
+
## On a plain host (neither herdr nor Orca)
|
|
38
|
+
|
|
39
|
+
Neither `HERDR_ENV=1` nor the Orca markers are set: the Claude Desktop Code tab, a plain terminal, any
|
|
40
|
+
agent session that owns no panes (queue row 192; D-2102). The role check above does not apply.
|
|
41
|
+
|
|
42
|
+
- **This session runs SINGLE-TIER as the orchestrator.** There are no seats, no pane briefs, no tab
|
|
43
|
+
canon and no board: run every step of [the per-spec loop](#per-spec-loop) yourself and skip every
|
|
44
|
+
On herdr and On Orca scope.
|
|
45
|
+
- **An empty mission is asked for, never assumed.** With no target named, ask the operator what to run
|
|
46
|
+
end-to-end before compiling anything; there is no other tier to relay it to.
|
|
47
|
+
- **Run and resume launch through the anchor-free lines** of the
|
|
48
|
+
[detached launch](#host-owned-daemon-and-detached-beats): the same `node` detach with no address, one
|
|
49
|
+
line for `tickmarkr run` and one for `tickmarkr resume <runId>`. The daemon places no board, so a
|
|
50
|
+
missing board is never a fault; confirm the launch from the lock pid as that section says.
|
|
51
|
+
- **Run-end and parks are watched through the host's background wait**, never a foreground wait past
|
|
52
|
+
the host's ten-minute tool cap: the cap kills that wait, ends the turn and leaves the run unwatched.
|
|
53
|
+
Start the wait as a background task whose exit wakes this session, on journal lines after a baseline
|
|
54
|
+
taken at arm time and on the lock holder's death:
|
|
55
|
+
`n=$(wc -l < <journal>); until tail -n +$((n + 1)) <journal> | grep -qE '"event":"(run-end|task-human|task-failed|consult-verdict)"' || ! kill -0 <pid>; do sleep 20; done`
|
|
56
|
+
(`<journal>` is `<state-dir>/runs/<runId>/journal.jsonl`, `<pid>` the lock pid). On every wake read
|
|
57
|
+
CURRENT `tickmarkr status <runId>` first, then re-arm.
|
|
58
|
+
- **The operator's view is `tickmarkr status <runId>`**, plus its live `status --watch --plain` form,
|
|
59
|
+
`tickmarkr status <runId> --watch --plain`, in a read-only Terminal tab beside this session.
|
|
60
|
+
- **Reviews and judges run headless.** The subprocess driver runs every worker, judge and review as a
|
|
61
|
+
headless CLI call with no pane to watch: read their verdicts from the journal, the gate evidence and
|
|
62
|
+
`tickmarkr status <runId>`, never from a screen.
|
|
63
|
+
- **Sessions hand off through the host's cross-session message tool**, confirmed by the peer's reply:
|
|
64
|
+
there is no composer to read back, so a message no peer answered is not a handoff.
|
|
65
|
+
- **Keep a permission mode that never stalls per command** for the whole run (for example
|
|
66
|
+
`bypassPermissions`): nobody answers a per-command approval prompt in an unattended session.
|
|
35
67
|
|
|
36
68
|
## Stand-down (mission end and retirement)
|
|
37
69
|
|
|
@@ -79,21 +111,24 @@ concurrently* — so a machine-wide check answers a question nobody asked and bl
|
|
|
79
111
|
|
|
80
112
|
## Verified handoffs (agent-to-agent messaging)
|
|
81
113
|
|
|
82
|
-
|
|
114
|
+
Never report "briefed" or "relayed" without the confirmation your host gives:
|
|
83
115
|
|
|
84
|
-
Use one of:
|
|
116
|
+
- **On herdr (`HERDR_ENV=1`)**: when relaying missions between agents in a multi-agent terminal, **never use bare send-text** (`herdr agent send` / pane send-text) — it writes text without pressing Enter, so handoffs sit unsubmitted (OBS-39). Use one of:
|
|
117
|
+
- `herdr pane run <pane> "<message>"` — text plus Enter in the target shell
|
|
118
|
+
- `herdr notification show "<message>"` — OS-level delivery for the operator
|
|
85
119
|
|
|
86
|
-
|
|
87
|
-
- `
|
|
88
|
-
|
|
89
|
-
After sending, **confirm delivery** by reading the target pane and verifying the message landed (input empty, agent status `working`, or notification acknowledged). Never report "briefed" or "relayed" without read-back confirmation.
|
|
120
|
+
After sending, **confirm delivery** by reading the target pane and verifying the message landed (input empty, agent status `working`, or notification acknowledged). Never report "briefed" or "relayed" without read-back confirmation.
|
|
121
|
+
- **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: send with `orca terminal send --terminal <handle> --text "<message>" --enter --wait-submit 15 --json` and treat it as delivered only when `result.send.prompt.stages` carries a `turn_started` stage — `accepted: true` proves input acceptance, not a started turn.
|
|
122
|
+
- With neither host, hand off through the host's cross-session message tool, confirmed by the peer's reply ([On a plain host (neither herdr nor Orca)](#on-a-plain-host-neither-herdr-nor-orca)).
|
|
90
123
|
|
|
91
124
|
## Per-spec loop
|
|
92
125
|
|
|
93
126
|
1. **Prepare** — confirm the target list. Run the [binary preflight](#binary-preflight-before-compile-or-run). Check `git status`, confirm no tickmarkr run is active, and work from a non-main branch.
|
|
94
127
|
2. **Compile** — run `tickmarkr compile <spec-or-directory>`. Fix source-spec defects instead of editing the generated graph.
|
|
95
128
|
3. **Plan** — run `tickmarkr plan`. Review routes, capability-floor warnings, and human gates before execution.
|
|
96
|
-
4. **Run** — launch `tickmarkr run` using the [detached launch](#host-owned-daemon-and-detached-beats), and start detached beats with their run-end stop items. A watch ending the seat's turn is no watch: keep a **blocking journal consumer** alive for the run's terminal events
|
|
129
|
+
4. **Run** — launch `tickmarkr run` using the [detached launch](#host-owned-daemon-and-detached-beats), and start detached beats with their run-end stop items. A watch ending the seat's turn is no watch: keep a **blocking journal consumer** alive for the run's terminal events, and never rely on a `Monitor`-only wake. Watch the run journal rather than polling agents: the consumer takes a line baseline at arm time, then wakes ONCE on `run-end`, `task-human`, `task-failed` or `consult-verdict`. A run-end record is history, never green; the debt clause is yours — read CURRENT `tickmarkr status <runId>` (step 5). Re-arm after every wake. ⛔ Never `tail -F | grep -m1` (run-end is the journal's last line, so tail never notices the broken pipe and the watcher hangs forever) and never a pane-level done wait (it fires on every agent turn end, not mission end). ⚠ A bare whole-file `grep -q '"event":"run-end"'` is the trap the watcher exists to avoid: on a resume it matches the PREVIOUS run's run-end and returns instantly, so a re-armed watcher reads as coverage that does not exist. Resolve blocked interactions in the relevant agent session. The consumer is host-specific; with neither host it is the background wait of [On a plain host (neither herdr nor Orca)](#on-a-plain-host-neither-herdr-nor-orca).
|
|
130
|
+
- **On herdr (`HERDR_ENV=1`)**: the consumer is the shipped watcher — `.claude/skills/tickmarkr-overseer/scripts/watch-journal.sh <state-dir>/runs 20 28800`, which grades the run-end summary's execution clauses for you, `EXECUTION COMPLETE` or `NOT GREEN`, never green — or a foreground `until grep` on the run's terminal events; ensure it is re-armed at most every twenty minutes.
|
|
131
|
+
- **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: the consumer is that same shipped journal watcher or a foreground `until grep` on the run's terminal events; ensure it is re-armed at most every twenty minutes.
|
|
97
132
|
5. **Verify and consolidate** — continue only after a green run. A run is green when the run-end event exists in the journal, the tip verify is not "failed", the summary's `failed`, `human`, `blocked` and `pending` buckets are all empty, and CURRENT `tickmarkr status <runId>` reads its owed checks outstanding empty AND known (`outstanding 0`) — a run with a parked task is partial, not green. Empty execution buckets alone are not green either (D-660): every bucket empty and the tip passed, but an operator waived one review, leaves one accepted-risk review check owed — status reads `outstanding 1 (T7 review)` and `run`/`resume` still exit 0 on execution alone, so the run is execution complete, not green. It turns green only when a `tickmarkr verify --record <runId>` discharge moves CURRENT status to `outstanding 0`, including a discharge landing after run-end — the historical run-end record keeps the old count, so never read debt from it; `outstanding unknown` is never green. Tickmarkr consolidates accepted work on `tickmarkr/<runId>` and never signs off to the main branch. A human controls any later release merge.
|
|
98
133
|
6. **Record** — `tickmarkr report <runId> --md` prints Markdown to stdout; redirect explicitly beside the spec (for example `tickmarkr report <runId> --md > feature.record.md`) and commit the execution record when the repository tracks those records.
|
|
99
134
|
7. **Continue** — move to the next requested target. If a target fails or is parked, stop with the journal evidence rather than silently skipping it.
|
|
@@ -101,12 +136,13 @@ After sending, **confirm delivery** by reading the target pane and verifying the
|
|
|
101
136
|
## Host-owned daemon and detached beats
|
|
102
137
|
|
|
103
138
|
Launch the daemon DETACHED, with no pane of its own: one `tickmarkr run` or `tickmarkr resume <runId>`
|
|
104
|
-
process in its own session, its stdout and stderr appended to `<state-dir>/daemon.log`, and
|
|
105
|
-
ORCH's recorded address as its board anchor, so the daemon self-places its one
|
|
106
|
-
ORCH in the ORCH tab
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
139
|
+
process in its own session, its stdout and stderr appended to `<state-dir>/daemon.log`, and on
|
|
140
|
+
herdr or Orca the ORCH's recorded address as its board anchor, so the daemon self-places its one
|
|
141
|
+
board beside the ORCH in the ORCH tab; with neither host it carries no address and places no board.
|
|
142
|
+
Never launch it in a visible split, pane or tab, as an agent harness background task, or tied to
|
|
143
|
+
an agent session: the launching shell may end, and the daemon keeps running and logging. The
|
|
144
|
+
detach wrapper is `node`, which tickmarkr already requires — no private script, no extra visible
|
|
145
|
+
daemon pane and no CLI log flag:
|
|
110
146
|
|
|
111
147
|
```bash
|
|
112
148
|
# On Orca: <ORCH handle> is the ORCH terminal handle
|
|
@@ -115,19 +151,27 @@ cd <repo> && ORCA_TERMINAL_HANDLE="<ORCH handle>" node -e "require('child_proces
|
|
|
115
151
|
# On herdr: <ORCH pane id> is the ORCH pane id
|
|
116
152
|
cd <repo> && HERDR_PANE_ID="<ORCH pane id>" node -e "require('child_process').spawn(process.argv[1], process.argv.slice(2), { detached: true, stdio: 'inherit' }).unref()" tickmarkr run >> <state-dir>/daemon.log 2>&1 < /dev/null
|
|
117
153
|
cd <repo> && HERDR_PANE_ID="<ORCH pane id>" node -e "require('child_process').spawn(process.argv[1], process.argv.slice(2), { detached: true, stdio: 'inherit' }).unref()" tickmarkr resume <runId> >> <state-dir>/daemon.log 2>&1 < /dev/null
|
|
154
|
+
# On a plain host (neither herdr nor Orca): the same detach with no address
|
|
155
|
+
cd <repo> && node -e "require('child_process').spawn(process.argv[1], process.argv.slice(2), { detached: true, stdio: 'inherit' }).unref()" tickmarkr run >> <state-dir>/daemon.log 2>&1 < /dev/null
|
|
156
|
+
cd <repo> && node -e "require('child_process').spawn(process.argv[1], process.argv.slice(2), { detached: true, stdio: 'inherit' }).unref()" tickmarkr resume <runId> >> <state-dir>/daemon.log 2>&1 < /dev/null
|
|
118
157
|
```
|
|
119
158
|
|
|
120
159
|
- **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: run the
|
|
121
160
|
`ORCA_TERMINAL_HANDLE` lines; `<ORCH handle>` is the ORCH's own `$ORCA_TERMINAL_HANDLE`.
|
|
122
161
|
- **On herdr (`HERDR_ENV=1`)**: run the `HERDR_PANE_ID` lines; `<ORCH pane id>` is the ORCH's own
|
|
123
162
|
`$HERDR_PANE_ID`.
|
|
163
|
+
- With neither host, run the two lines that carry no address
|
|
164
|
+
([On a plain host (neither herdr nor Orca)](#on-a-plain-host-neither-herdr-nor-orca)).
|
|
124
165
|
|
|
125
|
-
After
|
|
166
|
+
After any launch, read this repository's lock pid from `<state-dir>/graph.lock` and confirm it:
|
|
126
167
|
`kill -0 <pid>` succeeds, `ps -o pgid= -p <pid>` prints that same pid (its own session group;
|
|
127
168
|
ppid 1 is never proof on its own), walk its ppid chain and verify no agent session is an ancestor,
|
|
128
169
|
and read `<state-dir>/daemon.log`. No live holder means the launch failed: read the log, fix the
|
|
129
170
|
cause and run the same line again — never a second daemon beside a live holder, and never a
|
|
130
|
-
visible split to watch it
|
|
171
|
+
visible split to watch it.
|
|
172
|
+
|
|
173
|
+
- **On herdr (`HERDR_ENV=1`)**: the daemon-placed board is the live surface.
|
|
174
|
+
- **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: the daemon-placed board is the live surface.
|
|
131
175
|
|
|
132
176
|
Detached beats are product-owned, one per (tier, seat), from the repository root, through the
|
|
133
177
|
shipped lifecycle verbs — never a hand-rolled setsid plus nohup wrapper, pidfile or shell loop:
|