@boyingliu01/xp-gate 0.31.3 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/adapters/java.sh +256 -4
  2. package/adapters/powershell.sh +45 -6
  3. package/bin/xp-gate.js +8 -2
  4. package/build-integrity/__tests__/gate-10.test.ts +88 -1
  5. package/build-integrity/gate-10.ts +51 -16
  6. package/dsh.bundle.patch.yml +15 -0
  7. package/gate-13-compiler-strictness.sh +1031 -0
  8. package/gate-3.sh +48 -13
  9. package/hooks/gate-13-compiler-strictness.sh +1031 -0
  10. package/hooks/gate-3.sh +48 -13
  11. package/hooks/lib/coverage-total.sh +18 -2
  12. package/hooks/lib/java-test-selection.sh +251 -0
  13. package/hooks/lib/jscpd-run.sh +9 -0
  14. package/hooks/lib/validate-code-walkthrough.cjs +186 -29
  15. package/hooks/pre-commit +269 -13
  16. package/lib/__tests__/detect-deps-dsh-profile.test.js +106 -0
  17. package/lib/__tests__/doctor-tui-skill-freshness.test.js +140 -0
  18. package/lib/__tests__/gate-runner.test.js +29 -2
  19. package/lib/__tests__/issue-586-principles-cmdline.test.js +35 -0
  20. package/lib/__tests__/issue-618-gate-not-run.test.js +100 -0
  21. package/lib/__tests__/shared-utils.test.js +21 -0
  22. package/lib/check.js +6 -2
  23. package/lib/detect-deps.js +42 -10
  24. package/lib/doctor-tui.js +111 -21
  25. package/lib/gate-runner.js +111 -37
  26. package/lib/mattpocock-license-verdict.json +1 -1
  27. package/lib/principles.js +48 -6
  28. package/lib/shared-utils.js +8 -0
  29. package/mock-policy/AGENTS.md +3 -3
  30. package/mock-policy/gate-m3.ts +57 -23
  31. package/mutation/AGENTS.md +3 -3
  32. package/mutation/__tests__/pitest-gradle-availability.test.ts +74 -0
  33. package/mutation/__tests__/test-filter-parity.test.ts +5 -2
  34. package/mutation/gate-m.ts +30 -24
  35. package/mutation/runners/mutmut-runner.ts +80 -30
  36. package/mutation/runners/pitest-runner.ts +60 -42
  37. package/package.json +14 -2
  38. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  39. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  40. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  41. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  42. package/plugins/dsh/lib/command.d.ts +38 -0
  43. package/plugins/dsh/lib/command.d.ts.map +1 -0
  44. package/plugins/dsh/lib/command.js +87 -0
  45. package/plugins/dsh/lib/credentials.d.ts +39 -0
  46. package/plugins/dsh/lib/credentials.d.ts.map +1 -0
  47. package/plugins/dsh/lib/credentials.js +57 -0
  48. package/plugins/dsh/lib/delphi-config.d.ts +129 -0
  49. package/plugins/dsh/lib/delphi-config.d.ts.map +1 -0
  50. package/plugins/dsh/lib/delphi-config.js +369 -0
  51. package/plugins/dsh/lib/delphi-run.d.ts +24 -0
  52. package/plugins/dsh/lib/delphi-run.d.ts.map +1 -0
  53. package/plugins/dsh/lib/delphi-run.js +113 -0
  54. package/plugins/dsh/lib/delphi.d.ts +94 -0
  55. package/plugins/dsh/lib/delphi.d.ts.map +1 -0
  56. package/plugins/dsh/lib/delphi.js +207 -0
  57. package/plugins/dsh/lib/dialect.d.ts +24 -0
  58. package/plugins/dsh/lib/dialect.d.ts.map +1 -0
  59. package/plugins/dsh/lib/dialect.js +36 -0
  60. package/plugins/dsh/lib/evidence.d.ts +70 -0
  61. package/plugins/dsh/lib/evidence.d.ts.map +1 -0
  62. package/plugins/dsh/lib/evidence.js +92 -0
  63. package/plugins/dsh/lib/gate-runner.d.ts +49 -0
  64. package/plugins/dsh/lib/gate-runner.d.ts.map +1 -0
  65. package/plugins/dsh/lib/gate-runner.js +95 -0
  66. package/plugins/dsh/lib/git-context.d.ts +26 -0
  67. package/plugins/dsh/lib/git-context.d.ts.map +1 -0
  68. package/plugins/dsh/lib/git-context.js +30 -0
  69. package/plugins/dsh/lib/hooks.d.ts +42 -0
  70. package/plugins/dsh/lib/hooks.d.ts.map +1 -0
  71. package/plugins/dsh/lib/hooks.js +113 -0
  72. package/plugins/dsh/lib/index.d.ts +20 -0
  73. package/plugins/dsh/lib/index.d.ts.map +1 -0
  74. package/plugins/dsh/lib/index.js +430 -0
  75. package/plugins/dsh/lib/skill-provider.d.ts +15 -0
  76. package/plugins/dsh/lib/skill-provider.d.ts.map +1 -0
  77. package/plugins/dsh/lib/skill-provider.js +205 -0
  78. package/plugins/dsh/lib/sprint.d.ts +60 -0
  79. package/plugins/dsh/lib/sprint.d.ts.map +1 -0
  80. package/plugins/dsh/lib/sprint.js +92 -0
  81. package/plugins/dsh/package.json +14 -8
  82. package/plugins/dsh/scripts/verify-skill-provider.mjs +113 -0
  83. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  84. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  85. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  86. package/plugins/dsh/src/command-single-form.test.ts +67 -0
  87. package/plugins/dsh/src/command.test.ts +21 -17
  88. package/plugins/dsh/src/command.ts +19 -21
  89. package/plugins/dsh/src/compat.test.ts +200 -0
  90. package/plugins/dsh/src/contract.test.ts +7 -1
  91. package/plugins/dsh/src/credentials.test.ts +93 -0
  92. package/plugins/dsh/src/credentials.ts +71 -0
  93. package/plugins/dsh/src/delphi-cancellation.test.ts +71 -0
  94. package/plugins/dsh/src/delphi-config-apikey-override.test.ts +94 -0
  95. package/plugins/dsh/src/delphi-config-credential-refs.test.ts +75 -0
  96. package/plugins/dsh/src/delphi-config.ts +146 -53
  97. package/plugins/dsh/src/delphi-run.ts +27 -4
  98. package/plugins/dsh/src/delphi.ts +133 -65
  99. package/plugins/dsh/src/evidence.test.ts +74 -0
  100. package/plugins/dsh/src/evidence.ts +141 -0
  101. package/plugins/dsh/src/gate-runner.test.ts +14 -6
  102. package/plugins/dsh/src/gate-runner.ts +68 -5
  103. package/plugins/dsh/src/git-context.test.ts +39 -0
  104. package/plugins/dsh/src/git-context.ts +38 -0
  105. package/plugins/dsh/src/hooks.test.ts +87 -0
  106. package/plugins/dsh/src/hooks.ts +126 -0
  107. package/plugins/dsh/src/index.test.ts +63 -6
  108. package/plugins/dsh/src/index.ts +340 -13
  109. package/plugins/dsh/src/skill-provider.test.ts +194 -0
  110. package/plugins/dsh/src/skill-provider.ts +231 -0
  111. package/plugins/dsh/src/sprint.test.ts +64 -0
  112. package/plugins/dsh/src/sprint.ts +122 -0
  113. package/plugins/dsh/src/tri-state.test.ts +123 -0
  114. package/plugins/opencode/index.ts +192 -58
  115. package/plugins/opencode/package.json +1 -1
  116. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  117. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  118. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  119. package/plugins/qoder/plugin.json +1 -1
  120. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  121. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  122. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  123. package/principles/AGENTS.md +3 -3
  124. package/principles/__tests__/analyze-read-failure.test.ts +70 -0
  125. package/principles/__tests__/baseline-entry-total-warnings.test.ts +76 -0
  126. package/principles/__tests__/baseline-prune.test.ts +61 -0
  127. package/principles/__tests__/cli-files-separators.test.ts +69 -0
  128. package/principles/__tests__/config-merge-warnings.test.ts +148 -0
  129. package/principles/adapters/__tests__/cpp-code-block.test.ts +123 -0
  130. package/principles/adapters/__tests__/cpp-scanner-equivalence.test.ts +67 -0
  131. package/principles/adapters/__tests__/typescript-block-comment-lines.test.ts +53 -0
  132. package/principles/adapters/__tests__/typescript-masking.test.ts +96 -0
  133. package/principles/adapters/cpp.ts +102 -50
  134. package/principles/adapters/java.ts +22 -3
  135. package/principles/adapters/typescript.ts +548 -178
  136. package/principles/analyzer.ts +36 -3
  137. package/principles/baseline-prune.ts +19 -7
  138. package/principles/baseline.ts +58 -70
  139. package/principles/config.ts +56 -35
  140. package/principles/index.ts +122 -40
  141. package/principles/rules/clean-code/deep-nesting.ts +36 -22
  142. package/principles/rules/clean-code/unused-imports.ts +30 -19
  143. package/principles/rules/solid/dip.ts +30 -20
  144. package/principles/rules/solid/lsp.ts +17 -2
  145. package/principles/rules/solid/ocp.ts +33 -23
  146. package/principles/rules/solid/srp.ts +65 -23
  147. package/scripts/delphi-external-review.cjs +25 -2
  148. package/scripts/sync-package-content.js +28 -1
  149. package/skills/delphi-review/AGENTS.md +3 -3
  150. package/skills/sprint-flow/AGENTS.md +3 -3
  151. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -0,0 +1,194 @@
