tickmarkr 2.6.6 → 2.6.8

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.
Files changed (51) hide show
  1. package/dist/adapters/claude-code.js +8 -2
  2. package/dist/adapters/codex-commit-check.d.ts +3 -2
  3. package/dist/adapters/codex-commit-check.js +37 -10
  4. package/dist/adapters/codex.js +10 -4
  5. package/dist/cli/commands/beat.d.ts +7 -2
  6. package/dist/cli/commands/beat.js +144 -77
  7. package/dist/cli/commands/fleet.js +66 -21
  8. package/dist/cli/commands/init.js +18 -7
  9. package/dist/cli/commands/plan.js +16 -18
  10. package/dist/cli/commands/verify.js +4 -2
  11. package/dist/cli/help.d.ts +1 -0
  12. package/dist/cli/help.js +14 -6
  13. package/dist/cli/index.d.ts +1 -1
  14. package/dist/cli/index.js +1 -1
  15. package/dist/drivers/herdr.js +2 -2
  16. package/dist/drivers/orca.d.ts +12 -4
  17. package/dist/drivers/orca.js +200 -39
  18. package/dist/gates/baseline.d.ts +9 -0
  19. package/dist/gates/baseline.js +14 -3
  20. package/dist/gates/cache.js +1 -1
  21. package/dist/gates/evidence.js +3 -3
  22. package/dist/gates/review.d.ts +1 -1
  23. package/dist/gates/review.js +77 -13
  24. package/dist/gates/run-gates.d.ts +21 -6
  25. package/dist/gates/run-gates.js +439 -224
  26. package/dist/gates/test-manifest.d.ts +42 -0
  27. package/dist/gates/test-manifest.js +203 -118
  28. package/dist/run/beat-lifecycle.d.ts +239 -0
  29. package/dist/run/beat-lifecycle.js +1782 -0
  30. package/dist/run/daemon.d.ts +4 -0
  31. package/dist/run/daemon.js +129 -40
  32. package/dist/run/git-trust.d.ts +102 -0
  33. package/dist/run/git-trust.js +334 -0
  34. package/dist/run/git.d.ts +24 -3
  35. package/dist/run/git.js +169 -30
  36. package/dist/run/journal.js +15 -3
  37. package/dist/run/lease.d.ts +23 -0
  38. package/dist/run/lease.js +232 -38
  39. package/dist/run/merge.js +4 -4
  40. package/dist/run/stall.js +20 -1
  41. package/dist/run/supervision.d.ts +8 -0
  42. package/dist/run/supervision.js +24 -4
  43. package/dist/run/verification-job.d.ts +53 -0
  44. package/dist/run/verification-job.js +159 -0
  45. package/dist/tui/ink/fleet-app.d.ts +16 -2
  46. package/dist/tui/ink/fleet-app.js +265 -141
  47. package/package.json +1 -1
  48. package/skills/tickmarkr-auto/SKILL.md +80 -27
  49. package/skills/tickmarkr-loop/SKILL.md +87 -27
  50. package/skills/tickmarkr-overseer/SKILL.md +125 -47
  51. package/skills/tickmarkr-overseer/scripts/watch-journal.sh +9 -7
@@ -281,8 +281,15 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
281
281
  : readClaudeAliasIdentity(cwd, model) ?? CLAUDE_ALIAS_IDENTITY_STAMPS[model];
282
282
  // One ranking universe for the whole screen: every unclassified row bands fleet-relatively
283
283
  // against the same set, so a suggestion never depends on which adapter group renders first.
284
+ // T5: a displayed row's real members — every id doctor actually served among its collapsed variants
285
+ // (the bare id only when served, never the display label alone) — folded constituents append theirs.
286
+ // Each real id is ONE member even when doctor's list repeats it, so one act takes one reason from it.
284
287
  const detectedRows = fleetUnclassifiedModels(cfg, health, adapters)
285
- .map((row) => ({ ...row, resolvedModel: resolvedCatalogModel(row.adapter, row.model) }));
288
+ .map((row) => ({
289
+ ...row,
290
+ resolvedModel: resolvedCatalogModel(row.adapter, row.model),
291
+ members: [...new Set([row.model, ...(row.variants ?? [])])].filter((id) => health[row.adapter]?.models?.includes(id)),
292
+ }));
286
293
  const catalogRanking = catalogTierRanking(cfg, catalog, detectedRows, resolvedCatalogModel);
287
294
  const foldedRows = [];
288
295
  const folds = new Map();
@@ -296,6 +303,8 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
296
303
  if (first) {
297
304
  first.foldedModels ??= [first.model];
298
305
  first.foldedModels.push(row.model);
306
+ // T5: a collapsed constituent keeps every variant when it is catalog-folded
307
+ first.members = [...new Set([...first.members, ...row.members])];
299
308
  }
