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.
@@ -1,8 +1,8 @@
1
1
  # chati.dev Configuration
2
- version: "4.2.1"
2
+ version: "4.2.2"
3
3
  installed_at: "2026-02-07T10:00:00Z"
4
4
  updated_at: "2026-04-11T00:00:00Z"
5
- installer_version: "4.2.1"
5
+ installer_version: "4.2.2"
6
6
  project_type: greenfield
7
7
  language: en
8
8
  ides: [claude-code]
@@ -54,7 +54,7 @@ features:
54
54
  frustration_detection: true # Detect user frustration and adapt response style
55
55
  bash_security_checks: true # 23-point shell injection defense system
56
56
  # Agent Teams (v4.2.0 — Article XXI)
57
- agent_teams: false # Enable Claude Code native Agent Teams (requires claude provider)
57
+ agent_teams: true # Enable Claude Code native Agent Teams. Gated to claude provider; Gemini/Codex fall back silently.
58
58
  team_planning_size: 3 # Planning Team max teammates: detail + architect + ux
59
59
  team_build_size: 2 # Build Team max teammates: dev + qa-implementation
60
60
  team_echo_threshold: 0.92 # Content similarity ratio to trigger Echo Detection (Article XXII)
@@ -755,7 +755,8 @@ If any check is NO, the compliance block itself is an ERROR and the report canno
755
755
 
756
756
  ---
757
757
 
758
- *Chati.dev Constitution v4.2.1 — 22 Articles + Preamble*
758
+ *Chati.dev Constitution v4.2.2 — 22 Articles + Preamble*
759
759
  *v4.2.0 Amendments: Article V amended (Team Communication); Article VIII amended (Team Handoff Envelope); Article XII amended (PRISM L6 Team Roster); Article XXI added (Agent Teams Governance); Article XXII added (Fault Vector Protocol)*
760
760
  *v4.2.1 Fixes: installer refactor (recursive copy), features block in config, context files sync*
761
+ *v4.2.2 Fixes: 10 hooks rewritten to canonical hookSpecificOutput schema; .claude/settings.json now actually written by installer (CRITICAL — hooks were dormant before); license enforcement extended to Gemini and Codex per-turn; Agent Teams default ON; orchestrator jargon removed from user-facing output*
761
762
  *All agents are bound by this Constitution. Violations are enforced per article.*
@@ -1,11 +1,11 @@
1
1
  # Chati.dev System Context
2
2
 
3
3
  ## Framework
4
- - **Version**: 4.2.1
4
+ - **Version**: 4.2.2
5
5
  - **Agents**: Specialized agents across DISCOVER, PLAN, BUILD, DEPLOY phases
6
6
  - **Constitution**: 22 Articles + Preamble
7
7
  - **Quality**: 5 pipeline gates + 3-tier verdicts + Fault Vector Protocol (Article XXII)
8
- - **Agent Teams**: Native Claude Code teams with peer communication (Article XXI, feature-flagged)
8
+ - **Agent Teams**: Native Claude Code teams with peer communication (Article XXI, default ON in v4.2.2 for Claude provider)
9
9
 
10
10
  ## Key References
11
11
  - **Session State**: `.chati/session.yaml` (runtime — not committed)
@@ -81,10 +81,9 @@ async function main() {
81
81
  const secrets = containsSecrets(content);
82
82
 
83
83
  if (secrets.length > 0) {
84
- process.stdout.write(JSON.stringify({
85
- decision: 'block',
86
- reason: `[Article IV] Potential secret detected in file content. Pattern: ${secrets[0]}. Use environment variables instead.`,
87
- }));
84
+ process.stdout.write(JSON.stringify(denyOutput(
85
+ `[Article IV] Potential secret detected in file content. Pattern: ${secrets[0]}. Use environment variables instead.`
86
+ )));
88
87
  return;
89
88
  }
90
89
  }
