sinapse-ai 1.7.0 → 1.8.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 (96) hide show
  1. package/.claude/CLAUDE.md +5 -11
  2. package/.claude/hooks/README.md +14 -1
  3. package/.claude/hooks/code-intel-pretool.cjs +115 -0
  4. package/.claude/hooks/enforce-delegation.cjs +31 -3
  5. package/.claude/hooks/enforce-framework-boundary.cjs +324 -0
  6. package/.claude/hooks/enforce-permission-mode.cjs +249 -0
  7. package/.claude/hooks/secret-scanning.cjs +34 -43
  8. package/.claude/hooks/synapse-engine.cjs +23 -23
  9. package/.claude/hooks/telemetry-post-tool.cjs +128 -0
  10. package/.claude/hooks/telemetry-stop.cjs +132 -0
  11. package/.claude/hooks/verify-packages.cjs +9 -2
  12. package/.claude/rules/hook-governance.md +2 -0
  13. package/.sinapse-ai/cli/commands/health/index.js +24 -0
  14. package/.sinapse-ai/core/README.md +11 -0
  15. package/.sinapse-ai/core/config/config-loader.js +19 -0
  16. package/.sinapse-ai/core/execution/build-orchestrator.js +4 -1
  17. package/.sinapse-ai/core/execution/parallel-executor.js +7 -1
  18. package/.sinapse-ai/core/execution/subagent-dispatcher.js +126 -28
  19. package/.sinapse-ai/core/execution/wave-executor.js +4 -1
  20. package/.sinapse-ai/core/grounding/README.md +71 -11
  21. package/.sinapse-ai/core/health-check/checks/project/framework-config.js +38 -2
  22. package/.sinapse-ai/core/health-check/checks/project/package-json.js +47 -3
  23. package/.sinapse-ai/core/health-check/checks/services/gemini-cli.js +117 -0
  24. package/.sinapse-ai/core/health-check/checks/services/index.js +2 -0
  25. package/.sinapse-ai/core/health-check/healers/index.js +40 -3
  26. package/.sinapse-ai/core/ideation/ideation-engine.js +170 -121
  27. package/.sinapse-ai/core/ids/gate-evaluator.js +318 -0
  28. package/.sinapse-ai/core/ids/gates/g5-semantic-handshake.js +190 -0
  29. package/.sinapse-ai/core/ids/gates/g6-ci-integrity.js +162 -0
  30. package/.sinapse-ai/core/ids/index.js +30 -0
  31. package/.sinapse-ai/core/memory/__tests__/active-modules.verify.js +11 -0
  32. package/.sinapse-ai/core/orchestration/agent-invoker.js +29 -6
  33. package/.sinapse-ai/core/orchestration/brownfield-handler.js +36 -3
  34. package/.sinapse-ai/core/orchestration/executors/epic-3-executor.js +76 -5
  35. package/.sinapse-ai/core/orchestration/executors/epic-4-executor.js +63 -17
  36. package/.sinapse-ai/core/orchestration/executors/epic-6-executor.js +153 -41
  37. package/.sinapse-ai/core/orchestration/executors/epic-executor.js +40 -0
  38. package/.sinapse-ai/core/orchestration/greenfield-handler.js +87 -3
  39. package/.sinapse-ai/core/orchestration/master-orchestrator.js +105 -7
  40. package/.sinapse-ai/core/orchestration/parallel-executor.js +6 -1
  41. package/.sinapse-ai/core/orchestration/workflow-executor.js +41 -0
  42. package/.sinapse-ai/core/registry/squad-agent-resolver.js +253 -0
  43. package/.sinapse-ai/core/telemetry/ids-sink.js +188 -0
  44. package/.sinapse-ai/core/utils/output-formatter.js +8 -290
  45. package/.sinapse-ai/core-config.yaml +49 -1
  46. package/.sinapse-ai/data/entity-registry.yaml +15081 -13735
  47. package/.sinapse-ai/data/registry-update-log.jsonl +86 -0
  48. package/.sinapse-ai/development/agents/developer.md +2 -0
  49. package/.sinapse-ai/development/agents/devops.md +9 -0
  50. package/.sinapse-ai/development/external-executors/README.md +18 -0
  51. package/.sinapse-ai/development/external-executors/codex.md +56 -0
  52. package/.sinapse-ai/development/scripts/populate-entity-registry.js +65 -9
  53. package/.sinapse-ai/development/scripts/squad/squad-downloader.js +54 -11
  54. package/.sinapse-ai/development/tasks/delegate-to-external-executor.md +152 -0
  55. package/.sinapse-ai/development/tasks/github-devops-pre-push-quality-gate.md +46 -29
  56. package/.sinapse-ai/development/tasks/update-sinapse.md +3 -3
  57. package/.sinapse-ai/hooks/sinapse-brand-grounding.cjs +4 -7
  58. package/.sinapse-ai/hooks/sinapse-ds-grounding.cjs +4 -7
  59. package/.sinapse-ai/hooks/sinapse-vault-grounding.cjs +4 -7
  60. package/.sinapse-ai/infrastructure/integrations/ai-providers/ai-provider-factory.js +4 -1
  61. package/.sinapse-ai/infrastructure/integrations/ai-providers/claude-provider.js +57 -55
  62. package/.sinapse-ai/infrastructure/scripts/ide-sync/gemini-commands.js +298 -0
  63. package/.sinapse-ai/infrastructure/scripts/ide-sync/index.js +127 -6
  64. package/.sinapse-ai/infrastructure/scripts/ide-sync/persona-renderer.js +97 -0
  65. package/.sinapse-ai/infrastructure/scripts/ide-sync/transformers/antigravity.js +121 -0
  66. package/.sinapse-ai/infrastructure/scripts/ide-sync/transformers/cursor.js +119 -0
  67. package/.sinapse-ai/infrastructure/scripts/ide-sync/transformers/github-copilot.js +191 -0
  68. package/.sinapse-ai/infrastructure/scripts/ide-sync/transformers/kimi.js +448 -0
  69. package/.sinapse-ai/install-manifest.yaml +158 -90
  70. package/.sinapse-ai/scripts/pm.sh +18 -6
  71. package/bin/cli.js +17 -0
  72. package/bin/commands/agents.js +96 -0
  73. package/bin/commands/doctor.js +15 -0
  74. package/bin/commands/ideate.js +129 -0
  75. package/bin/commands/uninstall.js +40 -0
  76. package/bin/postinstall.js +50 -4
  77. package/bin/sinapse.js +146 -2
  78. package/bin/utils/secret-scanner-core.js +253 -0
  79. package/bin/utils/staged-secret-scan.js +106 -40
  80. package/package.json +13 -3
  81. package/packages/installer/src/installer/git-hooks-installer.js +384 -0
  82. package/packages/installer/src/installer/sinapse-ai-installer.js +16 -0
  83. package/packages/installer/src/wizard/ide-config-generator.js +23 -0
  84. package/packages/installer/src/wizard/validators.js +38 -1
  85. package/packages/installer/tests/unit/artifact-copy-pipeline/artifact-copy-pipeline.test.js +5 -1
  86. package/packages/installer/tests/unit/git-hooks-installer.test.js +262 -0
  87. package/scripts/eval-runner.js +422 -0
  88. package/scripts/generate-install-manifest.js +13 -9
  89. package/scripts/generate-synapse-runtime.js +51 -0
  90. package/scripts/validate-all.js +1 -0
  91. package/scripts/validate-evals.js +466 -0
  92. package/scripts/validate-schemas.js +539 -0
  93. package/scripts/validate-squad-orqx.js +9 -2
  94. package/.sinapse-ai/development/scripts/elicitation-engine.js +0 -385
  95. package/.sinapse-ai/development/scripts/elicitation-session-manager.js +0 -300
  96. package/.sinapse-ai/development/tasks/test-validation-task.md +0 -172
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Hook: Enforce Permission Mode — PreToolUse guard
6
+ *
7
+ * RULE: When the project is in "explore" (read-only) mode, Write and Edit
8
+ * operations are BLOCKED. Read operations are always allowed.
9
+ * Bash commands classified as write/delete/execute are also blocked.
10
+ *
11
+ * Protocol (Claude Code PreToolUse):
12
+ * exit 0 → allow (operation proceeds)
13
+ * exit 2 → block (message shown to model via stderr)
14
+ *
15
+ * Fail-open policy:
16
+ * - If the permission config cannot be loaded, the hook allows (exit 0).
17
+ * - If mode is "ask" or "auto", the hook allows (exit 0).
18
+ * - Any unexpected error: exit 0.
19
+ *
20
+ * @module enforce-permission-mode
21
+ */
22
+
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Project root resolution
28
+ // ---------------------------------------------------------------------------
29
+
30
+ function projectRoot() {
31
+ return process.env.CLAUDE_PROJECT_DIR || process.cwd();
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Lightweight mode loader (no yaml dependency — parses the simple key: value)
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Parse `permissions.mode` from a minimal YAML config without requiring js-yaml.
40
+ * Handles:
41
+ * permissions:
42
+ * mode: explore
43
+ *
44
+ * Returns 'ask' (the safe default) if the file is missing, unreadable, or
45
+ * the key is absent.
46
+ *
47
+ * @param {string} configPath
48
+ * @returns {string} mode name
49
+ */
50
+ function loadPermissionMode(configPath) {
51
+ try {
52
+ const content = fs.readFileSync(configPath, 'utf8');
53
+ // Match "mode: <value>" under a "permissions:" block
54
+ const match = content.match(/^\s*mode:\s*["']?(\w+)["']?\s*$/m);
55
+ if (match) {
56
+ return match[1].toLowerCase();
57
+ }
58
+ } catch {
59
+ // File missing or unreadable — fail-open
60
+ }
61
+ return 'ask';
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Operation classifier (mirrors OperationGuard.classifyOperation)
66
+ // ---------------------------------------------------------------------------
67
+
68
+ const SAFE_COMMANDS = [
69
+ 'git status', 'git log', 'git diff', 'git branch', 'git show',
70
+ 'git ls-files', 'git remote -v',
71
+ 'ls', 'pwd', 'cat', 'head', 'tail', 'wc', 'find', 'grep', 'which',
72
+ 'file', 'stat',
73
+ 'npm list', 'npm outdated', 'npm audit', 'npm view', 'npm search',
74
+ 'yarn list', 'yarn info', 'bun pm ls',
75
+ 'node --version', 'npm --version', 'yarn --version', 'bun --version',
76
+ 'git --version', 'python --version', 'python3 --version',
77
+ 'uname', 'whoami', 'hostname', 'date', 'uptime', 'df -h', 'free -h',
78
+ 'env', 'printenv',
79
+ 'curl -I', 'ping -c', 'nslookup', 'dig',
80
+ 'ps aux', 'top -l 1', 'htop',
81
+ 'gh auth status', 'gh repo view', 'gh pr list', 'gh pr view',
82
+ 'gh issue list', 'gh issue view', 'gh api',
83
+ ];
84
+
85
+ const DESTRUCTIVE_PATTERNS = [
86
+ /\brm\s+(-[rf]+\s+)?/,
87
+ /\brmdir\b/,
88
+ /\bunlink\b/,
89
+ /\bgit\s+reset\s+--hard\b/,
90
+ /\bgit\s+push\s+--force\b/,
91
+ /\bgit\s+push\s+-f\b/,
92
+ /\bgit\s+clean\s+-[fd]+/,
93
+ /\bgit\s+checkout\s+\.\s*$/,
94
+ /\bgit\s+restore\s+\.\s*$/,
95
+ /\bgit\s+stash\s+drop\b/,
96
+ /\bgit\s+branch\s+-[dD]\b/,
97
+ /\bDROP\s+(TABLE|DATABASE|INDEX|VIEW)\b/i,
98
+ /\bDELETE\s+FROM\b/i,
99
+ /\bTRUNCATE\b/i,
100
+ /\bALTER\s+TABLE\b.*\bDROP\b/i,
101
+ /\bkill\s+-9\b/,
102
+ /\bkillall\b/,
103
+ /\bshutdown\b/,
104
+ /\breboot\b/,
105
+ /\bnpm\s+uninstall\b/,
106
+ /\byarn\s+remove\b/,
107
+ /\bbun\s+remove\b/,
108
+ ];
109
+
110
+ const WRITE_PATTERNS = [
111
+ /[^<]>/,
112
+ />>/,
113
+ /\bmkdir\b/,
114
+ /\btouch\b/,
115
+ /\bmv\b/,
116
+ /\bcp\b/,
117
+ /\bln\b/,
118
+ /\bchmod\b/,
119
+ /\bchown\b/,
120
+ /\bsed\s+-i\b/,
121
+ /\bawk\s+-i\b/,
122
+ /\bgit\s+add\b/,
123
+ /\bgit\s+commit\b/,
124
+ /\bgit\s+push\b/,
125
+ /\bgit\s+merge\b/,
126
+ /\bgit\s+rebase\b/,
127
+ /\bgit\s+cherry-pick\b/,
128
+ /\bgit\s+stash\b/,
129
+ /\bnpm\s+install\b/,
130
+ /\bnpm\s+i\b/,
131
+ /\bnpm\s+ci\b/,
132
+ /\byarn\s+add\b/,
133
+ /\byarn\s+install\b/,
134
+ /\bbun\s+install\b/,
135
+ /\bbun\s+add\b/,
136
+ ];
137
+
138
+ /**
139
+ * Classify a Bash command as read/write/delete/execute.
140
+ * @param {string} command
141
+ * @returns {string}
142
+ */
143
+ function classifyBashCommand(command) {
144
+ const normalized = command.trim().toLowerCase();
145
+ for (const safe of SAFE_COMMANDS) {
146
+ if (normalized.startsWith(safe.toLowerCase())) return 'read';
147
+ }
148
+ for (const pat of DESTRUCTIVE_PATTERNS) {
149
+ if (pat.test(command)) return 'delete';
150
+ }
151
+ for (const pat of WRITE_PATTERNS) {
152
+ if (pat.test(command)) return 'write';
153
+ }
154
+ return 'execute';
155
+ }
156
+
157
+ /**
158
+ * Classify a tool call into an operation type.
159
+ * @param {string} tool
160
+ * @param {Object} params
161
+ * @returns {string}
162
+ */
163
+ function classifyOperation(tool, params) {
164
+ if (['Read', 'Glob', 'Grep', 'WebFetch', 'WebSearch'].includes(tool)) return 'read';
165
+ if (['Write', 'Edit', 'NotebookEdit'].includes(tool)) return 'write';
166
+ if (tool === 'Bash') return classifyBashCommand(params.command || '');
167
+ if (tool === 'Task') {
168
+ const readOnlyAgents = ['Explore', 'Plan', 'claude-code-guide'];
169
+ return readOnlyAgents.includes(params.subagent_type) ? 'read' : 'execute';
170
+ }
171
+ if (tool.startsWith('mcp__')) return 'execute';
172
+ return 'read'; // default to read (safe)
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Main
177
+ // ---------------------------------------------------------------------------
178
+
179
+ function main() {
180
+ // Parse stdin — fail-open on any parse error
181
+ let input;
182
+ try {
183
+ input = JSON.parse(fs.readFileSync(0, 'utf8'));
184
+ } catch {
185
+ process.exit(0);
186
+ }
187
+
188
+ const toolName = (input.tool_name || '').trim();
189
+ const toolInput = input.tool_input || {};
190
+
191
+ // Resolve project root
192
+ const root = projectRoot();
193
+ const configPath = path.join(root, '.sinapse', 'config.yaml');
194
+
195
+ // Load permission mode — fail-open if unresolvable
196
+ let mode;
197
+ try {
198
+ mode = loadPermissionMode(configPath);
199
+ } catch {
200
+ process.exit(0);
201
+ }
202
+
203
+ // Only enforce in explore mode. All other modes: allow.
204
+ if (mode !== 'explore') {
205
+ process.exit(0);
206
+ }
207
+
208
+ // Classify the operation
209
+ let operation;
210
+ try {
211
+ operation = classifyOperation(toolName, toolInput);
212
+ } catch {
213
+ process.exit(0); // fail-open
214
+ }
215
+
216
+ // In explore mode, only reads are allowed
217
+ if (operation === 'read') {
218
+ process.exit(0);
219
+ }
220
+
221
+ // Build context for the block message
222
+ let detail = '';
223
+ if (toolName === 'Bash' && toolInput.command) {
224
+ const cmd = toolInput.command;
225
+ detail = `\nCommand: ${cmd.length > 120 ? cmd.slice(0, 120) + '...' : cmd}`;
226
+ } else if (toolInput.file_path) {
227
+ detail = `\nFile: ${toolInput.file_path}`;
228
+ }
229
+
230
+ // BLOCK — exit 2
231
+ process.stderr.write(
232
+ `\nPERMISSION-MODE BLOCK [Explore / Read-Only]\n` +
233
+ `Tool: ${toolName} Operation: ${operation}${detail}\n` +
234
+ `\n` +
235
+ `The project is in Explore (read-only) mode. Write, Edit, and destructive\n` +
236
+ `Bash commands are not allowed in this mode.\n` +
237
+ `\n` +
238
+ `To enable writes, change the permission mode:\n` +
239
+ ` *mode ask — confirm before each change\n` +
240
+ ` *mode auto — full autonomy (no prompts)\n` +
241
+ `\n` +
242
+ `Or update .sinapse/config.yaml:\n` +
243
+ ` permissions:\n` +
244
+ ` mode: ask\n`,
245
+ );
246
+ process.exit(2);
247
+ }
248
+
249
+ main();
@@ -10,7 +10,12 @@
10
10
  * exit 0 → allow
11
11
  * exit 2 → block (message shown to model via stderr)
12
12
  *
13
- * Fail-open: if parsing fails, allow.
13
+ * fail-CLOSED: if the scanner cannot load or stdin cannot be parsed, BLOCK
14
+ * (exit 2) rather than silently allowing an unscanned write.
15
+ *
16
+ * Detection logic is shared with the git pre-commit scanner via
17
+ * bin/utils/secret-scanner-core.js (20+ named patterns + Shannon-entropy
18
+ * backstop + placeholder allowlist + lockfile-hash allowlist + redaction).
14
19
  *
15
20
  * @module secret-scanning
16
21
  */
@@ -19,38 +24,23 @@ const fs = require('fs');
19
24
  const path = require('path');
20
25
 
21
26
  // ---------------------------------------------------------------------------
22
- // Secret Patterns ordered by severity
27
+ // Shared detection core (single source of truth for patterns + entropy)
23
28
  // ---------------------------------------------------------------------------
24
29
 
25
- const SECRET_PATTERNS = [
26
- // API Keys & Tokens
27
- { name: 'AWS Access Key', pattern: /AKIA[0-9A-Z]{16}/ },
28
- { name: 'AWS Secret Key', pattern: /(?:aws_secret_access_key|secret_key)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/i },
29
- { name: 'GitHub Token', pattern: /gh[ps]_[A-Za-z0-9_]{36,}/ },
30
- { name: 'GitHub OAuth', pattern: /gho_[A-Za-z0-9_]{36,}/ },
31
- { name: 'Slack Token', pattern: /xox[bpors]-[0-9]{10,}-[A-Za-z0-9-]+/ },
32
- { name: 'Stripe Key', pattern: /[sr]k_(live|test)_[A-Za-z0-9]{20,}/ },
33
- { name: 'OpenAI Key', pattern: /sk-[A-Za-z0-9]{20,}/ },
34
- { name: 'Anthropic Key', pattern: /sk-ant-[A-Za-z0-9-]{20,}/ },
35
- { name: 'Supabase Key', pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}/ },
36
- { name: 'Google API Key', pattern: /AIza[0-9A-Za-z_-]{35}/ },
37
- { name: 'Vercel Token', pattern: /vercel_[A-Za-z0-9]{20,}/ },
38
-
39
- // Private Keys
40
- { name: 'RSA Private Key', pattern: /-----BEGIN RSA PRIVATE KEY-----/ },
41
- { name: 'SSH Private Key', pattern: /-----BEGIN OPENSSH PRIVATE KEY-----/ },
42
- { name: 'PGP Private Key', pattern: /-----BEGIN PGP PRIVATE KEY BLOCK-----/ },
43
- { name: 'EC Private Key', pattern: /-----BEGIN EC PRIVATE KEY-----/ },
44
-
45
- // Connection Strings
46
- { name: 'DB Connection String', pattern: /(?:postgres|mysql|mongodb|redis):\/\/[^:]+:[^@]+@[^/\s]+/i },
47
- { name: 'Supabase DB URL', pattern: /postgresql:\/\/postgres\.[A-Za-z0-9]+:[^@]+@/i },
48
-
49
- // Generic Patterns (broader, lower confidence)
50
- { name: 'Hardcoded Password', pattern: /(?:password|passwd|pwd)\s*[=:]\s*['"][^'"]{8,}['"]/i },
51
- { name: 'Bearer Token', pattern: /[Bb]earer\s+[A-Za-z0-9_\-.]{20,}/ },
52
- { name: 'Basic Auth', pattern: /[Bb]asic\s+[A-Za-z0-9+/=]{20,}/ },
53
- ];
30
+ let core;
31
+ try {
32
+ // Hook lives at <root>/.claude/hooks/; core lives at <root>/bin/utils/.
33
+ core = require(path.join(__dirname, '..', '..', 'bin', 'utils', 'secret-scanner-core.js'));
34
+ } catch (err) {
35
+ // fail-CLOSED: cannot scan do not allow the write.
36
+ process.stderr.write(
37
+ '\nSECRET SCANNING BLOCK: scanner failed to load (fail-closed).\n' +
38
+ String(err && err.message ? err.message : err) + '\n',
39
+ );
40
+ process.exit(2);
41
+ }
42
+
43
+ const { scanContent } = core;
54
44
 
55
45
  /** Files that are expected to contain secret-like patterns */
56
46
  const EXEMPT_PATHS = [
@@ -94,14 +84,9 @@ function isScannable(rel) {
94
84
  return SCANNABLE_EXTENSIONS.some((ext) => rel.endsWith(ext));
95
85
  }
96
86
 
97
- function scanForSecrets(content) {
98
- const findings = [];
99
- for (const { name, pattern } of SECRET_PATTERNS) {
100
- if (pattern.test(content)) {
101
- findings.push(name);
102
- }
103
- }
104
- return findings;
87
+ function scanForSecrets(content, filePath) {
88
+ // Delegates to the shared, hardened core. Returns redacted findings.
89
+ return scanContent(content, { filePath: filePath || '' });
105
90
  }
106
91
 
107
92
  // ---------------------------------------------------------------------------
@@ -113,7 +98,9 @@ function main() {
113
98
  try {
114
99
  input = JSON.parse(fs.readFileSync(0, 'utf8'));
115
100
  } catch {
116
- process.exit(0); // fail-open
101
+ // fail-CLOSED: unparseable hook input → block rather than allow blindly.
102
+ process.stderr.write('\nSECRET SCANNING BLOCK: could not parse hook input (fail-closed).\n');
103
+ process.exit(2);
117
104
  }
118
105
 
119
106
  const toolName = input.tool_name || '';
@@ -135,14 +122,18 @@ function main() {
135
122
  const content = toolInput.content || toolInput.new_string || '';
136
123
  if (!content) process.exit(0);
137
124
 
138
- const findings = scanForSecrets(content);
125
+ const findings = scanForSecrets(content, rel);
139
126
  if (findings.length === 0) process.exit(0);
140
127
 
141
- // BLOCK
128
+ // BLOCK — secrets are reported REDACTED (the core never returns raw values).
129
+ const lines = findings.map((f) => {
130
+ const ent = f.entropy ? ` (entropy ${f.entropy})` : '';
131
+ return ` - ${f.name}: ${f.redacted}${ent}`;
132
+ });
142
133
  process.stderr.write(
143
134
  `\nSECRET SCANNING BLOCK: Potential secrets detected!\n` +
144
135
  `File: ${rel}\n` +
145
- `Found: ${findings.join(', ')}\n` +
136
+ `Found:\n${lines.join('\n')}\n` +
146
137
  `\n` +
147
138
  `DO NOT commit secrets to code. Instead:\n` +
148
139
  ` - Use environment variables (.env) for local dev\n` +
@@ -39,10 +39,32 @@ function readStdin() {
39
39
  });
40
40
  }
41
41
 
42
+ /** Write to stdout robustly across real and mocked (Jest) streams. */
43
+ function writeStdout(output) {
44
+ return new Promise((resolve, reject) => {
45
+ let settled = false;
46
+ const finish = (err) => {
47
+ if (settled) return;
48
+ settled = true;
49
+ if (err) reject(err); else resolve();
50
+ };
51
+ try {
52
+ const flushed = process.stdout.write(output, (err) => finish(err));
53
+ if (flushed) setImmediate(() => finish());
54
+ else if (typeof process.stdout.once === 'function') process.stdout.once('drain', () => finish());
55
+ } catch (err) {
56
+ finish(err);
57
+ }
58
+ });
59
+ }
60
+
42
61
  /** Main hook execution pipeline. */
43
62
  async function main() {
44
63
  const input = await readStdin();
45
64
  const runtime = resolveHookRuntime(input);
65
+ // Silent exit (empty stdout = valid "no context") when no runtime is
66
+ // resolvable — missing cwd or missing `.synapse/`. Only NON-empty output
67
+ // must conform to the hook schema.
46
68
  if (!runtime) return;
47
69
 
48
70
  const result = await runtime.engine.process(input.prompt, runtime.session);
@@ -62,29 +84,7 @@ async function main() {
62
84
  }
63
85
 
64
86
  const output = JSON.stringify(buildHookOutput(result.xml));
65
-
66
- // Write output robustly across real process.stdout and mocked Jest streams.
67
- // Some mocks return boolean but never invoke callback; handle both patterns.
68
- await new Promise((resolve, reject) => {
69
- let settled = false;
70
- const finish = (err) => {
71
- if (settled) return;
72
- settled = true;
73
- if (err) reject(err);
74
- else resolve();
75
- };
76
-
77
- try {
78
- const flushed = process.stdout.write(output, (err) => finish(err));
79
- if (flushed) {
80
- setImmediate(() => finish());
81
- } else if (typeof process.stdout.once === 'function') {
82
- process.stdout.once('drain', () => finish());
83
- }
84
- } catch (err) {
85
- finish(err);
86
- }
87
- });
87
+ await writeStdout(output);
88
88
  }
89
89
 
90
90
  /** Entry point runner — lets Node exit naturally after stdout flush. */
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * telemetry-post-tool.cjs
4
+ * PostToolUse hook — accumulates per-session DORA counters into a state file.
5
+ *
6
+ * FAIL-OPEN TOTAL: any error is swallowed; hook always exits 0 so it never
7
+ * blocks the main Claude Code flow.
8
+ *
9
+ * State path: <projectRoot>/.sinapse/telemetry/sessions/<sessionId>.json
10
+ * Written by: this hook (PostToolUse)
11
+ * Read+cleared by: telemetry-stop.cjs (Stop)
12
+ *
13
+ * Registered in HOOK_EVENT_MAP as: PostToolUse, no matcher, timeout 3.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const os = require('os');
21
+
22
+ // ─── constants ───────────────────────────────────────────────────────────────
23
+
24
+ const FILE_WRITE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
25
+
26
+ // ─── helpers ─────────────────────────────────────────────────────────────────
27
+
28
+ function resolveProjectRoot() {
29
+ // CLAUDE_PROJECT_DIR is injected by Claude Code into hook environment
30
+ const fromEnv = process.env.CLAUDE_PROJECT_DIR || process.env.SINAPSE_PROJECT_DIR;
31
+ if (fromEnv) return fromEnv;
32
+ // Fallback: cwd at the time the hook is spawned
33
+ return process.cwd();
34
+ }
35
+
36
+ function resolveSessionId(data) {
37
+ return (
38
+ process.env.CLAUDE_SESSION_ID ||
39
+ process.env.CLAUDE_CODE_SESSION_ID ||
40
+ data.session_id ||
41
+ data.sessionId ||
42
+ 'unknown'
43
+ );
44
+ }
45
+
46
+ function sessionStatePath(projectRoot, sessionId) {
47
+ return path.join(projectRoot, '.sinapse', 'telemetry', 'sessions', `${sessionId}.json`);
48
+ }
49
+
50
+ function readState(statePath) {
51
+ try {
52
+ const raw = fs.readFileSync(statePath, 'utf8');
53
+ return JSON.parse(raw);
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function writeState(statePath, state) {
60
+ try {
61
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
62
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
63
+ } catch {
64
+ // fail-open: ignore write errors
65
+ }
66
+ }
67
+
68
+ // ─── main ────────────────────────────────────────────────────────────────────
69
+
70
+ function main() {
71
+ try {
72
+ // Read event payload from stdin (Claude Code injects it as JSON)
73
+ let raw = '';
74
+ try {
75
+ raw = fs.readFileSync('/dev/stdin', 'utf8');
76
+ } catch {
77
+ // On Windows there's no /dev/stdin — read from fd 0
78
+ try {
79
+ const buf = Buffer.alloc(64 * 1024);
80
+ let totalBytes = 0;
81
+ let bytesRead = 0;
82
+ // Read synchronously until EOF
83
+ while (true) {
84
+ try {
85
+ bytesRead = fs.readSync(0, buf, totalBytes, buf.length - totalBytes, null);
86
+ if (bytesRead === 0) break;
87
+ totalBytes += bytesRead;
88
+ } catch {
89
+ break;
90
+ }
91
+ }
92
+ raw = buf.slice(0, totalBytes).toString('utf8');
93
+ } catch {
94
+ raw = '{}';
95
+ }
96
+ }
97
+
98
+ const data = (() => { try { return JSON.parse(raw); } catch { return {}; } })();
99
+
100
+ const projectRoot = resolveProjectRoot();
101
+ const sessionId = resolveSessionId(data);
102
+ const toolName = data.tool_name || data.toolName || '';
103
+ const statePath = sessionStatePath(projectRoot, sessionId);
104
+
105
+ // Read existing state or bootstrap it
106
+ const existing = readState(statePath);
107
+ const state = existing || {
108
+ startedAt: Date.now(),
109
+ toolExecutions: 0,
110
+ filesModified: 0,
111
+ };
112
+
113
+ // Increment counters
114
+ state.toolExecutions += 1;
115
+ if (FILE_WRITE_TOOLS.has(toolName)) {
116
+ state.filesModified += 1;
117
+ }
118
+
119
+ writeState(statePath, state);
120
+ } catch {
121
+ // fail-open: swallow any top-level error
122
+ }
123
+
124
+ // Always exit 0 — observability hooks are never fail-closed
125
+ process.exit(0);
126
+ }
127
+
128
+ main();
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * telemetry-stop.cjs
4
+ * Stop hook — reads per-session DORA state and appends a session-end line
5
+ * to the JSONL sink, then cleans up the state file.
6
+ *
7
+ * FAIL-OPEN TOTAL: any error is swallowed; hook always exits 0.
8
+ *
9
+ * Sink path: <projectRoot>/.sinapse/telemetry/gate-decisions.jsonl
10
+ * State path: <projectRoot>/.sinapse/telemetry/sessions/<sessionId>.json
11
+ *
12
+ * Registered in HOOK_EVENT_MAP as: Stop, no matcher, timeout 5.
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ // ─── helpers ─────────────────────────────────────────────────────────────────
21
+
22
+ function resolveProjectRoot() {
23
+ const fromEnv = process.env.CLAUDE_PROJECT_DIR || process.env.SINAPSE_PROJECT_DIR;
24
+ if (fromEnv) return fromEnv;
25
+ return process.cwd();
26
+ }
27
+
28
+ function resolveSessionId(data) {
29
+ return (
30
+ process.env.CLAUDE_SESSION_ID ||
31
+ process.env.CLAUDE_CODE_SESSION_ID ||
32
+ data.session_id ||
33
+ data.sessionId ||
34
+ 'unknown'
35
+ );
36
+ }
37
+
38
+ function sinkPath(projectRoot) {
39
+ return path.join(projectRoot, '.sinapse', 'telemetry', 'gate-decisions.jsonl');
40
+ }
41
+
42
+ function sessionStatePath(projectRoot, sessionId) {
43
+ return path.join(projectRoot, '.sinapse', 'telemetry', 'sessions', `${sessionId}.json`);
44
+ }
45
+
46
+ function appendLine(filePath, record) {
47
+ try {
48
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
49
+ fs.appendFileSync(filePath, JSON.stringify(record) + '\n', 'utf8');
50
+ } catch {
51
+ // fail-open: ignore write errors
52
+ }
53
+ }
54
+
55
+ function readState(statePath) {
56
+ try {
57
+ const raw = fs.readFileSync(statePath, 'utf8');
58
+ return JSON.parse(raw);
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function deleteState(statePath) {
65
+ try {
66
+ fs.unlinkSync(statePath);
67
+ } catch {
68
+ // fail-open
69
+ }
70
+ }
71
+
72
+ // ─── main ────────────────────────────────────────────────────────────────────
73
+
74
+ function main() {
75
+ try {
76
+ // Read event payload from stdin
77
+ let raw = '';
78
+ try {
79
+ raw = fs.readFileSync('/dev/stdin', 'utf8');
80
+ } catch {
81
+ try {
82
+ const buf = Buffer.alloc(64 * 1024);
83
+ let totalBytes = 0;
84
+ let bytesRead = 0;
85
+ while (true) {
86
+ try {
87
+ bytesRead = fs.readSync(0, buf, totalBytes, buf.length - totalBytes, null);
88
+ if (bytesRead === 0) break;
89
+ totalBytes += bytesRead;
90
+ } catch {
91
+ break;
92
+ }
93
+ }
94
+ raw = buf.slice(0, totalBytes).toString('utf8');
95
+ } catch {
96
+ raw = '{}';
97
+ }
98
+ }
99
+
100
+ const data = (() => { try { return JSON.parse(raw); } catch { return {}; } })();
101
+
102
+ const projectRoot = resolveProjectRoot();
103
+ const sessionId = resolveSessionId(data);
104
+ const statePath = sessionStatePath(projectRoot, sessionId);
105
+ const state = readState(statePath);
106
+
107
+ const now = Date.now();
108
+ const startedAt = state ? state.startedAt : now;
109
+
110
+ // Build DORA session-end record with OTel GenAI field names
111
+ const record = {
112
+ timestamp: new Date(now).toISOString(),
113
+ 'gen_ai.operation.name': 'session.end',
114
+ 'gen_ai.request.model': 'sinapse-hooks',
115
+ 'gen_ai.usage.input_tokens': 0,
116
+ 'gen_ai.usage.output_tokens': 0,
117
+ sessionId,
118
+ 'dora.toolExecutions': state ? state.toolExecutions : 0,
119
+ 'dora.filesModified': state ? state.filesModified : 0,
120
+ 'dora.sessionDurationMs': now - startedAt,
121
+ };
122
+
123
+ appendLine(sinkPath(projectRoot), record);
124
+ deleteState(statePath);
125
+ } catch {
126
+ // fail-open: swallow any top-level error
127
+ }
128
+
129
+ process.exit(0);
130
+ }
131
+
132
+ main();