agent-orchestrator-kit 0.7.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 +12 -0
- package/README.md +4 -3
- package/bin/agent-orchestrator.js +68 -16
- package/bin/session-client.js +187 -0
- package/bin/spend-collect.js +129 -37
- package/package.json +1 -1
- package/templates/.agents/rules/session-handoff.mdc +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.8.0] - 2026-08-31
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Locked session client** — `handoff --restore` detects `cursor` / `claude` / `amp` (env, Amp parent process, or recent `session.json` tty mapping) and stores `pending.platform` + `pending.threadId`. Persist follows that client: Amp runs `amp threads export` plus local thread JSON; Cursor reads the spend hook; Claude reads `~/.claude/projects`. `--platform` / `AOK_PLATFORM` / `## Metrics` still override.
|
|
11
|
+
- **Amp CLI export adapter** (`amp-cli`) — when the locked client is Amp (or `--collect`), the kit calls `amp threads export <id>` (override `AOK_AMP_BIN`). Fail-open if Amp CLI is missing. `agentMode` (`low`/`medium`) is never stored as `session.model`.
|
|
12
|
+
- **Amp web / pipe restore** — if the parent process is `amp` and stdin is `/dev/null` (no pts), the kit takes the newest id from `amp threads list` instead of stale `session.json` `lastThreadId`.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Persist without `--collect` now collects **only the locked/resolved client**, not all three adapters. `--collect` still runs every adapter.
|
|
16
|
+
|
|
7
17
|
## [0.7.0] - 2026-08-30
|
|
8
18
|
|
|
9
19
|
### Breaking
|
|
@@ -231,6 +241,8 @@ All notable changes to this project will be documented in this file.
|
|
|
231
241
|
### Added
|
|
232
242
|
- Initial release: 5-role orchestration pipeline, `/opsx:*` commands, IDE sync
|
|
233
243
|
|
|
244
|
+
[0.8.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.7.0...v0.8.0
|
|
245
|
+
[0.7.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.6.0...v0.7.0
|
|
234
246
|
[0.6.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.5.0...v0.6.0
|
|
235
247
|
[0.5.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.4.0...v0.5.0
|
|
236
248
|
[0.4.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.3.0...v0.4.0
|
package/README.md
CHANGED
|
@@ -74,7 +74,7 @@ npx agent-orchestrator-kit@latest init --profile generic --ci gitlab --spec-veri
|
|
|
74
74
|
|
|
75
75
|
See [Installation](#installation) for profile/CI options.
|
|
76
76
|
|
|
77
|
-
**🔄 Already have the kit installed? Upgrade to latest (`## Metrics` self-report + opt-in `--collect` in v0.7.0 — **BREAKING:** `--no-collect` is gone; change metrics in v0.5.0+, factory phases 1–3 in v0.4.0+, lean pipeline / archive CLI in v0.3.0+, handoff CLI in v0.1.14+, Figma PAT in v0.1.11+):**
|
|
77
|
+
**🔄 Already have the kit installed? Upgrade to latest (locked Amp/Cursor client + `amp threads export` in v0.8.0; `## Metrics` self-report + opt-in `--collect` in v0.7.0 — **BREAKING:** `--no-collect` is gone; change metrics in v0.5.0+, factory phases 1–3 in v0.4.0+, lean pipeline / archive CLI in v0.3.0+, handoff CLI in v0.1.14+, Figma PAT in v0.1.11+):**
|
|
78
78
|
|
|
79
79
|
```bash
|
|
80
80
|
npx agent-orchestrator-kit@latest update
|
|
@@ -806,8 +806,9 @@ Every change accumulates git-tracked `openspec/changes/<name>/metrics.json` —
|
|
|
806
806
|
- **Session start** — `handoff --restore` writes a `pending` marker (`startedAt`, expected role).
|
|
807
807
|
- **Session end** — `handoff <name>` closes the pending session: duration, closed role, mapped phase (`explore` / `design` / `spec` / `review` / `apply` / `archive`), runtime (local/cloud), tasks snapshot (`n/m`), and spend from flags → self-report → optional `--collect` adapters. `--input-tokens` / `--output-tokens` / `--total-tokens` / `--cost-usd` override session totals only and do not wipe `spendByPlatform` / `spendByModel`. Never guess. Never invent USD from Amp credits. Never Cursor SDK / npm sqlite / a pricing table. No restore marker? Pass `--started-at <iso>` or the duration stays honestly `null`.
|
|
808
808
|
- **Archive** — successful `archive <name>` always creates or finalizes `metrics.json`, sets `archivedAt`, appends an Archiver session from the archived `## Metrics` (or archive flags), prints the same human summary as `metrics <name>`, and collects adapters only with `--collect`.
|
|
809
|
-
- **Platform** — `--platform` → `## Metrics: platform` → `AOK_PLATFORM` → host env (Amp / Cursor / Claude Code) → collected sources (`cursor|claude|amp` only). Invalid `--platform` fails before persist/move.
|
|
810
|
-
- **
|
|
809
|
+
- **Platform** — `--platform` → `## Metrics: platform` → `AOK_PLATFORM` → **pending client from `--restore`** → host env (Amp / Cursor / Claude Code) → collected sources (`cursor|claude|amp` only). Invalid `--platform` fails before persist/move.
|
|
810
|
+
- **Locked client** — `--restore` records `pending.platform` and Amp `pending.threadId` before phase work. Persist follows that client’s flow even if persist runs in another shell (no `AMP_*` / `CURSOR_*`). Amp: `amp threads export` (`AOK_AMP_BIN`) plus local `threads/*.json`. If Amp runs tools over a pipe (`/dev/null`), thread id comes from `amp threads list`, not stale `session.json` `lastThreadId`. Cursor: spend hook file. Claude: `~/.claude/projects`. `--collect` still runs all three adapters.
|
|
811
|
+
- **Cursor spend hook (optional)** — Cursor never writes token usage to disk, so the kit can install `scripts/cursor-spend-hook.cjs` plus `.cursor/hooks.json` entries (`stop` / `subagentStop` / `afterAgentResponse`) in `init` / `update` / `sync` / `mcp-setup`: the hook appends each turn's tokens to gitignored `.agents/spend/cursor-usage.jsonl`. Persist auto-reads that file when the locked client is Cursor. Persist and restore do not self-heal the hook. `sessionEnd` still runs `scripts/cursor-spend-collect.cjs`. Restart Cursor once after the first install. `status` shows a `Spend capture` section. Claude JSONL and Amp disk threads remain fallbacks; Amp web/CLI spend is taken from `amp threads export`.
|
|
811
812
|
|
|
812
813
|
Aggregates are recomputed on every write: per-phase totals (`durationMs`, tokens, `costUsd`, `sessions`, `roles`, `models`) plus overall `totals` (`sessions`, `cloudSessions`, `durationMs` = sum of session work time, `leadTimeMs` = wall clock from first session start to last session end), `spend` (USD only), and separate **by platform** / **by model** tables. Numbers are null-honest: a metric nobody reported stays `null`, never a fake `0`. No single total that adds Amp credits to USD.
|
|
813
814
|
|
|
@@ -6,6 +6,7 @@ import { join, dirname, basename, resolve } from 'path';
|
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
import { execSync } from 'child_process';
|
|
8
8
|
import { collectSpend } from './spend-collect.js';
|
|
9
|
+
import { resolveRestoreClient, ampThreadIdFromEnv } from './session-client.js';
|
|
9
10
|
|
|
10
11
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
12
|
const KIT_ROOT = join(__dirname, '..');
|
|
@@ -484,7 +485,7 @@ function printSpendHealth(projectDir) {
|
|
|
484
485
|
? String(process.env.AMP_DATA_DIR).trim()
|
|
485
486
|
: join(home, '.local', 'share', 'amp');
|
|
486
487
|
const ampOk = existsSync(join(ampDir, 'threads'));
|
|
487
|
-
console.log(` amp ${ampOk ? 'ok (threads found)' : 'no local Amp data'}`);
|
|
488
|
+
console.log(` amp ${ampOk ? 'ok (threads found)' : 'no local Amp data'}; locked client + amp threads export`);
|
|
488
489
|
console.log('');
|
|
489
490
|
}
|
|
490
491
|
|
|
@@ -1151,7 +1152,7 @@ function inferPlatformFromHost(env) {
|
|
|
1151
1152
|
return null;
|
|
1152
1153
|
}
|
|
1153
1154
|
|
|
1154
|
-
function resolvePlatform(opts, env, reported) {
|
|
1155
|
+
function resolvePlatform(opts, env, reported, pending) {
|
|
1155
1156
|
const flag = opts && opts.platform != null ? String(opts.platform).trim() : '';
|
|
1156
1157
|
if (flag) {
|
|
1157
1158
|
const lower = flag.toLowerCase();
|
|
@@ -1172,6 +1173,10 @@ function resolvePlatform(opts, env, reported) {
|
|
|
1172
1173
|
if (VALID_PLATFORMS.has(lower)) return { value: lower };
|
|
1173
1174
|
return { value: null, warn: 'invalid AOK_PLATFORM (use cursor, claude, or amp)' };
|
|
1174
1175
|
}
|
|
1176
|
+
const pendingPlatform = pending && pending.platform != null ? String(pending.platform).trim() : '';
|
|
1177
|
+
if (pendingPlatform && VALID_PLATFORMS.has(pendingPlatform)) {
|
|
1178
|
+
return { value: pendingPlatform };
|
|
1179
|
+
}
|
|
1175
1180
|
return { value: inferPlatformFromHost(env) };
|
|
1176
1181
|
}
|
|
1177
1182
|
|
|
@@ -1856,7 +1861,7 @@ function sessionTotalsFromSources(sources) {
|
|
|
1856
1861
|
return { inputTokens, outputTokens, totalTokens, costUsd };
|
|
1857
1862
|
}
|
|
1858
1863
|
|
|
1859
|
-
function runCollectSpend(metrics, windowStart, windowEnd) {
|
|
1864
|
+
function runCollectSpend(metrics, windowStart, windowEnd, extra = {}) {
|
|
1860
1865
|
try {
|
|
1861
1866
|
return collectSpend({
|
|
1862
1867
|
cwd: process.cwd(),
|
|
@@ -1865,6 +1870,10 @@ function runCollectSpend(metrics, windowStart, windowEnd) {
|
|
|
1865
1870
|
existingSourceIds: existingSourceIdSet(metrics),
|
|
1866
1871
|
env: process.env,
|
|
1867
1872
|
homedir: process.env.HOME,
|
|
1873
|
+
platforms: extra.platforms,
|
|
1874
|
+
ampThreadId: extra.ampThreadId,
|
|
1875
|
+
ampCli: extra.ampCli === true,
|
|
1876
|
+
exportAmpThread: extra.exportAmpThread,
|
|
1868
1877
|
});
|
|
1869
1878
|
} catch {
|
|
1870
1879
|
return { sources: [], byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
|
|
@@ -1910,7 +1919,11 @@ function metricsBackfillFile(filePath, changeName) {
|
|
|
1910
1919
|
if (!sessions.length) return { filePath, added: 0, empty: true };
|
|
1911
1920
|
const last = sessions[sessions.length - 1];
|
|
1912
1921
|
const windowStart = last.startedAt || collectWindowStart(metrics);
|
|
1913
|
-
const
|
|
1922
|
+
const lastPlatform = last && last.platform;
|
|
1923
|
+
const collected = runCollectSpend(metrics, windowStart, nowIso, {
|
|
1924
|
+
ampThreadId: last && last.threadId || ampThreadIdFromEnv(process.env) || null,
|
|
1925
|
+
ampCli: lastPlatform === 'amp',
|
|
1926
|
+
});
|
|
1914
1927
|
const incoming = collected.sources || [];
|
|
1915
1928
|
if (!incoming.length) return { filePath, added: 0 };
|
|
1916
1929
|
const overridden = sessionTotalsLookOverridden(last);
|
|
@@ -1935,9 +1948,9 @@ function metricsBackfillFile(filePath, changeName) {
|
|
|
1935
1948
|
return { filePath, added: incoming.length };
|
|
1936
1949
|
}
|
|
1937
1950
|
|
|
1938
|
-
function adapterSourceName(platform) {
|
|
1951
|
+
function adapterSourceName(platform, via) {
|
|
1939
1952
|
if (platform === 'claude') return 'claude-jsonl';
|
|
1940
|
-
if (platform === 'amp') return 'amp-thread';
|
|
1953
|
+
if (platform === 'amp') return via === 'amp-cli' ? 'amp-cli' : 'amp-thread';
|
|
1941
1954
|
if (platform === 'cursor') return 'cursor-hook';
|
|
1942
1955
|
return null;
|
|
1943
1956
|
}
|
|
@@ -2060,11 +2073,17 @@ function recomputeMetricsAggregates(metrics) {
|
|
|
2060
2073
|
recomputeSpendMaps(metrics);
|
|
2061
2074
|
}
|
|
2062
2075
|
|
|
2063
|
-
function metricsRecordSessionStart(projectDir, changeName, role) {
|
|
2076
|
+
function metricsRecordSessionStart(projectDir, changeName, role, client = {}) {
|
|
2064
2077
|
const filePath = metricsFilePath(projectDir, changeName);
|
|
2065
2078
|
const nowIso = new Date().toISOString();
|
|
2066
2079
|
const metrics = loadMetricsFile(filePath, changeName, nowIso);
|
|
2067
|
-
metrics.pending = {
|
|
2080
|
+
metrics.pending = {
|
|
2081
|
+
startedAt: nowIso,
|
|
2082
|
+
role: role || '',
|
|
2083
|
+
platform: client.platform || null,
|
|
2084
|
+
threadId: client.threadId || null,
|
|
2085
|
+
clientSource: client.source || null,
|
|
2086
|
+
};
|
|
2068
2087
|
metrics.updatedAt = nowIso;
|
|
2069
2088
|
saveMetricsFile(filePath, metrics);
|
|
2070
2089
|
return filePath;
|
|
@@ -2079,8 +2098,17 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
|
2079
2098
|
const reported = opts.reported || fields.metrics || emptyMetricsFields();
|
|
2080
2099
|
const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
|
|
2081
2100
|
const windowStart = collectWindowStart(metrics);
|
|
2082
|
-
const
|
|
2083
|
-
|
|
2101
|
+
const pending = metrics.pending || {};
|
|
2102
|
+
const platform = opts.platform || pending.platform || null;
|
|
2103
|
+
const collectAll = opts.collect === true;
|
|
2104
|
+
const platforms = collectAll ? undefined : (platform ? [platform] : []);
|
|
2105
|
+
const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
|
|
2106
|
+
const collected = shouldCollect
|
|
2107
|
+
? runCollectSpend(metrics, windowStart, nowIso, {
|
|
2108
|
+
platforms,
|
|
2109
|
+
ampThreadId: opts.ampThreadId || pending.threadId || ampThreadIdFromEnv(process.env) || null,
|
|
2110
|
+
ampCli: collectAll || platform === 'amp',
|
|
2111
|
+
})
|
|
2084
2112
|
: { sources: [] };
|
|
2085
2113
|
const session = {
|
|
2086
2114
|
startedAt,
|
|
@@ -2091,7 +2119,8 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
|
2091
2119
|
runtime: fields.runtime || 'local',
|
|
2092
2120
|
agentId: fields.agentId || 'none',
|
|
2093
2121
|
model: resolvedModel || null,
|
|
2094
|
-
platform: opts.platform || null,
|
|
2122
|
+
platform: opts.platform || pending.platform || null,
|
|
2123
|
+
threadId: opts.ampThreadId || pending.threadId || null,
|
|
2095
2124
|
tasks: fields.tasks || null,
|
|
2096
2125
|
sources: [],
|
|
2097
2126
|
inputTokens: null,
|
|
@@ -2120,8 +2149,16 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
|
|
|
2120
2149
|
const nowIso = new Date().toISOString();
|
|
2121
2150
|
const metrics = loadMetricsFile(filePath, changeName, nowIso);
|
|
2122
2151
|
const windowStart = lastSessionEndedAt(metrics) || metrics.createdAt;
|
|
2123
|
-
const
|
|
2124
|
-
|
|
2152
|
+
const collectAll = opts.collect === true;
|
|
2153
|
+
const platform = opts.platform || null;
|
|
2154
|
+
const platforms = collectAll ? undefined : (platform ? [platform] : []);
|
|
2155
|
+
const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
|
|
2156
|
+
const collected = shouldCollect
|
|
2157
|
+
? runCollectSpend(metrics, windowStart, nowIso, {
|
|
2158
|
+
platforms,
|
|
2159
|
+
ampThreadId: opts.ampThreadId || ampThreadIdFromEnv(process.env) || null,
|
|
2160
|
+
ampCli: collectAll || platform === 'amp',
|
|
2161
|
+
})
|
|
2125
2162
|
: { sources: [] };
|
|
2126
2163
|
const reported = opts.reported || emptyMetricsFields();
|
|
2127
2164
|
const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
|
|
@@ -2197,7 +2234,10 @@ function renderMetricsSummary(metrics) {
|
|
|
2197
2234
|
lines.push(`unreported: ${unreported}`);
|
|
2198
2235
|
if (metrics.archivedAt) lines.push(`archived: ${metrics.archivedAt}`);
|
|
2199
2236
|
if (metrics.pending) {
|
|
2200
|
-
|
|
2237
|
+
const pendingClient = metrics.pending.platform
|
|
2238
|
+
? ` ${metrics.pending.platform}${metrics.pending.threadId ? ` ${metrics.pending.threadId}` : ''}`
|
|
2239
|
+
: '';
|
|
2240
|
+
lines.push(`open session since ${metrics.pending.startedAt} (${metrics.pending.role || 'unknown role'}${pendingClient})`);
|
|
2201
2241
|
}
|
|
2202
2242
|
|
|
2203
2243
|
const phaseOrder = ['explore', 'design', 'spec', 'review', 'apply', 'archive', 'other'];
|
|
@@ -3877,8 +3917,18 @@ program
|
|
|
3877
3917
|
log.warn(`Memory JSON empty or missing at ${memoryPath}`);
|
|
3878
3918
|
}
|
|
3879
3919
|
if (opts.metrics !== false && existsSync(changeDir)) {
|
|
3880
|
-
const
|
|
3920
|
+
const client = resolveRestoreClient({
|
|
3921
|
+
env: process.env,
|
|
3922
|
+
cwd: projectDir,
|
|
3923
|
+
homedir: process.env.HOME,
|
|
3924
|
+
platform: opts.platform,
|
|
3925
|
+
});
|
|
3926
|
+
const metricsPath = metricsRecordSessionStart(projectDir, name, fields ? fields.nextRole : '', client);
|
|
3927
|
+
const clientLabel = client.platform
|
|
3928
|
+
? `${client.platform}${client.threadId ? ` ${client.threadId}` : ''} (${client.source})`
|
|
3929
|
+
: 'unknown — pass --platform or fill ## Metrics';
|
|
3881
3930
|
log.ok(`metrics: session start recorded (${metricsPath.replace(`${projectDir}/`, '')})`);
|
|
3931
|
+
log.info(`metrics: client ${clientLabel}`);
|
|
3882
3932
|
}
|
|
3883
3933
|
return;
|
|
3884
3934
|
}
|
|
@@ -3948,7 +3998,8 @@ program
|
|
|
3948
3998
|
}
|
|
3949
3999
|
|
|
3950
4000
|
const reported = fields.metrics || emptyMetricsFields();
|
|
3951
|
-
const
|
|
4001
|
+
const metricsPreview = loadMetricsFile(metricsFilePath(projectDir, name), name, new Date().toISOString());
|
|
4002
|
+
const platformResult = resolvePlatform(opts, process.env, reported, metricsPreview.pending);
|
|
3952
4003
|
if (platformResult.error) {
|
|
3953
4004
|
log.err(platformResult.error);
|
|
3954
4005
|
process.exitCode = 1;
|
|
@@ -3977,6 +4028,7 @@ program
|
|
|
3977
4028
|
totalTokens: opts.totalTokens,
|
|
3978
4029
|
costUsd: opts.costUsd,
|
|
3979
4030
|
collect: opts.collect === true,
|
|
4031
|
+
ampThreadId: (metricsPreview.pending && metricsPreview.pending.threadId) || ampThreadIdFromEnv(process.env) || null,
|
|
3980
4032
|
reported,
|
|
3981
4033
|
});
|
|
3982
4034
|
console.error(pc.green(' ✓'), `metrics.json updated: ${metricsPath.replace(`${projectDir}/`, '')}`);
|
|
@@ -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
|
|
|
@@ -309,6 +311,36 @@ function ampInputTokens(usage) {
|
|
|
309
311
|
return has ? sum : null;
|
|
310
312
|
}
|
|
311
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
|
+
|
|
312
344
|
function collectAmp({ cwd, windowStart, windowEnd, existing, env, homedir, notes }) {
|
|
313
345
|
const root = ampRoot(env, homedir);
|
|
314
346
|
const threadsDir = join(root, 'threads');
|
|
@@ -324,6 +356,7 @@ function collectAmp({ cwd, windowStart, windowEnd, existing, env, homedir, notes
|
|
|
324
356
|
notes.push('amp: cannot read threads');
|
|
325
357
|
return sources;
|
|
326
358
|
}
|
|
359
|
+
const ctx = { cwd, windowStart, windowEnd, existing, env, homedir, notes };
|
|
327
360
|
for (const file of files) {
|
|
328
361
|
let thread;
|
|
329
362
|
try {
|
|
@@ -331,31 +364,60 @@ function collectAmp({ cwd, windowStart, windowEnd, existing, env, homedir, notes
|
|
|
331
364
|
} catch {
|
|
332
365
|
continue;
|
|
333
366
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
}));
|
|
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;
|
|
357
380
|
}
|
|
358
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;
|
|
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);
|
|
420
|
+
}
|
|
359
421
|
return sources;
|
|
360
422
|
}
|
|
361
423
|
|
|
@@ -424,7 +486,7 @@ function aggregate(sources) {
|
|
|
424
486
|
bucket.costUsd = addNullable(bucket.costUsd, src.costUsd);
|
|
425
487
|
bucket.ampCredits = addNullable(bucket.ampCredits, src.ampCredits);
|
|
426
488
|
if (platform === 'claude') bucket.source = 'claude-jsonl';
|
|
427
|
-
else if (platform === 'amp') bucket.source = 'amp-thread';
|
|
489
|
+
else if (platform === 'amp') bucket.source = src.via === 'amp-cli' ? 'amp-cli' : 'amp-thread';
|
|
428
490
|
else bucket.source = 'cursor-hook';
|
|
429
491
|
}
|
|
430
492
|
const model = src.model;
|
|
@@ -464,22 +526,52 @@ export function collectSpend(options = {}) {
|
|
|
464
526
|
const windowStart = options.windowStart;
|
|
465
527
|
const windowEnd = options.windowEnd;
|
|
466
528
|
const notes = [];
|
|
467
|
-
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
|
+
};
|
|
468
547
|
let sources = [];
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
548
|
+
if (run('claude')) {
|
|
549
|
+
try {
|
|
550
|
+
sources = sources.concat(collectClaude(ctx));
|
|
551
|
+
} catch {
|
|
552
|
+
notes.push('claude: adapter failed');
|
|
553
|
+
}
|
|
473
554
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
+
}
|
|
478
568
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
569
|
+
if (run('cursor')) {
|
|
570
|
+
try {
|
|
571
|
+
sources = sources.concat(collectCursor(ctx));
|
|
572
|
+
} catch {
|
|
573
|
+
notes.push('cursor: adapter failed');
|
|
574
|
+
}
|
|
483
575
|
}
|
|
484
576
|
const { byPlatform, byModel } = aggregate(sources);
|
|
485
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",
|
|
@@ -12,7 +12,7 @@ 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`.
|
|
@@ -21,7 +21,7 @@ Agents (local or cloud) write session artifacts only to git-tracked paths — ne
|
|
|
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, Metrics.
|
|
23
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
|
|
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
25
|
4. Spawn `session-handoff` in persist mode ONLY if step 3 failed (Amp: isolated `subagent-session-handoff`). Fallback, never routine.
|
|
26
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
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.
|