tickmarkr 1.87.0 → 1.90.0

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 (93) hide show
  1. package/dist/adapters/catalog.d.ts +18 -1
  2. package/dist/adapters/catalog.js +44 -1
  3. package/dist/adapters/fake.d.ts +2 -1
  4. package/dist/adapters/fake.js +7 -0
  5. package/dist/adapters/grok.js +11 -0
  6. package/dist/adapters/kimi.d.ts +2 -1
  7. package/dist/adapters/kimi.js +36 -0
  8. package/dist/adapters/opencode.js +17 -0
  9. package/dist/adapters/pi.js +11 -0
  10. package/dist/adapters/prompt.js +8 -1
  11. package/dist/adapters/registry.js +76 -57
  12. package/dist/adapters/types.d.ts +34 -3
  13. package/dist/adapters/types.js +99 -1
  14. package/dist/cli/commands/approve.d.ts +2 -0
  15. package/dist/cli/commands/approve.js +104 -84
  16. package/dist/cli/commands/compile.d.ts +1 -1
  17. package/dist/cli/commands/compile.js +29 -12
  18. package/dist/cli/commands/doctor.d.ts +16 -0
  19. package/dist/cli/commands/doctor.js +52 -0
  20. package/dist/cli/commands/init.js +2 -1
  21. package/dist/cli/commands/plan.d.ts +1 -1
  22. package/dist/cli/commands/plan.js +10 -1
  23. package/dist/cli/commands/report.js +49 -0
  24. package/dist/cli/commands/status.js +298 -96
  25. package/dist/cli/commands/verify.d.ts +9 -0
  26. package/dist/cli/commands/verify.js +177 -0
  27. package/dist/cli/harness.d.ts +13 -0
  28. package/dist/cli/harness.js +50 -0
  29. package/dist/cli/index.d.ts +1 -1
  30. package/dist/cli/index.js +3 -1
  31. package/dist/compile/collateral.js +11 -11
  32. package/dist/compile/common.js +2 -2
  33. package/dist/compile/index.d.ts +14 -3
  34. package/dist/compile/index.js +36 -10
  35. package/dist/compile/native.d.ts +15 -1
  36. package/dist/compile/native.js +310 -28
  37. package/dist/config/config.js +2 -2
  38. package/dist/drivers/subprocess.d.ts +6 -1
  39. package/dist/drivers/subprocess.js +9 -4
  40. package/dist/gates/acceptance.d.ts +21 -1
  41. package/dist/gates/acceptance.js +67 -22
  42. package/dist/gates/artifact-manifest.d.ts +119 -0
  43. package/dist/gates/artifact-manifest.js +357 -0
  44. package/dist/gates/baseline.d.ts +45 -5
  45. package/dist/gates/baseline.js +119 -15
  46. package/dist/gates/llm.js +37 -26
  47. package/dist/gates/review.d.ts +16 -11
  48. package/dist/gates/review.js +44 -150
  49. package/dist/gates/run-gates.js +124 -7
  50. package/dist/gates/scope.js +3 -3
  51. package/dist/graph/files-glob.d.ts +18 -0
  52. package/dist/graph/files-glob.js +22 -0
  53. package/dist/graph/schema.d.ts +3 -1
  54. package/dist/graph/schema.js +4 -1
  55. package/dist/run/daemon.d.ts +44 -0
  56. package/dist/run/daemon.js +2334 -1973
  57. package/dist/run/git.d.ts +53 -0
  58. package/dist/run/git.js +119 -5
  59. package/dist/run/interactive-seed.d.ts +6 -2
  60. package/dist/run/interactive-seed.js +72 -5
  61. package/dist/run/journal.d.ts +9 -1
  62. package/dist/run/journal.js +99 -9
  63. package/dist/run/lock.d.ts +11 -0
  64. package/dist/run/lock.js +97 -6
  65. package/dist/run/merge.d.ts +4 -1
  66. package/dist/run/merge.js +26 -7
  67. package/dist/run/outcome.d.ts +50 -0
  68. package/dist/run/outcome.js +152 -0
  69. package/dist/run/protocol.d.ts +460 -0
  70. package/dist/run/protocol.js +433 -0
  71. package/dist/run/supervision.d.ts +29 -0
  72. package/dist/run/supervision.js +189 -0
  73. package/fixtures/authoring-lints/01-awk-range-self-pass.spec.md +12 -0
  74. package/fixtures/authoring-lints/02-judge-text-key-miss.spec.md +7 -0
  75. package/fixtures/authoring-lints/03-c1-t41-rendered-observable.spec.md +8 -0
  76. package/fixtures/authoring-lints/04-c1-t24-named-file.spec.md +8 -0
  77. package/fixtures/authoring-lints/05-c2-t24-t28-dep-inversion.spec.md +7 -0
  78. package/fixtures/authoring-lints/06-c2-denumbered-coupling.spec.md +7 -0
  79. package/fixtures/authoring-lints/07-c3a-t41-line-count-proxy.spec.md +7 -0
  80. package/fixtures/authoring-lints/08-c3b-t41-governance-referent.spec.md +7 -0
  81. package/fixtures/authoring-lints/09-c4-universals-without-pointer.spec.md +7 -0
  82. package/fixtures/authoring-lints/10-c5-t34-conjunct-flood.spec.md +7 -0
  83. package/fixtures/authoring-lints/11-c6-t34-q3-q9-q20-bundle.spec.md +7 -0
  84. package/fixtures/authoring-lints/12-c7-t24-prose-seam.spec.md +8 -0
  85. package/fixtures/wrapped-acceptance.native.md +29 -0
  86. package/package.json +1 -1
  87. package/schema/rungraph.schema.json +21 -2
  88. package/skills/tickmarkr-overseer/SKILL.md +262 -5
  89. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +79 -8
  90. package/skills/tickmarkr-overseer/scripts/watch-contamination.sh +80 -0
  91. package/skills/tickmarkr-overseer/scripts/watch-context.sh +86 -0
  92. package/skills/tickmarkr-overseer/scripts/watch-parks.sh +96 -0
  93. package/skills/tickmarkr-overseer/scripts/watch-pending-input.sh +201 -0
