tickmarkr 1.71.0 → 1.73.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.
Files changed (53) hide show
  1. package/dist/adapters/codex.js +13 -3
  2. package/dist/cli/commands/approve.js +21 -3
  3. package/dist/cli/commands/fleet.d.ts +2 -0
  4. package/dist/cli/commands/fleet.js +212 -449
  5. package/dist/cli/commands/resume.js +10 -2
  6. package/dist/cli/commands/status.d.ts +2 -0
  7. package/dist/cli/commands/status.js +219 -30
  8. package/dist/cli/commands/ui.d.ts +6 -2
  9. package/dist/cli/commands/ui.js +2 -4
  10. package/dist/config/config.d.ts +4 -0
  11. package/dist/config/config.js +6 -1
  12. package/dist/drivers/herdr.d.ts +6 -0
  13. package/dist/drivers/herdr.js +169 -71
  14. package/dist/drivers/subprocess.d.ts +3 -3
  15. package/dist/drivers/subprocess.js +10 -5
  16. package/dist/gates/acceptance.js +41 -17
  17. package/dist/route/preference.d.ts +2 -1
  18. package/dist/route/preference.js +8 -2
  19. package/dist/run/daemon.d.ts +1 -0
  20. package/dist/run/daemon.js +139 -4
  21. package/dist/run/git.d.ts +2 -0
  22. package/dist/run/git.js +55 -0
  23. package/dist/run/journal.d.ts +6 -1
  24. package/dist/run/journal.js +28 -1
  25. package/dist/tui/ink/components.d.ts +38 -0
  26. package/dist/tui/ink/components.js +42 -0
  27. package/dist/tui/ink/fleet-app.d.ts +113 -0
  28. package/dist/tui/ink/fleet-app.js +678 -0
  29. package/dist/tui/ink/studio-app.d.ts +59 -0
  30. package/dist/tui/ink/studio-app.js +320 -0
  31. package/package.json +4 -1
  32. package/dist/tui/app.d.ts +0 -72
  33. package/dist/tui/app.js +0 -334
  34. package/dist/tui/engine.d.ts +0 -52
  35. package/dist/tui/engine.js +0 -92
  36. package/dist/tui/frame.d.ts +0 -22
  37. package/dist/tui/frame.js +0 -56
  38. package/dist/tui/input.d.ts +0 -41
  39. package/dist/tui/input.js +0 -100
  40. package/dist/tui/views/consult-dossier.d.ts +0 -49
  41. package/dist/tui/views/consult-dossier.js +0 -169
  42. package/dist/tui/views/diff-modal.d.ts +0 -9
  43. package/dist/tui/views/diff-modal.js +0 -19
  44. package/dist/tui/views/fleet-view.d.ts +0 -91
  45. package/dist/tui/views/fleet-view.js +0 -337
  46. package/dist/tui/views/preview-view.d.ts +0 -7
  47. package/dist/tui/views/preview-view.js +0 -80
  48. package/dist/tui/views/profile-view.d.ts +0 -15
  49. package/dist/tui/views/profile-view.js +0 -93
  50. package/dist/tui/views/routing-view.d.ts +0 -48
  51. package/dist/tui/views/routing-view.js +0 -234
  52. package/dist/tui/views/runs-view.d.ts +0 -70
  53. package/dist/tui/views/runs-view.js +0 -387
@@ -14,11 +14,12 @@ import { formatOwnedName } from "../drivers/types.js";
14
14
  import { captureBaseline, detectGateCommands, detectVacuousOracles } from "../gates/baseline.js";
15
15
  import { runGates } from "../gates/run-gates.js";
16
16
  import { addEvidence, attributeBlocked, blockedTasks, getTask, graphDefinitionHash, loadGraph, pendingTasks, readyTasks, saveGraph, setStatus } from "../graph/graph.js";
17
+ import { GATE_NAMES } from "../graph/schema.js";
17
18
  import { augmentRetryBrief, consult, renderRetryGuidance } from "./consult.js";
18
19
  import { runEnvironment } from "./environment.js";
