vibe-forge 0.4.0 → 0.8.1

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 (129) hide show
  1. package/.claude/commands/clear-attention.md +63 -63
  2. package/.claude/commands/compact-context.md +52 -0
  3. package/.claude/commands/configure-vcs.md +102 -102
  4. package/.claude/commands/forge.md +218 -171
  5. package/.claude/commands/need-help.md +77 -77
  6. package/.claude/commands/update-status.md +64 -64
  7. package/.claude/commands/worker-loop.md +106 -106
  8. package/.claude/hooks/worker-loop.js +217 -187
  9. package/.claude/scripts/setup-worker-loop.sh +45 -45
  10. package/.claude/settings.json +89 -0
  11. package/LICENSE +21 -21
  12. package/README.md +253 -232
  13. package/agents/aegis/personality.md +303 -269
  14. package/agents/anvil/personality.md +278 -240
  15. package/agents/architect/personality.md +260 -234
  16. package/agents/crucible/personality.md +362 -309
  17. package/agents/crucible-x/personality.md +210 -0
  18. package/agents/ember/personality.md +293 -265
  19. package/agents/flux/personality.md +248 -0
  20. package/agents/furnace/personality.md +342 -291
  21. package/agents/herald/personality.md +249 -247
  22. package/agents/loki/personality.md +108 -0
  23. package/agents/oracle/personality.md +284 -0
  24. package/agents/pixel/personality.md +140 -0
  25. package/agents/planning-hub/personality.md +473 -251
  26. package/agents/scribe/personality.md +253 -251
  27. package/agents/slag/personality.md +268 -0
  28. package/agents/temper/personality.md +270 -0
  29. package/bin/cli.js +372 -325
  30. package/bin/dashboard/api/agents.js +333 -0
  31. package/bin/dashboard/api/dispatch.js +507 -0
  32. package/bin/dashboard/api/tasks.js +416 -0
  33. package/bin/dashboard/public/assets/index-BpHfsx1r.js +2 -0
  34. package/bin/dashboard/public/assets/index-QODv4Zn9.css +1 -0
  35. package/bin/dashboard/public/index.html +14 -0
  36. package/bin/dashboard/server.js +645 -0
  37. package/bin/forge-daemon.sh +477 -851
  38. package/bin/forge-setup.sh +661 -645
  39. package/bin/forge-spawn.sh +164 -164
  40. package/bin/forge.cmd +83 -83
  41. package/bin/forge.sh +566 -387
  42. package/bin/lib/agents.sh +177 -177
  43. package/bin/lib/check-aliases.js +50 -0
  44. package/bin/lib/colors.sh +44 -44
  45. package/bin/lib/config.sh +347 -313
  46. package/bin/lib/constants.sh +241 -206
  47. package/bin/lib/daemon/budgets.sh +107 -0
  48. package/bin/lib/daemon/dependencies.sh +146 -0
  49. package/bin/lib/daemon/display.sh +128 -0
  50. package/bin/lib/daemon/notifications.sh +273 -0
  51. package/bin/lib/daemon/routing.sh +93 -0
  52. package/bin/lib/daemon/state.sh +163 -0
  53. package/bin/lib/daemon/sync.sh +103 -0
  54. package/bin/lib/database.sh +357 -305
  55. package/bin/lib/frontmatter.js +106 -0
  56. package/bin/lib/heimdall-setup.js +113 -0
  57. package/bin/lib/heimdall.js +265 -0
  58. package/bin/lib/json.sh +264 -258
  59. package/bin/lib/terminal.js +452 -446
  60. package/bin/lib/util.sh +126 -126
  61. package/bin/lib/vcs.js +349 -349
  62. package/config/agent-manifest.yaml +237 -243
  63. package/config/agents.json +207 -132
  64. package/config/task-template.md +159 -87
  65. package/config/task-types.yaml +111 -106
  66. package/config/templates/handoff-template.md +40 -0
  67. package/context/agent-overrides/README.md +41 -0
  68. package/context/architecture.md +42 -0
  69. package/context/modern-conventions.md +129 -129
  70. package/context/project-context-template.md +122 -122
  71. package/docs/agents.md +473 -409
  72. package/docs/architecture.md +194 -162
  73. package/docs/commands.md +451 -388
  74. package/docs/security.md +195 -144
  75. package/package.json +77 -50
  76. package/.claude/settings.local.json +0 -33
  77. package/agents/forge-master/capabilities.md +0 -144
  78. package/agents/forge-master/context-template.md +0 -128
  79. package/agents/forge-master/personality.md +0 -138
  80. package/agents/sentinel/personality.md +0 -194
  81. package/context/forge-state.yaml +0 -19
  82. package/docs/TODO.md +0 -150
  83. package/docs/getting-started.md +0 -243
  84. package/docs/npm-publishing.md +0 -95
  85. package/docs/workflows/README.md +0 -32
  86. package/docs/workflows/azure-devops.md +0 -108
  87. package/docs/workflows/bitbucket.md +0 -104
  88. package/docs/workflows/git-only.md +0 -130
  89. package/docs/workflows/gitea.md +0 -168
  90. package/docs/workflows/github.md +0 -103
  91. package/docs/workflows/gitlab.md +0 -105
  92. package/docs/workflows.md +0 -454
  93. package/tasks/completed/ARCH-001-duplicate-agent-config.md +0 -121
  94. package/tasks/completed/ARCH-002-mixed-bash-node-implementation.md +0 -88
  95. package/tasks/completed/ARCH-003-worker-loop-hook-duplication.md +0 -77
  96. package/tasks/completed/ARCH-009-test-organization.md +0 -78
  97. package/tasks/completed/ARCH-011-jq-vs-nodejs-json.md +0 -94
  98. package/tasks/completed/ARCH-012-tmp-files-in-root.md +0 -71
  99. package/tasks/completed/ARCH-013-exit-code-constants.md +0 -65
  100. package/tasks/completed/ARCH-014-sed-incompatibility.md +0 -96
  101. package/tasks/completed/ARCH-015-docs-todo-tracking.md +0 -83
  102. package/tasks/completed/CLEAN-001.md +0 -38
  103. package/tasks/completed/CLEAN-003.md +0 -47
  104. package/tasks/completed/CLEAN-004.md +0 -56
  105. package/tasks/completed/CLEAN-005.md +0 -75
  106. package/tasks/completed/CLEAN-006.md +0 -47
  107. package/tasks/completed/CLEAN-007.md +0 -34
  108. package/tasks/completed/CLEAN-008.md +0 -49
  109. package/tasks/completed/CLEAN-012.md +0 -58
  110. package/tasks/completed/CLEAN-013.md +0 -45
  111. package/tasks/completed/SEC-001-sql-injection-fix.md +0 -58
  112. package/tasks/completed/SEC-002-notification-injection-fix.md +0 -45
  113. package/tasks/completed/SEC-003-eval-injection-fix.md +0 -54
  114. package/tasks/completed/SEC-004-pid-race-condition-fix.md +0 -49
  115. package/tasks/completed/SEC-005-worker-loop-path-fix.md +0 -51
  116. package/tasks/completed/SEC-006-eval-agent-names.md +0 -55
  117. package/tasks/completed/SEC-007-spawn-escaping.md +0 -67
  118. package/tasks/pending/ARCH-004-git-bash-detection-duplication.md +0 -72
  119. package/tasks/pending/ARCH-005-missing-src-directory.md +0 -95
  120. package/tasks/pending/ARCH-006-task-template-location.md +0 -64
  121. package/tasks/pending/ARCH-007-daemon-monolith.md +0 -91
  122. package/tasks/pending/ARCH-008-forge-master-vs-hub.md +0 -81
  123. package/tasks/pending/ARCH-010-missing-index-files.md +0 -84
  124. package/tasks/pending/CLEAN-002.md +0 -29
  125. package/tasks/pending/CLEAN-009.md +0 -31
  126. package/tasks/pending/CLEAN-010.md +0 -30
  127. package/tasks/pending/CLEAN-011.md +0 -30
  128. package/tasks/pending/CLEAN-014.md +0 -32
  129. package/tasks/review/task-001.md +0 -78
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * frontmatter.js - Safe YAML frontmatter field extractor
4
+ *
5
+ * Replaces grep/cut pipelines for extracting frontmatter fields from
6
+ * task and attention markdown files (RT-20260405-001 MEDIUM-5).
7
+ *
8
+ * Usage:
9
+ * node frontmatter.js <file> <field1> [field2] ...
10
+ * node frontmatter.js --section <file> <heading>
11
+ *
12
+ * Output (field mode):
13
+ * field1=value1
14
+ * field2=value2
15
+ *
16
+ * Output (section mode):
17
+ * First non-heading line under the matched ## heading
18
+ *
19
+ * Values are sanitized for safe shell consumption:
20
+ * - Shell metacharacters removed
21
+ * - Length capped at 200 chars
22
+ * - Missing fields output as empty: field=
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('fs');
28
+ const yaml = require('js-yaml');
29
+
30
+ const MAX_VALUE_LENGTH = 200;
31
+
32
+ // Strip characters unsafe for shell interpolation
33
+ function sanitize(val) {
34
+ if (val == null) return '';
35
+ const str = String(val)
36
+ .replace(/[\0\r]/g, '')
37
+ .replace(/[\n]/g, ' ')
38
+ .replace(/[$`"'\\(){}[\]!#;|&<>]/g, '')
39
+ .trim();
40
+ return str.substring(0, MAX_VALUE_LENGTH);
41
+ }
42
+
43
+ function extractFrontmatter(content) {
44
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
45
+ if (!match) return {};
46
+ try {
47
+ const parsed = yaml.load(match[1]);
48
+ return typeof parsed === 'object' && parsed !== null ? parsed : {};
49
+ } catch (_) {
50
+ return {};
51
+ }
52
+ }
53
+
54
+ function extractSection(content, heading) {
55
+ const pattern = new RegExp(`^## ${heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'im');
56
+ const idx = content.search(pattern);
57
+ if (idx === -1) return '';
58
+ const after = content.substring(idx).split('\n').slice(1);
59
+ for (const line of after) {
60
+ if (line.startsWith('## ')) break;
61
+ const trimmed = line.trim();
62
+ if (trimmed) return sanitize(trimmed);
63
+ }
64
+ return '';
65
+ }
66
+
67
+ // Main
68
+ const args = process.argv.slice(2);
69
+
70
+ if (args.length < 2) {
71
+ process.stderr.write('Usage: node frontmatter.js <file> <field1> [field2] ...\n');
72
+ process.stderr.write(' node frontmatter.js --section <file> <heading>\n');
73
+ process.exit(1);
74
+ }
75
+
76
+ // Section mode: --section <file> <heading>
77
+ if (args[0] === '--section') {
78
+ const file = args[1];
79
+ const heading = args[2];
80
+ if (!file || !heading) {
81
+ process.stderr.write('Usage: node frontmatter.js --section <file> <heading>\n');
82
+ process.exit(1);
83
+ }
84
+ let content;
85
+ try { content = fs.readFileSync(file, 'utf8'); } catch (_) { process.exit(0); }
86
+ process.stdout.write(extractSection(content, heading) + '\n');
87
+ process.exit(0);
88
+ }
89
+
90
+ // Field mode: <file> <field1> [field2] ...
91
+ const file = args[0];
92
+ const fields = args.slice(1);
93
+
94
+ let content;
95
+ try { content = fs.readFileSync(file, 'utf8'); } catch (_) {
96
+ // File unreadable: output empty values
97
+ for (const f of fields) process.stdout.write(`${f}=\n`);
98
+ process.exit(0);
99
+ }
100
+
101
+ const data = extractFrontmatter(content);
102
+
103
+ for (const field of fields) {
104
+ const val = sanitize(data[field]);
105
+ process.stdout.write(`${field}=${val}\n`);
106
+ }
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Heimdall Setup -- writes .claude/settings.local.json into a worker's
4
+ * working directory to register Heimdall as a PreToolUse hook.
5
+ *
6
+ * Called by the forge daemon at inbox-write time, before the worker
7
+ * picks up a lab task.
8
+ *
9
+ * Uses a merge strategy: if settings.local.json already exists, the
10
+ * Heimdall hooks are merged into the existing PreToolUse array rather
11
+ * than overwriting the file.
12
+ */
13
+
14
+ 'use strict'
15
+
16
+ const fs = require('fs')
17
+ const path = require('path')
18
+
19
+ // Absolute path to heimdall.js -- resolvable from any working directory
20
+ const HEIMDALL_PATH = path.resolve(__dirname, 'heimdall.js').replace(/\\/g, '/')
21
+
22
+ const HEIMDALL_HOOKS = ['Bash', 'Write', 'Edit'].map(matcher => ({
23
+ matcher,
24
+ hooks: [{ type: 'command', command: `node "${HEIMDALL_PATH}"` }],
25
+ }))
26
+
27
+ /**
28
+ * writeHeimdallHooks(worktreePath)
29
+ *
30
+ * Writes or merges Heimdall PreToolUse hooks into:
31
+ * <worktreePath>/.claude/settings.local.json
32
+ *
33
+ * Safe to call multiple times -- idempotent.
34
+ *
35
+ * @param {string} worktreePath Absolute path to the worker's worktree root
36
+ */
37
+ function writeHeimdallHooks(worktreePath) {
38
+ const claudeDir = path.join(worktreePath, '.claude')
39
+ const settingsPath = path.join(claudeDir, 'settings.local.json')
40
+
41
+ // Ensure .claude/ exists
42
+ if (!fs.existsSync(claudeDir)) {
43
+ fs.mkdirSync(claudeDir, { recursive: true })
44
+ }
45
+
46
+ // Read existing settings if present
47
+ let existing = {}
48
+ if (fs.existsSync(settingsPath)) {
49
+ try {
50
+ existing = JSON.parse(fs.readFileSync(settingsPath, 'utf8'))
51
+ } catch (_) {
52
+ // Corrupt file -- start fresh
53
+ existing = {}
54
+ }
55
+ }
56
+
57
+ // Ensure hooks structure exists
58
+ if (!existing.hooks) existing.hooks = {}
59
+ if (!existing.hooks.PreToolUse) existing.hooks.PreToolUse = []
60
+
61
+ // Merge: add Heimdall hook entries for matchers not already registered
62
+ const existingMatchers = new Set(
63
+ existing.hooks.PreToolUse.map(h => h.matcher)
64
+ )
65
+
66
+ for (const heimdallHook of HEIMDALL_HOOKS) {
67
+ if (!existingMatchers.has(heimdallHook.matcher)) {
68
+ existing.hooks.PreToolUse.push(heimdallHook)
69
+ }
70
+ }
71
+
72
+ fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2) + '\n')
73
+ }
74
+
75
+ /**
76
+ * writeContextFile(worktreePath, context)
77
+ *
78
+ * Writes the Heimdall context file to the worktree root so Heimdall
79
+ * can read per-task policy on every invocation.
80
+ *
81
+ * @param {string} worktreePath Absolute path to the worker's worktree root
82
+ * @param {object} context Context object matching the schema below
83
+ *
84
+ * Context schema:
85
+ * {
86
+ * story_id: string -- lab story ID (e.g. "FORGE-3")
87
+ * agent: string -- worker name (e.g. "anvil")
88
+ * worktree_path: string -- absolute path to worktree (same as worktreePath)
89
+ * assigned_branch: string -- git branch for this story
90
+ * handoff_dir: string -- absolute path to _vibe-chain-output/handoffs/
91
+ * escalation_dir: string -- absolute path to worker-inbox/<agent>/ dir
92
+ * audit_log: string -- absolute path to heimdall-audit.log
93
+ * has_db_migration: boolean
94
+ * has_api_changes: boolean
95
+ * allowed_paths: string[] -- absolute paths the worker may read/write
96
+ * }
97
+ */
98
+ function writeContextFile(worktreePath, context) {
99
+ const contextPath = path.join(worktreePath, '.context.json')
100
+ fs.writeFileSync(contextPath, JSON.stringify(context, null, 2) + '\n')
101
+ }
102
+
103
+ /**
104
+ * setup(worktreePath, context)
105
+ *
106
+ * Convenience function: writes both hooks and context file in one call.
107
+ */
108
+ function setup(worktreePath, context) {
109
+ writeHeimdallHooks(worktreePath)
110
+ writeContextFile(worktreePath, context)
111
+ }
112
+
113
+ module.exports = { setup, writeHeimdallHooks, writeContextFile, HEIMDALL_PATH }
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Heimdall -- Forge worker pre-tool hook interceptor (PLAT-1)
4
+ *
5
+ * Guards the Bifrost: intercepts every tool call before execution,
6
+ * checks it against per-task policy, and blocks or allows.
7
+ *
8
+ * Registered as a PreToolUse hook via .claude/settings.local.json
9
+ * written by the forge daemon at worker startup.
10
+ *
11
+ * Exit codes:
12
+ * 0 -- allow (optionally with audit log entry)
13
+ * 2 -- block (explanation written to stdout, fed back to model)
14
+ *
15
+ * Context file (.context.json in process.cwd()) is written by the forge
16
+ * daemon alongside each inbox task. If absent, Heimdall exits 0 immediately
17
+ * -- forge-native tasks are not restricted.
18
+ */
19
+
20
+ 'use strict'
21
+
22
+ const fs = require('fs')
23
+ const path = require('path')
24
+
25
+ // ── Input ─────────────────────────────────────────────────────────────────────
26
+
27
+ let input
28
+ try {
29
+ input = JSON.parse(fs.readFileSync(0, 'utf8'))
30
+ } catch (e) {
31
+ // Malformed input -- allow and exit rather than blocking the worker
32
+ process.exit(0)
33
+ }
34
+
35
+ const { tool_name, tool_input } = input
36
+
37
+ // ── Context file ──────────────────────────────────────────────────────────────
38
+
39
+ const contextPath = path.join(process.cwd(), '.context.json')
40
+
41
+ // No context file = forge-native task. Heimdall does not restrict forge-native work.
42
+ if (!fs.existsSync(contextPath)) {
43
+ process.exit(0)
44
+ }
45
+
46
+ let ctx
47
+ try {
48
+ ctx = JSON.parse(fs.readFileSync(contextPath, 'utf8'))
49
+ } catch (e) {
50
+ // Unreadable context -- fail open to avoid blocking forge-native work
51
+ process.exit(0)
52
+ }
53
+
54
+ const {
55
+ story_id,
56
+ agent,
57
+ worktree_path,
58
+ has_db_migration,
59
+ allowed_paths,
60
+ escalation_dir,
61
+ audit_log: ctxAuditLog,
62
+ } = ctx
63
+
64
+ // ── Logging ───────────────────────────────────────────────────────────────────
65
+
66
+ const AUDIT_LOG = ctxAuditLog
67
+ || path.join(process.cwd(), '_vibe-chain-output', 'heimdall-audit.log')
68
+
69
+ const MAX_VIOLATIONS = parseInt(process.env.HEIMDALL_MAX_VIOLATIONS || '3', 10)
70
+
71
+ // Violation tracking lives in the worktree root (process.cwd())
72
+ const VIOLATION_FILE = path.join(process.cwd(), `.${story_id}.violations`)
73
+
74
+ // Escalation signal written to the inbox agent dir so the daemon detects it
75
+ const ESCALATION_DIR = escalation_dir || process.cwd()
76
+
77
+ function timestamp() {
78
+ return new Date().toISOString()
79
+ }
80
+
81
+ function audit(level, message) {
82
+ const line = `[${timestamp()}] HEIMDALL ${level} ${agent}/${story_id}: ${message}\n`
83
+ try {
84
+ const dir = path.dirname(AUDIT_LOG)
85
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
86
+ fs.appendFileSync(AUDIT_LOG, line)
87
+ } catch (_) {
88
+ // Audit log write failure must never block or crash the hook
89
+ }
90
+ }
91
+
92
+ function block(reason) {
93
+ audit('BLOCKED', reason)
94
+
95
+ // Track violations
96
+ let violations = 0
97
+ try {
98
+ if (fs.existsSync(VIOLATION_FILE)) {
99
+ violations = parseInt(fs.readFileSync(VIOLATION_FILE, 'utf8').trim(), 10) || 0
100
+ }
101
+ } catch (_) {}
102
+
103
+ violations++
104
+
105
+ try {
106
+ fs.writeFileSync(VIOLATION_FILE, String(violations))
107
+ } catch (_) {}
108
+
109
+ if (violations >= MAX_VIOLATIONS) {
110
+ // Sound the Gjallarhorn
111
+ const escalationFile = path.join(ESCALATION_DIR, `${story_id}.escalation`)
112
+ try {
113
+ fs.writeFileSync(escalationFile, JSON.stringify({
114
+ story_id,
115
+ agent,
116
+ violations,
117
+ last_reason: reason,
118
+ timestamp: timestamp(),
119
+ }, null, 2))
120
+ } catch (_) {}
121
+
122
+ audit('SOUNDED', `${violations} violations -- escalating to human review`)
123
+
124
+ console.log(
125
+ `[HEIMDALL] Action blocked: ${reason}\n` +
126
+ `[HEIMDALL] GJALLARHORN SOUNDED: ${violations} violations recorded.\n` +
127
+ `[HEIMDALL] Story ${story_id} has been escalated to human review. Stop working on this story.`
128
+ )
129
+ } else {
130
+ console.log(
131
+ `[HEIMDALL] Action blocked: ${reason}\n` +
132
+ `[HEIMDALL] Violation ${violations}/${MAX_VIOLATIONS}. Self-correct and continue.`
133
+ )
134
+ }
135
+
136
+ process.exit(2)
137
+ }
138
+
139
+ function allow(note) {
140
+ if (note) audit('ALLOWED', note)
141
+ process.exit(0)
142
+ }
143
+
144
+ // ── Path helpers ──────────────────────────────────────────────────────────────
145
+
146
+ function isOutsideAllowedPaths(targetPath) {
147
+ if (!targetPath) return false
148
+ try {
149
+ const resolved = path.resolve(targetPath)
150
+ return !allowed_paths.some(p => resolved.startsWith(path.resolve(p)))
151
+ } catch (_) {
152
+ return true // Unresolvable path -- treat as outside
153
+ }
154
+ }
155
+
156
+ // ── Bash tool checks ──────────────────────────────────────────────────────────
157
+
158
+ if (tool_name === 'Bash') {
159
+ const cmd = (tool_input && tool_input.command || '').trim()
160
+
161
+ // Destructive rm -- check if target escapes worktree
162
+ if (/rm\s+-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r/.test(cmd) && /\brm\b/.test(cmd)) {
163
+ // Extract everything after the flags as the target
164
+ const rmMatch = cmd.match(/rm\s+(?:-\S+\s+)(.+)/)
165
+ const rmTarget = rmMatch ? rmMatch[1].trim().replace(/^['"]|['"]$/g, '') : ''
166
+
167
+ const isRootTarget = /^(\/|~)/.test(rmTarget) || rmTarget === '' || rmTarget.includes('..')
168
+ const isOutside = !rmTarget || isRootTarget || isOutsideAllowedPaths(rmTarget)
169
+
170
+ if (isOutside) {
171
+ block(`destructive rm outside worktree: ${cmd}`)
172
+ }
173
+ }
174
+
175
+ // Credential access
176
+ if (/\.(env|pem|key|cert)\b/.test(cmd) && !/\.env\.(example|sample|template)/.test(cmd)) {
177
+ block(`credential file access: ${cmd}`)
178
+ }
179
+ if (/~\/\.(ssh|aws|gnupg)\b/.test(cmd)) {
180
+ block(`credential directory access: ${cmd}`)
181
+ }
182
+ // Echoing or exporting secrets (but not reading them as part of build)
183
+ if (/(echo|printf|export)\s+.*\b(TOKEN|SECRET|PASSWORD|API_KEY)\s*=/.test(cmd)) {
184
+ block(`credential assignment in shell: ${cmd}`)
185
+ }
186
+
187
+ // Dangerous git operations
188
+ if (/git\s+push\b.*--force(-with-lease)?/.test(cmd)) {
189
+ block(`force push blocked: ${cmd}`)
190
+ }
191
+ if (/git\s+reset\s+--hard\s+HEAD~[2-9]/.test(cmd)) {
192
+ block(`multi-commit hard reset blocked: ${cmd}`)
193
+ }
194
+ if (/git\s+clean\s+-[a-zA-Z]*f[a-zA-Z]*d|-[a-zA-Z]*d[a-zA-Z]*f/.test(cmd) && /\bgit\b/.test(cmd)) {
195
+ block(`git clean -fd blocked: ${cmd}`)
196
+ }
197
+ // Push to a non-origin remote (e.g. git push upstream)
198
+ if (/git\s+push\s+(?!origin\b)(\S+)/.test(cmd)) {
199
+ const remoteMatch = cmd.match(/git\s+push\s+(\S+)/)
200
+ if (remoteMatch && remoteMatch[1] !== 'origin' && !remoteMatch[1].startsWith('-')) {
201
+ block(`push to non-origin remote blocked: ${cmd}`)
202
+ }
203
+ }
204
+ // Direct push to main/master — all changes must go through PRs
205
+ if (/git\s+push\b/.test(cmd) && /\b(main|master)\b/.test(cmd)) {
206
+ block(`direct push to main/master blocked — create a feature branch and open a PR`)
207
+ }
208
+
209
+ // DB destructive operations without authorization
210
+ if (/(DROP\s+TABLE|TRUNCATE\s+TABLE)/i.test(cmd) && !has_db_migration) {
211
+ block(`DROP/TRUNCATE requires has_db_migration: true on the story`)
212
+ }
213
+ if (/DELETE\s+FROM\s+\S+\s*;?\s*$/i.test(cmd) && !/WHERE\s+/i.test(cmd)) {
214
+ block(`DELETE without WHERE clause blocked: ${cmd}`)
215
+ }
216
+
217
+ // High-risk but legitimate -- audit log only
218
+ if (/\b(npm|yarn|pnpm)\s+(install|add|ci)\b/.test(cmd)) {
219
+ allow(`dependency install: ${cmd}`)
220
+ }
221
+ if (/\b(pip|pip3)\s+install\b/.test(cmd)) {
222
+ allow(`pip install: ${cmd}`)
223
+ }
224
+ if (/\bcargo\s+(build|install)\b/.test(cmd)) {
225
+ allow(`cargo build: ${cmd}`)
226
+ }
227
+ if (/\b(curl|wget)\b/.test(cmd)) {
228
+ allow(`network request: ${cmd}`)
229
+ }
230
+ if (/git\s+commit\s+--amend/.test(cmd)) {
231
+ allow(`git commit --amend`)
232
+ }
233
+ }
234
+
235
+ // ── Write / Edit tool checks ──────────────────────────────────────────────────
236
+
237
+ if (tool_name === 'Write' || tool_name === 'Edit') {
238
+ const filePath = tool_input && (tool_input.file_path || tool_input.path) || ''
239
+
240
+ if (filePath) {
241
+ // Credential files
242
+ if (/\.(env|pem|key|cert)$/.test(filePath) && !/\.(example|sample|template)$/.test(filePath)) {
243
+ block(`write to credential file: ${filePath}`)
244
+ }
245
+
246
+ // Settings that could override Heimdall
247
+ if (/\.claude[/\\]settings(\.local)?\.json$/.test(filePath)) {
248
+ block(`write to .claude/settings blocked -- Heimdall config is daemon-managed`)
249
+ }
250
+
251
+ // Path escape check
252
+ if (isOutsideAllowedPaths(filePath)) {
253
+ block(`path escape: ${filePath} is outside allowed paths`)
254
+ }
255
+
256
+ // Pipeline output writes -- allowed but logged
257
+ if (filePath.includes('_vibe-chain-output')) {
258
+ allow(`pipeline output write: ${filePath}`)
259
+ }
260
+ }
261
+ }
262
+
263
+ // ── Default: allow ────────────────────────────────────────────────────────────
264
+
265
+ allow(null)