claude-dev-env 8.69.0 → 8.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/.agents/skills/correction/SKILL.md +28 -6
  2. package/.agents/skills/correction/references/filing.md +33 -0
  3. package/.agents/skills/correction/scripts/correction_filing.py +327 -0
  4. package/.agents/skills/correction/scripts/correction_filing_constants/__init__.py +0 -0
  5. package/.agents/skills/correction/scripts/correction_filing_constants/config/__init__.py +0 -0
  6. package/.agents/skills/correction/scripts/correction_filing_constants/config/constants.py +56 -0
  7. package/.agents/skills/correction/scripts/pytest.ini +4 -0
  8. package/.agents/skills/correction/scripts/test_correction_filing.py +155 -0
  9. package/.agents/skills/pr-lifecycle/SKILL.md +3 -1
  10. package/.agents/skills/second-account-workers/SKILL.md +21 -3
  11. package/docs/rule-guides/features-start-with-an-eval.md +40 -0
  12. package/hooks/blocking/followup_pr_dedupe.py +55 -5
  13. package/hooks/blocking/image_read_size_gate.py +167 -0
  14. package/hooks/blocking/pr_lifecycle_skill_gate.py +62 -11
  15. package/hooks/blocking/pull_request_proof.py +86 -20
  16. package/hooks/blocking/test_artifact_look_gate.py +1 -0
  17. package/hooks/blocking/test_followup_pr_dedupe.py +33 -0
  18. package/hooks/blocking/test_image_read_size_gate.py +79 -0
  19. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +117 -3
  20. package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -5
  21. package/hooks/blocking/test_pull_request_proof.py +124 -1
  22. package/hooks/features/README.md +1 -0
  23. package/hooks/features/conduct-gates.md +1 -1
  24. package/hooks/features/read-gates.md +20 -0
  25. package/hooks/features/write-edit-blocking.md +3 -5
  26. package/hooks/hooks.json +11 -1
  27. package/hooks/hooks_constants/artifact_look_gate_constants.py +1 -2
  28. package/hooks/hooks_constants/followup_pr_dedupe_constants.py +4 -0
  29. package/hooks/hooks_constants/image_read_size_gate_constants.py +41 -0
  30. package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +3 -0
  31. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -5
  32. package/hooks/hooks_constants/pull_request_proof_constants.py +20 -1
  33. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +4 -11
  34. package/hooks/hooks_constants/transcript_skill_scan_constants.py +1 -0
  35. package/hooks/test_transcript_skill_scan.py +75 -1
  36. package/hooks/transcript_skill_scan.py +87 -15
  37. package/package.json +1 -1
  38. package/rules/anti-corollary-tests.md +2 -2
  39. package/rules/archiving-agent-config.md +2 -2
  40. package/rules/asd-ste100-language.md +2 -2
  41. package/rules/bdd.md +1 -1
  42. package/rules/build-needs-a-user.md +1 -1
  43. package/rules/cleanup-temp-files.md +3 -3
  44. package/rules/code-standards.md +1 -1
  45. package/rules/correction-lens.md +3 -3
  46. package/rules/destructive-commands.md +3 -3
  47. package/rules/doc-inventory-integrity.md +1 -1
  48. package/rules/docstring-prose-matches-implementation.md +1 -1
  49. package/rules/explore-thoroughly.md +1 -1
  50. package/rules/failure-blast-radius.md +1 -1
  51. package/rules/falsify-before-green.md +2 -2
  52. package/rules/features-start-with-an-eval.md +9 -0
  53. package/rules/filesystem-search.md +3 -3
  54. package/rules/flag-non-breaking-findings.md +2 -2
  55. package/rules/long-horizon-autonomy.md +2 -2
  56. package/rules/memory-stores-durable-facts.md +2 -2
  57. package/rules/no-contrast-framing.md +1 -1
  58. package/rules/no-cross-skill-duplicate-helpers.md +1 -1
  59. package/rules/orphan-css-class.md +1 -1
  60. package/rules/paired-test-coverage.md +1 -1
  61. package/rules/plain-illustrative-docstrings.md +1 -1
  62. package/rules/prefer-existing-tools.md +2 -2
  63. package/rules/proof-before-pull-request.md +1 -1
  64. package/rules/request-scope.md +1 -1
  65. package/rules/research-mode.md +2 -2
  66. package/rules/retired-hook-prose.md +1 -1
  67. package/rules/session-title.md +1 -1
  68. package/rules/shell-invocation.md +2 -2
  69. package/rules/testing.md +1 -1
  70. package/rules/verify-before-asking.md +3 -3
  71. package/rules/verify-runtime-state.md +2 -2
  72. package/rules/windows-filesystem-safe.md +1 -1
  73. package/rules/workers-done-before-complete.md +2 -2
  74. package/rules/workflow-substitution-slots.md +1 -1
  75. package/scripts/account_broker.py +10 -3
  76. package/scripts/account_broker_support.py +7 -3
  77. package/scripts/agent_image_copy.py +155 -0
  78. package/scripts/claude_account_worker.py +24 -5
  79. package/scripts/claude_account_worker_process.py +5 -0
  80. package/scripts/claude_account_worker_report.py +16 -1
  81. package/scripts/dev_env_scripts_constants/agent_image_copy_constants.py +11 -0
  82. package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +12 -0
  83. package/scripts/policy_lint/config/constants.py +1 -0
  84. package/scripts/test_account_broker.py +38 -0
  85. package/scripts/test_account_broker_support.py +24 -0
  86. package/scripts/test_agent_image_copy.py +76 -0
  87. package/scripts/test_claude_account_worker.py +47 -0
  88. package/scripts/test_claude_account_worker_process.py +20 -0
  89. package/scripts/test_claude_account_worker_report.py +15 -0
  90. package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +4 -4
  91. package/scripts/tests/test_rule_load_scopes.py +1 -0
  92. package/hooks/advisory/conftest.py +0 -10
  93. package/hooks/advisory/refactor_guard.py +0 -385
  94. package/hooks/advisory/refactor_guard_test_support.py +0 -46
  95. package/hooks/advisory/test_refactor_guard.py +0 -148
  96. package/hooks/advisory/test_refactor_guard_advisory.py +0 -242
  97. package/hooks/advisory/test_refactor_guard_eligibility.py +0 -166
  98. package/hooks/hooks_constants/refactor_guard_constants.py +0 -77
  99. package/hooks/hooks_constants/test_refactor_guard_constants.py +0 -21