19
- import { cleanupRunWorktrees, gitHead, linkNodeModules, sh, shGit, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
20
+ import { cleanupRunWorktrees, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, sh, shGit, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
20
21
  import { runInteractiveSeed } from "./interactive-seed.js";
21
- import { classifyWorkerResultCause, engagementComparable, Journal, loadRoutingProfile, newRunId } from "./journal.js";
22
+ import { classifyWorkerResultCause, engagementComparable, Journal, loadRoutingProfile, newRunId, phaseForGate, recordedTaskFailureKind } from "./journal.js";
22
23
  import { acquireRunLock, releaseRunLock } from "./lock.js";
23
24
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
24
25
  import { nextChannel, route } from "../route/router.js";
@@ -229,6 +230,7 @@ export async function runDaemon(repoRoot, opts = {}) {
229
230
  // GATE-08 lesson at the humanGate guard: condition on the data, not the code path). Dead-code
230
231
  // equivalence to the router.ts:194 profile⇒undefined pattern: no map entry ⇒ today's literal.
231
232
  const resume = opts.resume ? journal.replayResumeState() : new Map();
233
+ const satisfiedGates = opts.resume ? journal.replaySatisfiedGates() : new Map();
232
234
  const replayedExclusions = opts.resume ? journal.replayExcludedChannels() : new Set();
233
235
  if (opts.resume) {
234
236
  // v1.53 T5: a superseded run is dead — resuming it beside its successor is the exact
@@ -259,7 +261,13 @@ export async function runDaemon(repoRoot, opts = {}) {
259
261
  });
260
262
  }
261
263
  baseline = JSON.parse(readFileSync(join(journal.dir, "baseline.json"), "utf8"));
264
+ const replayEvents = journal.read();
262
265
  for (const [id, st] of journal.replayStatuses()) {
266
+ if (opts.retryFailed && st === "failed" && recordedTaskFailureKind(replayEvents, id) === "dispatch") {
267
+ graph = setStatus(graph, id, "pending");
268
+ resume.delete(id);
269
+ continue;
270
+ }
263
271
  // operator release: a graph.json edit back to "pending" beats a replayed human/failed park (locked decision 12)
264
272
  if ((st === "human" || st === "failed") && getTask(graph, id).status === "pending")
265
273
  continue;
@@ -268,6 +276,7 @@ export async function runDaemon(repoRoot, opts = {}) {
268
276
  journal.append("run-resume", undefined, {
269
277
  pid: process.pid, // v1.13 (VIS-11): record the live daemon pid for status liveness
270
278
  ...(replayedExclusions.size > 0 ? { excludedChannels: [...replayedExclusions].sort() } : {}),
279
+ ...(opts.retryFailed ? { retryFailed: true } : {}),
271
280
  });
272
281
  }
273
282
  else {
@@ -366,7 +375,10 @@ export async function runDaemon(repoRoot, opts = {}) {
366
375
  // merges are serialized — two concurrent `git merge`s in one worktree would corrupt each other
367
376
  let mergeChain = Promise.resolve();
368
377
  const mergeSerial = (taskBranch, t, gated) => {
369
- const next = mergeChain.then(() => mergeTask(intWt, taskBranch, `tickmarkr: merge ${t.id} ${t.title}`, gated));
378
+ const next = mergeChain.then(() => {
379
+ journal.phaseStart(t.id, "merge");
380
+ return mergeTask(intWt, taskBranch, `tickmarkr: merge ${t.id} ${t.title}`, gated);
381
+ });
370
382
  mergeChain = next.catch(() => undefined);
371
383
  return next;
372
384
  };
@@ -526,6 +538,114 @@ export async function runDaemon(repoRoot, opts = {}) {
526
538
  await park(t, `consult verdict: ${v.action} — ${v.notes}`, trigger, assignment, attempts, startMs, gateFails, consults, tokens, metered, retryMode); // decompose|human
527
539
  return false;
528
540
  };
541
+ // OBS-130: an operator-approved gate-fail resumes from the persisted task branch, after the exact
542
+ // satisfied gate. No worker dispatch and no earlier gate/judge invocation occur on this path.
543
+ // replaySatisfiedGates() admits only an explicit task-approved ledger event, scoped by task+gate.
544
+ const satisfiedGate = satisfiedGates.get(t.id);
545
+ if (satisfiedGate) {
546
+ const taskBase = await integrationHead(intWt);
547
+ const taskBranch = `${branch}--${t.id}`;
548
+ const priorWt = worktreePath(repoRoot, taskBranch);
549
+ if (!existsSync(priorWt)) {
550
+ throw new Error(`approved gate ${satisfiedGate} cannot resume: task worktree is missing`);
551
+ }
552
+ const commitsToCarry = await commitsAheadOf(taskBase, priorWt);
553
+ const wt = await driver.worktree(repoRoot, taskBranch, taskBase);
554
+ const carriedCommits = await cherryPickCommits(wt, commitsToCarry);
555
+ journal.append("worktree-recreation", t.id, { attempted: commitsToCarry, carried: carriedCommits });
556
+ if (!linkNodeModules(repoRoot, wt, { force: true })) {
557
+ await park(t, "environmental: node_modules link could not be re-asserted before gates (OBS-47)", "setup", assignment, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
558
+ return;
559
+ }
560
+ if (await npmDependencyManifestChanged(wt, taskBase)) {
561
+ const installCommand = npmDependencyInstallCommand(wt);
562
+ const installed = await sh(installCommand, repoRoot, 10 * 60_000);
563
+ if (installed.code !== 0) {
564
+ throw new Error(`dependency install failed (exit ${installed.code}): ${installed.stderr || installed.stdout}`);
565
+ }
566
+ }
567
+ const workerEvent = [...journal.read()].reverse()
568
+ .find((e) => e.event === "worker-result" && e.taskId === t.id);
569
+ if (!workerEvent)
570
+ throw new Error(`approved gate ${satisfiedGate} cannot resume: worker result is missing`);
571
+ const priorResult = {
572
+ ok: workerEvent.data.ok === true,
573
+ summary: typeof workerEvent.data.summary === "string" ? workerEvent.data.summary : "",
574
+ deviations: Array.isArray(workerEvent.data.deviations)
575
+ ? workerEvent.data.deviations.filter((d) => typeof d === "string")
576
+ : [],
577
+ raw: "",
578
+ };
579
+ const gateAuthor = rs?.lastAssignment ?? assignment;
580
+ const satisfiedIndex = GATE_NAMES.indexOf(satisfiedGate);
581
+ const remainingGates = t.gates.filter((gate) => GATE_NAMES.indexOf(gate) > satisfiedIndex);
582
+ const resumedTask = { ...t, gates: remainingGates };
583
+ gateLoop: while (true) {
584
+ const gated = await gitHead(wt);
585
+ journal.phaseStart(t.id, "gates");
586
+ const { results } = await runGates(resumedTask, {
587
+ worktree: wt, baseRef: taskBase, result: priorResult, author: gateAuthor,
588
+ commands, baseline, channels, adapters, cfg,
589
+ via: cfg.visibility.llm === "pane"
590
+ ? {
591
+ driver: trackedDriver,
592
+ keep: keepLlm,
593
+ onSlot: keepLlm ? (s) => keptSlots.push(s) : undefined,
594
+ nameFor: (role) => formatOwnedName({ role, taskId: t.id, attempt: 0, runId }),
595
+ labelFor: (role) => `${role.toUpperCase()} ${t.id}`,
596
+ }
597
+ : undefined,
598
+ excludeReviewers: badReviewers,
599
+ onGate: async (e) => {
600
+ if (e.phase === "start") {
601
+ journal.phaseStart(t.id, phaseForGate(e.gate), { gate: e.gate, index: e.index, total: e.total });
602
+ return;
603
+ }
604
+ const g = e.result;
605
+ journal.append("gate-result", t.id, {
606
+ gate: g.gate, pass: g.pass, details: g.details,
607
+ ...(g.meta?.skipped === true ? { skipped: true } : {}),
608
+ });
609
+ if (g.gate === "review" && !g.pass && /unparseable/.test(g.details)
610
+ && typeof g.meta?.reviewer === "string") {
611
+ badReviewers.push(g.meta.reviewer);
612
+ }
613
+ },
614
+ });
615
+ const approvedCommits = await commitsAheadOf(taskBase, wt);
616
+ graph = addEvidence(graph, t.id, { commits: approvedCommits, gateResults: results });
617
+ saveGraph(repoRoot, graph);
618
+ if (!results.every((g) => g.pass)) {
619
+ gateFails++;
620
+ await park(t, "post-approval gate failed", "gate-fail", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
621
+ return;
622
+ }
623
+ const m = await mergeSerial(taskBranch, t, gated);
624
+ if (m.tipMoved) {
625
+ journal.append("tip-moved", t.id, m.tipMoved);
626
+ if (tipMoves++ === 0)
627
+ continue gateLoop;
628
+ await park(t, "task branch tip moved twice after gating", "tip-moved", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
629
+ return;
630
+ }
631
+ if (!m.ok) {
632
+ journal.append("merge-conflict", t.id, { conflict: m.conflict });
633
+ await park(t, `merge conflict after approved gate: ${m.conflict ?? "unknown conflict"}`, "merge-conflict", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
634
+ return;
635
+ }
636
+ graph = setStatus(graph, t.id, "done");
637
+ saveGraph(repoRoot, graph);
638
+ journal.append("task-done", t.id, { attempts: rs?.attempts ?? 0, assignment: gateAuthor });
639
+ journal.append("merge", t.id, { branch: taskBranch, commit: await integrationHead(intWt) });
640
+ journal.telemetry({
641
+ taskId: t.id, shape: t.shape, adapter: gateAuthor.adapter, model: gateAuthor.model,
642
+ channel: gateAuthor.channel, attempts: rs?.attempts ?? 0, outcome: "done",
643
+ durationMs: Date.now() - startMs, firstAttemptOk: false, gateFails, consults, retryMode,
644
+ });
645
+ await reconcile({ spareLiveLlm: true });
646
+ return;
647
+ }
648
+ }
529
649
  // Phase 46 (RES-01): start at the replayed attempt count; a replayed count ≥ MAX_ATTEMPTS parks via
530
650
  // the existing attempt-cap check below with zero new code. Fresh path: rs is undefined ⇒ 0.
531
651
  // v1.24 OBS-18: after task-approved{release:attempt-cap}, replay zeros attempts so this loop
@@ -594,6 +714,7 @@ export async function runDaemon(repoRoot, opts = {}) {
594
714
  graph = setStatus(graph, t.id, "running");
595
715
  saveGraph(repoRoot, graph);
596
716
  journal.append("task-dispatch", t.id, { assignment, attempt, provenance: dispatchProvenance(r.provenance), retryMode });
717
+ journal.phaseStart(t.id, "worker", { attempt, assignment });
597
718
  const taskBase = await integrationHead(intWt); // deps are merged → visible to this task
598
719
  const taskBranch = `${branch}--${t.id}`; // "--": a ref can't nest under the existing integration branch (locked decision 10)
599
720
  const priorWt = worktreePath(repoRoot, taskBranch);
@@ -1044,9 +1165,22 @@ export async function runDaemon(repoRoot, opts = {}) {
1044
1165
  await park(t, "environmental: node_modules link could not be re-asserted before gates (OBS-47)", "setup", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
1045
1166
  return;
1046
1167
  }
1168
+ // OBS-126: workers cannot write the provisioned node_modules target outside their sandbox.
1169
+ // Once the link is known-good, the daemon installs only attempts whose npm manifest differs
1170
+ // from the integration-tip baseline. Keep lock/package manifests untouched: the worker's
1171
+ // committed files are the deliverable, while this step only provisions the gate-visible tree.
1172
+ if (await npmDependencyManifestChanged(wt, taskBase)) {
1173
+ const installCommand = npmDependencyInstallCommand(wt);
1174
+ const installed = await sh(installCommand, repoRoot, 10 * 60_000);
1175
+ if (installed.code !== 0) {
1176
+ throw new Error(`dependency install failed (exit ${installed.code}): ${installed.stderr || installed.stdout}`);
1177
+ }
1178
+ }
1047
1179
  const onGate = async (e) => {
1048
- if (e.phase === "start")
1180
+ if (e.phase === "start") {
1181
+ journal.phaseStart(t.id, phaseForGate(e.gate), { gate: e.gate, index: e.index, total: e.total });
1049
1182
  return;
1183
+ }
1050
1184
  const g = e.result;
1051
1185
  // GATE-09 (ROADMAP SC-4): journal every judge retry as an attributable event — which gate flaked,
1052
1186
  // which channel flaked, which channel retried — so `tickmarkr journal`/report can distinguish "judge
@@ -1075,6 +1209,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1075
1209
  let commits = [];
1076
1210
  gateLoop: while (true) {
1077
1211
  const gated = await gitHead(wt);
1212
+ journal.phaseStart(t.id, "gates");
1078
1213
  ({ results, commits } = await runGates(t, {
1079
1214
  worktree: wt, baseRef: taskBase, result, author: assignment,
1080
1215
  commands, baseline, channels, adapters, cfg,
package/dist/run/git.d.ts CHANGED
@@ -12,6 +12,8 @@ export declare function sh(cmd: string, cwd: string, timeoutMs?: number): Promis
12
12
  export declare function shGit(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
13
13
  export declare function shOk(cmd: string, cwd: string): Promise<string>;
14
14
  export declare function shGitOk(cmd: string, cwd: string): Promise<string>;
15
+ export declare function npmDependencyManifestChanged(cwd: string, baselineRef: string): Promise<boolean>;
16
+ export declare function npmDependencyInstallCommand(cwd: string): string;
15
17
  export declare function gitHead(cwd: string): Promise<string>;
16
18
  export declare const sanitizeBranch: (branch: string) => string;
17
19
  export declare const WORKTREES_DIR = "worktrees.noindex";
package/dist/run/git.js CHANGED
@@ -75,6 +75,61 @@ export async function shGitOk(cmd, cwd) {
75
75
  throw new Error(`command failed (${r.code}): ${cmd}\n${r.stderr || r.stdout}`);
76
76
  return r.stdout;
77
77
  }
78
+ const NPM_DEPENDENCY_MANIFESTS = ["package.json", "package-lock.json", "npm-shrinkwrap.json"];
79
+ // OBS-126: compare the whole attempt (committed, staged, unstaged, or newly added manifest) with
80
+ // the task's integration-tip baseline. A committed package.json change is invisible to `git status`,
81
+ // while an untracked manifest is invisible to `git diff`, so both views are required.
82
+ export async function npmDependencyManifestChanged(cwd, baselineRef) {
83
+ const paths = NPM_DEPENDENCY_MANIFESTS.map(shq).join(" ");
84
+ const diff = await shGit(`git diff --quiet ${shq(baselineRef)} -- ${paths}`, cwd);
85
+ if (diff.code === 1)
86
+ return true;
87
+ if (diff.code !== 0) {
88
+ throw new Error(`dependency manifest comparison failed (${diff.code}): ${diff.stderr || diff.stdout}`);
89
+ }
90
+ const untracked = await shGit(`git ls-files --others --exclude-standard -- ${paths}`, cwd);
91
+ if (untracked.code !== 0) {
92
+ throw new Error(`dependency manifest comparison failed (${untracked.code}): ${untracked.stderr || untracked.stdout}`);
93
+ }
94
+ return untracked.stdout.trim().length > 0;
95
+ }
96
+ // npm reifies a worktree's node_modules symlink as a private directory, which makes a successful
97
+ // install disappear at worktree cleanup. Build positional specs from the attempt manifest so the
98
+ // daemon can install them at the main repo root (the provisioned link's target) without saving them
99
+ // into that baseline manifest. `--install-links` packs file: dependencies before the attempt is removed.
100
+ export function npmDependencyInstallCommand(cwd) {
101
+ const manifestPath = join(cwd, "package.json");
102
+ const manifest = existsSync(manifestPath)
103
+ ? JSON.parse(readFileSync(manifestPath, "utf8"))
104
+ : {};
105
+ const dependencyGroups = [
106
+ manifest.peerDependencies,
107
+ manifest.devDependencies,
108
+ manifest.dependencies,
109
+ manifest.optionalDependencies,
110
+ ];
111
+ const dependencies = {};
112
+ for (const group of dependencyGroups) {
113
+ if (!group || typeof group !== "object" || Array.isArray(group))
114
+ continue;
115
+ for (const [name, spec] of Object.entries(group)) {
116
+ if (typeof spec === "string")
117
+ dependencies[name] = spec;
118
+ }
119
+ }
120
+ const specs = Object.entries(dependencies).map(([name, rawSpec]) => {
121
+ let spec = rawSpec;
122
+ if (spec.startsWith("file:"))
123
+ spec = `file:${resolve(cwd, spec.slice("file:".length))}`;
124
+ else if (spec.startsWith("./") || spec.startsWith("../"))
125
+ spec = resolve(cwd, spec);
126
+ return shq(`${name}@${spec}`);
127
+ });
128
+ return [
129
+ "npm install --no-save --no-audit --no-fund --package-lock=false --install-links",
130
+ ...specs,
131
+ ].join(" ");
132
+ }
78
133
  export async function gitHead(cwd) {
79
134
  return (await shGitOk("git rev-parse HEAD", cwd)).trim();
80
135
  }
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { type Assignment } from "../adapters/types.js";
3
3
  import type { TickmarkrConfig } from "../config/config.js";
4
- import { type TaskStatus } from "../graph/schema.js";
4
+ import { type GateName, type TaskStatus } from "../graph/schema.js";
5
5
  import { type ProfileDiscount, type RoutingProfile } from "../route/profile.js";
6
6
  export interface JournalEvent {
7
7
  ts: string;
@@ -9,6 +9,8 @@ export interface JournalEvent {
9
9
  taskId?: string;
10
10
  data: Record<string, unknown>;
11
11
  }
12
+ export type TaskPhase = "worker" | "gates" | `gate:${GateName}` | "judge" | "review" | "merge";
13
+ export declare function phaseForGate(gate: GateName): TaskPhase;
12
14
  export declare function formatJournalNarration({ event, taskId, data }: JournalEvent): string;
13
15
  export interface ResumeState {
14
16
  attempts: number;
@@ -18,6 +20,7 @@ export interface ResumeState {
18
20
  export declare const CHANNEL_EXCLUSION_KINDS: readonly ["dead-channel"];
19
21
  export type ChannelExclusionKind = (typeof CHANNEL_EXCLUSION_KINDS)[number];
20
22
  export declare const ATTEMPT_CAP_RELEASE: "attempt-cap";
23
+ export declare const GATE_SATISFIED_RELEASE: "gate-satisfied";
21
24
  export declare const PARK_KINDS: readonly ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota", "reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved", "infra", "dispatch"];
22
25
  export type ParkKind = (typeof PARK_KINDS)[number];
23
26
  export declare const RETRY_MODES: readonly ["resume", "fresh"];
@@ -143,9 +146,11 @@ export declare class Journal {
143
146
  }): string | null;
144
147
  private get journalPath();
145
148
  append(event: string, taskId?: string, data?: Record<string, unknown>): void;
149
+ phaseStart(taskId: string, phase: TaskPhase, data?: Record<string, unknown>): void;
146
150
  read(): JournalEvent[];
147
151
  replayStatuses(): Map<string, TaskStatus>;
148
152
  replayResumeState(): Map<string, ResumeState>;
153
+ replaySatisfiedGates(): Map<string, GateName>;
149
154
  replayExcludedChannels(): Set<string>;
150
155
  telemetry(row: TelemetryRow): void;
151
156
  readTelemetry(): TelemetryRow[];
@@ -3,9 +3,16 @@ import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { channelKey, TokenUsageSchema } from "../adapters/types.js";
5
5
  import { stateDirName, tickmarkrDir } from "../graph/graph.js";
6
- import { TIERS } from "../graph/schema.js";
6
+ import { GATE_NAMES, TIERS } from "../graph/schema.js";
7
7
  import { buildProfile, classify } from "../route/profile.js";
8
8
  import { redactSecrets } from "./redact.js";
9
+ export function phaseForGate(gate) {
10
+ if (gate === "acceptance")
11
+ return "judge";
12
+ if (gate === "review")
13
+ return "review";
14
+ return `gate:${gate}`;
15
+ }
9
16
  export function formatJournalNarration({ event, taskId, data }) {
10
17
  const assignment = data.assignment;
11
18
  const direct = [data.summary, data.reason, data.error, data.step, data.action, data.lint, data.branch, data.from]
@@ -33,6 +40,9 @@ export const CHANNEL_EXCLUSION_KINDS = ["dead-channel"];
33
40
  // Approve stamps this; replayResumeState zeros the attempt counter (fresh budget) while keeping
34
41
  // tried (consult bans / burned channels). Absent on pre-v1.24 events ⇒ inert (corpus outcome-identical).
35
42
  export const ATTEMPT_CAP_RELEASE = "attempt-cap";
43
+ // OBS-130: task-approved carries the exact failed gate the operator satisfied. The release tag keeps
44
+ // ordinary humanGate and attempt-cap approvals byte-compatible while making this authority explicit.
45
+ export const GATE_SATISFIED_RELEASE = "gate-satisfied";
36
46
  // Fail-closed shape for a dispatched assignment (journal.ts:75-90 posture): a malformed assignment in
37
47
  // one dispatch degrades that single task toward today's behavior — counts toward attempts, contributes
38
48
  // nothing to tried, poisons only lastAssignment — never crashes resume, never poisons other tasks.
@@ -360,6 +370,9 @@ export class Journal {
360
370
  // narration is observational; a broken sink must not affect the journal or run
361
371
  }
362
372
  }
373
+ phaseStart(taskId, phase, data = {}) {
374
+ this.append("phase-start", taskId, { ...data, phase });
375
+ }
363
376
  read() {
364
377
  return readJsonl(this.journalPath);
365
378
  }
@@ -461,6 +474,20 @@ export class Journal {
461
474
  }
462
475
  return m;
463
476
  }
477
+ // OBS-130: gate satisfaction is authority, not an inferred daemon state. Only an explicit
478
+ // task-approved event with the typed release marker and a known gate enters this fold. A daemon-made
479
+ // gate-satisfied event, a prior pass/fail result, malformed data, or another task's approval is inert.
480
+ replaySatisfiedGates() {
481
+ const satisfied = new Map();
482
+ for (const e of this.read()) {
483
+ if (e.event !== "task-approved" || !e.taskId || e.data.release !== GATE_SATISFIED_RELEASE)
484
+ continue;
485
+ if (typeof e.data.gate === "string" && GATE_NAMES.includes(e.data.gate)) {
486
+ satisfied.set(e.taskId, e.data.gate);
487
+ }
488
+ }
489
+ return satisfied;
490
+ }
464
491
  // v1.71 OBS-119: run-wide exclusion fold — same replay discipline as replayResumeState().
465
492
  // channel-exclusion is the typed event; dead-channel-failover.from is the pre-v1.71 compat seam.
466
493
  replayExcludedChannels() {
@@ -0,0 +1,38 @@
1
+ import type { ReactNode } from "react";
2
+ import { type SaveTarget } from "../save.js";
3
+ export type FleetListRow = {
4
+ id: string;
5
+ content: ReactNode;
6
+ };
7
+ export declare function TextLines({ lines }: {
8
+ lines: string[];
9
+ }): import("react").JSX.Element;
10
+ export declare function ToggleMark({ active }: {
11
+ active: boolean;
12
+ }): import("react").JSX.Element;
13
+ export declare function FleetListScreen({ title, legend, rows, cursor, details, }: {
14
+ title: string;
15
+ legend: string;
16
+ rows: FleetListRow[];
17
+ cursor: number;
18
+ details?: string[];
19
+ }): import("react").JSX.Element;
20
+ export declare function FleetReviewScreen({ title, legend, diff, }: {
21
+ title: string;
22
+ legend: string;
23
+ diff: string;
24
+ }): import("react").JSX.Element;
25
+ export type DiffConfirmationProps = {
26
+ target: SaveTarget;
27
+ targetPath: string;
28
+ diff: string;
29
+ liveRun: boolean;
30
+ };
31
+ export declare function DiffConfirmationScreen(props: DiffConfirmationProps): import("react").JSX.Element;
32
+ export declare function StudioScreen({ tabs, active, lines, status, children, }: {
33
+ tabs: string[];
34
+ active: number;
35
+ lines: string[];
36
+ status?: string;
37
+ children?: ReactNode;
38
+ }): import("react").JSX.Element;
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { GLYPHS } from "../../brand.js";
4
+ import { RELOAD_GUARD_NOTICE } from "../save.js";
5
+ function keyedLines(lines) {
6
+ const occurrences = new Map();
7
+ return lines.map((text) => {
8
+ const occurrence = occurrences.get(text) ?? 0;
9
+ occurrences.set(text, occurrence + 1);
10
+ return { id: `${text}:${occurrence}`, text };
11
+ });
12
+ }
13
+ export function TextLines({ lines }) {
14
+ return (_jsx(_Fragment, { children: keyedLines(lines).map(({ id, text }) => _jsx(Text, { children: text || " " }, id)) }));
15
+ }
16
+ export function ToggleMark({ active }) {
17
+ return active
18
+ ? _jsx(Text, { color: "ansi256(41)", children: GLYPHS.toggleActive })
19
+ : _jsx(Text, { dimColor: true, children: GLYPHS.toggleInactive });
20
+ }
21
+ export function FleetListScreen({ title, legend, rows, cursor, details = [], }) {
22
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: title }), _jsx(Text, { dimColor: true, children: legend }), rows.map((row, index) => (_jsxs(Text, { bold: index === cursor, children: [index === cursor ? `${GLYPHS.pointer} ` : " ", row.content] }, row.id))), _jsx(TextLines, { lines: details })] }));
23
+ }
24
+ export function FleetReviewScreen({ title, legend, diff, }) {
25
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: title }), _jsx(Text, { dimColor: true, children: legend }), _jsx(Text, { children: diff })] }));
26
+ }
27
+ export function DiffConfirmationScreen(props) {
28
+ return _jsx(TextLines, { lines: [
29
+ `Save overlay — ${props.targetPath} (${props.target === "repo" ? "repository" : "global"} target)`,
30
+ "t toggle target · y confirm · n/esc cancel",
31
+ ...(props.liveRun ? [RELOAD_GUARD_NOTICE] : []),
32
+ "─ diff ─",
33
+ props.diff || "(no overlay changes)",
34
+ ] });
35
+ }
36
+ export function StudioScreen({ tabs, active, lines, status = "no staged changes", children, }) {
37
+ const tabBar = [
38
+ "tickmarkr ui ──",
39
+ ...tabs.map((label, index) => `[${index + 1}]${index === active ? "*" : ""}${label}`),
40
+ ].join(" ");
41
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: tabBar }), children ?? _jsx(TextLines, { lines: lines }), _jsx(Text, { dimColor: true, children: status })] }));
42
+ }
@@ -0,0 +1,113 @@
1
+ import type { AuthHealth, WorkerAdapter } from "../../adapters/types.js";
2
+ import type { MapEntry, RoutingMode, Tier } from "../../config/config.js";
3
+ import { type Shape } from "../../graph/schema.js";
4
+ export type FleetSteeringKey = "review" | "consult";
5
+ export type FleetEditorState = {
6
+ denyAdapters: string[];
7
+ denyModels: string[];
8
+ classifications: FleetClassification[];
9
+ selectedMode: RoutingMode;
10
+ map: Record<string, MapEntry>;
11
+ steering: Record<FleetSteeringKey, string[] | undefined>;
12
+ };
13
+ export type FleetOverlayReview = {
14
+ kind: "empty";
15
+ } | {
16
+ kind: "diff";
17
+ before: string;
18
+ after: string;
19
+ diff: string;
20
+ path: string;
21
+ };
22
+ export type FleetEditorResult = {
23
+ kind: "write";
24
+ review: Extract<FleetOverlayReview, {
25
+ kind: "diff";
26
+ }>;
27
+ } | {
28
+ kind: "discard";
29
+ } | {
30
+ kind: "no-changes";
31
+ } | {
32
+ kind: "quit";
33
+ } | {
34
+ kind: "refresh";
35
+ };
36
+ type AgentCli = {
37
+ id: string;
38
+ version: string;
39
+ authed: boolean;
40
+ };
41
+ export type FleetModelGroup = {
42
+ adapter: string;
43
+ rows: Array<{
44
+ model: string;
45
+ tier?: Tier;
46
+ detectedAt?: string;
47
+ }>;
48
+ };
49
+ export type FleetClassification = {
50
+ adapter: string;
51
+ model: string;
52
+ tier: Tier;
53
+ note: string;
54
+ };
55
+ export type FleetModeOption = {
56
+ id: RoutingMode;
57
+ gloss: string;
58
+ };
59
+ export type FleetShapeRow = {
60
+ id: Shape;
61
+ label: string;
62
+ };
63
+ export type FleetCandidateOption = {
64
+ id: string;
65
+ label: string;
66
+ pin: {
67
+ via: string;
68
+ model: string;
69
+ };
70
+ };
71
+ export declare function formatDoctorAge(ageMs: number | null): string;
72
+ export declare function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, }: {
73
+ ageMs: number | null;
74
+ agents: AgentCli[];
75
+ initialDenyAdapters: string[];
76
+ initialDenyModels: string[];
77
+ modelGroups: FleetModelGroup[];
78
+ initialMode: RoutingMode;
79
+ modeOptions: FleetModeOption[];
80
+ initialMap: Record<string, MapEntry>;
81
+ modePreview: (mode: RoutingMode, map: Record<string, MapEntry>) => string[];
82
+ shapeRows: (mode: RoutingMode, map: Record<string, MapEntry>) => FleetShapeRow[];
83
+ candidatesForShape: (shape: Shape, mode: RoutingMode, map: Record<string, MapEntry>) => FleetCandidateOption[];
84
+ preferOptionsForShape: (shape: Shape, current: string[]) => string[];
85
+ initialSteering: Record<FleetSteeringKey, string[] | undefined>;
86
+ steeringOptionsFor: (which: FleetSteeringKey, current: string[]) => string[];
87
+ reviewOverlay: (state: FleetEditorState) => FleetOverlayReview;
88
+ reloadGuard: (bytes: string) => string | null;
89
+ }): import("react").JSX.Element;
90
+ export declare function runFleetInkEditor({ ageMs, adapters, health, initialDenyAdapters, initialDenyModels, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, initialInput, input, output, debug, }: {
91
+ ageMs: number | null;
92
+ adapters: WorkerAdapter[];
93
+ health: Record<string, AuthHealth>;
94
+ initialDenyAdapters: string[];
95
+ initialDenyModels: string[];
96
+ modelGroups: FleetModelGroup[];
97
+ initialMode: RoutingMode;
98
+ modeOptions: FleetModeOption[];
99
+ initialMap: Record<string, MapEntry>;
100
+ modePreview: (mode: RoutingMode, map: Record<string, MapEntry>) => string[];
101
+ shapeRows: (mode: RoutingMode, map: Record<string, MapEntry>) => FleetShapeRow[];
102
+ candidatesForShape: (shape: Shape, mode: RoutingMode, map: Record<string, MapEntry>) => FleetCandidateOption[];
103
+ preferOptionsForShape: (shape: Shape, current: string[]) => string[];
104
+ initialSteering: Record<FleetSteeringKey, string[] | undefined>;
105
+ steeringOptionsFor: (which: FleetSteeringKey, current: string[]) => string[];
106
+ reviewOverlay: (state: FleetEditorState) => FleetOverlayReview;
107
+ reloadGuard: (bytes: string) => string | null;
108
+ initialInput?: string[];
109
+ input: NodeJS.ReadStream;
110
+ output: NodeJS.WriteStream;
111
+ debug?: boolean;
112
+ }): Promise<FleetEditorResult>;
113
+ export {};