claude-dev-env 8.6.1 → 8.8.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 (119) hide show
  1. package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  2. package/.agents/skills/team-advisor/SKILL.md +2 -2
  3. package/_shared/advisor/reference/identity.md +1 -1
  4. package/_shared/advisor/reference/spawn-walk-log.md +1 -1
  5. package/_shared/advisor/reference/warm-up.md +3 -1
  6. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +16 -0
  7. package/_shared/advisor/scripts/model_tier_run_validator.py +23 -5
  8. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +85 -6
  9. package/_shared/pr-loop/scripts/terminology_sweep.py +10 -9
  10. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -0
  11. package/bin/install.test.mjs +25 -0
  12. package/docs/high-trust-agent-delivery.md +132 -0
  13. package/docs/references/advisor-tool.md +3 -2
  14. package/docs/second-claude-account.md +50 -0
  15. package/hooks/advisory/refactor_guard.py +2 -2
  16. package/hooks/advisory/test_refactor_guard.py +21 -0
  17. package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
  18. package/hooks/blocking/code_rules_shared.py +4 -7
  19. package/hooks/blocking/conftest.py +22 -0
  20. package/hooks/blocking/state_description_blocker.py +10 -8
  21. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
  22. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +4 -29
  23. package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
  24. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
  25. package/hooks/blocking/test_code_rules_shared.py +18 -0
  26. package/hooks/blocking/test_conftest.py +35 -0
  27. package/hooks/blocking/test_pii_scanner.py +8 -0
  28. package/hooks/blocking/test_state_description_blocker.py +16 -0
  29. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
  30. package/hooks/git-hooks/pull_request_handoff.py +2 -2
  31. package/hooks/git-hooks/test_config.py +10 -3
  32. package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
  33. package/hooks/git-hooks/test_verification_notice_state.py +50 -0
  34. package/hooks/git-hooks/verification_notice_state.py +3 -1
  35. package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
  36. package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
  37. package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
  38. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
  39. package/hooks/hooks_constants/state_description_blocker_constants.py +0 -3
  40. package/hooks/hooks_constants/test_state_description_blocker_constants.py +9 -7
  41. package/hooks/validators/exempt_paths.py +3 -18
  42. package/hooks/validators/test_exempt_paths.py +20 -0
  43. package/hooks/validators/test_python_antipattern_checks.py +14 -0
  44. package/package.json +1 -1
  45. package/rules/agent-merges-its-own-green-pull-request.md +54 -0
  46. package/rules/archiving-agent-config.md +10 -0
  47. package/rules/code-standards.md +1 -0
  48. package/rules/correction-lens.md +134 -0
  49. package/rules/flag-non-breaking-findings.md +11 -0
  50. package/rules/long-horizon-autonomy.md +11 -0
  51. package/rules/no-contrast-framing.md +68 -0
  52. package/rules/review-closure-is-a-check.md +44 -0
  53. package/scripts/Invoke-PesterSuites.ps1 +42 -0
  54. package/scripts/agent_merge_check.py +381 -0
  55. package/scripts/check.ps1 +12 -1
  56. package/scripts/ci/install-playtest.mjs +291 -0
  57. package/scripts/ci/install-playtest.test.mjs +84 -0
  58. package/scripts/ci/installer-lifecycle.mjs +11 -30
  59. package/scripts/ci/installer-lifecycle.test.mjs +14 -0
  60. package/scripts/ci/pstack-command-shim.test.mjs +33 -0
  61. package/scripts/ci/scratch-home-install.mjs +73 -0
  62. package/scripts/ci/scratch-home-install.test.mjs +49 -0
  63. package/scripts/claude_account_choice.py +246 -0
  64. package/scripts/claude_account_profile.py +371 -0
  65. package/scripts/claude_chain_usage.py +49 -5
  66. package/scripts/codex_compat_materializer.py +33 -16
  67. package/scripts/contrast_framing.py +101 -0
  68. package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
  69. package/scripts/dev_env_scripts_constants/claude_account_constants.py +166 -0
  70. package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
  71. package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
  72. package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
  73. package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +21 -0
  74. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
  75. package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
  76. package/scripts/durable_post_lint.py +30 -4
  77. package/scripts/followup_cli.py +6 -10
  78. package/scripts/merge_gate_checks.py +236 -0
  79. package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
  80. package/scripts/policy_lint/adapters.py +30 -0
  81. package/scripts/policy_lint/config/constants.py +2 -0
  82. package/scripts/policy_lint/registry.py +5 -0
  83. package/scripts/repository_checks/config/constants.py +12 -0
  84. package/scripts/repository_checks/config/policy_document.py +28 -2
  85. package/scripts/repository_checks/followups.py +54 -0
  86. package/scripts/repository_checks/match_exemptions.py +67 -0
  87. package/scripts/repository_checks/models.py +46 -2
  88. package/scripts/repository_checks/reporting.py +23 -7
  89. package/scripts/repository_checks/tracked_secrets.py +2 -2
  90. package/scripts/repository_policy.py +4 -2
  91. package/scripts/review_closure.py +134 -0
  92. package/scripts/review_closure_github.py +345 -0
  93. package/scripts/review_closure_model.py +360 -0
  94. package/scripts/test_agent_merge_check.py +317 -0
  95. package/scripts/test_claude_account_choice.py +217 -0
  96. package/scripts/test_claude_account_profile.py +220 -0
  97. package/scripts/test_claude_chain_usage.py +40 -0
  98. package/scripts/test_durable_post_lint.py +46 -0
  99. package/scripts/test_followup_cli.py +40 -0
  100. package/scripts/test_merge_gate_checks.py +92 -0
  101. package/scripts/tests/test_code_review_constants.py +78 -0
  102. package/scripts/tests/test_codex_compat_materializer.py +48 -4
  103. package/scripts/tests/test_contrast_framing.py +117 -0
  104. package/scripts/tests/test_followups.py +88 -0
  105. package/scripts/tests/test_match_exemptions.py +291 -0
  106. package/scripts/tests/test_models.py +67 -2
  107. package/scripts/tests/test_package_inventory.py +6 -4
  108. package/scripts/tests/test_pester_suites_run.py +53 -0
  109. package/scripts/tests/test_policy_document.py +39 -1
  110. package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
  111. package/scripts/tests/test_reporting.py +18 -0
  112. package/scripts/tests/test_repository_policy.py +43 -1
  113. package/scripts/tests/test_review_closure.py +126 -0
  114. package/scripts/tests/test_review_closure_github.py +240 -0
  115. package/scripts/tests/test_review_closure_model.py +299 -0
  116. package/scripts/tests/test_tracked_secrets.py +42 -0
  117. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
  118. package/scripts/repository_checks/email_exemptions.py +0 -39
  119. package/scripts/tests/test_email_exemptions.py +0 -158
