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 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
@@ -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 commits = existsSync(wt) ? await commitsAheadOf(published, wt) : [];
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
- if (!existsSync(priorWt) && !preservedRef) {
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() : await gitHead(priorWt);
4506
- const priorTaskSubject = await gateCommitSubject(taskBase, priorTaskTip, preservedRef ? repoRoot : priorWt);
4507
- const commitsToCarry = preservedRef ? await commitsAheadOfRef(taskBase, priorTaskTip, repoRoot) : await commitsAheadOf(taskBase, priorWt);
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
- const taskBranch = `${branch}--${t.id}`; // "--": a ref can't nest under the existing integration branch (locked decision 10)
5129
- const priorWt = worktreePath(repoRoot, taskBranch);
5130
- const recreating = existsSync(priorWt);
5131
- // Carry the interrupted worker's snapshot, never incidental files created by later gates.
5132
- const preserved = recreating ? reapedWorktreeRef : undefined;
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 = recreating ? await commitsAheadOf(taskBase, priorWt) : [];
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
- // OBS-28: lingering worktrees starve CLI probes; keepPanes:forever is the debug override.
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 cleanupRunWorktrees(repoRoot, branch, { removeTaskIds: summary.done });
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
- * OBS-28: remove this run's recorded task worktrees; tolerates already-gone paths. 2.8.1 T2: a run keeps no
238
- * integration checkout, and a 2.8.0 run's legacy one is never removed here (it may hold the only view of a merge).
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
- * OBS-28: remove this run's recorded task worktrees; tolerates already-gone paths. 2.8.1 T2: a run keeps no
821
- * integration checkout, and a 2.8.0 run's legacy one is never removed here (it may hold the only view of a merge).
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 id of opts.removeTaskIds)
825
- await removeWorktree(repo, worktreePath(repo, `${branch}--${id}`));
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;
@@ -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 === "exit-cause" && e.data.cause === "unclean");
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
- // A closed engagement ends at its run-end; an open or crashed one at its newest row.
43
- const to = at(events[lastEnd > lastResume ? lastEnd : events.length - 1]);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.8.8",
3
+ "version": "2.8.9",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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, 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.
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
- On each mission's terminal state, after the record commit and operator notification: the orchestrator stops every monitor and background task it started, sweeps the heartbeat/beat files its watchers wrote (a stale beat beside a live one reads as coverage to whoever globs the directory), prints one final stand-down line, and leaves nothing queued in its input box. A finished session with an armed watcher or pre-filled input is a loaded gun.
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 commit the execution record when the repository tracks those records.
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, confirm any PREVIOUS orchestrator has [stood down](#stand-down-mission-end-and-retirement) and close its tab.
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. This ensures that when the orchestrator stands down, the consultant panes persist and their assessments remain available for review and reference.
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
- - **Orchestrator, on terminal state** (green, failed, or parked), after the record commit and operator notification: stop every monitor and background task you started, sweep the heartbeat/beat files your watchers wrote (a stale beat beside a live one reads as coverage to whoever globs the directory), print one final stand-down line, and leave NOTHING queued in your input box. A finished session with an armed watcher or pre-filled input is a loaded gun — a retired v1.40 orchestrator sat idle with "merge … tag, publish" unsent in its input; one stray Enter would have shipped a duplicate release.
119
- - **Supervisor, when a mission completes** (and always before spawning the next orchestrator): verify the orchestrator stood down, then close its tab. Seeming input-box text in a retired pane can be the TUI's dim ghost-text suggestion, not queued input — confirm with an ANSI read (dim escape around the text) or type-one-char-and-read-back before treating it as the loaded gun; close the tab either way. The journal, execution record, OBS ledger, and memory hold the story; pane scrollback is disposable. Never leave a retired agent idle with watchers armed.
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`) and commit the execution record when the repository tracks those records. Then [stand down](#stand-down-mission-end-and-retirement).
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 until the thread is confirmed finished or a successor round supersedes them).
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 and re-resolve the seat by TITLE, then rewrite the
253
- handle file; and treat a stale handle as a dead seat until the read-back of the new handle proves
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, close the seat and create it again the lawful way. `split` is for seats
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 (`git worktree add --detach <dir> tickmarkr/<runId>`), refreshed by removing it
555
- (`git worktree remove <dir>`) and re-adding it; check the branch out attached only with the daemon
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. When the orchestrator stands down, the consultant panes should persist so their assessments remain available for review and reference.
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 /private/tmp/tkr-scoper-v155 -b spec/...`), never switching the main checkout's branch. This prevents race conditions between the specialist's branch operations and the orchestrator's shipping logic.
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** (operator observation, 2026-08-04: *"orch doesn't auto close the panes that he
1542
- created when no more needed"*). Panes accumulate silently — one mission reached **15 panes and 10 tabs**,
1543
- ten of them holding live agent sessions for work that had been on disk and fully consumed for hours.
1544
- Neither seat cleaned up, because neither had been told to.
1545
- - **Whoever spawns a pane owns closing it.** The orchestrator closes its workers; the overseer closes the
1546
- consultants and sweeps it spawned. The overseer sweeps whatever is left at mission end.
1547
- - **Verify the deliverable is ON DISK before closing** — a pane is the only place an unwritten finding
1548
- exists, and an agent that rendered "Done" without writing its artifact did not deliver (the
1549
- trust-disk-over-transcripts rule — cited by NAME, because a renumbered list orphans a "rule N").
1550
- **Non-empty is the FLOOR, not the test: completeness is the artifact's own TERMINAL MARKER.** One
1551
- consult verdict was 10KB on disk while its seat still read `working` — size proved it had started, and
1552
- only the closing `VERDICT:` line proved it had finished. Require the terminal marker a report is
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`. File/journal completion watchers close only recorded seats after the terminal artifact marker; timeout never authorizes closure.
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
- # TKR_CLOSE_PANES="w1:p1,w1:p2" closes those panes when every artifact completes — the answer to panes
19
- # accumulating because nobody was watching for "this seat is finished". It fires ONLY on completion,
20
- # never on timeout. Operator-observed 2026-08-06: five consult panes in one tab left each 14 columns
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
- # A file is DONE when the marker appears in its last few lines AND the file has stopped growing.
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 -5 "$f" 2>/dev/null | grep -qF -- "$MARKER" || return 1
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
- # A seat whose artifact is COMPLETE has nothing left to give: the report is the archive, the pane is
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