claude-dev-env 1.62.1 → 1.64.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.
@@ -29,12 +29,16 @@ from config.verified_commit_constants import (
29
29
  AGENT_META_SIDECAR_SUFFIX,
30
30
  AGENT_META_TYPE_KEY,
31
31
  AGENT_TRANSCRIPT_GLOB,
32
+ BRANCH_REFERENCE_PREFIX,
33
+ BRANCH_WORKTREE_ABSENT_MESSAGE,
32
34
  CLAUDE_HOME_DIRECTORY_NAME,
33
35
  CONFTEST_FILE_NAME,
34
36
  DOCS_ONLY_EXTENSIONS,
35
37
  ALL_FALLBACK_BASE_REFERENCES,
38
+ EMPTY_SURFACE_GUARD_MESSAGE,
36
39
  GIT_TIMEOUT_SECONDS,
37
40
  MANIFEST_HASH_CLI_FLAG,
41
+ MANIFEST_HASH_FOR_BRANCH_CLI_FLAG,
38
42
  MINIMUM_STATUS_FIELD_COUNT,
39
43
  MINTING_AGENT_TYPE,
40
44
  PYTHON_EXTENSION,
@@ -52,10 +56,13 @@ from config.verified_commit_constants import (
52
56
  TRANSCRIPT_TEXT_KEY,
53
57
  VERDICT_DIRECTORY_NAME,
54
58
  VERDICT_FENCE_PATTERN,
59
+ VERDICT_FILE_GLOB,
55
60
  VERDICT_JSON_INDENT,
56
61
  VERDICT_KEY_ALL_PASS,
57
62
  VERDICT_KEY_FINDINGS,
58
63
  VERDICT_KEY_MANIFEST_SHA256,
64
+ WORKTREE_LIST_BRANCH_PREFIX,
65
+ WORKTREE_LIST_PATH_PREFIX,
59
66
  )
60
67
 
61
68
 
@@ -243,6 +250,65 @@ def manifest_sha256(surface_manifest_text: str) -> str:
243
250
  return hashlib.sha256(surface_manifest_text.encode("utf-8")).hexdigest()
244
251
 
245
252
 
253
+ def empty_surface_hash() -> str:
254
+ """Return the manifest hash that represents an empty change surface.
255
+
256
+ A work tree whose HEAD equals the merge base has no changed or untracked
257
+ files, so ``branch_surface_manifest`` returns ``""`` and this hash is what
258
+ the minter or store CLI would produce for it. Comparing an attested hash
259
+ against this value lets the minter refuse to mint for a verifier that ran
260
+ in the wrong work tree.
261
+
262
+ Returns:
263
+ The hex sha256 digest of the empty surface manifest (the empty string).
264
+ """
265
+ return manifest_sha256("")
266
+
267
+
268
+ def worktree_path_for_branch(repo_directory: str, branch_name: str) -> str | None:
269
+ """Find the work-tree directory that has a given branch checked out.
270
+
271
+ Parses the porcelain output of ``git worktree list --porcelain`` and
272
+ returns the ``worktree <path>`` line whose block carries a
273
+ ``branch refs/heads/<branch_name>`` line. Returns None when git fails
274
+ or no work tree holds the branch.
275
+
276
+ Args:
277
+ repo_directory: Any directory inside the repository.
278
+ branch_name: The short branch name to locate (without ``refs/heads/``).
279
+
280
+ Returns:
281
+ The absolute path of the work tree that has the branch checked out,
282
+ or None when no work tree holds the branch or git fails.
283
+ """
284
+ porcelain_output = run_git(repo_directory, "worktree", "list", "--porcelain")
285
+ if porcelain_output is None:
286
+ return None
287
+ target_branch_reference = f"{BRANCH_REFERENCE_PREFIX}{branch_name}"
288
+ current_worktree_path: str | None = None
289
+ for each_line in porcelain_output.splitlines():
290
+ if each_line.startswith(WORKTREE_LIST_PATH_PREFIX):
291
+ current_worktree_path = each_line[len(WORKTREE_LIST_PATH_PREFIX):]
292
+ elif each_line.startswith(WORKTREE_LIST_BRANCH_PREFIX):
293
+ branch_reference = each_line[len(WORKTREE_LIST_BRANCH_PREFIX):]
294
+ if branch_reference == target_branch_reference and current_worktree_path:
295
+ return current_worktree_path
296
+ return None
297
+
298
+
299
+ def verdict_directory() -> Path:
300
+ """Return the shared directory holding every work tree's verdict file.
301
+
302
+ Verdicts live outside any repository (under the user's Claude home) so no
303
+ repo accumulates untracked verdict files; every work tree's verdict shares
304
+ this one directory, distinguished by file name.
305
+
306
+ Returns:
307
+ The verdict directory under the user's Claude home.
308
+ """
309
+ return Path.home() / CLAUDE_HOME_DIRECTORY_NAME / VERDICT_DIRECTORY_NAME
310
+
311
+
246
312
  def verdict_path_for_repo(repo_root: str) -> Path:
247
313
  """Derive the verdict file path for a repository work tree.
248
314
 
@@ -258,9 +324,7 @@ def verdict_path_for_repo(repo_root: str) -> Path:
258
324
  """
259
325
  normalized_root = str(Path(repo_root).resolve()).replace("\\", "/").lower()
260
326
  root_key = hashlib.sha256(normalized_root.encode("utf-8")).hexdigest()[:ROOT_KEY_HEX_LENGTH]
261
- return (
262
- Path.home() / CLAUDE_HOME_DIRECTORY_NAME / VERDICT_DIRECTORY_NAME / f"{root_key}.json"
263
- )
327
+ return verdict_directory() / f"{root_key}.json"
264
328
 
265
329
 
266
330
  def load_valid_verdict(repo_root: str, expected_manifest_sha256: str) -> dict | None:
@@ -289,6 +353,44 @@ def load_valid_verdict(repo_root: str, expected_manifest_sha256: str) -> dict |
289
353
  return verdict_record
290
354
 
291
355
 
356
+ def minted_verdict_covers_surface(expected_manifest_sha256: str) -> bool:
357
+ """Decide whether any minted verdict covers the live surface, keyed by hash.
358
+
359
+ A verdict's bound ``manifest_sha256`` commits to the exact set of surface
360
+ file paths and their byte contents; the work tree's location never enters
361
+ the hash. A clean verdict minted while verifying one work tree therefore
362
+ proves the same change surface in a sibling work tree of the same branch,
363
+ even though each work tree files its verdict under its own path-keyed name.
364
+ Scanning every verdict file by bound hash lets that verdict clear the
365
+ sibling's commit, while a verdict bound to a different hash — different
366
+ code — never matches. The path-keyed ``load_valid_verdict`` stays the fast
367
+ same-work-tree lookup; this is the cross-work-tree fallback.
368
+
369
+ Args:
370
+ expected_manifest_sha256: Hash of the live surface manifest the verdict
371
+ must match exactly.
372
+
373
+ Returns:
374
+ True as soon as one verdict file reports ``all_pass`` true and binds to
375
+ the expected hash; False when none match or the directory is absent.
376
+ """
377
+ verdict_dir = verdict_directory()
378
+ if not verdict_dir.is_dir():
379
+ return False
380
+ for each_verdict_file in sorted(verdict_dir.glob(VERDICT_FILE_GLOB)):
381
+ try:
382
+ verdict_record = json.loads(each_verdict_file.read_text(encoding="utf-8"))
383
+ except (OSError, json.JSONDecodeError):
384
+ continue
385
+ if not isinstance(verdict_record, dict):
386
+ continue
387
+ if verdict_record.get(VERDICT_KEY_ALL_PASS) is not True:
388
+ continue
389
+ if verdict_record.get(VERDICT_KEY_MANIFEST_SHA256) == expected_manifest_sha256:
390
+ return True
391
+ return False
392
+
393
+
292
394
  def _subagents_directory_for_transcript(transcript_path: str) -> Path | None:
293
395
  """Locate the live session's subagents directory from a transcript path.
294
396
 
@@ -647,14 +749,18 @@ def _print_live_manifest_hash(repo_directory: str) -> int:
647
749
  """Print the live surface manifest hash for a repo, for a workflow verifier.
648
750
 
649
751
  A workflow code-verifier runs this to learn the exact hash to bind its
650
- verdict to, so stdout carries only the hash and nothing else.
752
+ verdict to, so stdout carries only the hash and nothing else. When the
753
+ work tree has no changed or untracked files (empty change surface), this
754
+ prints the empty-surface guard message to stderr and returns nonzero —
755
+ an empty surface means the verifier is pointed at the wrong work tree.
651
756
 
652
757
  Args:
653
758
  repo_directory: A directory inside the work tree to bind the verdict to.
654
759
 
655
760
  Returns:
656
- 0 after printing the hash; nonzero with no stdout when the repo root or
657
- merge base cannot be resolved.
761
+ 0 after printing the hash; nonzero with no stdout when the repo root,
762
+ merge base, or manifest cannot be resolved, or when the change surface
763
+ is empty (wrong work tree).
658
764
  """
659
765
  repo_root = resolve_repo_root(repo_directory)
660
766
  if repo_root is None:
@@ -665,20 +771,69 @@ def _print_live_manifest_hash(repo_directory: str) -> int:
665
771
  surface_manifest_text = branch_surface_manifest(repo_root, merge_base_sha)
666
772
  if surface_manifest_text is None:
667
773
  return 1
774
+ if surface_manifest_text == "":
775
+ print(EMPTY_SURFACE_GUARD_MESSAGE.format(repo_root=repo_root), file=sys.stderr)
776
+ return 1
668
777
  print(manifest_sha256(surface_manifest_text))
669
778
  return 0
670
779
 
671
780
 
781
+ def _print_branch_manifest_hash(branch_name: str) -> int:
782
+ """Print the manifest hash for the work tree that holds a given branch.
783
+
784
+ Resolves the repository root from the current working directory, then
785
+ finds the work tree that has ``branch_name`` checked out, and delegates
786
+ to ``_print_live_manifest_hash`` for that work tree. This mode is immune
787
+ to the verifier's own cwd: it always hashes the work tree that holds the
788
+ branch under review, regardless of where the verifier itself is running.
789
+
790
+ Args:
791
+ branch_name: The short branch name to locate (without ``refs/heads/``).
792
+
793
+ Returns:
794
+ 0 after printing the hash; nonzero with a stderr message when the
795
+ repo root cannot be resolved, no work tree holds the branch, or the
796
+ located work tree has an empty change surface.
797
+ """
798
+ repo_root = resolve_repo_root(str(Path.cwd()))
799
+ if repo_root is None:
800
+ print("ERROR: Current directory is not inside a git repository.", file=sys.stderr)
801
+ return 1
802
+ branch_worktree_path = worktree_path_for_branch(repo_root, branch_name)
803
+ if branch_worktree_path is None:
804
+ print(
805
+ BRANCH_WORKTREE_ABSENT_MESSAGE.format(branch=branch_name),
806
+ file=sys.stderr,
807
+ )
808
+ return 1
809
+ return _print_live_manifest_hash(branch_worktree_path)
810
+
811
+
672
812
  def main() -> None:
673
813
  """Run the verdict-store CLI: compute the live surface-manifest hash.
674
814
 
675
- Reads ``--manifest-hash <repo_root>`` from argv and prints the live
676
- ``manifest_sha256`` so a workflow code-verifier can bind its verdict to the
677
- exact surface the gate checks. Exits nonzero with no stdout on any other
678
- argument shape or when the surface cannot be resolved.
815
+ Two modes:
816
+
817
+ ``--manifest-hash <work-tree-dir>``
818
+ Print the live ``manifest_sha256`` for the given work tree directory
819
+ so a workflow code-verifier can bind its verdict to the exact surface
820
+ the gate checks. Fails with a stderr message when the change surface
821
+ is empty (wrong work tree).
822
+
823
+ ``--manifest-hash-for-branch <branch>``
824
+ Resolve the work tree that has ``<branch>`` checked out (via
825
+ ``git worktree list --porcelain``) and print its manifest hash.
826
+ Immune to the verifier's own cwd — always targets the branch's own
827
+ work tree. Fails when no work tree holds the branch or the surface
828
+ is empty.
829
+
830
+ Exits nonzero with no stdout on any other argument shape or when the
831
+ surface cannot be resolved.
679
832
  """
680
833
  if len(sys.argv) == 3 and sys.argv[1] == MANIFEST_HASH_CLI_FLAG:
681
834
  sys.exit(_print_live_manifest_hash(sys.argv[2]))
835
+ if len(sys.argv) == 3 and sys.argv[1] == MANIFEST_HASH_FOR_BRANCH_CLI_FLAG:
836
+ sys.exit(_print_branch_manifest_hash(sys.argv[2]))
682
837
  sys.exit(1)
683
838
 
684
839
 
@@ -13,8 +13,11 @@ and allows the command only when one of these holds:
13
13
  - the surface is mechanically exempt (docs/images by extension, pytest
14
14
  test files by name convention, Python files whose docstring-stripped
15
15
  AST is unchanged), or
16
- - a verdict minted by ``verifier_verdict_minter.py`` reports ``all_pass``
17
- and binds to the exact live manifest hash.
16
+ - a passing verifier verdict binds to the exact live manifest hash —
17
+ matched by content hash, not by work-tree location, so a verdict
18
+ ``verifier_verdict_minter.py`` minted while verifying any work tree of
19
+ the surface clears the commit, as does one a workflow ``code-verifier``
20
+ emitted in its own transcript.
18
21
 
19
22
  The surface binds every changed and untracked file's content, so slicing
20
23
  work into small commits or staging files cannot move the hash, while any
@@ -57,6 +60,7 @@ from verification_verdict_store import (
57
60
  is_verification_exempt_diff,
58
61
  load_valid_verdict,
59
62
  manifest_sha256,
63
+ minted_verdict_covers_surface,
60
64
  resolve_merge_base,
61
65
  resolve_repo_root,
62
66
  workflow_verdict_covers_surface,
@@ -500,6 +504,8 @@ def deny_reason_for_directory(target_directory: str, transcript_path: str) -> st
500
504
  live_manifest_sha256 = manifest_sha256(surface_manifest_text)
501
505
  if load_valid_verdict(repo_root, live_manifest_sha256) is not None:
502
506
  return None
507
+ if minted_verdict_covers_surface(live_manifest_sha256):
508
+ return None
503
509
  if workflow_verdict_covers_surface(transcript_path, live_manifest_sha256):
504
510
  return None
505
511
  hash_preview = live_manifest_sha256[:HASH_PREVIEW_LENGTH]
@@ -35,9 +35,13 @@ blocking_directory = str(Path(__file__).resolve().parent)
35
35
  if blocking_directory not in sys.path:
36
36
  sys.path.insert(0, blocking_directory)
37
37
 
38
- from config.verified_commit_constants import MINTING_AGENT_TYPE
38
+ from config.verified_commit_constants import (
39
+ MINTING_AGENT_TYPE,
40
+ VERDICT_KEY_MANIFEST_SHA256,
41
+ )
39
42
  from verification_verdict_store import (
40
43
  branch_surface_manifest,
44
+ empty_surface_hash,
41
45
  manifest_sha256,
42
46
  resolve_merge_base,
43
47
  resolve_repo_root,
@@ -169,6 +173,53 @@ def resolved_subagent_type(subagent_stop_payload: dict) -> str | None:
169
173
  )
170
174
 
171
175
 
176
+ def _attested_or_recomputed_hash(verdict_record: dict, repo_root: str) -> str | None:
177
+ """Choose the surface hash the minted verdict binds to.
178
+
179
+ A code-verifier that verifies a work tree other than the stop event's cwd
180
+ attests the surface it checked by emitting ``manifest_sha256`` in its
181
+ verdict fence (computed against the verified work tree via the
182
+ ``--manifest-hash`` CLI). Binding the minted verdict to that attested hash
183
+ keeps the verdict tied to the code actually verified rather than the
184
+ subagent's cwd, so a verdict earned for one work tree clears a commit in a
185
+ sibling work tree of the same surface. When the fence attests no hash, the
186
+ minter recomputes one from the cwd work tree, which is correct whenever the
187
+ verifier ran in the work tree it verified.
188
+
189
+ Returns None (mints nothing) in two empty-surface cases:
190
+
191
+ - The attested hash equals ``empty_surface_hash()`` — the verifier called
192
+ the store CLI on a wrong (empty) work tree and the hash it got back
193
+ represents nothing.
194
+ - The recompute branch produces an empty manifest — the cwd work tree also
195
+ has no changed or untracked files versus the merge base.
196
+
197
+ Args:
198
+ verdict_record: The parsed verdict fence from the verifier transcript.
199
+ repo_root: The work-tree root resolved from the stop event's cwd, used
200
+ for the recompute fallback.
201
+
202
+ Returns:
203
+ The attested ``manifest_sha256`` when the fence carries a non-empty
204
+ string one that is not the empty-surface sentinel; the cwd work tree's
205
+ recomputed surface hash when the fence attests nothing and the surface
206
+ is non-empty; or None when the attested hash is the empty-surface
207
+ sentinel, the surface manifest is empty, or no upstream base resolves.
208
+ """
209
+ attested_manifest_sha256 = verdict_record.get(VERDICT_KEY_MANIFEST_SHA256)
210
+ if isinstance(attested_manifest_sha256, str) and attested_manifest_sha256:
211
+ if attested_manifest_sha256 == empty_surface_hash():
212
+ return None
213
+ return attested_manifest_sha256
214
+ merge_base_sha = resolve_merge_base(repo_root)
215
+ if merge_base_sha is None:
216
+ return None
217
+ surface_manifest_text = branch_surface_manifest(repo_root, merge_base_sha)
218
+ if not surface_manifest_text:
219
+ return None
220
+ return manifest_sha256(surface_manifest_text)
221
+
222
+
172
223
  def mint_for_payload(subagent_stop_payload: dict) -> Path | None:
173
224
  """Mint a verdict file for a code-verifier stop event.
174
225
 
@@ -177,8 +228,10 @@ def mint_for_payload(subagent_stop_payload: dict) -> Path | None:
177
228
 
178
229
  Returns:
179
230
  The verdict file path when minted; None when the payload is not a
180
- code-verifier stop, the transcript holds no verdict, or the
181
- session directory is not a work tree with an upstream base.
231
+ code-verifier stop, the transcript holds no verdict, the cwd is not a
232
+ work tree, or for a verdict that attests no ``manifest_sha256`` of
233
+ its own — that work tree has no upstream base to recompute the surface
234
+ hash from.
182
235
  """
183
236
  if resolved_subagent_type(subagent_stop_payload) != MINTING_AGENT_TYPE:
184
237
  return None
@@ -191,15 +244,12 @@ def mint_for_payload(subagent_stop_payload: dict) -> Path | None:
191
244
  repo_root = resolve_repo_root(subagent_stop_payload.get("cwd", "."))
192
245
  if repo_root is None:
193
246
  return None
194
- merge_base_sha = resolve_merge_base(repo_root)
195
- if merge_base_sha is None:
196
- return None
197
- surface_manifest_text = branch_surface_manifest(repo_root, merge_base_sha)
198
- if surface_manifest_text is None:
247
+ bound_manifest_sha256 = _attested_or_recomputed_hash(verdict_record, repo_root)
248
+ if bound_manifest_sha256 is None:
199
249
  return None
200
250
  return write_verdict(
201
251
  repo_root,
202
- manifest_sha256(surface_manifest_text),
252
+ bound_manifest_sha256,
203
253
  verdict_record["all_pass"],
204
254
  verdict_record["findings"],
205
255
  str(subagent_stop_payload.get("agent_id", "")),
@@ -0,0 +1,28 @@
1
+ """Constants for the dead argparse-argument detector in ``code_rules_enforcer``.
2
+
3
+ Lives under the hooks-tree ``hooks_constants`` package so module-level
4
+ UPPER_SNAKE constants satisfy the CODE_RULES "constants live in config"
5
+ requirement and share a home with the other hook-tree configuration.
6
+ """
7
+
8
+ ADD_ARGUMENT_METHOD_NAME: str = "add_argument"
9
+ ALL_PARSE_METHOD_NAMES: frozenset[str] = frozenset({"parse_args", "parse_known_args"})
10
+ DEST_KEYWORD_NAME: str = "dest"
11
+ NAMESPACE_KEYWORD_NAME: str = "namespace"
12
+ ACTION_KEYWORD_NAME: str = "action"
13
+ ALL_SUPPRESSED_ACTION_NAMES: frozenset[str] = frozenset({"help", "version"})
14
+ GETATTR_FUNCTION_NAME: str = "getattr"
15
+ GETATTR_NAME_ARGUMENT_MINIMUM: int = 2
16
+ ATTRGETTER_FUNCTION_NAME: str = "attrgetter"
17
+ NAMESPACE_DICT_ATTRIBUTE_NAME: str = "__dict__"
18
+ OPTION_PREFIX: str = "-"
19
+ LONG_OPTION_PREFIX: str = "--"
20
+ DEST_WORD_SEPARATOR: str = "-"
21
+ DEST_WORD_JOINER: str = "_"
22
+ EXPORTED_NAMES_ATTRIBUTE: str = "__all__"
23
+ MAX_DEAD_ARGPARSE_ARGUMENT_ISSUES: int = 25
24
+ DEAD_ARGPARSE_ARGUMENT_GUIDANCE: str = (
25
+ "optional CLI flag whose parsed value is never read in this file - remove the"
26
+ " add_argument call (argparse silently accepts an unused flag), or read the"
27
+ " parsed value where it is needed (CODE_RULES §9.8)"
28
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.62.1",
3
+ "version": "1.64.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,7 +34,9 @@ PR's owner.
34
34
 
35
35
  1. **Enter a worktree.** Call `EnterWorktree` with no arguments before any
36
36
  `gh`, `git`, file read, or edit. `gh`/`git` Bash calls do not auto-isolate,
37
- so this is mandatory. If it fails, report and stop.
37
+ so this is mandatory. If it fails, report and stop. A bare `EnterWorktree`
38
+ branches from `origin/main`; step 2 positions the worktree on the PR's head
39
+ ref, which the workflow needs.
38
40
 
39
41
  2. **Resolve PR scope.** When the user passed a PR URL or number, parse owner,
40
42
  repo, and number from it. Otherwise read the current branch's PR:
@@ -43,6 +45,18 @@ PR's owner.
43
45
  ready, mark it draft first (`gh pr ready <n> --repo <o>/<r> --undo`) so the
44
46
  loop owns the ready transition.
45
47
 
48
+ **Position the worktree on the PR branch.** The workflow reviews
49
+ `git diff origin/main...HEAD` against this worktree's local `HEAD` and pushes
50
+ each fix to the PR branch, so the worktree sits on the PR's head ref at the PR
51
+ HEAD before the workflow launches. A worktree fresh off `origin/main` has
52
+ `HEAD == origin/main`, shows an empty diff, and reports a false convergence
53
+ with zero findings. When a local worktree already tracks the PR branch, enter
54
+ that one by passing its path to `EnterWorktree`; otherwise put the entered
55
+ worktree on the branch with `gh pr checkout <number> --repo <owner>/<repo>`
56
+ (or `git fetch origin <headRefName>` then `git switch <headRefName>`). Confirm
57
+ before launching: `git rev-parse --abbrev-ref HEAD` equals the PR's head ref
58
+ and local `HEAD` equals the PR head SHA.
59
+
46
60
  3. **Verify the worktree is the PR's repo (strict pre-flight).** Run
47
61
  `python "$HOME/.claude/skills/_shared/pr-loop/scripts/preflight_worktree.py" --owner <owner> --repo <repo> --mode strict`.
48
62
  It confirms the working directory is a checkout of the PR's own repo and
@@ -56,6 +70,17 @@ PR's owner.
56
70
  4. **Grant project permissions.**
57
71
  `python "$HOME/.claude/skills/bugteam/scripts/grant_project_claude_permissions.py"`
58
72
 
73
+ In auto-mode the classifier blocks this grant as an unrequested change to the
74
+ permission allowlist: the `/autoconverge` invocation alone does not meet its
75
+ bar for an explicitly requested permission change. When it is blocked, keep
76
+ the run alive — surface the grant to the user through `AskUserQuestion` with
77
+ the exact command and ask them to approve it or run it themselves with the `!`
78
+ prefix:
79
+ `! python "$HOME/.claude/skills/bugteam/scripts/grant_project_claude_permissions.py"`.
80
+ Continue once the grant lands. A user who wants future runs to skip this
81
+ prompt can add a standing Bash permission allow-rule for that script in their
82
+ settings.
83
+
59
84
  ## Run the workflow
60
85
 
61
86
  Call the `Workflow` tool against the colocated script:
@@ -220,37 +220,61 @@ test('the fix flow spawns a code-verifier step between the edit step and the com
220
220
  );
221
221
  });
222
222
 
223
- function constantBody(constantName) {
224
- const constantStart = convergeSource.indexOf(`const ${constantName} =`);
225
- assert.notEqual(constantStart, -1, `expected ${constantName} to exist`);
226
- const nextConstantStart = convergeSource.indexOf('\nconst ', constantStart + 1);
227
- const constantEnd = nextConstantStart === -1 ? convergeSource.length : nextConstantStart;
228
- return convergeSource.slice(constantStart, constantEnd);
229
- }
230
-
231
- test('the shared verdict-fence steps name the binding-hash command and the verdict fence', () => {
232
- const fenceSteps = constantBody('VERDICT_FENCE_STEPS');
233
- assert.match(fenceSteps, /--manifest-hash/, 'expected the binding-hash command to be named');
223
+ test('the shared verdict-fence builder names the binding-hash command and the verdict fence', () => {
224
+ const fenceBuilder = lensPromptBody('buildVerdictFenceSteps');
234
225
  assert.match(
235
- fenceSteps,
226
+ fenceBuilder,
227
+ /--manifest-hash-for-branch/,
228
+ 'expected the binding-hash command to use --manifest-hash-for-branch (cwd-immune)',
229
+ );
230
+ assert.doesNotMatch(
231
+ fenceBuilder,
232
+ /--manifest-hash(?!-for-branch)/,
233
+ 'expected the old --manifest-hash <REPO> form to be removed in favour of --manifest-hash-for-branch',
234
+ );
235
+ assert.match(
236
+ fenceBuilder,
236
237
  /verification_verdict_store\.py/,
237
238
  'expected the verdict-store script that computes the binding hash to be named',
238
239
  );
239
- assert.match(fenceSteps, /```verdict/, 'expected the verdict fence to be specified');
240
- assert.match(fenceSteps, /manifest_sha256/, 'expected the verdict fence to carry manifest_sha256');
240
+ assert.match(fenceBuilder, /```verdict/, 'expected the verdict fence to be specified');
241
+ assert.match(fenceBuilder, /manifest_sha256/, 'expected the verdict fence to carry manifest_sha256');
242
+ assert.match(
243
+ fenceBuilder,
244
+ /gh pr view/,
245
+ 'expected buildVerdictFenceSteps to resolve the head branch via gh pr view (cwd-immune)',
246
+ );
247
+ assert.match(
248
+ fenceBuilder,
249
+ /headRefName/,
250
+ 'expected buildVerdictFenceSteps to extract the headRefName from gh pr view output',
251
+ );
252
+ });
253
+
254
+ test('the verdict-fence binding does not self-resolve a cwd via git rev-parse for the manifest hash', () => {
255
+ const fenceBuilder = lensPromptBody('buildVerdictFenceSteps');
256
+ assert.doesNotMatch(
257
+ fenceBuilder,
258
+ /git rev-parse --show-toplevel/,
259
+ 'expected the binding hash to be cwd-immune (no git rev-parse in the binding step)',
260
+ );
241
261
  });
242
262
 
243
- test('every verify step reuses the shared verdict-fence steps, uses code-verifier, and forbids edits', () => {
263
+ test('every verify step calls buildVerdictFenceSteps, uses code-verifier, and forbids edits', () => {
244
264
  for (const verifyFunctionName of [
245
265
  'verifyFixesInWorkingTree',
246
266
  'verifyRepairChanges',
247
- 'verifyHardeningChanges',
248
267
  ]) {
249
268
  const verifyBody = lensPromptBody(verifyFunctionName);
250
269
  assert.match(
251
270
  verifyBody,
252
- /VERDICT_FENCE_STEPS/,
253
- `expected ${verifyFunctionName} to reuse the shared VERDICT_FENCE_STEPS`,
271
+ /buildVerdictFenceSteps\(/,
272
+ `expected ${verifyFunctionName} to call buildVerdictFenceSteps (cwd-immune branch binding)`,
273
+ );
274
+ assert.doesNotMatch(
275
+ verifyBody,
276
+ /VERDICT_FENCE_STEPS(?!\s*\))/,
277
+ `expected ${verifyFunctionName} not to reference the removed VERDICT_FENCE_STEPS constant`,
254
278
  );
255
279
  assert.match(
256
280
  verifyBody,
@@ -270,6 +294,46 @@ test('every verify step reuses the shared verdict-fence steps, uses code-verifie
270
294
  }
271
295
  });
272
296
 
297
+ test('verifyHardeningChanges uses --manifest-hash-for-branch with the hardening branch, uses code-verifier, and forbids edits', () => {
298
+ const verifyBody = lensPromptBody('verifyHardeningChanges');
299
+ assert.match(
300
+ verifyBody,
301
+ /--manifest-hash-for-branch/,
302
+ 'expected verifyHardeningChanges to bind by hardening branch (cwd-immune)',
303
+ );
304
+ assert.doesNotMatch(
305
+ verifyBody,
306
+ /--manifest-hash(?!-for-branch)/,
307
+ 'expected verifyHardeningChanges not to use the old --manifest-hash <REPO> form',
308
+ );
309
+ assert.match(
310
+ verifyBody,
311
+ /agentType:\s*'code-verifier'/,
312
+ 'expected verifyHardeningChanges to spawn the code-verifier agent type',
313
+ );
314
+ assert.doesNotMatch(
315
+ verifyBody,
316
+ /schema:/,
317
+ 'expected verifyHardeningChanges to pass no schema so its verdict fence stays as assistant text',
318
+ );
319
+ assert.match(
320
+ verifyBody,
321
+ /do no edits|make no edits|not edit|no file edits/i,
322
+ 'expected verifyHardeningChanges to be told to make no edits',
323
+ );
324
+ });
325
+
326
+ test('verifyFixesInWorkingTree and verifyRepairChanges pass input.owner, input.repo, input.prNumber to buildVerdictFenceSteps', () => {
327
+ for (const verifyFunctionName of ['verifyFixesInWorkingTree', 'verifyRepairChanges']) {
328
+ const verifyBody = lensPromptBody(verifyFunctionName);
329
+ assert.match(
330
+ verifyBody,
331
+ /buildVerdictFenceSteps\(input\.owner,\s*input\.repo,\s*input\.prNumber\)/,
332
+ `expected ${verifyFunctionName} to pass PR coordinates to buildVerdictFenceSteps`,
333
+ );
334
+ }
335
+ });
336
+
273
337
  test('the commit step is instructed to make no further file edits', () => {
274
338
  const commitBody = lensPromptBody('commitVerifiedFixes');
275
339
  assert.match(