@@ -93,10 +92,9 @@ async function main() {
93
92
  if (toolName === 'Bash') {
94
93
  const command = toolInput.command || '';
95
94
  if (isDestructiveCommand(command)) {
96
- process.stdout.write(JSON.stringify({
97
- decision: 'block',
98
- reason: `[Article IV] Destructive command detected: "${command.slice(0, 60)}...". This requires explicit user confirmation.`,
99
- }));
95
+ process.stdout.write(JSON.stringify(denyOutput(
96
+ `[Article IV] Destructive command detected: "${command.slice(0, 60)}...". This requires explicit user confirmation.`
97
+ )));
100
98
  return;
101
99
  }
102
100
 
@@ -105,28 +103,49 @@ async function main() {
105
103
  if (injectionFindings.length > 0) {
106
104
  const critical = injectionFindings.filter(f => f.severity === 'critical');
107
105
  if (critical.length > 0) {
108
- process.stdout.write(JSON.stringify({
109
- decision: 'block',
110
- reason: `[Article IV] Shell injection risk detected (${critical.length} critical): ${critical.map(f => f.id).join(', ')}. Command: "${command.slice(0, 60)}..."`,
111
- }));
106
+ process.stdout.write(JSON.stringify(denyOutput(
107
+ `[Article IV] Shell injection risk detected (${critical.length} critical): ${critical.map(f => f.id).join(', ')}. Command: "${command.slice(0, 60)}..."`
108
+ )));
112
109
  return;
113
110
  }
114
- // High/medium findings: allow with warning (advisory)
115
- process.stdout.write(JSON.stringify({
116
- decision: 'allow',
117
- reason: `[Security Advisory] ${injectionFindings.length} shell security finding(s): ${injectionFindings.map(f => f.id).join(', ')}`,
118
- }));
111
+ // High/medium findings: allow (advisory only, surfaced via reason)
112
+ process.stdout.write(JSON.stringify(allowOutput(
113
+ `[Security Advisory] ${injectionFindings.length} shell security finding(s): ${injectionFindings.map(f => f.id).join(', ')}`
114
+ )));
119
115
  return;
120
116
  }
121
117
  }
122
118
 
123
- process.stdout.write(JSON.stringify({ decision: 'allow' }));
119
+ process.stdout.write(JSON.stringify(allowOutput()));
124
120
  } catch (err) {
125
121
  process.stderr.write(`[chati-hook-error] constitution-guard: ${err?.message || 'unknown'}\n`);
126
- process.stdout.write(JSON.stringify({ decision: 'block', reason: 'Hook error fail-closed for safety' }));
122
+ // Fail-open on hook errors: a broken hook should not block legitimate work.
123
+ // Real security checks above are explicit and run before this catch.
124
+ process.stdout.write(JSON.stringify(allowOutput()));
127
125
  }
128
126
  }
129
127
 
