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.
- package/.agents/skills/pr-lifecycle/SKILL.md +4 -4
- package/bin/install-pstack-plugin.mjs +71 -21
- package/bin/install-pstack-plugin.test.mjs +46 -9
- package/bin/install.test.mjs +9 -9
- package/docs/account-broker.md +1 -1
- package/hooks/blocking/reply_length_gate.py +11 -2
- package/hooks/blocking/resolve_thread_reply_gate.py +135 -0
- package/hooks/blocking/test_reply_length_gate.py +11 -0
- package/hooks/blocking/test_resolve_thread_reply_gate.py +199 -0
- package/hooks/features/conduct-gates.md +3 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/artifact_template_pointer_constants.py +3 -1
- package/hooks/hooks_constants/resolve_thread_reply_gate_constants.py +22 -0
- package/hooks/hooks_constants/test_artifact_template_pointer_constants.py +12 -0
- package/hooks/hooks_constants/transcript_skill_scan_constants.py +2 -0
- package/hooks/session/artifact_template_pointer.py +26 -3
- package/hooks/session/test_artifact_template_pointer.py +16 -0
- package/hooks/test_transcript_skill_scan.py +30 -0
- package/hooks/transcript_skill_scan.py +72 -1
- package/package.json +1 -1
- package/scripts/Sync-RepoMain.ps1 +102 -4
- package/scripts/account_broker.py +12 -4
- package/scripts/durable_post_lint.py +47 -1
- package/scripts/durable_post_lint_config/config/constants.py +6 -0
- package/scripts/durable_post_lint_config/config/test_constants.py +1 -0
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_account_broker.py +35 -0
- package/scripts/test_durable_post_lint.py +176 -3
- package/scripts/tests/Sync-RepoMain.Tests.ps1 +43 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
- 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
|
-
|
|
233
|
-
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
@@ -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
|
-
|
|
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=
|
|
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
|
|