@@ -1,16 +1,34 @@
1
1
  ---
2
2
  name: correction
3
- description: Turn a correction the user just made into a short handoff brief they paste into a corrections intake, or land the fix in this session with the fix playbook. Use when the user types /correction followed by what the agent got wrong, or /correction fix to land the fix here.
3
+ description: File a correction the user made to an agent as one labeled GitHub issue, list the open corrections, write a handoff brief, or land the fix in this session with the fix playbook. Use when the user types /correction, /correction fix, says "file this as a correction", or asks to show the open corrections.
4
4
  argument-hint: "[fix] <what the agent got wrong, in the user's words>"
5
5
  ---
6
6
 
7
7
  # /correction
8
8
 
9
- The user corrected an agent and wants the fix to stick. Write one brief that records the correction. By default the user pastes it into the place that turns corrections into lasting fixes. When `$ARGUMENTS` starts with the word `fix`, this session lands the fix itself.
9
+ The user corrected an agent and wants the fix to stick. A labeled issue is the shared store a corrections project watches. When `$ARGUMENTS` starts with the word `fix`, this session lands the fix itself: write the brief, then follow the fix playbook.
10
10
 
11
- `$ARGUMENTS` is the user's statement of the correction, after a leading `fix` when one is present. Fill the other fields from this session: the transcript, the files and pull requests it touched, and the messages it sent. When the session does not settle a field, write `unknown` there.
11
+ ## File it
12
12
 
13
- Print the brief as one fenced `text` block:
13
+ Use this for `/correction <text>` and "file this as a correction: <text>". Run:
14
+
15
+ ```bash
16
+ python "${CLAUDE_SKILL_DIR}/scripts/correction_filing.py" file <<'CORRECTION'
17
+ <the correction, word for word>
18
+ CORRECTION
19
+ ```
20
+
21
+ Pass the user's words exactly. The quoted heredoc keeps the shell from expanding `$`, backticks, and quotes in them. Print the one line the command prints, `Filed: <url>` or `Already filed: <url>`, and nothing else.
22
+
23
+ When it prints `No correction filing config`, write the brief below instead and tell the user the config file it names.
24
+
25
+ ## List them
26
+
27
+ For "show the open corrections", run `python "${CLAUDE_SKILL_DIR}/scripts/correction_filing.py" list` and print its lines.
28
+
29
+ ## Brief
30
+
31
+ Print one fenced `text` block. Fill the fields from this session: the transcript, the files and pull requests it touched, and the messages it sent. Write `unknown` for a field the session does not settle.
14
32
 
