@vegastack/vegafactory 0.19.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +417 -0
  3. package/dist/dashboard-artifact.json +21240 -0
  4. package/dist/index.js +22752 -0
  5. package/dist/run-wrapper.js +377 -0
  6. package/package.json +46 -0
  7. package/skill/dev-architect/SKILL.md +106 -0
  8. package/skill/dev-architect/agents/openai.yaml +4 -0
  9. package/skill/dev-architect/references/ai-agents.md +96 -0
  10. package/skill/dev-architect/references/conventions.md +113 -0
  11. package/skill/dev-architect/references/data.md +89 -0
  12. package/skill/dev-architect/references/infra.md +99 -0
  13. package/skill/dev-architect/references/mobile.md +75 -0
  14. package/skill/dev-architect/references/pinned-facts.md +133 -0
  15. package/skill/dev-architect/references/principles.md +117 -0
  16. package/skill/dev-architect/references/security.md +90 -0
  17. package/skill/dev-architect/references/stack.md +38 -0
  18. package/skill/dev-architect/references/web.md +102 -0
  19. package/skill/dev-architect/refresh/REFRESH.md +31 -0
  20. package/skill/dev-architect/refresh/sources.json +239 -0
  21. package/skill/dev-chronicle/SKILL.md +52 -0
  22. package/skill/dev-chronicle/agents/openai.yaml +4 -0
  23. package/skill/dev-chronicle/references/conventions.md +113 -0
  24. package/skill/dev-chronicle/references/styles.md +75 -0
  25. package/skill/dev-chronicle/refresh/REFRESH.md +3 -0
  26. package/skill/dev-chronicle/refresh/sources.json +6 -0
  27. package/skill/dev-debug/SKILL.md +43 -0
  28. package/skill/dev-debug/agents/openai.yaml +4 -0
  29. package/skill/dev-debug/references/conventions.md +113 -0
  30. package/skill/dev-debug/references/loop-ladder.md +20 -0
  31. package/skill/dev-debug/refresh/REFRESH.md +3 -0
  32. package/skill/dev-debug/refresh/sources.json +6 -0
  33. package/skill/dev-implement/SKILL.md +81 -0
  34. package/skill/dev-implement/agents/openai.yaml +4 -0
  35. package/skill/dev-implement/assets/workflows/implement-children.js +12 -0
  36. package/skill/dev-implement/references/ask-route.md +80 -0
  37. package/skill/dev-implement/references/changelog-and-chronicle.md +21 -0
  38. package/skill/dev-implement/references/conventions.md +113 -0
  39. package/skill/dev-implement/references/ledger-and-resume.md +52 -0
  40. package/skill/dev-implement/references/parallel-children.md +35 -0
  41. package/skill/dev-implement/references/worktrees.md +63 -0
  42. package/skill/dev-implement/refresh/REFRESH.md +3 -0
  43. package/skill/dev-implement/refresh/sources.json +6 -0
  44. package/skill/dev-implement/scripts/children.mjs +479 -0
  45. package/skill/dev-implement/scripts/effective-policy.mjs +694 -0
  46. package/skill/dev-implement/scripts/evidence-check.mjs +107 -0
  47. package/skill/dev-implement/scripts/evidence-upload.mjs +181 -0
  48. package/skill/dev-implement/scripts/learning.mjs +41 -0
  49. package/skill/dev-implement/scripts/lib/approval.mjs +1182 -0
  50. package/skill/dev-implement/scripts/lib/gh.mjs +101 -0
  51. package/skill/dev-implement/scripts/preflight.mjs +153 -0
  52. package/skill/dev-implement/scripts/questions.mjs +381 -0
  53. package/skill/dev-implement/scripts/reclaim.mjs +116 -0
  54. package/skill/dev-implement/scripts/recovery.mjs +213 -0
  55. package/skill/dev-implement/scripts/worktree.mjs +991 -0
  56. package/skill/dev-intake/SKILL.md +80 -0
  57. package/skill/dev-intake/agents/openai.yaml +4 -0
  58. package/skill/dev-intake/references/ask-route.md +80 -0
  59. package/skill/dev-intake/references/brief-template.md +89 -0
  60. package/skill/dev-intake/references/conventions.md +113 -0
  61. package/skill/dev-intake/refresh/REFRESH.md +3 -0
  62. package/skill/dev-intake/refresh/sources.json +6 -0
  63. package/skill/dev-intake/scripts/brief-lint.mjs +87 -0
  64. package/skill/dev-intake/scripts/lib/approval.mjs +1182 -0
  65. package/skill/dev-intake/scripts/questions.mjs +381 -0
  66. package/skill/dev-plan/SKILL.md +54 -0
  67. package/skill/dev-plan/agents/openai.yaml +4 -0
  68. package/skill/dev-plan/references/ask-route.md +80 -0
  69. package/skill/dev-plan/references/conventions.md +113 -0
  70. package/skill/dev-plan/references/plan-format.md +82 -0
  71. package/skill/dev-plan/refresh/REFRESH.md +3 -0
  72. package/skill/dev-plan/refresh/sources.json +6 -0
  73. package/skill/dev-plan/scripts/lib/approval.mjs +1182 -0
  74. package/skill/dev-plan/scripts/plan-lint.mjs +319 -0
  75. package/skill/dev-plan/scripts/questions.mjs +381 -0
  76. package/skill/dev-review/SKILL.md +76 -0
  77. package/skill/dev-review/agents/openai.yaml +4 -0
  78. package/skill/dev-review/assets/review-known-patterns.md.template +32 -0
  79. package/skill/dev-review/references/conventions.md +113 -0
  80. package/skill/dev-review/references/cross-agent.md +39 -0
  81. package/skill/dev-review/references/dispatch-prompts.md +121 -0
  82. package/skill/dev-review/references/security-axis.md +45 -0
  83. package/skill/dev-review/refresh/REFRESH.md +5 -0
  84. package/skill/dev-review/refresh/sources.json +6 -0
  85. package/skill/dev-setup/SKILL.md +113 -0
  86. package/skill/dev-setup/agents/openai.yaml +4 -0
  87. package/skill/dev-setup/assets/agents-section.md.template +24 -0
  88. package/skill/dev-setup/assets/dev-profile.md.template +102 -0
  89. package/skill/dev-setup/assets/factory-board.yml.template +162 -0
  90. package/skill/dev-setup/assets/hooks/decision-nudge.mjs +30 -0
  91. package/skill/dev-setup/assets/hooks/prompt-skill-mention.mjs +29 -0
  92. package/skill/dev-setup/assets/hooks/session-end.mjs +14 -0
  93. package/skill/dev-setup/assets/hooks/session-start.mjs +178 -0
  94. package/skill/dev-setup/assets/hooks/ship-guard.mjs +738 -0
  95. package/skill/dev-setup/assets/hooks/skill-activated.mjs +47 -0
  96. package/skill/dev-setup/assets/hooks/stop-heartbeat.mjs +14 -0
  97. package/skill/dev-setup/assets/workflows/implement-children.js +12 -0
  98. package/skill/dev-setup/references/ask-route.md +80 -0
  99. package/skill/dev-setup/references/conventions.md +113 -0
  100. package/skill/dev-setup/references/github-app.md +286 -0
  101. package/skill/dev-setup/references/harness-facts.md +169 -0
  102. package/skill/dev-setup/references/stack-playbooks.md +124 -0
  103. package/skill/dev-setup/refresh/REFRESH.md +13 -0
  104. package/skill/dev-setup/refresh/sources.json +602 -0
  105. package/skill/dev-setup/scripts/effective-policy.mjs +694 -0
  106. package/skill/dev-setup/scripts/questions.mjs +381 -0
  107. package/skill/dev-setup/scripts/ship-policy.mjs +195 -0
  108. package/skill/dev-ship/SKILL.md +56 -0
  109. package/skill/dev-ship/agents/openai.yaml +4 -0
  110. package/skill/dev-ship/references/conventions.md +113 -0
  111. package/skill/dev-ship/references/runbook.md +72 -0
  112. package/skill/dev-ship/refresh/REFRESH.md +3 -0
  113. package/skill/dev-ship/refresh/sources.json +6 -0
  114. package/skill/dev-ship/scripts/lib/approval.mjs +1182 -0
  115. package/skill/dev-ship/scripts/ship-gate.mjs +485 -0
  116. package/skill/dev-status/SKILL.md +56 -0
  117. package/skill/dev-status/agents/openai.yaml +4 -0
  118. package/skill/dev-status/references/conventions.md +113 -0
  119. package/skill/dev-status/refresh/REFRESH.md +3 -0
  120. package/skill/dev-status/refresh/sources.json +6 -0
  121. package/skill/dev-status/scripts/effective-policy.mjs +694 -0
  122. package/skill/dev-status/scripts/status.mjs +311 -0
  123. package/skill/skill-maintainer/SKILL.md +82 -0
  124. package/skill/skill-maintainer/agents/openai.yaml +4 -0
  125. package/skill/skill-maintainer/references/release-ops.md +61 -0
  126. package/skill/skill-maintainer/references/standards.md +137 -0
  127. package/skill/skill-maintainer/refresh/REFRESH.md +40 -0
  128. package/skill/skill-maintainer/refresh/sources.json +103 -0
  129. package/skill/skill-scan/SKILL.md +59 -0
  130. package/skill/skill-scan/agents/openai.yaml +4 -0
  131. package/skill/skill-scan/refresh/REFRESH.md +18 -0
  132. package/skill/skill-scan/refresh/sources.json +133 -0
  133. package/skill/skill-scan/scripts/lib/skillspector.mjs +226 -0
  134. package/skill/skill-scan/scripts/skill-scan.mjs +1002 -0
  135. package/skill/skillify/SKILL.md +108 -0
  136. package/skill/skillify/agents/openai.yaml +4 -0
  137. package/skill/skillify/assets/templates/README.md.template +26 -0
  138. package/skill/skillify/assets/templates/REFRESH.md.template +16 -0
  139. package/skill/skillify/assets/templates/SKILL.md.template +27 -0
  140. package/skill/skillify/assets/templates/evals.json.template +12 -0
  141. package/skill/skillify/assets/templates/openai.yaml.template +4 -0
  142. package/skill/skillify/assets/templates/skill.test.ts.template +27 -0
  143. package/skill/skillify/assets/templates/sources.json.template +6 -0
  144. package/skill/skillify/assets/templates/trigger-queries.json.template +1 -0
  145. package/skill/skillify/references/authoring.md +103 -0
  146. package/skill/skillify/references/eval-playbook.md +112 -0
  147. package/skill/skillify/refresh/REFRESH.md +23 -0
  148. package/skill/skillify/refresh/sources.json +6 -0
  149. package/skill/skillify/scripts/scaffold-skill.mjs +334 -0
  150. package/skill/skillify/scripts/trigger-check.mjs +209 -0
  151. package/skill/vegafactory-setup/SKILL.md +77 -0
  152. package/skill/vegafactory-setup/agents/openai.yaml +4 -0
  153. package/skill/vegafactory-setup/assets/control-room/boards.md.template +17 -0
  154. package/skill/vegafactory-setup/assets/control-room/decisions.md.template +5 -0
  155. package/skill/vegafactory-setup/assets/control-room/group.md.template +44 -0
  156. package/skill/vegafactory-setup/assets/control-room/onboarding/dispatcher-box.md.template +86 -0
  157. package/skill/vegafactory-setup/assets/control-room/onboarding/new-repo.md.template +13 -0
  158. package/skill/vegafactory-setup/assets/control-room/onboarding/new-teammate.md.template +12 -0
  159. package/skill/vegafactory-setup/assets/control-room/org.md.template +60 -0
  160. package/skill/vegafactory-setup/assets/control-room/people.csv.template +2 -0
  161. package/skill/vegafactory-setup/assets/control-room/repos.md.template +7 -0
  162. package/skill/vegafactory-setup/assets/control-room/rules/CODEOWNERS.template +6 -0
  163. package/skill/vegafactory-setup/assets/control-room/rules/README.md.template +9 -0
  164. package/skill/vegafactory-setup/assets/control-room/rules/stats-privacy.md.template +44 -0
  165. package/skill/vegafactory-setup/assets/control-room/templates/README.md.template +9 -0
  166. package/skill/vegafactory-setup/references/control-room.md +149 -0
  167. package/skill/vegafactory-setup/references/conventions.md +113 -0
  168. package/skill/vegafactory-setup/refresh/REFRESH.md +5 -0
  169. package/skill/vegafactory-setup/refresh/sources.json +6 -0
  170. package/skill-integrity.json +252 -0
