agent-orchestrator-kit 0.6.0 → 0.8.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 +32 -0
- package/README.md +45 -18
- package/bin/agent-orchestrator.js +625 -181
- package/bin/session-client.js +187 -0
- package/bin/spend-collect.js +204 -40
- package/package.json +1 -1
- package/templates/.agents/commands/opsx-archive.md +1 -1
- package/templates/.agents/rules/session-handoff.mdc +9 -8
- 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 +1 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readlinkSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir as osHomedir } from 'os';
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
const VALID_PLATFORMS = new Set(['cursor', 'claude', 'amp']);
|
|
7
|
+
const AMP_TTY_MAX_AGE_MS = 2 * 60 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
function trim(value) {
|
|
10
|
+
return value == null ? '' : String(value).trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function envFlagOn(value) {
|
|
14
|
+
if (value == null) return false;
|
|
15
|
+
const normalized = String(value).trim().toLowerCase();
|
|
16
|
+
return normalized !== '' && normalized !== '0' && normalized !== 'false';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ampDataRoot(env = {}, homedir) {
|
|
20
|
+
if (env.AMP_DATA_DIR && String(env.AMP_DATA_DIR).trim()) return String(env.AMP_DATA_DIR).trim();
|
|
21
|
+
if (env.XDG_DATA_HOME && String(env.XDG_DATA_HOME).trim()) {
|
|
22
|
+
return join(String(env.XDG_DATA_HOME).trim(), 'amp');
|
|
23
|
+
}
|
|
24
|
+
return join(homedir || env.HOME || osHomedir(), '.local', 'share', 'amp');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ampThreadIdFromEnv(env = {}) {
|
|
28
|
+
for (const key of ['AMP_CURRENT_THREAD', 'AMP_THREAD_ID']) {
|
|
29
|
+
const value = trim(env[key]);
|
|
30
|
+
if (value) return value;
|
|
31
|
+
}
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isUsableTtyPath(raw) {
|
|
36
|
+
const path = String(raw || '').replace(/^tty:/, '').trim();
|
|
37
|
+
if (!path.startsWith('/dev/')) return false;
|
|
38
|
+
if (path === '/dev/null' || path.startsWith('/dev/null')) return false;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function currentTtyKey(env = {}, readlink = null) {
|
|
43
|
+
const forced = trim(env.AOK_TTY);
|
|
44
|
+
if (forced) {
|
|
45
|
+
const path = forced.startsWith('tty:') ? forced.slice(4) : forced;
|
|
46
|
+
return isUsableTtyPath(path) ? (forced.startsWith('tty:') ? forced : `tty:${path}`) : '';
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const fn = readlink || readlinkSync;
|
|
50
|
+
const raw = String(fn('/proc/self/fd/0') || '').trim();
|
|
51
|
+
return isUsableTtyPath(raw) ? `tty:${raw}` : '';
|
|
52
|
+
} catch {
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const AMP_THREAD_ID_RE = /\bT-[0-9a-fA-F-]{8,}\b/g;
|
|
58
|
+
|
|
59
|
+
export function parseAmpThreadList(text) {
|
|
60
|
+
const ids = [];
|
|
61
|
+
for (const line of String(text || '').split('\n')) {
|
|
62
|
+
if (!line.trim() || /^Title\b/.test(line) || /^─/.test(line)) continue;
|
|
63
|
+
const matches = line.match(AMP_THREAD_ID_RE);
|
|
64
|
+
if (!matches || !matches.length) continue;
|
|
65
|
+
const id = matches[matches.length - 1];
|
|
66
|
+
if (!ids.includes(id)) ids.push(id);
|
|
67
|
+
}
|
|
68
|
+
return ids;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function listRecentAmpThreadIds(options = {}) {
|
|
72
|
+
if (typeof options.listAmpThreads === 'function') {
|
|
73
|
+
try {
|
|
74
|
+
const out = options.listAmpThreads();
|
|
75
|
+
if (Array.isArray(out)) return out.map((id) => trim(id)).filter(Boolean);
|
|
76
|
+
return parseAmpThreadList(out);
|
|
77
|
+
} catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const env = options.env || {};
|
|
82
|
+
const bin = options.ampBin || trim(env.AOK_AMP_BIN) || 'amp';
|
|
83
|
+
if (bin !== 'amp' && !existsSync(bin)) return [];
|
|
84
|
+
try {
|
|
85
|
+
const text = execFileSync(bin, ['threads', 'list', '--limit', String(options.limit || 5)], {
|
|
86
|
+
encoding: 'utf-8',
|
|
87
|
+
timeout: options.timeoutMs != null ? Number(options.timeoutMs) : 15000,
|
|
88
|
+
env,
|
|
89
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
90
|
+
});
|
|
91
|
+
return parseAmpThreadList(text);
|
|
92
|
+
} catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function parentProcessComm(ppid = process.ppid, readFile = readFileSync) {
|
|
98
|
+
try {
|
|
99
|
+
return String(readFile(`/proc/${ppid}/comm`, 'utf-8')).trim();
|
|
100
|
+
} catch {
|
|
101
|
+
return '';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function looksLikeAmpProcess(comm) {
|
|
106
|
+
const name = String(comm || '').toLowerCase();
|
|
107
|
+
return name === 'amp' || name.startsWith('amp');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isFreshTimestamp(value, now, maxAgeMs) {
|
|
111
|
+
if (value == null || value === '') return false;
|
|
112
|
+
const n = typeof value === 'number' ? (value < 1e12 ? value * 1000 : value) : Date.parse(String(value));
|
|
113
|
+
if (!Number.isFinite(n)) return false;
|
|
114
|
+
return now - n >= 0 && now - n <= maxAgeMs;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function readAmpSessionHint(options = {}) {
|
|
118
|
+
const env = options.env || {};
|
|
119
|
+
const homedir = options.homedir || env.HOME;
|
|
120
|
+
const now = options.now != null ? Number(options.now) : Date.now();
|
|
121
|
+
const maxAgeMs = options.maxAgeMs != null ? Number(options.maxAgeMs) : AMP_TTY_MAX_AGE_MS;
|
|
122
|
+
const filePath = join(ampDataRoot(env, homedir), 'session.json');
|
|
123
|
+
if (!existsSync(filePath)) return { threadId: '', source: '' };
|
|
124
|
+
let data;
|
|
125
|
+
try {
|
|
126
|
+
data = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
127
|
+
} catch {
|
|
128
|
+
return { threadId: '', source: '' };
|
|
129
|
+
}
|
|
130
|
+
if (!data || typeof data !== 'object') return { threadId: '', source: '', lastThreadId: '' };
|
|
131
|
+
const lastThreadId = trim(data.lastThreadId);
|
|
132
|
+
const rawTty = options.ttyKey != null ? options.ttyKey : currentTtyKey(env, options.readlink);
|
|
133
|
+
const ttyKey = isUsableTtyPath(rawTty) ? (String(rawTty).startsWith('tty:') ? rawTty : `tty:${rawTty}`) : '';
|
|
134
|
+
const byTty = data.lastThreadByTerminal && ttyKey ? data.lastThreadByTerminal[ttyKey] : null;
|
|
135
|
+
if (byTty && trim(byTty.lastThreadId) && isFreshTimestamp(byTty.updatedAt, now, maxAgeMs)) {
|
|
136
|
+
return { threadId: trim(byTty.lastThreadId), source: 'amp-session-tty', lastThreadId };
|
|
137
|
+
}
|
|
138
|
+
return { threadId: '', source: '', lastThreadId };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function detectSessionClient(options = {}) {
|
|
142
|
+
const env = options.env || {};
|
|
143
|
+
const ampId = ampThreadIdFromEnv(env);
|
|
144
|
+
if (ampId) {
|
|
145
|
+
return { platform: 'amp', threadId: ampId, source: 'amp-env' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (envFlagOn(env.CURSOR_AGENT) || trim(env.CURSOR_CONVERSATION_ID)) {
|
|
149
|
+
return { platform: 'cursor', threadId: null, source: 'cursor-env' };
|
|
150
|
+
}
|
|
151
|
+
if (envFlagOn(env.CLAUDECODE) || envFlagOn(env.CLAUDE_CODE) || trim(env.CLAUDE_CODE_ENTRYPOINT)) {
|
|
152
|
+
return { platform: 'claude', threadId: null, source: 'claude-env' };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const comm = options.parentComm != null ? options.parentComm : parentProcessComm();
|
|
156
|
+
const hint = readAmpSessionHint(options);
|
|
157
|
+
if (looksLikeAmpProcess(comm)) {
|
|
158
|
+
if (hint.threadId) {
|
|
159
|
+
return { platform: 'amp', threadId: hint.threadId, source: 'amp-parent' };
|
|
160
|
+
}
|
|
161
|
+
const listed = listRecentAmpThreadIds(options);
|
|
162
|
+
return {
|
|
163
|
+
platform: 'amp',
|
|
164
|
+
threadId: listed[0] || null,
|
|
165
|
+
source: listed[0] ? 'amp-threads-list' : 'amp-parent',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if (hint.threadId) {
|
|
169
|
+
return { platform: 'amp', threadId: hint.threadId, source: hint.source };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { platform: null, threadId: null, source: 'none' };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function resolveRestoreClient(options = {}) {
|
|
176
|
+
const env = options.env || {};
|
|
177
|
+
const detected = detectSessionClient(options);
|
|
178
|
+
const flag = trim(options.platform || env.AOK_PLATFORM).toLowerCase();
|
|
179
|
+
if (flag && VALID_PLATFORMS.has(flag)) {
|
|
180
|
+
return {
|
|
181
|
+
platform: flag,
|
|
182
|
+
threadId: detected.threadId,
|
|
183
|
+
source: options.platform ? 'flag' : 'aok-platform',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return detected;
|
|
187
|
+
}
|
package/bin/spend-collect.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
2
2
|
import { join, basename } from 'path';
|
|
3
3
|
import { homedir as osHomedir } from 'os';
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
5
|
+
import { listRecentAmpThreadIds } from './session-client.js';
|
|
4
6
|
|
|
5
7
|
const PLATFORMS = ['cursor', 'claude', 'amp'];
|
|
6
8
|
|
|
@@ -43,6 +45,19 @@ function stripFileUri(uri) {
|
|
|
43
45
|
return value.startsWith('file://') ? value.slice('file://'.length) : value;
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
function normalizeFsPath(value) {
|
|
49
|
+
const stripped = stripFileUri(value).trim();
|
|
50
|
+
if (!stripped) return '';
|
|
51
|
+
if (stripped.length > 1 && stripped.endsWith('/')) return stripped.replace(/\/+$/, '');
|
|
52
|
+
return stripped;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function pathsEqual(a, b) {
|
|
56
|
+
const left = normalizeFsPath(a);
|
|
57
|
+
const right = normalizeFsPath(b);
|
|
58
|
+
return Boolean(left) && left === right;
|
|
59
|
+
}
|
|
60
|
+
|
|
46
61
|
function parseTime(value) {
|
|
47
62
|
if (value == null || value === '') return NaN;
|
|
48
63
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
@@ -188,10 +203,69 @@ function ampRoot(env, homedir) {
|
|
|
188
203
|
return join(homedir || env.HOME || osHomedir(), '.local', 'share', 'amp');
|
|
189
204
|
}
|
|
190
205
|
|
|
191
|
-
function
|
|
206
|
+
function ampTrees(thread) {
|
|
192
207
|
const trees = thread && thread.env && thread.env.initial && thread.env.initial.trees;
|
|
193
|
-
|
|
194
|
-
|
|
208
|
+
return Array.isArray(trees) ? trees : [];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function ampCwdCandidates(thread) {
|
|
212
|
+
const out = [];
|
|
213
|
+
const push = (value) => {
|
|
214
|
+
if (typeof value === 'string' && value.trim()) out.push(value);
|
|
215
|
+
};
|
|
216
|
+
push(thread && thread.cwd);
|
|
217
|
+
push(thread && thread.workdir);
|
|
218
|
+
const env = thread && thread.env;
|
|
219
|
+
if (env && typeof env === 'object') {
|
|
220
|
+
push(env.cwd);
|
|
221
|
+
push(env.PWD);
|
|
222
|
+
push(env.pwd);
|
|
223
|
+
if (env.initial && typeof env.initial === 'object') {
|
|
224
|
+
push(env.initial.cwd);
|
|
225
|
+
push(env.initial.PWD);
|
|
226
|
+
push(env.initial.workdir);
|
|
227
|
+
push(env.initial.workspace);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const meta = thread && thread.meta;
|
|
231
|
+
if (meta && typeof meta === 'object') {
|
|
232
|
+
push(meta.cwd);
|
|
233
|
+
push(meta.workdir);
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function ampCurrentThreadId(env) {
|
|
239
|
+
if (!env || typeof env !== 'object') return '';
|
|
240
|
+
for (const key of ['AMP_CURRENT_THREAD', 'AMP_THREAD_ID']) {
|
|
241
|
+
const value = env[key];
|
|
242
|
+
if (value != null && String(value).trim()) return String(value).trim();
|
|
243
|
+
}
|
|
244
|
+
return '';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function ampThreadMentionsCwd(thread, cwd) {
|
|
248
|
+
const target = normalizeFsPath(cwd);
|
|
249
|
+
if (!target || target.length < 2) return false;
|
|
250
|
+
let blob;
|
|
251
|
+
try {
|
|
252
|
+
blob = JSON.stringify(thread);
|
|
253
|
+
} catch {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
return blob.includes(target) || blob.includes(`file://${target}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function ampThreadMatches(thread, cwd, env, fileName) {
|
|
260
|
+
const trees = ampTrees(thread);
|
|
261
|
+
if (trees.length > 0) {
|
|
262
|
+
return trees.some((tree) => tree && pathsEqual(tree.uri, cwd));
|
|
263
|
+
}
|
|
264
|
+
if (ampCwdCandidates(thread).some((candidate) => pathsEqual(candidate, cwd))) return true;
|
|
265
|
+
const threadKey = thread && thread.id ? String(thread.id) : basename(fileName, '.json');
|
|
266
|
+
const current = ampCurrentThreadId(env);
|
|
267
|
+
if (current && current === threadKey) return true;
|
|
268
|
+
return ampThreadMentionsCwd(thread, cwd);
|
|
195
269
|
}
|
|
196
270
|
|
|
197
271
|
function ampMessages(thread) {
|
|
@@ -237,6 +311,36 @@ function ampInputTokens(usage) {
|
|
|
237
311
|
return has ? sum : null;
|
|
238
312
|
}
|
|
239
313
|
|
|
314
|
+
export function sourcesFromAmpThread(thread, ctx, fileName = '', via = null) {
|
|
315
|
+
const sources = [];
|
|
316
|
+
if (!thread || typeof thread !== 'object') return sources;
|
|
317
|
+
const { cwd, windowStart, windowEnd, existing, env } = ctx;
|
|
318
|
+
if (!ampThreadMatches(thread, cwd, env, fileName)) return sources;
|
|
319
|
+
const threadKey = thread.id ? String(thread.id) : basename(fileName || 'thread', '.json');
|
|
320
|
+
for (const message of ampMessages(thread)) {
|
|
321
|
+
const usage = ampUsage(message);
|
|
322
|
+
if (!usage) continue;
|
|
323
|
+
const rawId = ampId(message);
|
|
324
|
+
if (rawId == null || rawId === '') continue;
|
|
325
|
+
const id = `${threadKey}:${rawId}`;
|
|
326
|
+
if (existing.has(id)) continue;
|
|
327
|
+
if (!inWindow(usage.timestamp, windowStart, windowEnd)) continue;
|
|
328
|
+
const record = sourceRecord({
|
|
329
|
+
id,
|
|
330
|
+
platform: 'amp',
|
|
331
|
+
model: usage.model,
|
|
332
|
+
inputTokens: ampInputTokens(usage),
|
|
333
|
+
outputTokens: usage.outputTokens,
|
|
334
|
+
costUsd: null,
|
|
335
|
+
ampCredits: null,
|
|
336
|
+
at: usage.timestamp,
|
|
337
|
+
});
|
|
338
|
+
if (via) record.via = via;
|
|
339
|
+
sources.push(record);
|
|
340
|
+
}
|
|
341
|
+
return sources;
|
|
342
|
+
}
|
|
343
|
+
|
|
240
344
|
function collectAmp({ cwd, windowStart, windowEnd, existing, env, homedir, notes }) {
|
|
241
345
|
const root = ampRoot(env, homedir);
|
|
242
346
|
const threadsDir = join(root, 'threads');
|
|
@@ -252,6 +356,7 @@ function collectAmp({ cwd, windowStart, windowEnd, existing, env, homedir, notes
|
|
|
252
356
|
notes.push('amp: cannot read threads');
|
|
253
357
|
return sources;
|
|
254
358
|
}
|
|
359
|
+
const ctx = { cwd, windowStart, windowEnd, existing, env, homedir, notes };
|
|
255
360
|
for (const file of files) {
|
|
256
361
|
let thread;
|
|
257
362
|
try {
|
|
@@ -259,30 +364,59 @@ function collectAmp({ cwd, windowStart, windowEnd, existing, env, homedir, notes
|
|
|
259
364
|
} catch {
|
|
260
365
|
continue;
|
|
261
366
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
367
|
+
sources.push(...sourcesFromAmpThread(thread, ctx, file));
|
|
368
|
+
}
|
|
369
|
+
return sources;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function exportAmpThread(threadId, options = {}) {
|
|
373
|
+
const id = threadId == null ? '' : String(threadId).trim();
|
|
374
|
+
if (!id) return null;
|
|
375
|
+
if (typeof options.exportAmpThread === 'function') {
|
|
376
|
+
try {
|
|
377
|
+
return options.exportAmpThread(id);
|
|
378
|
+
} catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const bin = options.ampBin || (options.env && options.env.AOK_AMP_BIN) || 'amp';
|
|
383
|
+
if (bin !== 'amp' && !existsSync(bin)) return null;
|
|
384
|
+
try {
|
|
385
|
+
const out = execFileSync(bin, ['threads', 'export', id], {
|
|
386
|
+
encoding: 'utf-8',
|
|
387
|
+
timeout: options.timeoutMs != null ? Number(options.timeoutMs) : 15000,
|
|
388
|
+
env: options.env || process.env,
|
|
389
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
390
|
+
});
|
|
391
|
+
const parsed = JSON.parse(out);
|
|
392
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
393
|
+
} catch {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function collectAmpCli(ctx) {
|
|
399
|
+
const { env, notes, ampThreadId } = ctx;
|
|
400
|
+
const ids = [];
|
|
401
|
+
const push = (value) => {
|
|
402
|
+
const id = value == null ? '' : String(value).trim();
|
|
403
|
+
if (id && !ids.includes(id)) ids.push(id);
|
|
404
|
+
};
|
|
405
|
+
push(ampThreadId);
|
|
406
|
+
push(ampCurrentThreadId(env));
|
|
407
|
+
if (!ids.length) {
|
|
408
|
+
for (const id of listRecentAmpThreadIds(ctx)) push(id);
|
|
409
|
+
}
|
|
410
|
+
const sources = [];
|
|
411
|
+
for (const id of ids) {
|
|
412
|
+
const thread = exportAmpThread(id, ctx);
|
|
413
|
+
if (!thread) {
|
|
414
|
+
notes.push(`amp: export failed for ${id}`);
|
|
415
|
+
continue;
|
|
285
416
|
}
|
|
417
|
+
const extracted = sourcesFromAmpThread(thread, ctx, `${id}.json`, 'amp-cli');
|
|
418
|
+
if (!extracted.length) notes.push(`amp: export ${id} had no matching usage`);
|
|
419
|
+
sources.push(...extracted);
|
|
286
420
|
}
|
|
287
421
|
return sources;
|
|
288
422
|
}
|
|
@@ -352,7 +486,7 @@ function aggregate(sources) {
|
|
|
352
486
|
bucket.costUsd = addNullable(bucket.costUsd, src.costUsd);
|
|
353
487
|
bucket.ampCredits = addNullable(bucket.ampCredits, src.ampCredits);
|
|
354
488
|
if (platform === 'claude') bucket.source = 'claude-jsonl';
|
|
355
|
-
else if (platform === 'amp') bucket.source = 'amp-thread';
|
|
489
|
+
else if (platform === 'amp') bucket.source = src.via === 'amp-cli' ? 'amp-cli' : 'amp-thread';
|
|
356
490
|
else bucket.source = 'cursor-hook';
|
|
357
491
|
}
|
|
358
492
|
const model = src.model;
|
|
@@ -392,22 +526,52 @@ export function collectSpend(options = {}) {
|
|
|
392
526
|
const windowStart = options.windowStart;
|
|
393
527
|
const windowEnd = options.windowEnd;
|
|
394
528
|
const notes = [];
|
|
395
|
-
const
|
|
529
|
+
const wanted = Array.isArray(options.platforms)
|
|
530
|
+
? options.platforms.filter((name) => PLATFORMS.includes(name))
|
|
531
|
+
: PLATFORMS;
|
|
532
|
+
const run = (name) => !wanted.length || wanted.includes(name);
|
|
533
|
+
const ctx = {
|
|
534
|
+
cwd,
|
|
535
|
+
windowStart,
|
|
536
|
+
windowEnd,
|
|
537
|
+
existing,
|
|
538
|
+
env,
|
|
539
|
+
homedir,
|
|
540
|
+
notes,
|
|
541
|
+
ampThreadId: options.ampThreadId,
|
|
542
|
+
exportAmpThread: options.exportAmpThread,
|
|
543
|
+
listAmpThreads: options.listAmpThreads,
|
|
544
|
+
ampBin: options.ampBin,
|
|
545
|
+
timeoutMs: options.timeoutMs,
|
|
546
|
+
};
|
|
396
547
|
let sources = [];
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
548
|
+
if (run('claude')) {
|
|
549
|
+
try {
|
|
550
|
+
sources = sources.concat(collectClaude(ctx));
|
|
551
|
+
} catch {
|
|
552
|
+
notes.push('claude: adapter failed');
|
|
553
|
+
}
|
|
401
554
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
555
|
+
if (run('amp')) {
|
|
556
|
+
if (options.ampCli === true || typeof options.exportAmpThread === 'function') {
|
|
557
|
+
try {
|
|
558
|
+
sources = sources.concat(collectAmpCli(ctx));
|
|
559
|
+
} catch {
|
|
560
|
+
notes.push('amp: cli export failed');
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
try {
|
|
564
|
+
sources = sources.concat(collectAmp(ctx));
|
|
565
|
+
} catch {
|
|
566
|
+
notes.push('amp: adapter failed');
|
|
567
|
+
}
|
|
406
568
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
569
|
+
if (run('cursor')) {
|
|
570
|
+
try {
|
|
571
|
+
sources = sources.concat(collectCursor(ctx));
|
|
572
|
+
} catch {
|
|
573
|
+
notes.push('cursor: adapter failed');
|
|
574
|
+
}
|
|
411
575
|
}
|
|
412
576
|
const { byPlatform, byModel } = aggregate(sources);
|
|
413
577
|
return { sources, byPlatform, byModel, notes };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-orchestrator-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -21,7 +21,7 @@ Archive is one CLI call, no phase subagents.
|
|
|
21
21
|
npx agent-orchestrator-kit archive <name> [--sync | --no-sync --force]
|
|
22
22
|
```
|
|
23
23
|
|
|
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
|
|
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
26
|
4. **Show stdout as-is.** On exit ≠ 0, report the gate from stderr and stop — no manual merge/move.
|
|
27
27
|
|
|
@@ -12,20 +12,21 @@ Agents (local or cloud) write session artifacts only to git-tracked paths — ne
|
|
|
12
12
|
## Session Start (before any work)
|
|
13
13
|
1. Honor pasted `/opsx:<phase> <name>` and announce the role.
|
|
14
14
|
2. `npx agent-orchestrator-kit status`
|
|
15
|
-
3. `npx agent-orchestrator-kit handoff --restore` (or `handoff <name> --restore`). The CLI briefing is canonical — it already reads memory.json and handoff.md; accumulated decisions print from git-tracked `openspec/changes/<name>/decisions.md`, not from Memory. No separate Memory MCP read step.
|
|
15
|
+
3. `npx agent-orchestrator-kit handoff --restore` (or `handoff <name> --restore`). The CLI briefing is canonical — it already reads memory.json and handoff.md; accumulated decisions print from git-tracked `openspec/changes/<name>/decisions.md`, not from Memory. No separate Memory MCP read step. Restore also locks the session client (`cursor` / `claude` / `amp`) into `metrics.json` `pending` — persist will follow that client’s spend flow. Override with `--platform` when detection is wrong.
|
|
16
16
|
4. If the restore CLI failed → read `openspec/changes/<name>/handoff.md` directly.
|
|
17
17
|
5. Spawn `session-handoff` in restore mode ONLY if both the CLI and handoff.md are unavailable (Amp: isolated `subagent-session-handoff`). This is a fallback, never a routine step.
|
|
18
18
|
6. Free-form continue/next/«далі» with one active change → execute `Handoff.next_command`.
|
|
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`, `accounts/fireworks/models/glm-5p2`) — NEVER pass a Closed role, a subagent name, or an Amp **mode** (`low`, `medium`, `high`, `ultra`) as `--model`. The parent SHOULD still pass `--model`. The parent MUST NOT guess tokens. Persist collects spend for the client locked at restore (Amp: `amp threads export` + local threads; Cursor: hook file; Claude: JSONL). `--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` runs all three adapters, not only the locked client. The same `npx agent-orchestrator-kit handoff <name>` works in Cursor, Claude Code, and Amp. `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> --model <llm-product-id>` (exit 0) — NEVER pass a Closed role or subagent name as `--model`;
|
|
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
|
|