@gobing-ai/spur 0.3.41 → 0.3.43

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 (185) hide show
  1. package/.claude-plugin/marketplace.json +15 -0
  2. package/README.md +15 -1
  3. package/config/templates/AGENTS.md +5 -3
  4. package/config/workflows/task-pipeline.yaml +113 -12
  5. package/package.json +15 -12
  6. package/plugins/README.md +655 -0
  7. package/plugins/sp/README.md +621 -0
  8. package/plugins/sp/agents/expert-spur.md +109 -0
  9. package/plugins/sp/agents/super-coder.md +126 -0
  10. package/plugins/sp/agents/super-planner.md +283 -0
  11. package/plugins/sp/agents/super-reviewer.md +154 -0
  12. package/plugins/sp/commands/dev-arch.md +30 -0
  13. package/plugins/sp/commands/dev-brainstorm.md +40 -0
  14. package/plugins/sp/commands/dev-changelog.md +28 -0
  15. package/plugins/sp/commands/dev-daily.md +29 -0
  16. package/plugins/sp/commands/dev-debug.md +29 -0
  17. package/plugins/sp/commands/dev-dogfood.md +33 -0
  18. package/plugins/sp/commands/dev-featurechange.md +130 -0
  19. package/plugins/sp/commands/dev-find-conflict.md +51 -0
  20. package/plugins/sp/commands/dev-find-issue.md +76 -0
  21. package/plugins/sp/commands/dev-find-next.md +60 -0
  22. package/plugins/sp/commands/dev-fixall.md +33 -0
  23. package/plugins/sp/commands/dev-gitmsg.md +28 -0
  24. package/plugins/sp/commands/dev-gtd.md +97 -0
  25. package/plugins/sp/commands/dev-handover.md +26 -0
  26. package/plugins/sp/commands/dev-idea.md +50 -0
  27. package/plugins/sp/commands/dev-next.md +31 -0
  28. package/plugins/sp/commands/dev-parallel.md +30 -0
  29. package/plugins/sp/commands/dev-plan.md +46 -0
  30. package/plugins/sp/commands/dev-refine.md +48 -0
  31. package/plugins/sp/commands/dev-refineall.md +65 -0
  32. package/plugins/sp/commands/dev-refresh.md +32 -0
  33. package/plugins/sp/commands/dev-reverse.md +32 -0
  34. package/plugins/sp/commands/dev-review.md +31 -0
  35. package/plugins/sp/commands/dev-run.md +63 -0
  36. package/plugins/sp/commands/dev-runall.md +83 -0
  37. package/plugins/sp/commands/dev-simplify.md +39 -0
  38. package/plugins/sp/commands/dev-unit.md +29 -0
  39. package/plugins/sp/commands/dev-verify.md +48 -0
  40. package/plugins/sp/commands/dev-verifyall.md +72 -0
  41. package/plugins/sp/commands/dev-wrap.md +48 -0
  42. package/plugins/sp/commands/dev-wrapall.md +51 -0
  43. package/plugins/sp/commands/rule-add.md +18 -0
  44. package/plugins/sp/commands/rule-refine.md +18 -0
  45. package/plugins/sp/commands/rule-scan.md +18 -0
  46. package/plugins/sp/commands/spur-init.md +22 -0
  47. package/plugins/sp/commands/workflow-add.md +18 -0
  48. package/plugins/sp/commands/workflow-refine.md +18 -0
  49. package/plugins/sp/hooks/agent-hint.ts +60 -0
  50. package/plugins/sp/hooks/careful-guard.ts +147 -0
  51. package/plugins/sp/hooks/context-post-tool.ts +325 -0
  52. package/plugins/sp/hooks/context-session-start.ts +182 -0
  53. package/plugins/sp/hooks/context-session-stop.ts +95 -0
  54. package/plugins/sp/hooks/hooks.json +51 -0
  55. package/plugins/sp/hooks/pi/guard-extension.ts +341 -0
  56. package/plugins/sp/hooks/task-write-guard.ts +84 -0
  57. package/plugins/sp/plugin.json +8 -0
  58. package/plugins/sp/scripts/batch-preflight.ts +204 -0
  59. package/plugins/sp/scripts/daily-summary/daily-summary.ts +606 -0
  60. package/plugins/sp/scripts/daily-summary/logger.ts +28 -0
  61. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +363 -0
  62. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +168 -0
  63. package/plugins/sp/scripts/feature-sync-bounded.ts +451 -0
  64. package/plugins/sp/scripts/stage-registry-adapter.ts +1341 -0
  65. package/plugins/sp/scripts/task-size-precheck.ts +202 -0
  66. package/plugins/sp/scripts/validate-commands.ts +651 -0
  67. package/plugins/sp/scripts/validate-flag-contracts.ts +875 -0
  68. package/plugins/sp/skills/brainstorm/SKILL.md +361 -0
  69. package/plugins/sp/skills/brainstorm/agents/openai.yaml +14 -0
  70. package/plugins/sp/skills/brainstorm/examples/ideation-example.md +209 -0
  71. package/plugins/sp/skills/brainstorm/metadata.openclaw +7 -0
  72. package/plugins/sp/skills/brainstorm/references/grilling-interview.md +68 -0
  73. package/plugins/sp/skills/brainstorm/references/workflows.md +255 -0
  74. package/plugins/sp/skills/branch-workflow/SKILL.md +109 -0
  75. package/plugins/sp/skills/branch-workflow/references/branch-lifecycle.md +69 -0
  76. package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +100 -0
  77. package/plugins/sp/skills/code-implementation/SKILL.md +203 -0
  78. package/plugins/sp/skills/code-implementation/references/debugging.md +64 -0
  79. package/plugins/sp/skills/code-implementation/references/implementation-patterns.md +113 -0
  80. package/plugins/sp/skills/code-improvement/SKILL.md +231 -0
  81. package/plugins/sp/skills/code-improvement/references/deepening-signals.md +205 -0
  82. package/plugins/sp/skills/code-review/SKILL.md +112 -0
  83. package/plugins/sp/skills/code-review/references/receiving-code-review.md +70 -0
  84. package/plugins/sp/skills/code-review/references/review-lenses.md +117 -0
  85. package/plugins/sp/skills/code-review/references/self-review-checklist.md +53 -0
  86. package/plugins/sp/skills/code-review/references/verification-before-completion.md +68 -0
  87. package/plugins/sp/skills/code-simplification/SKILL.md +225 -0
  88. package/plugins/sp/skills/code-testing/SKILL.md +108 -0
  89. package/plugins/sp/skills/code-testing/references/stacks/bun-ts.md +165 -0
  90. package/plugins/sp/skills/code-testing/references/stacks/go.md +88 -0
  91. package/plugins/sp/skills/code-testing/references/stacks/python.md +117 -0
  92. package/plugins/sp/skills/code-testing/references/test-loop-breaker.md +37 -0
  93. package/plugins/sp/skills/code-testing/references/test-output-discipline.md +52 -0
  94. package/plugins/sp/skills/code-testing/references/unit-testing.md +212 -0
  95. package/plugins/sp/skills/code-verification/SKILL.md +535 -0
  96. package/plugins/sp/skills/code-verification/references/code-improvement.md +39 -0
  97. package/plugins/sp/skills/code-verification/references/secu-review.md +94 -0
  98. package/plugins/sp/skills/code-verification/references/verdict-schema.md +141 -0
  99. package/plugins/sp/skills/conflict-finding/SKILL.md +254 -0
  100. package/plugins/sp/skills/conflict-finding/references/authority-resolution.md +258 -0
  101. package/plugins/sp/skills/conflict-finding/references/comparison-protocol.md +277 -0
  102. package/plugins/sp/skills/conflict-finding/references/finding-contract.md +346 -0
  103. package/plugins/sp/skills/conflict-finding/references/remediation-routing.md +276 -0
  104. package/plugins/sp/skills/daily-summary/SKILL.md +171 -0
  105. package/plugins/sp/skills/daily-summary/agents/openai.yaml +13 -0
  106. package/plugins/sp/skills/doc-evolve/SKILL.md +175 -0
  107. package/plugins/sp/skills/doc-evolve/references/operations.md +93 -0
  108. package/plugins/sp/skills/dogfood-testing/SKILL.md +608 -0
  109. package/plugins/sp/skills/dogfood-testing/references/monitor-ledger.md +211 -0
  110. package/plugins/sp/skills/dogfood-testing/references/report-template.md +351 -0
  111. package/plugins/sp/skills/doubt-driven-development/SKILL.md +136 -0
  112. package/plugins/sp/skills/functional-review/SKILL.md +364 -0
  113. package/plugins/sp/skills/functional-review/references/verdict-schema.md +133 -0
  114. package/plugins/sp/skills/indexed-context/SKILL.md +164 -0
  115. package/plugins/sp/skills/issue-finding/SKILL.md +442 -0
  116. package/plugins/sp/skills/issue-finding/agents/openai.yaml +15 -0
  117. package/plugins/sp/skills/issue-finding/examples/expected-findings.json +27 -0
  118. package/plugins/sp/skills/issue-finding/examples/session-test-loop.jsonl +23 -0
  119. package/plugins/sp/skills/issue-finding/references/session-formats.md +151 -0
  120. package/plugins/sp/skills/next-feature/SKILL.md +125 -0
  121. package/plugins/sp/skills/next-feature/references/handoff-routing.md +92 -0
  122. package/plugins/sp/skills/next-feature/references/proposal-contract.md +49 -0
  123. package/plugins/sp/skills/next-feature/references/ranking-rubric.md +52 -0
  124. package/plugins/sp/skills/next-feature/references/signal-derivation.md +67 -0
  125. package/plugins/sp/skills/next-router/SKILL.md +181 -0
  126. package/plugins/sp/skills/next-router/references/messages.md +90 -0
  127. package/plugins/sp/skills/next-router/references/routing-table.md +216 -0
  128. package/plugins/sp/skills/parallel-execution/SKILL.md +148 -0
  129. package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +109 -0
  130. package/plugins/sp/skills/parallel-execution/references/fan-out-patterns.md +101 -0
  131. package/plugins/sp/skills/parallel-execution/references/result-synthesis.md +94 -0
  132. package/plugins/sp/skills/reverse-engineering/SKILL.md +375 -0
  133. package/plugins/sp/skills/reverse-engineering/agents/openai.yaml +18 -0
  134. package/plugins/sp/skills/source-driven-development/SKILL.md +124 -0
  135. package/plugins/sp/skills/spec-decomposition/SKILL.md +117 -0
  136. package/plugins/sp/skills/spec-decomposition/references/decomposition.md +539 -0
  137. package/plugins/sp/skills/spur-cli/SKILL.md +133 -0
  138. package/plugins/sp/skills/spur-cli/references/agent.md +179 -0
  139. package/plugins/sp/skills/spur-cli/references/features/acceptance-criteria.md +82 -0
  140. package/plugins/sp/skills/spur-cli/references/features/hierarchy-mece.md +177 -0
  141. package/plugins/sp/skills/spur-cli/references/features/roadmap-priority.md +84 -0
  142. package/plugins/sp/skills/spur-cli/references/features/verbs.md +122 -0
  143. package/plugins/sp/skills/spur-cli/references/features.md +234 -0
  144. package/plugins/sp/skills/spur-cli/references/init.md +119 -0
  145. package/plugins/sp/skills/spur-cli/references/message.md +98 -0
  146. package/plugins/sp/skills/spur-cli/references/rules/authoring-rules.md +212 -0
  147. package/plugins/sp/skills/spur-cli/references/rules/fine-tuning.md +148 -0
  148. package/plugins/sp/skills/spur-cli/references/rules/operations.md +228 -0
  149. package/plugins/sp/skills/spur-cli/references/rules/validation-and-extension.md +129 -0
  150. package/plugins/sp/skills/spur-cli/references/rules.md +209 -0
  151. package/plugins/sp/skills/spur-cli/references/serve.md +58 -0
  152. package/plugins/sp/skills/spur-cli/references/tasks/l3-guard-cheatsheet.md +98 -0
  153. package/plugins/sp/skills/spur-cli/references/tasks/section-editing.md +95 -0
  154. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +361 -0
  155. package/plugins/sp/skills/spur-cli/references/tasks.md +280 -0
  156. package/plugins/sp/skills/spur-cli/references/team.md +133 -0
  157. package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +226 -0
  158. package/plugins/sp/skills/spur-cli/references/workflows/operations.md +211 -0
  159. package/plugins/sp/skills/spur-cli/references/workflows/validation-and-extension.md +129 -0
  160. package/plugins/sp/skills/spur-cli/references/workflows.md +355 -0
  161. package/plugins/sp/skills/spur-dev/SKILL.md +224 -0
  162. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +196 -0
  163. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +680 -0
  164. package/plugins/sp/skills/spur-dev/references/decision-brief.md +80 -0
  165. package/plugins/sp/skills/spur-dev/references/dev-operations.md +546 -0
  166. package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +164 -0
  167. package/plugins/sp/skills/spur-dev/references/execution-batch.md +790 -0
  168. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +352 -0
  169. package/plugins/sp/skills/spur-dev/references/feature-link-helper.md +191 -0
  170. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +417 -0
  171. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +171 -0
  172. package/plugins/sp/skills/spur-dev/references/glossary.md +95 -0
  173. package/plugins/sp/skills/spur-dev/references/idea-evaluation.md +80 -0
  174. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +120 -0
  175. package/plugins/sp/skills/spur-dev/references/planning-workflow.md +302 -0
  176. package/plugins/sp/skills/spur-dev/references/product-planning.md +206 -0
  177. package/plugins/sp/skills/spur-dev/references/section-batching.md +24 -0
  178. package/plugins/sp/skills/sys-architecture/SKILL.md +155 -0
  179. package/plugins/sp/skills/sys-architecture/references/decision-method.md +137 -0
  180. package/plugins/sp/skills/sys-architecture/references/upkeep-survey.md +75 -0
  181. package/plugins/sp/skills/sys-debugging/SKILL.md +228 -0
  182. package/plugins/sp/skills/sys-debugging/references/debugging-protocol.md +62 -0
  183. package/plugins/sp/skills/test-driven-development/SKILL.md +248 -0
  184. package/plugins/sp/skills/wayfinder/SKILL.md +292 -0
  185. package/spur.js +6035 -4963
