claude-dev-env 1.84.0 → 1.86.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.
- package/_shared/pr-loop/scripts/code_rules_gate.py +18 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +143 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +306 -64
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +89 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +200 -21
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker.py +1 -1
- package/hooks/blocking/code_rules_dead_module_constant.py +7 -4
- package/hooks/blocking/code_rules_docstrings.py +17 -13
- package/hooks/blocking/code_rules_imports_logging.py +10 -6
- package/hooks/blocking/code_rules_naming_collection.py +5 -3
- package/hooks/blocking/code_rules_paired_test.py +3 -2
- package/hooks/blocking/code_rules_string_magic.py +1 -1
- package/hooks/blocking/code_rules_unused_imports.py +2 -2
- package/hooks/blocking/session_edit_stage_gate.py +654 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +29 -0
- package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +24 -0
- package/hooks/blocking/test_session_edit_stage_gate.py +537 -0
- package/hooks/hooks.json +30 -0
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/blocking_check_limits.py +1 -0
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +92 -0
- package/hooks/hooks_constants/task_list_loop_starter_constants.py +18 -0
- package/hooks/lifecycle/CLAUDE.md +1 -1
- package/hooks/observability/CLAUDE.md +2 -0
- package/hooks/observability/session_file_edit_tracker.py +224 -0
- package/hooks/observability/test_session_file_edit_tracker.py +174 -0
- package/hooks/session/CLAUDE.md +6 -2
- package/hooks/session/session_edit_tracker_cleanup.py +120 -0
- package/hooks/session/task_list_loop_starter.py +36 -0
- package/hooks/session/test_session_edit_tracker_cleanup.py +157 -0
- package/hooks/session/test_task_list_loop_starter.py +53 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/env-var-table-code-drift.md +1 -1
- package/rules/package-inventory-stale-entry.md +1 -1
- package/rules/paired-test-coverage.md +1 -1
- package/rules/re-stage-before-commit.md +31 -0
- package/skills/_shared/pr-loop/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/handoff_constants.py +45 -0
- package/skills/_shared/pr-loop/scripts/test_write_handoff.py +201 -0
- package/skills/_shared/pr-loop/scripts/write_handoff.py +309 -0
- package/skills/autoconverge/SKILL.md +46 -3
- package/skills/autoconverge/workflow/converge.mjs +1 -1
- package/skills/pr-converge/SKILL.md +27 -1
- package/skills/pr-converge/reference/state-schema.md +12 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Write durable resume-handoff files for a pr-loop run.
|
|
2
|
+
|
|
3
|
+
A converge loop can stop mid-run: a paused tick, a budget cutoff, a killed
|
|
4
|
+
session. The next session then has no pointer to where the run was. This writer
|
|
5
|
+
drops that pointer somewhere the OS temp sweep cannot purge.
|
|
6
|
+
|
|
7
|
+
::
|
|
8
|
+
|
|
9
|
+
write_handoff.py --pr-number <N> --head-ref <branch> --phase tick
|
|
10
|
+
--resume-command "claude --resume /pr-converge" [--state-file <PATH>]
|
|
11
|
+
writes: ~/.claude/runtime/pr-loop/<run-name>/handoff.json + HANDOFF.md
|
|
12
|
+
plus: state-copy.json (only when --state-file is given)
|
|
13
|
+
|
|
14
|
+
It records the resume command, the phase reached, the steps already done, and a
|
|
15
|
+
copy of the loop state, so a fresh session picks up without re-deriving it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
_self_dir = Path(__file__).resolve().parent
|
|
28
|
+
if str(_self_dir) not in sys.path:
|
|
29
|
+
sys.path.insert(0, str(_self_dir))
|
|
30
|
+
|
|
31
|
+
from _path_resolver import build_run_name # noqa: E402
|
|
32
|
+
from skills_pr_loop_constants.handoff_constants import ( # noqa: E402
|
|
33
|
+
ATOMIC_STAGING_SUFFIX,
|
|
34
|
+
COMPLETED_STEPS_SEPARATOR,
|
|
35
|
+
DEFAULT_NEXT_STEP_LINE,
|
|
36
|
+
ALL_HANDOFF_DIR_SEGMENTS,
|
|
37
|
+
HANDOFF_JSON_FILENAME,
|
|
38
|
+
HANDOFF_JSON_INDENT,
|
|
39
|
+
HANDOFF_MARKDOWN_FILENAME,
|
|
40
|
+
HANDOFF_MARKDOWN_TEMPLATE,
|
|
41
|
+
NO_STATE_SNAPSHOT_LINE,
|
|
42
|
+
NO_STEPS_DONE_LINE,
|
|
43
|
+
STATE_COPY_FILENAME,
|
|
44
|
+
STEP_LINE_SEPARATOR,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _now_iso() -> str:
|
|
49
|
+
"""Return the current UTC time as an ISO-format timestamp string.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The stamp recorded on the handoff so a reader knows how fresh it is.
|
|
53
|
+
"""
|
|
54
|
+
return datetime.now(timezone.utc).isoformat()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _handoff_base_dir() -> Path:
|
|
58
|
+
"""Return the durable base directory that holds every run's handoff folder.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The ``~/.claude/runtime/pr-loop`` path under the user's home directory.
|
|
62
|
+
"""
|
|
63
|
+
return Path.home().joinpath(*ALL_HANDOFF_DIR_SEGMENTS)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def resolve_handoff_dir(run_name: str) -> Path:
|
|
67
|
+
"""Resolve the durable handoff directory for a run name.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
run_name: Run name token (from build_run_name).
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Absolute path to the run's handoff directory under the durable base.
|
|
74
|
+
"""
|
|
75
|
+
return _handoff_base_dir() / run_name
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _write_text_atomic(target_path: Path, text: str) -> None:
|
|
79
|
+
"""Write text to a path atomically via a staging file and a rename.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
target_path: Destination file path.
|
|
83
|
+
text: Full file contents to write.
|
|
84
|
+
"""
|
|
85
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
staging_path = target_path.with_name(
|
|
87
|
+
f"{target_path.name}{ATOMIC_STAGING_SUFFIX}.{os.getpid()}"
|
|
88
|
+
)
|
|
89
|
+
staging_path.write_text(text, encoding="utf-8")
|
|
90
|
+
os.replace(staging_path, target_path)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _snapshot_state(handoff_dir: Path, state_file: Path | None) -> str | None:
|
|
94
|
+
"""Copy a loop state file into the handoff directory when one is supplied.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
handoff_dir: The run's durable handoff directory.
|
|
98
|
+
state_file: Loop state file to snapshot, or None to skip the copy.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The forward-slash path of the copy to trust, or None when no state
|
|
102
|
+
file was supplied.
|
|
103
|
+
"""
|
|
104
|
+
if state_file is None:
|
|
105
|
+
return None
|
|
106
|
+
state_copy_path = handoff_dir / STATE_COPY_FILENAME
|
|
107
|
+
_write_text_atomic(state_copy_path, state_file.read_text(encoding="utf-8"))
|
|
108
|
+
return state_copy_path.as_posix()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _steps_markdown_block(all_completed_steps: list[str]) -> str:
|
|
112
|
+
"""Render the completed steps as a markdown bullet list.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
all_completed_steps: Steps already finished this run.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
A bullet list, or a placeholder line when no steps are recorded.
|
|
119
|
+
"""
|
|
120
|
+
if not all_completed_steps:
|
|
121
|
+
return NO_STEPS_DONE_LINE
|
|
122
|
+
return STEP_LINE_SEPARATOR.join(f"- {each_step}" for each_step in all_completed_steps)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _build_handoff_markdown(
|
|
126
|
+
*,
|
|
127
|
+
pr_number: int,
|
|
128
|
+
head_ref: str,
|
|
129
|
+
phase: str,
|
|
130
|
+
resume_command: str,
|
|
131
|
+
trusted_state_path: str | None,
|
|
132
|
+
all_completed_steps: list[str],
|
|
133
|
+
note: str | None,
|
|
134
|
+
) -> str:
|
|
135
|
+
"""Build the human-facing HANDOFF.md body for a fresh session.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
pr_number: Pull request number.
|
|
139
|
+
head_ref: Head branch ref.
|
|
140
|
+
phase: The checkpoint the run reached.
|
|
141
|
+
resume_command: The exact command a fresh session runs to resume.
|
|
142
|
+
trusted_state_path: The state-copy path to trust, or None.
|
|
143
|
+
all_completed_steps: Steps already finished this run.
|
|
144
|
+
note: Free-text next-step note, or None.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The rendered markdown document.
|
|
148
|
+
"""
|
|
149
|
+
state_line = (
|
|
150
|
+
f"Trust this state snapshot: {trusted_state_path}"
|
|
151
|
+
if trusted_state_path is not None
|
|
152
|
+
else NO_STATE_SNAPSHOT_LINE
|
|
153
|
+
)
|
|
154
|
+
return HANDOFF_MARKDOWN_TEMPLATE.format(
|
|
155
|
+
pr_number=pr_number,
|
|
156
|
+
head_ref=head_ref,
|
|
157
|
+
phase=phase,
|
|
158
|
+
resume_command=resume_command,
|
|
159
|
+
state_line=state_line,
|
|
160
|
+
steps_block=_steps_markdown_block(all_completed_steps),
|
|
161
|
+
next_step=note if note else DEFAULT_NEXT_STEP_LINE,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def write_handoff(
|
|
166
|
+
*,
|
|
167
|
+
pr_number: int,
|
|
168
|
+
head_ref: str,
|
|
169
|
+
phase: str,
|
|
170
|
+
resume_command: str,
|
|
171
|
+
state_file: Path | None = None,
|
|
172
|
+
run_id: str | None = None,
|
|
173
|
+
all_completed_steps: list[str] | None = None,
|
|
174
|
+
note: str | None = None,
|
|
175
|
+
) -> Path:
|
|
176
|
+
"""Write handoff.json, HANDOFF.md, and an optional state copy for a run.
|
|
177
|
+
|
|
178
|
+
Names the run with build_run_name and places the files under the durable
|
|
179
|
+
handoff directory. When a state file is supplied, its contents are copied to
|
|
180
|
+
state-copy.json and that copy path is recorded as the state to trust.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
pr_number: Pull request number.
|
|
184
|
+
head_ref: Head branch ref.
|
|
185
|
+
phase: The checkpoint the run reached (e.g. 'tick', 'teardown').
|
|
186
|
+
resume_command: The exact command a fresh session runs to resume.
|
|
187
|
+
state_file: Loop state file to snapshot, or None to skip the copy.
|
|
188
|
+
run_id: Workflow run id to resume from, or None.
|
|
189
|
+
all_completed_steps: Steps already finished this run, or None.
|
|
190
|
+
note: Free-text next-step note for the fresh session, or None.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Path to the run's durable handoff directory.
|
|
194
|
+
"""
|
|
195
|
+
steps_done = all_completed_steps if all_completed_steps else []
|
|
196
|
+
run_name = build_run_name(pr_number, head_ref, is_multi_pr=False)
|
|
197
|
+
handoff_dir = resolve_handoff_dir(run_name)
|
|
198
|
+
handoff_dir.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
|
|
200
|
+
trusted_state_path = _snapshot_state(handoff_dir, state_file)
|
|
201
|
+
payload = {
|
|
202
|
+
"pr_number": pr_number,
|
|
203
|
+
"head_ref": head_ref,
|
|
204
|
+
"phase": phase,
|
|
205
|
+
"resume_command": resume_command,
|
|
206
|
+
"run_id": run_id,
|
|
207
|
+
"state_file": trusted_state_path,
|
|
208
|
+
"completed_steps": steps_done,
|
|
209
|
+
"note": note,
|
|
210
|
+
"timestamp": _now_iso(),
|
|
211
|
+
}
|
|
212
|
+
_write_text_atomic(
|
|
213
|
+
handoff_dir / HANDOFF_JSON_FILENAME,
|
|
214
|
+
json.dumps(payload, indent=HANDOFF_JSON_INDENT) + "\n",
|
|
215
|
+
)
|
|
216
|
+
_write_text_atomic(
|
|
217
|
+
handoff_dir / HANDOFF_MARKDOWN_FILENAME,
|
|
218
|
+
_build_handoff_markdown(
|
|
219
|
+
pr_number=pr_number,
|
|
220
|
+
head_ref=head_ref,
|
|
221
|
+
phase=phase,
|
|
222
|
+
resume_command=resume_command,
|
|
223
|
+
trusted_state_path=trusted_state_path,
|
|
224
|
+
all_completed_steps=steps_done,
|
|
225
|
+
note=note,
|
|
226
|
+
),
|
|
227
|
+
)
|
|
228
|
+
return handoff_dir
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _split_completed_steps(raw_steps: str) -> list[str]:
|
|
232
|
+
"""Split a comma-separated steps argument into a trimmed, non-empty list.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
raw_steps: The raw --completed-steps CLI value.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
The individual step names with surrounding whitespace removed.
|
|
239
|
+
"""
|
|
240
|
+
return [
|
|
241
|
+
each_segment.strip()
|
|
242
|
+
for each_segment in raw_steps.split(COMPLETED_STEPS_SEPARATOR)
|
|
243
|
+
if each_segment.strip()
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
|
|
248
|
+
"""Parse command-line arguments.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
all_argv: Command-line argument list.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Parsed namespace with the handoff fields.
|
|
255
|
+
"""
|
|
256
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
257
|
+
parser.add_argument("--pr-number", type=int, required=True)
|
|
258
|
+
parser.add_argument("--head-ref", required=True)
|
|
259
|
+
parser.add_argument("--phase", required=True)
|
|
260
|
+
parser.add_argument("--resume-command", required=True)
|
|
261
|
+
parser.add_argument("--state-file", type=Path, default=None)
|
|
262
|
+
parser.add_argument("--run-id", default=None)
|
|
263
|
+
parser.add_argument("--completed-steps", default="")
|
|
264
|
+
parser.add_argument("--note", default=None)
|
|
265
|
+
return parser.parse_args(all_argv)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _write_handoff_from_arguments(arguments: argparse.Namespace) -> Path:
|
|
269
|
+
"""Map parsed CLI arguments onto a write_handoff call.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
arguments: The namespace parse_arguments produced.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
Path to the run's durable handoff directory.
|
|
276
|
+
"""
|
|
277
|
+
return write_handoff(
|
|
278
|
+
pr_number=arguments.pr_number,
|
|
279
|
+
head_ref=arguments.head_ref,
|
|
280
|
+
phase=arguments.phase,
|
|
281
|
+
resume_command=arguments.resume_command,
|
|
282
|
+
state_file=arguments.state_file,
|
|
283
|
+
run_id=arguments.run_id,
|
|
284
|
+
all_completed_steps=_split_completed_steps(arguments.completed_steps),
|
|
285
|
+
note=arguments.note,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def main(all_arguments: list[str]) -> int:
|
|
290
|
+
"""Entry point: write the handoff files and print the durable directory.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
all_arguments: Command-line arguments.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
0 on success, 1 on a state-file read or write failure.
|
|
297
|
+
"""
|
|
298
|
+
arguments = parse_arguments(all_arguments)
|
|
299
|
+
try:
|
|
300
|
+
handoff_dir = _write_handoff_from_arguments(arguments)
|
|
301
|
+
except (OSError, UnicodeDecodeError) as read_or_write_error:
|
|
302
|
+
print(f"write_handoff failed: {read_or_write_error}", file=sys.stderr)
|
|
303
|
+
return 1
|
|
304
|
+
print(handoff_dir.as_posix())
|
|
305
|
+
return 0
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
if __name__ == "__main__":
|
|
309
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -115,6 +115,19 @@ and `false` when it exits 0; on `true` the workflow skips the Copilot gate with
|
|
|
115
115
|
no agent spawned. The workflow runs in the background and notifies this session
|
|
116
116
|
on completion. Watch live progress with `/workflows`.
|
|
117
117
|
|
|
118
|
+
The moment the `Workflow` call returns its run id, write the durable handoff so a
|
|
119
|
+
fresh session can resume the same run:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
python "$HOME/.claude/skills/_shared/pr-loop/scripts/write_handoff.py" \
|
|
123
|
+
--pr-number <N> --head-ref <branch> --phase workflow \
|
|
124
|
+
--resume-command "Workflow({scriptPath, resumeFromRunId: '<runId>'})" \
|
|
125
|
+
--run-id <runId>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Write it again when the result lands, so the handoff carries the final run id and
|
|
129
|
+
names the teardown phase the fresh session picks up from.
|
|
130
|
+
|
|
118
131
|
The workflow returns
|
|
119
132
|
`{ converged, rounds, finalSha, blocker, standardsNote, copilotNote, reuseNote, deferredPrs }`.
|
|
120
133
|
`deferredPrs` is the list of draft environment-hardening PRs the standards-deferral
|
|
@@ -134,8 +147,32 @@ returns `blocker: "budget"` with the run id; resume with
|
|
|
134
147
|
the journal. Never start a round the budget cannot finish: a half-run
|
|
135
148
|
round records nothing resumable and replays dirty.
|
|
136
149
|
|
|
150
|
+
On a `blocker: "budget"` return, write the durable handoff with the run id and the
|
|
151
|
+
`Workflow({scriptPath, resumeFromRunId})` resume command before stopping, so a
|
|
152
|
+
fresh session resumes the paced run without the stopped session's transcript.
|
|
153
|
+
|
|
137
154
|
## Teardown (on workflow completion)
|
|
138
155
|
|
|
156
|
+
Teardown runs as an ordered checkpoint list. After each checkpoint finishes,
|
|
157
|
+
re-write the durable handoff with `--phase teardown`, the run id, and the
|
|
158
|
+
checkpoints done so far, so a fresh session that resumes reads `handoff.json`
|
|
159
|
+
`completed_steps` and skips the checkpoints already done:
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
python "$HOME/.claude/skills/_shared/pr-loop/scripts/write_handoff.py" \
|
|
163
|
+
--pr-number <N> --head-ref <branch> --phase teardown \
|
|
164
|
+
--resume-command "/autoconverge <PR URL>" \
|
|
165
|
+
--run-id <runId> \
|
|
166
|
+
--completed-steps "<checkpoints done so far>"
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The checkpoints run in this order: `report`, `close`. Write the
|
|
170
|
+
handoff after each one finishes.
|
|
171
|
+
|
|
172
|
+
On teardown entry, when `~/.claude/runtime/pr-loop/bugteam-pr-<N>/handoff.json`
|
|
173
|
+
exists, read its `completed_steps` and skip any checkpoint the list already
|
|
174
|
+
names, so a resumed run performs only the checkpoints left.
|
|
175
|
+
|
|
139
176
|
1. **When `converged` is true — build and publish the closing report.**
|
|
140
177
|
Skip this entire step (report, artifact publish, comment, Chrome open) when the
|
|
141
178
|
workflow returned a non-null `blocker`. Per-round live-dashboard refresh is out of
|
|
@@ -204,14 +241,18 @@ round records nothing resumable and replays dirty.
|
|
|
204
241
|
```
|
|
205
242
|
Start-Process chrome -ArgumentList '--new-window', '<artifact URL>'
|
|
206
243
|
```
|
|
207
|
-
|
|
244
|
+
Skip a missing Chrome without aborting the rest of teardown.
|
|
245
|
+
|
|
246
|
+
After the report is published, write the handoff with
|
|
247
|
+
`--completed-steps "report"`.
|
|
208
248
|
|
|
209
249
|
2. **Close the run.** Apply the `pr-loop-lifecycle` skill's Close section
|
|
210
250
|
(`../pr-loop-lifecycle/SKILL.md`): when `converged` is true, rewrite the PR
|
|
211
251
|
description and clean the working tree — see
|
|
212
252
|
[`pr-loop-lifecycle/reference/teardown-publish-permissions.md` § Clean working tree and § Publish the final PR description](../pr-loop-lifecycle/reference/teardown-publish-permissions.md);
|
|
213
253
|
the workflow already marked the PR ready. The permission revoke always runs,
|
|
214
|
-
including on a blocker exit.
|
|
254
|
+
including on a blocker exit. Write the handoff with
|
|
255
|
+
`--completed-steps "report,close"`.
|
|
215
256
|
|
|
216
257
|
3. **Print the final report:**
|
|
217
258
|
|
|
@@ -385,7 +426,9 @@ top-level `converged` is true only when every PR converged.
|
|
|
385
426
|
|
|
386
427
|
Run the single-PR [Teardown](#teardown-on-workflow-completion) once per entry in
|
|
387
428
|
`results`, using that PR's `owner`, `repo`, `prNumber`, and `finalSha`, and its
|
|
388
|
-
own worktree as the working directory.
|
|
429
|
+
own worktree as the working directory. Write one durable handoff per PR entry —
|
|
430
|
+
each with that PR's own `--pr-number` — so a fresh session can resume any PR on
|
|
431
|
+
its own. Build and publish a PR's closing report
|
|
389
432
|
only for a PR whose `converged` is true; for a PR that returned a blocker, skip
|
|
390
433
|
its report and carry the blocker into the final summary. Revoke project
|
|
391
434
|
permissions once per repository after every PR's teardown. Then print one summary
|
|
@@ -1359,7 +1359,7 @@ function runCopilotGate(head) {
|
|
|
1359
1359
|
`Copilot can run out of usage. When the newest Copilot review on HEAD carries an out-of-usage notice — a body stating Copilot was unable to review because the user who requested the review has reached their quota limit, or any equivalent quota / premium-request / usage-limit exhaustion message rather than an actual code review — Copilot is down for this run: return {sha:${'`'}${head}${'`'}, clean:true, down:true, findings:[]} and stop. Do NOT re-request a review, do NOT keep polling, and do NOT treat the notice as a finding.\n\n` +
|
|
1360
1360
|
`1. Read any existing Copilot review on HEAD first: python "${CONFIG.sharedScripts}/fetch_copilot_reviews.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber}. This lists every Copilot review across all commits newest-first; only count entries whose commit_id starts with ${head}. If the newest such HEAD-scoped Copilot review is the out-of-usage notice above -> return the down result and stop. A notice on any earlier commit is NOT down: ignore it and continue. With no Copilot review on HEAD, skip a duplicate request: python "${CONFIG.sharedScripts}/check_pending_reviews.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} --user copilot. Exit 0 means a request is already pending; otherwise request one:\n` +
|
|
1361
1361
|
` ${REVIEWER_GATE_SENTINEL}gh api --method POST repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/requested_reviewers -f 'reviewers[]=copilot-pull-request-reviewer[bot]'\n` +
|
|
1362
|
-
`2. Poll for Copilot's review on HEAD ${head}: up to ${CONFIG.copilotMaxPolls} attempts, 360 seconds apart (wait each 360-second interval inside this turn with the Monitor tool, per the WAITS AND POLLS rule above; if the attempt budget is spent with no review on HEAD, return down: true). Each attempt: python "${CONFIG.sharedScripts}/fetch_copilot_reviews.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} for the top-level review state, plus gh api "repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/comments" --paginate --slurp for inline comment ids (Copilot's login contains "copilot", case-insensitive). Only count entries whose commit_id starts with ${head}.\n` +
|
|
1362
|
+
`2. Poll for Copilot's review on HEAD ${head}: up to ${CONFIG.copilotMaxPolls} attempts, 360 seconds apart (wait each 360-second interval inside this turn with the Monitor tool, per the WAITS AND POLLS rule above; if the attempt budget is spent with no review on HEAD, return the full down result {sha:${'`'}${head}${'`'}, clean:false, down:true, findings:[]}). Each attempt: python "${CONFIG.sharedScripts}/fetch_copilot_reviews.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} for the top-level review state, plus gh api "repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/comments" --paginate --slurp for inline comment ids (Copilot's login contains "copilot", case-insensitive). Only count entries whose commit_id starts with ${head}.\n` +
|
|
1363
1363
|
` - Out-of-usage notice on HEAD -> return the down result above (clean:true, down:true) and stop.\n` +
|
|
1364
1364
|
` - Copilot review present on HEAD whose state is APPROVED, or COMMENTED with no inline findings -> a clean pass: return {sha:${'`'}${head}${'`'}, clean:true, down:false, findings:[]}.\n` +
|
|
1365
1365
|
` - Copilot findings on HEAD -> return them (each with its inline comment id in replyToCommentId; category 'code-standard' for pure CODE_RULES/style violations with no behavioral impact, 'bug' otherwise), clean:false, down:false.\n` +
|
|
@@ -43,6 +43,15 @@ working directory routes into the PR's repo for local work and returns to
|
|
|
43
43
|
the session worktree before teardown. See
|
|
44
44
|
[`reference/per-tick.md` § Step 1.5](reference/per-tick.md).
|
|
45
45
|
|
|
46
|
+
## Resume from a prior run
|
|
47
|
+
|
|
48
|
+
Before Step 0, check
|
|
49
|
+
`~/.claude/runtime/pr-loop/bugteam-pr-<PR number>/handoff.json`. When
|
|
50
|
+
`$CLAUDE_JOB_DIR/pr-converge-state.json` is absent but that handoff exists, seed
|
|
51
|
+
`phase`, `tick_count`, and the clean-at SHAs from the run's `state-copy.json`, so
|
|
52
|
+
a fresh session continues where the last one stopped rather than restarting at
|
|
53
|
+
BUGBOT.
|
|
54
|
+
|
|
46
55
|
## Copilot quota pre-check (start of run)
|
|
47
56
|
|
|
48
57
|
On the first tick, apply the `reviewer-gates` skill's Copilot quota gate
|
|
@@ -61,7 +70,8 @@ Before starting any tick, estimate whether the remaining session/usage
|
|
|
61
70
|
budget covers one full clean tick (worst case: a BUGBOT fetch + a
|
|
62
71
|
full-diff CODE_REVIEW + a fix commit + replies). If it does not, do not
|
|
63
72
|
start the tick. Stop at the current tick boundary: write updated state to
|
|
64
|
-
`$CLAUDE_JOB_DIR/pr-converge-state.json`,
|
|
73
|
+
`$CLAUDE_JOB_DIR/pr-converge-state.json`, write the durable handoff (see
|
|
74
|
+
[State persistence](#state-persistence)), then report the exact resume
|
|
65
75
|
command (`/pr-converge <PR URL>`) and the persisted `phase`/`tick_count`.
|
|
66
76
|
A tick cut off mid-flight poisons the resume state — clean SHAs recorded
|
|
67
77
|
against work that never landed — so an unstarted tick is always cheaper
|
|
@@ -83,6 +93,22 @@ On tick entry, read this file if it exists to restore phase, tick_count, and
|
|
|
83
93
|
clean-at SHAs. On tick exit, write updated state before calling ScheduleWakeup
|
|
84
94
|
so the next tick resumes with accurate state.
|
|
85
95
|
|
|
96
|
+
After the state write and before ScheduleWakeup, write the durable handoff so a
|
|
97
|
+
fresh session in a new job can resume this run:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
python "$HOME/.claude/skills/_shared/pr-loop/scripts/write_handoff.py" \
|
|
101
|
+
--pr-number <N> --head-ref <branch> --phase <phase> \
|
|
102
|
+
--resume-command "/pr-converge <PR URL>" \
|
|
103
|
+
--state-file "$CLAUDE_JOB_DIR/pr-converge-state.json" \
|
|
104
|
+
--completed-steps "<clean phases this run>"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
It writes `handoff.json`, `HANDOFF.md`, and `state-copy.json` under
|
|
108
|
+
`~/.claude/runtime/pr-loop/<run-name>/`. The job-dir state stays the source of
|
|
109
|
+
truth for a resumed tick; the handoff copy is the pointer a fresh session reads
|
|
110
|
+
when `$CLAUDE_JOB_DIR` is gone.
|
|
111
|
+
|
|
86
112
|
Fields: `phase`, `tick_count`, `bugbot_clean_at`, `code_review_clean_at`,
|
|
87
113
|
`bugteam_clean_at`, `copilot_clean_at`, `current_head`,
|
|
88
114
|
`bugbot_acknowledged_at`, `bugbot_down`, `copilot_down`,
|
|
@@ -100,3 +100,15 @@ if it exists and ends by writing the updated state back to that same file
|
|
|
100
100
|
before scheduling the next wakeup. Multi-PR mode additionally coordinates
|
|
101
101
|
across PRs via `<TMPDIR>/pr-converge-<session_id>/state.json` per
|
|
102
102
|
`multi-pr-orchestration.md` §What orchestrator does per tick.
|
|
103
|
+
|
|
104
|
+
## Handoff files
|
|
105
|
+
|
|
106
|
+
Each tick also writes a durable handoff under
|
|
107
|
+
`~/.claude/runtime/pr-loop/<run-name>/` via
|
|
108
|
+
`skills/_shared/pr-loop/scripts/write_handoff.py`: `handoff.json` (resume
|
|
109
|
+
command, phase, tick, and state path), `HANDOFF.md` (a prompt a fresh session
|
|
110
|
+
reads), and `state-copy.json` (a copy of the job-dir state). `$CLAUDE_JOB_DIR`
|
|
111
|
+
can be cleaned between sessions; this directory under the user home is not, so it
|
|
112
|
+
holds the pointer a new job needs. Live job-dir state wins for a resumed tick. A
|
|
113
|
+
fresh session reads the handoff copy only when `$CLAUDE_JOB_DIR` state is gone,
|
|
114
|
+
seeding phase, tick, and SHAs from `state-copy.json`.
|