claude-dev-env 1.65.1 → 1.66.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/plan-packet-validator.md +34 -0
- package/audit-rubrics/category_rubrics/category-n-test-name-scenario-verifier.md +6 -0
- package/commands/plan.md +6 -52
- package/hooks/blocking/code_rules_enforcer.py +2 -0
- package/hooks/blocking/code_rules_test_assertions.py +123 -1
- package/hooks/blocking/open_questions_in_plans_blocker.py +8 -1
- package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +90 -0
- package/hooks/blocking/test_open_questions_in_plans_blocker.py +43 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/open_questions_in_plans_blocker_constants.py +4 -0
- package/hooks/hooks_constants/test_open_questions_in_plans_blocker_constants.py +13 -1
- package/package.json +1 -1
- package/skills/anthropic-plan/SKILL.md +46 -85
- package/skills/anthropic-plan/scripts/anthropic_plan_scripts_constants/__init__.py +0 -0
- package/skills/anthropic-plan/scripts/anthropic_plan_scripts_constants/validate_packet_constants.py +33 -0
- package/skills/anthropic-plan/scripts/test_validate_packet.py +405 -0
- package/skills/anthropic-plan/scripts/validate_packet.py +397 -0
- package/skills/anthropic-plan/templates/README.md +20 -0
- package/skills/anthropic-plan/templates/build-prompt.md +9 -0
- package/skills/anthropic-plan/templates/source-map.md +5 -0
- package/skills/anthropic-plan/test_skill_contract.py +53 -0
- package/skills/anthropic-plan/workflow/plan-packet.contract.test.mjs +79 -0
- package/skills/anthropic-plan/workflow/plan-packet.mjs +299 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""Validate workflow-generated plan packets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from anthropic_plan_scripts_constants.validate_packet_constants import (
|
|
12
|
+
ALL_REQUIRED_RELATIVE_PATHS,
|
|
13
|
+
EXIT_CODE_VALIDATION_FAILED,
|
|
14
|
+
MARKDOWN_FILE_SUFFIX,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def required_relative_paths() -> tuple[str, ...]:
|
|
19
|
+
"""Return every required packet file path relative to the packet root.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Every required packet file path, relative to the packet root.
|
|
23
|
+
"""
|
|
24
|
+
return ALL_REQUIRED_RELATIVE_PATHS
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def markdown_relative_paths() -> list[str]:
|
|
28
|
+
"""Return required markdown packet files.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Each required packet file path that names a markdown file.
|
|
32
|
+
"""
|
|
33
|
+
return [
|
|
34
|
+
each_relative_path
|
|
35
|
+
for each_relative_path in required_relative_paths()
|
|
36
|
+
if each_relative_path.endswith(MARKDOWN_FILE_SUFFIX)
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def validate_packet(packet_directory: Path) -> list[str]:
|
|
41
|
+
"""Return validation errors for a packet directory.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
packet_directory: Directory that should contain a complete packet.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Every validation error found, or an empty list when the packet is valid.
|
|
48
|
+
"""
|
|
49
|
+
all_errors: list[str] = []
|
|
50
|
+
all_errors.extend(missing_file_errors(packet_directory))
|
|
51
|
+
all_errors.extend(markdown_content_errors(packet_directory))
|
|
52
|
+
all_errors.extend(packet_json_errors(packet_directory))
|
|
53
|
+
all_errors.extend(source_map_errors(packet_directory))
|
|
54
|
+
all_errors.extend(tdd_plan_errors(packet_directory))
|
|
55
|
+
all_errors.extend(implementation_step_errors(packet_directory))
|
|
56
|
+
all_errors.extend(build_prompt_errors(packet_directory))
|
|
57
|
+
return all_errors
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def missing_file_errors(packet_directory: Path) -> list[str]:
|
|
61
|
+
"""Return one error for each missing required packet file.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
packet_directory: Directory that should contain a complete packet.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
One error string for each required file absent from the directory.
|
|
68
|
+
"""
|
|
69
|
+
return [
|
|
70
|
+
f"missing required file: {each_relative_path}"
|
|
71
|
+
for each_relative_path in required_relative_paths()
|
|
72
|
+
if not (packet_directory / each_relative_path).is_file()
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def markdown_content_errors(packet_directory: Path) -> list[str]:
|
|
77
|
+
"""Return errors for invalid markdown packet content.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
packet_directory: Directory that should contain markdown packet files.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
One error string for each markdown file with placeholder or open-question text.
|
|
84
|
+
"""
|
|
85
|
+
all_errors: list[str] = []
|
|
86
|
+
for each_relative_path in markdown_relative_paths():
|
|
87
|
+
packet_file = packet_directory / each_relative_path
|
|
88
|
+
if not packet_file.is_file():
|
|
89
|
+
continue
|
|
90
|
+
markdown_text = packet_file.read_text(encoding="utf-8")
|
|
91
|
+
if has_placeholder_text(markdown_text):
|
|
92
|
+
all_errors.append(f"{each_relative_path} contains placeholder text")
|
|
93
|
+
if has_open_questions_heading(markdown_text):
|
|
94
|
+
all_errors.append(f"{each_relative_path} contains an Open Questions heading")
|
|
95
|
+
return all_errors
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def packet_json_errors(packet_directory: Path) -> list[str]:
|
|
99
|
+
"""Return errors for the machine-readable packet manifest.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
packet_directory: Directory that should contain packet.json.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
One error string for each packet.json field that is missing or inconsistent.
|
|
106
|
+
"""
|
|
107
|
+
packet_file = packet_directory / "packet.json"
|
|
108
|
+
if not packet_file.is_file():
|
|
109
|
+
return []
|
|
110
|
+
try:
|
|
111
|
+
payload_object: object = json.loads(packet_file.read_text(encoding="utf-8"))
|
|
112
|
+
except json.JSONDecodeError as packet_error:
|
|
113
|
+
return [f"packet.json is invalid JSON: {packet_error.msg}"]
|
|
114
|
+
if not isinstance(payload_object, dict):
|
|
115
|
+
return ["packet.json must contain an object"]
|
|
116
|
+
|
|
117
|
+
all_errors: list[str] = []
|
|
118
|
+
if payload_object.get("schemaVersion") != 1:
|
|
119
|
+
all_errors.append("packet.json schemaVersion must be 1")
|
|
120
|
+
if not isinstance(payload_object.get("slug"), str) or not payload_object.get("slug"):
|
|
121
|
+
all_errors.append("packet.json slug must be a non-empty string")
|
|
122
|
+
all_source_files = payload_object.get("sourceFiles")
|
|
123
|
+
if not isinstance(all_source_files, list) or not all_source_files:
|
|
124
|
+
all_errors.append("packet.json sourceFiles must be a non-empty list")
|
|
125
|
+
stored_packet_path = payload_object.get("packetPath", "")
|
|
126
|
+
if not is_same_packet_path(packet_directory, stored_packet_path):
|
|
127
|
+
all_errors.append("packet.json packetPath must match the validated packet directory")
|
|
128
|
+
if not isinstance(payload_object.get("validator"), dict):
|
|
129
|
+
all_errors.append("packet.json validator must be an object")
|
|
130
|
+
return all_errors
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def is_same_packet_path(packet_directory: Path, stored_packet_path: object) -> bool:
|
|
134
|
+
"""Return whether a stored packetPath names the validated packet directory.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
packet_directory: Directory passed to the validator on the command line.
|
|
138
|
+
stored_packet_path: The packetPath value read from packet.json.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
True when both paths resolve to the same directory regardless of
|
|
142
|
+
separator style, trailing separators, or relative-versus-absolute form,
|
|
143
|
+
otherwise False.
|
|
144
|
+
"""
|
|
145
|
+
if not isinstance(stored_packet_path, str) or not stored_packet_path:
|
|
146
|
+
return False
|
|
147
|
+
return packet_directory.resolve() == Path(stored_packet_path).resolve()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def source_map_errors(packet_directory: Path) -> list[str]:
|
|
151
|
+
"""Return errors for weak source-map grounding.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
packet_directory: Directory that should contain context/source-map.md.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
An error string when the source map lacks source-grounded rows, else an empty list.
|
|
158
|
+
"""
|
|
159
|
+
source_map_file = packet_directory / "context" / "source-map.md"
|
|
160
|
+
if not source_map_file.is_file():
|
|
161
|
+
return []
|
|
162
|
+
source_map_text = source_map_file.read_text(encoding="utf-8")
|
|
163
|
+
if not has_source_table_row(source_map_text):
|
|
164
|
+
return ["source-map.md must include source-grounded rows"]
|
|
165
|
+
return []
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def tdd_plan_errors(packet_directory: Path) -> list[str]:
|
|
169
|
+
"""Return errors for a weak TDD plan.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
packet_directory: Directory that should contain implementation/tdd-plan.md.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
One error string for each TDD-plan requirement the file fails to state.
|
|
176
|
+
"""
|
|
177
|
+
tdd_file = packet_directory / "implementation" / "tdd-plan.md"
|
|
178
|
+
if not tdd_file.is_file():
|
|
179
|
+
return []
|
|
180
|
+
tdd_text = tdd_file.read_text(encoding="utf-8").lower()
|
|
181
|
+
if "failing test" not in tdd_text and not re.search(r"\bred\b", tdd_text):
|
|
182
|
+
return ["tdd-plan.md must name failing tests"]
|
|
183
|
+
if "production" not in tdd_text and "green" not in tdd_text:
|
|
184
|
+
return ["tdd-plan.md must state the production-code step after red"]
|
|
185
|
+
return []
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def implementation_step_errors(packet_directory: Path) -> list[str]:
|
|
189
|
+
"""Return errors for implementation steps without test coverage.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
packet_directory: Directory that should contain implementation/steps.md.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
An error string when a numbered or bulleted step names no test or
|
|
196
|
+
non-code reason, else an empty list.
|
|
197
|
+
"""
|
|
198
|
+
steps_file = packet_directory / "implementation" / "steps.md"
|
|
199
|
+
if not steps_file.is_file():
|
|
200
|
+
return []
|
|
201
|
+
step_lines = [
|
|
202
|
+
each_line.strip()
|
|
203
|
+
for each_line in steps_file.read_text(encoding="utf-8").splitlines()
|
|
204
|
+
if is_step_line(each_line.strip())
|
|
205
|
+
]
|
|
206
|
+
missing_test_lines = [
|
|
207
|
+
each_line
|
|
208
|
+
for each_line in step_lines
|
|
209
|
+
if not step_line_has_test_contract(each_line)
|
|
210
|
+
]
|
|
211
|
+
if missing_test_lines:
|
|
212
|
+
return ["implementation/steps.md has steps without a test or non-code reason"]
|
|
213
|
+
return []
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def build_prompt_errors(packet_directory: Path) -> list[str]:
|
|
217
|
+
"""Return errors for a handoff prompt that depends on chat history.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
packet_directory: Directory that should contain handoff/build-prompt.md.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
One error string for each standalone-handoff requirement the prompt fails.
|
|
224
|
+
"""
|
|
225
|
+
build_prompt_file = packet_directory / "handoff" / "build-prompt.md"
|
|
226
|
+
if not build_prompt_file.is_file():
|
|
227
|
+
return []
|
|
228
|
+
build_prompt_text = build_prompt_file.read_text(encoding="utf-8").lower()
|
|
229
|
+
if any(each_phrase in build_prompt_text for each_phrase in forbidden_chat_phrases()):
|
|
230
|
+
return ["build-prompt.md must stand alone without chat history"]
|
|
231
|
+
if "use only this packet" not in build_prompt_text:
|
|
232
|
+
return ["build-prompt.md must tell the build agent to use only this packet"]
|
|
233
|
+
return []
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def has_placeholder_text(markdown_text: str) -> bool:
|
|
237
|
+
"""Return whether markdown contains unresolved placeholder text.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
markdown_text: Markdown content to inspect.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
True when the markdown prose contains keyword placeholders (todo, tbd,
|
|
244
|
+
fixme, replace_me, placeholder, fill this in), curly-brace template
|
|
245
|
+
tokens, or angle-bracket template tokens such as `<path>` or
|
|
246
|
+
`<Plan Title>` that are not known inline HTML tags. An angle bracket
|
|
247
|
+
directly preceded by an identifier character is a generic type in prose
|
|
248
|
+
(List<Item>, Optional<User>) rather than a placeholder, so it does not
|
|
249
|
+
count. Otherwise False.
|
|
250
|
+
"""
|
|
251
|
+
keyword_pattern = re.compile(
|
|
252
|
+
r"\b(?:todo|tbd|fixme|replace_me|placeholder|fill this in)\b|{{|}}",
|
|
253
|
+
re.IGNORECASE,
|
|
254
|
+
)
|
|
255
|
+
angle_bracket_pattern = re.compile(
|
|
256
|
+
r"(?<![A-Za-z0-9_])"
|
|
257
|
+
r"<(?!/?(?:details|summary|br|hr|div|span|img|a|p|kbd|code|pre|sub|sup"
|
|
258
|
+
r"|table|thead|tbody|tr|td|th)[ />])[A-Za-z][\w \-]*>",
|
|
259
|
+
)
|
|
260
|
+
prose_text = strip_markdown_code(markdown_text)
|
|
261
|
+
if keyword_pattern.search(prose_text):
|
|
262
|
+
return True
|
|
263
|
+
return bool(angle_bracket_pattern.search(prose_text))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def has_open_questions_heading(markdown_text: str) -> bool:
|
|
267
|
+
"""Return whether markdown contains an Open Questions heading.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
markdown_text: Markdown content to inspect.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
True when the markdown contains an Open Questions heading, otherwise False.
|
|
274
|
+
"""
|
|
275
|
+
heading_pattern = re.compile(
|
|
276
|
+
r"^\s*(?:#{1,6}\s+|\*\*\s*|__\s*)open[\s_-]+questions(?:[^A-Za-z0-9]|$)",
|
|
277
|
+
re.IGNORECASE | re.MULTILINE,
|
|
278
|
+
)
|
|
279
|
+
return bool(heading_pattern.search(strip_markdown_code(markdown_text)))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def strip_markdown_code(markdown_text: str) -> str:
|
|
283
|
+
"""Return markdown with code spans and fenced blocks removed.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
markdown_text: Markdown content to strip.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
The markdown content with fenced blocks and inline code spans removed.
|
|
290
|
+
"""
|
|
291
|
+
without_fences = re.sub(r"```[\s\S]*?```", "", markdown_text)
|
|
292
|
+
return re.sub(r"``[^`\n]+``|`[^`\n]+`", "", without_fences)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def has_source_table_row(source_map_text: str) -> bool:
|
|
296
|
+
"""Return whether source-map.md has at least one concrete source row.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
source_map_text: The source map markdown content.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
True when at least one table row names a concrete source path, otherwise False.
|
|
303
|
+
"""
|
|
304
|
+
source_file_token_pattern = re.compile(
|
|
305
|
+
r"[\w-]+\.(?:py|pyi|js|mjs|cjs|jsx|ts|tsx|mts|cts|json|jsonc|ya?ml|toml|ini|cfg"
|
|
306
|
+
r"|mdx?|rst|txt|sh|bash|ps[dm]?1|sql|html?|s?css|sass|less|go|rs|java|kts?|rb|php"
|
|
307
|
+
r"|cc?|cpp|h|hpp|cs|swift|scala|lua|vue|svelte|xml|env|lock|dockerfile|mk|gradle"
|
|
308
|
+
r"|proto|gr?aphql|gql)\b",
|
|
309
|
+
re.IGNORECASE,
|
|
310
|
+
)
|
|
311
|
+
for each_line in source_map_text.splitlines():
|
|
312
|
+
normalized_line = each_line.strip()
|
|
313
|
+
if not normalized_line.startswith("|"):
|
|
314
|
+
continue
|
|
315
|
+
if set(normalized_line.replace("|", "").strip()) <= {"-", ":"}:
|
|
316
|
+
continue
|
|
317
|
+
if "/" in normalized_line or "\\" in normalized_line:
|
|
318
|
+
return True
|
|
319
|
+
if source_file_token_pattern.search(normalized_line):
|
|
320
|
+
return True
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def is_step_line(stripped_line: str) -> bool:
|
|
325
|
+
"""Return whether a stripped line is a numbered or bulleted implementation step.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
stripped_line: A whitespace-stripped line from steps.md.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
True when the line begins with a numbered marker such as a digit run
|
|
332
|
+
followed by a dot, or a bullet marker (`-`, `*`, `+`), otherwise False.
|
|
333
|
+
"""
|
|
334
|
+
return bool(re.match(r"^(?:\d+\.|[-*+])\s+", stripped_line))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def step_line_has_test_contract(step_line: str) -> bool:
|
|
338
|
+
"""Return whether a step names test coverage or a non-code reason.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
step_line: Numbered implementation step line.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
True when the step names a test or a non-code reason, otherwise False.
|
|
345
|
+
"""
|
|
346
|
+
normalized_line = step_line.lower()
|
|
347
|
+
return (
|
|
348
|
+
"test" in normalized_line
|
|
349
|
+
or "non-code" in normalized_line
|
|
350
|
+
or "covered by" in normalized_line
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def forbidden_chat_phrases() -> tuple[str, ...]:
|
|
355
|
+
"""Return phrases that make a handoff prompt depend on chat history.
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
Each phrase that signals a handoff prompt depends on chat history.
|
|
359
|
+
"""
|
|
360
|
+
return (
|
|
361
|
+
"as discussed above",
|
|
362
|
+
"from our chat",
|
|
363
|
+
"previous conversation",
|
|
364
|
+
"earlier in this thread",
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def parse_arguments() -> argparse.Namespace:
|
|
369
|
+
"""Parse CLI arguments.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
The parsed command-line arguments namespace.
|
|
373
|
+
"""
|
|
374
|
+
parser = argparse.ArgumentParser(description="Validate a plan packet directory.")
|
|
375
|
+
parser.add_argument("packet_directory", type=Path)
|
|
376
|
+
return parser.parse_args()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def main() -> int:
|
|
380
|
+
"""Run the packet validator CLI.
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
Zero when the packet is valid, or a non-zero exit code when validation errors are found.
|
|
384
|
+
"""
|
|
385
|
+
parsed_arguments = parse_arguments()
|
|
386
|
+
packet_directory = parsed_arguments.packet_directory
|
|
387
|
+
all_errors = validate_packet(packet_directory)
|
|
388
|
+
if all_errors:
|
|
389
|
+
for each_error in all_errors:
|
|
390
|
+
print(each_error, file=sys.stderr)
|
|
391
|
+
return EXIT_CODE_VALIDATION_FAILED
|
|
392
|
+
print("packet validation passed")
|
|
393
|
+
return 0
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
if __name__ == "__main__":
|
|
397
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# <Plan Title>
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
State the requested outcome in one short paragraph.
|
|
6
|
+
|
|
7
|
+
## Status
|
|
8
|
+
|
|
9
|
+
- Approval: pending
|
|
10
|
+
- Deterministic validation: pending
|
|
11
|
+
- Semantic validation: pending
|
|
12
|
+
- Implementation: not started
|
|
13
|
+
|
|
14
|
+
## Packet Map
|
|
15
|
+
|
|
16
|
+
Link every first-level packet file.
|
|
17
|
+
|
|
18
|
+
## Build Path
|
|
19
|
+
|
|
20
|
+
Tell the build agent which files to read first.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Build Prompt
|
|
2
|
+
|
|
3
|
+
Use only this packet. Do not rely on prior chat history.
|
|
4
|
+
|
|
5
|
+
1. Read `README.md`.
|
|
6
|
+
2. Read `context/source-map.md`.
|
|
7
|
+
3. Read `implementation/tdd-plan.md`.
|
|
8
|
+
4. Follow `implementation/steps.md`.
|
|
9
|
+
5. Verify with `handoff/verification-commands.md`.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Contract tests for the anthropic-plan skill packet workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
SKILL_DIRECTORY = Path(__file__).resolve().parent
|
|
9
|
+
CLAUDE_DIRECTORY = SKILL_DIRECTORY.parent.parent
|
|
10
|
+
SKILL_PATH = SKILL_DIRECTORY / "SKILL.md"
|
|
11
|
+
PLAN_COMMAND_PATH = CLAUDE_DIRECTORY / "commands" / "plan.md"
|
|
12
|
+
VALIDATOR_AGENT_PATH = CLAUDE_DIRECTORY / "agents" / "plan-packet-validator.md"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_skill_invokes_plan_packet_workflow() -> None:
|
|
16
|
+
skill_text = SKILL_PATH.read_text(encoding="utf-8")
|
|
17
|
+
|
|
18
|
+
assert "Workflow({" in skill_text
|
|
19
|
+
assert "workflow/plan-packet.mjs" in skill_text
|
|
20
|
+
assert "docs/plans/<slug>/" in skill_text
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_skill_no_longer_mentions_single_home_plan_file() -> None:
|
|
24
|
+
skill_text = SKILL_PATH.read_text(encoding="utf-8")
|
|
25
|
+
|
|
26
|
+
assert "~/.claude/plans/<slug>.md" not in skill_text
|
|
27
|
+
assert "single-file" not in skill_text.lower()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_skill_names_validator_and_stop_before_code_rules() -> None:
|
|
31
|
+
skill_text = SKILL_PATH.read_text(encoding="utf-8")
|
|
32
|
+
|
|
33
|
+
assert "plan-packet-validator" in skill_text
|
|
34
|
+
assert "validate_packet.py" in skill_text
|
|
35
|
+
assert "stop before implementation" in skill_text.lower()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_plan_command_routes_to_anthropic_plan_without_stale_skills() -> None:
|
|
39
|
+
command_text = PLAN_COMMAND_PATH.read_text(encoding="utf-8")
|
|
40
|
+
|
|
41
|
+
assert "anthropic-plan" in command_text
|
|
42
|
+
assert "write-plan" not in command_text
|
|
43
|
+
assert "review-plan" not in command_text
|
|
44
|
+
assert "plan-executor" not in command_text
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_validator_agent_exists_and_is_read_only() -> None:
|
|
48
|
+
agent_text = VALIDATOR_AGENT_PATH.read_text(encoding="utf-8")
|
|
49
|
+
|
|
50
|
+
assert "name: plan-packet-validator" in agent_text
|
|
51
|
+
assert "tools: Read, Grep, Glob, Bash" in agent_text
|
|
52
|
+
assert "Never edit" in agent_text
|
|
53
|
+
assert "source-backed" in agent_text
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const workflowDirectory = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const workflowPath = join(workflowDirectory, 'plan-packet.mjs');
|
|
9
|
+
const workflowSource = existsSync(workflowPath) ? readFileSync(workflowPath, 'utf8') : '';
|
|
10
|
+
|
|
11
|
+
function functionBody(functionName) {
|
|
12
|
+
const functionStart = workflowSource.indexOf(`function ${functionName}(`);
|
|
13
|
+
assert.notEqual(functionStart, -1, `expected ${functionName} to exist`);
|
|
14
|
+
const nextFunctionMatch = /\n(?:async )?function /.exec(workflowSource.slice(functionStart + 1));
|
|
15
|
+
const functionEnd =
|
|
16
|
+
nextFunctionMatch === null ? workflowSource.length : functionStart + 1 + nextFunctionMatch.index;
|
|
17
|
+
return workflowSource.slice(functionStart, functionEnd);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test('workflow file exists and starts with meta export', () => {
|
|
21
|
+
assert.ok(existsSync(workflowPath), 'expected workflow/plan-packet.mjs to exist');
|
|
22
|
+
assert.match(workflowSource.trimStart(), /^export const meta = /);
|
|
23
|
+
assert.doesNotMatch(workflowSource, /^import\s/m);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('workflow declares packet creation, validation, and approval phases', () => {
|
|
27
|
+
assert.match(workflowSource, /name:\s*'plan-packet'/);
|
|
28
|
+
assert.match(workflowSource, /Discover/);
|
|
29
|
+
assert.match(workflowSource, /Write packet/);
|
|
30
|
+
assert.match(workflowSource, /Validate/);
|
|
31
|
+
assert.match(workflowSource, /Approval/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('workflow requires the docs plans packet root', () => {
|
|
35
|
+
const pathBuilder = functionBody('buildPacketPath');
|
|
36
|
+
assert.match(pathBuilder, /docs/);
|
|
37
|
+
assert.match(pathBuilder, /plans/);
|
|
38
|
+
assert.doesNotMatch(pathBuilder, /\.claude\/plans/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('workflow writes the packet before spawning the validator', () => {
|
|
42
|
+
const runBody = functionBody('runPlanPacketWorkflow');
|
|
43
|
+
const writeIndex = runBody.indexOf('writePacket');
|
|
44
|
+
const deterministicIndex = runBody.indexOf('runDeterministicValidation');
|
|
45
|
+
const semanticIndex = runBody.indexOf('runSemanticValidator');
|
|
46
|
+
assert.ok(writeIndex !== -1 && deterministicIndex !== -1 && semanticIndex !== -1);
|
|
47
|
+
assert.ok(writeIndex < deterministicIndex);
|
|
48
|
+
assert.ok(deterministicIndex < semanticIndex);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('workflow repairs semantic findings and caps repair loops at three', () => {
|
|
52
|
+
const runBody = functionBody('runPlanPacketWorkflow');
|
|
53
|
+
assert.match(runBody, /maxRepairLoops:\s*3/);
|
|
54
|
+
assert.match(runBody, /repairPacket/);
|
|
55
|
+
assert.match(runBody, /semanticValidation\.allPassed/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('semantic validator uses a dedicated validator agent with structured schema', () => {
|
|
59
|
+
const validatorBody = functionBody('runSemanticValidator');
|
|
60
|
+
assert.match(validatorBody, /agentType:\s*'plan-packet-validator'/);
|
|
61
|
+
assert.match(validatorBody, /schema:\s*validationSchema\(\)/);
|
|
62
|
+
assert.match(validatorBody, /source-backed/);
|
|
63
|
+
assert.match(validatorBody, /blind build agent/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('workflow stops before implementation work', () => {
|
|
67
|
+
const runBody = functionBody('runPlanPacketWorkflow');
|
|
68
|
+
assert.match(runBody, /implementationStarted:\s*false/);
|
|
69
|
+
assert.doesNotMatch(runBody, /clean-coder/);
|
|
70
|
+
assert.doesNotMatch(runBody, /git commit/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('workflow fails closed when a phase errors', () => {
|
|
74
|
+
const runBody = functionBody('runPlanPacketWorkflow');
|
|
75
|
+
assert.match(runBody, /try\s*{/);
|
|
76
|
+
assert.match(runBody, /catch\s*\(/);
|
|
77
|
+
assert.match(runBody, /validationPassed:\s*false/);
|
|
78
|
+
assert.match(runBody, /approvalRequired:\s*true/);
|
|
79
|
+
});
|