128
+ function allowOutput(reason) {
129
+ const out = {
130
+ hookSpecificOutput: {
131
+ hookEventName: 'PreToolUse',
132
+ permissionDecision: 'allow',
133
+ },
134
+ };
135
+ if (reason) out.hookSpecificOutput.permissionDecisionReason = reason;
136
+ return out;
137
+ }
138
+
139
+ function denyOutput(reason) {
140
+ return {
141
+ hookSpecificOutput: {
142
+ hookEventName: 'PreToolUse',
143
+ permissionDecision: 'deny',
144
+ permissionDecisionReason: reason,
145
+ },
146
+ };
147
+ }
148
+
130
149
  /**
131
150
  * 23-point shell injection security checks.
132
151
  * Inline implementation (hooks are standalone — no imports from src/).
@@ -135,15 +154,24 @@ async function main() {
135
154
  // SYNC: These 23 check IDs MUST match packages/chati-dev/src/security/bash-security.js
136
155
  // Hooks are standalone (no imports from src/), so duplication is necessary.
137
156
  // If you update here, update bash-security.js too. Run: npm test to verify both.
157
+ // Severity tiers:
158
+ // - critical = block immediately (clearly malicious, no legitimate use)
159
+ // - high/medium = advisory only (surfaced as reason, allowed). Used to be
160
+ // blocking, but several patterns produced too many false positives
161
+ // (e.g. SHELL_METACHARACTERS firing on every $(date), GIT_COMMIT_SUBSTITUTION
162
+ // firing on git commit -m "$(date)"). Critical patterns below are narrowly
163
+ // scoped to actually-dangerous shell injection vectors.
138
164
  const SHELL_INJECTION_CHECKS = [
139
- { id: 'INCOMPLETE_COMMANDS', pattern: /[|&;]\s*$/, severity: 'high' },
165
+ { id: 'INCOMPLETE_COMMANDS', pattern: /[|&;]\s*$/, severity: 'medium' },
140
166
  { id: 'JQ_SYSTEM_FUNCTION', pattern: /jq\b.*\bsystem\s*\(/i, severity: 'critical' },
141
- { id: 'JQ_FILE_ARGUMENTS', pattern: /jq\b.*--from-file|jq\b.*-f\s+[^|&;]+/i, severity: 'high' },
142
- { id: 'OBFUSCATED_FLAGS', pattern: /\$[({].*[)}].*-/, severity: 'high' },
143
- { id: 'SHELL_METACHARACTERS', pattern: /[`]|\$\(/, severity: 'critical' },
144
- { id: 'DANGEROUS_VARIABLES', pattern: /(?:^|\s)(?:PATH|LD_PRELOAD|LD_LIBRARY_PATH|DYLD_INSERT_LIBRARIES|PYTHONPATH|NODE_PATH|RUBYLIB|PERL5LIB)\s*=/, severity: 'critical' },
145
- { id: 'NEWLINES', pattern: /(?<!\\)\n.*(?:rm|curl|wget|chmod|chown|sudo|eval|exec)/, severity: 'high' },
146
- { id: 'BACKSLASH_ESCAPED_WHITESPACE', pattern: /\\\s+(?:-|\/)/,severity: 'medium' },
167
+ { id: 'JQ_FILE_ARGUMENTS', pattern: /jq\b.*--from-file|jq\b.*-f\s+[^|&;]+/i, severity: 'medium' },
168
+ { id: 'OBFUSCATED_FLAGS', pattern: /\$[({].*[)}].*-/, severity: 'medium' },
169
+ // SHELL_METACHARACTERS removed: $() and backticks are normal substitution.
170
+ // Genuinely dangerous substitutions are caught by DANGEROUS_PATTERNS_COMMAND_SUBSTITUTION below.
171
+ { id: 'DANGEROUS_VARIABLES', pattern: /(?:^|\s)(?:LD_PRELOAD|LD_LIBRARY_PATH|DYLD_INSERT_LIBRARIES|PYTHONPATH|NODE_PATH|RUBYLIB|PERL5LIB)\s*=/, severity: 'critical' },
172
+ // PATH= removed from critical (legitimate `PATH=/foo:$PATH cmd` exists).
173
+ { id: 'NEWLINES', pattern: /(?<!\\)\n.*(?:rm|curl|wget|chmod|chown|sudo|eval|exec)/, severity: 'medium' },
174
+ { id: 'BACKSLASH_ESCAPED_WHITESPACE', pattern: /\\\s+(?:-|\/)/, severity: 'medium' },
147
175
  // eslint-disable-next-line no-control-regex
148
176
  { id: 'CONTROL_CHARACTERS', pattern: /[\x00-\x08\x0e-\x1f\x7f]/, severity: 'critical' },
149
177
  { id: 'UNICODE_WHITESPACE', pattern: /[\u200B-\u200F\u2028-\u202F\uFEFF\u00A0\u2060\u180E]/, severity: 'critical' },
@@ -151,13 +179,13 @@ const SHELL_INJECTION_CHECKS = [
151
179
  { id: 'DANGEROUS_PATTERNS_INPUT_REDIRECTION', pattern: /<\s*(?:\/etc\/(?:passwd|shadow|sudoers)|\/proc\/|~\/\.ssh\/|~\/\.aws\/)/, severity: 'critical' },
152
180
  { id: 'DANGEROUS_PATTERNS_OUTPUT_REDIRECTION', pattern: />\s*(?:\/etc\/|~\/\.ssh\/|~\/\.bashrc|~\/\.zshrc|~\/\.profile|~\/\.gitconfig)/, severity: 'critical' },
153
181
  { id: 'IFS_INJECTION', pattern: /\bIFS\s*=/, severity: 'critical' },
154
- { id: 'BRACE_EXPANSION', pattern: /\{.*(?:rm|curl|wget|chmod|eval|exec|sudo).*[,}]/, severity: 'high' },
155
- { id: 'GIT_COMMIT_SUBSTITUTION', pattern: /git\s+(?:commit|push|tag).*\$[({]/, severity: 'high' },
182
+ { id: 'BRACE_EXPANSION', pattern: /\{.*(?:rm|curl|wget|chmod|eval|exec|sudo).*[,}]/, severity: 'medium' },
183
+ // GIT_COMMIT_SUBSTITUTION removed: `git commit -m "$(date)"` is legitimate.
156
184
  { id: 'PROC_ENVIRON_ACCESS', pattern: /\/proc\/(?:self|\d+)\/(?:environ|cmdline|maps|mem)/, severity: 'critical' },
157
- { id: 'MALFORMED_TOKEN_INJECTION', pattern: /\\x[0-9a-f]{2}|\\u[0-9a-f]{4}|\\[0-7]{3}/i, severity: 'high' },
185
+ { id: 'MALFORMED_TOKEN_INJECTION', pattern: /\\x[0-9a-f]{2}|\\u[0-9a-f]{4}|\\[0-7]{3}/i, severity: 'medium' },
158
186
  { id: 'MID_WORD_HASH', pattern: /\w#\w/, severity: 'medium' },
159
187
  { id: 'COMMENT_QUOTE_DESYNC', pattern: /#.*['"][^'"]*$/, severity: 'medium' },
160
- { id: 'QUOTED_NEWLINE', pattern: /["'][^"']*\n[^"']*["']/, severity: 'high' },
188
+ { id: 'QUOTED_NEWLINE', pattern: /["'][^"']*\n[^"']*["']/, severity: 'medium' },
161
189
  { id: 'ZSH_DANGEROUS_COMMANDS', pattern: /\b(?:zmodload|sysopen|sysread|syswrite|zsystem|zselect|ztcp)\b/, severity: 'critical' },
162
190
  { id: 'BACKSLASH_ESCAPED_OPERATORS', pattern: /\\[|;&]/, severity: 'medium' },
163
191
  ];
@@ -176,8 +204,16 @@ function runShellInjectionChecks(command) {
176
204
 
177
205
  export { containsSecrets, isDestructiveCommand, runShellInjectionChecks, SECRET_PATTERNS, DESTRUCTIVE_COMMANDS, SHELL_INJECTION_CHECKS };
178
206
 
179
- // Only run main when executed directly (not imported by tests)
207
+ // Only run main when executed directly (not imported by tests).
208
+ // realpathSync resolves symlinks on both sides — important on macOS where
209
+ // /tmp is a symlink to /private/tmp. Without realpath the comparison fails
210
+ // silently and main() never runs.
180
211
  import { fileURLToPath } from 'url';
181
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
182
- main();
212
+ import { realpathSync } from 'fs';
213
+ if (process.argv[1]) {
214
+ try {
215
+ if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
216
+ main();
217
+ }
218
+ } catch { /* path resolution failed — not invoked as a script */ }
183
219
  }
