chati-dev 4.2.1 → 4.3.0

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.
Files changed (222) hide show
  1. package/README.md +80 -53
  2. package/bin/chati.js +150 -5
  3. package/framework/agents/build/dev.md +509 -76
  4. package/framework/agents/deploy/devops.md +40 -25
  5. package/framework/agents/discover/brief.md +156 -22
  6. package/framework/agents/discover/brownfield-wu.md +24 -14
  7. package/framework/agents/discover/greenfield-wu.md +100 -14
  8. package/framework/agents/plan/architect-data-engineer.md +6 -6
  9. package/framework/agents/plan/architect-system.md +46 -12
  10. package/framework/agents/plan/architect.md +40 -20
  11. package/framework/agents/plan/detail.md +36 -24
  12. package/framework/agents/plan/phases.md +36 -26
  13. package/framework/agents/plan/tasks.md +114 -33
  14. package/framework/agents/plan/ux-brand-architect.md +240 -8
  15. package/framework/agents/plan/ux-component-engineer.md +28 -13
  16. package/framework/agents/plan/ux-researcher.md +7 -6
  17. package/framework/agents/plan/ux.md +55 -22
  18. package/framework/agents/quality/qa-implementation.md +143 -74
  19. package/framework/agents/quality/qa-planning.md +115 -42
  20. package/framework/agents/quality/qa-visual.md +439 -0
  21. package/framework/agents/shared/visualizer.md +128 -0
  22. package/framework/config.yaml +8 -7
  23. package/framework/constitution.md +127 -43
  24. package/framework/context/governance.md +12 -7
  25. package/framework/context/quality.md +6 -5
  26. package/framework/context/root.md +6 -6
  27. package/framework/data/entity-registry.yaml +377 -4
  28. package/framework/data/model-limits.json +19 -0
  29. package/framework/domains/agents/qa-visual.yaml +74 -0
  30. package/framework/domains/constitution.yaml +46 -2
  31. package/framework/domains/workflows/greenfield-fullstack.yaml +2 -2
  32. package/framework/hooks/advance-trigger.js +131 -0
  33. package/framework/hooks/brief-validator.js +83 -0
  34. package/framework/hooks/constitution-guard.js +91 -36
  35. package/framework/hooks/license-guard.js +129 -190
  36. package/framework/hooks/mode-governance.js +68 -16
  37. package/framework/hooks/model-governance.js +19 -8
  38. package/framework/hooks/package.json +3 -0
  39. package/framework/hooks/post-dev.js +207 -0
  40. package/framework/hooks/prism-engine.js +288 -105
  41. package/framework/hooks/read-protection.js +37 -9
  42. package/framework/hooks/reasoning-escalator.js +371 -0
  43. package/framework/hooks/reference-trigger.js +117 -0
  44. package/framework/hooks/session-digest.js +94 -20
  45. package/framework/hooks/settings.json +32 -1
  46. package/framework/hooks/style-guard.js +52 -13
  47. package/framework/hooks/team-quality-gate.js +58 -25
  48. package/framework/hooks/undercover-guard.js +34 -13
  49. package/framework/i18n/en.yaml +3 -3
  50. package/framework/i18n/es.yaml +3 -3
  51. package/framework/i18n/fr.yaml +3 -3
  52. package/framework/i18n/pt.yaml +3 -3
  53. package/framework/intelligence/context-engine.md +4 -5
  54. package/framework/intelligence/decision-engine.md +1 -1
  55. package/framework/intelligence/hooks-performance.md +3 -3
  56. package/framework/migrations/v1.0-to-v1.1.yaml +1 -1
  57. package/framework/migrations/v1.4-to-v2.0.yaml +11 -11
  58. package/framework/migrations/v4.0-to-v4.1.yaml +2 -2
  59. package/framework/migrations/v4.2-to-v4.3.yaml +29 -0
  60. package/framework/orchestrator/chati-router.js +387 -0
  61. package/framework/orchestrator/chati-update.md +40 -40
  62. package/framework/orchestrator/chati.md +318 -115
  63. package/framework/scaffold/motion-premium/README.md +89 -0
  64. package/framework/scaffold/motion-premium/app/globals.css.template +400 -0
  65. package/framework/scaffold/motion-premium/app/layout.tsx.template +110 -0
  66. package/framework/scaffold/motion-premium/components/animation/BackgroundCrossfadeProvider.tsx.template +170 -0
  67. package/framework/scaffold/motion-premium/components/animation/LenisProvider.tsx.template +49 -0
  68. package/framework/scaffold/motion-premium/components/animation/PageTransitionWrapper.tsx.template +83 -0
  69. package/framework/scaffold/motion-premium/components/animation/Preloader.tsx.template +171 -0
  70. package/framework/scaffold/motion-premium/components/ui/Container.tsx.template +69 -0
  71. package/framework/scaffold/motion-premium/components/ui/PageSection.tsx.template +74 -0
  72. package/framework/scaffold/motion-premium/lib/animations/gsap.ts.template +112 -0
  73. package/framework/scaffold/motion-premium/lib/animations/refreshCoordinator.ts.template +75 -0
  74. package/framework/scaffold/motion-premium/lib/animations/tokens.ts.template +119 -0
  75. package/framework/scaffold/motion-premium/lib/animations/useGsapContext.ts.template +92 -0
  76. package/framework/scaffold/motion-premium/lib/animations/useScrollSnapStepper.ts.template +265 -0
  77. package/framework/scaffold/motion-premium/lib/animations/useSmoothScroll.ts.template +67 -0
  78. package/framework/scaffold/motion-premium/lib/brand.ts.template +43 -0
  79. package/framework/scaffold/motion-premium/scaffold.yaml +174 -0
  80. package/framework/scaffold/motion-premium-3d/README.md +80 -0
  81. package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/ScrollDemoCanvas.tsx.template +81 -0
  82. package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/ScrollDemoClient.tsx.template +75 -0
  83. package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/page.tsx.template +26 -0
  84. package/framework/scaffold/motion-premium-3d/components/3d/CameraRig.tsx.template +100 -0
  85. package/framework/scaffold/motion-premium-3d/components/3d/CanvasProvider.tsx.template +85 -0
  86. package/framework/scaffold/motion-premium-3d/components/3d/InvalidateOnScroll.tsx.template +51 -0
  87. package/framework/scaffold/motion-premium-3d/components/3d/MeshCrossfade.tsx.template +79 -0
  88. package/framework/scaffold/motion-premium-3d/components/3d/ScrollCrossfade.tsx.template +88 -0
  89. package/framework/scaffold/motion-premium-3d/components/3d/ScrollScene.tsx.template +121 -0
  90. package/framework/scaffold/motion-premium-3d/components/webgl/SceneFallback.tsx.template +65 -0
  91. package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +68 -0
  92. package/framework/scaffold/motion-premium-3d/lib/webgl/detect.ts.template +69 -0
  93. package/framework/scaffold/motion-premium-3d/scaffold.yaml +133 -0
  94. package/framework/schemas/session.schema.json +109 -21
  95. package/framework/scripts/reference-capture.js +430 -0
  96. package/framework/scripts/visual-qa.js +674 -0
  97. package/framework/tasks/orchestrator-handoff.md +1 -1
  98. package/framework/tasks/orchestrator-resume.md +1 -1
  99. package/framework/tasks/orchestrator-route.md +1 -1
  100. package/framework/tasks/orchestrator-status.md +3 -3
  101. package/framework/tasks/qa-planning-gate-define.md +1 -1
  102. package/framework/templates/brandbook-html-tmpl.md +1 -1
  103. package/framework/templates/brandbook-tmpl.yaml +1 -1
  104. package/framework/templates/component-spec-tmpl.yaml +1 -1
  105. package/framework/templates/design-token-tmpl.yaml +1 -1
  106. package/framework/templates/icon-system-tmpl.yaml +1 -1
  107. package/framework/templates/team-planning-tasks.yaml +6 -5
  108. package/framework/workflows/brownfield-discovery.yaml +2 -2
  109. package/framework/workflows/brownfield-fullstack.yaml +15 -11
  110. package/framework/workflows/brownfield-service.yaml +14 -10
  111. package/framework/workflows/brownfield-ui.yaml +15 -11
  112. package/framework/workflows/greenfield-fullstack.yaml +16 -13
  113. package/framework/workflows/quick-flow.yaml +3 -3
  114. package/framework/workflows/standard-flow.yaml +12 -9
  115. package/package.json +10 -5
  116. package/src/autonomy/autonomous-gate.js +1 -0
  117. package/src/autonomy/build-state.js +1 -2
  118. package/src/autonomy/progress-reporter.js +1 -1
  119. package/src/config/agent-customizer.js +11 -3
  120. package/src/config/claude-settings-generator.js +226 -0
  121. package/src/config/context-file-generator.js +41 -21
  122. package/src/config/framework-adapter.js +1 -0
  123. package/src/config/gemini-hooks-generator.js +76 -6
  124. package/src/config/mcp-configs.js +1 -0
  125. package/src/context/layers/l1-global.js +2 -1
  126. package/src/dashboard/data-reader.js +4 -3
  127. package/src/dashboard/layout.js +2 -1
  128. package/src/decision/analyzer.js +6 -30
  129. package/src/decision/engine.js +4 -28
  130. package/src/decision/registry-healer.js +3 -2
  131. package/src/decision/registry-updater.js +23 -14
  132. package/src/extensions/loader.js +2 -8
  133. package/src/gates/g1-planning-complete.js +2 -1
  134. package/src/gates/g2-qa-planning.js +2 -1
  135. package/src/gates/g3-implementation.js +2 -1
  136. package/src/gates/g4-qa-implementation.js +3 -2
  137. package/src/gates/g5-deploy-ready.js +2 -1
  138. package/src/health/engine.js +4 -3
  139. package/src/installer/core.js +557 -81
  140. package/src/installer/preflight.js +131 -0
  141. package/src/installer/provider-overlay.js +3 -3
  142. package/src/installer/scaffold-applier.js +358 -0
  143. package/src/installer/templates.js +52 -31
  144. package/src/installer/validator.js +17 -12
  145. package/src/intelligence/registry-manager.js +22 -29
  146. package/src/intelligence/timeline.js +11 -6
  147. package/src/license/commands.js +1 -1
  148. package/src/license/wait.js +102 -0
  149. package/src/memory/agent-memory.js +81 -0
  150. package/src/memory/dream.js +32 -1
  151. package/src/merger/replace-merger.js +28 -15
  152. package/src/orchestrator/agent-selector.js +2 -1
  153. package/src/orchestrator/cli.js +1908 -80
  154. package/src/orchestrator/doctor.js +270 -0
  155. package/src/orchestrator/handoff-engine.js +4 -3
  156. package/src/orchestrator/index.js +2 -0
  157. package/src/orchestrator/pipeline-manager.js +306 -15
  158. package/src/orchestrator/session-manager.js +331 -6
  159. package/src/tasks/handoff.js +3 -2
  160. package/src/telemetry/config.js +4 -3
  161. package/src/telemetry/schema.js +1 -0
  162. package/src/terminal/collector.js +3 -2
  163. package/src/terminal/index.js +1 -2
  164. package/src/terminal/isolation.js +52 -18
  165. package/src/terminal/prompt-builder.js +42 -25
  166. package/src/terminal/run-parallel.js +1 -1
  167. package/src/terminal/run-team.js +3 -3
  168. package/src/terminal/team-task-list.js +43 -4
  169. package/src/upgrade/backup.js +3 -2
  170. package/src/upgrade/checker.js +3 -2
  171. package/src/upgrade/migrator.js +65 -7
  172. package/src/upgrade/tracked-files-detector.js +86 -0
  173. package/src/upgrade/user-messages.js +94 -0
  174. package/src/utils/config-parser.js +2 -1
  175. package/src/utils/feature-flags.js +2 -1
  176. package/src/utils/flatten-entities.js +69 -0
  177. package/src/utils/framework-dir.js +16 -0
  178. package/src/utils/model-id.js +85 -0
  179. package/src/utils/provider-limits.js +84 -23
  180. package/src/utils/schema-validator.js +1 -1
  181. package/src/wizard/i18n.js +5 -4
  182. package/src/wizard/index.js +14 -0
  183. package/assets/logo - c/303/263pia.png +0 -0
  184. package/assets/logo.svg +0 -42
  185. package/assets/logo2.png +0 -0
  186. package/assets/social-preview.png +0 -0
  187. package/scripts/bundle-framework.js +0 -69
  188. package/scripts/changelog-generator.js +0 -222
  189. package/scripts/codebase-mapper.js +0 -728
  190. package/scripts/commit-message-generator.js +0 -167
  191. package/scripts/coverage-analyzer.js +0 -260
  192. package/scripts/dependency-analyzer.js +0 -280
  193. package/scripts/doctor/checks/agents.js +0 -77
  194. package/scripts/doctor/checks/constitution.js +0 -41
  195. package/scripts/doctor/checks/domain-alignment.js +0 -58
  196. package/scripts/doctor/checks/prism-layers.js +0 -84
  197. package/scripts/doctor/checks/registry.js +0 -55
  198. package/scripts/doctor/checks/schemas.js +0 -61
  199. package/scripts/doctor/fixes/reference-fix.js +0 -100
  200. package/scripts/doctor/fixes/registry-fix.js +0 -56
  201. package/scripts/doctor/index.js +0 -212
  202. package/scripts/framework-analyzer.js +0 -308
  203. package/scripts/generate-constitution-domain.js +0 -253
  204. package/scripts/generate-signing-key.js +0 -33
  205. package/scripts/health-check.js +0 -481
  206. package/scripts/ide-sync.js +0 -326
  207. package/scripts/performance-analyzer.js +0 -325
  208. package/scripts/plan-tracker.js +0 -278
  209. package/scripts/populate-entity-registry.js +0 -481
  210. package/scripts/pr-review.js +0 -317
  211. package/scripts/rollback-manager.js +0 -310
  212. package/scripts/semantic-lint.js +0 -328
  213. package/scripts/sign-manifest.js +0 -53
  214. package/scripts/stuck-detector.js +0 -343
  215. package/scripts/test-quality-assessment.js +0 -257
  216. package/scripts/validate-agents.js +0 -368
  217. package/scripts/validate-package.js +0 -367
  218. package/scripts/validate-tasks.js +0 -465
  219. package/src/autonomy/worktree-manager.js +0 -250
  220. package/src/intelligence/decision-engine.js +0 -256
  221. package/src/intelligence/document-sharder.js +0 -221
  222. package/src/intelligence/elicitation.js +0 -265