300
309
  else {
301
310
  foldedRows.push(next);
@@ -347,9 +356,26 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
347
356
  ...unclassified
348
357
  .filter((row) => !editable.tiers[adapter.id]?.[row.model])
349
358
  .map((row) => {
350
- const evidence = rowEvidence(adapter.id, row.classifyModel ?? row.model, row.advisory.coverage === "covered" ? row.advisory.evidence : undefined);
359
+ const found = rowEvidence(adapter.id, row.classifyModel ?? row.model, row.advisory.coverage === "covered" ? row.advisory.evidence : undefined);
360
+ // T5: each member keeps its own recorded identity and auth verdict; a group is unauthed as a
361
+ // row only when every member is — one member's failed probe never blocks the others' reach
362
+ const grouped = row.members.length > 1 || (row.members.length === 1 && row.members[0] !== row.model);
363
+ const members = grouped ? row.members.map((model) => {
364
+ const identity = resolvedModelIdentity(adapter.id, model);
365
+ const probed = health[adapter.id]?.modelAuth?.[model];
366
+ return {
367
+ model,
368
+ ...(identity !== undefined ? { identity } : {}),
369
+ ...(probed?.authed === false ? { unauthed: probed.reason ?? "probe failed" } : {}),
370
+ };
371
+ }) : undefined;
372
+ const { unauthed: _unauthed, ...authedEvidence } = found ?? {};
373
+ const evidence = members?.some((member) => member.unauthed === undefined) && found?.unauthed !== undefined
374
+ ? (Object.keys(authedEvidence).length ? authedEvidence : undefined)
375
+ : found;
351
376
  return {
352
377
  model: row.model,
378
+ ...(members ? { members } : {}),
353
379
  detectedAt: row.detectedAt,
354
380
  ...(row.classifyModel ? { classifyModel: row.classifyModel } : {}),
355
381
  ...(row.variants ? { variants: row.variants } : {}),
@@ -474,15 +500,18 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
474
500
  ? { ...initial, allowOut: [...new Set([...(initial.allowOut ?? []), ...seeds.map((seed) => seed.entry)])].sort() }
475
501
  : initial);
476
502
  const FLAT_SCOPES = DENY_SCOPES.filter((scope) => scope.path.length === 3);
477
- const seedChannel = (seed) => ({ adapter: seed.adapter, model: seed.displayModel, ...(seed.identity !== undefined ? { identity: seed.identity } : {}) });
503
+ const seedChannel = (seed) => ({ adapter: seed.adapter, model: seed.model, ...(seed.identity !== undefined ? { identity: seed.identity } : {}) });
478
504
  // an allow-complement entry excludes a seeded channel by the allow form's own matching (adapter id,
479
505
  // model, adapter:model, recorded identity) — the seed's spelling is one such entry, never the only one
480
506
  const seedRow = (seed) => ({
481
507
  adapter: seed.adapter,
482
- models: [seed.displayModel],
483
- ...(seed.identity !== undefined ? { identities: { [seed.displayModel]: seed.identity } } : {}),
508
+ models: [seed.model],
509
+ ...(seed.identity !== undefined ? { identities: { [seed.model]: seed.identity } } : {}),
484
510
  });
485
- const allowExcludes = (staged, seed) => (staged.allowOut ?? []).some((entry) => universeEntryMatches(seedRow(seed), seed.displayModel, entry));
511
+ const allowExcludes = (staged, seed) => (staged.allowOut ?? []).some((entry) => universeEntryMatches(seedRow(seed), seed.model, entry));
512
+ // T5: the real channels a displayed row stands for (fleet's own derivation above); an ungrouped row is one
513
+ const rowMembers = (row) => ("members" in row && row.members)
514
+ || [{ model: ("classifyModel" in row && row.classifyModel) || row.model, ...(row.evidence?.identity !== undefined ? { identity: row.evidence.identity } : {}) }];
486
515
  // membership keeps the channel out: its allow complement or a flat (all-seats) deny entry
487
516
  const excludedIn = (staged, seed) => allowExcludes(staged, seed)
488
517
  || FLAT_SCOPES.some((scope) => (staged[scope.key] ?? []).some((entry) => entryMatchesChannel(entry, seedChannel(seed), true)));
@@ -573,12 +602,9 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
573
602
  // own flat-deny lift: a row the baseline membership kept out that the staged one admits; a lone or
574
603
  // unrelated tier stays user
575
604
  const own = admitted.some((seed) => seed.adapter === adapter && seed.model === model)
576
- || (modelGroups.find((group) => group.adapter === adapter)?.rows ?? [])
577
- .filter((row) => row.model === model || ("classifyModel" in row && row.classifyModel === model))
578
- .some((row) => {
579
- const channel = { adapter, displayModel: row.model, ...(row.evidence?.identity !== undefined ? { identity: row.evidence.identity } : {}) };
580
- return excludedIn(baseline, channel) && !excludedIn(staged, channel);
581
- });
605
+ || (modelGroups.find((group) => group.adapter === adapter)?.rows ?? []).flatMap(rowMembers)
606
+ .filter((member) => member.model === model)
607
+ .some((member) => excludedIn(baseline, { adapter, ...member }) && !excludedIn(staged, { adapter, ...member }));
582
608
  edits.push({ name: `tiers.${adapter}.models.${model}`, destination: own ? family() : "user" });
583
609
  }
584
610
  }
@@ -607,17 +633,22 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
607
633
  // the browser never showed, or whose spelling names no channel, is refused.
608
634
  const editingUniverse = (candidate, seeds) => {
609
635
  const target = universeOf(candidateChannels(candidate));
636
+ // T5 review: the seed's OWN channel must be in the universe — its entry matching another member's
637
+ // recorded identity (C-high recorded as C-low) never stands for an unauthed or unprobed C-low
610
638
  const covers = (seed) => target.some((row) => row.adapter === seed.adapter
611
- && row.models.some((model) => universeEntryMatches(row, model, seed.entry)));
639
+ && row.models.includes(seed.model) && universeEntryMatches(row, seed.model, seed.entry));
612
640
  for (const seed of seeds) {
613
641
  if (covers(seed))
614
642
  continue;
615
- const shown = modelGroups.some((group) => group.adapter === seed.adapter && group.rows.some((row) => row.model === seed.displayModel));
643
+ // T5: a seed names a real member of the row it was staged from — never that row's display label
644
+ const shown = modelGroups.some((group) => group.adapter === seed.adapter
645
+ && group.rows.some((row) => row.model === seed.displayModel && rowMembers(row).some((member) => member.model === seed.model)));
616
646
  const row = target.find((candidateRow) => candidateRow.adapter === seed.adapter);
617
647
  if (shown && row) {
618
- row.models = [...row.models, seed.displayModel];
648
+ if (!row.models.includes(seed.model))
649
+ row.models = [...row.models, seed.model];
619
650
  if (seed.identity !== undefined)
620
- row.identities = { ...row.identities, [seed.displayModel]: seed.identity };
651
+ row.identities = { ...row.identities, [seed.model]: seed.identity };
621
652
  }
622
653
  if (!covers(seed))
623
654
  throw new ConfigError(`the seeded allow entry ${seed.entry} names no channel of the discovered fleet — re-probe with tickmarkr doctor, then classify it again`);
@@ -1234,6 +1265,8 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
1234
1265
  steeringOptionsFor,
1235
1266
  reviewAdvisories,
1236
1267
  reviewOverlay,
1268
+ // B-KEYS r2 (D-1446): the editor's row memo keys on both overlay layers' bytes — previewCfg's own key
1269
+ layerStamp: () => `${currentUserText()}\u0000${currentRepoOverlayText(cwd)}`,
1237
1270
  reloadGuard,
1238
1271
  stagedRouting,
1239
1272
  holderOf,
@@ -1282,24 +1315,36 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
1282
1315
  // and it was not configured at probe time), so it stays unroutable — invisible in every
1283
1316
  // picker — until the next probe. Name the step, or the classify flow reads as broken.
1284
1317
  const unprobed = [];
1318
+ // T5: a grouped classification admits every member's real id, yet a member doctor recorded unauthed
1319
+ // stays unroutable — named, never implied routable by its group's admission
1320
+ const unauthed = [];
1285
1321
  for (const [adapter, models] of Object.entries(write.edited.tiers)) {
1286
1322
  for (const [model, assigned] of Object.entries(models)) {
1287
1323
  if (assigned === null || assigned === undefined)
1288
1324
  continue;
1289
1325
  if (JSON.stringify(write.initial.tiers[adapter]?.[model]) === JSON.stringify(assigned))
1290
1326
  continue;
1291
- if (health[adapter]?.modelAuth?.[model] === undefined)
1327
+ const probed = health[adapter]?.modelAuth?.[model];
1328
+ if (probed === undefined)
1292
1329
  unprobed.push(`${adapter}:${model}`);
1330
+ else if (probed.authed === false)
1331
+ unauthed.push(`${adapter}:${model}`);
1293
1332
  }
1294
1333
  }
1295
1334
  // B2: a repo-shadowed save says what still applies here — never claimed applied
1296
1335
  const head = [`fleet: wrote ${pending.path}`, ...(pending.destination === "user"
1297
1336
  ? repoShadowNotes(result.review.before, result.review.after).map((note) => `fleet: ${note}`)
1298
1337
  : [])].join("\n");
1299
- if (!unprobed.length)
1300
- return head;
1301
- const named = unprobed.slice(0, 3).join(", ") + (unprobed.length > 3 ? `, +${unprobed.length - 3} more` : "");
1302
- return `${head}\nfleet: ${unprobed.length} newly classified model(s) have no probe verdict yet (${named}) — run \`tickmarkr fleet --fresh\` to probe them; unverified models stay unroutable`;
1338
+ const listed = (ids) => ids.slice(0, 3).join(", ") + (ids.length > 3 ? `, +${ids.length - 3} more` : "");
1339
+ return [
1340
+ head,
1341
+ ...(unprobed.length
1342
+ ? [`fleet: ${unprobed.length} newly classified model(s) have no probe verdict yet (${listed(unprobed)}) — run \`tickmarkr fleet --fresh\` to probe them; unverified models stay unroutable`]
1343
+ : []),
1344
+ ...(unauthed.length
1345
+ ? [`fleet: ${unauthed.length} newly classified model(s) are unauthed (${listed(unauthed)}) — re-probe with tickmarkr doctor; unauthed models stay unroutable`]
1346
+ : []),
1347
+ ].join("\n");
1303
1348
  };
1304
1349
  return {
1305
1350
  props,
@@ -130,13 +130,24 @@ you were handed the whole picture — list the repository root, open every guida
130
130
  predate a project rename, so **search by CONCEPT, not by the current product name.** A memory nobody opens
131
131
  is worse than none: every seat assumes the lesson is recorded somewhere and no seat looks.
132
132
  - **The gates are the product.** Seven, defined in \`src/graph/schema.ts\`:
133
- \`build test lint evidence scope acceptance review\`. **That is DECLARATION order, not execution order** —
134
- the first five run as a battery that stops at its first red, then \`acceptance\` and \`review\` run
135
- CONCURRENTLY (\`run-gates.ts:39\`, *"judge ‖ review"*). One repair reorders them: a repair the REVIEW sent
136
- back (carried \`review:material\`, test selection on, no required repair test) runs build, lint, evidence and
137
- scope, then \`acceptance\` ‖ \`review\`, then its test screen, so a review that re-reds an unchanged subject
138
- starts no test at all; a test-red repair keeps the battery first. Either way nothing merges until the FULL
139
- suite has passed on the merge-candidate commit. The first five are MANDATORY; only \`acceptance\` and
133
+ \`build test lint evidence scope acceptance review\`. **That is DECLARATION order, not execution order.**
134
+ The cheap checks run first and stop at their first red: \`build\`, \`lint\`, then \`evidence\` and \`scope\`.
135
+ A fresh or semantic-repair candidate then runs \`acceptance\` ‖ \`review\` CONCURRENTLY (\`run-gates.ts\`,
136
+ *"judge ‖ review"*) before any test payload: a decisive semantic red ends the round with zero diagnostic
137
+ or full test-gate runs (a required acceptance oracle still executes); otherwise ONE full verification job
138
+ follows, with no selected screen. Only an attributed behavioral test-red repair may buy one diagnostic
139
+ first — every required failing file plus conservatively selected tests reaching the diff — admitted only
140
+ on comparable harness timing with ratio <= 0.15 AND estimated cost <= 60000 ms; its behavioral red stops
141
+ the round before semantics, otherwise semantics precede the full job. Disabled, unsupported, over-cap or
142
+ unknown-cost selection skips the diagnostic instead of running a full suite before semantics, and a
143
+ killed diagnostic is never green. A seatless or no-verdict review lets acceptance finish and publish and
144
+ the full job run in-round, and stays unsatisfied until review-only recovery; a seatless acceptance
145
+ cancels its sibling, records the unrun test (\`testOwed\`) and every enabled gate still owed (an unrecorded
146
+ review included), cannot merge and parks infra; an actual cancellation or throw awaits cleanup and leaves
147
+ no mergeable partial set. Test-only verification and an explicit full recheck keep their full scope with
148
+ no invented semantics and no diagnostic. Nothing merges until the FULL suite has passed on the exact
149
+ merge-candidate commit; an exact full green answers only after identity, environment, cleanliness and
150
+ current complete-manifest checks. The first five are MANDATORY; only \`acceptance\` and
140
151
  \`review\` may be omitted per task. Implementations are in \`src/gates/\` — \`baseline.ts\` (build/test/lint,
141
152
  diffed against a recorded baseline so pre-existing failures are forgiven), \`evidence.ts\`, \`scope.ts\`,
142
153
  \`acceptance.ts\` (its judge reads the DIFF and every criterion must cite a changed hunk), \`review.ts\`
@@ -11,7 +11,7 @@ import { DEFAULT_CONFIG, effectiveReviewPolicy, overlayPreferShapes, ROUTING_MOD
11
11
  import { batteryPriority, chainDepth, dispatchWaves, graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
12
12
  import { filesGlob } from "../../graph/files-glob.js";
13
13
  import { renderAcceptanceItem } from "../../graph/schema.js";
14
- import { pendingDaemonApprovalActions, resolveRunMode } from "../../run/daemon.js";
14
+ import { pendingDaemonApprovalActions, replayedReviewerDemotions, resolveRunMode } from "../../run/daemon.js";
15
15
  import { disallowedBy, excludedChannels, exclusionLine, observedSeat, routingEntrySeatLines } from "../../route/preference.js";
16
16
  import { decayWeight, HALF_LIFE_RUNS, staffLedEvidence } from "../../route/profile.js";
17
17
  import { resolvedFloor, route, RoutingError } from "../../route/router.js";
@@ -193,22 +193,20 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
193
193
  .filter((event) => event.event === "gate-result" && event.data.gate === "review" && typeof event.data.reviewer === "string")
194
194
  .map((event) => event.data.reviewer)
195
195
  : [];
196
- // daemon.ts replayedReviewerExclusions: a journaled review-pool-demotion is excluded from every review
197
- // pick of the resumed run this plan previews (reviewer role only).
198
- const replayedReviewerExclusions = [...new Set((matchingEvents ?? [])
199
- .filter((event) => event.event === "review-pool-demotion" && typeof event.data.reviewer === "string")
200
- .map((event) => event.data.reviewer))];
201
- const demotedReviewers = new Set(replayedReviewerExclusions);
196
+ // daemon.ts demotedReviewers: a journaled review-pool-demotion is a SOFT preference in the resumed run
197
+ // this plan previews (ranked last, never excluded; reviewer role only) — never a launch-caused one (v2.6.8 T1).
198
+ const demotedReviewers = replayedReviewerDemotions(matchingEvents ?? []);
202
199
  // daemon.ts demotedChannels: the resumed run's worker exclusions (journal replayExcludedChannels).
203
200
  const runJournal = matchingRunId ? Journal.open(cwd, matchingRunId) : undefined;
204
201
  const demotedWorkers = runJournal?.replayExcludedChannels() ?? new Set();
205
- // run-gates.ts retired (OBS-1025 add.2): seats with two no-verdicts this run are out of every pick, tallied
206
- // as daemon.ts seeds reviewNoVerdicts — noVerdict rows only, and a checkout-proof timeout never strikes.
202
+ // run-gates.ts retired (OBS-1025 add.2): seats with two real no-verdicts this run are out of every pick,
203
+ // tallied as daemon.ts seeds reviewNoVerdicts — noVerdict rows only, and a launch failure (any
204
+ // launchCause, or none) never strikes (v2.6.8 T1).
207
205
  const noVerdicts = new Map();
208
206
  for (const event of matchingEvents ?? []) {
209
207
  if (event.event !== "review-no-verdict" || event.data.noVerdict !== true || typeof event.data.reviewer !== "string")
210
208
  continue;
211
- if (event.data.cause === "seat-launch-failed" && event.data.launchCause === "checkout-proof-timeout")
209
+ if (event.data.cause === "seat-launch-failed")
212
210
  continue;
213
211
  noVerdicts.set(event.data.reviewer, (noVerdicts.get(event.data.reviewer) ?? 0) + 1);
214
212
  }
@@ -412,15 +410,15 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
412
410
  // Never dispatched, daemon.ts subjectAuthors is just the fresh author; its vendor stays off the review.
413
411
  const authors = [channelKey(author)];
414
412
  const authorVendors = carriedAuthorVendors(pools.review, authors);
415
- // Production's inputs as the task's first review round sees them: excludeReviewers = daemon badReviewers =
416
- // replayedReviewerExclusions. run-gates.ts:1303 floors on THIS task's journaled reviewers at their dispatched
417
- // tiers (daemon taskReviewers) ∪ excludeReviewers, so a replayed exclusion RAISES the floor; :1305 picks
418
- // with excludeReviewers ∪ retired. prefer only reorders (the seat count below passes none).
419
- const priorReviewers = [...(matchingEvents ?? []).filter((e) => e.taskId === task.id && typeof e.data.reviewer === "string"
420
- && ((e.event === "gate-result" && e.data.gate === "review") || e.event === "review-no-verdict"))
421
- .map((e) => ({ reviewer: e.data.reviewer, tier: e.data.reviewerTier })), ...replayedReviewerExclusions];
413
+ // Production's inputs as the task's first review round sees them: excludeReviewers = daemon badReviewers,
414
+ // empty at resume (a replayed demotion is soft). run-gates floors on THIS task's journaled reviewers at their
415
+ // dispatched tiers (daemon taskReviewers) ∪ excludeReviewers, and picks with excludeReviewers ∪ retired.
416
+ // prefer only reorders (the seat count below passes none).
417
+ const priorReviewers = (matchingEvents ?? []).filter((e) => e.taskId === task.id && typeof e.data.reviewer === "string"
418
+ && ((e.event === "gate-result" && e.data.gate === "review") || e.event === "review-no-verdict"))
419
+ .map((e) => ({ reviewer: e.data.reviewer, tier: e.data.reviewerTier }));
422
420
  const reviewFloor = gateReviewerFloor(task, cfg, author, pools.review, priorReviewers).floor;
423
- const exclusions = [...replayedReviewerExclusions, ...retiredReviewers];
421
+ const exclusions = retiredReviewers;
424
422
  const reviewer = pickReviewer(author, pools.review, exclusions, cfg.review.prefer ?? [], reviewFloor, reviewHistory, (seat, count) => { rotationSeat = seat; rotationCount = count; }, demotedReviewers, authorVendors, authors);
425
423
  if (!reviewer)
426
424
  return { line: "none — no cross-vendor reviewer available", cost: 0 };
@@ -13,7 +13,7 @@ import { runGates } from "../../gates/run-gates.js";
13
13
  import { getTask, loadGraph } from "../../graph/graph.js";
14
14
  import { GATE_NAMES } from "../../graph/schema.js";
15
15
  import { executionSignal } from "../../run/execution-budget.js";
16
- import { linkNodeModules, removeWorktree, shGit, shGitOk } from "../../run/git.js";
16
+ import { addDetachedWorktree, linkNodeModules, removeWorktree, shGit, shGitOk } from "../../run/git.js";
17
17
  import { foldOwedChecks, integrationMapped, owedCriteria, Journal, OWED_DISCHARGE_EVENT } from "../../run/journal.js";
18
18
  import { withRepositoryLease } from "../../run/lease.js";
19
19
  import { isPidLive } from "../../run/lock.js";
@@ -417,7 +417,9 @@ export async function verify(argv, cwd = process.cwd(), options = {}) {
417
417
  }
418
418
  const baseDir = mkdtempSync(join(stateDir, `base-${mergeBase.slice(0, 12)}-${process.pid}-`));
419
419
  try {
420
- await shGitOk(`git worktree add --detach '${baseDir}' '${mergeBase}'`, cwd);
420
+ const added = await addDetachedWorktree(cwd, baseDir, mergeBase);
421
+ if (added.code !== 0)
422
+ throw new Error(`command failed (${added.code}): git worktree add --detach '${baseDir}' '${mergeBase}'\n${added.stderr || added.stdout}`);
421
423
  linkNodeModules(cwd, baseDir, { force: true });
422
424
  const baseline = await captureBaseline(baseDir, subset);
423
425
  if (baseline.refusal)
@@ -186,6 +186,7 @@ export declare const COMMAND_HELP: {
186
186
  "--pct <0..100>": string;
187
187
  "--threshold-pct <0..100>": string;
188
188
  "--stand-down": string;
189
+ "--status": string;
189
190
  };
190
191
  examples: string[];
191
192
  };
package/dist/cli/help.js CHANGED
@@ -148,17 +148,25 @@ export const COMMAND_HELP = {
148
148
  ],
149
149
  },
150
150
  beat: {
151
- usage: "beat <orchestrator|orchestrator-context|overseer|overseer-context|watch> --seat <identity> [options]",
152
- description: "Write one supervision beat using the recorded arm. Stood-down arms stay DISARMED until explicitly re-armed; stopped beats become STALE.",
151
+ usage: "beat <start|stop|status> <orchestrator|orchestrator-context|overseer|overseer-context|watch> --seat <identity> | beat <tier> --seat <identity> [options]",
152
+ description: "start launches one detached --loop beat per tier and exits 0 only after reading back that child's own advancing beat; stop retires only that recorded generation and reads back DISARMED; status is read-only and exits nonzero for STALE, MISMATCH, UNREADABLE or a dead invocation's leftovers. The legacy <tier> form writes one supervision beat using the recorded arm: stood-down arms stay DISARMED until explicitly re-armed; stopped beats become STALE. " +
153
+ "Legacy migration: start refuses a legacy (unowned) ARMED/STALE arm nonzero and prints its exit under the RECORDED seat — `tickmarkr beat <tier> --seat <seat> --stand-down` (`--seat=<seat>` when the seat begins with a dash), after \"stop pid <N> first\" when its --loop writer is still alive (tickmarkr never signals a process it did not launch, nor takes its live claim; an arm with no recorded seat gets no invented one) — then start owns the tier and reads back ARMED. A writer stopped inside its claimed tick does not strand that step: the --stand-down reclaims the claim it died holding, printing the notice; while the writer lives, the step waits and refuses BUSY. " +
154
+ "Claims — actors IN: compliant claim contenders (start, stop and each legacy write — one-shot, --new-arm, --loop tick, --stand-down — through this CLI), a crashed owner (holder pid ESRCH or a zombie, its claim and records left behind) and a killed compliant recovery or release taker (dead at any step of the one claim-removal path); OUT, the one residual: an external writer editing, replacing or deleting claim, lock or record files outside this CLI, under which nothing beyond the identity MISMATCH refusals is promised (its edit between a final claim check and the mutation's own system call is not seen — there is no filesystem compare-and-rename). A claim records its holder pid and birth (its runtime's own start record, read with no process probe) and, for a launch claim, the launched writer publishing under it. Every claimant takes over a held claim only when every pid read from that claim's own bytes is confirmed dead (ESRCH or a zombie) — the legacy {tier, token, pid, claimedAt} claim included — printing the reclaim notice with its final result even when it then had to wait or refuse; a live, reused or unreadable holder or writer is never taken over: start tries once, every other claimant waits at most 20 attempts 250 ms apart, then refuses BUSY with nothing written. A launched writer acts under its launcher's claim only while that launcher is not confirmed dead, recording itself as the claim's writer before its first act; once the launcher is dead, a writer past its first beat releases that claim itself (with a notice) and takes the tier claim like any writer — or, when that launcher died before binding its arm epoch, exits right after the release, leaving an abandoned launch the next start, stop or legacy write retires — and one still launching refuses having written nothing; a killed start neither strands stop nor lets recovery take the claim from a live writer. Under its launcher's claim the writer checks nothing after its last act (each act verified the claim last), so start's release after its read-back never ends the loop it launched. A claim is removed in one way only, by recovery and by its holder's release alike: under a per-claim removal lock that appears already naming its taker's pid and birth, once no claimed act is running under those bytes (a release waits up to 2 s, a recovery refuses BUSY), the checked bytes are moved aside into the lock and kept only when they are exactly those bytes — other bytes found there are put straight back, and a claim linked while that removal is under way is never acted under: its claimant withdraws exactly the claim it linked and refuses BUSY (its next attempt links afresh). Claim bytes an invocation could not remove — another live taker held them or moved them aside, even on a mistaken death proof, or its release was refused — stay provably that process's strand wherever they are (its pid, its checked birth and a token none of its invocations still holds): while a live taker holds them every claimant is BUSY naming it; once none does, that process's own next start, stop or legacy write withdraws them from the claim path, or discards them from a killed taker's lock instead of restoring them, with a notice; its own status reports them as its stranded claim wherever they are — on the claim path or moved aside into a live or dead taker's lock, naming that taker — with the recovery command (to run once a live taker has finished or exited); and once that process has exited they are a dead claim any start, stop or legacy write reclaims — so no caller, a long-lived bridge included, needs to exit before recovery completes, and no claim is left that nobody acts under and nobody can recover. Killed-taker recovery: a lock whose taker is confirmed dead is recovered by the next start, stop or legacy write with a notice naming that taker — bytes it moved aside are discarded when every pid they record is dead, otherwise restored — and that invocation also removes the act markers, <tier>.stage.<pid>.<id> directories and the pid-named entry a taker killed while assembling or releasing its lock (before its owner entry is written, or after it is moved out) left, each with a notice naming the dead pid; a live or unprovable taker's lock is never taken (BUSY, naming it). \"Never taken over\" therefore means: only a claim or lock proven dead is reclaimed, exactly as checked; nothing live or unknown is taken, and no claim, lock or stage directory is ever the operator's to remove by hand. Dead owners recover the same way: a generation whose recorded pid is confirmed dead, or a launch abandoned before it bound its pid or arm epoch (no launch holds the claim and no recorded process is alive), is retired — its owner record removed, nothing signalled — by the next stop (which stands its arm down and reads back DISARMED), by start (which stands it down, then launches and reads back ARMED) or by a legacy write from that seat (whose requested write then proceeds: a one-shot records its beat on that arm, --stand-down stands it down, --new-arm/--loop replace it), each printing the recovery; an owner whose recorded process lives is never retired this way. Every claimed mutation — each owner record rename, log open, spawn, signal and removal, and each record a staged stand-down, arm or beat write changed — is one claimed act: it marks the claim, re-runs its owner-generation check inside the act (a staged write also compares every supervision record with what it was staged from), then verifies the claim LAST, immediately before the mutation. No claim removal lands inside an act, and a claim, owner or arm replaced before that final check lands nothing further: nothing more is written, signalled or removed and the invocation refuses nonzero at its next check, its release included (a launched writer under its launcher's claim has no release: what its acts landed, they landed under the verified claim). Status (beat status <tier> and the legacy <tier> --status) only observes: over a dead claim or a killed taker's or writer's leftovers it changes no file, exits nonzero naming the dead pids and prints the command that recovers them, and it names every live holder (the claim's, and each removal lock's taker) beside the state it reads, `tickmarkr beat stop <tier> --seat <seat>` (the recorded seat, else the one given, quoted as one shell word) — start or a legacy write recovers the same way; status never does. " +
155
+ "Identity: Linux teardown argv ([comm]) and a child dying mid-read stay UNREADABLE until a complete read or confirmed death (\"died before its beat read back\"); on Darwin a missing or unusable lsof refuses UNREADABLE naming lsof, and restoring lsof restores start/status/stop.",
153
156
  options: {
154
- "--seat <identity>": "Required: identify the supervising pane or agent.",
157
+ "--seat <identity>": "Required for start, stop and every write: identify the supervising pane or agent; optional for status.",
155
158
  "--new-arm": "Create a new durable arm, allowing beats to resume after stand-down; the hand-off step for a seat taking over a stood-down tier (combine with --arm-id and --pct on a context tier).",
156
- "--loop": "Create a new durable arm and beat in this process every 10 seconds; exit within one interval after stand-down.",
159
+ "--loop": "Create a new durable arm and beat in this process every 10 seconds; exit nonzero with its reason within one interval after stand-down, supersession or a refused write.",
157
160
  "--arm-id <identity>": "Name a newly created arm; with --pct, identify the context observation arm. Never refuses a tick.",
158
161
  "--pct <0..100>": "Report current context consumption percentage.",
159
162
  "--threshold-pct <0..100>": "Set the context warning threshold (default 75).",
160
- "--stand-down": "Record an explicit handoff and stand down this tier. A later one-shot tick on the old arm exits non-zero; context observations still raise and discharge the clear duty.",
161
- }, examples: ["beat overseer --seat supervisor", "beat overseer --seat supervisor --loop", "beat overseer --seat supervisor --stand-down", "beat overseer --seat supervisor --new-arm"],
163
+ "--stand-down": "Record an explicit handoff and stand down this tier. A later one-shot tick on the old arm exits non-zero; context observations still raise and discharge the clear duty. With the recorded seat it is the legacy migration step start prints — it reclaims a claim the stopped writer died holding, never a live one; then start owns the tier.",
164
+ "--status": "Legacy spelling of beat status <tier>: the same read-only result, writing nothing.",
165
+ }, examples: [
166
+ "beat start overseer --seat supervisor", "beat status overseer --seat supervisor", "beat stop overseer --seat supervisor",
167
+ "beat overseer --seat supervisor", "beat overseer --seat supervisor --loop", "beat overseer --seat supervisor --stand-down",
168
+ "beat overseer --seat supervisor --new-arm", "beat overseer --status",
169
+ ],
162
170
  },
163
171
  version: {
164
172
  usage: "version [--dist]", description: "Print the installed package version. Top-level aliases: --version and -v.",
@@ -45,7 +45,7 @@ export declare const COMMANDS: {
45
45
  eval: typeof evalCommand;
46
46
  };
47
47
  export type RegisteredCommand = keyof typeof COMMANDS;
48
- 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 (--fix writes the test-runner ignore when a safe edit exists)\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> preview locally with --preview; draft after confirmation or --yes (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n eval discover and validate fixtures, seed isolated temp repos, then clean them up\n run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)\n status live run state (--watch --events: JSON documents on stdout, keepalives on stderr; 2>&1 corrupts the stream)\n stats all-run channel delivery, red, rescue, author and reviewer statistics\n verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD \u2014 verdict/JSON on stdout, progress on stderr; 2>&1 corrupts the verdict stream (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md writes Markdown to stdout; redirect to save a record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open Home, Run or Evidence (--view home|run|evidence; --setup <id> opens Run Parks)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n beat <tier> record one supervision beat for orchestrator|orchestrator-context|overseer|overseer-context|watch, --seat <identity> required (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop\n approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume\n version print the installed version (--dist adds build location and fingerprint)\n\nUse tickmarkr <command> --help (or -h) for all options and examples.\nUse tickmarkr profile <operation> --help for nested profile help.";
48
+ 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 (--fix writes the test-runner ignore when a safe edit exists)\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> preview locally with --preview; draft after confirmation or --yes (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n eval discover and validate fixtures, seed isolated temp repos, then clean them up\n run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)\n status live run state (--watch --events: JSON documents on stdout, keepalives on stderr; 2>&1 corrupts the stream)\n stats all-run channel delivery, red, rescue, author and reviewer statistics\n verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD \u2014 verdict/JSON on stdout, progress on stderr; 2>&1 corrupts the verdict stream (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md writes Markdown to stdout; redirect to save a record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open Home, Run or Evidence (--view home|run|evidence; --setup <id> opens Run Parks)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n beat <tier> supervision beats for orchestrator|orchestrator-context|overseer|overseer-context|watch, --seat <identity> required: beat start|stop|status <tier> owns one detached loop per tier (status is read-only); the legacy beat <tier> records one beat (--stand-down to hand off), and status reads the tier STALE once the beats stop\n approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume\n version print the installed version (--dist adds build location and fingerprint)\n\nUse tickmarkr <command> --help (or -h) for all options and examples.\nUse tickmarkr profile <operation> --help for nested profile help.";
49
49
  export declare function dispatch(cmd: string | undefined, argv: string[], commands?: CommandMap): Promise<{
50
50
  out: string;
51
51
  code: number;
package/dist/cli/index.js CHANGED
@@ -46,7 +46,7 @@ usage: tickmarkr <command>
46
46
  profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)
47
47
  ui open Home, Run or Evidence (--view home|run|evidence; --setup <id> opens Run Parks)
48
48
  unlock remove a stale/garbage run lock (refuses if the holder is alive)
49
- beat <tier> record one supervision beat for orchestrator|orchestrator-context|overseer|overseer-context|watch, --seat <identity> required (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop
49
+ beat <tier> supervision beats for orchestrator|orchestrator-context|overseer|overseer-context|watch, --seat <identity> required: beat start|stop|status <tier> owns one detached loop per tier (status is read-only); the legacy beat <tier> records one beat (--stand-down to hand off), and status reads the tier STALE once the beats stop
50
50
  approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume
51
51
  version print the installed version (--dist adds build location and fingerprint)
52
52
 
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { declaredInputBoxForWorkerName, matchesEmptyInputBox, matchesInputBox, matchesOccupiedInputBox, missingInputStateDeclarations, shq } from "../adapters/types.js";
6
6
  import { consumePaneLaunchIntent, PANE_IDENTITY_ENV, paneIdentityLine } from "../brand.js";
7
- import { createWorktree, sh } from "../run/git.js";
7
+ import { createWorktree, sh, shGit } from "../run/git.js";
8
8
  import { Journal } from "../run/journal.js";
9
9
  import { readSupervision, readWatchBoard, requestWatchBoardStop, reserveWatchBoard, stopWatchBoard, WATCH_OWNER_ENV } from "../run/supervision.js";
10
10
  import { canonicalWorktreePath } from "./orca.js";
@@ -487,7 +487,7 @@ export class HerdrDriver {
487
487
  if (typeof pane.cwd !== "string" || canonicalWorktreePath(pane.cwd) !== checkout) {
488
488
  throw new Error(`herdr adopt: pane ${pane.pane_id} labelled ${slot.name} sits in ${pane.cwd ?? "an unreported cwd"}, not the task checkout ${checkout} — foreign`);
489
489
  }
490
- const top = await sh("git rev-parse --show-toplevel", checkout);
490
+ const top = await shGit("git rev-parse --show-toplevel", checkout);
491
491
  if (top.code !== 0 || canonicalWorktreePath(top.stdout.trim()) !== checkout) {
492
492
  throw new Error(`herdr adopt: ${checkout} is not its own git checkout — no checkout proof for ${slot.name}`);
493
493
  }
@@ -32,10 +32,13 @@ export declare class OrcaError extends Error {
32
32
  readonly code?: string;
33
33
  readonly runtimeId?: string;
34
34
  readonly launchCause?: SeatLaunchCause;
35
+ /** v2.6.8 T1: the CLI was killed at the timeout it was handed — the one evidence that a deadline interrupted it. */
36
+ readonly timedOut?: boolean;
35
37
  constructor(family: string, reason: string, raw: string, opts?: {
36
38
  code?: string;
37
39
  runtimeId?: string;
38
40
  launchCause?: SeatLaunchCause;
41
+ timedOut?: boolean;
39
42
  });
40
43
  }
41
44
  /** The slot cannot be addressed: dead/unknown terminal record, or a handle that cannot be recovered
@@ -86,9 +89,12 @@ export declare function canonicalWorktreePath(path: string): string;
86
89
  /** The proof line a worker terminal prints first: create, recovery and focus read it back. */
87
90
  export declare const CHECKOUT_MARK = "TICKMARKR_CHECKOUT";
88
91
  /** E1: the one typed launch cause a driver attaches to its launch failure. `checkout-proof-timeout`
89
- * means the terminal printed NO proof frame by the ceiling — a slow host, not a misplaced seat. A
90
- * foreign or malformed frame is placement evidence and carries no cause (never a timeout exemption). */
92
+ * means the terminal printed NO proof frame by the ceiling — or (v2.6.8 T1) a read the ceiling
93
+ * interrupted left the own frame's prefix unfinished — a slow host, not a misplaced seat. A complete
94
+ * foreign frame, or a malformed one read whole (in a page landing late or at the ceiling too), is
95
+ * placement evidence and carries no cause. */
91
96
  export type SeatLaunchCause = "checkout-proof-timeout";
97
+ export declare function withLaunchGuard<T>(guard: () => void, run: () => Promise<T>): Promise<T>;
92
98
  /** The exact bytes the create command prints as its first line. */
93
99
  export declare function checkoutProofLine(checkout: string): string;
94
100
  /** Every complete frame in a scrollback, decoded and canonicalized; whether an incomplete one was seen. */
@@ -266,12 +272,14 @@ export declare class OrcaDriver implements ExecutorDriver {
266
272
  * the terminal record must name `handle` and `_meta.runtimeId` must be `runtimeId` — the runtime
267
273
  * that supplied the ownership listing — else another terminal's or another runtime's bytes were
268
274
  * answered and nothing is proven. Proven means provesCheckout: exact canonical full-path equality
269
- * of a complete frame, no other checkout named, no incomplete frame.
275
+ * of a complete frame, no other checkout named, no incomplete frame or unparseable mark — in the pages
276
+ * or the anchor's tail (v2.6.8 T1).
270
277
  * E1: with a `deadline` (create's window) there is ONE absolute deadline: a read is issued only while
271
278
  * budget is left and is handed exactly that budget, and a page that still lands past it is late —
272
279
  * nothing late proves the checkout. `seen` is what the scrollback showed, for the typed timeout:
273
280
  * `misplaced` — a frame naming another checkout, a cut frame or an unparseable mark in anything read
274
- * (the anchor's tail and every page included, even when paging stopped or a later read threw);
281
+ * (the anchor's tail and every page included, even when paging stopped, a later read threw or the page's
282
+ * own cursors were malformed — v2.6.8 T1; the tail read after the pages it overlaps, tailInContext);
275
283
  * `absent` — ONLY when every page down to the start of the scrollback was read before the ceiling
276
284
  * and none carries a trace of the mark; `unread` — every other exit: PROOF_PAGES reached, a limited
277
285
  * read with no (or the same) next cursor or a truncated anchor without one, a read that threw, a read