tickmarkr 2.5.8 → 2.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/adapters/qwen.js +30 -3
  2. package/dist/cli/commands/approve.js +15 -3
  3. package/dist/cli/commands/beat.d.ts +2 -0
  4. package/dist/cli/commands/beat.js +28 -29
  5. package/dist/cli/commands/compile.js +11 -0
  6. package/dist/cli/commands/fleet.js +61 -53
  7. package/dist/cli/commands/verify.d.ts +4 -1
  8. package/dist/cli/commands/verify.js +16 -5
  9. package/dist/cli/help.d.ts +2 -0
  10. package/dist/cli/help.js +6 -4
  11. package/dist/config/config.d.ts +41 -3
  12. package/dist/config/config.js +48 -17
  13. package/dist/config/fleet-overlay.js +47 -62
  14. package/dist/gates/baseline.d.ts +65 -0
  15. package/dist/gates/baseline.js +163 -9
  16. package/dist/gates/review.d.ts +6 -4
  17. package/dist/gates/review.js +62 -21
  18. package/dist/gates/run-gates.d.ts +4 -1
  19. package/dist/gates/run-gates.js +21 -11
  20. package/dist/gates/test-manifest.d.ts +11 -1
  21. package/dist/gates/test-manifest.js +41 -21
  22. package/dist/run/daemon.js +84 -41
  23. package/dist/run/journal.d.ts +17 -2
  24. package/dist/run/journal.js +99 -11
  25. package/dist/run/merge.d.ts +13 -2
  26. package/dist/run/merge.js +74 -12
  27. package/dist/run/protocol.d.ts +82 -0
  28. package/dist/run/protocol.js +35 -0
  29. package/dist/run/receipt-resolver.d.ts +18 -0
  30. package/dist/run/receipt-resolver.js +132 -0
  31. package/dist/run/supervision.d.ts +14 -1
  32. package/dist/run/supervision.js +122 -24
  33. package/dist/tui/cockpit/evidence-view.d.ts +10 -1
  34. package/dist/tui/cockpit/evidence-view.js +37 -5
  35. package/dist/tui/ink/fleet-app.d.ts +12 -22
  36. package/dist/tui/ink/fleet-app.js +520 -131
  37. package/package.json +1 -1
  38. package/skills/tickmarkr-overseer/SKILL.md +61 -36
  39. package/skills/tickmarkr-overseer/scripts/watch-context.sh +14 -2
