@next-friday/create-app 9.5.0 → 9.6.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 (21) hide show
  1. package/package.json +1 -1
  2. package/templates/base/.agents/hooks/agent-mcp-gate.sh +8 -0
  3. package/templates/base/.agents/hooks/agent-production-gate.sh +24 -5
  4. package/templates/base/.agents/hooks/agent-shell-gate.sh +9 -1
  5. package/templates/base/.agents/hooks/agent-test-edit-gate.sh +10 -2
  6. package/templates/base/.agents/hooks/record-gate-decision.sh +28 -0
  7. package/templates/base/.cursor/hooks/agent-constitution-gate.sh +10 -2
  8. package/templates/base/SETUP.md +4 -2
  9. package/templates/base/plugins/sdlc/claude-code/hooks/check-reply-shape.sh +45 -1
  10. package/templates/base/plugins/sdlc/claude-code/hooks/hooks.json +11 -0
  11. package/templates/base/plugins/sdlc/claude-code/skills/sdlc-mode/SKILL.md +1 -1
  12. package/templates/base/plugins/sdlc/claude-code/skills/sdlc-mode/playbooks/maintain.md +1 -1
  13. package/templates/base/plugins/sdlc/claude-code/skills/sdlc-mode/playbooks/review.md +1 -1
  14. package/templates/base/plugins/sdlc/claude-code/skills/setup-sdlc/SKILL.md +2 -0
  15. package/templates/base/plugins/sdlc/cursor/hooks/check-reply-shape.sh +45 -1
  16. package/templates/base/plugins/sdlc/cursor/hooks/hooks.json +5 -0
  17. package/templates/base/plugins/sdlc/cursor/skills/sdlc-mode/SKILL.md +1 -1
  18. package/templates/base/plugins/sdlc/cursor/skills/sdlc-mode/playbooks/maintain.md +1 -1
  19. package/templates/base/plugins/sdlc/cursor/skills/sdlc-mode/playbooks/review.md +1 -1
  20. package/templates/base/plugins/sdlc/cursor/skills/setup-sdlc/SKILL.md +2 -0
  21. package/templates/base/scripts/check-agent-gates.ts +186 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@next-friday/create-app",
3
- "version": "9.5.0",
3
+ "version": "9.6.0",
4
4
  "description": "Create a Next Friday project from a template.",
