trackfw 7.2.0 → 7.4.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.
package/README.md CHANGED
@@ -18,6 +18,13 @@ ADR → REQ → ROADMAP → backlog / wip / blocked / done / abandoned
18
18
 
19
19
  Every piece of work traces back to a decision. Every decision links to a requirement. Every requirement lands in a roadmap. No orphan work, no undocumented choices.
20
20
 
21
+ > 🚧 **Platform support: Linux and macOS are supported. Windows support is partial.**
22
+ > The CLIs install on Windows and core governance commands run — but the generated
23
+ > guard hooks are POSIX shell scripts, and **on several agent CLIs they do not execute
24
+ > on Windows.** They are written to disk and reported as installed while never running.
25
+ > Native Windows hooks are in progress.
26
+ > **Read [Windows support (partial)](#windows-support-partial) before adopting on Windows.**
27
+
21
28
  ---
22
29
 
23
30
  ## The problem
@@ -93,9 +100,68 @@ npm install -g trackfw
93
100
  ```
94
101
 
95
102
  The npm package is pure Node.js — no compiled binary or postinstall download.
96
- It works wherever Node.js ≥ 18 is installed. Shared behavior, including the AI
103
+ It installs wherever Node.js ≥ 18 is installed. Shared behavior, including the AI
97
104
  integration lifecycle, follows the [CLI parity contract](docs/cli-parity.md).
98
105
 
106
+ > **The CLI installs on Node ≥ 18 alone — the generated guard hooks do not run on
107
+ > Node alone.** They are POSIX shell scripts and need a POSIX shell to execute. On
108
+ > Windows, see [Windows support](#windows-support-partial) before relying on them.
109
+
110
+ ### Windows support (partial)
111
+
112
+ 🚧 **Windows support is partial and in progress. Read this before adopting `trackfw`
113
+ on Windows.**
114
+
115
+ We publish a Windows binary and the npm/pip packages install on Windows. That is not
116
+ the same as the tool working end to end, and we would rather tell you where the edges
117
+ are than let you find them after adoption.
118
+
119
+ **What we know works**
120
+
121
+ - The three CLIs install (`npm install -g trackfw`, `pip install trackfw`, and the
122
+ published `trackfw_<version>_windows_amd64.tar.gz`).
123
+ - Core governance commands — `req new`, `roadmap new`, `roadmap move`, `status`,
124
+ `validate` — run, and artifacts are written with LF endings.
125
+
126
+ **What we know does not work yet**
127
+
128
+ - `scripts/install.sh` **refuses Windows**, even though we publish a Windows binary.
129
+ Install manually from the release archive for now.
130
+ - Our Windows CI still reports **known test failures**. They are mapped by root cause,
131
+ not unknown — but they are not zero.
132
+ - **Windows ARM64 is not built.** Only `windows_amd64` is published.
133
+
134
+ **Guard hooks on Windows — measured, per agent CLI**
135
+
136
+ The guard hooks are `.sh` scripts, executed by *your* AI agent CLI. Which shell that CLI
137
+ uses on Windows decides whether they run at all. We measured it:
138
+
139
+ | Agent CLI | Shell on Windows | Do the hooks run? | Basis |
140
+ |---|---|---|---|
141
+ | Gemini CLI | PowerShell, always | ❌ **No** | measured in vendor source |
142
+ | Codex CLI | PowerShell on the normal path | ❌ **No** | measured in vendor source |
143
+ | GitHub Copilot CLI | — | ❌ **No** — we populate the wrong config field | vendor docs + our code |
144
+ | Claude Code | Git Bash if installed, else PowerShell | ⚠️ **Only with Git Bash** | vendor documentation |
145
+ | Cursor · Kiro | unknown | ❓ **Unknown** | closed, undocumented |
146
+
147
+ 🔴 **This is the failure mode we care most about, because it is silent.** A guard that
148
+ never executes still reports health over something it never inspected. On the CLIs marked
149
+ ❌, `trackfw validate` will tell you the hook is installed — and it will never fire.
150
+
151
+ **Until native Windows hooks ship, do not rely on `credential_guard` or
152
+ `git_branch_guard` as an enforced control on Windows.** Treat them as documentation of
153
+ intent, not as enforcement.
154
+
155
+ We are **not** going to answer this by requiring Git Bash: it would fix one CLI out of
156
+ six and push the cost onto you. Windows hooks should run on Windows. Native hook
157
+ generation is the direction — see
158
+ [`docs/portabilidade/2026-09-05-contrato-de-execucao-de-hook-por-cli-de-agente-no-windows.md`](docs/portabilidade/2026-09-05-contrato-de-execucao-de-hook-por-cli-de-agente-no-windows.md)
159
+ for the full measurement, per CLI, with the level of certainty of each row.
160
+
161
+ **If you are on Windows**, we want your report. The Windows defects fixed so far came
162
+ from a user running the tool on real Windows 11 and measuring before reporting — open
163
+ an issue with what you measured.
164
+
99
165
  ### pip
100
166
 
101
167
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "7.2.0",
3
+ "version": "7.4.0",
4
4
  "description": "Governance CLI for AI-native software delivery and AI coding agents. Enforces traceability: ADR → REQ → ROADMAP → kanban. Native support for Codex, Claude Code, Gemini CLI, Cursor, Copilot, Windsurf, Amazon Q, OpenCode, and Kiro.",
5
5
  "keywords": [
6
6
  "cli",
@@ -6,6 +6,7 @@ const { Command } = require('commander')
6
6
  const { input } = require('@inquirer/prompts')
7
7
  const generators = require('../generators/adr')
8
8
  const { t } = require('../i18n')
9
+ const { homedir } = require('../homedir')
9
10
 
10
11
  const cmd = new Command('adr')
11
12
  cmd.description(t('adr.description'))
@@ -19,7 +20,7 @@ cmd.description(t('adr.description'))
19
20
  */
20
21
  function resolveAdrDir(scope) {
21
22
  if (scope === 'global') {
22
- return path.join(os.homedir(), '.trackfw', 'adr')
23
+ return path.join(homedir(), '.trackfw', 'adr')
23
24
  }
24
25
  if (scope === 'project') {
25
26
  return require('../config').load().adrDirs[0]
@@ -0,0 +1,520 @@
1
+ 'use strict'
2
+
3
+ // audit-surface.js — `trackfw audit-surface` command (Node.js CLI).
4
+ //
5
+ // AC16 invariant (no false positives, by construction): this module NEVER reads
6
+ // file content looking for hook-path strings. It ONLY opens the 8 exact wiring-file
7
+ // paths defined in RUNTIME_WIRING_PATH. Files like docs/cli-parity.md and
8
+ // internal/generators/agentfiles.go happen to mention those paths as strings but are
9
+ // never opened here — they live at paths not in RUNTIME_WIRING_PATH.
10
+
11
+ const { Command } = require('commander')
12
+ const { execFileSync, spawnSync } = require('child_process')
13
+ const crypto = require('crypto')
14
+ const path = require('path')
15
+
16
+ // Runtime names in canonical order (matches check-agent-hooks-parity.sh CLIS).
17
+ const CANONICAL_RUNTIMES = ['claude', 'codex', 'gemini', 'copilot', 'cursor', 'kiro', 'windsurf', 'amazonq']
18
+
19
+ const RUNTIME_WIRING_PATH = {
20
+ claude: '.claude/settings.json',
21
+ codex: '.codex/hooks.json',
22
+ gemini: '.gemini/settings.json',
23
+ copilot: '.github/hooks/trackfw-attention.json',
24
+ cursor: '.cursor/hooks.json',
25
+ kiro: '.kiro/hooks/trackfw-attention.json',
26
+ windsurf: '.windsurf/hooks.json',
27
+ amazonq: '.amazonq/cli-agents/q_cli_default.json',
28
+ }
29
+
30
+ const INSTRUCTION_FILE_PATHS = [
31
+ 'CLAUDE.md',
32
+ 'AGENTS.md',
33
+ 'GEMINI.md',
34
+ '.windsurfrules',
35
+ '.github/copilot-instructions.md',
36
+ '.amazonq/developer/guidelines.md',
37
+ '.cursor/rules/trackfw.mdc',
38
+ ]
39
+
40
+ // gitShow reads a file at a given ref via `git show <ref>:<path>`.
41
+ // Returns Buffer on success, null if not found.
42
+ function gitShow(ref, filePath, gitRoot) {
43
+ const result = spawnSync('git', ['show', `${ref}:${filePath}`], { cwd: gitRoot, maxBuffer: 10 * 1024 * 1024 })
44
+ if (result.status !== 0) return null
45
+ return result.stdout
46
+ }
47
+
48
+ // gitLsTree lists files under a directory at a given ref.
49
+ function gitLsTree(ref, dir, gitRoot) {
50
+ const result = spawnSync('git', ['ls-tree', '-r', '--name-only', ref, '--', dir], { cwd: gitRoot })
51
+ if (result.status !== 0) return []
52
+ const lines = result.stdout.toString().replace(/\n$/, '').split('\n').filter(Boolean)
53
+ return lines.sort()
54
+ }
55
+
56
+ // findGitRoot returns the git repository root.
57
+ function findGitRoot(cwd) {
58
+ const result = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: cwd || process.cwd() })
59
+ if (result.status !== 0) return null
60
+ return result.stdout.toString().trim()
61
+ }
62
+
63
+ // normalizeCommand strips known project-root env-var prefixes and outer quotes
64
+ // to produce a repo-relative script path. Returns '' if genuinely unresolvable
65
+ // (inline pipeline, -c string, builtin, etc.).
66
+ //
67
+ // Resolved forms:
68
+ // bare path: "scripts/hook.sh"
69
+ // path with args: "scripts/hook.sh --strict" → "scripts/hook.sh"
70
+ // interpreter prefix: "bash scripts/hook.sh" → "scripts/hook.sh"
71
+ // project-root prefix:"$CLAUDE_PROJECT_DIR/scripts/hook.sh" → "scripts/hook.sh"
72
+ //
73
+ // Recognised script extensions: .sh .bash .zsh .py .js .rb .pl .fish
74
+ function normalizeCommand(rawCmd) {
75
+ let cmd = rawCmd.trim()
76
+ // Strip surrounding double-quotes (Codex format).
77
+ if (cmd.startsWith('"') && cmd.endsWith('"')) {
78
+ cmd = cmd.slice(1, -1)
79
+ }
80
+ const prefixes = [
81
+ '$CLAUDE_PROJECT_DIR/',
82
+ '$GEMINI_PROJECT_DIR/',
83
+ '$(git rev-parse --show-toplevel)/',
84
+ ]
85
+ const stripPrefix = s => {
86
+ for (const p of prefixes) {
87
+ if (s.startsWith(p)) return s.slice(p.length)
88
+ }
89
+ return s
90
+ }
91
+ cmd = stripPrefix(cmd)
92
+
93
+ const scriptExts = ['.sh', '.bash', '.zsh', '.py', '.js', '.rb', '.pl', '.fish']
94
+ const hasScriptExt = s => scriptExts.some(ext => s.endsWith(ext))
95
+
96
+ // No spaces: bare path — accept if it has a recognised script extension.
97
+ if (!cmd.includes(' ')) {
98
+ return hasScriptExt(cmd) ? cmd : ''
99
+ }
100
+
101
+ // Command has spaces: interpreter prefix or path-with-arguments.
102
+ const interpreters = new Set(['bash', 'sh', 'dash', 'zsh', 'python', 'python3', 'python2', 'node', 'nodejs', 'ruby', 'perl'])
103
+ const tokens = cmd.trim().split(/\s+/)
104
+ let candidate = ''
105
+
106
+ if (interpreters.has(tokens[0])) {
107
+ for (let i = 1; i < tokens.length; i++) {
108
+ if (tokens[i] === '-c') return '' // inline script string, not a file path
109
+ if (tokens[i].startsWith('-')) continue // flags: -e, -u, -x, etc.
110
+ candidate = tokens[i]
111
+ break
112
+ }
113
+ } else {
114
+ // No interpreter: first token is the script path, rest are arguments.
115
+ candidate = tokens[0]
116
+ }
117
+
118
+ if (!candidate) return ''
119
+
120
+ // Strip project-root prefixes from candidate too.
121
+ candidate = stripPrefix(candidate)
122
+
123
+ return hasScriptExt(candidate) ? candidate : ''
124
+ }
125
+
126
+ // getSymlinkTarget checks whether path at ref is a git symlink (mode 120000).
127
+ // Returns { target, isLink } — isLink is true when it's a symlink.
128
+ function getSymlinkTarget(ref, scriptPath, gitRoot) {
129
+ const result = spawnSync('git', ['ls-tree', ref, '--', scriptPath], { cwd: gitRoot })
130
+ if (result.status !== 0 || !result.stdout || result.stdout.length === 0) {
131
+ return { target: '', isLink: false }
132
+ }
133
+ const line = result.stdout.toString()
134
+ const tabIdx = line.indexOf('\t')
135
+ if (tabIdx < 0) return { target: '', isLink: false }
136
+ const fields = line.slice(0, tabIdx).trim().split(/\s+/)
137
+ if (!fields.length || fields[0] !== '120000') return { target: '', isLink: false }
138
+ // Read the symlink target string.
139
+ const targetBuf = gitShow(ref, scriptPath, gitRoot)
140
+ if (!targetBuf) return { target: '', isLink: false }
141
+ return { target: targetBuf.toString().trim(), isLink: true }
142
+ }
143
+
144
+ // resolveScriptDigest follows the symlink chain (if any) at scriptPath with
145
+ // cycle detection and a depth limit, then returns the digest string.
146
+ //
147
+ // Marker format: "symlink-><first_target>|<outcome>" for symlink chains;
148
+ // plain "sha256:<hex>" or "not-found" for regular files.
149
+ //
150
+ // Outcomes for symlink chains:
151
+ // sha256:<hex> — resolved to real content
152
+ // not-found — final target absent at this ref
153
+ // not-supported — absolute symlink target
154
+ // circular-not-supported — cycle detected
155
+ // chain-not-supported — depth limit (8 hops) exceeded
156
+ function resolveScriptDigest(ref, scriptPath, gitRoot) {
157
+ const maxHops = 8
158
+ const visited = new Set([scriptPath])
159
+ let current = scriptPath
160
+ let firstTarget = '' // first symlink target, for the "symlink->" prefix
161
+
162
+ for (let hop = 0; hop < maxHops; hop++) {
163
+ const { target, isLink } = getSymlinkTarget(ref, current, gitRoot)
164
+ if (!isLink) {
165
+ // current is a regular file (or absent).
166
+ if (firstTarget === '') {
167
+ // No symlink in the chain: plain digest.
168
+ const buf = gitShow(ref, current, gitRoot)
169
+ return buf ? sha256hex(buf) : 'not-found'
170
+ }
171
+ // End of symlink chain.
172
+ const buf = gitShow(ref, current, gitRoot)
173
+ return buf
174
+ ? `symlink->${firstTarget}|` + sha256hex(buf)
175
+ : `symlink->${firstTarget}|not-found`
176
+ }
177
+
178
+ if (firstTarget === '') firstTarget = target
179
+
180
+ // Absolute target — cannot follow without checkout.
181
+ if (target.startsWith('/')) {
182
+ return `symlink->${firstTarget}|not-supported`
183
+ }
184
+
185
+ // Resolve relative to current's directory.
186
+ const linkDir = path.posix.dirname(current)
187
+ const resolved = path.posix.join(linkDir, target)
188
+
189
+ // Cycle detection.
190
+ if (visited.has(resolved)) {
191
+ return `symlink->${firstTarget}|circular-not-supported`
192
+ }
193
+ visited.add(resolved)
194
+ current = resolved
195
+ }
196
+
197
+ // Depth limit exceeded.
198
+ return `symlink->${firstTarget}|chain-not-supported`
199
+ }
200
+
201
+ // sha256hex computes SHA-256 of buffer content.
202
+ function sha256hex(buf) {
203
+ return 'sha256:' + crypto.createHash('sha256').update(buf).digest('hex')
204
+ }
205
+
206
+ // extractTuples parses wiring-file JSON and returns hook tuples per runtime schema.
207
+ function extractTuples(runtime, content) {
208
+ let root
209
+ try {
210
+ root = JSON.parse(content.toString())
211
+ } catch (e) {
212
+ return [{ event: 'parse-error', matcher: '', raw_command: e.message, script_path: '', script_digest: 'unresolvable' }]
213
+ }
214
+
215
+ switch (runtime) {
216
+ case 'claude':
217
+ case 'codex':
218
+ case 'amazonq':
219
+ case 'gemini':
220
+ return extractClaudeSchema(root)
221
+ case 'kiro':
222
+ return extractKiroSchema(root)
223
+ case 'copilot':
224
+ return extractCopilotSchema(root)
225
+ case 'cursor':
226
+ return extractCursorSchema(root)
227
+ case 'windsurf':
228
+ return extractWindsurfSchema(root)
229
+ default:
230
+ return []
231
+ }
232
+ }
233
+
234
+ // extractClaudeSchema: {"hooks": {"EVENT": [{"matcher":"...","hooks":[{"command":"...","type":"command"}]}]}}
235
+ function extractClaudeSchema(root) {
236
+ const hooks = root.hooks || {}
237
+ const events = Object.keys(hooks).sort()
238
+ const tuples = []
239
+ for (const event of events) {
240
+ const entries = (hooks[event] || []).slice().sort((a, b) => (a.matcher || '') < (b.matcher || '') ? -1 : (a.matcher || '') > (b.matcher || '') ? 1 : 0)
241
+ for (const entry of entries) {
242
+ for (const h of (entry.hooks || [])) {
243
+ if (h.type !== 'command') continue
244
+ tuples.push({
245
+ event,
246
+ matcher: entry.matcher || '',
247
+ raw_command: h.command || '',
248
+ script_path: normalizeCommand(h.command || ''),
249
+ script_digest: '',
250
+ })
251
+ }
252
+ }
253
+ }
254
+ return tuples
255
+ }
256
+
257
+ // extractKiroSchema: {"version":"v1","hooks":[{"trigger":"...","matcher":"...","action":{"type":"command","command":"..."}}]}
258
+ function extractKiroSchema(root) {
259
+ const hooks = (root.hooks || []).slice().sort((a, b) => {
260
+ if (a.trigger !== b.trigger) return a.trigger < b.trigger ? -1 : 1
261
+ return (a.matcher || '') < (b.matcher || '') ? -1 : (a.matcher || '') > (b.matcher || '') ? 1 : 0
262
+ })
263
+ return hooks
264
+ .filter(h => h.action && h.action.type === 'command')
265
+ .map(h => ({
266
+ event: h.trigger,
267
+ matcher: h.matcher || '',
268
+ raw_command: h.action.command || '',
269
+ script_path: normalizeCommand(h.action.command || ''),
270
+ script_digest: '',
271
+ }))
272
+ }
273
+
274
+ // extractCopilotSchema: {"version":1,"hooks":{"preToolUse":[{"type":"command","bash":"...","matcher":"..."}],...}}
275
+ function extractCopilotSchema(root) {
276
+ const hooks = root.hooks || {}
277
+ const events = Object.keys(hooks).sort()
278
+ const tuples = []
279
+ for (const event of events) {
280
+ const entries = (hooks[event] || []).slice().sort((a, b) => {
281
+ const ma = a.matcher || '', mb = b.matcher || ''
282
+ if (ma !== mb) return ma < mb ? -1 : 1
283
+ return (a.bash || '') < (b.bash || '') ? -1 : (a.bash || '') > (b.bash || '') ? 1 : 0
284
+ })
285
+ for (const entry of entries) {
286
+ if (entry.type !== 'command') continue
287
+ tuples.push({
288
+ event,
289
+ matcher: entry.matcher || '',
290
+ raw_command: entry.bash || '',
291
+ script_path: normalizeCommand(entry.bash || ''),
292
+ script_digest: '',
293
+ })
294
+ }
295
+ }
296
+ return tuples
297
+ }
298
+
299
+ // extractCursorSchema: {"hooks":{"preToolUse":[{"command":"...","matcher":"..."}],...}}
300
+ function extractCursorSchema(root) {
301
+ const hooks = root.hooks || {}
302
+ const events = Object.keys(hooks).sort()
303
+ const tuples = []
304
+ for (const event of events) {
305
+ const entries = (hooks[event] || []).slice().sort((a, b) => (a.command || '') < (b.command || '') ? -1 : 1)
306
+ for (const entry of entries) {
307
+ if (!entry.command) continue
308
+ tuples.push({
309
+ event,
310
+ matcher: entry.matcher || '',
311
+ raw_command: entry.command,
312
+ script_path: normalizeCommand(entry.command),
313
+ script_digest: '',
314
+ })
315
+ }
316
+ }
317
+ return tuples
318
+ }
319
+
320
+ // extractWindsurfSchema: {"hooks":{"pre_run_command":[{"command":"...","show_output":true}]}}
321
+ function extractWindsurfSchema(root) {
322
+ const hooks = root.hooks || {}
323
+ const events = Object.keys(hooks).sort()
324
+ const tuples = []
325
+ for (const event of events) {
326
+ const entries = (hooks[event] || []).slice().sort((a, b) => (a.command || '') < (b.command || '') ? -1 : 1)
327
+ for (const entry of entries) {
328
+ if (!entry.command) continue
329
+ tuples.push({
330
+ event,
331
+ matcher: '*',
332
+ raw_command: entry.command,
333
+ script_path: normalizeCommand(entry.command),
334
+ script_digest: '',
335
+ })
336
+ }
337
+ }
338
+ return tuples
339
+ }
340
+
341
+ // auditLifecycleHooks checks npm lifecycle hooks, .husky/pre-commit, and .vscode/tasks.json.
342
+ function auditLifecycleHooks(ref, gitRoot) {
343
+ const hooks = []
344
+
345
+ // npm lifecycle hooks: discover package.json by trying candidate paths in order.
346
+ const npmCandidates = ['package.json', 'npm/package.json']
347
+ let npmFound = null
348
+ let npmContent = null
349
+ for (const c of npmCandidates) {
350
+ const content = gitShow(ref, c, gitRoot)
351
+ if (content) { npmFound = c; npmContent = content; break }
352
+ }
353
+
354
+ if (npmFound) {
355
+ let pkg = {}
356
+ try { pkg = JSON.parse(npmContent.toString()) } catch (_) {}
357
+ const scripts = pkg.scripts || {}
358
+ for (const key of ['preinstall', 'postinstall', 'prepare']) {
359
+ if (key in scripts) {
360
+ hooks.push({ file: npmFound, key, command: scripts[key], present: true })
361
+ } else {
362
+ hooks.push({ file: npmFound, key, present: false })
363
+ }
364
+ }
365
+ } else {
366
+ for (const key of ['preinstall', 'postinstall', 'prepare']) {
367
+ hooks.push({ file: 'package.json', key, present: false })
368
+ }
369
+ }
370
+
371
+ const huskyContent = gitShow(ref, '.husky/pre-commit', gitRoot)
372
+ if (huskyContent) {
373
+ const cmd = extractHuskyCommand(huskyContent.toString())
374
+ hooks.push({ file: '.husky/pre-commit', key: 'pre-commit', command: cmd, present: true })
375
+ } else {
376
+ hooks.push({ file: '.husky/pre-commit', key: 'pre-commit', present: false })
377
+ }
378
+
379
+ // .vscode/tasks.json — presence/absence only (AC13 pattern: absence is information).
380
+ const vsContent = gitShow(ref, '.vscode/tasks.json', gitRoot)
381
+ hooks.push({ file: '.vscode/tasks.json', key: 'tasks', present: vsContent !== null })
382
+ return hooks
383
+ }
384
+
385
+ function extractHuskyCommand(content) {
386
+ for (const line of content.split('\n')) {
387
+ const t = line.trim()
388
+ if (!t || t.startsWith('#')) continue
389
+ return t
390
+ }
391
+ return ''
392
+ }
393
+
394
+ // runAuditSurface performs the full audit and returns a report object.
395
+ function runAuditSurface(ref, base, gitRoot) {
396
+ const report = { ref, hook_wiring: [], instruction_files: [], lifecycle_hooks: [] }
397
+ if (base) report.base = base
398
+
399
+ // 1. Hook wiring — 8 runtimes in canonical order.
400
+ for (const runtime of CANONICAL_RUNTIMES) {
401
+ const wiringFile = RUNTIME_WIRING_PATH[runtime]
402
+ const content = gitShow(ref, wiringFile, gitRoot)
403
+ if (!content) {
404
+ report.hook_wiring.push({ runtime, wiring_file: wiringFile, present: false, tuples: [] })
405
+ continue
406
+ }
407
+ const tuples = extractTuples(runtime, content)
408
+ // Compute digests for each tuple's script, following symlink chains.
409
+ for (const t of tuples) {
410
+ t.script_digest = t.script_path
411
+ ? resolveScriptDigest(ref, t.script_path, gitRoot)
412
+ : 'unresolvable'
413
+ }
414
+ report.hook_wiring.push({ runtime, wiring_file: wiringFile, present: true, tuples })
415
+ }
416
+
417
+ // 2. Instruction files (agent-config kind).
418
+ for (const p of INSTRUCTION_FILE_PATHS) {
419
+ const content = gitShow(ref, p, gitRoot)
420
+ report.instruction_files.push({ path: p, kind: 'agent-config', present: content !== null })
421
+ }
422
+
423
+ // 3. Slash commands (.claude/commands/**/*.md).
424
+ const slashFiles = gitLsTree(ref, '.claude/commands', gitRoot).filter(f => f.endsWith('.md'))
425
+ for (const f of slashFiles) {
426
+ report.instruction_files.push({ path: f, kind: 'slash-command', present: true })
427
+ }
428
+
429
+ // 4. Lifecycle hooks.
430
+ report.lifecycle_hooks = auditLifecycleHooks(ref, gitRoot)
431
+
432
+ return report
433
+ }
434
+
435
+ // tupleCount returns the total number of hook tuples across all runtimes.
436
+ function tupleCount(report) {
437
+ return report.hook_wiring.reduce((n, rr) => n + rr.tuples.length, 0)
438
+ }
439
+
440
+ // formatText renders the human-readable report.
441
+ // Format is byte-identical to Go and Python implementations.
442
+ function formatText(report) {
443
+ const n = tupleCount(report)
444
+ const lines = [`trackfw audit-surface: ${n} hook tuple(s) at ${report.ref}`, '']
445
+
446
+ for (const rr of report.hook_wiring) {
447
+ if (!rr.present) {
448
+ lines.push(`absent [${rr.runtime}] ${rr.wiring_file}`)
449
+ continue
450
+ }
451
+ if (rr.tuples.length === 0) {
452
+ lines.push(`no_hooks [${rr.runtime}] ${rr.wiring_file}`)
453
+ continue
454
+ }
455
+ for (const t of rr.tuples) {
456
+ const matcher = t.matcher || '*'
457
+ lines.push(`hook [${rr.runtime}] ${rr.wiring_file} ${t.event}/${matcher} ${t.raw_command} ${t.script_digest}`)
458
+ }
459
+ }
460
+
461
+ for (const f of report.instruction_files) {
462
+ if (f.kind === 'slash-command') {
463
+ if (f.present) lines.push(`slash-command ${f.path}`)
464
+ } else {
465
+ lines.push(`instruction [${f.present ? 'present' : 'absent'}] ${f.path}`)
466
+ }
467
+ }
468
+
469
+ for (const lh of report.lifecycle_hooks) {
470
+ if (lh.present) {
471
+ let line = `lifecycle [present] ${lh.file} ${lh.key}`
472
+ if (lh.command) line += ` ${lh.command}`
473
+ lines.push(line)
474
+ } else {
475
+ lines.push(`lifecycle [absent] ${lh.file} ${lh.key}`)
476
+ }
477
+ }
478
+
479
+ // Remove the trailing blank line we added if body has entries after the blank.
480
+ // If there are no body entries, remove the blank line too (match Go behavior).
481
+ const bodyStart = lines.indexOf('') + 1
482
+ if (bodyStart >= lines.length) {
483
+ // Nothing after the blank — remove it.
484
+ lines.splice(1, 1)
485
+ return lines.join('\n') + '\n'
486
+ }
487
+ return lines.join('\n') + '\n'
488
+ }
489
+
490
+ const cmd = new Command('audit-surface')
491
+ cmd.description('Report the executable surface of a git ref without checking it out')
492
+ cmd.argument('<ref>', 'git ref to audit (e.g. FETCH_HEAD, a commit hash, a branch name)')
493
+ cmd.option('--json', 'Emit report as JSON instead of text')
494
+ cmd.option('--base <base>', 'Base ref for Makefile/CI diff (optional; e.g. HEAD, main)')
495
+
496
+ cmd.action((ref, options) => {
497
+ const gitRoot = findGitRoot(process.cwd())
498
+ if (!gitRoot) {
499
+ process.stderr.write('audit-surface: not inside a git repository\n')
500
+ process.exit(1)
501
+ }
502
+
503
+ // Validate ref resolves to a commit object in this repository (F3 fix: ^{commit} rejects
504
+ // a 40-hex SHA from another repo that git would otherwise accept without ^{commit}).
505
+ const validateResult = spawnSync('git', ['rev-parse', '--verify', ref + '^{commit}'], { cwd: gitRoot })
506
+ if (validateResult.status !== 0) {
507
+ process.stderr.write(`audit-surface: ref "${ref}" does not resolve\n`)
508
+ process.exit(1)
509
+ }
510
+
511
+ const report = runAuditSurface(ref, options.base || '', gitRoot)
512
+
513
+ if (options.json) {
514
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n')
515
+ return
516
+ }
517
+ process.stdout.write(formatText(report))
518
+ })
519
+
520
+ module.exports = cmd