tickmarkr 1.33.5 → 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/codex.js +5 -2
- package/dist/adapters/model-lints.d.ts +11 -2
- package/dist/adapters/model-lints.js +23 -6
- package/dist/adapters/registry.d.ts +13 -1
- package/dist/adapters/registry.js +150 -19
- package/dist/cli/commands/compile.js +4 -3
- package/dist/cli/commands/doctor.js +9 -6
- package/dist/cli/commands/init.js +74 -11
- package/dist/cli/commands/plan.js +4 -4
- package/dist/cli/commands/profile.js +5 -4
- package/dist/cli/index.js +5 -2
- package/dist/config/config.d.ts +8 -1
- package/dist/config/config.js +17 -2
- 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
|
},
|
package/dist/adapters/codex.js
CHANGED
|
@@ -129,11 +129,14 @@ export const codex = {
|
|
|
129
129
|
probe: async () => probeVersion("codex"),
|
|
130
130
|
channels: (cfg) => channelsFromConfig("codex", cfg),
|
|
131
131
|
// --sandbox workspace-write is the autonomous sandbox mode (codex v0.144.1+)
|
|
132
|
-
|
|
132
|
+
// OBS-24: -c 'mcp_servers={}' is codex's analog of claude's --strict-mcp-config — operator-global
|
|
133
|
+
// MCP servers in ~/.codex/config.toml block startup indefinitely when one is down (live-measured
|
|
134
|
+
// 2026-07-15: probe wedged >120s with MCP, 30s clean), wedging probes and worktree workers alike.
|
|
135
|
+
headlessCommand: (promptFile, model) => `codex exec --sandbox workspace-write -c 'mcp_servers={}' ${GITDIR_WRITABLE} --model ${shq(model)} "$(cat ${shq(promptFile)})"`,
|
|
133
136
|
// TUI uses expanded -a never -s workspace-write (exec-only flags do not apply)
|
|
134
137
|
// (--help 2026-07-09: valid approval policies are untrusted|on-request|never; the previously
|
|
135
138
|
// used `on-failure` is invalid and made codex exit 2 pre-inference)
|
|
136
|
-
interactiveCommand: (promptFile, model) => `codex -a never -s workspace-write ${GITDIR_WRITABLE} --model ${shq(model)} "$(cat ${shq(promptFile)})"`,
|
|
139
|
+
interactiveCommand: (promptFile, model) => `codex -a never -s workspace-write -c 'mcp_servers={}' ${GITDIR_WRITABLE} --model ${shq(model)} "$(cat ${shq(promptFile)})"`,
|
|
137
140
|
invoke(task, _cwd, a, ctx) {
|
|
138
141
|
return { command: this.headlessCommand(ctx.promptFile, a.model) };
|
|
139
142
|
},
|
|
@@ -2,5 +2,14 @@ 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
|
-
export declare
|
|
6
|
-
export declare function
|
|
5
|
+
export declare const ttyVisual: () => boolean;
|
|
6
|
+
export declare function modelLints(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], opts?: {
|
|
7
|
+
tty?: boolean;
|
|
8
|
+
stateDir?: string;
|
|
9
|
+
}): string[];
|
|
10
|
+
export declare function formatModelAuthLine(excluded: {
|
|
11
|
+
key: string;
|
|
12
|
+
reason: string;
|
|
13
|
+
probedAt: string;
|
|
14
|
+
}[], tty?: boolean, stateDir?: string): string;
|
|
15
|
+
export declare function suggestOverlay(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], stateDir?: string): string;
|
|
@@ -9,10 +9,16 @@ const DAY_MS = 86400000;
|
|
|
9
9
|
// raw list (verified 2026-07-10). Data stays raw; lints stay signal.
|
|
10
10
|
const LINT_VARIANT_RE = /^auto$|-(fast|minimal|low|medium|high|xhigh)$/;
|
|
11
11
|
const LINT_CAP = 5;
|
|
12
|
+
const TTY_LINT_CAP = 3;
|
|
13
|
+
const DEFAULT_STATE_DIR = ".drovr";
|
|
14
|
+
const doctorJsonRef = (stateDir) => ` — see ${stateDir}/doctor.json`;
|
|
15
|
+
export const ttyVisual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
|
|
12
16
|
// Diffs detected models (doctor.json) against configured tiers, both directions, per adapter id in cfg.tiers.
|
|
13
17
|
// No ` ! ` prefix here — the consumer (doctor rows / plan lints) owns that. Pre-v1.5 doctor.json (models:[], no
|
|
14
18
|
// modelsDetectedAt) is the compat baseline: `?.`/`?? []` everywhere, no zod (would reject old files).
|
|
15
|
-
export function modelLints(cfg, health, adapters) {
|
|
19
|
+
export function modelLints(cfg, health, adapters, opts) {
|
|
20
|
+
const cap = opts?.tty ? TTY_LINT_CAP : LINT_CAP;
|
|
21
|
+
const doctorRef = opts?.tty ? doctorJsonRef(opts.stateDir ?? DEFAULT_STATE_DIR) : "";
|
|
16
22
|
const lints = [];
|
|
17
23
|
for (const id of Object.keys(cfg.tiers)) {
|
|
18
24
|
const adapter = adapters.find((a) => a.id === id);
|
|
@@ -37,8 +43,8 @@ export function modelLints(cfg, health, adapters) {
|
|
|
37
43
|
}
|
|
38
44
|
const extra = detected.filter((m) => !configured.includes(m) && !LINT_VARIANT_RE.test(m));
|
|
39
45
|
if (extra.length) {
|
|
40
|
-
const shown = extra.slice(0,
|
|
41
|
-
const tail = extra.length >
|
|
46
|
+
const shown = extra.slice(0, cap).join(", ");
|
|
47
|
+
const tail = extra.length > cap ? `, +${extra.length - cap} more${doctorRef}` : "";
|
|
42
48
|
lints.push(`${id}: reports ${extra.length} model(s) not in tiers (${shown}${tail}) — classify before routing (benchmark policy)`);
|
|
43
49
|
}
|
|
44
50
|
const at = h?.modelsDetectedAt;
|
|
@@ -50,13 +56,24 @@ export function modelLints(cfg, health, adapters) {
|
|
|
50
56
|
}
|
|
51
57
|
return lints;
|
|
52
58
|
}
|
|
59
|
+
// T2/T6: one lint per exclusion, naming the probe reason and date. TTY truncates reasons to 60 chars and
|
|
60
|
+
// points at doctor.json for the full text; non-TTY is byte-identical to the pre-T6 registry helper.
|
|
61
|
+
export function formatModelAuthLine(excluded, tty, stateDir = DEFAULT_STATE_DIR) {
|
|
62
|
+
const trunc = (s, n) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`);
|
|
63
|
+
const parts = excluded.map(({ key, reason, probedAt }) => {
|
|
64
|
+
const r = tty ? trunc(reason, 60) : reason;
|
|
65
|
+
return `${key} (${r} — probed ${probedAt.split("T")[0]})`;
|
|
66
|
+
});
|
|
67
|
+
const base = `model auth: ${excluded.length} channel(s) unauthed — ${parts.join(", ")}`;
|
|
68
|
+
return tty ? `${base}${doctorJsonRef(stateDir)}` : base;
|
|
69
|
+
}
|
|
53
70
|
// MODEL-05/06: render detected-vs-configured drift as a paste-ready config.yaml fragment. Locked v1.5
|
|
54
71
|
// decision: detection is strictly advisory — doctor prints, a human pastes; NO --write/--apply exists.
|
|
55
72
|
// Additions render WHOLE-LINE-COMMENTED with a `???` tier placeholder (a tier is a benchmark claim; the
|
|
56
73
|
// machine never fabricates one — auto-tiering reopens the NaN-routing class). Removals render as LIVE
|
|
57
74
|
// `<id>: null` tombstones (deepMerge deletes the key). Pure function: no fs, no routing contact.
|
|
58
75
|
// Returns "" when no adapter has a delta. Mirrors modelLints' per-adapter guards exactly.
|
|
59
|
-
export function suggestOverlay(cfg, health, adapters) {
|
|
76
|
+
export function suggestOverlay(cfg, health, adapters, stateDir = DEFAULT_STATE_DIR) {
|
|
60
77
|
const blocks = [];
|
|
61
78
|
for (const id of Object.keys(cfg.tiers)) {
|
|
62
79
|
const adapter = adapters.find((a) => a.id === id);
|
|
@@ -100,13 +117,13 @@ export function suggestOverlay(cfg, health, adapters) {
|
|
|
100
117
|
lines.push(` # ${model}: ??? #${date ? ` detected ${date} —` : ""} classify per benchmark policy (AA Index + SWE-bench Pro, dated), then uncomment`);
|
|
101
118
|
}
|
|
102
119
|
if (omitted)
|
|
103
|
-
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)`);
|
|
104
121
|
if (lines.length)
|
|
105
122
|
blocks.push(` ${id}:\n models:\n${lines.join("\n")}`);
|
|
106
123
|
}
|
|
107
124
|
if (blocks.length === 0)
|
|
108
125
|
return "";
|
|
109
|
-
return `# paste into
|
|
126
|
+
return `# paste into ${stateDir}/config.yaml — tickmarkr prints this, it never applies it\ntiers:\n${blocks.join("\n")}\n`;
|
|
110
127
|
}
|
|
111
128
|
// Purely relational id split for the addition gate (see suggestOverlay). Local, not exported: this is NOT a
|
|
112
129
|
// global identity concept — src/gates/review.ts has its own local modelId(), deliberately not shared.
|
|
@@ -5,7 +5,9 @@ export declare function allAdapters(opts?: {
|
|
|
5
5
|
}): WorkerAdapter[];
|
|
6
6
|
export declare function getAdapter(id: string, adapters: WorkerAdapter[]): WorkerAdapter;
|
|
7
7
|
export declare function probeAll(adapters: WorkerAdapter[]): Promise<Record<string, AuthHealth>>;
|
|
8
|
-
export
|
|
8
|
+
export type ProbeModelStatus = "ok" | "timeout" | "failed";
|
|
9
|
+
export type ProbeModelProgress = (adapter: string, model: string, status: ProbeModelStatus, durationMs: number) => void;
|
|
10
|
+
export declare function probeModels(cfg: DrovrConfig, repoRoot: string, adapters: WorkerAdapter[], health: Record<string, AuthHealth>, onProgress?: ProbeModelProgress): Promise<void>;
|
|
9
11
|
export declare function writeDoctor(repoRoot: string, health: Record<string, AuthHealth>): void;
|
|
10
12
|
export declare function readDoctor(repoRoot: string): Record<string, AuthHealth> | null;
|
|
11
13
|
export declare function discoverChannels(cfg: DrovrConfig, adapters: WorkerAdapter[], health: Record<string, AuthHealth>): BillingChannel[];
|
|
@@ -29,3 +31,13 @@ export declare function modelAuthLine(excluded: {
|
|
|
29
31
|
probedAt: string;
|
|
30
32
|
}[]): string;
|
|
31
33
|
export declare function doctorAgeMs(repoRoot: string): number | null;
|
|
34
|
+
export declare const INIT_DOCTOR_REUSE_MS: number;
|
|
35
|
+
export declare function formatDoctorAgeForInit(ageMs: number): string;
|
|
36
|
+
export declare function initDoctorReuse(repoRoot: string, fresh: boolean): {
|
|
37
|
+
reuse: boolean;
|
|
38
|
+
ageMs: number | null;
|
|
39
|
+
health: Record<string, AuthHealth> | null;
|
|
40
|
+
};
|
|
41
|
+
export declare function formatDoctorReport(cwd: string, cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], opts?: {
|
|
42
|
+
wrote?: boolean;
|
|
43
|
+
}): string;
|
|
@@ -1,8 +1,10 @@
|
|
|
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 { modelLints, suggestOverlay } from "./model-lints.js";
|
|
5
|
+
import { HerdrDriver } from "../drivers/herdr.js";
|
|
4
6
|
import { drovrDir, stateDirName } from "../graph/graph.js";
|
|
5
|
-
import { disallowedBy } from "../route/preference.js";
|
|
7
|
+
import { disallowedBy, excludedChannels, exclusionLine, preferRanks } from "../route/preference.js";
|
|
6
8
|
import { sh } from "../run/git.js";
|
|
7
9
|
import { claudeCode } from "./claude-code.js";
|
|
8
10
|
import { codex } from "./codex.js";
|
|
@@ -32,7 +34,8 @@ export async function probeAll(adapters) {
|
|
|
32
34
|
return out;
|
|
33
35
|
}
|
|
34
36
|
const MODEL_PROBE_PROMPT = "Reply with exactly OK and nothing else.";
|
|
35
|
-
|
|
37
|
+
// 60s: a healthy MCP-free codex probe measured 29.9s round-trip (2026-07-15) — 30s had zero headroom.
|
|
38
|
+
const MODEL_PROBE_TIMEOUT_MS = 60000;
|
|
36
39
|
// Auth-words only match when tied to a failure word; bare "auth"/"OAuth"/"authored" never fail (v1.27 T2).
|
|
37
40
|
const AUTH_FAILURE_RE = /\b4\d\d\b|\bauth(?:entication|orization)?\s+(?:error|failed|failure|denied)|unauthori[sz]ed|forbidden|access denied|credit(?:s)?\s+(?:exhausted|error|denied)/i;
|
|
38
41
|
function probeFailure(code, stdout, stderr, timedOut, timeoutMs = MODEL_PROBE_TIMEOUT_MS) {
|
|
@@ -44,8 +47,12 @@ function probeFailure(code, stdout, stderr, timedOut, timeoutMs = MODEL_PROBE_TI
|
|
|
44
47
|
? output.slice(0, 240) || `probe exited ${code}`
|
|
45
48
|
: undefined;
|
|
46
49
|
}
|
|
50
|
+
const probeModelStatus = (v) => v.authed ? "ok" : v.reason?.includes("timed out") ? "timeout" : "failed";
|
|
47
51
|
// v1.21: one bounded, headless call per configured model; detected-but-unclassified models never enter this loop.
|
|
48
|
-
export async function probeModels(cfg, repoRoot, adapters, health) {
|
|
52
|
+
export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
|
|
53
|
+
// T2: a prior doctor.json timeout verdict for this model skips the retry — a persistently dead
|
|
54
|
+
// model (e.g. opencode glm-5.2) costs one 30s attempt instead of two every run.
|
|
55
|
+
const priorHealth = readDoctor(repoRoot);
|
|
49
56
|
await Promise.all(adapters.map(async (a) => {
|
|
50
57
|
const h = health[a.id];
|
|
51
58
|
if (!h?.installed)
|
|
@@ -54,35 +61,65 @@ export async function probeModels(cfg, repoRoot, adapters, health) {
|
|
|
54
61
|
if (process.env.VITEST && [claudeCode, codex, cursorAgent, opencode, pi, grok].includes(a))
|
|
55
62
|
return;
|
|
56
63
|
const verdicts = {};
|
|
64
|
+
const priorModelAuth = priorHealth?.[a.id]?.modelAuth;
|
|
57
65
|
// models probe concurrently too (v1.33.5) — sequential chains made init wall time Σ(models×60s)
|
|
58
|
-
// on the slowest adapter (measured 96s); each probe is one tiny prompt, safe to overlap
|
|
59
|
-
|
|
66
|
+
// on the slowest adapter (measured 96s); each probe is one tiny prompt, safe to overlap.
|
|
67
|
+
// Capped at MODEL_PROBE_CONCURRENCY per adapter (v1.33.5 regression: 4 concurrent codex exec
|
|
68
|
+
// in one repo made ALL 4 codex probes time out where sequential passed 2/4).
|
|
69
|
+
await mapLimit(Object.keys(cfg.tiers[a.id]?.models ?? {}), MODEL_PROBE_CONCURRENCY, async (model) => {
|
|
70
|
+
const t0 = Date.now();
|
|
60
71
|
const probedAt = new Date().toISOString();
|
|
72
|
+
const priorTimedOut = priorModelAuth?.[model]?.reason?.includes("timed out") === true;
|
|
73
|
+
let verdict;
|
|
61
74
|
try {
|
|
62
75
|
if (typeof a.headlessCommand !== "function") {
|
|
63
|
-
|
|
64
|
-
return;
|
|
76
|
+
verdict = { authed: false, reason: "headless probe unavailable", probedAt, durationMs: Date.now() - t0 };
|
|
65
77
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
78
|
+
else {
|
|
79
|
+
const promptFile = join(mkdtempSync(join(tmpdir(), "drovr-auth-")), "probe.md");
|
|
80
|
+
writeFileSync(promptFile, MODEL_PROBE_PROMPT);
|
|
81
|
+
const r = await sh(a.headlessCommand(promptFile, model), repoRoot, MODEL_PROBE_TIMEOUT_MS);
|
|
82
|
+
if (r.timedOut && priorTimedOut) {
|
|
83
|
+
verdict = { authed: false, reason: `probe timed out (repeat — retry skipped) (${MODEL_PROBE_TIMEOUT_MS}ms)`, probedAt, durationMs: Date.now() - t0 };
|
|
84
|
+
}
|
|
85
|
+
else if (r.timedOut) {
|
|
86
|
+
const r2 = await sh(a.headlessCommand(promptFile, model), repoRoot, MODEL_PROBE_TIMEOUT_MS);
|
|
87
|
+
if (r2.timedOut) {
|
|
88
|
+
verdict = { authed: false, reason: `probe timed out twice (${MODEL_PROBE_TIMEOUT_MS}ms)`, probedAt, durationMs: Date.now() - t0 };
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
const reason = probeFailure(r2.code, r2.stdout, r2.stderr, r2.timedOut, MODEL_PROBE_TIMEOUT_MS);
|
|
92
|
+
verdict = reason ? { authed: false, reason, probedAt, durationMs: Date.now() - t0 } : { authed: true, probedAt, durationMs: Date.now() - t0 };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
const reason = probeFailure(r.code, r.stdout, r.stderr, r.timedOut, MODEL_PROBE_TIMEOUT_MS);
|
|
97
|
+
verdict = reason ? { authed: false, reason, probedAt, durationMs: Date.now() - t0 } : { authed: true, probedAt, durationMs: Date.now() - t0 };
|
|
74
98
|
}
|
|
75
99
|
}
|
|
76
|
-
const reason = probeFailure(r.code, r.stdout, r.stderr, r.timedOut, MODEL_PROBE_TIMEOUT_MS);
|
|
77
|
-
verdicts[model] = reason ? { authed: false, reason, probedAt } : { authed: true, probedAt };
|
|
78
100
|
}
|
|
79
101
|
catch (e) {
|
|
80
|
-
|
|
102
|
+
verdict = { authed: false, reason: String(e), probedAt, durationMs: Date.now() - t0 };
|
|
81
103
|
}
|
|
82
|
-
|
|
104
|
+
verdicts[model] = verdict;
|
|
105
|
+
onProgress?.(a.id, model, probeModelStatus(verdict), verdict.durationMs);
|
|
106
|
+
});
|
|
83
107
|
h.modelAuth = verdicts;
|
|
84
108
|
}));
|
|
85
109
|
}
|
|
110
|
+
// T2: caps concurrent probes per adapter at 2 — v1.33.5 regression, 4 concurrent codex exec in one
|
|
111
|
+
// repo made ALL 4 time out where sequential passed 2/4 (suspected CLI self-contention).
|
|
112
|
+
const MODEL_PROBE_CONCURRENCY = 2;
|
|
113
|
+
async function mapLimit(items, limit, fn) {
|
|
114
|
+
let i = 0;
|
|
115
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
116
|
+
while (i < items.length) {
|
|
117
|
+
const item = items[i++];
|
|
118
|
+
await fn(item);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
await Promise.all(workers);
|
|
122
|
+
}
|
|
86
123
|
const doctorPath = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "doctor.json");
|
|
87
124
|
export function writeDoctor(repoRoot, health) {
|
|
88
125
|
drovrDir(repoRoot);
|
|
@@ -165,3 +202,97 @@ export function doctorAgeMs(repoRoot) {
|
|
|
165
202
|
// renders a nonsensical negative age ("doctor.json is -0h old").
|
|
166
203
|
return Math.max(0, Date.now() - statSync(p).mtimeMs);
|
|
167
204
|
}
|
|
205
|
+
// ponytail: hardcoded 60m TTL for init reuse only — promote to config when an operator asks.
|
|
206
|
+
export const INIT_DOCTOR_REUSE_MS = 60 * 60 * 1000;
|
|
207
|
+
export function formatDoctorAgeForInit(ageMs) {
|
|
208
|
+
return `${Math.floor(ageMs / 60_000)}m`;
|
|
209
|
+
}
|
|
210
|
+
export function initDoctorReuse(repoRoot, fresh) {
|
|
211
|
+
const health = readDoctor(repoRoot);
|
|
212
|
+
const ageMs = doctorAgeMs(repoRoot);
|
|
213
|
+
const reuse = !fresh && health !== null && ageMs !== null && ageMs < INIT_DOCTOR_REUSE_MS;
|
|
214
|
+
return { reuse, ageMs, health: reuse ? health : null };
|
|
215
|
+
}
|
|
216
|
+
// Report body shared by init's cached-doctor path — mirrors doctor.ts formatting without probing.
|
|
217
|
+
export function formatDoctorReport(cwd, cfg, health, adapters, opts = {}) {
|
|
218
|
+
const rows = adapters.map((a) => {
|
|
219
|
+
const h = health[a.id];
|
|
220
|
+
const state = !h?.installed ? "not installed" : `${h.version ?? "installed"}${h.note ? ` (${h.note})` : ""}`;
|
|
221
|
+
return ` ${h?.installed ? "✓" : "✗"} ${a.id.padEnd(14)} ${state}`;
|
|
222
|
+
});
|
|
223
|
+
rows.push(` ${HerdrDriver.available() ? "✓" : "✗"} herdr ${HerdrDriver.available() ? "driver available (HERDR_ENV=1)" : "not detected — subprocess driver will be used"}`);
|
|
224
|
+
rows.push("workspace trust:");
|
|
225
|
+
for (const a of adapters) {
|
|
226
|
+
if (!health[a.id]?.installed)
|
|
227
|
+
continue;
|
|
228
|
+
if (!a.trust) {
|
|
229
|
+
rows.push(` = ${a.id.padEnd(14)} trust: n/a`);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
const v = a.trust(cwd);
|
|
234
|
+
if (v.status === "trusted")
|
|
235
|
+
rows.push(` ✓ ${a.id.padEnd(14)} trust: trusted`);
|
|
236
|
+
else if (v.status === "seeded")
|
|
237
|
+
rows.push(` ✓ ${a.id.padEnd(14)} trust: seeded`);
|
|
238
|
+
else
|
|
239
|
+
rows.push(` ! ${a.id.padEnd(14)} trust: action-required — run ONCE: ${v.command}`);
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
rows.push(` ! ${a.id.padEnd(14)} trust: action-required — run ONCE: (trust check failed: ${e instanceof Error ? e.message : String(e)})`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
for (const [role, sel] of [["judge", cfg.judge], ["consult", cfg.consult]]) {
|
|
246
|
+
if (!health[sel.adapter]?.installed) {
|
|
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
|
+
}
|
|
249
|
+
}
|
|
250
|
+
rows.push(...modelLints(cfg, health, adapters, { stateDir: stateDirName(cwd) }).map((l) => ` ! ${l}`));
|
|
251
|
+
const excluded = excludedChannels(cfg, adapters, health);
|
|
252
|
+
if (excluded.length)
|
|
253
|
+
rows.push(` ! ${exclusionLine(excluded)}`);
|
|
254
|
+
const servable = servableExclusions(cfg, adapters, health);
|
|
255
|
+
if (servable.length)
|
|
256
|
+
rows.push(` ! ${servabilityLine(servable)}`);
|
|
257
|
+
const visual = process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
|
|
258
|
+
const frag = suggestOverlay(cfg, health, adapters, stateDirName(cwd));
|
|
259
|
+
let drift = "";
|
|
260
|
+
if (frag) {
|
|
261
|
+
if (visual) {
|
|
262
|
+
const overlayPath = join(drovrDir(cwd), "doctor-overlay.yaml");
|
|
263
|
+
writeFileSync(overlayPath, frag);
|
|
264
|
+
drift = `\nmodel drift: unclassified models detected — paste-ready overlay written to ${overlayPath} (advisory; tickmarkr never applies it)`;
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
drift = `\nmodel drift — paste-ready overlay (advisory; tickmarkr never applies):\n${frag}`;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const trunc = (s, n) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`);
|
|
271
|
+
const dateOf = (iso) => iso.slice(0, 10);
|
|
272
|
+
const modelStatus = adapters.flatMap((a) => {
|
|
273
|
+
const h = health[a.id];
|
|
274
|
+
if (!h?.installed)
|
|
275
|
+
return [];
|
|
276
|
+
const classified = cfg.tiers[a.id]?.models ?? {};
|
|
277
|
+
const models = Object.keys(classified);
|
|
278
|
+
const unclassified = (h.models ?? []).filter((m) => !(m in classified));
|
|
279
|
+
if (!models.length && !unclassified.length)
|
|
280
|
+
return [];
|
|
281
|
+
const w = Math.max(8, ...models.map((m) => m.length));
|
|
282
|
+
const statusRows = [` ${a.id}`];
|
|
283
|
+
for (const m of models) {
|
|
284
|
+
const v = h.modelAuth?.[m];
|
|
285
|
+
const auth = !v ? "unknown" : v.authed ? "authed" : `unauthed: ${trunc(v.reason ?? "probe failed", 40)} (${dateOf(v.probedAt)})`;
|
|
286
|
+
const d = disallowedBy({ adapter: a.id, model: m }, cfg.routing);
|
|
287
|
+
const denied = d?.by === "deny" ? d.entry : "—";
|
|
288
|
+
const pref = preferRanks({ adapter: a.id, model: m }, cfg).map((p) => `${p.shape}#${p.rank}`).join(",") || "—";
|
|
289
|
+
statusRows.push(` ${m.padEnd(w)} ${classified[m].padEnd(8)} ${auth} denied=${denied} prefer=${pref}`);
|
|
290
|
+
}
|
|
291
|
+
if (unclassified.length)
|
|
292
|
+
statusRows.push(` (${unclassified.length} more listed, unclassified)`);
|
|
293
|
+
return statusRows;
|
|
294
|
+
});
|
|
295
|
+
const modelSummary = modelStatus.length ? `\nmodel status:\n${modelStatus.join("\n")}` : "";
|
|
296
|
+
const wrote = opts.wrote === false ? "" : `\nwrote ${stateDirName(cwd)}/doctor.json`;
|
|
297
|
+
return `tickmarkr doctor — capability matrix:\n${rows.join("\n")}${modelSummary}${drift}${wrote}`;
|
|
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,8 +1,8 @@
|
|
|
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";
|
|
5
|
-
import { modelLints, suggestOverlay } from "../../adapters/model-lints.js";
|
|
4
|
+
import { drovrDir, stateDirName } from "../../graph/graph.js";
|
|
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";
|
|
8
8
|
import { disallowedBy, excludedChannels, exclusionLine, preferRanks } from "../../route/preference.js";
|
|
@@ -35,6 +35,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
35
35
|
// stderr, live: auth probes are real LLM calls (up to 30s per configured model) and the CLI
|
|
36
36
|
// otherwise prints nothing until the end — silence here reads as a hang (v1.33.1)
|
|
37
37
|
console.error("probing installed agent CLIs — one short LLM call per configured model, may take a minute...");
|
|
38
|
+
const probeProgressTTY = process.stderr.isTTY === true;
|
|
38
39
|
const health = await probeAll(adapters);
|
|
39
40
|
// MODEL-02: detect models where the adapter exposes a list surface, BEFORE writing doctor.json (write once, below).
|
|
40
41
|
// Fail OPEN — the inverse of gates' fail-closed: detection is advisory, so a broken list surface NEVER fails doctor.
|
|
@@ -51,7 +52,9 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
51
52
|
}
|
|
52
53
|
catch { /* fail open: leave models as-is, doctor stays healthy */ }
|
|
53
54
|
}
|
|
54
|
-
await probeModels(cfg, cwd, adapters, health
|
|
55
|
+
await probeModels(cfg, cwd, adapters, health, probeProgressTTY
|
|
56
|
+
? (adapter, model, status, durationMs) => console.error(` ${adapter}:${model} ${status} (${(durationMs / 1000).toFixed(1)}s)`)
|
|
57
|
+
: undefined);
|
|
55
58
|
writeDoctor(cwd, health);
|
|
56
59
|
const rows = adapters.map((a) => {
|
|
57
60
|
const h = health[a.id];
|
|
@@ -88,7 +91,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
88
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}`);
|
|
89
92
|
}
|
|
90
93
|
}
|
|
91
|
-
rows.push(...modelLints(cfg, health, adapters).map((l) => ` ! ${l}`));
|
|
94
|
+
rows.push(...modelLints(cfg, health, adapters, { tty: ttyVisual(), stateDir: stateDirName(cwd) }).map((l) => ` ! ${l}`));
|
|
92
95
|
const excluded = excludedChannels(cfg, adapters, health);
|
|
93
96
|
if (excluded.length)
|
|
94
97
|
rows.push(` ! ${exclusionLine(excluded)}`);
|
|
@@ -99,7 +102,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
99
102
|
// MODEL-05/06: print-only drift fragment; advisory, whole-line-commented additions, drovr NEVER applies it.
|
|
100
103
|
// TTY gets a one-line summary + the fragment as a file (the full dump drowned everything else,
|
|
101
104
|
// v1.33.1 onboarding); machine/CI surface keeps the inline dump — layout is pinned by tests.
|
|
102
|
-
const frag = suggestOverlay(cfg, health, adapters);
|
|
105
|
+
const frag = suggestOverlay(cfg, health, adapters, stateDirName(cwd));
|
|
103
106
|
let drift = "";
|
|
104
107
|
if (frag) {
|
|
105
108
|
if (visual()) {
|
|
@@ -141,5 +144,5 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
141
144
|
return rows;
|
|
142
145
|
});
|
|
143
146
|
const modelSummary = modelStatus.length ? `\nmodel status:\n${modelStatus.join("\n")}` : "";
|
|
144
|
-
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`);
|
|
145
148
|
}
|
|
@@ -2,7 +2,8 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } fr
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
|
-
import {
|
|
5
|
+
import { allAdapters, formatDoctorAgeForInit, formatDoctorReport, initDoctorReuse } from "../../adapters/registry.js";
|
|
6
|
+
import { configTemplate, DEFAULT_CONFIG, globalConfigDir, loadConfig } from "../../config/config.js";
|
|
6
7
|
import { specTemplate } from "../../compile/native.js";
|
|
7
8
|
import { drovrDir } from "../../graph/graph.js";
|
|
8
9
|
import { doctor } from "./doctor.js";
|
|
@@ -23,6 +24,9 @@ Loop: \`tickmarkr compile <spec>\` → \`tickmarkr plan\` → \`tickmarkr run\`
|
|
|
23
24
|
- Treat missing or unparseable results as failures.
|
|
24
25
|
${DOCS_END}
|
|
25
26
|
`;
|
|
27
|
+
const skillPath = (cwd, skill) => join(cwd, ".claude", "skills", skill, "SKILL.md");
|
|
28
|
+
const skillsInstalled = (cwd) => AGENT_SKILLS.every((s) => existsSync(skillPath(cwd, s)));
|
|
29
|
+
const wizardDriverDefault = () => process.env.HERDR_ENV === "1" ? "herdr" : "auto";
|
|
26
30
|
async function installAgentFiles(cwd, force, docs, notes) {
|
|
27
31
|
const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
28
32
|
let prompt;
|
|
@@ -34,7 +38,7 @@ async function installAgentFiles(cwd, force, docs, notes) {
|
|
|
34
38
|
};
|
|
35
39
|
try {
|
|
36
40
|
for (const skill of AGENT_SKILLS) {
|
|
37
|
-
const dest =
|
|
41
|
+
const dest = skillPath(cwd, skill);
|
|
38
42
|
const exists = existsSync(dest);
|
|
39
43
|
if (exists && !force && !(await confirm(`Overwrite ${dest}?`))) {
|
|
40
44
|
notes.push(`skipped existing ${dest}; pass --force to overwrite it`);
|
|
@@ -63,6 +67,42 @@ async function installAgentFiles(cwd, force, docs, notes) {
|
|
|
63
67
|
prompt?.close();
|
|
64
68
|
}
|
|
65
69
|
}
|
|
70
|
+
const askDefault = async (rl, label, def) => {
|
|
71
|
+
const answer = (await rl.question(`${label} [${def}] `)).trim();
|
|
72
|
+
return answer || def;
|
|
73
|
+
};
|
|
74
|
+
const askYesNo = async (rl, label, def) => {
|
|
75
|
+
const hint = def ? "Y/n" : "y/N";
|
|
76
|
+
const answer = (await rl.question(`${label} [${hint}] `)).trim();
|
|
77
|
+
if (!answer)
|
|
78
|
+
return def;
|
|
79
|
+
return /^(?:y|yes)$/i.test(answer);
|
|
80
|
+
};
|
|
81
|
+
async function runInitWizard(cwd) {
|
|
82
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
83
|
+
try {
|
|
84
|
+
const driverDef = wizardDriverDefault();
|
|
85
|
+
const driverRaw = await askDefault(rl, "Driver (auto|herdr|subprocess)", driverDef);
|
|
86
|
+
const driver = ["auto", "herdr", "subprocess"].includes(driverRaw)
|
|
87
|
+
? driverRaw
|
|
88
|
+
: driverDef;
|
|
89
|
+
const concRaw = await askDefault(rl, "Concurrency", String(DEFAULT_CONFIG.concurrency));
|
|
90
|
+
const parsed = Number.parseInt(concRaw, 10);
|
|
91
|
+
const concurrency = Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_CONFIG.concurrency;
|
|
92
|
+
const llmDef = DEFAULT_CONFIG.visibility.llm;
|
|
93
|
+
const llmRaw = await askDefault(rl, "visibility.llm (pane|headless)", llmDef);
|
|
94
|
+
const llm = llmRaw === "pane" || llmRaw === "headless" ? llmRaw : llmDef;
|
|
95
|
+
let installSkills = false;
|
|
96
|
+
if (!skillsInstalled(cwd)) {
|
|
97
|
+
const skillsDef = existsSync(join(cwd, ".claude")) && !skillsInstalled(cwd);
|
|
98
|
+
installSkills = await askYesNo(rl, "Install agent skills (tickmarkr-loop/tickmarkr-auto) into .claude/skills?", skillsDef);
|
|
99
|
+
}
|
|
100
|
+
return { overlay: { driver, concurrency, visibility: { llm } }, installSkills };
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
rl.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
66
106
|
export async function init(argv, cwd = process.cwd()) {
|
|
67
107
|
const { values } = parseArgs({
|
|
68
108
|
args: argv,
|
|
@@ -71,20 +111,25 @@ export async function init(argv, cwd = process.cwd()) {
|
|
|
71
111
|
agent: { type: "boolean" },
|
|
72
112
|
force: { type: "boolean" },
|
|
73
113
|
docs: { type: "boolean" },
|
|
114
|
+
fresh: { type: "boolean" },
|
|
115
|
+
yes: { type: "boolean" },
|
|
74
116
|
},
|
|
75
117
|
});
|
|
76
118
|
const gdir = values["global-dir"] ?? globalConfigDir();
|
|
77
119
|
mkdirSync(gdir, { recursive: true });
|
|
78
120
|
const notes = [];
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
121
|
+
const globalPath = join(gdir, "config.yaml");
|
|
122
|
+
if (!existsSync(globalPath)) {
|
|
123
|
+
writeFileSync(globalPath, configTemplate());
|
|
124
|
+
notes.push(`wrote ${globalPath}`);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
notes.push(`kept existing ${globalPath}`);
|
|
87
128
|
}
|
|
129
|
+
const repoConfigPath = join(drovrDir(cwd), "config.yaml");
|
|
130
|
+
const repoConfigExists = existsSync(repoConfigPath);
|
|
131
|
+
if (repoConfigExists)
|
|
132
|
+
notes.push(`kept existing ${repoConfigPath}`);
|
|
88
133
|
const specPath = join(cwd, "tickmarkr.spec.md");
|
|
89
134
|
if (existsSync(specPath)) {
|
|
90
135
|
notes.push(`kept existing ${specPath}`);
|
|
@@ -96,8 +141,26 @@ export async function init(argv, cwd = process.cwd()) {
|
|
|
96
141
|
writeFileSync(specPath, specTemplate());
|
|
97
142
|
notes.push(`wrote ${specPath}`);
|
|
98
143
|
}
|
|
144
|
+
const fresh = values.fresh ?? false;
|
|
145
|
+
const { reuse, ageMs, health } = initDoctorReuse(cwd, fresh);
|
|
146
|
+
const doc = reuse && health && ageMs !== null
|
|
147
|
+
? `using probe results from ${formatDoctorAgeForInit(ageMs)} ago — run tickmarkr doctor to refresh (or init --fresh)\n${formatDoctorReport(cwd, loadConfig(cwd), health, allAdapters(), { wrote: false })}`
|
|
148
|
+
: await doctor([], cwd);
|
|
149
|
+
const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true && !(values.yes ?? false);
|
|
150
|
+
if (!repoConfigExists) {
|
|
151
|
+
if (interactive) {
|
|
152
|
+
const wizard = await runInitWizard(cwd);
|
|
153
|
+
writeFileSync(repoConfigPath, configTemplate(wizard.overlay));
|
|
154
|
+
notes.push(`wrote ${repoConfigPath}`);
|
|
155
|
+
if (wizard.installSkills)
|
|
156
|
+
await installAgentFiles(cwd, values.force ?? false, values.docs ?? false, notes);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
writeFileSync(repoConfigPath, configTemplate());
|
|
160
|
+
notes.push(`wrote ${repoConfigPath}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
99
163
|
if (values.agent)
|
|
100
164
|
await installAgentFiles(cwd, values.force ?? false, values.docs ?? false, notes);
|
|
101
|
-
const doc = await doctor([], cwd);
|
|
102
165
|
return `${notes.join("\n")}\n${doc}\nnext: edit tickmarkr.spec.md, then tickmarkr compile tickmarkr.spec.md && tickmarkr plan && tickmarkr run`;
|
|
103
166
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { allAdapters, discoverChannels, doctorAgeMs, modelAuthExclusions,
|
|
2
|
-
import { modelLints } from "../../adapters/model-lints.js";
|
|
1
|
+
import { allAdapters, discoverChannels, doctorAgeMs, modelAuthExclusions, probeAll, readDoctor, servableExclusions, servabilityLine } from "../../adapters/registry.js";
|
|
2
|
+
import { formatModelAuthLine, modelLints, ttyVisual } from "../../adapters/model-lints.js";
|
|
3
3
|
import { collateralLints } from "../../compile/collateral.js";
|
|
4
4
|
import { loadConfig } from "../../config/config.js";
|
|
5
5
|
import { loadGraph } from "../../graph/graph.js";
|
|
@@ -31,7 +31,7 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
|
|
|
31
31
|
// T2 (2026-07-13): per-model unauthed verdicts from doctor — one lint per exclusion (reason + date).
|
|
32
32
|
const modelUnauthed = modelAuthExclusions(cfg, adapters, health);
|
|
33
33
|
if (modelUnauthed.length)
|
|
34
|
-
lines.push(
|
|
34
|
+
lines.push(formatModelAuthLine(modelUnauthed, ttyVisual()), "");
|
|
35
35
|
const unauthed = adapters.filter((a) => health[a.id]?.installed && !health[a.id]?.authed).map((a) => a.id);
|
|
36
36
|
if (unauthed.length)
|
|
37
37
|
lines.push(`installed but unauthed: ${unauthed.join(", ")} — channels excluded from routing`, "");
|
|
@@ -58,7 +58,7 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
|
|
|
58
58
|
if (!health[sel.adapter]?.installed)
|
|
59
59
|
lints.push(`${role}: ${sel.adapter}:${sel.model} not installed — that gate/consult will fail closed`);
|
|
60
60
|
}
|
|
61
|
-
lints.push(...modelLints(cfg, health, adapters)); // health may be pre-v1.5/probeAll-fallback — no-detection branch covers both
|
|
61
|
+
lints.push(...modelLints(cfg, health, adapters, { tty: ttyVisual() })); // health may be pre-v1.5/probeAll-fallback — no-detection branch covers both
|
|
62
62
|
let cost = 0;
|
|
63
63
|
for (const t of g.tasks) {
|
|
64
64
|
try {
|
|
@@ -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/cli/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { unlock } from "./commands/unlock.js";
|
|
|
16
16
|
export const COMMANDS = {
|
|
17
17
|
init, doctor, compile, scope, plan, run, status, resume, report, profile, unlock, approve,
|
|
18
18
|
};
|
|
19
|
+
const HELP_CMDS = new Set(["help", "-h", "--help"]);
|
|
19
20
|
export const USAGE = `tickmarkr — spec-driven orchestration harness for AI coding agents
|
|
20
21
|
usage: tickmarkr <command>
|
|
21
22
|
init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs
|
|
@@ -34,9 +35,11 @@ usage: tickmarkr <command>
|
|
|
34
35
|
// unknown/missing cmd → USAGE (exit 1 if a cmd was typed, 0 for bare `drovr`); a handler throw becomes
|
|
35
36
|
// a one-line `drovr <cmd>: <message>` (never a raw stack) at exit 1.
|
|
36
37
|
export async function dispatch(cmd, argv, commands = COMMANDS) {
|
|
37
|
-
|
|
38
|
+
if (!cmd || HELP_CMDS.has(cmd))
|
|
39
|
+
return { out: USAGE, code: 0 };
|
|
40
|
+
const fn = commands[cmd];
|
|
38
41
|
if (!fn)
|
|
39
|
-
return { out: USAGE, code:
|
|
42
|
+
return { out: USAGE, code: 1 };
|
|
40
43
|
try {
|
|
41
44
|
return { out: await fn(argv), code: 0 };
|
|
42
45
|
}
|
package/dist/config/config.d.ts
CHANGED
|
@@ -180,5 +180,12 @@ export declare function globalConfigDir(): string;
|
|
|
180
180
|
export declare function loadConfig(repoRoot: string, opts?: {
|
|
181
181
|
globalDir?: string;
|
|
182
182
|
}): DrovrConfig;
|
|
183
|
-
export
|
|
183
|
+
export type InitConfigOverlay = {
|
|
184
|
+
concurrency?: number;
|
|
185
|
+
driver?: DrovrConfig["driver"];
|
|
186
|
+
visibility?: {
|
|
187
|
+
llm?: DrovrConfig["visibility"]["llm"];
|
|
188
|
+
};
|
|
189
|
+
};
|
|
190
|
+
export declare function configTemplate(overlay?: InitConfigOverlay): string;
|
|
184
191
|
export {};
|
package/dist/config/config.js
CHANGED
|
@@ -271,8 +271,8 @@ export function loadConfig(repoRoot, opts = {}) {
|
|
|
271
271
|
throw new ConfigError(z.prettifyError(r.error));
|
|
272
272
|
return r.data;
|
|
273
273
|
}
|
|
274
|
-
export function configTemplate() {
|
|
275
|
-
|
|
274
|
+
export function configTemplate(overlay) {
|
|
275
|
+
const base = `# tickmarkr config overlay — merges over built-in defaults (repo beats global beats defaults)
|
|
276
276
|
# concurrency: 3
|
|
277
277
|
# driver: auto # auto | herdr | subprocess
|
|
278
278
|
# taskTimeoutMinutes: 30
|
|
@@ -329,4 +329,19 @@ export function configTemplate() {
|
|
|
329
329
|
# ever — tickmarkr reuses stable worktree paths so the accept persists across runs; blocked-pane paging
|
|
330
330
|
# surfaces each first-time dialog.
|
|
331
331
|
`;
|
|
332
|
+
if (!overlay)
|
|
333
|
+
return base;
|
|
334
|
+
const lines = [];
|
|
335
|
+
if (overlay.concurrency !== undefined)
|
|
336
|
+
lines.push(`concurrency: ${overlay.concurrency}`);
|
|
337
|
+
if (overlay.driver !== undefined)
|
|
338
|
+
lines.push(`driver: ${overlay.driver}`);
|
|
339
|
+
if (overlay.visibility?.llm !== undefined) {
|
|
340
|
+
lines.push("visibility:");
|
|
341
|
+
lines.push(` llm: ${overlay.visibility.llm}`);
|
|
342
|
+
}
|
|
343
|
+
if (!lines.length)
|
|
344
|
+
return base;
|
|
345
|
+
const nl = base.indexOf("\n");
|
|
346
|
+
return `${base.slice(0, nl + 1)}${lines.join("\n")}\n${base.slice(nl + 1)}`;
|
|
332
347
|
}
|
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
|
|