tickmarkr 1.58.0 → 1.60.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.
package/README.md CHANGED
@@ -143,94 +143,34 @@ away from a failing adapter will never retry it in subsequent `tickmarkr resume`
143
143
 
144
144
  ## Choosing your fleet: `tickmarkr fleet`
145
145
 
146
- `tickmarkr doctor` is a pure sensor; `tickmarkr fleet` is the actuator. It walks six steps —
147
- probe data, agent CLIs, model tiers, routing mode, shape routing with a candidate picker, and
148
- steering preferences — and ends in a unified diff of your config overlay that is written **only
149
- on explicit confirm**. Pressing Enter through every step writes nothing. Assigning a tier to a
150
- model that has no classification yet requires a typed benchmark-provenance note, stored as a
151
- config comment beside the assignment.
152
-
153
- The shape routing step (step 5/6) features an **arrow-driven candidate picker** that ranks
154
- available channels by the production router's own logic, showing economics (tier + channel
155
- cost signals for API and sub channels) and the router's rationale for each rank. This replaces
156
- typed pin entry with verifiable suggestions. Step 4/6 shows an estimated spend context line
157
- across all shapes under each routing mode's resolved floors, also derived from the same preview
158
- routing machinery.
146
+ Configure which agent CLIs and models tickmarkr may route before your first run:
159
147
 
160
148
  ```bash
161
- tickmarkr fleet # interactive editor (requires a TTY)
162
- tickmarkr fleet --print # effective fleet state (repo > global > defaults), non-interactive good for CI drift checks
149
+ tickmarkr doctor # probe auth + capabilities (run after install or credential changes)
150
+ tickmarkr fleet # interactive editor (requires a TTY) — six steps, confirm to write
151
+ tickmarkr fleet --print # effective fleet state (repo > global > defaults), non-interactive
152
+ tickmarkr plan # lint the resolved routing table against your spec
153
+ tickmarkr run # dispatch with the fleet you confirmed
163
154
  ```
164
155
 
165
- Routing obeys a strict precedence **pin > prefer > floors > marginal-cost auto**:
156
+ `tickmarkr doctor` is a pure sensor; `tickmarkr fleet` is the actuator. The editor walks **six
157
+ steps** — probe data, agent CLIs, model tiers, routing mode, shape routing with a **candidate
158
+ picker**, and steering preferences — and ends in a unified diff of your repo config overlay.
159
+ Nothing is written until you confirm; pressing Enter through every step leaves config unchanged.
160
+ Step 5/6 uses an arrow-driven candidate picker ranked by the production router; step 3 may ask
161
+ for a benchmark-provenance note when you classify a new model.
166
162
 
167
- - **pin** a shape to an exact channel: `map: { plan: { via: claude-code, model: fable } }`
168
- - **prefer**-rank adapters per shape: `map: { implement: { prefer: [cursor-agent, codex] } }`
169
- - **floors** set the minimum capability band per shape (`migration: frontier`, `tests: cheap`);
170
- auto-routing then picks the cheapest authed channel at or above the floor
171
- - **deny/allow** bench models or whole adapters without touching tiers (see `deny.models` below)
172
- - **tiers** classify models into bands — only classified, doctor-authed models ever route
163
+ Routing-mode semantics, pin/prefer/floor precedence, review and consult steering syntax,
164
+ provenance rules, and `--quality` / `--mode` flags are documented in
165
+ **[FLEET.md](FLEET.md)** (advanced reference).
173
166
 
174
- More levers, all optional and absent-safe (absent config keeps default behavior):
167
+ ## Steering
175
168
 
176
- - `routing.explore` fence the exploration budget: `mode: off`, `excludeShapes`,
177
- `excludeComplexityAtOrAbove`, and a per-channel `cap`; `run --no-explore` disables
178
- exploration probes for a single run
179
- - `tiers.<adapter>.windows`declare context-window sizes per model; doctor grows a window
180
- column and `plan` warns (advisory, never blocking) when a task's payload estimate exceeds
181
- the routed model's window
182
- - `routing.sla` — per-shape latency expectations, surfaced as advisory plan lints against the
183
- learned performance profile
184
- - `run --quality` — compatibility alias for `run --mode partner-led` for that run; it disables
185
- exploration and records the selected mode in routing provenance
186
-
187
- ## Steering: routing modes, reviewer preferences, and reruns
188
-
189
- ### Routing modes
190
-
191
- `routing.mode` is a preset that compiles into floor assignments at config load time. The router never sees the mode itself; it receives resolved floors only.
192
-
193
- Three routing modes are available:
194
-
195
- - **`risk-based`** (default): byte-identical to pre-v1.51 routing. Absent `mode` key resolves as risk-based.
196
- - **`partner-led`**: resolves every non-overridden shape to a `frontier` floor and disables exploration — use when quality is paramount and cost is secondary.
197
- - **`staff-led`**: lowers each mode default by one tier (e.g., `implement` and `refactor` become `cheap` instead of `mid`) while keeping the preset floor for the integrity set (`plan`, `spec`, `migration`, `ui`) at `frontier`.
198
-
199
- Explicit `routing.floors` entries beat mode-preset deltas and are linted during plan if they shadow the mode's delta; an explicit integrity floor below `frontier` is also linted. The mode is compiled once at config load and never consulted during routing.
200
-
201
- ### Review preferences
202
-
203
- `review.prefer` is an ordered list of reviewer seats for the cross-vendor code-review gate. Entries are matched by diversity (never the same vendor or model as the original worker), and routing reorders the available channels only — it does not admit unauthed or denied channels.
204
-
205
- ```yaml
206
- review:
207
- prefer: [codex, kimi] # bare adapter: inherits model from the routed channel
208
- prefer: [codex:gpt-5.6-sol, kimi:kimi-code/k3] # adapter:model explicit
209
- prefer: [codex, kimi:kimi-code/k3] # mixed: bare and explicit
210
- ```
211
-
212
- **Grammar**: review prefer entries may name a bare adapter (inheriting the model from the current channel) or an explicit `adapter:model` pair. Bare adapters rank every diversity-eligible channel for that adapter; explicit pairs rank one diversity-eligible channel.
213
-
214
- ### Consult preferences
215
-
216
- `consult.prefer` is a ranked failover list of seats for escalations on deadlock or gate stalls. Unlike review, a consult seat has no channel to inherit a model from, so entries **must be explicit `adapter:model` pairs**.
217
-
218
- ```yaml
219
- consult:
220
- adapter: claude-code
221
- model: fable
222
- prefer: [codex:gpt-5.6-sol, kimi:kimi-code/k3] # adapter:model ONLY
223
- ```
224
-
225
- The daemon walks the preference list to the first live adapter, then the pinned `consult.adapter:model` pair as the final fallback. Failed or unparseable verdicts fall to the next entry.
226
-
227
- **Grammar**: consult prefer entries require `adapter:model` form — a bare adapter name is invalid and fails config load. Every entry must declare both the adapter and the model because a consult seat runs independently with no channel context.
228
-
229
- ### Rerun control: --supersedes
230
-
231
- `tickmarkr run --supersedes <prior-runId>` marks the current run as a rerun of a prior engagement. The current task graph is used for the rerun; compile it fresh first if the spec changed. The prior runId is recorded in the new journal, and the prior journal records the successor, for audit trails and change attribution.
232
-
233
- Use this when you modify the spec or worker logic and want to mark an intentional rerun while preserving the relationship in both run journals.
169
+ Fleet step 6/6 sets `review.prefer` and `consult.prefer`; routing modes (`risk-based`,
170
+ `partner-led`, `staff-led`) are chosen in step 4/6. Full grammar — including when review
171
+ prefer may name a **bare adapter** versus `adapter:model`, and why consult prefer entries
172
+ require **`adapter:model`** form plus `tickmarkr run --supersedes` rerun control, is in
173
+ **[FLEET.md](FLEET.md)**.
234
174
 
235
175
  ## Model scoping and auth detection
236
176
 
@@ -415,11 +355,18 @@ These are optional — the CLI works standalone. Skills are repo-scoped and ship
415
355
 
416
356
  ## Contributing
417
357
 
418
- See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, the green bar (build/test/lint), and
358
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, the green bar (build/test/lint), and
419
359
  design invariants. Pull requests welcome; non-trivial changes should include test coverage.
420
360
 
361
+ **Boundaries:** this repo is a squashed export of private development — each release is a verified
362
+ snapshot, not a live mirror of every commit. Before tickmarkr 2.0, minor versions may break with every
363
+ break noted in [CHANGELOG.md](CHANGELOG.md). Support is best effort for the latest version only;
364
+ accepted contributions are credited via `Co-authored-by:` on the release commit. Details in
365
+ [CONTRIBUTING.md](CONTRIBUTING.md).
366
+
421
367
  ## Documentation
422
368
 
369
+ - **[FLEET.md](FLEET.md)** — routing modes, steering syntax, tier provenance, and run flags (advanced reference)
423
370
  - **[LICENSE](LICENSE)** — MIT license
424
371
  - **[CONTRIBUTING.md](CONTRIBUTING.md)** — development setup and contribution guidelines
425
372
  - **[SECURITY.md](SECURITY.md)** — security policy and private vulnerability reporting
