tickmarkr 2.7.8 → 2.8.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.
@@ -5,7 +5,7 @@ export declare function readCodexModelsCache(path?: string): {
5
5
  retirements?: Record<string, ModelRetirement | null>;
6
6
  hidden?: string[];
7
7
  };
8
- export declare const CODEX_GIT_GRANT = "-c \"sandbox_workspace_write.writable_roots=[$(tkr_common=$(git rev-parse --path-format=absolute --git-common-dir); tkr_gitdir=$(git rev-parse --absolute-git-dir); if [ \"$tkr_gitdir\" != \"$tkr_common\" ]; then printf '\"%s\",' \"$tkr_gitdir\"; fi; printf '\"%s/objects\",\"%s/refs\",\"%s/logs\"' \"$tkr_common\" \"$tkr_common\" \"$tkr_common\")]\"";
8
+ export declare const CODEX_GIT_GRANT = "-c \"sandbox_workspace_write.writable_roots=[$(tkr_common=$(git rev-parse --path-format=absolute --git-common-dir && echo /) && tkr_gitdir=$(git rev-parse --absolute-git-dir && echo /) && tkr_common=${tkr_common%?/} && tkr_gitdir=${tkr_gitdir%?/} && { tkr_head=$(git symbolic-ref -q HEAD); [ $? -le 1 ]; } && case $tkr_head in (refs/heads/*) tkr_leaf=${tkr_head%/*};; (*) tkr_leaf=;; esac && [ \"$(printf %s \"$tkr_common$tkr_gitdir$tkr_leaf\" | LC_ALL=C tr -d '\\0-\\37\\177\"\\\\')\" = \"$tkr_common$tkr_gitdir$tkr_leaf\" ] && { [ \"$tkr_gitdir\" = \"$tkr_common\" ] || printf '\"%s\",' \"$tkr_gitdir\"; } && printf '\"%s/objects\"' \"$tkr_common\" && { [ -z \"$tkr_leaf\" ] || printf ',\"%s/%s\",\"%s/logs/%s\"' \"$tkr_common\" \"$tkr_leaf\" \"$tkr_common\" \"$tkr_leaf\"; })]\"";
9
9
  export declare const CODEX_TRUST_DIALOG: TrustDialog;
10
10
  export declare const CODEX_INPUT_BOX: import("./types.js").InputBox;
11
11
  export declare function seedCodexTrust(repoRoot: string, configPath?: string): TrustVerdict;
