tickmarkr 2.6.5 → 2.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +7 -3
  2. package/dist/adapters/claude-code.js +8 -2
  3. package/dist/adapters/codex-commit-check.d.ts +16 -4
  4. package/dist/adapters/codex-commit-check.js +150 -26
  5. package/dist/adapters/codex.d.ts +1 -0
  6. package/dist/adapters/codex.js +20 -8
  7. package/dist/adapters/types.d.ts +4 -1
  8. package/dist/cli/commands/beat.d.ts +6 -1
  9. package/dist/cli/commands/beat.js +123 -76
  10. package/dist/cli/commands/doctor.js +3 -1
  11. package/dist/cli/commands/fleet.js +368 -85
  12. package/dist/cli/commands/init.js +23 -9
  13. package/dist/cli/commands/plan.js +12 -1
  14. package/dist/cli/commands/verify.js +37 -9
  15. package/dist/cli/help.d.ts +1 -0
  16. package/dist/cli/help.js +10 -5
  17. package/dist/cli/index.d.ts +1 -1
  18. package/dist/cli/index.js +1 -1
  19. package/dist/config/fleet-overlay.d.ts +14 -0
  20. package/dist/config/fleet-overlay.js +154 -25
  21. package/dist/drivers/orca.d.ts +17 -3
  22. package/dist/drivers/orca.js +64 -20
  23. package/dist/gates/baseline.d.ts +9 -0
  24. package/dist/gates/baseline.js +14 -3
  25. package/dist/gates/cache.js +1 -1
  26. package/dist/gates/review.js +15 -0
  27. package/dist/gates/run-gates.d.ts +20 -7
  28. package/dist/gates/run-gates.js +345 -210
  29. package/dist/gates/test-manifest.d.ts +11 -0
  30. package/dist/gates/test-manifest.js +151 -111
  31. package/dist/gates/timeout-shaped.d.ts +3 -0
  32. package/dist/gates/timeout-shaped.js +10 -0
  33. package/dist/run/beat-lifecycle.d.ts +154 -0
  34. package/dist/run/beat-lifecycle.js +825 -0
  35. package/dist/run/daemon.js +202 -126
  36. package/dist/run/git-trust.d.ts +27 -0
  37. package/dist/run/git-trust.js +178 -0
  38. package/dist/run/git.js +34 -14
  39. package/dist/run/journal.js +15 -3
  40. package/dist/run/lease.d.ts +23 -0
  41. package/dist/run/lease.js +232 -38
  42. package/dist/run/merge.js +2 -2
  43. package/dist/run/stall.js +20 -1
  44. package/dist/run/supervision.d.ts +8 -0
  45. package/dist/run/supervision.js +24 -4
  46. package/dist/run/verification-job.d.ts +51 -0
  47. package/dist/run/verification-job.js +137 -0
  48. package/dist/tui/ink/fleet-app.d.ts +35 -5
  49. package/dist/tui/ink/fleet-app.js +244 -124
  50. package/package.json +1 -1
  51. package/skills/tickmarkr-auto/SKILL.md +19 -10
  52. package/skills/tickmarkr-loop/SKILL.md +19 -10
  53. package/skills/tickmarkr-overseer/SKILL.md +25 -16
  54. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +58 -16
  55. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +19 -6
  56. package/skills/tickmarkr-overseer/scripts/watch-parks.sh +121 -64