15
33
  ```text
16
34
  Correction: <$ARGUMENTS word for word, minus a leading fix>
@@ -28,12 +46,16 @@ Keep each field to one or two lines. Leave out the layer and the fix design; the
28
46
 
29
47
  | Mode | What follows the brief |
30
48
  |---|---|
31
- | Hand off, the default | Nothing. The brief is the whole reply. |
49
+ | File, the default | The filed line, or the brief when no config exists. |
32
50
  | `fix` | Read [`playbooks/fix.md`](playbooks/fix.md) right after the brief, and run its steps from step 2. |
33
51
 
34
52
  ## Layout
35
53
 
36
54
  | File | What it holds |
37
55
  |---|---|
38
- | `SKILL.md` | The brief and the playbook index |
56
+ | `SKILL.md` | Filing, the brief, and the playbook index |
39
57
  | `playbooks/fix.md` | The steps that land one correction as a control and a pull request in this session |
58
+ | `scripts/correction_filing.py` | Files one correction as a labeled issue, deduplicated, or lists the open ones |
59
+ | `scripts/correction_filing_constants/config/constants.py` | Config keys, issue shape, and redaction patterns |
60
+ | `scripts/test_correction_filing.py` | Behavior tests with a stand-in `gh` |
61
+ | `references/filing.md` | Config file, issue shape, dedupe, and commands |
@@ -0,0 +1,33 @@
1
+ # Correction filing
2
+
3
+ `scripts/correction_filing.py` is the one filing path. The skill calls it.
4
+
5
+ ## Config
6
+
7
+ The script reads `~/.claude/correction-capture.json`, or the file `CLAUDE_CORRECTION_CAPTURE_PATH` names:
8
+
9
+ ```json
10
+ {"repository": "owner/name", "label": "correction"}
11
+ ```
12
+
13
+ Keep this file private. Without it the script files nothing and exits 1.
14
+
15
+ ## Issue shape
16
+
17
+ - Title: `Correction: ` and the first 72 characters of the text.
18
+ - Body: the source (`typed`, `flag`, `reaction`, or `hook`), the text as a quote, and a hidden dedupe marker.
19
+ - The script masks GitHub, OpenAI, Slack, and AWS tokens and email addresses before filing.
20
+ - The script applies the label itself.
21
+
22
+ ## Dedupe
23
+
24
+ The key is a hash of `--dedupe-key` when given, else of the whitespace-collapsed text. A caller with a message id passes it there. Before filing, the script checks `correction-capture-filed.json` beside the config file for this repository and key, then every labeled issue, open and closed, and prints `Already filed: <url>` on a key match. The local file covers the minute after filing, when the labeled-issue listing can miss a new issue.
25
+
26
+ ## Commands
27
+
28
+ Without `--text`, `file` reads the correction from standard input.
29
+
30
+ ```bash
31
+ python correction_filing.py file [--text "<text>"] [--source flag] [--dedupe-key <id>]
32
+ python correction_filing.py list
33
+ ```
@@ -0,0 +1,327 @@
1
+ """File a user's correction as one labeled GitHub issue, or list the open ones.
2
+
3
+ The repository and label come from a private config file, so nothing
4
+ identifying lives in this package::
5
+
6
+ ~/.claude/correction-capture.json
7
+ {"repository": "owner/name", "label": "correction"}
8
+
9
+ python correction_filing.py file --text "use shorter replies"
10
+ Filed: https://github.com/owner/name/issues/12
11
+ python correction_filing.py file --text "use shorter replies"
12
+ Already filed: https://github.com/owner/name/issues/12
13
+ python correction_filing.py file <<'CORRECTION'
14
+ use shorter replies
15
+ CORRECTION
16
+ Already filed: https://github.com/owner/name/issues/12
17
+ python correction_filing.py list
18
+ #12 Correction: use shorter replies https://github.com/...
19
+
20
+ The issue quotes only the correction text, with tokens and email addresses
21
+ masked. A hidden dedupe marker in the body, and a local ledger of filed keys
22
+ beside the config file, keep a replayed correction from filing twice. CLAUDE_CORRECTION_CAPTURE_PATH names another config file.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import hashlib
29
+ import json
30
+ import os
31
+ import re
32
+ import subprocess
33
+ import sys
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+ from urllib.parse import quote
37
+
38
+ from correction_filing_constants.config.constants import (
39
+ ALL_REDACTION_PATTERNS,
40
+ ALL_SOURCES,
41
+ BODY_FIELD_TEMPLATE,
42
+ CLAUDE_DIRECTORY_NAME,
43
+ CONFIG_ENCODING,
44
+ CONFIG_FILE_NAME,
45
+ CONFIG_LABEL_KEY,
46
+ CONFIG_PATH_ENV_VAR,
47
+ CONFIG_REPOSITORY_KEY,
48
+ CREATED_URL_JQ_FILTER,
49
+ DEDUPE_KEY_LENGTH,
50
+ DEDUPE_MARKER_PATTERN,
51
+ DEDUPE_MARKER_TEMPLATE,
52
+ DEFAULT_SOURCE,
53
+ DUPLICATE_MESSAGE_TEMPLATE,
54
+ EMPTY_TEXT_MESSAGE,
55
+ FAILURE_EXIT_CODE,
56
+ FILED_MESSAGE_TEMPLATE,
57
+ GH_EXECUTABLE,
58
+ LABEL_FIELD_TEMPLATE,
59
+ LINE_SEPARATOR,
60
+ ISSUE_BODY_TEMPLATE,
61
+ ISSUES_CREATE_PATH_TEMPLATE,
62
+ ISSUES_LIST_JQ_FILTER,
63
+ ISSUES_LIST_PATH_TEMPLATE,
64
+ LEDGER_FILE_NAME,
65
+ LEDGER_KEY_TEMPLATE,
66
+ ISSUE_TITLE_PREFIX,
67
+ ISSUE_TITLE_TEXT_LENGTH,
68
+ LIST_LINE_TEMPLATE,
69
+ MISSING_CONFIG_MESSAGE_TEMPLATE,
70
+ NO_OPEN_CORRECTIONS_MESSAGE,
71
+ REDACTION_PLACEHOLDER,
72
+ SUCCESS_EXIT_CODE,
73
+ TITLE_FIELD_TEMPLATE,
74
+ UTF8_ENCODING,
75
+ WORD_SEPARATOR,
76
+ )
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class FilingTarget:
81
+ """The repository and label a correction files into."""
82
+
83
+ repository: str
84
+ label: str
85
+
86
+
87
+ class FilingConfigMissing(ValueError):
88
+ """Stop the run when the private config file is absent or incomplete."""
89
+
90
+
91
+ def _config_path() -> Path:
92
+ """Return the config path from the environment, or the default under ~/.claude."""
93
+ configured_path = os.environ.get(CONFIG_PATH_ENV_VAR, "")
94
+ if configured_path:
95
+ return Path(configured_path)
96
+ return Path.home() / CLAUDE_DIRECTORY_NAME / CONFIG_FILE_NAME
97
+
98
+
99
+ def load_filing_target(from_path: Path) -> FilingTarget:
100
+ """Read the repository and label from the config file.
101
+
102
+ Args:
103
+ from_path: the private config file.
104
+
105
+ Returns:
106
+ The repository and label to file into.
107
+
108
+ Raises:
109
+ FilingConfigMissing: the file is absent, unreadable, or lacks a key.
110
+ """
111
+ missing_message = MISSING_CONFIG_MESSAGE_TEMPLATE.format(path=from_path)
112
+ try:
113
+ parsed_config = json.loads(from_path.read_text(encoding=CONFIG_ENCODING))
114
+ except (OSError, ValueError) as error:
115
+ raise FilingConfigMissing(missing_message) from error
116
+ if not isinstance(parsed_config, dict):
117
+ raise FilingConfigMissing(missing_message)
118
+ repository = parsed_config.get(CONFIG_REPOSITORY_KEY, "")
119
+ label = parsed_config.get(CONFIG_LABEL_KEY, "")
120
+ if not isinstance(repository, str) or not isinstance(label, str):
121
+ raise FilingConfigMissing(missing_message)
122
+ if not repository or not label:
123
+ raise FilingConfigMissing(missing_message)
124
+ return FilingTarget(repository=repository, label=label)
125
+
126
+
127
+ def _redact_sensitive_text(correction_text: str) -> str:
128
+ """Mask tokens, keys, and email addresses in the correction text."""
129
+ redacted_text = correction_text
130
+ for each_pattern in ALL_REDACTION_PATTERNS:
131
+ redacted_text = re.sub(each_pattern, REDACTION_PLACEHOLDER, redacted_text)
132
+ return redacted_text
133
+
134
+
135
+ def _dedupe_key_for(correction_text: str, supplied_key: str) -> str:
136
+ """Return a hex key that names this correction across replays."""
137
+ key_source = supplied_key or WORD_SEPARATOR.join(correction_text.split())
138
+ full_digest = hashlib.sha256(key_source.encode(UTF8_ENCODING)).hexdigest()
139
+ return full_digest[:DEDUPE_KEY_LENGTH]
140
+
141
+
142
+ def issue_title_for(correction_text: str) -> str:
143
+ """Return a one-line issue title cut from the correction text.
144
+
145
+ Args:
146
+ correction_text: the redacted correction.
147
+
148
+ Returns:
149
+ The prefix and the first characters of the text on one line.
150
+ """
151
+ single_line_text = WORD_SEPARATOR.join(correction_text.split())
152
+ return ISSUE_TITLE_PREFIX + single_line_text[:ISSUE_TITLE_TEXT_LENGTH]
153
+
154
+
155
+ def issue_body_for(correction_text: str, source: str, dedupe_key: str) -> str:
156
+ """Return the issue body with the quoted correction, its source, and the marker.
157
+
158
+ Args:
159
+ correction_text: the redacted correction.
160
+ source: where the correction came from.
161
+ dedupe_key: the hex key the marker carries.
162
+
163
+ Returns:
164
+ The markdown issue body.
165
+ """
166
+ quoted_text = LINE_SEPARATOR.join(
167
+ "> " + each_line for each_line in correction_text.splitlines()
168
+ )
169
+ return ISSUE_BODY_TEMPLATE.format(
170
+ source=source,
171
+ quoted_text=quoted_text,
172
+ dedupe_marker=DEDUPE_MARKER_TEMPLATE.format(key=dedupe_key),
173
+ )
174
+
175
+
176
+ def _run_gh(all_arguments: list[str]) -> str:
177
+ completed_process = subprocess.run(
178
+ [GH_EXECUTABLE, *all_arguments],
179
+ capture_output=True,
180
+ text=True,
181
+ encoding=UTF8_ENCODING,
182
+ check=True,
183
+ )
184
+ return completed_process.stdout
185
+
186
+
187
+ def _list_labeled_issues(target: FilingTarget, state: str) -> list[dict[str, object]]:
188
+ """Return the issues carrying the label, with number, title, url, and body."""
189
+ issues_path = ISSUES_LIST_PATH_TEMPLATE.format(
190
+ repository=target.repository, label=quote(target.label), state=state
191
+ )
192
+ issue_lines = _run_gh(["api", "--paginate", issues_path, "--jq", ISSUES_LIST_JQ_FILTER])
193
+ return [json.loads(each_line) for each_line in issue_lines.splitlines() if each_line.strip()]
194
+
195
+
196
+ def _ledger_path() -> Path:
197
+ return _config_path().with_name(LEDGER_FILE_NAME)
198
+
199
+
200
+ def _read_ledger() -> dict[str, str]:
201
+ try:
202
+ parsed_ledger = json.loads(_ledger_path().read_text(encoding=CONFIG_ENCODING))
203
+ except (OSError, ValueError):
204
+ return {}
205
+ return parsed_ledger if isinstance(parsed_ledger, dict) else {}
206
+
207
+
208
+ def _ledger_key_for(target: FilingTarget, dedupe_key: str) -> str:
209
+ return LEDGER_KEY_TEMPLATE.format(repository=target.repository, dedupe_key=dedupe_key)
210
+
211
+
212
+ def _record_in_ledger(target: FilingTarget, dedupe_key: str, issue_url: str) -> None:
213
+ url_by_ledger_key = _read_ledger()
214
+ url_by_ledger_key[_ledger_key_for(target, dedupe_key)] = issue_url
215
+ _ledger_path().write_text(json.dumps(url_by_ledger_key), encoding=CONFIG_ENCODING)
216
+
217
+
218
+ def _find_filed_issue_url(target: FilingTarget, dedupe_key: str) -> str:
219
+ """Return the url of an issue already carrying this dedupe key, or empty."""
220
+ ledger_url = _read_ledger().get(_ledger_key_for(target, dedupe_key), "")
221
+ if ledger_url:
222
+ return ledger_url
223
+ for each_issue in _list_labeled_issues(target, "all"):
224
+ each_match = re.search(DEDUPE_MARKER_PATTERN, str(each_issue.get("body", "")))
225
+ if each_match and each_match.group(1) == dedupe_key:
226
+ return str(each_issue.get("url", ""))
227
+ return ""
228
+
229
+
230
+ def _file_correction(
231
+ target: FilingTarget, correction_text: str, source: str, supplied_key: str
232
+ ) -> str:
233
+ """File the correction once and return the message to print."""
234
+ redacted_text = _redact_sensitive_text(correction_text.strip())
235
+ dedupe_key = _dedupe_key_for(redacted_text, supplied_key)
236
+ existing_url = _find_filed_issue_url(target, dedupe_key)
237
+ if existing_url:
238
+ return DUPLICATE_MESSAGE_TEMPLATE.format(url=existing_url)
239
+ created_url = _run_gh(
240
+ [
241
+ "api",
242
+ "--method",
243
+ "POST",
244
+ ISSUES_CREATE_PATH_TEMPLATE.format(repository=target.repository),
245
+ "-f",
246
+ TITLE_FIELD_TEMPLATE.format(value=issue_title_for(redacted_text)),
247
+ "-f",
248
+ BODY_FIELD_TEMPLATE.format(value=issue_body_for(redacted_text, source, dedupe_key)),
249
+ "-f",
250
+ LABEL_FIELD_TEMPLATE.format(value=target.label),
251
+ "--jq",
252
+ CREATED_URL_JQ_FILTER,
253
+ ]
254
+ ).strip()
255
+ _record_in_ledger(target, dedupe_key, created_url)
256
+ return FILED_MESSAGE_TEMPLATE.format(url=created_url)
257
+
258
+
259
+ def _open_corrections_listing(target: FilingTarget) -> str:
260
+ """Return one line per open labeled issue, or a no-open line."""
261
+ all_open_issues = _list_labeled_issues(target, "open")
262
+ if not all_open_issues:
263
+ return NO_OPEN_CORRECTIONS_MESSAGE
264
+ return "".join(
265
+ LIST_LINE_TEMPLATE.format(
266
+ number=each_issue["number"],
267
+ title=each_issue["title"],
268
+ url=each_issue["url"],
269
+ )
270
+ for each_issue in all_open_issues
271
+ )
272
+
273
+
274
+ def _build_parser() -> argparse.ArgumentParser:
275
+ parser = argparse.ArgumentParser(description="File or list user corrections.")
276
+ all_subcommands = parser.add_subparsers(dest="command", required=True)
277
+ file_parser = all_subcommands.add_parser("file", help="File one correction.")
278
+ file_parser.add_argument("--text", default=None)
279
+ file_parser.add_argument("--source", choices=ALL_SOURCES, default=DEFAULT_SOURCE)
280
+ file_parser.add_argument("--dedupe-key", default="")
281
+ all_subcommands.add_parser("list", help="List the open corrections.")
282
+ return parser
283
+
284
+
285
+ def _run_command(target: FilingTarget, parsed_arguments: argparse.Namespace) -> int:
286
+ if parsed_arguments.command == "list":
287
+ sys.stdout.write(_open_corrections_listing(target))
288
+ return SUCCESS_EXIT_CODE
289
+ correction_text = (
290
+ sys.stdin.buffer.read().decode(UTF8_ENCODING)
291
+ if parsed_arguments.text is None
292
+ else parsed_arguments.text
293
+ )
294
+ if not correction_text.strip():
295
+ sys.stderr.write(EMPTY_TEXT_MESSAGE)
296
+ return FAILURE_EXIT_CODE
297
+ sys.stdout.write(
298
+ _file_correction(
299
+ target, correction_text, parsed_arguments.source, parsed_arguments.dedupe_key
300
+ )
301
+ )
302
+ return SUCCESS_EXIT_CODE
303
+
304
+
305
+ def main(all_arguments: list[str]) -> int:
306
+ """Run the file or list command.
307
+
308
+ Args:
309
+ all_arguments: the command-line arguments after the script name.
310
+
311
+ Returns:
312
+ The process exit code.
313
+ """
314
+ parsed_arguments = _build_parser().parse_args(all_arguments)
315
+ try:
316
+ target = load_filing_target(_config_path())
317
+ except FilingConfigMissing as error:
318
+ sys.stderr.write(str(error))
319
+ return FAILURE_EXIT_CODE
320
+ try:
321
+ return _run_command(target, parsed_arguments)
322
+ except subprocess.CalledProcessError as error:
323
+ sys.stderr.write(error.stderr or str(error))
324
+ return FAILURE_EXIT_CODE
325
+
326
+ if __name__ == "__main__":
327
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,56 @@
1
+ """Configuration constants for the correction filing command."""
2
+
3
+ ALL_REDACTION_PATTERNS = (
4
+ r"gh[pousr]_[A-Za-z0-9]{20,}",
5
+ r"github_pat_[A-Za-z0-9_]{20,}",
6
+ r"sk-[A-Za-z0-9_-]{20,}",
7
+ r"xox[abprs]-[A-Za-z0-9-]{10,}",
8
+ r"AKIA[0-9A-Z]{16}",
9
+ r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}",
10
+ )
11
+ BODY_FIELD_TEMPLATE = "body={value}"
12
+ ALL_SOURCES = ("typed", "flag", "reaction", "hook")
13
+ CLAUDE_DIRECTORY_NAME = ".claude"
14
+ CONFIG_ENCODING = "utf-8"
15
+ CONFIG_FILE_NAME = "correction-capture.json"
16
+ CONFIG_LABEL_KEY = "label"
17
+ CONFIG_PATH_ENV_VAR = "CLAUDE_CORRECTION_CAPTURE_PATH"
18
+ CONFIG_REPOSITORY_KEY = "repository"
19
+ CREATED_URL_JQ_FILTER = ".html_url"
20
+ DEDUPE_MARKER_PATTERN = r"<!-- correction-dedupe: ([0-9a-f]{16,64}) -->"
21
+ DEDUPE_MARKER_TEMPLATE = "<!-- correction-dedupe: {key} -->"
22
+ DEDUPE_KEY_LENGTH = 16
23
+ DEFAULT_SOURCE = "typed"
24
+ DUPLICATE_MESSAGE_TEMPLATE = "Already filed: {url}\n"
25
+ EMPTY_TEXT_MESSAGE = "The correction text is empty. Nothing was filed.\n"
26
+ FAILURE_EXIT_CODE = 1
27
+ FILED_MESSAGE_TEMPLATE = "Filed: {url}\n"
28
+ GH_EXECUTABLE = "gh"
29
+ ISSUE_BODY_TEMPLATE = (
30
+ "A correction the user made to an agent.\n\n"
31
+ "Source: {source}\n\n"
32
+ "{quoted_text}\n\n"
33
+ "{dedupe_marker}\n"
34
+ )
35
+ ISSUES_CREATE_PATH_TEMPLATE = "repos/{repository}/issues"
36
+ ISSUES_LIST_JQ_FILTER = (
37
+ ".[] | select(.pull_request | not) | {number, title, url: .html_url, body}"
38
+ )
39
+ ISSUES_LIST_PATH_TEMPLATE = "repos/{repository}/issues?labels={label}&state={state}&per_page=100"
40
+ ISSUE_TITLE_PREFIX = "Correction: "
41
+ ISSUE_TITLE_TEXT_LENGTH = 72
42
+ LABEL_FIELD_TEMPLATE = "labels[]={value}"
43
+ LEDGER_FILE_NAME = "correction-capture-filed.json"
44
+ LEDGER_KEY_TEMPLATE = "{repository} {dedupe_key}"
45
+ LINE_SEPARATOR = "\n"
46
+ LIST_LINE_TEMPLATE = "#{number} {title} {url}\n"
47
+ MISSING_CONFIG_MESSAGE_TEMPLATE = (
48
+ "No correction filing config at {path}. Write it as "
49
+ '{{"repository": "<owner>/<name>", "label": "<label>"}}. Nothing was filed.\n'
50
+ )
51
+ NO_OPEN_CORRECTIONS_MESSAGE = "No open corrections.\n"
52
+ REDACTION_PLACEHOLDER = "[redacted]"
53
+ SUCCESS_EXIT_CODE = 0
54
+ TITLE_FIELD_TEMPLATE = "title={value}"
55
+ UTF8_ENCODING = "utf-8"
56
+ WORD_SEPARATOR = " "
@@ -0,0 +1,4 @@
1
+ [pytest]
2
+ python_files = test_*.py
3
+ python_classes = Test*
4
+ python_functions = test_* should_*
@@ -0,0 +1,155 @@
1
+ """Behavior tests for correction_filing with a stand-in gh on PATH."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import io
7
+ import json
8
+ import os
9
+ import stat
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
16
+ if str(SCRIPTS_DIRECTORY) not in sys.path:
17
+ sys.path.insert(0, str(SCRIPTS_DIRECTORY))
18
+
19
+ correction_filing = importlib.import_module("correction_filing")
20
+
21
+ STAND_IN_GH = r'''#!/usr/bin/env python3
22
+ import json, os, sys
23
+ from urllib.parse import parse_qs, urlsplit
24
+ store = os.environ["STAND_IN_GH_STORE"]
25
+ issues = json.load(open(store)) if os.path.exists(store) else []
26
+ args = sys.argv[1:]
27
+ if "POST" in args:
28
+ fields = dict(args[i + 1].split("=", 1) for i, a in enumerate(args) if a == "-f")
29
+ number = len(issues) + 1
30
+ url = "https://example.test/issues/%d" % number
31
+ issues.append({"number": number, "title": fields["title"], "url": url,
32
+ "body": fields["body"], "label": fields["labels[]"], "state": "open"})
33
+ json.dump(issues, open(store, "w"))
34
+ print(url)
35
+ else:
36
+ query = parse_qs(urlsplit(args[2]).query)
37
+ state = query["state"][0]
38
+ for i in issues:
39
+ if i["label"] == query["labels"][0] and (state == "all" or i["state"] == state):
40
+ print(json.dumps({k: i[k] for k in ("number", "title", "url", "body")}))
41
+ '''
42
+
43
+
44
+ @pytest.fixture
45
+ def filing_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
46
+ bin_directory = tmp_path / "bin"
47
+ bin_directory.mkdir()
48
+ gh_path = bin_directory / "gh"
49
+ gh_path.write_text(STAND_IN_GH, encoding="utf-8")
50
+ gh_path.chmod(gh_path.stat().st_mode | stat.S_IEXEC)
51
+ store_path = tmp_path / "issues.json"
52
+ config_file = tmp_path / "correction-capture.json"
53
+ config_file.write_text(json.dumps({"repository": "owner/name", "label": "correction"}))
54
+ monkeypatch.setenv("PATH", str(bin_directory) + os.pathsep + os.environ["PATH"])
55
+ monkeypatch.setenv("STAND_IN_GH_STORE", str(store_path))
56
+ monkeypatch.setenv("CLAUDE_CORRECTION_CAPTURE_PATH", str(config_file))
57
+ return store_path
58
+
59
+
60
+ def _stored_issues(store_path: Path) -> list[dict[str, object]]:
61
+ return json.loads(store_path.read_text()) if store_path.exists() else []
62
+
63
+
64
+ def test_should_file_one_labeled_issue_quoting_the_text(filing_environment: Path, capsys: pytest.CaptureFixture[str]) -> None:
65
+ assert correction_filing.main(["file", "--text", "use shorter replies"]) == 0
66
+ all_issues = _stored_issues(filing_environment)
67
+ assert len(all_issues) == 1
68
+ assert all_issues[0]["label"] == "correction"
69
+ assert "> use shorter replies" in all_issues[0]["body"]
70
+ assert "Filed: https://example.test/issues/1" in capsys.readouterr().out
71
+
72
+
73
+ def test_should_file_a_replayed_correction_once(filing_environment: Path, capsys: pytest.CaptureFixture[str]) -> None:
74
+ correction_filing.main(["file", "--text", "stop asking me"])
75
+ assert correction_filing.main(["file", "--text", "stop asking me"]) == 0
76
+ assert len(_stored_issues(filing_environment)) == 1
77
+ assert "Already filed: https://example.test/issues/1" in capsys.readouterr().out
78
+
79
+
80
+ def test_should_dedupe_on_a_supplied_key(filing_environment: Path) -> None:
81
+ correction_filing.main(["file", "--text", "first", "--dedupe-key", "msg-7"])
82
+ correction_filing.main(["file", "--text", "second", "--dedupe-key", "msg-7"])
83
+ correction_filing.main(["file", "--text", "second", "--dedupe-key", "msg-8"])
84
+ assert len(_stored_issues(filing_environment)) == 2
85
+
86
+
87
+ def test_should_mask_tokens_and_emails(filing_environment: Path) -> None:
88
+ correction_filing.main(
89
+ ["file", "--text", "you leaked ghp_" + "a" * 36 + " and me@example.com"]
90
+ )
91
+ body = _stored_issues(filing_environment)[0]["body"]
92
+ assert "ghp_" not in body and "me@example.com" not in body
93
+ assert body.count("[redacted]") == 2
94
+
95
+
96
+ def test_should_file_nothing_without_config(filing_environment: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
97
+ monkeypatch.setenv("CLAUDE_CORRECTION_CAPTURE_PATH", str(tmp_path / "absent.json"))
98
+ assert correction_filing.main(["file", "--text", "anything"]) == 1
99
+ assert _stored_issues(filing_environment) == []
100
+ assert "No correction filing config" in capsys.readouterr().err
101
+
102
+
103
+ def test_should_file_nothing_for_empty_text(filing_environment: Path) -> None:
104
+ assert correction_filing.main(["file", "--text", " "]) == 1
105
+ assert _stored_issues(filing_environment) == []
106
+
107
+
108
+ def test_should_list_open_corrections(filing_environment: Path, capsys: pytest.CaptureFixture[str]) -> None:
109
+ correction_filing.main(["list"])
110
+ assert "No open corrections." in capsys.readouterr().out
111
+ correction_filing.main(["file", "--text", "use plain words"])
112
+ capsys.readouterr()
113
+ correction_filing.main(["list"])
114
+ assert "#1 Correction: use plain words https://example.test/issues/1" in capsys.readouterr().out
115
+
116
+
117
+ def test_should_build_title_and_body_from_text() -> None:
118
+ assert correction_filing.issue_title_for("a\nb") == "Correction: a b"
119
+ body = correction_filing.issue_body_for("x\ny", "flag", "abcdef0123456789")
120
+ assert "> x\n> y" in body and "Source: flag" in body
121
+ assert "<!-- correction-dedupe: abcdef0123456789 -->" in body
122
+
123
+
124
+ def test_should_read_target_and_reject_incomplete_config(tmp_path: Path) -> None:
125
+ good = tmp_path / "good.json"
126
+ good.write_text(json.dumps({"repository": "o/n", "label": "l"}))
127
+ assert correction_filing.load_filing_target(good) == correction_filing.FilingTarget("o/n", "l")
128
+ bad = tmp_path / "bad.json"
129
+ bad.write_text(json.dumps({"repository": "o/n"}))
130
+ with pytest.raises(correction_filing.FilingConfigMissing):
131
+ correction_filing.load_filing_target(bad)
132
+
133
+
134
+ def test_should_dedupe_from_the_ledger_when_the_listing_lags(filing_environment: Path, capsys: pytest.CaptureFixture[str]) -> None:
135
+ correction_filing.main(["file", "--text", "keep replies short"])
136
+ filing_environment.write_text("[]")
137
+ capsys.readouterr()
138
+ correction_filing.main(["file", "--text", "keep replies short"])
139
+ assert "Already filed: https://example.test/issues/1" in capsys.readouterr().out
140
+
141
+
142
+ def test_should_read_the_text_from_stdin_without_expanding_it(filing_environment: Path, monkeypatch: pytest.MonkeyPatch) -> None:
143
+ piped_bytes = 'say "$(whoami)" and `id` in caf\u00e9\n'.encode("utf-8")
144
+ monkeypatch.setattr(sys, "stdin", io.TextIOWrapper(io.BytesIO(piped_bytes), encoding="cp1252"))
145
+ assert correction_filing.main(["file"]) == 0
146
+ assert '> say "$(whoami)" and `id` in caf\u00e9' in _stored_issues(filing_environment)[0]["body"]
147
+
148
+
149
+ def test_should_file_again_after_the_config_names_another_repository(filing_environment: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
150
+ correction_filing.main(["file", "--text", "keep replies short"])
151
+ filing_environment.write_text("[]")
152
+ (tmp_path / "correction-capture.json").write_text(json.dumps({"repository": "owner/other", "label": "correction"}))
153
+ capsys.readouterr()
154
+ correction_filing.main(["file", "--text", "keep replies short"])
155
+ assert "Filed: https://example.test/issues/1" in capsys.readouterr().out
@@ -25,7 +25,9 @@ User-level rule: applies to **every** git repo that uses GitHub with `gh`. Small
25
25
  **Prove the change where it runs before you open the pull request.** The body carries a
26
26
  "Proof in practice" section with each command you ran and its quoted output. A hook denies
27
27
  a new pull request without one. Read `~/.claude/docs/rule-guides/proof-before-pull-request.md`
28
- before you plan the proof run.
28
+ before you plan the proof run. A `feat` pull request also needs an "Eval" section and a
29
+ session that invoked `/claude-api build-eval`; read
30
+ `~/.claude/docs/rule-guides/features-start-with-an-eval.md` before you build the feature.
29
31
 
30
32
  **Open every pull request ready for review.** Pass `--draft` only when the owner asks
31
33
  for a draft.