@@ -106,13 +106,25 @@ function codexRetirement(upgrade) {
106
106
  // worktree cwd, for Codex WORKERS only: v2.6.7 T4 (D-1046) — invoke() (the headless worker form, including
107
107
  // the interactive size fallback) and interactiveCommand carry it; headlessCommand, which judge, review,
108
108
  // consult and the model probe launch, carries no writable_roots at all (those seats never commit).
109
- // v2.6.6 T9 (K, D-912): the grant is exactly what a linked-worktree branch commit writes — the worktree's
110
- // own gitdir (only when it differs from the common dir), common/objects, common/refs, common/logs. The
111
- // shipped [common] grant (and D-910's [common, gitdir]) let a sandboxed seat write <common>/hooks and
112
- // <common>/config, which unsandboxed git later runs. No common-root fallback: an ordinary checkout gets
113
- // no gitdir root and its commit fails closed. packed-refs is not granted (packed-ref ops fail closed).
109
+ // v2.6.6 T9 (K, D-912) → v2.8.0 T1 (rows 17/18, D-910): the grant is a LEAF of what a linked-worktree branch
110
+ // commit writes, never the shared hooks/config (the shipped [common] and D-910's [common, gitdir] let a seat
111
+ // write <common>/hooks and <common>/config, which unsandboxed git later runs) and never whole common/refs and
112
+ // common/logs (which let a seat move main, any branch or tag, or the stash). The closed root table, in order:
113
+ // - the worktree's own gitdir, only when it differs from the common dir (an ordinary checkout gets none);
114
+ // - <common>/objects;
115
+ // - only when HEAD is a symbolic ref refs/heads/<B>: <common>/refs/heads/<D> and <common>/logs/refs/heads/<D>,
116
+ // <D> being <B> without its last slash-separated segment (refs/heads itself when <B> holds no slash);
117
+ // a detached HEAD gets no refs or reflog root.
118
+ // A directory outside any repository, any failed git read, or any root holding a character of the closed
119
+ // forbidden set (U+0000 to U+001F, U+007F DEL, the double quote, the backslash) yields the EMPTY array `[]`:
120
+ // no git write root, so a commit fails closed — never a partial array, an escaped string or codex's
121
+ // literal-string fallback. Every emitted root is therefore a TOML basic string that parses back to the exact
122
+ // path (git's own trailing newline is stripped through a sentinel, never a path's). No common-root fallback;
123
+ // packed-refs is not granted (packed-ref ops fail closed); stash, tags, notes and branches outside the leaf
124
+ // are denied. Declared: sibling refs in the same leaf (other task branches, the run's integration branch
125
+ // under the default prefix) stay writable until 2.8.1's integration identity.
114
126
  // doctor's probe (codex-commit-check.ts) executes these exact bytes; it never rebuilds the grant.
115
- export const CODEX_GIT_GRANT = `-c "sandbox_workspace_write.writable_roots=[$(tkr_common=$(git rev-parse --path-format=absolute --git-common-dir); tkr_gitdir=$(git rev-parse --absolute-git-dir); if [ "$tkr_gitdir" != "$tkr_common" ]; then printf '"%s",' "$tkr_gitdir"; fi; printf '"%s/objects","%s/refs","%s/logs"' "$tkr_common" "$tkr_common" "$tkr_common")]"`;
127
+ export const CODEX_GIT_GRANT = `-c "sandbox_workspace_write.writable_roots=[$(tkr_common=$(git rev-parse --path-format=absolute --git-common-dir && echo /) && tkr_gitdir=$(git rev-parse --absolute-git-dir && echo /) && tkr_common=\${tkr_common%?/} && tkr_gitdir=\${tkr_gitdir%?/} && { tkr_head=$(git symbolic-ref -q HEAD); [ $? -le 1 ]; } && case $tkr_head in (refs/heads/*) tkr_leaf=\${tkr_head%/*};; (*) tkr_leaf=;; esac && [ "$(printf %s "$tkr_common$tkr_gitdir$tkr_leaf" | LC_ALL=C tr -d '\\0-\\37\\177"\\\\')" = "$tkr_common$tkr_gitdir$tkr_leaf" ] && { [ "$tkr_gitdir" = "$tkr_common" ] || printf '"%s",' "$tkr_gitdir"; } && printf '"%s/objects"' "$tkr_common" && { [ -z "$tkr_leaf" ] || printf ',"%s/%s","%s/logs/%s"' "$tkr_common" "$tkr_leaf" "$tkr_common" "$tkr_leaf"; })]"`;
116
128
  // OBS-125: codex 0.144.x gates enabled hooks behind PER-PROJECT-PATH hook trust — a NEW gate distinct
117
129
  // from the directory trust seedCodexTrust seeds. Every worker runs in a fresh ephemeral worktree, an
118
130
  // untrusted project, so codex re-prompts "Hooks need review" and the interactive worker stalls forever
@@ -2,6 +2,40 @@ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { parseWorkerResult } from "./prompt.js";
4
4
  import { OPENCODE_BUSY_FRAME_MARKERS, shq, TokenUsageSchema } from "./types.js";
5
+ /**
6
+ * S23: the first citation of the gate's nonce-framed index — from the LAST line exactly `CITABLE_LINES_BEGIN <nonce>`
7
+ * to the next line exactly `CITABLE_LINES_END <nonce>`. Every entry must be a JSON object with a string path and a
8
+ * non-empty array of non-negative safe-integer lines before any is used; otherwise (no block, no END, an empty or
9
+ * invalid entry anywhere) there is no citation. Never throws.
10
+ */
11
+ function firstCitation(prompt, nonce) {
12
+ if (!nonce)
13
+ return undefined;
14
+ const lines = prompt.split("\n");
15
+ const begin = lines.lastIndexOf(`CITABLE_LINES_BEGIN ${nonce}`);
16
+ const end = begin < 0 ? -1 : lines.indexOf(`CITABLE_LINES_END ${nonce}`, begin + 1);
17
+ if (end < 0)
18
+ return undefined;
19
+ const entries = [];
20
+ for (const line of lines.slice(begin + 1, end)) {
21
+ let entry;
22
+ try {
23
+ entry = JSON.parse(line);
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
29
+ return undefined;
30
+ const { path, lines: cited } = entry;
31
+ if (typeof path !== "string" || !Array.isArray(cited) || cited.length === 0
32
+ || !cited.every((n) => Number.isSafeInteger(n) && n >= 0))
33
+ return undefined;
34
+ entries.push({ path, lines: cited });
35
+ }
36
+ const first = entries[0];
37
+ return first ? { path: first.path, line: first.lines[0] } : undefined;
38
+ }
5
39
  // ponytail: deterministic scripted adapter — the whole integration suite runs on it, zero tokens.
6
40
  export class FakeAdapter {
7
41
  scriptPath;
@@ -77,13 +111,15 @@ export class FakeAdapter {
77
111
  val = { ...verdict, criteria: items.map((criterion) => ({ criterion, met: true, reason: "scripted fake pass" })) };
78
112
  }
79
113
  }
114
+ // S23: a scripted row without evidence cites the first line of the first entry of the gate's own citable
115
+ // index — never a string. An invalid or unterminated index yields no evidence (the gate then fails closed).
80
116
  if (key === "judge" && isJudge && val && typeof val === "object" && !Array.isArray(val)) {
81
117
  const verdict = val;
82
- const line = /```diff\n([\s\S]*?)```/.exec(prompt)?.[1].split("\n").find((candidate) => candidate.trim());
83
- if (line && Array.isArray(verdict.criteria)) {
118
+ const evidence = firstCitation(prompt, nonce);
119
+ if (evidence && Array.isArray(verdict.criteria)) {
84
120
  val = {
85
121
  ...verdict,
86
- criteria: verdict.criteria.map((row) => row && typeof row === "object" && !("evidence" in row) ? { ...row, evidence: line } : row),
122
+ criteria: verdict.criteria.map((row) => row && typeof row === "object" && !("evidence" in row) ? { ...row, evidence } : row),
87
123
  };
88
124
  }
89
125
  }
@@ -152,10 +188,12 @@ export class FakeAdapter {
152
188
  }
153
189
  // the run nonce lives in the prompt writePrompt handed us; echo a matching trailer so
154
190
  // parseWorkerResult(output, nonce) succeeds exactly as before the nonce existed
191
+ // S23: the verdict nonce is the LAST whole `VERDICT_NONCE: <hex>` line — the gate writes its own after the goal,
192
+ // criteria and diff, so a nonce line quoted in any of them never binds.
155
193
  nonceFor(promptFile) {
156
194
  try {
157
195
  const prompt = readFileSync(promptFile, "utf8");
158
- return /VERDICT_NONCE:\s*([0-9a-f]+)/i.exec(prompt)?.[1]
196
+ return prompt.split("\n").map((line) => /^VERDICT_NONCE: ([0-9a-f]+)$/.exec(line)?.[1]).filter(Boolean).at(-1)
159
197
  ?? /TICKMARKR_RESULT_([0-9a-z]+)/.exec(prompt)?.[1]
160
198
  ?? "";
161
199
  }
@@ -22,7 +22,7 @@ import { sh } from "../../run/git.js";
22
22
  import { auditNamedTestOracles, listVitestTests } from "../../gates/acceptance.js";
23
23
  import { readReviewNoVerdictHistory, reviewNoVerdictRows } from "../../run/journal.js";
24
24
  import { checkModelDiscovery } from "../../adapters/model-discovery-check.js";
25
- import { recordLaunchServices } from "../../run/launchservices-check.js";
25
+ import { LAUNCHSERVICES_PORT_ADVISORY, probeLaunchServicesPorts, recordLaunchServices } from "../../run/launchservices-check.js";
26
26
  /** Where a newer `table_<date>.csv` is discovered — the deployed site builds filenames by
27
27
  * concatenation and publishes no index, so the release listing is the only enumerable surface. */
28
28
  export const LIVEBENCH_RELEASES_URL = "https://api.github.com/repos/LiveBench/livebench.github.io/contents/public";
@@ -670,7 +670,8 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
670
670
  const discovery = await checkModelDiscovery({ cwd, cfg, adapters, health, catalog, resolved: resolvedIdentitySnapshot, mayRefresh: !opts.catalog && !catalogRefreshLine && catalogRefreshAllowed, fetcher: opts.catalogFetcher, now: opts.catalogNow });
671
671
  catalog = discovery.catalog;
672
672
  aliasDriftFindings.push(...discovery.findings);
673
- const launchServices = await recordLaunchServices(readDoctor(cwd), health);
673
+ const portProbe = await probeLaunchServicesPorts();
674
+ const launchServices = await recordLaunchServices(readDoctor(cwd), health, portProbe.observation);
674
675
  writeDoctor(cwd, launchServices.health);
675
676
  const rows = adapters.map((a) => {
676
677
  const h = health[a.id];
@@ -680,6 +681,14 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
680
681
  const healthy = h.installed && (a.id !== kimi.id || h.authed) && !(a.id === codex.id && h.codexCommit === "escape");
681
682
  return alignedStatusRow(healthy ? "pass" : "fail", a.id, state);
682
683
  });
684
+ const ports = portProbe.ports;
685
+ const portLine = ports.status === "non-darwin" ? "launchservicesd ports: not applicable"
686
+ : ports.status === "read" ? `launchservicesd pid ${ports.pid}: ${ports.count} ports`
687
+ : `launchservicesd pid ${ports.status === "unknown" ? ports.pid ?? "unknown" : "unknown"}: ports unknown`;
688
+ if (ports.status !== "skipped") {
689
+ rows.push(alignedStatusRow(ports.status === "read" && ports.count < LAUNCHSERVICES_PORT_ADVISORY || ports.status === "non-darwin"
690
+ ? "pass" : "warn", "launchservicesd", portLine));
691
+ }
683
692
  const reviewDemotions = recentReviewDemotions(cwd);
684
693
  if (reviewDemotions.length) {
685
694
  rows.push(legend("recent review-seat demotions:"));
@@ -1,4 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
2
3
  import { homedir } from "node:os";
3
4
  import { dirname, join, relative } from "node:path";
4
5
  import { parseArgs } from "node:util";
@@ -92,6 +93,131 @@ function formatFleetSteering(cfg) {
92
93
  // T10: what every review says about where its bytes land and when they take effect
93
94
  const REPO_DESTINATION_NOTE = (path) => `destination: the repository overlay ${path} holds this repository's routing.allow/deny membership, so the edit lands there — the file is tracked: Fleet never stages or commits it; commit it to share the change`;
94
95
  const FUTURE_LOADS_NOTE = "the save changes future config loads only — a tickmarkr run already in progress keeps the config it loaded";
96
+ // Queue 134: routing.mode, review.prefer, consult.prefer and judge each follow the overlay that holds
97
+ // them. A YAML merge holds one only when that merge supplies it — an unrelated sibling merge does not —
98
+ // and the review refuses a write through a supplying merge (D-987). A scalar alias key is the name it
99
+ // resolves to, at the alias's own line; a parent alias is refused because the writer cannot update
100
+ // through it without replacing its siblings. A merge sequence is shallow and first-source-wins.
101
+ // Membership stays on heldLine.
102
+ const CONTROL_PATH = {
103
+ "routing.mode": ["routing", "mode"],
104
+ "review.prefer": ["review", "prefer"],
105
+ "consult.prefer": ["consult", "prefer"],
106
+ judge: ["judge"],
107
+ };
108
+ const CONTROL_NAMES = new Set(Object.keys(CONTROL_PATH));
109
+ const { isAlias: yamlIsAlias, isMap: yamlIsMap, isNode: yamlIsNode, isScalar: yamlIsScalar, isSeq: yamlIsSeq, parseDocument: yamlParseDocument, } = createRequire(import.meta.url)("yaml");
110
+ const controlHoldMemo = new Map();
111
+ const controlLineOf = (bytes, offset) => bytes.slice(0, offset).split("\n").length;
112
+ const resolveYaml = (doc, node) => yamlIsAlias(node) ? node.resolve(doc) : node;
113
+ // A plain scalar, or a scalar alias (`*modeKey` resolving to mode). The merge key `<<` is a symbol
114
+ // and is not a name. Callers that report a line keep the key node's own range — the alias as written,
115
+ // never the anchor it points at.
116
+ const scalarKeyName = (doc, key) => {
117
+ const resolved = resolveYaml(doc, key);
118
+ if (!yamlIsScalar(resolved) || typeof resolved.value === "symbol" || resolved.value == null)
119
+ return undefined;
120
+ return String(resolved.value);
121
+ };
122
+ const namedPair = (doc, map, name) => map.items.find((item) => scalarKeyName(doc, item.key) === name);
123
+ const mergePair = (map) => map.items.find((item) => yamlIsScalar(item.key) && typeof item.key.value === "symbol");
124
+ const mergeShapeOf = (bytes, value) => {
125
+ const range = yamlIsNode(value) ? value.range : null;
126
+ const text = range ? bytes.slice(range[0], range[1]).trim().replace(/\s+/g, " ") : "…";
127
+ return `<<: ${text}`;
128
+ };
129
+ const mergeSources = (doc, value) => {
130
+ const node = resolveYaml(doc, value);
131
+ return yamlIsSeq(node) ? node.items.map((item) => resolveYaml(doc, item)) : [node];
132
+ };
133
+ // YAML 1.1 merge: an explicit key wins over `<<`, and a sequence is shallow — the first source that
134
+ // declares a key owns that key whole. A later source's prefer or mode does not survive an earlier
135
+ // source that already declared the parent mapping. `seen` stops a merge that aliases itself.
136
+ const definesKey = (doc, node, key, seen) => {
137
+ const here = resolveYaml(doc, node);
138
+ if (!yamlIsMap(here) || seen.has(here))
139
+ return false;
140
+ if (namedPair(doc, here, key))
141
+ return true;
142
+ const merge = mergePair(here);
143
+ if (!merge)
144
+ return false;
145
+ const next = new Set(seen);
146
+ next.add(here);
147
+ return mergeSources(doc, merge.value).some((source) => definesKey(doc, source, key, next));
148
+ };
149
+ const controlSupplied = (doc, node, rest, seen = new Set()) => {
150
+ const here = resolveYaml(doc, node);
151
+ if (!yamlIsMap(here) || seen.has(here))
152
+ return false;
153
+ if (rest.length === 0)
154
+ return true;
155
+ const [head, ...tail] = rest;
156
+ if (head === undefined)
157
+ return false;
158
+ const pair = namedPair(doc, here, head);
159
+ if (pair) {
160
+ const value = resolveYaml(doc, pair.value);
161
+ if (yamlIsScalar(value) && value.value === null)
162
+ return true;
163
+ if (tail.length === 0)
164
+ return true;
165
+ const next = new Set(seen);
166
+ next.add(here);
167
+ return controlSupplied(doc, value, tail, next);
168
+ }
169
+ const merge = mergePair(here);
170
+ if (!merge)
171
+ return false;
172
+ const next = new Set(seen);
173
+ next.add(here);
174
+ for (const source of mergeSources(doc, merge.value)) {
175
+ if (!definesKey(doc, source, head, next))
176
+ continue;
177
+ return controlSupplied(doc, source, rest, next);
178
+ }
179
+ return false;
180
+ };
181
+ /** The repository (or user) overlay holds a control at its own key, at a null mask at it or above, or at a
182
+ * merge key that supplies it. An explicit key wins over a sibling merge. Parse errors hold nothing. */
183
+ function controlHold(bytes, path) {
184
+ const key = `${path.join(".")}\0${bytes}`;
185
+ if (controlHoldMemo.has(key))
186
+ return controlHoldMemo.get(key);
187
+ if (controlHoldMemo.size > 256)
188
+ controlHoldMemo.clear();
189
+ const doc = yamlParseDocument(bytes);
190
+ const hold = doc.errors.length ? undefined : walkControl(doc, bytes, doc.contents, path);
191
+ controlHoldMemo.set(key, hold);
192
+ return hold;
193
+ }
194
+ function walkControl(doc, bytes, node, rest) {
195
+ const here = resolveYaml(doc, node);
196
+ if (!yamlIsMap(here) || rest.length === 0)
197
+ return undefined;
198
+ const [head, ...tail] = rest;
199
+ const pair = head === undefined ? undefined : namedPair(doc, here, head);
200
+ if (pair) {
201
+ const range = yamlIsNode(pair.key) ? pair.key.range : null;
202
+ if (!range)
203
+ return undefined;
204
+ const line = controlLineOf(bytes, range[0]);
205
+ const value = resolveYaml(doc, pair.value);
206
+ if (yamlIsScalar(value) && value.value === null) {
207
+ return { line, via: yamlIsAlias(pair.key) && tail.length > 0 ? "alias-parent" : "mask" };
208
+ }
209
+ if (tail.length === 0)
210
+ return { line, via: "declared" };
211
+ const nested = walkControl(doc, bytes, value, tail);
212
+ return nested && yamlIsAlias(pair.key) ? { line, via: "alias-parent" } : nested;
213
+ }
214
+ const merge = mergePair(here);
215
+ if (!merge || !yamlIsScalar(merge.key) || !merge.key.range)
216
+ return undefined;
217
+ if (!controlSupplied(doc, here, rest))
218
+ return undefined;
219
+ return { line: controlLineOf(bytes, merge.key.range[0]), via: "merge", shape: mergeShapeOf(bytes, merge.value) };
220
+ }
95
221
  const MODE_GLOSS = {
96
222
  "partner-led": "every shape frontier · explore off",
97
223
  "risk-based": "risk-tiered default floors",
@@ -224,8 +350,10 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
224
350
  export async function assembleFleetEditor(cwd, adapters, io, opts) {
225
351
  const globalDir = opts.globalDir;
226
352
  // B2: the editor's choices are the operator's MACHINE choices — a save lands in the USER overlay,
227
- // which every repository on this machine inherits. T10 (D-925/D-926): except a membership edit the
228
- // repository overlay holds — it lands where it is effective (destinationEdits below).
353
+ // which every repository on this machine inherits. T10 (D-925/D-926): a membership edit and a
354
+ // routing.mode, review.prefer, consult.prefer or judge edit each land in the overlay that holds
355
+ // them (destinationEdits below) — the repository overlay when it declares the key or masks it
356
+ // with null at it or above, else the user overlay.
229
357
  const userPath = join(globalDir, "config.yaml");
230
358
  const repoPath = repoOverlayPath(cwd);
231
359
  const currentUserText = () => (existsSync(userPath) ? readFileSync(userPath, "utf8") : "");
@@ -552,11 +680,12 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
552
680
  const home = homedir();
553
681
  const shown = (path) => (path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path);
554
682
  const holderOf = (dotted) => {
555
- const path = dotted.split(".");
556
- const repoLine = heldLine(currentRepoOverlayText(cwd), path);
683
+ const control = dotted in CONTROL_PATH ? CONTROL_PATH[dotted] : undefined;
684
+ const lineIn = (text) => control ? controlHold(text, control)?.line : heldLine(text, dotted.split("."));
685
+ const repoLine = lineIn(currentRepoOverlayText(cwd));
557
686
  if (repoLine !== undefined)
558
687
  return `${relative(cwd, repoPath)}:${repoLine}`;
559
- const userLine = heldLine(currentUserText(), path);
688
+ const userLine = lineIn(currentUserText());
560
689
  return userLine === undefined ? undefined : `${shown(userPath)}:${userLine}`;
561
690
  };
562
691
  // the allow + flat-deny family is ONE coupled write (the allow form is
@@ -615,7 +744,14 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
615
744
  }
616
745
  if (JSON.stringify(initial.efforts ?? {}) !== JSON.stringify(staged.efforts ?? {}))
617
746
  edits.push({ name: "model efforts", destination: "user" });
618
- return [...edits, ...controls.map((name) => ({ name, destination: "user" }))];
747
+ // a control lands where it is held. A merge that supplies it is still the repository (the review
748
+ // then refuses the write); review.prefer and consult.prefer are two edits, never one
749
+ const controlDestination = (name) => {
750
+ if (!(name in CONTROL_PATH))
751
+ return "user";
752
+ return controlHold(currentRepoOverlayText(cwd), CONTROL_PATH[name]) !== undefined ? "repo" : "user";
753
+ };
754
+ return [...edits, ...controls.map((name) => ({ name, destination: controlDestination(name) }))];
619
755
  };
620
756
  // ONE review confirms ONE destination; a batch needing both is refused before any write (no
621
757
  // multi-file transaction) and names what to save in separate sessions
@@ -800,8 +936,8 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
800
936
  parts.push(`${unroutable} unroutable`);
801
937
  return ` mix: ${mix} — ${parts.join(" · ")}`;
802
938
  };
803
- // B2: the floors the candidate user-layer bytes resolve to — the same config the routed mix ranks
804
- // against, so a repo-declared mode that shadows the user's pick previews no floor change
939
+ // Floors come from the candidate the chosen destination would load. A routing.mode the repository
940
+ // holds is previewed there, so those floors move; an unheld mode is previewed in the user overlay.
805
941
  const floorPreview = (mode, map, deny, stage) => {
806
942
  if (mode === rm.mode.mode)
807
943
  return [];
@@ -818,9 +954,9 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
818
954
  : [" (no floor changes)"]),
819
955
  ];
820
956
  };
821
- // B2: `resolution` is the candidate user-layer bytes' own mode resolution — a repo-declared mode that
822
- // shadows the user's pick keeps its provenance, never an operator pin the loader would not apply
823
- // B2: `entry` is the slot the candidate's loader applies — a staged slot a repo declaration shadows
957
+ // `resolution` is the candidate destination's mode. A routing.mode the repository holds is written
958
+ // into that preview, so a changed mode pins the floors the loader will apply.
959
+ // `entry` is the slot the candidate's loader applies — a staged slot a repo declaration shadows
824
960
  // is no operator pin here; the repo's winning declaration keeps its provenance
825
961
  const whyDeclaration = (shape, resolution, entry, mapProducedValue) => {
826
962
  const mapChanged = JSON.stringify(mapSlot(entry)) !== JSON.stringify(mapSlot(editable.map[shape]));
@@ -1154,7 +1290,9 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
1154
1290
  // the diff and asks for confirmation, but owns neither filesystem access nor a writer.
1155
1291
  // B2: defaults/user/repo are re-read here, at review. T10: so is the destination.
1156
1292
  const modeChanged = state.selectedMode !== rm.mode.mode;
1157
- const steeringChanged = ["review", "consult"].some((key) => JSON.stringify(state.steering[key]) !== JSON.stringify(initialSteering[key]));
1293
+ const reviewChanged = JSON.stringify(state.steering.review) !== JSON.stringify(initialSteering.review);
1294
+ const consultChanged = JSON.stringify(state.steering.consult) !== JSON.stringify(initialSteering.consult);
1295
+ const steeringChanged = reviewChanged || consultChanged;
1158
1296
  const judgeSeat = state.judgeSeat;
1159
1297
  const judgeChanged = judgeSeat !== undefined
1160
1298
  && `${judgeSeat.adapter}:${judgeSeat.model}` !== initialJudge;
@@ -1162,11 +1300,14 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
1162
1300
  pendingWrite = null;
1163
1301
  return { kind: "empty" };
1164
1302
  }
1165
- const chosen = chooseDestination(destinationEdits(staged, metadata.seeds, [
1303
+ const controlNames = [
1166
1304
  ...(modeChanged ? ["routing.mode"] : []),
1167
- ...(steeringChanged ? ["review/consult prefer"] : []),
1305
+ ...(reviewChanged ? ["review.prefer"] : []),
1306
+ ...(consultChanged ? ["consult.prefer"] : []),
1168
1307
  ...(judgeChanged ? ["judge"] : []),
1169
- ]));
1308
+ ];
1309
+ const edits = destinationEdits(staged, metadata.seeds, controlNames);
1310
+ const chosen = chooseDestination(edits);
1170
1311
  if (!chosen.ok) {
1171
1312
  pendingWrite = null;
1172
1313
  return { kind: "refused", reason: chosen.reason };
@@ -1174,6 +1315,43 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
1174
1315
  const destination = chosen.destination;
1175
1316
  const user = currentUserText();
1176
1317
  const repo = currentRepoOverlayText(cwd);
1318
+ // A merge that supplies a control or an alias used as its parent is a holder, but the writer
1319
+ // cannot update through either key. Refuse before any bytes change, naming the key's own line.
1320
+ if (destination === "repo") {
1321
+ const refusals = edits.flatMap((edit) => {
1322
+ if (!(edit.name in CONTROL_PATH))
1323
+ return [];
1324
+ const hold = controlHold(repo, CONTROL_PATH[edit.name]);
1325
+ if (hold?.via === "merge") {
1326
+ const shape = hold.shape ?? "<<: …";
1327
+ return [`${edit.name} takes keys through the YAML merge key \`${shape}\` at ${holderOf(edit.name)} — Fleet does not write through merge keys; inline the merged keys there by hand, then save again`];
1328
+ }
1329
+ if (hold?.via === "alias-parent") {
1330
+ return [`${edit.name} takes keys through a YAML alias parent key at ${holderOf(edit.name)} — Fleet does not write through alias parent keys; replace that key with its scalar name, then save again`];
1331
+ }
1332
+ return [];
1333
+ });
1334
+ if (refusals.length) {
1335
+ pendingWrite = null;
1336
+ return { kind: "refused", reason: refusals.join("; ") };
1337
+ }
1338
+ }
1339
+ // clearing a repository-held prefer writes an explicit empty sequence, so a lower user list cannot
1340
+ // reappear. A user-held or unheld clear stays undefined and the writer deletes the key, as before.
1341
+ const steeringEdited = { ...state.steering };
1342
+ if (destination === "repo") {
1343
+ for (const key of ["review", "consult"]) {
1344
+ if (JSON.stringify(steeringEdited[key]) === JSON.stringify(initialSteering[key]))
1345
+ continue;
1346
+ const chain = steeringEdited[key];
1347
+ if (chain != null && chain.length > 0)
1348
+ continue;
1349
+ const name = `${key}.prefer`;
1350
+ const hold = controlHold(repo, CONTROL_PATH[name]);
1351
+ if (hold !== undefined && hold.via !== "merge")
1352
+ steeringEdited[key] = [];
1353
+ }
1354
+ }
1177
1355
  const before = destination === "repo" ? repo : user;
1178
1356
  const path = pathOf(destination);
1179
1357
  const lower = lowerLayers(destination);
@@ -1186,7 +1364,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
1186
1364
  lowerOverrides: lowerOverrides[destination],
1187
1365
  ...(modeChanged ? { mode: state.selectedMode } : {}),
1188
1366
  ...(judgeChanged && judgeSeat ? { judge: judgeSeat } : {}),
1189
- steering: { initial: initialSteering, edited: state.steering },
1367
+ steering: { initial: initialSteering, edited: steeringEdited },
1190
1368
  });
1191
1369
  after = renderFleetOverlayWrite(before, write); // T10 (D-987): a merge-key path refuses here
1192
1370
  }
@@ -1211,9 +1389,19 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
1211
1389
  catch {
1212
1390
  poolNotes = [];
1213
1391
  }
1392
+ const controlEdits = edits.filter((edit) => CONTROL_NAMES.has(edit.name));
1393
+ const controlNote = controlEdits.length === 0
1394
+ ? undefined
1395
+ : `destination: ${controlEdits.map((edit) => `${edit.name} at ${holderOf(edit.name)}`).join("; ")} — the file is tracked and Fleet never commits it`;
1214
1396
  const notes = [
1215
- // T10: the review names where the edit lands and what that file is
1216
- ...(destination === "repo" ? [REPO_DESTINATION_NOTE(repoPath)] : repoShadowNotes(before, after)),
1397
+ // T10: a membership edit keeps the membership destination note. Every repository-destined
1398
+ // control in the same batch is named beside it, path:line from holderOf, file tracked.
1399
+ ...(destination === "repo"
1400
+ ? [
1401
+ ...(edits.some((edit) => !CONTROL_NAMES.has(edit.name)) ? [REPO_DESTINATION_NOTE(repoPath)] : []),
1402
+ ...(controlNote ? [controlNote] : []),
1403
+ ]
1404
+ : repoShadowNotes(before, after)),
1217
1405
  FUTURE_LOADS_NOTE,
1218
1406
  ...(lower.ok ? [] : [`${lower.error} — y will be refused`]),
1219
1407
  ...poolNotes,
@@ -17,7 +17,7 @@ import { addDetachedWorktree, linkNodeModules, removeWorktree, shGit, shGitOk }
17
17
  import { foldOwedChecks, integrationMapped, owedCriteria, Journal, OWED_DISCHARGE_EVENT } from "../../run/journal.js";
18
18
  import { describeLeaseWait, withRepositoryLease } from "../../run/lease.js";
19
19
  import { isPidLive } from "../../run/lock.js";
20
- import { recordLaunchServices } from "../../run/launchservices-check.js";
20
+ import { LAUNCHSERVICES_PORT_STOP, probeLaunchServicesPorts, recordLaunchServices } from "../../run/launchservices-check.js";
21
21
  /**
22
22
  * tickmarkr verify — the gate battery as a standalone command (OPERATING-MODEL-2026-08-11 item 3).
23
23
  *
@@ -184,6 +184,14 @@ export async function verify(argv, cwd = process.cwd(), options = {}) {
184
184
  },
185
185
  allowPositionals: false,
186
186
  });
187
+ const portProbe = await probeLaunchServicesPorts({ requireTop: true });
188
+ const ports = portProbe.ports;
189
+ if (ports.status === "read" || ports.status === "unknown") {
190
+ console.error(`verify: ports ${ports.status === "read" ? ports.count : "unknown"}`);
191
+ }
192
+ if (ports.status === "read" && ports.count >= LAUNCHSERVICES_PORT_STOP) {
193
+ return { out: `verify refused: launchservicesd has ${ports.count} ports (threshold ${LAUNCHSERVICES_PORT_STOP}); log out or restart to reset launchservicesd before verifying`, code: 1 };
194
+ }
187
195
  const stateRoot = await verifyStateRoot(cwd);
188
196
  const fileRoot = (file) => existsSync(join(cwd, ".tickmarkr", file)) ? cwd : stateRoot;
189
197
  if (resolve(stateRoot) !== resolve(cwd)) {
@@ -12,7 +12,7 @@ export interface JudgeVerdict {
12
12
  criterion: string;
13
13
  met: boolean;
14
14
  reason: string;
15
- evidence: string | EvidenceCitation;
15
+ evidence: EvidenceCitation;
16
16
  }>;
17
17
  comments?: Array<{
18
18
  path: string;