@@ -1,99 +1,146 @@
1
- import { randomUUID } from "node:crypto";
2
- import { mkdirSync, renameSync, writeFileSync } from "node:fs";
3
- import { dirname } from "node:path";
4
- import { tickmarkrDir } from "../../graph/graph.js";
5
- import { SUPERVISION_BEAT_MS, SUPERVISION_DEFAULT_THRESHOLD_PCT, SUPERVISION_STALE_MS, SUPERVISION_TIERS, beatSupervision, newSupervisionArm, resolveSupervisionRoot, supervisionStandDownPath, } from "../../run/supervision.js";
6
- // One-shot ticks reuse a durable arm; only --new-arm (or --loop) acknowledges a stand-down.
7
- // The loop runs in this process, so its recorded pid belongs to the actual repeating writer.
8
- // Writes are unguarded and a refused ONE-SHOT tick THROWS: the shipped watcher gates on the exit
9
- // code with stdout discarded, so a refusal that exits 0 leaves a live watcher believing it is armed
10
- // while status reads DISARMED. Only the loop returns on stand-down — exiting is its designed end.
11
- const VALUE_OPTIONS = ["--seat", "--arm-id", "--pct", "--threshold-pct"];
12
- export async function beat(argv, cwd = process.cwd()) {
13
- const standDown = argv.includes("--stand-down");
14
- const seat = seatOf(argv);
15
- const pct = percentageOf(argv, "--pct");
16
- const thresholdPct = percentageOf(argv, "--threshold-pct") ?? SUPERVISION_DEFAULT_THRESHOLD_PCT;
17
- const armId = optionOf(argv, "--arm-id");
18
- const named = argv.find((a, i) => !a.startsWith("--") && !VALUE_OPTIONS.includes(argv[i - 1] ?? ""));
19
- if (!isTier(named)) {
20
- throw new Error(`usage: tickmarkr beat <${SUPERVISION_TIERS.join("|")}> --seat <identity> ` +
21
- `[--new-arm] [--loop] [--arm-id <identity> --pct <0..100> --threshold-pct <0..100>] [--stand-down] — ` +
22
- `got ${named ? `\`${named}\`` : "no tier"}`);
1
+ import { parseArgs } from "node:util";
2
+ import { SUPERVISION_BEAT_MS, SUPERVISION_DEFAULT_THRESHOLD_PCT, SUPERVISION_STALE_MS, SUPERVISION_TIERS, beatSupervision, locateSupervisionRoot, newSupervisionArm, publishStandDown, readSupervisionArm, resolveSupervisionRoot, } from "../../run/supervision.js";
3
+ import { BEAT_GENERATION_ENV, beatStatus, defaultBeatDeps, startBeat, stopBeat, withLegacyClaim, } from "../../run/beat-lifecycle.js";
4
+ // Two forms share one strict parser. The lifecycle verbs `start|stop|status <tier>` own a DETACHED loop
5
+ // (src/run/beat-lifecycle.ts); the legacy `<tier>` form keeps its duties: one-shot ticks reuse a durable
6
+ // arm, only --new-arm (or --loop) acknowledges a stand-down, and --status is the read-only status verb.
7
+ // Every refusal is NONZERO: the shipped watcher gates on the exit code with stdout discarded, so a refusal
8
+ // that exits 0 leaves a live watcher believing it is armed while status reads DISARMED — and a loop that is
9
+ // stood down, superseded or cannot write exits nonzero with its reason instead of going silently quiet.
10
+ const VERBS = ["start", "stop", "status"];
11
+ const LEGACY_WRITER_OPTIONS = ["new-arm", "loop", "arm-id", "pct", "threshold-pct", "stand-down"];
12
+ const usage = (got) => `usage: tickmarkr beat <start|stop|status> <tier> --seat <identity> | tickmarkr beat <${SUPERVISION_TIERS.join("|")}> --seat <identity> ` +
13
+ `[--new-arm] [--loop] [--arm-id <identity> --pct <0..100> --threshold-pct <0..100>] [--stand-down] [--status] — ` +
14
+ `got ${got ? `\`${got}\`` : "no tier"}`;
15
+ export async function beat(argv, cwd = process.cwd(), deps = defaultBeatDeps()) {
16
+ const parsed = parseBeat(argv);
17
+ const { verb, tier, seat, values } = parsed;
18
+ if (verb === "status" || values.status) {
19
+ // Read-only by construction: locating the root never creates it, and a repository without a state
20
+ // dir has, truthfully, no beat recorded.
21
+ const { root, state } = locateSupervisionRoot(cwd);
22
+ return state ? beatStatus(root, tier, seat, deps) : { out: `${tier} ABSENT — no state directory`, code: 0 };
23
23
  }
24
24
  // SUP-05: NO SEAT, NO BEAT — and the refusal comes before any write, so a refused invocation leaves
25
- // the tier exactly as it found it. In one-shot mode the exitedWriterPid it records has
26
- // already exited by the time anyone reads the record, so tier + writer + instant is a beat nobody can
27
- // attribute to a seat. Measured 2026-08-26: a consult seat ran the documented loop verbatim and the
28
- // board read that tier ARMED with no seat of that tier having armed anything, and a seatless ARMED
29
- // reads as coverage — worse than ABSENT, because ABSENT sends someone to look.
25
+ // the tier exactly as it found it. Tier + writer + instant is a beat nobody can attribute to a seat
26
+ // (measured 2026-08-26), and a seatless ARMED reads as coverage — worse than ABSENT.
30
27
  if (!seat) {
31
- throw new Error(`${named} needs --seat <identity> — a beat that names no seat arms a tier nobody occupies` +
28
+ throw new Error(`${verb ? `${verb} ${tier}` : tier} needs --seat <identity> — a beat that names no seat arms a tier nobody occupies` +
32
29
  " (pass the seat's own pane id or agent name)");
33
30
  }
34
31
  // A beat names repository state, never the caller's incidental directory. Resolution is read-only
35
32
  // and happens before every write, so a non-repository invocation cannot create the state it claims.
36
33
  const repoRoot = resolveSupervisionRoot(cwd);
37
- if (standDown)
38
- return standDownTier(repoRoot, named, seat);
39
- const loop = argv.includes("--loop");
40
- const arm = argv.includes("--new-arm") || loop ? newSupervisionArm(repoRoot, named, armId) : undefined;
34
+ if (verb === "start")
35
+ return startBeat(repoRoot, tier, seat, deps);
36
+ if (verb === "stop")
37
+ return stopBeat(repoRoot, tier, seat, deps);
38
+ const generation = deps.env[BEAT_GENERATION_ENV]?.trim() || undefined;
39
+ const writer = (settled = false, arm) => ({ generation, seat, settled, arm, deps });
40
+ if (values["stand-down"]) {
41
+ return withLegacyClaim(repoRoot, tier, writer(), (recheck) => { recheck(); return standDownTier(repoRoot, tier, seat); });
42
+ }
43
+ const loop = values.loop === true;
44
+ const armId = values["arm-id"];
45
+ const pct = percentage(values.pct, "--pct");
46
+ const thresholdPct = percentage(values["threshold-pct"], "--threshold-pct") ?? SUPERVISION_DEFAULT_THRESHOLD_PCT;
41
47
  // Context arm ids retain their existing obligation semantics; they do not implicitly re-arm liveness.
42
- const observation = pct === undefined ? undefined : { armId: armId ?? arm?.armId ?? seat, pct, thresholdPct };
43
- do {
44
- if (!beatSupervision(repoRoot, named, seat, observation, { arm, armId, loop })) {
45
- const refusal = `${named} DISARMED — stood down; use --new-arm to resume beating`;
46
- if (loop)
47
- return refusal;
48
- throw new Error(refusal);
48
+ const observe = (arm) => pct === undefined ? undefined : { armId: armId ?? arm?.armId ?? seat, pct, thresholdPct };
49
+ // EVERY mutation — the one-shot tick, the arm, and each later loop tick — runs under the tier claim, and
50
+ // `recheck` re-reads that claim and the tier's ownership immediately before the write: a generation-carrying
51
+ // writer mutates only while its COMPLETE generation (token, seat, pid once settled, and the owner's durable
52
+ // arm id + epoch) is on record; every other writer only while no detached beat owns the tier. `created` marks
53
+ // the arm this same claim just published: an owned re-arm rebinds the owner record to it — only while that arm
54
+ // and the owner checked before it are still the ones on disk — and is checked again as that new generation
55
+ // before its beat. A running loop carries its arm into every check. A claim someone else holds refuses with
56
+ // no bytes changed.
57
+ const tick = (recheck, arm, created = false) => {
58
+ recheck(arm, created);
59
+ if (beatSupervision(repoRoot, tier, seat, observe(arm), { arm, armId, loop }))
60
+ return;
61
+ const current = arm && readSupervisionArm(repoRoot, tier);
62
+ if (arm && (current?.armId !== arm.armId || current.armEpoch !== arm.armEpoch)) {
63
+ throw new Error(`${tier} superseded — a newer arm replaced this loop's arm ${arm.armId}; exiting`);
49
64
  }
50
- if (!loop)
51
- break;
52
- await new Promise((resolve) => setTimeout(resolve, SUPERVISION_BEAT_MS));
53
- } while (loop);
54
- return `${named} ARMED as ${seat} — beat again every ${SUPERVISION_BEAT_MS / 1_000}s; the tier reads STALE ${SUPERVISION_STALE_MS / 1_000}s after the last beat`;
55
- }
56
- /** `--seat <identity>` or `--seat=<identity>`; blank and missing are the same answer — none. */
57
- function seatOf(argv) {
58
- const seat = optionOf(argv, "--seat")?.trim();
59
- return seat && !seat.startsWith("--") ? seat : undefined;
65
+ throw new Error(`${tier} DISARMED — stood down; use --new-arm to resume beating`);
66
+ };
67
+ if (values["new-arm"] || loop) {
68
+ const arm = await withLegacyClaim(repoRoot, tier, writer(), (recheck) => {
69
+ recheck();
70
+ const created = newSupervisionArm(repoRoot, tier, armId);
71
+ tick(recheck, created, true);
72
+ return created;
73
+ });
74
+ // ponytail: a running loop outlasts a brief claim holder (a repeat start, a status-side stop) for ~5 s of
75
+ // retries, then refuses nonzero; a per-tick deadline if holders ever legitimately run longer.
76
+ const waits = { tries: 20, sleep: deps.sleep };
77
+ while (loop) {
78
+ await deps.sleep(SUPERVISION_BEAT_MS);
79
+ await withLegacyClaim(repoRoot, tier, writer(true, arm), (recheck) => tick(recheck, arm), waits);
80
+ }
81
+ }
82
+ else
83
+ await withLegacyClaim(repoRoot, tier, writer(), (recheck) => tick(recheck));
84
+ return `${tier} ARMED as ${seat} — beat again every ${SUPERVISION_BEAT_MS / 1_000}s; the tier reads STALE ${SUPERVISION_STALE_MS / 1_000}s after the last beat`;
60
85
  }
