claude-dev-env 1.60.0 → 1.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CLAUDE.md +12 -0
  2. package/audit-rubrics/category_rubrics/category-f-silent-failures.md +1 -1
  3. package/audit-rubrics/prompts/category-e-dead-code.md +17 -4
  4. package/audit-rubrics/prompts/category-f-silent-failures.md +1 -0
  5. package/bin/install.mjs +1 -1
  6. package/docs/CODE_RULES.md +2 -2
  7. package/hooks/blocking/code_rules_annotations_length.py +189 -10
  8. package/hooks/blocking/code_rules_dead_config_field.py +321 -0
  9. package/hooks/blocking/code_rules_enforcer.py +14 -0
  10. package/hooks/blocking/code_rules_orphan_css_class.py +196 -0
  11. package/hooks/blocking/config/verified_commit_constants.py +15 -2
  12. package/hooks/blocking/destructive_command_blocker.py +483 -61
  13. package/hooks/blocking/test_code_rules_enforcer_annotations.py +240 -0
  14. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  15. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +432 -0
  16. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +82 -0
  17. package/hooks/blocking/test_code_rules_enforcer_orphan_css_class.py +196 -0
  18. package/hooks/blocking/test_destructive_command_blocker.py +213 -0
  19. package/hooks/blocking/test_verification_verdict_store.py +212 -0
  20. package/hooks/blocking/test_verified_commit_gate.py +159 -0
  21. package/hooks/blocking/test_verifier_verdict_minter.py +74 -95
  22. package/hooks/blocking/verification_verdict_store.py +240 -0
  23. package/hooks/blocking/verified_commit_gate.py +31 -9
  24. package/hooks/blocking/verifier_verdict_minter.py +46 -124
  25. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  26. package/hooks/hooks_constants/dead_config_field_constants.py +39 -0
  27. package/hooks/hooks_constants/destructive_command_segment_constants.py +15 -0
  28. package/hooks/hooks_constants/orphan_css_class_constants.py +40 -0
  29. package/hooks/validation/mypy_validator.py +59 -7
  30. package/hooks/validation/test_mypy_validator.py +94 -0
  31. package/package.json +1 -1
  32. package/rules/orphan-css-class.md +23 -0
  33. package/skills/autoconverge/reference/gotchas.md +11 -0
  34. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +5 -1
  35. package/skills/autoconverge/workflow/converge.contract.test.mjs +202 -13
  36. package/skills/autoconverge/workflow/converge.mjs +392 -51
  37. package/skills/autoconverge/workflow/test_render_report.py +55 -0
  38. package/skills/doc-gist/SKILL.md +3 -2
  39. package/skills/doc-gist/references/examples/21-decision-signoff.html +546 -0
  40. package/skills/doc-gist/references/examples/README.md +2 -2
  41. package/skills/task-build/SKILL.md +31 -0
@@ -0,0 +1,40 @@
1
+ """Constants for the orphan-CSS-class check in code_rules_enforcer.
2
+
3
+ A Python module that builds HTML emits ``class="..."`` attributes in string
4
+ literals and pairs them with a ``<style>`` block whose selectors style those
5
+ classes. When a class appears in the markup but no selector defines it, the
6
+ markup carries a dead attribute or the style block is missing a rule. This
7
+ module holds the patterns that pair the two halves and the package-scan
8
+ budget that bounds the sibling read.
9
+ """
10
+
11
+ import re
12
+
13
+ __all__ = [
14
+ "CLASS_ATTRIBUTE_PATTERN",
15
+ "STYLE_BLOCK_PATTERN",
16
+ "CSS_CLASS_SELECTOR_PATTERN",
17
+ "PYTHON_MODULE_GLOB",
18
+ "MAX_ORPHAN_CSS_CLASS_ISSUES",
19
+ "MAX_SIBLING_MODULES_SCANNED",
20
+ "ORPHAN_CSS_CLASS_MESSAGE_SUFFIX",
21
+ ]
22
+
23
+ CLASS_ATTRIBUTE_PATTERN: re.Pattern[str] = re.compile(r"""class\s*=\s*["']([^"']+)["']""")
24
+
25
+ STYLE_BLOCK_PATTERN: re.Pattern[str] = re.compile(
26
+ r"<style[^>]*>(.*?)</style>", re.DOTALL | re.IGNORECASE
27
+ )
28
+
29
+ CSS_CLASS_SELECTOR_PATTERN: re.Pattern[str] = re.compile(r"\.(-?[_a-zA-Z][\w-]*)")
30
+
31
+ PYTHON_MODULE_GLOB: str = "*.py"
32
+
33
+ MAX_ORPHAN_CSS_CLASS_ISSUES: int = 10
34
+
35
+ MAX_SIBLING_MODULES_SCANNED: int = 60
36
+
37
+ ORPHAN_CSS_CLASS_MESSAGE_SUFFIX: str = (
38
+ "add a matching '.<class>' selector to the <style> block, "
39
+ "or drop the unused class attribute (CODE_RULES self-documenting markup)"
40
+ )
@@ -69,13 +69,55 @@ def is_file_within_project(target_file: str, project_root: Path) -> bool:
69
69
  return False
