tickmarkr 1.34.0 → 1.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -21
- package/dist/adapters/claude-code.js +3 -3
- package/dist/adapters/model-lints.d.ts +3 -2
- package/dist/adapters/model-lints.js +8 -7
- package/dist/adapters/registry.js +3 -3
- package/dist/cli/commands/compile.js +4 -3
- package/dist/cli/commands/doctor.js +4 -4
- package/dist/cli/commands/profile.js +5 -4
- package/dist/gates/review.js +1 -1
- package/dist/graph/graph.js +7 -1
- package/dist/run/daemon.js +7 -1
- package/dist/run/git.d.ts +7 -0
- package/dist/run/git.js +24 -2
- package/dist/run/lock.js +5 -4
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +3 -3
- package/skills/tickmarkr-loop/SKILL.md +3 -3
package/README.md
CHANGED
|
@@ -8,11 +8,15 @@ gates) only after independent verification. An **engagement** (`tickmarkr run`)
|
|
|
8
8
|
into a task graph, routes work to the best installed agent CLI (claude-code, codex, cursor-agent,
|
|
9
9
|
opencode, grok, pi) by cost within capability floors, dispatches **field teams** in isolated git
|
|
10
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** (
|
|
11
|
+
subprocesses otherwise — and records every event in the **audit trail** (engagement state directory).
|
|
12
12
|
Green tasks consolidate onto a `tickmarkr/<runId>` **consolidation** branch; **sign-off** (merge to
|
|
13
13
|
your mainline) is always the Partner's call, never the engine's. A fully green engagement is an
|
|
14
14
|
**unqualified opinion** — every assertion independently ticked.
|
|
15
15
|
|
|
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.
|
|
18
|
+
<!-- tickmarkr:compat-legacy END -->
|
|
19
|
+
|
|
16
20
|
> The package ships `tickmarkr` and `tkr` only; `tkr` is the short alias.
|
|
17
21
|
|
|
18
22
|
## Vocabulary
|
|
@@ -30,7 +34,7 @@ Audit voice in docs and reports maps to engine concepts exactly once:
|
|
|
30
34
|
| assertion | Acceptance criterion (`acceptance[]` entry) |
|
|
31
35
|
| tickmark | Passed gate |
|
|
32
36
|
| audit evidence | Evidence gate (commits/diff exist) |
|
|
33
|
-
| audit trail | Journal (
|
|
37
|
+
| audit trail | Journal (engagement event ledger) |
|
|
34
38
|
| the consolidation | Integration branch (`tickmarkr/<runId>`) |
|
|
35
39
|
| sign-off / issuance | Merge consolidation branch to main |
|
|
36
40
|
| unqualified opinion | Green engagement (all tasks merged) |
|
|
@@ -46,11 +50,11 @@ These are law; the codebase fails closed around them:
|
|
|
46
50
|
|
|
47
51
|
- `acceptance[]` required on every task; compile fails without it
|
|
48
52
|
- new engagements consolidate to a `tickmarkr/<runId>` branch — never main (resume of older
|
|
49
|
-
engagements keeps the branch names recorded in their journal
|
|
53
|
+
engagements keeps the branch names recorded in their journal)
|
|
50
54
|
- gates never trust worker claims; tickmarkr verifies everything itself
|
|
51
|
-
- state is files + git only
|
|
55
|
+
- state is files + git only; no DB, no services
|
|
52
56
|
- worker/judge/review/consult prompts end with machine-parseable trailers
|
|
53
|
-
(
|
|
57
|
+
(structured JSON verdicts); parse defensively, fail closed
|
|
54
58
|
|
|
55
59
|
Any agent (or human) operating this repo must respect the same five rules above — they are not
|
|
56
60
|
merely internal implementation details, they are the contract the gates enforce.
|
|
@@ -86,9 +90,9 @@ at 30s.
|
|
|
86
90
|
## Quickstart (5 minutes)
|
|
87
91
|
|
|
88
92
|
```bash
|
|
89
|
-
tickmarkr init # guided setup + doctor; scaffolds
|
|
90
|
-
# edit tickmarkr.spec.md # the native spec template
|
|
91
|
-
tickmarkr compile tickmarkr.spec.md # spec →
|
|
93
|
+
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)
|
|
92
96
|
tickmarkr plan # dry-run routing table + cost estimate + floor lints
|
|
93
97
|
tickmarkr run # execute the graph (--concurrency N --driver herdr|subprocess --route-strict)
|
|
94
98
|
tickmarkr report <runId> --md # engagement record in Markdown
|
|
@@ -170,7 +174,7 @@ cursor-agent and claude-code entirely.
|
|
|
170
174
|
|
|
171
175
|
## Model scoping and auth detection
|
|
172
176
|
|
|
173
|
-
Each agent CLI exposes a list of available models. tickmarkr's routing works only with **classified** models — those you explicitly enter into
|
|
177
|
+
Each agent CLI exposes a list of available models. tickmarkr's routing works only with **classified** models — those you explicitly enter into the config under `tiers`. The `tickmarkr doctor` command probes these models to detect auth status and records the results, which routing consumes to avoid 401/403 dispatch failures.
|
|
174
178
|
|
|
175
179
|
**Model terminology**:
|
|
176
180
|
|
|
@@ -180,7 +184,7 @@ Each agent CLI exposes a list of available models. tickmarkr's routing works onl
|
|
|
180
184
|
|
|
181
185
|
When you run `tickmarkr doctor`, it:
|
|
182
186
|
1. Probes each classified model exactly once (one minimal headless API call per model per adapter)
|
|
183
|
-
2. Records results
|
|
187
|
+
2. Records results locally: `authed: true` or `authed: false` with the failure reason and probe timestamp
|
|
184
188
|
3. Prints a model-status table for classified models only, showing tier, auth verdict, denial status, and prefer rank
|
|
185
189
|
|
|
186
190
|
**Routing and auth**:
|
|
@@ -206,9 +210,9 @@ routing:
|
|
|
206
210
|
## Run output and narration
|
|
207
211
|
|
|
208
212
|
When you execute `tickmarkr run`, the daemon streams live narration of each audit-trail event to stdout — task status changes,
|
|
209
|
-
gate results (tickmarks), worker dispatch, and merge commits. This narration is derived from the append-only
|
|
210
|
-
|
|
211
|
-
replays the
|
|
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
|
|
212
216
|
feed it to external tools; the narration is a convenience layer on top.
|
|
213
217
|
|
|
214
218
|
## Usage and cost
|
|
@@ -235,7 +239,7 @@ and first-attempt success rate. Cost reporting follows strict honesty rules and
|
|
|
235
239
|
- **Ranges, never single numbers**: quota multipliers and monthly-window variation make subscription costs a range, not a point estimate
|
|
236
240
|
- **"Not measurable" never becomes $0**: if a channel lacks pricing or metering data, the report explicitly states "not measurable"
|
|
237
241
|
- **Basis always shown**: every cost figure prints the token count, rate used, and date so estimates can be audited
|
|
238
|
-
- **No network calls**: pricing config is operator-maintained
|
|
242
|
+
- **No network calls**: pricing config is operator-maintained locally, seeded with dated
|
|
239
243
|
comments and LiteLLM's JSON file named as the copy-from source; tickmarkr never calls home to fetch rates
|
|
240
244
|
- **Attribution from journal**: token counts come from tickmarkr's own telemetry spans in the audit trail, never from provider invoices or dashboards
|
|
241
245
|
|
|
@@ -323,11 +327,11 @@ produced the graph. Upstream, these front-ends exist:
|
|
|
323
327
|
|
|
324
328
|
| Spec source | Support | How |
|
|
325
329
|
|---|---|---|
|
|
326
|
-
| **tickmarkr spec** (native format) | Native/default | `tickmarkr init` template with
|
|
330
|
+
| **tickmarkr spec** (native format) | Native/default | `tickmarkr init` template with native spec marker |
|
|
327
331
|
| **Spec Kit** feature dir | Native | dir containing `tasks.md` |
|
|
328
332
|
| **GSD** phase dir | Native | dir containing `*-PLAN.md` |
|
|
329
333
|
| **Markdown PRD** | Native (universal adapter) | any `.md` with tasks + acceptance criteria |
|
|
330
|
-
| OpenSpec, BMAD, Superpowers, others | Not yet | render to a tickmarkr spec (
|
|
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` |
|
|
331
335
|
|
|
332
336
|
Compile fails loudly on anything it can't recognize (`--type native|speckit|prd|gsd` to force).
|
|
333
337
|
|
|
@@ -393,10 +397,9 @@ npm run test:coverage # same suite, coverage floors enforced (see below)
|
|
|
393
397
|
npm run e2e # opt-in real-CLI end-to-end — DOES spend tokens, needs ≥1 agent CLI installed
|
|
394
398
|
```
|
|
395
399
|
|
|
396
|
-
`npm test` and `npm run test:coverage` must never call a real agent CLI or spend tokens —
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
`DROVR_E2E=1`, never in the default suite.
|
|
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.
|
|
400
403
|
|
|
401
404
|
Coverage floor: `src/{graph,route,gates,run}/**` must stay at lines 80% / functions 80% /
|
|
402
405
|
branches 70% — this is the CLAUDE.md invariant, enforced verbatim in `vitest.config.ts`.
|
|
@@ -432,7 +435,7 @@ behavior) are logged as a numbered entry (`OBS-NN`) in `.planning/OBSERVATIONS.m
|
|
|
432
435
|
That file is the standing abnormality ledger for this repo — read it before assuming a rough edge
|
|
433
436
|
is new.
|
|
434
437
|
|
|
435
|
-
|
|
438
|
+
See the design spec in docs/superpowers/specs/.
|
|
436
439
|
|
|
437
440
|
## License
|
|
438
441
|
|
|
@@ -49,15 +49,15 @@ export const claudeCode = {
|
|
|
49
49
|
// Gotchas (both bit the 2026-07-10 live check): bare '{}' is REJECTED ("mcpServers: expected record"),
|
|
50
50
|
// and --mcp-config is VARIADIC — a positional after it is eaten as a config-file path, so another
|
|
51
51
|
// flag must always follow the value, never the prompt.
|
|
52
|
-
headlessCommand: (promptFile, model) => `claude -p "$(cat ${shq(promptFile)})" --model ${shq(model)} --permission-mode
|
|
52
|
+
headlessCommand: (promptFile, model) => `claude -p "$(cat ${shq(promptFile)})" --model ${shq(model)} --permission-mode bypassPermissions --strict-mcp-config --mcp-config '{"mcpServers":{}}' --output-format text`,
|
|
53
53
|
// HYG-03: the residual first-entry dialog on an interactive TUI is the workspace TRUST dialog (not MCP
|
|
54
54
|
// config loading) — CLI-imposed, no flag to pre-accept, only store is claude's global last-writer-wins
|
|
55
55
|
// ~/.claude.json keyed on the exact path. Closed WON'T-FIX (decision B, 2026-07-10): drovr writes nothing
|
|
56
56
|
// to that file (a seed races claude's own writes, nondeterministically). Amortizes to one operator dismissal
|
|
57
57
|
// per stable worktree path; blocked-pane paging surfaces it. Do NOT change this command to "fix" the dialog —
|
|
58
58
|
// see .planning/REQUIREMENTS.md HYG-03 and 21-02-LIVE-CHECK.md. Revisit if upstream ships a --trust flag.
|
|
59
|
-
interactiveCommand: (promptFile, model) => `claude --model ${shq(model)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --permission-mode
|
|
60
|
-
resumeCommand: (sessionId, promptFile, model) => `claude -r ${shq(sessionId)} --model ${shq(model)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --permission-mode
|
|
59
|
+
interactiveCommand: (promptFile, model) => `claude --model ${shq(model)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`,
|
|
60
|
+
resumeCommand: (sessionId, promptFile, model) => `claude -r ${shq(sessionId)} --model ${shq(model)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`,
|
|
61
61
|
invoke(task, _cwd, a, ctx) {
|
|
62
62
|
return { command: this.headlessCommand(ctx.promptFile, a.model) };
|
|
63
63
|
},
|
|
@@ -5,10 +5,11 @@ export declare const MODEL_STALE_DAYS = 30;
|
|
|
5
5
|
export declare const ttyVisual: () => boolean;
|
|
6
6
|
export declare function modelLints(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], opts?: {
|
|
7
7
|
tty?: boolean;
|
|
8
|
+
stateDir?: string;
|
|
8
9
|
}): string[];
|
|
9
10
|
export declare function formatModelAuthLine(excluded: {
|
|
10
11
|
key: string;
|
|
11
12
|
reason: string;
|
|
12
13
|
probedAt: string;
|
|
13
|
-
}[], tty?: boolean): string;
|
|
14
|
-
export declare function suggestOverlay(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[]): string;
|
|
14
|
+
}[], tty?: boolean, stateDir?: string): string;
|
|
15
|
+
export declare function suggestOverlay(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], stateDir?: string): string;
|
|
@@ -10,14 +10,15 @@ const DAY_MS = 86400000;
|
|
|
10
10
|
const LINT_VARIANT_RE = /^auto$|-(fast|minimal|low|medium|high|xhigh)$/;
|
|
11
11
|
const LINT_CAP = 5;
|
|
12
12
|
const TTY_LINT_CAP = 3;
|
|
13
|
-
const
|
|
13
|
+
const DEFAULT_STATE_DIR = ".drovr";
|
|
14
|
+
const doctorJsonRef = (stateDir) => ` — see ${stateDir}/doctor.json`;
|
|
14
15
|
export const ttyVisual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
|
|
15
16
|
// Diffs detected models (doctor.json) against configured tiers, both directions, per adapter id in cfg.tiers.
|
|
16
17
|
// No ` ! ` prefix here — the consumer (doctor rows / plan lints) owns that. Pre-v1.5 doctor.json (models:[], no
|
|
17
18
|
// modelsDetectedAt) is the compat baseline: `?.`/`?? []` everywhere, no zod (would reject old files).
|
|
18
19
|
export function modelLints(cfg, health, adapters, opts) {
|
|
19
20
|
const cap = opts?.tty ? TTY_LINT_CAP : LINT_CAP;
|
|
20
|
-
const doctorRef = opts?.tty ?
|
|
21
|
+
const doctorRef = opts?.tty ? doctorJsonRef(opts.stateDir ?? DEFAULT_STATE_DIR) : "";
|
|
21
22
|
const lints = [];
|
|
22
23
|
for (const id of Object.keys(cfg.tiers)) {
|
|
23
24
|
const adapter = adapters.find((a) => a.id === id);
|
|
@@ -57,14 +58,14 @@ export function modelLints(cfg, health, adapters, opts) {
|
|
|
57
58
|
}
|
|
58
59
|
// T2/T6: one lint per exclusion, naming the probe reason and date. TTY truncates reasons to 60 chars and
|
|
59
60
|
// points at doctor.json for the full text; non-TTY is byte-identical to the pre-T6 registry helper.
|
|
60
|
-
export function formatModelAuthLine(excluded, tty) {
|
|
61
|
+
export function formatModelAuthLine(excluded, tty, stateDir = DEFAULT_STATE_DIR) {
|
|
61
62
|
const trunc = (s, n) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`);
|
|
62
63
|
const parts = excluded.map(({ key, reason, probedAt }) => {
|
|
63
64
|
const r = tty ? trunc(reason, 60) : reason;
|
|
64
65
|
return `${key} (${r} — probed ${probedAt.split("T")[0]})`;
|
|
65
66
|
});
|
|
66
67
|
const base = `model auth: ${excluded.length} channel(s) unauthed — ${parts.join(", ")}`;
|
|
67
|
-
return tty ? `${base}${
|
|
68
|
+
return tty ? `${base}${doctorJsonRef(stateDir)}` : base;
|
|
68
69
|
}
|
|
69
70
|
// MODEL-05/06: render detected-vs-configured drift as a paste-ready config.yaml fragment. Locked v1.5
|
|
70
71
|
// decision: detection is strictly advisory — doctor prints, a human pastes; NO --write/--apply exists.
|
|
@@ -72,7 +73,7 @@ export function formatModelAuthLine(excluded, tty) {
|
|
|
72
73
|
// machine never fabricates one — auto-tiering reopens the NaN-routing class). Removals render as LIVE
|
|
73
74
|
// `<id>: null` tombstones (deepMerge deletes the key). Pure function: no fs, no routing contact.
|
|
74
75
|
// Returns "" when no adapter has a delta. Mirrors modelLints' per-adapter guards exactly.
|
|
75
|
-
export function suggestOverlay(cfg, health, adapters) {
|
|
76
|
+
export function suggestOverlay(cfg, health, adapters, stateDir = DEFAULT_STATE_DIR) {
|
|
76
77
|
const blocks = [];
|
|
77
78
|
for (const id of Object.keys(cfg.tiers)) {
|
|
78
79
|
const adapter = adapters.find((a) => a.id === id);
|
|
@@ -116,13 +117,13 @@ export function suggestOverlay(cfg, health, adapters) {
|
|
|
116
117
|
lines.push(` # ${model}: ??? #${date ? ` detected ${date} —` : ""} classify per benchmark policy (AA Index + SWE-bench Pro, dated), then uncomment`);
|
|
117
118
|
}
|
|
118
119
|
if (omitted)
|
|
119
|
-
lines.push(` # (+${omitted} other detected id${omitted === 1 ? "" : "s"} not related to your configured models — see
|
|
120
|
+
lines.push(` # (+${omitted} other detected id${omitted === 1 ? "" : "s"} not related to your configured models — see ${stateDir}/doctor.json)`);
|
|
120
121
|
if (lines.length)
|
|
121
122
|
blocks.push(` ${id}:\n models:\n${lines.join("\n")}`);
|
|
122
123
|
}
|
|
123
124
|
if (blocks.length === 0)
|
|
124
125
|
return "";
|
|
125
|
-
return `# paste into
|
|
126
|
+
return `# paste into ${stateDir}/config.yaml — tickmarkr prints this, it never applies it\ntiers:\n${blocks.join("\n")}\n`;
|
|
126
127
|
}
|
|
127
128
|
// Purely relational id split for the addition gate (see suggestOverlay). Local, not exported: this is NOT a
|
|
128
129
|
// global identity concept — src/gates/review.ts has its own local modelId(), deliberately not shared.
|
|
@@ -247,7 +247,7 @@ export function formatDoctorReport(cwd, cfg, health, adapters, opts = {}) {
|
|
|
247
247
|
rows.push(` ! ${role} runs on ${sel.adapter}:${sel.model} — NOT installed; that gate will fail closed until you install it or remap cfg.${role}`);
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
|
-
rows.push(...modelLints(cfg, health, adapters).map((l) => ` ! ${l}`));
|
|
250
|
+
rows.push(...modelLints(cfg, health, adapters, { stateDir: stateDirName(cwd) }).map((l) => ` ! ${l}`));
|
|
251
251
|
const excluded = excludedChannels(cfg, adapters, health);
|
|
252
252
|
if (excluded.length)
|
|
253
253
|
rows.push(` ! ${exclusionLine(excluded)}`);
|
|
@@ -255,7 +255,7 @@ export function formatDoctorReport(cwd, cfg, health, adapters, opts = {}) {
|
|
|
255
255
|
if (servable.length)
|
|
256
256
|
rows.push(` ! ${servabilityLine(servable)}`);
|
|
257
257
|
const visual = process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
|
|
258
|
-
const frag = suggestOverlay(cfg, health, adapters);
|
|
258
|
+
const frag = suggestOverlay(cfg, health, adapters, stateDirName(cwd));
|
|
259
259
|
let drift = "";
|
|
260
260
|
if (frag) {
|
|
261
261
|
if (visual) {
|
|
@@ -293,6 +293,6 @@ export function formatDoctorReport(cwd, cfg, health, adapters, opts = {}) {
|
|
|
293
293
|
return statusRows;
|
|
294
294
|
});
|
|
295
295
|
const modelSummary = modelStatus.length ? `\nmodel status:\n${modelStatus.join("\n")}` : "";
|
|
296
|
-
const wrote = opts.wrote === false ? "" : `\nwrote
|
|
296
|
+
const wrote = opts.wrote === false ? "" : `\nwrote ${stateDirName(cwd)}/doctor.json`;
|
|
297
297
|
return `tickmarkr doctor — capability matrix:\n${rows.join("\n")}${modelSummary}${drift}${wrote}`;
|
|
298
298
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isAbsolute, join } from "node:path";
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
3
|
import { compileSource } from "../../compile/index.js";
|
|
4
|
-
import { saveGraph } from "../../graph/graph.js";
|
|
4
|
+
import { saveGraph, stateDirName } from "../../graph/graph.js";
|
|
5
5
|
import { isRunLockLive } from "../../run/lock.js";
|
|
6
6
|
export async function compile(argv, cwd = process.cwd()) {
|
|
7
7
|
const { values, positionals } = parseArgs({
|
|
@@ -16,8 +16,9 @@ export async function compile(argv, cwd = process.cwd()) {
|
|
|
16
16
|
const g = compileSource(isAbsolute(src) ? src : join(cwd, src), values.type, cwd);
|
|
17
17
|
// HARD-01: a compile clobbering a running daemon's graph.json is the same last-write-wins race
|
|
18
18
|
// as a second daemon. Read-only check — compile never acquires/holds (it's instantaneous).
|
|
19
|
+
const stateDir = stateDirName(cwd);
|
|
19
20
|
if (isRunLockLive(cwd))
|
|
20
|
-
throw new Error(
|
|
21
|
+
throw new Error(`${stateDir}/graph.lock is held by another drovr run, or is a stale/garbage lock — refusing to overwrite graph.json. If no run is active, run \`drovr unlock\`.`);
|
|
21
22
|
saveGraph(cwd, g);
|
|
22
|
-
return `compiled ${src} →
|
|
23
|
+
return `compiled ${src} → ${stateDir}/graph.json (${g.tasks.length} tasks, source ${g.spec.source}, hash ${g.spec.hash.slice(0, 12)})`;
|
|
23
24
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { allAdapters, probeAll, probeModels, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
|
|
4
|
-
import { drovrDir } from "../../graph/graph.js";
|
|
4
|
+
import { drovrDir, stateDirName } from "../../graph/graph.js";
|
|
5
5
|
import { modelLints, suggestOverlay, ttyVisual } from "../../adapters/model-lints.js";
|
|
6
6
|
import { loadConfig } from "../../config/config.js";
|
|
7
7
|
import { HerdrDriver } from "../../drivers/herdr.js";
|
|
@@ -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() }).map((l) => ` ! ${l}`));
|
|
94
|
+
rows.push(...modelLints(cfg, health, adapters, { tty: ttyVisual(), stateDir: stateDirName(cwd) }).map((l) => ` ! ${l}`));
|
|
95
95
|
const excluded = excludedChannels(cfg, adapters, health);
|
|
96
96
|
if (excluded.length)
|
|
97
97
|
rows.push(` ! ${exclusionLine(excluded)}`);
|
|
@@ -102,7 +102,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
102
102
|
// MODEL-05/06: print-only drift fragment; advisory, whole-line-commented additions, drovr NEVER applies it.
|
|
103
103
|
// TTY gets a one-line summary + the fragment as a file (the full dump drowned everything else,
|
|
104
104
|
// v1.33.1 onboarding); machine/CI surface keeps the inline dump — layout is pinned by tests.
|
|
105
|
-
const frag = suggestOverlay(cfg, health, adapters);
|
|
105
|
+
const frag = suggestOverlay(cfg, health, adapters, stateDirName(cwd));
|
|
106
106
|
let drift = "";
|
|
107
107
|
if (frag) {
|
|
108
108
|
if (visual()) {
|
|
@@ -144,5 +144,5 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
144
144
|
return rows;
|
|
145
145
|
});
|
|
146
146
|
const modelSummary = modelStatus.length ? `\nmodel status:\n${modelStatus.join("\n")}` : "";
|
|
147
|
-
return stylize(`tickmarkr doctor — capability matrix:\n${rows.join("\n")}${modelSummary}${drift}\nwrote
|
|
147
|
+
return stylize(`tickmarkr doctor — capability matrix:\n${rows.join("\n")}${modelSummary}${drift}\nwrote ${stateDirName(cwd)}/doctor.json`);
|
|
148
148
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { loadConfig } from "../../config/config.js";
|
|
4
|
-
import { drovrDir } from "../../graph/graph.js";
|
|
4
|
+
import { drovrDir, stateDirName } from "../../graph/graph.js";
|
|
5
5
|
import { learnedScore, MIN_SAMPLES, cellsOf } from "../../route/profile.js";
|
|
6
6
|
import { Journal, loadRoutingProfile, readProfileCursor, RUNS_WINDOW } from "../../run/journal.js";
|
|
7
7
|
// drovr profile — inspect (show) and forget (reset) the derived learned-routing profile (VIS-03).
|
|
@@ -15,14 +15,15 @@ export async function profile(argv, cwd = process.cwd()) {
|
|
|
15
15
|
// blanket-gitignores it, so the cursor is never git-addable. Opaque string: used only in a runId > compare.
|
|
16
16
|
function reset(cwd) {
|
|
17
17
|
const cursor = Journal.latestRunId(cwd) ?? ""; // empty repo ⇒ empty cursor ⇒ readProfileCursor === undefined
|
|
18
|
+
const stateDir = stateDirName(cwd);
|
|
18
19
|
const path = join(drovrDir(cwd), "profile-since");
|
|
19
20
|
writeFileSync(path, cursor + "\n");
|
|
20
21
|
return [
|
|
21
22
|
cursor
|
|
22
23
|
? `profile reset — learned routing now forgets runs at or before ${cursor}.`
|
|
23
24
|
: `profile reset — no runs yet; wrote an empty cursor.`,
|
|
24
|
-
` wrote
|
|
25
|
-
` to un-reset: delete
|
|
25
|
+
` wrote ${stateDir}/profile-since (telemetry is UNTOUCHED — report/resume still see every run).`,
|
|
26
|
+
` to un-reset: delete ${stateDir}/profile-since.`,
|
|
26
27
|
].join("\n");
|
|
27
28
|
}
|
|
28
29
|
// Inspection surface: preview:true bypasses the routing.learned:off short-circuit so `show` renders the
|
|
@@ -40,7 +41,7 @@ function show(cwd) {
|
|
|
40
41
|
if (!p || p.cells.size === 0) {
|
|
41
42
|
// preview bypasses the switch ⇒ undefined here means data, not policy: no telemetry, or all behind the cursor.
|
|
42
43
|
header.push("", cursor
|
|
43
|
-
? ` empty profile — no telemetry after the reset cursor (delete
|
|
44
|
+
? ` empty profile — no telemetry after the reset cursor (delete ${stateDirName(cwd)}/profile-since to see earlier runs).`
|
|
44
45
|
: ` empty profile — no telemetry yet.`);
|
|
45
46
|
return header.join("\n");
|
|
46
47
|
}
|
package/dist/gates/review.js
CHANGED
|
@@ -30,7 +30,7 @@ export function pickReviewer(author, channels, exclude = []) {
|
|
|
30
30
|
const DIFF_CAP = 60_000;
|
|
31
31
|
export async function reviewGate(task, worktree, baseRef, author, channels, adapters, cfg, via, excludeReviewers) {
|
|
32
32
|
if (task.complexity < cfg.review.complexityThreshold) {
|
|
33
|
-
return { gate: "review", pass: true, details: `skipped — complexity ${task.complexity} < threshold ${cfg.review.complexityThreshold}
|
|
33
|
+
return { gate: "review", pass: true, details: `skipped — complexity ${task.complexity} < threshold ${cfg.review.complexityThreshold}`, meta: { skipped: true } };
|
|
34
34
|
}
|
|
35
35
|
const reviewer = pickReviewer(author, channels, excludeReviewers ?? []);
|
|
36
36
|
if (!reviewer) {
|
package/dist/graph/graph.js
CHANGED
|
@@ -6,7 +6,13 @@ export function stateDirName(repoRoot) {
|
|
|
6
6
|
const cached = stateDirs.get(repoRoot);
|
|
7
7
|
if (cached)
|
|
8
8
|
return cached;
|
|
9
|
-
const name = existsSync(join(repoRoot, ".
|
|
9
|
+
const name = existsSync(join(repoRoot, ".tickmarkr"))
|
|
10
|
+
? ".tickmarkr"
|
|
11
|
+
: existsSync(join(repoRoot, ".drovr"))
|
|
12
|
+
? ".drovr"
|
|
13
|
+
: existsSync(join(repoRoot, ".drover"))
|
|
14
|
+
? ".drover"
|
|
15
|
+
: ".tickmarkr";
|
|
10
16
|
stateDirs.set(repoRoot, name);
|
|
11
17
|
return name;
|
|
12
18
|
}
|
package/dist/run/daemon.js
CHANGED
|
@@ -11,7 +11,7 @@ import { captureBaseline, detectGateCommands } from "../gates/baseline.js";
|
|
|
11
11
|
import { runGates } from "../gates/run-gates.js";
|
|
12
12
|
import { addEvidence, attributeBlocked, blockedTasks, getTask, loadGraph, pendingTasks, readyTasks, saveGraph, setStatus } from "../graph/graph.js";
|
|
13
13
|
import { consult } from "./consult.js";
|
|
14
|
-
import { gitHead, sh } from "./git.js";
|
|
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
17
|
import { ensureIntegration, integrationBranch, integrationHead, mergeTask } from "./merge.js";
|
|
@@ -706,6 +706,12 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
706
706
|
};
|
|
707
707
|
journal.append("run-end", undefined, { ...summary });
|
|
708
708
|
await reconcile(); // run-end boundary: nothing in flight — full sweep (empty desired set)
|
|
709
|
+
// OBS-28: lingering worktrees starve CLI probes; keepPanes:forever is the debug override.
|
|
710
|
+
if (!keepForever) {
|
|
711
|
+
const green = summary.failed.length === 0 && summary.human.length === 0
|
|
712
|
+
&& summary.blocked.length === 0 && summary.pending.length === 0;
|
|
713
|
+
await cleanupRunWorktrees(repoRoot, branch, { removeIntegration: green, removeTaskIds: summary.done });
|
|
714
|
+
}
|
|
709
715
|
// VIS-02: name each blocked subtree by its nearest parked/failed root, e.g. "3 blocked behind P40-02".
|
|
710
716
|
const attribution = [...attributeBlocked(graph).entries()]
|
|
711
717
|
.sort(([a], [b]) => a.localeCompare(b))
|
package/dist/run/git.d.ts
CHANGED
|
@@ -7,6 +7,13 @@ export interface ShResult {
|
|
|
7
7
|
export declare function sh(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
|
|
8
8
|
export declare function shOk(cmd: string, cwd: string): Promise<string>;
|
|
9
9
|
export declare function gitHead(cwd: string): Promise<string>;
|
|
10
|
+
export declare const sanitizeBranch: (branch: string) => string;
|
|
11
|
+
export declare const worktreePath: (repo: string, branch: string) => string;
|
|
12
|
+
/** OBS-28: remove this run's recorded worktrees; tolerates already-gone paths. */
|
|
13
|
+
export declare function cleanupRunWorktrees(repo: string, branch: string, opts: {
|
|
14
|
+
removeIntegration: boolean;
|
|
15
|
+
removeTaskIds: string[];
|
|
16
|
+
}): Promise<void>;
|
|
10
17
|
export declare function resolveIntegrationBranch(repo: string, branch: string): Promise<string>;
|
|
11
18
|
export declare function createWorktree(repo: string, branch: string, baseRef: string): Promise<string>;
|
|
12
19
|
export declare function removeWorktree(repo: string, dir: string): Promise<void>;
|
package/dist/run/git.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
2
|
+
import { existsSync, symlinkSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { shq } from "../adapters/types.js";
|
|
5
5
|
import { drovrDir } from "../graph/graph.js";
|
|
@@ -47,7 +47,16 @@ export async function shOk(cmd, cwd) {
|
|
|
47
47
|
export async function gitHead(cwd) {
|
|
48
48
|
return (await shOk("git rev-parse HEAD", cwd)).trim();
|
|
49
49
|
}
|
|
50
|
-
const
|
|
50
|
+
export const sanitizeBranch = (branch) => branch.replace(/[^\w.-]+/g, "-");
|
|
51
|
+
const sanitize = sanitizeBranch;
|
|
52
|
+
export const worktreePath = (repo, branch) => join(drovrDir(repo), "worktrees", sanitize(branch));
|
|
53
|
+
/** OBS-28: remove this run's recorded worktrees; tolerates already-gone paths. */
|
|
54
|
+
export async function cleanupRunWorktrees(repo, branch, opts) {
|
|
55
|
+
if (opts.removeIntegration)
|
|
56
|
+
await removeWorktree(repo, worktreePath(repo, branch));
|
|
57
|
+
for (const id of opts.removeTaskIds)
|
|
58
|
+
await removeWorktree(repo, worktreePath(repo, `${branch}--${id}`));
|
|
59
|
+
}
|
|
51
60
|
const branchExists = async (repo, branch) => (await sh(`git rev-parse --verify ${shq(`refs/heads/${branch}`)}`, repo)).code === 0;
|
|
52
61
|
export async function resolveIntegrationBranch(repo, branch) {
|
|
53
62
|
if (!branch.startsWith("drovr/"))
|
|
@@ -68,8 +77,21 @@ export async function createWorktree(repo, branch, baseRef) {
|
|
|
68
77
|
if (existsSync(dir))
|
|
69
78
|
await removeWorktree(repo, dir);
|
|
70
79
|
await shOk(`git worktree add -B ${shq(branch)} ${shq(dir)} ${shq(baseRef)}`, repo);
|
|
80
|
+
linkNodeModules(repo, dir);
|
|
71
81
|
return dir;
|
|
72
82
|
}
|
|
83
|
+
// best-effort: devDep-based gates (tsx, vitest) shell out root-anchored and ENOENT in a bare
|
|
84
|
+
// fresh worktree (OBS-27); failure here must never fail worktree creation
|
|
85
|
+
function linkNodeModules(repo, dir) {
|
|
86
|
+
const src = join(repo, "node_modules");
|
|
87
|
+
const dest = join(dir, "node_modules");
|
|
88
|
+
if (!existsSync(src) || existsSync(dest))
|
|
89
|
+
return;
|
|
90
|
+
try {
|
|
91
|
+
symlinkSync(src, dest, "dir");
|
|
92
|
+
}
|
|
93
|
+
catch { /* best-effort */ }
|
|
94
|
+
}
|
|
73
95
|
export async function removeWorktree(repo, dir) {
|
|
74
96
|
await sh(`git worktree remove --force ${shq(dir)}`, repo); // best-effort; stale dirs are re-added with -B
|
|
75
97
|
await sh(`rm -rf ${shq(dir)}`, repo);
|
package/dist/run/lock.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { linkSync, readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { drovrDir } from "../graph/graph.js";
|
|
4
|
+
import { drovrDir, stateDirName } from "../graph/graph.js";
|
|
5
5
|
// HARD-01/02: coarse per-run advisory lock over .drovr/graph.json. LOCK-02: the lock is created by
|
|
6
6
|
// the link(2) idiom — write the full payload to graph.lock.<pid>.tmp, then linkSync(tmp, lockPath),
|
|
7
7
|
// which is atomic and throws EEXIST if the lock already exists (the mutual-exclusion primitive).
|
|
@@ -125,11 +125,12 @@ export function acquireRunLock(repoRoot, runId) {
|
|
|
125
125
|
const again = acquireRunLock(repoRoot, runId); // re-serializes two stealers through linkSync
|
|
126
126
|
return { reclaimed: { pid: pid ?? -1, mtimeMs }, ...again };
|
|
127
127
|
}
|
|
128
|
+
const stateDir = stateDirName(repoRoot);
|
|
128
129
|
if (garbage)
|
|
129
|
-
throw new Error(
|
|
130
|
+
throw new Error(`${stateDir}/graph.lock holds an unreadable/garbage payload — refusing to reclaim it; run \`drovr unlock\` to remove it`);
|
|
130
131
|
// LOCK-02: shouldRefuse is false whenever dead, so this throw is reached only for a LIVE holder
|
|
131
132
|
// (incl. EPERM = alive-but-not-ours). The dead-but-fresh case self-clears via the reclaim branch.
|
|
132
|
-
throw new Error(
|
|
133
|
+
throw new Error(`${stateDir}/graph.lock held by pid ${pid ?? "?"}${heldRun ? ` (run ${heldRun})` : ""} — another drovr run? (operator escape: \`drovr unlock\`)`);
|
|
133
134
|
}
|
|
134
135
|
}
|
|
135
136
|
export function releaseRunLock(repoRoot) {
|
|
@@ -169,7 +170,7 @@ export function unlockRun(repoRoot) {
|
|
|
169
170
|
return { held: false };
|
|
170
171
|
} // statSync ENOENT ⇒ no lock
|
|
171
172
|
if (!insp.dead && !insp.garbage) {
|
|
172
|
-
throw new Error(
|
|
173
|
+
throw new Error(`${stateDirName(repoRoot)}/graph.lock held by LIVE pid ${insp.pid}${insp.runId ? ` (run ${insp.runId})` : ""} — refusing to unlock; stop that run first`);
|
|
173
174
|
}
|
|
174
175
|
try {
|
|
175
176
|
unlinkSync(p);
|
package/package.json
CHANGED
|
@@ -12,9 +12,9 @@ Use this to execute a requested sequence of repository specs. It is SDD-agnostic
|
|
|
12
12
|
|
|
13
13
|
When working in a multi-agent terminal environment, decide your role before starting:
|
|
14
14
|
|
|
15
|
-
- **Orchestrator:** your session was started to execute the mission.
|
|
16
|
-
- **Supervisor with a live orchestrator:** do not start a second run. Relay the mission to the existing orchestrator with a verified send, then supervise it.
|
|
17
|
-
- **Primary session without an orchestrator:**
|
|
15
|
+
- **Orchestrator:** your session was started to execute the mission. Rename your own tab/pane ORCHESTRATOR and run the loop below.
|
|
16
|
+
- **Supervisor with a live orchestrator:** do not start a second run. Relay the mission to the existing orchestrator with a verified send, then supervise it as OVERSEER.
|
|
17
|
+
- **Primary session without an orchestrator:** rename your own tab OVERSEER, spawn one child orchestration session with `--permission-mode bypassPermissions`, label its tab ORCHESTRATOR, give it the mission and these rules verbatim, then supervise it. Do not drive a duplicate single-tier run yourself.
|
|
18
18
|
|
|
19
19
|
Outside a multi-agent terminal environment, run the loop directly.
|
|
20
20
|
|
|
@@ -11,9 +11,9 @@ Use this for any spec that `tickmarkr compile` accepts. It is SDD-agnostic: use
|
|
|
11
11
|
|
|
12
12
|
When working in a multi-agent terminal environment, decide your role before starting:
|
|
13
13
|
|
|
14
|
-
- **Orchestrator:** your session was started to execute the mission.
|
|
15
|
-
- **Supervisor with a live orchestrator:** do not start a second run. Relay the mission to the existing orchestrator with a verified send, then supervise it.
|
|
16
|
-
- **Primary session without an orchestrator:**
|
|
14
|
+
- **Orchestrator:** your session was started to execute the mission. Rename your own tab/pane ORCHESTRATOR and run the loop below.
|
|
15
|
+
- **Supervisor with a live orchestrator:** do not start a second run. Relay the mission to the existing orchestrator with a verified send, then supervise it as OVERSEER.
|
|
16
|
+
- **Primary session without an orchestrator:** rename your own tab OVERSEER, spawn one child orchestration session with `--permission-mode bypassPermissions`, label its tab ORCHESTRATOR, give it the mission and these rules verbatim, then supervise it. Do not drive a duplicate single-tier run yourself.
|
|
17
17
|
|
|
18
18
|
Outside a multi-agent terminal environment, run the loop directly.
|
|
19
19
|
|