@friedbotstudio/create-baseline 0.2.0 → 0.3.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 +7 -3
- package/obj/template/.claude/commands/grant-push.md +19 -0
- package/obj/template/.claude/commands/init-project.md +26 -4
- package/obj/template/.claude/hooks/consent_gate_grant.mjs +107 -0
- package/obj/template/.claude/hooks/git_commit_guard.mjs +224 -0
- package/obj/template/.claude/hooks/harness_continuation.sh +101 -34
- package/obj/template/.claude/hooks/lib/common.mjs +283 -0
- package/obj/template/.claude/hooks/lib/common.sh +1 -1
- package/obj/template/.claude/hooks/memory_session_start.sh +20 -6
- package/obj/template/.claude/hooks/memory_stop.sh +161 -2
- package/obj/template/.claude/hooks/spec_approval_guard.sh +1 -1
- package/obj/template/.claude/hooks/swarm_approval_guard.sh +1 -1
- package/obj/template/.claude/hooks/tests/fixtures/ac008_byte_equal_reference.txt +7 -7
- package/obj/template/.claude/hooks/tests/fixtures/memory_stop_landmark_baseline.txt +21 -0
- package/obj/template/.claude/hooks/tests/fixtures/regenerate-ac008.sh +47 -0
- package/obj/template/.claude/hooks/tests/memory_session_start_test.sh +7 -3
- package/obj/template/.claude/hooks/tests/memory_stop_intent_test.sh +329 -0
- package/obj/template/.claude/hooks/tests/regenerate_ac008_test.sh +99 -0
- package/obj/template/.claude/memory/README.md +8 -3
- package/obj/template/.claude/memory/backlog.md +12 -0
- package/obj/template/.claude/project.json +6 -1
- package/obj/template/.claude/settings.json +3 -4
- package/obj/template/.claude/skills/audit-baseline/audit.sh +39 -21
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/_pending_opener_only.md +3 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_empty_body.md +4 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_with_entries.md +9 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_no_opener.md +3 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_opener_only.md +3 -0
- package/obj/template/.claude/skills/audit-baseline/tests/preamble_check_test.sh +147 -0
- package/obj/template/.claude/skills/chore/SKILL.md +5 -3
- package/obj/template/.claude/skills/commit/SKILL.md +5 -4
- package/obj/template/.claude/skills/copywriting/LICENSE +21 -0
- package/obj/template/.claude/skills/copywriting/NOTICE +23 -0
- package/obj/template/.claude/skills/copywriting/SKILL.md +1 -1
- package/obj/template/.claude/skills/design-ui/SKILL.md +23 -5
- package/obj/template/.claude/skills/design-ui/references/design-vs-development.md +26 -5
- package/obj/template/.claude/skills/design-ui/references/orchestration.md +1 -0
- package/obj/template/.claude/skills/design-ui/references/state-machine.md +5 -3
- package/obj/template/.claude/skills/documentation/LICENSE +202 -0
- package/obj/template/.claude/skills/documentation/NOTICE +22 -0
- package/obj/template/.claude/skills/google-analytics/SKILL.md +129 -0
- package/obj/template/.claude/skills/google-analytics/references/audiences.md +389 -0
- package/obj/template/.claude/skills/google-analytics/references/bigquery.md +470 -0
- package/obj/template/.claude/skills/google-analytics/references/custom-dimensions.md +355 -0
- package/obj/template/.claude/skills/google-analytics/references/custom-events.md +383 -0
- package/obj/template/.claude/skills/google-analytics/references/data-management.md +416 -0
- package/obj/template/.claude/skills/google-analytics/references/debugview.md +364 -0
- package/obj/template/.claude/skills/google-analytics/references/events-fundamentals.md +398 -0
- package/obj/template/.claude/skills/google-analytics/references/gtag.md +502 -0
- package/obj/template/.claude/skills/google-analytics/references/gtm-integration.md +483 -0
- package/obj/template/.claude/skills/google-analytics/references/measurement-protocol.md +519 -0
- package/obj/template/.claude/skills/google-analytics/references/privacy.md +441 -0
- package/obj/template/.claude/skills/google-analytics/references/recommended-events.md +464 -0
- package/obj/template/.claude/skills/google-analytics/references/reporting.md +397 -0
- package/obj/template/.claude/skills/google-analytics/references/setup.md +344 -0
- package/obj/template/.claude/skills/google-analytics/references/user-tracking.md +417 -0
- package/obj/template/.claude/skills/harness/SKILL.md +3 -1
- package/obj/template/.claude/skills/humanizer/LICENSE +21 -0
- package/obj/template/.claude/skills/humanizer/NOTICE +21 -0
- package/obj/template/.claude/skills/impeccable/LICENSE +202 -0
- package/obj/template/.claude/skills/impeccable/NOTICE +24 -0
- package/obj/template/.claude/skills/memory-flush/SKILL.md +20 -4
- package/obj/template/.claude/skills/memory-flush/sweep.py +74 -6
- package/obj/template/.claude/skills/memory-flush/tests/run.sh +300 -1
- package/obj/template/.claude/skills/optimize-seo/SKILL.md +313 -0
- package/obj/template/.claude/skills/optimize-seo/scripts/pagespeed.mjs +197 -0
- package/obj/template/.claude/skills/pagespeed-insights/LICENSE.md +37 -0
- package/obj/template/.claude/skills/pagespeed-insights/SKILL.md +446 -0
- package/obj/template/.claude/skills/pagespeed-insights/reference.md +50 -0
- package/obj/template/.claude/skills/tdd/SKILL.md +2 -1
- package/obj/template/.claude/skills/tdd/drift_check.py +180 -0
- package/obj/template/.claude/skills/tdd/tests/drift_check_test.sh +190 -0
- package/obj/template/.claude/skills/tdd/tests/run.sh +21 -0
- package/obj/template/.claude/skills/technical-tutorials/LICENSE +21 -0
- package/obj/template/.claude/skills/technical-tutorials/NOTICE +23 -0
- package/obj/template/.claude/skills/technical-tutorials/SKILL.md +1 -1
- package/obj/template/.claude/skills/triage/SKILL.md +8 -3
- package/obj/template/CLAUDE.md +37 -26
- package/obj/template/docs/init/seed.md +38 -23
- package/obj/template/manifest.json +80 -33
- package/package.json +1 -1
- package/src/CLAUDE.template.md +37 -26
- package/src/memory/backlog.template.md +12 -0
- package/src/project.template.json +6 -1
- package/src/seed.template.md +38 -23
- package/src/settings.template.json +3 -4
- package/obj/template/.claude/hooks/consent_gate_grant.sh +0 -89
- package/obj/template/.claude/hooks/git_commit_guard.sh +0 -93
package/README.md
CHANGED
|
@@ -64,7 +64,7 @@ A team that installs the baseline stops typing *"don't push, don't `--amend`, do
|
|
|
64
64
|
| **Hooks** at PreToolUse / PostToolUse / SessionStart / Stop / PreCompact / UserPromptSubmit | 22 | `.claude/hooks/` |
|
|
65
65
|
| **Skills** across artifact drafting, workflow phases, phase workers, spec helpers, orchestration, memory, audit, alternate tracks, and shared globals | 36 | `.claude/skills/` |
|
|
66
66
|
| **Subagent** — `swarm-worker`, executes pre-decided recipes inside isolated git worktrees | 1 | `.claude/agents/` |
|
|
67
|
-
| **Workflow phases** — intake → scout → research → spec → tdd → simplify → security → integrate → document → archive → commit | 11 | enforced by `track_guard` |
|
|
67
|
+
| **Workflow phases** — intake → scout → research → spec → tdd → simplify → security → integrate → document → archive → memory-flush → commit | 11 | enforced by `track_guard` |
|
|
68
68
|
| **Consent gates** — `/approve-spec`, `/approve-swarm`, `/grant-commit`. User-typed; structurally un-invokable by Claude | 3 | `consent_gate_grant` UserPromptSubmit hook |
|
|
69
69
|
| **MCP servers** declared in `.mcp.json` — `context7` (third-party API docs), `plantuml` (diagram render), `playwright` (cross-engine smoke) | 3 | `.mcp.json` |
|
|
70
70
|
|
|
@@ -148,13 +148,17 @@ cd ./your-project
|
|
|
148
148
|
/harness
|
|
149
149
|
```
|
|
150
150
|
|
|
151
|
-
The three consent gates pause the workflow until you type the corresponding command:
|
|
151
|
+
The three workflow-phase consent gates pause the workflow until you type the corresponding command:
|
|
152
152
|
|
|
153
153
|
- **`/approve-spec <slug>`** — after the spec phase, before any code is written
|
|
154
154
|
- **`/approve-swarm <slug>`** — after `/swarm-plan`, before parallel dispatch
|
|
155
155
|
- **`/grant-commit`** — after `/archive`, before the commit lands
|
|
156
156
|
|
|
157
|
-
|
|
157
|
+
A fourth consent gate sits outside the phase pipeline:
|
|
158
|
+
|
|
159
|
+
- **`/grant-push`** — opens a 5-minute window for `git push` on a protected branch (per `project.json → git.protected_branches`). Pushes on non-protected branches need no consent.
|
|
160
|
+
|
|
161
|
+
Each gate writes a short-lived consent marker via a UserPromptSubmit hook that runs *before* Claude is invoked on the body. Claude cannot forge the marker; the write-boundary guard validates it on disk before allowing the approval token through.
|
|
158
162
|
|
|
159
163
|
## How the enforcement works
|
|
160
164
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Grant consent for Claude to run `git push`. Valid for 5 minutes. Required by the Git Commit Guard hook on protected branches.
|
|
3
|
+
argument-hint: "[optional note]"
|
|
4
|
+
allowed-tools: Bash(mkdir:*), Bash(date:*), Bash(tee:*), Bash(git:*), Write
|
|
5
|
+
disable-model-invocation: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Write a consent token to `.claude/state/push_consent` so the Git Commit Guard hook allows the next `git push` on a protected branch. The token is the current UNIX epoch timestamp on line 1; any optional note goes on line 2.
|
|
9
|
+
|
|
10
|
+
How this works structurally: when the user typed `/grant-push`, the `consent_gate_grant` UserPromptSubmit hook ran *before* this body was passed to Claude and wrote a short-lived consent marker at `.claude/state/.push_consent_grant`. The `git_commit_guard` PreToolUse hook (Write matcher) reads that marker and allows Claude to write the consent file because the marker is fresh. Claude cannot forge the marker — that's what makes the gate structural. The Bash-matcher leg of the same guard then enforces the consent token on the actual `git push` invocation, but only when the current branch matches `project.json → git.protected_branches`.
|
|
11
|
+
|
|
12
|
+
Steps:
|
|
13
|
+
|
|
14
|
+
1. **Git-repo precheck.** Run `git rev-parse --is-inside-work-tree 2>/dev/null`. If the exit status is non-zero, this project is not a git repository: refuse to write the consent token and tell the user "Not a git repository — `/grant-push` is inapplicable. Push has no meaning outside a git repo." Stop here.
|
|
15
|
+
2. Run `date +%s` to get the current epoch.
|
|
16
|
+
3. Write the epoch (and the optional note `$ARGUMENTS` on line 2 if non-empty) to `.claude/state/push_consent`, overwriting any prior token.
|
|
17
|
+
4. Confirm to the user: "Push consent granted at <epoch>, valid for 300s (until <HH:MM:SS local>). The next `git push` on a protected branch will be allowed. Pushes on branches NOT in `project.json → git.protected_branches` do not require this consent."
|
|
18
|
+
|
|
19
|
+
Do not run `git push` yourself in this command. The user asks explicitly when they want a push; this command only opens the window.
|
|
@@ -63,7 +63,9 @@ The recommender's SKILL.md instructs it to:
|
|
|
63
63
|
|
|
64
64
|
Capture both the narrative and the JSON. Save the JSON to `.claude/state/init/<timestamp>.recommender.json`.
|
|
65
65
|
|
|
66
|
-
## Step 5 — Aggregate + present
|
|
66
|
+
## Step 5 — Aggregate + present (REVIEW ONLY — NOTHING WRITTEN YET)
|
|
67
|
+
|
|
68
|
+
**This step is a proposal, not configuration.** Nothing has been written to disk yet: `.claude/project.json` still reads `configured: false`, no new skills/hooks/MCPs are wired, and the `swarm-worker` agent file has not been re-rendered. The user is reading a *proposal* that takes effect only when they explicitly approve in this step.
|
|
67
69
|
|
|
68
70
|
Show the user one review surface before writing anything:
|
|
69
71
|
|
|
@@ -77,13 +79,32 @@ Show the user one review surface before writing anything:
|
|
|
77
79
|
3. **Recommender additions** (from JSON `additions`): MCP servers, skills, hooks, and any `swarm_worker_skills` to preload — name + reason for each.
|
|
78
80
|
4. **Gaps flagged** (from JSON `gaps`): things the baseline doesn't cover but might warrant a future spec.
|
|
79
81
|
|
|
80
|
-
|
|
82
|
+
After presenting the four blocks, **explicitly tell the user the project is NOT yet configured**. Print this exact block above the confirmation prompt:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
⚠ The baseline is still in PROJECT-AGNOSTIC MODE.
|
|
81
86
|
|
|
82
|
-
|
|
87
|
+
None of the proposal above has been applied. `project.json → configured`
|
|
88
|
+
is still `false`. test_runner / lint_runner are still in guide mode.
|
|
89
|
+
Closing this session now leaves the project unconfigured.
|
|
90
|
+
|
|
91
|
+
The next prompt is an action gate. You must explicitly approve to proceed
|
|
92
|
+
to Step 6 (apply) — otherwise nothing changes.
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Use `AskUserQuestion` to confirm. The question SHALL be a full sentence that names the un-configured state explicitly — not "Apply these changes?" alone, but: **"The project is NOT yet configured. Proceed to apply this proposal and finish setup?"** Options:
|
|
96
|
+
|
|
97
|
+
- `Proceed and apply` — advances to Step 6.
|
|
98
|
+
- `Edit before applying` — take the user's adjustments inline, re-show the surface, ask again.
|
|
99
|
+
- `Cancel — leave project unconfigured` — exit without writing; `configured` stays `false`; surface that the project remains in project-agnostic mode and `/init-project` can be re-run later.
|
|
100
|
+
|
|
101
|
+
If `Edit before applying`: take the user's adjustments inline, re-show the surface, **ask the same gate again**. Do not silently apply — the gate fires after every edit cycle until the user picks `Proceed and apply` or `Cancel`.
|
|
83
102
|
|
|
84
103
|
## Step 6 — Apply
|
|
85
104
|
|
|
86
|
-
Write to disk now.
|
|
105
|
+
Write to disk now. **This is the first step in the protocol that mutates files in the user's project** — until this step runs, `.claude/project.json` still reads `configured: false` and the baseline stays in project-agnostic mode. Reaching this step means the user explicitly picked `Proceed and apply` at Step 5's gate.
|
|
106
|
+
|
|
107
|
+
Do each sub-step in order; if any fails, stop and surface the error before continuing:
|
|
87
108
|
|
|
88
109
|
1. **Pre-create lazy directories**:
|
|
89
110
|
```bash
|
|
@@ -186,6 +207,7 @@ Print a final summary:
|
|
|
186
207
|
## Constraints
|
|
187
208
|
|
|
188
209
|
- **Steps 6 + 7 + 8 are atomic for the user.** If Step 8 fails, do not declare success at Step 9.
|
|
210
|
+
- **Step 5 is review, not setup.** Until the user explicitly picks `Proceed and apply` at Step 5's gate, the project remains in project-agnostic mode. Surfacing recommendations is not configuration; the gate prompt SHALL name the un-configured state in a full sentence so a skimming reader cannot mistake the proposal for a completion notice.
|
|
189
211
|
- **Never write `configured: true` before Step 8 passes.** A FAIL at Step 8 means the project is in a broken state; leaving `configured: true` would lie to `setup_guard` and the welcome hook in CLAUDE.md.
|
|
190
212
|
- **No silent decisions.** Every project-specific change appears in seed.md §16 so the next reader can see what diverged from baseline.
|
|
191
213
|
- **Idempotent.** Re-running on the same project produces the same §16 (modulo timestamp + run number) and passes `/audit-baseline` cleanly.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Consent Gate Grant — UserPromptSubmit
|
|
3
|
+
//
|
|
4
|
+
// JS port of consent_gate_grant.sh, adding a fourth arm for /grant-push.
|
|
5
|
+
//
|
|
6
|
+
// When the user types one of /approve-spec, /approve-swarm, /grant-commit,
|
|
7
|
+
// /grant-push, this hook fires BEFORE Claude is invoked. It writes a
|
|
8
|
+
// short-lived consent marker at .claude/state/.<gate>_grant.
|
|
9
|
+
//
|
|
10
|
+
// The marker is what makes the corresponding approval-token write succeed:
|
|
11
|
+
// the gate-specific PreToolUse guard (spec_approval_guard, swarm_approval_guard,
|
|
12
|
+
// git_commit_guard) reads the marker and allows Claude's write only if a
|
|
13
|
+
// fresh, slug-matched marker is on disk.
|
|
14
|
+
//
|
|
15
|
+
// Why the marker is unforgeable by Claude:
|
|
16
|
+
// - This hook runs on UserPromptSubmit, OUTSIDE Claude's tool boundary.
|
|
17
|
+
// - The PreToolUse guards block Claude from writing the marker file.
|
|
18
|
+
// - Markers expire after consent.gate_marker_ttl_seconds (default 120).
|
|
19
|
+
//
|
|
20
|
+
// Marker shapes:
|
|
21
|
+
// .spec_approval_grant line 1: slug · line 2: epoch · line 3: abs spec path
|
|
22
|
+
// .swarm_approval_grant line 1: slug · line 2: epoch
|
|
23
|
+
// .commit_consent_grant line 1: epoch · line 2: optional note
|
|
24
|
+
// .push_consent_grant line 1: epoch · line 2: optional note (NEW)
|
|
25
|
+
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import {
|
|
28
|
+
readPayload,
|
|
29
|
+
payloadGet,
|
|
30
|
+
canonicalSlug,
|
|
31
|
+
writeMarkerAtomic,
|
|
32
|
+
logLine,
|
|
33
|
+
CLAUDE_PROJECT_ROOT,
|
|
34
|
+
CONSENT_MARKER_SPEC,
|
|
35
|
+
CONSENT_MARKER_SWARM,
|
|
36
|
+
CONSENT_MARKER_COMMIT,
|
|
37
|
+
CONSENT_MARKER_PUSH,
|
|
38
|
+
} from './lib/common.mjs';
|
|
39
|
+
|
|
40
|
+
const HOOK = 'consent_gate_grant';
|
|
41
|
+
|
|
42
|
+
async function main() {
|
|
43
|
+
// Fast-path: rule out 99% of prompts before any regex parsing.
|
|
44
|
+
const payload = await readPayload();
|
|
45
|
+
const prompt = payloadGet(payload, '.prompt');
|
|
46
|
+
if (typeof prompt !== 'string' || prompt.length === 0) return;
|
|
47
|
+
if (!/\/(approve-spec|approve-swarm|grant-commit|grant-push)/.test(prompt)) return;
|
|
48
|
+
|
|
49
|
+
const firstLine = prompt.split(/\r?\n/)[0].trim();
|
|
50
|
+
const now = Math.floor(Date.now() / 1000);
|
|
51
|
+
|
|
52
|
+
let m;
|
|
53
|
+
|
|
54
|
+
m = firstLine.match(/^\/approve-spec\s+(\S+)/);
|
|
55
|
+
if (m) {
|
|
56
|
+
const arg = m[1];
|
|
57
|
+
const slug = canonicalSlug(arg);
|
|
58
|
+
let absPath;
|
|
59
|
+
if (arg.startsWith('/')) absPath = arg;
|
|
60
|
+
else if (arg.includes('/')) absPath = join(CLAUDE_PROJECT_ROOT, arg);
|
|
61
|
+
else absPath = join(CLAUDE_PROJECT_ROOT, 'docs', 'specs', `${slug}.md`);
|
|
62
|
+
if (writeMarkerAtomic(CONSENT_MARKER_SPEC, slug, String(now), absPath)) {
|
|
63
|
+
logLine(HOOK, `wrote spec_approval_grant slug=${slug} path=${absPath}`);
|
|
64
|
+
} else {
|
|
65
|
+
logLine(HOOK, `FAILED write spec_approval_grant slug=${slug}`);
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
m = firstLine.match(/^\/approve-swarm\s+(\S+)/);
|
|
71
|
+
if (m) {
|
|
72
|
+
const slug = canonicalSlug(m[1]);
|
|
73
|
+
if (writeMarkerAtomic(CONSENT_MARKER_SWARM, slug, String(now))) {
|
|
74
|
+
logLine(HOOK, `wrote swarm_approval_grant slug=${slug}`);
|
|
75
|
+
} else {
|
|
76
|
+
logLine(HOOK, `FAILED write swarm_approval_grant slug=${slug}`);
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
m = firstLine.match(/^\/grant-commit(\s.*)?$/);
|
|
82
|
+
if (m) {
|
|
83
|
+
const note = (m[1] || '').trim();
|
|
84
|
+
if (writeMarkerAtomic(CONSENT_MARKER_COMMIT, String(now), note)) {
|
|
85
|
+
logLine(HOOK, `wrote commit_consent_grant note=${note}`);
|
|
86
|
+
} else {
|
|
87
|
+
logLine(HOOK, `FAILED write commit_consent_grant`);
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
m = firstLine.match(/^\/grant-push(\s.*)?$/);
|
|
93
|
+
if (m) {
|
|
94
|
+
const note = (m[1] || '').trim();
|
|
95
|
+
if (writeMarkerAtomic(CONSENT_MARKER_PUSH, String(now), note)) {
|
|
96
|
+
logLine(HOOK, `wrote push_consent_grant note=${note}`);
|
|
97
|
+
} else {
|
|
98
|
+
logLine(HOOK, `FAILED write push_consent_grant`);
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
main().catch(() => {
|
|
105
|
+
// UserPromptSubmit hook must never fail loudly — silent exit on any error.
|
|
106
|
+
process.exit(0);
|
|
107
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Git Commit Guard — PreToolUse(Bash) and PreToolUse(Write|Edit|MultiEdit)
|
|
3
|
+
//
|
|
4
|
+
// JS port of git_commit_guard.sh, adding branch-aware consent policy:
|
|
5
|
+
//
|
|
6
|
+
// 1. Bash matcher — branch-aware:
|
|
7
|
+
// - `git push` is no longer in FORBIDDEN_RE (was an unconditional
|
|
8
|
+
// hard-block; now policy-driven).
|
|
9
|
+
// - `git commit` and `git push` both consult `git rev-parse
|
|
10
|
+
// --abbrev-ref HEAD`. Detached HEAD ("HEAD") → DENY explicitly.
|
|
11
|
+
// - On a branch matched by project.json → git.protected_branches
|
|
12
|
+
// (or when that key is null/absent → every branch protected),
|
|
13
|
+
// commits require fresh commit_consent (300s) and pushes require
|
|
14
|
+
// fresh push_consent (300s).
|
|
15
|
+
// - When git.branch_pattern is set and the current branch does NOT
|
|
16
|
+
// match the regex, commits are denied with the pattern surfaced.
|
|
17
|
+
// - On a non-protected branch, commits and pushes proceed without
|
|
18
|
+
// consent.
|
|
19
|
+
//
|
|
20
|
+
// 2. Write matcher — unchanged behavior plus an arm for push_consent:
|
|
21
|
+
// - Blocks Claude from writing the marker files (commit/push_consent_grant).
|
|
22
|
+
// - Gates writes to commit_consent on a fresh commit-consent marker.
|
|
23
|
+
// - Gates writes to push_consent on a fresh push-consent marker.
|
|
24
|
+
|
|
25
|
+
import { execFileSync } from 'node:child_process';
|
|
26
|
+
import { existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
27
|
+
import {
|
|
28
|
+
readPayload,
|
|
29
|
+
payloadGet,
|
|
30
|
+
projectGet,
|
|
31
|
+
emitBlock,
|
|
32
|
+
emitAllow,
|
|
33
|
+
logLine,
|
|
34
|
+
canonicalRel,
|
|
35
|
+
canonicalSlug,
|
|
36
|
+
validateConsentMarker,
|
|
37
|
+
blockMarkerSelfWrite,
|
|
38
|
+
matchAnyGlob,
|
|
39
|
+
CLAUDE_PROJECT_ROOT,
|
|
40
|
+
STATE_DIR,
|
|
41
|
+
CONSENT_MARKER_COMMIT,
|
|
42
|
+
CONSENT_MARKER_COMMIT_REL,
|
|
43
|
+
CONSENT_MARKER_PUSH,
|
|
44
|
+
CONSENT_MARKER_PUSH_REL,
|
|
45
|
+
} from './lib/common.mjs';
|
|
46
|
+
|
|
47
|
+
const HOOK = 'git_commit_guard';
|
|
48
|
+
|
|
49
|
+
// Hard-blocks that apply regardless of consent or branch.
|
|
50
|
+
// NOTE: `git push` was previously included; removed in the branch-aware
|
|
51
|
+
// policy. The remaining ops are still flat-out forbidden because they
|
|
52
|
+
// rewrite history, skip safety, or sweep paths.
|
|
53
|
+
const FORBIDDEN_RE = new RegExp(
|
|
54
|
+
'(' +
|
|
55
|
+
'\\bgit\\s+commit\\b[^|&;]*--amend' +
|
|
56
|
+
'|--no-verify' +
|
|
57
|
+
'|--no-gpg-sign' +
|
|
58
|
+
'|\\bgit\\s+reset\\s+--hard\\b' +
|
|
59
|
+
'|\\bgit\\s+clean\\s+-[a-zA-Z]*f\\b' +
|
|
60
|
+
'|\\bgit\\s+checkout\\s+--\\s' +
|
|
61
|
+
'|\\bgit\\s+branch\\s+-D\\b' +
|
|
62
|
+
'|\\bgit\\s+config\\b' +
|
|
63
|
+
'|\\bgit\\s+rebase\\s+-i\\b' +
|
|
64
|
+
'|\\bgit\\s+add\\s+-i\\b' +
|
|
65
|
+
'|\\bgit\\s+add\\s+(-A|\\.)(?![A-Za-z0-9_/.\\-])' +
|
|
66
|
+
')'
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
function currentBranch() {
|
|
70
|
+
try {
|
|
71
|
+
const out = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], cwd: CLAUDE_PROJECT_ROOT });
|
|
72
|
+
return out.trim();
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isInsideWorkTree() {
|
|
79
|
+
try {
|
|
80
|
+
execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { stdio: 'ignore', cwd: CLAUDE_PROJECT_ROOT });
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Returns: { protected: bool, patternViolation: string|null, detached: bool, branch: string|null }
|
|
88
|
+
function branchPolicy() {
|
|
89
|
+
const branch = currentBranch();
|
|
90
|
+
if (branch === null) return { protected: false, patternViolation: null, detached: false, branch: null, notGit: true };
|
|
91
|
+
if (branch === 'HEAD') return { protected: false, patternViolation: null, detached: true, branch, notGit: false };
|
|
92
|
+
|
|
93
|
+
// protected_branches: null/absent → every branch protected; else glob match.
|
|
94
|
+
const globs = projectGet('.git.protected_branches');
|
|
95
|
+
let isProtected;
|
|
96
|
+
if (globs == null) {
|
|
97
|
+
isProtected = true;
|
|
98
|
+
} else if (Array.isArray(globs)) {
|
|
99
|
+
isProtected = matchAnyGlob(branch, globs);
|
|
100
|
+
} else {
|
|
101
|
+
isProtected = true; // invalid type → fail-safe to protected
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// branch_pattern: null/absent → no check; string → must match.
|
|
105
|
+
const pattern = projectGet('.git.branch_pattern');
|
|
106
|
+
let patternViolation = null;
|
|
107
|
+
if (typeof pattern === 'string' && pattern.length > 0) {
|
|
108
|
+
try {
|
|
109
|
+
if (!new RegExp(pattern).test(branch)) patternViolation = pattern;
|
|
110
|
+
} catch {
|
|
111
|
+
// invalid regex → treat as no pattern, warn via log
|
|
112
|
+
logLine(HOOK, `WARN invalid git.branch_pattern regex: ${pattern}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { protected: isProtected, patternViolation, detached: false, branch, notGit: false };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function validateConsentToken(file, ttlKey, defaultTtl, gateLabel, cmdHint) {
|
|
120
|
+
let ttl = projectGet(ttlKey);
|
|
121
|
+
if (typeof ttl !== 'number' || !Number.isFinite(ttl)) ttl = defaultTtl;
|
|
122
|
+
if (!existsSync(file)) {
|
|
123
|
+
logLine(HOOK, `BLOCKED no consent file: ${file}`);
|
|
124
|
+
emitBlock(`${gateLabel}: no consent granted. The user must run \`${cmdHint}\` before a ${cmdHint.includes('push') ? 'push' : 'commit'} is allowed. Consent is valid for ${ttl}s.`);
|
|
125
|
+
}
|
|
126
|
+
let grantedAt;
|
|
127
|
+
try {
|
|
128
|
+
grantedAt = readFileSync(file, 'utf8').split(/\r?\n/)[0].trim();
|
|
129
|
+
} catch {
|
|
130
|
+
grantedAt = '';
|
|
131
|
+
}
|
|
132
|
+
if (!/^\d+$/.test(grantedAt)) {
|
|
133
|
+
logLine(HOOK, `BLOCKED malformed consent file: ${file}`);
|
|
134
|
+
emitBlock(`${gateLabel}: consent file is malformed. Ask the user to re-run \`${cmdHint}\`.`);
|
|
135
|
+
}
|
|
136
|
+
const now = Math.floor(Date.now() / 1000);
|
|
137
|
+
const age = now - parseInt(grantedAt, 10);
|
|
138
|
+
if (age > ttl) {
|
|
139
|
+
logLine(HOOK, `BLOCKED consent expired age=${age}s ttl=${ttl}s file=${file}`);
|
|
140
|
+
emitBlock(`${gateLabel}: consent expired (${age}s old, TTL ${ttl}s). Ask the user to re-run \`${cmdHint}\`.`);
|
|
141
|
+
}
|
|
142
|
+
logLine(HOOK, `ALLOWED age=${age}s file=${file}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function handleBash(cmd) {
|
|
146
|
+
if (!cmd || !/(^|\s)git(\s|$)/.test(cmd)) emitAllow();
|
|
147
|
+
|
|
148
|
+
// Hard-blocks first. Push is NOT in this set anymore.
|
|
149
|
+
if (FORBIDDEN_RE.test(cmd)) {
|
|
150
|
+
logLine(HOOK, `BLOCKED forbidden git op: ${cmd}`);
|
|
151
|
+
emitBlock('Git Commit Guard: forbidden git operation detected. seed.md forbids `git commit --amend`, `--no-verify`, `--no-gpg-sign`, `git reset --hard`, `git clean -f`, `git checkout -- `, `git branch -D`, `git config`, `git rebase -i`, `git add -i`, `git add -A|.` regardless of consent or branch. Ask the user to approve by stating the exact command.');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const isCommit = /\bgit\s+commit\b/.test(cmd);
|
|
155
|
+
const isPush = /\bgit\s+push\b/.test(cmd);
|
|
156
|
+
if (!isCommit && !isPush) emitAllow();
|
|
157
|
+
|
|
158
|
+
// Article VII applicability: gate operations require git.
|
|
159
|
+
if (!isInsideWorkTree()) {
|
|
160
|
+
logLine(HOOK, `ALLOWED not-a-git-repo cmd=${cmd}`);
|
|
161
|
+
emitAllow();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const policy = branchPolicy();
|
|
165
|
+
if (policy.detached) {
|
|
166
|
+
logLine(HOOK, `BLOCKED detached HEAD cmd=${cmd}`);
|
|
167
|
+
emitBlock(`Git Commit Guard: detached HEAD. Check out a branch first. Branch-aware policy needs a named branch to evaluate \`git.protected_branches\` and \`git.branch_pattern\`.`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (isCommit && policy.patternViolation) {
|
|
171
|
+
logLine(HOOK, `BLOCKED branch_pattern violation branch=${policy.branch} pattern=${policy.patternViolation}`);
|
|
172
|
+
emitBlock(`Git Commit Guard: branch '${policy.branch}' does not match \`git.branch_pattern\` (\`${policy.patternViolation}\`). Rename the branch to conform, or set \`git.branch_pattern\` to null in project.json to disable naming enforcement.`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!policy.protected) {
|
|
176
|
+
logLine(HOOK, `ALLOWED unprotected-branch branch=${policy.branch} cmd=${cmd}`);
|
|
177
|
+
emitAllow();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Protected — require the matching consent token.
|
|
181
|
+
if (isCommit) {
|
|
182
|
+
validateConsentToken(`${STATE_DIR}/commit_consent`, '.consent.commit_ttl_seconds', 300, 'Git Commit Guard', '/grant-commit');
|
|
183
|
+
} else {
|
|
184
|
+
validateConsentToken(`${STATE_DIR}/push_consent`, '.consent.push_ttl_seconds', 300, 'Git Commit Guard', '/grant-push');
|
|
185
|
+
}
|
|
186
|
+
emitAllow();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function handleWrite(payload) {
|
|
190
|
+
const filePath = payloadGet(payload, '.tool_input.file_path');
|
|
191
|
+
if (!filePath) emitAllow();
|
|
192
|
+
const rel = canonicalRel(filePath);
|
|
193
|
+
if (!rel) emitAllow();
|
|
194
|
+
|
|
195
|
+
// Block self-write of the commit / push markers (Claude can never forge them).
|
|
196
|
+
blockMarkerSelfWrite(rel, CONSENT_MARKER_COMMIT_REL, 'Git Commit Guard', '/grant-commit');
|
|
197
|
+
blockMarkerSelfWrite(rel, CONSENT_MARKER_PUSH_REL, 'Git Commit Guard', '/grant-push');
|
|
198
|
+
|
|
199
|
+
// Gate writes to the consent state files on a fresh marker.
|
|
200
|
+
if (rel === '.claude/state/commit_consent') {
|
|
201
|
+
validateConsentMarker(CONSENT_MARKER_COMMIT, 'Git Commit Guard', '/grant-commit');
|
|
202
|
+
} else if (rel === '.claude/state/push_consent') {
|
|
203
|
+
validateConsentMarker(CONSENT_MARKER_PUSH, 'Git Commit Guard', '/grant-push');
|
|
204
|
+
}
|
|
205
|
+
emitAllow();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function main() {
|
|
209
|
+
const payload = await readPayload();
|
|
210
|
+
const tool = payloadGet(payload, '.tool_name');
|
|
211
|
+
if (tool === 'Bash') {
|
|
212
|
+
const cmd = payloadGet(payload, '.tool_input.command');
|
|
213
|
+
handleBash(cmd);
|
|
214
|
+
} else if (tool === 'Write' || tool === 'Edit' || tool === 'MultiEdit') {
|
|
215
|
+
handleWrite(payload);
|
|
216
|
+
} else {
|
|
217
|
+
emitAllow();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
main().catch((err) => {
|
|
222
|
+
logLine(HOOK, `ERROR ${err && err.message ? err.message : String(err)}`);
|
|
223
|
+
emitAllow();
|
|
224
|
+
});
|
|
@@ -6,18 +6,25 @@
|
|
|
6
6
|
# tick) and decides whether to re-fire harness on the same turn or stay
|
|
7
7
|
# silent.
|
|
8
8
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
9
|
+
# Gate has two disjunctive paths, both gated by rung 1:
|
|
10
|
+
# Path A (mid-loop continuation):
|
|
11
|
+
# 1. stop_hook_active flag absent on payload (avoids in-turn recursion).
|
|
12
|
+
# 2. .claude/state/.harness_active marker exists (session-scoped).
|
|
13
|
+
# 3. harness_state.state equals "continue".
|
|
14
|
+
# Path B (rung 4 — gate-resume after a consent slash command):
|
|
15
|
+
# 1. stop_hook_active flag absent.
|
|
16
|
+
# 4a. harness_state.state equals "yielded".
|
|
17
|
+
# 4b. .claude/state/workflow.json exists and parses.
|
|
18
|
+
# 4c. at least one of {commit_consent, push_consent,
|
|
19
|
+
# spec_approvals/<slug>.approval, swarm_approvals/<slug>.approval}
|
|
20
|
+
# exists with mtime newer than harness_state's mtime.
|
|
21
|
+
# If Path A or Path B passes, the sanity rail runs and a block decision
|
|
22
|
+
# is emitted. Otherwise: exit 0 silent.
|
|
15
23
|
#
|
|
16
24
|
# Sanity rail: if the marker's slug content disagrees with workflow.json.slug,
|
|
17
25
|
# log one WARN line to harness_continuation.log; the decision is unchanged.
|
|
18
26
|
#
|
|
19
|
-
#
|
|
20
|
-
# Otherwise: exit 0 silent. Internal failures are treated as silence.
|
|
27
|
+
# Internal failures are treated as silence.
|
|
21
28
|
|
|
22
29
|
# shellcheck source=./lib/common.sh
|
|
23
30
|
. "${BASH_SOURCE[0]%/*}/lib/common.sh"
|
|
@@ -32,17 +39,14 @@ case "$STOP_ACTIVE" in
|
|
|
32
39
|
;;
|
|
33
40
|
esac
|
|
34
41
|
|
|
35
|
-
#
|
|
42
|
+
# Marker path (presence check is now inside Python — Path B can fire with
|
|
43
|
+
# the marker absent).
|
|
36
44
|
MARKER="$STATE_DIR/.harness_active"
|
|
37
|
-
if [ ! -f "$MARKER" ]; then
|
|
38
|
-
log_line harness_continuation "silent: rung2 marker missing ($MARKER)"
|
|
39
|
-
exit 0
|
|
40
|
-
fi
|
|
41
45
|
|
|
42
|
-
#
|
|
46
|
+
# harness_state existence — both paths need it readable.
|
|
43
47
|
HARNESS_STATE="$STATE_DIR/harness_state"
|
|
44
48
|
if [ ! -r "$HARNESS_STATE" ]; then
|
|
45
|
-
log_line harness_continuation "silent:
|
|
49
|
+
log_line harness_continuation "silent: harness_state missing or unreadable"
|
|
46
50
|
exit 0
|
|
47
51
|
fi
|
|
48
52
|
|
|
@@ -74,21 +78,92 @@ def _warn(message):
|
|
|
74
78
|
_log('WARN', message)
|
|
75
79
|
|
|
76
80
|
|
|
77
|
-
|
|
81
|
+
def _read_workflow_slug():
|
|
82
|
+
"""Return slug string from workflow.json, or None if file missing/unparseable.
|
|
83
|
+
|
|
84
|
+
An empty-string return ('') means the file exists and parses but has no
|
|
85
|
+
slug field — workflow.json present but ungated by slug.
|
|
86
|
+
"""
|
|
87
|
+
if not workflow_path or not os.path.exists(workflow_path):
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
with open(workflow_path) as f:
|
|
91
|
+
wf = json.load(f)
|
|
92
|
+
return wf.get('slug') or ''
|
|
93
|
+
except Exception:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _any_consent_newer_than(reference_mtime, workflow_slug):
|
|
98
|
+
"""Rung 4c: is there a consent/approval token with mtime > reference_mtime?
|
|
99
|
+
|
|
100
|
+
Scans four canonical paths under $STATE_DIR. The two slug-gated paths
|
|
101
|
+
(spec_approvals, swarm_approvals) only check when workflow_slug is
|
|
102
|
+
non-empty.
|
|
103
|
+
"""
|
|
104
|
+
state_dir = os.path.dirname(state_path)
|
|
105
|
+
candidates = [
|
|
106
|
+
os.path.join(state_dir, 'commit_consent'),
|
|
107
|
+
os.path.join(state_dir, 'push_consent'),
|
|
108
|
+
]
|
|
109
|
+
if workflow_slug:
|
|
110
|
+
candidates.append(
|
|
111
|
+
os.path.join(state_dir, 'spec_approvals', f'{workflow_slug}.approval')
|
|
112
|
+
)
|
|
113
|
+
candidates.append(
|
|
114
|
+
os.path.join(state_dir, 'swarm_approvals', f'{workflow_slug}.approval')
|
|
115
|
+
)
|
|
116
|
+
for path in candidates:
|
|
117
|
+
try:
|
|
118
|
+
if os.path.getmtime(path) > reference_mtime:
|
|
119
|
+
return True
|
|
120
|
+
except OSError:
|
|
121
|
+
continue
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Parse harness_state and capture its mtime (rung 4 uses the mtime for the
|
|
126
|
+
# fresh-consent comparison).
|
|
78
127
|
try:
|
|
128
|
+
state_mtime = os.path.getmtime(state_path)
|
|
79
129
|
with open(state_path) as f:
|
|
80
130
|
data = json.load(f)
|
|
81
131
|
except Exception as e:
|
|
82
|
-
_log('INFO', f'silent:
|
|
132
|
+
_log('INFO', f'silent: harness_state unparseable ({e!s})')
|
|
83
133
|
sys.exit(0)
|
|
84
134
|
|
|
85
135
|
state_value = data.get('state')
|
|
86
|
-
|
|
87
|
-
|
|
136
|
+
|
|
137
|
+
# Read workflow.json's slug ONCE; both Path B's rung-4 check and the sanity
|
|
138
|
+
# rail consume it. None = missing/unparseable; '' = present but no slug.
|
|
139
|
+
workflow_slug = _read_workflow_slug()
|
|
140
|
+
|
|
141
|
+
# Branch on state. Path A handles 'continue'; Path B (rung 4) handles
|
|
142
|
+
# 'yielded'. Anything else is silent.
|
|
143
|
+
emit_log_detail = ''
|
|
144
|
+
|
|
145
|
+
if state_value == 'continue':
|
|
146
|
+
# Path A: marker must be present (rung 2).
|
|
147
|
+
if not marker_path or not os.path.exists(marker_path):
|
|
148
|
+
_log('INFO', 'silent: rung2 marker missing for Path A (state=continue)')
|
|
149
|
+
sys.exit(0)
|
|
150
|
+
emit_log_detail = 'Path A (state=continue + marker present)'
|
|
151
|
+
elif state_value == 'yielded':
|
|
152
|
+
# Path B (rung 4): workflow.json must exist; a consent token must be
|
|
153
|
+
# newer than harness_state mtime.
|
|
154
|
+
if workflow_slug is None:
|
|
155
|
+
_log('INFO', 'silent: rung4 workflow.json missing or unparseable')
|
|
156
|
+
sys.exit(0)
|
|
157
|
+
if not _any_consent_newer_than(state_mtime, workflow_slug):
|
|
158
|
+
_log('INFO', 'silent: rung4 no consent token newer than harness_state')
|
|
159
|
+
sys.exit(0)
|
|
160
|
+
emit_log_detail = 'Path B (rung 4, state=yielded + fresh consent)'
|
|
161
|
+
else:
|
|
162
|
+
_log('INFO', f'silent: state={state_value!r} (not "continue" or "yielded")')
|
|
88
163
|
sys.exit(0)
|
|
89
164
|
|
|
90
|
-
# Sanity rail: marker slug should match workflow.json slug.
|
|
91
|
-
#
|
|
165
|
+
# Sanity rail: marker slug should match workflow.json slug. Mismatch is a
|
|
166
|
+
# WARN log line; the decision is unchanged.
|
|
92
167
|
marker_slug = ''
|
|
93
168
|
if marker_path and os.path.exists(marker_path):
|
|
94
169
|
try:
|
|
@@ -97,25 +172,17 @@ if marker_path and os.path.exists(marker_path):
|
|
|
97
172
|
except Exception:
|
|
98
173
|
marker_slug = ''
|
|
99
174
|
|
|
100
|
-
|
|
101
|
-
if
|
|
102
|
-
|
|
103
|
-
with open(workflow_path) as f:
|
|
104
|
-
wf = json.load(f)
|
|
105
|
-
workflow_slug = wf.get('slug') or ''
|
|
106
|
-
except Exception:
|
|
107
|
-
workflow_slug = ''
|
|
108
|
-
|
|
109
|
-
if marker_slug and workflow_slug and marker_slug != workflow_slug:
|
|
110
|
-
_warn(f'slug mismatch: marker={marker_slug} workflow={workflow_slug}')
|
|
175
|
+
rail_workflow_slug = workflow_slug or ''
|
|
176
|
+
if marker_slug and rail_workflow_slug and marker_slug != rail_workflow_slug:
|
|
177
|
+
_warn(f'slug mismatch: marker={marker_slug} workflow={rail_workflow_slug}')
|
|
111
178
|
|
|
112
|
-
#
|
|
179
|
+
# Gate passed — emit the block decision.
|
|
113
180
|
decision = {
|
|
114
181
|
'decision': 'block',
|
|
115
182
|
'reason': 'Workflow continuing per harness_state. Invoke Skill(harness) to advance to the next phase.',
|
|
116
183
|
}
|
|
117
184
|
print(json.dumps(decision))
|
|
118
|
-
_log('INFO', 'emit: decision=block (
|
|
185
|
+
_log('INFO', f'emit: decision=block ({emit_log_detail})')
|
|
119
186
|
PY
|
|
120
187
|
|
|
121
188
|
exit 0
|