tickmarkr 2.5.5 → 2.5.6

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 (48) hide show
  1. package/README.md +3 -1
  2. package/dist/cli/commands/approve.js +64 -3
  3. package/dist/cli/commands/doctor.d.ts +10 -0
  4. package/dist/cli/commands/fleet.js +4 -0
  5. package/dist/cli/commands/plan.js +20 -4
  6. package/dist/cli/commands/verify.js +102 -81
  7. package/dist/config/config.d.ts +11 -0
  8. package/dist/config/config.js +21 -12
  9. package/dist/config/fleet-overlay.js +55 -17
  10. package/dist/drivers/index.js +2 -1
  11. package/dist/drivers/orca.d.ts +21 -1
  12. package/dist/drivers/orca.js +209 -27
  13. package/dist/gates/baseline.d.ts +14 -3
  14. package/dist/gates/baseline.js +61 -14
  15. package/dist/gates/cache.d.ts +14 -13
  16. package/dist/gates/cache.js +17 -5
  17. package/dist/gates/llm.d.ts +3 -0
  18. package/dist/gates/llm.js +11 -0
  19. package/dist/gates/review.d.ts +28 -3
  20. package/dist/gates/review.js +106 -14
  21. package/dist/gates/run-gates.d.ts +6 -1
  22. package/dist/gates/run-gates.js +299 -101
  23. package/dist/gates/test-manifest.d.ts +30 -1
  24. package/dist/gates/test-manifest.js +113 -39
  25. package/dist/gates/test-reporter.js +14 -6
  26. package/dist/graph/graph.d.ts +2 -0
  27. package/dist/graph/graph.js +45 -1
  28. package/dist/run/consult.js +5 -4
  29. package/dist/run/daemon.d.ts +2 -0
  30. package/dist/run/daemon.js +480 -99
  31. package/dist/run/execution-budget.d.ts +25 -0
  32. package/dist/run/execution-budget.js +142 -0
  33. package/dist/run/git.d.ts +40 -0
  34. package/dist/run/git.js +89 -4
  35. package/dist/run/journal.d.ts +3 -1
  36. package/dist/run/journal.js +38 -16
  37. package/dist/run/lease.d.ts +44 -0
  38. package/dist/run/lease.js +226 -3
  39. package/dist/run/recovery.d.ts +8 -0
  40. package/dist/run/recovery.js +25 -0
  41. package/dist/run/repair-selection.d.ts +12 -0
  42. package/dist/run/repair-selection.js +56 -0
  43. package/dist/run/stall.d.ts +6 -1
  44. package/dist/run/stall.js +60 -3
  45. package/dist/tui/ink/fleet-app.d.ts +10 -2
  46. package/dist/tui/ink/fleet-app.js +33 -15
  47. package/package.json +2 -2
  48. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
