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
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
2
|
+
import { join, basename } from 'path';
|
|
3
|
+
import { homedir as osHomedir } from 'os';
|
|
4
|
+
|
|
5
|
+
const PLATFORMS = ['cursor', 'claude', 'amp'];
|
|
6
|
+
|
|
7
|
+
function numOrNull(value) {
|
|
8
|
+
if (value == null || value === '') return null;
|
|
9
|
+
const n = Number(value);
|
|
10
|
+
return Number.isFinite(n) ? n : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function addNullable(a, b) {
|
|
14
|
+
if (a == null && b == null) return null;
|
|
15
|
+
return (a ?? 0) + (b ?? 0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function emptyPlatform(source = 'none') {
|
|
19
|
+
return {
|
|
20
|
+
inputTokens: null,
|
|
21
|
+
outputTokens: null,
|
|
22
|
+
totalTokens: null,
|
|
23
|
+
costUsd: null,
|
|
24
|
+
ampCredits: null,
|
|
25
|
+
source,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function emptyByPlatform() {
|
|
30
|
+
return {
|
|
31
|
+
cursor: emptyPlatform(),
|
|
32
|
+
claude: emptyPlatform(),
|
|
33
|
+
amp: emptyPlatform(),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function encodeClaudeProjectDir(cwd) {
|
|
38
|
+
return String(cwd || '').replace(/[/.]/g, '-');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stripFileUri(uri) {
|
|
42
|
+
const value = String(uri || '');
|
|
43
|
+
return value.startsWith('file://') ? value.slice('file://'.length) : value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeFsPath(value) {
|
|
47
|
+
const stripped = stripFileUri(value).trim();
|
|
48
|
+
if (!stripped) return '';
|
|
49
|
+
if (stripped.length > 1 && stripped.endsWith('/')) return stripped.replace(/\/+$/, '');
|
|
50
|
+
return stripped;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function pathsEqual(a, b) {
|
|
54
|
+
const left = normalizeFsPath(a);
|
|
55
|
+
const right = normalizeFsPath(b);
|
|
56
|
+
return Boolean(left) && left === right;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseTime(value) {
|
|
60
|
+
if (value == null || value === '') return NaN;
|
|
61
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
62
|
+
return value < 1e12 ? value * 1000 : value;
|
|
63
|
+
}
|
|
64
|
+
return Date.parse(String(value));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function inWindow(timestamp, windowStart, windowEnd) {
|
|
68
|
+
const t = parseTime(timestamp);
|
|
69
|
+
if (!Number.isFinite(t)) return false;
|
|
70
|
+
if (windowStart) {
|
|
71
|
+
const start = parseTime(windowStart);
|
|
72
|
+
if (Number.isFinite(start) && t < start) return false;
|
|
73
|
+
}
|
|
74
|
+
if (windowEnd) {
|
|
75
|
+
const end = parseTime(windowEnd);
|
|
76
|
+
if (Number.isFinite(end) && t > end) return false;
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sourceRecord({ id, platform, model, inputTokens, outputTokens, costUsd, ampCredits, at }) {
|
|
82
|
+
const input = numOrNull(inputTokens);
|
|
83
|
+
const output = numOrNull(outputTokens);
|
|
84
|
+
let total = null;
|
|
85
|
+
if (input != null || output != null) total = (input ?? 0) + (output ?? 0);
|
|
86
|
+
return {
|
|
87
|
+
id: String(id),
|
|
88
|
+
platform,
|
|
89
|
+
model: model == null || model === '' ? null : String(model),
|
|
90
|
+
inputTokens: input,
|
|
91
|
+
outputTokens: output,
|
|
92
|
+
totalTokens: total,
|
|
93
|
+
costUsd: numOrNull(costUsd),
|
|
94
|
+
ampCredits: numOrNull(ampCredits),
|
|
95
|
+
at: at == null ? null : String(at),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function claudeInputTokens(usage) {
|
|
100
|
+
if (!usage || typeof usage !== 'object') return null;
|
|
101
|
+
let has = false;
|
|
102
|
+
let sum = 0;
|
|
103
|
+
if (usage.input_tokens != null || usage.inputTokens != null) {
|
|
104
|
+
has = true;
|
|
105
|
+
sum += numOrNull(usage.input_tokens ?? usage.inputTokens) ?? 0;
|
|
106
|
+
}
|
|
107
|
+
for (const [key, value] of Object.entries(usage)) {
|
|
108
|
+
if (key.startsWith('cache_') && value != null) {
|
|
109
|
+
has = true;
|
|
110
|
+
sum += numOrNull(value) ?? 0;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return has ? sum : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function claudeCostUsd(row, usage) {
|
|
117
|
+
const candidates = [
|
|
118
|
+
row && row.total_cost_usd,
|
|
119
|
+
row && row.totalCostUsd,
|
|
120
|
+
row && row.cost_usd,
|
|
121
|
+
row && row.costUsd,
|
|
122
|
+
usage && usage.total_cost_usd,
|
|
123
|
+
usage && usage.totalCostUsd,
|
|
124
|
+
usage && usage.cost_usd,
|
|
125
|
+
usage && usage.costUsd,
|
|
126
|
+
];
|
|
127
|
+
for (const value of candidates) {
|
|
128
|
+
const n = numOrNull(value);
|
|
129
|
+
if (n != null) return n;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isClaudeAssistant(row) {
|
|
135
|
+
if (!row || typeof row !== 'object') return false;
|
|
136
|
+
if (row.type === 'assistant') return true;
|
|
137
|
+
if (row.message && row.message.role === 'assistant') return true;
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function collectClaude({ cwd, windowStart, windowEnd, existing, env, homedir, notes }) {
|
|
142
|
+
const home = homedir || env.HOME || osHomedir();
|
|
143
|
+
const encoded = encodeClaudeProjectDir(cwd);
|
|
144
|
+
const projectDir = join(home, '.claude', 'projects', encoded);
|
|
145
|
+
const sources = [];
|
|
146
|
+
if (!existsSync(projectDir)) {
|
|
147
|
+
notes.push('claude: project folder missing');
|
|
148
|
+
return sources;
|
|
149
|
+
}
|
|
150
|
+
let files;
|
|
151
|
+
try {
|
|
152
|
+
files = readdirSync(projectDir).filter((name) => name.endsWith('.jsonl'));
|
|
153
|
+
} catch {
|
|
154
|
+
notes.push('claude: cannot read project folder');
|
|
155
|
+
return sources;
|
|
156
|
+
}
|
|
157
|
+
for (const file of files) {
|
|
158
|
+
let text;
|
|
159
|
+
try {
|
|
160
|
+
text = readFileSync(join(projectDir, file), 'utf-8');
|
|
161
|
+
} catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
for (const line of text.split('\n')) {
|
|
165
|
+
if (!line.trim()) continue;
|
|
166
|
+
let row;
|
|
167
|
+
try {
|
|
168
|
+
row = JSON.parse(line);
|
|
169
|
+
} catch {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (!isClaudeAssistant(row)) continue;
|
|
173
|
+
const message = row.message || {};
|
|
174
|
+
const usage = message.usage;
|
|
175
|
+
if (!usage || typeof usage !== 'object') continue;
|
|
176
|
+
const id = message.id;
|
|
177
|
+
if (id == null || id === '') continue;
|
|
178
|
+
if (existing.has(String(id))) continue;
|
|
179
|
+
if (row.cwd !== cwd) continue;
|
|
180
|
+
if (!inWindow(row.timestamp, windowStart, windowEnd)) continue;
|
|
181
|
+
sources.push(sourceRecord({
|
|
182
|
+
id,
|
|
183
|
+
platform: 'claude',
|
|
184
|
+
model: message.model,
|
|
185
|
+
inputTokens: claudeInputTokens(usage),
|
|
186
|
+
outputTokens: usage.output_tokens ?? usage.outputTokens,
|
|
187
|
+
costUsd: claudeCostUsd(row, usage),
|
|
188
|
+
ampCredits: null,
|
|
189
|
+
at: row.timestamp,
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return sources;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function ampRoot(env, homedir) {
|
|
197
|
+
if (env.AMP_DATA_DIR && String(env.AMP_DATA_DIR).trim()) return String(env.AMP_DATA_DIR).trim();
|
|
198
|
+
if (env.XDG_DATA_HOME && String(env.XDG_DATA_HOME).trim()) {
|
|
199
|
+
return join(String(env.XDG_DATA_HOME).trim(), 'amp');
|
|
200
|
+
}
|
|
201
|
+
return join(homedir || env.HOME || osHomedir(), '.local', 'share', 'amp');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function ampTrees(thread) {
|
|
205
|
+
const trees = thread && thread.env && thread.env.initial && thread.env.initial.trees;
|
|
206
|
+
return Array.isArray(trees) ? trees : [];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function ampCwdCandidates(thread) {
|
|
210
|
+
const out = [];
|
|
211
|
+
const push = (value) => {
|
|
212
|
+
if (typeof value === 'string' && value.trim()) out.push(value);
|
|
213
|
+
};
|
|
214
|
+
push(thread && thread.cwd);
|
|
215
|
+
push(thread && thread.workdir);
|
|
216
|
+
const env = thread && thread.env;
|
|
217
|
+
if (env && typeof env === 'object') {
|
|
218
|
+
push(env.cwd);
|
|
219
|
+
push(env.PWD);
|
|
220
|
+
push(env.pwd);
|
|
221
|
+
if (env.initial && typeof env.initial === 'object') {
|
|
222
|
+
push(env.initial.cwd);
|
|
223
|
+
push(env.initial.PWD);
|
|
224
|
+
push(env.initial.workdir);
|
|
225
|
+
push(env.initial.workspace);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const meta = thread && thread.meta;
|
|
229
|
+
if (meta && typeof meta === 'object') {
|
|
230
|
+
push(meta.cwd);
|
|
231
|
+
push(meta.workdir);
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function ampCurrentThreadId(env) {
|
|
237
|
+
if (!env || typeof env !== 'object') return '';
|
|
238
|
+
for (const key of ['AMP_CURRENT_THREAD', 'AMP_THREAD_ID']) {
|
|
239
|
+
const value = env[key];
|
|
240
|
+
if (value != null && String(value).trim()) return String(value).trim();
|
|
241
|
+
}
|
|
242
|
+
return '';
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function ampThreadMentionsCwd(thread, cwd) {
|
|
246
|
+
const target = normalizeFsPath(cwd);
|
|
247
|
+
if (!target || target.length < 2) return false;
|
|
248
|
+
let blob;
|
|
249
|
+
try {
|
|
250
|
+
blob = JSON.stringify(thread);
|
|
251
|
+
} catch {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
return blob.includes(target) || blob.includes(`file://${target}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function ampThreadMatches(thread, cwd, env, fileName) {
|
|
258
|
+
const trees = ampTrees(thread);
|
|
259
|
+
if (trees.length > 0) {
|
|
260
|
+
return trees.some((tree) => tree && pathsEqual(tree.uri, cwd));
|
|
261
|
+
}
|
|
262
|
+
if (ampCwdCandidates(thread).some((candidate) => pathsEqual(candidate, cwd))) return true;
|
|
263
|
+
const threadKey = thread && thread.id ? String(thread.id) : basename(fileName, '.json');
|
|
264
|
+
const current = ampCurrentThreadId(env);
|
|
265
|
+
if (current && current === threadKey) return true;
|
|
266
|
+
return ampThreadMentionsCwd(thread, cwd);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function ampMessages(thread) {
|
|
270
|
+
const out = [];
|
|
271
|
+
if (Array.isArray(thread.messages)) out.push(...thread.messages);
|
|
272
|
+
else if (thread.messages && typeof thread.messages === 'object') out.push(...Object.values(thread.messages));
|
|
273
|
+
if (Array.isArray(thread.turns)) out.push(...thread.turns);
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function ampUsage(message) {
|
|
278
|
+
if (message && message.usage && typeof message.usage === 'object') return message.usage;
|
|
279
|
+
if (message && message.message && message.message.usage && typeof message.message.usage === 'object') {
|
|
280
|
+
return message.message.usage;
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function ampId(message) {
|
|
286
|
+
return (
|
|
287
|
+
(message && (message.messageId || message.toMessageId))
|
|
288
|
+
|| (message && message.message && (message.message.messageId || message.message.toMessageId || message.message.id))
|
|
289
|
+
|| null
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function ampInputTokens(usage) {
|
|
294
|
+
if (usage.totalInputTokens != null) return numOrNull(usage.totalInputTokens);
|
|
295
|
+
let has = false;
|
|
296
|
+
let sum = 0;
|
|
297
|
+
if (usage.inputTokens != null) {
|
|
298
|
+
has = true;
|
|
299
|
+
sum += numOrNull(usage.inputTokens) ?? 0;
|
|
300
|
+
}
|
|
301
|
+
if (usage.cacheCreationInputTokens != null) {
|
|
302
|
+
has = true;
|
|
303
|
+
sum += numOrNull(usage.cacheCreationInputTokens) ?? 0;
|
|
304
|
+
}
|
|
305
|
+
if (usage.cacheReadInputTokens != null) {
|
|
306
|
+
has = true;
|
|
307
|
+
sum += numOrNull(usage.cacheReadInputTokens) ?? 0;
|
|
308
|
+
}
|
|
309
|
+
return has ? sum : null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function collectAmp({ cwd, windowStart, windowEnd, existing, env, homedir, notes }) {
|
|
313
|
+
const root = ampRoot(env, homedir);
|
|
314
|
+
const threadsDir = join(root, 'threads');
|
|
315
|
+
const sources = [];
|
|
316
|
+
if (!existsSync(threadsDir)) {
|
|
317
|
+
notes.push('amp: threads folder missing');
|
|
318
|
+
return sources;
|
|
319
|
+
}
|
|
320
|
+
let files;
|
|
321
|
+
try {
|
|
322
|
+
files = readdirSync(threadsDir).filter((name) => name.endsWith('.json'));
|
|
323
|
+
} catch {
|
|
324
|
+
notes.push('amp: cannot read threads');
|
|
325
|
+
return sources;
|
|
326
|
+
}
|
|
327
|
+
for (const file of files) {
|
|
328
|
+
let thread;
|
|
329
|
+
try {
|
|
330
|
+
thread = JSON.parse(readFileSync(join(threadsDir, file), 'utf-8'));
|
|
331
|
+
} catch {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (!thread || typeof thread !== 'object') continue;
|
|
335
|
+
if (!ampThreadMatches(thread, cwd, env, file)) continue;
|
|
336
|
+
// messageId values are thread-local counters (1, 3, 5, ...), so a bare id
|
|
337
|
+
// collides across threads; namespace with the thread id for global dedup.
|
|
338
|
+
const threadKey = thread.id ? String(thread.id) : basename(file, '.json');
|
|
339
|
+
for (const message of ampMessages(thread)) {
|
|
340
|
+
const usage = ampUsage(message);
|
|
341
|
+
if (!usage) continue;
|
|
342
|
+
const rawId = ampId(message);
|
|
343
|
+
if (rawId == null || rawId === '') continue;
|
|
344
|
+
const id = `${threadKey}:${rawId}`;
|
|
345
|
+
if (existing.has(id)) continue;
|
|
346
|
+
if (!inWindow(usage.timestamp, windowStart, windowEnd)) continue;
|
|
347
|
+
sources.push(sourceRecord({
|
|
348
|
+
id,
|
|
349
|
+
platform: 'amp',
|
|
350
|
+
model: usage.model,
|
|
351
|
+
inputTokens: ampInputTokens(usage),
|
|
352
|
+
outputTokens: usage.outputTokens,
|
|
353
|
+
costUsd: null,
|
|
354
|
+
ampCredits: null,
|
|
355
|
+
at: usage.timestamp,
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return sources;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export const CURSOR_USAGE_FILE_REL = join('.agents', 'spend', 'cursor-usage.jsonl');
|
|
363
|
+
|
|
364
|
+
function collectCursor({ cwd, windowStart, windowEnd, existing, notes }) {
|
|
365
|
+
const filePath = join(cwd, CURSOR_USAGE_FILE_REL);
|
|
366
|
+
if (!existsSync(filePath)) {
|
|
367
|
+
notes.push('cursor: usage file missing (spend hook not installed or no turns recorded yet)');
|
|
368
|
+
return [];
|
|
369
|
+
}
|
|
370
|
+
let text;
|
|
371
|
+
try {
|
|
372
|
+
text = readFileSync(filePath, 'utf-8');
|
|
373
|
+
} catch {
|
|
374
|
+
notes.push('cursor: cannot read usage file');
|
|
375
|
+
return [];
|
|
376
|
+
}
|
|
377
|
+
// stop / afterAgentResponse / loop follow-ups may write the same generation_id
|
|
378
|
+
// several times with cumulative turn totals; keep the largest record per id.
|
|
379
|
+
const bestById = new Map();
|
|
380
|
+
for (const line of text.split('\n')) {
|
|
381
|
+
if (!line.trim()) continue;
|
|
382
|
+
let row;
|
|
383
|
+
try {
|
|
384
|
+
row = JSON.parse(line);
|
|
385
|
+
} catch {
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (!row || typeof row !== 'object') continue;
|
|
389
|
+
const id = row.id == null || row.id === '' ? null : String(row.id);
|
|
390
|
+
if (!id) continue;
|
|
391
|
+
if (existing.has(id)) continue;
|
|
392
|
+
if (!inWindow(row.at, windowStart, windowEnd)) continue;
|
|
393
|
+
const inputTokens = numOrNull(row.inputTokens);
|
|
394
|
+
const outputTokens = numOrNull(row.outputTokens);
|
|
395
|
+
if (inputTokens == null && outputTokens == null) continue;
|
|
396
|
+
const record = sourceRecord({
|
|
397
|
+
id,
|
|
398
|
+
platform: 'cursor',
|
|
399
|
+
model: row.model || row.modelId,
|
|
400
|
+
inputTokens,
|
|
401
|
+
outputTokens,
|
|
402
|
+
costUsd: null,
|
|
403
|
+
ampCredits: null,
|
|
404
|
+
at: row.at,
|
|
405
|
+
});
|
|
406
|
+
const previous = bestById.get(id);
|
|
407
|
+
if (!previous || (record.totalTokens ?? 0) >= (previous.totalTokens ?? 0)) {
|
|
408
|
+
bestById.set(id, record);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return [...bestById.values()];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function aggregate(sources) {
|
|
415
|
+
const byPlatform = emptyByPlatform();
|
|
416
|
+
const byModel = new Map();
|
|
417
|
+
for (const src of sources) {
|
|
418
|
+
const platform = PLATFORMS.includes(src.platform) ? src.platform : null;
|
|
419
|
+
if (platform) {
|
|
420
|
+
const bucket = byPlatform[platform];
|
|
421
|
+
bucket.inputTokens = addNullable(bucket.inputTokens, src.inputTokens);
|
|
422
|
+
bucket.outputTokens = addNullable(bucket.outputTokens, src.outputTokens);
|
|
423
|
+
bucket.totalTokens = addNullable(bucket.totalTokens, src.totalTokens);
|
|
424
|
+
bucket.costUsd = addNullable(bucket.costUsd, src.costUsd);
|
|
425
|
+
bucket.ampCredits = addNullable(bucket.ampCredits, src.ampCredits);
|
|
426
|
+
if (platform === 'claude') bucket.source = 'claude-jsonl';
|
|
427
|
+
else if (platform === 'amp') bucket.source = 'amp-thread';
|
|
428
|
+
else bucket.source = 'cursor-hook';
|
|
429
|
+
}
|
|
430
|
+
const model = src.model;
|
|
431
|
+
if (model) {
|
|
432
|
+
const key = `${model}::${src.platform || ''}`;
|
|
433
|
+
const row = byModel.get(key) || {
|
|
434
|
+
model,
|
|
435
|
+
platform: src.platform || null,
|
|
436
|
+
inputTokens: null,
|
|
437
|
+
outputTokens: null,
|
|
438
|
+
totalTokens: null,
|
|
439
|
+
costUsd: null,
|
|
440
|
+
ampCredits: null,
|
|
441
|
+
};
|
|
442
|
+
row.inputTokens = addNullable(row.inputTokens, src.inputTokens);
|
|
443
|
+
row.outputTokens = addNullable(row.outputTokens, src.outputTokens);
|
|
444
|
+
row.totalTokens = addNullable(row.totalTokens, src.totalTokens);
|
|
445
|
+
row.costUsd = addNullable(row.costUsd, src.costUsd);
|
|
446
|
+
row.ampCredits = addNullable(row.ampCredits, src.ampCredits);
|
|
447
|
+
byModel.set(key, row);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return { byPlatform, byModel: [...byModel.values()] };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export function collectSpend(options = {}) {
|
|
454
|
+
const env = options.env || process.env;
|
|
455
|
+
const cwd = options.cwd != null ? options.cwd : process.cwd();
|
|
456
|
+
const homedir = options.homedir || env.HOME || osHomedir();
|
|
457
|
+
const existing = new Set(
|
|
458
|
+
Array.isArray(options.existingSourceIds)
|
|
459
|
+
? options.existingSourceIds
|
|
460
|
+
: options.existingSourceIds instanceof Set
|
|
461
|
+
? [...options.existingSourceIds]
|
|
462
|
+
: [],
|
|
463
|
+
);
|
|
464
|
+
const windowStart = options.windowStart;
|
|
465
|
+
const windowEnd = options.windowEnd;
|
|
466
|
+
const notes = [];
|
|
467
|
+
const ctx = { cwd, windowStart, windowEnd, existing, env, homedir, notes };
|
|
468
|
+
let sources = [];
|
|
469
|
+
try {
|
|
470
|
+
sources = sources.concat(collectClaude(ctx));
|
|
471
|
+
} catch {
|
|
472
|
+
notes.push('claude: adapter failed');
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
sources = sources.concat(collectAmp(ctx));
|
|
476
|
+
} catch {
|
|
477
|
+
notes.push('amp: adapter failed');
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
sources = sources.concat(collectCursor(ctx));
|
|
481
|
+
} catch {
|
|
482
|
+
notes.push('cursor: adapter failed');
|
|
483
|
+
}
|
|
484
|
+
const { byPlatform, byModel } = aggregate(sources);
|
|
485
|
+
return { sources, byPlatform, byModel, notes };
|
|
486
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-orchestrator-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Universal AI agent orchestration kit for Cursor, Claude Code, and Amp Code — spec-driven OpenSpec pipeline, conductor subagents, durable session handoff, factory gates and MCP setup, cloud-agent handoff, and optional local Figma PAT setup",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agent",
|
|
@@ -5,15 +5,15 @@ category: Workflow
|
|
|
5
5
|
description: Archive a completed change via the agent-orchestrator-kit CLI
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
Session Start /
|
|
8
|
+
Session Start / Exit: `.agents/rules/session-handoff.mdc`. Announce Archiver.
|
|
9
9
|
|
|
10
|
-
Archive is
|
|
10
|
+
Archive is one CLI call, no phase subagents.
|
|
11
11
|
|
|
12
12
|
**Steps**
|
|
13
13
|
|
|
14
|
-
1. **Resolve the
|
|
14
|
+
1. **Resolve the name.** After `/opsx:archive`, or `npx openspec list --json` + AskUserQuestion. Never guess.
|
|
15
15
|
|
|
16
|
-
2. **
|
|
16
|
+
2. **Sync decision.** If delta specs exist, ask: merge (`--sync`) or skip (`--no-sync --force`).
|
|
17
17
|
|
|
18
18
|
3. **Run the CLI:**
|
|
19
19
|
|
|
@@ -21,8 +21,8 @@ Archive is fully deterministic — one CLI call, no phase subagents.
|
|
|
21
21
|
npx agent-orchestrator-kit archive <name> [--sync | --no-sync --force]
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
Gates, optional `--sync`, move to `archive/YYYY-MM-DD-<name>`, validate+rollback, final `handoff.md` (`next_command: none`) + memory. A successful `archive` always creates or updates `metrics.json` (`archivedAt`, Archiver session) and prints the change-wide metrics summary. Collect runs only with `--collect`; if `spend.costUsd` is `null` — stderr warning, not a gate.
|
|
25
25
|
|
|
26
|
-
4. **Show
|
|
26
|
+
4. **Show stdout as-is.** On exit ≠ 0, report the gate from stderr and stop — no manual merge/move.
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
No next-thread prompt after a successful archive.
|
|
@@ -19,13 +19,14 @@ Agents (local or cloud) write session artifacts only to git-tracked paths — ne
|
|
|
19
19
|
7. Only then start phase work (spawn a specialist when the phase routing requires one).
|
|
20
20
|
|
|
21
21
|
## Session Exit (order)
|
|
22
|
-
1. The parent writes `openspec/changes/<name>/handoff.md` itself: Closed role, Change, Done, Decisions, Blocked, Next command, Next role, Attach, Subagents to spawn, Constraints, Runtime.
|
|
23
|
-
2.
|
|
24
|
-
3.
|
|
25
|
-
4.
|
|
26
|
-
5.
|
|
27
|
-
6.
|
|
28
|
-
7.
|
|
22
|
+
1. The parent writes `openspec/changes/<name>/handoff.md` itself: Closed role, Change, Done, Decisions, Blocked, Next command, Next role, Attach, Subagents to spawn, Constraints, Runtime, Metrics.
|
|
23
|
+
2. Fill `## Metrics` before running persist. Required keys: `platform`, `model`, `input_tokens`, `output_tokens`, `cost_usd`, `amp_credits`, `spend_source`. Use `unknown` when a value is missing — never invent `0`. This self-report is the primary spend source; `metrics.json` records what the CLI resolved.
|
|
24
|
+
3. `npx agent-orchestrator-kit handoff <name> --model <llm-product-id>` — require exit 0 (appends non-empty Decisions into append-only `openspec/changes/<name>/decisions.md`, upserts absolute-path Memory JSON, records the session into `openspec/changes/<name>/metrics.json`, prints the expanded prompt on stdout). `--model` is the LLM product id of this chat (`claude-opus-5`, `claude-fable-5`, `gpt-5.6-sol`, `cursor-grok-4.6`) — NEVER pass a Closed role (`Architect`, `Implementer`, `Explorer`) or a subagent name (`spec-architect`, `session-handoff`) as `--model`. The parent SHOULD still pass `--model`. The parent MUST NOT guess tokens. `--input-tokens` / `--output-tokens` / `--total-tokens` / `--cost-usd` override session-level totals only and do not wipe platform maps; they do not rewrite `## Metrics`. Optional `--platform cursor|claude|amp` or `AOK_PLATFORM`. Optional `--collect` also runs local spend adapters (Claude JSONL, Amp threads, Cursor spend hook file). The same `npx agent-orchestrator-kit handoff <name>` works in Cursor, Claude Code, and Amp and MUST NOT require Cursor SDK, a Claude `/cost` parser, or an Amp billing API as a required step. `decisions.md` is the git canon of change decisions; Memory `Decision:*` is a file→Memory mirror only. Cloud sessions pass `--runtime cloud` (or set `AOK_RUNTIME=cloud` / `AOK_AGENT_ID` in the cloud-agent environment).
|
|
25
|
+
4. Spawn `session-handoff` in persist mode ONLY if step 3 failed (Amp: isolated `subagent-session-handoff`). Fallback, never routine.
|
|
26
|
+
5. Memory MCP is an optional mirror: if tools are available, update `Change:<name>`, `Handoff:<name>`, `Decision:*` in one call; unavailability never blocks closing.
|
|
27
|
+
6. Paste CLI stdout as one fenced block. First line `/opsx:…`. Body uses `project.agent_language`. Self-contained (Done/Decisions/Blocked/spawn/HARD STOP). No banner.
|
|
28
|
+
7. If runtime is cloud: after persist, `git add openspec/changes/<name>/` → `git commit` → `git push` → `npx agent-orchestrator-kit handoff <name> --cloud-check` (exit 0 required). Closing without this is an incomplete handoff. Persist prints these steps on stderr; the CLI never runs `git commit` / `git push`.
|
|
29
|
+
8. Stop. Next role = new chat.
|
|
29
30
|
|
|
30
31
|
## Archive exception
|
|
31
32
|
`npx agent-orchestrator-kit archive <name>` writes the final `handoff.md` (`next_command: none`) in the archive folder and upserts memory itself. After a successful archive no fenced next-prompt is required — the pipeline is complete.
|
|
@@ -145,11 +145,12 @@ Archive is one deterministic CLI call — `npx agent-orchestrator-kit archive <n
|
|
|
145
145
|
- Never edit files outside your role's allowed output
|
|
146
146
|
|
|
147
147
|
**End of each session (HARD STOP — you are NOT done):**
|
|
148
|
-
1. Write `openspec/changes/<name>/handoff.md` in the parent using the template below
|
|
149
|
-
2.
|
|
150
|
-
3.
|
|
151
|
-
4.
|
|
152
|
-
5.
|
|
148
|
+
1. Write `openspec/changes/<name>/handoff.md` in the parent using the template below, including `## Metrics`.
|
|
149
|
+
2. Fill `## Metrics` (`platform`, `model`, `input_tokens`, `output_tokens`, `cost_usd`, `amp_credits`, `spend_source`) before persist. Use `unknown` when a value is missing — never invent `0`.
|
|
150
|
+
3. Run `npx agent-orchestrator-kit handoff <name> --model <llm-product-id>` and require exit 0. `--model` is the LLM product id of this chat (`claude-opus-5`, `claude-fable-5`, `gpt-5.6-sol`, `cursor-grok-4.6`) — NEVER pass a Closed role (`Architect`, `Implementer`, `Explorer`) or a subagent name (`spec-architect`, `session-handoff`) as `--model`. The parent SHOULD still pass `--model`. The parent MUST NOT guess tokens. `--input-tokens` / `--output-tokens` / `--total-tokens` / `--cost-usd` override session-level totals only and do not wipe platform maps or rewrite `## Metrics`. Optional `--platform cursor|claude|amp` or `AOK_PLATFORM`. Optional `--collect` also runs local spend adapters. The same command works in Cursor, Claude Code, and Amp and MUST NOT require Cursor SDK, a Claude `/cost` parser, or an Amp billing API as a required step. The CLI appends non-empty Decisions into append-only `openspec/changes/<name>/decisions.md` (the git canon), upserts Memory JSON with an absolute path (`Decision:*` is a file→Memory mirror only), and prints the expanded self-contained prompt on stdout. Spawn `session-handoff` in persist mode ONLY if this CLI step failed.
|
|
151
|
+
4. If Memory MCP tools are available, mirror `Change:<name>`, `Handoff:<name>`, and new `Decision:<topic>` entities in one call — optional; its absence never blocks closing.
|
|
152
|
+
5. Paste the CLI stdout as one fenced next-session prompt. First line is `/opsx:<next> <name>`; body uses `project.agent_language`; keep Done/Decisions/Blocked/spawn/HARD STOP complete. No banner. Do not emit a thin “read Memory” stub.
|
|
153
|
+
6. Do not start the next phase in this chat. If apply, include build/lint status in the persisted Done section.
|
|
153
154
|
|
|
154
155
|
`handoff.md` template:
|
|
155
156
|
|
|
@@ -197,6 +198,15 @@ Archive is one deterministic CLI call — `npx agent-orchestrator-kit archive <n
|
|
|
197
198
|
- runtime: <local | cloud>
|
|
198
199
|
- agent_id: <id | none>
|
|
199
200
|
|
|
201
|
+
## Metrics
|
|
202
|
+
- platform: <cursor | claude | amp | unknown>
|
|
203
|
+
- model: <llm-product-id | unknown>
|
|
204
|
+
- input_tokens: <n | unknown>
|
|
205
|
+
- output_tokens: <n | unknown>
|
|
206
|
+
- cost_usd: <n | unknown>
|
|
207
|
+
- amp_credits: <n | unknown>
|
|
208
|
+
- spend_source: <self-report | flag | adapter | unreported | unknown>
|
|
209
|
+
|
|
200
210
|
## Prompt
|
|
201
211
|
|
|
202
212
|
The Prompt section is overwritten by `npx agent-orchestrator-kit handoff <name>`. Do not hand-write a thin stub.
|
|
@@ -218,7 +228,7 @@ The Prompt section is overwritten by `npx agent-orchestrator-kit handoff <name>`
|
|
|
218
228
|
|
|
219
229
|
Before specialist work, the parent MUST restore context in order: honor the pasted `/opsx:*` command; run `npx agent-orchestrator-kit handoff --restore` (the CLI briefing is canonical — no separate Memory MCP read step); if the CLI failed, read `openspec/changes/<name>/handoff.md`; spawn `session-handoff` in restore mode ONLY when both failed. Missing Memory MCP never blocks a session. With one active change, free-form “continue” uses `Handoff.next_command` instead of asking for the phase. Amp spawns any needed subagent as an isolated `subagent-*` skill.
|
|
220
230
|
|
|
221
|
-
Before declaring a session closed, the parent MUST, in order: (1) write `openspec/changes/<name>/handoff.md` itself, (2) run `npx agent-orchestrator-kit handoff <name>` (exit 0) — this CLI appends `decisions.md` and mirrors `Decision:*` file→Memory; spawn `session-handoff` persist ONLY if this CLI step failed, (3) paste the CLI stdout prompt whose first line is `/opsx:<next> <name>`. Memory MCP mirroring is an optional single call. Never write Memory back into `decisions.md`. The prompt has no `NEXT_SESSION_PROMPT` label, uses `project.agent_language`, and MUST be self-contained (Done, Decisions, Blocked, attach, spawn, HARD STOP) so the next thread can run if Memory MCP is ignored. Never start the next phase in the current chat. Write session artifacts only to git-tracked paths (never `/tmp`, never gitignored caches). If runtime is cloud: after persist, commit → push → `npx agent-orchestrator-kit handoff <name> --cloud-check` with exit 0; closing without that is an incomplete handoff.
|
|
231
|
+
Before declaring a session closed, the parent MUST, in order: (1) write `openspec/changes/<name>/handoff.md` itself including `## Metrics` (keys `platform`, `model`, `input_tokens`, `output_tokens`, `cost_usd`, `amp_credits`, `spend_source`; `unknown` when missing), (2) run `npx agent-orchestrator-kit handoff <name> --model <llm-product-id>` (exit 0) — NEVER pass a Closed role or subagent name as `--model`; the parent SHOULD still pass `--model` and MUST NOT guess tokens; spend flags override session totals only and do not rewrite `## Metrics`; optional `--platform`; optional `--collect` for local adapters; the same CLI works in Cursor, Claude Code, and Amp and MUST NOT require Cursor SDK, Claude `/cost`, or Amp billing as a required step; this CLI appends `decisions.md` and mirrors `Decision:*` file→Memory; spawn `session-handoff` persist ONLY if this CLI step failed, (3) paste the CLI stdout prompt whose first line is `/opsx:<next> <name>`. Memory MCP mirroring is an optional single call. Never write Memory back into `decisions.md`. The prompt has no `NEXT_SESSION_PROMPT` label, uses `project.agent_language`, and MUST be self-contained (Done, Decisions, Blocked, attach, spawn, HARD STOP) so the next thread can run if Memory MCP is ignored. Never start the next phase in the current chat. Write session artifacts only to git-tracked paths (never `/tmp`, never gitignored caches). If runtime is cloud: after persist, commit → push → `npx agent-orchestrator-kit handoff <name> --cloud-check` with exit 0; closing without that is an incomplete handoff.
|
|
222
232
|
|
|
223
233
|
| Entity | Required fields |
|
|
224
234
|
|--------|-----------------|
|
|
@@ -21,11 +21,12 @@ Use when the parent's restore failed (CLI restore and handoff.md both unavailabl
|
|
|
21
21
|
|
|
22
22
|
Use when the parent's persist failed (`npx agent-orchestrator-kit handoff <name>` did not exit 0). A session is not closed until persist succeeds.
|
|
23
23
|
|
|
24
|
-
1. Write or update `openspec/changes/<name>/handoff.md` with every required section: Closed role, Change, Done, Decisions, Blocked, Next command, Next role, Attach, Subagents to spawn, Constraints, Runtime.
|
|
25
|
-
2.
|
|
26
|
-
3.
|
|
27
|
-
4.
|
|
28
|
-
5.
|
|
24
|
+
1. Write or update `openspec/changes/<name>/handoff.md` with every required section: Closed role, Change, Done, Decisions, Blocked, Next command, Next role, Attach, Subagents to spawn, Constraints, Runtime, Metrics.
|
|
25
|
+
2. Fill `## Metrics` (`platform`, `model`, `input_tokens`, `output_tokens`, `cost_usd`, `amp_credits`, `spend_source`) before persist. Use `unknown` when a value is missing. The section is the agent's self-report; the CLI does not overwrite it with resolved values.
|
|
26
|
+
3. Run `npx agent-orchestrator-kit handoff <name> --model <llm-product-id>` and require exit 0. `--model` is the LLM product id of this chat (`claude-opus-5`, `claude-fable-5`, `gpt-5.6-sol`, `cursor-grok-4.6`) — NEVER pass a Closed role (`Architect`, `Implementer`, `Explorer`) or a subagent name (`spec-architect`, `session-handoff`) as `--model`. The parent SHOULD still pass `--model`. The parent MUST NOT guess tokens. `--input-tokens` / `--output-tokens` / `--total-tokens` / `--cost-usd` override session-level totals only and do not wipe platform maps. Optional `--platform cursor|claude|amp` or `AOK_PLATFORM`. Optional `--collect` also runs local spend adapters. The same command works in Cursor, Claude Code, and Amp and MUST NOT require Cursor SDK, a Claude `/cost` parser, or an Amp billing API as a required step. This appends non-empty Decisions into append-only `openspec/changes/<name>/decisions.md` (git canon), upserts `.cursor/memory.json` using an absolute path (`Decision:*` mirrors that file, never the reverse), and prints the expanded next-session prompt on stdout. Cloud sessions pass `--runtime cloud` (or `AOK_RUNTIME` / `AOK_AGENT_ID`).
|
|
27
|
+
4. If Memory MCP tools are available, also create/update `Change:<name>`, `Handoff:<name>`, and each `Decision:<topic>` to match `decisions.md`. MCP failure is not a blocker after the CLI succeeds.
|
|
28
|
+
5. Put the CLI stdout prompt (first line `/opsx:…`) into **Next prompt** unchanged. Do not shorten it. Do not add a banner.
|
|
29
|
+
6. If runtime is cloud: after persist, commit and push `openspec/changes/<name>/`, then `npx agent-orchestrator-kit handoff <name> --cloud-check` (exit 0 required). Closing without this is an incomplete handoff. The CLI never runs `git commit` / `git push`.
|
|
29
30
|
|
|
30
31
|
## Rules
|
|
31
32
|
|
|
@@ -9,8 +9,9 @@ Workflow:
|
|
|
9
9
|
|
|
10
10
|
1. Read `.agents/orchestrator.yaml`, the complete change, review verdict, task state, and verification/merge evidence supplied by the conductor.
|
|
11
11
|
2. Refuse to archive unless required review is approved, all tasks are complete, and the configured merge/CI gate is satisfied.
|
|
12
|
-
3.
|
|
13
|
-
4. Run
|
|
12
|
+
3. Fill `## Metrics` in the change `handoff.md` (Archiver self-report: platform, model, tokens, cost_usd, amp_credits, spend_source; use `unknown` when missing) before running archive.
|
|
13
|
+
4. Run `npx agent-orchestrator-kit archive <name>` so delta requirements are merged into main specs, the change moves to the dated archive path, and stdout prints the change-wide metrics summary (by phase / by platform / by model).
|
|
14
|
+
5. Run strict validation after the move and report the resulting archive path and modified main specs.
|
|
14
15
|
|
|
15
16
|
Rules:
|
|
16
17
|
|
package/templates/AGENTS.md
CHANGED
|
@@ -23,7 +23,7 @@ Routing table, HARD STOP, and CLI forms: `.agents/rules/` (`agent-orchestration`
|
|
|
23
23
|
| Quick (MVP) | `/opsx:quick <name>` |
|
|
24
24
|
| Archive | `/opsx:archive` |
|
|
25
25
|
|
|
26
|
-
Session Start / Exit are **parent-driven** — canonical protocol in `.agents/rules/session-handoff.mdc`. Start: `status` → `handoff --restore` → `handoff.md` fallback. Exit HARD STOP: parent writes `handoff.md` → `npx agent-orchestrator-kit handoff <name>` (exit 0) → paste the CLI `/opsx:*` prompt. `session-handoff` subagent = fallback only. Do not start the next phase here.
|
|
26
|
+
Session Start / Exit are **parent-driven** — canonical protocol in `.agents/rules/session-handoff.mdc`. Start: `status` → `handoff --restore` → `handoff.md` fallback. Exit HARD STOP: parent writes `handoff.md` including `## Metrics` (use `unknown` when a value is missing) → `npx agent-orchestrator-kit handoff <name>` (exit 0; optional `--collect`) → paste the CLI `/opsx:*` prompt. `session-handoff` subagent = fallback only. Do not start the next phase here.
|
|
27
27
|
|
|
28
28
|
Quality gates: `gate-check --tasks <name>` lints the task contract (Files/Do/Done-when, `pipeline.task_contract: warn|strict|off`); `gate-check --review <name>` is deterministic Tier 1 of review — spec-reviewer (Tier 2) is spawned only after it passes and writes `apply-notes.md` on APPROVE.
|
|
29
29
|
|