claude-dev-env 1.65.0 → 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.
Files changed (28) hide show
  1. package/agents/plan-packet-validator.md +34 -0
  2. package/audit-rubrics/category_rubrics/category-n-test-name-scenario-verifier.md +6 -0
  3. package/commands/plan.md +6 -52
  4. package/hooks/blocking/code_rules_dead_module_constant.py +111 -24
  5. package/hooks/blocking/code_rules_enforcer.py +2 -0
  6. package/hooks/blocking/code_rules_test_assertions.py +123 -1
  7. package/hooks/blocking/open_questions_in_plans_blocker.py +8 -1
  8. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +88 -0
  9. package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +90 -0
  10. package/hooks/blocking/test_open_questions_in_plans_blocker.py +43 -0
  11. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  12. package/hooks/hooks_constants/dead_module_constant_constants.py +1 -0
  13. package/hooks/hooks_constants/open_questions_in_plans_blocker_constants.py +4 -0
  14. package/hooks/hooks_constants/test_open_questions_in_plans_blocker_constants.py +13 -1
  15. package/package.json +1 -1
  16. package/skills/anthropic-plan/SKILL.md +46 -85
  17. package/skills/anthropic-plan/scripts/anthropic_plan_scripts_constants/__init__.py +0 -0
  18. package/skills/anthropic-plan/scripts/anthropic_plan_scripts_constants/validate_packet_constants.py +33 -0
  19. package/skills/anthropic-plan/scripts/test_validate_packet.py +405 -0
  20. package/skills/anthropic-plan/scripts/validate_packet.py +397 -0
  21. package/skills/anthropic-plan/templates/README.md +20 -0
  22. package/skills/anthropic-plan/templates/build-prompt.md +9 -0
  23. package/skills/anthropic-plan/templates/source-map.md +5 -0
  24. package/skills/anthropic-plan/test_skill_contract.py +53 -0
  25. package/skills/anthropic-plan/workflow/plan-packet.contract.test.mjs +79 -0
  26. package/skills/anthropic-plan/workflow/plan-packet.mjs +299 -0
  27. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +8 -1
  28. package/skills/autoconverge/workflow/converge.mjs +9 -1
@@ -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,5 @@
1
+ # Source Map
2
+
3
+ | Source | Why it matters | Facts extracted | Plan implication |
4
+ |---|---|---|---|
5
+ | <path> | <reason> | <verified fact> | <implementation implication> |
@@ -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
+ });