claude-dev-env 1.41.0 → 1.42.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 (33) hide show
  1. package/CLAUDE.md +8 -0
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +231 -3
  3. package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +56 -2
  4. package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +2 -0
  5. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +121 -4
  6. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +73 -17
  7. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +385 -0
  8. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +33 -0
  9. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +3 -1
  10. package/package.json +1 -1
  11. package/skills/bugteam/scripts/_claude_permissions_common.py +109 -0
  12. package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +51 -2
  13. package/skills/bugteam/scripts/grant_project_claude_permissions.py +115 -4
  14. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +69 -17
  15. package/skills/bugteam/scripts/test_agent_config_carveout.py +356 -0
  16. package/skills/implement/SKILL.md +66 -0
  17. package/skills/implement/scripts/append_note.py +133 -0
  18. package/skills/implement/scripts/config/__init__.py +0 -0
  19. package/skills/implement/scripts/config/notes_constants.py +12 -0
  20. package/skills/implement/scripts/test_append_note.py +191 -0
  21. package/skills/pr-converge/config/constants.py +5 -0
  22. package/skills/pr-converge/scripts/check_bugbot_ci.py +1 -1
  23. package/skills/pr-converge/scripts/check_convergence.py +167 -28
  24. package/skills/pr-converge/scripts/check_pending_reviews.py +1 -1
  25. package/skills/pr-converge/scripts/conftest.py +60 -0
  26. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +1 -1
  27. package/skills/pr-converge/scripts/post_fix_reply.py +1 -1
  28. package/skills/pr-converge/scripts/test_check_bugbot_ci.py +1 -1
  29. package/skills/pr-converge/scripts/test_check_convergence.py +306 -0
  30. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +1 -1
  31. package/skills/refine/SKILL.md +257 -0
  32. package/skills/refine/templates/implementation-notes-template.html +56 -0
  33. package/skills/refine/templates/plan-template.md +60 -0