@@ -3,229 +3,159 @@
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
+ * Once activated, the license is trusted until explicitly revoked by the API.
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 → allow silently (always trust cached VALID status)
11
+ * - Cache EXPIRED/INVALID → block with renewal message
12
+ * - Cache stale (> 24h) + API reachable → refresh cache in background
13
+ * - API unreachable use cached status (no blocking on network issues)
14
+ *
15
+ * Rationale: users activate once at install time. After that, the hook should
16
+ * never block due to cache expiry or network issues. Only explicit server-side
17
+ * revocation (EXPIRED/INVALID status returned by API) blocks the user.
18
+ *
19
+ * Telemetry pings are owned by session-digest.js (PreCompact event), not here.
14
20
  */
15
21
 
16
22
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
17
- import { join, basename } from 'path';
23
+ import { join } from 'path';
18
24
  import { homedir } from 'os';
19
25
 
20
26
  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
27
+ const CACHE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
24
28
 
25
29
  const GLOBAL_DIR = join(homedir(), '.chati-dev');
26
30
  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
-
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
31
 
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();
32
+ /**
33
+ * Pure license validation function — provider-agnostic.
34
+ * Returns a verdict object that hook wrappers can translate to their
35
+ * provider's blocking schema (Claude UserPromptSubmit, Gemini BeforeModel,
36
+ * Codex UserPromptSubmit).
37
+ *
38
+ * @returns {Promise<{valid: boolean, reason?: string, status?: string}>}
39
+ */
40
+ export async function checkLicense() {
41
+ // Read global license file — REQUIRED for operation
42
+ if (!existsSync(LICENSE_PATH)) {
43
+ 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' };
44
+ }
72
45
 
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));
46
+ const licenseRaw = readFileSync(LICENSE_PATH, 'utf-8');
47
+ const licenseKey = readYamlField(licenseRaw, 'key');
86
48
 
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();
49
+ if (!licenseKey || licenseKey === 'null') {
50
+ 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
51
  }