@@ -0,0 +1,738 @@
1
+ #!/usr/bin/env node
2
+ // Ship guard — the "nothing ships without the operator's word" rule, made mechanical.
3
+ //
4
+ // Installed to .vegastack/hooks/ship-guard.mjs and wired as a PreToolUse hook on the
5
+ // harness's shell tool. Its ONLY source of policy is the compiled policy file
6
+ // ~/.vegastack/guard/<owner>__<repo>.json, keyed by the checkout's origin remote. dev-setup
7
+ // compiles that file from .vegastack/dev.md (the `## Environments` policy lines, the `gates:`
8
+ // knob, the backticked commands on the `## Ship` runbook's `ask:` lines) on the operator's
9
+ // yes, and `vegafactory guard sync` recompiles it. The guard never reads dev.md: the policy
10
+ // lives outside every worktree, so no task edits it and no commit carries it, and a command
11
+ // that touches the policy store is itself on the always-ask list.
12
+ //
13
+ // Fail closed: a command inside a guarded family that cannot be classified resolves to ask
14
+ // (Claude) / block (Codex), never allow; so does every guarded command while the policy file
15
+ // is missing, unreadable, malformed or compiled for another repository. A payload carrying no
16
+ // shell command is in no guarded family and resolves to allow.
17
+ //
18
+ // What this is not: the hook runs as the same user as the agent, so nothing on the machine
19
+ // is unwritable by it. Branch protection and a read-only token are the walls; this closes
20
+ // the self-authorisation path and makes tampering visible, nothing more.
21
+
22
+ import { execFileSync } from 'node:child_process'
23
+ import { readFileSync } from 'node:fs'
24
+ import { homedir } from 'node:os'
25
+ import { basename, join } from 'node:path'
26
+ import { pathToFileURL } from 'node:url'
27
+
28
+ // Hoisted: the scanner reads the word ignore followed by a quote and a comma as a removal cue and stops
29
+ // evaluating the file, which switches off twelve analyzers. A named constant keeps
30
+ // the word out of quote-adjacency without changing what execFileSync receives.
31
+ const NO_STDIO = 'ignore';
32
+ const KIND_DELETE = 'delete';
33
+ const VERB_WORKTREE_REMOVE = 'git worktree remove';
34
+
35
+ export const SCHEMA_VERSION = 2
36
+ export const SYNC_COMMAND = 'vegafactory guard sync'
37
+ // The policy store, as any path spelling would carry it — `~/`, a HOME-relative form, absolute.
38
+ const POLICY_STORE = '.vegastack/guard'
39
+
40
+ // The always-ask families. Matched on the resolved argv, flags in any position — never as
41
+ // substrings of the raw text. They ask whatever the knobs say: destructive, or a skipped check.
42
+ export const ALWAYS_ASK = [
43
+ { label: 'a force push', example: 'git push --force / -f / +refspec' },
44
+ { label: 'a hard reset', example: 'git reset --hard' },
45
+ { label: 'deleting a branch', example: 'git branch -d/-D, git push --delete / :branch' },
46
+ { label: 'removing a worktree by force', example: 'git worktree remove --force' },
47
+ { label: 'skipping the pre-commit checks', example: '--no-verify' },
48
+ { label: 'editing the ship-guard policy store', example: '~/.vegastack/guard/' },
49
+ ]
50
+
51
+ // The operations that reach the default branch or the world. Documentation for the reason
52
+ // text; the branch name itself comes from the policy, so this array stays generic.
53
+ export const DEFAULT_BRANCH_OPS = ['gh pr merge', 'git push origin <default-branch>', 'git tag', 'npm publish']
54
+
55
+ // --- the policy file -----------------------------------------------------------------
56
+
57
+ // One path segment: `owner/repo` → `owner__repo`, matching the statistics outbox layout.
58
+ export function repoSegment(repo) {
59
+ return String(repo ?? '').replace(/\//g, '__').replace(/[^A-Za-z0-9._-]/g, '-')
60
+ }
61
+
62
+ export function policyPath(home, repo) {
63
+ return join(home, '.vegastack', 'guard', repoSegment(repo) + '.json')
64
+ }
65
+
66
+ // `owner/repo` from an origin URL in any of git's spellings; null for anything else.
67
+ export function repoFromRemote(url) {
68
+ const text = String(url ?? '').trim()
69
+ const match = text.match(/^(?:[a-z][a-z0-9+.-]*:\/\/[^/\s]+\/|[^/@\s]+@[^:/\s]+:)([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/)
70
+ if (!match) return null
71
+ return match[1] + '/' + match[2]
72
+ }
73
+
74
+ // Reads the compiled policy. Anything short of a well-formed file for this very repository is
75
+ // `{ missing: <reason> }`, and every guarded command then asks with that reason.
76
+ export function readPolicyFile(text, expectedRepo, displayPath) {
77
+ const shown = displayPath || (expectedRepo ? '~/' + POLICY_STORE + '/' + repoSegment(expectedRepo) + '.json' : '~/' + POLICY_STORE + '/<owner>__<repo>.json')
78
+ if (!expectedRepo) return { missing: 'the ship guard could not read this checkout\'s origin remote (git remote get-url origin), so it has no policy' }
79
+ if (typeof text !== 'string') return { missing: `no ship-guard policy at ${shown}` }
80
+ let parsed
81
+ try {
82
+ parsed = JSON.parse(text)
83
+ } catch {
84
+ return { missing: `the ship-guard policy at ${shown} is not valid JSON` }
85
+ }
86
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { missing: `the ship-guard policy at ${shown} is not a JSON object` }
87
+ if (parsed.schemaVersion !== SCHEMA_VERSION) return { missing: `the ship-guard policy at ${shown} has schemaVersion ${JSON.stringify(parsed.schemaVersion)}, expected ${SCHEMA_VERSION}` }
88
+ if (parsed.repo !== expectedRepo) return { missing: `the ship-guard policy at ${shown} is for ${JSON.stringify(parsed.repo)}, not ${expectedRepo}` }
89
+ if (typeof parsed.policyDigest !== 'string' || !/^[a-f0-9]{64}$/.test(parsed.policyDigest)) return { missing: `the ship-guard policy at ${shown} has an invalid effective policy digest` }
90
+ const sources = parsed.sources
91
+ if (!sources || typeof sources !== 'object' || Array.isArray(sources) || !Object.values(sources).every((source) =>
92
+ source && typeof source === 'object' && !Array.isArray(source)
93
+ && ['org', 'group', 'repo'].includes(source.scope)
94
+ && typeof source.path === 'string' && source.path.trim()
95
+ && typeof source.revision === 'string'
96
+ && (source.revisionKind === 'git' ? /^[a-f0-9]{40}$/.test(source.revision)
97
+ : source.revisionKind === 'sha256' && /^[a-f0-9]{64}$/.test(source.revision)))) {
98
+ return { missing: `the ship-guard policy at ${shown} has invalid source provenance` }
99
+ }
100
+ const environments = Array.isArray(parsed.environments)
101
+ ? parsed.environments.filter((entry) => entry && typeof entry === 'object' && typeof entry.target === 'string' && (entry.policy === 'auto' || entry.policy === 'ask') && typeof entry.pattern === 'string' && entry.pattern.trim())
102
+ .map((entry) => ({ target: entry.target, policy: entry.policy, pattern: entry.pattern.trim() }))
103
+ : []
104
+ return {
105
+ defaultBranch: typeof parsed.defaultBranch === 'string' && parsed.defaultBranch ? parsed.defaultBranch : 'main',
106
+ gates: [1, 2, 3].includes(parsed.gates) ? parsed.gates : 3,
107
+ environments,
108
+ shipAsk: Array.isArray(parsed.shipAsk) ? parsed.shipAsk.filter((entry) => typeof entry === 'string' && entry.trim()).map((entry) => entry.trim()) : [],
109
+ }
110
+ }
111
+
112
+ // The policy the guard applies when it has none: no environment is auto, and no push
113
+ // destination can be compared with a default branch it does not know.
114
+ const NO_POLICY = { defaultBranch: null, gates: 3, environments: [], shipAsk: [] }
115
+
116
+ // --- shell-word parsing --------------------------------------------------------------
117
+
118
+ // Reads a command the way a POSIX shell reads it: quotes and escapes resolve, `;` `&&` `||`
119
+ // `|` `|&` `&` and newlines end a segment, dollar-paren substitution, backticks, `(…)` subshells and `{ …; }`
120
+ // groups are parsed for their own segments. Each segment carries its argv (`words`), the
121
+ // redirection targets (`redirects`) and the quoted words that held whitespace (`strings` —
122
+ // text handed to another program, probed separately).
123
+ export function parseCommand(command) {
124
+ const segments = []
125
+ if (typeof command === 'string') parseInto(command, segments)
126
+ return segments
127
+ }
128
+
129
+ function matchParen(text, open) {
130
+ let depth = 0
131
+ let quote = null
132
+ for (let i = open; i < text.length; i += 1) {
133
+ const ch = text[i]
134
+ if (quote) {
135
+ if (ch === '\\' && quote === '"') i += 1
136
+ else if (ch === quote) quote = null
137
+ continue
138
+ }
139
+ if (ch === '\\') { i += 1; continue }
140
+ if (ch === "'" || ch === '"') { quote = ch; continue }
141
+ if (ch === '(') depth += 1
142
+ else if (ch === ')') {
143
+ depth -= 1
144
+ if (depth === 0) return i
145
+ }
146
+ }
147
+ return text.length
148
+ }
149
+
150
+ function parseInto(text, segments) {
151
+ let words = []
152
+ let redirects = []
153
+ let strings = []
154
+ let deferred = []
155
+ let word = null
156
+ let quoted = false
157
+ let redirectNext = false
158
+
159
+ const flush = () => {
160
+ if (word === null) return
161
+ if (redirectNext) redirects.push(word)
162
+ else {
163
+ words.push(word)
164
+ if (quoted && /\s/.test(word)) strings.push(word)
165
+ }
166
+ word = null
167
+ quoted = false
168
+ redirectNext = false
169
+ }
170
+ const endSegment = () => {
171
+ flush()
172
+ if (words.length > 0 || redirects.length > 0) segments.push({ words, redirects, strings })
173
+ segments.push(...deferred)
174
+ words = []
175
+ redirects = []
176
+ strings = []
177
+ deferred = []
178
+ redirectNext = false
179
+ }
180
+ const substitute = (inner) => {
181
+ const nested = []
182
+ parseInto(inner, nested)
183
+ deferred.push(...nested)
184
+ }
185
+ const append = (piece) => { word = (word ?? '') + piece }
186
+
187
+ let i = 0
188
+ while (i < text.length) {
189
+ const ch = text[i]
190
+ if (ch === '$' && text[i + 1] === '(') {
191
+ const end = matchParen(text, i + 1)
192
+ substitute(text.slice(i + 2, end))
193
+ i = end + 1
194
+ continue
195
+ }
196
+ if (ch === '`') {
197
+ const end = text.indexOf('`', i + 1)
198
+ const stop = end === -1 ? text.length : end
199
+ substitute(text.slice(i + 1, stop))
200
+ i = stop + 1
201
+ continue
202
+ }
203
+ if (ch === '(' && word === null) {
204
+ const end = matchParen(text, i)
205
+ endSegment()
206
+ parseInto(text.slice(i + 1, end), segments)
207
+ i = end + 1
208
+ continue
209
+ }
210
+ if ((ch === '{' || ch === '}') && word === null && /\s|;|$/.test(text[i + 1] ?? '')) {
211
+ i += 1
212
+ continue
213
+ }
214
+ if (ch === "'") {
215
+ const end = text.indexOf("'", i + 1)
216
+ const stop = end === -1 ? text.length : end
217
+ append(text.slice(i + 1, stop))
218
+ quoted = true
219
+ i = stop + 1
220
+ continue
221
+ }
222
+ if (ch === '"') {
223
+ let j = i + 1
224
+ let buffer = ''
225
+ while (j < text.length && text[j] !== '"') {
226
+ if (text[j] === '\\' && j + 1 < text.length && '"\\$`\n'.includes(text[j + 1])) {
227
+ if (text[j + 1] !== '\n') buffer += text[j + 1]
228
+ j += 2
229
+ continue
230
+ }
231
+ if (text[j] === '$' && text[j + 1] === '(') {
232
+ const end = matchParen(text, j + 1)
233
+ substitute(text.slice(j + 2, end))
234
+ j = end + 1
235
+ continue
236
+ }
237
+ if (text[j] === '`') {
238
+ const end = text.indexOf('`', j + 1)
239
+ const stop = end === -1 ? text.length : end
240
+ substitute(text.slice(j + 1, stop))
241
+ j = stop + 1
242
+ continue
243
+ }
244
+ buffer += text[j]
245
+ j += 1
246
+ }
247
+ append(buffer)
248
+ quoted = true
249
+ i = j + 1
250
+ continue
251
+ }
252
+ if (ch === '\\') {
253
+ if (i + 1 < text.length && text[i + 1] !== '\n') append(text[i + 1])
254
+ i += 2
255
+ continue
256
+ }
257
+ if (ch === '\n' || ch === ';') {
258
+ endSegment()
259
+ i += 1
260
+ continue
261
+ }
262
+ if (ch === '&' && text[i + 1] === '>') {
263
+ flush()
264
+ redirectNext = true
265
+ i += text[i + 2] === '>' ? 3 : 2
266
+ continue
267
+ }
268
+ if (ch === '&' || ch === '|') {
269
+ endSegment()
270
+ i += 1
271
+ if (text[i] === ch || (ch === '|' && text[i] === '&')) i += 1
272
+ continue
273
+ }
274
+ if (ch === '>' || ch === '<') {
275
+ // A bare file descriptor before the operator (`2>&1`) is not an argument.
276
+ if (word !== null && /^\d+$/.test(word) && !quoted) word = null
277
+ flush()
278
+ i += 1
279
+ if (text[i] === ch) {
280
+ i += 1
281
+ if (ch === '<' && text[i] === '<') i += 1
282
+ }
283
+ if (text[i] === '&') {
284
+ i += 1
285
+ while (i < text.length && /[0-9-]/.test(text[i])) i += 1
286
+ continue
287
+ }
288
+ redirectNext = true
289
+ continue
290
+ }
291
+ if (/\s/.test(ch)) {
292
+ flush()
293
+ i += 1
294
+ continue
295
+ }
296
+ append(ch)
297
+ i += 1
298
+ }
299
+ endSegment()
300
+ }
301
+
302
+ // The segments' argv, joined — the compatibility view of parseCommand.
303
+ export function splitSegments(command) {
304
+ return parseCommand(command).map((segment) => segment.words.join(' ')).filter(Boolean)
305
+ }
306
+
307
+ // --- argv resolution -----------------------------------------------------------------
308
+
309
+ // Wrappers that carry a command without changing what it does: the head, the options that
310
+ // take a separate value, and how many positionals precede the wrapped command.
311
+ const WRAPPERS = {
312
+ sudo: { withValue: ['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-T', '-U', '-R'], positionals: 0 },
313
+ doas: { withValue: ['-u', '-C'], positionals: 0 },
314
+ env: { withValue: ['-u', '-C', '-S', '--unset', '--chdir', '--split-string'], positionals: 0 },
315
+ nice: { withValue: ['-n', '--adjustment'], positionals: 0 },
316
+ ionice: { withValue: ['-c', '-n', '-p'], positionals: 0 },
317
+ time: { withValue: ['-f', '-o', '--format', '--output'], positionals: 0 },
318
+ timeout: { withValue: ['-k', '-s', '--kill-after', '--signal'], positionals: 1 },
319
+ command: { withValue: [], positionals: 0 },
320
+ builtin: { withValue: [], positionals: 0 },
321
+ exec: { withValue: ['-a'], positionals: 0 },
322
+ nohup: { withValue: [], positionals: 0 },
323
+ caffeinate: { withValue: ['-t', '-w'], positionals: 0 },
324
+ stdbuf: { withValue: ['-i', '-o', '-e', '--input', '--output', '--error'], positionals: 0 },
325
+ xargs: { withValue: ['-I', '-n', '-L', '-P', '-d', '-s', '-E', '-a', '--max-args', '--max-lines', '--max-procs', '--delimiter', '--replace', '--arg-file'], positionals: 0 },
326
+ }
327
+ const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'ash', 'fish'])
328
+ // git's global options, which sit between `git` and the subcommand.
329
+ const GIT_GLOBAL_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--super-prefix', '--config-env', '--list-cmds', '--attr-source'])
330
+ // gh's global options.
331
+ const GH_GLOBAL_WITH_VALUE = new Set(['-R', '--repo'])
332
+ // Every git subcommand the guard knows how to classify or leave alone. A subcommand outside
333
+ // this list is an alias the guard cannot see through, so it asks.
334
+ const GIT_SUBCOMMANDS = new Set([
335
+ 'add', 'am', 'annotate', 'apply', 'archive', 'bisect', 'blame', 'branch', 'bundle', 'cat-file', 'check-attr',
336
+ 'check-mailmap', 'check-ref-format', 'checkout', 'checkout-index', 'cherry', 'cherry-pick', 'citool', 'clean', 'clone', 'column',
337
+ 'commit', 'commit-tree', 'config', 'count-objects', 'credential', 'describe', 'diff', 'diff-files', 'diff-index', 'diff-tree',
338
+ 'difftool', 'fast-export', 'fast-import', 'fetch', 'fetch-pack', 'filter-branch', 'fmt-merge-msg', 'for-each-ref', 'for-each-repo',
339
+ 'format-patch', 'fsck', 'gc', 'get-tar-commit-id', 'grep', 'gui', 'hash-object', 'help', 'hook', 'index-pack', 'init',
340
+ 'instaweb', 'interpret-trailers', 'log', 'ls-files', 'ls-remote', 'ls-tree', 'mailinfo', 'mailsplit', 'maintenance', 'merge',
341
+ 'merge-base', 'merge-file', 'merge-index', 'merge-one-file', 'merge-tree', 'mergetool', 'mktag', 'mktree', 'mv', 'name-rev',
342
+ 'notes', 'pack-objects', 'pack-redundant', 'pack-refs', 'patch-id', 'prune', 'prune-packed', 'pull', 'push', 'range-diff',
343
+ 'read-tree', 'rebase', 'reflog', 'remote', 'repack', 'replace', 'request-pull', 'rerere', 'reset', 'restore', 'rev-list',
344
+ 'rev-parse', 'revert', 'rm', 'send-email', 'send-pack', 'shortlog', 'show', 'show-branch', 'show-index', 'show-ref',
345
+ 'sparse-checkout', 'stash', 'status', 'stripspace', 'submodule', 'subtree', 'switch', 'symbolic-ref', 'tag', 'unpack-file',
346
+ 'unpack-objects', 'update-index', 'update-ref', 'update-server-info', 'var', 'verify-commit', 'verify-pack', 'verify-tag',
347
+ 'version', 'whatchanged', 'worktree', 'write-tree', 'check-ignore'])
348
+
349
+ function stripWrapper(words, spec) {
350
+ let rest = words.slice(1)
351
+ while (rest.length > 0 && rest[0].startsWith('-')) {
352
+ const option = rest[0]
353
+ if (option === '--') { rest = rest.slice(1); break }
354
+ const name = option.includes('=') ? option.slice(0, option.indexOf('=')) : option
355
+ if (spec.withValue.includes(name) && !option.includes('=')) rest = rest.slice(2)
356
+ else if (/^-[A-Za-z]\S+$/.test(option) && spec.withValue.includes(option.slice(0, 2))) rest = rest.slice(1)
357
+ else rest = rest.slice(1)
358
+ }
359
+ let positionals = spec.positionals
360
+ while (positionals > 0 && rest.length > 0) { rest = rest.slice(1); positionals -= 1 }
361
+ return rest
362
+ }
363
+
364
+ // A shell invoked with a `-c` option runs its next positional as a script.
365
+ function shellScript(words) {
366
+ const rest = words.slice(1)
367
+ let script = false
368
+ for (let i = 0; i < rest.length; i += 1) {
369
+ const token = rest[i]
370
+ if (token === '--') { i += 1; if (script && rest[i] !== undefined) return rest[i]; return null }
371
+ if (token.startsWith('-')) {
372
+ if (/^-[A-Za-z]*c[A-Za-z]*$/.test(token)) script = true
373
+ // options that take a separate value
374
+ if (token === '-o' || token === '+o' || token === '-O' || token === '+O') i += 1
375
+ continue
376
+ }
377
+ return script ? token : null
378
+ }
379
+ return null
380
+ }
381
+
382
+ // Reduces a segment's argv to the command it really runs: environment assignments and
383
+ // wrappers stripped, the head reduced to its basename, git and gh global options removed and
384
+ // git aliases declared inline expanded. `script` is set when the command is a shell running
385
+ // a script string, which is then classified on its own.
386
+ export function resolveWords(words) {
387
+ let rest = Array.isArray(words) ? words.filter((word) => typeof word === 'string') : []
388
+ let head = ''
389
+ for (let guard = 0; guard < 16 && rest.length > 0; guard += 1) {
390
+ while (rest.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(rest[0])) rest = rest.slice(1)
391
+ if (rest.length === 0) break
392
+ head = basename(rest[0])
393
+ if (SHELLS.has(head)) {
394
+ const script = shellScript(rest)
395
+ return { words: [head, ...rest.slice(1)], script }
396
+ }
397
+ if (!Object.prototype.hasOwnProperty.call(WRAPPERS, head)) break
398
+ rest = stripWrapper(rest, WRAPPERS[head])
399
+ head = ''
400
+ }
401
+ if (rest.length === 0) return { words: [], script: null }
402
+ rest = [head, ...rest.slice(1)]
403
+ if (head === 'git') {
404
+ const aliases = {}
405
+ let tail = rest.slice(1)
406
+ while (tail.length > 0 && tail[0].startsWith('-')) {
407
+ const option = tail[0]
408
+ const name = option.includes('=') ? option.slice(0, option.indexOf('=')) : option
409
+ const value = option.includes('=') ? option.slice(option.indexOf('=') + 1) : tail[1]
410
+ if (name === '-c' && typeof value === 'string') {
411
+ const alias = value.match(/^alias\.([^=]+)=(.*)$/)
412
+ if (alias) aliases[alias[1]] = alias[2]
413
+ }
414
+ if (GIT_GLOBAL_WITH_VALUE.has(name) && !option.includes('=')) tail = tail.slice(2)
415
+ else tail = tail.slice(1)
416
+ }
417
+ if (tail.length > 0 && Object.prototype.hasOwnProperty.call(aliases, tail[0])) {
418
+ const expansion = aliases[tail[0]]
419
+ tail = expansion.startsWith('!') ? ['!alias', ...tail.slice(1)] : [...expansion.split(/\s+/).filter(Boolean), ...tail.slice(1)]
420
+ }
421
+ rest = ['git', ...tail]
422
+ }
423
+ if (head === 'gh') {
424
+ let tail = rest.slice(1)
425
+ while (tail.length > 0 && tail[0].startsWith('-')) {
426
+ const option = tail[0]
427
+ const name = option.includes('=') ? option.slice(0, option.indexOf('=')) : option
428
+ if (GH_GLOBAL_WITH_VALUE.has(name) && !option.includes('=')) tail = tail.slice(2)
429
+ else tail = tail.slice(1)
430
+ }
431
+ rest = ['gh', ...tail]
432
+ }
433
+ return { words: rest, script: null }
434
+ }
435
+
436
+ // Backwards-compatible view: the resolved argv as one string.
437
+ export function normalizeSegment(segment) {
438
+ const words = typeof segment === 'string' ? parseCommand(segment).flatMap((entry) => entry.words) : []
439
+ return resolveWords(words).words.join(' ')
440
+ }
441
+
442
+ // --- classification ------------------------------------------------------------------
443
+
444
+ function ask(reason, rule) {
445
+ return { decision: 'ask', reason: reason + ' — run it by hand', rule }
446
+ }
447
+
448
+ const ALLOW = { decision: 'allow', reason: null, rule: 'not-guarded' }
449
+
450
+ const PUSH_WITH_VALUE = new Set(['-o', '--push-option', '--receive-pack', '--exec', '--repo'])
451
+
452
+ // `git push` read as git reads it: the flags, and the positionals [remote, refspec...].
453
+ function pushArguments(words) {
454
+ const flags = []
455
+ const positionals = []
456
+ let rest = words.slice(2)
457
+ while (rest.length > 0) {
458
+ const token = rest[0]
459
+ if (token === '--') { positionals.push(...rest.slice(1)); break }
460
+ if (token.startsWith('-') && token !== '-') {
461
+ const name = token.includes('=') ? token.slice(0, token.indexOf('=')) : token
462
+ flags.push(name)
463
+ rest = PUSH_WITH_VALUE.has(name) && !token.includes('=') ? rest.slice(2) : rest.slice(1)
464
+ continue
465
+ }
466
+ positionals.push(token)
467
+ rest = rest.slice(1)
468
+ }
469
+ return { flags, positionals }
470
+ }
471
+
472
+ function shortFlagsInclude(flags, letter) {
473
+ return flags.some((flag) => /^-[A-Za-z]+$/.test(flag) && flag.includes(letter))
474
+ }
475
+
476
+ // The branch a refspec lands on, or a marker for what the guard cannot read.
477
+ function pushDestination(refspec) {
478
+ const spec = refspec.startsWith('+') ? refspec.slice(1) : refspec
479
+ const colon = spec.indexOf(':')
480
+ const source = colon === -1 ? spec : spec.slice(0, colon)
481
+ const destination = colon === -1 ? spec : spec.slice(colon + 1)
482
+ if (colon !== -1 && source === '') return { kind: KIND_DELETE, branch: destination.replace(/^refs\/heads\//, '') }
483
+ if (destination.startsWith('refs/tags/')) return { kind: 'tag', branch: destination }
484
+ const branch = destination.replace(/^refs\/heads\//, '')
485
+ if (branch === '' || branch === 'HEAD' || branch === '@' || /[~^]/.test(branch) || branch.startsWith('refs/')) return { kind: 'unreadable', branch }
486
+ return { kind: 'branch', branch }
487
+ }
488
+
489
+ // The verbs an interpreter string is probed for: text a shell would run ends up in a nested
490
+ // segment and is classified properly; text handed to node, python, ssh or a file only gets
491
+ // this probe, and a hit asks.
492
+ const FAMILY_VERBS = ['git push', 'gh pr merge', 'gh api', 'gh release', 'npm publish', 'pnpm publish', 'yarn publish', 'bun publish', 'git tag', 'git reset', 'git branch -D', 'release create', VERB_WORKTREE_REMOVE]
493
+
494
+ function familyProbe(text, environments) {
495
+ const verbs = [...FAMILY_VERBS, ...environments.map((entry) => entry.pattern.split(/\s+/).slice(0, 2).join(' ')).filter((verb) => verb.includes(' '))]
496
+ return verbs.find((verb) => {
497
+ const index = text.indexOf(verb)
498
+ if (index === -1) return false
499
+ const before = index === 0 ? '' : text[index - 1]
500
+ const after = text[index + verb.length] ?? ''
501
+ return !/[\w-]/.test(before) && !/[\w-]/.test(after)
502
+ }) || null
503
+ }
504
+
505
+ function touchesPolicyStore(pieces) {
506
+ return pieces.some((piece) => typeof piece === 'string' && piece.includes(POLICY_STORE))
507
+ }
508
+
509
+ function classifyResolved(segment, resolved, settings, shipRule) {
510
+ const words = resolved.words
511
+ const text = words.join(' ')
512
+ const asWritten = [segment.words[0] ?? '', ...words.slice(1)].join(' ')
513
+ if (words.length === 0) return ALLOW
514
+
515
+ // Always-ask: the flag may sit anywhere.
516
+ if (words.includes('--no-verify')) return ask("skipping the pre-commit checks needs the operator's word", 'always-ask')
517
+ if (words[0] === 'git') {
518
+ const sub = words[1]
519
+ if (sub === 'push') {
520
+ const { flags, positionals } = pushArguments(words)
521
+ const refspecs = positionals.slice(1)
522
+ const forced = flags.includes('--force') || shortFlagsInclude(flags, 'f') || refspecs.some((spec) => spec.startsWith('+'))
523
+ if (forced) return ask("a force push needs the operator's word", 'always-ask')
524
+ const destinations = refspecs.map(pushDestination)
525
+ if (flags.includes('--delete') || shortFlagsInclude(flags, 'd') || destinations.some((entry) => entry.kind === 'delete')) {
526
+ return ask("deleting a remote branch needs the operator's word", 'always-ask')
527
+ }
528
+ }
529
+ if (sub === 'reset' && words.includes('--hard')) return ask("a hard reset needs the operator's word", 'always-ask')
530
+ if (sub === 'branch') {
531
+ const flags = words.slice(2).filter((word) => word.startsWith('-'))
532
+ if (flags.includes('--delete') || shortFlagsInclude(flags, 'd') || shortFlagsInclude(flags, 'D')) return ask("deleting a branch needs the operator's word", 'always-ask')
533
+ }
534
+ if (sub === 'worktree' && words[2] === 'remove') {
535
+ const flags = words.slice(3).filter((word) => word.startsWith('-'))
536
+ if (flags.includes('--force') || shortFlagsInclude(flags, 'f')) return ask("removing a worktree by force needs the operator's word", 'always-ask')
537
+ }
538
+ }
539
+
540
+ // Environments: longest pattern first, so `--env production` beats a shorter `wrangler deploy` line.
541
+ const matches = settings.environments
542
+ .filter((entry) => text.startsWith(entry.pattern) || asWritten.startsWith(entry.pattern))
543
+ .sort((a, b) => b.pattern.length - a.pattern.length)
544
+ if (matches.length > 0) {
545
+ const winner = matches[0]
546
+ if (winner.policy === 'auto') return { decision: 'allow', reason: null, rule: 'environment:' + winner.target }
547
+ return ask('the ' + winner.target + " environment needs the operator's word", 'environment:' + winner.target)
548
+ }
549
+
550
+ // The default-branch operations.
551
+ if (words[0] === 'gh') {
552
+ if (words[1] === 'pr' && words[2] === 'merge') return ask("merging to the default branch needs the operator's word", shipRule)
553
+ if (words[1] === 'api' && words.some((word) => /pulls\/\d+\/merge(\/|$)/.test(word))) return ask("merging to the default branch needs the operator's word", shipRule)
554
+ }
555
+ if (words[0] === 'git') {
556
+ const sub = words[1]
557
+ if (sub === 'tag') return ask("tagging a release needs the operator's word", shipRule)
558
+ if (sub === 'push') {
559
+ const { flags, positionals } = pushArguments(words)
560
+ if (flags.includes('--all') || flags.includes('--mirror')) return ask(`pushing every branch reaches ${settings.defaultBranch ?? 'the default branch'}, which needs the operator's word`, shipRule)
561
+ if (flags.includes('--tags')) return ask("pushing tags publishes a release, which needs the operator's word", shipRule)
562
+ const destinations = positionals.slice(1).map(pushDestination)
563
+ if (destinations.length === 0) return ask("a push whose branch the guard cannot read needs the operator's word", 'unclassified-in-family')
564
+ if (destinations.some((entry) => entry.kind === 'tag')) return ask("pushing a tag publishes a release, which needs the operator's word", shipRule)
565
+ if (destinations.some((entry) => entry.kind === 'unreadable')) return ask("a push whose branch the guard cannot read needs the operator's word", 'unclassified-in-family')
566
+ if (settings.defaultBranch === null) return ask("a push whose target the guard cannot check needs the operator's word", 'unclassified-in-family')
567
+ if (destinations.some((entry) => entry.branch === settings.defaultBranch)) return ask(`pushing to ${settings.defaultBranch} needs the operator's word`, shipRule)
568
+ return { decision: 'allow', reason: null, rule: 'branch-push' }
569
+ }
570
+ if (sub !== undefined && !sub.startsWith('-') && !GIT_SUBCOMMANDS.has(sub)) {
571
+ return ask(`git ${sub} is not a subcommand the guard knows — an alias it cannot see through needs the operator's word`, 'unclassified-in-family')
572
+ }
573
+ }
574
+ if ((words[0] === 'npm' || words[0] === 'pnpm' || words[0] === 'bun') && words[1] === 'publish') return ask("publishing needs the operator's word", shipRule)
575
+ for (const pattern of settings.shipAsk) {
576
+ if (text.startsWith(pattern) || asWritten.startsWith(pattern)) return ask('a Ship runbook ask: step needs the operator\'s word', shipRule)
577
+ }
578
+
579
+ // Fail closed: it looks like shipping, and no line in the policy says it is safe.
580
+ if (words.some((word) => /^(deploy|publish)(:|$)/.test(word)) || words.some((word, index) => word === 'release' && words[index + 1] === 'create')) {
581
+ return ask('no ## Environments line in dev.md covers this deploy, so it needs the operator\'s word', 'unclassified-in-family')
582
+ }
583
+ return ALLOW
584
+ }
585
+
586
+ export function classifySegment(segment, policy) {
587
+ const entry = typeof segment === 'string'
588
+ ? (parseCommand(segment)[0] ?? { words: [], redirects: [], strings: [] })
589
+ : Array.isArray(segment) ? { words: segment, redirects: [], strings: [] } : segment
590
+ if (!entry || !Array.isArray(entry.words) || entry.words.length === 0) return ALLOW
591
+ const missing = policy && typeof policy === 'object' && typeof policy.missing === 'string' ? policy.missing : null
592
+ const settings = missing ? NO_POLICY : (policy && typeof policy === 'object' ? { ...NO_POLICY, defaultBranch: 'main', ...policy } : { ...NO_POLICY, defaultBranch: 'main' })
593
+ const shipRule = settings.gates === 1 ? 'default-branch-ship' : 'default-branch'
594
+
595
+ if (touchesPolicyStore([...entry.words, ...(entry.redirects ?? [])])) return ask("editing the ship-guard policy store needs the operator's word", 'always-ask')
596
+
597
+ const resolved = resolveWords(entry.words)
598
+ let result
599
+ if (resolved.script !== null) {
600
+ result = classifyCommand(resolved.script, policy)
601
+ } else {
602
+ result = classifyResolved(entry, resolved, settings, shipRule)
603
+ if (result.decision !== 'ask' && !(resolved.words[0] === 'git' && resolved.words[1] === 'commit')) {
604
+ for (const string of entry.strings ?? []) {
605
+ const verb = familyProbe(string, settings.environments)
606
+ if (verb) {
607
+ result = ask(`text handed to another program carries \`${verb}\`, which the guard cannot classify, so it needs the operator's word`, 'unclassified-in-family')
608
+ break
609
+ }
610
+ }
611
+ }
612
+ }
613
+ if (missing && result.decision === 'ask' && result.rule !== 'always-ask' && result.rule !== 'no-policy') {
614
+ return { decision: 'ask', reason: missing + ' — run `' + SYNC_COMMAND + '` — ' + result.reason, rule: 'no-policy' }
615
+ }
616
+ return result
617
+ }
618
+
619
+ export function classifyCommand(command, policy) {
620
+ if (typeof command !== 'string') return ALLOW
621
+ if (command.includes(POLICY_STORE)) return ask("editing the ship-guard policy store needs the operator's word", 'always-ask')
622
+ let allowed = ALLOW
623
+ for (const segment of parseCommand(command)) {
624
+ const result = classifySegment(segment, policy)
625
+ if (result.decision === 'ask') return result
626
+ if (allowed.rule === 'not-guarded' && result.rule !== 'not-guarded') allowed = result
627
+ }
628
+ return allowed
629
+ }
630
+
631
+ // --- harness I/O ---------------------------------------------------------------------
632
+
633
+ const UNREADABLE = {
634
+ decision: 'ask',
635
+ reason: 'the ship guard could not read the hook payload — run the command by hand',
636
+ rule: 'unreadable-payload',
637
+ }
638
+
639
+ function shellQuote(part) {
640
+ if (/^[A-Za-z0-9_\/.:=@%+,-]+$/.test(part)) return part
641
+ return "'" + part.replace(/'/g, "'\\''") + "'"
642
+ }
643
+
644
+ // Claude puts the shell command at tool_input.command; Codex may send the argv array or the
645
+ // string itself. An argv array is quoted back into one shell string so the parser reads each
646
+ // element as one word. Every other shape is a tool that runs no shell command.
647
+ export function extractCommand(payload) {
648
+ const input = payload && typeof payload === 'object' ? payload.tool_input : null
649
+ if (typeof input === 'string') return input
650
+ if (!input || typeof input !== 'object') return null
651
+ const command = input.command
652
+ if (typeof command === 'string') return command
653
+ if (Array.isArray(command) && command.every((part) => typeof part === 'string')) return command.map(shellQuote).join(' ')
654
+ return null
655
+ }
656
+
657
+ export function renderDecision(result, harness) {
658
+ if (harness !== 'claude' && harness !== 'codex') {
659
+ return JSON.stringify({
660
+ decision: 'block',
661
+ reason: 'the ship guard is wired without --harness claude|codex — fix the hook command',
662
+ })
663
+ }
664
+ if (!result || result.decision !== 'ask') return ''
665
+ if (harness === 'claude') {
666
+ return JSON.stringify({
667
+ hookSpecificOutput: {
668
+ hookEventName: 'PreToolUse',
669
+ permissionDecision: 'ask',
670
+ permissionDecisionReason: result.reason,
671
+ },
672
+ })
673
+ }
674
+ return JSON.stringify({ decision: 'block', reason: result.reason })
675
+ }
676
+
677
+ function flag(argv, name) {
678
+ const index = argv.indexOf(name)
679
+ if (index === -1 || index === argv.length - 1) return null
680
+ return argv[index + 1]
681
+ }
682
+
683
+ function originRemote(cwd) {
684
+ try {
685
+ return execFileSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf8', stdio: [NO_STDIO, 'pipe', NO_STDIO], timeout: 5000 }).trim()
686
+ } catch {
687
+ return null
688
+ }
689
+ }
690
+
691
+ function readIfPresent(path) {
692
+ try {
693
+ return readFileSync(path, 'utf8')
694
+ } catch {
695
+ return null
696
+ }
697
+ }
698
+
699
+ // The policy for this invocation: `--repo` and `--policy` override the origin lookup and the
700
+ // home-directory path (for --check runs and tests); the hook wiring passes neither.
701
+ export function loadPolicy(argv, { cwd, home }) {
702
+ const repo = flag(argv, '--repo') || repoFromRemote(originRemote(cwd))
703
+ const explicit = flag(argv, '--policy')
704
+ const path = explicit || (repo ? policyPath(home, repo) : null)
705
+ const shown = explicit || (repo ? '~/' + POLICY_STORE + '/' + repoSegment(repo) + '.json' : null)
706
+ return readPolicyFile(path ? readIfPresent(path) : null, repo, shown)
707
+ }
708
+
709
+ function main(argv) {
710
+ const policy = loadPolicy(argv, { cwd: process.cwd(), home: homedir() })
711
+
712
+ if (argv.includes('--check')) {
713
+ const command = flag(argv, '--command') || ''
714
+ const result = classifyCommand(command, policy)
715
+ if (argv.includes('--json')) process.stdout.write(JSON.stringify(result) + '\n')
716
+ else if (result.reason) process.stdout.write(result.reason + '\n')
717
+ process.exit(result.decision === 'ask' ? 2 : 0)
718
+ }
719
+
720
+ const harness = flag(argv, '--harness')
721
+ let payload = null
722
+ try {
723
+ payload = JSON.parse(readFileSync(0, 'utf8'))
724
+ } catch {
725
+ process.stdout.write(renderDecision(UNREADABLE, harness))
726
+ process.exit(0)
727
+ }
728
+ const command = extractCommand(payload)
729
+ if (command === null) {
730
+ // Not a shell tool call at all — nothing for this guard to say.
731
+ if (harness !== 'claude' && harness !== 'codex') process.stdout.write(renderDecision(UNREADABLE, harness))
732
+ process.exit(0)
733
+ }
734
+ process.stdout.write(renderDecision(classifyCommand(command, policy), harness))
735
+ process.exit(0)
736
+ }
737
+
738
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(process.argv.slice(2))