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 +67 -1
- package/package.json +1 -1
- package/src/commands/adr.js +2 -1
- package/src/commands/audit-surface.js +520 -0
- package/src/commands/barrier.js +400 -34
- package/src/commands/context.js +42 -36
- package/src/commands/discover.js +72 -21
- package/src/commands/doctor.js +37 -4
- package/src/commands/index.js +1 -0
- package/src/commands/init.js +2 -1
- package/src/commands/integrations.js +22 -4
- package/src/commands/req.js +2 -1
- package/src/commands/thirdparty.js +8 -2
- package/src/commands/update-harness.js +6 -3
- package/src/commands/update.js +147 -10
- package/src/config/index.js +82 -2
- package/src/generators/hooks.js +139 -6
- package/src/generators/init.js +173 -21
- package/src/generators/req.js +15 -53
- package/src/generators/roadmap.js +132 -28
- package/src/homedir.js +30 -0
- package/src/integrations/assets/agents/architect.md +5 -3
- package/src/integrations/assets/agents/security.md +3 -0
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/doctor_remote.js +224 -0
- package/src/integrations/index.js +2 -1
- package/src/integrations/manager.js +10 -2
- package/src/integrations/render.js +39 -8
- package/src/integrations/scaffold_doctor.js +395 -0
- package/src/lib/pathfmt.js +43 -0
- package/src/lib/update-engine.js +28 -3
- package/src/serve/api_board.js +11 -10
- package/src/serve/api_chain.js +13 -17
- package/src/serve/api_metrics.js +2 -8
- package/src/validator/index.js +768 -167
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
|
|
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.
|
|
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",
|
package/src/commands/adr.js
CHANGED
|
@@ -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(
|
|
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
|