105
- }
106
52
 
107
- // ---------------------------------------------------------------------------
108
- // Session Ping (5-min throttle with phase-change detection)
109
- // ---------------------------------------------------------------------------
53
+ const status = readYamlField(licenseRaw, 'status');
54
+ const checkedAt = readYamlField(licenseRaw, 'checked_at');
55
+ const age = checkedAt ? Date.now() - new Date(checkedAt).getTime() : Infinity;
110
56
 
111
- async function allowWithPing(licenseKey) {
112
- const projectPath = process.cwd();
113
- const projectName = basename(projectPath) || 'unknown';
114
- const session = readSessionContext();
57
+ // EXPLICIT REVOCATION — block immediately
58
+ // Server explicitly said this license is no longer valid.
59
+ if (status === 'EXPIRED' || status === 'INVALID' || status === 'REVOKED') {
60
+ return { valid: false, reason: buildMessage(status, readYamlField(licenseRaw, 'reason')), status };
61
+ }
115
62
 
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();
63
+ // Fresh cache (< CACHE_TTL_MS) — trust without API call
64
+ if (status === 'VALID' && age < CACHE_TTL_MS) {
65
+ return { valid: true, status: 'VALID' };
123
66
  }
124
- }
125
67
 
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() {
68
+ // Stale cache — try background refresh.
69
+ // CRITICAL: cached VALID is trusted indefinitely. API call only updates the
70
+ // cache or detects revocation. Network failures NEVER block the user.
131
71
  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 {}; }
