From 2225ad435e190afc3fcbb6f262ae16c6d8426026 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 23:23:12 +0800 Subject: [PATCH] test: cover experimental gate through the built bin; fix Agent Note facts --- ...sh-guided-skill-session-commands.i18n.yaml | 4 +-- ...07-28-dsh-guided-skill-session-commands.md | 2 +- ...28-dsh-guided-skill-session-commands.zh.md | 2 +- ...-07-28-dsh-meta-source-workspace.i18n.yaml | 4 +-- .../2026-07-28-dsh-meta-source-workspace.md | 2 +- ...2026-07-28-dsh-meta-source-workspace.zh.md | 2 +- ...-31-experimental-subcommand-gate.i18n.yaml | 4 +-- ...2026-07-31-experimental-subcommand-gate.md | 6 ++++- ...6-07-31-experimental-subcommand-gate.zh.md | 6 ++++- apps/cli/tests/built-bin.e2e.ts | 25 +++++++++++++++++++ 10 files changed, 45 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml index 880278708e..cd0da77981 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.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 .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md -2026-07-28-dsh-guided-skill-session-commands.md: 241bf3c0af693f3ccc318bf44bbeccb6c5d73d94 -2026-07-28-dsh-guided-skill-session-commands.zh.md: f74b2540889900a01b7590344a3bff62e8a9fe4a +2026-07-28-dsh-guided-skill-session-commands.md: 8a091f7a03c85b0723d96b4fc546875a4e6c0f95 +2026-07-28-dsh-guided-skill-session-commands.zh.md: 861e6cf6cf07c41fde4f78b0833e3f8eb9508767 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md index 241bf3c0af..8a091f7a03 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md @@ -16,7 +16,7 @@ The seed reuses the existing TUI skill path, not a new one. `createTuiChat` alre **Freshness is gated in the launcher, not the TUI.** `runSkillSession` always mints a fresh session and provides the slot only when `resumeSessionId === undefined`, so a later `dsh --resume ` of that session is an ordinary TUI session with no re-injection. The TUI stays generic: it invokes whatever skill it is handed, once, at startup. -**`migrate`/`upgrade` take no options beyond the [experimental gate](2026-07-31-experimental-subcommand-gate.md)'s `--experimental`.** They carry no `--resume`, `--config`, or `-p`; a guided fresh-session entry has nothing to resume or reconfigure. Any leaked default-surface option fails loud, matching the `web`/`meta` rejection pattern in the Commander adapter. The two modes share one `SkillSessionInvocation` discriminant (`mode: 'migrate' | 'upgrade'`); `bin.ts` maps the mode to `dsh-${mode}`. +**`migrate`/`upgrade` take no default-surface options** (`upgrade` additionally carries the [experimental gate](2026-07-31-experimental-subcommand-gate.md)'s `--experimental`). They carry no `--resume`, `--config`, or `-p`; a guided fresh-session entry has nothing to resume or reconfigure. Any leaked default-surface option fails loud, matching the `web`/`meta` rejection pattern in the Commander adapter. The two modes share one `SkillSessionInvocation` discriminant (`mode: 'migrate' | 'upgrade'`); `bin.ts` maps the mode to `dsh-${mode}`. The `dsh-migrate` skill is bundled under `skills/` (shipped through `DSH_BUNDLED_SKILL_DIR`, like `dsh-upgrade`). It asks which source agent (opencode/pi/Claude Code/Codex) if unstated, then maps each capability — workspace instructions, personal overlay, skills, hooks, MCP, API/env — to its DSH equivalent, grounded in the actual repo surfaces (the `hooks-claude`/`hooks-codex` bridges, `~/.dsh/{config.yaml,.env,AGENTS.md,skills/}`, `AGENTS.md`/`CLAUDE.md`, `mcporter`), and states plainly when a capability has no equivalent. diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md index f74b254088..861e6cf6cf 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md @@ -16,7 +16,7 @@ Status: implemented **新鲜性在启动器而非 TUI 中把关。** `runSkillSession` 总是创建全新会话,且仅在 `resumeSessionId === undefined` 时提供该槽,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。TUI 保持通用:它只是把接到的 skill 在启动时调用一次。 -**`migrate`/`upgrade` 除[实验性门槛](2026-07-31-experimental-subcommand-gate.md)的 `--experimental` 外不接受任何选项。** 它们不带 `--resume`、`--config` 或 `-p`;引导式全新会话入口没有可恢复或可重配置的内容。任何泄漏的默认界面选项都会明确报错,与 Commander 适配器中 `web`/`meta` 的拒绝模式一致。两个 mode 共用一个 `SkillSessionInvocation` 判别式(`mode: 'migrate' | 'upgrade'`);`bin.ts` 将 mode 映射为 `dsh-${mode}`。 +**`migrate`/`upgrade` 不接受任何默认界面选项**(`upgrade` 另带[实验性门槛](2026-07-31-experimental-subcommand-gate.md)的 `--experimental`)。它们不带 `--resume`、`--config` 或 `-p`;引导式全新会话入口没有可恢复或可重配置的内容。任何泄漏的默认界面选项都会明确报错,与 Commander 适配器中 `web`/`meta` 的拒绝模式一致。两个 mode 共用一个 `SkillSessionInvocation` 判别式(`mode: 'migrate' | 'upgrade'`);`bin.ts` 将 mode 映射为 `dsh-${mode}`。 `dsh-migrate` skill 内置于 `skills/`(经 `DSH_BUNDLED_SKILL_DIR` 交付,与 `dsh-upgrade` 相同)。若未说明源 agent,它会先询问是哪个(opencode/pi/Claude Code/Codex),再把每项能力——workspace 指令、个人覆盖、skills、hooks、MCP、API/env——映射到对应的 DSH 等价物,并基于仓库实际的表面(`hooks-claude`/`hooks-codex` 桥、`~/.dsh/{config.yaml,.env,AGENTS.md,skills/}`、`AGENTS.md`/`CLAUDE.md`、`mcporter`)落地;当某能力无等价物时明确说明。 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml index 345e563890..72c62d1e60 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.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 .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md -2026-07-28-dsh-meta-source-workspace.md: ab6050150f6321197686e1ad649555d3eaa7c374 -2026-07-28-dsh-meta-source-workspace.zh.md: c6f570498f10f16746b7aa5756b2e04074ae6a9e +2026-07-28-dsh-meta-source-workspace.md: 95270a276cd5df03ffd2dfb419a33289d7b5b901 +2026-07-28-dsh-meta-source-workspace.zh.md: 645b20705386a3501d026fb58ca224c49cc69a17 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md index ab6050150f..95270a276c 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md @@ -36,6 +36,6 @@ The mode was verified interactively instead. Launched from `$HOME`, a `pwd` tool ## Consequences -Starting a session on dsh's own source is `dsh meta` from anywhere, and the workspace is guaranteed to be the same checkout the model is told about. The command always starts fresh; an ordinary `dsh --resume ` later restores the session and enters its persisted workspace. +Starting a session on dsh's own source is `dsh meta --experimental` from anywhere (or bare `dsh meta` under `DSH_EXPERIMENTAL=1`), and the workspace is guaranteed to be the same checkout the model is told about. The command always starts fresh; an ordinary `dsh --resume ` later restores the session and enters its persisted workspace. `runTui` gains an optional third parameter, so the workspace override is visible at the one function that owns TUI composition rather than hidden in a second copy of it. diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md index c6f570498f..645b207053 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md @@ -36,6 +36,6 @@ Status: implemented ## Consequences -在 dsh 自身源码上开启会话变成了在任意位置执行 `dsh meta`,且该 workspace 必然就是告知模型的那个检出目录。该命令始终启动新会话;之后,普通的 `dsh --resume ` 会恢复该会话并进入其已持久化的 workspace。 +在 dsh 自身源码上开启会话变成了在任意位置执行 `dsh meta --experimental`(在 `DSH_EXPERIMENTAL=1` 下可直接执行 `dsh meta`),且该 workspace 必然就是告知模型的那个检出目录。该命令始终启动新会话;之后,普通的 `dsh --resume ` 会恢复该会话并进入其已持久化的 workspace。 `runTui` 新增一个可选第三参数,因此 workspace 覆盖是在拥有 TUI 组合逻辑的那唯一一个函数上可见的,而不是隐藏在它的第二份副本中。 diff --git a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.i18n.yaml index 511535d762..8d925d4eee 100644 --- a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.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 .agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.md -2026-07-31-experimental-subcommand-gate.md: 4f38d8cd83ecae7b4e104a61acdb7acca98c071d -2026-07-31-experimental-subcommand-gate.zh.md: 5fc2ff6513622c42e8b0eef711710a634f86870a +2026-07-31-experimental-subcommand-gate.md: 41447c2d23bc71964f990298de3c48b2fe7ef309 +2026-07-31-experimental-subcommand-gate.zh.md: 35598a61d98ff7490b34d175fc915043f5b5236a diff --git a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.md b/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.md index 4f38d8cd83..41447c2d23 100644 --- a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.md +++ b/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.md @@ -12,10 +12,14 @@ The `meta` and `upgrade` entry points carried their experimental status in their `dsh experimental-meta` is `dsh meta` and `dsh experimental-upgrade` is `dsh upgrade`. Each runs only when the invocation passes its `--experimental` flag or the environment carries `DSH_EXPERIMENTAL=1`; otherwise the command fails loud on stderr with exit 1, naming both opt-ins. Per the pre-release stance, the old names are gone with no aliases, and `args.spec.ts` pins their rejection. -The gate has two halves with one owner each. The per-invocation half is a Commander `--experimental` option on each experimental subcommand, checked inside its action after the leaked-parent-option rejection. The environment half is a boolean `parseDshArgs` parameter: `bin.ts` reads `process.env.DSH_EXPERIMENTAL === '1'` at the process boundary and passes the result down, so the parser stays a pure function of its inputs and the tests need no env mutation. `1` is the only enabling value — the variable is an explicit opt-in, not a truthiness check. +The gate has two halves with one owner each. The per-invocation half is a Commander `--experimental` option on each experimental subcommand, checked inside its action after the leaked-parent-option rejection. The environment half is a boolean `parseDshArgs` parameter: `bin.ts` reads `process.env.DSH_EXPERIMENTAL === '1'` at the process boundary (after `loadEnv`, so a project `.env` can set it) and passes the result down, so the parser's environment dependency is explicit in its signature and the tests need no env mutation. `1` is the only enabling value — the variable is an explicit opt-in, not a truthiness check. Stabilizing a command later means deleting its `--experimental` option and `requireExperimental` call; the name does not move. +## Testing + +`args.spec.ts` pins both admit paths, bare-name rejection, old-name rejection, and leaked-option rejection under the env opt-in. `built-bin.e2e.ts` proves the assembled entry end to end: the gate diagnostic on stderr with exit 1, and that `--experimental`, `DSH_EXPERIMENTAL=1`, but not `DSH_EXPERIMENTAL=0`, reach the TUI's piped-stdio refusal — the next gate past this one. Both gated commands were also verified interactively in tmux: `dsh meta --experimental` and `DSH_EXPERIMENTAL=1 dsh meta` boot the TUI over the checkout, and `DSH_EXPERIMENTAL=1 dsh upgrade` seeds the `dsh-upgrade` skill. + ## Alternatives considered **Keep the `experimental-` name prefix.** Rejected by the user's direction: the prefix taxes every invocation, and stabilization would be a breaking rename instead of deleting a gate. diff --git a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.zh.md b/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.zh.md index 5fc2ff6513..35598a61d9 100644 --- a/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.zh.md @@ -12,10 +12,14 @@ Status: implemented `dsh experimental-meta` 改为 `dsh meta`,`dsh experimental-upgrade` 改为 `dsh upgrade`。二者只有在调用时传入各自的 `--experimental` 标志、或环境中带有 `DSH_EXPERIMENTAL=1` 时才会运行;否则命令在 stderr 上明确报错并以退出码 1 结束,同时指明两种选择加入方式。依据发布前立场,旧名称已移除且没有别名,`args.spec.ts` 钉住了对它们的拒绝。 -该门槛分为两半,各有其归属。按调用的一半是每个实验性子命令上的 Commander `--experimental` 选项,在其 action 内、泄漏父级选项的拒绝之后检查。环境的一半是 `parseDshArgs` 的一个布尔参数:`bin.ts` 在进程边界读取 `process.env.DSH_EXPERIMENTAL === '1'` 并向下传递结果,因此解析器保持为其输入的纯函数,测试也无需改动环境变量。`1` 是唯一的启用值——该变量是显式的选择加入,而不是真值判断。 +该门槛分为两半,各有其归属。按调用的一半是每个实验性子命令上的 Commander `--experimental` 选项,在其 action 内、泄漏父级选项的拒绝之后检查。环境的一半是 `parseDshArgs` 的一个布尔参数:`bin.ts` 在进程边界读取 `process.env.DSH_EXPERIMENTAL === '1'`(在 `loadEnv` 之后,因此项目 `.env` 也可以设置它)并向下传递结果,因此解析器对环境的依赖显式体现在签名中,测试也无需改动环境变量。`1` 是唯一的启用值——该变量是显式的选择加入,而不是真值判断。 之后要稳定某个命令,只需删除它的 `--experimental` 选项和 `requireExperimental` 调用;名字不再变动。 +## Testing + +`args.spec.ts` 钉住两条准入路径、裸名称拒绝、旧名称拒绝,以及在环境选择加入下对泄漏选项的拒绝。`built-bin.e2e.ts` 端到端地证明组装后的入口:stderr 上的门槛诊断与退出码 1,以及 `--experimental`、`DSH_EXPERIMENTAL=1`(而非 `DSH_EXPERIMENTAL=0`)会到达 TUI 的管道 stdio 拒绝——即此门之后的下一道关卡。两个被把守的命令还在 tmux 中做了交互式验证:`dsh meta --experimental` 与 `DSH_EXPERIMENTAL=1 dsh meta` 以检出目录为 workspace 启动 TUI,`DSH_EXPERIMENTAL=1 dsh upgrade` 播种 `dsh-upgrade` skill。 + ## Alternatives considered **保留 `experimental-` 名称前缀。** 按用户的指示拒绝:前缀让每次调用都付出代价,稳定时也会变成破坏性的重命名,而不是删除一个门槛。 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index c9e1b29969..9591ca3105 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -54,6 +54,31 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toBe('') }, 30_000) + describe('experimental subcommand gate', () => { + // The gate has two halves: a per-invocation --experimental flag parsed by + // Commander and an env opt-in read by bin.ts as exactly '1'. Passing the + // gate is proven by reaching the NEXT failure — the TUI's piped-stdio + // refusal — instead of the gate diagnostic. + it('rejects bare `meta`/`upgrade` LOUD, naming both opt-ins', async () => { + for (const command of ['meta', 'upgrade']) { + const { code, stderr } = await runBuiltBin([command], { DSH_EXPERIMENTAL: '' }) + expect(code).toBe(1) + expect(stderr).toContain(`${command} is experimental; pass --experimental or set DSH_EXPERIMENTAL=1`) + } + }, 30_000) + + it('admits --experimental and DSH_EXPERIMENTAL=1, but not other env values', async () => { + const flagged = await runBuiltBin(['meta', '--experimental'], { DSH_EXPERIMENTAL: '' }) + expect(flagged.stderr).toContain('requires stdin and stdout to be interactive TTYs') + const env = await runBuiltBin(['meta'], { DSH_EXPERIMENTAL: '1' }) + expect(env.stderr).toContain('requires stdin and stdout to be interactive TTYs') + // The env opt-in is exact: '0' (or any other value) does not enable. + const zero = await runBuiltBin(['meta'], { DSH_EXPERIMENTAL: '0' }) + expect(zero.code).toBe(1) + expect(zero.stderr).toContain('meta is experimental') + }, 30_000) + }) + describe('dsh --dump-config', () => { let home: string beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })