claude-dev-env 1.83.0 → 1.85.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/audit-contract.md +24 -12
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +16 -5
- package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
- 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 +8 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +2 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +85 -39
- package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +212 -0
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +18 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +18 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +37 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +14 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +94 -4
- package/agents/clean-coder.md +3 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/bin/install.mjs +16 -8
- package/bin/install.test.mjs +17 -6
- package/commands/CLAUDE.md +0 -1
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/CLAUDE.md +0 -4
- package/hooks/blocking/claude_md_orphan_file_blocker.py +1 -1
- package/hooks/blocking/code_rules_constants_config.py +5 -0
- package/hooks/blocking/code_rules_docstrings.py +133 -19
- package/hooks/blocking/code_rules_enforcer.py +4 -0
- package/hooks/blocking/code_rules_imports_logging.py +10 -6
- package/hooks/blocking/code_rules_magic_values.py +5 -0
- package/hooks/blocking/code_rules_naming_collection.py +15 -8
- package/hooks/blocking/code_rules_paired_test.py +3 -2
- package/hooks/blocking/code_rules_shared.py +34 -0
- package/hooks/blocking/code_rules_string_magic.py +1 -1
- package/hooks/blocking/code_rules_unused_imports.py +2 -2
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +4 -4
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +29 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +197 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +27 -0
- package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +24 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +33 -0
- package/hooks/blocking/test_code_rules_enforcer_split_shared.py +23 -0
- package/hooks/blocking/test_code_rules_magic_values.py +54 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +0 -6
- package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +184 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +5 -30
- package/hooks/hooks_constants/CLAUDE.md +0 -3
- package/hooks/hooks_constants/blocking_check_limits.py +4 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +0 -1
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -5
- package/hooks/validation/post_tool_use_dispatcher.py +1 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +51 -35
- package/hooks/workflow/CLAUDE.md +2 -8
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -1
- 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/plain-illustrative-docstrings.md +35 -1
- package/skills/CLAUDE.md +6 -1
- 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 +82 -58
- package/skills/autoconverge/reference/closing-report.md +6 -6
- package/skills/autoconverge/reference/convergence.md +17 -15
- package/skills/autoconverge/reference/gotchas.md +6 -3
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +2 -9
- package/skills/autoconverge/workflow/converge.contract.test.mjs +87 -13
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -9
- package/skills/autoconverge/workflow/converge.mjs +71 -74
- package/skills/autoconverge/workflow/render_report.py +7 -11
- package/skills/bugteam/CLAUDE.md +1 -1
- package/skills/bugteam/PROMPTS.md +7 -6
- package/skills/bugteam/SKILL.md +25 -72
- package/skills/bugteam/reference/CLAUDE.md +1 -3
- package/skills/bugteam/reference/README.md +1 -1
- package/skills/bugteam/reference/audit-and-teammates.md +1 -1
- package/skills/bugteam/reference/obstacles/fix-publish-summary.md +1 -1
- package/skills/bugteam/reference/team-setup.md +8 -7
- package/skills/bugteam/scripts/CLAUDE.md +0 -6
- package/skills/bugteam/scripts/README.md +0 -4
- package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +0 -1
- package/skills/code/SKILL.md +2 -0
- package/skills/copilot-review/CLAUDE.md +1 -1
- package/skills/copilot-review/SKILL.md +25 -23
- package/skills/findbugs/CLAUDE.md +2 -2
- package/skills/findbugs/SKILL.md +22 -83
- package/skills/fixbugs/SKILL.md +2 -4
- package/skills/log-audit/CLAUDE.md +20 -0
- package/skills/log-audit/SKILL.md +68 -0
- package/skills/log-audit/reference/CLAUDE.md +9 -0
- package/skills/log-audit/reference/charter.md +52 -0
- package/skills/log-audit/scripts/CLAUDE.md +27 -0
- package/skills/log-audit/scripts/cluster_recurrences.py +261 -0
- package/skills/log-audit/scripts/collect_log_window.py +199 -0
- package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +12 -0
- package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +23 -0
- package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +24 -0
- package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +49 -0
- package/skills/log-audit/scripts/mine_copilot_findings.py +302 -0
- package/skills/log-audit/scripts/test_cluster_recurrences.py +160 -0
- package/skills/log-audit/scripts/test_collect_log_window.py +111 -0
- package/skills/log-audit/scripts/test_mine_copilot_findings.py +126 -0
- package/skills/monitor-open-prs/SKILL.md +2 -2
- package/skills/post-audit-findings/SKILL.md +84 -0
- package/skills/pr-converge/CLAUDE.md +2 -0
- package/skills/pr-converge/SKILL.md +99 -60
- package/skills/pr-converge/reference/CLAUDE.md +1 -1
- package/skills/pr-converge/reference/convergence-gates.md +16 -19
- package/skills/pr-converge/reference/examples.md +5 -5
- package/skills/pr-converge/reference/fix-protocol.md +16 -43
- package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +1 -1
- package/skills/pr-converge/reference/per-tick.md +24 -45
- package/skills/pr-converge/reference/state-schema.md +27 -0
- package/skills/pr-converge/scripts/README.md +3 -5
- package/skills/pr-fix-protocol/SKILL.md +70 -0
- package/skills/pr-loop-lifecycle/SKILL.md +73 -0
- package/skills/{bugteam → pr-loop-lifecycle}/reference/teardown-publish-permissions.md +22 -24
- package/skills/pr-scope-resolve/SKILL.md +48 -0
- package/skills/qbug/CLAUDE.md +4 -4
- package/skills/qbug/SKILL.md +46 -144
- package/skills/qbug/test_qbug_skill_audit_schema.py +2 -2
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +1 -1
- package/skills/reviewer-gates/SKILL.md +96 -0
- package/skills/session-log/CLAUDE.md +7 -7
- package/skills/session-log/SKILL.md +27 -44
- package/skills/test_markdown_link_integrity.py +103 -0
- package/commands/doc-gist.md +0 -16
- package/hooks/blocking/_md_to_html_blocker_test_support.py +0 -65
- package/hooks/blocking/conftest.py +0 -30
- package/hooks/blocking/md_path_exemptions.py +0 -224
- package/hooks/blocking/md_to_html_blocker.py +0 -155
- package/hooks/blocking/test_md_to_html_blocker_exemptions.py +0 -434
- package/hooks/blocking/test_md_to_html_blocker_extensions.py +0 -157
- package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +0 -336
- package/hooks/hooks_constants/doc_gist_auto_publish_constants.py +0 -18
- package/hooks/hooks_constants/html_companion_constants.py +0 -20
- package/hooks/hooks_constants/md_to_html_blocker_constants.py +0 -76
- package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +0 -125
- package/hooks/workflow/doc_gist_auto_publish.py +0 -144
- package/hooks/workflow/md_to_html_companion.py +0 -358
- package/hooks/workflow/test_doc_gist_auto_publish.py +0 -117
- package/hooks/workflow/test_md_to_html_companion.py +0 -613
- package/skills/bugteam/reference/audit-contract.md +0 -163
- package/skills/bugteam/scripts/_bugteam_permissions_common.py +0 -455
- package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +0 -69
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -280
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -266
- package/skills/bugteam/scripts/test__bugteam_permissions_common.py +0 -160
- package/skills/bugteam/scripts/test_agent_config_carveout.py +0 -356
- package/skills/bugteam/scripts/test_bugteam_permissions_common.py +0 -140
- package/skills/doc-gist/CLAUDE.md +0 -25
- package/skills/doc-gist/SKILL.md +0 -97
- package/skills/doc-gist/references/CLAUDE.md +0 -9
- package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +0 -453
- package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +0 -515
- package/skills/doc-gist/references/examples/03-code-review-pr.html +0 -638
- package/skills/doc-gist/references/examples/04-code-understanding.html +0 -491
- package/skills/doc-gist/references/examples/05-design-system.html +0 -629
- package/skills/doc-gist/references/examples/06-component-variants.html +0 -605
- package/skills/doc-gist/references/examples/07-prototype-animation.html +0 -455
- package/skills/doc-gist/references/examples/08-prototype-interaction.html +0 -396
- package/skills/doc-gist/references/examples/09-slide-deck.html +0 -592
- package/skills/doc-gist/references/examples/10-svg-illustrations.html +0 -492
- package/skills/doc-gist/references/examples/11-status-report.html +0 -528
- package/skills/doc-gist/references/examples/12-incident-report.html +0 -596
- package/skills/doc-gist/references/examples/13-flowchart-diagram.html +0 -395
- package/skills/doc-gist/references/examples/14-research-feature-explainer.html +0 -381
- package/skills/doc-gist/references/examples/15-research-concept-explainer.html +0 -368
- package/skills/doc-gist/references/examples/16-implementation-plan.html +0 -702
- package/skills/doc-gist/references/examples/17-pr-writeup.html +0 -595
- package/skills/doc-gist/references/examples/18-editor-triage-board.html +0 -573
- package/skills/doc-gist/references/examples/19-editor-feature-flags.html +0 -663
- package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +0 -722
- package/skills/doc-gist/references/examples/21-decision-signoff.html +0 -546
- package/skills/doc-gist/references/examples/CLAUDE.md +0 -25
- package/skills/doc-gist/references/examples/README.md +0 -5
- package/skills/doc-gist/scripts/CLAUDE.md +0 -27
- package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +0 -10
- package/skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py +0 -16
- package/skills/doc-gist/scripts/gist_upload.py +0 -177
- package/skills/doc-gist/scripts/test_gist_upload.py +0 -51
- /package/skills/{doc-gist/scripts/doc_gist_scripts_constants → log-audit/scripts/log_audit_constants}/__init__.py +0 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Tests for write_handoff: the durable per-run handoff writer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import ModuleType
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
14
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
15
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_write_handoff_module() -> ModuleType:
|
|
19
|
+
module_path = _SCRIPTS_DIR / "write_handoff.py"
|
|
20
|
+
spec = importlib.util.spec_from_file_location("write_handoff", module_path)
|
|
21
|
+
assert spec is not None
|
|
22
|
+
assert spec.loader is not None
|
|
23
|
+
module = importlib.util.module_from_spec(spec)
|
|
24
|
+
spec.loader.exec_module(module)
|
|
25
|
+
return module
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
write_handoff = _load_write_handoff_module()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_FROZEN_TIMESTAMP = "2026-07-03T12:00:00+00:00"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _point_base_dir_at(monkeypatch: pytest.MonkeyPatch, base: Path) -> None:
|
|
35
|
+
monkeypatch.setattr(write_handoff, "_handoff_base_dir", lambda: base)
|
|
36
|
+
monkeypatch.setattr(write_handoff, "_now_iso", lambda: _FROZEN_TIMESTAMP)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_resolve_handoff_dir_joins_run_name_under_base(
|
|
40
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
41
|
+
) -> None:
|
|
42
|
+
"""resolve_handoff_dir places the run's directory under the durable base."""
|
|
43
|
+
_point_base_dir_at(monkeypatch, tmp_path)
|
|
44
|
+
resolved = write_handoff.resolve_handoff_dir("bugteam-pr-4")
|
|
45
|
+
assert resolved == tmp_path / "bugteam-pr-4"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_handoff_base_dir_lives_under_home_runtime_pr_loop(
|
|
49
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
50
|
+
) -> None:
|
|
51
|
+
"""The durable base sits under the user home, not the OS temp directory."""
|
|
52
|
+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
53
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
54
|
+
base = write_handoff._handoff_base_dir()
|
|
55
|
+
assert base == tmp_path / ".claude" / "runtime" / "pr-loop"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_write_handoff_records_resume_command_and_phase(
|
|
59
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
60
|
+
) -> None:
|
|
61
|
+
"""write_handoff writes handoff.json and HANDOFF.md carrying the resume
|
|
62
|
+
command, phase, run id, completed steps, and a timestamp."""
|
|
63
|
+
_point_base_dir_at(monkeypatch, tmp_path)
|
|
64
|
+
handoff_dir = write_handoff.write_handoff(
|
|
65
|
+
pr_number=4,
|
|
66
|
+
head_ref="feat/pr-loop-durable-handoff",
|
|
67
|
+
phase="tick",
|
|
68
|
+
resume_command="claude --resume /pr-converge",
|
|
69
|
+
run_id="wf_abc123",
|
|
70
|
+
all_completed_steps=["preflight", "audit"],
|
|
71
|
+
note="Copilot wait-gate still open.",
|
|
72
|
+
)
|
|
73
|
+
assert handoff_dir == tmp_path / "bugteam-pr-4"
|
|
74
|
+
|
|
75
|
+
payload = json.loads((handoff_dir / "handoff.json").read_text(encoding="utf-8"))
|
|
76
|
+
assert payload["resume_command"] == "claude --resume /pr-converge"
|
|
77
|
+
assert payload["phase"] == "tick"
|
|
78
|
+
assert payload["run_id"] == "wf_abc123"
|
|
79
|
+
assert payload["completed_steps"] == ["preflight", "audit"]
|
|
80
|
+
assert payload["timestamp"] == _FROZEN_TIMESTAMP
|
|
81
|
+
|
|
82
|
+
markdown = (handoff_dir / "HANDOFF.md").read_text(encoding="utf-8")
|
|
83
|
+
assert "claude --resume /pr-converge" in markdown
|
|
84
|
+
assert "preflight" in markdown
|
|
85
|
+
assert "Copilot wait-gate still open." in markdown
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_write_handoff_copies_state_file_when_given(
|
|
89
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
90
|
+
) -> None:
|
|
91
|
+
"""A supplied --state-file is copied to state-copy.json and the copy path is
|
|
92
|
+
recorded in handoff.json."""
|
|
93
|
+
_point_base_dir_at(monkeypatch, tmp_path)
|
|
94
|
+
source_state = tmp_path / "loop-state.json"
|
|
95
|
+
source_state.write_text('{"loop_count": 3}', encoding="utf-8")
|
|
96
|
+
|
|
97
|
+
handoff_dir = write_handoff.write_handoff(
|
|
98
|
+
pr_number=4,
|
|
99
|
+
head_ref="feat/branch",
|
|
100
|
+
phase="teardown",
|
|
101
|
+
resume_command="claude --resume /autoconverge",
|
|
102
|
+
state_file=source_state,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
state_copy = handoff_dir / "state-copy.json"
|
|
106
|
+
assert json.loads(state_copy.read_text(encoding="utf-8")) == {"loop_count": 3}
|
|
107
|
+
payload = json.loads((handoff_dir / "handoff.json").read_text(encoding="utf-8"))
|
|
108
|
+
assert payload["state_file"] == state_copy.as_posix()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_write_handoff_omits_state_copy_when_absent(
|
|
112
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
113
|
+
) -> None:
|
|
114
|
+
"""With no --state-file, no state-copy.json is written and state_file is null."""
|
|
115
|
+
_point_base_dir_at(monkeypatch, tmp_path)
|
|
116
|
+
handoff_dir = write_handoff.write_handoff(
|
|
117
|
+
pr_number=4,
|
|
118
|
+
head_ref="feat/branch",
|
|
119
|
+
phase="tick",
|
|
120
|
+
resume_command="claude --resume /pr-converge",
|
|
121
|
+
)
|
|
122
|
+
assert not (handoff_dir / "state-copy.json").exists()
|
|
123
|
+
payload = json.loads((handoff_dir / "handoff.json").read_text(encoding="utf-8"))
|
|
124
|
+
assert payload["state_file"] is None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_parse_arguments_reads_completed_steps_value() -> None:
|
|
128
|
+
"""The CLI captures the raw comma-separated --completed-steps value."""
|
|
129
|
+
arguments = write_handoff.parse_arguments(
|
|
130
|
+
[
|
|
131
|
+
"--pr-number",
|
|
132
|
+
"4",
|
|
133
|
+
"--head-ref",
|
|
134
|
+
"feat/branch",
|
|
135
|
+
"--phase",
|
|
136
|
+
"tick",
|
|
137
|
+
"--resume-command",
|
|
138
|
+
"claude --resume /pr-converge",
|
|
139
|
+
"--completed-steps",
|
|
140
|
+
"preflight,audit,fix",
|
|
141
|
+
]
|
|
142
|
+
)
|
|
143
|
+
assert arguments.completed_steps == "preflight,audit,fix"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_main_writes_handoff_and_prints_dir(
|
|
147
|
+
tmp_path: Path,
|
|
148
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
149
|
+
capsys: pytest.CaptureFixture[str],
|
|
150
|
+
) -> None:
|
|
151
|
+
"""main writes the handoff files and prints the durable directory path."""
|
|
152
|
+
_point_base_dir_at(monkeypatch, tmp_path)
|
|
153
|
+
exit_code = write_handoff.main(
|
|
154
|
+
[
|
|
155
|
+
"--pr-number",
|
|
156
|
+
"4",
|
|
157
|
+
"--head-ref",
|
|
158
|
+
"feat/branch",
|
|
159
|
+
"--phase",
|
|
160
|
+
"tick",
|
|
161
|
+
"--resume-command",
|
|
162
|
+
"claude --resume /pr-converge",
|
|
163
|
+
"--completed-steps",
|
|
164
|
+
"preflight,audit",
|
|
165
|
+
]
|
|
166
|
+
)
|
|
167
|
+
assert exit_code == 0
|
|
168
|
+
printed = capsys.readouterr().out.strip()
|
|
169
|
+
assert "\\" not in printed
|
|
170
|
+
assert printed.endswith("bugteam-pr-4")
|
|
171
|
+
payload = json.loads(
|
|
172
|
+
(tmp_path / "bugteam-pr-4" / "handoff.json").read_text(encoding="utf-8")
|
|
173
|
+
)
|
|
174
|
+
assert payload["completed_steps"] == ["preflight", "audit"]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_main_returns_one_on_non_utf8_state_file(
|
|
178
|
+
tmp_path: Path,
|
|
179
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
180
|
+
capsys: pytest.CaptureFixture[str],
|
|
181
|
+
) -> None:
|
|
182
|
+
"""main exits 1 rather than crashing when --state-file holds non-UTF-8 bytes."""
|
|
183
|
+
_point_base_dir_at(monkeypatch, tmp_path)
|
|
184
|
+
bad_state = tmp_path / "loop-state.json"
|
|
185
|
+
bad_state.write_bytes(b"\xff\xfe not valid utf-8")
|
|
186
|
+
exit_code = write_handoff.main(
|
|
187
|
+
[
|
|
188
|
+
"--pr-number",
|
|
189
|
+
"4",
|
|
190
|
+
"--head-ref",
|
|
191
|
+
"feat/branch",
|
|
192
|
+
"--phase",
|
|
193
|
+
"teardown",
|
|
194
|
+
"--resume-command",
|
|
195
|
+
"claude --resume /autoconverge",
|
|
196
|
+
"--state-file",
|
|
197
|
+
str(bad_state),
|
|
198
|
+
]
|
|
199
|
+
)
|
|
200
|
+
assert exit_code == 1
|
|
201
|
+
assert "write_handoff failed" in capsys.readouterr().err
|
|
@@ -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:]))
|