72
+ const machineId = await computeMachineId();
73
+ const res = await fetch(
74
+ `${API_BASE}/license/validate?key=${encodeURIComponent(licenseKey)}&machine_id=${encodeURIComponent(machineId)}`,
75
+ { signal: AbortSignal.timeout(5000) }
76
+ );
77
+ const data = await res.json();
78
+
79
+ // Persist updated status (keep existing key)
80
+ const existing = parseYaml(licenseRaw);
81
+ const updated = {
82
+ ...existing,
83
+ status: data.status,
84
+ plan: data.plan ?? '',
85
+ days_remaining: data.days_remaining ?? '',
86
+ expires_at: data.expires_at ?? '',
87
+ reason: data.reason ?? '',
88
+ checked_at: new Date().toISOString(),
89
+ };
90
+ mkdirSync(GLOBAL_DIR, { recursive: true });
91
+ writeFileSync(LICENSE_PATH, dumpYaml(updated));
92
+
93
+ if (data.status === 'VALID') return { valid: true, status: 'VALID' };
94
+ return { valid: false, reason: buildMessage(data.status, data.reason), status: data.status };
95
+ } catch {
96
+ // API unreachable — trust the last known status indefinitely.
97
+ // The user activated once; we never block due to network issues.
98
+ if (status === 'VALID') {
99
+ return { valid: true, status: 'VALID', graced: true };
100
+ }
101
+ // Status is unknown/null and API is unreachable — only now we ask user to retry.
102
+ return { valid: false, reason: 'License status unknown and unable to reach validation server. Try again when online.\nRun: npx chati-dev activate --key=YOUR-KEY', status: 'NETWORK_ERROR' };
103
+ }
150
104
  }
