@boyingliu01/xp-gate 0.31.4 → 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 (144) 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 +149 -2
  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-prune.test.ts +61 -0
  126. package/principles/__tests__/cli-files-separators.test.ts +69 -0
  127. package/principles/__tests__/config-merge-warnings.test.ts +148 -0
  128. package/principles/adapters/java.ts +22 -3
  129. package/principles/adapters/typescript.ts +70 -29
  130. package/principles/analyzer.ts +36 -3
  131. package/principles/baseline-prune.ts +19 -7
  132. package/principles/config.ts +56 -35
  133. package/principles/index.ts +122 -40
  134. package/principles/rules/clean-code/deep-nesting.ts +36 -22
  135. package/principles/rules/clean-code/unused-imports.ts +30 -19
  136. package/principles/rules/solid/dip.ts +30 -20
  137. package/principles/rules/solid/lsp.ts +17 -2
  138. package/principles/rules/solid/ocp.ts +33 -23
  139. package/principles/rules/solid/srp.ts +65 -23
  140. package/scripts/delphi-external-review.cjs +25 -2
  141. package/scripts/sync-package-content.js +28 -1
  142. package/skills/delphi-review/AGENTS.md +3 -3
  143. package/skills/sprint-flow/AGENTS.md +3 -3
  144. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -1,7 +1,10 @@
1
1
  import { describe, it, expect } from "vitest"
2
2
  import type { Context } from "@deepseek-ai/cordis"
3
3
  import type { ShellExecRequest, ShellExecSpec, ShellRunResult } from "@deepseek-ai/dsh-shell"
4
- import { apply } from "./index.js"
4
+ import type { SkillProvider, SkillProviderControl, SkillProviderObservation } from "@deepseek-ai/dsh-skill"
5
+ import { apply, inject } from "./index.js"
6
+
7
+ type ProviderFactory = (control: SkillProviderControl) => SkillProvider
5
8
 