1
+ /**
2
+ * @test REQ-DSH-006 bundled skill provider
3
+ * @intent Verify the plugin registers a DSH SkillProvider that publishes all 12 bundled
4
+ * skills with model-visible metadata, correct resourceBase for relative
5
+ * references/, and a body loader that re-reads from disk each call.
6
+ * @covers AC-DSH-006-01 AC-DSH-006-02 AC-DSH-006-03
7
+ *
8
+ * Why these assertions exist: v1 shipped 65 skill files inside the tarball and still
9
+ * delivered none of them, because the requirement assumed a `bundledSkillDir`
10
+ * discovery root the plugin cannot set itself. A test that only checks "files are
11
+ * present on disk" reproduces that bug, so every assertion here goes through the
12
+ * SkillProvider contract DSH actually consumes.
13
+ */
14
+
15
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"
16
+ import { tmpdir } from "node:os"
17
+ import { join } from "node:path"
18
+ import { fileURLToPath } from "node:url"
19
+
20
+ import { describe, expect, it, afterAll } from "vitest"
21
+
22
+ import {
23
+ createSkillProvider,
24
+ listBundledSkills,
25
+ } from "./skill-provider.js"
26
+ import { BUNDLED_SKILL_RANK } from "@deepseek-ai/dsh-skill"
27
+ import type {
28
+ SkillCandidate,
29
+ SkillProvider,
30
+ SkillProviderObservation,
31
+ } from "@deepseek-ai/dsh-skill"
32
+
33
+ const SKILLS_ROOT = fileURLToPath(new URL("../skills/", import.meta.url))
34
+
35
+ /** The repository ships this many skills; the count is part of the delivery promise. */
36
+ const EXPECTED_SKILL_COUNT = 12;
37
+
38
+ /** A skill body shorter than this is a stub, not a usable instruction set. */
39
+ const MIN_SKILL_BODY_CHARS = 200;
40
+
41
+ /** A description shorter than this cannot route a skill to the right task. */
42
+ const MIN_DESCRIPTION_CHARS = 50;
43
+
44
+ /** Every skill directory shipped in the tarball, read from disk rather than hard-coded. */
45
+ const shipped = listBundledSkills(SKILLS_ROOT).map((s) => s.name).sort()
46
+
47
+ const scratchDirs: string[] = []
48
+ function scratch(): string {
49
+ const dir = mkdtempSync(join(tmpdir(), "xp-gate-skill-provider-"))
50
+ scratchDirs.push(dir)
51
+ return dir
52
+ }
53
+
54
+ /**
55
+ * Narrow the dual-shape `list()` return to a plain candidate array.
56
+ * DSH allows a provider to answer with either a bare array or an observation
57
+ * object; this provider always uses the bare array, but the type is a union.
58
+ */
59
+ function candidatesOf(
60
+ listed: Awaited<ReturnType<SkillProvider["list"]>>,
61
+ ): readonly SkillCandidate[] {
62
+ return Array.isArray(listed) ? listed : ((listed as SkillProviderObservation).candidates ?? [])
63
+ }
64
+
65
+ afterAll(() => {
66
+ for (const dir of scratchDirs) rmSync(dir, { recursive: true, force: true })
67
+ })
68
+
69
+ describe("bundled skill provider", () => {
70
+ it("discovers all 12 bundled skills", () => {
71
+ expect(shipped).toHaveLength(EXPECTED_SKILL_COUNT)
72
+ expect(shipped).toContain("delphi-review")
73
+ expect(shipped).toContain("sprint-flow")
74
+ expect(shipped).toContain("test-driven-development")
75
+ })
76
+
77
+ it("returns candidates carrying the DSH skill contract", async () => {
78
+ const provider = createSkillProvider(SKILLS_ROOT)
79
+ const listed = await provider.list({})
80
+ const candidates = candidatesOf(listed)
81
+ expect(candidates).toBeDefined()
82
+ expect(candidates).toHaveLength(EXPECTED_SKILL_COUNT)
83
+
84
+ for (const candidate of candidates) {
85
+ expect(candidate.name).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/)
86
+ // A blank description makes the skill unroutable: the model never picks it.
87
+ expect(candidate.description.length).toBeGreaterThan(10)
88
+ expect(candidate.invocation).toEqual({
89
+ modelInvocable: true,
90
+ userInvocable: true,
91
+ })
92
+ expect(candidate.provider).toBe("xp-gate")
93
+ expect(candidate.source).toBe("bundled")
94
+ expect(candidate.rank).toBe(BUNDLED_SKILL_RANK)
95
+ }
96
+ })
97
+
98
+ it("gives every candidate a directory resourceBase so relative references resolve", () => {
99
+ // 8 of the 12 skills point at references/*.md and templates/*; without
100
+ // resourceBase those paths have no base and the skill dangles.
101
+ const candidates = listBundledSkills(SKILLS_ROOT)
102
+ expect(candidates).toHaveLength(EXPECTED_SKILL_COUNT)
103
+ for (const candidate of candidates) {
104
+ expect(candidate.resourceBase).toBeDefined()
105
+ expect(candidate.resourceBase?.kind).toBe("directory")
106
+ const base = candidate.resourceBase
107
+ if (base?.kind === "directory") {
108
+ expect(base.path).toContain(candidate.name)
109
+ }
110
+ }
111
+ })
112
+
113
+ it("loads a non-empty body with the frontmatter stripped", async () => {
114
+ const provider = createSkillProvider(SKILLS_ROOT)
115
+ const listed = await provider.list({})
116
+ const candidates = candidatesOf(listed)
117
+ const target = candidates.find((c) => c.name === "delphi-review")
118
+ expect(target).toBeDefined()
119
+ if (!target) return
120
+
121
+ const definition = await provider.get(target, {})
122
+ expect(definition).toBeDefined()
123
+ expect(definition?.name).toBe("delphi-review")
124
+ // The body starts after the YAML block, so the opening `---` must be gone.
125
+ expect(definition?.content ?? "").not.toMatch(/^---/)
126
+ expect((definition?.content ?? "").length).toBeGreaterThan(MIN_SKILL_BODY_CHARS)
127
+ expect(definition?.content).toContain("Delphi")
128
+ })
129
+
130
+ it("re-reads the body from disk on each get, so edits apply without a restart", async () => {
131
+ const root = scratch()
132
+ const dir = join(root, "demo-skill")
133
+ const file = join(dir, "SKILL.md")
134
+ try {
135
+ mkdirSync(dir, { recursive: true })
136
+ writeFileSync(
137
+ file,
138
+ "---\nname: demo-skill\ndescription: A demo skill used to prove the body is not cached.\n---\n\n# Original body marker\n",
139
+ "utf8",
140
+ )
141
+ } catch (error) {
142
+ throw new Error(`cannot build scratch skill: ${(error as Error).message}`)
143
+ }
144
+
145
+ const provider = createSkillProvider(root)
146
+ const listed = await provider.list({})
147
+ const candidates = candidatesOf(listed)
148
+ const target = candidates[0]
149
+ expect(target).toBeDefined()
150
+ if (!target) return
151
+
152
+ const first = await provider.get(target, {})
153
+ expect(first?.content).toContain("Original body marker")
154
+
155
+ try {
156
+ writeFileSync(
157
+ file,
158
+ "---\nname: demo-skill\ndescription: A demo skill used to prove the body is not cached.\n---\n\n# Edited body marker\n",
159
+ "utf8",
160
+ )
161
+ } catch (error) {
162
+ throw new Error(`cannot edit scratch skill: ${(error as Error).message}`)
163
+ }
164
+
165
+ const second = await provider.get(target, {})
166
+ expect(second?.content).toContain("Edited body marker")
167
+ expect(second?.content).not.toContain("Original body marker")
168
+ })
169
+
170
+ it("parses both folded and quoted YAML descriptions", () => {
171
+ // Two shapes ship in this repo: `description: >` blocks (delphi-review,
172
+ // sprint-flow) and one-line quoted values. A parser handling only one shape
173
+ // silently yields an empty description for the other, making the skill
174
+ // invisible to routing.
175
+ const candidates = listBundledSkills(SKILLS_ROOT)
176
+ const folded = candidates.find((c) => c.name === "delphi-review")
177
+ const quoted = candidates.find((c) => c.name === "admin-template-guidelines")
178
+
179
+ expect(folded?.description.length).toBeGreaterThan(MIN_DESCRIPTION_CHARS)
180
+ expect(quoted?.description.length).toBeGreaterThan(MIN_DESCRIPTION_CHARS)
181
+ // The folded block must be unfolded into one line, not left with newlines.
182
+ expect(folded?.description).not.toContain("\n")
183
+ })
184
+
185
+ it("returns an empty catalog for a missing skills root instead of throwing", async () => {
186
+ const provider = createSkillProvider(join(tmpdir(), "xp-gate-does-not-exist-root"))
187
+ const listed = await provider.list({})
188
+ const candidates = candidatesOf(listed)
189
+ expect(candidates).toHaveLength(0)
190
+ })
191
+ })
192
+
193
+
194
+
@@ -0,0 +1,231 @@
1
+ import { readdirSync, readFileSync } from "node:fs"
2
+ import { readFile } from "node:fs/promises"
3
+ import { join } from "node:path"
4
+
5
+ import { BUNDLED_SKILL_RANK } from "@deepseek-ai/dsh-skill"
6
+ import type {
7
+ SkillCandidate,
8
+ SkillDefinition,
9
+ SkillLookupOptions,
10
+ SkillProvider,
11
+ } from "@deepseek-ai/dsh-skill"
12
+
13
+ /**
14
+ * Provider label surfaced to the model alongside each skill.
15
+ * Matches the plugin name so a loaded skill is attributable.
16
+ */
17
+ const PROVIDER_NAME = "xp-gate"
18
+
19
+ /** Files a skill directory must contain to be loadable. */
20
+ const SKILL_FILE = "SKILL.md"
21
+
22
+ /** The `---` line that opens and closes a SKILL.md YAML frontmatter block. */
23
+ const FRONTMATTER_FENCE = "---"
24
+
25
+ /** Offset just past the opening fence, and past the closing fence plus its newline. */
26
+ const FRONTMATTER_START = FRONTMATTER_FENCE.length
27
+ const FRONTMATTER_END_OFFSET = FRONTMATTER_FENCE.length + 1
28
+
29
+ interface ParsedSkill {
30
+ readonly name: string
31
+ readonly description: string
32
+ readonly content: string
33
+ }
34
+
35
+ /**
36
+ * Unfold a YAML block scalar (`key: >` / `key: |`) body into one line.
37
+ * Folded blocks join on spaces, literal blocks on newlines.
38
+ */
39
+ function unfoldBlock(lines: readonly string[], literal: boolean): string {
40
+ const trimmed = lines.map((line) => line.trim())
41
+ if (literal) return trimmed.filter((line) => line.length > 0).join("\n")
42
+ return trimmed.filter((line) => line.length > 0).join(" ")
43
+ }
44
+
45
+ /**
46
+ * Extract `name` and `description` from a SKILL.md frontmatter block.
47
+ *
48
+ * Two description shapes ship in this repository: folded blocks
49
+ * (`description: >`) used by delphi-review and sprint-flow, and one-line quoted
50
+ * or plain values used by the rest. A parser handling only one shape yields an
51
+ * empty description for the other, which makes those skills unroutable — the
52
+ * model never picks a skill it cannot see described.
53
+ */
54
+ function parseSkill(raw: string, file: string): ParsedSkill {
55
+ if (!raw.startsWith(FRONTMATTER_FENCE)) {
56
+ throw new Error(`${file}: missing YAML frontmatter`)
57
+ }
58
+ const end = raw.indexOf(`\n${FRONTMATTER_FENCE}`, FRONTMATTER_START)
59
+ if (end === -1) {
60
+ throw new Error(`${file}: unterminated YAML frontmatter`)
61
+ }
62
+ const frontmatter = raw.slice(FRONTMATTER_START, end)
63
+ const content = raw.slice(end + FRONTMATTER_END_OFFSET).replace(/^\r?\n/, "")
64
+
65
+ const lines = frontmatter.split(/\r?\n/)
66
+ let name = ""
67
+ let description = ""
68
+ for (let index = 0; index < lines.length; index += 1) {
69
+ const line = lines[index] ?? ""
70
+ const keyed = /^([A-Za-z_][A-Za-z0-9_-]*):(.*)$/.exec(line)
71
+ if (!keyed) continue
72
+ const key = keyed[1]
73
+ const rest = (keyed[2] ?? "").trim()
74
+
75
+ // Collect the indented continuation lines of a block scalar.
76
+ const blockMatch = /^([>|])([+-]?)$/.exec(rest)
77
+ if (blockMatch) {
78
+ const block: string[] = []
79
+ let cursor = index + 1
80
+ while (cursor < lines.length) {
81
+ const next = lines[cursor] ?? ""
82
+ if (next.trim().length > 0 && !/^\s/.test(next)) break
83
+ block.push(next)
84
+ cursor += 1
85
+ }
86
+ index = cursor - 1
87
+ const value = unfoldBlock(block, blockMatch[1] === "|")
88
+ if (key === "name") name = value
89
+ if (key === "description") description = value
90
+ continue
91
+ }
92
+
93
+ if (key !== "name" && key !== "description") continue
94
+ // Strip a matching pair of surrounding quotes without touching inner ones.
95
+ const unquoted = /^(['"])(.*)\1$/.exec(rest)?.[2] ?? rest
96
+ if (key === "name") name = unquoted
97
+ if (key === "description") description = unquoted
98
+ }
99
+
100
+ if (!name) throw new Error(`${file}: frontmatter carries no name`)
101
+ if (!description) throw new Error(`${file}: frontmatter carries no description`)
102
+ return { name, description, content }
103
+ }
104
+
105
+ interface ListedSkill {
106
+ readonly name: string
107
+ readonly description: string
108
+ readonly directory: string
109
+ readonly file: string
110
+ }
111
+
112
+ /**
113
+ * Scan `root` for skill directories without loading their bodies.
114
+ *
115
+ * The catalog is fixed at package build time, so scanning once at apply() is
116
+ * enough; bodies stay on disk and are read per `get()`. A missing root yields
117
+ * an empty catalog rather than throwing: a plugin whose skills directory was
118
+ * stripped during packaging must still load its tools.
119
+ */
120
+ function scanSkills(root: string): ListedSkill[] {
121
+ let entries: string[]
122
+ try {
123
+ entries = readdirSync(root, { withFileTypes: true })
124
+ .filter((entry) => entry.isDirectory())
125
+ .map((entry) => entry.name)
126
+ } catch {
127
+ return []
128
+ }
129
+
130
+ const listed: ListedSkill[] = []
131
+ for (const entry of entries.sort()) {
132
+ const directory = join(root, entry)
133
+ const file = join(directory, SKILL_FILE)
134
+ let raw: string
135
+ try {
136
+ raw = readFileSync(file, "utf8")
137
+ } catch {
138
+ // A directory without SKILL.md is not a skill; skip it silently so a
139
+ // stray file in the package cannot break the whole catalog.
140
+ continue
141
+ }
142
+ const parsed = parseSkill(raw, file)
143
+ listed.push({
144
+ name: parsed.name,
145
+ description: parsed.description,
146
+ directory,
147
+ file,
148
+ })
149
+ }
150
+ return listed
151
+ }
152
+
153
+ /**
154
+ * Build the DSH skill candidates for every skill bundled under `root`.
155
+ * Exposed for tests and for callers that need the catalog without a provider.
156
+ */
157
+ export function listBundledSkills(root: string): SkillCandidate[] {
158
+ return scanSkills(root).map((skill) => ({
159
+ name: skill.name,
160
+ description: skill.description,
161
+ invocation: { modelInvocable: true, userInvocable: true },
162
+ provider: PROVIDER_NAME,
163
+ source: "bundled",
164
+ rank: BUNDLED_SKILL_RANK,
165
+ // 8 of the 12 skills reference references/*.md and templates/*; without a
166
+ // directory base those relative paths have nothing to resolve against.
167
+ resourceBase: { kind: "directory", path: skill.directory },
168
+ locator: skill.file,
169
+ }))
170
+ }
171
+
172
+ /**
173
+ * Load one candidate's body from disk.
174
+ *
175
+ * Separated from the provider literal so the lookup/read/decorate steps sit at the
176
+ * same nesting level instead of inside an object method's promise chain.
177
+ */
178
+ async function loadSkill(
179
+ candidate: SkillCandidate,
180
+ options: SkillLookupOptions,
181
+ byName: ReadonlyMap<string, ListedSkill>,
182
+ ): Promise<SkillDefinition | undefined> {
183
+ // A candidate carries the absolute SKILL.md path it was listed from, so loading
184
+ // works even if the scanned catalog is stale; fall back to the catalog for
185
+ // hand-built candidates in tests.
186
+ const locator = typeof candidate.locator === "string" ? candidate.locator : ""
187
+ const target = locator.length > 0 ? locator : byName.get(candidate.name)?.file
188
+ if (!target) return undefined
189
+
190
+ let raw: string
191
+ try {
192
+ raw = await readFile(target, { encoding: "utf8", signal: options.signal })
193
+ } catch {
194
+ // A skill deleted after listing is no longer loadable; the registry treats
195
+ // undefined as "gone" and invalidates its cached entry.
196
+ return undefined
197
+ }
198
+ const parsed = parseSkill(raw, target)
199
+ // Built field by field rather than by spreading and dropping the extras: `rank`
200
+ // and `locator` describe the catalog entry and are not part of a definition, and
201
+ // naming what belongs here keeps that boundary visible.
202
+ return {
203
+ name: candidate.name,
204
+ description: candidate.description,
205
+ invocation: candidate.invocation,
206
+ source: candidate.source,
207
+ provider: candidate.provider,
208
+ path: candidate.path,
209
+ resourceBase: candidate.resourceBase,
210
+ whenToUse: candidate.whenToUse,
211
+ content: parsed.content,
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Create the XP-Gate skill provider for a given skills root.
217
+ *
218
+ * Mirrors the shape of the official `@deepseek-ai/dsh-skill-office` provider:
219
+ * candidates are scanned once, bodies are re-read on every `get()` so editing a
220
+ * skill takes effect without restarting the host.
221
+ */
222
+ export function createSkillProvider(root: string): SkillProvider {
223
+ const byName = new Map(scanSkills(root).map((skill) => [skill.name, skill]))
224
+ const candidates = listBundledSkills(root)
225
+
226
+ return {
227
+ name: PROVIDER_NAME,
228
+ list: () => Promise.resolve(candidates),
229
+ get: (candidate, options) => loadSkill(candidate, options, byName),
230
+ }
231
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @test REQ-DSH-013 sprint-flow subcommands are callable from the model
3
+ * @intent Verify the sprint tool only ever builds a command from an enumerated
4
+ * subcommand, so exposing sprint state to the model does not widen the
5
+ * shell surface it already had.
6
+ * @covers AC-DSH-013-01 AC-DSH-013-02 AC-DSH-013-03
7
+ *
8
+ * The sprint-flow skill tells the model to run sprint-status, phase-transition and
9
+ * friends, but none of them were reachable from the plugin: a model following the
10
+ * skill could only report that it could not proceed. Each of these is read-mostly
11
+ * state, so the fix is a narrow tool, not a passthrough -- a passthrough would turn
12
+ * one tool into the whole CLI.
13
+ */
14
+
15
+ import { describe, expect, it } from "vitest"
16
+ import { SPRINT_SUBCOMMANDS, PHASE_STATUSES, buildSprintCommand, isSprintSubcommand } from "./sprint.js"
17
+
18
+ /** Collect the ids so a failure names what is missing rather than just a count. */
19
+ const IDS = SPRINT_SUBCOMMANDS.slice()
20
+
21
+ describe("sprint subcommand whitelist", () => {
22
+ it("exposes every subcommand the sprint-flow skill references", () => {
23
+ for (const needed of [
24
+ "sprint-status",
25
+ "phase-transition",
26
+ "retro",
27
+ "ui-review",
28
+ "sprint-init",
29
+ "sprint-audit",
30
+ ]) {
31
+ expect(IDS).toContain(needed)
32
+ }
33
+ })
34
+
35
+ it("accepts only enumerated subcommands", () => {
36
+ expect(isSprintSubcommand("sprint-status")).toBe(true)
37
+ // Anything not in the list must not reach a shell, however plausible it looks.
38
+ for (const rejected of ["", "uninstall", "sprint-status; rm -rf /", "$(whoami)", "gate-11"]) {
39
+ expect(isSprintSubcommand(rejected)).toBe(false)
40
+ }
41
+ })
42
+
43
+ it("builds sprint-status with structured options only", () => {
44
+ const cmd = buildSprintCommand("sprint-status", { json: true, dir: "/repo" }, "powershell")
45
+ expect(cmd).toContain("sprint-status")
46
+ expect(cmd).toContain("--json")
47
+ expect(cmd).toContain("--dir")
48
+ })
49
+
50
+ it("rejects a phase-transition whose status is not a documented value", () => {
51
+ // A status is a second injection point, so it is enumerated rather than quoted.
52
+ expect(PHASE_STATUSES).toEqual(["in_progress", "completed", "skipped", "failed", "paused"])
53
+ expect(() =>
54
+ buildSprintCommand("phase-transition", { phase: "3", status: "done; rm -rf /" }, "powershell"),
55
+ ).toThrow()
56
+ })
57
+
58
+ it("quotes a path containing shell metacharacters", () => {
59
+ const cmd = buildSprintCommand("sprint-status", { dir: "/tmp/a b; whoami" }, "powershell")
60
+ // The whole value must stay one argument; the semicolon must not start a command.
61
+ expect(cmd).not.toMatch(/; whoami\s*$/)
62
+ expect(cmd).toContain("a b; whoami")
63
+ })
64
+ })
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Sprint-flow subcommands the model may run.
3
+ *
4
+ * @test REQ-DSH-013 sprint-flow subcommands are callable from the model
5
+ * @intent Expose the sprint state commands the sprint-flow skill instructs the model
6
+ * to run, without opening a passthrough to the whole CLI.
7
+ * @covers AC-DSH-013-01
8
+ *
9
+ * The skill references these commands by name, so before this module existed a model
10
+ * following the skill could read the instruction and had no way to carry it out.
11
+ *
12
+ * The whitelist is the security boundary, not a convenience: a passthrough tool would
13
+ * turn one tool call into arbitrary CLI invocation, which is a far larger surface than
14
+ * the four gate tools it sits beside. Enumerating both the subcommand and the
15
+ * phase-transition status keeps every user-influenced token inside a known set.
16
+ */
17
+
18
+ import { type ShellDialect, shellQuote } from "./dialect.js"
19
+
20
+ /**
21
+ * Subcommands in scope. Deliberately excludes anything destructive or
22
+ * environment-mutating (`uninstall`, `init`, `migrate`, `setup-global`) even though
23
+ * they are the same CLI.
24
+ */
25
+ export const SPRINT_SUBCOMMANDS = [
26
+ "sprint-status",
27
+ "phase-transition",
28
+ "retro",
29
+ "ui-review",
30
+ "sprint-init",
31
+ "sprint-audit",
32
+ "next-sprint",
33
+ ] as const
34
+
35
+ export type SprintSubcommand = (typeof SPRINT_SUBCOMMANDS)[number]
36
+
37
+ /** The only status values `phase-transition` documents. */
38
+ export const PHASE_STATUSES = ["in_progress", "completed", "skipped", "failed", "paused"] as const
39
+
40
+ export type PhaseStatus = (typeof PHASE_STATUSES)[number]
41
+
42
+ /** Options a caller may pass. Every field is either enumerated or quoted. */
43
+ export interface SprintCommandOptions {
44
+ /** phase-transition phase number, 1-6. */
45
+ readonly phase?: string
46
+ /** phase-transition status. */
47
+ readonly status?: string
48
+ /** Project directory, quoted as a single argument. */
49
+ readonly dir?: string
50
+ /** Emit JSON where the subcommand supports it. */
51
+ readonly json?: boolean
52
+ /** `retro`: look back N days. */
53
+ readonly days?: number
54
+ /** `retro`: cover every sprint. */
55
+ readonly all?: boolean
56
+ }
57
+
58
+ /** Phase numbers the CLI accepts. */
59
+ const PHASE_NUMBERS: readonly string[] = ["1", "2", "3", "4", "5", "6"]
60
+
61
+ /** Type guard for the subcommand whitelist. */
62
+ export function isSprintSubcommand(value: string): boolean {
63
+ return (SPRINT_SUBCOMMANDS as readonly string[]).includes(value)
64
+ }
65
+
66
+ /**
67
+ * Validate a subcommand, throwing rather than returning a sentinel.
68
+ *
69
+ * A caller that ignores a boolean would fall through to "run nothing" or, worse, to a
70
+ * default subcommand; throwing makes the rejection impossible to overlook.
71
+ */
72
+ export function assertSprintSubcommand(value: string): asserts value is SprintSubcommand {
73
+ if (!isSprintSubcommand(value)) {
74
+ throw new Error(
75
+ `Unknown sprint subcommand "${value}". Allowed: ${SPRINT_SUBCOMMANDS.join(", ")}`,
76
+ )
77
+ }
78
+ }
79
+
80
+ /** Validate the two positional arguments phase-transition requires. */
81
+ function phaseTokens(options: SprintCommandOptions): string[] {
82
+ const phase = options.phase ?? ""
83
+ if (!PHASE_NUMBERS.includes(phase)) {
84
+ throw new Error(`phase must be one of ${PHASE_NUMBERS.join(", ")}; got "${phase}"`)
85
+ }
86
+ const status = options.status ?? ""
87
+ if (!(PHASE_STATUSES as readonly string[]).includes(status)) {
88
+ throw new Error(`status must be one of ${PHASE_STATUSES.join(", ")}; got "${status}"`)
89
+ }
90
+ return [phase, status]
91
+ }
92
+
93
+ /** Append the flags shared by several subcommands. */
94
+ function sharedFlags(options: SprintCommandOptions, dialect: ShellDialect): string[] {
95
+ const flags: string[] = []
96
+ if (options.json) flags.push("--json")
97
+ if (options.all) flags.push("--all")
98
+ if (options.days !== undefined && Number.isInteger(options.days) && options.days > 0) {
99
+ flags.push("--days", String(options.days))
100
+ }
101
+ if (options.dir !== undefined) flags.push("--dir", shellQuote(options.dir, dialect))
102
+ return flags
103
+ }
104
+
105
+ /**
106
+ * Build the command line for a whitelisted subcommand.
107
+ *
108
+ * User-influenced values reach the shell in exactly two ways: enumerated membership
109
+ * (subcommand, phase, status) or shell quoting (paths). Nothing is interpolated raw,
110
+ * which is what keeps this from becoming the injection surface a passthrough would be.
111
+ */
112
+ export function buildSprintCommand(
113
+ subcommand: string,
114
+ options: SprintCommandOptions = {},
115
+ dialect: ShellDialect = "posix",
116
+ ): string {
117
+ assertSprintSubcommand(subcommand)
118
+ const tokens = ["xp-gate", subcommand]
119
+ if (subcommand === "phase-transition") tokens.push(...phaseTokens(options))
120
+ tokens.push(...sharedFlags(options, dialect))
121
+ return tokens.join(" ")
122
+ }