151
105
 
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;
106
+ async function main() {
107
+ let input = '';
108
+ for await (const chunk of process.stdin) input += chunk;
167
109
 
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;
110
+ // Detect hook event from stdin to output the correct schema.
111
+ // PreToolUse needs hookSpecificOutput.permissionDecision.
112
+ // UserPromptSubmit needs exit 2 + stderr (silent block).
113
+ let event = null;
114
+ try {
115
+ const parsed = JSON.parse(input);
116
+ event = parsed.hook_event_name || null;
117
+ } catch { /* expected: may not be JSON */ }
171
118
 
172
- return false;
173
- } catch { return false; } // fail closed — don't add latency on parse error
174
- }
119
+ const isPreToolUse = event === 'PreToolUse';
175
120
 
176
- function recordPing(projectPath, session = {}) {
177
121
  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}'`);
122
+ const result = await checkLicense();
123
+ if (result.valid) {
124
+ process.stdout.write(isPreToolUse
125
+ ? JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' } })
126
+ : '{}');
188
127
  } else {
189
- content += `${projectPath}: '${value}'\n`;
128
+ if (isPreToolUse) {
129
+ process.stdout.write(JSON.stringify({
130
+ hookSpecificOutput: {
131
+ hookEventName: 'PreToolUse',
132
+ permissionDecision: 'deny',
133
+ permissionDecisionReason: result.reason,
134
+ },
135
+ }));
136
+ } else {
137
+ process.stderr.write(result.reason);
138
+ process.exit(2);
139
+ }
190
140
  }
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
141
+ } catch { /* fail open */
142
+ process.stdout.write(isPreToolUse
143
+ ? JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' } })
144
+ : '{}');
145
+ }
217
146
  }
