@orchestrator-claude/definitions 3.31.1 → 3.32.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/agents/docs-guardian.md +13 -73
- package/hooks/dangling-guard.ts +148 -10
- package/hooks/gate-guardian.ts +177 -40
- package/hooks/lib/api-client.ts +419 -38
- package/hooks/lib/capability-scanner.ts +640 -0
- package/hooks/lib/finops-parser.ts +247 -0
- package/hooks/lib/gate-logic.ts +7 -7
- package/hooks/lib/git-checkpoint.ts +20 -20
- package/hooks/lib/observations-helper.ts +104 -0
- package/hooks/lib/sprint-context.ts +102 -0
- package/hooks/mailbox-listener.ts +139 -25
- package/hooks/post-compact.ts +137 -11
- package/hooks/post-implement.ts +88 -0
- package/hooks/session-start.ts +416 -47
- package/hooks/sprint-registry.ts +6 -5
- package/hooks/subagent-start.ts +27 -15
- package/hooks/subagent-stop.ts +72 -13
- package/hooks/user-prompt.ts +43 -15
- package/hooks/workflow-guard.ts +96 -37
- package/manifest.json +53 -29
- package/package.json +1 -1
- package/skills/backlog-synthesis/SKILL.md +1 -1
- package/skills/workflow-status/SKILL.md +1 -1
- package/hooks/pre-agent-dispatch.ts +0 -57
- package/hooks/verification-before-completion.ts +0 -97
package/hooks/lib/api-client.ts
CHANGED
|
@@ -3,32 +3,30 @@
|
|
|
3
3
|
* Replaces orch-helpers.sh: typed, async/await, zero `node -e` hacks.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { readFileSync, writeFileSync, mkdirSync, statSync } from
|
|
7
|
-
import { join, dirname } from
|
|
8
|
-
import { fileURLToPath } from
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync, statSync, unlinkSync } from 'node:fs';
|
|
7
|
+
import { join, dirname } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
9
|
|
|
10
10
|
// Resolve paths relative to this file
|
|
11
11
|
const __filename = fileURLToPath(import.meta.url);
|
|
12
12
|
const __dirname = dirname(__filename);
|
|
13
|
-
const PROJECT_ROOT = join(__dirname,
|
|
14
|
-
const STATE_DIR = join(PROJECT_ROOT,
|
|
15
|
-
const JWT_CACHE = join(STATE_DIR,
|
|
16
|
-
const DEBUG_LOG = join(STATE_DIR,
|
|
13
|
+
const PROJECT_ROOT = join(__dirname, '..', '..', '..');
|
|
14
|
+
const STATE_DIR = join(PROJECT_ROOT, '.orchestrator', '.state');
|
|
15
|
+
const JWT_CACHE = join(STATE_DIR, 'jwt-token');
|
|
16
|
+
const DEBUG_LOG = join(STATE_DIR, 'hook-debug.log');
|
|
17
17
|
|
|
18
18
|
// Ensure state dir exists
|
|
19
19
|
mkdirSync(STATE_DIR, { recursive: true });
|
|
20
20
|
|
|
21
21
|
// Config from env
|
|
22
|
-
const API_URL = process.env.ORCHESTRATOR_API_URL ||
|
|
22
|
+
const API_URL = process.env.ORCHESTRATOR_API_URL || 'http://localhost:3001';
|
|
23
23
|
const AUTH_EMAIL =
|
|
24
24
|
process.env.ORCHESTRATOR_ADMIN_EMAIL ||
|
|
25
25
|
process.env.ORCHESTRATOR_AUTH_EMAIL ||
|
|
26
|
-
|
|
26
|
+
'admin@orchestrator.local';
|
|
27
27
|
const AUTH_PASSWORD =
|
|
28
|
-
process.env.ORCHESTRATOR_ADMIN_PASSWORD ||
|
|
29
|
-
|
|
30
|
-
"admin123";
|
|
31
|
-
const PROJECT_ID = process.env.ORCHESTRATOR_PROJECT_ID || "";
|
|
28
|
+
process.env.ORCHESTRATOR_ADMIN_PASSWORD || process.env.ORCHESTRATOR_AUTH_PASSWORD || 'admin123';
|
|
29
|
+
const PROJECT_ID = process.env.ORCHESTRATOR_PROJECT_ID || '';
|
|
32
30
|
|
|
33
31
|
// --- Types ---
|
|
34
32
|
|
|
@@ -38,6 +36,10 @@ export interface WorkflowStatus {
|
|
|
38
36
|
status: string;
|
|
39
37
|
currentPhase: string;
|
|
40
38
|
mode?: string;
|
|
39
|
+
/** ADR-017 Phase 3: execution mode for direct execution governance */
|
|
40
|
+
executionMode?: string;
|
|
41
|
+
/** PME variant tag (e.g. "consultoria-externa", "enterprise") — optional, fail-open */
|
|
42
|
+
variant?: string;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
export interface PendingAction {
|
|
@@ -58,10 +60,10 @@ export interface DetectResult {
|
|
|
58
60
|
// --- Logging ---
|
|
59
61
|
|
|
60
62
|
export function log(prefix: string, msg: string): void {
|
|
61
|
-
const ts = new Date().toISOString().replace(/\.\d+Z$/,
|
|
63
|
+
const ts = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
|
|
62
64
|
try {
|
|
63
65
|
const line = `[${ts}] ${prefix}: ${msg}\n`;
|
|
64
|
-
writeFileSync(DEBUG_LOG, line, { flag:
|
|
66
|
+
writeFileSync(DEBUG_LOG, line, { flag: 'a' });
|
|
65
67
|
} catch {
|
|
66
68
|
// fail silently
|
|
67
69
|
}
|
|
@@ -75,7 +77,7 @@ function getCachedToken(): string | null {
|
|
|
75
77
|
const ageMs = Date.now() - stat.mtimeMs;
|
|
76
78
|
if (ageMs < 3_500_000) {
|
|
77
79
|
// ~58 min
|
|
78
|
-
return readFileSync(JWT_CACHE,
|
|
80
|
+
return readFileSync(JWT_CACHE, 'utf-8').trim();
|
|
79
81
|
}
|
|
80
82
|
} catch {
|
|
81
83
|
// no cache
|
|
@@ -88,10 +90,10 @@ async function login(): Promise<string | null> {
|
|
|
88
90
|
|
|
89
91
|
try {
|
|
90
92
|
const resp = await fetch(`${API_URL}/api/v1/auth/login`, {
|
|
91
|
-
method:
|
|
93
|
+
method: 'POST',
|
|
92
94
|
headers: {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
+
'Content-Type': 'application/json',
|
|
96
|
+
'X-Project-ID': PROJECT_ID,
|
|
95
97
|
},
|
|
96
98
|
body: JSON.stringify({ email: AUTH_EMAIL, password: AUTH_PASSWORD }),
|
|
97
99
|
signal: AbortSignal.timeout(5000),
|
|
@@ -102,7 +104,7 @@ async function login(): Promise<string | null> {
|
|
|
102
104
|
const token =
|
|
103
105
|
(data.accessToken as string) ||
|
|
104
106
|
((data.data as Record<string, unknown>)?.accessToken as string) ||
|
|
105
|
-
|
|
107
|
+
'';
|
|
106
108
|
if (token) {
|
|
107
109
|
writeFileSync(JWT_CACHE, token);
|
|
108
110
|
}
|
|
@@ -123,7 +125,7 @@ async function apiGet<T>(path: string, token: string, timeoutMs = 5000): Promise
|
|
|
123
125
|
const resp = await fetch(`${API_URL}${path}`, {
|
|
124
126
|
headers: {
|
|
125
127
|
Authorization: `Bearer ${token}`,
|
|
126
|
-
|
|
128
|
+
'X-Project-ID': PROJECT_ID,
|
|
127
129
|
},
|
|
128
130
|
signal: AbortSignal.timeout(timeoutMs),
|
|
129
131
|
});
|
|
@@ -142,11 +144,11 @@ async function apiPost<T>(
|
|
|
142
144
|
): Promise<T | null> {
|
|
143
145
|
try {
|
|
144
146
|
const resp = await fetch(`${API_URL}${path}`, {
|
|
145
|
-
method:
|
|
147
|
+
method: 'POST',
|
|
146
148
|
headers: {
|
|
147
|
-
|
|
149
|
+
'Content-Type': 'application/json',
|
|
148
150
|
Authorization: `Bearer ${token}`,
|
|
149
|
-
|
|
151
|
+
'X-Project-ID': PROJECT_ID,
|
|
150
152
|
},
|
|
151
153
|
body: JSON.stringify(body),
|
|
152
154
|
signal: AbortSignal.timeout(timeoutMs),
|
|
@@ -164,7 +166,7 @@ export async function getActiveWorkflow(): Promise<WorkflowStatus | null> {
|
|
|
164
166
|
const token = await getToken();
|
|
165
167
|
if (!token) return null;
|
|
166
168
|
|
|
167
|
-
for (const status of [
|
|
169
|
+
for (const status of ['in_progress', 'awaiting_agent', 'awaiting_approval']) {
|
|
168
170
|
const resp = await apiGet<{ data?: WorkflowStatus[] }>(
|
|
169
171
|
`/api/v1/workflows?status=${status}&limit=1`,
|
|
170
172
|
token,
|
|
@@ -185,7 +187,9 @@ export async function getWorkflowStatus(workflowId: string): Promise<WorkflowSta
|
|
|
185
187
|
|
|
186
188
|
export async function getWorkflowMode(workflowId: string): Promise<string | null> {
|
|
187
189
|
const status = await getWorkflowStatus(workflowId);
|
|
188
|
-
|
|
190
|
+
if (!status) return null;
|
|
191
|
+
// ADR-017 Phase 3: prefer executionMode (direct governance) over mode when present
|
|
192
|
+
return status.executionMode ?? status.mode ?? null;
|
|
189
193
|
}
|
|
190
194
|
|
|
191
195
|
export async function getNextAction(workflowId: string): Promise<PendingAction | null> {
|
|
@@ -200,21 +204,288 @@ export async function detectWorkflow(prompt: string): Promise<DetectResult | nul
|
|
|
200
204
|
return apiPost(`/api/v1/workflows/detect`, token, { prompt }, 3000);
|
|
201
205
|
}
|
|
202
206
|
|
|
207
|
+
// --- Last-Completed Workflow State (B4 FinOps timing race fix) ---
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Payload written to .orchestrator/.state/last-completed-workflow.json
|
|
211
|
+
* after a successful completeWorkflow MCP call. Used by dangling-guard.ts
|
|
212
|
+
* as a fallback when getActiveWorkflow() returns null (timing race).
|
|
213
|
+
*/
|
|
214
|
+
export interface LastCompletedWorkflow {
|
|
215
|
+
workflowId: string;
|
|
216
|
+
completedAt: string; // ISO 8601
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const LAST_COMPLETED_FILE = join(STATE_DIR, 'last-completed-workflow.json');
|
|
220
|
+
const LAST_COMPLETED_TTL_MS = 60 * 60 * 1000; // 60 minutes
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Returns the last completed workflow from the state file if it exists
|
|
224
|
+
* and its completedAt timestamp is within the 60-minute TTL.
|
|
225
|
+
* Returns null on any failure (missing file, malformed JSON, expired).
|
|
226
|
+
*/
|
|
227
|
+
export function getLastCompletedWorkflow(): LastCompletedWorkflow | null {
|
|
228
|
+
try {
|
|
229
|
+
const raw = readFileSync(LAST_COMPLETED_FILE, 'utf-8');
|
|
230
|
+
const data = JSON.parse(raw) as Record<string, unknown>;
|
|
231
|
+
if (typeof data.workflowId !== 'string' || typeof data.completedAt !== 'string') {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
const completedAtMs = new Date(data.completedAt).getTime();
|
|
235
|
+
if (isNaN(completedAtMs)) return null;
|
|
236
|
+
if (Date.now() - completedAtMs > LAST_COMPLETED_TTL_MS) return null;
|
|
237
|
+
return { workflowId: data.workflowId, completedAt: data.completedAt };
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Writes the last-completed workflow state file.
|
|
245
|
+
* Called by the completeWorkflow MCP handler after successful completion.
|
|
246
|
+
*/
|
|
247
|
+
export function writeLastCompletedWorkflow(workflowId: string): void {
|
|
248
|
+
try {
|
|
249
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
250
|
+
writeFileSync(
|
|
251
|
+
LAST_COMPLETED_FILE,
|
|
252
|
+
JSON.stringify({ workflowId, completedAt: new Date().toISOString() }),
|
|
253
|
+
'utf-8'
|
|
254
|
+
);
|
|
255
|
+
} catch {
|
|
256
|
+
// fail silently — FinOps fallback is best-effort
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Deletes the last-completed workflow state file after it has been consumed
|
|
262
|
+
* by the dangling-guard Stop hook. Prevents double-posting telemetry on
|
|
263
|
+
* subsequent Stop invocations (idempotency guard).
|
|
264
|
+
* Returns true if file was deleted, false if already gone.
|
|
265
|
+
*/
|
|
266
|
+
export function consumeLastCompletedWorkflow(): boolean {
|
|
267
|
+
try {
|
|
268
|
+
readFileSync(LAST_COMPLETED_FILE); // throws if missing
|
|
269
|
+
unlinkSync(LAST_COMPLETED_FILE);
|
|
270
|
+
return true;
|
|
271
|
+
} catch {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
203
276
|
// --- Invocation Tracking ---
|
|
204
277
|
|
|
278
|
+
/** Response shape from POST /api/v1/invocations/start */
|
|
279
|
+
interface StartInvocationApiResponse {
|
|
280
|
+
invocationId: string;
|
|
281
|
+
status: string;
|
|
282
|
+
startedAt: string;
|
|
283
|
+
agentName: string;
|
|
284
|
+
phase: string;
|
|
285
|
+
workflowId?: string;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Payload written to the per-key invocation state file. */
|
|
289
|
+
export interface InvocationStatePayload {
|
|
290
|
+
invocationId: string;
|
|
291
|
+
startedAt: string;
|
|
292
|
+
agentType: string;
|
|
293
|
+
workflowId: string;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const INVOCATIONS_DIR = join(STATE_DIR, 'invocations');
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Returns the per-key state file path for an invocation.
|
|
300
|
+
* Pattern: <stateDir>/invocations/<workflowId>-<agentType>.json
|
|
301
|
+
* Ensures concurrent sprint-pane agents with same workflowId but different
|
|
302
|
+
* agentType get separate files (no collision). Same agentType → same file
|
|
303
|
+
* (last-writer-wins; acceptable trade-off per ADR-W3-B5).
|
|
304
|
+
*/
|
|
305
|
+
export function buildInvocationStatePath(
|
|
306
|
+
stateDir: string,
|
|
307
|
+
workflowId: string,
|
|
308
|
+
agentType: string
|
|
309
|
+
): string {
|
|
310
|
+
return join(stateDir, 'invocations', `${workflowId}-${agentType}.json`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Writes invocation state to the per-key state file.
|
|
315
|
+
* Called by subagent-start.ts after a successful startInvocation POST.
|
|
316
|
+
* Fail-open: silently returns false on any filesystem error.
|
|
317
|
+
*/
|
|
318
|
+
export function writeInvocationState(
|
|
319
|
+
workflowId: string,
|
|
320
|
+
agentType: string,
|
|
321
|
+
invocationId: string
|
|
322
|
+
): boolean {
|
|
323
|
+
try {
|
|
324
|
+
mkdirSync(INVOCATIONS_DIR, { recursive: true });
|
|
325
|
+
const path = buildInvocationStatePath(STATE_DIR, workflowId, agentType);
|
|
326
|
+
const payload: InvocationStatePayload = {
|
|
327
|
+
invocationId,
|
|
328
|
+
startedAt: new Date().toISOString(),
|
|
329
|
+
agentType,
|
|
330
|
+
workflowId,
|
|
331
|
+
};
|
|
332
|
+
writeFileSync(path, JSON.stringify(payload), 'utf-8');
|
|
333
|
+
return true;
|
|
334
|
+
} catch {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Reads invocation state from the per-key state file.
|
|
341
|
+
* Called by subagent-stop.ts to obtain the invocationId for completeAgentInvocation.
|
|
342
|
+
* Returns null if file is missing, malformed, or any read error occurs (fail-open).
|
|
343
|
+
*/
|
|
344
|
+
export function readInvocationState(
|
|
345
|
+
workflowId: string,
|
|
346
|
+
agentType: string
|
|
347
|
+
): InvocationStatePayload | null {
|
|
348
|
+
try {
|
|
349
|
+
const path = buildInvocationStatePath(STATE_DIR, workflowId, agentType);
|
|
350
|
+
const raw = readFileSync(path, 'utf-8');
|
|
351
|
+
const data = JSON.parse(raw) as Record<string, unknown>;
|
|
352
|
+
if (
|
|
353
|
+
typeof data.invocationId !== 'string' ||
|
|
354
|
+
typeof data.startedAt !== 'string' ||
|
|
355
|
+
typeof data.agentType !== 'string' ||
|
|
356
|
+
typeof data.workflowId !== 'string'
|
|
357
|
+
) {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
invocationId: data.invocationId,
|
|
362
|
+
startedAt: data.startedAt,
|
|
363
|
+
agentType: data.agentType,
|
|
364
|
+
workflowId: data.workflowId,
|
|
365
|
+
};
|
|
366
|
+
} catch {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** TTL for invocation state files: 4 hours. Files older than this are stale. */
|
|
372
|
+
const INVOCATION_STATE_TTL_MS = 4 * 60 * 60 * 1000;
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Returns true if an invocation state payload is stale (older than TTL_MS).
|
|
376
|
+
* Stale files are skipped by subagent-stop.ts to avoid zombie completions
|
|
377
|
+
* for agents that crashed before the stop hook ran.
|
|
378
|
+
* Malformed startedAt timestamps are treated as stale (fail-safe).
|
|
379
|
+
*/
|
|
380
|
+
export function isInvocationStateStale(
|
|
381
|
+
payload: InvocationStatePayload,
|
|
382
|
+
nowMs: number = Date.now(),
|
|
383
|
+
ttlMs: number = INVOCATION_STATE_TTL_MS
|
|
384
|
+
): boolean {
|
|
385
|
+
const startedAtMs = new Date(payload.startedAt).getTime();
|
|
386
|
+
if (isNaN(startedAtMs)) return true;
|
|
387
|
+
return nowMs - startedAtMs > ttlMs;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Deletes the per-key invocation state file after it has been consumed by
|
|
392
|
+
* subagent-stop.ts. Returns true if deleted, false if already gone.
|
|
393
|
+
*/
|
|
394
|
+
export function consumeInvocationState(workflowId: string, agentType: string): boolean {
|
|
395
|
+
try {
|
|
396
|
+
const path = buildInvocationStatePath(STATE_DIR, workflowId, agentType);
|
|
397
|
+
readFileSync(path); // throws if missing — fast existence check
|
|
398
|
+
unlinkSync(path);
|
|
399
|
+
return true;
|
|
400
|
+
} catch {
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Start tracking an agent invocation.
|
|
407
|
+
* Returns the invocationId string on success, or null on any failure (fail-open).
|
|
408
|
+
* Structured log on POST failure so failures are surfaced (not silent).
|
|
409
|
+
*/
|
|
205
410
|
export async function startInvocation(
|
|
206
411
|
agentType: string,
|
|
207
412
|
workflowId: string,
|
|
208
413
|
phase: string
|
|
209
|
-
): Promise<
|
|
414
|
+
): Promise<string | null> {
|
|
210
415
|
const token = await getToken();
|
|
211
|
-
if (!token)
|
|
212
|
-
|
|
213
|
-
|
|
416
|
+
if (!token) {
|
|
417
|
+
log(
|
|
418
|
+
'API-CLIENT',
|
|
419
|
+
`startInvocation: no token — skipping (agent=${agentType} workflow=${workflowId})`
|
|
420
|
+
);
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
const resp = await apiPost<StartInvocationApiResponse>(`/api/v1/invocations/start`, token, {
|
|
424
|
+
agentName: agentType,
|
|
214
425
|
workflowId,
|
|
215
426
|
phase,
|
|
216
|
-
startedAt: new Date().toISOString(),
|
|
217
427
|
});
|
|
428
|
+
if (!resp) {
|
|
429
|
+
log(
|
|
430
|
+
'API-CLIENT',
|
|
431
|
+
`startInvocation: POST failed — agent=${agentType} workflow=${workflowId} phase=${phase}`
|
|
432
|
+
);
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
const invocationId =
|
|
436
|
+
typeof resp.invocationId === 'string' && resp.invocationId.length > 0
|
|
437
|
+
? resp.invocationId
|
|
438
|
+
: null;
|
|
439
|
+
if (!invocationId) {
|
|
440
|
+
log(
|
|
441
|
+
'API-CLIENT',
|
|
442
|
+
`startInvocation: response missing invocationId — agent=${agentType} workflow=${workflowId}`
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
return invocationId;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Complete an agent invocation via the safety-net fallback path.
|
|
450
|
+
* Called by subagent-stop.ts after reading the per-key state file, in case
|
|
451
|
+
* the agent did not self-report via MCP tool.
|
|
452
|
+
*
|
|
453
|
+
* Follows the same fail-open pattern as startInvocation:
|
|
454
|
+
* - Returns true on success (HTTP 2xx from POST /invocations/:id/complete)
|
|
455
|
+
* - Returns false on any failure (no token, network error, non-2xx)
|
|
456
|
+
* - Structured log on failure so it is surfaced, not silent
|
|
457
|
+
*/
|
|
458
|
+
export async function completeAgentInvocation(
|
|
459
|
+
invocationId: string,
|
|
460
|
+
status: string,
|
|
461
|
+
summary?: string,
|
|
462
|
+
error?: string
|
|
463
|
+
): Promise<boolean> {
|
|
464
|
+
const token = await getToken();
|
|
465
|
+
if (!token) {
|
|
466
|
+
log(
|
|
467
|
+
'API-CLIENT',
|
|
468
|
+
`completeAgentInvocation: no token — skipping (invocationId=${invocationId})`
|
|
469
|
+
);
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
const body: Record<string, unknown> = { status };
|
|
473
|
+
if (summary !== undefined) body.summary = summary;
|
|
474
|
+
if (error !== undefined) body.error = error;
|
|
475
|
+
|
|
476
|
+
const resp = await apiPost<Record<string, unknown>>(
|
|
477
|
+
`/api/v1/invocations/${invocationId}/complete`,
|
|
478
|
+
token,
|
|
479
|
+
body
|
|
480
|
+
);
|
|
481
|
+
if (!resp) {
|
|
482
|
+
log(
|
|
483
|
+
'API-CLIENT',
|
|
484
|
+
`completeAgentInvocation: POST failed — invocationId=${invocationId} status=${status}`
|
|
485
|
+
);
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
return true;
|
|
218
489
|
}
|
|
219
490
|
|
|
220
491
|
export async function registerCheckpoint(
|
|
@@ -231,6 +502,116 @@ export async function registerCheckpoint(
|
|
|
231
502
|
});
|
|
232
503
|
}
|
|
233
504
|
|
|
505
|
+
// --- Telemetry ---
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Flat snake_case FinOps payload sent directly to POST /workflows/:id/telemetry.
|
|
509
|
+
* All fields are optional — callers may report a subset of metrics.
|
|
510
|
+
*/
|
|
511
|
+
export interface TelemetryPayload {
|
|
512
|
+
input_tokens?: number;
|
|
513
|
+
output_tokens?: number;
|
|
514
|
+
cache_read_tokens?: number;
|
|
515
|
+
total_tokens?: number;
|
|
516
|
+
total_turns?: number;
|
|
517
|
+
cache_hit_rate?: number;
|
|
518
|
+
estimated_cost_usd?: number;
|
|
519
|
+
model_used?: string;
|
|
520
|
+
session_id?: string;
|
|
521
|
+
orchestrator_session_id?: string;
|
|
522
|
+
variant?: string;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Post session telemetry data for a workflow.
|
|
527
|
+
* Sends the flat snake_case payload directly to the API endpoint.
|
|
528
|
+
* Returns true on success, false on any failure (fail-open).
|
|
529
|
+
*/
|
|
530
|
+
export async function postTelemetry(
|
|
531
|
+
workflowId: string,
|
|
532
|
+
payload: TelemetryPayload
|
|
533
|
+
): Promise<boolean> {
|
|
534
|
+
const token = await getToken();
|
|
535
|
+
if (!token) return false;
|
|
536
|
+
|
|
537
|
+
const result = await apiPost<Record<string, unknown>>(
|
|
538
|
+
`/api/v1/workflows/${workflowId}/telemetry`,
|
|
539
|
+
token,
|
|
540
|
+
payload as unknown as Record<string, unknown>
|
|
541
|
+
);
|
|
542
|
+
return result !== null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// --- PME Adversarial Gate Helpers (S188 / RFC-031 §4.4) ---
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Extracts the workflow type and variant from the workflow status endpoint.
|
|
549
|
+
* Returns null on any error (fail-open). Reuses getWorkflowStatus internally.
|
|
550
|
+
*/
|
|
551
|
+
export async function getWorkflowTypeAndVariant(
|
|
552
|
+
workflowId: string,
|
|
553
|
+
token: string
|
|
554
|
+
): Promise<{ type: string; variant: string } | null> {
|
|
555
|
+
try {
|
|
556
|
+
const resp = await apiGet<WorkflowStatus>(`/api/v1/workflows/${workflowId}/status`, token);
|
|
557
|
+
if (!resp) return null;
|
|
558
|
+
const { type, variant } = resp;
|
|
559
|
+
if (!type || !variant) return null;
|
|
560
|
+
return { type, variant };
|
|
561
|
+
} catch {
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Returns the GateCell mode ('REQ' | 'OPT' | 'SKIP') for a given
|
|
568
|
+
* workflow type + variant + gate key, by calling the F1 endpoint.
|
|
569
|
+
* Returns null on 404, missing field, or any infra error (fail-open).
|
|
570
|
+
*/
|
|
571
|
+
export async function getGateCellMode(
|
|
572
|
+
typeId: string,
|
|
573
|
+
variant: string,
|
|
574
|
+
gateKey: string,
|
|
575
|
+
token: string
|
|
576
|
+
): Promise<'REQ' | 'OPT' | 'SKIP' | null> {
|
|
577
|
+
try {
|
|
578
|
+
const resp = await apiGet<{
|
|
579
|
+
typeId: string;
|
|
580
|
+
modes: Record<string, { gates?: Record<string, string> }>;
|
|
581
|
+
}>(`/api/v1/workflow-definitions/${typeId}`, token);
|
|
582
|
+
if (!resp) return null;
|
|
583
|
+
const cell = resp.modes?.[variant]?.gates?.[gateKey];
|
|
584
|
+
if (cell === 'REQ' || cell === 'OPT' || cell === 'SKIP') return cell;
|
|
585
|
+
return null;
|
|
586
|
+
} catch {
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Retrieves the adversarial review verdict and iteration for a workflow.
|
|
593
|
+
* Calls GET /api/v1/artifact-content/:workflowId/adversarial_review.
|
|
594
|
+
* Returns null when artifact is absent (404), on 5xx, timeout, or missing
|
|
595
|
+
* metadata fields (fail-open — gate is not enforced when artifact is absent).
|
|
596
|
+
*/
|
|
597
|
+
export async function getAdversarialVerdict(
|
|
598
|
+
workflowId: string,
|
|
599
|
+
token: string
|
|
600
|
+
): Promise<{ verdict: string; iteration: number } | null> {
|
|
601
|
+
try {
|
|
602
|
+
const resp = await apiGet<{
|
|
603
|
+
content?: string;
|
|
604
|
+
metadata?: { verdict?: string; iteration?: number };
|
|
605
|
+
}>(`/api/v1/artifact-content/${workflowId}/adversarial_review`, token);
|
|
606
|
+
if (!resp) return null;
|
|
607
|
+
const { verdict, iteration } = resp.metadata ?? {};
|
|
608
|
+
if (typeof verdict !== 'string' || typeof iteration !== 'number') return null;
|
|
609
|
+
return { verdict, iteration };
|
|
610
|
+
} catch {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
234
615
|
// --- Stdin Helper ---
|
|
235
616
|
|
|
236
617
|
export async function readStdin(): Promise<Record<string, unknown>> {
|
|
@@ -238,7 +619,7 @@ export async function readStdin(): Promise<Record<string, unknown>> {
|
|
|
238
619
|
for await (const chunk of process.stdin) {
|
|
239
620
|
chunks.push(chunk as Buffer);
|
|
240
621
|
}
|
|
241
|
-
const raw = Buffer.concat(chunks).toString(
|
|
622
|
+
const raw = Buffer.concat(chunks).toString('utf-8').trim();
|
|
242
623
|
if (!raw) return {};
|
|
243
624
|
try {
|
|
244
625
|
return JSON.parse(raw) as Record<string, unknown>;
|
|
@@ -250,25 +631,25 @@ export async function readStdin(): Promise<Record<string, unknown>> {
|
|
|
250
631
|
// --- JSON Field Access ---
|
|
251
632
|
|
|
252
633
|
export function getField(obj: Record<string, unknown>, path: string): string {
|
|
253
|
-
const parts = path.split(
|
|
634
|
+
const parts = path.split('.');
|
|
254
635
|
let current: unknown = obj;
|
|
255
636
|
for (const part of parts) {
|
|
256
|
-
if (current == null || typeof current !==
|
|
637
|
+
if (current == null || typeof current !== 'object') return '';
|
|
257
638
|
current = (current as Record<string, unknown>)[part];
|
|
258
639
|
}
|
|
259
|
-
return current != null ? String(current) :
|
|
640
|
+
return current != null ? String(current) : '';
|
|
260
641
|
}
|
|
261
642
|
|
|
262
643
|
// --- Output Helpers ---
|
|
263
644
|
|
|
264
645
|
export function outputPreToolUse(opts: {
|
|
265
|
-
decision:
|
|
646
|
+
decision: 'allow' | 'deny';
|
|
266
647
|
reason?: string;
|
|
267
648
|
context?: string;
|
|
268
649
|
}): void {
|
|
269
650
|
const output: Record<string, unknown> = {
|
|
270
651
|
hookSpecificOutput: {
|
|
271
|
-
hookEventName:
|
|
652
|
+
hookEventName: 'PreToolUse',
|
|
272
653
|
permissionDecision: opts.decision,
|
|
273
654
|
...(opts.reason && { permissionDecisionReason: opts.reason }),
|
|
274
655
|
...(opts.context && { additionalContext: opts.context }),
|
|
@@ -289,5 +670,5 @@ export function outputContext(hookEvent: string, context: string): void {
|
|
|
289
670
|
}
|
|
290
671
|
|
|
291
672
|
export function outputBlock(reason: string): void {
|
|
292
|
-
console.log(JSON.stringify({ decision:
|
|
673
|
+
console.log(JSON.stringify({ decision: 'block', reason }));
|
|
293
674
|
}
|