@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.35 → 0.1.46

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 (128) hide show
  1. package/bin/adl +2 -2
  2. package/bin/reconcile +3 -11
  3. package/bin/setup +15 -15
  4. package/package.json +1 -1
  5. package/packages/agent-hooks/deploy-profile.json +1 -1
  6. package/packages/agent-hooks/package.json +1 -1
  7. package/packages/agent-hooks/src/main.ts +23 -2
  8. package/packages/agent-hooks/src/pre-tool-use/handler.ts +2 -2
  9. package/packages/agent-hooks/src/rules/impl/adl-build-run-role.ts +116 -0
  10. package/packages/agent-hooks/src/rules/impl/adl-git-user-override-block.ts +159 -0
  11. package/packages/agent-hooks/src/rules/impl/adl-mcp-cli.ts +22 -12
  12. package/packages/agent-hooks/src/rules/impl/adl-pr-complete-pending.ts +9 -9
  13. package/packages/agent-hooks/src/rules/impl/adl-pr-role.ts +6 -5
  14. package/packages/agent-hooks/src/rules/impl/adl-run-required.ts +11 -5
  15. package/packages/agent-hooks/src/rules/impl/adl-workitem-role.ts +18 -5
  16. package/packages/agent-hooks/src/rules/impl/agent-tamper-protection.ts +30 -25
  17. package/packages/agent-hooks/src/rules/impl/git-core.ts +4 -8
  18. package/packages/agent-hooks/src/rules/impl/git-target.ts +5 -0
  19. package/packages/agent-hooks/src/rules/impl/mcp-tool.ts +4 -2
  20. package/packages/agent-hooks/src/rules/impl/tool-frontend.ts +230 -11
  21. package/packages/agent-hooks/src/rules/registry.ts +11 -2
  22. package/packages/agent-hooks/src/rules/shell/git-identity.ts +1 -1
  23. package/packages/agent-hooks/src/rules/shell/invocation.ts +14 -2
  24. package/packages/agent-hooks/src/rules/shell/protected-carriers.ts +10 -7
  25. package/packages/agent-settings/assets/TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md +2 -2
  26. package/packages/agent-settings/package.json +1 -1
  27. package/packages/agent-settings/src/deploy/agents-deploy.ts +19 -1
  28. package/packages/agent-settings/src/deploy/index.ts +1 -0
  29. package/packages/agent-skills/assets/agent-search-tool-audit/SKILL.md +4 -6
  30. package/packages/agent-skills/assets/code-review/SKILL.md +84 -66
  31. package/packages/agent-skills/assets/diagnosing-bugs/SKILL.md +23 -2
  32. package/packages/agent-skills/assets/diagnosing-bugs/references/FEEDBACK-LOOP-TACTICS.md +1 -1
  33. package/packages/agent-skills/assets/explore-codebase/SKILL.md +3 -2
  34. package/packages/agent-skills/assets/finalize-feature/SKILL.md +63 -0
  35. package/packages/agent-skills/assets/fix-ci/SKILL.md +58 -0
  36. package/packages/agent-skills/assets/handoff/SKILL.md +1 -1
  37. package/packages/agent-skills/assets/implement-feature/SKILL.md +150 -0
  38. package/packages/agent-skills/assets/implement-feature/references/briefs.md +94 -0
  39. package/packages/agent-skills/assets/implement-item/SKILL.md +71 -0
  40. package/packages/agent-skills/assets/mechanics/SKILL.md +38 -0
  41. package/packages/agent-skills/assets/plan-feature/SKILL.md +141 -0
  42. package/packages/agent-skills/assets/prepare-promotion/SKILL.md +90 -0
  43. package/packages/agent-skills/assets/ralplan/SKILL.md +149 -0
  44. package/packages/agent-skills/assets/refactor-safely/SKILL.md +4 -3
  45. package/packages/agent-skills/assets/report-bug/SKILL.md +72 -0
  46. package/packages/agent-skills/assets/review-change/SKILL.md +82 -0
  47. package/packages/agent-skills/assets/review-plan/SKILL.md +124 -0
  48. package/packages/agent-skills/assets/triage-workitems/SKILL.md +99 -0
  49. package/packages/agent-skills/assets/ultragoal/SKILL.md +77 -0
  50. package/packages/agent-skills/assets/validate-design/SKILL.md +64 -0
  51. package/packages/agent-skills/assets/verify-item/SKILL.md +71 -0
  52. package/packages/agent-skills/assets/wayfinder/SKILL.md +1 -1
  53. package/packages/agent-skills/assets/workitems-process/SKILL.md +145 -0
  54. package/packages/agent-skills/assets/workitems-process/references/ado-and-git.md +227 -0
  55. package/packages/agent-skills/assets/workitems-process/references/evidence.md +74 -0
  56. package/packages/agent-skills/assets/workitems-process/references/workitem-states.md +92 -0
  57. package/packages/agent-skills/assets/write-feature/SKILL.md +94 -0
  58. package/packages/agent-skills/dist/deploy.d.ts +52 -0
  59. package/packages/agent-skills/dist/deploy.d.ts.map +1 -1
  60. package/packages/agent-skills/dist/deploy.js +218 -7
  61. package/packages/agent-skills/dist/deploy.js.map +1 -1
  62. package/packages/agent-skills/dist/index.d.ts +1 -1
  63. package/packages/agent-skills/dist/index.d.ts.map +1 -1
  64. package/packages/agent-skills/dist/index.js +1 -1
  65. package/packages/agent-skills/dist/index.js.map +1 -1
  66. package/packages/agent-skills/package.json +1 -1
  67. package/packages/agent-skills/src/deploy.ts +288 -6
  68. package/packages/agent-skills/src/index.ts +10 -1
  69. package/packages/azdo/README.md +13 -4
  70. package/packages/azdo/bunfig.toml +6 -0
  71. package/packages/azdo/package.json +1 -1
  72. package/packages/azdo/scripts/probe-ado-rules.ts +1 -1
  73. package/packages/azdo/src/activity-log.ts +118 -0
  74. package/packages/azdo/src/azdo-attachments.ts +109 -0
  75. package/packages/azdo/src/azdo-pl.ts +100 -3
  76. package/packages/azdo/src/azdo-pr.ts +376 -17
  77. package/packages/azdo/src/azdo-wit.ts +594 -34
  78. package/packages/azdo/src/cli.ts +119 -12
  79. package/packages/azdo/src/lib/client.ts +95 -17
  80. package/packages/azdo/src/lib/validate-pr-metadata.ts +14 -5
  81. package/packages/azdo/src/mcp-server.ts +121 -8
  82. package/packages/azdo/src/tool-dispatch.ts +107 -23
  83. package/packages/azdo/src/workflow-ladder.csv +125 -107
  84. package/packages/azdo/src/workflow-rules.ts +254 -97
  85. package/packages/fleet/src/install-adl-package.ts +4 -3
  86. package/packages/repo-controls/README.md +12 -4
  87. package/packages/repo-controls/assets/promote.ts +1 -0
  88. package/packages/repo-controls/assets/scaffold/docs/AGENTS.md +5 -5
  89. package/packages/repo-controls/assets/scaffold/plans/AGENTS.md +42 -13
  90. package/packages/repo-controls/assets/scaffold/root/CONTRIBUTING.md +5 -5
  91. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/closed_set/test_check.py +0 -11
  92. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/filename/test_check.py +4 -6
  93. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/ledger/test_check.py +15 -0
  94. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/docs-okf-profiles.yaml +1 -17
  95. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-profile-untyped/docs/research/no-type.md +1 -1
  96. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-profile-untyped/docs/research/typed-experiment.md +1 -1
  97. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-profile-untyped/docs/research/typed-research.md +1 -1
  98. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/extra-key.md +1 -1
  99. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/extra-keys.md +1 -1
  100. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-created.md +1 -1
  101. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-description.md +1 -1
  102. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-last-update.md +1 -1
  103. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-status.md +1 -1
  104. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-title.md +1 -1
  105. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/optional-keys.md +1 -1
  106. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/research-ok.md +1 -1
  107. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/test_check.py +3 -22
  108. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/check.py +39 -182
  109. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/fixtures/open-work-cap-clean/plans/features/az601-clean/intent.md +17 -0
  110. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/test_check.py +90 -321
  111. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plan_metadata.py +31 -5
  112. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/check.py +282 -54
  113. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/design.md +35 -0
  114. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/file-map-reference.md +19 -0
  115. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/intent.md +25 -0
  116. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/items/az351-restore-entrypoint.md +25 -0
  117. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/requirements.md +44 -0
  118. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-violation/plans/features/az347-test-coverage/requirements.md +44 -0
  119. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/test_check.py +646 -53
  120. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/cli.py +3 -3
  121. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/docs_shape.py +1 -12
  122. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/gate-registry.alt.yaml +1 -1
  123. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/gate-registry.yaml +1 -1
  124. package/packages/repo-controls/package.json +1 -1
  125. package/packages/repo-controls/src/config.ts +21 -2
  126. package/packages/repo-controls/src/doctor.ts +9 -14
  127. package/packages/repo-controls/src/findings-ratchet.ts +12 -0
  128. package/packages/repo-controls/src/hook.ts +61 -4