218
147
 
219
148
  // ---------------------------------------------------------------------------
220
- // Decision helpers
149
+ // Decision helpers (used only for non-hook Bash invocations via checkLicense)
221
150
  // ---------------------------------------------------------------------------
222
151
 
223
- function allow() {
224
- process.stdout.write(JSON.stringify({ decision: 'allow' }));
225
- }
226
-
227
152
  function block(reason) {
228
- process.stdout.write(JSON.stringify({ decision: 'block', reason }));
153
+ // v4.2.2 → v4.2.3 hotfix test: try exit code 2 + stderr instead of
154
+ // {decision:block} JSON. The JSON form was silently consumed as context
155
+ // injection in production. Per docs (variant 2), exit 2 + stderr is the
156
+ // canonical UserPromptSubmit block mechanism.
157
+ process.stderr.write(reason);
158
+ process.exit(2);
229
159
  }
230
160
 
231
161
  function buildMessage(status, reason) {
@@ -274,7 +204,16 @@ async function computeMachineId() {
274
204
  return createHash('sha256').update(raw).digest('hex').substring(0, 16);
275
205
  }
276
206
 
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.
277
211
  import { fileURLToPath } from 'url';
278
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
279
- 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 */ }
280
219
  }
@@ -15,8 +15,8 @@ import { join, relative, isAbsolute } from 'path';
15
15
 
16
16
  const MODE_SCOPES = {
17
17
  planning: {
18
- allowed: ['chati.dev/', '.chati/', 'chati.dev/artifacts/'],
19
- description: 'Planning mode: write only to chati.dev/ and .chati/',
18
+ allowed: ['chati.dev/', '.chati.dev/', '.chati/', 'artifacts/', 'CLAUDE.local.md', 'CLAUDE.md', '.claude/'],
19
+ description: 'Planning mode: write only to framework dir, .chati/, artifacts/, and Claude config files',
20
20
  },
21
21
  build: {
22
22
  allowed: ['*'],
@@ -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,70 @@ 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()));
91
+ return;
92
+ }
93
+
94
+ // Block direct edits to session.yaml — it must be managed by CLI only.
95
+ // CLI uses fs.writeFileSync (not intercepted by hooks), so this only blocks
96
+ // Claude's Edit/Write tool calls, which is exactly what we want.
97
+ const rel = isAbsolute(filePath) ? relative(projectDir, filePath) : filePath;
98
+ if (rel === join('.chati', 'session.yaml') || rel === '.chati/session.yaml') {
99
+ process.stdout.write(JSON.stringify(denyOutput(
100
+ '[Article XV] session.yaml is managed by the CLI. Use the router: node chati.dev/orchestrator/chati-router.js advance --agent {name} --score {score}'
101
+ )));
78
102
  return;
79
103
  }
80
104
 
81
105
  const mode = getCurrentMode(projectDir);
82
106
 
83
107
  if (isPathAllowed(filePath, projectDir, mode)) {
84
- process.stdout.write(JSON.stringify({ decision: 'allow' }));
108
+ process.stdout.write(JSON.stringify(allowOutput()));
85
109
  } else {
86
110
  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
- }));
111
+ process.stdout.write(JSON.stringify(denyOutput(
112
+ `[Article XI] ${scope.description}. Cannot write to "${filePath}" in ${mode} mode.`
113
+ )));
91
114
  }
