diff --git a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml new file mode 100644 index 0000000000..b27ba457af --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-23-personal-staging-maintenance-skills.md: a7ccc5b1e0f13e880c58a93d2e4c2cd4f06e2a93 +2026-07-23-personal-staging-maintenance-skills.zh.md: db1595c83da0ad93e9ba9055b5a3d7fe7cfe1706 diff --git a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.md b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.md new file mode 100644 index 0000000000..a7ccc5b1e0 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.md @@ -0,0 +1,35 @@ +# Agent Note: Personal staging maintenance skills + +Status: implemented + +English | [中文](2026-07-23-personal-staging-maintenance-skills.zh.md) + +## Problem + +Personal dsh customizations need a repeatable way to locate the installed source, isolate task work, serialize integration, and incorporate upstream changes without rewriting the checkout used by running sessions. User-local instructions solve this for one installation but cannot guide other users or remain synchronized with repository installer behavior. + +## Decision + +The repository distributes [`dsh-customize`](../../../../skills/dsh-customize/SKILL.md), [`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md), and [`dsh-upstream-customization`](../../../../skills/dsh-upstream-customization/SKILL.md) from its root `skills/` directory. Their descriptions name both the operation and user requests that select it. The shipped TUI supplies that directory to the local skill provider at startup, below project and user roots in discovery priority. The workflows derive the active checkout and staging branch from the installed launcher rather than a user-specific path or branch name, defer to repository-local instructions, require task worktrees, and serialize staging mutations with the staging worktree's established `.agents/merge.lock`. + +Before rebasing, an upgrade inspects the Git log and commit ranges to identify incoming upstream changes, personal commits, duplicates, and likely conflicts. It drops customizations already supplied upstream; when only a documentary local diff remains for such a customization, it also drops that account unless it adds an independently useful current contract absent upstream. Each attempt uses one UTC basic timestamp for its independent `dsh-staging-` sibling clone, local `dsh-upgrade/prepare-` branch, new `dsh-staging/` branch, private upstream and recovery refs, and launcher backup. The sibling name does not derive from the current directory name, and collisions fail rather than acquiring ad hoc suffixes. The workflow derives the current DSH process source from the process command and runtime environment rather than the shell working directory, then treats the repository and checkout behind the installed launcher as immutable except for holding its existing merge lock. + +After validation in the independent clone, the workflow creates and verifies the timestamped staging branch, then atomically moves the launcher once from the unchanged old staging checkout to the new staging checkout. The launcher never targets a preparation, feature, review, publication, or detached checkout. Failure before cutover leaves the installed checkout and launcher unchanged; failure after cutover restores and verifies the launcher backup. The old staging checkout, its branch, the recovery ref, and the launcher backup remain available until a restarted process proves that DSH runs from the new staging branch and the user explicitly approves rollback cleanup. + +`dsh-upstream-customization` owns upstream publication independently from local maintenance and upgrades. It recommends bug fixes, additive non-conflicting plugin features, and visual improvements; intrusive changes require maintainer approval first. At the end of an upgrade, the agent classifies remaining customizations, explains their upstream value, recommends whether to propose each one, and asks which named candidate the user wants to upstream. Only that selection loads the publication workflow; each feature still requires explicit approval before a push or draft PR. Approved changes start from current upstream `master` without unrelated personal commits. Draft PRs for TUI features preferably include a screenshot from the assembled application after credentials and personal data are removed. `dsh-customize` requires interactive TUI behavior to be exercised in a dedicated tmux session before integration. + +## Alternatives considered + +**Keep the workflows user-scoped.** This preserves personal flexibility but prevents other users from discovering the same safety rules and lets the workflow drift from the installer shipped by the repository. + +**Rebase the active staging checkout in place.** This is simpler but changes many files during preparation, can disrupt new dsh launches, and cannot provide atomic publication or an unchanged rollback checkout. + +**Update the existing staging checkout after moving the launcher elsewhere.** This retains one staging path but requires a mid-upgrade launcher target that is not a staging branch and still rewrites a checkout that may host a running process. + +**Lock only the final branch switch.** This shortens lock duration but permits a customization merge against the old base while the rebase is being prepared, invalidating the prepared history. + +**Open one upstream PR for all personal changes.** This reduces branch management but publishes unrelated customizations and removes the user's per-feature approval boundary. + +## Consequences + +Upgrade preparation holds the installed staging merge lock while dependencies and checks run, so local customization integration waits for a consistent result. One upgrade creates an independent timestamped clone and staging branch, performs one atomic launcher cutover, and requires one restart afterward; it never writes into the repository or checkout behind the launcher except to hold its existing lock. Each workflow records preconditions, repeats them before mutation, inspects state after interrupted mutations, restores the launcher backup on cutover failure, reruns failed checks after correction, and reports final state. The old staging checkout remains rollback storage until explicit user-approved cleanup. Checked-in evaluations cover selection, process-source protection, unsafe repository states, rollback, and publication authorization; repository documentation checks validate skill links and formatting, while technical review remains responsible for Git and filesystem correctness. diff --git a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md new file mode 100644 index 0000000000..db1595c83d --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 个人集成分支维护 skill(技能) + +Status: implemented + +[English](2026-07-23-personal-staging-maintenance-skills.md) | 中文 + +## 问题 + +个人 dsh 定制需要一套可重复执行的方法,用于定位已安装的源码、隔离各项任务的修改、串行集成变更,并在不改写运行中会话所用检出的前提下合入上游变更。用户本地指令能解决某一套安装中的问题,却无法指导其他用户,也无法持续与仓库安装脚本的行为保持同步。 + +## 决策 + +仓库从其根 `skills/` 目录分发 [`dsh-customize`](../../../../skills/dsh-customize/SKILL.md)、[`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md) 和 [`dsh-upstream-customization`](../../../../skills/dsh-upstream-customization/SKILL.md)。它们的描述同时说明操作内容和选择该 skill 的用户请求。分发的 TUI 在启动时将该目录提供给本地 skill 提供方,在发现优先级上位于项目根目录和用户根目录之后。这些 skill 根据已安装的启动器而非个人路径或分支名称定位当前生效的检出和集成分支,遵从仓库内指令,要求使用任务 worktree,并利用集成分支所在 worktree 的既有 `.agents/merge.lock`,串行执行每一次个人集成分支修改。 + +升级流程在变基前检查 Git 日志和提交范围,以识别将进入升级的上游变更、个人提交、重复内容和可能发生冲突的区域。它会丢弃上游已经提供的定制;如果这类定制在本地只剩说明性差异,也会一并丢弃,除非该说明包含上游缺失且可独立使用的当前契约。每次升级尝试使用同一个 UTC 基本格式时间戳,用于其独立的 `dsh-staging-` 同级克隆、本地 `dsh-upgrade/prepare-` 分支、新的 `dsh-staging/` 分支、私有的上游引用与恢复引用,以及启动器备份。同级克隆的名称不派生自当前目录名,名称冲突会直接失败,而不是追加临时后缀。流程根据进程命令和运行时环境而非 shell 工作目录推导当前 DSH 进程的源码位置,随后将已安装启动器所指向的仓库和检出视为不可变,唯一例外是持有其既有合并锁。 + +在独立克隆中验证通过后,工作流会创建并验证带时间戳的集成分支,然后以原子方式将启动器从保持不变的旧集成分支检出一次性切换到新集成分支检出。启动器绝不会指向准备、功能、评审、发布或处于分离状态的检出。切换前的失败会让已安装的检出和启动器保持不变;切换后的失败则恢复并验证启动器备份。旧的集成分支检出、其分支、恢复引用和启动器备份会一直保留,直到重启后的进程证明 DSH 运行于新的集成分支,且用户明确批准回滚清理为止。 + +`dsh-upstream-customization` 独立于本地维护和升级,负责向上游发布。它推荐 bug 修复、附加式且不冲突的插件功能,以及视觉改进;侵入式变更需先取得维护者批准。在升级结束时,agent 会对剩余定制进行分类、说明其上游价值、建议是否提交,并询问用户希望向上游贡献哪个具名候选项。只有用户做出选择后才会加载发布工作流;每项功能在推送或创建草稿 PR(Pull Request)前仍必须得到明确批准。获批的变更均以当前上游 `master` 为起点,不带入无关的个人提交。TUI 功能的草稿 PR 建议在移除凭证与个人数据后,附上完整应用的截图。`dsh-customize` 要求在集成前于专用 tmux 会话中检验交互式 TUI 行为。 + +## 备选方案 + +**将这些工作流限定在用户本地。** 这样可以保留个人使用的灵活性,但其他用户无法发现同一套安全规则,工作流也可能逐渐偏离仓库分发的安装脚本行为。 + +**在当前集成分支检出中原地变基。** 此方案更简单,但准备期间会修改大量文件,可能干扰新的 dsh 启动,也无法实现原子发布或提供一份保持不变的回滚检出。 + +**在将启动器迁往别处后更新现有的集成分支检出。** 此方案可以保留单一的集成分支路径,却要求在升级中途让启动器指向一个并非集成分支的目标,且仍会改写可能承载运行中进程的检出。 + +**只在最终切换分支时加锁。** 这样可以缩短持锁时间,却允许写入方在变基准备期间继续基于旧基线合并定制变更,导致准备好的历史失效。 + +**用一个上游 PR 发布所有个人变更。** 这会减少分支管理工作,却会发布无关的定制,并取消用户按功能逐项批准的边界。 + +## 影响 + +升级准备流程在安装依赖和运行检查期间持有已安装集成分支的合并锁,因此本地定制的集成必须等待一致的结果。一次升级会创建独立的带时间戳的克隆和集成分支,执行一次原子的启动器切换,并在切换后要求重启一次;除持有其既有锁之外,升级绝不会写入启动器所指向的仓库或检出。各工作流会记录前置条件、在修改前重复检查、在修改被中断后检查状态、在切换失败时恢复启动器备份、修复后重新运行失败的检查,并报告最终状态。旧的集成分支检出会作为回滚存储一直保留,直到用户明确批准清理为止。仓库内评估覆盖 skill 选择、进程源码保护、不安全的仓库状态、回滚和发布授权;仓库文档检查会验证 skill 的链接和格式,Git 与文件系统操作的正确性仍由技术评审负责。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index fb96ff101a..c45cb73de3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1165,10 +1165,12 @@ export interface Config { agentsHome?: string /** Additional skill roots scanned after project roots and before user roots. */ customSkillDirs?: string[] + /** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */ + bundledSkillDir?: string } ``` -Source: [`packages/skill/skill-local/src/index.ts:40`](../packages/skill/skill-local/src/index.ts) +Source: [`packages/skill/skill-local/src/index.ts:41`](../packages/skill/skill-local/src/index.ts) ## `@deepseek-ai/dsh-spill-local` @@ -1724,7 +1726,7 @@ Source: [`packages/core/tools/src/index.ts:566`](../packages/core/tools/src/inde ## `@deepseek-ai/dsh-tui` -Requires: `agents` · `sessions` · `commands` · `userInteraction` · `tools` · `llm` · `systemPrompt` · `tokenMeter` +Requires: `agents` · `sessions` · `commands` · `userInteraction` · `tools` · `llm` · `systemPrompt` · `tokenMeter` · `tuiPrompt` ```ts config-catalog /** Serializable plugin configuration. */ @@ -1770,21 +1772,30 @@ export interface TuiConfig { fileSearchExcludedDirectories?: string[] /** Show the terminal's hardware cursor at the pi editor's IME marker. */ showHardwareCursor?: boolean - /** Apply the built-in ANSI color palette. */ - color?: boolean - /** - * Paint the startup banner's product name in the DeepSeek brand gradient - * using 24-bit truecolor. Requires {@link TuiConfig.color}; falls back to the - * flat accent color when either is off. Unset auto-detects `COLORTERM` at the - * process boundary, so most deployments leave it unset. - */ - truecolor?: boolean + /** Color and prompt-template settings. */ + theme?: TuiThemeConfig /** Terminal window title while the UI is mounted; a logged session title prefixes it. */ title?: string } + +/** Theme and prompt-template settings for the pi-tui terminal mode. */ +export interface TuiThemeConfig { + /** Apply the built-in ANSI color palette. */ + color?: boolean + /** Paint the startup banner with the 24-bit DeepSeek brand gradient. */ + truecolor?: boolean + /** Left-aligned template on the row above the editor. */ + leftPrompt?: string + /** Right-aligned template on the row above the editor. */ + rightPrompt?: string + /** Template used as the editor's first-line prefix. */ + inputPrompt?: string + /** Static placeholder shown in an empty editor while the agent is running. */ + inputPlaceholder?: string +} ``` -Source: [`packages/ui/tui/src/index.ts:273`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:302`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/docs/core-data-structures/skills.i18n.yaml b/docs/core-data-structures/skills.i18n.yaml index 2f67387224..4d07a8d8b1 100644 --- a/docs/core-data-structures/skills.i18n.yaml +++ b/docs/core-data-structures/skills.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -skills.md: fc9599713dcfddec9719ed746b66ea0217b86cf5 -skills.zh.md: 0eb4c0aa69ed56117c7508358c0d47e3b3e95fcb +skills.md: 0cd0bc56f50e49e4cfde0e41394b42bc93399f69 +skills.zh.md: 732cdffc83b3ba13ecb498f5eafed511da71df36 diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index fc9599713d..0cd0bc56f5 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -47,6 +47,7 @@ The shipped local provider scans roots in rank order: | 300 | `custom` | `Config.customSkillDirs` | | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/skills` | +| 600 | `bundled` | `Config.bundledSkillDir` when configured | The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child. The local provider does not ship built-in system skills; deployments supply built-ins through another provider. @@ -56,7 +57,7 @@ Skill names are kebab-case (`^[a-z0-9]+(?:-[a-z0-9]+)*$`). The local provider ac ```ts type-equiv /** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */ -type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {}) +type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | 'bundled' | (string & {}) ``` ## Summaries, candidates, and complete definitions @@ -142,7 +143,7 @@ interface SkillLookupOptions { } ``` -The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, and `customSkillDirs`). The consumer owns its catalog description bound. +The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, `customSkillDirs`, and optional `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`). The consumer owns its catalog description bound. ```ts type-equiv /** Skill registry configuration. */ diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index 0eb4c0aa69..732cdffc83 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -47,6 +47,7 @@ interface SkillProvider { | 300 | `custom` | `Config.customSkillDirs` | | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/skills` | +| 600 | `bundled` | 配置了 `Config.bundledSkillDir` 时使用该目录 | 项目根目录为包含 `.git` 的最近祖先目录;找不到时使用当前 cwd。当 `ctx.fs` 可用时,git-root 向上查找通过文件系统服务探测 `.git`,使远程或沙箱工作区不会回退到宿主文件系统边界。用户 DSH 根目录会跳过其 `.system` 子目录。本地提供方不附带内置系统 skill;部署方通过另一个提供方提供内置 skill。 @@ -56,7 +57,7 @@ skill 名称为 kebab-case(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)。本地提供方 ```ts type-equiv /** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */ -type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {}) +type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | 'bundled' | (string & {}) ``` ## 摘要、候选项与完整定义 @@ -142,7 +143,7 @@ interface SkillLookupOptions { } ``` -注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome` 与 `customSkillDirs`)。消费方拥有其目录描述上限。 +注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome`、`customSkillDirs`,以及可选的 `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`)。消费方拥有其目录描述上限。 ```ts type-equiv /** Skill registry configuration. */ diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index ca1fec1dc5..a47d6e337b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2205,7 +2205,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SkillSource', - declaration: 'export type SkillSource = \'project-dsh\' | \'project-agents\' | \'runtime\' | \'user-dsh\' | \'user-agents\' | \'custom\' | (string & {});', + declaration: 'export type SkillSource = \'project-dsh\' | \'project-agents\' | \'runtime\' | \'user-dsh\' | \'user-agents\' | \'custom\' | \'bundled\' | (string & {});', }, { name: 'SkillSummary', diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 2c5e480e2a..02478a7244 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -32,6 +32,7 @@ const PROJECT_AGENTS_RANK = 200 const CUSTOM_RANK = 300 const USER_DSH_RANK = 400 const USER_AGENTS_RANK = 500 +const BUNDLED_RANK = 600 export const name = 'skill-local' export const inject = ['skills'] @@ -44,12 +45,15 @@ export interface Config { agentsHome?: string /** Additional skill roots scanned after project roots and before user roots. */ customSkillDirs?: string[] + /** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */ + bundledSkillDir?: string } export const Config: Schema = z.object({ dshHome: z.string(), agentsHome: z.string(), customSkillDirs: z.array(z.string()).default([]), + bundledSkillDir: z.string(), }) interface SkillRoot { @@ -57,6 +61,7 @@ interface SkillRoot { source: SkillSource rank: number skipSystem?: boolean + trustedHost?: boolean } interface SkillRootEntry { @@ -91,11 +96,14 @@ export class LocalSkillProvider implements SkillProvider { private readonly dshHome: string private readonly agentsHome: string private readonly customSkillDirs: string[] + private readonly bundledSkillDir: string | undefined constructor(private readonly ctx: Context, config: Config = {}) { this.dshHome = resolveDshHome(config.dshHome) this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents')) this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root)) + const bundledSkillDir = config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR + this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir) } /** @@ -122,7 +130,7 @@ export class LocalSkillProvider implements SkillProvider { */ async get(candidate: SkillCandidate, options: SkillLookupOptions): Promise { const locator = candidate.locator as LocalLocator - const parsed = await parseSkillFile(locator.path, this.ctx, options.signal) + const parsed = await parseSkillFile(locator.path, this.ctx, options.signal, candidate.source === 'bundled') if (parsed === undefined) return undefined return { name: parsed.name, @@ -151,6 +159,9 @@ export class LocalSkillProvider implements SkillProvider { ...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK })), { path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true }, { path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK }, + ...this.bundledSkillDir === undefined + ? [] + : [{ path: this.bundledSkillDir, source: 'bundled' as const, rank: BUNDLED_RANK, trustedHost: true }], ) return roots } @@ -167,7 +178,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise { const fs = optionalFileSystem(ctx) - if (fs !== undefined) return await listSkillRootEntriesFromFileSystem(root, fs) + if (fs !== undefined && root.trustedHost !== true) return await listSkillRootEntriesFromFileSystem(root, fs) return await listSkillRootEntriesFromNode(root, ctx) } @@ -225,8 +236,8 @@ async function listSkillRootEntriesFromNode(root: SkillRoot, ctx: Context): Prom return result } -async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal): Promise { - const raw = await readSkillText(ctx, path, signal) +async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal, trustedHost = false): Promise { + const raw = await readSkillText(ctx, path, signal, trustedHost) signal?.throwIfAborted() if (raw === undefined) { return undefined @@ -266,10 +277,10 @@ function optionalFileSystem(ctx: Context): FileSystem | undefined { return ctx.get('fs') } -async function readSkillText(ctx: Context, path: string, signal?: AbortSignal): Promise { +async function readSkillText(ctx: Context, path: string, signal?: AbortSignal, trustedHost = false): Promise { signal?.throwIfAborted() const fs = optionalFileSystem(ctx) - if (fs !== undefined) { + if (fs !== undefined && !trustedHost) { return await readSkillTextFromFileSystem(ctx, fs, path, signal) } try { diff --git a/packages/skill/skill-local/tests/skill-local.spec.ts b/packages/skill/skill-local/tests/skill-local.spec.ts index 0c7d473b13..fc3f83f955 100644 --- a/packages/skill/skill-local/tests/skill-local.spec.ts +++ b/packages/skill/skill-local/tests/skill-local.spec.ts @@ -149,15 +149,23 @@ describe('LocalSkillProvider', () => { await writeSkill(custom, 'custom-only', 'custom only') await writeSkill(join(home, '.dsh/skills/.system'), 'hidden-system', 'hidden system') - const ctx = await setupLocal(home, { customSkillDirs: [custom] }) + const bundled = await tempDir('skill-bundled') + await writeSkill(bundled, 'bundled-only', 'bundled skill') + await writeSkill(bundled, 'same', 'bundled skill') + const ctx = await setupLocal(home, { customSkillDirs: [custom], bundledSkillDir: bundled }) const skills = await ctx.skills.list({ cwd: join(project, 'src') }) - expect(skills.map(skill => [skill.name, skill.description])).toEqual([ - ['custom-only', 'custom only'], - ['same', 'project dsh skill'], + expect(skills.map(skill => skill.name)).toEqual([ + 'bundled-only', + 'custom-only', + 'same', ]) + expect(skills.find(skill => skill.name === 'custom-only')?.description).toBe('custom only') + expect(skills.find(skill => skill.name === 'same')?.description).toBe('project dsh skill') expect(skills.find(skill => skill.name === 'same')?.source).toBe('project-dsh') expect(skills.find(skill => skill.name === 'hidden-system')).toBeUndefined() + expect(skills.find(skill => skill.name === 'bundled-only')).toMatchObject({ source: 'bundled' }) + expect((await ctx.skills.get('bundled-only'))?.content).toBe('Use the skill.') const noGit = await tempDir('skill-no-git') await writeSkill(join(noGit, '.dsh/skills'), 'fallback-root', 'Fallback root') @@ -335,6 +343,20 @@ describe('LocalSkillProvider', () => { ]) expect(fs.listDirCalls).toBeGreaterThan(0) expect(await ctx.skills.get('binary-skill')).toBeUndefined() + + const bundled = await tempDir('skill-backend-bundled') + await writeSkill(bundled, 'bundled-host', 'Bundled host skill') + const bundledCtx = new Context() + await bundledCtx.plugin(TestFileSystem) + const bundledFs = bundledCtx.fs as TestFileSystem + bundledFs.failResolvePaths.add(bundled) + await bundledCtx.plugin(SkillService) + await bundledCtx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + bundledSkillDir: bundled, + }) + expect((await bundledCtx.skills.get('bundled-host'))?.source).toBe('bundled') }) it('forwards cancellation to filesystem reads while loading a skill', async () => { @@ -375,17 +397,22 @@ describe('LocalSkillProvider', () => { it('uses default home root resolution without exposing builtin skills', async () => { const previousDshHome = process.env.DSH_HOME const previousAgentsHome = process.env.DSH_AGENTS_HOME + const previousBundledSkillDir = process.env.DSH_BUNDLED_SKILL_DIR const envHome = await tempDir('skill-env-home') try { process.env.DSH_HOME = join(envHome, '.dsh') process.env.DSH_AGENTS_HOME = join(envHome, '.agents') + const bundled = join(envHome, 'bundled-skills') + process.env.DSH_BUNDLED_SKILL_DIR = bundled await writeSkill(join(envHome, '.dsh/skills'), 'env-skill', 'Env skill') + await writeSkill(bundled, 'env-bundled-skill', 'Env bundled skill') const ctx = new Context() await ctx.plugin(SkillService) await ctx.plugin(SkillLocal) - expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-skill']) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-bundled-skill', 'env-skill']) process.env.DSH_HOME = join(envHome, 'empty-dsh') + delete process.env.DSH_BUNDLED_SKILL_DIR process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents') const empty = new Context() await empty.plugin(SkillService) @@ -405,6 +432,11 @@ describe('LocalSkillProvider', () => { } else { process.env.DSH_AGENTS_HOME = previousAgentsHome } + if (previousBundledSkillDir === undefined) { + delete process.env.DSH_BUNDLED_SKILL_DIR + } else { + process.env.DSH_BUNDLED_SKILL_DIR = previousBundledSkillDir + } } }) }) diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index f736fc8d0f..d0b547ed8b 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -28,7 +28,7 @@ export function isSkillName(name: string): boolean { } /** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */ -export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {}) +export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | 'bundled' | (string & {}) /** Optional provider-specific base used by loaded skill bodies to resolve relative resources. */ export type SkillResourceBase = diff --git a/scripts/verify-md-links.ts b/scripts/verify-md-links.ts index 191a4c9c92..969b0f5e44 100644 --- a/scripts/verify-md-links.ts +++ b/scripts/verify-md-links.ts @@ -25,6 +25,7 @@ const PATTERNS = [ 'AGENTS.md', 'packages/AGENTS.md', '.agents/skills/**/*.md', + 'skills/**/*.md', ] /** A broken relative link: a target path that does not resolve to a file. */ diff --git a/scripts/verify-mermaid.ts b/scripts/verify-mermaid.ts index 79e80d802d..0cdf8982a4 100644 --- a/scripts/verify-mermaid.ts +++ b/scripts/verify-mermaid.ts @@ -26,6 +26,7 @@ const PATTERNS = [ 'AGENTS.md', 'packages/AGENTS.md', '.agents/skills/**/*.md', + 'skills/**/*.md', ] interface Block { diff --git a/skills/dsh-customize/SKILL.md b/skills/dsh-customize/SKILL.md new file mode 100644 index 0000000000..74da68b578 --- /dev/null +++ b/skills/dsh-customize/SKILL.md @@ -0,0 +1,35 @@ +--- +name: dsh-customize +description: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly. +--- + +# DSH Customize + +Make personal DSH changes in task worktrees and integrate them under the staging lock. Repository instructions still apply. + +## Find staging + +Do not assume a path or branch name. DSH is usually installed from source with a personal staging branch; create one for the user only when none exists. + +1. Inspect `command -v dsh` in the user's launch environment before resolving symlinks. +2. Follow the launcher through the full symlink chain to identify the source checkout. The standard [`scripts/install.sh`](../../scripts/install.sh) keeps every checkout under one container `${DSH_SOURCE}` (default `~/.dsh/source`): the master clone at `${DSH_SOURCE}/master` and each staging checkout as a git worktree `${DSH_SOURCE}/staging-`. `${DSH_BIN_DIR}/dsh` links to `${DSH_SOURCE}/current/bin/dsh`, and the stable `current` symlink points at the active staging worktree, so resolve `current` to reach the real checkout. All paths are configurable; an older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones — follow the launcher rather than assuming a layout. +3. Verify the checkout with Git, then record its branch, tip, status, remotes, worktrees, in-progress operations, and applicable `AGENTS.md` files. +4. Treat the launcher checkout's branch as staging unless the user says otherwise. The installed launcher must resolve to a staging worktree on a staging branch, never the master clone or a task, preparation, review, publication, or detached checkout. Ask if the launcher, checkout, or branch ownership is ambiguous; warn explicitly for a detached HEAD, the master clone, or a non-staging branch. + +## Customize + +1. Create a fresh task branch and worktree from the recorded staging tip, using the repository-required worktree location — default to `.worktrees/` under the repository root unless the repository requires otherwise. Never implement or commit directly on staging. +2. Implement the change, then select and run the repository-required review and checks. If a check fails, fix the cause and rerun it before integration. +3. For TUI or interactive behavior, test the assembled application interactively in a dedicated tmux session; unit tests and snapshots alone are insufficient. +4. Record the task tip and confirm the task worktree is clean before integration. + +## Integrate under the lock + +1. Resolve the worktree that owns staging and use `/.agents/merge.lock`. Keep it Git-ignored; never remove or replace it. Require `flock`. +2. Acquire the lock, then re-check branch ownership, exact staging tip, clean status, and absence of an in-progress Git operation. If staging moved, unlock and restart discovery against its current owner's lock. +3. Hold the same lock through final precondition checks, `git merge --no-ff`, required post-merge checks, conflict handling, and rollback. +4. If the merge or a post-merge check fails, abort the merge or restore the recorded clean staging tip before unlocking. Never discard unknown user files. +5. Before unlocking, verify staging's branch, commit, clean status, and required checks. Report that evidence and the commands run. +6. Remove the task worktree and branch only when their commits are reachable from staging and no longer needed. + +Use [`dsh-upstream-customization`](../dsh-upstream-customization/SKILL.md) when the user wants to contribute a personal feature upstream. diff --git a/skills/dsh-upgrade/SKILL.md b/skills/dsh-upgrade/SKILL.md new file mode 100644 index 0000000000..7570672cc8 --- /dev/null +++ b/skills/dsh-upgrade/SKILL.md @@ -0,0 +1,46 @@ +--- +name: dsh-upgrade +description: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH. +--- + +# DSH Upgrade + +Prepare and validate the upgrade in a fresh staging worktree of the master clone, leave the worktree the installed launcher currently uses unchanged, then atomically repoint the stable `current` symlink once. Read and follow [`dsh-customize`](../dsh-customize/SKILL.md) before starting; it owns checkout discovery and lock handling. + +## Layout + +A source-installed DSH keeps every checkout under one container directory `` (default `~/.dsh/source`): the master clone at `/master` (remote tracking `master`, the fetch/upgrade base, never a launcher target) and each staging checkout as a git worktree `/staging-` on branch `dsh-staging/`. The stable symlink `/current` points at the active staging worktree, and the PATH launcher links to `/current/bin/dsh`, so the launcher resolves PATH -> `current` -> staging worktree. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. All worktrees share the master clone's single `.git` object store; the master clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones; if so, follow the recorded launcher checkout rather than assuming this layout, treat that sibling clone as its own master, and create `current` and repoint PATH to `current/bin/dsh` as a one-time migration at cutover. + +## Names + +One upgrade attempt uses one UTC basic timestamp `YYYYMMDDTHHMMSSZ` for all names: + +- new staging worktree: `/staging-`; +- preparation branch: `dsh-upgrade/prepare-`; +- installed staging branch: `dsh-staging/`; +- fetched upstream ref: `refs/dsh-upgrade/upstream-`; +- recovery ref: `refs/dsh-upgrade/recovery-`; +- recorded `current` target before cutover: the old staging worktree path, kept for symlink rollback. + +The worktree name is always `staging-` under ``, never derived from the current staging directory name, so successive upgrades stay in one place and do not accumulate timestamps. The preparation branch and private refs are local-only and must never be pushed. Before starting, reject a current staging branch named exactly `dsh-staging`, because Git cannot also create `dsh-staging/`; require the user to choose a non-conflicting staging namespace rather than silently renaming it. If the new staging worktree path exists, resume only when it is a clean worktree of this master clone whose recorded old tip, upstream ref, recovery ref, and named branches exactly match this attempt; otherwise stop. Never add an ad hoc suffix or delete an unknown directory. + +## Upgrade + +1. Resolve the installed launcher, its staging worktree and branch, the master clone, the current DSH process source, and authoritative upstream. Record exact tips, paths, clean status, remotes, dependencies, worktrees, and in-progress Git operations. Require the installed staging worktree to be clean and its `.agents/merge.lock` to exist and be Git-excluded. Never stash automatically. +2. Treat the staging worktree behind the installed launcher as immutable for the whole attempt: do not touch its branch, HEAD, index, tracked or untracked files, dependencies, worktree registration, or lock file. Fetching into the shared master clone and creating new branches, worktrees, and private refs there are allowed because they are append-only and never alter the old worktree's checkout; opening and holding the existing lock is the only operation on the old worktree. +3. Allocate the timestamp and new staging worktree path. Acquire the installed worktree's existing `.agents/merge.lock`, repeat every precondition, and keep it through preparation, validation, and the `current` cutover. If staging moves while waiting, unlock and restart with a new timestamp; remove only attempt artifacts that this run created and verified as disposable. +4. In the master clone, create `refs/dsh-upgrade/recovery-` at the recorded old staging tip and `dsh-upgrade/prepare-` from that tip. Fetch exact authoritative upstream `master` into `refs/dsh-upgrade/upstream-` and record its object ID. Add a fresh worktree `/staging-` checked out on the preparation branch. Confirm the master clone's `.git/info/exclude` excludes `.agents/merge.lock`, which the new worktree inherits. +5. Inspect the Git log and commit ranges between the staging base, old staging tip, and fetched upstream tip. Identify incoming upstream changes, personal commits to preserve, likely duplicates, and conflict-prone areas before rebasing. +6. In the new worktree, rebase the preparation branch onto the fetched upstream commit. Preserve intentional customizations and drop behavior already upstream. If upstream contains the customization and its remaining local diff only documents that customization, prefer upstream and drop the documentary diff rather than retaining a stale local account. Preserve documentation only when it adds a current, independently useful contract absent upstream. Abort without changing the installed launcher when resolution is uncertain. +7. Install dependencies in the new worktree, review the resulting diff, and run the repository-required checks. Fix failures and rerun affected checks. Test the new worktree's `bin/dsh` directly. +8. Point `dsh-staging/` at the validated prepared tip and check it out in the new worktree. Ensure its `.agents/merge.lock` exists (Git-excluded through the shared master exclude). Verify its branch, exact commit, clean status, remotes, dependencies, and absence of in-progress Git operations, then smoke its `bin/dsh` from a clean temporary workspace. The preparation branch remains temporary; the timestamped staging branch owns the installed commit. +9. Recheck the old worktree, existing lock, launcher, `current`, master clone, new worktree, refs, and exact tips. Record `current`'s pre-cutover target, then repoint `current` at the new staging worktree in one atomic swap with `ln -sfn` (the `-n` stops `ln` from dereferencing the existing directory symlink and writing the link inside the old worktree; `mv` behaves the same way and is unusable). Leave the PATH launcher alone once it already resolves through `current`; if a legacy install still links PATH straight at a worktree, create `current` and repoint PATH to `current/bin/dsh` as a one-time migration here. The `current` target must be a clean staging worktree on a staging branch and must never be the master clone or a preparation, feature, review, publication, or detached checkout. Smoke the installed `dsh` command from a clean temporary workspace. +10. On failure before the `current` cutover, leave `current`, the launcher, and the old worktree unchanged and remove only verified attempt artifacts created by this run (including the new worktree registration if empty). On failure during or after cutover, inspect `current`'s observed target before acting; if cutover did not verify, atomically repoint `current` back to its recorded pre-cutover target with `ln -sfn` and verify that `dsh` starts from the unchanged old staging worktree. This rollback is the sole exception allowing `current` to return to the old staging worktree. Never retry a side-effecting operation blindly. +11. Release the old worktree's lock and tell the user to restart DSH through the installed launcher. The current process may continue from the old worktree, but no operation may mutate or remove it until the restarted process proves that it runs from `dsh-staging/` and the user confirms stability. Avoid customization integration during this confirmation window; if rollback is required after new work lands, reconcile that work explicitly rather than silently stranding it. +12. After confirmation, remove the preparation branch if no process uses it. Keep the old staging worktree and branch, the recovery ref, and the recorded pre-cutover `current` target as rollback until the user explicitly approves their removal; leave the actual `git worktree remove` and directory deletion to the user. Report old, upstream, prepared, and new staging commits; both staging worktree paths and branches; the master clone path; process-source evidence; the `current` pre-cutover target and cutover; commands and checks; final status; recovery ref; and retained rollback artifacts. + +The installed launcher always resolves through `current` to a staging worktree, never the master clone. Upgrade preparation adds a new worktree that shares the master object store while leaving the old worktree's checkout untouched; cutover is one atomic `current` repoint to the separately validated timestamped staging worktree, and the PATH launcher never moves. + +## Recommend upstream candidates + +After a successful upgrade, load [`dsh-upstream-customization`](../dsh-upstream-customization/SKILL.md) and classify each remaining personal customization by its rules. For each candidate, explain its classification and upstream value and recommend whether to propose it, then ask which named candidate, if any, the user wants to upstream. The answer selects a candidate to start that skill's publication workflow; it is not publishing approval, which that workflow still requires. diff --git a/skills/dsh-upstream-customization/SKILL.md b/skills/dsh-upstream-customization/SKILL.md new file mode 100644 index 0000000000..944f4b083c --- /dev/null +++ b/skills/dsh-upstream-customization/SKILL.md @@ -0,0 +1,27 @@ +--- +name: dsh-upstream-customization +description: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing. +--- + +# DSH Upstream Customization + +Classify and propose personal customizations upstream one feature at a time. + +## Classify + +- **Definitely propose:** bug fixes. +- **Propose:** additive, non-conflicting features implemented as plugins; visual improvements. +- **Do not propose without maintainer approval:** intrusive changes that alter existing architecture, core behavior, or broad contracts. +- Explain the classification and upstream value. If unsure whether a change is intrusive, treat it as intrusive. + +Classification and a recommendation are not publishing approval. Obtain explicit user approval naming one feature before pushing or opening a PR; approval for another feature, an upgrade, or local integration does not apply. + +## Publish an approved feature + +1. Fetch current upstream `master`, then rebuild only the approved feature on a fresh branch and worktree at that exact commit. Never publish the personal staging branch or unrelated customizations. +2. Follow repository instructions for implementation, review, testing, disclosure, PR writing, and pre-push checks. Fix failures and rerun the affected checks before publishing. +3. Review the outgoing commits and diff against upstream. Confirm they contain only the approved feature, no credentials or personal data, and a clean worktree. +4. Reconfirm the approved feature name and publishing target before the first push. Do not infer authorization from earlier local work. +5. Push only that branch and open only a draft PR. Keep its description synchronized with later changes. +6. For a TUI feature, preferably attach a screenshot from the assembled application after removing credentials and personal data. +7. Report the upstream base and branch commits, commands and checks run, pushed branch, and draft PR URL.