@@ -0,0 +1,433 @@
1
+ import { z } from "zod";
2
+ import { GATE_NAMES } from "../graph/schema.js";
3
+ import { normalizeGateOutcome } from "./outcome.js";
4
+ // T32 (F2): the decision protocol is deliberately smaller than the journal. Existing journals contain
5
+ // many observational event types and must remain readable; only the five event names below assert a
6
+ // decision identity. T38 and T40 will migrate their producers to these rows. Until then, Journal's
7
+ // legacy tuple append remains byte-compatible and this module supplies the validated object-write and
8
+ // compatibility-read boundaries without dual-writing anything.
9
+ const NonEmptyStringSchema = z.string().refine((value) => value.trim().length > 0, "must be a non-empty string");
10
+ const TimestampSchema = NonEmptyStringSchema;
11
+ const TaskIdSchema = NonEmptyStringSchema;
12
+ const TaskIdsSchema = z.array(TaskIdSchema);
13
+ const AttemptSchema = z.number().int().nonnegative();
14
+ const EvidenceSchema = z.record(z.string(), z.unknown());
15
+ /** The T31 outcome vocabulary, made executable and strict at the persistence boundary. */
16
+ export const GateOutcomeSchema = z.discriminatedUnion("kind", [
17
+ z.object({ kind: z.literal("passed") }).strict(),
18
+ z.object({ kind: z.literal("failed") }).strict(),
19
+ z.object({ kind: z.literal("skipped"), reason: NonEmptyStringSchema }).strict(),
20
+ z.object({ kind: z.literal("declined"), reason: NonEmptyStringSchema }).strict(),
21
+ z.object({ kind: z.literal("held"), reason: NonEmptyStringSchema }).strict(),
22
+ z.object({ kind: z.literal("unavailable"), reason: NonEmptyStringSchema }).strict(),
23
+ z.object({
24
+ kind: z.literal("infra"),
25
+ reason: NonEmptyStringSchema,
26
+ retryable: z.boolean(),
27
+ }).strict(),
28
+ ]);
29
+ export const ROLE_INVOCATION_ROLES = ["worker", "judge", "review", "consult"];
30
+ export const ROLE_INVOCATION_OUTCOMES = ["completed", "failed", "unavailable"];
31
+ export const RUN_TERMINAL_OUTCOMES = ["completed", "failed", "incomplete"];
32
+ const MeasuredLoadSchema = z.object({
33
+ oneMinute: z.number().nonnegative(),
34
+ fiveMinute: z.number().nonnegative(),
35
+ cores: z.number().int().positive(),
36
+ }).strict();
37
+ const UnavailableLoadSchema = z.object({
38
+ unavailableReason: NonEmptyStringSchema,
39
+ }).strict();
40
+ const TokenUsageSchema = z.object({
41
+ input: z.number().int().nonnegative(),
42
+ output: z.number().int().nonnegative(),
43
+ cacheRead: z.number().int().nonnegative().optional(),
44
+ cacheWrite: z.number().int().nonnegative().optional(),
45
+ reasoning: z.number().int().nonnegative().optional(),
46
+ }).strict();
47
+ const GatePhaseStartDataSchema = z.object({
48
+ attempt: AttemptSchema,
49
+ gate: z.enum(GATE_NAMES),
50
+ evidence: EvidenceSchema.optional(),
51
+ }).strict();
52
+ const GateTerminalDataSchema = z.object({
53
+ attempt: AttemptSchema,
54
+ gate: z.enum(GATE_NAMES),
55
+ outcome: GateOutcomeSchema,
56
+ details: z.string().optional(),
57
+ load: z.union([MeasuredLoadSchema, UnavailableLoadSchema]).optional(),
58
+ evidence: EvidenceSchema.optional(),
59
+ }).strict();
60
+ // Adapter/model/vendor and metering fields are named now so T38 can publish them without reopening
61
+ // this schema. They remain optional in T32 because identity/pair integrity lands before that producer
62
+ // migration; T38 is the owner that will require them at every live invocation boundary.
63
+ const RoleInvocationIdentityDataSchema = z.object({
64
+ attempt: AttemptSchema,
65
+ role: z.enum(ROLE_INVOCATION_ROLES),
66
+ adapter: NonEmptyStringSchema.optional(),
67
+ model: NonEmptyStringSchema.optional(),
68
+ vendor: NonEmptyStringSchema.optional(),
69
+ evidence: EvidenceSchema.optional(),
70
+ });
71
+ const RoleInvocationStartDataSchema = RoleInvocationIdentityDataSchema.strict();
72
+ const RoleInvocationTerminalDataSchema = RoleInvocationIdentityDataSchema.extend({
73
+ outcome: z.enum(ROLE_INVOCATION_OUTCOMES),
74
+ reason: NonEmptyStringSchema,
75
+ tokens: TokenUsageSchema.optional(),
76
+ unmeteredReason: NonEmptyStringSchema.optional(),
77
+ }).strict();
78
+ const RunTerminalDataSchema = z.object({
79
+ outcome: z.enum(RUN_TERMINAL_OUTCOMES),
80
+ reason: NonEmptyStringSchema,
81
+ // `run-end` is already a live event name. These fields preserve the summary contract consumed by
82
+ // daemon resume and report readers when T40 moves the producer onto this validated object write.
83
+ runId: NonEmptyStringSchema,
84
+ branch: NonEmptyStringSchema,
85
+ done: TaskIdsSchema,
86
+ failed: TaskIdsSchema,
87
+ human: TaskIdsSchema,
88
+ blocked: TaskIdsSchema,
89
+ pending: TaskIdsSchema,
90
+ tipVerify: z.enum(["passed", "failed"]).optional(),
91
+ lastMergedTask: TaskIdSchema.optional(),
92
+ approvalDisposition: z.enum(["complete", "outstanding"]).optional(),
93
+ outstandingApprovals: TaskIdsSchema.optional(),
94
+ phase: NonEmptyStringSchema.optional(),
95
+ fatal: z.boolean().optional(),
96
+ error: z.string().optional(),
97
+ evidence: EvidenceSchema.optional(),
98
+ }).strict();
99
+ export const GatePhaseStartEventSchema = z.object({
100
+ ts: TimestampSchema,
101
+ // The shipped `phase-start` is a broader task-lifecycle marker. A distinct name prevents its
102
+ // worker/gates/merge payloads from being falsely claimed as malformed gate decisions.
103
+ event: z.literal("gate-phase-start"),
104
+ taskId: TaskIdSchema,
105
+ data: GatePhaseStartDataSchema,
106
+ }).strict();
107
+ export const GateTerminalEventSchema = z.object({
108
+ ts: TimestampSchema,
109
+ event: z.literal("gate-result"),
110
+ taskId: TaskIdSchema,
111
+ data: GateTerminalDataSchema,
112
+ }).strict();
113
+ export const RoleInvocationStartEventSchema = z.object({
114
+ ts: TimestampSchema,
115
+ event: z.literal("role-invocation-start"),
116
+ taskId: TaskIdSchema,
117
+ data: RoleInvocationStartDataSchema,
118
+ }).strict();
119
+ export const RoleInvocationTerminalEventSchema = z.object({
120
+ ts: TimestampSchema,
121
+ event: z.literal("role-invocation-terminal"),
122
+ taskId: TaskIdSchema,
123
+ data: RoleInvocationTerminalDataSchema,
124
+ }).strict();
125
+ export const RunTerminalEventSchema = z.object({
126
+ ts: TimestampSchema,
127
+ event: z.literal("run-end"),
128
+ data: RunTerminalDataSchema,
129
+ }).strict();
130
+ /**
131
+ * The complete decision-event vocabulary. There is no catch-all object arm: an event claiming one of
132
+ * these names either validates as exactly one member or becomes a protocol issue on read.
133
+ */
134
+ export const DecisionEventSchema = z.discriminatedUnion("event", [
135
+ GatePhaseStartEventSchema,
136
+ GateTerminalEventSchema,
137
+ RoleInvocationStartEventSchema,
138
+ RoleInvocationTerminalEventSchema,
139
+ RunTerminalEventSchema,
140
+ ]);
141
+ // New writers do not choose timestamps. Journal.append adds the timestamp and validates the resulting
142
+ // full DecisionEvent before a byte reaches appendFileSync.
143
+ export const DecisionEventWriteSchema = z.discriminatedUnion("event", [
144
+ GatePhaseStartEventSchema.omit({ ts: true }),
145
+ GateTerminalEventSchema.omit({ ts: true }),
146
+ RoleInvocationStartEventSchema.omit({ ts: true }),
147
+ RoleInvocationTerminalEventSchema.omit({ ts: true }),
148
+ RunTerminalEventSchema.omit({ ts: true }),
149
+ ]);
150
+ export const DECISION_EVENT_NAMES = [
151
+ "gate-phase-start",
152
+ "gate-result",
153
+ "role-invocation-start",
154
+ "role-invocation-terminal",
155
+ "run-end",
156
+ ];
157
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
158
+ const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
159
+ const rawDataOf = (raw) => isRecord(raw) ? raw.data : undefined;
160
+ const eventTypeOf = (raw) => isRecord(raw) && typeof raw.event === "string" ? raw.event : undefined;
161
+ const isDecisionEventName = (event) => event !== undefined && DECISION_EVENT_NAMES.includes(event);
162
+ function legacyGateEvent(raw) {
163
+ if (!isRecord(raw) || raw.event !== "gate-result" || typeof raw.ts !== "string"
164
+ || typeof raw.taskId !== "string" || !isRecord(raw.data))
165
+ return undefined;
166
+ const data = raw.data;
167
+ // An `outcome` key declares the canonical format. A malformed or mixed canonical row must not fall
168
+ // through to the legacy booleans and become clean.
169
+ if (hasOwn(data, "outcome"))
170
+ return undefined;
171
+ const attempt = data.attempt === undefined ? 0 : data.attempt;
172
+ if (!Number.isInteger(attempt) || attempt < 0 || typeof data.gate !== "string"
173
+ || !GATE_NAMES.includes(data.gate))
174
+ return undefined;
175
+ const outcome = normalizeGateOutcome(data);
176
+ // T31's unavailable fallback is useful to old projections, but at this protocol boundary it means
177
+ // the known terminal could not be interpreted. Preserve it as a ProtocolIssue instead of silently
178
+ // manufacturing a terminal outcome.
179
+ if (outcome.kind === "unavailable")
180
+ return undefined;
181
+ const parsed = GateTerminalEventSchema.safeParse({
182
+ ts: raw.ts,
183
+ event: "gate-result",
184
+ taskId: raw.taskId,
185
+ data: {
186
+ attempt,
187
+ gate: data.gate,
188
+ outcome,
189
+ ...(typeof data.details === "string" ? { details: data.details } : {}),
190
+ evidence: { legacyData: data },
191
+ },
192
+ });
193
+ return parsed.success ? parsed.data : undefined;
194
+ }
195
+ const LegacyRunTerminalDataSchema = z.object({
196
+ runId: NonEmptyStringSchema,
197
+ branch: NonEmptyStringSchema,
198
+ done: TaskIdsSchema,
199
+ failed: TaskIdsSchema,
200
+ human: TaskIdsSchema,
201
+ blocked: TaskIdsSchema.optional(),
202
+ pending: TaskIdsSchema.optional(),
203
+ tipVerify: z.enum(["passed", "failed"]).optional(),
204
+ lastMergedTask: TaskIdSchema.optional(),
205
+ approvalDisposition: z.enum(["complete", "outstanding"]).optional(),
206
+ outstandingApprovals: TaskIdsSchema.optional(),
207
+ phase: NonEmptyStringSchema.optional(),
208
+ fatal: z.boolean().optional(),
209
+ error: z.string().optional(),
210
+ }).passthrough();
211
+ function legacyRunTerminalEvent(raw) {
212
+ if (!isRecord(raw) || raw.event !== "run-end" || typeof raw.ts !== "string"
213
+ || hasOwn(raw, "taskId") || !isRecord(raw.data))
214
+ return undefined;
215
+ // Either canonical discriminator declares a new-format row. Never repair a malformed canonical row
216
+ // from its summary arrays, because doing so could turn a rejected write into a clean terminal.
217
+ if (hasOwn(raw.data, "outcome") || hasOwn(raw.data, "reason"))
218
+ return undefined;
219
+ const legacy = LegacyRunTerminalDataSchema.safeParse(raw.data);
220
+ if (!legacy.success)
221
+ return undefined;
222
+ const data = legacy.data;
223
+ const failed = data.fatal === true || data.failed.length > 0 || data.tipVerify === "failed";
224
+ const incomplete = data.human.length > 0 || (data.blocked?.length ?? 0) > 0
225
+ || (data.pending?.length ?? 0) > 0 || data.approvalDisposition === "outstanding";
226
+ const outcome = failed ? "failed" : incomplete ? "incomplete" : "completed";
227
+ const reason = data.fatal === true
228
+ ? data.error?.trim() || "legacy run ended after a fatal error"
229
+ : failed
230
+ ? "legacy run summary records failed verification or tasks"
231
+ : incomplete
232
+ ? "legacy run summary records unfinished tasks or approvals"
233
+ : "legacy run summary records all tasks complete";
234
+ const parsed = RunTerminalEventSchema.safeParse({
235
+ ts: raw.ts,
236
+ event: "run-end",
237
+ data: {
238
+ outcome,
239
+ reason,
240
+ runId: data.runId,
241
+ branch: data.branch,
242
+ done: data.done,
243
+ failed: data.failed,
244
+ human: data.human,
245
+ blocked: data.blocked ?? [],
246
+ pending: data.pending ?? [],
247
+ ...(data.tipVerify === undefined ? {} : { tipVerify: data.tipVerify }),
248
+ ...(data.lastMergedTask === undefined ? {} : { lastMergedTask: data.lastMergedTask }),
249
+ ...(data.approvalDisposition === undefined ? {} : { approvalDisposition: data.approvalDisposition }),
250
+ ...(data.outstandingApprovals === undefined ? {} : { outstandingApprovals: data.outstandingApprovals }),
251
+ ...(data.phase === undefined ? {} : { phase: data.phase }),
252
+ ...(data.fatal === undefined ? {} : { fatal: data.fatal }),
253
+ ...(data.error === undefined ? {} : { error: data.error }),
254
+ evidence: { legacyData: raw.data },
255
+ },
256
+ });
257
+ return parsed.success ? parsed.data : undefined;
258
+ }
259
+ function schemaFor(event) {
260
+ switch (event) {
261
+ case "gate-phase-start": return GatePhaseStartEventSchema;
262
+ case "gate-result": return GateTerminalEventSchema;
263
+ case "role-invocation-start": return RoleInvocationStartEventSchema;
264
+ case "role-invocation-terminal": return RoleInvocationTerminalEventSchema;
265
+ case "run-end": return RunTerminalEventSchema;
266
+ }
267
+ }
268
+ function issueMessages(event, raw) {
269
+ const parsed = schemaFor(event).safeParse(raw);
270
+ if (parsed.success)
271
+ return [];
272
+ return parsed.error.issues.map((issue) => {
273
+ const path = issue.path.length > 0 ? issue.path.join(".") : "row";
274
+ return `${path}: ${issue.message}`;
275
+ });
276
+ }
277
+ /** Interpret every parseable JSONL row without dropping unknown or malformed-known events. */
278
+ export function trackJournalRows(runId, sourceRows) {
279
+ return sourceRows.map(({ sourceIndex, raw }) => {
280
+ const eventType = eventTypeOf(raw);
281
+ const rawData = rawDataOf(raw);
282
+ if (!isDecisionEventName(eventType)) {
283
+ return { kind: "historical-unknown", runId, sourceIndex, eventType, raw, rawData };
284
+ }
285
+ const canonical = DecisionEventSchema.safeParse(raw);
286
+ const compatible = canonical.success
287
+ ? canonical.data
288
+ : eventType === "gate-result"
289
+ ? legacyGateEvent(raw)
290
+ : eventType === "run-end"
291
+ ? legacyRunTerminalEvent(raw)
292
+ : undefined;
293
+ if (compatible) {
294
+ return { kind: "decision", runId, sourceIndex, event: compatible, raw, rawData };
295
+ }
296
+ const issues = issueMessages(eventType, raw);
297
+ if (eventType === "gate-result" && isRecord(rawData) && !hasOwn(rawData, "outcome")) {
298
+ const outcome = normalizeGateOutcome(rawData);
299
+ if (outcome.kind === "unavailable")
300
+ issues.push(`data.outcome: ${outcome.reason}`);
301
+ }
302
+ return {
303
+ kind: "protocol-issue",
304
+ runId,
305
+ sourceIndex,
306
+ eventType,
307
+ issues: issues.length > 0 ? issues : ["row does not satisfy its closed decision schema"],
308
+ raw,
309
+ rawData,
310
+ };
311
+ });
312
+ }
313
+ const pairKey = ({ runId, taskId, attempt, role }) => JSON.stringify([runId, taskId, attempt, role]);
314
+ function pairedIdentity(row) {
315
+ const event = row.event;
316
+ switch (event.event) {
317
+ case "gate-phase-start":
318
+ return {
319
+ identity: {
320
+ runId: row.runId,
321
+ taskId: event.taskId,
322
+ attempt: event.data.attempt,
323
+ role: `gate:${event.data.gate}`,
324
+ },
325
+ terminal: false,
326
+ };
327
+ case "gate-result":
328
+ return {
329
+ identity: {
330
+ runId: row.runId,
331
+ taskId: event.taskId,
332
+ attempt: event.data.attempt,
333
+ role: `gate:${event.data.gate}`,
334
+ },
335
+ terminal: true,
336
+ };
337
+ case "role-invocation-start":
338
+ return {
339
+ identity: {
340
+ runId: row.runId,
341
+ taskId: event.taskId,
342
+ attempt: event.data.attempt,
343
+ role: event.data.role,
344
+ },
345
+ terminal: false,
346
+ };
347
+ case "role-invocation-terminal":
348
+ return {
349
+ identity: {
350
+ runId: row.runId,
351
+ taskId: event.taskId,
352
+ attempt: event.data.attempt,
353
+ role: event.data.role,
354
+ },
355
+ terminal: true,
356
+ };
357
+ case "run-end":
358
+ return undefined;
359
+ }
360
+ }
361
+ /**
362
+ * Structural pair fold only. It does not reduce task or run state. An open start remains observable as
363
+ * open; duplicate starts/terminals and orphan terminals are invalid and never count as a closed pair.
364
+ */
365
+ export function foldPairIntegrity(rows) {
366
+ const byIdentity = new Map();
367
+ const issues = [];
368
+ for (const row of rows) {
369
+ if (row.kind === "protocol-issue") {
370
+ issues.push({
371
+ kind: "malformed-decision",
372
+ sourceIndexes: [row.sourceIndex],
373
+ reason: `${row.eventType}: ${row.issues.join("; ")}`,
374
+ });
375
+ continue;
376
+ }
377
+ if (row.kind !== "decision")
378
+ continue;
379
+ const paired = pairedIdentity(row);
380
+ if (!paired)
381
+ continue;
382
+ const key = pairKey(paired.identity);
383
+ let accumulator = byIdentity.get(key);
384
+ if (!accumulator) {
385
+ accumulator = {
386
+ identity: paired.identity,
387
+ starts: [],
388
+ terminals: [],
389
+ firstSourceIndex: row.sourceIndex,
390
+ };
391
+ byIdentity.set(key, accumulator);
392
+ }
393
+ (paired.terminal ? accumulator.terminals : accumulator.starts).push(row.sourceIndex);
394
+ }
395
+ const accumulators = [...byIdentity.values()].sort((a, b) => a.firstSourceIndex - b.firstSourceIndex);
396
+ const pairs = accumulators.map((pair) => {
397
+ if (pair.starts.length > 1) {
398
+ issues.push({
399
+ kind: "duplicate-start",
400
+ identity: pair.identity,
401
+ sourceIndexes: [...pair.starts],
402
+ reason: "one structural identity has more than one start",
403
+ });
404
+ }
405
+ if (pair.terminals.length > 1) {
406
+ issues.push({
407
+ kind: "duplicate-terminal",
408
+ identity: pair.identity,
409
+ sourceIndexes: [...pair.terminals],
410
+ reason: "one structural identity has more than one terminal",
411
+ });
412
+ }
413
+ const orphanTerminals = pair.starts.length === 0
414
+ ? pair.terminals
415
+ : pair.terminals.filter((sourceIndex) => sourceIndex < pair.starts[0]);
416
+ if (orphanTerminals.length > 0) {
417
+ issues.push({
418
+ kind: "terminal-without-start",
419
+ identity: pair.identity,
420
+ sourceIndexes: [...orphanTerminals],
421
+ reason: "a terminal has no preceding start with the same run, task, attempt and role",
422
+ });
423
+ }
424
+ const invalid = pair.starts.length > 1 || pair.terminals.length > 1 || orphanTerminals.length > 0;
425
+ return {
426
+ identity: pair.identity,
427
+ state: invalid ? "invalid" : pair.terminals.length === 0 ? "open" : "closed",
428
+ startSourceIndexes: [...pair.starts],
429
+ terminalSourceIndexes: [...pair.terminals],
430
+ };
431
+ });
432
+ return { pairs, issues };
433
+ }
@@ -0,0 +1,29 @@
1
+ /** How often a watcher rewrites its own tier's beat. */
2
+ export declare const SUPERVISION_BEAT_MS = 10000;
3
+ /** Ceiling before a beat reads STALE: SIX beats, lock.ts's ratio — five may be missed before alarm. */
4
+ export declare const SUPERVISION_STALE_MS: number;
5
+ export declare const SUPERVISION_FUTURE_GRACE_MS = 1000;
6
+ export declare const SUPERVISION_TIERS: readonly ["orchestrator", "overseer", "watch"];
7
+ export type SupervisionTier = (typeof SUPERVISION_TIERS)[number];
8
+ export type SupervisionState = "ABSENT" | "STALE" | "ARMED" | "UNREADABLE" | "DISARMED";
9
+ export interface TierLiveness {
10
+ tier: SupervisionTier;
11
+ state: SupervisionState;
12
+ /** Absent for ABSENT, UNREADABLE and DISARMED — no beat to age. Always present for STALE and ARMED. */
13
+ beatAgeMs?: number;
14
+ }
15
+ export declare const supervisionBeatPath: (repoRoot: string, tier: SupervisionTier) => string;
16
+ /** Where a watcher records that it STOOD DOWN. Its own file: the beat keeps meaning only "alive". */
17
+ export declare const supervisionStandDownPath: (repoRoot: string, tier: SupervisionTier) => string;
18
+ export declare function beatSupervision(repoRoot: string, tier: SupervisionTier): void;
19
+ /** Handle a watcher holds for as long as it is supervising; disarm stands it down and is idempotent. */
20
+ export interface ArmedSupervision {
21
+ disarm: () => void;
22
+ }
23
+ export declare function armSupervision(repoRoot: string, tier: SupervisionTier, beatMs?: number): ArmedSupervision;
24
+ export declare function readTierLiveness(repoRoot: string, tier: SupervisionTier, now?: number): TierLiveness;
25
+ export declare function supervisionStatus(repoRoot: string, tier: SupervisionTier, now?: number): TierLiveness;
26
+ /** Every KNOWN tier, always — a tier omitted from this list would read as one that is fine. */
27
+ export declare const readSupervision: (repoRoot: string, now?: number) => TierLiveness[];
28
+ /** One line, one word per tier. Shared by both status surfaces so neither can render a state twice. */
29
+ export declare const supervisionText: (tiers: readonly TierLiveness[], divider?: string) => string;
@@ -0,0 +1,189 @@
1
+ import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { stateDirName, tickmarkrDir } from "../graph/graph.js";
4
+ // SUP-01: supervision liveness as FILE STATE, not as a report — lock.ts's proven shape, one file per
5
+ // tier. A watcher beats its OWN file; every other seat derives that tier's state by stat()ing it. So
6
+ // "watchers alive" stops being the one claim only the seat that already lost it could make: the reader
7
+ // is never the watcher, and a watcher that dies stops writing without having to notice it died.
8
+ //
9
+ // BOTH polarities are distinguishable by construction, because collapsing them IS the defect: a seat
10
+ // that never armed reads exactly like one that armed and then died. No record at all ⇒ ABSENT (never
11
+ // armed). A record whose beat has aged past the ceiling ⇒ STALE (armed, then died). A fresh one ⇒
12
+ // ARMED. ABSENT is its own branch below and carries no age — STALE always carries one.
13
+ //
14
+ // SUP-02: the beat mtime is the ONLY input. Nothing here reads a process table and nothing matches a
15
+ // process name — a poll-grep watcher carries `grep` in its own argv, so the filter that removes the
16
+ // probing grep removes the watched one. The payload records a pid for an OPERATOR to read off a STALE
17
+ // record; the derivation never consults it, so a reused, dead or unreadable pid changes no state.
18
+ // Zero new deps — node:fs stdlib, exactly as lock.ts.
19
+ /** How often a watcher rewrites its own tier's beat. */
20
+ export const SUPERVISION_BEAT_MS = 10_000;
21
+ /** Ceiling before a beat reads STALE: SIX beats, lock.ts's ratio — five may be missed before alarm. */
22
+ export const SUPERVISION_STALE_MS = 6 * SUPERVISION_BEAT_MS;
23
+ // SUP-03: the ONLY legitimate reason a beat's mtime may lead the reader's clock is that the two are
24
+ // not the same clock and not the same resolution — a filesystem stamps sub-millisecond, Date.now()
25
+ // truncates to whole milliseconds, so a beat written this instant can read a fraction of a
26
+ // millisecond into the future. A second of slack covers even a coarse filesystem. Anything past it
27
+ // is SKEW, and skew is the one direction this instrument may not fail in: see readTierLiveness.
28
+ export const SUPERVISION_FUTURE_GRACE_MS = 1_000;
29
+ // The supervision seats this harness has. An unlisted tier is an INVISIBLE tier, which is the failure
30
+ // mode itself — an auditor read "no watchers were ever armed" off a surface that named none. Adding a
31
+ // seat means adding it here, and `status` then renders it whether or not it has ever beaten.
32
+ export const SUPERVISION_TIERS = ["orchestrator", "overseer", "watch"];
33
+ // PURE path math: stateDirName, never tickmarkrDir — the latter mkdirs the state dir and writes its
34
+ // .gitignore, so routing a READER through it would make status create the very tree it reports on.
35
+ export const supervisionBeatPath = (repoRoot, tier) => join(repoRoot, stateDirName(repoRoot), "supervision", `${tier}.beat`);
36
+ /** Where a watcher records that it STOOD DOWN. Its own file: the beat keeps meaning only "alive". */
37
+ export const supervisionStandDownPath = (repoRoot, tier) => join(repoRoot, stateDirName(repoRoot), "supervision", `${tier}.standdown`);
38
+ // WRITER — a watcher's own call, on its own tier, every SUPERVISION_BEAT_MS. Never a reader's: the
39
+ // purity fence (status --watch leaves the state dir byte-identical) is the test that catches a reader
40
+ // that beats on the watcher's behalf, which would report every dead tier as healthy.
41
+ export function beatSupervision(repoRoot, tier) {
42
+ tickmarkrDir(repoRoot); // the write path DOES create — beats land inside the gitignored state dir
43
+ const p = supervisionBeatPath(repoRoot, tier);
44
+ mkdirSync(dirname(p), { recursive: true });
45
+ writeFileSync(p, JSON.stringify({ tier, pid: process.pid, beatAt: new Date().toISOString() }) + "\n");
46
+ }
47
+ // THE WATCHER-FACING ENTRY POINT — the loop SUPERVISION_BEAT_MS actually drives. A supervising seat
48
+ // calls this once at the top of its watch and holds the handle for the duration; a seat that dies,
49
+ // hangs or is SIGKILLed stops beating without having to notice, which is the whole point. lock.ts's
50
+ // heartbeat shape exactly: an immediate first write (ARMED from instant zero, not one interval later),
51
+ // an unref'd interval that never holds the watcher's event loop open, and a beat failure that is
52
+ // swallowed rather than crashing the watcher — an unwritten beat ages out and reads STALE, which is
53
+ // the truth. The FIRST beat is swallowed on the same rule: a cosmetic instrument that could not write
54
+ // must not take the run down with it. NOT called from `status`: status is a reader (its purity fence
55
+ // is a test); the in-repo callsite is runDaemon, which arms the orchestrator tier for the life of
56
+ // the run. A tier nobody arms reads ABSENT — exactly what ABSENT means, not a false "healthy".
57
+ //
58
+ // Arming CLEARS any prior stand-down record: a tier that stood down and armed again is armed, and a
59
+ // marker left behind by the last run would otherwise report the live one as stood down forever.
60
+ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS) {
61
+ // The clearing gets its OWN try: a cleanup that cannot complete (a directory dropped at the marker
62
+ // path, a permission) must not cost the first beat. Sharing one try did exactly that — the tier armed
63
+ // with NO beat while the old marker stayed on disk, the one combination that reports a live watcher
64
+ // as stood down. Recursive because the path is ours: whatever occupies it is not our marker.
65
+ try {
66
+ rmSync(supervisionStandDownPath(repoRoot, tier), { force: true, recursive: true });
67
+ }
68
+ catch { /* uncleared: the reader validates the marker and a newer beat outranks it — never masked */ }
69
+ try {
70
+ beatSupervision(repoRoot, tier);
71
+ }
72
+ catch { /* repo gone / disk full — the tier reads ABSENT rather than crashing its watcher */ }
73
+ const timer = setInterval(() => {
74
+ try {
75
+ beatSupervision(repoRoot, tier);
76
+ }
77
+ catch { /* repo gone / disk full — let the beat expire */ }
78
+ }, beatMs);
79
+ timer.unref();
80
+ let stoodDown = false;
81
+ return {
82
+ // Stand down: stop beating AND say so. Idempotent because the daemon disarms from more than one
83
+ // exit path (its signal reaper exits the process before the finally can run), and the recorded
84
+ // instant belongs to the first stand-down.
85
+ disarm: () => {
86
+ if (stoodDown)
87
+ return;
88
+ stoodDown = true;
89
+ clearInterval(timer);
90
+ // Published ATOMICALLY — written aside, renamed over — so no reader can ever meet a half-written
91
+ // marker. A torn marker is rejected anyway (see readStandDown), but a stand-down that reads as
92
+ // garbage is a stand-down that reports as a death, and the rename costs one line.
93
+ const p = supervisionStandDownPath(repoRoot, tier);
94
+ const tmp = `${p}.${process.pid}.tmp`;
95
+ try {
96
+ mkdirSync(dirname(p), { recursive: true });
97
+ writeFileSync(tmp, JSON.stringify({ tier, pid: process.pid, disarmedAt: new Date().toISOString() }) + "\n");
98
+ renameSync(tmp, p);
99
+ }
100
+ catch { /* unrecordable stand-down ages out as STALE — pessimistic, which is the safe way to fail */ }
101
+ },
102
+ };
103
+ }
104
+ // BEAT DERIVATION — pure, and the only thing that reads the beat. One statSync: never creates,
105
+ // touches or reaps the record it reports on, and never creates the directory that holds it. ONLY a
106
+ // missing path is ABSENT: ENOENT (no beat file) and ENOTDIR (nothing that could hold one) mean no
107
+ // record was ever written. Every other stat failure is a record we could not read, and a stat-able
108
+ // inode that is not a regular file is not a beat — both are UNREADABLE, never silently ABSENT and
109
+ // never silently ARMED. Callers wanting the TIER's state want supervisionStatus below; this answers
110
+ // the narrower question "does the beat say alive", which is all a beat can ever say.
111
+ export function readTierLiveness(repoRoot, tier, now = Date.now()) {
112
+ return beatLiveness(tier, beatMtimeMs(repoRoot, tier), now);
113
+ }
114
+ // The beat's inode, or why there is no age to derive from it. Split out so the stand-down ranking below
115
+ // reads the SAME mtime this derivation does rather than a second, later stat of a moving record.
116
+ function beatMtimeMs(repoRoot, tier) {
117
+ let st;
118
+ try {
119
+ st = statSync(supervisionBeatPath(repoRoot, tier));
120
+ }
121
+ catch (e) {
122
+ const code = e.code;
123
+ // never armed — reachable without anything having been written
124
+ return code === "ENOENT" || code === "ENOTDIR" ? "ABSENT" : "UNREADABLE";
125
+ }
126
+ return st.isFile() ? st.mtimeMs : "UNREADABLE"; // a directory at the beat path is not a heartbeat
127
+ }
128
+ function beatLiveness(tier, mtimeMs, now) {
129
+ if (typeof mtimeMs !== "number")
130
+ return { tier, state: mtimeMs };
131
+ const age = now - mtimeMs;
132
+ // SUP-03: a beat dated AHEAD of the reader's clock past the grace above is not a fresh beat — it is
133
+ // a record whose age cannot be derived. Clamping it to zero (what this line used to do) made any
134
+ // future stamp read ARMED forever, so filesystem skew or one bad utimes froze a DEAD watcher as
135
+ // alive: fail-OPEN in the instrument built to catch exactly that, in the one direction it may not
136
+ // fail. UNREADABLE says what is true — something is there and its age is not knowable — and, unlike
137
+ // ARMED, it never claims a watcher is alive.
138
+ if (age < -SUPERVISION_FUTURE_GRACE_MS)
139
+ return { tier, state: "UNREADABLE" };
140
+ const beatAgeMs = Math.max(0, age); // inside the grace: the two clocks' resolutions, not skew
141
+ return { tier, state: beatAgeMs > SUPERVISION_STALE_MS ? "STALE" : "ARMED", beatAgeMs };
142
+ }
143
+ // A stand-down is only what a watcher RECORDED, so the record has to READ as one: a regular file whose
144
+ // payload names this tier and the instant it stood down. Path existence is not proof — a directory, a
145
+ // torn write or a stray file at that path says nothing about any watcher, and calling one of those a
146
+ // clean hand-off would hide a death behind whatever happened to be lying there. Pure: one stat, one
147
+ // read, and neither creates the record or the directory holding it.
148
+ function readStandDown(repoRoot, tier) {
149
+ const p = supervisionStandDownPath(repoRoot, tier);
150
+ let st;
151
+ try {
152
+ st = statSync(p);
153
+ }
154
+ catch (e) {
155
+ const code = e.code;
156
+ return code === "ENOENT" || code === "ENOTDIR" ? "NONE" : "UNREADABLE";
157
+ }
158
+ if (!st.isFile())
159
+ return "UNREADABLE";
160
+ try {
161
+ const rec = JSON.parse(readFileSync(p, "utf8"));
162
+ if (rec?.tier !== tier)
163
+ return "UNREADABLE";
164
+ if (typeof rec.disarmedAt !== "string" || Number.isNaN(Date.parse(rec.disarmedAt)))
165
+ return "UNREADABLE";
166
+ }
167
+ catch {
168
+ return "UNREADABLE";
169
+ } // unparseable or unreadable bytes — not a stand-down anyone can read
170
+ return { mtimeMs: st.mtimeMs };
171
+ }
172
+ // THE TIER'S STATE — what every surface and every operator reads. A valid stand-down outranks the beat:
173
+ // the watcher that wrote it is gone ON PURPOSE, and its last beat ages out exactly like a dead one's
174
+ // would. It outranks the beat it FOLLOWED and no other — a beat stamped after the marker was written by
175
+ // a watcher that armed again, so a marker some failed cleanup left behind can never mask a live tier.
176
+ export function supervisionStatus(repoRoot, tier, now = Date.now()) {
177
+ const beat = beatMtimeMs(repoRoot, tier);
178
+ const standDown = readStandDown(repoRoot, tier);
179
+ if (standDown === "UNREADABLE")
180
+ return { tier, state: "UNREADABLE" };
181
+ if (standDown !== "NONE" && !(typeof beat === "number" && beat > standDown.mtimeMs)) {
182
+ return { tier, state: "DISARMED" };
183
+ }
184
+ return beatLiveness(tier, beat, now);
185
+ }
186
+ /** Every KNOWN tier, always — a tier omitted from this list would read as one that is fine. */
187
+ export const readSupervision = (repoRoot, now = Date.now()) => SUPERVISION_TIERS.map((tier) => supervisionStatus(repoRoot, tier, now));
188
+ /** One line, one word per tier. Shared by both status surfaces so neither can render a state twice. */
189
+ export const supervisionText = (tiers, divider = " · ") => `supervision: ${tiers.map((t) => `${t.tier} ${t.state}`).join(divider)}`;
@@ -0,0 +1,12 @@
1
+ <!-- tickmarkr:spec -->
2
+ <!-- provenance: v1.89 Finding 0 awk-range self-pass certified only the task heading -->
3
+
4
+ ## T1: Clean leading task
5
+ - goal: Keep the first extracted task deliberately clean
6
+ - acceptance:
7
+ - judge: the first task remains ordinary canary data
8
+
9
+ ## T2: Finding after the range start
10
+ - goal: Consume T28 classification without declaring the dependency
11
+ - acceptance:
12
+ - judge: classification controls the conclusion
@@ -0,0 +1,7 @@
1
+ <!-- tickmarkr:spec -->
2
+ <!-- provenance: v1.89 judge-text extractor read judge instead of the compiled text key -->
3
+
4
+ ## T1: Typed judge finding
5
+ - goal: Put the finding only in a typed judge item
6
+ - acceptance:
7
+ - judge: cite the audit before accepting the task