tickmarkr 1.35.0 → 1.37.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 Khalid Alzahrani
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,49 +1,22 @@
1
+ <p align="center"><img src="assets/wordmark-dark.png" alt="tickmarkr" width="640"></p>
2
+
1
3
  # tickmarkr
2
4
 
3
5
  **Assertions are free. Tickmarks are earned.**
4
6
 
5
- tickmarkr is a spec-driven orchestration harness for AI coding agent CLIs framed as an external
6
- audit engagement. You scope assertions (acceptance criteria); the engine earns tickmarks (passed
7
- gates) only after independent verification. An **engagement** (`tickmarkr run`) compiles a spec
8
- into a task graph, routes work to the best installed agent CLI (claude-code, codex, cursor-agent,
9
- opencode, grok, pi) by cost within capability floors, dispatches **field teams** in isolated git
10
- worktrees as real visible TUIs when running under [herdr](https://herdr.dev), headless
11
- subprocesses otherwise and records every event in the **audit trail** (engagement state directory).
12
- Green tasks consolidate onto a `tickmarkr/<runId>` **consolidation** branch; **sign-off** (merge to
13
- your mainline) is always the Partner's call, never the engine's. A fully green engagement is an
14
- **unqualified opinion** — every assertion independently ticked.
7
+ tickmarkr is a spec-driven orchestration harness for AI coding agent CLIs. You write a spec with
8
+ acceptance criteria; the engine routes tasks to the best installed agent CLI (claude-code, codex,
9
+ cursor-agent, opencode, grok, pi) by cost and capability, dispatches work in isolated git worktrees
10
+ as interactive TUIs when running under [herdr](https://herdr.dev), headless subprocesses otherwise
11
+ and independently verifies every result before merge. Green tasks consolidate onto a
12
+ `tickmarkr/<runId>` branch; merging to your mainline is always your call, never automated. Engage
13
+ with full visibility into routing decisions, worker progress, and gate verdicts or run headless
14
+ and review the audit trail afterward.
15
15
 
16
16
  <!-- tickmarkr:compat-legacy BEGIN: sanctioned naming residue, unchanged pending shim spec -->
17
- **Legacy naming (unchanged):** tickmarkr reads existing state from `.drovr/` directories and resumes runs with legacy `drovr/<runId>` branch names recorded in their journals; new engagements write to `tickmarkr/<runId>`. Environment variables `DROVR_*` remain unchanged (e.g., `DROVR_FAKE_SCRIPT`, `DROVR_E2E`). Spec markers use the historical `<!-- drovr:spec -->` comment. These names are pending formalization in a future shim spec; no action required — existing runs and configurations remain fully compatible.
17
+ **Legacy naming (unchanged):** tickmarkr reads existing state from `.drovr/` directories and resumes runs with legacy `drovr/<runId>` branch names recorded in their journals; new engagements write to `tickmarkr/<runId>`. Environment variables `DROVR_*` remain unchanged (e.g., `DROVR_FAKE_SCRIPT`, `DROVR_E2E`). New specs use the `<!-- tickmarkr:spec -->` marker; the historical `<!-- drovr:spec -->` marker is still accepted. These names are pending formalization in a future shim spec; no action required — existing runs and configurations remain fully compatible.
18
18
  <!-- tickmarkr:compat-legacy END -->
19
19
 
20
- > The package ships `tickmarkr` and `tkr` only; `tkr` is the short alias.
21
-
22
- ## Vocabulary
23
-
24
- Audit voice in docs and reports maps to engine concepts exactly once:
25
-
26
- | Audit term | Engine concept |
27
- |---|---|
28
- | Partner | Human operator |
29
- | Manager | Overseer |
30
- | tickmarkr | Orchestrator daemon |
31
- | field teams / staff | Workers (agent CLIs in worktrees) |
32
- | seniors | Watchers (status monitor, gate panes) |
33
- | engagement | Run (`tickmarkr run` / `resume`) |
34
- | assertion | Acceptance criterion (`acceptance[]` entry) |
35
- | tickmark | Passed gate |
36
- | audit evidence | Evidence gate (commits/diff exist) |
37
- | audit trail | Journal (engagement event ledger) |
38
- | the consolidation | Integration branch (`tickmarkr/<runId>`) |
39
- | sign-off / issuance | Merge consolidation branch to main |
40
- | unqualified opinion | Green engagement (all tasks merged) |
41
- | qualified opinion | Failed or stalled engagement |
42
- | EQR | Acceptance judge (LLM gate vs `acceptance[]`) |
43
- | National Office | Consult (frontier-model escalation on deadlock) |
44
-
45
- Commands below use the real CLI surface — audit terms never replace bin names in examples.
46
-
47
20
  ## Invariants
48
21
 
49
22
  These are law; the codebase fails closed around them:
@@ -91,17 +64,17 @@ at 30s.
91
64
 
92
65
  ```bash
93
66
  tickmarkr init # guided setup + doctor; scaffolds config and spec template
94
- # edit tickmarkr.spec.md # the native spec template
95
- tickmarkr compile tickmarkr.spec.md # spec → engagement graph (fails without acceptance criteria)
96
- tickmarkr plan # dry-run routing table + cost estimate + floor lints
97
- tickmarkr run # execute the graph (--concurrency N --driver herdr|subprocess --route-strict)
67
+ # edit tickmarkr.spec.md # write your tasks and acceptance criteria
68
+ tickmarkr compile tickmarkr.spec.md # spec → task graph (fails without acceptance criteria)
69
+ tickmarkr plan # dry-run routing decisions + cost estimate
70
+ tickmarkr run # execute, route to best CLI, gate every result (--concurrency N)
98
71
  tickmarkr report <runId> --md # engagement record in Markdown
99
72
  ```
100
73
 
101
- That's the whole loop: `init` scaffolds, you write the spec's tasks and `acceptance[]`, `compile`
102
- turns it into a task graph, `plan` shows you the routing/cost dry run, `run` dispatches field teams
103
- and gates every result, and `report` writes the engagement record. Nothing merges to your mainline
104
- without your sign-off.
74
+ That's the flow: `init` scaffolds config, you write tasks with `acceptance[]` criteria, `compile`
75
+ validates and builds the graph, `plan` shows routing decisions and cost, `run` dispatches work to
76
+ installed CLIs and independently verifies every result, and `report` documents the outcome. All
77
+ green tasks land on `tickmarkr/<runId>` — merge to your mainline is your decision.
105
78
 
106
79
  ## Agent-ready repos: `tickmarkr init --agent`
107
80
 
@@ -126,33 +99,24 @@ Consent rules — every write is additive, never destructive:
126
99
  - non-interactive shells (no TTY) never prompt: missing skills are installed, everything else is
127
100
  skipped, and the summary names exactly what was skipped and which flag would enable it
128
101
 
129
- Supervise and finish:
102
+ ## Monitor and supervise
130
103
 
131
104
  ```bash
132
- tickmarkr status # live engagement state (--watch to follow)
133
- tickmarkr resume <runId> # continue an engagement from its audit trail
134
- tickmarkr approve <runId> <taskId> # Partner sign-off on a parked task (--by <name> --reason <text>)
105
+ tickmarkr status # engagement state (--watch to follow live)
106
+ tickmarkr resume <runId> # continue an engagement from the audit trail
107
+ tickmarkr approve <runId> <taskId> # approve a parked task (--reason to document)
135
108
  tickmarkr report <runId> # cost/quality report
136
109
  tickmarkr profile # show the learned routing profile
137
- tickmarkr unlock # remove a stale/garbage run lock (refuses if the holder is alive)
138
110
  ```
139
111
 
140
- Green tasks land on the engagement's `tickmarkr/<runId>` consolidation branch; sign-off to your
141
- mainline is your call, never tickmarkr's.
142
-
143
- ### Escalation and National Office verdicts
112
+ Green tasks land on `tickmarkr/<runId>`; merge to your mainline is always your call.
144
113
 
145
- When gates or field teams stall, tickmarkr escalates to a frontier-model **National Office** consult
146
- for a structured verdict. The consult can recommend rerouting to a different adapter/model channel,
147
- or human approval if deadlock persists. Reroute verdicts can target two scopes:
114
+ ### Escalation and consults
148
115
 
149
- - **Channel-level reroute**: exclude a single channel (e.g., `claude-code:opus-4-8`) and try the next ranked channel
150
- - **Adapter-level reroute**: exclude an entire adapter (e.g., `cursor-agent`) when the consult detects an environmental
151
- CLI-level failure (blocked dialog, auth issue, timeout pattern). The daemon expands the task's exclusion list to cover
152
- every available channel of that adapter, ensuring zero re-try of the same root cause
153
-
154
- Both exclusion types persist across resume — a task that failed due to a consult reroute will never re-try excluded channels
155
- or adapters in a subsequent `tickmarkr resume`.
116
+ When gates or tasks stall, tickmarkr escalates to a frontier-model consult for a structured verdict.
117
+ The consult can recommend rerouting (exclude a failed channel/adapter and try the next ranked option)
118
+ or human approval if deadlock persists. Exclusions persist across resume a task that escalated
119
+ away from a failing adapter will never retry it in subsequent `tickmarkr resume` calls.
156
120
 
157
121
  ### Approving tasks
158
122
 
@@ -162,15 +126,9 @@ or adapters in a subsequent `tickmarkr resume`.
162
126
  - The task finished with a result but gates require human judgment (`humanGate: true` in the spec)
163
127
  - Approving records the Partner's verdict and the task proceeds to merge
164
128
 
165
- **Attempt-cap parks** (attempt budget exhausted):
129
+ **Attempt-budget exhaustion**:
166
130
  - The task has burned its full attempt budget without reaching a conclusive result
167
- - All channels (including adapter exclusions from prior consults) remain excluded from retry
168
- - Approving grants a **fresh attempt budget** with the same exclusion list — the task re-routes around all previously-burned channels
169
- and adapters, giving it one more escalation ladder before exhaustion
170
-
171
- Example: a task escalates away from cursor-agent due to a trust dialog (adapter-level exclusion), then away from all claude-code
172
- models before hitting the attempt cap. `tickmarkr approve` grants a fresh ladder to try the frontier tier, still avoiding both
173
- cursor-agent and claude-code entirely.
131
+ - Approving grants a fresh attempt budget, routing around all previously-failed channels and adapters
174
132
 
175
133
  ## Model scoping and auth detection
176
134
 
@@ -178,8 +136,8 @@ Each agent CLI exposes a list of available models. tickmarkr's routing works onl
178
136
 
179
137
  **Model terminology**:
180
138
 
181
- - **Listed models**: All models a CLI advertises as available (e.g., pi advertises both `pi:glm-5.2` and `pi:anthropic-claude-opus-4-8`, but not all are authed)
182
- - **Scoped models**: Listed models with an explicit CLI namespace prefix (e.g., `pi:glm-5.2`, `openai:gpt-4o`). The design rule is to classify scoped models primarily, not unscoped listed models
139
+ - **Listed models**: All models a CLI advertises as available (e.g., pi advertises both `zai/glm-5.2` and `anthropic/claude-opus-4-8`, but not all are authed)
140
+ - **Scoped models**: Listed models with an explicit provider prefix (e.g., `zai/glm-5.2`, `zai-coding-plan/glm-5.2`). The design rule is to classify scoped models primarily, not unscoped listed models
183
141
  - **Classified models**: Models you've entered into `tiers` — the routing-eligible set, regardless of scope
184
142
 
185
143
  When you run `tickmarkr doctor`, it:
@@ -202,18 +160,17 @@ You can bench a classified model without removing it from tiers:
202
160
  routing:
203
161
  deny:
204
162
  models:
205
- - pi:glm-5.2 # stays in tiers, never routes
206
- - openai:gpt-4o # reason in git commit
163
+ - pi:zai/glm-5.2 # stays in tiers, never routes (key is adapter:model)
164
+ - cursor-agent:composer-2.5 # reason in git commit
207
165
  ```
208
166
  `tickmarkr plan` lints denied models identically to unauthed ones. Re-enable by deleting one line.
209
167
 
210
- ## Run output and narration
168
+ ## Run output and audit trail
211
169
 
212
- When you execute `tickmarkr run`, the daemon streams live narration of each audit-trail event to stdout — task status changes,
213
- gate results (tickmarks), worker dispatch, and merge commits. This narration is derived from the append-only event ledger,
214
- which remains the authoritative source of truth for resumability. If an engagement is interrupted, `tickmarkr resume <runId>`
215
- replays the ledger and picks up where it left off. You can also tail the ledger directly for raw event inspection or
216
- feed it to external tools; the narration is a convenience layer on top.
170
+ When you execute `tickmarkr run`, the daemon records every event in an append-only ledger (the audit trail):
171
+ task dispatch, gate verdicts, worker status, and merges. Narration streams to stdout; you can also
172
+ `--watch` or tail the ledger directly. If interrupted, `tickmarkr resume <runId>` replays the ledger
173
+ and continues from the last stable state deterministic, not ephemeral.
217
174
 
218
175
  ## Usage and cost
219
176
 
@@ -243,9 +200,14 @@ and first-attempt success rate. Cost reporting follows strict honesty rules and
243
200
  comments and LiteLLM's JSON file named as the copy-from source; tickmarkr never calls home to fetch rates
244
201
  - **Attribution from journal**: token counts come from tickmarkr's own telemetry spans in the audit trail, never from provider invoices or dashboards
245
202
 
246
- ## Visibility: supervised fleet UX
203
+ ## Visibility: optional supervised workspace
247
204
 
248
- When running under [herdr](https://herdr.dev), tickmarkr creates a visible, labeled pane-and-tab workspace:
205
+ When running under [herdr](https://herdr.dev), tickmarkr creates a labeled pane-and-tab workspace
206
+ for real-time visibility (optional — omit `--driver herdr` or run headless if preferred).
207
+
208
+ tickmarkr borrows audit-firm vocabulary for its roles: **you** are the *Partner* (final sign-off),
209
+ workers are the *field team*, the acceptance judge is the *EQR* (engagement quality reviewer), and
210
+ the frontier-model consult is the *National Office*. The terms below use that vocabulary:
249
211
 
250
212
  ### Tab vocabulary
251
213
 
@@ -289,7 +251,7 @@ Every pane and tab tickmarkr creates receives a **parseable ownership name** enc
289
251
  - `watch` — status monitor pane
290
252
  - `<role> · <taskId> · A<attempt> · R<runId>` — judge, review, consult, and worker panes (formats like `judge · task-abc123 · A1 · Rrun-20260713-175532`)
291
253
 
292
- tickmarkr creates all owned panes only within the run's workspace; any tickmarkr-owned panes discovered outside the run's workspace (from prior runs or placement bugs) are reconciled and closed along with their emptied tabs. Any pane not matching the ownership contract is **foreign** — created by the operator or another tool — and is never a candidate for closing.
254
+ tickmarkr creates all owned panes only within the run's workspace; any tickmarkr-owned panes discovered outside the run's workspace (from prior runs or placement bugs) are reconciled and closed. Any pane not matching the ownership contract is **foreign** — created by you or another tool — and is never closed automatically.
293
255
 
294
256
  **Desired-state reconciliation**: A pure function computes the exact set of panes that should exist from the audit trail at any moment:
295
257
  - Worker panes for all in-flight task attempts
@@ -305,138 +267,57 @@ The daemon reconciles at every safe point:
305
267
 
306
268
  Reconciliation failures (herdr unavailable, a pane vanished mid-sweep) never fail the engagement — visibility is cosmetic, gates are law.
307
269
 
308
- ### Workspace-trust pre-flight
309
-
310
- tickmarkr runs field teams in fresh git worktrees cloned from your repo. Before dispatch, every worker checks whether that repo is trusted by its CLI, fail-closed:
311
-
312
- **Seeding trust at doctor time**:
313
- - `tickmarkr doctor` probes each adapter's trust status for your repo root
314
- - Adapters that can pre-seed trust (codex via `config.toml`, others if applicable) do so automatically; you see `seeded` in the doctor output
315
- - Adapters requiring manual trust grant show `action-required` with the exact one-time command to run (e.g., `codex init /path/to/repo`)
316
- - Adapters without a trust concept report `n/a`
317
-
318
- **Auto-answering trust dialogs during runs**:
319
- - Some CLIs (e.g., cursor-agent) cannot pre-seed trust and show an interactive "Workspace Trust Required" dialog the first time they touch the worktree
320
- - For these CLIs, tickmarkr detects the trust dialog by its fingerprint and auto-answers it exactly once per slot — safe because tickmarkr created the worktree from your own repo
321
- - Any other blocked dialog still pages the operator for manual approval
270
+ ### Workspace trust
322
271
 
323
- ## Spec front-ends (what compiles today)
272
+ tickmarkr runs work in fresh git worktrees cloned from your repo. Before dispatch, workers check
273
+ whether the repo is trusted by their CLI:
324
274
 
325
- tickmarkr is SDD-agnostic downstream of `compile` routing, gates, and merges never know which methodology
326
- produced the graph. Upstream, these front-ends exist:
275
+ - `tickmarkr doctor` probes each CLI's trust status and auto-seeds trust where possible
276
+ - Some CLIs show a "Workspace Trust" dialog; tickmarkr detects and auto-answers it once per task
277
+ - Other blocked dialogs page you for manual approval
327
278
 
328
- | Spec source | Support | How |
329
- |---|---|---|
330
- | **tickmarkr spec** (native format) | Native/default | `tickmarkr init` template with native spec marker |
331
- | **Spec Kit** feature dir | Native | dir containing `tasks.md` |
332
- | **GSD** phase dir | Native | dir containing `*-PLAN.md` |
333
- | **Markdown PRD** | Native (universal adapter) | any `.md` with tasks + acceptance criteria |
334
- | OpenSpec, BMAD, Superpowers, others | Not yet | render to a tickmarkr spec (with full Task surface), or contribute a front-end: one parser module behind `src/compile/index.ts`, same pattern as `gsd.ts` |
279
+ ## Spec formats
335
280
 
336
- Compile fails loudly on anything it can't recognize (`--type native|speckit|prd|gsd` to force).
281
+ tickmarkr compiles several formats into its internal task graph. The most common:
337
282
 
338
- ## Planning layer: intent → spec → engagement
339
-
340
- tickmarkr pairs a lightweight planning step (`tickmarkr scope`) with the existing compile/run flow. The flow is:
341
-
342
- ```
343
- intent file (prose)
344
- ↓ [tickmarkr scope: 3-question gate]
345
- ↓ [clarification ≤3 blocking Qs]
346
- native spec (REQ-nn → oracle → task)
347
- ↓ [tickmarkr compile]
348
- ↓ [fails without acceptance criteria]
349
- task graph + routing table
350
- ↓ [tickmarkr run]
351
- ↓ [gates never trust workers]
352
- merged green tasks + engagement record
353
- ```
283
+ | Format | Example |
284
+ |---|---|
285
+ | **tickmarkr native** (default) | `tickmarkr init` template with native spec marker |
286
+ | **Markdown PRD** | Any `.md` file with task sections and `acceptance[]` criteria |
287
+ | **Spec Kit** | A [Spec Kit](https://github.com/github/spec-kit) spec directory |
288
+ | **GSD** | A GSD `.planning/` phase plan |
354
289
 
355
- **Clarification gate**: The `tickmarkr scope` CLI enforces ≤3 blocking questions. Drafting refuses until all are answered; residual uncertainty becomes explicit assumptions in the spec.
290
+ Compile fails loudly if it cannot recognize the format or if `acceptance[]` is missing. Use
291
+ `--type native|prd|speckit|gsd` to force a specific format.
356
292
 
357
- **Oracle types** assertions are typed for deterministic verification:
293
+ ## Acceptance criteria (assertions)
358
294
 
359
- - `oracle: command <cmd>` exit code (0 = pass); most reliable
360
- - `oracle: test <name>` — run a named test (must exist in the task's acceptance[] context)
361
- - `oracle: judge <rubric>` — EQR verdict, fail-closed, never overrides failed command/test
295
+ Every task requires explicit `acceptance[]` criteria; compile fails without them. Three oracle types:
362
296
 
363
- Untyped acceptance strings in existing specs compile as `oracle: judge` + warning (read-old/write-new compat, same as the rename precedent).
297
+ - **`command <cmd>`** pass if exit code 0; most reliable
298
+ - **`test <name>`** — run a named test from your suite (must exist)
299
+ - **`judge <rubric>`** — LLM verdict against your rubric; fail-closed, never overrides failed command/test
364
300
 
365
- **Scope semantics**: Plan-time blast radius is advisory + structured `{paths, confidence, reason}` — never authoritative. The run-time scope gate verifies actual file changes; if a worker requests widening, the daemon pauses as a designed human gate for operator approval.
301
+ At runtime, the **scope gate** verifies that workers only touched files declared in the spec. If a
302
+ worker must change out-of-scope paths, the task pauses as a human gate — your approval is required.
366
303
 
367
- ## Claude Code skills (ship with the repo)
304
+ ## Claude Code integration
368
305
 
369
- Cloning this repo gives Claude Code project skills under `.claude/skills/`:
306
+ If you clone this repo and use Claude Code, project skills are installed in `.claude/skills/`:
370
307
 
371
- - **`/tickmarkr-loop`** — the SDD-agnostic loop: compile one spec, read the plan, run from the journal, merge green work, and commit only the Markdown execution record beside that spec. For GSD bookkeeping, use `/tickmarkr-auto`.
372
- - **`/tickmarkr-auto`** — the **GSD binding**: run remaining GSD milestone phases autonomously with tickmarkr as the
373
- executor (plans → compile → dry-run → run → merge → sync-back). Bindings are per-SDD by nature — the
374
- sync-back writes that methodology's artifacts; other SDDs need their own thin binding, not this one.
375
- - **`/tickmarkr-overseer`** — supervise an autonomous tickmarkr engagement from a Herdr workspace: two-tier visible hierarchy,
376
- journal-based watchers, gate-failure triage, human-checkpoint routing. Requires `HERDR_ENV=1`.
308
+ - **`/tickmarkr-loop`** — compile a spec, review the routing plan, run the engagement, and commit the Markdown record
309
+ - **`/tickmarkr-auto`** — autonomous multi-phase runs (GSD milestones, etc.)
377
310
 
378
- Repo-scoped: available automatically in Claude Code sessions inside this repo; not installed by npm.
311
+ These are optional — the CLI works standalone. Skills are repo-scoped and ship in the npm tarball for agents working in projects that have run `tickmarkr init --agent`.
379
312
 
380
313
  ## Contributing
381
314
 
382
- ### Dev setup
383
-
384
- ```bash
385
- git clone <your fork>
386
- cd tickmarkr
387
- npm install
388
- npm run build # tsc → dist/; the only enforced static check
389
- npm link # exposes the tickmarkr/tkr bins from your checkout
390
- ```
391
-
392
- ### Tests are zero-token by design
393
-
394
- ```bash
395
- npm test # vitest unit+integration — fake adapter only, spends no tokens, ever
396
- npm run test:coverage # same suite, coverage floors enforced (see below)
397
- npm run e2e # opt-in real-CLI end-to-end — DOES spend tokens, needs ≥1 agent CLI installed
398
- ```
399
-
400
- `npm test` and `npm run test:coverage` must never call a real agent CLI or spend tokens —
401
- test isolation uses deterministic fixtures. If a change needs a real CLI to verify, that test
402
- belongs under `npm run e2e` (`tests/e2e/`), an opt-in test suite not run in CI.
403
-
404
- Coverage floor: `src/{graph,route,gates,run}/**` must stay at lines 80% / functions 80% /
405
- branches 70% — this is the CLAUDE.md invariant, enforced verbatim in `vitest.config.ts`.
406
- `src/compile/**`, `src/adapters/**`, and `src/drivers/**` carry their own (higher) measured
407
- thresholds in the same file.
408
-
409
- ### The spec-driven workflow — the gates are the review
410
-
411
- Changes to tickmarkr itself land the same way tickmarkr expects consumer changes to land: write a
412
- native spec (`specs/<version>-<name>.spec.md`, marked `<!-- tickmarkr:spec -->`), give every task
413
- real `acceptance[]` criteria, then compile/plan/run it and let the gates (baseline, evidence, scope,
414
- acceptance-judge, cross-vendor review) independently verify the diff before it merges. PRs are
415
- welcome, but a PR that only "looks right" isn't the bar — a green, gated engagement is. If you're
416
- proposing a non-trivial change, sketch it as a spec first; small fixes and docs can go straight to
417
- a PR.
418
-
419
- ### Adding a worker adapter
420
-
421
- Support for a new agent CLI is one new file implementing `WorkerAdapter`
422
- (`src/adapters/types.ts`) — `id`, `vendor`, `probe()`, `channels()`, `headlessCommand()`,
423
- `interactiveCommand()`, `invoke()`, `parse()`, plus the optional `listModels`/`resumeCommand`
424
- capabilities other adapters use when the CLI supports them. Use an existing adapter as the
425
- template (`src/adapters/codex.ts` is a good starting shape), then register it with one line in
426
- `src/adapters/registry.ts`'s `allAdapters()` — append it, don't insert it in the middle, since
427
- same-tier routing ties resolve by discovery order. No other file needs to know a new adapter
428
- exists.
429
-
430
- ### Filing defects
431
-
432
- Abnormalities found during a real engagement (not routine bugs — surprising or systemic
433
- behavior) are logged as a numbered entry (`OBS-NN`) in `.planning/OBSERVATIONS.md`, with a status
434
- (`OPEN` / `CLOSED-FIXED` / `CLOSED-MITIGATED`) and, once fixed, the spec/commit that closed it.
435
- That file is the standing abnormality ledger for this repo — read it before assuming a rough edge
436
- is new.
437
-
438
- See the design spec in docs/superpowers/specs/.
315
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, the green bar (build/test/lint), and
316
+ design invariants. Pull requests welcome; non-trivial changes should include test coverage.
439
317
 
440
- ## License
318
+ ## Documentation
441
319
 
442
- MIT
320
+ - **[LICENSE](LICENSE)** — MIT license
321
+ - **[CONTRIBUTING.md](CONTRIBUTING.md)** — development setup and contribution guidelines
322
+ - **[SECURITY.md](SECURITY.md)** — security policy and private vulnerability reporting
323
+ - **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)** — Contributor Covenant
@@ -38,6 +38,7 @@ export function probeVersion(bin) {
38
38
  export const claudeCode = {
39
39
  id: "claude-code",
40
40
  vendor: "anthropic",
41
+ probeCwd: "neutral",
41
42
  probe: async () => probeVersion("claude"),
42
43
  channels: (cfg) => channelsFromConfig("claude-code", cfg),
43
44
  // --strict-mcp-config --mcp-config '{"mcpServers":{}}': pin the MCP surface to empty so fresh-worktree
@@ -83,6 +83,7 @@ export const grok = {
83
83
  // FLEET-04 cross-vendor honesty: xai is diversity-distinct from anthropic/openai/cursor/zhipu.
84
84
  // MUST equal tiers.grok.vendor (a test pins the equality).
85
85
  vendor: "xai",
86
+ probeCwd: "neutral",
86
87
  probe: async () => {
87
88
  const h = probeVersion("grok");
88
89
  if (!h.installed)
@@ -1,11 +1,13 @@
1
- import type { DrovrConfig } from "../config/config.js";
1
+ import { type DrovrConfig } from "../config/config.js";
2
2
  import { type AuthHealth, type WorkerAdapter } from "./types.js";
3
3
  export declare const SEED_STAMPED = "2026-07-09";
4
4
  export declare const MODEL_STALE_DAYS = 30;
5
5
  export declare const ttyVisual: () => boolean;
6
+ export declare function seedPreferLints(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], overlayPreferShapes?: ReadonlySet<string>): string[];
6
7
  export declare function modelLints(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], opts?: {
7
8
  tty?: boolean;
8
9
  stateDir?: string;
10
+ overlayPreferShapes?: ReadonlySet<string>;
9
11
  }): string[];
10
12
  export declare function formatModelAuthLine(excluded: {
11
13
  key: string;
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_CONFIG, TIER_RANK } from "../config/config.js";
1
2
  import { MODEL_ID_RE } from "./types.js";
2
3
  // date the tiers seeds were last live-verified (Phase 6/7 reseed) — surfaced when an adapter has no list surface.
3
4
  export const SEED_STAMPED = "2026-07-09";
@@ -13,6 +14,34 @@ const TTY_LINT_CAP = 3;
13
14
  const DEFAULT_STATE_DIR = ".drovr";
14
15
  const doctorJsonRef = (stateDir) => ` — see ${stateDir}/doctor.json`;
15
16
  export const ttyVisual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
17
+ const adapterHasAuthedChannel = (adapterId, shape, cfg, health, adapters) => {
18
+ const entry = cfg.routing.map[shape];
19
+ const minTier = entry?.tier ?? cfg.routing.floors[shape] ?? "cheap";
20
+ const a = adapters.find((x) => x.id === adapterId);
21
+ const h = health[adapterId];
22
+ if (!a || !h?.installed || typeof a.channels !== "function")
23
+ return false;
24
+ if (!h.modelAuth || !Object.keys(h.modelAuth).length)
25
+ return true; // no per-model probe data — compat, not dead
26
+ return a.channels(cfg).some((c) => TIER_RANK[c.tier] >= TIER_RANK[minTier] && h.modelAuth?.[c.model]?.authed === true);
27
+ };
28
+ // OBS-30 T2: warn when a built-in seed prefer names an adapter with zero authed channels this probe pass.
29
+ export function seedPreferLints(cfg, health, adapters, overlayPreferShapes = new Set()) {
30
+ const lints = [];
31
+ for (const shape of Object.keys(cfg.routing.map)) {
32
+ if (overlayPreferShapes.has(shape))
33
+ continue;
34
+ for (const p of DEFAULT_CONFIG.routing.map[shape]?.prefer ?? []) {
35
+ const adapterId = p.includes(":") ? p.slice(0, p.indexOf(":")) : p;
36
+ if (!cfg.tiers[adapterId])
37
+ continue;
38
+ if (!adapterHasAuthedChannel(adapterId, shape, cfg, health, adapters)) {
39
+ lints.push(`routing seed names dead adapter '${adapterId}' for shape '${shape}' — auto-prefer is routing around it`);
40
+ }
41
+ }
42
+ }
43
+ return lints;
44
+ }
16
45
  // Diffs detected models (doctor.json) against configured tiers, both directions, per adapter id in cfg.tiers.
17
46
  // No ` ! ` prefix here — the consumer (doctor rows / plan lints) owns that. Pre-v1.5 doctor.json (models:[], no
18
47
  // modelsDetectedAt) is the compat baseline: `?.`/`?? []` everywhere, no zod (would reject old files).
@@ -54,6 +83,7 @@ export function modelLints(cfg, health, adapters, opts) {
54
83
  lints.push(`${id}: model knowledge is ${days} days old — rerun tickmarkr doctor`);
55
84
  }
56
85
  }
86
+ lints.push(...seedPreferLints(cfg, health, adapters, opts?.overlayPreferShapes));
57
87
  return lints;
58
88
  }
59
89
  // T2/T6: one lint per exclusion, naming the probe reason and date. TTY truncates reasons to 60 chars and
@@ -16,6 +16,7 @@ export function parseOpencodeModels(raw) {
16
16
  export const opencode = {
17
17
  id: "opencode",
18
18
  vendor: "mixed",
19
+ probeCwd: "neutral",
19
20
  probe: async () => probeVersion("opencode"),
20
21
  channels: (cfg) => channelsFromConfig("opencode", cfg),
21
22
  headlessCommand: (promptFile, model) => `opencode run -m ${shq(model)} "$(cat ${shq(promptFile)})"`,
@@ -37,6 +37,7 @@ export const pi = {
37
37
  // FLEET-04: cross-vendor review honesty — GLM's provider (pi's own label is "zai"; either is
38
38
  // diversity-distinct from anthropic/openai/cursor/mixed). Must equal tiers.pi.vendor.
39
39
  vendor: "zhipu",
40
+ probeCwd: "neutral",
40
41
  probe: async () => {
41
42
  const h = probeVersion("pi");
42
43
  if (!h.installed)
@@ -1,4 +1,5 @@
1
- import type { DrovrConfig } from "../config/config.js";
1
+ import { type DrovrConfig } from "../config/config.js";
2
+ import { type RoutingProfile } from "../route/profile.js";
2
3
  import { type AuthHealth, type BillingChannel, type WorkerAdapter } from "./types.js";
3
4
  export declare function allAdapters(opts?: {
4
5
  fakeScriptPath?: string;
@@ -7,6 +8,21 @@ export declare function getAdapter(id: string, adapters: WorkerAdapter[]): Worke
7
8
  export declare function probeAll(adapters: WorkerAdapter[]): Promise<Record<string, AuthHealth>>;
8
9
  export type ProbeModelStatus = "ok" | "timeout" | "failed";
9
10
  export type ProbeModelProgress = (adapter: string, model: string, status: ProbeModelStatus, durationMs: number) => void;
11
+ export interface AutoPreferDoc {
12
+ derivedAt: string;
13
+ [shape: string]: string[] | string;
14
+ }
15
+ export declare const pendingAutoPreferKey: unique symbol;
16
+ export declare const DOCTOR_ROUTING_STALE_MS: number;
17
+ export declare function deriveAutoPrefer(cfg: DrovrConfig, adapters: WorkerAdapter[], health: Record<string, AuthHealth>, profile?: RoutingProfile): AutoPreferDoc;
18
+ export declare function readAutoPrefer(repoRoot: string): AutoPreferDoc | null;
19
+ export declare function routingPreferContext(repoRoot: string, cfg: DrovrConfig, opts?: {
20
+ globalDir?: string;
21
+ }): {
22
+ autoPrefer?: AutoPreferDoc;
23
+ doctorFresh: boolean;
24
+ overlayPreferShapes: ReadonlySet<string>;
25
+ };
10
26
  export declare function probeModels(cfg: DrovrConfig, repoRoot: string, adapters: WorkerAdapter[], health: Record<string, AuthHealth>, onProgress?: ProbeModelProgress): Promise<void>;
11
27
  export declare function writeDoctor(repoRoot: string, health: Record<string, AuthHealth>): void;
12
28
  export declare function readDoctor(repoRoot: string): Record<string, AuthHealth> | null;
@@ -1,10 +1,13 @@
1
1
  import { existsSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { overlayPreferShapes, TIER_RANK } from "../config/config.js";
4
5
  import { modelLints, suggestOverlay } from "./model-lints.js";
5
6
  import { HerdrDriver } from "../drivers/herdr.js";
6
7
  import { drovrDir, stateDirName } from "../graph/graph.js";
7
8
  import { disallowedBy, excludedChannels, exclusionLine, preferRanks } from "../route/preference.js";
9
+ import { learnedScore } from "../route/profile.js";
10
+ import { marginalCostRank } from "../route/router.js";
8
11
  import { sh } from "../run/git.js";
9
12
  import { claudeCode } from "./claude-code.js";
10
13
  import { codex } from "./codex.js";
@@ -47,6 +50,57 @@ function probeFailure(code, stdout, stderr, timedOut, timeoutMs = MODEL_PROBE_TI
47
50
  ? output.slice(0, 240) || `probe exited ${code}`
48
51
  : undefined;
49
52
  }
53
+ export const pendingAutoPreferKey = Symbol.for("tickmarkr.pendingAutoPrefer");
54
+ const median = (xs) => {
55
+ const s = [...xs].sort((a, b) => a - b);
56
+ const m = Math.floor(s.length / 2);
57
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
58
+ };
59
+ // ponytail: 24h TTL matches plan.ts staleness — promote to config when an operator asks.
60
+ export const DOCTOR_ROUTING_STALE_MS = 24 * 60 * 60 * 1000;
61
+ export function deriveAutoPrefer(cfg, adapters, health, profile) {
62
+ const derivedAt = new Date().toISOString();
63
+ const out = { derivedAt };
64
+ for (const [shape, entry] of Object.entries(cfg.routing.map)) {
65
+ const minTier = entry?.tier ?? cfg.routing.floors[shape] ?? "cheap";
66
+ const ranked = [];
67
+ for (const a of adapters) {
68
+ const h = health[a.id];
69
+ if (!h?.installed || !h?.authed || typeof a.channels !== "function")
70
+ continue;
71
+ const qualifying = a.channels(cfg).filter((c) => TIER_RANK[c.tier] >= TIER_RANK[minTier] && h.modelAuth?.[c.model]?.authed === true);
72
+ if (!qualifying.length)
73
+ continue;
74
+ const durations = qualifying
75
+ .map((c) => h.modelAuth?.[c.model]?.durationMs)
76
+ .filter((d) => typeof d === "number");
77
+ ranked.push({
78
+ adapter: a.id,
79
+ mc: Math.min(...qualifying.map(marginalCostRank)),
80
+ dur: durations.length ? median(durations) : Number.POSITIVE_INFINITY,
81
+ learned: profile ? Math.max(...qualifying.map((c) => learnedScore(profile, shape, channelKey(c), c.channel))) : 0,
82
+ });
83
+ }
84
+ ranked.sort((a, b) => a.mc - b.mc || a.dur - b.dur || b.learned - a.learned);
85
+ out[shape] = ranked.map((r) => r.adapter);
86
+ }
87
+ return out;
88
+ }
89
+ export function readAutoPrefer(repoRoot) {
90
+ const raw = readDoctorFile(repoRoot);
91
+ if (!raw || typeof raw.autoPrefer !== "object" || raw.autoPrefer === null)
92
+ return null;
93
+ return raw.autoPrefer;
94
+ }
95
+ export function routingPreferContext(repoRoot, cfg, opts = {}) {
96
+ const age = doctorAgeMs(repoRoot);
97
+ const doctorFresh = age !== null && age <= DOCTOR_ROUTING_STALE_MS;
98
+ return {
99
+ autoPrefer: doctorFresh ? readAutoPrefer(repoRoot) ?? undefined : undefined,
100
+ doctorFresh,
101
+ overlayPreferShapes: overlayPreferShapes(repoRoot, opts),
102
+ };
103
+ }
50
104
  const probeModelStatus = (v) => v.authed ? "ok" : v.reason?.includes("timed out") ? "timeout" : "failed";
51
105
  // v1.21: one bounded, headless call per configured model; detected-but-unclassified models never enter this loop.
52
106
  export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
@@ -62,6 +116,7 @@ export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
62
116
  return;
63
117
  const verdicts = {};
64
118
  const priorModelAuth = priorHealth?.[a.id]?.modelAuth;
119
+ const probeRoot = a.probeCwd === "neutral" ? mkdtempSync(join(tmpdir(), "tickmarkr-probe-")) : repoRoot;
65
120
  // models probe concurrently too (v1.33.5) — sequential chains made init wall time Σ(models×60s)
66
121
  // on the slowest adapter (measured 96s); each probe is one tiny prompt, safe to overlap.
67
122
  // Capped at MODEL_PROBE_CONCURRENCY per adapter (v1.33.5 regression: 4 concurrent codex exec
@@ -78,12 +133,12 @@ export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
78
133
  else {
79
134
  const promptFile = join(mkdtempSync(join(tmpdir(), "drovr-auth-")), "probe.md");
80
135
  writeFileSync(promptFile, MODEL_PROBE_PROMPT);
81
- const r = await sh(a.headlessCommand(promptFile, model), repoRoot, MODEL_PROBE_TIMEOUT_MS);
136
+ const r = await sh(a.headlessCommand(promptFile, model), probeRoot, MODEL_PROBE_TIMEOUT_MS);
82
137
  if (r.timedOut && priorTimedOut) {
83
138
  verdict = { authed: false, reason: `probe timed out (repeat — retry skipped) (${MODEL_PROBE_TIMEOUT_MS}ms)`, probedAt, durationMs: Date.now() - t0 };
84
139
  }
85
140
  else if (r.timedOut) {
86
- const r2 = await sh(a.headlessCommand(promptFile, model), repoRoot, MODEL_PROBE_TIMEOUT_MS);
141
+ const r2 = await sh(a.headlessCommand(promptFile, model), probeRoot, MODEL_PROBE_TIMEOUT_MS);
87
142
  if (r2.timedOut) {
88
143
  verdict = { authed: false, reason: `probe timed out twice (${MODEL_PROBE_TIMEOUT_MS}ms)`, probedAt, durationMs: Date.now() - t0 };
89
144
  }
@@ -106,6 +161,7 @@ export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
106
161
  });
107
162
  h.modelAuth = verdicts;
108
163
  }));
164
+ health[pendingAutoPreferKey] = deriveAutoPrefer(cfg, adapters, health);
109
165
  }
110
166
  // T2: caps concurrent probes per adapter at 2 — v1.33.5 regression, 4 concurrent codex exec in one
111
167
  // repo made ALL 4 time out where sequential passed 2/4 (suspected CLI self-contention).
@@ -121,12 +177,24 @@ async function mapLimit(items, limit, fn) {
121
177
  await Promise.all(workers);
122
178
  }
123
179
  const doctorPath = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "doctor.json");
180
+ function readDoctorFile(repoRoot) {
181
+ return existsSync(doctorPath(repoRoot)) ? JSON.parse(readFileSync(doctorPath(repoRoot), "utf8")) : null;
182
+ }
124
183
  export function writeDoctor(repoRoot, health) {
125
184
  drovrDir(repoRoot);
126
- writeFileSync(doctorPath(repoRoot), JSON.stringify(health, null, 2) + "\n");
185
+ const pending = health[pendingAutoPreferKey];
186
+ const payload = { ...health };
187
+ delete payload[pendingAutoPreferKey];
188
+ if (pending)
189
+ payload.autoPrefer = pending;
190
+ writeFileSync(doctorPath(repoRoot), JSON.stringify(payload, null, 2) + "\n");
127
191
  }
128
192
  export function readDoctor(repoRoot) {
129
- return existsSync(doctorPath(repoRoot)) ? JSON.parse(readFileSync(doctorPath(repoRoot), "utf8")) : null;
193
+ const raw = readDoctorFile(repoRoot);
194
+ if (!raw)
195
+ return null;
196
+ const { autoPrefer: _, ...health } = raw;
197
+ return health;
130
198
  }
131
199
  export function discoverChannels(cfg, adapters, health) {
132
200
  const base = adapters
@@ -72,6 +72,7 @@ export declare function matchesTrustDialog(paneText: string, dialog: TrustDialog
72
72
  export interface WorkerAdapter {
73
73
  id: string;
74
74
  vendor: string;
75
+ probeCwd?: "repo" | "neutral";
75
76
  probe(): Promise<AuthHealth>;
76
77
  channels(cfg: DrovrConfig): BillingChannel[];
77
78
  headlessCommand(promptFile: string, model: string): string;
@@ -53,4 +53,4 @@ export const QUOTA_RE = /rate.?limit|quota|usage limit|out of credits|insufficie
53
53
  // operator-facing lint text and persisted to doctor.json — defense-in-depth for MODEL-05 (config
54
54
  // suggestions that could reach a shell). Covers observed ids incl. zai-coding-plan/glm-5.2,
55
55
  // gpt-5.6-sol, composer-2.5, gpt-5.3-codex; non-conforming (ANSI/control/shell-metachar) ids dropped.
56
- export const MODEL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._/:\[\]=,-]*$/;
56
+ export const MODEL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._/:[\]=,-]*$/;
@@ -1,9 +1,9 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { allAdapters, probeAll, probeModels, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
3
+ import { allAdapters, probeAll, probeModels, readAutoPrefer, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
4
4
  import { drovrDir, stateDirName } from "../../graph/graph.js";
5
5
  import { modelLints, suggestOverlay, ttyVisual } from "../../adapters/model-lints.js";
6
- import { loadConfig } from "../../config/config.js";
6
+ import { DEFAULT_CONFIG, loadConfig, overlayPreferShapes } from "../../config/config.js";
7
7
  import { HerdrDriver } from "../../drivers/herdr.js";
8
8
  import { disallowedBy, excludedChannels, exclusionLine, preferRanks } from "../../route/preference.js";
9
9
  // same gate + palette as status's audit-ledger frame: styled only on a real TTY, plain otherwise
@@ -91,7 +91,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
91
91
  rows.push(` ! ${role} runs on ${sel.adapter}:${sel.model} — NOT installed; that gate will fail closed until you install it or remap cfg.${role}`);
92
92
  }
93
93
  }
94
- rows.push(...modelLints(cfg, health, adapters, { tty: ttyVisual(), stateDir: stateDirName(cwd) }).map((l) => ` ! ${l}`));
94
+ rows.push(...modelLints(cfg, health, adapters, { tty: ttyVisual(), stateDir: stateDirName(cwd), overlayPreferShapes: overlayPreferShapes(cwd) }).map((l) => ` ! ${l}`));
95
95
  const excluded = excludedChannels(cfg, adapters, health);
96
96
  if (excluded.length)
97
97
  rows.push(` ! ${exclusionLine(excluded)}`);
@@ -143,6 +143,20 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
143
143
  rows.push(` (${unclassified.length} more listed, unclassified)`);
144
144
  return rows;
145
145
  });
146
- const modelSummary = modelStatus.length ? `\nmodel status:\n${modelStatus.join("\n")}` : "";
146
+ const autoPrefer = readAutoPrefer(cwd);
147
+ const preferStatus = autoPrefer
148
+ ? Object.keys(cfg.routing.map).flatMap((shape) => {
149
+ const auto = autoPrefer[shape];
150
+ if (!Array.isArray(auto))
151
+ return [];
152
+ const seed = DEFAULT_CONFIG.routing.map[shape]?.prefer ?? [];
153
+ if (!auto.length && !seed.length)
154
+ return []; // nothing derived, nothing seeded — an empty line is noise
155
+ return [` prefer ${shape} (auto): ${auto.join(" > ")} — seed was [${seed.join(", ")}]`];
156
+ })
157
+ : [];
158
+ const modelSummary = modelStatus.length || preferStatus.length
159
+ ? `\nmodel status:\n${[...modelStatus, ...preferStatus].join("\n")}`
160
+ : "";
147
161
  return stylize(`tickmarkr doctor — capability matrix:\n${rows.join("\n")}${modelSummary}${drift}\nwrote ${stateDirName(cwd)}/doctor.json`);
148
162
  }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  export type CommandMap = Record<string, (argv: string[]) => Promise<string>>;
3
3
  export declare const COMMANDS: CommandMap;
4
+ export declare const BANNER: string;
4
5
  export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix\n compile <src> spec \u2192 .drovr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n run execute the graph (--concurrency N --driver herdr|subprocess --route-strict)\n status live run state\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n approve <id> <task> approve a parked human gate (--by <name> --reason <text>); takes effect on resume";
5
6
  export declare function dispatch(cmd: string | undefined, argv: string[], commands?: CommandMap): Promise<{
6
7
  out: string;
package/dist/cli/index.js CHANGED
@@ -17,6 +17,17 @@ export const COMMANDS = {
17
17
  init, doctor, compile, scope, plan, run, status, resume, report, profile, unlock, approve,
18
18
  };
19
19
  const HELP_CMDS = new Set(["help", "-h", "--help"]);
20
+ // TTY-only pixel-tick logo (assets/mark.svg is the image twin — same block geometry).
21
+ // Never printed to pipes — the non-TTY stdout surface is byte-pinned by tests and consumed by machines.
22
+ const B = "\x1b[1m", R = "\x1b[0m";
23
+ const g = (n, s) => `\x1b[38;5;${n}m${s}${R}`; // 256-color green ramp, bright → deep
24
+ export const BANNER = [
25
+ ` ${g(84, "▄▄████")}`,
26
+ ` ${g(78, "▄▄████▀▀")}`,
27
+ `${g(41, "████▄▄▄▄████▀▀")} ${B}tickmarkr${R}`,
28
+ ` ${g(35, "▀▀████▀▀")} spec in, verified work out.`,
29
+ "",
30
+ ].join("\n");
20
31
  export const USAGE = `tickmarkr — spec-driven orchestration harness for AI coding agents
21
32
  usage: tickmarkr <command>
22
33
  init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs
@@ -35,11 +46,12 @@ usage: tickmarkr <command>
35
46
  // unknown/missing cmd → USAGE (exit 1 if a cmd was typed, 0 for bare `drovr`); a handler throw becomes
36
47
  // a one-line `drovr <cmd>: <message>` (never a raw stack) at exit 1.
37
48
  export async function dispatch(cmd, argv, commands = COMMANDS) {
49
+ const usage = process.stdout.isTTY ? BANNER + USAGE : USAGE;
38
50
  if (!cmd || HELP_CMDS.has(cmd))
39
- return { out: USAGE, code: 0 };
51
+ return { out: usage, code: 0 };
40
52
  const fn = commands[cmd];
41
53
  if (!fn)
42
- return { out: USAGE, code: 1 };
54
+ return { out: usage, code: 1 };
43
55
  try {
44
56
  return { out: await fn(argv), code: 0 };
45
57
  }
@@ -60,7 +72,7 @@ if (argv1Real && import.meta.url === pathToFileURL(argv1Real).href) {
60
72
  const [cmd, ...argv] = process.argv.slice(2);
61
73
  dispatch(cmd, argv).then(({ out, code }) => {
62
74
  // byte-identical streams: usage + success → stdout; a handler throw → stderr (original behavior)
63
- (out === USAGE || code === 0 ? console.log : console.error)(out);
75
+ (out.endsWith(USAGE) || code === 0 ? console.log : console.error)(out);
64
76
  if (code !== 0)
65
77
  process.exit(code);
66
78
  });
@@ -177,6 +177,9 @@ export declare class ConfigError extends Error {
177
177
  }
178
178
  export declare const DEFAULT_CONFIG: DrovrConfig;
179
179
  export declare function globalConfigDir(): string;
180
+ export declare function overlayPreferShapes(repoRoot: string, opts?: {
181
+ globalDir?: string;
182
+ }): ReadonlySet<string>;
180
183
  export declare function loadConfig(repoRoot: string, opts?: {
181
184
  globalDir?: string;
182
185
  }): DrovrConfig;
@@ -191,6 +191,11 @@ export const DEFAULT_CONFIG = {
191
191
  vendor: "cursor", channel: "sub",
192
192
  models: {
193
193
  "composer-2.5": "mid",
194
+ // composer-2.5-fast → cheap: speed-optimized variant, no independent benchmark scores yet → floor
195
+ // tier (same policy call as grok-composer-2.5-fast below). Id live-verified in doctor.json probe
196
+ // 2026-07-16 (cursor-agent 2026.07.09); gives cursor a cheap-tier channel so low-complexity shapes
197
+ // stop burning its mid. Operator-approved 2026-07-16.
198
+ "composer-2.5-fast": "cheap",
194
199
  },
195
200
  },
196
201
  // GLM-5.2 → mid per benchmark policy (2026-07): SWE-bench Pro 62.1 (> GPT-5.5 58.6), FrontierSWE 74.4 ≈ Opus 4.8;
@@ -262,6 +267,19 @@ export function globalConfigDir() {
262
267
  const legacy = join(base, "drovr");
263
268
  return existsSync(join(next, "config.yaml")) || !existsSync(join(legacy, "config.yaml")) ? next : legacy;
264
269
  }
270
+ export function overlayPreferShapes(repoRoot, opts = {}) {
271
+ const shapes = new Set();
272
+ for (const layer of [readYaml(join(opts.globalDir ?? globalConfigDir(), "config.yaml")), readYaml(join(repoRoot, stateDirName(repoRoot), "config.yaml"))]) {
273
+ const map = layer?.routing?.map;
274
+ if (!map)
275
+ continue;
276
+ for (const [shape, entry] of Object.entries(map)) {
277
+ if (entry && Object.prototype.hasOwnProperty.call(entry, "prefer") && entry.prefer !== null)
278
+ shapes.add(shape);
279
+ }
280
+ }
281
+ return shapes;
282
+ }
265
283
  export function loadConfig(repoRoot, opts = {}) {
266
284
  const globalCfg = readYaml(join(opts.globalDir ?? globalConfigDir(), "config.yaml"));
267
285
  const repoCfg = readYaml(join(repoRoot, stateDirName(repoRoot), "config.yaml"));
@@ -18,9 +18,17 @@ export interface Route {
18
18
  provenance: string;
19
19
  deviation?: RouteDeviation;
20
20
  }
21
+ export interface RoutingPreferContext {
22
+ autoPrefer?: {
23
+ derivedAt: string;
24
+ [shape: string]: string[] | string;
25
+ };
26
+ doctorFresh: boolean;
27
+ overlayPreferShapes: ReadonlySet<string>;
28
+ }
21
29
  export declare class RoutingError extends Error {
22
30
  constructor(msg: string);
23
31
  }
24
32
  export declare function marginalCostRank(c: BillingChannel): number;
25
- export declare function route(task: Task, cfg: DrovrConfig, channels: BillingChannel[], profile?: RoutingProfile): Route;
33
+ export declare function route(task: Task, cfg: DrovrConfig, channels: BillingChannel[], profile?: RoutingProfile, preferCtx?: RoutingPreferContext): Route;
26
34
  export declare function nextChannel(current: Assignment, task: Task, cfg: DrovrConfig, channels: BillingChannel[], tried: string[], profile?: RoutingProfile): Assignment | null;
@@ -2,6 +2,19 @@ import { channelKey, channelsFromConfig } from "../adapters/types.js";
2
2
  import { TIER_RANK } from "../config/config.js";
3
3
  import { disallowedBy } from "./preference.js";
4
4
  import { cellOf, EXPLORE_CAP, explorationBonus, learnedScore } from "./profile.js";
5
+ const autoPreferList = (doc, shape) => {
6
+ const v = doc?.[shape];
7
+ return Array.isArray(v) ? v : undefined;
8
+ };
9
+ const preferFromAuto = (shape, preferCtx) => !!preferCtx?.doctorFresh && !!preferCtx.autoPrefer && !preferCtx.overlayPreferShapes.has(shape) &&
10
+ autoPreferList(preferCtx.autoPrefer, shape) !== undefined;
11
+ const effectivePrefer = (shape, entry, preferCtx) => {
12
+ if (preferCtx?.overlayPreferShapes.has(shape))
13
+ return entry?.prefer;
14
+ if (preferCtx?.doctorFresh && preferCtx.autoPrefer)
15
+ return autoPreferList(preferCtx.autoPrefer, shape) ?? entry?.prefer;
16
+ return entry?.prefer;
17
+ };
5
18
  export class RoutingError extends Error {
6
19
  constructor(msg) {
7
20
  super(msg);
@@ -31,10 +44,11 @@ function ladderFor(task, entry) {
31
44
  const escalate = task.routingHints?.escalate ?? entry?.escalate ?? true;
32
45
  return escalate ? ["retry", "escalate", "consult", "human"] : ["retry", "consult", "human"];
33
46
  }
34
- export function route(task, cfg, channels, profile) {
47
+ export function route(task, cfg, channels, profile, preferCtx) {
35
48
  const lints = [];
36
49
  const floor = cfg.routing.floors[task.shape];
37
50
  const entry = cfg.routing.map[task.shape];
51
+ const prefer = effectivePrefer(task.shape, entry, preferCtx);
38
52
  const prefActive = !!(cfg.routing.allow || cfg.routing.deny);
39
53
  const disallowedPin = (via, model, kind) => {
40
54
  const d = disallowedBy({ adapter: via, model }, cfg.routing);
@@ -95,12 +109,12 @@ export function route(task, cfg, channels, profile) {
95
109
  if (entry?.tier)
96
110
  lintFloor(entry.tier, "map tier");
97
111
  if (prefActive)
98
- for (const p of entry?.prefer ?? [])
112
+ for (const p of prefer ?? [])
99
113
  preflightPrefer(p);
100
114
  // key order is a contract: prefer > marginal cost > tier (cheapest sufficient) > learned score (v1.6 ROUTE-06)
101
115
  // > discovery order (same-tier fairness, D2). The learned score is the LAST key — it decides only the
102
116
  // discovery-order tail, never a pin/floor/prefer/cost/tier boundary (they all sort above it, ROUTE-08).
103
- const staticCmp = (a, b) => preferIndex(a, entry?.prefer) - preferIndex(b, entry?.prefer) || marginalCostRank(a) - marginalCostRank(b) || TIER_RANK[a.tier] - TIER_RANK[b.tier];
117
+ const staticCmp = (a, b) => preferIndex(a, prefer) - preferIndex(b, prefer) || marginalCostRank(a) - marginalCostRank(b) || TIER_RANK[a.tier] - TIER_RANK[b.tier];
104
118
  const eligibleRaw = channels.filter((c) => TIER_RANK[c.tier] >= TIER_RANK[minTier]);
105
119
  if (!eligibleRaw.length) {
106
120
  let msg = `${task.id}: no channel at tier>=${minTier}; available: ${channels.map(channelKey).join(", ") || "(none)"}`;
@@ -137,19 +151,19 @@ export function route(task, cfg, channels, profile) {
137
151
  // entries, while intra-entry order (cost > tier > bonus > score) and every cold path stay byte-identical.
138
152
  // Cold reduces to preferIndex || cost || tier ≡ staticCmp (proof P1); rep keys use the group HEAD so
139
153
  // the probed group's budget self-extinguishes at EXPLORE_CAP (P4); flat lexicographic K is total (P5).
140
- const prefer = entry?.prefer ?? [];
154
+ const preferBands = prefer ?? [];
141
155
  const groupCmp = (a, b) => marginalCostRank(a) - marginalCostRank(b) || TIER_RANK[a.tier] - TIER_RANK[b.tier] ||
142
156
  bonusOf(b) - bonusOf(a) || scoreOf(b) - scoreOf(a);
143
157
  const heads = new Map();
144
158
  for (const c of [...eligibleRaw].sort(groupCmp)) {
145
- const g = preferIndex(c, prefer);
159
+ const g = preferIndex(c, preferBands);
146
160
  if (!heads.has(g))
147
161
  heads.set(g, c);
148
162
  }
149
163
  const keyOf = new Map(eligibleRaw.map((c) => {
150
- const g = preferIndex(c, prefer);
164
+ const g = preferIndex(c, preferBands);
151
165
  const rep = heads.get(g);
152
- return [channelKey(c), [g < prefer.length ? 0 : 1, -bonusOf(rep), -scoreOf(rep), g,
166
+ return [channelKey(c), [g < preferBands.length ? 0 : 1, -bonusOf(rep), -scoreOf(rep), g,
153
167
  marginalCostRank(c), TIER_RANK[c.tier], -bonusOf(c), -scoreOf(c)]];
154
168
  }));
155
169
  const kOf = (c) => keyOf.get(channelKey(c));
@@ -180,8 +194,11 @@ export function route(task, cfg, channels, profile) {
180
194
  "tier cheap (default)";
181
195
  // name the key that actually broke the tie: prefer outranks the marginal-cost/tier keys, so if the
182
196
  // winner matched a prefer entry, prefer decided it — not "cheapest sufficient tier" (ROUTE-03, WR-01)
183
- const chosenBy = learnedChosen || (entry?.prefer && preferIndex(eligible[0], entry.prefer) < entry.prefer.length
184
- ? "via prefer" : "cheapest sufficient tier");
197
+ const preferVia = preferFromAuto(task.shape, preferCtx)
198
+ ? `via prefer (auto-modernized ${preferCtx.autoPrefer.derivedAt.slice(0, 10)})`
199
+ : "via prefer";
200
+ const chosenBy = learnedChosen || (prefer && preferIndex(eligible[0], prefer) < prefer.length
201
+ ? preferVia : "cheapest sufficient tier");
185
202
  return { assignment: toAssignment(eligible[0]), ladder: ladderFor(task, entry), lints, provenance: `${degraded}${bound}, marginal-cost auto (${chosenBy})`, ...(deviation ? { deviation } : {}) };
186
203
  }
187
204
  export function nextChannel(current, task, cfg, channels, tried, profile) {
@@ -18,6 +18,8 @@ export interface RunSummary {
18
18
  human: string[];
19
19
  pending: string[];
20
20
  blocked: string[];
21
+ tipVerify?: "passed" | "failed";
22
+ lastMergedTask?: string;
21
23
  }
22
24
  export declare function formatSummary(s: RunSummary): string;
23
25
  export declare function runDaemon(repoRoot: string, opts?: RunOptions): Promise<RunSummary>;
@@ -14,13 +14,16 @@ import { consult } from "./consult.js";
14
14
  import { cleanupRunWorktrees, gitHead, sh } from "./git.js";
15
15
  import { Journal, loadRoutingProfile, newRunId } from "./journal.js";
16
16
  import { acquireRunLock, releaseRunLock } from "./lock.js";
17
- import { ensureIntegration, integrationBranch, integrationHead, mergeTask } from "./merge.js";
17
+ import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
18
18
  import { nextChannel, route } from "../route/router.js";
19
19
  import { desiredPanes } from "./reconcile.js";
20
20
  // VIS-01: one formatter, four readers (run-end journal event, run/resume CLI, run-end notify).
21
21
  // Parity by construction — every caller renders the same complete bucket line.
22
22
  export function formatSummary(s) {
23
- return `done: ${s.done.length}, failed: ${s.failed.length}, human: ${s.human.length}, blocked: ${s.blocked.length}, pending: ${s.pending.length}\nintegration branch: ${s.branch}`;
23
+ const tip = s.tipVerify === "failed"
24
+ ? `\ntip verify: FAILED${s.lastMergedTask ? ` (last merged: ${s.lastMergedTask})` : ""}`
25
+ : s.tipVerify === "passed" ? "\ntip verify: passed" : "";
26
+ return `done: ${s.done.length}, failed: ${s.failed.length}, human: ${s.human.length}, blocked: ${s.blocked.length}, pending: ${s.pending.length}\nintegration branch: ${s.branch}${tip}`;
24
27
  }
25
28
  const MAX_ATTEMPTS = 10; // ponytail: hard cap so a pathological ladder can never loop forever
26
29
  const BLOCKED_POLL_MS = 30_000; // between trailer-wait slices, check whether the pane is blocked on a prompt
@@ -704,12 +707,37 @@ export async function runDaemon(repoRoot, opts = {}) {
704
707
  blocked: blockedTasks(graph).map((t) => t.id),
705
708
  pending: pendingTasks(graph).map((t) => t.id),
706
709
  };
710
+ // OBS-34: post-merge integration-tip verify — strict exit codes, no baseline forgiveness.
711
+ const lastMergedTask = [...journal.read()].reverse().find((e) => e.event === "merge" && e.taskId)?.taskId;
712
+ if (summary.done.length > 0 && Object.keys(commands).length > 0) {
713
+ const tipResults = await verifyIntegrationTip(intWt, commands);
714
+ let tipFailed = false;
715
+ for (const r of tipResults) {
716
+ if (r.pass) {
717
+ journal.append("tip-verify", undefined, { gate: r.gate, cmd: r.cmd, pass: true, exitCode: r.exitCode, details: r.details });
718
+ }
719
+ else {
720
+ journal.append("tip-verify-failed", undefined, {
721
+ gate: r.gate,
722
+ cmd: r.cmd,
723
+ exitCode: r.exitCode,
724
+ fingerprints: r.fingerprints,
725
+ lastMergedTask,
726
+ });
727
+ tipFailed = true;
728
+ }
729
+ }
730
+ summary.tipVerify = tipFailed ? "failed" : "passed";
731
+ if (tipFailed && lastMergedTask)
732
+ summary.lastMergedTask = lastMergedTask;
733
+ }
707
734
  journal.append("run-end", undefined, { ...summary });
708
735
  await reconcile(); // run-end boundary: nothing in flight — full sweep (empty desired set)
709
736
  // OBS-28: lingering worktrees starve CLI probes; keepPanes:forever is the debug override.
710
737
  if (!keepForever) {
711
738
  const green = summary.failed.length === 0 && summary.human.length === 0
712
- && summary.blocked.length === 0 && summary.pending.length === 0;
739
+ && summary.blocked.length === 0 && summary.pending.length === 0
740
+ && summary.tipVerify !== "failed";
713
741
  await cleanupRunWorktrees(repoRoot, branch, { removeIntegration: green, removeTaskIds: summary.done });
714
742
  }
715
743
  // VIS-02: name each blocked subtree by its nearest parked/failed root, e.g. "3 blocked behind P40-02".
@@ -717,7 +745,10 @@ export async function runDaemon(repoRoot, opts = {}) {
717
745
  .sort(([a], [b]) => a.localeCompare(b))
718
746
  .map(([root, count]) => `${count} blocked behind ${root}`)
719
747
  .join(", ");
720
- await driver.notify(`tickmarkr ${runId}: ${summary.done.length} done, ${summary.failed.length} failed, ${summary.human.length} awaiting human, ${summary.blocked.length} blocked, ${summary.pending.length} pending${attribution ? ` (${attribution})` : ""} — integration branch ${branch} (merge to main is yours)`, { tier: "attention" });
748
+ const tipFail = summary.tipVerify === "failed"
749
+ ? ` — TIP VERIFY FAILED on ${summary.lastMergedTask ? `last merge ${summary.lastMergedTask}` : "integration tip"}`
750
+ : "";
751
+ await driver.notify(`tickmarkr ${runId}: ${summary.done.length} done, ${summary.failed.length} failed, ${summary.human.length} awaiting human, ${summary.blocked.length} blocked, ${summary.pending.length} pending${attribution ? ` (${attribution})` : ""}${tipFail} — integration branch ${branch} (merge to main is yours)`, { tier: summary.tipVerify === "failed" ? "attention" : "attention" });
721
752
  return summary;
722
753
  }
723
754
  finally {
@@ -12,7 +12,11 @@ export function formatJournalNarration({ event, taskId, data }) {
12
12
  const detail = Array.isArray(data.done)
13
13
  ? `done ${data.done.length}, failed ${Array.isArray(data.failed) ? data.failed.length : 0}`
14
14
  : typeof data.gate === "string"
15
- ? `${data.gate}${data.pass === true ? " passed" : data.pass === false ? " failed" : ""}`
15
+ ? event === "tip-verify-failed"
16
+ ? `${data.gate} failed${typeof data.lastMergedTask === "string" ? ` after ${data.lastMergedTask}` : ""}`
17
+ : event === "tip-verify"
18
+ ? `${data.gate} passed`
19
+ : `${data.gate}${data.pass === true ? " passed" : data.pass === false ? " failed" : ""}`
16
20
  : typeof data.code === "number" ? `exit ${data.code}`
17
21
  : typeof data.pid === "number" ? `pid ${data.pid}`
18
22
  : typeof data.baseRef === "string" ? `base ${data.baseRef.slice(0, 12)}`
@@ -1,4 +1,12 @@
1
1
  import type { DrovrConfig } from "../config/config.js";
2
+ export interface TipVerifyResult {
3
+ gate: string;
4
+ cmd: string;
5
+ pass: boolean;
6
+ exitCode: number;
7
+ fingerprints: string[];
8
+ details: string;
9
+ }
2
10
  export declare function integrationBranch(cfg: DrovrConfig, runId: string): string;
3
11
  export declare function ensureIntegration(repo: string, branch: string, baseRef: string): Promise<string>;
4
12
  export declare function integrationHead(intWt: string): Promise<string>;
@@ -10,3 +18,4 @@ export declare function mergeTask(intWt: string, taskBranch: string, message: st
10
18
  branchTip: string;
11
19
  };
12
20
  }>;
21
+ export declare function verifyIntegrationTip(intWt: string, commands: Record<string, string>): Promise<TipVerifyResult[]>;
package/dist/run/merge.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { shq } from "../adapters/types.js";
4
+ import { fingerprint } from "../gates/baseline.js";
4
5
  import { drovrDir } from "../graph/graph.js";
5
6
  import { gitHead, resolveIntegrationBranch, sh, shOk } from "./git.js";
6
7
  export function integrationBranch(cfg, runId) {
@@ -42,3 +43,20 @@ export async function mergeTask(intWt, taskBranch, message, gatedCommit) {
42
43
  await sh("git merge --abort", intWt);
43
44
  return { ok: false, conflict };
44
45
  }
46
+ // OBS-34: strict exit-code verify on the integration tip — no baseline forgiveness.
47
+ export async function verifyIntegrationTip(intWt, commands) {
48
+ const results = [];
49
+ for (const [gate, cmd] of Object.entries(commands)) {
50
+ const r = await sh(cmd, intWt);
51
+ const raw = (r.stdout + "\n" + r.stderr).split(intWt).join("");
52
+ results.push({
53
+ gate,
54
+ cmd,
55
+ pass: r.code === 0,
56
+ exitCode: r.code,
57
+ fingerprints: r.code !== 0 ? fingerprint(raw) : [],
58
+ details: r.code === 0 ? "exit 0" : `exit ${r.code}`,
59
+ });
60
+ }
61
+ return results;
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.35.0",
3
+ "version": "1.37.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,6 +18,7 @@
18
18
  ],
19
19
  "scripts": {
20
20
  "build": "tsc -p tsconfig.json",
21
+ "lint": "oxlint src tests scripts",
21
22
  "test": "vitest run",
22
23
  "test:coverage": "vitest run --coverage",
23
24
  "schema": "tsx scripts/emit-schema.ts",
@@ -32,6 +33,7 @@
32
33
  "@types/node": "^20.14.0",
33
34
  "@types/picomatch": "^4.0.0",
34
35
  "@vitest/coverage-v8": "^3.0.0",
36
+ "oxlint": "1.74.0",
35
37
  "tsx": "^4.19.0",
36
38
  "typescript": "^5.6.0",
37
39
  "vitest": "^3.0.0"
@@ -36,6 +36,6 @@ Run every requested target in order without seeking routine confirmation. Stop o
36
36
  2. **Compile** — run `tickmarkr compile <spec-or-directory>`. Fix source-spec defects instead of editing the generated graph.
37
37
  3. **Plan** — run `tickmarkr plan`. Review routes, capability-floor warnings, and human gates before execution.
38
38
  4. **Run** — run `tickmarkr run`. Watch the run journal for its terminal event rather than polling agents. Resolve blocked interactions in the relevant agent session.
39
- 5. **Verify and consolidate** — continue only after a green run. Tickmarkr consolidates accepted work on `tickmarkr/<runId>` and never signs off to the main branch. A human controls any later release merge.
39
+ 5. **Verify and consolidate** — continue only after a green run. A run is green when the run-end event exists in the journal AND the tip verify is not "failed". Tickmarkr consolidates accepted work on `tickmarkr/<runId>` and never signs off to the main branch. A human controls any later release merge.
40
40
  6. **Record** — write `tickmarkr report <runId> --md` beside the source spec and commit the execution record when the repository tracks those records.
41
41
  7. **Continue** — move to the next requested target. If a target fails or is parked, stop with the journal evidence rather than silently skipping it.
@@ -35,5 +35,5 @@ Proceed through the loop without seeking routine confirmation. Stop only for a b
35
35
  2. **Compile** — run `tickmarkr compile <spec>`. Correct compilation errors in the spec, never in the generated graph.
36
36
  3. **Plan** — run `tickmarkr plan`. Review the routing table, capability-floor warnings, and every human gate, including work that each gate blocks.
37
37
  4. **Run** — run `tickmarkr run`. Watch the run journal for its terminal event rather than repeatedly polling agents. Resolve blocked interactions in the agent session; do not turn them into proxy questions.
38
- 5. **Verify and consolidate** — accept only a green run. Tickmarkr consolidates accepted task work on `tickmarkr/<runId>`; it never signs off to the main branch. A human may later merge that integration branch through the repository's normal release process.
38
+ 5. **Verify and consolidate** — accept only a green run. A run is green when the run-end event exists in the journal AND the tip verify is not "failed". Tickmarkr consolidates accepted task work on `tickmarkr/<runId>`; it never signs off to the main branch. A human may later merge that integration branch through the repository's normal release process.
39
39
  6. **Record** — write `tickmarkr report <runId> --md` beside the source spec and commit the execution record when the repository tracks those records.