@next-friday/create-app 9.4.1 → 9.5.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@next-friday/create-app",
3
- "version": "9.4.1",
3
+ "version": "9.5.1",
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
+ }
@@ -0,0 +1 @@
1
+ ^[A-Z]+-[0-9]+$
@@ -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."
@@ -92,7 +92,7 @@ Replace `security@example.com` in `SECURITY.md` with the contact the security po
92
92
  - **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
93
  - **Higher risk (engineering lead):** decide which changes count as higher risk. A tech lead or architect approves their specs and plans.
94
94
  - **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`. Any value lifts the gate except an empty value, `0`, `false`, and `no`, in any letter case.
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, 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
96
  - **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
97
  - **People without Git experience:** connect GitHub to Claude (claude.ai or Cowork), so Claude commits `intent.md` for them.
98
98
 
@@ -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,