@@ -0,0 +1,25 @@
1
+ export declare class ExecutionBudgetExceeded extends Error {
2
+ readonly taskId: string;
3
+ constructor(taskId: string, reason?: string);
4
+ }
5
+ export interface ExecutionBudgetEvent {
6
+ event: string;
7
+ taskId?: string;
8
+ data: Record<string, unknown>;
9
+ }
10
+ export interface ExecutionBudgetOptions {
11
+ limitMs: number;
12
+ taskId: string;
13
+ readEvents: () => readonly ExecutionBudgetEvent[];
14
+ append: (event: string, taskId: string, data: Record<string, unknown>) => void;
15
+ }
16
+ export declare const executionSignal: () => AbortSignal | undefined;
17
+ /** Cleanup only: the caller must bound cleanup and must not authorize task work here. */
18
+ export declare const withoutExecutionBudget: <T>(run: () => T) => T;
19
+ export declare const remainingExecutionMs: () => number | undefined;
20
+ /** One wall-clock interval around task execution, shared by parallel children. Cancellation is
21
+ * cooperative: callers must stop on executionSignal(), and this wrapper waits for their cleanup.
22
+ * Durable slices are bought before work: at least one minute or 1% of the task ceiling, capped
23
+ * by what remains. This avoids a per-second journal stream. An interrupted slice stays fully
24
+ * charged, conservatively consuming its unused portion; daemon-offline time is never charged. */
25
+ export declare function withExecutionBudget<T>(opts: ExecutionBudgetOptions, run: () => Promise<T>): Promise<T>;
@@ -0,0 +1,142 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { randomUUID } from "node:crypto";
3
+ export class ExecutionBudgetExceeded extends Error {
4
+ taskId;
5
+ constructor(taskId, reason = "automated execution ceiling exhausted") {
6
+ super(`${taskId}: ${reason}`);
7
+ this.taskId = taskId;
8
+ this.name = "ExecutionBudgetExceeded";
9
+ }
10
+ }
11
+ const context = new AsyncLocalStorage();
12
+ const MIN_SLICE_MS = 60_000;
13
+ const finite = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
14
+ function consumed(opts) {
15
+ const reservations = new Map();
16
+ for (const e of opts.readEvents()) {
17
+ if (e.taskId !== opts.taskId || !["execution-budget-reserved", "execution-budget-settled"].includes(e.event))
18
+ continue;
19
+ const d = e.data;
20
+ if (!d || d.limitMs !== opts.limitMs || typeof d.id !== "string" || !d.id) {
21
+ throw new ExecutionBudgetExceeded(opts.taskId, "invalid execution budget accounting");
22
+ }
23
+ if (e.event === "execution-budget-reserved") {
24
+ if (!finite(d.reservedMs) || d.reservedMs === 0 || reservations.has(d.id)) {
25
+ throw new ExecutionBudgetExceeded(opts.taskId, "invalid execution reservation");
26
+ }
27
+ reservations.set(d.id, { reserved: d.reservedMs });
28
+ }
29
+ else {
30
+ const prior = reservations.get(d.id);
31
+ if (!prior || prior.used !== undefined || !finite(d.usedMs) || d.usedMs > prior.reserved) {
32
+ throw new ExecutionBudgetExceeded(opts.taskId, "invalid execution settlement");
33
+ }
34
+ prior.used = d.usedMs;
35
+ }
36
+ }
37
+ const total = [...reservations.values()].reduce((sum, r) => sum + (r.used ?? r.reserved), 0);
38
+ if (!finite(total) || total > opts.limitMs)
39
+ throw new ExecutionBudgetExceeded(opts.taskId, "invalid execution total");
40
+ return total;
41
+ }
42
+ export const executionSignal = () => {
43
+ const budget = context.getStore();
44
+ budget?.check();
45
+ return budget?.signal;
46
+ };
47
+ /** Cleanup only: the caller must bound cleanup and must not authorize task work here. */
48
+ export const withoutExecutionBudget = (run) => context.exit(run);
49
+ export const remainingExecutionMs = () => context.getStore()?.remaining();
50
+ /** One wall-clock interval around task execution, shared by parallel children. Cancellation is
51
+ * cooperative: callers must stop on executionSignal(), and this wrapper waits for their cleanup.
52
+ * Durable slices are bought before work: at least one minute or 1% of the task ceiling, capped
53
+ * by what remains. This avoids a per-second journal stream. An interrupted slice stays fully
54
+ * charged, conservatively consuming its unused portion; daemon-offline time is never charged. */
55
+ export async function withExecutionBudget(opts, run) {
56
+ if (!finite(opts.limitMs) || opts.limitMs === 0 || !opts.taskId)
57
+ throw new ExecutionBudgetExceeded(opts.taskId, "invalid execution ceiling");
58
+ const parent = context.getStore();
59
+ if (parent) {
60
+ if (parent.taskId !== opts.taskId || parent.limitMs !== opts.limitMs)
61
+ throw new ExecutionBudgetExceeded(opts.taskId, "nested execution budget mismatch");
62
+ parent.check();
63
+ parent.signal.throwIfAborted();
64
+ return run();
65
+ }
66
+ const prior = consumed(opts);
67
+ const available = opts.limitMs - prior;
68
+ const sliceMs = Math.max(MIN_SLICE_MS, opts.limitMs / 100);
69
+ if (available <= 0)
70
+ throw new ExecutionBudgetExceeded(opts.taskId);
71
+ const controller = new AbortController();
72
+ const start = performance.now();
73
+ let reserved = 0;
74
+ let current;
75
+ let timer;
76
+ const elapsed = () => Math.max(0, performance.now() - start);
77
+ const remaining = () => Math.max(0, available - elapsed());
78
+ const stop = (error) => controller.abort(error instanceof Error ? error : new ExecutionBudgetExceeded(opts.taskId));
79
+ const buy = (amount) => {
80
+ if (amount <= 0)
81
+ return;
82
+ const next = { id: randomUUID(), before: reserved, amount };
83
+ opts.append("execution-budget-reserved", opts.taskId, { id: next.id, limitMs: opts.limitMs, reservedMs: amount });
84
+ current = next;
85
+ reserved += amount;
86
+ };
87
+ // JS timers cannot preempt a blocked event loop. Charge elapsed overruns before another
88
+ // observable operation or completion; expiration consumes the whole allowance across resume.
89
+ const check = () => {
90
+ try {
91
+ const used = Math.min(available, elapsed());
92
+ if (used > reserved)
93
+ buy(used - reserved);
94
+ if (used >= available)
95
+ stop(new ExecutionBudgetExceeded(opts.taskId));
96
+ }
97
+ catch (error) {
98
+ stop(error);
99
+ }
100
+ };
101
+ const reserve = () => {
102
+ if (elapsed() >= available)
103
+ check();
104
+ if (controller.signal.aborted)
105
+ return;
106
+ buy(Math.min(available - reserved, Math.max(sliceMs, elapsed() - reserved + sliceMs)));
107
+ timer = setTimeout(() => {
108
+ try {
109
+ reserve();
110
+ }
111
+ catch (error) {
112
+ stop(error);
113
+ }
114
+ }, Math.max(0, reserved - elapsed()));
115
+ };
116
+ reserve();
117
+ controller.signal.throwIfAborted();
118
+ const budget = { taskId: opts.taskId, limitMs: opts.limitMs, signal: controller.signal, remaining, check };
119
+ try {
120
+ return await context.run(budget, async () => {
121
+ controller.signal.throwIfAborted();
122
+ const result = await run();
123
+ check();
124
+ controller.signal.throwIfAborted();
125
+ return result;
126
+ });
127
+ }
128
+ catch (error) {
129
+ check();
130
+ controller.signal.throwIfAborted();
131
+ throw error;
132
+ }
133
+ finally {
134
+ clearTimeout(timer);
135
+ check();
136
+ if (current) {
137
+ const usedMs = Math.min(current.amount, Math.max(0, elapsed() - current.before));
138
+ opts.append("execution-budget-settled", opts.taskId, { id: current.id, limitMs: opts.limitMs, usedMs });
139
+ }
140
+ controller.signal.throwIfAborted();
141
+ }
142
+ }
package/dist/run/git.d.ts CHANGED
@@ -76,6 +76,46 @@ export declare function readCapacity(value: unknown): CapacityRead;
76
76
  * different capacity, or a current capacity the caller could not state → no.