5
5
  "keywords": [
6
6
  "create",
@@ -1,12 +1,20 @@
1
1
  #!/usr/bin/env bash
2
2
  set -uo pipefail
3
3
 
4
+ # shellcheck source=.agents/hooks/record-gate-decision.sh
5
+ if ! source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/record-gate-decision.sh"; then
6
+ printf '%s\n' '{"permission":"deny","user_message":"agent-mcp-gate: .agents/hooks/record-gate-decision.sh could not be loaded, so the gate cannot record its decision. Stop and report this error to the user; agents may not edit the gate."}'
7
+ exit 2
8
+ fi
9
+
4
10
  allow() {
11
+ record_gate_decision agent-mcp-gate allow "" "${input:-}" "${lifted_approval:-}"
5
12
  printf '%s\n' '{"permission":"allow"}'
6
13
  exit 0
7
14
  }
8
15
 
9
16
  block() {
17
+ record_gate_decision agent-mcp-gate block "${2:-$1}" "${input:-}"
10
18
  jq -n --arg message "$1" '{permission:"deny",user_message:$message,agent_message:$message}'
11
19
  printf 'agent-mcp-gate: %s\n' "$1" >&2
12
20
  exit 2
@@ -1,12 +1,20 @@
1
1
  #!/usr/bin/env bash
2
2
  set -uo pipefail
3
3
 
4
+ # shellcheck source=.agents/hooks/record-gate-decision.sh
5
+ if ! source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/record-gate-decision.sh"; then
6
+ printf '%s\n' '{"permission":"deny","user_message":"agent-production-gate: .agents/hooks/record-gate-decision.sh could not be loaded, so the gate cannot record its decision. Stop and report this error to the user; agents may not edit the gate."}'
7
+ exit 2
8
+ fi
9
+
4
10
  allow() {
11
+ record_gate_decision agent-production-gate allow "" "${input:-}" "${lifted_approval:-}"
5
12
  printf '%s\n' '{"permission":"allow"}'
6
13
  exit 0
7
14
  }
8
15
 
9
16
  block() {
17
+ record_gate_decision agent-production-gate block "${2:-$1}" "${input:-}"
10
18
  jq -n --arg message "$1" '{permission:"deny",user_message:$message,agent_message:$message}'
11
19
  printf 'agent-production-gate: %s\n' "$1" >&2
12
20
  exit 2
@@ -14,15 +22,26 @@ block() {
14
22
 
15
23
  trap 'block ".agents/hooks/agent-production-gate.sh failed. Stop and report this error to the user; agents may not edit the gate."' ERR
16
24
  command -v jq > /dev/null || block "jq is required to read the hook input. Install it: https://jqlang.org/download/"
17
- command=$(jq -r '.command // .tool_input.command // empty')
25
+ input=$(cat)
26
+ command=$(jq -r '.command // .tool_input.command // empty' <<< "$input")
18
27
  [[ -n $command ]] || allow
19
- approval=$(tr '[:upper:]' '[:lower:]' <<< "${RELEASE_APPROVAL:-}")
20
- case $approval in
28
+ project=${CLAUDE_PROJECT_DIR:-${CURSOR_PROJECT_DIR:-$PWD}}
29
+ approval_form_file=.agents/release-approval-form
30
+ approval=${RELEASE_APPROVAL:-}
31
+ unmatched_approval=
32
+ case $(tr '[:upper:]' '[:lower:]' <<< "$approval") in
21
33
  "" | 0 | false | no) ;;
22
- *) allow ;;
34
+ *)
35
+ approval_form=$(head -n 1 "$project/$approval_form_file" 2> /dev/null) || approval_form=
36
+ if [[ -n $approval_form && $approval =~ $approval_form ]]; then
37
+ lifted_approval=$approval
38
+ allow
39
+ fi
40
+ unmatched_approval=" RELEASE_APPROVAL is set and does not match the form in $approval_form_file, so the gate stays in force."
41
+ ;;
23
42
  esac
24
43
 
25
- release_gate="A release manager runs prod deploys, commands against prod, and release tag pushes. Do not run this; report what is ready to promote, such as the commit SHA. If a gated command appears only inside a message, write the text to a file and pass it with git commit -F <file> or gh pr create --body-file <file>."
44
+ release_gate="A release manager runs prod deploys, commands against prod, and release tag pushes. Do not run this; report what is ready to promote, such as the commit SHA. If a gated command appears only inside a message, write the text to a file and pass it with git commit -F <file> or gh pr create --body-file <file>.$unmatched_approval"
26
45
 
27
46
  # The gate reads the command line as text, joining continued lines and dropping quotes,
28
47
  # backslashes, and fd redirections, so wrappers such as bash -c, eval, or npx and split words such
@@ -1,12 +1,20 @@
1
1
  #!/usr/bin/env bash
2
2
  set -uo pipefail
3
3
 
4
+ # shellcheck source=.agents/hooks/record-gate-decision.sh
5
+ if ! source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/record-gate-decision.sh"; then
6
+ printf '%s\n' '{"permission":"deny","user_message":"agent-shell-gate: .agents/hooks/record-gate-decision.sh could not be loaded, so the gate cannot record its decision. Stop and report this error to the user; agents may not edit the gate."}'
7
+ exit 2
8
+ fi
9
+
4
10
  allow() {
11
+ record_gate_decision agent-shell-gate allow "" "${input:-}" "${lifted_approval:-}"
5
12
  printf '%s\n' '{"permission":"allow"}'
6
13
  exit 0
7
14
  }
8
15
 
9
16
  block() {
17
+ record_gate_decision agent-shell-gate block "${2:-$1}" "${input:-}"
10
18
  jq -n --arg message "$1" '{permission:"deny",user_message:$message,agent_message:$message}'
11
19
  printf 'agent-shell-gate: %s\n' "$1" >&2
12
20
  exit 2
@@ -147,7 +155,7 @@ lex() {
147
155
  }
148
156
 
149
157
  writes_protected() {
150
- block "Changing $1 from the shell is blocked. $policy"
158
+ block "Changing $1 from the shell is blocked. $policy" "Changing a protected path from the shell is blocked. $policy"
151
159
  }
152
160
 
153
161
  check_git() {
@@ -1,12 +1,20 @@
1
1
  #!/usr/bin/env bash
2
2
  set -uo pipefail
3
3
 
4
+ # shellcheck source=.agents/hooks/record-gate-decision.sh
5
+ if ! source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/record-gate-decision.sh"; then
6
+ printf '%s\n' '{"permission":"deny","user_message":"agent-test-edit-gate: .agents/hooks/record-gate-decision.sh could not be loaded, so the gate cannot record its decision. Stop and report this error to the user; agents may not edit the gate."}'
7
+ exit 2
8
+ fi
9
+
4
10
  allow() {
11
+ record_gate_decision agent-test-edit-gate allow "" "${input:-}" "${lifted_approval:-}"
5
12
  printf '%s\n' '{"permission":"allow"}'
6
13
  exit 0
7
14
  }
8
15
 
9
16
  block() {
17
+ record_gate_decision agent-test-edit-gate block "${2:-$1}" "${input:-}"
10
18
  jq -n --arg message "$1" '{permission:"deny",user_message:$message,agent_message:$message}'
11
19
  printf 'agent-test-edit-gate: %s\n' "$1" >&2
12
20
  exit 2
@@ -20,7 +28,7 @@ project=${CLAUDE_PROJECT_DIR:-${CURSOR_PROJECT_DIR:-$PWD}}
20
28
  path=$(jq -r '.tool_input.file_path // .tool_input.path // .tool_input.notebook_path // .file_path // empty' <<< "$input")
21
29
  [[ -n $path && $path =~ $test_path_pattern ]] || allow
22
30
  if [[ -e "$project/.git/bug-fix-in-progress" ]]; then
23
- block "A bug fix is in progress (.git/bug-fix-in-progress), so $path cannot change. Fix the code, not the test."
31
+ block "A bug fix is in progress (.git/bug-fix-in-progress), so $path cannot change. Fix the code, not the test." "A bug fix is in progress (.git/bug-fix-in-progress), so a test file cannot change."
24
32
  fi
25
33
  branch=$(git -C "$project" symbolic-ref --quiet --short HEAD 2> /dev/null) || branch=
26
34
  [[ $branch == fix/* ]] || allow
@@ -29,6 +37,6 @@ git -C "$project" rev-parse --verify --quiet "$default_branch" > /dev/null 2>&1
29
37
  merge_base=$(git -C "$project" merge-base HEAD "$default_branch" 2> /dev/null) || allow
30
38
  committed_files=$(git -C "$project" log --format= --name-only "$merge_base..HEAD" 2> /dev/null) || allow
31
39
  if grep -Eq "$test_path_pattern" <<< "$committed_files"; then
32
- block "A bug fix is in progress on branch $branch and the test that reproduces it is committed, so $path cannot change. Fix the code, not the test."
40
+ block "A bug fix is in progress on branch $branch and the test that reproduces it is committed, so $path cannot change. Fix the code, not the test." "A bug fix is in progress on a fix branch whose test is committed, so a test file cannot change."
33
41
  fi
34
42
  allow
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bash
2
+ # Sourced by each gate hook. A record never changes the hook's answer: a failed write is reported
3
+ # on standard error and the function returns 0. The record holds no command text, path, file
4
+ # content, or environment value, because any of them can hold a secret: a hook passes a reason
5
+ # with no text from its input. A lifted production gate adds the approval value and the Git user
6
+ # name, which the release record needs.
7
+ record_gate_decision() {
8
+ local hook=$1 decision=$2 reason=$3 input=$4 approval=${5:-} project log session
9
+ project=${CLAUDE_PROJECT_DIR:-${CURSOR_PROJECT_DIR:-$PWD}}
10
+ log=${AGENT_GATE_LOG:-$project/.agents/logs/gate-decisions.jsonl}
11
+ session=$(jq -r '.session_id // .conversation_id // empty' <<< "$input" 2> /dev/null) || session=
12
+ {
13
+ mkdir -p "${log%/*}" \
14
+ && jq -cn \
15
+ --arg time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
16
+ --arg hook "$hook" \
17
+ --arg decision "$decision" \
18
+ --arg reason "$reason" \
19
+ --arg session "$session" \
20
+ --arg approval "$approval" \
21
+ --arg user "$([[ -n $approval ]] && git -C "$project" config user.name 2> /dev/null)" \
22
+ '{time: $time, hook: $hook, decision: $decision}
23
+ + (if $reason == "" then {} else {reason: $reason} end)
24
+ + (if $session == "" then {} else {session: $session} end)
25
+ + (if $approval == "" then {} else {approval: $approval, user: $user} end)' >> "$log"
26
+ } 2> /dev/null || printf '%s: could not write the decision record to %s\n' "$hook" "$log" >&2
27
+ return 0
28
+ }
@@ -3,12 +3,20 @@ set -uo pipefail
3
3
 
4
4
  protected='^(\.agents/rules/|\.agents/rules\.json$|\.agents/hooks/|\.claude/rules/|\.claude/settings(\.local)?\.json$|\.cursor/rules/|\.cursor/hooks/|\.cursor/hooks\.json$|scripts/sync-agent-rules\.ts$|scripts/check-agent-gates\.ts$|\.commitlintrc\.json$|\.prettierrc\.json$|prettier\.config\.ts$|eslint\.config\.ts$|\.husky/|\.lintstagedrc\.json$|\.gitleaks\.toml$|\.git/hooks/|\.git/config(\.worktree)?$)'
5
5
 
6
+ # shellcheck source=.agents/hooks/record-gate-decision.sh
7
+ if ! source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../.agents/hooks/record-gate-decision.sh"; then
8
+ printf '%s\n' '{"permission":"deny","user_message":"agent-constitution-gate: .agents/hooks/record-gate-decision.sh could not be loaded, so the gate cannot record its decision. Stop and report this error to the user; agents may not edit the gate."}'
9
+ exit 2
10
+ fi
11
+
6
12
  allow() {
13
+ record_gate_decision agent-constitution-gate allow "" "${input:-}" "${lifted_approval:-}"
7
14
  printf '%s\n' '{"permission":"allow"}'
8
15
  exit 0
9
16
  }
10
17
 
11
18
  block() {
19
+ record_gate_decision agent-constitution-gate block "${2:-$1}" "${input:-}"
12
20
  jq -n --arg message "$1" '{permission:"deny",user_message:$message,agent_message:$message}'
13
21
  printf 'agent-constitution-gate: %s\n' "$1" >&2
14
22
  exit 2
@@ -26,7 +34,7 @@ project=${CURSOR_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-${project:-$PWD}}}
26
34
  [[ $path == "$project"/* ]] || allow
27
35
  relative=${path#"$project"/}
28
36
  if [[ $relative =~ (^|/)\.\.?(/|$) ]]; then
29
- block "The edit to $relative uses . or .. in its path. Use the normalized path."
37
+ block "The edit to $relative uses . or .. in its path. Use the normalized path." "The edit uses . or .. in its path."
30
38
  fi
31
39
  [[ $relative =~ $protected ]] || allow
32
- block "The edit to $relative is blocked. That path is company policy; stop and ask the user."
40
+ block "The edit to $relative is blocked. That path is company policy; stop and ask the user." "The edit to a company policy path is blocked."
@@ -43,6 +43,8 @@ In `.github/CODEOWNERS`, add these lines in this order. GitHub uses the last mat
43
43
 
44
44
  Replace every owner above with a GitHub user or team that has write access to the repository. Also replace the owner placeholder in `.github/zizmor.yml`.
45
45
 
46
+ Code owners need: write access to the repository for each owner; for a team, a visible team that itself has write access; and, in a private repository, GitHub Pro, Team, or Enterprise ([code owners](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners)). Until the ruleset of step 3 requires their review, a code owner is asked for a review and a pull request merges without it.
47
+
46
48
  Replace `security@example.com` in `SECURITY.md` with the contact the security policy owner names, such as the security email of the company or of the client.
47
49
 
48
50
  **Check:** on GitHub, open `.github/CODEOWNERS`; GitHub shows no errors in the file. `grep example.com SECURITY.md` prints nothing.
@@ -73,7 +75,7 @@ Replace `security@example.com` in `SECURITY.md` with the contact the security po
73
75
 
74
76
  - **Code Review:** in [**Admin settings → Claude Code**](https://claude.ai/admin-settings/claude-code#code-review), turn on **Allow code review**, install the Claude GitHub App, and add this repository ([set up Code Review](https://code.claude.com/docs/en/code-review-setup)). It needs a Team or Enterprise plan ([availability](https://code.claude.com/docs/en/code-review#availability)). Keep **Review runs** at **After every push**, the default, so every push to a pull request gets a review. Reviews run on Anthropic's infrastructure, so the repository has no review workflow. Anyone with write access can also comment `@claude review` on a pull request to start a review ([comment commands](https://code.claude.com/docs/en/code-review-pull-requests#comment-commands)).
75
77
  - **Managed settings:** deploy the non-negotiable settings and hooks as [managed settings](https://code.claude.com/docs/en/settings), tailored from the example in `.claude/managed-settings.example.json`. Engineers cannot override them. When `strictKnownMarketplaces` is set, as in the example, add the marketplace `next-friday-plugins` of this project to it, or Claude Code does not register the project's plugin.
76
- - **Monitoring:** turn on the [OpenTelemetry export](https://code.claude.com/docs/en/monitoring-usage).
78
+ - **Monitoring:** turn on the [OpenTelemetry export](https://code.claude.com/docs/en/monitoring-usage). It needs an OpenTelemetry collector or backend that the organization runs, and its endpoint. Set `CLAUDE_CODE_ENABLE_TELEMETRY`, the exporters, and `OTEL_EXPORTER_OTLP_ENDPOINT` in managed settings, or each engineer sets them in a shell or in `~/.claude/settings.json`: Claude Code ignores these variables in the repository's `.claude/settings.json`, so the project cannot turn the export on.
77
79
 
78
80
  **Check:** the test pull request gets a **Claude Code Review** check run within a few minutes.
79
81
 
@@ -92,7 +94,7 @@ Replace `security@example.com` in `SECURITY.md` with the contact the security po
92
94
  - **Sources of truth (engineering lead):** for each artifact, name one system as the source of truth: this repository, an existing tool such as Jira, or, as the minimum, a link both ways (the record id in the file and the commit SHA in the record).
93
95
  - **Higher risk (engineering lead):** decide which changes count as higher risk. A tech lead or architect approves their specs and plans.
94
96
  - **Policies (each policy owner, with an engineer):** the plugin ships a default for each area, `plugins/sdlc/claude-code/skills/<area>-policy/SKILL.md` (security: OWASP ASVS 5.0.0 Level 1; UX: WCAG 2.2 Level AA; compliance: the OECD privacy principles; brand: one declared source for every brand value). Review it. To replace a default, add the organization's own rules in `.agents/skills/<area>-policy/SKILL.md` in the project: that file wins over the plugin's default, and the `CODEOWNERS` line of step 2 for that area makes the policy owner its code owner. Do not edit the plugin's copy. Then run `pnpm rules:sync`, which renders the project's own policy skill for Claude Code and Cursor. Test that the skill triggers: ask for the relevant task in different ways and confirm the skill loads each time.
95
- - **Production gate (engineering leadership, with change management and compliance):** name the release managers and add them as required reviewers on the `prod` environment, as the README's deploy section describes. `.agents/hooks/agent-production-gate.sh` blocks an agent's shell commands that dispatch a `prod` deploy, run a Railway command with `prod` as its environment, or push a release tag. It does not read MCP tools. A release manager whose own agent session must do one of these starts the agent with `RELEASE_APPROVAL` set to the approval record, for example `RELEASE_APPROVAL=CHG-123 claude`. The value lifts the gate only when it matches the extended regular expression on the first line of `.agents/release-approval-form`, shipped as `^[A-Z]+-[0-9]+$`; set that line to the form of your organization's approval records. Each gate hook appends its decisions to `.agents/logs/gate-decisions.jsonl`, which git ignores: the time, the hook, the decision, and the reason for a block, and, when the production gate is lifted, the approval value and the Git user name.
97
+ - **Production gate (engineering leadership, with change management and compliance):** name the release managers and add them as required reviewers on the `prod` environment, as the README's deploy section describes. `.agents/hooks/agent-production-gate.sh` blocks an agent's shell commands that dispatch a `prod` deploy, run a Railway command with `prod` as its environment, or push a release tag. It does not read MCP tools. A release manager whose own agent session must do one of these starts the agent with `RELEASE_APPROVAL` set to the approval record, for example `RELEASE_APPROVAL=CHG-123 claude`. The value lifts the gate only when it matches the extended regular expression on the first line of `.agents/release-approval-form`, shipped as `^[A-Z]+-[0-9]+$`; set that line to the form of your organization's approval records. Each gate hook appends its decisions to `.agents/logs/gate-decisions.jsonl`, which git ignores: the time, the hook, the decision, the session, and the reason for a block, and, when the production gate is lifted, the approval value and the Git user name. A record holds no command text and no path.
96
98
  - **Review (tech lead):** `.agents/review.md` names this stack's generated files to skip; add a path when the project gains a generated file, then run `pnpm rules:sync`, which renders it to `REVIEW.md` for Claude Code Review. Once a month, rate the review findings and tune `.agents/review.md`.
97
99
  - **People without Git experience:** connect GitHub to Claude (claude.ai or Cowork), so Claude commits `intent.md` for them.
98
100
 
@@ -3,8 +3,44 @@ command -v jq > /dev/null || exit 0
3
3
  mode=${1:-}
4
4
  input=$(cat)
5
5
  state_directory=${TMPDIR:-/tmp}/sdlc-reply-shape
6
- conversation=$(printf '%s' "$input" | jq -r '.conversation_id // ""' | tr -cd 'A-Za-z0-9_-')
6
+ conversation=$(printf '%s' "$input" | jq -r '.conversation_id // .session_id // ""')
7
+ conversation=${conversation:+$(printf '%s' "$conversation" | cksum | tr ' ' '-')}
8
+ project=$(printf '%s' "$input" | jq -r '.cwd // .workspace_roots[0] // ""')
9
+ snapshot_file=$state_directory/$conversation.files
10
+
11
+ # A copy of the index and a scratch object store keep the project's own Git data unwritten.
12
+ snapshot() {
13
+ [ -n "$project" ] && [ -n "$conversation" ] || return 1
14
+ repository=$(git -C "$project" rev-parse --show-toplevel 2> /dev/null) || return 1
15
+ index=$(git -C "$repository" rev-parse --git-path index) || return 1
16
+ objects=$(git -C "$repository" rev-parse --git-path objects) || return 1
17
+ case $index in /*) ;; *) index=$repository/$index ;; esac
18
+ case $objects in /*) ;; *) objects=$repository/$objects ;; esac
19
+ scratch=$state_directory/$conversation.git
20
+ rm -rf "$scratch"
21
+ mkdir -p "$scratch/objects" || return 1
22
+ cp "$index" "$scratch/index" 2> /dev/null
23
+ content=$(
24
+ export GIT_INDEX_FILE="$scratch/index" GIT_OBJECT_DIRECTORY="$scratch/objects" GIT_ALTERNATE_OBJECT_DIRECTORIES="$objects"
25
+ git -C "$repository" add -A -- ':(top)' ':(top,exclude)intent' 2> /dev/null
26
+ git -C "$repository" ls-files -s -- ':(top)' ':(top,exclude)intent' | cksum
27
+ )
28
+ rm -rf "$scratch"
29
+ printf '%s|%s' "$repository" "$content"
30
+ }
31
+
32
+ changed_since_snapshot() {
33
+ current=$(snapshot) || return 1
34
+ previous=$(cat "$snapshot_file" 2> /dev/null)
35
+ printf '%s' "$current" > "$snapshot_file"
36
+ [ "${previous%|*}" = "${current%|*}" ] && [ "$previous" != "$current" ]
37
+ }
38
+
7
39
  case $mode in
40
+ snapshot)
41
+ changed_since_snapshot
42
+ exit 0
43
+ ;;
8
44
  save)
9
45
  text=$(printf '%s' "$input" | jq -r '.text // ""')
10
46
  [ -n "$text" ] && [ -n "$conversation" ] || exit 0
@@ -12,12 +48,16 @@ case $mode in
12
48
  exit 0
13
49
  ;;
14
50
  followup)
51
+ changed=false
52
+ changed_since_snapshot && changed=true
15
53
  [ -n "$conversation" ] && [ -f "$state_directory/$conversation" ] || exit 0
16
54
  reply=$(cat "$state_directory/$conversation")
17
55
  rm -f "$state_directory/$conversation"
18
56
  [ "$(printf '%s' "$input" | jq -r '"\(.status // "") \(.loop_count // "")"')" = 'completed 0' ] || exit 0
19
57
  ;;
20
58
  *)
59
+ changed=false
60
+ changed_since_snapshot && changed=true
21
61
  [ "$(printf '%s' "$input" | jq -r '.stop_hook_active // false')" = true ] && exit 0
22
62
  reply=$(printf '%s' "$input" | jq -r '.last_assistant_message // ""')
23
63
  ;;
@@ -48,6 +88,10 @@ case $last in
48
88
  *) problems="${problems}End the reply with a line that starts with **Status:**; nothing may follow it. " ;;
49
89
  esac
50
90
 
91
+ if [ "$changed" = true ] && ! printf '%s\n' "$outside_fences" | grep -q '^\*\*Checks:\*\*'; then
92
+ problems="${problems}A file outside intent/ changed in this turn and the reply has no **Checks:** section. Run the checks the project names and add **Checks:** with each command and the lines of its output that show the result, or with each check that could not run and why. "
93
+ fi
94
+
51
95
  [ -z "$problems" ] && exit 0
52
96
  jq -n --arg mode "$mode" --arg reason "${problems}Send the whole reply again." \
53
97
  'if $mode == "followup" then {followup_message:$reason} else {decision:"block",reason:$reason} end'
@@ -1,5 +1,16 @@
1
1
  {
2
2
  "hooks": {
3
+ "UserPromptSubmit": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "timeout": 10,
8
+ "type": "command",
9
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/check-reply-shape.sh\" snapshot"
10
+ }
11
+ ]
12
+ }
13
+ ],
3
14
  "Stop": [
4
15
  {
5
16
  "hooks": [
@@ -49,7 +49,7 @@ For example, the person writes "who must approve pull request #7 of https://host
49
49
 
50
50
  **Steps:** <only when the session has no task-list tool, and never for the `question` playbook. One line for each numbered step of the playbook: `1. <the step, copied word for word>: done | open | skip: <reason>`. When the turn ran more than one playbook, one list for each, under its file name on a line of its own, such as `playbooks/build.md`.>
51
51
 
52
- **Checks:** <only when a step ran checks, and never for the `question` playbook. Each command, then the lines of its output that show the result, copied from the output.>
52
+ **Checks:** <when a step ran checks, and always when the turn changed a file outside `intent/`, whichever playbook ran. Otherwise never for the `question` playbook. Each command, then the lines of its output that show the result, copied from the output. For a check that could not run, the check and why.>
53
53
 
54
54
  <What the current step produces: the round of questions, or the whole draft. For a question or a request no row fits, the answer.>
55
55
 
@@ -13,6 +13,6 @@ This playbook starts from a finding such as an alert, a monitoring ticket, a fai
13
13
  - Start a subagent that has not seen the diagnosis, give it only the finding, the logs and files you read, and `intent.md`, and have it try to disprove the **Problem** section from the evidence. When you cannot start a subagent, read the evidence again from the first line as if someone else wrote the intent.
14
14
  - When a claim does not survive, move it under **Open questions**, and say in the pull request that the confidence check failed and on which claim.
15
15
  5. Commit `intent.md` on a new branch, push it, and open a pull request ready for review, as the opening paragraph of `playbooks/open-pr.md` says for branches and pull requests. When you cannot open a pull request from this session, say so and give the pushed branch's name.
16
- 6. **Gate: the service owner triages the finding.** The service owner merges the pull request to fix the finding now, leaves it open to schedule it, or closes it to dismiss it. A dismissal tunes the bands: the service owner adjusts `bands.yaml` so the same noise raises fewer findings. The service owner asks the product owner to decide a product-facing finding. You do none of these. Report the pull request's URL, keep it green as `playbooks/review.md` says, and wait.
16
+ 6. **Gate: the service owner triages the finding.** The service owner merges the pull request to fix the finding now, comments on it with when the finding is scheduled and leaves it open, or closes it to dismiss it. An open pull request with no such comment is undecided: say so, and never report it as scheduled. A dismissal tunes the bands: the service owner adjusts `bands.yaml` so the same noise raises fewer findings. The service owner asks the product owner to decide a product-facing finding. You do none of these. Report the pull request's URL, keep it green as `playbooks/review.md` says, and wait.
17
17
 
18
18
  **Next:** `playbooks/design.md`; an agent; when the service owner has merged the intent's pull request and a person or a job sends `/sdlc:sdlc-mode` with the intent's path.
@@ -1,6 +1,6 @@
1
1
  # Review phase: review the pull request and drive it to green
2
2
 
3
- 1. Find what to review: the pull request this session just opened, or the pull request or branch the request names. When there is no pull request, or this session cannot reach its host, review the branch's diff against the default branch and put the findings in your reply in place of a comment.
3
+ 1. Find what to review: the pull request this session just opened, or the pull request or branch the request names. When there is no pull request, or this session cannot reach its host, review the branch's diff against the default branch and put the findings in your reply in place of a comment. When a pull request exists and this session cannot post on it, the findings are not in the pull request's history: the **Status** line says that the findings are not on the pull request and that a person posts them there, step 3 stays `open`, and you do not report the review as done.
4
4
  2. Review with a fresh context. Start a subagent that has not seen the work. Give it only the diff, `REVIEW.md`, the change's `intent/<change>/spec.md` and `plan.md`, and the four policy files in force, found as `playbooks/design.md` step 2 says. When you cannot start a subagent, read the diff again from the first line as if someone else wrote it. The review runs each pass in `REVIEW.md` and reports each finding with its pass, **Important** or **Nit**, `path:line`, and the evidence. A change that is not a fix, an intent, or a spec, and has no `spec.md` or no `plan.md`, is itself a finding.
5
5
  3. Post the findings as one comment on the pull request, most severe first. Write "No findings" when there are none. A finding does not decide the merge. When a finding flags a mistake that an earlier review of this project flagged too, its correction goes into the project's agent instructions as part of this review: in the push of step 4, or, on a pull request or a branch this session did not create, as the exact line in this comment.
6
6
  4. On a pull request or a branch that this session did not create, change nothing and push nothing. Skip the rest of this step, and in step 5 only read and answer. Otherwise, fix every Important finding. Run the checks the project's agent instructions name, and push. Answer each finding you do not fix with the reason.
@@ -46,6 +46,8 @@ Do these steps in order. Run again with the same answers, the skill writes the s
46
46
  - `2. Owners: not done. .github/CODEOWNERS still holds the placeholder.`
47
47
  - `3. Repository settings: a person checks this. They live on the host, not in the repository.`
48
48
 
49
+ Report code owners, the ruleset of the default branch, and the OpenTelemetry export each on a line of its own, with what `SETUP.md` says the step needs, such as `Ruleset of main: a person checks this. It needs a repository admin, and in a private repository GitHub Pro, Team, or Enterprise.` A step that is not done fails no check.
50
+
49
51
  7. **Do the `not done` steps an agent can do:** a step whose whole work is editing a file of the repository with values the repository or the request already gives. Do not redo a `done` step. Do not edit a file the project's rules or agent settings reserve for a person, such as rules, hooks, lint, format, and commit configuration, and the agent settings themselves; list it with the exact new text and the file it goes in. Commit nothing. After each step, run its **Check** again and report the result.
50
52
  8. **List the steps a person does outside the repository,** each with who does it, from `SETUP.md`, and the full URL of the page where it is done when `SETUP.md` or the repository names one.
51
53
  9. **Write the reply** by the reply rules in `../sdlc-mode/SKILL.md`. After them, in this order: the budget line and the four role lines as written; each step of `SETUP.md` with its state; what you did; what a person does.
@@ -3,8 +3,44 @@ command -v jq > /dev/null || exit 0
3
3
  mode=${1:-}
4
4
  input=$(cat)
5
5
  state_directory=${TMPDIR:-/tmp}/sdlc-reply-shape
6
- conversation=$(printf '%s' "$input" | jq -r '.conversation_id // ""' | tr -cd 'A-Za-z0-9_-')
6
+ conversation=$(printf '%s' "$input" | jq -r '.conversation_id // .session_id // ""')
7
+ conversation=${conversation:+$(printf '%s' "$conversation" | cksum | tr ' ' '-')}
8
+ project=$(printf '%s' "$input" | jq -r '.cwd // .workspace_roots[0] // ""')
9
+ snapshot_file=$state_directory/$conversation.files
10
+
11
+ # A copy of the index and a scratch object store keep the project's own Git data unwritten.
12
+ snapshot() {
13
+ [ -n "$project" ] && [ -n "$conversation" ] || return 1
14
+ repository=$(git -C "$project" rev-parse --show-toplevel 2> /dev/null) || return 1
15
+ index=$(git -C "$repository" rev-parse --git-path index) || return 1
16
+ objects=$(git -C "$repository" rev-parse --git-path objects) || return 1
17
+ case $index in /*) ;; *) index=$repository/$index ;; esac
18
+ case $objects in /*) ;; *) objects=$repository/$objects ;; esac
19
+ scratch=$state_directory/$conversation.git
20
+ rm -rf "$scratch"
21
+ mkdir -p "$scratch/objects" || return 1
22
+ cp "$index" "$scratch/index" 2> /dev/null
23
+ content=$(
24
+ export GIT_INDEX_FILE="$scratch/index" GIT_OBJECT_DIRECTORY="$scratch/objects" GIT_ALTERNATE_OBJECT_DIRECTORIES="$objects"
25
+ git -C "$repository" add -A -- ':(top)' ':(top,exclude)intent' 2> /dev/null
26
+ git -C "$repository" ls-files -s -- ':(top)' ':(top,exclude)intent' | cksum
27
+ )
28
+ rm -rf "$scratch"
29
+ printf '%s|%s' "$repository" "$content"
30
+ }
31
+
32
+ changed_since_snapshot() {
33
+ current=$(snapshot) || return 1
34
+ previous=$(cat "$snapshot_file" 2> /dev/null)
35
+ printf '%s' "$current" > "$snapshot_file"
36
+ [ "${previous%|*}" = "${current%|*}" ] && [ "$previous" != "$current" ]
37
+ }
38
+
7
39
  case $mode in
40
+ snapshot)
41
+ changed_since_snapshot
42
+ exit 0
43
+ ;;
8
44
  save)
9
45
  text=$(printf '%s' "$input" | jq -r '.text // ""')
10
46
  [ -n "$text" ] && [ -n "$conversation" ] || exit 0
@@ -12,12 +48,16 @@ case $mode in
12
48
  exit 0
13
49
  ;;
14
50
  followup)
51
+ changed=false
52
+ changed_since_snapshot && changed=true
15
53
  [ -n "$conversation" ] && [ -f "$state_directory/$conversation" ] || exit 0
16
54
  reply=$(cat "$state_directory/$conversation")
17
55
  rm -f "$state_directory/$conversation"
18
56
  [ "$(printf '%s' "$input" | jq -r '"\(.status // "") \(.loop_count // "")"')" = 'completed 0' ] || exit 0
19
57
  ;;
20
58
  *)
59
+ changed=false
60
+ changed_since_snapshot && changed=true
21
61
  [ "$(printf '%s' "$input" | jq -r '.stop_hook_active // false')" = true ] && exit 0
22
62
  reply=$(printf '%s' "$input" | jq -r '.last_assistant_message // ""')
23
63
  ;;
@@ -48,6 +88,10 @@ case $last in
48
88
  *) problems="${problems}End the reply with a line that starts with **Status:**; nothing may follow it. " ;;
49
89
  esac
50
90
 
91
+ if [ "$changed" = true ] && ! printf '%s\n' "$outside_fences" | grep -q '^\*\*Checks:\*\*'; then
92
+ problems="${problems}A file outside intent/ changed in this turn and the reply has no **Checks:** section. Run the checks the project names and add **Checks:** with each command and the lines of its output that show the result, or with each check that could not run and why. "
93
+ fi
94
+
51
95
  [ -z "$problems" ] && exit 0
52
96
  jq -n --arg mode "$mode" --arg reason "${problems}Send the whole reply again." \
53
97
  'if $mode == "followup" then {followup_message:$reason} else {decision:"block",reason:$reason} end'
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "version": 1,
3
3
  "hooks": {
4
+ "beforeSubmitPrompt": [
5
+ {
6
+ "command": "sh \"${CURSOR_PLUGIN_ROOT}/hooks/check-reply-shape.sh\" snapshot"
7
+ }
8
+ ],
4
9
  "afterAgentResponse": [
5
10
  {
6
11
  "command": "sh \"${CURSOR_PLUGIN_ROOT}/hooks/check-reply-shape.sh\" save"
@@ -49,7 +49,7 @@ For example, the person writes "who must approve pull request #7 of https://host
49
49
 
50
50
  **Steps:** <only when the session has no task-list tool, and never for the `question` playbook. One line for each numbered step of the playbook: `1. <the step, copied word for word>: done | open | skip: <reason>`. When the turn ran more than one playbook, one list for each, under its file name on a line of its own, such as `playbooks/build.md`.>
51
51
 
52
- **Checks:** <only when a step ran checks, and never for the `question` playbook. Each command, then the lines of its output that show the result, copied from the output.>
52
+ **Checks:** <when a step ran checks, and always when the turn changed a file outside `intent/`, whichever playbook ran. Otherwise never for the `question` playbook. Each command, then the lines of its output that show the result, copied from the output. For a check that could not run, the check and why.>
53
53
 
54
54
  <What the current step produces: the round of questions, or the whole draft. For a question or a request no row fits, the answer.>
55
55
 
@@ -13,6 +13,6 @@ This playbook starts from a finding such as an alert, a monitoring ticket, a fai
13
13
  - Start a subagent that has not seen the diagnosis, give it only the finding, the logs and files you read, and `intent.md`, and have it try to disprove the **Problem** section from the evidence. When you cannot start a subagent, read the evidence again from the first line as if someone else wrote the intent.
14
14
  - When a claim does not survive, move it under **Open questions**, and say in the pull request that the confidence check failed and on which claim.
15
15
  5. Commit `intent.md` on a new branch, push it, and open a pull request ready for review, as the opening paragraph of `playbooks/open-pr.md` says for branches and pull requests. When you cannot open a pull request from this session, say so and give the pushed branch's name.
16
- 6. **Gate: the service owner triages the finding.** The service owner merges the pull request to fix the finding now, leaves it open to schedule it, or closes it to dismiss it. A dismissal tunes the bands: the service owner adjusts `bands.yaml` so the same noise raises fewer findings. The service owner asks the product owner to decide a product-facing finding. You do none of these. Report the pull request's URL, keep it green as `playbooks/review.md` says, and wait.
16
+ 6. **Gate: the service owner triages the finding.** The service owner merges the pull request to fix the finding now, comments on it with when the finding is scheduled and leaves it open, or closes it to dismiss it. An open pull request with no such comment is undecided: say so, and never report it as scheduled. A dismissal tunes the bands: the service owner adjusts `bands.yaml` so the same noise raises fewer findings. The service owner asks the product owner to decide a product-facing finding. You do none of these. Report the pull request's URL, keep it green as `playbooks/review.md` says, and wait.
17
17
 
18
18
  **Next:** `playbooks/design.md`; an agent; when the service owner has merged the intent's pull request and a person or a job sends `/sdlc:sdlc-mode` with the intent's path.
@@ -1,6 +1,6 @@
1
1
  # Review phase: review the pull request and drive it to green
2
2
 
3
- 1. Find what to review: the pull request this session just opened, or the pull request or branch the request names. When there is no pull request, or this session cannot reach its host, review the branch's diff against the default branch and put the findings in your reply in place of a comment.
3
+ 1. Find what to review: the pull request this session just opened, or the pull request or branch the request names. When there is no pull request, or this session cannot reach its host, review the branch's diff against the default branch and put the findings in your reply in place of a comment. When a pull request exists and this session cannot post on it, the findings are not in the pull request's history: the **Status** line says that the findings are not on the pull request and that a person posts them there, step 3 stays `open`, and you do not report the review as done.
4
4
  2. Review with a fresh context. Start a subagent that has not seen the work. Give it only the diff, `REVIEW.md`, the change's `intent/<change>/spec.md` and `plan.md`, and the four policy files in force, found as `playbooks/design.md` step 2 says. When you cannot start a subagent, read the diff again from the first line as if someone else wrote it. The review runs each pass in `REVIEW.md` and reports each finding with its pass, **Important** or **Nit**, `path:line`, and the evidence. A change that is not a fix, an intent, or a spec, and has no `spec.md` or no `plan.md`, is itself a finding.
5
5
  3. Post the findings as one comment on the pull request, most severe first. Write "No findings" when there are none. A finding does not decide the merge. When a finding flags a mistake that an earlier review of this project flagged too, its correction goes into the project's agent instructions as part of this review: in the push of step 4, or, on a pull request or a branch this session did not create, as the exact line in this comment.
6
6
  4. On a pull request or a branch that this session did not create, change nothing and push nothing. Skip the rest of this step, and in step 5 only read and answer. Otherwise, fix every Important finding. Run the checks the project's agent instructions name, and push. Answer each finding you do not fix with the reason.
@@ -46,6 +46,8 @@ Do these steps in order. Run again with the same answers, the skill writes the s
46
46
  - `2. Owners: not done. .github/CODEOWNERS still holds the placeholder.`
47
47
  - `3. Repository settings: a person checks this. They live on the host, not in the repository.`
48
48
 
49
+ Report code owners, the ruleset of the default branch, and the OpenTelemetry export each on a line of its own, with what `SETUP.md` says the step needs, such as `Ruleset of main: a person checks this. It needs a repository admin, and in a private repository GitHub Pro, Team, or Enterprise.` A step that is not done fails no check.
50
+
49
51
  7. **Do the `not done` steps an agent can do:** a step whose whole work is editing a file of the repository with values the repository or the request already gives. Do not redo a `done` step. Do not edit a file the project's rules or agent settings reserve for a person, such as rules, hooks, lint, format, and commit configuration, and the agent settings themselves; list it with the exact new text and the file it goes in. Commit nothing. After each step, run its **Check** again and report the result.
50
52
  8. **List the steps a person does outside the repository,** each with who does it, from `SETUP.md`, and the full URL of the page where it is done when `SETUP.md` or the repository names one.
51
53
  9. **Write the reply** by the reply rules in `../sdlc-mode/SKILL.md`. After them, in this order: the budget line and the four role lines as written; each step of `SETUP.md` with its state; what you did; what a person does.
@@ -1,13 +1,16 @@
1
1
  import {execFileSync, spawnSync} from "node:child_process";
2
- import {mkdtempSync, rmSync, writeFileSync} from "node:fs";
2
+ import {existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from "node:fs";
3
3
  import path from "node:path";
4
4
  import {tmpdir} from "node:os";
5
5
 
6
6
  type GateCase = {
7
+ expectedApproval?: string;
7
8
  expectedExit: 0 | 2;
8
9
  expectedPermission?: "allow";
10
+ expectedSession?: string;
9
11
  gate: string;
10
12
  input: Record<string, unknown>;
13
+ isRecordUnwritable?: boolean;
11
14
  name: string;
12
15
  projectDirectory?: string;
13
16
  releaseApproval?: string;
@@ -31,6 +34,7 @@ const mcpGate = ".agents/hooks/agent-mcp-gate.sh";
31
34
  const productionGate = ".agents/hooks/agent-production-gate.sh";
32
35
  const testEditGate = ".agents/hooks/agent-test-edit-gate.sh";
33
36
  const sourceFile = "src/example.ts";
37
+ const statusCommand = "git status";
34
38
 
35
39
  const blockedCommands = [
36
40
  "printf 'exit 0' > .husky/pre-commit",
@@ -109,7 +113,7 @@ const blockedCommands = [
109
113
 
110
114
  const allowedCommands = [
111
115
  'git commit -m "add login"',
112
- "git status",
116
+ statusCommand,
113
117
  "pnpm check",
114
118
  "cat .husky/pre-commit",
115
119
  "git diff .husky/pre-commit",
@@ -205,8 +209,22 @@ const productionCommands = [
205
209
  "cd a\\\\\ngit push origin v1.0.0",
206
210
  ];
207
211
 
208
- const approvalLiftsGate = ["CHG-123", "1", "CHG-1", "off", "00", " "];
209
- const approvalKeepsGate = ["", "0", "false", "no", "FALSE", "No", "nO"];
212
+ const approvalLiftsGate = ["CHG-123", "CHG-1"];
213
+
214
+ const approvalKeepsGate = [
215
+ "",
216
+ "0",
217
+ "false",
218
+ "no",
219
+ "FALSE",
220
+ "No",
221
+ "nO",
222
+ "1",
223
+ "off",
224
+ "00",
225
+ " ",
226
+ "chg-123",
227
+ ];
210
228
 
211
229
  const nonProductionCommands = [
212
230
  "gh workflow run deploy.yml -f environment=uat -f commit_sha=abc",
@@ -440,6 +458,7 @@ const cases: GateCase[] = [
440
458
  ...approvalLiftsGate.map(releaseApproval => ({
441
459
  gate: productionGate,
442
460
  releaseApproval,
461
+ expectedApproval: releaseApproval,
443
462
  input: {
444
463
  tool_input: {
445
464
  command: railwayProductionCommand,
@@ -554,12 +573,166 @@ const cases: GateCase[] = [
554
573
  expectedExit: expectedExit as 0 | 2,
555
574
  name: `test edit: ${name}`,
556
575
  })),
576
+ {
577
+ gate: shellGate,
578
+ expectedPermission: "allow",
579
+ expectedSession: "claude-session",
580
+ name: "record: the session of a Claude Code hook input",
581
+ expectedExit: 0,
582
+ input: {
583
+ command: statusCommand,
584
+ session_id: "claude-session",
585
+ },
586
+ },
587
+ {
588
+ gate: productionGate,
589
+ expectedSession: "cursor-conversation",
590
+ name: "record: the conversation of a Cursor hook input",
591
+ expectedExit: 2,
592
+ input: {
593
+ command: railwayProductionCommand,
594
+ conversation_id: "cursor-conversation",
595
+ },
596
+ },
597
+ {
598
+ gate: shellGate,
599
+ name: "record: a blocked command that holds a secret",
600
+ expectedExit: 2,
601
+ input: {
602
+ command: "rm sk_live_SECRET123=.agents/hooks/agent-shell-gate.sh",
603
+ },
604
+ },
605
+ {
606
+ gate: constitutionGate,
607
+ name: "record: a blocked path that holds a secret",
608
+ expectedExit: 2,
609
+ input: {
610
+ file_path: ".agents/rules/sk_live_SECRET123.md",
611
+ },
612
+ },
613
+ {
614
+ gate: productionGate,
615
+ projectDirectory: outsideRepository,
616
+ name: "record: an approval in the form when the project has no form file",
617
+ releaseApproval: "CHG-123",
618
+ expectedExit: 2,
619
+ input: {
620
+ command: railwayProductionCommand,
621
+ },
622
+ },
623
+ {
624
+ gate: shellGate,
625
+ expectedPermission: "allow",
626
+ name: "record: an allow when the record cannot be written",
627
+ expectedExit: 0,
628
+ isRecordUnwritable: true,
629
+ input: {
630
+ command: statusCommand,
631
+ },
632
+ },
633
+ {
634
+ gate: productionGate,
635
+ name: "record: a block when the record cannot be written",
636
+ expectedExit: 2,
637
+ isRecordUnwritable: true,
638
+ input: {
639
+ command: railwayProductionCommand,
640
+ },
641
+ },
557
642
  ];
558
643
 
559
- const failures = cases.filter(gateCase => {
644
+ const recordDirectory = mkdtempSync(path.join(tmpdir(), "agent-gates-records-"));
645
+ const recordKeys = new Set(["approval", "decision", "hook", "reason", "session", "time", "user"]);
646
+
647
+ const gitUsername = spawnSync("git", ["config", "user.name"], {
648
+ encoding: "utf8",
649
+ }).stdout.trim();
650
+
651
+ const unwritableRecord = path.join(recordDirectory, "a-file");
652
+
653
+ writeFileSync(unwritableRecord, "");
654
+
655
+ /**
656
+ * Read the lines a gate appended to its record.
657
+ * @param recordPath The file the gate was told to append to.
658
+ * @returns The lines, or none when the gate wrote no file.
659
+ */
660
+ function readRecordLines(recordPath: string): string[] {
661
+ return existsSync(recordPath) ? readFileSync(recordPath, "utf8").trim().split("\n") : [];
662
+ }
663
+
664
+ /**
665
+ * List the words of a hook input that look like a path, an assignment, or a file name.
666
+ * @param input The hook input.
667
+ * @returns Each such word of six characters or more.
668
+ */
669
+ function listInputWords(input: Record<string, unknown>): string[] {
670
+ return JSON.stringify(input)
671
+ .split(/[\s"'\\{}:,]+/u)
672
+ .filter(word => word.length >= 6 && /[./=]/u.test(word));
673
+ }
674
+
675
+ /**
676
+ * Tell whether a gate wrote the record its decision calls for, and nothing a record must not hold.
677
+ * @param gateCase The case that ran.
678
+ * @param recordPath The file the gate was told to append to.
679
+ * @returns True when the record is missing, malformed, or wrong.
680
+ */
681
+ function isRecordWrong(gateCase: GateCase, recordPath: string): boolean {
682
+ type DecisionRecord = {
683
+ approval?: string;
684
+ decision?: string;
685
+ hook?: string;
686
+ reason?: string;
687
+ session?: string;
688
+ time?: string;
689
+ user?: string;
690
+ };
691
+
692
+ const lines = readRecordLines(recordPath);
693
+ const lastLine = lines.at(-1) ?? "{}";
694
+ const record = JSON.parse(lastLine) as DecisionRecord;
695
+ const isBlock = gateCase.expectedExit === 2;
696
+ const isLifted = gateCase.expectedApproval !== undefined;
697
+ const secondsFromNow = Math.abs(Date.now() - Date.parse(record.time ?? "")) / 1000;
698
+
699
+ const found = [
700
+ record.decision,
701
+ record.hook,
702
+ record.session,
703
+ record.approval,
704
+ record.user,
705
+ record.reason !== undefined,
706
+ (record.time ?? "").endsWith("Z") && secondsFromNow < 600,
707
+ Object.keys(record).every(key => recordKeys.has(key)),
708
+ ];
709
+
710
+ const expected = [
711
+ isBlock ? "block" : "allow",
712
+ path.basename(gateCase.gate, ".sh"),
713
+ gateCase.expectedSession,
714
+ gateCase.expectedApproval,
715
+ isLifted ? gitUsername : undefined,
716
+ isBlock,
717
+ true,
718
+ true,
719
+ ];
720
+
721
+ const isHoldingInput = listInputWords(gateCase.input).some(word => lastLine.includes(word));
722
+
723
+ return lines.length !== 1 || JSON.stringify(found) !== JSON.stringify(expected) || isHoldingInput;
724
+ }
725
+
726
+ const failures = cases.filter((gateCase, index) => {
727
+ const recordPath =
728
+ gateCase.isRecordUnwritable === true
729
+ ? path.join(unwritableRecord, "gate-decisions.jsonl")
730
+ : path.join(recordDirectory, `${index}.jsonl`);
731
+
560
732
  const result = spawnSync(gateCase.gate, {
561
733
  env: {
562
734
  ...childEnvironment,
735
+ AGENT_GATE_LOG: recordPath,
563
736
  CLAUDE_PROJECT_DIR: gateCase.projectDirectory ?? process.cwd(),
564
737
  CURSOR_PROJECT_DIR: gateCase.projectDirectory ?? process.cwd(),
565
738
  RELEASE_APPROVAL: gateCase.releaseApproval,
@@ -572,10 +745,17 @@ const failures = cases.filter(gateCase => {
572
745
  (JSON.parse(result.stdout.toString()) as {permission?: string}).permission !==
573
746
  gateCase.expectedPermission;
574
747
 
575
- return result.status !== gateCase.expectedExit || isPermissionWrong;
748
+ const isRecordChecked = gateCase.isRecordUnwritable !== true;
749
+
750
+ return (
751
+ result.status !== gateCase.expectedExit ||
752
+ isPermissionWrong ||
753
+ (isRecordChecked && isRecordWrong(gateCase, recordPath))
754
+ );
576
755
  });
577
756
 
578
757
  for (const directory of [
758
+ recordDirectory,
579
759
  fixBranchWithTest,
580
760
  fixBranchWithoutTest,
581
761
  featureBranchWithTest,