claude-dev-env 8.98.6 → 8.98.8

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 (32) hide show
  1. package/.agents/skills/pr-lifecycle/SKILL.md +4 -4
  2. package/bin/install-pstack-plugin.mjs +71 -21
  3. package/bin/install-pstack-plugin.test.mjs +46 -9
  4. package/bin/install.test.mjs +9 -9
  5. package/docs/account-broker.md +1 -1
  6. package/hooks/blocking/reply_length_gate.py +11 -2
  7. package/hooks/blocking/resolve_thread_reply_gate.py +135 -0
  8. package/hooks/blocking/test_reply_length_gate.py +11 -0
  9. package/hooks/blocking/test_resolve_thread_reply_gate.py +199 -0
  10. package/hooks/features/conduct-gates.md +3 -0
  11. package/hooks/hooks.json +10 -0
  12. package/hooks/hooks_constants/artifact_template_pointer_constants.py +3 -1
  13. package/hooks/hooks_constants/resolve_thread_reply_gate_constants.py +22 -0
  14. package/hooks/hooks_constants/test_artifact_template_pointer_constants.py +12 -0
  15. package/hooks/hooks_constants/transcript_skill_scan_constants.py +2 -0
  16. package/hooks/session/artifact_template_pointer.py +26 -3
  17. package/hooks/session/test_artifact_template_pointer.py +16 -0
  18. package/hooks/test_transcript_skill_scan.py +30 -0
  19. package/hooks/transcript_skill_scan.py +72 -1
  20. package/package.json +1 -1
  21. package/scripts/Sync-RepoMain.ps1 +102 -4
  22. package/scripts/account_broker.py +12 -4
  23. package/scripts/durable_post_lint.py +47 -1
  24. package/scripts/durable_post_lint_config/config/constants.py +6 -0
  25. package/scripts/durable_post_lint_config/config/test_constants.py +1 -0
  26. package/scripts/policy_lint/adapter_configuration.py +5 -0
  27. package/scripts/policy_lint/config/constants.py +1 -0
  28. package/scripts/test_account_broker.py +35 -0
  29. package/scripts/test_durable_post_lint.py +176 -3
  30. package/scripts/tests/Sync-RepoMain.Tests.ps1 +43 -0
  31. package/scripts/tests/test_adapter_configuration.py +8 -0
  32. package/scripts/tests/test_sync_repo_main_mirror.py +226 -0
@@ -8,9 +8,11 @@
8
8
  a remote, such as a read-only mirror kept current by a scheduled task.
9
9
 
10
10
  Safety rules:
11
- - Only runs when the checked-out branch is the named branch
11
+ - Only runs when the checked-out branch is the named branch, unless
12
+ -Mirror is passed
12
13
  - Only runs when there are no tracked changes (untracked files are fine),
13
- unless -StashDirty is passed
14
+ unless -StashDirty is passed. A -Mirror return stashes the tree the old
15
+ checkout left whatever -StashDirty says
14
16
  - Uses merge --ff-only (never force, never hard reset, never clean)
15
17
  - When the fast-forward fails because the files were already copied in,
16
18
  moves HEAD with a mixed reset, which writes no files. It keeps that move
@@ -42,6 +44,20 @@
42
44
  missing; when that pop also fails, the log names the stash entry so the
43
45
  operator can recover it by hand.
44
46
 
47
+ .PARAMETER Mirror
48
+ Marks the tree as a mirror that must always sit on the named branch. With
49
+ the switch, a checkout found on another branch or a detached HEAD is moved
50
+ back: HEAD points at the local named branch again and a mixed reset moves
51
+ the index there. Every path that still holds the old checkout's content,
52
+ including files only the old checkout tracked, is stashed as "mirror
53
+ return <yyyy-MM-dd>: tree of <checkout> at <tip>", which puts the named
54
+ branch's files back. This stash runs with or without -StashDirty, because
55
+ a mirror's tree belongs to the named branch. The log names the checkout
56
+ and tip it left. A checkout with staged changes is skipped, so no staged
57
+ content is lost. When a step of the return fails, HEAD and the index go
58
+ back to the old checkout and the run exits 1. Without the switch, any
59
+ other checkout is skipped.
60
+
45
61
  .PARAMETER LogPath
46
62
  Absolute path to the run log (default: $HOME\.claude\logs\sync-repo-main.log).
47
63
  #>
@@ -52,6 +68,7 @@ param(
52
68
  [string]$Remote = 'origin',
53
69
  [string]$Branch = 'main',
54
70
  [switch]$StashDirty,
71
+ [switch]$Mirror,
55
72
  [string]$LogPath = (Join-Path $HOME '.claude\logs\sync-repo-main.log')
56
73
  )
57
74
 
@@ -131,6 +148,84 @@ function Invoke-GitOrExit {
131
148
  return $result.Output
132
149
  }