@@ -0,0 +1,356 @@
1
+ """Behavior tests for the agent-config carve-out and stale-trust-entry purge.
2
+
3
+ Covers two Bugbot findings on PR #467:
4
+ - Deny rules must be written to permissions.deny so agent-config edits
5
+ require explicit per-edit user approval.
6
+ - Trust entries in autoMode.environment must be purged on grant
7
+ (preventing accumulation across template revisions) and removed on
8
+ revoke regardless of the exact template wording.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import pytest
19
+
20
+ _script_directory = str(Path(__file__).resolve().parent)
21
+ if _script_directory not in sys.path:
22
+ sys.path.insert(0, _script_directory)
23
+
24
+ import _claude_permissions_common as common_module
25
+ import grant_project_claude_permissions as grant_module
26
+ import revoke_project_claude_permissions as revoke_module
27
+ from config.claude_permissions_common_constants import (
28
+ ALL_AGENT_CONFIG_DENY_TOOLS,
29
+ ALL_AGENT_CONFIG_PATH_PATTERNS,
30
+ ALL_PERMISSION_ALLOW_TOOLS,
31
+ AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE,
32
+ )
33
+
34
+
35
+ def _make_fake_project(tmp_path: Path) -> Path:
36
+ fake_project_root = tmp_path / "fake_project"
37
+ (fake_project_root / ".claude").mkdir(parents=True)
38
+ return fake_project_root
39
+
40
+
41
+ def _project_path_as_posix(fake_project_root: Path) -> str:
42
+ return str(fake_project_root).replace("\\", "/")
43
+
44
+
45
+ def _redirect_settings_to_fake_path(
46
+ fake_settings_path: Path, monkeypatch: pytest.MonkeyPatch
47
+ ) -> None:
48
+ fake_home_directory = fake_settings_path.parent.parent
49
+ monkeypatch.setattr(Path, "home", classmethod(lambda _cls: fake_home_directory))
50
+
51
+
52
+ def _prepare_fake_home(tmp_path: Path) -> Path:
53
+ fake_home_directory = tmp_path / "fake_home"
54
+ claude_settings_directory = fake_home_directory / ".claude"
55
+ claude_settings_directory.mkdir(parents=True)
56
+ return claude_settings_directory / "settings.json"
57
+
58
+
59
+ def _seed_grant_then_run(
60
+ fake_settings_path: Path,
61
+ fake_project_root: Path,
62
+ monkeypatch: pytest.MonkeyPatch,
63
+ pre_existing_settings: dict[str, Any],
64
+ ) -> None:
65
+ fake_settings_path.write_text(json.dumps(pre_existing_settings), encoding="utf-8")
66
+ _redirect_settings_to_fake_path(fake_settings_path, monkeypatch)
67
+ monkeypatch.chdir(fake_project_root)
68
+ grant_module.grant_permissions_for_current_directory()
69
+
70
+
71
+ def _seed_revoke_then_run(
72
+ fake_settings_path: Path,
73
+ fake_project_root: Path,
74
+ monkeypatch: pytest.MonkeyPatch,
75
+ pre_existing_settings: dict[str, Any],
76
+ ) -> None:
77
+ fake_settings_path.write_text(json.dumps(pre_existing_settings), encoding="utf-8")
78
+ _redirect_settings_to_fake_path(fake_settings_path, monkeypatch)
79
+ monkeypatch.chdir(fake_project_root)
80
+ revoke_module.revoke_permissions_for_current_directory()
81
+
82
+
83
+ def test_grant_writes_deny_rules_for_every_tool_and_pattern(
84
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
85
+ ) -> None:
86
+ fake_project_root = _make_fake_project(tmp_path)
87
+ fake_settings_path = _prepare_fake_home(tmp_path)
88
+ _seed_grant_then_run(
89
+ fake_settings_path, fake_project_root, monkeypatch, pre_existing_settings={}
90
+ )
91
+ capsys.readouterr()
92
+ written_settings = json.loads(fake_settings_path.read_text(encoding="utf-8"))
93
+ deny_list = written_settings["permissions"]["deny"]
94
+ project_path_posix = _project_path_as_posix(fake_project_root)
95
+ for each_tool in ALL_AGENT_CONFIG_DENY_TOOLS:
96
+ for each_pattern in ALL_AGENT_CONFIG_PATH_PATTERNS:
97
+ expected_rule = f"{each_tool}({project_path_posix}/.claude/{each_pattern})"
98
+ assert expected_rule in deny_list, (
99
+ f"deny list missing expected rule {expected_rule!r}"
100
+ )
101
+
102
+
103
+ def test_grant_writes_glob_deny_rules_for_every_agent_config_pattern(
104
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
105
+ ) -> None:
106
+ """Glob must be in the deny tuple so agent-config paths require approval.
107
+
108
+ The AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE promises Edit/Write/Read/Glob
109
+ trust EXCEPT for agent-config files. Glob deny rules are how the EXCEPT
110
+ clause is honored for the Glob tool.
111
+ """
112
+ fake_project_root = _make_fake_project(tmp_path)
113
+ fake_settings_path = _prepare_fake_home(tmp_path)
114
+ _seed_grant_then_run(
115
+ fake_settings_path, fake_project_root, monkeypatch, pre_existing_settings={}
116
+ )
117
+ capsys.readouterr()
118
+ written_settings = json.loads(fake_settings_path.read_text(encoding="utf-8"))
119
+ deny_list = written_settings["permissions"]["deny"]
120
+ project_path_posix = _project_path_as_posix(fake_project_root)
121
+ assert "Glob" in ALL_AGENT_CONFIG_DENY_TOOLS
122
+ assert "Glob" not in ALL_PERMISSION_ALLOW_TOOLS
123
+ for each_pattern in ALL_AGENT_CONFIG_PATH_PATTERNS:
124
+ expected_glob_rule = f"Glob({project_path_posix}/.claude/{each_pattern})"
125
+ assert expected_glob_rule in deny_list, (
126
+ f"deny list missing expected Glob rule {expected_glob_rule!r}"
127
+ )
128
+
129
+
130
+ def test_grant_purges_stale_trust_entries_then_writes_current_template(
131
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
132
+ ) -> None:
133
+ fake_project_root = _make_fake_project(tmp_path)
134
+ fake_settings_path = _prepare_fake_home(tmp_path)
135
+ project_path_posix = _project_path_as_posix(fake_project_root)
136
+ stale_entry_a = (
137
+ f"Trusted local workspace: {project_path_posix}/.claude/** old wording form A"
138
+ )
139
+ stale_entry_b = (
140
+ f"Trusted local workspace: {project_path_posix}/.claude/** "
141
+ f"different earlier wording"
142
+ )
143
+ unrelated_entry = "Some unrelated environment hint"
144
+ pre_existing_settings: dict[str, Any] = {
145
+ "autoMode": {
146
+ "environment": [stale_entry_a, stale_entry_b, unrelated_entry],
147
+ },
148
+ }
149
+ _seed_grant_then_run(
150
+ fake_settings_path,
151
+ fake_project_root,
152
+ monkeypatch,
153
+ pre_existing_settings=pre_existing_settings,
154
+ )
155
+ captured = capsys.readouterr()
156
+ written_settings = json.loads(fake_settings_path.read_text(encoding="utf-8"))
157
+ environment_list = written_settings["autoMode"]["environment"]
158
+ assert stale_entry_a not in environment_list
159
+ assert stale_entry_b not in environment_list
160
+ assert unrelated_entry in environment_list
161
+ matching_trust_entries = [
162
+ each_entry
163
+ for each_entry in environment_list
164
+ if isinstance(each_entry, str)
165
+ and each_entry.startswith("Trusted local workspace:")
166
+ and f"{project_path_posix}/.claude/**" in each_entry
167
+ ]
168
+ assert len(matching_trust_entries) == 1
169
+ assert "Stale auto-mode environment entries purged" in captured.out
170
+
171
+
172
+ def test_revoke_removes_deny_rules(
173
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
174
+ ) -> None:
175
+ fake_project_root = _make_fake_project(tmp_path)
176
+ fake_settings_path = _prepare_fake_home(tmp_path)
177
+ project_path_posix = _project_path_as_posix(fake_project_root)
178
+ all_deny_rules = common_module.build_agent_config_deny_rules(
179
+ project_path_posix,
180
+ ALL_AGENT_CONFIG_DENY_TOOLS,
181
+ ALL_AGENT_CONFIG_PATH_PATTERNS,
182
+ )
183
+ pre_existing_settings: dict[str, Any] = {
184
+ "permissions": {
185
+ "deny": list(all_deny_rules),
186
+ },
187
+ }
188
+ _seed_revoke_then_run(
189
+ fake_settings_path,
190
+ fake_project_root,
191
+ monkeypatch,
192
+ pre_existing_settings=pre_existing_settings,
193
+ )
194
+ capsys.readouterr()
195
+ written_settings = json.loads(fake_settings_path.read_text(encoding="utf-8"))
196
+ permissions_section = written_settings.get("permissions", {})
197
+ remaining_deny_list = permissions_section.get("deny", [])
198
+ for each_rule in all_deny_rules:
199
+ assert each_rule not in remaining_deny_list
200
+
201
+
202
+ def test_revoke_removes_every_legacy_trust_entry_for_project(
203
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
204
+ ) -> None:
205
+ fake_project_root = _make_fake_project(tmp_path)
206
+ fake_settings_path = _prepare_fake_home(tmp_path)
207
+ project_path_posix = _project_path_as_posix(fake_project_root)
208
+ legacy_entry_a = (
209
+ f"Trusted local workspace: {project_path_posix}/.claude/** template revision A"
210
+ )
211
+ legacy_entry_b = (
212
+ f"Trusted local workspace: {project_path_posix}/.claude/** template revision B"
213
+ )
214
+ unrelated_other_project_entry = (
215
+ "Trusted local workspace: /some/other/project/.claude/** still valid"
216
+ )
217
+ pre_existing_settings: dict[str, Any] = {
218
+ "autoMode": {
219
+ "environment": [
220
+ legacy_entry_a,
221
+ legacy_entry_b,
222
+ unrelated_other_project_entry,
223
+ ],
224
+ },
225
+ }
226
+ _seed_revoke_then_run(
227
+ fake_settings_path,
228
+ fake_project_root,
229
+ monkeypatch,
230
+ pre_existing_settings=pre_existing_settings,
231
+ )
232
+ written_settings = json.loads(fake_settings_path.read_text(encoding="utf-8"))
233
+ environment_list = written_settings.get("autoMode", {}).get("environment", [])
234
+ assert legacy_entry_a not in environment_list
235
+ assert legacy_entry_b not in environment_list
236
+ assert unrelated_other_project_entry in environment_list
237
+
238
+
239
+ def test_template_constant_documents_agent_config_carveout() -> None:
240
+ assert "agent-config files always require explicit per-edit user approval" in (
241
+ AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE
242
+ )
243
+
244
+
245
+ def test_is_trust_entry_for_project_predicate_filters_by_prefix_and_project_path() -> (
246
+ None
247
+ ):
248
+ project_path_posix = "/fake/proj"
249
+ trust_prefix = "Trusted local workspace:"
250
+ non_string_value: object = 42
251
+ assert (
252
+ common_module.is_trust_entry_for_project(
253
+ non_string_value, project_path_posix, trust_prefix
254
+ )
255
+ is False
256
+ )
257
+ wrong_prefix_entry = (
258
+ f"Something else: {project_path_posix}/.claude/** with marker token"
259
+ )
260
+ assert (
261
+ common_module.is_trust_entry_for_project(
262
+ wrong_prefix_entry, project_path_posix, trust_prefix
263
+ )
264
+ is False
265
+ )
266
+ different_project_entry = (
267
+ "Trusted local workspace: /other/project/.claude/** unrelated"
268
+ )
269
+ assert (
270
+ common_module.is_trust_entry_for_project(
271
+ different_project_entry, project_path_posix, trust_prefix
272
+ )
273
+ is False
274
+ )
275
+ matching_entry = (
276
+ f"Trusted local workspace: {project_path_posix}/.claude/** any wording form"
277
+ )
278
+ assert (
279
+ common_module.is_trust_entry_for_project(
280
+ matching_entry, project_path_posix, trust_prefix
281
+ )
282
+ is True
283
+ )
284
+
285
+
286
+ def test_is_trust_entry_rejects_cross_project_path_suffix_collision() -> None:
287
+ """When the project_path is a path suffix of an unrelated entry's path,
288
+ the predicate must reject the unrelated entry (the boundary anchor case)."""
289
+ short_project_path = "/projects/foo"
290
+ trust_prefix = "Trusted local workspace:"
291
+ longer_unrelated_path_entry = (
292
+ "Trusted local workspace: /Users/jon/projects/foo/.claude/** unrelated path"
293
+ )
294
+ assert (
295
+ common_module.is_trust_entry_for_project(
296
+ longer_unrelated_path_entry, short_project_path, trust_prefix
297
+ )
298
+ is False
299
+ )
300
+ quoted_matching_entry = (
301
+ f'Trusted local workspace: "{short_project_path}/.claude/**" quoted form'
302
+ )
303
+ assert (
304
+ common_module.is_trust_entry_for_project(
305
+ quoted_matching_entry, short_project_path, trust_prefix
306
+ )
307
+ is True
308
+ )
309
+
310
+
311
+ def test_second_grant_is_idempotent_when_no_other_settings_changed(
312
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
313
+ ) -> None:
314
+ """Running grant twice in a row must perform zero changes the second time.
315
+
316
+ On the second call the existing trust entry is byte-identical to the
317
+ freshly-formatted current entry, so purge_stale_trust_entries treats it as
318
+ protected and does not remove it; add_auto_mode_environment_entry then
319
+ no-ops because the entry is already present.
320
+ """
321
+ fake_project_root = _make_fake_project(tmp_path)
322
+ fake_settings_path = _prepare_fake_home(tmp_path)
323
+ _seed_grant_then_run(
324
+ fake_settings_path, fake_project_root, monkeypatch, pre_existing_settings={}
325
+ )
326
+ first_run_output = capsys.readouterr()
327
+ assert "No changes needed" not in first_run_output.out
328
+ _redirect_settings_to_fake_path(fake_settings_path, monkeypatch)
329
+ monkeypatch.chdir(fake_project_root)
330
+ grant_module.grant_permissions_for_current_directory()
331
+ second_run_output = capsys.readouterr()
332
+ assert "No changes needed; settings file left untouched." in second_run_output.out
333
+ assert "Stale auto-mode environment entries purged" not in second_run_output.out
334
+
335
+
336
+ def test_template_derives_human_readable_pattern_list_from_pattern_tuple() -> None:
337
+ """Every pattern in ALL_AGENT_CONFIG_PATH_PATTERNS must surface in the
338
+ rendered template through its derived human-readable form, and the
339
+ template must still expose the {project_path} placeholder for .format()
340
+ substitution at runtime."""
341
+ template_text: str = AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE
342
+ assert "{project_path}" in template_text
343
+ for each_pattern in ALL_AGENT_CONFIG_PATH_PATTERNS:
344
+ if each_pattern.endswith("/**"):
345
+ directory_name = each_pattern[: -len("/**")]
346
+ expected_phrase = f"anything under {directory_name}/"
347
+ elif each_pattern == "mcp.json":
348
+ expected_phrase = "the mcp.json file"
349
+ else:
350
+ expected_phrase = each_pattern
351
+ assert expected_phrase in template_text, (
352
+ f"template missing derived phrase for pattern {each_pattern!r}: "
353
+ f"expected {expected_phrase!r}"
354
+ )
355
+ rendered_template_text = template_text.format(project_path="/tmp/x")
356
+ assert "/tmp/x" in rendered_template_text
@@ -0,0 +1,66 @@
1
+ ---
2
+ name: implement
3
+ description: "Implement a spec while maintaining a running implementation-notes.html file that captures design decisions, deviations, tradeoffs, and open questions. Triggers: /implement, implement this spec, build out this plan and keep notes."
4
+ argument-hint: "[path to spec file, or omit to use a spec already in context]"
5
+ ---
6
+
7
+ # implement
8
+
9
+ Execute a spec end-to-end while keeping a sidecar `implementation-notes.html` that the user can read to see how the build diverged from or interpreted the written plan.
10
+
11
+ ## Instructions
12
+
13
+ Carry out the following prompt against the spec resolved below.
14
+
15
+ ### Resolve `<SPEC>`
16
+
17
+ - If `$ARGUMENTS` is non-empty, treat it as the path to the spec file and read it.
18
+ - Otherwise, use the most recent plan / spec / design doc already present in the conversation context.
19
+ - If neither is available, ask the user for the spec path via `AskUserQuestion` before proceeding.
20
+
21
+ ### Prompt to execute
22
+
23
+ > Implement `<SPEC>`. As you work maintain a running `implementation-notes.html` file that captures anything I should know about how the implementation diverges from or interprets the spec, including:
24
+ >
25
+ > - **Design decisions:** choices you made where the spec was ambiguous
26
+ > - **Deviations:** places where you intentionally departed from the spec, and why
27
+ > - **Tradeoffs:** alternatives you considered and why you picked what you did
28
+ > - **Open questions:** anything you'd want me to confirm or revise
29
+
30
+ ### How to write notes
31
+
32
+ Run `${CLAUDE_SKILL_DIR}/scripts/append_note.py` to append each entry. The script creates `implementation-notes.html` with the four sections on first run, then inserts a new `<li>` under the requested section. HTML-escapes `--about` and `--note` automatically. `${CLAUDE_SKILL_DIR}` is host-substituted by Claude Code at runtime so the bundled CLI is found regardless of the current working directory.
33
+
34
+ ```
35
+ python "${CLAUDE_SKILL_DIR}/scripts/append_note.py" \
36
+ --section decisions \
37
+ --about "Storage location" \
38
+ --note "Wrote notes next to the spec because the spec path was provided." \
39
+ --file /path/to/spec-dir/implementation-notes.html
40
+ ```
41
+
42
+ `--section` choices (slug → heading):
43
+
44
+ | Slug | Heading |
45
+ |---|---|
46
+ | `decisions` | Design decisions |
47
+ | `deviations` | Deviations |
48
+ | `tradeoffs` | Tradeoffs |
49
+ | `questions` | Open questions |
50
+
51
+ `--file` is optional. When omitted, the script writes to `./implementation-notes.html` in the current working directory. When a spec path is known, pass `--file` so notes land next to the spec rather than in CWD.
52
+
53
+ Append entries as decisions are made — do not batch them until the end.
54
+
55
+ ## Gotchas
56
+
57
+ - **Do not hand-edit `implementation-notes.html`.** The append script locates each section by its `<section id="...">` marker and the first `</ul>` after it. Editing the structure breaks subsequent appends; the script raises a `RuntimeError` naming the missing marker.
58
+ - **`--about` and `--note` are HTML-escaped automatically** — pass raw text, not pre-escaped HTML.
59
+
60
+ ## File index
61
+
62
+ | File | Purpose |
63
+ |---|---|
64
+ | `SKILL.md` | This hub |
65
+ | `scripts/append_note.py` | CLI to append one entry to a section |
66
+ | `scripts/config/notes_constants.py` | Section slugs → headings and default filename |
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env python3
2
+ """Append an entry to implementation-notes.html under one of four sections.
3
+
4
+ Used by the `implement` skill. Creates the file with all four sections if it
5
+ does not exist; otherwise appends a new <li> under the requested section.
6
+
7
+ Usage:
8
+ python append_note.py --section decisions --about "Where to write the file" --note "Wrote next to spec rather than CWD because spec path was known."
9
+ python append_note.py --section questions --about "Auth model" --note "Spec didn't say whether sessions persist across restarts." --file ./notes.html
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import html
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from config.notes_constants import DEFAULT_NOTES_FILENAME, HEADING_BY_SLUG
20
+
21
+
22
+ def _build_skeleton() -> str:
23
+ section_blocks = "\n".join(
24
+ f' <section id="{each_slug}">\n <h2>{each_heading}</h2>\n <ul></ul>\n </section>'
25
+ for each_slug, each_heading in HEADING_BY_SLUG.items()
26
+ )
27
+ return (
28
+ "<!doctype html>\n"
29
+ '<html lang="en">\n'
30
+ "<head>\n"
31
+ ' <meta charset="utf-8">\n'
32
+ " <title>Implementation notes</title>\n"
33
+ "</head>\n"
34
+ "<body>\n"
35
+ " <h1>Implementation notes</h1>\n"
36
+ f"{section_blocks}\n"
37
+ "</body>\n"
38
+ "</html>\n"
39
+ )
40
+
41
+
42
+ def _ensure_file(target: Path) -> str:
43
+ if not target.exists():
44
+ target.parent.mkdir(parents=True, exist_ok=True)
45
+ skeleton = _build_skeleton()
46
+ target.write_text(skeleton, encoding="utf-8")
47
+ return skeleton
48
+ return target.read_text(encoding="utf-8")
49
+
50
+
51
+ def _render_entry(about: str, note: str) -> str:
52
+ return f"<li><strong>{html.escape(about)}:</strong> {html.escape(note)}</li>"
53
+
54
+
55
+ def _insert_entry(document: str, slug: str, entry: str) -> str:
56
+ open_marker = f'<section id="{slug}">'
57
+ section_close_marker = "</section>"
58
+ close_marker = "</ul>"
59
+ section_start = document.find(open_marker)
60
+ if section_start == -1:
61
+ raise RuntimeError(
62
+ f"section '{slug}' not found in file — the file may have been "
63
+ f"edited by hand. Restore the four <section id=...> blocks or "
64
+ f"delete the file so it can be regenerated."
65
+ )
66
+ section_end = document.find(section_close_marker, section_start)
67
+ if section_end == -1:
68
+ raise RuntimeError(
69
+ f"section '{slug}' is missing its closing </section> — the file "
70
+ f"may have been edited by hand."
71
+ )
72
+ close_at = document.find(close_marker, section_start, section_end)
73
+ if close_at == -1:
74
+ raise RuntimeError(
75
+ f"section '{slug}' is missing its closing </ul> — the file may "
76
+ f"have been edited by hand."
77
+ )
78
+ boundary = close_at
79
+ while boundary > 0 and document[boundary - 1] in (" ", "\n"):
80
+ boundary -= 1
81
+ new_line = f"\n {entry}"
82
+ return document[:boundary] + new_line + "\n " + document[close_at:]
83
+
84
+
85
+ def _parse_arguments() -> argparse.Namespace:
86
+ parser = argparse.ArgumentParser(
87
+ description=f"Append an entry to {DEFAULT_NOTES_FILENAME}.",
88
+ )
89
+ parser.add_argument(
90
+ "--section",
91
+ required=True,
92
+ choices=sorted(HEADING_BY_SLUG.keys()),
93
+ help="Which section to append under.",
94
+ )
95
+ parser.add_argument(
96
+ "--about",
97
+ required=True,
98
+ help="Short label naming the part of the spec this entry relates to.",
99
+ )
100
+ parser.add_argument(
101
+ "--note",
102
+ required=True,
103
+ help="The decision / deviation / tradeoff / question itself.",
104
+ )
105
+ parser.add_argument(
106
+ "--file",
107
+ default=DEFAULT_NOTES_FILENAME,
108
+ help=(
109
+ f"Path to the notes file. Defaults to ./{DEFAULT_NOTES_FILENAME} "
110
+ f"in the current working directory."
111
+ ),
112
+ )
113
+ return parser.parse_args()
114
+
115
+
116
+ def main() -> int:
117
+ """Parse CLI arguments and append one entry to the notes file.
118
+
119
+ Returns:
120
+ Process exit code (0 on success).
121
+ """
122
+ arguments = _parse_arguments()
123
+ target_path = Path(arguments.file).expanduser().resolve()
124
+ document = _ensure_file(target_path)
125
+ entry = _render_entry(arguments.about, arguments.note)
126
+ updated = _insert_entry(document, arguments.section, entry)
127
+ target_path.write_text(updated, encoding="utf-8")
128
+ print(f"appended to [{arguments.section}] in {target_path}")
129
+ return 0
130
+
131
+
132
+ if __name__ == "__main__":
133
+ sys.exit(main())
File without changes
@@ -0,0 +1,12 @@
1
+ """Configuration for the implementation-notes append script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ HEADING_BY_SLUG: dict[str, str] = {
6
+ "decisions": "Design decisions",
7
+ "deviations": "Deviations",
8
+ "tradeoffs": "Tradeoffs",
9
+ "questions": "Open questions",
10
+ }
11
+
12
+ DEFAULT_NOTES_FILENAME = "implementation-notes.html"