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 +21 -0
- package/README.md +83 -202
- package/dist/adapters/claude-code.js +1 -0
- package/dist/adapters/grok.js +1 -0
- package/dist/adapters/model-lints.d.ts +3 -1
- package/dist/adapters/model-lints.js +30 -0
- package/dist/adapters/opencode.js +1 -0
- package/dist/adapters/pi.js +1 -0
- package/dist/adapters/registry.d.ts +17 -1
- package/dist/adapters/registry.js +72 -4
- package/dist/adapters/types.d.ts +1 -0
- package/dist/adapters/types.js +1 -1
- package/dist/cli/commands/doctor.js +18 -4
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +15 -3
- package/dist/config/config.d.ts +3 -0
- package/dist/config/config.js +18 -0
- package/dist/route/router.d.ts +9 -1
- package/dist/route/router.js +26 -9
- package/dist/run/daemon.d.ts +2 -0
- package/dist/run/daemon.js +35 -4
- package/dist/run/journal.js +5 -1
- package/dist/run/merge.d.ts +9 -0
- package/dist/run/merge.js +18 -0
- package/package.json +3 -1
- package/skills/tickmarkr-auto/SKILL.md +1 -1
- package/skills/tickmarkr-loop/SKILL.md +1 -1
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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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`).
|
|
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 #
|
|
95
|
-
tickmarkr compile tickmarkr.spec.md # spec →
|
|
96
|
-
tickmarkr plan # dry-run routing
|
|
97
|
-
tickmarkr run # execute
|
|
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
|
|
102
|
-
|
|
103
|
-
and
|
|
104
|
-
|
|
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
|
-
|
|
102
|
+
## Monitor and supervise
|
|
130
103
|
|
|
131
104
|
```bash
|
|
132
|
-
tickmarkr status #
|
|
133
|
-
tickmarkr resume <runId> # continue an engagement from
|
|
134
|
-
tickmarkr approve <runId> <taskId> #
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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-
|
|
129
|
+
**Attempt-budget exhaustion**:
|
|
166
130
|
- The task has burned its full attempt budget without reaching a conclusive result
|
|
167
|
-
-
|
|
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 `
|
|
182
|
-
- **Scoped models**: Listed models with an explicit
|
|
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
|
|
206
|
-
-
|
|
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
|
|
168
|
+
## Run output and audit trail
|
|
211
169
|
|
|
212
|
-
When you execute `tickmarkr run`, the daemon
|
|
213
|
-
gate
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
203
|
+
## Visibility: optional supervised workspace
|
|
247
204
|
|
|
248
|
-
When running under [herdr](https://herdr.dev), tickmarkr creates a
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
281
|
+
tickmarkr compiles several formats into its internal task graph. The most common:
|
|
337
282
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
tickmarkr
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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
|
-
|
|
293
|
+
## Acceptance criteria (assertions)
|
|
358
294
|
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
304
|
+
## Claude Code integration
|
|
368
305
|
|
|
369
|
-
|
|
306
|
+
If you clone this repo and use Claude Code, project skills are installed in `.claude/skills/`:
|
|
370
307
|
|
|
371
|
-
- **`/tickmarkr-loop`** —
|
|
372
|
-
- **`/tickmarkr-auto`** —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
package/dist/adapters/grok.js
CHANGED
|
@@ -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
|
|
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)})"`,
|
package/dist/adapters/pi.js
CHANGED
|
@@ -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
|
|
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),
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/adapters/types.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -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:
|
|
51
|
+
return { out: usage, code: 0 };
|
|
40
52
|
const fn = commands[cmd];
|
|
41
53
|
if (!fn)
|
|
42
|
-
return { out:
|
|
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
|
|
75
|
+
(out.endsWith(USAGE) || code === 0 ? console.log : console.error)(out);
|
|
64
76
|
if (code !== 0)
|
|
65
77
|
process.exit(code);
|
|
66
78
|
});
|
package/dist/config/config.d.ts
CHANGED
|
@@ -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;
|
package/dist/config/config.js
CHANGED
|
@@ -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"));
|
package/dist/route/router.d.ts
CHANGED
|
@@ -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;
|
package/dist/route/router.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
164
|
+
const g = preferIndex(c, preferBands);
|
|
151
165
|
const rep = heads.get(g);
|
|
152
|
-
return [channelKey(c), [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
|
|
184
|
-
?
|
|
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) {
|
package/dist/run/daemon.d.ts
CHANGED
|
@@ -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>;
|
package/dist/run/daemon.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
package/dist/run/journal.js
CHANGED
|
@@ -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
|
-
?
|
|
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)}`
|
package/dist/run/merge.d.ts
CHANGED
|
@@ -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.
|
|
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.
|