tickmarkr 2.5.9 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/adapters/claude-code.js +19 -6
  2. package/dist/adapters/prompt.js +1 -1
  3. package/dist/adapters/types.d.ts +3 -0
  4. package/dist/cli/commands/init.js +1 -1
  5. package/dist/cli/commands/report.js +11 -1
  6. package/dist/cli/commands/verify.js +2 -0
  7. package/dist/compile/native.js +39 -4
  8. package/dist/drivers/orca.d.ts +1 -1
  9. package/dist/drivers/orca.js +21 -4
  10. package/dist/gates/baseline.d.ts +2 -0
  11. package/dist/gates/baseline.js +9 -1
  12. package/dist/gates/cache.d.ts +8 -0
  13. package/dist/gates/cache.js +26 -13
  14. package/dist/gates/review.d.ts +2 -3
  15. package/dist/gates/review.js +21 -24
  16. package/dist/gates/run-gates.js +11 -1
  17. package/dist/gates/test-manifest.d.ts +5 -0
  18. package/dist/gates/test-manifest.js +87 -7
  19. package/dist/gates/test-reporter.js +4 -0
  20. package/dist/graph/graph.d.ts +1 -1
  21. package/dist/graph/graph.js +6 -1
  22. package/dist/graph/schema.d.ts +2 -0
  23. package/dist/graph/schema.js +2 -0
  24. package/dist/route/preference.d.ts +1 -1
  25. package/dist/route/preference.js +10 -39
  26. package/dist/run/daemon.d.ts +7 -1
  27. package/dist/run/daemon.js +160 -19
  28. package/dist/run/git.d.ts +10 -0
  29. package/dist/run/git.js +49 -1
  30. package/dist/run/journal.d.ts +1 -1
  31. package/dist/run/journal.js +42 -6
  32. package/dist/run/merge.d.ts +1 -1
  33. package/dist/run/merge.js +30 -6
  34. package/dist/tui/cockpit/live-runtime.d.ts +4 -0
  35. package/dist/tui/cockpit/live-runtime.js +37 -3
  36. package/dist/tui/cockpit/live-store.d.ts +2 -0
  37. package/package.json +1 -1
  38. package/schema/rungraph.schema.json +7 -0
  39. package/skills/tickmarkr-overseer/SKILL.md +33 -8
package/dist/run/git.d.ts CHANGED
@@ -190,6 +190,16 @@ export declare function resolveIntegrationBranch(_repo: string, branch: string):
190
190
  */
191
191
  export declare function preserveWorktree(cwd: string): Promise<string | undefined>;
192
192
  export declare function createWorktree(repo: string, branch: string, baseRef: string): Promise<string>;
193
+ export interface DependencyLink {
194
+ /** Package link, relative to node_modules (including @scope/name). */
195
+ link: string;
196
+ target: string;
197
+ classification: "worktree" | "node_modules" | "outside";
198
+ }
199
+ /** Inventory only package-level links; never descend into installed packages. */
200
+ export declare function inventoryDependencyLinks(worktree: string): DependencyLink[];
201
+ /** Shared pre-command refusal; unreadable inventories cannot establish isolation either. */
202
+ export declare function dependencyLinkRefusal(worktree: string): string | undefined;
193
203
  export declare function linkNodeModules(repo: string, dir: string, { force }?: {
194
204
  force?: boolean | undefined;
195
205
  }): boolean;
package/dist/run/git.js CHANGED
@@ -4,7 +4,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
4
4
  import { execFileSync, spawn } from "node:child_process";
5
5
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
6
6
  import { availableParallelism, homedir, tmpdir } from "node:os";
7
- import { join, resolve } from "node:path";
7
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
8
8
  import { StringDecoder } from "node:string_decoder";
9
9
  import { shq } from "../adapters/types.js";
10
10
  import { tickmarkrDir } from "../graph/graph.js";
@@ -624,6 +624,54 @@ function excludeNodeModules(dir) {
624
624
  }
625
625
  catch { /* not a git checkout (bare tmpdir in tests) — never fail provisioning over the exclude */ }
626
626
  }
