workflow-toolkit 1.0.1

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 (142) hide show
  1. package/.agents/skills/ponytail/SKILL.md +111 -0
  2. package/.agents/skills/ponytail-audit/SKILL.md +37 -0
  3. package/.agents/skills/ponytail-debt/SKILL.md +41 -0
  4. package/.agents/skills/ponytail-gain/SKILL.md +47 -0
  5. package/.agents/skills/ponytail-help/SKILL.md +70 -0
  6. package/.agents/skills/ponytail-review/SKILL.md +52 -0
  7. package/.agents/skills/prompt-review/SKILL.md +60 -0
  8. package/.agents/skills/wtk/SKILL.md +64 -0
  9. package/.agents/skills/wtk/references/artifacts.md +82 -0
  10. package/.agents/skills/wtk/references/evidence.md +68 -0
  11. package/.agents/skills/wtk/references/git.md +75 -0
  12. package/.agents/skills/wtk/references/test-contract.md +76 -0
  13. package/.agents/skills/wtk/references/validation.md +127 -0
  14. package/.agents/skills/wtk-config/SKILL.md +102 -0
  15. package/.agents/skills/wtk-config/assets/agents/claude/deep-reviewer.md +40 -0
  16. package/.agents/skills/wtk-config/assets/agents/claude/designer.md +57 -0
  17. package/.agents/skills/wtk-config/assets/agents/claude/explorer.md +41 -0
  18. package/.agents/skills/wtk-config/assets/agents/claude/implementer.md +57 -0
  19. package/.agents/skills/wtk-config/assets/agents/claude/planner.md +68 -0
  20. package/.agents/skills/wtk-config/assets/agents/claude/verifier.md +80 -0
  21. package/.agents/skills/wtk-config/assets/agents/codex/deep-reviewer.toml +41 -0
  22. package/.agents/skills/wtk-config/assets/agents/codex/designer.toml +55 -0
  23. package/.agents/skills/wtk-config/assets/agents/codex/explorer.toml +35 -0
  24. package/.agents/skills/wtk-config/assets/agents/codex/implementer.toml +52 -0
  25. package/.agents/skills/wtk-config/assets/agents/codex/planner.toml +66 -0
  26. package/.agents/skills/wtk-config/assets/agents/codex/verifier.toml +78 -0
  27. package/.agents/skills/wtk-config/assets/agents/cursor/deep-reviewer.md +38 -0
  28. package/.agents/skills/wtk-config/assets/agents/cursor/designer.md +55 -0
  29. package/.agents/skills/wtk-config/assets/agents/cursor/explorer.md +39 -0
  30. package/.agents/skills/wtk-config/assets/agents/cursor/implementer.md +55 -0
  31. package/.agents/skills/wtk-config/assets/agents/cursor/planner.md +66 -0
  32. package/.agents/skills/wtk-config/assets/agents/cursor/verifier.md +79 -0
  33. package/.agents/skills/wtk-config/scripts/ad-index.py +123 -0
  34. package/.agents/skills/wtk-config/scripts/repository_intelligence.py +671 -0
  35. package/.agents/skills/wtk-config/scripts/workflow_config.py +972 -0
  36. package/.agents/skills/wtk-deep-review/SKILL.md +164 -0
  37. package/.agents/skills/wtk-deep-review/assets/PROMPT.md +56 -0
  38. package/.agents/skills/wtk-deep-review/assets/REVIEW_UI.html +1340 -0
  39. package/.agents/skills/wtk-deep-review/assets/findings.schema.json +113 -0
  40. package/.agents/skills/wtk-deep-review/references/context-pack.md +88 -0
  41. package/.agents/skills/wtk-deep-review/references/orchestration.md +164 -0
  42. package/.agents/skills/wtk-deep-review/references/output-contracts.md +149 -0
  43. package/.agents/skills/wtk-deep-review/references/publish-github.md +83 -0
  44. package/.agents/skills/wtk-deep-review/references/state-and-learnings.md +64 -0
  45. package/.agents/skills/wtk-deep-review/references/subagent-runtimes.md +33 -0
  46. package/.agents/skills/wtk-deep-review/references/taxonomy.md +70 -0
  47. package/.agents/skills/wtk-deep-review/scripts/_common.py +371 -0
  48. package/.agents/skills/wtk-deep-review/scripts/build_jobs.py +561 -0
  49. package/.agents/skills/wtk-deep-review/scripts/build_knowledge.py +349 -0
  50. package/.agents/skills/wtk-deep-review/scripts/build_manifest.py +479 -0
  51. package/.agents/skills/wtk-deep-review/scripts/graft_context.py +160 -0
  52. package/.agents/skills/wtk-deep-review/scripts/graphify_context.py +75 -0
  53. package/.agents/skills/wtk-deep-review/scripts/merge_findings.py +313 -0
  54. package/.agents/skills/wtk-deep-review/scripts/render_html.py +240 -0
  55. package/.agents/skills/wtk-deep-review/scripts/render_review.py +282 -0
  56. package/.agents/skills/wtk-deep-review/scripts/run_jobs.py +436 -0
  57. package/.agents/skills/wtk-deep-review/scripts/token_metrics.py +369 -0
  58. package/.agents/skills/wtk-discover/SKILL.md +270 -0
  59. package/.agents/skills/wtk-discover/references/document-format.md +149 -0
  60. package/.agents/skills/wtk-implement/SKILL.md +95 -0
  61. package/.agents/skills/wtk-implement/references/checklist-format.md +116 -0
  62. package/.agents/skills/wtk-implement/references/screens.md +129 -0
  63. package/.agents/skills/wtk-implement/references/test-policy.md +152 -0
  64. package/.agents/skills/wtk-implement/references/verify.md +297 -0
  65. package/.agents/skills/wtk-knowledge-check/SKILL.md +16 -0
  66. package/.agents/skills/wtk-knowledge-check/scripts/check.ts +502 -0
  67. package/.agents/skills/wtk-knowledge-check/scripts/cli.ts +32 -0
  68. package/.agents/skills/wtk-knowledge-check/scripts/frontmatter.ts +44 -0
  69. package/.agents/skills/wtk-lean/.skill-meta.json +6 -0
  70. package/.agents/skills/wtk-lean/NOTICE.md +18 -0
  71. package/.agents/skills/wtk-lean/SKILL.md +137 -0
  72. package/.agents/skills/wtk-lean/references/build.md +168 -0
  73. package/.agents/skills/wtk-lean/references/checks.md +253 -0
  74. package/.agents/skills/wtk-lean/references/memory.md +156 -0
  75. package/.agents/skills/wtk-lean/references/plan.md +514 -0
  76. package/.agents/skills/wtk-lean/references/verify.md +336 -0
  77. package/.agents/skills/wtk-lean/scripts/check_commit.py +121 -0
  78. package/.agents/skills/wtk-lean/scripts/fixtures/checks.md +98 -0
  79. package/.agents/skills/wtk-lean/scripts/fixtures/plan.md +119 -0
  80. package/.agents/skills/wtk-lean/scripts/fixtures/verification.md +41 -0
  81. package/.agents/skills/wtk-lean/scripts/lessons.py +412 -0
  82. package/.agents/skills/wtk-lean/scripts/selftest.py +401 -0
  83. package/.agents/skills/wtk-lean/scripts/validate_checks.py +417 -0
  84. package/.agents/skills/wtk-lean/scripts/validate_plan.py +580 -0
  85. package/.agents/skills/wtk-lean/scripts/validate_verification.py +357 -0
  86. package/.agents/skills/wtk-plan/SKILL.md +102 -0
  87. package/.agents/skills/wtk-plan/references/document-format.md +222 -0
  88. package/.agents/skills/wtk-qa/SKILL.md +14 -0
  89. package/.agents/skills/wtk-qa-execute/SKILL.md +115 -0
  90. package/.agents/skills/wtk-qa-execute/references/fix-loop.md +23 -0
  91. package/.agents/skills/wtk-qa-execute/references/session-protocol.md +25 -0
  92. package/.agents/skills/wtk-qa-plan/SKILL.md +111 -0
  93. package/.agents/skills/wtk-qa-plan/references/profile.md +35 -0
  94. package/.agents/skills/wtk-ship/SKILL.md +49 -0
  95. package/.agents/skills/wtk-ship/remediation.py +170 -0
  96. package/.agents/skills/wtk-ship/scripts/close_feature.py +83 -0
  97. package/.agents/skills/wtk-ship/scripts/review_convergence.py +373 -0
  98. package/.wtk.toml.example +93 -0
  99. package/AGENTS.md +129 -0
  100. package/NOTICE.md +12 -0
  101. package/README.md +440 -0
  102. package/bin/wtk.js +25 -0
  103. package/docs/toolkit/README.md +56 -0
  104. package/docs/toolkit/decisions.md +61 -0
  105. package/docs/toolkit/guidelines/CONTEXT-BUDGET.md +64 -0
  106. package/docs/toolkit/guidelines/DX.md +67 -0
  107. package/docs/toolkit/guidelines/FRONTEND.md +73 -0
  108. package/docs/toolkit/guidelines/KNOWLEDGE-WIKI.md +128 -0
  109. package/docs/toolkit/guidelines/MODELING.md +134 -0
  110. package/docs/toolkit/guidelines/QA-EXECUTION.md +46 -0
  111. package/docs/toolkit/guidelines/QA-SCENARIOS.md +165 -0
  112. package/docs/toolkit/guidelines/REVIEW-ROUNDS.md +160 -0
  113. package/docs/toolkit/guidelines/SECURITY.md +160 -0
  114. package/docs/toolkit/guidelines/UI-UX.md +117 -0
  115. package/docs/toolkit/guidelines/WORKFLOW-MEMORY.md +73 -0
  116. package/docs/toolkit/guidelines.md +50 -0
  117. package/docs/toolkit/loop.md +77 -0
  118. package/docs/toolkit/purpose.md +51 -0
  119. package/docs/toolkit/repository-intelligence.md +53 -0
  120. package/docs/toolkit/reviews.md +77 -0
  121. package/knowledge/AGENTS.md +316 -0
  122. package/knowledge/raw/README.md +17 -0
  123. package/package.json +74 -0
  124. package/scripts/install_security_skills.py +816 -0
  125. package/scripts/installer/engine.js +254 -0
  126. package/scripts/installer/knowledge.js +28 -0
  127. package/scripts/installer/packets.js +77 -0
  128. package/scripts/installer/terminal.js +98 -0
  129. package/scripts/installer/transaction.js +75 -0
  130. package/skills-lock.json +92 -0
  131. package/templates/adoption/agents/core.md +23 -0
  132. package/templates/adoption/agents/quality.md +3 -0
  133. package/templates/adoption/knowledge/wiki/architecture/index.md +3 -0
  134. package/templates/adoption/knowledge/wiki/decisions/index.md +3 -0
  135. package/templates/adoption/knowledge/wiki/design/index.md +3 -0
  136. package/templates/adoption/knowledge/wiki/domain/index.md +3 -0
  137. package/templates/adoption/knowledge/wiki/index.md +3 -0
  138. package/templates/adoption/knowledge/wiki/log.md +3 -0
  139. package/templates/adoption/knowledge/wiki/open-questions/index.md +3 -0
  140. package/templates/adoption/knowledge/wiki/product/index.md +3 -0
  141. package/templates/adoption/knowledge/wiki/research/index.md +3 -0
  142. package/templates/adoption/product/AGENT-CONTEXT.md +25 -0
