chati-dev 4.2.0 → 4.2.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.
@@ -41,12 +41,25 @@ const STATE_TO_GOVERNANCE_MODE = {
41
41
 
42
42
  function getCurrentMode(projectDir) {
43
43
  const sessionPath = join(projectDir, '.chati', 'session.yaml');
44
- if (!existsSync(sessionPath)) return 'planning'; // Default to most restrictive
44
+ // No session = no enforcement yet (orchestrator will set mode on first run).
45
+ // Defaulting to planning here would deadlock fresh installs (planning blocks
46
+ // session.yaml writes, so the orchestrator could never bootstrap).
47
+ if (!existsSync(sessionPath)) return 'build';
45
48
 
46
49
  const raw = readFileSync(sessionPath, 'utf-8');
47
- const match = raw.match(/^\s*mode:\s*(.+)$/m);
48
- const state = match ? match[1].trim().replace(/^["']|["']$/g, '') : 'discover';
49
- return STATE_TO_GOVERNANCE_MODE[state] || 'planning';
50
+ // Prefer explicit mode field; fall back to project.state mapping.
51
+ const modeMatch = raw.match(/^\s*mode:\s*(.+)$/m);
52
+ if (modeMatch) {
53
+ const mode = modeMatch[1].trim().replace(/^["']|["']$/g, '');
54
+ if (MODE_SCOPES[mode]) return mode;
55
+ }
56
+ const stateMatch = raw.match(/^\s+state:\s*(.+)$/m);
57
+ if (stateMatch) {
58
+ const state = stateMatch[1].trim().replace(/^["']|["']$/g, '');
59
+ return STATE_TO_GOVERNANCE_MODE[state] || 'build';
60
+ }
61
+ // Session exists but no mode/state field — be permissive.
62
+ return 'build';
50
63
  }
51
64
 
52
65
  function isPathAllowed(filePath, projectDir, mode) {
@@ -74,31 +87,59 @@ async function main() {
74
87
  const filePath = toolInput.file_path || toolInput.path || '';
75
88
 
76
89
  if (!filePath) {
77
- process.stdout.write(JSON.stringify({ decision: 'allow' }));
90
+ process.stdout.write(JSON.stringify(allowOutput()));
78
91
  return;
79
92
  }
80
93
 
81
94
  const mode = getCurrentMode(projectDir);
82
95
 
83
96
  if (isPathAllowed(filePath, projectDir, mode)) {
84
- process.stdout.write(JSON.stringify({ decision: 'allow' }));
97
+ process.stdout.write(JSON.stringify(allowOutput()));
85
98
  } else {
86
99
  const scope = MODE_SCOPES[mode];
87
- process.stdout.write(JSON.stringify({
88
- decision: 'block',
89
- reason: `[Article XI] ${scope.description}. Cannot write to "${filePath}" in ${mode} mode.`,
90
- }));
100
+ process.stdout.write(JSON.stringify(denyOutput(
101
+ `[Article XI] ${scope.description}. Cannot write to "${filePath}" in ${mode} mode.`
102
+ )));
91
103
  }
92
104
  } catch (err) {
93
105
  process.stderr.write(`[chati-hook-error] mode-governance: ${err?.message || 'unknown'}\n`);
94
- process.stdout.write(JSON.stringify({ decision: 'block', reason: 'Hook error fail-closed for safety' }));
106
+ // Hook errors are fail-OPEN: a broken hook should not block the user.
107
+ // Security-critical denials are handled by explicit logic above.
108
+ process.stdout.write(JSON.stringify(allowOutput()));
95
109
  }
96
110
  }
97
111
 
112
+ function allowOutput() {
113
+ return {
114
+ hookSpecificOutput: {
115
+ hookEventName: 'PreToolUse',
116
+ permissionDecision: 'allow',
117
+ },
118
+ };
119
+ }
120
+
121
+ function denyOutput(reason) {
122
+ return {
123
+ hookSpecificOutput: {
124
+ hookEventName: 'PreToolUse',
125
+ permissionDecision: 'deny',
126
+ permissionDecisionReason: reason,
127
+ },
128
+ };
129
+ }
130
+
98
131
  export { getCurrentMode, isPathAllowed, MODE_SCOPES, STATE_TO_GOVERNANCE_MODE };
99
132
 
100
- // Only run main when executed directly (not imported by tests)
133
+ // Only run main when executed directly (not imported by tests).
134
+ // realpathSync resolves symlinks on both sides — important on macOS where
135
+ // /tmp is a symlink to /private/tmp. Without realpath the comparison fails
136
+ // silently and main() never runs.
101
137
  import { fileURLToPath } from 'url';
102
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
103
- main();
138
+ import { realpathSync } from 'fs';
139
+ if (process.argv[1]) {
140
+ try {
141
+ if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
142
+ main();
143
+ }
144
+ } catch { /* path resolution failed — not invoked as a script */ }
104
145
  }
@@ -80,24 +80,34 @@ async function main() {
80
80
  const assignment = AGENT_MODELS[agent];
81
81
  const expected = assignment.model || assignment;
82
82
  const provider = assignment.provider || 'claude';
83
- // Advisory note appended to context
83
+ // Advisory context injection via canonical UserPromptSubmit schema.
84
84
  process.stdout.write(JSON.stringify({
85
- result: 'allow',
86
- prefix: `<!-- [Article XVI] Agent "${agent}" assigned model: ${expected} (provider: ${provider}) -->`,
85
+ hookSpecificOutput: {
86
+ hookEventName: 'UserPromptSubmit',
87
+ additionalContext: `[Article XVI] Agent "${agent}" assigned model: ${expected} (provider: ${provider})`,
88
+ },
87
89
  }));
88
90
  } else {
89
- process.stdout.write(JSON.stringify({ result: 'allow' }));
91
+ process.stdout.write('{}');
90
92
  }
91
93
  } catch (err) {
92
94
  process.stderr.write(`[chati] model-governance: ${err.message}\n`);
93
- process.stdout.write(JSON.stringify({ result: 'allow' }));
95
+ process.stdout.write('{}');
94
96
  }
95
97
  }
96
98
 
97
99
  export { AGENT_MODELS, UPGRADE_CONDITIONS, getCurrentAgent };
98
100
 
99
- // Only run main when executed directly (not imported by tests)
101
+ // Only run main when executed directly (not imported by tests).
102
+ // realpathSync resolves symlinks on both sides — important on macOS where
103
+ // /tmp is a symlink to /private/tmp. Without realpath the comparison fails
104
+ // silently and main() never runs.
100
105
  import { fileURLToPath } from 'url';
101
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
102
- main();
106
+ import { realpathSync } from 'fs';
107
+ if (process.argv[1]) {
108
+ try {
109
+ if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
110
+ main();
111
+ }
112
+ } catch { /* path resolution failed — not invoked as a script */ }
103
113
  }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -65,8 +65,8 @@ async function main() {
65
65
  const session = readSessionState(projectDir);
66
66
 
67
67
  if (!session) {
68
- // No active session — don't inject anything
69
- process.stdout.write(JSON.stringify({ result: 'allow' }));
68
+ // No active session — don't inject anything (empty output = allow)
69
+ process.stdout.write('{}');
70
70
  return;
71
71
  }
72
72
 
@@ -163,13 +163,19 @@ async function main() {
163
163
  '</chati-context>',
164
164
  ].filter(Boolean).join('\n');
165
165
 
166
+ // UserPromptSubmit context injection schema:
167
+ // hookSpecificOutput.additionalContext is the canonical field for
168
+ // injecting text into Claude's view of the current prompt.
166
169
  process.stdout.write(JSON.stringify({
167
- result: 'allow',
168
- prefix: contextBlock,
170
+ hookSpecificOutput: {
171
+ hookEventName: 'UserPromptSubmit',
172
+ additionalContext: contextBlock,
173
+ },
169
174
  }));
170
175
  } catch (err) {
171
176
  process.stderr.write(`[chati] prism-engine: ${err.message}\n`);
172
- process.stdout.write(JSON.stringify({ result: 'allow' }));
177
+ // Silent allow on error — empty object = no injection, no block.
178
+ process.stdout.write('{}');
173
179
  }
174
180
  }
175
181
 
@@ -201,8 +207,16 @@ function detectFrustration(prompt) {
201
207
 
202
208
  export { readSessionState, detectFrustration };
203
209
 
204
- // Only run main when executed directly (not imported by tests)
210
+ // Only run main when executed directly (not imported by tests).
211
+ // realpathSync resolves symlinks on both sides — important on macOS where
212
+ // /tmp is a symlink to /private/tmp. Without realpath the comparison fails
213
+ // silently and main() never runs.
205
214
  import { fileURLToPath } from 'url';
206
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
207
- main();
215
+ import { realpathSync } from 'fs';
216
+ if (process.argv[1]) {
217
+ try {
218
+ if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
219
+ main();
220
+ }
221
+ } catch { /* path resolution failed — not invoked as a script */ }
208
222
  }
@@ -93,24 +93,52 @@ async function main() {
93
93
  const result = isSensitivePath(filePath, cwd);
94
94
 
95
95
  if (result.sensitive) {
96
- process.stdout.write(JSON.stringify({
97
- decision: 'block',
98
- reason: `[Article IV] ${result.reason}. Use environment variables or secure vaults instead of reading sensitive files directly.`,
99
- }));
96
+ process.stdout.write(JSON.stringify(denyOutput(
97
+ `[Article IV] ${result.reason}. Use environment variables or secure vaults instead of reading sensitive files directly.`
98
+ )));
100
99
  return;
101
100
  }
102
101
 
103
- process.stdout.write(JSON.stringify({ decision: 'allow' }));
102
+ process.stdout.write(JSON.stringify(allowOutput()));
104
103
  } catch (err) {
105
104
  process.stderr.write(`[chati-hook-error] read-protection: ${err?.message || 'unknown'}\n`);
106
- process.stdout.write(JSON.stringify({ decision: 'block', reason: 'Hook error fail-closed for safety' }));
105
+ // Fail-open: a broken hook should not prevent reads. Real protection
106
+ // patterns are explicit above and run before this catch.
107
+ process.stdout.write(JSON.stringify(allowOutput()));
107
108
  }
108
109
  }
109
110
 
111
+ function allowOutput() {
112
+ return {
113
+ hookSpecificOutput: {
114
+ hookEventName: 'PreToolUse',
115
+ permissionDecision: 'allow',
116
+ },
117
+ };
118
+ }
119
+
120
+ function denyOutput(reason) {
121
+ return {
122
+ hookSpecificOutput: {
123
+ hookEventName: 'PreToolUse',
124
+ permissionDecision: 'deny',
125
+ permissionDecisionReason: reason,
126
+ },
127
+ };
128
+ }
129
+
110
130
  export { isSensitivePath, SENSITIVE_PATTERNS, ALLOWED_EXCEPTIONS };
111
131
 
112
- // Only run main when executed directly (not imported by tests)
132
+ // Only run main when executed directly (not imported by tests).
133
+ // realpathSync resolves symlinks on both sides — important on macOS where
134
+ // /tmp is a symlink to /private/tmp. Without realpath the comparison fails
135
+ // silently and main() never runs.
113
136
  import { fileURLToPath } from 'url';
114
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
115
- main();
137
+ import { realpathSync } from 'fs';
138
+ if (process.argv[1]) {
139
+ try {
140
+ if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
141
+ main();
142
+ }
143
+ } catch { /* path resolution failed — not invoked as a script */ }
116
144
  }
