wogiflow 2.7.0 → 2.8.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.
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow - Claude Code PermissionDenied Hook
5
+ *
6
+ * Fires after auto-mode classifier denies a tool use.
7
+ * Available in Claude Code 2.1.88+.
8
+ *
9
+ * Handles:
10
+ * 1. Logging — tracks denied permissions for diagnostics
11
+ * 2. Workspace redirect — when a worker tries to access a file in another
12
+ * repo, redirect via the workspace message bus instead of retrying
13
+ * 3. Guidance — provides actionable hints about what to do instead
14
+ *
15
+ * Return { retry: true } to tell the model it can retry the operation.
16
+ * Return { retry: false } (or nothing) to accept the denial.
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const path = require('node:path');
22
+ const { runHook } = require('../shared/hook-runner');
23
+
24
+ runHook('PermissionDenied', async ({ input, parsedInput }) => {
25
+ const toolName = parsedInput.toolName || input.tool_name || 'unknown';
26
+ const toolInput = parsedInput.toolInput || input.tool_input || {};
27
+ const filePath = toolInput.file_path || toolInput.command || '';
28
+ const reason = input.denial_reason || input.reason || '';
29
+
30
+ // ── 1. Log the denial for diagnostics ──────────────────────
31
+ try {
32
+ const fs = require('node:fs');
33
+ const { PATHS } = require('../../../flow-utils');
34
+ const logPath = path.join(PATHS.state, 'permission-denials.json');
35
+
36
+ let denials = [];
37
+ try {
38
+ if (fs.existsSync(logPath)) {
39
+ denials = JSON.parse(fs.readFileSync(logPath, 'utf-8'));
40
+ if (!Array.isArray(denials)) denials = [];
41
+ }
42
+ } catch (_err) {
43
+ denials = [];
44
+ }
45
+
46
+ denials.push({
47
+ tool: toolName,
48
+ target: typeof filePath === 'string' ? filePath.substring(0, 200) : '',
49
+ reason: typeof reason === 'string' ? reason.substring(0, 200) : '',
50
+ timestamp: new Date().toISOString()
51
+ });
52
+
53
+ // Keep last 100 denials
54
+ if (denials.length > 100) denials = denials.slice(-100);
55
+
56
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
57
+ fs.writeFileSync(logPath, JSON.stringify(denials, null, 2));
58
+ } catch (_err) {
59
+ // Non-critical — don't let logging failure break the hook
60
+ }
61
+
62
+ // ── 2. Workspace redirect — cross-repo file access ─────────
63
+ // When a worker tries to read/write a file that belongs to another repo,
64
+ // redirect them to use the workspace message bus instead.
65
+ const isWorkspace = !!process.env.WOGI_WORKSPACE_ROOT;
66
+ const repoName = process.env.WOGI_REPO_NAME || '';
67
+
68
+ if (isWorkspace && typeof filePath === 'string' && filePath.length > 0) {
69
+ const workspaceRoot = process.env.WOGI_WORKSPACE_ROOT;
70
+
71
+ // Check if the denied path is inside the workspace but outside this repo
72
+ try {
73
+ const resolvedFile = path.resolve(filePath);
74
+ const resolvedRoot = path.resolve(workspaceRoot);
75
+ const isInsideWorkspace = resolvedFile.startsWith(resolvedRoot + path.sep);
76
+ const cwd = process.cwd();
77
+ const isOutsideOwnRepo = !resolvedFile.startsWith(cwd + path.sep) && resolvedFile !== cwd;
78
+
79
+ if (isInsideWorkspace && isOutsideOwnRepo) {
80
+ // This is a cross-repo access attempt — redirect to message bus
81
+ // Extract the target repo name from the path
82
+ const relPath = resolvedFile.substring(resolvedRoot.length + 1);
83
+ const targetRepo = relPath.split(path.sep)[0] || 'unknown';
84
+
85
+ const guidance = [
86
+ `Cross-repo file access denied: ${toolName} on ${path.basename(filePath)}`,
87
+ `Target repo: ${targetRepo} (you are: ${repoName})`,
88
+ '',
89
+ 'In workspace mode, repos cannot directly access each other\'s files.',
90
+ `Use workspace_send_message(to: "${targetRepo}", message: "...") to ask the other repo\'s worker.`,
91
+ `Or use workspace_send_message(to: "manager", message: "...") to escalate to the manager.`
92
+ ].join('\n');
93
+
94
+ return {
95
+ __raw: true,
96
+ retry: false,
97
+ message: guidance
98
+ };
99
+ }
100
+ } catch (_err) {
101
+ // Path resolution failed — fall through to default handling
102
+ }
103
+ }
104
+
105
+ // ── 3. Default handling — accept denial with guidance ──────
106
+ // For non-workspace denials, just accept and let the model know
107
+ return {
108
+ __raw: true,
109
+ retry: false
110
+ };
111
+ }, { failMode: 'silent' });
@@ -269,6 +269,8 @@ const HOOK_VERSION_MAP = {
269
269
  PostCompact: { major: 2, minor: 1, patch: 76 },
270
270
  // Hooks added in 2.1.84+
271
271
  TaskCreated: { major: 2, minor: 1, patch: 84 },
272
+ // Hooks added in 2.1.88+
273
+ PermissionDenied: { major: 2, minor: 1, patch: 88 },
272
274
  };
