tickmarkr 2.6.3 → 2.6.4
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/README.md +10 -2
- package/dist/adapters/codex.d.ts +2 -1
- package/dist/adapters/codex.js +26 -5
- package/dist/adapters/model-lints.d.ts +1 -0
- package/dist/adapters/model-lints.js +48 -1
- package/dist/adapters/model-windows.d.ts +2 -1
- package/dist/adapters/model-windows.js +18 -7
- package/dist/adapters/types.d.ts +6 -0
- package/dist/cli/commands/approve.js +27 -2
- package/dist/cli/commands/doctor.d.ts +2 -0
- package/dist/cli/commands/doctor.js +6 -1
- package/dist/cli/commands/fleet.js +241 -74
- package/dist/cli/commands/init.js +5 -3
- package/dist/cli/commands/report.d.ts +39 -2
- package/dist/cli/commands/report.js +227 -10
- package/dist/cli/commands/resume.js +2 -4
- package/dist/cli/commands/run.d.ts +11 -0
- package/dist/cli/commands/run.js +33 -5
- package/dist/cli/commands/status.js +30 -3
- package/dist/cli/commands/verify.js +332 -121
- package/dist/compile/native.js +130 -0
- package/dist/config/config.d.ts +25 -7
- package/dist/config/config.js +92 -11
- package/dist/route/router.d.ts +11 -0
- package/dist/route/router.js +29 -8
- package/dist/run/daemon.js +8 -3
- package/dist/run/journal.d.ts +97 -1
- package/dist/run/journal.js +337 -2
- package/dist/tui/cockpit/live-store.d.ts +6 -0
- package/dist/tui/cockpit/live-store.js +36 -11
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +2 -2
- package/skills/tickmarkr-loop/SKILL.md +10 -4
package/dist/run/journal.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
4
5
|
import { join } from "node:path";
|
|
@@ -6,9 +7,10 @@ import { z } from "zod";
|
|
|
6
7
|
import { channelKey, shq, TokenUsageSchema } from "../adapters/types.js";
|
|
7
8
|
import { graphDefinitionHash, stateDirName, taskContentDigest, taskDefinitionFingerprint, tickmarkrDir } from "../graph/graph.js";
|
|
8
9
|
import { GATE_NAMES, TIERS } from "../graph/schema.js";
|
|
9
|
-
import { channelRouteIdentity } from "../route/preference.js";
|
|
10
|
+
import { channelRouteIdentity, modelProvider } from "../route/preference.js";
|
|
10
11
|
import { buildProfile, classify } from "../route/profile.js";
|
|
11
12
|
import { DecisionEventSchema, trackJournalRows, } from "./protocol.js";
|
|
13
|
+
import { PRESERVE_COMMIT_SUBJECT, PRESERVE_PRODUCER_TRAILER } from "./git.js";
|
|
12
14
|
import { normalizeGateOutcome } from "./outcome.js";
|
|
13
15
|
import { redactSecrets } from "./redact.js";
|
|
14
16
|
export function phaseForGate(gate) {
|
|
@@ -1936,7 +1938,11 @@ export class Journal {
|
|
|
1936
1938
|
const reducedData = event === "run-end"
|
|
1937
1939
|
? (() => {
|
|
1938
1940
|
const preservedRefs = [...preservedRefsByTask(priorEvents)].flatMap(([preservedTaskId, refs]) => refs.map(({ ref, diffCommand }) => ({ taskId: preservedTaskId, ref, diffCommand })));
|
|
1939
|
-
|
|
1941
|
+
const reduced = preservedRefs.length > 0 ? { ...inputData, preservedRefs } : inputData;
|
|
1942
|
+
// C1: the terminal record carries the owed-check fold as of this append, revalidated from
|
|
1943
|
+
// each discharge's artifact — a reopened journal re-reduces, it never copies a prior total.
|
|
1944
|
+
// Canonical (validated) rows are strict and keep their schema; the live producer is the tuple.
|
|
1945
|
+
return decisionRow ? reduced : { ...reduced, owedChecks: foldOwedChecks(priorEvents, join(this.dir, "..", "..", "..")) };
|
|
1940
1946
|
})()
|
|
1941
1947
|
: event === "resume-restore" && rowTaskId && upheldFeedbackByTask(effectiveEvents(priorEvents)).has(rowTaskId)
|
|
1942
1948
|
? {
|
|
@@ -2383,3 +2389,332 @@ export class Journal {
|
|
|
2383
2389
|
.filter((row) => row && typeof row === "object" && "kind" in row && row.kind === "judge");
|
|
2384
2390
|
}
|
|
2385
2391
|
}
|
|
2392
|
+
// ── C1 (v2.6.4): owed checks ─────────────────────────────────────────────────────────────────────
|
|
2393
|
+
// A waiver accepts risk on ONE immutable subject. The same task-approved row that satisfies the gate
|
|
2394
|
+
// carries the obligation it leaves behind: run, task, gate, the exact base..head range and its
|
|
2395
|
+
// canonical subject, the acceptance criteria, the run-derived lifetime patch authors, the cause and
|
|
2396
|
+
// the evidence that would discharge it. The fold below is the only reader. It keeps every waiver as
|
|
2397
|
+
// accepted-risk history, subtracts a discharge only while its proof still revalidates, and answers
|
|
2398
|
+
// "unknown" — never zero — for a waiver without a well-formed obligation or a proof that no longer holds.
|
|
2399
|
+
export const OWED_CHECK_VERSION = 1;
|
|
2400
|
+
export const OWED_DISCHARGE_EVENT = "owed-check-discharged";
|
|
2401
|
+
const owedGit = (cwd, args, input) => execFileSync("git", args, {
|
|
2402
|
+
cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, ...(input === undefined ? { stdio: ["ignore", "pipe", "pipe"] } : { input }),
|
|
2403
|
+
});
|
|
2404
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
2405
|
+
export const owedCriteria = (acceptance) => sha256(JSON.stringify(acceptance));
|
|
2406
|
+
/** The daemon's canonical gate subject for base..head (daemon.ts gateCommitSubject), byte for byte. */
|
|
2407
|
+
export const owedSubject = (cwd, base, head) => sha256(owedGit(cwd, ["log", "--reverse", "--format=%T%x00%an%x00%ae%x00%cn%x00%ce%x00%B%x1e", `${base}..${head}`]));
|
|
2408
|
+
// Ownership joins on --stable ids (whitespace-blind: a re-indented carry keeps its author, which only
|
|
2409
|
+
// widens exclusions). Range identity uses --verbatim: re-indented content is a different program.
|
|
2410
|
+
const patchIdOf = (cwd, diff, mode = "--stable") => owedGit(cwd, ["patch-id", mode], diff).trim().split(/\s+/)[0] || undefined;
|
|
2411
|
+
/** Ordered verbatim patch ids of the commits in base..head; an empty commit authored no patch. */
|
|
2412
|
+
export const rangePatches = (cwd, base, head) => owedGit(cwd, ["rev-list", "--reverse", `${base}..${head}`]).trim().split("\n").filter(Boolean)
|
|
2413
|
+
.map((commit) => patchIdOf(cwd, owedGit(cwd, ["show", "--format=", "--binary", commit]), "--verbatim"))
|
|
2414
|
+
.filter((id) => id !== undefined);
|
|
2415
|
+
/** Verbatim patch identity of the whole change base..head introduces. */
|
|
2416
|
+
export const rangePatch = (cwd, base, head) => patchIdOf(cwd, owedGit(cwd, ["diff", "--binary", base, head]), "--verbatim") ?? "empty";
|
|
2417
|
+
const COMMIT_ID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
2418
|
+
/**
|
|
2419
|
+
* `head` is the task's own recorded integration merge of the obligation's range — exactly. Its first
|
|
2420
|
+
* parent is `mergeBase`; its second parent replays the waived history source tree for source tree from
|
|
2421
|
+
* a base with the waived base's tree (a carry may mint new commit objects, never new content — and
|
|
2422
|
+
* patch ids alone ignore where a hunk lands); its tree is git's own clean merge of the two (no
|
|
2423
|
+
* resolution, no amend); and it introduced the same ordered verbatim patch series and total patch.
|
|
2424
|
+
* Squashed, reordered, relocated, re-indented, reverted or conflict-resolved history fails one of
|
|
2425
|
+
* these and is refused as ambiguous; containment never counts.
|
|
2426
|
+
* Both verify (before discharging) and the fold (re-reading every discharge) decide through here.
|
|
2427
|
+
*/
|
|
2428
|
+
export function integrationMapped(cwd, events, o, mergeBase, head) {
|
|
2429
|
+
if (typeof mergeBase !== "string" || typeof head !== "string" || !COMMIT_ID.test(mergeBase) || !COMMIT_ID.test(head))
|
|
2430
|
+
return false;
|
|
2431
|
+
if (!events.some((e) => e.event === "merge" && e.taskId === o.taskId && e.data.commit === head))
|
|
2432
|
+
return false;
|
|
2433
|
+
try {
|
|
2434
|
+
const parents = owedGit(cwd, ["show", "-s", "--format=%P", head]).trim().split(" ");
|
|
2435
|
+
if (parents.length !== 2 || parents[0] !== mergeBase)
|
|
2436
|
+
return false;
|
|
2437
|
+
const [, carried] = parents;
|
|
2438
|
+
const tree = (rev) => owedGit(cwd, ["rev-parse", `${rev}^{tree}`]).trim();
|
|
2439
|
+
const trees = (from, to) => owedGit(cwd, ["log", "--reverse", "--format=%T", `${from}..${to}`]).trim();
|
|
2440
|
+
if (tree(owedGit(cwd, ["merge-base", mergeBase, carried]).trim()) !== tree(o.base) || trees(mergeBase, carried) !== trees(o.base, o.head))
|
|
2441
|
+
return false;
|
|
2442
|
+
// A conflicted merge-tree exits 1 and throws; a clean one prints its tree first.
|
|
2443
|
+
if (owedGit(cwd, ["merge-tree", "--write-tree", mergeBase, carried]).trim().split("\n")[0] !== tree(head))
|
|
2444
|
+
return false;
|
|
2445
|
+
const series = rangePatches(cwd, mergeBase, carried);
|
|
2446
|
+
return series.length === o.patches.length && series.every((id, i) => id === o.patches[i])
|
|
2447
|
+
&& rangePatch(cwd, mergeBase, head) === o.patch;
|
|
2448
|
+
}
|
|
2449
|
+
catch {
|
|
2450
|
+
return false;
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
/**
|
|
2454
|
+
* Lifetime patch authors of base..head, read from the run: the daemon's subjectAuthors fold
|
|
2455
|
+
* (daemon.ts, not exported and not this task's to edit) restated synchronously so the waive row is
|
|
2456
|
+
* written without yielding. Recreation rows name SOURCE hashes, so ownership joins by stable patch
|
|
2457
|
+
* identity across changed commit objects; a gate-only restore (resume-restore's seat, a recreation
|
|
2458
|
+
* after work started) never becomes an author. Unreadable history names an unknown author.
|
|
2459
|
+
*/
|
|
2460
|
+
export function owedAuthors(events, taskId, cwd, base, head) {
|
|
2461
|
+
const cache = new Map();
|
|
2462
|
+
const patches = (commits) => {
|
|
2463
|
+
const ids = new Set();
|
|
2464
|
+
for (const commit of commits) {
|
|
2465
|
+
if (!cache.has(commit))
|
|
2466
|
+
cache.set(commit, patchIdOf(cwd, owedGit(cwd, ["show", "--format=", "--binary", commit])));
|
|
2467
|
+
const id = cache.get(commit);
|
|
2468
|
+
if (id)
|
|
2469
|
+
ids.add(id);
|
|
2470
|
+
}
|
|
2471
|
+
return ids;
|
|
2472
|
+
};
|
|
2473
|
+
let current;
|
|
2474
|
+
let previous;
|
|
2475
|
+
let awaitingCarry = false;
|
|
2476
|
+
const owners = new Map();
|
|
2477
|
+
const preservedOwner = new Map();
|
|
2478
|
+
const attribute = (ids, attempt) => {
|
|
2479
|
+
for (const id of ids) {
|
|
2480
|
+
if (attempt?.incoming.has(id))
|
|
2481
|
+
continue;
|
|
2482
|
+
const authors = owners.get(id) ?? new Set();
|
|
2483
|
+
authors.add(preservedOwner.get(id) ?? attempt?.author ?? "unknown author (missing task-dispatch assignment)");
|
|
2484
|
+
owners.set(id, authors);
|
|
2485
|
+
}
|
|
2486
|
+
};
|
|
2487
|
+
try {
|
|
2488
|
+
for (const row of events) {
|
|
2489
|
+
if (row.taskId !== taskId)
|
|
2490
|
+
continue;
|
|
2491
|
+
const preserved = [row.data.ref, row.data.preservedRef].find((v) => typeof v === "string" && v.startsWith("refs/tickmarkr/preserved/"));
|
|
2492
|
+
if (preserved) {
|
|
2493
|
+
const [commit, subject, trailer] = owedGit(cwd, ["show", "-s", `--format=%H%n%s%n%(trailers:key=${PRESERVE_PRODUCER_TRAILER},valueonly)`, `${preserved}^{commit}`]).trim().split("\n");
|
|
2494
|
+
if (commit && subject === PRESERVE_COMMIT_SUBJECT) {
|
|
2495
|
+
const owner = typeof row.data.producer === "string" ? row.data.producer
|
|
2496
|
+
: trailer?.trim().replace(/ attempt \d+$/, "") || "unknown author (legacy unattributed preservation)";
|
|
2497
|
+
for (const id of patches([commit])) {
|
|
2498
|
+
const prior = preservedOwner.get(id);
|
|
2499
|
+
if (prior === undefined || prior === "unknown" || prior.startsWith("unknown author"))
|
|
2500
|
+
preservedOwner.set(id, owner);
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
if (row.event === "task-dispatch") {
|
|
2505
|
+
previous = current;
|
|
2506
|
+
const a = row.data.assignment;
|
|
2507
|
+
current = { author: typeof a?.adapter === "string" && typeof a?.model === "string"
|
|
2508
|
+
? `${a.adapter}:${a.model}` : "unknown author (missing task-dispatch assignment)", incoming: new Set() };
|
|
2509
|
+
awaitingCarry = true;
|
|
2510
|
+
}
|
|
2511
|
+
else if (row.event === "worktree-recreation") {
|
|
2512
|
+
const carried = patches(Array.isArray(row.data.carried) ? row.data.carried : []);
|
|
2513
|
+
attribute(carried, awaitingCarry ? previous : current);
|
|
2514
|
+
if (awaitingCarry && current)
|
|
2515
|
+
current.incoming = carried;
|
|
2516
|
+
awaitingCarry = false;
|
|
2517
|
+
}
|
|
2518
|
+
else if (["worker-launch", "worker-result", "gate-result", "task-human"].includes(row.event)) {
|
|
2519
|
+
awaitingCarry = false;
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
const subject = patches(owedGit(cwd, ["rev-list", "--reverse", `${base}..${head}`]).trim().split("\n").filter(Boolean));
|
|
2523
|
+
attribute(subject, current);
|
|
2524
|
+
return [...new Set([...subject].flatMap((id) => [...(owners.get(id) ?? [])]))].sort();
|
|
2525
|
+
}
|
|
2526
|
+
catch (error) {
|
|
2527
|
+
return [`unknown author (${String(error).split("\n")[0]})`];
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
/**
|
|
2531
|
+
* The obligation a waive of `gate` leaves on the task branch's current range. Synchronous on purpose:
|
|
2532
|
+
* approve writes it into the waive row under the approval serialization without yielding, so a live
|
|
2533
|
+
* daemon's boundary sees the same single append it always did. Anything not bound exactly — the
|
|
2534
|
+
* waived row measured another subject, unreadable refs, unresolved authors, unreadable criteria — is
|
|
2535
|
+
* recorded, never guessed, as known: false.
|
|
2536
|
+
*/
|
|
2537
|
+
export function captureOwedCheck(input) {
|
|
2538
|
+
const { cwd, runId, taskId, gate, events } = input;
|
|
2539
|
+
let base = "unknown", head = "unknown", subject = "unknown", patch = "unknown";
|
|
2540
|
+
let patches = [];
|
|
2541
|
+
let authors = [];
|
|
2542
|
+
let unknownReason;
|
|
2543
|
+
try {
|
|
2544
|
+
head = owedGit(cwd, ["rev-parse", "--verify", `${input.taskRef}^{commit}`]).trim();
|
|
2545
|
+
base = owedGit(cwd, ["merge-base", input.integrationRef, head]).trim();
|
|
2546
|
+
subject = owedSubject(cwd, base, head);
|
|
2547
|
+
const measured = [...events].reverse().find((e) => e.taskId === taskId && e.event === "gate-result" && e.data.gate === gate);
|
|
2548
|
+
if (measured?.data.commit !== subject) {
|
|
2549
|
+
throw new Error(`the waived ${gate} row measured ${String(measured?.data.commit ?? "no subject")}, not ${input.taskRef}'s range`);
|
|
2550
|
+
}
|
|
2551
|
+
patches = rangePatches(cwd, base, head);
|
|
2552
|
+
patch = rangePatch(cwd, base, head);
|
|
2553
|
+
authors = owedAuthors(events, taskId, cwd, base, head);
|
|
2554
|
+
if (!authors.length || authors.some((a) => a.startsWith("unknown")))
|
|
2555
|
+
throw new Error(`unresolved patch authors: ${authors.join(", ") || "none"}`);
|
|
2556
|
+
}
|
|
2557
|
+
catch (error) {
|
|
2558
|
+
unknownReason = (error instanceof Error ? error.message : String(error)).split("\n")[0];
|
|
2559
|
+
}
|
|
2560
|
+
if (!Array.isArray(input.acceptance))
|
|
2561
|
+
unknownReason ??= "task acceptance criteria unreadable";
|
|
2562
|
+
const files = Array.isArray(input.files) && input.files.every((f) => typeof f === "string") ? input.files : undefined;
|
|
2563
|
+
if (!files)
|
|
2564
|
+
unknownReason ??= "task file scope unreadable";
|
|
2565
|
+
if (!input.declared?.length)
|
|
2566
|
+
unknownReason ??= "declared channels unreadable";
|
|
2567
|
+
const criteria = Array.isArray(input.acceptance) ? owedCriteria(input.acceptance) : "unknown";
|
|
2568
|
+
return {
|
|
2569
|
+
version: OWED_CHECK_VERSION,
|
|
2570
|
+
id: sha256(JSON.stringify([runId, taskId, gate, base, head, subject, criteria])).slice(0, 16),
|
|
2571
|
+
runId, taskId, gate, base, head, subject, patch, patches, criteria, files: files ?? [], authors,
|
|
2572
|
+
declared: [...(input.declared ?? [])].map(({ key, vendor }) => ({ key, vendor })), cause: input.cause,
|
|
2573
|
+
evidence: `tickmarkr verify --base ${base} --task ${taskId} --record ${runId}: ${gate} passing on ${base}..${head} or on its recorded integration merge, reviewed outside the authors' vendors`,
|
|
2574
|
+
disposition: "accepted-risk",
|
|
2575
|
+
known: unknownReason === undefined,
|
|
2576
|
+
...(unknownReason === undefined ? {} : { unknownReason }),
|
|
2577
|
+
};
|
|
2578
|
+
}
|
|
2579
|
+
/** A bound fact: a non-empty string that capture did not record as unknown. */
|
|
2580
|
+
const isBound = (v) => typeof v === "string" && v !== "" && !v.startsWith("unknown");
|
|
2581
|
+
/** Every required field present and well-typed; anything else is malformed evidence, never a known check. */
|
|
2582
|
+
const isOwedCheck = (value, taskId, gate) => {
|
|
2583
|
+
if (typeof value !== "object" || value === null)
|
|
2584
|
+
return false;
|
|
2585
|
+
const o = value;
|
|
2586
|
+
return o.version === OWED_CHECK_VERSION && o.known === true && o.disposition === "accepted-risk"
|
|
2587
|
+
&& o.taskId === taskId && o.gate === gate && GATE_NAMES.includes(o.gate)
|
|
2588
|
+
&& [o.id, o.runId, o.taskId, o.base, o.head, o.subject, o.patch, o.criteria].every(isBound)
|
|
2589
|
+
&& typeof o.cause === "string" && typeof o.evidence === "string"
|
|
2590
|
+
&& Array.isArray(o.patches) && o.patches.every(isBound)
|
|
2591
|
+
&& Array.isArray(o.authors) && o.authors.length > 0 && o.authors.every(isBound)
|
|
2592
|
+
&& Array.isArray(o.files) && o.files.every((f) => typeof f === "string")
|
|
2593
|
+
&& Array.isArray(o.declared) && o.declared.length > 0 && o.declared.every(isChannelFact);
|
|
2594
|
+
};
|
|
2595
|
+
const providerOf = (fact) => modelProvider(fact.key.slice(fact.key.indexOf(":") + 1), fact.vendor);
|
|
2596
|
+
/** A resolved channel: an `adapter:model` key and a bound vendor — never an empty or placeholder claim. */
|
|
2597
|
+
const isChannelFact = (f) => typeof f === "object" && f !== null
|
|
2598
|
+
&& typeof f.key === "string" && /^[^:\s]+:\S+$/.test(f.key) && isBound(f.vendor);
|
|
2599
|
+
/**
|
|
2600
|
+
* The trusted channel identities, key → vendors: what this repository's adapters declared under its config
|
|
2601
|
+
* when the waive was written, recorded on the obligation by approve. Declaration, not doctor health — a
|
|
2602
|
+
* later logout does not revoke a proof, but a seat not declared (or declared under another vendor) never
|
|
2603
|
+
* resolved. Read from the row, the fold resolves identically in every process and every checkout.
|
|
2604
|
+
*/
|
|
2605
|
+
const declaredVendors = (o) => {
|
|
2606
|
+
const vendors = new Map();
|
|
2607
|
+
for (const c of o.declared)
|
|
2608
|
+
vendors.set(c.key, (vendors.get(c.key) ?? new Set()).add(c.vendor));
|
|
2609
|
+
return vendors;
|
|
2610
|
+
};
|
|
2611
|
+
/** Why a discharge row no longer proves its obligation, re-read from its artifact; undefined when it does. */
|
|
2612
|
+
function dischargeProblem(row, o, events, cwd) {
|
|
2613
|
+
const d = row.data;
|
|
2614
|
+
if (row.taskId !== o.taskId)
|
|
2615
|
+
return "discharge names another task";
|
|
2616
|
+
if (d.criteria !== o.criteria)
|
|
2617
|
+
return "discharge proved other criteria";
|
|
2618
|
+
const exact = d.mapping === "exact" && d.mergeBase === o.base && d.head === o.head;
|
|
2619
|
+
// The integration mapping is re-proved from git on every fold, never taken from the row's claims.
|
|
2620
|
+
const integration = d.mapping === "integration" && integrationMapped(cwd, events, o, d.mergeBase, d.head);
|
|
2621
|
+
if (!exact && !integration)
|
|
2622
|
+
return "discharge range does not bind the obligation";
|
|
2623
|
+
let artifact;
|
|
2624
|
+
try {
|
|
2625
|
+
const bytes = readFileSync(String(d.artifactPath));
|
|
2626
|
+
if (createHash("sha256").update(bytes).digest("hex") !== d.artifactSha256)
|
|
2627
|
+
return "artifact hash mismatch";
|
|
2628
|
+
artifact = JSON.parse(bytes.toString("utf8")) ?? {};
|
|
2629
|
+
}
|
|
2630
|
+
catch {
|
|
2631
|
+
return "artifact unavailable";
|
|
2632
|
+
}
|
|
2633
|
+
if (artifact.head !== d.head || artifact.mergeBase !== d.mergeBase)
|
|
2634
|
+
return "artifact measured another range";
|
|
2635
|
+
if (artifact.green !== true)
|
|
2636
|
+
return "artifact verdict is not green";
|
|
2637
|
+
// The criteria the hash-bound evidence itself measured: the row's claim alone only repeats the obligation.
|
|
2638
|
+
if (artifact.criteria !== o.criteria)
|
|
2639
|
+
return "artifact measured other criteria";
|
|
2640
|
+
// The scope the waived task declared, exactly: a wider allowlist (--files '*') is weakened evidence.
|
|
2641
|
+
if (JSON.stringify(artifact.files) !== JSON.stringify(o.files))
|
|
2642
|
+
return "artifact gated another file scope";
|
|
2643
|
+
const rows = (Array.isArray(artifact.gateRows) ? artifact.gateRows : []);
|
|
2644
|
+
const gateRow = rows.find((r) => r?.gate === o.gate && r.pass === true && r.meta?.skipped !== true);
|
|
2645
|
+
if (!gateRow)
|
|
2646
|
+
return `artifact holds no passing ${o.gate} row`;
|
|
2647
|
+
if (o.gate === "review") {
|
|
2648
|
+
const reviewer = d.reviewer;
|
|
2649
|
+
const authors = Array.isArray(d.authorChannels) ? d.authorChannels : [];
|
|
2650
|
+
// The row's reviewer must be the seat the hash-bound review row recorded — key, vendor and provider.
|
|
2651
|
+
if (!isChannelFact(reviewer) || gateRow.meta?.reviewer !== reviewer.key || gateRow.meta.vendor !== reviewer.vendor
|
|
2652
|
+
|| gateRow.meta.provider !== providerOf(reviewer))
|
|
2653
|
+
return "unresolved reviewer";
|
|
2654
|
+
// ...and a channel the waive recorded as declared under exactly that vendor: agreement between the row
|
|
2655
|
+
// and the artifact only repeats a claim, so identity and vendor resolve against the obligation's own.
|
|
2656
|
+
const declared = declaredVendors(o);
|
|
2657
|
+
const resolves = (f) => { const v = declared.get(f.key); return v?.size === 1 && v.has(f.vendor); };
|
|
2658
|
+
// An author key stays excluded whatever vendor any row claims for it.
|
|
2659
|
+
if (o.authors.includes(reviewer.key))
|
|
2660
|
+
return "same-vendor reviewer (the reviewer is a recorded patch author)";
|
|
2661
|
+
if (!resolves(reviewer))
|
|
2662
|
+
return "unresolved reviewer (no declared channel with that identity and vendor)";
|
|
2663
|
+
if (!authors.every(isChannelFact) || !o.authors.every((key) => authors.some((a) => a.key === key)))
|
|
2664
|
+
return "unresolved author channel";
|
|
2665
|
+
if (authors.some((a) => authors.some((b) => a.key === b.key && a.vendor !== b.vendor)))
|
|
2666
|
+
return "unresolved author channel (inconsistent vendor claims)";
|
|
2667
|
+
if (!authors.every(resolves))
|
|
2668
|
+
return "unresolved author channel (no declared channel with that identity and vendor)";
|
|
2669
|
+
if (authors.some((a) => a.vendor === reviewer.vendor || providerOf(a) === providerOf(reviewer)))
|
|
2670
|
+
return "same-vendor reviewer";
|
|
2671
|
+
}
|
|
2672
|
+
return undefined;
|
|
2673
|
+
}
|
|
2674
|
+
/**
|
|
2675
|
+
* The CURRENT owed-check fold: accepted-risk history, outstanding debt, and whether it is known.
|
|
2676
|
+
* `cwd` is any checkout of the run's repository (integration discharges are re-proved from git).
|
|
2677
|
+
* It never throws: a fold that cannot be computed is unknown debt, not a crash and never zero.
|
|
2678
|
+
*/
|
|
2679
|
+
export function foldOwedChecks(events, cwd) {
|
|
2680
|
+
try {
|
|
2681
|
+
return foldOwedChecksOrThrow(events, cwd);
|
|
2682
|
+
}
|
|
2683
|
+
catch (error) {
|
|
2684
|
+
return { known: false, debt: "unknown", outstanding: [], acceptedRisk: [], discharged: [], unknown: [{ reason: `owed-check fold failed: ${String(error).split("\n")[0]}` }] };
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
function foldOwedChecksOrThrow(events, cwd) {
|
|
2688
|
+
const accepted = new Map();
|
|
2689
|
+
const discharged = new Set();
|
|
2690
|
+
const unknown = [];
|
|
2691
|
+
for (const e of effectiveEvents(events)) {
|
|
2692
|
+
if (e.event === "task-approved" && e.data.release === GATE_SATISFIED_RELEASE) {
|
|
2693
|
+
const o = e.data.obligation;
|
|
2694
|
+
if (isOwedCheck(o, e.taskId, e.data.gate))
|
|
2695
|
+
accepted.set(o.id, o);
|
|
2696
|
+
else {
|
|
2697
|
+
const recorded = typeof o === "object" && o !== null ? o.unknownReason : undefined;
|
|
2698
|
+
unknown.push({ taskId: e.taskId, gate: String(e.data.gate), reason: o === undefined ? "legacy waiver without an owed-check obligation"
|
|
2699
|
+
: typeof recorded === "string" ? recorded : "malformed owed-check obligation" });
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
else if (e.event === OWED_DISCHARGE_EVENT) {
|
|
2703
|
+
const ids = Array.isArray(e.data.ids) ? e.data.ids : [];
|
|
2704
|
+
const problems = ids.map((id) => {
|
|
2705
|
+
const o = accepted.get(String(id));
|
|
2706
|
+
return o ? dischargeProblem(e, o, events, cwd) : "discharge names no recorded obligation";
|
|
2707
|
+
});
|
|
2708
|
+
const problem = ids.length ? problems.find((p) => p !== undefined) : "discharge names no obligation";
|
|
2709
|
+
if (problem)
|
|
2710
|
+
unknown.push({ taskId: e.taskId, reason: problem });
|
|
2711
|
+
else
|
|
2712
|
+
for (const id of ids)
|
|
2713
|
+
discharged.add(String(id));
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
const acceptedRisk = [...accepted.values()];
|
|
2717
|
+
const outstanding = acceptedRisk.filter((o) => !discharged.has(o.id));
|
|
2718
|
+
const known = unknown.length === 0;
|
|
2719
|
+
return { known, debt: known ? outstanding.length : "unknown", outstanding, acceptedRisk, discharged: [...discharged], unknown };
|
|
2720
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type JournalEvent } from "../../run/journal.js";
|
|
2
2
|
import { type OperatorRecord } from "../../run/operator-state.js";
|
|
3
3
|
export declare const OBSERVATION_INTERVAL_MS = 1000;
|
|
4
|
+
/** Stat-to-fstat observations one poll spends on a journal a writer keeps appending to. */
|
|
5
|
+
export declare const APPEND_RACE_OBSERVATIONS = 3;
|
|
4
6
|
export declare const STORE_LIMITS: {
|
|
5
7
|
readonly graphBytes: number;
|
|
6
8
|
readonly history: 256;
|
|
@@ -61,12 +63,16 @@ export declare class JournalTail {
|
|
|
61
63
|
private bytesRead;
|
|
62
64
|
private lastSuccessfulReadAt?;
|
|
63
65
|
private failure?;
|
|
66
|
+
private stale;
|
|
64
67
|
constructor(source: string, hooks?: {
|
|
65
68
|
record?: (record: OperatorRecord) => void;
|
|
66
69
|
reset?: () => void;
|
|
67
70
|
});
|
|
68
71
|
private reset;
|
|
72
|
+
/** A same-inode append between stat and fstat is retried; exhaustion keeps the last good snapshot, pending. */
|
|
69
73
|
poll(now?: number): TailSnapshot;
|
|
74
|
+
/** One stat-to-fstat observation. False when an append raced it; nothing was consumed or reset. */
|
|
75
|
+
private observe;
|
|
70
76
|
private appendCarry;
|
|
71
77
|
snapshot(): TailSnapshot;
|
|
72
78
|
/** Demand paging scans with fixed buffers, independent of the retained history; no growing line index. */
|
|
@@ -7,6 +7,8 @@ import { isPidLive, STALE_MS } from "../../run/lock.js";
|
|
|
7
7
|
import { readTierLiveness, SUPERVISION_TIERS } from "../../run/supervision.js";
|
|
8
8
|
import { OperatorStateFold } from "../../run/operator-state.js";
|
|
9
9
|
export const OBSERVATION_INTERVAL_MS = 1_000;
|
|
10
|
+
/** Stat-to-fstat observations one poll spends on a journal a writer keeps appending to. */
|
|
11
|
+
export const APPEND_RACE_OBSERVATIONS = 3;
|
|
10
12
|
// Graph declarations have a separate 16 MiB bound; journal retention remains unchanged.
|
|
11
13
|
export const STORE_LIMITS = { graphBytes: 16 * 1024 * 1024, history: 256, historyBytes: 2 * 1024 * 1024, recordBytes: 1024 * 1024, readBytes: 1024 * 1024, subscribers: 64, metrics: 12, errors: 32 };
|
|
12
14
|
const errorText = (e) => e instanceof Error ? e.message : String(e);
|
|
@@ -48,6 +50,7 @@ export class JournalTail {
|
|
|
48
50
|
bytesRead = 0;
|
|
49
51
|
lastSuccessfulReadAt;
|
|
50
52
|
failure;
|
|
53
|
+
stale = false;
|
|
51
54
|
constructor(source, hooks = {}) {
|
|
52
55
|
this.source = source;
|
|
53
56
|
this.hooks = hooks;
|
|
@@ -65,18 +68,45 @@ export class JournalTail {
|
|
|
65
68
|
this.generation++;
|
|
66
69
|
this.hooks.reset?.();
|
|
67
70
|
}
|
|
71
|
+
/** A same-inode append between stat and fstat is retried; exhaustion keeps the last good snapshot, pending. */
|
|
68
72
|
poll(now = Date.now()) {
|
|
73
|
+
try {
|
|
74
|
+
for (let observation = 1; !this.observe(); observation++) {
|
|
75
|
+
if (observation === APPEND_RACE_OBSERVATIONS) {
|
|
76
|
+
this.stale = true;
|
|
77
|
+
this.failure = undefined;
|
|
78
|
+
return this.snapshot();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
this.stale = false;
|
|
82
|
+
this.failure = undefined;
|
|
83
|
+
this.lastSuccessfulReadAt = now;
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
this.failure = { source: this.source, error: errorText(e) };
|
|
87
|
+
}
|
|
88
|
+
return this.snapshot();
|
|
89
|
+
}
|
|
90
|
+
/** One stat-to-fstat observation. False when an append raced it; nothing was consumed or reset. */
|
|
91
|
+
observe() {
|
|
69
92
|
let fd;
|
|
70
93
|
try {
|
|
71
94
|
const st = statSync(this.source, { bigint: true });
|
|
72
95
|
if (!st.isFile())
|
|
73
96
|
throw new Error("journal source is not a regular file");
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (Number(st.size) > this.offset) {
|
|
97
|
+
const replaced = !this.st || fileIdentity(st) !== fileIdentity(this.st) || st.size < this.st.size || (st.size === this.st.size && stamp(st) !== stamp(this.st));
|
|
98
|
+
if (Number(st.size) > (replaced ? 0 : this.offset)) {
|
|
77
99
|
fd = openSync(this.source, "r");
|
|
78
|
-
|
|
100
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
101
|
+
if (stamp(opened) !== stamp(st)) {
|
|
102
|
+
if (fileIdentity(opened) === fileIdentity(st) && opened.size > st.size)
|
|
103
|
+
return false;
|
|
79
104
|
throw new Error("journal changed before read; retry observation");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (replaced)
|
|
108
|
+
this.reset();
|
|
109
|
+
if (fd !== undefined) {
|
|
80
110
|
let remaining = STORE_LIMITS.readBytes;
|
|
81
111
|
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, Number(st.size) - this.offset));
|
|
82
112
|
while (this.offset < Number(st.size) && remaining > 0) {
|
|
@@ -113,17 +143,12 @@ export class JournalTail {
|
|
|
113
143
|
}
|
|
114
144
|
}
|
|
115
145
|
this.st = st;
|
|
116
|
-
|
|
117
|
-
this.lastSuccessfulReadAt = now;
|
|
118
|
-
}
|
|
119
|
-
catch (e) {
|
|
120
|
-
this.failure = { source: this.source, error: errorText(e) };
|
|
146
|
+
return true;
|
|
121
147
|
}
|
|
122
148
|
finally {
|
|
123
149
|
if (fd !== undefined)
|
|
124
150
|
closeSync(fd);
|
|
125
151
|
}
|
|
126
|
-
return this.snapshot();
|
|
127
152
|
}
|
|
128
153
|
appendCarry(bytes) {
|
|
129
154
|
this.carryBytes += bytes.length;
|
|
@@ -137,7 +162,7 @@ export class JournalTail {
|
|
|
137
162
|
history: this.history.slice(), errors: this.errors.slice(), malformedCount: this.malformedCount,
|
|
138
163
|
pending: this.carryBytes ? { line: this.line + 1, bytes: this.carryBytes } : undefined,
|
|
139
164
|
backlogBytes: Math.max(0, Number(this.st?.size ?? 0) - this.offset),
|
|
140
|
-
status: this.failure ? "unreadable" : this.malformedCount ? "corrupt" : this.carryBytes ? "pending" : "readable",
|
|
165
|
+
status: this.failure ? "unreadable" : this.malformedCount ? "corrupt" : this.carryBytes || this.stale ? "pending" : "readable",
|
|
141
166
|
error: this.failure, lastSuccessfulReadAt: this.lastSuccessfulReadAt, bytesRead: this.bytesRead,
|
|
142
167
|
};
|
|
143
168
|
}
|
package/package.json
CHANGED
|
@@ -92,7 +92,7 @@ After sending, **confirm delivery** by reading the target pane and verifying the
|
|
|
92
92
|
1. **Prepare** — confirm the target list. Run the [binary preflight](#binary-preflight-before-compile-or-run). Check `git status`, confirm no tickmarkr run is active, and work from a non-main branch.
|
|
93
93
|
2. **Compile** — run `tickmarkr compile <spec-or-directory>`. Fix source-spec defects instead of editing the generated graph.
|
|
94
94
|
3. **Plan** — run `tickmarkr plan`. Review routes, capability-floor warnings, and human gates before execution.
|
|
95
|
-
4. **Run** — run `tickmarkr run`. A watch ending the seat's turn is no watch: keep a **blocking journal consumer** alive for the run's terminal events — the shipped watcher below, or a foreground `until grep` on the run's terminal events — and ensure it is re-armed at most every twenty minutes. Never rely on a `Monitor`-only wake. Watch the run journal rather than polling agents, using the shipped watcher — `.claude/skills/tickmarkr-overseer/scripts/watch-journal.sh <state-dir>/runs 20 28800` — which takes a line baseline at arm time, then wakes ONCE on `run-end`, `task-human`, `task-failed` or `consult-verdict` and grades the run-end summary against every
|
|
96
|
-
5. **Verify and consolidate** — continue only after a green run. A run is green when the run-end event exists in the journal, the tip verify is not "failed",
|
|
95
|
+
4. **Run** — run `tickmarkr run`. A watch ending the seat's turn is no watch: keep a **blocking journal consumer** alive for the run's terminal events — the shipped watcher below, or a foreground `until grep` on the run's terminal events — and ensure it is re-armed at most every twenty minutes. Never rely on a `Monitor`-only wake. Watch the run journal rather than polling agents, using the shipped watcher — `.claude/skills/tickmarkr-overseer/scripts/watch-journal.sh <state-dir>/runs 20 28800` — which takes a line baseline at arm time, then wakes ONCE on `run-end`, `task-human`, `task-failed` or `consult-verdict` and grades the run-end summary against every execution clause for you; the debt clause is yours — read CURRENT `tickmarkr status <runId>` (step 5). Re-arm after every wake. ⛔ Never `tail -F | grep -m1` (run-end is the journal's last line, so tail never notices the broken pipe and the watcher hangs forever) and never a pane-level done wait (it fires on every agent turn end, not mission end). ⚠ A bare whole-file `grep -q '"event":"run-end"'` is the trap the watcher exists to avoid: on a resume it matches the PREVIOUS run's run-end and returns instantly, so a re-armed watcher reads as coverage that does not exist. Resolve blocked interactions in the relevant agent session.
|
|
96
|
+
5. **Verify and consolidate** — continue only after a green run. A run is green when the run-end event exists in the journal, the tip verify is not "failed", the summary's `failed`, `human`, `blocked` and `pending` buckets are all empty, and CURRENT `tickmarkr status <runId>` reads its owed checks outstanding empty AND known (`outstanding 0`) — a run with a parked task is partial, not green. Empty execution buckets alone are not green either (D-660): every bucket empty and the tip passed, but an operator waived one review, leaves one accepted-risk review check owed — status reads `outstanding 1 (T7 review)` and `run`/`resume` still exit 0 on execution alone, so the run is execution complete, not green. It turns green only when a `tickmarkr verify --record <runId>` discharge moves CURRENT status to `outstanding 0`, including a discharge landing after run-end — the historical run-end record keeps the old count, so never read debt from it; `outstanding unknown` is never green. Tickmarkr consolidates accepted work on `tickmarkr/<runId>` and never signs off to the main branch. A human controls any later release merge.
|
|
97
97
|
6. **Record** — `tickmarkr report <runId> --md` prints Markdown to stdout; redirect explicitly beside the spec (for example `tickmarkr report <runId> --md > feature.record.md`) and commit the execution record when the repository tracks those records.
|
|
98
98
|
7. **Continue** — move to the next requested target. If a target fails or is parked, stop with the journal evidence rather than silently skipping it.
|
|
@@ -88,8 +88,8 @@ When spawning consultants (agents gathering synthesis input for decisions like S
|
|
|
88
88
|
1. **Prepare** — start from the requested spec. Run the [binary preflight](#binary-preflight-before-compile-or-run). Check `git status`, confirm no tickmarkr run is active, and work from a non-main branch.
|
|
89
89
|
2. **Compile** — run `tickmarkr compile <spec>`. Correct compilation errors in the spec, never in the generated graph.
|
|
90
90
|
3. **Plan** — run `tickmarkr plan`. Review the routing table, capability-floor warnings, and every human gate, including work that each gate blocks.
|
|
91
|
-
4. **Run** — run `tickmarkr run`. A watch ending the seat's turn is no watch: keep a **blocking journal consumer** alive for the run's terminal events — the shipped watcher below, or a foreground `until grep` on the run's terminal events — and ensure it is re-armed at most every twenty minutes. Never rely on a `Monitor`-only wake. Watch the run journal rather than polling agents, using the shipped watcher — `.claude/skills/tickmarkr-overseer/scripts/watch-journal.sh <state-dir>/runs 20 28800` — which takes a line baseline at arm time, then wakes ONCE on `run-end`, `task-human`, `task-failed` or `consult-verdict` and grades the run-end summary against every
|
|
92
|
-
5. **Verify and consolidate** — accept only a green run. A run is green when the run-end event exists in the journal, the tip verify is not "failed",
|
|
91
|
+
4. **Run** — run `tickmarkr run`. A watch ending the seat's turn is no watch: keep a **blocking journal consumer** alive for the run's terminal events — the shipped watcher below, or a foreground `until grep` on the run's terminal events — and ensure it is re-armed at most every twenty minutes. Never rely on a `Monitor`-only wake. Watch the run journal rather than polling agents, using the shipped watcher — `.claude/skills/tickmarkr-overseer/scripts/watch-journal.sh <state-dir>/runs 20 28800` — which takes a line baseline at arm time, then wakes ONCE on `run-end`, `task-human`, `task-failed` or `consult-verdict` and grades the run-end summary against every execution clause for you; the debt clause is yours — read CURRENT `tickmarkr status <runId>` (step 5). Re-arm after every wake. ⛔ Never `tail -F | grep -m1` (run-end is the journal's last line, so tail never notices the broken pipe and the watcher hangs forever) and never a pane-level done wait (it fires on every agent turn end, not mission end). ⚠ A bare whole-file `grep -q '"event":"run-end"'` is the trap the watcher exists to avoid: on a resume it matches the PREVIOUS run's run-end and returns instantly, so a re-armed watcher reads as coverage that does not exist. Resolve blocked interactions in the agent session; do not turn them into proxy questions.
|
|
92
|
+
5. **Verify and consolidate** — accept only a green run. A run is green when the run-end event exists in the journal, the tip verify is not "failed", the summary's `failed`, `human`, `blocked` and `pending` buckets are all empty, and CURRENT `tickmarkr status <runId>` reads its owed checks outstanding empty AND known (`outstanding 0`) — a run with a parked task is partial, not green. Empty execution buckets alone are not green either (D-660): every bucket empty and the tip passed, but an operator waived one review, leaves one accepted-risk review check owed — status reads `outstanding 1 (T7 review)` and `run`/`resume` still exit 0 on execution alone, so the run is execution complete, not green. It turns green only when a `tickmarkr verify --record <runId>` discharge moves CURRENT status to `outstanding 0`, including a discharge landing after run-end — the historical run-end record keeps the old count, so never read debt from it; `outstanding unknown` is never green. Tickmarkr consolidates accepted task work on `tickmarkr/<runId>`; it never signs off to the main branch. A human may later merge that integration branch through the repository's normal release process.
|
|
93
93
|
6. **Record** — `tickmarkr report <runId> --md` prints Markdown to stdout. Redirect it explicitly beside the source spec (for example `tickmarkr report <runId> --md > feature.record.md`) and commit the execution record when the repository tracks those records. Then [stand down](#stand-down-mission-end-and-retirement).
|
|
94
94
|
|
|
95
95
|
## Cockpit, parked decisions and printed twins
|
|
@@ -122,8 +122,14 @@ own bound failure token: status prints `failed — T3 — failure <line>@<ts>`,
|
|
|
122
122
|
`tickmarkr approve <runId> T3 --recheck --park <line>@<ts>` re-gates its landed commits. Preserve resume refusals and repair the named source/config issue
|
|
123
123
|
(including deny/prefer conflicts); never edit the compiled graph to force a result. Resume
|
|
124
124
|
makes CURRENT TIP PENDING; historical GATES RAN does not prove completion. The completed
|
|
125
|
-
case has 3/3 recorded merges, a latest run-end, a nonfailed known tip result
|
|
126
|
-
`failed`, `human`, `blocked`, `pending` buckets
|
|
125
|
+
case has 3/3 recorded merges, a latest run-end, a nonfailed known tip result, empty
|
|
126
|
+
`failed`, `human`, `blocked`, `pending` buckets, and CURRENT status reading `outstanding 0`
|
|
127
|
+
— owed checks outstanding empty AND known. Replay D-660 before calling it green: the same
|
|
128
|
+
3/3 with every bucket empty, after T2's review was waived, reads `outstanding 1 (T2 review)`;
|
|
129
|
+
`resume` exits 0 and its final line says `execution complete; outstanding 1 (T2 review)`, not
|
|
130
|
+
`verified`. It is green only once `tickmarkr verify --record <runId>` discharges that check and
|
|
131
|
+
CURRENT status reads `outstanding 0` — even after run-end, whose record still carries 1.
|
|
132
|
+
`outstanding unknown` (a legacy waiver) is never green. A mismatched graph is “not comparable.”
|
|
127
133
|
|
|
128
134
|
Run offers only validated park verbs: human/attempt-cap/other non-gate parks allow approve;
|
|
129
135
|
infra allows approve or `--recheck`; review gate-fail allows `--waive`, `--uphold` or
|