@@ -115,7 +115,9 @@ async function main() {
115
115
  process.stderr.write(`[chati] session-digest daily-append: ${err.message}\n`);
116
116
  }
117
117
 
118
- // Auto memory consolidation: trigger when memories accumulate
118
+ // Auto memory consolidation: trigger when memories accumulate.
119
+ // CRITICAL: must be fully async (detached spawn) — PreCompact runs on the
120
+ // hot path and execSync would add up to 10s of latency to every compact.
119
121
  try {
120
122
  const memBase = join(projectDir, '.chati', 'memories');
121
123
  if (existsSync(memBase)) {
@@ -132,16 +134,19 @@ async function main() {
132
134
  // Consolidate when > 100 entries (50% of 200 cap)
133
135
  if (totalEntries > 100) {
134
136
  process.stderr.write(`[chati] auto-dream: ${totalEntries} entries, triggering consolidation\n`);
135
- // Fire-and-forget: consolidation runs in background
136
- // Import would fail in hook context, so we spawn a child process
137
- const { execSync } = await import('child_process');
137
+ // Fire-and-forget detached spawn returns immediately, child outlives parent.
138
+ const { spawn } = await import('child_process');
138
139
  try {
139
- execSync(`node -e "import('./packages/chati-dev/src/memory/dream.js').then(m => m.runDreamConsolidation('${projectDir.replace(/'/g, "\\'")}'))"`, {
140
+ const child = spawn('node', [
141
+ '-e',
142
+ `import('./packages/chati-dev/src/memory/dream.js').then(m => m.runDreamConsolidation(${JSON.stringify(projectDir)}))`,
143
+ ], {
140
144
  cwd: projectDir,
141
- timeout: 10000,
145
+ detached: true,
142
146
  stdio: 'ignore',
143
147
  });
144
- } catch { /* expected: consolidation may timeout or fail */ }
148
+ child.unref();
149
+ } catch { /* expected: spawn may fail in restricted env */ }
145
150
  }
146
151
  }
147
152
  } catch (err) {
@@ -149,8 +154,9 @@ async function main() {
149
154
  }
150
155
  }
151
156
 
152
- // Session telemetry ping (moved from license-guard to reduce per-prompt overhead)
153
- // 5-minute throttle with phase/agent change detection
157
+ // Session telemetry ping (owned by session-digest, was moved here from license-guard).
158
+ // 5-minute throttle with phase/agent change detection.
159
+ // Pings.yaml format: '/path/to/project': '<ISO8601>|<phase>|<agent>'
154
160
  try {
155
161
  const homedir = process.env.HOME || '';
156
162
  const pingsPath = join(homedir, '.chati-dev', 'pings.yaml');
@@ -163,15 +169,17 @@ async function main() {
163
169
 
164
170
  if (licenseKey && digest) {
165
171
  let shouldPing = false;
166
- const now = Date.now();
172
+ const nowIso = new Date().toISOString();
167
173
  const currentState = `${digest.mode}|${digest.currentAgent}`;
168
174
 
169
175
  if (existsSync(pingsPath)) {
170
176
  const pingsRaw = readFileSync(pingsPath, 'utf-8');
171
- const entryMatch = pingsRaw.match(new RegExp(`${projectDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*?'([^']+)'`));
177
+ const escaped = projectDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
178
+ const entryMatch = pingsRaw.match(new RegExp(`^${escaped}:\\s*'([^']+)'`, 'm'));
172
179
  if (entryMatch) {
173
180
  const [ts, phase, agent] = entryMatch[1].split('|');
174
- const elapsed = now - parseInt(ts);
181
+ const lastTime = new Date(ts).getTime();
182
+ const elapsed = isNaN(lastTime) ? Infinity : Date.now() - lastTime;
175
183
  const stateChanged = `${phase}|${agent}` !== currentState;
176
184
  shouldPing = elapsed > 300000 || stateChanged; // 5 min or state change
177
185
  } else {
@@ -182,16 +190,24 @@ async function main() {
182
190
  }
183
191
 
184
192
  if (shouldPing) {
185
- // Update ping timestamp
193
+ // Update ping entry — preserve other projects in the file.
186
194
  mkdirSync(join(homedir, '.chati-dev'), { recursive: true });
187
- writeFileSync(pingsPath, `'${projectDir}': '${now}|${currentState}'\n`, 'utf-8');
195
+ const newEntry = `${projectDir}: '${nowIso}|${currentState}'\n`;
196
+ let content = existsSync(pingsPath) ? readFileSync(pingsPath, 'utf-8') : '';
197
+ const escaped = projectDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
198
+ if (new RegExp(`^${escaped}:`, 'm').test(content)) {
199
+ content = content.replace(new RegExp(`^${escaped}:.*$`, 'm'), newEntry.trim());
200
+ } else {
201
+ content += newEntry;
202
+ }
203
+ writeFileSync(pingsPath, content, 'utf-8');
188
204
 
189
205
  // Fire-and-forget telemetry (3s timeout, never blocks)
190
206
  try {
191
207
  const body = JSON.stringify({
192
208
  license_key: licenseKey,
193
209
  project_name: digest.currentAgent || 'unknown',
194
- events: [{ type: 'session_active', timestamp: new Date().toISOString(), properties: { pipeline_phase: digest.mode, current_agent: digest.currentAgent } }],
210
+ events: [{ type: 'session_active', timestamp: nowIso, properties: { pipeline_phase: digest.mode, current_agent: digest.currentAgent } }],
195
211
  });
196
212
  fetch('https://chati.dev/api/telemetry', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, signal: AbortSignal.timeout(3000) }).catch(() => {});
197
213
  } catch { /* expected: network may be unavailable */ }
@@ -202,15 +218,24 @@ async function main() {
202
218
  process.stderr.write(`[chati] session-digest telemetry-ping: ${err.message}\n`);
203
219
  }
204
220
 
205
- process.stdout.write(JSON.stringify({ result: 'allow' }));
221
+ // PreCompact is observational only — empty output is the canonical "no-op".
222
+ process.stdout.write('{}');
206
223
  } catch (err) {
207
224
  process.stderr.write(`[chati] session-digest: ${err.message}\n`);
208
- process.stdout.write(JSON.stringify({ result: 'allow' }));
225
+ process.stdout.write('{}');
209
226
  }
210
227
  }
211
228
 
212
- // Only run main when executed directly (not imported by tests)
229
+ // Only run main when executed directly (not imported by tests).
230
+ // realpathSync resolves symlinks on both sides — important on macOS where
231
+ // /tmp is a symlink to /private/tmp. Without realpath the comparison fails
232
+ // silently and main() never runs.
213
233
  import { fileURLToPath } from 'url';
214
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
215
- main();
234
+ import { realpathSync } from 'fs';
235
+ if (process.argv[1]) {
236
+ try {
237
+ if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
238
+ main();
239
+ }
240
+ } catch { /* path resolution failed — not invoked as a script */ }
216
241
  }
@@ -97,7 +97,7 @@ async function main() {
97
97
  // Skip exempt paths
98
98
  const filePath = toolInput.file_path || '';
99
99
  if (isExemptPath(filePath, projectDir)) {
100
- process.stdout.write(JSON.stringify({ decision: 'allow' }));
100
+ process.stdout.write(JSON.stringify(allowOutput()));
101
101
  return;
102
102
  }
103
103
 
@@ -112,24 +112,44 @@ async function main() {
112
112
  }
113
113
 
114
114
  if (result.violations.length > 0) {
115
- // Block all style violations agent must rewrite
116
- process.stdout.write(JSON.stringify({
117
- decision: 'block',
118
- reason: `[Style Guard] ${result.violations.join(' ')} Rewrite without em-dashes and emojis.`,
119
- }));
115
+ // Advisory: surface as reason but allow. Style is a soft preference,
116
+ // not a security boundary — hard-blocking blocks legitimate markdown.
117
+ process.stdout.write(JSON.stringify(allowOutput(
118
+ `[Style Guard] ${result.violations.join(' ')} Consider rewriting without em-dashes and emojis (Article V).`
119
+ )));
120
120
  return;
121
121
  }
122
122
 
123
- process.stdout.write(JSON.stringify({ decision: 'allow' }));
123
+ process.stdout.write(JSON.stringify(allowOutput()));
124
124
  } catch (err) {
125
125
  process.stderr.write(`[chati] style-guard: ${err.message}\n`);
126
- process.stdout.write(JSON.stringify({ decision: 'allow' }));
126
+ process.stdout.write(JSON.stringify(allowOutput()));
127
127
  }
128
128
  }
129
129
 
130
+ function allowOutput(reason) {
131
+ const out = {
132
+ hookSpecificOutput: {
133
+ hookEventName: 'PreToolUse',
134
+ permissionDecision: 'allow',
135
+ },
136
+ };
137
+ if (reason) out.hookSpecificOutput.permissionDecisionReason = reason;
138
+ return out;
139
+ }
140
+
130
141
  export { checkStyle, checkBashStyle, isExemptPath, EM_DASH_PATTERN, EMOJI_PATTERN };
131
142
 
143
+ // Only run main when executed directly (not imported by tests).
144
+ // realpathSync resolves symlinks on both sides — important on macOS where
145
+ // /tmp is a symlink to /private/tmp. Without realpath the comparison fails
146
+ // silently and main() never runs.
132
147
  import { fileURLToPath } from 'url';
133
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
134
- main();
148
+ import { realpathSync } from 'fs';
149
+ if (process.argv[1]) {
150
+ try {
151
+ if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
152
+ main();
153
+ }
154
+ } catch { /* path resolution failed — not invoked as a script */ }
135
155
  }
@@ -79,7 +79,7 @@ async function main() {
79
79
  // Quick exit: no team active → allow everything
80
80
  const teamId = process.env.CHATI_TEAM_ID;
81
81
  if (!teamId) {
82
- process.stdout.write(JSON.stringify({ result: 'allow' }));
82
+ process.stdout.write(JSON.stringify(allowOutput()));
83
83
  return;
84
84
  }
85
85
 
@@ -87,14 +87,14 @@ async function main() {
87
87
  const currentAgent = process.env.CHATI_TEAM_MEMBER || detectAgentFromSession(projectDir);
88
88
  if (!currentAgent) {
89
89
  // Cannot determine agent → allow (fail-open for safety)
90
- process.stdout.write(JSON.stringify({ result: 'allow' }));
90
+ process.stdout.write(JSON.stringify(allowOutput()));
91
91
  return;
92
92
  }
93
93
 
94
94
  // Get the file path being written
95
95
  const filePath = event.tool_input?.file_path || '';
96
96
  if (!filePath) {
97
- process.stdout.write(JSON.stringify({ result: 'allow' }));
97
+ process.stdout.write(JSON.stringify(allowOutput()));
98
98
  return;
99
99
  }
100
100
 
@@ -103,7 +103,7 @@ async function main() {
103
103
 
104
104
  // Check shared paths first — always allowed
105
105
  if (SHARED_PATHS.some(sp => relativePath.startsWith(sp))) {
106
- process.stdout.write(JSON.stringify({ result: 'allow' }));
106
+ process.stdout.write(JSON.stringify(allowOutput()));
107
107
  return;
108
108
  }
109
109
 
@@ -112,24 +112,42 @@ async function main() {
112
112
  if (agent === currentAgent) continue;
113
113
  for (const scope of scopes) {
114
114
  if (relativePath.startsWith(scope) || (!scope.includes('/') && relativePath.endsWith(scope))) {
115
- process.stdout.write(JSON.stringify({
116
- result: 'block',
117
- reason: `[Team Quality Gate] ${currentAgent} cannot write to ${relativePath} — this path belongs to ${agent}. Use the team mailbox to coordinate changes (Article XXI).`,
118
- }));
115
+ process.stdout.write(JSON.stringify(denyOutput(
116
+ `[Team Quality Gate] ${currentAgent} cannot write to ${relativePath} — this path belongs to ${agent}. Use the team mailbox to coordinate changes (Article XXI).`
117
+ )));
119
118
  return;
120
119
  }
121
120
  }
122
121
  }
123
122
 
124
123
  // Path not owned by anyone specific → allow
125
- process.stdout.write(JSON.stringify({ result: 'allow' }));
124
+ process.stdout.write(JSON.stringify(allowOutput()));
126
125
  } catch (err) {
127
126
  // Fail-open: hook errors should not block execution
128
127
  process.stderr.write(`[chati] team-quality-gate: ${err.message}\n`);
129
- process.stdout.write(JSON.stringify({ result: 'allow' }));
128
+ process.stdout.write(JSON.stringify(allowOutput()));
130
129
  }
131
130
  }
132
131
 
132
+ function allowOutput() {
133
+ return {
134
+ hookSpecificOutput: {
135
+ hookEventName: 'PreToolUse',
136
+ permissionDecision: 'allow',
137
+ },
138
+ };
139
+ }
140
+
141
+ function denyOutput(reason) {
142
+ return {
143
+ hookSpecificOutput: {
144
+ hookEventName: 'PreToolUse',
145
+ permissionDecision: 'deny',
146
+ permissionDecisionReason: reason,
147
+ },
148
+ };
149
+ }
150
+
133
151
  function detectAgentFromSession(projectDir) {
134
152
  const sessionPath = join(projectDir, '.chati', 'session.yaml');
135
153
  if (!existsSync(sessionPath)) return null;
@@ -138,8 +156,16 @@ function detectAgentFromSession(projectDir) {
138
156
  return match ? match[1].trim().replace(/^["']|["']$/g, '') : null;
139
157
  }
140
158
 
141
- // Only run main when executed directly
159
+ // Only run main when executed directly (not imported by tests).
160
+ // realpathSync resolves symlinks on both sides — important on macOS where
161
+ // /tmp is a symlink to /private/tmp. Without realpath the comparison fails
162
+ // silently and main() never runs.
142
163
  import { fileURLToPath } from 'url';
143
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
144
- main();
164
+ import { realpathSync } from 'fs';
165
+ if (process.argv[1]) {
166
+ try {
167
+ if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
168
+ main();
169
+ }
170
+ } catch { /* path resolution failed — not invoked as a script */ }
145
171
  }
@@ -173,14 +173,14 @@ async function main() {
173
173
 
174
174
  // Check if undercover mode is enabled
175
175
  if (!isUndercoverEnabled(projectDir)) {
176
- process.stdout.write(JSON.stringify({ decision: 'allow' }));
176
+ process.stdout.write(JSON.stringify(allowOutput()));
177
177
  return;
178
178
  }
179
179
 
180
180
  // Skip framework-internal files
181
181
  const filePath = toolInput.file_path || '';
182
182
  if (isExemptPath(filePath, projectDir)) {
183
- process.stdout.write(JSON.stringify({ decision: 'allow' }));
183
+ process.stdout.write(JSON.stringify(allowOutput()));
184
184
  return;
185
185
  }
186
186
 
@@ -196,25 +196,44 @@ async function main() {
196
196
 
197
197
  if (scan.found) {
198
198
  const terms = scan.matches.map(m => `"${m.term}" → "${m.replacement}"`).join(', ');
199
- process.stdout.write(JSON.stringify({
200
- decision: 'allow', // Advisory — don't block, just warn
201
- reason: `[Undercover] Internal references detected in output: ${terms}. Please sanitize before shipping to production. Replace internal terms with their generic equivalents.`,
202
- }));
199
+ // Advisory only — surface findings as reason, allow operation.
200
+ process.stdout.write(JSON.stringify(allowOutput(
201
+ `[Undercover] Internal references detected: ${terms}. Sanitize before shipping. Replace internal terms with their generic equivalents.`
202
+ )));
203
203
  return;
204
204
  }
205
205
 
206
- process.stdout.write(JSON.stringify({ decision: 'allow' }));
206
+ process.stdout.write(JSON.stringify(allowOutput()));
207
207
  } catch (err) {
208
208
  process.stderr.write(`[chati-hook-error] undercover-guard: ${err?.message || 'unknown'}\n`);
209
209
  // Fail-open: undercover is advisory, not security-critical
210
- process.stdout.write(JSON.stringify({ decision: 'allow' }));
210
+ process.stdout.write(JSON.stringify(allowOutput()));
211
211
  }
212
212
  }
213
213
 
214
+ function allowOutput(reason) {
215
+ const out = {
216
+ hookSpecificOutput: {
217
+ hookEventName: 'PreToolUse',
218
+ permissionDecision: 'allow',
219
+ },
220
+ };
221
+ if (reason) out.hookSpecificOutput.permissionDecisionReason = reason;
222
+ return out;
223
+ }
224
+
214
225
  export { scanForInternals, scanBashForInternals, isExemptPath, isUndercoverEnabled, UNDERCOVER_RULES };
215
226
 
216
- // Only run main when executed directly (not imported by tests)
227
+ // Only run main when executed directly (not imported by tests).
228
+ // realpathSync resolves symlinks on both sides — important on macOS where
229
+ // /tmp is a symlink to /private/tmp. Without realpath the comparison fails
230
+ // silently and main() never runs.
217
231
  import { fileURLToPath } from 'url';
218
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
219
- main();
232
+ import { realpathSync } from 'fs';
233
+ if (process.argv[1]) {
234
+ try {
235
+ if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
236
+ main();
237
+ }
238
+ } catch { /* path resolution failed — not invoked as a script */ }
220
239
  }