@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.
- package/package.json +1 -1
- package/templates/base/.agents/hooks/agent-mcp-gate.sh +8 -0
- package/templates/base/.agents/hooks/agent-production-gate.sh +24 -5
- package/templates/base/.agents/hooks/agent-shell-gate.sh +9 -1
- package/templates/base/.agents/hooks/agent-test-edit-gate.sh +10 -2
- package/templates/base/.agents/hooks/record-gate-decision.sh +28 -0
- package/templates/base/.cursor/hooks/agent-constitution-gate.sh +10 -2
- package/templates/base/SETUP.md +4 -2
- package/templates/base/plugins/sdlc/claude-code/hooks/check-reply-shape.sh +45 -1
- package/templates/base/plugins/sdlc/claude-code/hooks/hooks.json +11 -0
- package/templates/base/plugins/sdlc/claude-code/skills/sdlc-mode/SKILL.md +1 -1
- package/templates/base/plugins/sdlc/claude-code/skills/sdlc-mode/playbooks/maintain.md +1 -1
- package/templates/base/plugins/sdlc/claude-code/skills/sdlc-mode/playbooks/review.md +1 -1
- package/templates/base/plugins/sdlc/claude-code/skills/setup-sdlc/SKILL.md +2 -0
- package/templates/base/plugins/sdlc/cursor/hooks/check-reply-shape.sh +45 -1
- package/templates/base/plugins/sdlc/cursor/hooks/hooks.json +5 -0
- package/templates/base/plugins/sdlc/cursor/skills/sdlc-mode/SKILL.md +1 -1
- package/templates/base/plugins/sdlc/cursor/skills/sdlc-mode/playbooks/maintain.md +1 -1
- package/templates/base/plugins/sdlc/cursor/skills/sdlc-mode/playbooks/review.md +1 -1
- package/templates/base/plugins/sdlc/cursor/skills/setup-sdlc/SKILL.md +2 -0
- package/templates/base/scripts/check-agent-gates.ts +186 -6
package/package.json
CHANGED
|
@@ -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
|
-
|
|
25
|
+
input=$(cat)
|
|
26
|
+
command=$(jq -r '.command // .tool_input.command // empty' <<< "$input")
|
|
18
27
|
[[ -n $command ]] || allow
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
*)
|
|
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."
|
package/templates/base/SETUP.md
CHANGED
|
@@ -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 // ""'
|
|
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'
|
|
@@ -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:** <
|
|
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,
|
|
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 // ""'
|
|
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'
|
|
@@ -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:** <
|
|
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,
|
|
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
|
-
|
|
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", "
|
|
209
|
-
|
|
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
|
|
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
|
-
|
|
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,
|