77
77
  */
78
78
  export declare function sameCapacity(recorded: unknown, current: RunCapacity | undefined): boolean;
79
+ /**
80
+ * R41: the verification PROTOCOL a verdict was produced under — which implementation of the manifest
81
+ * discovery/report contract judged it, and the npm lifecycle policy its runner children ran with.
82
+ * Two verdicts are comparable only when both match: a manifest built from module collection ("vl1.1",
83
+ * the pre-stamp implementation) and one built from file specifications ("vl1.2") certify different
84
+ * sets, and a suite whose `pretest` hook ran (ignore-scripts=false) is a different measurement from
85
+ * one where npm skipped it. Bump VERIFICATION_PROTOCOL whenever discovery or report semantics change.
86
+ */
87
+ export declare const VERIFICATION_PROTOCOL = "vl1.2";
88
+ export type RunnerLifecycle = "hooks" | "ignore-scripts" | "unknown";
89
+ export interface VerificationProtocol {
90
+ protocol: string;
91
+ /** The EFFECTIVE npm lifecycle policy a runner child receives: `hooks` (pretest/prebuild run),
92
+ * `ignore-scripts` (npm skips them), or `unknown` (could not be measured — never comparable). */
93
+ lifecycle: RunnerLifecycle;
94
+ /** Provenance only, never part of compatibility: an explicit process-scoped env export, or npm's
95
+ * own resolved config for that checkout (user/project npmrc). */
96
+ source: "explicit" | "npm-config" | "unknown";
97
+ }
98
+ /**
99
+ * R41: the lifecycle policy a runner child spawned from `env` in `cwd` receives. An explicit
100
+ * process-scoped `npm_config_ignore_scripts` export decides (npm reads env over every npmrc) and is
101
+ * recorded as such; otherwise the policy is MEASURED from npm's own resolved config for that checkout
102
+ * (`npm config get ignore-scripts` — user npmrc, project npmrc, env), so the identity binds what
103
+ * npm will actually do rather than a guess. A policy that cannot be measured is `unknown`, and an
104
+ * unknown policy is never compatible with anything, itself included. No env or npmrc is written here.
105
+ */
106
+ export declare function runnerLifecycle(env?: NodeJS.ProcessEnv, cwd?: string): Pick<VerificationProtocol, "lifecycle" | "source">;
107
+ export declare function verificationProtocol(env?: NodeJS.ProcessEnv, cwd?: string): VerificationProtocol;
108
+ /**
109
+ * May a verdict recorded under `recorded` be reused — cached, replayed — by a session running
110
+ * `current`? Unlike capacity, ABSENT is NOT compatible: a row with no protocol stamp was produced by
111
+ * the pre-stamp implementation (module-collection discovery, lifecycle unstated), which is exactly
112
+ * the evidence a changed protocol must not inherit — positive or negative. Malformed → no. An
113
+ * `unknown` lifecycle on either side → no: two unmeasured policies are not the same policy.
114
+ * `source` is provenance and never enters the comparison — an explicit `false` and an npmrc `false`
115
+ * are the same effective policy.
116
+ */
117
+ export declare function sameVerification(recorded: unknown, current: VerificationProtocol): boolean;
118
+ export declare const describeVerification: (value: unknown) => string;
79
119
  export declare const describeCapacity: (value: unknown) => string;
