@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.
@@ -20,15 +20,24 @@
20
20
  * - Backward compat: if process.stdin.isTTY → skip stdin read (PostToolUse path)
21
21
  */
22
22
 
23
- import { readdirSync, readFileSync, unlinkSync, renameSync, existsSync, mkdirSync } from "node:fs";
24
- import { join } from "node:path";
25
- import { log, outputContext } from "./lib/api-client.js";
26
-
27
- const HOOK = "MAILBOX-LISTENER";
23
+ import {
24
+ readdirSync,
25
+ readFileSync,
26
+ unlinkSync,
27
+ renameSync,
28
+ existsSync,
29
+ mkdirSync,
30
+ writeFileSync,
31
+ } from 'node:fs';
32
+ import { join } from 'node:path';
33
+ import { randomUUID } from 'node:crypto';
34
+ import { log, outputContext } from './lib/api-client.js';
35
+
36
+ const HOOK = 'MAILBOX-LISTENER';
28
37
 
29
38
  // --- Types (minimal, no Zod dependency in hooks) ---
30
39
 
31
- interface MailboxMessage {
40
+ export interface MailboxMessage {
32
41
  id: string;
33
42
  from: { role: string; index: number };
34
43
  to: { role: string; index: number };
@@ -53,7 +62,7 @@ async function readStdinPayload(): Promise<Record<string, unknown>> {
53
62
  for await (const chunk of process.stdin) {
54
63
  chunks.push(chunk as Buffer);
55
64
  }
56
- const raw = Buffer.concat(chunks).toString("utf-8").trim();
65
+ const raw = Buffer.concat(chunks).toString('utf-8').trim();
57
66
  if (!raw) return {};
58
67
  try {
59
68
  return JSON.parse(raw) as Record<string, unknown>;
@@ -67,13 +76,13 @@ async function readStdinPayload(): Promise<Record<string, unknown>> {
67
76
  function consumeInbox(
68
77
  inboxPath: string,
69
78
  quarantinePath: string,
70
- paneRole: string,
79
+ paneRole: string
71
80
  ): { consumed: MailboxMessage[]; errors: string[] } {
72
81
  // List .json files (exclude .tmp- in-flight writes)
73
82
  let files: string[];
74
83
  try {
75
84
  files = readdirSync(inboxPath)
76
- .filter((f) => f.endsWith(".json") && !f.startsWith(".tmp-"))
85
+ .filter((f) => f.endsWith('.json') && !f.startsWith('.tmp-'))
77
86
  .sort(); // FIFO by filename (UUID-based, but sorted for determinism)
78
87
  } catch {
79
88
  return { consumed: [], errors: [] }; // Directory read error — fail silently
@@ -91,7 +100,7 @@ function consumeInbox(
91
100
  for (const file of files) {
92
101
  const filePath = join(inboxPath, file);
93
102
  try {
94
- const raw = readFileSync(filePath, "utf-8");
103
+ const raw = readFileSync(filePath, 'utf-8');
95
104
  const parsed = JSON.parse(raw) as MailboxMessage;
96
105
 
97
106
  // Basic validation (no Zod in hooks — keep deps minimal)
@@ -102,7 +111,10 @@ function consumeInbox(
102
111
  // Consume: delete after successful parse
103
112
  unlinkSync(filePath);
104
113
  consumed.push(parsed);
105
- log(HOOK, `Consumed: ${parsed.id} (${parsed.body.type} from ${parsed.from.role}-${parsed.from.index})`);
114
+ log(
115
+ HOOK,
116
+ `Consumed: ${parsed.id} (${parsed.body.type} from ${parsed.from.role}-${parsed.from.index})`
117
+ );
106
118
  } catch (err) {
107
119
  // Quarantine malformed message
108
120
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -125,23 +137,21 @@ function consumeInbox(
125
137
  function buildContext(
126
138
  consumed: MailboxMessage[],
127
139
  errors: string[],
128
- paneRole: string,
140
+ paneRole: string
129
141
  ): string | null {
130
142
  if (consumed.length === 0 && errors.length === 0) {
131
143
  return null;
132
144
  }
133
145
 
134
- const parts: string[] = [
135
- `[MAILBOX] ${consumed.length} new message(s) for ${paneRole}:`,
136
- ];
146
+ const parts: string[] = [`[MAILBOX] ${consumed.length} new message(s) for ${paneRole}:`];
137
147
 
138
148
  for (const msg of consumed) {
139
149
  const from = `${msg.from.role}-${msg.from.index}`;
140
- parts.push("");
150
+ parts.push('');
141
151
  parts.push(`--- Message from ${from} (${msg.body.type}) ---`);
142
152
 
143
153
  switch (msg.body.type) {
144
- case "task_assignment":
154
+ case 'task_assignment':
145
155
  parts.push(`Task: ${msg.body.taskId}`);
146
156
  parts.push(`Title: ${msg.body.title}`);
147
157
  if (msg.body.description) parts.push(`Description: ${msg.body.description}`);
@@ -153,28 +163,28 @@ function buildContext(
153
163
  }
154
164
  break;
155
165
 
156
- case "task_complete":
166
+ case 'task_complete':
157
167
  parts.push(`Task: ${msg.body.taskId}`);
158
168
  parts.push(`Summary: ${msg.body.summary}`);
159
169
  break;
160
170
 
161
- case "status_update":
171
+ case 'status_update':
162
172
  parts.push(`Task: ${msg.body.taskId}`);
163
173
  parts.push(`Progress: ${msg.body.progress}%`);
164
174
  parts.push(`Status: ${msg.body.message}`);
165
175
  break;
166
176
 
167
- case "error_report":
177
+ case 'error_report':
168
178
  parts.push(`Error: ${msg.body.errorCode}`);
169
179
  parts.push(`Message: ${msg.body.message}`);
170
180
  if (msg.body.taskId) parts.push(`Task: ${msg.body.taskId}`);
171
181
  break;
172
182
 
173
- case "ping":
183
+ case 'ping':
174
184
  parts.push(`Ping received — respond with pong if appropriate.`);
175
185
  break;
176
186
 
177
- case "pong":
187
+ case 'pong':
178
188
  parts.push(`Pong received — heartbeat confirmed.`);
179
189
  break;
180
190
 
@@ -185,14 +195,113 @@ function buildContext(
185
195
  }
186
196
 
187
197
  if (errors.length > 0) {
188
- parts.push("");
198
+ parts.push('');
189
199
  parts.push(`[MAILBOX-WARN] ${errors.length} malformed message(s) quarantined:`);
190
200
  for (const e of errors) {
191
201
  parts.push(` - ${e}`);
192
202
  }
193
203
  }
194
204
 
195
- return parts.join("\n");
205
+ return parts.join('\n');
206
+ }
207
+
208
+ // --- Sidecar relay helpers (exported for testability, raw node:fs only) ---
209
+
210
+ /** Injectable deps interface for enumerateSidecarAddresses (enables unit testing without real fs). */
211
+ export interface SidecarRelayDeps {
212
+ readdirSync: (path: string) => string[];
213
+ writeFileSync: (path: string, data: string, encoding: BufferEncoding) => void;
214
+ }
215
+
216
+ /**
217
+ * Known sidecar roles (RFC-025 RoleSchema). Used as allowlist in enumerateSidecarAddresses
218
+ * to prevent path-traversal from crafted registry filenames and to guarantee valid role values.
219
+ */
220
+ const SIDECAR_ROLE_ALLOWLIST: ReadonlySet<string> = new Set([
221
+ 'debug-sidecar',
222
+ 'test-sidecar',
223
+ 'doc-sidecar',
224
+ ]);
225
+
226
+ /**
227
+ * Reads registry filenames and returns sidecar addresses (role + index).
228
+ * Reads filenames only — never parses registry JSON content.
229
+ * Returns [] on ENOENT or any read error (best-effort).
230
+ * Only returns entries whose role is in SIDECAR_ROLE_ALLOWLIST (path-traversal defense).
231
+ */
232
+ export function enumerateSidecarAddresses(
233
+ sprintId: string,
234
+ deps: SidecarRelayDeps = { readdirSync, writeFileSync }
235
+ ): { role: string; index: number }[] {
236
+ const registryDir = `/tmp/${sprintId}/registry`;
237
+ let files: string[];
238
+ try {
239
+ files = deps.readdirSync(registryDir);
240
+ } catch {
241
+ return [];
242
+ }
243
+ const results: { role: string; index: number }[] = [];
244
+ for (const filename of files) {
245
+ if (!filename.endsWith('.json')) continue;
246
+ const base = filename.slice(0, -5); // strip .json
247
+ if (!base.includes('sidecar')) continue;
248
+ const lastDash = base.lastIndexOf('-');
249
+ if (lastDash === -1) continue;
250
+ const indexStr = base.slice(lastDash + 1);
251
+ const index = parseInt(indexStr, 10);
252
+ if (isNaN(index)) continue;
253
+ const role = base.slice(0, lastDash);
254
+ // Allowlist: only accept known sidecar roles (prevents path-traversal, guarantees RoleSchema validity)
255
+ if (!SIDECAR_ROLE_ALLOWLIST.has(role)) continue;
256
+ results.push({ role, index });
257
+ }
258
+ return results;
259
+ }
260
+
261
+ /**
262
+ * Relays task_complete messages from consumed list to each sidecar inbox.
263
+ * Best-effort: per-sidecar try/catch — one failure does not block others.
264
+ * Only task_complete messages trigger relay (RF-001).
265
+ * Only runs when paneRole belongs to the orchestrator (role component === 'orchestrator').
266
+ */
267
+ export function relaySidecarMessages(
268
+ consumed: MailboxMessage[],
269
+ sprintId: string,
270
+ paneRole: string,
271
+ deps: SidecarRelayDeps = { readdirSync, writeFileSync }
272
+ ): void {
273
+ // Guard: only the orchestrator pane relays messages (prevents duplicate relay from other panes)
274
+ const roleComponent = paneRole.slice(0, paneRole.lastIndexOf('-'));
275
+ if (roleComponent !== 'orchestrator') return;
276
+
277
+ const taskCompletes = consumed.filter((m) => m.body.type === 'task_complete');
278
+ if (taskCompletes.length === 0) return;
279
+
280
+ const sidecars = enumerateSidecarAddresses(sprintId, deps);
281
+ if (sidecars.length === 0) return;
282
+
283
+ for (const msg of taskCompletes) {
284
+ for (const sidecar of sidecars) {
285
+ try {
286
+ const envelope = {
287
+ id: randomUUID(),
288
+ from: { role: 'orchestrator', index: 0 },
289
+ to: { role: sidecar.role, index: sidecar.index },
290
+ timestamp: new Date().toISOString(),
291
+ body: msg.body,
292
+ };
293
+ const sidecarAddr = `${sidecar.role}-${String(sidecar.index)}`;
294
+ const targetInbox = `/tmp/${sprintId}/mailbox/${sidecarAddr}/inbox`;
295
+ const targetPath = join(targetInbox, `${envelope.id}.json`);
296
+ deps.writeFileSync(targetPath, JSON.stringify(envelope), 'utf-8');
297
+ log(HOOK, `Relayed task_complete to ${sidecarAddr}: ${envelope.id}`);
298
+ } catch (err) {
299
+ const errMsg = err instanceof Error ? err.message : String(err);
300
+ log(HOOK, `Relay failed for ${sidecar.role}-${String(sidecar.index)}: ${errMsg}`);
301
+ // Best-effort: continue to next sidecar
302
+ }
303
+ }
304
+ }
196
305
  }
197
306
 
198
307
  // --- Main ---
@@ -232,6 +341,9 @@ async function main(): Promise<void> {
232
341
  return;
233
342
  }
234
343
 
344
+ // Relay task_complete to sidecar inboxes (best-effort, RFC-025 TD-135)
345
+ relaySidecarMessages(consumed, sprintId, paneRole);
346
+
235
347
  // Build context string
236
348
  const context = buildContext(consumed, errors, paneRole);
237
349
  if (!context) {
@@ -239,8 +351,10 @@ async function main(): Promise<void> {
239
351
  }
240
352
 
241
353
  // Emit context with the appropriate hook event name
242
- const effectiveEvent = hookEventName === "SessionStart" ? "SessionStart" : "PostToolUse";
354
+ const effectiveEvent = hookEventName === 'SessionStart' ? 'SessionStart' : 'PostToolUse';
243
355
  outputContext(effectiveEvent, context);
244
356
  }
245
357
 
358
+ // main() exits silently when SPRINT_ID/PANE_ROLE are unset (test/import context).
359
+ // No guard needed: the env-var check at the top of main() is the safe gate.
246
360
  void main();
@@ -2,23 +2,142 @@
2
2
  * post-compact.ts — PostCompact hook (RFC-022 Phase 2)
3
3
  * NEW hook: re-injects workflow state after context compaction
4
4
  * so Claude doesn't lose track of active workflow.
5
+ * Enhanced (RFC-027 S3): also re-injects capability matrix + memory context.
5
6
  */
6
7
 
8
+ import { readFileSync, existsSync } from 'node:fs';
9
+ import { join, dirname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { parse as parseYaml } from 'yaml';
12
+ import { log, getActiveWorkflow, getNextAction, outputContext } from './lib/api-client.js';
7
13
  import {
8
- log,
9
- getActiveWorkflow,
10
- getNextAction,
11
- outputContext,
12
- } from "./lib/api-client.js";
14
+ needsRescan,
15
+ buildCapabilityIndex,
16
+ writeCapabilitiesToVault,
17
+ readCapabilitiesFromVault,
18
+ scanAgentDefinitions,
19
+ formatCapabilityMatrix,
20
+ readMemoryIndex,
21
+ filterRelevantMemories,
22
+ formatMemoryContext,
23
+ } from './lib/capability-scanner.js';
13
24
 
14
- const HOOK = "POST-COMPACT";
25
+ const HOOK = 'POST-COMPACT';
26
+ const __filename = fileURLToPath(import.meta.url);
27
+ const __dirname = dirname(__filename);
28
+ const PROJECT_ROOT = join(__dirname, '..', '..');
29
+
30
+ // ─── Capability Matrix paths (mirror session-start.ts constants) ────────────
31
+ const HOME = process.env.HOME ?? process.env.USERPROFILE ?? '/home/user';
32
+ const INSTALLED_PLUGINS_PATH = join(HOME, '.claude', 'plugins', 'installed_plugins.json');
33
+ const AGENTS_DIR = join(PROJECT_ROOT, '.claude', 'agents');
34
+ const VAULT_CONFIG_PATH = join(PROJECT_ROOT, 'project-guidelines', '.vault-config.yaml');
35
+ const PROFILES_PATH = join(PROJECT_ROOT, '.claude', 'plugin-profiles.yaml');
36
+ const MEMORY_INDEX_PATH = join(
37
+ PROJECT_ROOT,
38
+ '.claude',
39
+ 'agent-memory',
40
+ 'orchestrator',
41
+ 'memory-index.yaml'
42
+ );
43
+
44
+ /**
45
+ * Build [CAPABILITY-MATRIX] + [MEMORY-CONTEXT] lines for injection.
46
+ * Fail-open: returns empty string on any error.
47
+ * Pattern copied from session-start.ts buildCapabilityMatrixContext().
48
+ */
49
+ function buildCapabilityMatrixContext(): string {
50
+ try {
51
+ let index = readCapabilitiesFromVault(VAULT_CONFIG_PATH);
52
+ const cachedMtime = index?.installedPluginsMtime ?? '';
53
+
54
+ if (!index || needsRescan(INSTALLED_PLUGINS_PATH, cachedMtime)) {
55
+ log(HOOK, 'Capability rescan triggered');
56
+ index = buildCapabilityIndex({
57
+ installedPluginsPath: INSTALLED_PLUGINS_PATH,
58
+ agentsDir: AGENTS_DIR,
59
+ vaultConfigPath: VAULT_CONFIG_PATH,
60
+ profilesPath: PROFILES_PATH,
61
+ });
62
+ writeCapabilitiesToVault(VAULT_CONFIG_PATH, index);
63
+ } else {
64
+ // Agents are always scanned fresh (fast, <10ms)
65
+ index.agents = scanAgentDefinitions(AGENTS_DIR);
66
+ log(HOOK, 'Using cached capability index');
67
+ }
68
+
69
+ const matrixLine = formatCapabilityMatrix(index);
70
+
71
+ // Memory context
72
+ const memoryEntries = readMemoryIndex(MEMORY_INDEX_PATH);
73
+ let memoryLine = '';
74
+ if (memoryEntries) {
75
+ const filtered = filterRelevantMemories(memoryEntries);
76
+ memoryLine = formatMemoryContext(filtered);
77
+ }
78
+
79
+ return [matrixLine, memoryLine].filter(Boolean).join('\n');
80
+ } catch (err) {
81
+ log(HOOK, `Capability matrix failed: ${err}`);
82
+ return '';
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Read version — try project-state.yaml first (RFC-033 Phase 1),
88
+ * fall back to legacy BACKLOG.md regex on any failure.
89
+ */
90
+ function readVersion(): string {
91
+ // Primary: project-state.yaml
92
+ try {
93
+ const statePath = join(PROJECT_ROOT, 'project-guidelines', 'project-state.yaml');
94
+ if (existsSync(statePath)) {
95
+ const raw = readFileSync(statePath, 'utf-8');
96
+ const doc = parseYaml(raw) as Record<string, unknown>;
97
+ const version = doc?.version as string | undefined;
98
+ if (version && typeof version === 'string' && /^v\d+\.\d+\.\d+/.test(version)) {
99
+ log(HOOK, `project-state.yaml version=${version}`);
100
+ return version;
101
+ }
102
+ }
103
+ } catch {
104
+ // fall through to BACKLOG.md
105
+ }
106
+
107
+ // Fallback: BACKLOG.md regex (original logic, preserved)
108
+ try {
109
+ const backlogPath = join(PROJECT_ROOT, 'project-guidelines', 'BACKLOG.md');
110
+ if (!existsSync(backlogPath)) return 'v?.?.?';
111
+ const content = readFileSync(backlogPath, 'utf-8');
112
+ const lines = content.split('\n').slice(0, 40);
113
+ const versionLine = lines.find((l) => /release atual|current release/i.test(l));
114
+ const match = versionLine?.match(/v(\d+\.\d+\.\d+)/);
115
+ log(HOOK, 'BACKLOG.md fallback version');
116
+ return match ? `v${match[1]}` : 'v?.?.?';
117
+ } catch {
118
+ return 'v?.?.?';
119
+ }
120
+ }
15
121
 
16
122
  async function main(): Promise<void> {
17
- log(HOOK, "PostCompact hook triggered");
123
+ log(HOOK, 'PostCompact hook triggered');
18
124
 
125
+ const version = readVersion();
126
+ const capabilityContext = buildCapabilityMatrixContext();
19
127
  const workflow = await getActiveWorkflow();
128
+
20
129
  if (!workflow) {
21
- log(HOOK, "No active workflow, nothing to re-inject");
130
+ log(HOOK, 'No active workflow — injecting capability context only');
131
+ outputContext(
132
+ 'PostCompact',
133
+ [
134
+ `[ORCHESTRATOR] Context was compacted. version=${version} | workflow=none`,
135
+ `Remember: YOU are the orchestrator (RFC-022). Dispatch sub-agents directly via Agent tool.`,
136
+ capabilityContext,
137
+ ]
138
+ .filter(Boolean)
139
+ .join('\n')
140
+ );
22
141
  return;
23
142
  }
24
143
 
@@ -35,10 +154,17 @@ async function main(): Promise<void> {
35
154
  parts.push(`Next: invoke '${action.pendingAction.agent}' (${action.pendingAction.status})`);
36
155
  }
37
156
 
38
- parts.push("", "Remember: YOU are the orchestrator (RFC-022). Dispatch sub-agents directly via Agent tool.");
157
+ parts.push(
158
+ '',
159
+ 'Remember: YOU are the orchestrator (RFC-022). Dispatch sub-agents directly via Agent tool.'
160
+ );
161
+
162
+ if (capabilityContext) {
163
+ parts.push(capabilityContext);
164
+ }
39
165
 
40
- log(HOOK, `Re-injecting workflow state (wf=${workflow.id})`);
41
- outputContext("PostCompact", parts.join("\n"));
166
+ log(HOOK, `Re-injecting workflow state + capability context (wf=${workflow.id})`);
167
+ outputContext('PostCompact', parts.join('\n'));
42
168
  }
43
169
 
44
170
  main().catch(() => process.exit(0));