@@ -0,0 +1,651 @@
1
+ /**
2
+ * validate-commands — thin-wrapper contract validator for plugin sp commands
3
+ * (feature O, task 0309; ADR-032).
4
+ *
5
+ * Validates each plugins/sp/commands/<name>.md directly against the
6
+ * thin-wrapper contract — no registry, no generation. The .md files are the
7
+ * hand-editable SSOT; superskill owns per-platform emission.
8
+ *
9
+ * Gates:
10
+ * (a) heading whitelist — H1 title + the per-contract ordered section headings
11
+ * (b) frontmatter schema — description, argument-hint, allowed-tools; dev-only extras
12
+ * (c) target resolution — sp:<skill> refs, workflow files, procedure anchors
13
+ * (d) allowed-tools coherence — Skill present iff body contains Skill() call
14
+ * (e) dev-command argument contract — syntax-only hint, Argument Flags table columns,
15
+ * single glossary reference, and bidirectional hint↔table parity
16
+ *
17
+ * Exit non-zero listing every violation on stderr.
18
+ */
19
+
20
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
21
+ import { join, resolve } from 'node:path';
22
+
23
+ // ─── Types ──────────────────────────────────────────────────────────────────
24
+
25
+ export interface Violation {
26
+ /** Command basename without extension (dev-run). */
27
+ readonly command: string;
28
+ /** Gate tag (a|b|c|d). */
29
+ readonly gate: 'a' | 'b' | 'c' | 'd' | 'e';
30
+ /** Human-readable description of the violation. */
31
+ readonly message: string;
32
+ }
33
+
34
+ export interface ValidationResult {
35
+ readonly violations: readonly Violation[];
36
+ readonly fileCount: number;
37
+ }
38
+
39
+ // ─── Parsing ────────────────────────────────────────────────────────────────
40
+
41
+ /** GitHub-style heading slug for anchor resolution. */
42
+ function slugify(heading: string): string {
43
+ return heading
44
+ .toLowerCase()
45
+ .replace(/[^\p{Letter}\p{Number} -]/gu, '')
46
+ .replaceAll(' ', '-');
47
+ }
48
+
49
+ interface ParsedCommand {
50
+ readonly name: string;
51
+ readonly title: string;
52
+ readonly headings: readonly string[];
53
+ readonly description: string | undefined;
54
+ readonly argumentHint: string | undefined;
55
+ readonly allowedTools: string[] | undefined;
56
+ readonly body: string;
57
+ }
58
+
59
+ function parseCommand(filePath: string, name: string): ParsedCommand {
60
+ const raw = readFileSync(filePath, 'utf8');
61
+
62
+ // Extract frontmatter between --- delimiters
63
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
64
+ const body = fmMatch ? raw.slice(fmMatch[0].length).trim() : raw;
65
+ const fm = fmMatch ? fmMatch[1] : '';
66
+
67
+ const description = extractYamlField(fm, 'description');
68
+ const argumentHint = extractYamlField(fm, 'argument-hint');
69
+ const allowedTools = extractYamlList(fm, 'allowed-tools');
70
+
71
+ const lines = body.split('\n');
72
+ const title = lines[0]?.startsWith('# ') ? lines[0].slice(2).trim() : '';
73
+
74
+ // Collect headings at EVERY level (not just `## `) so lifecycle prose cannot
75
+ // hide under a `### Behavior` subheading — gate (a) asserts the set is exactly
76
+ // {Usage, Implementation}. Fenced code blocks are skipped so a shell comment
77
+ // (`# regenerate …`) in an example is never mistaken for a heading; commands
78
+ // are hand-authored now, so that false positive would be a real trap.
79
+ const headings: string[] = [];
80
+ let inFence = false;
81
+ for (const [index, line] of lines.entries()) {
82
+ if (line.startsWith('```')) {
83
+ inFence = !inFence;
84
+ continue;
85
+ }
86
+ if (inFence) continue;
87
+ if (index === 0 && line.startsWith('# ')) continue; // H1 title, not a section heading
88
+ if (line.startsWith('#')) {
89
+ headings.push(line.replace(/^#+\s*/, '').trim());
90
+ }
91
+ }
92
+
93
+ return { name, title, headings, description, argumentHint, allowedTools, body };
94
+ }
95
+
96
+ /** Extract a plain YAML string field (handles quoted and unquoted). */
97
+ function extractYamlField(fm: string, key: string): string | undefined {
98
+ const re = new RegExp(`^${key}:\\s*(.+)$`, 'm');
99
+ const m = fm.match(re);
100
+ if (!m) return undefined;
101
+ const value = m[1].trim();
102
+ // Unquote double-quoted YAML scalars
103
+ if (value.startsWith('"') && value.endsWith('"')) {
104
+ return value.slice(1, -1).replace(/\\"/g, '"');
105
+ }
106
+ return value;
107
+ }
108
+
109
+ /** Extract a YAML flow list ["A", "B"]. */
110
+ function extractYamlList(fm: string, key: string): string[] | undefined {
111
+ const re = new RegExp(`^${key}:\\s*\\[(.*)\\]$`, 'm');
112
+ const m = fm.match(re);
113
+ if (!m) return undefined;
114
+ return [...m[1].matchAll(/"([^"]*)"/g)].map((q) => q[1]);
115
+ }
116
+
117
+ const ALLOWED_HEADINGS: Record<string, true> = { Usage: true, Implementation: true };
118
+
119
+ /** The heading set every non-dev command must carry — exactly these, no more, no fewer. */
120
+ const REQUIRED_HEADINGS = ['Usage', 'Implementation'] as const;
121
+
122
+ /** Dev commands carry an ordered Argument Flags → Usage → Implementation section sequence. */
123
+ const DEV_REQUIRED_HEADINGS = ['Argument Flags', 'Usage', 'Implementation'] as const;
124
+
125
+ const DEV_REQUIRED_SET: Record<string, true> = {
126
+ 'Argument Flags': true,
127
+ Implementation: true,
128
+ Usage: true,
129
+ };
130
+
131
+ /** Canonical glossary reference every dev command carries exactly once. */
132
+ const GLOSSARY_REF = '../skills/spur-dev/references/flag-glossary.md';
133
+
134
+ function isDevCommand(name: string): boolean {
135
+ return name.startsWith('dev-');
136
+ }
137
+
138
+ function checkHeadingWhitelist(cmd: ParsedCommand): readonly Violation[] {
139
+ const violations: Violation[] = [];
140
+ const dev = isDevCommand(cmd.name);
141
+ const required = dev ? DEV_REQUIRED_HEADINGS : REQUIRED_HEADINGS;
142
+ const allowedSet = dev ? DEV_REQUIRED_SET : ALLOWED_HEADINGS;
143
+ const allowedList = dev ? DEV_REQUIRED_HEADINGS : REQUIRED_HEADINGS;
144
+
145
+ // Forbidden: anything outside the allowed set, at any heading level.
146
+ for (const h of cmd.headings) {
147
+ if (!allowedSet[h]) {
148
+ violations.push({
149
+ command: cmd.name,
150
+ gate: 'a',
151
+ message: `forbidden heading "${h}" (allowed: ${allowedList.map((x) => `## ${x}`).join(', ')})`,
152
+ });
153
+ }
154
+ }
155
+
156
+ // Missing: a wrapper that simply omits a required section must not pass.
157
+ for (const req of required) {
158
+ if (!cmd.headings.includes(req)) {
159
+ violations.push({
160
+ command: cmd.name,
161
+ gate: 'a',
162
+ message: `missing required heading "## ${req}"`,
163
+ });
164
+ }
165
+ }
166
+
167
+ // Duplicated: two `## Usage` sections is a malformed wrapper, not a thin one.
168
+ for (const req of required) {
169
+ if (cmd.headings.filter((h) => h === req).length > 1) {
170
+ violations.push({
171
+ command: cmd.name,
172
+ gate: 'a',
173
+ message: `duplicate heading "## ${req}"`,
174
+ });
175
+ }
176
+ }
177
+
178
+ // Dev commands enforce order: Argument Flags immediately before Usage, Usage before Implementation.
179
+ if (dev) {
180
+ const indexes: Record<string, number> = {};
181
+ for (const [i, h] of cmd.headings.entries()) {
182
+ if (DEV_REQUIRED_SET[h] && indexes[h] === undefined) indexes[h] = i;
183
+ }
184
+ if (
185
+ indexes['Argument Flags'] !== undefined &&
186
+ indexes.Usage !== undefined &&
187
+ indexes['Argument Flags'] + 1 !== indexes.Usage
188
+ ) {
189
+ violations.push({
190
+ command: cmd.name,
191
+ gate: 'a',
192
+ message: 'heading order: "## Argument Flags" must immediately precede "## Usage"',
193
+ });
194
+ }
195
+ if (
196
+ indexes.Usage !== undefined &&
197
+ indexes.Implementation !== undefined &&
198
+ indexes.Usage >= indexes.Implementation
199
+ ) {
200
+ violations.push({
201
+ command: cmd.name,
202
+ gate: 'a',
203
+ message: 'heading order: "## Usage" must precede "## Implementation"',
204
+ });
205
+ }
206
+ }
207
+
208
+ return violations;
209
+ }
210
+
211
+ // ─── Gate (b): frontmatter schema ───────────────────────────────────────────
212
+
213
+ function checkFrontmatterSchema(cmd: ParsedCommand): readonly Violation[] {
214
+ const violations: Violation[] = [];
215
+ if (!cmd.description) {
216
+ violations.push({ command: cmd.name, gate: 'b', message: 'missing frontmatter description' });
217
+ }
218
+ if (!cmd.argumentHint) {
219
+ violations.push({ command: cmd.name, gate: 'b', message: 'missing frontmatter argument-hint' });
220
+ }
221
+ if (!cmd.allowedTools) {
222
+ violations.push({ command: cmd.name, gate: 'b', message: 'missing frontmatter allowed-tools' });
223
+ }
224
+ return violations;
225
+ }
226
+
227
+ // ─── Gate (c): target resolution ────────────────────────────────────────────
228
+
229
+ function checkTargetResolution(cmd: ParsedCommand, skillsDir: string, root: string): readonly Violation[] {
230
+ const violations: Violation[] = [];
231
+
232
+ // Extract ## Implementation section content
233
+ const implPart = cmd.body.split(/^## Implementation\n/m)[1] ?? '';
234
+ const implSection = implPart.split(/\n## (?!Usage)/m)[0].trim();
235
+
236
+ // Skill() calls: Skill(skill="sp:NAME",
237
+ // The name charset must stay at least as wide as any name a skill directory can
238
+ // take: a ref this pattern does not match is silently not collected, so the gate
239
+ // would pass a genuinely unresolved reference rather than report it.
240
+ const skillCalls = [...implSection.matchAll(/Skill\(skill="sp:([a-z][a-z0-9-]*)"/g)];
241
+ for (const ref of skillCalls) {
242
+ const skillName = ref[1];
243
+ const skillPath = join(skillsDir, skillName, 'SKILL.md');
244
+ if (!existsSync(skillPath)) {
245
+ violations.push({
246
+ command: cmd.name,
247
+ gate: 'c',
248
+ message: `unresolved skill reference sp:${skillName} (missing ${skillPath})`,
249
+ });
250
+ }
251
+ }
252
+
253
+ // Workflow file refs — patterns like .spur/workflows/<name>.yaml
254
+ const workflowRefs = [...implSection.matchAll(/\.spur\/workflows\/([^\s)"\]]+\.yaml)/g)];
255
+ for (const ref of workflowRefs) {
256
+ const wfPath = join(root, '.spur', 'workflows', ref[1]);
257
+ if (!existsSync(wfPath)) {
258
+ violations.push({
259
+ command: cmd.name,
260
+ gate: 'c',
261
+ message: `unresolved workflow file ${ref[0]} (missing ${wfPath})`,
262
+ });
263
+ }
264
+ }
265
+
266
+ // Procedure reference + anchor — markdown links with anchors in Implementation
267
+ const anchorRefs = [...implSection.matchAll(/\[([^\]]*)\]\(([^)]*#([^)]*))\)/g)];
268
+ for (const ref of anchorRefs) {
269
+ const filePart = ref[2].split('#')[0];
270
+ const anchor = ref[3];
271
+ // Resolve relative paths from commands/ directory
272
+ const resolved = resolve(join(root, 'plugins', 'sp', 'commands'), filePart);
273
+ if (!existsSync(resolved)) {
274
+ violations.push({
275
+ command: cmd.name,
276
+ gate: 'c',
277
+ message: `unresolved procedure reference ${filePart} (missing ${resolved})`,
278
+ });
279
+ continue;
280
+ }
281
+ // Check anchor exists
282
+ const content = readFileSync(resolved, 'utf8');
283
+ const headings = content.split('\n').filter((l) => l.startsWith('#'));
284
+ const slugAnchors = headings.map((l) => slugify(l.replace(/^#+\s*/, '').trim()));
285
+ // Honor explicit `**Anchor:** `#id`` directives (the glossary convention in
286
+ // flag-glossary.md) so a shared-flag entry can expose a stable `#flag-<name>`
287
+ // anchor independent of its heading text.
288
+ const explicitAnchors = [...content.matchAll(/^\*\*Anchor:\*\*\s*`#([^`]+)`/gm)].map((m) => m[1]);
289
+ const anchors = [...slugAnchors, ...explicitAnchors];
290
+ if (!anchors.includes(anchor)) {
291
+ violations.push({
292
+ command: cmd.name,
293
+ gate: 'c',
294
+ message: `unresolved anchor #${anchor} in ${filePart}`,
295
+ });
296
+ }
297
+ }
298
+
299
+ return violations;
300
+ }
301
+
302
+ // ─── Gate (d): allowed-tools coherence ──────────────────────────────────────
303
+
304
+ function checkAllowedToolsCoherence(cmd: ParsedCommand): readonly Violation[] {
305
+ const violations: Violation[] = [];
306
+ if (!cmd.allowedTools) return violations;
307
+
308
+ const hasSkillTool = cmd.allowedTools.includes('Skill');
309
+ const hasSkillCall = cmd.body.includes('Skill(');
310
+
311
+ if (hasSkillTool && !hasSkillCall) {
312
+ violations.push({
313
+ command: cmd.name,
314
+ gate: 'd',
315
+ message: 'allowed-tools includes Skill but body contains no Skill() call',
316
+ });
317
+ }
318
+ if (!hasSkillTool && hasSkillCall) {
319
+ violations.push({
320
+ command: cmd.name,
321
+ gate: 'd',
322
+ message: 'body contains Skill() call but allowed-tools does not include Skill',
323
+ });
324
+ }
325
+
326
+ return violations;
327
+ }
328
+
329
+ // ─── Gate (e): dev-command argument contract ────────────────────────────────
330
+
331
+ /**
332
+ * Extract the `## Argument Flags` section body (between the heading and the next
333
+ * level-two heading or end of body). Returns the raw section text.
334
+ */
335
+ function extractArgumentFlagsSection(body: string): string {
336
+ const marker = /^## Argument Flags\n/m;
337
+ const start = body.search(marker);
338
+ if (start < 0) return '';
339
+ const after = body.slice(start);
340
+ // Cut at the next level-two heading (## ) that is not the Argument Flags header itself.
341
+ const nextH2 = after.slice(after.indexOf('\n') + 1).search(/^## /m);
342
+ if (nextH2 < 0) return after;
343
+ return after.slice(0, after.indexOf('\n') + 1 + nextH2);
344
+ }
345
+
346
+ /**
347
+ * Parse a markdown table in a section. Returns the header cells and data rows.
348
+ * Header row is the first `|`-delimited row following the heading; the separator
349
+ * row (`| --- |`) is skipped; data rows are every subsequent `|`-delimited line
350
+ * until a blank line or non-table line.
351
+ */
352
+ interface MarkdownTable {
353
+ header: string[];
354
+ rows: string[][];
355
+ }
356
+
357
+ function parseMarkdownTable(section: string): MarkdownTable | null {
358
+ const lines = section.split('\n');
359
+ const tableLines: string[] = [];
360
+ let seenHeader = false;
361
+ for (const line of lines) {
362
+ const trimmed = line.trim();
363
+ if (!trimmed.startsWith('|')) {
364
+ if (seenHeader) break;
365
+ continue;
366
+ }
367
+ tableLines.push(trimmed);
368
+ seenHeader = true;
369
+ }
370
+ if (tableLines.length < 2) return null;
371
+ // Split on unescaped pipes only, then unescape `\|` → `|` per cell.
372
+ // Standard GFM: `\|` in a table cell renders as a literal `|`.
373
+ const split = (row: string): string[] =>
374
+ row
375
+ .replace(/^\|/, '')
376
+ .replace(/\|$/, '')
377
+ .split(/(?<!\\)\|/)
378
+ .map((c) => c.trim().replace(/\\\|/g, '|'));
379
+ const header = split(tableLines[0]);
380
+ // Skip the separator row (--- cells).
381
+ const dataRows = tableLines
382
+ .slice(1)
383
+ .filter((r) => !split(r).every((c) => /^:?-{2,}:?$/.test(c) || c === ''))
384
+ .map(split);
385
+ return { header, rows: dataRows };
386
+ }
387
+
388
+ /**
389
+ * Extract flag and positional tokens from a canonical (syntax-only) argument-hint.
390
+ * Positionals: `<...>` tokens. Flags: `--flag` optionally with `<value>` / alternatives.
391
+ */
392
+ function extractHintTokens(hint: string): { positionals: string[]; flags: string[] } {
393
+ const positionals = [...hint.matchAll(/<[^>]+>/g)].map((m) => m[0]);
394
+ // Flags: capture the `--flag` literal, ignoring the value placeholder that follows.
395
+ const flags = [...hint.matchAll(/(--[a-z][a-z0-9-]*)/g)].map((m) => m[1]);
396
+ // Deduplicate while preserving order.
397
+ return {
398
+ positionals: [...new Set(positionals)],
399
+ flags: [...new Set(flags)],
400
+ };
401
+ }
402
+
403
+ /**
404
+ * Extract the flag / positional tokens declared in an Argument Flags table. The
405
+ * first cell of each row is the token: `<positional>` or `--flag` (with optional
406
+ * value placeholder and alternatives). For parity we compare the leading token
407
+ * (`--flag` or `<positional>`), since hint parity is on those literals.
408
+ */
409
+ function extractTableTokens(rows: string[][]): { positionals: string[]; flags: string[] } {
410
+ const positionals: string[] = [];
411
+ const flags: string[] = [];
412
+ for (const row of rows) {
413
+ const cell = row[0] ?? '';
414
+ const pos = [...cell.matchAll(/<[^>]+>/g)].map((m) => m[0]);
415
+ const flg = cell.match(/--[a-z][a-z0-9-]*/);
416
+ if (pos.length > 0) positionals.push(...pos);
417
+ if (flg) flags.push(flg[0]); // Not `else if` — a cell like `--mode <full|implement>` has both.
418
+ }
419
+ return {
420
+ positionals: [...new Set(positionals)],
421
+ flags: [...new Set(flags)],
422
+ };
423
+ }
424
+
425
+ /** Flags whose table-row description marks them as alias / compat / no-op / deprecated. */
426
+ const COMPAT_MARKERS = /\b(alias|compat|no-op|deprecated|compatibility)\b/i;
427
+
428
+ function checkDevArgumentContract(cmd: ParsedCommand): readonly Violation[] {
429
+ if (!isDevCommand(cmd.name)) return [];
430
+ const violations: Violation[] = [];
431
+ const hint = cmd.argumentHint ?? '';
432
+
433
+ // 1. Syntax-only hint: no Markdown links (`](`) and no `<...>`-wrapped prose.
434
+ if (hint.includes('](')) {
435
+ violations.push({
436
+ command: cmd.name,
437
+ gate: 'e',
438
+ message: `argument-hint contains a Markdown link "](" — hint must be syntax only`,
439
+ });
440
+ }
441
+
442
+ // 2. Argument Flags table: exactly one table with exactly Flag | Description | Default.
443
+ const section = extractArgumentFlagsSection(cmd.body);
444
+ const table = parseMarkdownTable(section);
445
+ if (!table) {
446
+ violations.push({
447
+ command: cmd.name,
448
+ gate: 'e',
449
+ message: '## Argument Flags section must contain a markdown table',
450
+ });
451
+ return violations;
452
+ }
453
+ const expectedCols = ['Flag', 'Description', 'Default'];
454
+ if (table.header.length !== 3 || !expectedCols.every((c, i) => table.header[i] === c)) {
455
+ violations.push({
456
+ command: cmd.name,
457
+ gate: 'e',
458
+ message: `Argument Flags table columns must be exactly "Flag | Description | Default" (got "${table.header.join(' | ')}")`,
459
+ });
460
+ }
461
+
462
+ // Every row must have a non-blank Default cell (deterministic default rule).
463
+ for (const [idx, row] of table.rows.entries()) {
464
+ const defaultCell = row[2];
465
+ if (defaultCell === undefined || defaultCell === '' || /^\s*$/.test(defaultCell)) {
466
+ violations.push({
467
+ command: cmd.name,
468
+ gate: 'e',
469
+ message: `Argument Flags row ${idx + 1} ("${row[0] ?? ''}") has a blank Default cell`,
470
+ });
471
+ }
472
+ }
473
+
474
+ // 3. Exactly one canonical glossary reference.
475
+ const glossaryCount = (cmd.body.match(new RegExp(GLOSSARY_REF.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) ?? [])
476
+ .length;
477
+ if (glossaryCount !== 1) {
478
+ violations.push({
479
+ command: cmd.name,
480
+ gate: 'e',
481
+ message: `expected exactly one glossary reference to ${GLOSSARY_REF}, found ${glossaryCount}`,
482
+ });
483
+ }
484
+
485
+ // 4. Bidirectional hint ↔ table parity.
486
+ const hintTokens = extractHintTokens(hint);
487
+ const tableTokens = extractTableTokens(table.rows);
488
+
489
+ // Forward: every hint positional and flag must appear in the table.
490
+ for (const pos of hintTokens.positionals) {
491
+ if (!tableTokens.positionals.includes(pos)) {
492
+ violations.push({
493
+ command: cmd.name,
494
+ gate: 'e',
495
+ message: `hint positional "${pos}" has no matching row in the Argument Flags table`,
496
+ });
497
+ }
498
+ }
499
+ for (const flag of hintTokens.flags) {
500
+ if (!tableTokens.flags.includes(flag)) {
501
+ violations.push({
502
+ command: cmd.name,
503
+ gate: 'e',
504
+ message: `hint flag "${flag}" has no matching row in the Argument Flags table`,
505
+ });
506
+ }
507
+ }
508
+
509
+ // Reverse: every table flag must appear in the hint, unless the row marks it
510
+ // as an alias / compat / no-op / deprecated spelling (compatibility surface).
511
+ for (const flag of tableTokens.flags) {
512
+ if (hintTokens.flags.includes(flag)) continue;
513
+ const row = table.rows.find((r) => (r[0] ?? '').includes(flag));
514
+ const desc = row?.[1] ?? '';
515
+ if (!COMPAT_MARKERS.test(desc)) {
516
+ violations.push({
517
+ command: cmd.name,
518
+ gate: 'e',
519
+ message: `table flag "${flag}" is absent from the hint and is not marked alias/compat/no-op/deprecated`,
520
+ });
521
+ }
522
+ }
523
+
524
+ return violations;
525
+ }
526
+
527
+ // ─── Core validation ────────────────────────────────────────────────────────
528
+
529
+ export function validate(root: string = process.cwd()): ValidationResult {
530
+ const commandsDir = join(root, 'plugins', 'sp', 'commands');
531
+ const skillsDir = join(root, 'plugins', 'sp', 'skills');
532
+
533
+ const files = readdirSync(commandsDir)
534
+ .filter((f) => f.endsWith('.md'))
535
+ .sort();
536
+ const allViolations: Violation[] = [];
537
+
538
+ for (const file of files) {
539
+ const name = file.replace(/\.md$/, '');
540
+ const filePath = join(commandsDir, file);
541
+ const cmd = parseCommand(filePath, name);
542
+
543
+ allViolations.push(...checkHeadingWhitelist(cmd));
544
+ allViolations.push(...checkFrontmatterSchema(cmd));
545
+ allViolations.push(...checkTargetResolution(cmd, skillsDir, root));
546
+ allViolations.push(...checkAllowedToolsCoherence(cmd));
547
+ allViolations.push(...checkDevArgumentContract(cmd));
548
+ }
549
+
550
+ return { violations: allViolations, fileCount: files.length };
551
+ }
552
+
553
+ // ─── CLI ────────────────────────────────────────────────────────────────────
554
+
555
+ export interface CliArgs {
556
+ readonly check: boolean;
557
+ readonly help: boolean;
558
+ readonly json: boolean;
559
+ }
560
+
561
+ export function parseCliArgs(argv: string[]): CliArgs {
562
+ const args = new Set(argv);
563
+ return {
564
+ check: args.has('--check'),
565
+ help: args.has('--help') || args.has('-h'),
566
+ json: args.has('--json'),
567
+ };
568
+ }
569
+
570
+ export function renderHelp(): string {
571
+ return [
572
+ 'validate-commands — enforce the thin-wrapper contract on plugins/sp/commands/*.md',
573
+ '',
574
+ 'Usage: bun plugins/sp/scripts/validate-commands.ts [--json]',
575
+ '',
576
+ 'Options:',
577
+ ' --json Output JSON (default: human-readable)',
578
+ ' --help Show this help',
579
+ '',
580
+ 'Gates:',
581
+ ' (a) heading whitelist — H1 title + per-contract ordered section headings',
582
+ ' (b) frontmatter schema — description, argument-hint, allowed-tools present',
583
+ ' (c) target resolution — sp:<skill>, workflow, procedure anchor exist on disk',
584
+ ' (d) allowed-tools coherence — Skill <-> Skill() call',
585
+ ' (e) dev argument contract — syntax-only hint, Argument Flags table columns,',
586
+ ' single glossary reference, and bidirectional hint↔table parity (dev-* only)',
587
+ ].join('\n');
588
+ }
589
+
590
+ export interface CliResult {
591
+ readonly exitCode: number;
592
+ readonly stdout: string;
593
+ readonly stderr: string;
594
+ }
595
+
596
+ export function runCli(
597
+ argv: string[],
598
+ opts?: {
599
+ validateFn?: (root: string) => ValidationResult;
600
+ },
601
+ ): CliResult {
602
+ const args = parseCliArgs(argv);
603
+
604
+ if (args.help) {
605
+ return { exitCode: 0, stdout: renderHelp(), stderr: '' };
606
+ }
607
+
608
+ const v = (opts?.validateFn ?? validate)(process.cwd());
609
+
610
+ if (args.json) {
611
+ return { exitCode: v.violations.length > 0 ? 1 : 0, stdout: JSON.stringify(v), stderr: '' };
612
+ }
613
+
614
+ if (v.violations.length === 0) {
615
+ const msg = `${v.fileCount} commands pass all 5 thin-wrapper gates.\n`;
616
+ return { exitCode: 0, stdout: msg, stderr: '' };
617
+ }
618
+
619
+ const lines = v.violations.map((vi) => `(${vi.gate})\t${vi.command}\t${vi.message}`);
620
+ const stderr = [
621
+ `${v.violations.length} violation(s) across ${v.fileCount} commands:`,
622
+ ...lines,
623
+ '',
624
+ 'Fix the commands and re-run. Commands are hand-editable — no regeneration step.',
625
+ ].join('\n');
626
+
627
+ return { exitCode: 1, stdout: '', stderr };
628
+ }
629
+
630
+ /**
631
+ * Entry-point boot — runs the CLI using process.argv. Tests inject exit /
632
+ * stdout/stderr spies instead of spawning a subprocess.
633
+ */
634
+ export function bootMain(
635
+ argv: string[] = process.argv.slice(2),
636
+ opts?: {
637
+ exit?: (code?: number) => void;
638
+ stdout?: { write(s: string): boolean };
639
+ stderr?: { write(s: string): boolean };
640
+ run?: (argv: string[]) => CliResult;
641
+ },
642
+ ): void {
643
+ const result = (opts?.run ?? runCli)(argv);
644
+ if (result.stdout) (opts?.stdout ?? process.stdout).write(result.stdout);
645
+ if (result.stderr) (opts?.stderr ?? process.stderr).write(result.stderr);
646
+ (opts?.exit ?? process.exit)(result.exitCode);
647
+ }
648
+
649
+ if (import.meta.main) {
650
+ bootMain();
651
+ }