agent-orchestrator-kit 0.4.0 → 0.6.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 +20 -0
- package/README.md +46 -3
- package/bin/agent-orchestrator.js +758 -0
- package/bin/spend-collect.js +414 -0
- package/package.json +1 -1
- package/templates/.agents/commands/opsx-archive.md +7 -7
- package/templates/.agents/rules/session-handoff.mdc +1 -1
- package/templates/.agents/skills/agent-orchestration/SKILL.md +2 -2
- package/templates/.agents/subagents/session-handoff.md +1 -1
- package/templates/scripts/cursor-spend-hook.cjs +74 -0
|
@@ -0,0 +1,414 @@
|
|
|
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 parseTime(value) {
|
|
47
|
+
if (value == null || value === '') return NaN;
|
|
48
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
49
|
+
return value < 1e12 ? value * 1000 : value;
|
|
50
|
+
}
|
|
51
|
+
return Date.parse(String(value));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function inWindow(timestamp, windowStart, windowEnd) {
|
|
55
|
+
const t = parseTime(timestamp);
|
|
56
|
+
if (!Number.isFinite(t)) return false;
|
|
57
|
+
if (windowStart) {
|
|
58
|
+
const start = parseTime(windowStart);
|
|
59
|
+
if (Number.isFinite(start) && t < start) return false;
|
|
60
|
+
}
|
|
61
|
+
if (windowEnd) {
|
|
62
|
+
const end = parseTime(windowEnd);
|
|
63
|
+
if (Number.isFinite(end) && t > end) return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sourceRecord({ id, platform, model, inputTokens, outputTokens, costUsd, ampCredits, at }) {
|
|
69
|
+
const input = numOrNull(inputTokens);
|
|
70
|
+
const output = numOrNull(outputTokens);
|
|
71
|
+
let total = null;
|
|
72
|
+
if (input != null || output != null) total = (input ?? 0) + (output ?? 0);
|
|
73
|
+
return {
|
|
74
|
+
id: String(id),
|
|
75
|
+
platform,
|
|
76
|
+
model: model == null || model === '' ? null : String(model),
|
|
77
|
+
inputTokens: input,
|
|
78
|
+
outputTokens: output,
|
|
79
|
+
totalTokens: total,
|
|
80
|
+
costUsd: numOrNull(costUsd),
|
|
81
|
+
ampCredits: numOrNull(ampCredits),
|
|
82
|
+
at: at == null ? null : String(at),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function claudeInputTokens(usage) {
|
|
87
|
+
if (!usage || typeof usage !== 'object') return null;
|
|
88
|
+
let has = false;
|
|
89
|
+
let sum = 0;
|
|
90
|
+
if (usage.input_tokens != null || usage.inputTokens != null) {
|
|
91
|
+
has = true;
|
|
92
|
+
sum += numOrNull(usage.input_tokens ?? usage.inputTokens) ?? 0;
|
|
93
|
+
}
|
|
94
|
+
for (const [key, value] of Object.entries(usage)) {
|
|
95
|
+
if (key.startsWith('cache_') && value != null) {
|
|
96
|
+
has = true;
|
|
97
|
+
sum += numOrNull(value) ?? 0;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return has ? sum : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function claudeCostUsd(row, usage) {
|
|
104
|
+
const candidates = [
|
|
105
|
+
row && row.total_cost_usd,
|
|
106
|
+
row && row.totalCostUsd,
|
|
107
|
+
row && row.cost_usd,
|
|
108
|
+
row && row.costUsd,
|
|
109
|
+
usage && usage.total_cost_usd,
|
|
110
|
+
usage && usage.totalCostUsd,
|
|
111
|
+
usage && usage.cost_usd,
|
|
112
|
+
usage && usage.costUsd,
|
|
113
|
+
];
|
|
114
|
+
for (const value of candidates) {
|
|
115
|
+
const n = numOrNull(value);
|
|
116
|
+
if (n != null) return n;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isClaudeAssistant(row) {
|
|
122
|
+
if (!row || typeof row !== 'object') return false;
|
|
123
|
+
if (row.type === 'assistant') return true;
|
|
124
|
+
if (row.message && row.message.role === 'assistant') return true;
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function collectClaude({ cwd, windowStart, windowEnd, existing, env, homedir, notes }) {
|
|
129
|
+
const home = homedir || env.HOME || osHomedir();
|
|
130
|
+
const encoded = encodeClaudeProjectDir(cwd);
|
|
131
|
+
const projectDir = join(home, '.claude', 'projects', encoded);
|
|
132
|
+
const sources = [];
|
|
133
|
+
if (!existsSync(projectDir)) {
|
|
134
|
+
notes.push('claude: project folder missing');
|
|
135
|
+
return sources;
|
|
136
|
+
}
|
|
137
|
+
let files;
|
|
138
|
+
try {
|
|
139
|
+
files = readdirSync(projectDir).filter((name) => name.endsWith('.jsonl'));
|
|
140
|
+
} catch {
|
|
141
|
+
notes.push('claude: cannot read project folder');
|
|
142
|
+
return sources;
|
|
143
|
+
}
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
let text;
|
|
146
|
+
try {
|
|
147
|
+
text = readFileSync(join(projectDir, file), 'utf-8');
|
|
148
|
+
} catch {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
for (const line of text.split('\n')) {
|
|
152
|
+
if (!line.trim()) continue;
|
|
153
|
+
let row;
|
|
154
|
+
try {
|
|
155
|
+
row = JSON.parse(line);
|
|
156
|
+
} catch {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!isClaudeAssistant(row)) continue;
|
|
160
|
+
const message = row.message || {};
|
|
161
|
+
const usage = message.usage;
|
|
162
|
+
if (!usage || typeof usage !== 'object') continue;
|
|
163
|
+
const id = message.id;
|
|
164
|
+
if (id == null || id === '') continue;
|
|
165
|
+
if (existing.has(String(id))) continue;
|
|
166
|
+
if (row.cwd !== cwd) continue;
|
|
167
|
+
if (!inWindow(row.timestamp, windowStart, windowEnd)) continue;
|
|
168
|
+
sources.push(sourceRecord({
|
|
169
|
+
id,
|
|
170
|
+
platform: 'claude',
|
|
171
|
+
model: message.model,
|
|
172
|
+
inputTokens: claudeInputTokens(usage),
|
|
173
|
+
outputTokens: usage.output_tokens ?? usage.outputTokens,
|
|
174
|
+
costUsd: claudeCostUsd(row, usage),
|
|
175
|
+
ampCredits: null,
|
|
176
|
+
at: row.timestamp,
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return sources;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function ampRoot(env, homedir) {
|
|
184
|
+
if (env.AMP_DATA_DIR && String(env.AMP_DATA_DIR).trim()) return String(env.AMP_DATA_DIR).trim();
|
|
185
|
+
if (env.XDG_DATA_HOME && String(env.XDG_DATA_HOME).trim()) {
|
|
186
|
+
return join(String(env.XDG_DATA_HOME).trim(), 'amp');
|
|
187
|
+
}
|
|
188
|
+
return join(homedir || env.HOME || osHomedir(), '.local', 'share', 'amp');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function ampTreesMatch(thread, cwd) {
|
|
192
|
+
const trees = thread && thread.env && thread.env.initial && thread.env.initial.trees;
|
|
193
|
+
if (!Array.isArray(trees) || trees.length === 0) return false;
|
|
194
|
+
return trees.some((tree) => tree && stripFileUri(tree.uri) === cwd);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function ampMessages(thread) {
|
|
198
|
+
const out = [];
|
|
199
|
+
if (Array.isArray(thread.messages)) out.push(...thread.messages);
|
|
200
|
+
else if (thread.messages && typeof thread.messages === 'object') out.push(...Object.values(thread.messages));
|
|
201
|
+
if (Array.isArray(thread.turns)) out.push(...thread.turns);
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function ampUsage(message) {
|
|
206
|
+
if (message && message.usage && typeof message.usage === 'object') return message.usage;
|
|
207
|
+
if (message && message.message && message.message.usage && typeof message.message.usage === 'object') {
|
|
208
|
+
return message.message.usage;
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function ampId(message) {
|
|
214
|
+
return (
|
|
215
|
+
(message && (message.messageId || message.toMessageId))
|
|
216
|
+
|| (message && message.message && (message.message.messageId || message.message.toMessageId || message.message.id))
|
|
217
|
+
|| null
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function ampInputTokens(usage) {
|
|
222
|
+
if (usage.totalInputTokens != null) return numOrNull(usage.totalInputTokens);
|
|
223
|
+
let has = false;
|
|
224
|
+
let sum = 0;
|
|
225
|
+
if (usage.inputTokens != null) {
|
|
226
|
+
has = true;
|
|
227
|
+
sum += numOrNull(usage.inputTokens) ?? 0;
|
|
228
|
+
}
|
|
229
|
+
if (usage.cacheCreationInputTokens != null) {
|
|
230
|
+
has = true;
|
|
231
|
+
sum += numOrNull(usage.cacheCreationInputTokens) ?? 0;
|
|
232
|
+
}
|
|
233
|
+
if (usage.cacheReadInputTokens != null) {
|
|
234
|
+
has = true;
|
|
235
|
+
sum += numOrNull(usage.cacheReadInputTokens) ?? 0;
|
|
236
|
+
}
|
|
237
|
+
return has ? sum : null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function collectAmp({ cwd, windowStart, windowEnd, existing, env, homedir, notes }) {
|
|
241
|
+
const root = ampRoot(env, homedir);
|
|
242
|
+
const threadsDir = join(root, 'threads');
|
|
243
|
+
const sources = [];
|
|
244
|
+
if (!existsSync(threadsDir)) {
|
|
245
|
+
notes.push('amp: threads folder missing');
|
|
246
|
+
return sources;
|
|
247
|
+
}
|
|
248
|
+
let files;
|
|
249
|
+
try {
|
|
250
|
+
files = readdirSync(threadsDir).filter((name) => name.endsWith('.json'));
|
|
251
|
+
} catch {
|
|
252
|
+
notes.push('amp: cannot read threads');
|
|
253
|
+
return sources;
|
|
254
|
+
}
|
|
255
|
+
for (const file of files) {
|
|
256
|
+
let thread;
|
|
257
|
+
try {
|
|
258
|
+
thread = JSON.parse(readFileSync(join(threadsDir, file), 'utf-8'));
|
|
259
|
+
} catch {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (!thread || typeof thread !== 'object') continue;
|
|
263
|
+
if (!ampTreesMatch(thread, cwd)) continue;
|
|
264
|
+
// messageId values are thread-local counters (1, 3, 5, ...), so a bare id
|
|
265
|
+
// collides across threads; namespace with the thread id for global dedup.
|
|
266
|
+
const threadKey = thread.id ? String(thread.id) : basename(file, '.json');
|
|
267
|
+
for (const message of ampMessages(thread)) {
|
|
268
|
+
const usage = ampUsage(message);
|
|
269
|
+
if (!usage) continue;
|
|
270
|
+
const rawId = ampId(message);
|
|
271
|
+
if (rawId == null || rawId === '') continue;
|
|
272
|
+
const id = `${threadKey}:${rawId}`;
|
|
273
|
+
if (existing.has(id)) continue;
|
|
274
|
+
if (!inWindow(usage.timestamp, windowStart, windowEnd)) continue;
|
|
275
|
+
sources.push(sourceRecord({
|
|
276
|
+
id,
|
|
277
|
+
platform: 'amp',
|
|
278
|
+
model: usage.model,
|
|
279
|
+
inputTokens: ampInputTokens(usage),
|
|
280
|
+
outputTokens: usage.outputTokens,
|
|
281
|
+
costUsd: null,
|
|
282
|
+
ampCredits: null,
|
|
283
|
+
at: usage.timestamp,
|
|
284
|
+
}));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return sources;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export const CURSOR_USAGE_FILE_REL = join('.agents', 'spend', 'cursor-usage.jsonl');
|
|
291
|
+
|
|
292
|
+
function collectCursor({ cwd, windowStart, windowEnd, existing, notes }) {
|
|
293
|
+
const filePath = join(cwd, CURSOR_USAGE_FILE_REL);
|
|
294
|
+
if (!existsSync(filePath)) {
|
|
295
|
+
notes.push('cursor: usage file missing (spend hook not installed or no turns recorded yet)');
|
|
296
|
+
return [];
|
|
297
|
+
}
|
|
298
|
+
let text;
|
|
299
|
+
try {
|
|
300
|
+
text = readFileSync(filePath, 'utf-8');
|
|
301
|
+
} catch {
|
|
302
|
+
notes.push('cursor: cannot read usage file');
|
|
303
|
+
return [];
|
|
304
|
+
}
|
|
305
|
+
// stop / afterAgentResponse / loop follow-ups may write the same generation_id
|
|
306
|
+
// several times with cumulative turn totals; keep the largest record per id.
|
|
307
|
+
const bestById = new Map();
|
|
308
|
+
for (const line of text.split('\n')) {
|
|
309
|
+
if (!line.trim()) continue;
|
|
310
|
+
let row;
|
|
311
|
+
try {
|
|
312
|
+
row = JSON.parse(line);
|
|
313
|
+
} catch {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (!row || typeof row !== 'object') continue;
|
|
317
|
+
const id = row.id == null || row.id === '' ? null : String(row.id);
|
|
318
|
+
if (!id) continue;
|
|
319
|
+
if (existing.has(id)) continue;
|
|
320
|
+
if (!inWindow(row.at, windowStart, windowEnd)) continue;
|
|
321
|
+
const inputTokens = numOrNull(row.inputTokens);
|
|
322
|
+
const outputTokens = numOrNull(row.outputTokens);
|
|
323
|
+
if (inputTokens == null && outputTokens == null) continue;
|
|
324
|
+
const record = sourceRecord({
|
|
325
|
+
id,
|
|
326
|
+
platform: 'cursor',
|
|
327
|
+
model: row.model || row.modelId,
|
|
328
|
+
inputTokens,
|
|
329
|
+
outputTokens,
|
|
330
|
+
costUsd: null,
|
|
331
|
+
ampCredits: null,
|
|
332
|
+
at: row.at,
|
|
333
|
+
});
|
|
334
|
+
const previous = bestById.get(id);
|
|
335
|
+
if (!previous || (record.totalTokens ?? 0) >= (previous.totalTokens ?? 0)) {
|
|
336
|
+
bestById.set(id, record);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return [...bestById.values()];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function aggregate(sources) {
|
|
343
|
+
const byPlatform = emptyByPlatform();
|
|
344
|
+
const byModel = new Map();
|
|
345
|
+
for (const src of sources) {
|
|
346
|
+
const platform = PLATFORMS.includes(src.platform) ? src.platform : null;
|
|
347
|
+
if (platform) {
|
|
348
|
+
const bucket = byPlatform[platform];
|
|
349
|
+
bucket.inputTokens = addNullable(bucket.inputTokens, src.inputTokens);
|
|
350
|
+
bucket.outputTokens = addNullable(bucket.outputTokens, src.outputTokens);
|
|
351
|
+
bucket.totalTokens = addNullable(bucket.totalTokens, src.totalTokens);
|
|
352
|
+
bucket.costUsd = addNullable(bucket.costUsd, src.costUsd);
|
|
353
|
+
bucket.ampCredits = addNullable(bucket.ampCredits, src.ampCredits);
|
|
354
|
+
if (platform === 'claude') bucket.source = 'claude-jsonl';
|
|
355
|
+
else if (platform === 'amp') bucket.source = 'amp-thread';
|
|
356
|
+
else bucket.source = 'cursor-hook';
|
|
357
|
+
}
|
|
358
|
+
const model = src.model;
|
|
359
|
+
if (model) {
|
|
360
|
+
const key = `${model}::${src.platform || ''}`;
|
|
361
|
+
const row = byModel.get(key) || {
|
|
362
|
+
model,
|
|
363
|
+
platform: src.platform || null,
|
|
364
|
+
inputTokens: null,
|
|
365
|
+
outputTokens: null,
|
|
366
|
+
totalTokens: null,
|
|
367
|
+
costUsd: null,
|
|
368
|
+
ampCredits: null,
|
|
369
|
+
};
|
|
370
|
+
row.inputTokens = addNullable(row.inputTokens, src.inputTokens);
|
|
371
|
+
row.outputTokens = addNullable(row.outputTokens, src.outputTokens);
|
|
372
|
+
row.totalTokens = addNullable(row.totalTokens, src.totalTokens);
|
|
373
|
+
row.costUsd = addNullable(row.costUsd, src.costUsd);
|
|
374
|
+
row.ampCredits = addNullable(row.ampCredits, src.ampCredits);
|
|
375
|
+
byModel.set(key, row);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return { byPlatform, byModel: [...byModel.values()] };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function collectSpend(options = {}) {
|
|
382
|
+
const env = options.env || process.env;
|
|
383
|
+
const cwd = options.cwd != null ? options.cwd : process.cwd();
|
|
384
|
+
const homedir = options.homedir || env.HOME || osHomedir();
|
|
385
|
+
const existing = new Set(
|
|
386
|
+
Array.isArray(options.existingSourceIds)
|
|
387
|
+
? options.existingSourceIds
|
|
388
|
+
: options.existingSourceIds instanceof Set
|
|
389
|
+
? [...options.existingSourceIds]
|
|
390
|
+
: [],
|
|
391
|
+
);
|
|
392
|
+
const windowStart = options.windowStart;
|
|
393
|
+
const windowEnd = options.windowEnd;
|
|
394
|
+
const notes = [];
|
|
395
|
+
const ctx = { cwd, windowStart, windowEnd, existing, env, homedir, notes };
|
|
396
|
+
let sources = [];
|
|
397
|
+
try {
|
|
398
|
+
sources = sources.concat(collectClaude(ctx));
|
|
399
|
+
} catch {
|
|
400
|
+
notes.push('claude: adapter failed');
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
sources = sources.concat(collectAmp(ctx));
|
|
404
|
+
} catch {
|
|
405
|
+
notes.push('amp: adapter failed');
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
sources = sources.concat(collectCursor(ctx));
|
|
409
|
+
} catch {
|
|
410
|
+
notes.push('cursor: adapter failed');
|
|
411
|
+
}
|
|
412
|
+
const { byPlatform, byModel } = aggregate(sources);
|
|
413
|
+
return { sources, byPlatform, byModel, notes };
|
|
414
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-orchestrator-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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 runs collect unless `--no-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.
|
|
@@ -20,7 +20,7 @@ Agents (local or cloud) write session artifacts only to git-tracked paths — ne
|
|
|
20
20
|
|
|
21
21
|
## Session Exit (order)
|
|
22
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. `npx agent-orchestrator-kit handoff <name>` — require exit 0 (appends non-empty Decisions into append-only `openspec/changes/<name>/decisions.md`, upserts absolute-path Memory JSON, prints the expanded prompt on stdout). `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).
|
|
23
|
+
2. `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`. Persist auto-collects local usage from Claude JSONL, Amp threads, and the Cursor spend hook file (.agents/spend/cursor-usage.jsonl). 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`. 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).
|
|
24
24
|
3. Spawn `session-handoff` in persist mode ONLY if step 2 failed (Amp: isolated `subagent-session-handoff`). Fallback, never routine.
|
|
25
25
|
4. Memory MCP is an optional mirror: if tools are available, update `Change:<name>`, `Handoff:<name>`, `Decision:*` in one call; unavailability never blocks closing.
|
|
26
26
|
5. 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.
|
|
@@ -146,7 +146,7 @@ Archive is one deterministic CLI call — `npx agent-orchestrator-kit archive <n
|
|
|
146
146
|
|
|
147
147
|
**End of each session (HARD STOP — you are NOT done):**
|
|
148
148
|
1. Write `openspec/changes/<name>/handoff.md` in the parent using the template below.
|
|
149
|
-
2. Run `npx agent-orchestrator-kit handoff <name>` and require exit 0. 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.
|
|
149
|
+
2. 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`. Persist auto-collects local usage from Claude JSONL, Amp threads, and the Cursor spend hook file (.agents/spend/cursor-usage.jsonl). 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`. 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.
|
|
150
150
|
3. 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.
|
|
151
151
|
4. 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.
|
|
152
152
|
5. Do not start the next phase in this chat. If apply, include build/lint status in the persisted Done section.
|
|
@@ -218,7 +218,7 @@ The Prompt section is overwritten by `npx agent-orchestrator-kit handoff <name>`
|
|
|
218
218
|
|
|
219
219
|
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
220
|
|
|
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.
|
|
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> --model <llm-product-id>` (exit 0) — NEVER pass a Closed role or subagent name as `--model`; persist auto-collects local usage from Claude JSONL, Amp threads, and the Cursor spend hook file (.agents/spend/cursor-usage.jsonl); the parent SHOULD still pass `--model` and MUST NOT guess tokens; spend flags override session totals only; optional `--platform`; 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
222
|
|
|
223
223
|
| Entity | Required fields |
|
|
224
224
|
|--------|-----------------|
|
|
@@ -22,7 +22,7 @@ Use when the parent's restore failed (CLI restore and handoff.md both unavailabl
|
|
|
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
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. Run `npx agent-orchestrator-kit handoff <name>` and require exit 0. 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`).
|
|
25
|
+
2. 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`. Persist auto-collects local usage from Claude JSONL, Amp threads, and the Cursor spend hook file (.agents/spend/cursor-usage.jsonl). 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`. 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`).
|
|
26
26
|
3. 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.
|
|
27
27
|
4. Put the CLI stdout prompt (first line `/opsx:…`) into **Next prompt** unchanged. Do not shorten it. Do not add a banner.
|
|
28
28
|
5. 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`.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Cursor hook (stop / subagentStop): 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));
|