@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.
@@ -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 "node:fs";
7
- import { join, dirname } from "node:path";
8
- import { fileURLToPath } from "node:url";
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, ".orchestrator", ".state");
15
- const JWT_CACHE = join(STATE_DIR, "jwt-token");
16
- const DEBUG_LOG = join(STATE_DIR, "hook-debug.log");
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 || "http://localhost:3001";
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
- "admin@orchestrator.local";
26
+ 'admin@orchestrator.local';
27
27
  const AUTH_PASSWORD =
28
- process.env.ORCHESTRATOR_ADMIN_PASSWORD ||
29
- process.env.ORCHESTRATOR_AUTH_PASSWORD ||
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$/, "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: "a" });
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, "utf-8").trim();
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: "POST",
93
+ method: 'POST',
92
94
  headers: {
93
- "Content-Type": "application/json",
94
- "X-Project-ID": PROJECT_ID,
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
- "X-Project-ID": PROJECT_ID,
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: "POST",
147
+ method: 'POST',
146
148
  headers: {
147
- "Content-Type": "application/json",
149
+ 'Content-Type': 'application/json',
148
150
  Authorization: `Bearer ${token}`,
149
- "X-Project-ID": PROJECT_ID,
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 ["in_progress", "awaiting_agent", "awaiting_approval"]) {
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
- return status?.mode || null;
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<void> {
414
+ ): Promise<string | null> {
210
415
  const token = await getToken();
211
- if (!token) return;
212
- await apiPost(`/api/v1/invocations/start`, token, {
213
- agentType,
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("utf-8").trim();
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 !== "object") return "";
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: "allow" | "deny";
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: "PreToolUse",
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: "block", reason }));
673
+ console.log(JSON.stringify({ decision: 'block', reason }));
293
674
  }