627
+ /** Inventory only package-level links; never descend into installed packages. */
628
+ export function inventoryDependencyLinks(worktree) {
629
+ const dependencyPath = join(worktree, "node_modules");
630
+ try {
631
+ lstatSync(dependencyPath);
632
+ }
633
+ catch (error) {
634
+ if (error.code === "ENOENT")
635
+ return [];
636
+ throw error;
637
+ }
638
+ const root = realpathSync(worktree);
639
+ const dependencies = realpathSync(dependencyPath);
640
+ const inside = (parent, target) => {
641
+ const rel = relative(parent, target);
642
+ return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
643
+ };
644
+ const links = [];
645
+ const inspect = (link) => {
646
+ const path = join(dependencies, link);
647
+ if (!lstatSync(path).isSymbolicLink())
648
+ return;
649
+ const target = realpathSync(path);
650
+ links.push({ link, target, classification: inside(root, target) ? "worktree"
651
+ : inside(dependencies, target) ? "node_modules" : "outside" });
652
+ };
653
+ for (const entry of readdirSync(dependencies).sort()) {
654
+ inspect(entry);
655
+ if (entry.startsWith("@") && statSync(join(dependencies, entry)).isDirectory()) {
656
+ for (const child of readdirSync(join(dependencies, entry)).sort())
657
+ inspect(join(entry, child));
658
+ }
659
+ }
660
+ return links;
661
+ }
662
+ /** Shared pre-command refusal; unreadable inventories cannot establish isolation either. */
663
+ export function dependencyLinkRefusal(worktree) {
664
+ try {
665
+ const outside = inventoryDependencyLinks(worktree).filter(link => link.classification === "outside");
666
+ if (!outside.length)
667
+ return undefined;
668
+ return "infra: OBS-1118 refusing verification: dependency links resolve outside the worktree and real node_modules:\n"
669
+ + outside.map(({ link, target }) => `node_modules/${link} -> ${target}`).join("\n");
670
+ }
671
+ catch (error) {
672
+ return `infra: OBS-1118 refusing verification: cannot inventory dependency links in ${worktree}: ${String(error)}`;
673
+ }
674
+ }
627
675
  export function linkNodeModules(repo, dir, { force = false } = {}) {
628
676
  const src = join(repo, "node_modules");
629
677
  const dest = join(dir, "node_modules");
@@ -437,7 +437,7 @@ export declare class Journal {
437
437
  assignment?: Assignment;
438
438
  } | null;
439
439
  replayResumeState(): Map<string, ResumeState>;
440
- replaySatisfiedGates(): Map<string, GateName>;
440
+ replaySatisfiedGates(currentSubjects?: ReadonlyMap<string, string>): Map<string, GateName>;
441
441
  replayCurrentAttemptGateResults(): Map<string, CurrentAttemptGateReplay>;
442
442
  replayExcludedChannels(): Set<string>;
443
443
  telemetry(row: TelemetryRow): void;
@@ -1734,26 +1734,62 @@ export class Journal {
1734
1734
  }
1735
1735
  return m;
1736
1736
  }
