@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.
Files changed (88) hide show
  1. package/README.md +7 -3
  2. package/obj/template/.claude/commands/grant-push.md +19 -0
  3. package/obj/template/.claude/commands/init-project.md +26 -4
  4. package/obj/template/.claude/hooks/consent_gate_grant.mjs +107 -0
  5. package/obj/template/.claude/hooks/git_commit_guard.mjs +224 -0
  6. package/obj/template/.claude/hooks/harness_continuation.sh +101 -34
  7. package/obj/template/.claude/hooks/lib/common.mjs +283 -0
  8. package/obj/template/.claude/hooks/lib/common.sh +1 -1
  9. package/obj/template/.claude/hooks/memory_session_start.sh +20 -6
  10. package/obj/template/.claude/hooks/memory_stop.sh +161 -2
  11. package/obj/template/.claude/hooks/spec_approval_guard.sh +1 -1
  12. package/obj/template/.claude/hooks/swarm_approval_guard.sh +1 -1
  13. package/obj/template/.claude/hooks/tests/fixtures/ac008_byte_equal_reference.txt +7 -7
  14. package/obj/template/.claude/hooks/tests/fixtures/memory_stop_landmark_baseline.txt +21 -0
  15. package/obj/template/.claude/hooks/tests/fixtures/regenerate-ac008.sh +47 -0
  16. package/obj/template/.claude/hooks/tests/memory_session_start_test.sh +7 -3
  17. package/obj/template/.claude/hooks/tests/memory_stop_intent_test.sh +329 -0
  18. package/obj/template/.claude/hooks/tests/regenerate_ac008_test.sh +99 -0
  19. package/obj/template/.claude/memory/README.md +8 -3
  20. package/obj/template/.claude/memory/backlog.md +12 -0
  21. package/obj/template/.claude/project.json +6 -1
  22. package/obj/template/.claude/settings.json +3 -4
  23. package/obj/template/.claude/skills/audit-baseline/audit.sh +39 -21
  24. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/_pending_opener_only.md +3 -0
  25. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_empty_body.md +4 -0
  26. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_with_entries.md +9 -0
  27. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_no_opener.md +3 -0
  28. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_opener_only.md +3 -0
  29. package/obj/template/.claude/skills/audit-baseline/tests/preamble_check_test.sh +147 -0
  30. package/obj/template/.claude/skills/chore/SKILL.md +5 -3
  31. package/obj/template/.claude/skills/commit/SKILL.md +5 -4
  32. package/obj/template/.claude/skills/copywriting/LICENSE +21 -0
  33. package/obj/template/.claude/skills/copywriting/NOTICE +23 -0
  34. package/obj/template/.claude/skills/copywriting/SKILL.md +1 -1
  35. package/obj/template/.claude/skills/design-ui/SKILL.md +23 -5
  36. package/obj/template/.claude/skills/design-ui/references/design-vs-development.md +26 -5
  37. package/obj/template/.claude/skills/design-ui/references/orchestration.md +1 -0
  38. package/obj/template/.claude/skills/design-ui/references/state-machine.md +5 -3
  39. package/obj/template/.claude/skills/documentation/LICENSE +202 -0
  40. package/obj/template/.claude/skills/documentation/NOTICE +22 -0
  41. package/obj/template/.claude/skills/google-analytics/SKILL.md +129 -0
  42. package/obj/template/.claude/skills/google-analytics/references/audiences.md +389 -0
  43. package/obj/template/.claude/skills/google-analytics/references/bigquery.md +470 -0
  44. package/obj/template/.claude/skills/google-analytics/references/custom-dimensions.md +355 -0
  45. package/obj/template/.claude/skills/google-analytics/references/custom-events.md +383 -0
  46. package/obj/template/.claude/skills/google-analytics/references/data-management.md +416 -0
  47. package/obj/template/.claude/skills/google-analytics/references/debugview.md +364 -0
  48. package/obj/template/.claude/skills/google-analytics/references/events-fundamentals.md +398 -0
  49. package/obj/template/.claude/skills/google-analytics/references/gtag.md +502 -0
  50. package/obj/template/.claude/skills/google-analytics/references/gtm-integration.md +483 -0
  51. package/obj/template/.claude/skills/google-analytics/references/measurement-protocol.md +519 -0
  52. package/obj/template/.claude/skills/google-analytics/references/privacy.md +441 -0
  53. package/obj/template/.claude/skills/google-analytics/references/recommended-events.md +464 -0
  54. package/obj/template/.claude/skills/google-analytics/references/reporting.md +397 -0
  55. package/obj/template/.claude/skills/google-analytics/references/setup.md +344 -0
  56. package/obj/template/.claude/skills/google-analytics/references/user-tracking.md +417 -0
  57. package/obj/template/.claude/skills/harness/SKILL.md +3 -1
  58. package/obj/template/.claude/skills/humanizer/LICENSE +21 -0
  59. package/obj/template/.claude/skills/humanizer/NOTICE +21 -0
  60. package/obj/template/.claude/skills/impeccable/LICENSE +202 -0
  61. package/obj/template/.claude/skills/impeccable/NOTICE +24 -0
  62. package/obj/template/.claude/skills/memory-flush/SKILL.md +20 -4
  63. package/obj/template/.claude/skills/memory-flush/sweep.py +74 -6
  64. package/obj/template/.claude/skills/memory-flush/tests/run.sh +300 -1
  65. package/obj/template/.claude/skills/optimize-seo/SKILL.md +313 -0
  66. package/obj/template/.claude/skills/optimize-seo/scripts/pagespeed.mjs +197 -0
  67. package/obj/template/.claude/skills/pagespeed-insights/LICENSE.md +37 -0
  68. package/obj/template/.claude/skills/pagespeed-insights/SKILL.md +446 -0
  69. package/obj/template/.claude/skills/pagespeed-insights/reference.md +50 -0
  70. package/obj/template/.claude/skills/tdd/SKILL.md +2 -1
  71. package/obj/template/.claude/skills/tdd/drift_check.py +180 -0
  72. package/obj/template/.claude/skills/tdd/tests/drift_check_test.sh +190 -0
  73. package/obj/template/.claude/skills/tdd/tests/run.sh +21 -0
  74. package/obj/template/.claude/skills/technical-tutorials/LICENSE +21 -0
  75. package/obj/template/.claude/skills/technical-tutorials/NOTICE +23 -0
  76. package/obj/template/.claude/skills/technical-tutorials/SKILL.md +1 -1
  77. package/obj/template/.claude/skills/triage/SKILL.md +8 -3
  78. package/obj/template/CLAUDE.md +37 -26
  79. package/obj/template/docs/init/seed.md +38 -23
  80. package/obj/template/manifest.json +80 -33
  81. package/package.json +1 -1
  82. package/src/CLAUDE.template.md +37 -26
  83. package/src/memory/backlog.template.md +12 -0
  84. package/src/project.template.json +6 -1
  85. package/src/seed.template.md +38 -23
  86. package/src/settings.template.json +3 -4
  87. package/obj/template/.claude/hooks/consent_gate_grant.sh +0 -89
  88. 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