70
70
 
71
71
 
72
- def build_mypy_command(relative_file_path: str) -> list[str]:
73
- if IS_WINDOWS:
74
- base_command = [sys.executable, "-m", "mypy"]
75
- else:
76
- base_command = ["mypy"]
72
+ def discover_mypy_config(target_file: Path) -> Path | None:
73
+ """Return the nearest ancestor ``pyproject.toml`` that configures mypy.
74
+
75
+ Mypy applies a project's ``[tool.mypy]`` settings only when the config file
76
+ is on its invocation path; handing the discovered config to mypy lets a
77
+ check run from the repository root still honor the project's own import
78
+ resolution settings (such as ``ignore_missing_imports``) for a module that
79
+ imports its siblings by name. Reuses the validators-package walk-up so the
80
+ discovery logic lives in one place.
81
+
82
+ Args:
83
+ target_file: The Python file mypy will check.
84
+
85
+ Returns:
86
+ The nearest ancestor ``pyproject.toml`` declaring a ``[tool.mypy]``
87
+ table, or None when none exists above the file or the walk-up helper
88
+ cannot be imported.
89
+ """
90
+ validators_directory = os.path.join(
91
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "validators"
92
+ )
93
+ if validators_directory not in sys.path:
94
+ sys.path.insert(0, validators_directory)
95
+ try:
96
+ integration_module = importlib.import_module("mypy_integration")
97
+ except ImportError:
98
+ return None
99
+ discovered_config = integration_module.find_pyproject_with_mypy_config(target_file)
100
+ return discovered_config if isinstance(discovered_config, Path) else None
101
+
102
+
103
+ def build_mypy_command(relative_file_path: str, mypy_config_file: Path | None) -> list[str]:
104
+ """Build the mypy command line for one file.
77
105
 
78
- return base_command + [
106
+ Args:
107
+ relative_file_path: The target file path relative to the project root.
108
+ mypy_config_file: The ``pyproject.toml`` to pass via ``--config-file``,
109
+ or None to let mypy fall back to its own config discovery.
110
+
111
+ Returns:
112
+ The full mypy argument vector, including the interpreter prefix on
113
+ Windows and the config file when one was discovered.
114
+ """
115
+ base_command = [sys.executable, "-m", "mypy"] if IS_WINDOWS else ["mypy"]
116
+
117
+ config_arguments = (
118
+ ["--config-file", str(mypy_config_file)] if mypy_config_file is not None else []
119
+ )
120
+ return base_command + config_arguments + [
79
121
  "--no-error-summary",
80
122
  "--show-error-codes",
81
123
  "--no-color",
@@ -84,8 +126,18 @@ def build_mypy_command(relative_file_path: str) -> list[str]:
84
126
 
85
127
 
86
128
  def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
129
+ """Run mypy on one file from the project root and return its result.
130
+
131
+ Args:
132
+ target_file: The absolute path of the file to type-check.
133
+ project_root: The directory mypy runs from.
134
+
135
+ Returns:
136
+ The mypy exit code paired with its combined stdout and stderr text.
137
+ """
87
138
  relative_file_path = os.path.relpath(target_file, project_root)
88
- mypy_command = build_mypy_command(relative_file_path)
139
+ mypy_config_file = discover_mypy_config(Path(target_file))
140
+ mypy_command = build_mypy_command(relative_file_path, mypy_config_file)
89
141
 
90
142
  completed_process = subprocess.run(
91
143
  mypy_command,
@@ -0,0 +1,94 @@
1
+ """Behavior tests for the mypy_validator config-discovery fix.
2
+
3
+ The hook runs mypy from the project root, so without handing mypy the project's
4
+ own ``[tool.mypy]`` config a module that imports its siblings by name draws a
5
+ spurious ``import-not-found`` error. These tests drive the real production
6
+ functions: ``discover_mypy_config`` walks up to the nearest configuring
7
+ ``pyproject.toml`` and ``run_mypy`` passes it through so the project's
8
+ ``ignore_missing_imports`` setting applies.
9
+ """
10
+
11
+ import importlib.util
12
+ from pathlib import Path
13
+ from types import ModuleType
14
+
15
+ HOOK_PATH = Path(__file__).resolve().parent / "mypy_validator.py"
16
+
17
+ MODULE_WITH_SIBLING_IMPORT = (
18
+ "from sibling_only_resolvable_at_runtime import value\n\nx: int = value\n"
19
+ )
20
+ TOOL_MYPY_PYPROJECT = "[tool.mypy]\nignore_missing_imports = true\n"
21
+ NON_MYPY_PYPROJECT = "[tool.ruff]\nline-length = 100\n"
22
+
23
+
24
+ def _load_validator() -> ModuleType:
25
+ spec = importlib.util.spec_from_file_location("mypy_validator_under_test", HOOK_PATH)
26
+ assert spec is not None and spec.loader is not None
27
+ module = importlib.util.module_from_spec(spec)
28
+ spec.loader.exec_module(module)
29
+ return module
30
+
31
+
32
+ def test_discover_mypy_config_finds_nearest_tool_mypy_pyproject(tmp_path: Path) -> None:
33
+ validator = _load_validator()
34
+ (tmp_path / "pyproject.toml").write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
35
+ nested_module = tmp_path / "package" / "module.py"
36
+ nested_module.parent.mkdir(parents=True)
37
+ nested_module.write_text("value: int = 1\n", encoding="utf-8")
38
+
39
+ discovered = validator.discover_mypy_config(nested_module)
40
+
41
+ assert discovered is not None
42
+ assert discovered.resolve() == (tmp_path / "pyproject.toml").resolve()
43
+
44
+
45
+ def test_discover_mypy_config_returns_none_without_tool_mypy(tmp_path: Path) -> None:
46
+ validator = _load_validator()
47
+ (tmp_path / "pyproject.toml").write_text(NON_MYPY_PYPROJECT, encoding="utf-8")
48
+ standalone_module = tmp_path / "module.py"
49
+ standalone_module.write_text("value: int = 1\n", encoding="utf-8")
50
+
51
+ assert validator.discover_mypy_config(standalone_module) is None
52
+
53
+
54
+ def test_build_mypy_command_includes_config_file_when_present(tmp_path: Path) -> None:
55
+ validator = _load_validator()
56
+ config_file = tmp_path / "pyproject.toml"
57
+
58
+ command = validator.build_mypy_command("package/module.py", config_file)
59
+
60
+ assert "--config-file" in command
61
+ assert command[command.index("--config-file") + 1] == str(config_file)
62
+ assert command[-1] == "package/module.py"
63
+
64
+
65
+ def test_build_mypy_command_omits_config_file_when_absent(tmp_path: Path) -> None:
66
+ validator = _load_validator()
67
+
68
+ command = validator.build_mypy_command("package/module.py", None)
69
+
70
+ assert "--config-file" not in command
71
+ assert command[-1] == "package/module.py"
72
+
73
+
74
+ def test_run_mypy_suppresses_sibling_import_error_with_tool_mypy_config(tmp_path: Path) -> None:
75
+ validator = _load_validator()
76
+ (tmp_path / "pyproject.toml").write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
77
+ importer_module = tmp_path / "importer.py"
78
+ importer_module.write_text(MODULE_WITH_SIBLING_IMPORT, encoding="utf-8")
79
+
80
+ exit_code, output = validator.run_mypy(str(importer_module), str(tmp_path))
81
+
82
+ assert exit_code == 0, output
83
+ assert "import-not-found" not in output
84
+
85
+
86
+ def test_run_mypy_reports_import_error_without_tool_mypy_config(tmp_path: Path) -> None:
87
+ validator = _load_validator()
88
+ importer_module = tmp_path / "importer.py"
89
+ importer_module.write_text(MODULE_WITH_SIBLING_IMPORT, encoding="utf-8")
90
+
91
+ exit_code, output = validator.run_mypy(str(importer_module), str(tmp_path))
92
+
93
+ assert exit_code != 0
94
+ assert "import-not-found" in output
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.60.0",
3
+ "version": "1.62.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,23 @@
1
+ # Orphan CSS Class in Generated Markup
2
+
3
+ **When this applies:** Any Write or Edit to a production `.py` file that builds HTML by emitting `class="..."` attributes inside string literals and pairs them with a `<style>` block — in the same file or in a companion module beside it.
4
+
5
+ ## Rule
6
+
7
+ Every class name a markup string references has a matching `.<class>` selector in the `<style>` block. A class that appears in the markup but carries no selector anywhere is a dead attribute (or a missing rule): the markup names a style that the stylesheet never defines, so a reader who trusts the class to be styled is misled, and the attribute adds noise without effect.
8
+
9
+ When you add a `class="..."` attribute, add its `.<class>` selector to the `<style>` block in the same change. When you drop a selector, drop the class attribute it styled.
10
+
11
+ ## What the gate checks
12
+
13
+ The `check_orphan_css_classes` check in `code_rules_orphan_css_class.py` (wired into `code_rules_enforcer.py`) runs on every production Python write. It:
14
+
15
+ 1. Collects each class name referenced in a `class="..."` attribute across the file's string literals.
16
+ 2. Collects each class selector defined in a `<style>` block — both in the file under edit and in every Python module beside it (its own directory and immediate child directories), since a markup module commonly imports its style constant from a companion package directory.
17
+ 3. Flags each referenced class with no matching selector in that whole set.
18
+
19
+ The check stays quiet for a file that emits no `class="..."` markup, and for a file whose markup has no `<style>` source nearby (its stylesheet lives outside the scan, so the gate cannot judge it). Test files are exempt, since a fixture may carry intentional orphan markup.
20
+
21
+ ## Why this is a hook, not a lint pass
22
+
23
+ A class attribute with no matching selector reads as styled but renders unstyled. Native elements such as `<details>` stay functional without CSS, so the gap survives review as a cosmetic defect rather than a crash — exactly the class of issue that slips past a manual pass and lands as a deferred code-standard finding. Catching it at Write time keeps the markup and the stylesheet in step as each line is written.
@@ -45,3 +45,14 @@ fails in a new way.
45
45
  - **`gh` token drift across accounts.** When a run touches more than one GitHub
46
46
  account, pin the token with `--user <login>`; `gh auth token` alone can return
47
47
  another account's token after a switch.
48
+
49
+ - **The verified-commit gate can block the fix from landing.** The fix lens
50
+ commits and pushes through the `verified_commit_gate` hook, which denies a
51
+ `git commit`/`git push` until a `code-verifier` verdict covers the branch
52
+ surface. A run can reach a clean fix yet fail to land it — the push stays
53
+ blocked when no verdict is minted for that surface. A manual override exists:
54
+ a trailing `# verify-skip` comment on the commit or push command skips the
55
+ gate for that one command. Autoconverge must never apply that override on its
56
+ own. When landing a fix needs it, stop and tell the user the verified-commit
57
+ gate is blocking the push and that going forward needs either a `# verify-skip`
58
+ bypass or a switch to `/pr-converge`, then let the user decide.
@@ -62,7 +62,6 @@ SCENE_FIELD_CAPTION = "caption"
62
62
 
63
63
  MEDIUM_TERMINAL = "terminal"
64
64
  MEDIUM_CODE = "code"
65
- MEDIUM_TEXT = "text"
66
65
 
67
66
  CATEGORY_BUG = "bug"
68
67
  CATEGORY_LABEL_BY_VALUE = {
@@ -216,6 +215,11 @@ HTML_STYLE_BLOCK = """\
216
215
  .cause { background:#fff; border:1px solid #e2e8f0; border-radius:10px; padding:14px 18px; margin-top:16px; font-size:14px; color:#475569; }
217
216
  .cause b { color:#0f172a; }
218
217
 
218
+ .appendix { background:#fff; border:1px solid #e2e8f0; border-radius:10px; padding:14px 18px; margin-top:16px; font-size:13px; color:#475569; }
219
+ .appendix summary { font-weight:600; color:#0f172a; cursor:pointer; }
220
+ .appendix-body { margin-top:10px; }
221
+ .appendix-item { font-family:'JetBrains Mono',monospace; font-size:12px; color:#475569; padding:5px 0; border-top:1px solid #f1f5f9; }
222
+
219
223
  footer { margin-top:40px; padding-top:16px; border-top:1px solid #e2e8f0; color:#94a3b8; font-size:12px; }
220
224
  footer code { background:#e2e8f0; padding:1px 6px; border-radius:4px; font-family:'JetBrains Mono',monospace; }
221
225
  @media (max-width:680px){ .pf-grid,.term-grid{grid-template-columns:1fr;} }
@@ -14,8 +14,9 @@ const gotchasSource = readFileSync(
14
14
  function lensPromptBody(builderName) {
15
15
  const builderStart = convergeSource.indexOf(`function ${builderName}(`);
16
16
  assert.notEqual(builderStart, -1, `expected ${builderName} to exist`);
17
- const nextBuilderStart = convergeSource.indexOf('\nfunction ', builderStart + 1);
18
- const builderEnd = nextBuilderStart === -1 ? convergeSource.length : nextBuilderStart;
17
+ const nextBuilderMatch = /\n(?:async )?function /.exec(convergeSource.slice(builderStart + 1));
18
+ const builderEnd =
19
+ nextBuilderMatch === null ? convergeSource.length : builderStart + 1 + nextBuilderMatch.index;
19
20
  return convergeSource.slice(builderStart, builderEnd);
20
21
  }
21
22
 
@@ -67,8 +68,8 @@ test('gotchas doc states parallel lenses must avoid concurrent git operations',
67
68
  assert.match(gotchasSource, /fetch.*once.*before/i);
68
69
  });
69
70
 
70
- test('repair-convergence filters unresolved threads to bot authors and skips human threads', () => {
71
- const repairPrompt = lensPromptBody('repairConvergence');
71
+ test('repair-convergence edit step filters unresolved threads to bot authors and skips human threads', () => {
72
+ const repairPrompt = lensPromptBody('repairConvergenceEdit');
72
73
  assert.match(
73
74
  repairPrompt,
74
75
  /cursor.*claude.*copilot|copilot.*cursor.*claude|claude.*cursor.*copilot/is,
@@ -81,8 +82,8 @@ test('repair-convergence filters unresolved threads to bot authors and skips hum
81
82
  );
82
83
  });
83
84
 
84
- test('repair-convergence no longer instructs resolving every unresolved thread without an author filter', () => {
85
- const repairPrompt = lensPromptBody('repairConvergence');
85
+ test('repair-convergence edit step no longer instructs resolving every unresolved thread without an author filter', () => {
86
+ const repairPrompt = lensPromptBody('repairConvergenceEdit');
86
87
  assert.doesNotMatch(
87
88
  repairPrompt,
88
89
  /fetch every thread where isResolved is false/,
@@ -182,25 +183,213 @@ test('the CONVERGE branch re-resolves HEAD from GitHub on every entry', () => {
182
183
  assert.notEqual(resolveHeadIndex, -1, 'expected CONVERGE to re-resolve HEAD via resolveHead()');
183
184
  });
184
185
 
185
- test('fix prompt resolves threads by PRRT thread node id looked up from the comment databaseId', () => {
186
- const fixPrompt = lensPromptBody('applyFixes');
187
- assert.match(fixPrompt, /PRRT/, 'expected the thread node id form (PRRT_...) to be named');
186
+ test('fix edit prompt resolves threads by PRRT thread node id looked up from the comment databaseId', () => {
187
+ const editPrompt = lensPromptBody('applyFixesEdit');
188
+ assert.match(editPrompt, /PRRT/, 'expected the thread node id form (PRRT_...) to be named');
188
189
  assert.match(
189
- fixPrompt,
190
+ editPrompt,
190
191
  /databaseId/,
191
192
  'expected the GraphQL lookup matching comment databaseId to be named',
192
193
  );
193
194
  assert.match(
194
- fixPrompt,
195
+ editPrompt,
195
196
  /not the numeric comment id/,
196
197
  'expected an explicit guard against passing the numeric comment id to resolve_thread',
197
198
  );
198
199
  });
199
200
 
200
- test('fix prompt does not pass the numeric comment id straight to resolve_thread', () => {
201
+ test('fix edit prompt does not pass the numeric comment id straight to resolve_thread', () => {
201
202
  assert.doesNotMatch(
202
- lensPromptBody('applyFixes'),
203
+ lensPromptBody('applyFixesEdit'),
203
204
  /then resolve that thread \(use the github MCP pull_request_review_write/,
204
205
  'resolve_thread and resolveReviewThread require a PRRT_... thread node id, not the comment id',
205
206
  );
206
207
  });
208
+
209
+ test('the fix flow spawns a code-verifier step between the edit step and the commit step', () => {
210
+ const applyFixesBody = lensPromptBody('applyFixes');
211
+ const editIndex = applyFixesBody.indexOf('applyFixesEdit(');
212
+ const verifyIndex = applyFixesBody.indexOf('verifyFixesInWorkingTree(');
213
+ const commitIndex = applyFixesBody.indexOf('commitVerifiedFixes(');
214
+ assert.notEqual(editIndex, -1, 'expected applyFixes to call the edit step');
215
+ assert.notEqual(verifyIndex, -1, 'expected applyFixes to call the verify step');
216
+ assert.notEqual(commitIndex, -1, 'expected applyFixes to call the commit step');
217
+ assert.ok(
218
+ editIndex < verifyIndex && verifyIndex < commitIndex,
219
+ 'expected the order edit -> verify -> commit so the verifier verdict binds the fixed working tree',
220
+ );
221
+ });
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');
234
+ assert.match(
235
+ fenceSteps,
236
+ /verification_verdict_store\.py/,
237
+ 'expected the verdict-store script that computes the binding hash to be named',
238
+ );
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');
241
+ });
242
+
243
+ test('every verify step reuses the shared verdict-fence steps, uses code-verifier, and forbids edits', () => {
244
+ for (const verifyFunctionName of [
245
+ 'verifyFixesInWorkingTree',
246
+ 'verifyRepairChanges',
247
+ 'verifyHardeningChanges',
248
+ ]) {
249
+ const verifyBody = lensPromptBody(verifyFunctionName);
250
+ assert.match(
251
+ verifyBody,
252
+ /VERDICT_FENCE_STEPS/,
253
+ `expected ${verifyFunctionName} to reuse the shared VERDICT_FENCE_STEPS`,
254
+ );
255
+ assert.match(
256
+ verifyBody,
257
+ /agentType:\s*'code-verifier'/,
258
+ `expected ${verifyFunctionName} to spawn the code-verifier agent type`,
259
+ );
260
+ assert.doesNotMatch(
261
+ verifyBody,
262
+ /schema:/,
263
+ `expected ${verifyFunctionName} to pass no schema so its verdict fence stays as assistant text`,
264
+ );
265
+ assert.match(
266
+ verifyBody,
267
+ /do no edits|make no edits|not edit|no file edits/i,
268
+ `expected ${verifyFunctionName} to be told to make no edits`,
269
+ );
270
+ }
271
+ });
272
+
273
+ test('the commit step is instructed to make no further file edits', () => {
274
+ const commitBody = lensPromptBody('commitVerifiedFixes');
275
+ assert.match(
276
+ commitBody,
277
+ /no (?:further |additional )?(?:file )?edits|do not edit|make no edits/i,
278
+ 'expected the commit step to forbid further edits so the verified surface stays bound',
279
+ );
280
+ assert.match(
281
+ commitBody,
282
+ /agentType:\s*'clean-coder'/,
283
+ 'expected the commit step to use clean-coder',
284
+ );
285
+ });
286
+
287
+ test('the repair flow spawns a code-verifier step between the edit step and the commit step', () => {
288
+ const repairBody = lensPromptBody('repairConvergence');
289
+ const editIndex = repairBody.indexOf('repairConvergenceEdit(');
290
+ const verifyIndex = repairBody.indexOf('verifyRepairChanges(');
291
+ const commitIndex = repairBody.indexOf('commitRepairFixes(');
292
+ assert.notEqual(editIndex, -1, 'expected repairConvergence to call the edit step');
293
+ assert.notEqual(verifyIndex, -1, 'expected repairConvergence to call the verify step');
294
+ assert.notEqual(commitIndex, -1, 'expected repairConvergence to call the commit step');
295
+ assert.ok(
296
+ editIndex < verifyIndex && verifyIndex < commitIndex,
297
+ 'expected edit -> verify -> commit so the verifier verdict binds the repaired working tree',
298
+ );
299
+ assert.match(
300
+ repairBody,
301
+ /verdictPassed\(/,
302
+ 'expected the verify verdict to gate the repair commit step',
303
+ );
304
+ });
305
+
306
+ test('the standards-deferral flow spawns a code-verifier step between the edit step and the commit step', () => {
307
+ const standardsBody = lensPromptBody('spawnStandardsFollowUp');
308
+ const editIndex = standardsBody.indexOf('standardsFollowUpEdit(');
309
+ const verifyIndex = standardsBody.indexOf('verifyHardeningChanges(');
310
+ const commitIndex = standardsBody.indexOf('commitHardeningPr(');
311
+ assert.notEqual(editIndex, -1, 'expected spawnStandardsFollowUp to call the edit step');
312
+ assert.notEqual(verifyIndex, -1, 'expected spawnStandardsFollowUp to call the verify step');
313
+ assert.notEqual(commitIndex, -1, 'expected spawnStandardsFollowUp to call the commit step');
314
+ assert.ok(
315
+ editIndex < verifyIndex && verifyIndex < commitIndex,
316
+ 'expected edit -> verify -> commit so the verifier verdict binds the hardening working tree',
317
+ );
318
+ assert.match(
319
+ standardsBody,
320
+ /verdictPassed\(/,
321
+ 'expected the verify verdict to gate the hardening commit step',
322
+ );
323
+ });
324
+
325
+ test('the repair and hardening commit steps forbid further edits and use clean-coder', () => {
326
+ for (const commitFunctionName of ['commitRepairFixes', 'commitHardeningPr']) {
327
+ const commitBody = lensPromptBody(commitFunctionName);
328
+ assert.match(
329
+ commitBody,
330
+ /no (?:further |additional )?(?:file )?edits|do not edit|make no edits/i,
331
+ `expected ${commitFunctionName} to forbid further edits so the verified surface stays bound`,
332
+ );
333
+ assert.match(
334
+ commitBody,
335
+ /agentType:\s*'clean-coder'/,
336
+ `expected ${commitFunctionName} to use clean-coder`,
337
+ );
338
+ }
339
+ });
340
+
341
+ test('the standards-deferral edit step stages the hardening change without committing', () => {
342
+ const editBody = lensPromptBody('standardsFollowUpEdit');
343
+ assert.match(
344
+ editBody,
345
+ /do not commit and do not push|NO commit and NO push|Do NOT commit/i,
346
+ 'expected the standards edit step to leave the hardening change uncommitted',
347
+ );
348
+ assert.match(
349
+ editBody,
350
+ /agentType:\s*'clean-coder'/,
351
+ 'expected the standards edit step to use clean-coder',
352
+ );
353
+ });
354
+
355
+ test('spawnStandardsFollowUp reports whether a hardening PR opened on every path', () => {
356
+ const body = lensPromptBody('spawnStandardsFollowUp');
357
+ const falseReturns = body.match(/hardeningPrOpened:\s*false/g) || [];
358
+ assert.ok(
359
+ falseReturns.length >= 2,
360
+ 'expected both skip paths (no hardening staged, verify failed) to return hardeningPrOpened:false',
361
+ );
362
+ assert.match(
363
+ body,
364
+ /hardeningPrOpened:\s*true/,
365
+ 'expected the commit path to return hardeningPrOpened:true',
366
+ );
367
+ });
368
+
369
+ test('the standards-deferral note names the hardening PR only when one opened', () => {
370
+ const noteBody = lensPromptBody('standardsDeferralNote');
371
+ assert.match(
372
+ noteBody,
373
+ /environment-hardening PR/,
374
+ 'expected the opened-PR branch to name the hardening PR',
375
+ );
376
+ assert.match(
377
+ noteBody,
378
+ /no environment-hardening PR/i,
379
+ 'expected the skip branch to state no hardening PR was opened',
380
+ );
381
+ });
382
+
383
+ test('both standards-deferral call sites build standardsNote from the spawnStandardsFollowUp outcome', () => {
384
+ const callSiteUses = convergeSource.match(/standardsNote = standardsDeferralNote\(/g) || [];
385
+ assert.equal(
386
+ callSiteUses.length,
387
+ 2,
388
+ 'expected both standards-deferral call sites to build standardsNote via standardsDeferralNote(...)',
389
+ );
390
+ assert.doesNotMatch(
391
+ convergeSource,
392
+ /standardsNote = `\$\{[^}]+\} code-standard finding\(s\) deferred to a follow-up fix issue plus an environment-hardening PR/,
393
+ 'expected no unconditional hardening-PR claim in standardsNote',
394
+ );
395
+ });