@@ -3,17 +3,22 @@
3
3
  Two committed corpora plus trees built at `tmp_path` for the individual
4
4
  branches. Nothing reads the repository this package lives in.
5
5
 
6
- The previous suite tested the `.plans/a_new|b_committed|c_done` lifecycle tree:
7
- filename dates, `plan_state` agreement with the directory, the eight
8
- executable-shape concepts, duplicate names across states. None of those rules
9
- exist any more — documents no longer move and state lives only on the board —
10
- so the tests were replaced rather than adapted.
6
+ R1–R7 (ADL process handover, 2026-10-12) reshaped the tree this gate grades:
7
+ `spec.md` became `requirements.md`, `intent.md` is the one required plan file
8
+ (a Feature with only `intent.md` passes until planning), `design.md` and
9
+ `file-map-reference.md` joined the Feature-folder allow-list, plan files carry
10
+ the five Feature states while item files keep the twelve item states,
11
+ `GAPS-AND-FINDINGS.md` left the plans root, and each plan file and item file
12
+ now has a required-section list, with EARS acceptance criteria in
13
+ `requirements.md` and a `feature:` board URL on every item file.
11
14
  """
12
15
 
13
16
  from __future__ import annotations
14
17
 
15
18
  import pathlib
16
19
 
20
+ import pytest
21
+
17
22
  from helpers import corpus
18
23
 
19
24
  from adl_compliance.checks.layout.plans_layout import PlansLayout
@@ -28,7 +33,34 @@ ROW = load().by_id("LAY-009")
28
33
 
29
34
  AZ = "https://dev.azure.com/org/proj/_workitems/edit"
30
35
 
31
- FEATURE = """---
36
+ INTENT = """---
37
+ type: intent
38
+ title: "A feature"
39
+ status: New
40
+ work_item: {az}/{{work_item}}
41
+ last_update: 2026-09-15T03:20:00Z
42
+ ---
43
+
44
+ # A feature
45
+
46
+ ## Why
47
+
48
+ Because the board needs the file.
49
+
50
+ ## Outcome
51
+
52
+ The file exists.
53
+
54
+ ## Constraints
55
+
56
+ None.
57
+
58
+ ## Non-goals
59
+
60
+ None.
61
+ """.format(az=AZ)
62
+
63
+ REQUIREMENTS = """---
32
64
  type: feature
33
65
  title: "A feature"
34
66
  status: New
@@ -36,22 +68,107 @@ epic: {az}/320
36
68
  work_item: {az}/{{work_item}}
37
69
  last_update: 2026-09-15T03:20:00Z
38
70
  sources:
39
- - docs/architecture/workflow-automation.md
71
+ - intent.md
40
72
  ---
41
73
 
42
74
  # A feature
75
+
76
+ ## Problem
77
+
78
+ ## Acceptance criteria
79
+
80
+ - AC-1: THE SYSTEM SHALL keep the plan files in step with the board.
81
+
82
+ ## Scope
83
+
84
+ ## Predecessors
85
+
86
+ ## Items
87
+
88
+ ## Open questions for G1
89
+
90
+ none
91
+
92
+ ## Revision log
93
+ """.format(az=AZ)
94
+
95
+ DESIGN = """---
96
+ type: design
97
+ title: "A feature"
98
+ status: New
99
+ work_item: {az}/{{work_item}}
100
+ last_update: 2026-09-15T03:20:00Z
101
+ sources:
102
+ - requirements.md
103
+ ---
104
+
105
+ # A feature
106
+
107
+ ## Solution
108
+
109
+ ## Decisions
110
+
111
+ ## Verification
112
+
113
+ ## Docs impact
114
+
115
+ ## Size estimate
116
+
117
+ ## Risks
118
+ """.format(az=AZ)
119
+
120
+ FILE_MAP = """---
121
+ type: file-map
122
+ title: "A feature"
123
+ status: New
124
+ work_item: {az}/{{work_item}}
125
+ last_update: 2026-09-15T03:20:00Z
126
+ ---
127
+
128
+ # A feature
129
+
130
+ ## Baseline
131
+
132
+ ## Map
43
133
  """.format(az=AZ)
