tickmarkr 1.38.0 → 1.40.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 +25 -18
- package/dist/adapters/fake.js +23 -5
- package/dist/adapters/registry.js +12 -12
- package/dist/adapters/types.d.ts +1 -1
- package/dist/adapters/types.js +3 -2
- package/dist/cli/commands/init.js +43 -10
- package/dist/cli/commands/plan.js +12 -1
- package/dist/cli/commands/resume.d.ts +4 -1
- package/dist/cli/commands/resume.js +4 -1
- package/dist/cli/commands/run.d.ts +4 -1
- package/dist/cli/commands/run.js +9 -1
- package/dist/cli/commands/version.d.ts +1 -0
- package/dist/cli/commands/version.js +8 -0
- package/dist/cli/index.d.ts +5 -1
- package/dist/cli/index.js +8 -3
- package/dist/compile/native.js +8 -2
- package/dist/config/config.d.ts +3 -0
- package/dist/config/config.js +7 -1
- package/dist/drivers/subprocess.js +9 -2
- package/dist/gates/acceptance.d.ts +1 -0
- package/dist/gates/acceptance.js +24 -4
- package/dist/gates/review.js +26 -7
- package/dist/gates/run-gates.js +2 -2
- package/dist/graph/schema.d.ts +2 -0
- package/dist/graph/schema.js +16 -1
- package/dist/run/consult.d.ts +3 -0
- package/dist/run/consult.js +26 -2
- package/dist/run/daemon.js +19 -6
- package/package.json +1 -1
- package/schema/rungraph.schema.json +4 -0
- package/skills/tickmarkr-auto/SKILL.md +23 -2
- package/skills/tickmarkr-loop/SKILL.md +23 -2
package/README.md
CHANGED
|
@@ -6,12 +6,16 @@
|
|
|
6
6
|
|
|
7
7
|
tickmarkr is a spec-driven orchestration harness for AI coding agent CLIs. You write a spec with
|
|
8
8
|
acceptance criteria; the engine routes tasks to the best installed agent CLI (claude-code, codex,
|
|
9
|
-
cursor-agent, opencode, grok, pi) by cost and capability, dispatches work in
|
|
10
|
-
— as interactive TUIs when running under [herdr](https://herdr.dev), headless
|
|
11
|
-
— and independently verifies
|
|
9
|
+
cursor-agent, opencode, grok, pi) by cost and capability, dispatches work in git worktrees for
|
|
10
|
+
change isolation — as interactive TUIs when running under [herdr](https://herdr.dev), headless
|
|
11
|
+
subprocesses otherwise — and independently verifies each committed result by checking for no new
|
|
12
|
+
baseline failures per task, then strictly verifying the integration tip. Green tasks consolidate onto a
|
|
12
13
|
`tickmarkr/<runId>` branch; merging to your mainline is always your call, never automated. Engage
|
|
13
14
|
with full visibility into routing decisions, worker progress, and gate verdicts — or run headless
|
|
14
|
-
and review the
|
|
15
|
+
and review the local execution log afterward.
|
|
16
|
+
|
|
17
|
+
Here, **isolated git worktrees** means change isolation: each task gets its own worktree and branch
|
|
18
|
+
so its diff is separated from sibling tasks. It is not a process sandbox or host-containment boundary.
|
|
15
19
|
|
|
16
20
|
## Invariants
|
|
17
21
|
|
|
@@ -19,7 +23,8 @@ These are law; the codebase fails closed around them:
|
|
|
19
23
|
|
|
20
24
|
- `acceptance[]` required on every task; compile fails without it
|
|
21
25
|
- new engagements consolidate to a `tickmarkr/<runId>` branch — never main
|
|
22
|
-
- gates never trust worker claims;
|
|
26
|
+
- gates never trust worker claims; each task is checked for no new baseline failures and the merged
|
|
27
|
+
integration tip is strictly re-verified
|
|
23
28
|
- state is files + git only; no DB, no services
|
|
24
29
|
- worker/judge/review/consult prompts end with machine-parseable trailers
|
|
25
30
|
(structured JSON verdicts); parse defensively, fail closed
|
|
@@ -68,7 +73,8 @@ tickmarkr report <runId> --md # engagement record in Markdown
|
|
|
68
73
|
|
|
69
74
|
That's the flow: `init` scaffolds config, you write tasks with `acceptance[]` criteria, `compile`
|
|
70
75
|
validates and builds the graph, `plan` shows routing decisions and cost, `run` dispatches work to
|
|
71
|
-
installed CLIs and
|
|
76
|
+
installed CLIs and checks each task for no new baseline failures before strictly verifying the
|
|
77
|
+
integration tip, and `report` documents the outcome. All
|
|
72
78
|
green tasks land on `tickmarkr/<runId>` — merge to your mainline is your decision.
|
|
73
79
|
|
|
74
80
|
## Agent-ready repos: `tickmarkr init --agent`
|
|
@@ -98,7 +104,7 @@ Consent rules — every write is additive, never destructive:
|
|
|
98
104
|
|
|
99
105
|
```bash
|
|
100
106
|
tickmarkr status # engagement state (--watch to follow live)
|
|
101
|
-
tickmarkr resume <runId> # continue an engagement from the
|
|
107
|
+
tickmarkr resume <runId> # continue an engagement from the local execution log
|
|
102
108
|
tickmarkr approve <runId> <taskId> # approve a parked task (--reason to document)
|
|
103
109
|
tickmarkr report <runId> # cost/quality report
|
|
104
110
|
tickmarkr profile # show the learned routing profile
|
|
@@ -160,11 +166,11 @@ routing:
|
|
|
160
166
|
```
|
|
161
167
|
`tickmarkr plan` lints denied models identically to unauthed ones. Re-enable by deleting one line.
|
|
162
168
|
|
|
163
|
-
## Run output and
|
|
169
|
+
## Run output and local execution log
|
|
164
170
|
|
|
165
|
-
When you execute `tickmarkr run`, the daemon records every event in an append-only
|
|
171
|
+
When you execute `tickmarkr run`, the daemon records every event in an append-only local journal:
|
|
166
172
|
task dispatch, gate verdicts, worker status, and merges. Narration streams to stdout; you can also
|
|
167
|
-
`--watch` or tail the
|
|
173
|
+
`--watch` or tail the journal directly. If interrupted, `tickmarkr resume <runId>` replays the journal
|
|
168
174
|
and continues from the last stable state — deterministic, not ephemeral.
|
|
169
175
|
|
|
170
176
|
## Usage and cost
|
|
@@ -193,7 +199,7 @@ and first-attempt success rate. Cost reporting follows strict honesty rules and
|
|
|
193
199
|
- **Basis always shown**: every cost figure prints the token count, rate used, and date so estimates can be audited
|
|
194
200
|
- **No network calls**: pricing config is operator-maintained locally, seeded with dated
|
|
195
201
|
comments and LiteLLM's JSON file named as the copy-from source; tickmarkr never calls home to fetch rates
|
|
196
|
-
- **Attribution from journal**: token counts come from tickmarkr's own telemetry spans in the
|
|
202
|
+
- **Attribution from journal**: token counts come from tickmarkr's own telemetry spans in the local journal, never from provider invoices or dashboards
|
|
197
203
|
|
|
198
204
|
## Visibility: optional supervised workspace
|
|
199
205
|
|
|
@@ -248,7 +254,7 @@ Every pane and tab tickmarkr creates receives a **parseable ownership name** enc
|
|
|
248
254
|
|
|
249
255
|
tickmarkr creates all owned panes only within the run's workspace; any tickmarkr-owned panes discovered outside the run's workspace (from prior runs or placement bugs) are reconciled and closed. Any pane not matching the ownership contract is **foreign** — created by you or another tool — and is never closed automatically.
|
|
250
256
|
|
|
251
|
-
**Desired-state reconciliation**: A pure function computes the exact set of panes that should exist from the
|
|
257
|
+
**Desired-state reconciliation**: A pure function computes the exact set of panes that should exist from the local journal at any moment:
|
|
252
258
|
- Worker panes for all in-flight task attempts
|
|
253
259
|
- Gate panes for unread judge/review/consult verdicts
|
|
254
260
|
- The watch pane (if running)
|
|
@@ -264,11 +270,11 @@ Reconciliation failures (herdr unavailable, a pane vanished mid-sweep) never fai
|
|
|
264
270
|
|
|
265
271
|
### Workspace trust
|
|
266
272
|
|
|
267
|
-
tickmarkr
|
|
268
|
-
|
|
273
|
+
tickmarkr creates fresh git worktrees for change isolation. That is separate from each CLI's own
|
|
274
|
+
workspace-trust behavior:
|
|
269
275
|
|
|
270
|
-
- `tickmarkr doctor`
|
|
271
|
-
- Some CLIs show a "Workspace Trust" dialog; tickmarkr
|
|
276
|
+
- `tickmarkr doctor` invokes each installed adapter's trust hook where supported and seeds trust where possible
|
|
277
|
+
- Some CLIs show a "Workspace Trust" dialog; tickmarkr auto-answers only a recognized fingerprint once per slot
|
|
272
278
|
- Other blocked dialogs page you for manual approval
|
|
273
279
|
|
|
274
280
|
## Spec formats
|
|
@@ -293,8 +299,9 @@ Every task requires explicit `acceptance[]` criteria; compile fails without them
|
|
|
293
299
|
- **`test <name>`** — run a named test from your suite (must exist)
|
|
294
300
|
- **`judge <rubric>`** — LLM verdict against your rubric; fail-closed, never overrides failed command/test
|
|
295
301
|
|
|
296
|
-
At runtime, the **scope gate**
|
|
297
|
-
|
|
302
|
+
At runtime, the **scope gate** derives `git diff --name-only` from the task base and compares it with
|
|
303
|
+
the spec-declared files. Out-of-scope edits fail unless the operator config explicitly allowlists
|
|
304
|
+
them; worker-declared deviations are recorded as notes, not authority.
|
|
298
305
|
|
|
299
306
|
## Claude Code integration
|
|
300
307
|
|
package/dist/adapters/fake.js
CHANGED
|
@@ -14,7 +14,13 @@ export class FakeAdapter {
|
|
|
14
14
|
this.script = JSON.parse(readFileSync(scriptPath, "utf8"));
|
|
15
15
|
}
|
|
16
16
|
async probe() {
|
|
17
|
-
return {
|
|
17
|
+
return {
|
|
18
|
+
installed: true, authed: true, version: "fake", models: ["fake-1", "fake-2"],
|
|
19
|
+
modelAuth: {
|
|
20
|
+
"fake-1": { authed: true, probedAt: "1970-01-01T00:00:00.000Z" },
|
|
21
|
+
"fake-2": { authed: true, probedAt: "1970-01-01T00:00:00.000Z" },
|
|
22
|
+
},
|
|
23
|
+
};
|
|
18
24
|
}
|
|
19
25
|
channels(_cfg) {
|
|
20
26
|
return [
|
|
@@ -27,9 +33,11 @@ export class FakeAdapter {
|
|
|
27
33
|
// Phase 47 (GATE-09): detect role at COMMAND-BUILD time. runHeadless/runViaDriver write the prompt
|
|
28
34
|
// file BEFORE calling headlessCommand, so it exists here. The counter advances ONLY when the prompt
|
|
29
35
|
// contains TICKMARKR-JUDGE — a review/consult prompt never touches it (research Pitfall 3).
|
|
36
|
+
let prompt = "";
|
|
30
37
|
let isJudge = false;
|
|
31
38
|
try {
|
|
32
|
-
|
|
39
|
+
prompt = readFileSync(promptFile, "utf8");
|
|
40
|
+
isJudge = /TICKMARKR-JUDGE/.test(prompt);
|
|
33
41
|
}
|
|
34
42
|
catch {
|
|
35
43
|
// unreadable promptFile: can't detect role; serve static values (legacy headless-call behavior)
|
|
@@ -41,6 +49,14 @@ export class FakeAdapter {
|
|
|
41
49
|
val = val[Math.min(this.judgeIdx, val.length - 1)];
|
|
42
50
|
this.judgeIdx++;
|
|
43
51
|
}
|
|
52
|
+
if (key === "judge" && isJudge && val && typeof val === "object" && !Array.isArray(val)) {
|
|
53
|
+
const verdict = val;
|
|
54
|
+
if (verdict.pass === true && Array.isArray(verdict.criteria) && verdict.criteria.length === 0) {
|
|
55
|
+
const items = prompt.match(/## Acceptance criteria \(judge\)\n([\s\S]*?)\n\n## Diff/)?.[1]
|
|
56
|
+
.split("\n").filter((line) => line.startsWith("- ")).map((line) => line.slice(2)) ?? [];
|
|
57
|
+
val = { ...verdict, criteria: items.map((criterion) => ({ criterion, met: true, reason: "scripted fake pass" })) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
44
60
|
const p = join(dirname(this.scriptPath), `${key}.json`);
|
|
45
61
|
writeFileSync(p, JSON.stringify(val ?? {}, null, 1));
|
|
46
62
|
return p;
|
|
@@ -59,14 +75,16 @@ export class FakeAdapter {
|
|
|
59
75
|
// SPEND-01: synthetic usage as a TIMESTAMPED disk record the fake's own reader picks up post-hoc —
|
|
60
76
|
// never a trailer, never pane text. node stamps a real ms-precision ISO time (bash 3.2 on darwin has
|
|
61
77
|
// no ms clock) so the record lands at/after this attempt's dispatch and clears the sinceMs cursor.
|
|
62
|
-
// Written
|
|
78
|
+
// Written BEFORE the trailer echo (same order real CLIs use: session-store rows land during work,
|
|
79
|
+
// completion marker last) so an interactive harvest on the trailer never races the usage writer.
|
|
80
|
+
// Still after the step's `git add`/commit ⇒ stays untracked ⇒ invisible to scope/evidence gates.
|
|
63
81
|
const usageWrite = step.usage
|
|
64
82
|
? `; node -e ${shq(`require("fs").writeFileSync(".tickmarkr-usage.json", JSON.stringify({timestamp:new Date().toISOString(), usage:${JSON.stringify(step.usage)}}))`)}`
|
|
65
83
|
: "";
|
|
66
84
|
const base = !step.result
|
|
67
85
|
? `bash -c ${shq(step.shell)}` // no trailer: scripted stall/quota
|
|
68
|
-
: `bash -c ${shq(step.shell)}; echo ${shq(`TICKMARKR_RESULT_${nonce} ` + JSON.stringify({ deviations: [], ...step.result }))}`;
|
|
69
|
-
return base
|
|
86
|
+
: `bash -c ${shq(step.shell)}${usageWrite}; echo ${shq(`TICKMARKR_RESULT_${nonce} ` + JSON.stringify({ deviations: [], ...step.result }))}`;
|
|
87
|
+
return base;
|
|
70
88
|
}
|
|
71
89
|
// SPEND-01: read the cwd-keyed usage record from DISK (never a pane), honoring the attempt cursor.
|
|
72
90
|
// A record stamped before sinceMs, an unparseable stamp, or a bad usage shape all fail OPEN to
|
|
@@ -202,10 +202,10 @@ export function discoverChannels(cfg, adapters, health) {
|
|
|
202
202
|
.flatMap((a) => {
|
|
203
203
|
const h = health[a.id];
|
|
204
204
|
const s = h?.servable;
|
|
205
|
-
// T2 (2026-07-13): a model doctor marked
|
|
206
|
-
// channel — routing
|
|
207
|
-
//
|
|
208
|
-
return a.channels(cfg).filter((c) => modelAuthed(h, c.model) && (!s || s.includes(c.model)));
|
|
205
|
+
// T2 (2026-07-13): only a model doctor marked authed (modelAuth[model].authed===true) advertises a
|
|
206
|
+
// channel — routing an unknown or 403 into dispatch is fail-closed. Operators with pre-v1.21 doctor.json
|
|
207
|
+
// can opt into the prior unknown-is-routable behavior with routing.allowUnverifiedModels.
|
|
208
|
+
return a.channels(cfg).filter((c) => modelAuthed(h, c.model, cfg.routing.allowUnverifiedModels) && (!s || s.includes(c.model)));
|
|
209
209
|
});
|
|
210
210
|
if (!cfg.routing.allow && !cfg.routing.deny)
|
|
211
211
|
return base;
|
|
@@ -233,23 +233,23 @@ export function servabilityLine(excluded) {
|
|
|
233
233
|
const parts = excluded.map(({ key, adapter }) => `${key} (not in ${adapter}'s served model list)`);
|
|
234
234
|
return `servability: ${excluded.length} channel(s) unservable — ${parts.join(", ")}`;
|
|
235
235
|
}
|
|
236
|
-
// T2 (2026-07-13): channels discoverChannels dropped because doctor marked their model unauthed
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
// a missing modelAuth record ⇒ unknown ⇒ NOT excluded (compat with pre-v1.5 doctor.json + probeAll fallback).
|
|
236
|
+
// T2 (2026-07-13): channels discoverChannels dropped because doctor marked their model unauthed or left it
|
|
237
|
+
// unverified. Computed from the SAME inputs as the drop (a.channels(cfg)) so attribution can never drift from
|
|
238
|
+
// behavior. installed+authed mirrors the discoverChannels adapter gate.
|
|
240
239
|
export function modelAuthExclusions(cfg, adapters, health) {
|
|
241
240
|
const out = [];
|
|
242
241
|
for (const a of adapters) {
|
|
243
242
|
const h = health[a.id];
|
|
244
243
|
if (!h?.installed || !h?.authed)
|
|
245
244
|
continue;
|
|
246
|
-
const verdicts = h.modelAuth;
|
|
247
|
-
if (!verdicts)
|
|
248
|
-
continue;
|
|
249
245
|
for (const c of a.channels(cfg)) {
|
|
250
|
-
const v =
|
|
246
|
+
const v = h.modelAuth?.[c.model];
|
|
247
|
+
if (modelAuthed(h, c.model, cfg.routing.allowUnverifiedModels))
|
|
248
|
+
continue;
|
|
251
249
|
if (v?.authed === false)
|
|
252
250
|
out.push({ key: channelKey(c), adapter: a.id, reason: v.reason ?? "probe failed", probedAt: v.probedAt });
|
|
251
|
+
else
|
|
252
|
+
out.push({ key: channelKey(c), adapter: a.id, reason: "no model auth verdict — run tickmarkr doctor", probedAt: "not recorded" });
|
|
253
253
|
}
|
|
254
254
|
}
|
|
255
255
|
return out;
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export interface AuthHealth {
|
|
|
38
38
|
servable?: string[];
|
|
39
39
|
modelAuth?: Record<string, ModelAuth>;
|
|
40
40
|
}
|
|
41
|
-
export declare function modelAuthed(health: AuthHealth | undefined, model: string): boolean;
|
|
41
|
+
export declare function modelAuthed(health: AuthHealth | undefined, model: string, allowUnverifiedModels?: boolean): boolean;
|
|
42
42
|
export interface Invocation {
|
|
43
43
|
command: string;
|
|
44
44
|
}
|
package/dist/adapters/types.js
CHANGED
|
@@ -23,8 +23,9 @@ export function addUsage(a, b) {
|
|
|
23
23
|
reasoning: add(a.reasoning, b.reasoning),
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
-
export function modelAuthed(health, model) {
|
|
27
|
-
|
|
26
|
+
export function modelAuthed(health, model, allowUnverifiedModels = false) {
|
|
27
|
+
const authed = health?.modelAuth?.[model]?.authed;
|
|
28
|
+
return authed === true || (authed === undefined && allowUnverifiedModels);
|
|
28
29
|
}
|
|
29
30
|
export function matchesTrustDialog(paneText, dialog) {
|
|
30
31
|
return paneText.includes(dialog.fingerprint);
|
|
@@ -13,18 +13,51 @@ const DOCS_END = "<!-- tickmarkr:agent-docs end -->";
|
|
|
13
13
|
const AGENT_DOCS = `${DOCS_BEGIN}
|
|
14
14
|
## tickmarkr
|
|
15
15
|
|
|
16
|
-
tickmarkr
|
|
16
|
+
tickmarkr compiles repository specs into isolated, independently verified agent work.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
### Invariants
|
|
19
19
|
|
|
20
20
|
- Never run two tickmarkr runs in the same repository concurrently.
|
|
21
|
-
- Never
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
- Treat missing or unparseable results as failures.
|
|
21
|
+
- Never let tickmarkr merge work to main; new runs consolidate on \`tickmarkr/<runId>\`.
|
|
22
|
+
- Do not edit compiled graphs to force outcomes; fix source specs and recompile.
|
|
23
|
+
- Gates verify commits, diffs, acceptance criteria, and reviews independently — never trust a worker's completion claim.
|
|
24
|
+
- Treat missing or unparseable machine results and verdicts as failures.
|
|
25
|
+
|
|
26
|
+
### Commands
|
|
27
|
+
|
|
28
|
+
- \`tickmarkr compile <spec>\` — spec → RunGraph
|
|
29
|
+
- \`tickmarkr plan\` — routing table and human gates
|
|
30
|
+
- \`tickmarkr run\` — execute the graph
|
|
31
|
+
- \`tickmarkr status <runId>\` — run progress
|
|
32
|
+
- \`tickmarkr resume <runId>\` — continue a paused or failed run
|
|
33
|
+
- \`tickmarkr approve <runId> <taskId>\` — release a human gate
|
|
34
|
+
- \`tickmarkr report <runId> --md\` — execution record beside the spec
|
|
35
|
+
|
|
36
|
+
Loop: compile → plan → run → report. Watch the journal for run-end rather than polling workers.
|
|
37
|
+
|
|
38
|
+
### Role check (multi-agent environments)
|
|
39
|
+
|
|
40
|
+
- **Orchestrator:** run the loop in your session; do not start a second run.
|
|
41
|
+
- **Supervisor with a live orchestrator:** relay the mission via verified handoff (below), then supervise — do not duplicate the loop.
|
|
42
|
+
- **Primary session without an orchestrator:** spawn one child orchestration session, give it the mission and these rules, then supervise.
|
|
43
|
+
|
|
44
|
+
Outside multi-agent environments, run the loop directly.
|
|
45
|
+
|
|
46
|
+
### Version preflight
|
|
47
|
+
|
|
48
|
+
Before \`tickmarkr compile\` or \`tickmarkr run\`: run \`tickmarkr version\`, read \`package.json\` version, and if the binary is older on major.minor, stop and tell the operator to update. Never proceed on hope — stale binaries silently skip daemon gates.
|
|
49
|
+
|
|
50
|
+
### Tip-verify-before-green
|
|
51
|
+
|
|
52
|
+
A run is green only when the run-end event exists in the journal AND tip verify is not "failed". Never report green to the operator, tab titles, or records until both hold.
|
|
53
|
+
|
|
54
|
+
### Verified handoffs
|
|
55
|
+
|
|
56
|
+
When relaying missions between agents, never use bare send-text (\`herdr agent send\` / pane send-text) — it omits Enter. Use \`herdr pane run <pane> "<message>"\` or \`herdr notification show "<message>"\`. Confirm delivery by reading the target pane afterward; never report "relayed" without read-back.
|
|
25
57
|
${DOCS_END}
|
|
26
58
|
`;
|
|
27
|
-
const
|
|
59
|
+
const skillsRoot = (cwd) => existsSync(join(cwd, ".claude", "skills")) ? join(cwd, ".claude", "skills") : join(cwd, ".agents", "skills");
|
|
60
|
+
const skillPath = (cwd, skill) => join(skillsRoot(cwd), skill, "SKILL.md");
|
|
28
61
|
const skillsInstalled = (cwd) => AGENT_SKILLS.every((s) => existsSync(skillPath(cwd, s)));
|
|
29
62
|
const wizardDriverDefault = () => process.env.HERDR_ENV === "1" ? "herdr" : "auto";
|
|
30
63
|
async function installAgentFiles(cwd, force, docs, notes) {
|
|
@@ -44,7 +77,7 @@ async function installAgentFiles(cwd, force, docs, notes) {
|
|
|
44
77
|
notes.push(`skipped existing ${dest}; pass --force to overwrite it`);
|
|
45
78
|
continue;
|
|
46
79
|
}
|
|
47
|
-
mkdirSync(join(cwd,
|
|
80
|
+
mkdirSync(join(skillsRoot(cwd), skill), { recursive: true });
|
|
48
81
|
writeFileSync(dest, readFileSync(new URL(`../../../skills/${skill}/SKILL.md`, import.meta.url)), exists ? undefined : { flag: "wx" });
|
|
49
82
|
notes.push(`${exists ? "overwrote" : "wrote"} ${dest}`);
|
|
50
83
|
}
|
|
@@ -95,8 +128,8 @@ async function runInitWizard(cwd) {
|
|
|
95
128
|
const llm = llmRaw === "pane" || llmRaw === "headless" ? llmRaw : llmDef;
|
|
96
129
|
let installSkills = false;
|
|
97
130
|
if (!skillsInstalled(cwd)) {
|
|
98
|
-
const skillsDef = existsSync(join(cwd, ".claude")) && !skillsInstalled(cwd);
|
|
99
|
-
installSkills = await askYesNo(rl, "Install agent skills (tickmarkr-loop/tickmarkr-auto)
|
|
131
|
+
const skillsDef = existsSync(join(cwd, ".claude", "skills")) && !skillsInstalled(cwd);
|
|
132
|
+
installSkills = await askYesNo(rl, "Install agent skills (tickmarkr-loop/tickmarkr-auto)?", skillsDef);
|
|
100
133
|
}
|
|
101
134
|
return { overlay: { driver, concurrency, visibility: { llm } }, installSkills };
|
|
102
135
|
}
|
|
@@ -5,7 +5,15 @@ import { loadConfig } from "../../config/config.js";
|
|
|
5
5
|
import { loadGraph } from "../../graph/graph.js";
|
|
6
6
|
import { excludedChannels, exclusionLine } from "../../route/preference.js";
|
|
7
7
|
import { route, RoutingError } from "../../route/router.js";
|
|
8
|
+
import { modelId } from "../../gates/review.js";
|
|
8
9
|
import { loadRoutingProfile } from "../../run/journal.js";
|
|
10
|
+
const fleetCanCrossVendorReview = (channels) => {
|
|
11
|
+
for (let i = 0; i < channels.length; i++)
|
|
12
|
+
for (let j = 0; j < channels.length; j++)
|
|
13
|
+
if (i !== j && channels[i].vendor !== channels[j].vendor && modelId(channels[i].model) !== modelId(channels[j].model))
|
|
14
|
+
return true;
|
|
15
|
+
return false;
|
|
16
|
+
};
|
|
9
17
|
export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters()) {
|
|
10
18
|
// ponytail: hardcoded 24h TTL — promote to config when an operator asks. mtime is the signal because
|
|
11
19
|
// doctor.json has no probe timestamp and a schema field would break the existing-files compat invariant.
|
|
@@ -59,6 +67,9 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
|
|
|
59
67
|
lints.push(`${role}: ${sel.adapter}:${sel.model} not installed — that gate/consult will fail closed`);
|
|
60
68
|
}
|
|
61
69
|
lints.push(...modelLints(cfg, health, adapters, { tty: ttyVisual() })); // health may be pre-v1.5/probeAll-fallback — no-detection branch covers both
|
|
70
|
+
if (cfg.review.required && channels.length && !fleetCanCrossVendorReview(channels)) {
|
|
71
|
+
lints.push("review: no cross-vendor reviewer pair in fleet — set review.required: false to waive");
|
|
72
|
+
}
|
|
62
73
|
let cost = 0;
|
|
63
74
|
for (const t of g.tasks) {
|
|
64
75
|
try {
|
|
@@ -69,7 +80,7 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
|
|
|
69
80
|
}
|
|
70
81
|
const est = r.assignment.channel === "sub" ? 0 : cfg.pricing[r.assignment.tier];
|
|
71
82
|
cost += est;
|
|
72
|
-
lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} c${String(t.complexity).padEnd(3)}→ ${r.assignment.adapter}:${r.assignment.model} [${r.assignment.channel}/${r.assignment.tier}]${t.humanGate ? " (human gate)" : ""}${est ? ` ~$${est.toFixed(2)}` : ""} — ${r.provenance}`);
|
|
83
|
+
lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} c${String(t.complexity).padEnd(3)}→ ${r.assignment.adapter}:${r.assignment.model} [${r.assignment.channel}/${r.assignment.tier}]${t.timeoutMinutes !== undefined ? ` (timeout ${t.timeoutMinutes}m)` : ""}${t.humanGate ? " (human gate)" : ""}${est ? ` ~$${est.toFixed(2)}` : ""} — ${r.provenance}`);
|
|
73
84
|
if (r.deviation) {
|
|
74
85
|
deviations++;
|
|
75
86
|
const d = r.deviation;
|
|
@@ -2,10 +2,13 @@ import { loadConfig } from "../../config/config.js";
|
|
|
2
2
|
import { pickDriver } from "../../drivers/index.js";
|
|
3
3
|
import { formatSummary, runDaemon } from "../../run/daemon.js";
|
|
4
4
|
import { formatJournalNarration } from "../../run/journal.js";
|
|
5
|
+
const summaryGreen = (s) => s.failed.length === 0 && s.human.length === 0 && s.blocked.length === 0 && s.pending.length === 0
|
|
6
|
+
&& s.tipVerify !== "failed";
|
|
5
7
|
export async function resume(argv, cwd = process.cwd()) {
|
|
6
8
|
const runId = argv[0];
|
|
7
9
|
if (!runId)
|
|
8
10
|
throw new Error("usage: tickmarkr resume <run-id>");
|
|
9
11
|
const s = await runDaemon(cwd, { runId, resume: true, driver: pickDriver(loadConfig(cwd)), narrate: (event) => console.log(formatJournalNarration(event)) });
|
|
10
|
-
|
|
12
|
+
const out = `resumed ${s.runId} — ${formatSummary(s)}`;
|
|
13
|
+
return { out, code: summaryGreen(s) ? 0 : 2 };
|
|
11
14
|
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -6,6 +6,8 @@ import { loadGraph } from "../../graph/graph.js";
|
|
|
6
6
|
import { formatSummary, runDaemon } from "../../run/daemon.js";
|
|
7
7
|
import { route } from "../../route/router.js";
|
|
8
8
|
import { formatJournalNarration, loadRoutingProfile } from "../../run/journal.js";
|
|
9
|
+
const summaryGreen = (s) => s.failed.length === 0 && s.human.length === 0 && s.blocked.length === 0 && s.pending.length === 0
|
|
10
|
+
&& s.tipVerify !== "failed";
|
|
9
11
|
export async function run(argv, cwd = process.cwd()) {
|
|
10
12
|
const { values } = parseArgs({
|
|
11
13
|
args: argv,
|
|
@@ -15,6 +17,11 @@ export async function run(argv, cwd = process.cwd()) {
|
|
|
15
17
|
"route-strict": { type: "boolean" },
|
|
16
18
|
},
|
|
17
19
|
});
|
|
20
|
+
if (values.concurrency !== undefined) {
|
|
21
|
+
const n = Number(values.concurrency);
|
|
22
|
+
if (!Number.isInteger(n) || n <= 0)
|
|
23
|
+
throw new Error(`--concurrency must be a positive integer (got ${values.concurrency})`);
|
|
24
|
+
}
|
|
18
25
|
const cfg = loadConfig(cwd);
|
|
19
26
|
if (values["route-strict"]) {
|
|
20
27
|
const adapters = allAdapters();
|
|
@@ -31,5 +38,6 @@ export async function run(argv, cwd = process.cwd()) {
|
|
|
31
38
|
driver: pickDriver(cfg, values.driver),
|
|
32
39
|
narrate: (event) => console.log(formatJournalNarration(event)),
|
|
33
40
|
});
|
|
34
|
-
|
|
41
|
+
const out = `run ${s.runId} finished — ${formatSummary(s)} (merge to main is a human decision)`;
|
|
42
|
+
return { out, code: summaryGreen(s) ? 0 : 2 };
|
|
35
43
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function version(_argv?: string[]): Promise<string>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "../../../package.json");
|
|
5
|
+
export async function version(_argv = []) {
|
|
6
|
+
const { version: v } = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
7
|
+
return v;
|
|
8
|
+
}
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
export type
|
|
2
|
+
export type CommandResult = string | {
|
|
3
|
+
out: string;
|
|
4
|
+
code: number;
|
|
5
|
+
};
|
|
6
|
+
export type CommandMap = Record<string, (argv: string[]) => Promise<CommandResult>>;
|
|
3
7
|
export declare const COMMANDS: CommandMap;
|
|
4
8
|
export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n run execute the graph (--concurrency N --driver herdr|subprocess --route-strict)\n status live run state\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n approve <id> <task> approve a parked human gate (--by <name> --reason <text>); takes effect on resume";
|
|
5
9
|
export declare function dispatch(cmd: string | undefined, argv: string[], commands?: CommandMap): Promise<{
|
package/dist/cli/index.js
CHANGED
|
@@ -13,9 +13,12 @@ import { run } from "./commands/run.js";
|
|
|
13
13
|
import { scope } from "./commands/scope.js";
|
|
14
14
|
import { status } from "./commands/status.js";
|
|
15
15
|
import { unlock } from "./commands/unlock.js";
|
|
16
|
+
import { version } from "./commands/version.js";
|
|
17
|
+
const normalize = (r) => typeof r === "string" ? { out: r, code: 0 } : r;
|
|
16
18
|
export const COMMANDS = {
|
|
17
|
-
init, doctor, compile, scope, plan, run, status, resume, report, profile, unlock, approve,
|
|
19
|
+
init, doctor, compile, scope, plan, run, status, resume, report, profile, unlock, approve, version,
|
|
18
20
|
};
|
|
21
|
+
const VERSION_FLAGS = new Set(["version", "--version", "-v"]);
|
|
19
22
|
const HELP_CMDS = new Set(["help", "-h", "--help"]);
|
|
20
23
|
import { BANNER } from "../brand.js";
|
|
21
24
|
export const USAGE = `tickmarkr — spec-driven orchestration harness for AI coding agents
|
|
@@ -36,6 +39,8 @@ usage: tickmarkr <command>
|
|
|
36
39
|
// unknown/missing cmd → USAGE (exit 1 if a cmd was typed, 0 for bare `tickmarkr`); a handler throw becomes
|
|
37
40
|
// a one-line `tickmarkr <cmd>: <message>` (never a raw stack) at exit 1.
|
|
38
41
|
export async function dispatch(cmd, argv, commands = COMMANDS) {
|
|
42
|
+
if (cmd && VERSION_FLAGS.has(cmd))
|
|
43
|
+
return { out: await version(argv), code: 0 };
|
|
39
44
|
const usage = process.stdout.isTTY ? BANNER + USAGE : USAGE;
|
|
40
45
|
if (!cmd || HELP_CMDS.has(cmd))
|
|
41
46
|
return { out: usage, code: 0 };
|
|
@@ -43,7 +48,7 @@ export async function dispatch(cmd, argv, commands = COMMANDS) {
|
|
|
43
48
|
if (!fn)
|
|
44
49
|
return { out: usage, code: 1 };
|
|
45
50
|
try {
|
|
46
|
-
return
|
|
51
|
+
return normalize(await fn(argv));
|
|
47
52
|
}
|
|
48
53
|
catch (err) {
|
|
49
54
|
return { out: `tickmarkr ${cmd}: ${err.message}`, code: 1 };
|
|
@@ -62,7 +67,7 @@ if (argv1Real && import.meta.url === pathToFileURL(argv1Real).href) {
|
|
|
62
67
|
const [cmd, ...argv] = process.argv.slice(2);
|
|
63
68
|
dispatch(cmd, argv).then(({ out, code }) => {
|
|
64
69
|
// byte-identical streams: usage + success → stdout; a handler throw → stderr (original behavior)
|
|
65
|
-
(out.endsWith(USAGE)
|
|
70
|
+
(code === 1 && !out.endsWith(USAGE) ? console.error : console.log)(out);
|
|
66
71
|
if (code !== 0)
|
|
67
72
|
process.exit(code);
|
|
68
73
|
});
|
package/dist/compile/native.js
CHANGED
|
@@ -10,7 +10,7 @@ const NESTED_RE = /^\s+- (.+)$/;
|
|
|
10
10
|
// v1.19: a typed acceptance oracle line — "command: ...", "test: ...", or "judge: ...". Anything
|
|
11
11
|
// without one of these prefixes is a plain-string judge criterion (compat path, emits a warning).
|
|
12
12
|
const ORACLE_RE = new RegExp(`^(${ORACLES.join("|")}):\\s*(.*)$`);
|
|
13
|
-
const FIELDS = new Set(["goal", "shape", "deps", "files", "context", "complexity", "humangate", "pin", "floor", "gates", "acceptance"]);
|
|
13
|
+
const FIELDS = new Set(["goal", "shape", "deps", "files", "context", "complexity", "humangate", "pin", "floor", "gates", "acceptance", "timeout"]);
|
|
14
14
|
function invalid(task, field, detail) {
|
|
15
15
|
throw new CompileError(`Task ${task} field "${field}" ${detail}`);
|
|
16
16
|
}
|
|
@@ -110,6 +110,10 @@ export function compileNative(file) {
|
|
|
110
110
|
const badGate = draft.gates.find((gate) => !GATE_NAMES.includes(gate));
|
|
111
111
|
if (badGate)
|
|
112
112
|
invalid(draft.id, "gates", `contains invalid gate "${badGate}"`);
|
|
113
|
+
const timeoutMinutes = fields.timeout === undefined ? undefined : Number(fields.timeout);
|
|
114
|
+
if (fields.timeout !== undefined && (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0)) {
|
|
115
|
+
invalid(draft.id, "timeout", "must be a positive number");
|
|
116
|
+
}
|
|
113
117
|
const routingHints = pin || fields.floor ? {
|
|
114
118
|
...(pin ? { pin: { via: pin[0], model: pin[1] } } : {}),
|
|
115
119
|
...(fields.floor ? { floor: fields.floor } : {}),
|
|
@@ -125,6 +129,7 @@ export function compileNative(file) {
|
|
|
125
129
|
context: csv(fields.context),
|
|
126
130
|
acceptance: draft.acceptance,
|
|
127
131
|
...(fields.humangate === "true" ? { humanGate: true } : {}),
|
|
132
|
+
...(timeoutMinutes !== undefined ? { timeoutMinutes } : {}),
|
|
128
133
|
...(routingHints ? { routingHints } : {}),
|
|
129
134
|
...(draft.hasGates ? { gates: draft.gates } : {}),
|
|
130
135
|
};
|
|
@@ -166,7 +171,8 @@ acceptance is required on every task (a nested list of observable outcomes).
|
|
|
166
171
|
humanGate: true | false — pauses for a human review before merging this task
|
|
167
172
|
pin: "via model" — pin an exact channel, e.g. "claude-code opus"
|
|
168
173
|
floor: cheap | mid | frontier — minimum capability tier for routing
|
|
169
|
-
gates: nested list
|
|
174
|
+
gates: nested list; build | test | lint | evidence | scope are mandatory;
|
|
175
|
+
acceptance | review may be omitted
|
|
170
176
|
acceptance: nested list (REQUIRED, non-empty). Each item is either a typed oracle or plain text:
|
|
171
177
|
- command: <shell> (oracle: command — exit code)
|
|
172
178
|
- test: <name> (oracle: test — named test)
|
package/dist/config/config.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ declare const TierEnum: z.ZodEnum<{
|
|
|
6
6
|
}>;
|
|
7
7
|
export type Tier = z.infer<typeof TierEnum>;
|
|
8
8
|
export declare const TIER_RANK: Record<Tier, number>;
|
|
9
|
+
export declare const DEFAULT_DIFF_CAP = 60000;
|
|
9
10
|
export declare const MapEntrySchema: z.ZodObject<{
|
|
10
11
|
pin: z.ZodOptional<z.ZodObject<{
|
|
11
12
|
via: z.ZodString;
|
|
@@ -84,6 +85,7 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
|
|
|
84
85
|
halfLifeRuns: z.ZodOptional<z.ZodNumber>;
|
|
85
86
|
availWeight: z.ZodOptional<z.ZodNumber>;
|
|
86
87
|
}, z.core.$strip>>;
|
|
88
|
+
allowUnverifiedModels: z.ZodBoolean;
|
|
87
89
|
allow: z.ZodOptional<z.ZodObject<{
|
|
88
90
|
adapters: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
89
91
|
models: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -123,6 +125,7 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
|
|
|
123
125
|
build: z.ZodOptional<z.ZodString>;
|
|
124
126
|
test: z.ZodOptional<z.ZodString>;
|
|
125
127
|
lint: z.ZodOptional<z.ZodString>;
|
|
128
|
+
diffCap: z.ZodOptional<z.ZodNumber>;
|
|
126
129
|
byShape: z.ZodOptional<z.ZodOptional<z.ZodRecord<z.ZodEnum<{
|
|
127
130
|
plan: "plan";
|
|
128
131
|
spec: "spec";
|
package/dist/config/config.js
CHANGED
|
@@ -9,6 +9,7 @@ const TierEnum = z.enum(TIERS, {
|
|
|
9
9
|
error: (iss) => `Invalid option: expected one of "cheap"|"mid"|"frontier" (got ${JSON.stringify(iss.input)})`,
|
|
10
10
|
});
|
|
11
11
|
export const TIER_RANK = { cheap: 0, mid: 1, frontier: 2 };
|
|
12
|
+
export const DEFAULT_DIFF_CAP = 60_000;
|
|
12
13
|
export const MapEntrySchema = z.object({
|
|
13
14
|
pin: z.object({ via: z.string(), model: z.string() }).optional(),
|
|
14
15
|
tier: TierEnum.optional(),
|
|
@@ -86,6 +87,8 @@ export const TickmarkrConfigSchema = z.object({
|
|
|
86
87
|
halfLifeRuns: z.number().positive().optional(),
|
|
87
88
|
availWeight: z.number().nonnegative().optional(),
|
|
88
89
|
}).optional(),
|
|
90
|
+
// Pre-v1.21 doctor.json lacks per-model verdicts. Keep legacy unknown-is-routable behavior opt-in.
|
|
91
|
+
allowUnverifiedModels: z.boolean(),
|
|
89
92
|
allow: PrefBlockSchema.optional(),
|
|
90
93
|
deny: PrefBlockSchema.optional(),
|
|
91
94
|
}),
|
|
@@ -105,6 +108,7 @@ export const TickmarkrConfigSchema = z.object({
|
|
|
105
108
|
build: z.string(),
|
|
106
109
|
test: z.string(),
|
|
107
110
|
lint: z.string(),
|
|
111
|
+
diffCap: z.number().int().positive(),
|
|
108
112
|
byShape: z.partialRecord(z.enum(SHAPES), ShapeGateParticipationSchema).optional(),
|
|
109
113
|
}).partial(),
|
|
110
114
|
scope: z.object({
|
|
@@ -160,6 +164,7 @@ export const DEFAULT_CONFIG = {
|
|
|
160
164
|
// a workspace that has accumulated ≥MIN_SAMPLES warm telemetry per cell. Preview any workspace's effect
|
|
161
165
|
// first with `tickmarkr plan` / `tickmarkr report`; flip to "off" to pin exact static routing (the kill switch stands).
|
|
162
166
|
learned: "on",
|
|
167
|
+
allowUnverifiedModels: false,
|
|
163
168
|
},
|
|
164
169
|
// Seed table (spec §13). New models = edit this (or your config.yaml), never code.
|
|
165
170
|
tiers: {
|
|
@@ -230,7 +235,7 @@ export const DEFAULT_CONFIG = {
|
|
|
230
235
|
},
|
|
231
236
|
},
|
|
232
237
|
pricing: { cheap: 0.1, mid: 0.5, frontier: 2.5 },
|
|
233
|
-
gates: {},
|
|
238
|
+
gates: { diffCap: DEFAULT_DIFF_CAP },
|
|
234
239
|
judge: { adapter: "claude-code", model: "fable" },
|
|
235
240
|
review: { complexityThreshold: 7, required: true },
|
|
236
241
|
consult: { adapter: "claude-code", model: "fable", stallMinutes: 15 },
|
|
@@ -316,6 +321,7 @@ export function configTemplate(overlay) {
|
|
|
316
321
|
# scope:
|
|
317
322
|
# allowDeviations: [] # globs an operator permits out-of-scope edits into, e.g. ["package-lock.json"]
|
|
318
323
|
# gates: # override auto-detected commands; per-shape participation may only skip LLM gates
|
|
324
|
+
# diffCap: 60000 # fail closed before an LLM gate; split the task or raise this positive integer
|
|
319
325
|
# test: npm test
|
|
320
326
|
# byShape:
|
|
321
327
|
# docs: { acceptance: false, review: false } # baseline, evidence, and scope are mandatory
|
|
@@ -52,6 +52,7 @@ export class SubprocessDriver {
|
|
|
52
52
|
cwd: slot.cwd,
|
|
53
53
|
stdio: ["ignore", "pipe", "pipe"],
|
|
54
54
|
env: sealHerdrEnv(process.env),
|
|
55
|
+
detached: true,
|
|
55
56
|
});
|
|
56
57
|
s.proc = p;
|
|
57
58
|
p.stdout.on("data", (d) => (s.buf = (s.buf + d).slice(-MAX_BUF)));
|
|
@@ -96,8 +97,14 @@ export class SubprocessDriver {
|
|
|
96
97
|
}
|
|
97
98
|
async close(slot) {
|
|
98
99
|
const s = this.slots.get(slot.id);
|
|
99
|
-
if (s?.proc && !s.exited)
|
|
100
|
-
|
|
100
|
+
if (s?.proc && !s.exited) {
|
|
101
|
+
try {
|
|
102
|
+
process.kill(-s.proc.pid, "SIGKILL");
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
s.proc.kill("SIGKILL");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
101
108
|
this.slots.delete(slot.id);
|
|
102
109
|
}
|
|
103
110
|
worktree(repo, branch, baseRef) {
|
package/dist/gates/acceptance.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { channelKey, shq } from "../adapters/types.js";
|
|
2
|
+
import { DEFAULT_DIFF_CAP } from "../config/config.js";
|
|
2
3
|
import { renderAcceptanceItem } from "../graph/schema.js";
|
|
3
4
|
import { sh, shOk } from "../run/git.js";
|
|
4
5
|
import { extractJson, runLlm } from "./llm.js";
|
|
5
|
-
const DIFF_CAP = 60_000; // judge sees diff + criteria only, and not unboundedly (spec §12)
|
|
6
6
|
const isCommand = (a) => typeof a === "object" && a.oracle === "command";
|
|
7
7
|
const isTest = (a) => typeof a === "object" && a.oracle === "test";
|
|
8
8
|
const isJudge = (a) => typeof a === "string" || (typeof a === "object" && a.oracle === "judge");
|
|
@@ -57,7 +57,12 @@ export async function acceptanceGate(task, worktree, baseRef, judge, via, opts =
|
|
|
57
57
|
const warn = onlyJudge
|
|
58
58
|
? "WARNING: only judge oracles — no deterministic command/test oracle guards this task (deterministic preferred, spec §2).\n"
|
|
59
59
|
: "";
|
|
60
|
-
const diff =
|
|
60
|
+
const diff = await shOk(`git diff '${baseRef}..HEAD'`, worktree);
|
|
61
|
+
const diffCap = opts.diffCap ?? DEFAULT_DIFF_CAP;
|
|
62
|
+
if (diff.length > diffCap) {
|
|
63
|
+
return { gate: "acceptance", pass: false,
|
|
64
|
+
details: warn + detBlock + `diff exceeds verifiable cap (${diff.length} > ${diffCap}) — split the task, or raise gates.diffCap` };
|
|
65
|
+
}
|
|
61
66
|
const prompt = `TICKMARKR-JUDGE
|
|
62
67
|
You are a strict acceptance judge. Decide whether the diff satisfies EVERY acceptance criterion.
|
|
63
68
|
Judge only what the diff proves — plausible-but-wrong must fail. Do not award partial credit.
|
|
@@ -87,6 +92,21 @@ Respond with ONLY this JSON (no prose before or after):
|
|
|
87
92
|
details: warn + detBlock + "judge output unparseable — failing closed",
|
|
88
93
|
meta: { unparseable: true, judge: channelKey({ adapter: judge.adapter.id, model: judge.model }) } };
|
|
89
94
|
}
|
|
90
|
-
const
|
|
91
|
-
|
|
95
|
+
const inconsistencies = [];
|
|
96
|
+
if (v.criteria.length !== judgeItems.length) {
|
|
97
|
+
inconsistencies.push(`judge verdict inconsistent: criteria count mismatch — expected ${judgeItems.length}, received ${v.criteria.length}`);
|
|
98
|
+
}
|
|
99
|
+
v.criteria.forEach((c, i) => {
|
|
100
|
+
if (!c || typeof c !== "object" || c.met !== true) {
|
|
101
|
+
inconsistencies.push(`judge verdict inconsistent: criteria[${i}].met must be true`);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
const pass = v.pass === true && inconsistencies.length === 0;
|
|
105
|
+
const lines = v.criteria.map((c, i) => c && typeof c === "object"
|
|
106
|
+
? `${c.met ? "✓" : "✗"} ${c.criterion}: ${c.reason}`
|
|
107
|
+
: `✗ criteria[${i}]: invalid criterion`);
|
|
108
|
+
if (!v.pass)
|
|
109
|
+
lines.push("judge verdict pass=false");
|
|
110
|
+
lines.push(...inconsistencies);
|
|
111
|
+
return { gate: "acceptance", pass, details: warn + detBlock + (lines.join("\n") || "judge passed") };
|
|
92
112
|
}
|
package/dist/gates/review.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { channelKey } from "../adapters/types.js";
|
|
2
|
-
import { TIER_RANK } from "../config/config.js";
|
|
2
|
+
import { DEFAULT_DIFF_CAP, TIER_RANK } from "../config/config.js";
|
|
3
3
|
import { renderAcceptanceItem } from "../graph/schema.js";
|
|
4
4
|
import { getAdapter } from "../adapters/registry.js";
|
|
5
5
|
import { shOk } from "../run/git.js";
|
|
@@ -27,7 +27,6 @@ export function pickReviewer(author, channels, exclude = []) {
|
|
|
27
27
|
.filter((c) => c.vendor !== authorChannel.vendor && modelId(c.model) !== modelId(author.model) && !exclude.includes(channelKey(c)))
|
|
28
28
|
.sort((a, b) => TIER_RANK[b.tier] - TIER_RANK[a.tier] || marginalCostRank(a) - marginalCostRank(b))[0] ?? null);
|
|
29
29
|
}
|
|
30
|
-
const DIFF_CAP = 60_000;
|
|
31
30
|
export async function reviewGate(task, worktree, baseRef, author, channels, adapters, cfg, via, excludeReviewers) {
|
|
32
31
|
if (task.complexity < cfg.review.complexityThreshold) {
|
|
33
32
|
return { gate: "review", pass: true, details: `skipped — complexity ${task.complexity} < threshold ${cfg.review.complexityThreshold}`, meta: { skipped: true } };
|
|
@@ -38,7 +37,12 @@ export async function reviewGate(task, worktree, baseRef, author, channels, adap
|
|
|
38
37
|
? { gate: "review", pass: false, details: "no cross-vendor reviewer available (diversity rule); set review.required:false to waive" }
|
|
39
38
|
: { gate: "review", pass: true, details: "WARNING: no cross-vendor reviewer available — review waived by config" };
|
|
40
39
|
}
|
|
41
|
-
const diff =
|
|
40
|
+
const diff = await shOk(`git diff '${baseRef}..HEAD'`, worktree);
|
|
41
|
+
const diffCap = cfg.gates.diffCap ?? DEFAULT_DIFF_CAP;
|
|
42
|
+
if (diff.length > diffCap) {
|
|
43
|
+
return { gate: "review", pass: false,
|
|
44
|
+
details: `diff exceeds verifiable cap (${diff.length} > ${diffCap}) — split the task, or raise gates.diffCap` };
|
|
45
|
+
}
|
|
42
46
|
const prompt = `TICKMARKR-REVIEW
|
|
43
47
|
You are a skeptical cross-vendor code reviewer. Another agent (vendor: ${author.adapter}) authored this diff.
|
|
44
48
|
Look for correctness bugs, security issues, and acceptance-criteria gaps. Approve only if you would merge it.
|
|
@@ -56,14 +60,14 @@ Respond with ONLY this JSON:
|
|
|
56
60
|
{"approve": true|false, "issues": ["..."]}
|
|
57
61
|
`;
|
|
58
62
|
const raw = await runLlm(getAdapter(reviewer.adapter, adapters), reviewer.model, prompt, worktree, via ? { driver: via.driver, keep: via.keep, onSlot: via.onSlot, name: via.nameFor("review", reviewer.adapter), label: via.labelFor("review") } : undefined,
|
|
59
|
-
// frontier reviewers routinely need >5min on a
|
|
63
|
+
// frontier reviewers routinely need >5min on a configured-cap-sized diff, and `claude -p` buffers all
|
|
60
64
|
// output until completion — runLlm's 300s default killed reviews mid-flight, returning empty
|
|
61
65
|
// stdout that read as "unparseable" and escalated to re-implementation of green code
|
|
62
66
|
// (run-20260709-104447 P87-09). ponytail: literal 15min; make it cfg.review.timeoutMs if a
|
|
63
67
|
// second knob-turner appears.
|
|
64
68
|
900_000);
|
|
65
69
|
const v = extractJson(raw);
|
|
66
|
-
if (!v || typeof v.approve !== "boolean") {
|
|
70
|
+
if (!v || typeof v.approve !== "boolean" || !Array.isArray(v.issues)) {
|
|
67
71
|
return {
|
|
68
72
|
gate: "review",
|
|
69
73
|
pass: false,
|
|
@@ -71,10 +75,25 @@ Respond with ONLY this JSON:
|
|
|
71
75
|
meta: { reviewer: channelKey(reviewer) },
|
|
72
76
|
};
|
|
73
77
|
}
|
|
78
|
+
const issues = v.issues;
|
|
79
|
+
const inconsistencies = [];
|
|
80
|
+
issues.forEach((issue, i) => {
|
|
81
|
+
if (typeof issue !== "string")
|
|
82
|
+
inconsistencies.push(`review verdict inconsistent: issues[${i}] must be a string`);
|
|
83
|
+
});
|
|
84
|
+
if (v.approve && issues.length) {
|
|
85
|
+
inconsistencies.push("review verdict inconsistent: approve=true requires issues to be empty");
|
|
86
|
+
}
|
|
87
|
+
else if (!v.approve && !issues.length) {
|
|
88
|
+
inconsistencies.push("review verdict inconsistent: approve=false requires at least one issue");
|
|
89
|
+
}
|
|
90
|
+
const pass = v.approve === true && inconsistencies.length === 0;
|
|
91
|
+
const lines = issues.map((issue) => `- ${typeof issue === "string" ? issue : JSON.stringify(issue)}`);
|
|
92
|
+
lines.push(...inconsistencies);
|
|
74
93
|
return {
|
|
75
94
|
gate: "review",
|
|
76
|
-
pass
|
|
77
|
-
details: `reviewer ${reviewer.adapter}:${reviewer.model} (${reviewer.vendor}): ${v.approve ? "
|
|
95
|
+
pass,
|
|
96
|
+
details: `reviewer ${reviewer.adapter}:${reviewer.model} (${reviewer.vendor}): ${pass ? "approved" : v.approve ? "approval rejected" : "requested changes"}${lines.length ? "\n" + lines.join("\n") : ""}`,
|
|
78
97
|
meta: { reviewer: channelKey(reviewer) },
|
|
79
98
|
};
|
|
80
99
|
}
|
package/dist/gates/run-gates.js
CHANGED
|
@@ -63,7 +63,7 @@ export async function runGates(task, ctx) {
|
|
|
63
63
|
: undefined;
|
|
64
64
|
// v1.19 (T2): testCmd threads the detected test runner to the gate so named-test oracles run
|
|
65
65
|
// deterministically (filtered via -t) before any LLM judge dispatch.
|
|
66
|
-
let a = await acceptanceGate(task, ctx.worktree, ctx.baseRef, { adapter: judgeAdapter, model: ctx.cfg.judge.model }, jvia, { testCmd: ctx.commands.test });
|
|
66
|
+
let a = await acceptanceGate(task, ctx.worktree, ctx.baseRef, { adapter: judgeAdapter, model: ctx.cfg.judge.model }, jvia, { testCmd: ctx.commands.test, diffCap: ctx.cfg.gates.diffCap });
|
|
67
67
|
// GATE-09: an unparseable judge verdict retries the JUDGE exactly once on a failover channel — never
|
|
68
68
|
// the worker (run-20260711-185020 P43-03 L70-72 billed a judge flake as a worker attempt). The flaked
|
|
69
69
|
// first verdict NEVER enters results (no false gate-result journal event, no operator notify, no stale
|
|
@@ -89,7 +89,7 @@ export async function runGates(task, ctx) {
|
|
|
89
89
|
? { driver: ctx.via.driver, keep: ctx.via.keep, onSlot: ctx.via.onSlot, name: ctx.via.nameFor("judge", retryAdapter.id) + "-r1", label: ctx.via.labelFor("judge") }
|
|
90
90
|
: undefined;
|
|
91
91
|
// the retry IS a second acceptanceGate call: one code path, one parser, zero new parse leniency.
|
|
92
|
-
a = await acceptanceGate(task, ctx.worktree, ctx.baseRef, { adapter: retryAdapter, model: retry.model }, retryJvia, { testCmd: ctx.commands.test });
|
|
92
|
+
a = await acceptanceGate(task, ctx.worktree, ctx.baseRef, { adapter: retryAdapter, model: retry.model }, retryJvia, { testCmd: ctx.commands.test, diffCap: ctx.cfg.gates.diffCap });
|
|
93
93
|
a = { ...a, meta: { ...a.meta, judgeRetry: { flaked: flakedKey, retried: channelKey({ adapter: retry.adapter, model: retry.model }) } } };
|
|
94
94
|
}
|
|
95
95
|
await record(a);
|
package/dist/graph/schema.d.ts
CHANGED
|
@@ -77,6 +77,7 @@ export declare const TaskSchema: z.ZodObject<{
|
|
|
77
77
|
escalate: z.ZodOptional<z.ZodBoolean>;
|
|
78
78
|
}, z.core.$strip>>;
|
|
79
79
|
humanGate: z.ZodDefault<z.ZodBoolean>;
|
|
80
|
+
timeoutMinutes: z.ZodOptional<z.ZodNumber>;
|
|
80
81
|
status: z.ZodDefault<z.ZodEnum<{
|
|
81
82
|
pending: "pending";
|
|
82
83
|
running: "running";
|
|
@@ -161,6 +162,7 @@ export declare const RunGraphSchema: z.ZodObject<{
|
|
|
161
162
|
escalate: z.ZodOptional<z.ZodBoolean>;
|
|
162
163
|
}, z.core.$strip>>;
|
|
163
164
|
humanGate: z.ZodDefault<z.ZodBoolean>;
|
|
165
|
+
timeoutMinutes: z.ZodOptional<z.ZodNumber>;
|
|
164
166
|
status: z.ZodDefault<z.ZodEnum<{
|
|
165
167
|
pending: "pending";
|
|
166
168
|
running: "running";
|
package/dist/graph/schema.js
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
export const SHAPES = ["plan", "spec", "implement", "tests", "docs", "migration", "ui", "refactor", "chore"];
|
|
3
3
|
export const STATUSES = ["pending", "running", "gated", "failed", "done", "human"];
|
|
4
4
|
export const GATE_NAMES = ["build", "test", "lint", "evidence", "scope", "acceptance", "review"];
|
|
5
|
+
const MANDATORY_GATES = ["build", "test", "lint", "evidence", "scope"];
|
|
5
6
|
export const TIERS = ["cheap", "mid", "frontier"];
|
|
6
7
|
// v1.19 acceptance oracles: command (exit code), test (named test), judge (LLM, free-text rubric).
|
|
7
8
|
// A plain string is the read-old/write-new compat form — semantically a judge oracle (spec §2).
|
|
@@ -51,7 +52,19 @@ export const TaskSchema = z.object({
|
|
|
51
52
|
reason: z.string().optional(),
|
|
52
53
|
}))
|
|
53
54
|
.optional(),
|
|
54
|
-
gates: z
|
|
55
|
+
gates: z
|
|
56
|
+
.array(z.enum(GATE_NAMES))
|
|
57
|
+
.default(["build", "test", "lint", "evidence", "scope", "acceptance", "review"])
|
|
58
|
+
.superRefine((gates, ctx) => {
|
|
59
|
+
for (const gate of MANDATORY_GATES) {
|
|
60
|
+
if (!gates.includes(gate)) {
|
|
61
|
+
ctx.addIssue({
|
|
62
|
+
code: "custom",
|
|
63
|
+
message: `${gate} is a mandatory fail-closed gate invariant and cannot be omitted from task gates`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}),
|
|
55
68
|
routingHints: z
|
|
56
69
|
.object({
|
|
57
70
|
pin: z.object({ via: z.string(), model: z.string() }).optional(),
|
|
@@ -61,6 +74,8 @@ export const TaskSchema = z.object({
|
|
|
61
74
|
})
|
|
62
75
|
.optional(),
|
|
63
76
|
humanGate: z.boolean().default(false),
|
|
77
|
+
// v1.39 OBS-37b: per-task worker window override; absent ⇒ config taskTimeoutMinutes.
|
|
78
|
+
timeoutMinutes: z.number().positive().optional(),
|
|
64
79
|
status: z.enum(STATUSES).default("pending"),
|
|
65
80
|
evidence: z
|
|
66
81
|
.object({
|
package/dist/run/consult.d.ts
CHANGED
|
@@ -5,8 +5,11 @@ import type { GateResult } from "../gates/types.js";
|
|
|
5
5
|
export interface ConsultVerdict {
|
|
6
6
|
action: "retry" | "reroute" | "decompose" | "human";
|
|
7
7
|
notes: string;
|
|
8
|
+
reason?: string;
|
|
9
|
+
guidance?: string;
|
|
8
10
|
excludeAdapter?: string;
|
|
9
11
|
}
|
|
12
|
+
export declare function renderRetryGuidance(v: ConsultVerdict): string;
|
|
10
13
|
export interface Dossier {
|
|
11
14
|
taskId: string;
|
|
12
15
|
trigger: string;
|
package/dist/run/consult.js
CHANGED
|
@@ -3,6 +3,21 @@ import { join } from "node:path";
|
|
|
3
3
|
import { getAdapter } from "../adapters/registry.js";
|
|
4
4
|
import { extractJson, gatePaneName } from "../gates/llm.js";
|
|
5
5
|
import { sh } from "./git.js";
|
|
6
|
+
const MAX_RETRY_GUIDANCE_LINES = 10;
|
|
7
|
+
function guidanceParts(text) {
|
|
8
|
+
return text.split(/\n+/).flatMap((line) => line.split(/(?<=[.!?])\s+/)).map((s) => s.trim()).filter(Boolean);
|
|
9
|
+
}
|
|
10
|
+
// OBS-37a: retry worker prompts get structured bullets only — never the consult's raw notes prose
|
|
11
|
+
// (herdr false-blocked when consult verdict text was echoed verbatim in a cursor worker prompt).
|
|
12
|
+
export function renderRetryGuidance(v) {
|
|
13
|
+
const lines = [`Action: ${v.action}`];
|
|
14
|
+
if (v.reason)
|
|
15
|
+
lines.push(`Reason: ${v.reason}`);
|
|
16
|
+
const body = v.guidance ?? (v.reason ? "" : v.notes);
|
|
17
|
+
for (const part of guidanceParts(body))
|
|
18
|
+
lines.push(part);
|
|
19
|
+
return lines.slice(0, MAX_RETRY_GUIDANCE_LINES).map((l) => `- ${l}`).join("\n");
|
|
20
|
+
}
|
|
6
21
|
const ACTIONS = ["retry", "reroute", "decompose", "human"];
|
|
7
22
|
export function buildDossierPrompt(d) {
|
|
8
23
|
return `TICKMARKR-CONSULT
|
|
@@ -32,7 +47,7 @@ trust dialog, broken install) — not when a single model produced bad code. Omi
|
|
|
32
47
|
reroutes so other models of the same adapter remain eligible.
|
|
33
48
|
|
|
34
49
|
Respond with ONLY this JSON:
|
|
35
|
-
{"action": "retry" | "reroute" | "decompose" | "human", "
|
|
50
|
+
{"action": "retry" | "reroute" | "decompose" | "human", "reason": "why this action", "guidance": "imperative steps for the worker (newline-separated ok)", "excludeAdapter"?: "<adapter-id>"}
|
|
36
51
|
`;
|
|
37
52
|
}
|
|
38
53
|
let consultSeq = 0;
|
|
@@ -76,5 +91,14 @@ opts = {}) {
|
|
|
76
91
|
// zero-match expansion as channel-level reroute.
|
|
77
92
|
const raw = v.excludeAdapter;
|
|
78
93
|
const excludeAdapter = typeof raw === "string" && raw.length > 0 ? raw : undefined;
|
|
79
|
-
|
|
94
|
+
const reason = typeof v.reason === "string" ? v.reason : undefined;
|
|
95
|
+
const guidance = typeof v.guidance === "string" ? v.guidance : undefined;
|
|
96
|
+
const notes = String(v.notes ?? guidance ?? reason ?? "");
|
|
97
|
+
return {
|
|
98
|
+
action: v.action,
|
|
99
|
+
notes,
|
|
100
|
+
...(reason ? { reason } : {}),
|
|
101
|
+
...(guidance ? { guidance } : {}),
|
|
102
|
+
...(excludeAdapter ? { excludeAdapter } : {}),
|
|
103
|
+
};
|
|
80
104
|
}
|
package/dist/run/daemon.js
CHANGED
|
@@ -10,7 +10,7 @@ import { formatOwnedName } from "../drivers/types.js";
|
|
|
10
10
|
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
|
-
import { consult } from "./consult.js";
|
|
13
|
+
import { consult, renderRetryGuidance } from "./consult.js";
|
|
14
14
|
import { cleanupRunWorktrees, gitHead, sh } from "./git.js";
|
|
15
15
|
import { Journal, loadRoutingProfile, newRunId } from "./journal.js";
|
|
16
16
|
import { acquireRunLock, releaseRunLock } from "./lock.js";
|
|
@@ -159,6 +159,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
159
159
|
};
|
|
160
160
|
const execTask = async (t) => {
|
|
161
161
|
const startMs = Date.now();
|
|
162
|
+
const taskTimeoutMinutes = t.timeoutMinutes ?? cfg.taskTimeoutMinutes;
|
|
162
163
|
if (t.humanGate && !approved.has(t.id)) {
|
|
163
164
|
// GATE-08: the condition is the APPROVAL, never the code path. `!opts.resume` (or any run-phase
|
|
164
165
|
// term) would silently dispatch every unapproved gate that becomes ready during a resume — pinned
|
|
@@ -252,11 +253,13 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
252
253
|
const applyVerdict = async (v, attempts, trigger) => {
|
|
253
254
|
journal.append("consult-verdict", t.id, {
|
|
254
255
|
action: v.action, notes: v.notes,
|
|
256
|
+
...(v.reason ? { reason: v.reason } : {}),
|
|
257
|
+
...(v.guidance ? { guidance: v.guidance } : {}),
|
|
255
258
|
...(v.excludeAdapter ? { excludeAdapter: v.excludeAdapter } : {}),
|
|
256
259
|
});
|
|
257
260
|
await driver.notify(`tickmarkr ${runId}: ${t.id} consult verdict: ${v.action}`, { tier: "attention" });
|
|
258
261
|
if (v.action === "retry") {
|
|
259
|
-
feedback = v
|
|
262
|
+
feedback = renderRetryGuidance(v) || feedback;
|
|
260
263
|
return true;
|
|
261
264
|
}
|
|
262
265
|
if (v.action === "reroute") {
|
|
@@ -404,7 +407,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
404
407
|
finished = false;
|
|
405
408
|
exitCode = null;
|
|
406
409
|
output = "";
|
|
407
|
-
const deadline = Date.now() +
|
|
410
|
+
const deadline = Date.now() + taskTimeoutMinutes * 60_000;
|
|
408
411
|
while (Date.now() < deadline) {
|
|
409
412
|
const sliceStart = Date.now();
|
|
410
413
|
const slice = Math.min(BLOCKED_POLL_MS, deadline - sliceStart);
|
|
@@ -473,11 +476,21 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
473
476
|
else {
|
|
474
477
|
const inv = adapter.invoke(t, wt, assignment, { promptFile });
|
|
475
478
|
await driver.run(slot, `${inv.command}; ${exitMarkerCmd}`);
|
|
476
|
-
finished = await driver.waitOutput(slot, `TICKMARKR_EXIT_${nonce}:\\d`,
|
|
479
|
+
finished = await driver.waitOutput(slot, `TICKMARKR_EXIT_${nonce}:\\d`, taskTimeoutMinutes * 60_000, { regex: true });
|
|
477
480
|
output = await driver.read(slot, 500);
|
|
478
481
|
exitCode = Number(exitRe.exec(output)?.[1] ?? 1);
|
|
479
482
|
}
|
|
480
|
-
|
|
483
|
+
// SPEND-01 interactive metering race: the harvest loop breaks on the trailer, but the worker
|
|
484
|
+
// shell may still be running post-trailer bookkeeping (session-store flush, fake usage stamp,
|
|
485
|
+
// exit wrapper). Print mode already waits for TICKMARKR_EXIT, which follows that tail; drain
|
|
486
|
+
// interactive attempts to the same exit marker before close and the post-hoc usage disk read
|
|
487
|
+
// so a writer never races the reader (real CLIs can flush usage asynchronously after the trailer).
|
|
488
|
+
if (interactive && finished && !exitRe.test(output)) {
|
|
489
|
+
await driver.waitOutput(slot, `TICKMARKR_EXIT_${nonce}:\\d`, 2_000, { regex: true });
|
|
490
|
+
}
|
|
491
|
+
// keepPanes retains visible context, not a timed-out subprocess tree. Close before consult/retry
|
|
492
|
+
// can recreate the worktree; Herdr and subprocesses that reached their exit marker stay unchanged.
|
|
493
|
+
if (keepOpen && (finished || driver.id !== "subprocess"))
|
|
481
494
|
keptSlots.push(slot);
|
|
482
495
|
else
|
|
483
496
|
await driver.close(slot);
|
|
@@ -536,7 +549,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
536
549
|
journal.telemetry({ taskId: t.id, shape: t.shape, adapter: assignment.adapter, model: assignment.model, channel: assignment.channel, attempts: attempt + 1, outcome: "failed", durationMs: 0, overrun: true, retryMode });
|
|
537
550
|
const v = await runConsult("stall", output, exitCode !== null && interactive
|
|
538
551
|
? `worker process exited (code ${exitCode}) without a trailer`
|
|
539
|
-
: `no completion marker within ${
|
|
552
|
+
: `no completion marker within ${taskTimeoutMinutes}m`, []);
|
|
540
553
|
if (await applyVerdict(v, attempt + 1, "stall"))
|
|
541
554
|
continue;
|
|
542
555
|
return;
|
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@ Use this to execute a requested sequence of repository specs. It is SDD-agnostic
|
|
|
13
13
|
When working in a multi-agent terminal environment, decide your role before starting:
|
|
14
14
|
|
|
15
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
|
|
16
|
+
- **Supervisor with a live orchestrator:** do not start a second run. Relay the mission to the existing orchestrator with a [verified handoff](#verified-handoffs-agent-to-agent-messaging), then supervise it as OVERSEER.
|
|
17
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.
|
|
@@ -30,9 +30,30 @@ Outside a multi-agent terminal environment, run the loop directly.
|
|
|
30
30
|
|
|
31
31
|
Run every requested target in order without seeking routine confirmation. Stop only for a blocked agent interaction, a genuinely unresolved stalled task, a designed human gate, or a failed run that needs a human decision. Diagnose from the journal and evidence first; self-release and resume only after fixing and verifying a harness defect.
|
|
32
32
|
|
|
33
|
+
## Binary preflight (before compile or run)
|
|
34
|
+
|
|
35
|
+
Before `tickmarkr compile` or `tickmarkr run`, compare the installed binary against the repository's `package.json` version:
|
|
36
|
+
|
|
37
|
+
1. Run `tickmarkr version` (one line, machine-parseable).
|
|
38
|
+
2. Read the `version` field from the repository's `package.json`.
|
|
39
|
+
3. If the binary is **older on major.minor** than the repo (e.g. binary `1.36.x` vs repo `1.38.x`), **stop immediately** and tell the operator to update the global install (`npm i -g tickmarkr@latest`) or link the repo binary. Do not compile, plan, or run on hope.
|
|
40
|
+
|
|
41
|
+
A stale binary silently skips daemon gates shipped in newer releases — the v1.38 run exposed this when a global `1.36.0` binary missed the daemon tip-verify gate entirely (OBS-38). Preflight failure is always stop-and-report; never proceed-and-hope.
|
|
42
|
+
|
|
43
|
+
## Verified handoffs (agent-to-agent messaging)
|
|
44
|
+
|
|
45
|
+
When relaying missions between agents in a multi-agent terminal, **never use bare send-text** (`herdr agent send` / pane send-text) — it writes text without pressing Enter, so handoffs sit unsubmitted (OBS-39).
|
|
46
|
+
|
|
47
|
+
Use one of:
|
|
48
|
+
|
|
49
|
+
- `herdr pane run <pane> "<message>"` — text plus Enter in the target shell
|
|
50
|
+
- `herdr notification show "<message>"` — OS-level delivery for the operator
|
|
51
|
+
|
|
52
|
+
After sending, **confirm delivery** by reading the target pane and verifying the message landed (input empty, agent status `working`, or notification acknowledged). Never report "briefed" or "relayed" without read-back confirmation.
|
|
53
|
+
|
|
33
54
|
## Per-spec loop
|
|
34
55
|
|
|
35
|
-
1. **Prepare** — confirm the target list
|
|
56
|
+
1. **Prepare** — confirm the target list. Run the [binary preflight](#binary-preflight-before-compile-or-run). Check `git status`, confirm no tickmarkr run is active, and work from a non-main branch.
|
|
36
57
|
2. **Compile** — run `tickmarkr compile <spec-or-directory>`. Fix source-spec defects instead of editing the generated graph.
|
|
37
58
|
3. **Plan** — run `tickmarkr plan`. Review routes, capability-floor warnings, and human gates before execution.
|
|
38
59
|
4. **Run** — run `tickmarkr run`. Watch the run journal for its terminal event rather than polling agents. Resolve blocked interactions in the relevant agent session.
|
|
@@ -12,7 +12,7 @@ Use this for any spec that `tickmarkr compile` accepts. It is SDD-agnostic: use
|
|
|
12
12
|
When working in a multi-agent terminal environment, decide your role before starting:
|
|
13
13
|
|
|
14
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
|
|
15
|
+
- **Supervisor with a live orchestrator:** do not start a second run. Relay the mission to the existing orchestrator with a [verified handoff](#verified-handoffs-agent-to-agent-messaging), then supervise it as OVERSEER.
|
|
16
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.
|
|
@@ -29,9 +29,30 @@ Outside a multi-agent terminal environment, run the loop directly.
|
|
|
29
29
|
|
|
30
30
|
Proceed through the loop without seeking routine confirmation. Stop only for a blocked agent interaction, a genuinely unresolved stalled task, or a designed human gate. Diagnose from the journal and available evidence before escalating; if a harness defect is fixed and verified, resume the run.
|
|
31
31
|
|
|
32
|
+
## Binary preflight (before compile or run)
|
|
33
|
+
|
|
34
|
+
Before `tickmarkr compile` or `tickmarkr run`, compare the installed binary against the repository's `package.json` version:
|
|
35
|
+
|
|
36
|
+
1. Run `tickmarkr version` (one line, machine-parseable).
|
|
37
|
+
2. Read the `version` field from the repository's `package.json`.
|
|
38
|
+
3. If the binary is **older on major.minor** than the repo (e.g. binary `1.36.x` vs repo `1.38.x`), **stop immediately** and tell the operator to update the global install (`npm i -g tickmarkr@latest`) or link the repo binary. Do not compile, plan, or run on hope.
|
|
39
|
+
|
|
40
|
+
A stale binary silently skips daemon gates shipped in newer releases — the v1.38 run exposed this when a global `1.36.0` binary missed the daemon tip-verify gate entirely (OBS-38). Preflight failure is always stop-and-report; never proceed-and-hope.
|
|
41
|
+
|
|
42
|
+
## Verified handoffs (agent-to-agent messaging)
|
|
43
|
+
|
|
44
|
+
When relaying missions between agents in a multi-agent terminal, **never use bare send-text** (`herdr agent send` / pane send-text) — it writes text without pressing Enter, so handoffs sit unsubmitted (OBS-39).
|
|
45
|
+
|
|
46
|
+
Use one of:
|
|
47
|
+
|
|
48
|
+
- `herdr pane run <pane> "<message>"` — text plus Enter in the target shell
|
|
49
|
+
- `herdr notification show "<message>"` — OS-level delivery for the operator
|
|
50
|
+
|
|
51
|
+
After sending, **confirm delivery** by reading the target pane and verifying the message landed (input empty, agent status `working`, or notification acknowledged). Never report "briefed" or "relayed" without read-back confirmation.
|
|
52
|
+
|
|
32
53
|
## The loop
|
|
33
54
|
|
|
34
|
-
1. **Prepare** — start from the requested spec. Check `git status`, confirm no tickmarkr run is active, and work from a non-main branch.
|
|
55
|
+
1. **Prepare** — start from the requested spec. Run the [binary preflight](#binary-preflight-before-compile-or-run). Check `git status`, confirm no tickmarkr run is active, and work from a non-main branch.
|
|
35
56
|
2. **Compile** — run `tickmarkr compile <spec>`. Correct compilation errors in the spec, never in the generated graph.
|
|
36
57
|
3. **Plan** — run `tickmarkr plan`. Review the routing table, capability-floor warnings, and every human gate, including work that each gate blocks.
|
|
37
58
|
4. **Run** — run `tickmarkr run`. Watch the run journal for its terminal event rather than repeatedly polling agents. Resolve blocked interactions in the agent session; do not turn them into proxy questions.
|