chati-dev 4.2.1 → 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.
@@ -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
  }
@@ -83,13 +83,20 @@ Parse the JSON output. The `action` field tells you what to do:
83
83
  | `complete` | Action: Complete |
84
84
  | `error` | Display error, suggest `/chati status` |
85
85
 
86
- **After every action**, display context bracket from JSON `context_bracket`:
87
- ```
88
- FRESH → "Context: FRESH ({remaining}%)Proceeding to {agent}"
89
- MODERATE → "Context: MODERATE ({remaining}%) — Proceeding (context layers reduced)"
90
- DEPLETED "Context: DEPLETED ({remaining}%) Warning: context running low"
91
- CRITICAL "Context: CRITICAL ({remaining}%) Initiating handoff protocol"
92
- ```
86
+ **Internal: track context bracket from JSON `context_bracket` but DO NOT display it to the user.** The bracket is internal telemetry — users should never see "Context: FRESH (90%)" or framework jargon like "Initiating handoff protocol". Speak in natural language about what you are doing, not the internal state.
87
+
88
+ Only mention the bracket when it reaches CRITICAL, and only in plain language. Example: "I'm running low on memory let me wrap up the current step before continuing." Never say "CRITICAL bracket", "PRISM", "L0-L5", "deviation protocol", or other internal framework terms.
89
+
90
+ **Never reveal agent technical names** to the user. Refer to phases naturally:
91
+ - `greenfield-wu` / `brownfield-wu` "let's understand your goals"
92
+ - `brief` → "let's confirm what we're building"
93
+ - `detail` → "let's expand the requirements"
94
+ - `architect` → "let's design the architecture"
95
+ - `ux` → "let's design the user experience"
96
+ - `phases` / `tasks` → "let's break this down into phases"
97
+ - `qa-planning` / `qa-implementation` → "let's review what we have"
98
+ - `dev` → "let's build it"
99
+ - `devops` → "let's deploy it"
93
100
 
94
101
  ---
95
102
 
@@ -135,11 +142,7 @@ The user should experience a smooth start: they describe their project, the orch
135
142
 
136
143
  These agents (greenfield-wu, brownfield-wu, brief) run in the same conversation.
137
144
 
138
- 1. Display model recommendation from JSON `model_info`:
139
- ```
140
- Model recommendation for {agent}: {model} ({upgrade condition})
141
- To switch: /model {model}
142
- ```
145
+ 1. Track model recommendation from JSON `model_info` internally. Do NOT display "Model recommendation for {agent}" or expose internal agent names. If the recommended model is materially different from the current one and the difference will affect quality, mention it briefly in plain language: e.g., "This step works best on Opus — switch with /model opus if you want stronger reasoning." Otherwise stay silent.
143
146
  2. Read the agent file from `agent_file` in the JSON response
144
147
  3. Load its full content and **become** that agent
145
148
  4. Follow the agent's instructions — the user interacts with you directly
@@ -206,17 +209,17 @@ Quality gate passed: {score}%.
206
209
 
207
210
  These agents run in separate Claude Code processes.
208
211
 
209
- 1. Display context bracket status from JSON
212
+ 1. Track context bracket internally do NOT display it.
210
213
  2. Check `handoff_status.valid` from JSON:
211
- - If `false` with missing fields:
214
+ - If `false` with missing fields, present user-facing options in plain language:
212
215
  ```
213
- Context check: FAILED missing: {missing_fields}
214
- 1. Re-run previous agent to regenerate handoff (Recommended)
215
- 2. Continue anyway (risk: missing context)
216
- 3. Manual context injection (provide missing info)
216
+ I noticed some context from the previous step is missing. How should I proceed?
217
+ 1. Redo the previous step to regenerate the missing information (Recommended)
218
+ 2. Continue anyway, with the missing context
219
+ 3. Provide the missing details manually
217
220
  ```
218
- - If warnings only: display warnings, proceed
219
- - If `true`: display "Context check: OK — handoff verified"
221
+ - If warnings only: proceed silently. Only mention the warning if it directly affects what the user just asked for.
222
+ - If `true`: proceed silently. Do NOT announce "Context check: OK".
220
223
  3. Execute `spawn_command` via Bash tool (the full command is in the JSON)
221
224
  4. Wait for JSON output from the spawned process. Handle `status`:
222
225
  - `"complete"` → Present **Completion Options** (see above) → on approval, run `orchestrate advance --agent {name} --score {score}`
@@ -240,7 +243,7 @@ Planning phase agents (detail, architect, ux) run simultaneously.
240
243
 
241
244
  **NOTE (Article XXI):** When `features.agent_teams: true` in config.yaml, the orchestrator MUST attempt `spawn_team` FIRST for GROUP 1 agents (detail, architect, ux) and BUILD pair (dev, qa-implementation). Only fall back to `spawn_parallel` if team spawning is unavailable or fails. The CLI `orchestrate next` command handles this automatically — when teams are enabled, it returns `spawn_team` instead of `spawn_parallel`.
242
245
 
243
- 1. Display: "Spawning parallel group: {agents}"
246
+ 1. Display naturally what is happening using user-facing language. Examples (pick one that fits the phase): "Working on the planning phase now." / "Reviewing the architecture and UX in parallel." NEVER say "spawning parallel group", "spawn_parallel", or list internal agent names like "detail, architect, ux".
244
247
  2. Execute `parallel_spawn_command` via Bash tool
245
248
  3. Parse consolidated JSON output
246
249
  4. Present **Completion Options** (Parallel Group variant) — wait for user choice
@@ -271,7 +274,7 @@ node packages/chati-dev/bin/chati.js orchestrate spawn-team --team-type {plannin
271
274
  ```
272
275
  Parse JSON. If `fallback_required: true` → fall back silently to `spawn_parallel`. Do NOT display error messages.
273
276
 
274
- Display: "Forming {team_type} team: {members}"
277
+ Display naturally what the team is doing using user-facing language. For Planning Team: "Working on the planning — designing the requirements, architecture, and user experience together." For Build Team: "Building this with continuous quality review." NEVER say "team", "spawn_team", "Article XXI", "mailbox", "Planning Team", or list internal agent names.
275
278
 
276
279
  ### Step 2: Spawn Teammates via Agent Tool
277
280
 
@@ -413,13 +416,13 @@ If the Agent tool is unavailable or any sub-agent spawn fails:
413
416
 
414
417
  The user is returning to an active session.
415
418
 
416
- 1. Present `status_summary` from JSON in the user's language
417
- 2. Display context bracket
418
- 3. Offer options:
419
+ 1. Present a natural-language summary of where we are in the user's language. Translate the internal `status_summary` into user-friendly terms — never say "qa-planning completed" or expose internal agent names.
420
+ 2. Track context bracket internally — do NOT display.
421
+ 3. Offer options in plain language (translate internal agent names to user-facing phase descriptions):
419
422
  ```
420
- 1. Continue with {next_agent} (Recommended)
421
- 2. Review last output
422
- 3. View full status (/chati status)
423
+ 1. Continue (Recommended)
424
+ 2. Review what we just produced
425
+ 3. Show me the full project status
423
426
  ```
424
427
 
425
428
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chati-dev",
3
- "version": "4.2.1",
3
+ "version": "4.2.2",
4
4
  "description": "AI-Powered Multi-Agent Orchestration System — Structured vibe coding for Full Stack Development",
5
5
  "type": "module",
6
6
  "bin": {