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.
- package/framework/config.yaml +3 -3
- package/framework/constitution.md +2 -1
- package/framework/context/root.md +2 -2
- package/framework/hooks/constitution-guard.js +69 -33
- package/framework/hooks/license-guard.js +92 -188
- package/framework/hooks/mode-governance.js +55 -14
- package/framework/hooks/model-governance.js +18 -8
- package/framework/hooks/package.json +3 -0
- package/framework/hooks/prism-engine.js +22 -8
- package/framework/hooks/read-protection.js +37 -9
- package/framework/hooks/session-digest.js +45 -20
- package/framework/hooks/style-guard.js +30 -10
- package/framework/hooks/team-quality-gate.js +39 -13
- package/framework/hooks/undercover-guard.js +30 -11
- package/framework/orchestrator/chati.md +31 -28
- package/package.json +1 -1
- package/scripts/validate-package.js +146 -8
- package/src/config/claude-settings-generator.js +206 -0
- package/src/config/gemini-hooks-generator.js +58 -0
- package/src/installer/core.js +136 -1
- package/src/installer/templates.js +6 -2
- package/src/orchestrator/cli.js +41 -11
|
@@ -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
|
|
83
|
+
// Advisory context injection via canonical UserPromptSubmit schema.
|
|
84
84
|
process.stdout.write(JSON.stringify({
|
|
85
|
-
|
|
86
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
102
|
-
|
|
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
|
}
|
|
@@ -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(
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
+
detached: true,
|
|
142
146
|
stdio: 'ignore',
|
|
143
147
|
});
|
|
144
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
193
|
+
// Update ping entry — preserve other projects in the file.
|
|
186
194
|
mkdirSync(join(homedir, '.chati-dev'), { recursive: true });
|
|
187
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
215
|
-
|
|
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(
|
|
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
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
134
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
117
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
144
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
**
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
214
|
-
1.
|
|
215
|
-
2. Continue anyway
|
|
216
|
-
3.
|
|
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:
|
|
219
|
-
- If `true`:
|
|
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: "
|
|
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: "
|
|
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
|
|
417
|
-
2.
|
|
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
|
|
421
|
-
2. Review
|
|
422
|
-
3.
|
|
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
|
---
|