tickmarkr 2.5.7 → 2.5.8

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.
@@ -132,6 +132,42 @@ export declare function repairReachSinceApproval(events: JournalEvent[], taskId:
132
132
  export declare function repairsSinceApproval(events: JournalEvent[], taskId: string): number;
133
133
  /** Recheck releases not yet enacted by a battery (or by a legacy worker launch). */
134
134
  export declare function pendingRechecks(events: JournalEvent[]): Set<string>;
135
+ /**
136
+ * OBS-1075: what an accepted approval still authorises, typed by its release. `worker` funds one
137
+ * dispatch (plain, attempt-cap, review-upheld, scope-request); `battery` funds the recheck battery
138
+ * and no worker; `waiver` satisfies exactly its named gate; `inert` authorises nothing.
139
+ */
140
+ export type PendingApprovalAction = {
141
+ taskId: string;
142
+ ts: string;
143
+ authority: "worker";
144
+ release: "plain" | typeof ATTEMPT_CAP_RELEASE | typeof REVIEW_UPHELD_RELEASE | "scope-request";
145
+ } | {
146
+ taskId: string;
147
+ ts: string;
148
+ authority: "battery";
149
+ release: typeof RECHECK_RELEASE;
150
+ } | {
151
+ taskId: string;
152
+ ts: string;
153
+ authority: "waiver";
154
+ release: typeof GATE_SATISFIED_RELEASE;
155
+ gate: GateName;
156
+ } | {
157
+ taskId: string;
158
+ ts: string;
159
+ authority: "inert";
160
+ release: unknown;
161
+ };
162
+ /**
163
+ * Per task, the newest task-approved row no later enactment has consumed. Pure: events in, typed
164
+ * actions out. Acceptance is not enactment and a run boundary is not enactment — only the row the
165
+ * release causes consumes it (ENACTED_BY), so an approval accepted just before an abnormal exit
166
+ * survives every restart until it is enacted. A later approval supersedes an earlier one. An unknown
167
+ * release, or a waiver without a known gate, is inert: it supersedes, authorises nothing, and no row
168
+ * enacts it.
169
+ */
170
+ export declare function pendingApprovalActions(events: JournalEvent[]): Map<string, PendingApprovalAction>;
135
171
  /**
136
172
  * Why the last attempt failed, one row per journaled cause, in the daemon's own `source: details`
137
173
  * shape. The daemon builds that brief in a loop-local variable, which dies with the process: a resumed
@@ -240,6 +276,7 @@ export declare const TelemetryRowSchema: z.ZodObject<{
240
276
  "gate-fail": "gate-fail";
241
277
  quota: "quota";
242
278
  infra: "infra";
279
+ "scope-request": "scope-request";
243
280
  dispatch: "dispatch";
244
281
  "human-gate": "human-gate";
245
282
  "reroute-exhausted": "reroute-exhausted";
@@ -247,7 +284,6 @@ export declare const TelemetryRowSchema: z.ZodObject<{
247
284
  "merge-conflict": "merge-conflict";
248
285
  "tip-moved": "tip-moved";
249
286
  authoring: "authoring";
250
- "scope-request": "scope-request";
251
287
  "diff-cap": "diff-cap";
252
288
  }>>;
253
289
  tokens: z.ZodCatch<z.ZodOptional<z.ZodObject<{
@@ -620,6 +620,49 @@ export function pendingRechecks(events) {
620
620
  }
621
621
  return pending;
622
622
  }
623
+ const ENACTED_BY = {
624
+ worker: ["task-dispatch", "worker-launch"],
625
+ battery: ["recheck-battery"],
626
+ waiver: ["worktree-recreation"],
627
+ };
628
+ /**
629
+ * Per task, the newest task-approved row no later enactment has consumed. Pure: events in, typed
630
+ * actions out. Acceptance is not enactment and a run boundary is not enactment — only the row the
631
+ * release causes consumes it (ENACTED_BY), so an approval accepted just before an abnormal exit
632
+ * survives every restart until it is enacted. A later approval supersedes an earlier one. An unknown
633
+ * release, or a waiver without a known gate, is inert: it supersedes, authorises nothing, and no row
634
+ * enacts it.
635
+ */
636
+ export function pendingApprovalActions(events) {
637
+ const pending = new Map();
638
+ for (const e of events) {
639
+ if (!e.taskId)
640
+ continue;
641
+ if (e.event === "task-approved") {
642
+ pending.set(e.taskId, approvalAction(e.taskId, e));
643
+ continue;
644
+ }
645
+ const action = pending.get(e.taskId);
646
+ if (action && action.authority !== "inert" && ENACTED_BY[action.authority].includes(e.event))
647
+ pending.delete(e.taskId);
648
+ }
649
+ return pending;
650
+ }
651
+ function approvalAction(taskId, e) {
652
+ const { release, gate } = e.data;
653
+ const base = { taskId, ts: e.ts };
654
+ if (release === undefined)
655
+ return { ...base, authority: "worker", release: "plain" };
656
+ if (release === ATTEMPT_CAP_RELEASE || release === REVIEW_UPHELD_RELEASE || release === "scope-request") {
657
+ return { ...base, authority: "worker", release };
658
+ }
659
+ if (release === RECHECK_RELEASE)
660
+ return { ...base, authority: "battery", release };
661
+ if (release === GATE_SATISFIED_RELEASE && typeof gate === "string" && GATE_NAMES.includes(gate)) {
662
+ return { ...base, authority: "waiver", release, gate: gate };
663
+ }
664
+ return { ...base, authority: "inert", release };
665
+ }
623
666
  // Both retry decisions below govern exactly ONE dispatch: the next one. So both are read back from the
624
667
  // journal at the moment that dispatch is built, never carried in a process variable — a stop between
625
668
  // the decision and the dispatch (OBS-254's shape, one layer up) would otherwise send a normal prompt
@@ -12,6 +12,8 @@ export interface TipVerifyResult {
12
12
  spawnedCommand?: string;
13
13
  /** Q121s: nonzero exit whose failures are ALL baseline-recorded — forgiven exactly as the battery forgives. */
14
14
  forgiven?: boolean;
15
+ /** D-131: carried from a persisted per-gate verdict — this cycle did NOT execute the command. */
16
+ reused?: true;
15
17
  /**
16
18
  * OBS-534: what a nonzero exit is evidence OF, taken from the battery's own readers — `ceilingKillResult`
17
19
  * for a kill, the shared runner classifier for everything else. `infra` means nothing was verified.
package/dist/run/merge.js CHANGED
@@ -184,6 +184,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
184
184
  gate,
185
185
  cmd,
186
186
  pass: hit.pass,
187
+ reused: true,
187
188
  exitCode: hit.exitCode ?? 0,
188
189
  fingerprints: hit.meta?.fingerprints ?? [],
189
190
  ...(hit.meta?.forgiven ? { forgiven: true } : {}),
@@ -0,0 +1,41 @@
1
+ import type { GateResult } from "../gates/types.js";
2
+ /** Pure decision table of the scope-red disposition (OBS-1077, OBS-1074 residual): no filesystem,
3
+ * process, clock or journal. The caller supplies the inventory and performs every side effect. */
4
+ export type RepairDispositionKind = "fund-repair" | "scope-request" | "authoring" | "none";
5
+ export interface RepairDisposition {
6
+ kind: RepairDispositionKind;
7
+ /** validated inventory paths only — the sole paths a caller may put in an executable command */
8
+ paths: string[];
9
+ reason: string;
10
+ /** which evidence decided: a gate name, "worker" for a refusal, absent for the ordinary disposition */
11
+ source?: string;
12
+ /** an independent classification that kept priority over path prose */
13
+ blocker?: "infra" | "scope-collateral";
14
+ /** candidates that bound to nothing, or to more than one path; evidence, never approval advice */
15
+ diagnostics: ScopeHintDiagnostic[];
16
+ }
17
+ export interface ScopeHintDiagnostic {
18
+ candidate: string;
19
+ kind: "unresolved" | "ambiguous";
20
+ matches: string[];
21
+ reason: string;
22
+ }
23
+ export interface ScopeHintResolution {
24
+ resolved: string[];
25
+ diagnostics: ScopeHintDiagnostic[];
26
+ }
27
+ export interface RepairDispositionInput {
28
+ /** the failing gate results (passing rows are ignored) */
29
+ results: readonly GateResult[];
30
+ files: readonly string[];
31
+ /** the worker's refusal summary — present only when the worker reported ok:false */
32
+ refusalSummary?: string;
33
+ /** task tree plus diff, deleted tracked paths included */
34
+ inventory: Iterable<string>;
35
+ }
36
+ /** Lexing never starts in the middle of a word (same lexer as the daemon's scope-red disposition). */
37
+ export declare const namedPaths: (text: string) => string[];
38
+ /** Binds candidates to the SUPPLIED inventory and nothing else: an exact path, else a unique
39
+ * path-suffix. Anything unbound or bound twice comes back as a non-executable diagnostic. */
40
+ export declare function resolveScopeHints(candidates: Iterable<string>, inventory: Iterable<string>): ScopeHintResolution;
41
+ export declare function classifyRepairDisposition(input: RepairDispositionInput): RepairDisposition;
@@ -0,0 +1,77 @@
1
+ import { filesGlob } from "../graph/files-glob.js";
2
+ const failed = (g) => !(g.pass || g.meta?.skipped === true) || g.meta?.infra === true;
3
+ const REFUSAL_RE = /outside|out.of.scope|allowlist|scope expansion|not (?:in|own)|unowned/i;
4
+ /** Lexing never starts in the middle of a word (same lexer as the daemon's scope-red disposition). */
5
+ export const namedPaths = (text) => [...text.matchAll(/(?:^|[\s`'"])((?:[A-Za-z0-9_@.()[\]-]+\/)+[A-Za-z0-9_@.[\]-]+|[A-Za-z0-9_@-]+(?:\.[A-Za-z0-9_-]+)+)(?=$|[\s`'"),:;.!?])/g)]
6
+ .map((match) => match[1].replace(/^\.\//, "").replace(/\.$/, ""))
7
+ .filter((path) => !path.split("/").includes(".."));
8
+ /** Binds candidates to the SUPPLIED inventory and nothing else: an exact path, else a unique
9
+ * path-suffix. Anything unbound or bound twice comes back as a non-executable diagnostic. */
10
+ export function resolveScopeHints(candidates, inventory) {
11
+ const known = new Set(inventory);
12
+ const resolved = new Set();
13
+ const diagnostics = [];
14
+ for (const candidate of new Set(candidates)) {
15
+ if (known.has(candidate)) {
16
+ resolved.add(candidate);
17
+ continue;
18
+ }
19
+ const matches = [...known].filter((path) => path.endsWith(`/${candidate}`)).sort();
20
+ if (matches.length === 1)
21
+ resolved.add(matches[0]);
22
+ else
23
+ diagnostics.push(matches.length === 0
24
+ ? { candidate, kind: "unresolved", matches, reason: `scope hint resolves to nothing in the task tree: ${candidate}` }
25
+ : { candidate, kind: "ambiguous", matches, reason: `scope hint ${candidate} matches ${matches.length} paths: ${matches.join(", ")}` });
26
+ }
27
+ return { resolved: [...resolved].sort(), diagnostics };
28
+ }
29
+ export function classifyRepairDisposition(input) {
30
+ const reds = input.results.filter(failed);
31
+ const none = (reason, extra = {}) => ({ kind: "none", paths: [], reason, diagnostics: [], ...extra });
32
+ // Independent classifications first: prose naming a path never erases them.
33
+ const infra = reds.find((g) => g.meta?.infra === true);
34
+ if (infra)
35
+ return none(`${infra.gate}: infra red keeps its own classification`, { source: infra.gate, blocker: "infra" });
36
+ const verdict = reds.find((g) => g.gate === "scope")?.meta?.collateral;
37
+ if (verdict) {
38
+ return verdict.authoring
39
+ ? { kind: "authoring", paths: [...verdict.predicted], reason: verdict.repair, source: "scope", blocker: "scope-collateral", diagnostics: [] }
40
+ : none("scope: collateral verdict has unpredicted offenders — ordinary chargeable disposition", { source: "scope", blocker: "scope-collateral" });
41
+ }
42
+ if (input.files.length === 0)
43
+ return none("task declares no files[]");
44
+ const allowed = filesGlob([...input.files]);
45
+ const inventory = [...input.inventory];
46
+ const unowned = (text) => {
47
+ const r = resolveScopeHints(namedPaths(text).filter((path) => !allowed(path)), inventory);
48
+ return { paths: r.resolved.filter((path) => !allowed(path)), diagnostics: r.diagnostics };
49
+ };
50
+ const owned = (text) => resolveScopeHints(namedPaths(text), inventory).resolved.some(allowed);
51
+ const refusal = input.refusalSummary !== undefined && REFUSAL_RE.test(input.refusalSummary)
52
+ ? unowned(input.refusalSummary) : { paths: [], diagnostics: [] };
53
+ // A test gate names where the ASSERTION lives — a detection site, not the code that needs fixing.
54
+ const sites = reds.filter((g) => g.gate === "test").map((g) => unowned(g.details));
55
+ const others = reds.filter((g) => g.gate !== "test");
56
+ const hints = others.map((g) => unowned(g.details));
57
+ const diagnostics = [refusal, ...sites, ...hints].flatMap((r) => r.diagnostics)
58
+ .filter((d, i, all) => all.findIndex((o) => o.candidate === d.candidate) === i);
59
+ const uniq = (rs) => [...new Set(rs.flatMap((r) => r.paths))].sort();
60
+ if (refusal.paths.length) {
61
+ return { kind: "scope-request", paths: refusal.paths, source: "worker", diagnostics,
62
+ reason: `worker refusal names unowned paths: ${refusal.paths.join(", ")}` };
63
+ }
64
+ const repair = uniq(hints);
65
+ if (repair.length && !others.some((g) => owned(g.details))) {
66
+ const review = others.some((g) => g.gate === "review");
67
+ return { kind: review ? "scope-request" : "authoring", paths: repair, source: others[0].gate, diagnostics,
68
+ reason: `files[] repair hint: ${repair.join(", ")}` };
69
+ }
70
+ const detection = uniq(sites);
71
+ if (detection.length) {
72
+ return { kind: "fund-repair", paths: detection, source: "test", diagnostics,
73
+ reason: `test gate names a detection site, not an out-of-scope repair: ${detection.join(", ")}` };
74
+ }
75
+ // Unknown attribution never blocks funding: the ordinary chargeable disposition applies.
76
+ return none("no attributable out-of-scope repair — ordinary chargeable disposition", { diagnostics });
77
+ }
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, useInput } from "ink";
3
- import { useLayoutEffect, useState } from "react";
3
+ import { useLayoutEffect, useRef, useState } from "react";
4
4
  import { GLYPHS } from "../../brand.js";
5
5
  import { BodyText, CockpitGrid, Panel, ProgressMeter, Sparkline, StateGlyph, } from "./components.js";
6
6
  import { ABSENT_FIELD, fieldReading } from "./derive.js";
@@ -146,75 +146,90 @@ export function HomeView({ model, width, focused = true, onOpenPark, onOpenDiagn
146
146
  const maxOffset = Math.max(0, model.activity.length - 1);
147
147
  const maxSelection = Math.max(0, model.activity.length - 1);
148
148
  const safeOffset = Math.min(offset, maxOffset);
149
+ // Keep input ahead of the next render/effect subscription, as in EvidenceView.
150
+ // Render reconciles the refs with model updates; each move writes before setting state.
151
+ const sectionRef = useRef(effectiveSection);
152
+ const needsYouIndexRef = useRef(effectiveNeedsYouIndex);
153
+ const activityIndexRef = useRef(effectiveActivityIndex);
154
+ sectionRef.current = effectiveSection;
155
+ needsYouIndexRef.current = effectiveNeedsYouIndex;
156
+ activityIndexRef.current = effectiveActivityIndex;
157
+ const moveSection = (next) => {
158
+ sectionRef.current = next;
159
+ setSection(next);
160
+ };
161
+ const moveNeedsYou = (index) => {
162
+ needsYouIndexRef.current = index;
163
+ setNeedsYouIndex(index);
164
+ setSelectedNeedsYouId(model.needsYou[index]?.id);
165
+ };
166
+ const moveActivity = (index) => {
167
+ activityIndexRef.current = index;
168
+ setActivityIndex(index);
169
+ setSelectedEvidenceId(model.activity[index]?.evidence.id);
170
+ };
149
171
  useInput((_input, key) => {
150
172
  if (key.leftArrow && model.needsYou.length > 0) {
151
- if (effectiveSection === "needsYou") {
152
- const nextIndex = (effectiveNeedsYouIndex + 1) % model.needsYou.length;
153
- setNeedsYouIndex(nextIndex);
154
- setSelectedNeedsYouId(model.needsYou[nextIndex]?.id);
173
+ if (sectionRef.current === "needsYou") {
174
+ const nextIndex = (needsYouIndexRef.current + 1) % model.needsYou.length;
175
+ moveNeedsYou(nextIndex);
155
176
  }
156
177
  else {
157
- setSection("needsYou");
178
+ moveSection("needsYou");
158
179
  }
159
180
  }
160
181
  if (key.rightArrow)
161
- setSection("activity");
182
+ moveSection("activity");
162
183
  if (key.upArrow) {
163
- if (effectiveSection === "needsYou") {
184
+ if (sectionRef.current === "needsYou") {
164
185
  if (model.needsYou.length > 0) {
165
- const nextIndex = (effectiveNeedsYouIndex - 1 + model.needsYou.length) % model.needsYou.length;
166
- setNeedsYouIndex(nextIndex);
167
- setSelectedNeedsYouId(model.needsYou[nextIndex]?.id);
186
+ const nextIndex = (needsYouIndexRef.current - 1 + model.needsYou.length) % model.needsYou.length;
187
+ moveNeedsYou(nextIndex);
168
188
  }
169
189
  }
170
190
  else {
171
191
  if (model.activity.length > 0) {
172
- const nextIndex = Math.max(0, effectiveActivityIndex - 1);
173
- setActivityIndex(nextIndex);
174
- setSelectedEvidenceId(model.activity[nextIndex]?.evidence.id);
192
+ const nextIndex = Math.max(0, activityIndexRef.current - 1);
193
+ moveActivity(nextIndex);
175
194
  }
176
195
  }
177
196
  }
178
197
  if (key.downArrow) {
179
- if (effectiveSection === "needsYou") {
198
+ if (sectionRef.current === "needsYou") {
180
199
  if (model.needsYou.length > 0) {
181
- const nextIndex = (effectiveNeedsYouIndex + 1) % model.needsYou.length;
182
- setNeedsYouIndex(nextIndex);
183
- setSelectedNeedsYouId(model.needsYou[nextIndex]?.id);
200
+ const nextIndex = (needsYouIndexRef.current + 1) % model.needsYou.length;
201
+ moveNeedsYou(nextIndex);
184
202
  }
185
203
  }
186
204
  else {
187
205
  if (model.activity.length > 0) {
188
- const nextIndex = Math.min(maxSelection, effectiveActivityIndex + 1);
189
- setActivityIndex(nextIndex);
190
- setSelectedEvidenceId(model.activity[nextIndex]?.evidence.id);
206
+ const nextIndex = Math.min(maxSelection, activityIndexRef.current + 1);
207
+ moveActivity(nextIndex);
191
208
  }
192
209
  }
193
210
  }
194
211
  if (key.pageDown) {
195
212
  setOffset((o) => Math.max(0, o - HOME_ACTIVITY_VISIBLE_ROWS));
196
213
  if (model.activity.length > 0) {
197
- const nextIndex = Math.max(0, effectiveActivityIndex - HOME_ACTIVITY_VISIBLE_ROWS);
198
- setActivityIndex(nextIndex);
199
- setSelectedEvidenceId(model.activity[nextIndex]?.evidence.id);
214
+ const nextIndex = Math.max(0, activityIndexRef.current - HOME_ACTIVITY_VISIBLE_ROWS);
215
+ moveActivity(nextIndex);
200
216
  }
201
217
  }
202
218
  if (key.pageUp) {
203
219
  setOffset((o) => Math.min(maxOffset, o + HOME_ACTIVITY_VISIBLE_ROWS));
204
220
  if (model.activity.length > 0) {
205
- const nextIndex = Math.min(maxSelection, effectiveActivityIndex + HOME_ACTIVITY_VISIBLE_ROWS);
206
- setActivityIndex(nextIndex);
207
- setSelectedEvidenceId(model.activity[nextIndex]?.evidence.id);
221
+ const nextIndex = Math.min(maxSelection, activityIndexRef.current + HOME_ACTIVITY_VISIBLE_ROWS);
222
+ moveActivity(nextIndex);
208
223
  }
209
224
  }
210
225
  if (key.return) {
211
- if (effectiveSection === "needsYou") {
212
- const target = selectedNeedsYou;
226
+ if (sectionRef.current === "needsYou") {
227
+ const target = selectNeedsYouTarget(model, needsYouIndexRef.current);
213
228
  if (target)
214
229
  (target.kind === "park" ? onOpenPark : onOpenDiagnostic)(target.id);
215
230
  }
216
231
  else {
217
- const row = model.activity[effectiveActivityIndex];
232
+ const row = model.activity[activityIndexRef.current];
218
233
  if (row)
219
234
  onOpenEvidence(row.evidence);
220
235
  }
@@ -121,6 +121,7 @@ export declare function createLiveStore(options: LiveStoreOptions): {
121
121
  oracle: "test";
122
122
  test: string;
123
123
  text?: string | undefined;
124
+ landing?: string | undefined;
124
125
  } | {
125
126
  oracle: "judge";
126
127
  text: string;
@@ -138,6 +139,14 @@ export declare function createLiveStore(options: LiveStoreOptions): {
138
139
  confidence?: number | undefined;
139
140
  reason?: string | undefined;
140
141
  }[] | undefined;
142
+ pins?: ({
143
+ kind: "literal";
144
+ text: string;
145
+ glob: string;
146
+ } | {
147
+ kind: "fixture";
148
+ paths: string[];
149
+ })[] | undefined;
141
150
  routingHints?: {
142
151
  pin?: {
143
152
  via: string;
@@ -213,6 +222,7 @@ export declare function createLiveStore(options: LiveStoreOptions): {
213
222
  oracle: "test";
214
223
  test: string;
215
224
  text?: string | undefined;
225
+ landing?: string | undefined;
216
226
  } | {
217
227
  oracle: "judge";
218
228
  text: string;
@@ -230,6 +240,14 @@ export declare function createLiveStore(options: LiveStoreOptions): {
230
240
  confidence?: number | undefined;
231
241
  reason?: string | undefined;
232
242
  }[] | undefined;
243
+ pins?: ({
244
+ kind: "literal";
245
+ text: string;
246
+ glob: string;
247
+ } | {
248
+ kind: "fixture";
249
+ paths: string[];
250
+ })[] | undefined;
233
251
  routingHints?: {
234
252
  pin?: {
235
253
  via: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.5.7",
3
+ "version": "2.5.8",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -149,6 +149,10 @@
149
149
  "text": {
150
150
  "type": "string",
151
151
  "minLength": 1
152
+ },
153
+ "landing": {
154
+ "type": "string",
155
+ "minLength": 1
152
156
  }
153
157
  },
154
158
  "required": [
@@ -201,6 +205,56 @@
201
205
  ]
202
206
  }
203
207
  },
208
+ "pins": {
209
+ "type": "array",
210
+ "items": {
211
+ "oneOf": [
212
+ {
213
+ "type": "object",
214
+ "properties": {
215
+ "kind": {
216
+ "type": "string",
217
+ "const": "literal"
218
+ },
219
+ "text": {
220
+ "type": "string",
221
+ "minLength": 1
222
+ },
223
+ "glob": {
224
+ "type": "string",
225
+ "minLength": 1
226
+ }
227
+ },
228
+ "required": [
229
+ "kind",
230
+ "text",
231
+ "glob"
232
+ ]
233
+ },
234
+ {
235
+ "type": "object",
236
+ "properties": {
237
+ "kind": {
238
+ "type": "string",
239
+ "const": "fixture"
240
+ },
241
+ "paths": {
242
+ "minItems": 1,
243
+ "type": "array",
244
+ "items": {
245
+ "type": "string",
246
+ "minLength": 1
247
+ }
248
+ }
249
+ },
250
+ "required": [
251
+ "kind",
252
+ "paths"
253
+ ]
254
+ }
255
+ ]
256
+ }
257
+ },
204
258
  "gates": {
205
259
  "default": [
206
260
  "build",
@@ -288,6 +288,58 @@ journal tail to decide what happens next, or sweeping orphans — you have taken
288
288
  3. **Gate a mid-run fix at the base, not at a summary (law 47 / OBS-909).** A fix landed on main while a
289
289
  run is live is proved with `tickmarkr verify --base <main>`, never with a suite summary copied from a
290
290
  different tree. A release proof runs every CI-ordered step — including lint — before its suite.
291
+ 4. **Rescue a broken harness from OUTSIDE the run it broke — the daemon is NEVER asked to repair itself
292
+ as a task (v2.5.7 ledger D-59, D-64; OBS-1078).** When the harness is the defect — resume cannot be
293
+ relied on, a gate cannot be relied on, the binary every gate runs on is the thing that is wrong — no
294
+ task inside the run can fix it: the gates that would judge the repair are executed BY the suspect
295
+ binary, and the worktree the repair would run in is created by the same code. Dispatching
296
+ *"fix the daemon"* as a task of the run it broke buys a green gate from the defect and a repair nobody
297
+ can trust. The rescue route has three legs, each with its own record, and there is no fourth:
298
+ - **AN INDEPENDENTLY GATED EXTERNAL FIX LEG.** Its own checkout and branch off the run's base, its own
299
+ brief, its own `files[]`, its own battery — `tickmarkr verify --base <ref> --criteria <file>
300
+ --author <the seat that wrote it>` — plus the cross-vendor review its criterion names, all recorded
301
+ BEFORE the run consumes any of it. The leg's battery is scheduled at a boundary item 2 permits (the
302
+ run parked or ended, or the operator's recorded exception), and its reds are declared to the
303
+ contamination watcher in advance so they are classified instead of assumed.
304
+ - **AN EXPLICITLY RECORDED REBUILT BINARY AND RESTART — the provenance is written down in the same act,
305
+ or the rebuild did not happen.** Record: the fix leg's commit sha; the build command; the install
306
+ form (never `npm i -g .` on the repository directory — npm SYMLINKS a directory install and every
307
+ later build hot-swaps the machine-wide binary with no version change to notice it by); the
308
+ global-versus-repo **inode** comparison that proves a real install, which `tickmarkr version` cannot
309
+ do because it cannot go red when nothing is bumped; the version read-back; and the restart itself —
310
+ `tickmarkr resume <runId>`, the ORCHESTRATOR's command, never yours. An unrecorded rebuild leaves the
311
+ next seat arguing about which binary produced which journal rows, with nothing to read.
312
+ - **A SEPARATELY GATED UNION.** The rebuilt harness and the run's existing work are then gated
313
+ TOGETHER, on their own, at the one commit that carries both. A green fix leg beside a green run is
314
+ no proof of their union — see *a green leg beside a green parent*, under the release criterion
315
+ below — because the union's own conflict resolutions, ordering and collected-count changes are
316
+ exactly what neither green ever saw.
317
+ **NO BRANCH SURGERY: the run keeps its ORIGINAL base and its recorded baseline.** No rebase of the
318
+ integration branch, no re-cut `baseRef`, no re-baselining to make the rescue leg's diff read small.
319
+ Resume REPLAYS the journal's `baseRef`, so a rewritten one is a different run wearing the same id —
320
+ and a baseline re-recorded to suit the rescue launders every red the original baseline was there to
321
+ compare against.
322
+ 5. **A rebuilt binary is NOT a rebuilt tree: a claimed fix arrival needs ANCESTRY EVIDENCE naming the
323
+ actual task subject (v2.5.7 ledger D-80; OBS-1078).** Rebuilding and reinstalling `dist` changes the
324
+ daemon the orchestrator launches and nothing else. It puts no test, fixture, schema or skill byte into
325
+ any task worktree or into the integration branch: those trees were checked out from the integration tip
326
+ BEFORE the fix leg landed, and git does not retro-fill a checkout that already exists. So *"the fix has
327
+ reached the task checkouts"* is never accepted on a version read-back, a rebuild log, or the fixer's
328
+ say-so — require ancestry evidence naming the ACTUAL subjects, and read it before the claim, not after:
329
+ - **the TASK subject** — that task worktree's own branch and HEAD sha (`git -C <task-worktree> rev-parse
330
+ --abbrev-ref HEAD` and `git -C <task-worktree> rev-parse HEAD`), with the fix proven an ancestor of
331
+ THAT commit: `git -C <task-worktree> merge-base --is-ancestor <fix-sha> HEAD`, whose exit code is the
332
+ evidence, recorded beside all three shas;
333
+ - **the INTEGRATION subject** — the integration-branch commit that worktree was created from, named by
334
+ sha from the journal's `task-dispatch` row or `tickmarkr status`; never "the run", and never the
335
+ branch name alone, which moves while you are reading it;
336
+ - **and, where the fix is a FILE the tree must hold** — a new test, a fixture, a schema — its presence
337
+ at that commit: `git -C <task-worktree> cat-file -e <task-head>:<path>`. A fix that lives only in the
338
+ daemon's `dist` cannot make a worker's missing oracle appear, and a worker red on that missing file is
339
+ still a plan defect, not a retry.
340
+ A fix present in the daemon and absent from the task trees has been INSTALLED, not ARRIVED: say which
341
+ of the two you mean, and name the commit each half was read against. Evidence rule 13's baseRef trap is
342
+ the same defect wearing a diff instead of a claim.
291
343
 
292
344
  ### What the ORCHESTRATOR does, and what you require of it
293
345
 
@@ -383,12 +435,67 @@ its own — **not because any instrument detected it.**
383
435
  > covers — and a re-scope of any named subject voids it AUTOMATICALLY, with no ruling required.**
384
436
  > A void condition that needs a ruling to fire is not a void condition; it is a second thing to forget.
385
437
 
438
+ **⚡ ONE QUALIFICATION, PROSPECTIVE ONLY: A FUTURE SEAL MAY DECLARE THAT AN AUDITED FILES-ONLY AMENDMENT
439
+ DOES NOT VOID IT — AND THAT DECLARATION HAS TO BE IN THE SEAL, WRITTEN AT ISSUE (v2.5.7 ledger D-80, D-83;
440
+ OBS-1078).** The automatic-void rule above is QUALIFIED for seals issued after this clause, never deleted:
441
+ it still fires on its own terms for every seal that does not carry the declaration. A requirements seal may
442
+ declare, prospectively, that exactly one narrow class of change does not void it — an **AUDITED FILES-ONLY
443
+ AMENDMENT**: a recorded amendment to a task's `files[]` and nothing else, which leaves all four of these
444
+ UNTOUCHED.
445
+
446
+ 1. **the ACCEPTANCE TEXT** — every criterion's own words, byte for byte, a `test:` leaf title included;
447
+ 2. **the TASK IDENTITY** — which task is which: its id, its shape, its deps, the suites it owns;
448
+ 3. **the RUN IDENTITY** — the run, its base and its `baseRef`, the integration branch and its tip commit;
449
+ 4. **the SAFETY REQUIREMENTS** — the gates, bounds, refusals and contamination rules the run is held to.
450
+
451
+ **Replay it both ways, because the clause is a procedure and not a mood:**
452
+ - *A seal carrying that declaration, then an audited amendment adding one owned path to a task's `files[]`,
453
+ with all four invariants read and found unchanged* → **THE SEAL IS KEPT.** No successor seal and no
454
+ re-grade of what it already sealed: the declaration was the seal's own pre-commitment about this exact
455
+ change, made before the change existed — the only moment such a commitment can honestly be made. Read the
456
+ four invariants BEFORE ruling, and record the read beside the amendment.
457
+ - *The same seal, then an amendment that changes the RUN IDENTITY — a new run id, a re-cut `baseRef`, a
458
+ different integration tip* → **THE SEAL IS VOID, on its own declaration's terms.** Run identity is one of
459
+ the four, so *"files-only"* does not describe that amendment; moving any one of the four puts the change
460
+ outside the declared class, and **any other change of any kind needs a SUCCESSOR SEAL** — a new document,
461
+ sealed before the work it grades, naming the subject as it now stands.
462
+
463
+ **Three limits, and each one has been argued around:**
464
+ - **The declaration is never retroactive and never inferred.** A seal issued BEFORE this change — one whose
465
+ own text does not carry the declaration — **stays governed by the existing void conditions above,
466
+ unmodified: any re-scope, narrowing, widening, split, merge or re-ownership of a named subject voids it
467
+ AUTOMATICALLY, with no ruling required.** So *"the acceptance text never changed, therefore the seal
468
+ still holds"* is **REFUSED** for such a seal once its SUBJECT has moved: unchanged acceptance text alone
469
+ preserves nothing over a changed subject, because a seal grades the subject it named, not the sentences
470
+ it happened to be written in. Retrofitting the declaration onto an already-sealed document is itself an
471
+ amendment to a sealed subject — write the successor seal instead.
472
+ - **A files-only clause confers no amendment authority.** It says nothing about whether the amendment is
473
+ LAWFUL: compile's unit bounds still refuse an over-bound `files[]`, and no seal can widen what compile
474
+ will certify.
475
+ - **AUDITED means recorded, or it did not happen.** The approval row carrying the amended files, the
476
+ before-and-after `files[]`, and the four-invariant read are what let the next seat replay your ruling
477
+ instead of trusting it. An unrecorded *"files-only"* amendment is indistinguishable from a re-scope, and
478
+ is voided as one.
479
+
386
480
  **⚡ IDENTIFY THE SUBJECT BY WHAT THE CLAIM IS ABOUT. Half of the failure above was a category error, and
387
481
  it is the cheap half to fix:** a graph hash identifies a **PLAN**, and a plan is recompiled, re-cut and
388
482
  re-owned as a matter of course. **A criterion about a SHIPPED TREE names the COMMIT** — or the tag, or the
389
483
  export tree hash — **never a graph hash, never a run id, never a task list.** Ask what a reader would have
390
484
  to hold in their hand to check the clause: if it is bytes, name the bytes.
391
485
 
486
+ **⚡ A GREEN LEG BESIDE A GREEN PARENT IS NO PROOF OF THEIR UNION: THE RELEASE PROOF BINDS TO THE MERGED
487
+ CANDIDATE'S COMMIT, AND IS VOID THE MOMENT THAT SUBJECT MOVES (v2.5.7 ledger D-80, D-83; OBS-1078).** The
488
+ subject of a release proof is never *"the run"* and never *"the fix leg"* — it is the **MERGED candidate**:
489
+ the one commit that carries the run's work AND every leg merged into it. Name that commit's sha in the
490
+ proof, in the same act as the grade, and the proof is bound to that sha alone. **When the subject moves — a
491
+ later merge, a new integration tip, an amended or re-cut commit, a fresh export, a re-tag — the proof is
492
+ VOID, and the new subject is graded from scratch**, count oracle included, since the oracle is derived from
493
+ the merged tree and not from either parent. Two greens assembled into one verdict are two claims about two
494
+ trees that never contained each other: the union's own conflict resolutions, its ordering and its
495
+ collected-test total are exactly what neither green observed — which is why `RELEASING.md` step 4 matches
496
+ the run's head SHA to the mirror's `HEAD` before a single job log is graded. This is the void-condition duty
497
+ pointed at bytes: **a proof that names no commit cannot notice its subject leaving.**
498
+
392
499
  **THE RECIPROCAL DUTY, and it is yours because you write both documents:** when you issue a ruling that
393
500
  re-scopes, narrows, splits or re-owns anything, **the ruling must name every sealed document its subject
394
501
  appears in** — and say, in the ruling, whether each one is now void. You are the only seat that can do