claude-dev-env 8.74.1 → 8.75.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/.agents/skills/correction/SKILL.md +19 -16
- package/.agents/skills/correction/references/filing.md +1 -1
- package/.agents/skills/correction/scripts/correction_task_card.py +194 -0
- package/.agents/skills/correction/scripts/correction_task_card_constants/__init__.py +0 -0
- package/.agents/skills/correction/scripts/correction_task_card_constants/config/__init__.py +0 -0
- package/.agents/skills/correction/scripts/correction_task_card_constants/config/constants.py +38 -0
- package/.agents/skills/correction/scripts/test_correction_task_card.py +142 -0
- package/bin/cde.mjs +1 -1
- package/bin/cde.test.mjs +1 -1
- package/bin/install.mjs +1 -0
- package/bin/install.test.mjs +1 -0
- package/bin/merge_profile_settings.mjs +2 -3
- package/bin/merge_profile_settings.test.mjs +5 -5
- package/commands/fix-followups.md +5 -2
- package/docs/rule-guides/flag-non-breaking-findings.md +3 -5
- package/docs/rule-guides/paired-test-coverage.md +1 -1
- package/docs/rule-guides/session-title.md +3 -3
- package/docs/templates/html-plan/README.md +1 -1
- package/hooks/features/conduct-gates.md +0 -3
- package/hooks/features/session-context.md +1 -1
- package/hooks/followup_ledger.py +106 -108
- package/hooks/followup_ledger_maintenance.py +127 -0
- package/hooks/hooks.json +0 -11
- package/hooks/hooks_constants/artifact_template_pointer_constants.py +5 -1
- package/hooks/hooks_constants/followup_ledger_constants.py +14 -4
- package/hooks/hooks_constants/session_title_constants.py +1 -29
- package/hooks/session/artifact_template_pointer.py +5 -4
- package/hooks/session/test_artifact_template_pointer.py +13 -0
- package/hooks/test_followup_ledger.py +78 -59
- package/hooks/test_followup_ledger_maintenance.py +107 -0
- package/package.json +1 -1
- package/rules/flag-non-breaking-findings.md +1 -1
- package/scripts/dev_env_scripts_constants/followup_constants.py +4 -1
- package/scripts/followup_cli.py +11 -12
- package/scripts/policy_lint/adapter_configuration.py +0 -1
- package/scripts/policy_lint/config/constants.py +0 -1
- package/scripts/repository_checks/config/constants.py +0 -6
- package/scripts/repository_checks/runner.py +0 -7
- package/scripts/test_followup_cli.py +71 -7
- package/scripts/tests/test_adapter_configuration.py +2 -5
- package/scripts/tests/test_cde_lint.py +3 -4
- package/scripts/tests/test_repository_policy.py +1 -3
- package/hooks/blocking/session_title_stop_gate.py +0 -217
- package/hooks/blocking/test_session_title_stop_gate.py +0 -295
- package/scripts/repository_checks/followup_ledger_duplicates.py +0 -55
- package/scripts/tests/test_followup_ledger_duplicates.py +0 -58
|
@@ -1,30 +1,30 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: correction
|
|
3
|
-
description:
|
|
3
|
+
description: Turn a correction the user made to an agent into a Suggested task card that starts a fix session in one click, list the corrections filed as issues before the card, 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.
|
|
9
|
+
The user corrected an agent and wants the fix to stick. By default the correction becomes a Suggested task card, and the user starts the fix session from it in one click. No issue is filed. When `$ARGUMENTS` starts with the word `fix`, this session lands the fix itself: write the brief, then follow the fix playbook.
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## Hand it off
|
|
12
12
|
|
|
13
|
-
Use this for `/correction <text>` and "file this as a correction: <text>".
|
|
13
|
+
Use this for `/correction <text>` and "file this as a correction: <text>".
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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.
|
|
15
|
+
1. Write the brief below, then build the card from it with [`scripts/correction_task_card.py`](scripts/correction_task_card.py). Pipe one JSON object in a quoted heredoc: `title` starts with a verb and stays under 60 characters, `tldr` is one plain sentence, `brief` is the brief block text, and `files` lists the repository-relative paths the brief names.
|
|
16
|
+
```bash
|
|
17
|
+
python "${CLAUDE_SKILL_DIR}/scripts/correction_task_card.py" <<'CARD'
|
|
18
|
+
{"title": "<verb first>", "tldr": "<one sentence>", "brief": "<brief block, newlines as \n>", "files": ["<repo-relative path>"]}
|
|
19
|
+
CARD
|
|
20
|
+
```
|
|
22
21
|
|
|
23
|
-
When
|
|
22
|
+
2. When the session has `mcp__ccd_session__spawn_task`, call it with the `title`, `tldr`, and `prompt` the script prints. Reply with the card title.
|
|
23
|
+
3. Without that tool, run the script again with `--prompt-only` and print its output in one fenced `text` block as the handoff brief.
|
|
24
24
|
|
|
25
25
|
## List them
|
|
26
26
|
|
|
27
|
-
For "show the open corrections", run `python "${CLAUDE_SKILL_DIR}/scripts/correction_filing.py" list` and print its lines.
|
|
27
|
+
For "show the open corrections", run `python "${CLAUDE_SKILL_DIR}/scripts/correction_filing.py" list` and print its lines. It lists the issues filed before the card replaced filing, and the issues the correction flag mod still files.
|
|
28
28
|
|
|
29
29
|
## Brief
|
|
30
30
|
|
|
@@ -46,16 +46,19 @@ Keep each field to one or two lines. Leave out the layer and the fix design; the
|
|
|
46
46
|
|
|
47
47
|
| Mode | What follows the brief |
|
|
48
48
|
|---|---|
|
|
49
|
-
|
|
|
49
|
+
| Hand off, the default | The task card, or the handoff brief when `spawn_task` is absent. |
|
|
50
50
|
| `fix` | Read [`playbooks/fix.md`](playbooks/fix.md) right after the brief, and run its steps from step 2. |
|
|
51
51
|
|
|
52
52
|
## Layout
|
|
53
53
|
|
|
54
54
|
| File | What it holds |
|
|
55
55
|
|---|---|
|
|
56
|
-
| `SKILL.md` |
|
|
56
|
+
| `SKILL.md` | The handoff, the listing, the brief, and the playbook index |
|
|
57
57
|
| `playbooks/fix.md` | The steps that land one correction as a control and a pull request in this session |
|
|
58
|
-
| `scripts/
|
|
58
|
+
| `scripts/correction_task_card.py` | Builds the `spawn_task` title, tldr, and standalone prompt from the brief and the fix playbook |
|
|
59
|
+
| `scripts/correction_task_card_constants/config/constants.py` | Card field rules and the prompt template |
|
|
60
|
+
| `scripts/test_correction_task_card.py` | Behavior tests for the card against the shipped fix playbook |
|
|
61
|
+
| `scripts/correction_filing.py` | Lists the open correction issues, and files one for the correction flag mod |
|
|
59
62
|
| `scripts/correction_filing_constants/config/constants.py` | Config keys, issue shape, and redaction patterns |
|
|
60
63
|
| `scripts/test_correction_filing.py` | Behavior tests with a stand-in `gh` |
|
|
61
64
|
| `references/filing.md` | Config file, issue shape, dedupe, and commands |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Correction filing
|
|
2
2
|
|
|
3
|
-
`scripts/correction_filing.py`
|
|
3
|
+
`scripts/correction_filing.py` files a correction as a labeled issue for the correction flag mod in claude-mods-framework, which runs `correction_filing.py file --source flag`. `/correction` hands a correction off as a task card and files no issue. The skill runs only `list`, which reads the issues filed before the card and the issues the flag mod files.
|
|
4
4
|
|
|
5
5
|
## Config
|
|
6
6
|
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Build the Suggested task card that hands one correction to a fix session.
|
|
2
|
+
|
|
3
|
+
The agent pipes a JSON object on standard input, and the script prints the
|
|
4
|
+
three fields `spawn_task` takes::
|
|
5
|
+
|
|
6
|
+
python correction_task_card.py <<'CARD'
|
|
7
|
+
{"title": "Add a lint for bare except in hooks",
|
|
8
|
+
"tldr": "Agents keep writing bare except blocks in hooks.",
|
|
9
|
+
"brief": "Correction: stop writing bare except ...",
|
|
10
|
+
"files": ["packages/claude-dev-env/hooks/blocking/example.py"]}
|
|
11
|
+
CARD
|
|
12
|
+
{"title": "Add a lint ...", "tldr": "Agents keep ...", "prompt": "Land this correction ..."}
|
|
13
|
+
|
|
14
|
+
The prompt carries the brief, the files, and the fix playbook steps from step 2
|
|
15
|
+
on, so a fresh session needs nothing else. `--prompt-only` prints the prompt
|
|
16
|
+
alone, for the handoff brief a session without `spawn_task` shows instead.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from correction_task_card_constants.config.constants import (
|
|
28
|
+
ABSOLUTE_PATH_PATTERN,
|
|
29
|
+
ALL_CARD_KEYS,
|
|
30
|
+
BRIEF_KEY,
|
|
31
|
+
FAILURE_EXIT_CODE,
|
|
32
|
+
FILE_LINE_TEMPLATE,
|
|
33
|
+
FILES_KEY,
|
|
34
|
+
FIX_PLAYBOOK_DIRECTORY_NAME,
|
|
35
|
+
FIX_PLAYBOOK_FILE_NAME,
|
|
36
|
+
FIX_PLAYBOOK_FIRST_HANDED_OFF_STEP,
|
|
37
|
+
INVALID_INPUT_MESSAGE_TEMPLATE,
|
|
38
|
+
LINE_SEPARATOR,
|
|
39
|
+
MARKDOWN_LINK_PATTERN,
|
|
40
|
+
MAXIMUM_TITLE_LENGTH,
|
|
41
|
+
NO_FILES_LINE,
|
|
42
|
+
PROMPT_TEMPLATE,
|
|
43
|
+
REASON_ABSOLUTE_FILE_TEMPLATE,
|
|
44
|
+
REASON_EMPTY_FIELD_TEMPLATE,
|
|
45
|
+
REASON_FILES_NOT_A_LIST,
|
|
46
|
+
REASON_MULTI_LINE_FIELD_TEMPLATE,
|
|
47
|
+
REASON_NOT_AN_OBJECT,
|
|
48
|
+
REASON_TITLE_TOO_LONG_TEMPLATE,
|
|
49
|
+
SUCCESS_EXIT_CODE,
|
|
50
|
+
TITLE_KEY,
|
|
51
|
+
TLDR_KEY,
|
|
52
|
+
UTF8_ENCODING,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CardInputRunFatal(ValueError):
|
|
57
|
+
"""Stop the run when the piped card input breaks a field rule."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _fix_playbook_path() -> Path:
|
|
61
|
+
skill_directory = Path(__file__).resolve().parent.parent
|
|
62
|
+
return skill_directory / FIX_PLAYBOOK_DIRECTORY_NAME / FIX_PLAYBOOK_FILE_NAME
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def handed_off_playbook_steps(playbook_text: str) -> str:
|
|
66
|
+
"""Return the fix playbook from step 2 on, with each link reduced to its text.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
playbook_text: the whole fix playbook.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Step 2 through the reply line, readable without the skill's folder.
|
|
73
|
+
"""
|
|
74
|
+
first_step_index = (
|
|
75
|
+
playbook_text.index(LINE_SEPARATOR + FIX_PLAYBOOK_FIRST_HANDED_OFF_STEP) + 1
|
|
76
|
+
)
|
|
77
|
+
return re.sub(
|
|
78
|
+
MARKDOWN_LINK_PATTERN, r"\1", playbook_text[first_step_index:]
|
|
79
|
+
).strip()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _required_text(all_card_fields: dict[str, object], field: str) -> str:
|
|
83
|
+
raw_text = all_card_fields.get(field, "")
|
|
84
|
+
text = raw_text.strip() if isinstance(raw_text, str) else ""
|
|
85
|
+
if not text:
|
|
86
|
+
raise CardInputRunFatal(REASON_EMPTY_FIELD_TEMPLATE.format(field=field))
|
|
87
|
+
return text
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _one_line_text(all_card_fields: dict[str, object], field: str) -> str:
|
|
91
|
+
text = _required_text(all_card_fields, field)
|
|
92
|
+
if LINE_SEPARATOR in text:
|
|
93
|
+
raise CardInputRunFatal(REASON_MULTI_LINE_FIELD_TEMPLATE.format(field=field))
|
|
94
|
+
return text
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _card_title(all_card_fields: dict[str, object]) -> str:
|
|
98
|
+
title = _one_line_text(all_card_fields, TITLE_KEY)
|
|
99
|
+
if len(title) > MAXIMUM_TITLE_LENGTH:
|
|
100
|
+
raise CardInputRunFatal(
|
|
101
|
+
REASON_TITLE_TOO_LONG_TEMPLATE.format(
|
|
102
|
+
length=len(title), limit=MAXIMUM_TITLE_LENGTH
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
return title
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _file_lines(all_card_fields: dict[str, object]) -> str:
|
|
109
|
+
all_files = all_card_fields.get(FILES_KEY, [])
|
|
110
|
+
if not isinstance(all_files, list) or not all(
|
|
111
|
+
isinstance(each, str) for each in all_files
|
|
112
|
+
):
|
|
113
|
+
raise CardInputRunFatal(REASON_FILES_NOT_A_LIST)
|
|
114
|
+
all_named_paths = [each_path.strip() for each_path in all_files if each_path.strip()]
|
|
115
|
+
for each_path in all_named_paths:
|
|
116
|
+
if re.match(ABSOLUTE_PATH_PATTERN, each_path):
|
|
117
|
+
raise CardInputRunFatal(REASON_ABSOLUTE_FILE_TEMPLATE.format(path=each_path))
|
|
118
|
+
all_lines = [FILE_LINE_TEMPLATE.format(path=each_path) for each_path in all_named_paths]
|
|
119
|
+
return LINE_SEPARATOR.join(all_lines) or NO_FILES_LINE
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def build_task_card(
|
|
123
|
+
all_card_fields: dict[str, object], playbook_text: str
|
|
124
|
+
) -> dict[str, str]:
|
|
125
|
+
"""Return the title, tldr, and standalone prompt for `spawn_task`.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
all_card_fields: the parsed object with title, tldr, brief, and files.
|
|
129
|
+
playbook_text: the whole fix playbook.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The three `spawn_task` fields, in the order the tool lists them.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
CardInputRunFatal: a field is empty, spans lines, runs long, or names
|
|
136
|
+
an absolute file path.
|
|
137
|
+
"""
|
|
138
|
+
prompt = PROMPT_TEMPLATE.format(
|
|
139
|
+
brief=_required_text(all_card_fields, BRIEF_KEY),
|
|
140
|
+
file_lines=_file_lines(all_card_fields),
|
|
141
|
+
playbook_steps=handed_off_playbook_steps(playbook_text),
|
|
142
|
+
)
|
|
143
|
+
all_card_texts = (
|
|
144
|
+
_card_title(all_card_fields),
|
|
145
|
+
_one_line_text(all_card_fields, TLDR_KEY),
|
|
146
|
+
prompt,
|
|
147
|
+
)
|
|
148
|
+
return dict(zip(ALL_CARD_KEYS, all_card_texts))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _parsed_card_fields(piped_text: str) -> dict[str, object]:
|
|
152
|
+
try:
|
|
153
|
+
all_card_fields = json.loads(piped_text)
|
|
154
|
+
except json.JSONDecodeError as error:
|
|
155
|
+
raise CardInputRunFatal(REASON_NOT_AN_OBJECT) from error
|
|
156
|
+
if not isinstance(all_card_fields, dict):
|
|
157
|
+
raise CardInputRunFatal(REASON_NOT_AN_OBJECT)
|
|
158
|
+
return all_card_fields
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
162
|
+
parser = argparse.ArgumentParser(description="Build a correction task card.")
|
|
163
|
+
parser.add_argument("--prompt-only", action="store_true")
|
|
164
|
+
return parser
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def main(all_arguments: list[str]) -> int:
|
|
168
|
+
"""Read the card input from standard input and print the card or its prompt.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
all_arguments: the command-line arguments after the script name.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The process exit code.
|
|
175
|
+
"""
|
|
176
|
+
parsed_arguments = _build_parser().parse_args(all_arguments)
|
|
177
|
+
playbook_text = _fix_playbook_path().read_text(encoding=UTF8_ENCODING)
|
|
178
|
+
try:
|
|
179
|
+
all_card_fields = _parsed_card_fields(
|
|
180
|
+
sys.stdin.buffer.read().decode(UTF8_ENCODING)
|
|
181
|
+
)
|
|
182
|
+
task_card = build_task_card(all_card_fields, playbook_text)
|
|
183
|
+
except CardInputRunFatal as error:
|
|
184
|
+
sys.stderr.write(INVALID_INPUT_MESSAGE_TEMPLATE.format(reason=error))
|
|
185
|
+
return FAILURE_EXIT_CODE
|
|
186
|
+
if parsed_arguments.prompt_only:
|
|
187
|
+
sys.stdout.write(task_card["prompt"])
|
|
188
|
+
else:
|
|
189
|
+
sys.stdout.write(json.dumps(task_card) + LINE_SEPARATOR)
|
|
190
|
+
return SUCCESS_EXIT_CODE
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
sys.exit(main(sys.argv[1:]))
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Configuration constants for the correction task card command."""
|
|
2
|
+
|
|
3
|
+
ABSOLUTE_PATH_PATTERN = r"^(?:[/\\~]|[A-Za-z]:)"
|
|
4
|
+
BRIEF_KEY = "brief"
|
|
5
|
+
ALL_CARD_KEYS = ("title", "tldr", "prompt")
|
|
6
|
+
FAILURE_EXIT_CODE = 1
|
|
7
|
+
LINE_SEPARATOR = "\n"
|
|
8
|
+
FILE_LINE_TEMPLATE = "- `{path}`"
|
|
9
|
+
FILES_KEY = "files"
|
|
10
|
+
FIX_PLAYBOOK_FILE_NAME = "fix.md"
|
|
11
|
+
FIX_PLAYBOOK_FIRST_HANDED_OFF_STEP = "2. "
|
|
12
|
+
FIX_PLAYBOOK_DIRECTORY_NAME = "playbooks"
|
|
13
|
+
MARKDOWN_LINK_PATTERN = r"\[([^\]]+)\]\([^)]+\)"
|
|
14
|
+
MAXIMUM_TITLE_LENGTH = 59
|
|
15
|
+
NO_FILES_LINE = "- None named in the session. Step 4 finds them."
|
|
16
|
+
PROMPT_TEMPLATE = (
|
|
17
|
+
"Land this correction as one control and one pull request.\n\n"
|
|
18
|
+
"## Correction brief\n\n"
|
|
19
|
+
"{brief}\n\n"
|
|
20
|
+
"## Files, relative to the repository root\n\n"
|
|
21
|
+
"{file_lines}\n\n"
|
|
22
|
+
"## Fix playbook\n\n"
|
|
23
|
+
"The brief above is step 1. Start at step 2.\n\n"
|
|
24
|
+
"{playbook_steps}\n"
|
|
25
|
+
)
|
|
26
|
+
SUCCESS_EXIT_CODE = 0
|
|
27
|
+
TITLE_KEY = "title"
|
|
28
|
+
TLDR_KEY = "tldr"
|
|
29
|
+
UTF8_ENCODING = "utf-8"
|
|
30
|
+
INVALID_INPUT_MESSAGE_TEMPLATE = "Invalid card input: {reason}\n"
|
|
31
|
+
REASON_NOT_AN_OBJECT = "standard input must hold one JSON object"
|
|
32
|
+
REASON_EMPTY_FIELD_TEMPLATE = "{field} is empty"
|
|
33
|
+
REASON_MULTI_LINE_FIELD_TEMPLATE = "{field} must be one line"
|
|
34
|
+
REASON_TITLE_TOO_LONG_TEMPLATE = "title has {length} characters; the limit is {limit}"
|
|
35
|
+
REASON_FILES_NOT_A_LIST = "files must be a list of strings"
|
|
36
|
+
REASON_ABSOLUTE_FILE_TEMPLATE = (
|
|
37
|
+
"{path} is absolute; name it relative to the repository root"
|
|
38
|
+
)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Behavior tests for correction_task_card against the shipped fix playbook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
|
|
14
|
+
if str(SCRIPTS_DIRECTORY) not in sys.path:
|
|
15
|
+
sys.path.insert(0, str(SCRIPTS_DIRECTORY))
|
|
16
|
+
|
|
17
|
+
correction_task_card = importlib.import_module("correction_task_card")
|
|
18
|
+
|
|
19
|
+
FIX_PLAYBOOK_TEXT = (SCRIPTS_DIRECTORY.parent / "playbooks" / "fix.md").read_text(
|
|
20
|
+
encoding="utf-8"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _card_input(**overrides: object) -> dict[str, object]:
|
|
25
|
+
card_input: dict[str, object] = {
|
|
26
|
+
"title": "Add a lint for bare except blocks in hooks",
|
|
27
|
+
"tldr": "Agents keep writing bare except blocks in hook files.",
|
|
28
|
+
"brief": "Correction: stop writing bare except\nEvidence: hooks/example.py:12",
|
|
29
|
+
"files": ["packages/claude-dev-env/hooks/blocking/example.py"],
|
|
30
|
+
}
|
|
31
|
+
card_input.update(overrides)
|
|
32
|
+
return card_input
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run_main(
|
|
36
|
+
card_input: object, all_arguments: list[str], monkeypatch: pytest.MonkeyPatch
|
|
37
|
+
) -> int:
|
|
38
|
+
piped_bytes = json.dumps(card_input).encode("utf-8")
|
|
39
|
+
monkeypatch.setattr(
|
|
40
|
+
sys, "stdin", io.TextIOWrapper(io.BytesIO(piped_bytes), encoding="utf-8")
|
|
41
|
+
)
|
|
42
|
+
return correction_task_card.main(all_arguments)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_should_print_the_three_spawn_task_fields(
|
|
46
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
47
|
+
) -> None:
|
|
48
|
+
assert _run_main(_card_input(), [], monkeypatch) == 0
|
|
49
|
+
task_card = json.loads(capsys.readouterr().out)
|
|
50
|
+
assert list(task_card) == ["title", "tldr", "prompt"]
|
|
51
|
+
assert task_card["title"] == "Add a lint for bare except blocks in hooks"
|
|
52
|
+
assert task_card["tldr"] == "Agents keep writing bare except blocks in hook files."
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_should_carry_the_brief_files_and_playbook_steps_in_the_prompt() -> None:
|
|
56
|
+
prompt = correction_task_card.build_task_card(_card_input(), FIX_PLAYBOOK_TEXT)[
|
|
57
|
+
"prompt"
|
|
58
|
+
]
|
|
59
|
+
assert (
|
|
60
|
+
"Correction: stop writing bare except\nEvidence: hooks/example.py:12" in prompt
|
|
61
|
+
)
|
|
62
|
+
assert "- `packages/claude-dev-env/hooks/blocking/example.py`" in prompt
|
|
63
|
+
assert "Start at step 2." in prompt
|
|
64
|
+
for each_step_number in range(2, 9):
|
|
65
|
+
assert f"\n{each_step_number}. " in prompt
|
|
66
|
+
assert "**Reply:**" in prompt
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_should_leave_out_step_one_and_relative_links() -> None:
|
|
70
|
+
prompt = correction_task_card.build_task_card(_card_input(), FIX_PLAYBOOK_TEXT)[
|
|
71
|
+
"prompt"
|
|
72
|
+
]
|
|
73
|
+
assert "\n1. Write the brief" not in prompt
|
|
74
|
+
assert "](../" not in prompt
|
|
75
|
+
assert "`correction-lens.md`" in prompt
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_should_print_only_the_prompt_for_the_handoff_brief(
|
|
79
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
80
|
+
) -> None:
|
|
81
|
+
assert _run_main(_card_input(), ["--prompt-only"], monkeypatch) == 0
|
|
82
|
+
printed = capsys.readouterr().out
|
|
83
|
+
assert (
|
|
84
|
+
printed
|
|
85
|
+
== correction_task_card.build_task_card(_card_input(), FIX_PLAYBOOK_TEXT)[
|
|
86
|
+
"prompt"
|
|
87
|
+
]
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_should_name_no_files_when_the_session_names_none() -> None:
|
|
92
|
+
prompt = correction_task_card.build_task_card(
|
|
93
|
+
_card_input(files=[]), FIX_PLAYBOOK_TEXT
|
|
94
|
+
)["prompt"]
|
|
95
|
+
assert "- None named in the session. Step 4 finds them." in prompt
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.mark.parametrize(
|
|
99
|
+
("overrides", "reason_fragment"),
|
|
100
|
+
[
|
|
101
|
+
({"title": "x" * 60}, "title has 60 characters; the limit is 59"),
|
|
102
|
+
({"title": " "}, "title is empty"),
|
|
103
|
+
({"title": "Add a lint\nfor hooks"}, "title must be one line"),
|
|
104
|
+
({"tldr": "first line\nsecond line"}, "tldr must be one line"),
|
|
105
|
+
({"brief": ""}, "brief is empty"),
|
|
106
|
+
({"files": "hooks/example.py"}, "files must be a list of strings"),
|
|
107
|
+
({"files": ["/home/me/repo/hooks/example.py"]}, "is absolute"),
|
|
108
|
+
({"files": ["C:\\repo\\hooks\\example.py"]}, "is absolute"),
|
|
109
|
+
({"files": ["~/repo/hooks/example.py"]}, "is absolute"),
|
|
110
|
+
({"files": [" /home/me/repo/hooks/example.py"]}, "is absolute"),
|
|
111
|
+
],
|
|
112
|
+
)
|
|
113
|
+
def test_should_refuse_a_card_that_breaks_a_field_rule(
|
|
114
|
+
overrides: dict[str, object],
|
|
115
|
+
reason_fragment: str,
|
|
116
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
117
|
+
capsys: pytest.CaptureFixture[str],
|
|
118
|
+
) -> None:
|
|
119
|
+
assert _run_main(_card_input(**overrides), [], monkeypatch) == 1
|
|
120
|
+
captured = capsys.readouterr()
|
|
121
|
+
assert captured.out == ""
|
|
122
|
+
assert reason_fragment in captured.err
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_should_accept_a_title_at_the_length_limit() -> None:
|
|
126
|
+
task_card = correction_task_card.build_task_card(
|
|
127
|
+
_card_input(title="A" * 59), FIX_PLAYBOOK_TEXT
|
|
128
|
+
)
|
|
129
|
+
assert len(task_card["title"]) == 59
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_should_refuse_input_that_is_not_one_json_object(
|
|
133
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
134
|
+
) -> None:
|
|
135
|
+
assert _run_main(["a", "list"], [], monkeypatch) == 1
|
|
136
|
+
assert "standard input must hold one JSON object" in capsys.readouterr().err
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_should_return_the_playbook_from_step_two() -> None:
|
|
140
|
+
handed_off_steps = correction_task_card.handed_off_playbook_steps(FIX_PLAYBOOK_TEXT)
|
|
141
|
+
assert handed_off_steps.startswith("2. ")
|
|
142
|
+
assert "pull request link" in handed_off_steps
|
package/bin/cde.mjs
CHANGED
|
@@ -45,7 +45,7 @@ export function createHelpText() {
|
|
|
45
45
|
" cde followup brief Write the task an agent fixes them from",
|
|
46
46
|
" cde followup count Report the backlog against the threshold",
|
|
47
47
|
" cde followup clear Empty the ledger",
|
|
48
|
-
" cde followup dedupe
|
|
48
|
+
" cde followup dedupe Give each finding its own file, once",
|
|
49
49
|
"",
|
|
50
50
|
"Python may also be selected with CDE_PYTHON.",
|
|
51
51
|
].join("\n");
|
package/bin/cde.test.mjs
CHANGED
|
@@ -292,5 +292,5 @@ test("dispatches followup to the follow-up ledger command", async () => {
|
|
|
292
292
|
test("help names the followup command", () => {
|
|
293
293
|
assert.match(createHelpText(), /cde <lint\|verify\|followup>/);
|
|
294
294
|
assert.match(createHelpText(), /cde followup count/);
|
|
295
|
-
assert.match(createHelpText(), /cde followup dedupe/);
|
|
295
|
+
assert.match(createHelpText(), /cde followup dedupe +Give each finding its own file, once/);
|
|
296
296
|
});
|
package/bin/install.mjs
CHANGED
|
@@ -1255,6 +1255,7 @@ export const RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS = new Set([
|
|
|
1255
1255
|
'blocking/question_to_user_enforcer.py',
|
|
1256
1256
|
'blocking/session_handoff_blocker.py',
|
|
1257
1257
|
'blocking/artifact_look_gate.py',
|
|
1258
|
+
'blocking/session_title_stop_gate.py',
|
|
1258
1259
|
'session/issue_tracker_session_starter.py',
|
|
1259
1260
|
]);
|
|
1260
1261
|
|
package/bin/install.test.mjs
CHANGED
|
@@ -1231,6 +1231,7 @@ test('retired hook registrations stay managed so reinstall removes them', () =>
|
|
|
1231
1231
|
'blocking/question_to_user_enforcer.py',
|
|
1232
1232
|
'blocking/session_handoff_blocker.py',
|
|
1233
1233
|
'blocking/artifact_look_gate.py',
|
|
1234
|
+
'blocking/session_title_stop_gate.py',
|
|
1234
1235
|
'session/issue_tracker_session_starter.py',
|
|
1235
1236
|
]);
|
|
1236
1237
|
const shippedHooks = JSON.parse(
|
|
@@ -8,9 +8,8 @@
|
|
|
8
8
|
* timestamped `.bak` beside it.
|
|
9
9
|
*
|
|
10
10
|
* Each run also retires the profile-level session-title Stop gate an earlier
|
|
11
|
-
* install copied to `~/.claude`. The
|
|
12
|
-
*
|
|
13
|
-
* from each file and deletes the gate files.
|
|
11
|
+
* install copied to `~/.claude`. The merge removes that gate's Stop hook and
|
|
12
|
+
* allow rule from each file and deletes the gate files.
|
|
14
13
|
*
|
|
15
14
|
* Run `node merge_profile_settings.mjs --dry-run` to list the changes for
|
|
16
15
|
* every profile on this machine without writing anything.
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
|
|
18
18
|
const FIXED_NOW = new Date('2026-10-04T12:00:00.000Z');
|
|
19
19
|
const BACKUP_SUFFIX = '.2026-10-04T12-00-00-000Z.bak';
|
|
20
|
-
const
|
|
20
|
+
const OTHER_STOP_HOOK_COMMAND = 'python3 /plugin/hooks/lifecycle/other_stop_hook.py';
|
|
21
21
|
const PERMISSION_ALLOW_ITEMS = DECLARED_PROFILE_SETTINGS
|
|
22
22
|
.find((eachEntry) => eachEntry.keyPath.join('.') === 'permissions.allow').items;
|
|
23
23
|
const AUTO_MODE_ALLOW_ITEMS = DECLARED_PROFILE_SETTINGS
|
|
@@ -73,7 +73,7 @@ test('a missing settings.json is created with every declared entry and no Stop h
|
|
|
73
73
|
assert.deepEqual(outcome.gate.removedPaths, []);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
test('the retired profile gate leaves settings.json and ~/.claude, and
|
|
76
|
+
test('the retired profile gate leaves settings.json and ~/.claude, and another Stop hook stays', () => {
|
|
77
77
|
const homeDirectory = makeHome();
|
|
78
78
|
const claudeDirectory = join(homeDirectory, '.claude');
|
|
79
79
|
const settingsPath = join(claudeDirectory, 'settings.json');
|
|
@@ -83,7 +83,7 @@ test('the retired profile gate leaves settings.json and ~/.claude, and the plugi
|
|
|
83
83
|
{
|
|
84
84
|
hooks: [
|
|
85
85
|
{ type: 'command', command: expandedRetiredGateCommand(homeDirectory), timeout: 10 },
|
|
86
|
-
{ type: 'command', command:
|
|
86
|
+
{ type: 'command', command: OTHER_STOP_HOOK_COMMAND },
|
|
87
87
|
],
|
|
88
88
|
},
|
|
89
89
|
{ hooks: [{ type: 'command', command: expandedRetiredGateCommand(homeDirectory), timeout: 10 }] },
|
|
@@ -96,7 +96,7 @@ test('the retired profile gate leaves settings.json and ~/.claude, and the plugi
|
|
|
96
96
|
const outcome = mergeProfileSettings([settingsPath], { dryRun: false, homeDirectory, now: FIXED_NOW });
|
|
97
97
|
|
|
98
98
|
const mergedSettings = readSettings(settingsPath);
|
|
99
|
-
assert.deepEqual(stopCommands(mergedSettings), [
|
|
99
|
+
assert.deepEqual(stopCommands(mergedSettings), [OTHER_STOP_HOOK_COMMAND]);
|
|
100
100
|
assert.equal(mergedSettings.hooks.Stop.length, 1);
|
|
101
101
|
assert.equal(mergedSettings.permissions.allow.includes(RETIRED_SESSION_TITLE_GATE_PERMISSION), false);
|
|
102
102
|
assert.equal(outcome.files[0].removals.length, 3);
|
|
@@ -152,7 +152,7 @@ test('a partial file gains only the missing entries, keeps other keys, and is ba
|
|
|
152
152
|
const originalSettings = {
|
|
153
153
|
model: 'opus',
|
|
154
154
|
hooks: {
|
|
155
|
-
Stop: [{ hooks: [{ type: 'command', command:
|
|
155
|
+
Stop: [{ hooks: [{ type: 'command', command: OTHER_STOP_HOOK_COMMAND, timeout: 30 }] }],
|
|
156
156
|
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'python3 guard.py' }] }],
|
|
157
157
|
},
|
|
158
158
|
permissions: { allow: ['Read', 'mcp__claude-code-remote__set_session_title'], deny: ['Bash(rm:*)'] },
|
|
@@ -13,7 +13,8 @@ Use `docs/rule-guides/asd-ste100-language.md` for user-facing wording.
|
|
|
13
13
|
|
|
14
14
|
## 1. Read the ledger
|
|
15
15
|
|
|
16
|
-
Run `cde followup brief --repository-root <root>`.
|
|
16
|
+
Run `cde followup brief --repository-root <root>`. The ledger is the
|
|
17
|
+
`.claude/followups/` directory, one JSON file per finding.
|
|
17
18
|
|
|
18
19
|
When it prints `no follow-ups recorded`, report that and stop. There is no
|
|
19
20
|
work.
|
|
@@ -62,7 +63,9 @@ Push, then confirm the required checks report on the branch head.
|
|
|
62
63
|
## 5. Empty the ledger
|
|
63
64
|
|
|
64
65
|
Once the pull request is open, run
|
|
65
|
-
`cde followup clear --repository-root <root>`.
|
|
66
|
+
`cde followup clear --repository-root <root>`. It removes every finding file.
|
|
67
|
+
When the repository commits its ledger, commit those removals on the pull
|
|
68
|
+
request branch and push.
|
|
66
69
|
|
|
67
70
|
A finding the pull request left alone goes back in the ledger with
|
|
68
71
|
`cde followup ingest`, so the next pass picks it up.
|
|
@@ -10,7 +10,7 @@ A smell that blocks a commit stops delivery for something the reader could fix i
|
|
|
10
10
|
|
|
11
11
|
## The ledger
|
|
12
12
|
|
|
13
|
-
Recorded findings land in `.claude/followups
|
|
13
|
+
Recorded findings land in `.claude/followups/` at the repository root, one JSON file per finding. The file name is the first 16 hex digits of a SHA-256 hash over the check identifier, path, and message, so the same finding always writes the same file and two branches that record different findings never touch one file. `hooks/followup_ledger.py` writes and reads the directory. Ledger writes are fail-safe, so a ledger failure leaves the gate's decision unchanged.
|
|
14
14
|
|
|
15
15
|
| Field | What it carries |
|
|
16
16
|
|---|---|
|
|
@@ -21,7 +21,7 @@ Recorded findings land in `.claude/followups/smells.jsonl` at the repository roo
|
|
|
21
21
|
| `severity` | The class the gate put it in |
|
|
22
22
|
| `origin_commit` | The revision checked out when it was recorded |
|
|
23
23
|
|
|
24
|
-
The origin commit groups a follow-up pull request by the change that raised the findings. A smell seen again under a later revision keeps the revision that first raised it, so one smell stays one record. The
|
|
24
|
+
The origin commit groups a follow-up pull request by the change that raised the findings. A smell seen again under a later revision keeps the revision that first raised it, so one smell stays one record. The first write into a directory with no `.gitignore` adds one holding `*`, so the ledger stays out of the repository by default. A repository that commits its findings commits its own `.gitignore`. This repository commits one holding `!*.json`.
|
|
25
25
|
|
|
26
26
|
## The check identifier
|
|
27
27
|
|
|
@@ -38,12 +38,10 @@ A message with no catalog entry resolves to `<rule>/unclassified`, which a parti
|
|
|
38
38
|
| `cde followup brief` | Writes the task an agent fixes them from |
|
|
39
39
|
| `cde followup clear` | Empties the ledger |
|
|
40
40
|
| `cde followup count` | Reports the backlog against the threshold |
|
|
41
|
-
| `cde followup dedupe` |
|
|
41
|
+
| `cde followup dedupe` | Moves a legacy `smells.jsonl` into finding files and removes duplicate finding files |
|
|
42
42
|
|
|
43
43
|
`count` exits non-zero once the backlog passes `FOLLOWUP_BACKLOG_THRESHOLD` in `scripts/dev_env_scripts_constants/followup_constants.py`, so a scheduled job escalates. The number is the repository's setting.
|
|
44
44
|
|
|
45
|
-
`.gitattributes` marks the ledger `merge=union`, so a local `git merge` keeps both sides' appended lines without a conflict. GitHub's server-side merge ignores the attribute. Every ledger write drops exact repeated lines, and the `followup-ledger-duplicates` repository check fails a tree that still holds one.
|
|
46
|
-
|
|
47
45
|
The `/fix-followups` command reads the brief, fixes each rule group, opens a pull request, and clears the ledger.
|
|
48
46
|
|
|
49
47
|
## Worked example
|
|
@@ -17,7 +17,7 @@ When you add a public function to a module whose test suite already exercises th
|
|
|
17
17
|
|
|
18
18
|
Two complementary checks in `code_rules_paired_test.py` reach changed files through `code_rules_enforcer.py`, which the staged policy lint runs under its `code-rules` rule. No write-time hook runs them, so CI is where they report, against the merge base. The two checks cover the two write orders.
|
|
19
19
|
|
|
20
|
-
Both checks record smells, per [`flag-non-breaking-findings.md`](../../rules/flag-non-breaking-findings.md). `SEVERITY_BY_CHECK_ID` in `scripts/policy_lint/config/check_catalog_constants.py` declares `code-rules/paired-test-missing-function` and `code-rules/paired-test-omitted-function` as smells. The lint prints each finding as a warning, records it in `.claude/followups
|
|
20
|
+
Both checks record smells, per [`flag-non-breaking-findings.md`](../../rules/flag-non-breaking-findings.md). `SEVERITY_BY_CHECK_ID` in `scripts/policy_lint/config/check_catalog_constants.py` declares `code-rules/paired-test-missing-function` and `code-rules/paired-test-omitted-function` as smells. The lint prints each finding as a warning, records it as one file in `.claude/followups/`, and exits zero when every finding is a warning. A later pull request adds the missing tests.
|
|
21
21
|
|
|
22
22
|
`check_public_function_missing_paired_test` runs on a production Python write or edit and flags a public function when all of these hold:
|
|
23
23
|
|
|
@@ -6,7 +6,7 @@ Back to the [rules index](../../rules/index.md).
|
|
|
6
6
|
|
|
7
7
|
Before each turn ends, set the title to `<emoji> <name>`. Use the red flag when the user must act, the check mark when the work is done, and the hourglass while work, CI, or a merge queue runs. The name has 25 characters or fewer, in sentence case, with concrete nouns and no IDs, dates, or branch names.
|
|
8
8
|
|
|
9
|
-
**Enforcement:** `hooks/blocking/session_title_format_gate.py` denies a malformed title
|
|
9
|
+
**Enforcement:** `hooks/blocking/session_title_format_gate.py` denies a malformed title.
|
|
10
10
|
|
|
11
11
|
**When this applies:** The session has a tool whose name ends in `__set_session_title`, such as `mcp__claude-code-remote__set_session_title` in a cloud session or `mcp__ccd_session_mgmt__set_session_title` in the desktop app.
|
|
12
12
|
|
|
@@ -14,7 +14,7 @@ Before each turn ends, set the title to `<emoji> <name>`. Use the red flag when
|
|
|
14
14
|
|
|
15
15
|
Before each turn ends, set the title to `<emoji> <name>`. Get the session id from `get_session` with no `session_id` when the tool asks for one.
|
|
16
16
|
|
|
17
|
-
Set the title before the final reply, and set it in silence. The reply to the user carries no mention of the title, its emoji, or the reminder that asked for it.
|
|
17
|
+
Set the title before the final reply, and set it in silence. The reply to the user carries no mention of the title, its emoji, or the reminder that asked for it.
|
|
18
18
|
|
|
19
19
|
## Status emoji
|
|
20
20
|
|
|
@@ -30,4 +30,4 @@ The name has 25 characters or fewer and names the main change or outcome in conc
|
|
|
30
30
|
|
|
31
31
|
## Enforcement
|
|
32
32
|
|
|
33
|
-
`hooks/blocking/session_title_format_gate.py` denies a malformed title.
|
|
33
|
+
`hooks/blocking/session_title_format_gate.py` denies a malformed title.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# html-plan page template
|
|
2
2
|
|
|
3
|
-
Build a
|
|
3
|
+
Build a complex visual, such as a multi-part plan or evidence, from the html-plan skill. Load `house.css` from this folder after the plugin's `htmlplan.css`. The older [artifact page template](../artifact-page/README.md) stays available; use it when the request names it.
|
|
4
4
|
|
|
5
5
|
## Get the skill
|
|
6
6
|
|