@mmerterden/multi-agent-pipeline 20.28.0 → 20.30.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 (186) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +19 -6
  3. package/README.tr.md +5 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +7 -7
  7. package/install/catalog-history.json +1 -1
  8. package/install/claude.mjs +122 -1
  9. package/install/codex.mjs +2 -0
  10. package/install/copilot.mjs +13 -0
  11. package/install/templates/claude-hooks.json +30 -1
  12. package/manifest.json +189 -77
  13. package/package.json +2 -1
  14. package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
  15. package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +13 -15
  16. package/pipeline/commands/multi-agent/office-off/SKILL.md +27 -0
  17. package/pipeline/commands/multi-agent/office-on/SKILL.md +50 -0
  18. package/pipeline/commands/multi-agent/office-status/SKILL.md +29 -0
  19. package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
  20. package/pipeline/commands/multi-agent/serve/SKILL.md +4 -2
  21. package/pipeline/commands/multi-agent/stack/SKILL.md +15 -8
  22. package/pipeline/commands/multi-agent/steer/SKILL.md +10 -9
  23. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  24. package/pipeline/contract/CHANGELOG.md +62 -0
  25. package/pipeline/contract/README.md +52 -6
  26. package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
  27. package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
  28. package/pipeline/contract/fixtures/error-unsigned.json +1 -1
  29. package/pipeline/contract/manifest.json +367 -22
  30. package/pipeline/contract/types/index.d.ts +531 -4
  31. package/pipeline/lib/hook-registration.mjs +252 -0
  32. package/pipeline/lib/prefs-value.mjs +44 -0
  33. package/pipeline/lib/run-completion.mjs +148 -0
  34. package/pipeline/multi-agent-refs/analysis/render.md +4 -4
  35. package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -4
  36. package/pipeline/multi-agent-refs/features/doctor.md +11 -0
  37. package/pipeline/multi-agent-refs/features/office-threat-model.md +91 -0
  38. package/pipeline/multi-agent-refs/help/en.md +3 -0
  39. package/pipeline/multi-agent-refs/help/tr.md +3 -0
  40. package/pipeline/multi-agent-refs/phases/operations.md +17 -0
  41. package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
  42. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
  43. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
  44. package/pipeline/multi-agent-refs/phases/phase-3-review.md +2 -2
  45. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
  46. package/pipeline/multi-agent-refs/phases/phase-5-report.md +5 -4
  47. package/pipeline/office/NOTICE.md +42 -0
  48. package/pipeline/office/assets/BIN.png +0 -0
  49. package/pipeline/office/assets/BOOKSHELF.png +0 -0
  50. package/pipeline/office/assets/CACTUS.png +0 -0
  51. package/pipeline/office/assets/CLOCK.png +0 -0
  52. package/pipeline/office/assets/COFFEE.png +0 -0
  53. package/pipeline/office/assets/COFFEE_TABLE.png +0 -0
  54. package/pipeline/office/assets/CUSHIONED_BENCH.png +0 -0
  55. package/pipeline/office/assets/CUSHIONED_CHAIR_BACK.png +0 -0
  56. package/pipeline/office/assets/CUSHIONED_CHAIR_FRONT.png +0 -0
  57. package/pipeline/office/assets/CUSHIONED_CHAIR_SIDE.png +0 -0
  58. package/pipeline/office/assets/DESK_FRONT.png +0 -0
  59. package/pipeline/office/assets/DESK_SIDE.png +0 -0
  60. package/pipeline/office/assets/DOUBLE_BOOKSHELF.png +0 -0
  61. package/pipeline/office/assets/HANGING_PLANT.png +0 -0
  62. package/pipeline/office/assets/LARGE_PAINTING.png +0 -0
  63. package/pipeline/office/assets/LARGE_PLANT.png +0 -0
  64. package/pipeline/office/assets/PC_BACK.png +0 -0
  65. package/pipeline/office/assets/PC_FRONT_OFF.png +0 -0
  66. package/pipeline/office/assets/PC_FRONT_ON_1.png +0 -0
  67. package/pipeline/office/assets/PC_FRONT_ON_2.png +0 -0
  68. package/pipeline/office/assets/PC_FRONT_ON_3.png +0 -0
  69. package/pipeline/office/assets/PC_SIDE.png +0 -0
  70. package/pipeline/office/assets/PLANT.png +0 -0
  71. package/pipeline/office/assets/PLANT_2.png +0 -0
  72. package/pipeline/office/assets/POT.png +0 -0
  73. package/pipeline/office/assets/SMALL_PAINTING.png +0 -0
  74. package/pipeline/office/assets/SMALL_PAINTING_2.png +0 -0
  75. package/pipeline/office/assets/SMALL_TABLE_FRONT.png +0 -0
  76. package/pipeline/office/assets/SMALL_TABLE_SIDE.png +0 -0
  77. package/pipeline/office/assets/SOFA_BACK.png +0 -0
  78. package/pipeline/office/assets/SOFA_FRONT.png +0 -0
  79. package/pipeline/office/assets/SOFA_SIDE.png +0 -0
  80. package/pipeline/office/assets/TABLE_FRONT.png +0 -0
  81. package/pipeline/office/assets/WHITEBOARD.png +0 -0
  82. package/pipeline/office/assets/WOODEN_BENCH.png +0 -0
  83. package/pipeline/office/assets/WOODEN_CHAIR_BACK.png +0 -0
  84. package/pipeline/office/assets/WOODEN_CHAIR_FRONT.png +0 -0
  85. package/pipeline/office/assets/WOODEN_CHAIR_SIDE.png +0 -0
  86. package/pipeline/office/assets/char_0.png +0 -0
  87. package/pipeline/office/assets/char_1.png +0 -0
  88. package/pipeline/office/assets/char_2.png +0 -0
  89. package/pipeline/office/assets/char_3.png +0 -0
  90. package/pipeline/office/assets/char_4.png +0 -0
  91. package/pipeline/office/assets/char_5.png +0 -0
  92. package/pipeline/office/assets/floor_0.png +0 -0
  93. package/pipeline/office/assets/floor_1.png +0 -0
  94. package/pipeline/office/assets/floor_2.png +0 -0
  95. package/pipeline/office/assets/floor_3.png +0 -0
  96. package/pipeline/office/assets/floor_4.png +0 -0
  97. package/pipeline/office/assets/floor_5.png +0 -0
  98. package/pipeline/office/assets/floor_6.png +0 -0
  99. package/pipeline/office/assets/floor_7.png +0 -0
  100. package/pipeline/office/assets/floor_8.png +0 -0
  101. package/pipeline/office/assets/wall_0.png +0 -0
  102. package/pipeline/office/index.html +5319 -0
  103. package/pipeline/preferences-template.json +7 -0
  104. package/pipeline/rules/figma-pipeline.md +2 -0
  105. package/pipeline/schemas/activity-chat.schema.json +78 -0
  106. package/pipeline/schemas/activity-roles.json +48 -0
  107. package/pipeline/schemas/activity-roles.schema.json +49 -0
  108. package/pipeline/schemas/activity.schema.json +429 -0
  109. package/pipeline/schemas/agent-state.schema.json +20 -5
  110. package/pipeline/schemas/autopilot-on-request.schema.json +33 -0
  111. package/pipeline/schemas/autopilot-on.schema.json +111 -0
  112. package/pipeline/schemas/contract-error.schema.json +33 -3
  113. package/pipeline/schemas/issue-maturity.schema.json +68 -0
  114. package/pipeline/schemas/ledger.schema.json +202 -0
  115. package/pipeline/schemas/permissions.schema.json +47 -0
  116. package/pipeline/schemas/phases.json +3 -2
  117. package/pipeline/schemas/phases.schema.json +5 -1
  118. package/pipeline/schemas/prefs.schema.json +56 -0
  119. package/pipeline/schemas/prs.schema.json +73 -0
  120. package/pipeline/schemas/review-verdict-request.schema.json +20 -0
  121. package/pipeline/schemas/review-verdict.schema.json +62 -0
  122. package/pipeline/schemas/routine-delete.schema.json +59 -0
  123. package/pipeline/schemas/routines.schema.json +38 -0
  124. package/pipeline/schemas/run-evidence.schema.json +29 -0
  125. package/pipeline/schemas/run-results.schema.json +218 -0
  126. package/pipeline/schemas/runs-index.schema.json +1 -1
  127. package/pipeline/schemas/service-read.schema.json +58 -0
  128. package/pipeline/schemas/stack-change.schema.json +82 -0
  129. package/pipeline/schemas/stack-request.schema.json +33 -0
  130. package/pipeline/schemas/stack.schema.json +59 -0
  131. package/pipeline/schemas/steer-request.schema.json +25 -0
  132. package/pipeline/schemas/steer.schema.json +59 -0
  133. package/pipeline/schemas/tool-call-request.schema.json +19 -0
  134. package/pipeline/schemas/tool-call.schema.json +47 -0
  135. package/pipeline/schemas/tools.schema.json +60 -0
  136. package/pipeline/scripts/_active-run.mjs +158 -0
  137. package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
  138. package/pipeline/scripts/_cost.mjs +66 -1
  139. package/pipeline/scripts/_guard_rules.py +605 -0
  140. package/pipeline/scripts/_office-smoke-lib.sh +115 -0
  141. package/pipeline/scripts/_spawn-async.mjs +73 -0
  142. package/pipeline/scripts/_transcript-tail.mjs +274 -0
  143. package/pipeline/scripts/activity-snapshot.mjs +1027 -0
  144. package/pipeline/scripts/agent-guard.py +118 -7
  145. package/pipeline/scripts/agent-guard.sh +50 -3
  146. package/pipeline/scripts/argv-secret-scan.mjs +114 -0
  147. package/pipeline/scripts/autopilot-control.mjs +214 -9
  148. package/pipeline/scripts/autopilot-runner.mjs +8 -2
  149. package/pipeline/scripts/confirm-command.mjs +70 -0
  150. package/pipeline/scripts/contract-server.mjs +1178 -48
  151. package/pipeline/scripts/cost-analyze.mjs +2 -53
  152. package/pipeline/scripts/cost-table.json +1 -0
  153. package/pipeline/scripts/doctor.mjs +54 -0
  154. package/pipeline/scripts/evidence-gate.mjs +8 -4
  155. package/pipeline/scripts/gate-ledger.mjs +163 -7
  156. package/pipeline/scripts/office-build.mjs +89 -0
  157. package/pipeline/scripts/office-control.mjs +383 -0
  158. package/pipeline/scripts/office-ledger.mjs +409 -0
  159. package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
  160. package/pipeline/scripts/pre-commit-check.sh +12 -0
  161. package/pipeline/scripts/routine-registry.mjs +42 -6
  162. package/pipeline/scripts/run-results.mjs +373 -0
  163. package/pipeline/scripts/run-scope-guard.mjs +188 -0
  164. package/pipeline/scripts/run-scope-guard.sh +23 -0
  165. package/pipeline/scripts/run-stop-guard.mjs +246 -0
  166. package/pipeline/scripts/run-stop-guard.sh +25 -0
  167. package/pipeline/scripts/service-read.mjs +244 -0
  168. package/pipeline/scripts/stack-toggle.mjs +285 -0
  169. package/pipeline/scripts/steer-run.mjs +97 -0
  170. package/pipeline/scripts/toolkit-tools.mjs +599 -0
  171. package/pipeline/scripts/uninstall.mjs +4 -0
  172. package/pipeline/scripts/validate-state.mjs +26 -0
  173. package/pipeline/scripts/work-items.mjs +304 -0
  174. package/pipeline/scripts/write-state.mjs +114 -0
  175. package/pipeline/skills/.skill-manifest.json +19 -7
  176. package/pipeline/skills/.skills-index.json +34 -1
  177. package/pipeline/skills/shared/README.md +6 -3
  178. package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +13 -15
  179. package/pipeline/skills/shared/core/multi-agent-office-off/SKILL.md +16 -0
  180. package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +22 -0
  181. package/pipeline/skills/shared/core/multi-agent-office-status/SKILL.md +17 -0
  182. package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +4 -2
  183. package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +14 -6
  184. package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +10 -9
  185. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  186. package/pipeline/skills/skills-index.md +4 -1
