agent-orchestrator-kit 0.5.0 → 0.7.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 +33 -0
- package/README.md +50 -15
- package/bin/agent-orchestrator.js +899 -61
- package/bin/spend-collect.js +486 -0
- package/package.json +1 -1
- package/templates/.agents/commands/opsx-archive.md +7 -7
- package/templates/.agents/rules/session-handoff.mdc +8 -7
- package/templates/.agents/skills/agent-orchestration/SKILL.md +16 -6
- package/templates/.agents/subagents/session-handoff.md +6 -5
- package/templates/.agents/subagents/spec-archiver.md +3 -2
- package/templates/AGENTS.md +1 -1
- package/templates/CLAUDE.md +1 -1
- package/templates/scripts/cursor-spend-collect.cjs +285 -0
- package/templates/scripts/cursor-spend-hook.cjs +74 -0
package/templates/CLAUDE.md
CHANGED
|
@@ -10,6 +10,6 @@ See `AGENTS.md` and `.agents/rules/` for routing, HARD STOP, and CLI (`npx` only
|
|
|
10
10
|
|
|
11
11
|
Lean delegation: explore/design/propose/review spawn a mandatory specialist; apply is parent-driven from `tasks.md` + `apply-notes.md` (subagents optional for independent tasks); archive runs `npx agent-orchestrator-kit archive <name> [--sync]` — no subagent. Review is two-tiered: `gate-check --review` (deterministic) before `spec-reviewer`; `gate-check --tasks` lints the Files/Do/Done-when task contract.
|
|
12
12
|
|
|
13
|
-
Session Start/Exit are parent-driven (canonical: `.agents/rules/session-handoff.mdc`): restore with `npx agent-orchestrator-kit handoff --restore`; exit — write `handoff.md
|
|
13
|
+
Session Start/Exit are parent-driven (canonical: `.agents/rules/session-handoff.mdc`): restore with `npx agent-orchestrator-kit handoff --restore`; exit — write `handoff.md` including `## Metrics` (`unknown` when missing), run `npx agent-orchestrator-kit handoff <name>` (exit 0; optional `--collect`), paste the CLI prompt. `session-handoff` subagent is a fallback only. Do not start the next phase in this chat.
|
|
14
14
|
|
|
15
15
|
One active change. No `src/` in explore/design/review. After apply: build/lint. Skills: `.claude/skills/` (synced from `.agents/skills/`).
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Cursor hook (sessionEnd): merge leftover hook rows into the last metrics
|
|
3
|
+
// session after `stop` has written `.agents/spend/cursor-usage.jsonl`.
|
|
4
|
+
// Fail-open and silent — never block the agent loop.
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const { existsSync, readdirSync, readFileSync, writeFileSync, statSync } = require('fs');
|
|
8
|
+
const { join } = require('path');
|
|
9
|
+
|
|
10
|
+
function numOrNull(value) {
|
|
11
|
+
if (value == null || value === '') return null;
|
|
12
|
+
const n = Number(value);
|
|
13
|
+
return Number.isFinite(n) ? n : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function addNullable(a, b) {
|
|
17
|
+
if (a == null && b == null) return null;
|
|
18
|
+
return (a ?? 0) + (b ?? 0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resolveBaseDir(payload) {
|
|
22
|
+
const cwd = process.cwd();
|
|
23
|
+
if (existsSync(join(cwd, 'openspec', 'changes'))) return cwd;
|
|
24
|
+
const roots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots : [];
|
|
25
|
+
for (const root of roots) {
|
|
26
|
+
if (root && existsSync(join(String(root), 'openspec', 'changes'))) return String(root);
|
|
27
|
+
}
|
|
28
|
+
return cwd;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function existingIds(metrics) {
|
|
32
|
+
const ids = new Set();
|
|
33
|
+
for (const session of metrics.sessions || []) {
|
|
34
|
+
for (const src of session.sources || []) {
|
|
35
|
+
if (src && src.id != null && src.id !== '') ids.add(String(src.id));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return ids;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sourceTotals(sources) {
|
|
42
|
+
let inputTokens = null;
|
|
43
|
+
let outputTokens = null;
|
|
44
|
+
let totalTokens = null;
|
|
45
|
+
let costUsd = null;
|
|
46
|
+
for (const src of sources || []) {
|
|
47
|
+
inputTokens = addNullable(inputTokens, numOrNull(src.inputTokens));
|
|
48
|
+
outputTokens = addNullable(outputTokens, numOrNull(src.outputTokens));
|
|
49
|
+
totalTokens = addNullable(totalTokens, numOrNull(src.totalTokens));
|
|
50
|
+
if (src.costUsd != null) costUsd = addNullable(costUsd, numOrNull(src.costUsd));
|
|
51
|
+
}
|
|
52
|
+
return { inputTokens, outputTokens, totalTokens, costUsd };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function looksOverridden(session) {
|
|
56
|
+
const fromSources = sourceTotals(session.sources || []);
|
|
57
|
+
return ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'].some((key) => {
|
|
58
|
+
const sessionVal = numOrNull(session[key]);
|
|
59
|
+
const sourceVal = numOrNull(fromSources[key]);
|
|
60
|
+
if (sessionVal == null) return false;
|
|
61
|
+
if (sourceVal == null) return true;
|
|
62
|
+
return sessionVal !== sourceVal;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function emptyPlatform(source = 'none') {
|
|
67
|
+
return {
|
|
68
|
+
inputTokens: null,
|
|
69
|
+
outputTokens: null,
|
|
70
|
+
totalTokens: null,
|
|
71
|
+
costUsd: null,
|
|
72
|
+
ampCredits: null,
|
|
73
|
+
source,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function recompute(metrics) {
|
|
78
|
+
const phases = {};
|
|
79
|
+
const totals = { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 };
|
|
80
|
+
const spend = { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null };
|
|
81
|
+
const byPlatform = {
|
|
82
|
+
cursor: emptyPlatform(),
|
|
83
|
+
claude: emptyPlatform(),
|
|
84
|
+
amp: emptyPlatform(),
|
|
85
|
+
};
|
|
86
|
+
const byModel = new Map();
|
|
87
|
+
let firstStart = null;
|
|
88
|
+
let lastEnd = null;
|
|
89
|
+
|
|
90
|
+
for (const session of metrics.sessions || []) {
|
|
91
|
+
totals.sessions += 1;
|
|
92
|
+
if (session.runtime === 'cloud') totals.cloudSessions += 1;
|
|
93
|
+
totals.durationMs = addNullable(totals.durationMs, numOrNull(session.durationMs));
|
|
94
|
+
if (session.startedAt && (firstStart == null || session.startedAt < firstStart)) firstStart = session.startedAt;
|
|
95
|
+
if (session.endedAt && (lastEnd == null || session.endedAt > lastEnd)) lastEnd = session.endedAt;
|
|
96
|
+
|
|
97
|
+
const key = session.phase || 'other';
|
|
98
|
+
const phase = phases[key] || {
|
|
99
|
+
sessions: 0,
|
|
100
|
+
durationMs: null,
|
|
101
|
+
inputTokens: null,
|
|
102
|
+
outputTokens: null,
|
|
103
|
+
totalTokens: null,
|
|
104
|
+
costUsd: null,
|
|
105
|
+
agents: [],
|
|
106
|
+
models: [],
|
|
107
|
+
};
|
|
108
|
+
phase.sessions += 1;
|
|
109
|
+
phase.durationMs = addNullable(phase.durationMs, numOrNull(session.durationMs));
|
|
110
|
+
for (const spendKey of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd']) {
|
|
111
|
+
const fromSession = numOrNull(session[spendKey]);
|
|
112
|
+
let value = fromSession;
|
|
113
|
+
if (value == null) {
|
|
114
|
+
let sum = null;
|
|
115
|
+
for (const src of session.sources || []) sum = addNullable(sum, numOrNull(src[spendKey]));
|
|
116
|
+
value = sum;
|
|
117
|
+
}
|
|
118
|
+
phase[spendKey] = addNullable(phase[spendKey], value);
|
|
119
|
+
spend[spendKey] = addNullable(spend[spendKey], value);
|
|
120
|
+
}
|
|
121
|
+
if (session.role && !phase.agents.includes(session.role)) phase.agents.push(session.role);
|
|
122
|
+
if (session.model && !phase.models.includes(session.model)) phase.models.push(session.model);
|
|
123
|
+
if (Array.isArray(session.models)) {
|
|
124
|
+
for (const model of session.models) {
|
|
125
|
+
if (model && !phase.models.includes(model)) phase.models.push(model);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
phases[key] = phase;
|
|
129
|
+
|
|
130
|
+
for (const src of session.sources || []) {
|
|
131
|
+
const platform = src.platform;
|
|
132
|
+
if (platform && byPlatform[platform]) {
|
|
133
|
+
const bucket = byPlatform[platform];
|
|
134
|
+
bucket.inputTokens = addNullable(bucket.inputTokens, numOrNull(src.inputTokens));
|
|
135
|
+
bucket.outputTokens = addNullable(bucket.outputTokens, numOrNull(src.outputTokens));
|
|
136
|
+
bucket.totalTokens = addNullable(bucket.totalTokens, numOrNull(src.totalTokens));
|
|
137
|
+
bucket.costUsd = addNullable(bucket.costUsd, numOrNull(src.costUsd));
|
|
138
|
+
if (platform === 'claude') bucket.source = 'claude-jsonl';
|
|
139
|
+
else if (platform === 'amp') bucket.source = 'amp-thread';
|
|
140
|
+
else if (platform === 'cursor') bucket.source = 'cursor-hook';
|
|
141
|
+
}
|
|
142
|
+
if (src.model) {
|
|
143
|
+
const modelKey = `${src.model}::${src.platform || ''}`;
|
|
144
|
+
const row = byModel.get(modelKey) || {
|
|
145
|
+
model: src.model,
|
|
146
|
+
platform: src.platform || null,
|
|
147
|
+
inputTokens: null,
|
|
148
|
+
outputTokens: null,
|
|
149
|
+
totalTokens: null,
|
|
150
|
+
costUsd: null,
|
|
151
|
+
ampCredits: null,
|
|
152
|
+
};
|
|
153
|
+
row.inputTokens = addNullable(row.inputTokens, numOrNull(src.inputTokens));
|
|
154
|
+
row.outputTokens = addNullable(row.outputTokens, numOrNull(src.outputTokens));
|
|
155
|
+
row.totalTokens = addNullable(row.totalTokens, numOrNull(src.totalTokens));
|
|
156
|
+
row.costUsd = addNullable(row.costUsd, numOrNull(src.costUsd));
|
|
157
|
+
byModel.set(modelKey, row);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (firstStart && lastEnd) {
|
|
163
|
+
totals.leadTimeMs = Math.max(0, Date.parse(lastEnd) - Date.parse(firstStart));
|
|
164
|
+
}
|
|
165
|
+
metrics.phases = phases;
|
|
166
|
+
metrics.totals = totals;
|
|
167
|
+
metrics.spend = spend;
|
|
168
|
+
metrics.spendByPlatform = byPlatform;
|
|
169
|
+
metrics.spendByModel = [...byModel.values()];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function incomingCursorSources(cwd, existing, windowStart) {
|
|
173
|
+
const filePath = join(cwd, '.agents', 'spend', 'cursor-usage.jsonl');
|
|
174
|
+
if (!existsSync(filePath)) return [];
|
|
175
|
+
const startMs = windowStart ? Date.parse(windowStart) : NaN;
|
|
176
|
+
const bestById = new Map();
|
|
177
|
+
for (const line of readFileSync(filePath, 'utf-8').split('\n')) {
|
|
178
|
+
if (!line.trim()) continue;
|
|
179
|
+
let row;
|
|
180
|
+
try {
|
|
181
|
+
row = JSON.parse(line);
|
|
182
|
+
} catch {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (!row || typeof row !== 'object') continue;
|
|
186
|
+
const id = row.id == null || row.id === '' ? null : String(row.id);
|
|
187
|
+
if (!id || existing.has(id)) continue;
|
|
188
|
+
const atMs = Date.parse(row.at);
|
|
189
|
+
if (Number.isFinite(startMs) && Number.isFinite(atMs) && atMs < startMs) continue;
|
|
190
|
+
const inputTokens = numOrNull(row.inputTokens);
|
|
191
|
+
const outputTokens = numOrNull(row.outputTokens);
|
|
192
|
+
if (inputTokens == null && outputTokens == null) continue;
|
|
193
|
+
const totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
|
|
194
|
+
const record = {
|
|
195
|
+
id,
|
|
196
|
+
platform: 'cursor',
|
|
197
|
+
model: row.model || row.modelId || null,
|
|
198
|
+
inputTokens,
|
|
199
|
+
outputTokens,
|
|
200
|
+
totalTokens,
|
|
201
|
+
costUsd: null,
|
|
202
|
+
ampCredits: null,
|
|
203
|
+
at: row.at == null ? null : String(row.at),
|
|
204
|
+
};
|
|
205
|
+
const previous = bestById.get(id);
|
|
206
|
+
if (!previous || (record.totalTokens ?? 0) >= (previous.totalTokens ?? 0)) {
|
|
207
|
+
bestById.set(id, record);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return [...bestById.values()];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function backfillChange(cwd, changeName) {
|
|
214
|
+
const filePath = join(cwd, 'openspec', 'changes', changeName, 'metrics.json');
|
|
215
|
+
if (!existsSync(filePath)) return;
|
|
216
|
+
let metrics;
|
|
217
|
+
try {
|
|
218
|
+
metrics = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
219
|
+
} catch {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (!metrics || typeof metrics !== 'object') return;
|
|
223
|
+
const sessions = Array.isArray(metrics.sessions) ? metrics.sessions : [];
|
|
224
|
+
if (!sessions.length) return;
|
|
225
|
+
const last = sessions[sessions.length - 1];
|
|
226
|
+
const incoming = incomingCursorSources(
|
|
227
|
+
cwd,
|
|
228
|
+
existingIds(metrics),
|
|
229
|
+
last.startedAt || last.endedAt || metrics.createdAt,
|
|
230
|
+
);
|
|
231
|
+
if (!incoming.length) return;
|
|
232
|
+
const overridden = looksOverridden(last);
|
|
233
|
+
last.sources = [...(last.sources || []), ...incoming];
|
|
234
|
+
if (!overridden) {
|
|
235
|
+
const totals = sourceTotals(last.sources);
|
|
236
|
+
last.inputTokens = totals.inputTokens;
|
|
237
|
+
last.outputTokens = totals.outputTokens;
|
|
238
|
+
last.totalTokens = totals.totalTokens;
|
|
239
|
+
last.costUsd = totals.costUsd;
|
|
240
|
+
}
|
|
241
|
+
metrics.updatedAt = new Date().toISOString();
|
|
242
|
+
recompute(metrics);
|
|
243
|
+
writeFileSync(filePath, `${JSON.stringify(metrics, null, 2)}\n`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function main(raw) {
|
|
247
|
+
let payload = {};
|
|
248
|
+
try {
|
|
249
|
+
payload = raw ? JSON.parse(raw) : {};
|
|
250
|
+
} catch {
|
|
251
|
+
payload = {};
|
|
252
|
+
}
|
|
253
|
+
const cwd = resolveBaseDir(payload && typeof payload === 'object' ? payload : {});
|
|
254
|
+
const changesDir = join(cwd, 'openspec', 'changes');
|
|
255
|
+
if (!existsSync(changesDir)) return;
|
|
256
|
+
for (const name of readdirSync(changesDir)) {
|
|
257
|
+
if (name === 'archive') continue;
|
|
258
|
+
const full = join(changesDir, name);
|
|
259
|
+
try {
|
|
260
|
+
if (!statSync(full).isDirectory()) continue;
|
|
261
|
+
} catch {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
backfillChange(cwd, name);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (process.stdin.isTTY) {
|
|
269
|
+
try {
|
|
270
|
+
main('');
|
|
271
|
+
} catch {}
|
|
272
|
+
process.exit(0);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let input = '';
|
|
276
|
+
process.stdin.on('data', (chunk) => {
|
|
277
|
+
input += chunk;
|
|
278
|
+
});
|
|
279
|
+
process.stdin.on('end', () => {
|
|
280
|
+
try {
|
|
281
|
+
main(input);
|
|
282
|
+
} catch {}
|
|
283
|
+
process.exit(0);
|
|
284
|
+
});
|
|
285
|
+
process.stdin.on('error', () => process.exit(0));
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Cursor hook (stop / subagentStop / afterAgentResponse): appends per-turn token usage from the hook
|
|
3
|
+
// payload to .agents/spend/cursor-usage.jsonl so `agent-orchestrator-kit handoff`
|
|
4
|
+
// can collect real Cursor spend offline. Silent and fail-open by design: a hook
|
|
5
|
+
// must never block the agent loop, so every failure path exits 0 with no output.
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const { existsSync, mkdirSync, appendFileSync } = require('fs');
|
|
9
|
+
const { join } = require('path');
|
|
10
|
+
|
|
11
|
+
function numOrNull(value) {
|
|
12
|
+
if (value == null || value === '') return null;
|
|
13
|
+
const n = Number(value);
|
|
14
|
+
return Number.isFinite(n) ? n : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function resolveBaseDir(payload) {
|
|
18
|
+
const cwd = process.cwd();
|
|
19
|
+
if (existsSync(join(cwd, '.agents'))) return cwd;
|
|
20
|
+
const roots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots : [];
|
|
21
|
+
for (const root of roots) {
|
|
22
|
+
if (root && existsSync(join(String(root), '.agents'))) return String(root);
|
|
23
|
+
}
|
|
24
|
+
return cwd;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function main(raw) {
|
|
28
|
+
let payload;
|
|
29
|
+
try {
|
|
30
|
+
payload = JSON.parse(raw);
|
|
31
|
+
} catch {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (!payload || typeof payload !== 'object') return;
|
|
35
|
+
|
|
36
|
+
const inputTokens = numOrNull(payload.input_tokens);
|
|
37
|
+
const outputTokens = numOrNull(payload.output_tokens);
|
|
38
|
+
// Token fields are optional in Cursor hook payloads. No numbers -> no record;
|
|
39
|
+
// never write zeros for turns that did not report usage.
|
|
40
|
+
if (inputTokens == null && outputTokens == null) return;
|
|
41
|
+
|
|
42
|
+
const generationId = payload.generation_id ? String(payload.generation_id) : '';
|
|
43
|
+
const conversationId = payload.conversation_id ? String(payload.conversation_id) : '';
|
|
44
|
+
const id = generationId || (conversationId ? `${conversationId}:${Date.now()}` : `cursor:${Date.now()}`);
|
|
45
|
+
|
|
46
|
+
const record = {
|
|
47
|
+
id,
|
|
48
|
+
event: payload.hook_event_name ? String(payload.hook_event_name) : null,
|
|
49
|
+
conversationId: conversationId || null,
|
|
50
|
+
model: payload.model ? String(payload.model) : null,
|
|
51
|
+
modelId: payload.model_id ? String(payload.model_id) : null,
|
|
52
|
+
inputTokens,
|
|
53
|
+
outputTokens,
|
|
54
|
+
cacheReadTokens: numOrNull(payload.cache_read_tokens),
|
|
55
|
+
cacheWriteTokens: numOrNull(payload.cache_write_tokens),
|
|
56
|
+
at: new Date().toISOString(),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const spendDir = join(resolveBaseDir(payload), '.agents', 'spend');
|
|
60
|
+
mkdirSync(spendDir, { recursive: true });
|
|
61
|
+
appendFileSync(join(spendDir, 'cursor-usage.jsonl'), `${JSON.stringify(record)}\n`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let input = '';
|
|
65
|
+
process.stdin.on('data', (chunk) => {
|
|
66
|
+
input += chunk;
|
|
67
|
+
});
|
|
68
|
+
process.stdin.on('end', () => {
|
|
69
|
+
try {
|
|
70
|
+
main(input);
|
|
71
|
+
} catch {}
|
|
72
|
+
process.exit(0);
|
|
73
|
+
});
|
|
74
|
+
process.stdin.on('error', () => process.exit(0));
|