@@ -6,7 +6,7 @@ import { parseArgs } from "node:util";
6
6
  import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, readAutoPrefer } from "../../adapters/registry.js";
7
7
  import { fleetUnclassifiedModels } from "../../adapters/model-lints.js";
8
8
  import { GLYPHS, bold, legend as legendText, toggleActive, toggleInactive } from "../../brand.js";
9
- import { fleetEditableFromConfig, fleetEditableEquals, fleetRepoOverlayFromDelta, formatFleetPrint, globalConfigDir, overlayBytesLoadError, overlayPreferShapes, readOverlayFile, repoOverlayPath, repoOverlayYaml, ROUTING_MODES, unifiedYamlDiff, } from "../../config/config.js";
9
+ import { fleetEditableFromConfig, fleetEditableEquals, fleetRepoOverlayFromDelta, formatFleetPrint, globalConfigDir, harvestFleetProvenance, overlayBytesLoadError, overlayPreferShapes, readOverlayFile, repoOverlayPath, repoOverlayYaml, ROUTING_MODES, unifiedYamlDiff, } from "../../config/config.js";
10
10
  import { SHAPES, TIERS } from "../../graph/schema.js";
11
11
  import { candidateRow, costSignal, shapeCandidates } from "./fleet-picker.js";
12
12
  import { route } from "../../route/router.js";
@@ -16,6 +16,10 @@ const NON_TTY_MSG = "tickmarkr fleet: interactive fleet editor requires a TTY
16
16
  const QUIT = "fleet: quit without writing";
17
17
  const isDeniedAdapter = (id, editable) => editable.denyAdapters.includes(id);
18
18
  const isDeniedModel = (adapter, model, editable) => editable.denyModels.includes(`${adapter}:${model}`);
19
+ // v1.60 T3: every preview surface ranks with the SAME exploration setting as the candidate picker
20
+ // (rankCandidates routes noExplore so repeated calls agree) — a due probe must never make a
21
+ // step-4/5 row disagree with the picker's rank-1 for the same shape and channel set.
22
+ const PREVIEW_EXPLORE = { noExplore: true };
19
23
  function previewTask(shape) {
20
24
  return {
21
25
  id: "fleet-preview",
@@ -190,7 +194,10 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
190
194
  }
191
195
  const rm = resolveRunMode(cwd, { globalDir });
192
196
  const cfg = rm.cfg;
193
- const initial = fleetEditableFromConfig(cfg);
197
+ // OBS-88: harvest existing `# note` comments from the overlay bytes at session load — the
198
+ // session must know about every prior note, not only its own edits, or the next write strips them
199
+ const harvested = harvestFleetProvenance(currentRepoOverlayText(cwd));
200
+ const initial = fleetEditableFromConfig(cfg, harvested.tiers);
194
201
  const editable = structuredClone(initial);
195
202
  const health = cached;
196
203
  const term = openTerm(input, output);
@@ -290,16 +297,29 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
290
297
  }
291
298
  else if (k.name === "t" && rows[mCursor]) {
292
299
  if (rows[mCursor].kind === "classified") {
293
- return { out: "fleet: tier reassignment on classified models is not supported in v1 edit config directly", code: 1 };
300
+ // v1.60 T2: t on a classified row is a step-3 input mistake like any other the
301
+ // notice renders inline and the session (with every in-session edit) stays alive
302
+ term.frame([
303
+ ...listFrame(title, modelsLegend, renderRows(rows), mCursor),
304
+ "fleet: tier reassignment on classified models is not supported in v1 — edit config directly",
305
+ ]);
306
+ continue;
307
+ }
308
+ // v1.60 T2: an input mistake re-prompts the same field — a typo must never unwind
309
+ // the whole editor and discard every in-session edit before the review screen
310
+ let tier = (await term.askTyped(`tier (${TIERS.join("|")})> `));
311
+ while (!TIERS.includes(tier)) {
312
+ tier = (await term.askTyped(`fleet: invalid tier ${tier || "(empty)"} — tier (${TIERS.join("|")})> `));
313
+ }
314
+ let note = await term.askTyped("benchmark provenance note (required): ");
315
+ while (!note) {
316
+ note = await term.askTyped("fleet: a typed benchmark-provenance note is required — benchmark provenance note (required): ");
294
317
  }
295
- const tier = (await term.askTyped(`tier (${TIERS.join("|")})> `));
296
- if (!TIERS.includes(tier))
297
- return { out: `fleet: invalid tier ${tier}`, code: 1 };
298
- const note = await term.askTyped("benchmark provenance note (required): ");
299
- if (!note)
300
- return { out: "fleet: assigning a tier to an unclassified model requires a typed benchmark-provenance note", code: 1 };
301
318
  editable.tiers[a.id] ??= {};
302
- editable.tiers[a.id][rows[mCursor].model] = { tier, provenance: note };
319
+ // stamp at typing time — the serializer writes provenance verbatim, so harvested notes
320
+ // round-trip byte-for-byte instead of accreting a fresh date suffix every write (OBS-88)
321
+ const today = new Date().toISOString().slice(0, 10);
322
+ editable.tiers[a.id][rows[mCursor].model] = { tier, provenance: `${note} — fleet ${today}` };
303
323
  changed = true;
304
324
  }
305
325
  if (changed)
@@ -325,7 +345,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
325
345
  let apiUsd = 0;