@@ -0,0 +1,409 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file office-ledger.mjs - what the agents on this machine cost: tokens and
4
+ * estimated USD for today, this week (from Monday) and this month, in local
5
+ * time, by run, repository, model and phase.
6
+ *
7
+ * Two sources, never added together for one run:
8
+ *
9
+ * tracker-state the pipeline's own ledger, `tokens_in/out/cached` per phase
10
+ * in a run's tracker-state.json, priced at the phase's rung;
11
+ * transcript the host's usage records (`message.usage` on each assistant
12
+ * record, counted once per message id) in
13
+ * ~/.claude/projects/<slug>/<session>.jsonl and its
14
+ * subagents/agent-*.jsonl, priced at the model id.
15
+ *
16
+ * A run whose tracker carries tokens is counted from the tracker and its
17
+ * transcripts are left out, so nothing is counted twice. A run without tracker
18
+ * tokens is counted from the transcripts tied to it (by the session id it
19
+ * recorded, else by working directory and branch, as activity-snapshot.mjs
20
+ * ties them). A run with neither is `not-measured`: its numbers are null and
21
+ * it is listed by task id, never folded in as 0. A session no run claims is
22
+ * counted apart as outside the pipeline.
23
+ *
24
+ * Prices come from cost-table.json (_cost.mjs does the arithmetic, a cache
25
+ * write at 1.25 times input). `verifiedAt` in the table dates them; past
26
+ * PRICE_STALE_DAYS the document carries a warning naming that date. These are
27
+ * estimates at list price, not an invoice.
28
+ *
29
+ * Read-only. Each transcript's usage rows are kept in memory keyed by the
30
+ * file's size and mtime, so a second call re-reads only files that changed.
31
+ *
32
+ * Usage:
33
+ * office-ledger.mjs [--json]
34
+ *
35
+ * TRANSCRIPTS_ROOT overrides ~/.claude/projects.
36
+ *
37
+ * Exit: 0 printed, 2 usage.
38
+ *
39
+ * @module pipeline/scripts/office-ledger
40
+ */
41
+
42
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
43
+ import { join } from "node:path";
44
+ import { costUsd, modelRateLookup, priceUsage } from "./_cost.mjs";
45
+ import { readJsonl } from "./_jsonl.mjs";
46
+ import { matchRun, modelLabel, phaseAt, repoName, runExtras } from "./activity-snapshot.mjs";
47
+ import { defaultProjectsRoot } from "./transcript-index.mjs";
48
+ import { buildIndex } from "./runs-index.mjs";
49
+ import { readRegularJson } from "../lib/regular-file.mjs";
50
+ import { runMain } from "../lib/fatal.mjs";
51
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
52
+
53
+ export const CONTRACT_VERSION = "1.0.0";
54
+ export const PRICE_STALE_DAYS = 60;
55
+ const OUTSIDE = "outside the pipeline";
56
+ const UNKNOWN = "unknown";
57
+ const TABLE = JSON.parse(readFileSync(new URL("./cost-table.json", import.meta.url), "utf8"));
58
+ const rateFor = modelRateLookup(TABLE);
59
+ const PHASE_NAMES = Object.fromEntries(
60
+ JSON.parse(readFileSync(new URL("../schemas/phases.json", import.meta.url), "utf8")).phases.map(
61
+ (p) => [String(p.id), p.name],
62
+ ),
63
+ );
64
+
65
+ /**
66
+ * How old the price table is. Stale past PRICE_STALE_DAYS, and stale when it
67
+ * carries no date at all, since nothing then says the prices were checked.
68
+ */
69
+ export function priceAge(verifiedAt, now = Date.now()) {
70
+ const t = Date.parse(verifiedAt ?? "");
71
+ if (!Number.isFinite(t)) {
72
+ return {
73
+ ageDays: null,
74
+ stale: true,
75
+ warning: "cost-table.json carries no verifiedAt date; the prices may be out of date",
76
+ };
77
+ }
78
+ const ageDays = Math.floor((now - t) / 86_400_000);
79
+ const stale = ageDays > PRICE_STALE_DAYS;
80
+ return {
81
+ ageDays,
82
+ stale,
83
+ warning: stale
84
+ ? `cost-table.json prices were last verified ${verifiedAt} (${ageDays} days ago); the estimates may be off`
85
+ : null,
86
+ };
87
+ }
88
+
89
+ /** Local-time starts of today, this week (Monday) and this month. */
90
+ export function windowStarts(now) {
91
+ const d = new Date(now);
92
+ const today = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
93
+ const dow = (d.getDay() + 6) % 7;
94
+ const week = new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow).getTime();
95
+ const month = new Date(d.getFullYear(), d.getMonth(), 1).getTime();
96
+ return { today, week, month };
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Transcript usage rows
101
+
102
+ const fileCache = new Map();
103
+
104
+ const listDir = (dir) => {
105
+ try {
106
+ return readdirSync(dir);
107
+ } catch {
108
+ return [];
109
+ }
110
+ };
111
+
112
+ /** Every session file and its subagent files changed since `since`. */
113
+ function transcriptFiles(projectsRoot, since) {
114
+ const out = [];
115
+ for (const slug of listDir(projectsRoot)) {
116
+ const dir = join(projectsRoot, slug);
117
+ for (const f of listDir(dir)) {
118
+ if (!f.endsWith(".jsonl")) continue;
119
+ const sessionId = f.slice(0, -6);
120
+ const files = [join(dir, f)];
121
+ const sub = join(dir, sessionId, "subagents");
122
+ for (const s of listDir(sub)) if (/^agent-.*\.jsonl$/.test(s)) files.push(join(sub, s));
123
+ for (const path of files) {
124
+ let st;
125
+ try {
126
+ st = statSync(path);
127
+ } catch {
128
+ continue;
129
+ }
130
+ if (st.isFile() && st.mtimeMs >= since)
131
+ out.push({ path, sessionId, size: st.size, mtime: st.mtimeMs });
132
+ }
133
+ }
134
+ }
135
+ return out;
136
+ }
137
+
138
+ /** The usage rows of one file, and the working directory and branch it ran in. */
139
+ async function rowsOf(file) {
140
+ const hit = fileCache.get(file.path);
141
+ if (hit && hit.size === file.size && hit.mtime === file.mtime) return hit;
142
+ const rows = [];
143
+ let cwd = null;
144
+ let branch = null;
145
+ for await (const row of readJsonl(file.path, { maxBytes: Infinity })) {
146
+ // Only assistant records carry usage; every other type (api-request
147
+ // records included) is left as soon as its type is known.
148
+ if (!row.record) continue;
149
+ const rec = row.record;
150
+ if (rec.type !== "assistant") {
151
+ if (!cwd && typeof rec.cwd === "string") cwd = rec.cwd;
152
+ if (!branch && typeof rec.gitBranch === "string") branch = rec.gitBranch;
153
+ continue;
154
+ }
155
+ if (!cwd && typeof rec.cwd === "string") cwd = rec.cwd;
156
+ if (!branch && typeof rec.gitBranch === "string") branch = rec.gitBranch;
157
+ const u = rec.message?.usage;
158
+ const ts = Date.parse(rec.timestamp ?? "");
159
+ if (!u || !Number.isFinite(ts)) continue;
160
+ rows.push({
161
+ id: typeof rec.message?.id === "string" ? rec.message.id : null,
162
+ ts,
163
+ model: typeof rec.message?.model === "string" ? rec.message.model : null,
164
+ usage: {
165
+ input_tokens: Number(u.input_tokens || 0),
166
+ output_tokens: Number(u.output_tokens || 0),
167
+ cache_read_input_tokens: Number(u.cache_read_input_tokens || 0),
168
+ cache_creation_input_tokens: Number(u.cache_creation_input_tokens || 0),
169
+ },
170
+ });
171
+ }
172
+ const entry = { size: file.size, mtime: file.mtime, rows, cwd, branch };
173
+ fileCache.set(file.path, entry);
174
+ return entry;
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Amounts
179
+
180
+ const zeroTokens = () => ({ in: 0, out: 0, cacheRead: 0, cacheWrite: 0 });
181
+
182
+ function newAmount() {
183
+ return { tokens: null, usd: null, sources: new Set(), unpriced: 0, measured: false };
184
+ }
185
+
186
+ function addTo(a, entry) {
187
+ a.measured = true;
188
+ a.sources.add(entry.source);
189
+ a.tokens ??= zeroTokens();
190
+ for (const k of ["in", "out", "cacheRead", "cacheWrite"]) a.tokens[k] += entry.tokens[k];
191
+ if (entry.usd === null) a.unpriced += 1;
192
+ else a.usd = (a.usd ?? 0) + entry.usd;
193
+ }
194
+
195
+ function finish(a, { measuredEmpty = false } = {}) {
196
+ if (!a.measured) {
197
+ return measuredEmpty
198
+ ? { tokens: zeroTokens(), usd: 0, source: "transcript", unpriced: 0 }
199
+ : { tokens: null, usd: null, source: "not-measured", unpriced: 0 };
200
+ }
201
+ const source = a.sources.size > 1 ? "mixed" : [...a.sources][0];
202
+ const usd = a.usd === null ? null : Number(a.usd.toFixed(6));
203
+ return { tokens: a.tokens, usd, source, unpriced: a.unpriced };
204
+ }
205
+
206
+ function readTracker(run) {
207
+ if (!run.dir) return null;
208
+ for (const p of [
209
+ join(run.dir, "tracker-state.json"),
210
+ join(run.dir, "artifacts", "tracker-state.json"),
211
+ ]) {
212
+ if (existsSync(p)) return readRegularJson(p, null);
213
+ }
214
+ return null;
215
+ }
216
+
217
+ /** One priced entry per tracker phase that carries tokens. */
218
+ function trackerEntries(run, repo) {
219
+ const tracker = readTracker(run);
220
+ const out = [];
221
+ for (const p of Array.isArray(tracker?.phases) ? tracker.phases : []) {
222
+ const tin = Math.round(Number(p.tokens_in ?? 0) || 0);
223
+ const tout = Math.round(Number(p.tokens_out ?? 0) || 0);
224
+ const tc = Math.round(Number(p.tokens_cached ?? 0) || 0);
225
+ if (!tin && !tout && !tc) continue;
226
+ const at =
227
+ Date.parse(p.completed_at ?? "") ||
228
+ Date.parse(p.started_at ?? "") ||
229
+ Date.parse(tracker.started_at ?? "") ||
230
+ null;
231
+ const usd = costUsd(rateFor(p.model), tin, tout, tc);
232
+ out.push({
233
+ source: "tracker-state",
234
+ run: run.taskId,
235
+ repo,
236
+ model: modelLabel(p.model) ?? UNKNOWN,
237
+ phase: p.name || PHASE_NAMES[String(p.id)] || UNKNOWN,
238
+ at,
239
+ tokens: { in: tin, out: tout, cacheRead: tc, cacheWrite: 0 },
240
+ usd: usd === null ? null : usd,
241
+ });
242
+ }
243
+ return out;
244
+ }
245
+
246
+ /** Was the run doing anything after `since`? */
247
+ function activeSince(run, since) {
248
+ const times = [Date.parse(run.startedAt ?? "")];
249
+ for (const p of run.phases ?? [])
250
+ times.push(Date.parse(p.startedAt ?? ""), Date.parse(p.completedAt ?? ""));
251
+ return times.some((t) => Number.isFinite(t) && t >= since);
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // The ledger
256
+
257
+ /**
258
+ * @param {{runs?: object[], projectsRoot?: string, now?: number, verifiedAt?: string|null}} [opts]
259
+ */
260
+ export async function officeLedger(opts = {}) {
261
+ const now = opts.now ?? Date.now();
262
+ const starts = windowStarts(now);
263
+ const since = Math.min(starts.week, starts.month);
264
+ const runs = (opts.runs ?? buildIndex()).map((r) => ({ ...r, extras: runExtras(r) }));
265
+ const projectsRoot = opts.projectsRoot ?? defaultProjectsRoot();
266
+ const transcriptsReadable = existsSync(projectsRoot);
267
+
268
+ const entries = [];
269
+ const trackerMeasured = new Set();
270
+ const repoOf = (run) => run.project ?? repoName(run.extras?.projectRoot) ?? UNKNOWN;
271
+ for (const run of runs) {
272
+ const list = trackerEntries(run, repoOf(run));
273
+ if (list.length) trackerMeasured.add(run.taskId);
274
+ entries.push(...list);
275
+ }
276
+
277
+ const seen = new Set();
278
+ const transcriptMeasured = new Set();
279
+ for (const file of transcriptFiles(projectsRoot, since)) {
280
+ const { rows, cwd, branch } = await rowsOf(file);
281
+ const hit = matchRun(file.sessionId, cwd, branch, runs);
282
+ const run = hit.run;
283
+ if (run && trackerMeasured.has(run.taskId)) continue;
284
+ for (const r of rows) {
285
+ if (r.ts < since) continue;
286
+ if (r.id) {
287
+ if (seen.has(r.id)) continue;
288
+ seen.add(r.id);
289
+ }
290
+ const rate = rateFor(r.model);
291
+ const phaseId = run ? phaseAt(run, r.ts) : null;
292
+ if (run) transcriptMeasured.add(run.taskId);
293
+ entries.push({
294
+ source: "transcript",
295
+ run: run ? run.taskId : null,
296
+ repo: run ? repoOf(run) : (repoName(cwd) ?? UNKNOWN),
297
+ model: modelLabel(r.model) ?? UNKNOWN,
298
+ phase: run
299
+ ? phaseId === null
300
+ ? UNKNOWN
301
+ : (PHASE_NAMES[String(phaseId)] ?? String(phaseId))
302
+ : OUTSIDE,
303
+ at: r.ts,
304
+ tokens: {
305
+ in: r.usage.input_tokens,
306
+ out: r.usage.output_tokens,
307
+ cacheRead: r.usage.cache_read_input_tokens,
308
+ cacheWrite: r.usage.cache_creation_input_tokens,
309
+ },
310
+ usd: rate ? priceUsage(rate, r.usage) : null,
311
+ });
312
+ }
313
+ }
314
+
315
+ const windows = {};
316
+ for (const [name, start] of Object.entries(starts)) {
317
+ const inWindow = entries.filter((e) => e.at !== null && e.at >= start && e.at <= now);
318
+ const total = newAmount();
319
+ const outside = newAmount();
320
+ const groups = { byRun: new Map(), byRepo: new Map(), byModel: new Map(), byPhase: new Map() };
321
+ const bump = (map, key, repo, e) => {
322
+ if (!map.has(key)) map.set(key, { amount: newAmount(), repo });
323
+ addTo(map.get(key).amount, e);
324
+ };
325
+ for (const e of inWindow) {
326
+ addTo(total, e);
327
+ if (!e.run) addTo(outside, e);
328
+ else bump(groups.byRun, e.run, e.repo, e);
329
+ bump(groups.byRepo, e.repo, null, e);
330
+ bump(groups.byModel, e.model, null, e);
331
+ bump(groups.byPhase, e.phase, null, e);
332
+ }
333
+ const notMeasured = runs
334
+ .filter(
335
+ (r) =>
336
+ !trackerMeasured.has(r.taskId) &&
337
+ !transcriptMeasured.has(r.taskId) &&
338
+ activeSince(r, start),
339
+ )
340
+ .map((r) => r.taskId)
341
+ .sort();
342
+ for (const id of notMeasured) {
343
+ const run = runs.find((r) => r.taskId === id);
344
+ groups.byRun.set(id, { amount: newAmount(), repo: repoOf(run) });
345
+ }
346
+ const rows = (map, withRepo) =>
347
+ [...map.entries()]
348
+ .map(([key, { amount, repo }]) => ({
349
+ key: String(key),
350
+ ...(withRepo ? { repo } : {}),
351
+ ...finish(amount),
352
+ }))
353
+ .sort((a, b) => (b.usd ?? -1) - (a.usd ?? -1) || (a.key < b.key ? -1 : 1));
354
+ windows[name] = {
355
+ since: new Date(start).toISOString(),
356
+ total: finish(total, { measuredEmpty: transcriptsReadable }),
357
+ outsidePipeline: finish(outside, { measuredEmpty: transcriptsReadable }),
358
+ notMeasured,
359
+ byRun: rows(groups.byRun, true),
360
+ byRepo: rows(groups.byRepo, false),
361
+ byModel: rows(groups.byModel, false),
362
+ byPhase: rows(groups.byPhase, false),
363
+ };
364
+ }
365
+
366
+ const verifiedAt = opts.verifiedAt === undefined ? (TABLE.verifiedAt ?? null) : opts.verifiedAt;
367
+ const age = priceAge(verifiedAt, now);
368
+ return {
369
+ contractVersion: CONTRACT_VERSION,
370
+ generatedAt: new Date(now).toISOString(),
371
+ prices: { table: "cost-table.json", verifiedAt, ...age },
372
+ windows,
373
+ };
374
+ }
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // CLI
378
+
379
+ const money = (a) => (a.usd === null ? "not measured" : `$${a.usd.toFixed(2)}`);
380
+
381
+ function renderHuman(doc) {
382
+ const L = [];
383
+ for (const [name, w] of Object.entries(doc.windows)) {
384
+ L.push(`${name}: ${money(w.total)} (${w.total.source})`);
385
+ for (const r of w.byRun) L.push(` ${r.key}: ${money(r)} (${r.source})`);
386
+ L.push(` ${OUTSIDE}: ${money(w.outsidePipeline)} (${w.outsidePipeline.source})`);
387
+ }
388
+ if (doc.prices.warning) L.push(`note: ${doc.prices.warning}`);
389
+ L.push("Estimated at list price from cost-table.json, not an invoice.");
390
+ return L.join("\n") + "\n";
391
+ }
392
+
393
+ async function main(argv) {
394
+ if (argv.some((a) => a !== "--json")) {
395
+ process.stderr.write("usage: office-ledger.mjs [--json]\n");
396
+ return 2;
397
+ }
398
+ const doc = await officeLedger();
399
+ process.stdout.write(
400
+ argv.includes("--json") ? JSON.stringify(doc, null, 2) + "\n" : renderHuman(doc),
401
+ );
402
+ return 0;
403
+ }
404
+
405
+ if (invokedDirectly(import.meta.url)) {
406
+ runMain("office-ledger", async () => {
407
+ process.exitCode = await main(process.argv.slice(2));
408
+ });
409
+ }
@@ -220,15 +220,16 @@ async function main(args) {
220
220
  const UNREADABLE = gatesOn ? 3 : 2;
221
221
  const ZERO_STEPS = gatesOn ? 1 : 2;
222
222
 
223
+ // Recorded attended too (gate-ledger.mjs ATTENDED_RECORDED, marked
224
+ // `attended: true`): lib/run-completion.mjs reads the verdict before a run
225
+ // may write `status: complete`.
223
226
  const finish = async (code, result, detail = {}) => {
224
- if (gatesOn) {
225
- await recordQuietly(
226
- "plan-coverage",
227
- LEDGER[result.verdict] ?? "fail",
228
- { verdict: result.verdict, ...detail },
229
- { repo: root, statePath: statePath ?? null },
230
- );
231
- }
227
+ await recordQuietly(
228
+ "plan-coverage",
229
+ LEDGER[result.verdict] ?? "fail",
230
+ { verdict: result.verdict, ...detail },
231
+ { repo: root, statePath: statePath ?? null },
232
+ );
232
233
  return code;
233
234
  };
234
235
 
@@ -715,6 +715,18 @@ if [ "$IS_COMMIT_HOOK" = "1" ]; then
715
715
  owned_path_gate || exit 2
716
716
  fi
717
717
 
718
+ # Attended commit inside a pipeline run: the run's gate ledger should hold a
719
+ # passing build and test verdict for HEAD (evidence-gate.mjs records both in an
720
+ # attended run too). prefs.global.attendedCommitGate.mode: warn (default) says so
721
+ # on stderr and lets the commit through, block refuses it, off skips the check.
722
+ # Outside a run gate-ledger.mjs says nothing; any error of its own is not a
723
+ # reason to refuse a commit a person is watching.
724
+ if [ "$IS_COMMIT_HOOK" = "1" ] && ! ma_unattended && command -v node >/dev/null 2>&1; then
725
+ printf '%s' "${HOOK_INPUT:-}" \
726
+ | node "$PRE_COMMIT_DIR/gate-ledger.mjs" evidence --repo "${REPO_ROOT:-$PWD}" --hook-input - >/dev/null
727
+ [ "$?" = "4" ] && exit 2
728
+ fi
729
+
718
730
  # Unattended commit: the verification gates must have passed for this HEAD.
719
731
  # The environment is the hook's own, inherited from the process the launcher
720
732
  # started, so the run cannot talk its way past it by editing its state. An
@@ -24,11 +24,17 @@
24
24
  // node routine-registry.mjs remove --name <n> [--home <dir>]
25
25
  //
26
26
  // Exit: 0 ok, 1 error (validation / collision / not-found), 64 usage.
27
+ //
28
+ // listRoutines() and removeRoutine() are exported for contract-server.mjs
29
+ // (GET /v1/routines, DELETE /v1/routines/{name}), so the panel and the
30
+ // terminal commands run the same code; they throw RoutineError where the CLI
31
+ // prints and exits 1.
27
32
 
28
33
  import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, renameSync } from "node:fs";
29
34
  import { join, dirname } from "node:path";
30
35
  import { runMain } from "../lib/fatal.mjs";
31
36
  import { hostCommand } from "../lib/user-facing.mjs";
37
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
32
38
 
33
39
  const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
34
40
  const MAX_NAME = 40;
@@ -54,9 +60,10 @@ function parseArgs(argv) {
54
60
  return out;
55
61
  }
56
62
 
63
+ export class RoutineError extends Error {}
64
+
57
65
  function fail(msg) {
58
- console.error(`routine-registry: ${msg}`);
59
- process.exit(1);
66
+ throw new RoutineError(msg);
60
67
  }
61
68
 
62
69
  function homeDir(args) {
@@ -184,8 +191,20 @@ function cmdAdd(home, args) {
184
191
  );
185
192
  }
186
193
 
187
- function cmdRemove(home, name) {
188
- if (!name) fail("--name is required");
194
+ /** The registered routines, as `list --json` prints them. */
195
+ export function listRoutines(home) {
196
+ return getRoutines(readPrefs(home));
197
+ }
198
+
199
+ /**
200
+ * Remove a routine: its local-only command directory and its registry entry.
201
+ * A shipped command directory is refused, and so is a name that is neither
202
+ * registered nor on disk.
203
+ *
204
+ * @returns {{name: string, removedDir: boolean, removedEntry: boolean}}
205
+ */
206
+ export function removeRoutine(home, name) {
207
+ if (!name || !NAME_RE.test(name) || name.length > MAX_NAME) fail(`invalid routine name: ${name}`);
189
208
  const prefs = readPrefs(home);
190
209
  const routines = getRoutines(prefs);
191
210
  const idx = routines.findIndex((r) => r.name === name);
@@ -195,12 +214,19 @@ function cmdRemove(home, name) {
195
214
  fail(`"${name}" is a shipped command, not a routine; refusing to remove`);
196
215
  }
197
216
  if (idx < 0 && !existsSync(dir)) fail(`no routine named "${name}"`);
198
- if (existsSync(dir) && isLocalOnlyDir(dir)) rmSync(dir, { recursive: true, force: true });
217
+ const removedDir = existsSync(dir) && isLocalOnlyDir(dir);
218
+ if (removedDir) rmSync(dir, { recursive: true, force: true });
199
219
  if (idx >= 0) {
200
220
  routines.splice(idx, 1);
201
221
  prefs.global.routines = routines;
202
222
  writePrefs(home, prefs);
203
223
  }
224
+ return { name, removedDir, removedEntry: idx >= 0 };
225
+ }
226
+
227
+ function cmdRemove(home, name) {
228
+ if (!name) fail("--name is required");
229
+ removeRoutine(home, name);
204
230
  console.log(
205
231
  `routine-registry: removed "${name}" (clears from autocomplete on next session reload)`,
206
232
  );
@@ -226,4 +252,14 @@ function main() {
226
252
  }
227
253
  }
228
254
 
229
- runMain("routine-registry", main);
255
+ if (invokedDirectly(import.meta.url)) {
256
+ runMain("routine-registry", () => {
257
+ try {
258
+ main();
259
+ } catch (err) {
260
+ if (!(err instanceof RoutineError)) throw err;
261
+ console.error(`routine-registry: ${err.message}`);
262
+ process.exit(1);
263
+ }
264
+ });
265
+ }