61
- /** A `--name value` or `--name=value` option, excluding a missing value or the next flag. */
62
- function optionOf(argv, name) {
63
- const inline = argv.find((a) => a.startsWith(`${name}=`))?.slice(name.length + 1).trim();
64
- const spaced = argv[argv.indexOf(name) + 1]?.trim();
65
- const value = inline ?? (argv.includes(name) ? spaced : undefined);
66
- return value && !value.startsWith("--") ? value : undefined;
86
+ /** Unknown, duplicate, conflicting or empty options refuse here — before anything is read or written. */
87
+ function parseBeat(argv) {
88
+ let parsed;
89
+ try {
90
+ parsed = parseArgs({
91
+ args: argv, allowPositionals: true, strict: true, tokens: true,
92
+ options: {
93
+ seat: { type: "string" }, "new-arm": { type: "boolean" }, loop: { type: "boolean" },
94
+ "arm-id": { type: "string" }, pct: { type: "string" }, "threshold-pct": { type: "string" },
95
+ "stand-down": { type: "boolean" }, status: { type: "boolean" },
96
+ },
97
+ });
98
+ }
99
+ catch (error) {
100
+ throw new Error(`${error.message} — ${usage(undefined).replace(/ — got .*$/, "")}`);
101
+ }
102
+ const seen = new Set();
103
+ for (const token of parsed.tokens) {
104
+ if (token.kind !== "option")
105
+ continue;
106
+ if (seen.has(token.name))
107
+ throw new Error(`duplicate --${token.name} — pass each option once`);
108
+ seen.add(token.name);
109
+ if (typeof token.value === "string" && !token.value.trim())
110
+ throw new Error(`--${token.name} needs a non-empty value`);
111
+ }
112
+ const values = parsed.values;
113
+ const [first, ...rest] = parsed.positionals;
114
+ const verb = VERBS.includes(first ?? "") ? first : undefined;
115
+ const named = verb ? rest[0] : first;
116
+ if (!isTier(named))
117
+ throw new Error(usage(verb && named === undefined ? `${verb} with no tier` : named));
118
+ const extra = (verb ? rest.slice(1) : rest)[0];
119
+ if (extra !== undefined)
120
+ throw new Error(`unexpected argument \`${extra}\` — ${usage(named).replace(/ — got .*$/, "")}`);
121
+ const readOnly = verb !== undefined || values.status;
122
+ const writer = LEGACY_WRITER_OPTIONS.find((name) => values[name] !== undefined);
123
+ if (readOnly && writer)
124
+ throw new Error(`--${writer} conflicts with ${verb ? `beat ${verb}` : "--status"}`);
125
+ if (verb && values.status)
126
+ throw new Error(`--status conflicts with beat ${verb}`);
127
+ const withStandDown = values["stand-down"] && LEGACY_WRITER_OPTIONS.find((name) => name !== "stand-down" && values[name] !== undefined);
128
+ if (withStandDown)
129
+ throw new Error(`--${withStandDown} conflicts with --stand-down`);
130
+ return { ...(verb ? { verb } : {}), tier: named, ...(values.seat ? { seat: values.seat.trim() } : {}), values };
67
131
  }
68
- function percentageOf(argv, name) {
69
- const raw = optionOf(argv, name);
70
- if (raw === undefined) {
71
- if (argv.includes(name) || argv.some((a) => a.startsWith(`${name}=`))) {
72
- throw new Error(`${name} needs a number from 0 through 100`);
73
- }
132
+ function percentage(raw, name) {
133
+ if (raw === undefined)
74
134
  return undefined;
75
- }
76
135
  const value = Number(raw);
77
136
  if (!Number.isFinite(value) || value < 0 || value > 100) {
78
137
  throw new Error(`${name} must be a number from 0 through 100`);
79
138
  }
80
139
  return value;
81
140
  }
82
- // Stand-down is a RECORDED act, not a silence: the marker tells a reader this watcher left on purpose,
83
- // so the tier reads DISARMED rather than ageing out as a death. Published atomically — written aside,
84
- // renamed over — because a torn marker is rejected by the reader, and a rejected stand-down reports a
85
- // deliberate hand-off as a death.
141
+ /** Record an explicit hand-off: the tier reads DISARMED, a stood-down tier, not a dead one. */
86
142
  export function standDownTier(repoRoot, tier, seat) {
87
- tickmarkrDir(repoRoot); // the write path DOES create — markers land inside the gitignored state dir
88
- const p = supervisionStandDownPath(repoRoot, tier);
89
- const tmp = `${p}.${randomUUID()}.tmp`;
90
- mkdirSync(dirname(p), { recursive: true });
91
- // The marker names the seat for the same reason the beat does: "someone stood this tier down" is not
92
- // a hand-off anyone can act on, and on a seat tier the reader rejects an anonymous one outright.
93
- writeFileSync(tmp, JSON.stringify({
94
- tier, seat, standDownId: randomUUID(), exitedWriterPid: process.pid, disarmedAt: new Date().toISOString(),
95
- }) + "\n");
96
- renameSync(tmp, p);
143
+ publishStandDown(repoRoot, tier, seat);
97
144
  return `${tier} DISARMED — ${seat} handed off; status reads it stood down, not dead`;
98
145
  }
99
146
  const isTier = (v) => SUPERVISION_TIERS.includes(v ?? "");
@@ -15,6 +15,7 @@ import { HerdrDriver } from "../../drivers/herdr.js";
15
15
  import { ORCA_FIXTURE_VERSION, parseEnvelope, resolveOrcaCliBinary } from "../../drivers/orca.js";
16
16
  import { kimi, probeKimiDoctorTurn } from "../../adapters/kimi.js";
17
17
  import { recordCodexCommit } from "../../adapters/codex-commit-check.js";
18
+ import { codex } from "../../adapters/codex.js";
18
19
  import { denyPreferCollisionLine, denyPreferCollisions, disallowedBy, excludedChannels, exclusionLine, observedSeat, preferRanks } from "../../route/preference.js";
19
20
  import { CATALOG_REFRESH_TIMEOUT_MS, LIVEBENCH_TABLE_DATE, formatCatalogRefreshLegs, readCachedCatalog, refreshCatalogCommand } from "../../adapters/catalog-remote.js";
20
21
  import { sh } from "../../run/git.js";
@@ -672,7 +673,8 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
672
673
  const h = health[a.id];
673
674
  // OBS-503: a crash-on-version verdict carries its reason in note — print it instead of the lie.
674
675
  const state = !h.installed ? (h.note ?? "not installed") : `${h.version ?? "installed"}${h.note ? ` (${h.note})` : ""}`;
675
- const healthy = h.installed && (a.id !== kimi.id || h.authed);
676
+ // v2.6.6 T9 (K): a Codex sandbox escape is a BLOCKING security warning — never a passing row
677
+ const healthy = h.installed && (a.id !== kimi.id || h.authed) && !(a.id === codex.id && h.codexCommit === "escape");
676
678
  return alignedStatusRow(healthy ? "pass" : "fail", a.id, state);
677
679
  });
678
680
  const reviewDemotions = recentReviewDemotions(cwd);