@orchestrator-claude/definitions 3.31.0 → 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.
@@ -4,93 +4,462 @@
4
4
  * Injects workflow status context on session start.
5
5
  */
6
6
 
7
- import { readFileSync } from "node:fs";
8
- import { join, dirname } from "node:path";
9
- import { fileURLToPath } from "node:url";
7
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
8
+ import { join, dirname } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { parse as parseYaml } from 'yaml';
10
11
  import {
11
12
  log,
12
13
  getActiveWorkflow,
13
14
  getNextAction,
14
15
  outputContext,
15
- } from "./lib/api-client.js";
16
+ getToken,
17
+ } from './lib/api-client.js';
18
+ import {
19
+ needsRescan,
20
+ buildCapabilityIndex,
21
+ writeCapabilitiesToVault,
22
+ readCapabilitiesFromVault,
23
+ scanAgentDefinitions,
24
+ formatCapabilityMatrix,
25
+ readMemoryIndex,
26
+ filterRelevantMemories,
27
+ formatMemoryContext,
28
+ } from './lib/capability-scanner.js';
16
29
 
17
- const HOOK = "SESSION-START";
30
+ const HOOK = 'SESSION-START';
18
31
  const __filename = fileURLToPath(import.meta.url);
19
32
  const __dirname = dirname(__filename);
