tickmarkr 2.6.0 → 2.6.2

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 (103) hide show
  1. package/README.md +14 -3
  2. package/dist/adapters/catalog-remote.js +89 -47
  3. package/dist/adapters/claude-code.js +9 -6
  4. package/dist/adapters/codex.js +7 -4
  5. package/dist/adapters/prompt.d.ts +3 -1
  6. package/dist/adapters/prompt.js +21 -3
  7. package/dist/adapters/registry.js +3 -3
  8. package/dist/adapters/types.d.ts +13 -4
  9. package/dist/adapters/types.js +16 -0
  10. package/dist/cli/commands/approve.d.ts +5 -1
  11. package/dist/cli/commands/approve.js +66 -23
  12. package/dist/cli/commands/compile.js +13 -3
  13. package/dist/cli/commands/doctor.d.ts +2 -0
  14. package/dist/cli/commands/doctor.js +11 -3
  15. package/dist/cli/commands/fleet.js +71 -8
  16. package/dist/cli/commands/plan.js +7 -3
  17. package/dist/cli/commands/report.js +18 -2
  18. package/dist/cli/commands/resume.js +4 -2
  19. package/dist/cli/commands/status.js +37 -22
  20. package/dist/cli/help.d.ts +4 -0
  21. package/dist/cli/help.js +11 -2
  22. package/dist/config/config.d.ts +20 -0
  23. package/dist/config/config.js +47 -8
  24. package/dist/config/fleet-overlay.d.ts +1 -0
  25. package/dist/config/fleet-overlay.js +56 -0
  26. package/dist/drivers/orca.d.ts +26 -1
  27. package/dist/drivers/orca.js +199 -61
  28. package/dist/eval/canary.d.ts +2 -1
  29. package/dist/eval/canary.js +2 -2
  30. package/dist/eval/dispatch.js +1 -0
  31. package/dist/gates/acceptance.d.ts +2 -1
  32. package/dist/gates/acceptance.js +7 -2
  33. package/dist/gates/baseline.d.ts +12 -1
  34. package/dist/gates/baseline.js +11 -4
  35. package/dist/gates/cache.d.ts +3 -1
  36. package/dist/gates/cache.js +10 -3
  37. package/dist/gates/llm.d.ts +5 -4
  38. package/dist/gates/llm.js +17 -14
  39. package/dist/gates/review.d.ts +8 -0
  40. package/dist/gates/review.js +47 -15
  41. package/dist/gates/run-gates.d.ts +6 -1
  42. package/dist/gates/run-gates.js +34 -17
  43. package/dist/gates/test-manifest.d.ts +14 -0
  44. package/dist/gates/test-manifest.js +33 -6
  45. package/dist/graph/graph.d.ts +6 -2
  46. package/dist/graph/graph.js +15 -4
  47. package/dist/graph/schema.d.ts +2 -0
  48. package/dist/graph/schema.js +2 -0
  49. package/dist/plan/scope.js +2 -2
  50. package/dist/route/preference.d.ts +20 -2
  51. package/dist/route/preference.js +48 -13
  52. package/dist/route/role-pick.d.ts +16 -0
  53. package/dist/route/role-pick.js +15 -0
  54. package/dist/route/router.d.ts +14 -0
  55. package/dist/route/router.js +39 -16
  56. package/dist/run/consult.d.ts +13 -1
  57. package/dist/run/consult.js +19 -14
  58. package/dist/run/daemon.d.ts +46 -2
  59. package/dist/run/daemon.js +964 -190
  60. package/dist/run/git.d.ts +44 -1
  61. package/dist/run/git.js +103 -5
  62. package/dist/run/host-health.d.ts +20 -0
  63. package/dist/run/host-health.js +64 -0
  64. package/dist/run/journal.d.ts +126 -3
  65. package/dist/run/journal.js +418 -36
  66. package/dist/run/merge.d.ts +3 -1
  67. package/dist/run/merge.js +3 -2
  68. package/dist/run/operator-state.d.ts +24 -2
  69. package/dist/run/operator-state.js +41 -5
  70. package/dist/run/operator-summary.d.ts +3 -0
  71. package/dist/run/operator-summary.js +3 -1
  72. package/dist/run/protocol.d.ts +31 -1
  73. package/dist/run/protocol.js +3 -1
  74. package/dist/run/stall.d.ts +38 -2
  75. package/dist/run/stall.js +276 -6
  76. package/dist/run/supervision.d.ts +7 -1
  77. package/dist/run/supervision.js +5 -2
  78. package/dist/tui/cockpit/board.d.ts +1 -1
  79. package/dist/tui/cockpit/board.js +30 -22
  80. package/dist/tui/cockpit/decision-actions.d.ts +8 -5
  81. package/dist/tui/cockpit/decision-actions.js +55 -32
  82. package/dist/tui/cockpit/derive.d.ts +2 -0
  83. package/dist/tui/cockpit/derive.js +17 -2
  84. package/dist/tui/cockpit/live-runtime.d.ts +10 -0
  85. package/dist/tui/cockpit/live-runtime.js +50 -3
  86. package/dist/tui/cockpit/live-store.d.ts +1 -0
  87. package/dist/tui/cockpit/live-store.js +31 -8
  88. package/dist/tui/cockpit/run-cockpit.d.ts +3 -0
  89. package/dist/tui/cockpit/run-cockpit.js +28 -2
  90. package/dist/tui/cockpit/run-view.d.ts +11 -7
  91. package/dist/tui/cockpit/run-view.js +69 -15
  92. package/dist/tui/cockpit/setup-cockpit.d.ts +4 -0
  93. package/dist/tui/cockpit/setup-cockpit.js +6 -3
  94. package/dist/tui/ink/fleet-app.d.ts +15 -3
  95. package/dist/tui/ink/fleet-app.js +91 -22
  96. package/package.json +2 -1
  97. package/schema/config.schema.json +818 -0
  98. package/skills/tickmarkr-loop/SKILL.md +8 -2
  99. package/skills/tickmarkr-overseer/SKILL.md +85 -6
  100. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +88 -0
  101. package/skills/tickmarkr-overseer/scripts/context-statusline.sh +81 -0
  102. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +36 -34
  103. package/skills/tickmarkr-overseer/scripts/watch-journal.sh +6 -4