@@ -21,7 +21,7 @@ function decodeQwenEvents(events) {
21
21
  continue;
22
22
  if ("type" in event && event.type === "assistant" && "message" in event) {
23
23
  const message = event.message;
24
- if (message && typeof message === "object" && "content" in message && Array.isArray(message.content)) {
24
+ if (message && typeof message === "object" && (!("role" in message) || message.role === "assistant") && "content" in message && Array.isArray(message.content)) {
25
25
  for (const content of message.content) {
26
26
  if (!content || typeof content !== "object" || !(("type" in content) && content.type === "text"))
27
27
  continue;
@@ -64,7 +64,7 @@ function decodeQwenEvents(events) {
64
64
  failed = true;
65
65
  }
66
66
  }
67
- const assistantText = text.join("\n");
67
+ const assistantText = text.join("");
68
68
  const apiError = assistantText.match(/\[API Error:[^\n]*/)?.[0];
69
69
  if (apiError)
70
70
  failed = true;
@@ -95,6 +95,33 @@ function eventArray(raw) {
95
95
  }
96
96
  return undefined;
97
97
  }
98
+ // OBS-1085: a pane capture can begin inside an event's string. Do not repair that
99
+ // event or scan its prose for trailers. A candidate must decode all the way to
100
+ // the surviving array close: this prevents a nested object or an escaped tool
101
+ // echo from masquerading as a top-level event. Keep terminal failures, but only
102
+ // assistant events can contribute completion text. No partial tail is accepted.
103
+ function eventSuffix(raw) {
104
+ // Shell prompts and stderr after the stream may contain `]`; use the event
105
+ // array's object-plus-close boundary, just as eventArray does.
106
+ const close = raw.lastIndexOf("}]");
107
+ if (close === -1)
108
+ return undefined;
109
+ const end = close + 1;
110
+ const boundaries = /,\s*(?=\{)/g;
111
+ for (const boundary of raw.matchAll(boundaries)) {
112
+ const start = boundary.index + boundary[0].length;
113
+ if (start >= end)
114
+ break;
115
+ try {
116
+ const events = JSON.parse(`[${raw.slice(start, end + 1)}`);
117
+ if (!Array.isArray(events) || !events.every((event) => event !== null && typeof event === "object" && "type" in event && typeof event.type === "string"))
118
+ continue;
119
+ return events;
120
+ }
121
+ catch { /* still inside the clipped event; try the next boundary */ }
122
+ }
123
+ return undefined;
124
+ }
98
125
  // Decode qwen's JSON envelope before scanning only decoded assistant text for the worker trailer.
99
126
  export function parseQwenResult(raw, nonce) {
100
127
  const malformed = {
@@ -104,7 +131,7 @@ export function parseQwenResult(raw, nonce) {
104
131
  raw,
105
132
  cause: raw.trim() ? "malformed-verdict" : "empty-output",
106
133
  };
107
- const parsed = eventArray(raw);
134
+ const parsed = eventArray(raw) ?? eventSuffix(raw);
108
135
  if (!parsed)
109
136
  return malformed;
110
137
  const decoded = decodeQwenEvents(parsed);
@@ -88,10 +88,16 @@ export function permittedDecisionVerbs(park) {
88
88
  }
89
89
  if (park.kind === "infra")
90
90
  return ["approve", "recheck"];
91
+ // OBS-1084: an authoring park whose newest gate row is a red tool gate is a runner report (a load
92
+ // flake in an unowned suite), so the battery re-runs on the preserved ref with no worker.
93
+ if (park.kind === "authoring" && isToolGate(park.failedGate))
94
+ return ["approve", "recheck"];
91
95
  if (park.kind === "diff-cap")
92
96
  return ["recheck"]; // OBS-1007: a cap trip is re-gated under a raised cap, never re-bought
93
97
  return ["approve"];
94
98
  }
99
+ const TOOL_GATES = ["build", "test", "lint"];
100
+ const isToolGate = (gate) => gate !== undefined && TOOL_GATES.includes(gate);
95
101
  /** The release marker this command appends for a verb on a park — the fact a read-back must match. */
96
102
  export function releaseForDecision(verb, park) {
97
103
  if (verb === "waive")
@@ -170,7 +176,8 @@ export async function approve(argv, cwd = process.cwd()) {
170
176
  const capPark = park?.kind === ATTEMPT_CAP_RELEASE;
171
177
  const gateFailPark = park?.kind === "gate-fail";
172
178
  const infraPark = park?.kind === "infra" || park?.kind === "diff-cap";
173
- const failedGate = gateFailPark ? park?.failedGate : undefined;
179
+ const authoringRunnerPark = park?.kind === "authoring" && isToolGate(park.failedGate);
180
+ const failedGate = gateFailPark || authoringRunnerPark ? park?.failedGate : undefined;
174
181
  if ((park?.kind === "scope-request" && !decisions) || files !== undefined) {
175
182
  if (park?.kind !== "scope-request")
176
183
  throw new Error("--files requires a scope-request park");
@@ -203,6 +210,7 @@ export async function approve(argv, cwd = process.cwd()) {
203
210
  }
204
211
  journal.append("task-approved", taskId, {
205
212
  by, ...(reason ? { reason } : {}), via: "cli", release: "scope-request",
213
+ ...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }), // OBS-1083
206
214
  amendment: { from, to: graphDefinitionHash(amended), beforeFiles: task.files, files: amendedFiles, parkLine: park.line, definition: taskDefinitionFingerprint(task) },
207
215
  });
208
216
  // Do not write graph.json from this process while the daemon owns it: its sweep materializes
@@ -231,14 +239,18 @@ export async function approve(argv, cwd = process.cwd()) {
231
239
  return disposition(cwd, runId, "fund-fixed-attempt", `upheld the reviewer for ${taskId} in ${runId} — by ${by}`, serialization.contended);
232
240
  }
233
241
  if (recheck) {
234
- if ((!gateFailPark || !failedGate) && !infraPark) {
235
- throw new Error(`--recheck applies to a gate-fail, infra or diff-cap park; ${taskId}'s newest park is ${String(lastHuman?.data.kind ?? "none")} with failed gate ${failedGate ?? "none"} — refusing`);
242
+ if ((!gateFailPark || !failedGate) && !infraPark && !authoringRunnerPark) {
243
+ throw new Error(`--recheck applies to a gate-fail, infra, diff-cap or red-tool-gate authoring park; ${taskId}'s newest park is ${String(lastHuman?.data.kind ?? "none")} with failed gate ${failedGate ?? "none"} — refusing`);
236
244
  }
237
245
  journal.append("task-approved", taskId, {
238
246
  by,
239
247
  ...(reason ? { reason } : {}),
240
248
  via: "cli",
241
249
  release: RECHECK_RELEASE,
250
+ // OBS-1084: failedGate names the runner report for every recheck. Authoring rechecks additionally
251
+ // carry gate so their durable release row identifies the red tool gate that made recheck admissible.
252
+ ...(failedGate ? { failedGate } : {}),
253
+ ...(authoringRunnerPark && failedGate ? { gate: failedGate } : {}),
242
254
  ...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
243
255
  });
244
256
  return disposition(cwd, runId, "re-dispatch", `re-checking ${taskId} in ${runId} — by ${by}; ${failedGate ? `failed gate ${failedGate}` : `${park.kind} park`}; no gate marked satisfied`, serialization.contended);
@@ -1 +1,3 @@
1
+ import { type SupervisionTier } from "../../run/supervision.js";
1
2
  export declare function beat(argv: string[], cwd?: string): Promise<string>;
3
+ export declare function standDownTier(repoRoot: string, tier: SupervisionTier, seat: string): string;
@@ -1,23 +1,13 @@
1
- import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync, renameSync, writeFileSync } from "node:fs";
2
3
  import { dirname } from "node:path";
3
4
  import { tickmarkrDir } from "../../graph/graph.js";
4
- import { SUPERVISION_BEAT_MS, SUPERVISION_DEFAULT_THRESHOLD_PCT, SUPERVISION_STALE_MS, SUPERVISION_TIERS, beatSupervision, resolveSupervisionRoot, supervisionStandDownPath, } from "../../run/supervision.js";
5
- // SUP-04: the writer side of supervision, as a VERB. `beatSupervision` and `SUPERVISION_BEAT_MS` shipped
6
- // with exactly one in-repo caller — the daemon, on one tier — so `status` printed
7
- // `orchestrator ARMED / overseer ABSENT / watch ABSENT` while a real overseer worked the run: two thirds
8
- // of a supervision claim were constants dressed as measurements. A seat that supervises from a shell
9
- // (the overseer skill's watcher loop) has no way into a node module; a command does.
10
- //
11
- // ONE-SHOT AND ERROR-PROPAGATING, deliberately not `armSupervision`. That helper is the right shape for
12
- // a long-lived node process and the wrong shape here twice over. Its recurring interval would outlive
13
- // the invocation — in a long-lived dispatcher a later `--stand-down` would disarm only its own timer
14
- // while an earlier one kept writing, flipping DISARMED back to ARMED ten seconds later: the exact
15
- // fail-open this instrument exists to catch. And it swallows write failures by design (an unwritten
16
- // beat ages out, which is the truth for a watcher that must not be crashed by its own instrument) —
17
- // but a CLI that swallows them PRINTS a state nothing recorded, so `beat` says ARMED while `status`
18
- // reads UNREADABLE. Here the writes are unguarded: a failure leaves the process with a non-zero exit
19
- // and a message instead of a claim. The LOOP belongs to the caller, which is what makes the beat
20
- // evidence: stop calling it and the tier ages into STALE on its own.
5
+ import { SUPERVISION_BEAT_MS, SUPERVISION_DEFAULT_THRESHOLD_PCT, SUPERVISION_STALE_MS, SUPERVISION_TIERS, beatSupervision, newSupervisionArm, resolveSupervisionRoot, supervisionStandDownPath, } from "../../run/supervision.js";
6
+ // One-shot ticks reuse a durable arm; only --new-arm (or --loop) acknowledges a stand-down.
7
+ // The loop runs in this process, so its recorded pid belongs to the actual repeating writer.
8
+ // Writes are unguarded and a refused ONE-SHOT tick THROWS: the shipped watcher gates on the exit
9
+ // code with stdout discarded, so a refusal that exits 0 leaves a live watcher believing it is armed
10
+ // while status reads DISARMED. Only the loop returns on stand-down — exiting is its designed end.
21
11
  const VALUE_OPTIONS = ["--seat", "--arm-id", "--pct", "--threshold-pct"];
22
12
  export async function beat(argv, cwd = process.cwd()) {
23
13
  const standDown = argv.includes("--stand-down");
@@ -28,11 +18,11 @@ export async function beat(argv, cwd = process.cwd()) {
28
18
  const named = argv.find((a, i) => !a.startsWith("--") && !VALUE_OPTIONS.includes(argv[i - 1] ?? ""));
29
19
  if (!isTier(named)) {
30
20
  throw new Error(`usage: tickmarkr beat <${SUPERVISION_TIERS.join("|")}> --seat <identity> ` +
31
- `[--arm-id <identity> --pct <0..100> --threshold-pct <0..100>] [--stand-down] — ` +
21
+ `[--new-arm] [--loop] [--arm-id <identity> --pct <0..100> --threshold-pct <0..100>] [--stand-down] — ` +
32
22
  `got ${named ? `\`${named}\`` : "no tier"}`);
33
23
  }
34
24
  // SUP-05: NO SEAT, NO BEAT — and the refusal comes before any write, so a refused invocation leaves
35
- // the tier exactly as it found it. This verb is one-shot: the exitedWriterPid it records has
25
+ // the tier exactly as it found it. In one-shot mode the exitedWriterPid it records has
36
26
  // already exited by the time anyone reads the record, so tier + writer + instant is a beat nobody can
37
27
  // attribute to a seat. Measured 2026-08-26: a consult seat ran the documented loop verbatim and the
38
28
  // board read that tier ARMED with no seat of that tier having armed anything, and a seatless ARMED
@@ -46,12 +36,21 @@ export async function beat(argv, cwd = process.cwd()) {
46
36
  const repoRoot = resolveSupervisionRoot(cwd);
47
37
  if (standDown)
48
38
  return standDownTier(repoRoot, named, seat);
49
- // Clear a stand-down marker left by an earlier session BEFORE beating: a valid marker outranks every
50
- // beat that does not strictly follow it, and two writes landing in the same millisecond do not. An
51
- // uncleared marker would render DISARMED while this verb claimed ARMED, so the removal is unguarded
52
- // too — `force` makes the ordinary "no marker" case a no-op, and anything else is a real failure.
53
- rmSync(supervisionStandDownPath(repoRoot, named), { force: true, recursive: true });
54
- beatSupervision(repoRoot, named, seat, pct === undefined ? undefined : { armId: armId ?? seat, pct, thresholdPct });
39
+ const loop = argv.includes("--loop");
40
+ const arm = argv.includes("--new-arm") || loop ? newSupervisionArm(repoRoot, named, armId) : undefined;
41
+ // Context arm ids retain their existing obligation semantics; they do not implicitly re-arm liveness.
42
+ const observation = pct === undefined ? undefined : { armId: armId ?? arm?.armId ?? seat, pct, thresholdPct };
43
+ do {
44
+ if (!beatSupervision(repoRoot, named, seat, observation, { arm, armId, loop })) {
45
+ const refusal = `${named} DISARMED — stood down; use --new-arm to resume beating`;
46
+ if (loop)
47
+ return refusal;
48
+ throw new Error(refusal);
49
+ }
50
+ if (!loop)
51
+ break;
52
+ await new Promise((resolve) => setTimeout(resolve, SUPERVISION_BEAT_MS));
53
+ } while (loop);
55
54
  return `${named} ARMED as ${seat} — beat again every ${SUPERVISION_BEAT_MS / 1_000}s; the tier reads STALE ${SUPERVISION_STALE_MS / 1_000}s after the last beat`;
56
55
  }
57
56
  /** `--seat <identity>` or `--seat=<identity>`; blank and missing are the same answer — none. */
@@ -84,15 +83,15 @@ function percentageOf(argv, name) {
84
83
  // so the tier reads DISARMED rather than ageing out as a death. Published atomically — written aside,
85
84
  // renamed over — because a torn marker is rejected by the reader, and a rejected stand-down reports a
86
85
  // deliberate hand-off as a death.
87
- function standDownTier(repoRoot, tier, seat) {
86
+ export function standDownTier(repoRoot, tier, seat) {
88
87
  tickmarkrDir(repoRoot); // the write path DOES create — markers land inside the gitignored state dir
89
88
  const p = supervisionStandDownPath(repoRoot, tier);
90
- const tmp = `${p}.${process.pid}.tmp`;
89
+ const tmp = `${p}.${randomUUID()}.tmp`;
91
90
  mkdirSync(dirname(p), { recursive: true });
92
91
  // The marker names the seat for the same reason the beat does: "someone stood this tier down" is not
93
92
  // a hand-off anyone can act on, and on a seat tier the reader rejects an anonymous one outright.
94
93
  writeFileSync(tmp, JSON.stringify({
95
- tier, seat, exitedWriterPid: process.pid, disarmedAt: new Date().toISOString(),
94
+ tier, seat, standDownId: randomUUID(), exitedWriterPid: process.pid, disarmedAt: new Date().toISOString(),
96
95
  }) + "\n");
97
96
  renameSync(tmp, p);
98
97
  return `${tier} DISARMED — ${seat} handed off; status reads it stood down, not dead`;
@@ -2,6 +2,8 @@ import { isAbsolute, join } from "node:path";
2
2
  import { parseArgs } from "node:util";
3
3
  import { collateralLints, sourceScopeFindings, sourceScopeLints, } from "../../compile/collateral.js";
4
4
  import { CompileError } from "../../compile/common.js";
5
+ import { loadConfig } from "../../config/config.js";
6
+ import { denyPreferCollisionLine, denyPreferCollisions } from "../../route/preference.js";
5
7
  import { compileSource } from "../../compile/index.js";
6
8
  import { retiredLiteralErrors } from "../../compile/retired-literals.js";
7
9
  import { clearCompileRefusal, saveCompileRefusal, saveGraph, stateDirName } from "../../graph/graph.js";
@@ -82,6 +84,15 @@ export async function compile(argv, cwd = process.cwd(), harnessFrom = process.a
82
84
  if (uncovered.length > 0) {
83
85
  throw new CompileError(`${src} has uncovered declared pin obligations:\n${uncovered.map((line) => ` - ${line}`).join("\n")}${diagnostics}`);
84
86
  }
87
+ // v2.5.9 T11 (OBS-1086): a shape whose pool the config denies in full is unroutable for every task
88
+ // that uses it; plan already said so, compile now refuses the seal before any state write so a dry
89
+ // run reaches the same refusal. Pool only: a partial deny leaves live members, and pin/prefer
90
+ // collisions on the default map stay doctor's and resume's business. Scoped to the graph's shapes.
91
+ const deadPools = denyPreferCollisions(loadConfig(cwd), g.tasks.map((task) => task.shape))
92
+ .filter((collision) => collision.kind === "pool");
93
+ if (deadPools.length > 0) {
94
+ throw new CompileError(`${src} routes a task shape to a fully denied pool:\n${deadPools.map((collision) => ` - ${denyPreferCollisionLine(collision)}`).join("\n")}${diagnostics}`);
95
+ }
85
96
  // One bounded read supplies both cross-run surfaces: unresolved findings below and merge facts for
86
97
  // the ancestry check. Neither fact mutates the compiled graph; status and every readiness predicate
87
98
  // remain the source compiler's answer.
@@ -5,7 +5,7 @@ import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, modelAuthE
5
5
  import { catalogModelAdvisory, catalogTierRanking, declaredModelWindow, fleetUnclassifiedModels } from "../../adapters/model-lints.js";
6
6
  import { CATALOG_REFRESH_TIMEOUT_MS, formatCatalogRefreshLegs, readCachedCatalog, refreshCatalogCommand } from "../../adapters/catalog-remote.js";
7
7
  import { CLAUDE_ALIAS_IDENTITY_STAMPS, readClaudeAliasIdentity } from "../../adapters/claude-code.js";
8
- import { fleetEditableFromConfig, fleetEditableEquals, formatFleetPrint, globalConfigDir, loadConfigWithMode, overlayBytesLoadError, readOverlayFile, renderFleetOverlayWrite, repoOverlayPath, ROUTING_MODES, TIER_RANK, unifiedYamlDiff, } from "../../config/config.js";
8
+ import { fleetEditableFromConfig, fleetEditableEquals, formatFleetPrint, globalConfigDir, DENY_SCOPES, initialDenyPropOf, loadConfigWithMode, overlayBytesLoadError, readOverlayFile, stagedDenyKeyOf, renderFleetOverlayWrite, repoOverlayPath, ROUTING_MODES, TIER_RANK, unifiedYamlDiff, } from "../../config/config.js";
9
9
  import { exclusionReason, projectFleetWhy, renderFleetWhy } from "../../config/fleet-why.js";
10
10
  import { SHAPES, TIERS } from "../../graph/schema.js";
11
11
  import { doctor } from "./doctor.js";
@@ -320,14 +320,12 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
320
320
  ]));
321
321
  const channels = discoverChannels(cfg, adapters, health);
322
322
  const profile = loadRoutingProfile(cwd, cfg, { preview: true });
323
+ const stagedDenyOf = (editable) => Object.fromEntries(DENY_SCOPES.map((scope) => [stagedDenyKeyOf(scope), editable[scope.key] ?? []]));
323
324
  const stagedEditable = (map, deny) => ({
324
325
  ...structuredClone(initial),
325
- denyAdapters: deny.adapters,
326
- denyModels: deny.models,
326
+ ...Object.fromEntries(DENY_SCOPES.map((scope) => [scope.key, deny[stagedDenyKeyOf(scope)]])),
327
327
  // OBS-1046: the allow complement is staged beside the deny lists, never folded into them
328
328
  allowOut: deny.allowOut ?? initial.allowOut,
329
- denyWorkersAdapters: deny.workersAdapters,
330
- denyWorkersModels: deny.workersModels,
331
329
  map,
332
330
  });
333
331
  const candidateMemo = new Map();
@@ -573,39 +571,57 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
573
571
  const floor = cfgPreview.routing.floors[shape];
574
572
  const collisions = denyPreferCollisions(cfgPreview, [shape]);
575
573
  const discovered = new Set(previewChannels.map((c) => `${c.adapter}:${c.model}`));
576
- // discovery already dropped a channel whose model probe failed — it still gets its own row
577
- const unauthedRows = [
578
- ...unauthedClis.map((adapter) => `${adapter} — CLI unauthed — re-probe with tickmarkr doctor`),
579
- ...unauthedModels.filter(({ key }) => !discovered.has(key))
580
- .map(({ key, reason }) => `${key.replace(":", "/")} — unauthed (${reason}) — re-probe with tickmarkr doctor`),
581
- ];
582
- return [...unauthedRows, ...previewChannels.flatMap((c) => {
583
- const key = `${c.adapter}:${c.model}`;
584
- const reasons = exclusionCollector(c, cfgPreview.routing, "worker").map(exclusionReason);
585
- if (health[c.adapter]?.authed === false)
586
- reasons.push(`${c.adapter} CLI unauthed — re-probe with tickmarkr doctor`);
587
- else if (health[c.adapter]?.modelAuth?.[c.model]?.authed === false)
588
- reasons.push("unauthed — re-probe with tickmarkr doctor");
589
- if (entry?.pin && key !== `${entry.pin.via}:${entry.pin.model}`) {
590
- reasons.push(`not routing.map.${shape}.pin (${entry.pin.via}:${entry.pin.model}) — not manageable here`);
591
- }
592
- if (entry?.pool && !entry.pool.channels.includes(key)) {
593
- reasons.push(`outside routing.map.${shape}.pool — not manageable here`);
594
- }
595
- if (floor && TIER_RANK[c.tier] < TIER_RANK[floor]) {
596
- reasons.push(`below routing.floors.${shape} (${floor}) — not manageable here`);
597
- }
598
- if (entry?.prefer?.some((p) => entryMatchesChannel(p, c))) {
599
- reasons.push(`task hint: routing.map.${shape}.prefer names it — not manageable here`);
600
- }
601
- for (const collision of collisions) {
602
- const named = collision.detail.split(" > ").some((p) => entryMatchesChannel(p, c));
603
- if (named || offered.size === 0)
604
- reasons.push(denyPreferCollisionLine(collision));
605
- }
606
- // labelled like the models view's row (adapter/model), so a ledger row never reads as a candidate
607
- return reasons.length ? [`${c.adapter}/${c.model} — ${reasons.join("; ")}`] : [];
608
- })];
574
+ // T2 review (D-225): discovery already dropped a channel whose model probe failed — it is
575
+ // rebuilt as a structured channel from its adapter's own declaration and walks the SAME
576
+ // reach + collector path as every other row, the auth reason appended LAST; never a bare string
577
+ const withIdentity = (adapter, declared) => {
578
+ const identity = health[adapter]?.modelAuth?.[declared.model]?.identity ?? health[adapter]?.modelIdentities?.[declared.model];
579
+ return identity ? { ...declared, identity } : declared;
580
+ };
581
+ const failedProbes = unauthedModels.filter(({ key }) => !discovered.has(key)).flatMap(({ key, adapter, reason }) => {
582
+ const declared = adapters.find((a) => a.id === adapter)?.channels(cfg).find((c) => `${c.adapter}:${c.model}` === key);
583
+ return declared ? [{ ...withIdentity(adapter, declared), authReason: `unauthed (${reason}) — re-probe with tickmarkr doctor` }] : [];
584
+ });
585
+ // T2 review round 6: an installed-but-unauthed CLI is dropped by discovery AND by the model
586
+ // probe list, so its configured channels are rebuilt from the adapter's own declaration too —
587
+ // each greys with its reach and covering entry, the CLI auth reason last; never one bare adapter line
588
+ const unauthedCliChannels = unauthedClis.flatMap((adapter) => (adapters.find((a) => a.id === adapter)?.channels(cfg) ?? [])
589
+ .map((declared) => ({ ...withIdentity(adapter, declared), authReason: `${adapter} CLI unauthed — re-probe with tickmarkr doctor` })));
590
+ // D-225: the ledger is the EXCLUDED half of a closed partition — a channel the picker offers
591
+ // never greys beside itself; its pin/pool/floor/prefer captions stay in excludedNote
592
+ const ledgerChannels = [...previewChannels.map((c) => ({ ...c, authReason: undefined })), ...failedProbes, ...unauthedCliChannels]
593
+ .filter((c) => !offered.has(`${c.adapter}:${c.model}`));
594
+ return ledgerChannels.flatMap((c) => {
595
+ const key = `${c.adapter}:${c.model}`;
596
+ const reasons = exclusionCollector(c, cfgPreview.routing, "worker").map(exclusionReason);
597
+ // OBS-1065: a greyed picker row carries its reach beside its reasons, like the models view
598
+ const allSeats = exclusionCollector(c, cfgPreview.routing, "judge");
599
+ const reach = allSeats.some((scope) => scope.by === "deny") ? "out all"
600
+ : allSeats.length ? "out allow" : reasons.length ? "out workers" : "in";
601
+ if (!c.authReason && health[c.adapter]?.modelAuth?.[c.model]?.authed === false)
602
+ reasons.push("unauthed — re-probe with tickmarkr doctor");
603
+ if (entry?.pin && key !== `${entry.pin.via}:${entry.pin.model}`) {
604
+ reasons.push(`not routing.map.${shape}.pin (${entry.pin.via}:${entry.pin.model}) — not manageable here`);
605
+ }
606
+ if (entry?.pool && !entry.pool.channels.includes(key)) {
607
+ reasons.push(`outside routing.map.${shape}.pool — not manageable here`);
608
+ }
609
+ if (floor && TIER_RANK[c.tier] < TIER_RANK[floor]) {
610
+ reasons.push(`below routing.floors.${shape} (${floor}) — not manageable here`);
611
+ }
612
+ if (entry?.prefer?.some((p) => entryMatchesChannel(p, c))) {
613
+ reasons.push(`task hint: routing.map.${shape}.prefer names it — not manageable here`);
614
+ }
615
+ for (const collision of collisions) {
616
+ const named = collision.detail.split(" > ").some((p) => entryMatchesChannel(p, c));
617
+ if (named || offered.size === 0)
618
+ reasons.push(denyPreferCollisionLine(collision));
619
+ }
620
+ if (c.authReason)
621
+ reasons.push(c.authReason);
622
+ // labelled like the models view's row (adapter/model), so a ledger row never reads as a candidate
623
+ return reasons.length ? [`${c.adapter}/${c.model} — reach: ${reach} — ${reasons.join("; ")}`] : [];
624
+ });
609
625
  };
610
626
  const candidatesForShape = (shape, mode, map, deny) => {
611
627
  const preview = previewCfg(mode, map, deny);
@@ -616,6 +632,8 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
616
632
  id: `${candidate.assignment.adapter}:${candidate.assignment.model}`,
617
633
  label: candidateRow(candidate, cfg.pricing),
618
634
  pin: { via: candidate.assignment.adapter, model: candidate.assignment.model },
635
+ // OBS-1065: a picker lift reads eligibility from here, not the ledger strings
636
+ belowFloor: candidate.belowFloor,
619
637
  }));
620
638
  const offered = new Set(rows.map((row) => row.id));
621
639
  const notes = [excludedNoteFor(cfgPreview), ...shapeExclusionCaptions(shape, map, cfgPreview)].filter((note) => note !== undefined);
@@ -644,12 +662,10 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
644
662
  let pendingWrite = null;
645
663
  const reviewOverlay = (state) => {
646
664
  const staged = structuredClone(initial);
647
- staged.denyAdapters = state.denyAdapters;
648
- staged.denyModels = state.denyModels;
665
+ // OBS-994/FL-1, OBS-1099 add.1: every schema-enumerated deny scope rides the same review/write funnel
666
+ for (const scope of DENY_SCOPES)
667
+ staged[scope.key] = state[scope.key];
649
668
  staged.allowOut = state.allowOut ?? initial.allowOut;
650
- // OBS-994/FL-1: the workers-only deny scope rides the same review/write funnel as the flat one.
651
- staged.denyWorkersAdapters = state.denyWorkersAdapters;
652
- staged.denyWorkersModels = state.denyWorkersModels;
653
669
  staged.map = state.map;
654
670
  const today = new Date().toISOString().slice(0, 10);
655
671
  for (const classification of state.classifications) {
@@ -700,11 +716,8 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
700
716
  ageMs: doctorAgeMs(cwd),
701
717
  adapters,
702
718
  health,
703
- initialDenyAdapters: editable.denyAdapters,
704
- initialDenyModels: editable.denyModels,
719
+ ...Object.fromEntries(DENY_SCOPES.map((scope) => [initialDenyPropOf(scope), editable[scope.key] ?? []])),
705
720
  initialAllowOut: editable.allowOut,
706
- initialDenyWorkersAdapters: editable.denyWorkersAdapters,
707
- initialDenyWorkersModels: editable.denyWorkersModels,
708
721
  modelGroups,
709
722
  initialMode: rm.mode.mode,
710
723
  modeOptions: ROUTING_MODES.map((mode) => ({ id: mode, gloss: MODE_GLOSS[mode] })),
@@ -768,11 +781,6 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
768
781
  return {
769
782
  props,
770
783
  commit,
771
- renderWhy: () => renderFleetWhy(projectedShapeRows(rm.mode.mode, editable.map, {
772
- adapters: editable.denyAdapters,
773
- models: editable.denyModels,
774
- workersAdapters: editable.denyWorkersAdapters ?? [],
775
- workersModels: editable.denyWorkersModels ?? [],
776
- })),
784
+ renderWhy: () => renderFleetWhy(projectedShapeRows(rm.mode.mode, editable.map, stagedDenyOf(editable))),
777
785
  };
778
786
  }
@@ -1,4 +1,5 @@
1
1
  import { type Assignment, type BillingChannel } from "../../adapters/types.js";
2
+ import { type GateEvidenceOptions } from "../../gates/baseline.js";
2
3
  import { type AcceptanceItem } from "../../graph/schema.js";
3
4
  export declare function parseCriteria(text: string): AcceptanceItem[];
4
5
  export declare const HUMAN_CHANNEL: BillingChannel;
@@ -10,7 +11,9 @@ export declare function verifyStateRoot(cwd: string): Promise<string>;
10
11
  export declare function baselineCachePath(cwd: string, baseSha: string, commands: Record<string, string>): string;
11
12
  export declare function excludeAuthorProvider(channels: BillingChannel[], author: BillingChannel): BillingChannel[];
12
13
  export declare const VERIFY_HELP = "usage: tickmarkr verify [--base <ref>] [--criteria <file> | --task <id>] [--json]\nThe final verdict and JSON result are written to stdout; progress and diagnostics are written to stderr.\nDo not merge stdout and stderr (for example with 2>&1): doing so corrupts the verdict stream.";
13
- export declare function verify(argv: string[], cwd?: string): Promise<{
14
+ export declare function verify(argv: string[], cwd?: string, options?: {
15
+ evidence?: GateEvidenceOptions;
16
+ }): Promise<{
14
17
  out: string;
15
18
  code: number;
16
19
  }>;
@@ -130,7 +130,7 @@ async function warnWideTaskRange(cwd, stateRoot, taskId, mergeBase) {
130
130
  export const VERIFY_HELP = `usage: tickmarkr verify [--base <ref>] [--criteria <file> | --task <id>] [--json]
131
131
  The final verdict and JSON result are written to stdout; progress and diagnostics are written to stderr.
132
132
  Do not merge stdout and stderr (for example with 2>&1): doing so corrupts the verdict stream.`;
133
- export async function verify(argv, cwd = process.cwd()) {
133
+ export async function verify(argv, cwd = process.cwd(), options = {}) {
134
134
  if (argv.some((arg) => arg === "--help" || arg === "-h"))
135
135
  return { out: VERIFY_HELP, code: 0 };
136
136
  const { values } = parseArgs({
@@ -328,6 +328,7 @@ export async function verify(argv, cwd = process.cwd()) {
328
328
  worktree: cwd, baseRef: mergeBase,
329
329
  result: { ok: true, summary: "standalone verify — no worker claims to trust", deviations: [], raw: "" },
330
330
  author, commands, baseline, channels, ...(judgeChannels ? { judgeChannels } : {}), adapters, cfg,
331
+ evidence: options.evidence,
331
332
  verificationScope: "standalone", artifactDir, stateDir: join(stateRoot, ".tickmarkr"),
332
333
  onGate: (e) => {
333
334
  if (e.phase === "start")
@@ -345,17 +346,27 @@ export async function verify(argv, cwd = process.cwd()) {
345
346
  return match ? [{ classification: match[1], note: match[2], reviewer: result.meta?.reviewer }] : [];
346
347
  }));
347
348
  const artifactPath = join(artifactDir, "verify-results.json");
348
- writeFileSync(artifactPath, JSON.stringify({ base: baseTip, head, mergeBase, green, gateRows: results, reviewFindings }, null, 2) + "\n");
349
- console.error(`verify: artifacts written to ${artifactPath}`);
349
+ const artifactBytes = Buffer.from(JSON.stringify({ base: baseTip, head, mergeBase, green, gateRows: results, reviewFindings }, null, 2) + "\n");
350
+ let artifactSha256 = null;
351
+ let artifactAvailability = "capture-failed";
352
+ try {
353
+ writeFileSync(artifactPath, artifactBytes, { flag: "wx" });
354
+ artifactSha256 = createHash("sha256").update(artifactBytes).digest("hex");
355
+ artifactAvailability = "available";
356
+ console.error(`verify: artifacts written to ${artifactPath}`);
357
+ }
358
+ catch {
359
+ console.error(`verify: evidence capture failed for ${artifactPath}`);
360
+ }
350
361
  const review = reviewRows.at(-1);
351
362
  if (recordJournal && review) {
352
363
  recordJournal.append("review-leg2", values.task ?? "VERIFY", {
353
- base: baseTip, head, mergeBase, author: channelKey(author), artifactPath,
364
+ base: baseTip, head, mergeBase, author: channelKey(author), artifactPath, artifactSha256, artifactAvailability,
354
365
  ...review,
355
366
  });
356
367
  }
357
368
  if (values.json) {
358
- return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, artifactPath, results }, null, 2), code: green ? 0 : 2 };
369
+ return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, artifactPath, artifactSha256, artifactAvailability, results }, null, 2), code: green ? 0 : 2 };
359
370
  }
360
371
  const lines = results.map((r) => `${r.pass ? "PASS" : "FAIL"} ${r.gate}\n${r.details.split("\n").map((l) => ` ${l}`).join("\n")}`);
361
372
  const verdict = green
@@ -176,6 +176,8 @@ export declare const COMMAND_HELP: {
176
176
  description: string;
177
177
  options: {
178
178
  "--seat <identity>": string;
179
+ "--new-arm": string;
180
+ "--loop": string;
179
181
  "--arm-id <identity>": string;
180
182
  "--pct <0..100>": string;
181
183
  "--threshold-pct <0..100>": string;
package/dist/cli/help.js CHANGED
@@ -140,14 +140,16 @@ export const COMMAND_HELP = {
140
140
  },
141
141
  beat: {
142
142
  usage: "beat <orchestrator|orchestrator-context|overseer|overseer-context|watch> --seat <identity> [options]",
143
- description: "Write one supervision beat. The supervising seat's watcher loop repeats it; stopped beats become STALE.",
143
+ description: "Write one supervision beat using the recorded arm. Stood-down arms stay DISARMED until explicitly re-armed; stopped beats become STALE.",
144
144
  options: {
145
145
  "--seat <identity>": "Required: identify the supervising pane or agent.",
146
- "--arm-id <identity>": "Identify this supervision arm.",
146
+ "--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).",
147
+ "--loop": "Create a new durable arm and beat in this process every 10 seconds; exit within one interval after stand-down.",
148
+ "--arm-id <identity>": "Name a newly created arm; with --pct, identify the context observation arm. Never refuses a tick.",
147
149
  "--pct <0..100>": "Report current context consumption percentage.",
148
150
  "--threshold-pct <0..100>": "Set the context warning threshold (default 75).",
149
- "--stand-down": "Record an explicit handoff and stand down this tier.",
150
- }, examples: ["beat overseer --seat supervisor", "beat overseer --seat supervisor --stand-down"],
151
+ "--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.",
152
+ }, examples: ["beat overseer --seat supervisor", "beat overseer --seat supervisor --loop", "beat overseer --seat supervisor --stand-down", "beat overseer --seat supervisor --new-arm"],
151
153
  },
152
154
  version: {
153
155
  usage: "version [--dist]", description: "Print the installed package version. Top-level aliases: --version and -v.",
@@ -62,6 +62,46 @@ export declare const TierEntrySchema: z.ZodObject<{
62
62
  windows: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
63
63
  }, z.core.$strip>;
64
64
  export type TierEntry = z.infer<typeof TierEntrySchema>;
65
+ export declare const DenyBlockSchema: z.ZodObject<{
66
+ adapters: z.ZodOptional<z.ZodArray<z.ZodString>>;
67
+ models: z.ZodOptional<z.ZodArray<z.ZodString>>;
68
+ workers: z.ZodOptional<z.ZodObject<{
69
+ adapters: z.ZodOptional<z.ZodArray<z.ZodString>>;
70
+ models: z.ZodOptional<z.ZodArray<z.ZodString>>;
71
+ }, z.core.$strip>>;
72
+ }, z.core.$strip>;
73
+ type DenyKeysOf<T, P extends string> = {
74
+ [K in keyof T & string]: NonNullable<T[K]> extends string[] ? `${P}${Capitalize<K>}` : DenyKeysOf<NonNullable<T[K]>, `${P}${Capitalize<K>}`>;
75
+ }[keyof T & string];
76
+ export type DenyScopeKey = DenyKeysOf<z.infer<typeof DenyBlockSchema>, "deny">;
77
+ export type DenyScope = {
78
+ /** the FleetEditable field: deny + the path segments capitalized (denyWorkersModels) */
79
+ key: DenyScopeKey;
80
+ /** the overlay path: ["routing", "deny", ...segments] */
81
+ path: string[];
82
+ /** the dotted config path the exclusion collector reports (routing.deny.workers.models) */
83
+ dotted: string;
84
+ };
85
+ export declare function denyScopesOf(schema: z.ZodObject<z.ZodRawShape>, segments?: string[]): Array<{
86
+ key: string;
87
+ path: string[];
88
+ dotted: string;
89
+ }>;
90
+ export declare const DENY_SCOPES: readonly DenyScope[];
91
+ /** the staged-set key the fleet browser uses for a scope: its key minus the `deny` prefix (denyWorkersAdapters → workersAdapters) */
92
+ export type StagedDenyKey = DenyScopeKey extends `deny${infer Rest}` ? Uncapitalize<Rest> : never;
93
+ export declare const stagedDenyKeyOf: (scope: DenyScope) => StagedDenyKey;
94
+ /** the FleetApp prop that seeds one scope's staged set (initialDenyWorkersAdapters). Lives here, not in
95
+ * fleet-app.tsx, so the fleet command needs no runtime import of the Ink module (the print path must
96
+ * never load Ink, and chalk reads FORCE_COLOR at import time). */
97
+ export type InitialDenyProps = {
98
+ [K in DenyScopeKey as `initial${Capitalize<K>}`]?: string[];
99
+ };
100
+ export declare const initialDenyPropOf: (scope: DenyScope) => keyof InitialDenyProps;
101
+ /** the authored list at one deny scope of a routing block, undefined when the scope is absent */
102
+ export declare function denyEntriesAt(routing: TickmarkrConfig["routing"], scope: DenyScope): string[] | undefined;
103
+ /** a routing.deny block built from per-scope lists (absent or empty lists are left out) */
104
+ export declare function denyBlockFrom(lists: Partial<Record<DenyScopeKey, string[]>>): NonNullable<TickmarkrConfig["routing"]["deny"]>;
65
105
  export declare const ModelPricingSchema: z.ZodObject<{
66
106
  inPerMtok: z.ZodNumber;
67
107
  outPerMtok: z.ZodNumber;
@@ -371,11 +411,9 @@ export type FleetTierAssignment = {
371
411
  tier: Tier;
372
412
  provenance?: string;
373
413
  };
374
- export type FleetEditable = {
414
+ export type FleetEditable = Partial<Record<DenyScopeKey, string[]>> & {
375
415
  denyAdapters: string[];
376
416
  denyModels: string[];
377
- denyWorkersAdapters?: string[];
378
- denyWorkersModels?: string[];
379
417
  allowOut?: string[];
380
418
  tiers: Record<string, Record<string, FleetTierAssignment | null>>;
381
419
  map: Record<string, MapEntry>;