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.
Files changed (46) hide show
  1. package/.agents/skills/correction/SKILL.md +19 -16
  2. package/.agents/skills/correction/references/filing.md +1 -1
  3. package/.agents/skills/correction/scripts/correction_task_card.py +194 -0
  4. package/.agents/skills/correction/scripts/correction_task_card_constants/__init__.py +0 -0
  5. package/.agents/skills/correction/scripts/correction_task_card_constants/config/__init__.py +0 -0
  6. package/.agents/skills/correction/scripts/correction_task_card_constants/config/constants.py +38 -0
  7. package/.agents/skills/correction/scripts/test_correction_task_card.py +142 -0
  8. package/bin/cde.mjs +1 -1
  9. package/bin/cde.test.mjs +1 -1
  10. package/bin/install.mjs +1 -0
  11. package/bin/install.test.mjs +1 -0
  12. package/bin/merge_profile_settings.mjs +2 -3
  13. package/bin/merge_profile_settings.test.mjs +5 -5
  14. package/commands/fix-followups.md +5 -2
  15. package/docs/rule-guides/flag-non-breaking-findings.md +3 -5
  16. package/docs/rule-guides/paired-test-coverage.md +1 -1
  17. package/docs/rule-guides/session-title.md +3 -3
  18. package/docs/templates/html-plan/README.md +1 -1
  19. package/hooks/features/conduct-gates.md +0 -3
  20. package/hooks/features/session-context.md +1 -1
  21. package/hooks/followup_ledger.py +106 -108
  22. package/hooks/followup_ledger_maintenance.py +127 -0
  23. package/hooks/hooks.json +0 -11
  24. package/hooks/hooks_constants/artifact_template_pointer_constants.py +5 -1
  25. package/hooks/hooks_constants/followup_ledger_constants.py +14 -4
  26. package/hooks/hooks_constants/session_title_constants.py +1 -29
  27. package/hooks/session/artifact_template_pointer.py +5 -4
  28. package/hooks/session/test_artifact_template_pointer.py +13 -0
  29. package/hooks/test_followup_ledger.py +78 -59
  30. package/hooks/test_followup_ledger_maintenance.py +107 -0
  31. package/package.json +1 -1
  32. package/rules/flag-non-breaking-findings.md +1 -1
  33. package/scripts/dev_env_scripts_constants/followup_constants.py +4 -1
  34. package/scripts/followup_cli.py +11 -12
  35. package/scripts/policy_lint/adapter_configuration.py +0 -1
  36. package/scripts/policy_lint/config/constants.py +0 -1
  37. package/scripts/repository_checks/config/constants.py +0 -6
  38. package/scripts/repository_checks/runner.py +0 -7
  39. package/scripts/test_followup_cli.py +71 -7
  40. package/scripts/tests/test_adapter_configuration.py +2 -5
  41. package/scripts/tests/test_cde_lint.py +3 -4
  42. package/scripts/tests/test_repository_policy.py +1 -3
  43. package/hooks/blocking/session_title_stop_gate.py +0 -217
  44. package/hooks/blocking/test_session_title_stop_gate.py +0 -295
  45. package/scripts/repository_checks/followup_ledger_duplicates.py +0 -55
  46. 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` points an agent that starts an artifact page at `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.
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
@@ -1,20 +1,23 @@
1
- """Append-only ledger of non-breaking findings awaiting a follow-up fix.
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. ``cde followup`` reads the ledger and briefs the agent that
6
- fixes the recorded smells in their own pull request.
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 appended to leaves
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
- ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS,
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
- LEDGER_APPEND_MODE,
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 deduplicated_lines(all_lines: Sequence[str]) -> list[str]:
84
- """Return the lines with every exact repeat removed.
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
- all_lines: The ledger lines, in file order.
94
+ repository_root: The repository whose ledger to address.
91
95
 
92
96
  Returns:
93
- The lines in file order, each exact line once.
97
+ The absolute path of that repository's follow-up directory.
94
98
  """
95
- return list(dict.fromkeys(all_lines))
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
- def followup_ledger_path(repository_root: Path) -> Path:
130
- """Return the ledger path for one repository.
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 to address.
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 that repository's follow-up ledger.
113
+ The absolute path of the finding's file.
137
114
  """
138
- return repository_root.joinpath(*ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS)
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
- """Append one finding to the repository's ledger, once.
122
+ """Write one finding to its own file in the repository's ledger, once.
143
123
 
144
- The ledger first loses every exact repeated line, so lines a
145
- ``merge=union`` merge wrote twice leave on the next write. A finding
146
- already present in the ledger is left alone, so a gate that runs on every
147
- commit records a standing smell a single time. The ledger
148
- directory carries a ``.gitignore`` matching every file in it, so the
149
- ledger stays out of ``git status`` in any repository. Every filesystem
150
- error is swallowed, so a ledger failure leaves the caller's gate decision
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
- deduplicate_ledger(repository_root)
158
- all_recorded_keys = {
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
- ledger_path.parent.mkdir(parents=True, exist_ok=True)
178
- ignore_path = ledger_path.parent / LEDGER_IGNORE_FILE_NAME
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 ledger_path.open(LEDGER_APPEND_MODE, encoding=LEDGER_ENCODING) as ledger_file:
182
- ledger_file.write(record_text + "\n")
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, in record order.
162
+ """Return every finding the repository's ledger holds, ordered by check.
189
163
 
190
- An absent or unreadable ledger reads as empty, and a line that does not
191
- parse into a complete record is skipped, so one damaged line never hides
192
- the findings around it.
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, in the order they were appended.
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
- ledger_text = ledger_path.read_text(encoding=LEDGER_ENCODING)
203
- except (OSError, UnicodeError):
204
- return ()
191
+ return sorted(directory.glob(FINDING_FILE_PATTERN))
192
+ except OSError:
193
+ return []
205
194
 
206
- all_findings: list[FollowupFinding] = []
207
- for each_line in ledger_text.splitlines():
208
- each_finding = _finding_from_line(each_line)
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
- all_findings.append(each_finding)
211
- return tuple(all_findings)
213
+ all_pairs.append((each_path, each_finding))
214
+ return all_pairs
212
215
 
213
216
 
214
- def _finding_from_line(ledger_line: str) -> FollowupFinding | None:
215
- """Parse one ledger line into a finding.
217
+ def finding_from_text(record_text: str) -> FollowupFinding | None:
218
+ """Parse one JSON record into a finding.
216
219
 
217
220
  Args:
218
- ledger_line: One line of the ledger file.
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 line carries no complete record.
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(stripped_line)
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(rule_id, str) or not isinstance(file_path, str):
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 ledger line parsed into.
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 = "Build this artifact page from the html-plan skill, following "
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
- "ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS",
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
- "LEDGER_APPEND_MODE",
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
- ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS: tuple[str, ...] = (".claude", "followups", "smells.jsonl")
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
- LEDGER_APPEND_MODE: str = "a"
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 and session_title_stop_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: point an agent about to build an artifact page at html-plan.
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
- naming ``docs/templates/html-plan/README.md`` as the default and
6
- ``docs/templates/artifact-page/README.md`` as the alternative when a
7
- session starts a new artifact page:
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"})