1737
- // OBS-130/OBS-571: gate satisfaction is authority, not an inferred daemon state. Only an explicit
1738
- // task-approved event with the typed release marker and a known gate enters this fold. Authority is
1739
- // scoped to that approval: any later approval supersedes it, and the worktree-recreation row written
1740
- // before the approved gate suffix runs consumes it at enactment. A daemon-made gate-satisfied event,
1741
- // a prior pass/fail result, malformed data, or another task's approval is inert.
1742
- replaySatisfiedGates() {
1737
+ // OBS-130/OBS-571: only an explicit approval grants gate authority; recreation consumes
1738
+ // its immediate enactment. OBS-1133: retain a review waiver's subject separately so a later
1739
+ // recheck may carry it. Tool waivers never survive recheck. The daemon supplies the recreated
1740
+ // subject before using a carried waiver; absent that, replay can only compare journaled subjects.
1741
+ replaySatisfiedGates(currentSubjects) {
1743
1742
  const satisfied = new Map();
1743
+ const reviewSubjects = new Map();
1744
+ const subjects = new Map();
1745
+ const reviewWaivers = new Map();
1744
1746
  for (const e of this.read()) {
1745
1747
  if (!e.taskId)
1746
1748
  continue;
1749
+ if (e.event === "task-dispatch") {
1750
+ reviewSubjects.delete(e.taskId);
1751
+ subjects.delete(e.taskId);
1752
+ reviewWaivers.delete(e.taskId);
1753
+ satisfied.delete(e.taskId);
1754
+ }
1755
+ if (e.event === "gate-result") {
1756
+ const commit = typeof e.data.commit === "string" && e.data.commit ? e.data.commit : undefined;
1757
+ if (commit)
1758
+ subjects.set(e.taskId, commit);
1759
+ else
1760
+ subjects.delete(e.taskId);
1761
+ if (reviewWaivers.has(e.taskId) && reviewWaivers.get(e.taskId) !== commit) {
1762
+ reviewWaivers.delete(e.taskId);
1763
+ }
1764
+ if (e.data.gate === "review") {
1765
+ if (commit && e.data.pass === false)
1766
+ reviewSubjects.set(e.taskId, commit);
1767
+ else
1768
+ reviewSubjects.delete(e.taskId);
1769
+ }
1770
+ }
1747
1771
  if (e.event === "worktree-recreation") {
1748
1772
  satisfied.delete(e.taskId);
1749
1773
  continue;
1750
1774
  }
1751
1775
  if (e.event === "task-approved") {
1752
1776
  satisfied.delete(e.taskId);
1777
+ if (e.data.release === RECHECK_RELEASE) {
1778
+ const waivedSubject = reviewWaivers.get(e.taskId);
1779
+ const subject = currentSubjects?.get(e.taskId) ?? subjects.get(e.taskId);
1780
+ if (waivedSubject && waivedSubject === subject)
1781
+ satisfied.set(e.taskId, "review");
1782
+ continue;
1783
+ }
1784
+ reviewWaivers.delete(e.taskId);
1753
1785
  if (e.data.release === GATE_SATISFIED_RELEASE
1754
1786
  && typeof e.data.gate === "string"
1755
1787
  && GATE_NAMES.includes(e.data.gate)) {
1756
1788
  satisfied.set(e.taskId, e.data.gate);
1789
+ const subject = reviewSubjects.get(e.taskId);
1790
+ if (e.data.gate === "review" && subject && subject === subjects.get(e.taskId)) {
1791
+ reviewWaivers.set(e.taskId, subject);
1792
+ }
1757
1793
  }
1758
1794
  }
1759
1795
  }
@@ -5,7 +5,7 @@ export interface TipVerifyResult {
5
5
  evidenceReceipt?: GateEvidenceReceipt;
6
6
  evidenceReceipts?: GateEvidenceReceipt[];
7
7
  evidenceAbsence?: "provenance-refused" | "not-started" | "historical-cache";
8
- /** Absolute directory against which the original receipt artifact paths resolve. */
8
+ /** Directory the evidence setup returned; receipt artifact paths resolve against it. */
9
9
  originRunRoot?: string;
10
10
  nonce?: string;
11
11
  stdoutPath?: string;
package/dist/run/merge.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
- import { join, resolve } from "node:path";
2
+ import { join } from "node:path";
3
3
  import { shq } from "../adapters/types.js";
4
4
  import { beginGateEvidence, ceilingKillResult, classifyFreshRunnerOutput, classifyRunnerOutput, fileCountDeficit, waitForCalmWindow, effectiveCeilingMs, fingerprint, freshFailures, } from "../gates/baseline.js";
5
5
  import { evaluateManifestedTest, isVitestTestCommand } from "../gates/test-manifest.js";
6
6
  import { computeVerificationIdentity, formatReusedDetails, getVerdictStore, getWorktreeTree, resolveStateDir, isInfraResult, } from "../gates/cache.js";
7
7
  import { tickmarkrDir } from "../graph/graph.js";
8
- import { describeCapacity, gitHead, linkNodeModules, resolveIntegrationBranch, resolvedCapacity, sameCapacity, sh, shGit, shGitOk, WORKTREES_DIR } from "./git.js";
8
+ import { dependencyLinkRefusal, describeCapacity, gitHead, linkNodeModules, resolveIntegrationBranch, resolvedCapacity, sameCapacity, sh, shGit, shGitOk, WORKTREES_DIR } from "./git.js";
9
9
  import { executionSignal } from "./execution-budget.js";
10
10
  // Diagnostic convenience logs are observational, just like receipt persistence.
11
11
  function writeTipLog(path, text) {
@@ -128,6 +128,16 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
128
128
  if (!commands.test && commands.tipTest) {
129
129
  gatesToRun.push(["test", commands.tipTest]);
130
130
  }
131
+ const dependencyRefusal = dependencyLinkRefusal(intWt);
132
+ if (dependencyRefusal) {
133
+ for (const [gate, cmd] of gatesToRun.length ? gatesToRun : [["build", ""]]) {
134
+ const artifact = join(runDir, `tip-verify-${gate}.log`);
135
+ writeTipLog(artifact, dependencyRefusal + "\n");
136
+ results.push({ gate, cmd, pass: false, exitCode: 1, fingerprints: [],
137
+ cause: "infra", details: dependencyRefusal, evidenceAbsence: "not-started", artifact });
138
+ }
139
+ return results;
140
+ }
131
141
  if (journalFound && hasRunEvidenceOrMalformed && runStartCommands === undefined) {
132
142
  for (const [gate, cmd] of gatesToRun) {
133
143
  const artifact = join(runDir, `tip-verify-${gate}.log`);
@@ -151,6 +161,8 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
151
161
  // Publish only after the entire verification completes. The daemon kills a cancelled
152
162
  // verifier process, so completed early gates must remain in memory until then.
153
163
  const pending = new Map();
164
+ // D-267 (1): each fresh receipt's origin is the root its own beginGateEvidence returned.
165
+ const evidenceRoots = new Map();
154
166
  for (const [gate, cmd] of gatesToRun) {
155
167
  if (runStartCommands !== undefined) {
156
168
  const expectedCmd = gate === "test"
@@ -226,13 +238,20 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
226
238
  // a detected vitest test command never reaches the stdout-count/file-count path below (this
227
239
  // gate's own manifest is always the FULL suite; verify has no selected screen to hold). A
228
240
  // scripted test command that is not the detected runner falls through unchanged.
241
+ const evidenceSetup = { artifactDir: runDir, runId: runDir, ...evidenceOptions };
229
242
  if (gate === "test" && isVitestTestCommand(cmd, intWt)) {
243
+ // D-267 (1): stamp the root the evidence setup returned, then give that exact root to every
244
+ // manifested invocation. Do not reconstruct it from the caller's options: the receipt's
245
+ // artifact paths are relative to the root returned by beginGateEvidence.
246
+ const evidence = beginGateEvidence(intWt, gate, cmd, evidenceSetup);
247
+ const manifestedEvidence = { ...evidenceSetup, artifactDir: evidence.root };
248
+ evidenceRoots.set(gate, evidence.root);
230
249
  const outcome = await evaluateManifestedTest(cmd, intWt, {
231
250
  baselineDurations: entry?.fileDurations,
232
251
  longestFile: entry?.longestFile,
233
252
  overallCeilingMs: effectiveCeilingMs(entry),
234
253
  artifactDir: runDir,
235
- evidence: { artifactDir: runDir, runId: runDir, ...evidenceOptions },
254
+ evidence: manifestedEvidence,
236
255
  });
237
256
  const artifact = join(runDir, `tip-verify-${gate}.log`);
238
257
  if (!outcome.pass)
@@ -264,7 +283,8 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
264
283
  const ceilingMs = effectiveCeilingMs(entry);
265
284
  const evidenceReceipts = [];
266
285
  const execute = async () => {
267
- const evidence = beginGateEvidence(intWt, gate, cmd, { artifactDir: runDir, runId: runDir, ...evidenceOptions });
286
+ const evidence = beginGateEvidence(intWt, gate, cmd, evidenceSetup);
287
+ evidenceRoots.set(gate, evidence.root);
268
288
  try {
269
289
  const result = await sh(cmd, intWt, ceilingMs, { env: evidenceOptions.env, onReceipt: receipt => evidence.observe(receipt) });
270
290
  evidenceReceipts.push(...evidence.history, evidence.finish(result.stdout, result.stderr));
@@ -372,8 +392,12 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
372
392
  // spawns in the daemon child, and a command that changed the tree invalidates reuse.
373
393
  const completedTree = pending.size ? await getWorktreeTree(intWt) : undefined;
374
394
  for (const result of results) {
375
- if (!result.reused && result.evidenceReceipt)
376
- result.originRunRoot = resolve(evidenceOptions.artifactDir ?? runDir);
395
+ if (!result.reused && result.evidenceReceipt) {
396
+ const evidenceRoot = evidenceRoots.get(result.gate);
397
+ if (evidenceRoot === undefined)
398
+ throw new Error(`tip verify receipt for ${result.gate} has no evidence root from its evidence setup`);
399
+ result.originRunRoot = evidenceRoot;
400
+ }
377
401
  const id = pending.get(result.gate);
378
402
  if (id && id.tree === completedTree && !isInfraResult(result)) {
379
403
  store.set(id, { ...result, meta: {
@@ -22,6 +22,10 @@ export interface ShellDelivery {
22
22
  diagnostics: LiveStore["diagnostics"];
23
23
  stage: (edits: readonly string[]) => void;
24
24
  geometry: () => ShellCommit | undefined;
25
+ /** Application render commits since mount (OBS-1132: an idle board adds none). */
26
+ frames: () => number;
27
+ /** Model derivations (the App body) since mount. */
28
+ derivations: () => number;
25
29
  }
26
30
  export interface ConsolidatedOptions {
27
31
  input: NodeJS.ReadStream;
@@ -132,10 +132,42 @@ export async function runConsolidatedCockpit(options) {
132
132
  let committedTargets = [];
133
133
  let revision = 0;
134
134
  const listeners = new Set();
135
- const publish = () => { revision++; for (const listener of listeners)
135
+ // Every publish resyncs the key so a view switch never earns a second frame on the next tick.
136
+ const publish = () => { revision++; observed = observationKey(source.snapshot()); for (const listener of listeners)
136
137
  listener(); };
137
- const unsubscribe = source.subscribe(publish);
138
+ let observed = "";
138
139
  const geometry = () => planShell(output.columns ?? 80, output.rows ?? 24, state.shortcutColumns);
140
+ // OBS-1132: the store publishes on every observation tick; the board only re-derives and
141
+ // re-renders when what it would draw changed. Lock and supervision observation continue
142
+ // regardless. Excluded on purpose: sequence, observedAt, metrics and beat ages, which move
143
+ // every tick without changing a visible cell.
144
+ // ponytail: mirrors board.ts `ago` buckets and its ≥118-cell "wide" header — the only clock reader,
145
+ // and only the Run view draws the board, so Home and Evidence never invalidate on the age.
146
+ // The lock is compared by what it says (owner, state, liveness), never by its file stamp: the
147
+ // daemon heartbeat re-touches graph.lock every 10 s without changing a visible cell. Its clock-derived
148
+ // `expired` flag is not drawn by any view either, so a lock ageing past STALE_MS earns no frame.
149
+ const visibleAge = (ms) => ms < 6e4 ? "just now" : ms < 3.6e6 ? `${Math.round(ms / 6e4)}m` : `${Math.round(ms / 3.6e6)}h`;
150
+ const observationKey = (snap) => {
151
+ const last = snap.operator.lastEventAt;
152
+ const clock = state.view === "run" && geometry().bodyColumns >= 118 && last ? visibleAge((options.now ?? Date.now)() - Date.parse(last)) : "";
153
+ return JSON.stringify([
154
+ snap.freshness, snap.actionsEnabled, snap.errors, snap.viewport, snap.inputSequence, clock,
155
+ snap.journal.generation, snap.journal.offset, snap.journal.status, snap.journal.malformedCount, snap.journal.backlogBytes, snap.journal.pending, snap.journal.error,
156
+ snap.graph.identity, snap.graph.status, snap.config.identity, snap.config.status, snap.cache.identity, snap.cache.status,
157
+ snap.lock.value, snap.lock.status, snap.lock.state, snap.lock.alive,
158
+ // D-383: supervision tier state is not drawn by any view; an ARMED→STALE transition earns no
159
+ // frame. Its UNREADABLE case already surfaces through snap.errors above.
160
+ ]);
161
+ };
162
+ observed = observationKey(source.snapshot());
163
+ const unsubscribe = source.subscribe(() => {
164
+ const next = observationKey(source.snapshot());
165
+ if (next !== observed) {
166
+ observed = next;
167
+ publish();
168
+ }
169
+ });
170
+ let frames = 0, derivations = 0;
139
171
  const openEvidence = (identity) => {
140
172
  evidenceNavigation++;
141
173
  selectedEvidence = identity;
@@ -484,7 +516,7 @@ export async function runConsolidatedCockpit(options) {
484
516
  stop(e);
485
517
  return false;
486
518
  }
487
- }, diagnostics: source.diagnostics, stage: edits => { state = { ...state, staged: [...edits] }; publish(); } };
519
+ }, diagnostics: source.diagnostics, stage: edits => { state = { ...state, staged: [...edits] }; publish(); }, frames: () => frames, derivations: () => derivations };
488
520
  options.onDelivery?.(delivery);
489
521
  options.onShellDelivery?.(delivery);
490
522
  resize = () => {
@@ -530,6 +562,7 @@ export async function runConsolidatedCockpit(options) {
530
562
  function App() {
531
563
  useSyncExternalStore(listener => { listeners.add(listener); return () => { listeners.delete(listener); }; }, () => revision);
532
564
  const stdinContext = useStdin();
565
+ useLayoutEffect(() => { frames++; });
533
566
  useLayoutEffect(() => {
534
567
  const emitter = stdinContext.internal_eventEmitter;
535
568
  leafInput = emitter;
@@ -565,6 +598,7 @@ export async function runConsolidatedCockpit(options) {
565
598
  stop(error);
566
599
  }
567
600
  });
601
+ derivations++;
568
602
  const snap = source.snapshot();
569
603
  const p = geometry();
570
604
  const nextDecisionsKey = `${snap.journal.generation}:${snap.journal.offset}:${snap.graph.identity}`;
@@ -147,6 +147,7 @@ export declare function createLiveStore(options: LiveStoreOptions): {
147
147
  kind: "fixture";
148
148
  paths: string[];
149
149
  })[] | undefined;
150
+ outOfScope?: string[] | undefined;
150
151
  routingHints?: {
151
152
  pin?: {
152
153
  via: string;
@@ -248,6 +249,7 @@ export declare function createLiveStore(options: LiveStoreOptions): {
248
249
  kind: "fixture";
249
250
  paths: string[];
250
251
  })[] | undefined;
252
+ outOfScope?: string[] | undefined;
251
253
  routingHints?: {
252
254
  pin?: {
253
255
  via: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.5.9",
3
+ "version": "2.6.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -255,6 +255,13 @@
255
255
  ]
256
256
  }
257
257
  },
258
+ "outOfScope": {
259
+ "type": "array",
260
+ "items": {
261
+ "type": "string",
262
+ "minLength": 1
263
+ }
264
+ },
258
265
  "gates": {
259
266
  "default": [
260
267
  "build",
@@ -62,7 +62,7 @@ through brief lineage. **An executor choice nobody made is still an executor cho
62
62
  - **On herdr (`HERDR_ENV=1`)**: Every bundled `watch-*.sh` arm, including `watch-artifacts.sh`, sets `TKR_ARMING_SEAT=<seat>` and writes its own pid under
63
63
  `<state-dir>/overseer/pids/<arming-seat>-<script>-<pid>.pid`. A seat retires only watchers it armed,
64
64
  by reading those files and killing the exact recorded pids; it never uses `pkill -f`, `pgrep -f`, or
65
- any argv/path pattern. A journal path is shared by partner tiers and therefore cannot prove ownership.
65
+ any argv/path pattern. The pre-arm beat probe lists unowned writers and is reconciled in the beat section; retiring a watcher this seat armed stays kill-by-recorded-pid and never that probe. A journal path is shared by partner tiers and therefore cannot prove ownership.
66
66
  Verify each executing pid in two process-table reads before acting, kill-by-pid, arm the replacement,
67
67
  then verify its new pid in two process-table reads. A stand-down order inventories both sets: the
68
68
  ordering seat's recorded pids to retire, and the partner's watchers armed on the ordering seat that
@@ -72,7 +72,7 @@ through brief lineage. **An executor choice nobody made is still an executor cho
72
72
  and nothing had been watching either file.
73
73
  - **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: File and journal watchers record
74
74
  owner pid and arm id beside the evidence files; a seat retires only watchers it armed, by those
75
- recorded pids. It never uses `pkill -f`, `pgrep -f`, or any argv/path pattern. Verify each executing
75
+ recorded pids. It never uses `pkill -f`, `pgrep -f`, or any argv/path pattern. The pre-arm beat probe lists unowned writers and is reconciled in the beat section; retiring a watcher this seat armed stays kill-by-recorded-pid and never that probe. Verify each executing
76
76
  pid in two process-table reads before acting. A stand-down inventories this seat's recorded pids
77
77
  and the partner's watchers that must survive it.
78
78
  **An adopted seat ANNOUNCES itself, in the same act as re-arming:** tell the adopted orchestrator the
@@ -806,6 +806,12 @@ they are left implicit:
806
806
  report INSIDE the worktree and to commit nothing — the overseer commits from the main checkout — or give the work to a
807
807
  claude seat, or to a throwaway CLONE (a real `.git` directory). A brief that tells a codex-in-worktree seat to commit
808
808
  buys a stall, not a commit.
809
+
810
+ **Every one-shot `codex exec` from an agent shell closes stdin (D-307-pre).** This skill has no
811
+ `codex exec` recipe — cross-vendor seats are spawned interactive — and the seat's ad-hoc one-shot
812
+ did not close stdin on that command. The agent shell left stdin open, and `codex exec` then
813
+ blocks on reading additional input from stdin (`Reading additional input from stdin…`) until the
814
+ pipe ends (42 minutes, measured). Run it as `codex exec … < /dev/null`.
809
815
  4. **Gate every exec lane with the shipped battery, not hand-rolled greps.**
810
816
  `tickmarkr verify --base <ref> --criteria <file>` is the standalone form of the engine's own gates —
811
817
  build/test/lint diffed against a recorded baseline, evidence, scope, plus the semantic judges — one
@@ -826,8 +832,13 @@ they are left implicit:
826
832
  - **Verified send protocol**:
827
833
  - **On herdr (`HERDR_ENV=1`)**: `herdr agent send` writes WITHOUT Enter, and `pane run`'s Enter can be swallowed
828
834
  by bracketed-paste on long payloads. Robust sequence: read the pane (bare prompt required) → send-text →
829
- sleep 2–3s → send-keys Enter → read back (input empty / agent `working`). Never report "briefed" without
830
- the read-back. Long content goes in a brief file, never pane text. `scripts/seat-send.sh` encodes
835
+ sleep 2–3s → read back. Send `send-keys Enter` only when that read-back shows the staged text on the
836
+ composer and no permission prompt or numbered choice holds focus — an Enter sent onto an active prompt
837
+ approves it or picks a choice instead of submitting the brief (OBS-1119). When a prompt or choice holds
838
+ focus instead, resolve it first, then re-read before retrying. After a submitted Enter, read back once
839
+ more and confirm the composer is empty or the agent shows `working` — the pre-Enter read only proved
840
+ Enter was safe to send, not that the brief was submitted. Never report "briefed" without
841
+ both read-backs. Long content goes in a brief file, never pane text. `scripts/seat-send.sh` encodes
831
842
  this whole path — size guard, atomic prompt, prompt-line read-back, optional interrupt — and never
832
843
  auto-resends. Each adapter declares its prompt glyph beside its input-box matchers; `seat-send.sh` reads
833
844
  that declaration rather than assuming Claude's `❯`.
@@ -1009,7 +1020,7 @@ the repo root as its own `run_in_background` Bash call:
1009
1020
 
1010
1021
  ```bash
1011
1022
  cd <repo> && tickmarkr beat overseer --seat <overseer-agent-or-pane> --loop
1012
- tickmarkr beat overseer --seat <overseer-agent-or-pane> --stand-down # deliberately hand off; --loop exits
1023
+ cd <repo> && tickmarkr beat overseer --seat <overseer-agent-or-pane> --stand-down # deliberately hand off; --loop exits
1013
1024
  ```
1014
1025
 
1015
1026
  **The legacy wrapper loop, `while :; do tickmarkr beat overseer --seat <pane>; sleep 10; done`, is the
@@ -1043,9 +1054,10 @@ had three beat writers, one owned by an unrelated session. The shipped `--loop`
1043
1054
  but its process ownership still needs a live check. So:
1044
1055
 
1045
1056
  - **Split the liveness reads.** A tier's liveness is read from beat freshness in the repository status
1046
- path; the loop's liveness is read from the live process payload (`tickmarkr beat <tier> --seat <seat>
1047
- --loop` in this repo). Neither liveness claim is read from a recorded pid: a pid
1048
- recorded earlier can be stale, reused, or detached from the beat now holding the tier green.
1057
+ path. The loop's liveness is read from the live process payload of both arm forms: the loop arm
1058
+ (`tickmarkr beat <tier> --seat <seat> --loop` in this repo) and the legacy wrapper
1059
+ (`while :; do tickmarkr beat <tier> --seat <seat>; sleep 10; done`). Neither liveness claim is read from a recorded pid:
1060
+ a pid recorded earlier can be stale, reused, or detached from the beat now holding the tier green.
1049
1061
  - **At every adopt, clear, or re-brief, sweep for pre-existing beat writers on YOUR tier before
1050
1062
  arming one** (`pgrep -f "tickmarkr beat <tier>"`, **read twice and intersected** — this exact probe
1051
1063
  returned its own shell as pid 14680 on 2026-08-31). **The PRIMARY target is the legacy
@@ -1053,6 +1065,11 @@ but its process ownership still needs a live check. So:
1053
1065
  a `--loop` exits by itself once your new arm replaces its own, but the wrapper's bare tick beats
1054
1066
  whatever arm is on disk, so it survives your re-arm and never exits on its own. Trace each survivor
1055
1067
  to its parent session. Stop an unowned writer only — never its parent — then verify the parent survived.
1068
+ **Reconcile this probe with the recorded-pid ownership rule.** That rule retires watchers this seat
1069
+ armed by the exact recorded pid, and it never uses `pkill -f`, `pgrep -f`, or an argv pattern. This
1070
+ probe is the listing of unowned legacy writers that have no pid file; the pattern stays
1071
+ `pgrep -f "tickmarkr beat <tier>"` with no `--loop` qualifier. A stop is kill-by-pid of an unowned
1072
+ survivor after the two reads. The probe does not retire a recorded pid.
1056
1073
  - **`ARMED (<seat>)` is an attributable claim, not proof that the named seat is still alive.** Before
1057
1074
  trusting it, ask whose session owns the beater; a still-running `--loop` can keep naming a departed
1058
1075
  seat (rule 11's outliving-its-trigger failure, in beat form).
@@ -1547,6 +1564,14 @@ twice.** They are mission-independent on purpose: nothing here names a task, a l
1547
1564
  the same root read the other way: a DETACHED loop outlives its seat and holds a tier `ARMED` with
1548
1565
  nobody home (OBS-583). Neither direction may be assumed; the lifetime is a property of how the watcher
1549
1566
  was launched, and it belongs in writing next to every claim that one is armed.
1567
+ **A seat's background tasks are bound to the seat's life (D-302 add.3).** An ad-hoc background task —
1568
+ an analysis heredoc, a one-shot shell — dies with the seat that launched it. Record its pid and kill
1569
+ that pid at stand-down, the same recorded-pid act as any other task this seat owns. Leave it attached
1570
+ to the seat so it is not reparented to pid 1 and left running after the seat is gone. A watcher this
1571
+ skill explicitly labels detached, with its heartbeat file, is the process that may outlive the seat.
1572
+ **Forbid the home-wide recursive glob by name:** `glob.glob('~/**/.tickmarkr/runs/*/journal.jsonl', recursive=True)`.
1573
+ That heredoc walked the whole home (`~/**`, every worktree, `node_modules`, Library) and ran orphaned
1574
+ for 30 hours at 100% CPU after its seat was gone. No seat glob starts at `~` or `$HOME`.
1550
1575
  **And EVERY process-table probe has an idiom that defeats it, so the rule above needs the one test
1551
1576
  that survives all of them. Lead with this; it is not the last resort, it is the first move:**
1552
1577