- 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 corresponding write-boundary guard validates it on disk before allowing the approval token through.
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
- Use `AskUserQuestion` to confirm: "Apply these changes?" Options: `apply`, `apply with edits`, `cancel`.
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
- If `apply with edits`: take the user's adjustments inline, re-show the surface, ask again.
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. Do each sub-step in order; if any fails, stop and surface the error before continuing:
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
- # Three-rung gate (plus sanity rail) ALL three must pass to emit a block:
10
- # 1. stop_hook_active flag absent on payload (avoids in-turn recursion).
11
- # 2. .claude/state/.harness_active exists (session-scoped in-the-loop marker;
12
- # the harness skill creates it on continue, deletes it on yielded/done;
13
- # memory_session_start.sh deletes it on session boundary).
14
- # 3. harness_state.state equals "continue".
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
- # If all three pass, emit {"decision":"block","reason":"..."} to stdout.
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
- # Rung 2: active marker presencesession-scoped "in the loop" signal.
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
- # Rung 3 (plus sanity rail + emit) delegate to python for JSON parsing.
46
+ # harness_state existenceboth 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: rung3a harness_state missing or unreadable"
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
- # Rung 3: parse harness_state and check state field.
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: rung3b harness_state unparseable ({e!s})')
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
- if state_value != 'continue':
87
- _log('INFO', f'silent: rung3c state={state_value!r} (expected "continue")')
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
- # Mismatch is a WARN log line; the decision is unchanged.
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
- workflow_slug = ''
101
- if workflow_path and os.path.exists(workflow_path):
102
- try:
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
- # All rungs passed — emit the block decision.
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 (all rungs passed)')
185
+ _log('INFO', f'emit: decision=block ({emit_log_detail})')
119
186
  PY
120
187
 
121
188
  exit 0