claude-dev-env 8.4.3 → 8.5.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.
@@ -0,0 +1,66 @@
1
+ ---
2
+ description: Fix every non-breaking finding the follow-up ledger holds and open a draft PR for it
3
+ argument-hint: [repository root, or blank for the current repository]
4
+ ---
5
+
6
+ Fix the non-breaking findings a local gate recorded rather than blocked on,
7
+ and deliver them as one draft pull request.
8
+
9
+ The repository root is `$ARGUMENTS`, or the current repository when that is
10
+ blank.
11
+
12
+ Use `rules/asd-ste100-language.md` for user-facing wording.
13
+
14
+ ## 1. Read the ledger
15
+
16
+ Run `cde followup brief --repository-root <root>`.
17
+
18
+ When it prints `no follow-ups recorded`, report that and stop. There is no
19
+ work.
20
+
21
+ Otherwise the brief lists one line per finding, each carrying the rule
22
+ identifier, the file, and the message.
23
+
24
+ ## 2. Group the findings
25
+
26
+ Group the lines by rule identifier. A rule with several findings takes one
27
+ pass over every file it names, so the same fix shape lands once.
28
+
29
+ Read each named file before editing it. A recorded finding names the state of
30
+ the file at the moment the gate ran, so confirm the finding still holds.
31
+ Keep a finding the current file still carries. Drop the rest.
32
+
33
+ ## 3. Fix each group
34
+
35
+ Each finding is non-breaking, so the change that raised it already shipped.
36
+ Keep every fix mechanical. Touch no behavior, change no signature, and add no
37
+ abstraction.
38
+
39
+ Run the repository's own fast checks over the files you changed. Where a
40
+ finding names a generated file, regenerate it with the repository's tooling.
41
+
42
+ ## 4. Open the pull request
43
+
44
+ Branch from the repository's default branch. Commit each rule group on its
45
+ own, with a message naming the rule identifier and the files.
46
+
47
+ Open the pull request as a draft. Write the body as:
48
+
49
+ - One `Before:` paragraph saying which findings stood open.
50
+ - One `After:` paragraph saying what the tree now holds.
51
+ - A short `How` paragraph naming the rule groups and the files each touched.
52
+
53
+ Push, then confirm the required checks report on the branch head.
54
+
55
+ ## 5. Empty the ledger
56
+
57
+ Once the pull request is open, run
58
+ `cde followup clear --repository-root <root>`.
59
+
60
+ A finding the pull request left alone goes back in the ledger with
61
+ `cde followup ingest`, so the next pass picks it up.
62
+
63
+ ## Report
64
+
65
+ Name the pull request link, the rule groups fixed, the findings dropped as no
66
+ longer present, and anything left in the ledger.
@@ -0,0 +1,143 @@
1
+ """Append-only ledger of non-breaking findings awaiting a follow-up fix.
2
+
3
+ A gate that finds a breaking defect stops the commit, the push, or the tool
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.
7
+
8
+ Every write is fail-safe. A ledger that cannot be created or appended to leaves
9
+ the calling gate's decision unchanged, so recording a finding never becomes a
10
+ new way for a gate to fail.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import NamedTuple
19
+
20
+ _hooks_directory = str(Path(__file__).resolve().parent)
21
+ if _hooks_directory not in sys.path:
22
+ sys.path.insert(0, _hooks_directory)
23
+
24
+ from hooks_constants.followup_ledger_constants import (
25
+ FILE_PATH_KEY,
26
+ ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS,
27
+ LEDGER_APPEND_MODE,
28
+ LEDGER_ENCODING,
29
+ MESSAGE_KEY,
30
+ RULE_ID_KEY,
31
+ )
32
+
33
+
34
+ class FollowupFinding(NamedTuple):
35
+ """One non-breaking finding a later pull request resolves.
36
+
37
+ Attributes:
38
+ rule_id: Stable identifier of the check that raised the finding.
39
+ file_path: Repository-relative path the finding names.
40
+ message: The text a reader acts on.
41
+ """
42
+
43
+ rule_id: str
44
+ file_path: str
45
+ message: str
46
+
47
+
48
+ def followup_ledger_path(repository_root: Path) -> Path:
49
+ """Return the ledger path for one repository.
50
+
51
+ Args:
52
+ repository_root: The repository whose ledger to address.
53
+
54
+ Returns:
55
+ The absolute path of that repository's follow-up ledger.
56
+ """
57
+ return repository_root.joinpath(*ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS)
58
+
59
+
60
+ def record_followup_finding(repository_root: Path, finding: FollowupFinding) -> None:
61
+ """Append one finding to the repository's ledger, once.
62
+
63
+ A finding already present in the ledger is left alone, so a gate that runs
64
+ on every commit records a standing smell a single time. Every filesystem
65
+ error is swallowed, so a ledger failure leaves the caller's gate decision
66
+ unchanged.
67
+
68
+ Args:
69
+ repository_root: The repository whose ledger receives the finding.
70
+ finding: The non-breaking finding to record.
71
+ """
72
+ if finding in all_recorded_findings(repository_root):
73
+ return
74
+
75
+ ledger_path = followup_ledger_path(repository_root)
76
+ record_text = json.dumps(
77
+ {
78
+ RULE_ID_KEY: finding.rule_id,
79
+ FILE_PATH_KEY: finding.file_path,
80
+ MESSAGE_KEY: finding.message,
81
+ }
82
+ )
83
+ try:
84
+ ledger_path.parent.mkdir(parents=True, exist_ok=True)
85
+ with ledger_path.open(LEDGER_APPEND_MODE, encoding=LEDGER_ENCODING) as ledger_file:
86
+ ledger_file.write(record_text + "\n")
87
+ except OSError:
88
+ return
89
+
90
+
91
+ def all_recorded_findings(repository_root: Path) -> tuple[FollowupFinding, ...]:
92
+ """Return every finding the repository's ledger holds, in record order.
93
+
94
+ An absent or unreadable ledger reads as empty, and a line that does not
95
+ parse into a complete record is skipped, so one damaged line never hides
96
+ the findings around it.
97
+
98
+ Args:
99
+ repository_root: The repository whose ledger to read.
100
+
101
+ Returns:
102
+ The recorded findings, in the order they were appended.
103
+ """
104
+ ledger_path = followup_ledger_path(repository_root)
105
+ try:
106
+ ledger_text = ledger_path.read_text(encoding=LEDGER_ENCODING)
107
+ except (OSError, UnicodeError):
108
+ return ()
109
+
110
+ all_findings: list[FollowupFinding] = []
111
+ for each_line in ledger_text.splitlines():
112
+ each_finding = _finding_from_line(each_line)
113
+ if each_finding is not None:
114
+ all_findings.append(each_finding)
115
+ return tuple(all_findings)
116
+
117
+
118
+ def _finding_from_line(ledger_line: str) -> FollowupFinding | None:
119
+ """Parse one ledger line into a finding.
120
+
121
+ Args:
122
+ ledger_line: One line of the ledger file.
123
+
124
+ Returns:
125
+ The parsed finding, or None when the line carries no complete record.
126
+ """
127
+ stripped_line = ledger_line.strip()
128
+ if not stripped_line:
129
+ return None
130
+ try:
131
+ parsed_record = json.loads(stripped_line)
132
+ except json.JSONDecodeError:
133
+ return None
134
+ if not isinstance(parsed_record, dict):
135
+ return None
136
+ rule_id = parsed_record.get(RULE_ID_KEY)
137
+ file_path = parsed_record.get(FILE_PATH_KEY)
138
+ message = parsed_record.get(MESSAGE_KEY)
139
+ if not isinstance(rule_id, str) or not isinstance(file_path, str):
140
+ return None
141
+ if not isinstance(message, str):
142
+ return None
143
+ return FollowupFinding(rule_id, file_path, message)
@@ -0,0 +1,27 @@
1
+ """Constants for the non-breaking-finding follow-up ledger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS",
7
+ "LEDGER_ENCODING",
8
+ "LEDGER_APPEND_MODE",
9
+ "RULE_ID_KEY",
10
+ "FILE_PATH_KEY",
11
+ "MESSAGE_KEY",
12
+ "SEVERITY_BREAKING",
13
+ "SEVERITY_SMELL",
14
+ "ALL_SEVERITY_NAMES",
15
+ ]
16
+
17
+ ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS: tuple[str, ...] = (".claude", "followups", "smells.jsonl")
18
+ LEDGER_ENCODING: str = "utf-8"
19
+ LEDGER_APPEND_MODE: str = "a"
20
+
21
+ RULE_ID_KEY: str = "rule_id"
22
+ FILE_PATH_KEY: str = "file_path"
23
+ MESSAGE_KEY: str = "message"
24
+
25
+ SEVERITY_BREAKING: str = "breaking"
26
+ SEVERITY_SMELL: str = "smell"
27
+ ALL_SEVERITY_NAMES: frozenset[str] = frozenset({SEVERITY_BREAKING, SEVERITY_SMELL})
@@ -0,0 +1,83 @@
1
+ """Behavior tests for the non-breaking-finding follow-up ledger."""
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ _hooks_directory = str(Path(__file__).resolve().parent)
8
+ if _hooks_directory not in sys.path:
9
+ sys.path.insert(0, _hooks_directory)
10
+
11
+ from followup_ledger import (
12
+ FollowupFinding,
13
+ all_recorded_findings,
14
+ followup_ledger_path,
15
+ record_followup_finding,
16
+ )
17
+
18
+
19
+ def test_record_followup_finding_appends_one_readable_record(tmp_path: Path) -> None:
20
+ finding = FollowupFinding(
21
+ rule_id="instruction-filename",
22
+ file_path="docs/Claude.md",
23
+ message="Use the canonical filename: docs/Claude.md",
24
+ )
25
+
26
+ record_followup_finding(tmp_path, finding)
27
+
28
+ assert all_recorded_findings(tmp_path) == (finding,)
29
+
30
+
31
+ def test_record_followup_finding_keeps_distinct_findings_apart(tmp_path: Path) -> None:
32
+ first = FollowupFinding("instruction-filename", "docs/Claude.md", "wrong name")
33
+ second = FollowupFinding("instruction-mode", "docs/AGENTS.md", "wrong mode")
34
+
35
+ record_followup_finding(tmp_path, first)
36
+ record_followup_finding(tmp_path, second)
37
+
38
+ assert all_recorded_findings(tmp_path) == (first, second)
39
+
40
+
41
+ def test_record_followup_finding_records_a_repeat_once(tmp_path: Path) -> None:
42
+ finding = FollowupFinding("instruction-mode", "docs/AGENTS.md", "wrong mode")
43
+
44
+ record_followup_finding(tmp_path, finding)
45
+ record_followup_finding(tmp_path, finding)
46
+
47
+ assert all_recorded_findings(tmp_path) == (finding,)
48
+
49
+
50
+ def test_all_recorded_findings_is_empty_without_a_ledger(tmp_path: Path) -> None:
51
+ assert all_recorded_findings(tmp_path) == ()
52
+
53
+
54
+ def test_all_recorded_findings_skips_an_unreadable_line(tmp_path: Path) -> None:
55
+ finding = FollowupFinding("instruction-mode", "docs/AGENTS.md", "wrong mode")
56
+ record_followup_finding(tmp_path, finding)
57
+ ledger_path = followup_ledger_path(tmp_path)
58
+ with ledger_path.open("a", encoding="utf-8") as ledger_file:
59
+ ledger_file.write("{ not json\n")
60
+
61
+ assert all_recorded_findings(tmp_path) == (finding,)
62
+
63
+
64
+ def test_record_followup_finding_writes_one_json_object_per_line(tmp_path: Path) -> None:
65
+ record_followup_finding(
66
+ tmp_path, FollowupFinding("instruction-mode", "docs/AGENTS.md", "wrong mode")
67
+ )
68
+
69
+ all_lines = followup_ledger_path(tmp_path).read_text(encoding="utf-8").splitlines()
70
+
71
+ assert len(all_lines) == 1
72
+ assert json.loads(all_lines[0])["rule_id"] == "instruction-mode"
73
+
74
+
75
+ def test_record_followup_finding_swallows_a_write_failure(tmp_path: Path) -> None:
76
+ blocking_file = tmp_path / ".claude"
77
+ blocking_file.write_text("not a directory", encoding="utf-8")
78
+
79
+ record_followup_finding(
80
+ tmp_path, FollowupFinding("instruction-mode", "docs/AGENTS.md", "wrong mode")
81
+ )
82
+
83
+ assert all_recorded_findings(tmp_path) == ()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.4.3",
3
+ "version": "8.5.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,72 @@
1
+ # Flag Non-Breaking Findings
2
+
3
+ **When this applies:** Writing or changing a local gate — a git hook, a
4
+ pre-commit entry, a Claude tool-use hook — and deciding what one of its checks
5
+ does when it finds something.
6
+
7
+ ## Rule
8
+
9
+ A gate blocks on a breaking finding and records a non-breaking one.
10
+
11
+ A **breaking** finding means the change is wrong: a bug, a secret in the
12
+ tree, a broken test, a syntax error, an instruction file that fails to
13
+ load. The gate fails and the work stops there.
14
+
15
+ A **smell** finding means the change reads poorly: a length limit, a naming
16
+ convention, a prose term, a comment rule, a structural preference. The gate
17
+ records it and lets the commit, the push, or the tool call proceed. A later
18
+ pull request carries the fix.
19
+
20
+ Every check a gate runs declares which of the two it is. A check that cannot
21
+ say which one it raises is a breaking check until someone decides otherwise.
22
+
23
+ ## Why
24
+
25
+ A smell that blocks a commit stops delivery for something the reader would
26
+ have fixed in the next pass anyway. The writer then reaches for the bypass
27
+ flag, and the bypass turns off the breaking checks beside it. The gate that
28
+ blocks on everything ends up enforcing nothing.
29
+
30
+ Recording the smell keeps both halves working. The breaking checks stay
31
+ sharp because nobody routes around them, and the smells still get fixed
32
+ because the ledger holds them until a pull request does.
33
+
34
+ ## The ledger
35
+
36
+ A recorded finding lands in `.claude/followups/smells.jsonl` at the
37
+ repository root, one JSON object per line carrying the rule identifier, the
38
+ repository-relative path, and the message. `hooks/followup_ledger.py` writes
39
+ and reads it. Every write there is fail-safe, so a ledger failure leaves the
40
+ gate's decision unchanged.
41
+
42
+ The ledger is per-checkout state and stays out of the repository.
43
+
44
+ ## Reading and clearing it
45
+
46
+ | Command | What it does |
47
+ |---|---|
48
+ | `cde followup list` | Names every recorded follow-up |
49
+ | `cde followup ingest REPORT` | Records the diagnostics a policy-lint JSON report carries |
50
+ | `cde followup brief` | Writes the task an agent fixes them from |
51
+ | `cde followup clear` | Empties the ledger |
52
+
53
+ The `/fix-followups` command drives the whole pass: it reads the brief, fixes
54
+ each rule group, opens a draft pull request, and clears the ledger.
55
+
56
+ ## Worked example
57
+
58
+ `scripts/validate_instruction_pairs.py` raises five findings and splits them
59
+ on this line. A missing governing `AGENTS.md`, an import text that differs,
60
+ and an instruction path that is not a regular file each stop the instructions
61
+ loading, so the gate fails. A non-canonical filename and a Git mode other than
62
+ 100644 leave the instructions loading, so the gate records them and passes.
63
+
64
+ `SEVERITY_BY_RULE_ID` in that module is the whole declaration. Add a check,
65
+ add its row.
66
+
67
+ ## Sibling rules
68
+
69
+ | Rule | Role |
70
+ |---|---|
71
+ | [`ci-owns-the-gate.md`](ci-owns-the-gate.md) | The full check suite runs once, on CI |
72
+ | [`git-workflow.md`](git-workflow.md) | A red required check blocks the branch |
@@ -0,0 +1,72 @@
1
+ """Constants for the follow-up ledger command and the instruction-pair gate.
2
+
3
+ Script-level scalar constants live in dev_env_scripts_constants alongside
4
+ timing.py and the other per-script constant modules.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ FILENAME_RULE_ID: str = "instruction-filename"
10
+ REGULAR_FILE_RULE_ID: str = "instruction-regular-file"
11
+ GIT_MODE_RULE_ID: str = "instruction-git-mode"
12
+ MISSING_AGENTS_RULE_ID: str = "instruction-missing-agents"
13
+ IMPORT_TEXT_RULE_ID: str = "instruction-import-text"
14
+
15
+ SEVERITY_BREAKING: str = "breaking"
16
+ SEVERITY_SMELL: str = "smell"
17
+ SEVERITY_BY_RULE_ID: dict[str, str] = {
18
+ FILENAME_RULE_ID: SEVERITY_SMELL,
19
+ REGULAR_FILE_RULE_ID: SEVERITY_BREAKING,
20
+ GIT_MODE_RULE_ID: SEVERITY_SMELL,
21
+ MISSING_AGENTS_RULE_ID: SEVERITY_BREAKING,
22
+ IMPORT_TEXT_RULE_ID: SEVERITY_BREAKING,
23
+ }
24
+
25
+ GATE_PASSED_EXIT_CODE: int = 0
26
+ GATE_FAILED_EXIT_CODE: int = 1
27
+ RECORDED_SMELL_TEMPLATE: str = "recorded for follow-up: %s"
28
+
29
+ LIST_COMMAND_NAME: str = "list"
30
+ INGEST_COMMAND_NAME: str = "ingest"
31
+ BRIEF_COMMAND_NAME: str = "brief"
32
+ CLEAR_COMMAND_NAME: str = "clear"
33
+ ALL_COMMAND_NAMES: tuple[str, ...] = (
34
+ LIST_COMMAND_NAME,
35
+ INGEST_COMMAND_NAME,
36
+ BRIEF_COMMAND_NAME,
37
+ CLEAR_COMMAND_NAME,
38
+ )
39
+
40
+ SUCCESS_EXIT_CODE: int = 0
41
+ INVALID_INPUT_EXIT_CODE: int = 2
42
+
43
+ DIAGNOSTICS_KEY: str = "diagnostics"
44
+ DIAGNOSTIC_RULE_ID_KEY: str = "rule_id"
45
+ DIAGNOSTIC_MESSAGE_KEY: str = "message"
46
+ DIAGNOSTIC_LOCATION_KEY: str = "location"
47
+ LOCATION_PATH_KEY: str = "path"
48
+ ABSENT_LOCATION_PATH: str = ""
49
+
50
+ LINE_SEPARATOR: str = "\n"
51
+ EMPTY_LEDGER_MESSAGE: str = "no follow-ups recorded"
52
+ UNREADABLE_REPORT_TEMPLATE: str = "cannot read the lint report: {report_path}"
53
+ USAGE_TEXT: str = (
54
+ "Usage: followup_cli.py <list|ingest|brief|clear> [--repository-root PATH]\n"
55
+ " list Name every recorded follow-up\n"
56
+ " ingest REPORT Record every diagnostic in a policy-lint JSON report\n"
57
+ " brief Write the task an agent works the follow-ups from\n"
58
+ " clear Empty the ledger"
59
+ )
60
+
61
+ BRIEF_HEADER: str = (
62
+ "Fix every finding below in one change, then open a draft pull request "
63
+ "for it. Each finding is non-breaking, so the change that raised it "
64
+ "already shipped. Keep the fixes mechanical, touch no behavior, and run "
65
+ "the repository's own checks before pushing."
66
+ )
67
+ BRIEF_FINDING_TEMPLATE: str = "- [{rule_id}] {file_path}: {message}"
68
+ BRIEF_FOOTER_TEMPLATE: str = (
69
+ "Once the pull request is open, empty the ledger with "
70
+ "`python {command_path} clear`."
71
+ )
72
+ LIST_FINDING_TEMPLATE: str = "{rule_id}\t{file_path}\t{message}"