Skip to content

Commit b3b3791

Browse files
committed
chore(sync): cascade fleet skills + helpers from socket-repo-template
Pulls in the latest fleet-canonical skill changes from socket-repo-template via `node scripts/sync-scaffolding.mts --fix`. Highlights of what's new from upstream: - _shared/ taxonomy: prose modules + scripts/ for shared TS helpers. - New shared docs: variant-analysis.md, compound-lessons.md, skill-authoring.md. - New shared TS: scripts/git-default-branch.mts (main → master fallback). - worktree-management/ skill (new / pr-fanout / prune modes). - scanning-quality/scans/ split: variant-analysis, insecure-defaults, differential. - guarding-paths restructured: reference.md (prose) + templates/ (.tmpl scaffolding) instead of mixed reference/. - Skill orchestrator/reference split across updating, updating-lockstep, driving-cursor-bugbot, refreshing-history, guarding-paths. - refreshing-history/run.sh → run.mts (cross-platform via @socketsecurity/lib/spawn). - CLAUDE.md fleet block: new rules for variant analysis, compound lessons, plan review, default-branch fallback, .mts-over-.sh runners. Tests skipped via DISABLE_PRECOMMIT_TEST=1 (the project's documented opt-out for sync / history operations); lint + format hooks still ran.
1 parent 2b0d559 commit b3b3791

32 files changed

Lines changed: 2513 additions & 834 deletions

File tree

.claude/hooks/cross-repo-guard/index.mts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const logger = getDefaultLogger()
4646

