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
|
@@ -8,7 +8,6 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
8
8
|
- `blocking/edit_marker_gate.py` rejects strikethrough and edit-note markers in replacement chat text or cards.
|
|
9
9
|
- `blocking/issue_close_handoff_gate.py` denies an issue comment or issue write that closes the issue and routes a found defect to another issue or epic.
|
|
10
10
|
- `blocking/session_title_format_gate.py` denies a session title that breaks the `<emoji> <name>` shape or has a name longer than 25 characters.
|
|
11
|
-
- `blocking/session_title_stop_gate.py` blocks a turn end once when no session title call succeeded in that turn.
|
|
12
11
|
- `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
|
|
13
12
|
- `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
|
|
14
13
|
- `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
|
|
@@ -20,7 +19,6 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
20
19
|
- `blocking/edit_marker_gate.py` runs on `PreToolUse`, matcher `mcp__.*__update_message`, timeout `10` seconds in `hooks.json`.
|
|
21
20
|
- `blocking/issue_close_handoff_gate.py` runs on `PreToolUse`, matcher `mcp__.*__(add_issue_comment|issue_write|update_issue_comment)`, timeout `10` seconds in `hooks.json`.
|
|
22
21
|
- `blocking/session_title_format_gate.py` runs on `PreToolUse`, matcher `mcp__.*__set_session_title`, timeout `10` seconds in `hooks.json`.
|
|
23
|
-
- `blocking/session_title_stop_gate.py` runs on `Stop`, no matcher, timeout `10` seconds in `hooks.json`.
|
|
24
22
|
- `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
|
|
25
23
|
- `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
|
|
26
24
|
- `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)`, timeout `10` seconds in `hooks.json`.
|
|
@@ -36,7 +34,6 @@ Preconditions:
|
|
|
36
34
|
- **Edit marker.** Input is an update message with strikethrough or an edit note. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_edit_marker_gate.py -q`. The adjacent test observes a denial; clean replacement text passes.
|
|
37
35
|
- **Handoff close.** Input is an issue comment that says the issue is closed and that the found defect belongs to another issue. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_issue_close_handoff_gate.py -q`. The adjacent test observes a denial; a close with a plain cross-reference and a routing comment on an open issue both pass.
|
|
38
36
|
- **Session title.** Input is a set session title call with no leading emoji or a name over 25 characters. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_format_gate.py -q`. The adjacent test observes a denial that names the broken rule; a well-formed title passes.
|
|
39
|
-
- **Title at turn end.** Input is a remote session transcript whose last turn set no title. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_stop_gate.py -q`. The adjacent test observes a block; a turn with a successful title call passes.
|
|
40
37
|
- **Step note.** Input is a tool call after a transcript message without a status line while the flag is on. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_step_note_gate.py -q`. The adjacent test observes a block until a status line appears.
|
|
41
38
|
- **Checked claim.** Input is a Write after hedged reasoning in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_acting.py -q`. The adjacent test observes a block and a logged blocked outcome.
|
|
42
39
|
- **Lifecycle skill.** Input is a `git commit` or `gh pr create` command with no `pr-lifecycle` invocation in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pr_lifecycle_skill_gate.py -q`. The adjacent test observes a denial, then a silent allow once the skill or its slash command appears.
|
|
@@ -9,7 +9,7 @@ This family injects reminders and task guidance when an agent starts, resumes, s
|
|
|
9
9
|
- `session/working_style_prompt.py` injects the session's working style at session start.
|
|
10
10
|
- `session/advisor_rules_prompt.py` injects advisor guidance when the built-in advisor is enabled.
|
|
11
11
|
- `session/orchestrator_auto_starter.py` injects an orchestrator directive when its environment flag is enabled.
|
|
12
|
-
- `session/artifact_template_pointer.py`
|
|
12
|
+
- `session/artifact_template_pointer.py` routes an agent that starts an artifact page by scope. One flow or one comparison is an inline SVG flowchart with minimal text. A multi-part plan or evidence is an html-plan page from `docs/templates/html-plan/README.md`. When the request names the older artifact page template, it points at `docs/templates/artifact-page/README.md`, which names the tracker template and the diagram template and which ask each fits.
|
|
13
13
|
- `advisory/auto_mode_denial_quick_fix.py` proposes one `autoMode.allow` entry and a PowerShell block that writes it after an auto mode denial. It never retries the denied call.
|
|
14
14
|
|
|
15
15
|
## When it fires
|
package/hooks/followup_ledger.py
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Ledger of non-breaking findings awaiting a follow-up fix, one file each.
|
|
2
2
|
|
|
3
3
|
A gate that finds a breaking defect stops the commit, the push, or the tool
|
|
4
4
|
call. A gate that finds a non-breaking smell records the finding here and lets
|
|
5
|
-
the work proceed.
|
|
6
|
-
|
|
5
|
+
the work proceed. Each finding is its own JSON file under
|
|
6
|
+
``.claude/followups/``, named by a hash of the finding, so parallel branches
|
|
7
|
+
that record different findings never edit the same file. ``cde followup``
|
|
8
|
+
reads the ledger and briefs the agent that fixes the recorded smells in their
|
|
9
|
+
own pull request.
|
|
7
10
|
|
|
8
|
-
Every write is fail-safe. A ledger that cannot be created or
|
|
11
|
+
Every write is fail-safe. A ledger that cannot be created or written to leaves
|
|
9
12
|
the calling gate's decision unchanged, so recording a finding never becomes a
|
|
10
13
|
new way for a gate to fail.
|
|
11
14
|
"""
|
|
12
15
|
|
|
13
16
|
from __future__ import annotations
|
|
14
17
|
|
|
18
|
+
import hashlib
|
|
15
19
|
import json
|
|
16
20
|
import sys
|
|
17
|
-
from collections.abc import Sequence
|
|
18
21
|
from pathlib import Path
|
|
19
22
|
from typing import NamedTuple
|
|
20
23
|
|
|
@@ -24,9 +27,12 @@ if _hooks_directory not in sys.path:
|
|
|
24
27
|
|
|
25
28
|
from hooks_constants.followup_ledger_constants import (
|
|
26
29
|
ABSENT_ORIGIN_COMMIT,
|
|
27
|
-
|
|
30
|
+
ALL_FOLLOWUP_DIRECTORY_SEGMENTS,
|
|
28
31
|
CHECK_ID_KEY,
|
|
29
32
|
FILE_PATH_KEY,
|
|
33
|
+
FINDING_FILE_NAME_HASH_LENGTH,
|
|
34
|
+
FINDING_FILE_PATTERN,
|
|
35
|
+
FINDING_FILE_SUFFIX,
|
|
30
36
|
GIT_COMMON_DIRECTORY_FILE_NAME,
|
|
31
37
|
GIT_DIRECTORY_FILE_PREFIX,
|
|
32
38
|
GIT_DIRECTORY_NAME,
|
|
@@ -35,10 +41,11 @@ from hooks_constants.followup_ledger_constants import (
|
|
|
35
41
|
GIT_PACKED_REFERENCES_FILE_NAME,
|
|
36
42
|
GIT_PACKED_REFERENCES_PEELED_PREFIX,
|
|
37
43
|
GIT_REFERENCE_PREFIX,
|
|
38
|
-
|
|
44
|
+
LEDGER_CREATE_MODE,
|
|
39
45
|
LEDGER_ENCODING,
|
|
40
46
|
LEDGER_IGNORE_FILE_NAME,
|
|
41
47
|
LEDGER_IGNORE_TEXT,
|
|
48
|
+
LEDGER_JSON_INDENT,
|
|
42
49
|
MESSAGE_KEY,
|
|
43
50
|
ORIGIN_COMMIT_KEY,
|
|
44
51
|
RULE_ID_KEY,
|
|
@@ -80,151 +87,144 @@ class FollowupFinding(NamedTuple):
|
|
|
80
87
|
return (self.check_id or self.rule_id, self.file_path, self.message)
|
|
81
88
|
|
|
82
89
|
|
|
83
|
-
def
|
|
84
|
-
"""Return the
|
|
85
|
-
|
|
86
|
-
The first occurrence of each line keeps its place, so the ledger keeps its
|
|
87
|
-
record order after a ``merge=union`` merge writes both sides' lines.
|
|
90
|
+
def followup_directory(repository_root: Path) -> Path:
|
|
91
|
+
"""Return the directory that holds one repository's recorded findings.
|
|
88
92
|
|
|
89
93
|
Args:
|
|
90
|
-
|
|
94
|
+
repository_root: The repository whose ledger to address.
|
|
91
95
|
|
|
92
96
|
Returns:
|
|
93
|
-
The
|
|
97
|
+
The absolute path of that repository's follow-up directory.
|
|
94
98
|
"""
|
|
95
|
-
return
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def deduplicate_ledger(repository_root: Path) -> int:
|
|
99
|
-
"""Rewrite the repository's ledger without exact repeated lines.
|
|
100
|
-
|
|
101
|
-
An absent or unreadable ledger, and a ledger that cannot be rewritten, are
|
|
102
|
-
left as they are, so a ledger failure never becomes a gate failure.
|
|
99
|
+
return repository_root.joinpath(*ALL_FOLLOWUP_DIRECTORY_SEGMENTS)
|
|
103
100
|
|
|
104
|
-
Args:
|
|
105
|
-
repository_root: The repository whose ledger to clean.
|
|
106
|
-
|
|
107
|
-
Returns:
|
|
108
|
-
How many lines the rewrite removed.
|
|
109
|
-
"""
|
|
110
|
-
ledger_path = followup_ledger_path(repository_root)
|
|
111
|
-
try:
|
|
112
|
-
all_ledger_lines = ledger_path.read_text(encoding=LEDGER_ENCODING).splitlines()
|
|
113
|
-
except (OSError, UnicodeError):
|
|
114
|
-
return 0
|
|
115
|
-
all_unique_lines = deduplicated_lines(all_ledger_lines)
|
|
116
|
-
removed_line_count = len(all_ledger_lines) - len(all_unique_lines)
|
|
117
|
-
if not removed_line_count:
|
|
118
|
-
return 0
|
|
119
|
-
try:
|
|
120
|
-
ledger_path.write_text(
|
|
121
|
-
"".join(each_line + "\n" for each_line in all_unique_lines),
|
|
122
|
-
encoding=LEDGER_ENCODING,
|
|
123
|
-
)
|
|
124
|
-
except OSError:
|
|
125
|
-
return 0
|
|
126
|
-
return removed_line_count
|
|
127
101
|
|
|
102
|
+
def finding_path(repository_root: Path, finding: FollowupFinding) -> Path:
|
|
103
|
+
"""Return the file one finding is recorded in.
|
|
128
104
|
|
|
129
|
-
|
|
130
|
-
|
|
105
|
+
The file name is a hash of the finding's tracking key, so the same finding
|
|
106
|
+
always maps to the same file and two different findings never share one.
|
|
131
107
|
|
|
132
108
|
Args:
|
|
133
|
-
repository_root: The repository whose ledger
|
|
109
|
+
repository_root: The repository whose ledger holds the finding.
|
|
110
|
+
finding: The finding to locate.
|
|
134
111
|
|
|
135
112
|
Returns:
|
|
136
|
-
The absolute path of
|
|
113
|
+
The absolute path of the finding's file.
|
|
137
114
|
"""
|
|
138
|
-
|
|
115
|
+
key_text = json.dumps(list(finding.tracking_key()), ensure_ascii=True)
|
|
116
|
+
key_digest = hashlib.sha256(key_text.encode(LEDGER_ENCODING)).hexdigest()
|
|
117
|
+
file_name = key_digest[:FINDING_FILE_NAME_HASH_LENGTH] + FINDING_FILE_SUFFIX
|
|
118
|
+
return followup_directory(repository_root) / file_name
|
|
139
119
|
|
|
140
120
|
|
|
141
121
|
def record_followup_finding(repository_root: Path, finding: FollowupFinding) -> None:
|
|
142
|
-
"""
|
|
122
|
+
"""Write one finding to its own file in the repository's ledger, once.
|
|
143
123
|
|
|
144
|
-
The
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
ledger stays out of ``git status`` in any repository
|
|
150
|
-
error is swallowed, so a ledger
|
|
151
|
-
unchanged.
|
|
124
|
+
The file is created exclusively, so a finding already on disk keeps the
|
|
125
|
+
record that first raised it, and a gate that runs on every commit records
|
|
126
|
+
a standing smell a single time. Two writers recording different findings
|
|
127
|
+
write different files, so parallel branches never edit a shared file. The
|
|
128
|
+
first write in a directory with no ``.gitignore`` adds one matching every
|
|
129
|
+
file, so the ledger stays out of ``git status`` in any repository that
|
|
130
|
+
does not commit its own. Every filesystem error is swallowed, so a ledger
|
|
131
|
+
failure leaves the caller's gate decision unchanged.
|
|
152
132
|
|
|
153
133
|
Args:
|
|
154
134
|
repository_root: The repository whose ledger receives the finding.
|
|
155
135
|
finding: The non-breaking finding to record.
|
|
156
136
|
"""
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
each_finding.tracking_key()
|
|
160
|
-
for each_finding in all_recorded_findings(repository_root)
|
|
161
|
-
}
|
|
162
|
-
if finding.tracking_key() in all_recorded_keys:
|
|
163
|
-
return
|
|
164
|
-
|
|
165
|
-
ledger_path = followup_ledger_path(repository_root)
|
|
166
|
-
record_text = json.dumps(
|
|
167
|
-
{
|
|
168
|
-
RULE_ID_KEY: finding.rule_id,
|
|
169
|
-
FILE_PATH_KEY: finding.file_path,
|
|
170
|
-
MESSAGE_KEY: finding.message,
|
|
171
|
-
CHECK_ID_KEY: finding.check_id or finding.rule_id,
|
|
172
|
-
SEVERITY_KEY: finding.severity,
|
|
173
|
-
ORIGIN_COMMIT_KEY: finding.origin_commit,
|
|
174
|
-
}
|
|
175
|
-
)
|
|
137
|
+
record_path = finding_path(repository_root, finding)
|
|
138
|
+
record_text = json.dumps(_record_fields(finding), indent=LEDGER_JSON_INDENT)
|
|
176
139
|
try:
|
|
177
|
-
|
|
178
|
-
ignore_path =
|
|
140
|
+
record_path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
ignore_path = record_path.parent / LEDGER_IGNORE_FILE_NAME
|
|
179
142
|
if not ignore_path.exists():
|
|
180
143
|
ignore_path.write_text(LEDGER_IGNORE_TEXT, encoding=LEDGER_ENCODING)
|
|
181
|
-
with
|
|
182
|
-
|
|
144
|
+
with record_path.open(LEDGER_CREATE_MODE, encoding=LEDGER_ENCODING) as record_file:
|
|
145
|
+
record_file.write(record_text + "\n")
|
|
183
146
|
except OSError:
|
|
184
147
|
return
|
|
185
148
|
|
|
186
149
|
|
|
150
|
+
def _record_fields(finding: FollowupFinding) -> dict[str, str]:
|
|
151
|
+
return {
|
|
152
|
+
RULE_ID_KEY: finding.rule_id,
|
|
153
|
+
FILE_PATH_KEY: finding.file_path,
|
|
154
|
+
MESSAGE_KEY: finding.message,
|
|
155
|
+
CHECK_ID_KEY: finding.check_id or finding.rule_id,
|
|
156
|
+
SEVERITY_KEY: finding.severity,
|
|
157
|
+
ORIGIN_COMMIT_KEY: finding.origin_commit,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
187
161
|
def all_recorded_findings(repository_root: Path) -> tuple[FollowupFinding, ...]:
|
|
188
|
-
"""Return every finding the repository's ledger holds,
|
|
162
|
+
"""Return every finding the repository's ledger holds, ordered by check.
|
|
189
163
|
|
|
190
|
-
An absent
|
|
191
|
-
|
|
192
|
-
|
|
164
|
+
An absent directory reads as empty, and a file that does not parse into a
|
|
165
|
+
complete record is skipped, so one damaged file never hides the findings
|
|
166
|
+
around it.
|
|
193
167
|
|
|
194
168
|
Args:
|
|
195
169
|
repository_root: The repository whose ledger to read.
|
|
196
170
|
|
|
197
171
|
Returns:
|
|
198
|
-
The recorded findings,
|
|
172
|
+
The recorded findings, sorted by check identifier, path, and message.
|
|
173
|
+
"""
|
|
174
|
+
all_findings = [
|
|
175
|
+
each_finding
|
|
176
|
+
for _each_path, each_finding in all_finding_files(repository_root)
|
|
177
|
+
]
|
|
178
|
+
return tuple(sorted(all_findings, key=FollowupFinding.tracking_key))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def all_record_paths(directory: Path) -> list[Path]:
|
|
182
|
+
"""Return every finding file in one ledger directory, sorted by name.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
directory: The follow-up directory to list.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
The finding file paths, or an empty list when none can be listed.
|
|
199
189
|
"""
|
|
200
|
-
ledger_path = followup_ledger_path(repository_root)
|
|
201
190
|
try:
|
|
202
|
-
|
|
203
|
-
except
|
|
204
|
-
return
|
|
191
|
+
return sorted(directory.glob(FINDING_FILE_PATTERN))
|
|
192
|
+
except OSError:
|
|
193
|
+
return []
|
|
205
194
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
195
|
+
|
|
196
|
+
def all_finding_files(repository_root: Path) -> list[tuple[Path, FollowupFinding]]:
|
|
197
|
+
"""Return each readable finding file with the finding it holds.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
repository_root: The repository whose ledger to read.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Path and finding pairs, sorted by file name.
|
|
204
|
+
"""
|
|
205
|
+
all_pairs: list[tuple[Path, FollowupFinding]] = []
|
|
206
|
+
for each_path in all_record_paths(followup_directory(repository_root)):
|
|
207
|
+
try:
|
|
208
|
+
record_text = each_path.read_text(encoding=LEDGER_ENCODING)
|
|
209
|
+
except (OSError, UnicodeError):
|
|
210
|
+
continue
|
|
211
|
+
each_finding = finding_from_text(record_text)
|
|
209
212
|
if each_finding is not None:
|
|
210
|
-
|
|
211
|
-
return
|
|
213
|
+
all_pairs.append((each_path, each_finding))
|
|
214
|
+
return all_pairs
|
|
212
215
|
|
|
213
216
|
|
|
214
|
-
def
|
|
215
|
-
"""Parse one
|
|
217
|
+
def finding_from_text(record_text: str) -> FollowupFinding | None:
|
|
218
|
+
"""Parse one JSON record into a finding.
|
|
216
219
|
|
|
217
220
|
Args:
|
|
218
|
-
|
|
221
|
+
record_text: One finding file's text, or one legacy ledger line.
|
|
219
222
|
|
|
220
223
|
Returns:
|
|
221
|
-
The parsed finding, or None when the
|
|
224
|
+
The parsed finding, or None when the text carries no complete record.
|
|
222
225
|
"""
|
|
223
|
-
stripped_line = ledger_line.strip()
|
|
224
|
-
if not stripped_line:
|
|
225
|
-
return None
|
|
226
226
|
try:
|
|
227
|
-
parsed_record = json.loads(
|
|
227
|
+
parsed_record = json.loads(record_text)
|
|
228
228
|
except json.JSONDecodeError:
|
|
229
229
|
return None
|
|
230
230
|
if not isinstance(parsed_record, dict):
|
|
@@ -232,9 +232,7 @@ def _finding_from_line(ledger_line: str) -> FollowupFinding | None:
|
|
|
232
232
|
rule_id = parsed_record.get(RULE_ID_KEY)
|
|
233
233
|
file_path = parsed_record.get(FILE_PATH_KEY)
|
|
234
234
|
message = parsed_record.get(MESSAGE_KEY)
|
|
235
|
-
if not isinstance(
|
|
236
|
-
return None
|
|
237
|
-
if not isinstance(message, str):
|
|
235
|
+
if not all(isinstance(each, str) for each in (rule_id, file_path, message)):
|
|
238
236
|
return None
|
|
239
237
|
return FollowupFinding(
|
|
240
238
|
rule_id,
|
|
@@ -252,7 +250,7 @@ def _text_field(
|
|
|
252
250
|
"""Read one text field of a ledger record.
|
|
253
251
|
|
|
254
252
|
Args:
|
|
255
|
-
all_record_fields: The mapping one
|
|
253
|
+
all_record_fields: The mapping one record parsed into.
|
|
256
254
|
field_key: The key to read.
|
|
257
255
|
fallback: The value a record written before this field carried it takes.
|
|
258
256
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Clear and normalize the follow-up ledger directory.
|
|
2
|
+
|
|
3
|
+
``cde followup clear`` and ``cde followup dedupe`` run these. The gates that
|
|
4
|
+
record findings use only ``followup_ledger``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import NamedTuple
|
|
12
|
+
|
|
13
|
+
_hooks_directory = str(Path(__file__).resolve().parent)
|
|
14
|
+
if _hooks_directory not in sys.path:
|
|
15
|
+
sys.path.insert(0, _hooks_directory)
|
|
16
|
+
|
|
17
|
+
from followup_ledger import (
|
|
18
|
+
all_finding_files,
|
|
19
|
+
all_record_paths,
|
|
20
|
+
finding_from_text,
|
|
21
|
+
finding_path,
|
|
22
|
+
followup_directory,
|
|
23
|
+
record_followup_finding,
|
|
24
|
+
)
|
|
25
|
+
from hooks_constants.followup_ledger_constants import (
|
|
26
|
+
LEDGER_ENCODING,
|
|
27
|
+
LEGACY_LEDGER_FILE_NAME,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def clear_recorded_findings(repository_root: Path) -> None:
|
|
32
|
+
"""Remove every recorded finding and any legacy ledger file.
|
|
33
|
+
|
|
34
|
+
The directory and its ``.gitignore`` stay. A file that cannot be removed
|
|
35
|
+
is left in place.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
repository_root: The repository whose ledger to empty.
|
|
39
|
+
"""
|
|
40
|
+
directory = followup_directory(repository_root)
|
|
41
|
+
all_paths = [*all_record_paths(directory), directory / LEGACY_LEDGER_FILE_NAME]
|
|
42
|
+
for each_path in all_paths:
|
|
43
|
+
try:
|
|
44
|
+
each_path.unlink(missing_ok=True)
|
|
45
|
+
except OSError:
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LedgerNormalization(NamedTuple):
|
|
50
|
+
"""What one ``normalize_ledger`` run changed.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
migrated_count: Legacy ledger lines written as finding files.
|
|
54
|
+
removed_count: Finding files removed as duplicates.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
migrated_count: int
|
|
58
|
+
removed_count: int
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def normalize_ledger(repository_root: Path) -> LedgerNormalization:
|
|
62
|
+
"""Move every record to the file its tracking key names.
|
|
63
|
+
|
|
64
|
+
A legacy ``smells.jsonl`` ledger is split into one file per finding and
|
|
65
|
+
removed. A finding file whose name does not match its tracking key moves
|
|
66
|
+
to the matching name, or is removed when that name already holds the
|
|
67
|
+
finding.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
repository_root: The repository whose ledger to normalize.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
How many legacy lines moved and how many duplicate files left.
|
|
74
|
+
"""
|
|
75
|
+
migrated_count = _migrate_legacy_ledger(repository_root)
|
|
76
|
+
all_pairs = all_finding_files(repository_root)
|
|
77
|
+
all_readable_paths = {each_path for each_path, _each_finding in all_pairs}
|
|
78
|
+
removed_count = sum(
|
|
79
|
+
_settle_finding_file(
|
|
80
|
+
each_path,
|
|
81
|
+
finding_path(repository_root, each_finding),
|
|
82
|
+
all_readable_paths,
|
|
83
|
+
)
|
|
84
|
+
for each_path, each_finding in all_pairs
|
|
85
|
+
)
|
|
86
|
+
return LedgerNormalization(migrated_count, removed_count)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _settle_finding_file(
|
|
90
|
+
current_path: Path, expected_path: Path, all_readable_paths: set[Path]
|
|
91
|
+
) -> int:
|
|
92
|
+
if current_path == expected_path:
|
|
93
|
+
return 0
|
|
94
|
+
try:
|
|
95
|
+
if expected_path in all_readable_paths:
|
|
96
|
+
current_path.unlink()
|
|
97
|
+
return 1
|
|
98
|
+
if not expected_path.exists():
|
|
99
|
+
current_path.rename(expected_path)
|
|
100
|
+
except OSError:
|
|
101
|
+
return 0
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _migrate_legacy_ledger(repository_root: Path) -> int:
|
|
106
|
+
legacy_path = followup_directory(repository_root) / LEGACY_LEDGER_FILE_NAME
|
|
107
|
+
try:
|
|
108
|
+
legacy_text = legacy_path.read_text(encoding=LEDGER_ENCODING)
|
|
109
|
+
except (OSError, UnicodeError):
|
|
110
|
+
return 0
|
|
111
|
+
all_legacy_findings = [
|
|
112
|
+
each_finding
|
|
113
|
+
for each_finding in map(finding_from_text, legacy_text.splitlines())
|
|
114
|
+
if each_finding is not None
|
|
115
|
+
]
|
|
116
|
+
for each_finding in all_legacy_findings:
|
|
117
|
+
record_followup_finding(repository_root, each_finding)
|
|
118
|
+
if not all(
|
|
119
|
+
finding_path(repository_root, each_finding).is_file()
|
|
120
|
+
for each_finding in all_legacy_findings
|
|
121
|
+
):
|
|
122
|
+
return 0
|
|
123
|
+
try:
|
|
124
|
+
legacy_path.unlink()
|
|
125
|
+
except OSError:
|
|
126
|
+
return 0
|
|
127
|
+
return len(all_legacy_findings)
|
package/hooks/hooks.json
CHANGED
|
@@ -332,17 +332,6 @@
|
|
|
332
332
|
]
|
|
333
333
|
}
|
|
334
334
|
],
|
|
335
|
-
"Stop": [
|
|
336
|
-
{
|
|
337
|
-
"hooks": [
|
|
338
|
-
{
|
|
339
|
-
"type": "command",
|
|
340
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/session_title_stop_gate.py",
|
|
341
|
-
"timeout": 10
|
|
342
|
-
}
|
|
343
|
-
]
|
|
344
|
-
}
|
|
345
|
-
],
|
|
346
335
|
"PostToolUse": [
|
|
347
336
|
{
|
|
348
337
|
"matcher": "Write|Edit",
|
|
@@ -26,7 +26,11 @@ ALL_ARTIFACT_DESIGN_SKILL_NAMES = frozenset({"artifact-design"})
|
|
|
26
26
|
TEMPLATES_DIRECTORY = Path(__file__).resolve().parents[2] / "docs" / "templates"
|
|
27
27
|
ARTIFACT_TEMPLATE_PATH = TEMPLATES_DIRECTORY / "artifact-page" / "README.md"
|
|
28
28
|
HTML_PLAN_TEMPLATE_README_PATH = TEMPLATES_DIRECTORY / "html-plan" / "README.md"
|
|
29
|
-
POINTER_TEXT_PREFIX =
|
|
29
|
+
POINTER_TEXT_PREFIX = (
|
|
30
|
+
"Pick the visual by scope. For one flow or one comparison, draw an inline SVG"
|
|
31
|
+
" flowchart artifact with minimal text, using the artifact-diagramming skill."
|
|
32
|
+
" For a multi-part plan or evidence, build an html-plan page following "
|
|
33
|
+
)
|
|
30
34
|
POINTER_ALTERNATIVE_PREFIX = (
|
|
31
35
|
". When the request names the older artifact page template,"
|
|
32
36
|
" pick the one that fits the ask from "
|
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
__all__ = [
|
|
6
|
-
"
|
|
6
|
+
"ALL_FOLLOWUP_DIRECTORY_SEGMENTS",
|
|
7
|
+
"LEGACY_LEDGER_FILE_NAME",
|
|
8
|
+
"FINDING_FILE_SUFFIX",
|
|
9
|
+
"FINDING_FILE_PATTERN",
|
|
10
|
+
"FINDING_FILE_NAME_HASH_LENGTH",
|
|
7
11
|
"LEDGER_ENCODING",
|
|
8
|
-
"
|
|
12
|
+
"LEDGER_CREATE_MODE",
|
|
13
|
+
"LEDGER_JSON_INDENT",
|
|
9
14
|
"LEDGER_IGNORE_FILE_NAME",
|
|
10
15
|
"LEDGER_IGNORE_TEXT",
|
|
11
16
|
"RULE_ID_KEY",
|
|
@@ -28,9 +33,14 @@ __all__ = [
|
|
|
28
33
|
"ALL_SEVERITY_NAMES",
|
|
29
34
|
]
|
|
30
35
|
|
|
31
|
-
|
|
36
|
+
ALL_FOLLOWUP_DIRECTORY_SEGMENTS: tuple[str, ...] = (".claude", "followups")
|
|
37
|
+
LEGACY_LEDGER_FILE_NAME: str = "smells.jsonl"
|
|
38
|
+
FINDING_FILE_SUFFIX: str = ".json"
|
|
39
|
+
FINDING_FILE_PATTERN: str = "*.json"
|
|
40
|
+
FINDING_FILE_NAME_HASH_LENGTH: int = 16
|
|
32
41
|
LEDGER_ENCODING: str = "utf-8"
|
|
33
|
-
|
|
42
|
+
LEDGER_CREATE_MODE: str = "x"
|
|
43
|
+
LEDGER_JSON_INDENT: int = 2
|
|
34
44
|
LEDGER_IGNORE_FILE_NAME: str = ".gitignore"
|
|
35
45
|
LEDGER_IGNORE_TEXT: str = "*\n"
|
|
36
46
|
|
|
@@ -1,21 +1,14 @@
|
|
|
1
|
-
"""Constants for session_title_format_gate.py
|
|
1
|
+
"""Constants for session_title_format_gate.py."""
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
4
|
|
|
5
5
|
TITLE_TOOL_NAME_PATTERN = re.compile(r"^mcp__.+__set_session_title$")
|
|
6
|
-
REMOTE_SESSION_ENVIRONMENT_VARIABLE = "CLAUDE_CODE_REMOTE_SESSION_ID"
|
|
7
6
|
TITLE_INPUT_KEY = "title"
|
|
8
7
|
TOOL_NAME_KEY = "tool_name"
|
|
9
8
|
TOOL_INPUT_KEY = "tool_input"
|
|
10
|
-
TRANSCRIPT_PATH_KEY = "transcript_path"
|
|
11
|
-
STOP_HOOK_ACTIVE_KEY = "stop_hook_active"
|
|
12
9
|
FORMAT_GATE_EVENT_NAME = "PreToolUse"
|
|
13
|
-
STOP_GATE_EVENT_NAME = "Stop"
|
|
14
10
|
ALLOW_EXIT_CODE = 0
|
|
15
11
|
BLOCK_EXIT_CODE = 2
|
|
16
|
-
BLOCK_DECISION = "block"
|
|
17
|
-
DEFERRED_TOOL_ATTACHMENT_TYPES = frozenset({"deferred_tools_delta", "deferred_tools_record"})
|
|
18
|
-
HOOK_BLOCKING_ERROR_ATTACHMENT_TYPE = "hook_blocking_error"
|
|
19
12
|
|
|
20
13
|
STATUS_PREFIX_PATTERN = re.compile("^(\U0001f6a9|\u2705|\u23f3)\ufe0f? (?=\\S)")
|
|
21
14
|
MAXIMUM_NAME_LENGTH = 25
|
|
@@ -38,24 +31,3 @@ FORMAT_RETRY_INSTRUCTION = (
|
|
|
38
31
|
" the main change or outcome in concrete nouns, such as"
|
|
39
32
|
" '\u23f3 Broker gate + replay rule'."
|
|
40
33
|
)
|
|
41
|
-
|
|
42
|
-
STOP_BLOCK_REASON = (
|
|
43
|
-
"Before you end this turn, set the session title to its current status."
|
|
44
|
-
" Call the session title tool ({tool_name}); when it asks for a session id,"
|
|
45
|
-
" get it from get_session with no session_id."
|
|
46
|
-
" Use the shape '<emoji> <name>'."
|
|
47
|
-
" Emoji: \U0001f6a9 when the user must act (a question, an approval, or a step only the user can do);"
|
|
48
|
-
" \u2705 when the work is done (merged, or nothing left);"
|
|
49
|
-
" \u23f3 while work, CI or a merge queue still runs and nothing waits on the user."
|
|
50
|
-
" Name: 25 characters or fewer, the main change or outcome in concrete nouns,"
|
|
51
|
-
" two parts joined with ' + ', sentence case, no end punctuation,"
|
|
52
|
-
" no dates, IDs, branch names or filler words."
|
|
53
|
-
" Rename it when the scope of the work has changed."
|
|
54
|
-
" The title is a silent step: make the call and end the turn."
|
|
55
|
-
" Say nothing to the user about the title, its emoji or this reminder."
|
|
56
|
-
" When the user already has your answer this turn, end with no text."
|
|
57
|
-
" Next time, set the title before your final reply."
|
|
58
|
-
)
|
|
59
|
-
REMOTE_TITLE_TOOL_NAME = "mcp__claude-code-remote__set_session_title"
|
|
60
|
-
REMOTE_SERVER_TOOL_PREFIX = "mcp__claude-code-remote__"
|
|
61
|
-
UNKNOWN_TITLE_TOOL_NAME = "its name ends in __set_session_title"
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""PreToolUse hook:
|
|
2
|
+
"""PreToolUse hook: route an agent about to build an artifact page by scope.
|
|
3
3
|
|
|
4
4
|
Registered on ``Artifact|Skill``. It adds one line of ``additionalContext``
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
when a session starts a new artifact page. One flow or one comparison is an
|
|
6
|
+
inline SVG flowchart with minimal text. A multi-part plan or evidence is an
|
|
7
|
+
html-plan page from ``docs/templates/html-plan/README.md``, and
|
|
8
|
+
``docs/templates/artifact-page/README.md`` stays the alternative:
|
|
8
9
|
|
|
9
10
|
::
|
|
10
11
|
|
|
@@ -44,6 +44,19 @@ def test_artifact_quickstart_gets_the_template_pointer() -> None:
|
|
|
44
44
|
assert "permissionDecision" not in specific_output
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def test_pointer_routes_a_simple_visual_to_a_flowchart_ahead_of_html_plan() -> None:
|
|
48
|
+
hook_output = artifact_template_pointer.decide_hook_output(
|
|
49
|
+
_payload("Artifact", {"action": "quickstart", "intent": "other"})
|
|
50
|
+
)
|
|
51
|
+
assert hook_output is not None
|
|
52
|
+
pointer_text = hook_output["hookSpecificOutput"]["additionalContext"]
|
|
53
|
+
assert pointer_text.index("one flow or one comparison") < pointer_text.index("flowchart")
|
|
54
|
+
assert pointer_text.index("flowchart") < pointer_text.index("multi-part plan or evidence")
|
|
55
|
+
assert pointer_text.index("multi-part plan or evidence") < pointer_text.index(
|
|
56
|
+
str(HTML_PLAN_TEMPLATE_README_PATH)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
47
60
|
def test_artifact_design_skill_gets_the_template_pointer() -> None:
|
|
48
61
|
hook_output = artifact_template_pointer.decide_hook_output(
|
|
49
62
|
_payload("Skill", {"skill": "artifact-design"})
|