92
115
  } catch (err) {
93
116
  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' }));
117
+ // Hook errors are fail-OPEN: a broken hook should not block the user.
118
+ // Security-critical denials are handled by explicit logic above.
119
+ process.stdout.write(JSON.stringify(allowOutput()));
95
120
  }
96
121
  }
97
122
 
123
+ function allowOutput() {
124
+ return {
125
+ hookSpecificOutput: {
126
+ hookEventName: 'PreToolUse',
127
+ permissionDecision: 'allow',
128
+ },
129
+ };
130
+ }
131
+
132
+ function denyOutput(reason) {
133
+ return {
134
+ hookSpecificOutput: {
135
+ hookEventName: 'PreToolUse',
136
+ permissionDecision: 'deny',
137
+ permissionDecisionReason: reason,
138
+ },
139
+ };
140
+ }
141
+
98
142
  export { getCurrentMode, isPathAllowed, MODE_SCOPES, STATE_TO_GOVERNANCE_MODE };
99
143
 
100
- // Only run main when executed directly (not imported by tests)
144
+ // Only run main when executed directly (not imported by tests).
145
+ // realpathSync resolves symlinks on both sides — important on macOS where
146
+ // /tmp is a symlink to /private/tmp. Without realpath the comparison fails
147
+ // silently and main() never runs.
101
148
  import { fileURLToPath } from 'url';
102
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
103
- main();
149
+ import { realpathSync } from 'fs';
150
+ if (process.argv[1]) {
151
+ try {
152
+ if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
153
+ main();
154
+ }
155
+ } catch { /* path resolution failed — not invoked as a script */ }
104
156
  }
@@ -38,6 +38,7 @@ const AGENT_MODELS = {
38
38
  tasks: { provider: 'claude', model: 'sonnet', tier: 'sonnet' },
39
39
  'qa-planning': { provider: 'claude', model: 'opus', tier: 'opus' },
40
40
  'qa-implementation': { provider: 'claude', model: 'opus', tier: 'opus' },
41
+ 'qa-visual': { provider: 'claude', model: 'opus', tier: 'opus' },
41
42
  dev: { provider: 'claude', model: 'opus', tier: 'opus' },
42
43
  devops: { provider: 'claude', model: 'sonnet', tier: 'sonnet' },
43
44
  };
@@ -80,24 +81,34 @@ async function main() {
80
81
  const assignment = AGENT_MODELS[agent];
81
82
  const expected = assignment.model || assignment;
82
83
  const provider = assignment.provider || 'claude';
83
- // Advisory note appended to context
84
+ // Advisory context injection via canonical UserPromptSubmit schema.
84
85
  process.stdout.write(JSON.stringify({
85
- result: 'allow',
86
- prefix: `<!-- [Article XVI] Agent "${agent}" assigned model: ${expected} (provider: ${provider}) -->`,
86
+ hookSpecificOutput: {
87
+ hookEventName: 'UserPromptSubmit',
88
+ additionalContext: `[Article XVI] Agent "${agent}" assigned model: ${expected} (provider: ${provider})`,
89
+ },
87
90
  }));
88
91
  } else {
89
- process.stdout.write(JSON.stringify({ result: 'allow' }));
92
+ process.stdout.write('{}');
90
93
  }
91
94
  } catch (err) {
92
95
  process.stderr.write(`[chati] model-governance: ${err.message}\n`);
93
- process.stdout.write(JSON.stringify({ result: 'allow' }));
96
+ process.stdout.write('{}');
94
97
  }
95
98
  }
96
99
 
97
100
  export { AGENT_MODELS, UPGRADE_CONDITIONS, getCurrentAgent };
98
101
 
99
- // Only run main when executed directly (not imported by tests)
102
+ // Only run main when executed directly (not imported by tests).
103
+ // realpathSync resolves symlinks on both sides — important on macOS where
104
+ // /tmp is a symlink to /private/tmp. Without realpath the comparison fails
105
+ // silently and main() never runs.
100
106
  import { fileURLToPath } from 'url';
101
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
102
- main();
107
+ import { realpathSync } from 'fs';
108
+ if (process.argv[1]) {
109
+ try {
110
+ if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
111
+ main();
112
+ }
113
+ } catch { /* path resolution failed — not invoked as a script */ }
103
114
  }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }