tickmarkr 2.6.5 → 2.6.7

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 (56) hide show
  1. package/README.md +7 -3
  2. package/dist/adapters/claude-code.js +8 -2
  3. package/dist/adapters/codex-commit-check.d.ts +16 -4
  4. package/dist/adapters/codex-commit-check.js +150 -26
  5. package/dist/adapters/codex.d.ts +1 -0
  6. package/dist/adapters/codex.js +20 -8
  7. package/dist/adapters/types.d.ts +4 -1
  8. package/dist/cli/commands/beat.d.ts +6 -1
  9. package/dist/cli/commands/beat.js +123 -76
  10. package/dist/cli/commands/doctor.js +3 -1
  11. package/dist/cli/commands/fleet.js +368 -85
  12. package/dist/cli/commands/init.js +23 -9
  13. package/dist/cli/commands/plan.js +12 -1
  14. package/dist/cli/commands/verify.js +37 -9
  15. package/dist/cli/help.d.ts +1 -0
  16. package/dist/cli/help.js +10 -5
  17. package/dist/cli/index.d.ts +1 -1
  18. package/dist/cli/index.js +1 -1
  19. package/dist/config/fleet-overlay.d.ts +14 -0
  20. package/dist/config/fleet-overlay.js +154 -25
  21. package/dist/drivers/orca.d.ts +17 -3
  22. package/dist/drivers/orca.js +64 -20
  23. package/dist/gates/baseline.d.ts +9 -0
  24. package/dist/gates/baseline.js +14 -3
  25. package/dist/gates/cache.js +1 -1
  26. package/dist/gates/review.js +15 -0
  27. package/dist/gates/run-gates.d.ts +20 -7
  28. package/dist/gates/run-gates.js +345 -210
  29. package/dist/gates/test-manifest.d.ts +11 -0
  30. package/dist/gates/test-manifest.js +151 -111
  31. package/dist/gates/timeout-shaped.d.ts +3 -0
  32. package/dist/gates/timeout-shaped.js +10 -0
  33. package/dist/run/beat-lifecycle.d.ts +154 -0
  34. package/dist/run/beat-lifecycle.js +825 -0
  35. package/dist/run/daemon.js +202 -126
  36. package/dist/run/git-trust.d.ts +27 -0
  37. package/dist/run/git-trust.js +178 -0
  38. package/dist/run/git.js +34 -14
  39. package/dist/run/journal.js +15 -3
  40. package/dist/run/lease.d.ts +23 -0
  41. package/dist/run/lease.js +232 -38
  42. package/dist/run/merge.js +2 -2
  43. package/dist/run/stall.js +20 -1
  44. package/dist/run/supervision.d.ts +8 -0
  45. package/dist/run/supervision.js +24 -4
  46. package/dist/run/verification-job.d.ts +51 -0
  47. package/dist/run/verification-job.js +137 -0
  48. package/dist/tui/ink/fleet-app.d.ts +35 -5
  49. package/dist/tui/ink/fleet-app.js +244 -124
  50. package/package.json +1 -1
  51. package/skills/tickmarkr-auto/SKILL.md +19 -10
  52. package/skills/tickmarkr-loop/SKILL.md +19 -10
  53. package/skills/tickmarkr-overseer/SKILL.md +25 -16
  54. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +58 -16
  55. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +19 -6
  56. package/skills/tickmarkr-overseer/scripts/watch-parks.sh +121 -64
@@ -130,13 +130,24 @@ you were handed the whole picture — list the repository root, open every guida
130
130
  predate a project rename, so **search by CONCEPT, not by the current product name.** A memory nobody opens
131
131
  is worse than none: every seat assumes the lesson is recorded somewhere and no seat looks.
