tickmarkr 2.8.8 → 2.8.9
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/README.md +22 -0
- package/dist/run/daemon.js +157 -15
- package/dist/run/git.d.ts +7 -2
- package/dist/run/git.js +37 -4
- package/dist/run/wall-budget.js +8 -4
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +58 -3
- package/skills/tickmarkr-loop/SKILL.md +59 -5
- package/skills/tickmarkr-overseer/SKILL.md +134 -34
- package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +145 -18
package/README.md
CHANGED
|
@@ -441,6 +441,28 @@ The daemon reconciles at every safe point:
|
|
|
441
441
|
unavailable, a pane vanished mid-sweep) do not replace gate verdicts; unconfirmed board
|
|
442
442
|
cleanup is journaled and reported.
|
|
443
443
|
|
|
444
|
+
### Worktree lifecycle
|
|
445
|
+
|
|
446
|
+
Queue row 240 (D-2138): at run end, every journaled task checkout is removed once its
|
|
447
|
+
recovery ref is recorded, including parked and failed tasks. Clean merged checkouts
|
|
448
|
+
are removed silently. Dirty work is preserved under `refs/tickmarkr/preserved/`;
|
|
449
|
+
unmerged clean heads are pinned there too. The journal records the recovery ref and
|
|
450
|
+
removed checkout, and resume rebuilds from that work. Task branches and refs remain.
|
|
451
|
+
|
|
452
|
+
A crash or SIGTERM leaves checkouts until the next daemon start in the same checkout.
|
|
453
|
+
After its lifecycle row and reconciliation, that start sweeps other runs in this
|
|
454
|
+
checkout's runs directory only when their newest lifecycle row records a proven dead
|
|
455
|
+
pid, regardless of how they ended. Recovery and removal rows go into the owning run's
|
|
456
|
+
journal and name the sweeping run; outstanding checks and measured wall windows stay unchanged.
|
|
457
|
+
Only journaled task checkouts are touched: there is no worktree directory listing or
|
|
458
|
+
in-use check. Live or unrecorded pids, this run's checkouts, verification checkouts,
|
|
459
|
+
hand-made directories and sibling linked checkouts remain untouched; a refused start
|
|
460
|
+
sweeps nothing. `visibility.keepPanes: forever` disables both removal boundaries.
|
|
461
|
+
|
|
462
|
+
Staged bytes missing from the recovery tree, preservation failures and deletion failures
|
|
463
|
+
keep the checkout and journal a run-level removal failure in the current run. Cleanup
|
|
464
|
+
never changes the run's outcome or adds another run end.
|
|
465
|
+
|
|
444
466
|
### Workspace trust
|
|
445
467
|
|
|
446
468
|
tickmarkr creates fresh git worktrees for change isolation. That is separate from each CLI's own
|
package/dist/run/daemon.js
CHANGED
|
@@ -2133,6 +2133,54 @@ function endedRunIdsForReconcile(repoRoot) {
|
|
|
2133
2133
|
}
|
|
2134
2134
|
return ended;
|
|
2135
2135
|
}
|
|
2136
|
+
// Queue row 240 (D-2138): ownership comes only from dispatch rows in this checkout's runs directory.
|
|
2137
|
+
async function removeJournaledTaskWorktrees(repo, owner, current, activeBranch) {
|
|
2138
|
+
let events;
|
|
2139
|
+
try {
|
|
2140
|
+
events = owner.read();
|
|
2141
|
+
}
|
|
2142
|
+
catch {
|
|
2143
|
+
return;
|
|
2144
|
+
} // Queue row 240 (D-2138): unreadable ownership proves no task checkout.
|
|
2145
|
+
const branch = activeBranch ?? events.find((e) => e.event === "run-start")?.data.branch;
|
|
2146
|
+
if (typeof branch !== "string")
|
|
2147
|
+
return;
|
|
2148
|
+
await cleanupRunWorktrees(repo, branch, {
|
|
2149
|
+
removeTaskIds: events.flatMap((e) => e.event === "task-dispatch" && e.taskId ? [e.taskId] : []),
|
|
2150
|
+
run: owner.runId, by: current.runId,
|
|
2151
|
+
producer: (task) => knownProducer(events, task),
|
|
2152
|
+
append: (event, task, data) => {
|
|
2153
|
+
// Queue row 240 (D-2138): the recovery handle retains its terminal task reason without reopening the decision.
|
|
2154
|
+
const terminal = task && [...events].reverse().find((e) => e.taskId === task
|
|
2155
|
+
&& ["task-human", "task-failed", "task-done", "task-dispatch"].includes(e.event));
|
|
2156
|
+
const reason = terminal && terminal.data.reason;
|
|
2157
|
+
owner.append(event, task, { ...data, ...(typeof reason === "string" ? { reason } : {}) });
|
|
2158
|
+
},
|
|
2159
|
+
failed: (data) => current.append("worktree-removal-failed", undefined, data),
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
async function sweepDeadRunWorktrees(repo, current) {
|
|
2163
|
+
let runs;
|
|
2164
|
+
try {
|
|
2165
|
+
runs = readdirSync(join(tickmarkrDir(repo), "runs"));
|
|
2166
|
+
}
|
|
2167
|
+
catch {
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
for (const run of runs) {
|
|
2171
|
+
if (run === current.runId)
|
|
2172
|
+
continue;
|
|
2173
|
+
try {
|
|
2174
|
+
const owner = Journal.open(repo, run);
|
|
2175
|
+
const lifecycle = [...owner.read()].reverse().find((e) => e.event === "run-start" || e.event === "run-resume");
|
|
2176
|
+
const pid = lifecycle?.data.pid;
|
|
2177
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0 || isPidLive(pid))
|
|
2178
|
+
continue;
|
|
2179
|
+
await removeJournaledTaskWorktrees(repo, owner, current);
|
|
2180
|
+
}
|
|
2181
|
+
catch { /* Queue row 240 (D-2138): unreadable ownership proves nothing and never refuses a start. */ }
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2136
2184
|
export async function runDaemon(repoRoot, opts = {}) {
|
|
2137
2185
|
// OBS-1045: a daemon launched from inside a leased command (a nested runner, an operator's test
|
|
2138
2186
|
// shell) inherits its ancestor's lease token through the environment, and every gate it ran would
|
|
@@ -3217,6 +3265,8 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3217
3265
|
// panes too. OBS-777: the one startup snapshot lets it reclaim only older runs proven ended, while
|
|
3218
3266
|
// every live or unknown run remains spared.
|
|
3219
3267
|
await reconcile();
|
|
3268
|
+
if (!keepForever)
|
|
3269
|
+
await sweepDeadRunWorktrees(repoRoot, journal);
|
|
3220
3270
|
// v1.4 self-reference guard: a random nonce on the worker trailer AND exit marker. Displayed
|
|
3221
3271
|
// source/diffs (e.g. a worker editing tickmarkr's own prompt.ts/daemon.ts) can't know it, so an echoed
|
|
3222
3272
|
// TICKMARKR_RESULT/TICKMARKR_EXIT literal can never premature-harvest the worker. Quote-split keeps the
|
|
@@ -3553,6 +3603,64 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3553
3603
|
// row is deliberately written before the later worktree-recreation row so the journal cannot
|
|
3554
3604
|
// describe only the commits it carried while omitting uncommitted work the removal destroyed.
|
|
3555
3605
|
const producerNow = () => knownProducer(journal.read(), t.id);
|
|
3606
|
+
const readTaskWork = async (taskBase, priorWt, events = journal.read()) => {
|
|
3607
|
+
if (existsSync(priorWt))
|
|
3608
|
+
return { head: await gitHead(priorWt), commits: await commitsAheadOf(taskBase, priorWt) };
|
|
3609
|
+
const taskBranch = `${branch}--${t.id}`;
|
|
3610
|
+
try {
|
|
3611
|
+
const read = async (command) => {
|
|
3612
|
+
const result = await shGit(command, repoRoot);
|
|
3613
|
+
if (result.code !== 0)
|
|
3614
|
+
throw new Error(result.stderr || result.stdout || `git exited ${result.code}`);
|
|
3615
|
+
return result.stdout.trim();
|
|
3616
|
+
};
|
|
3617
|
+
const ahead = async (head) => (await read(`git log --reverse --format=%H ${shq(taskBase)}..${shq(head)}`)).split("\n").filter(Boolean);
|
|
3618
|
+
const ancestor = async (base, head) => {
|
|
3619
|
+
const result = await shGit(`git merge-base --is-ancestor ${shq(base)} ${shq(head)}`, repoRoot);
|
|
3620
|
+
if (result.code !== 0 && result.code !== 1)
|
|
3621
|
+
throw new Error(result.stderr || result.stdout || `ancestry exited ${result.code}`);
|
|
3622
|
+
return result.code === 0;
|
|
3623
|
+
};
|
|
3624
|
+
const branchRef = `refs/heads/${taskBranch}`;
|
|
3625
|
+
const exists = await shGit(`git show-ref --verify --quiet ${shq(branchRef)}`, repoRoot);
|
|
3626
|
+
if (exists.code !== 0 && exists.code !== 1)
|
|
3627
|
+
throw new Error(exists.stderr || exists.stdout || `branch read exited ${exists.code}`);
|
|
3628
|
+
const tip = exists.code === 0 ? await read(`git rev-parse --verify ${shq(`${branchRef}^{commit}`)}`) : undefined;
|
|
3629
|
+
const removalAt = events.map((e) => e.event === "worktree-removed" && e.data.task === t.id).lastIndexOf(true);
|
|
3630
|
+
const removal = events[removalAt];
|
|
3631
|
+
if (removal && !events.slice(removalAt + 1).some((e) => e.event === "worktree-recreation" && e.taskId === t.id)
|
|
3632
|
+
&& typeof removal.data.head === "string") {
|
|
3633
|
+
const resolved = await shGit(`git rev-parse --verify ${shq(`${removal.data.head}^{commit}`)}`, repoRoot);
|
|
3634
|
+
const head = resolved.stdout.trim();
|
|
3635
|
+
if (resolved.code === 0 && head && (!tip || await ancestor(tip, head))) {
|
|
3636
|
+
const commits = await ahead(head);
|
|
3637
|
+
let snapshot;
|
|
3638
|
+
if (typeof removal.data.ref === "string") {
|
|
3639
|
+
const pinned = await read(`git rev-parse --verify ${shq(`${removal.data.ref}^{commit}`)}`);
|
|
3640
|
+
if (pinned !== head)
|
|
3641
|
+
snapshot = removal.data.ref;
|
|
3642
|
+
}
|
|
3643
|
+
return { head, commits, snapshot, rebuiltFrom: "removal" };
|
|
3644
|
+
}
|
|
3645
|
+
}
|
|
3646
|
+
if (!tip)
|
|
3647
|
+
return { commits: [] };
|
|
3648
|
+
const commits = await ahead(tip);
|
|
3649
|
+
if (commits.length === 0)
|
|
3650
|
+
return { commits: [] };
|
|
3651
|
+
const dispatch = [...events].reverse().find((e) => e.event === "task-dispatch" && e.taskId === t.id);
|
|
3652
|
+
const base = dispatch?.data.base === undefined ? events.find((e) => e.event === "run-start")?.data.baseRef : dispatch.data.base;
|
|
3653
|
+
if (typeof base !== "string" || !await ancestor(base, tip))
|
|
3654
|
+
throw new Error("tip does not descend from its recorded dispatch base");
|
|
3655
|
+
const ref = `refs/tickmarkr/preserved/${tip}`;
|
|
3656
|
+
await read(`git update-ref ${shq(ref)} ${shq(tip)}`);
|
|
3657
|
+
journal.append("worktree-preserved", t.id, { ref, ...producerFields(producerNow()) });
|
|
3658
|
+
return { head: tip, commits, rebuiltFrom: "branch" };
|
|
3659
|
+
}
|
|
3660
|
+
catch (error) {
|
|
3661
|
+
return { commits: [], error: `Queue row 240 (D-2138): cannot rebuild task branch ${taskBranch}: ${error instanceof Error ? error.message : String(error)}` };
|
|
3662
|
+
}
|
|
3663
|
+
};
|
|
3556
3664
|
const recreateTaskWorktree = async (taskBranch, taskBase, priorWt) => {
|
|
3557
3665
|
const producer = producerNow();
|
|
3558
3666
|
const ref = await preserveWorktree(priorWt, producer);
|
|
@@ -4378,7 +4486,12 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
4378
4486
|
if (uncertain)
|
|
4379
4487
|
return decline(uncertain);
|
|
4380
4488
|
}
|
|
4381
|
-
const
|
|
4489
|
+
const work = await readTaskWork(published, wt);
|
|
4490
|
+
if (work.error) {
|
|
4491
|
+
await park(t, work.error, "infra", found.assignment, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
|
|
4492
|
+
return "parked";
|
|
4493
|
+
}
|
|
4494
|
+
const commits = work.commits;
|
|
4382
4495
|
if (!found.result.finished && commits.length === 0)
|
|
4383
4496
|
return decline("unfinished");
|
|
4384
4497
|
recordHarvest(commits, found.result.finished, found.result.summary);
|
|
@@ -4435,6 +4548,8 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
4435
4548
|
return found.assignment;
|
|
4436
4549
|
};
|
|
4437
4550
|
const harvestAuthor = rs && !satisfiedGate && !replayedGates && !recheck ? await harvestInterruptedAttempt() : undefined;
|
|
4551
|
+
if (harvestAuthor === "parked")
|
|
4552
|
+
return;
|
|
4438
4553
|
// OBS-1202: a recheck bound to a stall park that recorded a reapFailure re-verifies the parked
|
|
4439
4554
|
// attempt's owned census through the same reapWorker a live harvest uses, BEFORE any gate reads
|
|
4440
4555
|
// its tree. Only a complete, empty census releases the harvested commits to the battery; an
|
|
@@ -4487,7 +4602,12 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
4487
4602
|
const preservedRef = recheck
|
|
4488
4603
|
? effectiveEvents(journal.read()).reverse().find((e) => e.taskId === t.id && e.event === "task-approved" && e.data.release === RECHECK_RELEASE)?.data.recheckedRef
|
|
4489
4604
|
: undefined;
|
|
4490
|
-
|
|
4605
|
+
const work = preservedRef ? undefined : await readTaskWork(taskBase, priorWt);
|
|
4606
|
+
if (work?.error) {
|
|
4607
|
+
await park(t, work.error, "infra", assignment, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
|
|
4608
|
+
return;
|
|
4609
|
+
}
|
|
4610
|
+
if (!work?.head && !preservedRef) {
|
|
4491
4611
|
if (satisfiedGate || recheck)
|
|
4492
4612
|
throw new Error(`${recheck ? "recheck" : `approved gate ${satisfiedGate}`} cannot resume: task worktree is missing`);
|
|
4493
4613
|
// Observed passes are an optimization, never authority: without the task worktree there is no
|
|
@@ -4502,9 +4622,15 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
4502
4622
|
: satisfiedGate
|
|
4503
4623
|
? `approved gate ${satisfiedGate}`
|
|
4504
4624
|
: `recorded gates on ${replayedGates.commit.slice(0, 10)}`;
|
|
4505
|
-
const priorTaskTip = preservedRef ? (await shGit(`git rev-parse ${shq(preservedRef)}`, repoRoot)).stdout.trim() :
|
|
4506
|
-
const priorTaskSubject = await gateCommitSubject(taskBase, priorTaskTip, preservedRef ? repoRoot : priorWt);
|
|
4507
|
-
const commitsToCarry = preservedRef ? await commitsAheadOfRef(taskBase, priorTaskTip, repoRoot) :
|
|
4625
|
+
const priorTaskTip = preservedRef ? (await shGit(`git rev-parse ${shq(preservedRef)}`, repoRoot)).stdout.trim() : work.head;
|
|
4626
|
+
const priorTaskSubject = await gateCommitSubject(taskBase, priorTaskTip, preservedRef || work?.rebuiltFrom ? repoRoot : priorWt);
|
|
4627
|
+
const commitsToCarry = preservedRef ? await commitsAheadOfRef(taskBase, priorTaskTip, repoRoot) : [...work.commits];
|
|
4628
|
+
// Queue row 240 (D-2138): rechecking a dirt refusal gates the committed work as a present checkout did.
|
|
4629
|
+
// The refused bytes remain recoverable; an explicitly checked ref still selects its exact tree.
|
|
4630
|
+
const dirtRecheck = recheck && [...journal.read()].reverse()
|
|
4631
|
+
.find((e) => e.taskId === t.id && e.event === "gate-result")?.data.dirtyWorktree === true;
|
|
4632
|
+
if (work?.snapshot && !dirtRecheck)
|
|
4633
|
+
commitsToCarry.push(work.snapshot);
|
|
4508
4634
|
// 2.8.1 T2 (ANL-023 §7.2): a task with a recorded merge whose prior tip is PROVEN in E (`merge-base --is-ancestor`
|
|
4509
4635
|
// exit 0 — an empty or failed history read is no proof) keeps that tip as its gated subject — recreated on E it
|
|
4510
4636
|
// would be gated AT E, and a sibling's publication since its merge would hide the recorded merge. Nothing is gated
|
|
@@ -4538,6 +4664,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
4538
4664
|
satisfiedGate = journal.replaySatisfiedGates(new Map([[t.id, currentTaskSubject]])).get(t.id);
|
|
4539
4665
|
}
|
|
4540
4666
|
journal.append("worktree-recreation", t.id, {
|
|
4667
|
+
...(work?.rebuiltFrom ? { rebuiltFrom: work.rebuiltFrom } : {}),
|
|
4541
4668
|
attempted: commitsToCarry, carried: carriedCommits, ...(accountedCommits.length > 0 ? { accounted: accountedCommits } : {}),
|
|
4542
4669
|
});
|
|
4543
4670
|
// OBS-212: same fail-closed rule as the dispatch path — but this path is worse, because it runs
|
|
@@ -5096,6 +5223,15 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
5096
5223
|
});
|
|
5097
5224
|
}
|
|
5098
5225
|
lastContextTokens = undefined;
|
|
5226
|
+
const taskBase = published; // deps are merged → visible to this task (E, never the ref as it stands)
|
|
5227
|
+
const taskBranch = `${branch}--${t.id}`;
|
|
5228
|
+
const priorWt = worktreePath(repoRoot, taskBranch);
|
|
5229
|
+
const recreating = existsSync(priorWt);
|
|
5230
|
+
const work = await readTaskWork(taskBase, priorWt, journaledSoFar);
|
|
5231
|
+
if (work.error) {
|
|
5232
|
+
await park(t, work.error, "infra", assignment, attempt, startMs, gateFails, consults, tokens, metered, retryMode);
|
|
5233
|
+
return;
|
|
5234
|
+
}
|
|
5099
5235
|
graph = setStatus(graph, t.id, "running");
|
|
5100
5236
|
saveGraph(repoRoot, graph);
|
|
5101
5237
|
await trackedDriver.project?.(t.id, "in-progress");
|
|
@@ -5106,6 +5242,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
5106
5242
|
const workerDispatchOrdinal = nextWorkerDispatchOrdinal++;
|
|
5107
5243
|
vendorEscalated.delete(channelKey(assignment)); // dispatched now: from here on it IS tried
|
|
5108
5244
|
journal.append("task-dispatch", t.id, {
|
|
5245
|
+
base: taskBase,
|
|
5109
5246
|
...(scopeApproval?.data.release === "scope-request" ? { files: t.files, graphDefinitionHash: graphDefinitionHash(graph) } : {}),
|
|
5110
5247
|
assignment, attempt, workerDispatchOrdinal, provenance: dispatchProvenance([
|
|
5111
5248
|
channelKey(assignment) === channelKey(r.assignment) ? r.provenance
|
|
@@ -5123,15 +5260,17 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
5123
5260
|
...(carriedConsultGuidance ? { carriedConsultGuidance } : {}),
|
|
5124
5261
|
});
|
|
5125
5262
|
journal.phaseStart(t.id, "worker", { attempt, assignment });
|
|
5126
|
-
const taskBase = published; // deps are merged → visible to this task (E, never the ref as it stands)
|
|
5127
5263
|
taskBases.set(t.id, taskBase);
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
//
|
|
5132
|
-
|
|
5264
|
+
// Queue row 240 (D-2138): read a missing checkout like a present one: its recorded removal first,
|
|
5265
|
+
// with its preserved snapshot carried as a reaped worker's is; else a task branch proven by its
|
|
5266
|
+
// journaled dispatch base or the run-start base; else nothing. Never reset a branch holding commits unproven.
|
|
5267
|
+
// Queue row 240 (D-2138): like recheck, a dispatch after a dirt refusal carries committed work only;
|
|
5268
|
+
// the refused bytes remain recoverable from their ref, as when the prior checkout was present.
|
|
5269
|
+
const dirtRefusal = [...journaledSoFar].reverse()
|
|
5270
|
+
.find((e) => e.taskId === t.id && (e.event === "gate-result" || e.event === "task-dispatch"))?.data.dirtyWorktree === true;
|
|
5271
|
+
const preserved = recreating ? reapedWorktreeRef : dirtRefusal ? undefined : work.snapshot;
|
|
5133
5272
|
reapedWorktreeRef = undefined;
|
|
5134
|
-
const commitsToCarry =
|
|
5273
|
+
const commitsToCarry = [...work.commits];
|
|
5135
5274
|
if (preserved)
|
|
5136
5275
|
commitsToCarry.push(preserved);
|
|
5137
5276
|
const wt = await recreateTaskWorktree(taskBranch, taskBase, priorWt);
|
|
@@ -5140,8 +5279,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
5140
5279
|
// orphan work a consult already verified as landed.
|
|
5141
5280
|
const { carried: carriedCommits, accounted: accountedCommits } = commitsToCarry.length > 0
|
|
5142
5281
|
? await cherryPickCommits(wt, commitsToCarry) : { carried: [], accounted: [] };
|
|
5143
|
-
if (recreating) {
|
|
5282
|
+
if (recreating || work.rebuiltFrom) {
|
|
5144
5283
|
journal.append("worktree-recreation", t.id, {
|
|
5284
|
+
...(work.rebuiltFrom ? { rebuiltFrom: work.rebuiltFrom } : {}),
|
|
5145
5285
|
attempted: commitsToCarry, carried: carriedCommits, ...(accountedCommits.length > 0 ? { accounted: accountedCommits } : {}),
|
|
5146
5286
|
});
|
|
5147
5287
|
}
|
|
@@ -7564,9 +7704,11 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
7564
7704
|
}
|
|
7565
7705
|
journal.append("run-end", undefined, { ...summary });
|
|
7566
7706
|
await reconcile(); // run-end boundary: nothing in flight — full sweep (empty desired set)
|
|
7567
|
-
//
|
|
7707
|
+
// Queue row 240 (D-2138): every task checkout of this run is removed once its recovery ref is recorded,
|
|
7708
|
+
// unless keepPanes is forever. A dead run's leftovers go at the next daemon start in the same checkout.
|
|
7709
|
+
// Only journaled task checkouts are touched; no directory listing or in-use check exists.
|
|
7568
7710
|
if (!keepForever)
|
|
7569
|
-
await
|
|
7711
|
+
await removeJournaledTaskWorktrees(repoRoot, journal, journal, branch);
|
|
7570
7712
|
// VIS-02: name each blocked subtree by its nearest parked/failed root, e.g. "3 blocked behind P40-02".
|
|
7571
7713
|
const attribution = [...attributeBlocked(graph).entries()]
|
|
7572
7714
|
.sort(([a], [b]) => a.localeCompare(b))
|
package/dist/run/git.d.ts
CHANGED
|
@@ -234,11 +234,16 @@ export declare const sanitizeBranch: (branch: string) => string;
|
|
|
234
234
|
export declare const WORKTREES_DIR = "worktrees.noindex";
|
|
235
235
|
export declare const worktreePath: (repo: string, branch: string) => string;
|
|
236
236
|
/**
|
|
237
|
-
*
|
|
238
|
-
*
|
|
237
|
+
* Queue row 240 (D-2138): remove only journaled task checkouts, after preserving their recovery tree.
|
|
238
|
+
* There is no in-use check. Branches and recovery refs remain; a failed operation keeps the checkout.
|
|
239
239
|
*/
|
|
240
240
|
export declare function cleanupRunWorktrees(repo: string, branch: string, opts: {
|
|
241
241
|
removeTaskIds: string[];
|
|
242
|
+
run: string;
|
|
243
|
+
by: string;
|
|
244
|
+
producer: (task: string) => PreserveProducer;
|
|
245
|
+
append: (event: string, task: string | undefined, data: Record<string, unknown>) => void;
|
|
246
|
+
failed: (data: Record<string, unknown>) => void;
|
|
242
247
|
}): Promise<void>;
|
|
243
248
|
export declare function resolveIntegrationBranch(_repo: string, branch: string): Promise<string>;
|
|
244
249
|
/**
|
package/dist/run/git.js
CHANGED
|
@@ -817,12 +817,45 @@ const sanitize = sanitizeBranch;
|
|
|
817
817
|
export const WORKTREES_DIR = "worktrees.noindex";
|
|
818
818
|
export const worktreePath = (repo, branch) => join(tickmarkrDir(repo), WORKTREES_DIR, sanitize(branch));
|
|
819
819
|
/**
|
|
820
|
-
*
|
|
821
|
-
*
|
|
820
|
+
* Queue row 240 (D-2138): remove only journaled task checkouts, after preserving their recovery tree.
|
|
821
|
+
* There is no in-use check. Branches and recovery refs remain; a failed operation keeps the checkout.
|
|
822
822
|
*/
|
|
823
823
|
export async function cleanupRunWorktrees(repo, branch, opts) {
|
|
824
|
-
for (const
|
|
825
|
-
|
|
824
|
+
for (const task of new Set(opts.removeTaskIds)) {
|
|
825
|
+
const dir = worktreePath(repo, `${branch}--${task}`);
|
|
826
|
+
if (!existsSync(dir))
|
|
827
|
+
continue;
|
|
828
|
+
try {
|
|
829
|
+
const head = await gitHead(dir);
|
|
830
|
+
const producer = opts.producer(task);
|
|
831
|
+
let ref = await preserveWorktree(dir, producer);
|
|
832
|
+
if (!ref) {
|
|
833
|
+
const merged = await shGit(`git merge-base --is-ancestor ${shq(head)} ${shq(`refs/heads/${branch}`)}`, repo);
|
|
834
|
+
if (merged.code !== 0 && merged.code !== 1)
|
|
835
|
+
throw new Error(merged.stderr || merged.stdout || "ancestry comparison failed");
|
|
836
|
+
if (merged.code === 1) {
|
|
837
|
+
ref = `refs/tickmarkr/preserved/${head}`;
|
|
838
|
+
await shGitOk(`git update-ref ${shq(ref)} ${shq(head)}`, repo);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
// Queue row 240 (D-2138 add.4): a disposable preservation index never sees staged-only bytes.
|
|
842
|
+
const staged = await shGit(`git diff-index --cached --name-only --diff-filter=ACMRT -z ${shq(ref ?? head)} --`, dir);
|
|
843
|
+
if (staged.code !== 0 || staged.stdout.length)
|
|
844
|
+
throw new Error(staged.stderr || "staged bytes are absent from the recovery tree");
|
|
845
|
+
if (ref)
|
|
846
|
+
opts.append("worktree-preserved", task, { ref, ...producerFields(producer), by: opts.by });
|
|
847
|
+
if (ref)
|
|
848
|
+
opts.append("worktree-removed", undefined, { task, dir, head, ref, by: opts.by });
|
|
849
|
+
// Queue row 240 (D-2138): unlike rollback, a refused deletion must never fall through to rm -rf.
|
|
850
|
+
await withWorktreeAdmin(() => shGitOk(`git worktree remove --force ${shq(dir)}`, repo));
|
|
851
|
+
}
|
|
852
|
+
catch (error) {
|
|
853
|
+
try {
|
|
854
|
+
opts.failed({ run: opts.run, task, dir, error: error instanceof Error ? error.message : String(error) });
|
|
855
|
+
}
|
|
856
|
+
catch { /* Queue row 240 (D-2138): cleanup never replaces the run's outcome, even with an unwritable journal. */ }
|
|
857
|
+
}
|
|
858
|
+
}
|
|
826
859
|
}
|
|
827
860
|
export async function resolveIntegrationBranch(_repo, branch) {
|
|
828
861
|
return branch;
|
package/dist/run/wall-budget.js
CHANGED
|
@@ -21,8 +21,9 @@ const bucketOf = (gate) => gate === "test" ? "test" : gate === "acceptance" || g
|
|
|
21
21
|
// Rows another process writes around a restart — the operator's approval, the resuming daemon's
|
|
22
22
|
// reclaim and rehash audit — so the prior engagement's own last row is the one before them.
|
|
23
23
|
// ponytail: a closed list; a new out-of-engagement writer lengthens the interruption it precedes.
|
|
24
|
-
const OUTSIDE_ENGAGEMENT = new Set(["task-approved", "graph-rehash", "lock-reclaimed", "superseded"]);
|
|
25
|
-
const outsideEngagement = (e) => OUTSIDE_ENGAGEMENT.has(e.event) || (e.event === "
|
|
24
|
+
const OUTSIDE_ENGAGEMENT = new Set(["task-approved", "graph-rehash", "lock-reclaimed", "superseded", "worktree-removed", "worktree-removal-failed"]);
|
|
25
|
+
const outsideEngagement = (e) => OUTSIDE_ENGAGEMENT.has(e.event) || (e.event === "worktree-preserved" && e.data.by !== undefined)
|
|
26
|
+
|| (e.event === "exit-cause" && e.data.cause === "unclean");
|
|
26
27
|
const WORKER_END = new Set(["worker-result", "worker-dead", "worker-hard-timeout"]);
|
|
27
28
|
const invocationOf = (data) => {
|
|
28
29
|
if (typeof data.nonce === "string")
|
|
@@ -39,8 +40,11 @@ export function wallBudget(events) {
|
|
|
39
40
|
const lastEnd = lastOf("run-end");
|
|
40
41
|
const lastResume = lastOf("run-resume");
|
|
41
42
|
const from = at(events[first]);
|
|
42
|
-
//
|
|
43
|
-
|
|
43
|
+
// Queue row 240 (D-2138): cleanup by another daemon cannot lengthen an open or crashed engagement.
|
|
44
|
+
let newestInside = events.length - 1;
|
|
45
|
+
while (newestInside > first && outsideEngagement(events[newestInside]))
|
|
46
|
+
newestInside--;
|
|
47
|
+
const to = at(events[lastEnd > lastResume ? lastEnd : newestInside]);
|
|
44
48
|
if (!Number.isFinite(from) || !Number.isFinite(to) || to < from)
|
|
45
49
|
return undefined;
|
|
46
50
|
const spans = [];
|
package/package.json
CHANGED
|
@@ -26,7 +26,7 @@ When working in a multi-agent terminal environment (herdr or Orca), decide your
|
|
|
26
26
|
|
|
27
27
|
- **Orchestrator:** your session was started to execute the mission. Rename your own tab/pane `ORCH · <label>` (short labels: ≤20 chars, `ROLE · token`) and run the loop below.
|
|
28
28
|
- **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.
|
|
29
|
-
- **Primary session without an orchestrator:** rename your own tab `OVERSEER · <label>` and your agent `overseer`, spawn one child orchestration session with your host's launch form, label its tab `ORCH · <label>` 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,
|
|
29
|
+
- **Primary session without an orchestrator:** rename your own tab `OVERSEER · <label>` and your agent `overseer`, spawn one child orchestration session with your host's launch form, label its tab `ORCH · <label>` 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, retire any PREVIOUS orchestrator through [mission-end teardown](#mission-end-teardown-queue-row-240-d-2138-add1-r3-add4).
|
|
30
30
|
- **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 · <label>"` (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:
|
|
31
31
|
- **Claude Code:** `herdr agent start orchestrator --kind claude --pane <root-pane-id> -- --permission-mode bypassPermissions`
|
|
32
32
|
- **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.
|
|
@@ -69,7 +69,62 @@ agent session that owns no panes (queue row 192; D-2102). The role check above d
|
|
|
69
69
|
|
|
70
70
|
## Stand-down (mission end and retirement)
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
At each terminal state (green, failed, or parked), finish the execution record beside the source spec.
|
|
73
|
+
The ORCH's artifact is this execution record. Append its named terminal marker BEFORE the record commit
|
|
74
|
+
(queue row 240; D-2138 add.1 R3): `printf '\n<!-- tickmarkr:orch-end %s -->\n' "<runId>" >> "<execution-record>"`.
|
|
75
|
+
Verify the final line is `<!-- tickmarkr:orch-end <runId> -->`, then commit that record when tracked
|
|
76
|
+
and notify the operator. Stop every monitor and background task you started, sweep your watcher
|
|
77
|
+
heartbeat/beat files, print a final stand-down line, and leave nothing queued in your input box.
|
|
78
|
+
After that stand-down, write `pids/<seat>.stood-down` with `mission=<mission>` as the brief requires.
|
|
79
|
+
The supervisor reads back the stand-down and exactly that execution record's end marker, then closes
|
|
80
|
+
the retired ORCH at its recorded address using the host's exact command and ownership proof below;
|
|
81
|
+
a plain host has no spawned terminal to close. Ghost-text suggestions are UI, not queued input.
|
|
82
|
+
|
|
83
|
+
## Mission-end teardown (queue row 240; D-2138 add.1 R3, add.4)
|
|
84
|
+
|
|
85
|
+
**The spawner closes the seat it created.** Manual closes and watcher closes follow this same law:
|
|
86
|
+
close only once its artifact and terminal marker are on disk, its stand-down is read back, and no
|
|
87
|
+
next mission is named for it. An agent's `done` signal, a bare handle, or a title alone is never enough.
|
|
88
|
+
The ORCH's artifact is the execution record ending in `<!-- tickmarkr:orch-end <runId> -->`;
|
|
89
|
+
the supervisor reads exactly that record and that run's marker before retiring the orchestrator.
|
|
90
|
+
Keep a reused seat only with its next mission named and a `/clear` at the mission boundary.
|
|
91
|
+
Keep the latest settled consult round only while the mission continues; at mission end apply this
|
|
92
|
+
same close or reuse rule. A dedicated consultant tab may outlive the orchestrator tab during that
|
|
93
|
+
continuing mission, never indefinitely after the mission ends.
|
|
94
|
+
|
|
95
|
+
**Record ownership at spawn**, beside watcher pids under `<state-dir>/overseer/pids/<seat>.handle`:
|
|
96
|
+
write `host`, `address`, `title`, `checkout`, and `mission`, one `key=value` per line, from the create
|
|
97
|
+
receipt; use the actual tab title and checkout. This is the spawn ledger, not a list reconstructed
|
|
98
|
+
from titles. Record an owned tab as `tab=<tab-id>` when the seat owns it. Every spawned seat's brief
|
|
99
|
+
ends with: after stand-down, write `pids/<seat>.stood-down` containing `mission=<mission>`.
|
|
100
|
+
On every new assignment, including named-next-mission reuse, rewrite the ledger's `mission` and
|
|
101
|
+
delete the old stood-down file BEFORE that mission starts (D-2138 add.6 V01). Read back the stood-down
|
|
102
|
+
file and require its mission to equal the ledger's current mission; an earlier mission's stand-down
|
|
103
|
+
never retires the reused seat.
|
|
104
|
+
|
|
105
|
+
**Before every close, first match the spawn record against the live tab title and checkout**, at its
|
|
106
|
+
recorded address. A mismatch closes nothing. Never close a tab you did not create, a recycled handle
|
|
107
|
+
now showing an operator's terminal, a seat matched by title alone, or your own launching terminal.
|
|
108
|
+
A stale or mismatched Orca handle permits read-only re-resolution by title and checkout to read the
|
|
109
|
+
seat; never close the replacement by title, never rewrite ownership from a title-only match.
|
|
110
|
+
|
|
111
|
+
- **On herdr (`HERDR_ENV=1`)**: verify `herdr pane list`: the recorded address equals `pane_id`,
|
|
112
|
+
the recorded title equals `label`, and the recorded checkout equals `cwd` (filesystem identity).
|
|
113
|
+
Only then use `herdr pane close <pane>` at the recorded address. Prove the pane absent from
|
|
114
|
+
`herdr pane list`; for a seat owning its tab, use `herdr tab close <tab>` at its recorded tab id
|
|
115
|
+
after the final owned pane closes, and prove the tab absent from `herdr tab list`. A shared tab
|
|
116
|
+
with a pending sibling stays open. Never assume emptied tabs disappear without checking.
|
|
117
|
+
- **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: verify ownership with
|
|
118
|
+
`orca terminal list --include-visual-layouts --json`: the spawn record's address is the leaf handle,
|
|
119
|
+
its title is the live TAB title (never the shell's pane title), and its checkout matches the layout
|
|
120
|
+
worktree and terminal row. An unbound checkout or truncated listing is unverifiable and closes nothing.
|
|
121
|
+
Only then use `orca terminal close --terminal <handle> --json` with the recorded handle. Require
|
|
122
|
+
`result.close.handle` equal to that handle, or the documented live-terminal refusal
|
|
123
|
+
`ok:false`, `error.code:runtime_error`, `error.message:tab_not_found` from that same runtime.
|
|
124
|
+
Prove the handle absent from `orca terminal list --json` AND its owned tab absent from
|
|
125
|
+
`orca terminal list --include-visual-layouts --json` after its final terminal closes, in the same
|
|
126
|
+
runtime as the pre-close listing. A surviving owned tab is a spec question, never permission to
|
|
127
|
+
close by title. The Orca smoke e2e asserts this tab leg; a receipt alone is no close proof.
|
|
73
128
|
|
|
74
129
|
## Invariants
|
|
75
130
|
|
|
@@ -132,7 +187,7 @@ Never report "briefed" or "relayed" without the confirmation your host gives:
|
|
|
132
187
|
- **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.
|
|
133
188
|
- **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.
|
|
134
189
|
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.
|
|
135
|
-
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
|
|
190
|
+
6. **Record** — `tickmarkr report <runId> --md` prints Markdown to stdout; redirect explicitly beside the spec (for example `tickmarkr report <runId> --md > feature.record.md`) then follow [stand-down](#stand-down-mission-end-and-retirement) to append the ORCH end marker before committing the execution record when tracked.
|
|
136
191
|
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.
|
|
137
192
|
|
|
138
193
|
## Host-owned daemon and detached beats
|
|
@@ -15,7 +15,7 @@ When working in a multi-agent terminal environment (herdr or Orca), decide your
|
|
|
15
15
|
|
|
16
16
|
- **Orchestrator:** your session was started to execute the mission. Rename your own tab/pane `ORCH · <label>` (short labels: ≤20 chars, `ROLE · token`) and run the loop below.
|
|
17
17
|
- **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.
|
|
18
|
-
- **Primary session without an orchestrator:** rename your own tab `OVERSEER · <label>` and your agent `overseer`, spawn one child orchestration session with your host's launch form, label its tab `ORCH · <label>` 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,
|
|
18
|
+
- **Primary session without an orchestrator:** rename your own tab `OVERSEER · <label>` and your agent `overseer`, spawn one child orchestration session with your host's launch form, label its tab `ORCH · <label>` 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, retire any PREVIOUS orchestrator through [mission-end teardown](#mission-end-teardown-queue-row-240-d-2138-add1-r3-add4).
|
|
19
19
|
- **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 · <label>"` (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:
|
|
20
20
|
- **Claude Code:** `herdr agent start orchestrator --kind claude --pane <root-pane-id> -- --permission-mode bypassPermissions`
|
|
21
21
|
- **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.
|
|
@@ -111,12 +111,66 @@ Never report "briefed" or "relayed" without the confirmation your host gives:
|
|
|
111
111
|
|
|
112
112
|
## Dedicated consultant tab rule
|
|
113
113
|
|
|
114
|
-
When spawning consultants (agents gathering synthesis input for decisions like SCOPER analysis or architectural reviews), create them in a DEDICATED tab separate from the ORCHESTRATOR tab.
|
|
114
|
+
When spawning consultants (agents gathering synthesis input for decisions like SCOPER analysis or architectural reviews), create them in a DEDICATED tab separate from the ORCHESTRATOR tab. The dedicated consultant tab persists beyond an orchestrator retirement only while the mission continues; keep the latest settled round during that mission, then apply mission-end teardown to close or reuse it (queue row 240; D-2138 add.1 R3).
|
|
115
115
|
|
|
116
116
|
## Stand-down (mission end and retirement)
|
|
117
117
|
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
At each terminal state (green, failed, or parked), finish the execution record beside the source spec.
|
|
119
|
+
The ORCH's artifact is this execution record. Append its named terminal marker BEFORE the record commit
|
|
120
|
+
(queue row 240; D-2138 add.1 R3): `printf '\n<!-- tickmarkr:orch-end %s -->\n' "<runId>" >> "<execution-record>"`.
|
|
121
|
+
Verify the final line is `<!-- tickmarkr:orch-end <runId> -->`, then commit that record when tracked
|
|
122
|
+
and notify the operator. Stop every monitor and background task you started, sweep your watcher
|
|
123
|
+
heartbeat/beat files, print a final stand-down line, and leave nothing queued in your input box.
|
|
124
|
+
After that stand-down, write `pids/<seat>.stood-down` with `mission=<mission>` as the brief requires.
|
|
125
|
+
The supervisor reads back the stand-down and exactly that execution record's end marker, then closes
|
|
126
|
+
the retired ORCH at its recorded address using the host's exact command and ownership proof below;
|
|
127
|
+
a plain host has no spawned terminal to close. Ghost-text suggestions are UI, not queued input.
|
|
128
|
+
|
|
129
|
+
## Mission-end teardown (queue row 240; D-2138 add.1 R3, add.4)
|
|
130
|
+
|
|
131
|
+
**The spawner closes the seat it created.** Manual closes and watcher closes follow this same law:
|
|
132
|
+
close only once its artifact and terminal marker are on disk, its stand-down is read back, and no
|
|
133
|
+
next mission is named for it. An agent's `done` signal, a bare handle, or a title alone is never enough.
|
|
134
|
+
The ORCH's artifact is the execution record ending in `<!-- tickmarkr:orch-end <runId> -->`;
|
|
135
|
+
the supervisor reads exactly that record and that run's marker before retiring the orchestrator.
|
|
136
|
+
Keep a reused seat only with its next mission named and a `/clear` at the mission boundary.
|
|
137
|
+
Keep the latest settled consult round only while the mission continues; at mission end apply this
|
|
138
|
+
same close or reuse rule. A dedicated consultant tab may outlive the orchestrator tab during that
|
|
139
|
+
continuing mission, never indefinitely after the mission ends.
|
|
140
|
+
|
|
141
|
+
**Record ownership at spawn**, beside watcher pids under `<state-dir>/overseer/pids/<seat>.handle`:
|
|
142
|
+
write `host`, `address`, `title`, `checkout`, and `mission`, one `key=value` per line, from the create
|
|
143
|
+
receipt; use the actual tab title and checkout. This is the spawn ledger, not a list reconstructed
|
|
144
|
+
from titles. Record an owned tab as `tab=<tab-id>` when the seat owns it. Every spawned seat's brief
|
|
145
|
+
ends with: after stand-down, write `pids/<seat>.stood-down` containing `mission=<mission>`.
|
|
146
|
+
On every new assignment, including named-next-mission reuse, rewrite the ledger's `mission` and
|
|
147
|
+
delete the old stood-down file BEFORE that mission starts (D-2138 add.6 V01). Read back the stood-down
|
|
148
|
+
file and require its mission to equal the ledger's current mission; an earlier mission's stand-down
|
|
149
|
+
never retires the reused seat.
|
|
150
|
+
|
|
151
|
+
**Before every close, first match the spawn record against the live tab title and checkout**, at its
|
|
152
|
+
recorded address. A mismatch closes nothing. Never close a tab you did not create, a recycled handle
|
|
153
|
+
now showing an operator's terminal, a seat matched by title alone, or your own launching terminal.
|
|
154
|
+
A stale or mismatched Orca handle permits read-only re-resolution by title and checkout to read the
|
|
155
|
+
seat; never close the replacement by title, never rewrite ownership from a title-only match.
|
|
156
|
+
|
|
157
|
+
- **On herdr (`HERDR_ENV=1`)**: verify `herdr pane list`: the recorded address equals `pane_id`,
|
|
158
|
+
the recorded title equals `label`, and the recorded checkout equals `cwd` (filesystem identity).
|
|
159
|
+
Only then use `herdr pane close <pane>` at the recorded address. Prove the pane absent from
|
|
160
|
+
`herdr pane list`; for a seat owning its tab, use `herdr tab close <tab>` at its recorded tab id
|
|
161
|
+
after the final owned pane closes, and prove the tab absent from `herdr tab list`. A shared tab
|
|
162
|
+
with a pending sibling stays open. Never assume emptied tabs disappear without checking.
|
|
163
|
+
- **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: verify ownership with
|
|
164
|
+
`orca terminal list --include-visual-layouts --json`: the spawn record's address is the leaf handle,
|
|
165
|
+
its title is the live TAB title (never the shell's pane title), and its checkout matches the layout
|
|
166
|
+
worktree and terminal row. An unbound checkout or truncated listing is unverifiable and closes nothing.
|
|
167
|
+
Only then use `orca terminal close --terminal <handle> --json` with the recorded handle. Require
|
|
168
|
+
`result.close.handle` equal to that handle, or the documented live-terminal refusal
|
|
169
|
+
`ok:false`, `error.code:runtime_error`, `error.message:tab_not_found` from that same runtime.
|
|
170
|
+
Prove the handle absent from `orca terminal list --json` AND its owned tab absent from
|
|
171
|
+
`orca terminal list --include-visual-layouts --json` after its final terminal closes, in the same
|
|
172
|
+
runtime as the pre-close listing. A surviving owned tab is a spec question, never permission to
|
|
173
|
+
close by title. The Orca smoke e2e asserts this tab leg; a receipt alone is no close proof.
|
|
120
174
|
|
|
121
175
|
## The loop
|
|
122
176
|
|
|
@@ -127,7 +181,7 @@ When spawning consultants (agents gathering synthesis input for decisions like S
|
|
|
127
181
|
- **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.
|
|
128
182
|
- **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.
|
|
129
183
|
5. **Verify and consolidate** — accept only 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 task work on `tickmarkr/<runId>`; it never signs off to the main branch. A human may later merge that integration branch through the repository's normal release process.
|
|
130
|
-
6. **Record** — `tickmarkr report <runId> --md` prints Markdown to stdout. Redirect it explicitly beside the source spec (for example `tickmarkr report <runId> --md > feature.record.md`)
|
|
184
|
+
6. **Record** — `tickmarkr report <runId> --md` prints Markdown to stdout. Redirect it explicitly beside the source spec (for example `tickmarkr report <runId> --md > feature.record.md`) then follow [stand-down](#stand-down-mission-end-and-retirement) to append the ORCH end marker before committing the execution record when tracked.
|
|
131
185
|
|
|
132
186
|
## Host-owned daemon and detached beats
|
|
133
187
|
|
|
@@ -71,6 +71,53 @@ the answer took session-file archaeology). The flip from the engine (P89, journa
|
|
|
71
71
|
GSD legs (P92) had been RULED NOWHERE — no ledger entry decides it — and then propagated for six phases
|
|
72
72
|
through brief lineage. **An executor choice nobody made is still an executor choice, and it compounds.**
|
|
73
73
|
|
|
74
|
+
## Hand-made checkout law (queue row 240; D-2138 add.1 R4, add.4; gap G7)
|
|
75
|
+
|
|
76
|
+
This one law applies on herdr, on Orca and on a plain host, to EVERY hand-made checkout:
|
|
77
|
+
integration snapshots, SCOPER, REF, oracle and any other manually created worktree. `<repo>` is the
|
|
78
|
+
repository's primary root, never a task checkout. Its seats-directory home is `<repo>-seats/<name>`,
|
|
79
|
+
a sibling of the repository root; on Orca an Orca-managed worktree is also allowed. Never create one
|
|
80
|
+
inside `.tickmarkr/worktrees.noindex`, or under `/private/tmp` (including the `/tmp` alias): a reboot
|
|
81
|
+
wipes that temporary root. Product-journaled task checkouts, Orca-managed run checkouts and throwaway
|
|
82
|
+
clones retain their existing lifecycle; this is the creating seat's duty, never a product cleanup sweep.
|
|
83
|
+
|
|
84
|
+
**Creation and ledger.** Before creation, make the seats directory and `<repo>/.tickmarkr/overseer/`
|
|
85
|
+
if absent. Immediately after each successful creation, before using the checkout, append one line to
|
|
86
|
+
`<repo>/.tickmarkr/overseer/checkouts.ledger` (the repository's `.tickmarkr/overseer/checkouts.ledger`):
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
path=<repo>-seats/<name> | purpose=<snapshot/scoper/REF/oracle/...> | owner=<seat> | remove-when=mission <mission> ends | keep=none | state=open
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
For an Orca-managed checkout, record its actual path instead. Use a stable owner seat identity even on
|
|
93
|
+
a plain host; a successor never silently adopts another seat's lines. Each refresh closes the old
|
|
94
|
+
checkout's line and appends a new line for the newly created checkout. A keep reason is recorded as
|
|
95
|
+
`keep=<reason>; owner=<seat>; until=<condition>`, never inferred from a directory's name.
|
|
96
|
+
|
|
97
|
+
**Mission-end removal, per owned ledger line.** At mission end (green, failed or parked), after users
|
|
98
|
+
of the checkout have stood down, read only open lines whose `owner` is THIS seat. Use each line's exact
|
|
99
|
+
`path`: an unledgered checkout or another seat's checkout is untouched. Never discover removal targets
|
|
100
|
+
by directory glob, directory sweep or worktree-list inventory. For each owned line:
|
|
101
|
+
|
|
102
|
+
1. Run `git -C <dir> status --porcelain` at that exact ledger path; require successful exit and capture
|
|
103
|
+
the entire output. Nonempty means **dirty → kept**: record `state=kept` and report its status and path.
|
|
104
|
+
2. A recorded keep reason means **kept**, even if clean: record `state=kept`, the reason, owner seat and
|
|
105
|
+
condition for eventual removal, and report them. A dirty checkout with a keep reason records both.
|
|
106
|
+
3. Only a clean checkout with no keep reason can proceed. Before ANY such removal (including a snapshot
|
|
107
|
+
refresh), determine whether HEAD is detached with `git -C <dir> symbolic-ref -q HEAD` (exit 1 means
|
|
108
|
+
detached). Resolve its exact head with `git -C <dir> rev-parse --verify HEAD`. For a detached head,
|
|
109
|
+
prove it reachable from a retained named ref using
|
|
110
|
+
`git -C <repo> for-each-ref --contains <sha> --format='%(refname)'`. Record the retained ref on the
|
|
111
|
+
ledger line and retain it after removal; HEAD and a reflog are not named refs. A successful query
|
|
112
|
+
with no retained named ref means **pin first**: run
|
|
113
|
+
`git -C <repo> update-ref refs/tickmarkr/preserved/<sha> <sha>`, verify that ref resolves to `<sha>`,
|
|
114
|
+
and write `preserved=refs/tickmarkr/preserved/<sha>` on THIS checkout's ledger line BEFORE removal.
|
|
115
|
+
Keep that recovery ref after removal. A failed status, HEAD/ref query, pin or ledger write means
|
|
116
|
+
kept and reported, never permission to continue.
|
|
117
|
+
4. Then use `git worktree remove <dir>` from `<repo>`, **without force, never `--force` or `-f`**.
|
|
118
|
+
Only successful removal closes that ledger line (`state=closed`); a refusal leaves it open/kept
|
|
119
|
+
and is reported. Never delete the directory directly or override a dirty/locked refusal.
|
|
120
|
+
|
|
74
121
|
## On a plain host (neither herdr nor Orca)
|
|
75
122
|
|
|
76
123
|
Neither `HERDR_ENV=1` nor the Orca markers are set: the Claude Desktop Code tab, a plain terminal, any
|
|
@@ -197,7 +244,8 @@ replaces the two-tier setup.
|
|
|
197
244
|
(own tab, or a shared WORKERS tab), never the ORCH tab.
|
|
198
245
|
- `CONSULT · <topic>` — ONE shared tab for ALL consultants of a round, side-by-side splits; never one
|
|
199
246
|
tab per consultant; do NOT auto-close after adjudication (operator, 2026-08-09 — keep the round's
|
|
200
|
-
panes
|
|
247
|
+
panes while the mission continues or until a successor round supersedes them; at mission end apply
|
|
248
|
+
mission-end teardown, queue row 240; D-2138 add.1 R3).
|
|
201
249
|
- `REVIEW <task>` — reviewer panes.
|
|
202
250
|
Never multiply beyond these without asking. **Tabs you did not create are the OPERATOR'S — provenance
|
|
203
251
|
decides: never close, rename, reuse, or send input to one, however idle it looks.** An "idle
|
|
@@ -249,8 +297,8 @@ replaces the two-tier setup.
|
|
|
249
297
|
**Handles do not survive an Orca restart (OBS-1050, three gates lost to it in v2.5.6).** The restart
|
|
250
298
|
re-issues every terminal handle; a recorded one answers `terminal_handle_stale` and any watcher or gate
|
|
251
299
|
keyed to it is already dead. So: record every seat's handle in `pids/<seat>.handle` beside its title at
|
|
252
|
-
spawn; on `terminal_handle_stale`, re-list terminals
|
|
253
|
-
|
|
300
|
+
spawn with host, address, title, checkout and mission; on `terminal_handle_stale`, re-list terminals
|
|
301
|
+
and re-resolve by title and checkout for READS ONLY, never rewrite the spawn record from title alone; and treat a stale handle as a dead seat until the read-back of the new handle proves
|
|
254
302
|
otherwise — a gate that was running in that seat has to be re-launched, never assumed to continue.
|
|
255
303
|
When briefing a seat on either host, take its current address from the handoff file; never hardcode a pane id
|
|
256
304
|
or terminal handle in a brief or command template.
|
|
@@ -343,7 +391,8 @@ Kimi `-y`, artifact plus terminal marker, and verified file-brief delivery. Flee
|
|
|
343
391
|
into it (measured 2026-09-20: `terminal create` timed out on an unmanaged spec worktree, the overseer
|
|
344
392
|
split instead, and the run had to be re-seated before GO). Before delivering the brief, prove the seat
|
|
345
393
|
is in its own tab: `orca terminal list --json`, the new handle's `tabId` must differ from the
|
|
346
|
-
overseer's; if it does not,
|
|
394
|
+
overseer's; if it does not, withhold the brief and report the misplaced seat; never treat the
|
|
395
|
+
launching tab as owned (queue row 240; D-2138 add.4). `split` is for seats
|
|
347
396
|
that BELONG beside another seat (consultant pairs, a reviewer beside its surgeon), never for the
|
|
348
397
|
orchestrator or a gate seat.
|
|
349
398
|
**Seat names on Orca are TAB titles, and a tab title HOLDS; the pane title is the agent's and drifts.**
|
|
@@ -551,8 +600,15 @@ journal tail to decide what happens next, or sweeping orphans — you have taken
|
|
|
551
600
|
branch name alone, which moves while you are reading it;
|
|
552
601
|
- **the operator view of that branch** — a live run has NO integration checkout: the daemon publishes
|
|
553
602
|
each merge onto `tickmarkr/<runId>` by compare-and-swap from the repository root. View it through a
|
|
554
|
-
detached snapshot
|
|
555
|
-
|
|
603
|
+
detached snapshot in the seats-directory home `<repo>-seats/<name>`:
|
|
604
|
+
`git worktree add --detach <repo>-seats/<name> tickmarkr/<runId>`. Append its path, snapshot purpose,
|
|
605
|
+
owner seat and mission-end removal condition to `.tickmarkr/overseer/checkouts.ledger` before use.
|
|
606
|
+
Follow the hand-made checkout law (queue row 240; D-2138 add.1 R4, add.4): before refresh or
|
|
607
|
+
mission-end removal, prove a clean detached HEAD reachable from a retained named ref with
|
|
608
|
+
`git for-each-ref --contains <sha>`; otherwise pin `refs/tickmarkr/preserved/<sha>` and write that
|
|
609
|
+
handle on its ledger line first. Refresh only through that law's clean/no-keep check and
|
|
610
|
+
`git worktree remove <repo>-seats/<name>` without force, close its line, then re-add and append a
|
|
611
|
+
new line; dirty or kept snapshots stay and are reported. Check the branch out attached only with the daemon
|
|
556
612
|
stopped (no live `graph.lock`) — an attached checkout taken while the run is live goes stale at the next
|
|
557
613
|
publication, and any write to the ref parks the next merge infra. Run `tickmarkr verify` and `report`
|
|
558
614
|
from the main checkout or a snapshot;
|
|
@@ -1526,43 +1582,87 @@ orchestrator turn boundary.
|
|
|
1526
1582
|
|
|
1527
1583
|
At seat spawn, arm file/journal watchers for artifact completion, run events, missing progress and context evidence. Record each watcher owner and bounded expiry; renew on every wake and stop on stand-down. Read `orca terminal read --terminal <handle> --screen --json` on each wake to detect blocked or pending input. For a human gate, write a checkpoint evidence file and announce it through verified terminal send; the moment a successor opens that file (or any other cited file) back, log the open per CITE-IS-NOT-READ (`opened <path>` appended to `.tickmarkr/overseer/opened-files.log`). Use Orca notifications only when the installed host advertises a notification capability; the current CLI has no `notification` command, so the file and terminal receipt remain the delivery path. A notification or accepted input alone never proves delivery or completion. A seat-liveness watcher on the ORCHESTRATOR handle is mandatory, not optional: poll `orca terminal read --terminal <handle> --screen --json` on a bounded interval and key liveness on `result.terminal.status === "running"` — never on the envelope's own `ok`. `ok: true` proves only that the READ command executed; a closed seat's read still returns that same `ok: true` with `result.terminal.status: "exited"`, and a matcher keyed on `ok` reads that exited seat as alive and never fires (OBS-1087). Treat `result.terminal.status !== "running"`, `terminal_handle_stale`, or a missing terminal as seat death, and re-resolve by title before re-arming (OBS-1050 — the orchestrator seat exited silently and the daemon ran unsupervised to a PARTIAL run-end).
|
|
1528
1584
|
|
|
1585
|
+
The artifact watcher is available on Orca (queue row 240; D-2138 add.4):
|
|
1586
|
+
`TKR_CLOSE_TERMINALS="<handle>,<handle>" bash skills/tickmarkr-overseer/scripts/watch-artifacts.sh <MARKER> <cap-s> <poll-s> <file>...`.
|
|
1587
|
+
Supply one recorded address per artifact in argument order; a length mismatch is refused at arm time.
|
|
1588
|
+
Both all-complete and wake-on-any exits close only each completed artifact's stood-down seat after
|
|
1589
|
+
matching the spawn ledger to its live tab title, leaf handle and layout checkout. Pending siblings,
|
|
1590
|
+
missing markers, missing/current-mission-mismatched stand-downs and timeouts close nothing.
|
|
1591
|
+
The verified close uses `orca terminal close --terminal <handle> --json`, checks the handle receipt
|
|
1592
|
+
or documented live refusal, then handle absence from the terminal list and owned-tab absence from
|
|
1593
|
+
visual layouts. A stale handle permits read-only re-resolution by title; it never licenses a close.
|
|
1594
|
+
Do not arm closure for the latest settled consult round while its mission continues.
|
|
1595
|
+
|
|
1596
|
+
## Mission-end teardown (queue row 240; D-2138 add.1 R3, add.4)
|
|
1597
|
+
|
|
1598
|
+
**The spawner closes the seat it created.** Manual closes and watcher closes follow this same law:
|
|
1599
|
+
close only once its artifact and terminal marker are on disk, its stand-down is read back, and no
|
|
1600
|
+
next mission is named for it. An agent's `done` signal, a bare handle, or a title alone is never enough.
|
|
1601
|
+
The ORCH's artifact is the execution record ending in `<!-- tickmarkr:orch-end <runId> -->`;
|
|
1602
|
+
the supervisor reads exactly that record and that run's marker before retiring the orchestrator.
|
|
1603
|
+
Keep a reused seat only with its next mission named and a `/clear` at the mission boundary.
|
|
1604
|
+
Keep the latest settled consult round only while the mission continues; at mission end apply this
|
|
1605
|
+
same close or reuse rule. A dedicated consultant tab may outlive the orchestrator tab during that
|
|
1606
|
+
continuing mission, never indefinitely after the mission ends.
|
|
1607
|
+
|
|
1608
|
+
**Record ownership at spawn**, beside watcher pids under `<state-dir>/overseer/pids/<seat>.handle`:
|
|
1609
|
+
write `host`, `address`, `title`, `checkout`, and `mission`, one `key=value` per line, from the create
|
|
1610
|
+
receipt; use the actual tab title and checkout. This is the spawn ledger, not a list reconstructed
|
|
1611
|
+
from titles. Record an owned tab as `tab=<tab-id>` when the seat owns it. Every spawned seat's brief
|
|
1612
|
+
ends with: after stand-down, write `pids/<seat>.stood-down` containing `mission=<mission>`.
|
|
1613
|
+
On every new assignment, including named-next-mission reuse, rewrite the ledger's `mission` and
|
|
1614
|
+
delete the old stood-down file BEFORE that mission starts (D-2138 add.6 V01). Read back the stood-down
|
|
1615
|
+
file and require its mission to equal the ledger's current mission; an earlier mission's stand-down
|
|
1616
|
+
never retires the reused seat.
|
|
1617
|
+
|
|
1618
|
+
**Before every close, first match the spawn record against the live tab title and checkout**, at its
|
|
1619
|
+
recorded address. A mismatch closes nothing. Never close a tab you did not create, a recycled handle
|
|
1620
|
+
now showing an operator's terminal, a seat matched by title alone, or your own launching terminal.
|
|
1621
|
+
A stale or mismatched Orca handle permits read-only re-resolution by title and checkout to read the
|
|
1622
|
+
seat; never close the replacement by title, never rewrite ownership from a title-only match.
|
|
1623
|
+
|
|
1624
|
+
- **On herdr (`HERDR_ENV=1`)**: verify `herdr pane list`: the recorded address equals `pane_id`,
|
|
1625
|
+
the recorded title equals `label`, and the recorded checkout equals `cwd` (filesystem identity).
|
|
1626
|
+
Only then use `herdr pane close <pane>` at the recorded address. Prove the pane absent from
|
|
1627
|
+
`herdr pane list`; for a seat owning its tab, use `herdr tab close <tab>` at its recorded tab id
|
|
1628
|
+
after the final owned pane closes, and prove the tab absent from `herdr tab list`. A shared tab
|
|
1629
|
+
with a pending sibling stays open. Never assume emptied tabs disappear without checking.
|
|
1630
|
+
- **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: verify ownership with
|
|
1631
|
+
`orca terminal list --include-visual-layouts --json`: the spawn record's address is the leaf handle,
|
|
1632
|
+
its title is the live TAB title (never the shell's pane title), and its checkout matches the layout
|
|
1633
|
+
worktree and terminal row. An unbound checkout or truncated listing is unverifiable and closes nothing.
|
|
1634
|
+
Only then use `orca terminal close --terminal <handle> --json` with the recorded handle. Require
|
|
1635
|
+
`result.close.handle` equal to that handle, or the documented live-terminal refusal
|
|
1636
|
+
`ok:false`, `error.code:runtime_error`, `error.message:tab_not_found` from that same runtime.
|
|
1637
|
+
Prove the handle absent from `orca terminal list --json` AND its owned tab absent from
|
|
1638
|
+
`orca terminal list --include-visual-layouts --json` after its final terminal closes, in the same
|
|
1639
|
+
runtime as the pre-close listing. A surviving owned tab is a spec question, never permission to
|
|
1640
|
+
close by title. The Orca smoke e2e asserts this tab leg; a receipt alone is no close proof.
|
|
1641
|
+
|
|
1529
1642
|
## Specialist pipeline rules
|
|
1530
1643
|
|
|
1531
1644
|
### On herdr (`HERDR_ENV=1`) — specialist panes
|
|
1532
1645
|
|
|
1533
|
-
- **Dedicated consultant tab**: Consultants (agents spawned to gather synthesis input for decisions like SCOPER analysis or architectural reviews) must run in a DEDICATED tab separate from the ORCHESTRATOR tab.
|
|
1534
|
-
- **Scoper worktree rule**: The SCOPER (or any worktree-based specialist synthesizing into the spec pipeline) must do ALL git operations in a dedicated worktree (e.g., `git worktree add
|
|
1646
|
+
- **Dedicated consultant tab**: Consultants (agents spawned to gather synthesis input for decisions like SCOPER analysis or architectural reviews) must run in a DEDICATED tab separate from the ORCHESTRATOR tab. The dedicated consultant tab persists beyond an orchestrator retirement only while the mission continues; the latest settled round follows mission-end teardown at that mission's end (queue row 240; D-2138 add.1 R3).
|
|
1647
|
+
- **Scoper worktree rule**: The SCOPER (or any worktree-based specialist synthesizing into the spec pipeline) must do ALL git operations in a dedicated worktree in the seats-directory home `<repo>-seats/<name>` (e.g., `git worktree add -b spec/<mission> <repo>-seats/scoper-<mission>`), never switching the main checkout's branch. Append its path, scoper purpose, owner seat and mission-end removal condition to `.tickmarkr/overseer/checkouts.ledger` before use. The hand-made checkout law (queue row 240; D-2138 add.1 R4, add.4) governs it: at mission end remove only a clean owned checkout with no keep reason using `git worktree remove <repo>-seats/scoper-<mission>` without force and close its line; dirty or kept checkouts stay and are reported. Before removing a detached HEAD, prove reachability from a retained named ref with `git for-each-ref --contains <sha>`, otherwise pin `refs/tickmarkr/preserved/<sha>` and write that handle on its ledger line first. This prevents race conditions between the specialist's branch operations and the orchestrator's shipping logic.
|
|
1535
1648
|
- **One fresh pane per consult ROUND** (operator rule, 2026-08-04): every consult round spawns a NEW pane in
|
|
1536
1649
|
the consult tab rather than re-prompting the seat that answered last round — unless there is a stated
|
|
1537
1650
|
reason to reuse. Two payoffs: each round starts with a clean context window instead of inheriting the
|
|
1538
1651
|
previous round's, which is what fills a long mission's seats and forces mid-mission `/clear`; and the
|
|
1539
1652
|
prior round's pane persists as a readable record of what that seat actually saw and said. Reuse only when
|
|
1540
1653
|
continuity of the seat's own reasoning is the point, and say so when you do.
|
|
1541
|
-
- **CLOSE WHAT YOU SPAWNED
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
supposed to end with, per seat, then close.
|
|
1554
|
-
- **A pane is not an archive; the report is.** Once a worker's findings are written and synthesized, its
|
|
1555
|
-
transcript adds nothing the report does not.
|
|
1556
|
-
- **KEEP: the active seat, and the most recent SETTLED consult round.** That last one earns its place —
|
|
1557
|
-
re-prompting the seat that found a defect to confirm its own fix is cheaper and stricter than briefing
|
|
1558
|
-
a fresh one, which is the stated-reason exception above. Close consult rounds only once a later round
|
|
1559
|
-
has re-derived their findings.
|
|
1560
|
-
- Emptied tabs disappear on their own; do not close tabs by hand.
|
|
1561
|
-
- **CLOSE IT AUTOMATICALLY, because remembering is what fails.** `watch-artifacts.sh` already fires on
|
|
1562
|
-
the one signal that means a seat is finished — the artifact plus its terminal marker — so hand it the
|
|
1563
|
-
panes too: `TKR_CLOSE_PANES="w1:p1,w1:p2" watch-artifacts.sh …`. It closes them on completion and
|
|
1564
|
-
**never on timeout**, where the seats are still working. Closing on the marker cannot reap a seat
|
|
1565
|
-
mid-write, which is exactly why `done` would be the wrong trigger.
|
|
1654
|
+
- **CLOSE WHAT YOU SPAWNED**: apply the universal mission-end teardown, including the spawn record
|
|
1655
|
+
ownership check and current mission's stand-down. The spawner is responsible for closure.
|
|
1656
|
+
Keep the active seat and latest settled consult round while the mission continues; at its end close
|
|
1657
|
+
or name the next mission and clear before reuse (queue row 240; D-2138 add.1 R3, add.4).
|
|
1658
|
+
- For a seat owning its tab, after verified `herdr pane close <pane>`, explicitly use
|
|
1659
|
+
`herdr tab close <tab>` at the recorded tab id and prove absence from `herdr tab list`; shared tabs
|
|
1660
|
+
stay while a pending sibling owns a pane. The spawn record must match live label and checkout first.
|
|
1661
|
+
- **Automatic closure**: `TKR_CLOSE_PANES="w1:p1,w1:p2" watch-artifacts.sh …` supplies one recorded
|
|
1662
|
+
pane address per artifact, in argument order. Both all-complete and wake-on-any exits close only
|
|
1663
|
+
completed artifacts with current-mission stood-down files and verified ledger identity, never
|
|
1664
|
+
pending siblings or timeout. No ledger record means a reported refusal and an open pane.
|
|
1665
|
+
Do not arm closure for the latest settled consult round while its mission continues.
|
|
1566
1666
|
|
|
1567
1667
|
- **MEASURE BEFORE EVERY SPLIT, AND JOIN *DOWN* WHEN A RIGHT-SPLIT WOULD GO UNDER THE FLOOR.**
|
|
1568
1668
|
**Operator-observed 2026-08-06, with a screenshot:** five consult panes in one tab rendered **14 columns
|
|
@@ -1593,7 +1693,7 @@ At seat spawn, arm file/journal watchers for artifact completion, run events, mi
|
|
|
1593
1693
|
|
|
1594
1694
|
### On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`) — specialist terminals
|
|
1595
1695
|
|
|
1596
|
-
Create specialist seats using `orca terminal create --worktree path:<repo> --command "<agent-cmd>" --json` and record each returned handle. For a deliberate split use `orca terminal split --terminal <owned-handle> --direction horizontal --command "<agent-cmd>" --json`, retaining the child receipt. Deliver briefs with `terminal send --enter --wait-submit 15`, verify observed submission (`result.send.prompt.stages.includes("turn_started")`), and collect files beside `orca terminal read --terminal <handle> --screen --json`.
|
|
1696
|
+
Create specialist seats using `orca terminal create --worktree path:<repo> --command "<agent-cmd>" --json` and record each returned handle. For a deliberate split use `orca terminal split --terminal <owned-handle> --direction horizontal --command "<agent-cmd>" --json`, retaining the child receipt. Deliver briefs with `terminal send --enter --wait-submit 15`, verify observed submission (`result.send.prompt.stages.includes("turn_started")`), and collect files beside `orca terminal read --terminal <handle> --screen --json`. Completion watchers use the universal mission-end teardown: artifact plus terminal marker, current-mission stand-down read back, and spawn ledger matched to the live tab title and checkout before closing the recorded handle with `orca terminal close --terminal <handle> --json`; require the handle receipt or documented live refusal, handle absence and final owned-tab absence. Timeout never authorizes closure (queue row 240; D-2138 add.4).
|
|
1597
1697
|
|
|
1598
1698
|
## Non-negotiable rules
|
|
1599
1699
|
|
|
@@ -15,10 +15,9 @@
|
|
|
15
15
|
#
|
|
16
16
|
# Prints one wake reason and EXITS. Re-arm after every wake.
|
|
17
17
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
# wide and unreadable, because closing was a step someone had to remember.
|
|
18
|
+
# Queue row 240; D-2138 add.1 R3, add.4: optional close lists carry ONE recorded address per
|
|
19
|
+
# artifact, in argument order. Completion wakes on delivery; closure additionally requires the
|
|
20
|
+
# current mission's stand-down and live identity matching the spawn ledger. Never close on timeout.
|
|
22
21
|
set -u
|
|
23
22
|
# macOS ships bash 3.2, where `set -u` makes "${arr[@]}" on an EMPTY array a fatal unbound-variable
|
|
24
23
|
# error. Every expansion below therefore uses the ${arr[@]+"${arr[@]}"} guard. Caught by the timeout
|
|
@@ -38,6 +37,19 @@ POLL="${3:?}"
|
|
|
38
37
|
shift 3
|
|
39
38
|
[ "$#" -gt 0 ] || { echo "watch-artifacts: no files given" >&2; exit 2; }
|
|
40
39
|
|
|
40
|
+
# Refuse ambiguous arming before watching anything; preserve empty fields for count validation.
|
|
41
|
+
CLOSE_HOST=""
|
|
42
|
+
CLOSE_ADDRESSES="${TKR_CLOSE_TERMINALS:-${TKR_CLOSE_PANES:-}}"
|
|
43
|
+
if [ -n "${TKR_CLOSE_TERMINALS:-}" ] && [ -n "${TKR_CLOSE_PANES:-}" ]; then
|
|
44
|
+
echo "watch-artifacts: choose one host close list" >&2; exit 2
|
|
45
|
+
fi
|
|
46
|
+
if [ -n "$CLOSE_ADDRESSES" ]; then
|
|
47
|
+
if [ -n "${TKR_CLOSE_TERMINALS:-}" ]; then CLOSE_HOST=orca; else CLOSE_HOST=herdr; fi
|
|
48
|
+
node -e 'const a=process.argv[1].split(","); if(a.length!==Number(process.argv[2]) || a.some(x=>!x.trim())) process.exit(2)' "$CLOSE_ADDRESSES" "$#" || {
|
|
49
|
+
echo "watch-artifacts: close list requires one recorded address per artifact in argument order" >&2; exit 2;
|
|
50
|
+
}
|
|
51
|
+
fi
|
|
52
|
+
|
|
41
53
|
# OBS-848: ownership is a pid-file fact, never an argv/path pattern. Two tiers intentionally watch
|
|
42
54
|
# the same artifacts, so the journal/file argument cannot identify which watcher a seat may retire.
|
|
43
55
|
PID_DIR="${TKR_STATE_DIR:-.tickmarkr}/overseer/pids"
|
|
@@ -55,7 +67,7 @@ trap clear_pid EXIT
|
|
|
55
67
|
# two that happened to be in view at the time. Class, not instance.
|
|
56
68
|
END=$((SECONDS + CAP))
|
|
57
69
|
|
|
58
|
-
#
|
|
70
|
+
# Queue row 240; D-2138 add.4: DONE requires the marker on the final line and stable content.
|
|
59
71
|
#
|
|
60
72
|
# Anchored to the tail on purpose: a report that merely *mentions* its own marker mid-body has not
|
|
61
73
|
# finished, and grepping the whole file would call that done. This brief tells seats to end the file with
|
|
@@ -110,7 +122,7 @@ done
|
|
|
110
122
|
done_file() { # $1 = index into "$@", $2 = path
|
|
111
123
|
local i="$1" f="$2" sig
|
|
112
124
|
[ -s "$f" ] || return 1
|
|
113
|
-
tail -
|
|
125
|
+
tail -n 1 "$f" 2>/dev/null | grep -qF -- "$MARKER" || return 1
|
|
114
126
|
# CONTENT HASH, not size+mtime. The first version of this used `stat` size and mtime and the drill
|
|
115
127
|
# killed it on the incident's own shape: the correction that cost a ruling was `23 WEAK · 19 SOUND`
|
|
116
128
|
# -> `25 WEAK · 17 SOUND`, which is **byte-identical in length**, and mtime is whole seconds. A
|
|
@@ -124,12 +136,136 @@ done_file() { # $1 = index into "$@", $2 = path
|
|
|
124
136
|
return 1
|
|
125
137
|
}
|
|
126
138
|
|
|
139
|
+
# Queue row 240; D-2138 add.4 U04/U05/U08/U09, add.6 V01: both completion exits use
|
|
140
|
+
# this path. A bare address, title alone or old mission's stand-down never licenses a close.
|
|
141
|
+
close_completed() {
|
|
142
|
+
[ -n "$CLOSE_HOST" ] || return 0
|
|
143
|
+
node - "$PID_DIR" "$CLOSE_HOST" "$CLOSE_ADDRESSES" ${READY_INDEXES[@]+"${READY_INDEXES[@]}"} <<'NODE'
|
|
144
|
+
const fs = require("fs");
|
|
145
|
+
const path = require("path");
|
|
146
|
+
const { spawnSync } = require("child_process");
|
|
147
|
+
const [dir, host, addresses, ...ready] = process.argv.slice(2);
|
|
148
|
+
const canonical = p => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
|
|
149
|
+
const field = (o) => typeof o === "string" && o ? o : undefined;
|
|
150
|
+
const checkout = o => field(o.worktree) || field(o.worktreePath) || field(o.worktree_path)
|
|
151
|
+
|| field(o.worktree?.path) || (field(o.worktreeId)?.split("::").slice(1).join("::"))
|
|
152
|
+
|| field(o.path) || field(o.cwd);
|
|
153
|
+
const same = (a, b) => !!a && !!b && canonical(a) === canonical(b);
|
|
154
|
+
function record(file) {
|
|
155
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
156
|
+
const entries = raw.trim().split("\n").map(line => { const i = line.indexOf("="); return [line.slice(0, i), line.slice(i + 1)]; });
|
|
157
|
+
if (entries.some(([k]) => !k) || new Set(entries.map(([k]) => k)).size !== entries.length) throw Error("malformed spawn/stand-down record");
|
|
158
|
+
return { raw, values: Object.fromEntries(entries) };
|
|
159
|
+
}
|
|
160
|
+
function call(args, refusal = false) {
|
|
161
|
+
const r = spawnSync(host, args, { encoding: "utf8", timeout: 10000 });
|
|
162
|
+
if (r.error || r.signal) throw Error("host call unreadable or timed out");
|
|
163
|
+
let body; try { body = JSON.parse(r.stdout); } catch { throw Error("host response is not JSON"); }
|
|
164
|
+
if (refusal && r.status === 1 && body.ok === false && body.error?.code === "runtime_error" && body.error?.message === "tab_not_found") return body;
|
|
165
|
+
if (r.status !== 0 || body.ok === false || !body.result) throw Error("host call refused");
|
|
166
|
+
return body;
|
|
167
|
+
}
|
|
168
|
+
function orcaList(visual = true) {
|
|
169
|
+
const body = call(["terminal", "list", ...(visual ? ["--include-visual-layouts"] : []), "--limit", "10000", "--json"]);
|
|
170
|
+
if (body.ok !== true || !body._meta?.runtimeId || body._meta.runtimeId === "none" || body.result.truncated === true
|
|
171
|
+
|| !Array.isArray(body.result.terminals) || (visual && !Array.isArray(body.result.visualLayouts))) throw Error("unverifiable terminal list");
|
|
172
|
+
return body;
|
|
173
|
+
}
|
|
174
|
+
function tabs(node, out = []) {
|
|
175
|
+
if (Array.isArray(node)) node.forEach(n => tabs(n, out));
|
|
176
|
+
else if (node && typeof node === "object") {
|
|
177
|
+
if (Array.isArray(node.tabs)) out.push(...node.tabs);
|
|
178
|
+
tabs(node.first, out); tabs(node.second, out);
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
function leaves(node, out = []) {
|
|
183
|
+
if (Array.isArray(node)) node.forEach(n => leaves(n, out));
|
|
184
|
+
else if (node && typeof node === "object") {
|
|
185
|
+
if (node.type === "terminal" && field(node.handle)) out.push(node.handle);
|
|
186
|
+
else { leaves(node.panes, out); leaves(node.first, out); leaves(node.second, out); }
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
function liveTabs(body) {
|
|
191
|
+
return body.result.visualLayouts.flatMap(layout => tabs(layout.root).map(tab => ({ tab, checkout: checkout(layout), handles: leaves(tab.panes) })));
|
|
192
|
+
}
|
|
193
|
+
function paneList() {
|
|
194
|
+
const result = call(["pane", "list"]).result;
|
|
195
|
+
if (!Array.isArray(result.panes)) throw Error("unverifiable pane list");
|
|
196
|
+
return result.panes;
|
|
197
|
+
}
|
|
198
|
+
for (const index of ready) {
|
|
199
|
+
let attempted = false;
|
|
200
|
+
const address = addresses.split(",")[Number(index)];
|
|
201
|
+
try {
|
|
202
|
+
if (address === process.env.ORCA_TERMINAL_HANDLE || address === process.env.HERDR_PANE_ID) throw Error("own launching terminal");
|
|
203
|
+
const matches = fs.readdirSync(dir).filter(n => n.endsWith(".handle")).map(n => ({ file: path.join(dir, n), seat: n.slice(0, -7), ...record(path.join(dir, n)) }))
|
|
204
|
+
.filter(r => r.values.address === address && r.values.host === host);
|
|
205
|
+
if (matches.length !== 1) throw Error("no unique spawn ledger record names address");
|
|
206
|
+
const r = matches[0], v = r.values;
|
|
207
|
+
if (!v.title || !v.checkout || !v.mission) throw Error("incomplete spawn ledger");
|
|
208
|
+
if (r.seat === process.env.TKR_ARMING_SEAT) throw Error("own arming seat");
|
|
209
|
+
if (v.next_mission || v["next-mission"]) throw Error("named next mission keeps seat open until clear and reassignment");
|
|
210
|
+
const stood = path.join(dir, `${r.seat}.stood-down`);
|
|
211
|
+
const stoodDown = () => {
|
|
212
|
+
if (record(r.file).raw !== r.raw) throw Error("spawn ledger changed during verification");
|
|
213
|
+
if (record(stood).values.mission !== v.mission) throw Error("stand-down does not name current mission");
|
|
214
|
+
};
|
|
215
|
+
stoodDown();
|
|
216
|
+
if (host === "orca") {
|
|
217
|
+
const before = orcaList();
|
|
218
|
+
const rows = before.result.terminals.filter(t => t.handle === address);
|
|
219
|
+
const allTabs = liveTabs(before);
|
|
220
|
+
const owned = allTabs.filter(t => t.tab.title === v.title && same(t.checkout, v.checkout) && t.handles.includes(address));
|
|
221
|
+
if (rows.length !== 1 || !same(checkout(rows[0]), v.checkout) || owned.length !== 1) {
|
|
222
|
+
// Read-only re-resolution by title. Never substitute this candidate into a close command.
|
|
223
|
+
const candidates = allTabs.filter(t => t.tab.title === v.title && same(t.checkout, v.checkout)).flatMap(t => t.handles);
|
|
224
|
+
for (const handle of candidates) call(["terminal", "read", "--terminal", handle, "--screen", "--json"]);
|
|
225
|
+
throw Error("live tab title and checkout mismatch or stale handle; read-only re-resolution by title");
|
|
226
|
+
}
|
|
227
|
+
const tab = owned[0], tabId = tab.tab.id ?? tab.tab.tabId ?? rows[0].tabId;
|
|
228
|
+
if (!tabId) throw Error("owned tab id is unverifiable");
|
|
229
|
+
const finalTerminal = tab.handles.length === 1;
|
|
230
|
+
stoodDown();
|
|
231
|
+
attempted = true;
|
|
232
|
+
const receipt = call(["terminal", "close", "--terminal", address, "--json"], true);
|
|
233
|
+
if (receipt._meta?.runtimeId !== before._meta.runtimeId) throw Error("runtime changed at close");
|
|
234
|
+
if (receipt.ok !== false && (receipt.ok !== true || receipt.result?.close?.handle !== address)) throw Error("close receipt does not name recorded handle");
|
|
235
|
+
const after = orcaList(false), layoutAfter = orcaList();
|
|
236
|
+
if (after._meta.runtimeId !== before._meta.runtimeId || layoutAfter._meta.runtimeId !== before._meta.runtimeId
|
|
237
|
+
|| after.result.terminals.some(t => t.handle === address)
|
|
238
|
+
|| (finalTerminal && liveTabs(layoutAfter).some(t => (t.tab.id ?? t.tab.tabId) === tabId || (t.tab.title === v.title && same(t.checkout, v.checkout))))
|
|
239
|
+
|| (!finalTerminal && tab.handles.filter(h => h !== address).some(h => !after.result.terminals.some(t => t.handle === h)))) throw Error("handle or final owned tab survives close, sibling disappeared, or runtime changed; spec question, never close by title");
|
|
240
|
+
console.log(` CLOSED ${address}: receipt ${receipt.ok === false ? "runtime_error: tab_not_found" : receipt.result.close.handle}; handle absent from terminal list; ${finalTerminal ? `owned tab ${tabId} absent from visual layout` : `shared tab ${tabId} kept for sibling terminals`}`);
|
|
241
|
+
} else {
|
|
242
|
+
const before = paneList(), rows = before.filter(p => p.pane_id === address);
|
|
243
|
+
if (rows.length !== 1 || rows[0].label !== v.title || !same(rows[0].cwd, v.checkout)) throw Error("live pane label and checkout mismatch");
|
|
244
|
+
stoodDown();
|
|
245
|
+
attempted = true;
|
|
246
|
+
call(["pane", "close", address]);
|
|
247
|
+
const after = paneList();
|
|
248
|
+
if (after.some(p => p.pane_id === address)) throw Error("pane survives close");
|
|
249
|
+
// A shared consultant tab belongs to several seats. Only a recorded sole owner reaps its tab.
|
|
250
|
+
if (v.tab && rows[0].tab_id === v.tab && !after.some(p => p.tab_id === v.tab)) {
|
|
251
|
+
call(["tab", "close", v.tab]);
|
|
252
|
+
const listed = call(["tab", "list"]).result.tabs;
|
|
253
|
+
if (!Array.isArray(listed) || listed.some(t => (t.tab_id ?? t.id) === v.tab)) throw Error("owned tab survives close");
|
|
254
|
+
}
|
|
255
|
+
console.log(` CLOSED ${address}: spawn record matched label and cwd; pane absent from pane list`);
|
|
256
|
+
}
|
|
257
|
+
} catch (error) { console.log(` ${attempted ? "CLOSE UNPROVEN" : "LEFT OPEN"} ${address}: ${error.code === "ENOENT" ? "missing spawn or stand-down record" : error.message}`); }
|
|
258
|
+
}
|
|
259
|
+
NODE
|
|
260
|
+
}
|
|
261
|
+
|
|
127
262
|
while :; do
|
|
128
263
|
pending=()
|
|
129
264
|
ready=()
|
|
265
|
+
READY_INDEXES=()
|
|
130
266
|
i=0
|
|
131
267
|
for f in "$@"; do
|
|
132
|
-
if done_file "$i" "$f"; then ready+=("$f"); else pending+=("$f"); fi
|
|
268
|
+
if done_file "$i" "$f"; then ready+=("$f"); READY_INDEXES+=("$i"); else pending+=("$f"); fi
|
|
133
269
|
i=$((i + 1))
|
|
134
270
|
done
|
|
135
271
|
|
|
@@ -152,6 +288,7 @@ while :; do
|
|
|
152
288
|
if [ -s "$f" ]; then echo " PARTIAL $(wc -c <"$f" | tr -d ' ') bytes, no marker yet $f last-line: $(tail -n 1 "$f" | tr -d '\0' | cut -c1-72)"
|
|
153
289
|
else echo " NOT STARTED $f <- check whether that seat is BLOCKED; a stalled seat writes nothing"; fi
|
|
154
290
|
done
|
|
291
|
+
close_completed
|
|
155
292
|
exit 0
|
|
156
293
|
fi
|
|
157
294
|
|
|
@@ -161,17 +298,7 @@ while :; do
|
|
|
161
298
|
echo " RE-HASH BEFORE YOU QUOTE IT. The marker means the file ENDS with '$MARKER', never that its"
|
|
162
299
|
echo " author has stopped: a rewrite-in-place keeps the marker terminal at every instant. If shasum"
|
|
163
300
|
echo " now differs from the value above, you are reading a superseded file."
|
|
164
|
-
|
|
165
|
-
# not. Closing here is safe precisely because the marker — not `done`, not a size — is the trigger,
|
|
166
|
-
# so this can never reap a seat mid-write. Only on the COMPLETE path: on a timeout the seats are
|
|
167
|
-
# still working and closing one would destroy the work being waited for.
|
|
168
|
-
if [ -n "${TKR_CLOSE_PANES:-}" ]; then
|
|
169
|
-
for pane in ${TKR_CLOSE_PANES//,/ }; do
|
|
170
|
-
# `herdr pane close` exits 0 even for a pane that is already gone, so report the body rather
|
|
171
|
-
# than the status — an exit code here would claim a close that may never have happened.
|
|
172
|
-
printf ' CLOSED %s -> %s\n' "$pane" "$(herdr pane close "$pane" 2>&1 | head -c 80)"
|
|
173
|
-
done
|
|
174
|
-
fi
|
|
301
|
+
close_completed
|
|
175
302
|
exit 0
|
|
176
303
|
fi
|
|
177
304
|
|