4747
const FLEET_REPO_NAMES = [
4848
'claude-code',
49+
'skills',
4950
'socket-addon',
5051
'socket-btm',
5152
'socket-cli',
@@ -57,6 +58,7 @@ const FLEET_REPO_NAMES = [
5758
'socket-sdxgen',
5859
'socket-stuie',
5960
'ultrathink',
61+
'vscode-socket-security',
6062
] as const
6163

6264
const FLEET_RE_FRAGMENT = FLEET_REPO_NAMES.join('|')
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# gitmodules-comment-guard
2+
3+
A **Claude Code PreToolUse hook** that blocks Edit/Write tool calls
4+
which would land a `[submodule "..."]` section in `.gitmodules`
5+
without the canonical `# <slug>-<version>` comment immediately above
6+
it.
7+
8+
## Why this rule
9+
10+
The Socket fleet's lockstep harness uses the `# slug-version` annotation
11+
to surface upstream version drift in its update reports. Without it,
12+
`pnpm run lockstep` can't tell whether a submodule pin reflects v1.0 or
13+
v3.5 of the upstream — the report is meaningless. Adding the comment
14+
costs one line; missing it silently breaks the drift surface.
15+
16+
## Conventional shape
17+
18+
```gitmodules
19+
# semver-7.7.4
20+
[submodule "packages/node-smol-builder/upstream/semver"]
21+
path = packages/node-smol-builder/upstream/semver
22+
url = https://github.com/npm/node-semver.git
23+
ignore = dirty
24+
```
25+
26+
The slug is short (no path); the version is whatever upstream tags
27+
(`v25.9.0`, `1.7.19`, `liburing-2.14`, `epochs/three_hourly/2026-02-24_21H`).
28+
29+
## What's enforced
30+
31+
- Every `[submodule "PATH"]` line must be preceded *immediately* (no
32+
blank line) by `# <slug>-<version>`.
33+
- The slug pattern is permissive: `[a-z0-9]([a-z0-9-]*[a-z0-9])?`.
34+
- The version is anything non-whitespace after the first hyphen.
35+
36+
## What's not enforced
37+
38+
- `ignore = dirty` conventional but not blocked here. (It's a
39+
parallel-Claude-sessions concern, not a build break.)
40+
- Repository URL format / branch — those don't affect lockstep.
41+
42+
## Override marker
43+
44+
For a legitimate one-off where the comment doesn't apply:
45+
46+
```gitmodules
47+
[submodule "..."] # socket-hook: allow gitmodules-no-comment
48+
```
49+
50+
Don't reach for this — fix the comment instead.
51+
52+
## Wiring
53+
54+
In `.claude/settings.json`:
55+
56+
```json
57+
{
58+
"hooks": {
59+
"PreToolUse": [
60+
{
61+
"matcher": "Edit|Write",
62+
"hooks": [
63+
{
64+
"type": "command",
65+
"command": "node .claude/hooks/gitmodules-comment-guard/index.mts"
66+
}
67+
]
68+
}
69+
]
70+
}
71+
}
72+
```
73+
74+
## Cross-fleet sync
75+
76+
This hook lives in
77+
[`socket-repo-template`](https://github.com/SocketDev/socket-repo-template/tree/main/template/.claude/hooks/gitmodules-comment-guard)
78+
and is required to be byte-identical across every fleet repo.
79+
`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
#!/usr/bin/env node
2+
// Claude Code PreToolUse hook — gitmodules-comment-guard.
3+
//
4+
// Blocks Edit/Write tool calls that introduce a `[submodule "..."]`
5+
// section into `.gitmodules` without the canonical `# <name>-<version>`
6+
// comment immediately above it. Without that comment, the harness
7+
// can't surface upstream version drift in the `lockstep` reports — the
8+
// fleet relies on this annotation to know what version each pinned
9+
// submodule represents.
10+
//
11+
// What's enforced:
12+
// - Every `[submodule "PATH"]` line must be preceded (immediately,
13+
// no blank line) by `# <slug>-<version>` where <slug> matches
14+
// `[a-z0-9]([a-z0-9-]*[a-z0-9])?` and <version> is whatever the
15+
// upstream uses (`v25.9.0`, `0.1.0`, `1.7.19`, `liburing-2.14`,
16+
// `epochs/three_hourly/2026-02-24_21H`, etc.). The version is
17+
// the part after the FIRST hyphen — we don't try to parse it
18+
// beyond "non-empty".
19+
// - `ignore = dirty` is conventional but not enforced here (it's a
20+
// parallel-Claude-sessions concern; submodule add without it is
21+
// not a build break).
22+
//
23+
// Scope:
24+
// - Fires on Edit and Write tool calls.
25+
// - Only inspects `.gitmodules` at the repo root.
26+
// - Lines marked `# socket-hook: allow gitmodules-no-comment` are
27+
// exempt for one-off legitimate cases.
28+
//
29+
// The hook fails OPEN on its own bugs (exit 0 + stderr log) so a bad
30+
// hook deploy can't brick the session.
31+
32+
import process from 'node:process'
33+
34+
const ALLOW_MARKER = '# socket-hook: allow gitmodules-no-comment'
35+
36+
// Match `[submodule "PATH"]` with PATH captured. Tolerant of
37+
// whitespace and quoting variations.
38+
const SUBMODULE_RE = /^\s*\[submodule\s+"([^"]+)"\s*\]\s*$/
39+
40+
// Match `# <slug>-<version>` where the version is whatever follows
41+
// the first hyphen. We only require: starts with `# `, contains a
42+
// hyphen, has non-empty version part.
43+
const COMMENT_RE = /^#\s+[a-z0-9]+([a-z0-9-]*[a-z0-9])?-[^\s]/
44+
45+
interface Hook {
46+
// tool_name and tool_input shape — keeping it loose because the
47+
// PreToolUse payload schema isn't versioned beyond JSON-with-body.
48+
tool_name?: string
49+
tool_input?: {
50+
file_path?: string
51+
new_string?: string
52+
content?: string
53+
}
54+
}
55+
56+
// Read newline-separated lines for analysis.
57+
function findOrphanSubmoduleSections(text: string): string[] {
58+
const lines = text.split('\n')
59+
const orphans: string[] = []
60+
for (let i = 0; i < lines.length; i++) {
61+
const line = lines[i]
62+
if (!line) continue
63+
const match = SUBMODULE_RE.exec(line)
64+
if (!match) continue
65+
// Allow marker on the [submodule] line or the line above is
66+
// a one-off escape hatch.
67+
if (line.includes(ALLOW_MARKER)) continue
68+
if (i > 0 && lines[i - 1]?.includes(ALLOW_MARKER)) continue
69+
// The previous line must be a comment matching `# <slug>-<ver>`.
70+
const prev = i > 0 ? lines[i - 1] : ''
71+
if (!prev || !COMMENT_RE.test(prev)) {
72+
orphans.push(match[1] ?? line)
73+
}
74+
}
75+
return orphans
76+
}
77+
78+
function main() {
79+
let stdin = ''
80+
process.stdin.on('data', chunk => {
81+
stdin += chunk
82+
})
83+
process.stdin.on('end', () => {
84+
let payload: Hook
85+
try {
86+
payload = JSON.parse(stdin) as Hook
87+
} catch {
88+
// Bad payload — fail open.
89+
process.exit(0)
90+
}
91+
const tool = payload.tool_name
92+
if (tool !== 'Edit' && tool !== 'Write') {
93+
process.exit(0)
94+
}
95+
const filePath = payload.tool_input?.file_path
96+
if (!filePath || !filePath.endsWith('/.gitmodules')) {
97+
process.exit(0)
98+
}
99+
// Edit gives us new_string (the replacement); Write gives us
100+
// content (the full new file). Either way, we scan the proposed
101+
// text for the orphan condition. For Edit calls the new_string
102+
// may be a fragment that doesn't contain a [submodule] header —
103+
// that's fine, the check passes.
104+
const proposed =
105+
payload.tool_input?.content ?? payload.tool_input?.new_string ?? ''
106+
const orphans = findOrphanSubmoduleSections(proposed)
107+
if (orphans.length === 0) {
108+
process.exit(0)
109+
}
110+
// Block the tool call. Exit code 2 makes Claude Code refuse and
111+
// surface the stderr to the model so it can retry.
112+
process.stderr.write(
113+
`[gitmodules-comment-guard] refusing edit: ${orphans.length} ` +
114+
`submodule section(s) lack the canonical ` +
115+
`# <slug>-<version> comment immediately above:\n` +
116+
orphans.map(o => ` [submodule "${o}"]`).join('\n') +
117+
'\n\nFix: prepend a comment line on the line BEFORE each\n' +
118+
'[submodule "..."] section. Example:\n' +
119+
'\n # semver-7.7.4\n [submodule "packages/.../upstream/semver"]\n' +
120+
'\nThe slug should be a short name (no path); the version is\n' +
121+
'whatever the upstream tags (v25.9.0, 1.7.19, liburing-2.14, etc.).\n' +
122+
'\nOne-off override: append `# socket-hook: allow gitmodules-no-comment`\n' +
123+
'to the [submodule] line.\n',
124+
)
125+
process.exit(2)
126+
})
127+
// If stdin is closed before any data, treat as empty payload.
128+
if (process.stdin.readable === false) {
129+
process.exit(0)
130+
}
131+
}
132+
133+
main()
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "hook-gitmodules-comment-guard",
3+
"private": true,
4+
"type": "module",
5+
"main": "./index.mts",
6+
"exports": {
7+
".": "./index.mts"
8+
},
9+
"scripts": {
10+
"test": "node --test test/*.test.mts"
11+
}
12+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"compilerOptions": {
3+
"declarationMap": false,
4+
"erasableSyntaxOnly": true,
5+
"module": "nodenext",
6+
"moduleResolution": "nodenext",
7+
"noEmit": true,
8+
"rewriteRelativeImportExtensions": true,
9+
"skipLibCheck": true,
10+
"sourceMap": false,
11+
"strict": true,
12+
"target": "esnext",
13+
"verbatimModuleSyntax": true
14+
}
15+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Compound lessons
2+
3+
How a fleet skill or review turns a finding into a durable rule, instead of fixing it once and forgetting.
4+
5+
## The principle
6+
7+
Each unit of engineering work should make subsequent units **easier**, not harder. A bug fix that doesn't update the rule that allowed the bug is a half-finished job: the next change in the same area will hit the same class of bug, and the cycle repeats.
8+
9+
Three places a lesson can land in this fleet:
10+
11+
| Where | When | Effect |
12+
|---|---|---|
13+
| **CLAUDE.md fleet rule** | The mistake recurs across repos or is a fleet-wide invariant | Every fleet repo inherits the rule on next sync |
14+
| **`.claude/hooks/*` block** | The mistake is mechanical and can be detected from tool input/output | Hook blocks the next attempt before the file is written |
15+
| **Skill prompt update** | The mistake is judgment-shaped (review pass missed a class of finding) | Future runs of that skill catch the variant |
16+
17+
## When to compound
18+
19+
Compound a lesson **only** when one of these is true:
20+
21+
1. **Recurrence** — the same kind of bug has now appeared 2+ times. Write down the rule that would have caught both.
22+
2. **High blast radius** — the bug shipped, broke a downstream user, or required a revert. The rule prevents the next shipping incident.
23+
3. **Drift signal** — fleet repos disagreed on the answer. The rule reconciles which answer wins.
24+
25+
Don't compound for one-off fixes that won't recur. Don't write a "lesson" doc when the lesson is just "we fixed it." The fleet rule **is** the lesson; if you can't crystallize it into a rule, the lesson isn't ready.
26+
27+
## How to compound
28+
29+
1. **Name the rule** — one sentence, imperative voice. "Never X." "Always Y."
30+
2. **Cite the incident** — one-line `**Why:**` line referencing the commit, PR, or finding. Don't write a paragraph.
31+
3. **State the application** — one-line `**How to apply:**` line saying when the rule fires.
32+
4. **Land it where it'll fire** — CLAUDE.md, hook, or skill prompt. Pick the lowest-friction surface that catches the next occurrence.
33+
34+
Skip the retrospective doc. Skip the post-mortem template. The rule is the artifact.
35+
36+
## Anti-patterns
37+
38+
- **The "lessons learned" graveyard** — a `docs/lessons/` folder where dated markdown files rot. Don't. The rule belongs in the live config that fires on the next run.
39+
- **Vague rules** — "be careful with X." Useless. If you can't write the rule as a `rg` pattern or a CLAUDE.md `🚨` line, it isn't a rule yet.
40+
- **Rules without why** — future readers can't judge edge cases without the original incident. Always cite.
41+
42+
## Source
43+
44+
Borrowed from Every Inc.'s _Compound Engineering_ playbook (https://every.to/chain-of-thought/compound-engineering-how-every-codes-with-agents). Their `/ce-compound` slash command is the verb form of this principle; we encode the same discipline as a fleet convention rather than a slash command.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* Default-branch resolution for fleet skill runners.
3+
*
4+
* Per CLAUDE.md "Default branch fallback" rule: prefer main, fall back
5+
* to master. Never hard-code one or the other — fleet repos are mostly
6+
* on main, but a few legacy / vendored repos still use master, and a
7+
* script that hard-codes main silently no-ops on those.
8+
*
9+
* Cross-platform: shells out to git via @socketsecurity/lib/spawn, which
10+
* works the same on macOS / Linux / Windows.
11+
*/
12+
import { isSpawnError, spawn } from '@socketsecurity/lib/spawn'
13+
14+
export type ResolveDefaultBranchOptions = {
15+
/** Working directory; defaults to process.cwd(). */
16+
readonly cwd?: string | undefined
17+
/** Remote name; defaults to 'origin'. */
18+
readonly remote?: string | undefined
19+
}
20+
21+
/**
22+
* Resolve the remote's default branch, preferring `main` and falling back
23+
* to `master`. Returns `'main'` as a final fallback when the remote has
24+
* neither branch (e.g., fresh clone before `git fetch`).
25+
*
26+
* Resolution order:
27+
* 1. `git symbolic-ref refs/remotes/<remote>/HEAD` — most reliable.
28+
* 2. Probe `refs/remotes/<remote>/main` — true on the vast majority of fleet repos.
29+
* 3. Probe `refs/remotes/<remote>/master` — legacy / vendored repos.
30+
* 4. Assume `main` and let the next git command fail loudly.
31+
*/
32+
export async function resolveDefaultBranch(
33+
options: ResolveDefaultBranchOptions = {},
34+
): Promise<string> {
35+
const { cwd = process.cwd(), remote = 'origin' } = options
36+
37+
// Step 1: ask the remote what its HEAD points to.
38+
try {
39+
const ref = await runGit(
40+
['symbolic-ref', '--quiet', '--short', `refs/remotes/${remote}/HEAD`],
41+
cwd,
42+
)
43+
if (ref) {
44+
// Strip the "<remote>/" prefix.
45+
return ref.startsWith(`${remote}/`) ? ref.slice(remote.length + 1) : ref
46+
}
47+
} catch {
48+
// Fall through.
49+
}
50+
51+
// Step 2 + 3: probe main, then master.
52+
for (const branch of ['main', 'master']) {
53+
if (await branchExists(branch, cwd, remote)) {
54+
return branch
55+
}
56+
}
57+
58+
// Step 4: last resort.
59+
return 'main'
60+
}
61+
62+
async function branchExists(
63+
branch: string,
64+
cwd: string,
65+
remote: string,
66+
): Promise<boolean> {
67+
try {
68+
await runGit(
69+
['show-ref', '--verify', '--quiet', `refs/remotes/${remote}/${branch}`],
70+
cwd,
71+
)
72+
return true
73+
} catch {
74+
return false
75+
}
76+
}
77+
78+
async function runGit(args: readonly string[], cwd: string): Promise<string> {
79+
try {
80+
const result = await spawn('git', args, { cwd, stdioString: true })
81+
return String(result.stdout ?? '').trim()
82+
} catch (e) {
83+
if (isSpawnError(e)) {
84+
throw e
85+
}
86+
throw e
87+
}
88+
}

0 commit comments

Comments
 (0)