@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.
- package/CHANGELOG.md +19 -0
- package/README.md +19 -6
- package/README.tr.md +5 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +7 -7
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +122 -1
- package/install/codex.mjs +2 -0
- package/install/copilot.mjs +13 -0
- package/install/templates/claude-hooks.json +30 -1
- package/manifest.json +189 -77
- package/package.json +2 -1
- package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
- package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +13 -15
- package/pipeline/commands/multi-agent/office-off/SKILL.md +27 -0
- package/pipeline/commands/multi-agent/office-on/SKILL.md +50 -0
- package/pipeline/commands/multi-agent/office-status/SKILL.md +29 -0
- package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
- package/pipeline/commands/multi-agent/serve/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/stack/SKILL.md +15 -8
- package/pipeline/commands/multi-agent/steer/SKILL.md +10 -9
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/contract/CHANGELOG.md +62 -0
- package/pipeline/contract/README.md +52 -6
- package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
- package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
- package/pipeline/contract/fixtures/error-unsigned.json +1 -1
- package/pipeline/contract/manifest.json +367 -22
- package/pipeline/contract/types/index.d.ts +531 -4
- package/pipeline/lib/hook-registration.mjs +252 -0
- package/pipeline/lib/prefs-value.mjs +44 -0
- package/pipeline/lib/run-completion.mjs +148 -0
- package/pipeline/multi-agent-refs/analysis/render.md +4 -4
- package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -4
- package/pipeline/multi-agent-refs/features/doctor.md +11 -0
- package/pipeline/multi-agent-refs/features/office-threat-model.md +91 -0
- package/pipeline/multi-agent-refs/help/en.md +3 -0
- package/pipeline/multi-agent-refs/help/tr.md +3 -0
- package/pipeline/multi-agent-refs/phases/operations.md +17 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +5 -4
- package/pipeline/office/NOTICE.md +42 -0
- package/pipeline/office/assets/BIN.png +0 -0
- package/pipeline/office/assets/BOOKSHELF.png +0 -0
- package/pipeline/office/assets/CACTUS.png +0 -0
- package/pipeline/office/assets/CLOCK.png +0 -0
- package/pipeline/office/assets/COFFEE.png +0 -0
- package/pipeline/office/assets/COFFEE_TABLE.png +0 -0
- package/pipeline/office/assets/CUSHIONED_BENCH.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_BACK.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_FRONT.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_SIDE.png +0 -0
- package/pipeline/office/assets/DESK_FRONT.png +0 -0
- package/pipeline/office/assets/DESK_SIDE.png +0 -0
- package/pipeline/office/assets/DOUBLE_BOOKSHELF.png +0 -0
- package/pipeline/office/assets/HANGING_PLANT.png +0 -0
- package/pipeline/office/assets/LARGE_PAINTING.png +0 -0
- package/pipeline/office/assets/LARGE_PLANT.png +0 -0
- package/pipeline/office/assets/PC_BACK.png +0 -0
- package/pipeline/office/assets/PC_FRONT_OFF.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_1.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_2.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_3.png +0 -0
- package/pipeline/office/assets/PC_SIDE.png +0 -0
- package/pipeline/office/assets/PLANT.png +0 -0
- package/pipeline/office/assets/PLANT_2.png +0 -0
- package/pipeline/office/assets/POT.png +0 -0
- package/pipeline/office/assets/SMALL_PAINTING.png +0 -0
- package/pipeline/office/assets/SMALL_PAINTING_2.png +0 -0
- package/pipeline/office/assets/SMALL_TABLE_FRONT.png +0 -0
- package/pipeline/office/assets/SMALL_TABLE_SIDE.png +0 -0
- package/pipeline/office/assets/SOFA_BACK.png +0 -0
- package/pipeline/office/assets/SOFA_FRONT.png +0 -0
- package/pipeline/office/assets/SOFA_SIDE.png +0 -0
- package/pipeline/office/assets/TABLE_FRONT.png +0 -0
- package/pipeline/office/assets/WHITEBOARD.png +0 -0
- package/pipeline/office/assets/WOODEN_BENCH.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_BACK.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_FRONT.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_SIDE.png +0 -0
- package/pipeline/office/assets/char_0.png +0 -0
- package/pipeline/office/assets/char_1.png +0 -0
- package/pipeline/office/assets/char_2.png +0 -0
- package/pipeline/office/assets/char_3.png +0 -0
- package/pipeline/office/assets/char_4.png +0 -0
- package/pipeline/office/assets/char_5.png +0 -0
- package/pipeline/office/assets/floor_0.png +0 -0
- package/pipeline/office/assets/floor_1.png +0 -0
- package/pipeline/office/assets/floor_2.png +0 -0
- package/pipeline/office/assets/floor_3.png +0 -0
- package/pipeline/office/assets/floor_4.png +0 -0
- package/pipeline/office/assets/floor_5.png +0 -0
- package/pipeline/office/assets/floor_6.png +0 -0
- package/pipeline/office/assets/floor_7.png +0 -0
- package/pipeline/office/assets/floor_8.png +0 -0
- package/pipeline/office/assets/wall_0.png +0 -0
- package/pipeline/office/index.html +5319 -0
- package/pipeline/preferences-template.json +7 -0
- package/pipeline/rules/figma-pipeline.md +2 -0
- package/pipeline/schemas/activity-chat.schema.json +78 -0
- package/pipeline/schemas/activity-roles.json +48 -0
- package/pipeline/schemas/activity-roles.schema.json +49 -0
- package/pipeline/schemas/activity.schema.json +429 -0
- package/pipeline/schemas/agent-state.schema.json +20 -5
- package/pipeline/schemas/autopilot-on-request.schema.json +33 -0
- package/pipeline/schemas/autopilot-on.schema.json +111 -0
- package/pipeline/schemas/contract-error.schema.json +33 -3
- package/pipeline/schemas/issue-maturity.schema.json +68 -0
- package/pipeline/schemas/ledger.schema.json +202 -0
- package/pipeline/schemas/permissions.schema.json +47 -0
- package/pipeline/schemas/phases.json +3 -2
- package/pipeline/schemas/phases.schema.json +5 -1
- package/pipeline/schemas/prefs.schema.json +56 -0
- package/pipeline/schemas/prs.schema.json +73 -0
- package/pipeline/schemas/review-verdict-request.schema.json +20 -0
- package/pipeline/schemas/review-verdict.schema.json +62 -0
- package/pipeline/schemas/routine-delete.schema.json +59 -0
- package/pipeline/schemas/routines.schema.json +38 -0
- package/pipeline/schemas/run-evidence.schema.json +29 -0
- package/pipeline/schemas/run-results.schema.json +218 -0
- package/pipeline/schemas/runs-index.schema.json +1 -1
- package/pipeline/schemas/service-read.schema.json +58 -0
- package/pipeline/schemas/stack-change.schema.json +82 -0
- package/pipeline/schemas/stack-request.schema.json +33 -0
- package/pipeline/schemas/stack.schema.json +59 -0
- package/pipeline/schemas/steer-request.schema.json +25 -0
- package/pipeline/schemas/steer.schema.json +59 -0
- package/pipeline/schemas/tool-call-request.schema.json +19 -0
- package/pipeline/schemas/tool-call.schema.json +47 -0
- package/pipeline/schemas/tools.schema.json +60 -0
- package/pipeline/scripts/_active-run.mjs +158 -0
- package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
- package/pipeline/scripts/_cost.mjs +66 -1
- package/pipeline/scripts/_guard_rules.py +605 -0
- package/pipeline/scripts/_office-smoke-lib.sh +115 -0
- package/pipeline/scripts/_spawn-async.mjs +73 -0
- package/pipeline/scripts/_transcript-tail.mjs +274 -0
- package/pipeline/scripts/activity-snapshot.mjs +1027 -0
- package/pipeline/scripts/agent-guard.py +118 -7
- package/pipeline/scripts/agent-guard.sh +50 -3
- package/pipeline/scripts/argv-secret-scan.mjs +114 -0
- package/pipeline/scripts/autopilot-control.mjs +214 -9
- package/pipeline/scripts/autopilot-runner.mjs +8 -2
- package/pipeline/scripts/confirm-command.mjs +70 -0
- package/pipeline/scripts/contract-server.mjs +1178 -48
- package/pipeline/scripts/cost-analyze.mjs +2 -53
- package/pipeline/scripts/cost-table.json +1 -0
- package/pipeline/scripts/doctor.mjs +54 -0
- package/pipeline/scripts/evidence-gate.mjs +8 -4
- package/pipeline/scripts/gate-ledger.mjs +163 -7
- package/pipeline/scripts/office-build.mjs +89 -0
- package/pipeline/scripts/office-control.mjs +383 -0
- package/pipeline/scripts/office-ledger.mjs +409 -0
- package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
- package/pipeline/scripts/pre-commit-check.sh +12 -0
- package/pipeline/scripts/routine-registry.mjs +42 -6
- package/pipeline/scripts/run-results.mjs +373 -0
- package/pipeline/scripts/run-scope-guard.mjs +188 -0
- package/pipeline/scripts/run-scope-guard.sh +23 -0
- package/pipeline/scripts/run-stop-guard.mjs +246 -0
- package/pipeline/scripts/run-stop-guard.sh +25 -0
- package/pipeline/scripts/service-read.mjs +244 -0
- package/pipeline/scripts/stack-toggle.mjs +285 -0
- package/pipeline/scripts/steer-run.mjs +97 -0
- package/pipeline/scripts/toolkit-tools.mjs +599 -0
- package/pipeline/scripts/uninstall.mjs +4 -0
- package/pipeline/scripts/validate-state.mjs +26 -0
- package/pipeline/scripts/work-items.mjs +304 -0
- package/pipeline/scripts/write-state.mjs +114 -0
- package/pipeline/skills/.skill-manifest.json +19 -7
- package/pipeline/skills/.skills-index.json +34 -1
- package/pipeline/skills/shared/README.md +6 -3
- package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +13 -15
- package/pipeline/skills/shared/core/multi-agent-office-off/SKILL.md +16 -0
- package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +22 -0
- package/pipeline/skills/shared/core/multi-agent-office-status/SKILL.md +17 -0
- package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +4 -2
- package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +14 -6
- package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +10 -9
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- 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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|