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.
@@ -1,8 +1,8 @@
1
1
  # chati.dev Configuration
2
- version: "4.2.0"
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.0"
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,6 +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.0 — 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
+ *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*
760
762
  *All agents are bound by this Constitution. Violations are enforced per article.*
@@ -1,6 +1,6 @@
1
1
  # Chati.dev Governance Rules
2
2
 
3
- Extracted from `chati.dev/constitution.md` (19 Articles). Read the full constitution for complete rules.
3
+ Extracted from `chati.dev/constitution.md` (22 Articles). Read the full constitution for complete rules.
4
4
 
5
5
  ## Mode Governance (Article XI)
6
6
  - **3 modes**: planning (read all, write chati.dev/ only), build (full access), deploy (full + infra)
@@ -50,3 +50,26 @@ Extracted from `chati.dev/constitution.md` (19 Articles). Read the full constitu
50
50
  - Provider selected at install time, auto-configures optimal models per agent
51
51
  - Resolution chain: agent_overrides > agent default > primary provider
52
52
  - Context files auto-generated for non-Claude providers (GEMINI.md, AGENTS.md)
53
+
54
+ ## Anti-Loop Protocol (Article XX)
55
+ - 3-Strike Rule: 3 edits to same file without resolution = STOP
56
+ - Mandatory Diagnosis Mode: read complete file, trace path, root cause in one sentence
57
+ - Paradigm Change required after diagnosis (CSS failed → try JavaScript, etc.)
58
+ - Escalation after 5 total attempts: present honestly, ask for direction
59
+ - Task score capped at 0% for violations
60
+
61
+ ## Agent Teams Governance (Article XXI)
62
+ - Feature-flagged (`agent_teams: true` in config.yaml), defaults to OFF
63
+ - 2 canonical teams: Planning Team (Detail+Architect+UX) and Build Team (Dev+QA)
64
+ - Only orchestrator can form/dissolve teams
65
+ - Intra-team communication via Team Mailbox (Article V amendment)
66
+ - Cross-team communication still orchestrator-mediated
67
+ - Team Quality Gate: all tasks complete + scores pass + mailbox clean
68
+ - Max 2 Team Correction Cycles before degraded dissolution
69
+ - Graceful fallback to sequential pipeline when unavailable (Gemini/Codex/flag off)
70
+
71
+ ## Fault Vector Protocol (Article XXII)
72
+ - Root Layer Routing: classify fault origin (INTENT/SPEC/CODE/DEFER) before correction
73
+ - Evidence-Bound Verdicts: ERROR findings require tool evidence (lint/typecheck/test/screenshot)
74
+ - Decision Trail: append-only log of known-bad states to prevent cyclical re-introduction
75
+ - Echo Detection: content-similarity matching to detect same defect reappearing across review cycles
@@ -5,7 +5,8 @@
5
5
  |-------|--------------|
6
6
  | qa-planning | 95% |
7
7
  | qa-implementation | 95% |
8
- | All others | 90% |
8
+ | Standard agents | 90% |
9
+ | Discovery agents (WU, brief) | 85% |
9
10
 
10
11
  ## Review Range
11
12
  Scores within 5 points below threshold trigger REVIEW (human confirmation required even in autonomous mode).
@@ -26,3 +27,15 @@ Scores within 5 points below threshold trigger REVIEW (human confirmation requir
26
27
  ## Blocker Taxonomy
27
28
  - **C01-C15**: Code blockers (technical)
28
29
  - **G01-G08**: General blockers (process, communication, dependency)
30
+
31
+ ## Fault Vector Protocol (Article XXII)
32
+ - **Root Layer Routing**: INTENT (requirement wrong) -> Brief/Detail | SPEC (spec wrong) -> Architect/Tasks | CODE (implementation wrong) -> Dev | DEFER (out of scope) -> Backlog
33
+ - **Evidence-Bound Verdicts**: ERROR/WARNING require tool evidence. Without evidence -> downgraded to SUGGESTION (non-blocking)
34
+ - **Decision Trail**: Known-bad states logged in session.yaml `decision_trail[]`. Agents read before iterating.
35
+ - **Echo Detection**: Similarity >= 0.85 with prior Decision Trail entry = ECHO. Escalate immediately, no more automated loops.
36
+ - **FVP Compliance Checklist**: Mandatory in every QA correction report
37
+
38
+ ## Team Quality Gate (Article XXI)
39
+ - All team tasks complete + all scores pass tier threshold + mailbox clean
40
+ - Max 2 Team Correction Cycles before degraded dissolution
41
+ - Team score = average of member task scores
@@ -1,10 +1,11 @@
1
1
  # Chati.dev System Context
2
2
 
3
3
  ## Framework
4
- - **Version**: 4.1.6
4
+ - **Version**: 4.2.2
5
5
  - **Agents**: Specialized agents across DISCOVER, PLAN, BUILD, DEPLOY phases
6
- - **Constitution**: 19 Articles + Preamble
7
- - **Quality**: 5 pipeline gates + 3-tier verdicts (APPROVED / NEEDS_REVISION / BLOCKED)
6
+ - **Constitution**: 22 Articles + Preamble
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, default ON in v4.2.2 for Claude provider)
8
9
 
9
10
  ## Key References
10
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
  }