@@ -3,217 +3,110 @@
3
3
  * License Guard Hook — UserPromptSubmit
4
4
  *
5
5
  * Validates license status before each user prompt turn.
6
- * Uses a 24h cache stored in ~/.chati-dev/license.yaml (global, all projects).
6
+ * Uses a 4-hour cache stored in ~/.chati-dev/license.yaml (global, all projects).
7
7
  *
8
8
  * Behavior:
9
- * - No key configured allow (orchestrator handles inline activation)
10
- * - Cache VALID < 24h → allow silently + session ping (once/day/project)
11
- * - Cache EXPIRED/INVALID < 24h → block with renewal message
12
- * - Cache stale (> 24h) → call API, refresh cache, then allow or block
13
- * - API unreachable fail open (allow), never block work
9
+ * - No license file block with activation instructions
10
+ * - Cache VALID < 4h → allow silently
11
+ * - Cache EXPIRED/INVALID → block with renewal message
12
+ * - Cache stale (> 4h) → call API, refresh cache, then allow or block
13
+ * - API unreachable + cache VALID < 24h → grace allow
14
+ * - API unreachable + cache stale → block (cannot verify)
15
+ *
16
+ * Telemetry pings are owned by session-digest.js (PreCompact event), not here.
14
17
  */
15
18
 