273
275
 
274
276
  /**
@@ -310,7 +312,26 @@ function versionMeetsMinimum(version, minimum) {
310
312
  * @returns {string[]} List of removed hook names
311
313
  */
312
314
  function stripUnsupportedHooks(settings, ccVersion) {
313
- if (!settings || !settings.hooks || !ccVersion) return [];
315
+ if (!settings || !settings.hooks) return [];
316
+
317
+ // CRITICAL: When CC version is unknown, strip ALL hooks added after the base set (2.1.23).
318
+ // CC rejects the ENTIRE settings file if it encounters an unknown hook event name —
319
+ // this means one unsupported hook kills ALL hooks. Fail-safe: keep only the base set.
320
+ const BASE_HOOKS = new Set([
321
+ 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
322
+ 'Stop', 'SessionEnd', 'TaskCompleted'
323
+ ]);
324
+
325
+ if (!ccVersion) {
326
+ const removed = [];
327
+ for (const hookName of Object.keys(settings.hooks)) {
328
+ if (!BASE_HOOKS.has(hookName)) {
329
+ delete settings.hooks[hookName];
330
+ removed.push(`${hookName} (CC version unknown — keeping base hooks only)`);
331
+ }
332
+ }
333
+ return removed;
334
+ }
314
335
 
315
336
  const removed = [];
316
337
  for (const hookName of Object.keys(settings.hooks)) {
@@ -323,6 +344,39 @@ function stripUnsupportedHooks(settings, ccVersion) {
323
344
  return removed;
324
345
  }
325
346
 
347
+ /**
348
+ * Dynamically inject hooks that are NOT in the base settings.json but are
349
+ * supported by the detected Claude Code version. These hooks are intentionally
350
+ * excluded from the committed settings.json because CC rejects the ENTIRE file
351
+ * when it encounters an unknown hook event name.
352
+ *
353
+ * @param {Object} settings - Parsed settings object (mutated in place)
354
+ * @param {{ major: number, minor: number, patch: number }} ccVersion
355
+ */
356
+ function injectDynamicHooks(settings, ccVersion) {
357
+ if (!settings || !settings.hooks) return;
358
+
359
+ // Map of hooks to inject with their minimum version and command
360
+ const DYNAMIC_HOOKS = [
361
+ {
362
+ name: 'TaskCreated',
363
+ minVersion: { major: 2, minor: 1, patch: 84 },
364
+ config: [{ hooks: [{ type: 'command', command: 'node scripts/hooks/entry/claude-code/task-created.js', timeout: 5 }] }]
365
+ },
366
+ {
367
+ name: 'PermissionDenied',
368
+ minVersion: { major: 2, minor: 1, patch: 88 },
369
+ config: [{ hooks: [{ type: 'command', command: 'node scripts/hooks/entry/claude-code/permission-denied.js', timeout: 5 }] }]
370
+ }
371
+ ];
372
+
373
+ for (const hook of DYNAMIC_HOOKS) {
374
+ if (!settings.hooks[hook.name] && versionMeetsMinimum(ccVersion, hook.minVersion)) {
375
+ settings.hooks[hook.name] = hook.config;
376
+ }
377
+ }
378
+ }
379
+
326
380
  /**
327
381
  * Rewrite hook command paths from local dev paths to package paths.
328
382
  * The package's settings.json uses local paths (node scripts/hooks/...)
@@ -404,12 +458,20 @@ function copyClaudeResources() {
404
458
  const ccVersion = detectClaudeCodeVersion();
405
459
  const removedHooks = stripUnsupportedHooks(ours, ccVersion);
406
460
  if (removedHooks.length > 0) {
407
- console.log(`[wogiflow] Claude Code ${ccVersion.major}.${ccVersion.minor}.${ccVersion.patch} detected. Excluded unsupported hooks:`);
461
+ const versionStr = ccVersion ? `${ccVersion.major}.${ccVersion.minor}.${ccVersion.patch}` : 'unknown';
462
+ console.log(`[wogiflow] Claude Code ${versionStr} detected. Excluded unsupported hooks:`);
408
463
  for (const h of removedHooks) {
409
464
  console.log(` - ${h}`);
410
465
  }
411
466
  console.log('[wogiflow] Update Claude Code for full functionality: npm i -g @anthropic-ai/claude-code@latest');
412
467
  }
468
+
469
+ // Dynamically inject hooks supported by this CC version but not in the base settings.json.
470
+ // These hooks are NOT committed to the repo to avoid breaking older CC versions.
471
+ if (ccVersion) {
472
+ injectDynamicHooks(ours, ccVersion);
473
+ }
474
+
413
475
  // Always update hooks (core WogiFlow functionality)
414
476
  existing.hooks = ours.hooks;
415
477
  existing._wogiFlowManaged = true;