@@ -27,7 +27,8 @@ publish_plan_max_positional_arguments = 3
27
27
  publish_plan_failure_injector_position = 2
28
28
  frontmatter_unsupported_fields = ("tools", "model", "color", "disable-model-invocation")
29
29
  instruction_alias_filenames = frozenset({"AGENTS.md", "CLAUDE.md"})
30
- failure_blast_radius_rule_relative_path = "rules/failure-blast-radius.md"
30
+ codex_instruction_rule_relative_paths = ("rules/failure-blast-radius.md", "rules/correction-lens.md")
31
+ codex_instruction_source_separator = ", "
31
32
  codex_instruction_target_path = "AGENTS.md"
32
33
  codex_instruction_section_heading = "## Excerpt for repository-instruction sessions"
33
34
  codex_hook_manifest_source_path = "hooks/hooks.json"
@@ -502,41 +503,57 @@ def convert_agent(agent: ClaudeAgent) -> str:
502
503
  return content
503
504
 
504
505
 
505
- def render_codex_failure_blast_radius(rule_content: str) -> str:
506
- """Extract the repository-instruction contract from the canonical rule.
506
+ def render_codex_instruction_excerpt(rule_content: str, rule_relative_path: str) -> str:
507
+ """Extract one rule's repository-instruction contract.
508
+
509
+ A rule reaches a Codex session through the fenced block under its
510
+ "Excerpt for repository-instruction sessions" heading, and this returns
511
+ that block's own text.
507
512
 
508
513
  Args:
509
- rule_content: Canonical failure blast-radius rule text.
514
+ rule_content: Canonical rule text.
515
+ rule_relative_path: Repository-relative path of that rule, named in errors.
510
516
 
511
517
  Returns:
512
518
  The fenced repository-instruction excerpt with a trailing newline.
513
519
 
514
520
  Raises:
515
- MaterializerError: If the canonical rule lacks the required excerpt.
521
+ MaterializerError: If the rule lacks a complete excerpt.
516
522
  """
517
523
  heading_start = rule_content.find(codex_instruction_section_heading)
518
524
  if heading_start < 0:
519
- raise MaterializerError("failure blast-radius rule requires a Codex excerpt")
525
+ raise MaterializerError(f"{rule_relative_path} requires a Codex excerpt")
520
526
  fence_start = rule_content.find("```", heading_start)
521
527
  if fence_start < 0:
522
- raise MaterializerError("failure blast-radius rule requires a Codex excerpt")
528
+ raise MaterializerError(f"{rule_relative_path} requires a Codex excerpt")
523
529
  content_start = rule_content.find(line_separator, fence_start)
524
530
  fence_end = rule_content.find(line_separator + "```", content_start + 1)
525
531
  if content_start < 0 or fence_end < 0:
526
- raise MaterializerError("failure blast-radius rule requires a complete Codex excerpt")
532
+ raise MaterializerError(f"{rule_relative_path} requires a complete Codex excerpt")
527
533
  return rule_content[content_start + len(line_separator) : fence_end].rstrip() + line_separator
528
534
 
529
535
 
530
536
  def _build_codex_instruction_projection(config: MaterializerConfig) -> PlannedFile | None:
531
- """Build the managed AGENTS.md projection when the canonical rule is present."""
532
- source_path = config.source_root / failure_blast_radius_rule_relative_path
533
- if not source_path.exists() and not _is_reparse_point(source_path):
537
+ """Build the managed AGENTS.md projection from every projected rule present."""
538
+ all_present_rules: list[tuple[str, str]] = []
539
+ for each_relative_path in codex_instruction_rule_relative_paths:
540
+ candidate_path = config.source_root / each_relative_path
541
+ if not candidate_path.exists() and not _is_reparse_point(candidate_path):
542
+ continue
543
+ source_path = _validated_source_file(config, each_relative_path, each_relative_path)
544
+ rule_content = source_path.read_text(encoding="utf-8")
545
+ all_present_rules.append(
546
+ (each_relative_path, render_codex_instruction_excerpt(rule_content, each_relative_path))
547
+ )
548
+ if not all_present_rules:
534
549
  return None
535
- source_path = _validated_source_file(config, failure_blast_radius_rule_relative_path, "failure blast-radius rule")
536
- rule_content = source_path.read_text(encoding="utf-8")
537
- projected_content = render_codex_failure_blast_radius(rule_content)
550
+ projected_content = line_separator.join(
551
+ each_excerpt for _, each_excerpt in all_present_rules
552
+ )
538
553
  return PlannedFile(
539
- failure_blast_radius_rule_relative_path,
554
+ codex_instruction_source_separator.join(
555
+ each_path for each_path, _ in all_present_rules
556
+ ),
540
557
  codex_instruction_target_path,
541
558
  projected_content,
542
559
  hash_content(projected_content),
@@ -987,7 +1004,7 @@ def discover_agents(config: MaterializerConfig) -> list[ClaudeAgent]:
987
1004
  if _is_reparse_point(each_path):
988
1005
  raise MaterializerError(f"source reparse point is not allowed: {each_path}")
989
1006
  relative_source = each_path.relative_to(config.source_root).as_posix()
990
- if relative_source == failure_blast_radius_rule_relative_path:
1007
+ if relative_source in codex_instruction_rule_relative_paths:
991
1008
  _validate_containment(config.source_root, each_path)
992
1009
  continue
993
1010
  _validate_containment(config.source_root, each_path)
@@ -0,0 +1,101 @@
1
+ """Find contrast framing in authored prose.
2
+
3
+ A contrast defines its subject against a rejected alternative, so the reader
4
+ carries two readings where one would do.
5
+
6
+ ::
7
+
8
+ all_hits = find_contrast_framing("A diff regression, not shared code.")
9
+ ok: all_hits == [(1, 18, "trailing-comma-not")]
10
+ flag: find_contrast_framing("The function runs more than 30 lines.") == []
11
+
12
+ Code spans, fenced blocks, paths, and link targets are blanked before the
13
+ patterns run, so an example quoted in backticks stays quiet. A blockquote line
14
+ is skipped, since it carries someone else's words. A comma-``not`` match
15
+ reports only when no bare ``not`` already sits earlier in the line, so a
16
+ second one in a list stays quiet, and a list that opens with ``not`` keeps
17
+ even its first comma quiet. The pattern list lives in the constants module
18
+ beside this one, and the durable post linter reads the same list.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+
25
+ from banned_prose_words import prose_lines
26
+ from dev_env_scripts_constants.contrast_framing_constants import (
27
+ ALL_CONTRAST_FRAMING_FORMS,
28
+ CONTRAST_FRAMING_MESSAGE_TEMPLATE,
29
+ CONTRAST_FRAMING_QUOTED_LINE_LIMIT,
30
+ ContrastFramingForm,
31
+ )
32
+
33
+ _BARE_NOT = re.compile(r"\bnot\b", re.IGNORECASE)
34
+
35
+
36
+ def _reports(form: ContrastFramingForm, prose_line: str, match_start: int) -> bool:
37
+ if not form.repeats_as_a_list:
38
+ return True
39
+ return _BARE_NOT.search(prose_line[:match_start]) is None
40
+
41
+
42
+ def _line_hits(line_number: int, prose_line: str) -> list[tuple[int, int, str]]:
43
+ if prose_line.lstrip().startswith(">"):
44
+ return []
45
+ return [
46
+ (line_number, each_match.start() + 1, each_form.name)
47
+ for each_form in ALL_CONTRAST_FRAMING_FORMS
48
+ for each_match in each_form.pattern.finditer(prose_line)
49
+ if _reports(each_form, prose_line, each_match.start())
50
+ ]
51
+
52
+
53
+ def find_contrast_framing(document_text: str) -> list[tuple[int, int, str]]:
54
+ """Return each contrast-framing occurrence in authored prose.
55
+
56
+ Args:
57
+ document_text: Full document source.
58
+
59
+ Returns:
60
+ Ordered ``(line number, column, form name)`` triples, both 1-based.
61
+ """
62
+ all_hits: list[tuple[int, int, str]] = []
63
+ for each_line_number, each_prose_line in enumerate(prose_lines(document_text), 1):
64
+ all_hits.extend(_line_hits(each_line_number, each_prose_line))
65
+ return sorted(all_hits)
66
+
67
+
68
+ def form_named(form_name: str) -> ContrastFramingForm:
69
+ """Return the contrast form carrying one name.
70
+
71
+ Args:
72
+ form_name: Name reported by ``find_contrast_framing``.
73
+
74
+ Returns:
75
+ The matching form.
76
+
77
+ Raises:
78
+ KeyError: If no form carries that name.
79
+ """
80
+ for each_form in ALL_CONTRAST_FRAMING_FORMS:
81
+ if each_form.name == form_name:
82
+ return each_form
83
+ raise KeyError(form_name)
84
+
85
+
86
+ def describe_contrast_framing(source_line: str, form_name: str) -> str:
87
+ """Return the reader-facing message for one occurrence.
88
+
89
+ Args:
90
+ source_line: The document line the occurrence sits on.
91
+ form_name: Name of the form that matched.
92
+
93
+ Returns:
94
+ The message naming the form, the line it sits on, and the fix.
95
+ """
96
+ matched_form = form_named(form_name)
97
+ return CONTRAST_FRAMING_MESSAGE_TEMPLATE.format(
98
+ name=form_name,
99
+ text=source_line.strip()[:CONTRAST_FRAMING_QUOTED_LINE_LIMIT],
100
+ guidance=matched_form.guidance,
101
+ )
@@ -0,0 +1,121 @@
1
+ """Named constants for agent_merge_check, the agent merge check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ GITHUB_API_ROOT = "https://api.github.com"
6
+ GITHUB_GRAPHQL_ENDPOINT = "https://api.github.com/graphql"
7
+ PULL_REQUEST_ENDPOINT_TEMPLATE = "{api_root}/repos/{slug}/pulls/{number}"
8
+ REVIEW_THREADS_ENDPOINT_TEMPLATE = (
9
+ "{api_root}/repos/{slug}/pulls/{number}/ccr/review_threads"
10
+ )
11
+ ACCEPT_HEADER = "Accept"
12
+ GITHUB_ACCEPT_TYPE = "application/vnd.github+json"
13
+ AUTHORIZATION_HEADER = "Authorization"
14
+ BEARER_PREFIX = "Bearer "
15
+ CONTENT_TYPE_HEADER = "Content-Type"
16
+ JSON_CONTENT_TYPE = "application/json"
17
+ UTF8_ENCODING = "utf-8"
18
+ REQUEST_TIMEOUT_SECONDS = 30
19
+
20
+ ALL_TOKEN_ENVIRONMENT_VARIABLES = ("GH_TOKEN", "GITHUB_TOKEN")
21
+
22
+ DRAFT_KEY = "draft"
23
+ MERGEABLE_STATE_KEY = "mergeable_state"
24
+ NUMBER_KEY = "number"
25
+ TITLE_KEY = "title"
26
+ HEAD_KEY = "head"
27
+ SHA_KEY = "sha"
28
+
29
+ MERGEABLE_STATE_CLEAN = "clean"
30
+ MERGEABLE_STATE_BEHIND = "behind"
31
+ MERGEABLE_STATE_DIRTY = "dirty"
32
+ MERGEABLE_STATE_BLOCKED = "blocked"
33
+ MERGEABLE_STATE_UNSTABLE = "unstable"
34
+ MERGEABLE_STATE_UNKNOWN = "unknown"
35
+ SETTLE_ATTEMPT_COUNT = 5
36
+ SETTLE_WAIT_SECONDS = 3
37
+
38
+ DRAFT_HOLD_REASON = (
39
+ "The pull request is a draft, so it carries no verdict to merge on. "
40
+ "Mark it ready once its checks pass."
41
+ )
42
+ BEHIND_HOLD_REASON = (
43
+ "The head is behind the base branch and the branch rule requires an "
44
+ "up-to-date head. Merge the base branch into this one and push."
45
+ )
46
+ DIRTY_HOLD_REASON = (
47
+ "The head conflicts with the base branch. Merge the base branch into "
48
+ "this one, resolve the conflict, and push."
49
+ )
50
+ BLOCKED_HOLD_REASON = (
51
+ "A required status check is not passing on this head. Read the failing "
52
+ "check, fix it, and push."
53
+ )
54
+ UNSTABLE_HOLD_REASON = (
55
+ "A check on this head is failing or still running. Wait for it, and fix "
56
+ "it when it comes back red."
57
+ )
58
+ ALL_HOLD_REASONS_BY_STATE = {
59
+ MERGEABLE_STATE_BEHIND: BEHIND_HOLD_REASON,
60
+ MERGEABLE_STATE_DIRTY: DIRTY_HOLD_REASON,
61
+ MERGEABLE_STATE_BLOCKED: BLOCKED_HOLD_REASON,
62
+ MERGEABLE_STATE_UNSTABLE: UNSTABLE_HOLD_REASON,
63
+ }
64
+ UNKNOWN_STATE_HOLD_TEMPLATE = (
65
+ "GitHub reports the merge state as {state}, which this check does not "
66
+ "treat as ready. Read the pull request page."
67
+ )
68
+ UNRESOLVED_THREADS_HOLD_TEMPLATE = (
69
+ "{count} review thread(s) are open. Answer each one and resolve it."
70
+ )
71
+
72
+ MERGE_VERDICT_LABEL = "MERGE"
73
+ HOLD_VERDICT_LABEL = "HOLD"
74
+ VERDICT_LINE_TEMPLATE = "{label} {slug}#{number} {sha} :: {detail}"
75
+ SHORT_SHA_LENGTH = 7
76
+ READY_DETAIL = "green, no open review thread, ready for the agent to merge"
77
+
78
+ MERGE_EXIT_CODE = 0
79
+ HOLD_EXIT_CODE = 1
80
+ ERROR_EXIT_CODE = 2
81
+
82
+ NO_SIGN_IN_MESSAGE = "No GitHub token in the environment. Set GH_TOKEN or GITHUB_TOKEN."
83
+ SLUG_SEPARATOR = "/"
84
+ SLUG_ARGUMENT_HELP = "Repository as owner/name, such as jl-cmd/claude-dev-env."
85
+ NUMBER_ARGUMENT_HELP = "Pull request number."
86
+ COMMAND_DESCRIPTION = (
87
+ "Report whether a pull request is ready for the agent that drives it to merge it."
88
+ )
89
+
90
+ REVIEW_THREAD_PAGE_SIZE = 100
91
+ UNRESOLVED_THREAD_QUERY = """
92
+ query($owner: String!, $name: String!, $number: Int!, $pageSize: Int!) {
93
+ repository(owner: $owner, name: $name) {
94
+ pullRequest(number: $number) {
95
+ reviewThreads(first: $pageSize) {
96
+ nodes { isResolved isOutdated }
97
+ }
98
+ }
99
+ }
100
+ }
101
+ """
102
+ QUERY_KEY = "query"
103
+ VARIABLES_KEY = "variables"
104
+ OWNER_VARIABLE = "owner"
105
+ NAME_VARIABLE = "name"
106
+ NUMBER_VARIABLE = "number"
107
+ PAGE_SIZE_VARIABLE = "pageSize"
108
+ DATA_KEY = "data"
109
+ REPOSITORY_KEY = "repository"
110
+ PULL_REQUEST_KEY = "pullRequest"
111
+ REVIEW_THREADS_KEY = "reviewThreads"
112
+ NODES_KEY = "nodes"
113
+ ALL_THREAD_NODE_KEYS = (
114
+ DATA_KEY,
115
+ REPOSITORY_KEY,
116
+ PULL_REQUEST_KEY,
117
+ REVIEW_THREADS_KEY,
118
+ NODES_KEY,
119
+ )
120
+ ALL_RESOLVED_KEYS = ("isResolved", "is_resolved", "resolved")
121
+ ALL_OUTDATED_KEYS = ("isOutdated", "is_outdated", "outdated")
@@ -0,0 +1,166 @@
1
+ """Named constants for the second Claude account: its profile, launcher, and picker.
2
+
3
+ The profile sync links the main Claude home into a second account's profile
4
+ directory. The launcher points Claude at that profile. The picker reads both
5
+ accounts' usage meters and names the account a job runs on, protecting the
6
+ main account so its owner never runs out.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import timedelta
12
+
13
+ MAIN_CLAUDE_HOME_DIRECTORY_NAME: str = ".claude"
14
+ """Directory under the user home that holds the main account's Claude home."""
15
+
16
+ PROFILES_ROOT_DIRECTORY_NAME: str = ".claude-profiles"
17
+ """Directory under the user home that holds named Claude profiles."""
18
+
19
+ PROFILES_ROOT_ENVIRONMENT_VARIABLE: str = "LLM_SETTINGS_PROFILES_ROOT"
20
+ """Environment variable that relocates the profiles root."""
21
+
22
+ SECOND_ACCOUNT_PROFILE_NAME: str = "ev"
23
+ """Profile directory name the second account signs in under."""
24
+
25
+ ALL_LAUNCHER_DIRECTORY_RELATIVE_PARTS: tuple[str, ...] = (".local", "bin")
26
+ """Path parts under the user home of the directory on PATH that holds the launcher."""
27
+
28
+ LAUNCHER_FILE_NAME: str = "claude-ev.cmd"
29
+ """File name of the launcher that runs Claude under the second account."""
30
+
31
+ LAUNCHER_TEXT_TEMPLATE: str = (
32
+ "@echo off\r\n"
33
+ "setlocal\r\n"
34
+ 'set "CLAUDE_CONFIG_DIR={profile_home}"\r\n'
35
+ "claude %*\r\n"
36
+ "exit /b %ERRORLEVEL%\r\n"
37
+ )
38
+ """Launcher body: point Claude at the profile and pass every argument through."""
39
+
40
+ LAUNCHER_REPLACED_SUFFIX: str = ".replaced-"
41
+ """Suffix, before the run time, of an older launcher the sync moved aside."""
42
+
43
+ ALL_WINDOWS_JUNCTION_COMMAND_PREFIX: tuple[str, ...] = ("cmd", "/c", "mklink", "/J")
44
+ """Command that links a directory as a junction on Windows, before link and target."""
45
+
46
+ CREDENTIALS_FILE_NAME: str = ".credentials.json"
47
+ """File in a Claude home that holds that account's sign-in."""
48
+
49
+ ALL_ACCOUNT_LOCAL_NAMES: frozenset[str] = frozenset(
50
+ {
51
+ ".credentials.json",
52
+ "projects",
53
+ "sessions",
54
+ "session-env",
55
+ "shell-snapshots",
56
+ "todos",
57
+ "statsig",
58
+ "file-history",
59
+ "history.jsonl",
60
+ "ide",
61
+ "debug",
62
+ "telemetry",
63
+ "logs",
64
+ "tasks",
65
+ "plans",
66
+ "backups",
67
+ "cache",
68
+ "paste-cache",
69
+ "stats-cache.json",
70
+ }
71
+ )
72
+ """Top-level Claude home entries that belong to one account and are never linked."""
73
+
74
+ ALL_ACCOUNT_LOCAL_NAME_PREFIXES: tuple[str, ...] = (".claude.json", ".credentials.json")
75
+ """Name prefixes of per-account state files, covering the global config and its backups."""
76
+
77
+ REPLACED_DIRECTORY_NAME: str = ".replaced"
78
+ """Profile subdirectory that holds entries the sync moved aside, one folder per run."""
79
+
80
+ REPLACED_STAMP_FORMAT: str = "%Y%m%dT%H%M%SZ"
81
+ """UTC time format naming each run's folder under the replaced directory."""
82
+
83
+ WINDOWS_EXTENDED_PATH_PREFIX: str = "\\\\?\\"
84
+ """Prefix Windows puts on a junction target that the link comparison strips."""
85
+
86
+ WINDOWS_OS_NAME: str = "nt"
87
+ """Value of ``os.name`` on Windows, where directories link as junctions."""
88
+
89
+ TEXT_ENCODING: str = "utf-8"
90
+ """Encoding for the launcher file and the JSON reports."""
91
+
92
+ MAIN_SPEND_WINDOW: timedelta = timedelta(hours=24)
93
+ """Main takes jobs only when its weekly window resets within this span."""
94
+
95
+ MAIN_WEEKLY_USED_CEILING_PERCENT: float = 90.0
96
+ """Main takes jobs only while its weekly use is under this percent."""
97
+
98
+ MAIN_SESSION_USED_CEILING_PERCENT: float = 50.0
99
+ """Main takes jobs only while its 5-hour use is under this percent."""
100
+
101
+ SECOND_WEEKLY_USED_CEILING_PERCENT: float = 95.0
102
+ """The second account takes jobs while its weekly use is under this percent."""
103
+
104
+ SECOND_SESSION_USED_CEILING_PERCENT: float = 90.0
105
+ """The second account takes jobs while its 5-hour use is under this percent."""
106
+
107
+ FULL_PERCENT: float = 100.0
108
+ """Percent scale ceiling, so remaining is this minus used."""
109
+
110
+ SECONDS_PER_HOUR: int = 3600
111
+ """Seconds in one hour, for reason text that names hours until a reset."""
112
+
113
+ CHOICE_MAIN: str = "main"
114
+ """Picker answer: run the job on the main account."""
115
+
116
+ CHOICE_SECOND: str = "second"
117
+ """Picker answer: run the job on the second account."""
118
+
119
+ CHOICE_WAIT: str = "wait"
120
+ """Picker answer: neither account has room, so the job waits."""
121
+
122
+ REASON_MAIN_EXPIRING_TEMPLATE: str = (
123
+ "main week resets in {hours_until_reset} hours with {remaining_percent:.0f}% left"
124
+ )
125
+ """Reason when main spends leftover usage that expires soon."""
126
+
127
+ REASON_SECOND_HAS_ROOM_TEMPLATE: str = (
128
+ "second account has {weekly_remaining_percent:.0f}% of its week"
129
+ " and {session_remaining_percent:.0f}% of its 5-hour window left"
130
+ )
131
+ """Reason when the second account takes the job."""
132
+
133
+ REASON_SECOND_UNREAD: str = (
134
+ "second account meter unreadable; its own run refreshes the sign-in"
135
+ )
136
+ """Reason when the second account's meter cannot be read and it takes the job anyway."""
137
+
138
+ REASON_WAIT_TEMPLATE: str = (
139
+ "second account is at {weekly_used_percent:.0f}% of its week"
140
+ " and {session_used_percent:.0f}% of its 5-hour window; next reset {next_reset}"
141
+ )
142
+ """Reason when neither account has room."""
143
+
144
+ UNKNOWN_RESET_TEXT: str = "unknown"
145
+ """Reset text when the blocking meter carries no reset time."""
146
+
147
+ JSON_ACCOUNT_KEY: str = "account"
148
+ """Picker JSON key naming the chosen account."""
149
+
150
+ JSON_CONFIG_DIRECTORY_KEY: str = "config_dir"
151
+ """Picker JSON key naming the Claude home the chosen account runs under."""
152
+
153
+ JSON_REASON_KEY: str = "reason"
154
+ """Picker JSON key carrying the plain-words reason for the choice."""
155
+
156
+ JSON_LINKED_KEY: str = "linked"
157
+ """Sync JSON key listing entries the run linked."""
158
+
159
+ JSON_MOVED_ASIDE_KEY: str = "moved_aside"
160
+ """Sync JSON key listing entries the run moved into the replaced directory."""
161
+
162
+ JSON_UNLINKED_KEY: str = "unlinked"
163
+ """Sync JSON key listing links the run removed because main no longer has the entry."""
164
+
165
+ JSON_LAUNCHER_KEY: str = "launcher"
166
+ """Sync JSON key naming the launcher file the run wrote or confirmed."""
@@ -0,0 +1,82 @@
1
+ """Constants for the contrast-framing detector.
2
+
3
+ Each form names one shape a sentence takes when it defines its subject
4
+ against a rejected reading::
5
+
6
+ "a diff regression, not shared code" -> trailing-comma-not
7
+ "park it rather than fix it" -> substitution-rather-than
8
+ "precision matters more than coverage" -> comparative-ranking
9
+ ok: "the function runs more than 30 lines"
10
+
11
+ The rule file ``rules/no-contrast-framing.md`` carries one row for each name
12
+ here, and a test holds the two in step.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from dataclasses import dataclass
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ContrastFramingForm:
23
+ """One banned contrast form and the pattern that finds it."""
24
+
25
+ name: str
26
+ pattern: re.Pattern[str]
27
+ guidance: str
28
+ repeats_as_a_list: bool = False
29
+
30
+
31
+ ALL_CONTRAST_FRAMING_FORMS: tuple[ContrastFramingForm, ...] = (
32
+ ContrastFramingForm(
33
+ "trailing-comma-not",
34
+ re.compile(r",\s+not\s+[\w\"'`(]", re.IGNORECASE),
35
+ "Drop the clause after the comma and state what is true.",
36
+ repeats_as_a_list=True,
37
+ ),
38
+ ContrastFramingForm(
39
+ "corrective-it-is-not",
40
+ re.compile(
41
+ r"\b(?:it|that|this)(?:'s|\s+is|\s+was)\s+not\b[^.!?]*?,\s*"
42
+ r"(?:it|that|this)(?:'s|\s+is|\s+was)\b",
43
+ re.IGNORECASE,
44
+ ),
45
+ "State what it is and leave the rejected reading out.",
46
+ ),
47
+ ContrastFramingForm(
48
+ "substitution-rather-than",
49
+ re.compile(r"\brather than\b", re.IGNORECASE),
50
+ "Name the thing you chose and stop.",
51
+ ),
52
+ ContrastFramingForm(
53
+ "additive-not-just",
54
+ re.compile(r"\bnot (?:just|only|merely|simply)\b", re.IGNORECASE),
55
+ "Say the point the sentence is building toward.",
56
+ ),
57
+ ContrastFramingForm(
58
+ "comparative-ranking",
59
+ re.compile(
60
+ r"\b(?:matters?|counts?|helps?|weighs?)\s+more\s+than\b"
61
+ r"|\b(?:more|less)\s+(?:important|useful|valuable)\s+than\b"
62
+ r"|\bmore\s+than\s+(?:trying|attempting|hoping)\b",
63
+ re.IGNORECASE,
64
+ ),
65
+ "State the thing you want. Leave the ranking out.",
66
+ ),
67
+ ContrastFramingForm(
68
+ "substitution-as-opposed-to",
69
+ re.compile(r"\bas opposed to\b", re.IGNORECASE),
70
+ "Name the thing you chose and stop.",
71
+ ),
72
+ )
73
+
74
+ CONTRAST_FRAMING_MESSAGE_TEMPLATE: str = (
75
+ 'Contrast framing ({name}): "{text}". {guidance}'
76
+ )
77
+
78
+ CONTRAST_FRAMING_QUOTED_LINE_LIMIT: int = 120
79
+
80
+ CONTRAST_FRAMING_FINDING_CODE: str = "contrast-framing"
81
+
82
+ CONTRAST_FRAMING_RULE_DOCUMENT: str = "rules/no-contrast-framing.md"
@@ -0,0 +1,23 @@
1
+ """Names and values ``merge_gate_checks.py`` reads a merge policy with.
2
+
3
+ The fenced block marker and the ruleset field names describe a document and a
4
+ GitHub payload that both live outside this package, so they sit here rather
5
+ than inside the module that reads them.
6
+ """
7
+
8
+ REQUIRED_CHECK_BLOCK_INFO_STRING: str = "required-status-checks"
9
+ FENCE_MARKER: str = "```"
10
+ RULESET_API_TEMPLATE: str = (
11
+ "https://api.github.com/repos/{repository}/rules/branches/{branch}"
12
+ )
13
+ RULESET_API_ACCEPT_HEADER: str = "application/vnd.github+json"
14
+ RULESET_REQUEST_TIMEOUT_SECONDS: int = 30
15
+ REQUIRED_STATUS_CHECKS_RULE_TYPE: str = "required_status_checks"
16
+ RULE_TYPE_KEY: str = "type"
17
+ RULE_PARAMETERS_KEY: str = "parameters"
18
+ RULE_CONTEXT_KEY: str = "context"
19
+ ALL_GITHUB_TOKEN_VARIABLES: tuple[str, ...] = ("GITHUB_TOKEN", "GH_TOKEN")
20
+ MERGE_GATE_CLEAN_EXIT_CODE: int = 0
21
+ MERGE_GATE_MISSING_CHECK_EXIT_CODE: int = 1
22
+ JSON_INDENT_WIDTH: int = 2
23
+ UTF8_ENCODING: str = "utf-8"