44
134
 
45
135
  ITEM = """---
46
- type: {{item_type}}
136
+ type: {item_type}
47
137
  title: "An item"
48
138
  status: New
49
- work_item: {az}/{{work_item}}
139
+ work_item: {az}/{work_item}
140
+ feature: {az}/{feature}
50
141
  last_update: 2026-09-15T03:20:00Z
51
142
  ---
52
143
 
53
144
  # An item
54
- """.format(az=AZ)
145
+ """.format(az=AZ, item_type="{item_type}", work_item="{work_item}", feature="{feature}")
146
+
147
+ PBI_SECTIONS = "## What it delivers\n\n## Verification\n"
148
+ BUG_SECTIONS = (
149
+ "## Current behaviour\n\n## Expected behaviour\n\n"
150
+ "## Behaviour that must not change\n\n## Responsible Feature\n\n"
151
+ "## Predecessors\n\n## Verification\n"
152
+ )
153
+
154
+
155
+ def item_body(item_type: str, work_item: int, feature: int = 1) -> str:
156
+ body = ITEM.replace("{item_type}", item_type)
157
+ body = body.replace("{work_item}", str(work_item))
158
+ body = body.replace("{feature}", str(feature))
159
+ return body.rstrip("\n") + "\n\n" + (
160
+ BUG_SECTIONS if item_type == "bug" else PBI_SECTIONS
161
+ )
162
+
163
+
164
+ def feature_files(work_item: int = 1) -> dict[str, str]:
165
+ """A complete, conforming Feature folder (all four plan files, no items)."""
166
+ return {
167
+ "plans/features/az1-x/intent.md": INTENT.format(work_item=work_item),
168
+ "plans/features/az1-x/requirements.md": REQUIREMENTS.format(work_item=work_item),
169
+ "plans/features/az1-x/design.md": DESIGN.format(work_item=work_item),
170
+ "plans/features/az1-x/file-map-reference.md": FILE_MAP.format(work_item=work_item),
171
+ }
55
172
 
56
173
 
57
174
  def graded_directly(root: pathlib.Path) -> object:
@@ -85,8 +202,8 @@ def test_the_violation_corpus_reports_each_disagreement() -> None:
85
202
  verdict = graded_directly(VIOLATION)
86
203
  assert verdict.outcome is Outcome.FINDINGS
87
204
  messages = " | ".join(f.message for f in verdict.findings)
88
- # spec work_item 999 vs directory az347, item work_item 352 vs filename
89
- # az351, item feature az999 vs its actual directory, and a bad item_type.
205
+ # requirements work_item 999 vs directory az347, item work_item 352 vs
206
+ # filename az351, and a bad item_type.
90
207
  assert "work_item 999 disagrees with az347" in messages, verdict.render()
91
208
  assert "work_item 352 disagrees with az351" in messages, verdict.render()
92
209
  assert "'chore' is not pbi or bug" in messages, verdict.render()
@@ -112,7 +229,7 @@ def test_a_leftover_lifecycle_directory_is_reported(tmp_path: pathlib.Path) -> N
112
229
  build(
113
230
  tmp_path,
114
231
  {
115
- "plans/features/az1-x/spec.md": FEATURE.format(work_item=1),
232
+ "plans/features/az1-x/intent.md": INTENT.format(work_item=1),
116
233
  "plans/a_new/26-09-15-old-plan.md": "# old",
117
234
  },
118
235
  )
@@ -131,7 +248,7 @@ def test_an_unapproved_root_directory_is_reported(tmp_path: pathlib.Path) -> Non
131
248
  build(
132
249
  tmp_path,
133
250
  {
134
- "plans/features/az1-x/spec.md": FEATURE.format(work_item=1),
251
+ "plans/features/az1-x/intent.md": INTENT.format(work_item=1),
135
252
  "plans/archive/old.md": "# old",
136
253
  },
137
254
  )
@@ -144,7 +261,7 @@ def test_an_unapproved_root_file_is_reported(tmp_path: pathlib.Path) -> None:
144
261
  build(
145
262
  tmp_path,
146
263
  {
147
- "plans/features/az1-x/spec.md": FEATURE.format(work_item=1),
264
+ "plans/features/az1-x/intent.md": INTENT.format(work_item=1),
148
265
  "plans/NOTES.md": "# notes",
149
266
  },
150
267
  )
@@ -152,12 +269,30 @@ def test_an_unapproved_root_file_is_reported(tmp_path: pathlib.Path) -> None:
152
269
  assert any("direct plans files must be" in f.message for f in verdict.findings)
153
270
 
154
271
 
272
+ def test_gaps_and_findings_is_no_longer_allowed_at_the_plans_root(
273
+ tmp_path: pathlib.Path,
274
+ ) -> None:
275
+ """R7: the register is retired; findings enter only through report-bug."""
276
+ verdict = graded_directly(
277
+ build(
278
+ tmp_path,
279
+ {
280
+ "plans/features/az1-x/intent.md": INTENT.format(work_item=1),
281
+ "plans/GAPS-AND-FINDINGS.md": "# gaps\n",
282
+ },
283
+ )
284
+ )
285
+ finding = next(f for f in verdict.findings if f.path == "plans/GAPS-AND-FINDINGS.md")
286
+ assert "direct plans files must be" in finding.message
287
+ assert "GAPS-AND-FINDINGS.md" not in finding.message
288
+
289
+
155
290
  def test_a_loose_file_in_features_is_reported(tmp_path: pathlib.Path) -> None:
156
291
  verdict = graded_directly(
157
292
  build(
158
293
  tmp_path,
159
294
  {
160
- "plans/features/az1-x/spec.md": FEATURE.format(work_item=1),
295
+ "plans/features/az1-x/intent.md": INTENT.format(work_item=1),
161
296
  "plans/features/stray.md": "# stray",
162
297
  },
163
298
  )
@@ -183,7 +318,7 @@ def test_a_gitkeep_beside_a_feature_is_reported_and_names_the_deletion(
183
318
  tmp_path: pathlib.Path,
184
319
  ) -> None:
185
320
  files = {
186
- "plans/features/az1-x/spec.md": FEATURE.format(work_item=1),
321
+ "plans/features/az1-x/intent.md": INTENT.format(work_item=1),
187
322
  "plans/features/.gitkeep": "",
188
323
  }
189
324
  verdict = graded_directly(build(tmp_path, {"plans/AGENTS.md": "x", **files}))
@@ -207,7 +342,7 @@ def test_any_other_loose_file_is_still_reported_beside_a_gitkeep(
207
342
  def test_a_gitkeep_is_only_the_features_directorys_exemption(tmp_path: pathlib.Path) -> None:
208
343
  """Strictness: the allowance is for `features/` alone, not a Feature folder."""
209
344
  files = {
210
- "plans/features/az1-x/spec.md": FEATURE.format(work_item=1),
345
+ "plans/features/az1-x/intent.md": INTENT.format(work_item=1),
211
346
  "plans/features/az1-x/.gitkeep": "",
212
347
  }
213
348
  verdict = graded_directly(build(tmp_path, {"plans/AGENTS.md": "x", **files}))
@@ -216,31 +351,70 @@ def test_a_gitkeep_is_only_the_features_directorys_exemption(tmp_path: pathlib.P
216
351
 
217
352
  def test_a_feature_directory_not_named_az_id_is_reported(tmp_path: pathlib.Path) -> None:
218
353
  verdict = graded_directly(
219
- build(tmp_path, {"plans/features/coverage/spec.md": FEATURE.format(work_item=1)})
354
+ build(tmp_path, {"plans/features/coverage/intent.md": INTENT.format(work_item=1)})
220
355
  )
221
356
  assert any(
222
357
  "must be named az<work item id>-<kebab-slug>" in f.message for f in verdict.findings
223
358
  )
224
359
 
225
360
 
226
- def test_a_feature_directory_without_a_spec_is_reported(tmp_path: pathlib.Path) -> None:
361
+ # ------------------------------------------------------------------ R2: intent
362
+
363
+
364
+ def test_a_feature_directory_without_an_intent_is_reported(tmp_path: pathlib.Path) -> None:
227
365
  verdict = graded_directly(
228
366
  build(
229
367
  tmp_path,
230
368
  {
231
- "plans/features/az1-x/items/az2-y.md": ITEM.format(item_type="pbi", work_item=2)
369
+ "plans/features/az1-x/items/az2-y.md": item_body("pbi", 2),
232
370
  },
233
371
  )
234
372
  )
235
- assert any("must hold a spec.md" in f.message for f in verdict.findings)
373
+ assert any("must hold an intent.md" in f.message for f in verdict.findings)
236
374
 
237
375
 
238
- def test_an_extra_file_beside_spec_is_reported(tmp_path: pathlib.Path) -> None:
376
+ def test_a_feature_folder_holding_only_intent_passes_until_planning(
377
+ tmp_path: pathlib.Path,
378
+ ) -> None:
379
+ """R2: `requirements.md` arrives with the planning run, not before it."""
380
+ verdict = graded_directly(
381
+ build(tmp_path, {"plans/features/az1-x/intent.md": INTENT.format(work_item=1)})
382
+ )
383
+ assert verdict.outcome is Outcome.CLEAN, verdict.render()
384
+
385
+
386
+ def test_the_missing_intent_fix_points_at_the_workitems_process_skill(
387
+ tmp_path: pathlib.Path,
388
+ ) -> None:
389
+ """R5: templates ship with the workitems-process skill only."""
390
+ verdict = graded_directly(
391
+ build(
392
+ tmp_path,
393
+ {
394
+ "plans/features/az1-x/items/az2-y.md": item_body("pbi", 2),
395
+ },
396
+ )
397
+ )
398
+ finding = next(f for f in verdict.findings if "must hold an intent.md" in f.message)
399
+ assert "workitems-process" in finding.fix
400
+
401
+
402
+ # ---------------------------------------------------- R3/R6: plan file siblings
403
+
404
+
405
+ def test_design_and_file_map_are_permitted_beside_requirements(
406
+ tmp_path: pathlib.Path,
407
+ ) -> None:
408
+ verdict = graded_directly(build(tmp_path, feature_files()))
409
+ assert verdict.outcome is Outcome.CLEAN, verdict.render()
410
+
411
+
412
+ def test_an_extra_file_beside_the_plan_files_is_reported(tmp_path: pathlib.Path) -> None:
239
413
  verdict = graded_directly(
240
414
  build(
241
415
  tmp_path,
242
416
  {
243
- "plans/features/az1-x/spec.md": FEATURE.format(work_item=1),
417
+ **feature_files(),
244
418
  "plans/features/az1-x/notes.md": "# notes",
245
419
  },
246
420
  )
@@ -248,11 +422,16 @@ def test_an_extra_file_beside_spec_is_reported(tmp_path: pathlib.Path) -> None:
248
422
  # The message ENUMERATES what is allowed, so whoever hits it can correct the
249
423
  # file without opening the check. A bare "not allowed" sends the reader here.
250
424
  finding = next(f for f in verdict.findings if "Feature directory holds" in f.message)
251
- assert finding.message == "a Feature directory holds spec.md, handoff.md, intent.md, review.md, items/ only"
252
- assert "rename it to one of handoff.md, intent.md, review.md" in finding.fix
425
+ assert finding.message == (
426
+ "a Feature directory holds intent.md, requirements.md, design.md, "
427
+ "file-map-reference.md, handoff.md, review.md, items/ only"
428
+ )
429
+ assert "rename it to one of handoff.md, review.md" in finding.fix
253
430
 
254
431
 
255
- def test_supporting_documents_are_permitted_beside_spec(tmp_path: pathlib.Path) -> None:
432
+ def test_supporting_documents_are_permitted_beside_the_plan_files(
433
+ tmp_path: pathlib.Path,
434
+ ) -> None:
256
435
  """handoff.md and review.md are named by ROLE, so a feature's supporting documents
