- 去掉 tea、gh、Gitea 字样:查工单 spx issue view,查 PR spx pr view,发起 PR spx pr create 且描述必须含 Closes #N - 审查和实施的 diff 不再写死 origin/main:审查取 PR 的目标分支,实施取计划里的目标分支或仓库默认分支 - 工单模板同时看 .gitea/ISSUE_TEMPLATE/ 和 .github/ISSUE_TEMPLATE/,禁止合并的说法不带工具名 - 内联兜底提示词同步改;设置里存着旧版的用户激活时按现有机制被问是否换新版 - 新增测试守住自带提示词和兜底不再点名平台
632 lines
28 KiB
TypeScript
632 lines
28 KiB
TypeScript
/**
|
||
* Non-secret extension settings persisted as `settings.json` under
|
||
* `context.globalStorageUri`. That directory survives VSIX 更新;`globalState`
|
||
* 在部分 VS Code profile 下只活在内存里,一重载就丢。
|
||
*
|
||
* Token stays in `context.secrets` (see `src/auth/secrets.ts`).
|
||
* 读:文件 → 旧 globalState(一次性迁移)。写:只写文件。
|
||
*
|
||
* Empty-string values for prompts are treated as "use the default" so the
|
||
* form can save user input verbatim and still fall back when the user clears
|
||
* a field.
|
||
*/
|
||
|
||
import type { Disposable, ExtensionContext } from 'vscode'
|
||
import type { MigrationNote } from './promptMigration'
|
||
import { EventEmitter } from 'node:events'
|
||
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||
import path from 'node:path'
|
||
import { seedCommitProfilePath } from '../cc/commitProfile'
|
||
import { listClaudeProfiles, setProfilesDirOverride } from '../cc/profiles'
|
||
import { normalizeReleasePrefix } from '../git/release'
|
||
import { PREVIOUS_DEFAULT_REVIEW_PROMPT } from './previousReviewPrompt'
|
||
import { planPromptMigration, planReviewPromptMigration } from './promptMigration'
|
||
|
||
/**
|
||
* Fallback prompt strings used when the on-disk markdown can't be read.
|
||
* The full editable versions live in `prompts/*.md` at the extension root;
|
||
* users can tweak them without rebuilding. These short fallbacks just keep
|
||
* the extension usable if the file is missing/corrupt.
|
||
*/
|
||
const FALLBACK_BRAINSTORM_PROMPT = `{{#if issueNumber}}
|
||
/ecc:grilling 讨论下 {{issueNumber}} 号工单,先用 spx issue view 看工单。
|
||
{{#if prototypeFile}}
|
||
工单已有原型插件 {{prototypeFile}},先读它;原型要改就 ListAgents 找到 issue-{{issueNumber}}-原型 会话{{#if prototypeSessionId}}(id {{prototypeSessionId}}){{/if}}并 SendMessage 给它,不要派子代理、不要自己动插件代码。
|
||
{{/if}}
|
||
达成共识后用 ecc:spec 写 .claude/specs/<stem>.md,我确认后用 ecc:plan 出 .claude/plans/<stem>.md,两个文件在仓库默认分支上提交并用 spx issue marker 同步 marker。不要 git checkout / 不要写代码 / 不要建 PR。
|
||
{{else}}
|
||
我现在有这样一个需求 {{userRequest}},你用 spx 命令创建工单。工单 body 末尾必须包含 <!-- spx:nonce={{nonce}} -->。创建完工单立即停下汇报,不要擅自实施。
|
||
{{/if}}`
|
||
|
||
const FALLBACK_IMPLEMENT_PLAN_PROMPT = `/ecc:tdd @{{planFile}} 按计划实施,编码派子代理,用 spx pr create 发起 PR,body 中必须包含 "Closes #{{issueNumber}}"。严禁合并 PR。`
|
||
|
||
const FALLBACK_TEST_PROMPT = `/pr-acceptance-testing 验收 {{issueNumber}} 号工单({{prRef}})。验收目标以工单正文为准,不要再问验收哪个。`
|
||
|
||
const FALLBACK_REVIEW_PROMPT = `用 spx pr view 查 #{{prNumber}} PR 的目标分支 <base>,把 git diff origin/<base>...HEAD 交给 ecc:code-reviewer 子代理审查。审查意见用 spx pr review-comment 发评论。严禁合并 PR。`
|
||
|
||
const FALLBACK_PROTOTYPE_PROMPT = `/ecc:figma-plugin-prototyping 给 {{issueNumber}} 号工单画原型:先读工单正文锁定要画的界面和状态,再写本地 Figma 插件把画板和点击流做出来。画完汇报插件路径和怎么导入,不要改业务代码。
|
||
|
||
画完用 \`opencli spx issue marker --issue {{issueNumber}} --type prototype --value <manifest.json 的仓库相对路径>\` 把原型插件挂到工单上,再汇报。`
|
||
|
||
type PromptName = 'brainstorm' | 'implement-plan' | 'review' | 'test' | 'prototype'
|
||
|
||
/**
|
||
* Read a default prompt template from `${extensionPath}/prompts/${name}.md`.
|
||
*
|
||
* This is called every time `getSettings` runs, so edits to the markdown
|
||
* file take effect on the next cc/codex tab launch without reloading the
|
||
* window. If the file is missing or unreadable we fall back to a short
|
||
* inline string so the extension stays functional.
|
||
*/
|
||
export function readDefaultPrompt(extensionPath: string, name: PromptName): string {
|
||
const filePath = path.join(extensionPath, 'prompts', `${name}.md`)
|
||
try {
|
||
return readFileSync(filePath, 'utf-8')
|
||
}
|
||
catch (err) {
|
||
console.warn(`[spx] 读 ${filePath} 失败,使用内联 fallback:`, err)
|
||
switch (name) {
|
||
case 'brainstorm': return FALLBACK_BRAINSTORM_PROMPT
|
||
case 'implement-plan': return FALLBACK_IMPLEMENT_PLAN_PROMPT
|
||
case 'review': return FALLBACK_REVIEW_PROMPT
|
||
case 'test': return FALLBACK_TEST_PROMPT
|
||
case 'prototype': return FALLBACK_PROTOTYPE_PROMPT
|
||
}
|
||
}
|
||
}
|
||
|
||
export const DEFAULT_WEBHOOK_PORT = 17421
|
||
|
||
export interface Settings {
|
||
/** Local HTTP port for receiving gitea webhook callbacks. */
|
||
webhookPort: number
|
||
/**
|
||
* Gitea webhook 的 secret,用于校验 `X-Gitea-Signature`(HMAC-SHA256)。
|
||
* webhook 经 frp 暴露公网,不校验等于任何人都能伪造事件触发 codex。
|
||
* 空 = 不校验(未在 Gitea 侧配置 secret 的过渡状态)。
|
||
*/
|
||
webhookSecret: string
|
||
/**
|
||
* 头脑风暴模板(Handlebars)。`{{#if issueNumber}}` 走续聊已有工单,
|
||
* `{{else}}` 走新建工单(用 `{{userRequest}}` / `{{nonce}}`);续聊分支里
|
||
* `{{#if prototypeFile}}` 嵌原型段落。
|
||
*/
|
||
brainstormPrompt: string
|
||
/** 实施模板;常用 `{{planFile}}` / `{{issueNumber}}`。 */
|
||
implementPlanPrompt: string
|
||
/** Whether to automatically run `codex exec review` when a PR opens. */
|
||
autoReview: boolean
|
||
/** 审查模板;常用 `{{prNumber}}`。 */
|
||
reviewPrompt: string
|
||
/** 测试会话首个提示词模板;常用 `{{issueNumber}}` / `{{prRef}}`。 */
|
||
testPrompt: string
|
||
/** 原型会话首个提示词模板;常用 `{{issueNumber}}`。 */
|
||
prototypePrompt: string
|
||
/**
|
||
* 某一版自带提示词用户已经选过「使用新版」或「保留现有」。
|
||
* 键是提示词字段,值是那一版自带正文的摘要。正文再变就再问。
|
||
*/
|
||
promptDefaultChoices?: Record<string, string>
|
||
/** Day-to-day development branch (e.g. `main`). Jenkins doesn't watch it. */
|
||
devBranch: string
|
||
/**
|
||
* 发布分支名前缀(如 `release/`),远端该前缀下字典序最大的分支即当期发布点。
|
||
* 读出时已归一化:trim、空回退默认、末尾补 `/`。
|
||
*/
|
||
releaseBranchPrefix: string
|
||
/** 正式服部署的 Jenkins 任务名;空串 = 不提供「部署到正式服」。 */
|
||
jenkinsJob: string
|
||
/** Jenkins 任务的 `REMOTE_HOST` 参数取值,空回退 `正式服`。 */
|
||
jenkinsProdHost: string
|
||
/**
|
||
* Directory template for the worktree created when implementing a plan.
|
||
* Placeholders: `$project_root` (workspace abs path), `$project_name` (its
|
||
* basename), `$feature_name` (the spec/plan slug). A leading `~` expands to
|
||
* the user's home dir. Empty string means "use the default
|
||
* `~/Sources/worktree/$project_name/$feature_name`".
|
||
*/
|
||
worktreeDirectory: string
|
||
/**
|
||
* Which editor opens a worktree when the user clicks it in the detail
|
||
* panel. `'pycharm'` shells out to the JetBrains `pycharm` launcher;
|
||
* `'vscode'` (default) opens a new VS Code window.
|
||
*/
|
||
worktreeOpenWith: 'vscode' | 'pycharm'
|
||
/**
|
||
* Command used to launch PyCharm when `worktreeOpenWith === 'pycharm'`.
|
||
* A bare name relies on PATH, which a GUI-launched VS Code often lacks, so
|
||
* an absolute path (or a `~`-prefixed one) is the reliable choice. Empty
|
||
* means "use the default `pycharm`".
|
||
*/
|
||
pycharmCommand: string
|
||
/**
|
||
* Path to a user-provided shell script that runs *after* the extension
|
||
* creates a worktree via `git worktree add`. Empty string means use the
|
||
* default `<workspaceRoot>/.spx/worktree-post-create.sh`. Absent file
|
||
* (default path or user-specified) is treated as a no-op, not an error.
|
||
*
|
||
* Hook receives env: `WORKTREE_PATH`, `WORKSPACE_ROOT`, `BRANCH`,
|
||
* `ISSUE_NUMBER`, `MAIN_BRANCH`.
|
||
*/
|
||
worktreePostCreateScript: string
|
||
/**
|
||
* Path to a user-provided shell script that runs *before* the extension
|
||
* removes a worktree via `git worktree remove`. Empty string means use
|
||
* the default `<workspaceRoot>/.spx/worktree-pre-remove.sh`. Absent file
|
||
* is treated as a no-op.
|
||
*
|
||
* Same env vars as the post-create hook.
|
||
*/
|
||
worktreePreRemoveScript: string
|
||
/**
|
||
* Path to a user-provided shell script that runs *before* the extension
|
||
* spawns the implement cc terminal tab. Empty string means use the
|
||
* default `<workspaceRoot>/.spx/impl-tab-pre-create.sh`. Absent file is
|
||
* treated as a no-op. Tab-reuse fast path (already-open impl terminal)
|
||
* does NOT trigger this hook.
|
||
*
|
||
* Same env vars as the worktree hooks.
|
||
*/
|
||
implTabPreCreateScript: string
|
||
/**
|
||
* Path to a user-provided shell script that runs *after* the implement
|
||
* cc terminal tab is closed (detected via `onDidCloseTerminal`). Empty
|
||
* string means use the default
|
||
* `<workspaceRoot>/.spx/impl-tab-post-close.sh`. Absent file is treated
|
||
* as a no-op. Typically used to tear down an IDE launched by the
|
||
* pre-create hook.
|
||
*
|
||
* Same env vars as the worktree hooks.
|
||
*/
|
||
implTabPostCloseScript: string
|
||
/**
|
||
* 新开会话终端是否分到右侧一栏。关掉则在当前编辑组加标签,适合小屏。
|
||
*/
|
||
splitNewTerminals: boolean
|
||
/**
|
||
* YouTrack instance base URL (e.g. `https://ziwuxian.youtrack.cloud`). Empty
|
||
* string means "YouTrack source disabled" — the loader returns no issues.
|
||
*/
|
||
youtrackBaseUrl: string
|
||
/**
|
||
* Short name of the YouTrack project to mirror into the board (e.g. `LXF`).
|
||
* Empty string means "disabled". The token lives in SecretStorage keyed by
|
||
* the base URL's host.
|
||
*/
|
||
youtrackProjectShortName: string
|
||
/**
|
||
* Optional YouTrack command applied when a youtrack card is dropped in the
|
||
* 完成 column (e.g. `State Fixed`). Empty string means "auto-detect" — the
|
||
* extension finds the project's first `isResolved` state value and uses that.
|
||
*/
|
||
youtrackCloseCommand: string
|
||
/**
|
||
* 头脑风暴会话的默认 Claude profile 绝对路径。工单详情面板里的
|
||
* 「头脑风暴配置文件」为空时用它;它也为空才落到 DEFAULT_PROFILE_PATH。
|
||
*/
|
||
defaultBrainstormProfilePath: string
|
||
/** 实施会话的默认 Claude profile;工单级「实施配置文件」为空时用它。 */
|
||
defaultImplementProfilePath: string
|
||
/** 测试会话的默认 Claude profile;工单级「测试配置文件」为空时用它。 */
|
||
defaultTestProfilePath: string
|
||
/** 原型会话的默认 Claude profile;工单级「原型配置文件」为空时用它。 */
|
||
defaultPrototypeProfilePath: string
|
||
/** 代码阅读里问 AI(块下面、预览顶部、「了解一块代码」)的默认 Claude profile;空串时用插件的默认 profile,它也没有就不带 `--settings`。 */
|
||
defaultAskProfilePath: string
|
||
/** 代码阅读里重写注释的默认 Claude profile;空串时用插件的默认 profile,它也没有就不带 `--settings`。 */
|
||
defaultRewriteProfilePath: string
|
||
/**
|
||
* Claude settings profile path for the conflict-resolution cc session
|
||
* (drag to 完成 when PR merge conflicts). Empty = DEFAULT_PROFILE_PATH.
|
||
*/
|
||
conflictResolutionProfilePath: string
|
||
/**
|
||
* 工具栏「提交代码」使用的 Claude profile 绝对路径。
|
||
* 空串 = 未配置 → 提交严格失败。升级时 key 缺失会 seed deepseek-v4-pro(若存在)。
|
||
*/
|
||
commitProfilePath: string
|
||
/**
|
||
* 「改动」tab 单文件说明 / 工单 PR 变更摘要使用的 Claude profile 绝对路径。
|
||
* 空串 = 未配置 → 生成失败。升级时 key 缺失会 seed deepseek-v4-pro(若存在)。
|
||
*/
|
||
prDiffSummaryProfilePath: string
|
||
/**
|
||
* codex 审查会话使用的模型(`-c model=`)。空串 = 不传,用 codex
|
||
* `config.toml` 默认。
|
||
*/
|
||
codexModel: string
|
||
/**
|
||
* codex 审查会话的思考级别(`-c model_reasoning_effort=`)。空串 = 不传,
|
||
* 用 codex 默认。合法值 minimal/low/medium/high/xhigh。
|
||
*/
|
||
codexReasoningEffort: string
|
||
/**
|
||
* Claude `--settings` profile 所在目录。空串 = 自动探测。
|
||
* 支持前导 `~`。保存后 listClaudeProfiles / getDefaultProfilePath 都从这里读。
|
||
*/
|
||
profilesDirectory: string
|
||
/**
|
||
* 移交存储(阿里云 OSS)的 endpoint,如 `oss-cn-beijing.aliyuncs.com`。空串 = 没配置,
|
||
* 移交和接管都会停下来提示去跑「配置移交存储」。AccessKey 在 SecretStorage 里。
|
||
*/
|
||
handoffOssEndpoint: string
|
||
/** 移交存储的 bucket 名。空串 = 没配置。 */
|
||
handoffOssBucket: string
|
||
}
|
||
|
||
export const SETTINGS_KEY = 'superpowers.settings'
|
||
const SETTINGS_FILE = 'settings.json'
|
||
|
||
export function settingsFilePath(ctx: ExtensionContext): string {
|
||
return path.join(ctx.globalStorageUri.fsPath, SETTINGS_FILE)
|
||
}
|
||
|
||
function parseStoredBlob(raw: unknown): Partial<Settings> | null {
|
||
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
||
return null
|
||
return raw as Partial<Settings>
|
||
}
|
||
|
||
/** 文件优先;没有文件时读旧 globalState,供迁移。 */
|
||
export function readStoredSettings(ctx: ExtensionContext): Partial<Settings> {
|
||
try {
|
||
const parsed: unknown = JSON.parse(readFileSync(settingsFilePath(ctx), 'utf8'))
|
||
const blob = parseStoredBlob(parsed)
|
||
if (blob)
|
||
return blob
|
||
}
|
||
catch {
|
||
// 文件不存在或坏掉:回退 memento。
|
||
}
|
||
return parseStoredBlob(ctx.globalState.get(SETTINGS_KEY)) ?? {}
|
||
}
|
||
|
||
function writeStoredSettings(ctx: ExtensionContext, blob: Partial<Settings>): void {
|
||
const dir = ctx.globalStorageUri.fsPath
|
||
mkdirSync(dir, { recursive: true })
|
||
const file = settingsFilePath(ctx)
|
||
const tmp = `${file}.tmp`
|
||
writeFileSync(tmp, `${JSON.stringify(blob, null, 2)}\n`)
|
||
renameSync(tmp, file)
|
||
}
|
||
|
||
function defaults(ctx: ExtensionContext): Settings {
|
||
return {
|
||
webhookPort: DEFAULT_WEBHOOK_PORT,
|
||
webhookSecret: '',
|
||
brainstormPrompt: readDefaultPrompt(ctx.extensionPath, 'brainstorm'),
|
||
implementPlanPrompt: readDefaultPrompt(ctx.extensionPath, 'implement-plan'),
|
||
autoReview: true,
|
||
reviewPrompt: readDefaultPrompt(ctx.extensionPath, 'review'),
|
||
testPrompt: readDefaultPrompt(ctx.extensionPath, 'test'),
|
||
prototypePrompt: readDefaultPrompt(ctx.extensionPath, 'prototype'),
|
||
devBranch: 'main',
|
||
releaseBranchPrefix: 'release/',
|
||
jenkinsJob: '',
|
||
jenkinsProdHost: '正式服',
|
||
worktreeDirectory: '~/Sources/worktree/$project_name/$feature_name',
|
||
worktreeOpenWith: 'vscode',
|
||
pycharmCommand: 'pycharm',
|
||
worktreePostCreateScript: '',
|
||
worktreePreRemoveScript: '',
|
||
implTabPreCreateScript: '',
|
||
implTabPostCloseScript: '',
|
||
splitNewTerminals: true,
|
||
youtrackBaseUrl: '',
|
||
youtrackProjectShortName: '',
|
||
youtrackCloseCommand: '',
|
||
defaultBrainstormProfilePath: '',
|
||
defaultImplementProfilePath: '',
|
||
defaultTestProfilePath: '',
|
||
defaultPrototypeProfilePath: '',
|
||
defaultAskProfilePath: '',
|
||
defaultRewriteProfilePath: '',
|
||
conflictResolutionProfilePath: '',
|
||
commitProfilePath: '',
|
||
prDiffSummaryProfilePath: '',
|
||
codexModel: '',
|
||
codexReasoningEffort: '',
|
||
profilesDirectory: '',
|
||
handoffOssEndpoint: '',
|
||
handoffOssBucket: '',
|
||
}
|
||
}
|
||
|
||
export function getSettings(ctx: ExtensionContext): Settings {
|
||
const stored = readStoredSettings(ctx)
|
||
const base = defaults(ctx)
|
||
|
||
const webhookPort = typeof stored.webhookPort === 'number' && Number.isInteger(stored.webhookPort)
|
||
&& stored.webhookPort >= 1 && stored.webhookPort <= 65535
|
||
? stored.webhookPort
|
||
: base.webhookPort
|
||
// webhook secret:'' 有意义(= 不校验),只认字符串类型。
|
||
const webhookSecret = typeof stored.webhookSecret === 'string'
|
||
? stored.webhookSecret
|
||
: base.webhookSecret
|
||
|
||
const brainstormPrompt = typeof stored.brainstormPrompt === 'string' && stored.brainstormPrompt.length > 0
|
||
? stored.brainstormPrompt
|
||
: base.brainstormPrompt
|
||
const implementPlanPrompt = typeof stored.implementPlanPrompt === 'string' && stored.implementPlanPrompt.length > 0
|
||
? stored.implementPlanPrompt
|
||
: base.implementPlanPrompt
|
||
const autoReview = typeof stored.autoReview === 'boolean' ? stored.autoReview : base.autoReview
|
||
const reviewPrompt = typeof stored.reviewPrompt === 'string' && stored.reviewPrompt.length > 0
|
||
? stored.reviewPrompt
|
||
: base.reviewPrompt
|
||
const testPrompt = typeof stored.testPrompt === 'string' && stored.testPrompt.length > 0
|
||
? stored.testPrompt
|
||
: base.testPrompt
|
||
const prototypePrompt = typeof stored.prototypePrompt === 'string' && stored.prototypePrompt.length > 0
|
||
? stored.prototypePrompt
|
||
: base.prototypePrompt
|
||
// devBranch always has a usable value — fall back to 'main' if missing/blank
|
||
// so we never end up trying to fetch the empty string.
|
||
const devBranch = typeof stored.devBranch === 'string' && stored.devBranch.length > 0
|
||
? stored.devBranch
|
||
: base.devBranch
|
||
// 发布前缀读出时归一化(空 → 默认、补尾 `/`),下游拼分支名不用再防御。
|
||
const releaseBranchPrefix = normalizeReleasePrefix(
|
||
typeof stored.releaseBranchPrefix === 'string' ? stored.releaseBranchPrefix : base.releaseBranchPrefix,
|
||
)
|
||
// jenkinsJob:'' 有意义(= 不提供部署动作),只认字符串类型。
|
||
const jenkinsJob = typeof stored.jenkinsJob === 'string'
|
||
? stored.jenkinsJob.trim()
|
||
: base.jenkinsJob
|
||
const jenkinsProdHost = typeof stored.jenkinsProdHost === 'string' && stored.jenkinsProdHost.trim().length > 0
|
||
? stored.jenkinsProdHost.trim()
|
||
: base.jenkinsProdHost
|
||
// worktree dir template: empty/blank means "use the default template", so
|
||
// fall back to base (unlike the hook paths below, where '' is meaningful).
|
||
const worktreeDirectory = typeof stored.worktreeDirectory === 'string' && stored.worktreeDirectory.length > 0
|
||
? stored.worktreeDirectory
|
||
: base.worktreeDirectory
|
||
// worktreeOpenWith is an enum — only the two known values are valid; anything
|
||
// else (legacy installs, garbage) collapses to the default.
|
||
const worktreeOpenWith = stored.worktreeOpenWith === 'pycharm' || stored.worktreeOpenWith === 'vscode'
|
||
? stored.worktreeOpenWith
|
||
: base.worktreeOpenWith
|
||
// pycharm launcher command: blank means "use the default `pycharm`", so fall
|
||
// back to base rather than preserving an empty string.
|
||
const pycharmCommand = typeof stored.pycharmCommand === 'string' && stored.pycharmCommand.length > 0
|
||
? stored.pycharmCommand
|
||
: base.pycharmCommand
|
||
// worktree hook script paths: '' is meaningful (= "use default
|
||
// .spx/worktree-*.sh"), so don't coerce. Non-string legacy values fall
|
||
// back to ''. The hook runner resolves '' to the default at execution
|
||
// time and silently skips when the file doesn't exist.
|
||
const worktreePostCreateScript = typeof stored.worktreePostCreateScript === 'string'
|
||
? stored.worktreePostCreateScript
|
||
: base.worktreePostCreateScript
|
||
const worktreePreRemoveScript = typeof stored.worktreePreRemoveScript === 'string'
|
||
? stored.worktreePreRemoveScript
|
||
: base.worktreePreRemoveScript
|
||
// impl-tab hook script paths: same '' = default semantics as worktree hooks.
|
||
const implTabPreCreateScript = typeof stored.implTabPreCreateScript === 'string'
|
||
? stored.implTabPreCreateScript
|
||
: base.implTabPreCreateScript
|
||
const implTabPostCloseScript = typeof stored.implTabPostCloseScript === 'string'
|
||
? stored.implTabPostCloseScript
|
||
: base.implTabPostCloseScript
|
||
const splitNewTerminals = typeof stored.splitNewTerminals === 'boolean'
|
||
? stored.splitNewTerminals
|
||
: base.splitNewTerminals
|
||
// YouTrack config: '' is meaningful (= "source disabled" / "auto-detect close
|
||
// command"), so don't coerce. Non-string legacy values collapse to ''.
|
||
const youtrackBaseUrl = typeof stored.youtrackBaseUrl === 'string'
|
||
? stored.youtrackBaseUrl
|
||
: base.youtrackBaseUrl
|
||
const youtrackProjectShortName = typeof stored.youtrackProjectShortName === 'string'
|
||
? stored.youtrackProjectShortName
|
||
: base.youtrackProjectShortName
|
||
const youtrackCloseCommand = typeof stored.youtrackCloseCommand === 'string'
|
||
? stored.youtrackCloseCommand
|
||
: base.youtrackCloseCommand
|
||
// 四个会话默认 profile:'' 有意义(= 落到 DEFAULT_PROFILE_PATH),不强制回退。
|
||
const defaultBrainstormProfilePath = typeof stored.defaultBrainstormProfilePath === 'string'
|
||
? stored.defaultBrainstormProfilePath
|
||
: base.defaultBrainstormProfilePath
|
||
const defaultImplementProfilePath = typeof stored.defaultImplementProfilePath === 'string'
|
||
? stored.defaultImplementProfilePath
|
||
: base.defaultImplementProfilePath
|
||
const defaultTestProfilePath = typeof stored.defaultTestProfilePath === 'string'
|
||
? stored.defaultTestProfilePath
|
||
: base.defaultTestProfilePath
|
||
const defaultPrototypeProfilePath = typeof stored.defaultPrototypeProfilePath === 'string'
|
||
? stored.defaultPrototypeProfilePath
|
||
: base.defaultPrototypeProfilePath
|
||
const defaultAskProfilePath = typeof stored.defaultAskProfilePath === 'string'
|
||
? stored.defaultAskProfilePath.trim()
|
||
: base.defaultAskProfilePath
|
||
const defaultRewriteProfilePath = typeof stored.defaultRewriteProfilePath === 'string'
|
||
? stored.defaultRewriteProfilePath.trim()
|
||
: base.defaultRewriteProfilePath
|
||
// conflict-resolution profile: '' is meaningful (= DEFAULT_PROFILE_PATH at
|
||
// launch time), so don't coerce. Non-string legacy values collapse to ''.
|
||
const conflictResolutionProfilePath = typeof stored.conflictResolutionProfilePath === 'string'
|
||
? stored.conflictResolutionProfilePath
|
||
: base.conflictResolutionProfilePath
|
||
// 提交 profile:'' 有意义(= 未配置 → 提交失败)。缺 key 时先给 '';
|
||
// deepseek seed 在 getEffectiveCommitProfilePath 里做(需异步 list profiles)。
|
||
const commitProfilePath = typeof stored.commitProfilePath === 'string'
|
||
? stored.commitProfilePath
|
||
: base.commitProfilePath
|
||
const prDiffSummaryProfilePath = typeof stored.prDiffSummaryProfilePath === 'string'
|
||
? stored.prDiffSummaryProfilePath
|
||
: base.prDiffSummaryProfilePath
|
||
// codex 模型 / 思考级别:'' 有意义(= 用 codex 默认),不强制回退。
|
||
const codexModel = typeof stored.codexModel === 'string'
|
||
? stored.codexModel
|
||
: base.codexModel
|
||
const codexReasoningEffort = typeof stored.codexReasoningEffort === 'string'
|
||
? stored.codexReasoningEffort
|
||
: base.codexReasoningEffort
|
||
// profiles 目录:'' 有意义(= 自动探测),不强制回退。
|
||
const profilesDirectory = typeof stored.profilesDirectory === 'string'
|
||
? stored.profilesDirectory
|
||
: base.profilesDirectory
|
||
// 移交存储:'' 有意义(= 没配置),只认字符串类型。
|
||
const handoffOssEndpoint = typeof stored.handoffOssEndpoint === 'string'
|
||
? stored.handoffOssEndpoint.trim()
|
||
: base.handoffOssEndpoint
|
||
const handoffOssBucket = typeof stored.handoffOssBucket === 'string'
|
||
? stored.handoffOssBucket.trim()
|
||
: base.handoffOssBucket
|
||
|
||
return {
|
||
webhookPort,
|
||
webhookSecret,
|
||
brainstormPrompt,
|
||
implementPlanPrompt,
|
||
autoReview,
|
||
reviewPrompt,
|
||
testPrompt,
|
||
prototypePrompt,
|
||
devBranch,
|
||
releaseBranchPrefix,
|
||
jenkinsJob,
|
||
jenkinsProdHost,
|
||
worktreeDirectory,
|
||
worktreeOpenWith,
|
||
pycharmCommand,
|
||
worktreePostCreateScript,
|
||
worktreePreRemoveScript,
|
||
implTabPreCreateScript,
|
||
implTabPostCloseScript,
|
||
splitNewTerminals,
|
||
youtrackBaseUrl,
|
||
youtrackProjectShortName,
|
||
youtrackCloseCommand,
|
||
defaultBrainstormProfilePath,
|
||
defaultImplementProfilePath,
|
||
defaultTestProfilePath,
|
||
defaultPrototypeProfilePath,
|
||
defaultAskProfilePath,
|
||
defaultRewriteProfilePath,
|
||
conflictResolutionProfilePath,
|
||
commitProfilePath,
|
||
prDiffSummaryProfilePath,
|
||
codexModel,
|
||
codexReasoningEffort,
|
||
profilesDirectory,
|
||
handoffOssEndpoint,
|
||
handoffOssBucket,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 读取工具栏「提交代码」有效的 profile path。
|
||
* ① stored 已有 key(含用户清空的 '')→ 原样返回
|
||
* ② key 从未出现 → list profiles 后 seed deepseek-v4-pro(启动时 persistSeededProfiles 会写盘)
|
||
*/
|
||
export async function getEffectiveCommitProfilePath(ctx: ExtensionContext): Promise<string> {
|
||
const stored = readStoredSettings(ctx)
|
||
if (typeof stored.commitProfilePath === 'string')
|
||
return stored.commitProfilePath
|
||
const profiles = await listClaudeProfiles()
|
||
return seedCommitProfilePath(undefined, profiles)
|
||
}
|
||
|
||
/**
|
||
* 读取改动说明有效的 profile path。规则与提交代码相同:已有 key 原样返回,缺 key 才 seed。
|
||
*/
|
||
export async function getEffectivePrDiffSummaryProfilePath(ctx: ExtensionContext): Promise<string> {
|
||
const stored = readStoredSettings(ctx)
|
||
if (typeof stored.prDiffSummaryProfilePath === 'string')
|
||
return stored.prDiffSummaryProfilePath
|
||
const profiles = await listClaudeProfiles()
|
||
return seedCommitProfilePath(undefined, profiles)
|
||
}
|
||
|
||
const settingsChanges = new EventEmitter()
|
||
|
||
export function onDidChangeSettings(ctx: ExtensionContext, listener: () => void): Disposable {
|
||
const key = settingsFilePath(ctx)
|
||
settingsChanges.on(key, listener)
|
||
return {
|
||
dispose: () => {
|
||
settingsChanges.off(key, listener)
|
||
},
|
||
}
|
||
}
|
||
|
||
export async function saveSettings(ctx: ExtensionContext, next: Partial<Settings>): Promise<void> {
|
||
const merged: Partial<Settings> = { ...readStoredSettings(ctx), ...next }
|
||
writeStoredSettings(ctx, merged)
|
||
if (next.profilesDirectory !== undefined)
|
||
setProfilesDirOverride(next.profilesDirectory.trim() || undefined)
|
||
settingsChanges.emit(settingsFilePath(ctx))
|
||
}
|
||
|
||
/**
|
||
* 一次性把存量自定义提示词迁到 Handlebars:单花括号改写成双花括号,续聊 /
|
||
* 原型段落并进头脑风暴模板。写盘前先把 settings.json 备份一份。
|
||
*
|
||
* 返回日志条目而不是自己写 logger——logger 依赖 vscode 运行时,store 要能在
|
||
* 单测里直接 import。
|
||
*/
|
||
export function migratePromptTemplates(ctx: ExtensionContext): MigrationNote[] {
|
||
const stored = readStoredSettings(ctx) as Record<string, unknown>
|
||
const planned = planPromptMigration(
|
||
stored,
|
||
readDefaultPrompt(ctx.extensionPath, 'brainstorm'),
|
||
)
|
||
const review = planReviewPromptMigration(
|
||
planned.blob?.reviewPrompt ?? stored.reviewPrompt,
|
||
PREVIOUS_DEFAULT_REVIEW_PROMPT,
|
||
readDefaultPrompt(ctx.extensionPath, 'review'),
|
||
)
|
||
const blob = review
|
||
? { ...(planned.blob ?? stored), reviewPrompt: review.prompt }
|
||
: planned.blob
|
||
if (!blob)
|
||
return []
|
||
const out: MigrationNote[] = []
|
||
const file = settingsFilePath(ctx)
|
||
if (existsSync(file)) {
|
||
const backup = `${file}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`
|
||
try {
|
||
copyFileSync(file, backup)
|
||
out.push({ level: 'info', message: `提示词迁移前备份设置:${backup}` })
|
||
}
|
||
catch (err) {
|
||
return [{
|
||
level: 'warn',
|
||
message: `settings.json 备份失败,本次不迁移提示词:${err instanceof Error ? err.message : String(err)}`,
|
||
}]
|
||
}
|
||
}
|
||
writeStoredSettings(ctx, blob as Partial<Settings>)
|
||
out.push(...planned.notes)
|
||
if (review)
|
||
out.push(...review.notes)
|
||
if (planned.blob)
|
||
out.push({ level: 'info', message: '提示词模板已迁到 Handlebars' })
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* 启动时:把旧 memento 迁到文件;commit / 改动说明 profile 缺 key 且能 seed 则写盘。
|
||
* 空串视为用户主动清空,不覆盖。
|
||
*/
|
||
export async function persistSeededProfiles(ctx: ExtensionContext): Promise<void> {
|
||
const stored = readStoredSettings(ctx)
|
||
const fileExists = existsSync(settingsFilePath(ctx))
|
||
const profiles = await listClaudeProfiles()
|
||
const patch: Partial<Settings> = {}
|
||
if (typeof stored.commitProfilePath !== 'string') {
|
||
const seeded = seedCommitProfilePath(undefined, profiles)
|
||
if (seeded)
|
||
patch.commitProfilePath = seeded
|
||
}
|
||
if (typeof stored.prDiffSummaryProfilePath !== 'string') {
|
||
const seeded = seedCommitProfilePath(undefined, profiles)
|
||
if (seeded)
|
||
patch.prDiffSummaryProfilePath = seeded
|
||
}
|
||
const shouldMigrate = !fileExists && Object.keys(stored).length > 0
|
||
if (shouldMigrate || Object.keys(patch).length > 0)
|
||
await saveSettings(ctx, patch)
|
||
}
|