20
- const PROJECT_ROOT = join(__dirname, "..", "..");
33
+ const GRAPHRAG_TIMEOUT_MS = 2000;
34
+ const PROJECT_ROOT = join(__dirname, '..', '..');
35
+
36
+ // ─── Capability Matrix paths (RFC-027 Sprint 2) ─────────
37
+ const HOME = process.env.HOME ?? process.env.USERPROFILE ?? '/home/user';
38
+ const INSTALLED_PLUGINS_PATH = join(HOME, '.claude', 'plugins', 'installed_plugins.json');
39
+ const AGENTS_DIR = join(PROJECT_ROOT, '.claude', 'agents');
40
+ const VAULT_CONFIG_PATH = join(PROJECT_ROOT, 'project-guidelines', '.vault-config.yaml');
41
+ const PROFILES_PATH = join(PROJECT_ROOT, '.claude', 'plugin-profiles.yaml');
42
+ const MEMORY_INDEX_PATH = join(
43
+ PROJECT_ROOT,
44
+ '.claude',
45
+ 'agent-memory',
46
+ 'orchestrator',
47
+ 'memory-index.yaml'
48
+ );
49
+
50
+ /**
51
+ * Read session_id from SessionStart stdin payload.
52
+ * Claude Code sends JSON with session_id on SessionStart.
53
+ */
54
+ function readSessionId(): string | null {
55
+ try {
56
+ const raw = readFileSync('/dev/stdin', 'utf-8').trim();
57
+ if (!raw) return null;
58
+ const payload = JSON.parse(raw) as Record<string, unknown>;
59
+ return (payload.session_id as string) ?? null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ // ─── RFC-033 Phase 1: project-state.yaml reader ─────────────────────────────
66
+
67
+ const PROJECT_STATE_PATH = join(PROJECT_ROOT, 'project-guidelines', 'project-state.yaml');
68
+
69
+ interface PendingCounts {
70
+ critical: number;
71
+ high: number;
72
+ medium: number;
73
+ ci_gating: number;
74
+ }
75
+
76
+ interface BacklogSummary {
77
+ version: string;
78
+ next: string;
79
+ status: string;
80
+ lastSession: string;
81
+ }
82
+
83
+ /**
84
+ * Synthesize a short status string from pending counts (RFC-033 Phase 1).
85
+ * Output format: "0 CRITICAL · 0 HIGH · 1 MEDIUM · 0 CI-gating" (<=80 chars).
86
+ * Exported for unit-testability.
87
+ */
88
+ export function synthesizeStatus(pending: PendingCounts): string {
89
+ const { critical, high, medium, ci_gating: ciGating } = pending;
90
+ return `${String(critical)} CRITICAL · ${String(high)} HIGH · ${String(medium)} MEDIUM · ${String(ciGating)} CI-gating`;
91
+ }
92
+
93
+ /**
94
+ * Try to read project-state.yaml and map fields to BacklogSummary shape.
95
+ * Returns null on ANY failure (file absent, parse error, missing required fields).
96
+ * Exported for unit-testability.
97
+ */
98
+ export function readProjectState(filePath: string): BacklogSummary | null {
99
+ try {
100
+ if (!existsSync(filePath)) return null;
101
+ const raw = readFileSync(filePath, 'utf-8');
102
+ const doc = parseYaml(raw) as Record<string, unknown>;
103
+
104
+ // Required field guard
105
+ const version = doc?.version as string | undefined;
106
+ const session = doc?.session as Record<string, unknown> | undefined;
107
+ const nextStep = doc?.next_step as Record<string, unknown> | undefined;
108
+ const pending = doc?.pending as Record<string, unknown> | undefined;
109
+
110
+ if (!version || typeof version !== 'string') return null;
111
+ const nextSummary = nextStep?.summary as string | undefined;
112
+ if (!nextSummary || typeof nextSummary !== 'string') return null;
113
+
114
+ const lastSummary = (session?.last_summary as string | undefined) ?? '';
115
+ const counts: PendingCounts = {
116
+ critical: Number(pending?.critical ?? 0),
117
+ high: Number(pending?.high ?? 0),
118
+ medium: Number(pending?.medium ?? 0),
119
+ ci_gating: Number(pending?.ci_gating ?? 0),
120
+ };
121
+
122
+ return {
123
+ version,
124
+ next: nextSummary,
125
+ status: synthesizeStatus(counts),
126
+ lastSession: lastSummary,
127
+ };
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
21
132
 
22
133
  /**
23
- * Read BACKLOG.md to extract version and next work item.
24
- * Returns structured summary for the greeting.
134
+ * Legacy BACKLOG.md parser — kept as the fallback path (RFC-033 Phase 1).
135
+ * Extracted from the original getBacklogSummary() body; behaviour unchanged.
25
136
  */
26
- function getBacklogSummary(): { version: string; next: string; status: string } {
137
+ function readBacklogFallback(): BacklogSummary {
27
138
  try {
28
- const backlogPath = join(PROJECT_ROOT, "project-guidelines", "BACKLOG.md");
29
- const content = readFileSync(backlogPath, "utf-8");
30
- const lines = content.split("\n").slice(0, 40);
139
+ const backlogPath = join(PROJECT_ROOT, 'project-guidelines', 'BACKLOG.md');
140
+ const content = readFileSync(backlogPath, 'utf-8');
141
+ const allLines = content.split('\n');
142
+ // Version is always near the top (frontmatter + header area)
143
+ const headerLines = allLines.slice(0, 40);
31
144
 
32
145
  // Extract version from "**Release Atual:** vX.Y.Z" or "Current Release: vX.Y.Z"
33
- const versionLine = lines.find((l) => /release atual|current release/i.test(l));
146
+ const versionLine = headerLines.find((l) => /release atual|current release/i.test(l));
34
147
  const versionMatch = versionLine?.match(/v(\d+\.\d+\.\d+)/);
35
- const version = versionMatch ? `v${versionMatch[1]}` : "v?.?.?";
36
-
37
- // Extract pending status from "**Pendente:**" line (colon may be inside or outside bold)
38
- const pendenteLine = lines.find((l) => /\*\*pendente/i.test(l));
39
- const status = pendenteLine
40
- ?.replace(/.*\*\*pendente:?\*\*:?\s*/i, "")
41
- .replace(/\s*$/, "")
42
- .slice(0, 80) || "";
43
-
44
- // Extract next step from "**Proximo passo:**" or "**Next:**" line
45
- const nextLine = lines.find((l) => /\*\*proximo passo/i.test(l) || /\*\*next/i.test(l));
46
- const next = nextLine
47
- ?.replace(/.*\*\*(proximo passo|next):?\*\*:?\s*/i, "")
48
- .replace(/\s*$/, "")
49
- .slice(0, 120) || "see BACKLOG.md";
50
-
51
- log(HOOK, `BACKLOG parsed: version=${version} next=${next.slice(0, 50)}`);
52
- return { version, next, status };
148
+ const version = versionMatch ? `v${versionMatch[1]}` : 'v?.?.?';
149
+
150
+ // Extract pending status from "**Pendente:**" line — must be line-anchored
151
+ // to avoid matching inline mentions inside session summaries.
152
+ const pendenteLine = allLines.find((l) => /^\*\*pendente/i.test(l));
153
+ const status =
154
+ pendenteLine
155
+ ?.replace(/.*\*\*pendente:?\*\*:?\s*/i, '')
156
+ .replace(/\s*$/, '')
157
+ .slice(0, 80) || '';
158
+
159
+ // Extract last session summary — allows "Sessao N:**", "Sessao N FINAL:**",
160
+ // "Sessao N (suffix):**". Picks the entry with the highest session number
161
+ // so updates at the file top are preferred over older entries.
162
+ const sessionRegex = /^\*\*Sessao (\d+)[^*]*:\*\*/i;
163
+ const sessionMatches = allLines
164
+ .map((l, idx) => {
165
+ const m = sessionRegex.exec(l);
166
+ return m ? { line: l, num: Number(m[1]), idx } : null;
167
+ })
168
+ .filter((x): x is { line: string; num: number; idx: number } => x !== null);
169
+ const latestSession = sessionMatches.sort((a, b) => b.num - a.num || a.idx - b.idx)[0];
170
+ const lastSession = latestSession?.line.replace(/\*\*/g, '').slice(0, 120) || '';
171
+
172
+ // Extract next step from "**Proximo passo:**" or "**Next:**" line — must be
173
+ // line-anchored to avoid matching inline mentions inside session summaries.
174
+ const nextLine = allLines.find((l) => /^\*\*proximo passo/i.test(l) || /^\*\*next/i.test(l));
175
+ const next =
176
+ nextLine
177
+ ?.replace(/.*\*\*(proximo passo|next):?\*\*:?\s*/i, '')
178
+ .replace(/\s*$/, '')
179
+ .slice(0, 120) || 'see BACKLOG.md';
180
+
181
+ log(
182
+ HOOK,
183
+ `BACKLOG.md fallback parsed: version=${version} next=${next.slice(0, 50)} lastSession=${lastSession.slice(0, 50)}`
184
+ );
185
+ return { version, next, status, lastSession };
53
186
  } catch (err) {
54
187
  log(HOOK, `BACKLOG read failed: ${err}`);
55
- return { version: "v?.?.?", next: "see BACKLOG.md", status: "" };
188
+ return { version: 'v?.?.?', next: 'see BACKLOG.md', status: '', lastSession: '' };
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Read session context: try project-state.yaml first (RFC-033 Phase 1),
194
+ * fall back to legacy BACKLOG.md parsing on any failure.
195
+ * Return shape is IDENTICAL to the original function.
196
+ */
197
+ function getBacklogSummary(): BacklogSummary {
198
+ const structured = readProjectState(PROJECT_STATE_PATH);
199
+ if (structured !== null) {
200
+ log(
201
+ HOOK,
202
+ `project-state.yaml: version=${structured.version} next=${structured.next.slice(0, 50)}`
203
+ );
204
+ return structured;
205
+ }
206
+ log(HOOK, 'project-state.yaml absent/invalid — using BACKLOG.md fallback');
207
+ return readBacklogFallback();
208
+ }
209
+
210
+ type TierInfo = { onboardingNeeded: true } | { tier: number; source: string } | null;
211
+
212
+ /**
213
+ * Check the project tier from vault YAML config.
214
+ * Returns null on any unexpected error (tier omitted from output).
215
+ * Note: API call removed (I-5) — the endpoint needs a real project UUID which the hook doesn't have.
216
+ */
217
+ async function checkTier(): Promise<TierInfo> {
218
+ try {
219
+ // Use module-level VAULT_CONFIG_PATH constant (IMP-003)
220
+ if (!existsSync(VAULT_CONFIG_PATH)) {
221
+ return { onboardingNeeded: true };
222
+ }
223
+
224
+ const content = readFileSync(VAULT_CONFIG_PATH, 'utf-8');
225
+ const config = parseYaml(content) as Record<string, unknown>;
226
+ const project = config?.project as Record<string, unknown> | undefined;
227
+ const tier = project?.tier as number | undefined;
228
+ if (tier !== undefined) {
229
+ return { tier, source: 'config' };
230
+ }
231
+
232
+ return null;
233
+ } catch (err) {
234
+ log(HOOK, `Tier check failed: ${err}`);
235
+ return null;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Format tier info as a segment string for the ORCHESTRATOR-DATA line.
241
+ */
242
+ export function formatTierSegment(info: TierInfo): string {
243
+ if (info === null) return '';
244
+ if ('onboardingNeeded' in info) return ' | onboarding_needed=true';
245
+ return ` | tier=${info.tier} (${info.source})`;
246
+ }
247
+
248
+ /**
249
+ * Build [CAPABILITY-MATRIX] + [MEMORY-CONTEXT] lines for injection.
250
+ * Fail-open: returns empty string on any error.
251
+ */
252
+ function buildCapabilityMatrixContext(): string {
253
+ try {
254
+ let index = readCapabilitiesFromVault(VAULT_CONFIG_PATH);
255
+ const cachedMtime = index?.installedPluginsMtime ?? '';
256
+
257
+ if (!index || needsRescan(INSTALLED_PLUGINS_PATH, cachedMtime)) {
258
+ log(HOOK, 'Capability rescan triggered');
259
+ index = buildCapabilityIndex({
260
+ installedPluginsPath: INSTALLED_PLUGINS_PATH,
261
+ agentsDir: AGENTS_DIR,
262
+ vaultConfigPath: VAULT_CONFIG_PATH,
263
+ profilesPath: PROFILES_PATH,
264
+ });
265
+ writeCapabilitiesToVault(VAULT_CONFIG_PATH, index);
266
+ } else {
267
+ // Agents are always scanned fresh (fast, <10ms)
268
+ index.agents = scanAgentDefinitions(AGENTS_DIR);
269
+ log(HOOK, 'Using cached capability index');
270
+ }
271
+
272
+ const matrixLine = formatCapabilityMatrix(index);
273
+
274
+ // Memory context
275
+ const memoryEntries = readMemoryIndex(MEMORY_INDEX_PATH);
276
+ let memoryLine = '';
277
+ if (memoryEntries) {
278
+ const filtered = filterRelevantMemories(memoryEntries);
279
+ memoryLine = formatMemoryContext(filtered);
280
+ }
281
+
282
+ return [matrixLine, memoryLine].filter(Boolean).join('\n');
283
+ } catch (err) {
284
+ log(HOOK, `Capability matrix failed: ${err}`);
285
+ return '';
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Check sprint mailbox inbox for pending tasks (RFC-027 Sprint 4).
291
+ * Only runs when SPRINT_ID and PANE_ROLE are set (sprint pane sessions).
292
+ * Returns formatted [ACTION-REQUIRED] lines or empty string.
293
+ */
294
+ function checkSprintInbox(): string {
295
+ const sprintId = process.env.SPRINT_ID;
296
+ const paneRole = process.env.PANE_ROLE;
297
+
298
+ if (!sprintId || !paneRole) return '';
299
+
300
+ try {
301
+ const inboxDir = join('/tmp', sprintId, 'mailbox', paneRole, 'inbox');
302
+ if (!existsSync(inboxDir)) return '';
303
+
304
+ const files = readdirSync(inboxDir).filter((f) => f.endsWith('.json'));
305
+ if (files.length === 0) return '';
306
+
307
+ const pendingTasks: string[] = [];
308
+ const maxShow = 5;
309
+
310
+ for (const file of files.slice(0, maxShow)) {
311
+ try {
312
+ const content = readFileSync(join(inboxDir, file), 'utf-8');
313
+ const envelope = JSON.parse(content) as Record<string, unknown>;
314
+ const body = envelope.body as Record<string, unknown> | undefined;
315
+ if (body?.type) {
316
+ pendingTasks.push(
317
+ ` → ${body.type}: ${body.title || body.summary || body.originalTaskId || '(no title)'}`
318
+ );
319
+ }
320
+ } catch {
321
+ // skip malformed files
322
+ }
323
+ }
324
+
325
+ if (pendingTasks.length === 0) return '';
326
+
327
+ const extra = files.length > maxShow ? `\n (+ ${files.length - maxShow} more)` : '';
328
+ return [
329
+ `[ACTION-REQUIRED] ${files.length} pending task(s) in mailbox:`,
330
+ ...pendingTasks,
331
+ extra,
332
+ `[ACTION-REQUIRED] Execute NOW. Send task_complete when done.`,
333
+ ]
334
+ .filter(Boolean)
335
+ .join('\n');
336
+ } catch (err) {
337
+ log(HOOK, `checkSprintInbox failed: ${err}`);
338
+ return '';
339
+ }
340
+ }
341
+
342
+ // ─── Graph Context (RFC-021 T-G4-06) ──────────────────────────────────────────
343
+
344
+ type GraphFetcher = (path: string, token: string, timeoutMs?: number) => Promise<unknown>;
345
+
346
+ interface GraphStatsResponse {
347
+ nodeCount?: number;
348
+ edgeCount?: number;
349
+ hotPaths?: string[];
350
+ }
351
+
352
+ interface AtRiskResponse {
353
+ atRisk?: string[];
354
+ }
355
+
356
+ /**
357
+ * Fetch graph stats and at-risk components from the GraphRAG API.
358
+ * Uses Promise.all for parallel calls with a 2s AbortController timeout.
359
+ * Fail-open: returns "" on any error so session-start never blocks.
360
+ *
361
+ * @param token - JWT token (null → skip API calls, return "")
362
+ * @param fetcher - injectable fetch function (default: real HTTP via api-client pattern)
363
+ */
364
+ export async function getGraphContext(
365
+ token: string | null,
366
+ fetcher?: GraphFetcher
367
+ ): Promise<string> {
368
+ if (token === null || token === '') return '';
369
+
370
+ const defaultFetcher: GraphFetcher = async (path, tok, timeoutMs = GRAPHRAG_TIMEOUT_MS) => {
371
+ const resp = await fetch(`http://localhost:3000${path}`, {
372
+ headers: { Authorization: `Bearer ${tok}` },
373
+ signal: AbortSignal.timeout(timeoutMs),
374
+ });
375
+ if (!resp.ok) return null;
376
+ return resp.json() as Promise<unknown>;
377
+ };
378
+
379
+ const fetch_ = fetcher ?? defaultFetcher;
380
+
381
+ try {
382
+ const [statsRaw, atRiskRaw] = await Promise.all([
383
+ fetch_('/api/v1/graphrag/stats', token, GRAPHRAG_TIMEOUT_MS),
384
+ fetch_('/api/v1/graphrag/at-risk', token, GRAPHRAG_TIMEOUT_MS),
385
+ ]);
386
+
387
+ const stats = statsRaw as GraphStatsResponse | null;
388
+ if (stats?.nodeCount === undefined) return '';
389
+
390
+ const atRiskData = atRiskRaw as AtRiskResponse | null;
391
+ const components = String(stats.nodeCount);
392
+ const edges = String(stats.edgeCount ?? 0);
393
+ const hotPaths = (stats.hotPaths ?? []).join(',');
394
+ const atRisk = (atRiskData?.atRisk ?? []).join(',');
395
+
396
+ return `[GRAPH-CONTEXT] components=${components} edges=${edges} hot_paths=[${hotPaths}] at_risk=[${atRisk}]`;
397
+ } catch {
398
+ return '';
56
399
  }
57
400
  }
58
401
 
59
402
  async function main(): Promise<void> {
60
- log(HOOK, "SessionStart hook triggered");
403
+ log(HOOK, 'SessionStart hook triggered');
404
+
405
+ const sessionId = readSessionId();
406
+ const sessionSegment = sessionId ? ` | session=${sessionId}` : '';
407
+ if (sessionId) log(HOOK, `Claude session: ${sessionId}`);
61
408
 
62
409
  const backlog = getBacklogSummary();
410
+ const tierInfo = await checkTier();
411
+ const tierSegment = formatTierSegment(tierInfo);
412
+ const capabilityContext = buildCapabilityMatrixContext();
63
413
  const workflow = await getActiveWorkflow();
64
414
 
415
+ // Sprint inbox check (RFC-027 S4 — only for sprint panes)
416
+ const sprintInboxContext = checkSprintInbox();
417
+
418
+ // Graph context (RFC-021 T-G4-06) — fail-open, 2s timeout
419
+ const token = await getToken();
420
+ const graphContext = await getGraphContext(token);
421
+
65
422
  if (!workflow) {
66
- log(HOOK, "No active workflow, injecting greeting data");
423
+ log(HOOK, 'No active workflow, injecting greeting data');
67
424
  outputContext(
68
- "SessionStart",
425
+ 'SessionStart',
69
426
  [
70
- `[ORCHESTRATOR-DATA] version=${backlog.version} | workflow=none | next=${backlog.next}`,
427
+ `[ORCHESTRATOR-DATA] version=${backlog.version}${sessionSegment} | workflow=none${tierSegment} | next=${backlog.next}`,
428
+ backlog.lastSession ? `[LAST-SESSION] ${backlog.lastSession}` : '',
429
+ backlog.status ? `[PENDENTE] ${backlog.status}` : '',
71
430
  `[ORCHESTRATOR] Greet the user via AskUserQuestion using the data above (CLAUDE.md Rule #1).`,
72
- "Do NOT invoke /orchestrator skill for the greeting — use AskUserQuestion directly.",
73
- "Invoke /orchestrator skill only when the user chooses to start a workflow.",
74
- ].join("\n")
431
+ 'Do NOT invoke /orchestrator skill for the greeting — use AskUserQuestion directly.',
432
+ 'Invoke /orchestrator skill only when the user chooses to start a workflow.',
433
+ capabilityContext,
434
+ graphContext,
435
+ sprintInboxContext,
436
+ ]
437
+ .filter(Boolean)
438
+ .join('\n')
75
439
  );
76
440
  return;
77
441
  }
78
442
 
79
443
  // Active workflow — inject state + backlog
80
444
  const action = await getNextAction(workflow.id);
81
- const actionHint = action?.hasAction && action.pendingAction
82
- ? ` | pending=${action.pendingAction.agent} (${action.pendingAction.status})`
83
- : "";
445
+ const actionHint =
446
+ action?.hasAction && action.pendingAction
447
+ ? ` | pending=${action.pendingAction.agent} (${action.pendingAction.status})`
448
+ : '';
84
449
 
85
450
  log(HOOK, `Injecting workflow context (wf=${workflow.id} phase=${workflow.currentPhase})`);
86
451
  outputContext(
87
- "SessionStart",
452
+ 'SessionStart',
88
453
  [
89
- `[ORCHESTRATOR-DATA] version=${backlog.version} | workflow=${workflow.id} | type=${workflow.type} | phase=${workflow.currentPhase} | status=${workflow.status}${actionHint}`,
454
+ `[ORCHESTRATOR-DATA] version=${backlog.version}${sessionSegment} | workflow=${workflow.id} | type=${workflow.type} | phase=${workflow.currentPhase} | status=${workflow.status}${actionHint}${tierSegment}`,
90
455
  `[ORCHESTRATOR] Active workflow detected. Greet the user via AskUserQuestion showing workflow state (CLAUDE.md Rule #1).`,
91
- "Do NOT invoke /orchestrator skill for the greeting — use AskUserQuestion directly.",
92
- "Invoke /orchestrator skill when the user chooses to continue the workflow.",
93
- ].join("\n")
456
+ 'Do NOT invoke /orchestrator skill for the greeting — use AskUserQuestion directly.',
457
+ 'Invoke /orchestrator skill when the user chooses to continue the workflow.',
458
+ capabilityContext,
459
+ graphContext,
460
+ ]
461
+ .filter(Boolean)
462
+ .join('\n')
94
463
  );
95
464
  }
96
465
 
@@ -39,10 +39,9 @@ async function main(): Promise<void> {
39
39
  const stdin = await readStdin();
40
40
  const sessionId = (stdin.session_id as string) ?? undefined;
41
41
 
42
-
43
42
  // Read required env vars
44
- const sprintId = process.env['SPRINT_ID'];
45
- const paneRole = process.env['PANE_ROLE'];
43
+ const sprintId = process.env.SPRINT_ID;
44
+ const paneRole = process.env.PANE_ROLE;
46
45
 
47
46
  if (!sprintId || !paneRole) {
48
47
  log(`Skipping: SPRINT_ID=${sprintId ?? 'unset'} PANE_ROLE=${paneRole ?? 'unset'}`);
@@ -52,7 +51,7 @@ async function main(): Promise<void> {
52
51
  const registryDir = `/tmp/${sprintId}/registry`;
53
52
  const entryPath = join(registryDir, `${paneRole}.json`);
54
53
 
55
- const workflowId = process.env['WORKFLOW_ID'];
54
+ const workflowId = process.env.WORKFLOW_ID;
56
55
 
57
56
  const entry: Record<string, unknown> = {
58
57
  role: paneRole,
@@ -73,7 +72,9 @@ async function main(): Promise<void> {
73
72
  try {
74
73
  mkdirSync(registryDir, { recursive: true });
75
74
  writeFileSync(entryPath, JSON.stringify(entry, null, 2), 'utf-8');
76
- log(`Registered ${paneRole} for sprint ${sprintId} (pid=${process.pid}, session=${sessionId ?? 'unknown'})`);
75
+ log(
76
+ `Registered ${paneRole} for sprint ${sprintId} (pid=${process.pid}, session=${sessionId ?? 'unknown'})`
77
+ );
77
78
  } catch (err) {
78
79
  // Log but do not fail — registration is best-effort
79
80
  log(`Registration failed: ${err}`);
@@ -10,17 +10,18 @@ import {
10
10
  getField,
11
11
  getActiveWorkflow,
12
12
  startInvocation,
13
- } from "./lib/api-client.js";
13
+ writeInvocationState,
14
+ } from './lib/api-client.js';
14
15
 
15
- const HOOK = "SUBAGENT-START";
16
+ const HOOK = 'SUBAGENT-START';
16
17
 
17
18
  const PHASE_MAP: Record<string, string> = {
18
- specifier: "specify",
19
- planner: "plan",
20
- "task-generator": "tasks",
21
- implementer: "implement",
22
- researcher: "research",
23
- reviewer: "review",
19
+ specifier: 'specify',
20
+ planner: 'plan',
21
+ 'task-generator': 'tasks',
22
+ implementer: 'implement',
23
+ researcher: 'research',
24
+ reviewer: 'review',
24
25
  };
25
26
 
26
27
  async function main(): Promise<void> {
@@ -28,10 +29,10 @@ async function main(): Promise<void> {
28
29
  log(HOOK, `SubagentStart triggered`);
29
30
 
30
31
  const agentType =
31
- getField(stdin, "tool_input.subagent_type") ||
32
- getField(stdin, "subagent_type") ||
33
- getField(stdin, "tool_input.type") ||
34
- "unknown";
32
+ getField(stdin, 'tool_input.subagent_type') ||
33
+ getField(stdin, 'subagent_type') ||
34
+ getField(stdin, 'tool_input.type') ||
35
+ 'unknown';
35
36
 
36
37
  const phase = PHASE_MAP[agentType] || agentType;
37
38
 
@@ -39,12 +40,23 @@ async function main(): Promise<void> {
39
40
 
40
41
  const workflow = await getActiveWorkflow();
41
42
  if (!workflow) {
42
- log(HOOK, "No active workflow, skipping");
43
+ log(HOOK, 'No active workflow, skipping');
43
44
  return;
44
45
  }
45
46
 
46
- await startInvocation(agentType, workflow.id, phase);
47
- log(HOOK, `START: agent=${agentType} phase=${phase} workflow=${workflow.id}`);
47
+ const invocationId = await startInvocation(agentType, workflow.id, phase);
48
+ if (invocationId) {
49
+ const written = writeInvocationState(workflow.id, agentType, invocationId);
50
+ log(
51
+ HOOK,
52
+ `START: agent=${agentType} phase=${phase} workflow=${workflow.id} invocationId=${invocationId} stateWritten=${written}`
53
+ );
54
+ } else {
55
+ log(
56
+ HOOK,
57
+ `START: agent=${agentType} phase=${phase} workflow=${workflow.id} — startInvocation returned null (POST failed or no token)`
58
+ );
59
+ }
48
60
  }
49
61
 
50
62
  main().catch(() => process.exit(0));