faberun 0.3.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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. package/src/web/server.mjs +552 -0
@@ -0,0 +1,337 @@
1
+ /**
2
+ * The usage and cost ledger: `usage.jsonl`, one record per invocation, and the
3
+ * arithmetic that rolls it up onto a node.
4
+ *
5
+ * Reporting only. No control path reads this -- a spent allowance is handled by
6
+ * runtime re-tiering, never by a token or dollar cap -- and that is why a usage
7
+ * the provider did not report stays null instead of becoming a plausible zero.
8
+ */
9
+ import { appendJsonl, writeJsonAtomic } from "./store.mjs";
10
+ import { basename, join } from "node:path";
11
+ import { errorMessage, stableJson } from "../util.mjs";
12
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
13
+ import { liveUsage } from "../harnesses/exec-jsonl/index.mjs";
14
+
15
+ import { priceUsage, readBoundedTail } from "../engine/process.mjs";
16
+ import { writeNode } from "../engine/state.mjs";
17
+ import { normalizeProviderResult } from "../harnesses/index.mjs";
18
+
19
+ // `priceUsage` is defined beside `invocationResult`, the second source point,
20
+ // and re-exported here so the ledger's public surface is unchanged. This module
21
+ // already imports `engine/process.mjs`, so the definition lives there to keep
22
+ // the two source points out of a runtime import cycle.
23
+ export { priceUsage };
24
+
25
+ /** @typedef {import("../engine/process.mjs").Invocation} Invocation */
26
+ /** @typedef {import("../engine/process.mjs").Job} Job */
27
+ /** @typedef {ReturnType<typeof import("../run/lock.mjs").acquire>} LockHandle */
28
+ /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
29
+ /** @typedef {import("../harnesses/index.mjs").ProviderEnvelope} ProviderEnvelope */
30
+ /** @typedef {ProviderEnvelope & {costProvenance?: "priced"}} PricedEnvelope */
31
+ /** @typedef {{kind: "adopted"|"rejudge"|"restart"|"reconciled"|"exhausted"|"stalled", phase?: "worker"|"judge", result?: unknown, usage?: Usage, costUsd?: number|null, costProvenance?: "priced", exhaustedUntil?: string|null, error?: {code: string, message: string}|null, invocationId?: string, reason?: string}} RecoveryOutcome */
32
+ /** @typedef {import("../contract/index.mjs").Usage} Usage */
33
+
34
+ /**
35
+ * Extract the invocation's provider envelope from the bounded transcript tail
36
+ * and persist its usage into the matching invocation record. By default the
37
+ * usage is also accumulated into `state.usage` (the caller then transitions or
38
+ * continues); with `accumulate: false` only the invocation record is updated,
39
+ * for jobs whose node already reached a terminal state that already counted
40
+ * this spend.
41
+ *
42
+ * @param {Job} job
43
+ * @param {{accumulate?: boolean}} [options]
44
+ * @returns {PricedEnvelope}
45
+ */
46
+ export function recordInvocationUsage(job, options = {}) {
47
+ const { state } = job;
48
+ /** @type {PricedEnvelope} */
49
+ let envelope;
50
+ let boundedStdout = "";
51
+ try {
52
+ boundedStdout = readBoundedTail(job.paths.stdout);
53
+ const boundedStderr = readBoundedTail(job.paths.stderr, 512 * 1024);
54
+ envelope = normalizeProviderResult(job.runtime, boundedStdout, job.exitCode, job.signal, {
55
+ preferStructured: job.phase === "judge",
56
+ stderr: boundedStderr,
57
+ });
58
+ } catch (error) {
59
+ envelope = {
60
+ status: "failed",
61
+ result: null,
62
+ continuationId: null,
63
+ usage: { inputTokens: null, outputTokens: null, cacheReadInputTokens: null },
64
+ costUsd: null,
65
+ error: { code: "invalid_output", message: errorMessage(error) },
66
+ };
67
+ }
68
+ // Failure envelopes carry zeroed usage (a killed provider emits no terminal
69
+ // event), yet its transcript holds real per-turn counters. Backfill the
70
+ // normalized usage components from the live meter so kills, timeouts, and
71
+ // scope failures still report what they spent, cache reads separated.
72
+ if (envelope.usage.inputTokens === null && boundedStdout) {
73
+ const observed = liveUsage(job.runtime.harness, boundedStdout);
74
+ if (observed.inputTokens !== null) {
75
+ envelope = { ...envelope, usage: { ...envelope.usage, inputTokens: observed.inputTokens, cacheReadInputTokens: observed.cacheReadInputTokens } };
76
+ }
77
+ }
78
+ // Price only after the backfill has run: the counters this function persists
79
+ // and returns are the ones the price is derived from, and the envelope becomes
80
+ // the single priced object every later copy spreads from.
81
+ const priced = priceUsage(job.runtime, envelope.usage, envelope.costUsd);
82
+ envelope = { ...envelope, costUsd: priced.costUsd, costProvenance: priced.costProvenance };
83
+ state.invocations = (state.invocations ?? []).map((invocation) => invocation.id === job.invocation.id
84
+ ? { ...invocation, usage: envelope.usage, costUsd: envelope.costUsd, costProvenance: envelope.costProvenance }
85
+ : invocation);
86
+ if (options.accumulate !== false) state.usage = addUsage(state.usage, envelope.usage);
87
+ return envelope;
88
+ }
89
+ const USAGE_LOG_NAME = "usage.jsonl";
90
+ /** @param {Usage|undefined} usage @returns {boolean} */
91
+ function hasMeasuredUsage(usage) {
92
+ return Boolean(usage && [usage.inputTokens, usage.outputTokens, usage.cacheReadInputTokens]
93
+ .some((value) => typeof value === "number" && Number.isFinite(value)));
94
+ }
95
+ /** @param {NodeSnapshot} state @returns {Usage} */
96
+ export function invocationUsage(state) {
97
+ const seen = new Set();
98
+ return (state.invocations ?? []).reduce((total, invocation) => {
99
+ if (invocation.id && seen.has(invocation.id)) return total;
100
+ if (invocation.id) seen.add(invocation.id);
101
+ return addUsage(total, invocation.usage);
102
+ }, /** @type {Usage} */ ({ inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 }));
103
+ }
104
+ /** @param {NodeSnapshot} state @returns {number|undefined} */
105
+ export function invocationCost(state) {
106
+ const costs = /** @type {number[]} */ ((state.invocations ?? [])
107
+ .map((invocation) => invocation.costUsd)
108
+ .filter((cost) => typeof cost === "number" && Number.isFinite(cost)));
109
+ return costs.length ? costs.reduce((total, cost) => total + cost, 0) : undefined;
110
+ }
111
+ /**
112
+ * Invocation ids already present in the run's usage.jsonl. The append path
113
+ * uses this to stay idempotent across resume and replay.
114
+ *
115
+ * @param {string} runDir
116
+ * @returns {Set<string>}
117
+ */
118
+ export function usageRecordIds(runDir) {
119
+ const ids = new Set();
120
+ const path = join(runDir, USAGE_LOG_NAME);
121
+ if (!existsSync(path)) return ids;
122
+ for (const line of readFileSync(path, "utf8").split("\n")) {
123
+ if (!line.trim()) continue;
124
+ try {
125
+ const record = JSON.parse(line);
126
+ const value = record && typeof record === "object" && !Array.isArray(record)
127
+ ? /** @type {Record<string, unknown>} */ (record)
128
+ : null;
129
+ if (value && typeof value.invocationId === "string") ids.add(value.invocationId);
130
+ } catch {
131
+ // A truncated tail line is repaired by appendJsonl on the next write.
132
+ }
133
+ }
134
+ return ids;
135
+ }
136
+ /**
137
+ * Append one usage.jsonl record for a worker or judge invocation. Usage is a
138
+ * reporting record only: no control path reads this file to gate work.
139
+ *
140
+ * @param {string} runDir
141
+ * @param {Invocation|undefined|null} invocation
142
+ */
143
+ export function appendUsageRecord(runDir, invocation) {
144
+ if (!invocation?.id || usageRecordIds(runDir).has(invocation.id)) return;
145
+ const usage = /** @type {Usage} */ (invocation.usage ?? { inputTokens: null, outputTokens: null, cacheReadInputTokens: null });
146
+ appendJsonl(join(runDir, USAGE_LOG_NAME), {
147
+ invocationId: invocation.id,
148
+ runId: invocation.runId ?? basename(runDir),
149
+ nodeId: invocation.nodeId ?? null,
150
+ attempt: invocation.attempt ?? null,
151
+ role: invocation.role ?? null,
152
+ runtimeId: invocation.runtimeId ?? null,
153
+ model: invocation.model ?? null,
154
+ inputTokens: typeof usage.inputTokens === "number" ? usage.inputTokens : null,
155
+ cacheReadInputTokens: typeof usage.cacheReadInputTokens === "number" ? usage.cacheReadInputTokens : null,
156
+ outputTokens: typeof usage.outputTokens === "number" ? usage.outputTokens : null,
157
+ costUsd: typeof invocation.costUsd === "number" ? invocation.costUsd : null,
158
+ // A persisted `priced` marker wins; otherwise the pre-Phase-4 rule applies
159
+ // unchanged: a reported number is `provider`, absence is `unknown`.
160
+ costProvenance: invocation.costProvenance ?? (typeof invocation.costUsd === "number" ? "provider" : "unknown"),
161
+ startedAt: invocation.startedAt ?? null,
162
+ finishedAt: invocation.closedAt ?? null,
163
+ });
164
+ }
165
+ /**
166
+ * Recovery can discover usage after the run synchronized its records. Attach
167
+ * it to the authoritative invocation first, then write the updated usage
168
+ * record. The invocation id makes repeated resumes idempotent.
169
+ *
170
+ * @param {string} runDir
171
+ * @param {NodeSnapshot} state
172
+ * @param {RecoveryOutcome|null|undefined} recovery
173
+ * @param {LockHandle} lock
174
+ * @returns {Promise<void>}
175
+ */
176
+ export async function persistRecoveryUsage(runDir, state, recovery, lock) {
177
+ if (!recovery?.invocationId) return;
178
+ const current = state.invocations?.find((invocation) => invocation.id === recovery.invocationId);
179
+ if (!current) return;
180
+ const usage = hasMeasuredUsage(current.usage) ? current.usage : recovery.usage;
181
+ // Cost and provenance are selected together from whichever source wins the
182
+ // numeric-cost predicate: an independent fallback could attach a stray
183
+ // recovery provenance to an already-settled cost that was never priced.
184
+ const priced = typeof current.costUsd === "number"
185
+ ? { costUsd: current.costUsd, costProvenance: current.costProvenance }
186
+ : { costUsd: recovery.costUsd ?? null, costProvenance: recovery.costProvenance };
187
+ const changed = stableJson(current.usage) !== stableJson(usage)
188
+ || current.costUsd !== priced.costUsd
189
+ || (current.costProvenance ?? null) !== (priced.costProvenance ?? null);
190
+ if (changed) {
191
+ state.invocations = (state.invocations ?? []).map((invocation) => invocation.id === current.id
192
+ ? { ...invocation, usage, costUsd: priced.costUsd, costProvenance: priced.costProvenance }
193
+ : invocation);
194
+ state.usage = invocationUsage(state);
195
+ writeNode(runDir, state, lock);
196
+ }
197
+ const updated = state.invocations?.find((invocation) => invocation.id === current.id);
198
+ if (updated) appendUsageRecord(runDir, updated);
199
+ }
200
+ /**
201
+ * @param {Usage|undefined} left
202
+ * @param {Usage|undefined} right
203
+ * @returns {Usage}
204
+ */
205
+ function addUsage(left, right) {
206
+ return {
207
+ inputTokens: (left?.inputTokens ?? 0) + (right?.inputTokens ?? 0),
208
+ outputTokens: (left?.outputTokens ?? 0) + (right?.outputTokens ?? 0),
209
+ cacheReadInputTokens: (left?.cacheReadInputTokens ?? 0) + (right?.cacheReadInputTokens ?? 0),
210
+ };
211
+ }
212
+ /** @returns {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}} */
213
+ export function emptyUsage() {
214
+ return { inputTokens: null, outputTokens: null, cacheReadInputTokens: null };
215
+ }
216
+
217
+ /**
218
+ * The named artifact the fresh-session hypothesis is recorded to. The
219
+ * measurement is the acceptance, not a threshold met: a cohort with no gate
220
+ * revision is recorded as absent rather than compared against an empty set.
221
+ */
222
+ export const CACHE_READ_PER_REVISION_ARTIFACT = "cacheReadPerRevision.json";
223
+
224
+ /** @typedef {{runCount: number, runsWithGateRevision: number, gateRevisions: number, retryWorkerInvocations: number, cacheReadTokens: number, cacheReadPerRevision: number|null}} CacheReadCohort */
225
+ /** @typedef {{before: CacheReadCohort, after: CacheReadCohort}} CacheReadCohorts */
226
+
227
+ /**
228
+ * `cacheReadPerRevision` over one cohort of run directories:
229
+ *
230
+ * cacheReadPerRevision =
231
+ * cache-read tokens attributable to gate revisions
232
+ * / number of gate revisions
233
+ *
234
+ * A gate revision is one `state.revisions` increment — a judge or mechanical
235
+ * rejection that re-dispatched the worker. The persisted ledger does not link
236
+ * an invocation to the rejection that caused it, so the attribution is the
237
+ * declared approximation: the cache-read tokens recorded on worker invocations
238
+ * after the first attempt of their node, which are the retries that include
239
+ * those gate revisions. A cohort with no gate revision reports
240
+ * `cacheReadPerRevision: null`, never a divide-by-zero.
241
+ *
242
+ * @param {string[]} runDirs
243
+ * @returns {CacheReadCohort}
244
+ */
245
+ export function measureCacheReadPerRevision(runDirs) {
246
+ let gateRevisions = 0;
247
+ let retryWorkerInvocations = 0;
248
+ let cacheReadTokens = 0;
249
+ let runsWithGateRevision = 0;
250
+ for (const runDir of runDirs) {
251
+ let runRevisions = 0;
252
+ for (const name of listNodeSnapshotFiles(runDir)) {
253
+ let state;
254
+ try {
255
+ state = JSON.parse(readFileSync(join(runDir, "nodes", name), "utf8"));
256
+ } catch {
257
+ // A torn or unreadable snapshot contributes nothing; a measurement
258
+ // never fails because one run was mid-write.
259
+ continue;
260
+ }
261
+ if (typeof state.revisions === "number" && Number.isFinite(state.revisions)) runRevisions += state.revisions;
262
+ for (const invocation of Array.isArray(state.invocations) ? state.invocations : []) {
263
+ if (invocation?.role !== "worker") continue;
264
+ if (!(typeof invocation.attempt === "number" && invocation.attempt > 1)) continue;
265
+ retryWorkerInvocations += 1;
266
+ const cacheRead = invocation.usage?.cacheReadInputTokens;
267
+ if (typeof cacheRead === "number" && Number.isFinite(cacheRead)) cacheReadTokens += cacheRead;
268
+ }
269
+ }
270
+ if (runRevisions > 0) {
271
+ gateRevisions += runRevisions;
272
+ runsWithGateRevision += 1;
273
+ }
274
+ }
275
+ return {
276
+ runCount: runDirs.length,
277
+ runsWithGateRevision,
278
+ gateRevisions,
279
+ retryWorkerInvocations,
280
+ cacheReadTokens,
281
+ cacheReadPerRevision: gateRevisions > 0 ? cacheReadTokens / gateRevisions : null,
282
+ };
283
+ }
284
+
285
+ /**
286
+ * The two declared cohorts and the recorded comparison. `before` is the runs
287
+ * preceding the change commit and `after` those following it; when the after
288
+ * cohort records no gate revision the artifact says so explicitly rather than
289
+ * presenting an empty comparison. `changeCommit` is recorded so the cohort
290
+ * boundary is auditable.
291
+ *
292
+ * @param {CacheReadCohort} before
293
+ * @param {CacheReadCohort} after
294
+ * @param {{changeCommit?: string|null, generatedAt?: string, runDirs?: {before: string[], after: string[]}}} [context]
295
+ * @returns {Record<string, unknown>}
296
+ */
297
+ export function cacheReadPerRevisionArtifact(before, after, context = {}) {
298
+ const measured = after.gateRevisions > 0;
299
+ return {
300
+ schemaVersion: 1,
301
+ metric: "cacheReadPerRevision",
302
+ definition: "cache-read tokens attributable to gate revisions divided by the number of gate revisions",
303
+ attribution: "gateRevisions is the sum of persisted node revisions; cacheReadTokens is the cache-read tokens on worker invocations after the first attempt, the retries that include those gate revisions",
304
+ changeCommit: context.changeCommit ?? null,
305
+ generatedAt: context.generatedAt ?? new Date().toISOString(),
306
+ status: measured ? "measured" : "no_post_change_revision",
307
+ note: measured
308
+ ? "the after cohort records at least one gate revision; before and after are compared below"
309
+ : "no post-change gate revision exists: the after cohort records zero gate revisions, so no comparison is fabricated",
310
+ before,
311
+ after,
312
+ ...(context.runDirs ? { runDirs: context.runDirs } : {}),
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Write the measurement to its named artifact path.
318
+ *
319
+ * @param {string} artifactPath
320
+ * @param {CacheReadCohort} before
321
+ * @param {CacheReadCohort} after
322
+ * @param {{changeCommit?: string|null, generatedAt?: string, runDirs?: {before: string[], after: string[]}}} [context]
323
+ * @returns {string} the artifact path
324
+ */
325
+ export function writeCacheReadPerRevisionArtifact(artifactPath, before, after, context = {}) {
326
+ writeJsonAtomic(artifactPath, cacheReadPerRevisionArtifact(before, after, context));
327
+ return artifactPath;
328
+ }
329
+
330
+ /** @param {string} runDir @returns {string[]} */
331
+ function listNodeSnapshotFiles(runDir) {
332
+ try {
333
+ return readdirSync(join(runDir, "nodes")).filter((name) => name.endsWith(".json"));
334
+ } catch {
335
+ return [];
336
+ }
337
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The operator harness registry: how to launch an interactive harness, how to
3
+ * recognize it from the environment, and whether it can render ambient state.
4
+ *
5
+ * The seat is where a human talks to a harness; it is not a second worker
6
+ * engine. These five entries are the whole operator surface and each one
7
+ * declares exactly three facts. The worker registry in `harnesses/` answers a
8
+ * different question with a much larger interface, and keeping the two apart is
9
+ * why this is one declarative file instead of a folder per harness.
10
+ *
11
+ * `canRenderAmbient` is true only for claude, the single harness with a
12
+ * statusLine surface (TECH-SPEC 2026-09-12 §3.2). An `envMarker` is the
13
+ * variable the harness leaves in the shell it starts, so the registry can name
14
+ * the harness a seat is already inside without probing a process table.
15
+ *
16
+ * @typedef {Readonly<{argv: readonly string[], envMarker: string, canRenderAmbient: boolean}>} OperatorHarness
17
+ */
18
+
19
+ /** @type {Readonly<Record<string, OperatorHarness>>} */
20
+ export const OPERATOR_HARNESSES = Object.freeze({
21
+ claude: Object.freeze({ argv: Object.freeze(["claude"]), envMarker: "CLAUDECODE", canRenderAmbient: true }),
22
+ codex: Object.freeze({ argv: Object.freeze(["codex"]), envMarker: "CODEX_SANDBOX", canRenderAmbient: false }),
23
+ zcode: Object.freeze({ argv: Object.freeze(["zcode"]), envMarker: "ZCODE_MODEL", canRenderAmbient: false }),
24
+ dsh: Object.freeze({ argv: Object.freeze(["dsh"]), envMarker: "DSH_PERMISSION_MODE", canRenderAmbient: false }),
25
+ agy: Object.freeze({ argv: Object.freeze(["agy"]), envMarker: "AGY_MODEL", canRenderAmbient: false }),
26
+ });
27
+
28
+ /**
29
+ * @param {string} name
30
+ * @returns {boolean}
31
+ */
32
+ export function canRenderAmbient(name) {
33
+ const harness = /** @type {OperatorHarness|undefined} */ (OPERATOR_HARNESSES[name]);
34
+ return harness?.canRenderAmbient === true;
35
+ }
36
+
37
+ /**
38
+ * The argv a seat launches for one harness, with an optional instruction
39
+ * appended as a single positional argument. `seat switch` passes the operator
40
+ * brief here so a fresh harness starts by reading what the previous one knew,
41
+ * and the registry stays the one place that knows each harness's base command.
42
+ *
43
+ * @param {string} name
44
+ * @param {string|null} [instruction]
45
+ * @returns {string[]|null}
46
+ */
47
+ export function launchArgv(name, instruction = null) {
48
+ const harness = /** @type {OperatorHarness|undefined} */ (OPERATOR_HARNESSES[name]);
49
+ if (!harness) return null;
50
+ return instruction ? [...harness.argv, instruction] : [...harness.argv];
51
+ }
52
+
53
+ /**
54
+ * The operator harness this process is running inside, or null. Detection is
55
+ * marker-based on purpose: the markers above are the only environment facts a
56
+ * harness guarantees, and reading them costs nothing.
57
+ *
58
+ * @param {Record<string, string|undefined>} [env]
59
+ * @returns {string|null}
60
+ */
61
+ export function detectOperatorHarness(env = process.env) {
62
+ for (const [name, harness] of Object.entries(OPERATOR_HARNESSES)) {
63
+ if (env[harness.envMarker] !== undefined) return name;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * The operator harness a pane's current command names, or null while the pane
70
+ * is still a shell or has moved on to something the registry does not know.
71
+ * The registry's launch argv is the contract: whatever `argv[0]` is, that is
72
+ * the command a live seat of that harness shows.
73
+ *
74
+ * @param {string|null|undefined} command
75
+ * @returns {string|null}
76
+ */
77
+ export function detectOperatorHarnessByCommand(command) {
78
+ const name = String(command ?? "").split(/[\\/]/u).at(-1) ?? "";
79
+ for (const [harness, entry] of Object.entries(OPERATOR_HARNESSES)) {
80
+ if (entry.argv[0] === name) return harness;
81
+ }
82
+ return null;
83
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * The seat facade the CLI calls: `startSeat`, `attachSeat`, `seatStatus`,
3
+ * `stopSeat`, `switchSeat`.
4
+ *
5
+ * It composes the tmux session layer with the operator harness registry and
6
+ * executes no tmux itself; every tmux fact comes back from `tmux.mjs` as an
7
+ * explicit result. The seat is where a human talks to a harness, never the
8
+ * engine that drives a run. `switchSeat` does write one durable file -- the
9
+ * operator brief under the campaign directory -- but nothing here reads or
10
+ * writes a run's lock, status or node snapshots, so swapping the pane cannot
11
+ * touch the controller that drives the run.
12
+ */
13
+ import { join, resolve } from "node:path";
14
+ import { OPERATOR_HARNESSES, canRenderAmbient, detectOperatorHarness, detectOperatorHarnessByCommand, launchArgv } from "./harnesses.mjs";
15
+ import { SEAT_SESSION, createSeatWindow, listSeatWindows, respawnSeatWindow, stopSeatSession, stopSeatWindow, tmuxAvailability } from "./tmux.mjs";
16
+ import { BRIEF_FILE } from "../campaign/layout.mjs";
17
+ import { briefFromState, materializeBrief } from "../campaign/brief.mjs";
18
+ import { readProjectionState } from "../campaign/projection.mjs";
19
+ import { resolveCampaign } from "../campaign/index.mjs";
20
+ import { errorMessage } from "../util.mjs";
21
+
22
+ /** `seat status --json` payload version. */
23
+ const SEAT_STATUS_SCHEMA_VERSION = 1;
24
+
25
+ /** @typedef {import("./tmux.mjs").SeatWindow} SeatWindow */
26
+ /** @typedef {{campaign: string, harness: string|null, canRenderAmbient: boolean, command: string|null, index: number|null}} SeatStatusEntry */
27
+ /** @typedef {{ok: boolean, available: boolean, reason: string|null, session: string, campaign: string, harness: string|null, window: string|null, command: string|null, attachCommand: string|null, stderr: string, message: string|null}} SeatStartResult */
28
+ /** @typedef {{ok: boolean, available: boolean, reason: string|null, session: string, campaign: string|null, harness: string|null, window: string|null, command: string|null, brief: string|null, attachCommand: string|null, stderr: string, message: string|null}} SeatSwitchResult */
29
+
30
+ /**
31
+ * @param {{campaign: string, harness?: string, cwd?: string}} options
32
+ * @returns {SeatStartResult}
33
+ */
34
+ export function startSeat(options) {
35
+ const campaign = options.campaign;
36
+ if (!campaign) return startFailure("campaign_required", "", null, "a campaign id is required");
37
+ const harness = options.harness ?? detectOperatorHarness();
38
+ if (!harness || !Object.prototype.hasOwnProperty.call(OPERATOR_HARNESSES, harness)) {
39
+ const known = Object.keys(OPERATOR_HARNESSES).join(", ");
40
+ return startFailure("unknown_harness", campaign, harness ?? null, `unknown harness ${harness ?? "(none detected)"}; choose one of ${known}`);
41
+ }
42
+ const created = createSeatWindow({
43
+ session: SEAT_SESSION,
44
+ window: campaign,
45
+ argv: /** @type {string[]} */ (launchArgv(harness)),
46
+ harness,
47
+ cwd: resolve(options.cwd ?? "."),
48
+ });
49
+ if (!created.ok) {
50
+ const message = created.available
51
+ ? `tmux could not create the seat window: ${created.reason ?? "tmux_command_failed"}`
52
+ : "tmux is not available; cannot start the seat";
53
+ return { ...startFailure(created.reason ?? "tmux_command_failed", campaign, harness, message), available: created.available, stderr: created.stderr };
54
+ }
55
+ return {
56
+ ok: true,
57
+ available: true,
58
+ reason: null,
59
+ session: SEAT_SESSION,
60
+ campaign,
61
+ harness,
62
+ window: campaign,
63
+ command: created.command,
64
+ attachCommand: attachCommandLine(SEAT_SESSION, campaign, null),
65
+ stderr: "",
66
+ message: null,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * @param {{campaign?: string, ssh?: string}} [options]
72
+ * @returns {{ok: boolean, available: boolean, reason: string|null, command: string|null, target: string, message: string|null}}
73
+ */
74
+ export function attachSeat(options = {}) {
75
+ const target = options.campaign ? `${SEAT_SESSION}:${options.campaign}` : SEAT_SESSION;
76
+ // An SSH attach runs tmux on the far side, so the local binary is irrelevant.
77
+ if (options.ssh) {
78
+ return { ok: true, available: true, reason: null, command: attachCommandLine(SEAT_SESSION, options.campaign ?? null, options.ssh), target, message: null };
79
+ }
80
+ const availability = tmuxAvailability();
81
+ if (!availability.available) {
82
+ return { ok: false, available: false, reason: "tmux_unavailable", command: null, target, message: "tmux is not available; cannot reattach to the seat" };
83
+ }
84
+ return { ok: true, available: true, reason: null, command: attachCommandLine(SEAT_SESSION, options.campaign ?? null, null), target, message: null };
85
+ }
86
+
87
+ /**
88
+ * @returns {{schemaVersion: number, session: string, tmux: boolean, seats: SeatStatusEntry[], reason: string|null}}
89
+ */
90
+ export function seatStatus() {
91
+ const listed = listSeatWindows(SEAT_SESSION);
92
+ if (!listed.available) {
93
+ return { schemaVersion: SEAT_STATUS_SCHEMA_VERSION, session: SEAT_SESSION, tmux: false, seats: [], reason: "tmux_unavailable" };
94
+ }
95
+ return { schemaVersion: SEAT_STATUS_SCHEMA_VERSION, session: SEAT_SESSION, tmux: true, seats: listed.windows.map(seatEntry), reason: listed.reason };
96
+ }
97
+
98
+ /**
99
+ * @param {{campaign?: string}} [options]
100
+ * @returns {{ok: boolean, available: boolean, reason: string|null, stopped: "window"|"session", stderr: string}}
101
+ */
102
+ export function stopSeat(options = {}) {
103
+ const result = options.campaign ? stopSeatWindow(SEAT_SESSION, options.campaign) : stopSeatSession(SEAT_SESSION);
104
+ return { ok: result.ok, available: result.available, reason: result.reason, stopped: options.campaign ? "window" : "session", stderr: result.stderr };
105
+ }
106
+
107
+ /**
108
+ * Swap the harness in a campaign's seat window without touching the run. It
109
+ * materializes the operator brief from the campaign's durable facts, respawns
110
+ * the window on the new harness with that brief as its opening instruction,
111
+ * and returns. The controller lock, the run status and every node snapshot are
112
+ * never opened for writing: the brief lives under the campaign directory, and
113
+ * the only process this kills is the pane's.
114
+ *
115
+ * @param {{campaign?: string, harness?: string, cwd?: string}} options
116
+ * @returns {SeatSwitchResult}
117
+ */
118
+ export function switchSeat(options) {
119
+ const cwd = resolve(options.cwd ?? ".");
120
+ const runsDir = join(cwd, ".runs");
121
+ let resolved;
122
+ try {
123
+ resolved = resolveCampaign(runsDir, options.campaign);
124
+ } catch (error) {
125
+ return switchFailure("campaign_not_found", options.campaign ?? null, options.harness ?? null, errorMessage(error));
126
+ }
127
+ const campaign = resolved.campaign.id;
128
+ const harness = options.harness;
129
+ if (!harness || !Object.prototype.hasOwnProperty.call(OPERATOR_HARNESSES, harness)) {
130
+ const known = Object.keys(OPERATOR_HARNESSES).join(", ");
131
+ return switchFailure("unknown_harness", campaign, harness ?? null, `unknown harness ${harness ?? "(none)"}; choose one of ${known}`);
132
+ }
133
+ const briefPath = join(resolved.path, BRIEF_FILE);
134
+ try {
135
+ const { state } = readProjectionState(resolved.path, resolved.campaign);
136
+ materializeBrief(resolved.path, briefFromState(resolved.campaign, state, []));
137
+ } catch (error) {
138
+ return switchFailure("brief_failed", campaign, harness, `could not write the operator brief: ${errorMessage(error)}`);
139
+ }
140
+ const argv = /** @type {string[]} */ (launchArgv(harness, `Read ${briefPath} before continuing this campaign.`));
141
+ const respawned = respawnSeatWindow({ session: SEAT_SESSION, window: campaign, argv, harness, cwd });
142
+ if (!respawned.ok) {
143
+ const message = respawned.available
144
+ ? `tmux could not respawn the seat window: ${respawned.reason ?? "tmux_command_failed"}`
145
+ : "tmux is not available; cannot switch the seat";
146
+ return { ...switchFailure(respawned.reason ?? "tmux_command_failed", campaign, harness, message), available: respawned.available, stderr: respawned.stderr };
147
+ }
148
+ return {
149
+ ok: true,
150
+ available: true,
151
+ reason: null,
152
+ session: SEAT_SESSION,
153
+ campaign,
154
+ harness,
155
+ window: campaign,
156
+ command: respawned.command,
157
+ brief: briefPath,
158
+ attachCommand: attachCommandLine(SEAT_SESSION, campaign, null),
159
+ stderr: "",
160
+ message: null,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * The line to paste. `attach` must not run `tmux attach` itself: attaching
166
+ * from a child process nests sessions. The SSH form quotes the whole remote
167
+ * command so it survives the local shell.
168
+ *
169
+ * @param {string} session
170
+ * @param {string|null} campaign
171
+ * @param {string|null} ssh
172
+ * @returns {string}
173
+ */
174
+ function attachCommandLine(session, campaign, ssh) {
175
+ const attach = `tmux attach -t ${campaign ? `${session}:${campaign}` : session}`;
176
+ return ssh ? `ssh -t ${ssh} ${JSON.stringify(attach)}` : attach;
177
+ }
178
+
179
+ /**
180
+ * @param {SeatWindow} window
181
+ * @returns {SeatStatusEntry}
182
+ */
183
+ function seatEntry(window) {
184
+ const harness = window.harness ?? detectOperatorHarnessByCommand(window.command);
185
+ return {
186
+ campaign: window.window,
187
+ harness,
188
+ canRenderAmbient: harness ? canRenderAmbient(harness) : false,
189
+ command: window.command,
190
+ index: window.index,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * @param {string} reason
196
+ * @param {string} campaign
197
+ * @param {string|null} harness
198
+ * @param {string} message
199
+ * @returns {SeatStartResult}
200
+ */
201
+ function startFailure(reason, campaign, harness, message) {
202
+ return {
203
+ ok: false,
204
+ available: reason !== "tmux_unavailable",
205
+ reason,
206
+ session: SEAT_SESSION,
207
+ campaign,
208
+ harness,
209
+ window: null,
210
+ command: null,
211
+ attachCommand: null,
212
+ stderr: "",
213
+ message,
214
+ };
215
+ }
216
+
217
+ /**
218
+ * @param {string} reason
219
+ * @param {string|null} campaign
220
+ * @param {string|null} harness
221
+ * @param {string} message
222
+ * @returns {SeatSwitchResult}
223
+ */
224
+ function switchFailure(reason, campaign, harness, message) {
225
+ return {
226
+ ok: false,
227
+ available: reason !== "tmux_unavailable",
228
+ reason,
229
+ session: SEAT_SESSION,
230
+ campaign,
231
+ harness,
232
+ window: null,
233
+ command: null,
234
+ brief: null,
235
+ attachCommand: null,
236
+ stderr: "",
237
+ message,
238
+ };
239
+ }