tickmarkr 1.58.0 → 1.60.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 +29 -82
- package/dist/cli/commands/fleet.js +40 -13
- package/dist/cli/commands/init.js +37 -30
- package/dist/cli/commands/plan.js +1 -2
- package/dist/cli/commands/run.js +4 -4
- package/dist/cli/commands/status.js +19 -9
- package/dist/config/config.d.ts +21 -3
- package/dist/config/config.js +97 -32
- package/dist/route/router.d.ts +1 -3
- package/dist/route/router.js +11 -25
- package/dist/run/daemon.js +3 -4
- package/package.json +2 -1
- package/skills/tickmarkr-auto/SKILL.md +3 -1
- package/skills/tickmarkr-loop/SKILL.md +3 -1
- package/skills/tickmarkr-overseer/SKILL.md +110 -0
- package/skills/tickmarkr-overseer/scripts/watch-panes.sh +93 -0
package/README.md
CHANGED
|
@@ -143,94 +143,34 @@ away from a failing adapter will never retry it in subsequent `tickmarkr resume`
|
|
|
143
143
|
|
|
144
144
|
## Choosing your fleet: `tickmarkr fleet`
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
probe data, agent CLIs, model tiers, routing mode, shape routing with a candidate picker, and
|
|
148
|
-
steering preferences — and ends in a unified diff of your config overlay that is written **only
|
|
149
|
-
on explicit confirm**. Pressing Enter through every step writes nothing. Assigning a tier to a
|
|
150
|
-
model that has no classification yet requires a typed benchmark-provenance note, stored as a
|
|
151
|
-
config comment beside the assignment.
|
|
152
|
-
|
|
153
|
-
The shape routing step (step 5/6) features an **arrow-driven candidate picker** that ranks
|
|
154
|
-
available channels by the production router's own logic, showing economics (tier + channel
|
|
155
|
-
cost signals for API and sub channels) and the router's rationale for each rank. This replaces
|
|
156
|
-
typed pin entry with verifiable suggestions. Step 4/6 shows an estimated spend context line
|
|
157
|
-
across all shapes under each routing mode's resolved floors, also derived from the same preview
|
|
158
|
-
routing machinery.
|
|
146
|
+
Configure which agent CLIs and models tickmarkr may route before your first run:
|
|
159
147
|
|
|
160
148
|
```bash
|
|
161
|
-
tickmarkr
|
|
162
|
-
tickmarkr fleet
|
|
149
|
+
tickmarkr doctor # probe auth + capabilities (run after install or credential changes)
|
|
150
|
+
tickmarkr fleet # interactive editor (requires a TTY) — six steps, confirm to write
|
|
151
|
+
tickmarkr fleet --print # effective fleet state (repo > global > defaults), non-interactive
|
|
152
|
+
tickmarkr plan # lint the resolved routing table against your spec
|
|
153
|
+
tickmarkr run # dispatch with the fleet you confirmed
|
|
163
154
|
```
|
|
164
155
|
|
|
165
|
-
|
|
156
|
+
`tickmarkr doctor` is a pure sensor; `tickmarkr fleet` is the actuator. The editor walks **six
|
|
157
|
+
steps** — probe data, agent CLIs, model tiers, routing mode, shape routing with a **candidate
|
|
158
|
+
picker**, and steering preferences — and ends in a unified diff of your repo config overlay.
|
|
159
|
+
Nothing is written until you confirm; pressing Enter through every step leaves config unchanged.
|
|
160
|
+
Step 5/6 uses an arrow-driven candidate picker ranked by the production router; step 3 may ask
|
|
161
|
+
for a benchmark-provenance note when you classify a new model.
|
|
166
162
|
|
|
167
|
-
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
auto-routing then picks the cheapest authed channel at or above the floor
|
|
171
|
-
- **deny/allow** bench models or whole adapters without touching tiers (see `deny.models` below)
|
|
172
|
-
- **tiers** classify models into bands — only classified, doctor-authed models ever route
|
|
163
|
+
Routing-mode semantics, pin/prefer/floor precedence, review and consult steering syntax,
|
|
164
|
+
provenance rules, and `--quality` / `--mode` flags are documented in
|
|
165
|
+
**[FLEET.md](FLEET.md)** (advanced reference).
|
|
173
166
|
|
|
174
|
-
|
|
167
|
+
## Steering
|
|
175
168
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
the routed model's window
|
|
182
|
-
- `routing.sla` — per-shape latency expectations, surfaced as advisory plan lints against the
|
|
183
|
-
learned performance profile
|
|
184
|
-
- `run --quality` — compatibility alias for `run --mode partner-led` for that run; it disables
|
|
185
|
-
exploration and records the selected mode in routing provenance
|
|
186
|
-
|
|
187
|
-
## Steering: routing modes, reviewer preferences, and reruns
|
|
188
|
-
|
|
189
|
-
### Routing modes
|
|
190
|
-
|
|
191
|
-
`routing.mode` is a preset that compiles into floor assignments at config load time. The router never sees the mode itself; it receives resolved floors only.
|
|
192
|
-
|
|
193
|
-
Three routing modes are available:
|
|
194
|
-
|
|
195
|
-
- **`risk-based`** (default): byte-identical to pre-v1.51 routing. Absent `mode` key resolves as risk-based.
|
|
196
|
-
- **`partner-led`**: resolves every non-overridden shape to a `frontier` floor and disables exploration — use when quality is paramount and cost is secondary.
|
|
197
|
-
- **`staff-led`**: lowers each mode default by one tier (e.g., `implement` and `refactor` become `cheap` instead of `mid`) while keeping the preset floor for the integrity set (`plan`, `spec`, `migration`, `ui`) at `frontier`.
|
|
198
|
-
|
|
199
|
-
Explicit `routing.floors` entries beat mode-preset deltas and are linted during plan if they shadow the mode's delta; an explicit integrity floor below `frontier` is also linted. The mode is compiled once at config load and never consulted during routing.
|
|
200
|
-
|
|
201
|
-
### Review preferences
|
|
202
|
-
|
|
203
|
-
`review.prefer` is an ordered list of reviewer seats for the cross-vendor code-review gate. Entries are matched by diversity (never the same vendor or model as the original worker), and routing reorders the available channels only — it does not admit unauthed or denied channels.
|
|
204
|
-
|
|
205
|
-
```yaml
|
|
206
|
-
review:
|
|
207
|
-
prefer: [codex, kimi] # bare adapter: inherits model from the routed channel
|
|
208
|
-
prefer: [codex:gpt-5.6-sol, kimi:kimi-code/k3] # adapter:model explicit
|
|
209
|
-
prefer: [codex, kimi:kimi-code/k3] # mixed: bare and explicit
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
**Grammar**: review prefer entries may name a bare adapter (inheriting the model from the current channel) or an explicit `adapter:model` pair. Bare adapters rank every diversity-eligible channel for that adapter; explicit pairs rank one diversity-eligible channel.
|
|
213
|
-
|
|
214
|
-
### Consult preferences
|
|
215
|
-
|
|
216
|
-
`consult.prefer` is a ranked failover list of seats for escalations on deadlock or gate stalls. Unlike review, a consult seat has no channel to inherit a model from, so entries **must be explicit `adapter:model` pairs**.
|
|
217
|
-
|
|
218
|
-
```yaml
|
|
219
|
-
consult:
|
|
220
|
-
adapter: claude-code
|
|
221
|
-
model: fable
|
|
222
|
-
prefer: [codex:gpt-5.6-sol, kimi:kimi-code/k3] # adapter:model ONLY
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
The daemon walks the preference list to the first live adapter, then the pinned `consult.adapter:model` pair as the final fallback. Failed or unparseable verdicts fall to the next entry.
|
|
226
|
-
|
|
227
|
-
**Grammar**: consult prefer entries require `adapter:model` form — a bare adapter name is invalid and fails config load. Every entry must declare both the adapter and the model because a consult seat runs independently with no channel context.
|
|
228
|
-
|
|
229
|
-
### Rerun control: --supersedes
|
|
230
|
-
|
|
231
|
-
`tickmarkr run --supersedes <prior-runId>` marks the current run as a rerun of a prior engagement. The current task graph is used for the rerun; compile it fresh first if the spec changed. The prior runId is recorded in the new journal, and the prior journal records the successor, for audit trails and change attribution.
|
|
232
|
-
|
|
233
|
-
Use this when you modify the spec or worker logic and want to mark an intentional rerun while preserving the relationship in both run journals.
|
|
169
|
+
Fleet step 6/6 sets `review.prefer` and `consult.prefer`; routing modes (`risk-based`,
|
|
170
|
+
`partner-led`, `staff-led`) are chosen in step 4/6. Full grammar — including when review
|
|
171
|
+
prefer may name a **bare adapter** versus `adapter:model`, and why consult prefer entries
|
|
172
|
+
require **`adapter:model`** form — plus `tickmarkr run --supersedes` rerun control, is in
|
|
173
|
+
**[FLEET.md](FLEET.md)**.
|
|
234
174
|
|
|
235
175
|
## Model scoping and auth detection
|
|
236
176
|
|
|
@@ -415,11 +355,18 @@ These are optional — the CLI works standalone. Skills are repo-scoped and ship
|
|
|
415
355
|
|
|
416
356
|
## Contributing
|
|
417
357
|
|
|
418
|
-
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, the green bar (build/test/lint), and
|
|
358
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, the green bar (build/test/lint), and
|
|
419
359
|
design invariants. Pull requests welcome; non-trivial changes should include test coverage.
|
|
420
360
|
|
|
361
|
+
**Boundaries:** this repo is a squashed export of private development — each release is a verified
|
|
362
|
+
snapshot, not a live mirror of every commit. Before tickmarkr 2.0, minor versions may break with every
|
|
363
|
+
break noted in [CHANGELOG.md](CHANGELOG.md). Support is best effort for the latest version only;
|
|
364
|
+
accepted contributions are credited via `Co-authored-by:` on the release commit. Details in
|
|
365
|
+
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
366
|
+
|
|
421
367
|
## Documentation
|
|
422
368
|
|
|
369
|
+
- **[FLEET.md](FLEET.md)** — routing modes, steering syntax, tier provenance, and run flags (advanced reference)
|
|
423
370
|
- **[LICENSE](LICENSE)** — MIT license
|
|
424
371
|
- **[CONTRIBUTING.md](CONTRIBUTING.md)** — development setup and contribution guidelines
|
|
425
372
|
- **[SECURITY.md](SECURITY.md)** — security policy and private vulnerability reporting
|
|
@@ -6,7 +6,7 @@ import { parseArgs } from "node:util";
|
|
|
6
6
|
import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, readAutoPrefer } from "../../adapters/registry.js";
|
|
7
7
|
import { fleetUnclassifiedModels } from "../../adapters/model-lints.js";
|
|
8
8
|
import { GLYPHS, bold, legend as legendText, toggleActive, toggleInactive } from "../../brand.js";
|
|
9
|
-
import { fleetEditableFromConfig, fleetEditableEquals, fleetRepoOverlayFromDelta, formatFleetPrint, globalConfigDir, overlayBytesLoadError, overlayPreferShapes, readOverlayFile, repoOverlayPath, repoOverlayYaml, ROUTING_MODES, unifiedYamlDiff, } from "../../config/config.js";
|
|
9
|
+
import { fleetEditableFromConfig, fleetEditableEquals, fleetRepoOverlayFromDelta, formatFleetPrint, globalConfigDir, harvestFleetProvenance, overlayBytesLoadError, overlayPreferShapes, readOverlayFile, repoOverlayPath, repoOverlayYaml, ROUTING_MODES, unifiedYamlDiff, } from "../../config/config.js";
|
|
10
10
|
import { SHAPES, TIERS } from "../../graph/schema.js";
|
|
11
11
|
import { candidateRow, costSignal, shapeCandidates } from "./fleet-picker.js";
|
|
12
12
|
import { route } from "../../route/router.js";
|
|
@@ -16,6 +16,10 @@ const NON_TTY_MSG = "tickmarkr fleet: interactive fleet editor requires a TTY
|
|
|
16
16
|
const QUIT = "fleet: quit without writing";
|
|
17
17
|
const isDeniedAdapter = (id, editable) => editable.denyAdapters.includes(id);
|
|
18
18
|
const isDeniedModel = (adapter, model, editable) => editable.denyModels.includes(`${adapter}:${model}`);
|
|
19
|
+
// v1.60 T3: every preview surface ranks with the SAME exploration setting as the candidate picker
|
|
20
|
+
// (rankCandidates routes noExplore so repeated calls agree) — a due probe must never make a
|
|
21
|
+
// step-4/5 row disagree with the picker's rank-1 for the same shape and channel set.
|
|
22
|
+
const PREVIEW_EXPLORE = { noExplore: true };
|
|
19
23
|
function previewTask(shape) {
|
|
20
24
|
return {
|
|
21
25
|
id: "fleet-preview",
|
|
@@ -190,7 +194,10 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
190
194
|
}
|
|
191
195
|
const rm = resolveRunMode(cwd, { globalDir });
|
|
192
196
|
const cfg = rm.cfg;
|
|
193
|
-
|
|
197
|
+
// OBS-88: harvest existing `# note` comments from the overlay bytes at session load — the
|
|
198
|
+
// session must know about every prior note, not only its own edits, or the next write strips them
|
|
199
|
+
const harvested = harvestFleetProvenance(currentRepoOverlayText(cwd));
|
|
200
|
+
const initial = fleetEditableFromConfig(cfg, harvested.tiers);
|
|
194
201
|
const editable = structuredClone(initial);
|
|
195
202
|
const health = cached;
|
|
196
203
|
const term = openTerm(input, output);
|
|
@@ -290,16 +297,29 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
290
297
|
}
|
|
291
298
|
else if (k.name === "t" && rows[mCursor]) {
|
|
292
299
|
if (rows[mCursor].kind === "classified") {
|
|
293
|
-
|
|
300
|
+
// v1.60 T2: t on a classified row is a step-3 input mistake like any other — the
|
|
301
|
+
// notice renders inline and the session (with every in-session edit) stays alive
|
|
302
|
+
term.frame([
|
|
303
|
+
...listFrame(title, modelsLegend, renderRows(rows), mCursor),
|
|
304
|
+
"fleet: tier reassignment on classified models is not supported in v1 — edit config directly",
|
|
305
|
+
]);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
// v1.60 T2: an input mistake re-prompts the same field — a typo must never unwind
|
|
309
|
+
// the whole editor and discard every in-session edit before the review screen
|
|
310
|
+
let tier = (await term.askTyped(`tier (${TIERS.join("|")})> `));
|
|
311
|
+
while (!TIERS.includes(tier)) {
|
|
312
|
+
tier = (await term.askTyped(`fleet: invalid tier ${tier || "(empty)"} — tier (${TIERS.join("|")})> `));
|
|
313
|
+
}
|
|
314
|
+
let note = await term.askTyped("benchmark provenance note (required): ");
|
|
315
|
+
while (!note) {
|
|
316
|
+
note = await term.askTyped("fleet: a typed benchmark-provenance note is required — benchmark provenance note (required): ");
|
|
294
317
|
}
|
|
295
|
-
const tier = (await term.askTyped(`tier (${TIERS.join("|")})> `));
|
|
296
|
-
if (!TIERS.includes(tier))
|
|
297
|
-
return { out: `fleet: invalid tier ${tier}`, code: 1 };
|
|
298
|
-
const note = await term.askTyped("benchmark provenance note (required): ");
|
|
299
|
-
if (!note)
|
|
300
|
-
return { out: "fleet: assigning a tier to an unclassified model requires a typed benchmark-provenance note", code: 1 };
|
|
301
318
|
editable.tiers[a.id] ??= {};
|
|
302
|
-
|
|
319
|
+
// stamp at typing time — the serializer writes provenance verbatim, so harvested notes
|
|
320
|
+
// round-trip byte-for-byte instead of accreting a fresh date suffix every write (OBS-88)
|
|
321
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
322
|
+
editable.tiers[a.id][rows[mCursor].model] = { tier, provenance: `${note} — fleet ${today}` };
|
|
303
323
|
changed = true;
|
|
304
324
|
}
|
|
305
325
|
if (changed)
|
|
@@ -325,7 +345,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
325
345
|
let apiUsd = 0;
|
|
326
346
|
for (const shape of SHAPES) {
|
|
327
347
|
try {
|
|
328
|
-
const a = route(previewTask(shape), previewCfg(cand), channels, profile).assignment;
|
|
348
|
+
const a = route(previewTask(shape), previewCfg(cand), channels, profile, undefined, undefined, PREVIEW_EXPLORE).assignment;
|
|
329
349
|
tierCount[a.tier] = (tierCount[a.tier] ?? 0) + 1;
|
|
330
350
|
if (a.channel === "sub")
|
|
331
351
|
subs += 1;
|
|
@@ -393,7 +413,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
393
413
|
const shapeRows = () => SHAPES.map((shape) => {
|
|
394
414
|
let now;
|
|
395
415
|
try {
|
|
396
|
-
const r = route(previewTask(shape), previewCfg(selectedMode), channels, profile);
|
|
416
|
+
const r = route(previewTask(shape), previewCfg(selectedMode), channels, profile, undefined, undefined, PREVIEW_EXPLORE);
|
|
397
417
|
now = `${r.assignment.adapter}:${r.assignment.model} (${r.assignment.channel}, ${r.assignment.tier}) ${costSignal(r.assignment, cfg.pricing)}`;
|
|
398
418
|
}
|
|
399
419
|
catch (e) {
|
|
@@ -527,7 +547,14 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
527
547
|
else
|
|
528
548
|
delete merged[key];
|
|
529
549
|
}
|
|
530
|
-
|
|
550
|
+
// OBS-88: session notes (which include the harvested ones attached at load) win per model;
|
|
551
|
+
// harvested notes alone cover entries with no editable seat — e.g. a null tombstone's comment
|
|
552
|
+
const tierNotes = structuredClone(harvested.tiers);
|
|
553
|
+
for (const [ad, ms] of Object.entries(provenanceMap(editable))) {
|
|
554
|
+
for (const [m, n] of Object.entries(ms))
|
|
555
|
+
(tierNotes[ad] ??= {})[m] = n;
|
|
556
|
+
}
|
|
557
|
+
const after = withModeLine(repoOverlayYaml(merged, tierNotes, { adapters: harvested.denyAdapters, models: harvested.denyModels }), writeMode);
|
|
531
558
|
if ((!modeChanged && !steeringChanged && fleetEditableEquals(initial, editable)) || before === after) {
|
|
532
559
|
return "fleet: no overlay changes (empty diff)";
|
|
533
560
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
import { createInterface } from "node:readline/promises";
|
|
4
5
|
import { parseArgs } from "node:util";
|
|
5
6
|
import { allAdapters, formatDoctorAgeForInit, formatDoctorReport, initDoctorReuse } from "../../adapters/registry.js";
|
|
@@ -61,7 +62,7 @@ function nextSteps(cwd, scaffoldedSpec) {
|
|
|
61
62
|
return `next: edit ${scaffoldedSpec}, then tickmarkr compile ${scaffoldedSpec} && tickmarkr plan && tickmarkr run`;
|
|
62
63
|
}
|
|
63
64
|
const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
|
|
64
|
-
const AGENT_SKILLS = ["tickmarkr-loop", "tickmarkr-auto"];
|
|
65
|
+
const AGENT_SKILLS = ["tickmarkr-loop", "tickmarkr-auto", "tickmarkr-overseer"];
|
|
65
66
|
const DOCS_BEGIN = "<!-- tickmarkr:agent-docs begin -->";
|
|
66
67
|
const DOCS_END = "<!-- tickmarkr:agent-docs end -->";
|
|
67
68
|
const AGENT_DOCS = `${DOCS_BEGIN}
|
|
@@ -110,9 +111,16 @@ A run is green only when the run-end event exists in the journal AND tip verify
|
|
|
110
111
|
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.
|
|
111
112
|
${DOCS_END}
|
|
112
113
|
`;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
// Every applicable host location gets the skills, each paired with its own repository guidance
|
|
115
|
+
// file: codex discovers .agents/skills + AGENTS.md (always applicable); claude discovers
|
|
116
|
+
// .claude/skills + CLAUDE.md (applicable when the repo already shows claude usage).
|
|
117
|
+
const hostTargets = (cwd) => {
|
|
118
|
+
const targets = [{ skillsDir: join(cwd, ".agents", "skills"), docPath: join(cwd, "AGENTS.md") }];
|
|
119
|
+
if (existsSync(join(cwd, ".claude")) || existsSync(join(cwd, "CLAUDE.md")))
|
|
120
|
+
targets.push({ skillsDir: join(cwd, ".claude", "skills"), docPath: join(cwd, "CLAUDE.md") });
|
|
121
|
+
return targets;
|
|
122
|
+
};
|
|
123
|
+
const skillsInstalled = (cwd) => hostTargets(cwd).every((t) => AGENT_SKILLS.every((s) => existsSync(join(t.skillsDir, s, "SKILL.md"))));
|
|
116
124
|
const wizardDriverDefault = () => process.env.HERDR_ENV === "1" ? "herdr" : "auto";
|
|
117
125
|
async function installAgentFiles(cwd, force, docs, notes) {
|
|
118
126
|
const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
@@ -124,31 +132,30 @@ async function installAgentFiles(cwd, force, docs, notes) {
|
|
|
124
132
|
return /^(?:y|yes)$/i.test((await prompt.question(`${question} [y/N] `)).trim());
|
|
125
133
|
};
|
|
126
134
|
try {
|
|
127
|
-
for (const
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
135
|
+
for (const { skillsDir, docPath } of hostTargets(cwd)) {
|
|
136
|
+
for (const skill of AGENT_SKILLS) {
|
|
137
|
+
const dest = join(skillsDir, skill, "SKILL.md");
|
|
138
|
+
const exists = existsSync(dest);
|
|
139
|
+
if (exists && !force && !(await confirm(`Overwrite ${dest}?`))) {
|
|
140
|
+
notes.push(`skipped existing ${dest}; pass --force to overwrite it`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// whole skill dir, not just SKILL.md — the overseer ships its pane-watcher script
|
|
144
|
+
cpSync(fileURLToPath(new URL(`../../../skills/${skill}`, import.meta.url)), join(skillsDir, skill), { recursive: true });
|
|
145
|
+
notes.push(`${exists ? "overwrote" : "wrote"} ${dest}`);
|
|
146
|
+
}
|
|
147
|
+
const current = existsSync(docPath) ? readFileSync(docPath, "utf8") : "";
|
|
148
|
+
if (current.includes(DOCS_BEGIN) || current.includes(`<!-- ${LEGACY_PREFIX}:agent-docs begin -->`)
|
|
149
|
+
|| current.includes(DOCS_END) || current.includes(`<!-- ${LEGACY_PREFIX}:agent-docs end -->`)) {
|
|
150
|
+
notes.push(`kept existing tickmarkr agent docs in ${docPath}`);
|
|
151
|
+
}
|
|
152
|
+
else if (docs || await confirm(`Append tickmarkr agent docs to ${docPath}?`)) {
|
|
153
|
+
appendFileSync(docPath, `${current ? current.endsWith("\n") ? "\n" : "\n\n" : ""}${AGENT_DOCS}`);
|
|
154
|
+
notes.push(`appended tickmarkr agent docs to ${docPath}`);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
notes.push(`skipped agent docs for ${docPath}; pass --docs to append them`);
|
|
133
158
|
}
|
|
134
|
-
mkdirSync(join(skillsRoot(cwd), skill), { recursive: true });
|
|
135
|
-
writeFileSync(dest, readFileSync(new URL(`../../../skills/${skill}/SKILL.md`, import.meta.url)), exists ? undefined : { flag: "wx" });
|
|
136
|
-
notes.push(`${exists ? "overwrote" : "wrote"} ${dest}`);
|
|
137
|
-
}
|
|
138
|
-
const claude = join(cwd, "CLAUDE.md");
|
|
139
|
-
const agents = join(cwd, "AGENTS.md");
|
|
140
|
-
const docPath = existsSync(claude) || !existsSync(agents) ? claude : agents;
|
|
141
|
-
const current = existsSync(docPath) ? readFileSync(docPath, "utf8") : "";
|
|
142
|
-
if (current.includes(DOCS_BEGIN) || current.includes(`<!-- ${LEGACY_PREFIX}:agent-docs begin -->`)
|
|
143
|
-
|| current.includes(DOCS_END) || current.includes(`<!-- ${LEGACY_PREFIX}:agent-docs end -->`)) {
|
|
144
|
-
notes.push(`kept existing tickmarkr agent docs in ${docPath}`);
|
|
145
|
-
}
|
|
146
|
-
else if (docs || await confirm(`Append tickmarkr agent docs to ${docPath}?`)) {
|
|
147
|
-
appendFileSync(docPath, `${current ? current.endsWith("\n") ? "\n" : "\n\n" : ""}${AGENT_DOCS}`);
|
|
148
|
-
notes.push(`appended tickmarkr agent docs to ${docPath}`);
|
|
149
|
-
}
|
|
150
|
-
else {
|
|
151
|
-
notes.push(`skipped agent docs for ${docPath}; pass --docs to append them`);
|
|
152
159
|
}
|
|
153
160
|
}
|
|
154
161
|
finally {
|
|
@@ -183,7 +190,7 @@ async function runInitWizard(cwd) {
|
|
|
183
190
|
let installSkills = false;
|
|
184
191
|
if (!skillsInstalled(cwd)) {
|
|
185
192
|
const skillsDef = existsSync(join(cwd, ".claude", "skills")) && !skillsInstalled(cwd);
|
|
186
|
-
installSkills = await askYesNo(rl, "Install agent skills (tickmarkr-loop/tickmarkr-auto)?", skillsDef);
|
|
193
|
+
installSkills = await askYesNo(rl, "Install agent skills (tickmarkr-loop/tickmarkr-auto/tickmarkr-overseer)?", skillsDef);
|
|
187
194
|
}
|
|
188
195
|
return { overlay: { driver, concurrency, visibility: { llm } }, installSkills };
|
|
189
196
|
}
|
|
@@ -8,7 +8,7 @@ import { loadGraph } from "../../graph/graph.js";
|
|
|
8
8
|
import { resolveRunMode } from "../../run/daemon.js";
|
|
9
9
|
import { excludedChannels, exclusionLine } from "../../route/preference.js";
|
|
10
10
|
import { staffLedEvidence } from "../../route/profile.js";
|
|
11
|
-
import {
|
|
11
|
+
import { route, RoutingError } from "../../route/router.js";
|
|
12
12
|
import { modelId } from "../../gates/review.js";
|
|
13
13
|
import { loadRoutingProfile } from "../../run/journal.js";
|
|
14
14
|
// T4 (v1.50): TTY-only brand pass — the title helper frames the routing table, lint/unroutable
|
|
@@ -44,7 +44,6 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters())
|
|
|
44
44
|
if (values.mode !== undefined && !ROUTING_MODES.includes(values.mode)) {
|
|
45
45
|
throw new Error(`--mode must be one of ${ROUTING_MODES.join(" | ")} (got ${values.mode})`);
|
|
46
46
|
}
|
|
47
|
-
delete process.env[QUALITY_ENV];
|
|
48
47
|
const g = loadGraph(cwd);
|
|
49
48
|
const { cfg, mode, source } = resolveRunMode(cwd, { flag: values.mode, spec: g.mode });
|
|
50
49
|
// readDoctor cache path: staleness line only fires here (probeAll fallback is fresh by construction).
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -4,7 +4,7 @@ import { ROUTING_MODES } from "../../config/config.js";
|
|
|
4
4
|
import { pickDriver } from "../../drivers/index.js";
|
|
5
5
|
import { loadGraph } from "../../graph/graph.js";
|
|
6
6
|
import { formatSummary, resolveRunMode, runDaemon } from "../../run/daemon.js";
|
|
7
|
-
import { route, NO_EXPLORE_ENV
|
|
7
|
+
import { route, NO_EXPLORE_ENV } from "../../route/router.js";
|
|
8
8
|
import { formatJournalNarration, loadRoutingProfile } from "../../run/journal.js";
|
|
9
9
|
import { ttyVisual } from "../../adapters/model-lints.js";
|
|
10
10
|
import { statusRow } from "../../brand.js";
|
|
@@ -25,8 +25,9 @@ export const narrationLine = (event) => {
|
|
|
25
25
|
const summaryGreen = (s) => s.failed.length === 0 && s.human.length === 0 && s.blocked.length === 0 && s.pending.length === 0
|
|
26
26
|
&& s.tipVerify !== "failed";
|
|
27
27
|
// v1.51 T2: --quality is a pure compatibility alias for `--mode partner-led` (this run only). It
|
|
28
|
-
// carries no one-band floor raise of its own
|
|
29
|
-
// TICKMARKR_QUALITY env, so no downstream code
|
|
28
|
+
// carries no one-band floor raise of its own — and since the OBS-89 rip (v1.60) route() no longer
|
|
29
|
+
// reads the retired TICKMARKR_QUALITY env at all, so no downstream code can raise a floor on its
|
|
30
|
+
// behalf (proven in mode-sources).
|
|
30
31
|
const QUALITY_ALIAS_NOTICE = "tickmarkr: --quality is a compatibility alias for --mode partner-led (this run only) — "
|
|
31
32
|
+ "the v1.47 one-band floor raise is retired (deprecated); use --mode partner-led";
|
|
32
33
|
export async function run(argv, cwd = process.cwd()) {
|
|
@@ -56,7 +57,6 @@ export async function run(argv, cwd = process.cwd()) {
|
|
|
56
57
|
if (values.quality)
|
|
57
58
|
console.warn(QUALITY_ALIAS_NOTICE);
|
|
58
59
|
const flagMode = values.mode ?? (values.quality ? "partner-led" : undefined);
|
|
59
|
-
delete process.env[QUALITY_ENV];
|
|
60
60
|
const graph = loadGraph(cwd);
|
|
61
61
|
const { cfg, conflict } = resolveRunMode(cwd, { flag: flagMode, spec: graph.mode });
|
|
62
62
|
if (conflict) {
|
|
@@ -54,12 +54,19 @@ const gateStates = (task, events) => {
|
|
|
54
54
|
};
|
|
55
55
|
// verdict semantics only: pass brand green, fail red, skip/open dim chrome — everything else stays quiet
|
|
56
56
|
const GATE_STATE_TOKEN = { pass: ok, fail, skip: dim, open: dim };
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
57
|
+
// TTY cells are bare glyphs in fixed GATE_NAMES order — gate identity lives once in the frame
|
|
58
|
+
// legend, and in words on a failing row; non-TTY keeps the letter+box chips (byte-pinned surface)
|
|
59
|
+
const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => unicode
|
|
60
|
+
? GATE_STATE_TOKEN[states[i]](gateBox(states[i], true))
|
|
61
|
+
: `${GATE_KEYS[gate]}${gateBox(states[i], false)}`).join(" ");
|
|
62
|
+
// plain (uncolored) cell width for column math — ANSI codes have zero display width
|
|
63
|
+
const gateChainWidth = (unicode) => GATE_NAMES.reduce((w, gate) => w + (unicode ? 1 : GATE_KEYS[gate].length + 3) + 1, -1);
|
|
64
|
+
const failedGates = (states) => GATE_NAMES.filter((_, i) => states[i] === "fail");
|
|
65
|
+
// plain-text form of the failed-gate words for column math; rendered with a dim dot + red names
|
|
66
|
+
const failedSuffix = (states) => {
|
|
67
|
+
const f = failedGates(states);
|
|
68
|
+
return f.length ? ` · ${f.join(", ")}` : "";
|
|
69
|
+
};
|
|
63
70
|
const shortGoal = (goal, max) => {
|
|
64
71
|
const clause = goal.split(/[,;.?!]/, 1)[0].trim();
|
|
65
72
|
if (clause.length <= max)
|
|
@@ -193,19 +200,22 @@ const renderFrame = (cwd) => {
|
|
|
193
200
|
: `no runs yet${dot}`) +
|
|
194
201
|
`${gauge} ${done === g.tasks.length && g.tasks.length > 0 ? ok(tally) : tally}`;
|
|
195
202
|
const hr = rule(Math.min(width, 100));
|
|
196
|
-
const gatesLegend = legend(` gates: ${GATE_NAMES.
|
|
203
|
+
const gatesLegend = legend(` gates: ${GATE_NAMES.join(" · ")}`);
|
|
197
204
|
const taskVerdict = (st) => st === "done" ? "pass" : st === "failed" ? "fail" : st === "human" ? "warn" : "neutral";
|
|
198
205
|
const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
|
|
199
|
-
const stW = Math.max(...cells.map((c) => (String(c.st) + c.label).length));
|
|
206
|
+
const stW = Math.max(...cells.map((c) => (String(c.st) + c.label + failedSuffix(c.states)).length));
|
|
200
207
|
const chainW = gateChainWidth(true);
|
|
201
208
|
const assignW = Math.max(...cells.map((c) => c.assignCol.length));
|
|
202
209
|
const goalW = Math.max(8, width - (5 + idW) - 2 - chainW - 2 - stW - 2 - assignW);
|
|
203
210
|
const rows = cells.map(({ t, st, label, assignCol, states }) => {
|
|
204
211
|
const goal = shortGoal(t.goal, goalW).padEnd(goalW);
|
|
205
212
|
const stWord = st === "done" ? ok(String(st)) : st === "failed" ? fail(String(st)) : st === "human" ? warn(String(st)) : String(st);
|
|
213
|
+
// a fail names its gate in words right here — the one moment gate identity is needed on a row
|
|
214
|
+
const f = failedGates(states);
|
|
206
215
|
const statusCell = stWord +
|
|
207
216
|
(label ? (label === " starved" ? fail(label) : dim(label)) : "") +
|
|
208
|
-
" "
|
|
217
|
+
(f.length ? dim(" · ") + fail(f.join(", ")) : "") +
|
|
218
|
+
" ".repeat(stW - (String(st) + label + failedSuffix(states)).length);
|
|
209
219
|
return ` ${statusRow(taskVerdict(st), `${t.id.padEnd(idW)} ${goal} ${gateChain(states, true)} ${statusCell} ${dim(assignCol)}`)}`;
|
|
210
220
|
});
|
|
211
221
|
return [header, hr, gatesLegend, ...rows].join("\n");
|
package/dist/config/config.d.ts
CHANGED
|
@@ -250,7 +250,7 @@ export type FleetEditable = {
|
|
|
250
250
|
};
|
|
251
251
|
export declare function repoOverlayPath(repoRoot: string): string;
|
|
252
252
|
export declare function readOverlayFile(path: string): Record<string, unknown>;
|
|
253
|
-
export declare function fleetEditableFromConfig(cfg: TickmarkrConfig): FleetEditable;
|
|
253
|
+
export declare function fleetEditableFromConfig(cfg: TickmarkrConfig, provenance?: Record<string, Record<string, string>>): FleetEditable;
|
|
254
254
|
/** Resolve which layer last set a dotted fleet path (defaults < global < repo). */
|
|
255
255
|
export declare function fleetKeyLayer(repoRoot: string, dotted: string, opts?: {
|
|
256
256
|
globalDir?: string;
|
|
@@ -259,10 +259,28 @@ export declare function fleetKeyLayer(repoRoot: string, dotted: string, opts?: {
|
|
|
259
259
|
export declare function formatFleetPrint(repoRoot: string, opts?: {
|
|
260
260
|
globalDir?: string;
|
|
261
261
|
}): string;
|
|
262
|
+
/** Trailing `# note` comments on per-entry lines an operator may have hand-written or a prior
|
|
263
|
+
* fleet write stamped: model tier lines (including null tombstones) and deny list items. */
|
|
264
|
+
export type HarvestedProvenance = {
|
|
265
|
+
tiers: Record<string, Record<string, string>>;
|
|
266
|
+
denyAdapters: Record<string, string>;
|
|
267
|
+
denyModels: Record<string, string>;
|
|
268
|
+
};
|
|
269
|
+
/** OBS-88: harvest existing provenance comments from raw repo-overlay bytes at fleet-session
|
|
270
|
+
* load. yaml.parse discards comments, so before this every fleet write re-serialized the file
|
|
271
|
+
* knowing only the current session's own notes and silently stripped all prior ones — a typed
|
|
272
|
+
* benchmark-provenance note survived exactly one write. Fail-open to empty: an unreadable
|
|
273
|
+
* overlay is the loader's problem to reject, never the harvester's. */
|
|
274
|
+
export declare function harvestFleetProvenance(overlayText: string): HarvestedProvenance;
|
|
275
|
+
/** Harvested deny-entry notes keyed by the exact entry string, re-attached at serialize time. */
|
|
276
|
+
export type FleetDenyNotes = {
|
|
277
|
+
adapters?: Record<string, string>;
|
|
278
|
+
models?: Record<string, string>;
|
|
279
|
+
};
|
|
262
280
|
/** Build the repo overlay fragment fleet would write for edits since session start. */
|
|
263
281
|
export declare function fleetRepoOverlayFromDelta(initial: FleetEditable, edited: FleetEditable, existingRepo?: Record<string, unknown>): Record<string, unknown>;
|
|
264
|
-
export declare function repoOverlayYaml(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string
|
|
265
|
-
export declare function serializeFleetOverlay(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string
|
|
282
|
+
export declare function repoOverlayYaml(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string>>, denyNotes?: FleetDenyNotes): string;
|
|
283
|
+
export declare function serializeFleetOverlay(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string>>, denyNotes?: FleetDenyNotes): string;
|
|
266
284
|
export declare function unifiedYamlDiff(before: string, after: string, label?: string): string;
|
|
267
285
|
export declare function fleetEditableEquals(a: FleetEditable, b: FleetEditable): boolean;
|
|
268
286
|
export {};
|
package/dist/config/config.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { parse, stringify } from "yaml";
|
|
4
|
+
import { isMap, isScalar, isSeq, parse, parseDocument, stringify } from "yaml";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { stateDirName } from "../graph/graph.js";
|
|
7
7
|
import { SHAPES, TIERS } from "../graph/schema.js";
|
|
@@ -546,12 +546,16 @@ export function readOverlayFile(path) {
|
|
|
546
546
|
throw new ConfigError(`invalid overlay at ${path}`);
|
|
547
547
|
return raw;
|
|
548
548
|
}
|
|
549
|
-
export function fleetEditableFromConfig(cfg) {
|
|
549
|
+
export function fleetEditableFromConfig(cfg, provenance = {}) {
|
|
550
550
|
const tiers = {};
|
|
551
551
|
for (const [adapter, entry] of Object.entries(cfg.tiers)) {
|
|
552
552
|
tiers[adapter] = {};
|
|
553
|
-
for (const [model, tier] of Object.entries(entry.models))
|
|
554
|
-
|
|
553
|
+
for (const [model, tier] of Object.entries(entry.models)) {
|
|
554
|
+
// OBS-88: notes harvested from the overlay ride the session state from load, so a later
|
|
555
|
+
// write knows about every existing note — not only the ones this session typed itself
|
|
556
|
+
const note = provenance[adapter]?.[model];
|
|
557
|
+
tiers[adapter][model] = note ? { tier, provenance: note } : { tier };
|
|
558
|
+
}
|
|
555
559
|
}
|
|
556
560
|
return {
|
|
557
561
|
denyAdapters: [...(cfg.routing.deny?.adapters ?? [])].sort(),
|
|
@@ -649,6 +653,47 @@ export function formatFleetPrint(repoRoot, opts = {}) {
|
|
|
649
653
|
function sortedUnique(xs) {
|
|
650
654
|
return [...new Set(xs)].sort();
|
|
651
655
|
}
|
|
656
|
+
/** OBS-88: harvest existing provenance comments from raw repo-overlay bytes at fleet-session
|
|
657
|
+
* load. yaml.parse discards comments, so before this every fleet write re-serialized the file
|
|
658
|
+
* knowing only the current session's own notes and silently stripped all prior ones — a typed
|
|
659
|
+
* benchmark-provenance note survived exactly one write. Fail-open to empty: an unreadable
|
|
660
|
+
* overlay is the loader's problem to reject, never the harvester's. */
|
|
661
|
+
export function harvestFleetProvenance(overlayText) {
|
|
662
|
+
const out = { tiers: {}, denyAdapters: {}, denyModels: {} };
|
|
663
|
+
if (!overlayText.trim())
|
|
664
|
+
return out;
|
|
665
|
+
const doc = parseDocument(overlayText);
|
|
666
|
+
const note = (n) => {
|
|
667
|
+
const c = isScalar(n) ? n.comment : undefined;
|
|
668
|
+
return typeof c === "string" && c.trim() ? c.trim() : undefined;
|
|
669
|
+
};
|
|
670
|
+
const tiers = doc.getIn(["tiers"]);
|
|
671
|
+
if (isMap(tiers)) {
|
|
672
|
+
for (const ap of tiers.items) {
|
|
673
|
+
if (!isScalar(ap.key))
|
|
674
|
+
continue;
|
|
675
|
+
const models = isMap(ap.value) ? ap.value.get("models") : undefined;
|
|
676
|
+
if (!isMap(models))
|
|
677
|
+
continue;
|
|
678
|
+
for (const mp of models.items) {
|
|
679
|
+
const n = note(mp.value);
|
|
680
|
+
if (isScalar(mp.key) && n)
|
|
681
|
+
(out.tiers[String(ap.key.value)] ??= {})[String(mp.key.value)] = n;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
for (const [key, dest] of [["adapters", out.denyAdapters], ["models", out.denyModels]]) {
|
|
686
|
+
const seq = doc.getIn(["routing", "deny", key]);
|
|
687
|
+
if (!isSeq(seq))
|
|
688
|
+
continue;
|
|
689
|
+
for (const item of seq.items) {
|
|
690
|
+
const n = note(item);
|
|
691
|
+
if (isScalar(item) && n)
|
|
692
|
+
dest[String(item.value)] = n;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return out;
|
|
696
|
+
}
|
|
652
697
|
/** Build the repo overlay fragment fleet would write for edits since session start. */
|
|
653
698
|
export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
|
|
654
699
|
if (fleetEditableEquals(initial, edited))
|
|
@@ -714,11 +759,11 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
|
|
|
714
759
|
out.tiers = tiersOut;
|
|
715
760
|
return out;
|
|
716
761
|
}
|
|
717
|
-
export function repoOverlayYaml(overlay, provenance = {}) {
|
|
762
|
+
export function repoOverlayYaml(overlay, provenance = {}, denyNotes = {}) {
|
|
718
763
|
if (!Object.keys(overlay).length)
|
|
719
764
|
return "";
|
|
720
765
|
const fleet = fleetSubset(overlay);
|
|
721
|
-
const fleetBody = serializeFleetOverlay(fleet, provenance);
|
|
766
|
+
const fleetBody = serializeFleetOverlay(fleet, provenance, denyNotes);
|
|
722
767
|
const rest = { ...overlay };
|
|
723
768
|
for (const k of FLEET_OVERLAY_KEYS)
|
|
724
769
|
delete rest[k];
|
|
@@ -727,25 +772,39 @@ export function repoOverlayYaml(overlay, provenance = {}) {
|
|
|
727
772
|
const head = stringify(rest).trimEnd();
|
|
728
773
|
return fleetBody ? `${head}\n${fleetBody}` : `${head}\n`;
|
|
729
774
|
}
|
|
730
|
-
export function serializeFleetOverlay(overlay, provenance = {}) {
|
|
775
|
+
export function serializeFleetOverlay(overlay, provenance = {}, denyNotes = {}) {
|
|
731
776
|
if (!Object.keys(overlay).length)
|
|
732
777
|
return "";
|
|
733
778
|
const lines = [];
|
|
734
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
735
779
|
// OBS-75: never glue stringify() output onto a key line — wrap the key into the object and
|
|
736
780
|
// re-indent the whole emitted block, so sequences/nested maps nest correctly and null
|
|
737
781
|
// tombstones/empty collections survive the serialize→parse round-trip.
|
|
738
782
|
const block = (obj, pad) => stringify(obj).trimEnd().split("\n").map((l) => `${pad}${l}`);
|
|
783
|
+
// deny lists emit item-by-item through the same stringify quoting rules as block(), so a
|
|
784
|
+
// harvested `# reason` can re-attach to its exact entry (multi-line emissions never take one)
|
|
785
|
+
const denySeq = (key, v) => {
|
|
786
|
+
if (v === undefined)
|
|
787
|
+
return [];
|
|
788
|
+
if (v === null || !v.length)
|
|
789
|
+
return block({ [key]: v }, " ");
|
|
790
|
+
const out = [` ${key}:`];
|
|
791
|
+
for (const item of v) {
|
|
792
|
+
const emitted = stringify([item]).trimEnd().split("\n");
|
|
793
|
+
const n = denyNotes[key]?.[item];
|
|
794
|
+
if (n && emitted.length === 1)
|
|
795
|
+
emitted[0] += ` # ${n}`;
|
|
796
|
+
out.push(...emitted.map((l) => ` ${l}`));
|
|
797
|
+
}
|
|
798
|
+
return out;
|
|
799
|
+
};
|
|
739
800
|
const routing = overlay.routing;
|
|
740
801
|
if (routing) {
|
|
741
802
|
lines.push("routing:");
|
|
742
803
|
const deny = routing.deny;
|
|
743
804
|
if (deny && (deny.adapters !== undefined || deny.models !== undefined)) {
|
|
744
805
|
lines.push(" deny:");
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
if (deny.models !== undefined)
|
|
748
|
-
lines.push(...block({ models: deny.models }, " "));
|
|
806
|
+
lines.push(...denySeq("adapters", deny.adapters));
|
|
807
|
+
lines.push(...denySeq("models", deny.models));
|
|
749
808
|
}
|
|
750
809
|
if (routing.map)
|
|
751
810
|
lines.push(...block({ map: routing.map }, " "));
|
|
@@ -767,13 +826,11 @@ export function serializeFleetOverlay(overlay, provenance = {}) {
|
|
|
767
826
|
if (models.length) {
|
|
768
827
|
body.push(" models:");
|
|
769
828
|
for (const [model, tier] of models) {
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
body.push(` ${model}: ${tier}${suffix}`);
|
|
776
|
-
}
|
|
829
|
+
// OBS-88: notes serialize verbatim (fresh session notes arrive pre-stamped with their
|
|
830
|
+
// "— fleet <date>" suffix), so a harvested note round-trips byte-for-byte every write
|
|
831
|
+
const note = provenance[adapter]?.[model];
|
|
832
|
+
const suffix = note ? ` # ${note}` : "";
|
|
833
|
+
body.push(` ${model}: ${tier === null ? "null" : tier}${suffix}`);
|
|
777
834
|
}
|
|
778
835
|
}
|
|
779
836
|
else if (entry.models) {
|
|
@@ -789,10 +846,20 @@ export function serializeFleetOverlay(overlay, provenance = {}) {
|
|
|
789
846
|
return `${lines.join("\n")}\n`;
|
|
790
847
|
}
|
|
791
848
|
export function unifiedYamlDiff(before, after, label = "config overlay") {
|
|
792
|
-
const a = before.split("\n");
|
|
793
|
-
const b = after.split("\n");
|
|
794
849
|
if (before === after)
|
|
795
850
|
return "";
|
|
851
|
+
const a = before.split("\n");
|
|
852
|
+
const b = after.split("\n");
|
|
853
|
+
// v1.60 T3: shortest-edit (LCS) matching. The old scan resynced greedily on the first mismatched
|
|
854
|
+
// line, so one inserted line could cascade into a whole-file remove/re-add hunk on the one
|
|
855
|
+
// confirmation surface an operator reviews before a write.
|
|
856
|
+
// ponytail: O(n·m) table — overlays are tens of lines; Myers O(nd) if files ever grow.
|
|
857
|
+
const lcs = Array.from({ length: a.length + 1 }, () => Array.from({ length: b.length + 1 }, () => 0));
|
|
858
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
859
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
860
|
+
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
796
863
|
const header = [`--- ${label} (current)`, `+++ ${label} (proposed)`];
|
|
797
864
|
const hunks = [];
|
|
798
865
|
let i = 0;
|
|
@@ -803,17 +870,15 @@ export function unifiedYamlDiff(before, after, label = "config overlay") {
|
|
|
803
870
|
j++;
|
|
804
871
|
continue;
|
|
805
872
|
}
|
|
806
|
-
const
|
|
807
|
-
const
|
|
808
|
-
while (i < a.length && (j
|
|
809
|
-
i
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
for (let k = startJ; k < j; k++)
|
|
816
|
-
hunks.push(`+${b[k]}`);
|
|
873
|
+
const del = [];
|
|
874
|
+
const add = [];
|
|
875
|
+
while ((i < a.length || j < b.length) && !(i < a.length && j < b.length && a[i] === b[j])) {
|
|
876
|
+
if (j >= b.length || (i < a.length && lcs[i + 1][j] >= lcs[i][j + 1]))
|
|
877
|
+
del.push(`-${a[i++]}`);
|
|
878
|
+
else
|
|
879
|
+
add.push(`+${b[j++]}`);
|
|
880
|
+
}
|
|
881
|
+
hunks.push("@@", ...del, ...add);
|
|
817
882
|
}
|
|
818
883
|
return `${header.join("\n")}\n${hunks.join("\n")}\n`;
|
|
819
884
|
}
|
package/dist/route/router.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Assignment, type BillingChannel } from "../adapters/types.js";
|
|
2
|
-
import { type TickmarkrConfig
|
|
2
|
+
import { type TickmarkrConfig } from "../config/config.js";
|
|
3
3
|
import type { Task } from "../graph/schema.js";
|
|
4
4
|
import { type RoutingProfile } from "./profile.js";
|
|
5
5
|
export type LadderStep = "retry" | "escalate" | "consult" | "human";
|
|
@@ -28,12 +28,10 @@ export interface RoutingPreferContext {
|
|
|
28
28
|
}
|
|
29
29
|
export interface ExploreContext {
|
|
30
30
|
noExplore?: boolean;
|
|
31
|
-
quality?: boolean;
|
|
32
31
|
}
|
|
33
32
|
export declare const NO_EXPLORE_ENV = "TICKMARKR_NO_EXPLORE";
|
|
34
33
|
export declare const QUALITY_ENV = "TICKMARKR_QUALITY";
|
|
35
34
|
export declare const ROUTING_ENV_SEAMS: readonly ["TICKMARKR_QUALITY", "TICKMARKR_NO_EXPLORE"];
|
|
36
|
-
export declare const raiseTier: (tier: Tier) => Tier;
|
|
37
35
|
export declare class RoutingError extends Error {
|
|
38
36
|
constructor(msg: string);
|
|
39
37
|
}
|
package/dist/route/router.js
CHANGED
|
@@ -3,15 +3,18 @@ import { TIER_RANK } from "../config/config.js";
|
|
|
3
3
|
import { disallowedBy } from "./preference.js";
|
|
4
4
|
import { cellOf, EXPLORE_CAP, explorationBonus, learnedScore, MIN_SAMPLES } from "./profile.js";
|
|
5
5
|
export const NO_EXPLORE_ENV = "TICKMARKR_NO_EXPLORE";
|
|
6
|
+
// OBS-89 (v1.60): the TICKMARKR_QUALITY variable is RETIRED — nothing in src reads it anymore and
|
|
7
|
+
// route() ignores it entirely (the v1.47 one-band floor raise, its exploration suppression, and the
|
|
8
|
+
// env fallback were deleted, not moved). The name survives solely as a scrub target: the spawn seam
|
|
9
|
+
// keeps erasing a stale operator shell's legacy export from child environments, and the retired-seam
|
|
10
|
+
// tests (tests/run/git.test.ts, tests/setup.ts, tests/gates/baseline.test.ts) pin that contract.
|
|
6
11
|
export const QUALITY_ENV = "TICKMARKR_QUALITY";
|
|
7
12
|
// OBS-74: every routing env seam, in one list — the spawn seam (src/run/git.ts) scrubs exactly
|
|
8
13
|
// these from child env so gate/baseline/tip-verify children are hermetic by construction.
|
|
9
14
|
export const ROUTING_ENV_SEAMS = [QUALITY_ENV, NO_EXPLORE_ENV];
|
|
10
15
|
const exploreCap = (cfg) => cfg.routing.explore?.cap ?? EXPLORE_CAP;
|
|
11
|
-
const qualityOn = (exploreCtx) => !!exploreCtx?.quality || process.env[QUALITY_ENV] === "1";
|
|
12
|
-
export const raiseTier = (tier) => tier === "cheap" ? "mid" : tier === "mid" ? "frontier" : "frontier";
|
|
13
16
|
const exploreOff = (task, cfg, exploreCtx) => {
|
|
14
|
-
if (
|
|
17
|
+
if (exploreCtx?.noExplore || process.env[NO_EXPLORE_ENV] === "1")
|
|
15
18
|
return true;
|
|
16
19
|
const e = cfg.routing.explore;
|
|
17
20
|
if (!e)
|
|
@@ -110,15 +113,6 @@ function withoutExcluded(channels, exclude) {
|
|
|
110
113
|
// OBS-57: in-run demotion after consecutive no-trailer windows — route around poisoned channels for the rest of the run.
|
|
111
114
|
return channels.filter((c) => !exclude.has(channelKey(c)));
|
|
112
115
|
}
|
|
113
|
-
const qualityBound = (quality, configFloor, taskFloor) => {
|
|
114
|
-
if (!quality)
|
|
115
|
-
return undefined;
|
|
116
|
-
if (configFloor)
|
|
117
|
-
return `floor ${configFloor}→${raiseTier(configFloor)} (--quality)`;
|
|
118
|
-
if (taskFloor)
|
|
119
|
-
return `floor ${taskFloor}→${raiseTier(taskFloor)} (--quality)`;
|
|
120
|
-
return undefined;
|
|
121
|
-
};
|
|
122
116
|
const maybeSlaLint = (lints, task, profile, slaMinutes, c) => {
|
|
123
117
|
if (slaMinutes === undefined || !profile)
|
|
124
118
|
return;
|
|
@@ -135,7 +129,6 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
135
129
|
channels = withoutExcluded(channels, exclude);
|
|
136
130
|
const lints = [];
|
|
137
131
|
const advisoryFloor = cfg.routing.floors[task.shape];
|
|
138
|
-
const quality = qualityOn(exploreCtx);
|
|
139
132
|
const floor = advisoryFloor;
|
|
140
133
|
const slaMinutes = cfg.routing.sla?.[task.shape];
|
|
141
134
|
// ponytail: sla is plan-time advisory only — never thread into learnedScore (would reroute warm rivals).
|
|
@@ -173,8 +166,7 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
173
166
|
lints.push(`${task.id} (${task.shape}): ${what} routes ${tier}, below advisory floor ${advisoryFloor}`);
|
|
174
167
|
}
|
|
175
168
|
};
|
|
176
|
-
const
|
|
177
|
-
const taskFloor = taskFloorRaw && quality ? raiseTier(taskFloorRaw) : taskFloorRaw;
|
|
169
|
+
const taskFloor = task.routingHints?.floor;
|
|
178
170
|
const source = task.routingHints?.source;
|
|
179
171
|
const src = source ? `, ${source}` : ""; // never interpolate a possibly-undefined source
|
|
180
172
|
// task pin: planner-authored, try-first — degrades on miss or below-floor (D-05, research A3), never throws
|
|
@@ -201,12 +193,7 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
201
193
|
return { assignment: toAssignment(c), ladder: ladderFor(task, entry), lints, provenance: `${degraded}pin ${entry.pin.via}:${entry.pin.model} (config routing.map)` };
|
|
202
194
|
}
|
|
203
195
|
const baseTier = floor ?? "cheap";
|
|
204
|
-
|
|
205
|
-
if (quality && advisoryFloor) {
|
|
206
|
-
const raised = raiseTier(advisoryFloor);
|
|
207
|
-
if (TIER_RANK[raised] > TIER_RANK[minTier])
|
|
208
|
-
minTier = raised;
|
|
209
|
-
}
|
|
196
|
+
const minTier = taskFloor && TIER_RANK[taskFloor] > TIER_RANK[baseTier] ? taskFloor : baseTier; // task floor is a hard >= constraint in all paths (D-04)
|
|
210
197
|
if (prefActive)
|
|
211
198
|
for (const p of prefer ?? [])
|
|
212
199
|
preflightPrefer(p);
|
|
@@ -301,10 +288,9 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
301
288
|
eligible = spreadStatic(sortedStatic);
|
|
302
289
|
spreadDecided = channelKey(eligible[0]) !== channelKey(sortedStatic[0]);
|
|
303
290
|
}
|
|
304
|
-
const bound =
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
"tier cheap (default)");
|
|
291
|
+
const bound = taskFloor && TIER_RANK[taskFloor] >= TIER_RANK[baseTier] ? `floor ${taskFloor} (task hint${src})` :
|
|
292
|
+
floor ? `floor ${floor} (config floors)` :
|
|
293
|
+
"tier cheap (default)";
|
|
308
294
|
// name the key that actually broke the tie: prefer outranks the marginal-cost/tier keys, so if the
|
|
309
295
|
// winner matched a prefer entry, prefer decided it — not "cheapest sufficient tier" (ROUTE-03, WR-01)
|
|
310
296
|
const preferVia = preferFromAuto(task.shape, preferCtx)
|
package/dist/run/daemon.js
CHANGED
|
@@ -19,7 +19,7 @@ import { cleanupRunWorktrees, gitHead, linkNodeModules, sh, shGit, WORKTREE_LAYO
|
|
|
19
19
|
import { classifyWorkerResultCause, engagementComparable, Journal, loadRoutingProfile, newRunId } from "./journal.js";
|
|
20
20
|
import { acquireRunLock, releaseRunLock } from "./lock.js";
|
|
21
21
|
import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
|
|
22
|
-
import { nextChannel,
|
|
22
|
+
import { nextChannel, route } from "../route/router.js";
|
|
23
23
|
import { desiredPanes } from "./reconcile.js";
|
|
24
24
|
import { normalizeStallSnapshot } from "./stall.js";
|
|
25
25
|
const MODE_RANK = { "staff-led": 0, "risk-based": 1, "partner-led": 2 };
|
|
@@ -90,9 +90,8 @@ async function cherryPickCommits(wt, commits) {
|
|
|
90
90
|
return carried;
|
|
91
91
|
}
|
|
92
92
|
export async function runDaemon(repoRoot, opts = {}) {
|
|
93
|
-
// v1.51 T2: retired --quality env seam. Mode resolution owns premium routing
|
|
94
|
-
//
|
|
95
|
-
delete process.env[QUALITY_ENV];
|
|
93
|
+
// v1.51 T2 / OBS-89 (v1.60): retired --quality env seam. Mode resolution owns premium routing;
|
|
94
|
+
// route() no longer reads the retired env at all, so the old entrypoint scrub is gone with it.
|
|
96
95
|
const adapters = opts.adapters ?? allAdapters();
|
|
97
96
|
const health = readDoctor(repoRoot) ?? (await probeAll(adapters));
|
|
98
97
|
const driver = opts.driver ?? new SubprocessDriver();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tickmarkr",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.60.0",
|
|
4
4
|
"description": "Spec in, verified work out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"scripts": {
|
|
32
32
|
"build": "tsc -p tsconfig.json",
|
|
33
33
|
"lint": "oxlint src tests scripts",
|
|
34
|
+
"pretest": "npm run build",
|
|
34
35
|
"test": "vitest run",
|
|
35
36
|
"test:coverage": "vitest run --coverage",
|
|
36
37
|
"schema": "tsx scripts/emit-schema.ts",
|
|
@@ -14,7 +14,9 @@ When working in a multi-agent terminal environment, decide your role before star
|
|
|
14
14
|
|
|
15
15
|
- **Orchestrator:** your session was started to execute the mission. Rename your own tab/pane `ORCH · <version>` (short labels: ≤20 chars, `ROLE · token`) and run the loop below.
|
|
16
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
|
-
- **Primary session without an orchestrator:** rename your own tab `OVERSEER · <version>` and your agent `overseer`, spawn one child orchestration session with
|
|
17
|
+
- **Primary session without an orchestrator:** rename your own tab `OVERSEER · <version>` and your agent `overseer`, spawn one child orchestration session with your host's launch form, label its tab `ORCH · <version>` and name its agent, give it the mission and these rules verbatim, then supervise it. Do not drive a duplicate single-tier run yourself. Before spawning, confirm any PREVIOUS orchestrator has stood down (monitors stopped, input box empty — dim ghost-text suggestions are UI, not queued input; ANSI-verify before alarming) and close its tab — the journal, records, and ledger hold the story; scrollback is disposable.
|
|
18
|
+
- **Claude Code:** `herdr agent start orchestrator --cwd <repo> --no-focus -- claude --permission-mode bypassPermissions`
|
|
19
|
+
- **Codex:** `herdr agent start orchestrator --cwd <repo> --no-focus -- codex --ask-for-approval never --sandbox workspace-write`
|
|
18
20
|
|
|
19
21
|
Outside a multi-agent terminal environment, run the loop directly.
|
|
20
22
|
|
|
@@ -13,7 +13,9 @@ When working in a multi-agent terminal environment, decide your role before star
|
|
|
13
13
|
|
|
14
14
|
- **Orchestrator:** your session was started to execute the mission. Rename your own tab/pane `ORCH · <version>` (short labels: ≤20 chars, `ROLE · token`) and run the loop below.
|
|
15
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
|
-
- **Primary session without an orchestrator:** rename your own tab `OVERSEER · <version>` and your agent `overseer`, spawn one child orchestration session with
|
|
16
|
+
- **Primary session without an orchestrator:** rename your own tab `OVERSEER · <version>` and your agent `overseer`, spawn one child orchestration session with your host's launch form, label its tab `ORCH · <version>` and name its agent, give it the mission and these rules verbatim, then supervise it. Do not drive a duplicate single-tier run yourself. Before spawning, confirm any PREVIOUS orchestrator has [stood down](#stand-down-mission-end-and-retirement) and close its tab.
|
|
17
|
+
- **Claude Code:** `herdr agent start orchestrator --cwd <repo> --no-focus -- claude --permission-mode bypassPermissions`
|
|
18
|
+
- **Codex:** `herdr agent start orchestrator --cwd <repo> --no-focus -- codex --ask-for-approval never --sandbox workspace-write`
|
|
17
19
|
|
|
18
20
|
Outside a multi-agent terminal environment, run the loop directly.
|
|
19
21
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tickmarkr-overseer
|
|
3
|
+
description: "Use when the user asks to oversee/supervise/babysit an autonomous tickmarkr run in a Herdr workspace (e.g. '/tickmarkr-overseer run the milestone', 'supervise this tickmarkr run', 'babysit this pipeline'). Requires HERDR_ENV=1. The skill argument is the mission (what to run end-to-end)."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Overseer (tickmarkr)
|
|
7
|
+
|
|
8
|
+
Become the OVERSEER for this workspace. Do no heavy work directly — build and supervise a two-tier
|
|
9
|
+
hierarchy of VISIBLE agents (you → orchestrator → tickmarkr's own worker fleet), and route human decisions
|
|
10
|
+
to the user with evidence.
|
|
11
|
+
|
|
12
|
+
The mission is the skill argument. If empty, ask the user what to run end-to-end before doing anything else.
|
|
13
|
+
Requires `HERDR_ENV=1`; if unset, say so and stop.
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
0. **Adopt before you build.** If this workspace already has a supervision hierarchy — an
|
|
18
|
+
OVERSEER/ORCHESTRATOR tab, a live agent named `*orch*`, or a `<repo>/<state-dir>/overseer/` dir
|
|
19
|
+
(state dir = `.tickmarkr/`; legacy standalone `<repo>/.overseer/` counts
|
|
20
|
+
too) — do NOT spawn a duplicate (two orchestrators risk two concurrent tickmarkr runs in one repo,
|
|
21
|
+
which tickmarkr forbids). Read that dir's `DECISIONS.md` + `ORCH-BRIEF.md`, check the existing agents'
|
|
22
|
+
status, and either ADOPT the
|
|
23
|
+
existing orchestrator (updated brief, re-armed watchers) or, if the old hierarchy is dead, archive the
|
|
24
|
+
stale brief and build fresh.
|
|
25
|
+
1. Load the `herdr` skill. `herdr pane list` to map the workspace — the focused pane is yours. Rename your
|
|
26
|
+
tab OVERSEER; create ONE tab ORCHESTRATOR.
|
|
27
|
+
**Live tab labels (standing operator rule, 2026-07-12):** on every decision or state change (role
|
|
28
|
+
handoff, task done/merged, run end) rename the affected tabs — and keep labels SHORT: the role as the
|
|
29
|
+
main name plus at most ONE hot-state token. Vocabulary: ORCH carries the milestone and progress
|
|
30
|
+
fraction (`ORCH · v1.19 4/5`, updated on every task-done); WORKERS carries the task token (tickmarkr
|
|
31
|
+
updates it). Never long context strings or ✓-chains.
|
|
32
|
+
2. **Orchestrator**: Launch the orchestrator with your agent host. For Claude Code, use `herdr agent start orchestrator --cwd <repo> --no-focus -- claude --permission-mode bypassPermissions` (pin a strong model with `--model <m>` if the operator has a policy). For Codex, use `herdr agent start orchestrator --cwd <repo> --no-focus -- codex --ask-for-approval never --sandbox workspace-write` (add `--model <m>` to specify the model). Workers you never spawn — tickmarkr spawns its own visible worker panes.
|
|
33
|
+
3. **Standing instructions travel as a brief FILE, never as pane text** — PTY input truncates at ~1024B and a
|
|
34
|
+
truncated brief silently drops policy. Write the full brief to `<repo>/.tickmarkr/overseer/ORCH-BRIEF.md`
|
|
35
|
+
(inside the tickmarkr state dir — already self-gitignored, no exclude step needed), then send one line:
|
|
36
|
+
`herdr pane run <orch> "Read .tickmarkr/overseer/ORCH-BRIEF.md and follow it exactly."` The brief MUST contain: the
|
|
37
|
+
mission, the pane mechanics below, rules 1–2, and require a verbatim one-sentence acknowledgment of the
|
|
38
|
+
human-checkpoint rule before anything is dispatched. Delete the dir at mission end.
|
|
39
|
+
4. Arm the watcher (Supervision). Report the hierarchy map (pane ids + names) to the user.
|
|
40
|
+
|
|
41
|
+
## Supervising tickmarkr as the executor
|
|
42
|
+
|
|
43
|
+
When the mission runs `/tickmarkr-auto` (tickmarkr dispatches the workers), supervision changes shape:
|
|
44
|
+
|
|
45
|
+
- **Give the run a live surface.** `tickmarkr run` is stdout-silent until run-end by design — split a pane in the
|
|
46
|
+
ORCHESTRATOR tab running `tickmarkr status --watch`. Narration also arrives as herdr notifications.
|
|
47
|
+
- **Watch the journal, not the panes.** The append-only journal
|
|
48
|
+
(`.tickmarkr/runs/<runId>/journal.jsonl`) is the
|
|
49
|
+
source of truth. Arm a background watcher on `run-end` / `task-human` / `task-failed` / `consult-verdict`
|
|
50
|
+
events; never sleep-poll inside an agent turn.
|
|
51
|
+
- **Daemon liveness ≠ journal activity.** A dead daemon emits no events, so journal watchers sleep through
|
|
52
|
+
its death. `tickmarkr status` prints last-event age + daemon pid liveness; check it before diagnosing a stall.
|
|
53
|
+
Recovery is `tickmarkr resume <runId>` — crash-safe by design (journal replay restores attempt counts and
|
|
54
|
+
consult channel bans).
|
|
55
|
+
- **Gate quiet ≠ idle.** Between `worker-result` and the batched `gate-result`s tickmarkr runs shell gates plus a
|
|
56
|
+
headless LLM judge/review with little visible signal — check the journal timestamps before intervening.
|
|
57
|
+
- **Classify gate failures before reacting.** The same fingerprint failing across DIFFERENT workers, or a
|
|
58
|
+
scope/test catch-22 (attempt N edits a file → scope gate fails; attempt N+1 leaves it → test gate fails),
|
|
59
|
+
is a PLAN defect: widen `files_modified` in the phase PLAN, recompile the phase dir after the run ends or
|
|
60
|
+
the task parks, release (`human → pending`), resume. A cross-vendor review rejection with concrete findings
|
|
61
|
+
is a REAL defect — let the escalation ladder work.
|
|
62
|
+
- **Dialog watchers go stale per attempt.** Every retry/escalation may spawn a new pane; re-arm dialog
|
|
63
|
+
watchers on each `task-dispatch` journal event.
|
|
64
|
+
|
|
65
|
+
## Pane mechanics that bite
|
|
66
|
+
|
|
67
|
+
- **Verified send protocol**: `herdr agent send` writes WITHOUT Enter, and `pane run`'s Enter can be swallowed
|
|
68
|
+
by bracketed-paste on long payloads. Robust sequence: read the pane (bare prompt required) → send-text →
|
|
69
|
+
sleep 2–3s → send-keys Enter → read back (input empty / agent `working`). Never report "briefed" without
|
|
70
|
+
the read-back. Long content goes in a brief file, never pane text.
|
|
71
|
+
- **Guard-before-Enter** (race-safe prompt answering): chain with `&&` — pane get shows `blocked` && pane
|
|
72
|
+
read shows the expected option under the cursor && only then send-keys. If no longer `blocked`, someone
|
|
73
|
+
already answered; do nothing.
|
|
74
|
+
- `herdr wait agent-status` exits 1 on timeout, 0 on match — but ALSO 0 (with an error JSON) when the pane is
|
|
75
|
+
GONE. Never chain `wait && act` without confirming the pane exists.
|
|
76
|
+
- Stale typed input is unclearable via CLI — supersede it:
|
|
77
|
+
`pane run "<-- disregard everything before this arrow (stale draft). ACTUAL: <message>"`.
|
|
78
|
+
|
|
79
|
+
## Supervision watcher
|
|
80
|
+
|
|
81
|
+
Arm the bundled watcher as its OWN Bash call with `run_in_background` — chaining it after other commands
|
|
82
|
+
with `&` orphans it from the wake chain. It prints one wake reason and exits; re-arm after every wake.
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
.claude/skills/tickmarkr-overseer/scripts/watch-panes.sh WORKER_PANE ORCH_PANE [--fast-blocked]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Default mode wakes only when both panes are quiet (dropped handoff) or the orchestrator blocks; the
|
|
89
|
+
orchestrator gets a 90s grace window to handle worker blocks first. For long parked stretches a targeted
|
|
90
|
+
`herdr wait agent-status <pane> --status <s> --timeout <ms>` beats the watcher. When parking a human
|
|
91
|
+
checkpoint, also fire `herdr notification show "HUMAN CHECKPOINT: <gate>" --sound request`.
|
|
92
|
+
|
|
93
|
+
## Specialist pipeline rules
|
|
94
|
+
|
|
95
|
+
- **Dedicated consultant tab**: Consultants (agents spawned to gather synthesis input for decisions like SCOPER analysis or architectural reviews) must run in a DEDICATED tab separate from the ORCHESTRATOR tab. When the orchestrator stands down, the consultant panes should persist so their assessments remain available for review and reference.
|
|
96
|
+
- **Scoper worktree rule**: The SCOPER (or any worktree-based specialist synthesizing into the spec pipeline) must do ALL git operations in a dedicated worktree (e.g., `git worktree add /private/tmp/tkr-scoper-v155 -b spec/...`), never switching the main checkout's branch. This prevents race conditions between the specialist's branch operations and the orchestrator's shipping logic.
|
|
97
|
+
|
|
98
|
+
## Non-negotiable rules
|
|
99
|
+
|
|
100
|
+
1. **Takeover rule**: only act on a worker if it needs input AND the orchestrator is not `working`.
|
|
101
|
+
2. **Human checkpoints (absolute)**: any gate marked `autonomous: false` or asking for product/visual
|
|
102
|
+
sign-off is NEVER auto-answered — regardless of how obviously correct the highlighted option looks. Leave
|
|
103
|
+
it blocked and bring the user the decision WITH evidence. If the mission explicitly delegates authority,
|
|
104
|
+
routine-class gates may be overseer-decided after polling the operator first — but spend and ship gates
|
|
105
|
+
NEVER self-decide.
|
|
106
|
+
3. **Trust disk over transcripts**: verify artifacts on disk before building on them; a subagent killed
|
|
107
|
+
mid-flight still renders "Done" without writing its artifact.
|
|
108
|
+
4. **Report concisely on every state change**: what happened, who handled it, what's next. Lead with the
|
|
109
|
+
outcome. Surface product decisions; never make them.
|
|
110
|
+
5. **Log every abnormality** to `.planning/OBSERVATIONS.md` (or the project's ledger), even mid-run.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# watch-panes.sh — Herdr overseer watcher.
|
|
3
|
+
# Polls worker + orchestrator agent_status via `herdr pane get`, prints ONE wake
|
|
4
|
+
# reason to stdout, then exits. Run it via the Bash tool with run_in_background
|
|
5
|
+
# so the overseer is re-invoked when it fires. Re-run (re-arm) after every wake.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# watch-panes.sh WORKER_PANE ORCH_PANE [--fast-blocked] [--cap-seconds N] [--settle-seconds N]
|
|
9
|
+
#
|
|
10
|
+
# Options:
|
|
11
|
+
# --fast-blocked Wake fast (20s debounce) whenever the WORKER blocks, even
|
|
12
|
+
# if the orchestrator is working. Use during stretches with
|
|
13
|
+
# human checkpoints. Without it, a blocked worker gets a 90s
|
|
14
|
+
# grace window for the orchestrator to handle it first
|
|
15
|
+
# (takeover rule), and only wakes if BOTH panes are quiet.
|
|
16
|
+
# --cap-seconds N Max watch duration (default 14400 = 4h).
|
|
17
|
+
# --settle-seconds N Initial sleep before polling (default 60) so a
|
|
18
|
+
# just-dispatched turn can start without an instant refire.
|
|
19
|
+
#
|
|
20
|
+
# Wake reasons printed:
|
|
21
|
+
# WORKER_PANE_GONE / ORCH_PANE_GONE — a pane disappeared
|
|
22
|
+
# ORCH:blocked WORKER:<s> — orchestrator itself needs input
|
|
23
|
+
# WORKER:blocked ORCH:<s> — worker blocked (fast-blocked mode)
|
|
24
|
+
# WORKER:<s> ORCH:<s> — both quiet: dropped handoff / done
|
|
25
|
+
# WATCH_CAP_REACHED ... — cap hit, everything still working
|
|
26
|
+
set -u
|
|
27
|
+
WORKER="${1:?worker pane id required}"
|
|
28
|
+
ORCH="${2:?orchestrator pane id required}"
|
|
29
|
+
shift 2
|
|
30
|
+
FAST_BLOCKED=false
|
|
31
|
+
CAP=14400
|
|
32
|
+
SETTLE=60
|
|
33
|
+
while [ $# -gt 0 ]; do
|
|
34
|
+
case "$1" in
|
|
35
|
+
--fast-blocked) FAST_BLOCKED=true ;;
|
|
36
|
+
--cap-seconds) CAP="$2"; shift ;;
|
|
37
|
+
--settle-seconds) SETTLE="$2"; shift ;;
|
|
38
|
+
esac
|
|
39
|
+
shift
|
|
40
|
+
done
|
|
41
|
+
|
|
42
|
+
get_status() {
|
|
43
|
+
herdr pane get "$1" 2>/dev/null | python3 -c '
|
|
44
|
+
import sys, json
|
|
45
|
+
try:
|
|
46
|
+
d = json.load(sys.stdin)
|
|
47
|
+
p = d.get("result", {}).get("pane", d.get("result", {}))
|
|
48
|
+
print(p.get("agent_status", "unknown"))
|
|
49
|
+
except Exception:
|
|
50
|
+
print("gone")
|
|
51
|
+
' 2>/dev/null || echo gone
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Transient CLI/server failures also read as "gone" — recheck before declaring.
|
|
55
|
+
confirmed_gone() {
|
|
56
|
+
sleep 5
|
|
57
|
+
[ "$(get_status "$1")" = "gone" ]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
END=$((SECONDS + CAP))
|
|
61
|
+
sleep "$SETTLE"
|
|
62
|
+
while [ $SECONDS -lt $END ]; do
|
|
63
|
+
WS=$(get_status "$WORKER")
|
|
64
|
+
OS=$(get_status "$ORCH")
|
|
65
|
+
if [ "$WS" = "gone" ]; then
|
|
66
|
+
confirmed_gone "$WORKER" && { echo "WORKER_PANE_GONE ORCH:$(get_status "$ORCH")"; exit 0; }
|
|
67
|
+
continue
|
|
68
|
+
fi
|
|
69
|
+
if [ "$OS" = "gone" ]; then
|
|
70
|
+
confirmed_gone "$ORCH" && { echo "ORCH_PANE_GONE WORKER:$(get_status "$WORKER")"; exit 0; }
|
|
71
|
+
continue
|
|
72
|
+
fi
|
|
73
|
+
# orchestrator itself stuck on a prompt (debounced)
|
|
74
|
+
if [ "$OS" = "blocked" ]; then
|
|
75
|
+
sleep 15
|
|
76
|
+
[ "$(get_status "$ORCH")" = "blocked" ] && { echo "ORCH:blocked WORKER:$WS"; exit 0; }
|
|
77
|
+
fi
|
|
78
|
+
# human-checkpoint mode: wake fast on any persisting worker block
|
|
79
|
+
if $FAST_BLOCKED && [ "$WS" = "blocked" ]; then
|
|
80
|
+
sleep 20
|
|
81
|
+
[ "$(get_status "$WORKER")" = "blocked" ] && { echo "WORKER:blocked ORCH:$(get_status "$ORCH")"; exit 0; }
|
|
82
|
+
fi
|
|
83
|
+
# standard mode: worker needs attention AND orchestrator is not driving
|
|
84
|
+
if [ "$WS" != "working" ]; then
|
|
85
|
+
sleep 90
|
|
86
|
+
WS2=$(get_status "$WORKER"); OS2=$(get_status "$ORCH")
|
|
87
|
+
if [ "$WS2" != "working" ] && [ "$OS2" != "working" ]; then
|
|
88
|
+
echo "WORKER:$WS2 ORCH:$OS2"; exit 0
|
|
89
|
+
fi
|
|
90
|
+
fi
|
|
91
|
+
sleep 20
|
|
92
|
+
done
|
|
93
|
+
echo "WATCH_CAP_REACHED WORKER:$(get_status "$WORKER") ORCH:$(get_status "$ORCH")"
|