132
132
  - **The gates are the product.** Seven, defined in \`src/graph/schema.ts\`:
133
- \`build test lint evidence scope acceptance review\`. **That is DECLARATION order, not execution order** —
134
- the first five run as a battery that stops at its first red, then \`acceptance\` and \`review\` run
135
- CONCURRENTLY (\`run-gates.ts:39\`, *"judge ‖ review"*). One repair reorders them: a repair the REVIEW sent
136
- back (carried \`review:material\`, test selection on, no required repair test) runs build, lint, evidence and
137
- scope, then \`acceptance\` ‖ \`review\`, then its test screen, so a review that re-reds an unchanged subject
138
- starts no test at all; a test-red repair keeps the battery first. Either way nothing merges until the FULL
139
- suite has passed on the merge-candidate commit. The first five are MANDATORY; only \`acceptance\` and
133
+ \`build test lint evidence scope acceptance review\`. **That is DECLARATION order, not execution order.**
134
+ The cheap checks run first and stop at their first red: \`build\`, \`lint\`, then \`evidence\` and \`scope\`.
135
+ A fresh or semantic-repair candidate then runs \`acceptance\` ‖ \`review\` CONCURRENTLY (\`run-gates.ts\`,
136
+ *"judge ‖ review"*) before any test payload: a decisive semantic red ends the round with zero diagnostic
137
+ or full test-gate runs (a required acceptance oracle still executes); otherwise ONE full verification job
138
+ follows, with no selected screen. Only an attributed behavioral test-red repair may buy one diagnostic
139
+ first — every required failing file plus conservatively selected tests reaching the diff — admitted only
140
+ on comparable harness timing with ratio <= 0.15 AND estimated cost <= 60000 ms; its behavioral red stops
141
+ the round before semantics, otherwise semantics precede the full job. Disabled, unsupported, over-cap or
142
+ unknown-cost selection skips the diagnostic instead of running a full suite before semantics, and a
143
+ killed diagnostic is never green. A seatless or no-verdict review lets acceptance finish and publish and
144
+ the full job run in-round, and stays unsatisfied until review-only recovery; a seatless acceptance
145
+ cancels its sibling, records the unrun test (\`testOwed\`) and every enabled gate still owed (an unrecorded
146
+ review included), cannot merge and parks infra; an actual cancellation or throw awaits cleanup and leaves
147
+ no mergeable partial set. Test-only verification and an explicit full recheck keep their full scope with
148
+ no invented semantics and no diagnostic. Nothing merges until the FULL suite has passed on the exact
149
+ merge-candidate commit; an exact full green answers only after identity, environment, cleanliness and
150
+ current complete-manifest checks. The first five are MANDATORY; only \`acceptance\` and
140
151
  \`review\` may be omitted per task. Implementations are in \`src/gates/\` — \`baseline.ts\` (build/test/lint,
141
152
  diffed against a recorded baseline so pre-existing failures are forgiven), \`evidence.ts\`, \`scope.ts\`,
142
153
  \`acceptance.ts\` (its judge reads the DIFF and every criterion must cite a changed hunk), \`review.ts\`
@@ -639,7 +650,8 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
639
650
  const interactive = input.isTTY === true && output.isTTY === true && !(values.yes ?? false);
640
651
  // Act 1 — preferences. Only when the repo config does not exist yet: init never rewrites an
641
652
  // operator's config; re-tuning is `tickmarkr fleet` (act 3 below still runs). Act 1 writes only
642
- // project execution preferences; model choices are act 3's, saved to the USER overlay (B2).
653
+ // project execution preferences; model choices are act 3's, saved by Fleet's one destination rule:
654
+ // the USER overlay (B2), except a membership edit the repo overlay holds (T10).
643
655
  if (!repoConfigExists) {
644
656
  if (interactive) {
645
657
  emitBanner();
@@ -677,7 +689,9 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
677
689
  // Act 3 — fleet. The compact discovery surface prints BETWEEN the acts so the operator reads
678
690
  // what the fleet browser (and its presets overlay, raised on the first Shapes entry) ranks
679
691
  // with; the final summary then carries a pointer, not a repeat. Its confirmation is the shared
680
- // Fleet actuator's: the reviewed choices land in the user overlay (gdir), never the repo's.
692
+ // Fleet actuator's: a preset (routing.mode) lands in the user overlay (gdir), and so does every
693
+ // choice except a routing.allow/deny membership edit this repo overlay holds — T10 writes that one
694
+ // where it is effective, and the review names the destination before y.
681
695
  if (interactive) {
682
696
  output.write(`${doc}\n`);
683
697
  doc = "discovery shown above — full matrix any time with `tickmarkr doctor`";
@@ -19,7 +19,8 @@ import { auditNamedTestOracles, listVitestTests } from "../../gates/acceptance.j
19
19
  import { carriedAuthorVendors, gateReviewerFloor, modelId, modelProvider, pickReviewer } from "../../gates/review.js";
20
20
  import { Journal, loadRoutingProfile, readProfileCursor, recordedGraphDefinitionHash, RUNS_WINDOW } from "../../run/journal.js";
21
21
  import { harnessLine, resolveHarness } from "../harness.js";
22
- import { codexCommitHeadline } from "../../adapters/codex-commit-check.js";
22
+ import { codexCommitHeadline, codexEscapeRoleLine } from "../../adapters/codex-commit-check.js";
23
+ import { codex } from "../../adapters/codex.js";
23
24
  import { channelKey, shq } from "../../adapters/types.js";
24
25
  import { shGit } from "../../run/git.js";
25
26
  import { runLockRunId, runStatusLine } from "../../run/lock.js";
@@ -279,6 +280,16 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
279
280
  const entrySeats = routingEntrySeatLines(cfg);
280
281
  if (entrySeats.length)
281
282
  lines.push(...entrySeats, "");
283
+ // v2.6.6 T9 (K): Codex judge/review/consult seats launch with the worker grant (llm.ts, consult.ts), so a
284
+ // doctor-probed escape blocks them too; worker rows carry their own headline below.
285
+ const codexRoles = [
286
+ ...(cfg.judge.adapter === codex.id ? ["judge"] : []),
287
+ ...(pools.review.some((c) => c.adapter === codex.id) ? ["review"] : []),
288
+ ...(cfg.consult.adapter === codex.id || pools.consult.some((c) => c.adapter === codex.id) ? ["consult"] : []),
289
+ ];
290
+ const codexEscape = codexEscapeRoleLine(codexRoles, adapters, health);
291
+ if (codexEscape)
292
+ lines.push(codexEscape, "");
282
293
  // OBS-1185: the floor route() resolved for THIS task — a task hint over the configured/mode floor.
283
294
  const derivation = (t) => {
284
295
  const floor = resolvedFloor(t, cfg);
@@ -102,6 +102,37 @@ export function excludeAuthorProvider(channels, author) {
102
102
  const provider = modelProvider(author.model, author.vendor);
103
103
  return [author, ...channels.filter((candidate) => candidate !== author && modelProvider(candidate.model, candidate.vendor) !== provider)];
104
104
  }
105
+ /**
106
+ * G (D-874): `--author` names who wrote the diff so review can EXCLUDE it — it never asks for a seat. So
107
+ * it resolves against installed identities (an installed adapter's doctor-listed or configured model),
108
+ * not against the review pool, whose role allow/deny, model-auth and tier gates say nothing about
109
+ * authorship. A pool member resolves to itself, unchanged. Anything else is an author-only identity: its
110
+ * vendor is the configured declaration (the model's override, else the adapter entry's; the adapter's own
111
+ * only when config has no entry), never inferred, and an unseeded model carries no tier of its own — it
112
+ * holds the human sentinel's frontier floor, so the reviewer floor is never lowered. Same-vendor, so it
113
+ * can never be seated. An identity nothing declares refuses by name.
114
+ */
115
+ function authorIdentity(claim, cfg, adapters, health, pool) {
116
+ const [adapter = "", ...rest] = claim.split(":");
117
+ const model = rest.join(":");
118
+ const member = pool.find((c) => c.adapter === adapter && c.model === model);
119
+ if (member)
120
+ return member;
121
+ const a = adapters.find((x) => x.id === adapter);
122
+ const h = health[adapter];
123
+ const seeded = a?.channels(cfg).find((c) => c.model === model);
124
+ const entry = cfg.tiers[adapter];
125
+ const vendor = seeded?.vendor ?? (entry ? entry.modelOverrides?.[model]?.vendor ?? entry.vendor : a?.vendor);
126
+ const why = !a ? `no registered adapter "${adapter}"`
127
+ : !h?.installed ? `doctor does not record ${adapter} as installed`
128
+ : !seeded && !h.models?.includes(model) ? `${adapter} neither lists "${model}" in doctor nor configures it`
129
+ : !vendor ? `no vendor is declared for ${adapter}:${model}`
130
+ : undefined;
131
+ if (why) {
132
+ throw new Error(`--author ${claim} does not name a discoverable author identity (${why}) — name an installed adapter's doctor-listed or configured model as <adapter>:<model>, or human`);
133
+ }
134
+ return seeded ?? { adapter, vendor: vendor, model, channel: entry?.modelOverrides?.[model]?.channel ?? entry?.channel ?? "sub", tier: "frontier" };
135
+ }
105
136
  function recordedMerges(repoRoot) {
106
137
  const runs = join(repoRoot, ".tickmarkr", "runs");
107
138
  if (!existsSync(runs))
@@ -288,6 +319,9 @@ export async function verify(argv, cwd = process.cwd(), options = {}) {
288
319
  const pools = rolePools(cfg, adapters, health);
289
320
  judgeChannels = pools.judge;
290
321
  channels = pools.review;
322
+ // Resolved before either branch so an undiscoverable claim refuses here, ahead of any capture —
323
+ // even where recorded authors bind and the claim is otherwise ignored.
324
+ const claimed = values.author && values.author !== "human" ? authorIdentity(values.author, cfg, adapters, health, channels) : undefined;
291
325
  if (owed.length) {
292
326
  // The run's lifetime patch authors bind the reviewer's exclusions; --author never overrides
293
327
  // them. An author the pool cannot resolve leaves review unseatable, which fails closed.
@@ -298,15 +332,9 @@ export async function verify(argv, cwd = process.cwd(), options = {}) {
298
332
  if (first)
299
333
  author = { adapter: first.adapter, model: first.model, channel: first.channel, tier: first.tier };
300
334
  }
301
- else if (values.author && values.author !== "human") {
302
- const [adapter, ...rest] = values.author.split(":");
303
- const model = rest.join(":");
304
- const c = channels.find((ch) => ch.adapter === adapter && ch.model === model);
305
- if (!c) {
306
- throw new Error(`--author ${values.author} does not name a discoverable review channel — one of: ${channels.map(channelKey).join(", ") || "(none)"}`);
307
- }
308
- author = { adapter: c.adapter, model: c.model, channel: c.channel, tier: c.tier };
309
- channels = excludeAuthorProvider(channels, c);
335
+ else if (claimed) {
336
+ author = { adapter: claimed.adapter, model: claimed.model, channel: claimed.channel, tier: claimed.tier };
337
+ channels = excludeAuthorProvider(channels, claimed);
310
338
  }
311
339
  else {
312
340
  channels = [...channels, HUMAN_CHANNEL];
@@ -186,6 +186,7 @@ export declare const COMMAND_HELP: {
186
186
  "--pct <0..100>": string;
187
187
  "--threshold-pct <0..100>": string;
188
188
  "--stand-down": string;
189
+ "--status": string;
189
190
  };
190
191
  examples: string[];
191
192
  };
package/dist/cli/help.js CHANGED
@@ -148,17 +148,22 @@ export const COMMAND_HELP = {
148
148
  ],
149
149
  },
150
150
  beat: {
151
- usage: "beat <orchestrator|orchestrator-context|overseer|overseer-context|watch> --seat <identity> [options]",
152
- description: "Write one supervision beat using the recorded arm. Stood-down arms stay DISARMED until explicitly re-armed; stopped beats become STALE.",
151
+ usage: "beat <start|stop|status> <orchestrator|orchestrator-context|overseer|overseer-context|watch> --seat <identity> | beat <tier> --seat <identity> [options]",
152
+ description: "start launches one detached --loop beat per tier and exits 0 only after reading back that child's own advancing beat; stop retires only that recorded generation and reads back DISARMED; status is read-only and exits nonzero for STALE, MISMATCH or UNREADABLE. The legacy <tier> form writes one supervision beat using the recorded arm: stood-down arms stay DISARMED until explicitly re-armed; stopped beats become STALE.",
153
153
  options: {
154
- "--seat <identity>": "Required: identify the supervising pane or agent.",
154
+ "--seat <identity>": "Required for start, stop and every write: identify the supervising pane or agent; optional for status.",
155
155
  "--new-arm": "Create a new durable arm, allowing beats to resume after stand-down; the hand-off step for a seat taking over a stood-down tier (combine with --arm-id and --pct on a context tier).",
156
- "--loop": "Create a new durable arm and beat in this process every 10 seconds; exit within one interval after stand-down.",
156
+ "--loop": "Create a new durable arm and beat in this process every 10 seconds; exit nonzero with its reason within one interval after stand-down, supersession or a refused write.",
157
157
  "--arm-id <identity>": "Name a newly created arm; with --pct, identify the context observation arm. Never refuses a tick.",
158
158
  "--pct <0..100>": "Report current context consumption percentage.",
159
159
  "--threshold-pct <0..100>": "Set the context warning threshold (default 75).",
160
160
  "--stand-down": "Record an explicit handoff and stand down this tier. A later one-shot tick on the old arm exits non-zero; context observations still raise and discharge the clear duty.",
161
- }, examples: ["beat overseer --seat supervisor", "beat overseer --seat supervisor --loop", "beat overseer --seat supervisor --stand-down", "beat overseer --seat supervisor --new-arm"],
161
+ "--status": "Legacy spelling of beat status <tier>: the same read-only result, writing nothing.",
162
+ }, examples: [
163
+ "beat start overseer --seat supervisor", "beat status overseer --seat supervisor", "beat stop overseer --seat supervisor",
164
+ "beat overseer --seat supervisor", "beat overseer --seat supervisor --loop", "beat overseer --seat supervisor --stand-down",
165
+ "beat overseer --seat supervisor --new-arm", "beat overseer --status",
166
+ ],
162
167
  },
163
168
  version: {
164
169
  usage: "version [--dist]", description: "Print the installed package version. Top-level aliases: --version and -v.",
@@ -45,7 +45,7 @@ export declare const COMMANDS: {
45
45
  eval: typeof evalCommand;
46
46
  };
47
47
  export type RegisteredCommand = keyof typeof COMMANDS;
48
- export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix (--fix writes the test-runner ignore when a safe edit exists)\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> preview locally with --preview; draft after confirmation or --yes (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n eval discover and validate fixtures, seed isolated temp repos, then clean them up\n run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)\n status live run state (--watch --events: JSON documents on stdout, keepalives on stderr; 2>&1 corrupts the stream)\n stats all-run channel delivery, red, rescue, author and reviewer statistics\n verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD \u2014 verdict/JSON on stdout, progress on stderr; 2>&1 corrupts the verdict stream (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md writes Markdown to stdout; redirect to save a record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open Home, Run or Evidence (--view home|run|evidence; --setup <id> opens Run Parks)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n beat <tier> record one supervision beat for orchestrator|orchestrator-context|overseer|overseer-context|watch, --seat <identity> required (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop\n approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume\n version print the installed version (--dist adds build location and fingerprint)\n\nUse tickmarkr <command> --help (or -h) for all options and examples.\nUse tickmarkr profile <operation> --help for nested profile help.";
48
+ export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix (--fix writes the test-runner ignore when a safe edit exists)\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> preview locally with --preview; draft after confirmation or --yes (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n eval discover and validate fixtures, seed isolated temp repos, then clean them up\n run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)\n status live run state (--watch --events: JSON documents on stdout, keepalives on stderr; 2>&1 corrupts the stream)\n stats all-run channel delivery, red, rescue, author and reviewer statistics\n verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD \u2014 verdict/JSON on stdout, progress on stderr; 2>&1 corrupts the verdict stream (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md writes Markdown to stdout; redirect to save a record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open Home, Run or Evidence (--view home|run|evidence; --setup <id> opens Run Parks)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n beat <tier> supervision beats for orchestrator|orchestrator-context|overseer|overseer-context|watch, --seat <identity> required: beat start|stop|status <tier> owns one detached loop per tier (status is read-only); the legacy beat <tier> records one beat (--stand-down to hand off), and status reads the tier STALE once the beats stop\n approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume\n version print the installed version (--dist adds build location and fingerprint)\n\nUse tickmarkr <command> --help (or -h) for all options and examples.\nUse tickmarkr profile <operation> --help for nested profile help.";
49
49
  export declare function dispatch(cmd: string | undefined, argv: string[], commands?: CommandMap): Promise<{
50
50
  out: string;
51
51
  code: number;
package/dist/cli/index.js CHANGED
@@ -46,7 +46,7 @@ usage: tickmarkr <command>
46
46
  profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)
47
47
  ui open Home, Run or Evidence (--view home|run|evidence; --setup <id> opens Run Parks)
48
48
  unlock remove a stale/garbage run lock (refuses if the holder is alive)
49
- beat <tier> record one supervision beat for orchestrator|orchestrator-context|overseer|overseer-context|watch, --seat <identity> required (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop
49
+ beat <tier> supervision beats for orchestrator|orchestrator-context|overseer|overseer-context|watch, --seat <identity> required: beat start|stop|status <tier> owns one detached loop per tier (status is read-only); the legacy beat <tier> records one beat (--stand-down to hand off), and status reads the tier STALE once the beats stop
50
50
  approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume
51
51
  version print the installed version (--dist adds build location and fingerprint)
52
52
 
@@ -21,6 +21,14 @@ type FleetOverlayWriteFields = {
21
21
  };
22
22
  universe?: FleetUniverseRow[];
23
23
  lowerMap?: Record<string, MapEntry>;
24
+ preservedAllow?: {
25
+ adapters?: string[];
26
+ models?: string[];
27
+ };
28
+ lowerAllow?: {
29
+ adapters?: string[];
30
+ models?: string[];
31
+ };
24
32
  };
25
33
  type EffortFree = FleetEditable & {
26
34
  efforts?: undefined;
@@ -40,6 +48,12 @@ export declare function fleetFirstTouchProvenance(note: string, firstTouch: Flee
40
48
  /** Apply only fields fleet authored to the parsed YAML document. Untouched nodes retain their
41
49
  * keys, ordering, scalar style, and comments; fresh provenance is written directly on its tier node. */
42
50
  export declare function renderFleetOverlayWrite(priorBytes: string, write: FleetOverlayWrite): string;
51
+ /** T10: the 1-based line of the key that holds `path` in these overlay bytes — the path's own key, the
52
+ * key whose `null` masks it from above, or a `<<` merge key on it (D-987: the write there refuses) — else
53
+ * undefined (the overlay neither declares nor masks it). */
54
+ export declare function overlayHoldingLine(bytes: string, path: readonly string[]): number | undefined;
55
+ /** T10: does `path` hold a mapping in these overlay bytes — neither absent nor a `null` mask? */
56
+ export declare function overlayHoldsMap(bytes: string, path: readonly string[]): boolean;
43
57
  /** Build the repo overlay fragment fleet would write for edits since session start. */
44
58
  export declare function fleetRepoOverlayFromDelta(initial: FleetEditable, edited: FleetEditable, existingRepo?: Record<string, unknown>, firstTouches?: Readonly<Record<string, FleetFirstTouch>>, universe?: FleetUniverseRow[]): Record<string, unknown>;
45
59
  export declare function repoOverlayYaml(overlay: Record<string, unknown>): string;
@@ -1,6 +1,6 @@
1
1
  // Fleet-overlay mutation, serialization, and diff rendering for the `tickmarkr fleet` write path.
2
- import { isAlias, isMap, isNode, isScalar, isSeq, parseDocument, stringify, visit } from "yaml";
3
- import { DENY_SCOPES, universeCovers, universeEntryMatches } from "./config.js";
2
+ import { isAlias, isCollection, isMap, isNode, isScalar, isSeq, parseDocument, stringify, visit } from "yaml";
3
+ import { ConfigError, DENY_SCOPES, universeCovers, universeEntryMatches } from "./config.js";
4
4
  // OBS-1099 add.1: the writer ranges over the schema-derived scopes. The flat (all-seats) scopes
5
5
  // take part in the membership/allow-form write; every nested scope (routing.deny.workers.*) is a
6
6
  // literal deny list written on its own. ponytail: "flat" = directly under routing.deny. Read
@@ -64,9 +64,12 @@ function residualDeny(universe, entries) {
64
64
  // list keeps its node. A staged entry the allow form cannot express as a membership key (outside the
65
65
  // probe universe, or a bare-model/identity spelling) is written verbatim too; canonical keys the
66
66
  // session added ride the allow form alone.
67
+ // T10 (D-973): read as the loader reads these bytes (readYaml parses with yaml's defaults, as toJS does
68
+ // here) — aliases at the leaf, its items and every ancestor resolve, and `<<` merges only in a document
69
+ // whose YAML version merges — so an aliased list's entries are authored entries too.
67
70
  function authoredEntries(doc, path) {
68
- const node = doc.getIn(path, true);
69
- return isSeq(node) ? node.items.flatMap((item) => (isScalar(item) ? [String(item.value)] : [])) : [];
71
+ const value = path.reduce((node, key) => (node !== null && typeof node === "object" ? node[key] : undefined), doc.toJS());
72
+ return Array.isArray(value) ? value.flatMap((item) => (item === null || typeof item !== "object" ? [String(item)] : [])) : [];
70
73
  }
71
74
  function flatDenyAfterWrite(doc, path, after, universe) {
72
75
  const authored = authoredEntries(doc, path);
@@ -112,8 +115,10 @@ function unpackFleetProvenance(provenance) {
112
115
  }
113
116
  function setScalarPreservingComment(doc, path, value, authoredComment) {
114
117
  const existing = doc.getIn(path, true);
115
- if (isScalar(existing))
118
+ if (isScalar(existing)) {
119
+ releaseAnchors(doc, path);
116
120
  existing.value = value;
121
+ }
117
122
  else
118
123
  setAt(doc, path, doc.createNode(value));
119
124
  if (authoredComment !== undefined) {
@@ -146,6 +151,10 @@ function setStringSequencePreservingComments(doc, path, values) {
146
151
  setAt(doc, path, doc.createNode(values));
147
152
  return;
148
153
  }
154
+ // T10 owed review F2: an unchanged list is not edited, so its anchor and every alias keep their bytes
155
+ if (existing.items.length === values.length && existing.items.every((item, at) => isScalar(item) && String(item.value) === values[at]))
156
+ return;
157
+ releaseAnchors(doc, path); // an anchored list is edited in place: its other aliases keep the original
149
158
  const available = [...existing.items];
150
159
  existing.items = values.map((value) => {
151
160
  const at = available.findIndex((item) => isScalar(item) && String(item.value) === value);
@@ -175,6 +184,30 @@ function materializeAliases(doc, path) {
175
184
  doc.setIn(path.slice(0, depth), detachedCopy(doc, node, target, new Set()));
176
185
  }
177
186
  }
187
+ // T10 (D-973): a write mutates every ancestor on its path and edits or replaces the leaf with all it
188
+ // holds; any of those nodes may be an anchor other aliases share. Each such alias first becomes a
189
+ // detached copy of what it resolved to, so the write never changes another consumer's value nor
190
+ // leaves its alias dangling.
191
+ function releaseAnchors(doc, path) {
192
+ const held = new Set();
193
+ for (let depth = 1; depth <= path.length; depth++) {
194
+ const node = doc.getIn(path.slice(0, depth), true);
195
+ if (!isNode(node))
196
+ break;
197
+ for (const inner of depth < path.length ? [node] : nodesOf(node)) {
198
+ if (!isAlias(inner) && inner.anchor)
199
+ held.add(inner);
200
+ }
201
+ }
202
+ if (!held.size)
203
+ return;
204
+ visit(doc, {
205
+ Alias: (_key, alias) => {
206
+ const target = alias.resolve(doc);
207
+ return target !== undefined && held.has(target) ? detachedCopy(doc, alias, target, new Set()) : undefined;
208
+ },
209
+ });
210
+ }
178
211
  const nodesOf = (root) => {
179
212
  const out = [];
180
213
  visit(root, (_key, node) => {
@@ -231,26 +264,56 @@ function deleteAt(doc, path) {
231
264
  return;
232
265
  }
233
266
  // deleteIn throws on an empty document — only delete keys that exist.
234
- if (doc.getIn(path) !== undefined)
235
- doc.deleteIn(path);
267
+ if (doc.getIn(path) === undefined)
268
+ return;
269
+ releaseAnchors(doc, path);
270
+ doc.deleteIn(path);
236
271
  }
237
272
  function setAt(doc, path, value) {
238
273
  materializeAliases(doc, path); // edit a copy of an aliased mapping, never drop what it resolved to
274
+ releaseAnchors(doc, path);
239
275
  for (let depth = 1; depth < path.length; depth++) {
240
276
  const node = doc.getIn(path.slice(0, depth), true);
241
277
  if (node === undefined)
242
278
  break;
243
279
  if (!isMap(node)) {
244
- doc.deleteIn(path.slice(0, depth));
280
+ const prefix = path.slice(0, depth);
281
+ doc.deleteIn(prefix);
282
+ // T10: a null mask over membership lists stays a mask for every deny leaf — and the routing.allow
283
+ // block — this write does not set, so materializing one leaf never resurrects a lower layer's
284
+ // other deny lists or its allow form (judge/review/consult reach)
285
+ if (isScalar(node) && node.value === null) {
286
+ const under = (inner, outer) => outer.every((key, at) => inner[at] === key);
287
+ for (const masked of [...DENY_SCOPES.map((scope) => scope.path), ["routing", "allow"]]) {
288
+ if (under(masked, prefix) && !under(masked, path) && !under(path, masked))
289
+ doc.setIn(masked, doc.createNode(null));
290
+ }
291
+ }
245
292
  break;
246
293
  }
247
294
  }
248
295
  doc.setIn(path, value);
249
296
  }
297
+ // T10 (D-987): Fleet writes through no YAML merge key — a membership write whose edited path (the edited
298
+ // leaf or any map above it) takes keys through `<<` is refused, fail-closed, so nothing is published.
299
+ function refuseMerge(doc, priorBytes, path) {
300
+ let node = doc.contents;
301
+ for (let depth = 0; depth <= path.length && isMap(node); depth++) {
302
+ const merge = node.items.find((item) => isScalar(item.key) && typeof item.key.value === "symbol");
303
+ if (merge) {
304
+ const range = isNode(merge.value) ? merge.value.range : undefined;
305
+ const shape = range ? priorBytes.slice(range[0], range[1]).trim().replace(/\s+/g, " ") : "…";
306
+ throw new ConfigError(`${path.join(".")} takes keys through the YAML merge key \`<<: ${shape}\` in ${path.slice(0, depth).join(".") || "the document root"} — Fleet does not write through merge keys; inline the merged keys there by hand, then save again`);
307
+ }
308
+ node = depth < path.length ? node.get(path[depth], true) : undefined;
309
+ if (isAlias(node))
310
+ node = node.resolve(doc);
311
+ }
312
+ }
250
313
  function deleteEmptyMap(doc, path) {
251
314
  const node = doc.getIn(path, true);
252
315
  if (isMap(node) && node.items.length === 0)
253
- doc.deleteIn(path);
316
+ deleteAt(doc, path);
254
317
  }
255
318
  /** Apply only fields fleet authored to the parsed YAML document. Untouched nodes retain their
256
319
  * keys, ordering, scalar style, and comments; fresh provenance is written directly on its tier node. */
@@ -266,24 +329,49 @@ export function renderFleetOverlayWrite(priorBytes, write) {
266
329
  const flatChanged = new Map(FLAT_DENY_SCOPES.map((scope) => [scope.key, changed(...listsOf(write, scope))]));
267
330
  if ([...flatChanged.values()].some(Boolean) || changed(initial.allowOut, edited.allowOut)) {
268
331
  if (write.universe) {
332
+ refuseMerge(doc, priorBytes, ["routing", "allow"]);
269
333
  // Membership write: the allow form IS the fleet; deny adapters/models scopes are tombstoned
270
334
  // so a lower layer can never re-exclude behind the operator's back (workers untouched).
271
335
  const form = allowFormFromExclusions(write.universe, edited);
336
+ const universe = write.universe;
337
+ const known = (entry) => universe.some((row) => entry === row.adapter || row.models.some((m) => universeEntryMatches(row, m, entry)));
338
+ const retainedAll = [];
339
+ for (const leaf of ["adapters", "models"]) {
340
+ const retained = [...(write.preservedAllow?.[leaf] ?? []), ...authoredEntries(doc, ["routing", "allow", leaf])]
341
+ .filter((entry) => !known(entry));
342
+ if (!retained.length)
343
+ continue;
344
+ retainedAll.push(...retained);
345
+ form[leaf] = sortedUnique([...form[leaf], ...retained]);
346
+ form.excluded = true;
347
+ }
348
+ // T10 review: a retained allow entry admits a channel the allow form cannot see, so a staged flat
349
+ // deny covering it stays in deny verbatim — folded into the allow form alone, the retained entry
350
+ // would re-admit what the operator just excluded (rail out · all over an unprobed fake:U).
351
+ // Either allow leaf admits a bare model (routing matches both leaves alike), so coverage never
352
+ // depends on which leaf retained the entry.
353
+ // ponytail: a bare retained entry's adapter is unknown, so any discovered adapter id covers it;
354
+ // an identity alias of an unprobed channel is unknowable and is matched by spelling only.
355
+ const coversRetained = (entry) => retainedAll.some((kept) => {
356
+ const colon = kept.indexOf(":");
357
+ if (colon !== -1) {
358
+ const model = kept.slice(colon + 1);
359
+ return universeEntryMatches({ adapter: kept.slice(0, colon), models: [model] }, model, entry);
360
+ }
361
+ return entry === kept || universe.some((row) => row.adapter === entry);
362
+ });
272
363
  if (form.excluded) {
273
364
  const allowNode = doc.getIn(["routing", "allow"], true);
274
365
  if (allowNode !== undefined && !isMap(allowNode))
275
- doc.deleteIn(["routing", "allow"]);
276
- if (form.adapters.length) {
277
- setStringSequencePreservingComments(doc, ["routing", "allow", "adapters"], form.adapters);
278
- }
279
- else {
280
- deleteAt(doc, ["routing", "allow", "adapters"]);
281
- }
282
- if (form.models.length) {
283
- setStringSequencePreservingComments(doc, ["routing", "allow", "models"], form.models);
284
- }
285
- else {
286
- deleteAt(doc, ["routing", "allow", "models"]);
366
+ deleteAt(doc, ["routing", "allow"]);
367
+ for (const leaf of ["adapters", "models"]) {
368
+ const path = ["routing", "allow", leaf];
369
+ if (form[leaf].length)
370
+ setStringSequencePreservingComments(doc, path, form[leaf]);
371
+ else if (write.lowerAllow?.[leaf] != null)
372
+ setStringSequencePreservingComments(doc, path, null);
373
+ else
374
+ deleteAt(doc, path);
287
375
  }
288
376
  // Whole fleet out: allow stays present but empty — fail-closed, nothing admitted.
289
377
  if (doc.getIn(["routing", "allow"]) === undefined) {
@@ -291,8 +379,12 @@ export function renderFleetOverlayWrite(priorBytes, write) {
291
379
  }
292
380
  }
293
381
  else {
294
- // Whole fleet in: no restriction to express — the allow block goes away entirely.
295
- deleteAt(doc, ["routing", "allow"]);
382
+ // Whole fleet in: no restriction to express — the allow block goes away entirely, or is
383
+ // masked when a lower layer's allow would otherwise come back (T10 A/B resurrection).
384
+ if (write.lowerAllow)
385
+ setAt(doc, ["routing", "allow"], doc.createNode(null));
386
+ else
387
+ deleteAt(doc, ["routing", "allow"]);
296
388
  }
297
389
  // Authored and non-canonical entries stay in deny (LEG2-T3 finding 4, round 2 finding 2).
298
390
  // OBS-1046: only an EDITED scope is rewritten, and only when its bytes must change — an
@@ -313,7 +405,8 @@ export function renderFleetOverlayWrite(priorBytes, write) {
313
405
  const stale = authored.some((entry) => admitted.includes(entry));
314
406
  if (!touched && !stale)
315
407
  continue;
316
- const remaining = flatDenyAfterWrite(doc, path, after, write.universe);
408
+ refuseMerge(doc, priorBytes, path);
409
+ const remaining = [...new Set([...flatDenyAfterWrite(doc, path, after, write.universe), ...after.filter(coversRetained)])];
317
410
  if (remaining.length) {
318
411
  if (remaining.join("\n") !== authored.join("\n"))
319
412
  setStringSequencePreservingComments(doc, path, remaining);
@@ -327,6 +420,7 @@ export function renderFleetOverlayWrite(priorBytes, write) {
327
420
  for (const scope of FLAT_DENY_SCOPES) {
328
421
  if (!flatChanged.get(scope.key))
329
422
  continue;
423
+ refuseMerge(doc, priorBytes, scope.path);
330
424
  const after = edited[scope.key] ?? [];
331
425
  setStringSequencePreservingComments(doc, scope.path, after.length ? sortedUnique(after) : null);
332
426
  }
@@ -341,6 +435,7 @@ export function renderFleetOverlayWrite(priorBytes, write) {
341
435
  const [before, after] = listsOf(write, scope);
342
436
  if (!changed(before, after))
343
437
  continue;
438
+ refuseMerge(doc, priorBytes, scope.path);
344
439
  setStringSequencePreservingComments(doc, scope.path, after.length ? sortedUnique(after) : null);
345
440
  }
346
441
  for (const shape of new Set([...Object.keys(initial.map), ...Object.keys(edited.map)])) {
@@ -530,8 +625,9 @@ export function renderFleetOverlayWrite(priorBytes, write) {
530
625
  // byte-identical to yaml's own stringifyComment. OBS-1046: a scalar parsed from the prior bytes
531
626
  // keeps the exact whitespace it had before its hash sign (yaml itself emits one space, so the
532
627
  // sentinel carries the rest); only a comment fleet authored gets the two-space style.
628
+ // T10 (D-936): a flow collection's trailing note (`[codex] # c`) is inline too, so it keeps its gap.
533
629
  visit(doc, (_key, node) => {
534
- if (isScalar(node) && typeof node.comment === "string" && !node.comment.includes("\n")) {
630
+ if ((isScalar(node) || (isCollection(node) && node.flow)) && typeof node.comment === "string" && !node.comment.includes("\n")) {
535
631
  const tail = node.range ? priorBytes.slice(node.range[1], node.range[2]) : "";
536
632
  const gap = /^([ \t]+)#/.exec(tail)?.[1] ?? " ";
537
633
  node.comment = `${INLINE_COMMENT_SENTINEL}${gap.slice(1)}#${node.comment}`;
@@ -547,6 +643,39 @@ export function renderFleetOverlayWrite(priorBytes, write) {
547
643
  flowCollectionPadding: false,
548
644
  });
549
645
  }
646
+ /** T10: the 1-based line of the key that holds `path` in these overlay bytes — the path's own key, the
647
+ * key whose `null` masks it from above, or a `<<` merge key on it (D-987: the write there refuses) — else
648
+ * undefined (the overlay neither declares nor masks it). */
649
+ export function overlayHoldingLine(bytes, path) {
650
+ const doc = parseDocument(bytes);
651
+ if (doc.errors.length)
652
+ return undefined;
653
+ const lineOf = (offset) => bytes.slice(0, offset).split("\n").length;
654
+ let node = doc.contents;
655
+ let line;
656
+ for (const key of path) {
657
+ if (isAlias(node))
658
+ node = node.resolve(doc);
659
+ if (line !== undefined && isScalar(node) && node.value === null)
660
+ return line; // a mask at or above
661
+ if (!isMap(node))
662
+ return undefined;
663
+ const merge = node.items.find((item) => isScalar(item.key) && typeof item.key.value === "symbol");
664
+ const pair = merge ?? node.items.find((item) => isScalar(item.key) && String(item.key.value) === key);
665
+ if (!pair || !isScalar(pair.key) || !pair.key.range)
666
+ return undefined;
667
+ line = lineOf(pair.key.range[0]);
668
+ if (merge)
669
+ return line;
670
+ node = pair.value;
671
+ }
672
+ return line;
673
+ }
674
+ /** T10: does `path` hold a mapping in these overlay bytes — neither absent nor a `null` mask? */
675
+ export function overlayHoldsMap(bytes, path) {
676
+ const doc = parseDocument(bytes);
677
+ return !doc.errors.length && isMap(doc.getIn(path, true));
678
+ }
550
679
  /** Build the repo overlay fragment fleet would write for edits since session start. */
551
680
  export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}, firstTouches = {}, universe) {
552
681
  if (fleetEditableEquals(initial, edited))
@@ -5,7 +5,7 @@ import { type ExecutorDriver, type FocusTarget, type FocusResult, type NotifyOpt
5
5
  /** The response families the ONE shared envelope parser serves. There is no second JSON seam. */
6
6
  export declare const ORCA_RESPONSE_FAMILIES: readonly ["status", "create", "list", "read", "send", "wait", "show", "close", "worktree-current", "worktree-set", "hooks-status", "split"];
7
7
  export type OrcaFamily = (typeof ORCA_RESPONSE_FAMILIES)[number];
8
- export declare const ORCA_FIXTURE_VERSION = "1.4.195";
8
+ export declare const ORCA_FIXTURE_VERSION = "1.4.218";
9
9
  export declare const ORCA_CLI_COMMAND_ENV = "ORCA_CLI_COMMAND";
10
10
  export declare const STALE_HANDLE_CODE = "terminal_handle_stale";
11
11
  export declare const TERMINAL_GONE_CODE = "terminal_gone";
@@ -176,6 +176,17 @@ export interface OrcaDriverOpts {
176
176
  probeStalenessMs?: number;
177
177
  launchingHandle?: string;
178
178
  }
179
+ /**
180
+ * F (D-832): what one proof page's OWN metadata says about the scrollback it was read from — the closed
181
+ * seam checkoutProven decides paging and absence by. `complete`: explicit `limited:false` and
182
+ * `truncated:false` with cursors that agree — nothing lies past this page. `partial`: explicit booleans,
183
+ * one of them true, cursors that agree — more scrollback exists. `unread`: everything else — a flag
184
+ * missing or not a boolean, a cursor that is not a string, `limited:false` with a next cursor short of
185
+ * the latest one (the contradictory "last" page), a blind stream page
186
+ * (isBlindStreamPage), or a source that is not the stream (`screen-unavailable`). Missing metadata is
187
+ * never inferred complete: its page's bytes still count as read evidence, but no absence rests on it.
188
+ */
189
+ export declare function classifyProofPage(term: Record<string, unknown>): "complete" | "partial" | "unread";
179
190
  export declare class OrcaDriver implements ExecutorDriver {
180
191
  id: string;
181
192
  interactive: boolean;
@@ -265,8 +276,11 @@ export declare class OrcaDriver implements ExecutorDriver {
265
276
  * and none carries a trace of the mark; `unread` — every other exit: PROOF_PAGES reached, a limited
266
277
  * read with no (or the same) next cursor or a truncated anchor without one, a read that threw, a read
267
278
  * the ceiling cut off or never let start (the anchor included), or a page that landed late (even one
268
- * carrying the right proof), a malformed page (its tail, cursors or paging flags), or a blind stream
269
- * page (isBlindStreamPage). Unread output is unknown — never an empty page, never absence.
279
+ * carrying the right proof), a malformed page (its tail or cursors), a blind stream page
280
+ * (isBlindStreamPage), or — F (D-832) — any page read whose paging metadata classifyProofPage cannot
281
+ * call complete or partial (flags missing, not boolean or contradicted; even when they were omitted,
282
+ * that page's bytes stay evidence and its matching proof still proves). Unread output is unknown —
283
+ * never an empty page, never absence.
270
284
  * Without a deadline (reconcile, recovery) only `proven` is consumed.
271
285
  */
272
286
  private checkoutProven;