@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
@@ -0,0 +1,430 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { defineTool } from "@deepseek-ai/dsh-tools";
5
+ import z from "@deepseek-ai/schemastery";
6
+ import { GATE_WHITELIST, buildCommand, detectDialect, resolveTarget } from "./command.js";
7
+ import { runXpGate } from "./gate-runner.js";
8
+ import { DelphiSetupError, listCredentialReferences, loadDelphiConfig, toDelphiOptions } from "./delphi-config.js";
9
+ import { formatOutcome, runReview } from "./delphi-run.js";
10
+ import { buildEvidence } from "./evidence.js";
11
+ import { readGitContext } from "./git-context.js";
12
+ import { delphiGuardDecision, isCheckablePath, isEditTool } from "./hooks.js";
13
+ import { PHASE_STATUSES, SPRINT_SUBCOMMANDS, buildSprintCommand } from "./sprint.js";
14
+ import { createSkillProvider } from "./skill-provider.js";
15
+ import { createCredentialResolver } from "./credentials.js";
16
+ export const name = "tool-xp-gate";
17
+ // "skills" is required: the bundled skill provider registers during apply(), and
18
+ // without it ctx.skills may not be mounted yet when this plugin starts.
19
+ export const inject = ["tools", "shell", "skills"];
20
+ /**
21
+ * Absolute path to the skills directory shipped alongside this module.
22
+ *
23
+ * Resolved from `import.meta.url` rather than `process.cwd()`: after an npm
24
+ * install the plugin lives in the profile's node_modules, so the working
25
+ * directory says nothing about where its assets are. This is the same
26
+ * resolution the official `@deepseek-ai/dsh-skill-office` uses.
27
+ */
28
+ function bundledSkillsRoot() {
29
+ return fileURLToPath(new URL("../skills/", import.meta.url));
30
+ }
31
+ const DEFAULT_TIMEOUT_MS = 120_000;
32
+ // Multi-round reviews over large inputs are slow; the transport applies its
33
+ // own per-request timeout on top of this ceiling.
34
+ const DELPHI_TIMEOUT_MS = 900_000;
35
+ /** The document Gate MW reads at push time. */
36
+ const EVIDENCE_FILE = ".code-walkthrough-result.json";
37
+ /**
38
+ * Files already reported on in this process.
39
+ *
40
+ * Server-lifetime rather than session-lifetime on purpose: the addon outlives any
41
+ * one session, and re-reporting the same unchanged file to a second session would
42
+ * spend a subprocess to repeat something already said.
43
+ */
44
+ const checkedThisSession = new Set();
45
+ // Boy Scout (#447): gate-check's `gates` parameter nests one level too deep
46
+ // inline (parameters → gates → items), so the schema lives at module scope.
47
+ const GATES_PARAMETER = {
48
+ type: "array",
49
+ items: { type: "string", enum: GATE_WHITELIST },
50
+ description: "Optional gate subset to run (e.g. ['principles', 'arch']).",
51
+ };
52
+ // Boy Scout (#447): apply() used to inline four register(defineTool({...}))
53
+ // blocks (149 lines, 5 brace levels). Each tool now lives in its own factory;
54
+ // apply() is pure wiring.
55
+ function gateCheckTool(shell, dialect) {
56
+ return defineTool({
57
+ name: "gate-check",
58
+ description: "Run XP-Gate quality gates (xp-gate check) on a file or directory and return the gate report. Prefers the global xp-gate CLI and degrades to an actionable install hint when it is absent.",
59
+ parameters: {
60
+ path: {
61
+ type: "string",
62
+ required: true,
63
+ description: "File or directory path to check (absolute or relative to the workspace).",
64
+ },
65
+ gates: GATES_PARAMETER,
66
+ },
67
+ output: {
68
+ schema: { type: "string" },
69
+ render: (_args, value) => [{ type: "text", text: value }],
70
+ },
71
+ timeoutMs: DEFAULT_TIMEOUT_MS,
72
+ async execute(args, exec) {
73
+ const cwd = exec.agent?.session?.header?.cwd ?? process.cwd();
74
+ const target = resolveTarget(args.path, cwd);
75
+ const command = buildCommand({ subcommand: "check", target, gates: args.gates, dialect });
76
+ return runXpGate(shell, exec.signal, command, cwd, DEFAULT_TIMEOUT_MS);
77
+ },
78
+ });
79
+ }
80
+ function gatePrinciplesTool(shell, dialect) {
81
+ return defineTool({
82
+ name: "gate-principles",
83
+ description: "Run the 14 Clean Code / SOLID rules (xp-gate principles, Gate 4) on a file or directory and return the principles report.",
84
+ parameters: {
85
+ path: {
86
+ type: "string",
87
+ required: true,
88
+ description: "File or directory path to check (absolute or relative to the workspace).",
89
+ },
90
+ },
91
+ output: {
92
+ schema: { type: "string" },
93
+ render: (_args, value) => [{ type: "text", text: value }],
94
+ },
95
+ timeoutMs: DEFAULT_TIMEOUT_MS,
96
+ async execute(args, exec) {
97
+ const cwd = exec.agent?.session?.header?.cwd ?? process.cwd();
98
+ const target = resolveTarget(args.path, cwd);
99
+ const command = buildCommand({ subcommand: "principles", target, dialect });
100
+ return runXpGate(shell, exec.signal, command, cwd, DEFAULT_TIMEOUT_MS);
101
+ },
102
+ });
103
+ }
104
+ function gateArchTool(shell, dialect) {
105
+ return defineTool({
106
+ name: "gate-arch",
107
+ description: "Run architecture validation (xp-gate arch, Gate 6) with the pinned local @archlinter/cli built-in rules and baseline ratchet, and return the architecture report. Pass a config file only to opt into archlint's own --config path (pre-0.16 schemas are rejected by archlint itself).",
108
+ parameters: {
109
+ config: {
110
+ type: "string",
111
+ description: "Optional architecture config file (opts into archlint --config; the default built-in-rules path needs none).",
112
+ },
113
+ },
114
+ output: {
115
+ schema: { type: "string" },
116
+ render: (_args, value) => [{ type: "text", text: value }],
117
+ },
118
+ timeoutMs: DEFAULT_TIMEOUT_MS,
119
+ async execute(args, exec) {
120
+ const cwd = exec.agent?.session?.header?.cwd ?? process.cwd();
121
+ const command = buildCommand({ subcommand: "arch", config: args.config, dialect });
122
+ return runXpGate(shell, exec.signal, command, cwd, DEFAULT_TIMEOUT_MS);
123
+ },
124
+ });
125
+ }
126
+ // Delphi multi-model cross-review.
127
+ //
128
+ // DSH's own subagents cannot do this: they inherit one host-configured model,
129
+ // so three "experts" would be three instances of the same model, and Delphi's
130
+ // value rests entirely on the reviewers being DIFFERENT models. This tool
131
+ // therefore calls an external OpenAI-compatible provider directly, with one
132
+ // distinct model per seat.
133
+ // Boy Scout (#447): the review body used to sit inline in execute() at 5 brace
134
+ // levels (defineTool → execute → try → runReview options). Module scope keeps
135
+ // the try/catch semantics identical while dropping two levels.
136
+ /**
137
+ * Write .code-walkthrough-result.json for Gate MW, or explain why it cannot.
138
+ *
139
+ * Returns a suffix for the tool's output rather than throwing: the review itself
140
+ * succeeded, and losing its text because evidence could not be written would be a
141
+ * worse outcome than a clear note that the push gate will not be satisfied.
142
+ */
143
+ async function writeWalkthroughEvidence(cwd, cfg, outcome) {
144
+ try {
145
+ const lastRound = outcome.rounds[outcome.rounds.length - 1];
146
+ const seats = lastRound.verdicts.map((v) => ({
147
+ role: v.role,
148
+ model: v.requestedModel,
149
+ resolved: v.resolvedModel ?? "",
150
+ }));
151
+ const git = readGitContext(cwd);
152
+ const evidence = buildEvidence({
153
+ head: git.head,
154
+ branch: git.branch,
155
+ seats,
156
+ verdict: outcome.consensus ? "APPROVED" : "REQUEST_CHANGES",
157
+ now: new Date(),
158
+ baseUrl: cfg.baseUrl,
159
+ consensusRatio: outcome.finalRatio,
160
+ });
161
+ const target = join(cwd, EVIDENCE_FILE);
162
+ await writeFile(target, `${JSON.stringify(evidence, null, 2)}\n`, "utf8");
163
+ return `\nEvidence: ${EVIDENCE_FILE} written for ${evidence.commit.slice(0, 8)}.`;
164
+ }
165
+ catch (error) {
166
+ return `\nEvidence: not written — ${error.message}`;
167
+ }
168
+ }
169
+ async function runDelphiReview(subject, request) {
170
+ const { addressed, maxRounds, cwd, resolveApiKey, signal, writeEvidence } = request;
171
+ try {
172
+ // Resolve credentials BEFORE loading the config: the loader is synchronous and
173
+ // throws as soon as a referenced variable has no value. Reading the reference
174
+ // names first turns "the DSH process never inherited WHALECLOUD_API_KEY" from a
175
+ // hard failure into a lookup in the credential store.
176
+ const apiKeyOverrides = {};
177
+ for (const name of listCredentialReferences(cwd)) {
178
+ const value = await resolveApiKey(name);
179
+ if (value !== undefined && value.length > 0)
180
+ apiKeyOverrides[name] = value;
181
+ }
182
+ const cfg = loadDelphiConfig(cwd, process.env, undefined, apiKeyOverrides);
183
+ const options = toDelphiOptions(cfg);
184
+ const outcome = await runReview({ ...options, maxRounds: maxRounds ?? cfg.maxRounds }, subject, addressed, signal);
185
+ const seats = cfg.seats.map((s) => s.model).join(", ");
186
+ const suffix = writeEvidence
187
+ ? await writeWalkthroughEvidence(cwd, cfg, outcome)
188
+ : "";
189
+ return `${formatOutcome(outcome)}\n\nProvider: ${cfg.provider}\nSeats: ${seats}${suffix}`;
190
+ }
191
+ catch (e) {
192
+ // Config problems are the common failure; report them as guidance
193
+ // rather than a stack trace.
194
+ if (e instanceof DelphiSetupError)
195
+ return `Delphi review not started: ${e.message}`;
196
+ throw e;
197
+ }
198
+ }
199
+ function delphiReviewTool(resolveApiKey) {
200
+ return defineTool({
201
+ name: "delphi-review",
202
+ description: "Run a Delphi cross-review of a subject (a diff, a design, a spec) using three DISTINCT models from an external OpenAI-compatible provider configured in .delphi-config.json. Round 1 is anonymous and concurrent; later rounds feed back the aggregate until the approval ratio reaches the configured threshold (default 90%) or the round cap (default 5). Use this whenever a change needs independent multi-model review rather than a single model's opinion.",
203
+ parameters: {
204
+ subject: {
205
+ type: "string",
206
+ required: true,
207
+ description: "The material to review, inline. For a diff, pass the diff text (or a summarized form if very large).",
208
+ },
209
+ addressed: {
210
+ type: "string",
211
+ description: "Optional description of what changed since a previous round, fed back to later rounds so reviewers can revise their position.",
212
+ },
213
+ maxRounds: {
214
+ type: "number",
215
+ description: "Override the configured round cap (Delphi default is 5).",
216
+ },
217
+ cwd: {
218
+ type: "string",
219
+ description: "Project root containing .delphi-config.json (defaults to the session cwd).",
220
+ },
221
+ writeEvidence: {
222
+ type: "boolean",
223
+ description: "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.",
224
+ },
225
+ },
226
+ output: {
227
+ schema: { type: "string" },
228
+ render: (_args, value) => [{ type: "text", text: value }],
229
+ },
230
+ timeoutMs: DELPHI_TIMEOUT_MS,
231
+ async execute(args, exec) {
232
+ const cwd = args.cwd ?? exec.agent?.session?.header?.cwd ?? process.cwd();
233
+ return runDelphiReview(args.subject, {
234
+ addressed: args.addressed ?? "",
235
+ maxRounds: args.maxRounds,
236
+ cwd,
237
+ resolveApiKey,
238
+ signal: exec.signal,
239
+ writeEvidence: args.writeEvidence ?? false,
240
+ });
241
+ },
242
+ });
243
+ }
244
+ /**
245
+ * Sprint-flow state commands.
246
+ *
247
+ * The sprint-flow skill tells the model to run these by name; without a tool the
248
+ * instruction was unfollowable. Deliberately narrow: the subcommand and the
249
+ * phase-transition status are enumerated, and paths are quoted, so this does not
250
+ * become a passthrough to the whole CLI.
251
+ */
252
+ function sprintTool(shell, dialect) {
253
+ return defineTool({
254
+ name: "sprint",
255
+ description: "Inspect and advance XP-Gate Sprint Flow state: sprint-status, phase-transition, retro, ui-review, sprint-init, sprint-audit, next-sprint.",
256
+ parameters: {
257
+ subcommand: {
258
+ type: "string",
259
+ required: true,
260
+ description: `One of: ${SPRINT_SUBCOMMANDS.join(", ")}.`,
261
+ // Enumerated rather than free text: the schema rejects anything else before
262
+ // the value could ever reach a shell.
263
+ enum: SPRINT_SUBCOMMANDS.slice(),
264
+ },
265
+ phase: { type: "string", description: "phase-transition only: phase number, 1-6." },
266
+ status: {
267
+ type: "string",
268
+ description: "phase-transition only: phase status.",
269
+ enum: PHASE_STATUSES.slice(),
270
+ },
271
+ dir: { type: "string", description: "Project directory (absolute or relative)." },
272
+ json: { type: "boolean", description: "Request JSON output where supported." },
273
+ days: { type: "number", description: "retro only: days to look back." },
274
+ all: { type: "boolean", description: "retro only: cover every sprint." },
275
+ },
276
+ output: {
277
+ schema: { type: "string" },
278
+ render: (_args, value) => [{ type: "text", text: value }],
279
+ },
280
+ timeoutMs: DEFAULT_TIMEOUT_MS,
281
+ async execute(args, exec) {
282
+ const cwd = exec.agent?.session?.header?.cwd ?? process.cwd();
283
+ const command = buildSprintCommand(args.subcommand, args, dialect);
284
+ return runXpGate(shell, exec.signal, command, cwd, DEFAULT_TIMEOUT_MS);
285
+ },
286
+ });
287
+ }
288
+ /**
289
+ * Runtime schema for the keys above.
290
+ *
291
+ * Exported as a schema, not just a type: DSH validates against it and surfaces the
292
+ * keys in the profile schema, so a user can discover and set them. A bare interface
293
+ * is invisible to the harness.
294
+ */
295
+ export const Config = z.object({
296
+ principlesCheck: z
297
+ .union([z.const("auto"), z.const("off")])
298
+ .default("auto")
299
+ .description("Save-time principles check: auto = on for source files, once per file; off = do not register the hook."),
300
+ delphiGuard: z
301
+ .union([z.const("auto"), z.const("off")])
302
+ .default("auto")
303
+ .description("Sprint write guard: auto = deny edits until delphi-review is APPROVED; off = do not register the hook."),
304
+ });
305
+ export function apply(ctx, config = {}) {
306
+ const cfg = config;
307
+ // DSH runs a POSIX shell off-Windows and PowerShell on Windows; generating the
308
+ // wrong dialect makes every tool fail to parse rather than degrade gracefully.
309
+ const dialect = detectDialect();
310
+ const shell = ctx.shell;
311
+ // ctx.credentials is optional: outside DSH there is no store, and the resolver
312
+ // then reads the process environment directly. Inside DSH this is what makes
313
+ // delphi-review work on a machine whose key lives in the credential store rather
314
+ // than in the host process's inherited environment.
315
+ //
316
+ // The cast is needed because the service is attached by a peer package's module
317
+ // augmentation, which only applies when that package is in the compilation; a
318
+ // consumer may install this plugin without it.
319
+ const credentials = ctx.credentials;
320
+ const resolveApiKey = createCredentialResolver(credentials, process.env);
321
+ ctx.tools.register(gateCheckTool(shell, dialect));
322
+ ctx.tools.register(gatePrinciplesTool(shell, dialect));
323
+ ctx.tools.register(gateArchTool(shell, dialect));
324
+ ctx.tools.register(delphiReviewTool(resolveApiKey));
325
+ ctx.tools.register(sprintTool(shell, dialect));
326
+ // Publishing the 12 bundled skills is what makes them visible to the model.
327
+ // They ship in the tarball either way, but without a provider no discovery
328
+ // root points at the plugin directory and the model never sees them — the v1
329
+ // bug this provider exists to fix.
330
+ ctx.skills.registerProvider(() => createSkillProvider(bundledSkillsRoot()));
331
+ registerHooks(ctx, shell, dialect, cfg);
332
+ }
333
+ /**
334
+ * Attach the advisory principles result to an accepted tool result.
335
+ *
336
+ * Best effort by contract: a throwing post-execute fails the tool call, which is a
337
+ * much worse outcome than skipping one advisory check, so nothing here propagates.
338
+ */
339
+ async function enrichWithPrinciples(shell, dialect, exec, result, next) {
340
+ try {
341
+ const note = await principlesNote(shell, dialect, exec, result);
342
+ if (note === undefined)
343
+ return next();
344
+ const accepted = await next();
345
+ if (accepted.kind !== "accept" || accepted.value !== undefined)
346
+ return accepted;
347
+ // Appended to the tool's own output rather than as a separate message: the model
348
+ // reads it immediately, with no dependency on message identity.
349
+ return {
350
+ ...accepted,
351
+ content: [...(accepted.content ?? []), { type: "text", text: note }],
352
+ };
353
+ }
354
+ catch {
355
+ return next();
356
+ }
357
+ }
358
+ /**
359
+ * Register the pre/post-execute hooks.
360
+ *
361
+ * Both default to on and are individually switchable, because each is a policy a
362
+ * repository may reasonably reject: the guard blocks writes, and the save check
363
+ * costs a subprocess per file.
364
+ */
365
+ function registerHooks(ctx, shell, dialect, cfg) {
366
+ if (cfg.delphiGuard !== "off") {
367
+ ctx.on("tools/pre-execute", async (exec, next) => {
368
+ const cwd = sessionCwd(exec) ?? process.cwd();
369
+ // Deny before dispatch so the edit never reaches the filesystem.
370
+ return delphiGuardDecision(cwd, exec.name) ?? next();
371
+ });
372
+ }
373
+ if (cfg.principlesCheck !== "off") {
374
+ ctx.on("tools/post-execute", (exec, result, next) => enrichWithPrinciples(shell, dialect, exec, result, next));
375
+ }
376
+ }
377
+ /** The session cwd, if the execution carries one. */
378
+ function sessionCwd(exec) {
379
+ return exec.agent?.session?.header?.cwd;
380
+ }
381
+ /**
382
+ * Advisory principles result for a just-written file, or undefined to stay silent.
383
+ *
384
+ * Cost is the whole design here. `xp-gate principles` is a Node CLI with a
385
+ * 200-400ms cold start, and an editing session makes dozens of edits, so two
386
+ * filters cut nearly all of the calls:
387
+ *
388
+ * - extension allowlist: the engine covers source files, not .md/.json/.yaml,
389
+ * which are a large share of the edits in repositories that use xp-gate
390
+ * - per-process dedupe: the model often edits the same file ten times in a row, and
391
+ * those later edits are it fixing what the first check reported
392
+ *
393
+ * Anything unexpected returns undefined. Blocking the tool call to report an
394
+ * advisory violation would be far worse than missing the report.
395
+ */
396
+ async function principlesNote(shell, dialect, exec, result) {
397
+ if (!isEditTool(exec.name) || result.isError)
398
+ return undefined;
399
+ const path = writtenPath(exec.arguments);
400
+ if (path === undefined || !isCheckablePath(path))
401
+ return undefined;
402
+ const cwd = sessionCwd(exec) ?? process.cwd();
403
+ const absolute = resolveTarget(path, cwd);
404
+ if (checkedThisSession.has(absolute))
405
+ return undefined;
406
+ checkedThisSession.add(absolute);
407
+ try {
408
+ // No caller signal reaches a post-execute hook; the hook's own ceiling is enough,
409
+ // and it is the shell that enforces it.
410
+ const report = await runXpGate(shell, AbortSignal.timeout(DEFAULT_TIMEOUT_MS), buildCommand({ subcommand: "principles", target: absolute, dialect }), cwd, DEFAULT_TIMEOUT_MS);
411
+ return `[XP-Gate] principles for ${path}:\n${report}`;
412
+ }
413
+ catch {
414
+ return undefined;
415
+ }
416
+ }
417
+ /**
418
+ * The file a write/edit touched.
419
+ *
420
+ * Both shapes appear across DSH's file tools, so both are read rather than assuming
421
+ * one: `file_path` from the fs tools, `path` from str-replace-editor.
422
+ */
423
+ function writtenPath(args) {
424
+ if (typeof args !== "object" || args === null)
425
+ return undefined;
426
+ const record = args;
427
+ const candidate = record.file_path ?? record.path;
428
+ return typeof candidate === "string" && candidate.length > 0 ? candidate : undefined;
429
+ }
430
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,15 @@
1
+ import type { SkillCandidate, SkillProvider } from "@deepseek-ai/dsh-skill";
2
+ /**
3
+ * Build the DSH skill candidates for every skill bundled under `root`.
4
+ * Exposed for tests and for callers that need the catalog without a provider.
5
+ */
6
+ export declare function listBundledSkills(root: string): SkillCandidate[];
7
+ /**
8
+ * Create the XP-Gate skill provider for a given skills root.
9
+ *
10
+ * Mirrors the shape of the official `@deepseek-ai/dsh-skill-office` provider:
11
+ * candidates are scanned once, bodies are re-read on every `get()` so editing a
12
+ * skill takes effect without restarting the host.
13
+ */
14
+ export declare function createSkillProvider(root: string): SkillProvider;
15
+ //# sourceMappingURL=skill-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skill-provider.d.ts","sourceRoot":"","sources":["../src/skill-provider.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EACV,cAAc,EAGd,aAAa,EACd,MAAM,wBAAwB,CAAA;AA8I/B;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,EAAE,CAahE;AA6CD;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,CAS/D"}
@@ -0,0 +1,205 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { BUNDLED_SKILL_RANK } from "@deepseek-ai/dsh-skill";
5
+ /**
6
+ * Provider label surfaced to the model alongside each skill.
7
+ * Matches the plugin name so a loaded skill is attributable.
8
+ */
9
+ const PROVIDER_NAME = "xp-gate";
10
+ /** Files a skill directory must contain to be loadable. */
11
+ const SKILL_FILE = "SKILL.md";
12
+ /** The `---` line that opens and closes a SKILL.md YAML frontmatter block. */
13
+ const FRONTMATTER_FENCE = "---";
14
+ /** Offset just past the opening fence, and past the closing fence plus its newline. */
15
+ const FRONTMATTER_START = FRONTMATTER_FENCE.length;
16
+ const FRONTMATTER_END_OFFSET = FRONTMATTER_FENCE.length + 1;
17
+ /**
18
+ * Unfold a YAML block scalar (`key: >` / `key: |`) body into one line.
19
+ * Folded blocks join on spaces, literal blocks on newlines.
20
+ */
21
+ function unfoldBlock(lines, literal) {
22
+ const trimmed = lines.map((line) => line.trim());
23
+ if (literal)
24
+ return trimmed.filter((line) => line.length > 0).join("\n");
25
+ return trimmed.filter((line) => line.length > 0).join(" ");
26
+ }
27
+ /**
28
+ * Extract `name` and `description` from a SKILL.md frontmatter block.
29
+ *
30
+ * Two description shapes ship in this repository: folded blocks
31
+ * (`description: >`) used by delphi-review and sprint-flow, and one-line quoted
32
+ * or plain values used by the rest. A parser handling only one shape yields an
33
+ * empty description for the other, which makes those skills unroutable — the
34
+ * model never picks a skill it cannot see described.
35
+ */
36
+ function parseSkill(raw, file) {
37
+ if (!raw.startsWith(FRONTMATTER_FENCE)) {
38
+ throw new Error(`${file}: missing YAML frontmatter`);
39
+ }
40
+ const end = raw.indexOf(`\n${FRONTMATTER_FENCE}`, FRONTMATTER_START);
41
+ if (end === -1) {
42
+ throw new Error(`${file}: unterminated YAML frontmatter`);
43
+ }
44
+ const frontmatter = raw.slice(FRONTMATTER_START, end);
45
+ const content = raw.slice(end + FRONTMATTER_END_OFFSET).replace(/^\r?\n/, "");
46
+ const lines = frontmatter.split(/\r?\n/);
47
+ let name = "";
48
+ let description = "";
49
+ for (let index = 0; index < lines.length; index += 1) {
50
+ const line = lines[index] ?? "";
51
+ const keyed = /^([A-Za-z_][A-Za-z0-9_-]*):(.*)$/.exec(line);
52
+ if (!keyed)
53
+ continue;
54
+ const key = keyed[1];
55
+ const rest = (keyed[2] ?? "").trim();
56
+ // Collect the indented continuation lines of a block scalar.
57
+ const blockMatch = /^([>|])([+-]?)$/.exec(rest);
58
+ if (blockMatch) {
59
+ const block = [];
60
+ let cursor = index + 1;
61
+ while (cursor < lines.length) {
62
+ const next = lines[cursor] ?? "";
63
+ if (next.trim().length > 0 && !/^\s/.test(next))
64
+ break;
65
+ block.push(next);
66
+ cursor += 1;
67
+ }
68
+ index = cursor - 1;
69
+ const value = unfoldBlock(block, blockMatch[1] === "|");
70
+ if (key === "name")
71
+ name = value;
72
+ if (key === "description")
73
+ description = value;
74
+ continue;
75
+ }
76
+ if (key !== "name" && key !== "description")
77
+ continue;
78
+ // Strip a matching pair of surrounding quotes without touching inner ones.
79
+ const unquoted = /^(['"])(.*)\1$/.exec(rest)?.[2] ?? rest;
80
+ if (key === "name")
81
+ name = unquoted;
82
+ if (key === "description")
83
+ description = unquoted;
84
+ }
85
+ if (!name)
86
+ throw new Error(`${file}: frontmatter carries no name`);
87
+ if (!description)
88
+ throw new Error(`${file}: frontmatter carries no description`);
89
+ return { name, description, content };
90
+ }
91
+ /**
92
+ * Scan `root` for skill directories without loading their bodies.
93
+ *
94
+ * The catalog is fixed at package build time, so scanning once at apply() is
95
+ * enough; bodies stay on disk and are read per `get()`. A missing root yields
96
+ * an empty catalog rather than throwing: a plugin whose skills directory was
97
+ * stripped during packaging must still load its tools.
98
+ */
99
+ function scanSkills(root) {
100
+ let entries;
101
+ try {
102
+ entries = readdirSync(root, { withFileTypes: true })
103
+ .filter((entry) => entry.isDirectory())
104
+ .map((entry) => entry.name);
105
+ }
106
+ catch {
107
+ return [];
108
+ }
109
+ const listed = [];
110
+ for (const entry of entries.sort()) {
111
+ const directory = join(root, entry);
112
+ const file = join(directory, SKILL_FILE);
113
+ let raw;
114
+ try {
115
+ raw = readFileSync(file, "utf8");
116
+ }
117
+ catch {
118
+ // A directory without SKILL.md is not a skill; skip it silently so a
119
+ // stray file in the package cannot break the whole catalog.
120
+ continue;
121
+ }
122
+ const parsed = parseSkill(raw, file);
123
+ listed.push({
124
+ name: parsed.name,
125
+ description: parsed.description,
126
+ directory,
127
+ file,
128
+ });
129
+ }
130
+ return listed;
131
+ }
132
+ /**
133
+ * Build the DSH skill candidates for every skill bundled under `root`.
134
+ * Exposed for tests and for callers that need the catalog without a provider.
135
+ */
136
+ export function listBundledSkills(root) {
137
+ return scanSkills(root).map((skill) => ({
138
+ name: skill.name,
139
+ description: skill.description,
140
+ invocation: { modelInvocable: true, userInvocable: true },
141
+ provider: PROVIDER_NAME,
142
+ source: "bundled",
143
+ rank: BUNDLED_SKILL_RANK,
144
+ // 8 of the 12 skills reference references/*.md and templates/*; without a
145
+ // directory base those relative paths have nothing to resolve against.
146
+ resourceBase: { kind: "directory", path: skill.directory },
147
+ locator: skill.file,
148
+ }));
149
+ }
150
+ /**
151
+ * Load one candidate's body from disk.
152
+ *
153
+ * Separated from the provider literal so the lookup/read/decorate steps sit at the
154
+ * same nesting level instead of inside an object method's promise chain.
155
+ */
156
+ async function loadSkill(candidate, options, byName) {
157
+ // A candidate carries the absolute SKILL.md path it was listed from, so loading
158
+ // works even if the scanned catalog is stale; fall back to the catalog for
159
+ // hand-built candidates in tests.
160
+ const locator = typeof candidate.locator === "string" ? candidate.locator : "";
161
+ const target = locator.length > 0 ? locator : byName.get(candidate.name)?.file;
162
+ if (!target)
163
+ return undefined;
164
+ let raw;
165
+ try {
166
+ raw = await readFile(target, { encoding: "utf8", signal: options.signal });
167
+ }
168
+ catch {
169
+ // A skill deleted after listing is no longer loadable; the registry treats
170
+ // undefined as "gone" and invalidates its cached entry.
171
+ return undefined;
172
+ }
173
+ const parsed = parseSkill(raw, target);
174
+ // Built field by field rather than by spreading and dropping the extras: `rank`
175
+ // and `locator` describe the catalog entry and are not part of a definition, and
176
+ // naming what belongs here keeps that boundary visible.
177
+ return {
178
+ name: candidate.name,
179
+ description: candidate.description,
180
+ invocation: candidate.invocation,
181
+ source: candidate.source,
182
+ provider: candidate.provider,
183
+ path: candidate.path,
184
+ resourceBase: candidate.resourceBase,
185
+ whenToUse: candidate.whenToUse,
186
+ content: parsed.content,
187
+ };
188
+ }
189
+ /**
190
+ * Create the XP-Gate skill provider for a given skills root.
191
+ *
192
+ * Mirrors the shape of the official `@deepseek-ai/dsh-skill-office` provider:
193
+ * candidates are scanned once, bodies are re-read on every `get()` so editing a
194
+ * skill takes effect without restarting the host.
195
+ */
196
+ export function createSkillProvider(root) {
197
+ const byName = new Map(scanSkills(root).map((skill) => [skill.name, skill]));
198
+ const candidates = listBundledSkills(root);
199
+ return {
200
+ name: PROVIDER_NAME,
201
+ list: () => Promise.resolve(candidates),
202
+ get: (candidate, options) => loadSkill(candidate, options, byName),
203
+ };
204
+ }
205
+ //# sourceMappingURL=skill-provider.js.map