257
436
  sit at the same path in every feature. A topic-named file (CHANNELS-HANDOVER.md)
258
437
  states the topic a second time and differently each time, which is the drift the
@@ -261,7 +440,7 @@ def test_supporting_documents_are_permitted_beside_spec(tmp_path: pathlib.Path)
261
440
  build(
262
441
  tmp_path,
263
442
  {
264
- "plans/features/az1-x/spec.md": FEATURE.format(work_item=1),
443
+ **feature_files(),
265
444
  "plans/features/az1-x/handoff.md": "# handoff",
266
445
  "plans/features/az1-x/review.md": "# review",
267
446
  },
@@ -275,7 +454,7 @@ def test_a_topic_named_supporting_file_is_still_reported(tmp_path: pathlib.Path)
275
454
  build(
276
455
  tmp_path,
277
456
  {
278
- "plans/features/az1-x/spec.md": FEATURE.format(work_item=1),
457
+ **feature_files(),
279
458
  "plans/features/az1-x/CHANNELS-HANDOVER.md": "# handover",
280
459
  },
281
460
  )
@@ -288,8 +467,8 @@ def test_an_item_not_named_az_id_is_reported(tmp_path: pathlib.Path) -> None:
288
467
  build(
289
468
  tmp_path,
290
469
  {
291
- "plans/features/az1-x/spec.md": FEATURE.format(work_item=1),
292
- "plans/features/az1-x/items/26-09-15-old-name.md": ITEM.format(item_type="pbi", work_item=2),
470
+ **feature_files(),
471
+ "plans/features/az1-x/items/26-09-15-old-name.md": item_body("pbi", 2),
293
472
  },
294
473
  )
295
474
  )
