@orchestrator-claude/definitions 3.31.1 → 3.31.2

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.
@@ -0,0 +1,247 @@
1
+ /**
2
+ * finops-parser.ts — JSONL session parser + FinOps cost estimator (RFC-027 Sprint 3)
3
+ *
4
+ * Parses Claude Code session JSONL files to extract token usage, tool calls,
5
+ * and duration. Computes API cost estimates and formats telemetry output.
6
+ *
7
+ * All public functions are fail-open: they return null/0/empty on any error, never throw.
8
+ */
9
+
10
+ import { createReadStream, existsSync } from 'node:fs';
11
+ import { createInterface } from 'node:readline';
12
+
13
+ // ─── Types ────────────────────────────────────────────────
14
+
15
+ export interface SessionMetrics {
16
+ inputTokens: number;
17
+ outputTokens: number;
18
+ cacheReadTokens: number;
19
+ toolCalls: number;
20
+ durationSeconds: number;
21
+ }
22
+
23
+ export interface ModelPricing {
24
+ /** Price per 1M input tokens (USD) */
25
+ input: number;
26
+ /** Price per 1M output tokens (USD) */
27
+ output: number;
28
+ /** Price per 1M cache-read tokens (USD) */
29
+ cacheRead: number;
30
+ }
31
+
32
+ // ─── Constants ────────────────────────────────────────────
33
+
34
+ export const MODEL_PRICING: Record<string, ModelPricing> = {
35
+ 'claude-sonnet-4-6': { input: 3.0, output: 15.0, cacheRead: 0.3 },
36
+ 'claude-opus-4-6': { input: 15.0, output: 75.0, cacheRead: 1.5 },
37
+ 'claude-haiku-4-6': { input: 0.25, output: 1.25, cacheRead: 0.03 },
38
+ };
39
+
40
+ export const DEFAULT_MODEL = 'claude-sonnet-4-6';
41
+
42
+ // ─── JSONL Parsing ────────────────────────────────────────
43
+
44
+ interface UsageBlock {
45
+ input_tokens?: number;
46
+ output_tokens?: number;
47
+ cache_read_input_tokens?: number;
48
+ }
49
+
50
+ interface ContentBlock {
51
+ type?: string;
52
+ }
53
+
54
+ interface JsonlLine {
55
+ type?: string;
56
+ usage?: UsageBlock;
57
+ message?: {
58
+ usage?: UsageBlock;
59
+ content?: ContentBlock[];
60
+ };
61
+ timestamp?: string;
62
+ }
63
+
64
+ /**
65
+ * Parse a Claude Code session JSONL file using streaming readline.
66
+ * Aggregates token usage, tool call count, and duration from timestamps.
67
+ *
68
+ * Returns null on any error (fail-open).
69
+ */
70
+ export async function parseJsonlSession(filePath: string): Promise<SessionMetrics | null> {
71
+ try {
72
+ if (!existsSync(filePath)) return null;
73
+
74
+ let inputTokens = 0;
75
+ let outputTokens = 0;
76
+ let cacheReadTokens = 0;
77
+ let toolCalls = 0;
78
+ let firstTimestamp: number | null = null;
79
+ let lastTimestamp: number | null = null;
80
+ let hasData = false;
81
+
82
+ const rl = createInterface({
83
+ input: createReadStream(filePath, { encoding: 'utf-8' }),
84
+ crlfDelay: Infinity,
85
+ });
86
+
87
+ for await (const line of rl) {
88
+ const trimmed = line.trim();
89
+ if (!trimmed) continue;
90
+
91
+ let parsed: JsonlLine;
92
+ try {
93
+ parsed = JSON.parse(trimmed) as JsonlLine;
94
+ } catch {
95
+ // Skip malformed lines (fail-open)
96
+ continue;
97
+ }
98
+
99
+ hasData = true;
100
+
101
+ // Track timestamps for duration calculation
102
+ if (parsed.timestamp) {
103
+ try {
104
+ const ts = new Date(parsed.timestamp).getTime();
105
+ if (!Number.isNaN(ts)) {
106
+ if (firstTimestamp === null) firstTimestamp = ts;
107
+ lastTimestamp = ts;
108
+ }
109
+ } catch {
110
+ // ignore invalid timestamps
111
+ }
112
+ }
113
+
114
+ // Count tool_use events — in Claude Code JSONL, tool_use is nested
115
+ // inside message.content[] blocks, not at the top-level type field.
116
+ if (parsed.type === 'tool_use') {
117
+ toolCalls += 1;
118
+ } else if (parsed.message?.content) {
119
+ for (const block of parsed.message.content) {
120
+ if (block.type === 'tool_use') toolCalls += 1;
121
+ }
122
+ }
123
+
124
+ // Aggregate usage from any line that has a usage block
125
+ // Claude Code JSONL nests usage under message.usage (assistant type)
126
+ // or at top-level (older/test format). Check both paths.
127
+ const usage = parsed.message?.usage ?? parsed.usage;
128
+ if (usage) {
129
+ inputTokens += usage.input_tokens ?? 0;
130
+ outputTokens += usage.output_tokens ?? 0;
131
+ cacheReadTokens += usage.cache_read_input_tokens ?? 0;
132
+ }
133
+ }
134
+
135
+ if (!hasData) return null;
136
+
137
+ const durationSeconds =
138
+ firstTimestamp !== null && lastTimestamp !== null
139
+ ? Math.round((lastTimestamp - firstTimestamp) / 1000)
140
+ : 0;
141
+
142
+ return {
143
+ inputTokens,
144
+ outputTokens,
145
+ cacheReadTokens,
146
+ toolCalls,
147
+ durationSeconds,
148
+ };
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ // ─── Path Resolution ──────────────────────────────────────
155
+
156
+ /**
157
+ * Find the JSONL file for a given session ID by globbing ~/.claude/projects/.
158
+ * Uses a 5-second timeout. Returns null if not found or on any error.
159
+ */
160
+ export async function findJsonlPath(sessionId: string): Promise<string | null> {
161
+ try {
162
+ if (!sessionId) return null;
163
+
164
+ const { glob } = await import('glob');
165
+ const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? '';
166
+ if (!homeDir) return null;
167
+
168
+ const pattern = `${homeDir}/.claude/projects/**/${sessionId}.jsonl`;
169
+
170
+ const matches = await Promise.race([
171
+ glob(pattern, { nodir: true }),
172
+ new Promise<null>((resolve) =>
173
+ setTimeout(() => {
174
+ resolve(null);
175
+ }, 5000)
176
+ ),
177
+ ]);
178
+
179
+ if (!matches || !Array.isArray(matches) || matches.length === 0) return null;
180
+ return matches[0] ?? null;
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ // ─── Cost Estimation ─────────────────────────────────────
187
+
188
+ /**
189
+ * Estimate API cost from session metrics using model pricing table.
190
+ * Falls back to DEFAULT_MODEL pricing for unknown models.
191
+ * Never throws (fail-open).
192
+ */
193
+ export function estimateCost(metrics: SessionMetrics, model?: string): number {
194
+ try {
195
+ const pricing = MODEL_PRICING[model ?? DEFAULT_MODEL] ?? MODEL_PRICING[DEFAULT_MODEL];
196
+ if (!pricing) return 0;
197
+
198
+ const inputCost = (metrics.inputTokens / 1_000_000) * pricing.input;
199
+ const outputCost = (metrics.outputTokens / 1_000_000) * pricing.output;
200
+ const cacheCost = (metrics.cacheReadTokens / 1_000_000) * pricing.cacheRead;
201
+
202
+ return inputCost + outputCost + cacheCost;
203
+ } catch {
204
+ return 0;
205
+ }
206
+ }
207
+
208
+ // ─── Output Formatting ────────────────────────────────────
209
+
210
+ /**
211
+ * Format session metrics and cost as a multi-line [FINOPS] telemetry string.
212
+ * Follows the RFC-027 §5.5 format.
213
+ * Never throws (fail-open).
214
+ */
215
+ export function formatFinopsOutput(
216
+ metrics: SessionMetrics,
217
+ cost: number,
218
+ extras?: {
219
+ commits?: number;
220
+ filesChanged?: number;
221
+ sessionLabel?: string;
222
+ }
223
+ ): string {
224
+ try {
225
+ const durationMinutes = Math.round(metrics.durationSeconds / 60);
226
+
227
+ const lines: string[] = [
228
+ `[FINOPS] Session telemetry:${extras?.sessionLabel ? ` ${extras.sessionLabel}` : ''}`,
229
+ ` Input tokens: ${metrics.inputTokens.toLocaleString('en-US')}`,
230
+ ` Output tokens: ${metrics.outputTokens.toLocaleString('en-US')}`,
231
+ ` Cache hits: ${metrics.cacheReadTokens.toLocaleString('en-US')}`,
232
+ ` Tool calls: ${metrics.toolCalls}`,
233
+ ` Duration: ${durationMinutes}min`,
234
+ ` API cost est: $${cost.toFixed(2)}`,
235
+ ];
236
+
237
+ if (extras?.commits !== undefined || extras?.filesChanged !== undefined) {
238
+ const commits = extras.commits ?? 0;
239
+ const files = extras.filesChanged ?? 0;
240
+ lines.push(` Deliverables: ${commits} commits, ${files} files`);
241
+ }
242
+
243
+ return lines.join('\n');
244
+ } catch {
245
+ return '[FINOPS] Error formatting telemetry';
246
+ }
247
+ }
@@ -4,8 +4,8 @@
4
4
  * Fully testable without any mocks.
5
5
  */
6
6
 
7
- export type RiskLevel = "HIGH" | "LOW";
8
- export type GateStrategy = "AUTO_APPROVE" | "ASK_HUMAN" | "BLOCK_AND_REQUIRE";
7
+ export type RiskLevel = 'HIGH' | 'LOW';
8
+ export type GateStrategy = 'AUTO_APPROVE' | 'ASK_HUMAN' | 'BLOCK_AND_REQUIRE';
9
9
 
10
10
  export interface GateContext {
11
11
  workflowId: string;
@@ -29,9 +29,9 @@ export interface GateDecision {
29
29
  */
30
30
  export function classifyRisk(targetPhase: string): RiskLevel {
31
31
  if (/implement|deploy|migrate|delete/i.test(targetPhase)) {
32
- return "HIGH";
32
+ return 'HIGH';
33
33
  }
34
- return "LOW";
34
+ return 'LOW';
35
35
  }
36
36
 
37
37
  /**
@@ -43,16 +43,16 @@ export function classifyRisk(targetPhase: string): RiskLevel {
43
43
  * BLOCK_AND_REQUIRE exists as a type but has no trigger rule yet.
44
44
  */
45
45
  export function deriveGateStrategy(ctx: GateContext): GateDecision {
46
- if (ctx.riskLevel === "HIGH") {
46
+ if (ctx.riskLevel === 'HIGH') {
47
47
  return {
48
- strategy: "ASK_HUMAN",
48
+ strategy: 'ASK_HUMAN',
49
49
  reason: `Phase '${ctx.targetPhase}' is high-risk and requires human approval before proceeding.`,
50
50
  };
51
51
  }
52
52
 
53
53
  // Default: AUTO_APPROVE
54
54
  return {
55
- strategy: "AUTO_APPROVE",
55
+ strategy: 'AUTO_APPROVE',
56
56
  reason: `Phase '${ctx.targetPhase}' is low-risk — auto-approved.`,
57
57
  };
58
58
  }
@@ -3,19 +3,19 @@
3
3
  * Replaces post-phase-checkpoint.sh git logic.
4
4
  */
5
5
 
6
- import { execSync } from "node:child_process";
7
- import { readFileSync, writeFileSync } from "node:fs";
8
- import { join, dirname } from "node:path";
9
- import { fileURLToPath } from "node:url";
10
- import { log, registerCheckpoint } from "./api-client.js";
6
+ import { execSync } from 'node:child_process';
7
+ import { readFileSync, writeFileSync } from 'node:fs';
8
+ import { join, dirname } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { log, registerCheckpoint } from './api-client.js';
11
11
 
12
12
  const __filename = fileURLToPath(import.meta.url);
13
13
  const __dirname = dirname(__filename);
14
- const PROJECT_ROOT = join(__dirname, "..", "..", "..");
15
- const STATE_DIR = join(PROJECT_ROOT, ".orchestrator", ".state");
16
- const LAST_PHASE_FILE = join(STATE_DIR, "last-checkpointed-phase");
14
+ const PROJECT_ROOT = join(__dirname, '..', '..', '..');
15
+ const STATE_DIR = join(PROJECT_ROOT, '.orchestrator', '.state');
16
+ const LAST_PHASE_FILE = join(STATE_DIR, 'last-checkpointed-phase');
17
17
 
18
- const PHASE_ORDER = ["research", "specify", "plan", "tasks", "implement", "validate"];
18
+ const PHASE_ORDER = ['research', 'specify', 'plan', 'tasks', 'implement', 'validate'];
19
19
 
20
20
  /** Map from current phase to the phase that just completed */
21
21
  function getCompletedPhase(currentPhase: string): string | null {
@@ -25,7 +25,7 @@ function getCompletedPhase(currentPhase: string): string | null {
25
25
  }
26
26
 
27
27
  function exec(cmd: string): string {
28
- return execSync(cmd, { cwd: PROJECT_ROOT, encoding: "utf-8" }).trim();
28
+ return execSync(cmd, { cwd: PROJECT_ROOT, encoding: 'utf-8' }).trim();
29
29
  }
30
30
 
31
31
  /**
@@ -38,31 +38,31 @@ export async function createCheckpointIfNeeded(
38
38
  ): Promise<string | null> {
39
39
  const completedPhase = getCompletedPhase(currentPhase);
40
40
  if (!completedPhase) {
41
- log("CHECKPOINT", `No completed phase from: ${currentPhase}`);
41
+ log('CHECKPOINT', `No completed phase from: ${currentPhase}`);
42
42
  return null;
43
43
  }
44
44
 
45
45
  // Check if already checkpointed
46
- let lastPhase = "";
46
+ let lastPhase = '';
47
47
  try {
48
- lastPhase = readFileSync(LAST_PHASE_FILE, "utf-8").trim();
48
+ lastPhase = readFileSync(LAST_PHASE_FILE, 'utf-8').trim();
49
49
  } catch {
50
50
  // no file
51
51
  }
52
52
  if (lastPhase === completedPhase) {
53
- log("CHECKPOINT", `Already checkpointed: ${completedPhase}`);
53
+ log('CHECKPOINT', `Already checkpointed: ${completedPhase}`);
54
54
  return null;
55
55
  }
56
56
 
57
57
  try {
58
58
  // Stage all changes
59
- exec("git add -A");
59
+ exec('git add -A');
60
60
 
61
61
  // Check if there are staged changes
62
62
  try {
63
- exec("git diff --cached --quiet");
63
+ exec('git diff --cached --quiet');
64
64
  // No changes
65
- log("CHECKPOINT", `No changes for phase: ${completedPhase}`);
65
+ log('CHECKPOINT', `No changes for phase: ${completedPhase}`);
66
66
  writeFileSync(LAST_PHASE_FILE, completedPhase);
67
67
  return null;
68
68
  } catch {
@@ -71,9 +71,9 @@ export async function createCheckpointIfNeeded(
71
71
 
72
72
  const commitMsg = `[orchestrator] ${completedPhase}: Auto-checkpoint after ${completedPhase} phase`;
73
73
  exec(`git commit -m "${commitMsg}" --no-verify`);
74
- const commitHash = exec("git rev-parse HEAD");
74
+ const commitHash = exec('git rev-parse HEAD');
75
75
 
76
- log("CHECKPOINT", `Created: phase=${completedPhase} commit=${commitHash}`);
76
+ log('CHECKPOINT', `Created: phase=${completedPhase} commit=${commitHash}`);
77
77
 
78
78
  // Register via API
79
79
  await registerCheckpoint(
@@ -85,7 +85,7 @@ export async function createCheckpointIfNeeded(
85
85
  writeFileSync(LAST_PHASE_FILE, completedPhase);
86
86
  return commitHash;
87
87
  } catch (err) {
88
- log("CHECKPOINT", `Error: ${err}`);
88
+ log('CHECKPOINT', `Error: ${err}`);
89
89
  return null;
90
90
  }
91
91
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * observations-helper.ts — Tier B observations for sub-agents (RFC-027 Sprint 3)
3
+ *
4
+ * Provides types and helper to write observations.yaml files.
5
+ * Used by subagent-stop.ts to record what each sub-agent observed.
6
+ * All functions are fail-open: errors logged to stderr, never thrown.
7
+ */
8
+
9
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
10
+ import { join, dirname } from 'node:path';
11
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
12
+
13
+ // ─── Types ───────────────────────────────────────────────
14
+
15
+ export interface ObservationEntry {
16
+ timestamp: string;
17
+ session_id: string;
18
+ phase: string;
19
+ workflow_id?: string;
20
+ summary: string;
21
+ tags: string[];
22
+ promoted: boolean;
23
+ }
24
+
25
+ export interface ObservationsFile {
26
+ schema: 'observations/v1';
27
+ agent: string;
28
+ entries: ObservationEntry[];
29
+ }
30
+
31
+ // ─── Constants ───────────────────────────────────────────
32
+
33
+ const MAX_ENTRIES = 50;
34
+ const MAX_SUMMARY_LENGTH = 200;
35
+
36
+ // ─── Functions ───────────────────────────────────────────
37
+
38
+ /**
39
+ * Append an observation entry to the agent's observations.yaml.
40
+ * Creates the file and directory if they don't exist.
41
+ * FIFO rotation: oldest entries removed when >50.
42
+ * Fail-open: any error → console.warn, return without throwing.
43
+ */
44
+ export function appendObservation(
45
+ memoryDir: string,
46
+ agent: string,
47
+ entry: Omit<ObservationEntry, 'promoted'>
48
+ ): void {
49
+ try {
50
+ const agentDir = join(memoryDir, agent);
51
+ const filePath = join(agentDir, 'observations.yaml');
52
+
53
+ // Ensure directory exists
54
+ if (!existsSync(agentDir)) {
55
+ mkdirSync(agentDir, { recursive: true });
56
+ }
57
+
58
+ // Truncate summary
59
+ const summary =
60
+ entry.summary.length > MAX_SUMMARY_LENGTH
61
+ ? entry.summary.slice(0, MAX_SUMMARY_LENGTH)
62
+ : entry.summary;
63
+
64
+ const newEntry: ObservationEntry = {
65
+ ...entry,
66
+ summary,
67
+ promoted: false,
68
+ };
69
+
70
+ let data: ObservationsFile;
71
+
72
+ if (existsSync(filePath)) {
73
+ try {
74
+ const raw = readFileSync(filePath, 'utf-8');
75
+ const parsed = parseYaml(raw) as ObservationsFile;
76
+ if (parsed?.entries && Array.isArray(parsed.entries)) {
77
+ data = {
78
+ schema: 'observations/v1',
79
+ agent,
80
+ entries: [...parsed.entries, newEntry],
81
+ };
82
+ } else {
83
+ data = { schema: 'observations/v1', agent, entries: [newEntry] };
84
+ }
85
+ } catch {
86
+ // Corrupt YAML → start fresh
87
+ data = { schema: 'observations/v1', agent, entries: [newEntry] };
88
+ }
89
+ } else {
90
+ data = { schema: 'observations/v1', agent, entries: [newEntry] };
91
+ }
92
+
93
+ // FIFO rotation: keep only last MAX_ENTRIES
94
+ if (data.entries.length > MAX_ENTRIES) {
95
+ data.entries = data.entries.slice(data.entries.length - MAX_ENTRIES);
96
+ }
97
+
98
+ writeFileSync(filePath, stringifyYaml(data), 'utf-8');
99
+ } catch (err) {
100
+ console.warn(
101
+ `[OBSERVATIONS] Failed to write observation for agent '${agent}': ${err instanceof Error ? err.message : String(err)}`
102
+ );
103
+ }
104
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * sprint-context.ts — Sprint context persistence helper (RFC-027 Sprint 1)
3
+ *
4
+ * Reads and writes sprint context JSON files stored at:
5
+ * .claude/sprint-context/{workflowId}.json
6
+ *
7
+ * Note: files use .json format internally for zero-dependency operation
8
+ * (no yaml package required). The directory name uses 'sprint-context'
9
+ * as specified in RFC-027.
10
+ *
11
+ * All operations are fail-open: no function throws. Errors are swallowed
12
+ * and null / void is returned.
13
+ */
14
+
15
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
16
+ import { join, dirname } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ // ─────────────────────────────────────────────────────────────
20
+ // Path resolution
21
+ // ─────────────────────────────────────────────────────────────
22
+
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = dirname(__filename);
25
+
26
+ /**
27
+ * Project root: go up from .claude/hooks/lib → .claude/hooks → .claude → project root
28
+ */
29
+ const PROJECT_ROOT = join(__dirname, '..', '..', '..');
30
+ const SPRINT_CONTEXT_DIR = join(PROJECT_ROOT, '.claude', 'sprint-context');
31
+
32
+ // ─────────────────────────────────────────────────────────────
33
+ // Types
34
+ // ─────────────────────────────────────────────────────────────
35
+
36
+ export interface SprintContext {
37
+ /** Schema version for forward compatibility (e.g. "1.0") */
38
+ schema: string;
39
+ /** Relative path from project root to the parent RFC/ADR document */
40
+ parentDocument: string;
41
+ /** 1-based sprint index within the parent document */
42
+ sprintIndex: number;
43
+ /** Short description of what this sprint covers */
44
+ sprintScope: string;
45
+ /** Summary of sprints executed before this one */
46
+ previousSprints: {
47
+ index: number;
48
+ status: 'complete' | 'in_progress' | 'skipped';
49
+ deliverables: string[];
50
+ }[];
51
+ /** Workflow ID this sprint context belongs to */
52
+ workflowId: string;
53
+ /** ISO-8601 timestamp when the context was first created */
54
+ createdAt: string;
55
+ }
56
+
57
+ // ─────────────────────────────────────────────────────────────
58
+ // Helpers
59
+ // ─────────────────────────────────────────────────────────────
60
+
61
+ function contextPath(workflowId: string): string {
62
+ return join(SPRINT_CONTEXT_DIR, `${workflowId}.json`);
63
+ }
64
+
65
+ // ─────────────────────────────────────────────────────────────
66
+ // Public API
67
+ // ─────────────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Read the sprint context for a given workflow.
71
+ * Returns null if the file does not exist or contains invalid JSON.
72
+ * Never throws.
73
+ */
74
+ export function readSprintContext(workflowId: string): SprintContext | null {
75
+ try {
76
+ const filePath = contextPath(workflowId);
77
+ if (!existsSync(filePath)) {
78
+ return null;
79
+ }
80
+ const raw = readFileSync(filePath, 'utf-8');
81
+ // No schema validation — caller must tolerate partial/stale data gracefully
82
+ return JSON.parse(raw) as SprintContext;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Write a sprint context for a given workflow.
90
+ * Creates the directory if it does not exist.
91
+ * Overwrites any existing file for the same workflowId.
92
+ * Never throws.
93
+ */
94
+ export function writeSprintContext(workflowId: string, ctx: SprintContext): void {
95
+ try {
96
+ mkdirSync(SPRINT_CONTEXT_DIR, { recursive: true });
97
+ const filePath = contextPath(workflowId);
98
+ writeFileSync(filePath, JSON.stringify(ctx, null, 2), 'utf-8');
99
+ } catch {
100
+ // fail-open: swallow errors silently
101
+ }
102
+ }