@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.
@@ -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));
@@ -0,0 +1,88 @@
1
+ /**
2
+ * post-implement.ts — PostToolUse[mcp__orchestrator-tools__advancePhase] hook
3
+ * (RFC-034 §5 / TD-102, System-A vehicle)
4
+ *
5
+ * Purpose: emit a deterministic orchestrator signal the moment the IMPLEMENT phase
6
+ * has been completed, so the main-as-orchestrator dispatches the docs-guardian
7
+ * sub-agent before proceeding past the IMPLEMENT gate. A hook cannot itself spawn a
8
+ * Claude sub-agent in the main-as-orchestrator model (RFC-022) — it can only signal;
9
+ * the orchestrator owns the dispatch (see the orchestrator Skin / SKILL dispatch rule).
10
+ *
11
+ * Detection rationale (verify-before-assert, S307):
12
+ * The MCP advancePhase tool RESPONSE is only `{ success, workflowId }`
13
+ * (AdvancePhaseHandler.ts) — it discards the REST controller's `currentPhase` and
14
+ * carries NO from/previous phase. At PostToolUse time the advance has already
15
+ * committed, so the live workflow's currentPhase is the NEW phase. In the standard
16
+ * dev workflow sequence (… -> implement -> review -> complete), 'review' is reached
17
+ * ONLY by leaving 'implement'. So post-advance currentPhase === 'review' is the
18
+ * available, deterministic proxy for "IMPLEMENT just completed". It fires exactly
19
+ * once (a later review -> complete advance lands on 'complete', not 'review').
20
+ *
21
+ * Fail-open: any missing env / API error / parse failure -> emit nothing, never throw.
22
+ * PostToolUse cannot block the tool; emitting no context is the safe no-op.
23
+ */
24
+
25
+ import {
26
+ readStdin,
27
+ getField,
28
+ getActiveWorkflow,
29
+ getWorkflowStatus,
30
+ outputContext,
31
+ log,
32
+ } from './lib/api-client.js';
33
+
34
+ const HOOK = 'POST-IMPLEMENT';
35
+
36
+ /** The phase entered upon completing IMPLEMENT in the standard dev workflow sequence. */
37
+ const POST_IMPLEMENT_PHASE = 'review';
38
+
39
+ /**
40
+ * True when the post-advance phase indicates IMPLEMENT was just completed.
41
+ * Case-insensitive; empty/unknown phases return false (fail-safe — no signal).
42
+ */
43
+ function isImplementCompleted(currentPhase: string | null | undefined): boolean {
44
+ return (currentPhase ?? '').toLowerCase() === POST_IMPLEMENT_PHASE;
45
+ }
46
+
47
+ /** Builds the orchestrator dispatch signal for a completed IMPLEMENT phase. */
48
+ function buildPostImplementSignal(workflowId: string): string {
49
+ return [
50
+ `[ORCHESTRATOR] IMPLEMENT completed for ${workflowId} — dispatch the docs-guardian sub-agent before proceeding past the IMPLEMENT gate.`,
51
+ `(RFC-034 System-A post-implement signal: docs-guardian checks documentation compliance against the DOCS-CONSTITUTION after implementation.)`,
52
+ ].join('\n');
53
+ }
54
+
55
+ async function main(): Promise<void> {
56
+ const stdin = await readStdin();
57
+
58
+ // Defensive: only act on advancePhase (the settings.json matcher already scopes this).
59
+ const toolName = getField(stdin, 'tool_name') || getField(stdin, 'tool');
60
+ if (toolName && !toolName.includes('advancePhase')) {
61
+ return;
62
+ }
63
+
64
+ // Resolve the workflow id: prefer the tool input, else the active workflow.
65
+ const wfId = getField(stdin, 'tool_input.workflowId') || (await getActiveWorkflow())?.id || '';
66
+ if (!wfId) {
67
+ log(HOOK, 'no workflow id resolvable — skip (fail-open)');
68
+ return;
69
+ }
70
+
71
+ const status = await getWorkflowStatus(wfId);
72
+ if (!status) {
73
+ log(HOOK, `no status for workflow ${wfId} — skip (fail-open)`);
74
+ return;
75
+ }
76
+
77
+ const phase = (status.currentPhase || '').toLowerCase();
78
+ log(HOOK, `workflow=${wfId} post-advance currentPhase=${phase}`);
79
+
80
+ if (!isImplementCompleted(phase)) {
81
+ return; // not the implement -> review transition
82
+ }
83
+
84
+ log(HOOK, `IMPLEMENT completed for ${wfId} — emitting docs-guardian dispatch signal`);
85
+ outputContext('PostToolUse', buildPostImplementSignal(wfId));
86
+ }
87
+
88
+ main().catch(() => process.exit(0));