326
346
  for (const shape of SHAPES) {
327
347
  try {
328
- const a = route(previewTask(shape), previewCfg(cand), channels, profile).assignment;
348
+ const a = route(previewTask(shape), previewCfg(cand), channels, profile, undefined, undefined, PREVIEW_EXPLORE).assignment;
329
349
  tierCount[a.tier] = (tierCount[a.tier] ?? 0) + 1;
330
350
  if (a.channel === "sub")
331
351
  subs += 1;
@@ -393,7 +413,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
393
413
  const shapeRows = () => SHAPES.map((shape) => {
394
414
  let now;
395
415
  try {
396
- const r = route(previewTask(shape), previewCfg(selectedMode), channels, profile);
416
+ const r = route(previewTask(shape), previewCfg(selectedMode), channels, profile, undefined, undefined, PREVIEW_EXPLORE);
397
417
  now = `${r.assignment.adapter}:${r.assignment.model} (${r.assignment.channel}, ${r.assignment.tier}) ${costSignal(r.assignment, cfg.pricing)}`;
398
418
  }
399
419
  catch (e) {
@@ -527,7 +547,14 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
527
547
  else
528
548
  delete merged[key];
529
549
  }
530
- const after = withModeLine(repoOverlayYaml(merged, provenanceMap(editable)), writeMode);
550
+ // OBS-88: session notes (which include the harvested ones attached at load) win per model;
551
+ // harvested notes alone cover entries with no editable seat — e.g. a null tombstone's comment
552
+ const tierNotes = structuredClone(harvested.tiers);
553
+ for (const [ad, ms] of Object.entries(provenanceMap(editable))) {
554
+ for (const [m, n] of Object.entries(ms))
555
+ (tierNotes[ad] ??= {})[m] = n;
556
+ }
557
+ const after = withModeLine(repoOverlayYaml(merged, tierNotes, { adapters: harvested.denyAdapters, models: harvested.denyModels }), writeMode);
531
558
  if ((!modeChanged && !steeringChanged && fleetEditableEquals(initial, editable)) || before === after) {
532
559
  return "fleet: no overlay changes (empty diff)";
533
560
  }
@@ -1,5 +1,6 @@
1
- import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
3
4
  import { createInterface } from "node:readline/promises";
4
5
  import { parseArgs } from "node:util";
5
6
  import { allAdapters, formatDoctorAgeForInit, formatDoctorReport, initDoctorReuse } from "../../adapters/registry.js";
@@ -61,7 +62,7 @@ function nextSteps(cwd, scaffoldedSpec) {
61
62
  return `next: edit ${scaffoldedSpec}, then tickmarkr compile ${scaffoldedSpec} && tickmarkr plan && tickmarkr run`;
62
63
  }
63
64
  const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
64
- const AGENT_SKILLS = ["tickmarkr-loop", "tickmarkr-auto"];
65
+ const AGENT_SKILLS = ["tickmarkr-loop", "tickmarkr-auto", "tickmarkr-overseer"];
65
66
  const DOCS_BEGIN = "<!-- tickmarkr:agent-docs begin -->";
66
67
  const DOCS_END = "<!-- tickmarkr:agent-docs end -->";
67
68
  const AGENT_DOCS = `${DOCS_BEGIN}
@@ -110,9 +111,16 @@ A run is green only when the run-end event exists in the journal AND tip verify
110
111
  When relaying missions between agents, never use bare send-text (\`herdr agent send\` / pane send-text) — it omits Enter. Use \`herdr pane run <pane> "<message>"\` or \`herdr notification show "<message>"\`. Confirm delivery by reading the target pane afterward; never report "relayed" without read-back.
111
112
  ${DOCS_END}
112
113
  `;
113
- const skillsRoot = (cwd) => existsSync(join(cwd, ".claude", "skills")) ? join(cwd, ".claude", "skills") : join(cwd, ".agents", "skills");
114
- const skillPath = (cwd, skill) => join(skillsRoot(cwd), skill, "SKILL.md");
115
- const skillsInstalled = (cwd) => AGENT_SKILLS.every((s) => existsSync(skillPath(cwd, s)));
114
+ // Every applicable host location gets the skills, each paired with its own repository guidance
115
+ // file: codex discovers .agents/skills + AGENTS.md (always applicable); claude discovers
116
+ // .claude/skills + CLAUDE.md (applicable when the repo already shows claude usage).
117
+ const hostTargets = (cwd) => {
118
+ const targets = [{ skillsDir: join(cwd, ".agents", "skills"), docPath: join(cwd, "AGENTS.md") }];
119
+ if (existsSync(join(cwd, ".claude")) || existsSync(join(cwd, "CLAUDE.md")))
120
+ targets.push({ skillsDir: join(cwd, ".claude", "skills"), docPath: join(cwd, "CLAUDE.md") });
121
+ return targets;
122
+ };
123
+ const skillsInstalled = (cwd) => hostTargets(cwd).every((t) => AGENT_SKILLS.every((s) => existsSync(join(t.skillsDir, s, "SKILL.md"))));
116
124
  const wizardDriverDefault = () => process.env.HERDR_ENV === "1" ? "herdr" : "auto";
117
125
  async function installAgentFiles(cwd, force, docs, notes) {
118
126
  const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
@@ -124,31 +132,30 @@ async function installAgentFiles(cwd, force, docs, notes) {
124
132
  return /^(?:y|yes)$/i.test((await prompt.question(`${question} [y/N] `)).trim());
125
133
  };
126
134
  try {
127
- for (const skill of AGENT_SKILLS) {
128
- const dest = skillPath(cwd, skill);
129
- const exists = existsSync(dest);
130
- if (exists && !force && !(await confirm(`Overwrite ${dest}?`))) {
131
- notes.push(`skipped existing ${dest}; pass --force to overwrite it`);
132
- continue;
135
+ for (const { skillsDir, docPath } of hostTargets(cwd)) {
136
+ for (const skill of AGENT_SKILLS) {
137
+ const dest = join(skillsDir, skill, "SKILL.md");
138
+ const exists = existsSync(dest);
139
+ if (exists && !force && !(await confirm(`Overwrite ${dest}?`))) {
140
+ notes.push(`skipped existing ${dest}; pass --force to overwrite it`);
141
+ continue;
142
+ }
143
+ // whole skill dir, not just SKILL.md — the overseer ships its pane-watcher script
144
+ cpSync(fileURLToPath(new URL(`../../../skills/${skill}`, import.meta.url)), join(skillsDir, skill), { recursive: true });
145
+ notes.push(`${exists ? "overwrote" : "wrote"} ${dest}`);
146
+ }
147
+ const current = existsSync(docPath) ? readFileSync(docPath, "utf8") : "";
148
+ if (current.includes(DOCS_BEGIN) || current.includes(`<!-- ${LEGACY_PREFIX}:agent-docs begin -->`)
149
+ || current.includes(DOCS_END) || current.includes(`<!-- ${LEGACY_PREFIX}:agent-docs end -->`)) {
150
+ notes.push(`kept existing tickmarkr agent docs in ${docPath}`);
151
+ }
152
+ else if (docs || await confirm(`Append tickmarkr agent docs to ${docPath}?`)) {
153
+ appendFileSync(docPath, `${current ? current.endsWith("\n") ? "\n" : "\n\n" : ""}${AGENT_DOCS}`);
154
+ notes.push(`appended tickmarkr agent docs to ${docPath}`);
155
+ }
156
+ else {
157
+ notes.push(`skipped agent docs for ${docPath}; pass --docs to append them`);
133
158
  }
134
- mkdirSync(join(skillsRoot(cwd), skill), { recursive: true });
135
- writeFileSync(dest, readFileSync(new URL(`../../../skills/${skill}/SKILL.md`, import.meta.url)), exists ? undefined : { flag: "wx" });
136
- notes.push(`${exists ? "overwrote" : "wrote"} ${dest}`);
137
- }
138
- const claude = join(cwd, "CLAUDE.md");
139
- const agents = join(cwd, "AGENTS.md");
140
- const docPath = existsSync(claude) || !existsSync(agents) ? claude : agents;
141
- const current = existsSync(docPath) ? readFileSync(docPath, "utf8") : "";
142
- if (current.includes(DOCS_BEGIN) || current.includes(`<!-- ${LEGACY_PREFIX}:agent-docs begin -->`)
143
- || current.includes(DOCS_END) || current.includes(`<!-- ${LEGACY_PREFIX}:agent-docs end -->`)) {
144
- notes.push(`kept existing tickmarkr agent docs in ${docPath}`);
145
- }
146
- else if (docs || await confirm(`Append tickmarkr agent docs to ${docPath}?`)) {
147
- appendFileSync(docPath, `${current ? current.endsWith("\n") ? "\n" : "\n\n" : ""}${AGENT_DOCS}`);
148
- notes.push(`appended tickmarkr agent docs to ${docPath}`);
149
- }
150
- else {
151
- notes.push(`skipped agent docs for ${docPath}; pass --docs to append them`);
152
159
  }
153
160
  }
154
161
  finally {
@@ -183,7 +190,7 @@ async function runInitWizard(cwd) {
183
190
  let installSkills = false;
184
191
  if (!skillsInstalled(cwd)) {
185
192
  const skillsDef = existsSync(join(cwd, ".claude", "skills")) && !skillsInstalled(cwd);
186
- installSkills = await askYesNo(rl, "Install agent skills (tickmarkr-loop/tickmarkr-auto)?", skillsDef);
193
+ installSkills = await askYesNo(rl, "Install agent skills (tickmarkr-loop/tickmarkr-auto/tickmarkr-overseer)?", skillsDef);
187
194
  }
188
195
  return { overlay: { driver, concurrency, visibility: { llm } }, installSkills };
189
196
  }
@@ -8,7 +8,7 @@ import { loadGraph } from "../../graph/graph.js";
8
8
  import { resolveRunMode } from "../../run/daemon.js";
9
9
  import { excludedChannels, exclusionLine } from "../../route/preference.js";
10
10
  import { staffLedEvidence } from "../../route/profile.js";
11
- import { QUALITY_ENV, route, RoutingError } from "../../route/router.js";
11
+ import { route, RoutingError } from "../../route/router.js";
12
12
  import { modelId } from "../../gates/review.js";
13
13
  import { loadRoutingProfile } from "../../run/journal.js";
14
14
  // T4 (v1.50): TTY-only brand pass — the title helper frames the routing table, lint/unroutable
@@ -44,7 +44,6 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters())
44
44
  if (values.mode !== undefined && !ROUTING_MODES.includes(values.mode)) {
45
45
  throw new Error(`--mode must be one of ${ROUTING_MODES.join(" | ")} (got ${values.mode})`);
46
46
  }
47
- delete process.env[QUALITY_ENV];
48
47
  const g = loadGraph(cwd);
49
48
  const { cfg, mode, source } = resolveRunMode(cwd, { flag: values.mode, spec: g.mode });
50
49
  // readDoctor cache path: staleness line only fires here (probeAll fallback is fresh by construction).
@@ -4,7 +4,7 @@ import { ROUTING_MODES } from "../../config/config.js";
4
4
  import { pickDriver } from "../../drivers/index.js";
5
5
  import { loadGraph } from "../../graph/graph.js";
6
6
  import { formatSummary, resolveRunMode, runDaemon } from "../../run/daemon.js";
7
- import { route, NO_EXPLORE_ENV, QUALITY_ENV } from "../../route/router.js";
7
+ import { route, NO_EXPLORE_ENV } from "../../route/router.js";
8
8
  import { formatJournalNarration, loadRoutingProfile } from "../../run/journal.js";
9
9
  import { ttyVisual } from "../../adapters/model-lints.js";
10
10
  import { statusRow } from "../../brand.js";
@@ -25,8 +25,9 @@ export const narrationLine = (event) => {
25
25
  const summaryGreen = (s) => s.failed.length === 0 && s.human.length === 0 && s.blocked.length === 0 && s.pending.length === 0
26
26
  && s.tipVerify !== "failed";
27
27
  // v1.51 T2: --quality is a pure compatibility alias for `--mode partner-led` (this run only). It
28
- // carries no one-band floor raise of its own: the flag never sets ExploreContext.quality or the
29
- // TICKMARKR_QUALITY env, so no downstream code raises a floor on its behalf (proven in mode-sources).
28
+ // carries no one-band floor raise of its own — and since the OBS-89 rip (v1.60) route() no longer
29
+ // reads the retired TICKMARKR_QUALITY env at all, so no downstream code can raise a floor on its
30
+ // behalf (proven in mode-sources).
30
31
  const QUALITY_ALIAS_NOTICE = "tickmarkr: --quality is a compatibility alias for --mode partner-led (this run only) — "
31
32
  + "the v1.47 one-band floor raise is retired (deprecated); use --mode partner-led";
32
33
  export async function run(argv, cwd = process.cwd()) {
@@ -56,7 +57,6 @@ export async function run(argv, cwd = process.cwd()) {
56
57
  if (values.quality)
57
58
  console.warn(QUALITY_ALIAS_NOTICE);
58
59
  const flagMode = values.mode ?? (values.quality ? "partner-led" : undefined);
59
- delete process.env[QUALITY_ENV];
60
60
  const graph = loadGraph(cwd);
61
61
  const { cfg, conflict } = resolveRunMode(cwd, { flag: flagMode, spec: graph.mode });
62
62
  if (conflict) {
@@ -54,12 +54,19 @@ const gateStates = (task, events) => {
54
54
  };
55
55
  // verdict semantics only: pass brand green, fail red, skip/open dim chrome — everything else stays quiet
56
56
  const GATE_STATE_TOKEN = { pass: ok, fail, skip: dim, open: dim };
57
- const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => {
58
- const chip = `${GATE_KEYS[gate]}${gateBox(states[i], unicode)}`;
59
- return unicode ? GATE_STATE_TOKEN[states[i]](chip) : chip;
60
- }).join(" ");
61
- // plain (uncolored) chip width for column math — ANSI codes have zero display width
62
- const gateChainWidth = (unicode) => GATE_NAMES.reduce((w, gate) => w + GATE_KEYS[gate].length + (unicode ? 1 : 3) + 1, -1);
57
+ // TTY cells are bare glyphs in fixed GATE_NAMES order — gate identity lives once in the frame
58
+ // legend, and in words on a failing row; non-TTY keeps the letter+box chips (byte-pinned surface)
59
+ const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => unicode
60
+ ? GATE_STATE_TOKEN[states[i]](gateBox(states[i], true))
61
+ : `${GATE_KEYS[gate]}${gateBox(states[i], false)}`).join(" ");
62
+ // plain (uncolored) cell width for column math ANSI codes have zero display width
63
+ const gateChainWidth = (unicode) => GATE_NAMES.reduce((w, gate) => w + (unicode ? 1 : GATE_KEYS[gate].length + 3) + 1, -1);
64
+ const failedGates = (states) => GATE_NAMES.filter((_, i) => states[i] === "fail");
65
+ // plain-text form of the failed-gate words for column math; rendered with a dim dot + red names
66
+ const failedSuffix = (states) => {
67
+ const f = failedGates(states);
68
+ return f.length ? ` · ${f.join(", ")}` : "";
69
+ };
63
70
  const shortGoal = (goal, max) => {
64
71
  const clause = goal.split(/[,;.?!]/, 1)[0].trim();
65
72
  if (clause.length <= max)
@@ -193,19 +200,22 @@ const renderFrame = (cwd) => {
193
200
  : `no runs yet${dot}`) +
194
201
  `${gauge} ${done === g.tasks.length && g.tasks.length > 0 ? ok(tally) : tally}`;
195
202
  const hr = rule(Math.min(width, 100));
196
- const gatesLegend = legend(` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(" · ")}`);
203
+ const gatesLegend = legend(` gates: ${GATE_NAMES.join(" · ")}`);
197
204
  const taskVerdict = (st) => st === "done" ? "pass" : st === "failed" ? "fail" : st === "human" ? "warn" : "neutral";
198
205
  const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
199
- const stW = Math.max(...cells.map((c) => (String(c.st) + c.label).length));
206
+ const stW = Math.max(...cells.map((c) => (String(c.st) + c.label + failedSuffix(c.states)).length));
200
207
  const chainW = gateChainWidth(true);
201
208
  const assignW = Math.max(...cells.map((c) => c.assignCol.length));
202
209
  const goalW = Math.max(8, width - (5 + idW) - 2 - chainW - 2 - stW - 2 - assignW);
203
210
  const rows = cells.map(({ t, st, label, assignCol, states }) => {
204
211
  const goal = shortGoal(t.goal, goalW).padEnd(goalW);
205
212
  const stWord = st === "done" ? ok(String(st)) : st === "failed" ? fail(String(st)) : st === "human" ? warn(String(st)) : String(st);
213
+ // a fail names its gate in words right here — the one moment gate identity is needed on a row
214
+ const f = failedGates(states);
206
215
  const statusCell = stWord +
207
216
  (label ? (label === " starved" ? fail(label) : dim(label)) : "") +
208
- " ".repeat(stW - (String(st) + label).length);
217
+ (f.length ? dim(" · ") + fail(f.join(", ")) : "") +
218
+ " ".repeat(stW - (String(st) + label + failedSuffix(states)).length);
209
219
  return ` ${statusRow(taskVerdict(st), `${t.id.padEnd(idW)} ${goal} ${gateChain(states, true)} ${statusCell} ${dim(assignCol)}`)}`;
210
220
  });
211
221
  return [header, hr, gatesLegend, ...rows].join("\n");
@@ -250,7 +250,7 @@ export type FleetEditable = {
250
250
  };
251
251
  export declare function repoOverlayPath(repoRoot: string): string;
252
252
  export declare function readOverlayFile(path: string): Record<string, unknown>;
253
- export declare function fleetEditableFromConfig(cfg: TickmarkrConfig): FleetEditable;
253
+ export declare function fleetEditableFromConfig(cfg: TickmarkrConfig, provenance?: Record<string, Record<string, string>>): FleetEditable;
254
254
  /** Resolve which layer last set a dotted fleet path (defaults < global < repo). */
255
255
  export declare function fleetKeyLayer(repoRoot: string, dotted: string, opts?: {
256
256
  globalDir?: string;
@@ -259,10 +259,28 @@ export declare function fleetKeyLayer(repoRoot: string, dotted: string, opts?: {
259
259
  export declare function formatFleetPrint(repoRoot: string, opts?: {
260
260
  globalDir?: string;
261
261
  }): string;
262
+ /** Trailing `# note` comments on per-entry lines an operator may have hand-written or a prior
263
+ * fleet write stamped: model tier lines (including null tombstones) and deny list items. */
264
+ export type HarvestedProvenance = {
265
+ tiers: Record<string, Record<string, string>>;
266
+ denyAdapters: Record<string, string>;
267
+ denyModels: Record<string, string>;
268
+ };
269
+ /** OBS-88: harvest existing provenance comments from raw repo-overlay bytes at fleet-session
270
+ * load. yaml.parse discards comments, so before this every fleet write re-serialized the file
271
+ * knowing only the current session's own notes and silently stripped all prior ones — a typed
272
+ * benchmark-provenance note survived exactly one write. Fail-open to empty: an unreadable
273
+ * overlay is the loader's problem to reject, never the harvester's. */
274
+ export declare function harvestFleetProvenance(overlayText: string): HarvestedProvenance;
275
+ /** Harvested deny-entry notes keyed by the exact entry string, re-attached at serialize time. */
276
+ export type FleetDenyNotes = {
277
+ adapters?: Record<string, string>;
278
+ models?: Record<string, string>;
279
+ };
262
280
  /** Build the repo overlay fragment fleet would write for edits since session start. */
263
281
  export declare function fleetRepoOverlayFromDelta(initial: FleetEditable, edited: FleetEditable, existingRepo?: Record<string, unknown>): Record<string, unknown>;
264
- export declare function repoOverlayYaml(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string>>): string;
265
- export declare function serializeFleetOverlay(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string>>): string;
282
+ export declare function repoOverlayYaml(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string>>, denyNotes?: FleetDenyNotes): string;
283
+ export declare function serializeFleetOverlay(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string>>, denyNotes?: FleetDenyNotes): string;
266
284
  export declare function unifiedYamlDiff(before: string, after: string, label?: string): string;
267
285
  export declare function fleetEditableEquals(a: FleetEditable, b: FleetEditable): boolean;
268
286
  export {};
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { parse, stringify } from "yaml";
4
+ import { isMap, isScalar, isSeq, parse, parseDocument, stringify } from "yaml";
5
5
  import { z } from "zod";
6
6
  import { stateDirName } from "../graph/graph.js";
7
7
  import { SHAPES, TIERS } from "../graph/schema.js";
@@ -546,12 +546,16 @@ export function readOverlayFile(path) {
546
546
  throw new ConfigError(`invalid overlay at ${path}`);
547
547
  return raw;
548
548
  }
549
- export function fleetEditableFromConfig(cfg) {
549
+ export function fleetEditableFromConfig(cfg, provenance = {}) {
550
550
  const tiers = {};
551
551
  for (const [adapter, entry] of Object.entries(cfg.tiers)) {
552
552
  tiers[adapter] = {};
553
- for (const [model, tier] of Object.entries(entry.models))
554
- tiers[adapter][model] = { tier };
553
+ for (const [model, tier] of Object.entries(entry.models)) {
554
+ // OBS-88: notes harvested from the overlay ride the session state from load, so a later
555
+ // write knows about every existing note — not only the ones this session typed itself
556
+ const note = provenance[adapter]?.[model];
557
+ tiers[adapter][model] = note ? { tier, provenance: note } : { tier };
558
+ }
555
559
  }
556
560
  return {
557
561
  denyAdapters: [...(cfg.routing.deny?.adapters ?? [])].sort(),
@@ -649,6 +653,47 @@ export function formatFleetPrint(repoRoot, opts = {}) {
649
653
  function sortedUnique(xs) {
650
654
  return [...new Set(xs)].sort();
651
655
  }
656
+ /** OBS-88: harvest existing provenance comments from raw repo-overlay bytes at fleet-session
657
+ * load. yaml.parse discards comments, so before this every fleet write re-serialized the file
658
+ * knowing only the current session's own notes and silently stripped all prior ones — a typed
659
+ * benchmark-provenance note survived exactly one write. Fail-open to empty: an unreadable
660
+ * overlay is the loader's problem to reject, never the harvester's. */
661
+ export function harvestFleetProvenance(overlayText) {
662
+ const out = { tiers: {}, denyAdapters: {}, denyModels: {} };
663
+ if (!overlayText.trim())
664
+ return out;
665
+ const doc = parseDocument(overlayText);
666
+ const note = (n) => {
667
+ const c = isScalar(n) ? n.comment : undefined;
668
+ return typeof c === "string" && c.trim() ? c.trim() : undefined;
669
+ };
670
+ const tiers = doc.getIn(["tiers"]);
671
+ if (isMap(tiers)) {
672
+ for (const ap of tiers.items) {
673
+ if (!isScalar(ap.key))
674
+ continue;
675
+ const models = isMap(ap.value) ? ap.value.get("models") : undefined;
676
+ if (!isMap(models))
677
+ continue;
678
+ for (const mp of models.items) {
679
+ const n = note(mp.value);
680
+ if (isScalar(mp.key) && n)
681
+ (out.tiers[String(ap.key.value)] ??= {})[String(mp.key.value)] = n;
682
+ }
683
+ }
684
+ }
685
+ for (const [key, dest] of [["adapters", out.denyAdapters], ["models", out.denyModels]]) {
686
+ const seq = doc.getIn(["routing", "deny", key]);
687
+ if (!isSeq(seq))
688
+ continue;
689
+ for (const item of seq.items) {
690
+ const n = note(item);
691
+ if (isScalar(item) && n)
692
+ dest[String(item.value)] = n;
693
+ }
694
+ }
695
+ return out;
696
+ }
652
697
  /** Build the repo overlay fragment fleet would write for edits since session start. */
653
698
  export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
654
699
  if (fleetEditableEquals(initial, edited))
@@ -714,11 +759,11 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
714
759
  out.tiers = tiersOut;
715
760
  return out;
716
761
  }
717
- export function repoOverlayYaml(overlay, provenance = {}) {
762
+ export function repoOverlayYaml(overlay, provenance = {}, denyNotes = {}) {
718
763
  if (!Object.keys(overlay).length)
719
764
  return "";
720
765
  const fleet = fleetSubset(overlay);
721
- const fleetBody = serializeFleetOverlay(fleet, provenance);
766
+ const fleetBody = serializeFleetOverlay(fleet, provenance, denyNotes);
722
767
  const rest = { ...overlay };
723
768
  for (const k of FLEET_OVERLAY_KEYS)
724
769
  delete rest[k];
@@ -727,25 +772,39 @@ export function repoOverlayYaml(overlay, provenance = {}) {
727
772
  const head = stringify(rest).trimEnd();
728
773
  return fleetBody ? `${head}\n${fleetBody}` : `${head}\n`;
729
774
  }
730
- export function serializeFleetOverlay(overlay, provenance = {}) {
775
+ export function serializeFleetOverlay(overlay, provenance = {}, denyNotes = {}) {
731
776
  if (!Object.keys(overlay).length)
732
777
  return "";
733
778
  const lines = [];
734
- const today = new Date().toISOString().slice(0, 10);
735
779
  // OBS-75: never glue stringify() output onto a key line — wrap the key into the object and
736
780
  // re-indent the whole emitted block, so sequences/nested maps nest correctly and null
737
781
  // tombstones/empty collections survive the serialize→parse round-trip.
738
782
  const block = (obj, pad) => stringify(obj).trimEnd().split("\n").map((l) => `${pad}${l}`);
783
+ // deny lists emit item-by-item through the same stringify quoting rules as block(), so a
784
+ // harvested `# reason` can re-attach to its exact entry (multi-line emissions never take one)
785
+ const denySeq = (key, v) => {
786
+ if (v === undefined)
787
+ return [];
788
+ if (v === null || !v.length)
789
+ return block({ [key]: v }, " ");
790
+ const out = [` ${key}:`];
791
+ for (const item of v) {
792
+ const emitted = stringify([item]).trimEnd().split("\n");
793
+ const n = denyNotes[key]?.[item];
794
+ if (n && emitted.length === 1)
795
+ emitted[0] += ` # ${n}`;
796
+ out.push(...emitted.map((l) => ` ${l}`));
797
+ }
798
+ return out;
799
+ };
739
800
  const routing = overlay.routing;
740
801
  if (routing) {
741
802
  lines.push("routing:");
742
803
  const deny = routing.deny;
743
804
  if (deny && (deny.adapters !== undefined || deny.models !== undefined)) {
744
805
  lines.push(" deny:");
745
- if (deny.adapters !== undefined)
746
- lines.push(...block({ adapters: deny.adapters }, " "));
747
- if (deny.models !== undefined)
748
- lines.push(...block({ models: deny.models }, " "));
806
+ lines.push(...denySeq("adapters", deny.adapters));
807
+ lines.push(...denySeq("models", deny.models));
749
808
  }
750
809
  if (routing.map)
751
810
  lines.push(...block({ map: routing.map }, " "));
@@ -767,13 +826,11 @@ export function serializeFleetOverlay(overlay, provenance = {}) {
767
826
  if (models.length) {
768
827
  body.push(" models:");
769
828
  for (const [model, tier] of models) {
770
- if (tier === null)
771
- body.push(` ${model}: null`);
772
- else {
773
- const note = provenance[adapter]?.[model];
774
- const suffix = note ? ` # ${note} — fleet ${today}` : "";
775
- body.push(` ${model}: ${tier}${suffix}`);
776
- }
829
+ // OBS-88: notes serialize verbatim (fresh session notes arrive pre-stamped with their
830
+ // "— fleet <date>" suffix), so a harvested note round-trips byte-for-byte every write
831
+ const note = provenance[adapter]?.[model];
832
+ const suffix = note ? ` # ${note}` : "";
833
+ body.push(` ${model}: ${tier === null ? "null" : tier}${suffix}`);
777
834
  }
778
835
  }
779
836
  else if (entry.models) {
@@ -789,10 +846,20 @@ export function serializeFleetOverlay(overlay, provenance = {}) {
789
846
  return `${lines.join("\n")}\n`;
790
847
  }
791
848
  export function unifiedYamlDiff(before, after, label = "config overlay") {
792
- const a = before.split("\n");
793
- const b = after.split("\n");
794
849
  if (before === after)
795
850
  return "";
851
+ const a = before.split("\n");
852
+ const b = after.split("\n");
853
+ // v1.60 T3: shortest-edit (LCS) matching. The old scan resynced greedily on the first mismatched
854
+ // line, so one inserted line could cascade into a whole-file remove/re-add hunk on the one
855
+ // confirmation surface an operator reviews before a write.
856
+ // ponytail: O(n·m) table — overlays are tens of lines; Myers O(nd) if files ever grow.
857
+ const lcs = Array.from({ length: a.length + 1 }, () => Array.from({ length: b.length + 1 }, () => 0));
858
+ for (let i = a.length - 1; i >= 0; i--) {
859
+ for (let j = b.length - 1; j >= 0; j--) {
860
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
861
+ }
862
+ }
796
863
  const header = [`--- ${label} (current)`, `+++ ${label} (proposed)`];
797
864
  const hunks = [];
798
865
  let i = 0;
@@ -803,17 +870,15 @@ export function unifiedYamlDiff(before, after, label = "config overlay") {
803
870
  j++;
804
871
  continue;
805
872
  }
806
- const startI = i;
807
- const startJ = j;
808
- while (i < a.length && (j >= b.length || a[i] !== b[j]))
809
- i++;
810
- while (j < b.length && (i >= a.length || a[i] !== b[j]))
811
- j++;
812
- hunks.push("@@");
813
- for (let k = startI; k < i; k++)
814
- hunks.push(`-${a[k]}`);
815
- for (let k = startJ; k < j; k++)
816
- hunks.push(`+${b[k]}`);
873
+ const del = [];
874
+ const add = [];
875
+ while ((i < a.length || j < b.length) && !(i < a.length && j < b.length && a[i] === b[j])) {
876
+ if (j >= b.length || (i < a.length && lcs[i + 1][j] >= lcs[i][j + 1]))
877
+ del.push(`-${a[i++]}`);
878
+ else
879
+ add.push(`+${b[j++]}`);
880
+ }
881
+ hunks.push("@@", ...del, ...add);
817
882
  }
818
883
  return `${header.join("\n")}\n${hunks.join("\n")}\n`;
819
884
  }
@@ -1,5 +1,5 @@
1
1
  import { type Assignment, type BillingChannel } from "../adapters/types.js";
2
- import { type TickmarkrConfig, type Tier } from "../config/config.js";
2
+ import { type TickmarkrConfig } from "../config/config.js";
3
3
  import type { Task } from "../graph/schema.js";
4
4
  import { type RoutingProfile } from "./profile.js";
5
5
  export type LadderStep = "retry" | "escalate" | "consult" | "human";
@@ -28,12 +28,10 @@ export interface RoutingPreferContext {
28
28
  }
29
29
  export interface ExploreContext {
30
30
  noExplore?: boolean;
31
- quality?: boolean;
32
31
  }
33
32
  export declare const NO_EXPLORE_ENV = "TICKMARKR_NO_EXPLORE";
34
33
  export declare const QUALITY_ENV = "TICKMARKR_QUALITY";
35
34
  export declare const ROUTING_ENV_SEAMS: readonly ["TICKMARKR_QUALITY", "TICKMARKR_NO_EXPLORE"];
36
- export declare const raiseTier: (tier: Tier) => Tier;
37
35
  export declare class RoutingError extends Error {
38
36
  constructor(msg: string);
39
37
  }
@@ -3,15 +3,18 @@ import { TIER_RANK } from "../config/config.js";
3
3
  import { disallowedBy } from "./preference.js";
4
4
  import { cellOf, EXPLORE_CAP, explorationBonus, learnedScore, MIN_SAMPLES } from "./profile.js";
5
5
  export const NO_EXPLORE_ENV = "TICKMARKR_NO_EXPLORE";
6
+ // OBS-89 (v1.60): the TICKMARKR_QUALITY variable is RETIRED — nothing in src reads it anymore and
7
+ // route() ignores it entirely (the v1.47 one-band floor raise, its exploration suppression, and the
8
+ // env fallback were deleted, not moved). The name survives solely as a scrub target: the spawn seam
9
+ // keeps erasing a stale operator shell's legacy export from child environments, and the retired-seam
10
+ // tests (tests/run/git.test.ts, tests/setup.ts, tests/gates/baseline.test.ts) pin that contract.
6
11
  export const QUALITY_ENV = "TICKMARKR_QUALITY";
7
12
  // OBS-74: every routing env seam, in one list — the spawn seam (src/run/git.ts) scrubs exactly
8
13
  // these from child env so gate/baseline/tip-verify children are hermetic by construction.
9
14
  export const ROUTING_ENV_SEAMS = [QUALITY_ENV, NO_EXPLORE_ENV];
10
15
  const exploreCap = (cfg) => cfg.routing.explore?.cap ?? EXPLORE_CAP;
11
- const qualityOn = (exploreCtx) => !!exploreCtx?.quality || process.env[QUALITY_ENV] === "1";
12
- export const raiseTier = (tier) => tier === "cheap" ? "mid" : tier === "mid" ? "frontier" : "frontier";
13
16
  const exploreOff = (task, cfg, exploreCtx) => {
14
- if (qualityOn(exploreCtx) || exploreCtx?.noExplore || process.env[NO_EXPLORE_ENV] === "1")
17
+ if (exploreCtx?.noExplore || process.env[NO_EXPLORE_ENV] === "1")
15
18
  return true;
16
19
  const e = cfg.routing.explore;
17
20
  if (!e)
@@ -110,15 +113,6 @@ function withoutExcluded(channels, exclude) {
110
113
  // OBS-57: in-run demotion after consecutive no-trailer windows — route around poisoned channels for the rest of the run.
111
114
  return channels.filter((c) => !exclude.has(channelKey(c)));
112
115
  }
113
- const qualityBound = (quality, configFloor, taskFloor) => {
114
- if (!quality)
115
- return undefined;
116
- if (configFloor)
117
- return `floor ${configFloor}→${raiseTier(configFloor)} (--quality)`;
118
- if (taskFloor)
119
- return `floor ${taskFloor}→${raiseTier(taskFloor)} (--quality)`;
120
- return undefined;
121
- };
122
116
  const maybeSlaLint = (lints, task, profile, slaMinutes, c) => {
123
117
  if (slaMinutes === undefined || !profile)
124
118
  return;
@@ -135,7 +129,6 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
135
129
  channels = withoutExcluded(channels, exclude);
136
130
  const lints = [];
137
131
  const advisoryFloor = cfg.routing.floors[task.shape];
138
- const quality = qualityOn(exploreCtx);
139
132
  const floor = advisoryFloor;
140
133
  const slaMinutes = cfg.routing.sla?.[task.shape];
141
134
  // ponytail: sla is plan-time advisory only — never thread into learnedScore (would reroute warm rivals).
@@ -173,8 +166,7 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
173
166
  lints.push(`${task.id} (${task.shape}): ${what} routes ${tier}, below advisory floor ${advisoryFloor}`);
174
167
  }
175
168
  };
176
- const taskFloorRaw = task.routingHints?.floor;
177
- const taskFloor = taskFloorRaw && quality ? raiseTier(taskFloorRaw) : taskFloorRaw;
169
+ const taskFloor = task.routingHints?.floor;
178
170
  const source = task.routingHints?.source;
179
171
  const src = source ? `, ${source}` : ""; // never interpolate a possibly-undefined source
180
172
  // task pin: planner-authored, try-first — degrades on miss or below-floor (D-05, research A3), never throws
@@ -201,12 +193,7 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
201
193
  return { assignment: toAssignment(c), ladder: ladderFor(task, entry), lints, provenance: `${degraded}pin ${entry.pin.via}:${entry.pin.model} (config routing.map)` };
202
194
  }
203
195
  const baseTier = floor ?? "cheap";
204
- let minTier = taskFloor && TIER_RANK[taskFloor] > TIER_RANK[baseTier] ? taskFloor : baseTier; // task floor is a hard >= constraint in all paths (D-04)
205
- if (quality && advisoryFloor) {
206
- const raised = raiseTier(advisoryFloor);
207
- if (TIER_RANK[raised] > TIER_RANK[minTier])
208
- minTier = raised;
209
- }
196
+ const minTier = taskFloor && TIER_RANK[taskFloor] > TIER_RANK[baseTier] ? taskFloor : baseTier; // task floor is a hard >= constraint in all paths (D-04)
210
197
  if (prefActive)
211
198
  for (const p of prefer ?? [])
212
199
  preflightPrefer(p);
@@ -301,10 +288,9 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
301
288
  eligible = spreadStatic(sortedStatic);
302
289
  spreadDecided = channelKey(eligible[0]) !== channelKey(sortedStatic[0]);
303
290
  }
304
- const bound = qualityBound(quality, advisoryFloor, taskFloorRaw) ??
305
- (taskFloor && TIER_RANK[taskFloor] >= TIER_RANK[baseTier] ? `floor ${taskFloor} (task hint${src})` :
306
- floor ? `floor ${floor} (config floors)` :
307
- "tier cheap (default)");
291
+ const bound = taskFloor && TIER_RANK[taskFloor] >= TIER_RANK[baseTier] ? `floor ${taskFloor} (task hint${src})` :
292
+ floor ? `floor ${floor} (config floors)` :
293
+ "tier cheap (default)";
308
294
  // name the key that actually broke the tie: prefer outranks the marginal-cost/tier keys, so if the
309
295
  // winner matched a prefer entry, prefer decided it — not "cheapest sufficient tier" (ROUTE-03, WR-01)
310
296
  const preferVia = preferFromAuto(task.shape, preferCtx)
@@ -19,7 +19,7 @@ import { cleanupRunWorktrees, gitHead, linkNodeModules, sh, shGit, WORKTREE_LAYO
19
19
  import { classifyWorkerResultCause, engagementComparable, Journal, loadRoutingProfile, newRunId } from "./journal.js";
20
20
  import { acquireRunLock, releaseRunLock } from "./lock.js";
21
21
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
22
- import { nextChannel, QUALITY_ENV, route } from "../route/router.js";
22
+ import { nextChannel, route } from "../route/router.js";
23
23
  import { desiredPanes } from "./reconcile.js";
24
24
  import { normalizeStallSnapshot } from "./stall.js";
25
25
  const MODE_RANK = { "staff-led": 0, "risk-based": 1, "partner-led": 2 };
@@ -90,9 +90,8 @@ async function cherryPickCommits(wt, commits) {
90
90
  return carried;
91
91
  }
92
92
  export async function runDaemon(repoRoot, opts = {}) {
93
- // v1.51 T2: retired --quality env seam. Mode resolution owns premium routing now; an exported
94
- // TICKMARKR_QUALITY must not resurrect the old one-band floor raise in daemon dispatches.
95
- delete process.env[QUALITY_ENV];
93
+ // v1.51 T2 / OBS-89 (v1.60): retired --quality env seam. Mode resolution owns premium routing;
94
+ // route() no longer reads the retired env at all, so the old entrypoint scrub is gone with it.
96
95
  const adapters = opts.adapters ?? allAdapters();
97
96
  const health = readDoctor(repoRoot) ?? (await probeAll(adapters));
98
97
  const driver = opts.driver ?? new SubprocessDriver();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.58.0",
3
+ "version": "1.60.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,6 +31,7 @@
31
31
  "scripts": {
32
32
  "build": "tsc -p tsconfig.json",
33
33
  "lint": "oxlint src tests scripts",
34
+ "pretest": "npm run build",
34
35
  "test": "vitest run",
35
36
  "test:coverage": "vitest run --coverage",
36
37
  "schema": "tsx scripts/emit-schema.ts",
@@ -14,7 +14,9 @@ When working in a multi-agent terminal environment, decide your role before star
14
14
 
15
15
  - **Orchestrator:** your session was started to execute the mission. Rename your own tab/pane `ORCH · <version>` (short labels: ≤20 chars, `ROLE · token`) and run the loop below.
16
16
  - **Supervisor with a live orchestrator:** do not start a second run. Relay the mission to the existing orchestrator with a [verified handoff](#verified-handoffs-agent-to-agent-messaging), then supervise it as OVERSEER.
17
- - **Primary session without an orchestrator:** rename your own tab `OVERSEER · <version>` and your agent `overseer`, spawn one child orchestration session with `--permission-mode bypassPermissions`, label its tab `ORCH · <version>` and name its agent, give it the mission and these rules verbatim, then supervise it. Do not drive a duplicate single-tier run yourself. Before spawning, confirm any PREVIOUS orchestrator has stood down (monitors stopped, input box empty — dim ghost-text suggestions are UI, not queued input; ANSI-verify before alarming) and close its tab — the journal, records, and ledger hold the story; scrollback is disposable.
17
+ - **Primary session without an orchestrator:** rename your own tab `OVERSEER · <version>` and your agent `overseer`, spawn one child orchestration session with your host's launch form, label its tab `ORCH · <version>` and name its agent, give it the mission and these rules verbatim, then supervise it. Do not drive a duplicate single-tier run yourself. Before spawning, confirm any PREVIOUS orchestrator has stood down (monitors stopped, input box empty — dim ghost-text suggestions are UI, not queued input; ANSI-verify before alarming) and close its tab — the journal, records, and ledger hold the story; scrollback is disposable.
18
+ - **Claude Code:** `herdr agent start orchestrator --cwd <repo> --no-focus -- claude --permission-mode bypassPermissions`
19
+ - **Codex:** `herdr agent start orchestrator --cwd <repo> --no-focus -- codex --ask-for-approval never --sandbox workspace-write`
18
20
 
19
21
  Outside a multi-agent terminal environment, run the loop directly.
20
22
 
@@ -13,7 +13,9 @@ When working in a multi-agent terminal environment, decide your role before star
13
13
 
14
14
  - **Orchestrator:** your session was started to execute the mission. Rename your own tab/pane `ORCH · <version>` (short labels: ≤20 chars, `ROLE · token`) and run the loop below.
15
15
  - **Supervisor with a live orchestrator:** do not start a second run. Relay the mission to the existing orchestrator with a [verified handoff](#verified-handoffs-agent-to-agent-messaging), then supervise it as OVERSEER.
16
- - **Primary session without an orchestrator:** rename your own tab `OVERSEER · <version>` and your agent `overseer`, spawn one child orchestration session with `--permission-mode bypassPermissions`, label its tab `ORCH · <version>` and name its agent, give it the mission and these rules verbatim, then supervise it. Do not drive a duplicate single-tier run yourself. Before spawning, confirm any PREVIOUS orchestrator has [stood down](#stand-down-mission-end-and-retirement) and close its tab.
16
+ - **Primary session without an orchestrator:** rename your own tab `OVERSEER · <version>` and your agent `overseer`, spawn one child orchestration session with your host's launch form, label its tab `ORCH · <version>` and name its agent, give it the mission and these rules verbatim, then supervise it. Do not drive a duplicate single-tier run yourself. Before spawning, confirm any PREVIOUS orchestrator has [stood down](#stand-down-mission-end-and-retirement) and close its tab.
17
+ - **Claude Code:** `herdr agent start orchestrator --cwd <repo> --no-focus -- claude --permission-mode bypassPermissions`
18
+ - **Codex:** `herdr agent start orchestrator --cwd <repo> --no-focus -- codex --ask-for-approval never --sandbox workspace-write`
17
19
 
18
20
  Outside a multi-agent terminal environment, run the loop directly.
19
21
 
@@ -0,0 +1,110 @@
1
+ ---
2
+ name: tickmarkr-overseer
3
+ description: "Use when the user asks to oversee/supervise/babysit an autonomous tickmarkr run in a Herdr workspace (e.g. '/tickmarkr-overseer run the milestone', 'supervise this tickmarkr run', 'babysit this pipeline'). Requires HERDR_ENV=1. The skill argument is the mission (what to run end-to-end)."
4
+ ---
5
+
6
+ # Overseer (tickmarkr)
7
+
8
+ Become the OVERSEER for this workspace. Do no heavy work directly — build and supervise a two-tier
9
+ hierarchy of VISIBLE agents (you → orchestrator → tickmarkr's own worker fleet), and route human decisions
10
+ to the user with evidence.
11
+
12
+ The mission is the skill argument. If empty, ask the user what to run end-to-end before doing anything else.
13
+ Requires `HERDR_ENV=1`; if unset, say so and stop.
14
+
15
+ ## Setup
16
+
17
+ 0. **Adopt before you build.** If this workspace already has a supervision hierarchy — an
18
+ OVERSEER/ORCHESTRATOR tab, a live agent named `*orch*`, or a `<repo>/<state-dir>/overseer/` dir
19
+ (state dir = `.tickmarkr/`; legacy standalone `<repo>/.overseer/` counts
20
+ too) — do NOT spawn a duplicate (two orchestrators risk two concurrent tickmarkr runs in one repo,
21
+ which tickmarkr forbids). Read that dir's `DECISIONS.md` + `ORCH-BRIEF.md`, check the existing agents'
22
+ status, and either ADOPT the
23
+ existing orchestrator (updated brief, re-armed watchers) or, if the old hierarchy is dead, archive the
24
+ stale brief and build fresh.
25
+ 1. Load the `herdr` skill. `herdr pane list` to map the workspace — the focused pane is yours. Rename your
26
+ tab OVERSEER; create ONE tab ORCHESTRATOR.
27
+ **Live tab labels (standing operator rule, 2026-07-12):** on every decision or state change (role
28
+ handoff, task done/merged, run end) rename the affected tabs — and keep labels SHORT: the role as the
29
+ main name plus at most ONE hot-state token. Vocabulary: ORCH carries the milestone and progress
30
+ fraction (`ORCH · v1.19 4/5`, updated on every task-done); WORKERS carries the task token (tickmarkr
31
+ updates it). Never long context strings or ✓-chains.
32
+ 2. **Orchestrator**: Launch the orchestrator with your agent host. For Claude Code, use `herdr agent start orchestrator --cwd <repo> --no-focus -- claude --permission-mode bypassPermissions` (pin a strong model with `--model <m>` if the operator has a policy). For Codex, use `herdr agent start orchestrator --cwd <repo> --no-focus -- codex --ask-for-approval never --sandbox workspace-write` (add `--model <m>` to specify the model). Workers you never spawn — tickmarkr spawns its own visible worker panes.
33
+ 3. **Standing instructions travel as a brief FILE, never as pane text** — PTY input truncates at ~1024B and a
34
+ truncated brief silently drops policy. Write the full brief to `<repo>/.tickmarkr/overseer/ORCH-BRIEF.md`
35
+ (inside the tickmarkr state dir — already self-gitignored, no exclude step needed), then send one line:
36
+ `herdr pane run <orch> "Read .tickmarkr/overseer/ORCH-BRIEF.md and follow it exactly."` The brief MUST contain: the
37
+ mission, the pane mechanics below, rules 1–2, and require a verbatim one-sentence acknowledgment of the
38
+ human-checkpoint rule before anything is dispatched. Delete the dir at mission end.
39
+ 4. Arm the watcher (Supervision). Report the hierarchy map (pane ids + names) to the user.
40
+
41
+ ## Supervising tickmarkr as the executor
42
+
43
+ When the mission runs `/tickmarkr-auto` (tickmarkr dispatches the workers), supervision changes shape:
44
+
45
+ - **Give the run a live surface.** `tickmarkr run` is stdout-silent until run-end by design — split a pane in the
46
+ ORCHESTRATOR tab running `tickmarkr status --watch`. Narration also arrives as herdr notifications.
47
+ - **Watch the journal, not the panes.** The append-only journal
48
+ (`.tickmarkr/runs/<runId>/journal.jsonl`) is the
49
+ source of truth. Arm a background watcher on `run-end` / `task-human` / `task-failed` / `consult-verdict`
50
+ events; never sleep-poll inside an agent turn.
51
+ - **Daemon liveness ≠ journal activity.** A dead daemon emits no events, so journal watchers sleep through
52
+ its death. `tickmarkr status` prints last-event age + daemon pid liveness; check it before diagnosing a stall.
53
+ Recovery is `tickmarkr resume <runId>` — crash-safe by design (journal replay restores attempt counts and
54
+ consult channel bans).
55
+ - **Gate quiet ≠ idle.** Between `worker-result` and the batched `gate-result`s tickmarkr runs shell gates plus a
56
+ headless LLM judge/review with little visible signal — check the journal timestamps before intervening.
57
+ - **Classify gate failures before reacting.** The same fingerprint failing across DIFFERENT workers, or a
58
+ scope/test catch-22 (attempt N edits a file → scope gate fails; attempt N+1 leaves it → test gate fails),
59
+ is a PLAN defect: widen `files_modified` in the phase PLAN, recompile the phase dir after the run ends or
60
+ the task parks, release (`human → pending`), resume. A cross-vendor review rejection with concrete findings
61
+ is a REAL defect — let the escalation ladder work.
62
+ - **Dialog watchers go stale per attempt.** Every retry/escalation may spawn a new pane; re-arm dialog
63
+ watchers on each `task-dispatch` journal event.
64
+
65
+ ## Pane mechanics that bite
66
+
67
+ - **Verified send protocol**: `herdr agent send` writes WITHOUT Enter, and `pane run`'s Enter can be swallowed
68
+ by bracketed-paste on long payloads. Robust sequence: read the pane (bare prompt required) → send-text →
69
+ sleep 2–3s → send-keys Enter → read back (input empty / agent `working`). Never report "briefed" without
70
+ the read-back. Long content goes in a brief file, never pane text.
71
+ - **Guard-before-Enter** (race-safe prompt answering): chain with `&&` — pane get shows `blocked` && pane
72
+ read shows the expected option under the cursor && only then send-keys. If no longer `blocked`, someone
73
+ already answered; do nothing.
74
+ - `herdr wait agent-status` exits 1 on timeout, 0 on match — but ALSO 0 (with an error JSON) when the pane is
75
+ GONE. Never chain `wait && act` without confirming the pane exists.
76
+ - Stale typed input is unclearable via CLI — supersede it:
77
+ `pane run "<-- disregard everything before this arrow (stale draft). ACTUAL: <message>"`.
78
+
79
+ ## Supervision watcher
80
+
81
+ Arm the bundled watcher as its OWN Bash call with `run_in_background` — chaining it after other commands
82
+ with `&` orphans it from the wake chain. It prints one wake reason and exits; re-arm after every wake.
83
+
84
+ ```bash
85
+ .claude/skills/tickmarkr-overseer/scripts/watch-panes.sh WORKER_PANE ORCH_PANE [--fast-blocked]
86
+ ```
87
+
88
+ Default mode wakes only when both panes are quiet (dropped handoff) or the orchestrator blocks; the
89
+ orchestrator gets a 90s grace window to handle worker blocks first. For long parked stretches a targeted
90
+ `herdr wait agent-status <pane> --status <s> --timeout <ms>` beats the watcher. When parking a human
91
+ checkpoint, also fire `herdr notification show "HUMAN CHECKPOINT: <gate>" --sound request`.
92
+
93
+ ## Specialist pipeline rules
94
+
95
+ - **Dedicated consultant tab**: Consultants (agents spawned to gather synthesis input for decisions like SCOPER analysis or architectural reviews) must run in a DEDICATED tab separate from the ORCHESTRATOR tab. When the orchestrator stands down, the consultant panes should persist so their assessments remain available for review and reference.
96
+ - **Scoper worktree rule**: The SCOPER (or any worktree-based specialist synthesizing into the spec pipeline) must do ALL git operations in a dedicated worktree (e.g., `git worktree add /private/tmp/tkr-scoper-v155 -b spec/...`), never switching the main checkout's branch. This prevents race conditions between the specialist's branch operations and the orchestrator's shipping logic.
97
+
98
+ ## Non-negotiable rules
99
+
100
+ 1. **Takeover rule**: only act on a worker if it needs input AND the orchestrator is not `working`.
101
+ 2. **Human checkpoints (absolute)**: any gate marked `autonomous: false` or asking for product/visual
102
+ sign-off is NEVER auto-answered — regardless of how obviously correct the highlighted option looks. Leave
103
+ it blocked and bring the user the decision WITH evidence. If the mission explicitly delegates authority,
104
+ routine-class gates may be overseer-decided after polling the operator first — but spend and ship gates
105
+ NEVER self-decide.
106
+ 3. **Trust disk over transcripts**: verify artifacts on disk before building on them; a subagent killed
107
+ mid-flight still renders "Done" without writing its artifact.
108
+ 4. **Report concisely on every state change**: what happened, who handled it, what's next. Lead with the
109
+ outcome. Surface product decisions; never make them.
110
+ 5. **Log every abnormality** to `.planning/OBSERVATIONS.md` (or the project's ledger), even mid-run.
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env bash
2
+ # watch-panes.sh — Herdr overseer watcher.
3
+ # Polls worker + orchestrator agent_status via `herdr pane get`, prints ONE wake
4
+ # reason to stdout, then exits. Run it via the Bash tool with run_in_background
5
+ # so the overseer is re-invoked when it fires. Re-run (re-arm) after every wake.
6
+ #
7
+ # Usage:
8
+ # watch-panes.sh WORKER_PANE ORCH_PANE [--fast-blocked] [--cap-seconds N] [--settle-seconds N]
9
+ #
10
+ # Options:
11
+ # --fast-blocked Wake fast (20s debounce) whenever the WORKER blocks, even
12
+ # if the orchestrator is working. Use during stretches with
13
+ # human checkpoints. Without it, a blocked worker gets a 90s
14
+ # grace window for the orchestrator to handle it first
15
+ # (takeover rule), and only wakes if BOTH panes are quiet.
16
+ # --cap-seconds N Max watch duration (default 14400 = 4h).
17
+ # --settle-seconds N Initial sleep before polling (default 60) so a
18
+ # just-dispatched turn can start without an instant refire.
19
+ #
20
+ # Wake reasons printed:
21
+ # WORKER_PANE_GONE / ORCH_PANE_GONE — a pane disappeared
22
+ # ORCH:blocked WORKER:<s> — orchestrator itself needs input
23
+ # WORKER:blocked ORCH:<s> — worker blocked (fast-blocked mode)
24
+ # WORKER:<s> ORCH:<s> — both quiet: dropped handoff / done
25
+ # WATCH_CAP_REACHED ... — cap hit, everything still working
26
+ set -u
27
+ WORKER="${1:?worker pane id required}"
28
+ ORCH="${2:?orchestrator pane id required}"
29
+ shift 2
30
+ FAST_BLOCKED=false
31
+ CAP=14400
32
+ SETTLE=60
33
+ while [ $# -gt 0 ]; do
34
+ case "$1" in
35
+ --fast-blocked) FAST_BLOCKED=true ;;
36
+ --cap-seconds) CAP="$2"; shift ;;
37
+ --settle-seconds) SETTLE="$2"; shift ;;
38
+ esac
39
+ shift
40
+ done
41
+
42
+ get_status() {
43
+ herdr pane get "$1" 2>/dev/null | python3 -c '
44
+ import sys, json
45
+ try:
46
+ d = json.load(sys.stdin)
47
+ p = d.get("result", {}).get("pane", d.get("result", {}))
48
+ print(p.get("agent_status", "unknown"))
49
+ except Exception:
50
+ print("gone")
51
+ ' 2>/dev/null || echo gone
52
+ }
53
+
54
+ # Transient CLI/server failures also read as "gone" — recheck before declaring.
55
+ confirmed_gone() {
56
+ sleep 5
57
+ [ "$(get_status "$1")" = "gone" ]
58
+ }
59
+
60
+ END=$((SECONDS + CAP))
61
+ sleep "$SETTLE"
62
+ while [ $SECONDS -lt $END ]; do
63
+ WS=$(get_status "$WORKER")
64
+ OS=$(get_status "$ORCH")
65
+ if [ "$WS" = "gone" ]; then
66
+ confirmed_gone "$WORKER" && { echo "WORKER_PANE_GONE ORCH:$(get_status "$ORCH")"; exit 0; }
67
+ continue
68
+ fi
69
+ if [ "$OS" = "gone" ]; then
70
+ confirmed_gone "$ORCH" && { echo "ORCH_PANE_GONE WORKER:$(get_status "$WORKER")"; exit 0; }
71
+ continue
72
+ fi
73
+ # orchestrator itself stuck on a prompt (debounced)
74
+ if [ "$OS" = "blocked" ]; then
75
+ sleep 15
76
+ [ "$(get_status "$ORCH")" = "blocked" ] && { echo "ORCH:blocked WORKER:$WS"; exit 0; }
77
+ fi
78
+ # human-checkpoint mode: wake fast on any persisting worker block
79
+ if $FAST_BLOCKED && [ "$WS" = "blocked" ]; then
80
+ sleep 20
81
+ [ "$(get_status "$WORKER")" = "blocked" ] && { echo "WORKER:blocked ORCH:$(get_status "$ORCH")"; exit 0; }
82
+ fi
83
+ # standard mode: worker needs attention AND orchestrator is not driving
84
+ if [ "$WS" != "working" ]; then
85
+ sleep 90
86
+ WS2=$(get_status "$WORKER"); OS2=$(get_status "$ORCH")
87
+ if [ "$WS2" != "working" ] && [ "$OS2" != "working" ]; then
88
+ echo "WORKER:$WS2 ORCH:$OS2"; exit 0
89
+ fi
90
+ fi
91
+ sleep 20
92
+ done
93
+ echo "WATCH_CAP_REACHED WORKER:$(get_status "$WORKER") ORCH:$(get_status "$ORCH")"