@boyingliu01/xp-gate 0.31.4 → 0.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapters/java.sh +256 -4
- package/adapters/powershell.sh +45 -6
- package/bin/xp-gate.js +8 -2
- package/build-integrity/__tests__/gate-10.test.ts +88 -1
- package/build-integrity/gate-10.ts +51 -16
- package/dsh.bundle.patch.yml +15 -0
- package/gate-13-compiler-strictness.sh +1031 -0
- package/gate-3.sh +65 -16
- package/hooks/gate-13-compiler-strictness.sh +1031 -0
- package/hooks/gate-3.sh +65 -16
- package/hooks/lib/coverage-total.sh +18 -2
- package/hooks/lib/java-test-selection.sh +251 -0
- package/hooks/lib/jscpd-run.sh +101 -0
- package/hooks/lib/validate-code-walkthrough.cjs +186 -29
- package/hooks/pre-commit +259 -57
- package/lib/__tests__/detect-deps-dsh-profile.test.js +106 -0
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +140 -0
- package/lib/__tests__/gate-runner.test.js +29 -2
- package/lib/__tests__/issue-586-principles-cmdline.test.js +35 -0
- package/lib/__tests__/issue-618-gate-not-run.test.js +100 -0
- package/lib/__tests__/shared-utils.test.js +21 -0
- package/lib/check.js +6 -2
- package/lib/detect-deps.js +42 -10
- package/lib/doctor-tui.js +111 -21
- package/lib/gate-runner.js +111 -37
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/principles.js +48 -6
- package/lib/shared-utils.js +8 -0
- package/mock-policy/AGENTS.md +3 -3
- package/mock-policy/gate-m3.ts +57 -23
- package/mutation/AGENTS.md +3 -3
- package/mutation/__tests__/pitest-gradle-availability.test.ts +74 -0
- package/mutation/__tests__/test-filter-parity.test.ts +5 -2
- package/mutation/gate-m.ts +30 -24
- package/mutation/runners/mutmut-runner.ts +80 -30
- package/mutation/runners/pitest-runner.ts +60 -42
- package/package.json +14 -2
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/lib/command.d.ts +38 -0
- package/plugins/dsh/lib/command.d.ts.map +1 -0
- package/plugins/dsh/lib/command.js +87 -0
- package/plugins/dsh/lib/credentials.d.ts +39 -0
- package/plugins/dsh/lib/credentials.d.ts.map +1 -0
- package/plugins/dsh/lib/credentials.js +57 -0
- package/plugins/dsh/lib/delphi-config.d.ts +129 -0
- package/plugins/dsh/lib/delphi-config.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-config.js +369 -0
- package/plugins/dsh/lib/delphi-run.d.ts +24 -0
- package/plugins/dsh/lib/delphi-run.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-run.js +113 -0
- package/plugins/dsh/lib/delphi.d.ts +94 -0
- package/plugins/dsh/lib/delphi.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi.js +207 -0
- package/plugins/dsh/lib/dialect.d.ts +24 -0
- package/plugins/dsh/lib/dialect.d.ts.map +1 -0
- package/plugins/dsh/lib/dialect.js +36 -0
- package/plugins/dsh/lib/evidence.d.ts +70 -0
- package/plugins/dsh/lib/evidence.d.ts.map +1 -0
- package/plugins/dsh/lib/evidence.js +92 -0
- package/plugins/dsh/lib/gate-runner.d.ts +49 -0
- package/plugins/dsh/lib/gate-runner.d.ts.map +1 -0
- package/plugins/dsh/lib/gate-runner.js +95 -0
- package/plugins/dsh/lib/git-context.d.ts +26 -0
- package/plugins/dsh/lib/git-context.d.ts.map +1 -0
- package/plugins/dsh/lib/git-context.js +30 -0
- package/plugins/dsh/lib/hooks.d.ts +42 -0
- package/plugins/dsh/lib/hooks.d.ts.map +1 -0
- package/plugins/dsh/lib/hooks.js +113 -0
- package/plugins/dsh/lib/index.d.ts +20 -0
- package/plugins/dsh/lib/index.d.ts.map +1 -0
- package/plugins/dsh/lib/index.js +430 -0
- package/plugins/dsh/lib/skill-provider.d.ts +15 -0
- package/plugins/dsh/lib/skill-provider.d.ts.map +1 -0
- package/plugins/dsh/lib/skill-provider.js +205 -0
- package/plugins/dsh/lib/sprint.d.ts +60 -0
- package/plugins/dsh/lib/sprint.d.ts.map +1 -0
- package/plugins/dsh/lib/sprint.js +92 -0
- package/plugins/dsh/package.json +14 -8
- package/plugins/dsh/scripts/verify-skill-provider.mjs +113 -0
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/src/command-single-form.test.ts +67 -0
- package/plugins/dsh/src/command.test.ts +21 -17
- package/plugins/dsh/src/command.ts +19 -21
- package/plugins/dsh/src/compat.test.ts +200 -0
- package/plugins/dsh/src/contract.test.ts +7 -1
- package/plugins/dsh/src/credentials.test.ts +93 -0
- package/plugins/dsh/src/credentials.ts +71 -0
- package/plugins/dsh/src/delphi-cancellation.test.ts +71 -0
- package/plugins/dsh/src/delphi-config-apikey-override.test.ts +94 -0
- package/plugins/dsh/src/delphi-config-credential-refs.test.ts +75 -0
- package/plugins/dsh/src/delphi-config.ts +146 -53
- package/plugins/dsh/src/delphi-run.ts +27 -4
- package/plugins/dsh/src/delphi.ts +133 -65
- package/plugins/dsh/src/evidence.test.ts +74 -0
- package/plugins/dsh/src/evidence.ts +141 -0
- package/plugins/dsh/src/gate-runner.test.ts +14 -6
- package/plugins/dsh/src/gate-runner.ts +68 -5
- package/plugins/dsh/src/git-context.test.ts +39 -0
- package/plugins/dsh/src/git-context.ts +38 -0
- package/plugins/dsh/src/hooks.test.ts +87 -0
- package/plugins/dsh/src/hooks.ts +126 -0
- package/plugins/dsh/src/index.test.ts +63 -6
- package/plugins/dsh/src/index.ts +340 -13
- package/plugins/dsh/src/skill-provider.test.ts +194 -0
- package/plugins/dsh/src/skill-provider.ts +231 -0
- package/plugins/dsh/src/sprint.test.ts +64 -0
- package/plugins/dsh/src/sprint.ts +122 -0
- package/plugins/dsh/src/tri-state.test.ts +123 -0
- package/plugins/opencode/index.ts +192 -58
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/principles/__tests__/analyze-read-failure.test.ts +70 -0
- package/principles/__tests__/baseline-prune.test.ts +61 -0
- package/principles/__tests__/cli-files-separators.test.ts +69 -0
- package/principles/__tests__/config-merge-warnings.test.ts +148 -0
- package/principles/adapters/java.ts +22 -3
- package/principles/adapters/typescript.ts +70 -29
- package/principles/analyzer.ts +36 -3
- package/principles/baseline-prune.ts +19 -7
- package/principles/config.ts +56 -35
- package/principles/index.ts +122 -40
- package/principles/rules/clean-code/deep-nesting.ts +36 -22
- package/principles/rules/clean-code/unused-imports.ts +30 -19
- package/principles/rules/solid/dip.ts +30 -20
- package/principles/rules/solid/lsp.ts +17 -2
- package/principles/rules/solid/ocp.ts +33 -23
- package/principles/rules/solid/srp.ts +65 -23
- package/scripts/delphi-external-review.cjs +25 -2
- package/scripts/sync-package-content.js +28 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- 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 {
|
|
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
|
-
|
|
43
|
-
|
|
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("
|
|
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("
|
|
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
|
|
package/plugins/dsh/src/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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
|
-
{ ...
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
}
|