80
120
  /**
81
121
  * The capacity a child spawned on THIS async context would receive: the same precedence `shell`
package/dist/run/git.js CHANGED
@@ -1,8 +1,9 @@
1
+ import { executionSignal } from "./execution-budget.js";
1
2
  import { commandLeaseEnvironment, withCommandLease } from "./lease.js";
2
3
  import { AsyncLocalStorage } from "node:async_hooks";
3
- import { spawn } from "node:child_process";
4
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
- import { availableParallelism, tmpdir } from "node:os";
4
+ import { execFileSync, spawn } from "node:child_process";
5
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
6
+ import { availableParallelism, homedir, tmpdir } from "node:os";
6
7
  import { join, resolve } from "node:path";
7
8
  import { StringDecoder } from "node:string_decoder";
8
9
  import { shq } from "../adapters/types.js";
@@ -82,6 +83,86 @@ export function sameCapacity(recorded, current) {
82
83
  return false;
83
84
  return read.capacity.forkCap === current.forkCap && read.capacity.cores === current.cores;
84
85
  }
86
+ /**
87
+ * R41: the verification PROTOCOL a verdict was produced under — which implementation of the manifest
88
+ * discovery/report contract judged it, and the npm lifecycle policy its runner children ran with.
89
+ * Two verdicts are comparable only when both match: a manifest built from module collection ("vl1.1",
90
+ * the pre-stamp implementation) and one built from file specifications ("vl1.2") certify different
91
+ * sets, and a suite whose `pretest` hook ran (ignore-scripts=false) is a different measurement from
92
+ * one where npm skipped it. Bump VERIFICATION_PROTOCOL whenever discovery or report semantics change.
93
+ */
94
+ export const VERIFICATION_PROTOCOL = "vl1.2";
95
+ const lifecycleOf = (raw) => {
96
+ const v = raw?.trim().toLowerCase();
97
+ return v === "false" ? "hooks" : v === "true" ? "ignore-scripts" : undefined;
98
+ };
99
+ // Measured effective policy, keyed on everything npm resolves it from that this process can see:
100
+ // the checkout, its project npmrc and the user npmrc (path + mtime), and the explicit env. A
101
+ // rewritten npmrc changes the key, so a later battery in the same process re-measures.
102
+ const measuredLifecycle = new Map();
103
+ const mtimeOf = (p) => { try {
104
+ return String(statSync(p).mtimeMs);
105
+ }
106
+ catch {
107
+ return "-";
108
+ } };
109
+ /**
110
+ * R41: the lifecycle policy a runner child spawned from `env` in `cwd` receives. An explicit
111
+ * process-scoped `npm_config_ignore_scripts` export decides (npm reads env over every npmrc) and is
112
+ * recorded as such; otherwise the policy is MEASURED from npm's own resolved config for that checkout
113
+ * (`npm config get ignore-scripts` — user npmrc, project npmrc, env), so the identity binds what
114
+ * npm will actually do rather than a guess. A policy that cannot be measured is `unknown`, and an
115
+ * unknown policy is never compatible with anything, itself included. No env or npmrc is written here.
116
+ */
117
+ export function runnerLifecycle(env = process.env, cwd = process.cwd()) {
118
+ const explicit = lifecycleOf(env.npm_config_ignore_scripts ?? env.NPM_CONFIG_IGNORE_SCRIPTS);
119
+ if (explicit)
120
+ return { lifecycle: explicit, source: "explicit" };
121
+ const userconfig = env.NPM_CONFIG_USERCONFIG ?? env.npm_config_userconfig ?? join(homedir(), ".npmrc");
122
+ const key = [cwd, mtimeOf(join(cwd, ".npmrc")), userconfig, mtimeOf(userconfig),
123
+ env.npm_config_ignore_scripts ?? env.NPM_CONFIG_IGNORE_SCRIPTS ?? ""].join("\0");
124
+ let lifecycle = measuredLifecycle.get(key);
125
+ if (lifecycle === undefined) {
126
+ try {
127
+ const out = execFileSync("npm", ["config", "get", "ignore-scripts"], {
128
+ cwd: existsSync(cwd) ? cwd : process.cwd(), env, encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "ignore"],
129
+ });
130
+ lifecycle = lifecycleOf(out) ?? "unknown";
131
+ }
132
+ catch {
133
+ lifecycle = "unknown";
134
+ }
135
+ measuredLifecycle.set(key, lifecycle);
136
+ }
137
+ return { lifecycle, source: lifecycle === "unknown" ? "unknown" : "npm-config" };
138
+ }
139
+ export function verificationProtocol(env = process.env, cwd = process.cwd()) {
140
+ return { protocol: VERIFICATION_PROTOCOL, ...runnerLifecycle(env, cwd) };
141
+ }
142
+ /**
143
+ * May a verdict recorded under `recorded` be reused — cached, replayed — by a session running
144
+ * `current`? Unlike capacity, ABSENT is NOT compatible: a row with no protocol stamp was produced by
145
+ * the pre-stamp implementation (module-collection discovery, lifecycle unstated), which is exactly
146
+ * the evidence a changed protocol must not inherit — positive or negative. Malformed → no. An
147
+ * `unknown` lifecycle on either side → no: two unmeasured policies are not the same policy.
148
+ * `source` is provenance and never enters the comparison — an explicit `false` and an npmrc `false`
149
+ * are the same effective policy.
150
+ */
151
+ export function sameVerification(recorded, current) {
152
+ if (recorded === null || typeof recorded !== "object")
153
+ return false;
154
+ const { protocol, lifecycle } = recorded;
155
+ if (lifecycle === "unknown" || current.lifecycle === "unknown")
156
+ return false;
157
+ return protocol === current.protocol && lifecycle === current.lifecycle;
158
+ }
159
+ export const describeVerification = (value) => {
160
+ if (value === null || typeof value !== "object")
161
+ return "an unrecorded verification protocol";
162
+ const { protocol, lifecycle, source } = value;
163
+ return typeof protocol === "string" && typeof lifecycle === "string"
164
+ ? `protocol ${protocol}, lifecycle ${lifecycle}${typeof source === "string" ? ` (${source})` : ""}` : "a malformed verification protocol";
165
+ };
85
166
  export const describeCapacity = (value) => {
86
167
  const read = readCapacity(value);
87
168
  return read.state === "present"
@@ -131,7 +212,10 @@ export const SPAWN_RETRY_BACKOFF_MS = 50;
131
212
  const SHELL_REAP_GRACE_MS = 2000;
132
213
  /** Shared command seam, including invocation-bound manifested runners. */
133
214
  export function shell(cmd, cwd, timeoutMs, login = false, options = {}) {
134
- return withCommandLease(cmd, () => executeShell(cmd, cwd, timeoutMs, login, options));
215
+ const inherited = executionSignal();
216
+ const signal = inherited && options.signal ? AbortSignal.any([inherited, options.signal]) : inherited ?? options.signal;
217
+ signal?.throwIfAborted();
218
+ return withCommandLease(cmd, () => executeShell(cmd, cwd, timeoutMs, login, { ...options, signal }));
135
219
  }
136
220
  function executeShell(cmd, cwd, timeoutMs, login, options) {
137
221
  options.signal?.throwIfAborted();
@@ -289,6 +373,7 @@ function executeShell(cmd, cwd, timeoutMs, login, options) {
289
373
  return (async () => {
290
374
  const startedAt = Date.now();
291
375
  for (let n = 1;; n++) {
376
+ options.signal?.throwIfAborted();
292
377
  const r = await attempt();
293
378
  if (!("refused" in r))
294
379
  return r;
@@ -60,6 +60,7 @@ export interface ConsultGuidanceCarry {
60
60
  */
61
61
  export declare function outstandingConsultGuidance(events: JournalEvent[], taskId: string): ConsultGuidanceCarry | undefined;
62
62
  export declare const UNIDENTIFIED = "<unidentified>";
63
+ export declare function boundedSymbol(note: string): string;
63
64
  /**
64
65
  * Structured findings for a BLOCKING review/judge gate result, parsed from the details the gate
65
66
  * already writes (D-03: no gate-module change, so an older gate's prose degrades to one unclassified
@@ -192,7 +193,7 @@ export declare function pendingRepairFindings(events: JournalEvent[], taskId: st
192
193
  * a channel it is no longer using, and a later unrelated failure is not parked under a stale reason.
193
194
  */
194
195
  export declare function activeRetryBan(events: JournalEvent[], taskId: string, channel: string): string | undefined;
195
- export declare const PARK_KINDS: readonly ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota", "reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved", "infra", "dispatch", "authoring", "scope-request"];
196
+ export declare const PARK_KINDS: readonly ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota", "reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved", "infra", "dispatch", "authoring", "scope-request", "diff-cap"];
196
197
  export type ParkKind = (typeof PARK_KINDS)[number];
197
198
  export declare const RETRY_MODES: readonly ["resume", "fresh", "repair"];
198
199
  export type RetryMode = (typeof RETRY_MODES)[number];
@@ -244,6 +245,7 @@ export declare const TelemetryRowSchema: z.ZodObject<{
244
245
  "tip-moved": "tip-moved";
245
246
  authoring: "authoring";
246
247
  "scope-request": "scope-request";
248
+ "diff-cap": "diff-cap";
247
249
  }>>;
248
250
  tokens: z.ZodCatch<z.ZodOptional<z.ZodObject<{
249
251
  input: z.ZodNumber;
@@ -1,4 +1,5 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { createHash } from "node:crypto";
2
3
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import { z } from "zod";
@@ -60,8 +61,8 @@ export function formatJournalNarration({ event, taskId, data }) {
60
61
  // replayResumeState(), consumed by the daemon on resume to re-seed demotedChannels.
61
62
  export const CHANNEL_EXCLUSION_KINDS = ["dead-channel"];
62
63
  // v1.24 OBS-18: explicit data on task-approved when the operator releases an attempt-cap park.
63
- // Approve stamps this; replayResumeState zeros the attempt counter (fresh budget) while keeping
64
- // tried (consult bans / burned channels). Absent on pre-v1.24 events ⇒ inert (corpus outcome-identical).
64
+ // Approve stamps this; replayResumeState resets both the attempt counter and tried seats for a fresh
65
+ // budget. Absent on pre-v1.24 events ⇒ inert (corpus outcome-identical).
65
66
  export const ATTEMPT_CAP_RELEASE = "attempt-cap";
66
67
  // OBS-130: task-approved carries the exact failed gate the operator satisfied. The release tag keeps
67
68
  // ordinary humanGate and attempt-cap approvals byte-compatible while making this authority explicit.
@@ -74,8 +75,8 @@ export const REVIEW_UPHELD_RELEASE = "review-upheld";
74
75
  // gate before it — daemon.ts remainingGates), which is wrong when the gate failed against a stale task
75
76
  // DECLARATION rather than a bad diff: amend the spec's files[], recompile, and the gate now passes
76
77
  // honestly. This release runs the whole gate suite over the parked commit before any worker and
77
- // marks no gate satisfied: green merges directly, red returns to the ladder. Budget semantics match attempt-cap
78
- // (fresh attempts, tried survives) because the park cost the task its remaining budget.
78
+ // marks no gate satisfied: green merges directly, red returns to the existing ladder with attempts
79
+ // and tried seats intact.
79
80
  export const RECHECK_RELEASE = "recheck";
80
81
  // OBS-738: one authority for every recovery surface. The ref is accepted only from the row that
81
82
  // preservation itself writes; task-human prose, branch heads and commit history are deliberately
@@ -217,9 +218,20 @@ function identifierIn(note) {
217
218
  // code identity still has one stable identity of its own: its own words, with the volatile tokens
218
219
  // swept out so line/path churn cannot mint a new symbol for the same finding. It is the reviewer's
219
220
  // own bytes, never a guess, and it can never fuse two different findings into one.
221
+ // OBS-1013 add.3: the words are BOUNDED — the first sentence, or a short hash when even that is long.
222
+ // A whole-note symbol made a 600-byte fingerprint no reviewer could echo, so a real closure read as
223
+ // malformed and bought a worker. A sentence a reviewer can copy is the identity; the hash is the fallback.
224
+ const SYMBOL_SENTENCE_CAP = 160;
225
+ export function boundedSymbol(note) {
226
+ const words = normalizeGateFailure(note);
227
+ if (!words)
228
+ return "";
229
+ const sentence = words.split(/(?<=[.!?])\s+|\n/)[0].trim();
230
+ return sentence.length <= SYMBOL_SENTENCE_CAP ? sentence : createHash("sha256").update(words).digest("hex").slice(0, 12);
231
+ }
220
232
  function toFinding(cls, note, path, symbol, rationale) {
221
233
  const p = path || UNIDENTIFIED;
222
- const s = symbol || normalizeGateFailure(note) || UNIDENTIFIED;
234
+ const s = symbol || boundedSymbol(note) || UNIDENTIFIED;
223
235
  return {
224
236
  class: cls, path: p, symbol: s, note,
225
237
  ...(rationale !== undefined ? { rationale } : {}),
@@ -773,7 +785,7 @@ const DispatchAssignmentSchema = z.object({
773
785
  // routing profile's QUALITY_FAIL_PARKS (route/profile.ts): the channel did nothing wrong.
774
786
  export const PARK_KINDS = ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota",
775
787
  "reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved", "infra", "dispatch",
776
- "authoring", "scope-request"];
788
+ "authoring", "scope-request", "diff-cap"];
777
789
  // v1.85 T3: "repair" is a third dispatch mode beside the v1.29 session pair — a fix-only attempt that
778
790
  // carries the failing findings and the diff CONTENT of the work already landed, instead of re-buying
779
791
  // ~20m of onboarding to rediscover them (62 of 68 measured re-dispatches were fresh).
@@ -1483,24 +1495,32 @@ export class Journal {
1483
1495
  // A reroute bans the in-force channel; retry/decompose/human verdicts ban nothing (D-03).
1484
1496
  pendingReroute.add(e.taskId);
1485
1497
  }
1486
- else if (e.event === "task-approved"
1487
- && (e.data.release === ATTEMPT_CAP_RELEASE || e.data.release === RECHECK_RELEASE)) {
1498
+ else if (e.event === "task-approved" && e.data.release === ATTEMPT_CAP_RELEASE) {
1488
1499
  // v1.24 OBS-18: operator released an attempt-cap park. Pre-v1.24 task-approved events have no
1489
1500
  // `release` key ⇒ this branch never fires (corpus criterion: identical statuses + resume state).
1490
- // attempts reset to 0 so the daemon's attempt-cap check does not re-park in the same tick;
1491
- // tried survives (consult bans and burned channels are not forgotten); lastAssignment is
1492
- // cleared so the daemon's nextChannel-over-tried path skips burned channels on first dispatch
1493
- // (restoring the last burned assignment would re-try it first — the failure the tried-list exists to prevent).
1501
+ // attempts and tried reset so the daemon's attempt-cap check does not re-park in the same tick
1502
+ // and the newly funded engagement starts with a cleared seat list; lastAssignment clears too.
1503
+ // OBS-1028: a RECHECK release is deliberately NOT here — it marks no gate satisfied and funds no
1504
+ // attempt, so the ladder (attempts AND tried) survives it untouched. Only THIS fresh-budget
1505
+ // release resets the ladder, and it resets both halves: a fresh budget on a cleared seat list.
1494
1506
  const st = m.get(e.taskId);
1495
1507
  if (st) {
1496
1508
  st.attempts = 0;
1509
+ st.tried = [];
1497
1510
  st.lastAssignment = undefined;
1498
1511
  }
1499
1512
  }
1513
+ else if (e.event === "task-approved" && e.data.release === RECHECK_RELEASE) {
1514
+ // OBS-1028: the ladder survives a recheck (attempts AND tried); only the seat memory is cleared
1515
+ // so a red battery's next dispatch is picked over the exclusions instead of restoring the parked seat.
1516
+ const st = m.get(e.taskId);
1517
+ if (st)
1518
+ st.lastAssignment = undefined;
1519
+ }
1500
1520
  else if (e.event === "task-approved" && e.data.release === REVIEW_UPHELD_RELEASE) {
1501
- // OBS-189: the operator upheld the reviewer. Same budget semantics as the attempt-cap release
1502
- // (fresh attempts, tried survives, no burned-channel restore) PLUS the findings carry: the
1503
- // newest failed review's details ride into the funded attempt as its retry brief.
1521
+ // OBS-189: the operator upheld the reviewer. It funds fresh attempts without forgetting tried
1522
+ // seats (unlike an attempt-cap fresh-budget release), and carries the newest failed review's
1523
+ // details into the funded attempt as its retry brief.
1504
1524
  const st = m.get(e.taskId);
1505
1525
  if (st) {
1506
1526
  st.attempts = 0;
@@ -1608,7 +1628,9 @@ export class Journal {
1608
1628
  replayExcludedChannels() {
1609
1629
  const excluded = new Set();
1610
1630
  for (const e of this.read()) {
1611
- if (e.event === "channel-exclusion" && typeof e.data.channel === "string") {
1631
+ // OBS-1010: an in-run no-trailer demotion is a WORKER exclusion too; review-pool-demotion is
1632
+ // the reviewer seat's own fold (daemon demotedReviewers) and never benches the worker channel.
1633
+ if ((e.event === "channel-exclusion" || e.event === "channel-demotion") && typeof e.data.channel === "string") {
1612
1634
  excluded.add(e.data.channel);
1613
1635
  }
1614
1636
  else if (e.event === "dead-channel-failover" && typeof e.data.from === "string") {
@@ -12,3 +12,47 @@ export declare class CommandLeases {
12
12
  private readonly owners;
13
13
  run<T>(run: () => Promise<T>, waiting: (count: number) => void, pollMs: number, signal?: AbortSignal): Promise<T>;
14
14
  }
15
+ /**
16
+ * OBS-1042: the standalone battery's cross-process lease. Two `tickmarkr verify` runs in two linked
17
+ * worktrees of one clone share NO daemon and NO state directory, so the in-process `CommandLeases`
18
+ * above never saw each other and both full suites ran on one host. This reservation is keyed on the
19
+ * repository's COMMON git dir (`git rev-parse --git-common-dir`), which every linked worktree of a
20
+ * clone shares and an unrelated repository never does. One file, created exclusively; the holder
21
+ * writes its pid and cwd so a waiter can journal who it waits on and reclaim a reservation whose
22
+ * holder is dead. Each reservation releases only itself: a waiter that never acquired removes nothing,
23
+ * so cancelling it never frees the holder. The daemon's adoption is deferred to 2.5.7.
24
+ */
25
+ export interface RepositoryLeaseHolder {
26
+ pid: number;
27
+ cwd: string;
28
+ at: number;
29
+ token: string;
30
+ }
31
+ export interface RepositoryLeaseOptions {
32
+ pollMs?: number;
33
+ signal?: AbortSignal;
34
+ /** Called once per distinct holder the waiter is queued behind. */
35
+ onWait?: (holder: RepositoryLeaseHolder) => void;
36
+ }
37
+ export declare function repositoryLeasePath(cwd: string): Promise<string>;
38
+ /** OBS-1057: dev+ino alone is not an identity across unlink/recreate — Linux hands the freed inode
39
+ * number straight to the next file, so a stale observation of a dead or corrupt reservation matched
40
+ * the fresh reservation that replaced it and reclaimDead removed the live winner (public CI, ubuntu).
41
+ * The bytes are the generation: a fragment never hashes like a complete record, and two complete
42
+ * records differ by token. Size and mtime are not enough (a same-length reservation written within
43
+ * the filesystem's timestamp resolution — Leg-2 review of the first cut). */
44
+ export declare const inodeIdentity: (st: {
45
+ dev: number | bigint;
46
+ ino: number | bigint;
47
+ }, bytes: string | Buffer) => string;
48
+ /** The identity a reclaimer would observe at a path right now, or undefined when nothing is there. */
49
+ export declare const observeIdentity: (path: string) => string | undefined;
50
+ export declare const readHolder: (path: string) => RepositoryLeaseHolder | undefined;
51
+ /** Publish `mine` at `path` atomically: the record is written whole to a private file and linked
52
+ * into place — `link` is exclusive (EEXIST when the path is taken) and a reader never sees a partial
53
+ * record, so a process killed at any point of its acquisition leaves either nothing or a complete
54
+ * record naming a pid a waiter can check. Returns false when the path is taken. */
55
+ export declare function tryReserve(path: string, mine: RepositoryLeaseHolder): boolean;
56
+ /** Exposed for recovery regressions; the standalone verify uses the combined critical section. */
57
+ export declare const reclaimDead: (path: string, identity: string, pollMs?: number) => Promise<boolean>;
58
+ export declare function withRepositoryLease<T>(cwd: string, run: () => Promise<T>, opts?: RepositoryLeaseOptions): Promise<T>;