@@ -8,10 +8,11 @@ import { formatOwnedName, parseOwnedName, } from "../../drivers/types.js";
8
8
  import { blockedTasks, graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
9
9
  import { GATE_NAMES } from "../../graph/schema.js";
10
10
  import { projectActivity } from "../../run/activity.js";
11
+ import { authorsNote, doneAuthors } from "../../run/operator-state.js";
11
12
  import { projectOperatorSummary } from "../../run/operator-summary.js";
12
13
  import { trackJournalRows } from "../../run/protocol.js";
13
14
  import { newestPark, permittedDecisionVerbs } from "./approve.js";
14
- import { Journal, formatJournalNarration, engagementComparable, isQualityFailureParkKind, parseRunId, preservedRefsByTask, recordedTaskFailureKind, runHasEnded, upheldFeedbackByTask, } from "../../run/journal.js";
15
+ import { Journal, bindingToken, effectiveEvents, formatJournalNarration, parseJournalText, physicalLine, engagementComparable, isQualityFailureParkKind, parseRunId, preservedRefsByTask, recordedTaskFailureKind, runHasEnded, upheldFeedbackByTask, } from "../../run/journal.js";
15
16
  import { isPidLive, runLockRunId, runStatusLine } from "../../run/lock.js";
16
17
  import { normalizeGateOutcome } from "../../run/outcome.js";
17
18
  import { desiredPanes } from "../../run/reconcile.js";
@@ -166,7 +167,7 @@ export const decisionEventsFromJournal = (events, runId, stateDir = ".tickmarkr"
166
167
  ...base,
167
168
  type: "human-decision-required",
168
169
  tier: "decision",
169
- approvalCommand: `tickmarkr approve ${runId} ${event.taskId}`,
170
+ approvalCommand: `tickmarkr approve ${runId} ${event.taskId} --park ${bindingToken({ line: physicalLine(events, index), ts: event.ts })}`,
170
171
  ...(typeof event.data.kind === "string" ? { kind: event.data.kind } : {}),
171
172
  ...(typeof event.data.reason === "string" ? { reason: event.data.reason } : {}),
172
173
  }];
@@ -840,18 +841,6 @@ const statusEngagement = (events, loadedHash) => {
840
841
  }
841
842
  return { comparable: true };
842
843
  };
843
- // The journal's own reader rule (src/run/journal.ts readJsonl), applied to bytes already in hand:
844
- // skip blanks, drop a line that will not parse (a torn trailing write after a crash), keep the rest.
845
- const parseJournalSnapshot = (raw) => raw.split("\n").flatMap((line) => {
846
- if (!line.trim())
847
- return [];
848
- try {
849
- return [JSON.parse(line)];
850
- }
851
- catch {
852
- return [];
853
- }
854
- });
855
844
  // runLockOwner is the single lock payload/liveness reader. Its current path helper also initializes
856
845
  // .tickmarkr metadata; an engine-written lock necessarily passed through that initializer already, so
857
846
  // status avoids invoking it in stripped reader-purity fixtures where no engine lock can exist.
