tickmarkr 1.72.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.
- package/dist/cli/commands/approve.js +21 -3
- package/dist/cli/commands/resume.js +10 -2
- package/dist/cli/commands/status.d.ts +2 -0
- package/dist/cli/commands/status.js +219 -30
- package/dist/cli/commands/ui.d.ts +6 -2
- package/dist/cli/commands/ui.js +2 -4
- package/dist/config/config.d.ts +4 -0
- package/dist/config/config.js +6 -1
- package/dist/drivers/herdr.d.ts +4 -0
- package/dist/drivers/herdr.js +72 -8
- package/dist/drivers/subprocess.d.ts +3 -3
- package/dist/drivers/subprocess.js +10 -5
- package/dist/route/preference.d.ts +2 -1
- package/dist/route/preference.js +8 -2
- package/dist/run/daemon.d.ts +1 -0
- package/dist/run/daemon.js +139 -4
- package/dist/run/git.d.ts +2 -0
- package/dist/run/git.js +55 -0
- package/dist/run/journal.d.ts +6 -1
- package/dist/run/journal.js +28 -1
- package/dist/tui/ink/components.d.ts +18 -0
- package/dist/tui/ink/components.js +30 -2
- package/dist/tui/ink/studio-app.d.ts +59 -0
- package/dist/tui/ink/studio-app.js +320 -0
- package/package.json +1 -1
- package/dist/tui/app.d.ts +0 -72
- package/dist/tui/app.js +0 -334
- package/dist/tui/engine.d.ts +0 -52
- package/dist/tui/engine.js +0 -92
- package/dist/tui/frame.d.ts +0 -22
- package/dist/tui/frame.js +0 -56
- package/dist/tui/input.d.ts +0 -41
- package/dist/tui/input.js +0 -100
- package/dist/tui/views/consult-dossier.d.ts +0 -49
- package/dist/tui/views/consult-dossier.js +0 -169
- package/dist/tui/views/diff-modal.d.ts +0 -9
- package/dist/tui/views/diff-modal.js +0 -19
- package/dist/tui/views/fleet-view.d.ts +0 -91
- package/dist/tui/views/fleet-view.js +0 -337
- package/dist/tui/views/preview-view.d.ts +0 -7
- package/dist/tui/views/preview-view.js +0 -80
- package/dist/tui/views/profile-view.d.ts +0 -15
- package/dist/tui/views/profile-view.js +0 -93
- package/dist/tui/views/routing-view.d.ts +0 -48
- package/dist/tui/views/routing-view.js +0 -234
- package/dist/tui/views/runs-view.d.ts +0 -70
- package/dist/tui/views/runs-view.js +0 -387
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { ExecutorDriver, NotifyOpts, Slot } from "./types.js";
|
|
2
2
|
export declare const MAX_BUF: number;
|
|
3
3
|
export declare const HERDR_CONTROL_VARS: readonly ["HERDR_ENV", "HERDR_SOCKET_PATH"];
|
|
4
|
-
/** Copy of env with herdr control-plane vars stripped.
|
|
4
|
+
/** Copy of worker env with the fork cap applied and herdr control-plane vars stripped. */
|
|
5
5
|
export declare function sealHerdrEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
6
|
-
/**
|
|
7
|
-
export declare function herdrSealShellPrefix(): string;
|
|
6
|
+
/** Pane/login-shell form of the same worker env seal (herdr seed + daemon setup). */
|
|
7
|
+
export declare function herdrSealShellPrefix(env?: NodeJS.ProcessEnv): string;
|
|
8
8
|
export declare class SubprocessDriver implements ExecutorDriver {
|
|
9
9
|
id: string;
|
|
10
10
|
interactive: boolean;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { shq } from "../adapters/types.js";
|
|
3
|
+
import { createWorktree, DEFAULT_FORK_CAP, FORK_CAP_ENV } from "../run/git.js";
|
|
3
4
|
// HARD-03: cap retained worker output so a chatty worker can't grow tickmarkr's heap unbounded.
|
|
4
5
|
// 2MB is safe against BOTH consumers: the largest read() call site asks for 1000 lines
|
|
5
6
|
// (daemon.ts:217/247), and every marker (TICKMARKR_RESULT, TICKMARKR_EXIT_<nonce>) is emitted at the
|
|
@@ -12,16 +13,20 @@ export const MAX_BUF = 2 * 1024 * 1024;
|
|
|
12
13
|
// (and its own herdr driver CLI calls) keep the live session. Socket path is the wire; HERDR_ENV
|
|
13
14
|
// is the "I am inside herdr" gate every agent skill checks before mutating panes.
|
|
14
15
|
export const HERDR_CONTROL_VARS = ["HERDR_ENV", "HERDR_SOCKET_PATH"];
|
|
15
|
-
/** Copy of env with herdr control-plane vars stripped.
|
|
16
|
+
/** Copy of worker env with the fork cap applied and herdr control-plane vars stripped. */
|
|
16
17
|
export function sealHerdrEnv(env = process.env) {
|
|
17
18
|
const out = { ...env };
|
|
19
|
+
if (!(FORK_CAP_ENV in out))
|
|
20
|
+
out[FORK_CAP_ENV] = DEFAULT_FORK_CAP;
|
|
18
21
|
for (const k of HERDR_CONTROL_VARS)
|
|
19
22
|
delete out[k];
|
|
20
23
|
return out;
|
|
21
24
|
}
|
|
22
|
-
/**
|
|
23
|
-
export function herdrSealShellPrefix() {
|
|
24
|
-
|
|
25
|
+
/** Pane/login-shell form of the same worker env seal (herdr seed + daemon setup). */
|
|
26
|
+
export function herdrSealShellPrefix(env = process.env) {
|
|
27
|
+
const forkCap = sealHerdrEnv(env)[FORK_CAP_ENV] ?? DEFAULT_FORK_CAP;
|
|
28
|
+
return `export ${FORK_CAP_ENV}=${shq(forkCap)}; ` +
|
|
29
|
+
HERDR_CONTROL_VARS.map((k) => `unset ${k}`).join("; ") + "; ";
|
|
25
30
|
}
|
|
26
31
|
export class SubprocessDriver {
|
|
27
32
|
id = "subprocess";
|
|
@@ -4,6 +4,7 @@ export interface Disallowed {
|
|
|
4
4
|
by: "deny" | "allow";
|
|
5
5
|
entry: string;
|
|
6
6
|
}
|
|
7
|
+
export type PreferenceRole = "worker" | "judge" | "review" | "consult";
|
|
7
8
|
export declare function excludedChannels(cfg: TickmarkrConfig, adapters: {
|
|
8
9
|
id: string;
|
|
9
10
|
}[] | string[], health: Record<string, AuthHealth>): {
|
|
@@ -24,7 +25,7 @@ export declare function preferRanks(c: {
|
|
|
24
25
|
export declare function disallowedBy(c: {
|
|
25
26
|
adapter: string;
|
|
26
27
|
model: string;
|
|
27
|
-
}, routing: TickmarkrConfig["routing"]): Disallowed | null;
|
|
28
|
+
}, routing: TickmarkrConfig["routing"], role?: PreferenceRole): Disallowed | null;
|
|
28
29
|
export interface DenyPreferCollision {
|
|
29
30
|
kind: "prefer" | "pin";
|
|
30
31
|
shape: string;
|
package/dist/route/preference.js
CHANGED
|
@@ -36,10 +36,16 @@ export function preferRanks(c, cfg) {
|
|
|
36
36
|
return out;
|
|
37
37
|
}
|
|
38
38
|
// entries in either list accept adapter id, model id, or adapter:model (grammar A1)
|
|
39
|
-
export function disallowedBy(c, routing) {
|
|
39
|
+
export function disallowedBy(c, routing, role = "worker") {
|
|
40
40
|
const { allow, deny } = routing;
|
|
41
41
|
const matches = (e) => e === c.adapter || e === c.model || e === channelKey(c);
|
|
42
|
-
const
|
|
42
|
+
const workerDeny = role === "worker" ? deny?.workers : undefined;
|
|
43
|
+
const d = [
|
|
44
|
+
...(deny?.adapters ?? []),
|
|
45
|
+
...(deny?.models ?? []),
|
|
46
|
+
...(workerDeny?.adapters ?? []),
|
|
47
|
+
...(workerDeny?.models ?? []),
|
|
48
|
+
].find(matches);
|
|
43
49
|
if (d)
|
|
44
50
|
return { by: "deny", entry: d };
|
|
45
51
|
if (allow) {
|
package/dist/run/daemon.d.ts
CHANGED
package/dist/run/daemon.js
CHANGED
|
@@ -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(() =>
|
|
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
|
}
|
package/dist/run/journal.d.ts
CHANGED
|
@@ -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[];
|
package/dist/run/journal.js
CHANGED
|
@@ -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() {
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import { type SaveTarget } from "../save.js";
|
|
2
3
|
export type FleetListRow = {
|
|
3
4
|
id: string;
|
|
4
5
|
content: ReactNode;
|
|
5
6
|
};
|
|
7
|
+
export declare function TextLines({ lines }: {
|
|
8
|
+
lines: string[];
|
|
9
|
+
}): import("react").JSX.Element;
|
|
6
10
|
export declare function ToggleMark({ active }: {
|
|
7
11
|
active: boolean;
|
|
8
12
|
}): import("react").JSX.Element;
|
|
@@ -18,3 +22,17 @@ export declare function FleetReviewScreen({ title, legend, diff, }: {
|
|
|
18
22
|
legend: string;
|
|
19
23
|
diff: string;
|
|
20
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;
|
|
@@ -1,14 +1,42 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
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
|
+
}
|
|
4
16
|
export function ToggleMark({ active }) {
|
|
5
17
|
return active
|
|
6
18
|
? _jsx(Text, { color: "ansi256(41)", children: GLYPHS.toggleActive })
|
|
7
19
|
: _jsx(Text, { dimColor: true, children: GLYPHS.toggleInactive });
|
|
8
20
|
}
|
|
9
21
|
export function FleetListScreen({ title, legend, rows, cursor, details = [], }) {
|
|
10
|
-
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))),
|
|
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 })] }));
|
|
11
23
|
}
|
|
12
24
|
export function FleetReviewScreen({ title, legend, diff, }) {
|
|
13
25
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: title }), _jsx(Text, { dimColor: true, children: legend }), _jsx(Text, { children: diff })] }));
|
|
14
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,59 @@
|
|
|
1
|
+
import { type RunGraph } from "../../graph/schema.js";
|
|
2
|
+
import { type JournalEvent } from "../../run/journal.js";
|
|
3
|
+
import { FleetStaging } from "../staging.js";
|
|
4
|
+
export type StudioInkView = {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
render(props: {
|
|
8
|
+
cols: number;
|
|
9
|
+
rows: number;
|
|
10
|
+
}): string[];
|
|
11
|
+
key?(name: string): void;
|
|
12
|
+
};
|
|
13
|
+
export type StudioInkIO = {
|
|
14
|
+
input: NodeJS.ReadStream;
|
|
15
|
+
output: NodeJS.WriteStream;
|
|
16
|
+
views?: StudioInkView[];
|
|
17
|
+
repoRoot?: string;
|
|
18
|
+
globalDir?: string;
|
|
19
|
+
staging?: FleetStaging;
|
|
20
|
+
runsData?: RunsCockpitData;
|
|
21
|
+
clock?: () => number;
|
|
22
|
+
debug?: boolean;
|
|
23
|
+
};
|
|
24
|
+
export type RunsCockpitData = {
|
|
25
|
+
runId?: string;
|
|
26
|
+
events: JournalEvent[];
|
|
27
|
+
graph: RunGraph;
|
|
28
|
+
prompts?: Record<string, string[]>;
|
|
29
|
+
};
|
|
30
|
+
type DossierVerdict = {
|
|
31
|
+
action: "retry" | "reroute" | "decompose" | "human";
|
|
32
|
+
reason?: string;
|
|
33
|
+
prompt?: string;
|
|
34
|
+
};
|
|
35
|
+
type ConsultDossierData = {
|
|
36
|
+
taskId: string;
|
|
37
|
+
verdicts: DossierVerdict[];
|
|
38
|
+
};
|
|
39
|
+
type RunsCockpitTask = {
|
|
40
|
+
id: string;
|
|
41
|
+
status: string;
|
|
42
|
+
phase?: string;
|
|
43
|
+
elapsed?: string;
|
|
44
|
+
consultCount: number;
|
|
45
|
+
};
|
|
46
|
+
export declare function foldConsultDossier(data: RunsCockpitData, taskId: string): ConsultDossierData;
|
|
47
|
+
export declare function buildRunsCockpitTasks(data: RunsCockpitData, nowMs: number): RunsCockpitTask[];
|
|
48
|
+
export declare function StudioApp({ views: suppliedViews, repoRoot, globalDir, staging: suppliedStaging, runsData: suppliedRunsData, clock, columns, rows, }: {
|
|
49
|
+
views?: StudioInkView[];
|
|
50
|
+
repoRoot?: string;
|
|
51
|
+
globalDir?: string;
|
|
52
|
+
staging?: FleetStaging;
|
|
53
|
+
runsData?: RunsCockpitData;
|
|
54
|
+
clock?: () => number;
|
|
55
|
+
columns?: number;
|
|
56
|
+
rows?: number;
|
|
57
|
+
}): import("react").JSX.Element;
|
|
58
|
+
export declare function runStudioInk({ input, output, views, repoRoot, globalDir, staging, runsData, clock, debug, }: StudioInkIO): Promise<void>;
|
|
59
|
+
export {};
|