claude-dev-env 8.62.1 → 8.64.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/context-audit/SKILL.md +48 -0
- package/.agents/skills/context-audit/references/depth-modes.md +67 -0
- package/.agents/skills/context-audit/scripts/context_audit.py +109 -0
- package/.agents/skills/context-audit/scripts/context_audit_constants/__init__.py +1 -0
- package/.agents/skills/context-audit/scripts/context_audit_constants/config/__init__.py +1 -0
- package/.agents/skills/context-audit/scripts/context_audit_constants/config/constants.py +179 -0
- package/.agents/skills/context-audit/scripts/context_inventory.py +362 -0
- package/.agents/skills/context-audit/scripts/context_report.py +176 -0
- package/.agents/skills/context-audit/scripts/context_sources.py +252 -0
- package/.agents/skills/context-audit/scripts/test_context_audit.py +217 -0
- package/.agents/skills/search-before-acting/SKILL.md +42 -0
- package/.agents/skills/search-before-acting/playbooks/build.md +18 -0
- package/.agents/skills/search-before-acting/playbooks/delegate.md +9 -0
- package/.agents/skills/search-before-acting/playbooks/plan-or-log.md +9 -0
- package/.agents/skills/search-before-acting/references/report-shape.md +14 -0
- package/.agents/skills/search-before-acting/references/search-places.md +26 -0
- package/bin/ever-shipped-skills.mjs +2 -0
- package/bin/ever-shipped-skills.test.mjs +4 -0
- package/docs/rule-guides/explore-thoroughly.md +10 -0
- package/hooks/blocking/pr_lifecycle_skill_gate.py +5 -2
- package/hooks/blocking/pull_request_proof.py +50 -8
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +4 -1
- package/hooks/blocking/test_pull_request_proof.py +45 -2
- package/hooks/hooks_constants/pull_request_proof_constants.py +14 -0
- package/package.json +1 -1
- package/rules/explore-thoroughly.md +4 -4
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Read checkout files and measure them as context rows.
|
|
2
|
+
|
|
3
|
+
A row records one loaded file: its size, its links out, and its depth mode.
|
|
4
|
+
The helpers here read text, parse frontmatter, and follow links and imports.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from context_audit_constants.config import constants
|
|
16
|
+
from context_audit_constants.config.constants import DepthMode, RowKind, Trigger
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ContextRow:
|
|
21
|
+
"""One file an agent loads, with its size and how it loads."""
|
|
22
|
+
|
|
23
|
+
path: str
|
|
24
|
+
kind: RowKind
|
|
25
|
+
loader: str
|
|
26
|
+
trigger: Trigger
|
|
27
|
+
bytes: int
|
|
28
|
+
lines: int
|
|
29
|
+
est_tokens: int
|
|
30
|
+
links_out: int
|
|
31
|
+
depth_mode: DepthMode
|
|
32
|
+
cap: int | None
|
|
33
|
+
note: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class RowSource:
|
|
38
|
+
"""Where a row's file sits and how the agent reaches it."""
|
|
39
|
+
|
|
40
|
+
path: str
|
|
41
|
+
kind: RowKind
|
|
42
|
+
loader: str
|
|
43
|
+
trigger: Trigger
|
|
44
|
+
note: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def list_tracked_files(root: Path) -> list[Path]:
|
|
48
|
+
"""Return the tracked files under *root*, minus caches and vendored folders.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
root: A git checkout.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Absolute paths of tracked files that exist on disk.
|
|
55
|
+
"""
|
|
56
|
+
listing = subprocess.run(
|
|
57
|
+
constants.ALL_GIT_LIST_FILES_ARGUMENTS,
|
|
58
|
+
cwd=root,
|
|
59
|
+
capture_output=True,
|
|
60
|
+
check=True,
|
|
61
|
+
).stdout
|
|
62
|
+
all_paths = [
|
|
63
|
+
root / os.fsdecode(each_entry)
|
|
64
|
+
for each_entry in listing.split(constants.GIT_LIST_FILES_SEPARATOR)
|
|
65
|
+
if each_entry
|
|
66
|
+
]
|
|
67
|
+
return [
|
|
68
|
+
each_path
|
|
69
|
+
for each_path in all_paths
|
|
70
|
+
if not constants.ALL_SKIPPED_PATH_PARTS.intersection(each_path.parts)
|
|
71
|
+
and each_path.is_file()
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_text(path: Path) -> str:
|
|
76
|
+
"""Return the file text, or an empty string when it cannot be read.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
path: The file to read.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The decoded text.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
return path.read_text(
|
|
86
|
+
encoding=constants.TEXT_ENCODING, errors=constants.TEXT_DECODE_ERRORS
|
|
87
|
+
)
|
|
88
|
+
except OSError:
|
|
89
|
+
return ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def parse_frontmatter(text: str) -> dict[str, str]:
|
|
93
|
+
"""Return the frontmatter fields of a markdown file as flat strings.
|
|
94
|
+
|
|
95
|
+
::
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
name: demo
|
|
99
|
+
paths:
|
|
100
|
+
- src/**
|
|
101
|
+
---
|
|
102
|
+
-> {"name": "demo", "paths": "- src/**"}
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
text: The file text.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Field text by field name; empty when the file has no frontmatter.
|
|
109
|
+
"""
|
|
110
|
+
fence_length = len(constants.FRONTMATTER_FENCE)
|
|
111
|
+
closing_index = text.find(constants.FRONTMATTER_CLOSING_FENCE, fence_length)
|
|
112
|
+
if not text.startswith(constants.FRONTMATTER_FENCE) or closing_index == -1:
|
|
113
|
+
return {}
|
|
114
|
+
field_by_name: dict[str, str] = {}
|
|
115
|
+
current_name = ""
|
|
116
|
+
for each_line in text[fence_length:closing_index].splitlines():
|
|
117
|
+
current_name = _merge_frontmatter_line(field_by_name, current_name, each_line)
|
|
118
|
+
return field_by_name
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _merge_frontmatter_line(
|
|
122
|
+
field_by_name: dict[str, str], current_name: str, line: str
|
|
123
|
+
) -> str:
|
|
124
|
+
field_match = re.match(constants.FRONTMATTER_FIELD_PATTERN, line)
|
|
125
|
+
if field_match:
|
|
126
|
+
field_by_name[field_match.group(1)] = field_match.group(2).strip()
|
|
127
|
+
return field_match.group(1)
|
|
128
|
+
if current_name and line.startswith(
|
|
129
|
+
constants.ALL_FRONTMATTER_CONTINUATION_PREFIXES
|
|
130
|
+
):
|
|
131
|
+
field_by_name[current_name] = (
|
|
132
|
+
f"{field_by_name[current_name]} {line.strip()}".strip()
|
|
133
|
+
)
|
|
134
|
+
return current_name
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def find_relative_links(source: Path, text: str, root: Path) -> list[Path]:
|
|
138
|
+
"""Return the files inside *root* that markdown links in *text* point to.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
source: The file the text came from.
|
|
142
|
+
text: Markdown text.
|
|
143
|
+
root: The checkout root.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Resolved link targets that exist inside the checkout.
|
|
147
|
+
"""
|
|
148
|
+
all_targets = [
|
|
149
|
+
each_target
|
|
150
|
+
for each_target in re.findall(constants.LINK_PATTERN, text)
|
|
151
|
+
if not re.match(constants.URL_SCHEME_PATTERN, each_target)
|
|
152
|
+
]
|
|
153
|
+
return _existing_inside(source, all_targets, root)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def find_import_targets(source: Path, text: str, root: Path) -> list[Path]:
|
|
157
|
+
"""Return the files inside *root* that ``@path`` import lines name.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
source: The instruction file the text came from.
|
|
161
|
+
text: The instruction text.
|
|
162
|
+
root: The checkout root.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Resolved import targets that exist inside the checkout.
|
|
166
|
+
"""
|
|
167
|
+
return _existing_inside(source, re.findall(constants.IMPORT_PATTERN, text), root)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _existing_inside(source: Path, all_targets: list[str], root: Path) -> list[Path]:
|
|
171
|
+
all_candidates = [
|
|
172
|
+
(source.parent / each_target).resolve() for each_target in all_targets
|
|
173
|
+
]
|
|
174
|
+
return [
|
|
175
|
+
each_candidate
|
|
176
|
+
for each_candidate in all_candidates
|
|
177
|
+
if each_candidate.is_file() and root in each_candidate.parents
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def classify_depth(
|
|
182
|
+
cap: int | None, byte_count: int, line_count: int, link_count: int
|
|
183
|
+
) -> DepthMode:
|
|
184
|
+
"""Return how much depth a file carries against its cap.
|
|
185
|
+
|
|
186
|
+
::
|
|
187
|
+
|
|
188
|
+
classify_depth(20, 0, 0, 0) -> empty
|
|
189
|
+
classify_depth(None, 900, 30, 0) -> reference
|
|
190
|
+
classify_depth(20, 4000, 64, 2) -> carries
|
|
191
|
+
classify_depth(20, 600, 12, 3) -> points
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
cap: The line cap, or None for on-link depth.
|
|
195
|
+
byte_count: File size in bytes.
|
|
196
|
+
line_count: File length in lines.
|
|
197
|
+
link_count: Relative links out of the file.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
The depth mode; a file over cap lines, or over cap times 120 bytes, carries.
|
|
201
|
+
"""
|
|
202
|
+
if byte_count <= constants.EMPTY_FILE_BYTE_LIMIT:
|
|
203
|
+
return DepthMode.EMPTY
|
|
204
|
+
if cap is None:
|
|
205
|
+
return DepthMode.REFERENCE
|
|
206
|
+
if line_count > cap or byte_count > cap * constants.CHARACTERS_PER_CAPPED_LINE:
|
|
207
|
+
return DepthMode.CARRIES
|
|
208
|
+
return DepthMode.POINTS if link_count else DepthMode.LEAN
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def count_lines(text: str) -> int:
|
|
212
|
+
"""Return the line count of *text*, counting a final unterminated line.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
text: Any text.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
The number of lines.
|
|
219
|
+
"""
|
|
220
|
+
has_unterminated_tail = bool(text) and not text.endswith(constants.LINE_BREAK)
|
|
221
|
+
return text.count(constants.LINE_BREAK) + int(has_unterminated_tail)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def measure_row(
|
|
225
|
+
source: RowSource, text: str, cap: int | None, link_count: int
|
|
226
|
+
) -> ContextRow:
|
|
227
|
+
"""Return the row for *text* loaded from *source*.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
source: Where the file sits and how it loads.
|
|
231
|
+
text: The text the agent receives.
|
|
232
|
+
cap: The line cap, or None for on-link depth.
|
|
233
|
+
link_count: Relative links out of the text.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
The measured row.
|
|
237
|
+
"""
|
|
238
|
+
byte_count = len(text.encode(constants.TEXT_ENCODING))
|
|
239
|
+
line_count = count_lines(text)
|
|
240
|
+
return ContextRow(
|
|
241
|
+
path=source.path,
|
|
242
|
+
kind=source.kind,
|
|
243
|
+
loader=source.loader,
|
|
244
|
+
trigger=source.trigger,
|
|
245
|
+
bytes=byte_count,
|
|
246
|
+
lines=line_count,
|
|
247
|
+
est_tokens=round(byte_count / constants.BYTES_PER_ESTIMATED_TOKEN),
|
|
248
|
+
links_out=link_count,
|
|
249
|
+
depth_mode=classify_depth(cap, byte_count, line_count, link_count),
|
|
250
|
+
cap=cap,
|
|
251
|
+
note=source.note,
|
|
252
|
+
)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Behavior tests for the context audit, run as a command on a temporary git checkout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
SCRIPT_PATH = Path(__file__).resolve().parent / "context_audit.py"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def write_file(root: Path, relative_path: str, text: str) -> None:
|
|
16
|
+
target = root / relative_path
|
|
17
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
target.write_text(text, encoding="utf-8")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def numbered_lines(count: int) -> str:
|
|
22
|
+
return "".join(f"line {each_index}\n" for each_index in range(count))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def checkout(tmp_path: Path) -> Path:
|
|
27
|
+
root = tmp_path / "sample-repo"
|
|
28
|
+
write_file(root, "CLAUDE.md", "@AGENTS.md\n@docs/shared.md\n")
|
|
29
|
+
write_file(root, "AGENTS.md", numbered_lines(30))
|
|
30
|
+
write_file(root, "docs/shared.md", "Shared steps. See [deep](deep.md).\n")
|
|
31
|
+
write_file(root, "docs/deep.md", "Deep notes. See [deeper](deeper.md).\n")
|
|
32
|
+
write_file(root, "docs/deeper.md", "Deeper notes.\n")
|
|
33
|
+
write_file(root, "nested/CLAUDE.md", "")
|
|
34
|
+
write_file(
|
|
35
|
+
root,
|
|
36
|
+
".claude/rules/api.md",
|
|
37
|
+
"---\npaths:\n - src/api/**\n---\nUse the client.\n",
|
|
38
|
+
)
|
|
39
|
+
write_file(root, ".claude/rules/general.md", "Keep diffs small.\n")
|
|
40
|
+
write_file(
|
|
41
|
+
root,
|
|
42
|
+
".claude/skills/demo/SKILL.md",
|
|
43
|
+
"---\nname: demo\ndescription: Run the demo. Use when asked to demo.\n---\n"
|
|
44
|
+
+ numbered_lines(250),
|
|
45
|
+
)
|
|
46
|
+
write_file(root, ".claude/skills/demo/references/guide.md", "Guide body.\n")
|
|
47
|
+
write_file(
|
|
48
|
+
root,
|
|
49
|
+
".claude/skills/quiet/SKILL.md",
|
|
50
|
+
"---\nname: quiet\ndescription: Hidden.\ndisable-model-invocation: true\n---\nBody.\n",
|
|
51
|
+
)
|
|
52
|
+
write_file(
|
|
53
|
+
root, "tests/fixture/.claude/skills/ghost/SKILL.md", "---\nname: ghost\n---\n"
|
|
54
|
+
)
|
|
55
|
+
write_file(root, "tool.py", '"""' + numbered_lines(20) + '"""\n')
|
|
56
|
+
subprocess.run(["git", "init", "-q"], cwd=root, check=True)
|
|
57
|
+
subprocess.run(["git", "add", "-A"], cwd=root, check=True)
|
|
58
|
+
return root
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_audit(*all_arguments: str) -> subprocess.CompletedProcess[str]:
|
|
62
|
+
return subprocess.run(
|
|
63
|
+
[sys.executable, str(SCRIPT_PATH), *all_arguments],
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
check=False,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def audit_rows(
|
|
71
|
+
checkout: Path, tmp_path: Path, *all_extra_arguments: str
|
|
72
|
+
) -> dict[tuple[str, str], dict]:
|
|
73
|
+
rows_path = tmp_path / "rows.jsonl"
|
|
74
|
+
completed = run_audit(str(checkout), "--rows", str(rows_path), *all_extra_arguments)
|
|
75
|
+
assert completed.returncode == 0, completed.stderr
|
|
76
|
+
all_rows = [
|
|
77
|
+
json.loads(each_line) for each_line in rows_path.read_text().splitlines()
|
|
78
|
+
]
|
|
79
|
+
return {(each_row["path"], each_row["kind"]): each_row for each_row in all_rows}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_root_instructions_load_at_session_start_and_carry_when_over_cap(
|
|
83
|
+
checkout: Path, tmp_path: Path
|
|
84
|
+
) -> None:
|
|
85
|
+
row_by_key = audit_rows(checkout, tmp_path)
|
|
86
|
+
agents_row = row_by_key[("AGENTS.md", "instructions")]
|
|
87
|
+
assert agents_row["trigger"] == "session-start"
|
|
88
|
+
assert agents_row["loader"] == "@import from CLAUDE.md"
|
|
89
|
+
assert agents_row["lines"] == 30
|
|
90
|
+
assert agents_row["depth_mode"] == "carries"
|
|
91
|
+
assert row_by_key[("CLAUDE.md", "instructions")]["depth_mode"] == "lean"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_nested_empty_instructions_load_on_folder_enter_as_empty(
|
|
95
|
+
checkout: Path, tmp_path: Path
|
|
96
|
+
) -> None:
|
|
97
|
+
nested_row = audit_rows(checkout, tmp_path)[("nested/CLAUDE.md", "instructions")]
|
|
98
|
+
assert nested_row["trigger"] == "folder-enter"
|
|
99
|
+
assert nested_row["depth_mode"] == "empty"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_import_loads_with_importer_and_its_links_follow_on_demand(
|
|
103
|
+
checkout: Path, tmp_path: Path
|
|
104
|
+
) -> None:
|
|
105
|
+
row_by_key = audit_rows(checkout, tmp_path)
|
|
106
|
+
assert row_by_key[("docs/shared.md", "import")]["trigger"] == "session-start"
|
|
107
|
+
assert row_by_key[("docs/shared.md", "import")]["depth_mode"] == "points"
|
|
108
|
+
deep_row = row_by_key[("docs/deep.md", "linked-doc")]
|
|
109
|
+
deeper_row = row_by_key[("docs/deeper.md", "linked-doc")]
|
|
110
|
+
assert (deep_row["trigger"], deep_row["depth_mode"], deep_row["note"]) == (
|
|
111
|
+
"on-link",
|
|
112
|
+
"reference",
|
|
113
|
+
"link depth 1",
|
|
114
|
+
)
|
|
115
|
+
assert deeper_row["note"] == "link depth 2"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_rule_with_paths_loads_on_path_match_and_plain_rule_at_session_start(
|
|
119
|
+
checkout: Path, tmp_path: Path
|
|
120
|
+
) -> None:
|
|
121
|
+
row_by_key = audit_rows(checkout, tmp_path)
|
|
122
|
+
assert row_by_key[(".claude/rules/api.md", "rule")]["trigger"] == "path-match"
|
|
123
|
+
assert (
|
|
124
|
+
row_by_key[(".claude/rules/general.md", "rule")]["trigger"] == "session-start"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_skill_yields_listing_body_and_reference_rows(
|
|
129
|
+
checkout: Path, tmp_path: Path
|
|
130
|
+
) -> None:
|
|
131
|
+
row_by_key = audit_rows(checkout, tmp_path)
|
|
132
|
+
description_row = row_by_key[(".claude/skills/demo/SKILL.md", "skill-description")]
|
|
133
|
+
body_row = row_by_key[(".claude/skills/demo/SKILL.md", "skill-body")]
|
|
134
|
+
reference_row = row_by_key[
|
|
135
|
+
(".claude/skills/demo/references/guide.md", "skill-reference")
|
|
136
|
+
]
|
|
137
|
+
assert (description_row["trigger"], description_row["depth_mode"]) == (
|
|
138
|
+
"session-start",
|
|
139
|
+
"points",
|
|
140
|
+
)
|
|
141
|
+
assert (body_row["trigger"], body_row["depth_mode"]) == ("skill-invoke", "carries")
|
|
142
|
+
assert reference_row["trigger"] == "on-link"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_disabled_skill_has_no_listing_and_fixture_skill_is_skipped(
|
|
146
|
+
checkout: Path, tmp_path: Path
|
|
147
|
+
) -> None:
|
|
148
|
+
row_by_key = audit_rows(checkout, tmp_path)
|
|
149
|
+
assert (".claude/skills/quiet/SKILL.md", "skill-description") not in row_by_key
|
|
150
|
+
assert (".claude/skills/quiet/SKILL.md", "skill-body") in row_by_key
|
|
151
|
+
assert not any(
|
|
152
|
+
each_path.startswith("tests/") and each_kind.startswith("skill")
|
|
153
|
+
for each_path, each_kind in row_by_key
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_module_docstring_loads_on_file_open_and_carries_over_cap(
|
|
158
|
+
checkout: Path, tmp_path: Path
|
|
159
|
+
) -> None:
|
|
160
|
+
docstring_row = audit_rows(checkout, tmp_path)[("tool.py", "docstring")]
|
|
161
|
+
assert (
|
|
162
|
+
docstring_row["trigger"],
|
|
163
|
+
docstring_row["depth_mode"],
|
|
164
|
+
docstring_row["cap"],
|
|
165
|
+
) == ("file-open", "carries", 15)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_saved_hook_text_counts_at_session_start(
|
|
169
|
+
checkout: Path, tmp_path: Path
|
|
170
|
+
) -> None:
|
|
171
|
+
hook_text_file = tmp_path / "hook-banner.txt"
|
|
172
|
+
hook_text_file.write_text(numbered_lines(3), encoding="utf-8")
|
|
173
|
+
row_by_key = audit_rows(
|
|
174
|
+
checkout, tmp_path, "--session-start-text", str(hook_text_file)
|
|
175
|
+
)
|
|
176
|
+
hook_row = row_by_key[("hook-banner.txt", "hook-text")]
|
|
177
|
+
assert (hook_row["trigger"], hook_row["lines"], hook_row["depth_mode"]) == (
|
|
178
|
+
"session-start",
|
|
179
|
+
3,
|
|
180
|
+
"lean",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_report_lists_each_cleanup_section(checkout: Path) -> None:
|
|
185
|
+
completed = run_audit(str(checkout))
|
|
186
|
+
assert completed.returncode == 0, completed.stderr
|
|
187
|
+
carries_section, _, after_carries = completed.stdout.partition(
|
|
188
|
+
"## Empty instruction stubs"
|
|
189
|
+
)
|
|
190
|
+
stubs_section, _, split_section = after_carries.partition("## Long skills to split")
|
|
191
|
+
assert "| AGENTS.md | instructions | session-start | 30 | 20 |" in carries_section
|
|
192
|
+
assert "| tool.py | docstring | file-open | 20 | 15 |" in carries_section
|
|
193
|
+
assert "SKILL.md" not in carries_section
|
|
194
|
+
assert stubs_section.strip() == "- nested/CLAUDE.md (instructions)"
|
|
195
|
+
assert (
|
|
196
|
+
"| .claude/skills/demo/SKILL.md | 254 | 200 |"
|
|
197
|
+
in split_section.split("\n", 3)[3]
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_command_option_is_gone_and_text_file_must_exist(
|
|
202
|
+
checkout: Path, tmp_path: Path
|
|
203
|
+
) -> None:
|
|
204
|
+
rejected = run_audit(str(checkout), "--session-start-command", "echo hi")
|
|
205
|
+
assert rejected.returncode == 2
|
|
206
|
+
assert "unrecognized arguments" in rejected.stderr
|
|
207
|
+
missing = run_audit(
|
|
208
|
+
str(checkout), "--session-start-text", str(tmp_path / "absent.txt")
|
|
209
|
+
)
|
|
210
|
+
assert missing.returncode == 2
|
|
211
|
+
assert "file not found" in missing.stderr
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_folder_outside_git_is_refused(tmp_path: Path) -> None:
|
|
215
|
+
completed = run_audit(str(tmp_path))
|
|
216
|
+
assert completed.returncode == 2
|
|
217
|
+
assert "not a git checkout" in completed.stderr
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: search-before-acting
|
|
3
|
+
description: >-
|
|
4
|
+
Search for what already does an ask before logging, planning, delegating,
|
|
5
|
+
or building it, then report what exists in four points and wait for the
|
|
6
|
+
user. Use when a user asks for new or changed behavior, a tool, a check,
|
|
7
|
+
a workflow, a report, a tracker entry, or a plan, and before writing a
|
|
8
|
+
brief for a worker.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Search before acting
|
|
12
|
+
|
|
13
|
+
The user should hear about work that already does their ask before anyone duplicates it. Search first, report what you find, and let the user decide what to add.
|
|
14
|
+
|
|
15
|
+
## Steps
|
|
16
|
+
|
|
17
|
+
1. Restate the ask as the outcome the user wants, in their words.
|
|
18
|
+
2. Search every place in [`references/search-places.md`](references/search-places.md) that this session can reach. Note each place you could not reach.
|
|
19
|
+
3. Sort each hit as full, partial, or related. A hit is full when it already produces the outcome from step 1.
|
|
20
|
+
4. When any hit is full or partial, stop. Send the four-point report from [`references/report-shape.md`](references/report-shape.md) and wait for the user. When a hit is full, tell the user they already have it.
|
|
21
|
+
5. When nothing is found, say where you looked in one line, then continue with the playbook below.
|
|
22
|
+
|
|
23
|
+
## Playbooks
|
|
24
|
+
|
|
25
|
+
Open the playbook that matches the next move.
|
|
26
|
+
|
|
27
|
+
| Next move | Playbook |
|
|
28
|
+
|---|---|
|
|
29
|
+
| Build or change code, a hook, a skill, a rule, a workflow, or config | [`playbooks/build.md`](playbooks/build.md) |
|
|
30
|
+
| Log the ask in a tracker, or write a plan for it | [`playbooks/plan-or-log.md`](playbooks/plan-or-log.md) |
|
|
31
|
+
| Hand the ask to a worker, a thread, or another session | [`playbooks/delegate.md`](playbooks/delegate.md) |
|
|
32
|
+
|
|
33
|
+
## Layout
|
|
34
|
+
|
|
35
|
+
| File | What it holds |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `SKILL.md` | The steps and the playbook index |
|
|
38
|
+
| `references/search-places.md` | Where to search, with the query to run in each place |
|
|
39
|
+
| `references/report-shape.md` | The four-point report and the "you already have it" answer |
|
|
40
|
+
| `playbooks/build.md` | The search and report before a build, and the pull request section |
|
|
41
|
+
| `playbooks/plan-or-log.md` | The search and report before a tracker entry or plan |
|
|
42
|
+
| `playbooks/delegate.md` | The search before a brief, and what the brief carries |
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Build
|
|
2
|
+
|
|
3
|
+
Use this playbook before you build or change code, a hook, a skill, a rule, a workflow, or config for an ask.
|
|
4
|
+
|
|
5
|
+
1. Run the skill steps. Search the places in [`../references/search-places.md`](../references/search-places.md), with production config and launcher options first when the ask names a running system.
|
|
6
|
+
2. When a hit is full or partial, send the [report](../references/report-shape.md) and wait for the user's answer.
|
|
7
|
+
3. When the user says go, build only the missing part from point 3 of the report. Extend the existing piece where it lives. Start a new piece only when the existing one cannot hold the addition, and say why in the pull request.
|
|
8
|
+
4. When nothing exists, follow `prefer-existing-tools.md` to check open-source options, then build.
|
|
9
|
+
5. Add an "Existing work" section to the pull request body. List each hit with its link or `file:line` and what the pull request adds on top of it. When nothing was found, say so and name the places searched. The pull request gate denies a body without this section.
|
|
10
|
+
|
|
11
|
+
Example section:
|
|
12
|
+
|
|
13
|
+
```markdown
|
|
14
|
+
## Existing work
|
|
15
|
+
|
|
16
|
+
- `scripts/run_batch.py:40` already runs jobs in parallel with `--workers`. This pull request adds a per-job timeout on top of it.
|
|
17
|
+
- Searched open and merged pull requests for "timeout" and "workers"; none add a timeout.
|
|
18
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Delegate
|
|
2
|
+
|
|
3
|
+
Use this playbook before you hand an ask to a worker, a thread, or another session.
|
|
4
|
+
|
|
5
|
+
1. Run the skill steps yourself before you write the brief. The worker starts from your search.
|
|
6
|
+
2. When a hit is full or partial, send the user the [report](../references/report-shape.md) and wait. Delegate only after the user says go.
|
|
7
|
+
3. Put the search result in the brief. Name each existing piece with its link or `file:line`, what it covers, and the missing part the worker builds.
|
|
8
|
+
4. When nothing exists, put the places searched in the brief, so the worker skips them and searches what you could not reach.
|
|
9
|
+
5. Ask the worker to report any existing piece it finds that the brief missed, and to stop on it before it builds.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Plan or log
|
|
2
|
+
|
|
3
|
+
Use this playbook before you add an ask to a tracker, open an issue or card for it, or write a plan for it.
|
|
4
|
+
|
|
5
|
+
1. Run the skill steps. Search open and closed tracker entries for the ask first, then the rest of [`../references/search-places.md`](../references/search-places.md).
|
|
6
|
+
2. When a tracker entry already names the ask, update that entry and link it. Skip the new entry.
|
|
7
|
+
3. When code, config, or a merged pull request already does the ask in full or in part, send the [report](../references/report-shape.md) and wait for the user before you log or plan.
|
|
8
|
+
4. When the user says go, log or plan only the missing part. Link each existing piece in the entry or plan, so the reader starts from what is live.
|
|
9
|
+
5. When nothing exists, write the entry or plan with one line that names the places searched.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Report shape
|
|
2
|
+
|
|
3
|
+
Send this report when the search finds something that does the ask in full or in part. Send it before any log entry, plan, brief, or edit, and wait for the user.
|
|
4
|
+
|
|
5
|
+
1. **What exists and where.** Each hit with a link or `file:line`, and whether it is live.
|
|
6
|
+
2. **What it covers of the ask.** The parts of the outcome it already produces.
|
|
7
|
+
3. **What is missing.** The parts of the outcome nothing produces yet.
|
|
8
|
+
4. **What the addition would add.** What the new work gives on top of what exists, and the smallest change that closes the gap.
|
|
9
|
+
|
|
10
|
+
When nothing is missing, the report is one line: the user already has it, with the link and how to use it.
|
|
11
|
+
|
|
12
|
+
When a place in [`search-places.md`](search-places.md) was out of reach, add one line naming it and the check that would reach it.
|
|
13
|
+
|
|
14
|
+
Keep the report as short as the user's channel asks for. When the channel limits length, put the four points in a linked file and send a short message that says what exists, what is missing, and what the user decides.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Search places
|
|
2
|
+
|
|
3
|
+
Search each place this session can reach. Start from the outcome words in the ask and from the names of the systems it touches. Try two or three phrasings, since an existing feature often uses different words than the ask, such as "workers" for "parallel" or "queue" for "batch".
|
|
4
|
+
|
|
5
|
+
| Place | What to run |
|
|
6
|
+
|---|---|
|
|
7
|
+
| Repository code | `rg -i "<outcome words>"` across each repository the work touches, and a search for the system's entry points and their flags, such as `--help` output and argument parsers |
|
|
8
|
+
| Open pull requests | `gh pr list --state open --search "<words>"`, or the GitHub search tool with `is:pr is:open` |
|
|
9
|
+
| Merged pull requests | `gh pr list --state merged --search "<words>"`, and `git log --oneline -i --grep "<words>"` on the default branch |
|
|
10
|
+
| Hooks | the hook registry, such as `hooks.json` or `settings.json`, and the hook directories, for a gate or reminder that already does the step |
|
|
11
|
+
| Rules and instructions | `rules/`, `AGENTS.md`, `CLAUDE.md`, project instructions, and memory notes |
|
|
12
|
+
| Skills and commands | the installed skills list, `skills/`, `.agents/skills/`, and `commands/` |
|
|
13
|
+
| Workflows and schedules | `.github/workflows/`, scheduled routines, cron entries, and task schedulers |
|
|
14
|
+
| Trackers | open and closed issues, project boards, and task cards that name the ask |
|
|
15
|
+
| Production config | config files, environment settings, feature flags, and launcher options that the running system reads, including values that turn on a mode the ask describes |
|
|
16
|
+
| Run logs and dashboards | recent run output that shows the behavior already happening |
|
|
17
|
+
|
|
18
|
+
## What to record for each hit
|
|
19
|
+
|
|
20
|
+
- The place and a link or `file:line`.
|
|
21
|
+
- One sentence on what it does now.
|
|
22
|
+
- Whether it is live: merged and running, merged and unused, or still open.
|
|
23
|
+
|
|
24
|
+
## When a place is out of reach
|
|
25
|
+
|
|
26
|
+
Name the place and the check that would reach it, such as "the production config on the build machine, read through a remote session". The report states the gap. The user may know the answer at once.
|
|
@@ -18,6 +18,7 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
|
|
|
18
18
|
'condensing-instructions',
|
|
19
19
|
'codex-review',
|
|
20
20
|
'codex-cleanse',
|
|
21
|
+
'context-audit',
|
|
21
22
|
'context7-mcp',
|
|
22
23
|
'copilot-finding-triage',
|
|
23
24
|
'copilot-review',
|
|
@@ -94,6 +95,7 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
|
|
|
94
95
|
'reviewer-gates',
|
|
95
96
|
'reviews',
|
|
96
97
|
'run-claude-dev-env',
|
|
98
|
+
'search-before-acting',
|
|
97
99
|
'second-account-workers',
|
|
98
100
|
'session-continuity',
|
|
99
101
|
'session-log',
|
|
@@ -45,6 +45,10 @@ test('EVER_SHIPPED_SKILL_NAMES includes the correction skill', () => {
|
|
|
45
45
|
assert.equal(EVER_SHIPPED_SKILL_NAMES.has('correction'), true);
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
test('EVER_SHIPPED_SKILL_NAMES includes the search before acting skill', () => {
|
|
49
|
+
assert.equal(EVER_SHIPPED_SKILL_NAMES.has('search-before-acting'), true);
|
|
50
|
+
});
|
|
51
|
+
|
|
48
52
|
test('EVER_SHIPPED_SKILL_NAMES includes the windows scheduled task skill', () => {
|
|
49
53
|
assert.equal(EVER_SHIPPED_SKILL_NAMES.has('windows-scheduled-task'), true);
|
|
50
54
|
});
|
|
@@ -6,6 +6,16 @@ Source: [Anthropic - Overthinking and Excessive Thoroughness](https://platform.c
|
|
|
6
6
|
|
|
7
7
|
Note: This deliberately chooses exploration depth over the "commit and execute quickly" pattern from the same source. Thorough upfront exploration is preferred for the intended workflow.
|
|
8
8
|
|
|
9
|
+
## Search for existing work first
|
|
10
|
+
|
|
11
|
+
Before you log, plan, delegate, or build an ask, search for what already does it in full or in part. An ask is any request for new or changed behavior, a tool, a check, a workflow, a report, a tracker entry, or a plan. The `search-before-acting` skill holds the search places, the four-point report, and a playbook for each next move.
|
|
12
|
+
|
|
13
|
+
When the search finds something, stop before you act and report what exists and where, what it covers of the ask, what is missing, and what the addition would add. When it covers the whole ask, tell the user they already have it. When the search finds nothing, say where you looked and continue.
|
|
14
|
+
|
|
15
|
+
[`prefer-existing-tools.md`](../../rules/prefer-existing-tools.md) and [`build-needs-a-user.md`](../../rules/build-needs-a-user.md) load only when a session touches a script, hook, or manifest path. This rule loads in every session, so the search comes before the plan is set.
|
|
16
|
+
|
|
17
|
+
The pull request gate reads the body of each new pull request for an "Existing work" heading. Under it, list what the search found with a link or `file:line` and what the pull request adds on top of it, or state that nothing was found and name the places searched.
|
|
18
|
+
|
|
9
19
|
## Before committing to an approach
|
|
10
20
|
|
|
11
21
|
- Read the relevant files. Understand what exists before proposing what to change.
|
|
@@ -53,7 +53,7 @@ from hooks_constants.hook_specific_output_keys import (
|
|
|
53
53
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
54
54
|
from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
|
|
55
55
|
from blocking.followup_pr_dedupe import duplicate_followup_reason
|
|
56
|
-
from blocking.pull_request_proof import missing_proof_reason
|
|
56
|
+
from blocking.pull_request_proof import missing_existing_work_reason, missing_proof_reason
|
|
57
57
|
from transcript_skill_scan import is_skill_loaded_after_last_compaction
|
|
58
58
|
|
|
59
59
|
|
|
@@ -199,7 +199,7 @@ def _subagent_transcript_path(all_payload_fields: dict[str, object]) -> str | No
|
|
|
199
199
|
|
|
200
200
|
|
|
201
201
|
def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | None:
|
|
202
|
-
"""Return a deny for an unloaded skill, a new pull request with no proof, or a second follow-up.
|
|
202
|
+
"""Return a deny for an unloaded skill, a new pull request with no proof or existing-work section, or a second follow-up.
|
|
203
203
|
|
|
204
204
|
Args:
|
|
205
205
|
all_payload_fields: The parsed PreToolUse input.
|
|
@@ -211,6 +211,9 @@ def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | N
|
|
|
211
211
|
proof_reason = missing_proof_reason(all_payload_fields)
|
|
212
212
|
if proof_reason is not None:
|
|
213
213
|
return _deny(proof_reason)
|
|
214
|
+
search_reason = missing_existing_work_reason(all_payload_fields)
|
|
215
|
+
if search_reason is not None:
|
|
216
|
+
return _deny(search_reason)
|
|
214
217
|
duplicate_reason = duplicate_followup_reason(all_payload_fields)
|
|
215
218
|
return None if duplicate_reason is None else _deny(duplicate_reason)
|
|
216
219
|
|