@@ -0,0 +1,580 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ validate_plan.py - deterministic gate for a feature plan.md, run before writing checks.
4
+
5
+ One artifact, one gate. The plan carries both halves a human confirms - what must be
6
+ true (problem, criteria, assumptions) and what is being built (flow, relations,
7
+ surface, landing, impact) - so this script turns the closure gate into a pass/fail run
8
+ instead of trusting the model to remember it.
9
+
10
+ The failures it exists to catch are the ones that make a plan lie rather than inform:
11
+
12
+ - a criterion with no SHALL, which reads like a requirement and cannot be tested;
13
+ - an assumption with no chosen default, which is an open question wearing a
14
+ decision's clothes;
15
+ - a section quietly absent, which is indistinguishable from "nothing to say here"
16
+ unless the file is required to say it out loud;
17
+ - a `Landing` row with no literal shape, which reads like a decision and cannot be
18
+ copied, so the next person re-decides it;
19
+ - a `Landing` row with no rejected alternative, which is the difference between a
20
+ decision and a description of what happened;
21
+ - columns and types inside `Relations`, which is the exact detail that goes stale and
22
+ then misleads the next reader with the authority of a written diagram;
23
+ - a `Surface` row with no statuses, since the statuses are what becomes a Coverage set
24
+ in checks.md - drop them here and the join has nothing to enumerate.
25
+
26
+ Pure standard library, zero dependencies. Operates only on the plan.md markdown
27
+ artifact - never on the target codebase - so it stays stack-agnostic.
28
+
29
+ What it checks:
30
+ ERROR - a required section is missing
31
+ ERROR - an acceptance criterion has no SHALL (not testable / not EARS-shaped)
32
+ ERROR - an Assumptions row has an empty "Chosen default" or "Rationale" cell
33
+ ERROR - a Traceability row has a malformed requirement ID
34
+ ERROR - the Observable section is empty, or has no rows and no `None - <why>`
35
+ ERROR - an Observable row whose Landing cell is blank
36
+ ERROR - an Observable `n/a` or `existing` landing with no reason after it
37
+ ERROR - Flow, Relations, Surface or Impact is empty, or still the template placeholder
38
+ (each has a one-line "None - <why>" answer, so a blank is never "nothing to say")
39
+ ERROR - the Landing section is empty (state `None - <why>` instead)
40
+ ERROR - Landing has no door rows and does not state `None - <why>`
41
+ ERROR - a Landing row with no literal shape
42
+ ERROR - a Landing row with no rejected alternative named
43
+ ERROR - a Relations diagram carrying an attribute block: columns and types go stale
44
+ ERROR - a Surface row whose Status cell names no status code
45
+ ERROR - a check id written into Surface, which cannot exist before checks.md does
46
+ WARN - an AC has SHALL but no recognizable EARS lead keyword
47
+ WARN - an AC carries a vague word instead of a concrete value
48
+ WARN - no numbered acceptance criteria found at all
49
+ WARN - template placeholder rows are still present (plan not filled in)
50
+ WARN - open questions are not explicitly resolved
51
+ WARN - no Sources section ('nothing' is valid, a missing section is not)
52
+ WARN - an Impact section with no rows
53
+ WARN - a Flow hop, or a flowchart node, naming a module marked neither existing
54
+ nor as a Landing door
55
+ WARN - a source marked binding (only Verify step 1 opens those, and only at profile ui)
56
+
57
+ Usage:
58
+ python3 <skill-dir>/scripts/validate_plan.py [target] [--root DIR] [--strict]
59
+
60
+ Invoke from the skill directory that ships this script (not the project root).
61
+ target Path to a plan.md, a feature directory, a feature name, or a project root.
62
+ Omitted -> auto-detect the single feature under <root>/.specs/features/.
63
+ --root Project root that contains .specs/ (default: current dir).
64
+ --strict Treat warnings as errors.
65
+
66
+ Exit codes: 0 pass, 1 errors found (or warnings under --strict), 2 usage error.
67
+ """
68
+
69
+ import argparse
70
+ import os
71
+ import re
72
+ import sys
73
+
74
+ # Each entry is a tuple of acceptable heading names (first is canonical).
75
+ REQUIRED_SECTIONS = [
76
+ ("Problem", "Problem Statement"),
77
+ ("Out of scope", "Out of Scope"),
78
+ ("Assumptions", "Assumptions & Open Questions"),
79
+ ("Criteria", "User Stories"),
80
+ ("Traceability", "Requirement Traceability"),
81
+ ("Observable",),
82
+ ("Flow",),
83
+ ("Relations",),
84
+ ("Surface",),
85
+ ("Landing",),
86
+ ("Impact",),
87
+ ]
88
+ ADVISORY_SECTIONS = ["Sources"]
89
+
90
+ # The shape half: each has a one-line answer when it does not apply.
91
+ SHAPE_HINTS = (
92
+ ("Flow", "state the hops in order, or `single module - <name>`"),
93
+ ("Relations", "state the entities and cardinality, or `None - no stored-data shape change`"),
94
+ ("Surface", "state the route signature, or `None - nothing consumed outside`"),
95
+ ("Impact", "state what changes underneath, or `nothing` - a missing row is not an answer"),
96
+ )
97
+
98
+ ID_RE = re.compile(r"^[A-Z][A-Z0-9]*-\d+$")
99
+ PLACEHOLDER_RE = re.compile(r"^\s*[\[<].+[\]>]\s*$")
100
+ CID_RE = re.compile(r"\bC\d+\b")
101
+ STATUS_RE = re.compile(r"\b[1-5]\d\d\b")
102
+ NONE_RE = re.compile(r"\b(none|nothing|n/?a|single module)\b", re.IGNORECASE)
103
+ VAGUE_RE = re.compile(
104
+ r"\b(gracefully|properly|correctly|quickly|fast|slow|efficiently|reasonably|"
105
+ r"appropriately|as expected|user-friendly|robust)\b",
106
+ re.IGNORECASE,
107
+ )
108
+ HEADER_CELL_RE = re.compile(
109
+ r"^(assumption|#|id|kind|criterion|slice|set|check|front|decision|excluded)\b", re.IGNORECASE
110
+ )
111
+
112
+ # `Entity { string name }` in an erDiagram is the columns-and-types syntax, which is
113
+ # precisely the reversible detail this artifact keeps out.
114
+ ER_ATTRIBUTE_RE = re.compile(r"^\s*\w+\s*\{\s*$")
115
+
116
+ # A hop is answered when it says the module already exists, or names the door that creates it.
117
+ # `out:` cannot keep a trailing \b: the colon is not a word character, so the
118
+ # boundary never fires and an `out:` hop that names a slug in backticks is
119
+ # misread as an unresolved module.
120
+ HOP_RESOLVED_RE = re.compile(r"\b(exists|existing|new\b|door\s*\d+)|out\s*:", re.IGNORECASE)
121
+ MODULE_RE = re.compile(r"`([^`]+)`")
122
+ # A mermaid flowchart node: `A[Label]`, `A["Label"]`, `A(Label)`. The label is what has to
123
+ # carry the resolution marker, the same as a list hop does.
124
+ NODE_LABEL_RE = re.compile(r"[\[(]\s*\"?([^\"\]()|]+?)\"?\s*[\])]")
125
+ EDGE_LINE_RE = re.compile(r"--+>|--+\s")
126
+
127
+
128
+ def resolve_plan(target, root):
129
+ """Return the path to a plan.md from a file, dir, feature name, or auto-detect."""
130
+ if target:
131
+ if os.path.isfile(target):
132
+ return target
133
+ if os.path.isdir(target):
134
+ cand = os.path.join(target, "plan.md")
135
+ if os.path.isfile(cand):
136
+ return cand
137
+ return _autodetect(target)
138
+ cand = os.path.join(root, ".specs", "features", target, "plan.md")
139
+ if os.path.isfile(cand):
140
+ return cand
141
+ return None
142
+ return _autodetect(root)
143
+
144
+
145
+ def _autodetect(root):
146
+ base = os.path.join(root, ".specs", "features")
147
+ if not os.path.isdir(base):
148
+ return None
149
+ features = [
150
+ d for d in sorted(os.listdir(base))
151
+ if os.path.isfile(os.path.join(base, d, "plan.md"))
152
+ ]
153
+ if len(features) == 1:
154
+ return os.path.join(base, features[0], "plan.md")
155
+ if len(features) == 0:
156
+ return None
157
+ raise SystemExit(
158
+ "validate_plan: multiple features found; pass one explicitly:\n "
159
+ + "\n ".join(os.path.join(base, f, "plan.md") for f in features)
160
+ )
161
+
162
+
163
+ def split_row(line):
164
+ return [c.strip() for c in line.strip().strip("|").split("|")]
165
+
166
+
167
+ def is_separator(line):
168
+ return bool(re.match(r"^\s*\|?[\s:|-]+\|?\s*$", line)) and "-" in line
169
+
170
+
171
+ def section_bounds(lines, names):
172
+ """Return (start, end) body indices for the first heading matching any name."""
173
+ if isinstance(names, str):
174
+ names = (names,)
175
+ pattern = re.compile(
176
+ r"^#{1,4}\s+(?:" + "|".join(re.escape(n) for n in names) + r")\b.*$",
177
+ re.IGNORECASE,
178
+ )
179
+ start = None
180
+ for i, ln in enumerate(lines):
181
+ if pattern.match(ln.strip()):
182
+ start = i + 1
183
+ break
184
+ if start is None:
185
+ return None
186
+ end = len(lines)
187
+ for j in range(start, len(lines)):
188
+ if re.match(r"^#{1,2}\s+\S", lines[j]): # only a top-level heading closes it
189
+ end = j
190
+ break
191
+ return (start, end)
192
+
193
+
194
+ def first_table(lines, bounds):
195
+ """Return the data rows of the FIRST contiguous markdown table in a section.
196
+
197
+ Scoping to the first table matters: a section may carry a second table (e.g. an
198
+ unresolved-questions table under Assumptions), and validating its rows against
199
+ the first table's column meaning produces false errors.
200
+ """
201
+ if not bounds:
202
+ return []
203
+ rows, started = [], False
204
+ for i in range(*bounds):
205
+ stripped = lines[i].strip()
206
+ if stripped.startswith("|"):
207
+ started = True
208
+ rows.append(stripped)
209
+ elif started and stripped == "":
210
+ continue
211
+ elif started:
212
+ break
213
+ data = [r for r in rows if not is_separator(r)]
214
+ return data[1:] if data else [] # the first row is the header
215
+
216
+
217
+ def strip_fences(lines, keep=("mermaid",)):
218
+ """
219
+ Drop fenced code blocks so a template example inside the file is not parsed.
220
+
221
+ Blocks whose info string matches `keep` survive: a mermaid erDiagram is content in
222
+ this artifact, not an example, and the columns check has to see inside it.
223
+ """
224
+ out, fence_info = [], None
225
+ for ln in lines:
226
+ stripped = ln.strip()
227
+ if stripped.startswith("```"):
228
+ if fence_info is None:
229
+ fence_info = stripped.strip("`").strip().lower()
230
+ else:
231
+ fence_info = None
232
+ out.append("")
233
+ continue
234
+ out.append(ln if (fence_info is None or fence_info in keep) else "")
235
+ return out
236
+
237
+
238
+ def classify_ears(text):
239
+ """Return (ok, note). ok requires a SHALL; note records the EARS pattern."""
240
+ low = text.strip().lower()
241
+ if not re.search(r"\bshall\b", low):
242
+ return (False, "no SHALL")
243
+ kws = []
244
+ if re.search(r"\bwhile\b", low):
245
+ kws.append("WHILE")
246
+ if re.search(r"\bwhen\b", low):
247
+ kws.append("WHEN")
248
+ if re.match(r"^\s*if\b", low) or re.search(r"\bif\b.*\bthen\b", low):
249
+ kws.append("IF/THEN")
250
+ if re.search(r"\bwhere\b", low):
251
+ kws.append("WHERE")
252
+ if len(kws) >= 2:
253
+ return (True, "complex (" + "+".join(kws) + ")")
254
+ if kws:
255
+ return (True, {
256
+ "WHILE": "state-driven",
257
+ "WHEN": "event-driven",
258
+ "IF/THEN": "unwanted-behavior",
259
+ "WHERE": "optional-feature",
260
+ }[kws[0]])
261
+ if re.match(r"^\s*the\b", low):
262
+ return (True, "ubiquitous")
263
+ return (True, "warn: SHALL present but no EARS lead keyword")
264
+
265
+
266
+ def sibling_profile(plan_path):
267
+ """The profile checks.md declares, when it exists yet."""
268
+ checks = os.path.join(os.path.dirname(os.path.abspath(plan_path)), "checks.md")
269
+ if not os.path.isfile(checks):
270
+ return None
271
+ with open(checks, "r", encoding="utf-8") as f:
272
+ for ln in f:
273
+ m = re.match(r"^\**Profile\**\s*:\s*`?(\w+)`?", ln.strip(), re.IGNORECASE)
274
+ if m:
275
+ return m.group(1).lower()
276
+ return None
277
+
278
+
279
+ def check_criteria(lines):
280
+ """EARS shape of every numbered acceptance criterion."""
281
+ errors, warnings = [], []
282
+ in_ac, ac_count, blanks = False, 0, 0
283
+ # A blank line does NOT close the block - well-formed markdown puts one between the
284
+ # label and the list, and treating it as a terminator silently skips every criterion.
285
+ for i, ln in enumerate(lines, start=1):
286
+ stripped = ln.strip()
287
+ if re.match(r"^\*{0,2}Acceptance Criteria\*{0,2}\s*:?\s*$", stripped, re.IGNORECASE):
288
+ in_ac, blanks = True, 0
289
+ continue
290
+ if not in_ac:
291
+ continue
292
+ if stripped == "":
293
+ blanks += 1
294
+ if blanks >= 2:
295
+ in_ac = False
296
+ continue
297
+ blanks = 0
298
+ m = re.match(r"^\s*\d+\.\s+(.*)$", ln)
299
+ if m:
300
+ item = m.group(1).strip()
301
+ if PLACEHOLDER_RE.match(item):
302
+ continue
303
+ ac_count += 1
304
+ ok, note = classify_ears(item)
305
+ if not ok:
306
+ errors.append(f"L{i}: acceptance criterion has no SHALL (not testable): {item[:70]}")
307
+ elif note.startswith("warn"):
308
+ warnings.append(
309
+ f"L{i}: AC has SHALL but no EARS keyword (WHEN/WHILE/WHERE/IF or "
310
+ f"ubiquitous 'The … shall'): {item[:60]}"
311
+ )
312
+ vague = VAGUE_RE.search(item)
313
+ if vague:
314
+ warnings.append(f"L{i}: AC uses '{vague.group(0)}' instead of a concrete value: {item[:60]}")
315
+ elif re.match(r"^#{1,4}\s", ln) or stripped.startswith("**") or re.match(r"^\s*[-*+]\s", ln):
316
+ in_ac = False
317
+ if ac_count == 0:
318
+ warnings.append("no numbered acceptance criteria found - is the plan filled in?")
319
+ return errors, warnings
320
+
321
+
322
+ def _warn_unresolved(warnings, idx, names, land_text, kind):
323
+ """Warn when no name in `names` is marked as existing or present in Landing."""
324
+ if not names:
325
+ return
326
+ if any(n.lower() in land_text for n in names):
327
+ return
328
+ warnings.append(
329
+ f"L{idx + 1}: Flow {kind} names `{names[0]}` without marking it as existing or as a "
330
+ "Landing door - if it is neither, it is placement and belongs in the diff"
331
+ )
332
+
333
+
334
+ def check_file(path):
335
+ with open(path, "r", encoding="utf-8") as f:
336
+ raw = f.read().splitlines()
337
+ lines = strip_fences(raw)
338
+ errors, warnings = [], []
339
+
340
+ # Sections. A missing one and a deliberate "None" are different answers, and only the
341
+ # second is contestable - which is the whole reason every section is required.
342
+ present = {}
343
+ for names in REQUIRED_SECTIONS:
344
+ b = section_bounds(lines, names)
345
+ present[names[0]] = b
346
+ if b is None:
347
+ errors.append(f"missing required section: ## {names[0]}")
348
+ for name in ADVISORY_SECTIONS:
349
+ label = re.compile(r"^\**" + re.escape(name) + r"\**\s*:", re.IGNORECASE)
350
+ if section_bounds(lines, name) is None and not any(label.match(ln.strip()) for ln in lines):
351
+ warnings.append(f"no {name} section - 'nothing' is a valid answer, a missing section is not")
352
+
353
+ # A source marked binding is only ever opened by step 1 of Verify, which runs at `ui`.
354
+ if any(re.search(r"\bbinding\b", ln, re.IGNORECASE) for ln in lines):
355
+ profile = sibling_profile(path)
356
+ if profile and profile != "ui":
357
+ warnings.append(
358
+ f"a source is marked binding but checks.md declares profile {profile}, so nobody "
359
+ "opens it - raise the profile to ui or drop the marking"
360
+ )
361
+
362
+ # --- what must be true ---
363
+
364
+ e, w = check_criteria(lines)
365
+ errors += e
366
+ warnings += w
367
+
368
+ b = present.get("Assumptions")
369
+ if b:
370
+ template_seen = False
371
+ for r in first_table(lines, b):
372
+ cells = split_row(r)
373
+ if len(cells) < 3:
374
+ continue
375
+ assumption, chosen, rationale = cells[0], cells[1], cells[2]
376
+ if PLACEHOLDER_RE.match(assumption) and PLACEHOLDER_RE.match(chosen):
377
+ template_seen = True
378
+ continue
379
+ if not assumption:
380
+ continue
381
+ if not chosen or PLACEHOLDER_RE.match(chosen):
382
+ errors.append(f"assumption '{assumption[:40]}' has empty 'Chosen default'")
383
+ if not rationale or PLACEHOLDER_RE.match(rationale):
384
+ errors.append(f"assumption '{assumption[:40]}' has empty 'Rationale'")
385
+ if template_seen:
386
+ warnings.append("Assumptions table still contains template placeholder rows")
387
+ oq = [lines[i] for i in range(*b) if "open questions" in lines[i].lower()]
388
+ oq_clean = re.sub(r"[*_]", "", " ".join(oq)).lower()
389
+ if not oq:
390
+ warnings.append("no 'Open questions:' line in the Assumptions section")
391
+ elif not re.search(r"open questions.*:\s*none", oq_clean):
392
+ warnings.append("open questions do not read as resolved ('Open questions: none')")
393
+
394
+ b = present.get("Traceability")
395
+ if b:
396
+ template_seen, real_ids = False, 0
397
+ for r in first_table(lines, b):
398
+ cells = split_row(r)
399
+ if not cells or not cells[0]:
400
+ continue
401
+ rid = cells[0]
402
+ if PLACEHOLDER_RE.match(rid) or "[" in rid or "<" in rid:
403
+ template_seen = True
404
+ continue
405
+ if not ID_RE.match(rid):
406
+ errors.append(f"malformed requirement ID: '{rid}' (expected e.g. AUTH-01)")
407
+ else:
408
+ real_ids += 1
409
+ if template_seen and real_ids == 0:
410
+ warnings.append("Traceability has only template rows (no real IDs yet)")
411
+
412
+ # Observable: every item of every surface present. A surface carries the same decisions
413
+ # every time it appears, so a blank here is an item nobody decided - not one that does not
414
+ # apply, which is what the mandatory `n/a - <reason>` escape is for.
415
+ obs = present.get("Observable")
416
+ if obs:
417
+ body = [lines[i].strip() for i in range(*obs) if lines[i].strip()]
418
+ rows = first_table(lines, obs)
419
+ declares_none = any(NONE_RE.search(x) for x in body)
420
+ if not body:
421
+ errors.append(
422
+ "Observable section is empty - walk each surface's decisions, or state "
423
+ "`None - no user-facing surface`"
424
+ )
425
+ elif not rows and not declares_none:
426
+ errors.append("Observable has no rows and does not state `None - no user-facing surface`")
427
+ for r in rows:
428
+ cells = split_row(r)
429
+ if len(cells) < 3 or not cells[0] or PLACEHOLDER_RE.match(cells[0]):
430
+ continue
431
+ label = f"{cells[0][:28]} / {cells[1][:28]}"
432
+ landing = cells[2]
433
+ if not landing or PLACEHOLDER_RE.match(landing) or landing in ("-", "\u2014"):
434
+ errors.append(
435
+ f"Observable '{label}': landing is blank - a criterion, `existing - <what>`, "
436
+ "or `n/a - <reason>`"
437
+ )
438
+ continue
439
+ low = landing.lower()
440
+ for kw in ("n/a", "na -", "existing"):
441
+ if low.startswith(kw):
442
+ rest = landing[len(kw):].strip(" -\u2013\u2014:")
443
+ if len(rest) < 3:
444
+ errors.append(
445
+ f"Observable '{label}': `{kw}` with no reason - say why it does not "
446
+ "apply, or name what already behaves that way"
447
+ )
448
+ break
449
+
450
+ # --- what is being built ---
451
+
452
+ for sec, hint in SHAPE_HINTS:
453
+ b = present.get(sec)
454
+ if not b:
455
+ continue
456
+ body = [lines[i].strip() for i in range(*b) if lines[i].strip()]
457
+ if not body:
458
+ errors.append(f"{sec} section is empty - {hint}")
459
+ elif all(PLACEHOLDER_RE.match(x) for x in body):
460
+ errors.append(f"{sec} section is still the template placeholder")
461
+
462
+ # Relations: no columns, no types. In mermaid that detail is an attribute block.
463
+ rel = present.get("Relations")
464
+ if rel:
465
+ for i in range(*rel):
466
+ if ER_ATTRIBUTE_RE.match(lines[i]):
467
+ errors.append(
468
+ f"L{i + 1}: Relations carries an attribute block ('{lines[i].strip()}') - columns and "
469
+ "types are reversible, come from the repo's conventions, and go stale here"
470
+ )
471
+ break
472
+
473
+ # Surface: statuses are what becomes a Coverage set in checks.md.
474
+ surf = present.get("Surface")
475
+ if surf:
476
+ body = [lines[i].strip() for i in range(*surf) if lines[i].strip()]
477
+ declares_none = any(NONE_RE.search(x) for x in body)
478
+ rows = first_table(lines, surf)
479
+ for r in rows:
480
+ cells = split_row(r)
481
+ if not cells or not cells[0] or PLACEHOLDER_RE.match(cells[0]):
482
+ continue
483
+ status_cell = cells[3] if len(cells) > 3 else ""
484
+ if not STATUS_RE.search(status_cell):
485
+ errors.append(
486
+ f"Surface '{cells[0][:48]}': Status names no status code - those statuses are the "
487
+ "set that owes a Coverage row in checks.md"
488
+ )
489
+ for i in range(*surf):
490
+ if CID_RE.search(lines[i]):
491
+ errors.append(
492
+ f"L{i + 1}: Surface names a check id, but checks.md does not exist yet - each route's "
493
+ "statuses become a Coverage set there instead"
494
+ )
495
+ break
496
+ if not rows and not declares_none:
497
+ errors.append("Surface has no route rows and does not state `None - nothing consumed outside`")
498
+
499
+ # Landing: the doors.
500
+ land = present.get("Landing")
501
+ if land:
502
+ body = [lines[i].strip() for i in range(*land) if lines[i].strip()]
503
+ rows_data = first_table(lines, land)
504
+ declares_none = any(NONE_RE.search(x) for x in body)
505
+ if not body:
506
+ errors.append("Landing section is empty - state `None - <why nothing here is one-way>`")
507
+ elif not rows_data and not declares_none:
508
+ errors.append(
509
+ "Landing has no door rows and does not state `None - <why>` - the omission has to be contestable"
510
+ )
511
+ for r in rows_data:
512
+ cells = split_row(r)
513
+ if len(cells) >= 3 and cells[0] and not PLACEHOLDER_RE.match(cells[0]):
514
+ if not cells[1] or PLACEHOLDER_RE.match(cells[1]):
515
+ errors.append(f"Landing '{cells[0][:40]}': no literal shape - the next person copies this")
516
+ if not cells[2] or PLACEHOLDER_RE.match(cells[2]):
517
+ errors.append(f"Landing '{cells[0][:40]}': no rejected alternative named")
518
+
519
+ # Impact: a section with no rows is a sweep nobody did.
520
+ imp = present.get("Impact")
521
+ if imp:
522
+ body = [lines[i].strip() for i in range(*imp) if lines[i].strip()]
523
+ if body and not first_table(lines, imp) and not any(x.startswith(("-", "*")) for x in body):
524
+ warnings.append("Impact has neither rows nor bullets - name the fronts, even to say nothing changes")
525
+
526
+ # Flow: a module that neither exists nor is created by a door is placement, which this
527
+ # artifact deliberately leaves to the diff. Naming one here is how the catalogue creeps back.
528
+ flow = present.get("Flow")
529
+ if flow:
530
+ land_text = "\n".join(lines[i] for i in range(*land)).lower() if land else ""
531
+ for i in range(*flow):
532
+ ln = lines[i].strip()
533
+ if not re.match(r"^\s*(\d+\.|[-*])\s", ln):
534
+ continue
535
+ if HOP_RESOLVED_RE.search(ln):
536
+ continue
537
+ _warn_unresolved(warnings, i, MODULE_RE.findall(ln), land_text, "hop")
538
+
539
+ # A branching path is drawn instead of listed, and the same rule holds inside the
540
+ # diagram: a node that neither exists nor is created by a door is placement.
541
+ for i in range(*flow):
542
+ ln = lines[i]
543
+ if not EDGE_LINE_RE.search(ln) or HOP_RESOLVED_RE.search(ln):
544
+ continue
545
+ labels = [lbl.strip() for lbl in NODE_LABEL_RE.findall(ln) if lbl.strip()]
546
+ _warn_unresolved(warnings, i, labels, land_text, "diagram node")
547
+
548
+ return errors, warnings
549
+
550
+
551
+ def main(argv=None):
552
+ p = argparse.ArgumentParser(
553
+ prog="validate_plan.py",
554
+ description="Pre-checks gate for a feature plan.md: the criteria, and the shape they land in.",
555
+ )
556
+ p.add_argument("target", nargs="?", default=None)
557
+ p.add_argument("--root", default=".")
558
+ p.add_argument("--strict", action="store_true")
559
+ args = p.parse_args(argv)
560
+
561
+ path = resolve_plan(args.target, args.root)
562
+ if not path:
563
+ print(
564
+ "validate_plan: could not locate a plan.md. Pass a path or run from the project root.",
565
+ file=sys.stderr,
566
+ )
567
+ return 2
568
+
569
+ errors, warnings = check_file(path)
570
+ for w in warnings:
571
+ print(f" WARN {w}")
572
+ for e in errors:
573
+ print(f" ERROR {e}")
574
+ fail = errors or (warnings and args.strict)
575
+ print(f"\nvalidate_plan: {len(errors)} error(s), {len(warnings)} warning(s) in {path}")
576
+ return 1 if fail else 0
577
+
578
+
579
+ if __name__ == "__main__":
580
+ raise SystemExit(main())