@@ -302,23 +481,23 @@ def test_an_item_not_named_az_id_is_reported(tmp_path: pathlib.Path) -> None:
302
481
  # ----------------------------------------------------------------- frontmatter
303
482
 
304
483
 
305
- def test_a_spec_without_an_epic_is_reported(tmp_path: pathlib.Path) -> None:
484
+ def test_a_requirements_file_without_an_epic_is_reported(tmp_path: pathlib.Path) -> None:
306
485
  verdict = graded_directly(
307
486
  build(
308
487
  tmp_path,
309
488
  {
310
- "plans/features/az1-x/spec.md": "---\ntype: Feature\nwork_item: 1\n---\n\n# x\n",
489
+ "plans/features/az1-x/requirements.md": "---\ntype: feature\nwork_item: 1\n---\n\n# x\n",
311
490
  },
312
491
  )
313
492
  )
314
493
  assert any("must name the Epic" in f.message for f in verdict.findings)
315
494
 
316
495
 
317
- def test_a_spec_work_item_disagreeing_with_its_directory_is_reported(
496
+ def test_a_plan_work_item_disagreeing_with_its_directory_is_reported(
318
497
  tmp_path: pathlib.Path,
319
498
  ) -> None:
320
499
  verdict = graded_directly(
321
- build(tmp_path, {"plans/features/az1-x/spec.md": FEATURE.format(work_item=2)})
500
+ build(tmp_path, {"plans/features/az1-x/intent.md": INTENT.format(work_item=2)})
322
501
  )
323
502
  assert any(
324
503
  "work_item 2 disagrees with az1" in f.message for f in verdict.findings
@@ -334,8 +513,8 @@ def test_an_item_work_item_disagreeing_with_its_filename_is_reported(
334
513
  build(
335
514
  tmp_path,
336
515
  {
337
- "plans/features/az1-x/spec.md": FEATURE.format(work_item=1),
338
- "plans/features/az1-x/items/az2-y.md": ITEM.format(item_type="pbi", work_item=3),
516
+ **feature_files(),
517
+ "plans/features/az1-x/items/az2-y.md": item_body("pbi", 3),
339
518
  },
340
519
  )
341
520
  )
@@ -350,7 +529,7 @@ def test_a_status_outside_the_board_vocabulary_is_reported(tmp_path: pathlib.Pat
350
529
  build(
351
530
  tmp_path,
352
531
  {
353
- "plans/features/az1-x/spec.md": FEATURE.format(work_item=1).replace(
532
+ "plans/features/az1-x/intent.md": INTENT.format(work_item=1).replace(
354
533
  "status: New", "status: draft"
355
534
  )
356
535
  },
@@ -359,6 +538,88 @@ def test_a_status_outside_the_board_vocabulary_is_reported(tmp_path: pathlib.Pat
359
538
  assert any("is not a work item state" in f.message for f in verdict.findings)
360
539
 
361
540
 
541
+ @pytest.mark.parametrize(
542
+ "status", ("New", "In Progress", "Awaiting Release", "Done", "Removed")
543
+ )
544
+ def test_each_feature_state_is_accepted_on_every_plan_file(
545
+ tmp_path: pathlib.Path, status: str
546
+ ) -> None:
547
+ """R1: plan files mirror the FEATURE's board state, not an item's."""
548
+ files = feature_files()
549
+ for path in files:
550
+ files[path] = files[path].replace("status: New", f"status: {status}")
551
+ verdict = graded_directly(build(tmp_path, files))
552
+ assert verdict.outcome is Outcome.CLEAN, verdict.render()
553
+
554
+
555
+ @pytest.mark.parametrize(
556
+ "status",
557
+ (
558
+ "In Planning", "Awaiting Approval", "Approved", "Design Validation",
559
+ "In Implementation", "In Testing", "In Review", "Updating Docs",
560
+ "Parked for Removal",
561
+ ),
562
+ )
563
+ def test_an_item_state_on_a_plan_file_is_reported(
564
+ tmp_path: pathlib.Path, status: str
565
+ ) -> None:
566
+ verdict = graded_directly(
567
+ build(
568
+ tmp_path,
569
+ {
570
+ "plans/features/az1-x/intent.md": INTENT.format(work_item=1).replace(
571
+ "status: New", f"status: {status}"
572
+ )
573
+ },
574
+ )
575
+ )
576
+ assert any("is not a work item state" in f.message for f in verdict.findings)
577
+
578
+
579
+ def test_a_feature_state_on_an_item_file_is_reported(tmp_path: pathlib.Path) -> None:
580
+ """R1 the other way: `Awaiting Release` is a Feature state, not an item's."""
581
+ verdict = graded_directly(
582
+ build(
583
+ tmp_path,
584
+ {
585
+ **feature_files(),
586
+ "plans/features/az1-x/items/az2-y.md": item_body("pbi", 2).replace(
587
+ "status: New", "status: Awaiting Release"
588
+ ),
589
+ },
590
+ )
591
+ )
592
+ assert any("is not a work item state" in f.message for f in verdict.findings)
593
+
594
+
595
+ def test_design_validation_is_the_renamed_item_state(tmp_path: pathlib.Path) -> None:
596
+ """F8: Spec Validation became Design Validation in the item vocabulary."""
597
+ verdict = graded_directly(
598
+ build(
599
+ tmp_path,
600
+ {
601
+ **feature_files(),
602
+ "plans/features/az1-x/items/az2-y.md": item_body("pbi", 2).replace(
603
+ "status: New", "status: Design Validation"
604
+ ),
605
+ },
606
+ )
607
+ )
608
+ assert verdict.outcome is Outcome.CLEAN, verdict.render()
609
+ retired = graded_directly(
610
+ build(
611
+ tmp_path,
612
+ {
613
+ **feature_files(),
614
+ "plans/features/az1-x/items/az2-y.md": item_body("pbi", 2).replace(
615
+ "status: New", "status: Spec Validation"
616
+ ),
617
+ },
618
+ )
619
+ )
620
+ assert any("is not a work item state" in f.message for f in retired.findings)
621
+
622
+
362
623
  def test_a_bare_work_item_id_is_reported(tmp_path: pathlib.Path) -> None:
363
624
  # The id is already in the filename; the field earns its place by being the
364
625
  # link a reader can click through to the board.
@@ -366,7 +627,7 @@ def test_a_bare_work_item_id_is_reported(tmp_path: pathlib.Path) -> None:
366
627
  build(
367
628
  tmp_path,
368
629
  {
369
- "plans/features/az1-x/spec.md": FEATURE.format(work_item=1).replace(
630
+ "plans/features/az1-x/intent.md": INTENT.format(work_item=1).replace(
370
631
  f"work_item: {AZ}/1", "work_item: 1"
371
632
  )
372
633
  },
@@ -375,26 +636,340 @@ def test_a_bare_work_item_id_is_reported(tmp_path: pathlib.Path) -> None:
375
636
  assert any("must be a board URL" in f.message for f in verdict.findings)
376
637
 
377
638
 
378
- def test_a_spec_without_sources_is_reported(tmp_path: pathlib.Path) -> None:
639
+ # ------------------------------------------------------- R4: required sections
640
+
641
+
642
+ def test_a_plan_file_missing_a_required_section_is_reported(tmp_path: pathlib.Path) -> None:
379
643
  verdict = graded_directly(
380
644
  build(
381
645
  tmp_path,
382
646
  {
383
- "plans/features/az1-x/spec.md": FEATURE.format(work_item=1).replace(
384
- "sources:\n - docs/architecture/workflow-automation.md\n", ""
647
+ "plans/features/az1-x/intent.md": INTENT.format(work_item=1).replace(
648
+ "## Outcome\n", ""
385
649
  )
386
650
  },
387
651
  )
388
652
  )
653
+ assert any("missing section ## Outcome" in f.message for f in verdict.findings)
654
+
655
+
656
+ def test_each_required_section_of_requirements_is_reported_by_name(
657
+ tmp_path: pathlib.Path,
658
+ ) -> None:
659
+ body = REQUIREMENTS.format(work_item=1)
660
+ for section in ("## Problem", "## Acceptance criteria", "## Items", "## Revision log"):
661
+ verdict = graded_directly(
662
+ build(
663
+ tmp_path,
664
+ {"plans/features/az1-x/requirements.md": body.replace(f"{section}\n", "")},
665
+ )
666
+ )
667
+ assert any(
668
+ f"missing section {section}" in f.message for f in verdict.findings
669
+ ), f"{section} removed but not reported: {verdict.render()}"
670
+
671
+
672
+ def test_each_required_section_of_design_is_reported_by_name(
673
+ tmp_path: pathlib.Path,
674
+ ) -> None:
675
+ body = DESIGN.format(work_item=1)
676
+ for section in ("## Solution", "## Docs impact", "## Size estimate", "## Risks"):
677
+ verdict = graded_directly(
678
+ build(
679
+ tmp_path,
680
+ {"plans/features/az1-x/design.md": body.replace(f"{section}\n", "")},
681
+ )
682
+ )
683
+ assert any(
684
+ f"missing section {section}" in f.message for f in verdict.findings
685
+ ), f"{section} removed but not reported: {verdict.render()}"
686
+
687
+
688
+ def test_each_required_section_of_the_file_map_is_reported_by_name(
689
+ tmp_path: pathlib.Path,
690
+ ) -> None:
691
+ body = FILE_MAP.format(work_item=1)
692
+ for section in ("## Baseline", "## Map"):
693
+ verdict = graded_directly(
694
+ build(
695
+ tmp_path,
696
+ {"plans/features/az1-x/file-map-reference.md": body.replace(f"{section}\n", "")},
697
+ )
698
+ )
699
+ assert any(
700
+ f"missing section {section}" in f.message for f in verdict.findings
701
+ ), f"{section} removed but not reported: {verdict.render()}"
702
+
703
+
704
+ def test_a_pbi_item_missing_its_sections_is_reported(tmp_path: pathlib.Path) -> None:
705
+ verdict = graded_directly(
706
+ build(
707
+ tmp_path,
708
+ {
709
+ **feature_files(),
710
+ "plans/features/az1-x/items/az2-y.md": item_body("pbi", 2).replace(
711
+ "## Verification\n", ""
712
+ ),
713
+ },
714
+ )
715
+ )
716
+ assert any("missing section ## Verification" in f.message for f in verdict.findings)
717
+
718
+
719
+ def test_a_bug_item_uses_the_fixes_bug_section_set(tmp_path: pathlib.Path) -> None:
720
+ """A type: bug item file is a Fixes Bug item file: Bugs are never planned,
721
+ so its sections are the bugfix shape, not the PBI's."""
722
+ verdict = graded_directly(
723
+ build(
724
+ tmp_path,
725
+ {
726
+ **feature_files(),
727
+ "plans/features/az1-x/items/az2-y.md": item_body("bug", 2).replace(
728
+ "## Responsible Feature\n", ""
729
+ ),
730
+ },
731
+ )
732
+ )
733
+ messages = [f.message for f in verdict.findings if f.path.endswith("az2-y.md")]
734
+ assert "missing section ## Responsible Feature" in messages
735
+ assert not any("missing section ## What it delivers" in m for m in messages)
736
+
737
+
738
+ def test_a_pbi_item_with_bug_sections_is_reported(tmp_path: pathlib.Path) -> None:
739
+ verdict = graded_directly(
740
+ build(
741
+ tmp_path,
742
+ {
743
+ **feature_files(),
744
+ "plans/features/az1-x/items/az2-y.md": item_body("pbi", 2).replace(
745
+ "## What it delivers\n", "## Current behaviour\n"
746
+ ),
747
+ },
748
+ )
749
+ )
750
+ assert any(
751
+ "missing section ## What it delivers" in f.message for f in verdict.findings
752
+ )
753
+
754
+
755
+ def test_optional_sections_are_not_required(tmp_path: pathlib.Path) -> None:
756
+ verdict = graded_directly(
757
+ build(
758
+ tmp_path,
759
+ {
760
+ **feature_files(),
761
+ "plans/features/az1-x/items/az2-y.md": item_body("pbi", 2).replace(
762
+ "## Verification\n", "## Verification\n\n## Related\n"
763
+ ),
764
+ },
765
+ )
766
+ )
767
+ assert verdict.outcome is Outcome.CLEAN, verdict.render()
768
+
769
+
770
+ # ------------------------------------------------------------------ R4: EARS
771
+
772
+
773
+ def test_every_numbered_acceptance_criterion_must_be_an_ears_sentence(
774
+ tmp_path: pathlib.Path,
775
+ ) -> None:
776
+ body = REQUIREMENTS.format(work_item=1).replace(
777
+ "- AC-1: THE SYSTEM SHALL keep the plan files in step with the board.",
778
+ "- AC-1: THE SYSTEM SHALL keep the plan files in step with the board.\n"
779
+ "- AC-2: keep the board in step.\n"
780
+ "- AC-3: THE SYSTEM SHALL also do this.\n",
781
+ )
782
+ verdict = graded_directly(
783
+ build(
784
+ tmp_path,
785
+ {
786
+ "plans/features/az1-x/intent.md": INTENT.format(work_item=1),
787
+ "plans/features/az1-x/requirements.md": body,
788
+ },
789
+ )
790
+ )
791
+ ac2 = [f for f in verdict.findings if "AC-2" in f.message]
792
+ assert len(ac2) == 1, verdict.render()
793
+ assert not [f for f in verdict.findings if "AC-3" in f.message], verdict.render()
794
+ assert not [f for f in verdict.findings if "AC-1" in f.message], verdict.render()
795
+
796
+
797
+ @pytest.mark.parametrize(
798
+ "opener",
799
+ (
800
+ "WHEN the board moves",
801
+ "WHILE the run is open",
802
+ "IF the plan drifts THEN",
803
+ "WHERE the file maps",
804
+ ),
805
+ )
806
+ def test_an_ears_opener_is_accepted_before_the_system_shall(
807
+ tmp_path: pathlib.Path, opener: str
808
+ ) -> None:
809
+ body = REQUIREMENTS.format(work_item=1).replace(
810
+ "- AC-1: THE SYSTEM SHALL keep the plan files in step with the board.",
811
+ f"- AC-1: {opener} THE SYSTEM SHALL keep the plan files in step with the board.",
812
+ )
813
+ verdict = graded_directly(
814
+ build(
815
+ tmp_path,
816
+ {
817
+ "plans/features/az1-x/intent.md": INTENT.format(work_item=1),
818
+ "plans/features/az1-x/requirements.md": body,
819
+ },
820
+ )
821
+ )
822
+ assert verdict.outcome is Outcome.CLEAN, verdict.render()
823
+
824
+
825
+ def test_an_if_opener_without_then_is_not_ears(tmp_path: pathlib.Path) -> None:
826
+ body = REQUIREMENTS.format(work_item=1).replace(
827
+ "- AC-1: THE SYSTEM SHALL keep the plan files in step with the board.",
828
+ "- AC-1: IF the plan drifts THE SYSTEM SHALL keep the plan files in step.",
829
+ )
830
+ verdict = graded_directly(
831
+ build(
832
+ tmp_path,
833
+ {
834
+ "plans/features/az1-x/intent.md": INTENT.format(work_item=1),
835
+ "plans/features/az1-x/requirements.md": body,
836
+ },
837
+ )
838
+ )
839
+ assert any("AC-1" in f.message for f in verdict.findings), verdict.render()
840
+
841
+
842
+ def test_non_criterion_lines_are_not_ears_checked(tmp_path: pathlib.Path) -> None:
843
+ body = REQUIREMENTS.format(work_item=1).replace(
844
+ "- AC-1: THE SYSTEM SHALL keep the plan files in step with the board.",
845
+ "AC-1 is the load-bearing criterion.\n\n"
846
+ "- AC-1: THE SYSTEM SHALL keep the plan files in step with the board.\n",
847
+ )
848
+ verdict = graded_directly(
849
+ build(
850
+ tmp_path,
851
+ {
852
+ "plans/features/az1-x/intent.md": INTENT.format(work_item=1),
853
+ "plans/features/az1-x/requirements.md": body,
854
+ },
855
+ )
856
+ )
857
+ assert verdict.outcome is Outcome.CLEAN, verdict.render()
858
+
859
+
860
+ # ------------------------------------------------------------ R4: feature link
861
+
862
+
863
+ def test_an_item_without_a_feature_link_is_reported(tmp_path: pathlib.Path) -> None:
864
+ verdict = graded_directly(
865
+ build(
866
+ tmp_path,
867
+ {
868
+ **feature_files(),
869
+ "plans/features/az1-x/items/az2-y.md": item_body("pbi", 2).replace(
870
+ f"feature: {AZ}/1\n", ""
871
+ ),
872
+ },
873
+ )
874
+ )
875
+ assert any("missing feature link" in f.message for f in verdict.findings)
876
+
877
+
878
+ def test_an_item_feature_link_disagreeing_with_its_folder_is_reported(
879
+ tmp_path: pathlib.Path,
880
+ ) -> None:
881
+ verdict = graded_directly(
882
+ build(
883
+ tmp_path,
884
+ {
885
+ **feature_files(),
886
+ "plans/features/az1-x/items/az2-y.md": item_body("pbi", 2, feature=999),
887
+ },
888
+ )
889
+ )
890
+ assert any("feature 999 disagrees with az1" in f.message for f in verdict.findings)
891
+
892
+
893
+ def test_an_item_feature_link_must_be_a_board_url(tmp_path: pathlib.Path) -> None:
894
+ verdict = graded_directly(
895
+ build(
896
+ tmp_path,
897
+ {
898
+ **feature_files(),
899
+ "plans/features/az1-x/items/az2-y.md": item_body("pbi", 2).replace(
900
+ f"feature: {AZ}/1", "feature: 1"
901
+ ),
902
+ },
903
+ )
904
+ )
905
+ assert any("feature must be a board URL" in f.message for f in verdict.findings)
906
+
907
+
908
+ # --------------------------------------------------------------- plan file keys
909
+
910
+
911
+ def test_a_requirements_file_without_sources_is_reported(tmp_path: pathlib.Path) -> None:
912
+ verdict = graded_directly(
913
+ build(
914
+ tmp_path,
915
+ {
916
+ "plans/features/az1-x/requirements.md": REQUIREMENTS.format(
917
+ work_item=1
918
+ ).replace("sources:\n - intent.md\n", "")
919
+ },
920
+ )
921
+ )
922
+ assert any("must link the documents it derives from" in f.message for f in verdict.findings)
923
+
924
+
925
+ def test_requirements_sources_must_list_intent_first(tmp_path: pathlib.Path) -> None:
926
+ verdict = graded_directly(
927
+ build(
928
+ tmp_path,
929
+ {
930
+ "plans/features/az1-x/requirements.md": REQUIREMENTS.format(
931
+ work_item=1
932
+ ).replace(" - intent.md", " - docs/architecture/workflow-automation.md"),
933
+ },
934
+ )
935
+ )
936
+ assert any("must list intent.md first" in f.message for f in verdict.findings)
937
+
938
+
939
+ def test_a_design_file_without_sources_is_reported(tmp_path: pathlib.Path) -> None:
940
+ verdict = graded_directly(
941
+ build(
942
+ tmp_path,
943
+ {
944
+ "plans/features/az1-x/design.md": DESIGN.format(
945
+ work_item=1
946
+ ).replace("sources:\n - requirements.md\n", "")
947
+ },
948
+ )
949
+ )
389
950
  assert any("must link the documents it derives from" in f.message for f in verdict.findings)
390
951
 
391
952
 
953
+ def test_design_sources_must_list_requirements(tmp_path: pathlib.Path) -> None:
954
+ verdict = graded_directly(
955
+ build(
956
+ tmp_path,
957
+ {
958
+ "plans/features/az1-x/design.md": DESIGN.format(
959
+ work_item=1
960
+ ).replace(" - requirements.md", " - intent.md"),
961
+ },
962
+ )
963
+ )
964
+ assert any("must list requirements.md" in f.message for f in verdict.findings)
965
+
966
+
392
967
  def test_a_missing_last_update_is_reported(tmp_path: pathlib.Path) -> None:
393
968
  verdict = graded_directly(
394
969
  build(
395
970
  tmp_path,
396
971
  {
397
- "plans/features/az1-x/spec.md": FEATURE.format(work_item=1).replace(
972
+ "plans/features/az1-x/intent.md": INTENT.format(work_item=1).replace(
398
973
  "last_update: 2026-09-15T03:20:00Z\n", ""
399
974
  )
400
975
  },
@@ -408,14 +983,37 @@ def test_an_item_type_outside_pbi_and_bug_is_reported(tmp_path: pathlib.Path) ->
408
983
  build(
409
984
  tmp_path,
410
985
  {
411
- "plans/features/az1-x/spec.md": FEATURE.format(work_item=1),
412
- "plans/features/az1-x/items/az2-y.md": ITEM.format(item_type="task", work_item=2),
986
+ **feature_files(),
987
+ "plans/features/az1-x/items/az2-y.md": item_body("task", 2),
413
988
  },
414
989
  )
415
990
  )
416
991
  assert any("type 'task' is not pbi or bug" in f.message for f in verdict.findings)
417
992
 
418
993
 
994
+ def test_each_plan_file_type_is_enforced(tmp_path: pathlib.Path) -> None:
995
+ templates = {
996
+ "intent.md": INTENT,
997
+ "requirements.md": REQUIREMENTS,
998
+ "design.md": DESIGN,
999
+ "file-map-reference.md": FILE_MAP,
1000
+ }
1001
+ cases = {
1002
+ "intent.md": ("intent", "feature"),
1003
+ "requirements.md": ("feature", "design"),
1004
+ "design.md": ("design", "file-map"),
1005
+ "file-map-reference.md": ("file-map", "intent"),
1006
+ }
1007
+ for name, (want, wrong) in cases.items():
1008
+ body = templates[name].format(work_item=1).replace(f"type: {want}", f"type: {wrong}")
1009
+ verdict = graded_directly(
1010
+ build(tmp_path / name.replace(".md", ""), {f"plans/features/az1-x/{name}": body})
1011
+ )
1012
+ assert any(
1013
+ f"type must be {want!r}" in f.message for f in verdict.findings
1014
+ ), f"{name} with type {wrong}: {verdict.render()}"
1015
+
1016
+
419
1017
  def test_a_supporting_document_must_carry_the_tree_s_frontmatter(tmp_path: pathlib.Path) -> None:
420
1018
  """A handoff without frontmatter is an undated wall of prose naming no work item
421
1019
  — readable today, unattributable in a month. That is the state the channels
@@ -424,7 +1022,7 @@ def test_a_supporting_document_must_carry_the_tree_s_frontmatter(tmp_path: pathl
424
1022
  build(
425
1023
  tmp_path,
426
1024
  {
427
- "plans/features/az1-x/spec.md": FEATURE.format(work_item=1),
1025
+ **feature_files(),
428
1026
  "plans/features/az1-x/handoff.md": "# handoff\n\nprose, no frontmatter\n",
429
1027
  },
430
1028
  )
@@ -453,7 +1051,7 @@ def test_a_redeclared_plans_tree_still_catches_a_legacy_lifecycle_dir(
453
1051
  build(
454
1052
  root,
455
1053
  {
456
- "roadmap/features/az1-x/spec.md": FEATURE.format(work_item=1),
1054
+ "roadmap/features/az1-x/intent.md": INTENT.format(work_item=1),
457
1055
  "roadmap/a_new/leftover.md": "# a leftover plan from before the migration\n",
458
1056
  },
459
1057
  )
@@ -490,12 +1088,7 @@ def test_ledger_md_is_allowed_at_the_plans_root(tmp_path: pathlib.Path) -> None:
490
1088
 
491
1089
 
492
1090
  def test_ledger_yaml_at_the_plans_root_is_reported(tmp_path: pathlib.Path) -> None:
493
- """The retired form is a finding, and the remedy names what IS allowed.
494
-
495
- The message is derived from ROOT_ALLOWED_FILES rather than restated: the
496
- hardcoded copy had drifted at both ends, naming `LEDGER.yaml` (no longer
497
- allowed) and omitting `GAPS-AND-FINDINGS.md` (allowed all along).
498
- """
1091
+ """The retired form is a finding, and the remedy names what IS allowed."""
499
1092
  verdict = graded_directly(
500
1093
  build(tmp_path, {
501
1094
  "plans/AGENTS.md": "x",