6
9
  interface CapturedTool {
7
10
  name: string
@@ -15,6 +18,8 @@ function makeSpec(req: ShellExecRequest): ShellExecSpec {
15
18
  command: req.command,
16
19
  workdir: req.workdir ?? "/workspace",
17
20
  timeoutMs: req.timeoutMs ?? 1000,
21
+ // Resolved specs require the expiry policy; the request may omit it.
22
+ onExpiry: "kill",
18
23
  stdoutMaxBytes: 1_000_000,
19
24
  signal: req.signal,
20
25
  sandboxPolicy: undefined,
@@ -39,20 +44,30 @@ function makeShell(stdoutText: string, requests: ShellExecRequest[]) {
39
44
  requests.push(req)
40
45
  return makeSpec(req)
41
46
  },
42
- run(): Promise<ShellRunResult> {
43
- return Promise.resolve(makeRunResult(stdoutText))
47
+ // 0.2.0-rc.2 seam: execute() returns a handle whose result() settles.
48
+ execute(): Promise<{ result(): Promise<ShellRunResult> }> {
49
+ return Promise.resolve({ result: () => Promise.resolve(makeRunResult(stdoutText)) })
44
50
  },
45
51
  }
46
52
  }
47
53
 
54
+ /** The repository ships this many skills; the count is part of the delivery promise. */
55
+ const EXPECTED_SKILL_COUNT = 12;
56
+
48
57
  function makeCtx(stdoutText: string) {
49
58
  const captured: unknown[] = []
50
59
  const requests: ShellExecRequest[] = []
60
+ const providers: ProviderFactory[] = []
61
+ const hooks: string[] = []
51
62
  const ctx = {
52
63
  tools: { register: (def: unknown) => captured.push(def) },
64
+ skills: { registerProvider: (create: ProviderFactory) => providers.push(create) },
53
65
  shell: makeShell(stdoutText, requests),
66
+ // The plugin registers pre/post-execute hooks; recording the names is enough to
67
+ // prove registration without standing up the real waterfall.
68
+ on: (name: string) => hooks.push(name),
54
69
  } as unknown as Context
55
- return { ctx, captured, requests }
70
+ return { ctx, captured, requests, providers, hooks }
56
71
  }
57
72
 
58
73
  describe("apply", () => {
@@ -65,6 +80,48 @@ describe("apply", () => {
65
80
  expect(names).toContain("gate-arch")
66
81
  })
67
82
 
83
+ /**
84
+ * @test REQ-DSH-006
85
+ * @intent 验证 apply(ctx) 通过 ctx.skills.registerProvider 注册了 bundled skill provider,
86
+ * 且该 provider 能列出 12 个 skill —— 这是 skill 对模型可见的唯一通路
87
+ * @covers AC-DSH-006-02
88
+ */
89
+ it("registers the bundled skill provider that publishes all 12 skills", async () => {
90
+ const { ctx, providers } = makeCtx("ok")
91
+ apply(ctx)
92
+ expect(providers).toHaveLength(1)
93
+
94
+ const create = providers[0]
95
+ expect(create).toBeDefined()
96
+ if (!create) return
97
+ // Recording invalidations rather than no-oping: listing must not invalidate, and
98
+ // an empty stub would hide it if it started to.
99
+ const invalidations: string[] = []
100
+ const provider = create({
101
+ signal: new AbortController().signal,
102
+ invalidate: (name: string) => void invalidations.push(name),
103
+ })
104
+ expect(provider.name).toBe("xp-gate")
105
+
106
+ const listed = await provider.list({})
107
+ // DSH permits either a bare array or an observation object; this provider
108
+ // answers with the array form.
109
+ const candidates = Array.isArray(listed)
110
+ ? listed
111
+ : ((listed as SkillProviderObservation).candidates ?? [])
112
+ expect(candidates).toHaveLength(EXPECTED_SKILL_COUNT)
113
+ expect(invalidations).toHaveLength(0)
114
+ })
115
+
116
+ /**
117
+ * @test REQ-DSH-006
118
+ * @intent 验证 inject 声明了 "skills",否则 ctx.skills 在该插件 apply 时可能尚未就绪
119
+ * @covers AC-DSH-006-02
120
+ */
121
+ it("declares the skills capability in inject", () => {
122
+ expect(inject).toContain("skills")
123
+ })
124
+
68
125
  /**
69
126
  * @test REQ-DSH-002
70
127
  * @intent 验证 gate-check 工具注册(path required / gates optional)并端到端接线 args→命令→shell
@@ -78,7 +135,7 @@ describe("apply", () => {
78
135
  { path: "/repo/src", gates: ["principles"] },
79
136
  { signal: new AbortController().signal },
80
137
  )
81
- expect(out).toBe("gate report")
138
+ expect(out).toBe("[XP-Gate] PASS\ngate report")
82
139
  expect(requests[0]?.command).toContain("xp-gate check '/repo/src' --gates 'principles'")
83
140
  })
84
141
 
@@ -92,7 +149,7 @@ describe("apply", () => {
92
149
  apply(ctx)
93
150
  const tool = captured.find((c) => (c as CapturedTool).name === "gate-principles") as CapturedTool
94
151
  const out = await tool.execute({ path: "/repo/src/app.ts" }, { signal: new AbortController().signal })
95
- expect(out).toBe("principles report")
152
+ expect(out).toBe("[XP-Gate] PASS\nprinciples report")
96
153
  expect(requests[0]?.command).toContain("xp-gate principles '/repo/src/app.ts'")
97
154
  })
98
155
 
@@ -1,19 +1,61 @@
1
+ import { writeFile } from "node:fs/promises"
2
+ import { join } from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+
5
+ import type { CredentialProvider } from "@deepseek-ai/dsh-credentials"
1
6
  import { defineTool } from "@deepseek-ai/dsh-tools"
7
+ import type { PostToolDecision, ToolExecution, ToolExecutionResult } from "@deepseek-ai/dsh-tools"
8
+ import z from "@deepseek-ai/schemastery"
2
9
  import type { Context } from "@deepseek-ai/cordis"
3
10
  import type { ShellDialect } from "./command.js"
4
11
  import { GATE_WHITELIST, buildCommand, detectDialect, resolveTarget } from "./command.js"
5
12
  import { runXpGate } from "./gate-runner.js"
6
- import { DelphiSetupError, loadDelphiConfig, toDelphiOptions } from "./delphi-config.js"
13
+ import type { ResolvedDelphiConfig } from "./delphi-config.js"
14
+ import { DelphiSetupError, listCredentialReferences, loadDelphiConfig, toDelphiOptions } from "./delphi-config.js"
15
+ import type { ReviewOutcome } from "./delphi-run.js"
7
16
  import { formatOutcome, runReview } from "./delphi-run.js"
17
+ import { buildEvidence } from "./evidence.js"
18
+ import { readGitContext } from "./git-context.js"
19
+ import { delphiGuardDecision, isCheckablePath, isEditTool } from "./hooks.js"
20
+ import { PHASE_STATUSES, SPRINT_SUBCOMMANDS, buildSprintCommand } from "./sprint.js"
21
+ import { createSkillProvider } from "./skill-provider.js"
22
+ import type { ApiKeyResolver } from "./credentials.js"
23
+ import { createCredentialResolver } from "./credentials.js"
8
24
 
9
25
  export const name = "tool-xp-gate"
10
- export const inject = ["tools", "shell"]
26
+ // "skills" is required: the bundled skill provider registers during apply(), and
27
+ // without it ctx.skills may not be mounted yet when this plugin starts.
28
+ export const inject = ["tools", "shell", "skills"]
29
+
30
+ /**
31
+ * Absolute path to the skills directory shipped alongside this module.
32
+ *
33
+ * Resolved from `import.meta.url` rather than `process.cwd()`: after an npm
34
+ * install the plugin lives in the profile's node_modules, so the working
35
+ * directory says nothing about where its assets are. This is the same
36
+ * resolution the official `@deepseek-ai/dsh-skill-office` uses.
37
+ */
38
+ function bundledSkillsRoot(): string {
39
+ return fileURLToPath(new URL("../skills/", import.meta.url))
40
+ }
11
41
 
12
42
  const DEFAULT_TIMEOUT_MS = 120_000
13
43
  // Multi-round reviews over large inputs are slow; the transport applies its
14
44
  // own per-request timeout on top of this ceiling.
15
45
  const DELPHI_TIMEOUT_MS = 900_000
16
46
 
47
+ /** The document Gate MW reads at push time. */
48
+ const EVIDENCE_FILE = ".code-walkthrough-result.json"
49
+
50
+ /**
51
+ * Files already reported on in this process.
52
+ *
53
+ * Server-lifetime rather than session-lifetime on purpose: the addon outlives any
54
+ * one session, and re-reporting the same unchanged file to a second session would
55
+ * spend a subprocess to repeat something already said.
56
+ */
57
+ const checkedThisSession = new Set<string>()
58
+
17
59
  // Boy Scout (#447): gate-check's `gates` parameter nests one level too deep
18
60
  // inline (parameters → gates → items), so the schema lives at module scope.
19
61
  const GATES_PARAMETER = {
@@ -113,21 +155,78 @@ function gateArchTool(shell: Context["shell"], dialect: ShellDialect) {
113
155
  // Boy Scout (#447): the review body used to sit inline in execute() at 5 brace
114
156
  // levels (defineTool → execute → try → runReview options). Module scope keeps
115
157
  // the try/catch semantics identical while dropping two levels.
116
- async function runDelphiReview(
117
- subject: string,
118
- addressed: string,
119
- maxRounds: number | undefined,
158
+ /**
159
+ * Write .code-walkthrough-result.json for Gate MW, or explain why it cannot.
160
+ *
161
+ * Returns a suffix for the tool's output rather than throwing: the review itself
162
+ * succeeded, and losing its text because evidence could not be written would be a
163
+ * worse outcome than a clear note that the push gate will not be satisfied.
164
+ */
165
+ async function writeWalkthroughEvidence(
120
166
  cwd: string,
167
+ cfg: ResolvedDelphiConfig,
168
+ outcome: ReviewOutcome,
121
169
  ): Promise<string> {
122
170
  try {
123
- const cfg = loadDelphiConfig(cwd)
171
+ const lastRound = outcome.rounds[outcome.rounds.length - 1]
172
+ const seats = lastRound.verdicts.map((v) => ({
173
+ role: v.role,
174
+ model: v.requestedModel,
175
+ resolved: v.resolvedModel ?? "",
176
+ }))
177
+ const git = readGitContext(cwd)
178
+ const evidence = buildEvidence({
179
+ head: git.head,
180
+ branch: git.branch,
181
+ seats,
182
+ verdict: outcome.consensus ? "APPROVED" : "REQUEST_CHANGES",
183
+ now: new Date(),
184
+ baseUrl: cfg.baseUrl,
185
+ consensusRatio: outcome.finalRatio,
186
+ })
187
+ const target = join(cwd, EVIDENCE_FILE)
188
+ await writeFile(target, `${JSON.stringify(evidence, null, 2)}\n`, "utf8")
189
+ return `\nEvidence: ${EVIDENCE_FILE} written for ${evidence.commit.slice(0, 8)}.`
190
+ } catch (error) {
191
+ return `\nEvidence: not written — ${(error as Error).message}`
192
+ }
193
+ }
194
+
195
+ /** Everything a review needs from the tool call, minus the material under review. */
196
+ interface ReviewRequest {
197
+ readonly addressed: string
198
+ readonly maxRounds: number | undefined
199
+ readonly cwd: string
200
+ readonly resolveApiKey: ApiKeyResolver
201
+ readonly signal: AbortSignal | undefined
202
+ readonly writeEvidence: boolean
203
+ }
204
+
205
+ async function runDelphiReview(subject: string, request: ReviewRequest): Promise<string> {
206
+ const { addressed, maxRounds, cwd, resolveApiKey, signal, writeEvidence } = request
207
+ try {
208
+ // Resolve credentials BEFORE loading the config: the loader is synchronous and
209
+ // throws as soon as a referenced variable has no value. Reading the reference
210
+ // names first turns "the DSH process never inherited WHALECLOUD_API_KEY" from a
211
+ // hard failure into a lookup in the credential store.
212
+ const apiKeyOverrides: Record<string, string> = {}
213
+ for (const name of listCredentialReferences(cwd)) {
214
+ const value = await resolveApiKey(name)
215
+ if (value !== undefined && value.length > 0) apiKeyOverrides[name] = value
216
+ }
217
+ const cfg = loadDelphiConfig(cwd, process.env, undefined, apiKeyOverrides)
218
+ const options = toDelphiOptions(cfg)
124
219
  const outcome = await runReview(
125
- { ...toDelphiOptions(cfg), maxRounds: maxRounds ?? cfg.maxRounds },
220
+ { ...options, maxRounds: maxRounds ?? cfg.maxRounds },
126
221
  subject,
127
222
  addressed,
223
+ signal,
128
224
  )
129
225
  const seats = cfg.seats.map((s) => s.model).join(", ")
130
- return `${formatOutcome(outcome)}\n\nProvider: ${cfg.provider}\nSeats: ${seats}`
226
+ const suffix = writeEvidence
227
+ ? await writeWalkthroughEvidence(cwd, cfg, outcome)
228
+ : ""
229
+ return `${formatOutcome(outcome)}\n\nProvider: ${cfg.provider}\nSeats: ${seats}${suffix}`
131
230
  } catch (e) {
132
231
  // Config problems are the common failure; report them as guidance
133
232
  // rather than a stack trace.
@@ -136,7 +235,7 @@ async function runDelphiReview(
136
235
  }
137
236
  }
138
237
 
139
- function delphiReviewTool() {
238
+ function delphiReviewTool(resolveApiKey: ApiKeyResolver) {
140
239
  return defineTool({
141
240
  name: "delphi-review",
142
241
  description:
@@ -161,6 +260,11 @@ function delphiReviewTool() {
161
260
  type: "string",
162
261
  description: "Project root containing .delphi-config.json (defaults to the session cwd).",
163
262
  },
263
+ writeEvidence: {
264
+ type: "boolean",
265
+ description:
266
+ "Write .code-walkthrough-result.json for the Gate MW pre-push check. Off by default because it is a file side effect; requires an APPROVED review.",
267
+ },
164
268
  },
165
269
  output: {
166
270
  schema: { type: "string" },
@@ -169,18 +273,241 @@ function delphiReviewTool() {
169
273
  timeoutMs: DELPHI_TIMEOUT_MS,
170
274
  async execute(args, exec) {
171
275
  const cwd = args.cwd ?? exec.agent?.session?.header?.cwd ?? process.cwd()
172
- return runDelphiReview(args.subject, args.addressed ?? "", args.maxRounds, cwd)
276
+ return runDelphiReview(args.subject, {
277
+ addressed: args.addressed ?? "",
278
+ maxRounds: args.maxRounds,
279
+ cwd,
280
+ resolveApiKey,
281
+ signal: exec.signal,
282
+ writeEvidence: args.writeEvidence ?? false,
283
+ })
173
284
  },
174
285
  })
175
286
  }
176
287
 
177
- export function apply(ctx: Context, _config: unknown = {}): void {
288
+ /**
289
+ * Sprint-flow state commands.
290
+ *
291
+ * The sprint-flow skill tells the model to run these by name; without a tool the
292
+ * instruction was unfollowable. Deliberately narrow: the subcommand and the
293
+ * phase-transition status are enumerated, and paths are quoted, so this does not
294
+ * become a passthrough to the whole CLI.
295
+ */
296
+ function sprintTool(shell: Context["shell"], dialect: ShellDialect) {
297
+ return defineTool({
298
+ name: "sprint",
299
+ description:
300
+ "Inspect and advance XP-Gate Sprint Flow state: sprint-status, phase-transition, retro, ui-review, sprint-init, sprint-audit, next-sprint.",
301
+ parameters: {
302
+ subcommand: {
303
+ type: "string",
304
+ required: true,
305
+ description: `One of: ${SPRINT_SUBCOMMANDS.join(", ")}.`,
306
+ // Enumerated rather than free text: the schema rejects anything else before
307
+ // the value could ever reach a shell.
308
+ enum: SPRINT_SUBCOMMANDS.slice(),
309
+ },
310
+ phase: { type: "string", description: "phase-transition only: phase number, 1-6." },
311
+ status: {
312
+ type: "string",
313
+ description: "phase-transition only: phase status.",
314
+ enum: PHASE_STATUSES.slice(),
315
+ },
316
+ dir: { type: "string", description: "Project directory (absolute or relative)." },
317
+ json: { type: "boolean", description: "Request JSON output where supported." },
318
+ days: { type: "number", description: "retro only: days to look back." },
319
+ all: { type: "boolean", description: "retro only: cover every sprint." },
320
+ },
321
+ output: {
322
+ schema: { type: "string" },
323
+ render: (_args, value) => [{ type: "text", text: value }],
324
+ },
325
+ timeoutMs: DEFAULT_TIMEOUT_MS,
326
+ async execute(args, exec) {
327
+ const cwd = exec.agent?.session?.header?.cwd ?? process.cwd()
328
+ const command = buildSprintCommand(args.subcommand, args, dialect)
329
+ return runXpGate(shell, exec.signal, command, cwd, DEFAULT_TIMEOUT_MS)
330
+ },
331
+ })
332
+ }
333
+
334
+ /** Plugin configuration. */
335
+ interface XpGatePluginConfig {
336
+ /** Save-time principles check: auto = source files, once each; off = no hook. */
337
+ readonly principlesCheck: "auto" | "off"
338
+ /** Sprint write guard: auto = deny until APPROVED; off = no hook. */
339
+ readonly delphiGuard: "auto" | "off"
340
+ }
341
+
342
+ /**
343
+ * Runtime schema for the keys above.
344
+ *
345
+ * Exported as a schema, not just a type: DSH validates against it and surfaces the
346
+ * keys in the profile schema, so a user can discover and set them. A bare interface
347
+ * is invisible to the harness.
348
+ */
349
+ export const Config = z.object({
350
+ principlesCheck: z
351
+ .union([z.const("auto"), z.const("off")])
352
+ .default("auto")
353
+ .description(
354
+ "Save-time principles check: auto = on for source files, once per file; off = do not register the hook.",
355
+ ),
356
+ delphiGuard: z
357
+ .union([z.const("auto"), z.const("off")])
358
+ .default("auto")
359
+ .description(
360
+ "Sprint write guard: auto = deny edits until delphi-review is APPROVED; off = do not register the hook.",
361
+ ),
362
+ })
363
+
364
+ export function apply(ctx: Context, config: unknown = {}): void {
365
+ const cfg = config as Partial<XpGatePluginConfig>
178
366
  // DSH runs a POSIX shell off-Windows and PowerShell on Windows; generating the
179
367
  // wrong dialect makes every tool fail to parse rather than degrade gracefully.
180
368
  const dialect = detectDialect()
181
369
  const shell = ctx.shell
370
+ // ctx.credentials is optional: outside DSH there is no store, and the resolver
371
+ // then reads the process environment directly. Inside DSH this is what makes
372
+ // delphi-review work on a machine whose key lives in the credential store rather
373
+ // than in the host process's inherited environment.
374
+ //
375
+ // The cast is needed because the service is attached by a peer package's module
376
+ // augmentation, which only applies when that package is in the compilation; a
377
+ // consumer may install this plugin without it.
378
+ const credentials = (ctx as unknown as { credentials?: CredentialProvider }).credentials
379
+ const resolveApiKey = createCredentialResolver(credentials, process.env)
182
380
  ctx.tools.register(gateCheckTool(shell, dialect))
183
381
  ctx.tools.register(gatePrinciplesTool(shell, dialect))
184
382
  ctx.tools.register(gateArchTool(shell, dialect))
185
- ctx.tools.register(delphiReviewTool())
383
+ ctx.tools.register(delphiReviewTool(resolveApiKey))
384
+ ctx.tools.register(sprintTool(shell, dialect))
385
+
386
+ // Publishing the 12 bundled skills is what makes them visible to the model.
387
+ // They ship in the tarball either way, but without a provider no discovery
388
+ // root points at the plugin directory and the model never sees them — the v1
389
+ // bug this provider exists to fix.
390
+ ctx.skills.registerProvider(() => createSkillProvider(bundledSkillsRoot()))
391
+
392
+ registerHooks(ctx, shell, dialect, cfg)
393
+ }
394
+
395
+ /**
396
+ * Attach the advisory principles result to an accepted tool result.
397
+ *
398
+ * Best effort by contract: a throwing post-execute fails the tool call, which is a
399
+ * much worse outcome than skipping one advisory check, so nothing here propagates.
400
+ */
401
+ async function enrichWithPrinciples(
402
+ shell: Context["shell"],
403
+ dialect: ShellDialect,
404
+ exec: ToolExecution,
405
+ result: Readonly<ToolExecutionResult>,
406
+ next: () => Promise<PostToolDecision>,
407
+ ): Promise<PostToolDecision> {
408
+ try {
409
+ const note = await principlesNote(shell, dialect, exec, result)
410
+ if (note === undefined) return next()
411
+ const accepted = await next()
412
+ if (accepted.kind !== "accept" || accepted.value !== undefined) return accepted
413
+ // Appended to the tool's own output rather than as a separate message: the model
414
+ // reads it immediately, with no dependency on message identity.
415
+ return {
416
+ ...accepted,
417
+ content: [...(accepted.content ?? []), { type: "text" as const, text: note }],
418
+ }
419
+ } catch {
420
+ return next()
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Register the pre/post-execute hooks.
426
+ *
427
+ * Both default to on and are individually switchable, because each is a policy a
428
+ * repository may reasonably reject: the guard blocks writes, and the save check
429
+ * costs a subprocess per file.
430
+ */
431
+ function registerHooks(
432
+ ctx: Context,
433
+ shell: Context["shell"],
434
+ dialect: ShellDialect,
435
+ cfg: Partial<XpGatePluginConfig>,
436
+ ): void {
437
+ if (cfg.delphiGuard !== "off") {
438
+ ctx.on("tools/pre-execute", async (exec, next) => {
439
+ const cwd = sessionCwd(exec) ?? process.cwd()
440
+ // Deny before dispatch so the edit never reaches the filesystem.
441
+ return delphiGuardDecision(cwd, exec.name) ?? next()
442
+ })
443
+ }
444
+ if (cfg.principlesCheck !== "off") {
445
+ ctx.on("tools/post-execute", (exec, result, next) =>
446
+ enrichWithPrinciples(shell, dialect, exec, result, next),
447
+ )
448
+ }
449
+ }
450
+
451
+ /** The session cwd, if the execution carries one. */
452
+ function sessionCwd(exec: { agent?: { session?: { header?: { cwd?: string } } } }): string | undefined {
453
+ return exec.agent?.session?.header?.cwd
454
+ }
455
+
456
+ /**
457
+ * Advisory principles result for a just-written file, or undefined to stay silent.
458
+ *
459
+ * Cost is the whole design here. `xp-gate principles` is a Node CLI with a
460
+ * 200-400ms cold start, and an editing session makes dozens of edits, so two
461
+ * filters cut nearly all of the calls:
462
+ *
463
+ * - extension allowlist: the engine covers source files, not .md/.json/.yaml,
464
+ * which are a large share of the edits in repositories that use xp-gate
465
+ * - per-process dedupe: the model often edits the same file ten times in a row, and
466
+ * those later edits are it fixing what the first check reported
467
+ *
468
+ * Anything unexpected returns undefined. Blocking the tool call to report an
469
+ * advisory violation would be far worse than missing the report.
470
+ */
471
+ async function principlesNote(
472
+ shell: Context["shell"],
473
+ dialect: ShellDialect,
474
+ exec: ToolExecution,
475
+ result: Readonly<ToolExecutionResult>,
476
+ ): Promise<string | undefined> {
477
+ if (!isEditTool(exec.name) || result.isError) return undefined
478
+ const path = writtenPath(exec.arguments)
479
+ if (path === undefined || !isCheckablePath(path)) return undefined
480
+
481
+ const cwd = sessionCwd(exec) ?? process.cwd()
482
+ const absolute = resolveTarget(path, cwd)
483
+ if (checkedThisSession.has(absolute)) return undefined
484
+ checkedThisSession.add(absolute)
485
+
486
+ try {
487
+ // No caller signal reaches a post-execute hook; the hook's own ceiling is enough,
488
+ // and it is the shell that enforces it.
489
+ const report = await runXpGate(
490
+ shell,
491
+ AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
492
+ buildCommand({ subcommand: "principles", target: absolute, dialect }),
493
+ cwd,
494
+ DEFAULT_TIMEOUT_MS,
495
+ )
496
+ return `[XP-Gate] principles for ${path}:\n${report}`
497
+ } catch {
498
+ return undefined
499
+ }
500
+ }
501
+
502
+ /**
503
+ * The file a write/edit touched.
504
+ *
505
+ * Both shapes appear across DSH's file tools, so both are read rather than assuming
506
+ * one: `file_path` from the fs tools, `path` from str-replace-editor.
507
+ */
508
+ function writtenPath(args: unknown): string | undefined {
509
+ if (typeof args !== "object" || args === null) return undefined
510
+ const record = args as { file_path?: unknown; path?: unknown }
511
+ const candidate = record.file_path ?? record.path
512
+ return typeof candidate === "string" && candidate.length > 0 ? candidate : undefined
186
513
  }