@@ -891,7 +880,7 @@ const readRunRecord = (cwd, graph, namedRunId) => {
891
880
  if (!runId)
892
881
  return undefined;
893
882
  const raw = readRunJournalRaw(cwd, runId, lockedRunId === runId);
894
- const events = parseJournalSnapshot(raw);
883
+ const events = parseJournalText(raw);
895
884
  // The resume comparator is the fail-closed baseline; a matching graph-rehash is the daemon's
896
885
  // append-only audit that authorizes this status replay after stop-amend-resume.
897
886
  const engagement = statusEngagement(events, graphDefinitionHash(graph));
@@ -917,7 +906,8 @@ const recordTaskRows = (record, graph, isDaemonAlive) => record.comparable && re
917
906
  const recordProjection = (record, graph, isDaemonAlive) => {
918
907
  const rows = record ? recordTaskRows(record, graph, isDaemonAlive) : new Map();
919
908
  const events = record?.comparable ? record.events : [];
920
- const activity = projectActivity(record?.runId ?? "", trackJournalRows(record?.runId ?? "", events.map((raw, sourceIndex) => ({ raw, sourceIndex }))), graph.tasks);
909
+ // OBS-1178: a refused or unsound decision released nothing, so the activity fold never reads it.
910
+ const activity = projectActivity(record?.runId ?? "", trackJournalRows(record?.runId ?? "", effectiveEvents(events).map((raw, sourceIndex) => ({ raw, sourceIndex }))), graph.tasks);
921
911
  const tasks = graph.tasks.map(task => {
922
912
  const row = rows.get(task.id);
923
913
  const evidence = events.filter(event => event.taskId === task.id);
@@ -988,6 +978,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
988
978
  const comparable = record?.comparable ?? false;
989
979
  const rehashAt = record?.rehashAt;
990
980
  const assignments = new Map();
981
+ const authorsByTask = new Map();
991
982
  const contexts = new Map();
992
983
  // v1.53 T5: this run is dead — a newer run replaced it
993
984
  const supersededBy = [...events].reverse()
@@ -1000,6 +991,11 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1000
991
  assignments.set(e.taskId, `${a.adapter}:${a.model}`);
1001
992
  }
1002
993
  }
994
+ if (e.event === "task-done" && e.taskId) {
995
+ const authors = doneAuthors(e.data);
996
+ if (authors)
997
+ authorsByTask.set(e.taskId, authors);
998
+ }
1003
999
  if (e.event === "context-sample" && e.taskId && typeof e.data.tokens === "number" && Number.isFinite(e.data.tokens)) {
1004
1000
  contexts.set(e.taskId, e.data.tokens); // last write wins
1005
1001
  }
@@ -1061,12 +1057,28 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1061
1057
  // OBS-738: recovery facts stay on the journal's two established reducers. The prefix fold keeps an
1062
1058
  // older journal equally readable by asking upheldFeedbackByTask what was active at that exact
1063
1059
  // restore boundary; current resume-restore rows additionally carry the same task for narration.
1060
+ // OBS-1178: both folds read decisions through the one decision fold over the whole journal.
1061
+ const decided = effectiveEvents(events);
1064
1062
  const preservedRefs = preservedRefsByTask(events);
1065
- const restoredUpheldFeedback = new Set(events.flatMap((event, index) => event.event === "resume-restore" && event.taskId
1066
- && upheldFeedbackByTask(events.slice(0, index)).has(event.taskId)
1063
+ const restoredUpheldFeedback = new Set(decided.flatMap((event, index) => event.event === "resume-restore" && event.taskId
1064
+ && upheldFeedbackByTask(decided.slice(0, index)).has(event.taskId)
1067
1065
  ? [event.taskId]
1068
1066
  : []));
1067
+ // OBS-1178: a failed task's newest task-failed row is the token its recheck binds to. Only an
1068
+ // effective decision consumes it — a refused or unsound recheck leaves the task failed and the token shown.
1069
+ const failureTokens = new Map();
1070
+ decided.forEach((event, index) => {
1071
+ if (!event.taskId || !["task-dispatch", "task-done", "task-failed", "task-human", "task-approved"].includes(event.event))
1072
+ return;
1073
+ if (event.event === "task-failed")
1074
+ failureTokens.set(event.taskId, bindingToken({ line: physicalLine(decided, index), ts: event.ts }));
1075
+ else
1076
+ failureTokens.delete(event.taskId);
1077
+ });
1069
1078
  const recoveryLinesForTask = (taskId) => [
1079
+ ...(failureTokens.has(taskId) && runId
1080
+ ? [`failed — ${taskId} — failure ${failureTokens.get(taskId)} — re-gate landed work with \`tickmarkr approve ${runId} ${taskId} --recheck --park ${failureTokens.get(taskId)}\``]
1081
+ : []),
1070
1082
  ...(preservedRefs.get(taskId) ?? []).flatMap(({ ref, diffCommand }) => [
1071
1083
  `preserved worktree — ${taskId} — ${ref}`,
1072
1084
  ` ${diffCommand}`,
@@ -1106,12 +1118,14 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1106
1118
  const label = isStarved ? " starved" : phrase ? ` ${phrase}` : "";
1107
1119
  const channel = journalRowsOnly ? folded?.actor ?? "-" : assignments.get(t.id) ?? "-";
1108
1120
  const ctx = contexts.get(t.id);
1109
- const assignCol = ctx !== undefined ? `${channel}${divider}ctx ${ctx}` : channel;
1121
+ // Merged authors are their own fact beside the last dispatch: a carry-only seat authored nothing.
1122
+ const authors = authorsNote(authorsByTask.get(t.id), channel);
1123
+ const assignCol = [channel, ctx !== undefined ? `ctx ${ctx}` : "", authors ?? ""].filter(Boolean).join(divider);
1110
1124
  const gates = comparable
1111
1125
  ? gateSnapshot(t, events, rehashAt)
1112
1126
  : { states: defaultGateStates(t), priorGraph: false };
1113
1127
  const pane = panes.get(t.id);
1114
- return { t, st, merged, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, livePhase, pane, summary, ...gates };
1128
+ return { t, st, merged, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, authors, livePhase, pane, summary, ...gates };
1115
1129
  });
1116
1130
  if (!unicode) {
1117
1131
  // Keep the machine task-title columns intact; projection details occupy their own line.
@@ -1215,7 +1229,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1215
1229
  }).join("");
1216
1230
  const gateHeader = GATE_NAMES.map((gate) => fitCells(gate.slice(0, 2), 3)).join("");
1217
1231
  const noteFor = (cell) => {
1218
- const { t, st, merged, failureKind, redTier, states, priorGraph, isStarved, phrase, ctx, livePhase, pane } = cell;
1232
+ const { t, st, merged, failureKind, redTier, states, priorGraph, isStarved, phrase, ctx, livePhase, pane, authors } = cell;
1219
1233
  const effort = effortByTask.get(t.id);
1220
1234
  const blocking = graphTaskStatus(st, t.status) === "done" ? [] : dependents.get(t.id) ?? [];
1221
1235
  const failed = failedGates(states);
@@ -1242,6 +1256,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1242
1256
  blocking.length ? `blocks ${blocking.join(", ")}` : "",
1243
1257
  pane ? `pane ${pane}` : "",
1244
1258
  ctx !== undefined ? `ctx ${ctx}` : "",
1259
+ authors ?? "",
1245
1260
  ].filter(Boolean).join(" · ");
1246
1261
  };
1247
1262
  const staleWorker = (cell) => cell.livePhase?.phase === "worker"
@@ -1491,7 +1506,7 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1491
1506
  decisionRunId = runId;
1492
1507
  journalCursor = 0;
1493
1508
  }
1494
- const journalEvents = parseJournalSnapshot(readRunJournalRaw(cwd, runId, lockedRunId === runId));
1509
+ const journalEvents = parseJournalText(readRunJournalRaw(cwd, runId, lockedRunId === runId));
1495
1510
  if (journalEvents.length < journalCursor)
1496
1511
  journalCursor = 0;
1497
1512
  const fresh = decisionEventsFromJournal(journalEvents, runId, stateDirName(cwd))
@@ -39,6 +39,8 @@ export declare const COMMAND_HELP: {
39
39
  usage: string;
40
40
  description: string;
41
41
  options: {
42
+ "--pick <role>": string;
43
+ "--exclude-vendor <vendor>": string;
42
44
  "--print": string;
43
45
  "--why": string;
44
46
  "--global-dir <path>": string;
@@ -161,6 +163,8 @@ export declare const COMMAND_HELP: {
161
163
  usage: string;
162
164
  description: string;
163
165
  options: {
166
+ "--park <line>@<ts>": string;
167
+ "--gate <gate>": string;
164
168
  "--files <glob,\u2026>": string;
165
169
  "--by <name>": string;
166
170
  "--reason <text>": string;
package/dist/cli/help.js CHANGED
@@ -30,6 +30,8 @@ export const COMMAND_HELP = {
30
30
  fleet: {
31
31
  usage: "fleet [options]", description: "Edit fleet configuration interactively, or print it for scripts.",
32
32
  options: {
33
+ "--pick <role>": "Resolve one role preference as JSON without launching a seat (review or consult; roles without prefer refuse).",
34
+ "--exclude-vendor <vendor>": "Exclude a vendor from --pick; repeat for multiple vendors.",
33
35
  "--print": "Print fleet configuration without opening the editor.",
34
36
  "--why": "Include routing eligibility explanations in printed output.",
35
37
  "--global-dir <path>": "Use this global configuration directory.",
@@ -129,14 +131,21 @@ export const COMMAND_HELP = {
129
131
  approve: {
130
132
  usage: "approve <run-id> <task-id> [options]", description: "Append a validated park decision. A running owner may enact it; a closed run requires a separate resume. Decisions cannot be undone.",
131
133
  options: {
134
+ "--park <line>@<ts>": "Bind the decision to the park token status prints (a failed task's recheck binds its failure token); refused once a newer park opens.",
135
+ "--gate <gate>": "Bind a waive to the park's failed gate; refused when the park failed another gate.",
132
136
  "--files <glob,…>": "Extend files[] for a scope-request park with comma-separated repository-relative globs; required for scope approval.",
133
137
  "--by <name>": "Name the actor (default: current OS user).",
134
138
  "--reason <text>": "Record the decision reason.",
135
139
  "--waive": "Waive only the identified failed gate.",
136
140
  "--uphold": "Uphold a review failure and fund a fixed attempt.",
137
141
  "--recheck": "Request rechecking an infra or failed-gate park; satisfies no gate.",
138
- "--review-rounds <N>": "Set a positive integer review-round ceiling with the decision.",
139
- }, examples: ["approve run-example T1 --by operator --reason 'ready to proceed'", "approve run-example T1 --recheck --reason 'infra recovered'"],
142
+ "--review-rounds <N>": "Set a positive integer review-round ceiling that binds only this approval engagement; a later approval without --review-rounds restores the built-in default.",
143
+ }, examples: [
144
+ "approve run-example T1 --park 42@2026-09-26T08:00:00.000Z --by operator --reason 'ready to proceed'",
145
+ "approve run-example T1 --waive --park 42@2026-09-26T08:00:00.000Z --gate review",
146
+ "approve run-example T1 --recheck --park 42@2026-09-26T08:00:00.000Z --reason 'infra recovered'",
147
+ "approve run-example T3 --recheck --park 57@2026-09-26T08:05:00.000Z --reason 'failed task: re-gate its landed commits'",
148
+ ],
140
149
  },
141
150
  beat: {
142
151
  usage: "beat <orchestrator|orchestrator-context|overseer|overseer-context|watch> --seat <identity> [options]",
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { type Effort } from "../graph/schema.js";
2
3
  declare const TierEnum: z.ZodEnum<{
3
4
  cheap: "cheap";
4
5
  mid: "mid";
@@ -41,6 +42,7 @@ export declare const MapEntrySchema: z.ZodObject<{
41
42
  escalate: z.ZodOptional<z.ZodBoolean>;
42
43
  }, z.core.$strip>;
43
44
  export type MapEntry = z.infer<typeof MapEntrySchema>;
45
+ export declare const EFFORT_ADAPTERS: readonly ["claude-code", "codex"];
44
46
  export declare const TierEntrySchema: z.ZodObject<{
45
47
  vendor: z.ZodNullable<z.ZodString>;
46
48
  channel: z.ZodEnum<{
@@ -58,6 +60,11 @@ export declare const TierEntrySchema: z.ZodObject<{
58
60
  sub: "sub";
59
61
  api: "api";
60
62
  }>>;
63
+ effort: z.ZodOptional<z.ZodEnum<{
64
+ low: "low";
65
+ medium: "medium";
66
+ high: "high";
67
+ }>>;
61
68
  }, z.core.$strip>>>;
62
69
  windows: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
63
70
  }, z.core.$strip>;
@@ -274,6 +281,11 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
274
281
  sub: "sub";
275
282
  api: "api";
276
283
  }>>;
284
+ effort: z.ZodOptional<z.ZodEnum<{
285
+ low: "low";
286
+ medium: "medium";
287
+ high: "high";
288
+ }>>;
277
289
  }, z.core.$strip>>>;
278
290
  windows: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
279
291
  }, z.core.$strip>>;
@@ -383,6 +395,12 @@ export declare function loadConfigWithMode(repoRoot: string, opts?: {
383
395
  cfg: TickmarkrConfig;
384
396
  mode: ModeResolution;
385
397
  };
398
+ /** OBS-1182: tiers.<adapter>.modelOverrides as the layers under the repo overlay (defaults + global)
399
+ * merge them. Read raw, never schema-validated: a lower layer may only validate beside repo fields
400
+ * (global `vendor: null` completed by a repo vendor) and its override metadata is still inherited. */
401
+ export declare function lowerLayerModelOverrides(opts?: {
402
+ globalDir?: string;
403
+ }): Record<string, Record<string, Record<string, unknown>>>;
386
404
  export declare function loadConfig(repoRoot: string, opts?: {
387
405
  globalDir?: string;
388
406
  }): TickmarkrConfig;
@@ -416,6 +434,8 @@ export type FleetEditable = Partial<Record<DenyScopeKey, string[]>> & {
416
434
  denyModels: string[];
417
435
  allowOut?: string[];
418
436
  tiers: Record<string, Record<string, FleetTierAssignment | null>>;
437
+ /** OBS-1182: tiers.<adapter>.modelOverrides.<model>.effort — absent = CLI default; present only when one is set */
438
+ efforts?: Record<string, Record<string, Effort>>;
419
439
  map: Record<string, MapEntry>;
420
440
  floors: Record<string, Tier>;
421
441
  };
@@ -2,11 +2,11 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { filesGlob } from "../graph/files-glob.js";
5
- import { Document, parse } from "yaml";
5
+ import { Document, isScalar, parse } from "yaml";
6
6
  import { z } from "zod";
7
7
  import { CITED_MODEL_WINDOWS } from "../adapters/model-windows.js";
8
8
  import { stateDirName } from "../graph/graph.js";
9
- import { SHAPES, TIERS } from "../graph/schema.js";
9
+ import { EFFORTS, SHAPES, TIERS } from "../graph/schema.js";
10
10
  const TierEnum = z.enum(TIERS, {
11
11
  error: (iss) => `Invalid option: expected one of "cheap"|"mid"|"frontier" (got ${JSON.stringify(iss.input)})`,
12
12
  });
@@ -51,21 +51,32 @@ export const MapEntrySchema = z.object({
51
51
  escalate: z.boolean().optional(),
52
52
  });
53
53
  const DeclaredVendorSchema = z.string().trim().min(1, "vendor must be nonempty");
54
- const ModelOverrideSchema = z
54
+ // OBS-1182: effort is launch metadata only these CLIs accept (claude --effort, codex -c
55
+ // model_reasoning_effort=). Every other adapter's override refuses the key outright, so config
56
+ // load and the emitted JSON schema both reject an effort that could never be launched.
57
+ export const EFFORT_ADAPTERS = ["claude-code", "codex"];
58
+ const EffortEnum = z.enum(EFFORTS, {
59
+ error: (iss) => `Invalid effort: expected one of "low"|"medium"|"high" (got ${JSON.stringify(iss.input)})`,
60
+ });
61
+ const NoEffort = z.never({
62
+ error: () => `effort is launch metadata only ${EFFORT_ADAPTERS.join(" and ")} accept — this adapter's CLI takes no effort level`,
63
+ });
64
+ const modelOverrideSchema = (effort) => z
55
65
  .object({
56
66
  vendor: DeclaredVendorSchema.optional(),
57
67
  channel: z.enum(["sub", "api"]).optional(),
68
+ effort: effort.optional(),
58
69
  })
59
- .refine((override) => override.vendor !== undefined || override.channel !== undefined, {
60
- message: "a model override must declare vendor, channel, or both",
70
+ .refine((override) => override.vendor !== undefined || override.channel !== undefined || override.effort !== undefined, {
71
+ message: "a model override must declare vendor, channel, effort, or a combination",
61
72
  });
62
- export const TierEntrySchema = z.object({
73
+ const tierEntrySchema = (effort) => z.object({
63
74
  vendor: DeclaredVendorSchema.nullable(),
64
75
  channel: z.enum(["sub", "api"]),
65
76
  models: z.record(z.string(), TierEnum),
66
77
  // T19: keep model values scalar for existing consumers. Optional sibling metadata resolves before
67
78
  // parent metadata in channelsFromConfig; neither provider prefixes nor adapter ids manufacture it.
68
- modelOverrides: z.record(z.string(), ModelOverrideSchema).optional(),
79
+ modelOverrides: z.record(z.string(), modelOverrideSchema(effort)).optional(),
69
80
  // v1.47 T3: optional per-model context-window sizes (tokens). Absent block ⇒ no doctor column, no plan lint.
70
81
  windows: z.record(z.string(), z.number().int().positive()).optional(),
71
82
  }).superRefine((entry, ctx) => {
@@ -81,6 +92,13 @@ export const TierEntrySchema = z.object({
81
92
  });
82
93
  }
83
94
  });
95
+ export const TierEntrySchema = tierEntrySchema(EffortEnum);
96
+ // Per-adapter object + catchall rather than a record, so the emitted JSON schema carries the same
97
+ // adapter restriction as the loader; typed as the record every consumer already reads.
98
+ // ponytail: catchall parses the EFFORT_ADAPTERS keys first — the seed table already lists them first.
99
+ const TiersSchema = z
100
+ .object(Object.fromEntries(EFFORT_ADAPTERS.map((id) => [id, TierEntrySchema.optional()])))
101
+ .catchall(tierEntrySchema(NoEffort));
84
102
  // v1.10 FLEET-06: optional routing.allow/deny fleet preference; absent blocks ⇒ byte-identical routing/discovery.
85
103
  // deny wins on conflict; presence of allow (even {} / empty arrays) activates allowlist (fail-closed).
86
104
  const PrefBlockSchema = z.object({
@@ -372,7 +390,7 @@ export const TickmarkrConfigSchema = z.object({
372
390
  allow: PrefBlockSchema.optional(),
373
391
  deny: DenyBlockSchema.optional(),
374
392
  }),
375
- tiers: z.record(z.string(), TierEntrySchema),
393
+ tiers: TiersSchema,
376
394
  pricing: z.record(z.string(), z.number()),
377
395
  // v1.20 REC-02: optional detailed price table for cost estimation. Distinct from `pricing` above
378
396
  // (the coarse per-task tier estimate `tickmarkr plan` shows) — this one drives the usage/cost report.
@@ -798,6 +816,15 @@ export function loadConfigWithMode(repoRoot, opts = {}) {
798
816
  assertSeededModelWindows();
799
817
  return { cfg: r.data, mode: resolveRoutingMode(r.data, [globalCfg, repoCfg]) };
800
818
  }
819
+ /** OBS-1182: tiers.<adapter>.modelOverrides as the layers under the repo overlay (defaults + global)
820
+ * merge them. Read raw, never schema-validated: a lower layer may only validate beside repo fields
821
+ * (global `vendor: null` completed by a repo vendor) and its override metadata is still inherited. */
822
+ export function lowerLayerModelOverrides(opts = {}) {
823
+ const isMap = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
824
+ const merged = deepMerge(structuredClone(DEFAULT_CONFIG), readYaml(join(opts.globalDir ?? globalConfigDir(), "config.yaml")));
825
+ const tiers = isMap(merged) && isMap(merged.tiers) ? merged.tiers : {};
826
+ return Object.fromEntries(Object.entries(tiers).map(([adapter, entry]) => [adapter, Object.fromEntries(Object.entries(isMap(entry) && isMap(entry.modelOverrides) ? entry.modelOverrides : {}).filter(([, o]) => isMap(o)))]));
827
+ }
801
828
  export function loadConfig(repoRoot, opts = {}) {
802
829
  return loadConfigWithMode(repoRoot, opts).cfg;
803
830
  }
@@ -959,10 +986,14 @@ export function universeCovers(universe, entry) {
959
986
  // Absent universe ⇒ deny arrays verbatim and no allowOut, so non-UI callers stay byte-identical.
960
987
  export function fleetEditableFromConfig(cfg, universe) {
961
988
  const tiers = {};
989
+ const efforts = {};
962
990
  for (const [adapter, entry] of Object.entries(cfg.tiers)) {
963
991
  tiers[adapter] = {};
964
992
  for (const [model, tier] of Object.entries(entry.models)) {
965
993
  tiers[adapter][model] = { tier };
994
+ const effort = entry.modelOverrides?.[model]?.effort;
995
+ if (effort)
996
+ (efforts[adapter] ??= {})[model] = effort;
966
997
  }
967
998
  }
968
999
  // every enumerated deny scope rides verbatim (sorted) under its own key
@@ -991,6 +1022,7 @@ export function fleetEditableFromConfig(cfg, universe) {
991
1022
  ...denyLists,
992
1023
  ...(allowOut !== undefined ? { allowOut } : {}),
993
1024
  tiers,
1025
+ ...(Object.keys(efforts).length ? { efforts } : {}),
994
1026
  map: structuredClone(cfg.routing.map),
995
1027
  floors: { ...cfg.routing.floors },
996
1028
  };
@@ -1037,6 +1069,13 @@ export function formatFleetPrint(repoRoot, opts = {}) {
1037
1069
  for (const [adapter, entry] of Object.entries(effective.tiers)) {
1038
1070
  for (const model of Object.keys(entry.models))
1039
1071
  annotate(["tiers", adapter, "models", model]);
1072
+ // OBS-1182: the effort value prints in place; its note names the layer that set it and the edit
1073
+ for (const [model, override] of Object.entries(entry.modelOverrides ?? {})) {
1074
+ const path = ["tiers", adapter, "modelOverrides", model, "effort"];
1075
+ const node = doc.getIn(path, true);
1076
+ if (override.effort && isScalar(node))
1077
+ node.comment = ` ${fleetKeyLayer(repoRoot, path.join("."), opts)} — fleet e edits`;
1078
+ }
1040
1079
  }
1041
1080
  return String(doc);
1042
1081
  }
@@ -20,6 +20,7 @@ export type FleetOverlayWrite = {
20
20
  };
21
21
  };
22
22
  universe?: FleetUniverseRow[];
23
+ lowerOverrides?: Record<string, Record<string, Record<string, unknown>>>;
23
24
  };
24
25
  export type FleetFirstTouch = {
25
26
  vendor: string;
@@ -360,6 +360,62 @@ export function renderFleetOverlayWrite(priorBytes, write) {
360
360
  setScalarPreservingComment(doc, path, after.tier, unpackFleetProvenance(after.provenance).provenance);
361
361
  }
362
362
  }
363
+ // OBS-1182: effort lands in tiers.<adapter>.modelOverrides.<model>.effort — the scalar tier and
364
+ // every sibling override key (vendor, channel) keep their bytes.
365
+ const efforts = (e) => e.efforts ?? {};
366
+ for (const adapter of new Set([...Object.keys(efforts(initial)), ...Object.keys(efforts(edited))])) {
367
+ for (const model of new Set([...Object.keys(efforts(initial)[adapter] ?? {}), ...Object.keys(efforts(edited)[adapter] ?? {})])) {
368
+ const after = efforts(edited)[adapter]?.[model];
369
+ if (efforts(initial)[adapter]?.[model] === after)
370
+ continue;
371
+ const override = ["tiers", adapter, "modelOverrides", model];
372
+ if (after !== undefined) {
373
+ // Setting beneath a tombstone (the override, or modelOverrides above it) lifts it, and
374
+ // deepMerge would restore what it masked: re-mask each lower-layer key it covered — sibling
375
+ // models, then this override's vendor/channel (unknown lower ⇒ both, blind).
376
+ // ponytail: tiers.<adapter> itself tombstoned is unreachable here — its models, and so a tier, are masked too.
377
+ const tombstoned = (depth) => {
378
+ const node = doc.getIn(override.slice(0, depth), true);
379
+ return node !== undefined && !isMap(node);
380
+ };
381
+ const lowerModels = write.lowerOverrides?.[adapter] ?? {};
382
+ const remask = [];
383
+ if (tombstoned(3))
384
+ for (const other of Object.keys(lowerModels))
385
+ if (other !== model)
386
+ remask.push([...override.slice(0, 3), other]);
387
+ if (tombstoned(3) || tombstoned(4)) {
388
+ for (const key of write.lowerOverrides ? Object.keys(lowerModels[model] ?? {}) : ["vendor", "channel"]) {
389
+ if (key !== "effort")
390
+ remask.push([...override, key]);
391
+ }
392
+ }
393
+ setScalarPreservingComment(doc, [...override, "effort"], after);
394
+ for (const path of remask)
395
+ setScalarPreservingComment(doc, path, null);
396
+ continue;
397
+ }
398
+ // Clearing means no effort in the MERGED config: drop the repo key, then mask an effort the
399
+ // lower layers still declare (unknown lower ⇒ a repo that never held the key inherited it).
400
+ const held = doc.getIn([...override, "effort"]) !== undefined;
401
+ deleteAt(doc, [...override, "effort"]);
402
+ const lower = write.lowerOverrides?.[adapter]?.[model];
403
+ const maskEffort = write.lowerOverrides ? lower?.effort !== undefined : !held;
404
+ const repoNode = doc.getIn(override, true);
405
+ const { effort: _masked, ...rest } = { ...lower, ...(isMap(repoNode) ? repoNode.toJSON() : {}) };
406
+ if (Object.values(rest).some((v) => v !== null && v !== undefined)) {
407
+ if (maskEffort)
408
+ setScalarPreservingComment(doc, [...override, "effort"], null);
409
+ }
410
+ else if (maskEffort || (isMap(repoNode) && repoNode.items.length > 0)) {
411
+ // Tombstone-only siblings must still suppress lower metadata when effort is cleared,
412
+ // even without inherited effort. Their merged {} would fail the nonempty refinement.
413
+ setScalarPreservingComment(doc, override, null);
414
+ }
415
+ for (const depth of [4, 3, 2, 1])
416
+ deleteEmptyMap(doc, override.slice(0, depth));
417
+ }
418
+ }
363
419
  if (write.mode !== undefined) {
364
420
  setScalarPreservingComment(doc, ["routing", "mode"], write.mode);
365
421
  }
@@ -123,12 +123,13 @@ export declare function mapAgentState(term: Record<string, unknown>, tuiIdle: bo
123
123
  * A retired record is replaced by a new reservation (CAS on the tombstone bytes) only once its pane
124
124
  * is proven gone. Everything else — a reservation or claim with no bound pane, a failed cleanup, a
125
125
  * record another driver holds with a live observer — refuses and keeps the record exactly as it is.
126
+ * One reservation or claim is HELD rather than in flight: its placer is this process after an
127
+ * indeterminate split receipt (OBS-1172). It is reconciled again on every call, never split over.
126
128
  */
127
129
  /** `placer` is the pid of the narrator that wrote the reservation: a reserved or claimed record is a
128
130
  * normal intermediate state while that pid lives (another driver awaiting listing or bind) and a
129
131
  * crash to recover only once it is dead. */
130
132
  type BoardRecord = WatchBoardOwner & {
131
- retired?: true;
132
133
  runtimeId?: string;
133
134
  placer?: number;
134
135
  };
@@ -198,6 +199,16 @@ export declare class OrcaDriver implements ExecutorDriver {
198
199
  /** Explicit runtime probe. Also T3's doctor probe. */
199
200
  probeRuntime(cwd?: string): Promise<string>;
200
201
  slot(cwd: string, name: string, opts?: SlotOpts): Promise<Slot>;
202
+ /**
203
+ * OBS-1109: bind THIS instance to a terminal an earlier daemon created for an OWNED slot, so a
204
+ * resumed daemon can read the interrupted attempt's pane. Binding needs ownership evidence, never
205
+ * a title or handle alone: exactly one tab on the tracked worktree carries the FULL journaled owned
206
+ * title, it resolves to exactly one terminal, and that terminal's scrollback proves the exact task
207
+ * checkout `slot.cwd` (the frame only tickmarkr's own create command prints). Zero, ambiguous or
208
+ * foreign candidates leave the slot unbound: the throw is the caller's decline. The journaled
209
+ * nonce is then checked by the caller against the trailer this binding reads.
210
+ */
211
+ adopt(slot: Slot): Promise<Slot>;
201
212
  /** Where to invoke the CLI for this slot's calls (see OrcaSlotState.dir). */
202
213
  private cliCwd;
203
214
  private state;
@@ -290,6 +301,15 @@ export declare class OrcaDriver implements ExecutorDriver {
290
301
  sendKey(slot: Slot, key: string): Promise<void>;
291
302
  nudge(slot: Slot, message: string): Promise<boolean>;
292
303
  narrator(cwd: string, command: string, runId?: string): Promise<Slot>;
304
+ /** OBS-1172: what an indeterminate split left behind, reconciled from the owner record and Orca's
305
+ * terminal table — never from the receipt that did not arrive. The reservation's token reaches no
306
+ * process but the split's own command, so a claim proves that split started a board; the owned
307
+ * record (this driver, the watch title) is ours by that same token. The claimant must be live (pid
308
+ * and arm presence), and it must name the terminal it runs in — a listed pane in the launching tab.
309
+ * A claim that names none proves a board started, not WHICH pane hosts it: a sole neighbouring pane
310
+ * (row titles are shell-controlled, never ownership keys) is not adopted, so the claim stays held.
311
+ * No claim and no such pane: the split started nothing the table shows. Anything else is held. */
312
+ private settleIndeterminate;
293
313
  private boardPath;
294
314
  /** A recorded pane (a receipt's handle bound to the split envelope's runtime, never a guess) is
295
315
  * gone when that runtime no longer lists it, or when a handle-bound close receipt names it.
@@ -303,6 +323,11 @@ export declare class OrcaDriver implements ExecutorDriver {
303
323
  * handle-bound close (timeout keeps the tombstone and the pane); a dead one never acknowledges,
304
324
  * so it is only asked. */
305
325
  private retireAndClose;
326
+ /** Stop a retired board's observer before its pane is closed. A live observer acknowledges on
327
+ * injected time; a dead one never can, so it is only asked. OBS-1131: an acknowledgement timeout
328
+ * is not proof of death — while the pid lives the owned pane stays protected — but a pid that died
329
+ * during the wait is, and its pane is closed instead of leaked. */
330
+ private stopObserver;
306
331
  /** WB-1 seam: the daemon reports this board lost. "Lost" can be a stale beat or missing presence
307
332
  * under a still-live owner pid, so it is not proof of a dead observer — retirement keeps close's
308
333
  * acknowledgement discipline (Leg-2 T9 P1). */