tickmarkr 2.1.9 → 2.2.1
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/adapters/model-lints.js +9 -0
- package/dist/adapters/pi.d.ts +1 -0
- package/dist/adapters/pi.js +15 -1
- package/dist/adapters/prompt.js +11 -3
- package/dist/adapters/registry.js +13 -4
- package/dist/adapters/types.d.ts +3 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli/commands/approve.d.ts +20 -0
- package/dist/cli/commands/approve.js +127 -35
- package/dist/cli/commands/doctor.d.ts +1 -1
- package/dist/cli/commands/doctor.js +14 -4
- package/dist/cli/commands/plan.js +4 -1
- package/dist/cli/commands/verify.js +2 -0
- package/dist/cli/commands/version.d.ts +2 -1
- package/dist/cli/commands/version.js +25 -4
- package/dist/compile/collateral.js +149 -4
- package/dist/compile/native.js +2 -0
- package/dist/drivers/index.d.ts +1 -0
- package/dist/drivers/index.js +25 -10
- package/dist/drivers/orca.d.ts +19 -0
- package/dist/drivers/orca.js +45 -9
- package/dist/gates/baseline.d.ts +2 -0
- package/dist/gates/baseline.js +49 -14
- package/dist/gates/llm.d.ts +1 -1
- package/dist/gates/llm.js +41 -26
- package/dist/gates/review.js +2 -0
- package/dist/gates/run-gates.d.ts +6 -0
- package/dist/gates/run-gates.js +9 -1
- package/dist/run/consult.d.ts +7 -0
- package/dist/run/consult.js +16 -2
- package/dist/run/daemon.d.ts +12 -6
- package/dist/run/daemon.js +306 -24
- package/dist/run/journal.d.ts +16 -0
- package/dist/run/journal.js +51 -6
- package/dist/tui/cockpit/setup-cockpit.d.ts +8 -10
- package/dist/tui/cockpit/setup-cockpit.js +92 -53
- package/package.json +1 -1
- package/skills/tickmarkr-loop/SKILL.md +1 -0
- package/skills/tickmarkr-overseer/SKILL.md +17 -0
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +2 -2
|
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { DEFAULT_CONFIG, TIER_RANK } from "../config/config.js";
|
|
5
5
|
import { filesGlob } from "../graph/files-glob.js";
|
|
6
|
+
import { piModelVendor } from "./pi.js";
|
|
6
7
|
import { buildTaskPrompt } from "./prompt.js";
|
|
7
8
|
import { channelKey, MODEL_ID_RE } from "./types.js";
|
|
8
9
|
import { resolveCatalogModel } from "./catalog-remote.js";
|
|
@@ -446,6 +447,14 @@ export function modelLints(cfg, health, adapters, opts) {
|
|
|
446
447
|
const lints = [];
|
|
447
448
|
for (const adapter of adapters) {
|
|
448
449
|
const id = adapter.id;
|
|
450
|
+
if (id === "pi" && adapter.channels) {
|
|
451
|
+
for (const channel of adapter.channels(cfg)) {
|
|
452
|
+
const expected = cfg.tiers.pi?.modelOverrides?.[channel.model]?.vendor ?? piModelVendor(channel.model);
|
|
453
|
+
if (expected && channel.vendor !== expected) {
|
|
454
|
+
lints.push(`pi: ${channel.model} channel vendor ${channel.vendor} disagrees with provider vendor ${expected} — set tiers.pi.modelOverrides.${channel.model}.vendor to ${expected}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
449
458
|
if (!adapter.listModels) {
|
|
450
459
|
// v1.90 / OBS-504: the seeds-stamped wording presumes a seeded tier table. agy ships routable
|
|
451
460
|
// but UNCLASSIFIED (no listModels, no seed models) — for that shape the honest sentence names
|
package/dist/adapters/pi.d.ts
CHANGED
|
@@ -6,4 +6,5 @@ export interface ServedModelDrift {
|
|
|
6
6
|
}
|
|
7
7
|
export declare function readPiServedModels(): ServedModelDrift[];
|
|
8
8
|
export declare function servedModelNote(drifts?: ServedModelDrift[]): string;
|
|
9
|
+
export declare function piModelVendor(model: string): string | undefined;
|
|
9
10
|
export declare const pi: WorkerAdapter;
|
package/dist/adapters/pi.js
CHANGED
|
@@ -118,6 +118,17 @@ export function servedModelNote(drifts = readPiServedModels()) {
|
|
|
118
118
|
return "";
|
|
119
119
|
return `served-model drift: ${drifts.map((d) => `pinned ${d.pinned} served ${d.served}`).join(", ")}`;
|
|
120
120
|
}
|
|
121
|
+
const PI_PROVIDER_VENDORS = {
|
|
122
|
+
anthropic: "anthropic",
|
|
123
|
+
google: "google",
|
|
124
|
+
openai: "openai",
|
|
125
|
+
"openai-codex": "openai",
|
|
126
|
+
xai: "xai",
|
|
127
|
+
zai: "zhipu",
|
|
128
|
+
};
|
|
129
|
+
export function piModelVendor(model) {
|
|
130
|
+
return PI_PROVIDER_VENDORS[model.split("/", 1)[0]];
|
|
131
|
+
}
|
|
121
132
|
export const pi = {
|
|
122
133
|
id: "pi",
|
|
123
134
|
// FLEET-04: cross-vendor review honesty — GLM's provider (pi's own label is "zai"; either is
|
|
@@ -140,7 +151,10 @@ export const pi = {
|
|
|
140
151
|
const note = `auth verified via pi --list-models (free; auth-filtered by pi)${drift ? `; ${drift}` : ""}`;
|
|
141
152
|
return { ...h, servable: parsePiModels(r.stdout || ""), note };
|
|
142
153
|
},
|
|
143
|
-
channels: (cfg) => channelsFromConfig("pi", cfg)
|
|
154
|
+
channels: (cfg) => channelsFromConfig("pi", cfg).map((channel) => ({
|
|
155
|
+
...channel,
|
|
156
|
+
vendor: cfg.tiers.pi?.modelOverrides?.[channel.model]?.vendor ?? piModelVendor(channel.model) ?? channel.vendor,
|
|
157
|
+
})),
|
|
144
158
|
// v1.65 T3: every flag the command builders below hardcode — verified in `pi --help` 2026-07-22.
|
|
145
159
|
hardcodedFlags: { binary: "pi", flags: ["-p", "--approve", "--model"] },
|
|
146
160
|
// --approve: pi's per-directory trust prompt would stall fresh worktrees (herdr scrapes the dialog
|
package/dist/adapters/prompt.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { renderAcceptanceItem } from "../graph/schema.js";
|
|
4
4
|
import { classifyVerdictCause } from "../gates/verdict-cause.js";
|
|
@@ -30,8 +30,16 @@ TICKMARKR_RESULT_${nonce} {"ok":true|false,"summary":"<one sentence>","deviation
|
|
|
30
30
|
`;
|
|
31
31
|
}
|
|
32
32
|
export function writePrompt(dir, task, attempt, feedback = "", nonce = "") {
|
|
33
|
-
const
|
|
34
|
-
|
|
33
|
+
const prompts = join(dir, "prompts");
|
|
34
|
+
const p = join(prompts, `${task.id}-a${attempt}.md`);
|
|
35
|
+
mkdirSync(prompts, { recursive: true });
|
|
36
|
+
if (existsSync(p)) {
|
|
37
|
+
let engagement = 0;
|
|
38
|
+
let archive = join(prompts, `${task.id}-a${attempt}-engagement-${engagement}.md`);
|
|
39
|
+
while (existsSync(archive))
|
|
40
|
+
archive = join(prompts, `${task.id}-a${attempt}-engagement-${++engagement}.md`);
|
|
41
|
+
copyFileSync(p, archive);
|
|
42
|
+
}
|
|
35
43
|
writeFileSync(p, buildTaskPrompt(task, feedback, nonce));
|
|
36
44
|
return p;
|
|
37
45
|
}
|
|
@@ -11,7 +11,7 @@ import { FakeAdapter } from "./fake.js";
|
|
|
11
11
|
import { parseWorkerResult } from "./prompt.js";
|
|
12
12
|
import { sealHerdrEnv } from "../drivers/subprocess.js";
|
|
13
13
|
import { catalogEntries, isNativeCliDrive, projectCliEntries, SHIPPED_CLI_CATALOG, } from "./catalog.js";
|
|
14
|
-
import { channelKey, channelsFromConfig, modelAuthed, MODEL_ID_RE, QUOTA_RE, shq, } from "./types.js";
|
|
14
|
+
import { channelKey, channelsFromConfig, modelAuthed, MODEL_ID_RE, MODEL_PROBE_ERRORS, QUOTA_RE, shq, } from "./types.js";
|
|
15
15
|
// Compatibility projection for callers/tests that only need shipped advisory names. The literal
|
|
16
16
|
// list is gone: both advisory and routable catalog views derive from SHIPPED_CLI_CATALOG. Native
|
|
17
17
|
// definitions (claudeCode, codex, cursorAgent, opencode, pi, grok, kimi) are owned by catalog.ts;
|
|
@@ -410,6 +410,12 @@ function probeFailure(code, stdout, stderr, timedOut, timeoutMs = MODEL_PROBE_TI
|
|
|
410
410
|
? reasonTail(output) || `probe exited ${code}`
|
|
411
411
|
: undefined;
|
|
412
412
|
}
|
|
413
|
+
const PROBE_ERROR_RE = new RegExp(`\\b(${MODEL_PROBE_ERRORS.join("|")})\\b`);
|
|
414
|
+
function probeError(code, stdout, stderr, timedOut) {
|
|
415
|
+
if (timedOut || code === 0)
|
|
416
|
+
return undefined;
|
|
417
|
+
return PROBE_ERROR_RE.exec(`${stderr}\n${stdout}`)?.[1];
|
|
418
|
+
}
|
|
413
419
|
const probeModelStatus = (v) => v.authed ? "ok" : v.reason?.includes("timed out") ? "timeout" : "failed";
|
|
414
420
|
// v1.21: one bounded, headless call per configured model; detected-but-unclassified models never enter this loop.
|
|
415
421
|
export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
|
|
@@ -436,7 +442,7 @@ export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
|
|
|
436
442
|
const attempt = async (model, retry) => {
|
|
437
443
|
const t0 = Date.now();
|
|
438
444
|
const probedAt = new Date().toISOString();
|
|
439
|
-
const v = (authed, reason) => ({ authed, ...(reason !== undefined ? { reason } : {}), probedAt, durationMs: Date.now() - t0 });
|
|
445
|
+
const v = (authed, reason, error) => ({ authed, ...(reason !== undefined ? { reason } : {}), ...(error ? { probeError: error } : {}), probedAt, durationMs: Date.now() - t0 });
|
|
440
446
|
try {
|
|
441
447
|
if (typeof a.headlessCommand !== "function")
|
|
442
448
|
return { verdict: v(false, "headless probe unavailable"), timedOut: false };
|
|
@@ -455,8 +461,11 @@ export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
|
|
|
455
461
|
return { verdict: v(true), timedOut: false };
|
|
456
462
|
if (!retry)
|
|
457
463
|
return { verdict: null, timedOut: r.timedOut === true };
|
|
464
|
+
const error = probeError(r.code, r.stdout, r.stderr, r.timedOut);
|
|
458
465
|
return {
|
|
459
|
-
verdict:
|
|
466
|
+
verdict: error
|
|
467
|
+
? v(priorModelAuth?.[model]?.authed === true, undefined, error)
|
|
468
|
+
: r.timedOut && retry.firstTimedOut ? v(false, `probe timed out twice (${MODEL_PROBE_TIMEOUT_MS}ms)`) : v(false, reason),
|
|
460
469
|
timedOut: r.timedOut === true,
|
|
461
470
|
};
|
|
462
471
|
}
|
|
@@ -605,7 +614,7 @@ export function modelAuthExclusions(cfg, adapters, health) {
|
|
|
605
614
|
if (modelAuthed(h, c.model, cfg.routing.allowUnverifiedModels))
|
|
606
615
|
continue;
|
|
607
616
|
if (v?.authed === false)
|
|
608
|
-
out.push({ key: channelKey(c), adapter: a.id, reason: v.reason ?? "probe failed", probedAt: v.probedAt });
|
|
617
|
+
out.push({ key: channelKey(c), adapter: a.id, reason: v.probeError ? `probe-error (${v.probeError})` : v.reason ?? "probe failed", probedAt: v.probedAt });
|
|
609
618
|
else
|
|
610
619
|
out.push({ key: channelKey(c), adapter: a.id, reason: "no model auth verdict — run tickmarkr doctor", probedAt: "not recorded" });
|
|
611
620
|
}
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -23,9 +23,12 @@ export interface BillingChannel {
|
|
|
23
23
|
channel: "sub" | "api";
|
|
24
24
|
tier: Tier;
|
|
25
25
|
}
|
|
26
|
+
export declare const MODEL_PROBE_ERRORS: readonly ["EMFILE", "EAGAIN", "ENFILE", "ENOMEM", "ENOSPC"];
|
|
27
|
+
export type ModelProbeError = typeof MODEL_PROBE_ERRORS[number];
|
|
26
28
|
export interface ModelAuth {
|
|
27
29
|
authed: boolean;
|
|
28
30
|
reason?: string;
|
|
31
|
+
probeError?: ModelProbeError;
|
|
29
32
|
probedAt: string;
|
|
30
33
|
}
|
|
31
34
|
export interface AuthHealth {
|
package/dist/adapters/types.js
CHANGED
|
@@ -23,6 +23,7 @@ export function addUsage(a, b) {
|
|
|
23
23
|
reasoning: add(a.reasoning, b.reasoning),
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
+
export const MODEL_PROBE_ERRORS = ["EMFILE", "EAGAIN", "ENFILE", "ENOMEM", "ENOSPC"];
|
|
26
27
|
export function modelAuthed(health, model, allowUnverifiedModels = false) {
|
|
27
28
|
const authed = health?.modelAuth?.[model]?.authed;
|
|
28
29
|
return authed === true || (authed === undefined && allowUnverifiedModels);
|
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
export declare const APPROVAL_DISPOSITIONS: readonly ["dispatch", "waive-gate", "re-dispatch", "fund-fixed-attempt", "fresh-budget"];
|
|
2
|
+
export type ApprovalDisposition = (typeof APPROVAL_DISPOSITIONS)[number];
|
|
3
|
+
/**
|
|
4
|
+
* What each disposition's release actually buys — the clause every operator-facing sentence about
|
|
5
|
+
* this approval ends on. One table, because two surfaces say it: this command's own message, and the
|
|
6
|
+
* setup cockpit's pre-write confirm inset, which predicts the effect BEFORE the write. A prediction
|
|
7
|
+
* that can drift from the write is worth less than none, so neither surface writes the phrase itself.
|
|
8
|
+
*/
|
|
9
|
+
export declare const APPROVAL_ENACTS: Record<ApprovalDisposition, string>;
|
|
10
|
+
export declare function approvalDispositionForRelease(release: unknown): ApprovalDisposition;
|
|
1
11
|
export type ApprovalStatus = "deferred-live" | "recorded-no-owner";
|
|
12
|
+
/** The requested run plus the different live run currently blocking its repository, when present. */
|
|
13
|
+
export interface ApprovalRunOwner {
|
|
14
|
+
runId: string;
|
|
15
|
+
live: boolean;
|
|
16
|
+
blockingRunId?: string;
|
|
17
|
+
}
|
|
18
|
+
/** The same read `approve` performs, for surfaces that must predict an enactment before writing. */
|
|
19
|
+
export declare function approvalRunOwner(cwd: string, runId: string): ApprovalRunOwner;
|
|
20
|
+
/** The one sentence that says who enacts this release and what it buys. */
|
|
21
|
+
export declare function approvalEnactment(token: ApprovalDisposition, run: ApprovalRunOwner): string;
|
|
2
22
|
/** The production command registered in COMMANDS; its returned bytes are what the CLI prints. */
|
|
3
23
|
export declare function approve(argv: string[], cwd?: string): Promise<string>;
|
|
@@ -1,12 +1,65 @@
|
|
|
1
1
|
import { userInfo } from "node:os";
|
|
2
2
|
import { GATE_NAMES } from "../../graph/schema.js";
|
|
3
3
|
import { ATTEMPT_CAP_RELEASE, GATE_SATISFIED_RELEASE, Journal, RECHECK_RELEASE, REVIEW_UPHELD_RELEASE } from "../../run/journal.js";
|
|
4
|
+
export const APPROVAL_DISPOSITIONS = ["dispatch", "waive-gate", "re-dispatch", "fund-fixed-attempt", "fresh-budget"];
|
|
5
|
+
/**
|
|
6
|
+
* What each disposition's release actually buys — the clause every operator-facing sentence about
|
|
7
|
+
* this approval ends on. One table, because two surfaces say it: this command's own message, and the
|
|
8
|
+
* setup cockpit's pre-write confirm inset, which predicts the effect BEFORE the write. A prediction
|
|
9
|
+
* that can drift from the write is worth less than none, so neither surface writes the phrase itself.
|
|
10
|
+
*/
|
|
11
|
+
export const APPROVAL_ENACTS = {
|
|
12
|
+
dispatch: "dispatch it",
|
|
13
|
+
"waive-gate": "continue past the approved gate",
|
|
14
|
+
"re-dispatch": "re-dispatch against the full gate suite",
|
|
15
|
+
"fund-fixed-attempt": "dispatch a fixed attempt carrying the findings",
|
|
16
|
+
"fresh-budget": "dispatch it on a fresh attempt budget",
|
|
17
|
+
};
|
|
18
|
+
export function approvalDispositionForRelease(release) {
|
|
19
|
+
if (release === GATE_SATISFIED_RELEASE)
|
|
20
|
+
return "waive-gate";
|
|
21
|
+
if (release === RECHECK_RELEASE)
|
|
22
|
+
return "re-dispatch";
|
|
23
|
+
if (release === REVIEW_UPHELD_RELEASE)
|
|
24
|
+
return "fund-fixed-attempt";
|
|
25
|
+
if (release === ATTEMPT_CAP_RELEASE)
|
|
26
|
+
return "fresh-budget";
|
|
27
|
+
return "dispatch";
|
|
28
|
+
}
|
|
4
29
|
import { acquireApprovalSerialization, runLockOwner } from "../../run/lock.js";
|
|
30
|
+
// THE liveness rule, written once. The lock is REPOSITORY-wide, so a live owner of some OTHER run is
|
|
31
|
+
// not an owner of this one and sweeps none of its approvals — claiming otherwise is the same
|
|
32
|
+
// falsehood in a new shape. Liveness itself comes from lock.ts's runLockOwner (the same inspect() the
|
|
33
|
+
// acquire/unlock decision table uses), never a second `process.kill(pid, 0)`, and never the lock
|
|
34
|
+
// FILE's presence: a stale lock whose recorded pid is dead is not a live run.
|
|
35
|
+
const ownedByLiveDaemon = (owner, runId) => {
|
|
36
|
+
const run = { runId, live: owner?.live === true && owner.runId === runId };
|
|
37
|
+
if (owner?.live === true && owner.runId !== undefined && owner.runId !== runId) {
|
|
38
|
+
// Preserve the shipped enumerable { runId, live } shape while carrying the third state.
|
|
39
|
+
Object.defineProperty(run, "blockingRunId", { value: owner.runId });
|
|
40
|
+
}
|
|
41
|
+
return run;
|
|
42
|
+
};
|
|
43
|
+
/** The same read `approve` performs, for surfaces that must predict an enactment before writing. */
|
|
44
|
+
export function approvalRunOwner(cwd, runId) {
|
|
45
|
+
return ownedByLiveDaemon(runLockOwner(cwd), runId);
|
|
46
|
+
}
|
|
47
|
+
/** The one sentence that says who enacts this release and what it buys. */
|
|
48
|
+
export function approvalEnactment(token, run) {
|
|
49
|
+
if (run.live) {
|
|
50
|
+
return `the live daemon enacts this at its next task boundary — it will ${APPROVAL_ENACTS[token]}`;
|
|
51
|
+
}
|
|
52
|
+
if (run.blockingRunId) {
|
|
53
|
+
return `release recorded; live run \`${run.blockingRunId}\` holds the repository lock, so resume \`${run.runId}\` after it ends to ${APPROVAL_ENACTS[token]}`;
|
|
54
|
+
}
|
|
55
|
+
return `run \`tickmarkr resume ${run.runId}\` to ${APPROVAL_ENACTS[token]}`;
|
|
56
|
+
}
|
|
5
57
|
/** The production command registered in COMMANDS; its returned bytes are what the CLI prints. */
|
|
6
58
|
export async function approve(argv, cwd = process.cwd()) {
|
|
7
|
-
const { runId, taskId, by, reason, uphold, recheck, reviewRoundCeiling } = parseArgs(argv);
|
|
8
|
-
|
|
9
|
-
|
|
59
|
+
const { runId, taskId, by, reason, waive, uphold, recheck, reviewRoundCeiling } = parseArgs(argv);
|
|
60
|
+
const decisions = [waive, uphold, recheck].filter(Boolean).length;
|
|
61
|
+
if (decisions > 1)
|
|
62
|
+
throw new Error("--waive, --uphold and --recheck are different decisions — pass one");
|
|
10
63
|
const serialization = await acquireApprovalSerialization(cwd, runId);
|
|
11
64
|
try {
|
|
12
65
|
// Journal.open throws `no journal for <runId> at <dir>` on an unknown run — that IS the refusal.
|
|
@@ -30,13 +83,15 @@ export async function approve(argv, cwd = process.cwd()) {
|
|
|
30
83
|
}
|
|
31
84
|
}
|
|
32
85
|
const lastHuman = events[lastHumanIndex];
|
|
86
|
+
const capPark = lastHuman?.data.kind === ATTEMPT_CAP_RELEASE;
|
|
87
|
+
const gateFailPark = lastHuman?.data.kind === "gate-fail";
|
|
88
|
+
const failedGate = gateFailPark ? failedGateForNewestPark(events, taskId, lastHumanIndex) : undefined;
|
|
89
|
+
if (gateFailPark && !failedGate) {
|
|
90
|
+
throw new Error(`task ${taskId} is parked on gate-fail but has no failed gate result on the newest park — refusing to infer one`);
|
|
91
|
+
}
|
|
33
92
|
if (uphold) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const lastFailed = events.slice(0, lastHumanIndex).reverse().find((e) => e.event === "gate-result" && e.taskId === taskId && e.data.pass === false
|
|
37
|
-
&& typeof e.data.gate === "string" && GATE_NAMES.includes(e.data.gate))?.data.gate;
|
|
38
|
-
if (lastFailed !== "review") {
|
|
39
|
-
throw new Error(`--uphold applies to a review rejection; ${taskId}'s last failed gate is ${lastFailed ?? "none"} — refusing`);
|
|
93
|
+
if (!gateFailPark || failedGate !== "review") {
|
|
94
|
+
throw new Error(`--uphold applies to a review gate-fail park; ${taskId}'s newest park is ${String(lastHuman?.data.kind ?? "none")} with failed gate ${failedGate ?? "none"} — refusing`);
|
|
40
95
|
}
|
|
41
96
|
journal.append("task-approved", taskId, {
|
|
42
97
|
by,
|
|
@@ -46,15 +101,11 @@ export async function approve(argv, cwd = process.cwd()) {
|
|
|
46
101
|
gate: "review",
|
|
47
102
|
...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
|
|
48
103
|
});
|
|
49
|
-
return disposition(cwd, runId, `upheld the reviewer for ${taskId} in ${runId} — by ${by}
|
|
104
|
+
return disposition(cwd, runId, "fund-fixed-attempt", `upheld the reviewer for ${taskId} in ${runId} — by ${by}`, serialization.contended);
|
|
50
105
|
}
|
|
51
|
-
const capPark = lastHuman?.data.kind === ATTEMPT_CAP_RELEASE;
|
|
52
|
-
const gateFailPark = lastHuman?.data.kind === "gate-fail";
|
|
53
106
|
if (recheck) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (!gateFailPark) {
|
|
57
|
-
throw new Error(`--recheck applies to a gate-fail park; ${taskId}'s park kind is ${lastHuman?.data.kind ?? "none"} — refusing`);
|
|
107
|
+
if (!gateFailPark || !failedGate) {
|
|
108
|
+
throw new Error(`--recheck applies to a gate-fail park; ${taskId}'s newest park is ${String(lastHuman?.data.kind ?? "none")} with failed gate ${failedGate ?? "none"} — refusing`);
|
|
58
109
|
}
|
|
59
110
|
journal.append("task-approved", taskId, {
|
|
60
111
|
by,
|
|
@@ -63,14 +114,27 @@ export async function approve(argv, cwd = process.cwd()) {
|
|
|
63
114
|
release: RECHECK_RELEASE,
|
|
64
115
|
...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
|
|
65
116
|
});
|
|
66
|
-
return disposition(cwd, runId, `re-checking ${taskId} in ${runId} — by ${by};
|
|
117
|
+
return disposition(cwd, runId, "re-dispatch", `re-checking ${taskId} in ${runId} — by ${by}; failed gate ${failedGate}; no gate marked satisfied`, serialization.contended);
|
|
67
118
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
119
|
+
if (waive) {
|
|
120
|
+
if (!gateFailPark || !failedGate) {
|
|
121
|
+
throw new Error(`--waive applies to a gate-fail park; ${taskId}'s newest park is ${String(lastHuman?.data.kind ?? "none")} with failed gate ${failedGate ?? "none"} — refusing`);
|
|
122
|
+
}
|
|
123
|
+
journal.append("task-approved", taskId, {
|
|
124
|
+
by,
|
|
125
|
+
...(reason ? { reason } : {}),
|
|
126
|
+
via: "cli",
|
|
127
|
+
release: GATE_SATISFIED_RELEASE,
|
|
128
|
+
gate: failedGate,
|
|
129
|
+
...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
|
|
130
|
+
});
|
|
131
|
+
return disposition(cwd, runId, "waive-gate", `waived failed gate ${failedGate} for ${taskId} in ${runId} — by ${by}`, serialization.contended);
|
|
132
|
+
}
|
|
133
|
+
if (gateFailPark) {
|
|
134
|
+
const choices = [`--waive (disposition waive-gate)`, `--recheck (disposition re-dispatch)`];
|
|
135
|
+
if (failedGate === "review")
|
|
136
|
+
choices.push(`--uphold (disposition fund-fixed-attempt)`);
|
|
137
|
+
throw new Error(`task ${taskId} is parked on failed gate ${failedGate}; plain approve has disposition only for non-gate parks — pass ${choices.join(" or ")}`);
|
|
74
138
|
}
|
|
75
139
|
journal.append("task-approved", taskId, {
|
|
76
140
|
by,
|
|
@@ -78,9 +142,9 @@ export async function approve(argv, cwd = process.cwd()) {
|
|
|
78
142
|
via: "cli",
|
|
79
143
|
...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
|
|
80
144
|
...(capPark ? { release: ATTEMPT_CAP_RELEASE } : {}),
|
|
81
|
-
...(failedGate ? { release: GATE_SATISFIED_RELEASE, gate: failedGate } : {}),
|
|
82
145
|
});
|
|
83
|
-
|
|
146
|
+
const token = capPark ? "fresh-budget" : "dispatch";
|
|
147
|
+
return disposition(cwd, runId, token, `approved ${taskId} in ${runId} — by ${by}`, serialization.contended);
|
|
84
148
|
}
|
|
85
149
|
finally {
|
|
86
150
|
serialization.release();
|
|
@@ -89,29 +153,40 @@ export async function approve(argv, cwd = process.cwd()) {
|
|
|
89
153
|
// The status is command OUTPUT, not a typed sibling result: the registered approve function returns
|
|
90
154
|
// one primitive string and the dispatcher prints those bytes unchanged. A compact sentinel followed
|
|
91
155
|
// by JSON makes the contract unambiguous to machines without widening the shared command result type.
|
|
92
|
-
// No-lock approvals keep their historical one-line result for the cockpit; a dead recorded owner
|
|
93
|
-
// a command delayed behind terminalization
|
|
94
|
-
|
|
156
|
+
// No-lock approvals keep their historical one-line result for the cockpit; a dead recorded owner, a
|
|
157
|
+
// live owner executing some OTHER run, and a command delayed behind terminalization all emit
|
|
158
|
+
// recorded-no-owner — none of them is a daemon that will sweep this run's approvals.
|
|
159
|
+
//
|
|
160
|
+
// The ENACTMENT half of the message is completed here because only here is liveness known: the call
|
|
161
|
+
// sites carry the decision, not the answer to who will act on it. `deferred-live` keeps its v1.89
|
|
162
|
+
// token — machine consumers parse it — while its TEXT now names the boundary sweep. A recovery
|
|
163
|
+
// command is emitted only with no live repository owner; a different run's live owner instead names
|
|
164
|
+
// the blocker and waits until it ends.
|
|
165
|
+
function disposition(cwd, runId, token, message, contended) {
|
|
95
166
|
const owner = runLockOwner(cwd);
|
|
96
|
-
const
|
|
167
|
+
const run = ownedByLiveDaemon(owner, runId);
|
|
168
|
+
const out = `approval disposition ${token}: ${message}; ${approvalEnactment(token, run)}`;
|
|
97
169
|
if (!owner && !contended)
|
|
98
|
-
return
|
|
99
|
-
const status =
|
|
170
|
+
return out;
|
|
171
|
+
const status = run.live ? "deferred-live" : "recorded-no-owner";
|
|
100
172
|
const record = {
|
|
101
173
|
status,
|
|
102
|
-
|
|
174
|
+
disposition: token,
|
|
175
|
+
// Resume is safe to prescribe only when no live repository owner would contend with it.
|
|
176
|
+
...(!run.live && !run.blockingRunId ? { resume: `tickmarkr resume ${runId}` } : {}),
|
|
103
177
|
...(owner?.pid === undefined ? {} : { ownerPid: owner.pid }),
|
|
104
178
|
...(owner?.runId === undefined ? {} : { ownerRunId: owner.runId }),
|
|
105
179
|
};
|
|
106
|
-
return `${
|
|
180
|
+
return `${out}\nTICKMARKR_APPROVAL ${JSON.stringify(record)}`;
|
|
107
181
|
}
|
|
108
|
-
const USAGE = "usage: tickmarkr approve <run-id> <task-id> [--uphold|--recheck] [--review-rounds <positive-integer>] [--by <name>] [--reason <text>]";
|
|
182
|
+
const USAGE = "usage: tickmarkr approve <run-id> <task-id> [--waive|--uphold|--recheck] [--review-rounds <positive-integer>] [--by <name>] [--reason <text>]";
|
|
109
183
|
// hand-parsed argv — no CLI framework (house style). Positionals are runId then taskId; decision,
|
|
110
184
|
// ceiling, actor and reason are flags. Throws usage on missing positionals (mirrors resume.ts/unlock.ts).
|
|
111
185
|
function parseArgs(argv) {
|
|
112
186
|
const positionals = [];
|
|
113
187
|
let by;
|
|
114
188
|
let reason;
|
|
189
|
+
let waive = false;
|
|
115
190
|
let uphold = false;
|
|
116
191
|
let recheck = false;
|
|
117
192
|
let reviewRoundCeiling;
|
|
@@ -127,6 +202,9 @@ function parseArgs(argv) {
|
|
|
127
202
|
if (!reason)
|
|
128
203
|
throw new Error(USAGE);
|
|
129
204
|
}
|
|
205
|
+
else if (a === "--waive") {
|
|
206
|
+
waive = true;
|
|
207
|
+
}
|
|
130
208
|
else if (a === "--uphold") {
|
|
131
209
|
uphold = true;
|
|
132
210
|
}
|
|
@@ -150,5 +228,19 @@ function parseArgs(argv) {
|
|
|
150
228
|
if (!runId || !taskId) {
|
|
151
229
|
throw new Error(USAGE);
|
|
152
230
|
}
|
|
153
|
-
return { runId, taskId, by: by ?? userInfo().username, reason, uphold, recheck, reviewRoundCeiling };
|
|
231
|
+
return { runId, taskId, by: by ?? userInfo().username, reason, waive, uphold, recheck, reviewRoundCeiling };
|
|
232
|
+
}
|
|
233
|
+
function failedGateForNewestPark(events, taskId, lastHumanIndex) {
|
|
234
|
+
for (let i = lastHumanIndex - 1; i >= 0; i -= 1) {
|
|
235
|
+
const event = events[i];
|
|
236
|
+
if (event.taskId !== taskId)
|
|
237
|
+
continue;
|
|
238
|
+
if (event.event === "task-approved" || event.event === "task-human" || event.event === "task-dispatch")
|
|
239
|
+
return undefined;
|
|
240
|
+
if (event.event === "gate-result" && event.data.pass === false
|
|
241
|
+
&& typeof event.data.gate === "string" && GATE_NAMES.includes(event.data.gate)) {
|
|
242
|
+
return event.data.gate;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
154
246
|
}
|
|
@@ -12,7 +12,7 @@ import { graphPath, loadGraph, tickmarkrDir, stateDirName } from "../../graph/gr
|
|
|
12
12
|
import { catalogModelAdvisory, catalogTierRanking, declaredModelWindow, hasWindowsConfig, modelLints, suggestOverlay, ttyVisual } from "../../adapters/model-lints.js";
|
|
13
13
|
import { loadConfig, overlayPreferShapes } from "../../config/config.js";
|
|
14
14
|
import { HerdrDriver } from "../../drivers/herdr.js";
|
|
15
|
-
import { parseEnvelope } from "../../drivers/orca.js";
|
|
15
|
+
import { ORCA_FIXTURE_VERSION, parseEnvelope, resolveOrcaCliBinary } from "../../drivers/orca.js";
|
|
16
16
|
import { kimi, probeKimiDoctorTurn } from "../../adapters/kimi.js";
|
|
17
17
|
import { denyPreferCollisionLine, denyPreferCollisions, disallowedBy, excludedChannels, exclusionLine, preferRanks } from "../../route/preference.js";
|
|
18
18
|
import { LIVEBENCH_TABLE_DATE, readCachedCatalog, refreshCatalogCommand } from "../../adapters/catalog-remote.js";
|
|
@@ -34,7 +34,9 @@ const attentionRow = (text) => ` ${statusRow("warn", text)}`;
|
|
|
34
34
|
* stderr (Electron timestamps), so the row is byte-stable across runs.
|
|
35
35
|
*/
|
|
36
36
|
export async function probeOrcaCapability(cwd, opts = {}) {
|
|
37
|
-
const binary = opts.resolveOrcaBinary
|
|
37
|
+
const binary = opts.resolveOrcaBinary
|
|
38
|
+
? opts.resolveOrcaBinary(cwd)
|
|
39
|
+
: resolveOrcaCliBinary(cwd, { resolve: (bin, dir) => resolveShellBinary(bin, dir) });
|
|
38
40
|
if (!binary)
|
|
39
41
|
return { verdict: "fail", detail: "CLI not installed" };
|
|
40
42
|
let response;
|
|
@@ -60,8 +62,16 @@ export async function probeOrcaCapability(cwd, opts = {}) {
|
|
|
60
62
|
const reachable = typeof runtime === "object" && runtime !== null && !Array.isArray(runtime)
|
|
61
63
|
? runtime.reachable
|
|
62
64
|
: undefined;
|
|
63
|
-
if (reachable === true)
|
|
64
|
-
|
|
65
|
+
if (reachable === true) {
|
|
66
|
+
const appVersion = typeof runtime === "object" && runtime !== null && !Array.isArray(runtime)
|
|
67
|
+
? runtime.appVersion
|
|
68
|
+
: undefined;
|
|
69
|
+
if (typeof appVersion !== "string" || !appVersion) {
|
|
70
|
+
return { verdict: "warn", detail: `runtime reachable (${envelope.runtimeId}, appVersion absent; fixture pin ${ORCA_FIXTURE_VERSION})` };
|
|
71
|
+
}
|
|
72
|
+
const detail = `runtime reachable (${envelope.runtimeId}, appVersion ${appVersion}; fixture pin ${ORCA_FIXTURE_VERSION})`;
|
|
73
|
+
return { verdict: appVersion === ORCA_FIXTURE_VERSION ? "pass" : "warn", detail };
|
|
74
|
+
}
|
|
65
75
|
if (reachable === false)
|
|
66
76
|
return { verdict: "fail", detail: "CLI installed but runtime unreachable" };
|
|
67
77
|
return { verdict: "fail", detail: "CLI installed but runtime probe failed — status carries no reachability proof" };
|
|
@@ -17,6 +17,7 @@ import { loadRoutingProfile } from "../../run/journal.js";
|
|
|
17
17
|
import { harnessLine, resolveHarness } from "../harness.js";
|
|
18
18
|
import { shq } from "../../adapters/types.js";
|
|
19
19
|
import { shGit } from "../../run/git.js";
|
|
20
|
+
import { driverEvidence, pickDriver } from "../../drivers/index.js";
|
|
20
21
|
// T4 (v1.50): TTY-only brand pass — the title helper frames the routing table, lint/unroutable
|
|
21
22
|
// markers carry the attention glyph, section labels dim to chrome (the doctor/status system).
|
|
22
23
|
// Gated on ttyVisual(): the non-TTY surface returns untouched (byte-pinned, machine-consumable).
|
|
@@ -99,7 +100,7 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
99
100
|
const DOCTOR_STALE_MS = 24 * 60 * 60 * 1000;
|
|
100
101
|
// v1.51 T2: plan previews any mode without a config edit — same source precedence as run
|
|
101
102
|
// (flag > spec front-matter > repo > global > default), same preset compiler.
|
|
102
|
-
const { values } = parseArgs({ args: argv, options: { mode: { type: "string" } }, allowPositionals: true });
|
|
103
|
+
const { values } = parseArgs({ args: argv, options: { mode: { type: "string" }, driver: { type: "string" } }, allowPositionals: true });
|
|
103
104
|
if (values.mode !== undefined && !ROUTING_MODES.includes(values.mode)) {
|
|
104
105
|
throw new Error(`--mode must be one of ${ROUTING_MODES.join(" | ")} (got ${values.mode})`);
|
|
105
106
|
}
|
|
@@ -136,6 +137,7 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
136
137
|
inputRefusals.set(finding.taskId, [...(inputRefusals.get(finding.taskId) ?? []), finding.detail]);
|
|
137
138
|
}
|
|
138
139
|
const { cfg, mode, source } = resolveRunMode(cwd, { flag: values.mode, spec: g.mode });
|
|
140
|
+
const selectedDriver = pickDriver(cfg, values.driver);
|
|
139
141
|
// readDoctor cache path: staleness line only fires here (probeAll fallback is fresh by construction).
|
|
140
142
|
const cached = readDoctor(cwd);
|
|
141
143
|
const health = cached ?? (await probeAll(adapters));
|
|
@@ -157,6 +159,7 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
157
159
|
const lines = [
|
|
158
160
|
`tickmarkr plan — dry run (${channels.length} channels available)`,
|
|
159
161
|
`mode: ${mode.mode} (${source}) · explore ${cfg.routing.explore?.mode ?? "on"}`,
|
|
162
|
+
`driver: ${driverEvidence(cfg, selectedDriver, values.driver)}`,
|
|
160
163
|
harnessLine(resolveHarness(harnessFrom)),
|
|
161
164
|
"",
|
|
162
165
|
];
|
|
@@ -205,6 +205,8 @@ export async function verify(argv, cwd = process.cwd()) {
|
|
|
205
205
|
onGate: (e) => {
|
|
206
206
|
if (e.phase === "start")
|
|
207
207
|
console.error(`verify: → ${e.gate} (${e.index}/${e.total})`);
|
|
208
|
+
else if (e.phase === "note")
|
|
209
|
+
console.error(`verify: note ${e.gate} ${e.name} ${JSON.stringify(e.payload)}`);
|
|
208
210
|
else
|
|
209
211
|
console.error(`verify: ${e.result.pass ? "✓" : "✗"} ${e.result.gate} — ${e.result.details.split("\n")[0] ?? ""}`);
|
|
210
212
|
},
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export declare function
|
|
1
|
+
export declare function distFingerprint(dir?: string): string;
|
|
2
|
+
export declare function version(argv?: string[], dir?: string): Promise<string>;
|
|
@@ -1,8 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative } from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "../../../package.json");
|
|
5
|
-
|
|
6
|
+
const distDir = join(dirname(pkgPath), "dist");
|
|
7
|
+
export function distFingerprint(dir = distDir) {
|
|
8
|
+
if (!existsSync(dir))
|
|
9
|
+
return "missing";
|
|
10
|
+
const files = [];
|
|
11
|
+
const visit = (path) => {
|
|
12
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
13
|
+
const child = join(path, entry.name);
|
|
14
|
+
if (entry.isDirectory())
|
|
15
|
+
visit(child);
|
|
16
|
+
else if (entry.isFile())
|
|
17
|
+
files.push(child);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
visit(dir);
|
|
21
|
+
const hash = createHash("sha256");
|
|
22
|
+
for (const file of files.sort())
|
|
23
|
+
hash.update(relative(dir, file)).update("\0").update(readFileSync(file)).update("\0");
|
|
24
|
+
return hash.digest("hex");
|
|
25
|
+
}
|
|
26
|
+
export async function version(argv = [], dir = distDir) {
|
|
6
27
|
const { version: v } = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
7
|
-
return v;
|
|
28
|
+
return argv.includes("--dist") ? `${v} dist:${distFingerprint(dir)}` : v;
|
|
8
29
|
}
|