16
19
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
17
- import { join, basename } from 'path';
20
+ import { join } from 'path';
18
21
  import { homedir } from 'os';
19
22
 
20
23
  const API_BASE = 'https://chati.dev/api';
21
- const TELEMETRY_ENDPOINT = 'https://chati.dev/api/telemetry';
22
- const CACHE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours — shorter window to catch expirations faster
23
- const PING_THROTTLE_MS = 5 * 60 * 1000; // 5 minutes
24
+ const CACHE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
24
25
 
25
26
  const GLOBAL_DIR = join(homedir(), '.chati-dev');
26
27
  const LICENSE_PATH = join(GLOBAL_DIR, 'license.yaml');
27
- const PINGS_PATH = join(GLOBAL_DIR, 'pings.yaml');
28
-
29
- async function main() {
30
- let input = '';
31
- for await (const chunk of process.stdin) input += chunk;
32
-
33
- try {
34
- // Read global license file — REQUIRED for operation
35
- if (!existsSync(LICENSE_PATH)) {
36
- block('No license found. Run: npx chati-dev activate --key=YOUR-KEY\nGet a license at https://chati.dev/pricing');
37
- return;
38
- }
39
28
 
40
- const licenseRaw = readFileSync(LICENSE_PATH, 'utf-8');
41
- const licenseKey = readYamlField(licenseRaw, 'key');
42
-
43
- if (!licenseKey || licenseKey === 'null') {
44
- block('No license key configured. Run: npx chati-dev activate --key=YOUR-KEY\nGet a license at https://chati.dev/pricing');
45
- return;
46
- }
47
-
48
- // Check cache validity
49
- const status = readYamlField(licenseRaw, 'status');
50
- const checkedAt = readYamlField(licenseRaw, 'checked_at');
51
- const age = checkedAt ? Date.now() - new Date(checkedAt).getTime() : Infinity;
52
-
53
- if (age < CACHE_TTL_MS) {
54
- if (status === 'VALID') {
55
- await allowWithPing(licenseKey);
56
- return;
57
- }
58
- if (status === 'EXPIRED' || status === 'INVALID') {
59
- block(buildMessage(status, readYamlField(licenseRaw, 'reason')));
60
- return;
61
- }
62
- }
63
-
64
- // Cache stale — call API
65
- try {
66
- const machineId = await computeMachineId();
67
- const res = await fetch(
68
- `${API_BASE}/license/validate?key=${encodeURIComponent(licenseKey)}&machine_id=${encodeURIComponent(machineId)}`,
69
- { signal: AbortSignal.timeout(5000) }
70
- );
71
- const data = await res.json();
29
+ /**
30
+ * Pure license validation function — provider-agnostic.
31
+ * Returns a verdict object that hook wrappers can translate to their
32
+ * provider's blocking schema (Claude UserPromptSubmit, Gemini BeforeModel,
33
+ * Codex UserPromptSubmit).
34
+ *
35
+ * @returns {Promise<{valid: boolean, reason?: string, status?: string}>}
36
+ */
37
+ export async function checkLicense() {
38
+ // Read global license file — REQUIRED for operation
39
+ if (!existsSync(LICENSE_PATH)) {
40
+ return { valid: false, reason: 'No license found. Run: npx chati-dev activate --key=YOUR-KEY\nGet a license at https://chati.dev/pricing', status: 'MISSING' };
41
+ }
72
42
 
73
- // Persist updated status (keep existing key)
74
- const existing = parseYaml(licenseRaw);
75
- const updated = {
76
- ...existing,
77
- status: data.status,
78
- plan: data.plan ?? '',
79
- days_remaining: data.days_remaining ?? '',
80
- expires_at: data.expires_at ?? '',
81
- reason: data.reason ?? '',
82
- checked_at: new Date().toISOString(),
83
- };
84
- mkdirSync(GLOBAL_DIR, { recursive: true });
85
- writeFileSync(LICENSE_PATH, dumpYaml(updated));
43
+ const licenseRaw = readFileSync(LICENSE_PATH, 'utf-8');
44
+ const licenseKey = readYamlField(licenseRaw, 'key');
86
45
 
87
- if (data.status === 'VALID') {
88
- await allowWithPing(licenseKey);
89
- return;
90
- }
91
- block(buildMessage(data.status, data.reason));
92
- } catch {
93
- // API unreachable — check if we have a recent VALID cache to fall back on
94
- // If cache is less than 24h old AND was VALID, allow (grace period for network issues)
95
- // Otherwise block — we cannot verify the license
96
- if (age < 24 * 60 * 60 * 1000 && status === 'VALID') {
97
- allow(); // grace: last check was recent and valid, allow despite network issue
98
- } else {
99
- block('Unable to verify license (network issue). If this persists, check your connection.\nRun: npx chati-dev activate --key=YOUR-KEY');
100
- }
101
- }
102
- } catch { /* expected: stdin parse may fail — fail open */
103
- allow();
46
+ if (!licenseKey || licenseKey === 'null') {
47
+ return { valid: false, reason: 'No license key configured. Run: npx chati-dev activate --key=YOUR-KEY\nGet a license at https://chati.dev/pricing', status: 'MISSING' };
104
48
  }
105
- }
106
49
 
107
- // ---------------------------------------------------------------------------
108
- // Session Ping (5-min throttle with phase-change detection)
109
- // ---------------------------------------------------------------------------
110
-
111
- async function allowWithPing(licenseKey) {
112
- const projectPath = process.cwd();
113
- const projectName = basename(projectPath) || 'unknown';
114
- const session = readSessionContext();
50
+ // Check cache validity
51
+ const status = readYamlField(licenseRaw, 'status');
52
+ const checkedAt = readYamlField(licenseRaw, 'checked_at');
53
+ const age = checkedAt ? Date.now() - new Date(checkedAt).getTime() : Infinity;
115
54
 
116
- if (shouldPing(projectPath, session)) {
117
- recordPing(projectPath, session); // sync prevents duplicate pings even if fetch fails
118
- const pingPromise = sendSessionPing(licenseKey, projectName, session);
119
- allow();
120
- await pingPromise; // keep process alive until ping completes (max 3s)
121
- } else {
122
- allow();
55
+ if (age < CACHE_TTL_MS) {
56
+ if (status === 'VALID') return { valid: true, status: 'VALID' };
57
+ if (status === 'EXPIRED' || status === 'INVALID') {
58
+ return { valid: false, reason: buildMessage(status, readYamlField(licenseRaw, 'reason')), status };
59
+ }
123
60
  }
124
- }
125
61
 
126
- /**
127
- * Reads .chati/session.yaml from cwd to extract pipeline phase, agent, and project type.
128
- * Returns {} on any error (fail silently — this is optional enrichment).
129
- */
130
- function readSessionContext() {
62
+ // Cache stale — call API
131
63
  try {
132
- const sessionPath = join(process.cwd(), '.chati', 'session.yaml');
133
- if (!existsSync(sessionPath)) return {};
134
- const raw = readFileSync(sessionPath, 'utf-8');
135
-
136
- // project.state → pipeline_phase
137
- const stateMatch = raw.match(/^\s+state:\s*(.+)$/m);
138
- const pipeline_phase = stateMatch ? stateMatch[1].trim().replace(/^["']|["']$/g, '') : null;
139
-
140
- // current_agent (top-level)
141
- const agentMatch = raw.match(/^current_agent:\s*(.+)$/m);
142
- const current_agent = agentMatch ? agentMatch[1].trim().replace(/^["']|["']$/g, '') : null;
143
-
144
- // project.type project_type
145
- const typeMatch = raw.match(/^\s+type:\s*(.+)$/m);
146
- const project_type = typeMatch ? typeMatch[1].trim().replace(/^["']|["']$/g, '') : null;
147
-
148
- return { pipeline_phase, current_agent, project_type };
149
- } catch { return {}; }
64
+ const machineId = await computeMachineId();
65
+ const res = await fetch(
66
+ `${API_BASE}/license/validate?key=${encodeURIComponent(licenseKey)}&machine_id=${encodeURIComponent(machineId)}`,
67
+ { signal: AbortSignal.timeout(5000) }
68
+ );
69
+ const data = await res.json();
70
+
71
+ // Persist updated status (keep existing key)
72
+ const existing = parseYaml(licenseRaw);
73
+ const updated = {
74
+ ...existing,
75
+ status: data.status,
76
+ plan: data.plan ?? '',
77
+ days_remaining: data.days_remaining ?? '',
78
+ expires_at: data.expires_at ?? '',
79
+ reason: data.reason ?? '',
80
+ checked_at: new Date().toISOString(),
81
+ };
82
+ mkdirSync(GLOBAL_DIR, { recursive: true });
83
+ writeFileSync(LICENSE_PATH, dumpYaml(updated));
84
+
85
+ if (data.status === 'VALID') return { valid: true, status: 'VALID' };
86
+ return { valid: false, reason: buildMessage(data.status, data.reason), status: data.status };
87
+ } catch {
88
+ // API unreachable — grace period if cache is recent and was VALID
89
+ if (age < 24 * 60 * 60 * 1000 && status === 'VALID') {
90
+ return { valid: true, status: 'VALID', graced: true };
91
+ }
92
+ return { valid: false, reason: 'Unable to verify license (network issue). If this persists, check your connection.\nRun: npx chati-dev activate --key=YOUR-KEY', status: 'NETWORK_ERROR' };
93
+ }
150
94
  }
151
95
 
152
- /**
153
- * Pings.yaml format: '/path/to/project': '2026-03-22T21:00:00.000Z|discover|greenfield-wu'
154
- * Ping if: (1) no entry, (2) >5 min ago, OR (3) phase/agent changed
155
- */
156
- function shouldPing(projectPath, session = {}) {
157
- try {
158
- if (!existsSync(PINGS_PATH)) return true;
159
- const content = readFileSync(PINGS_PATH, 'utf-8');
160
- const escaped = projectPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
161
- const match = content.match(new RegExp(`^${escaped}:\\s*'?([^'\\n]+)'?$`, 'm'));
162
- if (!match) return true;
163
-
164
- const [timestamp, lastPhase, lastAgent] = match[1].trim().split('|');
165
- const age = Date.now() - new Date(timestamp).getTime();
166
- if (age > PING_THROTTLE_MS) return true;
167
-
168
- // Phase or agent changed → ping immediately
169
- if (session.pipeline_phase && session.pipeline_phase !== lastPhase) return true;
170
- if (session.current_agent && session.current_agent !== lastAgent) return true;
171
-
172
- return false;
173
- } catch { return false; } // fail closed — don't add latency on parse error
174
- }
96
+ async function main() {
97
+ let input = '';
98
+ for await (const chunk of process.stdin) input += chunk;
175
99
 
176
- function recordPing(projectPath, session = {}) {
177
100
  try {
178
- mkdirSync(GLOBAL_DIR, { recursive: true });
179
- const timestamp = new Date().toISOString();
180
- const phase = session.pipeline_phase ?? '';
181
- const agent = session.current_agent ?? '';
182
- const value = `${timestamp}|${phase}|${agent}`;
183
-
184
- let content = existsSync(PINGS_PATH) ? readFileSync(PINGS_PATH, 'utf-8') : '';
185
- const escaped = projectPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
186
- if (new RegExp(`^${escaped}:`, 'm').test(content)) {
187
- content = content.replace(new RegExp(`^(${escaped}:).*$`, 'm'), `$1 '${value}'`);
101
+ const result = await checkLicense();
102
+ if (result.valid) {
103
+ allow();
188
104
  } else {
189
- content += `${projectPath}: '${value}'\n`;
105
+ block(result.reason);
190
106
  }
191
- writeFileSync(PINGS_PATH, content);
192
- } catch {} // fail silently
193
- }
194
-
195
- async function sendSessionPing(licenseKey, projectName, session = {}) {
196
- try {
197
- const properties = {};
198
- if (session.pipeline_phase) properties.pipeline_phase = session.pipeline_phase;
199
- if (session.current_agent) properties.current_agent = session.current_agent;
200
- if (session.project_type) properties.project_type = session.project_type;
201
-
202
- await fetch(TELEMETRY_ENDPOINT, {
203
- method: 'POST',
204
- headers: { 'Content-Type': 'application/json' },
205
- body: JSON.stringify({
206
- license_key: licenseKey,
207
- project_name: projectName,
208
- events: [{
209
- type: 'session_active',
210
- timestamp: new Date().toISOString(),
211
- properties,
212
- }],
213
- }),
214
- signal: AbortSignal.timeout(3000),
215
- });
216
- } catch {} // fire-and-forget, always fail silently
107
+ } catch { /* expected: stdin parse may fail — fail open */
108
+ allow();
109
+ }
217
110
  }
218
111
 
219
112
  // ---------------------------------------------------------------------------
@@ -221,10 +114,12 @@ async function sendSessionPing(licenseKey, projectName, session = {}) {
221
114
  // ---------------------------------------------------------------------------
222
115
 
223
116
  function allow() {
224
- process.stdout.write(JSON.stringify({ decision: 'allow' }));
117
+ // UserPromptSubmit canonical "allow" is empty object — omit decision.
118
+ process.stdout.write('{}');
225
119
  }
226
120
 
227
121
  function block(reason) {
122
+ // UserPromptSubmit blocking schema: decision + reason at top level.
228
123
  process.stdout.write(JSON.stringify({ decision: 'block', reason }));
229
124
  }
230
125
 
@@ -274,7 +169,16 @@ async function computeMachineId() {
274
169
  return createHash('sha256').update(raw).digest('hex').substring(0, 16);
275
170
  }
276
171
 
172
+ // Only run main when executed directly (not imported by tests).
173
+ // realpathSync resolves symlinks on both sides — important on macOS where
174
+ // /tmp is a symlink to /private/tmp. Without realpath the comparison fails
175
+ // silently and main() never runs.
277
176
  import { fileURLToPath } from 'url';
278
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
279
- main();
177
+ import { realpathSync } from 'fs';
178
+ if (process.argv[1]) {
179
+ try {
180
+ if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
181
+ main();
182
+ }
183
+ } catch { /* path resolution failed — not invoked as a script */ }
280
184
  }
@@ -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
  }