133
150
 
151
+ function Invoke-MirrorReturn {
152
+ <#
153
+ Moves a mirror found on another branch or a detached HEAD back to the
154
+ named branch. HEAD and the index move first, then every path where the
155
+ tree still holds the old checkout's content is stashed, including files
156
+ only the old checkout tracked. Any failure puts HEAD and the index back
157
+ on the old checkout, so the next run meets the same state and tries again.
158
+ #>
159
+ param(
160
+ [Parameter(Mandatory)]
161
+ [AllowEmptyString()]
162
+ [string]$LeftBranch
163
+ )
164
+ $leftCheckoutName = if ($LeftBranch) { "branch '$LeftBranch'" } else { 'detached HEAD' }
165
+ $stagedResult = Invoke-Git -Arguments @('diff', '--cached', '--quiet')
166
+ if ($stagedResult.ExitCode -ne 0) {
167
+ Write-SyncLog -Level 'SKIP' -Message "mirror return refused: $leftCheckoutName has staged changes"
168
+ exit 3
169
+ }
170
+ $leftTip = Invoke-GitOrExit -Arguments @('rev-parse', 'HEAD') `
171
+ -FailureMessage 'cannot read the tip of the checkout being left' -FailureExitCode 2
172
+ $null = Invoke-GitOrExit -Arguments @('rev-parse', '--verify', '-q', "refs/heads/$Branch") `
173
+ -FailureMessage "mirror has no local $Branch branch to return to" -FailureExitCode 2
174
+ $leftOnlyResult = Invoke-Git -Arguments @('diff', '--name-only', '--no-renames', '-z', '--diff-filter=A', "refs/heads/$Branch", $leftTip)
175
+ if ($leftOnlyResult.ExitCode -ne 0) {
176
+ Write-SyncLog -Level 'ERROR' -Message "mirror return skipped: cannot list files only $leftCheckoutName tracks: $($leftOnlyResult.Output)"
177
+ exit 1
178
+ }
179
+ $null = Invoke-GitOrExit -Arguments @('symbolic-ref', 'HEAD', "refs/heads/$Branch") `
180
+ -FailureMessage "cannot point HEAD at $Branch" -FailureExitCode 1
181
+
182
+ $resetResult = Invoke-Git -Arguments @('reset', '-q')
183
+ $changedResult = if ($resetResult.ExitCode -eq 0) { Invoke-Git -Arguments @('diff', '--name-only', '--no-renames', '-z') } else { $resetResult }
184
+ $failure = if ($resetResult.ExitCode -ne 0) { "mixed reset onto $Branch failed: $($resetResult.Output)" }
185
+ elseif ($changedResult.ExitCode -ne 0) { "cannot list the tree left by $leftCheckoutName`: $($changedResult.Output)" }
186
+ $leftTreeNote = 'the tree already matched'
187
+ if (-not $failure) {
188
+ $allLeftPaths = [System.Collections.Generic.List[string]]::new()
189
+ foreach ($changedPath in Get-NulSeparatedPaths -Output $changedResult.Output) { $allLeftPaths.Add($changedPath) }
190
+ foreach ($leftOnlyPath in Get-NulSeparatedPaths -Output $leftOnlyResult.Output) {
191
+ if (Test-Path -LiteralPath (Join-Path $RepoPath $leftOnlyPath)) { $allLeftPaths.Add($leftOnlyPath) }
192
+ }
193
+ if ($allLeftPaths.Count -gt 0) {
194
+ $leftTreeStashMessage = 'mirror return {0}: tree of {1} at {2}' -f (Get-Date -Format 'yyyy-MM-dd'), $leftCheckoutName, $leftTip
195
+ $pathspecFile = $null
196
+ try {
197
+ $pathspecFile = New-TemporaryFile
198
+ $pathspecText = (($allLeftPaths | Select-Object -Unique | ForEach-Object { ":(literal)$_" }) -join "`0") + "`0"
199
+ [System.IO.File]::WriteAllText($pathspecFile.FullName, $pathspecText, [System.Text.UTF8Encoding]::new($false))
200
+ $stashResult = Invoke-Git -Arguments @('stash', 'push', '--all', '-m', $leftTreeStashMessage, "--pathspec-from-file=$($pathspecFile.FullName)", '--pathspec-file-nul')
201
+ }
202
+ catch {
203
+ $stashResult = [pscustomobject]@{ ExitCode = 1; Output = $_.Exception.Message }
204
+ }
205
+ finally {
206
+ if ($pathspecFile) {
207
+ Remove-Item -LiteralPath $pathspecFile.FullName -Force -ErrorAction SilentlyContinue
208
+ }
209
+ }
210
+ if ($stashResult.ExitCode -ne 0) {
211
+ $failure = "stashing the tree of $leftCheckoutName failed: $($stashResult.Output)"
212
+ }
213
+ else {
214
+ $leftTreeNote = "its tree is stashed as '$leftTreeStashMessage'"
215
+ }
216
+ }
217
+ }
218
+ if ($failure) {
219
+ $restoreArguments = if ($LeftBranch) { @('symbolic-ref', 'HEAD', "refs/heads/$LeftBranch") } else { @('update-ref', '--no-deref', 'HEAD', $leftTip) }
220
+ $restoreResult = Invoke-Git -Arguments $restoreArguments
221
+ $indexResult = if ($resetResult.ExitCode -eq 0) { Invoke-Git -Arguments @('reset', '-q') } else { $restoreResult }
222
+ $restoreNote = if ($restoreResult.ExitCode -eq 0 -and $indexResult.ExitCode -eq 0) { "HEAD and index are back on $leftCheckoutName at $leftTip" } else { "putting HEAD back on $leftCheckoutName at $leftTip also failed: $($restoreResult.Output) $($indexResult.Output)" }
223
+ Write-SyncLog -Level 'ERROR' -Message "mirror return failed, $failure; $restoreNote"
224
+ exit 1
225
+ }
226
+ Write-SyncLog -Level 'MIRROR' -Message "left $leftCheckoutName at $leftTip; HEAD is on $Branch and $leftTreeNote"
227
+ }
228
+
134
229
  function Get-NulSeparatedPaths {
135
230
  param(
136
231
  [Parameter(Mandatory)]
@@ -229,8 +324,11 @@ try {
229
324
  $currentBranch = Invoke-GitOrExit -Arguments @('branch', '--show-current') `
230
325
  -FailureMessage 'cannot read current branch' -FailureExitCode 2
231
326
  if ($currentBranch -ne $Branch) {
232
- Write-SyncLog -Level 'SKIP' -Message "checkout is '$currentBranch', not '$Branch' (remote=$Remote)"
233
- exit 3
327
+ if (-not $Mirror) {
328
+ Write-SyncLog -Level 'SKIP' -Message "checkout is '$currentBranch', not '$Branch' (remote=$Remote)"
329
+ exit 3
330
+ }
331
+ Invoke-MirrorReturn -LeftBranch $currentBranch
234
332
  }
235
333
 
236
334
  $statusOutput = Invoke-GitOrExit -Arguments @('status', '--porcelain') `
@@ -168,6 +168,11 @@ def _mark_spent(all_state: dict[str, object], reading: Reading, now: datetime) -
168
168
  return until
169
169
 
170
170
 
171
+ def _is_no_roster_name_beside_a_roster(product: Product, name: str, all_readings: Sequence[Reading]) -> bool:
172
+ all_roster_names = {each_reading.account.name for each_reading in all_readings}
173
+ return product is Product.CODEX and bool(all_roster_names) and name == NO_ROSTER_ACCOUNT_NAME and name not in all_roster_names
174
+
175
+
171
176
  def _outside_roster_resets(product: Product, all_readings: Sequence[Reading], all_state: Mapping[str, object], now: datetime) -> dict[str, datetime]:
172
177
  marks = all_state.get("spent") if isinstance(all_state.get("spent"), dict) else {}
173
178
  all_roster_names = {each_reading.account.name for each_reading in all_readings}
@@ -178,7 +183,7 @@ def _outside_roster_resets(product: Product, all_readings: Sequence[Reading], al
178
183
  continue
179
184
  name = each_key[len(prefix):-len(placeholder_suffix)]
180
185
  reset = _stored_reset(each_raw, now)
181
- if name not in all_roster_names and reset is not None:
186
+ if name not in all_roster_names and reset is not None and not _is_no_roster_name_beside_a_roster(product, name, all_readings):
182
187
  all_resets_by_name[name] = reset
183
188
  return all_resets_by_name
184
189
 
@@ -395,13 +400,16 @@ def _save_spent_arguments(all_marks: Sequence[str], all_readings: Sequence[Readi
395
400
  all_accounts_by_profile = {each_reading.account.profile: each_reading for each_reading in all_readings if each_reading.account.profile}
396
401
  all_accounts_by_name = all_accounts_by_profile | {each_reading.account.name: each_reading for each_reading in all_readings}
397
402
  all_parsed_marks = [_parse_spent_mark(all_accounts_by_name, each_mark, product) for each_mark in all_marks]
398
- for each_reading, each_reset in all_parsed_marks:
403
+ all_saved_marks = [each_mark for each_mark in all_parsed_marks if not _is_no_roster_name_beside_a_roster(product, each_mark[0].account.name, all_readings)]
404
+ for each_reading, each_reset in all_saved_marks:
399
405
  _record_spent(all_state, each_reading, now, each_reset)
400
- if all_parsed_marks:
406
+ if all_saved_marks:
401
407
  _save_state(broker_state_path(), all_state)
402
408
  for each_reading, _ in all_parsed_marks:
403
409
  name = each_reading.account.name
404
- if name not in all_accounts_by_name and (all_accounts_by_name or name != NO_ROSTER_ACCOUNT_NAME):
410
+ if _is_no_roster_name_beside_a_roster(product, name, all_readings):
411
+ print(f"warning: spent mark names {name}, the home used only with no roster, so it is outside the roster; choose ignores it", file=sys.stderr)
412
+ elif name not in all_accounts_by_name and (all_accounts_by_name or name != NO_ROSTER_ACCOUNT_NAME):
405
413
  print(f"warning: spent mark names {name}, an account outside the roster; choose waits until its reset", file=sys.stderr)
406
414
 
407
415
 
@@ -18,6 +18,7 @@ from dev_env_scripts_constants.private_term_constants import (
18
18
  PRIVATE_TERM_MESSAGE_TEMPLATE,
19
19
  )
20
20
  from durable_post_lint_config.config.constants import (
21
+ ACTION_GITHUB_MCP_COMMENT,
21
22
  ACTION_PR_CREATE,
22
23
  ACTION_PR_EDIT,
23
24
  ALL_BARE_VOLATILE_PATH_MARKERS,
@@ -33,6 +34,7 @@ from durable_post_lint_config.config.constants import (
33
34
  BODY_MUST_NOT_BE_EMPTY_MESSAGE,
34
35
  BODY_REQUIRED_MESSAGE,
35
36
  CLEAN_EXIT_CODE,
37
+ COMMENT_LENGTH_FINDING_CODE,
36
38
  CONTENT_FINDING_EXIT_CODE,
37
39
  CONVENTIONAL_TITLE_PATTERN,
38
40
  EDIT_INPUT_REQUIRED_MESSAGE,
@@ -45,6 +47,8 @@ from durable_post_lint_config.config.constants import (
45
47
  MISSING_HEADING_MESSAGE_TEMPLATE,
46
48
  PATH_ANCHOR_CHARACTER,
47
49
  PATH_SEGMENT_START_CHARACTERS,
50
+ PROOF_GATE_CREATE_TOOL_NAME,
51
+ PULL_REQUEST_PROOF_FINDING_CODE,
48
52
  RELEASE_BRANCH_PREFIX,
49
53
  REPOSITORY_OWNER_SEPARATOR,
50
54
  REWRITTEN_RELEASE_BODY_CODE,
@@ -56,6 +60,17 @@ from durable_post_lint_config.config.constants import (
56
60
  )
57
61
  from private_terms import private_term_line_numbers
58
62
 
63
+ _hooks_directory = str(Path(__file__).resolve().parents[1] / "hooks")
64
+ if _hooks_directory not in sys.path:
65
+ sys.path.insert(0, _hooks_directory)
66
+
67
+ from blocking.pull_request_proof import (
68
+ missing_eval_section_reason,
69
+ missing_existing_work_reason,
70
+ missing_proof_reason,
71
+ )
72
+ from blocking.reply_length_gate import comment_length_violation
73
+
59
74
 
60
75
  class DurablePostUsageError(ValueError):
61
76
  """Report an invalid GitHub post request shape."""
@@ -145,6 +160,32 @@ def _description_heading_findings(body_text: str) -> list[DurablePostFinding]:
145
160
  ]
146
161
 
147
162
 
163
+ def _pull_request_proof_findings(
164
+ title: str | None, body_text: str
165
+ ) -> list[DurablePostFinding]:
166
+ create_payload = {
167
+ "tool_name": PROOF_GATE_CREATE_TOOL_NAME,
168
+ "tool_input": {"title": title, "body": body_text},
169
+ }
170
+ all_reasons = (
171
+ missing_proof_reason(create_payload),
172
+ missing_existing_work_reason(create_payload),
173
+ missing_eval_section_reason(create_payload),
174
+ )
175
+ return [
176
+ DurablePostFinding(code=PULL_REQUEST_PROOF_FINDING_CODE, message=each_reason)
177
+ for each_reason in all_reasons
178
+ if each_reason is not None
179
+ ]
180
+
181
+
182
+ def _comment_length_findings(body_text: str) -> list[DurablePostFinding]:
183
+ violation = comment_length_violation(body_text)
184
+ if violation is None:
185
+ return []
186
+ return [DurablePostFinding(code=COMMENT_LENGTH_FINDING_CODE, message=violation)]
187
+
188
+
148
189
  def _contrast_framing_findings(text: str) -> list[DurablePostFinding]:
149
190
  all_lines = text.splitlines()
150
191
  return [
@@ -210,6 +251,7 @@ def _body_findings(
210
251
  action: str,
211
252
  body_text: str | None,
212
253
  head_branch: str | None = None,
254
+ title: str | None = None,
213
255
  ) -> list[DurablePostFinding]:
214
256
  if body_text is None:
215
257
  return []
@@ -226,6 +268,10 @@ def _body_findings(
226
268
  else:
227
269
  if action in ALL_PR_DESCRIPTION_ACTIONS:
228
270
  all_findings.extend(_description_heading_findings(body_text))
271
+ if action == ACTION_PR_CREATE:
272
+ all_findings.extend(_pull_request_proof_findings(title, body_text))
273
+ if action == ACTION_GITHUB_MCP_COMMENT:
274
+ all_findings.extend(_comment_length_findings(body_text))
229
275
  all_findings.extend(_contrast_framing_findings(body_text))
230
276
  if find_volatile_path_marker(body_text) is not None:
231
277
  all_findings.append(
@@ -263,7 +309,7 @@ def lint_durable_post(
263
309
  return (
264
310
  *_title_findings(title),
265
311
  *_private_term_findings(title, repository),
266
- *_body_findings(action, body_text, head_branch),
312
+ *_body_findings(action, body_text, head_branch, title),
267
313
  *_private_term_findings(body_text, repository),
268
314
  )
269
315
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import re
4
4
 
5
+ ACTION_GITHUB_MCP_COMMENT = "github-mcp-comment"
5
6
  ACTION_GITHUB_MCP_POST = "github-mcp-post"
6
7
  ACTION_ISSUE_COMMENT = "issue-comment"
7
8
  ACTION_ISSUE_CREATE = "issue-create"
@@ -13,6 +14,7 @@ ACTION_PR_REVIEW = "pr-review"
13
14
 
14
15
  ALL_POST_ACTIONS: frozenset[str] = frozenset(
15
16
  {
17
+ ACTION_GITHUB_MCP_COMMENT,
16
18
  ACTION_GITHUB_MCP_POST,
17
19
  ACTION_ISSUE_COMMENT,
18
20
  ACTION_ISSUE_CREATE,
@@ -25,6 +27,7 @@ ALL_POST_ACTIONS: frozenset[str] = frozenset(
25
27
  )
26
28
  ALL_BODY_REQUIRED_ACTIONS: frozenset[str] = frozenset(
27
29
  {
30
+ ACTION_GITHUB_MCP_COMMENT,
28
31
  ACTION_GITHUB_MCP_POST,
29
32
  ACTION_ISSUE_COMMENT,
30
33
  ACTION_ISSUE_CREATE,
@@ -102,3 +105,6 @@ REWRITTEN_RELEASE_BODY_MESSAGE = (
102
105
  "keep the generated text whole"
103
106
  )
104
107
  REPOSITORY_OWNER_SEPARATOR = "/"
108
+ PROOF_GATE_CREATE_TOOL_NAME = "mcp__github__create_pull_request"
109
+ PULL_REQUEST_PROOF_FINDING_CODE = "pull-request-proof-section"
110
+ COMMENT_LENGTH_FINDING_CODE = "github-comment-length"
@@ -12,6 +12,7 @@ from durable_post_lint_config.config.constants import (
12
12
 
13
13
  def test_action_inventory_covers_pull_request_issue_and_tool_posts() -> None:
14
14
  assert ALL_POST_ACTIONS == {
15
+ "github-mcp-comment",
15
16
  "github-mcp-post",
16
17
  "issue-comment",
17
18
  "issue-create",
@@ -144,6 +144,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
144
144
  hooks/blocking/open_tasks_stop_gate.py -> exempt
145
145
  hooks/blocking/owned_pr_merge_stop_gate.py -> exempt
146
146
  hooks/blocking/session_title_turn_gate.py -> exempt
147
+ hooks/blocking/resolve_thread_reply_gate.py -> exempt
147
148
  hooks/blocking/some_new_blocker.py -> flagged
148
149
 
149
150
  The Bash PreToolUse dispatcher sits under ``blocking/`` for layout reasons
@@ -202,6 +203,10 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
202
203
  closing reply names no ``Blocked:`` line for it. It decides when a turn may
203
204
  end and no code or safety policy.
204
205
 
206
+ The resolve reply gate denies a review thread resolve until a reply on the
207
+ thread has posted. It decides the order of two review calls and no code or
208
+ safety policy.
209
+
205
210
  Args:
206
211
  registered_string: One command, path, script, or entrypoint string.
207
212
  """
@@ -83,6 +83,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
83
83
  "blocking/open_tasks_stop_gate.py",
84
84
  "blocking/owned_pr_merge_stop_gate.py",
85
85
  "blocking/session_title_turn_gate.py",
86
+ "blocking/resolve_thread_reply_gate.py",
86
87
  )
87
88
  ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
88
89
  ALL_TEST_FILE_PREFIXES = ("test_",)
@@ -947,6 +947,41 @@ def test_should_wait_for_spent_default_home_until_the_mark_passes(
947
947
  assert running_decision["home"] == str(home.resolve())
948
948
 
949
949
 
950
+ def test_should_run_on_a_roster_account_while_a_no_roster_default_mark_is_live(
951
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
952
+ ) -> None:
953
+ account = _account("personal")
954
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"personal": _meters(99, 99)}))
955
+ _freeze_clock(monkeypatch, NOW)
956
+ no_roster_account = Account(Product.CODEX, "default", Path(), False)
957
+ account_broker._save_state(
958
+ account_broker.broker_state_path(),
959
+ {"meters": {}, "spent": {account_broker._state_key(no_roster_account): (NOW + timedelta(hours=1)).timestamp()}, "affinity": {}},
960
+ )
961
+
962
+ code = account_broker.main(("choose", "--product", "codex"))
963
+
964
+ decision = json.loads(capsys.readouterr().out)["decision"]
965
+ assert code == 0
966
+ assert decision["action"] == "run"
967
+ assert decision["account"] == "personal"
968
+
969
+
970
+ def test_should_not_save_a_default_spent_mark_while_a_roster_exists(
971
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
972
+ ) -> None:
973
+ account = _account("personal")
974
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"personal": _meters(99, 99)}))
975
+ _freeze_clock(monkeypatch, NOW)
976
+
977
+ code = account_broker.main(("choose", "--product", "codex", "--spent", "default"))
978
+
979
+ decision = json.loads(capsys.readouterr().out)["decision"]
980
+ assert code == 0
981
+ assert decision["account"] == "personal"
982
+ assert account_broker._load_state(account_broker.broker_state_path())["spent"] == {}
983
+
984
+
950
985
  def test_should_keep_a_spent_mark_without_a_reset_for_one_hour(
951
986
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
952
987
  ) -> None:
@@ -11,6 +11,11 @@ import pytest
11
11
 
12
12
  import durable_post_lint
13
13
  from dev_env_scripts_constants.private_term_constants import PrivateTermDigest
14
+ from hooks_constants.pull_request_proof_constants import (
15
+ MISSING_EVAL_SECTION_REASON,
16
+ MISSING_EXISTING_WORK_REASON,
17
+ TEST_ONLY_PROOF_REASON,
18
+ )
14
19
 
15
20
  SCRIPT_PATH = Path(__file__).resolve().parent / "durable_post_lint.py"
16
21
  VALID_PR_BODY = """## Why
@@ -32,7 +37,25 @@ Blast radius text.
32
37
  ## Verification
33
38
 
34
39
  Verification text.
40
+
41
+ ## Existing work
42
+
43
+ `scripts/durable_post_lint.py:1` holds the post checks.
44
+
45
+ ## Proof in practice
46
+
47
+ Ran `python scripts/durable_post_lint.py --action pr-create --body-file body.md` on the new body.
48
+
49
+ ## Eval
50
+
51
+ `python scripts/durable_post_lint.py --action pr-create --body-file eval_body.md`
35
52
  """
53
+ OVER_LONG_REPLY_BODY = (
54
+ "Fixed in `abc1234`.\n\n"
55
+ "The parser now rejects an empty header.\n\n"
56
+ "The test covers both cases.\n\n"
57
+ "---\nPosted by the review agent."
58
+ )
36
59
  MINIMAL_PR_BODY = """## Why
37
60
 
38
61
  Why text.
@@ -54,6 +77,7 @@ Verification text.
54
77
  ("issue-edit", None, "Updated issue body."),
55
78
  ("issue-comment", None, "Issue comment."),
56
79
  ("github-mcp-post", None, "MCP body."),
80
+ ("github-mcp-comment", None, "Reply body."),
57
81
  ],
58
82
  )
59
83
  def test_all_actions_accept_valid_inputs(
@@ -112,6 +136,7 @@ def test_pr_edit_requires_one_edit_field() -> None:
112
136
  "issue-edit",
113
137
  "issue-comment",
114
138
  "github-mcp-post",
139
+ "github-mcp-comment",
115
140
  ],
116
141
  )
117
142
  def test_body_actions_require_body(action: str) -> None:
@@ -176,10 +201,9 @@ def test_pr_body_reports_each_missing_heading(missing_heading: str) -> None:
176
201
  ]
177
202
 
178
203
 
179
- @pytest.mark.parametrize("action", ["pr-create", "pr-edit"])
180
- def should_accept_a_body_carrying_only_the_two_required_headings(action: str) -> None:
204
+ def should_accept_an_edit_body_carrying_only_the_two_required_headings() -> None:
181
205
  all_findings = durable_post_lint.lint_durable_post(
182
- action=action,
206
+ action="pr-edit",
183
207
  title="fix(cli): tighten input handling",
184
208
  body_text=MINIMAL_PR_BODY,
185
209
  )
@@ -201,6 +225,155 @@ def should_still_accept_a_body_published_under_the_superseded_heading_set() -> N
201
225
  assert all_findings == ()
202
226
 
203
227
 
228
+ def should_report_a_create_body_with_no_existing_work_section() -> None:
229
+ body_text = VALID_PR_BODY.replace(
230
+ "## Existing work\n\n`scripts/durable_post_lint.py:1` holds the post checks.\n\n", ""
231
+ )
232
+ all_findings = durable_post_lint.lint_durable_post(
233
+ action="pr-create",
234
+ title="feat: add durable post linting",
235
+ body_text=body_text,
236
+ )
237
+ assert [each_finding.message for each_finding in all_findings] == [
238
+ MISSING_EXISTING_WORK_REASON
239
+ ]
240
+
241
+
242
+ def should_report_a_create_body_whose_proof_runs_only_tests() -> None:
243
+ body_text = VALID_PR_BODY.replace(
244
+ "Ran `python scripts/durable_post_lint.py --action pr-create --body-file body.md` on the new body.",
245
+ "Ran `python -m pytest scripts`.",
246
+ )
247
+ all_findings = durable_post_lint.lint_durable_post(
248
+ action="pr-create",
249
+ title="fix: tighten durable post linting",
250
+ body_text=body_text,
251
+ )
252
+ assert [each_finding.message for each_finding in all_findings] == [
253
+ TEST_ONLY_PROOF_REASON
254
+ ]
255
+
256
+
257
+ def should_report_a_feature_create_body_with_no_eval_section() -> None:
258
+ body_text = VALID_PR_BODY.split("## Eval")[0]
259
+ all_findings = durable_post_lint.lint_durable_post(
260
+ action="pr-create",
261
+ title="feat: add durable post linting",
262
+ body_text=body_text,
263
+ )
264
+ assert [each_finding.message for each_finding in all_findings] == [
265
+ MISSING_EVAL_SECTION_REASON
266
+ ]
267
+
268
+
269
+ def should_leave_an_edit_body_with_no_existing_work_section_clean() -> None:
270
+ all_findings = durable_post_lint.lint_durable_post(
271
+ action="pr-edit",
272
+ title=None,
273
+ body_text=MINIMAL_PR_BODY,
274
+ )
275
+ assert all_findings == ()
276
+
277
+
278
+ def should_exit_with_findings_for_a_create_body_with_no_existing_work_section(
279
+ tmp_path: Path,
280
+ ) -> None:
281
+ body_file = tmp_path / "body.md"
282
+ body_file.write_text(
283
+ VALID_PR_BODY.replace("## Existing work", "## Prior art"), encoding="utf-8"
284
+ )
285
+ completed_process = subprocess.run(
286
+ [
287
+ sys.executable,
288
+ str(SCRIPT_PATH),
289
+ "--action",
290
+ "pr-create",
291
+ "--title",
292
+ "feat: add durable post linting",
293
+ "--body-file",
294
+ str(body_file),
295
+ ],
296
+ check=False,
297
+ capture_output=True,
298
+ text=True,
299
+ )
300
+ assert completed_process.returncode == 1
301
+ assert completed_process.stderr == f"{MISSING_EXISTING_WORK_REASON}\n"
302
+
303
+
304
+ def should_report_a_review_reply_over_three_sentences() -> None:
305
+ all_findings = durable_post_lint.lint_durable_post(
306
+ action="github-mcp-comment",
307
+ title=None,
308
+ body_text=OVER_LONG_REPLY_BODY,
309
+ )
310
+ assert [each_finding.code for each_finding in all_findings] == [
311
+ "github-comment-length"
312
+ ]
313
+
314
+
315
+ def should_report_a_review_reply_sentence_over_fifteen_words() -> None:
316
+ all_findings = durable_post_lint.lint_durable_post(
317
+ action="github-mcp-comment",
318
+ title=None,
319
+ body_text=(
320
+ "This change moves the parser into its own module and keeps every"
321
+ " caller on the same import path."
322
+ ),
323
+ )
324
+ assert [each_finding.code for each_finding in all_findings] == [
325
+ "github-comment-length"
326
+ ]
327
+
328
+
329
+ def should_accept_a_short_review_reply() -> None:
330
+ all_findings = durable_post_lint.lint_durable_post(
331
+ action="github-mcp-comment",
332
+ title=None,
333
+ body_text="Fixed in `abc1234`.\n\nThe parser now rejects an empty header.",
334
+ )
335
+ assert all_findings == ()
336
+
337
+
338
+ def should_exit_with_findings_for_an_over_long_review_reply(tmp_path: Path) -> None:
339
+ body_file = tmp_path / "reply.md"
340
+ body_file.write_text(OVER_LONG_REPLY_BODY, encoding="utf-8")
341
+ completed_process = subprocess.run(
342
+ [
343
+ sys.executable,
344
+ str(SCRIPT_PATH),
345
+ "--action",
346
+ "github-mcp-comment",
347
+ "--body-file",
348
+ str(body_file),
349
+ ],
350
+ check=False,
351
+ capture_output=True,
352
+ text=True,
353
+ )
354
+ assert completed_process.returncode == 1
355
+ assert "at most three short sentences" in completed_process.stderr
356
+
357
+
358
+ def should_exit_clean_for_a_short_review_reply(tmp_path: Path) -> None:
359
+ body_file = tmp_path / "reply.md"
360
+ body_file.write_text("Fixed in `abc1234`.\n", encoding="utf-8")
361
+ completed_process = subprocess.run(
362
+ [
363
+ sys.executable,
364
+ str(SCRIPT_PATH),
365
+ "--action",
366
+ "github-mcp-comment",
367
+ "--body-file",
368
+ str(body_file),
369
+ ],
370
+ check=False,
371
+ capture_output=True,
372
+ text=True,
373
+ )
374
+ assert completed_process.returncode == 0
375
+
376
+
204
377
  def test_non_pr_body_needs_no_pr_headings() -> None:
205
378
  all_findings = durable_post_lint.lint_durable_post(
206
379
  action="issue-comment",
@@ -68,9 +68,52 @@ BeforeAll {
68
68
  & pwsh -NoProfile -File $scriptUnderTest -RepoPath $Fixture.MirrorPath -StashDirty -LogPath $Fixture.LogPath | Out-Null
69
69
  return $LASTEXITCODE
70
70
  }
71
+
72
+ function Invoke-MirrorSyncScript {
73
+ param([pscustomobject]$Fixture)
74
+ & pwsh -NoProfile -File $scriptUnderTest -RepoPath $Fixture.MirrorPath -StashDirty -Mirror -LogPath $Fixture.LogPath | Out-Null
75
+ return $LASTEXITCODE
76
+ }
77
+
78
+ function Switch-MirrorToFeatureBranch {
79
+ <#
80
+ Leaves the mirror on a feature branch whose commit edits tracked.txt,
81
+ the way a session that checked out a pull request in the mirror does.
82
+ #>
83
+ param([pscustomobject]$Fixture)
84
+ Invoke-TestGit -RepoPath $Fixture.MirrorPath -Arguments @('switch', '-q', '-c', 'feature') | Out-Null
85
+ Write-TestFile -RepoPath $Fixture.MirrorPath -RelativePath 'tracked.txt' -Content "feature`n"
86
+ Invoke-TestGit -RepoPath $Fixture.MirrorPath -Arguments @('commit', '-q', '-am', 'feature edit') | Out-Null
87
+ return Invoke-TestGit -RepoPath $Fixture.MirrorPath -Arguments @('rev-parse', 'HEAD')
88
+ }
71
89
  }
72
90
 
73
91
  Describe 'Sync-RepoMain' {
92
+ It 'skips a mirror left on another branch when -Mirror is absent' {
93
+ $fixture = New-MirrorBehindRemote -Name 'feature-no-mirror'
94
+ $featureTip = Switch-MirrorToFeatureBranch -Fixture $fixture
95
+
96
+ $exitCode = Invoke-SyncScript -Fixture $fixture
97
+
98
+ $exitCode | Should -Be 3
99
+ (Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('rev-parse', 'HEAD')) | Should -Be $featureTip
100
+ }
101
+
102
+ It 'returns a mirror left on another branch to main and fast-forwards it with -Mirror' {
103
+ $fixture = New-MirrorBehindRemote -Name 'feature-mirror'
104
+ $featureTip = Switch-MirrorToFeatureBranch -Fixture $fixture
105
+
106
+ $exitCode = Invoke-MirrorSyncScript -Fixture $fixture
107
+
108
+ $exitCode | Should -Be 0
109
+ (Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('branch', '--show-current')) | Should -Be 'main'
110
+ (Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('rev-parse', 'HEAD')) | Should -Be $fixture.RemoteTip
111
+ [System.IO.File]::ReadAllText((Join-Path $fixture.MirrorPath 'tracked.txt')) | Should -Be "new`n"
112
+ (Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('status', '--porcelain')) | Should -BeNullOrEmpty
113
+ (Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('rev-parse', 'feature')) | Should -Be $featureTip
114
+ (Get-Content -LiteralPath $fixture.LogPath -Raw) | Should -Match "left branch 'feature' at $featureTip"
115
+ }
116
+
74
117
  It 'fast-forwards a clean mirror that is behind the remote' {
75
118
  $fixture = New-MirrorBehindRemote -Name 'clean-behind'
76
119