From 72344fce93b920b6d3241793b68735d944cdef98 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 22:40:57 +0800 Subject: [PATCH 01/17] fix(web): edit a declared provider's name and protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Models editor card curated its pi-ai fields by what every route has, so `displayName` and `api` — the two a hand-declared route names for itself — were asked for at creation and then reachable only through settings.yaml. The editor now renders both for a route the directory reports as declared, from the same namespace schema the create card reads. A catalog route gets neither: it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override all of them. Clearing the name unsets it and the route falls back to its id, which is what the field's placeholder shows; storing the empty string would be refused by the adapter. A declared profile naming no protocol selects nothing rather than reading as if it had picked the first choice. The Provider ID stays fixed: it is the settings dict key, it is referenced from `agent-default-model` and every logged request header, and it is the stem of a credential reference the page can never read back to move. Fixes #2204 --- ...-a-provider-from-the-models-page.i18n.yaml | 4 +- ...claring-a-provider-from-the-models-page.md | 12 ++- ...ring-a-provider-from-the-models-page.zh.md | 12 ++- apps/web/tests/models-settings.e2e.ts | 36 ++++++- .../models-settings/declared-edit.expected.md | 65 +++++++++++++ docs/user/guide/providers.i18n.yaml | 4 +- docs/user/guide/providers.md | 2 + docs/user/guide/providers.zh.md | 2 + packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 4 +- packages/client/ui-models/README.zh.md | 4 +- .../ui-models/src/client/ModelsSection.tsx | 7 ++ .../ui-models/src/client/ProviderEditor.tsx | 72 +++++++++++++- .../ui-models/tests/provider-form.spec.tsx | 93 +++++++++++++++++++ 14 files changed, 300 insertions(+), 21 deletions(-) create mode 100644 apps/web/tests/snapshots/models-settings/declared-edit.expected.md diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml index c34594bd36..bff5cc88fc 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.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/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md -2026-08-04-declaring-a-provider-from-the-models-page.md: 44441da5427dbcd3f0651c2c137a5132ff6d968b -2026-08-04-declaring-a-provider-from-the-models-page.zh.md: 974c4fa5be0893e8fdf6a9142877a8b966708dab +2026-08-04-declaring-a-provider-from-the-models-page.md: 3c63254364e3803e9bddab2a5aad4c092e4f0426 +2026-08-04-declaring-a-provider-from-the-models-page.zh.md: c38b0387b3c7778b93fb43232c22a7acb24dc281 diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md index 44441da542..3c63254364 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md @@ -22,22 +22,30 @@ Fetching asks about the endpoint **the form currently shows** — a base URL edi The protocol choices come from the namespace's **own schema**, read through the settings descriptor the page already fetches (`providers.*.api` is a union of the adapter's `supportedProtocols()`). No new wire field, no constant in the client, and no way for the offered choices to drift from the accepted ones. +The editor reaches the two fields a route the directory reports as **declared** names for itself — its display name and that protocol. A create card asking for a field no editor can change leaves that field reachable only through `settings.yaml`, which is the posture this note set out to end. Both render in the fold beside the endpoint, the protocol from the same schema read; clearing the name unsets it and the route falls back to its id, which is what the field's placeholder shows, while the protocol has no fallback to clear to. A catalog route gets neither: it defaults its name from its catalog entry, and each of its models carries its own protocol, so a route-level one could only override every one of them. + +The **Provider ID** is the one create-card field that stays fixed, and not for want of a control. It is the `providers.` dict key, so changing it is a move rather than an edit, and the editor is addressed by the `settingsPath` that move would invalidate. It is referenced from outside this namespace — `agent-default-model` stores a `provider` string, and every `request/header` in every session log already records one — so a rename would silently strip meaning from referents this page cannot see. And it is the stem of the derived credential reference: the page writes keys but can never read one back, so it cannot move `OLD_API_KEY` to `NEW_API_KEY`, leaving a rename to either orphan the stored key or point the profile at a reference under the previous name. Declaring the new route and deleting the old one does all three explicitly, and the page already offers both halves. + ## Alternatives considered **Declare a provider through `ProviderEditor` with extra fields.** One card instead of two, but the editor is addressed by `settingsPath`, and a route being named has no path yet. Recomputing the path per keystroke would remount the card and discard the draft; deferring it would mean the editor's whole write path no longer described what it was editing. **Add a wire field for the protocol list.** Explicit. But the settings schema already crosses the wire and already contains the union, so a second copy could disagree with the first — and the one the adapter enforces is the schema. +**Let the Provider ID be edited, with the page performing the move.** The card would unset the old key and set the new profile in one `settings.mutate`, and the rest is a rename. But the credential cannot travel with it — the page holds a redacted descriptor, never a value — and the referents in other namespaces and in logged sessions have no rename path at all, so the honest version of this feature is the create-then-delete the page already has. + +**Offer the protocol on every pi-ai route, with an inherit choice.** Symmetric with the base URL beside it, and repointing a catalog route at a gateway speaking another wire protocol is a real thing to want. But no consumer asks for it, one wrong pick silently repoints every model on the route, and the inherit choice would be the only way to write a declared route into a profile the adapter refuses. `settings.yaml` still expresses the repoint for a deployment that means it. + **Fetch against the stored profile instead of the live form.** No key would leave the form for an unsaved provider. But the flow that needs fetching most is the one where nothing is stored yet, and a form whose endpoint was edited would quietly interrogate the old one. **Write adopted candidates straight into the list.** Fewer clicks, but a fetch would then overwrite capacities the user had corrected, and a listing that discloses only ids would replace real numbers with nothing. ## Consequences -A gateway, a self-hosted server, or a model newer than the installed catalog is now configurable without leaving the browser, and the endpoint itself supplies the model ids where it can. The page grew two components and one shared list editor; the editor card's pi-ai fold grew from two fields to a list. +A gateway, a self-hosted server, or a model newer than the installed catalog is now configurable without leaving the browser, and the endpoint itself supplies the model ids where it can. The page grew two components and one shared list editor; the editor card's pi-ai fold grew from two fields to a list, plus a name and a protocol on a declared route. What it costs: only pi-ai routes can be hand-declared, because `llm-pi-ai` is the one namespace whose profiles describe a whole provider — a `llm-deepseek` route stays a composition fact. Interrogation reaches only OpenAI-compatible endpoints, so a gateway speaking another protocol reports that it cannot be asked and its models are typed in. And the page now holds a key in component state for the duration of a fetch, which is the same exposure `credentials.set` already has and no longer than the card lives. ## Testing -`packages/client/ui-models/tests/provider-form.spec.tsx` drives the rendered page over a scripted wire face: adding, editing, and removing rows; a cleared optional field leaving the profile and a non-integer capacity never entering it; the interrogation carrying the edited endpoint, the unsaved key, and the profile's protocol; the picker's default selection, toggling, cancel, and adopt-keeps-tuned-rows; the empty, refused, and rejected-transport paths; the create writing one profile plus its credential; every gate on the create button; and the read-only posture. `protocolChoices` is covered against a schema that declares the union and one that does not. +`packages/client/ui-models/tests/provider-form.spec.tsx` drives the rendered page over a scripted wire face: adding, editing, and removing rows; a cleared optional field leaving the profile and a non-integer capacity never entering it; the interrogation carrying the edited endpoint, the unsaved key, and the profile's protocol; the picker's default selection, toggling, cancel, and adopt-keeps-tuned-rows; the empty, refused, and rejected-transport paths; the create writing one profile plus its credential; every gate on the create button; and the read-only posture. `protocolChoices` is covered against a schema that declares the union and one that does not. The editor's own field inventory is asserted per route kind — a catalog route stops at the key and the endpoint, a declared one also carries the protocol — along with the protocol edit travelling as a single `api` path op, a rename travelling as a single `displayName` one, a cleared name unsetting rather than storing the empty string the adapter refuses, and a declared profile naming no protocol selecting nothing rather than the first choice. `apps/web/tests/models-settings.e2e.ts` reopens the declared route through the real wire, captures the card, and asserts the chosen protocol and the new name both reach `settings.yaml` and the row re-registers under the rename. diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md index 974c4fa5be..c38b0387b3 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md @@ -22,22 +22,30 @@ Status: implemented 协议选项来自该 namespace **自己的 schema**,经页面本就会获取的 settings 描述符读出(`providers.*.api` 是适配器 `supportedProtocols()` 的一个 union)。没有新增协议字段,客户端里没有常量,提供的选项也无从与被接受的集合发生漂移。 +对于目录报告为**已声明**的路由,编辑器够得着它为自己命名的那两个字段——显示名称与该协议。创建卡片索要一个编辑器改不了的字段,等于把该字段留在只有 `settings.yaml` 才能触及的位置,而那正是本记录要终结的姿态。两者都渲染在折叠区里、紧挨着端点,协议读的是同一份 schema;清空名称即取消设置,路由退回自己的 id——字段占位符显示的就是它,而协议没有可退回的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。 + +**Provider ID** 是创建卡片上唯一保持固定的字段,原因不是没做控件。它是 `providers.` 这个字典键,因此改它是一次搬移而非一次编辑,而编辑器正是由那次搬移会作废的 `settingsPath` 寻址的。它还被本 namespace 之外引用——`agent-default-model` 存着一个 `provider` 字符串,每条会话日志里的每个 `request/header` 也都已经记下了一个——因此重命名会悄悄抽空这个页面看不见的那些引用。它同时是派生凭据引用的词干:页面写得了密钥却永远读不回来,因此无法把 `OLD_API_KEY` 搬到 `NEW_API_KEY`,重命名要么让已存密钥成为孤儿,要么让 profile 指向一个仍带旧名的引用。声明新路由再删掉旧的,把这三件事都显式做了一遍,而页面本就提供这两半。 + ## Alternatives considered **在 `ProviderEditor` 上加字段来声明提供方。** 两张卡片变一张,但编辑器由 `settingsPath` 寻址,而正在被命名的路由还没有路径。逐次按键重算路径会让卡片重新挂载并丢掉草稿;推迟计算则意味着编辑器的整条写入路径不再描述它正在编辑的东西。 **为协议列表新增一个协议字段。** 显式。但 settings schema 本来就会跨越协议层、本来就含有那个 union,因此第二份副本可能与第一份不一致——而适配器强制执行的是 schema 那一份。 +**开放 Provider ID 编辑,由页面来完成这次搬移。** 卡片可以在一次 `settings.mutate` 里取消旧键、设置新 profile,剩下的就只是改名。但凭据没法跟着走——页面手里只有脱敏描述符,从来没有值——而其他 namespace 与已记录会话里的引用根本没有重命名通路,因此这个功能诚实的版本,就是页面已经具备的「先建后删」。 + +**给每条 pi-ai 路由都提供协议,并附一个「继承」选项。** 与紧挨着的 API 地址对称,而且把内置目录路由指向讲另一种协议的网关确实是有人会想要的事。但目前没有消费方提出这个诉求,一次选错就会静默地把该路由上每个模型都重指,而「继承」选项还会成为把已声明路由写成适配器拒绝的 profile 的唯一途径。真要这么做的部署,`settings.yaml` 仍然表达得了。 + **针对已存 profile 而非实时表单发起获取。** 对尚未保存的提供方来说,密钥就不会离开表单。但最需要获取的恰恰是「什么都还没存」的那条流程,而端点已修改的表单会悄悄去询问旧地址。 **把采纳的候选直接写进列表。** 点击更少,但一次获取就会覆盖用户已更正的容量,而只公布 id 的列表会把真实数字替换成空。 ## Consequences -网关、自建服务,或比已安装 catalog 更新的模型,如今无需离开浏览器就能配置,而模型 id 在端点能提供时由端点自己给出。页面多了两个组件和一个共用的列表编辑器;编辑卡片的 pi-ai 折叠区从两个字段长成了一个列表。 +网关、自建服务,或比已安装 catalog 更新的模型,如今无需离开浏览器就能配置,而模型 id 在端点能提供时由端点自己给出。页面多了两个组件和一个共用的列表编辑器;编辑卡片的 pi-ai 折叠区从两个字段长成了一个列表,已声明路由上还多了一个名称输入框和一个协议选择框。 代价是:只有 pi-ai 路由可以手工声明,因为 `llm-pi-ai` 是唯一一个其 profile 描述整个提供方的 namespace——`llm-deepseek` 路由仍是组合面的事实。询问只覆盖 OpenAI 兼容端点,因此讲其他协议的网关会报告自己无法被询问,其模型需手工键入。另外,页面在一次获取期间会把密钥保存在组件状态里,这与 `credentials.set` 已有的暴露面相同,且不长于卡片的存活时间。 ## Testing -`packages/client/ui-models/tests/provider-form.spec.tsx` 在脚本化的协议面之上驱动渲染后的页面:添加、编辑与移除行;被清空的可选字段离开 profile、非整数容量从不进入;询问携带已修改的端点、未保存的密钥,以及 profile 自身的协议;选择框的默认选中、勾选切换、取消,以及「采纳保留已调优的行」;空列表、被拒、传输被拒三条路径;创建写入一份 profile 加其凭据;创建按钮上的每一道门控;以及只读姿态。`protocolChoices` 针对「声明了该 union」与「没有声明」两种 schema 都有覆盖。 +`packages/client/ui-models/tests/provider-form.spec.tsx` 在脚本化的协议面之上驱动渲染后的页面:添加、编辑与移除行;被清空的可选字段离开 profile、非整数容量从不进入;询问携带已修改的端点、未保存的密钥,以及 profile 自身的协议;选择框的默认选中、勾选切换、取消,以及「采纳保留已调优的行」;空列表、被拒、传输被拒三条路径;创建写入一份 profile 加其凭据;创建按钮上的每一道门控;以及只读姿态。`protocolChoices` 针对「声明了该 union」与「没有声明」两种 schema 都有覆盖。编辑器自身的字段清单按路由种类各有断言——内置目录路由止于密钥与端点,已声明路由还带着协议——同时覆盖协议改动只以单条 `api` path op 传出、改名只以单条 `displayName` path op 传出、清空名称是取消设置而不是存入适配器会拒绝的空串,以及不写协议的已声明 profile 什么都不选中、而非选中第一个候选。`apps/web/tests/models-settings.e2e.ts` 经真实协议层重新打开这条已声明路由,捕获该卡片,并断言选定的协议与新名称都抵达了 `settings.yaml`、该行也以新名重新注册。 diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 364b00f9e5..6afdfa4429 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -28,6 +28,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md') const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md') const DECLARED_EXPECTED = join(SNAPSHOT_DIR, 'declared.expected.md') +const DECLARED_EDIT_EXPECTED = join(SNAPSHOT_DIR, 'declared-edit.expected.md') const NATIVE_DELETE_EXPECTED = join(SNAPSHOT_DIR, 'native-delete.expected.md') const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md') const MODE = webSnapshotMode() @@ -209,6 +210,37 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('reopens the name and protocol a declared route was created with', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declared-identity')) + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.getByRole('button', { name: '编辑 Acme Gateway (acme-gateway)' }).click() + await dialog.getByText('自定义设置').click() + // The create card asked this route for a name and a protocol because + // nothing can default them; the editor reaches the same two fields rather + // than sending the user to settings.yaml for what only this route names. + const protocol = dialog.getByLabel('API 协议') + await protocol.waitFor({ timeout: 10_000 }) + expect(await protocol.inputValue()).toBe('openai-completions') + const name = dialog.getByLabel('显示名称', { exact: true }) + expect(await name.inputValue()).toBe('Acme Gateway') + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(DECLARED_EDIT_EXPECTED, snapshot, MODE) + + await protocol.selectOption('anthropic-messages') + await name.fill('Acme 网关') + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await expect.poll(async () => dialog.getByLabel('API 协议').count(), { timeout: 10_000 }).toBe(0) + // The adapter re-resolved the route under the new protocol and re-registered + // it under the new name: an unserviceable profile would have been refused + // at the write instead, and a rename that did not re-register would leave + // the old label on the row. + await dialog.getByText('Acme 网关', { exact: true }).first().waitFor({ timeout: 10_000 }) + const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') + expect(document).toContain('api: anthropic-messages') + expect(document).toContain('displayName: Acme 网关') + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it('confirms an identified provider deletion before removing its profile and key', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete')) const settingsDialog = page.getByRole('dialog', { name: '设置' }) @@ -243,8 +275,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ - 'configured.expected.md', 'declared.expected.md', 'delete.expected.md', - 'empty.expected.md', 'native-delete.expected.md', + 'configured.expected.md', 'declared-edit.expected.md', 'declared.expected.md', + 'delete.expected.md', 'empty.expected.md', 'native-delete.expected.md', ]) }) }) diff --git a/apps/web/tests/snapshots/models-settings/declared-edit.expected.md b/apps/web/tests/snapshots/models-settings/declared-edit.expected.md new file mode 100644 index 0000000000..e36c7ff2e8 --- /dev/null +++ b/apps/web/tests/snapshots/models-settings/declared-edit.expected.md @@ -0,0 +1,65 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "模型" [level=2] + - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - list: + - listitem: + - text: minimax-cn + - img "API 密钥已配置" + - button "编辑 minimax-cn": 编辑 + - button "删除 minimax-cn": 删除 + - listitem: + - text: Acme Gateway 自定义 + - button "编辑 Acme Gateway (acme-gateway)": 编辑 + - button "删除 Acme Gateway (acme-gateway)": 删除 + - text: Acme Gateway acme-gateway API 密钥 + - textbox "API 密钥": + - /placeholder: 输入 API 密钥,或留空使用环境认证 + - group: + - text: 自定义设置 显示名称 + - textbox "显示名称": + - /placeholder: acme-gateway + - text: Acme Gateway + - text: API 地址 + - textbox "API 地址": + - /placeholder: https://gateway.acme.example/v1 + - text: https://gateway.acme.example/v1 + - text: API 协议 + - combobox "API 协议": + - option "openai-completions" [selected] + - option "openai-responses" + - option "anthropic-messages" + - region "模型目录": + - text: 模型目录 已自定义模型目录 + - button "恢复默认模型" + - button "获取可用模型" + - textbox "模型 ID 1": + - /placeholder: 模型 ID + - text: acme-large + - textbox "显示名称 1": + - /placeholder: 显示名称 + - button "容量 1" + - button "删除模型 1" + - button "添加模型" + - button "取消" + - button "保存" + - button "添加提供方": + - img + - text: 添加提供方 + - button "添加自定义提供方": + - img + - text: 添加自定义提供方 diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index c95a0db6c0..e482a07615 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.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 docs/user/guide/providers.md -providers.md: 4667e54161e77a62d454f3f78a8164d5c79b78c8 -providers.zh.md: 15e0ccc826a5c285c71d4776793f8048c11f6254 +providers.md: fff2e9f66b1dcec2b549420ee43f908043983338 +providers.zh.md: a27adefdbb76c43d099982b0503a9adde139597f diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 4667e54161..fff2e9f66b 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -29,6 +29,8 @@ That holds for providers that authenticate with an API key. The catalog also car ![The custom provider form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png) +Every field but the Provider ID stays editable afterwards: **Edit** on the row reopens the same fields, with the display name and the protocol under 自定义设置 beside the base URL. Clearing the display name falls back to the Provider ID. The Provider ID itself is fixed: it names the route in requests, in `agent-default-model`, and in every session already logged, and it is the stem of the credential reference the page can never read back — so renaming a route means declaring a new provider and deleting the old one. + **Let the endpoint report its models.** Expand **Model catalog** and choose **Fetch available models**: the interrogation asks the endpoint **the form currently shows** — including a base URL edited but not yet saved and a key typed but not yet stored — and offers what it reports as candidates to pick from. A route the installed catalog describes is answered from that catalog with no network call. Adopting a candidate only writes rows into the draft; nothing is stored until you save. Keys are write-only: the page only ever holds a redacted descriptor, never the literal secret. A key you enter is stored in `$DSH_HOME/.credentials.yaml`, and the profile records only the variable name that references it. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 15e0ccc826..a27adefdbb 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -29,6 +29,8 @@ Harness 出厂自带 DeepSeek,同时预装了一个通用的多提供方适配 ![自定义提供方表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png) +除 Provider ID 外的每个字段之后都还能改:行上的**编辑**会重新打开这些字段,显示名称和协议在「自定义设置」里、紧挨着 API 地址;显示名称清空即退回 Provider ID。Provider ID 本身固定不可改:它在请求里、在 `agent-default-model` 里、在每一条已记录的会话里点名这条路由,同时还是凭据引用的词干,而页面永远读不回凭据值——因此重命名一条路由等于声明一个新提供方再把旧的删掉。 + **让端点自己报模型。** 展开**模型目录**后点**获取可用模型**,会按你**当前表单里**的地址与密钥去问端点(地址改了但没保存、密钥刚输入还没存下,都算数),把它报告的模型列成候选让你勾选。内置目录里的路由直接由目录作答,不联网。采纳只是把行写进草稿,最终还是你点保存才落盘。 密钥是只写的:页面拿到的永远是脱敏描述符,不是明文。写入的密钥存进 `$DSH_HOME/.credentials.yaml`,profile 里只记录引用它的变量名。 diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index c03e091a5e..bb7212c7ff 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.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 packages/client/ui-models/README.md -README.md: e0c5728d47e053df1934ef9eb69df3f8d985a4ec -README.zh.md: fe11e6cdd190e19d5b5dac6dc95950ba59a3172b +README.md: f86564151a3c3788c3002421cac465ef98177281 +README.zh.md: f94438db18a2034b4180f5b79ea521ad7037754c diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index e0c5728d47..f86564151a 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. -Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint) and each adapter's model catalog. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. @@ -28,7 +28,7 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name. +- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). Both families expose `baseURL` and model `id`/`name`/`contextWindow`/`maxTokens`; a hand-declared pi-ai route also exposes `displayName` and `api`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name. - **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them. - **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create. - **Interrogation covers OpenAI-compatible endpoints** — the adapter reads only that model-list response format, so a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index fe11e6cdd1..f94438db18 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,7 +4,7 @@ 模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),以及各适配器自己的模型目录。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 @@ -28,7 +28,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型, ## 已知限制与暂缓事项 -- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。DeepSeek 公开 `baseURL`、`reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;pi-ai 公开 `baseURL` 与 `reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。 +- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。两个家族都公开 `baseURL` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;手工声明的 pi-ai 路由还公开 `displayName` 与 `api`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。 - **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据和无法识别的目标会保留,因为该行无法证明自己拥有它们。 - **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。 - **询问只覆盖 OpenAI 兼容端点**:适配器只读这种模型列表响应格式,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。 diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index 5551b847fd..be4532444c 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -54,6 +54,8 @@ interface EditorTarget extends ProviderIdentity { settingsPath: readonly string[] /** Writable credential identified under this page's conventional reference. */ credentialRef?: string + /** The adapter reports this route as one it does not ship (see {@link ProviderEditorProps.declared}). */ + declared?: boolean } /** Values that vary around the shared provider-editor rendering. */ @@ -71,6 +73,7 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): provider={target.provider} displayName={target.displayName} settingsPath={target.settingsPath} + {...target.declared === true ? { declared: true } : {}} {...props} /> ) @@ -135,6 +138,10 @@ function targetOf(row: ProviderRow): EditorTarget { settingsNs: row.entry.settingsNs, settingsPath: row.entry.settingsPath, ...credentialRef === undefined ? {} : { credentialRef }, + // Absent is not "shipped": an adapter that answers nothing leaves the + // route-level fields only a declared route owns off the card, exactly as + // it leaves the custom tag off the row. + ...row.entry.declared === true ? { declared: true } : {}, } } diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 01978273ae..afae84a89f 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -7,7 +7,9 @@ * a key is entered; a blank key materializes a reference-free profile for * provider-native authentication); * the collapsed 自定义设置 area carries the per-family extras (`baseURL` for - * both families and DeepSeek's id/name/context-window model catalog). + * both families, DeepSeek's id/name/context-window model catalog, and the + * wire protocol of a pi-ai route the adapter does not ship — the field the + * create card asked that route for, editable here for the same reason). * Reasoning effort is deliberately absent: it is a per-MODEL capability, and * the models under one provider disagree about it, so a provider-scoped * control can only be set to a value some of them reject. The composer's @@ -30,7 +32,7 @@ import { import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' import { ModelListEditor } from './ModelListEditor.tsx' -import { deriveKeyRef, messageOf } from './store.ts' +import { deriveKeyRef, messageOf, protocolChoices } from './store.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -48,6 +50,14 @@ export interface ProviderEditorProps { displayName: string /** Hide the title row (the add card renders its own provider select). */ hideTitle?: boolean + /** + * Whether the adapter reports this route as hand-declared — absent from its + * installed catalog. Such a route carries its own wire protocol, chosen when + * it was created and editable here for the same reason; a catalog route's + * models each carry theirs, so a route-level protocol there could only + * override every one of them and the card does not offer it. + */ + declared?: boolean /** The owning namespace view (schema, layers, secrets). */ namespace: SettingsNamespaceView /** Path from the section root to this provider's profile. */ @@ -139,6 +149,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const disabled = props.readOnly || busy const layout = layoutOf(namespace.ns) const keyRef = refFor(namespace, settingsPath, props.provider) + // The same schema read the create card makes, so the choices offered here + // and there cannot drift apart: both come from the adapter's own `Config`. + const protocols = useMemo(() => protocolChoices(namespace), [namespace]) useEffect(() => { let stale = false @@ -289,11 +302,15 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { } /** - * The curated fields of one known adapter family. Taking the narrowed - * family as a parameter is what makes `EFFORT_FIELD` total here: an - * unknown namespace never reaches this body. + * The curated fields of one known adapter family. The family arrives + * narrowed so the per-family branches below are total: an unknown namespace + * renders the hint instead and never reaches this body. */ const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => { + // What a hand-declared route names for itself and nothing else can supply. + // A whole-section `llm-deepseek` profile is a composition fact with no + // per-route identity for its schema to carry, hence the family test. + const ownsIdentity = family === 'pi-ai' && props.declared === true const customModels = getPath(draft, ['models']) const modelsOverridden = hasPath(draft, ['models']) const models = modelDrafts(modelsOverridden ? customModels : inheritedModels()) @@ -334,6 +351,28 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
{t('customized')}
+ {/* The name and the protocol are the create card's two remaining + profile fields; a route the adapter ships defaults both from + its catalog entry and neither belongs on its card. */} + {ownsIdentity + ? ( +
+ {t('customDisplayName')} + { setField('displayName', event.target.value) }} + /> +
+ ) + : null}
{t('baseUrl')}
+ {/* The protocol sits beside the endpoint it describes, as it does + on the create card. */} + {ownsIdentity + ? ( +
+ {t('customApi')} + +
+ ) + : null} {/* Both families edit the same rows through the same contract; only the extras differ — DeepSeek's inherited capacities, pi-ai's endpoint interrogation. */} diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 9ff96cdebc..696e2258be 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -719,10 +719,103 @@ describe('hand-declared providers', () => { expect(fields()).toEqual([en.customRoute, en.customDisplayName, en.baseUrl, en.customApi, en.keyInput]) cleanup() + // A shipped route's models each carry their own protocol, so its editor + // offers no route-level protocol to override them with. await mountSection({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } }) openEditor('openai') fireEvent.click(screen.getByText(en.customized)) expect(fields()).toEqual([en.keyInput, en.baseUrl]) + cleanup() + + // A hand-declared route named its own protocol at creation, so editing it + // reaches the same field the create card asked for. + await mountSection({ + providers: { 'acme-gateway': { api: 'openai-completions', baseURL: 'https://gateway.acme.example/v1' } }, + declaredRoutes: ['acme-gateway'], + }) + openEditor('acme-gateway') + expect(fields()).toEqual([en.keyInput, en.customDisplayName, en.baseUrl, en.customApi]) + }) + + it('renames a declared route and falls back to its id when the name is cleared', async () => { + const { mutate } = await mountSection({ + providers: { + 'acme-gateway': { displayName: 'Acme Gateway', api: 'openai-completions', baseURL: 'https://acme.test/v1' }, + }, + declaredRoutes: ['acme-gateway'], + }) + openEditor('acme-gateway') + + const name = screen.getByLabelText(en.customDisplayName) + expect(name.value).toBe('Acme Gateway') + // The route id, not the stored name: it is what the route will be called + // the moment the field is cleared. + expect(name.placeholder).toBe('acme-gateway') + fireEvent.change(name, { target: { value: 'Acme 网关' } }) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + expect(firstMutate(mutate).ops) + .toEqual([{ op: 'set', path: ['providers', 'acme-gateway', 'displayName'], value: 'Acme 网关' }]) + }) + + it('drops the stored name rather than storing an empty one the adapter refuses', async () => { + // `llm-pi-ai` rejects an empty displayName outright, so clearing the field + // must unset it — which is also what the user means: use the route id. + const { mutate } = await mountSection({ + providers: { 'acme-gateway': { displayName: 'Acme Gateway', api: 'openai-completions' } }, + declaredRoutes: ['acme-gateway'], + }) + openEditor('acme-gateway') + + fireEvent.change(screen.getByLabelText(en.customDisplayName), { target: { value: ' ' } }) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + expect(firstMutate(mutate).ops) + .toEqual([{ op: 'unset', path: ['providers', 'acme-gateway', 'displayName'] }]) + }) + + it('edits the protocol a declared route was created with', async () => { + const { mutate } = await mountSection({ + providers: { + 'acme-gateway': { + apiKeyEnv: 'ACME_GATEWAY_API_KEY', + api: 'openai-completions', + baseURL: 'https://gateway.acme.example/v1', + models: [{ id: 'acme-large' }], + }, + }, + declaredRoutes: ['acme-gateway'], + }) + openEditor('acme-gateway') + + const protocol = screen.getByLabelText(en.customApi) + expect(protocol.value).toBe('openai-completions') + fireEvent.change(protocol, { target: { value: 'anthropic-messages' } }) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + // Only the protocol travels: every other stored field is unchanged, so no + // op restates it. + expect(firstMutate(mutate)).toEqual({ + ns: 'llm-pi-ai', + ops: [{ op: 'set', path: ['providers', 'acme-gateway', 'api'], value: 'anthropic-messages' }], + expectedRevision: 3, + }) + }) + + it('selects nothing for a declared route whose profile names no protocol', async () => { + // A route hand-written into settings.yaml with no model needs no protocol + // to resolve, so the card can be opened over one. The select must not read + // as if that route had picked its first choice. + await mountSection({ + providers: { 'acme-gateway': { baseURL: 'https://gateway.acme.example/v1' } }, + declaredRoutes: ['acme-gateway'], + }) + openEditor('acme-gateway') + + expect(screen.getByLabelText(en.customApi).value).toBe('') }) it('retries only the key after the profile landed, and reports the provider on cancel', async () => { From 23995f88cec8fef41a7545e67c3c22b26ed97eef Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 23:18:26 +0800 Subject: [PATCH 02/17] fix(web): give the protocol dropdowns the shared chevron MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `select.input` caps the control at 240px, and a `` that takes `.input` without `.selectInput`, so the next one cannot be added bare. --- ...-a-provider-from-the-models-page.i18n.yaml | 4 ++-- ...claring-a-provider-from-the-models-page.md | 2 +- ...ring-a-provider-from-the-models-page.zh.md | 2 +- .../src/client/CustomProviderCard.tsx | 2 +- .../ui-models/src/client/ProviderEditor.tsx | 2 +- .../client/ui-models/tests/styles.spec.ts | 23 +++++++++++++++++++ 6 files changed, 29 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml index bff5cc88fc..79aba9caa4 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.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/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md -2026-08-04-declaring-a-provider-from-the-models-page.md: 3c63254364e3803e9bddab2a5aad4c092e4f0426 -2026-08-04-declaring-a-provider-from-the-models-page.zh.md: c38b0387b3c7778b93fb43232c22a7acb24dc281 +2026-08-04-declaring-a-provider-from-the-models-page.md: dc0f6826be4c878c0ae5ffbca076f2b3037f1182 +2026-08-04-declaring-a-provider-from-the-models-page.zh.md: 5f25eff196869008d4e0d58776283c70de30e26a diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md index 3c63254364..dc0f6826be 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md @@ -48,4 +48,4 @@ What it costs: only pi-ai routes can be hand-declared, because `llm-pi-ai` is th ## Testing -`packages/client/ui-models/tests/provider-form.spec.tsx` drives the rendered page over a scripted wire face: adding, editing, and removing rows; a cleared optional field leaving the profile and a non-integer capacity never entering it; the interrogation carrying the edited endpoint, the unsaved key, and the profile's protocol; the picker's default selection, toggling, cancel, and adopt-keeps-tuned-rows; the empty, refused, and rejected-transport paths; the create writing one profile plus its credential; every gate on the create button; and the read-only posture. `protocolChoices` is covered against a schema that declares the union and one that does not. The editor's own field inventory is asserted per route kind — a catalog route stops at the key and the endpoint, a declared one also carries the protocol — along with the protocol edit travelling as a single `api` path op, a rename travelling as a single `displayName` one, a cleared name unsetting rather than storing the empty string the adapter refuses, and a declared profile naming no protocol selecting nothing rather than the first choice. `apps/web/tests/models-settings.e2e.ts` reopens the declared route through the real wire, captures the card, and asserts the chosen protocol and the new name both reach `settings.yaml` and the row re-registers under the rename. +`packages/client/ui-models/tests/provider-form.spec.tsx` drives the rendered page over a scripted wire face: adding, editing, and removing rows; a cleared optional field leaving the profile and a non-integer capacity never entering it; the interrogation carrying the edited endpoint, the unsaved key, and the profile's protocol; the picker's default selection, toggling, cancel, and adopt-keeps-tuned-rows; the empty, refused, and rejected-transport paths; the create writing one profile plus its credential; every gate on the create button; and the read-only posture. `protocolChoices` is covered against a schema that declares the union and one that does not. The stylesheet gate reads the package's own sources and fails any `` 都会失败——否则它保留的系统箭头会紧贴 `select.input` 所设 240px 上限的右边缘。编辑器自身的字段清单按路由种类各有断言——内置目录路由止于密钥与端点,已声明路由还带着协议——同时覆盖协议改动只以单条 `api` path op 传出、改名只以单条 `displayName` path op 传出、清空名称是取消设置而不是存入适配器会拒绝的空串,以及不写协议的已声明 profile 什么都不选中、而非选中第一个候选。`apps/web/tests/models-settings.e2e.ts` 经真实协议层重新打开这条已声明路由,捕获该卡片,并断言选定的协议与新名称都抵达了 `settings.yaml`、该行也以新名重新注册。 diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index b8b4459c77..f3c43fcc5a 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -236,7 +236,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
{t('customApi')} { expect(block('.rowCard')).not.toMatch(/\bbackground\s*:/) }) + it('gives every dropdown the shared chevron instead of the OS arrow', () => { + // `select.input` caps the control at 240px, and the OS arrow is painted + // flush inside that shrunk right edge — visibly tighter than every other + // control on the page. `.selectInput` is what removes it, reserves the + // right pad, and paints the shared chevron; a `
diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 7d75e1de11..561a5df494 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -80,6 +80,7 @@ export const en = { customRouteTaken: 'A provider already uses this ID.', customDisplayName: 'Display name', customApi: 'API protocol', + customApiUnset: 'Not selected', customNeedsBaseUrl: 'A custom provider needs a base URL.', customNeedsModels: 'A custom provider needs at least one model.', create: 'Create provider', @@ -173,6 +174,7 @@ export const zh: typeof en = { customRouteTaken: '已有提供方使用了这个 ID。', customDisplayName: '显示名称', customApi: 'API 协议', + customApiUnset: '未选择', customNeedsBaseUrl: '自定义提供方需要填写 API 地址。', customNeedsModels: '自定义提供方至少需要一个模型。', create: '创建提供方', diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 696e2258be..be0f198df7 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from 'schemastery' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' -import { ModelsSection } from '../src/client/ModelsSection.tsx' +import { ModelsSection, providerCopy } from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx' import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx' import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx' @@ -47,6 +47,7 @@ function fail(message: string, code: string): RpcResponse { function piAiNamespace( providers: Record, userProviders: Record = providers, + baseProviders: Record = {}, ): SettingsNamespaceView { return { ns: 'llm-pi-ai', @@ -54,7 +55,7 @@ function piAiNamespace( // `value` is the effective section; `user` is only the layer this page // writes. They differ whenever a composition `base` supplies something. value: { providers }, - base: {}, + base: { providers: baseProviders }, user: { providers: userProviders }, applies: 'live', secrets: [], @@ -66,6 +67,8 @@ function scriptedFace(options: { providers?: Record /** User layer, when it differs from the effective section. */ userProviders?: Record + /** Composition layer, for a route a `cordis.yml` pins rather than the page. */ + baseProviders?: Record /** Routes the adapter reports as hand-declared; the rest come back as shipped. */ declaredRoutes?: readonly string[] discover?: ReturnType @@ -75,7 +78,7 @@ function scriptedFace(options: { const providers = options.providers ?? { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy.example/v1' }, } - const namespace = piAiNamespace(providers, options.userProviders ?? providers) + const namespace = piAiNamespace(providers, options.userProviders ?? providers, options.baseProviders ?? {}) const discover = options.discover ?? vi.fn(() => Promise.resolve(ok({ models: [] }))) const mutate = options.mutate ?? vi.fn(() => Promise.resolve(ok(namespace))) const set = options.set ?? vi.fn(() => Promise.resolve(ok({}))) @@ -759,6 +762,55 @@ describe('hand-declared providers', () => { .toEqual([{ op: 'set', path: ['providers', 'acme-gateway', 'displayName'], value: 'Acme 网关' }]) }) + it('offers the composition name as what a cleared field falls back to', async () => { + // A `cordis.yml` can pin a route the catalog does not ship, so a declared + // route's profile is not always the page's own. The field edits the user + // layer alone, and clearing it restores the layer beneath — the + // composition name here, not the route id — so that is what it offers. + await mountSection({ + providers: { 'acme-gateway': { displayName: 'Acme (pinned)', api: 'openai-completions' } }, + baseProviders: { 'acme-gateway': { displayName: 'Acme (pinned)', api: 'openai-completions' } }, + userProviders: {}, + declaredRoutes: ['acme-gateway'], + }) + openEditor('acme-gateway') + + const name = screen.getByLabelText(en.customDisplayName) + expect(name.value).toBe('') + expect(name.placeholder).toBe('Acme (pinned)') + }) + + it('names the provider as the refreshed directory reports it after a rename', async () => { + // The status line used to echo the target captured when the card opened, + // which never lied while the name could not change. It can now. + const { face } = await mountSection({ + providers: { 'acme-gateway': { displayName: 'Acme Gateway', api: 'openai-completions' } }, + declaredRoutes: ['acme-gateway'], + }) + // The reload after the write answers with the renamed route, exactly as + // the adapter re-registers it. + face.llm.providers = vi.fn(() => Promise.resolve(ok({ + providers: [{ + provider: 'acme-gateway', + displayName: 'Acme 网关', + settingsNs: 'llm-pi-ai', + settingsPath: ['providers', 'acme-gateway'], + active: true, + declared: true, + }], + }))) + openEditor('acme-gateway') + + fireEvent.change(screen.getByLabelText(en.customDisplayName), { target: { value: 'Acme 网关' } }) + fireEvent.click(screen.getByText(en.apply)) + + const notice = await screen.findByRole('status') + expect(notice.textContent).toBe(providerCopy(en.savedProvider, { + provider: 'acme-gateway', + displayName: 'Acme 网关', + })) + }) + it('drops the stored name rather than storing an empty one the adapter refuses', async () => { // `llm-pi-ai` rejects an empty displayName outright, so clearing the field // must unset it — which is also what the user means: use the route id. From daf90bda7ed209af533da259d9a6d1678524d49a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:20:53 +0800 Subject: [PATCH 04/17] refactor(sdk): remove unreleased project toolchain --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 4 +- ...-executable-sdk-runtime-distribution.zh.md | 4 +- ...-24-single-harness-home-resolver.i18n.yaml | 4 +- ...2026-07-24-single-harness-home-resolver.md | 15 +- ...6-07-24-single-harness-home-resolver.zh.md | 15 +- .../2026-07-26-subprocess-seam.i18n.yaml | 4 +- .../2026-07-26-subprocess-seam.md | 6 +- .../2026-07-26-subprocess-seam.zh.md | 6 +- .../2026-07-29-package-regrouping.i18n.yaml | 4 +- .../2026-07-29-package-regrouping.md | 32 +- .../2026-07-29-package-regrouping.zh.md | 32 +- ...hared-feedback-telemetry-user-id.i18n.yaml | 4 +- ...08-07-shared-feedback-telemetry-user-id.md | 3 +- ...07-shared-feedback-telemetry-user-id.zh.md | 3 +- ...nt-persona-tool-filter-and-depth.i18n.yaml | 4 +- ...-subagent-persona-tool-filter-and-depth.md | 2 +- ...bagent-persona-tool-filter-and-depth.zh.md | 2 +- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 4 +- ...-21-continuable-background-subagents.zh.md | 4 +- ...ipt-sdk-and-sdk-subagent-backend.i18n.yaml | 4 +- ...typescript-sdk-and-sdk-subagent-backend.md | 4 +- ...escript-sdk-and-sdk-subagent-backend.zh.md | 4 +- ...7-31-telemetry-anonymous-user-id.i18n.yaml | 4 +- .../2026-07-31-telemetry-anonymous-user-id.md | 7 +- ...26-07-31-telemetry-anonymous-user-id.zh.md | 7 +- ...-20-remove-stdio-and-echo-agents.i18n.yaml | 4 +- ...2026-07-20-remove-stdio-and-echo-agents.md | 4 +- ...6-07-20-remove-stdio-and-echo-agents.zh.md | 4 +- .../2026-08-04-remove-tui-package.i18n.yaml | 4 +- .../2026-08-04-remove-tui-package.md | 6 +- .../2026-08-04-remove-tui-package.zh.md | 6 +- ...-11-remove-sdk-project-toolchain.i18n.yaml | 6 + ...2026-08-11-remove-sdk-project-toolchain.md | 41 + ...6-08-11-remove-sdk-project-toolchain.zh.md | 41 + ...sdk-project-editing-architecture.i18n.yaml | 6 - ...-07-15-sdk-project-editing-architecture.md | 129 --- ...-15-sdk-project-editing-architecture.zh.md | 129 --- ...026-07-14-sdk-developer-projects.i18n.yaml | 6 - .../2026-07-14-sdk-developer-projects.md | 167 --- .../2026-07-14-sdk-developer-projects.zh.md | 167 --- ...07-17-sdk-follow-up-capabilities.i18n.yaml | 6 - .../2026-07-17-sdk-follow-up-capabilities.md | 120 -- ...026-07-17-sdk-follow-up-capabilities.zh.md | 120 -- ...6-07-19-make-jsonrpc-directional.i18n.yaml | 4 +- .../2026-07-19-make-jsonrpc-directional.md | 6 +- .../2026-07-19-make-jsonrpc-directional.zh.md | 6 +- AGENTS.md | 2 +- THIRD_PARTY_NOTICES.md | 4 - docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 10 +- docs/config-catalog.zh.md | 10 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 26 +- docs/module-graph.zh.md | 26 +- docs/testing.i18n.yaml | 4 +- docs/testing.md | 2 +- docs/testing.zh.md | 2 +- knip.json | 28 +- packages/README.i18n.yaml | 4 +- packages/README.md | 2 +- packages/README.zh.md | 2 +- packages/boot/README.i18n.yaml | 4 +- packages/boot/README.md | 2 +- packages/boot/README.zh.md | 2 +- .../examples/jsonrpc-demo/README.i18n.yaml | 4 +- packages/examples/jsonrpc-demo/README.md | 2 +- packages/examples/jsonrpc-demo/README.zh.md | 2 +- packages/interaction/README.i18n.yaml | 4 +- packages/interaction/README.md | 2 +- packages/interaction/README.zh.md | 2 +- packages/scaffold/README.md | 17 - packages/scaffold/README.zh.md | 17 - packages/scaffold/create-sdk/README.i18n.yaml | 6 - packages/scaffold/create-sdk/README.md | 25 - packages/scaffold/create-sdk/README.zh.md | 25 - packages/scaffold/create-sdk/package.json | 49 - packages/scaffold/create-sdk/src/args.ts | 96 -- packages/scaffold/create-sdk/src/bin.ts | 10 - packages/scaffold/create-sdk/src/command.ts | 144 --- .../create-sdk/src/create-questions.ts | 204 ---- .../scaffold/create-sdk/src/create-wizard.ts | 233 ---- packages/scaffold/create-sdk/src/headless.ts | 98 -- packages/scaffold/create-sdk/src/index.ts | 7 - packages/scaffold/create-sdk/src/invariant.ts | 30 - .../create-sdk/src/project-scaffolder.ts | 41 - .../src/templates/assets/created.txt.tpl | 1 - .../templates/assets/install-question.txt.tpl | 1 - .../src/templates/assets/next-steps.txt.tpl | 5 - .../templates/assets/setup-failure.txt.tpl | 2 - .../src/templates/assets/usage.txt.tpl | 14 - .../src/templates/create-templates.ts | 59 - .../create-sdk/tests/built-artifacts.e2e.ts | 28 - .../create-sdk/tests/create.snapshot.ts | 391 ------- .../scaffold/create-sdk/tests/create.spec.ts | 678 ----------- .../create-sdk/tests/link-workspace.e2e.ts | 126 -- packages/scaffold/create-sdk/tsconfig.json | 19 - packages/scaffold/create-sdk/tsdown.config.ts | 14 - packages/scaffold/helper/README.i18n.yaml | 6 - packages/scaffold/helper/README.md | 29 - packages/scaffold/helper/README.zh.md | 29 - packages/scaffold/helper/package.json | 59 - .../helper/src/documents/cordis-yaml-file.ts | 190 ---- .../scaffold/helper/src/documents/env-file.ts | 111 -- .../helper/src/documents/package-json-file.ts | 169 --- .../src/documents/pnpm-workspace-file.ts | 96 -- .../helper/src/documents/project-file.ts | 64 -- .../helper/src/documents/tsconfig-file.ts | 89 -- .../helper/src/features/builtin/app.ts | 100 -- .../helper/src/features/builtin/helpers.ts | 122 -- .../helper/src/features/builtin/index.ts | 368 ------ .../helper/src/features/builtin/provider.ts | 108 -- .../helper/src/features/builtin/spine.ts | 59 - .../helper/src/features/define-feature.ts | 286 ----- .../src/features/feature-configurator.ts | 111 -- .../scaffold/helper/src/features/feature.ts | 345 ------ .../scaffold/helper/src/features/registry.ts | 87 -- .../scaffold/helper/src/features/resources.ts | 97 -- packages/scaffold/helper/src/ids.ts | 31 - packages/scaffold/helper/src/index.ts | 50 - packages/scaffold/helper/src/invariant.ts | 30 - .../src/package-managers/link-workspace.ts | 170 --- .../src/package-managers/package-manager.ts | 332 ------ .../src/plugins/local-plugin-blueprint.ts | 123 -- .../scaffold/helper/src/project/change-set.ts | 26 - .../src/project/npm-dependency-policy.ts | 62 - .../src/project/project-edit-session.ts | 628 ---------- .../helper/src/project/sdk-project.ts | 309 ----- packages/scaffold/helper/src/project/types.ts | 48 - .../src/questions/clack-nested-multiselect.ts | 304 ----- .../helper/src/questions/clack-prompt-port.ts | 127 --- .../src/questions/headless-prompt-port.ts | 97 -- .../helper/src/questions/prompt-port.ts | 124 -- .../scaffold/helper/src/questions/question.ts | 206 ---- .../helper/src/templates/assets/README.md.tpl | 27 - .../helper/src/templates/assets/gitignore.tpl | 5 - .../helper/src/templates/assets/index.ts.tpl | 20 - .../assets/local-plugin-tsdown.config.ts.tpl | 13 - .../src/templates/assets/local-plugin.ts.tpl | 9 - .../src/templates/assets/local-tool.ts.tpl | 17 - .../src/templates/assets/package.json.tpl | 14 - .../src/templates/assets/persona.txt.tpl | 3 - .../templates/assets/tsconfig.base.json.tpl | 14 - .../src/templates/assets/tsdown.config.ts.tpl | 13 - .../src/templates/assets/yarnrc.yml.tpl | 1 - .../helper/src/templates/project-template.ts | 110 -- .../helper/src/templates/template-assets.ts | 19 - .../helper/src/templates/text-template.ts | 44 - .../scaffold/helper/tests/documents.spec.ts | 430 ------- .../helper/tests/headless-prompt-port.spec.ts | 95 -- .../scaffold/helper/tests/project.spec.ts | 1009 ----------------- .../scaffold/helper/tests/questions.spec.ts | 484 -------- packages/scaffold/helper/tsconfig.json | 46 - packages/scaffold/helper/tsdown.config.ts | 14 - packages/scaffold/scripts/README.i18n.yaml | 6 - packages/scaffold/scripts/README.md | 37 - packages/scaffold/scripts/README.zh.md | 37 - packages/scaffold/scripts/package.json | 71 -- packages/scaffold/scripts/src/args.ts | 74 -- packages/scaffold/scripts/src/bin.ts | 10 - packages/scaffold/scripts/src/build.ts | 94 -- packages/scaffold/scripts/src/command.ts | 76 -- packages/scaffold/scripts/src/config.ts | 37 - .../scripts/src/config/config-workflow.ts | 241 ---- .../scaffold/scripts/src/create-plugin.ts | 91 -- .../scaffold/scripts/src/dev/tsdown-config.ts | 7 - packages/scaffold/scripts/src/index.ts | 11 - packages/scaffold/scripts/src/invariant.ts | 30 - .../scripts/src/local-plugin-loader-hooks.ts | 27 - packages/scaffold/scripts/src/runtime.ts | 137 --- packages/scaffold/scripts/src/telemetry.ts | 63 - .../assets/config-install-failure.txt.tpl | 2 - .../src/templates/assets/usage.txt.tpl | 8 - .../src/templates/dsh-sdk-templates.ts | 21 - .../__snapshots__/config.snapshot.ts.snap | 288 ----- .../scaffold/scripts/tests/config.snapshot.ts | 128 --- .../scaffold/scripts/tests/scripts.spec.ts | 650 ----------- packages/scaffold/scripts/tsconfig.json | 15 - packages/scaffold/scripts/tsdown.config.ts | 26 - packages/scaffold/telemetry/README.i18n.yaml | 6 - packages/scaffold/telemetry/README.md | 30 - packages/scaffold/telemetry/README.zh.md | 30 - packages/scaffold/telemetry/package.json | 49 - .../scaffold/telemetry/src/anonymous-id.ts | 93 -- .../telemetry/src/consent-resolver.ts | 125 -- packages/scaffold/telemetry/src/index.ts | 50 - packages/scaffold/telemetry/src/invariant.ts | 30 - packages/scaffold/telemetry/src/payload.ts | 82 -- packages/scaffold/telemetry/src/reporter.ts | 148 --- .../scaffold/telemetry/src/secret-redactor.ts | 208 ---- .../telemetry/tests/anonymous-id.spec.ts | 90 -- .../telemetry/tests/consent-resolver.spec.ts | 132 --- .../scaffold/telemetry/tests/payload.spec.ts | 69 -- .../scaffold/telemetry/tests/reporter.spec.ts | 134 --- .../telemetry/tests/secret-redactor.spec.ts | 176 --- packages/scaffold/telemetry/tsconfig.json | 15 - packages/{scaffold => sdk}/README.i18n.yaml | 6 +- packages/sdk/README.md | 11 + packages/sdk/README.zh.md | 11 + .../{scaffold => sdk}/client/README.i18n.yaml | 2 +- packages/{scaffold => sdk}/client/README.md | 0 .../{scaffold => sdk}/client/README.zh.md | 0 .../{scaffold => sdk}/client/package.json | 2 +- packages/{scaffold => sdk}/client/src/api.ts | 0 .../{scaffold => sdk}/client/src/client.ts | 0 .../{scaffold => sdk}/client/src/dispose.ts | 0 .../{scaffold => sdk}/client/src/index.ts | 0 .../{scaffold => sdk}/client/src/invariant.ts | 0 .../{scaffold => sdk}/client/src/types.ts | 0 .../client/tests/dispose.spec.ts | 0 .../client/tests/fake-runtime.ts | 0 .../client/tests/sdk-client.spec.ts | 0 .../{scaffold => sdk}/client/tsconfig.json | 0 .../protocol/README.i18n.yaml | 2 +- packages/{scaffold => sdk}/protocol/README.md | 0 .../{scaffold => sdk}/protocol/README.zh.md | 0 .../{scaffold => sdk}/protocol/package.json | 2 +- .../{scaffold => sdk}/protocol/src/index.ts | 0 .../protocol/src/invariant.ts | 0 .../protocol/src/transport.ts | 0 .../{scaffold => sdk}/protocol/src/types.ts | 0 .../protocol/tests/transport.spec.ts | 0 .../{scaffold => sdk}/protocol/tsconfig.json | 0 .../{scaffold => sdk}/server/README.i18n.yaml | 6 +- packages/{scaffold => sdk}/server/README.md | 2 +- .../{scaffold => sdk}/server/README.zh.md | 2 +- .../{scaffold => sdk}/server/package.json | 2 +- .../{scaffold => sdk}/server/src/index.ts | 0 .../{scaffold => sdk}/server/src/invariant.ts | 0 .../{scaffold => sdk}/server/src/server.ts | 0 .../server/tests/built-scope-carrier.e2e.ts | 2 +- .../server/tests/plugin-apply.spec.ts | 0 .../server/tests/plugin-shape.spec.ts | 0 .../server/tests/server.spec.ts | 0 .../{scaffold => sdk}/server/tsconfig.json | 0 packages/session/user-id/README.i18n.yaml | 4 +- packages/session/user-id/README.md | 4 +- packages/session/user-id/README.zh.md | 4 +- packages/session/user-id/src/index.ts | 3 +- .../subagent/subagent-codex/tsconfig.json | 2 +- .../subagent-dsh-sdk/README.i18n.yaml | 4 +- packages/subagent/subagent-dsh-sdk/README.md | 2 +- .../subagent/subagent-dsh-sdk/README.zh.md | 2 +- .../tests/subagent-dsh-sdk.spec.ts | 2 +- .../subagent/subagent-dsh-sdk/tsconfig.json | 4 +- pnpm-lock.yaml | 228 +--- scripts/check-workspace-constraints.ts | 6 - scripts/rescope-vendor.ts | 80 +- scripts/run-gates.ts | 2 +- .../verify-package-readme-model-experience.ts | 8 +- skills/create-dsh-sdk-project/SKILL.md | 57 - tsconfig.base.json | 15 +- tsconfig.host.json | 10 +- vitest.config.ts | 2 - vitest.snapshot.config.ts | 2 +- 256 files changed, 308 insertions(+), 15082 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.md create mode 100644 .agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.zh.md delete mode 100644 .agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.i18n.yaml delete mode 100644 .agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md delete mode 100644 .agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.zh.md delete mode 100644 .agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.i18n.yaml delete mode 100644 .agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md delete mode 100644 .agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.zh.md delete mode 100644 .agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml delete mode 100644 .agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md delete mode 100644 .agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md delete mode 100644 packages/scaffold/README.md delete mode 100644 packages/scaffold/README.zh.md delete mode 100644 packages/scaffold/create-sdk/README.i18n.yaml delete mode 100644 packages/scaffold/create-sdk/README.md delete mode 100644 packages/scaffold/create-sdk/README.zh.md delete mode 100644 packages/scaffold/create-sdk/package.json delete mode 100644 packages/scaffold/create-sdk/src/args.ts delete mode 100644 packages/scaffold/create-sdk/src/bin.ts delete mode 100644 packages/scaffold/create-sdk/src/command.ts delete mode 100644 packages/scaffold/create-sdk/src/create-questions.ts delete mode 100644 packages/scaffold/create-sdk/src/create-wizard.ts delete mode 100644 packages/scaffold/create-sdk/src/headless.ts delete mode 100644 packages/scaffold/create-sdk/src/index.ts delete mode 100644 packages/scaffold/create-sdk/src/invariant.ts delete mode 100644 packages/scaffold/create-sdk/src/project-scaffolder.ts delete mode 100644 packages/scaffold/create-sdk/src/templates/assets/created.txt.tpl delete mode 100644 packages/scaffold/create-sdk/src/templates/assets/install-question.txt.tpl delete mode 100644 packages/scaffold/create-sdk/src/templates/assets/next-steps.txt.tpl delete mode 100644 packages/scaffold/create-sdk/src/templates/assets/setup-failure.txt.tpl delete mode 100644 packages/scaffold/create-sdk/src/templates/assets/usage.txt.tpl delete mode 100644 packages/scaffold/create-sdk/src/templates/create-templates.ts delete mode 100644 packages/scaffold/create-sdk/tests/built-artifacts.e2e.ts delete mode 100644 packages/scaffold/create-sdk/tests/create.snapshot.ts delete mode 100644 packages/scaffold/create-sdk/tests/create.spec.ts delete mode 100644 packages/scaffold/create-sdk/tests/link-workspace.e2e.ts delete mode 100644 packages/scaffold/create-sdk/tsconfig.json delete mode 100644 packages/scaffold/create-sdk/tsdown.config.ts delete mode 100644 packages/scaffold/helper/README.i18n.yaml delete mode 100644 packages/scaffold/helper/README.md delete mode 100644 packages/scaffold/helper/README.zh.md delete mode 100644 packages/scaffold/helper/package.json delete mode 100644 packages/scaffold/helper/src/documents/cordis-yaml-file.ts delete mode 100644 packages/scaffold/helper/src/documents/env-file.ts delete mode 100644 packages/scaffold/helper/src/documents/package-json-file.ts delete mode 100644 packages/scaffold/helper/src/documents/pnpm-workspace-file.ts delete mode 100644 packages/scaffold/helper/src/documents/project-file.ts delete mode 100644 packages/scaffold/helper/src/documents/tsconfig-file.ts delete mode 100644 packages/scaffold/helper/src/features/builtin/app.ts delete mode 100644 packages/scaffold/helper/src/features/builtin/helpers.ts delete mode 100644 packages/scaffold/helper/src/features/builtin/index.ts delete mode 100644 packages/scaffold/helper/src/features/builtin/provider.ts delete mode 100644 packages/scaffold/helper/src/features/builtin/spine.ts delete mode 100644 packages/scaffold/helper/src/features/define-feature.ts delete mode 100644 packages/scaffold/helper/src/features/feature-configurator.ts delete mode 100644 packages/scaffold/helper/src/features/feature.ts delete mode 100644 packages/scaffold/helper/src/features/registry.ts delete mode 100644 packages/scaffold/helper/src/features/resources.ts delete mode 100644 packages/scaffold/helper/src/ids.ts delete mode 100644 packages/scaffold/helper/src/index.ts delete mode 100644 packages/scaffold/helper/src/invariant.ts delete mode 100644 packages/scaffold/helper/src/package-managers/link-workspace.ts delete mode 100644 packages/scaffold/helper/src/package-managers/package-manager.ts delete mode 100644 packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts delete mode 100644 packages/scaffold/helper/src/project/change-set.ts delete mode 100644 packages/scaffold/helper/src/project/npm-dependency-policy.ts delete mode 100644 packages/scaffold/helper/src/project/project-edit-session.ts delete mode 100644 packages/scaffold/helper/src/project/sdk-project.ts delete mode 100644 packages/scaffold/helper/src/project/types.ts delete mode 100644 packages/scaffold/helper/src/questions/clack-nested-multiselect.ts delete mode 100644 packages/scaffold/helper/src/questions/clack-prompt-port.ts delete mode 100644 packages/scaffold/helper/src/questions/headless-prompt-port.ts delete mode 100644 packages/scaffold/helper/src/questions/prompt-port.ts delete mode 100644 packages/scaffold/helper/src/questions/question.ts delete mode 100644 packages/scaffold/helper/src/templates/assets/README.md.tpl delete mode 100644 packages/scaffold/helper/src/templates/assets/gitignore.tpl delete mode 100644 packages/scaffold/helper/src/templates/assets/index.ts.tpl delete mode 100644 packages/scaffold/helper/src/templates/assets/local-plugin-tsdown.config.ts.tpl delete mode 100644 packages/scaffold/helper/src/templates/assets/local-plugin.ts.tpl delete mode 100644 packages/scaffold/helper/src/templates/assets/local-tool.ts.tpl delete mode 100644 packages/scaffold/helper/src/templates/assets/package.json.tpl delete mode 100644 packages/scaffold/helper/src/templates/assets/persona.txt.tpl delete mode 100644 packages/scaffold/helper/src/templates/assets/tsconfig.base.json.tpl delete mode 100644 packages/scaffold/helper/src/templates/assets/tsdown.config.ts.tpl delete mode 100644 packages/scaffold/helper/src/templates/assets/yarnrc.yml.tpl delete mode 100644 packages/scaffold/helper/src/templates/project-template.ts delete mode 100644 packages/scaffold/helper/src/templates/template-assets.ts delete mode 100644 packages/scaffold/helper/src/templates/text-template.ts delete mode 100644 packages/scaffold/helper/tests/documents.spec.ts delete mode 100644 packages/scaffold/helper/tests/headless-prompt-port.spec.ts delete mode 100644 packages/scaffold/helper/tests/project.spec.ts delete mode 100644 packages/scaffold/helper/tests/questions.spec.ts delete mode 100644 packages/scaffold/helper/tsconfig.json delete mode 100644 packages/scaffold/helper/tsdown.config.ts delete mode 100644 packages/scaffold/scripts/README.i18n.yaml delete mode 100644 packages/scaffold/scripts/README.md delete mode 100644 packages/scaffold/scripts/README.zh.md delete mode 100644 packages/scaffold/scripts/package.json delete mode 100644 packages/scaffold/scripts/src/args.ts delete mode 100644 packages/scaffold/scripts/src/bin.ts delete mode 100644 packages/scaffold/scripts/src/build.ts delete mode 100644 packages/scaffold/scripts/src/command.ts delete mode 100644 packages/scaffold/scripts/src/config.ts delete mode 100644 packages/scaffold/scripts/src/config/config-workflow.ts delete mode 100644 packages/scaffold/scripts/src/create-plugin.ts delete mode 100644 packages/scaffold/scripts/src/dev/tsdown-config.ts delete mode 100644 packages/scaffold/scripts/src/index.ts delete mode 100644 packages/scaffold/scripts/src/invariant.ts delete mode 100644 packages/scaffold/scripts/src/local-plugin-loader-hooks.ts delete mode 100644 packages/scaffold/scripts/src/runtime.ts delete mode 100644 packages/scaffold/scripts/src/telemetry.ts delete mode 100644 packages/scaffold/scripts/src/templates/assets/config-install-failure.txt.tpl delete mode 100644 packages/scaffold/scripts/src/templates/assets/usage.txt.tpl delete mode 100644 packages/scaffold/scripts/src/templates/dsh-sdk-templates.ts delete mode 100644 packages/scaffold/scripts/tests/__snapshots__/config.snapshot.ts.snap delete mode 100644 packages/scaffold/scripts/tests/config.snapshot.ts delete mode 100644 packages/scaffold/scripts/tests/scripts.spec.ts delete mode 100644 packages/scaffold/scripts/tsconfig.json delete mode 100644 packages/scaffold/scripts/tsdown.config.ts delete mode 100644 packages/scaffold/telemetry/README.i18n.yaml delete mode 100644 packages/scaffold/telemetry/README.md delete mode 100644 packages/scaffold/telemetry/README.zh.md delete mode 100644 packages/scaffold/telemetry/package.json delete mode 100644 packages/scaffold/telemetry/src/anonymous-id.ts delete mode 100644 packages/scaffold/telemetry/src/consent-resolver.ts delete mode 100644 packages/scaffold/telemetry/src/index.ts delete mode 100644 packages/scaffold/telemetry/src/invariant.ts delete mode 100644 packages/scaffold/telemetry/src/payload.ts delete mode 100644 packages/scaffold/telemetry/src/reporter.ts delete mode 100644 packages/scaffold/telemetry/src/secret-redactor.ts delete mode 100644 packages/scaffold/telemetry/tests/anonymous-id.spec.ts delete mode 100644 packages/scaffold/telemetry/tests/consent-resolver.spec.ts delete mode 100644 packages/scaffold/telemetry/tests/payload.spec.ts delete mode 100644 packages/scaffold/telemetry/tests/reporter.spec.ts delete mode 100644 packages/scaffold/telemetry/tests/secret-redactor.spec.ts delete mode 100644 packages/scaffold/telemetry/tsconfig.json rename packages/{scaffold => sdk}/README.i18n.yaml (57%) create mode 100644 packages/sdk/README.md create mode 100644 packages/sdk/README.zh.md rename packages/{scaffold => sdk}/client/README.i18n.yaml (80%) rename packages/{scaffold => sdk}/client/README.md (100%) rename packages/{scaffold => sdk}/client/README.zh.md (100%) rename packages/{scaffold => sdk}/client/package.json (96%) rename packages/{scaffold => sdk}/client/src/api.ts (100%) rename packages/{scaffold => sdk}/client/src/client.ts (100%) rename packages/{scaffold => sdk}/client/src/dispose.ts (100%) rename packages/{scaffold => sdk}/client/src/index.ts (100%) rename packages/{scaffold => sdk}/client/src/invariant.ts (100%) rename packages/{scaffold => sdk}/client/src/types.ts (100%) rename packages/{scaffold => sdk}/client/tests/dispose.spec.ts (100%) rename packages/{scaffold => sdk}/client/tests/fake-runtime.ts (100%) rename packages/{scaffold => sdk}/client/tests/sdk-client.spec.ts (100%) rename packages/{scaffold => sdk}/client/tsconfig.json (100%) rename packages/{scaffold => sdk}/protocol/README.i18n.yaml (80%) rename packages/{scaffold => sdk}/protocol/README.md (100%) rename packages/{scaffold => sdk}/protocol/README.zh.md (100%) rename packages/{scaffold => sdk}/protocol/package.json (96%) rename packages/{scaffold => sdk}/protocol/src/index.ts (100%) rename packages/{scaffold => sdk}/protocol/src/invariant.ts (100%) rename packages/{scaffold => sdk}/protocol/src/transport.ts (100%) rename packages/{scaffold => sdk}/protocol/src/types.ts (100%) rename packages/{scaffold => sdk}/protocol/tests/transport.spec.ts (100%) rename packages/{scaffold => sdk}/protocol/tsconfig.json (100%) rename packages/{scaffold => sdk}/server/README.i18n.yaml (56%) rename packages/{scaffold => sdk}/server/README.md (95%) rename packages/{scaffold => sdk}/server/README.zh.md (95%) rename packages/{scaffold => sdk}/server/package.json (97%) rename packages/{scaffold => sdk}/server/src/index.ts (100%) rename packages/{scaffold => sdk}/server/src/invariant.ts (100%) rename packages/{scaffold => sdk}/server/src/server.ts (100%) rename packages/{scaffold => sdk}/server/tests/built-scope-carrier.e2e.ts (98%) rename packages/{scaffold => sdk}/server/tests/plugin-apply.spec.ts (100%) rename packages/{scaffold => sdk}/server/tests/plugin-shape.spec.ts (100%) rename packages/{scaffold => sdk}/server/tests/server.spec.ts (100%) rename packages/{scaffold => sdk}/server/tsconfig.json (100%) delete mode 100644 skills/create-dsh-sdk-project/SKILL.md diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index a48de33ce2..5c33e5ee85 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: c2b6d9ff1825915e39738bf8f782c302ecfc1d0d -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: d7758a77083e07b1d2cac99ae2be3f15e6edd2dc +2026-07-10-single-file-executable-sdk-runtime-distribution.md: d7c0d3c602668c0f4a0a4c7d21d54d013fac0e28 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: fadfc8cdcb0651665b965126ab0b25ed4218a533 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index c2b6d9ff18..d7c0d3c602 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -23,11 +23,11 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay expected outputs, `$DSH_SNAPSHOT`); this document says "VFS" for the former. -### The serving surface is a plugin: the two packages ui/jsonrpc + examples/jsonrpc-demo +### The serving surface is a plugin: the two packages sdk/server + examples/jsonrpc-demo The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `acp/acp` + `examples/acp-demo` pattern — the serving surface is itself a plugin: -- [`packages/scaffold/server`](../../../../packages/scaffold/server/README.md) (`@deepseek-ai/dsh-jsonrpc`): the pure protocol plugin; on apply it mounts `HarnessSdkServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering and flushing the `shutdown` response it disposes the root runtime so persistence drains, then `exit(0)`; an HMR-style unload only stops the service without exiting the process). +- [`packages/sdk/server`](../../../../packages/sdk/server/README.md) (`@deepseek-ai/dsh-jsonrpc`): the pure protocol plugin; on apply it mounts `HarnessSdkServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering and flushing the `shutdown` response it disposes the root runtime so persistence drains, then `exit(0)`; an HMR-style unload only stops the service without exiting the process). - [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md) (`@deepseek-ai/dsh-jsonrpc-demo`): a thin app bin — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-jsonrpc` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the bin (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130). Config discovery has two channels and fails loudly when both are missing: the `DSH_CORDIS_CONFIG` environment variable first (the SDK client convention), then an argv positional argument; no default path and no built-in fallback whatsoever — "the plugins actually booted are decided by an external cordis.yml" is a hard semantic. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index d7758a7708..fadfc8cdcb 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -23,11 +23,11 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的「快照」(ACP 回放预期输出、`$DSH_SNAPSHOT`)无关,本文用「VFS」指前者。 -### 对外服务接口也是插件:ui/jsonrpc + examples/jsonrpc-demo 两个包 +### 对外服务接口也是插件:sdk/server + examples/jsonrpc-demo 两个包 确定性协议实现(`server.ts` / `transport.ts`)按 `acp/acp` + `examples/acp-demo` 的既有模式落为两包——对外服务接口本身也是插件: -- [`packages/scaffold/server`](../../../../packages/scaffold/server/README.md)(`@deepseek-ai/dsh-jsonrpc`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkServer` 与按行传输的 JSON-RPC 层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose(资源释放),让待处理的持久化操作完成,再调用 `exit(0)`;HMR 式卸载只停止服务,不退出进程)。 +- [`packages/sdk/server`](../../../../packages/sdk/server/README.md)(`@deepseek-ai/dsh-jsonrpc`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkServer` 与按行传输的 JSON-RPC 层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose(资源释放),让待处理的持久化操作完成,再调用 `exit(0)`;HMR 式卸载只停止服务,不退出进程)。 - [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md)(`@deepseek-ai/dsh-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-jsonrpc` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。 配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量(SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——「实际启动的插件由外部 `cordis.yml` 决定」是硬语义。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml index 86745008b2..f889e8fb3c 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.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/architecture/2026-07-24-single-harness-home-resolver.md -2026-07-24-single-harness-home-resolver.md: 1e128911dce0b50dc95722c6edd82cb322d18f3d -2026-07-24-single-harness-home-resolver.zh.md: 258a16e6ca139da191db564aae40d23d05ca46b7 +2026-07-24-single-harness-home-resolver.md: dd1bc3ac59091d0b4405b164ddcb3ba00929fd9e +2026-07-24-single-harness-home-resolver.zh.md: a203d33186ec102f9a9380964ad9daf8ab6cc215 diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md index 1e128911dc..dd1bc3ac59 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md @@ -6,13 +6,12 @@ English | [中文](2026-07-24-single-harness-home-resolver.zh.md) ## Problem -The harness had three inconsistent conventions for "where does DeepSeek Harness user data live": +The harness had two inconsistent conventions for "where does DeepSeek Harness user data live": - `@deepseek-ai/dsh-home` resolved `configured ?? $DSH_HOME ?? ~/.dsh`. - `@deepseek-ai/dsh-paths` shipped a **second** `resolveDshHome` with the same precedence plus tilde expansion — a near-duplicate of `dsh-home` that no gate flagged because the two lived in different packages and had already drifted (only one expanded tildes). -- `@deepseek-ai/dsh-telemetry`'s `globalConfigDir` used a *different* policy entirely: `DSH_CONFIG_HOME > $XDG_CONFIG_HOME/deepseek-harness > %APPDATA%/deepseek-harness > ~/.config/deepseek-harness`. -So most of the product parked everything under one `~/.dsh` root while telemetry alone stored its anonymous id elsewhere, under a `deepseek-harness` namespace that contradicts the repo-wide `dsh` shorthand (`DSH_HOME`, `@deepseek-ai/dsh-*`, `~/.dsh`). Two resolvers plus a divergent third policy means no single home fact. +Two resolvers for the same cross-cutting fact meant there was no single home policy. ## Decision @@ -22,20 +21,16 @@ One resolver owns the harness home, in `@deepseek-ai/dsh-paths`, single-root: explicit configured path > $DSH_HOME > ~/.dsh ``` -An empty or whitespace-only `$DSH_HOME` is treated as unset, matching the guard telemetry's old resolver carried: without it `resolve('')` would silently place the home at the current working directory. The harness keeps all user data under one root; there is no XDG config/data/cache split. `dshHomePath(...segments)` joins deployment-owned children onto that root, and `dsh-app-boot` exposes it to Loader `!!js` config expressions before mounting entries, so shipped compositions derive `sessions` and `storages` without copying the resolver. `dshHomeDisplay()` names a resolved root symbolically for user-facing paths — `~/.dsh` for the default home, `$DSH_HOME` for any configured home — so the user-global `AGENTS.md` label never leaks an absolute machine path. It replaces workspace-context's bespoke default-vs-`$DSH_HOME` check. +An empty or whitespace-only `$DSH_HOME` is treated as unset; otherwise `resolve('')` would silently place the home at the current working directory. The harness keeps all user data under one root; there is no XDG config/data/cache split. `dshHomePath(...segments)` joins deployment-owned children onto that root, and `dsh-app-boot` exposes it to Loader `!!js` config expressions before mounting entries, so shipped compositions derive `sessions` and `storages` without copying the resolver. `dshHomeDisplay()` names a resolved root symbolically for user-facing paths — `~/.dsh` for the default home, `$DSH_HOME` for any configured home — so the user-global `AGENTS.md` label never leaks an absolute machine path. It replaces workspace-context's bespoke default-vs-`$DSH_HOME` check. -`@deepseek-ai/dsh-home` is deleted. Its three importers (`dsh-tool-bash`, `dsh-skill-local`, `dsh-agent-spine-demo`) now import `resolveDshHome` from `dsh-paths`. `dsh-telemetry`'s `globalConfigDir` delegates to `resolveDshHome`, dropping its second resolver, the `DSH_CONFIG_HOME` override, the XDG/`%APPDATA%` branches, and the `deepseek-harness` namespace; the anonymous id now lives directly under the harness home. +`@deepseek-ai/dsh-home` is deleted. Its three importers (`dsh-tool-bash`, `dsh-skill-local`, `dsh-agent-spine-demo`) import `resolveDshHome` from `dsh-paths`. ## Alternatives considered **Leave the two `resolveDshHome` copies in place.** They had already drifted (one expands tildes, one didn't) and encode the same cross-cutting fact twice. Consolidation is the point of the `util/` layer; a duplicate resolver is a latent divergence bug. -**Adopt XDG (honor `$XDG_CONFIG_HOME`, or split config/data/cache into separate trees).** Considered and dropped in favor of one obvious root. A single `$DSH_HOME || ~/.dsh` ground truth matches `~/.claude` / `~/.aws`, needs no per-kind reclassification of every `~/.dsh` consumer, and leaves no resolver asymmetry to reconcile. Telemetry aligning onto the same root — rather than keeping its own XDG path — is precisely the divergence this removes. - -**Keep telemetry's own config dir.** Its `deepseek-harness` namespace and separate XDG policy were the lone exception to the `dsh`/`~/.dsh` convention. Folding it onto the shared resolver is what makes "one home fact" true. The cost is that the anonymous id becomes scoped to `$DSH_HOME` rather than the machine: a project that points `DSH_HOME` at a repo-local path (or a command that loads a project `.env` before telemetry) gets a home-local id, so the id counts harness homes, not machines. This is accepted as the intended meaning of single-root — a relocated `$DSH_HOME` moves *all* harness state, telemetry identity included — and the module contract is stated as per-harness-home rather than per-machine. A machine-global identity that ignored `$DSH_HOME` would reintroduce exactly the second home policy this decision removes. +**Adopt XDG (honor `$XDG_CONFIG_HOME`, or split config/data/cache into separate trees).** Considered and dropped in favor of one obvious root. A single `$DSH_HOME || ~/.dsh` ground truth matches `~/.claude` / `~/.aws`, needs no per-kind reclassification of every `~/.dsh` consumer, and leaves no resolver asymmetry to reconcile. ## Consequences - One home fact, one resolver. `dsh-paths` is the sole owner; the `util/` group loses the `home` package. -- Telemetry's anonymous id moves from `~/.config/deepseek-harness/telemetry.json` to the harness home (`~/.dsh/telemetry.json` by default). Under the pre-release "backends reject old formats" stance this needs no migration: an orphaned old id simply regenerates once, and the id is anonymous by construction. -- Telemetry drops Windows `%APPDATA%` handling. `resolveDshHome` uses `os.homedir()`, which is correct on Windows; the harness does not special-case `%APPDATA%` for its single root. diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md index 258a16e6ca..a203d33186 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md @@ -6,13 +6,12 @@ Status: implemented ## 问题 -对于"DeepSeek Harness 用户数据存放在哪里",harness 里存在三套互不一致的约定: +对于"DeepSeek Harness 用户数据存放在哪里",harness 里存在两套互不一致的约定: - `@deepseek-ai/dsh-home` 按 `configured ?? $DSH_HOME ?? ~/.dsh` 解析。 - `@deepseek-ai/dsh-paths` 又提供了**第二个** `resolveDshHome`,优先级相同但额外做了波浪号展开——它几乎是 `dsh-home` 的重复实现,却没有任何门禁发现,因为两者分属不同的包,而且早已漂移(只有一个会展开波浪号)。 -- `@deepseek-ai/dsh-telemetry` 的 `globalConfigDir` 采用了*完全不同*的策略:`DSH_CONFIG_HOME > $XDG_CONFIG_HOME/deepseek-harness > %APPDATA%/deepseek-harness > ~/.config/deepseek-harness`。 -于是产品的大部分内容都停放在同一个 `~/.dsh` 根目录下,唯独 telemetry 把匿名 id 存到别处,落在一个 `deepseek-harness` 命名空间里,这与全仓库通行的 `dsh` 简写(`DSH_HOME`、`@deepseek-ai/dsh-*`、`~/.dsh`)相冲突。两个解析器再加上一个各行其是的第三套策略,意味着不存在单一的 home 事实。 +同一条横切事实有两个解析器,意味着不存在单一的 home 策略。 ## 决策 @@ -22,20 +21,16 @@ Status: implemented explicit configured path > $DSH_HOME > ~/.dsh ``` -空或仅含空白的 `$DSH_HOME` 被当作未设置处理,这与 telemetry 旧解析器所带的保护一致:若无此保护,`resolve('')` 会悄悄把 home 落在当前工作目录。harness 把所有用户数据都放在同一个根目录下;不存在 XDG 的 config/data/cache 拆分。`dshHomePath(...segments)` 将部署负责的子路径拼接到该根目录下,`dsh-app-boot` 在挂载条目前向 Loader `!!js` 配置表达式暴露它,因此出厂组合无需复制解析器即可派生 `sessions` 和 `storages`。`dshHomeDisplay()` 为面向用户的路径以符号形式命名已解析的根目录——默认 home 显示为 `~/.dsh`,任何已配置的 home 显示为 `$DSH_HOME`——这样面向用户全局的 `AGENTS.md` 标签就绝不会泄露机器上的绝对路径。它取代了 workspace-context 中自定义的"默认值 vs `$DSH_HOME`"判断。 +空或仅含空白的 `$DSH_HOME` 被当作未设置处理;否则,`resolve('')` 会悄悄把 home 落在当前工作目录。harness 把所有用户数据都放在同一个根目录下;不存在 XDG 的 config/data/cache 拆分。`dshHomePath(...segments)` 将部署负责的子路径拼接到该根目录下,`dsh-app-boot` 在挂载条目前向 Loader `!!js` 配置表达式暴露它,因此出厂组合无需复制解析器即可派生 `sessions` 和 `storages`。`dshHomeDisplay()` 为面向用户的路径以符号形式命名已解析的根目录——默认 home 显示为 `~/.dsh`,任何已配置的 home 显示为 `$DSH_HOME`——这样面向用户全局的 `AGENTS.md` 标签就绝不会泄露机器上的绝对路径。它取代了 workspace-context 中自定义的"默认值 vs `$DSH_HOME`"判断。 -`@deepseek-ai/dsh-home` 被删除。它的三个引用方(`dsh-tool-bash`、`dsh-skill-local`、`dsh-agent-spine-demo`)现在从 `dsh-paths` 导入 `resolveDshHome`。`dsh-telemetry` 的 `globalConfigDir` 转而委托给 `resolveDshHome`,去掉了它的第二个解析器、`DSH_CONFIG_HOME` 覆盖项、XDG/`%APPDATA%` 分支以及 `deepseek-harness` 命名空间;匿名 id 现在直接存放在 harness home 之下。 +`@deepseek-ai/dsh-home` 被删除。它的三个引用方(`dsh-tool-bash`、`dsh-skill-local`、`dsh-agent-spine-demo`)从 `dsh-paths` 导入 `resolveDshHome`。 ## 备选方案 **保留两份 `resolveDshHome` 副本。** 它们早已漂移(一个展开波浪号,一个不展开),并把同一条横切事实编码了两遍。`util/` 层的意义正是在于合并,重复的解析器是一个潜在的分歧 bug。 -**采用 XDG(遵从 `$XDG_CONFIG_HOME`,或把 config/data/cache 拆分到各自的目录树)。** 经过考虑后放弃,转而采用一个显而易见的根目录。单一的 `$DSH_HOME || ~/.dsh` 基准事实与 `~/.claude` / `~/.aws` 一致,无需对每个 `~/.dsh` 消费方按类别重新归类,也不留下任何需要协调的解析器不对称。telemetry 对齐到同一根目录——而不是保留自己的 XDG 路径——正是本决策所要消除的那种分歧。 - -**保留 telemetry 自己的 config 目录。** 它的 `deepseek-harness` 命名空间和独立的 XDG 策略是唯一违背 `dsh`/`~/.dsh` 约定的例外。把它折叠到共享解析器上,才让"单一 home 事实"成真。代价是匿名 id 的作用域从机器变成了 `$DSH_HOME`:若某个项目把 `DSH_HOME` 指向仓库本地路径(或某条命令在 telemetry 之前加载了项目的 `.env`),得到的就是 home 本地的 id,因此该 id 统计的是 harness home,而非机器。这被接受为单一根目录的应有含义——重定位 `$DSH_HOME` 会移动*全部* harness 状态,telemetry 身份也在其中——模块约定据此表述为 per-harness-home 而非 per-machine。一个忽略 `$DSH_HOME` 的机器级全局身份,恰恰会重新引入本决策所要消除的那第二套 home 策略。 +**采用 XDG(遵从 `$XDG_CONFIG_HOME`,或把 config/data/cache 拆分到各自的目录树)。** 经过考虑后放弃,转而采用一个显而易见的根目录。单一的 `$DSH_HOME || ~/.dsh` 基准事实与 `~/.claude` / `~/.aws` 一致,无需对每个 `~/.dsh` 消费方按类别重新归类,也不留下任何需要协调的解析器不对称。 ## 影响 - 单一 home 事实,单一解析器。`dsh-paths` 是唯一归属方;`util/` 组失去了 `home` 包。 -- telemetry 的匿名 id 从 `~/.config/deepseek-harness/telemetry.json` 移到 harness home(默认为 `~/.dsh/telemetry.json`)。在预发布的"后端拒绝旧格式"立场下,这无需迁移:一个遗留的旧 id 只会重新生成一次,而且该 id 本就是匿名构造的。 -- telemetry 去掉了 Windows `%APPDATA%` 处理。`resolveDshHome` 使用 `os.homedir()`,这在 Windows 上是正确的;harness 不会为它的单一根目录对 `%APPDATA%` 做特殊处理。 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml index dabe24e211..98fb649660 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.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/architecture/2026-07-26-subprocess-seam.md -2026-07-26-subprocess-seam.md: 7899af56259d39a4e3f924bcba673c8efa99c682 -2026-07-26-subprocess-seam.zh.md: d9cbc0ebf33bc785984b0a9bd58fa8ebc6aab436 +2026-07-26-subprocess-seam.md: f43b55b0b760c2aabe317abacdb800b755de059b +2026-07-26-subprocess-seam.zh.md: df8c3ddab80933882bf3587a9f01e019eb721a87 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md index 7899af5625..f43b55b0b7 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md @@ -17,11 +17,11 @@ A new `subprocess/` capability family owns "run and manage a process"; the bash - **`dsh-bash-local` (Consumer)** — `inject: ['subprocess']`; maps each resolved `BashExecSpec` onto a `SubprocessSpawnSpec` (`['bash', '-c', command]`), keeps its config, `resolve()` defaulting, fused-deadline `timedOut`/`aborted` classification, the `[stderr]`-marked background read merge with its consuming cursor, and the `onProcessDone` subclass hook. `dsh-bash-sandbox` is unchanged apart from redeclaring the inherited inject; it still wraps at the command-string level and re-enters the inherited spawn path. - **`dsh-bash` (Service Definition)** — re-exports the moved vocabulary from `dsh-subprocess`, so no bash Consumer changes an import; `BashExecRequest`/`BashExecSpec`/`BashProcess` and the sandbox facts remain bash-owned. -Every composition that loads a bash executor now also loads `@deepseek-ai/dsh-subprocess-local` (CLI, examples, python bundled runtime, create-sdk's bash feature resources, inline test configs). +Every composition that loads a bash executor also loads `@deepseek-ai/dsh-subprocess-local` (CLI, examples, the Python bundled runtime, and inline test configs). Background-process lifetime moved from the executor to the subprocess service: the executor no longer retains a live-process set, so an executor reload leaves background work running and readable, and composition teardown (the service's disposal) remains the kill-and-join boundary. One behavioral contract shifted with it: a background spawn failure can no longer be buffered as fake stderr inside the plumbing (the service rejects `done` and buffers nothing for a process that never ran), so the executor injects the `spawn failed: …` note into exactly one `readOutput()` delta. -Observed stream and lifecycle needs then moved the eligible process consumers onto the seam: LSP uses piped protocol streams plus a collected stderr tail; the ACP backend uses piped ndjson, inherited stderr, and a consumer-owned stdin-EOF disposal ladder; PTY uses `spawnTerminal()` while keeping readiness and terminal policy. `dsh-subagent-subprocess` and the private LSP tree helpers were deleted. MCP transport spawning, the SDK package-manager runner, synchronous TUI Git probing, and dependency-light test-support launchers remain outside by ownership or execution shape; their production callers share the scrub where applicable. +Observed stream and lifecycle needs then moved the eligible process consumers onto the seam: LSP uses piped protocol streams plus a collected stderr tail; the ACP backend uses piped ndjson, inherited stderr, and a consumer-owned stdin-EOF disposal ladder; PTY uses `spawnTerminal()` while keeping readiness and terminal policy. `dsh-subagent-subprocess` and the private LSP tree helpers were deleted. MCP transport spawning and dependency-light test-support launchers remain outside by ownership or execution shape; their production callers share the scrub where applicable. ## Alternatives considered @@ -31,7 +31,7 @@ Observed stream and lifecycle needs then moved the eligible process consumers on **Use one `stdio: 'pipe' | 'inherit' | 'collect'` mode for all streams.** Rejected because real consumers mix modes per stream: LSP uses pipe/pipe/collect, ACP uses pipe/pipe/inherit, and Bash uses data/collect/collect. -**Route every process launch through `ctx.subprocess`.** Rejected because the MCP SDK owns its transport spawn, the SDK wizard has no Cordis context and needs inherited redirection, the TUI probe is synchronous, and support launchers deliberately stay independent of product seams. PTY allocation did move behind `spawnTerminal()` because the provider, not the consumer, owns that substrate-specific primitive. +**Route every process launch through `ctx.subprocess`.** Rejected because the MCP SDK owns its transport spawn and support launchers deliberately stay independent of product seams. PTY allocation did move behind `spawnTerminal()` because the provider, not the consumer, owns that substrate-specific primitive. **Put `run_in_background`/task semantics into the subprocess capability seam instead.** Rejected: that boundary already exists — `ctx.tasks` owns ids, ownership, and notices, and the bash tool adapts a `BashProcess` into task hooks. The subprocess seam sits *below* the bash executor, not beside the task registry. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md index d9cbc0ebf3..df8c3ddab8 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md @@ -17,11 +17,11 @@ Status: implemented - **`dsh-bash-local`(Consumer)**——`inject: ['subprocess']`;把每个解析后的 `BashExecSpec` 映射为一个 `SubprocessSpawnSpec`(`['bash', '-c', command]`),并保留自身配置、`resolve()` 默认值补全、基于融合 deadline 的 `timedOut`/`aborted` 分类、带 `[stderr]` 标记的后台读取合并及其消费游标,以及 `onProcessDone` 子类钩子。`dsh-bash-sandbox` 除了重新声明继承来的 inject 之外没有变化;它仍在命令字符串层面做包装,并重新进入继承的 spawn 路径。 - **`dsh-bash`(Service Definition)**——把迁走的词汇从 `dsh-subprocess` 重导出,因此没有任何 bash Consumer 需要改动导入;`BashExecRequest`/`BashExecSpec`/`BashProcess` 与沙箱事实仍归 bash 所有。 -如今,每个加载 bash 执行器的组合都同时加载 `@deepseek-ai/dsh-subprocess-local`:CLI(命令行界面)、各示例、Python 捆绑运行时、create-sdk 的 bash 功能资源,以及各内联测试配置。 +每个加载 bash 执行器的组合都同时加载 `@deepseek-ai/dsh-subprocess-local`:CLI(命令行界面)、各示例、Python 捆绑运行时以及各内联测试配置。 后台进程的存续期从执行器移到了管理器:执行器不再保有存活进程集合,于是重载执行器后,后台工作会继续运行且仍可读取,而组合拆除(管理器的 dispose)仍是先终止再等待退出的边界。一条行为约定随之挪动:后台 spawn 失败不再能在管道内部被缓冲成伪造的 stderr(对一个从未真正运行的进程,管理器会 reject `done`,且不缓冲任何内容),因此执行器把 `spawn failed: …` 提示注入恰好一个 `readOutput()` 增量。 -基于已观察到的流与生命周期需求,具备条件的进程消费方随后迁到该 seam:LSP 使用管道化协议流加收集式 stderr 尾部;ACP(Agent Client Protocol)后端使用管道化 ndjson、继承式 stderr 和消费方拥有的 stdin-EOF dispose 阶梯;PTY 使用 `spawnTerminal()`,同时保留就绪与终端策略。`dsh-subagent-subprocess` 与 LSP 私有进程树辅助函数均被删除。MCP 传输 spawn、SDK 包管理器运行器、同步 TUI Git 探测和刻意保持轻依赖的 test-support 启动器因所有权或执行形状仍留在外部;适用的生产调用方共享凭据清除。 +基于已观察到的流与生命周期需求,具备条件的进程消费方随后迁到该 seam:LSP 使用管道化协议流加收集式 stderr 尾部;ACP(Agent Client Protocol)后端使用管道化 ndjson、继承式 stderr 和消费方拥有的 stdin-EOF dispose 阶梯;PTY 使用 `spawnTerminal()`,同时保留就绪与终端策略。`dsh-subagent-subprocess` 与 LSP 私有进程树辅助函数均被删除。MCP 传输 spawn 和刻意保持轻依赖的 test-support 启动器因所有权或执行形状仍留在外部;适用的生产调用方共享凭据清除。 ## 曾考虑的替代方案 @@ -31,7 +31,7 @@ Status: implemented **用单个 `stdio: 'pipe' | 'inherit' | 'collect'` 模式统一全部流。**否决:真实消费方按流混用模式——LSP 使用 pipe/pipe/collect,ACP 使用 pipe/pipe/inherit,Bash 使用 data/collect/collect。 -**把每一次进程启动都路由到 `ctx.subprocess`。**否决:MCP SDK 拥有其传输 spawn,SDK 向导没有 Cordis 上下文且需要继承式重定向,TUI 探测是同步的,support 启动器则刻意独立于产品 seam。PTY 分配迁到 `spawnTerminal()`,因为这项底层专用原语归提供方而非消费方所有。 +**把每一次进程启动都路由到 `ctx.subprocess`。**否决:MCP SDK 拥有其传输 spawn,support 启动器则刻意独立于产品 seam。PTY 分配迁到 `spawnTerminal()`,因为这项底层专用原语归提供方而非消费方所有。 **改把 `run_in_background`/任务语义放进 subprocess 能力 seam。**否决:那条边界已经存在。`ctx.tasks` 拥有 id、所有权与通知,bash 工具则把 `BashProcess` 适配成任务钩子。subprocess seam 位于 bash 执行器*之下*,而不是与任务注册表并列。 diff --git a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.i18n.yaml index f5c1e4cad3..3654266f68 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.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/architecture/2026-07-29-package-regrouping.md -2026-07-29-package-regrouping.md: 30fc45a122263350b4a2ad1998850f631c20f9b8 -2026-07-29-package-regrouping.zh.md: a3a9a11ec71b7f894dcea7c733eb39a80b71ac50 +2026-07-29-package-regrouping.md: 6b85fafaebd75b051d239966a30bb81bdaf5522f +2026-07-29-package-regrouping.zh.md: 06eb210e318833d9651af2c4f0e4d792799a2018 diff --git a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md index 30fc45a122..6b85fafaeb 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md +++ b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md @@ -10,29 +10,26 @@ The two-level `packages//` hierarchy ([original decision](../../arch - `ui/` mixed four unrelated planes: the human terminal channel (`tui`), the SDK's JSON-RPC server half (`jsonrpc`, whose peer dependency on `dsh-sdk-protocol` binds it to the SDK wire stack), the human-interaction seams (`user-interaction`, `user-approval`, `permission`, `tool-ask-user`, `commands`), and channel-neutral boot glue (`app-boot`). Its own README narrated the mixture instead of stating a role. - The session family was fragmented across five groups — `session-persistence/`, `session-projection/`, `session-query/`, `session-title/`, and `telemetry/` — although the measured dependency edges tie them together (query → persistence, title → projection, projection → persistence; see [docs/module-graph.md](../../../../docs/module-graph.md)). -- Two group names collided with unrelated packages: `telemetry/` (session reporting) vs `dsh-telemetry` (launcher-side SDK telemetry), and `timeout/` (a tool-call guard) vs `util/timeout` (the generic promise utility). +- The `timeout/` group for a tool-call guard collided with `util/timeout`, the generic promise utility. - `cordis/` named its group after the framework every package is built on, so the name discriminated nothing; its single package `tool-cordis` is the runtime self-modification toolset. -- The old `sdk/` folder names were inconsistent: `sdk/sdk-client` and `sdk/sdk-protocol` repeated the group name while `sdk/telemetry`, `sdk/helper`, and `sdk/scripts` did not. The north star for the regrouping: **closely clustered packages share a group.** A cluster is measured — peer-dependency edges and co-change — not thematic. An isolated seam family may stand alone as a small group; the failure mode to avoid is the grab-bag whose name describes no single role. ## Decision -Six groups are recomposed; every other group keeps its prior boundary and contents (the dependency analysis confirmed the capability families — `bash/`, `pty/`, `code-runtime/`, `sandbox/`, `subprocess/`, `fs/`, `lsp/`, `web/`, `skill/`, and the rest — were already drawn correctly). npm package names did not change; the folder tree carries the whole change. +Five regrouping decisions remain current; every other group keeps its prior boundary and contents (the dependency analysis confirmed the capability families — `bash/`, `pty/`, `code-runtime/`, `sandbox/`, `subprocess/`, `fs/`, `lsp/`, `web/`, `skill/`, and the rest — were already drawn correctly). The original sixth decision collected the SDK project initializer, launcher tooling, and runtime JSON-RPC packages under `scaffold/`; [removing that unreleased toolchain](../simplification/2026-08-11-remove-sdk-project-toolchain.md) superseded it by deleting the project tooling and moving the surviving runtime trio to `sdk/`. | Group | Members (folder names) | From | |---|---|---| | `session/` | session-persistence, session-persistence-jsonl, session-persistence-sqlite, session-checkpoint-policy, session-projection, session-projection-cache, session-title, session-title-llm, session-title-first-message-llm, session-title-all-messages-llm, session-telemetry, session-telemetry-otel | `session-persistence/` + `session-projection/` + `session-title/` + `telemetry/` | | `interaction/` | user-interaction, user-approval, permission, tool-ask-user, commands, tui | `ui/` | | `boot/` | app-boot | `ui/` | -| `scaffold/` | helper, scripts, create-sdk, protocol, client, server, telemetry | `sdk/` + `ui/jsonrpc` | | `guard/` | repeat-tool-guard, timeout-policy | `guard/` + `timeout/` | | `self-modification/` | tool-cordis | `cordis/` | -- **`session/`** is the durable session data plane: the persistence seam with its backends and checkpoint policy, the projection fold that serves whole values from that log, log-backed titles, and OTel reporting. The title fold is itself load-bearing for the read side (`session-query` peer-depends on `dsh-session-title`), so titles belong with the data plane, not in a derived-services annex. The plain name is deliberate (prefer names a human would say); the nearby `core/session` package remains the live in-memory service, while this group is the durable family around it. `session-query/` stays a standalone group — the read/tool surface has its own model tools and SQLite FTS backend and is consumed independently of persistence internals. Absorbing `telemetry/` ended the group-name collision with `dsh-telemetry`. +- **`session/`** is the durable session data plane: the persistence seam with its backends and checkpoint policy, the projection fold that serves whole values from that log, log-backed titles, and OTel reporting. The title fold is itself load-bearing for the read side (`session-query` peer-depends on `dsh-session-title`), so titles belong with the data plane, not in a derived-services annex. The plain name is deliberate (prefer names a human would say); the nearby `core/session` package remains the live in-memory service, while this group is the durable family around it. `session-query/` stays a standalone group — the read/tool surface has its own model tools and SQLite FTS backend and is consumed independently of persistence internals. - **`interaction/`** is the human-collaboration plane plus the terminal channel that answers it: the question/approval seams, the permission preset, the model-facing `ask_user_question` tool, the human-command registry (`plan-mode` and `command-goal` already consume `commands` together with the interaction seams), and `tui` — the interactive channel is the plane's richest provider and consumer (peer edges to `commands` and `user-interaction`), and a one-package `tui/` group would spend a top-level name on one plugin. -- **`boot/`** is a role-complete single-package group: the shared bin boot glue that belongs to no channel and no assembly (consumed by `apps/cli`, the `scaffold/` launcher, and the `examples/` demo bins). -- **`scaffold/`** is the developer-tooling family: project helper, launcher, initializer, wire protocol with both ends (`server` is the former `ui/jsonrpc`), and launcher telemetry. Renamed from `sdk/`: the whole `packages/` tree *is* the SDK, so a group named `sdk/` inside it said nothing; `scaffold/` names the create/launch/drive-a-project role. Folders drop the legacy `sdk-` prefix (`protocol`, `client`, `server`), matching the `client/`/`host/` role-named folder style; the three affected npm names are mapped explicitly beside the group wildcard in `tsconfig.base.json` until the deferred renames land. +- **`boot/`** is a role-complete single-package group: the shared bin boot glue that belongs to no channel and no assembly (consumed by `apps/cli` and the `examples/` demo bins). - **`guard/`** keeps its documented role, loop-hygiene guards, and gains the tool-call timeout enforcer, dissolving the one-package `timeout/` group whose name collided with `util/timeout`. - **`self-modification/`** names the role `cordis/` obscured: the toolset with which the agent inspects and mounts plugins in its own live runtime, and the landing zone for future self-modification packages. @@ -40,21 +37,16 @@ Six groups are recomposed; every other group keeps its prior boundary and conten ## Deferred renames (FIXME markers) -Five npm names should eventually change, but renaming inside the reorganization would have turned a pure-move PR into an import-churn PR. Instead, each affected package's module JSDoc carries a `FIXME` naming the intended new name. `FIXME` blocks a tagged release ([marker semantics](../../../../docs/development.md)), which is the wanted forcing function: these renames are only free while nothing external consumes the packages. +Two npm names should eventually change, but renaming inside the reorganization would have turned a pure-move PR into an import-churn PR. Each affected package's module JSDoc carries a `FIXME` naming the intended new name. `FIXME` blocks a tagged release ([marker semantics](../../../../docs/development.md)), which is the wanted forcing function: these renames are only free while nothing external consumes the packages. Removing the unreleased SDK project toolchain deleted the other three packages and their rename markers instead of preserving names for code that no longer exists. | Current npm name | Intended name | Why | |---|---|---| | `@deepseek-ai/dsh-jsonrpc` | `@deepseek-ai/dsh-sdk-server` | Names the wire encoding, not the role; it is the server half of the SDK protocol | -| `@deepseek-ai/dsh-telemetry` | `@deepseek-ai/dsh-sdk-telemetry` | Collides with the `dsh-session-telemetry` family; it is launcher-side SDK telemetry | -| `@deepseek-ai/dsh-helper` | `@deepseek-ai/dsh-sdk-helper` | Indefensibly generic as a published name | -| `@deepseek-ai/dsh-scripts` | `@deepseek-ai/dsh-sdk-scripts` | Same | | `@deepseek-ai/dsh-timeout-policy` | `@deepseek-ai/dsh-timeout-guard` | Suggestion, not settled: aligns the name with its `guard/` home; decide at resolution time | -The first four are settled intent; resolving them converges the SDK wire stack's npm names on `dsh-sdk-*` (the npm prefix names the product stack; the `scaffold/` folder names the role). `@deepseek-ai/create-sdk` keeps its documented npm-initializer exception. - ## What the move touched -The moves landed as pure `git mv` moves, so rename detection carries the history. A group move touched: the moved package's `tsconfig.json` relative `references` and every dependent's entry (including the `apps/cli` project references), the tsconfig aggregate and path maps, group READMEs (five new bilingual triplets, deletions for dissolved groups, the [packages/README.md](../../../../packages/README.md) hierarchy table, the root `AGENTS.md` layout map), regenerated artifacts (`docs/module-graph.md`, path-embedding catalogs, the lockfile's importer keys), and root-relative `packages/...` citations in prose and gate scripts. Remaining group-path referents (workspace configs, test globs, lint keys) were found mechanically by the acceptance gates failing loud — the repository's own misconfiguration rule. +The moves landed as pure `git mv` moves, so rename detection carries the history. A group move touched: the moved package's `tsconfig.json` relative `references` and every dependent's entry (including the `apps/cli` project references), the tsconfig aggregate and path maps, group READMEs, the [packages/README.md](../../../../packages/README.md) hierarchy table, the root `AGENTS.md` layout map, regenerated artifacts (`docs/module-graph.md`, path-embedding catalogs, and the lockfile's importer keys), and root-relative `packages/...` citations in prose and gate scripts. Remaining group-path referents (workspace configs, test globs, lint keys) were found mechanically by the acceptance gates failing loud — the repository's own misconfiguration rule. A group move did not touch: npm names, imports, `cordis.yml` configs, snapshot fixtures, the `pnpm-workspace.yaml`/`tsdown` globs (both `packages/*/*`), or the Python runtime manifest — all reference packages by npm name. @@ -62,13 +54,13 @@ A group move did not touch: npm names, imports, `cordis.yml` configs, snapshot f ## Alternatives considered -**Coarse domain buckets** (`exec/` = subprocess+sandbox+bash+pty+code-runtime, `workspace/` = fs+lsp+workspace, `orchestration/` = subagent+workflow+tasks, `knowledge/` = web+skill, `collab/` = plan+todo+goal; ~16 groups). Rejected: the measured graph contradicts the merges. `sandbox` and `subprocess` are shared infrastructure consumed across families (bash ×5, fs ×5, pty, lsp, mcp, subagent, scaffold edges), `web` ↔ `skill` have zero edges, and a large bucket reproduces the `ui/` grab-bag at a larger scale. +**Coarse domain buckets** (`exec/` = subprocess+sandbox+bash+pty+code-runtime, `workspace/` = fs+lsp+workspace, `orchestration/` = subagent+workflow+tasks, `knowledge/` = web+skill, `collab/` = plan+todo+goal; ~16 groups). Rejected: the measured graph contradicts the merges. `sandbox` and `subprocess` are shared infrastructure consumed across families (bash ×5, fs ×5, pty, lsp, mcp, and subagent edges), `web` ↔ `skill` have zero edges, and a large bucket reproduces the `ui/` grab-bag at a larger scale. **Abstract layer names** (`capability/`, `policy/`, `extension/`, `provider/`). Rejected: they describe every plugin equally badly, and a `capability/` bucket would hold ~50 packages. **A full npm rename sweep** (`dsh--` for every package). Rejected: npm names are flat, so group-prefixing adds churn across imports, configs, and fixtures with no disambiguation gain; targeted FIXME-tracked renames cover the actual collisions. -**Performing the five renames inside the reorganization.** Rejected: renames multiply open-PR conflicts and destroy the pure-move review property. The FIXME markers keep them visible release blockers to resolve as small follow-up PRs. +**Performing the deferred renames inside the reorganization.** Rejected: renames multiply open-PR conflicts and destroy the pure-move review property. The remaining FIXME markers keep them visible release blockers to resolve as small follow-up PRs. **A two-way session split** (`session-core/` + `session-utils/`). Rejected: query belongs to neither side cleanly, and `session-core` invites confusion with `core/session` (`dsh-session`, the live in-memory service, which stays in `core/`). @@ -78,9 +70,7 @@ A group move did not touch: npm names, imports, `cordis.yml` configs, snapshot f **A standalone one-package `tui/` group.** Rejected: `tui` is the interaction plane's primary provider/consumer (peer edges to `commands`, `user-interaction`), and a top-level name spent on one plugin adds a group without adding information; it folds into `interaction/`. -**Keeping the group name `sdk/`.** Rejected: the whole `packages/` tree is the SDK, so an `sdk/` group inside it discriminates nothing — the same disease as `cordis/`. `scaffold/` names the actual role (create, launch, and drive projects from outside). - -**Moving `app-boot` to `apps/`.** Rejected: `apps/` is the assembly tier over the package tier, and `dsh-app-boot` is a library that package-tier code imports (`scaffold/scripts`' launcher peer-depends on it) — placing it in `apps/` would invert the tiers and put a workspace library outside the `packages/*/*` build globs. It stays a package; `boot/` is its role-complete home. +**Moving `app-boot` to `apps/`.** Rejected: `apps/` is the assembly tier over the package tier, and `dsh-app-boot` is a package-tier library — placing it in `apps/` would invert the tiers and put a workspace library outside the `packages/*/*` build globs. It stays a package; `boot/` is its role-complete home. **Moving `tool-cordis` into `core/`.** Rejected: self-modification is its own product seam, expected to grow; the spine stays minimal. The group was first named `self-evolve/`; the name settled on `self-modification/` as the plainer term. @@ -88,9 +78,9 @@ A group move did not touch: npm names, imports, `cordis.yml` configs, snapshot f ## Consequences -- The tree matches the map: the six recomposed groups hold exactly the listed members; the groups `ui/`, `sdk/`, `telemetry/`, `timeout/`, `cordis/`, `session-persistence/`, `session-projection/`, and `session-title/` no longer exist; every other group's contents are unchanged. The workspace package-name set is identical before and after (zero npm renames), and the five FIXME markers pin the deferred ones. A FIXME that later proves wrong must be removed explicitly with rationale, never silently dropped. +- The five still-current regrouped families hold the listed members; the groups `ui/`, `telemetry/`, `timeout/`, `cordis/`, `session-persistence/`, `session-projection/`, and `session-title/` no longer exist. The regrouping itself changed no npm names. The later SDK toolchain removal intentionally changed the package set and restored `sdk/` as the precise home of the runtime SDK trio. Two FIXME markers pin the remaining deferred renames; a FIXME that later proves wrong must be removed explicitly with rationale, never silently dropped. - What pins the result: `pnpm run typecheck`, the unit suites of every moved group, `verify-package-paths`, `verify-md-links`, and the corpus-wide translation pairing all pass on the moved tree; the group-scoped test globs in `vitest.snapshot.config.ts` were rewritten with the moves so the suites collect the same test files as before (a fail-open glob would silently drop coverage). - Every open PR touching a moved file rebases across the move once; rename detection resolves most hunks mechanically. - Single-package groups remain (`boot/`, `self-modification/`, and existing ones such as `acp/`). Accepted deliberately: each is role-complete rather than a fragment of a family, and a truthful small group beats a nominal merge. -- The `scaffold/` folders diverge from their npm names until the deferred renames land — the one transitional asymmetry, carried by three explicit `paths` entries in `tsconfig.base.json` and resolved by the FIXME renames. +- The `sdk/` role folders map explicitly to their npm names in `tsconfig.base.json`; the `server/` mapping remains transitional until `dsh-jsonrpc` is renamed. - What this gave up: nothing functional — the change is navigational. Muscle memory and external links to old GitHub paths break, which is acceptable pre-release with no external consumers. diff --git a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md index a3a9a11ec7..06eb210e31 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md @@ -10,29 +10,26 @@ Status: implemented - `ui/` 混杂了四个互不相关的平面:人类终端通道(`tui`)、SDK 的 JSON-RPC 服务端一半(`jsonrpc`,它对 `dsh-sdk-protocol` 的对等依赖(peer dependency)把它绑在 SDK 通信栈上)、人机交互 seam(`user-interaction`、`user-approval`、`permission`、`tool-ask-user`、`commands`),以及与通道无关的 boot 胶水(`app-boot`)。它自己的 README 只能逐一叙述这堆混杂,说不出一个统一职责。 - 会话家族被割裂在五个组里——`session-persistence/`、`session-projection/`、`session-query/`、`session-title/` 与 `telemetry/`——而实测依赖边明明把它们连成一体(query → persistence、title → projection、projection → persistence;见 [docs/module-graph.md](../../../../docs/module-graph.md))。 -- 两个组名与不相干的包撞名:`telemetry/`(会话上报)撞上 `dsh-telemetry`(启动器侧 SDK telemetry),`timeout/`(一个工具调用守卫)撞上 `util/timeout`(通用 promise 工具)。 +- 用于工具调用守卫的 `timeout/` 组与通用 promise 工具 `util/timeout` 撞名。 - `cordis/` 拿所有包共同依托的框架给自己的组命名,这个名字因此毫无区分度;组里唯一的包 `tool-cordis` 是运行时自我修改工具集。 -- 旧 `sdk/` 的目录命名不一致:`sdk/sdk-client` 和 `sdk/sdk-protocol` 重复了组名,而 `sdk/telemetry`、`sdk/helper`、`sdk/scripts` 没有。 这次重新分组的指导准则:**聚类紧密的包同处一组。**聚类以实测为准(对等依赖边与 co-change),而非按主题归类。孤立的 seam 家族可以自成一个小组;要避免的失败形态,是名字概括不出单一职责的大杂烩组。 ## Decision -重组六个组;其余每个组都保持先前的边界与内容不变(依赖分析确认各能力家族——`bash/`、`pty/`、`code-runtime/`、`sandbox/`、`subprocess/`、`fs/`、`lsp/`、`web/`、`skill/` 及其余——本来就划得正确)。npm 包名一个未改;整个变更全部由目录树承载。 +五项重组决策仍然有效;其余每个组都保持先前的边界与内容不变(依赖分析确认各能力家族——`bash/`、`pty/`、`code-runtime/`、`sandbox/`、`subprocess/`、`fs/`、`lsp/`、`web/`、`skill/` 及其余——本来就划得正确)。原本的第六项决策把 SDK 项目初始化器、启动器工具与运行时 JSON-RPC 包汇集到 `scaffold/`;[移除这套未发布工具链](../simplification/2026-08-11-remove-sdk-project-toolchain.md)的决策删除了项目工具,并将存留的运行时三包移到 `sdk/`,从而取代了该项决策。 | 组 | 成员(目录名) | 来源 | |---|---|---| | `session/` | session-persistence、session-persistence-jsonl、session-persistence-sqlite、session-checkpoint-policy、session-projection、session-projection-cache、session-title、session-title-llm、session-title-first-message-llm、session-title-all-messages-llm、session-telemetry、session-telemetry-otel | `session-persistence/` + `session-projection/` + `session-title/` + `telemetry/` | | `interaction/` | user-interaction、user-approval、permission、tool-ask-user、commands、tui | `ui/` | | `boot/` | app-boot | `ui/` | -| `scaffold/` | helper、scripts、create-sdk、protocol、client、server、telemetry | `sdk/` + `ui/jsonrpc` | | `guard/` | repeat-tool-guard、timeout-policy | `guard/` + `timeout/` | | `self-modification/` | tool-cordis | `cordis/` | -- **`session/`** 是持久会话数据平面:持久化 seam 连同其各后端与检查点策略、从该日志折叠(fold)出全量值对外供值的投影、日志兜底的标题,以及 OTel 上报。标题折叠本身就是读取侧的承重构件(`session-query` 对 `dsh-session-title` 声明对等依赖),所以标题属于数据平面,而非某个「派生服务」附属区。用这个朴素的名字是有意为之(名字要像人起的);旁边的 `core/session` 包仍是常驻内存的实时服务,本组则是围绕它的持久家族。`session-query/` 保持独立成组:这个读取/工具面自带模型工具和 SQLite FTS 后端,其消费不依赖持久化内部实现。吸收 `telemetry/` 之后,与 `dsh-telemetry` 的组名冲突就此终结。 +- **`session/`** 是持久会话数据平面:持久化 seam 连同其各后端与检查点策略、从该日志折叠(fold)出全量值对外供值的投影、日志兜底的标题,以及 OTel 上报。标题折叠本身就是读取侧的承重构件(`session-query` 对 `dsh-session-title` 声明对等依赖),所以标题属于数据平面,而非某个「派生服务」附属区。用这个朴素的名字是有意为之(名字要像人起的);旁边的 `core/session` 包仍是常驻内存的实时服务,本组则是围绕它的持久家族。`session-query/` 保持独立成组:这个读取/工具面自带模型工具和 SQLite FTS 后端,其消费不依赖持久化内部实现。 - **`interaction/`** 是人机协作平面加上应答它的终端通道:提问/批准 seam、权限预设、面向模型的 `ask_user_question` 工具、人类命令注册表(`plan-mode` 与 `command-goal` 已经把 `commands` 和各交互 seam 放在一起消费),以及 `tui`——这个交互通道是该平面最重的提供方与消费方(对 `commands` 与 `user-interaction` 均有对等依赖边),而一个单包 `tui/` 组会把一个顶层名字花在一个插件上。 -- **`boot/`** 是角色完备的单包组:不归属任何通道也不归属任何组装的共享 bin boot 胶水(被 `apps/cli`、`scaffold/` 的启动器和 `examples/` 各演示 bin 消费)。 -- **`scaffold/`** 是开发者工具家族:项目 helper、启动器、初始化器、连同两端的通信协议(`server` 即原先的 `ui/jsonrpc`),以及启动器侧 telemetry。从 `sdk/` 改名:整个 `packages/` 树本身就是 SDK,树里再放一个叫 `sdk/` 的组等于什么都没说;`scaffold/` 说出了「创建/启动/驱动项目」这一实际角色。目录去掉遗留的 `sdk-` 前缀(`protocol`、`client`、`server`),与 `client/`/`host/` 的角色命名风格一致;在推迟的改名落地之前,受影响的三个 npm 名在 `tsconfig.base.json` 里于组通配符旁显式映射。 +- **`boot/`** 是角色完备的单包组:不归属任何通道也不归属任何组装的共享 bin boot 胶水(被 `apps/cli` 与 `examples/` 各演示 bin 消费)。 - **`guard/`** 保留其文档记载的角色(循环卫生守卫),并新纳入强制执行工具调用超时的包;那个与 `util/timeout` 撞名的单包组 `timeout/` 随之解散。 - **`self-modification/`** 把 `cordis/` 遮蔽掉的角色说了出来:它是 agent(智能体)检查并挂载自身实时运行时中插件所用的工具集,也是未来自我修改类包的落点。 @@ -40,21 +37,16 @@ Status: implemented ## Deferred renames (FIXME markers) -五个 npm 名最终应当改掉,但在这次重组内部改名,会把一个纯移动的 PR(Pull Request)变成大量翻改 import 的 PR。因此每个受影响包的模块 JSDoc 里带有一条 `FIXME`,写明意图中的新名字。`FIXME` 会阻塞打 tag 的发布([标记语义](../../../../docs/development.md)),这正是想要的倒逼机制:只有趁还没有外部消费方使用这些包时,这些改名才是零成本的。 +两个 npm 名最终应当改掉,但在这次重组内部改名,会把一个纯移动的 PR(Pull Request)变成大量翻改 import 的 PR。每个受影响包的模块 JSDoc 里带有一条 `FIXME`,写明意图中的新名字。`FIXME` 会阻塞打 tag 的发布([标记语义](../../../../docs/development.md)),这正是想要的倒逼机制:只有趁还没有外部消费方使用这些包时,这些改名才是零成本的。移除未发布的 SDK 项目工具链时,另外三个包及其改名标记一并删除,没有为已经不存在的代码保留包名。 | 当前 npm 名 | 目标名 | 原因 | |---|---|---| | `@deepseek-ai/dsh-jsonrpc` | `@deepseek-ai/dsh-sdk-server` | 名字说的是协议编码而非角色;它是 SDK 协议的服务端一半 | -| `@deepseek-ai/dsh-telemetry` | `@deepseek-ai/dsh-sdk-telemetry` | 与 `dsh-session-telemetry` 家族撞名;它是启动器侧 SDK telemetry | -| `@deepseek-ai/dsh-helper` | `@deepseek-ai/dsh-sdk-helper` | 作为公开发布名空泛得站不住脚 | -| `@deepseek-ai/dsh-scripts` | `@deepseek-ai/dsh-sdk-scripts` | 同上 | | `@deepseek-ai/dsh-timeout-policy` | `@deepseek-ai/dsh-timeout-guard` | 仅为建议、尚未定案:使名字与其 `guard/` 归属对齐;到解决时再定 | -前四个是已定的意图;兑现之后,SDK 通信栈的 npm 名随之收敛为 `dsh-sdk-*`(npm 前缀指产品栈,`scaffold/` 目录名指角色)。`@deepseek-ai/create-sdk` 保留其文档记载的 npm 初始化器特例。 - ## What the move touched -移动以纯 `git mv` 形式落地,历史由重命名检测承载。组移动触及了:被移动包的 `tsconfig.json` 相对 `references` 及每个依赖方的对应条目(含 `apps/cli` 的 project references);tsconfig 聚合与路径映射;各组 README(五组新的双语三文件配对、被解散组的 README 删除、[packages/README.md](../../../../packages/README.md) 的层级结构表、根 `AGENTS.md` 的布局图);重新生成的产物(`docs/module-graph.md`、内嵌路径的目录、锁文件的 importer 键);以及散文与门禁脚本中以仓库根为基准的 `packages/...` 引用。其余每一处组路径引用(workspace 配置、测试 glob、lint 键)都由验收门禁的响亮失败机械地找了出来——这正是本仓库自己的「配置错误必须响亮失败」规则。 +移动以纯 `git mv` 形式落地,历史由重命名检测承载。组移动触及了:被移动包的 `tsconfig.json` 相对 `references` 及每个依赖方的对应条目(含 `apps/cli` 的 project references);tsconfig 聚合与路径映射;各组 README;[packages/README.md](../../../../packages/README.md) 的层级结构表;根 `AGENTS.md` 的布局图;重新生成的产物(`docs/module-graph.md`、内嵌路径的目录以及锁文件的 importer 键);以及散文与门禁脚本中以仓库根为基准的 `packages/...` 引用。其余每一处组路径引用(workspace 配置、测试 glob、lint 键)都由验收门禁的响亮失败机械地找了出来——这正是本仓库自己的「配置错误必须响亮失败」规则。 组移动未触及:npm 包名、import、`cordis.yml` 配置、快照 fixture(测试前置数据)、`pnpm-workspace.yaml` 与 `tsdown` 的 glob(都是 `packages/*/*`),以及 Python 运行时 manifest(元数据清单)——它们全部按 npm 包名引用包。 @@ -62,13 +54,13 @@ Status: implemented ## Alternatives considered -**粗粒度领域桶**(`exec/` = subprocess+sandbox+bash+pty+code-runtime,`workspace/` = fs+lsp+workspace,`orchestration/` = subagent+workflow+tasks,`knowledge/` = web+skill,`collab/` = plan+todo+goal;约 16 个组)。不予采纳:实测依赖图与这些合并相矛盾。`sandbox` 和 `subprocess` 是被各家族跨界消费的共享基础设施(与 bash ×5、fs ×5、pty、lsp、mcp、subagent、scaffold 均有依赖边),`web` ↔ `skill` 之间零依赖边,而大桶只会在更大尺度上复现 `ui/` 式大杂烩。 +**粗粒度领域桶**(`exec/` = subprocess+sandbox+bash+pty+code-runtime,`workspace/` = fs+lsp+workspace,`orchestration/` = subagent+workflow+tasks,`knowledge/` = web+skill,`collab/` = plan+todo+goal;约 16 个组)。不予采纳:实测依赖图与这些合并相矛盾。`sandbox` 和 `subprocess` 是被各家族跨界消费的共享基础设施(与 bash ×5、fs ×5、pty、lsp、mcp 及 subagent 均有依赖边),`web` ↔ `skill` 之间零依赖边,而大桶只会在更大尺度上复现 `ui/` 式大杂烩。 **抽象分层名**(`capability/`、`policy/`、`extension/`、`provider/`)。不予采纳:这些名字对每个插件都同样地不达意,而且一个 `capability/` 桶会装下约 50 个包。 **一轮全量 npm 重命名**(每个包都改为 `dsh--`)。不予采纳:npm 包名是扁平的,加组前缀只会在 import、配置和 fixture 之间制造改动,却换不来任何消歧收益;用 FIXME 跟踪的定点改名足以覆盖真正的撞名。 -**在重组内部一并完成那五个改名。** 不予采纳:改名会成倍放大开放 PR 的冲突,并破坏纯移动的评审属性。FIXME 标记让这些改名保持为可见的发布阻塞项,留待以小型后续 PR 逐一解决。 +**在重组内部一并完成推迟的改名。** 不予采纳:改名会成倍放大开放 PR 的冲突,并破坏纯移动的评审属性。剩余的 FIXME 标记让这些改名保持为可见的发布阻塞项,留待以小型后续 PR 逐一解决。 **会话两分法**(`session-core/` + `session-utils/`)。不予采纳:query 放哪一侧都不干净,而且 `session-core` 容易与 `core/session` 混淆(后者是 `dsh-session`,常驻内存的实时服务,留在 `core/` 不动)。 @@ -78,9 +70,7 @@ Status: implemented **独立的单包 `tui/` 组。** 不予采纳:`tui` 是交互平面最重的提供方/消费方(对 `commands`、`user-interaction` 有对等依赖边),把一个顶层名字花在一个插件上只添组不添信息;它折入 `interaction/`。 -**保留组名 `sdk/`。** 不予采纳:整个 `packages/` 树本身就是 SDK,树里的 `sdk/` 组毫无区分度——与 `cordis/` 同病。`scaffold/` 说出了实际角色(从外部创建、启动、驱动项目)。 - -**把 `app-boot` 挪到 `apps/`。** 不予采纳:`apps/` 是包层之上的组装层,而 `dsh-app-boot` 是被包层代码 import 的库(`scaffold/scripts` 的启动器对它声明对等依赖)——放进 `apps/` 会颠倒层级,并把一个 workspace 库放到 `packages/*/*` 构建 glob 之外。它仍是一个包;`boot/` 是它角色完备的家。 +**把 `app-boot` 挪到 `apps/`。** 不予采纳:`apps/` 是包层之上的组装层,而 `dsh-app-boot` 是包层的库——放进 `apps/` 会颠倒层级,并把一个 workspace 库放到 `packages/*/*` 构建 glob 之外。它仍是一个包;`boot/` 是它角色完备的家。 **把 `tool-cordis` 挪进 `core/`。** 不予采纳:自我修改是独立的产品 seam,预期还会生长;主干保持精简。该组最初命名为 `self-evolve/`;名字最终定为更朴素的 `self-modification/`。 @@ -88,9 +78,9 @@ Status: implemented ## Consequences -- 目录树与映射表一致:重组的六个组恰好持有所列成员;`ui/`、`sdk/`、`telemetry/`、`timeout/`、`cordis/`、`session-persistence/`、`session-projection/`、`session-title/` 这些组不复存在;其余每个组的内容不变。workspace 的包名集合在前后完全相同(npm 改名为零),五条 FIXME 标记钉住推迟的改名。日后若某条 FIXME 被证明不对,必须连同理由显式移除,绝不允许无声消失。 +- 五个仍然有效的重组家族持有所列成员;`ui/`、`telemetry/`、`timeout/`、`cordis/`、`session-persistence/`、`session-projection/`、`session-title/` 这些组不复存在。重组本身没有更改 npm 名。后续移除 SDK 工具链的决策有意改变包集合,并恢复 `sdk/` 作为运行时 SDK 三包的精确归属。两条 FIXME 标记钉住剩余的推迟改名;日后若某条 FIXME 被证明不对,必须连同理由显式移除,绝不允许无声消失。 - 结果由以下检查钉住:`pnpm run typecheck`、每个被移动组的单元测试套件、`verify-package-paths`、`verify-md-links` 与全语料翻译配对在移动后的树上全部通过;`vitest.snapshot.config.ts` 中按组划定的测试 glob 随移动一并改写,套件收集到与移动前相同的测试文件(glob 匹配为空会无声地丢失覆盖)。 - 每个触碰被移动文件的开放 PR 都跨过这次移动做一次变基;重命名检测可机械化解决大多数改动块。 - 单包组依然存在(`boot/`、`self-modification/`,以及 `acp/` 等既有单包组)。这是有意接受的:每个都是角色完备的整体而非某个家族的碎片,一个名实相符的小组胜过一次徒有其名的合并。 -- 在推迟的改名落地之前,`scaffold/` 的目录名与其 npm 名并不一致——这是唯一的过渡性不对称,由 `tsconfig.base.json` 里三条显式 `paths` 映射承载,并由 FIXME 改名最终消除。 +- `sdk/` 的角色目录在 `tsconfig.base.json` 中显式映射到各自的 npm 名;在 `dsh-jsonrpc` 完成改名之前,`server/` 的映射仍是过渡性的。 - **这次变更放弃了什么:** 功能上一无所失——变更只关乎导航。肌肉记忆和指向旧 GitHub 路径的外部链接会失效;在 pre-release、尚无外部消费者的前提下,这可以接受。 diff --git a/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.i18n.yaml index 226b62f100..418853a3d7 100644 --- a/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.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/architecture/2026-08-07-shared-feedback-telemetry-user-id.md -2026-08-07-shared-feedback-telemetry-user-id.md: 6d4020828cb1f2ab3de0328c8959a18a0fcfe6c4 -2026-08-07-shared-feedback-telemetry-user-id.zh.md: 892fa0f848d656609885d008ab36e3ebbe09b992 +2026-08-07-shared-feedback-telemetry-user-id.md: 5ef487646c94808177e1263b94b8c23a9a045d97 +2026-08-07-shared-feedback-telemetry-user-id.zh.md: 1ee433396f83a8caf5e35c9cfd1b4f3c04d6c59f diff --git a/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.md b/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.md index 6d4020828c..5ef487646c 100644 --- a/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.md +++ b/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.md @@ -14,7 +14,7 @@ The earlier [anonymous-user-id decision](../feature/2026-07-31-telemetry-anonymo `@deepseek-ai/dsh-user-id` owns `getOrCreateAnonymousUserId()` and the `$DSH_HOME/.userid` storage contract. `session-telemetry-otel` uses the returned id as OpenTelemetry Resource `user.id`; the `/feedback` success acknowledgement reports `Feedback recorded for session {sessionId}` followed by `User: {userId}` on a second line, which keeps both identifiers available through the generic command row's expandable body. Invalid feedback is rejected before resolving the id, so an empty command does not create `.userid`. -The extraction preserves the existing random UUID, home resolution, process memo, exclusive-create concurrency, corruption replacement, and best-effort write semantics. It does not unify the dsh-sdk launcher's separate `telemetry.json` identity. +The extraction preserves the existing random UUID, home resolution, process memo, exclusive-create concurrency, corruption replacement, and best-effort write semantics. ## Alternatives considered @@ -23,7 +23,6 @@ The extraction preserves the existing random UUID, home resolution, process memo | Import the helper from `session-telemetry-otel` | Couples feedback to an optional exporter backend and forms a reverse dependency cycle once telemetry exports feedback | | Duplicate the persistence helper in feedback | Two implementations of one file contract can drift and race with different validation or failure semantics | | Generate a separate feedback user id | The acknowledgement could not correlate with the OTel Resource and would not satisfy the reporting purpose | -| Move the launcher telemetry id too | The launcher feed is not a consumer of `.userid`; unifying unrelated stores remains out of scope | ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.zh.md b/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.zh.md index 892fa0f848..1ee433396f 100644 --- a/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.zh.md @@ -14,7 +14,7 @@ OpenTelemetry 后端已在 `$DSH_HOME/.userid` 中持久化一个匿名 UUID。` `@deepseek-ai/dsh-user-id` 负责 `getOrCreateAnonymousUserId()` 和 `$DSH_HOME/.userid` 存储契约。`session-telemetry-otel` 将返回的 id 用作 OpenTelemetry Resource 的 `user.id`;`/feedback` 的成功确认先报告 `Feedback recorded for session {sessionId}`,再在第二行显示 `User: {userId}`,使两个标识符都可通过通用命令行的可展开正文查看。系统在获取 id 前拒绝无效反馈,因此空命令不会创建 `.userid`。 -此次抽取保留既有的随机 UUID、home 解析、进程内缓存、独占创建并发、损坏文件替换与 best-effort 写入语义。它不会统一 dsh-sdk launcher 独立的 `telemetry.json` 身份。 +此次抽取保留既有的随机 UUID、home 解析、进程内缓存、独占创建并发、损坏文件替换与 best-effort 写入语义。 ## 考虑过的替代方案 @@ -23,7 +23,6 @@ OpenTelemetry 后端已在 `$DSH_HOME/.userid` 中持久化一个匿名 UUID。` | 从 `session-telemetry-otel` 导入辅助函数 | 使反馈耦合到可选的导出后端,并在遥测导出反馈后形成反向依赖环 | | 在反馈中复制持久化辅助函数 | 同一文件契约的两份实现可能发生偏差,并因校验或失败语义不同而产生竞态 | | 生成独立的反馈用户 id | 确认文本无法与 OTel Resource 相关联,因而不能达到报告目的 | -| 同时移动 launcher telemetry id | launcher 回流不是 `.userid` 的消费方;统一无关存储仍不在范围内 | ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml index 17cee6fc65..4d297b56a2 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.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-12-subagent-persona-tool-filter-and-depth.md -2026-07-12-subagent-persona-tool-filter-and-depth.md: 2c5eaf8829e01536a2de34cc190573ff35e4ab77 -2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: ae64e26d95e42ca6c933892348e22d3cbf0dc722 +2026-07-12-subagent-persona-tool-filter-and-depth.md: b5b38836b987ab2ef2ceebf3a1efa7b6211b1b0e +2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: 1dd5725309159a70dab0f3cd669a79460c3b1cd2 diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md index 2c5eaf8829..b5b38836b9 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md @@ -53,7 +53,7 @@ The depth limit bounds recursive delegation independently of tool visibility. A The effective parent depth is the greater of durable `SessionHeader.delegationDepth` and runtime `AgentOptions.subagentDepth`. An in-process child records its derived depth in the session header, and resume restores that header, so a restart cannot lower the recursion count. -Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; loader-resolved `dsh-tool-subagent` configuration instead defaults to `3`, accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. Three is a small finite default that still permits a root plus three descendant generations: the [SDK helper's generated subagent entries](../../../../packages/scaffold/helper/src/features/builtin/index.ts) and [JSON-RPC example](../../../../examples/jsonrpc-agent/cordis.yml) use that general policy, while the shipped interactive ACP, headless, and REPL examples pin one. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`. +Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; loader-resolved `dsh-tool-subagent` configuration instead defaults to `3`, accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. Three is a small finite default that still permits a root plus three descendant generations: the [JSON-RPC example](../../../../examples/jsonrpc-agent/cordis.yml) uses that general policy, while the ACP and headless examples pin one. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`. A deployment can combine depth and filtering, but the numeric cap does not synthesize a filter. The delegation tool stays visible at the cap because authorization may depend on runtime state; every attempted start checks the calling agent's current durable and runtime depth, and a rejected start returns an errored tool result without publishing a child. A deployment may separately deny delegation tools in children when its visibility policy is static. Neither choice changes the provider's conversation-history behavior. diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md index ae64e26d95..1dd5725309 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md @@ -53,7 +53,7 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma 有效父级深度取持久 `SessionHeader.delegationDepth` 与运行时 `AgentOptions.subagentDepth` 中的较大值。进程内子 agent 把推导出的深度记录在会话 header 中,恢复时会重新载入该 header,因此重启无法降低递归计数。 -每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。直接的 `SubagentStartRequest` 可以省略上限,让此机制不约束深度;经 loader 解析的 `dsh-tool-subagent` 配置则默认值为 `3`、接受数值覆盖,并使用显式的 `'provider-managed'` 来省略由进程外提供方部署拥有递归预算时的上限。三是一个较小的有限默认值,仍允许 root 加三代后代:[SDK 辅助函数生成的 subagent 条目](../../../../packages/scaffold/helper/src/features/builtin/index.ts)和 [JSON-RPC 示例](../../../../examples/jsonrpc-agent/cordis.yml)采用这项通用策略,而已交付的交互式 ACP、headless 和 REPL 示例固定为一。提供方缺少 `depthLimit` 时,数值工具上限会在提供方挂载阶段失败。 +每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。直接的 `SubagentStartRequest` 可以省略上限,让此机制不约束深度;经 loader 解析的 `dsh-tool-subagent` 配置则默认值为 `3`、接受数值覆盖,并使用显式的 `'provider-managed'` 来省略由进程外提供方部署拥有递归预算时的上限。三是一个较小的有限默认值,仍允许 root 加三代后代:[JSON-RPC 示例](../../../../examples/jsonrpc-agent/cordis.yml)采用这项通用策略,而 ACP 与 headless 示例固定为一。提供方缺少 `depthLimit` 时,数值工具上限会在提供方挂载阶段失败。 部署可以组合深度与过滤,但数值上限不会合成过滤器。委派工具在上限处仍然可见,因为授权可能依赖运行时状态;每次尝试启动都会检查调用方 agent 当前的持久与运行时深度,被拒绝的启动返回错误工具结果,且不发布子 agent。可见性策略固定的部署可以另外在子 agent 中 deny 委派工具。两种选择都不改变提供方的对话历史行为。 diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index 03d812f73b..31632c8f55 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.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-21-continuable-background-subagents.md -2026-07-21-continuable-background-subagents.md: e37abdd798242bc1086754e86a85ef46c08e59a3 -2026-07-21-continuable-background-subagents.zh.md: 729ec62d9d3511661638144cdef6e31bf98ecdb7 +2026-07-21-continuable-background-subagents.md: ffb0e2f15648aba9c988f955729c252f4776a149 +2026-07-21-continuable-background-subagents.zh.md: 4a7d425f87c9f6b6d641bb89b3decbdea289223d diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md index e37abdd798..ffb0e2f156 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -37,7 +37,7 @@ Every later turn creates another Task. Its producer resources cover only that ac Opening a child session in a human-facing adapter reads its persisted transcript and does not resume an Agent merely to display it. Human input starts or joins the same Task-backed activation used by parent input through the continuation manager. A human-started Task retains the exact currently loaded parent Agent as its notification target, and `task_output` remains the single result path. The existing completion listener injects at most one unsolicited notice while the Task is unreported; `kill`, a terminal read, or a terminal wait may mark it reported and suppress that notice. Human interaction is therefore permitted only while that parent instance remains live. A user-owned conversation that may outlive the parent and explicitly merge a conclusion back belongs to [interactive side sessions](../../proposed/feature/2026-07-08-interactive-side-sessions.md), not this Task-owned lifecycle. -`TaskService.start()` rejects producers when no Task control surface is attached. A human-facing adapter that accepts child input must therefore attach a Task control surface, or run in a deployment that loads `@deepseek-ai/dsh-tool-tasks`; loading the Task service alone is insufficient. SDK-generated spawn and fork compositions mount `@deepseek-ai/dsh-tasks-local` and `@deepseek-ai/dsh-tool-tasks` with the subagent tools. This dependency is the cost of using the same Task result, cancellation, and notification path for parent- and human-started activations. +`TaskService.start()` rejects producers when no Task control surface is attached. A human-facing adapter that accepts child input must therefore attach a Task control surface, or run in a deployment that loads `@deepseek-ai/dsh-tool-tasks`; loading the Task service alone is insufficient. This dependency is the cost of using the same Task result, cancellation, and notification path for parent- and human-started activations. Cancellation always targets the whole current activation. If human and parent messages have joined one turn, either caller's cancellation aborts that turn, disposes its run, and settles its Task as `killed`; the messages do not have independent results or cancellation rights. `followup()` requires the caller's signal, and abort while live steering awaits request admission aborts the activation-owned controller so the provider discards the pending message and the call settles only after the child reaches quiescence. Independent cancellation requires a later message to start a separate turn instead of steering the current one. @@ -112,7 +112,7 @@ Task records and active-run associations are process-local. Persistence makes th - `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: an absent or detached flush listener and a permanent listener failure reject with `DURABILITY_FAILED`, a transient loop-checkpoint failure can succeed on the final confirmation, cancellation owns either final-checkpoint outcome, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent/tests/continuation.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service, and `ctx.subagents`) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is pre-turn, model-hidden, versioned, durable under the service-allocated child id, and survives blocked or throwing initial prompt admission; cancellation, steering, cold follow-up, authorization, ownership conflicts, and resume races retain the contracts described above. - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` pins the `send_message` schema, coordinator attribution, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the configured background route: continuable mode requires a resumable provider and returns both ids without requiring `send_message`, while one-shot mode keeps the plain Task acknowledgement even when the provider can resume. -- `packages/scaffold/helper/tests/project.spec.ts` pins the Task service and model-facing Task controls in generated spawn and fork compositions. +- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` pins Task service integration and the model-facing Task controls. - The keyless ACP snapshot scenario `subagent-continuable` (examples/acp-agent) pins the model-visible transcript: the two-id acknowledgement, a final durability-confirmation failure rendered through `task_output` without unconfirmed child output, and a `send_message` follow-up whose started Task fails with the id unavailable. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md index 729ec62d9d..4a7d425f87 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -37,7 +37,7 @@ durable child Session 用户界面适配器打开 child 会话时,只读取持久化 transcript,不会仅为展示而恢复 agent。用户输入通过继续执行管理器,启动或加入与 parent 输入相同的 Task 激活。由用户启动的 Task 会保留当前加载的精确 parent Agent 作为通知目标,`task_output` 仍是唯一结果路径。只要 Task 尚未标记为已报告,现有完成监听器最多注入一条主动通知;`kill`、终态读取或终态等待都可能将其标记为已报告,并抑制这条通知。因此,仅允许在该 parent 实例保持存活时进行用户交互。可以比 parent 存活更久、并将结论显式合并回去的用户自有会话属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.md),不属于这一由 Task 持有的生命周期。 -如果没有附加 Task 控制面,`TaskService.start()` 会拒绝 producer。因此,接受 child 输入的用户界面适配器必须附加 Task 控制面,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。SDK 生成的 spawn 与 fork 组合在挂载 subagent 工具时,也会挂载 `@deepseek-ai/dsh-tasks-local` 与 `@deepseek-ai/dsh-tool-tasks`。这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。 +如果没有附加 Task 控制面,`TaskService.start()` 会拒绝 producer。因此,接受 child 输入的用户界面适配器必须附加 Task 控制面,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。 取消始终作用于当前完整激活。如果用户消息和 parent 消息已经加入同一个轮次,任一调用方发起取消都会中止该轮次、dispose 其 run,并将对应 Task 结算为 `killed`;这些消息没有独立的结果或取消权。`followup()` 要求调用方提供信号;若在线 steering 正在等待请求准入时该信号被中止,激活自有的 controller 会被中止,以便提供方丢弃待处理消息,并且该调用仅在子 agent 完全停稳后结算。若需要独立取消,后续消息必须另起轮次,而不能加入当前轮次。 @@ -112,7 +112,7 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 - `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:缺少 flush 监听器、flush 监听器已脱离或监听器持续失败时,均会以 `DURABILITY_FAILED` 拒绝;循环检查点的瞬时失败可在最终确认成功后继续完成,发生取消时最终检查点无论成功还是失败都由取消优先决定结果,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent/tests/continuation.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务和 `ctx.subagents`):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次前、对模型隐藏、带版本、在服务分配的 child id 下持久化,并在初始 prompt admission 阻止请求或抛出异常时仍保留;取消、steering、cold follow-up、授权、所有权冲突与 resume 竞态保留上述约定。 - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、coordinator 来源标记、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖配置的后台路由:可继续模式要求提供方可恢复,并在不要求 `send_message` 的情况下返回两个 id;即使提供方可以恢复,一次性模式仍保持普通的 Task 确认消息。 -- `packages/scaffold/helper/tests/project.spec.ts` 固定生成的 spawn 与 fork 组合中的 Task 服务及面向模型的 Task 控制工具。 +- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 固定 Task 服务集成及面向模型的 Task 控制工具。 - 无密钥 ACP 快照场景 `subagent-continuable`(examples/acp-agent)固定模型可见的 transcript:双 id 确认消息、最终持久性确认失败(该失败通过 `task_output` 呈现,且不包含未经确认的 child 输出),以及一次 `send_message` 后续操作——其已启动的 Task 会带着「id 不可用」失败。 ## 影响 diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml index 912cc3a7d7..0e82d81ad0 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.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-27-typescript-sdk-and-sdk-subagent-backend.md -2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: e4ceeb6b1bc0b9e10fb5383454cbac1b404d4ad8 -2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 18e5c891f5810135b1ed5e6cfc27b94ad452c575 +2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: e5014689bd52992b4897b6f288d4b87dca54898b +2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 78a7c23ee5bd34ce11004b49a63765a2ccf25bc4 diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md index e4ceeb6b1b..e5014689bd 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md @@ -12,8 +12,8 @@ The stdio JSON-RPC serving surface (`@deepseek-ai/dsh-jsonrpc`, the [single-exe Three packages, layered exactly like the existing Python stack, plus one Service provider registration: -- **`@deepseek-ai/dsh-sdk-protocol`** (`packages/scaffold/protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` moves here verbatim from `dsh-jsonrpc` (which now imports it), and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. The package root explicitly exports that complete interface and provides no source-module deep imports. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. One behavioral change: an error response now rejects with `JsonRpcResponseError` carrying the wire `code`/`data` (the Python client already preserved these; the old transport threw a bare `Error` with only the message). -- **`@deepseek-ai/dsh-sdk-client`** (`packages/scaffold/client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Its package-root consumer interface explicitly exports both client layers, caller-facing types, and the protocol-owned `JsonRpcResponseError`; source modules, normalization helpers, and the notification producer stay internal. `TurnResult.events` contains only the root session's typed events, while `notifications` retains session ids across the root and descendants discovered from `subagent.started`; session-tree scoping is client-side, mirroring `client.py`. Deliberate asymmetries with Python: the launch spec is explicit `command`/`args` (no bundled-runtime resolution — that is a distribution concern with no TS consumer yet); `env` replaces rather than merges (callers own credential policy; `scrubbedParentEnv` from the subprocess seam is one import away); `TurnResult` carries the structured `reason` (Python exposes only `status`); teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit (the client runs outside any harness context, so it cannot ride `ctx.subprocess`). +- **`@deepseek-ai/dsh-sdk-protocol`** (`packages/sdk/protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` moves here verbatim from `dsh-jsonrpc` (which now imports it), and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. The package root explicitly exports that complete interface and provides no source-module deep imports. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. One behavioral change: an error response now rejects with `JsonRpcResponseError` carrying the wire `code`/`data` (the Python client already preserved these; the old transport threw a bare `Error` with only the message). +- **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Its package-root consumer interface explicitly exports both client layers, caller-facing types, and the protocol-owned `JsonRpcResponseError`; source modules, normalization helpers, and the notification producer stay internal. `TurnResult.events` contains only the root session's typed events, while `notifications` retains session ids across the root and descendants discovered from `subagent.started`; session-tree scoping is client-side, mirroring `client.py`. Deliberate asymmetries with Python: the launch spec is explicit `command`/`args` (no bundled-runtime resolution — that is a distribution concern with no TS consumer yet); `env` replaces rather than merges (callers own credential policy; `scrubbedParentEnv` from the subprocess seam is one import away); `TurnResult` carries the structured `reason` (Python exposes only `status`); teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit (the client runs outside any harness context, so it cannot ride `ctx.subprocess`). - **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `provider`/`model` config feeds the child's `initialize`; `env` is where deployments pass the child's own key and `DSH_CORDIS_CONFIG`. - **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, tree-scoped teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself. diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md index 18e5c891f5..78a7c23ee5 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md @@ -12,8 +12,8 @@ stdio JSON-RPC 对外服务接口(`@deepseek-ai/dsh-jsonrpc`,见[单文件 三个包,分层与既有 Python 栈完全一致,外加一个 Service provider 注册: -- **`@deepseek-ai/dsh-sdk-protocol`**(`packages/scaffold/protocol/`)—— 把线协议做成共享且具名。`JsonRpcLineTransport` 从 `dsh-jsonrpc` 原样移入(后者现在导入它),`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result`、`SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。该包根显式导出这一完整接口,且不提供指向源模块的深层导入。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 `code`/`data` 的 `JsonRpcResponseError` 拒绝(Python 客户端本就保留这些;旧传输只抛携带消息的裸 `Error`)。 -- **`@deepseek-ai/dsh-sdk-client`**(`packages/scaffold/client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(spawn、分帧、通知扇出、有类型的错误表面、经共享 dispose(资源释放)阶梯关闭至完全停稳)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 `JsonRpcResponseError`;源模块、规范化辅助函数和通知投递端都保留为内部实现。`TurnResult.events` 只包含根会话的类型化事件,而 `notifications` 则保留根会话及从 `subagent.started` 发现的后代各自的会话 id;基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。与 Python 的刻意不对称:启动规格是显式 `command`/`args`(无捆绑运行时解析——那是尚无 TS 消费方的发行问题);`env` 整体替换而非合并(凭据策略归调用方;subprocess seam 的 `scrubbedParentEnv` 一个 import 即得);`TurnResult` 携带结构化 `reason`(Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(客户端运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess`)。 +- **`@deepseek-ai/dsh-sdk-protocol`**(`packages/sdk/protocol/`)—— 把线协议做成共享且具名。`JsonRpcLineTransport` 从 `dsh-jsonrpc` 原样移入(后者现在导入它),`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result`、`SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。该包根显式导出这一完整接口,且不提供指向源模块的深层导入。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 `code`/`data` 的 `JsonRpcResponseError` 拒绝(Python 客户端本就保留这些;旧传输只抛携带消息的裸 `Error`)。 +- **`@deepseek-ai/dsh-sdk-client`**(`packages/sdk/client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(spawn、分帧、通知扇出、有类型的错误表面、经共享 dispose(资源释放)阶梯关闭至完全停稳)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 `JsonRpcResponseError`;源模块、规范化辅助函数和通知投递端都保留为内部实现。`TurnResult.events` 只包含根会话的类型化事件,而 `notifications` 则保留根会话及从 `subagent.started` 发现的后代各自的会话 id;基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。与 Python 的刻意不对称:启动规格是显式 `command`/`args`(无捆绑运行时解析——那是尚无 TS 消费方的发行问题);`env` 整体替换而非合并(凭据策略归调用方;subprocess seam 的 `scrubbedParentEnv` 一个 import 即得);`TurnResult` 携带结构化 `reason`(Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(客户端运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess`)。 - **`@deepseek-ai/dsh-subagent-dsh-sdk`**(`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,采用与 `subagent-acp` 对等的结构:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样通过 `onError` sink 将结果归一为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未运行任何轮次便已结束的子进程,都是 `error`)。其 `provider`/`model` 配置喂给子进程的 `initialize`;`env` 是部署传入子进程自有密钥与 `DSH_CORDIS_CONFIG` 的地方。 - **subagent seam 新增 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境清理、进程树清理)属于 `dsh-subprocess` seam;`subagent-acp` 经 `ctx.subprocess` spawn 子进程,本后端则经 SDK 客户端 spawn 子进程(subprocess README 记载的 SDK 托管传输例外)并自行应用该 seam 的 `scrubbedParentEnv()`。 diff --git a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.i18n.yaml index 1bec2a758b..a00ad7057e 100644 --- a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.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-telemetry-anonymous-user-id.md -2026-07-31-telemetry-anonymous-user-id.md: 75b65e9fd477d19afb3a3a25e424a7f7620099a3 -2026-07-31-telemetry-anonymous-user-id.zh.md: 3db5b665f9cbbe6f884a9717afa758f22a419658 +2026-07-31-telemetry-anonymous-user-id.md: 4d5d14cb63ebbf9d9f71c729da998ad9804e56bf +2026-07-31-telemetry-anonymous-user-id.zh.md: 69c48076ceb97a4bbc3cc35a2aaa3e632b1b1b06 diff --git a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md index 75b65e9fd4..4d5d14cb63 100644 --- a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md +++ b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md @@ -6,11 +6,11 @@ English | [中文](2026-07-31-telemetry-anonymous-user-id.zh.md) ## Problem -Session telemetry is mounted by default ([default-mount Note](2026-07-31-web-telemetry-default-mount.md)), but the OTel Resource carried only `service.name`/`service.version` — no user-level identity at all, so the collector could neither aggregate per user nor count active users. The only prior ruling on point was an unimplemented one to derive a user id by hashing the hostname/local IP; the dsh-sdk toolchain keeps its own anonymous id (`$DSH_HOME/telemetry.json`), but that is the launcher feed's private fact, unrelated to the OTel feed. The OTel feed needed an anonymous user identity with clean semantics. +Session telemetry is mounted by default ([default-mount Note](2026-07-31-web-telemetry-default-mount.md)), but the OTel Resource carried only `service.name`/`service.version` — no user-level identity at all, so the collector could neither aggregate per user nor count active users. The only prior ruling on point was an unimplemented one to derive a user id by hashing the hostname/local IP. The OTel feed needed an anonymous user identity with clean semantics. ## Decision -`getOrCreateAnonymousUserId()` returns the bare UUID line in `$DSH_HOME/.userid` (resolved by `resolveDshHome`, `$DSH_HOME` > `~/.dsh`), minting and persisting a random UUID v4 on first use; the backend constructor carries it as the Resource's `user.id` (the OTel semconv user attribute), once per export batch. The original implementation lived inside `session-telemetry-otel` because no second real consumer existed. `/feedback` later became that consumer, so [the shared-id decision](../architecture/2026-08-07-shared-feedback-telemetry-user-id.md) moves ownership to `@deepseek-ai/dsh-user-id` without changing the storage, anonymity, concurrency, or loss semantics recorded here. The dsh-sdk launcher telemetry keeps its own anonymous-id store (`telemetry.json`) and remains unrelated. +`getOrCreateAnonymousUserId()` returns the bare UUID line in `$DSH_HOME/.userid` (resolved by `resolveDshHome`, `$DSH_HOME` > `~/.dsh`), minting and persisting a random UUID v4 on first use; the backend constructor carries it as the Resource's `user.id` (the OTel semconv user attribute), once per export batch. The original implementation lived inside `session-telemetry-otel` because no second real consumer existed. `/feedback` later became that consumer, so [the shared-id decision](../architecture/2026-08-07-shared-feedback-telemetry-user-id.md) moves ownership to `@deepseek-ai/dsh-user-id` without changing the storage, anonymity, concurrency, or loss semantics recorded here. | Ruling | Value | Rationale | |---|---|---| @@ -32,13 +32,12 @@ Session telemetry is mounted by default ([default-mount Note](2026-07-31-web-tel | Hostname/IP-hash-derived id (the prior ruling) | Reversible means not anonymous; the random UUID is semantically clean — the user ruled to supersede | | user.id on every record's attributes (Claude Code's shape) | Touches the session-telemetry seam contract or injects per record, growing the wire; once per batch on the Resource already aggregates | | A shared package before `/feedback` needed the id (the first cut) | At that time the only real consumer was the OTel backend; extraction became justified only when direct feedback needed the same correlation id | -| Reusing telemetry.json instead of a new file | The file name/JSON format files the identity under the launcher feed's naming; the OTel feed's identity is a standalone fact | | AppCLIEntry reading the id and injecting via config patch | Every surface entry needs wiring; a runtime fact inside deployment config conflates the two | | Housing it in `@deepseek-ai/dsh-paths` | paths is pure path computation with zero IO; a persisting identity capability would pollute the package boundary | ## Consequences - One `$DSH_HOME` is one stable user in the OTel feed; separate homes are separate users by construction, with no cross-home linking mechanism. -- The OTel feed and `/feedback` share `.userid`; the launcher feed still uses `telemetry.json` and cannot be correlated with them. +- The OTel feed and `/feedback` share `.userid`. - Deleting `.userid` resets the identity (effective next launch); on an unwritable home each process holds its own in-memory id until the home becomes writable. - The [default-mount Note](2026-07-31-web-telemetry-default-mount.md)'s identity follow-up is closed for the anonymous-user-id part by this decision; hostname/surface dimensions, the redaction rule, and the usage-metrics track remain open. diff --git a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.zh.md b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.zh.md index 3db5b665f9..69c48076ce 100644 --- a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.zh.md @@ -6,11 +6,11 @@ Status: implemented ## Problem -session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry-default-mount.md)),但 OTel Resource 只有 `service.name`/`service.version`,没有任何用户级标识——接收端无法按用户聚合、无法数活跃用户。此前唯一相关口径是一条未实现的「hostname/本机 IP 哈希派生 user.id」裁定;dsh-sdk 工具链另有自用的匿名 id(`$DSH_HOME/telemetry.json`),但那是 launcher 回流的私有事实,与 OTel 回流无关。需要给 OTel 回流一个语义干净的匿名用户身份。 +session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry-default-mount.md)),但 OTel Resource 只有 `service.name`/`service.version`,没有任何用户级标识——接收端无法按用户聚合、无法数活跃用户。此前唯一相关口径是一条未实现的「hostname/本机 IP 哈希派生 user.id」裁定。需要给 OTel 回流一个语义干净的匿名用户身份。 ## Decision -`getOrCreateAnonymousUserId()` 返回 `$DSH_HOME/.userid`(`resolveDshHome` 解析,`$DSH_HOME` > `~/.dsh`)中的裸 UUID 行,首用生成随机 UUID v4 并落盘;后端构造时把它作为 Resource 的 `user.id`(OTel semconv 标准用户属性)随每批导出携带一次。原始实现位于 `session-telemetry-otel`,因为当时不存在第二个真实消费方。`/feedback` 后来成为该消费方,因此[共享 id 决策](../architecture/2026-08-07-shared-feedback-telemetry-user-id.md)将所有权移交给 `@deepseek-ai/dsh-user-id`,但不改变本 Note 记录的存储、匿名、并发与丢失语义。dsh-sdk launcher telemetry 继续使用自己独立的匿名 id 存储(`telemetry.json`),与此身份无关。 +`getOrCreateAnonymousUserId()` 返回 `$DSH_HOME/.userid`(`resolveDshHome` 解析,`$DSH_HOME` > `~/.dsh`)中的裸 UUID 行,首用生成随机 UUID v4 并落盘;后端构造时把它作为 Resource 的 `user.id`(OTel semconv 标准用户属性)随每批导出携带一次。原始实现位于 `session-telemetry-otel`,因为当时不存在第二个真实消费方。`/feedback` 后来成为该消费方,因此[共享 id 决策](../architecture/2026-08-07-shared-feedback-telemetry-user-id.md)将所有权移交给 `@deepseek-ai/dsh-user-id`,但不改变本 Note 记录的存储、匿名、并发与丢失语义。 | 裁定 | 取值 | 理由 | |---|---|---| @@ -32,13 +32,12 @@ session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry | hostname/IP 哈希派生 id(此前口径) | 可反查即非匿名;随机 UUID 语义干净,用户裁决取代 | | user.id 放每条 record 的 attributes(Claude Code 形态) | 要动 session-telemetry seam 约定或逐条注入,wire 体积涨;Resource 每批一次已满足聚合 | | 在 `/feedback` 需要该 id 之前抽取共享包(初版实现) | 当时唯一的真实消费方是 OTel 后端;只有直接反馈需要同一个关联 id 后,抽取才具备依据 | -| 复用 telemetry.json 不新建文件 | 文件名/JSON 格式把身份挂在 launcher 链路命名下;OTel 回流身份是独立事实 | | AppCLIEntry 读好 id 经 config patch 注入 | 每个 surface 入口都要接线;config 里传运行时事实与部署配置混淆 | | 挂进 `@deepseek-ai/dsh-paths` | paths 是纯路径计算零 IO;带持久化的身份能力会污染包边界 | ## Consequences - 一个 `$DSH_HOME` 在 OTel 回流中是一个稳定用户;不同 home 在构造上就是不同用户,无跨 home 关联机制。 -- OTel 回流与 `/feedback` 共享 `.userid`;launcher 回流仍使用 `telemetry.json`,无法与前两者关联。 +- OTel 回流与 `/feedback` 共享 `.userid`。 - 删除 `.userid` 即重置身份(下次启动生效);home 不可写时每进程各自持有一个内存 id 直至恢复可写。 - [默认挂载 Note](2026-07-31-web-telemetry-default-mount.md) 的身份 follow-up 中「匿名用户 id」项由本决定关闭;hostname/surface 维度与脱敏规则、usage-metrics track 仍是待办。 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml index b23fcae758..c8970f1f94 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.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/simplification/2026-07-20-remove-stdio-and-echo-agents.md -2026-07-20-remove-stdio-and-echo-agents.md: 23fcb90599c2ff96cd7ac0e6f7ee8fd508a6d1ad -2026-07-20-remove-stdio-and-echo-agents.zh.md: 7aabf5612a54245327da9af804f745237472cb88 +2026-07-20-remove-stdio-and-echo-agents.md: fcfd0399bc6cf040287a057c56339690c8d92d8a +2026-07-20-remove-stdio-and-echo-agents.zh.md: 6f31d11885aad43b5f2ea236efb95c950290033a diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md index 23fcb90599..fcfd0399bc 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md @@ -22,7 +22,7 @@ The remaining application roles are explicit: - [`dsh --profile headless`](../../../../apps/cli/README.md) owns non-interactive execution. Its `headless` profile is the product composition; `examples/headless-agent` owns replay snapshots, generic real-agent suites, and an unexported keyless Loader driver. - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) and `@deepseek-ai/dsh-jsonrpc` own their framed protocol integrations. -The SDK project model and create/config workflows replace the `stdio` run-interface option with `tui`; generated TUI projects compose `@deepseek-ai/dsh-tui` and create or resume one exact session. Repository-facing demo documentation requires a DeepSeek API key and leads with the real Headless or TUI agents. +The SDK project model that carried the `stdio` run-interface option is deleted by the [SDK project toolchain removal](2026-08-11-remove-sdk-project-toolchain.md). Repository-facing demo documentation requires a DeepSeek API key and leads with a current runnable product. Keyless validation is test-owned. The Headless Loader smoke uses a fixture adapter to exercise a real tool round trip, the `dsh` built-bin suite pins the published one-shot entry and output, the product Headless snapshot pins persistence, and the Headless PTY shutdown e2e pins signal escalation. Package-specific Loader tests keep deterministic adapters beside their scenarios. None is exposed as a runnable mock agent. @@ -45,5 +45,5 @@ The built `dsh` bin rejects a piped TUI launch before Loader boot and points at - Interactive and non-interactive product execution each have one owner and one runnable coding leaf. - The repository has no keyless user-facing agent demo; local agent demos require `DEEPSEEK_API_KEY`. - CI retains keyless real-entry coverage through test fixtures rather than a product command. -- Existing stdio-agent configurations, Echo commands, and SDK `--interface=stdio` invocations fail instead of being translated. +- Existing stdio-agent configurations and Echo commands fail instead of being translated. - Piped multi-turn interaction in one process and the readline provider for non-TTY `ask_user_question` are intentionally gone; resume covers durable multi-turn work, and a non-TTY composition must supply its own interaction provider. diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md index 7aabf5612a..6f31d11885 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md @@ -22,7 +22,7 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个 - [`dsh --profile headless`](../../../../apps/cli/README.md) 负责非交互式执行。其 `headless` profile 是产品组装;`examples/headless-agent` 负责回放快照、通用真实 agent 测试套件和未导出的无密钥 Loader driver。 - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) 和 `@deepseek-ai/dsh-jsonrpc` 负责各自的分帧协议集成。 -SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换为 `tui`;生成的 TUI 工程组合 `@deepseek-ai/dsh-tui`,并创建或恢复一个确切会话。仓库中的演示文档要求 DeepSeek API key,并优先引导到真实的 Headless 或 TUI agent。 +承载 `stdio` 运行接口选项的 SDK 项目模型已由 [SDK 项目工具链移除决策](2026-08-11-remove-sdk-project-toolchain.md)删除。仓库中的演示文档要求 DeepSeek API key,并优先引导到当前可运行的产品。 无密钥验证由测试负责。Headless Loader 冒烟测试使用 fixture 适配器验证真实工具往返;`dsh` built-bin 测试套件固定已发布的一次性入口和输出;产品 Headless 快照固定持久化;Headless PTY 关闭 e2e 固定信号升级。各包专属的 Loader 测试则将确定性适配器放在对应场景旁。其中任何一项都不会作为可运行的 mock agent 对外暴露。 @@ -45,5 +45,5 @@ TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真 - 交互式与非交互式产品执行分别只有一个归属方和一个可运行的 coding 叶节点。 - 仓库没有面向用户的无密钥 agent 演示;本地 agent 演示需要 `DEEPSEEK_API_KEY`。 - CI 通过测试 fixture 保留针对真实入口的无密钥覆盖,而不是依赖产品命令。 -- 既有 stdio agent 配置、Echo 命令和 SDK `--interface=stdio` 调用会直接失败,不会被转换。 +- 既有 stdio agent 配置和 Echo 命令会直接失败,不会被转换。 - 有意移除了单进程内基于管道的多轮交互,以及面向非 TTY `ask_user_question` 的 readline 提供方;恢复会话可以满足持久多轮工作,非 TTY 组装则必须自行提供交互提供方。 diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml index bcf543a344..5b13319748 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.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/simplification/2026-08-04-remove-tui-package.md -2026-08-04-remove-tui-package.md: 19cc7d1a89a55bb57a69b9fce301f48f89384acd -2026-08-04-remove-tui-package.zh.md: be18cbd33cd4a2bb592de4e7986b2786da272d0f +2026-08-04-remove-tui-package.md: 4b89e45f009c2b240f88db40875d77d1a809e721 +2026-08-04-remove-tui-package.zh.md: 0ff7fa0b4fd48f51b12e407000ee98fb6a28f476 diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md index 19cc7d1a89..4b89e45f00 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md @@ -14,7 +14,7 @@ The package also made the repository's supported application inventory misleadin The `packages/ui/tui` package is deleted without a compatibility package or alias. Its source, package tests, terminal snapshots, dependency declarations, patched `pi-tui` artifact, workspace references, generated service catalog entry, and documentation are removed together. Generic host and agent-loop capabilities remain unchanged. -The SDK run-interface union now contains only `acp` and `embed`. `create-sdk` defaults to ACP, generated templates contain no terminal startup, resume, session-environment, or model-argument branch, and the builtin `ask-user` feature is removed because neither remaining generated interface supplies a `UserInteractionProvider`. Host applications may still mount the provider-neutral `dsh-user-interaction`, `dsh-commands`, and presentation services directly. +The SDK project toolchain that remained as the TUI package's final consumer is deleted by the [toolchain removal decision](2026-08-11-remove-sdk-project-toolchain.md). Host applications may still mount the provider-neutral `dsh-user-interaction`, `dsh-commands`, and presentation services directly. This decision supersedes the reusable-package retention in [the explicit-config `dsh` entrypoint decision](../../archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.md) and the current applicability of the archived TUI implementation notes. Their historical records remain frozen, but they are not authority for the supported package or application inventory. @@ -22,7 +22,7 @@ This note consolidates the deleted package-only records that could not remain cu ## Verification -Repository searches and generated catalogs contain no TUI package, dependency patch, SDK interface option, service key, or package link. Focused SDK tests cover ACP and embedded creation, configuration, templates, and snapshots. The ordinary source build, typecheck, lint, hygiene, documentation gates, and remaining assembled snapshot suites run without the deleted workspace. +Repository searches and generated catalogs contain no TUI package, dependency patch, service key, or package link. The ordinary source build, typecheck, lint, hygiene, documentation gates, and remaining assembled snapshot suites run without the deleted workspace. ## Alternatives considered @@ -34,6 +34,6 @@ Repository searches and generated catalogs contain no TUI package, dependency pa ## Consequences -DeepSeek Harness has no terminal UI package or generated TUI application. Existing imports, `cordis.yml` rows, SDK `--interface=tui` requests, and projects that depend on the package fail instead of being translated. Web remains the shipped interactive surface; ACP, JSON-RPC, and one-shot CLI remain the non-Web entry points. +DeepSeek Harness has no terminal UI package. Existing imports and `cordis.yml` rows that depend on the package fail instead of being translated. Web remains the shipped interactive surface; ACP, JSON-RPC, and one-shot CLI remain the non-Web entry points. The provider-neutral command, user-interaction, approval, tool-presentation, PTY, and session-projection capabilities remain available to other hosts. Reintroducing a terminal frontend requires a named product or deployment, an explicit package boundary, a concrete interaction provider, and assembled lifecycle and transcript acceptance for that frontend. diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.zh.md b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.zh.md index be18cbd33c..0ff7fa0b4f 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.zh.md @@ -14,7 +14,7 @@ Status: implemented 删除 `packages/ui/tui` 包,不提供兼容包或别名。其源码、包测试、终端快照、依赖声明、已打补丁的 `pi-tui` 产物、workspace 引用、生成的服务目录条目和文档会一并移除。通用宿主能力与 agent loop(智能体循环)能力保持不变。 -SDK 的运行接口联合类型现在只包含 `acp` 与 `embed`。`create-sdk` 默认使用 ACP,生成的模板不再包含终端启动、恢复、会话环境或模型参数分支;内置的 `ask-user` 功能也被移除,因为剩余两个生成接口都不提供 `UserInteractionProvider`。宿主应用仍可直接挂载提供方无关的 `dsh-user-interaction`、`dsh-commands` 和呈现服务。 +作为 TUI 包最后消费方的 SDK 项目工具链已由[工具链移除决策](2026-08-11-remove-sdk-project-toolchain.md)删除。宿主应用仍可直接挂载提供方无关的 `dsh-user-interaction`、`dsh-commands` 和呈现服务。 本决策取代[显式配置 `dsh` 入口决策](../../archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.md)中保留可复用包的决定,也使已归档 TUI 实现记录不再适用于当前状态。这些历史记录继续保持冻结,但不再作为受支持包或应用清单的依据。 @@ -22,7 +22,7 @@ SDK 的运行接口联合类型现在只包含 `acp` 与 `embed`。`create-sdk` ## 验证 -仓库搜索结果与生成的服务目录中不再包含 TUI 包、依赖补丁、SDK 接口选项、服务键或包链接。专项 SDK 测试覆盖 ACP 和嵌入式两种模式的创建、配置、模板与快照。常规源码构建、类型检查、lint、hygiene、文档门禁以及其余组装快照测试套件均可在没有已删除 workspace 的情况下运行。 +仓库搜索结果与生成的服务目录中不再包含 TUI 包、依赖补丁、服务键或包链接。常规源码构建、类型检查、lint、hygiene、文档门禁以及其余组装快照测试套件均可在没有已删除 workspace 的情况下运行。 ## 考虑过的替代方案 @@ -34,6 +34,6 @@ SDK 的运行接口联合类型现在只包含 `acp` 与 `embed`。`create-sdk` ## 后果 -DeepSeek Harness 不再提供终端 UI 包或生成的 TUI 应用。现有 import、`cordis.yml` 条目、SDK `--interface=tui` 请求以及依赖该包的项目会直接失败,不会得到兼容转换。Web 仍是已交付的交互表面;ACP、JSON-RPC 与一次性 CLI 仍是 Web 之外的入口。 +DeepSeek Harness 不再提供终端 UI 包。现有 import 和依赖该包的 `cordis.yml` 条目会直接失败,不会得到兼容转换。Web 仍是已交付的交互表面;ACP、JSON-RPC 与一次性 CLI 仍是 Web 之外的入口。 提供方无关的命令、用户交互、审批、工具呈现、PTY 与会话投影能力仍可供其他宿主使用。重新引入终端前端时,必须为其提供具名产品或部署、显式包边界、具体交互提供方,以及组装后的生命周期与 transcript(文本记录)验收。 diff --git a/.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.i18n.yaml new file mode 100644 index 0000000000..9fd01c3972 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.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 .agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.md +2026-08-11-remove-sdk-project-toolchain.md: 5c147560a2a9a796d6ca98d1acaf648049ae6a9f +2026-08-11-remove-sdk-project-toolchain.zh.md: 07624052e936d8705958e826c68f39f34dcd146a diff --git a/.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.md b/.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.md new file mode 100644 index 0000000000..5c147560a2 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.md @@ -0,0 +1,41 @@ +# Agent Note: Remove the SDK project toolchain + +Status: implemented + +English | [中文](2026-08-11-remove-sdk-project-toolchain.zh.md) + +## Problem + +The repository carried an unreleased developer-project product with no consumers. `@deepseek-ai/create-sdk` generated an editable Cordis project, `@deepseek-ai/dsh-scripts` supplied its `dsh-sdk` development, build, start, configuration, and plugin-install commands, `@deepseek-ai/dsh-helper` coordinated feature definitions and multi-file project edits, and `@deepseek-ai/dsh-telemetry` reported launcher activity. The design aimed to keep generated projects editable while giving creation and later configuration one definition of dependencies, Cordis entries, environment placeholders, and owned files. + +No project was created through a public release, and no current repository or external consumer requires that lifecycle. Keeping it meant maintaining four packages, two interactive command products, project templates, package-manager adapters, configuration reconciliation, launcher telemetry, a repository skill, and their tests and documentation without evidence that the product boundary should exist. + +The same `scaffold/` group also contained the independently used SDK protocol, TypeScript client, and JSON-RPC server. Those packages serve the Python SDK, the `dsh-sdk` subagent provider, and the JSON-RPC example; their runtime protocol does not depend on generated projects or the removed launcher. + +## Decision + +The SDK project toolchain is deleted. The `@deepseek-ai/create-sdk`, `@deepseek-ai/dsh-scripts`, `@deepseek-ai/dsh-helper`, and `@deepseek-ai/dsh-telemetry` packages, their binaries, tests, templates, feature catalog, project-editing model, package-manager support, launcher telemetry, and repository creation skill have no replacement or compatibility layer. Their workspace, build, test, packaging, documentation-generator, vendoring-rescope, and dependency records are removed with them. + +The runtime SDK remains. `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, and `@deepseek-ai/dsh-jsonrpc` move unchanged from `packages/scaffold/` to `packages/sdk/`; their npm names and wire behavior do not change. Consumers continue to provide an executable plus an external `cordis.yml`, and the JSON-RPC server remains an ordinary plugin selected by that configuration. + +The canceled developer-project, project-editing, and follow-up-capabilities proposals are deleted rather than retained as active or rejected records. This note preserves the motivation they shared, the decision not to ship that product, the capability given up, and the condition for reconsideration. Frozen archived Agent Notes remain historical snapshots and are not edited. + +## Verification + +The workspace contains none of the four deleted package names or either removed command product. Package aggregates, source path maps, package metadata, test collection, publication constraints, generated catalogs, dependency notices, and the lockfile resolve only the three runtime SDK packages under `packages/sdk/`. The runtime SDK package tests, its built server smoke, TypeScript consumers, repository documentation gates, build, and hygiene checks pin the surviving behavior and the absence of stale package paths. + +## Alternatives considered + +**Delete only the initializer.** Rejected because `dsh-sdk`, the shared project model, and launcher telemetry existed to operate projects created by that initializer, and there are no existing projects that need them. + +**Keep error-only packages or command aliases.** Rejected because none of the commands shipped publicly. A tombstone would preserve package and executable surface area without a compatibility obligation. + +**Delete the runtime SDK stack too.** Rejected because the Python SDK, the out-of-process Harness subagent provider, and the JSON-RPC example are current consumers of the protocol, client, and server. + +**Leave the runtime stack under `packages/scaffold/`.** Rejected because nothing left in that group scaffolds a project. `packages/sdk/` states the surviving role directly even though the repository as a whole is also an SDK. + +## Consequences + +DeepSeek Harness no longer creates or manages standalone developer SDK projects. Automatic project generation, feature-tree configuration, local-plugin scaffolding, project-local development/build/start commands, and developer-cycle launcher telemetry are intentionally unavailable; ordinary applications and runtime distributions continue to compose plugins through their owning packages and `cordis.yml` files. + +The repository loses the complete support graph rather than carrying dormant abstractions. Reintroducing a project toolchain requires a real consumer and a new proposal grounded in that consumer's workflow; it does not revive these packages or their deleted compatibility-free formats by default. diff --git a/.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.zh.md b/.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.zh.md new file mode 100644 index 0000000000..07624052e9 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 移除 SDK 项目工具链 + +Status: implemented + +[English](2026-08-11-remove-sdk-project-toolchain.md) | 中文 + +## 问题 + +仓库曾包含一套从未发布且没有消费方的开发者项目产品。`@deepseek-ai/create-sdk` 用于生成可编辑的 Cordis 项目;`@deepseek-ai/dsh-scripts` 提供 `dsh-sdk` 的开发、构建、启动、配置和插件安装命令;`@deepseek-ai/dsh-helper` 协调功能定义与多文件项目编辑;`@deepseek-ai/dsh-telemetry` 上报启动器活动。该设计旨在让生成的项目保持可编辑,并使项目创建与后续配置对依赖、Cordis 配置项、环境变量占位符和归属文件采用同一套定义。 + +没有任何项目是通过公开发布版创建的,当前仓库和外部消费方也都不需要这套生命周期。保留它就意味着继续维护 4 个包、2 套交互式命令产品、项目模板、包管理器适配器、配置调和、启动器遥测、1 个仓库 skill(技能)及其测试和文档,却没有证据表明这项产品边界应当存在。 + +同一 `scaffold/` 分组还包含各自独立使用的 SDK 协议、TypeScript 客户端和 JSON-RPC 服务器。这些包为 Python SDK、`dsh-sdk` subagent 提供方和 JSON-RPC 示例提供支持;其运行时协议不依赖生成的项目或被移除的启动器。 + +## 决策 + +删除 SDK 项目工具链。`@deepseek-ai/create-sdk`、`@deepseek-ai/dsh-scripts`、`@deepseek-ai/dsh-helper` 和 `@deepseek-ai/dsh-telemetry` 包及其二进制文件、测试、模板、功能目录、项目编辑模型、包管理器支持、启动器遥测和仓库项目创建 skill 均不提供替代实现或兼容层。与其对应的 workspace、构建、测试、打包、文档生成器、vendor scope 重写和依赖记录也一并移除。 + +保留运行时 SDK。`@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol` 和 `@deepseek-ai/dsh-jsonrpc` 保持原样,从 `packages/scaffold/` 移至 `packages/sdk/`;其 npm 名称和协议交互行为保持不变。消费方继续提供一个可执行文件和一份外置 `cordis.yml`,JSON-RPC 服务器仍是由该配置选择的普通插件。 + +被取消的开发者项目、项目编辑和后续能力提案予以删除,而不是保留为活跃或已否决记录。本 Agent Note 保留这些提案共有的动机、不交付该产品的决策、放弃的能力,以及重新考虑这一决定的条件。已冻结的归档 Agent Note 仍是历史快照,不作修改。 + +## 验证 + +workspace 中不再存在上述 4 个已删除包名或 2 套已移除的命令产品。包聚合配置、源码路径映射、包元数据、测试收集配置、发布约束、生成目录、依赖声明文件和锁文件都只解析 `packages/sdk/` 下的 3 个运行时 SDK 包。运行时 SDK 包测试、已构建服务器的冒烟测试、TypeScript 消费方、仓库文档门禁、构建和 hygiene 检查共同固定了保留的行为,并确保不存在陈旧的包路径。 + +## 考虑过的替代方案 + +**只删除初始化器。** 不予采纳,因为 `dsh-sdk`、共享项目模型和启动器遥测都是为了操作该初始化器创建的项目,而现有项目均不需要这些能力。 + +**保留仅用于报错的包或命令别名。** 不予采纳,因为这些命令都从未公开发布。墓碑会在不存在兼容义务的情况下保留包与可执行文件的接口范围。 + +**同时删除运行时 SDK 栈。** 不予采纳,因为 Python SDK、进程外 Harness subagent 提供方和 JSON-RPC 示例目前仍是协议、客户端和服务器的消费方。 + +**将运行时栈继续留在 `packages/scaffold/` 下。** 不予采纳,因为该分组剩余内容均不再负责搭建项目。尽管整个仓库同样是一套 SDK,`packages/sdk/` 仍直接说明了保留内容的职责。 + +## 后果 + +DeepSeek Harness 不再创建或管理独立的开发者 SDK 项目。自动项目生成、功能树配置、本地插件脚手架、项目本地的开发、构建和启动命令,以及面向开发周期的启动器遥测均有意不再提供;普通应用和运行时分发仍通过各自归属的包和 `cordis.yml` 文件组合插件。 + +仓库删除完整的支持图,而不是继续保留休眠抽象。重新引入项目工具链必须先有真实消费方,并基于该消费方的工作流提出新提案;默认情况下,不会复活这些包或已删除且不承诺兼容的格式。 diff --git a/.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.i18n.yaml deleted file mode 100644 index 7493384e67..0000000000 --- a/.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 .agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md -2026-07-15-sdk-project-editing-architecture.md: 8335af516dbaa85f4adb85286f976ce9be2c9da8 -2026-07-15-sdk-project-editing-architecture.zh.md: 4c59ac8cdedf0c7fdd1a2e7994135bc89653d55e diff --git a/.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md b/.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md deleted file mode 100644 index 8335af516d..0000000000 --- a/.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md +++ /dev/null @@ -1,129 +0,0 @@ -# Agent Note: SDK project editing architecture - -Status: proposed - -English | [中文](2026-07-15-sdk-project-editing-architecture.zh.md) - -## Problem - -[Developer-owned SDK projects](../feature/2026-07-14-sdk-developer-projects.md) are created through create, adjusted through config, and built and run through commands such as start. Initial creation, configuration changes, and build and runtime commands all need to understand features, feature options, npm dependencies, Cordis config entries, environment variables, package managers, local plugins, and several project files. If each project-reading and project-writing workflow uses a separate interpretation protocol, the SDK developer workflows become difficult to maintain. - -## Proposal - -The SDK uses one shared object-oriented project model. `SdkProject` is a read-only snapshot, and `ProjectEditSession` is the only mutation and commit boundary. Feature objects own their feature options, relationships, resource contributions, and current-state inspection. Create and config orchestrate only their respective user workflows and modify projects through the same domain operations. - -Structured files are modified through document objects, while one-shot text artifacts are generated from complete templates. Questions are typed objects presented through clack. Diff calculation may remain an edit-session implementation detail, but it is not a public execution protocol that callers must assemble. - -## Terminology - -| Term | Usage in this Agent Note | Meaning | -|---|---|---| -| Feature | feature | A product unit curated and managed by the SDK; one feature may contain several feature options and contribute several Cordis config entries, npm dependencies, environment placeholders, and owned files | -| Feature option | feature option | A finite selectable implementation or configuration shape within one feature; feature rules may make options fixed, exclusive, or additive | -| Cordis plugin | Cordis plugin | A plugin implementation loaded by Cordis, usually exported by an npm package; it is not an item in `cordis.yml` | -| Cordis config entry | Cordis config entry | One item in the `cordis.yml` plugin list, identified as an instance by `id` and referring to a Cordis plugin through `name` | -| Cordis plugin config | Cordis plugin config | The configuration object or shape exposed by a Cordis plugin; an individual field owned and updated by a feature is a config key | -| config key | config key | One field in Cordis plugin config; a feature updates only the config keys it declares as owned and preserves unknown config keys | -| npm dependency | npm dependency | A package relationship in `package.json`; literal fields such as `dependencies` and `devDependencies` keep their names | -| Feature requirement | feature requirement | A relationship declared through `requires` by a feature or feature option | - -## Package boundaries - -| Package | Responsibility | Does not own | -|---|---|---| -| `@deepseek-ai/dsh-helper` | Edit sessions, feature configuration, project-template rendering, package-manager adaptation, and prompt interaction adaptation | Booting Cordis applications or deciding create/config terminal workflows | -| `@deepseek-ai/dsh-scripts` | `dsh-sdk start/dev/build/config`, process lifecycle, project entry loading, the config workflow, and its terminal-copy templates | Interpreting feature definitions directly or modifying YAML/JSON ASTs | -| `@deepseek-ai/create-sdk` | Arguments, question order, initial project creation, installation finish, and terminal-copy templates for `npm create @deepseek-ai/sdk` | Becoming a generated project's runtime npm dependency or providing a library API | - -`@deepseek-ai/create-sdk` is the only exception to the repository's `@deepseek-ai/dsh-*` naming rule. npm's scoped-initializer convention requires that package name for `npm create @deepseek-ai/sdk`. The exception is a repository architecture fact and does not add a third developer product entrypoint. - -The three packages export only the narrow entrypoints consumed by adjacent layers and provide no `src/*` deep imports. The scripts library entrypoint and build-config subpath serve generated code and project build configuration, while the developer product contract remains the `dsh-sdk` commands. - -## Project aggregate and edit session - -`SdkProject.create(root, request)` constructs a new project snapshot that has not been written, while `SdkProject.open(root)` loads an existing project. Open requires only readable root `package.json` and `cordis.yml` files; every other file is an optional resource. Both paths return the same read-only aggregate and distinguish their source through explicit origin state. - -`project.edit()` clones project documents into a working copy. Domain commands such as install, configure, enable, disable, and addPlugin modify only the working copy. Each command immediately re-inspects its owning feature, and the final commit checks all relationships and files again. - -```text -validate feature requirements and resource ownership - -> validate every affected document - -> compute changed and removed paths - -> compare existing files with the session's original text - -> write through one commit boundary - -> return a new SdkProject snapshot and ChangeSet -``` - -Validation failure or an external edit causes zero writes. “One commit” means only zero pre-write side effects and one write entrypoint. `ChangeSet` describes final feature, plugin, and file changes for Review & Apply and create completion. - -## Features and resource ownership - -A feature is a first-class behavior object. Shallow base classes implement install, configure, enable, disable, required/requires validation, and common state inspection. Features with fixed, exclusive, or additive feature options share these lifecycles. Only features whose resource contributions depend on project context or require custom round-tripping use dedicated behavior classes; other features declare their actual differences through standardized data. - -Each feature contributes stable-keyed Cordis config entries, npm dependencies, environment placeholders, and owned files. The registry rejects two features that declare the same resource key during initialization. Different feature options within one feature may share resources, which that feature resolves from the final option set. - -A Cordis config entry anchors feature installation. The npm package name assigns the entry to a feature, and the entry ID distinguishes several instances of one plugin package. An npm dependency without a feature-owned Cordis config entry leaves the feature uninstalled. Once a Cordis config entry exists, a missing npm dependency, unreadable Cordis plugin config, or resource conflict puts the feature into an inconsistent state; the config command shows diagnostics and refuses speculative modification. - -Configuring the same feature option updates only its owned config keys and preserves unknown keys. Replacing a feature option removes old resources that are exclusive and still confirmable. If an old resource cannot be confirmed or an owned file was modified by the developer, the whole operation fails. - -## Questions and workflows - -TypeScript `Question` objects keep defaults, validation, applicability, and types together. `PromptPort` is the only interface between the domain layer and the terminal library, and helper provides one thin `ClackPromptPort`. Create and config inject their own command-line input and output streams and retain ownership of cancellation, return, and completion semantics in their workflows. - -Create keeps its stateful question order in one wizard, while config keeps final-state selection in one workflow. Both use the same feature configurator for feature options and dedicated inputs, so adding an ordinary feature, feature option, or parameter does not require changes to both entrypoints. - -## Project documents and templates - -Only structured files that helper reads or modifies have concrete document objects: `package.json`, `cordis.yml`, `.env`, `.env.example`, the root `tsconfig.json`, and the pnpm workspace file. Document objects own parsing, cloning, validation, and serialization. Concrete classes and modules use `*File` and `*-file.ts` names respectively. Business code does not manipulate YAML/JSON ASTs directly, and malformed shapes fail loudly at the owning document boundary. - -README, entrypoint code, build configuration, `.gitignore`, and other one-shot text artifacts use one complete template per real file. Complete product copy such as CLI usage, creation and recovery messages, installation and retry guidance, and the default persona also comes from package-local templates owned by the package that presents it. - -Helper provides the generic typed `TextTemplate` renderer, and caller packages load their own templates through package-local asset URLs. - -Templates use Handlebars strict mode and `noEscape` without custom processing. File owners encode typed values for the target language. Template source escapes interpolation as `\{{model}}` when it must emit the downstream literal unchanged. - -## Command and runtime boundary - -Scripts supports `dsh-sdk start/dev/build/config`. Start dynamically loads a module target and calls its named entrypoint. Dev adds TypeScript and local-workspace source resolution before following the same path. Build invokes the project's installed tsdown. Config opens one edit session and commits after Review & Apply. Generated projects run `tsc -b` directly for typechecking. - -HMR is an explicit Cordis config entry loaded by dev and start. Its required `node-addon-require-builtin` package is supplied transitively by the scripts package and is absent from the generated project's `package.json`. - -Dev and start execute the developer entrypoint, where developer code handles command-line arguments and cwd. Developers pass `--model=` and `--resume=` to start the standard flow. - -## Repository live-link mode - -Create-sdk retains a hidden `--link-workspace` option for Harness repository development and e2e. The parser accepts it, but help, public flag lists, and ordinary user documentation omit it. It accepts no repository-path parameter; the repository root is derived upward from the executing create-sdk module. - -Link mode preserves the ordinary project file shape. `@deepseek-ai/*` points into `packages/`, Cordis-related npm dependencies point into `vendor/`, and shared lower-level packages resolve to the same physical copy used by the repository so Cordis type merging cannot produce multiple module type definitions. npm uses `file:`, pnpm uses `link:` with automatic peer installation disabled, and Yarn uses `portal:` plus resolutions. Repository packages must be built first. - -## Future work - -- **Replaceable required spine roles.** The current `spine` owns the full implementation set, including SystemPrompt and LLMService, through one fixed feature option. Developers cannot replace or switch these roles and must edit Cordis config entries manually. -- **Service contracts and package declarations.** When replacing a builtin service, a Cordis plugin currently cannot declare the services it provides through `provides` metadata, so the SDK cannot assist configuration during development or check compatibility at runtime. A corresponding protocol remains to be designed. -- **Feature parameter descriptions.** Feature-specific inputs currently require handwritten declarations. The SDK cannot derive interactive parameters automatically from arbitrary Cordis plugin config or npm package.json information. Future declarative metadata may expose a limited parameter set without turning arbitrary Cordis plugin config into a generic form. -- **SDK application-level configuration.** The current project resource model describes Cordis config entries and config keys owned by individual Cordis plugins, so every SDK-managed setting must belong to one plugin. Cross-plugin or whole-application settings have no independent persistence location. Future work must define an application-level configuration document and its ownership, read, and mutation boundaries. - -## Alternatives considered - -**Keep the static Catalog and central engine.** This minimizes the initial rewrite, but feature parameters, round-tripping, owned files, and create/config reuse continue to accumulate in one coordinator. Splitting files shortens the file without consolidating responsibility. - -**Use `wizard.json` and a generic Questionnaire.** Static forms cannot directly express feature requirements, option switches, existing-value refill, and project-resource changes. Types, gates, and dynamic options still connect through string registries and a procedural `run()`, creating another internal DSL. - -**Expose the live-link flag.** The mode depends on Harness monorepo layout and unpublished packages and serves repository development only. Making it public would create a project-creation contract that the SDK cannot support outside the repository. - -## Acceptance criteria - -- Create and config modify projects only through `SdkProject` and `ProjectEditSession`; any business, document, or concurrency validation failure before writing leaves the filesystem unchanged -- Adding an ordinary feature, feature option, or parameter extends only its typed spec or owning behavior object, without adding a central switch to create or config workflows -- Helper owns the feature model, npm dependency and other resource configuration, and inconsistent-state detection -- Structured files change through `*File` document objects; one-shot files and complete product copy come from package-owned Handlebars templates, and business decisions do not enter a template DSL -- `dsh-sdk start/dev/build/config` is the runtime product surface, typecheck uses `tsc -b` directly, HMR is not injected by command mode, and only the scripts package transitively supplies `node-addon-require-builtin` -- `--link-workspace` exists only as a hidden repository-development option and preserves one module identity under npm, pnpm, and Yarn - -## Risks - -- Behavior objects and typed specs create two extension shapes. Dedicated classes must remain limited to features that truly depend on project context or custom behavior, or the design will grow a meaningless type hierarchy -- Optimistic concurrency checks and pre-write validation cannot recover from an I/O failure during writing; callers must still report a possible partial commit to the developer -- Hidden link mode depends on repository layout and package-manager link semantics and must change with either one -- The Cordis loader resolves `node-addon-require-builtin` from its own module path, so the scripts package must continue to satisfy that optional peer under npm, pnpm, and Yarn npm dependency layouts -- Handlebars `noEscape` makes typed model construction responsible for target-language encoding; new template fields must be escaped correctly at the owning boundary, and downstream Handlebars placeholders must be escaped explicitly in template source diff --git a/.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.zh.md b/.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.zh.md deleted file mode 100644 index 4c59ac8cde..0000000000 --- a/.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.zh.md +++ /dev/null @@ -1,129 +0,0 @@ -# Agent Note: SDK 工程编辑架构 - -Status: proposed - -[English](2026-07-15-sdk-project-editing-architecture.md) | 中文 - -## 问题 - -[开发者拥有的 SDK 工程](../feature/2026-07-14-sdk-developer-projects.md) 由 create 创建,可以通过 config 调整,并由 start 等命令构建和运行。初始创建、配置调整和编译运行都需要理解功能、功能选项、NPM 依赖、Cordis 配置项、环境变量、包管理器、本地插件和多个项目文件。如果各个项目读写工作流分别使用不同的解析协议,SDK 开发者工作流会变得难以维护。 - -## 提案 - -SDK 使用一个共享的面向对象工程模型。`SdkProject` 是只读快照,`ProjectEditSession` 是唯一修改与提交边界;功能对象负责自身的功能选项、关系、资源贡献和现状识别;create 与 config 只编排各自的用户工作流,并通过同一组领域操作修改工程。 - -结构化文件通过文档对象修改,一次性文本产物通过完整模板生成。问题由类型化对象表达,并使用 clack 交互。差异计算可以作为编辑会话的内部实现,但不成为要求调用方组装的公共执行协议。 - -## 术语 - -| 名词 | 本文用词 | 含义 | -|---|---|---| -| Feature | 功能 | 由 SDK 策划和管理的产品单元;一项功能可以包含多个功能选项,并贡献多个 Cordis 配置项、NPM 依赖、环境变量占位和独占文件 | -| Feature option | 功能选项 | 一项功能内有限、可选择的实现或配置形状;根据功能规则可以固定、互斥或多选 | -| Cordis plugin | Cordis 插件 | Cordis 加载的插件实现,通常由一个 NPM 包导出;它不是 `cordis.yml` 中的一项配置 | -| Cordis config entry | Cordis 配置项 | `cordis.yml` 插件列表中的一项,通过 `id` 标识实例并通过 `name` 指向 Cordis 插件 | -| Cordis plugin config | Cordis 插件配置 | Cordis 插件公开的配置对象或配置结构;其中由功能拥有并更新的单个字段称为「配置键」 | -| config key | 配置键 | Cordis 插件配置中的单个字段;功能只更新自己声明拥有的配置键,并保留未知配置键 | -| npm dependency | NPM 依赖 | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 | -| Feature requirement | 功能依赖 | 功能或功能选项通过 `requires` 声明的关系 | - -## 包边界 - -| 包 | 责任 | 不负责 | -|---|---|---| -| `@deepseek-ai/dsh-helper` | 编辑会话、功能配置、工程模板渲染、包管理器适配和 prompt 交互适配 | 启动 Cordis 应用或决定 create/config 的终端工作流 | -| `@deepseek-ai/dsh-scripts` | `dsh-sdk start/dev/build/config`、进程生命周期、项目入口加载、config 工作流和所属终端文案模板 | 直接解释功能定义或修改 YAML/JSON AST | -| `@deepseek-ai/create-sdk` | `npm create @deepseek-ai/sdk` 的参数、问题顺序、首次工程创建、安装收尾和所属终端文案模板 | 成为生成工程的运行时 NPM 依赖或提供库 API | - -`@deepseek-ai/create-sdk` 是仓库 `@deepseek-ai/dsh-*` 命名规则的唯一例外;npm scoped initializer 约定要求 `npm create @deepseek-ai/sdk` 对应这个包名。该例外是仓库架构事实,不增加第三个开发者产品入口。 - -三个包只导出相邻层实际使用的最小入口,不提供 `src/*` 深路径。scripts 的库入口与构建配置子路径服务生成代码和项目构建配置,但开发者产品约定仍由 `dsh-sdk` 命令承担。 - -## 工程聚合与编辑会话 - -`SdkProject.create(root, request)` 构造尚未写盘的新工程快照,`SdkProject.open(root)` 加载已有工程。open 只要求根 `package.json` 与 `cordis.yml` 可读,其余文件是按需存在的资源;两条路径返回同一种只读聚合,并通过显式 origin 区分来源。 - -`project.edit()` 克隆项目文档形成 working copy。install、configure、enable、disable 和 addPlugin 等领域命令只修改 working copy;命令完成后立即重新检查所属功能,最终 commit 再检查全部关系和文件。 - -```text -validate feature requirements and resource ownership - -> validate every affected document - -> compute changed and removed paths - -> compare existing files with the session's original text - -> write through one commit boundary - -> return a new SdkProject snapshot and ChangeSet -``` - -校验失败或检测到会话外修改时不写盘。“一次 commit”只表示写入前零副作用和单一写入口。`ChangeSet` 只描述功能、插件和文件的最终变化,用于 Review & Apply 与 create 收尾。 - -## 功能与资源所有权 - -功能是一等行为对象。浅层基类实现 install、configure、enable、disable、required/requires 校验和通用状态检查;固定功能选项、互斥功能选项与可多选功能选项共享这些生命周期。只有资源贡献依赖项目上下文或需要自定义 round-trip 的功能才使用专用行为类,其余功能通过标准化数据声明真正不同的部分。 - -每项功能贡献带稳定 key 的 Cordis 配置项、NPM 依赖、环境变量占位和独占文件。注册表初始化时拒绝不同功能声明同一个资源 key;同一功能的不同功能选项可以共享资源,并由该功能根据最终选项集合处理。 - -Cordis 配置项是功能安装锚点。NPM 包名用于确定配置项所属的功能,配置项 ID 区分同一插件包的多个实例;只有 NPM 依赖而没有功能拥有的 Cordis 配置项时,该功能仍视为未安装。Cordis 配置项存在后,缺失 NPM 依赖、无法读取的 Cordis 插件配置或资源冲突会使功能进入不一致状态,config 命令显示诊断并拒绝猜测式修改。 - -配置同一功能选项时,只更新其声明拥有的配置键,保留未知键。替换功能选项会删除旧功能选项独占且仍可确认的资源;无法确认旧资源或发现独占文件被用户修改时,整个操作失败。 - -## 问题与工作流 - -问题由 TypeScript `Question` 对象表达,默认值、校验、适用条件和类型留在同一个对象中。`PromptPort` 是领域层与终端库之间的唯一接口,helper 提供一个轻量的 `ClackPromptPort`;create 和 config 注入各自的命令行输入输出流,并在各自工作流中决定取消、返回和收尾语义。 - -create 的有状态问题顺序留在一个向导中,config 的最终状态选择留在一个工作流中。两者通过同一个功能配置器收集功能选项与专用输入,因此增加一项普通功能、功能选项或参数不要求同时修改两个入口。 - -## 项目文档与模板 - -只有需要读取或修改的结构化文件拥有具体文档对象,包括 `package.json`、`cordis.yml`、`.env`、`.env.example`、根 `tsconfig.json` 和 pnpm workspace 文件。文档对象拥有解析、克隆、校验和序列化行为;具体类与模块分别使用 `*File` 和 `*-file.ts` 命名,业务层不直接操作 YAML/JSON AST,异常结构会在所属文档边界明确报错。 - -README、入口代码、构建配置、`.gitignore` 和其他一次性文本产物使用与真实文件一一对应的完整模板。CLI(命令行界面)用法、创建结果与恢复提示、安装与重试指导以及默认 persona 等完整产品文案也由所属包的本地模板提供。 - -helper 提供通用的数据类型化 `TextTemplate` 模板渲染器,调用方包通过本地 asset URL 加载自己的模板。 - -模板使用 Handlebars strict mode 与 `noEscape`,不进行自定义处理。文件对象负责把类型化数据值编码成目标语言文本;模板源码在必须原样输出下游字面量时,将插值转义为 `\{{model}}`。 - -## 命令与运行边界 - -scripts 支持 `dsh-sdk start/dev/build/config`。start 动态加载模块 target 并调用其命名入口;dev 在同一路径前增加 TypeScript 与本地 workspace 源码解析;build 调用工程安装的 tsdown;config 打开一个编辑会话并在 Review & Apply 后提交。类型检查由生成工程直接执行 `tsc -b`。 - -HMR(热模块替换)作为显式 Cordis 配置项由 dev 和 start 加载;它所需的 `node-addon-require-builtin` 由 scripts 包传递提供,不写入开发者工程的 `package.json`。 - -dev/start 会执行开发者入口,在开发者代码中处理命令行参数、cwd,由开发者自行传入 `--model=` 与 `--resume=` 启动标准流程。 - -## 仓库本地链接模式 - -create-sdk 保留隐藏的 `--link-workspace` 选项供 Harness 仓库开发和 e2e 使用。该选项可以被解析,但不出现在 help、公开 flag 清单或普通用户文档中,也不接收仓库路径参数;仓库根从正在执行的 create-sdk 模块位置向上确定。 - -链接模式保持普通工程的文件形状。`@deepseek-ai/*` 指向 `packages/`,Cordis 相关 NPM 依赖指向 `vendor/`,共享底层包锚定到仓库实际使用的同一物理拷贝,避免 Cordis 类型合并产生多个模块类型定义。npm 使用 `file:`,pnpm 使用 `link:` 并关闭自动 peer 安装,Yarn 使用 `portal:` 与 resolutions;仓库包需要先构建。 - -## 后续工作 - -- **可替换的 required 主干角色。** 当前 `spine` 以一个固定功能选项拥有整组实现,包含 SystemPrompt、LLMService 等。无法让开发者对其进行替换和切换,只能手工修改 Cordis 配置项。 -- **服务约定与包声明。** 替换特定内建服务时,Cordis 插件目前无法通过 `provides` 元数据声明其提供的服务,因此 SDK 无法在开发阶段辅助配置,也无法在运行时检查兼容性。后续需要设计相应协议。 -- **功能参数描述。** 当前功能的专用输入必须手工声明;SDK 无法从任意 Cordis 插件配置或 NPM package.json 信息中自动推导可交互参数。后续可以定义有限的声明式参数元数据,但不把任意 Cordis 插件配置转换成通用表单。 -- **SDK 应用级配置。** 当前项目资源模型只描述 Cordis 配置项及单个 Cordis 插件拥有的配置键,因此所有受 SDK 管理的配置都必须归属某个插件。跨插件或面向整个 SDK 应用的设置没有独立持久化位置;后续需要定义应用级配置文档及其所有权、读取和修改边界。 - -## 曾考虑的替代方案 - -**保留静态 Catalog 与中心 engine。** 该方案改动最小,但功能参数、round-trip、独占文件和 create/config 复用都会继续进入同一个协调中心;拆文件只能缩短单文件,不能收拢职责。 - -**使用 `wizard.json` 与通用 Questionnaire。** 静态表单无法直接表达功能依赖、选项切换、已有值回填和项目资源变化;类型、门禁和动态选项最终仍要通过字符串注册表与过程式 `run()` 连接,形成新的内部 DSL。 - -**公开本地链接 flag。** 该模式依赖 Harness monorepo 布局和未发布包,只服务仓库开发;公开后会形成无法对外兑现的项目创建约定,因此保持隐藏。 - -## 验收标准 - -- create 与 config 只通过 `SdkProject` 和 `ProjectEditSession` 修改工程,写入前的任何业务、文档或并发校验失败都不产生磁盘变化 -- 新增普通功能、功能选项或参数只扩展类型化 spec 或所属行为对象,create/config 工作流不增加中央 switch -- 功能模型、NPM 依赖与其他资源配置、不一致检测由 helper 统一实现 -- 结构化文件通过 `*File` 文档对象修改;一次性文件和完整产品文案通过所属包的 Handlebars 模板生成,业务决策不进入模板 DSL -- `dsh-sdk start/dev/build/config` 是运行时产品接口,类型检查直接使用 `tsc -b`,HMR 不通过命令隐式注入,`node-addon-require-builtin` 只由 scripts 包传递提供 -- `--link-workspace` 只作为隐藏的仓库开发选项存在,并对 npm、pnpm 和 Yarn 保持单一模块身份 - -## 风险 - -- 行为对象与类型化 spec 并存会形成两种扩展形状;专用类必须只用于确实依赖项目上下文或需要自定义行为的功能,否则会重新产生无意义的类型层次 -- 乐观并发检查与写前校验不能解决写入中途的 I/O 故障,调用方仍需向开发者报告可能的部分提交 -- 隐藏链接模式依赖仓库目录与包管理器链接语义,仓库布局或工具行为变化时必须与实现一起更新 -- Cordis loader 从自身模块路径加载 `node-addon-require-builtin`;在 npm、pnpm 和 Yarn 的 NPM 依赖布局下,scripts 包必须持续满足该可选对等依赖(peer dependency) -- Handlebars 的 `noEscape` 把目标语言编码责任交给 typed model 构造方;新增模板字段时必须在 owner 处完成正确转义,下游 Handlebars 占位符必须在模板源码中显式转义 diff --git a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.i18n.yaml b/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.i18n.yaml deleted file mode 100644 index be85390433..0000000000 --- a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 .agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md -2026-07-14-sdk-developer-projects.md: 65d2bf66232993222832eb0f2f4f56cfcf7afd16 -2026-07-14-sdk-developer-projects.zh.md: fc96d25dbea44d28bbc146b76fc3e81093bec976 diff --git a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md b/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md deleted file mode 100644 index 65d2bf6623..0000000000 --- a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md +++ /dev/null @@ -1,167 +0,0 @@ -# Agent Note: Developer-owned SDK projects - -Status: proposed - -English | [中文](2026-07-14-sdk-developer-projects.zh.md) - -## Problem - -DeepSeek Harness composes features through Cordis plugins, but building a runnable project from an empty directory still requires a developer to understand npm dependencies, the `cordis.yml` plugin set, environment variables, TypeScript builds, local-plugin workspaces, and runtime entrypoints together. These manual steps constrain one another: omitting any one can produce a project that installs but cannot be developed, develops but cannot be built, or builds but cannot start. - -A one-shot generator reduces only the initial creation cost. If the generated result is hidden inside a preset or an uneditable CLI, advanced developers cannot reshape the plugin tree, change Cordis plugin config, or add project-specific behavior. If a generated project immediately leaves tool management altogether, developers must again maintain consistency across all npm dependencies and Cordis plugin config themselves. - -Initial creation and later configuration address the same builtin feature set. When those workflows maintain separate feature lists, feature options, and npm dependencies, new Cordis plugins, npm packages, and Cordis plugin config changes make them diverge. Projects also need an ordinary local-plugin development path that participates in development, build, and start flows. - -## Proposal - -The SDK creates an ordinary, explicit TypeScript/Cordis project owned by its developer. `cordis.yml` is the only runtime plugin tree; development and production read the same file. The generated `package.json`, `cordis.yml`, TypeScript entrypoint, build configuration, and `plugins/*` remain directly editable instead of being hidden behind a preset. - -The only developer product entrypoints are `npm create @deepseek-ai/sdk` and the `dsh-sdk` commands. The initializer performs initial creation, `dsh-sdk config` manages SDK-recognized builtin features afterward, and `dsh-sdk dev`, `dsh-sdk build`, and `dsh-sdk start` own development, build, and startup; this phase provides no `dsh-sdk create`. Create and config consume one manually authored feature definition, so each feature has one source for its feature options, npm dependencies, Cordis config entries, related files, and inspection rules. The [SDK project editing architecture](../architecture/2026-07-15-sdk-project-editing-architecture.md) defines terms such as feature and feature option. - -The SDK offers interaction for feature selection and finite feature options only; it does not turn arbitrary Cordis plugin config into a generic form. A feature collects the small number of dedicated inputs required by its feature options. All other Cordis plugin config remains in `cordis.yml`, with comments documenting common edits, for direct developer control. - -## Developer workflow - -Initial creation collects information in an order where earlier answers determine later questions: target directory and package identity, model provider and credentials, run interface, builtin features and feature options, an optional local plugin, package manager, and whether to install npm dependencies and build. Command-line arguments suppress questions they already answer. Create and config require an interactive TTY in this phase, and cancelling creation writes nothing to the target directory. - -```sh -npm create @deepseek-ai/sdk my-agent -cd my-agent -npm exec dsh-sdk dev index.ts -npm exec dsh-sdk config -npm exec dsh-sdk build -npm exec dsh-sdk start index.js -``` - -Create rejects every target path that already exists. After committing the project files, the CLI asks whether to install npm dependencies and build. An install or build failure preserves the generated project and prints commands that can retry the failed work. - -Create also offers one `none / plugin / tool` choice. `plugin` creates a fixed `plugins/plugin` Cordis plugin, while `tool` creates a fixed `plugins/tool` model-facing tool; one project creation includes at most one local plugin. The operation updates the workspace, root npm dependency, TypeScript reference, build configuration, and `cordis.yml` together, and any pre-write validation failure leaves the project absent. - -## Features supported during creation - -The table is the developer-visible support set for this phase. A `required` feature is always present but may still offer finite feature options; a `default` feature is preselected in the feature tree; an `optional` feature is selected explicitly. The table describes the product support set, while the runtime registry remains the implementation source of truth. - -| Feature | Create state | Feature options | Constraints and relationships | -|---|---|---|---| -| `provider` | required | `deepseek` (default) / `custom` | DeepSeek collects an API key; custom also collects a base URL, and a CLI option may override the model name | -| `app` | required | `tui` (default) / `acp` / `embed` | Selects the run interface | -| `spine` | required | `default` | Timer, the LLM seam, session storage, system prompt, the tool registry, the agent registry, and the agent loop | -| `bash` | required | `local` (default) / `sandbox` | The two feature options are exclusive and independent of the run interface, and both install the model-facing bash tool; sandbox installs the local sandbox provider and sandboxed bash backend | -| `persistence` | required | `jsonl` (default) / `sqlite` | Every project selects exactly one persistence backend | -| `hmr` | default | `default` | Loads `@cordisjs/plugin-hmr`; dev and start both enable it with the plugin defaults | -| `fs` | default | `local` | Installs the local filesystem, policy, and model-facing tools; the process sandbox does not confine in-process fs tools | -| `todo` | default | `default` | Provides the `todo_write` tool | -| `skill` | default | `default` | Installs the skill registry, the local skill provider, and the model-facing skill tool | -| `web` | optional | `deepseek` (default) / `exa` / `perplexity` / `fetch-only` | Search feature options are exclusive; Exa and Perplexity collect their API keys; timeout policy is recommended | -| `subagent` | optional | `spawn` (default) / `fork`, multiple | This phase provides only in-process backends | -| `workflow` | optional | `workerthread` | Requires the subagent `spawn` feature option | -| `compact` | optional | `basic` | Uses SDK-provided context-compaction parameters | -| `hooks` | optional | `claude` (default) / `codex`, multiple | Each feature option creates a separate editable configuration file | -| `guard` | optional | `repeat-tool` | Provides repeated-tool-call reminders | -| `timeout-policy` | optional | `default` | Applies a uniform policy to tools that declare timeout budgets | -| `ask-user` | optional | `default` | Provides the `ask_user_question` tool; only `tui` can select it because ACP is an automation transport and embed provides no human-interaction service | - -Both `bash` feature options apply to ACP, TUI, and embed and are not selected by the run interface. The sandbox feature option writes no active config key and therefore keeps `dsh-bash-sandbox`'s `read-only` default. Generated `cordis.yml` includes a commented example that developers can change explicitly to `workspace-write`: - -```yaml -- id: bash - name: '@deepseek-ai/dsh-bash-sandbox' - # Uncomment to allow writes under the project workspace. - # config: - # mode: workspace-write - # workspaceRoot: !!js process.cwd() -``` - -Feature contributions reference only single-plugin npm packages and never bundle packages such as `agent-spine-demo`, `tui-demo`, or `acp-demo`. Plugins outside the table are not managed by create in this phase; advanced developers may still compose them by editing the ordinary project files directly. - -## Generated project - -With default answers, an npm project uses the DeepSeek provider, the TUI interface, local bash, JSONL persistence, and the preselected hmr, fs, todo, and skill features. Its initial tree is: - -```text -my-agent/ -├── .env -├── .env.example -├── .gitignore -├── README.md -├── cordis.yml -├── index.ts -├── package.json -├── tsconfig.base.json -├── tsconfig.json -└── tsdown.config.ts -``` - -`.env.example` always exists, and the SDK keeps its placeholders aligned with the current feature set. A gitignored `.env` is also created when a secret is captured or the developer confirms an empty credential to fill later. The SDK only appends differently named variables that are not already present in `.env` and never updates or removes existing contents. Feature-option changes may remove obsolete `.env.example` placeholders, while old credentials remain in `.env` for the developer to manage. pnpm and Yarn projects add their required workspace files, but do not fork the runtime plugin tree or TypeScript entrypoint. - -Generated `package.json` provides the following scripts. `dev`, `build`, `start`, and `config` invoke `dsh-sdk`, while `typecheck` invokes TypeScript directly: - -| Script | Behavior | -|---|---| -| `dev` | Run `dsh-sdk dev index.ts`, registering development-time resolution for TypeScript and local workspace plugins | -| `build` | Run `dsh-sdk build`, invoking the project's installed tsdown for the root entrypoint and `plugins/*` packages | -| `typecheck` | Run `tsc -b` directly | -| `start` | Run `dsh-sdk start index.js`, starting the built entrypoint without an implicit build | -| `config` | Run `dsh-sdk config` to edit the current project's feature tree | - -`dsh-sdk start` and `dsh-sdk dev` accept a module target and forward arguments after `--` unchanged to the project entrypoint. Generic argument parsing uses Node `parseArgs()` with zero schema: valued flags use `--key=value`, bare flags become `true`, and `--no-*` becomes `false`. - -- TUI projects pass the selected model through `--model=` and create or resume an agent according to optional `--resume=`; -- ACP clients create fresh sessions through protocol `session/new`; -- Embed uses the model written into the generated code. - -Each feature-owned Cordis config entry keeps its developer-editable Cordis plugin config and explanatory comments in `cordis.yml`. When `dsh-sdk config` changes other features, it preserves unknown fields, formatting on untouched nodes, and comments. HMR is an ordinary leaf config entry: when the feature is selected, dev and start load the same watcher, and the command does not change the plugin tree implicitly. - -## Post-creation configuration - -`dsh-sdk config` requires only readable root `package.json` and `cordis.yml` files in the current directory. It inspects standard features and their current feature options, expresses the final desired state through one feature tree, and shows feature changes and affected files before Review & Apply. - -`dsh-sdk config` can install missing features, enable or disable installed features, and switch finite feature options. Required features cannot be removed. An npm dependency change runs the project package manager's install once after the file commit; installation failure does not roll back committed project files. - -The SDK modifies only Cordis config entries, config keys, npm dependencies, `.env.example` placeholders, and owned files explicitly owned by a feature. Updating the same feature option preserves unknown config keys in its Cordis config entries. Handwritten and third-party plugins support enable and disable by stable ID only. When a known feature has been edited into an incomplete, ambiguous, or otherwise unreadable shape, `dsh-sdk config` displays diagnostics and refuses automatic changes until the developer repairs it manually. - -One config session accumulates every change in an in-memory working copy. Before Apply, it validates feature relationships, resource conflicts, and document shapes, then compares each affected existing file with the text read when the session opened. Validation failure or an external edit causes zero writes. Once physical writes begin, the SDK does not provide cross-file transactional rollback. - -## Maintenance model - -The SDK curates its builtin support set instead of exposing npm packages automatically by npm dependency name or directory convention. One feature may compose several Cordis config entries, feature options may share resources, and a feature option may declare a feature requirement on another feature or a specific feature option. Adding an ordinary feature or feature option does not require changes to both create and config command workflows. - -## Future work - -- `dsh-sdk add [package-spec]` unifies local-plugin creation with external Cordis plugin installation: without a package or repository source it creates a local plugin/tool, while a supplied source adds the npm dependency and `cordis.yml` config entry; the source model leaves room for GitHub repositories and other extensions -- Non-interactive create/config: both workflows require a TTY in this phase and provide no complete input contract for automation -- More feature-specific inputs: this product surface exposes only finite feature options, secrets, and a few dedicated values in this phase rather than a generic parameter interface for Cordis plugin config - -## Alternatives considered - -**An opaque preset or generator-owned project.** This shortens initial creation but hides the real plugin tree and build boundaries, prevents advanced developers from composing Cordis plugins directly, and makes project behavior depend on the CLI version rather than committed project files. - -**A one-shot generator only.** Leaving all later maintenance manual redistributes feature requirements, feature-option switches, and multi-file updates. A config workflow over the shared registry retains continuing management for generated projects. - -**Separate `cordis.yml` files for development and production.** Two plugin trees mean a successful development run does not demonstrate that production loads the same features. Dev adds only TypeScript and local-workspace resolution; runtime configuration remains singular. - -**A generic form for arbitrary Cordis plugin config.** Cordis plugin config contains nested structures, expressions, and plugin-specific semantics. A generic form would become a second incomplete schema. The SDK manages finite feature options and dedicated secrets, while developers continue to edit complex config directly. - -**A private local-plugin discovery protocol.** Ordinary package-manager workspaces, root npm dependencies, TypeScript references, and Cordis config entries already express the complete relationship. Another discovery protocol would create hidden state understood only by the SDK. - -**A `dsh-sdk create` command for existing projects.** Create already provides one editable local-plugin skeleton, and later plugins can use ordinary workspace and Cordis mechanisms manually. A parallel command would add a second scaffolding product surface without adding composition functionality. - -**Automatically expose every new Cordis plugin as a builtin.** An npm package cannot say how several plugins compose into one product feature, nor can it derive exclusivity, feature requirements, secrets, interface applicability, or security constraints. The support set requires human curation; automation is suitable only for checking whether candidates have been classified. - -## Acceptance criteria - -- `npm create @deepseek-ai/sdk` collects project identity, provider, interface, features, an optional local plugin, package manager, and installation choice in the documented order, and cancellation leaves the target path absent -- A default npm project has the documented tree and `dev`, `build`, `typecheck`, `start`, and `config` scripts, with dev and start sharing one `cordis.yml` -- Create offers the documented features and feature options; local and sandbox bash are exclusive with local as the default, the sandbox Cordis config entry retains the editable commented config example, and HMR is selected by default and loaded by both dev and start -- Create's `plugin` or `tool` choice creates at most one fixed-name local plugin and atomically updates its files and root-project relationships; this phase provides no `dsh-sdk create` -- `dsh-sdk config` reads the same support set from an existing project, installs, enables, disables, and switches supported feature options, preserves unknown config and comments, and refuses to modify inconsistent config -- `.env.example` reflects variables required by the current features; `.env` only appends missing differently named variables and never updates or removes existing contents -- npm, pnpm, and Yarn workspaces install, build, and start; local plugins resolve from source under dev and from built output under start - -## Risks - -- Developers can edit a builtin into a shape the registry cannot recognize; the SDK stops automating that feature instead of guessing and overwriting config -- Pre-write validation and external-edit detection do not provide transactional rollback once multi-file writes begin; an I/O failure can leave a partial commit requiring manual repair -- The sandbox feature option depends on an available local sandbox backend for the target platform; an unavailable backend must fail closed instead of falling back to unsandboxed execution -- HMR retains its filesystem watcher and hot-reload behavior under production start; this is the result of an explicit plugin choice, not an implicit development-only service -- The append-only `.env` policy retains credentials that are no longer used; the SDK does not decide when user-owned secret data is safe to delete diff --git a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.zh.md b/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.zh.md deleted file mode 100644 index fc96d25dbe..0000000000 --- a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.zh.md +++ /dev/null @@ -1,167 +0,0 @@ -# Agent Note: 开发者拥有的 SDK 工程 - -Status: proposed - -[English](2026-07-14-sdk-developer-projects.md) | 中文 - -## 问题 - -DeepSeek Harness 通过 Cordis 插件对功能进行组合,但从空目录开始搭建一个可运行工程仍要求开发者同时理解 NPM 依赖、`cordis.yml` 插件组、环境变量、TypeScript 构建、本地插件 workspace 和运行入口。手工步骤之间存在约束,漏掉任意一处都会得到能够安装却无法开发、能够开发却无法构建,或能够构建却无法启动的工程。 - -一次性生成器只能降低首次创建成本。若生成结果隐藏在 preset 或不可编辑的 CLI(命令行界面)内部,高级开发者无法调整插件树、修改 Cordis 插件配置或增加项目特有行为;若创建后的工程完全脱离工具管理,开发者又必须重新承担所有 NPM 依赖和 Cordis 插件配置的一致性工作。 - -初始创建和后续配置面对同一组内置功能。两条流程各自维护功能列表、功能选项和 NPM 依赖时,新增 Cordis 插件、NPM 包或调整 Cordis 插件配置会使二者逐渐分叉。工程还需要一条普通的本地插件开发路径,参与开发、构建和启动流程。 - -## 提案 - -SDK 创建一个普通、显式且归开发者所有的 TypeScript/Cordis 工程。`cordis.yml` 是唯一的运行时插件树;开发和生产读取同一份文件。工程中的 `package.json`、`cordis.yml`、TypeScript 入口、构建配置和 `plugins/*` 均可直接编辑,SDK 不把它们封装成不可见的 preset。 - -开发者产品入口只有 `npm create @deepseek-ai/sdk` 和 `dsh-sdk` 命令。前者负责首次创建,`dsh-sdk config` 在创建后管理 SDK 能识别的内置功能,`dsh-sdk dev`、`dsh-sdk build` 与 `dsh-sdk start` 负责开发、构建和启动;本期不提供 `dsh-sdk create`。create 与 config 使用同一份人工编写的功能定义,因此一项功能的功能选项、NPM 依赖、Cordis 配置项、相关文件和识别规则只有一个来源。[SDK 工程编辑架构](../architecture/2026-07-15-sdk-project-editing-architecture.md) 定义了功能、功能选项等术语。 - -SDK 只为功能选择和有限功能选项提供交互,不尝试把任意 Cordis 插件配置变成通用表单。功能选项所需的少量专用输入由所属功能收集;其余 Cordis 插件配置留在 `cordis.yml` 中,并通过注释指明常用改法,由开发者直接修改。 - -## 开发者流程 - -首次创建按会影响后续问题集合的顺序收集信息:目标目录与包身份、模型提供方与凭据、运行接口、内置功能与功能选项、可选本地插件、包管理器,以及是否安装 NPM 依赖并构建。命令参数已提供的答案不重复询问;本期 create 和 config 都要求交互式 TTY,取消创建时不写入目标目录。 - -```sh -npm create @deepseek-ai/sdk my-agent -cd my-agent -npm exec dsh-sdk dev index.ts -npm exec dsh-sdk config -npm exec dsh-sdk build -npm exec dsh-sdk start index.js -``` - -create 拒绝任何已经存在的目标路径。工程文件提交成功后,CLI 询问是否安装 NPM 依赖并构建;安装或构建失败时保留生成结果,并打印可以重新执行的命令。 - -create 还提供一次 `none / plugin / tool` 选择。`plugin` 固定生成 `plugins/plugin` 的 Cordis 插件,`tool` 固定生成 `plugins/tool` 的模型工具;一次创建至多包含一个本地插件。生成操作同时更新 workspace、根 NPM 依赖、TypeScript reference、构建配置和 `cordis.yml`,任何写入前校验失败都不创建工程。 - -## 创建时支持的功能 - -下表是本期 create 面向开发者展示的支持集。`required` 始终存在但仍可切换有限功能选项;`default` 在选择树中预选;`optional` 由开发者主动选择。表格说明产品支持集,运行时注册表是实现的真源。 - -| 功能 | create 状态 | 功能选项 | 限制与关系 | -|---|---|---|---| -| `provider` | required | `deepseek`(默认)/ `custom` | DeepSeek 收集 API key;custom 另收集 base URL,模型名可由 CLI 参数覆盖 | -| `app` | required | `tui`(默认)/ `acp` / `embed` | 选择运行接口 | -| `spine` | required | `default` | timer、LLM(大语言模型)seam、会话存储、系统提示词、工具注册表、agent 注册表,以及 agent loop(智能体循环) | -| `bash` | required | `local`(默认)/ `sandbox` | 两个功能选项互斥、与运行接口正交,且都安装面向模型的 bash 工具;sandbox 安装本地沙箱提供方和沙箱 bash 后端 | -| `persistence` | required | `jsonl`(默认)/ `sqlite` | 每个工程恰好选择一个持久化后端 | -| `hmr` | default | `default` | 加载 `@cordisjs/plugin-hmr`;dev 和 start 都启用,使用插件默认配置 | -| `fs` | default | `local` | 安装本地文件系统、策略和模型工具;进程沙箱不约束进程内 fs 工具 | -| `todo` | default | `default` | 提供 `todo_write` 工具 | -| `skill` | default | `default` | 安装 skill(技能)注册表、本地 skill 提供方和面向模型的 skill 工具 | -| `web` | optional | `deepseek`(默认)/ `exa` / `perplexity` / `fetch-only` | 搜索功能选项互斥;Exa/Perplexity 收集各自 API key;建议同时启用 timeout policy | -| `subagent` | optional | `spawn`(默认)/ `fork`,可多选 | 本期只提供进程内后端 | -| `workflow` | optional | `workerthread` | 要求 subagent 的 `spawn` 功能选项 | -| `compact` | optional | `basic` | 使用 SDK 提供的上下文压缩(context compaction)参数 | -| `hooks` | optional | `claude`(默认)/ `codex`,可多选 | 各功能选项生成独立的可编辑配置文件 | -| `guard` | optional | `repeat-tool` | 提供重复工具调用提醒 | -| `timeout-policy` | optional | `default` | 对声明超时预算的工具执行统一策略 | -| `ask-user` | optional | `default` | 提供 `ask_user_question` 工具;只有 `tui` 可选,因为 ACP(Agent Client Protocol)是自动化传输,而 embed 不提供人类交互服务 | - -`bash` 的两个功能选项都适用于 ACP、TUI 和 embed,不由运行接口决定。sandbox 功能选项不写任何生效的配置键,因而沿用 `dsh-bash-sandbox` 的 `read-only` 默认值;生成的 `cordis.yml` 保留注释示例,开发者可以显式改为 `workspace-write`: - -```yaml -- id: bash - name: '@deepseek-ai/dsh-bash-sandbox' - # Uncomment to allow writes under the project workspace. - # config: - # mode: workspace-write - # workspaceRoot: !!js process.cwd() -``` - -功能贡献只引用单插件 NPM 包,绝不引用 `agent-spine-demo`、`tui-demo`、`acp-demo` 这类组合 NPM 包。表格之外的插件不由本期 create 管理;开发者仍可直接编辑普通工程文件进行高级组合。 - -## 生成工程 - -使用默认答案创建 npm 工程时,提供方为 DeepSeek,运行接口为 TUI,bash 为 local,持久化为 JSONL,hmr、fs、todo 与 skill 处于选中状态。初始目录树为: - -```text -my-agent/ -├── .env -├── .env.example -├── .gitignore -├── README.md -├── cordis.yml -├── index.ts -├── package.json -├── tsconfig.base.json -├── tsconfig.json -└── tsdown.config.ts -``` - -`.env.example` 始终存在,并由 SDK 根据当前功能维护占位。收集到 secret 或开发者确认稍后填写空凭据时,同时生成 gitignored `.env`。SDK 只向 `.env` 追加尚不存在的不同名变量,绝不覆盖或删除已有内容;切换功能选项可以清理 `.env.example` 中不再需要的占位,但旧凭据仍留在 `.env` 中供开发者自行处理。pnpm 和 Yarn 工程增加各自所需的 workspace 配置文件,但运行时插件树和 TypeScript 入口不分叉。 - -生成的 `package.json` 提供以下 scripts;其中 `dev`、`build`、`start` 与 `config` 调用 `dsh-sdk`,`typecheck` 直接调用 TypeScript: - -| script | 行为 | -|---|---| -| `dev` | 运行 `dsh-sdk dev index.ts`,为 TypeScript 和本地 workspace 插件注册开发期解析 | -| `build` | 运行 `dsh-sdk build`,调用工程安装的 tsdown 构建根入口和 `plugins/*` 包 | -| `typecheck` | 直接运行 `tsc -b` | -| `start` | 运行 `dsh-sdk start index.js`,启动已构建入口且不隐式构建 | -| `config` | 运行 `dsh-sdk config`,修改当前工程功能树 | - -`dsh-sdk start` 与 `dsh-sdk dev` 可以接收模块 target,并把 `--` 后的参数原样转发给工程入口。通用参数解析使用 Node `parseArgs()` 的零 schema 模式:带值 flag 采用 `--key=value`,bare flag 转换为 `true`,`--no-*` 转换为 `false`。 - -- TUI 工程通过 `--model=` 传入所选 model,并根据可选的 `--resume=` 创建或恢复 agent; -- ACP 客户端通过协议 `session/new` 创建全新会话; -- embed 使用生成代码中的 model。 - -每个功能拥有的 Cordis 配置项在 `cordis.yml` 中保留自己的可编辑 Cordis 插件配置和说明注释;`dsh-sdk config` 修改其他功能时必须保留未知字段、未修改节点的格式和注释。HMR(热模块替换)是普通叶子配置项:选择该功能后,dev 和 start 加载同一个 watcher,命令不隐式改变插件树。 - -## 创建后的配置 - -`dsh-sdk config` 只要求当前目录具有可读的根 `package.json` 与 `cordis.yml`。它检查标准功能及其当前功能选项,以一棵功能树表达最终目标状态,并在 Review & Apply 前展示功能变化和受影响文件。 - -`dsh-sdk config` 可以安装缺失功能、启停已安装功能和切换有限功能选项。required 功能不能取消。改变 NPM 依赖后只运行一次项目包管理器安装;安装失败不回滚已经提交的工程文件。 - -SDK 只修改功能明确拥有的 Cordis 配置项、配置键、NPM 依赖、`.env.example` 占位和自有文件。同一功能选项的更新保留 Cordis 配置项中的未知配置键;手写或第三方插件只支持按稳定 ID 启停。已知功能被手改成不完整、歧义或无法读取的形状时,`dsh-sdk config` 显示诊断并拒绝自动修改,直到开发者手工修复。 - -一次 config 会话在内存工作区上累计全部修改。Apply 前完成功能关系、资源冲突和文件形状校验,并比较受影响文件与会话打开时的原文;校验失败或检测到外部修改时不写盘。实际写盘开始后不提供跨文件事务回滚。 - -## 维护模型 - -Builtin 支持集由 SDK 人工策划,不根据 NPM 依赖名称或目录约定自动暴露。一个功能可以组合多个 Cordis 配置项,功能选项可以共享资源,并声明对其他功能或特定功能选项的功能依赖;新增普通功能或功能选项无需同时修改 create 和 config 两个命令流程。 - -## 后续工作 - -- `dsh-sdk add [package-spec]`:统一本地插件创建与外部 Cordis 插件接入;未指定包或仓库来源时创建本地插件/工具,指定来源时增加 NPM 依赖和 `cordis.yml` 配置项,来源模型为 GitHub 仓库等扩展保留空间 -- 非交互 create/config:本期两个流程都要求 TTY,不提供供自动化调用的完整输入约定 -- 更多功能专用参数输入:本期产品只展示有限功能选项、secret 和少量专用值,不为 Cordis 插件配置提供通用参数界面 - -## 曾考虑的替代方案 - -**不可编辑的 preset 或生成器托管工程。** 该方案可以缩短初次创建路径,但会隐藏真实插件树和构建边界,使高级开发者无法直接组合 Cordis 插件,也让项目行为依赖 CLI 版本而不是检入的工程文件。 - -**只提供一次性生成器。** 创建后完全依赖手工维护,会让功能依赖、功能选项切换和多文件更新再次分散;共享注册表的 config 流程为生成工程保留持续管理机制。 - -**为开发和生产维护两份 `cordis.yml`。** 两份插件树会使开发成功无法证明生产加载相同功能;dev 只增加 TypeScript 与本地 workspace 解析,运行配置保持唯一。 - -**为任意 Cordis 插件配置生成通用表单。** Cordis 插件配置包含嵌套结构、表达式和插件特有语义,通用表单会形成第二套不完整 schema。SDK 只管理有限功能选项和专用 secret,复杂配置继续由开发者直接编辑。 - -**使用私有协议发现本地插件。** 普通包管理器 workspace、根 NPM 依赖、TypeScript references 和 Cordis 配置项已能表达完整关系;额外发现协议会创造只能由 SDK 理解的隐藏状态。 - -**在现有工程中提供 `dsh-sdk create`。** create 已能生成一个可编辑的本地插件骨架,后续插件可以沿用普通 workspace 和 Cordis 机制手工添加;再提供并行命令会增加第二条脚手架产品面,却不增加新的组合功能。 - -**把每个新 Cordis 插件自动暴露为 builtin。** 包无法说明多个插件如何组合成一项产品功能,也无法推导互斥关系、功能依赖、secret、接口适用性和安全限制;支持集需要人工策划,自动化只适合检查候选是否完成分类。 - -## 验收标准 - -- `npm create @deepseek-ai/sdk` 按本文顺序收集项目身份、提供方、接口、功能、可选本地插件、包管理器和安装选择,并在取消时保持目标路径不存在 -- 默认 npm 工程具有本文目录树和 `dev`、`build`、`typecheck`、`start`、`config` scripts,且 dev/start 使用同一份 `cordis.yml` -- create 展示本文功能及功能选项;`bash` 的 local/sandbox 二选一且默认 local,sandbox Cordis 配置项保留可编辑的注释配置示例;HMR 默认选中并同时由 dev/start 加载 -- create 的 `plugin` 或 `tool` 选择至多生成一个固定名称的本地插件,并原子更新插件文件与根工程关系;本期不提供 `dsh-sdk create` -- `dsh-sdk config` 从现有工程读取同一支持集,能够安装、启停和切换支持的功能选项,保留未知配置与注释,并拒绝修改不一致配置 -- `.env.example` 反映当前功能所需变量;`.env` 只追加缺失的不同名变量,从不覆盖或清理已有内容 -- npm、pnpm 和 Yarn 生成的 workspace 能安装、构建和启动;本地插件在 dev 中使用源码,在 start 中使用构建产物 - -## 风险 - -- 开发者可以把 builtin 手改成注册表无法识别的形状;SDK 选择停止自动管理该功能而不是猜测并覆盖配置 -- 多文件写入前的校验和外部修改检测不能提供写入阶段的事务回滚;I/O 中途失败可能留下需要人工修复的部分提交 -- sandbox 功能选项依赖目标平台存在可用的本地沙箱后端;后端不可用时必须 fail closed,不能退回无沙箱执行 -- HMR 在生产启动中也保持文件 watcher 和热重载行为;这是显式插件选择的结果,不是仅限开发环境的隐式服务 -- `.env` 的仅追加策略会保留已经不用的凭据,SDK 不判断这些用户拥有的密钥数据何时可以安全删除 diff --git a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml deleted file mode 100644 index 7084931b1d..0000000000 --- a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 .agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md -2026-07-17-sdk-follow-up-capabilities.md: f14d46a61f5fd3e64067441c2f8340cf94746a79 -2026-07-17-sdk-follow-up-capabilities.zh.md: 2e5efba340d503d2445e408bfc43ee0d6c6bec3c diff --git a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md deleted file mode 100644 index f14d46a61f..0000000000 --- a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md +++ /dev/null @@ -1,120 +0,0 @@ -# Agent Note: SDK follow-up capabilities - -Status: proposed - -English | [中文](2026-07-17-sdk-follow-up-capabilities.zh.md) - -## Problem - -The first SDK release creates and edits developer-owned Cordis projects through the shared model defined by the [developer-project Agent Note](2026-07-14-sdk-developer-projects.md) and the [project-editing architecture](../architecture/2026-07-15-sdk-project-editing-architecture.md). Its create and config workflows are interactive, external Cordis plugins require manual dependency and configuration edits, command-line telemetry has no owning boundary, and interactive branches lack a stable test strategy. - -These gaps are coupled. Create and config already share questions, feature configuration, and `ProjectEditSession`; adding separate automation paths would duplicate that domain logic. External-plugin installation must update both the package manager's files and `cordis.yml`. Telemetry must observe commands such as create and build that do not boot Cordis. Interactive testing must exercise Harness behavior without making terminal rendering a brittle product contract. - -## Proposal - -The SDK extends the existing prompt and project-editing boundaries instead of creating parallel workflows. A non-interactive prompt port and structured feature plan drive create and config, `dsh-sdk create ` delegates dependency resolution to the project package manager before mounting the resolved package through `ProjectEditSession`, launcher-side telemetry wraps `create-sdk` and every `dsh-sdk` command, and injected prompt streams provide the primary interactive-test hook. - -| Capability | Product entrypoint | Owning mechanism | Required outcome | -|---|---|---|---| -| Headless project creation | `create-sdk --config ` or `--config-json ` with optional `--json` | `HeadlessPromptPort`, structured project answers, and a complete feature plan | No terminal blocking; missing required input is explicit | -| External Cordis plugin installation | `dsh-sdk create ` | Native package-manager `add` plus `ProjectEditSession` | The dependency and `cordis.yml` entry identify the package manager's resolved package | -| Developer-cycle telemetry | `create-sdk` and every `dsh-sdk` command | Launcher-side consent, payload, redaction, anonymous identity, and delivery services | Reporting is best-effort and cannot change the command result | -| Interactive regression coverage | Create and config tests | Injected `PromptPort` input/output and filesystem assertions | Tests cover Harness decisions and generated files without snapshotting terminal repainting | - -## Shared headless workflow - -### Structured input and lifecycle events - -Headless create accepts a JSON object either inline through `--config-json` or from a file through `--config`. Scalar fields supply the ordinary create answers, while `features` supplies the complete selected feature set, feature options, secrets, and dedicated values. Defaults remain valid only where the owning question declares one; the headless path never invents an answer for a required prompt. - -With `--json`, stdout is an NDJSON event stream. `done` means creation and any requested setup completed, `action-required` names an unanswered required prompt, and `error` reports another failure. Human-readable progress and package-manager output go to stderr so every stdout line remains parseable as one event. A caller responds to `action-required` by adding the missing value and running the command again. - -Create and config consume the same feature-plan shape. Create exposes it through the command-line inputs above; config uses it at the shared workflow boundary so a later automation entrypoint does not need a second feature-selection model. - -### Prompt and project-editing boundaries - -`PromptPort` remains the only boundary between SDK questions and an interaction implementation. `ClackPromptPort` handles terminals. `HeadlessPromptPort` consumes defaults exposed by the question contract and otherwise fails with the unanswered prompt; prefilled values normally prevent the port from being called. - -Both paths use the same `Question` objects, `FeatureConfigurator`, `SdkProject`, and `ProjectEditSession`. The headless path therefore changes how answers arrive, not how features are interpreted or files are committed. - -### Agent skill - -The repository ships a thin `SKILL.md` that teaches an agent to construct the structured input, request NDJSON, fill an `action-required` value, and retry. The skill invokes the public CLI and does not import an internal SDK API or introduce another project specification. - -## External Cordis plugin installation - -`dsh-sdk create ` accepts a package-manager-native npm specifier such as `pkg@version` or a GitHub specifier such as `github:owner/repo#ref`. After confirmation, it asks the project's package manager to add the source, compares the direct dependency names before and after the operation, reopens the project, and mounts each newly resolved package in `cordis.yml` through `ProjectEditSession`. - -The package manager owns source parsing, version or commit resolution, integrity data, lockfile updates, and any build policy. The SDK does not download or unpack a second copy through giget or pacote. An external plugin remains a dependency under `node_modules`; local plugin scaffolding remains a separate project-creation concern. - -This proposal concerns dependencies of developer-owned SDK projects. Standalone apps install external packages as [profile bundles](../../implemented/simplification/2026-08-09-remove-repository-plugin.md), with their profile package manager and lockfile owning acquisition and lifecycle policy. - -## Launcher telemetry - -### Consent and collection - -Telemetry wraps the `create-sdk` initializer and the `dsh-sdk` launcher command lifecycle because project initialization, plugin creation, and build do not reliably boot Cordis. One event records the command name, duration, success, a random per-user anonymous identifier, and redacted `cordis.yml` and `package.json` text when those project files are eligible. - -Reporting is enabled unless a present telemetry config entry is explicitly disabled. `DO_NOT_TRACK` and CI deny reporting regardless of project configuration. A missing `cordis.yml` does not itself deny the event, but `package.json` content is included only when `cordis.yml` establishes that the directory is an SDK project. - -### Safety and delivery - -The payload builder never reads `.env`. It redacts secret-shaped keys and values, known token forms, PEM blocks, URL credentials, and high-entropy opaque strings in the two eligible text files. Redaction is a safety backstop rather than a guarantee; SDK projects must keep credentials in `.env`. - -The reporter uses a fixed endpoint and resolves every send path without throwing. Command dispatch records success or failure in a `finally` path, starts reporting after the command outcome is known, and drains within a bounded interval. Consent parsing, payload construction, storage, or network failures are swallowed only at this telemetry boundary and never alter the command's exit code. - -## Interactive workflow testing - -Create and config tests inject a `PromptPort` and scripted input/output streams into the existing workflows. Parameterized scenarios cover feature selection, feature options, secrets, cancellation, review, and apply behavior, then assert the resulting `cordis.yml` and other project files. The stable product assertion is the generated project state, not clack's ANSI redraw sequence. - -One or two optional real-PTY smoke tests may cover the shipped binary and TTY guard that injection cannot reproduce. Native PTY tooling does not belong on the required path unless it is reliable across the repository's supported Node and host versions. - -## Deferred work - -- Extend the headless create specification to express local `plugin` or `tool` scaffolding instead of defaulting that interactive choice to none. -- Expose the telemetry opt-out in create and config while preserving the consent representation in which only a disabled telemetry entry is written. -- Define whether GitHub source dependencies must be prebuilt or may run package-manager-controlled preparation scripts, and surface the policy before installation. -- Replace the telemetry package's `.invalid` endpoint placeholder with the production endpoint before release. - -## Alternatives considered - -**Build a separate headless creation engine.** This would duplicate questions, feature requirements, configuration behavior, and project-editing rules. Reusing the prompt and edit-session boundaries keeps one implementation of project semantics. - -**Make a specification file the primary automation interface.** Agents can pass the same typed JSON object inline, while people and CI may still use a file. A file-only protocol adds persistence and cleanup without adding semantics. - -**Use `npx skills add` as the project creator.** The skills CLI installs Markdown skills; it does not create SDK projects or install npm packages. The agent skill therefore drives the SDK initializer instead of replacing it. - -**Fetch GitHub and npm sources through giget or pacote.** A second fetch layer would duplicate package-manager resolution, integrity, lockfile, and lifecycle policy. Native dependency specifiers keep those decisions in the selected package manager. - -**Implement telemetry as a Cordis runtime plugin.** Create and build do not necessarily boot Cordis, so a runtime plugin cannot observe the complete developer command cycle. The launcher is the boundary shared by those commands. - -**Derive the anonymous identifier from git metadata.** Repository remotes can identify a project or organization. A random per-user identifier supports aggregation without encoding repository identity. - -**Collect only aggregate counters.** Aggregate-only events reduce exposure but cannot answer which plugins, dependencies, and configuration shapes developers actually use. This proposal accepts collection of redacted project text and makes that exposure explicit. - -**Use real PTYs and transcript snapshots as the primary test strategy.** Native PTY dependencies and terminal repaint sequences add platform and rendering instability while mostly testing clack. Injected interaction plus generated-file assertions tests the SDK-owned behavior directly. - -## Acceptance criteria - -- Create runs without a TTY from a complete structured input, emits only NDJSON on stdout under `--json`, and reports missing required input as `action-required` without writing a partial project. -- Create and config resolve the same feature-plan contract through the shared question, feature-configuration, and project-editing code paths. -- `dsh-sdk create ` uses the selected project package manager, mounts the dependency name that operation actually added, and fails loudly when no new dependency can be identified. -- The initializer and every `dsh-sdk` command reach one best-effort telemetry completion path; an explicit disabled entry, `DO_NOT_TRACK`, or CI prevents delivery, and telemetry failures never change the command result. -- Telemetry never reads `.env`, withholds unrelated `package.json` content when no `cordis.yml` exists, redacts both eligible text payloads, and uses an identifier unrelated to git metadata. -- Interactive tests cover create and config decisions through injected interaction and assert committed project files; any real-PTY coverage remains a narrow smoke layer. -- The agent skill documents the public structured-input and event contracts without depending on private package exports. - -## Risks - -- Full redacted `cordis.yml` and `package.json` text still reveals plugin and dependency names, URLs, paths, and configuration values to the endpoint operator, and heuristic redaction can miss a secret. -- Default-on reporting may surprise developers when no telemetry entry exists; the CLI must make the opt-out discoverable before release. -- A package-manager add can change `package.json`, the lockfile, and installed files before `ProjectEditSession` mounts the plugin, so a later mount failure can leave dependency changes that require manual recovery. -- GitHub dependencies may execute preparation or lifecycle code according to package-manager policy; an unresolved build policy is a supply-chain and reproducibility risk. -- Injected prompt tests do not prove raw-mode, signal, or repaint behavior in a real terminal; the optional smoke layer must cover only those residual contracts. - -## References - -- [Vercel Eve](https://github.com/vercel/eve) and [Vercel Labs Skills](https://github.com/vercel-labs/skills) for the distinction between a headless initializer and skill distribution. -- [npm package specifications](https://docs.npmjs.com/cli/v11/using-npm/package-spec), [pnpm add](https://pnpm.io/cli/add), and [Yarn add](https://yarnpkg.com/cli/add) for package-manager-native sources. -- [`DO_NOT_TRACK`](https://donottrack.sh/) for the environment-level opt-out convention. -- [Clack](https://github.com/bombshell-dev/clack) and [Vitest snapshots](https://vitest.dev/guide/snapshot) for injected prompts and generated-file assertions. diff --git a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md deleted file mode 100644 index 2e5efba340..0000000000 --- a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md +++ /dev/null @@ -1,120 +0,0 @@ -# Agent Note: SDK 后续功能 - -Status: proposed - -[English](2026-07-17-sdk-follow-up-capabilities.md) | 中文 - -## 问题 - -首个 SDK 版本通过[开发者工程 Agent Note](2026-07-14-sdk-developer-projects.md) 和 [SDK 工程编辑架构](../architecture/2026-07-15-sdk-project-editing-architecture.md)定义的共享模型创建和编辑开发者拥有的 Cordis 工程。create 和 config 工作流仅支持交互调用,接入外部 Cordis 插件需要手工修改依赖和配置,命令行遥测没有明确的所属边界,交互分支也缺少稳定的测试策略。 - -这些缺口彼此关联。create 和 config 已经共享问题、功能配置和 `ProjectEditSession`;若另建自动化路径,就会复制领域逻辑。安装外部插件必须同时修改包管理器文件和 `cordis.yml`。遥测需要观察 create、build 等不会启动 Cordis 的命令。交互测试需要覆盖 Harness 自身行为,同时避免把终端渲染固化成脆弱的产品约定。 - -## 提案 - -SDK 扩展现有提示词与工程编辑边界,不另建平行工作流。非交互式 `PromptPort` 实现和结构化功能计划驱动 create 与 config;`dsh-sdk create ` 先把依赖解析交给工程的包管理器,再通过 `ProjectEditSession` 挂载解析所得的包;启动器侧遥测包住 `create-sdk` 和每个 `dsh-sdk` 命令;注入的提示词输入输出流提供主要的交互测试钩子。 - -| 功能 | 产品入口 | 所属机制 | 必须达到的结果 | -|---|---|---|---| -| Headless 工程创建 | `create-sdk --config ` 或 `--config-json `,可搭配 `--json` | `HeadlessPromptPort`、结构化工程答案和完整功能计划 | 不阻塞等待终端;明确报告缺失的必答输入 | -| 外部 Cordis 插件安装 | `dsh-sdk create ` | 包管理器原生 `add` 加 `ProjectEditSession` | 依赖和 `cordis.yml` 配置项指向包管理器解析出的包 | -| 开发周期遥测 | `create-sdk` 和每个 `dsh-sdk` 命令 | 启动器侧的上报条件判断、遥测内容构建、脱敏、匿名身份和传输服务 | 上报采用尽力而为语义,不能改变命令结果 | -| 交互回归覆盖 | create 和 config 测试 | 注入的 `PromptPort` 输入输出和文件系统断言 | 测试覆盖 Harness 决策与生成文件,不快照终端重绘 | - -## 共享 headless 工作流 - -### 结构化输入和生命周期事件 - -Headless create 通过 `--config-json` 接收内联 JSON 对象,或通过 `--config` 从文件读取。标量字段提供普通 create 答案,`features` 提供完整的已选功能、功能选项、secret(密钥)和专用值。只有所属问题明确声明的默认值才有效;headless 路径绝不为必答问题臆造答案。 - -使用 `--json` 时,stdout 是 NDJSON 事件流。`done` 表示创建及要求执行的安装和构建均已完成,`action-required` 指明一个尚未回答的必答问题,`error` 报告其他失败。面向人的进度信息和包管理器输出写入 stderr,确保 stdout 每一行都能解析成一个事件。调用方收到 `action-required` 后补充缺失值,再次运行命令。 - -Create 和 config 使用相同的功能计划形状。create 通过上述命令行输入公开该形状;config 在共享工作流边界使用同一形状,使后续自动化入口无需另建功能选择模型。 - -### Prompt 与工程编辑边界 - -`PromptPort` 仍是 SDK 问题与交互实现之间的唯一边界。`ClackPromptPort` 负责终端交互。`HeadlessPromptPort` 使用问题约定公开的默认值,否则通过未回答问题快速失败;预填值通常会让流程根本不调用该 port。 - -两条路径使用相同的 `Question` 对象、`FeatureConfigurator`、`SdkProject` 和 `ProjectEditSession`。因此,headless 路径只改变答案的到达方式,不改变功能解释或文件提交方式。 - -### Agent skill - -仓库提供一份轻量 `SKILL.md`,指导 agent skill(智能体技能)构造结构化输入、请求 NDJSON、补充 `action-required` 指明的值并重试。该 skill 调用公开 CLI,不导入 SDK 内部 API,也不引入另一套工程规格。 - -## 外部 Cordis 插件安装 - -`dsh-sdk create ` 接受包管理器原生的 npm package specifier,例如 `pkg@version`,也接受 `github:owner/repo#ref` 等 GitHub package specifier。用户确认后,命令要求工程包管理器添加来源,对比操作前后的直接依赖名,重新打开工程,再通过 `ProjectEditSession` 把每个新增且已解析的包挂载进 `cordis.yml`。 - -包管理器负责来源解析、版本或 commit 解析、`integrity` 数据、lockfile 更新和构建策略。SDK 不再通过 giget 或 pacote 下载、解压第二份副本。外部插件是 `node_modules` 下的依赖;本地插件脚手架仍属于独立的工程创建问题。 - -本提案只涉及开发者自有 SDK 工程的依赖。独立应用将外部包安装为 [profile 组合包](../../implemented/simplification/2026-08-09-remove-repository-plugin.md),由 profile 的包管理器与 lockfile 负责获取和生命周期策略。 - -## Launcher 遥测 - -### Consent 与采集 - -遥测包住 `create-sdk` 初始化命令与 `dsh-sdk` launcher 的命令生命周期,因为工程初始化、插件创建和 build 都不会稳定地启动 Cordis。每个事件记录命令名、时长、成败、随机生成的用户级匿名标识符,以及符合条件时经过脱敏的 `cordis.yml` 与 `package.json` 文本。 - -除非当前存在的遥测配置项被明确禁用,否则允许上报。`DO_NOT_TRACK` 和 CI 无论工程配置如何都禁止上报。缺少 `cordis.yml` 本身不会禁止事件,但只有 `cordis.yml` 能证明目录是 SDK 工程时,遥测内容才包含 `package.json` 文本。 - -### 安全与传输 - -Payload 构建器绝不读取 `.env`。它会脱敏两个符合条件的文本文件中的疑似密钥键和值、已知 token 形式、PEM 块、URL 凭据和高熵不透明字符串。脱敏只是安全兜底,不能提供绝对保证;SDK 工程必须把凭据放进 `.env`。 - -`TelemetryReporter` 使用固定 endpoint,每条发送路径都会正常结束且不抛错。命令分发通过 `finally` 路径记录成败,在命令结果已确定后启动上报,并在有界时间内等待传输结束。只有遥测边界会吞掉上报条件解析、遥测内容构建、存储或网络错误,这些错误绝不改变命令退出码。 - -## 交互工作流测试 - -Create 和 config 测试向现有工作流注入 `PromptPort` 和脚本化输入输出流。参数化场景覆盖功能选择、功能选项、secret、取消、评审和应用行为,再断言最终的 `cordis.yml` 及其他工程文件。稳定的产品断言是生成后的工程状态,不是 clack 的 ANSI 重绘序列。 - -可以用一到两个可选的真实 PTY 冒烟测试覆盖注入无法复现的发布二进制和 TTY 检查。除非原生 PTY 工具在仓库支持的 Node 与宿主版本上足够可靠,否则它不进入必跑路径。 - -## 延后工作 - -- 扩展 headless create 规格,使其能表达本地 `plugin` 或 `tool` 脚手架,而不是把该交互选择默认为 none。 -- 在 create 和 config 中公开遥测关闭选项,同时保留只有禁用时才写入遥测配置项的上报许可表示。 -- 明确 GitHub 来源依赖必须预先构建,还是允许运行由包管理器控制的 preparation script(准备脚本),并在安装前向用户展示该策略。 -- 发布前把遥测包中的 `.invalid` endpoint 占位符替换为生产端点。 - -## 曾考虑的替代方案 - -**另建 headless 创建引擎。** 该方案会复制问题、功能依赖、配置行为和工程编辑规则。复用提示词与编辑会话边界,可以保证工程语义只有一份实现。 - -**把规格文件作为主要自动化接口。** Agent 可以内联传入相同的类型化 JSON 对象,人和 CI 仍可选用文件。文件专用协议会增加持久化与清理工作,却不增加语义。 - -**使用 `npx skills add` 创建工程。** Skills CLI 只安装 Markdown skill,不创建 SDK 工程,也不安装 npm 包。因此,agent skill 驱动 SDK 初始化命令,而不是取代它。 - -**通过 giget 或 pacote 获取 GitHub 与 npm 来源。** 第二套获取层会复制包管理器的解析、完整性、lockfile 和生命周期策略。原生 package specifier 让这些决策留在所选包管理器中。 - -**把遥测实现成 Cordis 运行时插件。** Create 和 build 不一定启动 Cordis,因此运行时插件无法观察完整的开发命令周期。Launcher 是这些命令共用的边界。 - -**从 git 元数据派生匿名标识符。** 仓库的 git remote 可能识别工程或组织。随机的用户级标识符能够支持聚合,同时不编码仓库身份。 - -**只采集聚合计数。** 仅聚合事件可以降低暴露,但无法回答开发者实际使用哪些插件、依赖和配置形状。本提案接受采集脱敏后的工程文本,并明确记录这项暴露。 - -**把真实 PTY 和 transcript(文本记录)快照作为主要测试策略。** 原生 PTY 依赖与终端重绘序列会带来平台和渲染不稳定性,而且主要是在测试 clack。注入交互并断言生成文件,可以直接测试 SDK 拥有的行为。 - -## 验收标准 - -- Create 能依据完整结构化输入在没有 TTY 时运行;使用 `--json` 时 stdout 只输出 NDJSON;缺少必答输入时通过 `action-required` 报告,且不写入部分工程。 -- Create 和 config 通过共享的问题、功能配置和工程编辑代码路径解析相同的功能计划约定。 -- `dsh-sdk create ` 使用工程选定的包管理器,挂载该操作实际新增的依赖名;无法识别新增依赖时快速失败。 -- 初始化命令与每个 `dsh-sdk` 命令都进入同一条尽力而为的遥测收尾路径;明确禁用的配置项、`DO_NOT_TRACK` 或 CI 会阻止传输,遥测失败绝不改变命令结果。 -- 遥测绝不读取 `.env`;没有 `cordis.yml` 时不发送无关的 `package.json` 内容;两个符合条件的文本都经过脱敏;匿名标识符与 git 元数据无关。 -- 交互测试通过注入交互覆盖 create 和 config 决策,并断言已提交的工程文件;真实 PTY 覆盖只作为窄范围冒烟层。 -- Agent skill 说明公开的结构化输入与事件约定,不依赖包的私有导出。 - -## 风险 - -- 即使经过脱敏,完整的 `cordis.yml` 与 `package.json` 文本仍会向 endpoint 运营方暴露插件名、依赖名、URL、路径和配置值;启发式脱敏也可能漏掉 secret。 -- 没有遥测配置项时默认上报可能让开发者意外;发布前 CLI 必须让关闭方法易于发现。 -- 在 `ProjectEditSession` 挂载插件前,包管理器的 add 操作已经可能修改 `package.json`、lockfile 和安装文件;后续挂载失败会留下需要手工恢复的依赖改动。 -- GitHub 依赖可能按包管理器策略执行 preparation 或 lifecycle script;尚未解决的构建策略会带来供应链与可复现性风险。 -- 注入提示词交互的测试无法证明真实终端中的 raw mode、signal 或重绘行为;可选冒烟层只应覆盖这些残余约定。 - -## 参考资料 - -- [Vercel Eve](https://github.com/vercel/eve) 与 [Vercel Labs Skills](https://github.com/vercel-labs/skills) 用于区分 headless 初始化命令与 skill 分发。 -- [npm package specifications](https://docs.npmjs.com/cli/v11/using-npm/package-spec)、[pnpm add](https://pnpm.io/cli/add)和 [Yarn add](https://yarnpkg.com/cli/add)说明包管理器原生来源。 -- [`DO_NOT_TRACK`](https://donottrack.sh/)定义环境级关闭约定。 -- [Clack](https://github.com/bombshell-dev/clack) 和 [Vitest snapshots](https://vitest.dev/guide/snapshot) 说明注入提示词交互与生成文件断言。 diff --git a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml index 71d6fa82b5..ad95a75a91 100644 --- a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml +++ b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.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/proposed/simplification/2026-07-19-make-jsonrpc-directional.md -2026-07-19-make-jsonrpc-directional.md: 945d58a0c989dac2beeb129bfd545fe5674c63cd -2026-07-19-make-jsonrpc-directional.zh.md: 181aab8cd2f2473f0b2694df1dff072fd805e69d +2026-07-19-make-jsonrpc-directional.md: 87c134ec1f412b1e4df439a5b6434f83e52b531e +2026-07-19-make-jsonrpc-directional.zh.md: b728f2335f1605f80b0b224fa2c889591c635645 diff --git a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.md b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.md index 945d58a0c9..87c134ec1f 100644 --- a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.md +++ b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.md @@ -20,11 +20,11 @@ Return the settled outcome directly from `session/prompt` as `{ status, reason } ## Implementation plan -1. In `packages/scaffold/server/src/server.ts`, replace `SessionPromptResult.accepted` with `status: 'ok' | 'error' | 'aborted'` and the captured `TurnEndReason`. `HarnessSdkServer.prompt()` will return `completed` as `ok`, `aborted` as `aborted`, and every other current or merge-extensible reason as `error`; reaching idle without a `turn/end` remains an invariant error. Remove only `session.finished`, leaving `session.event`, `subagent.started`, and `subagent.finished` unchanged. -2. In `packages/scaffold/protocol/src/transport.ts`, narrow the shared class to the directions with consumers — inbound requests/outbound responses (the server) and outbound requests/inbound responses plus inbound notifications (the TypeScript SDK client) — removing only server-originated `request()` use and client-originated notification dispatch, or split the class into a server-side and client-side transport. Request result, method-not-found, and handler-error responses retain their current behavior and remain ordered after notifications emitted by the awaited handler. +1. In `packages/sdk/server/src/server.ts`, replace `SessionPromptResult.accepted` with `status: 'ok' | 'error' | 'aborted'` and the captured `TurnEndReason`. `HarnessSdkServer.prompt()` will return `completed` as `ok`, `aborted` as `aborted`, and every other current or merge-extensible reason as `error`; reaching idle without a `turn/end` remains an invariant error. Remove only `session.finished`, leaving `session.event`, `subagent.started`, and `subagent.finished` unchanged. +2. In `packages/sdk/protocol/src/transport.ts`, narrow the shared class to the directions with consumers — inbound requests/outbound responses (the server) and outbound requests/inbound responses plus inbound notifications (the TypeScript SDK client) — removing only server-originated `request()` use and client-originated notification dispatch, or split the class into a server-side and client-side transport. Request result, method-not-found, and handler-error responses retain their current behavior and remain ordered after notifications emitted by the awaited handler. 3. In `python/sdk/src/deepseek_harness/client.py`, `models.py`, and `__init__.py`, remove `IncomingRequest`, `_requests`, `notify()`, `next_request()`, `respond()`, and `respond_error()`. Add a public validated `SessionPromptResponse` carrying status and reason, return it from `session_prompt()`, and keep an explicit reader guard that ignores unexpected server-request frames instead of allowing them to match a response waiter. 4. In `python/sdk/src/deepseek_harness/api.py`, build `TurnResult.status` and a new `TurnResult.reason` from `SessionPromptResponse`, then delete the `session.finished` branch and second completion loop. Keep the subscription open during the request and preserve `_request_raw()`'s final notification drain so the last `turn/end` event and any subagent notification written before the response are collected before `Session.run()` reconstructs the final assistant message. -5. Replace the symmetric transport-pair cases in `packages/scaffold/protocol/tests/transport.spec.ts` with per-direction coverage, and update `server.spec.ts`, `plugin-apply.spec.ts`, and `built-scope-carrier.e2e.ts` for direct outcomes, ordering, overlap, shutdown, and the narrowed fake; update the TypeScript SDK client (`packages/scaffold/client`) and its suites for response-based settlement. Update `python/sdk/tests/test_client.py` for response-based settlement, unexpected-request-frame handling, callback and concurrency behavior, and the removed public helpers. Update the JSON-RPC and bilingual Python SDK READMEs, export JSDoc and declarations, `scripts/smoke-python-runtime.py`, and the Python single-executable snapshot. +5. Replace the symmetric transport-pair cases in `packages/sdk/protocol/tests/transport.spec.ts` with per-direction coverage, and update `server.spec.ts`, `plugin-apply.spec.ts`, and `built-scope-carrier.e2e.ts` for direct outcomes, ordering, overlap, shutdown, and the narrowed fake; update the TypeScript SDK client (`packages/sdk/client`) and its suites for response-based settlement. Update `python/sdk/tests/test_client.py` for response-based settlement, unexpected-request-frame handling, callback and concurrency behavior, and the removed public helpers. Update the JSON-RPC and bilingual Python SDK READMEs, export JSDoc and declarations, `scripts/smoke-python-runtime.py`, and the Python single-executable snapshot. ## Alternatives considered diff --git a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md index 181aab8cd2..b728f2335f 100644 --- a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md +++ b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md @@ -20,11 +20,11 @@ JSON-RPC 桥接层把两个端点都建模为对称的对等端,但实际协 ## 实施计划 -1. 在 `packages/scaffold/server/src/server.ts` 中,用 `status: 'ok' | 'error' | 'aborted'` 和捕获的 `TurnEndReason` 替换 `SessionPromptResult.accepted`。`HarnessSdkServer.prompt()` 把 `completed` 映射为 `ok`,把 `aborted` 映射为 `aborted`,把其他当前已有或可通过声明合并扩展的原因映射为 `error`;进入空闲状态却没有 `turn/end` 仍视为不变量错误。只删除 `session.finished`,保持 `session.event`、`subagent.started` 和 `subagent.finished` 不变。 -2. 在 `packages/scaffold/protocol/src/transport.ts` 中,把共享类收窄到有消费者的方向——入站请求/出站响应(服务端)与出站请求/入站响应及入站通知(TypeScript SDK 客户端)——只删除服务端发起的 `request()` 用法与客户端发起的通知分发,或把该类拆分为服务端与客户端两个传输。请求结果、方法不存在与处理器错误响应保持原有行为,并继续排在被等待处理器发出的通知之后。 +1. 在 `packages/sdk/server/src/server.ts` 中,用 `status: 'ok' | 'error' | 'aborted'` 和捕获的 `TurnEndReason` 替换 `SessionPromptResult.accepted`。`HarnessSdkServer.prompt()` 把 `completed` 映射为 `ok`,把 `aborted` 映射为 `aborted`,把其他当前已有或可通过声明合并扩展的原因映射为 `error`;进入空闲状态却没有 `turn/end` 仍视为不变量错误。只删除 `session.finished`,保持 `session.event`、`subagent.started` 和 `subagent.finished` 不变。 +2. 在 `packages/sdk/protocol/src/transport.ts` 中,把共享类收窄到有消费者的方向——入站请求/出站响应(服务端)与出站请求/入站响应及入站通知(TypeScript SDK 客户端)——只删除服务端发起的 `request()` 用法与客户端发起的通知分发,或把该类拆分为服务端与客户端两个传输。请求结果、方法不存在与处理器错误响应保持原有行为,并继续排在被等待处理器发出的通知之后。 3. 在 `python/sdk/src/deepseek_harness/client.py`、`models.py` 和 `__init__.py` 中,删除 `IncomingRequest`、`_requests`、`notify()`、`next_request()`、`respond()` 和 `respond_error()`。新增公开且经过校验的 `SessionPromptResponse` 来携带状态与原因,由 `session_prompt()` 返回该对象,并保留明确的读取保护:忽略意外的服务端请求帧,避免它们命中响应等待器。 4. 在 `python/sdk/src/deepseek_harness/api.py` 中,根据 `SessionPromptResponse` 构造 `TurnResult.status` 和新增的 `TurnResult.reason`,再删除 `session.finished` 分支与第二个完成循环。请求期间保持订阅打开,并保留 `_request_raw()` 最后的通知排空步骤,确保写在响应前的最后一条 `turn/end` 事件与任何 subagent 通知,都会在 `Session.run()` 重建最终助手消息之前被收集。 -5. 用按方向的覆盖替换 `packages/scaffold/protocol/tests/transport.spec.ts` 中的对称传输对用例,并更新 `server.spec.ts`、`plugin-apply.spec.ts` 和 `built-scope-carrier.e2e.ts`,覆盖直接结果、顺序、重叠、关闭和收窄后的伪实现;同步更新 TypeScript SDK 客户端(`packages/scaffold/client`)及其套件以采用基于响应的结束流程。更新 `python/sdk/tests/test_client.py`,覆盖基于响应的结束流程、意外请求帧处理、回调与并发行为,以及已删除的公开辅助方法。同步更新 JSON-RPC README、双语 Python SDK README、导出 JSDoc 与声明、`scripts/smoke-python-runtime.py` 和 Python 单可执行文件快照。 +5. 用按方向的覆盖替换 `packages/sdk/protocol/tests/transport.spec.ts` 中的对称传输对用例,并更新 `server.spec.ts`、`plugin-apply.spec.ts` 和 `built-scope-carrier.e2e.ts`,覆盖直接结果、顺序、重叠、关闭和收窄后的伪实现;同步更新 TypeScript SDK 客户端(`packages/sdk/client`)及其套件以采用基于响应的结束流程。更新 `python/sdk/tests/test_client.py`,覆盖基于响应的结束流程、意外请求帧处理、回调与并发行为,以及已删除的公开辅助方法。同步更新 JSON-RPC README、双语 Python SDK README、导出 JSDoc 与声明、`scripts/smoke-python-runtime.py` 和 Python 单可执行文件快照。 ## 备选方案 diff --git a/AGENTS.md b/AGENTS.md index 2235cc1a1a..1c21055d58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// acp/ automation-only Agent Client Protocol server interaction/ approval/interaction capabilities, permission, commands, ask-user boot/ shared app-bin glue - scaffold/ project tooling: helper, launcher, initializer, SDK protocol + sdk/ JSON-RPC protocol, server, and TypeScript client examples/ demo bundles (agent-spine + CLI/ACP/JSON-RPC bins) support/ dev/test infrastructure util/ zero-dependency utilities diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 0ecae4b668..c2502f8e73 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -35,8 +35,6 @@ External packages that a workspace package resolves at runtime. The tier covers | [`@anthropic-ai/claude-agent-sdk`](https://github.com/anthropics/claude-agent-sdk-typescript) | SEE LICENSE IN README.md | | [`@anthropic-ai/sdk`](https://github.com/anthropics/anthropic-sdk-typescript) | MIT | | [`@babel/code-frame`](https://github.com/babel/babel) | MIT | -| [`@clack/core`](https://github.com/bombshell-dev/clack) | MIT | -| [`@clack/prompts`](https://github.com/bombshell-dev/clack) | MIT | | [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT | | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | | [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | @@ -59,10 +57,8 @@ External packages that a workspace package resolves at runtime. The tier covers | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | | [`e2b`](https://github.com/e2b-dev/e2b) | MIT | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | -| [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | -| [`jsonc-parser`](https://github.com/microsoft/node-jsonc-parser) | MIT | | [`katex`](https://github.com/KaTeX/KaTeX) | MIT | | [`koffi`](https://github.com/Koromix/koffi) | MIT | | [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index a20a76a7ef..f65a2caaa9 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 6188fec1cfe65369e59c10b851629122c393af1e -config-catalog.zh.md: 1aefcaff17d272d4767a22a5ba72a7ab6dc0b914 +config-catalog.md: da5df06f0836fd82c38f07866130b0628c18128f +config-catalog.zh.md: a0c5e3e184a5d28e59db5266e871c8a5b575dae9 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6188fec1cf..da5df06f08 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -724,7 +724,7 @@ export interface JsonRpcConfig { Depends on: `Readable` (`node:stream`) · `Writable` (`node:stream`) -Source: [`packages/scaffold/server/src/index.ts:29`](../packages/scaffold/server/src/index.ts) +Source: [`packages/sdk/server/src/index.ts:29`](../packages/sdk/server/src/index.ts) ## `@deepseek-ai/dsh-llm-deepseek` @@ -2807,7 +2807,6 @@ Abstract service classes — a deployment loads a concrete implementation packag Imported as libraries by other packages; a `cordis.yml` cannot load them. -- `@deepseek-ai/create-sdk` ([`packages/scaffold/create-sdk/src/index.ts`](../packages/scaffold/create-sdk/src/index.ts)) - `@deepseek-ai/dsh-acp-snapshot` ([`packages/support/acp-snapshot/src/index.ts`](../packages/support/acp-snapshot/src/index.ts)) - `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts)) - `@deepseek-ai/dsh-app-boot` ([`packages/boot/app-boot/src/index.ts`](../packages/boot/app-boot/src/index.ts)) @@ -2822,7 +2821,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) - `@deepseek-ai/dsh-cmdline` ([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts)) - `@deepseek-ai/dsh-environment` ([`packages/util/environment/src/index.ts`](../packages/util/environment/src/index.ts)) -- `@deepseek-ai/dsh-helper` ([`packages/scaffold/helper/src/index.ts`](../packages/scaffold/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) - `@deepseek-ai/dsh-llm-mock-server` ([`packages/support/llm-mock-server/src/index.ts`](../packages/support/llm-mock-server/src/index.ts)) @@ -2832,13 +2830,11 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts)) - `@deepseek-ai/dsh-sandbox-windows-acl` ([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) -- `@deepseek-ai/dsh-scripts` ([`packages/scaffold/scripts/src/index.ts`](../packages/scaffold/scripts/src/index.ts)) -- `@deepseek-ai/dsh-sdk-client` ([`packages/scaffold/client/src/index.ts`](../packages/scaffold/client/src/index.ts)) -- `@deepseek-ai/dsh-sdk-protocol` ([`packages/scaffold/protocol/src/index.ts`](../packages/scaffold/protocol/src/index.ts)) +- `@deepseek-ai/dsh-sdk-client` ([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts)) +- `@deepseek-ai/dsh-sdk-protocol` ([`packages/sdk/protocol/src/index.ts`](../packages/sdk/protocol/src/index.ts)) - `@deepseek-ai/dsh-session-telemetry` ([`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm` ([`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) -- `@deepseek-ai/dsh-telemetry` ([`packages/scaffold/telemetry/src/index.ts`](../packages/scaffold/telemetry/src/index.ts)) - `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts)) - `@deepseek-ai/dsh-type-meta` ([`packages/typert/type-meta/src/index.ts`](../packages/typert/type-meta/src/index.ts)) - `@deepseek-ai/dsh-typert-generator` ([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 1aefcaff17..a0c5e3e184 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -726,7 +726,7 @@ export interface JsonRpcConfig { 依赖:`Readable`(`node:stream`)· `Writable`(`node:stream`) -来源:[`packages/scaffold/server/src/index.ts:29`](../packages/scaffold/server/src/index.ts) +来源:[`packages/sdk/server/src/index.ts:29`](../packages/sdk/server/src/index.ts) ## `@deepseek-ai/dsh-llm-deepseek` @@ -2807,7 +2807,6 @@ export interface Config { 由其他包作为库导入;`cordis.yml` 无法加载它们。 -- `@deepseek-ai/create-sdk`([`packages/scaffold/create-sdk/src/index.ts`](../packages/scaffold/create-sdk/src/index.ts)) - `@deepseek-ai/dsh-acp-snapshot`([`packages/support/acp-snapshot/src/index.ts`](../packages/support/acp-snapshot/src/index.ts)) - `@deepseek-ai/dsh-agent-loop-testkit`([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts)) - `@deepseek-ai/dsh-app-boot`([`packages/boot/app-boot/src/index.ts`](../packages/boot/app-boot/src/index.ts)) @@ -2822,7 +2821,6 @@ export interface Config { - `@deepseek-ai/dsh-client-web-react`([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) - `@deepseek-ai/dsh-cmdline`([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts)) - `@deepseek-ai/dsh-environment`([`packages/util/environment/src/index.ts`](../packages/util/environment/src/index.ts)) -- `@deepseek-ai/dsh-helper`([`packages/scaffold/helper/src/index.ts`](../packages/scaffold/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol`([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-demo`([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) - `@deepseek-ai/dsh-llm-mock-server`([`packages/support/llm-mock-server/src/index.ts`](../packages/support/llm-mock-server/src/index.ts)) @@ -2832,13 +2830,11 @@ export interface Config { - `@deepseek-ai/dsh-retention`([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts)) - `@deepseek-ai/dsh-sandbox-windows-acl`([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts)) - `@deepseek-ai/dsh-scope`([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) -- `@deepseek-ai/dsh-scripts`([`packages/scaffold/scripts/src/index.ts`](../packages/scaffold/scripts/src/index.ts)) -- `@deepseek-ai/dsh-sdk-client`([`packages/scaffold/client/src/index.ts`](../packages/scaffold/client/src/index.ts)) -- `@deepseek-ai/dsh-sdk-protocol`([`packages/scaffold/protocol/src/index.ts`](../packages/scaffold/protocol/src/index.ts)) +- `@deepseek-ai/dsh-sdk-client`([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts)) +- `@deepseek-ai/dsh-sdk-protocol`([`packages/sdk/protocol/src/index.ts`](../packages/sdk/protocol/src/index.ts)) - `@deepseek-ai/dsh-session-telemetry`([`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm`([`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess`([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) -- `@deepseek-ai/dsh-telemetry`([`packages/scaffold/telemetry/src/index.ts`](../packages/scaffold/telemetry/src/index.ts)) - `@deepseek-ai/dsh-timeout`([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts)) - `@deepseek-ai/dsh-type-meta`([`packages/typert/type-meta/src/index.ts`](../packages/typert/type-meta/src/index.ts)) - `@deepseek-ai/dsh-typert-generator`([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 3f5820ec3e..2df3ae822d 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: 859a449f63bd36177a9c666ac9d894cb495b224f -module-graph.zh.md: cedf087810cf309200368b43c6d53757426c58cd +module-graph.md: ceabbbb7ff94e014f515f5de2a21588e18aa24d3 +module-graph.zh.md: 64ef16ed986d583842a44d9ce2b82f6677b4eaba diff --git a/docs/module-graph.md b/docs/module-graph.md index 859a449f63..ceabbbb7ff 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -247,13 +247,10 @@ flowchart TD pkg_sandbox_policy["sandbox-policy"] pkg_sandbox_windows_acl["sandbox-windows-acl"] end - subgraph group_scaffold["packages/scaffold"] - pkg_helper["helper"] + subgraph group_sdk["packages/sdk"] pkg_jsonrpc["jsonrpc"] - pkg_scripts["scripts"] pkg_sdk_client["sdk-client"] pkg_sdk_protocol["sdk-protocol"] - pkg_telemetry["telemetry"] end subgraph group_self_modification["packages/self-modification"] pkg_tool_cordis["tool-cordis"] @@ -353,12 +350,6 @@ flowchart TD pkg_subprocess_e2b --> pkg_timeout pkg_frontend_static --> pkg_host_webserver pkg_frontend_static --> pkg_invariants - pkg_helper --> pkg_brand - pkg_helper --> pkg_invariants - pkg_helper --> pkg_subprocess - pkg_telemetry --> pkg_brand - pkg_telemetry --> pkg_invariants - pkg_telemetry --> pkg_paths pkg_user_id --> pkg_brand pkg_user_id --> pkg_invariants pkg_user_id --> pkg_paths @@ -612,8 +603,6 @@ flowchart TD pkg_sandbox_policy --> pkg_sandbox pkg_sandbox_policy --> pkg_session pkg_sandbox_policy --> pkg_system_prompt - pkg_scripts --> pkg_app_boot - pkg_scripts --> pkg_invariants pkg_session_persistence_jsonl --> pkg_invariants pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence @@ -1301,8 +1290,6 @@ flowchart TD | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | -| [`helper`](../packages/scaffold/helper) | `scaffold` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | -| [`telemetry`](../packages/scaffold/telemetry) | `scaffold` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`user-id`](../packages/session/user-id) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -1366,7 +1353,6 @@ flowchart TD | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`scripts`](../packages/scaffold/scripts) | `scaffold` | [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | @@ -1450,11 +1436,11 @@ flowchart TD | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | -| [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | @@ -1466,9 +1452,9 @@ flowchart TD | [`client-ui-task`](../packages/client/ui-task) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`jsonrpc`](../packages/scaffold/server) | `scaffold` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`sdk-client`](../packages/scaffold/client) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session) | -| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/scaffold/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`jsonrpc`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | +| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index cedf087810..64ef16ed98 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -249,13 +249,10 @@ flowchart TD pkg_sandbox_policy["sandbox-policy"] pkg_sandbox_windows_acl["sandbox-windows-acl"] end - subgraph group_scaffold["packages/scaffold"] - pkg_helper["helper"] + subgraph group_sdk["packages/sdk"] pkg_jsonrpc["jsonrpc"] - pkg_scripts["scripts"] pkg_sdk_client["sdk-client"] pkg_sdk_protocol["sdk-protocol"] - pkg_telemetry["telemetry"] end subgraph group_self_modification["packages/self-modification"] pkg_tool_cordis["tool-cordis"] @@ -355,12 +352,6 @@ flowchart TD pkg_subprocess_e2b --> pkg_timeout pkg_frontend_static --> pkg_host_webserver pkg_frontend_static --> pkg_invariants - pkg_helper --> pkg_brand - pkg_helper --> pkg_invariants - pkg_helper --> pkg_subprocess - pkg_telemetry --> pkg_brand - pkg_telemetry --> pkg_invariants - pkg_telemetry --> pkg_paths pkg_user_id --> pkg_brand pkg_user_id --> pkg_invariants pkg_user_id --> pkg_paths @@ -614,8 +605,6 @@ flowchart TD pkg_sandbox_policy --> pkg_sandbox pkg_sandbox_policy --> pkg_session pkg_sandbox_policy --> pkg_system_prompt - pkg_scripts --> pkg_app_boot - pkg_scripts --> pkg_invariants pkg_session_persistence_jsonl --> pkg_invariants pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence @@ -1303,8 +1292,6 @@ flowchart TD | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | -| [`helper`](../packages/scaffold/helper) | `scaffold` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | -| [`telemetry`](../packages/scaffold/telemetry) | `scaffold` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`user-id`](../packages/session/user-id) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -1368,7 +1355,6 @@ flowchart TD | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`scripts`](../packages/scaffold/scripts) | `scaffold` | [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | @@ -1452,11 +1438,11 @@ flowchart TD | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | -| [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | @@ -1468,9 +1454,9 @@ flowchart TD | [`client-ui-task`](../packages/client/ui-task) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`jsonrpc`](../packages/scaffold/server) | `scaffold` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`sdk-client`](../packages/scaffold/client) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session) | -| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/scaffold/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`jsonrpc`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | +| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index c3743e38c0..8ff27e971f 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.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 docs/testing.md -testing.md: f330bb1e02f3613c63f3989a8f9128f737bf5c52 -testing.zh.md: db6facb4fa4bf07eda0a6ee7e558c8c60d4c331e +testing.md: e66be9a6325c9d464a3999bd587f8b6c61c651c9 +testing.zh.md: 9ebcd12a4660fe660fd570d3adf978d968f1f320 diff --git a/docs/testing.md b/docs/testing.md index f330bb1e02..e66be9a632 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -32,7 +32,7 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword - Product-visible plugins require a non-unit REAL-composition test. Hand-built `ctx.plugin(...)` suites are insufficient: boot test-only `cordis.yml` through Loader and app/process, mock only external services or nondeterministic inputs, and assert model-visible request/log, durable state, or user-visible output. Keep opt-ins out of shipped defaults. - A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green when a default export replaces the required named exports — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. -- "Real entry path" means the published artifact: a package `bin` runs built `lib/bin.js` under plain `node`, exposing failures tsx masks (settle races, module resolution, swallowed load failures). The same applies to non-index runtime entries (the worker-thread sibling `lib/worker.cjs`) and singleton modules shared across bundles (`packages/scaffold/server/tests/built-scope-carrier.e2e.ts`). Keep the built-artifact smokes green (`packages/examples/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. +- "Real entry path" means the published artifact: a package `bin` runs built `lib/bin.js` under plain `node`, exposing failures tsx masks (settle races, module resolution, swallowed load failures). The same applies to non-index runtime entries (the worker-thread sibling `lib/worker.cjs`) and singleton modules shared across bundles (`packages/sdk/server/tests/built-scope-carrier.e2e.ts`). Keep the built-artifact smokes green (`packages/examples/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. ## Test resolution: source plane only diff --git a/docs/testing.zh.md b/docs/testing.zh.md index db6facb4fa..9ebcd12a46 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -32,7 +32,7 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身 - 产品可见的插件必须有一个非单元的真实组合测试。手动构建的 `ctx.plugin(...)` 套件不够:通过 Loader 和 app/process 启动仅用于测试的 `cordis.yml`,只 mock 外部服务或非确定性输入,断言模型可见的请求/日志、持久状态或用户可见输出。不要把 opt-in 选项混入交付默认值。 - 一个守卫只有在回归真的能让它失败时才有效。对于没有 `inject` 的插件(bundle/组合插件),Loader 冒烟测试在默认导出替换必需的具名导出时仍然绿着——需要添加显式的 `expect('default' in mod).toBe(false)` 加 `unwrapExports` 往返断言,并证明它有效:引入回归、观察变红、回退。 -- 「真实入口路径」指已发布的产物:包的 `bin` 所运行的是构建后的 `lib/bin.js`,并由普通 `node` 执行,从而暴露 tsx 会掩盖的失败(等待稳定时的竞态、模块解析、被吞掉的加载失败)。同样的规则适用于非 index 运行时入口(worker-thread 的同级文件 `lib/worker.cjs`),也适用于多个 bundle 共享的单例模块(`packages/scaffold/server/tests/built-scope-carrier.e2e.ts`)。保持构建产物冒烟测试绿色(`packages/examples/*/tests/built-bin.e2e.ts`、`packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`),并断言真正缺失的配置以非零状态退出。 +- 「真实入口路径」指已发布的产物:包的 `bin` 所运行的是构建后的 `lib/bin.js`,并由普通 `node` 执行,从而暴露 tsx 会掩盖的失败(等待稳定时的竞态、模块解析、被吞掉的加载失败)。同样的规则适用于非 index 运行时入口(worker-thread 的同级文件 `lib/worker.cjs`),也适用于多个 bundle 共享的单例模块(`packages/sdk/server/tests/built-scope-carrier.e2e.ts`)。保持构建产物冒烟测试绿色(`packages/examples/*/tests/built-bin.e2e.ts`、`packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`),并断言真正缺失的配置以非零状态退出。 ## 测试解析:仅限源码 diff --git a/knip.json b/knip.json index 2f755dbb5f..e418c8ce3d 100644 --- a/knip.json +++ b/knip.json @@ -527,7 +527,7 @@ "tests/**/*.ts" ] }, - "packages/scaffold/server": { + "packages/sdk/server": { "entry": [ "tests/**/*.spec.ts", "tests/**/*.e2e.ts" @@ -551,32 +551,6 @@ "src/**/*.ts" ] }, - "packages/scaffold/create-sdk": { - "entry": [ - "src/bin.ts", - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts", - "tests/**/*.snapshot.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/scaffold/scripts": { - "entry": [ - "src/bin.ts", - "tests/**/*.spec.ts", - "tests/**/*.snapshot.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ], - "ignoreDependencies": [ - "node-addon-require-builtin" - ] - }, "packages/subagent/subagent-spawn": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 5f2d8b0585..883a2e14ff 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.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 packages/README.md -README.md: eb7df95bde10dafd7afcb168d30c9dda90296687 -README.zh.md: 03cd02510267d3abdd414bed6ec1f42773d0811a +README.md: a0960bdd6f943659181c865ebb6a49940507571f +README.zh.md: 013806e802f524b34757bb2de073625eb8b0f768 diff --git a/packages/README.md b/packages/README.md index eb7df95bde..a0960bdd6f 100644 --- a/packages/README.md +++ b/packages/README.md @@ -46,7 +46,7 @@ Groups hold `packages///`; names stay `@deepseek-ai/dsh-`. **Gr | [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable surface | | [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface | | [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface | -| [`scaffold/`](scaffold/README.md) | Create/launch/drive project tooling: helper, launcher, initializer, wire protocol with both ends, launcher telemetry | Product — stable surface | +| [`sdk/`](sdk/README.md) | Out-of-process runtime SDK: JSON-RPC protocol, TypeScript client, and server plugin | Product — stable surface | | [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface | | [`interaction/`](interaction/README.md) | Human-collaboration plane: approval/interaction seams, permission preset, commands, ask-user tool | Product — stable surface | | [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 03cd025102..013806e802 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -46,7 +46,7 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通 | [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境叠加 `.env` 提供方 | 产品:稳定接口 | | [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定接口 | | [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定接口 | -| [`scaffold/`](scaffold/README.md) | 创建/启动/驱动项目的工具:helper、启动器、初始化器、带两端的通信协议、启动器 telemetry | 产品:稳定接口 | +| [`sdk/`](sdk/README.md) | 进程外运行时 SDK:JSON-RPC 协议、TypeScript 客户端和服务器插件 | 产品:稳定接口 | | [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定接口 | | [`interaction/`](interaction/README.md) | 人机协作平面:批准/交互 seam、权限预设、命令、用户问答工具 | 产品:稳定接口 | | [`boot/`](boot/README.md) | 共享的 app bin 启动粘合层 | 产品:稳定接口 | diff --git a/packages/boot/README.i18n.yaml b/packages/boot/README.i18n.yaml index 0de587115e..5dd74d1588 100644 --- a/packages/boot/README.i18n.yaml +++ b/packages/boot/README.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 packages/boot/README.md -README.md: 79d653260ea4a9d9a4c71a593b41a6a7e17efa14 -README.zh.md: 839be164328ef168cd6ac18bf2f1dcb930dfce3e +README.md: cdf551729567a7ad4be9dbd99861db4ad57cd5d7 +README.zh.md: f775e9aac291ce176516c20eca773d6520050e23 diff --git a/packages/boot/README.md b/packages/boot/README.md index 79d653260e..cdf5517295 100644 --- a/packages/boot/README.md +++ b/packages/boot/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The channel-neutral boot library the app bins share: `apps/cli`, the [`scaffold/`](../scaffold/README.md) launcher, and the [`examples/`](../examples/README.md) demo bins all consume it. +The channel-neutral boot library shared by `apps/cli` and the [`examples/`](../examples/README.md) demo bins. | Package | Role | ctx key | |---|---|---| diff --git a/packages/boot/README.zh.md b/packages/boot/README.zh.md index 839be16432..f775e9aac2 100644 --- a/packages/boot/README.zh.md +++ b/packages/boot/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -各 app bin 共享的、与渠道无关的启动库:`apps/cli`、[`scaffold/`](../scaffold/README.md) 启动器与 [`examples/`](../examples/README.md) demo bin 都消费它。 +由 `apps/cli` 和 [`examples/`](../examples/README.md) demo bin 共享、与渠道无关的启动库。 | 包 | 职责 | ctx 键 | |---|---|---| diff --git a/packages/examples/jsonrpc-demo/README.i18n.yaml b/packages/examples/jsonrpc-demo/README.i18n.yaml index ae8f55a8cd..58b21c6929 100644 --- a/packages/examples/jsonrpc-demo/README.i18n.yaml +++ b/packages/examples/jsonrpc-demo/README.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 packages/examples/jsonrpc-demo/README.md -README.md: 40ced3ee1fe2d3eac69b82501d417130267d7634 -README.zh.md: 451bdf7428f8265750082af240418d4653bb8595 +README.md: b2035b4caf2cadcbd5e93b27ac4aad2bdadcdb4d +README.zh.md: 40867d03c31cb44ed7b2cc20643b3838de294b92 diff --git a/packages/examples/jsonrpc-demo/README.md b/packages/examples/jsonrpc-demo/README.md index 40ced3ee1f..b2035b4caf 100644 --- a/packages/examples/jsonrpc-demo/README.md +++ b/packages/examples/jsonrpc-demo/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../scaffold/server/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. The published `dsh-jsonrpc-agent` bin resolves bare plugins from the configuration project. The Python SDK's `dsh-jsonrpc-agent-pkg` [single-executable runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) uses `lib/packaged-bin.js` instead: packaged bare plugins resolve from its closed runtime tree, while relative plugins remain configuration-relative. +Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../sdk/server/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. The published `dsh-jsonrpc-agent` bin resolves bare plugins from the configuration project. The Python SDK's `dsh-jsonrpc-agent-pkg` [single-executable runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) uses `lib/packaged-bin.js` instead: packaged bare plugins resolve from its closed runtime tree, while relative plugins remain configuration-relative. ## Config discovery diff --git a/packages/examples/jsonrpc-demo/README.zh.md b/packages/examples/jsonrpc-demo/README.zh.md index 451bdf7428..40867d03c3 100644 --- a/packages/examples/jsonrpc-demo/README.zh.md +++ b/packages/examples/jsonrpc-demo/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -只包含 bin 的应用,启动外部 `cordis.yml`;其 [`jsonrpc`](../../scaffold/server/README.md) 入口通过按换行分隔的 stdio 为 SDK 客户端提供服务。配置负责组合主干、后端和服务插件。发布的 `dsh-jsonrpc-agent` bin 从配置项目解析裸插件。Python SDK 的 `dsh-jsonrpc-agent-pkg` [单文件可执行运行时](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)改用 `lib/packaged-bin.js`:已打包的裸插件从封闭运行时包树解析,相对插件仍以配置目录为基准。 +只包含 bin 的应用,启动外部 `cordis.yml`;其 [`jsonrpc`](../../sdk/server/README.md) 入口通过按换行分隔的 stdio 为 SDK 客户端提供服务。配置负责组合主干、后端和服务插件。发布的 `dsh-jsonrpc-agent` bin 从配置项目解析裸插件。Python SDK 的 `dsh-jsonrpc-agent-pkg` [单文件可执行运行时](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)改用 `lib/packaged-bin.js`:已打包的裸插件从封闭运行时包树解析,相对插件仍以配置目录为基准。 ## 配置发现 diff --git a/packages/interaction/README.i18n.yaml b/packages/interaction/README.i18n.yaml index d8b76378eb..c865c1f7dd 100644 --- a/packages/interaction/README.i18n.yaml +++ b/packages/interaction/README.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 packages/interaction/README.md -README.md: 8fe77ae845390359e2c1e3302400c08828a186a1 -README.zh.md: 68e13be7c55e64305bbe0b87ede8d37e55a7cd36 +README.md: 4640bf015723d03257eae3a360201c8cbe122e63 +README.zh.md: c7b9cd8f30ba12cf7c7ef3affc24e4ca72010b25 diff --git a/packages/interaction/README.md b/packages/interaction/README.md index 8fe77ae845..4640bf0157 100644 --- a/packages/interaction/README.md +++ b/packages/interaction/README.md @@ -14,4 +14,4 @@ The services and plugins through which a human collaborates with a running agent These packages integrate through existing agent and session contracts rather than changing the loop. Interactive applications provide the concrete command, approval, and question adapters; automation uses [`acp/`](../acp/README.md), and runnable demo bundles live under [`examples/`](../examples/README.md). The product [`dsh`](../../apps/cli/README.md) CLI composes these packages directly. -The subsystem references: [approval.md](../../docs/subsystems/approval.md), [permission.md](../../docs/subsystems/permission.md), [user-interaction.md](../../docs/subsystems/user-interaction.md), and [commands.md](../../docs/subsystems/commands.md). The automation-only ACP transport is [`acp/`](../acp/README.md), the SDK's JSON-RPC server half [`scaffold/server`](../scaffold/README.md), and the shared bin boot glue [`boot/`](../boot/README.md). +The subsystem references: [approval.md](../../docs/subsystems/approval.md), [permission.md](../../docs/subsystems/permission.md), [user-interaction.md](../../docs/subsystems/user-interaction.md), and [commands.md](../../docs/subsystems/commands.md). The automation-only ACP transport is [`acp/`](../acp/README.md), the SDK's JSON-RPC server half is [`sdk/server`](../sdk/README.md), and the shared bin boot glue is [`boot/`](../boot/README.md). diff --git a/packages/interaction/README.zh.md b/packages/interaction/README.zh.md index 68e13be7c5..c7b9cd8f30 100644 --- a/packages/interaction/README.zh.md +++ b/packages/interaction/README.zh.md @@ -14,4 +14,4 @@ 这些包通过现有的 agent(智能体)和会话约定集成,而不改变循环。交互式应用提供具体的命令、审批和提问适配器;自动化使用 [`acp/`](../acp/README.md),可运行的演示组合包位于 [`examples/`](../examples/README.md)。产品 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)直接组合这些包。 -子系统参考:[approval.md](../../docs/subsystems/approval.md)、[permission.md](../../docs/subsystems/permission.md)、[user-interaction.md](../../docs/subsystems/user-interaction.md)与 [commands.md](../../docs/subsystems/commands.md)。仅自动化的 ACP 传输在 [`acp/`](../acp/README.md),SDK 的 JSON-RPC 服务器一半在 [`scaffold/server`](../scaffold/README.md),共享 bin 启动胶水在 [`boot/`](../boot/README.md)。 +子系统参考:[approval.md](../../docs/subsystems/approval.md)、[permission.md](../../docs/subsystems/permission.md)、[user-interaction.md](../../docs/subsystems/user-interaction.md)与 [commands.md](../../docs/subsystems/commands.md)。仅自动化的 ACP 传输是 [`acp/`](../acp/README.md),SDK 的 JSON-RPC 服务器一半是 [`sdk/server`](../sdk/README.md),共享 bin 启动胶水是 [`boot/`](../boot/README.md)。 diff --git a/packages/scaffold/README.md b/packages/scaffold/README.md deleted file mode 100644 index f36e828a50..0000000000 --- a/packages/scaffold/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# scaffold/ — create, launch, and drive projects from outside - -English | [中文](README.zh.md) - -This group contains developer tooling for Harness projects and the client stack for driving a Harness runtime from another process. Folders are role-named; npm names converge on `dsh-sdk-*` through the FIXME-tracked renames in the [regrouping Agent Note](../../.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md). - -| Package | Role | -|---|---| -| [`helper/`](helper/README.md) | Provides the shared project-editing domain | -| [`scripts/`](scripts/README.md) | Provides the `dsh-sdk` project commands | -| [`create-sdk/`](create-sdk/README.md) | Creates new SDK projects | -| [`protocol/`](protocol/README.md) | Defines the SDK runtime wire protocol | -| [`client/`](client/README.md) | Drives a Harness runtime through the TypeScript client API | -| [`server/`](server/README.md) | Serves out-of-process SDK clients over stdio JSON-RPC | -| [`telemetry/`](telemetry/README.md) | Provides launcher telemetry, consent, and redaction primitives | - -`@deepseek-ai/create-sdk` follows npm's scoped initializer naming convention; the other packages follow the repository's `@deepseek-ai/dsh-*` convention. See the [developer-project workflow](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md), [project-editing architecture](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md), and [TypeScript SDK design](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md). diff --git a/packages/scaffold/README.zh.md b/packages/scaffold/README.zh.md deleted file mode 100644 index f58d8c8def..0000000000 --- a/packages/scaffold/README.zh.md +++ /dev/null @@ -1,17 +0,0 @@ -# scaffold/:从外部创建、启动、驱动项目 - -[English](README.md) | 中文 - -本组包含 Harness 项目的开发者工具,以及从另一进程驱动 Harness 运行时的客户端栈。目录按角色命名;npm 名则经由[重新分组 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md)中 FIXME 跟踪的改名收敛为 `dsh-sdk-*`。 - -| 包 | 职责 | -|---|---| -| [`helper/`](helper/README.md) | 提供共享的项目编辑领域 | -| [`scripts/`](scripts/README.md) | 提供 `dsh-sdk` 项目命令 | -| [`create-sdk/`](create-sdk/README.md) | 创建新的 SDK 项目 | -| [`protocol/`](protocol/README.md) | 定义 SDK 运行时通信协议 | -| [`client/`](client/README.md) | 通过 TypeScript 客户端 API 驱动 Harness 运行时 | -| [`server/`](server/README.md) | 通过 stdio JSON-RPC 为进程外 SDK 客户端提供服务 | -| [`telemetry/`](telemetry/README.md) | 提供启动器 telemetry、同意与脱敏原语 | - -`@deepseek-ai/create-sdk` 遵循 npm 的 scoped initializer 命名约定;其余包遵循仓库的 `@deepseek-ai/dsh-*` 约定。参见[开发者项目工作流](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md)、[项目编辑架构](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md)与 [TypeScript SDK 设计](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md)。 diff --git a/packages/scaffold/create-sdk/README.i18n.yaml b/packages/scaffold/create-sdk/README.i18n.yaml deleted file mode 100644 index 62d5bd3793..0000000000 --- a/packages/scaffold/create-sdk/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 packages/scaffold/create-sdk/README.md -README.md: aa09236832a50abdcd2b158e0561db26bce19cf6 -README.zh.md: a4685e66bc3eb7924dd247a7b6b420f6bcc70990 diff --git a/packages/scaffold/create-sdk/README.md b/packages/scaffold/create-sdk/README.md deleted file mode 100644 index aa09236832..0000000000 --- a/packages/scaffold/create-sdk/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# `@deepseek-ai/create-sdk` - -English | [中文](README.zh.md) - -Interactive initializer for `npm create @deepseek-ai/sdk [directory]`. Directory/name/description have visible editable defaults. A tree picker selects features and configures finite options with Right/Left navigation; secret text follows only for selected options. Local plugin creation is one none/plugin/tool choice. - -The supported package surface is the `create-sdk` bin. The package root exports no symbols, and workflow, bin, source, and package-manifest subpaths are not exported. - -The initializer rejects every existing target path, creates one `SdkProject` edit session, validates and commits it, then asks whether to install NPM dependencies and build. Install or build failures keep the generated project and print a retry command. - -Public flags are `[directory]`, `--description`, `--provider`, `--base-url`, `--api-key`, `--model`, `--interface`, `--pm`, `--install`/`--no-install`, plus the headless flags `--config ` / `--config-json ` and `--json`. Interactive flags prefill matching questions; a headless spec (`--config`/`--config-json`) supplies every answer and its feature plan up front, so creation runs without a TTY and drives through a `HeadlessPromptPort` that fails loud on any missing required answer. `--json` emits NDJSON lifecycle events (`done` / `action-required` / `error`) so an agent can fill the named missing input and re-run. - -The provider choice is DeepSeek or a custom endpoint backed by `llm-pi-ai`. DeepSeek asks only for an API key and uses the public endpoint plus `deepseek-v4-flash`; custom also asks for a base URL. An empty key requires confirmation and creates a commented empty `.env` variable so provider startup fails clearly until it is filled. Existing plugin defaults are omitted; required SDK presets remain typed against the owning package's Config. - -## Model Experience - -Indirectly, through the generated project composition and its selected runtime plugins; the headless `--config-json` + `--json` surface additionally lets an agent create a project end to end and react to `action-required` events. - -#### KV Cache effect - -No direct invalidation; the named consumer owns any request-prefix changes. - -## Known Limitations and Deferred Work - -- **Headless local plugins** — the headless spec supplies project answers and the feature plan; scaffolding a local plugin (the interactive none/plugin/tool choice) is not yet expressible in the spec and defaults to none. diff --git a/packages/scaffold/create-sdk/README.zh.md b/packages/scaffold/create-sdk/README.zh.md deleted file mode 100644 index a4685e66bc..0000000000 --- a/packages/scaffold/create-sdk/README.zh.md +++ /dev/null @@ -1,25 +0,0 @@ -# `@deepseek-ai/create-sdk` - -[English](README.md) | 中文 - -用于 `npm create @deepseek-ai/sdk [directory]` 的交互式初始化器。目录/名称/描述都提供可见且可编辑的默认值。树形选择器用于选择功能,并通过 Right/Left 导航配置取值有限的选项;只有选中相应选项后才会询问密钥文本。本地插件创建提供 none/plugin/tool 三选一。 - -受支持的包接口是 `create-sdk` bin。包根不导出任何符号,也不导出 workflow、bin、source 或 package-manifest 子路径。 - -初始化器拒绝任何已经存在的目标路径,创建一个 `SdkProject` 编辑会话,验证并提交该会话,然后询问是否安装 NPM 依赖并构建。安装或构建失败时会保留生成的项目,并打印重试命令。 - -公开标志包括 `[directory]`、`--description`、`--provider`、`--base-url`、`--api-key`、`--model`、`--interface`、`--pm`、`--install`/`--no-install`,以及无头模式标志 `--config `/`--config-json ` 和 `--json`。交互式标志会预填对应问题;无头 spec(`--config`/`--config-json`)会预先提供所有答案和功能方案,因此创建过程无需 TTY,并通过 `HeadlessPromptPort` 驱动;若缺少任何必填答案,该端口会明确失败。`--json` 会发送 NDJSON 生命周期事件(`done`/`action-required`/`error`),使 agent(智能体)能够补充其中点名的缺失输入并重新运行。 - -提供方可以选择 DeepSeek,也可以选择由 `llm-pi-ai` 支持的自定义端点。选择 DeepSeek 时只询问 API key,并使用公共端点与 `deepseek-v4-flash`;自定义端点还会询问 base URL。密钥为空时必须确认;系统会在 `.env` 中创建一个被注释掉的空变量,从而使提供方在为该变量填入值之前启动时明确失败。现有插件的默认值会被省略;必填 SDK 预设仍按所属包的 Config 保持类型约束。 - -## 模型体验 - -通过生成的项目组合及其所选运行时插件间接提供;此外,无头 `--config-json` + `--json` 接口允许 agent 端到端创建项目,并响应 `action-required` 事件。 - -#### KV Cache 影响 - -不会直接导致 KV Cache 失效;由具名消费方负责请求前缀变更。 - -## 已知限制与暂缓事项 - -- **无头本地插件**:无头 spec 会提供项目答案和功能方案;目前还不能在 spec 中表达本地插件脚手架(交互式 none/plugin/tool 选择),默认使用 none。 diff --git a/packages/scaffold/create-sdk/package.json b/packages/scaffold/create-sdk/package.json deleted file mode 100644 index 33cf2f2f15..0000000000 --- a/packages/scaffold/create-sdk/package.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "@deepseek-ai/create-sdk", - "description": "Create a DeepSeek Harness SDK project with npm create @deepseek-ai/sdk", - "version": "0.0.1-rc.1", - "publishConfig": { - "access": "restricted" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/scaffold/create-sdk" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "bin": { - "create-sdk": "lib/bin.js" - }, - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - } - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/bin.js", - "lib/assets", - "lib/types/**/*.d.ts" - ], - "license": "BSD-3-Clause", - "dependencies": { - "@deepseek-ai/dsh-helper": "workspace:^", - "commander": "^15.0.0" - }, - "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - } -} diff --git a/packages/scaffold/create-sdk/src/args.ts b/packages/scaffold/create-sdk/src/args.ts deleted file mode 100644 index da69fec25e..0000000000 --- a/packages/scaffold/create-sdk/src/args.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Commander adapter for the create-sdk command surface. - * - * @module @deepseek-ai/create-sdk/args - */ - -import { Command, Option } from 'commander' -import type { PackageManagerName, RunInterface } from '@deepseek-ai/dsh-helper' - -/** Parsed create command flags before interactive resolution. */ -export interface CreateArgs { - directory?: string - description?: string - provider?: 'deepseek-official' | 'custom' - baseURL?: string - apiKey?: string - model?: string - runInterface?: RunInterface - packageManager?: PackageManagerName - install?: boolean - linkWorkspace?: boolean - config?: string - configJson?: string - json?: boolean - help: boolean -} - -interface CommanderCreateOptions { - description?: string - provider?: 'deepseek-official' | 'custom' - baseUrl?: string - apiKey?: string - model?: string - interface?: RunInterface - pm?: PackageManagerName - install?: boolean - linkWorkspace?: boolean - config?: string - configJson?: string - json?: boolean - help?: boolean -} - -function createProgram(): Command { - return new Command() - .name('create-sdk') - .description('Create a DeepSeek Harness SDK project') - .helpOption(false) - .showHelpAfterError(false) - .exitOverride() - .configureOutput({ - /* v8 ignore next -- the command wrapper renders the package-owned usage template */ - writeOut: () => {}, - /* v8 ignore next -- Commander output is deliberately suppressed; errors are returned to the bin wrapper */ - writeErr: () => {}, - }) - .argument('[directory]') - .option('-h, --help') - .option('--description ') - .addOption(new Option('--provider ').choices(['deepseek-official', 'custom'])) - .option('--base-url ') - .option('--api-key ') - .option('--model ') - .addOption(new Option('--interface ').choices(['acp', 'embed'])) - .addOption(new Option('--pm ').choices(['npm', 'pnpm', 'yarn'])) - .addOption(new Option('--install').default(undefined)) - .addOption(new Option('--no-install').default(undefined)) - .option('--link-workspace') - .option('--config ') - .option('--config-json ') - .addOption(new Option('--json').default(undefined)) -} - -/** Parse create-sdk positionals/options through Commander into a domain-neutral value. */ -export function parseCreateArgs(argv: readonly string[]): CreateArgs { - const program = createProgram() - program.parse([...argv], { from: 'user' }) - const options = program.opts() - const directory = program.processedArgs[0] as string | undefined - return { - ...directory === undefined ? {} : { directory }, - ...options.description === undefined ? {} : { description: options.description }, - ...options.provider === undefined ? {} : { provider: options.provider }, - ...options.baseUrl === undefined ? {} : { baseURL: options.baseUrl }, - ...options.apiKey === undefined ? {} : { apiKey: options.apiKey }, - ...options.model === undefined ? {} : { model: options.model }, - ...options.interface === undefined ? {} : { runInterface: options.interface }, - ...options.pm === undefined ? {} : { packageManager: options.pm }, - ...options.install === undefined ? {} : { install: options.install }, - ...options.linkWorkspace ? { linkWorkspace: true } : {}, - ...options.config === undefined ? {} : { config: options.config }, - ...options.configJson === undefined ? {} : { configJson: options.configJson }, - ...options.json === undefined ? {} : { json: options.json }, - help: options.help ?? false, - } -} diff --git a/packages/scaffold/create-sdk/src/bin.ts b/packages/scaffold/create-sdk/src/bin.ts deleted file mode 100644 index 7a04af8b84..0000000000 --- a/packages/scaffold/create-sdk/src/bin.ts +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env node -/** - * Self-executing create-sdk command. - * - * @module @deepseek-ai/create-sdk/bin - */ - -import { runCreateCommand } from './command.ts' - -process.exitCode = await runCreateCommand() diff --git a/packages/scaffold/create-sdk/src/command.ts b/packages/scaffold/create-sdk/src/command.ts deleted file mode 100644 index 9897db2a21..0000000000 --- a/packages/scaffold/create-sdk/src/command.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Internal create-sdk command composition used by the package bin. - * - * @module @deepseek-ai/create-sdk/command - */ - -import { readFile } from 'node:fs/promises' -import { - ClackPromptPort, - HeadlessPromptError, - HeadlessPromptPort, - NodeCommandRunner, - PromptCancelledError, - type PackageManagerVersionProbe, - type PromptPort, -} from '@deepseek-ai/dsh-helper' -import { parseCreateArgs, type CreateArgs } from './args.ts' -import { CreateWizard, type ResolvedCreateRequest } from './create-wizard.ts' -import { resolveHeadless } from './headless.ts' -import { scaffoldProject, type ScaffoldResult } from './project-scaffolder.ts' -import { CREATE_TEMPLATES, packageManagerTemplateModel } from './templates/create-templates.ts' - -/** Process and terminal slice used by the initializer. */ -export interface CreateCommandContext { - cwd: string - stdin: NodeJS.ReadStream - stdout: NodeJS.WriteStream - stderr: NodeJS.WriteStream - releaseVersion?: string - versionProbe?: PackageManagerVersionProbe - port?: PromptPort - setup?: (request: ResolvedCreateRequest) => Promise -} - -/** Read this initializer package's release version in source and built layouts. */ -export async function readCreateSdkVersion(): Promise { - const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) as { version?: unknown } - /* v8 ignore next -- this package's checked-in manifest always carries its version */ - if (typeof manifest.version !== 'string') throw new Error('create-sdk package version is missing') - return manifest.version -} - -/** Resolve, write, optionally install, and build one new project. */ -export async function createProject( - argv: readonly string[], - context: CreateCommandContext, -): Promise { - const args = parseCreateArgs(argv) - // Under --json, stdout carries only NDJSON events: human-readable progress - // and package-manager child output move to stderr. - const progress = args.json === true ? context.stderr : context.stdout - if (args.help) { - context.stdout.write(CREATE_TEMPLATES.usage.render({})) - return undefined - } - const headless = await resolveHeadless(args) - if (!headless && !context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) { - throw new Error('create-sdk requires an interactive TTY, --config , or --config-json ') - } - const wizard = new CreateWizard({ - args: headless ? headless.args : args, - /* v8 ignore next -- production TTY wiring is exercised by the built-bin smoke */ - port: context.port ?? (headless ? new HeadlessPromptPort() : new ClackPromptPort(context.stdin, context.stdout)), - cwd: context.cwd, - releaseVersion: context.releaseVersion ?? await readCreateSdkVersion(), - ...context.versionProbe ? { versionProbe: context.versionProbe } : {}, - ...headless?.features ? { features: headless.features } : {}, - }) - const resolved = await wizard.run() - const result = await scaffoldProject(resolved.directory, resolved.request) - progress.write(CREATE_TEMPLATES.created.render({ - name: resolved.request.name, - directory: resolved.directory, - })) - if (resolved.install) { - try { - if (context.setup) await context.setup(resolved) - else { - const runner = args.json === true ? new NodeCommandRunner(context.stderr) : new NodeCommandRunner() - await resolved.request.packageManager.install(resolved.directory, runner) - await resolved.request.packageManager.build(resolved.directory, runner) - } - } catch (error) { - context.stderr.write(CREATE_TEMPLATES.setupFailure.render({ - directory: resolved.directory, - error: String(error), - ...packageManagerTemplateModel(resolved.request.packageManager), - })) - throw error - } - } - progress.write(CREATE_TEMPLATES.nextSteps.render({ - directory: resolved.directory, - setupRequired: !resolved.install, - ...packageManagerTemplateModel(resolved.request.packageManager), - })) - return result -} - -/** Whether NDJSON lifecycle events were requested, tolerating unparseable argv. */ -function wantsJsonEvents(argv: readonly string[]): boolean { - let parsed: CreateArgs - try { - parsed = parseCreateArgs(argv) - } catch { - return false - } - return parsed.json === true -} - -/** Run the create command with process defaults and convert cancellation to a clean exit. */ -export async function runCreateCommand( - argv: readonly string[] = process.argv.slice(2), - context: CreateCommandContext = { - cwd: process.cwd(), - stdin: process.stdin, - stdout: process.stdout, - stderr: process.stderr, - }, -): Promise { - const json = wantsJsonEvents(argv) - const emit = (event: Record): void => { - context.stdout.write(`${JSON.stringify(event)}\n`) - } - try { - await createProject(argv, context) - if (json) emit({ type: 'done' }) - return 0 - } catch (error) { - if (error instanceof PromptCancelledError) { - if (json) emit({ type: 'error', reason: 'cancelled' }) - else context.stderr.write('create-sdk: cancelled\n') - return 1 - } - if (json && error instanceof HeadlessPromptError) { - emit({ type: 'action-required', prompt: error.prompt }) - return 1 - } - const message = error instanceof Error ? error.message : String(error) - if (json) emit({ type: 'error', message }) - else context.stderr.write(`create-sdk: ${message}\n`) - return 1 - } -} diff --git a/packages/scaffold/create-sdk/src/create-questions.ts b/packages/scaffold/create-sdk/src/create-questions.ts deleted file mode 100644 index 42a18907ce..0000000000 --- a/packages/scaffold/create-sdk/src/create-questions.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * Static create-sdk question sequence; dynamic feature/plugin loops remain - * in the wizard orchestrator. - * - * @module @deepseek-ai/create-sdk/create-questions - */ - -import { existsSync } from 'node:fs' -import { basename, resolve } from 'node:path' -import { - ConfirmQuestion, - SecretQuestion, - SelectQuestion, - TextQuestion, - requireAnswer, - type PromptPort, - type Question, - type RunInterface, -} from '@deepseek-ai/dsh-helper' -import type { CreateArgs } from './args.ts' - -/** Answers that establish project identity and feature applicability. */ -export interface ProjectAnswers { - directory: string - name: string - description: string - provider: 'deepseek-official' | 'custom' - baseURL: string - apiKey: string - model: string - runInterface: RunInterface -} - -interface ProjectAnswerState extends Partial { - readonly args: CreateArgs - readonly cwd: string -} - -interface WizardStep { - run(port: PromptPort, state: TState): Promise -} - -function questionStep(options: { - question: (state: TState) => Question - when?: (state: TState) => boolean - prefilled?: (state: TState) => TValue | undefined - apply: (state: TState, value: TValue) => void -}): WizardStep { - return { - async run(port, state) { - if (options.when && !options.when(state)) return - const value = requireAnswer(await options.question(state).resolve(port, options.prefilled?.(state))) - options.apply(state, value) - }, - } -} - -/** Validate one required text answer. */ -function nonEmpty(value: string): string | undefined { - return value.trim().length === 0 ? 'A value is required' : undefined -} - -function packageName(value: string): string | undefined { - if (!/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value)) { - return 'Use a lowercase npm package name' - } - return undefined -} - -function projectDirectory(value: string, cwd: string): string | undefined { - const empty = nonEmpty(value) - if (empty) return empty - return existsSync(resolve(cwd, value)) ? 'Target already exists' : undefined -} - -const API_KEY_STEP: WizardStep = { - async run(port, state) { - let prefilled = state.args.apiKey - while (true) { - const apiKey = requireAnswer(await new SecretQuestion({ - id: 'apiKey', - message: state.provider === 'custom' ? 'Custom provider API key' : 'DeepSeek API key', - }).resolve(port, prefilled)) - if (apiKey.length > 0) { - state.apiKey = apiKey - return - } - const keepEmpty = requireAnswer(await new ConfirmQuestion({ - id: 'apiKey.empty', - message: 'Keep the API key empty and fill .env later?', - initialValue: false, - tone: 'warning', - }).resolve(port)) - if (keepEmpty) { - state.apiKey = '' - return - } - prefilled = undefined - } - }, -} - -const PROJECT_QUESTION_STEPS: readonly WizardStep[] = [ - questionStep({ - question: state => new TextQuestion({ - id: 'directory', - message: 'Where should the project be created?', - placeholder: 'my-agent', - defaultValue: 'my-agent', - validate: value => projectDirectory(value, state.cwd), - }), - prefilled: state => state.args.directory, - apply: (state, value) => { state.directory = resolve(state.cwd, value) }, - }), - questionStep({ - question: (state) => { - /* v8 ignore next -- the preceding directory step always populates this state */ - if (!state.directory) throw new Error('directory must resolve before package name') - return new TextQuestion({ - id: 'name', - message: 'Package name', - placeholder: basename(state.directory), - defaultValue: basename(state.directory), - validate: packageName, - }) - }, - apply: (state, value) => { state.name = value }, - }), - questionStep({ - question: (state) => { - /* v8 ignore next -- the preceding package-name step always populates this state */ - if (!state.name) throw new Error('package name must resolve before description') - return new TextQuestion({ - id: 'description', - message: 'Project description', - placeholder: `A DeepSeek Harness agent named ${state.name}`, - defaultValue: `A DeepSeek Harness agent named ${state.name}`, - validate: nonEmpty, - }) - }, - prefilled: state => state.args.description, - apply: (state, value) => { state.description = value }, - }), - questionStep({ - question: () => new SelectQuestion<'deepseek-official' | 'custom'>({ - id: 'provider', - message: 'Model provider', - options: [ - { value: 'deepseek-official', label: 'DeepSeek' }, - { value: 'custom', label: 'Custom endpoint (pi-ai)' }, - ], - initialValue: 'deepseek-official', - }), - prefilled: state => state.args.provider, - apply: (state, value) => { state.provider = value }, - }), - questionStep({ - question: () => new TextQuestion({ - id: 'baseURL', message: 'Custom provider base URL', validate: nonEmpty, - }), - when: state => state.provider === 'custom' || state.args.baseURL !== undefined, - prefilled: state => state.args.baseURL, - apply: (state, value) => { state.baseURL = value }, - }), - API_KEY_STEP, - questionStep({ - question: () => new SelectQuestion({ - id: 'interface', - message: 'Run interface', - options: [ - { value: 'acp', label: 'ACP automation server' }, - { value: 'embed', label: 'Embedded context' }, - ], - initialValue: 'acp', - }), - prefilled: state => state.args.runInterface, - apply: (state, value) => { state.runInterface = value }, - }), -] - -function completeAnswers(state: ProjectAnswerState): ProjectAnswers { - const keys = ['directory', 'name', 'description', 'provider', 'baseURL', 'apiKey', 'model', 'runInterface'] as const - for (const key of keys) { - /* v8 ignore next -- the fixed step list above populates every key or throws/cancels first */ - if (state[key] === undefined) throw new Error(`create question did not resolve ${key}`) - } - return state as ProjectAnswerState & ProjectAnswers -} - -/** Run the fixed project-context sequence in declaration order. */ -export async function collectProjectAnswers( - port: PromptPort, - args: CreateArgs, - cwd: string, -): Promise { - const state: ProjectAnswerState = { - args, - cwd, - baseURL: args.baseURL ?? '', - model: args.model ?? 'deepseek-v4-flash', - } - for (const step of PROJECT_QUESTION_STEPS) await step.run(port, state) - return completeAnswers(state) -} diff --git a/packages/scaffold/create-sdk/src/create-wizard.ts b/packages/scaffold/create-sdk/src/create-wizard.ts deleted file mode 100644 index fb6849ba2a..0000000000 --- a/packages/scaffold/create-sdk/src/create-wizard.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * Declarative create questions with dynamic feature and plugin orchestration. - * - * @module @deepseek-ai/create-sdk/create-wizard - */ - -import { resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { - FeatureConfigurator, - ConfirmQuestion, - LocalPluginBlueprint, - NpmPackageManager, - SelectQuestion, - featureId, - createBuiltinRegistry, - createPackageManager, - inferPackageManagerName, - probePackageManagerVersion, - requireAnswer, - type FeatureRegistry, - type FeatureSelection, - type LocalPluginKind, - type PackageManager, - type PackageManagerName, - type PackageManagerVersionProbe, - type ProjectCreationRequest, - type ProjectProfile, - type PromptPort, -} from '@deepseek-ai/dsh-helper' -import type { CreateArgs } from './args.ts' -import { collectProjectAnswers, type ProjectAnswers } from './create-questions.ts' -import { CREATE_TEMPLATES, packageManagerTemplateModel } from './templates/create-templates.ts' - -/** Fully resolved initializer request and post-create choice. */ -export interface ResolvedCreateRequest { - directory: string - request: ProjectCreationRequest - install: boolean -} - -/** Create-specific orchestration around declarative questions and dynamic selections. */ -export class CreateWizard { - private readonly args: CreateArgs - private readonly port: PromptPort - private readonly cwd: string - private readonly releaseVersion: string - private readonly versionProbe: PackageManagerVersionProbe - private readonly userAgent: string - private readonly linkWorkspaceRoot: string | undefined - private readonly featurePlan: readonly FeatureSelection[] | undefined - - /** Bind parsed args and infrastructure to one wizard run. */ - constructor(options: { - args: CreateArgs - port: PromptPort - cwd?: string - releaseVersion: string - versionProbe?: PackageManagerVersionProbe - userAgent?: string - features?: readonly FeatureSelection[] - }) { - this.args = options.args - this.port = options.port - this.cwd = resolve(options.cwd ?? process.cwd()) - this.releaseVersion = options.releaseVersion - this.versionProbe = options.versionProbe ?? probePackageManagerVersion - /* v8 ignore next -- pnpm supplies npm_config_user_agent while direct invocations may omit it */ - this.userAgent = options.userAgent ?? process.env.npm_config_user_agent ?? '' - this.linkWorkspaceRoot = options.args.linkWorkspace - ? fileURLToPath(new URL('../../../../', import.meta.url)) - : undefined - this.featurePlan = options.features - } - - /** Collect all answers before constructing any project files. */ - async run(): Promise { - const answers = await this.collectProjectAnswers() - const profile = this.provisionalProfile(answers) - const registry = createBuiltinRegistry(profile) - const features = await this.collectFeatures(profile, registry, answers) - const localPlugins = await this.collectPlugins() - const { manager, install } = await this.collectPackageManager() - return { - directory: answers.directory, - install, - request: { - name: answers.name, - description: answers.description, - runtime: { model: answers.model }, - packageManager: manager, - releaseVersion: this.releaseVersion, - ...this.linkWorkspaceRoot ? { linkWorkspaceRoot: this.linkWorkspaceRoot } : {}, - features, - localPlugins, - }, - } - } - - private async collectProjectAnswers(): Promise { - return collectProjectAnswers(this.port, this.args, this.cwd) - } - - private provisionalProfile(answers: ProjectAnswers): ProjectProfile { - return { - name: answers.name, - description: answers.description, - runtime: { model: answers.model }, - runInterface: answers.runInterface, - packageManager: new NpmPackageManager('10.0.0'), - releaseVersion: this.releaseVersion, - ...this.linkWorkspaceRoot ? { linkWorkspaceRoot: this.linkWorkspaceRoot } : {}, - } - } - - private async collectFeatures( - profile: ProjectProfile, - registry: FeatureRegistry, - answers: ProjectAnswers, - ): Promise { - const configurator = new FeatureConfigurator(this.port) - const selections: FeatureSelection[] = [ - { - id: featureId('provider'), - options: [answers.provider], - ...answers.baseURL ? { values: { baseURL: answers.baseURL } } : {}, - secrets: { apiKey: answers.apiKey }, - }, - { id: featureId('spine'), options: ['default'] }, - { id: featureId('app'), options: [answers.runInterface] }, - ] - const configurable = registry.all().filter(feature => feature.id === 'bash' - || feature.id === 'persistence' - || (!feature.required && feature.isApplicable(profile))) - const selected = this.featurePlan - ? this.featurePlan.map(feature => ({ value: feature.id, choices: feature.options })) - : [...requireAnswer(await this.port.nestedMultiselect({ - message: 'Select features', - options: configurable.map((feature) => { - const nested = feature.mode !== 'single' - const defaults = new Set(feature.defaultOptions(profile)) - return { - value: feature.id, - label: feature.summary, - required: feature.required, - default: feature.required || feature.id === 'hmr' || feature.id === 'fs' || feature.id === 'todo' - || feature.id === 'skill', - ...nested ? { - choiceMode: feature.mode === 'multiple' ? 'multiple' as const : 'exclusive' as const, - choices: feature.options.map(option => ({ - value: option.id, - label: option.label, - default: defaults.has(option.id), - })), - } : {}, - } - }), - }))] - if (!this.featurePlan) { - for (const { value: id } of [...selected]) { - const feature = registry.get(id) - for (const suggestedId of feature.suggests) { - if (selected.some(item => item.value === suggestedId)) continue - const suggested = registry.get(suggestedId) - const add = requireAnswer(await new ConfirmQuestion({ - id: `${feature.id}.${suggested.id}`, - message: `Add the recommended ${suggested.summary.toLowerCase()} for ${feature.summary.toLowerCase()}?`, - initialValue: true, - }).resolve(this.port)) - if (add) selected.push({ value: suggested.id, choices: suggested.defaultOptions(profile) }) - } - } - } - const fixed = new Set(selections.map(selection => selection.id)) - const choices = new Map() - for (const feature of registry.all()) { - if (feature.required && feature.isApplicable(profile) && !fixed.has(feature.id)) { - choices.set(feature.id, feature.defaultOptions(profile)) - } - } - for (const choice of selected) { - choices.set(choice.value, choice.choices.length > 0 ? choice.choices : undefined) - } - const plannedById = new Map((this.featurePlan ?? []).map(feature => [feature.id, feature])) - for (const [id, options] of choices) { - const planned = plannedById.get(id) - selections.push(await configurator.configure( - registry.get(id), - profile, - undefined, - options, - planned?.secrets ?? {}, - planned?.values ?? {}, - )) - } - return selections - } - - private async collectPlugins(): Promise { - const kind = requireAnswer(await new SelectQuestion({ - id: 'plugins.kind', - message: 'Local plugin', - options: [ - { value: 'none', label: 'No local plugin' }, - { value: 'plugin', label: 'Cordis plugin' }, - { value: 'tool', label: 'Model-facing tool' }, - ], - initialValue: 'none', - }).resolve(this.port)) - return kind === 'none' ? [] : [new LocalPluginBlueprint(kind, kind)] - } - - private async collectPackageManager(): Promise<{ manager: PackageManager; install: boolean }> { - const inferred = inferPackageManagerName(this.args.packageManager, this.userAgent) - const name = requireAnswer(await new SelectQuestion({ - id: 'packageManager', - message: 'Package manager', - options: [ - { value: 'npm', label: 'npm' }, - { value: 'pnpm', label: 'pnpm' }, - { value: 'yarn', label: 'Yarn' }, - ], - initialValue: inferred ?? 'npm', - }).resolve(this.port, inferred)) - const manager = createPackageManager(name, await this.versionProbe(name, this.cwd)) - const install = requireAnswer(await new ConfirmQuestion({ - id: 'install', - message: CREATE_TEMPLATES.installQuestion.render(packageManagerTemplateModel(manager)).trimEnd(), - initialValue: true, - }).resolve(this.port, this.args.install)) - return { manager, install } - } -} diff --git a/packages/scaffold/create-sdk/src/headless.ts b/packages/scaffold/create-sdk/src/headless.ts deleted file mode 100644 index 8f6130acd7..0000000000 --- a/packages/scaffold/create-sdk/src/headless.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Headless create input: a structured project spec supplied by an agent or CI - * instead of interactive prompts. - * - * @module @deepseek-ai/create-sdk/headless - */ - -import { readFile } from 'node:fs/promises' -import type { FeatureSelection, PackageManagerName, RunInterface } from '@deepseek-ai/dsh-helper' -import type { CreateArgs } from './args.ts' - -/** - * Structured, non-interactive create input. Scalar fields mirror {@link CreateArgs} - * project answers; `features` is the headless feature plan handed to `CreateWizard` - * (the interactive tree/suggests prompts are skipped). Absent required answers make - * the run fail loud through `HeadlessPromptPort` rather than blocking. - */ -interface HeadlessCreateSpec { - directory?: string - description?: string - provider?: 'deepseek-official' | 'custom' - baseURL?: string - apiKey?: string - model?: string - interface?: RunInterface - pm?: PackageManagerName - install?: boolean - linkWorkspace?: boolean - features?: readonly FeatureSelection[] -} - -/** Resolved headless input: the args the wizard reads plus the feature plan. */ -export interface ResolvedHeadless { - args: CreateArgs - features: readonly FeatureSelection[] | undefined -} - -function asRecord(value: unknown, source: string): Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw new Error(`${source}: expected a JSON object`) - } - return value as Record -} - -/** Parse and shallow-validate a headless spec from JSON text. */ -function parseHeadlessSpec(text: string, source: string): HeadlessCreateSpec { - let parsed: unknown - try { - parsed = JSON.parse(text) - } catch (error) { - /* v8 ignore next -- JSON.parse only throws Error instances; the String() branch is defensive */ - throw new Error(`${source}: invalid JSON (${error instanceof Error ? error.message : String(error)})`) - } - const record = asRecord(parsed, source) - if (record.features !== undefined && !Array.isArray(record.features)) { - throw new Error(`${source}: "features" must be an array`) - } - return record -} - -/** - * Load a headless spec from `--config-json` (inline) or `--config` (a JSON file), - * returning `undefined` when neither is supplied. - * @param args - parsed create args. - * @param readFileText - File-reader hook for tests. - * @returns the resolved args + feature plan, or `undefined` for interactive runs. - */ -export async function resolveHeadless( - args: CreateArgs, - readFileText: (path: string) => Promise = path => readFile(path, 'utf8'), -): Promise { - let text: string - let source: string - if (args.configJson !== undefined) { - text = args.configJson - source = '--config-json' - } else if (args.config !== undefined) { - source = args.config - text = await readFileText(args.config) - } else { - return undefined - } - const spec = parseHeadlessSpec(text, source) - const resolvedArgs: CreateArgs = { - ...spec.directory === undefined ? {} : { directory: spec.directory }, - ...spec.description === undefined ? {} : { description: spec.description }, - ...spec.provider === undefined ? {} : { provider: spec.provider }, - ...spec.baseURL === undefined ? {} : { baseURL: spec.baseURL }, - ...spec.apiKey === undefined ? {} : { apiKey: spec.apiKey }, - ...spec.model === undefined ? {} : { model: spec.model }, - ...spec.interface === undefined ? {} : { runInterface: spec.interface }, - ...spec.pm === undefined ? {} : { packageManager: spec.pm }, - ...spec.install === undefined ? {} : { install: spec.install }, - ...spec.linkWorkspace ? { linkWorkspace: true } : {}, - help: false, - } - return { args: resolvedArgs, features: spec.features } -} diff --git a/packages/scaffold/create-sdk/src/index.ts b/packages/scaffold/create-sdk/src/index.ts deleted file mode 100644 index f931d956f6..0000000000 --- a/packages/scaffold/create-sdk/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * The create-sdk package is a CLI initializer; its library entry exports no symbols. - * - * @module @deepseek-ai/create-sdk - */ - -export {} diff --git a/packages/scaffold/create-sdk/src/invariant.ts b/packages/scaffold/create-sdk/src/invariant.ts deleted file mode 100644 index a4de619111..0000000000 --- a/packages/scaffold/create-sdk/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/create-sdk`. - * @module @deepseek-ai/create-sdk/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/create-sdk' - -/** Cordis companion plugin name. */ -export const name = 'create-sdk-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this SDK build-time package owns no live event stream or mutable data; - * generated output and consumer tests cover its contract. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/scaffold/create-sdk/src/project-scaffolder.ts b/packages/scaffold/create-sdk/src/project-scaffolder.ts deleted file mode 100644 index f17f04317d..0000000000 --- a/packages/scaffold/create-sdk/src/project-scaffolder.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Project creation use case over the shared SDK aggregate and edit session. - * - * @module @deepseek-ai/create-sdk/project-scaffolder - */ - -import { stat } from 'node:fs/promises' -import { - SdkProject, - createBuiltinRegistry, - type ChangeSet, - type ProjectCreationRequest, -} from '@deepseek-ai/dsh-helper' - -/** Result of writing one new SDK project. */ -export interface ScaffoldResult { - project: SdkProject - changes: ChangeSet -} - -/** Create a project entirely in memory, then validate and commit it once. */ -export async function scaffoldProject(root: string, request: ProjectCreationRequest): Promise { - let targetExists = true - try { - await stat(root) - } catch (error) { - /* v8 ignore else -- the other arm requires a filesystem permission/IO fault from stat */ - if ((error as NodeJS.ErrnoException).code === 'ENOENT') targetExists = false - /* v8 ignore next -- paired with the ignored defensive stat-error arm above */ - else throw error - } - if (targetExists) throw new Error(`target already exists: ${root}`) - const project = SdkProject.create(root, request) - const registry = createBuiltinRegistry(project.profile) - const edit = project.edit(registry) - for (const selection of request.features) { - edit.installFeature(registry.get(selection.id), selection) - } - for (const plugin of request.localPlugins) edit.addPlugin(plugin) - return edit.commit() -} diff --git a/packages/scaffold/create-sdk/src/templates/assets/created.txt.tpl b/packages/scaffold/create-sdk/src/templates/assets/created.txt.tpl deleted file mode 100644 index 9d4eff4e92..0000000000 --- a/packages/scaffold/create-sdk/src/templates/assets/created.txt.tpl +++ /dev/null @@ -1 +0,0 @@ -Created {{name}} in {{directory}} diff --git a/packages/scaffold/create-sdk/src/templates/assets/install-question.txt.tpl b/packages/scaffold/create-sdk/src/templates/assets/install-question.txt.tpl deleted file mode 100644 index 4872f63df0..0000000000 --- a/packages/scaffold/create-sdk/src/templates/assets/install-question.txt.tpl +++ /dev/null @@ -1 +0,0 @@ -Run {{packageManager}} {{installArgs}} and then build the project? diff --git a/packages/scaffold/create-sdk/src/templates/assets/next-steps.txt.tpl b/packages/scaffold/create-sdk/src/templates/assets/next-steps.txt.tpl deleted file mode 100644 index c3f237deb6..0000000000 --- a/packages/scaffold/create-sdk/src/templates/assets/next-steps.txt.tpl +++ /dev/null @@ -1,5 +0,0 @@ -{{#if setupRequired}} -Next: cd {{directory}} && {{packageManager}} {{installArgs}} && {{packageManager}} {{buildArgs}} && {{packageManager}} start -{{else}} -Next: cd {{directory}} && {{packageManager}} start -{{/if}} diff --git a/packages/scaffold/create-sdk/src/templates/assets/setup-failure.txt.tpl b/packages/scaffold/create-sdk/src/templates/assets/setup-failure.txt.tpl deleted file mode 100644 index 7d1e06c820..0000000000 --- a/packages/scaffold/create-sdk/src/templates/assets/setup-failure.txt.tpl +++ /dev/null @@ -1,2 +0,0 @@ -Project files are ready, but setup failed: {{error}} -Retry: cd {{directory}} && {{packageManager}} {{installArgs}} && {{packageManager}} {{buildArgs}} diff --git a/packages/scaffold/create-sdk/src/templates/assets/usage.txt.tpl b/packages/scaffold/create-sdk/src/templates/assets/usage.txt.tpl deleted file mode 100644 index 307cc23398..0000000000 --- a/packages/scaffold/create-sdk/src/templates/assets/usage.txt.tpl +++ /dev/null @@ -1,14 +0,0 @@ -Usage: create-sdk [directory] [options] - -Options: - --description - --provider - --base-url - --api-key - --model - --interface - --pm - --install / --no-install - --config - --config-json - --json diff --git a/packages/scaffold/create-sdk/src/templates/create-templates.ts b/packages/scaffold/create-sdk/src/templates/create-templates.ts deleted file mode 100644 index efeaf759c6..0000000000 --- a/packages/scaffold/create-sdk/src/templates/create-templates.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Package-owned terminal templates for create-sdk. - * - * @module @deepseek-ai/create-sdk/templates/create-templates - */ - -import { - TextTemplate, - type PackageManager, - type PackageManagerName, -} from '@deepseek-ai/dsh-helper' - -interface CreatedTemplateModel { - name: string - directory: string -} - -interface NextStepsTemplateModel extends PackageManagerTemplateModel { - directory: string - setupRequired: boolean -} - -interface SetupFailureTemplateModel extends PackageManagerTemplateModel { - directory: string - error: string -} - -/** Package-manager execution data consumed by create-sdk templates. */ -export interface PackageManagerTemplateModel { - packageManager: PackageManagerName - installArgs: string - buildArgs: string -} - -/** - * Map package-manager execution data into terminal-template fields. - * @param manager - selected package-manager strategy. - * @returns executable name and operation arguments. - */ -export function packageManagerTemplateModel(manager: PackageManager): PackageManagerTemplateModel { - return { - packageManager: manager.name, - installArgs: manager.installCommand().join(' '), - buildArgs: manager.buildCommand().join(' '), - } -} - -/** Compiled create-sdk terminal templates. */ -export const CREATE_TEMPLATES = { - usage: TextTemplate.fromFile>(new URL('./assets/usage.txt.tpl', import.meta.url)), - created: TextTemplate.fromFile(new URL('./assets/created.txt.tpl', import.meta.url)), - nextSteps: TextTemplate.fromFile(new URL('./assets/next-steps.txt.tpl', import.meta.url)), - setupFailure: TextTemplate.fromFile( - new URL('./assets/setup-failure.txt.tpl', import.meta.url), - ), - installQuestion: TextTemplate.fromFile( - new URL('./assets/install-question.txt.tpl', import.meta.url), - ), -} as const diff --git a/packages/scaffold/create-sdk/tests/built-artifacts.e2e.ts b/packages/scaffold/create-sdk/tests/built-artifacts.e2e.ts deleted file mode 100644 index 827abe06a2..0000000000 --- a/packages/scaffold/create-sdk/tests/built-artifacts.e2e.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { execFile } from 'node:child_process' -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { promisify } from 'node:util' -import { describe, expect, it } from 'vitest' - -const execFileAsync = promisify(execFile) -const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) -const createBin = join(repoRoot, 'packages/scaffold/create-sdk/lib/bin.js') -const scriptsBin = join(repoRoot, 'packages/scaffold/scripts/lib/bin.js') - -describe.skipIf(!existsSync(createBin) || !existsSync(scriptsBin))( - 'SDK built artifacts', - () => { - it('runs the published dsh-sdk bin help path under plain Node', async () => { - const result = await execFileAsync(process.execPath, [scriptsBin, '--help'], { encoding: 'utf8' }) - expect(result.stdout).toContain('Usage: dsh-sdk ') - expect(result.stderr).toBe('') - }) - - it('runs the published create-sdk bin help path under plain Node', async () => { - const result = await execFileAsync(process.execPath, [createBin, '--help'], { encoding: 'utf8' }) - expect(result.stdout).toContain('Usage: create-sdk [directory]') - expect(result.stderr).toBe('') - }) - }, -) diff --git a/packages/scaffold/create-sdk/tests/create.snapshot.ts b/packages/scaffold/create-sdk/tests/create.snapshot.ts deleted file mode 100644 index dcf0b791a5..0000000000 --- a/packages/scaffold/create-sdk/tests/create.snapshot.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - featureId, - createPackageManager, - type NestedMultiSelectValue, - type PromptPort, -} from '@deepseek-ai/dsh-helper' -import type { - ConfirmPromptRequest, - MultiSelectPromptRequest, - NestedMultiSelectRequest, - PromptOutcome, - SecretPromptRequest, - SelectPromptRequest, - TextPromptRequest, -} from '../../helper/src/questions/prompt-port.ts' -import { parseCreateArgs } from '../src/args.ts' -import { CreateWizard } from '../src/create-wizard.ts' -import { CREATE_TEMPLATES, packageManagerTemplateModel } from '../src/templates/create-templates.ts' - -class RecordingPort implements PromptPort { - readonly transcript: unknown[] = [] - readonly #answers: unknown[] - - constructor(answers: unknown[]) { this.#answers = [...answers] } - - answer(record: unknown): Promise> { - this.transcript.push(record) - return Promise.resolve({ status: 'answered', value: this.#answers.shift() as T }) - } - - text(request: TextPromptRequest): Promise> { - return this.answer({ - kind: 'text', - message: request.message, - defaultValue: request.defaultValue, - initialValue: request.initialValue, - }) - } - secret(request: SecretPromptRequest): Promise> { - return this.answer({ kind: 'secret', message: request.message }) - } - select(request: SelectPromptRequest): Promise> { - return this.answer({ - kind: 'select', message: request.message, options: request.options.map(option => option.label), - initialValue: request.initialValue, - }) - } - multiselect(request: MultiSelectPromptRequest): Promise> { - return this.answer({ - kind: 'multiselect', message: request.message, options: request.options.map(option => option.label), - initialValues: request.initialValues, - }) - } - confirm(request: ConfirmPromptRequest): Promise> { - return this.answer({ kind: 'confirm', message: request.message, initialValue: request.initialValue }) - } - nestedMultiselect( - request: NestedMultiSelectRequest, - ): Promise[]>> { - return this.answer({ - kind: 'nested-multiselect', - message: request.message, - options: request.options.map(option => ({ - label: option.label, - required: option.required, - default: option.default, - choices: option.choices?.map(choice => choice.label), - })), - }) - } -} - -describe.skipIf(process.platform === 'win32')('create-sdk terminal contract', () => { - it('renders package-manager-specific setup commands', () => { - const model = packageManagerTemplateModel(createPackageManager('yarn', '4.0.0')) - expect(CREATE_TEMPLATES.installQuestion.render(model)).toBe('Run yarn install and then build the project?\n') - expect(CREATE_TEMPLATES.setupFailure.render({ - directory: '/workspace/agent', - error: 'offline', - ...model, - })).toContain('yarn install && yarn build') - }) - - it('pins the full unresolved question order and completion messages', async () => { - const port = new RecordingPort([ - 'my-agent', - 'my-agent', - 'Snapshot agent', - 'deepseek-official', - 'secret-key', - 'acp', - [ - { value: featureId('persistence'), choices: ['jsonl'] }, - { value: featureId('hmr'), choices: [] }, - { value: featureId('web'), choices: ['exa'] }, - { value: featureId('workflow'), choices: [] }, - ], - true, - 'exa-key', - 'none', - 'npm', - false, - ]) - const resolved = await new CreateWizard({ - args: parseCreateArgs([]), - port, - cwd: '/workspace', - releaseVersion: '0.0.1', - userAgent: '', - versionProbe: async () => '10.0.0', - }).run() - expect({ - prompts: port.transcript, - result: { - directory: resolved.directory, - name: resolved.request.name, - manager: resolved.request.packageManager.name, - install: resolved.install, - features: resolved.request.features.map(item => ({ id: item.id, options: item.options })), - }, - messages: { - created: CREATE_TEMPLATES.created.render({ - name: resolved.request.name, - directory: resolved.directory, - }), - next: CREATE_TEMPLATES.nextSteps.render({ - directory: resolved.directory, - setupRequired: false, - ...packageManagerTemplateModel(resolved.request.packageManager), - }), - failure: CREATE_TEMPLATES.setupFailure.render({ - directory: resolved.directory, - error: String(new Error('offline')), - ...packageManagerTemplateModel(resolved.request.packageManager), - }), - }, - }).toMatchInlineSnapshot(` - { - "messages": { - "created": "Created my-agent in /workspace/my-agent - ", - "failure": "Project files are ready, but setup failed: Error: offline - Retry: cd /workspace/my-agent && npm install && npm run build - ", - "next": "Next: cd /workspace/my-agent && npm start - ", - }, - "prompts": [ - { - "defaultValue": "my-agent", - "initialValue": undefined, - "kind": "text", - "message": "Where should the project be created?", - }, - { - "defaultValue": "my-agent", - "initialValue": undefined, - "kind": "text", - "message": "Package name", - }, - { - "defaultValue": "A DeepSeek Harness agent named my-agent", - "initialValue": undefined, - "kind": "text", - "message": "Project description", - }, - { - "initialValue": "deepseek-official", - "kind": "select", - "message": "Model provider", - "options": [ - "DeepSeek", - "Custom endpoint (pi-ai)", - ], - }, - { - "kind": "secret", - "message": "DeepSeek API key", - }, - { - "initialValue": "acp", - "kind": "select", - "message": "Run interface", - "options": [ - "ACP automation server", - "Embedded context", - ], - }, - { - "kind": "nested-multiselect", - "message": "Select features", - "options": [ - { - "choices": [ - "Local executor", - "Sandboxed executor", - ], - "default": true, - "label": "Command execution", - "required": true, - }, - { - "choices": [ - "JSONL files", - "SQLite database", - ], - "default": true, - "label": "Durable session storage", - "required": true, - }, - { - "choices": undefined, - "default": true, - "label": "Hot-module reload", - "required": false, - }, - { - "choices": undefined, - "default": true, - "label": "Read, write, and edit local files", - "required": false, - }, - { - "choices": undefined, - "default": true, - "label": "Model-facing task tracking", - "required": false, - }, - { - "choices": undefined, - "default": true, - "label": "Local skill discovery", - "required": false, - }, - { - "choices": [ - "DeepSeek search", - "Exa search", - "Perplexity search", - "Fetch only", - ], - "default": false, - "label": "Web search and fetch tools", - "required": false, - }, - { - "choices": [ - "Fresh child agent", - "Fork parent history", - ], - "default": false, - "label": "Delegate work to child agents", - "required": false, - }, - { - "choices": undefined, - "default": false, - "label": "Scripted multi-agent workflows", - "required": false, - }, - { - "choices": undefined, - "default": false, - "label": "Automatic context compaction", - "required": false, - }, - { - "choices": [ - "Claude Code hooks", - "Codex hooks", - ], - "default": false, - "label": "Run Claude Code or Codex hooks", - "required": false, - }, - { - "choices": undefined, - "default": false, - "label": "Loop-hygiene reminders", - "required": false, - }, - { - "choices": undefined, - "default": false, - "label": "Tool timeout policy", - "required": false, - }, - ], - }, - { - "initialValue": true, - "kind": "confirm", - "message": "Add the recommended tool timeout policy for web search and fetch tools?", - }, - { - "kind": "secret", - "message": "Exa API key", - }, - { - "initialValue": "none", - "kind": "select", - "message": "Local plugin", - "options": [ - "No local plugin", - "Cordis plugin", - "Model-facing tool", - ], - }, - { - "initialValue": "npm", - "kind": "select", - "message": "Package manager", - "options": [ - "npm", - "pnpm", - "Yarn", - ], - }, - { - "initialValue": true, - "kind": "confirm", - "message": "Run npm install and then build the project?", - }, - ], - "result": { - "directory": "/workspace/my-agent", - "features": [ - { - "id": "provider", - "options": [ - "deepseek-official", - ], - }, - { - "id": "spine", - "options": [ - "default", - ], - }, - { - "id": "app", - "options": [ - "acp", - ], - }, - { - "id": "bash", - "options": [ - "local", - ], - }, - { - "id": "persistence", - "options": [ - "jsonl", - ], - }, - { - "id": "hmr", - "options": [ - "default", - ], - }, - { - "id": "web", - "options": [ - "exa", - ], - }, - { - "id": "workflow", - "options": [ - "workerthread", - ], - }, - { - "id": "timeout-policy", - "options": [ - "default", - ], - }, - ], - "install": false, - "manager": "npm", - "name": "my-agent", - }, - } - `) - }) -}) diff --git a/packages/scaffold/create-sdk/tests/create.spec.ts b/packages/scaffold/create-sdk/tests/create.spec.ts deleted file mode 100644 index 060f76b11d..0000000000 --- a/packages/scaffold/create-sdk/tests/create.spec.ts +++ /dev/null @@ -1,678 +0,0 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { PassThrough, Writable } from 'node:stream' -import { fileURLToPath } from 'node:url' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - HeadlessPromptPort, - LocalPluginBlueprint, - featureId, - NodeCommandRunner, - NpmPackageManager, - type FeatureSelection, - type NestedMultiSelectValue, - type PromptPort, -} from '@deepseek-ai/dsh-helper' -import type { - ConfirmPromptRequest, - MultiSelectPromptRequest, - NestedMultiSelectRequest, - PromptOutcome, - SecretPromptRequest, - SelectPromptRequest, - TextPromptRequest, -} from '../../helper/src/questions/prompt-port.ts' -import { parseCreateArgs } from '../src/args.ts' -import { - createProject, - readCreateSdkVersion, - runCreateCommand, - type CreateCommandContext, -} from '../src/command.ts' -import { CreateWizard } from '../src/create-wizard.ts' -import { resolveHeadless } from '../src/headless.ts' -import { scaffoldProject } from '../src/project-scaffolder.ts' - -class ScriptedPort implements PromptPort { - readonly requests: string[] = [] - readonly #answers: unknown[] - - constructor(answers: unknown[]) { - this.#answers = [...answers] - } - - answer(message: string): Promise> { - this.requests.push(message) - const value = this.#answers.shift() - return Promise.resolve(value === ScriptedPort.cancel - ? { status: 'cancelled' } - : { status: 'answered', value: value as T }) - } - - async text(request: TextPromptRequest): Promise> { - const outcome = await this.answer(request.message) - if (outcome.status === 'cancelled') return outcome - const value = outcome.value || request.defaultValue || '' - const diagnostic = request.validate?.(value) - if (diagnostic) throw new Error(diagnostic) - return { status: 'answered', value } - } - secret(request: SecretPromptRequest): Promise> { return this.answer(request.message) } - select(request: SelectPromptRequest): Promise> { return this.answer(request.message) } - multiselect(request: MultiSelectPromptRequest): Promise> { - return this.answer(request.message) - } - confirm(request: ConfirmPromptRequest): Promise> { return this.answer(request.message) } - nestedMultiselect( - request: NestedMultiSelectRequest, - ): Promise[]>> { - return this.answer(request.message) - } - - static readonly cancel = Symbol('cancel') -} - -const temporary: string[] = [] -const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) - -interface GeneratedPackageManifest { - scripts?: Record - dependencies?: Record - devDependencies?: Record -} - -interface GeneratedTsConfig { - compilerOptions: { - types?: readonly string[] - } -} - -function parseGeneratedPackageManifest(text: string): GeneratedPackageManifest { - return JSON.parse(text) as GeneratedPackageManifest -} - -function parseGeneratedTsConfig(text: string): GeneratedTsConfig { - return JSON.parse(text) as GeneratedTsConfig -} - -function commandContext( - cwd: string, - port?: PromptPort, - setup?: CreateCommandContext['setup'], -): CreateCommandContext & { readStdout: () => string; readStderr: () => string } { - let stdout = '' - let stderr = '' - const input = Object.assign(new PassThrough(), { isTTY: true }) as unknown as NodeJS.ReadStream - const output = Object.assign(new Writable({ - write(chunk, _encoding, callback) { stdout += String(chunk); callback() }, - }), { isTTY: true }) as unknown as NodeJS.WriteStream - const error = new Writable({ - write(chunk, _encoding, callback) { stderr += String(chunk); callback() }, - }) as unknown as NodeJS.WriteStream - return { - cwd, - stdin: input, - stdout: output, - stderr: error, - releaseVersion: '0.0.1', - versionProbe: async () => '10.0.0', - ...port ? { port } : {}, - ...setup ? { setup } : {}, - readStdout: () => stdout, - readStderr: () => stderr, - } -} - -afterEach(async () => { - await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true }))) -}) - -describe('create arguments', () => { - it('parses public options and the private repository link mode', () => { - expect(parseCreateArgs([ - 'agent', '--description=demo', '--provider', 'deepseek-official', '--base-url=https://api.example', - '--api-key', 'key', '--model=m', '--interface', 'acp', '--pm=pnpm', '--no-install', - '--link-workspace', - ])).toEqual({ - directory: 'agent', - description: 'demo', - provider: 'deepseek-official', - baseURL: 'https://api.example', - apiKey: 'key', - model: 'm', - runInterface: 'acp', - packageManager: 'pnpm', - install: false, - linkWorkspace: true, - help: false, - }) - expect(parseCreateArgs(['--link-workspace']).linkWorkspace).toBe(true) - expect(() => parseCreateArgs(['--link-packages-workspace'])).toThrow("unknown option '--link-packages-workspace'") - expect(parseCreateArgs(['--provider=custom']).provider).toBe('custom') - expect(parseCreateArgs(['--help']).help).toBe(true) - expect(() => parseCreateArgs(['--interface=bad'])).toThrow('Allowed choices are acp, embed') - expect(() => parseCreateArgs(['--unknown'])).toThrow("unknown option '--unknown'") - expect(() => parseCreateArgs(['one', 'two'])).toThrow('too many arguments') - }) - - it('validates empty directories and package names', async () => { - const root = await mkdtemp(join(tmpdir(), 'create-validation-')) - temporary.push(root) - await expect(new CreateWizard({ - args: parseCreateArgs(['']), port: new ScriptedPort([]), cwd: root, - releaseVersion: '0.0.1', versionProbe: async () => '10.0.0', - }).run()).rejects.toThrow('A value is required') - await expect(new CreateWizard({ - args: parseCreateArgs(['agent']), port: new ScriptedPort(['Invalid Name']), cwd: root, - releaseVersion: '0.0.1', versionProbe: async () => '10.0.0', - }).run()).rejects.toThrow('lowercase npm package name') - }) - - it('rejects an existing target before asking project questions', async () => { - const cwd = await mkdtemp(join(tmpdir(), 'create-existing-target-')) - temporary.push(cwd) - await mkdir(join(cwd, 'taken')) - const port = new ScriptedPort([]) - const wizard = new CreateWizard({ - args: parseCreateArgs(['taken']), - port, - cwd, - releaseVersion: '0.0.1', - versionProbe: async () => '10.0.0', - }) - await expect(wizard.run()).rejects.toThrow('directory: Target already exists') - expect(port.requests).toEqual([]) - }) -}) - -describe('CreateWizard and scaffolder', () => { - it('asks only unresolved questions in requirement-safe order', async () => { - const cwd = await mkdtemp(join(tmpdir(), 'create-wizard-')) - temporary.push(cwd) - const port = new ScriptedPort([ - 'my-agent', - [ - { value: featureId('persistence'), choices: ['sqlite'] }, - { value: featureId('hmr'), choices: [] }, - { value: featureId('fs'), choices: [] }, - { value: featureId('web'), choices: ['exa'] }, - ], - false, - 'exa-key', - 'tool', - ]) - const args = parseCreateArgs([ - 'my-agent', - '--description=demo', - '--provider=deepseek-official', - '--api-key=deepseek-key', - '--model=deepseek-v4-flash', - '--interface=acp', - '--pm=npm', - '--no-install', - '--link-workspace', - ]) - const resolved = await new CreateWizard({ - args, - port, - cwd, - releaseVersion: '0.0.1', - versionProbe: async () => '10.0.0', - }).run() - expect(port.requests).toEqual([ - 'Package name', - 'Select features', - 'Add the recommended tool timeout policy for web search and fetch tools?', - 'Exa API key', - 'Local plugin', - ]) - expect(resolved.install).toBe(false) - expect(resolved.request.packageManager.name).toBe('npm') - expect(resolved.request.linkWorkspaceRoot).toBe(repoRoot) - expect(resolved.request.localPlugins[0]).toMatchObject({ name: 'tool', kind: 'tool' }) - expect(resolved.request.features.find(item => item.id === 'web')).toMatchObject({ - options: ['exa'], secrets: { apiKey: 'exa-key' }, - }) - expect(resolved.request.features.find(item => item.id === 'hmr')).toMatchObject({ options: ['default'] }) - }) - - it('runs headlessly from a feature plan without reaching the terminal', async () => { - const cwd = await mkdtemp(join(tmpdir(), 'create-headless-')) - temporary.push(cwd) - const features: FeatureSelection[] = [ - { id: featureId('persistence'), options: ['sqlite'], values: { region: 'us' } }, - { id: featureId('web'), options: ['exa'], secrets: { apiKey: 'exa-key' } }, - ] - const resolved = await new CreateWizard({ - args: parseCreateArgs([ - 'my-agent', '--description=demo', '--provider=deepseek-official', '--api-key=deepseek-key', - '--model=deepseek-v4-flash', '--interface=acp', '--pm=npm', '--no-install', - ]), - port: new HeadlessPromptPort(), - cwd, - releaseVersion: '0.0.1', - versionProbe: async () => '10.0.0', - features, - }).run() - expect(resolved.install).toBe(false) - expect(resolved.request.localPlugins).toEqual([]) - expect(resolved.request.features.find(item => item.id === 'web')).toMatchObject({ - options: ['exa'], secrets: { apiKey: 'exa-key' }, - }) - expect(resolved.request.features.find(item => item.id === 'persistence')).toMatchObject({ options: ['sqlite'] }) - expect(resolved.request.features.find(item => item.id === 'provider')).toMatchObject({ - secrets: { apiKey: 'deepseek-key' }, - }) - }) - - it('rejects a non-string feature value in a headless plan', async () => { - const cwd = await mkdtemp(join(tmpdir(), 'create-headless-bad-')) - temporary.push(cwd) - const features = [ - { id: featureId('persistence'), options: ['sqlite'], values: { bad: 1 } }, - ] as unknown as FeatureSelection[] - await expect(new CreateWizard({ - args: parseCreateArgs([ - 'my-agent', '--description=demo', '--provider=deepseek-official', '--api-key=k', - '--model=m', '--interface=acp', '--pm=npm', '--no-install', - ]), - port: new HeadlessPromptPort(), - cwd, - releaseVersion: '0.0.1', - versionProbe: async () => '10.0.0', - features, - }).run()).rejects.toThrow('must be a string') - }) - - it('writes the project once and refuses every existing target', async () => { - const root = await mkdtemp(join(tmpdir(), 'create-scaffold-')) - temporary.push(root) - const request = { - name: 'agent', - description: 'demo', - runtime: { model: 'deepseek-v4-flash' }, - packageManager: new NpmPackageManager('10.0.0'), - releaseVersion: '0.0.1', - features: [ - { id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'key' } }, - { id: featureId('bash'), options: ['local'] }, - { id: featureId('app'), options: ['embed'] }, - { id: featureId('persistence'), options: ['jsonl'] }, - ], - localPlugins: [new LocalPluginBlueprint('plugin', 'plugin')], - } - const target = join(root, 'project') - const result = await scaffoldProject(target, request) - expect(result.changes.changedFiles).toContain('README.md') - const index = await readFile(join(target, 'index.ts'), 'utf8') - expect(index).toContain('SdkBootContext') - expect(index).toContain('ctx.agents.create') - expect(index).toContain('agentOptions: { model: "deepseek-v4-flash" }') - expect(index).not.toContain('AgentId') - const tsconfig = parseGeneratedTsConfig(await readFile(join(target, 'tsconfig.base.json'), 'utf8')) - const manifest = parseGeneratedPackageManifest(await readFile(join(target, 'package.json'), 'utf8')) - expect(tsconfig.compilerOptions.types).toEqual(['node']) - expect(manifest.scripts).toEqual({ - dev: 'dsh-sdk dev index.ts', - build: 'dsh-sdk build', - typecheck: 'tsc -b', - start: 'dsh-sdk start index.js', - config: 'dsh-sdk config', - }) - expect(manifest.dependencies).not.toHaveProperty('node-addon-require-builtin') - expect(manifest.devDependencies?.['@types/node']).toBe('^22.20.0') - expect(await readFile(join(target, 'plugins/plugin/src/index.ts'), 'utf8')).toContain('export function apply') - const cordis = await readFile(join(target, 'cordis.yml'), 'utf8') - expect(cordis).toMatch(/^- id:/) - expect(cordis).not.toMatch(/^\[/) - const occupied = join(root, 'occupied') - await mkdir(occupied) - await expect(scaffoldProject(occupied, request)).rejects.toThrow('already exists') - await writeFile(join(occupied, 'keep'), 'x') - await expect(scaffoldProject(occupied, request)).rejects.toThrow('already exists') - }) - - it('installs workflow requirements before validating the next feature', async () => { - const cwd = await mkdtemp(join(tmpdir(), 'create-workflow-requires-')) - temporary.push(cwd) - const port = new ScriptedPort([ - 'workflow-agent', - [ - { value: featureId('persistence'), choices: ['jsonl'] }, - { value: featureId('workflow'), choices: [] }, - ], - 'none', - ]) - const resolved = await new CreateWizard({ - args: parseCreateArgs([ - 'workflow-agent', '--description=test', '--provider=deepseek-official', '--api-key=key', - '--interface=embed', '--pm=npm', '--no-install', - ]), - port, - cwd, - releaseVersion: '0.0.1', - versionProbe: async () => '10.0.0', - }).run() - const result = await scaffoldProject(resolved.directory, resolved.request) - expect(result.project.cordis.entry('subagent-spawn')).toBeDefined() - expect(result.project.cordis.entry('tool-subagent')).toBeDefined() - }) - - it('confirms an empty provider key and leaves a documented .env placeholder', async () => { - const cwd = await mkdtemp(join(tmpdir(), 'create-empty-key-')) - temporary.push(cwd) - const port = new ScriptedPort([ - 'empty-key-agent', - '', - true, - [{ value: featureId('persistence'), choices: ['jsonl'] }], - 'none', - ]) - const resolved = await new CreateWizard({ - args: parseCreateArgs([ - 'empty-key-agent', '--description=test', '--provider=deepseek-official', - '--interface=embed', '--pm=npm', '--no-install', - ]), - port, - cwd, - releaseVersion: '0.0.1', - versionProbe: async () => '10.0.0', - }).run() - await scaffoldProject(resolved.directory, resolved.request) - expect(await readFile(join(resolved.directory, '.env'), 'utf8')).toBe( - '# Required before the first model request.\nDEEPSEEK_API_KEY=\n', - ) - expect(port.requests).toContain('Keep the API key empty and fill .env later?') - }) - - it('collects custom provider inputs, retries an empty key, and accepts a recommendation', async () => { - const cwd = await mkdtemp(join(tmpdir(), 'create-custom-inputs-')) - temporary.push(cwd) - const port = new ScriptedPort([ - 'custom-agent', - 'test custom provider', - 'custom', - 'https://provider.example/v1', - '', false, 'custom-key', - 'embed', - [ - { value: featureId('persistence'), choices: ['jsonl'] }, - { value: featureId('web'), choices: ['deepseek-official'] }, - ], - true, - 'none', - 'npm', - false, - ]) - const resolved = await new CreateWizard({ - args: parseCreateArgs(['custom-agent']), - port, - cwd, - releaseVersion: '0.0.1', - versionProbe: async () => '10.0.0', - userAgent: '', - }).run() - expect(resolved.request.features.find(item => item.id === 'provider')).toMatchObject({ - options: ['custom'], values: { baseURL: 'https://provider.example/v1' }, secrets: { apiKey: 'custom-key' }, - }) - expect(resolved.request.features.some(item => item.id === 'timeout-policy')).toBe(true) - }) - - it('does not re-suggest an already selected feature', async () => { - const cwd = await mkdtemp(join(tmpdir(), 'create-selected-suggestion-')) - temporary.push(cwd) - const port = new ScriptedPort([ - 'agent', - [ - { value: featureId('persistence'), choices: ['jsonl'] }, - { value: featureId('web'), choices: ['deepseek-official'] }, - { value: featureId('timeout-policy'), choices: ['default'] }, - ], - 'none', - ]) - const resolved = await new CreateWizard({ - args: parseCreateArgs([ - 'agent', '--description=test', '--provider=deepseek-official', '--api-key=key', - '--interface=embed', '--pm=npm', '--no-install', - ]), - port, - cwd, - releaseVersion: '0.0.1', - versionProbe: async () => '10.0.0', - }).run() - expect(resolved.request.features.filter(item => item.id === 'timeout-policy')).toHaveLength(1) - }) - - it('uses process defaults when constructor infrastructure is omitted', async () => { - const name = `default-infra-${String(process.pid)}` - const port = new ScriptedPort([ - name, [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none', - ]) - const resolved = await new CreateWizard({ - args: parseCreateArgs([ - name, '--description=test', '--provider=deepseek-official', '--api-key=key', - '--interface=embed', '--pm=npm', '--no-install', - ]), - port, - releaseVersion: '0.0.1', - }).run() - expect(resolved.request.packageManager.name).toBe('npm') - }) - - it('reads the release batch from the initializer package', async () => { - // The version tracks the release, including a prerelease such as 0.0.1-rc.1, - // so the expectation comes from the manifest rather than a literal. - const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) as { version: string } - - await expect(readCreateSdkVersion()).resolves.toBe(manifest.version) - }) -}) - -describe('create command composition', () => { - const argv = (directory: string, install: boolean): string[] => [ - directory, '--description=test', '--provider=deepseek-official', '--api-key=key', - '--interface=embed', '--pm=npm', install ? '--install' : '--no-install', - ] - - it('prints help before requiring a TTY and rejects non-interactive creation', async () => { - const root = await mkdtemp(join(tmpdir(), 'create-command-help-')) - temporary.push(root) - const context = commandContext(root) - context.stdin.isTTY = false - context.stdout.isTTY = false - await expect(createProject(['--help'], context)).resolves.toBeUndefined() - expect(context.readStdout()).toContain('Usage: create-sdk') - expect(context.readStdout()).toContain('--config-json ') - expect(context.readStdout()).not.toContain('--link-workspace') - await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY') - context.stdin.isTTY = true - await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY') - }) - - it('creates headlessly from --config-json with no TTY', async () => { - const root = await mkdtemp(join(tmpdir(), 'create-headless-cmd-')) - temporary.push(root) - const spec = JSON.stringify({ - directory: 'agent', description: 'test', provider: 'deepseek-official', apiKey: 'key', - model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: false, - features: [{ id: 'persistence', options: ['jsonl'] }], - }) - const context = commandContext(root) - context.stdin.isTTY = false - context.stdout.isTTY = false - const result = await createProject(['--config-json', spec], context) - expect(result?.project.root).toBe(join(root, 'agent')) - }) - - it('emits NDJSON lifecycle events under --json', async () => { - const root = await mkdtemp(join(tmpdir(), 'create-headless-json-')) - temporary.push(root) - const base = { - description: 'test', model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: false, - } - const ok = commandContext(root) - ok.stdin.isTTY = false - ok.stdout.isTTY = false - const okSpec = JSON.stringify({ ...base, directory: 'done-agent', provider: 'deepseek-official', apiKey: 'key', features: [] }) - await expect(runCreateCommand(['--config-json', okSpec, '--json'], ok)).resolves.toBe(0) - expect(ok.readStdout()).toContain('{"type":"done"}') - // stdout stays pure NDJSON: every line parses, human progress goes to stderr - for (const line of ok.readStdout().split('\n').filter(line => line.length > 0)) { - expect(() => { JSON.parse(line) }).not.toThrow() - } - expect(ok.readStderr()).toContain('Created done-agent') - expect(ok.readStderr()).toContain('Next: cd') - - const missing = commandContext(root) - missing.stdin.isTTY = false - missing.stdout.isTTY = false - const missingSpec = JSON.stringify({ ...base, directory: 'miss-agent', provider: 'custom', baseURL: 'https://x', features: [] }) - await expect(runCreateCommand(['--config-json', missingSpec, '--json'], missing)).resolves.toBe(1) - expect(missing.readStdout()).toContain('"type":"action-required"') - - const broken = commandContext(root) - broken.stdin.isTTY = false - broken.stdout.isTTY = false - await expect(runCreateCommand(['--config-json', '{bad', '--json'], broken)).resolves.toBe(1) - expect(broken.readStdout()).toContain('"type":"error"') - - const cancelled = commandContext(root, new ScriptedPort([ScriptedPort.cancel])) - await expect(runCreateCommand(['--json', ...argv('cancel-agent', false)], cancelled)).resolves.toBe(1) - expect(cancelled.readStdout()).toContain('"reason":"cancelled"') - }) - - it('creates through an injected prompt port and delegates optional setup', async () => { - const root = await mkdtemp(join(tmpdir(), 'create-command-success-')) - temporary.push(root) - const port = new ScriptedPort([ - 'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none', - ]) - let setupDirectory = '' - const context = commandContext(root, port, async (request) => { setupDirectory = request.directory }) - const result = await createProject(argv('agent', true), context) - expect(result?.project.root).toBe(join(root, 'agent')) - expect(setupDirectory).toBe(join(root, 'agent')) - expect(context.readStdout()).toContain('Created agent') - expect(context.readStdout()).toContain('Next: cd') - const noInstall = commandContext(root, new ScriptedPort([ - 'next', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none', - ])) - await expect(createProject(argv('next', false), noInstall)).resolves.toBeDefined() - expect(noInstall.readStdout()).toContain('npm install && npm run build && npm start') - }) - - it('uses the package manager setup path when no setup override is supplied', async () => { - const root = await mkdtemp(join(tmpdir(), 'create-command-default-setup-')) - temporary.push(root) - const port = new ScriptedPort([ - 'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none', - ]) - const install = vi.spyOn(NpmPackageManager.prototype, 'install').mockResolvedValue() - const build = vi.spyOn(NpmPackageManager.prototype, 'build').mockResolvedValue() - const context = commandContext(root, port) - delete context.releaseVersion - delete context.versionProbe - await createProject(argv('agent', true), context) - expect(install).toHaveBeenCalledOnce() - expect(build).toHaveBeenCalledOnce() - const spec = JSON.stringify({ - directory: 'json-agent', description: 'test', provider: 'deepseek-official', apiKey: 'key', - model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: true, features: [], - }) - const json = commandContext(root) - json.stdin.isTTY = false - json.stdout.isTTY = false - await createProject(['--config-json', spec, '--json'], json) - // json mode hands install/build a runner that redirects child output to stderr - expect(install).toHaveBeenCalledTimes(2) - expect(install.mock.calls[1]?.[1]).toBeInstanceOf(NodeCommandRunner) - install.mockRestore() - build.mockRestore() - }) - - it('reports setup failures after preserving generated files', async () => { - const root = await mkdtemp(join(tmpdir(), 'create-command-failure-')) - temporary.push(root) - const port = new ScriptedPort([ - 'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none', - ]) - const context = commandContext(root, port, async () => { throw new Error('offline') }) - await expect(createProject(argv('agent', true), context)).rejects.toThrow('offline') - expect(context.readStderr()).toContain('Project files are ready, but setup failed') - expect(context.readStderr()).toContain('npm install && npm run build') - const stringFailure = commandContext(root, new ScriptedPort([ - 'next', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none', - ]), async () => { throw 'offline-string' }) - await expect(runCreateCommand(argv('next', true), stringFailure)).resolves.toBe(1) - expect(stringFailure.readStderr()).toContain('offline-string') - }) - - it('maps cancellation and ordinary errors to command exit codes', async () => { - const root = await mkdtemp(join(tmpdir(), 'create-command-exit-')) - temporary.push(root) - const cancelled = commandContext(root, new ScriptedPort([ScriptedPort.cancel])) - await expect(runCreateCommand([], cancelled)).resolves.toBe(1) - expect(cancelled.readStderr()).toContain('cancelled') - const invalid = commandContext(root) - await expect(runCreateCommand(['--unknown'], invalid)).resolves.toBe(1) - expect(invalid.readStderr()).toContain('unknown option') - const help = commandContext(root) - await expect(runCreateCommand(['--help'], help)).resolves.toBe(0) - }) -}) - -describe('resolveHeadless', () => { - it('returns undefined without a config source', async () => { - expect(await resolveHeadless(parseCreateArgs(['agent']))).toBeUndefined() - }) - - it('maps every inline --config-json field into args plus the feature plan', async () => { - const spec = JSON.stringify({ - directory: 'a', description: 'd', provider: 'custom', baseURL: 'https://x', apiKey: 'k', - model: 'm', interface: 'acp', pm: 'pnpm', install: true, linkWorkspace: true, - features: [{ id: 'todo', options: ['default'] }], - }) - const resolved = await resolveHeadless(parseCreateArgs(['--config-json', spec])) - expect(resolved?.args).toMatchObject({ - directory: 'a', description: 'd', provider: 'custom', baseURL: 'https://x', apiKey: 'k', - model: 'm', runInterface: 'acp', packageManager: 'pnpm', install: true, linkWorkspace: true, help: false, - }) - expect(resolved?.features).toEqual([{ id: 'todo', options: ['default'] }]) - }) - - it('reads --config from a file via the injected reader and omits absent fields', async () => { - const resolved = await resolveHeadless( - parseCreateArgs(['--config', '/spec.json']), - async () => JSON.stringify({ description: 'from-file' }), - ) - expect(resolved?.args.description).toBe('from-file') - expect(resolved?.args.directory).toBeUndefined() - expect(resolved?.args.linkWorkspace).toBeUndefined() - expect(resolved?.features).toBeUndefined() - }) - - it('reads --config from disk with the default reader', async () => { - const dir = await mkdtemp(join(tmpdir(), 'create-headless-file-')) - temporary.push(dir) - const file = join(dir, 'spec.json') - await writeFile(file, JSON.stringify({ description: 'on-disk' })) - const resolved = await resolveHeadless(parseCreateArgs(['--config', file])) - expect(resolved?.args.description).toBe('on-disk') - }) - - it('fails loud on invalid JSON, a non-object root, or a non-array features field', async () => { - await expect(resolveHeadless(parseCreateArgs(['--config-json', '{bad']))).rejects.toThrow('invalid JSON') - await expect(resolveHeadless(parseCreateArgs(['--config-json', '[]']))).rejects.toThrow('expected a JSON object') - await expect(resolveHeadless(parseCreateArgs(['--config-json', 'null']))).rejects.toThrow('expected a JSON object') - await expect(resolveHeadless(parseCreateArgs(['--config-json', '5']))).rejects.toThrow('expected a JSON object') - await expect(resolveHeadless(parseCreateArgs(['--config-json', '{"features":1}']))).rejects.toThrow('must be an array') - }) - - it('accepts a minimal spec, leaving unspecified answers undefined', async () => { - const resolved = await resolveHeadless(parseCreateArgs(['--config-json', '{"directory":"x"}'])) - expect(resolved?.args.directory).toBe('x') - expect(resolved?.args.description).toBeUndefined() - expect(resolved?.features).toBeUndefined() - }) -}) diff --git a/packages/scaffold/create-sdk/tests/link-workspace.e2e.ts b/packages/scaffold/create-sdk/tests/link-workspace.e2e.ts deleted file mode 100644 index 0e995202fd..0000000000 --- a/packages/scaffold/create-sdk/tests/link-workspace.e2e.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { execFile } from 'node:child_process' -import { existsSync } from 'node:fs' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { homedir, tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { promisify } from 'node:util' -import { afterEach, describe, expect, it } from 'vitest' -import { - LocalPluginBlueprint, - featureId, - createPackageManager, - type PackageManagerName, -} from '@deepseek-ai/dsh-helper' -import { scrubEnvironment } from '../../helper/src/package-managers/package-manager.ts' -import { scaffoldProject } from '../src/project-scaffolder.ts' - -const execFileAsync = promisify(execFile) -const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) -const builtScripts = join(repoRoot, 'packages/scaffold/scripts/lib/bin.js') -const temporary: string[] = [] - -function resolveCorepackHome(): string { - return process.env.COREPACK_HOME ?? join( - process.env.XDG_CACHE_HOME - ?? process.env.LOCALAPPDATA - ?? join(homedir(), process.platform === 'win32' ? 'AppData/Local' : '.cache'), - 'node/corepack', - ) -} - -afterEach(async () => { - await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true }))) -}) - -async function managerVersion(name: PackageManagerName): Promise { - try { - return (await execFileAsync(name, ['--version'], { encoding: 'utf8' })).stdout.trim() - } catch { - // An unavailable optional manager skips only its own live-link case. - return undefined - } -} - -const managers: PackageManagerName[] = ['npm', 'pnpm', 'yarn'] - -describe.skipIf(!existsSync(builtScripts))('live-linked generated projects', () => { - for (const name of managers) { - it(`${name}: installs the local closure and resolves plugin TypeScript in dev`, async (context) => { - const version = await managerVersion(name) - if (!version) { - context.skip() - return - } - const parent = await mkdtemp(join(tmpdir(), `dsh-link-${name}-`)) - const root = join(parent, 'project') - temporary.push(parent) - const manager = createPackageManager(name, version) - await scaffoldProject(root, { - name: `linked-${name}`, - description: 'link e2e', - runtime: { model: 'deepseek-v4-flash' }, - packageManager: manager, - releaseVersion: '0.0.1', - linkWorkspaceRoot: repoRoot, - features: [ - { id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'test-key' } }, - { id: featureId('bash'), options: ['local'] }, - { id: featureId('app'), options: ['embed'] }, - { id: featureId('persistence'), options: ['jsonl'] }, - ], - localPlugins: [new LocalPluginBlueprint('probe', 'plugin')], - }) - await writeFile(join(root, 'plugins/probe/src/index.ts'), ` - import { writeFileSync } from 'node:fs' - import type { Context } from '@deepseek-ai/cordis' - export const name = 'probe' - export function apply(_ctx: Context): void { - writeFileSync(new URL('../../../plugin-loaded', import.meta.url), 'loaded\\n') - } - `) - const cacheRoot = join(tmpdir(), 'dsh-sdk-link-cache', name) - const pnpmStore = name === 'pnpm' - ? (await execFileAsync(name, ['store', 'path', '--silent'], { encoding: 'utf8' })).stdout.trim() - : undefined - const commandEnvironment = { - ...scrubEnvironment(), - COREPACK_HOME: resolveCorepackHome(), - ...name === 'pnpm' ? {} : { XDG_CACHE_HOME: join(cacheRoot, 'cache') }, - XDG_DATA_HOME: join(cacheRoot, 'data'), - npm_config_cache: join(cacheRoot, 'npm'), - ...pnpmStore === undefined ? {} : { pnpm_config_store_dir: pnpmStore }, - // A generated project has no lockfile yet; ambient CI must not make its first Yarn install immutable. - ...name === 'yarn' ? { YARN_ENABLE_IMMUTABLE_INSTALLS: 'false' } : {}, - } - await execFileAsync(name, manager.installCommand(), { - cwd: root, - env: commandEnvironment, - encoding: 'utf8', - timeout: 120_000, - }) - await execFileAsync(name, manager.buildCommand(), { - cwd: root, - env: commandEnvironment, - encoding: 'utf8', - timeout: 120_000, - }) - expect(existsSync(join(root, 'index.js'))).toBe(true) - expect(existsSync(join(root, 'plugins/probe/lib/index.js'))).toBe(true) - const dshSdk = join(root, 'node_modules/@deepseek-ai/dsh-scripts/lib/bin.js') - const run = await execFileAsync(process.execPath, [dshSdk, 'dev', 'index.ts'], { - cwd: root, - env: { ...commandEnvironment, DEEPSEEK_API_KEY: 'test-key' }, - encoding: 'utf8', - timeout: 30_000, - }) - expect(run.stderr).not.toContain('without inject') - expect(await readFile(join(root, 'plugin-loaded'), 'utf8')).toBe('loaded\n') - const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { - dependencies: Record - } - expect(manifest.dependencies['@deepseek-ai/cordis']).toMatch(name === 'npm' ? /^file:/ : name === 'pnpm' ? /^link:/ : /^portal:/) - expect(manifest.dependencies).not.toHaveProperty('node-addon-require-builtin') - }, 180_000) - } -}) diff --git a/packages/scaffold/create-sdk/tsconfig.json b/packages/scaffold/create-sdk/tsconfig.json deleted file mode 100644 index e2ed951fa4..0000000000 --- a/packages/scaffold/create-sdk/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": ["src"], - "references": [ - { - "path": "../helper" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../support/invariants" - } - ] -} diff --git a/packages/scaffold/create-sdk/tsdown.config.ts b/packages/scaffold/create-sdk/tsdown.config.ts deleted file mode 100644 index 08d522590e..0000000000 --- a/packages/scaffold/create-sdk/tsdown.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from 'tsdown' - -/** Bundle the library and create bin, then mirror package-owned terminal templates. */ -export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, - copy: [{ from: 'src/templates/assets/*', to: 'lib/assets' }], -}) diff --git a/packages/scaffold/helper/README.i18n.yaml b/packages/scaffold/helper/README.i18n.yaml deleted file mode 100644 index a62db5f38a..0000000000 --- a/packages/scaffold/helper/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 packages/scaffold/helper/README.md -README.md: 416fcab9815e50ca662333eb6925cc37eb0c41c4 -README.zh.md: c50ec7aafa03377bd11759c50eeb2422a12fab68 diff --git a/packages/scaffold/helper/README.md b/packages/scaffold/helper/README.md deleted file mode 100644 index 416fcab981..0000000000 --- a/packages/scaffold/helper/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# `@deepseek-ai/dsh-helper` - -English | [中文](README.zh.md) - -Shared project domain and infrastructure for `create-sdk` and `dsh-sdk config`. `SdkProject` is a read-only snapshot; `ProjectEditSession` is the only mutation and commit boundary. The [SDK architecture Agent Note](../../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) owns the rationale. - -The package owns the builtin typed-spec catalog, provider/app behavior entities, structured project file objects, helper-owned project templates, the shared typed `TextTemplate` renderer, package-manager strategies, local-plugin blueprints, typed questions, and the clack prompt adapter. It never boots a Cordis application. - -All business and document validation completes before commit writes any affected file. Commit detects external edits made after the session opened, but deliberately provides no cross-file rollback after writing starts. - -Builtin features are provider, bash, app, persistence, HMR, filesystem, todo, skill, web, subagent, workflow, compaction, hooks, repeat-tool guard, and timeout policy. The catalog owns feature options, required and non-default Cordis plugin config, feature requirements, resource contribution, and round-trip markers; create and config use the same registry and configurator. The ACP app option contributes only the automation bridge; interactive services belong to host compositions. - -`SdkProject.open()` requires only readable root `package.json` and `cordis.yml`, but rejects a config that references the removed `@deepseek-ai/dsh-tui` root or a subpath. A Cordis config entry anchors feature installation; a package present only through a linked NPM dependency closure leaves the feature absent. Once an owned Cordis config entry exists, an incomplete resource shape is `inconsistent` and cannot be modified automatically. - -`.env.example` follows the currently selected features. `.env` is append-only: helper may add a missing differently named variable, but never updates or removes existing content. - -The package root explicitly exports only the objects consumed by `create-sdk` and `dsh-scripts`; internal modules have no `src/*` or package-manifest subpath export. - -## Model Experience - -None, as the project domain edits files and never mounts a live agent or model request. - -#### KV Cache effect - -None; this package neither assembles nor sends a provider request. - -## Known Limitations and Deferred Work - -- **Commit is not transactional across files** — external edits are detected before each write, but a later failure does not roll back files already written. diff --git a/packages/scaffold/helper/README.zh.md b/packages/scaffold/helper/README.zh.md deleted file mode 100644 index c50ec7aafa..0000000000 --- a/packages/scaffold/helper/README.zh.md +++ /dev/null @@ -1,29 +0,0 @@ -# `@deepseek-ai/dsh-helper` - -[English](README.md) | 中文 - -供 `create-sdk` 与 `dsh-sdk config` 共用的项目领域和基础设施。`SdkProject` 是只读快照;`ProjectEditSession` 是唯一的变更与提交边界。设计理由由 [SDK 架构 Agent Note(agent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) 负责。 - -该包(package)负责内置的类型化 spec 目录、提供方/应用行为实体、结构化项目文件对象、helper 自有项目模板、共享的类型化 `TextTemplate` 渲染器、包管理器策略、本地插件蓝图、类型化问题,以及 clack 交互提示适配器。它绝不会启动 Cordis 应用。 - -所有业务验证与文档验证都会在提交写入任何受影响文件前完成。提交会检测编辑会话打开后发生的外部修改,但在开始写入后,有意不提供跨文件回滚。 - -内置功能包括提供方、bash、app、持久化、HMR(热模块替换)、filesystem、todo、skill(技能)、web、subagent、工作流、压缩(compaction)、钩子、repeat-tool guard 和 timeout policy。目录负责功能选项、必需和非默认 Cordis 插件配置、功能依赖、资源贡献与往返标记;create 与 config 使用同一注册表和配置器。ACP(Agent Client Protocol)应用选项只贡献自动化桥;交互式服务属于宿主组合。 - -`SdkProject.open()` 只要求根目录下的 `package.json` 和 `cordis.yml` 可读,但会拒绝引用已移除的 `@deepseek-ai/dsh-tui` 包根或其子路径的配置。Cordis 配置项用于锚定功能安装;如果某个包只存在于链接的 NPM 依赖闭包中,则该功能仍视为不存在。一旦所属的 Cordis 配置项存在,资源结构不完整就是 `inconsistent`,无法自动修改。 - -`.env.example` 跟随当前所选功能。`.env` 仅追加:helper 可以补充缺失且名称不同的变量,但绝不会更新或删除现有内容。 - -包根明确只导出 `create-sdk` 和 `dsh-scripts` 使用的对象;内部模块不提供 `src/*` 或 package-manifest 子路径导出。 - -## 模型体验 - -无。项目领域只编辑文件,绝不会挂载运行中的 agent(智能体),也不会发起模型请求。 - -#### KV Cache 影响 - -无;此包既不组装也不发送提供方请求。 - -## 已知限制与暂缓事项 - -- **提交不具备跨文件事务性**:每次写入前都会检测外部修改,但后续失败不会回滚已经写入的文件。 diff --git a/packages/scaffold/helper/package.json b/packages/scaffold/helper/package.json deleted file mode 100644 index 74df5f76a8..0000000000 --- a/packages/scaffold/helper/package.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-helper", - "description": "Domain model and infrastructure for creating and editing DeepSeek Harness SDK projects", - "version": "0.0.1-rc.1", - "publishConfig": { - "access": "restricted" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/scaffold/helper" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - } - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/assets", - "lib/types/**/*.d.ts" - ], - "license": "BSD-3-Clause", - "dependencies": { - "@clack/core": "^1.4.3", - "@clack/prompts": "^1.7.0", - "handlebars": "^4.7.9", - "jsonc-parser": "^3.3.1", - "yaml": "^2.9.0" - }, - "peerDependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-hooks-claude": "workspace:^", - "@deepseek-ai/dsh-hooks-codex": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", - "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/dsh-tool-subagent": "workspace:^", - "@deepseek-ai/dsh-tool-todo": "workspace:^", - "@deepseek-ai/dsh-tool-web": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - } -} diff --git a/packages/scaffold/helper/src/documents/cordis-yaml-file.ts b/packages/scaffold/helper/src/documents/cordis-yaml-file.ts deleted file mode 100644 index 39fd8fd596..0000000000 --- a/packages/scaffold/helper/src/documents/cordis-yaml-file.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Comment-preserving Cordis YAML document and `!!js` expression value. - * - * @module @deepseek-ai/dsh-helper/documents/cordis-yaml-file - */ - -import { - Document, isMap, isSeq, parseDocument, visit, YAMLMap, YAMLSeq, - type ScalarTag, -} from 'yaml' -import { ProjectFile, withTrailingNewline } from './project-file.ts' - -/** Explicit JavaScript expression serialized with Cordis' `!!js` YAML tag. */ -export class JsExpression { - /** Expression source evaluated by the Cordis include loader. */ - readonly source: string - - /** Create an expression value. */ - constructor(source: string) { - if (source.trim().length === 0) throw new Error('JavaScript expression must not be empty') - this.source = source - } - - /** Return expression source for YAML scalar stringification. */ - toString(): string { - return this.source - } -} - -const JS_EXPRESSION_TAG: ScalarTag = { - tag: 'tag:yaml.org,2002:js', - identify: value => value instanceof JsExpression, - resolve: value => new JsExpression(value), - stringify: item => String(item.value), -} - -/** Plain domain representation of one top-level Cordis config entry. */ -export interface CordisConfigEntry { - id: string - name: string - config?: Record - disabled?: boolean -} - -function parseYaml(text: string): Document.Parsed { - const document = parseDocument(text, { - customTags: [JS_EXPRESSION_TAG], - keepSourceTokens: true, - prettyErrors: true, - }) - if (document.errors.length > 0) { - throw new Error(`invalid cordis.yml: ${document.errors.map(error => error.message).join('; ')}`) - } - if (!isSeq(document.contents)) throw new Error('invalid cordis.yml: root must be a sequence') - visit(document, { Collection: (_key, collection) => { collection.flow = false } }) - return document -} - -function entryFromValue(value: unknown): CordisConfigEntry { - /* v8 ignore next -- entries() calls this only after requiring a YAMLMap, whose JSON value is an object */ - if (value === null || Array.isArray(value) || typeof value !== 'object') { - throw new Error('invalid cordis.yml entry: expected an object') - } - const entry = value as Record - if (typeof entry.id !== 'string' || entry.id.length === 0) { - throw new Error('invalid cordis.yml entry: id must be a non-empty string') - } - if (typeof entry.name !== 'string' || entry.name.length === 0) { - throw new Error(`invalid cordis.yml entry ${entry.id}: name must be a non-empty string`) - } - if (entry.config !== undefined - && (entry.config === null || Array.isArray(entry.config) || typeof entry.config !== 'object')) { - throw new Error(`invalid cordis.yml entry ${entry.id}: plugin config must be an object`) - } - if (entry.disabled !== undefined && typeof entry.disabled !== 'boolean') { - throw new Error(`invalid cordis.yml entry ${entry.id}: disabled must be boolean`) - } - return { - id: entry.id, - name: entry.name, - ...entry.config !== undefined ? { config: entry.config as Record } : {}, - ...entry.disabled !== undefined ? { disabled: entry.disabled } : {}, - } -} - -/** Editable top-level cordis.yml using YAML's document API. */ -export class CordisYamlFile extends ProjectFile { - private readonly document: Document.Parsed - - private constructor(document: Document.Parsed, originalText?: string) { - super('cordis.yml', originalText) - this.document = document - } - - /** Create an empty Cordis config entry list. */ - static create(): CordisYamlFile { - return new CordisYamlFile(parseYaml('[]\n')) - } - - /** Parse an existing cordis.yml while retaining comments and scalar styles. */ - static parse(text: string): CordisYamlFile { - return new CordisYamlFile(parseYaml(text), text) - } - - /** Clone through YAML text so the edit session owns an independent AST. */ - override clone(): CordisYamlFile { - return new CordisYamlFile(parseYaml(this.serialize()), this.originalText) - } - - private sequence(): YAMLSeq { - /* v8 ignore next -- parseYaml and create both establish a sequence root */ - if (!isSeq(this.document.contents)) throw new Error('cordis.yml root is not a sequence') - return this.document.contents - } - - private entryNode(id: string): YAMLMap | undefined { - for (const item of this.sequence().items) { - if (!isMap(item)) continue - if (item.get('id') === id) return item - } - return undefined - } - - /** Return defensive plain entry values in file order. */ - entries(): CordisConfigEntry[] { - return this.sequence().items.map((item) => { - if (!isMap(item)) throw new Error('invalid cordis.yml: every entry must be a mapping') - return entryFromValue(item.toJSON()) - }) - } - - /** Find one entry by stable id. */ - entry(id: string): CordisConfigEntry | undefined { - return this.entries().find(entry => entry.id === id) - } - - /** Add one new top-level entry, rejecting duplicate ids. */ - addEntry(entry: CordisConfigEntry, commentedExample?: string): void { - if (this.entryNode(entry.id)) throw new Error(`Cordis config entry already exists: ${entry.id}`) - const node = this.document.createNode(entry) - if (commentedExample) node.comment = commentedExample.split('\n').map(line => ` ${line}`).join('\n') - this.sequence().items.push(node) - } - - /** Remove an entry by id and report whether it existed. */ - removeEntry(id: string): boolean { - const sequence = this.sequence() - const index = sequence.items.findIndex(item => isMap(item) && item.get('id') === id) - if (index < 0) return false - sequence.items.splice(index, 1) - return true - } - - /** Enable or disable an entry through the Loader-native field. */ - setDisabled(id: string, disabled: boolean): void { - const node = this.entryNode(id) - if (!node) throw new Error(`Cordis config entry does not exist: ${id}`) - if (disabled) node.set('disabled', true) - else node.delete('disabled') - } - - /** Replace only owned plugin config keys while retaining unknown user keys. */ - updateOwnedConfig(id: string, ownedKeys: readonly string[], next: Record): void { - const entry = this.entryNode(id) - if (!entry) throw new Error(`Cordis config entry does not exist: ${id}`) - let config: unknown = entry.get('config', true) - if (config === undefined || config === null) { - config = new YAMLMap() - entry.set('config', config) - } - if (!isMap(config)) throw new Error(`Cordis config entry ${id} plugin config is not a mapping`) - for (const key of ownedKeys) config.delete(key) - for (const [key, value] of Object.entries(next)) config.set(key, this.document.createNode(value)) - if (config.items.length === 0) entry.delete('config') - } - - /** Validate ids, names, plugin config maps, and id uniqueness. */ - override validate(): void { - const seen = new Set() - for (const entry of this.entries()) { - if (seen.has(entry.id)) throw new Error(`duplicate Cordis config entry id: ${entry.id}`) - seen.add(entry.id) - } - } - - /** Serialize through the YAML document while retaining untouched trivia. */ - override serialize(): string { - return withTrailingNewline(this.document.toString({ lineWidth: 0 })) - } -} diff --git a/packages/scaffold/helper/src/documents/env-file.ts b/packages/scaffold/helper/src/documents/env-file.ts deleted file mode 100644 index d0e6ca8473..0000000000 --- a/packages/scaffold/helper/src/documents/env-file.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Ownership-aware, line-preserving dotenv document. - * - * @module @deepseek-ai/dsh-helper/documents/env-file - */ - -import { ProjectFile, withTrailingNewline } from './project-file.ts' - -interface ParsedVariable { - index: number - value: string -} - -const VARIABLE = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/ -const VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/ - -/** `.env` appends missing variables; `.env.example` supports managed replacement and removal. */ -export class EnvFile extends ProjectFile { - private readonly lines: string[] - - private constructor(relativePath: '.env' | '.env.example', lines: string[], originalText?: string) { - super(relativePath, originalText, relativePath === '.env' ? 0o600 : undefined) - this.lines = [...lines] - } - - /** Create an empty environment file. */ - static create(relativePath: '.env' | '.env.example'): EnvFile { - return new EnvFile(relativePath, []) - } - - /** Parse an existing environment file without rewriting unknown lines. */ - static parse(relativePath: '.env' | '.env.example', text: string): EnvFile { - const normalized = text.replace(/\n$/, '') - return new EnvFile(relativePath, normalized.length === 0 ? [] : normalized.split('\n'), text) - } - - /** Clone the current line model. */ - override clone(): EnvFile { - return new EnvFile(this.relativePath as '.env' | '.env.example', this.lines, this.originalText) - } - - private variables(): Map { - const values = new Map() - this.lines.forEach((line, index) => { - const match = VARIABLE.exec(line) - if (!match) return - const name = match[1] - const value = match[2] - /* v8 ignore next -- both captures are mandatory in VARIABLE */ - if (name === undefined || value === undefined) return - const occurrences = values.get(name) ?? [] - occurrences.push({ index, value }) - values.set(name, occurrences) - }) - return values - } - - /** Read the effective value; append-only `.env` accepts duplicates and uses the last declaration. */ - get(name: string): string | undefined { - const occurrences = this.variables().get(name) ?? [] - if (this.relativePath === '.env.example' && occurrences.length > 1) { - throw new Error(`${this.relativePath} contains duplicate variable ${name}`) - } - return occurrences.at(-1)?.value - } - - /** Add or replace one SDK-managed `.env.example` variable while preserving unrelated lines. */ - set(name: string, value: string): void { - if (this.relativePath !== '.env.example') throw new Error('.env is append-only') - if (!VARIABLE_NAME.test(name)) throw new Error(`invalid environment variable name: ${name}`) - const occurrences = this.variables().get(name) ?? [] - if (occurrences.length > 1) throw new Error(`${this.relativePath} contains duplicate variable ${name}`) - const line = `${name}=${value}` - if (occurrences[0]) this.lines[occurrences[0].index] = line - else this.lines.push(line) - } - - /** Append a missing `.env` variable and optional comment without changing any existing declaration. */ - append(name: string, value: string, comment?: string): boolean { - if (this.relativePath !== '.env') throw new Error('.env.example is SDK-managed') - if (!VARIABLE_NAME.test(name)) throw new Error(`invalid environment variable name: ${name}`) - if (comment !== undefined && (!comment || comment.includes('\n'))) { - throw new Error('environment comment must be one non-empty line') - } - if (this.variables().has(name)) return false - if (comment) this.lines.push(`# ${comment}`) - this.lines.push(`${name}=${value}`) - return true - } - - /** Remove one SDK-managed `.env.example` variable while retaining every other line. */ - remove(name: string): void { - if (this.relativePath !== '.env.example') throw new Error('.env is append-only') - const occurrences = this.variables().get(name) ?? [] - if (occurrences.length > 1) throw new Error(`${this.relativePath} contains duplicate variable ${name}`) - if (occurrences[0]) this.lines.splice(occurrences[0].index, 1) - } - - /** Validate the managed placeholder file; append-only `.env` accepts duplicate declarations. */ - override validate(): void { - if (this.relativePath === '.env') return - for (const [name, occurrences] of this.variables()) { - if (occurrences.length > 1) throw new Error(`${this.relativePath} contains duplicate variable ${name}`) - } - } - - /** Serialize all retained lines with one trailing newline. */ - override serialize(): string { - return withTrailingNewline(this.lines.join('\n')) - } -} diff --git a/packages/scaffold/helper/src/documents/package-json-file.ts b/packages/scaffold/helper/src/documents/package-json-file.ts deleted file mode 100644 index f565f383d2..0000000000 --- a/packages/scaffold/helper/src/documents/package-json-file.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Structured package.json document owned by an SDK project. - * - * @module @deepseek-ai/dsh-helper/documents/package-json-file - */ - -import { ProjectFile, withTrailingNewline } from './project-file.ts' - -/** NPM dependency sections managed by the SDK. */ -export type NpmDependencySection = 'dependencies' | 'devDependencies' - -/** JSON shape retained by {@link PackageJsonFile}. */ -export interface PackageManifest { - name?: string - version?: string - private?: boolean - description?: string - type?: string - packageManager?: string - scripts?: Record - dependencies?: Record - devDependencies?: Record - workspaces?: string[] - resolutions?: Record - [key: string]: unknown -} - -function parseManifest(text: string): PackageManifest { - let value: unknown - try { - value = JSON.parse(text) - } catch (error) { - throw new Error(`invalid package.json: ${String(error)}`) - } - if (value === null || Array.isArray(value) || typeof value !== 'object') { - throw new Error('invalid package.json: root must be an object') - } - return value as PackageManifest -} - -function sortedRecord(value: Record): Record { - return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))) -} - -/** Editable, deterministic package.json representation. */ -export class PackageJsonFile extends ProjectFile { - private readonly manifest: PackageManifest - - private constructor(manifest: PackageManifest, originalText?: string) { - super('package.json', originalText) - this.manifest = structuredClone(manifest) - } - - /** Create a new package manifest from a complete rendered template. */ - static create(text: string): PackageJsonFile { - return new PackageJsonFile(parseManifest(text)) - } - - /** Parse an existing package.json document. */ - static parse(text: string): PackageJsonFile { - return new PackageJsonFile(parseManifest(text), text) - } - - /** Clone this document and its nested manifest data. */ - override clone(): PackageJsonFile { - return new PackageJsonFile(this.manifest, this.originalText) - } - - /** Return a defensive copy of the manifest. */ - value(): Readonly { - return structuredClone(this.manifest) - } - - /** Set one package script. */ - setScript(name: string, command: string): void { - this.manifest.scripts ??= {} - this.manifest.scripts[name] = command - } - - /** Read one package script. */ - script(name: string): string | undefined { - return this.manifest.scripts?.[name] - } - - /** Remove one package script. */ - removeScript(name: string): void { - delete this.manifest.scripts?.[name] - } - - /** Set one NPM dependency in its runtime or development section. */ - setNpmDependency(section: NpmDependencySection, name: string, spec: string): void { - this.manifest[section] ??= {} - this.manifest[section][name] = spec - } - - /** Remove one NPM dependency from a section. */ - removeNpmDependency(section: NpmDependencySection, name: string): void { - delete this.manifest[section]?.[name] - } - - /** Read an NPM dependency spec from either managed section. */ - npmDependency(name: string): { section: NpmDependencySection; spec: string } | undefined { - for (const section of ['dependencies', 'devDependencies'] as const) { - const spec = this.manifest[section]?.[name] - if (spec !== undefined) return { section, spec } - } - return undefined - } - - /** Return all managed NPM dependency names. */ - npmDependencyNames(): string[] { - return [...new Set([ - ...Object.keys(this.manifest.dependencies ?? {}), - ...Object.keys(this.manifest.devDependencies ?? {}), - ])].sort() - } - - /** Add a package-manager workspace glob. */ - addWorkspace(pattern: string): void { - const workspaces = this.manifest.workspaces ??= [] - if (!workspaces.includes(pattern)) workspaces.push(pattern) - } - - /** Set or remove the packageManager field. */ - setPackageManager(value: string | undefined): void { - if (value === undefined) delete this.manifest.packageManager - else this.manifest.packageManager = value - } - - /** Pin a Yarn resolution used by live-link projects. */ - setResolution(name: string, spec: string): void { - this.manifest.resolutions ??= {} - this.manifest.resolutions[name] = spec - } - - /** Validate the fields the SDK relies on. */ - override validate(): void { - if (!this.manifest.name || typeof this.manifest.name !== 'string') { - throw new Error('package.json name must be a non-empty string') - } - for (const section of ['scripts', 'dependencies', 'devDependencies'] as const) { - const value: unknown = this.manifest[section] - if (value === undefined) continue - if (value === null || Array.isArray(value) || typeof value !== 'object') { - throw new Error(`package.json ${section} must be an object`) - } - for (const [key, item] of Object.entries(value)) { - if (typeof item !== 'string' || item.length === 0) { - throw new Error(`package.json ${section}.${key} must be a non-empty string`) - } - } - } - if (this.manifest.workspaces !== undefined - && (!Array.isArray(this.manifest.workspaces) || this.manifest.workspaces.some(item => typeof item !== 'string'))) { - throw new Error('package.json workspaces must be an array of strings') - } - } - - /** Serialize with deterministic managed maps and two-space JSON formatting. */ - override serialize(): string { - const value: PackageManifest = structuredClone(this.manifest) - if (this.manifest.scripts) value.scripts = sortedRecord(this.manifest.scripts) - if (this.manifest.dependencies) value.dependencies = sortedRecord(this.manifest.dependencies) - if (this.manifest.devDependencies) value.devDependencies = sortedRecord(this.manifest.devDependencies) - if (this.manifest.workspaces) value.workspaces = [...this.manifest.workspaces].sort() - if (this.manifest.resolutions) value.resolutions = sortedRecord(this.manifest.resolutions) - return withTrailingNewline(JSON.stringify(value, null, 2)) - } -} diff --git a/packages/scaffold/helper/src/documents/pnpm-workspace-file.ts b/packages/scaffold/helper/src/documents/pnpm-workspace-file.ts deleted file mode 100644 index b980434bad..0000000000 --- a/packages/scaffold/helper/src/documents/pnpm-workspace-file.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Structured pnpm workspace configuration for generated SDK projects. - * - * @module @deepseek-ai/dsh-helper/documents/pnpm-workspace-file - */ - -import { - isMap, isScalar, isSeq, parseDocument, - type Document, type Scalar, type YAMLMap, type YAMLSeq, -} from 'yaml' -import { ProjectFile, withTrailingNewline } from './project-file.ts' - -function parseYaml(text: string): Document.Parsed { - const document = parseDocument(text, { keepSourceTokens: true, prettyErrors: true }) - if (document.errors.length > 0) { - throw new Error(`invalid pnpm-workspace.yaml: ${document.errors.map(error => error.message).join('; ')}`) - } - if (!isMap(document.contents)) throw new Error('pnpm-workspace.yaml root must be an object') - return document -} - -/** Generated pnpm-workspace.yaml model. */ -export class PnpmWorkspaceFile extends ProjectFile { - private readonly document: Document.Parsed - - private constructor(document: Document.Parsed, originalText?: string) { - super('pnpm-workspace.yaml', originalText) - this.document = document - } - - /** Create a pnpm workspace document. */ - static create(): PnpmWorkspaceFile { - const document = new PnpmWorkspaceFile(parseYaml('{}\n')) - document.mapping().set('packages', document.document.createNode([])) - document.mapping().set('allowBuilds', document.document.createNode({ esbuild: true })) - return document - } - - /** Parse the workspace fields the SDK owns while retaining all other YAML. */ - static parse(text: string): PnpmWorkspaceFile { - const document = new PnpmWorkspaceFile(parseYaml(text), text) - document.packageSequence() - const autoInstallPeers = document.mapping().get('autoInstallPeers') - if (autoInstallPeers !== undefined && typeof autoInstallPeers !== 'boolean') { - throw new Error('pnpm-workspace.yaml autoInstallPeers must be boolean') - } - return document - } - - /** Clone the complete comment-preserving workspace document. */ - override clone(): PnpmWorkspaceFile { - return new PnpmWorkspaceFile(parseYaml(this.serialize()), this.originalText) - } - - /** Add one package workspace glob. */ - addPackage(pattern: string): void { - const packages = this.packageSequence() - if (packages.items.some(item => item.value === pattern)) return - packages.add(this.document.createNode(pattern)) - } - - /** Disable registry peer auto-installation for live-link projects. */ - disableAutoInstallPeers(): void { - this.mapping().set('autoInstallPeers', false) - } - - /** Validate workspace globs. */ - override validate(): void { - for (const pattern of this.packageValues()) { - if (pattern.trim().length === 0) throw new Error('pnpm workspace pattern must not be empty') - } - } - - /** Serialize the workspace while retaining unknown settings and comments. */ - override serialize(): string { - return withTrailingNewline(this.document.toString({ lineWidth: 0 })) - } - - private mapping(): YAMLMap { - /* v8 ignore next -- parseYaml and create both establish a mapping root */ - if (!isMap(this.document.contents)) throw new Error('pnpm-workspace.yaml root must be an object') - return this.document.contents - } - - private packageSequence(): YAMLSeq> { - const packages = this.mapping().get('packages', true) - if (!isSeq(packages) || packages.items.some(item => !isScalar(item) || typeof item.value !== 'string')) { - throw new Error('pnpm-workspace.yaml packages must be an array of strings') - } - return packages as YAMLSeq> - } - - private packageValues(): string[] { - return this.packageSequence().items.map(item => item.value) - } -} diff --git a/packages/scaffold/helper/src/documents/project-file.ts b/packages/scaffold/helper/src/documents/project-file.ts deleted file mode 100644 index 9be20c3395..0000000000 --- a/packages/scaffold/helper/src/documents/project-file.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Base abstraction for one file in an SDK project snapshot. - * - * @module @deepseek-ai/dsh-helper/documents/project-file - */ - -/** Return text with exactly one trailing newline. */ -export function withTrailingNewline(text: string): string { - return text.replace(/\n*$/, '') + '\n' -} - -/** One cloneable, validatable project file. */ -export abstract class ProjectFile { - /** Project-relative POSIX path. */ - readonly relativePath: string - - /** Text observed when the document entered the snapshot; absent for a new file. */ - readonly originalText: string | undefined - - /** Permission bits used only when the file is first created. */ - readonly createMode: number | undefined - - protected constructor(relativePath: string, originalText?: string, createMode?: number) { - if (relativePath.startsWith('/') || relativePath.split('/').includes('..')) { - throw new Error(`project document path must stay inside the project: ${relativePath}`) - } - this.relativePath = relativePath - this.originalText = originalText - this.createMode = createMode - } - - /** Clone the document for an isolated edit session. */ - abstract clone(): ProjectFile - - /** Validate the document's complete current state. */ - abstract validate(): void - - /** Serialize the complete current file. */ - abstract serialize(): string -} - -/** Immutable complete-text file used by one-shot artifacts. */ -export class TextProjectFile extends ProjectFile { - private readonly text: string - - /** Create a complete-text project document. */ - constructor(relativePath: string, text: string, originalText?: string) { - super(relativePath, originalText) - this.text = withTrailingNewline(text) - } - - /** Clone this immutable document. */ - override clone(): TextProjectFile { - return new TextProjectFile(this.relativePath, this.text, this.originalText) - } - - /** Complete text artifacts have no extra structural validation. */ - override validate(): void {} - - /** Return the complete artifact text. */ - override serialize(): string { - return this.text - } -} diff --git a/packages/scaffold/helper/src/documents/tsconfig-file.ts b/packages/scaffold/helper/src/documents/tsconfig-file.ts deleted file mode 100644 index 2f61e10951..0000000000 --- a/packages/scaffold/helper/src/documents/tsconfig-file.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Comment-preserving root tsconfig editor for local plugin references. - * - * @module @deepseek-ai/dsh-helper/documents/tsconfig-file - */ - -import { applyEdits, modify, parse, type ParseError } from 'jsonc-parser' -import { ProjectFile, withTrailingNewline } from './project-file.ts' - -const FORMAT = { insertSpaces: true, tabSize: 2, eol: '\n' } - -function parseConfig(text: string): Record { - const errors: ParseError[] = [] - const value: unknown = parse(text, errors, { allowTrailingComma: true, disallowComments: false }) - if (errors.length > 0 || value === null || Array.isArray(value) || typeof value !== 'object') { - throw new Error('tsconfig.json is not a valid JSONC object') - } - return value as Record -} - -/** Root tsconfig document edited with jsonc-parser patches. */ -export class TsConfigFile extends ProjectFile { - private text: string - - private constructor(text: string, originalText?: string) { - super('tsconfig.json', originalText) - this.text = withTrailingNewline(text) - } - - /** Create the root project-reference config. */ - static create(): TsConfigFile { - return new TsConfigFile(JSON.stringify({ - extends: './tsconfig.base.json', - compilerOptions: { noEmit: true }, - include: ['index.ts'], - references: [], - }, null, 2)) - } - - /** Parse an existing root tsconfig. */ - static parse(text: string): TsConfigFile { - parseConfig(text) - return new TsConfigFile(text, text) - } - - /** Clone the current JSONC text. */ - override clone(): TsConfigFile { - return new TsConfigFile(this.text, this.originalText) - } - - /** Add one project reference while retaining comments and formatting. */ - addReference(path: string): void { - const value = parseConfig(this.text) - const references = value.references - if (references !== undefined && !Array.isArray(references)) { - throw new Error('tsconfig.json references must be an array') - } - const typed = (references ?? []) as unknown[] - for (const item of typed) { - if (item === null || Array.isArray(item) || typeof item !== 'object' || typeof (item as { path?: unknown }).path !== 'string') { - throw new Error('tsconfig.json references must contain { path: string } objects') - } - } - if (typed.some(item => (item as { path: string }).path === path)) return - this.text = applyEdits(this.text, modify( - this.text, - ['references', typed.length], - { path }, - { formattingOptions: FORMAT, isArrayInsertion: true }, - )) - } - - /** Validate JSONC and the project-reference fields. */ - override validate(): void { - const value = parseConfig(this.text) - if (value.references === undefined) return - if (!Array.isArray(value.references)) throw new Error('tsconfig.json references must be an array') - for (const item of value.references) { - if (item === null || Array.isArray(item) || typeof item !== 'object' || typeof (item as { path?: unknown }).path !== 'string') { - throw new Error('tsconfig.json references must contain { path: string } objects') - } - } - } - - /** Return patched JSONC text. */ - override serialize(): string { - return withTrailingNewline(this.text) - } -} diff --git a/packages/scaffold/helper/src/features/builtin/app.ts b/packages/scaffold/helper/src/features/builtin/app.ts deleted file mode 100644 index 1695accf56..0000000000 --- a/packages/scaffold/helper/src/features/builtin/app.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Required run-interface app feature. - * - * @module @deepseek-ai/dsh-helper/features/builtin/app - */ - -import { featureId } from '../../ids.ts' -import type { ProjectProfile, RunInterface } from '../../project/types.ts' -import { - createAppPackageScripts, - createAppProjectArtifacts, - createProjectTemplateContext, -} from '../../templates/project-template.ts' -import { - FeatureOption, - ExclusiveOptionFeature, -} from '../feature.ts' -import { ProjectContribution, type ProjectResource } from '../resources.ts' -import { - npmCordisConfigEntry, - ownedTextFile, - packageScript, - requiredString, -} from './helpers.ts' - -const ID = featureId('app') - -function appProjectResources( - profile: ProjectProfile, - runInterface: RunInterface, -): readonly ProjectResource[] { - const context = createProjectTemplateContext(profile, runInterface) - const scripts = createAppPackageScripts() - return [ - ...createAppProjectArtifacts(context).map(document => ( - ownedTextFile(ID, document.relativePath, document.serialize()) - )), - packageScript(ID, 'dev', scripts.dev), - packageScript(ID, 'start', scripts.start), - ] -} - -class AppOption extends FeatureOption { - override readonly id: RunInterface - override readonly label: string - - constructor(id: RunInterface, label: string) { - super() - this.id = id - this.label = label - } - - /** Identify external options by their run interface, not the shared interaction service. */ - override markerConfigEntries(): readonly { id: string; name: string }[] { - switch (this.id) { - case 'acp': return [{ id: 'acp', name: '@deepseek-ai/dsh-acp' }] - case 'embed': return [] - } - } - - /** Embed is identified by the configured loop and absence of an external entry point. */ - override matchesConfigEntries(entries: readonly { id: string; name: string }[], profile: ProjectProfile): boolean { - if (this.id !== 'embed') return super.matchesConfigEntries(entries, profile) - return entries.some(entry => entry.id === 'agent-loop' && entry.name === '@deepseek-ai/dsh-agent-loop') - && !entries.some(entry => entry.name === '@deepseek-ai/dsh-acp') - } - - override contribution(profile: ProjectProfile): ProjectContribution { - switch (this.id) { - case 'acp': - return new ProjectContribution([ - ...appProjectResources(profile, this.id), - ...npmCordisConfigEntry(ID, { - id: 'acp', - name: '@deepseek-ai/dsh-acp', - config: { model: profile.runtime.model }, - }, ['model'], config => requiredString(config, 'model')), - ]) - case 'embed': - return new ProjectContribution(appProjectResources(profile, this.id)) - } - } -} - -/** Required app selection represented by ACP or embed options. */ -export class AppFeature extends ExclusiveOptionFeature { - override readonly id = ID - override readonly summary = 'Run interface' - override readonly required = true - override readonly requires = [featureId('spine')] - override readonly options = [ - new AppOption('acp', 'ACP automation server'), - new AppOption('embed', 'Embedded context'), - ] - - /** Default to the profile's already selected run interface. */ - override defaultOptions(profile: ProjectProfile): readonly string[] { - return [profile.runInterface] - } -} diff --git a/packages/scaffold/helper/src/features/builtin/helpers.ts b/packages/scaffold/helper/src/features/builtin/helpers.ts deleted file mode 100644 index 9b21c7f64f..0000000000 --- a/packages/scaffold/helper/src/features/builtin/helpers.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Small resource constructors shared by builtin feature modules. - * - * @module @deepseek-ai/dsh-helper/features/builtin/helpers - */ - -import type { CordisConfigEntry } from '../../documents/cordis-yaml-file.ts' -import { TextProjectFile } from '../../documents/project-file.ts' -import { resourceKey } from '../../ids.ts' -import type { - CordisConfigEntryResource, - EnvironmentResource, - OwnedFileResource, - NpmDependencyResource, - PackageScriptResource, -} from '../resources.ts' - -/** Return the installable package name for a bare package or package subpath. */ -function installablePackageName(specifier: string): string { - const segments = specifier.split('/') - const expectedSegments = specifier.startsWith('@') ? 2 : 1 - if (segments.length < expectedSegments || segments.slice(0, expectedSegments).some(segment => segment.length === 0)) { - throw new Error(`invalid bare package specifier: ${JSON.stringify(specifier)}`) - } - return segments.slice(0, expectedSegments).join('/') -} - -/** Create a runtime NPM dependency resource. */ -function npmDependency(_owner: string, specifier: string): NpmDependencyResource { - const name = installablePackageName(specifier) - return { - kind: 'npm-dependency', - key: resourceKey(`npm-dependency:${name}`), - name, - section: 'dependencies', - } -} - -/** Create a feature-owned package script that is replaceable only while unchanged. */ -export function packageScript(_owner: string, name: string, command: string): PackageScriptResource { - return { - kind: 'package-script', - key: resourceKey(`package-script:${name}`), - name, - command, - removeOnlyWhenUnchanged: true, - } -} - -/** Create a Cordis config entry resource with explicitly owned config keys. */ -export function cordisConfigEntry( - _owner: string, - value: CordisConfigEntry, - ownedConfigKeys: readonly string[] = Object.keys(value.config ?? {}), - validateConfig?: CordisConfigEntryResource['validateConfig'], -): CordisConfigEntryResource { - return { - kind: 'cordis-config-entry', - key: resourceKey(`cordis-config-entry:${value.id}`), - entry: value, - ownedConfigKeys, - ...validateConfig ? { validateConfig } : {}, - } -} - -/** Couple one bare-package or subpath Cordis entry to its installable NPM package. */ -export function npmCordisConfigEntry( - owner: string, - value: CordisConfigEntry, - ownedConfigKeys: readonly string[] = Object.keys(value.config ?? {}), - validateConfig?: CordisConfigEntryResource['validateConfig'], -): readonly [NpmDependencyResource, CordisConfigEntryResource] { - return [ - npmDependency(owner, value.name), - cordisConfigEntry(owner, value, ownedConfigKeys, validateConfig), - ] -} - -/** Create a secret/environment binding resource. */ -export function environment( - _owner: string, - name: string, - value: string | undefined, - comment?: string, -): EnvironmentResource { - return { - kind: 'environment', - key: resourceKey(`environment:${name}`), - name, - ...value === undefined ? {} : { value }, - exampleValue: '', - ...comment === undefined ? {} : { comment }, - } -} - -/** Create an owned complete-text file that is removable only while unchanged. */ -export function ownedTextFile(_owner: string, path: string, text: string): OwnedFileResource { - return { - kind: 'owned-file', - key: resourceKey(`file:${path}`), - document: new TextProjectFile(path, text), - removeOnlyWhenUnchanged: true, - } -} - -/** Validate a config key as a string when present. */ -export function optionalString(config: Readonly>, key: string): string[] { - return config[key] === undefined || typeof config[key] === 'string' ? [] : [`${key} must be a string`] -} - -/** Validate a config key as a non-empty string when required. */ -export function requiredString(config: Readonly>, key: string): string[] { - return typeof config[key] === 'string' && config[key].length > 0 ? [] : [`${key} must be a non-empty string`] -} - -/** Validate a config key as an array of strings. */ -export function stringArray(config: Readonly>, key: string): string[] { - const value = config[key] - return Array.isArray(value) && value.every(item => typeof item === 'string') - ? [] - : [`${key} must be an array of strings`] -} diff --git a/packages/scaffold/helper/src/features/builtin/index.ts b/packages/scaffold/helper/src/features/builtin/index.ts deleted file mode 100644 index 38246e6cf2..0000000000 --- a/packages/scaffold/helper/src/features/builtin/index.ts +++ /dev/null @@ -1,368 +0,0 @@ -/** - * Ordered builtin feature catalog: behavior entities only where project - * context changes the contribution, typed specs everywhere else. - * - * @module @deepseek-ai/dsh-helper/features/builtin - */ - -import type { Config as ClaudeHooksConfig } from '@deepseek-ai/dsh-hooks-claude' -import type { Config as CodexHooksConfig } from '@deepseek-ai/dsh-hooks-codex' -import type { Config as JsonlConfig } from '@deepseek-ai/dsh-session-persistence-jsonl' -import type { Config as SqliteConfig } from '@deepseek-ai/dsh-session-persistence-sqlite' -import type { Config as ToolSubagentConfig } from '@deepseek-ai/dsh-tool-subagent' -import type { Config as ToolTodoConfig } from '@deepseek-ai/dsh-tool-todo' -import type { Config as ToolWebConfig } from '@deepseek-ai/dsh-tool-web' -import type { ProjectProfile } from '../../project/types.ts' -import { defineFeatures } from '../define-feature.ts' -import { FeatureRegistry } from '../registry.ts' -import { AppFeature } from './app.ts' -import { ProviderFeature } from './provider.ts' -import { SpineFeature } from './spine.ts' - -/** - * Build and definition-check the complete builtin set for one project profile. - * @param profile - project context used to validate conditional contributions. - * @returns ordered builtin feature registry. - */ -export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry { - return new FeatureRegistry(defineFeatures([ - new ProviderFeature(), - new SpineFeature(), - { - id: 'bash', - summary: 'Command execution', - mode: 'exclusive', - required: true, - baseResources: [ - { kind: 'npm-cordis-config-entry', id: 'subprocess', package: '@deepseek-ai/dsh-subprocess-local' }, - { kind: 'npm-cordis-config-entry', id: 'bash-env', package: '@deepseek-ai/dsh-bash-env' }, - { kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' }, - ], - options: [ - { - id: 'local', - label: 'Local executor', - default: true, - resources: [{ kind: 'npm-cordis-config-entry', id: 'bash', package: '@deepseek-ai/dsh-bash-local' }], - }, - { - id: 'sandbox', - label: 'Sandboxed executor', - resources: [ - { kind: 'npm-cordis-config-entry', id: 'sandbox', package: '@deepseek-ai/dsh-sandbox-local' }, - { - kind: 'npm-cordis-config-entry', - id: 'bash', - package: '@deepseek-ai/dsh-bash-sandbox', - commentedExample: `Uncomment to allow writes under the project workspace. -config: - mode: workspace-write - workspaceRoot: !!js process.cwd()`, - }, - ], - }, - ], - }, - new AppFeature(), - { - id: 'persistence', - summary: 'Durable session storage', - mode: 'exclusive', - required: true, - options: [ - { - id: 'jsonl', - label: 'JSONL files', - default: true, - resources: [{ - kind: 'npm-cordis-config-entry', - id: 'session-persistence', - package: '@deepseek-ai/dsh-session-persistence-jsonl', - config: { root: './.sessions' } satisfies JsonlConfig, - }], - }, - { - id: 'sqlite', - label: 'SQLite database', - resources: [{ - kind: 'npm-cordis-config-entry', - id: 'session-persistence', - package: '@deepseek-ai/dsh-session-persistence-sqlite', - config: { path: './.sessions/sessions.sqlite' } satisfies SqliteConfig, - }], - }, - ], - }, - { - id: 'hmr', - summary: 'Hot-module reload', - mode: 'single', - options: [{ - id: 'default', - label: 'Cordis HMR', - default: true, - resources: [{ kind: 'npm-cordis-config-entry', id: 'hmr', package: '@deepseek-ai/cordis-plugin-hmr' }], - }], - }, - { - id: 'fs', - summary: 'Read, write, and edit local files', - mode: 'single', - options: [{ - id: 'local', - label: 'Local filesystem', - default: true, - resources: [ - { kind: 'npm-cordis-config-entry', id: 'fs-local', package: '@deepseek-ai/dsh-fs-local' }, - { kind: 'npm-cordis-config-entry', id: 'fs-policy', package: '@deepseek-ai/dsh-fs-policy' }, - { kind: 'npm-cordis-config-entry', id: 'tool-fs', package: '@deepseek-ai/dsh-tool-fs' }, - ], - }], - }, - { - id: 'todo', - summary: 'Model-facing task tracking', - mode: 'single', - options: [{ - id: 'default', - label: 'todo_write tool', - default: true, - resources: [{ - kind: 'npm-cordis-config-entry', - id: 'tool-todo', - package: '@deepseek-ai/dsh-tool-todo', - config: { allowParallelInProgress: true } satisfies ToolTodoConfig, - }], - }], - }, - { - id: 'skill', - summary: 'Local skill discovery', - mode: 'single', - options: [{ - id: 'default', - label: 'Local skills and skill tool', - default: true, - resources: [ - { kind: 'npm-cordis-config-entry', id: 'skill', package: '@deepseek-ai/dsh-skill' }, - { kind: 'npm-cordis-config-entry', id: 'skill-local', package: '@deepseek-ai/dsh-skill-local' }, - { kind: 'npm-cordis-config-entry', id: 'tool-skill', package: '@deepseek-ai/dsh-tool-skill' }, - ], - }], - }, - { - id: 'web', - summary: 'Web search and fetch tools', - mode: 'exclusive', - suggests: ['timeout-policy'], - baseResources: [ - { kind: 'npm-cordis-config-entry', id: 'web', package: '@deepseek-ai/dsh-web' }, - { kind: 'npm-cordis-config-entry', id: 'web-fetch-local', package: '@deepseek-ai/dsh-web-fetch-local' }, - ], - options: [ - { - id: 'deepseek-official', - label: 'DeepSeek search', - default: true, - markers: [{ id: 'web-search-deepseek', name: '@deepseek-ai/dsh-web-search-deepseek' }], - resources: [ - { kind: 'npm-cordis-config-entry', id: 'web-search-deepseek', package: '@deepseek-ai/dsh-web-search-deepseek' }, - { kind: 'npm-cordis-config-entry', id: 'tool-web', package: '@deepseek-ai/dsh-tool-web' }, - ], - }, - { - id: 'exa', - label: 'Exa search', - secrets: [{ id: 'apiKey', environment: 'EXA_API_KEY', message: 'Exa API key', required: true }], - markers: [{ id: 'web-search-exa', name: '@deepseek-ai/dsh-web-search-exa' }], - resources: [ - { kind: 'npm-cordis-config-entry', id: 'web-search-exa', package: '@deepseek-ai/dsh-web-search-exa' }, - { kind: 'npm-cordis-config-entry', id: 'tool-web', package: '@deepseek-ai/dsh-tool-web' }, - ], - }, - { - id: 'perplexity', - label: 'Perplexity search', - secrets: [{ - id: 'apiKey', - environment: 'PERPLEXITY_API_KEY', - message: 'Perplexity API key', - required: true, - }], - markers: [{ id: 'web-search-perplexity', name: '@deepseek-ai/dsh-web-search-perplexity' }], - resources: [ - { - kind: 'npm-cordis-config-entry', - id: 'web-search-perplexity', - package: '@deepseek-ai/dsh-web-search-perplexity', - }, - { kind: 'npm-cordis-config-entry', id: 'tool-web', package: '@deepseek-ai/dsh-tool-web' }, - ], - }, - { - id: 'fetch-only', - label: 'Fetch only', - markers: [{ id: 'tool-web', name: '@deepseek-ai/dsh-tool-web', config: { search: false } }], - resources: [{ - kind: 'npm-cordis-config-entry', - id: 'tool-web', - package: '@deepseek-ai/dsh-tool-web', - config: { search: false } satisfies ToolWebConfig, - }], - }, - ], - }, - { - id: 'subagent', - summary: 'Delegate work to child agents', - mode: 'multiple', - // In-process options select continuable background delegation; the - // follow-up adapter remains an independently loadable global tool. - baseResources: [ - { kind: 'npm-cordis-config-entry', id: 'tasks', package: '@deepseek-ai/dsh-tasks-local' }, - { kind: 'npm-cordis-config-entry', id: 'tool-tasks', package: '@deepseek-ai/dsh-tool-tasks' }, - { kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' }, - { kind: 'npm-cordis-config-entry', id: 'tool-subagent-control', package: '@deepseek-ai/dsh-tool-subagent-control' }, - ], - options: [ - { - id: 'spawn', - label: 'Fresh child agent', - default: true, - resources: [ - { kind: 'npm-cordis-config-entry', id: 'subagent-spawn', package: '@deepseek-ai/dsh-subagent-spawn' }, - { - kind: 'npm-cordis-config-entry', - id: 'tool-subagent', - package: '@deepseek-ai/dsh-tool-subagent', - config: { provider: 'spawn', backgroundMode: 'continuable' } satisfies ToolSubagentConfig, - }, - ], - }, - { - id: 'fork', - label: 'Fork parent history', - resources: [ - { kind: 'npm-cordis-config-entry', id: 'subagent-fork', package: '@deepseek-ai/dsh-subagent-fork' }, - { - kind: 'npm-cordis-config-entry', - id: 'tool-subagent-fork', - package: '@deepseek-ai/dsh-tool-subagent', - config: { - provider: 'fork', - toolName: 'subagent_fork', - backgroundMode: 'continuable', - } satisfies ToolSubagentConfig, - }, - ], - }, - ], - }, - { - id: 'workflow', - summary: 'Scripted multi-agent workflows', - mode: 'single', - options: [{ - id: 'workerthread', - label: 'Worker thread engine', - default: true, - requires: [{ id: 'subagent', options: ['spawn'] }], - resources: [ - { - kind: 'npm-cordis-config-entry', - id: 'workflow-workerthread', - package: '@deepseek-ai/dsh-workflow-workerthread', - }, - { kind: 'npm-cordis-config-entry', id: 'tool-workflow', package: '@deepseek-ai/dsh-tool-workflow' }, - ], - }], - }, - { - id: 'compact', - summary: 'Automatic context compaction', - mode: 'single', - options: [{ - id: 'basic', - label: 'Basic compaction', - default: true, - resources: [ - { - kind: 'npm-cordis-config-entry', - id: 'token-meter', - package: '@deepseek-ai/dsh-token-meter', - }, - { - kind: 'npm-cordis-config-entry', - id: 'compact-basic', - package: '@deepseek-ai/dsh-compact-basic', - }, - ], - }], - }, - { - id: 'hooks', - summary: 'Run Claude Code or Codex hooks', - mode: 'multiple', - requires: [{ id: 'bash' }], - options: [ - { - id: 'claude', - label: 'Claude Code hooks', - default: true, - resources: [ - { - kind: 'npm-cordis-config-entry', - id: 'hooks-claude', - package: '@deepseek-ai/dsh-hooks-claude', - config: { configPath: './hooks.json' } satisfies ClaudeHooksConfig, - }, - { kind: 'owned-file', path: 'hooks.json', text: '{}' }, - ], - }, - { - id: 'codex', - label: 'Codex hooks', - resources: [ - { - kind: 'npm-cordis-config-entry', - id: 'hooks-codex', - package: '@deepseek-ai/dsh-hooks-codex', - config: { configPath: './codex-hooks.json' } satisfies CodexHooksConfig, - }, - { kind: 'owned-file', path: 'codex-hooks.json', text: '{}' }, - ], - }, - ], - }, - { - id: 'guard', - summary: 'Loop-hygiene reminders', - mode: 'single', - options: [{ - id: 'repeat-tool', - label: 'Repeat-tool reminders', - default: true, - resources: [{ - kind: 'npm-cordis-config-entry', - id: 'repeat-tool-guard', - package: '@deepseek-ai/dsh-repeat-tool-guard', - }], - }], - }, - { - id: 'timeout-policy', - summary: 'Tool timeout policy', - mode: 'single', - options: [{ - id: 'default', - label: 'Timeout policy', - default: true, - resources: [{ - kind: 'npm-cordis-config-entry', - id: 'timeout-policy', - package: '@deepseek-ai/dsh-timeout-policy', - }], - }], - }, - ]), profile) -} diff --git a/packages/scaffold/helper/src/features/builtin/provider.ts b/packages/scaffold/helper/src/features/builtin/provider.ts deleted file mode 100644 index a72daff94b..0000000000 --- a/packages/scaffold/helper/src/features/builtin/provider.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Required direct-fetch DeepSeek and custom pi-ai provider behavior. - * - * @module @deepseek-ai/dsh-helper/features/builtin/provider - */ - -import { featureId } from '../../ids.ts' -import type { FeatureSelection, ProjectProfile } from '../../project/types.ts' -import { - FeatureOption, - ExclusiveOptionFeature, - type FeatureProjectView, -} from '../feature.ts' -import { ProjectContribution } from '../resources.ts' -import { npmCordisConfigEntry, environment } from './helpers.ts' - -const ID = featureId('provider') -const DEFAULT_MODEL = 'deepseek-v4-flash' -const API_KEY_COMMENT = 'Required before the first model request.' - -class DeepSeekOption extends FeatureOption { - override readonly id = 'deepseek-official' - override readonly label = 'DeepSeek' - override readonly secrets = [{ - id: 'apiKey', - environment: 'DEEPSEEK_API_KEY', - message: 'DeepSeek API key', - required: true, - }] - - override contribution(_profile: ProjectProfile, secrets: Readonly>): ProjectContribution { - return new ProjectContribution([ - ...npmCordisConfigEntry(ID, { - id: 'llm-deepseek', - name: '@deepseek-ai/dsh-llm-deepseek', - }, ['baseURL', 'models']), - environment(ID, 'DEEPSEEK_API_KEY', secrets.apiKey, API_KEY_COMMENT), - ]) - } -} - -class CustomOption extends FeatureOption { - override readonly id = 'custom' - override readonly label = 'Custom endpoint (pi-ai)' - override readonly secrets = [{ - id: 'apiKey', - environment: 'DEEPSEEK_API_KEY', - message: 'Custom provider API key', - required: true, - }] - override readonly inputs = [{ - id: 'baseURL', - message: 'Custom provider base URL', - }] - - override contribution(_profile: ProjectProfile, secrets: Readonly>): ProjectContribution { - return new ProjectContribution([ - ...npmCordisConfigEntry(ID, { - id: 'llm-pi-ai', - name: '@deepseek-ai/dsh-llm-pi-ai', - }, ['baseURL', 'models']), - environment(ID, 'DEEPSEEK_API_KEY', secrets.apiKey, API_KEY_COMMENT), - ]) - } -} - -/** Required provider feature with DeepSeek and custom pi-ai options. */ -export class ProviderFeature extends ExclusiveOptionFeature { - override readonly id = ID - override readonly summary = 'Model provider' - override readonly required = true - override readonly options = [new DeepSeekOption(), new CustomOption()] - - /** Prefer the direct-fetch adapter and its public endpoint defaults. */ - override defaultOptions(): readonly string[] { - return ['deepseek-official'] - } - - /** Recover literal endpoint overrides from either provider entry. */ - override readSelection(project: FeatureProjectView, selection: FeatureSelection): FeatureSelection { - const base = super.readSelection(project, selection) - const entry = project.cordisConfigEntries().find(item => item.id === 'llm-deepseek' || item.id === 'llm-pi-ai') - const baseURL = entry?.config?.baseURL - return typeof baseURL === 'string' ? { ...base, values: { baseURL } } : base - } - - /** Apply explicit endpoint/model overrides while omitting provider defaults. */ - override contribution(selection: FeatureSelection, profile: ProjectProfile): ProjectContribution { - const contribution = super.contribution(selection, profile) - const baseURL = selection.values?.baseURL - if (baseURL !== undefined && typeof baseURL !== 'string') throw new Error('provider baseURL must be a string') - return new ProjectContribution(contribution.resources.map((resource) => { - if (resource.kind !== 'cordis-config-entry' || (resource.entry.id !== 'llm-deepseek' - && resource.entry.id !== 'llm-pi-ai')) return resource - return { - ...resource, - entry: { - ...resource.entry, - config: { - ...resource.entry.config, - ...baseURL ? { baseURL } : {}, - ...profile.runtime.model === DEFAULT_MODEL ? {} : { models: [profile.runtime.model] }, - }, - }, - } - })) - } -} diff --git a/packages/scaffold/helper/src/features/builtin/spine.ts b/packages/scaffold/helper/src/features/builtin/spine.ts deleted file mode 100644 index 26f0d03232..0000000000 --- a/packages/scaffold/helper/src/features/builtin/spine.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Required agent-spine feature expressed as top-level Cordis config entries. - * - * @module @deepseek-ai/dsh-helper/features/builtin/spine - */ - -import { featureId } from '../../ids.ts' -import type { ProjectProfile } from '../../project/types.ts' -import { loadHelperTemplate } from '../../templates/template-assets.ts' -import { FeatureOption, FixedFeature } from '../feature.ts' -import { ProjectContribution } from '../resources.ts' -import { cordisConfigEntry, npmCordisConfigEntry, requiredString } from './helpers.ts' - -const ID = featureId('spine') -const PERSONA = loadHelperTemplate>('persona.txt.tpl').render({}).trimEnd() - -function emptyAgentsDiagnostics(config: Readonly>): string[] { - const agents = config.agents - if (!Array.isArray(agents)) return ['agents must be an array'] - return agents.length === 0 ? [] : ['agents must be empty'] -} - -class SpineOption extends FeatureOption { - override readonly id = 'default' - override readonly label = 'Default agent spine' - - override contribution(_profile: ProjectProfile): ProjectContribution { - return new ProjectContribution([ - ...npmCordisConfigEntry(ID, { id: 'timer', name: '@deepseek-ai/cordis-plugin-timer' }), - ...npmCordisConfigEntry(ID, { id: 'llm', name: '@deepseek-ai/dsh-llm' }), - ...npmCordisConfigEntry(ID, { id: 'session', name: '@deepseek-ai/dsh-session' }), - ...npmCordisConfigEntry(ID, { - id: 'system-prompt', - name: '@deepseek-ai/dsh-system-prompt', - config: { persona: PERSONA }, - }, ['persona'], config => requiredString(config, 'persona')), - ...npmCordisConfigEntry(ID, { id: 'tools', name: '@deepseek-ai/dsh-tools' }, []), - ...npmCordisConfigEntry(ID, { id: 'agent', name: '@deepseek-ai/dsh-agent' }), - ...npmCordisConfigEntry(ID, { id: 'invariants', name: '@deepseek-ai/dsh-invariants' }), - cordisConfigEntry(ID, { id: 'session-invariant', name: '@deepseek-ai/dsh-session/invariant' }), - cordisConfigEntry(ID, { id: 'agent-invariant', name: '@deepseek-ai/dsh-agent/invariant' }), - ...npmCordisConfigEntry(ID, { id: 'scope-invariant', name: '@deepseek-ai/dsh-scope/invariant' }), - cordisConfigEntry(ID, { id: 'agent-loop-invariant', name: '@deepseek-ai/dsh-agent-loop/invariant' }), - ...npmCordisConfigEntry(ID, { - id: 'agent-loop', - name: '@deepseek-ai/dsh-agent-loop', - config: { agents: [] }, - }, ['agents'], emptyAgentsDiagnostics), - ]) - } -} - -/** Required providerless agent spine without a composition bundle entry. */ -export class SpineFeature extends FixedFeature { - override readonly id = ID - override readonly summary = 'Agent runtime spine' - override readonly required = true - override readonly options = [new SpineOption()] -} diff --git a/packages/scaffold/helper/src/features/define-feature.ts b/packages/scaffold/helper/src/features/define-feature.ts deleted file mode 100644 index 720f15ecd6..0000000000 --- a/packages/scaffold/helper/src/features/define-feature.ts +++ /dev/null @@ -1,286 +0,0 @@ -/** - * Typed declarative definitions for features whose behavior is entirely - * the shared resource lifecycle. - * - * @module @deepseek-ai/dsh-helper/features/define-feature - */ - -import type { CordisConfigEntry } from '../documents/cordis-yaml-file.ts' -import { TextProjectFile } from '../documents/project-file.ts' -import { featureId, resourceKey, type FeatureId } from '../ids.ts' -import type { FeatureSelection, ProjectProfile, RunInterface } from '../project/types.ts' -import { - Feature, - FeatureOption, - type FeatureRequirement, - type FeatureSecret, -} from './feature.ts' -import { ProjectContribution, type ProjectResource } from './resources.ts' - -/** Static NPM dependency in a declarative feature. */ -interface NpmDependencySpec { - kind: 'npm-dependency' - name: string - section?: 'dependencies' | 'devDependencies' -} - -/** Bare-package Cordis config entry that also contributes its NPM dependency. */ -interface NpmCordisConfigEntrySpec { - kind: 'npm-cordis-config-entry' - id: string - package: string - config?: Readonly> - ownedConfigKeys?: readonly string[] - commentedExample?: string -} - -/** Relative or absolute file Cordis config entry with no NPM dependency. */ -interface FileCordisConfigEntrySpec { - kind: 'file-cordis-config-entry' - id: string - path: string - config?: Readonly> - ownedConfigKeys?: readonly string[] - commentedExample?: string -} - -/** Static complete file owned by one feature option. */ -interface OwnedFileSpec { - kind: 'owned-file' - path: string - text: string - removeOnlyWhenUnchanged?: boolean -} - -/** Resource forms that require no feature-specific imperative code. */ -type FeatureResourceSpec = - | NpmDependencySpec - | NpmCordisConfigEntrySpec - | FileCordisConfigEntrySpec - | OwnedFileSpec - -/** Cordis config entry identity and optional plugin-config subset that identifies an option. */ -interface FeatureOptionMarkerSpec { - id: string - name: string - config?: Readonly> -} - -/** Declarative requirement converted to branded domain identity at the boundary. */ -interface FeatureRequirementSpec { - id: string - options?: readonly string[] -} - -/** One static option inside a typed feature definition. */ -interface FeatureOptionSpec { - id: string - label: string - default?: boolean - resources: readonly FeatureResourceSpec[] - secrets?: readonly FeatureSecret[] - markers?: readonly FeatureOptionMarkerSpec[] - requires?: readonly FeatureRequirementSpec[] -} - -/** Complete declarative feature definition. */ -export interface FeatureSpec { - id: string - summary: string - mode: 'single' | 'exclusive' | 'multiple' - options: readonly FeatureOptionSpec[] - baseResources?: readonly FeatureResourceSpec[] - required?: boolean - requires?: readonly FeatureRequirementSpec[] - suggests?: readonly string[] - supportedInterfaces?: readonly RunInterface[] -} - -function sameShape(expected: unknown, actual: unknown): boolean { - if (expected === null || actual === null) return expected === actual - if (Array.isArray(expected)) { - return Array.isArray(actual) && (expected.length === 0 || actual.every(item => sameShape(expected[0], item))) - } - if (typeof expected !== 'object') return typeof expected === typeof actual - if (typeof actual !== 'object' || Array.isArray(actual)) return false - return Object.entries(expected as Record).every( - ([key, value]) => sameShape(value, (actual as Record)[key]), - ) -} - -function configDiagnostics( - expected: Readonly> | undefined, -): ((config: Readonly>) => readonly string[]) | undefined { - if (!expected || Object.keys(expected).length === 0) return undefined - return config => Object.entries(expected).flatMap(([key, value]) => sameShape(value, config[key]) - ? [] - : [`${key} has fields or value types that do not match the expected config`]) -} - -function resourcesFromSpec(spec: FeatureResourceSpec): ProjectResource[] { - switch (spec.kind) { - case 'npm-dependency': - return [{ - kind: 'npm-dependency', - key: resourceKey(`npm-dependency:${spec.name}`), - name: spec.name, - section: spec.section ?? 'dependencies', - }] - case 'npm-cordis-config-entry': - case 'file-cordis-config-entry': { - const config = spec.config ? { ...spec.config } : undefined - const validateConfig = configDiagnostics(config) - const name = spec.kind === 'npm-cordis-config-entry' ? spec.package : spec.path - return [ - ...spec.kind === 'npm-cordis-config-entry' - ? [{ - kind: 'npm-dependency' as const, - key: resourceKey(`npm-dependency:${spec.package}`), - name: spec.package, - section: 'dependencies' as const, - }] - : [], - { - kind: 'cordis-config-entry', - key: resourceKey(`cordis-config-entry:${spec.id}`), - entry: { - id: spec.id, - name, - ...config ? { config } : {}, - }, - ownedConfigKeys: spec.ownedConfigKeys ?? Object.keys(config ?? {}), - ...spec.commentedExample ? { commentedExample: spec.commentedExample } : {}, - ...validateConfig ? { validateConfig } : {}, - }, - ] - } - case 'owned-file': - return [{ - kind: 'owned-file', - key: resourceKey(`file:${spec.path}`), - document: new TextProjectFile(spec.path, spec.text), - removeOnlyWhenUnchanged: spec.removeOnlyWhenUnchanged ?? true, - }] - } -} - -function isSubset(expected: Readonly>, actual: Readonly>): boolean { - return Object.entries(expected).every(([key, value]) => Object.is(actual[key], value)) -} - -class DefinedFeatureOption extends FeatureOption { - override readonly id: string - override readonly label: string - override readonly secrets: readonly FeatureSecret[] - private readonly spec: FeatureOptionSpec - - constructor(spec: FeatureOptionSpec) { - super() - this.spec = spec - this.id = spec.id - this.label = spec.label - this.secrets = spec.secrets ?? [] - } - - override contribution(_profile: ProjectProfile, secrets: Readonly>): ProjectContribution { - return new ProjectContribution([ - ...this.spec.resources.flatMap(resourcesFromSpec), - ...this.secrets.map(secret => ({ - kind: 'environment' as const, - key: resourceKey(`environment:${secret.environment}`), - name: secret.environment, - ...secrets[secret.id] === undefined ? {} : { value: secrets[secret.id] }, - exampleValue: '', - })), - ]) - } - - override markerConfigEntries(): readonly Pick[] { - const markers = this.spec.markers ?? this.spec.resources.flatMap((resource) => { - switch (resource.kind) { - case 'npm-cordis-config-entry': return [{ id: resource.id, name: resource.package }] - case 'file-cordis-config-entry': return [{ id: resource.id, name: resource.path }] - default: return [] - } - }) - return markers.map(marker => ({ id: marker.id, name: marker.name })) - } - - override matchesConfigEntries(entries: readonly CordisConfigEntry[]): boolean { - const markers = this.spec.markers - if (!markers) return this.markerConfigEntries().some(marker => entries.some( - entry => entry.id === marker.id && entry.name === marker.name, - )) - return markers.some(marker => entries.some(entry => entry.id === marker.id - && entry.name === marker.name - && (!marker.config || isSubset(marker.config, entry.config ?? {})))) - } -} - -/** Feature entity backed by a typed static definition. */ -class DefinedFeature extends Feature { - override readonly id: FeatureId - override readonly summary: string - override readonly mode: FeatureSpec['mode'] - override readonly options: readonly FeatureOption[] - override readonly required: boolean - override readonly requires: readonly FeatureId[] - override readonly suggests: readonly FeatureId[] - override readonly supportedInterfaces: readonly RunInterface[] - private readonly spec: FeatureSpec - - /** Validate and materialize one declarative definition. */ - constructor(spec: FeatureSpec) { - super() - this.spec = spec - this.id = featureId(spec.id) - this.summary = spec.summary - this.mode = spec.mode - this.options = spec.options.map(option => new DefinedFeatureOption(option)) - const defaultCount = spec.options.filter(option => option.default).length - if (spec.mode === 'single' && (spec.options.length !== 1 || defaultCount !== 1)) { - throw new Error(`single feature ${spec.id} requires one default option`) - } - if (spec.mode === 'exclusive' && defaultCount !== 1) { - throw new Error(`exclusive feature ${spec.id} requires exactly one default option`) - } - if (spec.mode === 'multiple' && defaultCount === 0) { - throw new Error(`multiple feature ${spec.id} requires at least one default option`) - } - this.required = spec.required ?? false - this.requires = (spec.requires ?? []).map(requirement => featureId(requirement.id)) - this.suggests = (spec.suggests ?? []).map(featureId) - this.supportedInterfaces = spec.supportedInterfaces ?? ['acp', 'embed'] - } - - override defaultOptions(): readonly string[] { - return this.spec.options.filter(option => option.default).map(option => option.id) - } - - override baseContribution(): ProjectContribution { - return new ProjectContribution((this.spec.baseResources ?? []).flatMap(resourcesFromSpec)) - } - - override requirements(selection: FeatureSelection): readonly FeatureRequirement[] { - const selected = new Set(selection.options) - return [ - ...(this.spec.requires ?? []), - ...this.spec.options.filter(option => selected.has(option.id)).flatMap(option => option.requires ?? []), - ].map(requirement => ({ - id: featureId(requirement.id), - ...requirement.options ? { options: requirement.options } : {}, - })) - } -} - -/** Construct the shared lifecycle entity from a typed declarative definition. */ -export function defineFeature(spec: FeatureSpec): Feature { - return new DefinedFeature(spec) -} - -/** Materialize one ordered catalog containing static specs and behavior entities. */ -export function defineFeatures(definitions: readonly (Feature | FeatureSpec)[]): Feature[] { - return definitions.map(definition => definition instanceof Feature - ? definition - : defineFeature(definition)) -} diff --git a/packages/scaffold/helper/src/features/feature-configurator.ts b/packages/scaffold/helper/src/features/feature-configurator.ts deleted file mode 100644 index e6e14030f9..0000000000 --- a/packages/scaffold/helper/src/features/feature-configurator.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Shared option and secret question flow for create and config. - * - * @module @deepseek-ai/dsh-helper/features/feature-configurator - */ - -import type { Feature } from './feature.ts' -import type { FeatureSelection, ProjectProfile } from '../project/types.ts' -import type { PromptPort } from '../questions/prompt-port.ts' -import { requireAnswer } from '../questions/prompt-port.ts' -import { MultiSelectQuestion, SecretQuestion, SelectQuestion, TextQuestion } from '../questions/question.ts' - -/** Resolve one feature selection without knowing which workflow requested it. */ -export class FeatureConfigurator { - private readonly port: PromptPort - - /** Bind the configurator to the shared prompt boundary. */ - constructor(port: PromptPort) { - this.port = port - } - - /** - * Ask option and input questions, preserving current secrets on empty input. - * @param feature - feature whose options and inputs are collected. - * @param profile - target project context. - * @param current - currently installed selection, when configuring. - * @param prefilledOptions - options already chosen by a tree picker. - * @param prefilledSecrets - non-interactive secret values supplied by creation. - * @param prefilledValues - non-interactive value inputs supplied by a headless spec. - * @returns normalized selection with captured values and secrets. - */ - async configure( - feature: Feature, - profile: ProjectProfile, - current?: FeatureSelection, - prefilledOptions?: readonly string[], - prefilledSecrets: Readonly> = {}, - prefilledValues: Readonly> = {}, - ): Promise { - let options: readonly string[] - switch (feature.mode) { - case 'single': - options = feature.defaultOptions(profile) - break - case 'exclusive': { - const initialValue = current?.options[0] ?? feature.defaultOptions(profile)[0] - if (initialValue === undefined) throw new Error(`feature ${feature.id} has no default option`) - const question = new SelectQuestion({ - id: `${feature.id}.option`, - message: `Choose ${feature.summary.toLowerCase()}`, - options: feature.options.map(option => ({ value: option.id, label: option.label })), - initialValue, - }) - const prefilled = prefilledOptions?.[0] - options = [requireAnswer(await question.resolve(this.port, prefilled))] - break - } - case 'multiple': { - const question = new MultiSelectQuestion({ - id: `${feature.id}.options`, - message: `Choose ${feature.summary.toLowerCase()}`, - options: feature.options.map(option => ({ value: option.id, label: option.label })), - initialValues: current?.options ?? feature.defaultOptions(profile), - required: true, - }) - options = requireAnswer(await question.resolve(this.port, prefilledOptions)) - break - } - } - const selected: FeatureSelection = { - id: feature.id, - options, - } - const coercedPrefilled: Record = {} - for (const [key, value] of Object.entries(prefilledValues)) { - if (typeof value !== 'string') throw new Error(`${feature.id}.${key} value must be a string`) - coercedPrefilled[key] = value - } - const values: Record = {} - for (const input of feature.valueInputs(selected, profile)) { - const existing = current?.values?.[input.id] - if (existing !== undefined && typeof existing !== 'string') { - throw new Error(`${feature.id}.${input.id} current value must be a string`) - } - const question = new TextQuestion({ - id: `${feature.id}.${input.id}`, - message: input.message, - ...existing === undefined ? {} : { initialValue: existing }, - validate: value => value.trim().length === 0 ? 'A value is required' : undefined, - }) - values[input.id] = requireAnswer(await question.resolve(this.port, coercedPrefilled[input.id])) - } - const base: FeatureSelection = Object.keys(values).length === 0 - ? selected - : { ...selected, values } - const secrets = { ...current?.secrets } - for (const secret of feature.secrets(base, profile)) { - const existing = secrets[secret.id] - const question = new SecretQuestion({ - id: `${feature.id}.${secret.id}`, - message: existing === undefined ? secret.message : `${secret.message} (leave empty to keep current)`, - validate: value => secret.required && existing === undefined && value.length === 0 - ? 'A value is required' - : undefined, - }) - const answer = requireAnswer(await question.resolve(this.port, prefilledSecrets[secret.id])) - if (answer.length > 0) secrets[secret.id] = answer - } - return Object.keys(secrets).length === 0 ? base : { ...base, secrets } - } -} diff --git a/packages/scaffold/helper/src/features/feature.ts b/packages/scaffold/helper/src/features/feature.ts deleted file mode 100644 index 6fec1e84e3..0000000000 --- a/packages/scaffold/helper/src/features/feature.ts +++ /dev/null @@ -1,345 +0,0 @@ -/** - * Stateful builtin feature and option domain objects. - * - * @module @deepseek-ai/dsh-helper/features/feature - */ - -import type { CordisConfigEntry } from '../documents/cordis-yaml-file.ts' -import type { PackageManifest } from '../documents/package-json-file.ts' -import type { FeatureId } from '../ids.ts' -import type { FeatureSelection, ProjectProfile, RunInterface } from '../project/types.ts' -import { ProjectContribution, type CordisConfigEntryResource, type ProjectResource } from './resources.ts' - -/** Read-only project surface used by feature inspection. */ -export interface FeatureProjectView { - readonly profile: ProjectProfile - cordisConfigEntries(): readonly CordisConfigEntry[] - packageManifest(): Readonly - hasDocument(path: string): boolean - readEnvironment(path: '.env' | '.env.example', name: string): string | undefined -} - -/** Installation state visible to create/config workflows. */ -type FeatureInstallationState = 'absent' | 'enabled' | 'disabled' | 'inconsistent' - -/** Result of round-tripping one feature from a project snapshot. */ -export interface FeatureInstallation { - id: FeatureId - state: FeatureInstallationState - options: readonly string[] - selection?: FeatureSelection - diagnostics: readonly string[] -} - -/** One final-state requirement on another builtin feature. */ -export interface FeatureRequirement { - id: FeatureId - options?: readonly string[] -} - -/** One secret captured into an environment binding rather than Cordis plugin config. */ -export interface FeatureSecret { - id: string - environment: string - message: string - required: boolean -} - -/** One visible string value requested only by options that own it. */ -export interface FeatureValueInput { - id: string - message: string -} - -/** One selectable behavior option owned by a feature. */ -export abstract class FeatureOption { - abstract readonly id: string - abstract readonly label: string - readonly secrets: readonly FeatureSecret[] = [] - readonly inputs: readonly FeatureValueInput[] = [] - - /** Contribute this option's project resources. */ - abstract contribution(profile: ProjectProfile, secrets: Readonly>): ProjectContribution - - /** Every Cordis config entry package owned by this option during inspection. */ - ownedConfigEntries(profile: ProjectProfile): readonly Pick[] { - return this.contribution(profile, {}).resources - .filter((resource): resource is CordisConfigEntryResource => resource.kind === 'cordis-config-entry') - .map(resource => ({ id: resource.entry.id, name: resource.entry.name })) - } - - /** Cordis config entry identities that distinguish this option during inspection. */ - markerConfigEntries(profile: ProjectProfile): readonly Pick[] { - return this.ownedConfigEntries(profile) - } - - /** Whether current owned Cordis config entries identify this option. */ - matchesConfigEntries(entries: readonly CordisConfigEntry[], profile: ProjectProfile): boolean { - return this.markerConfigEntries(profile).some(marker => entries.some( - entry => entry.id === marker.id && entry.name === marker.name, - )) - } -} - -/** How a feature's options compose. */ -export type FeatureOptionMode = 'single' | 'exclusive' | 'multiple' - -function packageNames(resources: readonly ProjectResource[]): Set { - return new Set(resources - .filter((resource): resource is CordisConfigEntryResource => resource.kind === 'cordis-config-entry') - .map(resource => resource.entry.name)) -} - -function configDiagnostics(resource: CordisConfigEntryResource, entry: CordisConfigEntry): string[] { - /* v8 ignore next -- entries without validators have no diagnostics to compute */ - if (!resource.validateConfig) return [] - return [...resource.validateConfig(entry.config ?? {})].map(message => `${entry.id}: ${message}`) -} - -/** A behavior-owning builtin feature with shallow option composition. */ -export abstract class Feature { - /** Stable registry identity. */ - abstract readonly id: FeatureId - /** User-facing feature summary. */ - abstract readonly summary: string - /** Option-selection rule. */ - abstract readonly mode: FeatureOptionMode - /** Available behavior options. */ - abstract readonly options: readonly FeatureOption[] - /** Whether every valid project must enable this feature. */ - readonly required: boolean = false - /** Unconditional feature requirements. */ - readonly requires: readonly FeatureId[] = [] - /** Features recommended during creation. */ - readonly suggests: readonly FeatureId[] = [] - /** Run interfaces under which this feature is meaningful. */ - readonly supportedInterfaces: readonly RunInterface[] = ['acp', 'embed'] - - /** - * Options selected when installation has no override. - * @param profile - project context controlling applicable defaults. - * @returns selected option ids. - */ - abstract defaultOptions(profile: ProjectProfile): readonly string[] - - /** - * Shared resources present for every installed option set. - * @param _profile - project context available to behavior features. - * @returns shared project contribution. - */ - baseContribution(_profile: ProjectProfile): ProjectContribution { - return new ProjectContribution([]) - } - - /** - * Additional final-state requirements depending on selected options. - * @param _selection - normalized feature selection. - * @returns required features and option constraints. - */ - requirements(_selection: FeatureSelection): readonly FeatureRequirement[] { - return this.requires.map(id => ({ id })) - } - - /** - * Whether the feature may be selected for this project run interface. - * @param profile - project context to check. - * @returns whether the feature applies. - */ - isApplicable(profile: ProjectProfile): boolean { - return this.supportedInterfaces.includes(profile.runInterface) - } - - /** - * Validate and normalize one requested option set. - * @param selection - requested feature and options. - * @param profile - project context for applicability and defaults. - * @returns deduplicated, sorted selection. - */ - normalizeSelection(selection: FeatureSelection, profile: ProjectProfile): FeatureSelection { - if (selection.id !== this.id) throw new Error(`selection ${selection.id} does not belong to feature ${this.id}`) - if (!this.isApplicable(profile)) { - throw new Error(`feature ${this.id} is not available for ${profile.runInterface}`) - } - const available = new Set(this.options.map(option => option.id)) - const options = [...new Set(selection.options.length > 0 ? selection.options : this.defaultOptions(profile))] - for (const option of options) { - if (!available.has(option)) throw new Error(`unknown ${this.id} option: ${option}`) - } - if (this.mode === 'single' && (options.length !== 1 || this.options.length !== 1)) { - throw new Error(`feature ${this.id} has one fixed option`) - } - if (this.mode === 'exclusive' && options.length !== 1) { - throw new Error(`feature ${this.id} requires exactly one option`) - } - if (this.mode === 'multiple' && options.length === 0) { - throw new Error(`feature ${this.id} requires at least one option`) - } - return { ...selection, options: options.sort() } - } - - /** - * Build the complete selected resource contribution. - * @param selection - selected options and captured inputs. - * @param profile - target project context. - * @returns merged base and option resources. - */ - contribution(selection: FeatureSelection, profile: ProjectProfile): ProjectContribution { - const normalized = this.normalizeSelection(selection, profile) - const selected = this.selectedOptions(normalized) - .map(option => option.contribution(profile, normalized.secrets ?? {})) - return ProjectContribution.merge(this.baseContribution(profile), ...selected) - } - - /** - * All secret definitions required by one selected option set. - * @param selection - selected options. - * @param profile - target project context. - * @returns selected secret definitions. - */ - secrets(selection: FeatureSelection, profile: ProjectProfile): readonly FeatureSecret[] { - const normalized = this.normalizeSelection(selection, profile) - return this.selectedOptions(normalized).flatMap(option => option.secrets) - } - - /** - * All visible value definitions required by one selected option set. - * @param selection - selected options. - * @param profile - target project context. - * @returns selected visible-input definitions. - */ - valueInputs(selection: FeatureSelection, profile: ProjectProfile): readonly FeatureValueInput[] { - const normalized = this.normalizeSelection(selection, profile) - return this.selectedOptions(normalized).flatMap(option => option.inputs) - } - - private selectedOptions(selection: FeatureSelection): readonly FeatureOption[] { - return selection.options.map((id) => { - const option = this.options.find(candidate => candidate.id === id) - /* v8 ignore next -- normalizeSelection already membership-checks every selected id */ - if (!option) throw new Error(`unknown ${this.id} option: ${id}`) - return option - }) - } - - /** - * Recover input and secret values after structural inspection. - * @param project - project snapshot being inspected. - * @param selection - structurally detected selection. - * @returns selection enriched with readable values. - */ - readSelection(project: FeatureProjectView, selection: FeatureSelection): FeatureSelection { - const secrets = Object.fromEntries(this.secrets(selection, project.profile).flatMap((secret) => { - const value = project.readEnvironment('.env', secret.environment) - return value === undefined ? [] : [[secret.id, value]] - })) - return Object.keys(secrets).length === 0 ? selection : { ...selection, secrets } - } - - /** - * Inspect current files and reject any partial or ambiguous owned file set. - * @param project - project snapshot to inspect. - * @returns installation state, selection, and diagnostics. - */ - inspect(project: FeatureProjectView): FeatureInstallation { - const profile = project.profile - const allPackages = new Set() - for (const option of this.options) { - for (const entry of option.ownedConfigEntries(profile)) allPackages.add(entry.name) - } - for (const name of packageNames(this.baseContribution(profile).resources)) allPackages.add(name) - const configEntries = project.cordisConfigEntries() - const ownedConfigEntries = configEntries.filter(entry => allPackages.has(entry.name)) - - const options = this.options - .filter(option => option.matchesConfigEntries(configEntries, profile)) - .map(option => option.id) - if (ownedConfigEntries.length === 0 && options.length === 0) { - return { id: this.id, state: 'absent', options: [], diagnostics: [] } - } - let selection: FeatureSelection - try { - selection = this.normalizeSelection({ id: this.id, options }, profile) - } catch (error) { - return { id: this.id, state: 'inconsistent', options, diagnostics: [String(error)] } - } - selection = this.readSelection(project, selection) - const expected = this.contribution(selection, profile) - const expectedEntries = expected.resources - .filter((resource): resource is CordisConfigEntryResource => resource.kind === 'cordis-config-entry') - const diagnostics: string[] = [] - for (const resource of expectedEntries) { - const actual = ownedConfigEntries.find(entry => entry.id === resource.entry.id && entry.name === resource.entry.name) - if (!actual) diagnostics.push(`missing Cordis config entry ${resource.entry.id} (${resource.entry.name})`) - else diagnostics.push(...configDiagnostics(resource, actual)) - } - for (const actual of ownedConfigEntries) { - if (!expectedEntries.some(resource => resource.entry.id === actual.id && resource.entry.name === actual.name)) { - diagnostics.push(`unexpected owned Cordis config entry ${actual.id} (${actual.name})`) - } - } - const manifest = project.packageManifest() - for (const resource of expected.resources) { - switch (resource.kind) { - case 'npm-dependency': - if (!manifest[resource.section]?.[resource.name]) { - diagnostics.push(`missing package.json ${resource.section} entry ${resource.name}`) - } - break - case 'package-script': - if (!manifest.scripts?.[resource.name]) { - diagnostics.push(`missing package.json script ${resource.name}`) - } - break - case 'owned-file': - if (!project.hasDocument(resource.document.relativePath)) diagnostics.push(`missing owned file ${resource.document.relativePath}`) - break - case 'environment': - try { - if (project.readEnvironment('.env.example', resource.name) === undefined) { - diagnostics.push(`missing .env.example variable ${resource.name}`) - } - } catch (error) { - diagnostics.push(String(error)) - } - break - case 'cordis-config-entry': break - } - } - const disabled = ownedConfigEntries.map(entry => entry.disabled === true) - if (disabled.some(Boolean) && disabled.some(value => !value)) { - diagnostics.push('owned Cordis config entries have mixed enabled states') - } - if (diagnostics.length > 0) { - return { id: this.id, state: 'inconsistent', options, diagnostics } - } - return { - id: this.id, - state: ownedConfigEntries.length > 0 && disabled.every(Boolean) ? 'disabled' : 'enabled', - options, - selection, - diagnostics: [], - } - } -} - -/** Fixed one-option feature base. */ -export abstract class FixedFeature extends Feature { - override readonly mode = 'single' - - /** Select the sole option. */ - override defaultOptions(): readonly string[] { - const option = this.options[0] - if (!option) throw new Error(`simple feature ${this.id} has no option`) - return [option.id] - } -} - -/** Mutually exclusive option feature base. */ -export abstract class ExclusiveOptionFeature extends Feature { - override readonly mode = 'exclusive' -} - -/** Additive multi-option feature base. */ -export abstract class MultiOptionFeature extends Feature { - override readonly mode = 'multiple' -} diff --git a/packages/scaffold/helper/src/features/registry.ts b/packages/scaffold/helper/src/features/registry.ts deleted file mode 100644 index 5135f2e40c..0000000000 --- a/packages/scaffold/helper/src/features/registry.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Builtin feature registry and definition-time conflict checks. - * - * @module @deepseek-ai/dsh-helper/features/registry - */ - -import type { FeatureId, ResourceKey } from '../ids.ts' -import type { ProjectProfile } from '../project/types.ts' -import type { Feature, FeatureProjectView } from './feature.ts' -import type { CordisConfigEntryResource } from './resources.ts' - -/** Compile-time builtin feature collection. */ -export class FeatureRegistry { - private readonly features = new Map() - - /** Register and validate a complete builtin set. */ - constructor(features: readonly Feature[], validationProfile: ProjectProfile) { - const owners = new Map() - for (const feature of features) { - if (this.features.has(feature.id)) throw new Error(`duplicate feature id: ${feature.id}`) - this.features.set(feature.id, feature) - const validationInterface = feature.supportedInterfaces[0] - if (!validationInterface) throw new Error(`feature ${feature.id} supports no run interface`) - const selections = feature.options.map(option => ({ id: feature.id, options: [option.id] })) - for (const selection of selections) { - const contribution = feature.contribution(selection, { - ...validationProfile, - runInterface: validationInterface, - }) - for (const resource of contribution.resources) { - const owner = owners.get(resource.key) - if (owner && owner !== feature.id) { - throw new Error(`resource ${resource.key} is declared by both ${owner} and ${feature.id}`) - } - owners.set(resource.key, feature.id) - } - } - } - } - - /** - * Return all builtins in display order. - * @returns all registered features. - */ - all(): readonly Feature[] { - return [...this.features.values()] - } - - /** - * Resolve one builtin or fail loud. - * @param id - stable feature identity. - * @returns registered feature. - */ - get(id: FeatureId): Feature { - const feature = this.features.get(id) - if (!feature) throw new Error(`unknown feature: ${id}`) - return feature - } - - /** - * Inspect every applicable builtin in display order. - * @param project - project view to inspect. - * @returns installation snapshots for applicable features. - */ - inspect(project: FeatureProjectView): ReturnType[] { - return this.all() - .filter(feature => feature.isApplicable(project.profile)) - .map(feature => feature.inspect(project)) - } - - /** - * Resolve the builtin that owns a Cordis package name for this profile. - * @param name - Loader package name. - * @param profile - project context controlling applicability. - * @returns owning feature, if the package is builtin-owned. - */ - ownerOfPackage(name: string, profile: ProjectProfile): Feature | undefined { - return this.all().find((feature) => { - if (!feature.isApplicable(profile)) return false - const selections = feature.options.map(option => ({ id: feature.id, options: [option.id] })) - return selections.some(selection => feature.contribution(selection, profile).resources.some( - (resource): resource is CordisConfigEntryResource => resource.kind === 'cordis-config-entry' - && resource.entry.name === name, - )) - }) - } -} diff --git a/packages/scaffold/helper/src/features/resources.ts b/packages/scaffold/helper/src/features/resources.ts deleted file mode 100644 index 14b76db57a..0000000000 --- a/packages/scaffold/helper/src/features/resources.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Resource vocabulary contributed by builtin SDK features. - * - * @module @deepseek-ai/dsh-helper/features/resources - */ - -import type { CordisConfigEntry } from '../documents/cordis-yaml-file.ts' -import type { ProjectFile } from '../documents/project-file.ts' -import type { ResourceKey } from '../ids.ts' - -/** Runtime or development NPM dependency contribution. */ -export interface NpmDependencyResource { - kind: 'npm-dependency' - key: ResourceKey - name: string - section: 'dependencies' | 'devDependencies' -} - -/** Feature-owned package script. */ -export interface PackageScriptResource { - kind: 'package-script' - key: ResourceKey - name: string - command: string - removeOnlyWhenUnchanged: boolean -} - -/** Owned Cordis config entry plus the config keys safe to update in place. */ -export interface CordisConfigEntryResource { - kind: 'cordis-config-entry' - key: ResourceKey - entry: CordisConfigEntry - ownedConfigKeys: readonly string[] - commentedExample?: string - validateConfig?: (config: Readonly>) => readonly string[] -} - -/** Environment variable reference and dotenv material. */ -export interface EnvironmentResource { - kind: 'environment' - key: ResourceKey - name: string - value?: string - exampleValue: string - comment?: string -} - -/** Feature-exclusive complete file. */ -export interface OwnedFileResource { - kind: 'owned-file' - key: ResourceKey - document: ProjectFile - removeOnlyWhenUnchanged: boolean -} - -/** Any resource a feature can add to a project. */ -export type ProjectResource = - | NpmDependencyResource - | PackageScriptResource - | CordisConfigEntryResource - | EnvironmentResource - | OwnedFileResource - -/** Complete resource contribution for one selected feature state. */ -export class ProjectContribution { - readonly resources: readonly ProjectResource[] - - /** Validate and retain one feature-owned resource set. */ - constructor(resources: readonly ProjectResource[]) { - const seen = new Set() - for (const resource of resources) { - if (seen.has(resource.key)) throw new Error(`duplicate contribution resource key: ${resource.key}`) - seen.add(resource.key) - } - this.resources = resources - } - - /** Merge base and option contributions by stable key. */ - static merge(...contributions: readonly ProjectContribution[]): ProjectContribution { - const resources = new Map() - for (const contribution of contributions) { - for (const resource of contribution.resources) { - const previous = resources.get(resource.key) - if (previous && JSON.stringify(previous) !== JSON.stringify(resource)) { - throw new Error(`resource ${resource.key} has conflicting definitions inside one feature`) - } - resources.set(resource.key, resource) - } - } - return new ProjectContribution([...resources.values()]) - } - - /** Index resources by stable key. */ - byKey(): ReadonlyMap { - return new Map(this.resources.map(resource => [resource.key, resource])) - } -} diff --git a/packages/scaffold/helper/src/ids.ts b/packages/scaffold/helper/src/ids.ts deleted file mode 100644 index 4d671753ca..0000000000 --- a/packages/scaffold/helper/src/ids.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Branded identities owned by the SDK project domain. - * - * @module @deepseek-ai/dsh-helper/ids - */ - -import type { Branded } from '@deepseek-ai/dsh-brand' - -/** Stable identity of a builtin SDK feature. */ -export type FeatureId = Branded<'FeatureId'> - -/** - * Construct a feature identity from its registry key. - * @param value - lowercase kebab-case registry key. - * @returns branded feature identity. - */ -export function featureId(value: string): FeatureId { - if (!/^[a-z][a-z0-9-]*$/.test(value)) { - throw new Error(`invalid feature id: ${JSON.stringify(value)}`) - } - return value as FeatureId -} - -/** Stable identity of a resource contributed to an SDK project. */ -export type ResourceKey = Branded<'ResourceKey'> - -/** Construct a resource key from its owner-qualified value. */ -export function resourceKey(value: string): ResourceKey { - if (value.length === 0) throw new Error('resource key must not be empty') - return value as ResourceKey -} diff --git a/packages/scaffold/helper/src/index.ts b/packages/scaffold/helper/src/index.ts deleted file mode 100644 index db4468aae5..0000000000 --- a/packages/scaffold/helper/src/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Shared domain and infrastructure for DeepSeek Harness SDK project tooling. - * - * FIXME: rename to `@deepseek-ai/dsh-sdk-helper` before the first tagged release — - * the current name is indefensibly generic as a published name - * ([regrouping Agent Note](../../../../.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md)). - * - * @module @deepseek-ai/dsh-helper - */ - -export { featureId } from './ids.ts' -export { TextTemplate } from './templates/text-template.ts' -export type { - FeatureSelection, - ProjectCreationRequest, - ProjectProfile, - RunInterface, -} from './project/types.ts' -export type { ChangeSet, ProjectCommitResult } from './project/change-set.ts' -export { SdkProject } from './project/sdk-project.ts' -export { - NodeCommandRunner, - NpmPackageManager, - createPackageManager, - inferPackageManagerName, - probePackageManagerVersion, -} from './package-managers/package-manager.ts' -export type { - CommandRunner, - PackageManager, - PackageManagerName, - PackageManagerVersionProbe, -} from './package-managers/package-manager.ts' -export { LocalPluginBlueprint } from './plugins/local-plugin-blueprint.ts' -export type { LocalPluginKind } from './plugins/local-plugin-blueprint.ts' -export type { Feature, FeatureInstallation } from './features/feature.ts' -export type { FeatureRegistry } from './features/registry.ts' -export { FeatureConfigurator } from './features/feature-configurator.ts' -export { createBuiltinRegistry } from './features/builtin/index.ts' -export { PromptCancelledError, requireAnswer } from './questions/prompt-port.ts' -export type { NestedMultiSelectValue, PromptPort } from './questions/prompt-port.ts' -export { - ConfirmQuestion, - SecretQuestion, - SelectQuestion, - TextQuestion, -} from './questions/question.ts' -export type { Question } from './questions/question.ts' -export { ClackPromptPort } from './questions/clack-prompt-port.ts' -export { HeadlessPromptError, HeadlessPromptPort } from './questions/headless-prompt-port.ts' diff --git a/packages/scaffold/helper/src/invariant.ts b/packages/scaffold/helper/src/invariant.ts deleted file mode 100644 index dfebbb2006..0000000000 --- a/packages/scaffold/helper/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-helper`. - * @module @deepseek-ai/dsh-helper/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-helper' - -/** Cordis companion plugin name. */ -export const name = 'helper-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this SDK build-time package owns no live event stream or mutable data; - * generated output and consumer tests cover its contract. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/scaffold/helper/src/package-managers/link-workspace.ts b/packages/scaffold/helper/src/package-managers/link-workspace.ts deleted file mode 100644 index 4d185ce4d1..0000000000 --- a/packages/scaffold/helper/src/package-managers/link-workspace.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Repository package discovery and NPM dependency-closure rewriting for live links. - * - * @module @deepseek-ai/dsh-helper/package-managers/link-workspace - */ - -import { readFile, readdir } from 'node:fs/promises' -import { existsSync, realpathSync } from 'node:fs' -import { basename, dirname, join, relative, resolve, sep } from 'node:path' -import type { PackageJsonFile, PackageManifest } from '../documents/package-json-file.ts' -import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts' -import type { ProjectFile } from '../documents/project-file.ts' -import type { PackageManager } from './package-manager.ts' - -interface WorkspacePackage { - directory: string - manifest: PackageManifest -} - -function posixPath(path: string): string { - return path.split(sep).join('/') -} - -function canonicalPath(path: string): string { - let existing = resolve(path) - const suffix: string[] = [] - while (!existsSync(existing)) { - const parent = dirname(existing) - /* v8 ignore next -- every absolute path reaches the existing filesystem root */ - if (parent === existing) throw new Error(`cannot resolve an existing ancestor for ${path}`) - suffix.unshift(basename(existing)) - existing = parent - } - return resolve(realpathSync(existing), ...suffix) -} - -async function packageDirectories(root: string): Promise { - const result: string[] = [] - for (const vendor of await readdir(join(root, 'vendor'), { withFileTypes: true })) { - if (vendor.isDirectory()) result.push(join(root, 'vendor', vendor.name)) - } - for (const group of await readdir(join(root, 'packages'), { withFileTypes: true })) { - if (!group.isDirectory()) continue - for (const pkg of await readdir(join(root, 'packages', group.name), { withFileTypes: true })) { - if (pkg.isDirectory()) result.push(join(root, 'packages', group.name, pkg.name)) - } - } - return result -} - -/** Index of repository packages used by `--link-workspace`. */ -export class LinkWorkspace { - readonly root: string - private readonly packages: Map - - private constructor(root: string, packages: Map) { - this.root = root - this.packages = packages - } - - /** Scan vendor and package workspaces from a repository root. */ - static async open(root: string): Promise { - const absolute = resolve(root) - const packages = new Map() - for (const directory of await packageDirectories(absolute)) { - let manifest: PackageManifest - try { - manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as PackageManifest - } catch (error) { - throw new Error(`cannot read linked package at ${directory}: ${String(error)}`) - } - if (!manifest.name || typeof manifest.name !== 'string') continue - if (packages.has(manifest.name)) throw new Error(`duplicate linked package name: ${manifest.name}`) - packages.set(manifest.name, { directory, manifest }) - } - if (!packages.has('@deepseek-ai/cordis') || !packages.has('@deepseek-ai/dsh-scripts')) { - throw new Error(`not a DeepSeek Harness repository root: ${absolute}`) - } - return new LinkWorkspace(absolute, packages) - } - - /** Expand direct NPM dependencies through all repository-local NPM dependency edges. */ - closure(names: Iterable): string[] { - const pending = [...names] - const result = new Set() - while (pending.length > 0) { - const name = pending.pop() - if (!name || result.has(name)) continue - const pkg = this.packages.get(name) - /* v8 ignore next -- closure() only returns names present in this package map */ - if (!pkg) continue - result.add(name) - const edges = { - ...pkg.manifest.dependencies, - ...pkg.manifest.peerDependencies as Record | undefined, - } - for (const dependencyName of Object.keys(edges)) { - if (this.packages.has(dependencyName) && !result.has(dependencyName)) pending.push(dependencyName) - } - } - return [...result].sort() - } - - /** Rewrite the full local closure to manager-specific live-link specs. */ - apply( - projectRoot: string, - manifest: PackageJsonFile, - manager: PackageManager, - documents: readonly ProjectFile[], - ): void { - const canonicalProjectRoot = canonicalPath(projectRoot) - const names = this.closure(manifest.npmDependencyNames()) - for (const name of names) { - const pkg = this.packages.get(name) - /* v8 ignore next -- closure() only returns names present in this package map */ - if (!pkg) continue - const relativePath = posixPath(relative(canonicalProjectRoot, realpathSync(pkg.directory))) - const spec = manager.linkSpec(relativePath) - const current = manifest.npmDependency(name) - manifest.setNpmDependency(current?.section ?? 'dependencies', name, spec) - if (manager.name === 'yarn') manifest.setResolution(name, spec) - } - if (manager.name === 'pnpm') { - const workspace = documents.find((item): item is PnpmWorkspaceFile => item instanceof PnpmWorkspaceFile) - if (!workspace) throw new Error('pnpm link mode requires pnpm-workspace.yaml') - workspace.disableAutoInstallPeers() - } - } - - /** Resolve a package directory for diagnostics and tests. */ - packageDirectory(name: string): string | undefined { - const directory = this.packages.get(name)?.directory - return directory - ? resolve(dirname(directory), directory.split(sep).at(-1) as string) - : undefined - } - - /** - * Rewrite one nested generated manifest's local dependencies to live-link specs. - * - * A generated workspace member resolves its own dependencies, so every local - * name it declares must point into this repository as well: none of them — - * the harness packages or the rescoped framework — exists on a public - * registry, so a semver spec there fails the install outright. - * `peerDependencies` keeps its range because a peer states what the consumer - * must supply, and package managers reject a link spec in that section. - * @param projectRoot - Absolute root of the generated project. - * @param manifestPath - The nested manifest's project-relative POSIX path. - * @param text - The nested manifest's complete current text. - * @param manager - Package manager whose link-spec form applies. - * @returns The manifest text with every resolved local dependency relinked. - */ - relinkNestedManifest(projectRoot: string, manifestPath: string, text: string, manager: PackageManager): string { - const manifest = JSON.parse(text) as Record - const manifestDirectory = resolve(canonicalPath(projectRoot), dirname(manifestPath)) - let changed = false - for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) { - const dependencies = manifest[section] - if (typeof dependencies !== 'object' || dependencies === null) continue - for (const [name] of Object.entries(dependencies as Record)) { - const pkg = this.packages.get(name) - if (!pkg) continue - const relativePath = posixPath(relative(manifestDirectory, realpathSync(pkg.directory))) - ;(dependencies as Record)[name] = manager.linkSpec(relativePath) - changed = true - } - } - return changed ? `${JSON.stringify(manifest, null, 2)}\n` : text - } -} diff --git a/packages/scaffold/helper/src/package-managers/package-manager.ts b/packages/scaffold/helper/src/package-managers/package-manager.ts deleted file mode 100644 index 5c070afce7..0000000000 --- a/packages/scaffold/helper/src/package-managers/package-manager.ts +++ /dev/null @@ -1,332 +0,0 @@ -/** - * Package-manager strategies for SDK project workspaces and child commands. - * - * @module @deepseek-ai/dsh-helper/package-managers/package-manager - */ - -import { execFile, spawn } from 'node:child_process' -import { scrubbedParentEnv, SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subprocess' -import { promisify } from 'node:util' -import type { PackageJsonFile } from '../documents/package-json-file.ts' -import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts' -import type { ProjectFile } from '../documents/project-file.ts' - -/** Supported generated-project package managers. */ -export type PackageManagerName = 'npm' | 'pnpm' | 'yarn' - -/** Result from one child package-manager process. */ -export interface CommandResult { - exitCode: number | null - signal: NodeJS.Signals | null -} - -/** Injectable subprocess boundary used by package-manager strategies. */ -export interface CommandRunner { - /** Run one executable without a shell and await process exit. */ - run(command: string, args: readonly string[], cwd: string): Promise -} - -/** Injectable package-manager version probe used by project creation. */ -export type PackageManagerVersionProbe = (name: PackageManagerName, cwd: string) => Promise - -const execFileAsync = promisify(execFile) - -/** - * Read a manager version without forwarding ambient credentials. - * @param name - package-manager executable. - * @param cwd - working directory used for resolution. - * @returns trimmed version output. - */ -export async function probePackageManagerVersion(name: PackageManagerName, cwd: string): Promise { - try { - const { stdout } = await execFileAsync(name, ['--version'], { - cwd, - env: scrubEnvironment(), - encoding: 'utf8', - }) - const version = stdout.trim() - if (!version) throw new Error('empty version output') - return version - } catch (error) { - throw new Error(`cannot run ${name} --version: ${String(error)}`) - } -} - -/** - * Remove credential-shaped environment variables from spawned commands. - * @param environment - source environment (injectable for tests); the default - * path shares the subprocess seam's scrub so every harness spawner drops the - * same names. - */ -export function scrubEnvironment(environment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - if (environment === undefined) return scrubbedParentEnv() - return Object.fromEntries(Object.entries(environment).filter(([name]) => !SENSITIVE_ENV_PATTERN.test(name))) -} - -/** Node child-process command runner with inherited stdio and quiescent completion. */ -export class NodeCommandRunner implements CommandRunner { - private readonly output: NodeJS.WritableStream | undefined - - /** - * @param output - redirect target for child stdout+stderr; the child inherits - * this process's stdio when absent. Callers whose own stdout carries a machine - * protocol (create-sdk --json NDJSON) redirect child output to keep the - * protocol stream pure. - */ - constructor(output?: NodeJS.WritableStream) { - this.output = output - } - - /** Spawn one child and settle only after exit, with redirected stdio drained. */ - run(command: string, args: readonly string[], cwd: string): Promise { - return new Promise((resolve, reject) => { - const output = this.output - if (output === undefined) { - const child = spawn(command, [...args], { cwd, env: scrubEnvironment(), stdio: 'inherit', shell: false }) - child.once('error', reject) - child.once('exit', (exitCode, signal) => { resolve({ exitCode, signal }) }) - return - } - const child = spawn(command, [...args], { - cwd, - env: scrubEnvironment(), - stdio: ['inherit', 'pipe', 'pipe'], - shell: false, - }) - child.stdout.pipe(output, { end: false }) - child.stderr.pipe(output, { end: false }) - child.once('error', reject) - child.once('close', (exitCode, signal) => { resolve({ exitCode, signal }) }) - }) - } -} - -function major(version: string): number { - const match = /^(\d+)/.exec(version) - if (!match?.[1]) throw new Error(`invalid package manager version: ${JSON.stringify(version)}`) - return Number(match[1]) -} - -/** Behavior owned by one generated-project package manager. */ -export abstract class PackageManager { - /** Manager executable and project identity. */ - abstract readonly name: PackageManagerName - - /** Detected concrete manager version. */ - readonly version: string - - constructor(version: string) { - this.version = version - } - - /** Validate the detected version against this SDK's supported floor. */ - abstract validateVersion(): void - - /** - * Configure root manifest fields and return manager-specific files. - * @param manifest - generated root manifest to update. - * @returns manager-specific companion documents. - */ - abstract configureWorkspace(manifest: PackageJsonFile): ProjectFile[] - - /** - * Build the NPM dependency spec for a local workspace plugin. - * @returns manager-specific local NPM dependency spec. - */ - abstract localPluginSpec(): string - - /** - * Resolve a repository live-link NPM dependency. - * @param relativePath - relative path from generated project to package. - * @returns manager-specific NPM dependency spec. - */ - abstract linkSpec(relativePath: string): string - - /** - * Build install command arguments. - * @returns arguments following the manager executable. - */ - installCommand(): readonly string[] { - return ['install'] - } - - /** - * Build project-build command arguments. - * @returns arguments following the manager executable. - */ - buildCommand(): readonly string[] { - return ['run', 'build'] - } - - /** - * Run NPM dependency installation and fail on non-zero or signalled exit. - * @param cwd - generated project directory. - * @param runner - optional subprocess boundary. - */ - async install(cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise { - await this.runChecked(runner, this.installCommand(), cwd, 'install') - } - - /** - * Run the project build and fail on non-zero or signalled exit. - * @param cwd - generated project directory. - * @param runner - optional subprocess boundary. - */ - async build(cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise { - await this.runChecked(runner, this.buildCommand(), cwd, 'build') - } - - /** - * Build add-dependency command arguments for one already-normalized source spec. - * @param spec - a package-manager-native dependency source (`pkg@version` or `github:owner/repo#ref`). - * @returns arguments following the manager executable. - */ - addCommand(spec: string): readonly string[] { - return ['add', spec] - } - - /** - * Add one dependency from a native source spec and fail on non-zero or signalled exit. - * @param spec - a package-manager-native dependency source. - * @param cwd - project directory. - * @param runner - optional subprocess boundary. - */ - async add(spec: string, cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise { - await this.runChecked(runner, this.addCommand(spec), cwd, 'add') - } - - private async runChecked(runner: CommandRunner, args: readonly string[], cwd: string, operation: string): Promise { - const result = await runner.run(this.name, args, cwd) - if (result.signal !== null) { - throw new Error(`${this.name} ${operation} was killed by ${result.signal}`) - } - if (result.exitCode !== 0) { - throw new Error(`${this.name} ${operation} exited with code ${String(result.exitCode)}`) - } - } -} - -/** npm workspace behavior. */ -export class NpmPackageManager extends PackageManager { - override readonly name = 'npm' - - /** npm 10 is the supported floor at the repository's Node floor. */ - override validateVersion(): void { - if (major(this.version) < 10) throw new Error(`npm >=10 is required, got ${this.version}`) - } - - /** Configure package.json workspaces; npm needs no companion file. */ - override configureWorkspace(manifest: PackageJsonFile): ProjectFile[] { - manifest.addWorkspace('plugins/*') - manifest.setPackageManager(undefined) - return [] - } - - /** npm resolves workspace packages through its ordinary wildcard. */ - override localPluginSpec(): string { - return '*' - } - - /** npm live links use file NPM dependencies. */ - override linkSpec(relativePath: string): string { - return `file:${relativePath}` - } - - /** npm adds a dependency through `install ` rather than an `add` verb. */ - override addCommand(spec: string): readonly string[] { - return ['install', spec] - } -} - -/** pnpm workspace behavior. */ -export class PnpmPackageManager extends PackageManager { - override readonly name = 'pnpm' - - /** pnpm 10 is the supported floor for strict NPM dependency-build policy. */ - override validateVersion(): void { - if (major(this.version) < 10) throw new Error(`pnpm >=10 is required, got ${this.version}`) - } - - /** Configure packageManager and a structured pnpm workspace file. */ - override configureWorkspace(manifest: PackageJsonFile): ProjectFile[] { - manifest.setPackageManager(`pnpm@${this.version}`) - const workspace = PnpmWorkspaceFile.create() - workspace.addPackage('plugins/*') - return [workspace] - } - - /** pnpm uses its explicit workspace protocol. */ - override localPluginSpec(): string { - return 'workspace:*' - } - - /** pnpm live links use link NPM dependencies. */ - override linkSpec(relativePath: string): string { - return `link:${relativePath}` - } -} - -/** Yarn Berry-compatible workspace behavior. */ -export class YarnPackageManager extends PackageManager { - override readonly name = 'yarn' - - /** Yarn classic is excluded because the generated project relies on modern workspaces. */ - override validateVersion(): void { - if (major(this.version) < 2) throw new Error(`Yarn >=2 is required, got ${this.version}`) - } - - /** Configure packageManager and package.json workspaces. */ - override configureWorkspace(manifest: PackageJsonFile): ProjectFile[] { - manifest.addWorkspace('plugins/*') - manifest.setPackageManager(`yarn@${this.version}`) - return [] - } - - /** Modern Yarn uses the workspace protocol. */ - override localPluginSpec(): string { - return 'workspace:*' - } - - /** Yarn live links use portal NPM dependencies to preserve package identity. */ - override linkSpec(relativePath: string): string { - return `portal:${relativePath}` - } - - /** Yarn runs scripts without the `run` token. */ - override buildCommand(): readonly string[] { - return ['build'] - } -} - -/** - * Construct and validate one package-manager strategy. - * @param name - selected manager. - * @param version - detected concrete version. - * @returns validated strategy. - */ -export function createPackageManager(name: PackageManagerName, version: string): PackageManager { - let manager: PackageManager - switch (name) { - case 'npm': manager = new NpmPackageManager(version); break - case 'pnpm': manager = new PnpmPackageManager(version); break - case 'yarn': manager = new YarnPackageManager(version); break - } - manager.validateVersion() - return manager -} - -/** - * Infer a package manager from an explicit choice or npm user-agent value. - * @param explicit - explicit CLI selection. - * @param userAgent - npm-compatible user-agent string. - * @returns selected or inferred manager name. - */ -export function inferPackageManagerName( - explicit: PackageManagerName | undefined, - userAgent: string | undefined = process.env.npm_config_user_agent, -): PackageManagerName | undefined { - if (explicit) return explicit - const token = userAgent?.split(' ')[0]?.split('/')[0] - if (token === 'npm' || token === 'pnpm' || token === 'yarn') return token - return undefined -} diff --git a/packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts b/packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts deleted file mode 100644 index da978e845c..0000000000 --- a/packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Source blueprints for local Cordis plugins generated under `plugins/*`. - * - * @module @deepseek-ai/dsh-helper/plugins/local-plugin-blueprint - */ - -import { TextProjectFile } from '../documents/project-file.ts' -import type { CordisConfigEntry } from '../documents/cordis-yaml-file.ts' -import { resolveNpmDependency } from '../project/npm-dependency-policy.ts' -import { loadHelperTemplate } from '../templates/template-assets.ts' - -/** Supported generated local-plugin shapes. */ -export type LocalPluginKind = 'plugin' | 'tool' - -function kebab(value: string): string { - const result = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') - if (!result || !/^[a-z]/.test(result)) throw new Error(`invalid local plugin name: ${JSON.stringify(value)}`) - return result -} - -function packageName(projectName: string, pluginName: string): string { - if (projectName.startsWith('@')) { - const separator = projectName.indexOf('/') - if (separator > 1 && separator < projectName.length - 1) { - return `${projectName.slice(0, separator)}/${projectName.slice(separator + 1)}-${pluginName}` - } - } - return `${projectName}-${pluginName}` -} - -interface LocalPluginTemplateContext { - pluginName: string - toolName: string - toolTitle: string -} - -const PLUGIN_SOURCE = loadHelperTemplate('local-plugin.ts.tpl') -const TOOL_SOURCE = loadHelperTemplate('local-tool.ts.tpl') -const PLUGIN_TSDOWN = loadHelperTemplate('local-plugin-tsdown.config.ts.tpl') - -/** One local plugin's derived package, source, build, and runtime entry. */ -export class LocalPluginBlueprint { - /** Normalized local package and Cordis config entry name. */ - readonly name: string - /** Generated plugin source shape. */ - readonly kind: LocalPluginKind - - /** Normalize and validate one local plugin request. */ - constructor(name: string, kind: LocalPluginKind) { - this.name = kebab(name) - this.kind = kind - } - - /** Root-relative plugin directory. */ - get directory(): string { - return `plugins/${this.name}` - } - - /** - * Derive an npm package name from the root project identity. - * @param projectName - generated root package name. - * @returns local plugin package name. - */ - packageName(projectName: string): string { - return packageName(projectName, this.name) - } - - /** - * Build the runtime Cordis config entry for this local package. - * @param projectName - generated root package name. - * @returns Loader entry referencing the local package. - */ - cordisConfigEntry(projectName: string): CordisConfigEntry { - return { id: this.name, name: this.packageName(projectName) } - } - - /** - * Render the complete local package files. - * @param projectName - generated root package name. - * @param releaseVersion - SDK dependency version. - * @returns local manifest, configs, and source documents. - */ - documents(projectName: string, releaseVersion: string): TextProjectFile[] { - const name = this.packageName(projectName) - const toolName = this.name.replaceAll('-', '_') - const cordisSpec = resolveNpmDependency('@deepseek-ai/cordis', 'devDependencies', releaseVersion).spec - const manifest = { - name, - version: '0.0.0', - private: true, - type: 'module', - main: 'lib/index.js', - types: 'lib/index.d.ts', - exports: { '.': { types: './lib/index.d.ts', default: './lib/index.js' } }, - peerDependencies: { - ...this.kind === 'tool' ? { '@deepseek-ai/dsh-tools': `^${releaseVersion}` } : {}, - '@deepseek-ai/cordis': cordisSpec, - }, - devDependencies: { - '@deepseek-ai/cordis': cordisSpec, - }, - } - const tsconfig = { - extends: '../../tsconfig.base.json', - compilerOptions: { rootDir: 'src', outDir: 'lib/types' }, - include: ['src'], - } - const context: LocalPluginTemplateContext = { - pluginName: this.name, - toolName, - toolTitle: toolName.replaceAll('_', ' '), - } - return [ - new TextProjectFile(`${this.directory}/package.json`, JSON.stringify(manifest, null, 2)), - new TextProjectFile(`${this.directory}/tsconfig.json`, JSON.stringify(tsconfig, null, 2)), - new TextProjectFile(`${this.directory}/tsdown.config.ts`, PLUGIN_TSDOWN.render(context)), - new TextProjectFile( - `${this.directory}/src/index.ts`, - (this.kind === 'tool' ? TOOL_SOURCE : PLUGIN_SOURCE).render(context), - ), - ] - } -} diff --git a/packages/scaffold/helper/src/project/change-set.ts b/packages/scaffold/helper/src/project/change-set.ts deleted file mode 100644 index d6207a8df1..0000000000 --- a/packages/scaffold/helper/src/project/change-set.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Result summary for one SDK project edit session. - * - * @module @deepseek-ai/dsh-helper/project/change-set - */ - -import type { FeatureId } from '../ids.ts' - -/** Immutable description of committed or pending project changes. */ -export interface ChangeSet { - addedFeatures: readonly FeatureId[] - enabledFeatures: readonly FeatureId[] - disabledFeatures: readonly FeatureId[] - configuredFeatures: readonly FeatureId[] - addedPlugins: readonly string[] - enabledPlugins: readonly string[] - disabledPlugins: readonly string[] - changedFiles: readonly string[] - npmDependenciesChanged: boolean -} - -/** Result of committing one project edit session. */ -export interface ProjectCommitResult { - project: TProject - changes: ChangeSet -} diff --git a/packages/scaffold/helper/src/project/npm-dependency-policy.ts b/packages/scaffold/helper/src/project/npm-dependency-policy.ts deleted file mode 100644 index a876973a29..0000000000 --- a/packages/scaffold/helper/src/project/npm-dependency-policy.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * NPM dependency baseline and version policy for generated SDK projects. - * - * @module @deepseek-ai/dsh-helper/project/npm-dependency-policy - */ - -import type { NpmDependencySection } from '../documents/package-json-file.ts' - -/** One NPM dependency spec selected by the SDK release policy. */ -export interface ResolvedNpmDependency { - section: NpmDependencySection - spec: string -} - -/** NPM dependency maps rendered into a newly created root package.json. */ -export interface BaselineNpmDependencies { - dependencies: Readonly> - devDependencies: Readonly> -} - -const EXTERNAL_NPM_DEPENDENCY_SPECS: Readonly> = { - '@deepseek-ai/cordis-plugin-hmr': '^1.0.15', - '@deepseek-ai/cordis-plugin-timer': '^1.1.2', - '@types/node': '^22.20.0', - '@deepseek-ai/cordis': '^4.0.0-rc.7', - tsdown: '0.22.2', - tsx: '^4.22.4', - typescript: '^6.0.3', -} - -const BASELINE_NPM_DEPENDENCY_NAMES: Readonly> = { - dependencies: ['@deepseek-ai/dsh-scripts', '@deepseek-ai/cordis'], - devDependencies: ['@types/node', 'tsdown', 'tsx', 'typescript'], -} - -/** Resolve one package to its generated-project section and version spec. */ -export function resolveNpmDependency( - name: string, - requestedSection: NpmDependencySection, - releaseVersion: string, -): ResolvedNpmDependency { - if (name.startsWith('@deepseek-ai/dsh-')) { - return { section: requestedSection, spec: `^${releaseVersion}` } - } - const spec = EXTERNAL_NPM_DEPENDENCY_SPECS[name] - if (spec) return { section: requestedSection, spec } - throw new Error(`no generated-project NPM dependency policy for ${name}`) -} - -/** Build the root package.json NPM dependency maps from the shared version policy. */ -export function baselineNpmDependencies(releaseVersion: string): BaselineNpmDependencies { - return { - dependencies: Object.fromEntries(BASELINE_NPM_DEPENDENCY_NAMES.dependencies.map((name) => { - const dependency = resolveNpmDependency(name, 'dependencies', releaseVersion) - return [name, dependency.spec] - })), - devDependencies: Object.fromEntries(BASELINE_NPM_DEPENDENCY_NAMES.devDependencies.map((name) => { - const dependency = resolveNpmDependency(name, 'devDependencies', releaseVersion) - return [name, dependency.spec] - })), - } -} diff --git a/packages/scaffold/helper/src/project/project-edit-session.ts b/packages/scaffold/helper/src/project/project-edit-session.ts deleted file mode 100644 index 3d86050017..0000000000 --- a/packages/scaffold/helper/src/project/project-edit-session.ts +++ /dev/null @@ -1,628 +0,0 @@ -/** - * Isolated domain-command and commit boundary for SDK project changes. - * - * @module @deepseek-ai/dsh-helper/project/project-edit-session - */ - -import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises' -import { dirname, resolve } from 'node:path' -import type { - Feature, - FeatureInstallation, - FeatureProjectView, - FeatureRequirement, -} from '../features/feature.ts' -import type { FeatureRegistry } from '../features/registry.ts' -import type { ProjectResource } from '../features/resources.ts' -import { CordisYamlFile, type CordisConfigEntry } from '../documents/cordis-yaml-file.ts' -import { EnvFile } from '../documents/env-file.ts' -import { PackageJsonFile, type PackageManifest } from '../documents/package-json-file.ts' -import { ProjectFile, TextProjectFile } from '../documents/project-file.ts' -import { TsConfigFile } from '../documents/tsconfig-file.ts' -import { featureId, type FeatureId, type ResourceKey } from '../ids.ts' -import { LinkWorkspace } from '../package-managers/link-workspace.ts' -import type { LocalPluginBlueprint } from '../plugins/local-plugin-blueprint.ts' -import type { FeatureSelection, ProjectProfile } from './types.ts' -import { resolveNpmDependency } from './npm-dependency-policy.ts' -import type { ChangeSet, ProjectCommitResult } from './change-set.ts' -import type { SdkProject } from './sdk-project.ts' - -interface MutableFeatureState { - selection?: FeatureSelection - state: FeatureInstallation['state'] -} - -function sameText(left: ProjectFile, right: ProjectFile | undefined): boolean { - return right !== undefined && left.serialize() === right.serialize() -} - -function npmDependencyShape(manifest: Readonly): string { - return JSON.stringify({ - /* v8 ignore next -- generated manifests always carry the managed dependency maps */ - dependencies: manifest.dependencies ?? {}, - /* v8 ignore next -- generated manifests always carry the managed dependency maps */ - devDependencies: manifest.devDependencies ?? {}, - }) -} - -function asError(error: unknown): Error { - /* v8 ignore else -- node:fs promise APIs reject Error objects */ - if (error instanceof Error) return error - /* v8 ignore next -- node:fs promise APIs reject Error objects */ - return new Error(String(error)) -} - -function canUpdateResource(previous: ProjectResource, next: ProjectResource): boolean { - if (previous.kind !== next.kind) return false - switch (previous.kind) { - case 'npm-dependency': return previous.name === (next as typeof previous).name - case 'package-script': return previous.name === (next as typeof previous).name - case 'cordis-config-entry': { - const candidate = next as typeof previous - return previous.entry.name === candidate.entry.name - } - case 'environment': return previous.name === (next as typeof previous).name - case 'owned-file': return previous.document.relativePath === (next as typeof previous).document.relativePath - } -} - -/** Mutable working copy that applies feature and local-plugin domain commands. */ -export class ProjectEditSession implements FeatureProjectView { - readonly profile: ProjectProfile - private readonly source: SdkProject - private readonly registry: FeatureRegistry - private readonly documents: Map - private readonly removed = new Map() - private readonly states = new Map() - private readonly added = new Set() - private readonly enabled = new Set() - private readonly disabled = new Set() - private readonly configured = new Set() - private readonly addedPlugins = new Set() - private readonly enabledPlugins = new Set() - private readonly disabledPlugins = new Set() - private committed = false - - /** Clone one project snapshot into an isolated working copy. */ - constructor(source: SdkProject, registry: FeatureRegistry) { - this.source = source - this.registry = registry - this.profile = source.profile - this.documents = source.cloneDocuments() - for (const feature of registry.all()) { - /* v8 ignore next -- no current built-in feature is interface-specific */ - if (!feature.isApplicable(this.profile)) continue - const installation = feature.inspect(this) - this.states.set(feature.id, { - state: installation.state, - ...installation.selection ? { selection: installation.selection } : {}, - }) - } - } - - /** Root manifest value for feature inspection. */ - packageManifest(): Readonly { - return this.manifest().value() - } - - /** Cordis config entries for feature and custom-plugin inspection. */ - cordisConfigEntries(): readonly CordisConfigEntry[] { - return this.cordis().entries() - } - - /** Whether one managed document exists in the working copy. */ - /* jscpd:ignore-start -- FeatureProjectView deliberately has symmetric snapshot/edit implementations. */ - hasDocument(path: string): boolean { - return this.documents.has(path) - } - - /** Read one unique working-copy environment variable. */ - readEnvironment(path: '.env' | '.env.example', name: string): string | undefined { - const document = this.documents.get(path) - if (!document) return undefined - if (!(document instanceof EnvFile)) throw new Error(`${path} is not an environment document`) - return document.get(name) - } - /* jscpd:ignore-end */ - - /** Inspect every applicable builtin against the current working copy. */ - inspections(): readonly FeatureInstallation[] { - return this.registry.inspect(this) - } - - /** Install a builtin and recursively satisfy its declared requirements. */ - installFeature(feature: Feature, selection: FeatureSelection): void { - this.assertOpen() - this.installFeatureRecursive(feature, selection, new Set()) - } - - /** Replace one installed builtin's feature-option and captured-input selection. */ - configureFeature(feature: Feature, selection: FeatureSelection): void { - this.assertOpen() - const current = this.state(feature) - if (current.state === 'inconsistent') throw new Error(`feature ${feature.id} is inconsistent`) - if (current.state === 'absent' || !current.selection) { - this.installFeature(feature, selection) - return - } - const normalized = feature.normalizeSelection(selection, this.profile) - this.ensureRequirements(feature, normalized, new Set([feature.id])) - this.replaceContribution( - feature.contribution(current.selection, this.profile), - feature.contribution(normalized, this.profile), - ) - current.selection = normalized - current.state = current.state === 'disabled' ? 'disabled' : 'enabled' - if (current.state === 'disabled') this.setFeatureDisabled(feature, normalized, true) - this.assertFeatureConsistent(feature) - this.configured.add(feature.id) - } - - /** Enable all entries owned by one installed feature. */ - enableFeature(feature: Feature): void { - this.assertOpen() - const current = this.state(feature) - if (current.state === 'inconsistent') throw new Error(`feature ${feature.id} is inconsistent`) - if (current.state === 'absent' || !current.selection) { - throw new Error(`feature ${feature.id} is not installed`) - } - this.setFeatureDisabled(feature, current.selection, false) - current.state = 'enabled' - this.assertFeatureConsistent(feature) - this.disabled.delete(feature.id) - this.enabled.add(feature.id) - } - - /** Disable an optional feature without removing its configuration. */ - disableFeature(feature: Feature): void { - this.assertOpen() - if (feature.required) throw new Error(`required feature ${feature.id} cannot be disabled`) - const current = this.state(feature) - if (current.state === 'inconsistent') throw new Error(`feature ${feature.id} is inconsistent`) - if (current.state === 'absent' || !current.selection) { - throw new Error(`feature ${feature.id} is not installed`) - } - const dependent = this.registry.all().find((candidate) => { - const state = this.states.get(candidate.id) - return state?.state === 'enabled' && state.selection - && candidate.requirements(state.selection).some(requirement => requirement.id === feature.id) - }) - if (dependent) throw new Error(`feature ${feature.id} is required by ${dependent.id}`) - this.setFeatureDisabled(feature, current.selection, true) - current.state = 'disabled' - this.assertFeatureConsistent(feature) - this.enabled.delete(feature.id) - this.disabled.add(feature.id) - } - - /** Add a generated local plugin and all four of its project registrations. */ - addPlugin(blueprint: LocalPluginBlueprint): void { - this.assertOpen() - const manifest = this.manifest() - const cordis = this.cordis() - const tsconfig = this.documents.get('tsconfig.json') - if (!(tsconfig instanceof TsConfigFile)) { - throw new Error('adding a local plugin requires a valid tsconfig.json') - } - const packageName = blueprint.packageName(this.profile.name) - if (manifest.npmDependency(packageName)) throw new Error(`root NPM dependency already exists: ${packageName}`) - const entry = blueprint.cordisConfigEntry(this.profile.name) - if (cordis.entry(entry.id)) throw new Error(`Cordis config entry already exists: ${entry.id}`) - const documents = blueprint.documents(this.profile.name, this.profile.releaseVersion) - for (const document of documents) { - if (this.documents.has(document.relativePath)) { - throw new Error(`local plugin file already exists: ${document.relativePath}`) - } - } - for (const document of documents) this.documents.set(document.relativePath, document) - manifest.setNpmDependency('dependencies', packageName, this.profile.packageManager.localPluginSpec()) - tsconfig.addReference(`./${blueprint.directory}`) - cordis.addEntry(entry) - this.addedPlugins.add(entry.id) - } - - /** - * Mount a Cordis entry for an external dependency the package manager has already - * added (github or npm), without generating files or re-adding the dependency. - * @param id - stable Cordis config entry id. - * @param packageName - the installed dependency's package name. - */ - addExternalPlugin(id: string, packageName: string): void { - this.assertOpen() - if (!this.manifest().npmDependency(packageName)) { - throw new Error(`external plugin dependency is not installed: ${packageName}`) - } - const cordis = this.cordis() - if (cordis.entry(id)) throw new Error(`Cordis config entry already exists: ${id}`) - cordis.addEntry({ id, name: packageName }) - this.addedPlugins.add(id) - } - - /** Enable or disable one custom/manual Cordis config entry by stable id. */ - setCustomPluginDisabled(id: string, disabled: boolean): void { - this.assertOpen() - const entry = this.cordis().entry(id) - if (!entry) throw new Error(`Cordis config entry does not exist: ${id}`) - if (this.registry.ownerOfPackage(entry.name, this.profile)) { - throw new Error(`Cordis config entry ${id} belongs to a builtin feature`) - } - this.cordis().setDisabled(id, disabled) - if (disabled) { - this.enabledPlugins.delete(id) - this.disabledPlugins.add(id) - } else { - this.disabledPlugins.delete(id) - this.enabledPlugins.add(id) - } - } - - /** Summarize all pending domain and file changes. */ - changes(): ChangeSet { - const changedFiles = new Set() - for (const [path, document] of this.documents) { - if (this.source.origin === 'create' || !sameText(document, this.source.document(path))) changedFiles.add(path) - } - for (const path of this.removed.keys()) changedFiles.add(path) - return { - addedFeatures: [...this.added].sort(), - enabledFeatures: [...this.enabled].sort(), - disabledFeatures: [...this.disabled].sort(), - configuredFeatures: [...this.configured].sort(), - addedPlugins: [...this.addedPlugins].sort(), - enabledPlugins: [...this.enabledPlugins].sort(), - disabledPlugins: [...this.disabledPlugins].sort(), - changedFiles: [...changedFiles].sort(), - npmDependenciesChanged: npmDependencyShape(this.manifest().value()) - !== npmDependencyShape(this.source.packageManifest()), - } - } - - /** Validate, detect external edits, write affected files, and return a fresh snapshot. */ - async commit(): Promise> { - this.assertOpen() - if (this.profile.linkWorkspaceRoot) { - const workspace = await LinkWorkspace.open(this.profile.linkWorkspaceRoot) - workspace.apply( - this.source.root, - this.manifest(), - this.profile.packageManager, - [...this.documents.values()], - ) - // Generated workspace members resolve their own dependencies, so the root - // manifest's links are not enough: relink every nested manifest as well. - for (const [path, document] of this.documents) { - if (path === 'package.json' || !path.endsWith('/package.json')) continue - const relinked = workspace.relinkNestedManifest( - this.source.root, - path, - document.serialize(), - this.profile.packageManager, - ) - this.documents.set(path, new TextProjectFile(path, relinked, document.originalText)) - } - } - this.validateFinalState() - const changes = this.changes() - await this.assertUnchanged(changes.changedFiles) - await mkdir(this.source.root, { recursive: true }) - for (const path of changes.changedFiles) { - const document = this.documents.get(path) - const absolute = resolve(this.source.root, path) - if (!document) { - await unlink(absolute) - continue - } - await mkdir(dirname(absolute), { recursive: true }) - await writeFile(absolute, document.serialize(), { - encoding: 'utf8', - ...document.createMode === undefined ? {} : { mode: document.createMode }, - }) - } - this.committed = true - return { project: await this.source.reopen(), changes } - } - - private installFeatureRecursive( - feature: Feature, - selection: FeatureSelection, - stack: Set, - ): void { - if (stack.has(feature.id)) throw new Error(`cyclic feature requirement involving ${feature.id}`) - const current = this.state(feature) - if (current.state === 'inconsistent') throw new Error(`feature ${feature.id} is inconsistent`) - if (current.state !== 'absent' && current.selection) { - this.configureFeature(feature, selection) - if (current.state === 'disabled') this.enableFeature(feature) - return - } - const normalized = feature.normalizeSelection(selection, this.profile) - const nextStack = new Set(stack).add(feature.id) - this.ensureRequirements(feature, normalized, nextStack) - this.replaceContribution(undefined, feature.contribution(normalized, this.profile)) - current.selection = normalized - current.state = 'enabled' - this.assertFeatureConsistent(feature) - this.added.add(feature.id) - } - - private ensureRequirements(feature: Feature, selection: FeatureSelection, stack: Set): void { - for (const requirement of feature.requirements(selection)) { - const required = this.registry.get(requirement.id) - const state = this.state(required) - if (state.state === 'inconsistent') throw new Error(`required feature ${required.id} is inconsistent`) - if (state.state === 'absent' || !state.selection) { - this.installFeatureRecursive(required, { - id: required.id, - options: requirement.options ?? required.defaultOptions(this.profile), - }, stack) - } else { - const next = this.selectionWithRequiredOptions(required, state.selection, requirement) - if (next !== state.selection) this.configureFeature(required, next) - if (state.state === 'disabled') this.enableFeature(required) - } - } - } - - private selectionWithRequiredOptions( - feature: Feature, - selection: FeatureSelection, - requirement: FeatureRequirement, - ): FeatureSelection { - if (!requirement.options || requirement.options.every(option => selection.options.includes(option))) { - return selection - } - if (feature.mode !== 'multiple') { - throw new Error(`${feature.id} does not satisfy the option requirement from another feature`) - } - return { ...selection, options: [...new Set([...selection.options, ...requirement.options])] } - } - - private replaceContribution( - previous: ReturnType | undefined, - next: ReturnType, - ): void { - const previousByKey = previous?.byKey() ?? new Map() - const nextByKey = next.byKey() - for (const [key, resource] of previousByKey) { - const replacement = nextByKey.get(key) - if (!replacement || !canUpdateResource(resource, replacement)) this.removeResource(resource) - } - for (const [key, resource] of nextByKey) { - const previousResource = previousByKey.get(key) - this.applyResource( - resource, - previousResource && canUpdateResource(previousResource, resource) ? previousResource : undefined, - ) - } - } - - private applyResource(resource: ProjectResource, previous: ProjectResource | undefined): void { - switch (resource.kind) { - case 'npm-dependency': { - const dependency = resolveNpmDependency(resource.name, resource.section, this.profile.releaseVersion) - this.manifest().setNpmDependency(dependency.section, resource.name, dependency.spec) - return - } - case 'package-script': { - const manifest = this.manifest() - const current = manifest.script(resource.name) - if (!previous || previous.kind !== 'package-script') { - if (current !== undefined) throw new Error(`feature-owned package script already exists: ${resource.name}`) - manifest.setScript(resource.name, resource.command) - return - } - if (current === resource.command) return - if (current !== previous.command) { - throw new Error(`feature-owned package script was modified: ${resource.name}`) - } - manifest.setScript(resource.name, resource.command) - return - } - case 'cordis-config-entry': { - const current = this.cordis().entry(resource.entry.id) - if (!current) this.cordis().addEntry(resource.entry, resource.commentedExample) - else { - if (current.name !== resource.entry.name) { - throw new Error(`Cordis config entry ${resource.entry.id} is owned by ${current.name}, not ${resource.entry.name}`) - } - this.cordis().updateOwnedConfig( - resource.entry.id, - resource.ownedConfigKeys, - resource.entry.config ?? {}, - ) - this.cordis().setDisabled(resource.entry.id, false) - } - return - } - case 'environment': { - this.environment('.env.example').set(resource.name, resource.exampleValue) - /* v8 ignore else -- an omitted secret intentionally materializes only its example placeholder */ - if (resource.value !== undefined) { - const environment = this.environment('.env') - environment.append( - resource.name, - resource.value, - resource.value === '' ? resource.comment : undefined, - ) - } - return - } - case 'owned-file': { - const existing = this.documents.get(resource.document.relativePath) - if (!existing) { - this.documents.set(resource.document.relativePath, resource.document.clone()) - this.removed.delete(resource.document.relativePath) - return - } - if (!previous || previous.kind !== 'owned-file') { - throw new Error(`feature-owned file already exists: ${resource.document.relativePath}`) - } - if (previous.document.serialize() === resource.document.serialize()) return - if (existing.serialize() !== previous.document.serialize()) { - throw new Error(`feature-owned file was modified: ${resource.document.relativePath}`) - } - this.documents.set(resource.document.relativePath, resource.document.clone()) - this.removed.delete(resource.document.relativePath) - return - } - } - } - - private removeResource(resource: ProjectResource): void { - switch (resource.kind) { - case 'npm-dependency': - this.manifest().removeNpmDependency(resource.section, resource.name) - return - case 'package-script': { - const manifest = this.manifest() - const current = manifest.script(resource.name) - if (current === undefined) throw new Error(`owned package script is missing: ${resource.name}`) - if (resource.removeOnlyWhenUnchanged && current !== resource.command) { - throw new Error(`feature-owned package script was modified: ${resource.name}`) - } - manifest.removeScript(resource.name) - return - } - case 'cordis-config-entry': { - const entry = this.cordis().entry(resource.entry.id) - if (!entry || entry.name !== resource.entry.name) { - throw new Error(`cannot confirm old Cordis resource ${resource.entry.id}`) - } - this.cordis().removeEntry(resource.entry.id) - return - } - case 'environment': - this.environment('.env.example').remove(resource.name) - return - case 'owned-file': { - const document = this.documents.get(resource.document.relativePath) - if (!document) throw new Error(`owned file is missing: ${resource.document.relativePath}`) - if (resource.removeOnlyWhenUnchanged && document.serialize() !== resource.document.serialize()) { - throw new Error(`owned file was modified: ${resource.document.relativePath}`) - } - this.documents.delete(resource.document.relativePath) - if (this.source.document(resource.document.relativePath)) { - this.removed.set(resource.document.relativePath, document) - } - } - } - } - - private setFeatureDisabled(feature: Feature, selection: FeatureSelection, disabled: boolean): void { - for (const resource of feature.contribution(selection, this.profile).resources) { - if (resource.kind === 'cordis-config-entry') this.cordis().setDisabled(resource.entry.id, disabled) - } - } - - private validateFinalState(): void { - for (const document of this.documents.values()) document.validate() - const profile = this.finalProfile() - const view = this.projectView(profile) - for (const feature of this.registry.all()) { - const state = this.states.get(feature.id) - /* v8 ignore next 5 -- no current built-in feature is interface-specific */ - if (!feature.isApplicable(profile)) { - if (state?.state === 'enabled') { - throw new Error(`feature ${feature.id} is not available for ${profile.runInterface}`) - } - continue - } - const installation = feature.inspect(view) - /* v8 ignore next 3 -- public domain commands assert feature consistency before final validation */ - if (installation.state === 'inconsistent') { - throw new Error(`feature ${feature.id} is inconsistent: ${installation.diagnostics.join('; ')}`) - } - /* v8 ignore next 3 -- required features are installed by creation and cannot be disabled by public commands */ - if (feature.required && installation.state !== 'enabled') { - throw new Error(`required feature ${feature.id} must be installed and enabled`) - } - if (installation.state !== 'enabled' || !installation.selection) continue - for (const requirement of feature.requirements(installation.selection)) { - const required = this.registry.get(requirement.id).inspect(view) - /* v8 ignore next 3 -- ensureRequirements establishes enabled requirements before contributions change */ - if (required.state !== 'enabled') { - throw new Error(`feature ${feature.id} requires enabled ${requirement.id}`) - } - for (const option of requirement.options ?? []) { - /* v8 ignore next 3 -- selectionWithRequiredOptions establishes required options before commit */ - if (!required.options.includes(option)) { - throw new Error(`feature ${feature.id} requires ${requirement.id} option ${option}`) - } - } - } - } - } - - private assertFeatureConsistent(feature: Feature): void { - const installation = feature.inspect(this) - /* v8 ignore next 3 -- resource application either succeeds completely or throws at the owning operation */ - if (installation.state === 'inconsistent') { - throw new Error(`feature ${feature.id} is inconsistent: ${installation.diagnostics.join('; ')}`) - } - } - - private finalProfile(): ProjectProfile { - const runInterface = this.states.get(featureId('app'))?.selection?.options[0] - if (runInterface !== 'acp' && runInterface !== 'embed') return this.profile - return { ...this.profile, runInterface } - } - - private projectView(profile: ProjectProfile): FeatureProjectView { - return { - profile, - cordisConfigEntries: () => this.cordisConfigEntries(), - packageManifest: () => this.packageManifest(), - hasDocument: path => this.hasDocument(path), - readEnvironment: (path, name) => this.readEnvironment(path, name), - } - } - - private async assertUnchanged(paths: readonly string[]): Promise { - for (const path of paths) { - const source = this.source.document(path) - const absolute = resolve(this.source.root, path) - try { - const current = await readFile(absolute, 'utf8') - if (source?.originalText === undefined || current !== source.originalText) { - throw new Error(`project file changed outside this edit session: ${path}`) - } - } catch (error) { - const code = (error as NodeJS.ErrnoException).code - if (code === 'ENOENT' && source?.originalText === undefined) continue - if (error instanceof Error && error.message.startsWith('project file changed outside')) throw error - throw new Error(`cannot verify project file ${path}: ${asError(error).message}`) - } - } - } - - private state(feature: Feature): MutableFeatureState { - const state = this.states.get(feature.id) - if (!state) throw new Error(`feature ${feature.id} is not applicable to this project`) - return state - } - - private manifest(): PackageJsonFile { - const document = this.documents.get('package.json') - if (!(document instanceof PackageJsonFile)) throw new Error('project package.json is missing') - return document - } - - private cordis(): CordisYamlFile { - const document = this.documents.get('cordis.yml') - if (!(document instanceof CordisYamlFile)) throw new Error('project cordis.yml is missing') - return document - } - - private environment(path: '.env' | '.env.example'): EnvFile { - const existing = this.documents.get(path) - if (existing instanceof EnvFile) return existing - if (existing) throw new Error(`${path} is not an environment document`) - const document = EnvFile.create(path) - this.documents.set(path, document) - return document - } - - private assertOpen(): void { - if (this.committed) throw new Error('project edit session has already committed') - } -} diff --git a/packages/scaffold/helper/src/project/sdk-project.ts b/packages/scaffold/helper/src/project/sdk-project.ts deleted file mode 100644 index 41b66fe7d0..0000000000 --- a/packages/scaffold/helper/src/project/sdk-project.ts +++ /dev/null @@ -1,309 +0,0 @@ -/** - * Read-only aggregate for one generated or existing SDK project. - * - * @module @deepseek-ai/dsh-helper/project/sdk-project - */ - -import { access, readFile } from 'node:fs/promises' -import { basename, resolve } from 'node:path' -import { CordisYamlFile, type CordisConfigEntry } from '../documents/cordis-yaml-file.ts' -import { EnvFile } from '../documents/env-file.ts' -import { PackageJsonFile, type PackageManifest } from '../documents/package-json-file.ts' -import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts' -import { ProjectFile, TextProjectFile } from '../documents/project-file.ts' -import { TsConfigFile } from '../documents/tsconfig-file.ts' -import { - createPackageManager, - type PackageManager, - type PackageManagerName, -} from '../package-managers/package-manager.ts' -import { - createBaselineProjectArtifacts, - createPackageJsonDoc, - createProjectTemplateContext, -} from '../templates/project-template.ts' -import type { ProjectCreationRequest, ProjectProfile, RunInterface } from './types.ts' -import type { FeatureRegistry } from '../features/registry.ts' -import { ProjectEditSession } from './project-edit-session.ts' - -/** Whether a project snapshot describes uncommitted creation or files on disk. */ -export type ProjectOrigin = 'create' | 'disk' - -const OPTIONAL_DOCUMENTS = [ - '.env', - '.env.example', - 'tsconfig.json', - 'pnpm-workspace.yaml', - 'hooks.json', - 'codex-hooks.json', - 'README.md', - 'index.ts', -] as const - -function runInterface(entries: readonly CordisConfigEntry[]): RunInterface { - if (entries.some(entry => entry.name === '@deepseek-ai/dsh-tui' - || entry.name.startsWith('@deepseek-ai/dsh-tui/'))) { - throw new Error('unsupported run interface: @deepseek-ai/dsh-tui has been removed') - } - if (entries.some(entry => entry.name === '@deepseek-ai/dsh-acp')) return 'acp' - return 'embed' -} - -function runtimeModel(entries: readonly CordisConfigEntry[]): string { - const acp = entries.find(entry => entry.name === '@deepseek-ai/dsh-acp') - if (typeof acp?.config?.model === 'string' && acp.config.model.length > 0) return acp.config.model - const provider = entries.find(entry => entry.name === '@deepseek-ai/dsh-llm-deepseek' - || entry.name === '@deepseek-ai/dsh-llm-pi-ai') - const models = provider?.config?.models - if (Array.isArray(models) && typeof models[0] === 'string') return models[0] - return 'deepseek-v4-flash' -} - -function releaseVersion(manifest: Readonly): string { - const spec = manifest.dependencies?.['@deepseek-ai/dsh-scripts'] - const match = spec && /(?:^|[^0-9])(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(spec) - return match?.[1] ?? '0.0.1' -} - -async function pathExists(path: string): Promise { - try { - await access(path) - return true - } catch (error) { - /* v8 ignore else -- the other arm requires a filesystem permission/IO fault from access */ - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false - /* v8 ignore next -- paired with the ignored defensive access-error arm above */ - throw error - } -} - -async function detectPackageManager(root: string, manifest: Readonly): Promise { - let name: PackageManagerName = 'npm' - let version = '10.0.0' - const field = manifest.packageManager - if (field) { - const match = /^(npm|pnpm|yarn)@(.+)$/.exec(field) - if (!match?.[1] || !match[2]) throw new Error(`invalid packageManager field: ${field}`) - name = match[1] as PackageManagerName - version = match[2] - } else if (await pathExists(resolve(root, 'pnpm-lock.yaml'))) { - name = 'pnpm' - version = '10.0.0' - } else if (await pathExists(resolve(root, 'yarn.lock'))) { - name = 'yarn' - version = '2.0.0' - } - return createPackageManager(name, version) -} - -function linkedRepositoryRoot(root: string, manifest: Readonly): string | undefined { - const spec = manifest.dependencies?.['@deepseek-ai/dsh-scripts'] - const match = /^(?:file|link|portal):(.+)\/packages\/scaffold\/scripts\/?$/.exec(spec ?? '') - return match?.[1] ? resolve(root, match[1]) : undefined -} - -function parseOptionalDocument(path: string, text: string): ProjectFile { - try { - switch (path) { - case '.env': return EnvFile.parse('.env', text) - case '.env.example': return EnvFile.parse('.env.example', text) - case 'tsconfig.json': return TsConfigFile.parse(text) - case 'pnpm-workspace.yaml': return PnpmWorkspaceFile.parse(text) - default: return new TextProjectFile(path, text, text) - } - } catch { - // Optional malformed resources do not invalidate the project aggregate; - // an operation that needs their structure checks the concrete document type. - return new TextProjectFile(path, text, text) - } -} - -/** A project snapshot whose documents can only be changed through {@link ProjectEditSession}. */ -export class SdkProject { - /** Absolute project directory. */ - readonly root: string - /** Whether this snapshot is an uncommitted blueprint or disk state. */ - readonly origin: ProjectOrigin - /** Project identity, runtime, interface, and package-manager context. */ - readonly profile: ProjectProfile - private readonly documents: ReadonlyMap - - private constructor( - root: string, - origin: ProjectOrigin, - profile: ProjectProfile, - documents: ReadonlyMap, - ) { - this.root = resolve(root) - this.origin = origin - this.profile = profile - this.documents = documents - } - - /** - * Build an in-memory project blueprint without touching the target directory. - * @param root - target project directory. - * @param request - complete creation request. - * @returns uncommitted project snapshot. - */ - static create(root: string, request: ProjectCreationRequest): SdkProject { - const app = request.features.find(selection => selection.id === 'app') - const selectedInterface = app?.options[0] - if (selectedInterface !== 'acp' && selectedInterface !== 'embed') { - throw new Error('project creation requires one app feature option') - } - const profile: ProjectProfile = { - name: request.name, - description: request.description, - runtime: request.runtime, - runInterface: selectedInterface, - packageManager: request.packageManager, - releaseVersion: request.releaseVersion, - ...request.linkWorkspaceRoot ? { linkWorkspaceRoot: resolve(request.linkWorkspaceRoot) } : {}, - } - const templates = createProjectTemplateContext(profile) - const manifest = createPackageJsonDoc(templates) - const documents = new Map() - documents.set(manifest.relativePath, manifest) - documents.set('cordis.yml', CordisYamlFile.create()) - documents.set('.env.example', EnvFile.create('.env.example')) - documents.set('tsconfig.json', TsConfigFile.create()) - for (const document of request.packageManager.configureWorkspace(manifest)) { - documents.set(document.relativePath, document) - } - for (const document of createBaselineProjectArtifacts(templates)) { - documents.set(document.relativePath, document) - } - return new SdkProject(root, 'create', profile, documents) - } - - /** - * Load an existing project from required and SDK-managed optional files. - * @param root - existing project directory. - * @returns disk-backed project snapshot. - * @throws When the config references the removed `@deepseek-ai/dsh-tui` root or a subpath. - */ - static async open(root: string): Promise { - const absolute = resolve(root) - const [manifestText, cordisText] = await Promise.all([ - readFile(resolve(absolute, 'package.json'), 'utf8'), - readFile(resolve(absolute, 'cordis.yml'), 'utf8'), - ]) - const manifest = PackageJsonFile.parse(manifestText) - const cordis = CordisYamlFile.parse(cordisText) - const value = manifest.value() - const manager = await detectPackageManager(absolute, value) - const entries = cordis.entries() - const linkWorkspaceRoot = linkedRepositoryRoot(absolute, value) - const profile: ProjectProfile = { - name: value.name ?? basename(absolute), - description: typeof value.description === 'string' ? value.description : '', - runtime: { model: runtimeModel(entries) }, - runInterface: runInterface(entries), - packageManager: manager, - releaseVersion: releaseVersion(value), - ...linkWorkspaceRoot ? { linkWorkspaceRoot } : {}, - } - const documents = new Map([ - ['package.json', manifest], - ['cordis.yml', cordis], - ]) - await Promise.all(OPTIONAL_DOCUMENTS.map(async (path) => { - try { - const text = await readFile(resolve(absolute, path), 'utf8') - documents.set(path, parseOptionalDocument(path, text)) - } catch (error) { - /* v8 ignore next -- optional-file reads fail normally only with ENOENT; other IO faults surface */ - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - } - })) - return new SdkProject(absolute, 'disk', profile, documents) - } - - /** - * Read the root package manifest defensively. - * @returns cloned manifest value. - */ - packageManifest(): Readonly { - return this.packageJson.value() - } - - /** - * Read Cordis config entries defensively in file order. - * @returns cloned Cordis config entries. - */ - cordisConfigEntries(): readonly CordisConfigEntry[] { - return this.cordis.entries() - } - - /** - * Check whether this snapshot contains one managed document. - * @param path - project-relative document path. - * @returns whether the document is loaded. - */ - hasDocument(path: string): boolean { - return this.documents.has(path) - } - - /** - * Read one environment variable from a loaded dotenv document. - * @param path - environment file to read. - * @param name - variable name. - * @returns variable value when present. - */ - readEnvironment(path: '.env' | '.env.example', name: string): string | undefined { - const document = this.documents.get(path) - if (!document) return undefined - if (!(document instanceof EnvFile)) throw new Error(`${path} is not an environment document`) - return document.get(name) - } - - /** Read the root package document. */ - get packageJson(): PackageJsonFile { - const document = this.documents.get('package.json') - if (!(document instanceof PackageJsonFile)) throw new Error('project package.json is missing or invalid') - return document - } - - /** Read the root Cordis document. */ - get cordis(): CordisYamlFile { - const document = this.documents.get('cordis.yml') - if (!(document instanceof CordisYamlFile)) throw new Error('project cordis.yml is missing or invalid') - return document - } - - /** - * Return one managed document without exposing the aggregate map. - * @param path - project-relative document path. - * @returns loaded document when present. - */ - document(path: string): ProjectFile | undefined { - return this.documents.get(path) - } - - /** - * Create the only mutable boundary for this snapshot. - * @param registry - feature catalog governing edits. - * @returns isolated edit session. - */ - edit(registry: FeatureRegistry): ProjectEditSession { - return new ProjectEditSession(this, registry) - } - - /** - * Clone every managed document for an isolated edit session. - * @returns project-relative document map. - */ - cloneDocuments(): Map { - return new Map([...this.documents].map(([path, document]) => [path, document.clone()])) - } - - /** - * Reload this aggregate from committed disk state. - * @returns fresh disk-backed snapshot. - */ - reopen(): Promise { - return SdkProject.open(this.root) - } - -} diff --git a/packages/scaffold/helper/src/project/types.ts b/packages/scaffold/helper/src/project/types.ts deleted file mode 100644 index 72f4259e88..0000000000 --- a/packages/scaffold/helper/src/project/types.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Shared creation and project-profile values for SDK project editing. - * - * @module @deepseek-ai/dsh-helper/project/types - */ - -import type { PackageManager } from '../package-managers/package-manager.ts' -import type { LocalPluginBlueprint } from '../plugins/local-plugin-blueprint.ts' -import type { FeatureId } from '../ids.ts' - -/** Run interface selected for a generated project. */ -export type RunInterface = 'acp' | 'embed' - -/** Values shared by the required provider and app features. */ -interface ProjectRuntimeOptions { - model: string -} - -/** Selected options and captured secrets for one feature. */ -export interface FeatureSelection { - id: FeatureId - options: readonly string[] - values?: Readonly> - secrets?: Readonly> -} - -/** Stable context available to project and feature objects. */ -export interface ProjectProfile { - name: string - description: string - runtime: ProjectRuntimeOptions - runInterface: RunInterface - packageManager: PackageManager - releaseVersion: string - linkWorkspaceRoot?: string -} - -/** Fully collected create request; it contains intent, never rendered file text. */ -export interface ProjectCreationRequest { - name: string - description: string - runtime: ProjectRuntimeOptions - packageManager: PackageManager - releaseVersion: string - linkWorkspaceRoot?: string - features: readonly FeatureSelection[] - localPlugins: readonly LocalPluginBlueprint[] -} diff --git a/packages/scaffold/helper/src/questions/clack-nested-multiselect.ts b/packages/scaffold/helper/src/questions/clack-nested-multiselect.ts deleted file mode 100644 index 1633bdedf7..0000000000 --- a/packages/scaffold/helper/src/questions/clack-nested-multiselect.ts +++ /dev/null @@ -1,304 +0,0 @@ -/** - * Tree-shaped Clack picker for root checkboxes with finite child options. - * - * @module @deepseek-ai/dsh-helper/questions/clack-nested-multiselect - */ - -import { styleText } from 'node:util' -import type { Readable, Writable } from 'node:stream' -import { Prompt, isCancel } from '@clack/core' -import { - S_BAR, - S_BAR_END, - S_CHECKBOX_ACTIVE, - S_CHECKBOX_INACTIVE, - S_CHECKBOX_SELECTED, - S_RADIO_ACTIVE, - S_RADIO_INACTIVE, - symbol, - symbolBar, -} from '@clack/prompts' -import type { - NestedMultiSelectOption, - NestedMultiSelectRequest, - NestedMultiSelectValue, - PromptOutcome, -} from './prompt-port.ts' - -interface NestedPromptOptions extends NestedMultiSelectRequest { - input: Readable - output: Writable -} - -class NestedPrompt extends Prompt[]> { - readonly options: readonly NestedMultiSelectOption[] - private readonly selected = new Set() - private readonly selectedChoices = new Map>() - private readonly initialSelected: Set - private readonly initialChoices: Map> - private readonly showChanges: boolean - private layer: 'root' | 'choices' = 'root' - private rootCursor = 0 - private choiceCursor = 0 - - constructor(options: NestedPromptOptions) { - super({ - input: options.input, - output: options.output, - validate: value => NestedPrompt.validate(options.options, value), - render(this: Prompt[]>) { - return (this as NestedPrompt).renderFrame(options.message) - }, - }, false) - this.options = options.options - this.showChanges = options.showChanges ?? false - for (const option of options.options) { - if (option.required || option.default) this.selected.add(option.value) - this.selectedChoices.set(option.value, new Set( - option.choices?.filter(choice => choice.default).map(choice => choice.value) ?? [], - )) - } - this.initialSelected = new Set(this.selected) - this.initialChoices = new Map([...this.selectedChoices].map(([value, choices]) => [ - value, new Set(choices), - ])) - this.updateValue() - this.on('cursor', (action) => { this.handleAction(action) }) - } - - private static validate( - options: readonly NestedMultiSelectOption[], - value: readonly NestedMultiSelectValue[] | undefined, - ): string | undefined { - /* v8 ignore next -- NestedPrompt initializes its value before submission validation */ - const selected = new Map(value?.map(item => [item.value, item.choices]) ?? []) - for (const option of options) { - if (option.disabled) continue - /* v8 ignore next -- required options initialize selected and cannot be toggled off */ - if (option.required && !selected.has(option.value)) return `${option.label} is required` - if (!selected.has(option.value) || !option.choiceMode) continue - const choices = selected.get(option.value) - /* v8 ignore next -- selected.has above guarantees the map value exists */ - if (!choices) continue - const count = choices.length - if (option.choiceMode === 'exclusive' && count !== 1) return `Choose one ${option.label} option` - if (option.choiceMode === 'multiple' && count === 0) return `Choose at least one ${option.label} option` - } - return undefined - } - - protected override _shouldSubmit(): boolean { - if (this.layer === 'choices') { - this.leaveChoices() - return false - } - return true - } - - private handleAction(action: string | undefined): void { - if (this.layer === 'root') this.handleRootAction(action) - else this.handleChoiceAction(action) - this.updateValue() - } - - private handleRootAction(action: string | undefined): void { - if (action === 'up') this.rootCursor = this.move(this.rootCursor, -1, this.options.length) - if (action === 'down') this.rootCursor = this.move(this.rootCursor, 1, this.options.length) - const option = this.options[this.rootCursor] - /* v8 ignore next -- Clack cannot emit a cursor action when the option list is empty */ - if (!option) return - if (action === 'space' && !option.required && !option.disabled) { - if (this.selected.has(option.value)) this.selected.delete(option.value) - else this.selected.add(option.value) - } - if (action === 'right' && !option.disabled && option.choices && option.choices.length > 0) { - this.selected.add(option.value) - this.layer = 'choices' - const selected = this.selectedChoices.get(option.value) - const selectedIndex = option.choices.findIndex(choice => selected?.has(choice.value)) - this.choiceCursor = Math.max(selectedIndex, 0) - } - } - - private handleChoiceAction(action: string | undefined): void { - const rootOption = this.options[this.rootCursor] - /* v8 ignore next -- the choices layer is entered only from a concrete root option */ - if (!rootOption) return - /* v8 ignore next -- the choices layer is entered only for a non-empty choices array */ - const choices = rootOption.choices ?? [] - if (action === 'left') { - this.leaveChoices() - return - } - if (action === 'up') this.choiceCursor = this.move(this.choiceCursor, -1, choices.length) - if (action === 'down') this.choiceCursor = this.move(this.choiceCursor, 1, choices.length) - if ((action === 'up' || action === 'down') && rootOption.choiceMode === 'exclusive') { - const choice = choices[this.choiceCursor] - /* v8 ignore else -- a cursor in the non-empty choices layer always addresses a choice */ - if (choice) this.selectedChoices.set(rootOption.value, new Set([choice.value])) - } - if (action !== 'space' && action !== 'right') return - const choice = choices[this.choiceCursor] - /* v8 ignore next -- the choices layer requires a non-empty choice list */ - if (!choice) return - /* v8 ignore next -- every root option initializes its choice set in the constructor */ - const selected = this.selectedChoices.get(rootOption.value) ?? new Set() - if (rootOption.choiceMode === 'exclusive') { - selected.clear() - selected.add(choice.value) - } else if (selected.has(choice.value)) selected.delete(choice.value) - else selected.add(choice.value) - this.selectedChoices.set(rootOption.value, selected) - } - - private move(cursor: number, offset: number, length: number): number { - /* v8 ignore next -- cursor movement is emitted only for a non-empty displayed list */ - if (length === 0) return 0 - return (cursor + offset + length) % length - } - - private updateValue(): void { - this._setValue(this.options.filter(option => this.selected.has(option.value)).map(option => ({ - value: option.value, - /* v8 ignore next -- every root option initializes its choice set in the constructor */ - choices: [...this.selectedChoices.get(option.value) ?? []], - }))) - } - - private renderFrame(message: string): string { - const header = `${symbolBar(this.state)} ${message}` - if (this.state === 'submit') { - /* v8 ignore next -- NestedPrompt initializes its value before it can submit */ - const summary = (this.value ?? []).map(item => this.options.find(option => option.value === item.value)?.label) - .filter(Boolean).join(', ') || 'none' - return `${symbol(this.state)} ${message}\n${styleText('gray', S_BAR)} ${styleText('dim', summary)}` - } - if (this.state === 'cancel') return `${symbol(this.state)} ${message}` - const body = this.layer === 'root' ? this.renderRoot() : this.renderChoices() - const instructions = this.layer === 'root' - ? `${styleText('dim', '↑/↓')} navigate ${styleText('dim', 'Space')} select ${styleText('dim', '→')} configure ${styleText('dim', 'Enter')} confirm` - : `${styleText('dim', '↑/↓')} navigate ${styleText('dim', 'Space/→')} select ${styleText('dim', '←/Enter')} back` - const error = this.state === 'error' ? `\n${styleText('yellow', `${S_BAR_END} ${this.error}`)}` : '' - return `${header}\n${styleText('cyan', S_BAR)} ${body.join(`\n${styleText('cyan', S_BAR)} `)}\n${styleText('cyan', S_BAR_END)} ${instructions}${error}` - } - - private renderRoot(): string[] { - return this.options.map((option, index) => { - const active = index === this.rootCursor - const selected = this.selected.has(option.value) - const focus = active ? styleText('cyan', '›') : ' ' - const checkbox = selected - ? styleText('green', S_CHECKBOX_SELECTED) - : styleText('dim', active ? S_CHECKBOX_ACTIVE : S_CHECKBOX_INACTIVE) - const choices = option.choices?.filter(choice => this.selectedChoices.get(option.value)?.has(choice.value)) - .map(choice => choice.label).join(', ') - const suffix = option.choices?.length - ? ` ${styleText('dim', `* →${choices ? ` ${choices}` : ''}`)}` - : '' - const required = option.required ? ` ${styleText('yellow', '(required)')}` : '' - const issue = this.choiceIssue(option) - const warningText = option.warning ?? issue - const warning = warningText ? ` ${styleText('yellow', `▲ ${warningText}`)}` : '' - const changed = this.optionChanged(option) - const change = changed ? ` ${styleText('yellow', '● changed')}` : '' - const label = active - ? styleText('cyan', option.label) - : changed - ? styleText('yellow', option.label) - : selected ? styleText('green', option.label) : styleText('dim', option.label) - const line = `${focus} ${checkbox} ${label}${required}${suffix}${warning}${change}` - return option.disabled ? styleText('gray', line) : line - }) - } - - private renderChoices(): string[] { - const rootOption = this.options[this.rootCursor] - /* v8 ignore next -- renderChoices runs only after entering from a concrete root option */ - if (!rootOption) return [] - /* v8 ignore next -- every root option initializes its choice set in the constructor */ - const selected = this.selectedChoices.get(rootOption.value) ?? new Set() - const issue = this.choiceIssue(rootOption) - const changed = this.optionChanged(rootOption) - const header = styleText('dim', `${rootOption.label} options`) - + (issue ? ` ${styleText('yellow', `▲ ${issue}`)}` : '') - + (changed ? ` ${styleText('yellow', '● changed')}` : '') - const choices = rootOption.choices - /* v8 ignore next -- the choices layer is entered only for a non-empty choices array */ - if (!choices) return [header] - return [ - header, - ...choices.map((choice, index) => { - const active = index === this.choiceCursor - const checked = selected.has(choice.value) - const choiceChanged = this.choiceChanged(rootOption.value, choice.value) - const focus = active ? styleText('cyan', '›') : ' ' - const marker = rootOption.choiceMode === 'exclusive' - ? checked ? styleText('green', S_RADIO_ACTIVE) : styleText('dim', S_RADIO_INACTIVE) - : checked ? styleText('green', S_CHECKBOX_SELECTED) : styleText('dim', S_CHECKBOX_INACTIVE) - const label = active - ? styleText('cyan', choice.label) - : choiceChanged - ? styleText('yellow', choice.label) - : checked ? styleText('green', choice.label) : styleText('dim', choice.label) - const change = choiceChanged ? ` ${styleText('yellow', '●')}` : '' - return `${focus} ${marker} ${label}${change}` - }), - ] - } - - private optionChanged(option: NestedMultiSelectOption): boolean { - if (!this.showChanges) return false - const selected = this.selected.has(option.value) - const initiallySelected = this.initialSelected.has(option.value) - if (selected !== initiallySelected) return true - if (!selected) return false - /* v8 ignore next -- every root option initializes both current and baseline option sets */ - const current = this.selectedChoices.get(option.value) ?? new Set() - /* v8 ignore next -- every root option initializes both current and baseline option sets */ - const initial = this.initialChoices.get(option.value) ?? new Set() - return current.size !== initial.size || [...current].some(value => !initial.has(value)) - } - - private choiceChanged(value: TValue, choice: TChoice): boolean { - if (!this.showChanges) return false - return this.selectedChoices.get(value)?.has(choice) !== this.initialChoices.get(value)?.has(choice) - } - - private choiceIssue(option: NestedMultiSelectOption): string | undefined { - if (option.disabled || !this.selected.has(option.value) || !option.choiceMode) return undefined - /* v8 ignore next -- every root option initializes its choice set in the constructor */ - const count = this.selectedChoices.get(option.value)?.size ?? 0 - if (option.choiceMode === 'exclusive' && count !== 1) return 'choose one' - if (option.choiceMode === 'multiple' && count === 0) return 'choose at least one' - return undefined - } - - private leaveChoices(): boolean { - const option = this.options[this.rootCursor] - /* v8 ignore next -- leaveChoices runs only after entering from a concrete root option */ - if (!option) return false - const issue = this.choiceIssue(option) - if (issue) { - this.error = `${option.label}: ${issue}` - this.state = 'error' - return false - } - this.error = '' - this.layer = 'root' - return true - } -} - -/** Run the nested picker with Clack's standard cancellation symbol. */ -export async function clackNestedMultiselect( - request: NestedPromptOptions, -): Promise[]>> { - const value = await new NestedPrompt(request).prompt() - return isCancel(value) - ? { status: 'cancelled' } - : { - status: 'answered', - /* v8 ignore next -- NestedPrompt initializes its value before it can submit */ - value: value ?? [], - } -} diff --git a/packages/scaffold/helper/src/questions/clack-prompt-port.ts b/packages/scaffold/helper/src/questions/clack-prompt-port.ts deleted file mode 100644 index c5ebff1427..0000000000 --- a/packages/scaffold/helper/src/questions/clack-prompt-port.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Thin @clack/prompts adapter for the shared prompt port. - * - * @module @deepseek-ai/dsh-helper/questions/clack-prompt-port - */ - -import type { Readable, Writable } from 'node:stream' -import { styleText } from 'node:util' -import { - confirm, - isCancel, - multiselect, - password, - select, - text, - S_WARN, -} from '@clack/prompts' -import type { Option } from '@clack/prompts' -import type { - ConfirmPromptRequest, - MultiSelectPromptRequest, - NestedMultiSelectRequest, - NestedMultiSelectValue, - PromptOutcome, - PromptPort, - SecretPromptRequest, - SelectPromptRequest, - TextPromptRequest, -} from './prompt-port.ts' -import { clackNestedMultiselect } from './clack-nested-multiselect.ts' - -function outcome(value: T | symbol): PromptOutcome { - return isCancel(value) ? { status: 'cancelled' } : { status: 'answered', value } -} - -function clackOptions(values: readonly import('./prompt-port.ts').PromptOption[]): Option[] { - return values.map(value => ({ - value: value.value, - label: value.label, - ...value.hint === undefined ? {} : { hint: value.hint }, - ...value.disabled === undefined ? {} : { disabled: value.disabled }, - })) as Option[] -} - -/** Clack-backed prompt adapter with injectable streams for snapshots and tests. */ -export class ClackPromptPort implements PromptPort { - private readonly input: Readable - private readonly output: Writable - - /** Bind all prompts to one input/output pair. */ - constructor(input: Readable = process.stdin, output: Writable = process.stdout) { - this.input = input - this.output = output - } - - /** Ask for visible text through clack. */ - async text(request: TextPromptRequest): Promise> { - return outcome(await text({ - message: request.message, - ...request.placeholder === undefined ? {} : { placeholder: request.placeholder }, - ...request.initialValue === undefined ? {} : { initialValue: request.initialValue }, - ...request.defaultValue === undefined ? {} : { defaultValue: request.defaultValue }, - ...request.validate === undefined - ? {} - : { - /* v8 ignore next -- value/default precedence is exercised through the adapter contract tests */ - validate: value => request.validate?.(value || request.defaultValue || ''), - }, - input: this.input, - output: this.output, - })) - } - - /** Ask for a masked secret through clack. */ - async secret(request: SecretPromptRequest): Promise> { - return outcome(await password({ - message: request.message, - ...request.validate === undefined ? {} : { - /* v8 ignore next -- @clack/password always calls validation with a string; fallback is defensive */ - validate: value => request.validate?.(value ?? ''), - }, - input: this.input, - output: this.output, - })) - } - - /** Ask for one option through clack. */ - async select(request: SelectPromptRequest): Promise> { - return outcome(await select({ - ...request, - options: clackOptions(request.options), - input: this.input, - output: this.output, - })) - } - - /** Ask for multiple options through clack. */ - async multiselect(request: MultiSelectPromptRequest): Promise> { - return outcome(await multiselect({ - message: request.message, - options: clackOptions(request.options), - ...request.initialValues === undefined ? {} : { initialValues: [...request.initialValues] }, - ...request.required === undefined ? {} : { required: request.required }, - input: this.input, - output: this.output, - })) - } - - /** Ask for confirmation through clack. */ - async confirm(request: ConfirmPromptRequest): Promise> { - return outcome(await confirm({ - message: request.tone === 'warning' - ? styleText('yellow', `${S_WARN} ${request.message}`) - : request.message, - ...request.initialValue === undefined ? {} : { initialValue: request.initialValue }, - input: this.input, - output: this.output, - })) - } - - /** Select root values and finite child options in one tree prompt. */ - nestedMultiselect( - request: NestedMultiSelectRequest, - ): Promise[]>> { - return clackNestedMultiselect({ ...request, input: this.input, output: this.output }) - } -} diff --git a/packages/scaffold/helper/src/questions/headless-prompt-port.ts b/packages/scaffold/helper/src/questions/headless-prompt-port.ts deleted file mode 100644 index 658500aed0..0000000000 --- a/packages/scaffold/helper/src/questions/headless-prompt-port.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Non-interactive prompt port for headless create/config and skill-driven runs. - * - * @module @deepseek-ai/dsh-helper/questions/headless-prompt-port - */ - -import type { - ConfirmPromptRequest, - MultiSelectPromptRequest, - NestedMultiSelectRequest, - NestedMultiSelectValue, - PromptOutcome, - PromptPort, - SecretPromptRequest, - SelectPromptRequest, - TextPromptRequest, -} from './prompt-port.ts' - -/** - * Raised when a headless run reaches a decision that was neither prefilled nor - * carries a usable default. The message names the unanswered prompt so an agent - * or CI caller can see exactly which input the spec must supply. - */ -export class HeadlessPromptError extends Error { - /** The unanswered prompt's user-facing message. */ - readonly prompt: string - - /** Build an error naming the unanswered prompt. */ - constructor(prompt: string) { - super(`headless run needs an answer for: ${prompt}`) - this.name = 'HeadlessPromptError' - this.prompt = prompt - } -} - -/** Resolve an answered outcome. */ -function answered(value: T): Promise> { - return Promise.resolve({ status: 'answered', value }) -} - -/** Reject with a named unanswered-prompt error. */ -function unanswered(message: string): Promise> { - return Promise.reject(new HeadlessPromptError(message)) -} - -/** - * A {@link PromptPort} that never blocks on a terminal. - * - * Answers are expected to arrive as prefilled values through the `Question` / - * `FeatureConfigurator` layers, so in a fully specified run this port is never - * reached. When it *is* reached, it takes the prompt's own declared default - * (`defaultValue` / `initialValue`) if one exists; otherwise it fails loud with - * {@link HeadlessPromptError}. Nested feature selection has no scalar default, - * so it always fails loud — headless callers must supply the feature set through - * the spec rather than the tree picker. - */ -export class HeadlessPromptPort implements PromptPort { - /** Answer visible text from its default, or fail loud. */ - text(request: TextPromptRequest): Promise> { - const fallback = request.initialValue ?? request.defaultValue - if (fallback === undefined) return unanswered(request.message) - const diagnostic = request.validate?.(fallback) - if (diagnostic) return unanswered(`${request.message} (${diagnostic})`) - return answered(fallback) - } - - /** A secret has no safe default: always fail loud. */ - secret(request: SecretPromptRequest): Promise> { - return unanswered(request.message) - } - - /** Answer a single choice from its initial value, or fail loud. */ - select(request: SelectPromptRequest): Promise> { - if (request.initialValue === undefined) return unanswered(request.message) - return answered(request.initialValue) - } - - /** Answer a multi-choice from its initial values, or fail loud when required. */ - multiselect(request: MultiSelectPromptRequest): Promise> { - const initial = request.initialValues ?? [] - if (request.required && initial.length === 0) return unanswered(request.message) - return answered(initial) - } - - /** Answer a confirmation from its initial value, or fail loud. */ - confirm(request: ConfirmPromptRequest): Promise> { - if (request.initialValue === undefined) return unanswered(request.message) - return answered(request.initialValue) - } - - /** Nested feature selection has no scalar default: always fail loud. */ - nestedMultiselect( - request: NestedMultiSelectRequest, - ): Promise[]>> { - return unanswered(request.message) - } -} diff --git a/packages/scaffold/helper/src/questions/prompt-port.ts b/packages/scaffold/helper/src/questions/prompt-port.ts deleted file mode 100644 index f37fdff025..0000000000 --- a/packages/scaffold/helper/src/questions/prompt-port.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Terminal-prompt port shared by create and config workflows. - * - * @module @deepseek-ai/dsh-helper/questions/prompt-port - */ - -/** One selectable prompt option. */ -export interface PromptOption { - value: T - label: string - hint?: string - disabled?: boolean -} - -/** Answer or explicit cancellation returned by every prompt. */ -export type PromptOutcome = - | { status: 'answered'; value: T } - | { status: 'cancelled' } - -/** Input for one text prompt. */ -export interface TextPromptRequest { - message: string - placeholder?: string - initialValue?: string - defaultValue?: string - validate?: (value: string) => string | undefined -} - -/** Input for one masked secret prompt. */ -export interface SecretPromptRequest { - message: string - validate?: (value: string) => string | undefined -} - -/** Input for one single-choice prompt. */ -export interface SelectPromptRequest { - message: string - options: readonly PromptOption[] - initialValue?: T -} - -/** Input for one additive multi-choice prompt. */ -export interface MultiSelectPromptRequest { - message: string - options: readonly PromptOption[] - initialValues?: readonly T[] - required?: boolean -} - -/** Input for one yes/no prompt. */ -export interface ConfirmPromptRequest { - message: string - initialValue?: boolean - tone?: 'default' | 'warning' -} - -/** One nested choice under a multi-select option. */ -interface NestedSelectChoice { - value: T - label: string - default?: boolean -} - -/** One root option with optional child option configuration. */ -export interface NestedMultiSelectOption { - value: TValue - label: string - required?: boolean - default?: boolean - disabled?: boolean - warning?: string - choiceMode?: 'exclusive' | 'multiple' - choices?: readonly NestedSelectChoice[] -} - -/** Input for a tree-shaped feature-style picker. */ -export interface NestedMultiSelectRequest { - message: string - options: readonly NestedMultiSelectOption[] - showChanges?: boolean -} - -/** One selected root option and its child options. */ -export interface NestedMultiSelectValue { - value: TValue - choices: readonly TChoice[] -} - -/** Interaction boundary consumed by typed question objects. */ -export interface PromptPort { - /** Ask for one line of visible text. */ - text(request: TextPromptRequest): Promise> - /** Ask for one masked value. */ - secret(request: SecretPromptRequest): Promise> - /** Ask for exactly one option. */ - select(request: SelectPromptRequest): Promise> - /** Ask for zero or more options. */ - multiselect(request: MultiSelectPromptRequest): Promise> - /** Ask for a boolean confirmation. */ - confirm(request: ConfirmPromptRequest): Promise> - /** Select root options and configure finite child options in one tree prompt. */ - nestedMultiselect( - request: NestedMultiSelectRequest, - ): Promise[]>> -} - -/** Error used when a workflow chooses to turn prompt cancellation into command cancellation. */ -export class PromptCancelledError extends Error { - /** Create a stable cancellation error. */ - constructor(message = 'operation cancelled') { - super(message) - this.name = 'PromptCancelledError' - } -} - -/** - * Return an answered value or throw the shared cancellation error. - * @param outcome - prompt result to unwrap. - * @returns answered value. - */ -export function requireAnswer(outcome: PromptOutcome): T { - if (outcome.status === 'cancelled') throw new PromptCancelledError() - return outcome.value -} diff --git a/packages/scaffold/helper/src/questions/question.ts b/packages/scaffold/helper/src/questions/question.ts deleted file mode 100644 index 45697d7eb5..0000000000 --- a/packages/scaffold/helper/src/questions/question.ts +++ /dev/null @@ -1,206 +0,0 @@ -/** - * Typed question objects with prefill, validation, and prompt behavior together. - * - * @module @deepseek-ai/dsh-helper/questions/question - */ - -import type { PromptOption, PromptOutcome, PromptPort } from './prompt-port.ts' - -function resolvePrefilled( - id: string, - value: string | undefined, - validate: ((value: string) => string | undefined) | undefined, -): PromptOutcome | undefined { - if (value === undefined) return undefined - const diagnostic = validate?.(value) - if (diagnostic) throw new Error(`${id}: ${diagnostic}`) - return { status: 'answered', value } -} - -/** A typed business question resolved from prefilled input or one prompt call. */ -export abstract class Question { - /** Stable question identity used in diagnostics. */ - readonly id: string - /** User-facing prompt text. */ - readonly message: string - - protected constructor(id: string, message: string) { - this.id = id - this.message = message - } - - /** - * Resolve a prefilled answer without prompting, or ask through the port. - * @param port - prompt interaction boundary. - * @param prefilled - optional value supplied by CLI or current project state. - * @returns answered or cancelled prompt outcome. - */ - abstract resolve(port: PromptPort, prefilled?: T): Promise> -} - -/** Visible single-line text question. */ -export class TextQuestion extends Question { - /** Light hint displayed when no text has been entered. */ - readonly placeholder: string | undefined - /** Editable value displayed in the input. */ - readonly initialValue: string | undefined - /** Value accepted when the user submits an empty input. */ - readonly defaultValue: string | undefined - private readonly validate: ((value: string) => string | undefined) | undefined - - /** Configure one text question. */ - constructor(options: { - id: string - message: string - placeholder?: string - initialValue?: string - defaultValue?: string - validate?: (value: string) => string | undefined - }) { - super(options.id, options.message) - this.placeholder = options.placeholder - this.initialValue = options.initialValue - this.defaultValue = options.defaultValue - this.validate = options.validate - } - - /** Validate prefilled text or ask for it. */ - override async resolve(port: PromptPort, prefilled?: string): Promise> { - const resolved = resolvePrefilled(this.id, prefilled, this.validate) - if (resolved) return resolved - return port.text({ - message: this.message, - ...this.placeholder === undefined ? {} : { placeholder: this.placeholder }, - ...this.initialValue === undefined ? {} : { initialValue: this.initialValue }, - ...this.defaultValue === undefined ? {} : { defaultValue: this.defaultValue }, - ...this.validate === undefined ? {} : { validate: this.validate }, - }) - } -} - -/** Masked secret question whose empty-input semantics are set by its caller. */ -export class SecretQuestion extends Question { - private readonly validate: ((value: string) => string | undefined) | undefined - - /** Configure one secret question. */ - constructor(options: { - id: string - message: string - validate?: (value: string) => string | undefined - }) { - super(options.id, options.message) - this.validate = options.validate - } - - /** Validate a prefilled secret or ask for a masked value. */ - override async resolve(port: PromptPort, prefilled?: string): Promise> { - const resolved = resolvePrefilled(this.id, prefilled, this.validate) - if (resolved) return resolved - return port.secret({ - message: this.message, - ...this.validate === undefined ? {} : { validate: this.validate }, - }) - } -} - -/** Single-choice question. */ -export class SelectQuestion extends Question { - /** Available choices in display order. */ - readonly options: readonly PromptOption[] - /** Initially focused choice. */ - readonly initialValue: T | undefined - - /** Configure one single-choice question. */ - constructor(options: { - id: string - message: string - options: readonly PromptOption[] - initialValue?: T - }) { - super(options.id, options.message) - this.options = options.options - this.initialValue = options.initialValue - } - - /** Validate a prefilled option or ask for one choice. */ - override async resolve(port: PromptPort, prefilled?: T): Promise> { - if (prefilled !== undefined) { - if (!this.options.some(option => Object.is(option.value, prefilled) && !option.disabled)) { - throw new Error(`${this.id}: unknown or disabled option ${String(prefilled)}`) - } - return { status: 'answered', value: prefilled } - } - return port.select({ - message: this.message, - options: this.options, - ...this.initialValue === undefined ? {} : { initialValue: this.initialValue }, - }) - } -} - -/** Additive multi-choice question. */ -export class MultiSelectQuestion extends Question { - readonly options: readonly PromptOption[] - readonly initialValues: readonly T[] - readonly required: boolean - - /** Configure one multi-choice question. */ - constructor(options: { - id: string - message: string - options: readonly PromptOption[] - initialValues?: readonly T[] - required?: boolean - }) { - super(options.id, options.message) - this.options = options.options - this.initialValues = options.initialValues ?? [] - this.required = options.required ?? false - } - - /** Validate prefilled values or ask for an additive selection. */ - override async resolve(port: PromptPort, prefilled?: readonly T[]): Promise> { - if (prefilled !== undefined) { - for (const value of prefilled) { - /* v8 ignore next -- unknown, disabled, and accepted values are each pinned by the question tests */ - if (!this.options.some(option => Object.is(option.value, value) && option.disabled !== true)) { - throw new Error(`${this.id}: unknown or disabled option ${String(value)}`) - } - } - if (this.required && prefilled.length === 0) throw new Error(`${this.id}: choose at least one option`) - return { status: 'answered', value: prefilled } - } - return port.multiselect({ - message: this.message, - options: this.options, - initialValues: this.initialValues, - required: this.required, - }) - } -} - -/** Boolean confirmation question. */ -export class ConfirmQuestion extends Question { - /** Answer selected by pressing Enter. */ - readonly initialValue: boolean - /** Visual severity used by the prompt adapter. */ - readonly tone: 'default' | 'warning' - - /** Configure one confirmation question. */ - constructor(options: { - id: string - message: string - initialValue?: boolean - tone?: 'default' | 'warning' - }) { - super(options.id, options.message) - this.initialValue = options.initialValue ?? true - this.tone = options.tone ?? 'default' - } - - /** Return a prefilled boolean or ask for confirmation. */ - override async resolve(port: PromptPort, prefilled?: boolean): Promise> { - if (prefilled !== undefined) return { status: 'answered', value: prefilled } - return port.confirm({ message: this.message, initialValue: this.initialValue, tone: this.tone }) - } -} diff --git a/packages/scaffold/helper/src/templates/assets/README.md.tpl b/packages/scaffold/helper/src/templates/assets/README.md.tpl deleted file mode 100644 index 619849d8cd..0000000000 --- a/packages/scaffold/helper/src/templates/assets/README.md.tpl +++ /dev/null @@ -1,27 +0,0 @@ -# {{name}} - -{{description}} - -Built with the DeepSeek Harness SDK using the {{model}} model. - -{{#if isAcp}} -## Run as an ACP automation server - -Run `{{packageManager}} start` and configure a programmatic ACP client to launch this project. Standard output is reserved for ACP JSON-RPC. -{{else}} -## Embed the harness - -Import and call the exported `main()` from `index.ts` in your host application. -{{/if}} - -## Development - -Install NPM dependencies with `{{packageManager}} {{installArgs}}`, then use: - -- `dev`: `{{packageManager}} run dev` -- `build`: `{{packageManager}} {{buildArgs}}` -- `typecheck`: `{{packageManager}} run typecheck` -- `start`: `{{packageManager}} start` -- `config`: `{{packageManager}} run config` - -Edit `cordis.yml` to change the runtime plugin tree. Add or remove builtin features with `{{packageManager}} exec dsh-sdk config`. diff --git a/packages/scaffold/helper/src/templates/assets/gitignore.tpl b/packages/scaffold/helper/src/templates/assets/gitignore.tpl deleted file mode 100644 index 30b03c75c5..0000000000 --- a/packages/scaffold/helper/src/templates/assets/gitignore.tpl +++ /dev/null @@ -1,5 +0,0 @@ -node_modules/ -lib/ -.env -.sessions/ -*.tsbuildinfo diff --git a/packages/scaffold/helper/src/templates/assets/index.ts.tpl b/packages/scaffold/helper/src/templates/assets/index.ts.tpl deleted file mode 100644 index 584fadf9a1..0000000000 --- a/packages/scaffold/helper/src/templates/assets/index.ts.tpl +++ /dev/null @@ -1,20 +0,0 @@ -{{#if isAcp}} -import { startSDK, type SdkBootContext } from '@deepseek-ai/dsh-scripts' -{{else}} -import { randomUUID } from 'node:crypto' -import { SessionId } from '@deepseek-ai/dsh-session' -import { startSDK, type SdkBootContext } from '@deepseek-ai/dsh-scripts' -{{/if}} - -/** Boot this project's cordis.yml when invoked by dsh-scripts. */ -export async function main(boot: SdkBootContext) { - const ctx = await startSDK(new URL('./cordis.yml', import.meta.url)) -{{#if isEmbed}} - await ctx.agents.create({ - sessionId: SessionId(`main-session-${randomUUID()}`), - meta: { cwd: boot.cwd }, - agentOptions: { model: {{modelLiteral}} }, - }) -{{/if}} - return ctx -} diff --git a/packages/scaffold/helper/src/templates/assets/local-plugin-tsdown.config.ts.tpl b/packages/scaffold/helper/src/templates/assets/local-plugin-tsdown.config.ts.tpl deleted file mode 100644 index d24fb6f870..0000000000 --- a/packages/scaffold/helper/src/templates/assets/local-plugin-tsdown.config.ts.tpl +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from 'tsdown' -import { PluginBuild } from '@deepseek-ai/dsh-scripts/dev/tsdown-config' - -export default defineConfig(PluginBuild({ - entry: ['src/index.ts'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: true, - clean: false, -})) diff --git a/packages/scaffold/helper/src/templates/assets/local-plugin.ts.tpl b/packages/scaffold/helper/src/templates/assets/local-plugin.ts.tpl deleted file mode 100644 index 76b12769bb..0000000000 --- a/packages/scaffold/helper/src/templates/assets/local-plugin.ts.tpl +++ /dev/null @@ -1,9 +0,0 @@ -/** Local Cordis plugin. */ -import type { Context } from '@deepseek-ai/cordis' - -export const name = '{{pluginName}}' - -/** Register this plugin's project-local behavior. */ -export function apply(ctx: Context): void { - ctx.effect(() => () => {}) -} diff --git a/packages/scaffold/helper/src/templates/assets/local-tool.ts.tpl b/packages/scaffold/helper/src/templates/assets/local-tool.ts.tpl deleted file mode 100644 index a48a73375c..0000000000 --- a/packages/scaffold/helper/src/templates/assets/local-tool.ts.tpl +++ /dev/null @@ -1,17 +0,0 @@ -/** Project-local model-facing tool. */ -import type { Context } from '@deepseek-ai/cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' - -export const name = '{{pluginName}}' -export const inject = ['tools'] - -/** Register the {{toolName}} tool. */ -export function apply(ctx: Context): void { - ctx.tools.register(defineTool({ - name: '{{toolName}}', - description: 'Project-local {{toolTitle}} tool.', - parameters: {}, - execute: async () => [{ type: 'text', text: '{{toolName}} completed.' }], - presentCall: args => ({ card: 'generic', title: '{{toolTitle}}', kind: 'other', rawInput: args }), - })) -} diff --git a/packages/scaffold/helper/src/templates/assets/package.json.tpl b/packages/scaffold/helper/src/templates/assets/package.json.tpl deleted file mode 100644 index b5aa0fde0d..0000000000 --- a/packages/scaffold/helper/src/templates/assets/package.json.tpl +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": {{name}}, - "version": "0.0.0", - "private": true, - "description": {{description}}, - "type": "module", - "scripts": { - "build": "dsh-sdk build", - "typecheck": "tsc -b", - "config": "dsh-sdk config" - }, - "dependencies": {{dependencies}}, - "devDependencies": {{devDependencies}} -} diff --git a/packages/scaffold/helper/src/templates/assets/persona.txt.tpl b/packages/scaffold/helper/src/templates/assets/persona.txt.tpl deleted file mode 100644 index b17a6e802a..0000000000 --- a/packages/scaffold/helper/src/templates/assets/persona.txt.tpl +++ /dev/null @@ -1,3 +0,0 @@ -You are a coding assistant powered by the \{{model}} model. Your working directory is \{{cwd}}. - -Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/packages/scaffold/helper/src/templates/assets/tsconfig.base.json.tpl b/packages/scaffold/helper/src/templates/assets/tsconfig.base.json.tpl deleted file mode 100644 index f712277e9a..0000000000 --- a/packages/scaffold/helper/src/templates/assets/tsconfig.base.json.tpl +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2024", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "declaration": true, - "composite": true, - "outDir": "lib", - "rootDir": ".", - "types": ["node"], - "skipLibCheck": true - } -} diff --git a/packages/scaffold/helper/src/templates/assets/tsdown.config.ts.tpl b/packages/scaffold/helper/src/templates/assets/tsdown.config.ts.tpl deleted file mode 100644 index c923dd2d5b..0000000000 --- a/packages/scaffold/helper/src/templates/assets/tsdown.config.ts.tpl +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from 'tsdown' -import { ProjectBuild } from '@deepseek-ai/dsh-scripts/dev/tsdown-config' - -export default defineConfig(ProjectBuild({ - entry: ['index.ts'], - outDir: '.', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, -})) diff --git a/packages/scaffold/helper/src/templates/assets/yarnrc.yml.tpl b/packages/scaffold/helper/src/templates/assets/yarnrc.yml.tpl deleted file mode 100644 index 3186f3f079..0000000000 --- a/packages/scaffold/helper/src/templates/assets/yarnrc.yml.tpl +++ /dev/null @@ -1 +0,0 @@ -nodeLinker: node-modules diff --git a/packages/scaffold/helper/src/templates/project-template.ts b/packages/scaffold/helper/src/templates/project-template.ts deleted file mode 100644 index 97408b345f..0000000000 --- a/packages/scaffold/helper/src/templates/project-template.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Strict Handlebars wrapper and complete-file SDK project artifacts. - * - * @module @deepseek-ai/dsh-helper/templates/project-template - */ - -import { PackageJsonFile } from '../documents/package-json-file.ts' -import { TextProjectFile } from '../documents/project-file.ts' -import type { PackageManagerName } from '../package-managers/package-manager.ts' -import { baselineNpmDependencies } from '../project/npm-dependency-policy.ts' -import type { ProjectProfile, RunInterface } from '../project/types.ts' -import { loadHelperTemplate } from './template-assets.ts' -import type { TextTemplate } from './text-template.ts' - -/** Stable typed view consumed by all generated text artifacts. */ -export interface ProjectTemplateContext { - name: string - description: string - releaseVersion: string - model: string - modelLiteral: string - isAcp: boolean - isEmbed: boolean - packageManager: PackageManagerName - installArgs: string - buildArgs: string -} - -/** Complete project-file template artifact. */ -export class TemplateArtifact extends TextProjectFile { - /** Render and own one complete project file. */ - constructor(relativePath: string, template: TextTemplate, model: TModel) { - super(relativePath, template.render(model)) - } -} - -const README_TEMPLATE = loadHelperTemplate('README.md.tpl') -const PACKAGE_JSON_TEMPLATE = loadHelperTemplate<{ - name: string - description: string - dependencies: string - devDependencies: string -}>('package.json.tpl') -const INDEX_TEMPLATE = loadHelperTemplate('index.ts.tpl') -const TSDOWN_TEMPLATE = loadHelperTemplate('tsdown.config.ts.tpl') -const TSCONFIG_BASE_TEMPLATE = loadHelperTemplate('tsconfig.base.json.tpl') -const GITIGNORE_TEMPLATE = loadHelperTemplate('gitignore.tpl') -const YARNRC_TEMPLATE = loadHelperTemplate('yarnrc.yml.tpl') - -/** Build the template model for one project and selected run interface. */ -export function createProjectTemplateContext( - profile: ProjectProfile, - runInterface: RunInterface = profile.runInterface, -): ProjectTemplateContext { - return { - name: profile.name, - description: profile.description, - releaseVersion: profile.releaseVersion, - model: profile.runtime.model, - modelLiteral: JSON.stringify(profile.runtime.model), - isAcp: runInterface === 'acp', - isEmbed: runInterface === 'embed', - packageManager: profile.packageManager.name, - installArgs: profile.packageManager.installCommand().join(' '), - buildArgs: profile.packageManager.buildCommand().join(' '), - } -} - -/** Render the complete root package defaults before structured contributions merge. */ -export function createPackageJsonDoc(context: ProjectTemplateContext): PackageJsonFile { - const npmDependencies = baselineNpmDependencies(context.releaseVersion) - return PackageJsonFile.create(PACKAGE_JSON_TEMPLATE.render({ - name: JSON.stringify(context.name), - description: JSON.stringify(context.description), - dependencies: JSON.stringify(npmDependencies.dependencies), - devDependencies: JSON.stringify(npmDependencies.devDependencies), - })) -} - -/** Build interface-independent one-shot project artifacts. */ -export function createBaselineProjectArtifacts( - context: ProjectTemplateContext, -): TemplateArtifact[] { - return [ - new TemplateArtifact('tsdown.config.ts', TSDOWN_TEMPLATE, context), - new TemplateArtifact('tsconfig.base.json', TSCONFIG_BASE_TEMPLATE, context), - new TemplateArtifact('.gitignore', GITIGNORE_TEMPLATE, context), - ...context.packageManager === 'yarn' - ? [new TemplateArtifact('.yarnrc.yml', YARNRC_TEMPLATE, context)] - : [], - ] -} - -/** Build files owned by the selected app feature option. */ -export function createAppProjectArtifacts( - context: ProjectTemplateContext, -): TemplateArtifact[] { - return [ - new TemplateArtifact('README.md', README_TEMPLATE, context), - new TemplateArtifact('index.ts', INDEX_TEMPLATE, context), - ] -} - -/** Build package scripts owned by the selected app feature option. */ -export function createAppPackageScripts(): Readonly> { - return { - dev: 'dsh-sdk dev index.ts', - start: 'dsh-sdk start index.js', - } -} diff --git a/packages/scaffold/helper/src/templates/template-assets.ts b/packages/scaffold/helper/src/templates/template-assets.ts deleted file mode 100644 index fbf7fbec45..0000000000 --- a/packages/scaffold/helper/src/templates/template-assets.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Asset loader for templates owned by dsh-helper. - * - * @module @deepseek-ai/dsh-helper/templates/template-assets - */ - -import { TextTemplate } from './text-template.ts' - -/** - * Load one helper-owned template in source and bundled layouts. - * @param filename - basename under the helper template asset directory. - * @returns compiled typed template. - */ -export function loadHelperTemplate(filename: string): TextTemplate { - if (filename.includes('/') || filename.includes('\\')) { - throw new Error(`helper template filename must not contain a directory: ${filename}`) - } - return TextTemplate.fromFile(new URL(`./assets/${filename}`, import.meta.url)) -} diff --git a/packages/scaffold/helper/src/templates/text-template.ts b/packages/scaffold/helper/src/templates/text-template.ts deleted file mode 100644 index b5fa1652cc..0000000000 --- a/packages/scaffold/helper/src/templates/text-template.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Strict typed rendering for package-owned text templates. - * - * @module @deepseek-ai/dsh-helper/templates/text-template - */ - -import { readFileSync } from 'node:fs' -import Handlebars from 'handlebars' - -/** Strict Handlebars template with no HTML escaping or custom extensions. */ -export class TextTemplate { - private readonly renderer: Handlebars.TemplateDelegate - - /** - * Compile one template under the SDK's fixed rendering policy. - * @param source - complete template source. - */ - constructor(source: string) { - const handlebars = Handlebars.create() - this.renderer = handlebars.compile(source, { - strict: true, - noEscape: true, - preventIndent: true, - }) - } - - /** - * Load a template asset owned by the calling package. - * @param url - source or bundled asset URL. - * @returns compiled template. - */ - static fromFile(url: URL): TextTemplate { - return new TextTemplate(readFileSync(url, 'utf8')) - } - - /** - * Render text from one complete typed model. - * @param model - values referenced by the template. - * @returns rendered text. - */ - render(model: TModel): string { - return this.renderer(model) - } -} diff --git a/packages/scaffold/helper/tests/documents.spec.ts b/packages/scaffold/helper/tests/documents.spec.ts deleted file mode 100644 index 12e97dc6c0..0000000000 --- a/packages/scaffold/helper/tests/documents.spec.ts +++ /dev/null @@ -1,430 +0,0 @@ -import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Writable } from 'node:stream' -import { afterEach, describe, expect, it } from 'vitest' -import { CordisYamlFile, JsExpression } from '../src/documents/cordis-yaml-file.ts' -import { EnvFile } from '../src/documents/env-file.ts' -import { PackageJsonFile } from '../src/documents/package-json-file.ts' -import { PnpmWorkspaceFile } from '../src/documents/pnpm-workspace-file.ts' -import { TsConfigFile } from '../src/documents/tsconfig-file.ts' -import { TextProjectFile, withTrailingNewline } from '../src/documents/project-file.ts' -import { featureId, resourceKey } from '../src/ids.ts' -import { LinkWorkspace } from '../src/package-managers/link-workspace.ts' -import { LocalPluginBlueprint } from '../src/plugins/local-plugin-blueprint.ts' -import { - NpmPackageManager, - NodeCommandRunner, - PnpmPackageManager, - YarnPackageManager, - createPackageManager, - inferPackageManagerName, - probePackageManagerVersion, - scrubEnvironment, - type CommandRunner, -} from '../src/package-managers/package-manager.ts' -import { createBaselineProjectArtifacts } from '../src/templates/project-template.ts' -import { loadHelperTemplate } from '../src/templates/template-assets.ts' -import { TextTemplate } from '../src/templates/text-template.ts' -import { resolveNpmDependency } from '../src/project/npm-dependency-policy.ts' - -const temporary: string[] = [] - -afterEach(async () => { - await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true }))) -}) - -describe('structured project documents', () => { - it('normalizes trailing newlines and preserves managed package fields', () => { - expect(withTrailingNewline('a\n\n')).toBe('a\n') - const manifest = PackageJsonFile.parse('{"name":"demo","custom":1,"dependencies":{"z":"1"}}\n') - manifest.setScript('start', 'node index.js') - manifest.setNpmDependency('dependencies', 'a', '2') - manifest.setNpmDependency('devDependencies', 'typescript', '3') - manifest.removeNpmDependency('dependencies', 'z') - manifest.addWorkspace('plugins/*') - manifest.addWorkspace('plugins/*') - manifest.setPackageManager('pnpm@10.0.0') - manifest.setResolution('a', 'portal:../a') - manifest.validate() - expect(manifest.npmDependency('a')).toEqual({ section: 'dependencies', spec: '2' }) - expect(manifest.npmDependencyNames()).toEqual(['a', 'typescript']) - expect(JSON.parse(manifest.serialize())).toMatchObject({ - name: 'demo', - custom: 1, - dependencies: { a: '2' }, - workspaces: ['plugins/*'], - }) - manifest.setPackageManager(undefined) - expect(manifest.value().packageManager).toBeUndefined() - expect(() => PackageJsonFile.parse('[]')).toThrow('root must be an object') - expect(() => PackageJsonFile.parse('{')).toThrow('invalid package.json') - for (const [text, diagnostic] of [ - ['{}', 'name must be'], - ['{"name":"x","scripts":null}', 'scripts must be an object'], - ['{"name":"x","dependencies":[]}', 'dependencies must be an object'], - ['{"name":"x","devDependencies":{"bad":""}}', 'must be a non-empty string'], - ['{"name":"x","workspaces":"bad"}', 'workspaces must be an array'], - ] as const) expect(() => { PackageJsonFile.parse(text).validate() }).toThrow(diagnostic) - const minimal = PackageJsonFile.parse('{"name":"x"}') - minimal.validate() - expect(minimal.npmDependency('missing')).toBeUndefined() - expect(minimal.serialize()).toBe('{\n "name": "x"\n}\n') - expect(PackageJsonFile.parse('{"name":"x","devDependencies":{"a":"1"}}').npmDependencyNames()).toEqual(['a']) - }) - - it('round-trips Cordis comments and !!js while editing owned fields', () => { - const created = CordisYamlFile.create().clone() - created.addEntry({ id: 'created', name: 'created-package' }, `Uncomment this example. -config: - value: true`) - expect(created.serialize()).toMatch(/^- id: created/m) - expect(created.serialize()).toContain(' # Uncomment this example.\n # config:\n # value: true') - expect(created.serialize()).not.toMatch(/^\[/) - const flow = CordisYamlFile.parse('[{ id: flow, name: flow-package, config: { root: ./flow } }]\n') - expect(flow.serialize()).toContain('- id: flow\n name: flow-package\n config:\n root: ./flow') - expect(flow.serialize()).not.toContain('{') - const document = CordisYamlFile.parse(`# lead -- id: provider - name: 'provider-package' - config: - endpoint: !!js process.env.PROVIDER_URL - custom: keep -`) - const endpoint = document.entry('provider')?.config?.endpoint - expect(endpoint).toBeInstanceOf(JsExpression) - document.updateOwnedConfig('provider', ['endpoint'], { endpoint: new JsExpression('process.env.NEXT_URL') }) - document.setDisabled('provider', true) - document.addEntry({ id: 'tool', name: 'demo-tool' }) - document.validate() - const text = document.serialize() - expect(text).toContain('# lead') - expect(text).toContain('!!js process.env.NEXT_URL') - expect(text).toContain('custom: keep') - expect(document.removeEntry('tool')).toBe(true) - expect(document.removeEntry('tool')).toBe(false) - document.setDisabled('provider', false) - expect(document.entry('provider')?.disabled).toBeUndefined() - expect(() => { document.addEntry({ id: 'provider', name: 'duplicate' }) }).toThrow('already exists') - expect(() => CordisYamlFile.parse('{}')).toThrow('root must be a sequence') - }) - - it('rejects malformed Cordis config entries and missing mutation targets', () => { - expect(() => new JsExpression(' ')).toThrow('must not be empty') - expect(() => CordisYamlFile.parse('[')).toThrow('invalid cordis.yml') - for (const [text, diagnostic] of [ - ['- nope\n', 'every entry must be a mapping'], - ['- name: pkg\n', 'id must be'], - ['- id: x\n', 'name must be'], - ['- id: x\n name: pkg\n config: nope\n', 'config must be'], - ['- id: x\n name: pkg\n disabled: nope\n', 'disabled must be'], - ] as const) expect(() => CordisYamlFile.parse(text).entries()).toThrow(diagnostic) - const duplicate = CordisYamlFile.parse('- id: x\n name: one\n- id: x\n name: two\n') - expect(() => { duplicate.validate() }).toThrow('duplicate Cordis config entry id') - const document = CordisYamlFile.create() - expect(() => { document.setDisabled('missing', true) }).toThrow('does not exist') - expect(() => { document.updateOwnedConfig('missing', [], {}) }).toThrow('does not exist') - document.addEntry({ id: 'plain', name: 'pkg' }) - document.updateOwnedConfig('plain', [], { value: 1 }) - expect(document.entry('plain')?.config).toEqual({ value: 1 }) - document.updateOwnedConfig('plain', ['value'], {}) - expect(document.entry('plain')?.config).toBeUndefined() - const scalar = CordisYamlFile.parse('- id: plain\n name: pkg\n config: value\n') - expect(() => { scalar.updateOwnedConfig('plain', [], {}) }).toThrow('config is not a mapping') - expect(() => { CordisYamlFile.parse('- nope\n').setDisabled('missing', true) }).toThrow('does not exist') - }) - - it('keeps .env append-only while managing .env.example strictly', () => { - const document = EnvFile.parse('.env', '# keep\nA=1\nexport B=2\n') - expect(document.get('A')).toBe('1') - expect(document.get('B')).toBe('2') - expect(document.append('A', 'next')).toBe(false) - expect(document.append('C', '', 'Required')).toBe(true) - expect(document.serialize()).toBe('# keep\nA=1\nexport B=2\n# Required\nC=\n') - expect(() => { document.append('bad-name', 'x') }).toThrow('invalid environment variable') - expect(() => { document.append('D', '', '') }).toThrow('non-empty line') - expect(() => { document.append('D', '', 'bad\ncomment') }).toThrow('non-empty line') - expect(() => { document.set('A', 'next') }).toThrow('.env is append-only') - expect(() => { document.remove('A') }).toThrow('.env is append-only') - const duplicateEnv = EnvFile.parse('.env', 'A=1\nA=2\n') - expect(duplicateEnv.get('A')).toBe('2') - expect(duplicateEnv.append('A', 'next')).toBe(false) - expect(() => { duplicateEnv.validate() }).not.toThrow() - - const example = EnvFile.parse('.env.example', '# keep\nA=1\n') - example.set('A', 'next') - example.set('C', '') - example.remove('C') - expect(example.serialize()).toBe('# keep\nA=next\n') - expect(() => { example.append('C', '') }).toThrow('.env.example is SDK-managed') - expect(() => { example.set('bad-name', 'x') }).toThrow('invalid environment variable') - const duplicate = EnvFile.parse('.env.example', 'A=1\nA=2\n') - expect(() => { duplicate.validate() }).toThrow('duplicate variable A') - expect(() => duplicate.get('A')).toThrow('duplicate variable A') - expect(() => { duplicate.set('A', 'next') }).toThrow('duplicate variable A') - expect(() => { duplicate.remove('A') }).toThrow('duplicate variable A') - example.remove('missing') - expect(document.get('missing')).toBeUndefined() - expect(EnvFile.parse('.env', '').clone().serialize()).toBe('\n') - }) - - it('patches JSONC references without erasing comments', () => { - const document = TsConfigFile.parse(`{ - // retained - "references": [{ "path": "./plugins/a" }] -}`) - document.addReference('./plugins/a') - document.addReference('./plugins/b') - document.validate() - expect(document.serialize()).toContain('// retained') - expect(document.serialize()).toContain('./plugins/b') - expect(() => TsConfigFile.parse('{')).toThrow('valid JSONC object') - const malformed = TsConfigFile.parse('{"references": {}}') - expect(() => { malformed.addReference('./plugins/x') }).toThrow('must be an array') - const badItem = TsConfigFile.parse('{"references":[null]}') - expect(() => { badItem.addReference('./plugins/x') }).toThrow('must contain') - expect(() => { badItem.validate() }).toThrow('must contain') - const created = TsConfigFile.create() - created.validate() - expect(created.clone().serialize()).toContain('"references": []') - TsConfigFile.parse('{}').validate() - const noReferences = TsConfigFile.parse('{}') - noReferences.addReference('./plugin') - expect(noReferences.serialize()).toContain('./plugin') - expect(() => { TsConfigFile.parse('{"references":{}}').validate() }).toThrow('must be an array') - }) - - it('creates and parses pnpm workspace policy', () => { - const document = PnpmWorkspaceFile.create() - document.addPackage('plugins/*') - document.addPackage('plugins/*') - document.disableAutoInstallPeers() - document.validate() - expect(document.serialize()).toContain('autoInstallPeers: false') - const parsed = PnpmWorkspaceFile.parse(document.serialize()) - expect(parsed.clone().serialize()).toBe(document.serialize()) - expect(() => PnpmWorkspaceFile.parse('packages: [')).toThrow('invalid pnpm-workspace.yaml') - expect(() => PnpmWorkspaceFile.parse('packages: nope')).toThrow('packages must be an array') - expect(() => PnpmWorkspaceFile.parse('packages: [{}]')).toThrow('packages must be an array') - expect(() => PnpmWorkspaceFile.parse('packages: [1]')).toThrow('packages must be an array') - expect(() => PnpmWorkspaceFile.parse('[]')).toThrow('root must be an object') - expect(() => PnpmWorkspaceFile.parse('packages: []\nautoInstallPeers: nope')).toThrow('must be boolean') - const invalid = PnpmWorkspaceFile.create() - invalid.addPackage(' ') - expect(() => { invalid.validate() }).toThrow('must not be empty') - expect(PnpmWorkspaceFile.parse('packages: []\n').serialize()).not.toContain('autoInstallPeers') - const preserved = PnpmWorkspaceFile.parse(`# keep workspace settings -packages: - - apps/* -catalog: - react: ^19.0.0 -overrides: - legacy: modern -`) - preserved.disableAutoInstallPeers() - const preservedText = preserved.clone().serialize() - expect(preservedText).toContain('# keep workspace settings') - expect(preservedText).toContain('catalog:\n react: ^19.0.0') - expect(preservedText).toContain('overrides:\n legacy: modern') - expect(preservedText).toContain('autoInstallPeers: false') - }) - - it('renders strict complete-file templates without escaping code text', () => { - const template = new TextTemplate<{ value: string }>('value={{value}} missing={{missing}}') - expect(() => template.render({ value: '' })).toThrow() - const valid = new TextTemplate<{ value: string }>('value={{value}}') - expect(valid.render({ value: '' })).toBe('value=') - expect(new TextTemplate>('\\{{model}}').render({})).toBe('{{model}}') - expect(() => new TextProjectFile('/absolute', 'x')).toThrow('stay inside') - expect(() => new TextProjectFile('../outside', 'x')).toThrow('stay inside') - expect(new TextProjectFile('inside', 'x').clone().serialize()).toBe('x\n') - expect(() => featureId('Bad Id')).toThrow('invalid feature id') - expect(() => resourceKey('')).toThrow('must not be empty') - expect(() => loadHelperTemplate('../bad.tpl')).toThrow('must not contain a directory') - expect(createBaselineProjectArtifacts({ - name: 'demo', description: 'demo', releaseVersion: '0.0.1', model: 'model', modelLiteral: '"model"', packageManager: 'yarn', - isAcp: false, isEmbed: true, - installArgs: 'install', buildArgs: 'build', - }).map(document => document.relativePath)).toContain('.yarnrc.yml') - expect(() => new LocalPluginBlueprint('---', 'plugin')).toThrow('invalid local plugin name') - expect(new LocalPluginBlueprint('tool', 'tool').packageName('@scope/project')).toBe('@scope/project-tool') - expect(new LocalPluginBlueprint('tool', 'tool').packageName('@invalid')).toBe('@invalid-tool') - }) -}) - -describe('package manager strategies', () => { - it('owns workspace fields, execution commands, and supported version floors', () => { - const npm = new NpmPackageManager('10.1.0') - const pnpm = new PnpmPackageManager('10.2.0') - const yarn = new YarnPackageManager('4.0.0') - for (const manager of [npm, pnpm, yarn]) manager.validateVersion() - expect(npm.localPluginSpec()).toBe('*') - expect(npm.linkSpec('../x')).toBe('file:../x') - expect(npm.configureWorkspace(PackageJsonFile.create('{"name":"demo"}'))).toEqual([]) - const pnpmManifest = PackageJsonFile.create('{"name":"demo"}') - expect(pnpm.configureWorkspace(pnpmManifest)[0]).toBeInstanceOf(PnpmWorkspaceFile) - expect(pnpm.localPluginSpec()).toBe('workspace:*') - expect(pnpm.linkSpec('../x')).toBe('link:../x') - const yarnManifest = PackageJsonFile.create('{"name":"demo"}') - expect(yarn.configureWorkspace(yarnManifest)).toEqual([]) - expect(yarn.localPluginSpec()).toBe('workspace:*') - expect(yarn.linkSpec('../x')).toBe('portal:../x') - expect(yarn.buildCommand()).toEqual(['build']) - expect(npm.installCommand()).toEqual(['install']) - expect(npm.buildCommand()).toEqual(['run', 'build']) - expect(() => createPackageManager('npm', '9.0.0')).toThrow('npm >=10') - expect(() => createPackageManager('pnpm', '9.0.0')).toThrow('pnpm >=10') - expect(() => createPackageManager('yarn', '1.22.0')).toThrow('Yarn >=2') - expect(inferPackageManagerName(undefined, 'pnpm/10.0.0 node/v24')).toBe('pnpm') - expect(inferPackageManagerName(undefined, 'unknown/1')).toBeUndefined() - expect(inferPackageManagerName('yarn', undefined)).toBe('yarn') - expect(() => createPackageManager('npm', 'invalid')).toThrow('invalid package manager version') - expect(resolveNpmDependency('@deepseek-ai/cordis', 'devDependencies', '0.0.1')).toEqual({ - section: 'devDependencies', spec: '^4.0.0-rc.7', - }) - expect(resolveNpmDependency('@deepseek-ai/cordis-plugin-hmr', 'dependencies', '0.0.1').spec).toBe('^1.0.15') - expect(resolveNpmDependency('tsdown', 'devDependencies', '0.0.1').spec).toBe('0.22.2') - expect(resolveNpmDependency('@deepseek-ai/dsh-tools', 'dependencies', '1.2.3').spec).toBe('^1.2.3') - expect(() => resolveNpmDependency('unknown', 'dependencies', '0.0.1')).toThrow('no generated-project') - }) - - it('checks install/build process outcomes and scrubs credential-shaped names', async () => { - const calls: string[][] = [] - const runner: CommandRunner = { - run: async (command, args) => { - calls.push([command, ...args]) - return { exitCode: 0, signal: null } - }, - } - const npm = new NpmPackageManager('10.0.0') - await npm.install('/tmp', runner) - await npm.build('/tmp', runner) - expect(calls).toEqual([['npm', 'install'], ['npm', 'run', 'build']]) - await npm.add('some-pkg@1.0.0', '/tmp', runner) - const pnpm = createPackageManager('pnpm', '10.0.0') - await pnpm.add('github:o/r#sha', '/tmp', runner) - expect(calls).toContainEqual(['npm', 'install', 'some-pkg@1.0.0']) - expect(calls).toContainEqual(['pnpm', 'add', 'github:o/r#sha']) - const failed: CommandRunner = { run: async () => ({ exitCode: 2, signal: null }) } - await expect(npm.install('/tmp', failed)).rejects.toThrow('exited with code 2') - const killed: CommandRunner = { run: async () => ({ exitCode: null, signal: 'SIGTERM' }) } - await expect(npm.build('/tmp', killed)).rejects.toThrow('killed by SIGTERM') - expect(scrubEnvironment({ - PATH: '/bin', - API_KEY: 'secret', - DB_PASSWORD: 'secret', - TOKEN_VALUE: 'secret', - })).toEqual({ PATH: '/bin' }) - }) - - it('probes versions and runs real child-process boundaries', async () => { - await expect(probePackageManagerVersion('npm', process.cwd())).resolves.toMatch(/^\d+/) - await expect(probePackageManagerVersion('npm', '/missing/dsh-cwd')).rejects.toThrow('cannot run npm --version') - const root = await mkdtemp(join(tmpdir(), 'dsh-empty-version-')) - temporary.push(root) - const executable = join(root, 'npm') - await writeFile(executable, '#!/bin/sh\nexit 0\n') - await chmod(executable, 0o755) - const before = process.env.PATH - process.env.PATH = root - await expect(probePackageManagerVersion('npm', root)).rejects.toThrow('empty version output') - process.env.PATH = before - const runner = new NodeCommandRunner() - await expect(runner.run(process.execPath, ['-e', ''], root)).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(runner.run('missing-dsh-command', [], root)).rejects.toThrow() - let redirected = '' - const output = new Writable({ - write(chunk, _encoding, callback) { redirected += String(chunk); callback() }, - }) - const redirecting = new NodeCommandRunner(output) - await expect(redirecting.run( - process.execPath, - ['-e', 'process.stdout.write("child-out"); process.stderr.write("child-err")'], - root, - )).resolves.toEqual({ exitCode: 0, signal: null }) - expect(redirected).toContain('child-out') - expect(redirected).toContain('child-err') - await expect(redirecting.run('missing-dsh-command', [], root)).rejects.toThrow() - }) - - it('discovers and rewrites a repository-local NPM dependency closure', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-link-workspace-')) - temporary.push(root) - await mkdir(join(root, 'vendor', 'cordis'), { recursive: true }) - await mkdir(join(root, 'packages', 'sdk', 'scripts'), { recursive: true }) - await mkdir(join(root, 'packages', 'sdk', 'helper'), { recursive: true }) - await writeFile(join(root, 'vendor', 'cordis', 'package.json'), JSON.stringify({ name: '@deepseek-ai/cordis' })) - await writeFile(join(root, 'packages', 'sdk', 'helper', 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-helper' })) - await writeFile(join(root, 'packages', 'sdk', 'scripts', 'package.json'), JSON.stringify({ - name: '@deepseek-ai/dsh-scripts', dependencies: { '@deepseek-ai/dsh-helper': '^0.0.1' }, peerDependencies: { '@deepseek-ai/cordis': '^4' }, - })) - const workspace = await LinkWorkspace.open(root) - expect(workspace.closure(['@deepseek-ai/dsh-scripts'])).toEqual([ - '@deepseek-ai/cordis', '@deepseek-ai/dsh-helper', '@deepseek-ai/dsh-scripts', - ]) - const manifest = PackageJsonFile.create('{"name":"consumer","description":"test"}') - manifest.setNpmDependency('dependencies', '@deepseek-ai/dsh-scripts', '^0.0.1') - const pnpmWorkspace = PnpmWorkspaceFile.create() - workspace.apply(join(root, 'consumer'), manifest, new PnpmPackageManager('10.0.0'), [pnpmWorkspace]) - expect(manifest.npmDependency('@deepseek-ai/cordis')?.spec).toMatch(/^link:/) - expect(pnpmWorkspace.serialize()).toContain('autoInstallPeers: false') - expect(workspace.packageDirectory('@deepseek-ai/cordis')).toBe(join(root, 'vendor', 'cordis')) - expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('@deepseek-ai/cordis') - expect(workspace.packageDirectory('missing')).toBeUndefined() - // A generated workspace member resolves its own dependencies: every local name it - // declares relinks, while a peer keeps the range package managers require there. - const nested = workspace.relinkNestedManifest(join(root, 'consumer'), 'plugins/probe/package.json', `${JSON.stringify({ - name: 'probe', - dependencies: { '@deepseek-ai/dsh-helper': '^0.0.1', 'left-pad': '^1' }, - peerDependencies: { '@deepseek-ai/dsh-scripts': '^0.0.1' }, - devDependencies: { '@deepseek-ai/dsh-scripts': '^0.0.1' }, - }, null, 2)}\n`, new PnpmPackageManager('10.0.0')) - const nestedManifest = JSON.parse(nested) as { - dependencies: Record - peerDependencies: Record - devDependencies: Record - } - expect(nestedManifest.dependencies['@deepseek-ai/dsh-helper']).toMatch(/^link:\.\.\/\.\.\//) - expect(nestedManifest.dependencies['left-pad']).toBe('^1') - expect(nestedManifest.devDependencies['@deepseek-ai/dsh-scripts']).toMatch(/^link:\.\.\/\.\.\//) - expect(nestedManifest.peerDependencies['@deepseek-ai/dsh-scripts']).toBe('^0.0.1') - // Nothing local to relink, and a non-object section, leave the text byte-identical. - const untouched = `${JSON.stringify({ name: 'probe', dependencies: { 'left-pad': '^1' }, devDependencies: null }, null, 2)}\n` - expect(workspace.relinkNestedManifest(join(root, 'consumer'), 'plugins/probe/package.json', untouched, new PnpmPackageManager('10.0.0'))) - .toBe(untouched) - const yarnManifest = PackageJsonFile.create('{"name":"consumer"}') - yarnManifest.setNpmDependency('dependencies', '@deepseek-ai/dsh-scripts', '^0.0.1') - workspace.apply(join(root, 'consumer-yarn'), yarnManifest, new YarnPackageManager('4.0.0'), []) - expect(yarnManifest.value().resolutions).toBeDefined() - const pnpmManifest = PackageJsonFile.create('{"name":"consumer"}') - pnpmManifest.setNpmDependency('dependencies', '@deepseek-ai/dsh-scripts', '^0.0.1') - expect(() => { workspace.apply(join(root, 'consumer-pnpm'), pnpmManifest, new PnpmPackageManager('10.0.0'), []) }) - .toThrow('requires pnpm-workspace.yaml') - }) - - it('rejects malformed linked repositories', async () => { - const missing = await mkdtemp(join(tmpdir(), 'dsh-link-missing-')) - temporary.push(missing) - await mkdir(join(missing, 'vendor'), { recursive: true }) - await mkdir(join(missing, 'packages'), { recursive: true }) - await expect(LinkWorkspace.open(missing)).rejects.toThrow('not a DeepSeek Harness repository root') - const unreadable = await mkdtemp(join(tmpdir(), 'dsh-link-unreadable-')) - temporary.push(unreadable) - await mkdir(join(unreadable, 'vendor', 'bad'), { recursive: true }) - await mkdir(join(unreadable, 'packages'), { recursive: true }) - await expect(LinkWorkspace.open(unreadable)).rejects.toThrow('cannot read linked package') - const unnamed = await mkdtemp(join(tmpdir(), 'dsh-link-unnamed-')) - temporary.push(unnamed) - await mkdir(join(unnamed, 'vendor', 'unnamed'), { recursive: true }) - await mkdir(join(unnamed, 'packages'), { recursive: true }) - await writeFile(join(unnamed, 'vendor', 'unnamed', 'package.json'), '{}') - await expect(LinkWorkspace.open(unnamed)).rejects.toThrow('not a DeepSeek Harness repository root') - const duplicate = await mkdtemp(join(tmpdir(), 'dsh-link-duplicate-')) - temporary.push(duplicate) - await mkdir(join(duplicate, 'vendor', 'one'), { recursive: true }) - await mkdir(join(duplicate, 'packages', 'group', 'two'), { recursive: true }) - await writeFile(join(duplicate, 'vendor', 'one', 'package.json'), '{"name":"duplicate"}') - await writeFile(join(duplicate, 'packages', 'group', 'two', 'package.json'), '{"name":"duplicate"}') - await expect(LinkWorkspace.open(duplicate)).rejects.toThrow('duplicate linked package name') - }) -}) diff --git a/packages/scaffold/helper/tests/headless-prompt-port.spec.ts b/packages/scaffold/helper/tests/headless-prompt-port.spec.ts deleted file mode 100644 index 12febcfeff..0000000000 --- a/packages/scaffold/helper/tests/headless-prompt-port.spec.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { HeadlessPromptError, HeadlessPromptPort } from '../src/questions/headless-prompt-port.ts' - -/** Unwrap an answered outcome or fail the test. */ -async function answered(promise: Promise<{ status: 'answered'; value: T } | { status: 'cancelled' }>): Promise { - const outcome = await promise - if (outcome.status !== 'answered') throw new Error('expected an answered outcome') - return outcome.value -} - -describe('HeadlessPromptError', () => { - it('names the unanswered prompt', () => { - const error = new HeadlessPromptError('DeepSeek API key') - expect(error).toBeInstanceOf(Error) - expect(error.name).toBe('HeadlessPromptError') - expect(error.prompt).toBe('DeepSeek API key') - expect(error.message).toContain('DeepSeek API key') - }) -}) - -describe('HeadlessPromptPort', () => { - const port = new HeadlessPromptPort() - - describe('text', () => { - it('takes the initial value when present', async () => { - expect(await answered(port.text({ message: 'name', initialValue: 'agent' }))).toBe('agent') - }) - - it('falls back to the default value', async () => { - expect(await answered(port.text({ message: 'dir', defaultValue: 'my-agent' }))).toBe('my-agent') - }) - - it('prefers the initial value over the default value', async () => { - expect(await answered(port.text({ message: 'dir', initialValue: 'given', defaultValue: 'my-agent' }))).toBe('given') - }) - - it('fails loud when no default exists', async () => { - await expect(port.text({ message: 'base URL' })).rejects.toThrow(HeadlessPromptError) - }) - - it('fails loud when the default is invalid', async () => { - await expect(port.text({ - message: 'name', - defaultValue: '', - validate: value => value.length === 0 ? 'required' : undefined, - })).rejects.toThrow(/required/) - }) - }) - - describe('secret', () => { - it('always fails loud', async () => { - await expect(port.secret({ message: 'API key' })).rejects.toThrow(HeadlessPromptError) - }) - }) - - describe('select', () => { - it('takes the initial value when present', async () => { - expect(await answered(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }], initialValue: 'npm' }))).toBe('npm') - }) - - it('fails loud without an initial value', async () => { - await expect(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }] })).rejects.toThrow(HeadlessPromptError) - }) - }) - - describe('multiselect', () => { - it('returns the initial values', async () => { - expect(await answered(port.multiselect({ message: 'x', options: [], initialValues: ['a', 'b'] }))).toEqual(['a', 'b']) - }) - - it('returns an empty selection when none are supplied and none are required', async () => { - expect(await answered(port.multiselect({ message: 'x', options: [] }))).toEqual([]) - }) - - it('fails loud when required and nothing is preselected', async () => { - await expect(port.multiselect({ message: 'x', options: [], required: true })).rejects.toThrow(HeadlessPromptError) - }) - }) - - describe('confirm', () => { - it('takes the initial value when present', async () => { - expect(await answered(port.confirm({ message: 'install?', initialValue: false }))).toBe(false) - }) - - it('fails loud without an initial value', async () => { - await expect(port.confirm({ message: 'apply?' })).rejects.toThrow(HeadlessPromptError) - }) - }) - - describe('nestedMultiselect', () => { - it('always fails loud', async () => { - await expect(port.nestedMultiselect({ message: 'Select features', options: [] })).rejects.toThrow(HeadlessPromptError) - }) - }) -}) diff --git a/packages/scaffold/helper/tests/project.spec.ts b/packages/scaffold/helper/tests/project.spec.ts deleted file mode 100644 index b803e658d7..0000000000 --- a/packages/scaffold/helper/tests/project.spec.ts +++ /dev/null @@ -1,1009 +0,0 @@ -import { chmod, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' -import { - FeatureOption, - ExclusiveOptionFeature, - MultiOptionFeature, - FixedFeature, - type FeatureProjectView, -} from '../src/features/feature.ts' -import { createBuiltinRegistry } from '../src/features/builtin/index.ts' -import { - npmCordisConfigEntry, - cordisConfigEntry, - environment as environmentResource, - optionalString, - ownedTextFile, - requiredString, - stringArray, -} from '../src/features/builtin/helpers.ts' -import { defineFeatures, defineFeature } from '../src/features/define-feature.ts' -import { FeatureRegistry } from '../src/features/registry.ts' -import { ProjectContribution } from '../src/features/resources.ts' -import type { CordisConfigEntryResource, ProjectResource } from '../src/features/resources.ts' -import type { CordisConfigEntry } from '../src/documents/cordis-yaml-file.ts' -import { PackageJsonFile } from '../src/documents/package-json-file.ts' -import { TextProjectFile } from '../src/documents/project-file.ts' -import { featureId, resourceKey } from '../src/ids.ts' -import { NpmPackageManager } from '../src/package-managers/package-manager.ts' -import { LocalPluginBlueprint } from '../src/plugins/local-plugin-blueprint.ts' -import { SdkProject } from '../src/project/sdk-project.ts' -import type { - FeatureSelection, - ProjectCreationRequest, - ProjectProfile, -} from '../src/project/types.ts' - -const temporary: string[] = [] -const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) - -afterEach(async () => { - await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true }))) -}) - -function selection(id: string, options: readonly string[], secrets?: Record): FeatureSelection { - return { id: featureId(id), options, ...secrets ? { secrets } : {} } -} - -function request( - extra: readonly FeatureSelection[] = [], - plugins: readonly LocalPluginBlueprint[] = [], - app: 'acp' | 'embed' = 'embed', - bash: 'local' | 'sandbox' = 'local', -): ProjectCreationRequest { - return { - name: 'test-agent', - description: 'test project', - runtime: { model: 'deepseek-v4-flash' }, - packageManager: new NpmPackageManager('10.0.0'), - releaseVersion: '0.0.1', - features: [ - selection('provider', ['deepseek-official'], { apiKey: 'test-key' }), - selection('bash', [bash]), - selection('app', [app]), - selection('persistence', ['jsonl']), - ...extra, - ], - localPlugins: plugins, - } -} - -async function createCommitted( - extra: readonly FeatureSelection[] = [], - plugins: readonly LocalPluginBlueprint[] = [], -): Promise { - const root = await mkdtemp(join(tmpdir(), 'dsh-project-domain-')) - temporary.push(root) - const creation = request(extra, plugins) - const project = SdkProject.create(root, creation) - const registry = createBuiltinRegistry(project.profile) - const edit = project.edit(registry) - for (const item of creation.features) edit.installFeature(registry.get(item.id), item) - for (const plugin of plugins) edit.addPlugin(plugin) - return (await edit.commit()).project -} - -describe('SdkProject and ProjectEditSession', () => { - it('derives existing-project profiles and tolerates malformed optional documents', async () => { - const make = async ( - name: string, - manifest: Record, - cordis: string, - extras: Record = {}, - ): Promise => { - const root = await mkdtemp(join(tmpdir(), `${name}-`)) - temporary.push(root) - await writeFile(join(root, 'package.json'), JSON.stringify(manifest)) - await writeFile(join(root, 'cordis.yml'), cordis) - for (const [path, text] of Object.entries(extras)) await writeFile(join(root, path), text) - return SdkProject.open(root) - } - const acp = await make('dsh-open-acp', { - name: 'acp', description: 'ACP', packageManager: 'pnpm@10.1.0', - dependencies: { '@deepseek-ai/dsh-scripts': '^1.2.3' }, - }, `- id: acp - name: '@deepseek-ai/dsh-acp' - config: { model: app-model } -`, { '.env': 'KEY=value\n', 'tsconfig.json': '{bad', 'pnpm-workspace.yaml': 'bad' }) - expect(acp.profile).toMatchObject({ - runInterface: 'acp', runtime: { model: 'app-model' }, releaseVersion: '1.2.3', description: 'ACP', - }) - expect(acp.profile.packageManager.name).toBe('pnpm') - expect(acp.readEnvironment('.env', 'KEY')).toBe('value') - expect(() => acp.readEnvironment('.env.example', 'KEY')).not.toThrow() - expect(acp.document('tsconfig.json')).toBeInstanceOf(TextProjectFile) - await expect(make('dsh-open-tui', {}, `- id: provider - name: '@deepseek-ai/dsh-llm-deepseek' - config: { models: [provider-model] } -- id: tui - name: '@deepseek-ai/dsh-tui' -`)).rejects.toThrow('unsupported run interface: @deepseek-ai/dsh-tui has been removed') - await expect(make('dsh-open-tui-subpath', {}, `- id: tui-prompt - name: '@deepseek-ai/dsh-tui/prompt' -`)).rejects.toThrow('unsupported run interface: @deepseek-ai/dsh-tui has been removed') - const embedded = await make('dsh-open-embed', {}, `- id: provider - name: '@deepseek-ai/dsh-llm-deepseek' - config: { models: [provider-model] } -`, { 'yarn.lock': '' }) - expect(embedded.profile.runInterface).toBe('embed') - expect(embedded.profile.runtime.model).toBe('provider-model') - expect(embedded.profile.packageManager.name).toBe('yarn') - expect(embedded.profile.name).toBe(embedded.root.split('/').at(-1)) - const pnpm = await make('dsh-open-pnpm', { name: 'pnpm' }, '[]\n', { 'pnpm-lock.yaml': '' }) - expect(pnpm.profile.packageManager.name).toBe('pnpm') - const defaults = await make('dsh-open-default', { name: 'default', packageManager: 'npm@10.0.0' }, '[]\n') - expect(defaults.profile).toMatchObject({ - runInterface: 'embed', runtime: { model: 'deepseek-v4-flash' }, releaseVersion: '0.0.1', - }) - expect(() => SdkProject.create(defaults.root, { ...request(), features: [] })).toThrow('requires one app') - await expect(make('dsh-open-invalid-manager', { name: 'bad', packageManager: 'bad' }, '[]\n')) - .rejects.toThrow('invalid packageManager field') - const providerFallback = await make('dsh-open-provider-fallback', { name: 'fallback' }, `- id: provider - name: '@deepseek-ai/dsh-llm-deepseek' - config: { models: [fallback-model] } -`) - expect(providerFallback.profile.runtime.model).toBe('fallback-model') - const pnpmRequest = { ...request(), packageManager: new (await import('../src/package-managers/package-manager.ts')).PnpmPackageManager('10.0.0') } - expect(SdkProject.create(join(defaults.root, 'pnpm'), pnpmRequest).hasDocument('pnpm-workspace.yaml')).toBe(true) - }) - - it('commits a complete blueprint and round-trips every installed feature', async () => { - const project = await createCommitted([ - selection('hmr', ['default']), - selection('fs', ['local']), - selection('todo', ['default']), - selection('web', ['exa'], { apiKey: 'exa-key' }), - selection('subagent', ['fork']), - selection('workflow', ['workerthread']), - selection('hooks', ['claude', 'codex']), - ], [new LocalPluginBlueprint('sample', 'plugin'), new LocalPluginBlueprint('lookup', 'tool')]) - const registry = createBuiltinRegistry(project.profile) - const inspections = registry.inspect(project) - expect(inspections.filter(item => item.state === 'enabled').map(item => item.id)).toEqual([ - 'provider', 'spine', 'bash', 'app', 'persistence', 'hmr', 'fs', 'todo', 'web', 'subagent', 'workflow', 'hooks', - ]) - expect(inspections.find(item => item.id === 'subagent')?.options).toEqual(['spawn', 'fork']) - expect(project.cordisConfigEntries().map(entry => entry.id)).toContain('lookup') - const index = await readFile(join(project.root, 'index.ts'), 'utf8') - expect(index).toContain('SdkBootContext') - expect(index).toContain('agents.create') - expect(index).not.toContain('boot.args.resume') - expect(index).not.toContain('AgentId') - expect(index).toContain('SessionId(`main-session-${randomUUID()}`)') - expect(project.packageManifest().scripts).toEqual({ - dev: 'dsh-sdk dev index.ts', - build: 'dsh-sdk build', - typecheck: 'tsc -b', - start: 'dsh-sdk start index.js', - config: 'dsh-sdk config', - }) - expect(await readFile(join(project.root, '.env.example'), 'utf8')).toContain('EXA_API_KEY=') - expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] }) - expect(project.cordis.entry('session-invariant')?.name).toBe('@deepseek-ai/dsh-session/invariant') - expect(project.cordis.entry('agent-invariant')?.name).toBe('@deepseek-ai/dsh-agent/invariant') - expect(project.cordis.entry('scope-invariant')?.name).toBe('@deepseek-ai/dsh-scope/invariant') - expect(project.cordis.entry('agent-loop-invariant')?.name).toBe('@deepseek-ai/dsh-agent-loop/invariant') - expect(project.cordis.entry('system-prompt')?.config?.persona).toContain('{{cwd}}') - expect(project.packageManifest().dependencies?.['@deepseek-ai/cordis-plugin-timer']).toBe('^1.1.2') - expect(project.packageManifest().dependencies?.['@deepseek-ai/cordis-plugin-hmr']).toBe('^1.0.15') - expect(project.packageManifest().dependencies?.['@deepseek-ai/dsh-scope']).toBe('^0.0.1') - expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-scope/invariant') - expect(project.packageManifest().dependencies).not.toHaveProperty('node-addon-require-builtin') - expect(project.cordis.entry('hmr')).toMatchObject({ name: '@deepseek-ai/cordis-plugin-hmr' }) - expect(project.cordis.entry('llm-deepseek')).not.toHaveProperty('config.apiKey') - expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('baseURL') - expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('models') - }) - - it.each(['spawn', 'fork'] as const)('mounts Task controls for %s subagents', async (option) => { - const project = await createCommitted([selection('subagent', [option])]) - expect(project.cordis.entry('tasks')?.name).toBe('@deepseek-ai/dsh-tasks-local') - expect(project.cordis.entry('tool-tasks')?.name).toBe('@deepseek-ai/dsh-tool-tasks') - expect(project.packageManifest().dependencies).toMatchObject({ - '@deepseek-ai/dsh-tasks-local': '^0.0.1', - '@deepseek-ai/dsh-tool-tasks': '^0.0.1', - }) - expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-tasks') - }) - - it('round-trips embed app projects without an ACP Cordis config entry', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-embed-app-')) - temporary.push(root) - const creation = request([], [], 'embed') - const project = SdkProject.create(root, creation) - const registry = createBuiltinRegistry(project.profile) - const edit = project.edit(registry) - for (const item of creation.features) edit.installFeature(registry.get(item.id), item) - const committed = (await edit.commit()).project - const app = createBuiltinRegistry(committed.profile).get(featureId('app')).inspect(committed) - expect(app).toMatchObject({ state: 'enabled', options: ['embed'] }) - expect(app.selection).toEqual(selection('app', ['embed'])) - expect(committed.cordis.entry('agent-loop')?.config).toEqual({ agents: [] }) - expect(committed.cordis.entry('acp')).toBeUndefined() - }) - - it('emits the sandbox workspace-write example as inactive Cordis config', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-sandbox-bash-')) - temporary.push(root) - const creation = request([], [], 'embed', 'sandbox') - const project = SdkProject.create(root, creation) - const registry = createBuiltinRegistry(project.profile) - const edit = project.edit(registry) - for (const item of creation.features) edit.installFeature(registry.get(item.id), item) - await edit.commit() - const cordis = await readFile(join(root, 'cordis.yml'), 'utf8') - expect(cordis).toContain(`- id: bash - name: "@deepseek-ai/dsh-bash-sandbox" - # Uncomment to allow writes under the project workspace. - # config: - # mode: workspace-write - # workspaceRoot: !!js process.cwd()`) - }) - - it('round-trips the custom pi-ai provider with explicit endpoint and default model', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-custom-provider-')) - temporary.push(root) - const base = request() - const creation: ProjectCreationRequest = { - ...base, - features: [ - { - id: featureId('provider'), - options: ['custom'], - values: { baseURL: 'https://custom.example/v1' }, - secrets: { apiKey: 'custom-key' }, - }, - ...base.features.filter(item => item.id !== 'provider'), - ], - } - const project = SdkProject.create(root, creation) - const registry = createBuiltinRegistry(project.profile) - const edit = project.edit(registry) - for (const item of creation.features) edit.installFeature(registry.get(item.id), item) - const committed = (await edit.commit()).project - expect(committed.cordis.entry('llm-pi-ai')).toMatchObject({ - name: '@deepseek-ai/dsh-llm-pi-ai', - config: { baseURL: 'https://custom.example/v1' }, - }) - expect(committed.cordis.entry('llm-pi-ai')?.config).not.toHaveProperty('models') - expect(createBuiltinRegistry(committed.profile).get(featureId('provider')).inspect(committed)).toMatchObject({ - state: 'enabled', options: ['custom'], - }) - }) - - it('switches exclusive options and refuses disabling a required feature', async () => { - const project = await createCommitted([selection('subagent', ['spawn']), selection('workflow', ['workerthread'])]) - const registry = createBuiltinRegistry(project.profile) - const edit = project.edit(registry) - const persistence = registry.get(featureId('persistence')) - edit.configureFeature(persistence, selection('persistence', ['sqlite'])) - expect(() => { edit.disableFeature(registry.get(featureId('subagent'))) }).toThrow('required by workflow') - expect(() => { edit.disableFeature(registry.get(featureId('app'))) }).toThrow('required feature') - const committed = await edit.commit() - expect(committed.project.cordis.entry('session-persistence')?.name).toContain('sqlite') - expect(committed.changes.npmDependenciesChanged).toBe(true) - }) - - it('switches app-owned files and scripts while protecting user edits', async () => { - const project = await createCommitted() - const registry = createBuiltinRegistry(project.profile) - const edit = project.edit(registry) - edit.configureFeature(registry.get(featureId('app')), selection('app', ['acp'])) - const acp = (await edit.commit()).project - expect(acp.profile.runInterface).toBe('acp') - expect(acp.cordis.entry('commands')).toBeUndefined() - expect(acp.cordis.entry('user-interaction')).toBeUndefined() - expect(acp.packageManifest().scripts).toMatchObject({ - dev: 'dsh-sdk dev index.ts', - start: 'dsh-sdk start index.js', - }) - expect(await readFile(join(acp.root, 'README.md'), 'utf8')).toContain('Run as an ACP automation server') - expect(await readFile(join(acp.root, 'index.ts'), 'utf8')).not.toContain('agents.create') - - const acpRegistry = createBuiltinRegistry(acp.profile) - const embedEdit = acp.edit(acpRegistry) - embedEdit.configureFeature(acpRegistry.get(featureId('app')), selection('app', ['embed'])) - const embed = (await embedEdit.commit()).project - expect(embed.profile.runInterface).toBe('embed') - expect(await readFile(join(embed.root, 'README.md'), 'utf8')).toContain('Embed the harness') - const embedIndex = await readFile(join(embed.root, 'index.ts'), 'utf8') - expect(embedIndex).toContain('agents.create') - expect(embedIndex).toContain("import { SessionId } from '@deepseek-ai/dsh-session'") - expect(embedIndex).not.toContain('AgentId') - - await writeFile(join(embed.root, 'README.md'), '# Custom README\n') - const modified = await SdkProject.open(embed.root) - const modifiedRegistry = createBuiltinRegistry(modified.profile) - expect(() => { modified.edit(modifiedRegistry).configureFeature( - modifiedRegistry.get(featureId('app')), - selection('app', ['acp']), - ) }).toThrow('feature-owned file was modified: README.md') - - const manifest = PackageJsonFile.parse(await readFile(join(embed.root, 'package.json'), 'utf8')) - manifest.removeScript('dev') - await writeFile(join(embed.root, 'package.json'), manifest.serialize()) - const incomplete = await SdkProject.open(embed.root) - expect(createBuiltinRegistry(incomplete.profile).get(featureId('app')).inspect(incomplete).diagnostics) - .toContain('missing package.json script dev') - }) - - it('supports disabled feature reconfiguration and rejects invalid state operations', async () => { - const project = await createCommitted([selection('todo', ['default'])]) - const registry = createBuiltinRegistry(project.profile) - const edit = project.edit(registry) - expect(edit.inspections()).not.toHaveLength(0) - const todo = registry.get(featureId('todo')) - edit.configureFeature(registry.get(featureId('web')), selection('web', ['deepseek-official'])) - edit.disableFeature(todo) - edit.configureFeature(todo, selection('todo', ['default'])) - edit.enableFeature(todo) - expect(() => { edit.enableFeature(registry.get(featureId('workflow'))) }).toThrow('not installed') - expect(() => { edit.disableFeature(registry.get(featureId('workflow'))) }).toThrow('not installed') - expect(() => { edit.setCustomPluginDisabled('missing', true) }).toThrow('does not exist') - const committed = await edit.commit() - expect(committed.changes.enabledFeatures).toContain('todo') - expect(() => { edit.enableFeature(todo) }).toThrow('already committed') - }) - - it('preserves custom entries and toggles only their Loader disabled state', async () => { - const project = await createCommitted([], [new LocalPluginBlueprint('sample', 'plugin')]) - const registry = createBuiltinRegistry(project.profile) - const edit = project.edit(registry) - edit.setCustomPluginDisabled('sample', true) - expect(edit.cordisConfigEntries().find(entry => entry.id === 'sample')?.disabled).toBe(true) - expect(() => { edit.setCustomPluginDisabled('agent-loop', true) }).toThrow('builtin feature') - const next = (await edit.commit()).project - const enable = next.edit(createBuiltinRegistry(next.profile)) - enable.setCustomPluginDisabled('sample', false) - expect((await enable.commit()).project.cordis.entry('sample')?.disabled).toBeUndefined() - }) - - it('rejects local plugin collisions and invalid optional document shapes', async () => { - const project = await createCommitted([], [new LocalPluginBlueprint('sample', 'plugin')]) - const registry = createBuiltinRegistry(project.profile) - const npmDependencyConflict = project.edit(registry) - expect(() => { npmDependencyConflict.addPlugin(new LocalPluginBlueprint('sample', 'plugin')) }) - .toThrow('NPM dependency already exists') - await writeFile(join(project.root, 'tsconfig.json'), 'not-json\n') - const malformed = await SdkProject.open(project.root) - expect(() => { malformed.edit(createBuiltinRegistry(malformed.profile)).addPlugin( - new LocalPluginBlueprint('other', 'plugin'), - ) }).toThrow('requires a valid tsconfig') - const entryProject = await createCommitted() - const entryEdit = entryProject.edit(createBuiltinRegistry(entryProject.profile)) - ;(entryEdit as unknown as { cordis(): { addEntry(entry: CordisConfigEntry): void } }).cordis() - .addEntry({ id: 'sample', name: 'manual' }) - expect(() => { entryEdit.addPlugin(new LocalPluginBlueprint('sample', 'plugin')) }).toThrow('entry already exists') - const fileEdit = entryProject.edit(createBuiltinRegistry(entryProject.profile)) - ;(fileEdit as unknown as { documents: Map }).documents - .set('plugins/other/package.json', new TextProjectFile('plugins/other/package.json', '{}')) - expect(() => { fileEdit.addPlugin(new LocalPluginBlueprint('other', 'plugin')) }).toThrow('file already exists') - }) - - it('reinstalls existing/disabled features and detects requirement cycles', async () => { - const project = await createCommitted([selection('todo', ['default']), selection('subagent', ['spawn'])]) - const registry = createBuiltinRegistry(project.profile) - const edit = project.edit(registry) - const todo = registry.get(featureId('todo')) - edit.installFeature(todo, selection('todo', ['default'])) - edit.disableFeature(todo) - edit.installFeature(todo, selection('todo', ['default'])) - const subagent = registry.get(featureId('subagent')) - edit.disableFeature(subagent) - edit.installFeature(registry.get(featureId('workflow')), selection('workflow', ['workerthread'])) - expect(edit.cordisConfigEntries().find(entry => entry.id === 'subagent-spawn')?.disabled).toBeUndefined() - - class Cyclic extends FixedFeature { - override readonly summary = 'cyclic' - override readonly options = [new (class extends FeatureOption { - override readonly id = 'one' - override readonly label = 'One' - override contribution(): ProjectContribution { return new ProjectContribution([]) } - })()] - override readonly id - override readonly requires - constructor(id: string, required: string) { - super() - this.id = featureId(id) - this.requires = [featureId(required)] - } - } - const one = new Cyclic('cycle-one', 'cycle-two') - const two = new Cyclic('cycle-two', 'cycle-one') - const cycleRegistry = new FeatureRegistry([one, two], project.profile) - expect(() => { project.edit(cycleRegistry).installFeature(one, selection('cycle-one', ['one'])) }) - .toThrow('cyclic feature requirement') - }) - - it('removes clean owned files and detects files disappearing before commit', async () => { - const project = await createCommitted([selection('hooks', ['claude', 'codex']), selection('todo', ['default'])]) - const registry = createBuiltinRegistry(project.profile) - const remove = project.edit(registry) - remove.configureFeature(registry.get(featureId('hooks')), selection('hooks', ['codex'])) - const committed = await remove.commit() - expect(committed.changes.changedFiles).toContain('hooks.json') - const edit = committed.project.edit(createBuiltinRegistry(committed.project.profile)) - edit.disableFeature(createBuiltinRegistry(committed.project.profile).get(featureId('todo'))) - await rm(join(committed.project.root, 'cordis.yml')) - await expect(edit.commit()).rejects.toThrow('cannot verify project file cordis.yml') - }) - - it('guards internal resource collisions and malformed aggregate documents', async () => { - const project = await createCommitted() - const registry = createBuiltinRegistry(project.profile) - const edit = project.edit(registry) - type Internals = { - documents: Map - states: Map, unknown> - applyResource(resource: ProjectResource, previous: ProjectResource | undefined): void - removeResource(resource: ProjectResource): void - replaceContribution(previous: ProjectContribution | undefined, next: ProjectContribution): void - finalProfile(): ProjectProfile - manifest(): unknown - cordis(): unknown - environment(path: '.env' | '.env.example'): unknown - state(feature: FixedFeature): unknown - } - const internals = edit as unknown as Internals - const collidingEntry: ProjectResource = { - kind: 'cordis-config-entry', key: resourceKey('cordis-config-entry:agent-loop'), - entry: { id: 'agent-loop', name: 'other-package' }, ownedConfigKeys: [], - } - expect(() => { internals.applyResource(collidingEntry, undefined) }).toThrow('is owned by') - const existingFile: ProjectResource = { - kind: 'owned-file', key: resourceKey('file:tsconfig.json'), - document: new TextProjectFile('tsconfig.json', 'replacement'), removeOnlyWhenUnchanged: true, - } - expect(() => { internals.applyResource(existingFile, undefined) }).toThrow('already exists') - internals.documents.set('owned.txt', new TextProjectFile('owned.txt', 'old')) - const previousFile: ProjectResource = { - ...existingFile, key: resourceKey('file:owned.txt'), document: new TextProjectFile('owned.txt', 'old'), - } - const nextFile: ProjectResource = { - ...existingFile, key: resourceKey('file:owned.txt'), document: new TextProjectFile('owned.txt', 'replacement'), - } - internals.applyResource(nextFile, previousFile) - expect(internals.documents.get('owned.txt')?.serialize()).toBe('replacement\n') - internals.documents.set('owned.txt', new TextProjectFile('owned.txt', 'user edit')) - expect(() => { internals.applyResource(nextFile, previousFile) }).toThrow('was modified') - const existingScript: ProjectResource = { - kind: 'package-script', key: resourceKey('package-script:build'), - name: 'build', command: 'other build', removeOnlyWhenUnchanged: true, - } - expect(() => { internals.applyResource(existingScript, undefined) }).toThrow('script already exists') - const transientScript: ProjectResource = { - kind: 'package-script', key: resourceKey('package-script:transient'), - name: 'transient', command: 'first', removeOnlyWhenUnchanged: true, - } - internals.applyResource(transientScript, undefined) - const nextScript: ProjectResource = { ...transientScript, command: 'second' } - internals.applyResource(nextScript, transientScript) - internals.applyResource(nextScript, transientScript) - ;(internals.manifest() as PackageJsonFile).setScript('transient', 'user edit') - expect(() => { internals.applyResource(transientScript, nextScript) }).toThrow('script was modified') - expect(() => { internals.removeResource(nextScript) }).toThrow('script was modified') - ;(internals.manifest() as PackageJsonFile).setScript('transient', 'second') - internals.removeResource(nextScript) - expect(() => { internals.removeResource(nextScript) }).toThrow('script is missing') - expect(() => { internals.removeResource({ - ...existingFile, key: resourceKey('file:missing.txt'), document: new TextProjectFile('missing.txt', 'missing'), - }) }).toThrow('owned file is missing') - expect(() => { internals.removeResource({ - kind: 'cordis-config-entry', key: resourceKey('cordis-config-entry:missing'), - entry: { id: 'missing', name: 'missing' }, ownedConfigKeys: [], - }) }).toThrow('cannot confirm old Cordis resource') - const transient: ProjectResource = { - kind: 'owned-file', key: resourceKey('file:transient.txt'), - document: new TextProjectFile('transient.txt', 'transient'), removeOnlyWhenUnchanged: true, - } - internals.applyResource(transient, undefined) - internals.removeResource(transient) - internals.replaceContribution( - new ProjectContribution([{ kind: 'npm-dependency', key: resourceKey('shared'), name: '@deepseek-ai/cordis', section: 'dependencies' }]), - new ProjectContribution([{ - kind: 'cordis-config-entry', key: resourceKey('shared'), entry: { id: 'new', name: 'new' }, ownedConfigKeys: [], - }]), - ) - internals.replaceContribution( - new ProjectContribution([{ - kind: 'environment', key: resourceKey('environment:SAME'), name: 'SAME', value: 'old', exampleValue: '', - }]), - new ProjectContribution([{ - kind: 'environment', key: resourceKey('environment:SAME'), name: 'SAME', value: 'new', exampleValue: '', - }]), - ) - internals.documents.set('.env', new TextProjectFile('.env', 'bad')) - expect(() => edit.readEnvironment('.env', 'KEY')).toThrow('not an environment document') - expect(() => { internals.environment('.env') }).toThrow('not an environment document') - internals.documents.delete('package.json') - expect(() => { internals.manifest() }).toThrow('package.json is missing') - internals.documents.delete('cordis.yml') - expect(() => { internals.cordis() }).toThrow('cordis.yml is missing') - class Foreign extends FixedFeature { - override readonly id = featureId('foreign') - override readonly summary = 'foreign' - override readonly options = [] - } - expect(() => { internals.state(new Foreign()) }).toThrow('not applicable') - internals.states.delete(featureId('app')) - expect(internals.finalProfile()).toBe(project.profile) - const sourceDocuments = (project as unknown as { documents: Map }).documents - sourceDocuments.set('.env', new TextProjectFile('.env', 'bad')) - expect(() => project.readEnvironment('.env', 'KEY')).toThrow('not an environment document') - sourceDocuments.delete('package.json') - expect(() => project.packageJson).toThrow('package.json is missing or invalid') - sourceDocuments.delete('cordis.yml') - expect(() => project.cordis).toThrow('cordis.yml is missing or invalid') - }) - - it('rejects external edits before writing any affected file', async () => { - const project = await createCommitted([selection('todo', ['default'])]) - const registry = createBuiltinRegistry(project.profile) - const edit = project.edit(registry) - edit.disableFeature(registry.get(featureId('todo'))) - const manifestBefore = await readFile(join(project.root, 'package.json'), 'utf8') - await writeFile(join(project.root, 'cordis.yml'), '# external\n[]\n') - await expect(edit.commit()).rejects.toThrow('changed outside this edit session') - expect(await readFile(join(project.root, 'package.json'), 'utf8')).toBe(manifestBefore) - }) - - it('rejects a create target file that appeared after the edit session opened', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-create-conflict-')) - temporary.push(root) - const creation = request() - const project = SdkProject.create(root, creation) - const registry = createBuiltinRegistry(project.profile) - const edit = project.edit(registry) - for (const item of creation.features) edit.installFeature(registry.get(item.id), item) - await writeFile(join(root, 'README.md'), 'external\n') - await expect(edit.commit()).rejects.toThrow('changed outside this edit session: README.md') - }) - - it('uses Cordis config entries as the installation anchor and rejects partial resources', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-inconsistent-')) - temporary.push(root) - await writeFile(join(root, 'package.json'), JSON.stringify({ - name: 'partial', dependencies: { '@deepseek-ai/dsh-llm-deepseek': '^0.0.1' }, - })) - await writeFile(join(root, 'cordis.yml'), '[]\n') - const project = await SdkProject.open(root) - const registry = createBuiltinRegistry(project.profile) - expect(registry.get(featureId('provider')).inspect(project)).toMatchObject({ - state: 'absent', diagnostics: [], - }) - - const partialRoot = await mkdtemp(join(tmpdir(), 'dsh-entry-partial-')) - temporary.push(partialRoot) - await writeFile(join(partialRoot, 'package.json'), JSON.stringify({ name: 'partial-entry' })) - await writeFile(join(partialRoot, 'cordis.yml'), `- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKeyEnv: DEEPSEEK_API_KEY -`) - const partial = await SdkProject.open(partialRoot) - const installation = createBuiltinRegistry(partial.profile) - .get(featureId('provider')).inspect(partial) - expect(installation.state).toBe('inconsistent') - expect(installation.diagnostics).toContain('missing package.json dependencies entry @deepseek-ai/dsh-llm-deepseek') - const partialEdit = partial.edit(createBuiltinRegistry(partial.profile)) - const provider = createBuiltinRegistry(partial.profile).get(featureId('provider')) - expect(() => { partialEdit.configureFeature(provider, selection('provider', ['deepseek-official'])) }).toThrow('inconsistent') - expect(() => { partialEdit.enableFeature(provider) }).toThrow('inconsistent') - expect(() => { partialEdit.disableFeature(provider) }).toThrow('required feature') - }) - - it('rejects inconsistent optional features and incompatible requirement options', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-optional-inconsistent-')) - temporary.push(root) - await writeFile(join(root, 'package.json'), '{"name":"partial"}') - await writeFile(join(root, 'cordis.yml'), `- id: web-search-exa - name: '@deepseek-ai/dsh-web-search-exa' -`) - const project = await SdkProject.open(root) - const builtin = createBuiltinRegistry(project.profile) - const edit = project.edit(builtin) - const web = builtin.get(featureId('web')) - expect(() => { edit.disableFeature(web) }).toThrow('inconsistent') - expect(() => { edit.installFeature(web, selection('web', ['deepseek-official'])) }).toThrow('inconsistent') - - class RequiresWeb extends FixedFeature { - override readonly id = featureId('requires-web') - override readonly summary = 'requires web' - override readonly requires = [featureId('web')] - override readonly options = [new (class extends FeatureOption { - override readonly id = 'one' - override readonly label = 'One' - override contribution(): ProjectContribution { return new ProjectContribution([]) } - })()] - } - const requiresWeb = new RequiresWeb() - const webRegistry = new FeatureRegistry([web, requiresWeb], project.profile) - expect(() => { project.edit(webRegistry).installFeature(requiresWeb, selection('requires-web', ['one'])) }) - .toThrow('required feature web is inconsistent') - - class RequiresAcp extends FixedFeature { - override readonly id = featureId('requires-acp') - override readonly summary = 'requires acp' - override readonly options = [new (class extends FeatureOption { - override readonly id = 'one' - override readonly label = 'One' - override contribution(): ProjectContribution { return new ProjectContribution([]) } - })()] - override requirements(): readonly [{ id: ReturnType; options: readonly string[] }] { - return [{ id: featureId('app'), options: ['acp'] }] - } - } - const requiring = new RequiresAcp() - const complete = await createCommitted() - const app = createBuiltinRegistry(complete.profile).get(featureId('app')) - const registry = new FeatureRegistry([app, requiring], complete.profile) - expect(() => { complete.edit(registry).installFeature(requiring, selection('requires-acp', ['one'])) }) - .toThrow('does not satisfy the option requirement') - }) - - it('removes obsolete environment resources when switching options', async () => { - const project = await createCommitted([selection('web', ['exa'], { apiKey: 'exa' })]) - const registry = createBuiltinRegistry(project.profile) - const edit = project.edit(registry) - edit.configureFeature(registry.get(featureId('web')), selection('web', ['deepseek-official'])) - expect(edit.readEnvironment('.env.example', 'EXA_API_KEY')).toBeUndefined() - }) - - it('preserves duplicate and existing .env values while appending differently named secrets', async () => { - const project = await createCommitted() - if (process.platform !== 'win32') { - expect((await stat(join(project.root, '.env'))).mode & 0o777).toBe(0o600) - } - const original = '# keep\nDEEPSEEK_API_KEY=first\nDEEPSEEK_API_KEY=second\n' - await writeFile(join(project.root, '.env'), original) - if (process.platform !== 'win32') await chmod(join(project.root, '.env'), 0o640) - const reopened = await SdkProject.open(project.root) - const registry = createBuiltinRegistry(reopened.profile) - expect(registry.get(featureId('provider')).inspect(reopened)).toMatchObject({ - state: 'enabled', selection: { secrets: { apiKey: 'second' } }, - }) - const edit = reopened.edit(registry) - edit.configureFeature( - registry.get(featureId('provider')), - selection('provider', ['deepseek-official'], { apiKey: 'replacement' }), - ) - edit.installFeature(registry.get(featureId('web')), selection('web', ['exa'], { apiKey: 'exa-key' })) - const withExa = (await edit.commit()).project - expect(await readFile(join(withExa.root, '.env'), 'utf8')).toBe(`${original}EXA_API_KEY=exa-key\n`) - if (process.platform !== 'win32') { - expect((await stat(join(withExa.root, '.env'))).mode & 0o777).toBe(0o640) - } - const nextRegistry = createBuiltinRegistry(withExa.profile) - const remove = withExa.edit(nextRegistry) - remove.configureFeature(nextRegistry.get(featureId('web')), selection('web', ['deepseek-official'])) - await remove.commit() - expect(await readFile(join(withExa.root, '.env'), 'utf8')).toBe(`${original}EXA_API_KEY=exa-key\n`) - }) - - it('refuses to remove a feature-owned file after user edits', async () => { - const project = await createCommitted([selection('hooks', ['claude', 'codex'])]) - await writeFile(join(project.root, 'hooks.json'), '{"hooks":{}}\n') - const reopened = await SdkProject.open(project.root) - const registry = createBuiltinRegistry(reopened.profile) - const edit = reopened.edit(registry) - expect(() => { edit.configureFeature( - registry.get(featureId('hooks')), - selection('hooks', ['codex']), - ) }).toThrow('owned file was modified: hooks.json') - }) - - it('does not mistake a linked NPM dependency closure for an installed feature', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-link-closure-inspection-')) - temporary.push(root) - const base = request([selection('hooks', ['claude'])], [new LocalPluginBlueprint('probe', 'plugin')]) - const creation: ProjectCreationRequest = { ...base, linkWorkspaceRoot: repoRoot } - const project = SdkProject.create(root, creation) - const registry = createBuiltinRegistry(project.profile) - const edit = project.edit(registry) - for (const item of creation.features) edit.installFeature(registry.get(item.id), item) - for (const blueprint of creation.localPlugins) edit.addPlugin(blueprint) - const committed = (await edit.commit()).project - expect(committed.packageManifest().dependencies?.['@deepseek-ai/dsh-subagent']).toMatch(/^file:/) - // A generated workspace member resolves its own dependencies, so its manifest links too. - const plugin = JSON.parse(await readFile(join(root, 'plugins/probe/package.json'), 'utf8')) as { - devDependencies?: Record - peerDependencies?: Record - } - // Asserted by shape, not by the framework's name: what matters is that the - // resolved section links into this repository while the peer keeps its range. - expect(Object.values(plugin.devDependencies ?? {}).every(spec => spec.startsWith('file:'))).toBe(true) - expect(Object.values(plugin.peerDependencies ?? {}).some(spec => spec.startsWith('^'))).toBe(true) - expect(createBuiltinRegistry(committed.profile).get(featureId('subagent')).inspect(committed).state).toBe('absent') - }) - - it('mounts an external plugin dependency and rejects missing deps or duplicate entries', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-external-plugin-')) - temporary.push(root) - const creation = request() - const project = SdkProject.create(root, creation) - const registry = createBuiltinRegistry(project.profile) - const edit = project.edit(registry) - for (const item of creation.features) edit.installFeature(registry.get(item.id), item) - await edit.commit() - const manifestPath = join(root, 'package.json') - const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { dependencies?: Record } - manifest.dependencies = { ...manifest.dependencies, 'ext-plugin': 'github:o/r#sha' } - await writeFile(manifestPath, JSON.stringify(manifest, null, 2)) - const reopened = await SdkProject.open(root) - const edit2 = reopened.edit(createBuiltinRegistry(reopened.profile)) - edit2.addExternalPlugin('ext-plugin', 'ext-plugin') - expect(() => { edit2.addExternalPlugin('ext-plugin', 'ext-plugin') }).toThrow('already exists') - expect(() => { edit2.addExternalPlugin('missing', 'not-a-dep') }).toThrow('not installed') - const commit = await edit2.commit() - expect(commit.project.cordis.entry('ext-plugin')?.name).toBe('ext-plugin') - }) -}) - -describe('extension points', () => { - const profile: ProjectProfile = { - name: 'test', description: 'test', runtime: { model: 'm' }, runInterface: 'embed', - packageManager: new NpmPackageManager('10.0.0'), releaseVersion: '0.0.1', - } - - it('rejects cross-feature resource ownership conflicts at registry construction', () => { - class TestOption extends FeatureOption { - override readonly id = 'default' - override readonly label = 'Default' - override contribution(): ProjectContribution { - return new ProjectContribution([{ - kind: 'npm-dependency', key: resourceKey('npm-dependency:shared'), name: 'shared', section: 'dependencies', - }]) - } - } - class TestFeature extends FixedFeature { - override readonly id - override readonly summary = 'test' - override readonly options = [new TestOption()] - constructor(id: string) { - super() - this.id = featureId(id) - } - } - expect(() => new FeatureRegistry([ - new TestFeature('one'), new TestFeature('two'), - ], profile)).toThrow('declared by both one and two') - expect(new TestFeature('one').defaultOptions()).toEqual(['default']) - expect(() => new FeatureRegistry([ - new TestFeature('one'), new TestFeature('one'), - ], profile)).toThrow('duplicate feature id') - }) - - it('validates selection modes and declarative feature definitions', () => { - const option = { id: 'one', label: 'One', default: true, resources: [] } as const - expect(() => defineFeature({ id: 'bad-single', summary: 'bad', mode: 'single', options: [] })) - .toThrow('requires one default option') - expect(() => defineFeature({ - id: 'bad-exclusive', summary: 'bad', mode: 'exclusive', options: [{ ...option, default: false }], - })).toThrow('exactly one default option') - expect(() => defineFeature({ - id: 'bad-multiple', summary: 'bad', mode: 'multiple', options: [{ ...option, default: false }], - })).toThrow('at least one default option') - const exclusive = defineFeature({ - id: 'defined', summary: 'Defined', mode: 'exclusive', supportedInterfaces: ['embed'], - requires: [{ id: 'base' }], suggests: ['suggested'], - baseResources: [{ kind: 'npm-dependency', name: 'base', section: 'devDependencies' }], - options: [ - { - id: 'one', label: 'One', default: true, - requires: [{ id: 'option', options: ['required'] }], - secrets: [{ id: 'token', environment: 'TOKEN', message: 'Token', required: true }], - resources: [ - { - kind: 'npm-cordis-config-entry', id: 'one', package: 'one-package', - config: { nested: { value: 1 }, list: ['x'], nullable: null }, - }, - { kind: 'owned-file', path: 'one.txt', text: 'one', removeOnlyWhenUnchanged: false }, - ], - }, - { - id: 'two', label: 'Two', resources: [ - { kind: 'file-cordis-config-entry', id: 'two', path: './two.ts' }, - ], - }, - ], - }) - expect(exclusive.defaultOptions(profile)).toEqual(['one']) - expect(exclusive.isApplicable(profile)).toBe(true) - expect(exclusive.isApplicable({ ...profile, runInterface: 'acp' })).toBe(false) - expect(exclusive.requirements(selection('defined', ['one']))).toEqual([ - { id: 'base' }, { id: 'option', options: ['required'] }, - ]) - const contribution = exclusive.contribution({ - id: featureId('defined'), options: ['one'], secrets: { token: 'secret' }, - }, profile) - expect(contribution.resources.map(resource => resource.kind)).toEqual([ - 'npm-dependency', 'npm-dependency', 'cordis-config-entry', 'owned-file', 'environment', - ]) - const entry = contribution.resources.find(resource => resource.kind === 'cordis-config-entry') - expect(entry?.validateConfig?.({ nested: { value: 2 }, list: ['a', 'b'], nullable: null })).toEqual([]) - expect(entry?.validateConfig?.({ nested: [], list: 'bad' })).toHaveLength(3) - expect(() => exclusive.normalizeSelection(selection('other', ['one']), profile)).toThrow('does not belong') - expect(() => exclusive.normalizeSelection(selection('defined', ['one']), { ...profile, runInterface: 'acp' })) - .toThrow('not available') - expect(() => exclusive.normalizeSelection(selection('defined', ['missing']), profile)).toThrow('unknown') - expect(() => exclusive.normalizeSelection(selection('defined', ['one', 'two']), profile)).toThrow('exactly one') - expect(defineFeatures([exclusive, { - id: 'fixed', summary: 'Fixed', mode: 'single', options: [option], - }])).toHaveLength(2) - expect(() => new FeatureRegistry([], profile).get(featureId('missing'))).toThrow('unknown feature') - expect(new FeatureRegistry([exclusive], profile).ownerOfPackage('one-package', { ...profile, runInterface: 'acp' })) - .toBeUndefined() - class Unsupported extends FixedFeature { - override readonly id = featureId('unsupported') - override readonly summary = 'unsupported' - override readonly options = [new (class extends FeatureOption { - override readonly id = 'one' - override readonly label = 'One' - override contribution(): ProjectContribution { return new ProjectContribution([]) } - })()] - override readonly supportedInterfaces = [] - } - expect(() => new FeatureRegistry([new Unsupported()], profile)).toThrow('supports no run interface') - }) - - it('covers feature base classes and resource conflict checks', () => { - class EmptySimple extends FixedFeature { - override readonly id = featureId('empty') - override readonly summary = 'empty' - override readonly options = [] - } - expect(() => new EmptySimple().defaultOptions()).toThrow('has no option') - class BadSimple extends FixedFeature { - override readonly id = featureId('bad-simple') - override readonly summary = 'bad' - override readonly options = [new (class extends FeatureOption { - override readonly id = 'one' - override readonly label = 'One' - override contribution(): ProjectContribution { return new ProjectContribution([]) } - })(), new (class extends FeatureOption { - override readonly id = 'two' - override readonly label = 'Two' - override contribution(): ProjectContribution { return new ProjectContribution([]) } - })()] - } - expect(() => new BadSimple().normalizeSelection(selection('bad-simple', ['one']), profile)).toThrow('one fixed option') - class EmptyMulti extends MultiOptionFeature { - override readonly id = featureId('multi') - override readonly summary = 'multi' - override readonly options = [] - override defaultOptions(): readonly string[] { return [] } - } - expect(() => new EmptyMulti().normalizeSelection(selection('multi', []), profile)).toThrow('at least one') - class EmptyExclusive extends ExclusiveOptionFeature { - override readonly id = featureId('exclusive') - override readonly summary = 'exclusive' - override readonly options = [] - override defaultOptions(): readonly string[] { return [] } - } - expect(() => new EmptyExclusive().normalizeSelection(selection('exclusive', []), profile)).toThrow('exactly one') - const resource = { - kind: 'npm-dependency' as const, key: resourceKey('same'), name: 'one', section: 'dependencies' as const, - } - expect(() => new ProjectContribution([resource, resource])).toThrow('duplicate contribution') - expect(() => ProjectContribution.merge( - new ProjectContribution([resource]), - new ProjectContribution([{ ...resource, name: 'two' }]), - )).toThrow('conflicting definitions') - expect(ProjectContribution.merge(new ProjectContribution([resource]), new ProjectContribution([resource])).byKey().size) - .toBe(1) - expect(ownedTextFile('owner', 'file.txt', 'text').document).toBeInstanceOf(TextProjectFile) - expect(optionalString({ value: 1 }, 'value')).toHaveLength(1) - expect(optionalString({}, 'value')).toEqual([]) - expect(requiredString({ value: 'x' }, 'value')).toEqual([]) - expect(stringArray({ value: ['a'] }, 'value')).toEqual([]) - expect(stringArray({ value: [1] }, 'value')).toHaveLength(1) - expect(cordisConfigEntry('owner', { id: 'entry', name: 'pkg' }).ownedConfigKeys).toEqual([]) - expect(npmCordisConfigEntry('owner', { id: 'entry', name: 'pkg' })[1].ownedConfigKeys).toEqual([]) - expect(npmCordisConfigEntry('owner', { id: 'entry', name: '@scope/pkg/subpath' })[0].name).toBe('@scope/pkg') - expect(npmCordisConfigEntry('owner', { id: 'entry', name: 'pkg/subpath' })[0].name).toBe('pkg') - for (const invalid of ['', '@scope', '@scope/']) { - expect(() => npmCordisConfigEntry('owner', { id: 'entry', name: invalid })).toThrow('invalid bare package specifier') - } - expect(environmentResource('owner', 'EMPTY', undefined)).not.toHaveProperty('value') - const builtins = createBuiltinRegistry(profile) - expect(builtins.get(featureId('app')).defaultOptions(profile)).toEqual(['embed']) - expect(builtins.get(featureId('hmr')).defaultOptions(profile)).toEqual(['default']) - const app = builtins.get(featureId('app')) - const acpEntry = builtins.get(featureId('app')).contribution(selection('app', ['acp']), profile).resources - .find((resource): resource is CordisConfigEntryResource => - resource.kind === 'cordis-config-entry' && resource.entry.id === 'acp') - expect(acpEntry?.entry.id).toBe('acp') - expect(acpEntry?.validateConfig?.({ model: '' })).toHaveLength(1) - const embedOption = app.options.find(option => option.id === 'embed') - expect(embedOption?.markerConfigEntries(profile)).toEqual([]) - expect(embedOption?.contribution(profile, {}).resources.map(resource => resource.kind)).toEqual([ - 'owned-file', 'owned-file', 'package-script', 'package-script', - ]) - expect(embedOption?.matchesConfigEntries([ - { id: 'agent-loop', name: '@deepseek-ai/dsh-agent-loop' }, - { id: 'acp', name: '@deepseek-ai/dsh-acp' }, - ], profile)).toBe(false) - const spineAgentLoop = builtins.get(featureId('spine')).contribution(selection('spine', ['default']), profile).resources - .find((resource): resource is CordisConfigEntryResource => - resource.kind === 'cordis-config-entry' && resource.entry.id === 'agent-loop') - expect(spineAgentLoop?.validateConfig?.({ agents: 'main' })).toEqual(['agents must be an array']) - expect(spineAgentLoop?.validateConfig?.({ agents: ['main'] })).toEqual(['agents must be empty']) - expect(spineAgentLoop?.validateConfig?.({ agents: [] })).toEqual([]) - expect(builtins.get(featureId('provider')).defaultOptions(profile)).toEqual(['deepseek-official']) - expect(() => builtins.get(featureId('provider')).contribution({ - id: featureId('provider'), options: ['custom'], values: { baseURL: 1 }, - }, profile)).toThrow('baseURL must be a string') - const alternateModel = builtins.get(featureId('provider')).contribution({ - id: featureId('provider'), options: ['deepseek-official'], - }, { ...profile, runtime: { model: 'other' } }).resources - .find(resource => resource.kind === 'cordis-config-entry') - expect(alternateModel?.entry.config?.models).toEqual(['other']) - class RequiringSimple extends BadSimple { - override readonly requires = [featureId('npm-dependency')] - } - expect(new RequiringSimple().requirements(selection('bad-simple', ['one']))).toEqual([{ id: 'npm-dependency' }]) - expect(builtins.get(featureId('bash')).defaultOptions(profile)).toEqual(['local']) - }) - - it('reports every inconsistent feature resource shape', () => { - const feature = defineFeature({ - id: 'inspectable', summary: 'Inspectable', mode: 'single', - baseResources: [{ kind: 'file-cordis-config-entry', id: 'base', path: 'pkg' }], - options: [{ - id: 'one', label: 'One', default: true, - secrets: [{ id: 'token', environment: 'TOKEN', message: 'Token', required: true }], - resources: [ - { kind: 'file-cordis-config-entry', id: 'one', path: 'pkg', config: { value: 'x' } }, - { kind: 'npm-dependency', name: 'dep' }, - { kind: 'owned-file', path: 'owned.txt', text: 'owned' }, - ], - }], - }) - const view = (entries: readonly CordisConfigEntry[]): FeatureProjectView => ({ - profile, - cordisConfigEntries: () => entries, - packageManifest: () => ({}), - hasDocument: () => false, - readEnvironment: (path) => { - if (path === '.env.example') throw new Error('bad env') - return 'secret' - }, - }) - expect(feature.inspect(view([])).state).toBe('absent') - const inconsistent = feature.inspect(view([ - { id: 'one', name: 'pkg', config: { value: 1 } }, - { id: 'extra', name: 'pkg', disabled: true }, - ])) - expect(inconsistent.state).toBe('inconsistent') - expect(inconsistent.diagnostics.join('\n')).toContain('missing Cordis config entry base') - expect(inconsistent.diagnostics.join('\n')).toContain('unexpected owned Cordis config entry extra') - expect(inconsistent.diagnostics.join('\n')).toContain('missing package.json dependencies entry dep') - expect(inconsistent.diagnostics.join('\n')).toContain('missing owned file owned.txt') - expect(inconsistent.diagnostics.join('\n')).toContain('bad env') - expect(inconsistent.diagnostics.join('\n')).toContain('mixed enabled states') - expect(feature.inspect(view([{ id: 'unknown', name: 'pkg' }])).state).toBe('inconsistent') - const ambiguous = defineFeature({ - id: 'ambiguous', summary: 'Ambiguous', mode: 'exclusive', - options: [ - { id: 'one', label: 'One', default: true, resources: [], markers: [{ id: 'one', name: 'pkg' }] }, - { id: 'two', label: 'Two', resources: [], markers: [{ id: 'two', name: 'pkg' }] }, - ], - }) - expect(ambiguous.inspect(view([{ id: 'one', name: 'pkg' }, { id: 'two', name: 'pkg' }])).state) - .toBe('inconsistent') - const noValidator = defineFeature({ - id: 'no-validator', summary: 'No validator', mode: 'single', - options: [{ - id: 'one', label: 'One', default: true, - resources: [{ kind: 'file-cordis-config-entry', id: 'plain', path: 'plain-package' }], - }], - }) - expect(noValidator.inspect(view([{ id: 'plain', name: 'plain-package' }])).state).toBe('enabled') - const app = createBuiltinRegistry(profile).get(featureId('app')) - expect(app.inspect(view([{ id: 'acp', name: '@deepseek-ai/dsh-acp' }])).state) - .toBe('inconsistent') - }) -}) diff --git a/packages/scaffold/helper/tests/questions.spec.ts b/packages/scaffold/helper/tests/questions.spec.ts deleted file mode 100644 index 3cfc7ae029..0000000000 --- a/packages/scaffold/helper/tests/questions.spec.ts +++ /dev/null @@ -1,484 +0,0 @@ -import { PassThrough, Writable } from 'node:stream' -import { stripVTControlCharacters } from 'node:util' -import { S_CHECKBOX_SELECTED, S_RADIO_ACTIVE, S_WARN } from '@clack/prompts' -import { describe, expect, it } from 'vitest' -import { createBuiltinRegistry } from '../src/features/builtin/index.ts' -import { FeatureConfigurator } from '../src/features/feature-configurator.ts' -import { FeatureOption, ExclusiveOptionFeature } from '../src/features/feature.ts' -import { ProjectContribution } from '../src/features/resources.ts' -import { featureId } from '../src/ids.ts' -import { NpmPackageManager } from '../src/package-managers/package-manager.ts' -import { ClackPromptPort } from '../src/questions/clack-prompt-port.ts' -import { - PromptCancelledError, - requireAnswer, - type ConfirmPromptRequest, - type MultiSelectPromptRequest, - type NestedMultiSelectRequest, - type NestedMultiSelectValue, - type PromptOutcome, - type PromptPort, - type SecretPromptRequest, - type SelectPromptRequest, - type TextPromptRequest, -} from '../src/questions/prompt-port.ts' -import { - ConfirmQuestion, - MultiSelectQuestion, - SecretQuestion, - SelectQuestion, - TextQuestion, -} from '../src/questions/question.ts' -import type { ProjectProfile } from '../src/project/types.ts' -import { clackNestedMultiselect } from '../src/questions/clack-nested-multiselect.ts' - -function validateString( - outcome: PromptOutcome, - validate: ((value: string) => string | undefined) | undefined, -): PromptOutcome { - if (outcome.status === 'answered') { - const diagnostic = validate?.(outcome.value) - if (diagnostic) throw new Error(diagnostic) - } - return outcome -} - -class QueuePromptPort implements PromptPort { - readonly answers: unknown[] - readonly requests: string[] = [] - - constructor(answers: unknown[]) { - this.answers = [...answers] - } - - next(message: string): PromptOutcome { - this.requests.push(message) - const value = this.answers.shift() - return value === QueuePromptPort.cancel ? { status: 'cancelled' } : { status: 'answered', value: value as T } - } - - async text(request: TextPromptRequest): Promise> { - return validateString(this.next(request.message), request.validate) - } - - async secret(request: SecretPromptRequest): Promise> { - return validateString(this.next(request.message), request.validate) - } - - select(request: SelectPromptRequest): Promise> { - return Promise.resolve(this.next(request.message)) - } - - multiselect(request: MultiSelectPromptRequest): Promise> { - return Promise.resolve(this.next(request.message)) - } - - confirm(request: ConfirmPromptRequest): Promise> { - return Promise.resolve(this.next(request.message)) - } - nestedMultiselect( - request: NestedMultiSelectRequest, - ): Promise[]>> { - return Promise.resolve(this.next(request.message)) - } - - static readonly cancel = Symbol('cancel') -} - -describe('typed questions', () => { - it('uses and validates prefilled answers without prompting', async () => { - const port = new QueuePromptPort([]) - const text = new TextQuestion({ id: 'name', message: 'Name', validate: value => value ? undefined : 'required' }) - await expect(text.resolve(port, 'demo')).resolves.toEqual({ status: 'answered', value: 'demo' }) - await expect(text.resolve(port, '')).rejects.toThrow('name: required') - const select = new SelectQuestion({ - id: 'choice', message: 'Choice', options: [{ value: 'a', label: 'A' }], initialValue: 'a', - }) - await expect(select.resolve(port, 'b')).rejects.toThrow('unknown or disabled option') - await expect(select.resolve(port, 'a')).resolves.toMatchObject({ value: 'a' }) - const disabled = new SelectQuestion({ - id: 'disabled', message: 'Disabled', options: [{ value: 'a', label: 'A', disabled: true }], - }) - await expect(disabled.resolve(port, 'a')).rejects.toThrow('disabled option') - const multi = new MultiSelectQuestion({ - id: 'many', message: 'Many', options: [{ value: 'a', label: 'A' }], required: true, - }) - await expect(multi.resolve(port, [])).rejects.toThrow('choose at least one') - await expect(multi.resolve(port, ['missing'])).rejects.toThrow('unknown or disabled option') - await expect(new MultiSelectQuestion({ - id: 'disabled-many', message: 'Disabled many', options: [{ value: 'a', label: 'A', disabled: true }], - }).resolve(port, ['a'])).rejects.toThrow('disabled option') - await expect(new MultiSelectQuestion({ - id: 'optional', message: 'Optional', options: [{ value: 'a', label: 'A' }], - }).resolve(port, [])).resolves.toMatchObject({ value: [] }) - const secret = new SecretQuestion({ id: 'secret', message: 'Secret', validate: value => value ? undefined : 'required' }) - await expect(secret.resolve(port, 'value')).resolves.toMatchObject({ value: 'value' }) - await expect(secret.resolve(port, '')).rejects.toThrow('secret: required') - await expect(new ConfirmQuestion({ id: 'confirm', message: 'Confirm' }).resolve(port, false)) - .resolves.toEqual({ status: 'answered', value: false }) - expect(port.requests).toEqual([]) - }) - - it('delegates each interaction shape and propagates cancellation', async () => { - const port = new QueuePromptPort(['text', 'secret', 'a', ['a'], true, QueuePromptPort.cancel]) - await expect(new TextQuestion({ id: 't', message: 'Text' }).resolve(port)).resolves.toMatchObject({ value: 'text' }) - await expect(new SecretQuestion({ id: 's', message: 'Secret' }).resolve(port)).resolves.toMatchObject({ value: 'secret' }) - await expect(new SelectQuestion({ - id: 'one', message: 'One', options: [{ value: 'a', label: 'A' }], - }).resolve(port)).resolves.toMatchObject({ value: 'a' }) - await expect(new MultiSelectQuestion({ - id: 'many', message: 'Many', options: [{ value: 'a', label: 'A' }], - }).resolve(port)).resolves.toMatchObject({ value: ['a'] }) - await expect(new ConfirmQuestion({ id: 'yes', message: 'Yes?' }).resolve(port)).resolves.toMatchObject({ value: true }) - const cancelled = await new ConfirmQuestion({ id: 'cancel', message: 'Cancel?' }).resolve(port) - expect(() => requireAnswer(cancelled)).toThrow(PromptCancelledError) - const optionsPort = new QueuePromptPort(['full', 'a', ['a']]) - await new TextQuestion({ - id: 'full', message: 'Full', placeholder: 'p', initialValue: 'i', defaultValue: 'd', validate: () => undefined, - }).resolve(optionsPort) - await new SelectQuestion({ - id: 'initial', message: 'Initial', options: [{ value: 'a', label: 'A' }], initialValue: 'a', - }).resolve(optionsPort) - await new MultiSelectQuestion({ - id: 'initial-many', message: 'Initial many', options: [{ value: 'a', label: 'A' }], - initialValues: ['a'], required: true, - }).resolve(optionsPort) - }) - - it('accepts a visible placeholder default before required validation', async () => { - const input = new PassThrough() - const output = new Writable({ write(_chunk, _encoding, callback) { callback() } }) - const pending = new ClackPromptPort(input, output).text({ - message: 'Directory', - placeholder: 'my-agent', - defaultValue: 'my-agent', - validate: value => value ? undefined : 'required', - }) - setTimeout(() => input.write('\r'), 0) - await expect(pending).resolves.toEqual({ status: 'answered', value: 'my-agent' }) - }) - - it('renders warning confirmations with a yellow warning marker', async () => { - const input = new PassThrough() - let screen = '' - const output = new Writable({ write(chunk, _encoding, callback) { screen += String(chunk); callback() } }) - const pending = new ClackPromptPort(input, output).confirm({ - message: 'Keep empty?', - initialValue: true, - tone: 'warning', - }) - setTimeout(() => input.write('\r'), 0) - await expect(pending).resolves.toEqual({ status: 'answered', value: true }) - expect(stripVTControlCharacters(screen)).toContain(`${S_WARN} Keep empty?`) - }) - - it('adapts secret, select, multiselect, nested, and cancellation prompts', async () => { - const run = async ( - start: (port: ClackPromptPort) => Promise>, - keys: string, - ): Promise> => { - const input = new PassThrough() - const output = new Writable({ write(_chunk, _encoding, callback) { callback() } }) - const pending = start(new ClackPromptPort(input, output)) - setTimeout(() => input.write(keys), 0) - return pending - } - await expect(run(port => port.secret({ message: 'Secret', validate: value => value ? undefined : 'required' }), 'key\r')) - .resolves.toEqual({ status: 'answered', value: 'key' }) - await expect(run(port => port.secret({ message: 'Secret' }), 'plain\r')) - .resolves.toEqual({ status: 'answered', value: 'plain' }) - await expect(run(port => port.text({ message: 'Text', initialValue: 'seed' }), '\r')) - .resolves.toEqual({ status: 'answered', value: 'seed' }) - let validated = 'unset' - await expect(run(port => port.text({ - message: 'Empty', validate: (value) => { validated = value; return undefined }, - }), '\r')).resolves.toEqual({ status: 'answered', value: '' }) - expect(validated).toBe('') - await expect(run(port => port.select({ - message: 'Select', options: [{ value: 'a', label: 'A', hint: 'hint' }, { value: 'b', label: 'B', disabled: true }], - initialValue: 'a', - }), '\r')).resolves.toEqual({ status: 'answered', value: 'a' }) - await expect(run(port => port.multiselect({ - message: 'Many', options: [{ value: 'a', label: 'A' }], initialValues: ['a'], required: true, - }), '\r')).resolves.toEqual({ status: 'answered', value: ['a'] }) - await expect(run(port => port.multiselect({ - message: 'Many', options: [{ value: 'a', label: 'A' }], - }), ' \r')).resolves.toEqual({ status: 'answered', value: ['a'] }) - await expect(run(port => port.nestedMultiselect({ - message: 'Nested', options: [{ value: 'a', label: 'A', default: true }], - }), '\r')).resolves.toEqual({ status: 'answered', value: [{ value: 'a', choices: [] }] }) - await expect(run(port => port.confirm({ message: 'Cancel' }), '\u0003')).resolves.toEqual({ status: 'cancelled' }) - expect(new ClackPromptPort()).toBeInstanceOf(ClackPromptPort) - }) -}) - -describe('nested Clack picker', () => { - it('navigates root options, ignores disabled rows, and toggles optional rows', async () => { - const input = new PassThrough() - const output = new Writable({ write(_chunk, _encoding, callback) { callback() } }) - const pending = clackNestedMultiselect({ - message: 'Features', showChanges: true, input, output, - options: [ - { value: 'required', label: 'Required', required: true }, - { value: 'optional', label: 'Optional', default: true }, - { value: 'added', label: 'Added' }, - { value: 'disabled', label: 'Disabled', disabled: true, warning: 'disabled warning' }, - ], - }) - setTimeout(() => input.write('\x1b[A \x1b[B\x1b[B \x1b[B \x1b[A\r'), 0) - await expect(pending).resolves.toEqual({ - status: 'answered', - value: [{ value: 'required', choices: [] }, { value: 'added', choices: [] }], - }) - }) - - it('cancels from the root layer', async () => { - const input = new PassThrough() - const output = new Writable({ write(_chunk, _encoding, callback) { callback() } }) - const pending = clackNestedMultiselect({ - message: 'Features', input, output, options: [{ value: 'one', label: 'One' }], - }) - setTimeout(() => input.write('\u0003'), 0) - await expect(pending).resolves.toEqual({ status: 'cancelled' }) - }) - - it('enters an exclusive child with Right and commits the selected option', async () => { - const input = new PassThrough() - let screen = '' - const output = new Writable({ write(chunk, _encoding, callback) { screen += String(chunk); callback() } }) - const pending = clackNestedMultiselect({ - message: 'Features', - showChanges: true, - input, - output, - options: [ - { - value: 'persistence', - label: 'Session storage', - required: true, - default: true, - choiceMode: 'exclusive', - choices: [ - { value: 'jsonl', label: 'JSONL', default: true }, - { value: 'sqlite', label: 'SQLite' }, - ], - }, - { value: 'fs', label: 'Filesystem', default: true }, - ], - }) - setTimeout(() => input.write('\x1b[C\x1b[B\x1b[A\x1b[B\x1b[C\r\r'), 0) - await expect(pending).resolves.toEqual({ - status: 'answered', - value: [ - { value: 'persistence', choices: ['sqlite'] }, - { value: 'fs', choices: [] }, - ], - }) - const rendered = stripVTControlCharacters(screen) - expect(rendered).toContain(`› ${S_CHECKBOX_SELECTED} Session storage`) - expect(rendered).toContain(`› ${S_RADIO_ACTIVE} SQLite`) - expect(rendered).toContain('● changed') - }) - - it('highlights and blocks a selected multiple feature with no child option', async () => { - const input = new PassThrough() - let screen = '' - const output = new Writable({ write(chunk, _encoding, callback) { screen += String(chunk); callback() } }) - const pending = clackNestedMultiselect({ - message: 'Features', - input, - output, - options: [{ - value: 'hooks', - label: 'Hooks', - default: true, - choiceMode: 'multiple', - choices: [ - { value: 'claude', label: 'Claude', default: true }, - { value: 'codex', label: 'Codex' }, - ], - }], - }) - setTimeout(() => input.write('\x1b[C \x1b[D \x1b[D\r'), 0) - await expect(pending).resolves.toEqual({ - status: 'answered', - value: [{ value: 'hooks', choices: ['claude'] }], - }) - expect(stripVTControlCharacters(screen)).toContain('▲ choose at least one') - }) - - it('blocks root submission for an exclusive feature with no selected option', async () => { - const input = new PassThrough() - let screen = '' - const output = new Writable({ write(chunk, _encoding, callback) { screen += String(chunk); callback() } }) - const pending = clackNestedMultiselect({ - message: 'Features', input, output, - options: [{ - value: 'provider', label: 'Provider', default: true, choiceMode: 'exclusive', - choices: [{ value: 'one', label: 'One' }], - }], - }) - setTimeout(() => input.write('\r\x1b[C\x1b[C\r\r'), 0) - await expect(pending).resolves.toEqual({ - status: 'answered', value: [{ value: 'provider', choices: ['one'] }], - }) - expect(stripVTControlCharacters(screen)).toContain('Choose one Provider option') - }) - - it('blocks root submission for a multiple feature with no selected option', async () => { - const input = new PassThrough() - const output = new Writable({ write(_chunk, _encoding, callback) { callback() } }) - const pending = clackNestedMultiselect({ - message: 'Features', input, output, - options: [{ - value: 'hooks', label: 'Hooks', default: true, choiceMode: 'multiple', - choices: [{ value: 'one', label: 'One' }], - }], - }) - setTimeout(() => input.write('\r\x1b[C \r\r'), 0) - await expect(pending).resolves.toEqual({ - status: 'answered', value: [{ value: 'hooks', choices: ['one'] }], - }) - }) - - it('renders an unchanged checked option while another child is focused', async () => { - const input = new PassThrough() - const output = new Writable({ write(_chunk, _encoding, callback) { callback() } }) - const pending = clackNestedMultiselect({ - message: 'Features', input, output, - options: [{ - value: 'hooks', label: 'Hooks', default: true, choiceMode: 'multiple', - choices: [ - { value: 'one', label: 'One', default: true }, - { value: 'two', label: 'Two', default: true }, - ], - }], - }) - setTimeout(() => input.write('\x1b[C\x1b[B\x1b[D\r'), 0) - await expect(pending).resolves.toEqual({ - status: 'answered', value: [{ value: 'hooks', choices: ['one', 'two'] }], - }) - }) - - it('submits an empty optional selection', async () => { - const input = new PassThrough() - const output = new Writable({ write(_chunk, _encoding, callback) { callback() } }) - const pending = clackNestedMultiselect({ - message: 'Features', input, output, options: [{ value: 'one', label: 'One' }], - }) - setTimeout(() => input.write('\r'), 0) - await expect(pending).resolves.toEqual({ status: 'answered', value: [] }) - }) -}) - -describe('feature configurator', () => { - const profile: ProjectProfile = { - name: 'demo', - description: 'demo', - runtime: { model: 'deepseek-v4-flash' }, - runInterface: 'embed', - packageManager: new NpmPackageManager('10.0.0'), - releaseVersion: '0.0.1', - } - - it('shares exclusive, multiple, fixed, and secret behavior', async () => { - const registry = createBuiltinRegistry(profile) - const port = new QueuePromptPort(['sqlite', ['spawn', 'fork'], 'deepseek-official', 'new-key']) - const configurator = new FeatureConfigurator(port) - await expect(configurator.configure(registry.get(featureId('persistence')), profile)).resolves.toMatchObject({ - options: ['sqlite'], - }) - await expect(configurator.configure(registry.get(featureId('subagent')), profile)).resolves.toMatchObject({ - options: ['spawn', 'fork'], - }) - await expect(configurator.configure( - registry.get(featureId('provider')), - profile, - { id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'old-key' } }, - )).resolves.toMatchObject({ secrets: { apiKey: 'new-key' } }) - expect(port.requests).toEqual([ - 'Choose durable session storage', - 'Choose delegate work to child agents', - 'Choose model provider', - 'DeepSeek API key (leave empty to keep current)', - ]) - }) - - it('validates feature values, defaults, and retained secrets', async () => { - const registry = createBuiltinRegistry(profile) - const fixed = new FeatureConfigurator(new QueuePromptPort([])) - await expect(fixed.configure(registry.get(featureId('bash')), profile, undefined, ['local'])).resolves.toMatchObject({ - options: ['local'], - }) - const requiredSecret = new FeatureConfigurator(new QueuePromptPort([])) - await expect(requiredSecret.configure( - registry.get(featureId('provider')), profile, undefined, ['deepseek-official'], { apiKey: '' }, - )).rejects.toThrow('required') - const keep = new FeatureConfigurator(new QueuePromptPort(['deepseek-official', ''])) - await expect(keep.configure( - registry.get(featureId('provider')), - profile, - { id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'old' } }, - )).resolves.toMatchObject({ secrets: { apiKey: 'old' } }) - const custom = registry.get(featureId('provider')) - await expect(new FeatureConfigurator(new QueuePromptPort(['custom'])).configure( - custom, - profile, - { id: featureId('provider'), options: ['custom'], values: { baseURL: 1 }, secrets: { apiKey: 'old' } }, - )).rejects.toThrow('current value must be a string') - await expect(new FeatureConfigurator(new QueuePromptPort(['custom', ''])).configure( - custom, profile, undefined, - )).rejects.toThrow('required') - await expect(new FeatureConfigurator(new QueuePromptPort(['custom', 'https://next', ''])).configure( - custom, - profile, - { - id: featureId('provider'), options: ['custom'], - values: { baseURL: 'https://old' }, secrets: { apiKey: 'old' }, - }, - )).resolves.toMatchObject({ values: { baseURL: 'https://next' }, secrets: { apiKey: 'old' } }) - class EmptyExclusive extends ExclusiveOptionFeature { - override readonly id = featureId('empty-exclusive') - override readonly summary = 'Empty' - override readonly options = [new (class extends FeatureOption { - override readonly id = 'one' - override readonly label = 'One' - override contribution(): ProjectContribution { return new ProjectContribution([]) } - })()] - override defaultOptions(): readonly string[] { return [] } - } - await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(new EmptyExclusive(), profile)) - .rejects.toThrow('has no default option') - }) - - it('configures fully from prefilled options, values, and secrets without prompting', async () => { - const registry = createBuiltinRegistry(profile) - const port = new QueuePromptPort([]) - const result = await new FeatureConfigurator(port).configure( - registry.get(featureId('provider')), - profile, - undefined, - ['custom'], - { apiKey: 'prefilled-key' }, - { baseURL: 'https://prefilled' }, - ) - expect(result).toMatchObject({ - options: ['custom'], - values: { baseURL: 'https://prefilled' }, - secrets: { apiKey: 'prefilled-key' }, - }) - expect(port.requests).toEqual([]) - }) - - it('rejects a non-string prefilled feature value', async () => { - const registry = createBuiltinRegistry(profile) - await expect(new FeatureConfigurator(new QueuePromptPort([])).configure( - registry.get(featureId('provider')), - profile, - undefined, - ['custom'], - { apiKey: 'k' }, - { baseURL: 123 }, - )).rejects.toThrow('must be a string') - }) -}) diff --git a/packages/scaffold/helper/tsconfig.json b/packages/scaffold/helper/tsconfig.json deleted file mode 100644 index f33033f47f..0000000000 --- a/packages/scaffold/helper/tsconfig.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": ["src"], - "references": [ - { - "path": "../../util/brand" - }, - { - "path": "../../compact/compact-basic" - }, - { - "path": "../../hooks/hooks-claude" - }, - { - "path": "../../hooks/hooks-codex" - }, - { - "path": "../../session/session-persistence-jsonl" - }, - { - "path": "../../session/session-persistence-sqlite" - }, - { - "path": "../../subagent/tool-subagent" - }, - { - "path": "../../todo/tool-todo" - }, - { - "path": "../../web/tool-web" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../subprocess/subprocess" - }, - { - "path": "../../support/invariants" - } - ] -} diff --git a/packages/scaffold/helper/tsdown.config.ts b/packages/scaffold/helper/tsdown.config.ts deleted file mode 100644 index b8ba9cb652..0000000000 --- a/packages/scaffold/helper/tsdown.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from 'tsdown' - -/** Bundle helper runtime and mirror template assets beside the bundle. */ -export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/invariant.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, - copy: [{ from: 'src/templates/assets/*', to: 'lib/assets' }], -}) diff --git a/packages/scaffold/scripts/README.i18n.yaml b/packages/scaffold/scripts/README.i18n.yaml deleted file mode 100644 index db65cb315f..0000000000 --- a/packages/scaffold/scripts/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 packages/scaffold/scripts/README.md -README.md: 9a696bf5a4de9a80f0741f07a7e753733bc2f998 -README.zh.md: cf9fc2cbbe467eb86e89d9eb77e9bc34860b5a3a diff --git a/packages/scaffold/scripts/README.md b/packages/scaffold/scripts/README.md deleted file mode 100644 index 9a696bf5a4..0000000000 --- a/packages/scaffold/scripts/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# `@deepseek-ai/dsh-scripts` - -English | [中文](README.zh.md) - -The `dsh-sdk` launcher owns SDK project startup and configuration. - -| Command | Behavior | -|---|---| -| `dsh-sdk start [target] [-- args…]` | Import a module target and invoke `main(bootContext)`, or boot `cordis.yml` when omitted; arguments after `--` are forwarded | -| `dsh-sdk dev [target] [-- args…]` | Register TypeScript and local-workspace source resolution, then use the start path | -| `dsh-sdk build [args…]` | Invoke the project's installed tsdown with the project arguments | -| `dsh-sdk config` | Open one interactive edit session, review accumulated changes, commit once, and install once when NPM dependencies changed | -| `dsh-sdk create ` | Add an external Cordis plugin from a native package-manager source (`pkg@version` or `github:owner/repo#ref`): confirm, ` add `, then mount the resolved dependency in `cordis.yml`. No giget/pacote; the package manager resolves and pins the source (github deps build via their own `prepare` under the manager's policy) | - -`ProjectBuild(tsdownConfig)` and `PluginBuild(tsdownConfig)` are exported only from `@deepseek-ai/dsh-scripts/dev/tsdown-config`. Development and production read the same `cordis.yml`. - -Generated project scripts invoke `dsh-sdk` for dev, build, start, and config; typecheck runs `tsc -b` directly. HMR remains an explicit `cordis.yml` feature loaded by both dev and start. - -The runtime library exports `startSDK(source)` to load `.env` and `cordis.yml` and return the live context, and `runSDK(target)` to import a project module and invoke its `main(bootContext)` (`runSDK()` without a target delegates to `startSDK('./cordis.yml')`). `SdkBootContext` carries the raw forwarded `argv`, generic `args`, the absolute launcher `cwd`, and the `start`/`dev` mode. The launcher declares no project options: Node `parseArgs()` runs with zero schema, so valued flags use `--key=value`, bare flags become booleans, `--no-cache` becomes `args.cache = false`, and option names retain Node's spelling (`--max-depth=3` → `args['max-depth']`). - -`start` never builds. `dev` registers the project-installed tsx transform plus an exact package-name map from `plugins/*/package.json` to each `src/index.ts`, then follows the same start path. `build` invokes the project-installed tsdown and forwards its arguments; an absent tsdown config is a successful no-op. - -`config` requires a TTY. One feature tree selects the desired enabled set; changed rows are highlighted, Right changes finite feature options, required rows cannot be deselected, inconsistent rows show diagnostics, and custom/manual Cordis config entries support enable/disable. The workflow reconciles that target into one edit session. Review & Apply commits once, then NPM dependency changes trigger one package-manager install. A failed install does not undo committed files. - -The root library exports `startSDK`, `runSDK`, and the `SdkBootArgs`/`SdkBootContext` types; command composition remains private to the bin. No `src/*`, bin, or package-manifest subpath is exported. - -## Model Experience - -Indirectly, through the project `cordis.yml` tree loaded by `start` or `dev`. - -#### KV Cache effect - -No direct invalidation; the named consumer owns any request-prefix changes. - -## Known Limitations and Deferred Work - -- **Launcher arguments are schema-free** — `start` and `dev` preserve Node `parseArgs()` output rather than validating project-specific flags. diff --git a/packages/scaffold/scripts/README.zh.md b/packages/scaffold/scripts/README.zh.md deleted file mode 100644 index cf9fc2cbbe..0000000000 --- a/packages/scaffold/scripts/README.zh.md +++ /dev/null @@ -1,37 +0,0 @@ -# `@deepseek-ai/dsh-scripts` - -[English](README.md) | 中文 - -`dsh-sdk` 启动器负责 SDK 项目启动与配置。 - -| 命令 | 行为 | -|---|---| -| `dsh-sdk start [target] [-- args…]` | 导入模块目标并调用 `main(bootContext)`;省略目标时启动 `cordis.yml`;`--` 后的参数原样转发 | -| `dsh-sdk dev [target] [-- args…]` | 注册 TypeScript 与本地工作区源代码解析,然后进入 start 路径 | -| `dsh-sdk build [args…]` | 使用项目参数调用项目已安装的 tsdown | -| `dsh-sdk config` | 打开一个交互式编辑会话,审阅累计变更,统一提交一次;NPM 依赖变化时只安装一次 | -| `dsh-sdk create ` | 从包管理器原生支持的来源(`pkg@version` 或 `github:owner/repo#ref`)添加外部 Cordis 插件:确认后执行 ` add `,再将解析出的依赖挂载到 `cordis.yml`。不使用 giget/pacote;由包管理器解析并固定来源(GitHub 依赖会在管理器策略下通过自身 `prepare` 构建) | - -`ProjectBuild(tsdownConfig)` 与 `PluginBuild(tsdownConfig)` 只从 `@deepseek-ai/dsh-scripts/dev/tsdown-config` 导出。开发环境与生产环境读取同一个 `cordis.yml`。 - -生成项目的脚本通过 `dsh-sdk` 执行 dev、build、start 和 config;类型检查直接运行 `tsc -b`。HMR(热模块替换)始终是显式的 `cordis.yml` 功能,并由 dev 与 start 同时加载。 - -运行时库导出 `startSDK(source)`,用于加载 `.env` 和 `cordis.yml` 并返回活跃上下文;还导出 `runSDK(target)`,用于导入项目模块并调用其 `main(bootContext)`(不带目标的 `runSDK()` 会委派给 `startSDK('./cordis.yml')`)。`SdkBootContext` 携带原样转发的 `argv`、通用 `args`、启动器的绝对 `cwd`,以及 `start`/`dev` 模式。启动器不声明项目选项:Node `parseArgs()` 使用空 schema 运行,因此带值的标志写作 `--key=value`,裸标志变为布尔值,`--no-cache` 变为 `args.cache = false`,选项名称保留 Node 的拼写(`--max-depth=3` → `args['max-depth']`)。 - -`start` 绝不构建。`dev` 注册项目已安装的 tsx 转换,并建立从 `plugins/*/package.json` 中的精确包名到各自 `src/index.ts` 的映射,然后沿用相同的 start 路径。`build` 调用项目已安装的 tsdown 并转发其参数;缺少 tsdown 配置时视为成功且不执行操作。 - -`config` 要求 TTY。一个功能树用于选择期望的启用集合;变更行会高亮,Right 用于修改取值有限的功能选项,必需行无法取消选择,不一致行会显示诊断,自定义/手动 Cordis 配置项支持启用/禁用。工作流会在一个编辑会话中将配置协调至该目标状态。Review & Apply 只提交一次;之后,如果 NPM 依赖有变更,则触发一次包管理器安装。安装失败不会撤销已提交文件。 - -根库导出 `startSDK`、`runSDK` 以及 `SdkBootArgs`/`SdkBootContext` 类型;命令组合仍是 bin 的私有实现。不导出 `src/*`、bin 或 package-manifest 子路径。 - -## 模型体验 - -通过项目 `cordis.yml` 树间接提供;该树由 `start` 或 `dev` 加载。 - -#### KV Cache 影响 - -不会直接导致 KV Cache 失效;由具名消费方负责请求前缀变更。 - -## 已知限制与暂缓事项 - -- **启动器参数没有 schema**:`start` 和 `dev` 会保留 Node `parseArgs()` 输出,而不会验证项目专用标志。 diff --git a/packages/scaffold/scripts/package.json b/packages/scaffold/scripts/package.json deleted file mode 100644 index c12396fccc..0000000000 --- a/packages/scaffold/scripts/package.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-scripts", - "description": "DeepSeek Harness SDK launcher for start, dev, build, and project configuration", - "version": "0.0.1-rc.1", - "publishConfig": { - "access": "restricted" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/scaffold/scripts" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "bin": { - "dsh-sdk": "lib/bin.js" - }, - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./dev/tsdown-config": { - "types": "./lib/types/dev/tsdown-config.d.ts", - "default": "./lib/dev/tsdown-config.js" - } - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/bin.js", - "lib/dev/tsdown-config.js", - "lib/local-plugin-loader-hooks.js", - "lib/assets", - "lib/types/**/*.d.ts" - ], - "license": "BSD-3-Clause", - "dependencies": { - "@deepseek-ai/dsh-helper": "workspace:^", - "@deepseek-ai/dsh-telemetry": "workspace:^", - "commander": "^15.0.0", - "node-addon-require-builtin": "^0.1.4" - }, - "peerDependencies": { - "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "tsdown": "^0.22.2", - "tsx": "^4.22.4" - }, - "peerDependenciesMeta": { - "tsdown": { - "optional": true - }, - "tsx": { - "optional": true - } - }, - "devDependencies": { - "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "tsdown": "^0.22.2", - "tsx": "^4.22.4" - } -} diff --git a/packages/scaffold/scripts/src/args.ts b/packages/scaffold/scripts/src/args.ts deleted file mode 100644 index 4d1ce867de..0000000000 --- a/packages/scaffold/scripts/src/args.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Commander adapter for the dsh-sdk subcommand surface. - * - * @module @deepseek-ai/dsh-scripts/args - */ - -import { parseArgs as parseNodeArgs } from 'node:util' -import { Command } from 'commander' - -/** Commands implemented by the dsh-sdk launcher. */ -type DshSdkCommand = 'start' | 'dev' | 'build' | 'config' | 'create' - -/** Parsed dsh-sdk invocation. */ -export interface DshSdkArgs { - command?: DshSdkCommand - target?: string - source?: string - forwarded: readonly string[] - help: boolean -} - -/** Parse arbitrary project flags through Node's zero-schema argument parser. */ -export function parseSdkBootArgs(argv: readonly string[]): Record { - return parseNodeArgs({ - args: [...argv], - strict: false, - allowPositionals: true, - allowNegative: true, - }).values -} - -/** Parse one launcher invocation through real Commander subcommands. */ -export function parseDshSdkArgs(argv: readonly string[]): DshSdkArgs { - if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') { - return { forwarded: [], help: true } - } - const separator = argv.indexOf('--') - const launcherArgv = separator === -1 ? argv : argv.slice(0, separator) - const passthrough = separator === -1 ? [] : argv.slice(separator + 1) - let parsed: DshSdkArgs | undefined - const program = new Command() - .name('dsh-sdk') - .helpOption(false) - .showHelpAfterError(false) - .exitOverride() - .configureOutput({ - /* v8 ignore next -- the command wrapper renders the package-owned usage template */ - writeOut: () => {}, - /* v8 ignore next -- Commander errors are returned to the command wrapper */ - writeErr: () => {}, - }) - program.command('start [target]').helpOption(false).action((target?: string) => { - parsed = { command: 'start', ...target ? { target } : {}, forwarded: [], help: false } - }) - program.command('dev [target]').helpOption(false).action((target?: string) => { - parsed = { command: 'dev', ...target ? { target } : {}, forwarded: [], help: false } - }) - program.command('build [args...]').helpOption(false).allowUnknownOption(true).action((args: string[] = []) => { - parsed = { command: 'build', forwarded: args, help: false } - }) - program.command('config').helpOption(false).action(() => { - parsed = { command: 'config', forwarded: [], help: false } - }) - program.command('create ').helpOption(false).action((source: string) => { - parsed = { command: 'create', source, forwarded: [], help: false } - }) - program.parse([...launcherArgv], { from: 'user' }) - /* v8 ignore next -- every registered Commander action above assigns parsed or Commander throws */ - if (!parsed) throw new Error('dsh-sdk command did not resolve') - if (parsed.command === 'config' && passthrough.length > 0) { - throw new Error('dsh-sdk config does not accept forwarded arguments') - } - return { ...parsed, forwarded: [...parsed.forwarded, ...passthrough] } -} diff --git a/packages/scaffold/scripts/src/bin.ts b/packages/scaffold/scripts/src/bin.ts deleted file mode 100644 index f125f0f4ac..0000000000 --- a/packages/scaffold/scripts/src/bin.ts +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env node -/** - * Self-executing dsh-sdk launcher. - * - * @module @deepseek-ai/dsh-scripts/bin - */ - -import { runDshSdkCommand } from './command.ts' - -process.exitCode = await runDshSdkCommand() diff --git a/packages/scaffold/scripts/src/build.ts b/packages/scaffold/scripts/src/build.ts deleted file mode 100644 index 025ac4b2ab..0000000000 --- a/packages/scaffold/scripts/src/build.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * User-owned tsdown configuration wrappers and child-process invocation. - * - * @module @deepseek-ai/dsh-scripts/build - */ - -import { createRequire } from 'node:module' -import { existsSync, readFileSync, readdirSync } from 'node:fs' -import { dirname, resolve } from 'node:path' -import type { UserConfig } from 'tsdown' -import { NodeCommandRunner, type CommandRunner } from '@deepseek-ai/dsh-helper' - -function hasLocalPluginPackages(root: string): boolean { - const directory = resolve(root, 'plugins') - return existsSync(directory) && readdirSync(directory, { withFileTypes: true }).some( - item => item.isDirectory() && existsSync(resolve(directory, item.name, 'package.json')), - ) -} - -function hasTsdownConfig(root: string): boolean { - const hasConfigFile = [ - 'tsdown.config.ts', 'tsdown.config.mts', 'tsdown.config.cts', - 'tsdown.config.js', 'tsdown.config.mjs', 'tsdown.config.cjs', - 'tsdown.config.json', - ] - .some(name => existsSync(resolve(root, name))) - if (hasConfigFile) return true - let manifestText: string - try { - manifestText = readFileSync(resolve(root, 'package.json'), 'utf8') - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false - throw error - } - const manifest: unknown = JSON.parse(manifestText) - return manifest !== null && !Array.isArray(manifest) && typeof manifest === 'object' - && Object.hasOwn(manifest, 'tsdown') -} - -/** - * Preserve the developer's root config and append a separate workspace pass - * when generated local plugin packages exist. - * @param tsdownConfig - developer-owned root tsdown config. - * @returns root tsdown config and optional local-plugin workspace pass. - */ -export function ProjectBuild(tsdownConfig: UserConfig): UserConfig[] { - if (tsdownConfig.workspace !== undefined) { - throw new Error('ProjectBuild owns workspace discovery; remove config.workspace') - } - const root = resolve(tsdownConfig.cwd ?? process.cwd()) - return hasLocalPluginPackages(root) - ? [{ ...tsdownConfig }, { workspace: { include: ['plugins/*'] } }] - : [{ ...tsdownConfig }] -} - -/** - * Preserve a local plugin package's developer-owned tsdown config. - * @param tsdownConfig - developer-owned plugin tsdown config. - * @returns validated tsdown config copy. - */ -export function PluginBuild(tsdownConfig: UserConfig): UserConfig { - if (tsdownConfig.workspace !== undefined) throw new Error('PluginBuild does not accept nested workspace config') - return { ...tsdownConfig } -} - -function resolveTsdownBin(cwd: string): string { - const require = createRequire(resolve(cwd, 'package.json')) - let manifestPath: string - try { - manifestPath = require.resolve('tsdown/package.json') - } catch (error) { - throw new Error(`dsh-sdk build requires tsdown in this project: ${String(error)}`) - } - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { bin?: unknown } - const bin = typeof manifest.bin === 'string' - ? manifest.bin - : manifest.bin && typeof manifest.bin === 'object' - ? (manifest.bin as Record).tsdown - : undefined - if (typeof bin !== 'string') throw new Error('installed tsdown package has no executable') - return resolve(dirname(manifestPath), bin) -} - -/** Invoke the project's installed tsdown, forwarding all build arguments. */ -export async function runProjectBuild( - args: readonly string[], - cwd: string = process.cwd(), - runner: CommandRunner = new NodeCommandRunner(), -): Promise { - if (!hasTsdownConfig(cwd)) return - const result = await runner.run(process.execPath, [resolveTsdownBin(cwd), ...args], resolve(cwd)) - if (result.signal) throw new Error(`tsdown was killed by ${result.signal}`) - if (result.exitCode !== 0) throw new Error(`tsdown exited with code ${String(result.exitCode)}`) -} diff --git a/packages/scaffold/scripts/src/command.ts b/packages/scaffold/scripts/src/command.ts deleted file mode 100644 index ebf9b06846..0000000000 --- a/packages/scaffold/scripts/src/command.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Internal dsh-sdk command composition used by the package bin. - * - * @module @deepseek-ai/dsh-scripts/command - */ - -import { parseDshSdkArgs } from './args.ts' -import { runProjectBuild } from './build.ts' -import { runConfigCommand, type ConfigCommandContext } from './config.ts' -import { runCreatePluginCommand } from './create-plugin.ts' -import { runSDK } from './runtime.ts' -import { reportCommandTelemetry, type CommandTelemetryEvent } from './telemetry.ts' -import { DSH_SDK_TEMPLATES } from './templates/dsh-sdk-templates.ts' - -/** Injectable process and command boundaries used by the dsh-sdk bin. */ -export interface DshSdkCommandContext extends ConfigCommandContext { - cwd: string - stdin: NodeJS.ReadStream - stdout: NodeJS.WriteStream - stderr: NodeJS.WriteStream - run?: typeof runSDK - build?: typeof runProjectBuild - config?: typeof runConfigCommand - createPlugin?: typeof runCreatePluginCommand - telemetry?: (event: CommandTelemetryEvent) => Promise -} - -/** Run one parsed dsh-sdk command and return its process exit code. */ -export async function runDshSdkCommand( - argv: readonly string[] = process.argv.slice(2), - context: DshSdkCommandContext = { - cwd: process.cwd(), - stdin: process.stdin, - stdout: process.stdout, - stderr: process.stderr, - }, -): Promise { - const startedAt = Date.now() - let command: string | undefined - let success = true - try { - const args = parseDshSdkArgs(argv) - if (args.help || !args.command) { - context.stdout.write(DSH_SDK_TEMPLATES.usage.render({})) - return 0 - } - command = args.command - const run = context.run ?? runSDK - const build = context.build ?? runProjectBuild - const config = context.config ?? runConfigCommand - const createPlugin = context.createPlugin ?? runCreatePluginCommand - switch (args.command) { - case 'start': await run(args.target, { cwd: context.cwd, argv: args.forwarded }); break - case 'dev': await run(args.target, { cwd: context.cwd, dev: true, argv: args.forwarded }); break - case 'build': await build(args.forwarded, context.cwd); break - case 'config': { - const result = await config(context) - if (result.installError) { success = false; return 1 } - break - } - /* v8 ignore next -- Commander requires , so create never dispatches without it */ - case 'create': await createPlugin(args.source ?? '', context); break - } - return 0 - } catch (error) { - success = false - context.stderr.write(`dsh-sdk: ${error instanceof Error ? error.message : String(error)}\n`) - return 1 - } finally { - if (command !== undefined) { - /* v8 ignore next -- production telemetry wiring is exercised by the built-bin smoke */ - const telemetry = context.telemetry ?? reportCommandTelemetry - await telemetry({ command, cwd: context.cwd, durationMs: Date.now() - startedAt, success }) - } - } -} diff --git a/packages/scaffold/scripts/src/config.ts b/packages/scaffold/scripts/src/config.ts deleted file mode 100644 index 88d8cc035a..0000000000 --- a/packages/scaffold/scripts/src/config.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * dsh-sdk config command composition. - * - * @module @deepseek-ai/dsh-scripts/config - */ - -import { - ClackPromptPort, - SdkProject, - createBuiltinRegistry, - type PromptPort, -} from '@deepseek-ai/dsh-helper' -import { ConfigWorkflow, type ConfigWorkflowResult } from './config/config-workflow.ts' - -/** Process stream slice required by dsh-sdk config. */ -export interface ConfigCommandContext { - cwd: string - stdin: NodeJS.ReadStream - stdout: NodeJS.WriteStream - port?: PromptPort - install?: (project: SdkProject) => Promise -} - -/** Open and interactively edit one existing SDK project. */ -export async function runConfigCommand(context: ConfigCommandContext): Promise { - if (!context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) { - throw new Error('dsh-sdk config requires an interactive TTY') - } - const project = await SdkProject.open(context.cwd) - const registry = createBuiltinRegistry(project.profile) - return new ConfigWorkflow( - /* v8 ignore next -- production TTY wiring is exercised by the built-bin smoke */ - context.port ?? new ClackPromptPort(context.stdin, context.stdout), - context.stdout, - context.install, - ).run(project, registry) -} diff --git a/packages/scaffold/scripts/src/config/config-workflow.ts b/packages/scaffold/scripts/src/config/config-workflow.ts deleted file mode 100644 index 73bb07ea02..0000000000 --- a/packages/scaffold/scripts/src/config/config-workflow.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** - * Tree-shaped existing-project feature workflow and single Apply boundary. - * - * @module @deepseek-ai/dsh-scripts/config/config-workflow - */ - -import type { Writable } from 'node:stream' -import { - FeatureConfigurator, - ConfirmQuestion, - requireAnswer, - type Feature, - type FeatureInstallation, - type FeatureRegistry, - type FeatureSelection, - type ChangeSet, - type NestedMultiSelectValue, - type ProjectCommitResult, - type PromptPort, - type RunInterface, - type SdkProject, -} from '@deepseek-ai/dsh-helper' -import { DSH_SDK_TEMPLATES } from '../templates/dsh-sdk-templates.ts' - -/** Config result, including an install failure that happened after commit. */ -export interface ConfigWorkflowResult { - commit?: ProjectCommitResult - installError?: Error -} - -/** - * Non-interactive desired end-state for a config run: the complete set of enabled - * features, with options and any secrets/values a newly installed feature needs. - * Features not listed are reconciled to disabled, exactly as an interactive tree - * selection would be. Custom (non-feature) cordis plugins keep their current state; - * toggling them headlessly is not yet supported. - */ -export interface ConfigPlan { - features: readonly FeatureSelection[] -} - -function featureTarget(feature: Feature): string { - return `feature:${feature.id}` -} - -function pluginTarget(id: string): string { - return `plugin:${id}` -} - -function sameOptions(left: readonly string[], right: readonly string[]): boolean { - return [...left].sort().join('\0') === [...right].sort().join('\0') -} - -function targetRunInterface( - current: RunInterface, - desired: ReadonlyMap>, -): RunInterface { - const selected = desired.get('feature:app')?.choices[0] - return selected === 'acp' || selected === 'embed' ? selected : current -} - -/** Reconcile one tree selection into domain commands, then review and commit once. */ -export class ConfigWorkflow { - private readonly port: PromptPort - private readonly output: Writable - private readonly install: (project: SdkProject) => Promise - - /** Bind terminal prompts and descriptive output. */ - constructor( - port: PromptPort, - output: Writable = process.stdout, - install: (project: SdkProject) => Promise = project => project.profile.packageManager.install(project.root), - ) { - this.port = port - this.output = output - this.install = install - } - - /** Select desired state, reconcile the working copy, review, and apply. */ - async run(project: SdkProject, registry: FeatureRegistry, plan?: ConfigPlan): Promise { - const edit = project.edit(registry) - const configurator = new FeatureConfigurator(this.port) - const features = registry.all().filter(feature => feature.isApplicable(project.profile)) - const inspections = new Map(edit.inspections().map(item => [item.id, item])) - const custom = edit.cordisConfigEntries().filter(entry => !registry.ownerOfPackage(entry.name, project.profile)) - const desired = plan - ? [ - ...plan.features.map(selection => ({ - value: featureTarget(registry.get(selection.id)), - choices: selection.options, - })), - ...custom - .filter(entry => !entry.disabled) - .map(entry => ({ value: pluginTarget(entry.id), choices: [] as readonly string[] })), - ] - : requireAnswer(await this.port.nestedMultiselect({ - message: 'Configure the project', - showChanges: true, - options: [ - ...features.map((feature) => { - const installation = inspections.get(feature.id) - /* v8 ignore next -- inspections() is built from this exact feature registry */ - if (!installation) throw new Error(`feature inspection is missing: ${feature.id}`) - const inconsistent = installation.state === 'inconsistent' - const selectedOptions = new Set(installation.options.length > 0 - ? installation.options - : feature.defaultOptions(project.profile)) - return { - value: featureTarget(feature), - label: feature.summary, - required: feature.required, - default: feature.required || installation.state === 'enabled' || inconsistent, - disabled: inconsistent, - ...inconsistent ? { warning: installation.diagnostics.join('; ') } : {}, - ...feature.mode === 'single' ? {} : { - choiceMode: feature.mode, - choices: feature.options.map(option => ({ - value: option.id, - label: option.label, - default: selectedOptions.has(option.id), - })), - }, - } - }), - ...custom.map(entry => ({ - value: pluginTarget(entry.id), - label: `${entry.name} [custom]`, - default: !entry.disabled, - })), - ], - })) - const desiredByTarget = new Map(desired.map(item => [item.value, item])) - const targetProfile = { - ...project.profile, - runInterface: targetRunInterface(project.profile.runInterface, desiredByTarget), - } - for (const feature of features) { - /* v8 ignore next -- no current built-in feature is interface-specific */ - if (!feature.isApplicable(targetProfile)) desiredByTarget.delete(featureTarget(feature)) - } - - const plannedById = new Map( - (plan?.features ?? []).map(selection => [selection.id, selection]), - ) - for (const feature of features) { - const installation = inspections.get(feature.id) - /* v8 ignore next -- inspections() is built from this exact feature registry */ - if (!installation) throw new Error(`feature inspection is missing: ${feature.id}`) - if (installation.state === 'inconsistent') continue - const choice = desiredByTarget.get(featureTarget(feature)) - if (!choice && !feature.required) continue - await this.enableOrConfigure(feature, installation, choice, project, edit, configurator, plannedById.get(feature.id)) - } - - for (const feature of [...features].reverse()) { - const installation = inspections.get(feature.id) - /* v8 ignore next -- inspections() is built from this exact feature registry */ - if (!installation) throw new Error(`feature inspection is missing: ${feature.id}`) - if (feature.required || installation.state !== 'enabled' - || desiredByTarget.has(featureTarget(feature))) continue - edit.disableFeature(feature) - } - - for (const entry of custom) { - const enabled = desiredByTarget.has(pluginTarget(entry.id)) - if (enabled === !entry.disabled) continue - edit.setCustomPluginDisabled(entry.id, !enabled) - } - - const changes = edit.changes() - if (changes.changedFiles.length === 0) { - this.output.write('No changes.\n') - return {} - } - this.renderReview(changes) - const apply = requireAnswer(await new ConfirmQuestion({ - id: 'config.apply', message: 'Apply these changes?', initialValue: true, - }).resolve(this.port)) - if (!apply) return {} - const commit = await edit.commit() - if (!commit.changes.npmDependenciesChanged) return { commit } - try { - await this.install(project) - return { commit } - } catch (error) { - const installError = error instanceof Error ? error : new Error(String(error)) - const manager = project.profile.packageManager - this.output.write(DSH_SDK_TEMPLATES.configInstallFailure.render({ - error: installError.message, - packageManager: manager.name, - installArgs: manager.installCommand().join(' '), - })) - return { commit, installError } - } - } - - private async enableOrConfigure( - feature: Feature, - installation: FeatureInstallation, - choice: NestedMultiSelectValue | undefined, - project: SdkProject, - edit: ReturnType, - configurator: FeatureConfigurator, - planned?: FeatureSelection, - ): Promise { - const options = choice?.choices.length - ? choice.choices - : installation.options.length > 0 - ? installation.options - : feature.defaultOptions(project.profile) - if (installation.state === 'absent') { - const selection = await configurator.configure( - feature, project.profile, undefined, options, planned?.secrets ?? {}, planned?.values ?? {}, - ) - edit.installFeature(feature, selection) - return - } - /* v8 ignore next -- non-absent/non-inconsistent inspections always carry their normalized selection */ - if (!installation.selection) throw new Error(`feature ${feature.id} has no readable selection`) - if (!sameOptions(installation.options, options)) { - const selection: FeatureSelection = await configurator.configure( - feature, project.profile, installation.selection, options, planned?.secrets ?? {}, planned?.values ?? {}, - ) - edit.configureFeature(feature, selection) - } - if (installation.state === 'disabled') edit.enableFeature(feature) - } - - private renderReview(changes: ChangeSet): void { - const lines = [ - ...changes.addedFeatures.map(id => `Install feature: ${id}`), - ...changes.enabledFeatures.map(id => `Enable feature: ${id}`), - ...changes.disabledFeatures.map(id => `Disable feature: ${id}`), - ...changes.configuredFeatures.map(id => `Configure feature: ${id}`), - ...changes.enabledPlugins.map(id => `Enable custom plugin: ${id}`), - ...changes.disabledPlugins.map(id => `Disable custom plugin: ${id}`), - ...changes.changedFiles.map(path => `Change file: ${path}`), - ] - this.output.write(`${lines.join('\n')}\n`) - } -} diff --git a/packages/scaffold/scripts/src/create-plugin.ts b/packages/scaffold/scripts/src/create-plugin.ts deleted file mode 100644 index f658451fde..0000000000 --- a/packages/scaffold/scripts/src/create-plugin.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * dsh-sdk create command: add an external Cordis plugin (github or npm) as a - * native package-manager dependency and mount it in cordis.yml. - * - * @module @deepseek-ai/dsh-scripts/create-plugin - */ - -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' -import { - ClackPromptPort, - ConfirmQuestion, - SdkProject, - createBuiltinRegistry, - requireAnswer, - type PackageManager, - type ProjectCommitResult, - type PromptPort, -} from '@deepseek-ai/dsh-helper' - -/** Process and interaction slice required by dsh-sdk create. */ -export interface CreatePluginContext { - cwd: string - stdin: NodeJS.ReadStream - stdout: NodeJS.WriteStream - port?: PromptPort - add?: (manager: PackageManager, spec: string, cwd: string) => Promise -} - -/** Result of a create run; `undefined` when the confirmation was declined. */ -export type CreatePluginResult = ProjectCommitResult | undefined - -/** Derive a stable cordis entry id from a package name's last path segment. */ -function pluginId(packageName: string): string { - const base = packageName.startsWith('@') ? packageName.slice(packageName.indexOf('/') + 1) : packageName - const id = base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') - /* v8 ignore next -- a valid npm package name always yields a non-empty id */ - if (!id) throw new Error(`cannot derive a plugin id from package name: ${packageName}`) - return id -} - -/** Read the direct dependency names declared in a project's package.json. */ -async function dependencyNames(cwd: string): Promise> { - const manifest = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8')) as { - dependencies?: Record - } - /* v8 ignore next -- generated projects always declare a dependencies map */ - return new Set(Object.keys(manifest.dependencies ?? {})) -} - -/** - * Add one external plugin dependency to the current project and mount it. - * @param source - a package-manager-native source (`pkg@version` or `github:owner/repo#ref`). - * @param context - process, interaction, and dependency-add boundaries. - * @returns the commit result, or `undefined` when the confirmation was declined. - */ -export async function runCreatePluginCommand( - source: string, - context: CreatePluginContext, -): Promise { - const spec = source.trim() - if (!spec) throw new Error('dsh-sdk create requires a plugin source (pkg@version or github:owner/repo#ref)') - if (!context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) { - throw new Error('dsh-sdk create requires an interactive TTY') - } - const project = await SdkProject.open(context.cwd) - /* v8 ignore next -- production TTY wiring is exercised by the built-bin smoke */ - const port = context.port ?? new ClackPromptPort(context.stdin, context.stdout) - const confirmed = requireAnswer(await new ConfirmQuestion({ - id: 'create.confirm', - message: `Add plugin '${spec}' as a dependency and mount it in cordis.yml?`, - initialValue: true, - }).resolve(port)) - if (!confirmed) return undefined - - const before = await dependencyNames(context.cwd) - /* v8 ignore next -- production package-manager wiring is exercised by the built-bin smoke */ - const add = context.add ?? ((manager, source, cwd) => manager.add(source, cwd)) - await add(project.profile.packageManager, spec, context.cwd) - const after = await dependencyNames(context.cwd) - const added = [...after].filter(name => !before.has(name)) - if (added.length === 0) throw new Error(`dsh-sdk create: '${spec}' added no new dependency`) - - const reopened = await SdkProject.open(context.cwd) - const registry = createBuiltinRegistry(reopened.profile) - const edit = reopened.edit(registry) - for (const packageName of added) edit.addExternalPlugin(pluginId(packageName), packageName) - const commit = await edit.commit() - context.stdout.write(`Mounted ${added.join(', ')} in cordis.yml.\n`) - return commit -} diff --git a/packages/scaffold/scripts/src/dev/tsdown-config.ts b/packages/scaffold/scripts/src/dev/tsdown-config.ts deleted file mode 100644 index 15e5603cfa..0000000000 --- a/packages/scaffold/scripts/src/dev/tsdown-config.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Generated-project tsdown config wrappers. - * - * @module @deepseek-ai/dsh-scripts/dev/tsdown-config - */ - -export { PluginBuild, ProjectBuild } from '../build.ts' diff --git a/packages/scaffold/scripts/src/index.ts b/packages/scaffold/scripts/src/index.ts deleted file mode 100644 index 0db62945f9..0000000000 --- a/packages/scaffold/scripts/src/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Public DeepSeek Harness SDK runtime entry points. - * - * FIXME: rename to `@deepseek-ai/dsh-sdk-scripts` before the first tagged release — - * the current name is indefensibly generic as a published name - * ([regrouping Agent Note](../../../../.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md)). - * - * @module @deepseek-ai/dsh-scripts - */ - -export { runSDK, startSDK, type SdkBootContext } from './runtime.ts' diff --git a/packages/scaffold/scripts/src/invariant.ts b/packages/scaffold/scripts/src/invariant.ts deleted file mode 100644 index b43a6d1cf3..0000000000 --- a/packages/scaffold/scripts/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-scripts`. - * @module @deepseek-ai/dsh-scripts/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-scripts' - -/** Cordis companion plugin name. */ -export const name = 'scripts-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this SDK build-time package owns no live event stream or mutable data; - * generated output and consumer tests cover its contract. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/scaffold/scripts/src/local-plugin-loader-hooks.ts b/packages/scaffold/scripts/src/local-plugin-loader-hooks.ts deleted file mode 100644 index b57a76da08..0000000000 --- a/packages/scaffold/scripts/src/local-plugin-loader-hooks.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Node module customization hook for project-local plugin package names. - * - * @module @deepseek-ai/dsh-scripts/local-plugin-loader-hooks - */ - -import type { ResolveHookContext, ResolveFnOutput } from 'node:module' - -interface HookData { - mappings: Readonly> -} - -let mappings: Readonly> = {} - -/** Receive the package-name to source-URL map from the launcher thread. */ -export function initialize(data: HookData): void { - mappings = { ...data.mappings } -} - -/** Resolve exact local workspace package names to their TypeScript entry source. */ -export async function resolve( - specifier: string, - context: ResolveHookContext, - nextResolve: (specifier: string, context: ResolveHookContext) => Promise, -): Promise { - return nextResolve(mappings[specifier] ?? specifier, context) -} diff --git a/packages/scaffold/scripts/src/runtime.ts b/packages/scaffold/scripts/src/runtime.ts deleted file mode 100644 index b0d4c8c5ad..0000000000 --- a/packages/scaffold/scripts/src/runtime.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Shared start/dev runtime and project-local module resolution. - * - * @module @deepseek-ai/dsh-scripts/runtime - */ - -import { register as registerHook } from 'node:module' -import { access, readFile, readdir } from 'node:fs/promises' -import { dirname, resolve } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' -import type { Context } from '@deepseek-ai/cordis' -import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' -import { parseSdkBootArgs } from './args.ts' - -/** Options that distinguish dev boot from production boot. */ -interface BootProjectOptions { - cwd?: string - dev?: boolean - argv?: readonly string[] -} - -/** Startup context passed to a generated project's exported `main()`. */ -export interface SdkBootContext { - /** Developer arguments forwarded after the launcher's `--` separator. */ - readonly argv: readonly string[] - /** SDK-recognized structured arguments parsed from {@link argv}. */ - readonly args: Record - /** Absolute project working directory selected by the launcher. */ - readonly cwd: string - /** Whether the launcher is running the built or TypeScript development entry. */ - readonly mode: 'start' | 'dev' -} - -async function localPluginMappings(cwd: string): Promise> { - const mappings: Record = {} - let directories - try { - directories = await readdir(resolve(cwd, 'plugins'), { withFileTypes: true }) - } catch (error) { - /* v8 ignore else -- the other arm requires a filesystem permission/IO fault from readdir */ - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return mappings - /* v8 ignore next -- paired with the ignored defensive readdir-error arm above */ - throw error - } - for (const directory of directories) { - if (!directory.isDirectory()) continue - const root = resolve(cwd, 'plugins', directory.name) - let manifest: { name?: unknown } - try { - manifest = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8')) as { name?: unknown } - await access(resolve(root, 'src/index.ts')) - } catch (error) { - throw new Error(`cannot load local plugin metadata from ${root}: ${String(error)}`) - } - if (typeof manifest.name !== 'string' || manifest.name.length === 0) { - throw new Error(`local plugin package has no name: ${root}`) - } - if (mappings[manifest.name]) throw new Error(`duplicate local plugin package name: ${manifest.name}`) - mappings[manifest.name] = pathToFileURL(resolve(root, 'src/index.ts')).href - } - return mappings -} - -/** Register tsx and exact local-plugin source mappings for the current process. */ -async function registerDevRuntime(cwd: string = process.cwd()): Promise { - let registerTsx: typeof import('tsx/esm/api')['register'] - try { - ({ register: registerTsx } = await import('tsx/esm/api')) - } catch (error) { - /* v8 ignore next -- tsx is a declared project NPM dependency; missing-package behavior is defensive */ - throw new Error(`dsh-sdk dev requires the project's tsx NPM dependency: ${String(error)}`) - } - registerTsx() - const mappings = await localPluginMappings(resolve(cwd)) - const hook = new URL( - /* v8 ignore next -- the .js arm is exercised by the built-bin smoke rather than source coverage */ - import.meta.url.endsWith('.ts') - ? './local-plugin-loader-hooks.ts' - : './local-plugin-loader-hooks.js', import.meta.url) - registerHook(hook, { data: { mappings } }) -} - -/** - * Boot one cordis.yml after loading its sibling .env. - * @param source - file path or file URL to cordis.yml. - * @param options - working directory and development-runtime options. - * @returns live Cordis context. - */ -export async function startSDK( - source: string | URL = './cordis.yml', - options: BootProjectOptions = {}, -): Promise { - const cwd = resolve(options.cwd ?? process.cwd()) - if (options.dev) await registerDevRuntime(cwd) - if (source instanceof URL && source.protocol !== 'file:') { - throw new Error(`cordis.yml URL must use file:, got ${source.protocol}`) - } - const requested = source instanceof URL ? fileURLToPath(source) : source - const absolute = resolveConfigPath(requested, undefined, cwd) - loadEnv('dsh-sdk', dirname(absolute)) - installFailLoud('dsh-sdk') - return boot('dsh-sdk', absolute) -} - -/** - * Import and invoke a module target's main(), or directly boot cordis.yml. - * @param target - module path relative to the project, or absent for cordis.yml. - * @param options - working directory and development-runtime options. - * @returns target main result or live Cordis context. - */ -export async function runSDK( - target?: string, - options: BootProjectOptions = {}, -): Promise { - /* v8 ignore next -- the bin always supplies cwd; direct consumers normally accept process.cwd() */ - const cwd = resolve(options.cwd ?? process.cwd()) - if (options.dev) await registerDevRuntime(cwd) - if (!target) return startSDK('./cordis.yml', { cwd }) - const absolute = resolve(cwd, target) - try { - await access(absolute) - } catch (error) { - const hint = options.dev ? '' : ' Run dsh-sdk build first if this is a TypeScript project.' - throw new Error(`cannot start missing target ${target}.${hint} ${String(error)}`) - } - const module = await import(pathToFileURL(absolute).href) as { main?: (context: SdkBootContext) => unknown } - if (typeof module.main !== 'function') { - throw new Error(`dsh-sdk target ${target} must export function main()`) - } - const argv = [...options.argv ?? []] - return module.main({ - argv, - args: parseSdkBootArgs(argv), - cwd, - mode: options.dev ? 'dev' : 'start', - }) -} diff --git a/packages/scaffold/scripts/src/telemetry.ts b/packages/scaffold/scripts/src/telemetry.ts deleted file mode 100644 index d739a7164b..0000000000 --- a/packages/scaffold/scripts/src/telemetry.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Launcher-side telemetry wiring: resolve consent and send one fire-and-forget - * event around each dsh-sdk command. Best-effort — never affects the command's - * outcome or exit code. - * - * @module @deepseek-ai/dsh-scripts/telemetry - */ - -import { - ConsentResolver, - TelemetryReporter, - buildTelemetryPayload, - type ConsentDecision, -} from '@deepseek-ai/dsh-telemetry' - -/** One command's telemetry lifecycle facts. */ -export interface CommandTelemetryEvent { - /** The dsh-sdk command that ran. */ - command: string - /** Project directory whose consent, `cordis.yml`, and `package.json` are read. */ - cwd: string - /** Wall-clock duration in milliseconds. */ - durationMs: number - /** Whether the command completed without error. */ - success: boolean -} - -/** Injectable consent and delivery hooks for tests. */ -export interface CommandTelemetryDeps { - resolve?: (cwd: string) => Promise - reporter?: Pick -} - -/** - * Resolve consent for the project and, when allowed, assemble and send one - * telemetry event, draining in-flight sends before returning. Swallows every - * error so telemetry can never change a command's result. - * @param event - the command lifecycle facts. - * @param deps - Consent and delivery hooks; defaults hit the real endpoint. - */ -export async function reportCommandTelemetry( - event: CommandTelemetryEvent, - deps: CommandTelemetryDeps = {}, -): Promise { - try { - /* v8 ignore next -- the production ConsentResolver is exercised by the built-bin smoke */ - const resolve = deps.resolve ?? (cwd => new ConsentResolver().resolve(cwd)) - const consent = await resolve(event.cwd) - if (!consent.allowed) return - const payload = await buildTelemetryPayload({ - command: event.command, - durationMs: event.durationMs, - success: event.success, - projectDir: event.cwd, - }) - /* v8 ignore next -- the production TelemetryReporter is exercised by the built-bin smoke */ - const reporter = deps.reporter ?? new TelemetryReporter() - reporter.report(payload, consent) - await reporter.flush() - } catch { - // Telemetry is best-effort; a consent, payload, or delivery fault never reaches the command. - } -} diff --git a/packages/scaffold/scripts/src/templates/assets/config-install-failure.txt.tpl b/packages/scaffold/scripts/src/templates/assets/config-install-failure.txt.tpl deleted file mode 100644 index ee1da80f76..0000000000 --- a/packages/scaffold/scripts/src/templates/assets/config-install-failure.txt.tpl +++ /dev/null @@ -1,2 +0,0 @@ -Changes were committed, but install failed: {{error}} -Retry: {{packageManager}} {{installArgs}} diff --git a/packages/scaffold/scripts/src/templates/assets/usage.txt.tpl b/packages/scaffold/scripts/src/templates/assets/usage.txt.tpl deleted file mode 100644 index b372c65d17..0000000000 --- a/packages/scaffold/scripts/src/templates/assets/usage.txt.tpl +++ /dev/null @@ -1,8 +0,0 @@ -Usage: dsh-sdk [options] - -Commands: - start [target] [-- args...] Import a built module, or boot cordis.yml - dev [target] [-- args...] Start with TypeScript and local-plugin source resolution - build [args...] Run the project's installed tsdown - config Interactively edit project features - create Add an external plugin dependency (pkg@version or github:owner/repo#ref) and mount it in cordis.yml diff --git a/packages/scaffold/scripts/src/templates/dsh-sdk-templates.ts b/packages/scaffold/scripts/src/templates/dsh-sdk-templates.ts deleted file mode 100644 index a7dabc3b85..0000000000 --- a/packages/scaffold/scripts/src/templates/dsh-sdk-templates.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Package-owned terminal templates for the dsh-sdk launcher. - * - * @module @deepseek-ai/dsh-scripts/templates/dsh-sdk-templates - */ - -import { TextTemplate, type PackageManagerName } from '@deepseek-ai/dsh-helper' - -interface ConfigInstallFailureTemplateModel { - error: string - packageManager: PackageManagerName - installArgs: string -} - -/** Compiled dsh-sdk terminal templates. */ -export const DSH_SDK_TEMPLATES = { - usage: TextTemplate.fromFile>(new URL('./assets/usage.txt.tpl', import.meta.url)), - configInstallFailure: TextTemplate.fromFile( - new URL('./assets/config-install-failure.txt.tpl', import.meta.url), - ), -} as const diff --git a/packages/scaffold/scripts/tests/__snapshots__/config.snapshot.ts.snap b/packages/scaffold/scripts/tests/__snapshots__/config.snapshot.ts.snap deleted file mode 100644 index 6ba55fd1a9..0000000000 --- a/packages/scaffold/scripts/tests/__snapshots__/config.snapshot.ts.snap +++ /dev/null @@ -1,288 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`dsh-sdk config terminal contract > pins the feature tree and Review & Apply output 1`] = ` -{ - "committed": { - "addedFeatures": [ - "todo", - ], - "addedPlugins": [], - "changedFiles": [ - "cordis.yml", - "package.json", - ], - "configuredFeatures": [], - "disabledFeatures": [], - "disabledPlugins": [], - "enabledFeatures": [], - "enabledPlugins": [], - "npmDependenciesChanged": true, - }, - "installs": 1, - "review": "Install feature: todo -Change file: cordis.yml -Change file: package.json -", - "transcript": [ - { - "kind": "nested-multiselect", - "message": "Configure the project", - "options": [ - { - "choiceMode": "exclusive", - "choices": [ - { - "default": true, - "label": "DeepSeek", - "value": "deepseek-official", - }, - { - "default": false, - "label": "Custom endpoint (pi-ai)", - "value": "custom", - }, - ], - "default": true, - "disabled": false, - "label": "Model provider", - "required": true, - "value": "feature:provider", - "warning": undefined, - }, - { - "choiceMode": undefined, - "choices": undefined, - "default": true, - "disabled": false, - "label": "Agent runtime spine", - "required": true, - "value": "feature:spine", - "warning": undefined, - }, - { - "choiceMode": "exclusive", - "choices": [ - { - "default": true, - "label": "Local executor", - "value": "local", - }, - { - "default": false, - "label": "Sandboxed executor", - "value": "sandbox", - }, - ], - "default": true, - "disabled": false, - "label": "Command execution", - "required": true, - "value": "feature:bash", - "warning": undefined, - }, - { - "choiceMode": "exclusive", - "choices": [ - { - "default": true, - "label": "ACP automation server", - "value": "acp", - }, - { - "default": false, - "label": "Embedded context", - "value": "embed", - }, - ], - "default": true, - "disabled": false, - "label": "Run interface", - "required": true, - "value": "feature:app", - "warning": undefined, - }, - { - "choiceMode": "exclusive", - "choices": [ - { - "default": true, - "label": "JSONL files", - "value": "jsonl", - }, - { - "default": false, - "label": "SQLite database", - "value": "sqlite", - }, - ], - "default": true, - "disabled": false, - "label": "Durable session storage", - "required": true, - "value": "feature:persistence", - "warning": undefined, - }, - { - "choiceMode": undefined, - "choices": undefined, - "default": false, - "disabled": false, - "label": "Hot-module reload", - "required": false, - "value": "feature:hmr", - "warning": undefined, - }, - { - "choiceMode": undefined, - "choices": undefined, - "default": false, - "disabled": false, - "label": "Read, write, and edit local files", - "required": false, - "value": "feature:fs", - "warning": undefined, - }, - { - "choiceMode": undefined, - "choices": undefined, - "default": false, - "disabled": false, - "label": "Model-facing task tracking", - "required": false, - "value": "feature:todo", - "warning": undefined, - }, - { - "choiceMode": undefined, - "choices": undefined, - "default": false, - "disabled": false, - "label": "Local skill discovery", - "required": false, - "value": "feature:skill", - "warning": undefined, - }, - { - "choiceMode": "exclusive", - "choices": [ - { - "default": true, - "label": "DeepSeek search", - "value": "deepseek-official", - }, - { - "default": false, - "label": "Exa search", - "value": "exa", - }, - { - "default": false, - "label": "Perplexity search", - "value": "perplexity", - }, - { - "default": false, - "label": "Fetch only", - "value": "fetch-only", - }, - ], - "default": false, - "disabled": false, - "label": "Web search and fetch tools", - "required": false, - "value": "feature:web", - "warning": undefined, - }, - { - "choiceMode": "multiple", - "choices": [ - { - "default": true, - "label": "Fresh child agent", - "value": "spawn", - }, - { - "default": false, - "label": "Fork parent history", - "value": "fork", - }, - ], - "default": false, - "disabled": false, - "label": "Delegate work to child agents", - "required": false, - "value": "feature:subagent", - "warning": undefined, - }, - { - "choiceMode": undefined, - "choices": undefined, - "default": false, - "disabled": false, - "label": "Scripted multi-agent workflows", - "required": false, - "value": "feature:workflow", - "warning": undefined, - }, - { - "choiceMode": undefined, - "choices": undefined, - "default": false, - "disabled": false, - "label": "Automatic context compaction", - "required": false, - "value": "feature:compact", - "warning": undefined, - }, - { - "choiceMode": "multiple", - "choices": [ - { - "default": true, - "label": "Claude Code hooks", - "value": "claude", - }, - { - "default": false, - "label": "Codex hooks", - "value": "codex", - }, - ], - "default": false, - "disabled": false, - "label": "Run Claude Code or Codex hooks", - "required": false, - "value": "feature:hooks", - "warning": undefined, - }, - { - "choiceMode": undefined, - "choices": undefined, - "default": false, - "disabled": false, - "label": "Loop-hygiene reminders", - "required": false, - "value": "feature:guard", - "warning": undefined, - }, - { - "choiceMode": undefined, - "choices": undefined, - "default": false, - "disabled": false, - "label": "Tool timeout policy", - "required": false, - "value": "feature:timeout-policy", - "warning": undefined, - }, - ], - "showChanges": true, - }, - { - "initialValue": true, - "kind": "confirm", - "message": "Apply these changes?", - }, - ], -} -`; diff --git a/packages/scaffold/scripts/tests/config.snapshot.ts b/packages/scaffold/scripts/tests/config.snapshot.ts deleted file mode 100644 index 10f1a6ab28..0000000000 --- a/packages/scaffold/scripts/tests/config.snapshot.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Writable } from 'node:stream' -import { afterEach, describe, expect, it } from 'vitest' -import { - NpmPackageManager, - SdkProject, - featureId, - createBuiltinRegistry, - type NestedMultiSelectValue, - type PromptPort, -} from '@deepseek-ai/dsh-helper' -import type { - ConfirmPromptRequest, - MultiSelectPromptRequest, - NestedMultiSelectRequest, - PromptOutcome, - SecretPromptRequest, - SelectPromptRequest, - TextPromptRequest, -} from '../../helper/src/questions/prompt-port.ts' -import { ConfigWorkflow } from '../src/config/config-workflow.ts' - -class RecordingPort implements PromptPort { - readonly transcript: unknown[] = [] - readonly #answers: unknown[] - - constructor(answers: unknown[]) { this.#answers = [...answers] } - - answer(record: unknown): Promise> { - this.transcript.push(record) - return Promise.resolve({ status: 'answered', value: this.#answers.shift() as T }) - } - - text(request: TextPromptRequest): Promise> { - return this.answer({ kind: 'text', message: request.message }) - } - secret(request: SecretPromptRequest): Promise> { - return this.answer({ kind: 'secret', message: request.message }) - } - select(request: SelectPromptRequest): Promise> { - return this.answer({ - kind: 'select', message: request.message, - options: request.options.map(option => ({ value: option.value, label: option.label })), - }) - } - multiselect(request: MultiSelectPromptRequest): Promise> { - return this.answer({ kind: 'multiselect', message: request.message }) - } - confirm(request: ConfirmPromptRequest): Promise> { - return this.answer({ kind: 'confirm', message: request.message, initialValue: request.initialValue }) - } - nestedMultiselect( - request: NestedMultiSelectRequest, - ): Promise[]>> { - return this.answer({ - kind: 'nested-multiselect', - message: request.message, - showChanges: request.showChanges, - options: request.options.map(option => ({ - value: option.value, - label: option.label, - required: option.required, - default: option.default, - disabled: option.disabled, - warning: option.warning, - choiceMode: option.choiceMode, - choices: option.choices?.map(choice => ({ - value: choice.value, - label: choice.label, - default: choice.default, - })), - })), - }) - } -} - -const temporary: string[] = [] - -afterEach(async () => { - await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true }))) -}) - -async function baseProject(): Promise { - const root = await mkdtemp(join(tmpdir(), 'dsh-config-snapshot-')) - temporary.push(root) - const request = { - name: 'snapshot-agent', - description: 'snapshot', - runtime: { model: 'deepseek-v4-flash' }, - packageManager: new NpmPackageManager('10.0.0'), - releaseVersion: '0.0.1', - features: [ - { id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'key' } }, - { id: featureId('bash'), options: ['local'] }, - { id: featureId('app'), options: ['acp'] }, - { id: featureId('persistence'), options: ['jsonl'] }, - ], - localPlugins: [], - } - const project = SdkProject.create(root, request) - const registry = createBuiltinRegistry(project.profile) - const edit = project.edit(registry) - for (const item of request.features) edit.installFeature(registry.get(item.id), item) - return (await edit.commit()).project -} - -describe('dsh-sdk config terminal contract', () => { - it('pins the feature tree and Review & Apply output', async () => { - const project = await baseProject() - const registry = createBuiltinRegistry(project.profile) - const port = new RecordingPort([ - [{ value: 'feature:todo', choices: [] }], - true, - ]) - let output = '' - const stream = new Writable({ write(chunk, _encoding, callback) { output += String(chunk); callback() } }) - let installs = 0 - const result = await new ConfigWorkflow(port, stream, async () => { installs += 1 }).run(project, registry) - expect({ - transcript: port.transcript, - review: output, - installs, - committed: result.commit?.changes, - }).toMatchSnapshot() - }) -}) diff --git a/packages/scaffold/scripts/tests/scripts.spec.ts b/packages/scaffold/scripts/tests/scripts.spec.ts deleted file mode 100644 index a1a4a07517..0000000000 --- a/packages/scaffold/scripts/tests/scripts.spec.ts +++ /dev/null @@ -1,650 +0,0 @@ -import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { PassThrough, Writable } from 'node:stream' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' -import { - HeadlessPromptPort, - LocalPluginBlueprint, - NpmPackageManager, - SdkProject, - featureId, - createBuiltinRegistry, - type CommandRunner, - type NestedMultiSelectValue, - type ProjectCreationRequest, - type PromptPort, -} from '@deepseek-ai/dsh-helper' -import type { - ConfirmPromptRequest, - MultiSelectPromptRequest, - NestedMultiSelectRequest, - PromptOutcome, - SecretPromptRequest, - SelectPromptRequest, - TextPromptRequest, -} from '../../helper/src/questions/prompt-port.ts' -import { runSDK, startSDK } from '@deepseek-ai/dsh-scripts' -import { parseDshSdkArgs, parseSdkBootArgs } from '../src/args.ts' -import { PluginBuild, ProjectBuild, runProjectBuild } from '../src/build.ts' -import { runDshSdkCommand, type DshSdkCommandContext } from '../src/command.ts' -import { runConfigCommand } from '../src/config.ts' -import { ConfigWorkflow, type ConfigPlan } from '../src/config/config-workflow.ts' -import { runCreatePluginCommand } from '../src/create-plugin.ts' -import { reportCommandTelemetry, type CommandTelemetryEvent } from '../src/telemetry.ts' -import { initialize, resolve as resolveLocalPlugin } from '../src/local-plugin-loader-hooks.ts' - -const temporary: string[] = [] - -afterEach(async () => { - await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true }))) -}) - -class QueuePort implements PromptPort { - readonly #answers: unknown[] - constructor(answers: unknown[]) { this.#answers = [...answers] } - next(): Promise> { - return Promise.resolve({ status: 'answered', value: this.#answers.shift() as T }) - } - text(_request: TextPromptRequest): Promise> { return this.next() } - secret(_request: SecretPromptRequest): Promise> { return this.next() } - select(_request: SelectPromptRequest): Promise> { return this.next() } - multiselect(_request: MultiSelectPromptRequest): Promise> { return this.next() } - confirm(_request: ConfirmPromptRequest): Promise> { return this.next() } - nestedMultiselect( - _request: NestedMultiSelectRequest, - ): Promise[]>> { return this.next() } -} - -function outputBuffer(): { stream: Writable; read: () => string } { - let text = '' - return { - stream: new Writable({ write(chunk, _encoding, callback) { text += String(chunk); callback() } }), - read: () => text, - } -} - -function commandContext(cwd: string): DshSdkCommandContext & { readStdout: () => string; readStderr: () => string } { - let stdout = '' - let stderr = '' - const stdin = Object.assign(new PassThrough(), { isTTY: true }) as unknown as NodeJS.ReadStream - const output = Object.assign(new Writable({ - write(chunk, _encoding, callback) { stdout += String(chunk); callback() }, - }), { isTTY: true }) as unknown as NodeJS.WriteStream - const error = new Writable({ - write(chunk, _encoding, callback) { stderr += String(chunk); callback() }, - }) as unknown as NodeJS.WriteStream - return { - cwd, stdin, stdout: output, stderr: error, - readStdout: () => stdout, - readStderr: () => stderr, - } -} - -function creation( - extra: ProjectCreationRequest['features'] = [], - localPlugins: readonly LocalPluginBlueprint[] = [], - app: 'acp' | 'embed' = 'embed', -): ProjectCreationRequest { - return { - name: 'config-agent', - description: 'config test', - runtime: { model: 'deepseek-v4-flash' }, - packageManager: new NpmPackageManager('10.0.0'), - releaseVersion: '0.0.1', - features: [ - { id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'key' } }, - { id: featureId('bash'), options: ['local'] }, - { id: featureId('app'), options: [app] }, - { id: featureId('persistence'), options: ['jsonl'] }, - ...extra, - ], - localPlugins, - } -} - -async function committedProject( - extra: ProjectCreationRequest['features'] = [], - localPlugins: readonly LocalPluginBlueprint[] = [], - app: 'acp' | 'embed' = 'embed', -): Promise { - const root = await mkdtemp(join(tmpdir(), 'dsh-config-workflow-')) - temporary.push(root) - const request = creation(extra, localPlugins, app) - const project = SdkProject.create(root, request) - const registry = createBuiltinRegistry(project.profile) - const edit = project.edit(registry) - for (const item of request.features) edit.installFeature(registry.get(item.id), item) - for (const plugin of localPlugins) edit.addPlugin(plugin) - return (await edit.commit()).project -} - -describe('Commander launcher arguments', () => { - it('parses real subcommands and forwards arbitrary build options', () => { - expect(parseDshSdkArgs([])).toMatchObject({ help: true }) - expect(parseDshSdkArgs(['start', 'index.js'])).toMatchObject({ command: 'start', target: 'index.js' }) - expect(parseDshSdkArgs(['dev'])).toEqual({ command: 'dev', forwarded: [], help: false }) - expect(parseDshSdkArgs(['build', '--watch', '--minify'])).toMatchObject({ - command: 'build', forwarded: ['--watch', '--minify'], - }) - expect(parseDshSdkArgs(['start', 'index.js', '--', '--resume', 'session-1'])).toMatchObject({ - command: 'start', target: 'index.js', forwarded: ['--resume', 'session-1'], - }) - expect(parseDshSdkArgs(['config'])).toMatchObject({ command: 'config' }) - expect(parseDshSdkArgs(['start'])).toEqual({ command: 'start', forwarded: [], help: false }) - expect(parseDshSdkArgs(['dev', 'index.ts'])).toMatchObject({ command: 'dev', target: 'index.ts' }) - expect(parseDshSdkArgs(['-h'])).toMatchObject({ help: true }) - expect(parseDshSdkArgs(['--help'])).toMatchObject({ help: true }) - expect(() => parseDshSdkArgs(['unknown'])).toThrow() - expect(() => parseDshSdkArgs(['config', 'extra'])).toThrow() - expect(() => parseDshSdkArgs(['config', '--', 'extra'])).toThrow('does not accept forwarded') - expect(parseSdkBootArgs([ - '--model=mock', '--resume=session-1', '--custom=value', '--verbose', '--no-cache', '--max-depth=-1', - ])).toEqual({ - model: 'mock', resume: 'session-1', custom: 'value', verbose: true, cache: false, 'max-depth': '-1', - }) - }) - - it('dispatches every command and maps failures to exit codes', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-command-')) - temporary.push(root) - const context = commandContext(root) - const calls: unknown[] = [] - context.run = async (target, options) => { calls.push(['run', target, options]); return undefined } - context.build = async (args, cwd) => { calls.push(['build', args, cwd]) } - context.config = async () => { calls.push(['config']); return {} } - await expect(runDshSdkCommand(['start', 'index.js', '--', '--resume', 'session-1'], context)).resolves.toBe(0) - await expect(runDshSdkCommand(['dev', 'index.ts'], context)).resolves.toBe(0) - await expect(runDshSdkCommand(['build', '--watch'], context)).resolves.toBe(0) - await expect(runDshSdkCommand(['config'], context)).resolves.toBe(0) - expect(calls).toHaveLength(4) - expect(calls[0]).toEqual(['run', 'index.js', { cwd: root, argv: ['--resume', 'session-1'] }]) - expect(calls[1]).toEqual(['run', 'index.ts', { cwd: root, dev: true, argv: [] }]) - context.config = async () => ({ installError: new Error('offline') }) - await expect(runDshSdkCommand(['config'], context)).resolves.toBe(1) - context.config = async () => { throw 'broken' } - await expect(runDshSdkCommand(['config'], context)).resolves.toBe(1) - expect(context.readStderr()).toContain('broken') - await expect(runDshSdkCommand(['unknown'], context)).resolves.toBe(1) - await expect(runDshSdkCommand([], context)).resolves.toBe(0) - expect(context.readStdout()).toContain('Usage: dsh-sdk') - expect(context.readStdout()).toContain('create ') - - const defaults = commandContext(root) - await writeFile(join(root, 'main.mjs'), 'export function main() { return "ok" }\n') - await expect(runDshSdkCommand(['start', 'main.mjs'], defaults)).resolves.toBe(0) - await expect(runDshSdkCommand(['build'], defaults)).resolves.toBe(0) - defaults.port = new QueuePort([[]]) - await expect(runDshSdkCommand(['config'], defaults)).resolves.toBe(1) - }) -}) - -describe('build profiles and invocation', () => { - it('discovers root and plugin targets and creates independent profiles', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-build-profile-')) - temporary.push(root) - await mkdir(join(root, 'plugins', 'one', 'src'), { recursive: true }) - await writeFile(join(root, 'index.ts'), 'export {}\n') - await writeFile(join(root, 'plugins', 'one', 'package.json'), '{"name":"one"}\n') - await writeFile(join(root, 'plugins', 'one', 'src', 'index.ts'), 'export {}\n') - expect(ProjectBuild({ cwd: root, entry: ['index.ts'] })).toEqual([ - { cwd: root, entry: ['index.ts'] }, - { workspace: { include: ['plugins/*'] } }, - ]) - expect(PluginBuild({ entry: ['src/index.ts'], dts: true })).toEqual({ entry: ['src/index.ts'], dts: true }) - expect(() => ProjectBuild({ workspace: true })).toThrow('owns workspace discovery') - expect(() => PluginBuild({ workspace: true })).toThrow('does not accept nested workspace') - expect(ProjectBuild({ cwd: join(root, 'empty'), entry: ['index.ts'] })).toEqual([ - { cwd: join(root, 'empty'), entry: ['index.ts'] }, - ]) - expect(ProjectBuild({ entry: ['index.ts'] })[0]).toMatchObject({ entry: ['index.ts'] }) - }) - - it('runs the project-installed tsdown and reports child failure', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-build-run-')) - temporary.push(root) - await writeFile(join(root, 'package.json'), '{"type":"module"}\n') - await writeFile(join(root, 'index.ts'), 'export {}\n') - await writeFile(join(root, 'tsdown.config.ts'), 'export default {}\n') - await mkdir(join(root, 'node_modules'), { recursive: true }) - const manifest = fileURLToPath(import.meta.resolve('tsdown/package.json')) - await symlink(dirname(manifest), join(root, 'node_modules', 'tsdown')) - const calls: string[][] = [] - const runner: CommandRunner = { - run: async (command, args) => { - calls.push([command, ...args]) - return { exitCode: 0, signal: null } - }, - } - await runProjectBuild(['--watch'], root, runner) - expect(calls[0]?.[0]).toBe(process.execPath) - expect(calls[0]?.at(-1)).toBe('--watch') - const failed: CommandRunner = { run: async () => ({ exitCode: 2, signal: null }) } - await expect(runProjectBuild([], root, failed)).rejects.toThrow('exited with code 2') - const killed: CommandRunner = { run: async () => ({ exitCode: null, signal: 'SIGTERM' }) } - await expect(runProjectBuild([], root, killed)).rejects.toThrow('killed by SIGTERM') - }) - - it('recognizes every tsdown config source', async () => { - const manifest = fileURLToPath(import.meta.resolve('tsdown/package.json')) - for (const extension of ['cts', 'cjs', 'json']) { - const root = await mkdtemp(join(tmpdir(), `dsh-build-${extension}-`)) - temporary.push(root) - await writeFile(join(root, 'package.json'), '{"type":"module"}\n') - await writeFile(join(root, `tsdown.config.${extension}`), '{}\n') - await mkdir(join(root, 'node_modules'), { recursive: true }) - await symlink(dirname(manifest), join(root, 'node_modules', 'tsdown')) - let called = false - await runProjectBuild([], root, { - run: async () => { called = true; return { exitCode: 0, signal: null } }, - }) - expect(called).toBe(true) - } - const root = await mkdtemp(join(tmpdir(), 'dsh-build-package-json-')) - temporary.push(root) - await writeFile(join(root, 'package.json'), '{"type":"module","tsdown":{}}\n') - await mkdir(join(root, 'node_modules'), { recursive: true }) - await symlink(dirname(manifest), join(root, 'node_modules', 'tsdown')) - let called = false - await runProjectBuild([], root, { - run: async () => { called = true; return { exitCode: 0, signal: null } }, - }) - expect(called).toBe(true) - }) - - it('reports missing and malformed project tsdown executables', async () => { - const missing = await mkdtemp(join(tmpdir(), 'dsh-build-missing-')) - temporary.push(missing) - await writeFile(join(missing, 'package.json'), '{"type":"module"}') - await writeFile(join(missing, 'tsdown.config.ts'), 'export default {}\n') - await expect(runProjectBuild([], missing)).rejects.toThrow('requires tsdown') - const malformed = await mkdtemp(join(tmpdir(), 'dsh-build-malformed-')) - temporary.push(malformed) - await writeFile(join(malformed, 'package.json'), '{"type":"module"}') - await writeFile(join(malformed, 'tsdown.config.ts'), 'export default {}\n') - await mkdir(join(malformed, 'node_modules', 'tsdown'), { recursive: true }) - await writeFile(join(malformed, 'node_modules', 'tsdown', 'package.json'), JSON.stringify({ - name: 'tsdown', version: '0.0.0', exports: { './package.json': './package.json' }, bin: {}, - })) - await expect(runProjectBuild([], malformed)).rejects.toThrow('has no executable') - await writeFile(join(malformed, 'node_modules', 'tsdown', 'package.json'), JSON.stringify({ - name: 'tsdown', version: '0.0.0', exports: { './package.json': './package.json' }, - })) - await expect(runProjectBuild([], malformed)).rejects.toThrow('has no executable') - const stringBin = await mkdtemp(join(tmpdir(), 'dsh-build-string-bin-')) - temporary.push(stringBin) - await writeFile(join(stringBin, 'package.json'), '{"type":"module"}') - await writeFile(join(stringBin, 'tsdown.config.js'), 'export default {}\n') - await mkdir(join(stringBin, 'node_modules', 'tsdown'), { recursive: true }) - await writeFile(join(stringBin, 'node_modules', 'tsdown', 'package.json'), JSON.stringify({ - name: 'tsdown', version: '0.0.0', exports: { './package.json': './package.json' }, bin: 'cli.js', - })) - await writeFile(join(stringBin, 'node_modules', 'tsdown', 'cli.js'), '') - let command = '' - await runProjectBuild([], stringBin, { - run: async (_node, args) => { command = args[0] ?? ''; return { exitCode: 0, signal: null } }, - }) - expect(command).toContain('cli.js') - }) - - it('returns a no-op for a project with no build targets and hints on a missing start target', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-no-build-')) - temporary.push(root) - let called = false - await runProjectBuild([], root, { run: async () => { called = true; return { exitCode: 0, signal: null } } }) - expect(called).toBe(false) - const unreadableManifest = await mkdtemp(join(tmpdir(), 'dsh-build-unreadable-manifest-')) - temporary.push(unreadableManifest) - await mkdir(join(unreadableManifest, 'package.json')) - await expect(runProjectBuild([], unreadableManifest)).rejects.toThrow() - await expect(runSDK('index.js', { cwd: root })).rejects.toThrow('Run dsh-sdk build first') - }) - - it('invokes the target module main export and rejects passive modules', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-module-main-')) - temporary.push(root) - await writeFile(join(root, 'main.mjs'), 'export function main(context) { return context }\n') - await writeFile(join(root, 'passive.mjs'), 'export const value = 1\n') - await expect(runSDK('main.mjs', { - cwd: root, - argv: ['--model=mock', '--resume=session-1', 'custom'], - })).resolves.toEqual({ - argv: ['--model=mock', '--resume=session-1', 'custom'], - args: { model: 'mock', resume: 'session-1' }, cwd: root, mode: 'start', - }) - await expect(runSDK('passive.mjs', { cwd: root })).rejects.toThrow('must export function main()') - }) - - it('boots empty Cordis configs and delegates targetless runs', async () => { - expectTypeOf(runSDK).toBeCallableWith() - const root = await mkdtemp(join(tmpdir(), 'dsh-start-sdk-')) - temporary.push(root) - await writeFile(join(root, 'cordis.yml'), '[]\n') - const byUrl = await startSDK(pathToFileURL(join(root, 'cordis.yml'))) - await byUrl.fiber.dispose() - const byRun = await runSDK(undefined, { cwd: root }) as import('@deepseek-ai/cordis').Context - await byRun.fiber.dispose() - const dev = await startSDK('./cordis.yml', { cwd: root, dev: true }) - await dev.fiber.dispose() - await expect(startSDK(new URL('https://example.invalid/cordis.yml'), { cwd: root })).rejects.toThrow() - }) - - it('validates local plugin metadata in dev mode', async () => { - const malformed = await mkdtemp(join(tmpdir(), 'dsh-dev-malformed-')) - temporary.push(malformed) - await mkdir(join(malformed, 'plugins', 'bad'), { recursive: true }) - await expect(runSDK('missing.ts', { cwd: malformed, dev: true })).rejects.toThrow('cannot load local plugin metadata') - const absent = await mkdtemp(join(tmpdir(), 'dsh-dev-absent-')) - temporary.push(absent) - await expect(runSDK('missing.ts', { cwd: absent, dev: true })).rejects.toThrow('cannot start missing target') - - const unnamed = await mkdtemp(join(tmpdir(), 'dsh-dev-unnamed-')) - temporary.push(unnamed) - await mkdir(join(unnamed, 'plugins', 'bad', 'src'), { recursive: true }) - await writeFile(join(unnamed, 'plugins', 'bad', 'package.json'), '{}') - await writeFile(join(unnamed, 'plugins', 'bad', 'src/index.ts'), 'export {}\n') - await expect(runSDK('missing.ts', { cwd: unnamed, dev: true })).rejects.toThrow('has no name') - - const duplicate = await mkdtemp(join(tmpdir(), 'dsh-dev-duplicate-')) - temporary.push(duplicate) - for (const name of ['one', 'two']) { - await mkdir(join(duplicate, 'plugins', name, 'src'), { recursive: true }) - await writeFile(join(duplicate, 'plugins', name, 'package.json'), '{"name":"same"}') - await writeFile(join(duplicate, 'plugins', name, 'src/index.ts'), 'export {}\n') - } - await expect(runSDK('missing.ts', { cwd: duplicate, dev: true })).rejects.toThrow('duplicate local plugin') - - const valid = await mkdtemp(join(tmpdir(), 'dsh-dev-valid-')) - temporary.push(valid) - await mkdir(join(valid, 'plugins', 'one', 'src'), { recursive: true }) - await writeFile(join(valid, 'plugins', 'README.md'), 'skip\n') - await writeFile(join(valid, 'plugins', 'one', 'package.json'), '{"name":"local"}') - await writeFile(join(valid, 'plugins', 'one', 'src/index.ts'), 'export {}\n') - await writeFile(join(valid, 'main.ts'), 'export function main() { return "dev" }\n') - await expect(runSDK('main.ts', { cwd: valid, dev: true })).resolves.toBe('dev') - await expect(runSDK('missing.ts', { cwd: valid, dev: true })).rejects.toThrow('cannot start missing target') - }) - - it('maps only exact local package names through the loader hook', async () => { - initialize({ mappings: { local: 'file:///tmp/local.ts' } }) - const next = async (specifier: string) => ({ url: specifier, format: 'module' as const }) - const context: import('node:module').ResolveHookContext = { - conditions: [], importAttributes: {}, parentURL: undefined, - } - await expect(resolveLocalPlugin('local', context, next)).resolves.toMatchObject({ url: 'file:///tmp/local.ts' }) - await expect(resolveLocalPlugin('other', context, next)).resolves.toMatchObject({ url: 'other' }) - }) -}) - -describe('ConfigWorkflow', () => { - it('opens a project through the config command prompt seam', async () => { - const project = await committedProject() - const context = commandContext(project.root) - context.port = new QueuePort([[]]) - context.install = async () => { throw new Error('install should not run') } - await expect(runConfigCommand(context)).resolves.toEqual({}) - delete context.port - delete context.install - context.stdin.isTTY = false - await expect(runConfigCommand(context)).rejects.toThrow('interactive TTY') - context.stdin.isTTY = true - context.stdout.isTTY = false - await expect(runConfigCommand(context)).rejects.toThrow('interactive TTY') - }) - it('accumulates a disable and commits only after Review & Apply', async () => { - const project = await committedProject([{ id: featureId('todo'), options: ['default'] }]) - const registry = createBuiltinRegistry(project.profile) - const output = outputBuffer() - const workflow = new ConfigWorkflow(new QueuePort([ - [], true, - ]), output.stream, async () => { throw new Error('install should not run') }) - const result = await workflow.run(project, registry) - expect(result.commit?.project.cordis.entry('tool-todo')?.disabled).toBe(true) - expect(output.read()).toContain('Disable feature: todo') - }) - - it('reconciles a headless plan without prompting and preserves custom plugins', async () => { - const project = await committedProject([], [new LocalPluginBlueprint('plugin', 'plugin')]) - const registry = createBuiltinRegistry(project.profile) - const output = outputBuffer() - let installs = 0 - const plan: ConfigPlan = { - features: [ - { id: featureId('bash'), options: ['local'] }, - { id: featureId('persistence'), options: ['jsonl'] }, - { id: featureId('todo'), options: ['default'] }, - { id: featureId('web'), options: ['exa'], secrets: { apiKey: 'exa-key' } }, - ], - } - const result = await new ConfigWorkflow( - new HeadlessPromptPort(), output.stream, async () => { installs += 1 }, - ).run(project, registry, plan) - expect(result.commit?.project.cordis.entry('tool-todo')).toBeDefined() - // the unlisted custom local plugin keeps its enabled state (not nuked by the plan) - expect(result.commit?.project.cordis.entry('plugin')?.disabled).toBeFalsy() - expect(installs).toBe(1) - }) - - it('installs once after NPM dependency changes and keeps committed files on install failure', async () => { - const project = await committedProject() - const registry = createBuiltinRegistry(project.profile) - const output = outputBuffer() - let installs = 0 - const workflow = new ConfigWorkflow(new QueuePort([ - [{ value: 'feature:todo', choices: [] }], true, - ]), output.stream, async () => { - installs += 1 - throw new Error('offline') - }) - const result = await workflow.run(project, registry) - expect(installs).toBe(1) - expect(result.installError?.message).toBe('offline') - expect(result.commit?.project.cordis.entry('tool-todo')).toBeDefined() - expect(output.read()).toContain('Changes were committed, but install failed') - }) - - it('cancels apply and enables a disabled feature without reinstalling', async () => { - const project = await committedProject([{ id: featureId('todo'), options: ['default'] }]) - const registry = createBuiltinRegistry(project.profile) - const cancelled = await new ConfigWorkflow(new QueuePort([[], false]), outputBuffer().stream).run(project, registry) - expect(cancelled).toEqual({}) - const disable = project.edit(registry) - disable.disableFeature(registry.get(featureId('todo'))) - const disabled = (await disable.commit()).project - let installs = 0 - const enabled = await new ConfigWorkflow(new QueuePort([ - [{ value: 'feature:todo', choices: [] }], true, - ]), outputBuffer().stream, async () => { installs += 1 }).run(disabled, createBuiltinRegistry(disabled.profile)) - expect(enabled.commit?.project.cordis.entry('tool-todo')?.disabled).toBeUndefined() - expect(installs).toBe(0) - }) - - it('toggles custom Cordis config entries without changing NPM dependencies', async () => { - const project = await committedProject([], [new LocalPluginBlueprint('sample', 'plugin')]) - await expect(new ConfigWorkflow(new QueuePort([ - [{ value: 'plugin:sample', choices: [] }], - ]), outputBuffer().stream).run(project, createBuiltinRegistry(project.profile))).resolves.toEqual({}) - const output = outputBuffer() - const disabled = await new ConfigWorkflow(new QueuePort([[], true]), output.stream).run( - project, createBuiltinRegistry(project.profile), - ) - expect(disabled.commit?.project.cordis.entry('sample')?.disabled).toBe(true) - expect(output.read()).toContain('Disable custom plugin: sample') - const next = disabled.commit?.project - if (!next) throw new Error('custom toggle did not commit') - const enabled = await new ConfigWorkflow(new QueuePort([ - [{ value: 'plugin:sample', choices: [] }], true, - ]), outputBuffer().stream).run(next, createBuiltinRegistry(next.profile)) - expect(enabled.commit?.project.cordis.entry('sample')?.disabled).toBeUndefined() - }) - - it('shows inconsistent features as diagnostic-only rows', async () => { - const complete = await committedProject() - await writeFile(join(complete.root, 'cordis.yml'), `${await readFile(join(complete.root, 'cordis.yml'), 'utf8')}- id: web-search-exa - name: '@deepseek-ai/dsh-web-search-exa' -`) - const project = await SdkProject.open(complete.root) - const port = new QueuePort([[]]) - await expect(new ConfigWorkflow(port, outputBuffer().stream).run(project, createBuiltinRegistry(project.profile))) - .resolves.toEqual({}) - }) - - it('uses the default installer and normalizes non-Error install failures', async () => { - const project = await committedProject() - const install = vi.spyOn(NpmPackageManager.prototype, 'install').mockResolvedValue() - await new ConfigWorkflow(new QueuePort([ - [{ value: 'feature:todo', choices: [] }], true, - ])).run(project, createBuiltinRegistry(project.profile)) - expect(install).toHaveBeenCalledOnce() - install.mockRestore() - const next = await committedProject() - const failed = await new ConfigWorkflow(new QueuePort([ - [{ value: 'feature:todo', choices: [] }], true, - ]), outputBuffer().stream, async () => { throw 'offline-string' }).run(next, createBuiltinRegistry(next.profile)) - expect(failed.installError?.message).toBe('offline-string') - }) - - it('reconciles a child option selected in the feature tree', async () => { - const project = await committedProject() - const registry = createBuiltinRegistry(project.profile) - let installs = 0 - const workflow = new ConfigWorkflow(new QueuePort([ - [{ value: 'feature:persistence', choices: ['sqlite'] }], true, - ]), outputBuffer().stream, async () => { installs += 1 }) - const result = await workflow.run(project, registry) - expect(result.commit?.project.cordis.entry('session-persistence')).toMatchObject({ - name: '@deepseek-ai/dsh-session-persistence-sqlite', - config: { path: './.sessions/sessions.sqlite' }, - }) - expect(installs).toBe(1) - }) - - it('switches required provider and interface options', async () => { - const project = await committedProject() - const registry = createBuiltinRegistry(project.profile) - const workflow = new ConfigWorkflow(new QueuePort([ - [ - { value: 'feature:provider', choices: ['custom'] }, - { value: 'feature:app', choices: ['acp'] }, - { value: 'feature:persistence', choices: ['jsonl'] }, - ], - 'https://provider.example/v1', - 'custom-key', - true, - ]), outputBuffer().stream, async () => {}) - const result = await workflow.run(project, registry) - const provider = result.commit?.project.cordis.entry('llm-pi-ai') - expect(provider?.config).not.toHaveProperty('apiKey') - expect(provider?.config?.baseURL).toBe('https://provider.example/v1') - expect(result.commit?.project.cordis.entry('acp')).toBeDefined() - expect(result.commit?.project.cordis.entry('agent-loop')).toBeDefined() - expect(result.commit?.project.cordis.entry('agent-core')).toBeUndefined() - }) - -}) - -describe('dsh-sdk create', () => { - const writeDependency = (name: string) => async (_m: unknown, spec: string, cwd: string): Promise => { - const path = join(cwd, 'package.json') - const manifest = JSON.parse(await readFile(path, 'utf8')) as { dependencies?: Record } - manifest.dependencies = { ...manifest.dependencies, [name]: spec } - await writeFile(path, JSON.stringify(manifest, null, 2)) - } - - it('adds a dependency and mounts it after confirmation', async () => { - const project = await committedProject() - const context = { ...commandContext(project.root), port: new QueuePort([true]), add: writeDependency('my-ext-plugin') } - const result = await runCreatePluginCommand('github:o/r#sha', context) - expect(result?.project.cordis.entry('my-ext-plugin')?.name).toBe('my-ext-plugin') - expect(context.readStdout()).toContain('Mounted my-ext-plugin') - }) - - it('derives the cordis id from a scoped package name', async () => { - const project = await committedProject() - const context = { ...commandContext(project.root), port: new QueuePort([true]), add: writeDependency('@acme/cool-plugin') } - const result = await runCreatePluginCommand('@acme/cool-plugin@1.0.0', context) - expect(result?.project.cordis.entry('cool-plugin')?.name).toBe('@acme/cool-plugin') - }) - - it('returns undefined and adds nothing when declined', async () => { - const project = await committedProject() - let added = false - const context = { - ...commandContext(project.root), - port: new QueuePort([false]), - add: async () => { added = true }, - } - await expect(runCreatePluginCommand('pkg@1.0.0', context)).resolves.toBeUndefined() - expect(added).toBe(false) - }) - - it('rejects an empty source, a non-TTY session, and a no-op add', async () => { - const project = await committedProject() - await expect(runCreatePluginCommand(' ', { ...commandContext(project.root), port: new QueuePort([]) })) - .rejects.toThrow('requires a plugin source') - const noTty = commandContext(project.root) - noTty.stdin.isTTY = false - noTty.stdout.isTTY = false - await expect(runCreatePluginCommand('pkg@1.0.0', noTty)).rejects.toThrow('interactive TTY') - const noOutTty = commandContext(project.root) - noOutTty.stdout.isTTY = false - await expect(runCreatePluginCommand('pkg@1.0.0', noOutTty)).rejects.toThrow('interactive TTY') - await expect(runCreatePluginCommand('pkg@1.0.0', { - ...commandContext(project.root), port: new QueuePort([true]), add: async () => {}, - })).rejects.toThrow('added no new dependency') - }) - - it('dispatches create through the launcher', async () => { - const project = await committedProject() - const context = commandContext(project.root) - context.createPlugin = async () => undefined - await expect(runDshSdkCommand(['create', 'pkg@1.0.0'], context)).resolves.toBe(0) - }) -}) - -describe('command telemetry', () => { - it('reports when consent allows and skips when denied or faulting', async () => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-telemetry-')) - temporary.push(dir) - const sent: unknown[] = [] - const reporter = { report: () => { sent.push(1) }, flush: async () => {} } - await reportCommandTelemetry( - { command: 'build', cwd: dir, durationMs: 5, success: true }, - { resolve: async () => ({ allowed: true, reason: 'absent' }), reporter }, - ) - expect(sent).toHaveLength(1) - await reportCommandTelemetry( - { command: 'build', cwd: dir, durationMs: 5, success: true }, - { resolve: async () => ({ allowed: false, reason: 'disabled' }), reporter }, - ) - expect(sent).toHaveLength(1) - await expect(reportCommandTelemetry( - { command: 'build', cwd: dir, durationMs: 5, success: true }, - { resolve: async () => { throw new Error('boom') }, reporter }, - )).resolves.toBeUndefined() - expect(sent).toHaveLength(1) - }) - - it('emits a telemetry event carrying each command outcome', async () => { - const project = await committedProject() - const events: CommandTelemetryEvent[] = [] - const context = commandContext(project.root) - context.telemetry = async (event) => { events.push(event) } - context.build = async () => {} - await expect(runDshSdkCommand(['build'], context)).resolves.toBe(0) - expect(events).toHaveLength(1) - expect(events[0]).toMatchObject({ command: 'build', cwd: project.root, success: true }) - - await runDshSdkCommand([], context) - expect(events).toHaveLength(1) - - context.build = async () => { throw new Error('boom') } - await expect(runDshSdkCommand(['build'], context)).resolves.toBe(1) - expect(events[1]).toMatchObject({ command: 'build', success: false }) - - context.config = async () => ({ installError: new Error('offline') }) - await expect(runDshSdkCommand(['config'], context)).resolves.toBe(1) - expect(events.at(-1)).toMatchObject({ command: 'config', success: false }) - }) -}) diff --git a/packages/scaffold/scripts/tsconfig.json b/packages/scaffold/scripts/tsconfig.json deleted file mode 100644 index 2230a377ab..0000000000 --- a/packages/scaffold/scripts/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": ["src"], - "references": [ - { "path": "../helper" }, - { "path": "../telemetry" }, - { "path": "../../boot/app-boot" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../support/invariants" } - ] -} diff --git a/packages/scaffold/scripts/tsdown.config.ts b/packages/scaffold/scripts/tsdown.config.ts deleted file mode 100644 index 14bb59b8bc..0000000000 --- a/packages/scaffold/scripts/tsdown.config.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { defineConfig } from 'tsdown' - -/** Bundle each public or runtime entry and mirror package-owned terminal templates. */ -export default defineConfig([ - { - entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', - fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, - copy: [{ from: 'src/templates/assets/*', to: 'lib/assets' }], - }, - { - entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', - fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, - }, - { - entry: ['lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', - fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, - }, - { - entry: ['lib/types/dev/tsdown-config.js'], outDir: 'lib/dev', format: ['esm'], platform: 'node', - target: 'es2024', fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, - }, - { - entry: ['lib/types/local-plugin-loader-hooks.js'], outDir: 'lib', format: ['esm'], platform: 'node', - target: 'es2024', fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, - }, -]) diff --git a/packages/scaffold/telemetry/README.i18n.yaml b/packages/scaffold/telemetry/README.i18n.yaml deleted file mode 100644 index beb145cfc0..0000000000 --- a/packages/scaffold/telemetry/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 packages/scaffold/telemetry/README.md -README.md: 024ff8724604426d3d77609929ca40341f98e0fb -README.zh.md: 8d69d9289ed9c412e4d38fe9b24bb3222f81f94a diff --git a/packages/scaffold/telemetry/README.md b/packages/scaffold/telemetry/README.md deleted file mode 100644 index 024ff87246..0000000000 --- a/packages/scaffold/telemetry/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# `@deepseek-ai/dsh-telemetry` - -English | [中文](README.zh.md) - -Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain library the launcher imports around each command; it is **not** a Cordis plugin, because `build` and first-init `create` never boot Cordis. Wiring the reporter into the launcher command dispatch and adding the telemetry consent feature to the `dsh-helper` catalog live in their owning packages, not here. - -| Export | Role | -|---|---| -| `SecretRedactor` | Conservative safety backstop: replaces secret-shaped values (secret-like keys, known token shapes, PEM blocks, URL credentials, high-entropy opaque tokens) with a placeholder in both parsed values (`redactValue`) and raw text (`redactText`). Never drops a field or line. | -| `ConsentResolver` | Parses (never boots) a project `cordis.yml` and reads the telemetry entry's enabled/disabled state as consent; `DO_NOT_TRACK`/CI env force a hard opt-out. | -| `buildTelemetryPayload` | Assembles `{command, durationMs, success, cordisYmlContent, packageJsonContent}`, running the redactor over the full `cordis.yml` and `package.json` text. Never reads `.env`; `package.json` ships only alongside a `cordis.yml`, so a command run in a non-SDK directory never uploads that directory's unrelated manifest. | -| `getOrCreateAnonymousId` | Random UUID persisted in the harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`$DSH_HOME` > `~/.dsh`), scoped to that home rather than the machine, never derived from git. | -| `TelemetryReporter` | Fire-and-forget send: `report()` never blocks or throws; delivery resolves on every path; `flush()` optionally drains in-flight sends within a cap. | - -Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. Telemetry reports by default and is off only when a present telemetry entry is explicitly `disabled`: a missing `cordis.yml` (first `create`), an enabled entry, or a `cordis.yml` with no telemetry entry all report. `DO_NOT_TRACK`/CI always deny. The no-config and absent-entry defaults are configurable on `ConsentResolver`. - -The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`). - -## Model Experience - -None, as the reporter sends developer-cycle telemetry from the launcher and never reaches a model request. - -#### KV Cache effect - -None; this package neither assembles nor sends a provider request. - -## Known Limitations and Deferred Work - -- **Placeholder endpoint** — `DSH_TELEMETRY_ENDPOINT` points at `.invalid` until the real endpoint is set. -- **Redaction is heuristic** — a conservative backstop, not a guarantee; secrets belong in `.env`, which is never read or reported. diff --git a/packages/scaffold/telemetry/README.zh.md b/packages/scaffold/telemetry/README.zh.md deleted file mode 100644 index 8d69d9289e..0000000000 --- a/packages/scaffold/telemetry/README.zh.md +++ /dev/null @@ -1,30 +0,0 @@ -# `@deepseek-ai/dsh-telemetry` - -[English](README.md) | 中文 - -用于 dsh-sdk 工具链的启动器侧 telemetry 原语。这是启动器在执行每个命令时导入的普通库;它**不是** Cordis 插件,因为 `build` 与首次初始化的 `create` 从不启动 Cordis。将 reporter 接入启动器命令分发,并把 telemetry consent 功能加入 `dsh-helper` 目录,属于各自所属包的职责,而不是此包的职责。 - -| 导出 | 职责 | -|---|---| -| `SecretRedactor` | 保守的安全后备:在已解析值(`redactValue`)与原始文本(`redactText`)中,将形似密钥的值(疑似密钥的键名、已知 token 格式、PEM 块、URL 凭据、高熵不透明 token)替换为占位符。绝不删除字段或行。 | -| `ConsentResolver` | 解析项目 `cordis.yml`(绝不启动),读取 telemetry 配置项的启用/禁用状态作为 consent;`DO_NOT_TRACK`/CI 环境会强制完全停止上报。 | -| `buildTelemetryPayload` | 组装 `{command, durationMs, success, cordisYmlContent, packageJsonContent}`,对完整的 `cordis.yml` 与 `package.json` 文本运行 redactor。绝不读取 `.env`;发送 `package.json` 的前提是同时存在 `cordis.yml`,因此在非 SDK 目录运行的命令不会上传该目录中无关的 manifest(元数据清单)。 | -| `getOrCreateAnonymousId` | 将随机 UUID 持久化到 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析出的 harness home(`$DSH_HOME` > `~/.dsh`);其范围限定为该 home,而不是整台机器,且绝不从 git 派生。 | -| `TelemetryReporter` | 即发即弃发送:`report()` 绝不阻塞或抛出;无论经过哪条路径,发送操作最终都会结束;`flush()` 可以在上限内排空进行中的发送。 | - -Consent 由 `cordis.yml` 中的 telemetry 配置项承载,因此禁用 telemetry 就是禁用该配置项。telemetry 默认上报,只有已经存在的 telemetry 配置项被显式设为 `disabled` 时才关闭:缺少 `cordis.yml`(首次 `create`)、配置项已启用,或 `cordis.yml` 中没有 telemetry 配置项时都会上报。`DO_NOT_TRACK`/CI 始终拒绝。无配置与缺少配置项的默认值可以通过 `ConsentResolver` 配置。 - -收集端点是固定常量(`DSH_TELEMETRY_ENDPOINT`)。 - -## 模型体验 - -无。reporter 从启动器发送开发周期 telemetry,绝不会进入模型请求。 - -#### KV Cache 影响 - -无;此包既不组装也不发送提供方请求。 - -## 已知限制与暂缓事项 - -- **占位端点**:`DSH_TELEMETRY_ENDPOINT` 指向 `.invalid`,直到设置真实端点。 -- **脱敏依赖启发式规则**:这只是保守后备,不是保证;密钥应存放于 `.env`,而该文件绝不会被读取或上报。 diff --git a/packages/scaffold/telemetry/package.json b/packages/scaffold/telemetry/package.json deleted file mode 100644 index 4dfeb54e2b..0000000000 --- a/packages/scaffold/telemetry/package.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-telemetry", - "description": "Launcher-side dsh-sdk telemetry: secret redaction, consent resolution, anonymous id, payload builder, and fire-and-forget reporter", - "version": "0.0.1-rc.1", - "publishConfig": { - "access": "restricted" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/scaffold/telemetry" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts" - ], - "license": "BSD-3-Clause", - "dependencies": { - "yaml": "^2.9.0" - }, - "peerDependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-paths": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-paths": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - } -} diff --git a/packages/scaffold/telemetry/src/anonymous-id.ts b/packages/scaffold/telemetry/src/anonymous-id.ts deleted file mode 100644 index 08925d1ad2..0000000000 --- a/packages/scaffold/telemetry/src/anonymous-id.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Per-harness-home anonymous telemetry id. - * - * The id is a random UUID persisted directly in the harness home resolved by - * {@link resolveDshHome} (`$DSH_HOME` > `~/.dsh`), and never derived from the - * git remote, repository URL, or any other identifying source (a derived id - * would make "anonymous" a fiction). The id is scoped to the harness home, not - * the machine: every command sharing one `$DSH_HOME` reuses the same id, so the - * default `~/.dsh` counts per-OS-user home directories, while a relocated - * `$DSH_HOME` moves the id with the rest of the harness data — the single-root - * convention this package shares, not a telemetry-specific policy. - * - * @module @deepseek-ai/dsh-telemetry/anonymous-id - */ - -import { randomUUID } from 'node:crypto' -import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import type { Branded } from '@deepseek-ai/dsh-brand' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' - -/** A harness-home-scoped anonymous telemetry id (random UUID v4). */ -export type AnonymousId = Branded<'AnonymousId'> - -/** Default file, inside the harness home, storing the anonymous id. */ -export const ANONYMOUS_ID_FILE_NAME = 'telemetry.json' - -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - -/** Ambient hooks for locating and generating the id; every field has a default. */ -export interface AnonymousIdOptions { - /** Environment consulted for `DSH_HOME`; defaults to `process.env`. */ - env?: NodeJS.ProcessEnv - /** UUID generator; defaults to `crypto.randomUUID` (test hook). */ - randomUUID?: () => string -} - -/** - * Resolve the single-root harness home that stores the anonymous id. - * Delegates to {@link resolveDshHome} so telemetry shares the harness's one - * home-resolution policy (`DSH_HOME` > `~/.dsh`) instead of maintaining a - * second config-directory convention. - * @param options - Environment hook. - * @returns absolute harness home path. - */ -export function globalConfigDir(options: AnonymousIdOptions = {}): string { - return resolveDshHome(undefined, options.env ?? process.env) -} - -/** Read a valid persisted id from the store, or `undefined` when absent/corrupt. */ -async function readPersistedId(file: string): Promise { - let text: string - try { - text = await readFile(file, 'utf8') - } catch { - // Absent or unreadable: the caller mints and persists a fresh id. - return undefined - } - let parsed: unknown - try { - parsed = JSON.parse(text) - } catch { - // Corrupt JSON: the caller overwrites the store with a fresh id. - return undefined - } - if (parsed !== null && typeof parsed === 'object') { - const value = (parsed as Record).anonymousId - if (typeof value === 'string' && UUID_PATTERN.test(value)) return value as AnonymousId - } - return undefined -} - -/** - * Return the harness home's anonymous id, creating and persisting one on first use. - * Persistence is best-effort: a write failure still returns a usable id for the - * current run so telemetry is never blocked by config-dir permissions. - * @param options - config-location and UUID-generation hooks. - * @returns the stable per-harness-home anonymous id. - */ -export async function getOrCreateAnonymousId(options: AnonymousIdOptions = {}): Promise { - const file = join(globalConfigDir(options), ANONYMOUS_ID_FILE_NAME) - const existing = await readPersistedId(file) - if (existing !== undefined) return existing - const generate = options.randomUUID ?? randomUUID - const created = generate() as AnonymousId - try { - await mkdir(dirname(file), { recursive: true }) - await writeFile(file, `${JSON.stringify({ anonymousId: created }, null, 2)}\n`, 'utf8') - } catch { - // Best-effort persistence: return the fresh id even when the store is unwritable. - } - return created -} diff --git a/packages/scaffold/telemetry/src/consent-resolver.ts b/packages/scaffold/telemetry/src/consent-resolver.ts deleted file mode 100644 index a4327dc9f7..0000000000 --- a/packages/scaffold/telemetry/src/consent-resolver.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Consent resolution for dsh-sdk telemetry. - * - * Telemetry is OFF only when `cordis.yml` contains a telemetry entry that is - * explicitly `disabled`; every other file state reports (no `cordis.yml`, an - * enabled entry, or no telemetry entry at all). The resolver PARSES `cordis.yml` - * — it never boots a Cordis application — because several launcher commands - * (`build`, `create`) never boot Cordis at all. `DO_NOT_TRACK` and CI - * environment signals force a denial regardless of file state. - * - * @module @deepseek-ai/dsh-telemetry/consent-resolver - */ - -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' -import { parseDocument, type ScalarTag } from 'yaml' - -/** Default `cordis.yml` entry name that carries telemetry consent. */ -export const DEFAULT_TELEMETRY_PLUGIN_NAME = '@deepseek-ai/dsh-telemetry' - -/** - * Passthrough for Cordis' `!!js` expression tag so parsing consent never fails - * on projects that inline JavaScript expressions; the resolver only reads plain - * `name`/`disabled` scalars and does not evaluate expressions. - */ -const JS_EXPRESSION_TAG: ScalarTag = { - tag: 'tag:yaml.org,2002:js', - resolve: value => value, -} - -/** Why telemetry is or is not permitted for one command. */ -export type ConsentReason = - | 'enabled' - | 'disabled' - | 'absent' - | 'no-config' - | 'do-not-track' - | 'ci' - | 'unreadable' - -/** Resolved telemetry consent for one command invocation. */ -export interface ConsentDecision { - /** Whether telemetry may be sent. */ - allowed: boolean - /** The signal that determined {@link allowed}. */ - reason: ConsentReason -} - -/** Tuning for {@link ConsentResolver}; every field defaults to a documented value. */ -export interface ConsentResolverOptions { - /** `cordis.yml` entry name whose enabled state carries consent. */ - telemetryPluginName?: string - /** Environment used for `DO_NOT_TRACK`/CI checks; defaults to `process.env`. */ - env?: NodeJS.ProcessEnv - /** Honor `DO_NOT_TRACK`/CI env signals as a hard opt-out. Defaults to `true`. */ - honorEnvOptOut?: boolean - /** Consent when `cordis.yml` does not exist yet (first `create`). Defaults to `true` (telemetry is default-on). */ - allowWhenNoConfig?: boolean - /** Consent when `cordis.yml` exists but has no telemetry entry. Defaults to `true` (report unless a present entry is disabled). */ - allowWhenEntryAbsent?: boolean -} - -/** Whether an environment variable is set to a non-empty, non-"0"/"false" value. */ -function envEnabled(value: string | undefined): boolean { - if (value === undefined) return false - const normalized = value.trim().toLowerCase() - return normalized.length > 0 && normalized !== '0' && normalized !== 'false' -} - -/** Read a `cordis.yml` entry's `name`/`disabled` scalars, tolerating `!!js` tags. */ -function readTelemetryEntry(text: string, pluginName: string): { present: boolean; disabled: boolean } { - const document = parseDocument(text, { customTags: [JS_EXPRESSION_TAG] }) - const contents: unknown = document.toJS({ maxAliasCount: -1 }) - if (!Array.isArray(contents)) return { present: false, disabled: false } - for (const entry of contents) { - if (entry === null || typeof entry !== 'object') continue - const record = entry as Record - if (record.name === pluginName) return { present: true, disabled: record.disabled === true } - } - return { present: false, disabled: false } -} - -/** Resolve telemetry consent by parsing a project's `cordis.yml` and the environment. */ -export class ConsentResolver { - readonly #pluginName: string - readonly #env: NodeJS.ProcessEnv - readonly #honorEnvOptOut: boolean - readonly #allowWhenNoConfig: boolean - readonly #allowWhenEntryAbsent: boolean - - /** @param options - plugin name, environment, and default-decision knobs. */ - constructor(options: ConsentResolverOptions = {}) { - this.#pluginName = options.telemetryPluginName ?? DEFAULT_TELEMETRY_PLUGIN_NAME - this.#env = options.env ?? process.env - this.#honorEnvOptOut = options.honorEnvOptOut ?? true - this.#allowWhenNoConfig = options.allowWhenNoConfig ?? true - this.#allowWhenEntryAbsent = options.allowWhenEntryAbsent ?? true - } - - /** - * Resolve consent for a command run in the given project directory. - * @param projectDir - absolute or relative project root containing `cordis.yml`. - * @returns the consent decision and the signal that produced it. - */ - async resolve(projectDir: string): Promise { - if (this.#honorEnvOptOut) { - if (envEnabled(this.#env.DO_NOT_TRACK)) return { allowed: false, reason: 'do-not-track' } - if (envEnabled(this.#env.CI)) return { allowed: false, reason: 'ci' } - } - let text: string - try { - text = await readFile(join(projectDir, 'cordis.yml'), 'utf8') - } catch (error) { - // Missing cordis.yml is the first-init (`create`) path; any other read - // fault is treated conservatively as its own reason. - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return { allowed: this.#allowWhenNoConfig, reason: 'no-config' } - } - return { allowed: false, reason: 'unreadable' } - } - const entry = readTelemetryEntry(text, this.#pluginName) - if (!entry.present) return { allowed: this.#allowWhenEntryAbsent, reason: 'absent' } - return entry.disabled ? { allowed: false, reason: 'disabled' } : { allowed: true, reason: 'enabled' } - } -} diff --git a/packages/scaffold/telemetry/src/index.ts b/packages/scaffold/telemetry/src/index.ts deleted file mode 100644 index 3c0a5beae7..0000000000 --- a/packages/scaffold/telemetry/src/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Launcher-side telemetry for the dsh-sdk toolchain: secret redaction, consent - * resolution, anonymous id, payload assembly, and a fire-and-forget reporter. - * - * This package is a plain library the launcher imports around each command — it - * is NOT a Cordis plugin (several commands never boot Cordis). Wiring it into - * the launcher command dispatch and the helper feature catalog lives outside - * this package. - * - * FIXME: rename to `@deepseek-ai/dsh-sdk-telemetry` before the first tagged release — - * the current name collides with the `dsh-session-telemetry` family; this is - * launcher-side SDK telemetry - * ([regrouping Agent Note](../../../../.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md)). - * - * @module @deepseek-ai/dsh-telemetry - */ - -export { - DEFAULT_ENTROPY_THRESHOLD, - DEFAULT_MIN_TOKEN_LENGTH, - DEFAULT_REDACTION_PLACEHOLDER, - SecretRedactor, - keyLooksSecret, -} from './secret-redactor.ts' -export type { SecretRedactorOptions } from './secret-redactor.ts' -export { - ConsentResolver, - DEFAULT_TELEMETRY_PLUGIN_NAME, -} from './consent-resolver.ts' -export type { - ConsentDecision, - ConsentReason, - ConsentResolverOptions, -} from './consent-resolver.ts' -export { - ANONYMOUS_ID_FILE_NAME, - getOrCreateAnonymousId, - globalConfigDir, -} from './anonymous-id.ts' -export type { AnonymousId, AnonymousIdOptions } from './anonymous-id.ts' -export { buildTelemetryPayload } from './payload.ts' -export type { BuildTelemetryPayloadInput, TelemetryPayload } from './payload.ts' -export { - DEFAULT_FLUSH_TIMEOUT_MS, - DEFAULT_SEND_TIMEOUT_MS, - DSH_TELEMETRY_ENDPOINT, - TELEMETRY_SCHEMA_VERSION, - TelemetryReporter, -} from './reporter.ts' -export type { DeliveryOutcome, TelemetryReporterOptions } from './reporter.ts' diff --git a/packages/scaffold/telemetry/src/invariant.ts b/packages/scaffold/telemetry/src/invariant.ts deleted file mode 100644 index bee683c50e..0000000000 --- a/packages/scaffold/telemetry/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-telemetry`. - * @module @deepseek-ai/dsh-telemetry/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-telemetry' - -/** Cordis companion plugin name. */ -export const name = 'telemetry-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this SDK build-time package owns no live event stream or mutable data; - * generated output and consumer tests cover its contract. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/scaffold/telemetry/src/payload.ts b/packages/scaffold/telemetry/src/payload.ts deleted file mode 100644 index 505cedb872..0000000000 --- a/packages/scaffold/telemetry/src/payload.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Telemetry payload assembly. - * - * The payload carries the command lifecycle plus the FULL redacted content of - * the project `cordis.yml` and `package.json`. It NEVER reads or includes `.env` - * — secrets live only in `.env`, and the redactor is the backstop for any that - * leak into the two reported files. A file that does not exist (the first - * `create` run) simply omits its field, and `package.json` ships only when - * `cordis.yml` is present: without it the directory is not an SDK project, and - * its manifest belongs to whatever unrelated project the command ran in. - * - * @module @deepseek-ai/dsh-telemetry/payload - */ - -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' -import { SecretRedactor } from './secret-redactor.ts' - -/** Project files whose full (redacted) content ships with the payload. */ -const REPORTED_FILES = ['cordis.yml', 'package.json'] as const - -/** One command's telemetry payload. */ -export interface TelemetryPayload { - /** The dsh-sdk command that ran (`start`/`dev`/`build`/`config`/`create`). */ - command: string - /** Wall-clock duration of the command in milliseconds. */ - durationMs: number - /** Whether the command completed without error. */ - success: boolean - /** Redacted full text of the project `cordis.yml`, absent when the file does not exist. */ - cordisYmlContent?: string - /** Redacted full text of the project `package.json`, absent when it or `cordis.yml` does not exist. */ - packageJsonContent?: string -} - -/** Inputs for {@link buildTelemetryPayload}. */ -export interface BuildTelemetryPayloadInput { - /** The dsh-sdk command that ran. */ - command: string - /** Wall-clock duration of the command in milliseconds. */ - durationMs: number - /** Whether the command completed without error. */ - success: boolean - /** Project root whose `cordis.yml` and `package.json` are read. */ - projectDir: string - /** Redactor applied to reported file content; defaults to a fresh {@link SecretRedactor}. */ - redactor?: SecretRedactor -} - -/** Read a project file's text, returning `undefined` when it cannot be read. */ -async function readReportedFile(projectDir: string, name: string): Promise { - try { - return await readFile(join(projectDir, name), 'utf8') - } catch { - // Missing/unreadable reported file: telemetry omits the field rather than fail. - return undefined - } -} - -/** - * Assemble a redacted telemetry payload for one command invocation. - * @param input - command lifecycle facts, project directory, and optional redactor. - * @returns the payload with redacted `cordis.yml`/`package.json` content. - */ -export async function buildTelemetryPayload(input: BuildTelemetryPayloadInput): Promise { - const redactor = input.redactor ?? new SecretRedactor() - const [cordisYml, packageJson] = await Promise.all( - REPORTED_FILES.map(name => readReportedFile(input.projectDir, name)), - ) - return { - command: input.command, - durationMs: input.durationMs, - success: input.success, - ...cordisYml !== undefined ? { cordisYmlContent: redactor.redactText(cordisYml) } : {}, - // package.json is an SDK-project manifest only alongside cordis.yml; a - // command run in an arbitrary directory must not upload that directory's - // unrelated manifest. - ...cordisYml !== undefined && packageJson !== undefined - ? { packageJsonContent: redactor.redactText(packageJson) } - : {}, - } -} diff --git a/packages/scaffold/telemetry/src/reporter.ts b/packages/scaffold/telemetry/src/reporter.ts deleted file mode 100644 index 7a03c9a0f7..0000000000 --- a/packages/scaffold/telemetry/src/reporter.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Fire-and-forget telemetry reporter for the dsh-sdk launcher. - * - * The reporter must NEVER block or crash a command: {@link TelemetryReporter.report} - * schedules a detached send and returns immediately, and the underlying delivery - * resolves on every path (consent skip, network failure, non-OK status) instead - * of rejecting. {@link TelemetryReporter.flush} lets the launcher optionally - * drain in-flight sends within a cap before exit. - * - * @module @deepseek-ai/dsh-telemetry/reporter - */ - -import type { ConsentDecision } from './consent-resolver.ts' -import type { TelemetryPayload } from './payload.ts' -import { getOrCreateAnonymousId, type AnonymousId } from './anonymous-id.ts' -import { SecretRedactor } from './secret-redactor.ts' - -/** - * Fail-safe placeholder collection endpoint. The `.invalid` TLD guarantees - * delivery fails harmlessly until a collector is deployed. This is a fixed - * protocol constant, not a deployment tunable. - */ -// TODO(telemetry-endpoint): Replace the placeholder before release. -export const DSH_TELEMETRY_ENDPOINT = 'https://telemetry.example.invalid/v1/dsh-sdk' - -/** Wire-envelope schema version; bump on any incompatible body change. */ -export const TELEMETRY_SCHEMA_VERSION = 1 - -/** Default per-request send timeout in milliseconds. */ -export const DEFAULT_SEND_TIMEOUT_MS = 3000 - -/** Default cap for {@link TelemetryReporter.flush} in milliseconds. */ -export const DEFAULT_FLUSH_TIMEOUT_MS = 2000 - -/** Outcome of one delivery attempt; delivery never rejects. */ -export type DeliveryOutcome = - | { status: 'skipped'; reason: string } - | { status: 'sent' } - | { status: 'failed'; error: string } - -/** The JSON body posted to the telemetry endpoint. */ -interface TelemetryEnvelope extends TelemetryPayload { - schemaVersion: number - anonymousId: AnonymousId - sentAt: string -} - -/** Injectable dependencies for {@link TelemetryReporter}; every field has a default. */ -export interface TelemetryReporterOptions { - /** Collection endpoint; defaults to {@link DSH_TELEMETRY_ENDPOINT}. */ - endpoint?: string - /** `fetch` implementation; defaults to the global `fetch`. */ - fetch?: typeof globalThis.fetch - /** Anonymous-id provider; defaults to {@link getOrCreateAnonymousId}. */ - anonymousId?: () => Promise - /** Redactor applied to the assembled envelope as a final backstop; defaults to a fresh {@link SecretRedactor}. */ - redactor?: SecretRedactor - /** Per-request send timeout in milliseconds. */ - timeoutMs?: number - /** Clock for the envelope timestamp; defaults to `Date.now`. */ - now?: () => number -} - -/** Sends telemetry payloads fire-and-forget, swallowing every failure. */ -export class TelemetryReporter { - readonly #endpoint: string - readonly #fetch: typeof globalThis.fetch - readonly #anonymousId: () => Promise - readonly #redactor: SecretRedactor - readonly #timeoutMs: number - readonly #now: () => number - readonly #inflight = new Set>() - - /** @param options - endpoint, transport, id provider, and timing dependencies. */ - constructor(options: TelemetryReporterOptions = {}) { - this.#endpoint = options.endpoint ?? DSH_TELEMETRY_ENDPOINT - this.#fetch = options.fetch ?? globalThis.fetch - this.#anonymousId = options.anonymousId ?? getOrCreateAnonymousId - this.#redactor = options.redactor ?? new SecretRedactor() - this.#timeoutMs = options.timeoutMs ?? DEFAULT_SEND_TIMEOUT_MS - this.#now = options.now ?? Date.now - } - - /** - * Schedule a detached, non-blocking send. Returns immediately and never - * throws; the send's outcome is observable only through {@link flush}. - * @param payload - the command payload to report. - * @param consent - resolved consent; a denial short-circuits to a skip. - */ - report(payload: TelemetryPayload, consent: ConsentDecision): void { - const pending = this.#deliver(payload, consent) - this.#inflight.add(pending) - void pending.finally(() => this.#inflight.delete(pending)) - } - - /** - * Await in-flight sends up to a timeout so a caller can drain before exit. - * Resolves on the cap regardless of send progress; never rejects. - * @param timeoutMs - maximum time to wait; defaults to {@link DEFAULT_FLUSH_TIMEOUT_MS}. - */ - async flush(timeoutMs: number = DEFAULT_FLUSH_TIMEOUT_MS): Promise { - if (this.#inflight.size === 0) return - const drained = Promise.allSettled([...this.#inflight]).then(() => undefined) - let timer!: ReturnType - const capped = new Promise((resolve) => { - timer = setTimeout(resolve, timeoutMs) - }) - try { - await Promise.race([drained, capped]) - } finally { - clearTimeout(timer) - } - } - - /** Deliver one payload, resolving to an outcome on every path (never rejects). */ - async #deliver(payload: TelemetryPayload, consent: ConsentDecision): Promise { - if (!consent.allowed) return { status: 'skipped', reason: consent.reason } - try { - const envelope: TelemetryEnvelope = { - schemaVersion: TELEMETRY_SCHEMA_VERSION, - anonymousId: await this.#anonymousId(), - sentAt: new Date(this.#now()).toISOString(), - ...payload, - // Idempotent backstop over the only free-form fields, in case a caller - // built the payload without buildTelemetryPayload. Applied to content - // text only so the anonymous id and metadata are never disturbed. - ...payload.cordisYmlContent !== undefined - ? { cordisYmlContent: this.#redactor.redactText(payload.cordisYmlContent) } - : {}, - ...payload.packageJsonContent !== undefined - ? { packageJsonContent: this.#redactor.redactText(payload.packageJsonContent) } - : {}, - } - const response = await this.#fetch(this.#endpoint, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(envelope), - signal: AbortSignal.timeout(this.#timeoutMs), - }) - if (!response.ok) return { status: 'failed', error: `HTTP ${response.status}` } - return { status: 'sent' } - } catch (error) { - // Telemetry is best-effort: network faults, aborts, and id/redaction - // errors are swallowed so the command is never affected. - return { status: 'failed', error: error instanceof Error ? error.message : String(error) } - } - } -} diff --git a/packages/scaffold/telemetry/src/secret-redactor.ts b/packages/scaffold/telemetry/src/secret-redactor.ts deleted file mode 100644 index 087ba2284a..0000000000 --- a/packages/scaffold/telemetry/src/secret-redactor.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * Conservative secret redactor: the safety backstop that scrubs credential-like - * values from telemetry content before it leaves the machine. - * - * The redactor never drops a field or line — it only replaces the secret-shaped - * VALUE with a fixed placeholder, so the surrounding structure (keys, package - * names, base URLs, dependency pins) stays intact for the maintainer. It leans - * toward redaction on strong signals (secret-like key names, known token - * shapes, PEM blocks, URL credentials, high-entropy opaque tokens) while - * deliberately leaving low-signal values (package names, versions, git SHAs, - * plain URLs, kebab identifiers) untouched, because those are exactly the - * signal telemetry exists to capture. - * - * @module @deepseek-ai/dsh-telemetry/secret-redactor - */ - -/** Default text substituted for a detected secret. */ -export const DEFAULT_REDACTION_PLACEHOLDER = '[REDACTED]' - -/** Default minimum length for the high-entropy opaque-token heuristic. */ -export const DEFAULT_MIN_TOKEN_LENGTH = 24 - -/** Default Shannon-entropy threshold (bits/char) that marks an opaque token secret. */ -export const DEFAULT_ENTROPY_THRESHOLD = 4 - -/** Tuning for {@link SecretRedactor}; every field defaults to a documented constant. */ -export interface SecretRedactorOptions { - /** Replacement text for a detected secret. */ - placeholder?: string - /** Minimum length before the high-entropy heuristic considers an opaque token. */ - minTokenLength?: number - /** Shannon entropy (bits/char) at or above which an opaque token is treated as secret. */ - entropyThreshold?: number -} - -/** - * Regexes for well-known credential shapes. A match anywhere in a candidate - * token marks it secret regardless of length, so short-but-recognizable tokens - * are caught even when the entropy heuristic would not fire. - */ -const KNOWN_SECRET_PATTERNS: readonly RegExp[] = [ - /sk-(?:ant-)?[A-Za-z0-9_-]{10,}/, // OpenAI / DeepSeek / Anthropic style - /gh[pousr]_[A-Za-z0-9]{16,}/, // GitHub personal/oauth/server/refresh tokens - /github_pat_[A-Za-z0-9_]{20,}/, // GitHub fine-grained PAT - /xox[baprs]-[A-Za-z0-9-]{10,}/, // Slack tokens - /AKIA[0-9A-Z]{16}/, // AWS access key id - /AIza[0-9A-Za-z_-]{35}/, // Google API key - /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/, // JWT -] - -/** - * Key names (normalized to lowercase, separators stripped) whose value is a - * secret. Split by match strategy so short/ambiguous words do not over-match: - * `author` must not trip the `auth` rule. - */ -const KEY_SUBSTRING_INDICATORS: readonly string[] = [ - 'password', 'passwd', 'passphrase', 'secret', 'apikey', 'apisecret', - 'clientsecret', 'privatekey', 'secretkey', 'accesskey', 'credential', - 'connectionstring', 'sastoken', 'xapikey', 'authtoken', 'accesstoken', - 'refreshtoken', 'idtoken', 'sessiontoken', 'bearertoken', -] -const KEY_SUFFIX_INDICATORS: readonly string[] = ['token'] -const KEY_EXACT_INDICATORS: readonly string[] = [ - 'auth', 'authorization', 'cookie', 'bearer', 'dsn', 'signature', -] - -/** - * Whether a key name marks its value as a secret. - * @param key - raw object key or assignment name. - * @returns whether the value under this key must be redacted. - */ -export function keyLooksSecret(key: string): boolean { - const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '') - if (normalized.length === 0) return false - if (KEY_SUBSTRING_INDICATORS.some(indicator => normalized.includes(indicator))) return true - if (KEY_SUFFIX_INDICATORS.some(indicator => normalized.endsWith(indicator))) return true - return KEY_EXACT_INDICATORS.includes(normalized) -} - -/** Shannon entropy in bits per character. */ -function shannonEntropy(value: string): number { - const counts = new Map() - for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1) - let entropy = 0 - for (const count of counts.values()) { - const probability = count / value.length - entropy -= probability * Math.log2(probability) - } - return entropy -} - -/** Opaque-token character set (base64/base64url plus common token punctuation). */ -const OPAQUE_TOKEN = /^[A-Za-z0-9+/=_.-]+$/ -/** Version-like leader kept visible (dependency pins, semver). */ -const VERSION_LIKE = /^v?\d+(?:\.\d+)+/ - -/** - * Conservative secret detector and redactor for telemetry content. - * Detection is a pure function of the input; construction only fixes tunables. - */ -export class SecretRedactor { - readonly #placeholder: string - readonly #minTokenLength: number - readonly #entropyThreshold: number - - /** @param options - placeholder text and heuristic thresholds. */ - constructor(options: SecretRedactorOptions = {}) { - this.#placeholder = options.placeholder ?? DEFAULT_REDACTION_PLACEHOLDER - this.#minTokenLength = options.minTokenLength ?? DEFAULT_MIN_TOKEN_LENGTH - this.#entropyThreshold = options.entropyThreshold ?? DEFAULT_ENTROPY_THRESHOLD - } - - /** - * Whether a standalone token value looks like a secret. - * @param value - candidate token, already trimmed of surrounding quotes. - * @returns whether the value should be redacted on its own merits. - */ - isSecretValue(value: string): boolean { - if (KNOWN_SECRET_PATTERNS.some(pattern => pattern.test(value))) return true - if (value.length < this.#minTokenLength) return false - if (!OPAQUE_TOKEN.test(value)) return false - // Git SHAs and integrity digests are hex and public — never a secret we hide. - if (/^[0-9a-fA-F]+$/.test(value)) return false - if (VERSION_LIKE.test(value)) return false - const classes = (/[a-z]/.test(value) ? 1 : 0) + (/[A-Z]/.test(value) ? 1 : 0) + (/[0-9]/.test(value) ? 1 : 0) - return classes >= 3 || shannonEntropy(value) >= this.#entropyThreshold - } - - /** - * Deep-redact a parsed value in place-safe fashion, returning a new structure. - * A secret-named key redacts its string value outright; every other string is - * judged on its own shape. Non-string leaves pass through untouched. - * @param value - parsed JSON-like value (object, array, or primitive). - * @returns a structurally identical value with secret strings replaced. - */ - redactValue(value: T): T { - return this.#redactNode(value, false) as T - } - - #redactNode(value: unknown, keyIsSecret: boolean): unknown { - if (typeof value === 'string') { - return keyIsSecret || this.isSecretValue(value) ? this.#placeholder : value - } - if (Array.isArray(value)) return value.map(item => this.#redactNode(item, false)) - if (value !== null && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value).map(([key, child]) => [key, this.#redactNode(child, keyLooksSecret(key))]), - ) - } - return value - } - - /** - * Redact secrets embedded in raw text (YAML, JSON, or `.env`-style content), - * preserving every line and key while replacing only secret-shaped values. - * @param text - raw file or message text. - * @returns text with detected secrets replaced by the placeholder. - */ - redactText(text: string): string { - let output = this.#redactPemBlocks(text) - output = this.#redactAssignments(output) - output = this.#redactUrlCredentials(output) - output = this.#redactBearerTokens(output) - return this.#redactStandaloneTokens(output) - } - - #redactPemBlocks(text: string): string { - return text.replace( - /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z ]+ )?PRIVATE KEY-----/g, - this.#placeholder, - ) - } - - #redactAssignments(text: string): string { - // `key: value`, `key = value`, or `"key": "value"` across YAML/JSON/.env. - return text.replace( - /("?)([A-Za-z0-9_.-]+)\1(\s*[:=]\s*)(["']?)([^\n\r"']+)\4/g, - (match, keyQuote: string, key: string, separator: string, valueQuote: string, value: string) => - keyLooksSecret(key) && value.trim().length > 0 - ? `${keyQuote}${key}${keyQuote}${separator}${valueQuote}${this.#placeholder}${valueQuote}` - : match, - ) - } - - #redactUrlCredentials(text: string): string { - // Redact only the password in `scheme://user:password@host`, keeping host visible. - return text.replace( - /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)(@)/gi, - (_match, prefix: string, _password: string, at: string) => `${prefix}${this.#placeholder}${at}`, - ) - } - - #redactBearerTokens(text: string): string { - // The candidate must contain a digit: real bearer credentials are never - // letters-only, while prose like "bearer authentication" is. - return text.replace( - /(bearer\s+)((?=[a-z._-]*[0-9])[a-z0-9._-]{8,})/gi, - (_match, prefix: string) => `${prefix}${this.#placeholder}`, - ) - } - - #redactStandaloneTokens(text: string): string { - // `/` is excluded so package names, file paths, and URLs are never split or - // redacted; a secret containing `/` is still scrubbed piecewise. - return text.replace(/[A-Za-z0-9][A-Za-z0-9+=_.-]{7,}/g, token => - this.isSecretValue(token) ? this.#placeholder : token) - } -} diff --git a/packages/scaffold/telemetry/tests/anonymous-id.spec.ts b/packages/scaffold/telemetry/tests/anonymous-id.spec.ts deleted file mode 100644 index 13ba3a8b76..0000000000 --- a/packages/scaffold/telemetry/tests/anonymous-id.spec.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { isAbsolute, join, resolve } from 'node:path' -import { defaultDshHome } from '@deepseek-ai/dsh-paths' -import { afterEach, describe, expect, it } from 'vitest' -import { - ANONYMOUS_ID_FILE_NAME, - getOrCreateAnonymousId, - globalConfigDir, -} from '@deepseek-ai/dsh-telemetry' - -const dirs: string[] = [] - -async function tempDir(): Promise { - const dir = await mkdtemp(join(tmpdir(), 'dsh-anon-')) - dirs.push(dir) - return dir -} - -afterEach(async () => { - await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) -}) - -const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - -describe('globalConfigDir', () => { - it('prefers an explicit DSH_HOME override', () => { - expect(globalConfigDir({ env: { DSH_HOME: '/custom/dsh' } })).toBe(resolve('/custom/dsh')) - }) - - it('falls back to ~/.dsh when DSH_HOME is unset', () => { - expect(globalConfigDir({ env: {} })).toBe(resolve(defaultDshHome())) - }) - - it('reads process.env by default', () => { - // No override supplied: the call must not throw and must return an absolute path. - // The ambient DSH_HOME is unknown here, so assert only the invariant the - // resolver guarantees rather than a specific location. - expect(isAbsolute(globalConfigDir())).toBe(true) - }) -}) - -describe('getOrCreateAnonymousId', () => { - it('creates, persists, and returns a UUID on first use', async () => { - const dir = await tempDir() - const id = await getOrCreateAnonymousId({ env: { DSH_HOME: dir } }) - expect(id).toMatch(UUID) - const stored: unknown = JSON.parse(await readFile(join(dir, ANONYMOUS_ID_FILE_NAME), 'utf8')) - expect(stored).toEqual({ anonymousId: id }) - }) - - it('returns the same persisted id on subsequent calls', async () => { - const dir = await tempDir() - const first = await getOrCreateAnonymousId({ env: { DSH_HOME: dir } }) - const second = await getOrCreateAnonymousId({ env: { DSH_HOME: dir } }) - expect(second).toBe(first) - }) - - it('uses the injected UUID generator', async () => { - const dir = await tempDir() - const id = await getOrCreateAnonymousId({ - env: { DSH_HOME: dir }, - randomUUID: () => '00000000-0000-4000-8000-000000000000', - }) - expect(id).toBe('00000000-0000-4000-8000-000000000000') - }) - - it('regenerates when the stored file is corrupt JSON', async () => { - const dir = await tempDir() - await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), 'not json', 'utf8') - const id = await getOrCreateAnonymousId({ env: { DSH_HOME: dir } }) - expect(id).toMatch(UUID) - }) - - it('regenerates when the stored value is not a valid UUID or object', async () => { - const dir = await tempDir() - await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), JSON.stringify({ anonymousId: 'nope' }), 'utf8') - expect(await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })).toMatch(UUID) - await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), '123', 'utf8') - expect(await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })).toMatch(UUID) - }) - - it('returns a usable id even when persistence fails', async () => { - const dir = await tempDir() - // A regular file where a directory is expected makes mkdir/writeFile fail. - await writeFile(join(dir, 'blocker'), 'x', 'utf8') - const id = await getOrCreateAnonymousId({ env: { DSH_HOME: join(dir, 'blocker') } }) - expect(id).toMatch(UUID) - }) -}) diff --git a/packages/scaffold/telemetry/tests/consent-resolver.spec.ts b/packages/scaffold/telemetry/tests/consent-resolver.spec.ts deleted file mode 100644 index b9c9300e78..0000000000 --- a/packages/scaffold/telemetry/tests/consent-resolver.spec.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { ConsentResolver, DEFAULT_TELEMETRY_PLUGIN_NAME, type ConsentDecision } from '@deepseek-ai/dsh-telemetry' - -const dirs: string[] = [] - -async function projectDir(cordisYml?: string): Promise { - const dir = await mkdtemp(join(tmpdir(), 'dsh-consent-')) - dirs.push(dir) - if (cordisYml !== undefined) await writeFile(join(dir, 'cordis.yml'), cordisYml, 'utf8') - return dir -} - -afterEach(async () => { - await Promise.all(dirs.splice(0).map(dir => import('node:fs/promises').then(fs => fs.rm(dir, { recursive: true, force: true })))) -}) - -const enabledYml = `- id: telemetry\n name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'\n` - -describe('ConsentResolver environment opt-out', () => { - it('denies when DO_NOT_TRACK is set', async () => { - const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '1' } }).resolve(await projectDir(enabledYml)) - expect(decision).toEqual({ allowed: false, reason: 'do-not-track' }) - }) - - it('denies when CI is set', async () => { - const decision = await new ConsentResolver({ env: { CI: 'true' } }).resolve(await projectDir(enabledYml)) - expect(decision).toEqual({ allowed: false, reason: 'ci' }) - }) - - it('ignores falsy env values and continues to the file', async () => { - const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '0', CI: 'false' } }) - .resolve(await projectDir(enabledYml)) - expect(decision).toEqual({ allowed: true, reason: 'enabled' }) - }) - - it('can be told to ignore env opt-out signals', async () => { - const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '1' }, honorEnvOptOut: false }) - .resolve(await projectDir(enabledYml)) - expect(decision).toEqual({ allowed: true, reason: 'enabled' }) - }) - - it('reads process.env by default', async () => { - const saved = { CI: process.env.CI, DO_NOT_TRACK: process.env.DO_NOT_TRACK } - delete process.env.CI - delete process.env.DO_NOT_TRACK - try { - const decision = await new ConsentResolver().resolve(await projectDir(enabledYml)) - expect(decision).toEqual({ allowed: true, reason: 'enabled' }) - } finally { - if (saved.CI !== undefined) process.env.CI = saved.CI - if (saved.DO_NOT_TRACK !== undefined) process.env.DO_NOT_TRACK = saved.DO_NOT_TRACK - } - }) -}) - -describe('ConsentResolver cordis.yml state', () => { - const resolver = new ConsentResolver({ env: {} }) - - it('allows when the telemetry entry is enabled', async () => { - expect(await resolver.resolve(await projectDir(enabledYml))) - .toEqual({ allowed: true, reason: 'enabled' }) - }) - - it('denies when the telemetry entry is disabled', async () => { - const yml = `- id: telemetry\n name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'\n disabled: true\n` - expect(await resolver.resolve(await projectDir(yml))) - .toEqual({ allowed: false, reason: 'disabled' }) - }) - - it('tolerates !!js expression tags while reading plain scalars', async () => { - const yml = [ - '- id: telemetry', - ` name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'`, - '- id: llm', - ' name: \'@deepseek-ai/dsh-llm-deepseek\'', - ' config:', - ' apiKeyEnv: DEEPSEEK_API_KEY', - ' model: !!js process.env.DEEPSEEK_MODEL', - '', - ].join('\n') - expect(await resolver.resolve(await projectDir(yml))) - .toEqual({ allowed: true, reason: 'enabled' }) - }) - - it('reports (allows) when cordis.yml has no telemetry entry', async () => { - const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n' - expect(await resolver.resolve(await projectDir(yml))) - .toEqual({ allowed: true, reason: 'absent' }) - }) - - it('can be told to deny when the entry is absent', async () => { - const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n' - const decision = await new ConsentResolver({ env: {}, allowWhenEntryAbsent: false }).resolve(await projectDir(yml)) - expect(decision).toEqual({ allowed: false, reason: 'absent' }) - }) - - it('skips non-object sequence items and a non-sequence root, still reporting absent', async () => { - expect(await resolver.resolve(await projectDir('- just-a-string\n- id: x\n name: y\n'))) - .toEqual({ allowed: true, reason: 'absent' }) - expect(await resolver.resolve(await projectDir('root: not-a-sequence\n'))) - .toEqual({ allowed: true, reason: 'absent' }) - }) - - it('honors a custom telemetry plugin name', async () => { - const yml = '- id: t\n name: \'my-consent-marker\'\n' - const decision = await new ConsentResolver({ env: {}, telemetryPluginName: 'my-consent-marker' }) - .resolve(await projectDir(yml)) - expect(decision).toEqual({ allowed: true, reason: 'enabled' }) - }) -}) - -describe('ConsentResolver missing or unreadable cordis.yml', () => { - it('reports no-config and allows by default on first init', async () => { - expect(await new ConsentResolver({ env: {} }).resolve(await projectDir())) - .toEqual({ allowed: true, reason: 'no-config' }) - }) - - it('can deny on first init', async () => { - const decision = await new ConsentResolver({ env: {}, allowWhenNoConfig: false }).resolve(await projectDir()) - expect(decision).toEqual({ allowed: false, reason: 'no-config' }) - }) - - it('denies with an unreadable reason when cordis.yml is not a regular file', async () => { - const dir = await projectDir() - await mkdir(join(dir, 'cordis.yml')) // a directory where the resolver expects a file - expect(await new ConsentResolver({ env: {} }).resolve(dir)) - .toEqual({ allowed: false, reason: 'unreadable' }) - }) -}) diff --git a/packages/scaffold/telemetry/tests/payload.spec.ts b/packages/scaffold/telemetry/tests/payload.spec.ts deleted file mode 100644 index ed3ab2f82f..0000000000 --- a/packages/scaffold/telemetry/tests/payload.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { SecretRedactor, buildTelemetryPayload } from '@deepseek-ai/dsh-telemetry' - -const dirs: string[] = [] - -async function projectDir(files: Record): Promise { - const dir = await mkdtemp(join(tmpdir(), 'dsh-payload-')) - dirs.push(dir) - await Promise.all(Object.entries(files).map(([name, content]) => writeFile(join(dir, name), content, 'utf8'))) - return dir -} - -afterEach(async () => { - await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) -}) - -describe('buildTelemetryPayload', () => { - it('carries lifecycle facts and redacted file content', async () => { - const dir = await projectDir({ - 'cordis.yml': '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n config:\n apiKey: sk-abcdefghij1234567890\n', - 'package.json': '{ "name": "my-app", "config": { "token": "sk-abcdefghij1234567890" } }', - }) - const payload = await buildTelemetryPayload({ command: 'build', durationMs: 42, success: true, projectDir: dir }) - expect(payload.command).toBe('build') - expect(payload.durationMs).toBe(42) - expect(payload.success).toBe(true) - expect(payload.cordisYmlContent).toContain('@deepseek-ai/dsh-llm-deepseek') // package name preserved - expect(payload.cordisYmlContent).not.toContain('sk-abcdefghij1234567890') // secret scrubbed - expect(payload.packageJsonContent).toContain('my-app') - expect(payload.packageJsonContent).not.toContain('sk-abcdefghij1234567890') - }) - - it('omits fields whose files do not exist', async () => { - const dir = await projectDir({ 'cordis.yml': '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n' }) - const payload = await buildTelemetryPayload({ command: 'create', durationMs: 1, success: false, projectDir: dir }) - expect(payload.cordisYmlContent).toBeDefined() - expect('packageJsonContent' in payload).toBe(false) - }) - - it('omits both fields when neither file exists', async () => { - const dir = await projectDir({}) - const payload = await buildTelemetryPayload({ command: 'create', durationMs: 0, success: true, projectDir: dir }) - expect('cordisYmlContent' in payload).toBe(false) - expect('packageJsonContent' in payload).toBe(false) - }) - - it('withholds package.json when cordis.yml is absent (not an SDK project)', async () => { - const dir = await projectDir({ 'package.json': '{ "name": "unrelated-repo" }' }) - const payload = await buildTelemetryPayload({ command: 'build', durationMs: 3, success: false, projectDir: dir }) - expect('cordisYmlContent' in payload).toBe(false) - expect('packageJsonContent' in payload).toBe(false) - }) - - it('uses a supplied redactor', async () => { - const dir = await projectDir({ - 'cordis.yml': '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n', - 'package.json': '{ "password": "hunter2" }', - }) - const redactor = new SecretRedactor({ placeholder: '<>' }) - const payload = await buildTelemetryPayload({ - command: 'config', durationMs: 5, success: true, projectDir: dir, redactor, - }) - expect(payload.packageJsonContent).toContain('<>') - expect(payload.packageJsonContent).not.toContain('hunter2') - }) -}) diff --git a/packages/scaffold/telemetry/tests/reporter.spec.ts b/packages/scaffold/telemetry/tests/reporter.spec.ts deleted file mode 100644 index 5d8a490b9e..0000000000 --- a/packages/scaffold/telemetry/tests/reporter.spec.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - DSH_TELEMETRY_ENDPOINT, - SecretRedactor, - TELEMETRY_SCHEMA_VERSION, - TelemetryReporter, - type AnonymousId, - type ConsentDecision, - type TelemetryPayload, -} from '@deepseek-ai/dsh-telemetry' - -const ALLOW: ConsentDecision = { allowed: true, reason: 'enabled' } -const DENY: ConsentDecision = { allowed: false, reason: 'disabled' } -const anon = (value = 'anon-123'): (() => Promise) => async () => value as AnonymousId - -function okResponse(): Response { - return { ok: true } as Response -} - -describe('TelemetryReporter.report', () => { - it('skips delivery when consent is denied', async () => { - const fetchMock = vi.fn(async () => okResponse()) - const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon() }) - reporter.report({ command: 'build', durationMs: 1, success: true }, DENY) - await reporter.flush(50) - expect(fetchMock).not.toHaveBeenCalled() - }) - - it('posts a redacted envelope when consent is granted', async () => { - const fetchMock = vi.fn(() => Promise.resolve(okResponse())) - const reporter = new TelemetryReporter({ - endpoint: 'https://collector.test/telemetry', - fetch: fetchMock, - anonymousId: anon('anon-xyz'), - redactor: new SecretRedactor(), - now: () => 0, - timeoutMs: 100, - }) - const payload: TelemetryPayload = { - command: 'config', - durationMs: 7, - success: true, - cordisYmlContent: 'apiKey: sk-abcdefghij1234567890\nname: \'@deepseek-ai/dsh-llm-deepseek\'\n', - packageJsonContent: '{ "name": "app" }', - } - reporter.report(payload, ALLOW) - await reporter.flush(50) - - expect(fetchMock).toHaveBeenCalledTimes(1) - const call = fetchMock.mock.calls[0]! - expect(call[0]).toBe('https://collector.test/telemetry') - const init = call[1]! - expect(init.method).toBe('POST') - const body = JSON.parse(init.body as string) as Record - expect(body.schemaVersion).toBe(TELEMETRY_SCHEMA_VERSION) - expect(body.anonymousId).toBe('anon-xyz') - expect(body.sentAt).toBe('1970-01-01T00:00:00.000Z') - expect(body.command).toBe('config') - expect(body.cordisYmlContent).not.toContain('sk-abcdefghij1234567890') - expect(body.cordisYmlContent).toContain('@deepseek-ai/dsh-llm-deepseek') - expect(body.packageJsonContent).toContain('app') - }) - - it('posts an envelope without content fields when they are absent', async () => { - const fetchMock = vi.fn(() => Promise.resolve(okResponse())) - const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), now: () => 0, timeoutMs: 100 }) - reporter.report({ command: 'start', durationMs: 2, success: true }, ALLOW) - await reporter.flush(50) - const body = JSON.parse(fetchMock.mock.calls[0]![1]!.body as string) as Record - expect('cordisYmlContent' in body).toBe(false) - expect('packageJsonContent' in body).toBe(false) - }) - - it('swallows a non-OK HTTP status', async () => { - const fetchMock = vi.fn(async () => ({ ok: false, status: 503 } as Response)) - const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 }) - reporter.report({ command: 'dev', durationMs: 3, success: true }, ALLOW) - await expect(reporter.flush(50)).resolves.toBeUndefined() - expect(fetchMock).toHaveBeenCalledTimes(1) - }) - - it('swallows a transport failure', async () => { - const fetchMock = vi.fn(async () => { throw new Error('network down') }) - const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 }) - reporter.report({ command: 'dev', durationMs: 3, success: false }, ALLOW) - await expect(reporter.flush(50)).resolves.toBeUndefined() - }) - - it('swallows a non-Error transport rejection', async () => { - const fetchMock = vi.fn(async () => { throw 'boom' }) - const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 }) - reporter.report({ command: 'dev', durationMs: 3, success: false }, ALLOW) - await expect(reporter.flush(50)).resolves.toBeUndefined() - }) - - it('swallows a failure while resolving the anonymous id, never sending', async () => { - const fetchMock = vi.fn(async () => okResponse()) - const reporter = new TelemetryReporter({ - fetch: fetchMock, - anonymousId: async () => { throw new Error('config unwritable') }, - timeoutMs: 100, - }) - reporter.report({ command: 'build', durationMs: 1, success: true }, ALLOW) - await reporter.flush(50) - expect(fetchMock).not.toHaveBeenCalled() - }) -}) - -describe('TelemetryReporter.flush', () => { - it('returns immediately when nothing is in flight', async () => { - const reporter = new TelemetryReporter({ fetch: vi.fn(async () => okResponse()), anonymousId: anon() }) - await expect(reporter.flush()).resolves.toBeUndefined() - }) - - it('resolves on the timeout cap when a send never settles', async () => { - const reporter = new TelemetryReporter({ - fetch: () => new Promise(() => {}), - anonymousId: anon(), - timeoutMs: 10, - }) - reporter.report({ command: 'start', durationMs: 1, success: true }, ALLOW) - const started = Date.now() - await reporter.flush(15) - expect(Date.now() - started).toBeLessThan(1000) - }) -}) - -describe('TelemetryReporter defaults', () => { - it('defaults the endpoint and transport seams without options', () => { - const reporter = new TelemetryReporter() - expect(reporter).toBeInstanceOf(TelemetryReporter) - expect(DSH_TELEMETRY_ENDPOINT).toContain('.invalid') - }) -}) diff --git a/packages/scaffold/telemetry/tests/secret-redactor.spec.ts b/packages/scaffold/telemetry/tests/secret-redactor.spec.ts deleted file mode 100644 index 77d89d968e..0000000000 --- a/packages/scaffold/telemetry/tests/secret-redactor.spec.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - DEFAULT_ENTROPY_THRESHOLD, - DEFAULT_MIN_TOKEN_LENGTH, - DEFAULT_REDACTION_PLACEHOLDER, - SecretRedactor, - keyLooksSecret, -} from '@deepseek-ai/dsh-telemetry' - -const REDACTED = DEFAULT_REDACTION_PLACEHOLDER - -describe('exported defaults', () => { - it('expose the documented tunable defaults', () => { - expect(DEFAULT_REDACTION_PLACEHOLDER).toBe('[REDACTED]') - expect(DEFAULT_MIN_TOKEN_LENGTH).toBe(24) - expect(DEFAULT_ENTROPY_THRESHOLD).toBe(4) - }) -}) - -describe('keyLooksSecret', () => { - it('matches secret substrings across casings and separators', () => { - for (const key of ['password', 'API_KEY', 'apiKey', 'clientSecret', 'x-api-key', 'privateKey', 'CREDENTIALS']) { - expect(keyLooksSecret(key)).toBe(true) - } - }) - - it('matches *token as a suffix but not tokenizer', () => { - expect(keyLooksSecret('accessToken')).toBe(true) - expect(keyLooksSecret('token')).toBe(true) - expect(keyLooksSecret('tokenizer')).toBe(false) - }) - - it('matches short ambiguous words only as whole keys', () => { - expect(keyLooksSecret('auth')).toBe(true) - expect(keyLooksSecret('authorization')).toBe(true) - expect(keyLooksSecret('cookie')).toBe(true) - expect(keyLooksSecret('author')).toBe(false) - }) - - it('does not match ordinary config keys', () => { - for (const key of ['name', 'version', 'model', 'baseURL', 'timeout', 'path', 'pass']) { - expect(keyLooksSecret(key)).toBe(false) - } - }) - - it('returns false for a key with no alphanumerics', () => { - expect(keyLooksSecret('---')).toBe(false) - }) -}) - -describe('SecretRedactor.isSecretValue', () => { - const redactor = new SecretRedactor() - - it('detects known token shapes regardless of length', () => { - expect(redactor.isSecretValue('sk-abcdefghij1234567890')).toBe(true) - expect(redactor.isSecretValue('sk-ant-abcdefghij1234567890')).toBe(true) - expect(redactor.isSecretValue('ghp_abcdefghijklmnop1234')).toBe(true) - expect(redactor.isSecretValue('github_pat_abcdefghijklmnopqrst')).toBe(true) - expect(redactor.isSecretValue('xoxb-abcdefghij-klmno')).toBe(true) - expect(redactor.isSecretValue('AKIA1234567890ABCDEF')).toBe(true) - expect(redactor.isSecretValue(`AIza${'a'.repeat(35)}`)).toBe(true) - expect(redactor.isSecretValue('eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijklmnop')).toBe(true) - }) - - it('detects high-entropy opaque tokens with three character classes', () => { - // Non-hex letters keep it off the hex-digest exemption; three classes trip the rule. - expect(redactor.isSecretValue('zX9zX9zX9zX9zX9zX9zX9zX9')).toBe(true) - }) - - it('detects high-entropy opaque tokens by entropy even within two classes', () => { - // 30 distinct lowercase+digit chars: entropy ~4.9, only two classes. - const token = 'abcdefghijklmnopqrstuvwxyz0123' - expect(token.length).toBeGreaterThanOrEqual(DEFAULT_MIN_TOKEN_LENGTH) - expect(redactor.isSecretValue(token)).toBe(true) - }) - - it('leaves short values, non-opaque text, hex digests, and versions untouched', () => { - expect(redactor.isSecretValue('deepseek-chat')).toBe(false) // short - expect(redactor.isSecretValue('a token with spaces here!!')).toBe(false) // not opaque - expect(redactor.isSecretValue('a'.repeat(40))).toBe(false) // low entropy, one class - expect(redactor.isSecretValue('abcdef0123456789abcdef0123456789abcdef01')).toBe(false) // 40-hex git SHA - expect(redactor.isSecretValue('1.2.3.4.5.6.7.8.9.10.11.12')).toBe(false) // version-like - expect(redactor.isSecretValue('ZXQPZXQPZXQPZXQPZXQPZXQP')).toBe(false) // uppercase only, low entropy - }) - - it('honors a custom entropy threshold', () => { - const strict = new SecretRedactor({ entropyThreshold: 100 }) - // Two-class token can no longer trip the entropy branch under an impossible threshold. - expect(strict.isSecretValue('abcdefghijklmnopqrstuvwxyz0123')).toBe(false) - }) -}) - -describe('SecretRedactor.redactValue', () => { - const redactor = new SecretRedactor() - - it('redacts secret-keyed strings and secret-shaped strings, keeping structure', () => { - const result = redactor.redactValue({ - apiKey: 'short-not-shaped', - name: 'my-package', - token: 'sk-abcdefghij1234567890', - count: 3, - enabled: true, - missing: null, - nested: { password: 'p', note: 'plain text value' }, - list: ['harmless', 'sk-abcdefghij1234567890'], - }) - expect(result).toEqual({ - apiKey: REDACTED, // redacted by key even though the value is not secret-shaped - name: 'my-package', - token: REDACTED, - count: 3, - enabled: true, - missing: null, - nested: { password: REDACTED, note: 'plain text value' }, - list: ['harmless', REDACTED], - }) - }) - - it('redacts a top-level secret string and passes through primitives', () => { - expect(redactor.redactValue('sk-abcdefghij1234567890')).toBe(REDACTED) - expect(redactor.redactValue('plain')).toBe('plain') - expect(redactor.redactValue(42)).toBe(42) - expect(redactor.redactValue(null)).toBeNull() - }) -}) - -describe('SecretRedactor.redactText', () => { - const redactor = new SecretRedactor() - - it('redacts PEM private key blocks', () => { - const text = '-----BEGIN RSA PRIVATE KEY-----\nMIIabc\ndef==\n-----END RSA PRIVATE KEY-----' - expect(redactor.redactText(text)).toBe(REDACTED) - }) - - it('redacts secret-keyed assignments across YAML, JSON, and .env', () => { - expect(redactor.redactText('password: hunter2')).toBe(`password: ${REDACTED}`) - expect(redactor.redactText('apiKey: "sk-abcdefghij1234567890"')).toBe(`apiKey: "${REDACTED}"`) - expect(redactor.redactText('"token": "abcdefgh"')).toBe(`"token": "${REDACTED}"`) - expect(redactor.redactText('API_KEY=sk-abcdefghij1234567890')).toBe(`API_KEY=${REDACTED}`) - }) - - it('keeps non-secret assignments and whitespace-only secret values intact', () => { - expect(redactor.redactText('model: deepseek-chat')).toBe('model: deepseek-chat') - expect(redactor.redactText('password: \n')).toBe('password: \n') - }) - - it('redacts only the password in URL credentials, keeping the host', () => { - expect(redactor.redactText('url: https://user:s3cretPass@api.deepseek.com/v1')) - .toBe(`url: https://user:${REDACTED}@api.deepseek.com/v1`) - }) - - it('redacts bearer tokens embedded in free text', () => { - expect(redactor.redactText('sending Bearer abcdefgh12345678 now')) - .toBe(`sending Bearer ${REDACTED} now`) - }) - - it('keeps letters-only prose after the word bearer intact', () => { - expect(redactor.redactText('uses bearer authentication for requests')) - .toBe('uses bearer authentication for requests') - expect(redactor.redactText('"description": "bearer token-helper middleware"')) - .toBe('"description": "bearer token-helper middleware"') - }) - - it('redacts standalone secret-shaped tokens while keeping package names and paths', () => { - expect(redactor.redactText('key sk-abcdefghij1234567890 end')) - .toBe(`key ${REDACTED} end`) - expect(redactor.redactText('name: @deepseek-ai/dsh-telemetry')).toBe('name: @deepseek-ai/dsh-telemetry') - expect(redactor.redactText('path: ./plugins/local-plugin/src/index.ts')) - .toBe('path: ./plugins/local-plugin/src/index.ts') - }) - - it('is idempotent on already-redacted text', () => { - const once = redactor.redactText('password: hunter2') - expect(redactor.redactText(once)).toBe(once) - }) -}) diff --git a/packages/scaffold/telemetry/tsconfig.json b/packages/scaffold/telemetry/tsconfig.json deleted file mode 100644 index 3d97e58b03..0000000000 --- a/packages/scaffold/telemetry/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { "path": "../../util/brand" }, - { "path": "../../util/paths" }, - { "path": "../../support/invariants" } - ] -} diff --git a/packages/scaffold/README.i18n.yaml b/packages/sdk/README.i18n.yaml similarity index 57% rename from packages/scaffold/README.i18n.yaml rename to packages/sdk/README.i18n.yaml index 9cc0926043..c9fc6e2600 100644 --- a/packages/scaffold/README.i18n.yaml +++ b/packages/sdk/README.i18n.yaml @@ -1,6 +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 packages/scaffold/README.md -README.md: f36e828a507aea80a064de7d7de30ba65d63600b -README.zh.md: f58d8c8defeea948d59c3b11b4c60703b8144ea8 +# pnpm run verify-translation-pairing --write packages/sdk/README.md +README.md: 052ac933defae8766e99d79b79bc4cdc6c6d74db +README.zh.md: 364f752fcbed06cae8b0675cae852be2760bd3f4 diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 0000000000..052ac933de --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,11 @@ +# sdk/ — drive Harness runtimes from another process + +English | [中文](README.zh.md) + +This group contains the protocol stack for driving a Harness runtime from another process. Callers supply the runtime executable and its `cordis.yml`; this group does not create, configure, build, or launch developer projects. The [TypeScript SDK decision](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md) owns the client contract, and the [toolchain removal](../../.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.md) owns the product boundary. + +| Package | Role | +|---|---| +| [`protocol/`](protocol/README.md) | Defines the SDK runtime wire protocol | +| [`client/`](client/README.md) | Drives a Harness runtime through the TypeScript client API | +| [`server/`](server/README.md) | Serves out-of-process SDK clients over stdio JSON-RPC | diff --git a/packages/sdk/README.zh.md b/packages/sdk/README.zh.md new file mode 100644 index 0000000000..364f752fcb --- /dev/null +++ b/packages/sdk/README.zh.md @@ -0,0 +1,11 @@ +# sdk/:从另一进程驱动 Harness 运行时 + +[English](README.md) | 中文 + +本组包含用于从另一进程驱动 Harness 运行时的协议栈。调用方提供运行时可执行文件及其 `cordis.yml`;本组不创建、配置、构建或启动开发者项目。[TypeScript SDK 决策](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md)负责客户端约定,[工具链移除](../../.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.md)负责产品边界。 + +| 包 | 职责 | +|---|---| +| [`protocol/`](protocol/README.md) | 定义 SDK 运行时通信协议 | +| [`client/`](client/README.md) | 通过 TypeScript 客户端 API 驱动 Harness 运行时 | +| [`server/`](server/README.md) | 通过 stdio JSON-RPC 为进程外 SDK 客户端提供服务 | diff --git a/packages/scaffold/client/README.i18n.yaml b/packages/sdk/client/README.i18n.yaml similarity index 80% rename from packages/scaffold/client/README.i18n.yaml rename to packages/sdk/client/README.i18n.yaml index c08016649d..da2067692b 100644 --- a/packages/scaffold/client/README.i18n.yaml +++ b/packages/sdk/client/README.i18n.yaml @@ -1,6 +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 packages/scaffold/client/README.md +# pnpm run verify-translation-pairing --write packages/sdk/client/README.md README.md: b33457875f81d11d09bab2e5aa5ce730e233c78a README.zh.md: 271f07ffb0f97abe005971962beb517acfdc05a4 diff --git a/packages/scaffold/client/README.md b/packages/sdk/client/README.md similarity index 100% rename from packages/scaffold/client/README.md rename to packages/sdk/client/README.md diff --git a/packages/scaffold/client/README.zh.md b/packages/sdk/client/README.zh.md similarity index 100% rename from packages/scaffold/client/README.zh.md rename to packages/sdk/client/README.zh.md diff --git a/packages/scaffold/client/package.json b/packages/sdk/client/package.json similarity index 96% rename from packages/scaffold/client/package.json rename to packages/sdk/client/package.json index 796a32d5c0..f767cb1da9 100644 --- a/packages/scaffold/client/package.json +++ b/packages/sdk/client/package.json @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/scaffold/client" + "directory": "packages/sdk/client" }, "type": "module", "main": "lib/index.js", diff --git a/packages/scaffold/client/src/api.ts b/packages/sdk/client/src/api.ts similarity index 100% rename from packages/scaffold/client/src/api.ts rename to packages/sdk/client/src/api.ts diff --git a/packages/scaffold/client/src/client.ts b/packages/sdk/client/src/client.ts similarity index 100% rename from packages/scaffold/client/src/client.ts rename to packages/sdk/client/src/client.ts diff --git a/packages/scaffold/client/src/dispose.ts b/packages/sdk/client/src/dispose.ts similarity index 100% rename from packages/scaffold/client/src/dispose.ts rename to packages/sdk/client/src/dispose.ts diff --git a/packages/scaffold/client/src/index.ts b/packages/sdk/client/src/index.ts similarity index 100% rename from packages/scaffold/client/src/index.ts rename to packages/sdk/client/src/index.ts diff --git a/packages/scaffold/client/src/invariant.ts b/packages/sdk/client/src/invariant.ts similarity index 100% rename from packages/scaffold/client/src/invariant.ts rename to packages/sdk/client/src/invariant.ts diff --git a/packages/scaffold/client/src/types.ts b/packages/sdk/client/src/types.ts similarity index 100% rename from packages/scaffold/client/src/types.ts rename to packages/sdk/client/src/types.ts diff --git a/packages/scaffold/client/tests/dispose.spec.ts b/packages/sdk/client/tests/dispose.spec.ts similarity index 100% rename from packages/scaffold/client/tests/dispose.spec.ts rename to packages/sdk/client/tests/dispose.spec.ts diff --git a/packages/scaffold/client/tests/fake-runtime.ts b/packages/sdk/client/tests/fake-runtime.ts similarity index 100% rename from packages/scaffold/client/tests/fake-runtime.ts rename to packages/sdk/client/tests/fake-runtime.ts diff --git a/packages/scaffold/client/tests/sdk-client.spec.ts b/packages/sdk/client/tests/sdk-client.spec.ts similarity index 100% rename from packages/scaffold/client/tests/sdk-client.spec.ts rename to packages/sdk/client/tests/sdk-client.spec.ts diff --git a/packages/scaffold/client/tsconfig.json b/packages/sdk/client/tsconfig.json similarity index 100% rename from packages/scaffold/client/tsconfig.json rename to packages/sdk/client/tsconfig.json diff --git a/packages/scaffold/protocol/README.i18n.yaml b/packages/sdk/protocol/README.i18n.yaml similarity index 80% rename from packages/scaffold/protocol/README.i18n.yaml rename to packages/sdk/protocol/README.i18n.yaml index 541155d37c..5b7608b650 100644 --- a/packages/scaffold/protocol/README.i18n.yaml +++ b/packages/sdk/protocol/README.i18n.yaml @@ -1,6 +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 packages/scaffold/protocol/README.md +# pnpm run verify-translation-pairing --write packages/sdk/protocol/README.md README.md: 082a890454f900aec51df123669f28814d39d601 README.zh.md: d9b8460e51b5313f4c3a8ac66471e8cd39142430 diff --git a/packages/scaffold/protocol/README.md b/packages/sdk/protocol/README.md similarity index 100% rename from packages/scaffold/protocol/README.md rename to packages/sdk/protocol/README.md diff --git a/packages/scaffold/protocol/README.zh.md b/packages/sdk/protocol/README.zh.md similarity index 100% rename from packages/scaffold/protocol/README.zh.md rename to packages/sdk/protocol/README.zh.md diff --git a/packages/scaffold/protocol/package.json b/packages/sdk/protocol/package.json similarity index 96% rename from packages/scaffold/protocol/package.json rename to packages/sdk/protocol/package.json index 42c326edf8..b08eb9709e 100644 --- a/packages/scaffold/protocol/package.json +++ b/packages/sdk/protocol/package.json @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/scaffold/protocol" + "directory": "packages/sdk/protocol" }, "type": "module", "main": "lib/index.js", diff --git a/packages/scaffold/protocol/src/index.ts b/packages/sdk/protocol/src/index.ts similarity index 100% rename from packages/scaffold/protocol/src/index.ts rename to packages/sdk/protocol/src/index.ts diff --git a/packages/scaffold/protocol/src/invariant.ts b/packages/sdk/protocol/src/invariant.ts similarity index 100% rename from packages/scaffold/protocol/src/invariant.ts rename to packages/sdk/protocol/src/invariant.ts diff --git a/packages/scaffold/protocol/src/transport.ts b/packages/sdk/protocol/src/transport.ts similarity index 100% rename from packages/scaffold/protocol/src/transport.ts rename to packages/sdk/protocol/src/transport.ts diff --git a/packages/scaffold/protocol/src/types.ts b/packages/sdk/protocol/src/types.ts similarity index 100% rename from packages/scaffold/protocol/src/types.ts rename to packages/sdk/protocol/src/types.ts diff --git a/packages/scaffold/protocol/tests/transport.spec.ts b/packages/sdk/protocol/tests/transport.spec.ts similarity index 100% rename from packages/scaffold/protocol/tests/transport.spec.ts rename to packages/sdk/protocol/tests/transport.spec.ts diff --git a/packages/scaffold/protocol/tsconfig.json b/packages/sdk/protocol/tsconfig.json similarity index 100% rename from packages/scaffold/protocol/tsconfig.json rename to packages/sdk/protocol/tsconfig.json diff --git a/packages/scaffold/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml similarity index 56% rename from packages/scaffold/server/README.i18n.yaml rename to packages/sdk/server/README.i18n.yaml index d6c24c3566..b481e64095 100644 --- a/packages/scaffold/server/README.i18n.yaml +++ b/packages/sdk/server/README.i18n.yaml @@ -1,6 +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 packages/scaffold/server/README.md -README.md: 9c53946f4112e9ce8e17590c2ff91fb4b8c2ef74 -README.zh.md: 270691f859426dbdc6ca9d8f05049d290eaa8e1b +# pnpm run verify-translation-pairing --write packages/sdk/server/README.md +README.md: c5a7da2dd2ee962bd4d7cac7063ae1191cab3324 +README.zh.md: f1e425bc90df4119db9b1ffad7c3ef454369cb4c diff --git a/packages/scaffold/server/README.md b/packages/sdk/server/README.md similarity index 95% rename from packages/scaffold/server/README.md rename to packages/sdk/server/README.md index 9c53946f41..c5a7da2dd2 100644 --- a/packages/scaffold/server/README.md +++ b/packages/sdk/server/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-process SDK clients can drive harness agents. [`HarnessSdkServer`](src/server.ts) owns the protocol methods and notifications; the transport and the named wire types live in [`dsh-sdk-protocol`](../../scaffold/protocol/README.md), shared with the client SDKs; [`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) supplies the surrounding `cordis.yml` application. +The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-process SDK clients can drive harness agents. [`HarnessSdkServer`](src/server.ts) owns the protocol methods and notifications; the transport and the named wire types live in [`dsh-sdk-protocol`](../protocol/README.md), shared with the client SDKs; [`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) supplies the surrounding `cordis.yml` application. ## Wiring diff --git a/packages/scaffold/server/README.zh.md b/packages/sdk/server/README.zh.md similarity index 95% rename from packages/scaffold/server/README.zh.md rename to packages/sdk/server/README.zh.md index 270691f859..f1e425bc90 100644 --- a/packages/scaffold/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`jsonrpc` 插件通过 stdio 提供以换行符分隔的 JSON-RPC,使进程外 SDK 客户端能够驱动 harness agent(智能体)。[`HarnessSdkServer`](src/server.ts) 负责协议方法和通知;传输与具名协议类型位于 [`dsh-sdk-protocol`](../../scaffold/protocol/README.md),与客户端 SDK 共享;[`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) 提供外围的 `cordis.yml` 应用。 +`jsonrpc` 插件通过 stdio 提供以换行符分隔的 JSON-RPC,使进程外 SDK 客户端能够驱动 harness agent(智能体)。[`HarnessSdkServer`](src/server.ts) 负责协议方法和通知;传输与具名协议类型位于 [`dsh-sdk-protocol`](../protocol/README.md),与客户端 SDK 共享;[`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) 提供外围的 `cordis.yml` 应用。 ## 组装 diff --git a/packages/scaffold/server/package.json b/packages/sdk/server/package.json similarity index 97% rename from packages/scaffold/server/package.json rename to packages/sdk/server/package.json index 0bd44281fe..72a09f0333 100644 --- a/packages/scaffold/server/package.json +++ b/packages/sdk/server/package.json @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/scaffold/server" + "directory": "packages/sdk/server" }, "type": "module", "main": "lib/index.js", diff --git a/packages/scaffold/server/src/index.ts b/packages/sdk/server/src/index.ts similarity index 100% rename from packages/scaffold/server/src/index.ts rename to packages/sdk/server/src/index.ts diff --git a/packages/scaffold/server/src/invariant.ts b/packages/sdk/server/src/invariant.ts similarity index 100% rename from packages/scaffold/server/src/invariant.ts rename to packages/sdk/server/src/invariant.ts diff --git a/packages/scaffold/server/src/server.ts b/packages/sdk/server/src/server.ts similarity index 100% rename from packages/scaffold/server/src/server.ts rename to packages/sdk/server/src/server.ts diff --git a/packages/scaffold/server/tests/built-scope-carrier.e2e.ts b/packages/sdk/server/tests/built-scope-carrier.e2e.ts similarity index 98% rename from packages/scaffold/server/tests/built-scope-carrier.e2e.ts rename to packages/sdk/server/tests/built-scope-carrier.e2e.ts index a51c88ddb3..a76510f68b 100644 --- a/packages/scaffold/server/tests/built-scope-carrier.e2e.ts +++ b/packages/sdk/server/tests/built-scope-carrier.e2e.ts @@ -36,7 +36,7 @@ const [ load("packages/examples/agent-spine-demo/lib/index.js"), load("packages/subagent/subagent/lib/index.js"), load("packages/session/session-persistence-jsonl/lib/index.js"), - load("packages/scaffold/server/lib/index.js"), + load("packages/sdk/server/lib/index.js"), load("packages/core/session/lib/index.js"), ]); diff --git a/packages/scaffold/server/tests/plugin-apply.spec.ts b/packages/sdk/server/tests/plugin-apply.spec.ts similarity index 100% rename from packages/scaffold/server/tests/plugin-apply.spec.ts rename to packages/sdk/server/tests/plugin-apply.spec.ts diff --git a/packages/scaffold/server/tests/plugin-shape.spec.ts b/packages/sdk/server/tests/plugin-shape.spec.ts similarity index 100% rename from packages/scaffold/server/tests/plugin-shape.spec.ts rename to packages/sdk/server/tests/plugin-shape.spec.ts diff --git a/packages/scaffold/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts similarity index 100% rename from packages/scaffold/server/tests/server.spec.ts rename to packages/sdk/server/tests/server.spec.ts diff --git a/packages/scaffold/server/tsconfig.json b/packages/sdk/server/tsconfig.json similarity index 100% rename from packages/scaffold/server/tsconfig.json rename to packages/sdk/server/tsconfig.json diff --git a/packages/session/user-id/README.i18n.yaml b/packages/session/user-id/README.i18n.yaml index 5d58bba70e..2427f8bdc9 100644 --- a/packages/session/user-id/README.i18n.yaml +++ b/packages/session/user-id/README.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 packages/session/user-id/README.md -README.md: 31a72f5e7b58b90b165b16374c2301389cbe2ca0 -README.zh.md: 013097b3038c43ff740660ef9159ca2b13f7b743 +README.md: eb50bb06af52b3d977068b73388361bc1c25087f +README.zh.md: 54287676c4e524c2458e4bab7e6bb3f52850ff25 diff --git a/packages/session/user-id/README.md b/packages/session/user-id/README.md index 31a72f5e7b..eb50bb06af 100644 --- a/packages/session/user-id/README.md +++ b/packages/session/user-id/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Shared anonymous identity for session telemetry and direct feedback acknowledgement. `getOrCreateAnonymousUserId()` returns a random UUID v4 scoped to one harness home, persisted as the bare line `$DSH_HOME/.userid` (`~/.dsh/.userid` when `DSH_HOME` is unset). The OpenTelemetry backend reports it as Resource `user.id`; `/feedback` includes the same value in its acknowledgement so an operator can correlate a submitted session and user with exported telemetry. -The identity is never derived from the hostname, network address, git remote, or another identifying source. Deleting `.userid` resets the identity on the next process launch. Separate harness homes have separate identities, and the dsh-sdk launcher telemetry intentionally keeps its own unrelated store. +The identity is never derived from the hostname, network address, git remote, or another identifying source. Deleting `.userid` resets the identity on the next process launch. Separate harness homes have separate identities. ## Storage contract @@ -26,4 +26,4 @@ None; this package never contributes to a model request. - **No recovery after deletion** — loss mints a new anonymous identity by design; recovery would require stable derivation material that weakens anonymity. - **Best-effort concurrency** — a reader landing in the narrow interval between a concurrent process's exclusive create and completed write can use a different in-memory UUID for that run; later launches converge on the persisted value. -- **No cross-home identity** — different `$DSH_HOME` values cannot be correlated, and this package does not unify the separate dsh-sdk launcher telemetry identity. +- **No cross-home identity** — different `$DSH_HOME` values cannot be correlated. diff --git a/packages/session/user-id/README.zh.md b/packages/session/user-id/README.zh.md index 013097b303..54287676c4 100644 --- a/packages/session/user-id/README.zh.md +++ b/packages/session/user-id/README.zh.md @@ -4,7 +4,7 @@ 会话遥测与直接反馈确认共用的匿名身份。`getOrCreateAnonymousUserId()` 返回一个限定于单个 harness home 的随机 UUID v4,并以裸行形式持久化到 `$DSH_HOME/.userid`(未设置 `DSH_HOME` 时为 `~/.dsh/.userid`)。OpenTelemetry 后端将其作为 Resource 的 `user.id` 上报;`/feedback` 在确认文本中包含同一个值,以便运维人员将所报告的会话和用户与导出的遥测相关联。 -该身份绝不从 hostname、网络地址、git remote 或其他可用于识别身份的来源派生。删除 `.userid` 后,下次启动进程时会重置身份。不同 harness home 拥有不同身份,dsh-sdk launcher telemetry 则刻意使用与此无关的独立存储。 +该身份绝不从 hostname、网络地址、git remote 或其他可用于识别身份的来源派生。删除 `.userid` 后,下次启动进程时会重置身份。不同 harness home 拥有不同身份。 ## 存储契约 @@ -26,4 +26,4 @@ - **删除后无法恢复**:身份丢失后会按设计生成新的匿名身份;若要恢复身份,就需要稳定的派生材料,这会削弱匿名性。 - **Best-effort 并发**:如果读取方恰好落在并发进程完成独占创建但尚未写完的狭窄时间窗内,本次运行可能使用不同的内存 UUID;后续启动会收敛到已持久化的值。 -- **没有跨 home 身份**:不同 `$DSH_HOME` 值之间无法关联,本包也不会统一 dsh-sdk launcher telemetry 的独立身份。 +- **没有跨 home 身份**:不同 `$DSH_HOME` 值之间无法关联。 diff --git a/packages/session/user-id/src/index.ts b/packages/session/user-id/src/index.ts index ca314e945a..5bc3344315 100644 --- a/packages/session/user-id/src/index.ts +++ b/packages/session/user-id/src/index.ts @@ -6,8 +6,7 @@ * and never derived from the hostname, network address, git remote, or any * other identifying source. It is scoped to the harness home, not the * machine: every process sharing one `$DSH_HOME` reports the same id, and - * deleting the file mints a fresh identity on the next launch. The dsh-sdk - * launcher telemetry keeps its own separate store. + * deleting the file mints a fresh identity on the next launch. * * Reads and writes are synchronous so boot-time and command consumers can * use one API. The result is memoized per resolved file path: one process diff --git a/packages/subagent/subagent-codex/tsconfig.json b/packages/subagent/subagent-codex/tsconfig.json index 3bb974477e..fc83f3ad5a 100644 --- a/packages/subagent/subagent-codex/tsconfig.json +++ b/packages/subagent/subagent-codex/tsconfig.json @@ -24,7 +24,7 @@ "path": "../../llm/llm" }, { - "path": "../../scaffold/protocol" + "path": "../../sdk/protocol" }, { "path": "../../core/session" diff --git a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml index 0d7e60cc46..dc98bdc655 100644 --- a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml +++ b/packages/subagent/subagent-dsh-sdk/README.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 packages/subagent/subagent-dsh-sdk/README.md -README.md: 493bb187d45c7654958cfb3dbbe1dee6bb21b368 -README.zh.md: 2e1d9b1e602f2180d20d43fe8c358163ec4ec024 +README.md: 8d20b44dacd773345986e2a2fc0e6aca9470bf2c +README.zh.md: 4c9117cad610276ca9d12c914a44dcbaecbbf26e diff --git a/packages/subagent/subagent-dsh-sdk/README.md b/packages/subagent/subagent-dsh-sdk/README.md index 493bb187d4..8d20b44dac 100644 --- a/packages/subagent/subagent-dsh-sdk/README.md +++ b/packages/subagent/subagent-dsh-sdk/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The SDK provider runs each subagent as a complete DeepSeek Harness runtime in a fresh subprocess, driven over stdio JSON-RPC through the [TypeScript SDK client](../../scaffold/client/README.md). It is the second out-of-process backend beside [`subagent-acp`](../subagent-acp/README.md), differing in the wire and the child contract: the ACP backend drives any Agent Client Protocol agent; this backend drives specifically a harness SDK runtime (`dsh-jsonrpc-agent` bin or packaged executable), so the child is a full peer harness — own `cordis.yml`-decided composition, session persistence, model route, and tools. +The SDK provider runs each subagent as a complete DeepSeek Harness runtime in a fresh subprocess, driven over stdio JSON-RPC through the [TypeScript SDK client](../../sdk/client/README.md). It is the second out-of-process backend beside [`subagent-acp`](../subagent-acp/README.md), differing in the wire and the child contract: the ACP backend drives any Agent Client Protocol agent; this backend drives specifically a harness SDK runtime (`dsh-jsonrpc-agent` bin or packaged executable), so the child is a full peer harness — own `cordis.yml`-decided composition, session persistence, model route, and tools. ## Start and ownership diff --git a/packages/subagent/subagent-dsh-sdk/README.zh.md b/packages/subagent/subagent-dsh-sdk/README.zh.md index 2e1d9b1e60..4c9117cad6 100644 --- a/packages/subagent/subagent-dsh-sdk/README.zh.md +++ b/packages/subagent/subagent-dsh-sdk/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -SDK 提供方会在全新的子进程中把每个 subagent 作为完整的 DeepSeek Harness 运行时运行,并经由 [TypeScript SDK 客户端](../../scaffold/client/README.md) 通过 stdio JSON-RPC 驱动。它是 [`subagent-acp`](../subagent-acp/README.md) 之外的第二个进程外后端,差异在协议格式(wire format)和子进程约定:ACP(Agent Client Protocol)后端能驱动任何 Agent Client Protocol agent(智能体);本后端专门驱动 harness SDK 运行时(`dsh-jsonrpc-agent` bin 或打包后的可执行文件),因此子进程是一个完整的对等 harness,拥有由 `cordis.yml` 决定的组合、会话持久化、模型路由和工具。 +SDK 提供方会在全新的子进程中把每个 subagent 作为完整的 DeepSeek Harness 运行时运行,并经由 [TypeScript SDK 客户端](../../sdk/client/README.md) 通过 stdio JSON-RPC 驱动。它是 [`subagent-acp`](../subagent-acp/README.md) 之外的第二个进程外后端,差异在协议格式(wire format)和子进程约定:ACP(Agent Client Protocol)后端能驱动任何 Agent Client Protocol agent(智能体);本后端专门驱动 harness SDK 运行时(`dsh-jsonrpc-agent` bin 或打包后的可执行文件),因此子进程是一个完整的对等 harness,拥有由 `cordis.yml` 决定的组合、会话持久化、模型路由和工具。 ## 启动与所有权 diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index 1f518bde8d..46f0a5b5bb 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -24,7 +24,7 @@ import { type SdkRunSpec, } from '../src/run.ts' -const fakeRuntime = fileURLToPath(new URL('../../../scaffold/client/tests/fake-runtime.ts', import.meta.url)) +const fakeRuntime = fileURLToPath(new URL('../../../sdk/client/tests/fake-runtime.ts', import.meta.url)) /** A parent Agent stub. The SDK backend reads exactly one thing off it: the session header's cwd (the workspace its child inherits). */ const fakeParent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent diff --git a/packages/subagent/subagent-dsh-sdk/tsconfig.json b/packages/subagent/subagent-dsh-sdk/tsconfig.json index 2b9aa29e21..f620190104 100644 --- a/packages/subagent/subagent-dsh-sdk/tsconfig.json +++ b/packages/subagent/subagent-dsh-sdk/tsconfig.json @@ -27,10 +27,10 @@ "path": "../../core/session" }, { - "path": "../../scaffold/client" + "path": "../../sdk/client" }, { - "path": "../../scaffold/protocol" + "path": "../../sdk/protocol" }, { "path": "../subagent" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a787f4c68e..51df989efa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -493,7 +493,7 @@ importers: version: link:../packages/support/invariants '@deepseek-ai/dsh-jsonrpc': specifier: workspace:* - version: link:../packages/scaffold/server + version: link:../packages/sdk/server '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../packages/llm/llm @@ -5480,7 +5480,7 @@ importers: specifier: workspace:^ version: link:../sandbox-local - packages/scaffold/client: + packages/sdk/client: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -5498,75 +5498,7 @@ importers: specifier: workspace:^ version: link:../../core/session - packages/scaffold/create-sdk: - dependencies: - '@deepseek-ai/dsh-helper': - specifier: workspace:^ - version: link:../helper - commander: - specifier: ^15.0.0 - version: 15.0.0 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - - packages/scaffold/helper: - dependencies: - '@clack/core': - specifier: ^1.4.3 - version: 1.4.3 - '@clack/prompts': - specifier: ^1.7.0 - version: 1.7.0 - handlebars: - specifier: ^4.7.9 - version: 4.7.9 - jsonc-parser: - specifier: ^3.3.1 - version: 3.3.1 - yaml: - specifier: ^2.9.0 - version: 2.9.0 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-hooks-claude': - specifier: workspace:^ - version: link:../../hooks/hooks-claude - '@deepseek-ai/dsh-hooks-codex': - specifier: workspace:^ - version: link:../../hooks/hooks-codex - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-session-persistence-jsonl': - specifier: workspace:^ - version: link:../../session/session-persistence-jsonl - '@deepseek-ai/dsh-session-persistence-sqlite': - specifier: workspace:^ - version: link:../../session/session-persistence-sqlite - '@deepseek-ai/dsh-subprocess': - specifier: workspace:^ - version: link:../../subprocess/subprocess - '@deepseek-ai/dsh-tool-subagent': - specifier: workspace:^ - version: link:../../subagent/tool-subagent - '@deepseek-ai/dsh-tool-todo': - specifier: workspace:^ - version: link:../../todo/tool-todo - '@deepseek-ai/dsh-tool-web': - specifier: workspace:^ - version: link:../../web/tool-web - - packages/scaffold/protocol: + packages/sdk/protocol: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -5584,38 +5516,7 @@ importers: specifier: workspace:^ version: link:../../subagent/subagent - packages/scaffold/scripts: - dependencies: - '@deepseek-ai/dsh-helper': - specifier: workspace:^ - version: link:../helper - '@deepseek-ai/dsh-telemetry': - specifier: workspace:^ - version: link:../telemetry - commander: - specifier: ^15.0.0 - version: 15.0.0 - node-addon-require-builtin: - specifier: ^0.1.4 - version: 0.1.4 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-app-boot': - specifier: workspace:^ - version: link:../../boot/app-boot - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - tsdown: - specifier: ^0.22.2 - version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3) - tsx: - specifier: ^4.22.4 - version: 4.22.4 - - packages/scaffold/server: + packages/sdk/server: dependencies: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery @@ -5658,25 +5559,6 @@ importers: specifier: workspace:^ version: link:../../subagent/subagent - packages/scaffold/telemetry: - dependencies: - yaml: - specifier: ^2.9.0 - version: 2.9.0 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-paths': - specifier: workspace:^ - version: link:../../util/paths - packages/self-modification/tool-cordis: dependencies: '@deepseek-ai/schemastery': @@ -6613,7 +6495,7 @@ importers: version: link:../../support/loader-smoke '@deepseek-ai/dsh-sdk-protocol': specifier: workspace:^ - version: link:../../scaffold/protocol + version: link:../../sdk/protocol '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -6659,10 +6541,10 @@ importers: version: link:../../support/loader-smoke '@deepseek-ai/dsh-sdk-client': specifier: workspace:^ - version: link:../../scaffold/client + version: link:../../sdk/client '@deepseek-ai/dsh-sdk-protocol': specifier: workspace:^ - version: link:../../scaffold/protocol + version: link:../../sdk/protocol '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -7822,7 +7704,7 @@ importers: version: link:../../packages/support/invariants '@deepseek-ai/dsh-jsonrpc': specifier: workspace:^ - version: link:../../packages/scaffold/server + version: link:../../packages/sdk/server '@deepseek-ai/dsh-jsonrpc-demo': specifier: workspace:^ version: link:../../packages/examples/jsonrpc-demo @@ -7873,7 +7755,7 @@ importers: version: link:../../packages/core/scope '@deepseek-ai/dsh-sdk-protocol': specifier: workspace:^ - version: link:../../packages/scaffold/protocol + version: link:../../packages/sdk/protocol '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session @@ -8584,14 +8466,6 @@ packages: '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} - '@clack/core@1.4.3': - resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} - engines: {node: '>= 20.12.0'} - - '@clack/prompts@1.7.0': - resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} - engines: {node: '>= 20.12.0'} - '@connectrpc/connect-web@2.0.0-rc.3': resolution: {integrity: sha512-w88P8Lsn5CCsA7MFRl2e6oLY4J/5toiNtJns/YJrlyQaWOy3RO8pDgkz+iIkG98RPMhj2thuBvsd3Cn4DKKCkw==} peerDependencies: @@ -11674,18 +11548,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-string-truncated-width@3.0.3: - resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} - - fast-string-width@3.0.2: - resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.3: resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} - fast-wrap-ansi@0.2.2: - resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} - fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -11840,11 +11705,6 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} - handlebars@4.7.9: - resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} - engines: {node: '>=0.4.7'} - hasBin: true - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -12101,9 +11961,6 @@ packages: engines: {node: '>=6'} hasBin: true - jsonc-parser@3.3.1: - resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} - jsx-ast-utils-x@0.1.0: resolution: {integrity: sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -12491,9 +12348,6 @@ packages: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -12527,9 +12381,6 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -13055,9 +12906,6 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - sisteransi@1.0.5: - resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - smol-toml@1.6.1: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} @@ -13070,10 +12918,6 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -13290,11 +13134,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} - engines: {node: '>=0.8.0'} - hasBin: true - unbash@3.0.0: resolution: {integrity: sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==} engines: {node: '>=14'} @@ -13618,9 +13457,6 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -14269,18 +14105,6 @@ snapshots: '@chevrotain/types@11.1.2': {} - '@clack/core@1.4.3': - dependencies: - fast-wrap-ansi: 0.2.2 - sisteransi: 1.0.5 - - '@clack/prompts@1.7.0': - dependencies: - '@clack/core': 1.4.3 - fast-string-width: 3.0.2 - fast-wrap-ansi: 0.2.2 - sisteransi: 1.0.5 - '@connectrpc/connect-web@2.0.0-rc.3(@bufbuild/protobuf@2.13.0)(@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.13.0))': dependencies: '@bufbuild/protobuf': 2.13.0 @@ -16964,18 +16788,8 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-string-truncated-width@3.0.3: {} - - fast-string-width@3.0.2: - dependencies: - fast-string-truncated-width: 3.0.3 - fast-uri@3.1.3: {} - fast-wrap-ansi@0.2.2: - dependencies: - fast-string-width: 3.0.2 - fast-xml-builder@1.2.0: dependencies: path-expression-matcher: 1.5.0 @@ -17155,15 +16969,6 @@ snapshots: hachure-fill@0.5.2: {} - handlebars@4.7.9: - dependencies: - minimist: 1.2.8 - neo-async: 2.6.2 - source-map: 0.6.1 - wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.19.3 - has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -17395,8 +17200,6 @@ snapshots: json5@2.2.3: {} - jsonc-parser@3.3.1: {} - jsx-ast-utils-x@0.1.0: {} jszip@3.10.1: @@ -17985,8 +17788,6 @@ snapshots: dependencies: brace-expansion: 2.1.2 - minimist@1.2.8: {} - minipass@7.1.3: {} minisearch@7.2.0: {} @@ -18007,8 +17808,6 @@ snapshots: negotiator@1.0.0: {} - neo-async@2.6.2: {} - node-addon-api@7.1.1: {} node-addon-native-custom-loader@0.1.4: {} @@ -18676,16 +18475,12 @@ snapshots: signal-exit@4.1.0: {} - sisteransi@1.0.5: {} - smol-toml@1.6.1: {} smol-toml@1.7.1: {} source-map-js@1.2.1: {} - source-map@0.6.1: {} - space-separated-tokens@2.0.2: {} spdx-exceptions@2.5.0: {} @@ -18867,9 +18662,6 @@ snapshots: typescript@6.0.3: {} - uglify-js@3.19.3: - optional: true - unbash@3.0.0: {} unconfig-core@7.5.0: @@ -19246,8 +19038,6 @@ snapshots: word-wrap@1.2.5: {} - wordwrap@1.0.0: {} - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index cfc10fda49..0036dc2929 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -135,7 +135,6 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-web-app': ['cordis.patch.yml'], '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], - '@deepseek-ai/dsh-helper': ['lib/assets'], // The Python runtime uses a distinct closed-resolution bin; the public CLI // keeps config-owned bare-package resolution through lib/bin.js. '@deepseek-ai/dsh-jsonrpc-demo': ['lib/packaged-bin.js'], @@ -145,11 +144,6 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js'], '@deepseek-ai/dsh-skill-badge': ['assets'], '@deepseek-ai/dsh-subprocess-local': ['scripts/ensure-spawn-helper.mjs'], - '@deepseek-ai/dsh-scripts': [ - 'lib/dev/tsdown-config.js', - 'lib/local-plugin-loader-hooks.js', - 'lib/assets', - ], } function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean { diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index bca3183795..45f936e58b 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -79,8 +79,6 @@ interface GenericSkip { const GENERIC_SKIPS: readonly GenericSkip[] = [ // `vendorPackages` lists vendor/ directory names, joined with 'vendor' below it. { file: 'packages/examples/acp-demo/tests/built-bin.e2e.ts', upstream: ['cordis', 'cosmokit', 'schemastery'] }, - // Mixes join(root, 'vendor', 'cordis') paths with real manifest names. - { file: 'packages/scaffold/helper/tests/documents.spec.ts', upstream: ['cordis'] }, // `Symbol.for('schemastery')` and the `vendor:` metadata field are upstream identifiers. { file: 'vendor/schemastery/src/index.ts', upstream: ['schemastery'] }, // Asserts the vendored-manifest table, which gains an upstream-name column. @@ -121,8 +119,6 @@ const POSTCONDITIONS: readonly PostCondition[] = [ { file: 'scripts/gen-scoped-events.ts', text: '=== \'@deepseek-ai/cordis\'', count: 1 }, { file: 'packages/typert/generator/src/analyzer.ts', text: '!== \'@deepseek-ai/cordis\'', count: 2 }, { file: 'scripts/check-workspace-constraints.ts', text: '?.[\'@deepseek-ai/cordis\']', count: 2 }, - { file: 'packages/scaffold/helper/src/project/npm-dependency-policy.ts', text: '\'@deepseek-ai/cordis\': \'^4.0.0-rc.7\'', count: 1 }, - { file: 'packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts', text: '\'@deepseek-ai/cordis\': cordisSpec', count: 2 }, { file: 'packages/boot/app-boot/tsdown.config.ts', text: '[\'@deepseek-ai/cordis-plugin-include\']', count: 1 }, { file: 'tsconfig.base.json', text: '"@deepseek-ai/cordis-plugin-loader": ["./vendor/loader/src"]', count: 1 }, // One insertion, once: a duplicated log entry is what a non-idempotent apply produced. @@ -134,8 +130,6 @@ const POSTCONDITIONS: readonly PostCondition[] = [ // The preset id the shipped composition documents to its own model. { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'The `cordis` agent preset', count: 1 }, { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'corrupting the `cordis` preset', count: 1 }, - // The vendor-directory paths in these fixtures must survive the rename. - { file: 'packages/scaffold/helper/tests/documents.spec.ts', text: 'join(root, \'vendor\', \'cordis\')', count: 2 }, { file: 'packages/examples/acp-demo/tests/built-bin.e2e.ts', text: '\'cordis\', \'loader\', \'include\', \'timer\', \'hmr\', \'logger-console\',', count: 1 }, ] @@ -171,76 +165,6 @@ const EXACT_EDITS: readonly ExactEdit[] = [ errors.push(\`\${label}: @deepseek-ai/cordis peer (\${peer}) and dev (\${dev}) ranges must match\`)`, expect: 1, }, - { - id: 'scaffold-dependency-policy', - file: 'packages/scaffold/helper/src/project/npm-dependency-policy.ts', - find: ' cordis: \'^4.0.0-rc.7\',', - replace: ' \'@deepseek-ai/cordis\': \'^4.0.0-rc.7\',', - expect: 1, - }, - { - id: 'scaffold-plugin-blueprint', - file: 'packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts', - find: ` cordis: cordisSpec, - }, - devDependencies: { - cordis: cordisSpec, - },`, - replace: ` '@deepseek-ai/cordis': cordisSpec, - }, - devDependencies: { - '@deepseek-ai/cordis': cordisSpec, - },`, - expect: 1, - }, - { - id: 'scaffold-link-workspace-lookup', - file: 'packages/scaffold/create-sdk/tests/link-workspace.e2e.ts', - find: 'manifest.dependencies.cordis', - replace: 'manifest.dependencies[\'@deepseek-ai/cordis\']', - expect: 1, - }, - { - id: 'documents-spec-manifest-name', - file: 'packages/scaffold/helper/tests/documents.spec.ts', - find: 'JSON.stringify({ name: \'cordis\' })', - replace: 'JSON.stringify({ name: \'@deepseek-ai/cordis\' })', - expect: 1, - }, - { - id: 'documents-spec-peer-key', - file: 'packages/scaffold/helper/tests/documents.spec.ts', - find: 'peerDependencies: { cordis: \'^4\' },', - replace: 'peerDependencies: { \'@deepseek-ai/cordis\': \'^4\' },', - expect: 1, - }, - { - id: 'documents-spec-closure-order', - file: 'packages/scaffold/helper/tests/documents.spec.ts', - find: ' \'@deepseek-ai/dsh-helper\', \'@deepseek-ai/dsh-scripts\', \'cordis\',', - replace: ' \'@deepseek-ai/cordis\', \'@deepseek-ai/dsh-helper\', \'@deepseek-ai/dsh-scripts\',', - expect: 1, - }, - { - id: 'documents-spec-lookups', - file: 'packages/scaffold/helper/tests/documents.spec.ts', - find: ` expect(manifest.npmDependency('cordis')?.spec).toMatch(/^link:/) - expect(pnpmWorkspace.serialize()).toContain('autoInstallPeers: false') - expect(workspace.packageDirectory('cordis')).toBe(join(root, 'vendor', 'cordis')) - expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('cordis')`, - replace: ` expect(manifest.npmDependency('@deepseek-ai/cordis')?.spec).toMatch(/^link:/) - expect(pnpmWorkspace.serialize()).toContain('autoInstallPeers: false') - expect(workspace.packageDirectory('@deepseek-ai/cordis')).toBe(join(root, 'vendor', 'cordis')) - expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('@deepseek-ai/cordis')`, - expect: 1, - }, - { - id: 'documents-spec-policy-lookup', - file: 'packages/scaffold/helper/tests/documents.spec.ts', - find: ' expect(resolveNpmDependency(\'cordis\', \'devDependencies\', \'0.0.1\')).toEqual({', - replace: ' expect(resolveNpmDependency(\'@deepseek-ai/cordis\', \'devDependencies\', \'0.0.1\')).toEqual({', - expect: 1, - }, { // The rescoped name is already covered by the `@deepseek-ai/.+` pattern beside it. id: 'knip-logger-console', @@ -318,8 +242,8 @@ const EXACT_EDITS: readonly ExactEdit[] = [ { id: 'vendor-readme-local-modification-log', file: 'vendor/README.md', - find: '\n## Sync procedure', - replace: '17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table\'s `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for(\'schemastery\')` and Schemastery\'s `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table\'s two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).\n\n## Sync procedure', + find: '\n18. **`cordis/package.json` publishes `src`**', + replace: '\n17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table\'s `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for(\'schemastery\')` and Schemastery\'s `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table\'s two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).\n18. **`cordis/package.json` publishes `src`**', expect: 1, }, { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index ad7b6842d9..c824c96ac0 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -622,7 +622,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { 'apps/cli/tests/built-bin.e2e.ts', 'packages/examples/acp-demo/tests/built-bin.e2e.ts', 'packages/host/directory-picker-native/tests/built-worker.e2e.ts', - 'packages/scaffold/server/tests/built-scope-carrier.e2e.ts', + 'packages/sdk/server/tests/built-scope-carrier.e2e.ts', 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts', 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', 'packages/api/remotes/tests/built-lib.e2e.ts', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 88b18e7bb0..add47496f5 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -111,12 +111,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/subprocess/subprocess-local': { kind: 'indirect', reason: 'The spawn backend delegates model rendering to consumer seams such as the bash executor family.' }, 'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' }, 'packages/sandbox/sandbox-windows-acl': { kind: 'indirect', reason: 'The provider backend delegates model rendering to the bash/pwsh sandbox executors and their tools.' }, - 'packages/scaffold/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' }, - 'packages/scaffold/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' }, - 'packages/scaffold/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' }, - 'packages/scaffold/client': { kind: 'none', reason: 'Client-process library; the model surface lives in the spawned runtime\'s composed plugins.' }, - 'packages/scaffold/protocol': { kind: 'none', reason: 'Client-facing wire library; the runtime plugins behind the serving entry own the model surface.' }, - 'packages/scaffold/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' }, + 'packages/sdk/client': { kind: 'none', reason: 'Client-process library; the model surface lives in the spawned runtime\'s composed plugins.' }, + 'packages/sdk/protocol': { kind: 'none', reason: 'Client-facing wire library; the runtime plugins behind the serving entry own the model surface.' }, 'packages/session/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers no model surface.' }, 'packages/session/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers no model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, diff --git a/skills/create-dsh-sdk-project/SKILL.md b/skills/create-dsh-sdk-project/SKILL.md deleted file mode 100644 index 5daee98bca..0000000000 --- a/skills/create-dsh-sdk-project/SKILL.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -name: create-dsh-sdk-project -description: Create a DeepSeek Harness SDK project non-interactively (headless), driven by an agent instead of the interactive wizard. Use when asked to scaffold a new DSH SDK project without a terminal. ---- - -# Create a DeepSeek Harness SDK project headlessly - -The `create-sdk` initializer normally runs an interactive wizard. To create a project -**without a terminal**, pass a structured spec and ask for machine-readable events: - -```sh -npm create @deepseek-ai/sdk -- --config-json '' --json -``` - -- `--config-json ''` supplies the whole spec inline (no prompts). Alternatively - `--config ` reads the same spec from a file. -- `--json` makes the command emit one NDJSON lifecycle event per line to stdout. - -## Spec shape - -All fields are optional except those a chosen feature requires. Unsupplied answers that -have a sensible default are taken from it; a *required* answer with no default (a secret, -a custom provider base URL, a required feature option) makes the run fail loud rather than -block. - -```json -{ - "directory": "my-agent", - "description": "A DeepSeek Harness agent", - "provider": "deepseek-official", - "apiKey": "", - "model": "deepseek-v4-flash", - "interface": "acp", - "pm": "npm", - "install": false, - "features": [ - { "id": "persistence", "options": ["sqlite"] }, - { "id": "web", "options": ["exa"], "secrets": { "apiKey": "" } } - ] -} -``` - -`features` is the complete set of optional features to enable, each with its chosen -options and any secrets/values it needs. The interactive feature tree and its -recommended-feature prompts are skipped in headless mode. - -## Reacting to events - -Each line of stdout is one JSON object: - -- `{"type":"done"}` — the project was created (and installed, if `install` was true). -- `{"type":"action-required","prompt":""}` — a required answer was missing. - Add the corresponding field to the spec (e.g. an `apiKey`, a feature secret, a custom - `baseURL`) and re-run. -- `{"type":"error","message":""}` — the run failed for another reason. - -Iterate: read `action-required`, fill the named input into the spec, re-run until `done`. diff --git a/tsconfig.base.json b/tsconfig.base.json index a08b438f3a..223c31062a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -119,7 +119,7 @@ "./packages/acp/*/src/invariant.ts", "./packages/storage/*/src/invariant.ts", "./packages/workspace/*/src/invariant.ts", - "./packages/scaffold/*/src/invariant.ts", + "./packages/sdk/*/src/invariant.ts", "./packages/interaction/*/src/invariant.ts", "./packages/boot/*/src/invariant.ts", "./packages/examples/*/src/invariant.ts", @@ -190,12 +190,11 @@ "@deepseek-ai/dsh-client-ui-models": ["./packages/client/ui-models/src"], "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"], "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"], - // scaffold/ folders are role-named without the sdk- prefix (folder ≠ npm - // suffix), so the generic wildcard cannot map these three; the npm-side - // renames that restore symmetry are FIXME-tracked in the regrouping note. - "@deepseek-ai/dsh-sdk-client": ["./packages/scaffold/client/src"], - "@deepseek-ai/dsh-sdk-protocol": ["./packages/scaffold/protocol/src"], - "@deepseek-ai/dsh-jsonrpc": ["./packages/scaffold/server/src"], + // sdk/ folders are role-named without their npm-side sdk/jsonrpc prefixes, + // so the generic wildcard cannot map these three package names. + "@deepseek-ai/dsh-sdk-client": ["./packages/sdk/client/src"], + "@deepseek-ai/dsh-sdk-protocol": ["./packages/sdk/protocol/src"], + "@deepseek-ai/dsh-jsonrpc": ["./packages/sdk/server/src"], "@deepseek-ai/dsh-*": [ "./packages/core/*/src", "./packages/prompt/*/src", @@ -233,7 +232,7 @@ "./packages/acp/*/src", "./packages/storage/*/src", "./packages/workspace/*/src", - "./packages/scaffold/*/src", + "./packages/sdk/*/src", "./packages/interaction/*/src", "./packages/boot/*/src", "./packages/examples/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index 2beed0b1c3..b8d20e054b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -227,7 +227,7 @@ { "path": "./packages/bundle/web-app" }, { "path": "./packages/boot/app-boot" }, { "path": "./packages/boot/cmdline" }, - { "path": "./packages/scaffold/server" }, + { "path": "./packages/sdk/server" }, { "path": "./packages/examples/jsonrpc-demo" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/typert/generator" }, @@ -274,12 +274,8 @@ { "path": "./packages/host/directory-picker-native" }, { "path": "./packages/host/frontend-static" }, { "path": "./packages/host/webserver" }, - { "path": "./packages/scaffold/client" }, - { "path": "./packages/scaffold/helper" }, - { "path": "./packages/scaffold/protocol" }, - { "path": "./packages/scaffold/scripts" }, - { "path": "./packages/scaffold/create-sdk" }, - { "path": "./packages/scaffold/telemetry" }, + { "path": "./packages/sdk/client" }, + { "path": "./packages/sdk/protocol" }, { "path": "./packages/lsp/lsp" }, { "path": "./packages/lsp/lsp-local" }, { "path": "./packages/lsp/tool-lsp" }, diff --git a/vitest.config.ts b/vitest.config.ts index c0eb076b5c..c698057915 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -32,8 +32,6 @@ const windowsUnsupportedPackages = process.platform === 'win32' 'packages/subprocess/*', 'packages/pty/pty-local', 'packages/sandbox/sandbox-local', - 'packages/scaffold/create-sdk', - 'packages/scaffold/helper', ] : [] diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index cf9cae0780..049688a0ec 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -51,7 +51,7 @@ export default defineConfig({ ...(process.env.DSH_EXAMPLE_MODE === 'lib' ? ['apps/web/tests/**/*.snapshot.ts'] : []), 'apps/cli/tests/**/*.snapshot.ts', 'examples/*/tests/**/*.snapshot.ts', - 'packages/scaffold/*/tests/**/*.snapshot.ts', + 'packages/sdk/*/tests/**/*.snapshot.ts', ], // Replay never writes committed outputs and every scenario owns its // mutable runtime state (the subprocess suites use a unique temp dir and From 0c708cb10dd7d37bb959b2c25fc583fbf090652e Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 19:54:25 +0800 Subject: [PATCH 05/17] refactor: replace overloaded surface terminology --- .../2026-06-13-capability-seams.i18n.yaml | 2 +- .../2026-06-13-capability-seams.md | 2 +- ...06-17-filesystem-capability-seam.i18n.yaml | 2 +- .../2026-06-17-filesystem-capability-seam.md | 6 +- ...ifecycle-and-ownership-contracts.i18n.yaml | 4 +- ...agent-lifecycle-and-ownership-contracts.md | 2 +- ...nt-lifecycle-and-ownership-contracts.zh.md | 2 +- ...ed-persistence-write-coordinator.i18n.yaml | 2 +- ...18-shared-persistence-write-coordinator.md | 2 +- .../2026-06-20-branded-ids.i18n.yaml | 2 +- .../architecture/2026-06-20-branded-ids.md | 2 +- ...eneric-long-running-tool-runtime.i18n.yaml | 4 +- ...06-20-generic-long-running-tool-runtime.md | 20 +- ...20-generic-long-running-tool-runtime.zh.md | 16 +- .../2026-06-24-web-capability-seam.i18n.yaml | 2 +- .../2026-06-24-web-capability-seam.md | 8 +- ...06-26-file-context-as-event-gate.i18n.yaml | 2 +- .../2026-06-26-file-context-as-event-gate.md | 2 +- ...sh-stdin-env-trusted-plugin-api.i18n.yaml} | 6 +- ...6-30-bash-stdin-env-trusted-plugin-api.md} | 4 +- ...0-bash-stdin-env-trusted-plugin-api.zh.md} | 2 +- ...026-06-30-event-domain-semantics.i18n.yaml | 2 +- .../2026-06-30-event-domain-semantics.md | 2 +- ...6-07-05-reconstructable-requests.i18n.yaml | 2 +- .../2026-07-05-reconstructable-requests.md | 2 +- ...05-windows-jsonl-durable-publish.i18n.yaml | 2 +- ...026-07-05-windows-jsonl-durable-publish.md | 2 +- ...6-07-06-timeout-deadline-library.i18n.yaml | 2 +- .../2026-07-06-timeout-deadline-library.md | 2 +- ...06-tool-result-retention-library.i18n.yaml | 2 +- ...026-07-06-tool-result-retention-library.md | 2 +- ...26-07-08-tool-output-spill-files.i18n.yaml | 2 +- .../2026-07-08-tool-output-spill-files.md | 2 +- ...cutable-sdk-runtime-distribution.i18n.yaml | 2 +- ...ile-executable-sdk-runtime-distribution.md | 6 +- ...07-12-agent-scope-runtime-design.i18n.yaml | 2 +- .../2026-07-12-agent-scope-runtime-design.md | 2 +- .../2026-07-12-scoped-layers-store.i18n.yaml | 2 +- .../2026-07-12-scoped-layers-store.md | 4 +- ...-14-provider-routed-llm-adapters.i18n.yaml | 4 +- ...2026-07-14-provider-routed-llm-adapters.md | 4 +- ...6-07-14-provider-routed-llm-adapters.zh.md | 4 +- .../2026-07-15-lsp-capability-seam.i18n.yaml | 2 +- .../2026-07-15-lsp-capability-seam.md | 2 +- ...07-16-explicit-turn-cancellation.i18n.yaml | 2 +- .../2026-07-16-explicit-turn-cancellation.md | 2 +- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 4 +- ...026-07-19-gui-layering-and-rpc-protocol.md | 4 +- ...-07-19-gui-layering-and-rpc-protocol.zh.md | 4 +- ...7-19-gui-web-client-architecture.i18n.yaml | 2 +- .../2026-07-19-gui-web-client-architecture.md | 2 +- ...kage-invariant-runtime-contracts.i18n.yaml | 4 +- ...-19-package-invariant-runtime-contracts.md | 2 +- ...-package-invariant-runtime-contracts.zh.md | 2 +- ...-package-owned-invariant-service.i18n.yaml | 4 +- ...6-07-19-package-owned-invariant-service.md | 6 +- ...7-19-package-owned-invariant-service.zh.md | 6 +- ...2-slot-type-chain-implementation.i18n.yaml | 4 +- ...26-07-22-slot-type-chain-implementation.md | 8 +- ...07-22-slot-type-chain-implementation.zh.md | 4 +- ...7-23-client-plugin-loading-model.i18n.yaml | 4 +- .../2026-07-23-client-plugin-loading-model.md | 6 +- ...26-07-23-client-plugin-loading-model.zh.md | 4 +- .../2026-07-23-toolview-dissolution.i18n.yaml | 4 +- .../2026-07-23-toolview-dissolution.md | 2 +- .../2026-07-23-toolview-dissolution.zh.md | 2 +- ...tree-boot-and-transport-layering.i18n.yaml | 2 +- ...config-tree-boot-and-transport-layering.md | 2 +- ...ession-scope-and-provide-channel.i18n.yaml | 2 +- ...lient-session-scope-and-provide-channel.md | 2 +- .../2026-07-26-task-registry-seam.i18n.yaml | 4 +- .../2026-07-26-task-registry-seam.md | 10 +- .../2026-07-26-task-registry-seam.zh.md | 8 +- ...07-28-api-browser-trust-boundary.i18n.yaml | 2 +- .../2026-07-28-api-browser-trust-boundary.md | 2 +- ...08-08-per-preset-standing-mounts.i18n.yaml | 2 +- .../2026-08-08-per-preset-standing-mounts.md | 2 +- ...6-08-10-message-feedback-sidecar.i18n.yaml | 4 +- .../2026-08-10-message-feedback-sidecar.md | 4 +- .../2026-08-10-message-feedback-sidecar.zh.md | 2 +- ...20-error-cause-chain-diagnostics.i18n.yaml | 2 +- ...026-07-20-error-cause-chain-diagnostics.md | 4 +- ...mpty-model-response-is-retryable.i18n.yaml | 2 +- ...07-24-empty-model-response-is-retryable.md | 2 +- .../feature/2026-06-15-code-mode.i18n.yaml | 2 +- .../feature/2026-06-15-code-mode.md | 2 +- ...26-06-17-filesystem-tool-schemas.i18n.yaml | 2 +- .../2026-06-17-filesystem-tool-schemas.md | 6 +- ...06-18-compaction-capability-seam.i18n.yaml | 2 +- .../2026-06-18-compaction-capability-seam.md | 2 +- .../2026-06-30-hook-protocol-lib.i18n.yaml | 2 +- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- ...30-interception-extension-points.i18n.yaml | 2 +- ...026-06-30-interception-extension-points.md | 2 +- ...026-06-30-session-store-fork-api.i18n.yaml | 2 +- .../2026-06-30-session-store-fork-api.md | 4 +- .../2026-07-05-dynamic-workflows.i18n.yaml | 2 +- .../feature/2026-07-05-dynamic-workflows.md | 6 +- .../2026-07-06-approval-seam.i18n.yaml | 2 +- .../feature/2026-07-06-approval-seam.md | 2 +- .../2026-07-06-explicit-tool-order.i18n.yaml | 2 +- .../feature/2026-07-06-explicit-tool-order.md | 2 +- .../feature/2026-07-06-sandbox.i18n.yaml | 2 +- .../implemented/feature/2026-07-06-sandbox.md | 2 +- .../2026-07-07-mcp-client-plugin.i18n.yaml | 2 +- .../feature/2026-07-07-mcp-client-plugin.md | 2 +- ...-07-08-background-subagent-tasks.i18n.yaml | 4 +- .../2026-07-08-background-subagent-tasks.md | 6 +- ...2026-07-08-background-subagent-tasks.zh.md | 4 +- ...-self-referential-cordis-toolset.i18n.yaml | 2 +- ...6-07-08-self-referential-cordis-toolset.md | 8 +- ...ession-identity-and-log-location.i18n.yaml | 2 +- ...agent-session-identity-and-log-location.md | 6 +- .../2026-07-16-harness-level-loop.i18n.yaml | 4 +- .../feature/2026-07-16-harness-level-loop.md | 6 +- .../2026-07-16-harness-level-loop.zh.md | 4 +- ...-fresh-agent-ralph-workflow-tool.i18n.yaml | 4 +- ...6-07-19-fresh-agent-ralph-workflow-tool.md | 10 +- ...7-19-fresh-agent-ralph-workflow-tool.zh.md | 4 +- .../2026-07-19-human-goal-command.i18n.yaml | 2 +- .../feature/2026-07-19-human-goal-command.md | 6 +- ...26-07-19-model-facing-goal-tools.i18n.yaml | 4 +- .../2026-07-19-model-facing-goal-tools.md | 8 +- .../2026-07-19-model-facing-goal-tools.zh.md | 6 +- ...rsisted-same-session-goal-domain.i18n.yaml | 2 +- ...7-19-persisted-same-session-goal-domain.md | 2 +- ...7-19-plugin-command-registration.i18n.yaml | 2 +- .../2026-07-19-plugin-command-registration.md | 2 +- ...-20-code-mode-typed-tool-returns.i18n.yaml | 2 +- ...2026-07-20-code-mode-typed-tool-returns.md | 2 +- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 4 +- ...-21-continuable-background-subagents.zh.md | 4 +- ...ge-input-and-durable-attachments.i18n.yaml | 2 +- ...dal-image-input-and-durable-attachments.md | 2 +- ...026-07-23-web-assistant-markdown.i18n.yaml | 2 +- .../2026-07-23-web-assistant-markdown.md | 2 +- ...26-07-28-skill-invocation-policy.i18n.yaml | 2 +- .../2026-07-28-skill-invocation-policy.md | 2 +- .../2026-08-03-fs-tool-error-remedy.i18n.yaml | 2 +- .../2026-08-03-fs-tool-error-remedy.md | 2 +- ...-20-core-data-structures-catalog.i18n.yaml | 2 +- ...2026-06-20-core-data-structures-catalog.md | 2 +- ...=> 2026-07-06-export-jsdoc-gate.i18n.yaml} | 6 +- ...ate.md => 2026-07-06-export-jsdoc-gate.md} | 12 +- ....md => 2026-07-06-export-jsdoc-gate.zh.md} | 2 +- .../2026-07-06-node-engine-floor.i18n.yaml | 2 +- .../process/2026-07-06-node-engine-floor.md | 2 +- ...ackage-model-experience-contract.i18n.yaml | 2 +- ...07-12-package-model-experience-contract.md | 6 +- ...emove-generated-agent-note-index.i18n.yaml | 2 +- ...07-19-remove-generated-agent-note-index.md | 2 +- ...package-anchored-subsystem-pages.i18n.yaml | 2 +- ...-08-03-package-anchored-subsystem-pages.md | 2 +- ...-19-drop-mutable-session-summary.i18n.yaml | 2 +- ...2026-06-19-drop-mutable-session-summary.md | 2 +- ...026-06-20-public-agent-stop-api.i18n.yaml} | 6 +- ...md => 2026-06-20-public-agent-stop-api.md} | 2 +- ...=> 2026-06-20-public-agent-stop-api.zh.md} | 2 +- .../2026-06-26-fsspec-style-fs-seam.i18n.yaml | 2 +- .../2026-06-26-fsspec-style-fs-seam.md | 2 +- ...lan-specific-collaboration-state.i18n.yaml | 2 +- ...07-22-plan-specific-collaboration-state.md | 2 +- .../2026-08-08-remove-cli-demo.i18n.yaml | 2 +- .../2026-08-08-remove-cli-demo.md | 2 +- .../2026-06-19-acp-snapshot-tests.i18n.yaml | 2 +- .../testing/2026-06-19-acp-snapshot-tests.md | 4 +- .../2026-06-19-real-api-e2e-ci.i18n.yaml | 2 +- .../testing/2026-06-19-real-api-e2e-ci.md | 2 +- .../2026-06-16-typed-event-schemas.i18n.yaml | 2 +- .../2026-06-16-typed-event-schemas.md | 2 +- ...sdk-project-editing-architecture.i18n.yaml | 2 +- ...-07-15-sdk-project-editing-architecture.md | 2 +- ...2026-07-06-recallable-compaction.i18n.yaml | 2 +- .../2026-07-06-recallable-compaction.md | 2 +- ...-07-08-interactive-side-sessions.i18n.yaml | 2 +- .../2026-07-08-interactive-side-sessions.md | 10 +- ...026-07-14-sdk-developer-projects.i18n.yaml | 4 +- .../2026-07-14-sdk-developer-projects.md | 4 +- .../2026-07-14-sdk-developer-projects.zh.md | 2 +- ...2026-06-11-api-extractor-reports.i18n.yaml | 2 +- .../2026-06-11-api-extractor-reports.md | 10 +- ...07-04-prune-dead-core-spine-api.i18n.yaml} | 6 +- ...> 2026-07-04-prune-dead-core-spine-api.md} | 12 +- ...026-07-04-prune-dead-core-spine-api.zh.md} | 2 +- ...apse-workflow-to-foreground-core.i18n.yaml | 2 +- ...12-collapse-workflow-to-foreground-core.md | 2 +- ...prune-unused-skill-registry-api.i18n.yaml} | 6 +- ...-07-12-prune-unused-skill-registry-api.md} | 4 +- ...-12-prune-unused-skill-registry-api.zh.md} | 2 +- .../skills/dsh-find-simplifications/SKILL.md | 10 +- .agents/skills/dsh-prose-standard/SKILL.md | 2 +- .github/workflows/ci.yml | 2 +- apps/web/tests/shipped-composition.e2e.ts | 2 +- apps/web/vite.config.ts | 2 +- docs/agent-lifecycle.i18n.yaml | 2 +- docs/agent-lifecycle.md | 2 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 4 +- docs/capability-seams.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 6 +- docs/config-catalog.zh.md | 2 +- docs/cookbook/adding-a-package.i18n.yaml | 4 +- docs/cookbook/adding-a-package.md | 4 +- docs/cookbook/adding-a-package.zh.md | 4 +- docs/cookbook/adding-a-tool.i18n.yaml | 4 +- docs/cookbook/adding-a-tool.md | 2 +- docs/cookbook/adding-a-tool.zh.md | 2 +- docs/cordis-api/inherited.md | 2 +- docs/glossary.i18n.yaml | 2 +- docs/glossary.md | 2 +- docs/graph-atlas.i18n.yaml | 2 +- docs/graph-atlas.md | 2 +- docs/i18n/style-samples.md | 2 +- docs/i18n/terminology.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- ...ession-disabled-filesystem-tools.i18n.yaml | 2 +- ...js-expression-disabled-filesystem-tools.md | 2 +- docs/subsystems/approval.i18n.yaml | 4 +- docs/subsystems/approval.md | 4 +- docs/subsystems/approval.zh.md | 4 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 4 +- docs/subsystems/attachment.zh.md | 4 +- docs/subsystems/bash.i18n.yaml | 4 +- docs/subsystems/bash.md | 6 +- docs/subsystems/bash.zh.md | 6 +- docs/subsystems/client-modules.i18n.yaml | 4 +- docs/subsystems/client-modules.md | 4 +- docs/subsystems/client-modules.zh.md | 4 +- docs/subsystems/code-runtime.i18n.yaml | 4 +- docs/subsystems/code-runtime.md | 4 +- docs/subsystems/code-runtime.zh.md | 4 +- docs/subsystems/commands.i18n.yaml | 4 +- docs/subsystems/commands.md | 6 +- docs/subsystems/commands.zh.md | 6 +- docs/subsystems/compaction.i18n.yaml | 4 +- docs/subsystems/compaction.md | 4 +- docs/subsystems/compaction.zh.md | 4 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 6 +- docs/subsystems/core.zh.md | 6 +- docs/subsystems/credentials.i18n.yaml | 4 +- docs/subsystems/credentials.md | 4 +- docs/subsystems/credentials.zh.md | 4 +- docs/subsystems/feedback.i18n.yaml | 4 +- docs/subsystems/feedback.md | 8 +- docs/subsystems/feedback.zh.md | 6 +- docs/subsystems/filesystem.i18n.yaml | 4 +- docs/subsystems/filesystem.md | 6 +- docs/subsystems/filesystem.zh.md | 6 +- docs/subsystems/goal.i18n.yaml | 4 +- docs/subsystems/goal.md | 4 +- docs/subsystems/goal.zh.md | 4 +- docs/subsystems/http-server.i18n.yaml | 4 +- docs/subsystems/http-server.md | 4 +- docs/subsystems/http-server.zh.md | 4 +- docs/subsystems/invariants.i18n.yaml | 4 +- docs/subsystems/invariants.md | 4 +- docs/subsystems/invariants.zh.md | 4 +- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 6 +- docs/subsystems/llm-streaming.zh.md | 6 +- docs/subsystems/permission.i18n.yaml | 4 +- docs/subsystems/permission.md | 4 +- docs/subsystems/permission.zh.md | 4 +- docs/subsystems/persistence.i18n.yaml | 4 +- docs/subsystems/persistence.md | 4 +- docs/subsystems/persistence.zh.md | 4 +- docs/subsystems/plan.i18n.yaml | 4 +- docs/subsystems/plan.md | 4 +- docs/subsystems/plan.zh.md | 4 +- docs/subsystems/pty.i18n.yaml | 4 +- docs/subsystems/pty.md | 4 +- docs/subsystems/pty.zh.md | 4 +- docs/subsystems/sandbox.i18n.yaml | 4 +- docs/subsystems/sandbox.md | 4 +- docs/subsystems/sandbox.zh.md | 4 +- docs/subsystems/scope.i18n.yaml | 2 +- docs/subsystems/scope.md | 2 +- docs/subsystems/session-projection.i18n.yaml | 4 +- docs/subsystems/session-projection.md | 4 +- docs/subsystems/session-projection.zh.md | 4 +- docs/subsystems/session-query.i18n.yaml | 4 +- docs/subsystems/session-query.md | 4 +- docs/subsystems/session-query.zh.md | 4 +- docs/subsystems/session-reference.i18n.yaml | 4 +- docs/subsystems/session-reference.md | 4 +- docs/subsystems/session-reference.zh.md | 4 +- docs/subsystems/session-title.i18n.yaml | 4 +- docs/subsystems/session-title.md | 4 +- docs/subsystems/session-title.zh.md | 4 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 4 +- docs/subsystems/session.zh.md | 4 +- docs/subsystems/settings.i18n.yaml | 4 +- docs/subsystems/settings.md | 4 +- docs/subsystems/settings.zh.md | 4 +- docs/subsystems/skills.i18n.yaml | 4 +- docs/subsystems/skills.md | 4 +- docs/subsystems/skills.zh.md | 4 +- docs/subsystems/spill.i18n.yaml | 4 +- docs/subsystems/spill.md | 4 +- docs/subsystems/spill.zh.md | 4 +- docs/subsystems/storage.i18n.yaml | 4 +- docs/subsystems/storage.md | 4 +- docs/subsystems/storage.zh.md | 4 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 4 +- docs/subsystems/subagent.zh.md | 4 +- docs/subsystems/subprocess.i18n.yaml | 4 +- docs/subsystems/subprocess.md | 4 +- docs/subsystems/subprocess.zh.md | 4 +- docs/subsystems/system-prompt.i18n.yaml | 4 +- docs/subsystems/system-prompt.md | 4 +- docs/subsystems/system-prompt.zh.md | 4 +- docs/subsystems/tasks.i18n.yaml | 4 +- docs/subsystems/tasks.md | 26 +- docs/subsystems/tasks.zh.md | 24 +- docs/subsystems/telemetry.i18n.yaml | 4 +- docs/subsystems/telemetry.md | 4 +- docs/subsystems/telemetry.zh.md | 4 +- docs/subsystems/token-meter.i18n.yaml | 4 +- docs/subsystems/token-meter.md | 4 +- docs/subsystems/token-meter.zh.md | 4 +- docs/subsystems/tools.i18n.yaml | 4 +- docs/subsystems/tools.md | 10 +- docs/subsystems/tools.zh.md | 8 +- docs/subsystems/typert.i18n.yaml | 4 +- docs/subsystems/typert.md | 4 +- docs/subsystems/typert.zh.md | 4 +- docs/subsystems/user-interaction.i18n.yaml | 4 +- docs/subsystems/user-interaction.md | 6 +- docs/subsystems/user-interaction.zh.md | 6 +- docs/subsystems/web.i18n.yaml | 4 +- docs/subsystems/web.md | 8 +- docs/subsystems/web.zh.md | 6 +- docs/subsystems/workflow.i18n.yaml | 4 +- docs/subsystems/workflow.md | 4 +- docs/subsystems/workflow.zh.md | 4 +- docs/subsystems/workspace.i18n.yaml | 4 +- docs/subsystems/workspace.md | 4 +- docs/subsystems/workspace.zh.md | 4 +- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 16 +- docs/tool-catalog.zh.md | 4 +- docs/tool-execution-pipeline.i18n.yaml | 2 +- docs/tool-execution-pipeline.md | 2 +- examples/jsonrpc-agent/README.i18n.yaml | 2 +- examples/jsonrpc-agent/README.md | 4 +- native/landlock-run/AGENTS.md | 2 +- native/landlock-run/packages/entry/src/main.c | 2 +- packages/README.i18n.yaml | 2 +- packages/README.md | 82 +++---- packages/api/remotes/README.i18n.yaml | 2 +- packages/api/remotes/README.md | 2 +- packages/bash/bash-local/README.i18n.yaml | 4 +- packages/bash/bash-local/README.md | 2 +- packages/bash/bash-local/README.zh.md | 2 +- .../bash/bash-local/tests/executor.spec.ts | 2 +- packages/bash/bash/README.i18n.yaml | 4 +- packages/bash/bash/README.md | 2 +- packages/bash/bash/README.zh.md | 2 +- packages/bash/tool-bash/README.i18n.yaml | 4 +- packages/bash/tool-bash/README.md | 2 +- packages/bash/tool-bash/README.zh.md | 2 +- packages/bash/tool-bash/src/render.ts | 2 +- packages/bash/tool-bash/tests/tools.spec.ts | 6 +- packages/bash/tool-pwsh/tests/tools.spec.ts | 6 +- packages/boot/app-boot/src/profile.ts | 2 +- packages/bundle/base/cordis.patch.yml | 2 +- packages/client/AGENTS.md | 4 +- .../client/connection/src/client/fixture.ts | 2 +- .../client/connection/src/client/index.ts | 2 +- .../connection/tests/client-apply.spec.ts | 2 +- packages/client/locale/src/invariant.ts | 2 +- packages/client/modules/README.i18n.yaml | 2 +- packages/client/modules/README.md | 2 +- .../client/modules/src/client/manifest.ts | 18 +- packages/client/modules/src/client/system.ts | 20 +- packages/client/modules/tests/loader.spec.ts | 18 +- .../runtime/src/client/contract/store.ts | 4 +- packages/client/runtime/src/client/index.ts | 2 +- .../runtime/src/client/sessions/manager.ts | 4 +- .../runtime/src/client/sessions/session.ts | 4 +- packages/client/tsdown.client.ts | 2 +- .../client/ui-conversation/README.i18n.yaml | 2 +- packages/client/ui-conversation/README.md | 2 +- .../ui-conversation/src/client/service.ts | 2 +- .../tests/apply-inject.spec.tsx | 64 ++--- packages/client/ui-goal/README.i18n.yaml | 2 +- packages/client/ui-goal/README.md | 2 +- packages/client/ui-layout/README.i18n.yaml | 2 +- packages/client/ui-layout/README.md | 2 +- packages/client/ui-layout/src/client/index.ts | 4 +- packages/client/ui-layout/tests/apply.spec.ts | 4 +- packages/client/ui-model/README.i18n.yaml | 2 +- packages/client/ui-model/README.md | 2 +- .../client/ui-permission/README.i18n.yaml | 2 +- packages/client/ui-permission/README.md | 2 +- .../client/ui-primitives/src/invariant.ts | 2 +- packages/client/ui-sidebar/README.i18n.yaml | 2 +- packages/client/ui-sidebar/README.md | 2 +- packages/client/ui-skill/README.i18n.yaml | 2 +- packages/client/ui-skill/README.md | 2 +- packages/client/ui-slash/README.i18n.yaml | 2 +- packages/client/ui-slash/README.md | 2 +- packages/client/ui-slots/src/renderer.ts | 8 +- packages/client/ui-slots/tests/core.spec.ts | 4 +- .../{surface.spec.ts => dynamic-keys.spec.ts} | 22 +- .../client/ui-slots/tests/type-chain.spec.tsx | 2 +- packages/client/ui-theme/README.i18n.yaml | 2 +- packages/client/ui-theme/README.md | 2 +- .../ui-trajectory/tests/client-bundle.spec.ts | 16 +- packages/client/web-react/src/index.ts | 2 +- .../client/web-react/src/session-provider.tsx | 4 +- .../tests/scoped-slots-real-core.spec.tsx | 2 +- packages/client/web/README.i18n.yaml | 2 +- packages/client/web/README.md | 2 +- packages/client/web/src/seed.ts | 2 +- .../code-runtime-worker/README.i18n.yaml | 2 +- .../code-runtime-worker/README.md | 4 +- .../code-runtime-worker/src/bootstrap.ts | 4 +- .../code-runtime/README.i18n.yaml | 2 +- packages/code-runtime/code-runtime/README.md | 2 +- packages/core/agent-loop/README.i18n.yaml | 2 +- packages/core/agent-loop/README.md | 2 +- .../agent-loop/tests/interception.spec.ts | 2 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 2 +- packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 2 +- packages/core/agent/README.zh.md | 2 +- packages/core/agent/src/index.ts | 2 +- .../agent/tests/verify-export-jsdoc.spec.ts | 16 +- packages/core/scope/README.i18n.yaml | 2 +- packages/core/scope/README.md | 6 +- packages/core/scope/src/index.ts | 4 +- packages/core/system-prompt/README.i18n.yaml | 2 +- packages/core/system-prompt/README.md | 2 +- packages/core/tools/README.i18n.yaml | 2 +- packages/core/tools/README.md | 2 +- packages/core/tools/src/code-mode.ts | 4 +- packages/core/tools/src/index.ts | 6 +- packages/core/tools/src/presentation.ts | 2 +- packages/core/tools/src/schema.ts | 2 +- packages/core/tools/src/ts-types.ts | 2 +- packages/core/tools/tests/code-mode.spec.ts | 2 +- packages/core/tools/tests/tools.spec.ts | 2 +- packages/examples/acp-demo/src/index.ts | 2 +- packages/feedback/README.i18n.yaml | 4 +- packages/feedback/README.md | 2 +- packages/feedback/README.zh.md | 2 +- .../feedback/command-feedback/src/index.ts | 2 +- .../tests/command-feedback.spec.ts | 2 +- .../message-feedback/README.i18n.yaml | 2 +- packages/feedback/message-feedback/README.md | 2 +- .../message-feedback/tests/helpers.ts | 2 +- packages/fs/fs-local/README.i18n.yaml | 2 +- packages/fs/fs-local/README.md | 2 +- packages/fs/fs-policy/package.json | 2 +- packages/fs/fs-policy/tests/policy.spec.ts | 2 +- packages/fs/fs/README.i18n.yaml | 2 +- packages/fs/fs/README.md | 2 +- packages/fs/fs/src/types.ts | 2 +- .../src/{surface.ts => direct-call.ts} | 6 +- packages/fs/tool-fs-search/src/glob.ts | 4 +- packages/fs/tool-fs-search/src/grep.ts | 4 +- packages/fs/tool-fs-search/src/search-core.ts | 2 +- .../fs/tool-fs-search/tests/tools.spec.ts | 4 +- packages/fs/tool-fs/src/edit.ts | 6 +- packages/fs/tool-fs/src/index.ts | 6 +- packages/fs/tool-fs/src/sandbox.ts | 6 +- packages/fs/tool-fs/src/write.ts | 6 +- packages/fs/tool-fs/tests/tools.spec.ts | 6 +- packages/goal/command-goal/README.i18n.yaml | 2 +- packages/goal/command-goal/README.md | 2 +- packages/goal/tool-goal/README.i18n.yaml | 4 +- packages/goal/tool-goal/README.md | 2 +- packages/goal/tool-goal/README.zh.md | 2 +- packages/hooks/hook-protocol/README.i18n.yaml | 2 +- packages/hooks/hook-protocol/README.md | 2 +- packages/hooks/hook-protocol/src/runner.ts | 2 +- packages/host/apiproxy/src/api/index.ts | 2 +- packages/host/apiproxy/src/api/rpc.ts | 2 +- .../host/apiproxy/src/api/sessions.schema.ts | 2 +- packages/host/apiproxy/src/fetch/client.ts | 2 +- .../apiproxy/tests/api-proxy-tasks.spec.ts | 2 +- .../interaction/commands/README.i18n.yaml | 4 +- packages/interaction/commands/README.md | 2 +- packages/interaction/commands/README.zh.md | 2 +- packages/interaction/commands/package.json | 2 +- packages/interaction/commands/src/index.ts | 2 +- .../interaction/user-interaction/src/index.ts | 2 +- packages/llm/llm-pi-ai/README.i18n.yaml | 2 +- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm/README.i18n.yaml | 2 +- packages/llm/llm/README.md | 4 +- packages/llm/llm/src/index.ts | 4 +- .../context-breakdown-projection.spec.ts | 2 +- packages/lsp/lsp/README.i18n.yaml | 2 +- packages/lsp/lsp/README.md | 2 +- packages/mcp/mcp-client/README.i18n.yaml | 2 +- packages/mcp/mcp-client/README.md | 2 +- packages/plan/plan-mode/README.i18n.yaml | 2 +- packages/plan/plan-mode/README.md | 2 +- .../plan/plan-mode/tests/plan-mode.spec.ts | 4 +- .../pty/tool-bash-persistent/src/invariant.ts | 2 +- packages/pty/tool-pty/README.i18n.yaml | 4 +- packages/pty/tool-pty/README.md | 2 +- packages/pty/tool-pty/README.zh.md | 2 +- packages/pty/tool-pty/tests/tools.spec.ts | 2 +- .../sandbox/sandbox-policy/README.i18n.yaml | 2 +- packages/sandbox/sandbox-policy/README.md | 2 +- .../sandbox-windows-acl/README.i18n.yaml | 2 +- .../sandbox/sandbox-windows-acl/README.md | 2 +- .../scaffold/client/tests/dispose.spec.ts | 2 +- packages/scaffold/create-sdk/README.i18n.yaml | 2 +- packages/scaffold/create-sdk/README.md | 4 +- packages/scaffold/create-sdk/src/args.ts | 2 +- packages/scaffold/scripts/src/args.ts | 2 +- packages/scaffold/server/src/server.ts | 2 +- packages/self-modification/README.i18n.yaml | 2 +- packages/self-modification/README.md | 2 +- .../tool-cordis/src/api-catalog.ts | 16 +- .../tool-cordis/src/guard.ts | 4 +- .../tool-cordis/src/inspect.ts | 2 +- .../tool-cordis/tests/inspect.spec.ts | 2 +- .../tool-cordis/tests/tool-cordis.spec.ts | 2 +- .../README.i18n.yaml | 2 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/src/index.ts | 4 +- .../session-persistence-jsonl/src/win32.ts | 2 +- .../tests/jsonl.spec.ts | 2 +- .../README.i18n.yaml | 2 +- .../session-persistence-sqlite/README.md | 2 +- .../session-persistence-sqlite/src/index.ts | 2 +- .../session-persistence/README.i18n.yaml | 2 +- .../session/session-persistence/README.md | 2 +- .../session-persistence/src/coordinator.ts | 2 +- .../tests/persistence.spec.ts | 2 +- packages/skill/skill/README.i18n.yaml | 2 +- packages/skill/skill/README.md | 2 +- packages/skill/tool-skill/README.i18n.yaml | 2 +- packages/skill/tool-skill/README.md | 2 +- packages/spill/spill-policy/README.i18n.yaml | 2 +- packages/spill/spill-policy/README.md | 4 +- packages/spill/spill-policy/package.json | 2 +- packages/subagent/subagent-acp/src/run.ts | 2 +- packages/subagent/subagent/README.i18n.yaml | 2 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/src/index.ts | 2 +- .../subagent/tests/continuation.spec.ts | 2 +- .../tool-subagent-control/src/index.ts | 2 +- .../tool-subagent/tests/tool-subagent.spec.ts | 4 +- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- packages/support/acp-snapshot/src/launcher.ts | 2 +- packages/support/invariants/README.i18n.yaml | 2 +- packages/support/invariants/README.md | 2 +- .../support/invariants/tests/service.spec.ts | 2 +- packages/tasks/tasks-local/README.i18n.yaml | 4 +- packages/tasks/tasks-local/README.md | 6 +- packages/tasks/tasks-local/README.zh.md | 6 +- packages/tasks/tasks-local/src/index.ts | 28 +-- .../tasks/tasks-local/tests/tasks.spec.ts | 62 ++--- packages/tasks/tasks/README.i18n.yaml | 4 +- packages/tasks/tasks/README.md | 10 +- packages/tasks/tasks/README.zh.md | 10 +- packages/tasks/tasks/src/index.ts | 14 +- packages/tasks/tasks/src/types.ts | 6 +- packages/tasks/tasks/tests/service.spec.ts | 6 +- packages/tasks/tool-tasks/README.i18n.yaml | 4 +- packages/tasks/tool-tasks/README.md | 10 +- packages/tasks/tool-tasks/README.zh.md | 10 +- packages/tasks/tool-tasks/src/index.ts | 6 +- .../tasks/tool-tasks/tests/tool-tasks.spec.ts | 4 +- packages/typert/generator/README.i18n.yaml | 2 +- packages/typert/generator/README.md | 2 +- .../typert/generator/src/cordis-catalog.ts | 18 +- packages/typert/generator/src/index.ts | 2 +- packages/typert/generator/src/renderer.ts | 2 +- .../tests/cordis-catalog-contract.spec.ts | 2 +- packages/typert/type-meta/README.i18n.yaml | 2 +- packages/typert/type-meta/README.md | 2 +- packages/util/retention/README.i18n.yaml | 2 +- packages/util/retention/README.md | 2 +- packages/util/timeout/README.i18n.yaml | 2 +- packages/util/timeout/README.md | 2 +- packages/web/tool-web/README.i18n.yaml | 2 +- packages/web/tool-web/README.md | 2 +- .../web/web-search-deepseek/README.i18n.yaml | 2 +- packages/web/web-search-deepseek/README.md | 2 +- .../web/web-search-deepseek/src/provider.ts | 4 +- packages/web/web-search-deepseek/src/types.ts | 4 +- .../web/web-search-perplexity/src/provider.ts | 2 +- .../web/web-search-perplexity/src/types.ts | 4 +- packages/web/web/README.i18n.yaml | 2 +- packages/web/web/README.md | 2 +- packages/web/web/src/types.ts | 2 +- packages/workflow/tool-ralph/README.i18n.yaml | 2 +- packages/workflow/tool-ralph/README.md | 2 +- .../workflow/tool-workflow/README.i18n.yaml | 2 +- packages/workflow/tool-workflow/README.md | 2 +- .../tests/workflow-workerthread.spec.ts | 2 +- packages/workflow/workflow/README.i18n.yaml | 2 +- packages/workflow/workflow/README.md | 2 +- .../workflow/workflow/tests/workflow.spec.ts | 2 +- python/sdk-runtime/README.i18n.yaml | 2 +- python/sdk-runtime/README.md | 2 +- .../runtime/cordis.yml | 2 +- scripts/cordis-core-api.ts | 2 +- scripts/gen-config-catalog.ts | 4 +- scripts/gen-cordis-catalog.ts | 77 +++--- scripts/gen-doc-graphs.ts | 10 +- scripts/gen-tool-catalog.ts | 16 +- scripts/jsdoc.ts | 8 +- scripts/translation-pairing.ts | 2 +- scripts/verify-client-domain-graph.ts | 4 +- scripts/verify-export-jsdoc.ts | 32 +-- scripts/verify-node-next-types.ts | 2 +- .../verify-package-readme-model-experience.ts | 222 +++++++++--------- tsconfig.base.client.json | 2 +- 626 files changed, 1396 insertions(+), 1397 deletions(-) rename .agents/notes/implemented/architecture/{2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml => 2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml} (64%) rename .agents/notes/implemented/architecture/{2026-06-30-bash-stdin-env-trusted-plugin-surface.md => 2026-06-30-bash-stdin-env-trusted-plugin-api.md} (92%) rename .agents/notes/implemented/architecture/{2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md => 2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md} (98%) rename .agents/notes/implemented/process/{2026-07-06-export-surface-jsdoc-gate.i18n.yaml => 2026-07-06-export-jsdoc-gate.i18n.yaml} (57%) rename .agents/notes/implemented/process/{2026-07-06-export-surface-jsdoc-gate.md => 2026-07-06-export-jsdoc-gate.md} (85%) rename .agents/notes/implemented/process/{2026-07-06-export-surface-jsdoc-gate.zh.md => 2026-07-06-export-jsdoc-gate.zh.md} (99%) rename .agents/notes/implemented/simplification/{2026-06-20-public-agent-stop-surface.i18n.yaml => 2026-06-20-public-agent-stop-api.i18n.yaml} (57%) rename .agents/notes/implemented/simplification/{2026-06-20-public-agent-stop-surface.md => 2026-06-20-public-agent-stop-api.md} (98%) rename .agents/notes/implemented/simplification/{2026-06-20-public-agent-stop-surface.zh.md => 2026-06-20-public-agent-stop-api.zh.md} (98%) rename .agents/notes/proposed/simplification/{2026-07-04-prune-dead-core-spine-surface.i18n.yaml => 2026-07-04-prune-dead-core-spine-api.i18n.yaml} (56%) rename .agents/notes/proposed/simplification/{2026-07-04-prune-dead-core-spine-surface.md => 2026-07-04-prune-dead-core-spine-api.md} (94%) rename .agents/notes/proposed/simplification/{2026-07-04-prune-dead-core-spine-surface.zh.md => 2026-07-04-prune-dead-core-spine-api.zh.md} (99%) rename .agents/notes/rejected/simplification/{2026-07-12-prune-unused-skill-registry-surface.i18n.yaml => 2026-07-12-prune-unused-skill-registry-api.i18n.yaml} (54%) rename .agents/notes/rejected/simplification/{2026-07-12-prune-unused-skill-registry-surface.md => 2026-07-12-prune-unused-skill-registry-api.md} (96%) rename .agents/notes/rejected/simplification/{2026-07-12-prune-unused-skill-registry-surface.zh.md => 2026-07-12-prune-unused-skill-registry-api.zh.md} (98%) rename packages/client/ui-slots/tests/{surface.spec.ts => dynamic-keys.spec.ts} (71%) rename packages/fs/tool-fs-search/src/{surface.ts => direct-call.ts} (82%) diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml index ccf15a5c1e..fa02932d96 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.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/architecture/2026-06-13-capability-seams.md -2026-06-13-capability-seams.md: ca5071cff1b26ba3b89537a73f40d7ecbde4b2bb +2026-06-13-capability-seams.md: b2dc124f7bf0b56598466e6521e0764af06075ab 2026-06-13-capability-seams.zh.md: 7790124b980e0525705c2db398802ea6a418685a diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.md b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.md index ca5071cff1..b2dc124f7b 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.md +++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.md @@ -6,7 +6,7 @@ English | [中文](2026-06-13-capability-seams.zh.md) ## Problem -The harness has swappable capabilities — bash execution today, sandboxed/remote executors and alternative model providers tomorrow. A capability has three concerns that change at different rates and for different reasons: the *contract* (what the capability is), the *implementation* (how it runs), and the *consumer surface* (what the model and other plugins program against). Bundling them in one package couples those rates of change — swapping a local executor for a sandboxed one would churn the tool schemas the model sees, even though the model-facing contract never changed. +The harness has swappable capabilities — bash execution today, sandboxed/remote executors and alternative model providers tomorrow. A capability has three concerns that change at different rates and for different reasons: the *contract* (what the capability is), the *implementation* (how it runs), and the *consumer API* (what the model and other plugins program against). Bundling them in one package couples those rates of change — swapping a local executor for a sandboxed one would churn the tool schemas the model sees, even though the model-facing contract never changed. This is distinct from "who provides vs. needs a capability at runtime", which Cordis already answers with services + `inject` (a provider registers `ctx.bash`; a consumer declares `inject: ['bash']` and its fiber pends until the service exists). That mechanism is necessary but doesn't dictate package boundaries; this Agent Note does. diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml index 68bcd74197..bb80dd7ef1 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.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/architecture/2026-06-17-filesystem-capability-seam.md -2026-06-17-filesystem-capability-seam.md: 928ad4926e46ed5b6b35552b75882d565d8ad6db +2026-06-17-filesystem-capability-seam.md: 63628d592c17f000ae49f9558597bf8059f04942 2026-06-17-filesystem-capability-seam.zh.md: 02af77a3cc291eeba2be966a4bc08aa006675e7d diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md index 928ad4926e..63628d592c 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -12,7 +12,7 @@ That couples three concerns that change independently: 1. The filesystem contract: what operations plugins can ask for. 2. The backend: local disk now, sandboxed/remote/project-scoped filesystem later. -3. The consumer surface: model-facing `read` / `write` / `edit` schemas and result formatting. +3. The consumer API: model-facing `read` / `write` / `edit` schemas and result formatting. Without a `ctx.fs` interface, swapping local filesystem access for a sandboxed or remote backend would churn the tool schemas, demos, and prompt guidance even when the model-facing contract should stay stable. It also makes permission/sandbox boundaries harder to reason about: a `cwd` option can look like a sandbox even though it is only a base path unless an explicit backend or `tools/execute` policy enforces containment. @@ -139,7 +139,7 @@ The defensive-pattern classes this repo has been bitten by are pinned directly: ## Alternatives considered - **Model-facing tools directly over `node:fs`** — the tool package would own execution policy, path resolution, atomic writes, text decoding, and edit semantics at once, coupling the three independently-changing concerns the Problem names and churning schemas on any backend swap. -- **One combined `dsh-fs-tools` package** — the pre-seam shape; rejected for the same Service Definition / Service provider / Consumer split as bash, and the combined name never became public surface. +- **One combined `dsh-fs-tools` package** — the pre-seam shape; rejected for the same Service Definition / Service provider / Consumer split as bash, and the combined name never became public API. - **Observed-state on `ctx.fs`** — the shape this Agent Note first landed; superseded by [the split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [the event-gate Agent Note](2026-06-26-file-context-as-event-gate.md): a sandboxed/remote backend must not inherit model-facing observation policy, so the provider keeps only the version token and the optional version-guarded mutation. ## Consequences @@ -160,4 +160,4 @@ The defensive-pattern classes this repo has been bitten by are pinned directly: **Error codes become part of the seam.** `FsError` codes make stale-version and observation failures machine-routable through the existing structured error taxonomy. The cost is that `dsh-fs` imports the shared `HarnessError` base from `dsh-llm`; that dependency is intentional and stays limited to the error vocabulary. -**Package churn is front-loaded.** The three-package split adds boilerplate before there is more than one backend. This is intentional: filesystem access is a likely sandbox/remote boundary, and changing the package surface after shipping model-facing tools would be more expensive. +**Package churn is front-loaded.** The three-package split adds boilerplate before there is more than one backend. This is intentional: filesystem access is a likely sandbox/remote boundary, and changing the package API after shipping model-facing tools would be more expensive. diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.i18n.yaml index 4ea64d5530..ac99292150 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.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/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.md -2026-06-18-agent-lifecycle-and-ownership-contracts.md: ad334d30afcaf1864a1ea3dd42f3c5b7d157603b -2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md: b9240969c9e952c1b38cbd4c088853005e156067 +2026-06-18-agent-lifecycle-and-ownership-contracts.md: c3522d18dad2703664f12364e4d70cc057a3a945 +2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md: 16781c8a5d1a1c67d1fe9296b0f34f2a00b11caf diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.md b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.md index ad334d30af..c3522d18da 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.md +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.md @@ -43,7 +43,7 @@ The bash owner-token comparison relies on the shared `Agent.id`/`SessionId` bein - **A public `BashTask.owner` field** instead of the `BashExecutor.ownerOf(id)` Service Definition method — rejected: one read path, no redundant API. - **Sibling cordis effects for the agent's session lifecycle** — rejected: a fiber unload disposes sibling effects concurrently (`Promise.all`), racing removal of the store-owned append publication hooks against the loop's closing `session/flush`; the single composite effect's ordered LIFO chain is what captures the closing `turn/end` on both disposal paths. -- **A separate step-only `abort()` beside `cancel()`** — shipped originally, then removed as unused; `cancel()` is the single public stop primitive ([the public-stop-surface Agent Note](../simplification/2026-06-20-public-agent-stop-surface.md)). +- **A separate step-only `abort()` beside `cancel()`** — shipped originally, then removed as unused; `cancel()` is the single public stop primitive ([the public-stop-API Agent Note](../simplification/2026-06-20-public-agent-stop-api.md)). ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md index b9240969c9..16781c8a5d 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md @@ -43,7 +43,7 @@ bash 所有者 token 比较依赖共享的 `Agent.id`/`SessionId` 在存活 agen - **公开的 `BashTask.owner` 字段**而非 `BashExecutor.ownerOf(id)` Service Definition 方法:否决。一条读取路径即可,无需冗余 API。 - **为 agent 的会话生命周期使用兄弟 Cordis effect**:否决。fiber 卸载时并发释放兄弟 effect(`Promise.all`),store 拥有的 append 发布钩子的移除与循环的关闭 `session/flush` 产生竞争;单一复合 effect 的有序 LIFO 链才能在两条释放路径上都捕获关闭的 `turn/end`。 -- **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 Agent Note](../simplification/2026-06-20-public-agent-stop-surface.md))。 +- **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 Agent Note](../simplification/2026-06-20-public-agent-stop-api.md))。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index 00cff75149..05d506ea1f 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.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/architecture/2026-06-18-shared-persistence-write-coordinator.md -2026-06-18-shared-persistence-write-coordinator.md: c376bb4a209a250d33274dfd60531475354ba363 +2026-06-18-shared-persistence-write-coordinator.md: 93b6cd1bd058499e71948d3909de8e4c076b445e 2026-06-18-shared-persistence-write-coordinator.zh.md: 06a2dafc4d14c29bb7758fdc67d3f0f8136983e2 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index c376bb4a20..93b6cd1bd0 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -40,7 +40,7 @@ The shared `runPersistenceContract` (public-API contract) runs for every backend ## Alternatives considered - **A base class the backends extend** — rejected for composition: a backend exposes only the hooks, cannot reach the coordinator's private orchestration state, and a third-party backend may still implement the abstract service directly without the coordinator at all. -- **A wider hook surface** — each candidate hook folds away: there is no scope-specific live lookup because `loadStored` plus the coordinator's cwd check preserves the collision boundary, no storage-locator generic because validated JSONL metadata reproduces its path while SQLite is already id-bound, no separate `materialize` hook because the first batch must commit atomically with materialization, no separate create-collision probe because it is `loadStored(id) !== undefined`, and no coordinator pass-through for `list()` because listing needs none of the orchestration. +- **A wider hook API** — each candidate hook folds away: there is no scope-specific live lookup because `loadStored` plus the coordinator's cwd check preserves the collision boundary, no storage-locator generic because validated JSONL metadata reproduces its path while SQLite is already id-bound, no separate `materialize` hook because the first batch must commit atomically with materialization, no separate create-collision probe because it is `loadStored(id) !== undefined`, and no coordinator pass-through for `list()` because listing needs none of the orchestration. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml index 2270bc6f5f..f6675dfc1d 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.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/architecture/2026-06-20-branded-ids.md -2026-06-20-branded-ids.md: 78242226103fef0eac320cdfac0ccb82292fd9e8 +2026-06-20-branded-ids.md: ded48409bcb3deb19e35029fa795b5ea28c9f6d7 2026-06-20-branded-ids.zh.md: 613ef81d198f10ec70ca5cb019776e607f4a8a79 diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md index 7824222610..ded48409bc 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md @@ -60,7 +60,7 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o ## Verification -The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end (Service Definition, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing surface) with no `dsh-bash` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`CallId`/`SessionId`/`BashTaskId`) is keyed by bare `string`; public method params and exported signatures keep the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`), never as scattered `as` casts. +The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end (Service Definition, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing tool) with no `dsh-bash` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`CallId`/`SessionId`/`BashTaskId`) is keyed by bare `string`; public method params and exported signatures keep the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`), never as scattered `as` casts. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml index 6da549618b..511aa0f0c3 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.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/architecture/2026-06-20-generic-long-running-tool-runtime.md -2026-06-20-generic-long-running-tool-runtime.md: 0dd49fe60f2c45081973657ff960b780d2d47257 -2026-06-20-generic-long-running-tool-runtime.zh.md: 6e8458aea73536859bf4c81894679d31a5034d3a +2026-06-20-generic-long-running-tool-runtime.md: 457eaac7de92ea37287803349672271148d59185 +2026-06-20-generic-long-running-tool-runtime.zh.md: 9a86c4cf2b49973212adfd4b9acdb54150af01d1 diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md index 0dd49fe60f..457eaac7de 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -25,7 +25,7 @@ Long-running tools are producers. `dsh-tool-bash` adapts a `BashProcess` into in The literal types live on the [tasks subsystem page](../../../../docs/subsystems/tasks.md). A producer calls `ctx.tasks.start()` with a kind, label, optional owning `Agent`, optional positive `outputLimitBytes`, and a `run()` function. The runtime completes all failable preflight work before calling `run()` and invokes it once. After `run()` returns hooks, registration commits without another failable step; a producer cannot start work that lacks a collectable task id. -`outputLimitBytes` is producer-owned presentation policy, not a registry buffer. The registry validates and projects it unchanged into `TaskSnapshot`; generic control surfaces apply the cap to complete model-facing output after adding their own status or notice metadata. Omitting it preserves the existing surface behavior, so the runtime does not impose a hidden default on unrelated producer families. +`outputLimitBytes` is producer-owned presentation policy, not a registry buffer. The registry validates and projects it unchanged into `TaskSnapshot`; generic control APIs apply the cap to complete model-facing output after adding their own status or notice metadata. Omitting it preserves the existing controller behavior, so the runtime does not impose a hidden default on unrelated producer families. A model-facing producer exposes that committed id in its canonical success value, normally `{ kind: 'background', taskId }`; Native rendering may keep human-readable prose. A pre-aborted background call fails rather than returning a no-op because no task exists to satisfy the promised handle. Once registration publishes the id, cancellation belongs to the task's own controller and the task runtime: later cancellation of the producing tool call must not kill the published task. `task_kill`, owner disposal, and service teardown request cancellation; foreground execution remains coupled to the call's `exec.signal`. @@ -39,7 +39,7 @@ Statuses are `running`, `stopping`, `completed`, `killed`, and `failed`. Produce The runtime attaches one continuation to `done`, records the first terminal outcome, resolves waiters, and invokes completion listeners with per-listener error containment. First-wins settlement matters during teardown: if `cancel` throws, the runtime force-fails the record and warns that work may be orphaned rather than waiting forever for a promise that may never settle. A later producer outcome cannot overwrite that diagnosis or notify twice. A `cancel` that returns without eventually settling `done` still blocks teardown because the runtime cannot distinguish it from a slow, valid stop. -Task registrations are not effects of the producer tool fiber. Reloading a tool or control-surface plugin therefore does not kill work owned by an agent and backend. The task service's own disposal cancels all live tasks and awaits contract-compliant producers. +Task registrations are not effects of the producer tool fiber. Reloading a tool or controller plugin therefore does not kill work owned by an agent and backend. The task service's own disposal cancels all live tasks and awaits contract-compliant producers. ## Authorization and owner lifecycle @@ -51,7 +51,7 @@ The first task for an owner attaches one asynchronous effect to `owner.ctx`. Age For contract-compliant producers, `AgentHandle.dispose()` resolves only after owned background work has stopped. Work intended to outlive an agent must be started unowned; survival across runtime restarts requires a separate durable-job design. -## Service surface +## Service API `TaskService` provides: @@ -61,13 +61,13 @@ For contract-compliant producers, `AgentHandle.dispose()` resolves only after ow - `kill(id, caller?, reason?)` for cancellation. - `wait(id, timeoutMs, caller?, signal?)` for bounded terminal waiting. - `onTaskDone(listener)` for effect-scoped observation with exact-owner delivery and listener containment. -- `attachSurface(name)` for the control-surface availability fence. +- `attachController(name)` for the task-controller availability fence. `wait` returns the terminal snapshot when the task settles or the live snapshot when its timeout expires. Aborting a wait cancels only that wait. If settlement has already assigned terminal delivery to the waiter, the terminal snapshot still wins. Waiters unregister synchronously on abort so a same-tick settlement cannot suppress a completion notice on behalf of a reader that receives nothing. -A producer loaded without any control surface would let callers start work they cannot collect or stop. `dsh-tool-tasks` therefore calls `attachSurface()` for its lifetime, and `start()` fails before producer execution when no surface is attached. This check occurs at start rather than plugin load because sibling plugins may activate concurrently. Custom non-model surfaces can attach themselves without teaching the registry tool names. +A producer loaded without any controller would let callers start work they cannot collect or stop. `dsh-tool-tasks` therefore calls `attachController()` for its lifetime, and `start()` fails before producer execution when no controller is attached. This check occurs at start rather than plugin load because sibling plugins may activate concurrently. Custom non-model controllers can attach themselves without teaching the registry tool names. -## Model-facing control surface +## Model-facing control API `dsh-tool-tasks` registers three kind-independent tools with generic UI cards: @@ -79,13 +79,13 @@ Stream reads share one task-scoped consuming cursor because the owning model is The system prompt tells the model to retain task ids, continue independent work instead of busy-polling or duplicating a running task, collect relevant tasks before its final answer, and kill work that no longer matters. Completion injects a logged `context/message` into the exact owner's session; it becomes durable context for the next request but does not wake an idle agent. -The runtime marks a terminal task `reported` when a read or wait delivers it, when a live waiter has claimed delivery at settlement, or when the model explicitly kills it. Reported tasks do not inject redundant completion notices. Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown. When a snapshot carries `outputLimitBytes`, `dsh-tool-tasks` preserves UTF-8 boundaries and reuses an existing producer truncation marker rather than duplicating it. Reads reserve status suffixes and retain the output tail; completion notices reserve the stable `background task ` prefix and `task_output` instruction before truncating variable kind, label, status, detail, or the truncation marker itself, so the minimum PTY cap still identifies the task to collect. The task surface resolves the caller-visible producer cap in a prepended pre-execute listener before policy can deny or short-circuit dispatch, then applies it through the task definitions' last-mile `finalizeContent` callback so normalized tool errors, outer pipeline failures, and single-text policy results cannot escape the bound; deliberately structured multi-block policy results retain policy ownership of their shape and size. +The runtime marks a terminal task `reported` when a read or wait delivers it, when a live waiter has claimed delivery at settlement, or when the model explicitly kills it. Reported tasks do not inject redundant completion notices. Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown. When a snapshot carries `outputLimitBytes`, `dsh-tool-tasks` preserves UTF-8 boundaries and reuses an existing producer truncation marker rather than duplicating it. Reads reserve status suffixes and retain the output tail; completion notices reserve the stable `background task ` prefix and `task_output` instruction before truncating variable kind, label, status, detail, or the truncation marker itself, so the minimum PTY cap still identifies the task to collect. The task controller resolves the caller-visible producer cap in a prepended pre-execute listener before policy can deny or short-circuit dispatch, then applies it through the task definitions' last-mile `finalizeContent` callback so normalized tool errors, outer pipeline failures, and single-text policy results cannot escape the bound; deliberately structured multi-block policy results retain policy ownership of their shape and size. ## Producer opt-in Each producer owns whether its schema exposes `run_in_background` through defaulted config. `dsh-tool-bash`, `dsh-tool-pty`, and each `dsh-tool-subagent` instance use `enableRunInBackground`, defaulting to true. A disabled instance omits the parameter and also rejects a forced background argument at execution because the generic argument validator permits undeclared keys. Schema omission advertises the capability; the execution check enforces it. -`ctx.tasks` does not rewrite producer schemas. A bundle forwards configuration only for producers it owns. If a background call reaches `start()` without an attached surface, the runtime fence fails before execution. +`ctx.tasks` does not rewrite producer schemas. A bundle forwards configuration only for producers it owns. If a background call reaches `start()` without an attached controller, the runtime fence fails before execution. ## Producer integrations @@ -107,7 +107,7 @@ The current `TaskStart.run()` contract passes in-process callbacks and exact `Ag ### Consumer-owned authorization or cleanup events -Consumer-owned checks invite inconsistent or missing isolation on each new surface. A broadcast cleanup event makes every listener filter every agent and provides no registration disposer. Central authorization plus one owner-scoped effect gives every consumer the same fence and an awaited, removable lifecycle hook. +Consumer-owned checks invite inconsistent or missing isolation on each new controller. A broadcast cleanup event makes every listener filter every agent and provides no registration disposer. Central authorization plus one owner-scoped effect gives every consumer the same fence and an awaited, removable lifecycle hook. ### Blocking output or a separate wait tool @@ -125,7 +125,7 @@ Authorization, not unguessability, is the access boundary, and ids do not derive ## Testing -Unit coverage pins preflight atomicity, per-kind ids, output-limit validation and projection, complete UTF-8 result bounds, stream and final reads, wait timeout and abort races, cancellation, first-wins settlement, listener containment, notice suppression, owner isolation, stale owner instances, owner cleanup, service teardown, and the no-surface fence. Producer tests cover bash process mapping, subagent startup cancellation, terminal mapping, and disposal. Snapshot coverage pins the control-tool schemas and prompt guidance. +Unit coverage pins preflight atomicity, per-kind ids, output-limit validation and projection, complete UTF-8 result bounds, stream and final reads, wait timeout and abort races, cancellation, first-wins settlement, listener containment, notice suppression, owner isolation, stale owner instances, owner cleanup, service teardown, and the no-controller fence. Producer tests cover bash process mapping, subagent startup cancellation, terminal mapping, and disposal. Snapshot coverage pins the control-tool schemas and prompt guidance. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md index 6e8458aea7..9a86c4cf2b 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md @@ -25,7 +25,7 @@ Status: implemented 字面类型见[任务子系统页面](../../../../docs/subsystems/tasks.md)。生产方调用 `ctx.tasks.start()`,传入 kind、label、可选的所属 `Agent`、可选的正数 `outputLimitBytes` 与一个 `run()` 函数。运行时会在调用 `run()` 前完成所有可能失败的预检工作,并且只调用一次。`run()` 返回钩子后,注册过程不会再执行可能失败的步骤而直接提交;生产方无法启动没有可收集 task id 的工作。 -`outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `TaskSnapshot`;通用控制接口添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有接口行为,因此运行时不会向无关的生产方类别施加隐式默认值。 +`outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `TaskSnapshot`;通用任务控制器添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有控制器行为,因此运行时不会向无关的生产方类别施加隐式默认值。 面向模型的生产方会在规范成功值中暴露已提交的 id,通常为 `{ kind: 'background', taskId }`;Native 渲染仍可保留便于人类阅读的行文。预先被中止的后台调用会失败,而不是返回空操作,因为不存在可履行所承诺句柄的任务。一旦注册过程发布 id,取消就归任务自身的控制器与任务运行时所有:随后取消生产工具调用不得终止已发布的任务。`task_kill`、所有者资源释放和服务拆除会请求取消;前台执行仍与调用的 `exec.signal` 耦合。 @@ -39,7 +39,7 @@ Status: implemented 运行时为 `done` 附加一个 continuation,记录第一个终止结果、解决等待方,并逐个调用完成监听器,同时隔离每个监听器的错误。首次结果优先的结算在资源销毁期间至关重要:如果 `cancel` 抛出,运行时会强制将记录标为失败,并警告工作可能遗留,而不是永远等待一个可能永不完成的 promise。后续生产方结果不能覆盖该诊断,也不能重复通知。`cancel` 返回后如果最终未使 `done` 完成,仍会阻塞资源销毁,因为运行时无法区分这种情况与缓慢但有效的停止。 -任务注册不是生产方工具 fiber 的 effect。因此,重新加载工具或控制接口插件不会终止由 agent(智能体)和后端拥有的工作。任务服务自身释放时会取消所有实时任务,并等待遵守约定的生产方。 +任务注册不是生产方工具 fiber 的 effect。因此,重新加载工具或控制器插件不会终止由 agent(智能体)和后端拥有的工作。任务服务自身释放时会取消所有实时任务,并等待遵守约定的生产方。 ## 授权与所有者生命周期 @@ -61,13 +61,13 @@ task id 在运行时全局可见且可预测,因此注册表会授权每次访 - `kill(id, caller?, reason?)`:取消。 - `wait(id, timeoutMs, caller?, signal?)`:有界的终止等待。 - `onTaskDone(listener)`:effect 作用域内的观察,具有精确所有者投递和监听器隔离。 -- `attachSurface(name)`:控制接口可用性防线。 +- `attachController(name)`:任务控制器可用性防线。 `wait` 在任务完成时返回终止快照,在等待超时时返回实时快照。中止一次等待只取消该次等待。如果结算已经将终止投递分配给该等待方,终止快照仍然优先。等待方在中止时同步注销,因此同一 tick 内的结算无法代表一个什么也未收到的读取方压制完成通知。 -如果生产方加载时没有任何控制接口,调用方就能启动无法收集或停止的工作。因此,`dsh-tool-tasks` 在其整个生命周期内调用 `attachSurface()`;没有附加接口时,`start()` 会在生产方开始执行前失败。该检查发生在启动时而非插件加载时,因为兄弟插件可能并发激活。自定义的非模型接口可以自行附加,无需让注册表了解工具名称。 +如果生产方加载时没有任何任务控制器,调用方就能启动无法收集或停止的工作。因此,`dsh-tool-tasks` 在其整个生命周期内调用 `attachController()`;没有附加控制器时,`start()` 会在生产方开始执行前失败。该检查发生在启动时而非插件加载时,因为兄弟插件可能并发激活。自定义的非模型控制器可以自行附加,无需让注册表了解工具名称。 -## 面向模型的控制接口 +## 面向模型的控制器 `dsh-tool-tasks` 注册三个与 kind 无关的工具,并使用通用 UI 卡片: @@ -79,13 +79,13 @@ task id 在运行时全局可见且可预测,因此注册表会授权每次访 系统提示词要求模型保留 task id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务,并终止不再重要的工作。完成时,系统会向确切所有者的会话注入一条已记录的 `context/message`;它会成为下一个请求的持久上下文,但不会唤醒空闲的 agent。 -当读取或等待交付终止任务、实时等待方在结算时认领了投递,或模型显式终止任务时,运行时将终止任务标为 `reported`。已报告的任务不会注入冗余的完成通知。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。当快照携带 `outputLimitBytes` 时,`dsh-tool-tasks` 会保持 UTF-8 边界,并复用生产方已有的截断标记,而不会重复添加。读取会为状态后缀预留空间并保留输出尾部;完成通知会先为稳定的 `background task ` 前缀与 `task_output` 指令预留空间,再截断可变的 kind、label、status、detail,乃至截断标记本身,因此 PTY 的最小上限仍能标识需要收集的任务。任务接口在策略有机会拒绝或短路分发之前,于最先执行的 pre-execute 监听器中解析调用方可见的生产方上限;随后通过任务定义最后一道的 `finalizeContent` 回调应用该上限,使规范化的工具错误、外层流水线失败与单文本策略结果都无法绕过该边界;经特意结构化的多块策略结果仍由策略拥有其形状与大小。 +当读取或等待交付终止任务、实时等待方在结算时认领了投递,或模型显式终止任务时,运行时将终止任务标为 `reported`。已报告的任务不会注入冗余的完成通知。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。当快照携带 `outputLimitBytes` 时,`dsh-tool-tasks` 会保持 UTF-8 边界,并复用生产方已有的截断标记,而不会重复添加。读取会为状态后缀预留空间并保留输出尾部;完成通知会先为稳定的 `background task ` 前缀与 `task_output` 指令预留空间,再截断可变的 kind、label、status、detail,乃至截断标记本身,因此 PTY 的最小上限仍能标识需要收集的任务。任务控制器在策略有机会拒绝或短路分发之前,于最先执行的 pre-execute 监听器中解析调用方可见的生产方上限;随后通过任务定义最后一道的 `finalizeContent` 回调应用该上限,使规范化的工具错误、外层流水线失败与单文本策略结果都无法绕过该边界;经特意结构化的多块策略结果仍由策略拥有其形状与大小。 ## 生产方显式启用 每个生产方通过带默认值的配置,自行决定其 schema 是否暴露 `run_in_background`。`dsh-tool-bash`、`dsh-tool-pty` 和每个 `dsh-tool-subagent` 实例都使用 `enableRunInBackground`,默认值为 true。禁用的实例会省略该参数;由于通用参数校验器允许未声明的键,它还会在执行时拒绝强制传入的后台参数。省略 schema 用于声明能力不可用;执行检查负责强制该约束。 -`ctx.tasks` 不改写生产方 schema。bundle 只转发其所拥有生产方的配置。如果后台调用在没有附加接口的情况下到达 `start()`,运行时防线会在执行前使其失败。 +`ctx.tasks` 不改写生产方 schema。bundle 只转发其所拥有生产方的配置。如果后台调用在没有附加控制器的情况下到达 `start()`,运行时防线会在执行前使其失败。 ## 生产方集成 @@ -125,7 +125,7 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `Bas ## 测试 -单元覆盖固定预检原子性、按 kind 分配的 id、输出上限的校验与投影、完整结果的 UTF-8 字节上限、流式与最终读取、等待超时与中止竞态、取消、首次结果优先的结算、监听器隔离、通知压制、所有者隔离、陈旧的所有者实例、所有者清理、服务资源销毁和无接口防线。生产方测试覆盖 bash 进程映射、subagent 启动取消、终止映射与释放。快照覆盖固定控制工具 schema 与提示词指导。 +单元覆盖固定预检原子性、按 kind 分配的 id、输出上限的校验与投影、完整结果的 UTF-8 字节上限、流式与最终读取、等待超时与中止竞态、取消、首次结果优先的结算、监听器隔离、通知压制、所有者隔离、陈旧的所有者实例、所有者清理、服务资源销毁和无控制器防线。生产方测试覆盖 bash 进程映射、subagent 启动取消、终止映射与释放。快照覆盖固定控制工具 schema 与提示词指导。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml index 2824639954..1c95e8afc6 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.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/architecture/2026-06-24-web-capability-seam.md -2026-06-24-web-capability-seam.md: ccf04420425555055ce3810d25bf764db17322bb +2026-06-24-web-capability-seam.md: 5df68ea1f0c32491f48d6da98a8fbce0e658e102 2026-06-24-web-capability-seam.zh.md: 73f7d0d9b82c03d41bdc768f22b22316e1bc907f diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md index ccf0442042..5df68ea1f0 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md @@ -8,7 +8,7 @@ English | [中文](2026-06-24-web-capability-seam.zh.md) The harness needs model-facing web tools without binding the model contract to one vendor's API shape. Search is the immediate pressure point: supporting both Exa search and Perplexity search from the start — two deliberately different provider shapes (Exa returns a flat `results[]` of `{title, url, highlights, publishedDate}`; Perplexity returns a generated answer plus citations) — is what proves the normalized web contract does not just mirror one vendor. Fetch is a separate operation: an anonymous public HTTP(S) fetch backend has transport, security, redirect, decoding, and size-limit concerns that are not the same as provider-backed search. -The model-facing surface must stay stable while backends change. A search provider swap should not change how the model asks for a query, and a fetch implementation swap should not change how the model asks for a URL. Conversely, a provider package should not expose its own model-facing tool schema just because it has extra provider-specific knobs. +The model-facing API must stay stable while backends change. A search provider swap should not change how the model asks for a query, and a fetch implementation swap should not change how the model asks for a URL. Conversely, a provider package should not expose its own model-facing tool schema just because it has extra provider-specific knobs. Putting search and fetch directly in `dsh-tool-web` would make the model-facing tool own provider selection, backend request mapping, transport policy, result normalization, prompt guidance, presentation, and schema registration at once. Letting each provider register its own tool has the opposite problem: tool availability, names, descriptions, and parameters would depend on whichever provider packages happen to load, and provider-specific fields would leak into the model contract. @@ -74,7 +74,7 @@ Provider packages depend only on `dsh-web` and Cordis. They own credentials, end ## `ctx.web` contract -`ctx.web` is a provider registry plus a provider-selecting execution surface. The registry half stays close to `LlmService`: a `Map` per capability kind, `registerSearchProvider` / `registerFetchProvider` methods that return disposers, duplicate ids that throw `WebError`, and execution-time resolution that throws when the selected provider is absent or unusable. The authoritative signatures live in `packages/web/web/src/types.ts`; the seam's shape: +`ctx.web` is a provider registry plus a provider-selecting execution API. The registry half stays close to `LlmService`: a `Map` per capability kind, `registerSearchProvider` / `registerFetchProvider` methods that return disposers, duplicate ids that throw `WebError`, and execution-time resolution that throws when the selected provider is absent or unusable. The authoritative signatures live in `packages/web/web/src/types.ts`; the seam's shape: ```ts import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web' @@ -191,7 +191,7 @@ interface WebSearchSource { } ``` -`content` is optional provider-generated answer text, search context, or summary. `sources[]` is the portable citation surface. A source always has a URL; title, snippet, and `publishedAt` are optional because not every provider returns them. `title` is not required: Perplexity-style citations may provide only URLs, and forcing adapters to invent titles would make the seam lie. `dsh-tool-web` renders a `title ?? hostname(url)`-style fallback label for display. `publishedAt` is an optional publication/crawl timestamp as an ISO-8601 string — Exa returns it as `publishedDate` on each result and Perplexity returns a `date` on search results, so it is real provider data, not derived; the seam carries it as a string and leaves date parsing to the consumer. +`content` is optional provider-generated answer text, search context, or summary. `sources[]` is the portable citation shape. A source always has a URL; title, snippet, and `publishedAt` are optional because not every provider returns them. `title` is not required: Perplexity-style citations may provide only URLs, and forcing adapters to invent titles would make the seam lie. `dsh-tool-web` renders a `title ?? hostname(url)`-style fallback label for display. `publishedAt` is an optional publication/crawl timestamp as an ISO-8601 string — Exa returns it as `publishedDate` on each result and Perplexity returns a `date` on search results, so it is real provider data, not derived; the seam carries it as a string and leaves date parsing to the consumer. Exa search maps each entry of the provider's flat `results[]` into a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first `highlights[]` entry (an entry with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. Exa returns no provider-generated answer, so `content` is omitted. Perplexity search maps `choices[0].message.content` to `content` and prefers the structured top-level `search_results[]` for `sources[]` — `url` ← `url`, `title` ← `title`, `snippet` ← `snippet` (often empty), `publishedAt` ← `date` — falling back to the URL-only `citations[]` array only when `search_results` is absent (those sources carry just a `url`). If a provider returns fewer structured fields than the seam supports, the adapter omits those optional fields. @@ -294,7 +294,7 @@ This resembles OpenCode's local web search: one stable `websearch` tool dispatch ### Split search and fetch into two seams (`dsh-search`, `dsh-fetch`) -Tempting because the two halves share no request schema and no business logic, so each would map cleanly onto the bash/fs three-package template, and the `Search`/`Fetch` method-pair duplication on `WebService` would disappear. Rejected because the shared machinery — provider-id registry, registration-order-independent selection policy, abort propagation, the `WebError` taxonomy, and the product-facing "how this harness reaches the web" config surface — is real and would otherwise be duplicated across two near-identical seams. One `ctx.web` middle layer gives the product a single thing to inject and configure and gives provider selection one owner. The price is the parallel `searchX`/`fetchX` method pairs, which is accepted deliberately. +Tempting because the two halves share no request schema and no business logic, so each would map cleanly onto the bash/fs three-package template, and the `Search`/`Fetch` method-pair duplication on `WebService` would disappear. Rejected because the shared machinery — provider-id registry, registration-order-independent selection policy, abort propagation, the `WebError` taxonomy, and the product-facing "how this harness reaches the web" configuration API — is real and would otherwise be duplicated across two near-identical seams. One `ctx.web` middle layer gives the product a single thing to inject and configure and gives provider selection one owner. The price is the parallel `searchX`/`fetchX` method pairs, which is accepted deliberately. ### Choose the first registered provider diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml index c2d46de08f..a72e809c63 100644 --- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-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/architecture/2026-06-26-file-context-as-event-gate.md -2026-06-26-file-context-as-event-gate.md: 743c8f150a83f2452cb0ff672860b8462a56f762 +2026-06-26-file-context-as-event-gate.md: 5f61201c8129b74233222bdc71eaf20443794760 2026-06-26-file-context-as-event-gate.zh.md: cd79b44dd3f3b75c5e5addc5be10c02bd1ce0151 diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md index 743c8f150a..5f61201c81 100644 --- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md +++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -138,7 +138,7 @@ The tool passes `exec` (the tool-execution context) as the `actor` argument on e An observed-state entry is the **prior-observation record**, but its discriminant matters. Successful read/write/edit records present at a version, allowing create-then-edit or edit-then-edit without an intervening read. A read/view that confirms absence replaces any old positive version with absent, allowing only a guarded create; a later successful create replaces it with the new present version. Missing entry alone means unseen and produces `FS_NOT_OBSERVED` for edit. The owner is derived structurally from `{ agent?: { session? } }`; disposal drops all state (HMR safety). -`dsh-fs-policy` is now a pure policy/recording plugin with no service surface — it influences the world only through the event gate. That is what removes the method coupling from `dsh-tool-fs`. +`dsh-fs-policy` is now a pure policy/recording plugin with no service API — it influences the world only through the event gate. That is what removes the method coupling from `dsh-tool-fs`. ## Bare-provider behavior (no `dsh-fs-policy`) diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml similarity index 64% rename from .agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml rename to .agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml index a1fac69cf4..76c9d1fa03 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml @@ -1,6 +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 .agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md -2026-06-30-bash-stdin-env-trusted-plugin-surface.md: d8193b47ff16da7efb5f6bf1c3f34c0e1251572c -2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 7f5bb62d92f27c49b0f7f262dc64dce39c89f2cd +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md +2026-06-30-bash-stdin-env-trusted-plugin-api.md: 2087b2f7a9682ad5f554d4a7a2f521485d164432 +2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md: d3ed6a86c7f2e356f50a918dddf3fe47ea7ae820 diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md similarity index 92% rename from .agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md rename to .agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md index d8193b47ff..2087b2f7a9 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md @@ -2,7 +2,7 @@ Status: implemented -English | [中文](2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md) +English | [中文](2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md) ## Problem @@ -30,4 +30,4 @@ Three deliberate choices: ## Consequences -Hook bridges pass JSON payloads and hook-specific variables through the existing bash seam, retaining its process-group, truncation, and spill behavior. The model surface remains unchanged, and the bash tool remains the sole owner of model-call request construction. The vocabulary lives in [the bash data-structure reference](../../../../docs/subsystems/bash.md). +Hook bridges pass JSON payloads and hook-specific variables through the existing bash seam, retaining its process-group, truncation, and spill behavior. The model-facing behavior remains unchanged, and the bash tool remains the sole owner of model-call request construction. The vocabulary lives in [the bash data-structure reference](../../../../docs/subsystems/bash.md). diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md similarity index 98% rename from .agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md rename to .agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md index 7f5bb62d92..d3ed6a86c7 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md @@ -2,7 +2,7 @@ Status: implemented -[English](2026-06-30-bash-stdin-env-trusted-plugin-surface.md) | 中文 +[English](2026-06-30-bash-stdin-env-trusted-plugin-api.md) | 中文 ## 问题 diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml index 8916c347d9..9d211becd5 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.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/architecture/2026-06-30-event-domain-semantics.md -2026-06-30-event-domain-semantics.md: dbb3ef49979b7eeaa9a43b7db4a2d5a5839b5569 +2026-06-30-event-domain-semantics.md: 70da718b5471ce309a090c8aade3e7290cc949dc 2026-06-30-event-domain-semantics.zh.md: 93fe14adeaa47d276219a316ff1250a9982f5f14 diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md index dbb3ef4997..70da718b54 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -1,4 +1,4 @@ -# Agent Note: Event-domain semantics — session is the fact log, agent is the live surface +# Agent Note: Event-domain semantics — session is the fact log, agent is the live event channel Status: implemented diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml index 06b4d1d456..521ee50423 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.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/architecture/2026-07-05-reconstructable-requests.md -2026-07-05-reconstructable-requests.md: d7b867d6e17c2dfe0e49b70bd0f5e7ff173fb572 +2026-07-05-reconstructable-requests.md: e78284964ca85905524d3a0800b2de46c6964094 2026-07-05-reconstructable-requests.zh.md: c41e5f89558a9c7b09ccd2bf4fd5b7d9a6cde419 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md index d7b867d6e1..e78284964c 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -37,7 +37,7 @@ Like MiniCode, the conversation advances append-only and resets only when model- ## Alternatives considered - **Client as source of truth** (literal MiniCode): a second operative truth beside the log — the two drift and nothing notices; see the section above. -- **A stateful transmission client mirroring the log** — duplicates conversation state, needs rollback around listeners, leaves an unlogged edit surface, and still cannot reconstruct request headers. Session-owned caches plus logged headers avoid those split truths. +- **A stateful transmission client mirroring the log** — duplicates conversation state, needs rollback around listeners, leaves an unlogged edit path, and still cannot reconstruct request headers. Session-owned caches plus logged headers avoid those split truths. - **Per-call request scalars** (a freely mutable config handed to each `agent/request` dispatch): a listener flips the model per call with zero accounting, silently abandoning the provider cache this design exists to protect. Config is per-conversation logged state; the waterfall proposes, the log records. - **Detect-and-report** (compare consecutive requests, warn on divergence): catches violations after the fact; a violating request is still constructible and ships. Rejected for interface-level unrepresentability. - **Event-driven assembly** (re-render only on change signals): a missed-signal bug class — a tool registered mid-session emits `tools/change`, not `system-prompt/change`, and a third-party provider may emit nothing. Per-step render + value compare is robust with zero signal discipline. diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml index 951017ba47..cf8eaf225c 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.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/architecture/2026-07-05-windows-jsonl-durable-publish.md -2026-07-05-windows-jsonl-durable-publish.md: 38c4adc7a4f85d45e53e70fcac84073ab4e50775 +2026-07-05-windows-jsonl-durable-publish.md: 546cde6086f3c84e22b4d4de144a48bb3425cd4e 2026-07-05-windows-jsonl-durable-publish.zh.md: 205460bcd374bd351cfdf541eb4041c09461229d diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md index 38c4adc7a4..546cde6086 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md +++ b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md @@ -16,7 +16,7 @@ The JSONL backend forks inside `materialize()` before any namespace mutation. Sh POSIX keeps the existing protocol: create the root, project directory, and session directory with parent directory fsyncs, write and fsync a temp file, publish with `link()` so an existing final log is never overwritten, fsync the session directory, then remove the redundant temp hard link. -Windows creates missing directories through a durable staging publish: create a random sibling directory under the constant `.dsh-mkdir-` prefix, independent of the target basename, then publish it to the final directory name with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without `MOVEFILE_REPLACE_EXISTING` or `MOVEFILE_COPY_ALLOWED`. File materialization writes and fsyncs the temp log, then publishes that temp file to the final path with the same write-through `MoveFileExW` call and no replacement. `koffi` is the minimal Win32 bridge for this API surface; its install script is allowed in `pnpm-workspace.yaml` because the package ships the native loader and prebuilt platform modules. +Windows creates missing directories through a durable staging publish: create a random sibling directory under the constant `.dsh-mkdir-` prefix, independent of the target basename, then publish it to the final directory name with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without `MOVEFILE_REPLACE_EXISTING` or `MOVEFILE_COPY_ALLOWED`. File materialization writes and fsyncs the temp log, then publishes that temp file to the final path with the same write-through `MoveFileExW` call and no replacement. `koffi` is the minimal Win32 bridge for this API; its install script is allowed in `pnpm-workspace.yaml` because the package ships the native loader and prebuilt platform modules. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index fa9304ec09..ed6d77249d 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.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/architecture/2026-07-06-timeout-deadline-library.md -2026-07-06-timeout-deadline-library.md: b7c7ac07ce0acec819fa7e88b32b3b6d624f3e6e +2026-07-06-timeout-deadline-library.md: 7b252052c27fbf9f10716bd874a371b102abb614 2026-07-06-timeout-deadline-library.zh.md: e4ec6b9a9e07b55faf9f44f2b99b765620e626c4 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md index b7c7ac07ce..7b252052c2 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md @@ -18,7 +18,7 @@ Each new external-process or network tool re-derived the same four things — cl `@deepseek-ai/dsh-timeout` lives under `packages/util/` (peer to `dsh-brand`) and owns the *timing and classification* half of timeout; the *termination* half — the hard kill — stays in each capability's implementation. It is a library of pure functions, **not** a cordis service or plugin: it takes no `ctx`, registers nothing, holds no cross-call state, and emits no events. There is deliberately no central "timeout service" that would have to know how to stop every capability's work — that knowledge is exactly what a microkernel keeps out of shared layers, and what Codex's exec-only `ExecExpiration` scope demonstrates. -### The library surface +### The library API Four functions, one watchdog interface, and one reason type: diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml index 29c7c13813..bd8d4692ee 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.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/architecture/2026-07-06-tool-result-retention-library.md -2026-07-06-tool-result-retention-library.md: 5e42660360e5a23b419c75b9c8006bec459bc322 +2026-07-06-tool-result-retention-library.md: 1736d2dad98cbb8b67d570ce25742a84ea0ede59 2026-07-06-tool-result-retention-library.zh.md: 2a523e2eff36daf9100ac10bf5ae569eca30f830 diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md index 5e42660360..1736d2dad9 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md @@ -142,7 +142,7 @@ The formatter hook is deliberately small: a tool turns a `RetentionNotice` into **Boundaries the library holds.** `truncated` means the retainer omitted otherwise-available content because of a budget; it never means the upstream was incomplete. Tool-specific states — `incomplete`, permission failures, provider partial failures, binary skips, bash spill-path recovery, invalid UTF-8 — stay in tool-domain fields, outside the retainer. When a future change migrates a tool, that package's README and tests must prove the model-facing result text is unchanged except for deliberate notice wording. -**Tradeoffs accepted.** The v1 surface deliberately supports only item `head` retention and text `head` / `tail` / `headTail`; windows, grouped budgets, sort-aware caps, and upstream-stop control wait until a second consumer proves the need. Text retention counts bytes for process/body safety, leaving character- and line-level preview budgets as separate tool-owned concerns. +**Tradeoffs accepted.** The v1 API deliberately supports only item `head` retention and text `head` / `tail` / `headTail`; windows, grouped budgets, sort-aware caps, and upstream-stop control wait until a second consumer proves the need. Text retention counts bytes for process/body safety, leaving character- and line-level preview budgets as separate tool-owned concerns. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml index 34abd78451..3905215513 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.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/architecture/2026-07-08-tool-output-spill-files.md -2026-07-08-tool-output-spill-files.md: 0c8e5e25fc8a229db8fca36512ba781e548b5256 +2026-07-08-tool-output-spill-files.md: 3c24bc9ed754726b70e833627a22167c8256162c 2026-07-08-tool-output-spill-files.zh.md: 771253335bf5822cb855c63e7dd0bbf0d517bab3 diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md index 0c8e5e25fc..3c24bc9ed7 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md @@ -10,7 +10,7 @@ Tool outputs need bounded model-facing previews, but some oversized results are Before this change the behavior was uneven. `dsh-bash-local` already writes complete stdout/stderr streams to private temp spill files when its in-memory tail overflows, but ordinary text tool results were returned inline unless the tool hand-rolled its own cap. The [tool result retention library](2026-07-06-tool-result-retention-library.md) owns preview mechanics, but it does not own storage or an execution-pipeline policy that applies those mechanics to final tool results. -The shape matches the timeout policy design: a tool author declares a canonical value plus Native renderer, and a policy plugin enforces the deployment's default context budget on rendered content. Tool-specific early spill remains possible for provider acquisition bounds; tool-owned surface spill may retain a complete acquired canonical value while replacing only presentation. The [canonical tool-output contract](2026-07-20-canonical-tool-output-contract.md) owns that split. +The shape matches the timeout policy design: a tool author declares a canonical value plus Native renderer, and a policy plugin enforces the deployment's default context budget on rendered content. Tool-specific early spill remains possible for provider acquisition bounds; tool-owned presentation spill may retain a complete acquired canonical value while replacing only presentation. The [canonical tool-output contract](2026-07-20-canonical-tool-output-contract.md) owns that split. ## Decision diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index a48de33ce2..362877ac10 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: c2b6d9ff1825915e39738bf8f782c302ecfc1d0d +2026-07-10-single-file-executable-sdk-runtime-distribution.md: fd39d8c10d7f76003c93b6fa45a3b0211cead23e 2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: d7758a77083e07b1d2cac99ae2be3f15e6edd2dc diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index c2b6d9ff18..fd39d8c10d 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -6,7 +6,7 @@ English | [中文](2026-07-10-single-file-executable-sdk-runtime-distribution.zh ## Problem -DeepSeek Harness needs a dedicated SDK distribution form for the Python library — no Node installation, runs directly on the target platform: a single-file executable (hereafter "the exe") that exposes a stdio JSON-RPC serving surface (`HarnessSdkServer`, the Python SDK's peer), where the plugins and configuration actually booted are decided entirely by a `cordis.yml` supplied from outside the exe. +DeepSeek Harness needs a dedicated SDK distribution form for the Python library — no Node installation, runs directly on the target platform: a single-file executable (hereafter "the exe") that exposes a stdio JSON-RPC serving interface (`HarnessSdkServer`, the Python SDK's peer), where the plugins and configuration actually booted are decided entirely by a `cordis.yml` supplied from outside the exe. - The JSONRPC protocol for talking to the Python SDK is already validated - A standardized way for cordis.yml to load every plugin (ESModule) is needed @@ -23,7 +23,7 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay expected outputs, `$DSH_SNAPSHOT`); this document says "VFS" for the former. -### The serving surface is a plugin: the two packages ui/jsonrpc + examples/jsonrpc-demo +### The serving interface is a plugin: the two packages ui/jsonrpc + examples/jsonrpc-demo The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `acp/acp` + `examples/acp-demo` pattern — the serving surface is itself a plugin: @@ -80,6 +80,6 @@ Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disp ## Consequences -**Bought**: zero-dependency single-file distribution on target platforms; plugin semantics strictly identical to running from source (the same real package tree, no transpilation, no registry); the serving surface, the plugin set, and the configuration all converge on two sources of truth — `cordis.yml` plus one dependency manifest; the exe and node carriers share one tree and one semantics, so development verification never waits for packaging; official Node binaries remove the patched-binary supply-chain concern. +**Bought**: zero-dependency single-file distribution on target platforms; plugin semantics strictly identical to running from source (the same real package tree, no transpilation, no registry); the serving interface, the plugin set, and the configuration all converge on two sources of truth — `cordis.yml` plus one dependency manifest; the exe and node carriers share one tree and one semantics, so development verification never waits for packaging; official Node binaries remove the patched-binary supply-chain concern. **Paid**: artifacts on the order of 174MB with source entering the blob as-is (no bytecode obfuscation; a closed-source distribution requirement needs a separate evaluation); pkg's VFS/module-hook layer remains community-maintained (the build script pins `@yao-pkg/pkg@6.21.0`; upgrading is an explicit change); `--sea` is one invocation per target (matching CI's one leg per platform; local multi-platform builds are serial). diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index 91deb7153c..ce530420a5 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.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/architecture/2026-07-12-agent-scope-runtime-design.md -2026-07-12-agent-scope-runtime-design.md: 8903a2fefa83dba042f8105b182e590783a9adde +2026-07-12-agent-scope-runtime-design.md: 5bee5f3f79be903848f8ecdf1ea84fe6fca60f6f 2026-07-12-agent-scope-runtime-design.zh.md: d86ab3d8cb9a051d4664aedac812b10b53521f22 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 8903a2fefa..5bee5f3f79 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -25,7 +25,7 @@ The design can be skimmed as seven choices: | Coordinate create/resume | One `AgentCreationTransaction` | | Protect durable, queued, model, or wire data | Materialize once at that boundary | | Pass typed values inside one process | Readonly borrowed contract | -| Compose the model-visible prompt and tool surface | One shared tool view plus the authoritative assembly-waterfall result | +| Compose the model-visible prompt and tool set | One shared tool view plus the authoritative assembly-waterfall result | | Coordinate subagent, worker, and process shutdown | One cancellation signal plus the independent terminal/quiescence facts of that boundary | The rest of this Agent Note expands those choices in dependency order: Cordis mechanics, scope routing, creation and session commit, tools and prompts, subagents and workflows, then executable checks. diff --git a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml index d4886965ad..bbecaabbbf 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.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/architecture/2026-07-12-scoped-layers-store.md -2026-07-12-scoped-layers-store.md: c5186d1652bca617eed62ec02937f2d055ea727c +2026-07-12-scoped-layers-store.md: 91df72cf18c3f28d49534793bee85094b299c9a5 2026-07-12-scoped-layers-store.zh.md: f5084426beef836d71e55c0898ec1edbdc7725f3 diff --git a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md index c5186d1652..91df72cf18 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md +++ b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md @@ -111,11 +111,11 @@ All seven facades keep validation and diagnostics in their owning registry and c ## Consequences - Scope-aware registries express one aggregate layer and reuse the same construction, ownership, rollback, notification, and reclamation choreography. Domain-specific validation, diagnostics, filtering, evaluation, and observer policy remain in each registry. -- The public read surface stays narrow: direct table iteration preserves explicitly live behavior, while `merge()` is the one shared materialized shadowing operation. A heterogeneous `ScopeLayer` has no layer-wide `values()` contract. +- The public read API stays narrow: direct table iteration preserves explicitly live behavior, while `merge()` is the one shared materialized shadowing operation. A heterogeneous `ScopeLayer` has no layer-wide `values()` contract. - The helper is deliberately synchronous. A future registration that needs asynchronous setup or several independently owned undos must identify its ownership and settlement boundaries before widening this contract. - An action must throw before retaining a contribution or return an undo for everything it retained; the helper cannot repair mutation outside that contract. The provided entry operations are atomic, and migrated registries perform fallible validation before insertion. - A scoped layer remains allocated until every table in its aggregate is empty. Disposing one facade therefore cannot discard sibling contributions owned by the same scope. -- The four public symbols become a reusable package contract. Keeping `EntryValues` internal and consumer policy outside the helper limits the compatibility surface. +- The four public symbols become a reusable package contract. Keeping `EntryValues` internal and consumer policy outside the helper limits the compatibility API. - The migration changes no public registry behavior and no model-, human-, wire-, persistence-, configuration-, or dependency-graph output. ## Verification diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml index dd7ff64291..b1ffadacb3 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.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/architecture/2026-07-14-provider-routed-llm-adapters.md -2026-07-14-provider-routed-llm-adapters.md: 8bd34126d05140e486cd170bfc9255f0962dc413 -2026-07-14-provider-routed-llm-adapters.zh.md: 09d9032da5845d3e416dcfa047fde8e6527025b4 +2026-07-14-provider-routed-llm-adapters.md: df152a4436226ac5cb1941ce060a35bd4a0d496d +2026-07-14-provider-routed-llm-adapters.zh.md: 181323c136a4f953d64928a8698720e164dd5ad1 diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md index 8bd34126d0..df152a4436 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md @@ -48,7 +48,7 @@ This state is model-visible replay input and therefore follows the existing [rec ### Propagate the target through every request producer -Every model-selection surface carries provider and model together: declarative agents, ACP and stdio app config, the JSON-RPC initialize request, subagent overrides and inheritance, workflow child overrides, and direct compaction summarization. Subagents inherit both fields from their parent before applying request overrides. The system-prompt variable set gains `provider` beside `model`. +Every model-selection path carries provider and model together: declarative agents, ACP and stdio app config, the JSON-RPC initialize request, subagent overrides and inheritance, workflow child overrides, and direct compaction summarization. Subagents inherit both fields from their parent before applying request overrides. The system-prompt variable set gains `provider` beside `model`. Compaction configuration gains `summarizationProvider` beside `summarizationModel`. Both are empty to inherit, or both are non-empty to select an explicit target; a half-configured pair fails load. Inheritance uses the last logged request target when one exists and falls back to the agent's creation options. `compact/summary` records both fields with the existing model-call envelope. @@ -66,7 +66,7 @@ The on-disk session format remains the pre-release pinned version `0`, with no c **Let `dsh-llm-pi-ai` automatically register every pi-ai provider.** This would claim ambient credentials and provider names the deployment never intended to expose, and would conflict with native adapters such as `dsh-llm-deepseek`. Explicit profiles make capability and credential scope reviewable. -**Mount one pi-ai plugin instance per provider.** Separate instances isolate config but repeat plugin declarations and cannot make profile registration atomic. One adapter already receives provider on every request, so a validated profile map is the smaller lifecycle surface. +**Mount one pi-ai plugin instance per provider.** Separate instances isolate config but repeat plugin declarations and cannot make profile registration atomic. One adapter already receives provider on every request, so a validated profile map is the smaller lifecycle API. **Accept arbitrary inline pi-ai model descriptors.** This would support catalog-external private model ids, but it exposes pi-ai's model and compatibility schema as Harness configuration and makes the adapter responsible for validating protocol-specific combinations. The first version supports custom endpoints by overriding `baseURL` on catalog models; custom descriptors require a separate decision after a real catalog-external deployment is identified. diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md index 09d9032da5..181323c136 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md @@ -48,7 +48,7 @@ pi-ai 回放状态是其成功 `AssistantMessage` 的带版本最小投影,包 ### 在所有请求生产方中传播目标 -每个模型选择接口都同时携带 provider 与 model:声明式 agent、ACP(Agent Client Protocol)和 stdio 应用配置、JSON-RPC initialize 请求、subagent 覆盖与继承、工作流子 agent 覆盖,以及直接压缩摘要。subagent 先从父 agent 继承两个字段,再应用请求覆盖。系统提示词变量集合在 `model` 之外增加 `provider`。 +每条模型选择路径都同时携带 provider 与 model:声明式 agent、ACP(Agent Client Protocol)和 stdio 应用配置、JSON-RPC initialize 请求、subagent 覆盖与继承、工作流子 agent 覆盖,以及直接压缩摘要。subagent 先从父 agent 继承两个字段,再应用请求覆盖。系统提示词变量集合在 `model` 之外增加 `provider`。 压缩配置在 `summarizationModel` 之外增加 `summarizationProvider`。两个值均为空时继承,均非空时选择显式目标;只配置其中一个会导致加载失败。继承优先使用最近一次记录的请求目标,没有时回退到 agent 创建选项。`compact/summary` 使用现有模型调用 envelope 记录两个字段。 @@ -60,7 +60,7 @@ JSON-RPC 运行时显式接收 provider 与 model。仅当 `deepseek` 提供方 **继续以模型名称作为注册表键,并增加通配适配器。** 通配机制会在精确注册与兜底插件之间引入回退顺序,使重复所有权取决于监听器顺序;若不再增加其他约定,仍无法区分不同提供方中相同的模型 ID。 -**将提供方与模型编码到一个字符串中。** OpenRouter 的 `openai/gpt-*` 等值已经包含类似提供方的前缀和斜杠。分隔符约定会把路由语法泄漏到每个模型选择接口,并需要转义规则;两个显式字段更清晰,也可以分别记录日志。 +**将提供方与模型编码到一个字符串中。** OpenRouter 的 `openai/gpt-*` 等值已经包含类似提供方的前缀和斜杠。分隔符约定会把路由语法泄漏到每个模型选择器,并需要转义规则;两个显式字段更清晰,也可以分别记录日志。 **增加 `backend + provider + model`。** backend 键可以让 `dsh-llm-deepseek` 与 pi-ai 的 DeepSeek 实现共存,并按请求切换。最终采用的部署规则是一个提供方对应一个适配器所有者:同一上游的不同实现属于由插件组合选定的替代项。第三个路由维度会增加每个请求与配置的负担,却没有当前消费方。 diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index 91c9baf2a0..6fe5951d90 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.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/architecture/2026-07-15-lsp-capability-seam.md -2026-07-15-lsp-capability-seam.md: a461432fe5f218ea6c3ff3e031b52f7ebffbd7a5 +2026-07-15-lsp-capability-seam.md: c407de5275da0a8323b3c6186e178c8b2fafdc31 2026-07-15-lsp-capability-seam.zh.md: bfbd8d04f71b154d2833c86f2a6dc84d7e13fff4 diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md index a461432fe5..c407de5275 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md @@ -137,7 +137,7 @@ Navigation maps `Location` directly and `LocationLink` from `targetUri` plus `ta Abort reaches every query phase and sends `$/cancelRequest` once an id exists. An unresponsive server is terminated and awaited without collateral active work because the instance is serialized. Disposal rejects and cancels work, attempts graceful shutdown, escalates through bounded termination, and awaits quiescence. -## Deliberately deferred surface +## Deliberately deferred API Symbols are deferred because they need different schemas and overlap read/search; a future workspace-symbol tool must accept a search query. Call hierarchy is deferred because support is uneven, and `prepareCallHierarchy` remains an internal prerequisite rather than a model operation. diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index 19f77344e1..08ddfd9b1c 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.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/architecture/2026-07-16-explicit-turn-cancellation.md -2026-07-16-explicit-turn-cancellation.md: 5d7b5856ceebd3dc49564c1800004188da162fd8 +2026-07-16-explicit-turn-cancellation.md: 86faad9929d3eb5b00e66bb1c46a2e35b135d954 2026-07-16-explicit-turn-cancellation.zh.md: ba20b80f3c280087d71688ef4596fb075fa5782b diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index 5d7b5856ce..86faad9929 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -40,7 +40,7 @@ Initiator-scope tests assert that every hook still observes the exact Agent and **Persist a free-form string reason.** Strings admit spelling drift, prevent exhaustive switching, and encourage consumers to parse presentation text. The runtime uses a closed discriminated union, while the terminal record needs only the stable aborted outcome. -**Persist the typed caller cause in `turn/end`.** No production replay, UI, ACP, telemetry, or workflow consumer distinguishes `user` from `parent`. Copying the request source into the terminal result would conflate two facts and add Session-specific validation without a consumer; a future audit surface can record a separate cancellation-request event. +**Persist the typed caller cause in `turn/end`.** No production replay, UI, ACP, telemetry, or workflow consumer distinguishes `user` from `parent`. Copying the request source into the terminal result would conflate two facts and add Session-specific validation without a consumer; a future audit trail can record a separate cancellation-request event. **Define speculative `superseded`, `timeout`, and `shutdown` variants now.** No current Agent cancellation producer implements those semantics. `shutdown` is already lifecycle disposal, and timeout or supersession should enter the union only with an owning policy and unique terminal meaning. diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index 3d370c1e37..d467b9c37b 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.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/architecture/2026-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: da96ae97f2a2d64aeef7794bd82ccbd86602b1ad -2026-07-19-gui-layering-and-rpc-protocol.zh.md: 36dc7391bc3f9bb0d5105fea14a2763d0b7159a1 +2026-07-19-gui-layering-and-rpc-protocol.md: deba5e81c35e1572e2e6dc68b48234414ac9a7d5 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: e506c6242794b1b065136c8190f5c46a46e0d069 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index da96ae97f2..deba5e81c3 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -30,7 +30,7 @@ Directories layer as follows: - **Static-arrival entry packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `hmr`): no `dsh.client` key and no browser bundle — the shell bundles their `src/client/` half and registers it with `ctx.modules`; they are governed as entries of the host-authored graph like everything else. - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dsh.client` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. - `apps/` holds the externally exported applications, assembled from Client / Host mixtures. - - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`. + - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell API exported by `dsh-client-web`. - `apps/cli` (`@deepseek-ai/dsh`) dispatches commands: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh --profile headless` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer. - A future Electron application reuses the same web client packages over an IPC fetch carrier. @@ -116,7 +116,7 @@ Domain interface signatures perceive only the narrow forms: `RpcRequest

= { r ### RpcReceipt: the carrier receipt -The HTTP response body of a `ClientResponse` is `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }` — a carrier-layer receipt, **not** an RpcMessage (a response has no response); late/duplicate answers get `not-pending`, and the logical convergence surface is the `*/resolved` frames. +The HTTP response body of a `ClientResponse` is `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }` — a carrier-layer receipt, **not** an RpcMessage (a response has no response); late/duplicate answers get `not-pending`, and the logical convergence point is the `*/resolved` frames. ## The type system: signatures are the source of truth diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index 36dc7391bc..e506c62427 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -28,7 +28,7 @@ Status: implemented - **静态到达 entry 包**(`connection`、`runtime`、`ui-theme`、`i18n`、`hmr`):无 `dsh.client` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。 - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dsh.client` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 - `apps/` 作为对外导出的应用入口,可以由 Client / Host 混合组装。 - - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。 + - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳 API 之上的一层薄 `main.ts`。 - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh --profile headless` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 - 将来的 Electron 应用经由 IPC fetch 载体复用同一套 web client 包。 @@ -114,7 +114,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. ### RpcReceipt:载体回执 -`ClientResponse` 的 HTTP 应答体是 `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }`——载体层回执,**不是** RpcMessage(response 不再有 response);迟到/重复应答收 `not-pending`,逻辑收敛面是 `*/resolved` 帧。 +`ClientResponse` 的 HTTP 应答体是 `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }`——载体层回执,**不是** RpcMessage(response 不再有 response);迟到/重复应答收 `not-pending`,逻辑收敛点是 `*/resolved` 帧。 ## 类型体系:函数签名即事实源 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 1712fa8304..8714bf9cf5 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.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/architecture/2026-07-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: bc61aab894d587820ef4cb568b6439993a27d30d +2026-07-19-gui-web-client-architecture.md: 070b857f14007f429826ab83b17b5c8fbd3d3d0b 2026-07-19-gui-web-client-architecture.zh.md: 1f5bafe1dff878b5ca5ffcbdb9ed8ca38a863c9f diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index bc61aab894..070b857f14 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -42,7 +42,7 @@ Implementation homes: registry core and the props-share types in `packages/clien ## Services and scope addressing -A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer installation contract), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). +A service is a plugin's only API toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer installation contract), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). There is no component registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Final Chat business Nodes dispatch through the keyed/session `'conversation.chat.node'` slot; ui-tool owns its `tool-call` entry, recursively renders the supplied `subCalls`, and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and roots and descendants dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '' }, Row))`; the declaration is the load and reload dependency ([decision](2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components. The target-neutral event and view registries are data assembly seams rather than parallel component registries ([decision](2026-08-09-client-conversation-node-assembly.md)). diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml index 9a1610a341..1b504e78f1 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.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/architecture/2026-07-19-package-invariant-runtime-contracts.md -2026-07-19-package-invariant-runtime-contracts.md: c1a5aa1e55965b07b34ce307375d77e75cdeb9be -2026-07-19-package-invariant-runtime-contracts.zh.md: 29fc2ecf2937b7557e16d972ad71230c0e304617 +2026-07-19-package-invariant-runtime-contracts.md: 6e6516c2fa629aa736c178be4acbc8b42fd9a0b9 +2026-07-19-package-invariant-runtime-contracts.zh.md: a7432ca0320646c57b29fbfd64c679b94c9861af diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md index c1a5aa1e55..6e6516c2fa 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md @@ -73,6 +73,6 @@ Vitest mounts `InvariantService` with `{ enabled: true }` for every package test - Every package has visible ownership and publication wiring, but only packages with a plausible runtime relation add listeners or trace state. - Empty companions remain reviewable decisions with package-specific explanations and fail the gate if the explanation is removed. -- Type declarations, Cordis loadability, plugin metadata, service method surfaces, and pure algebra remain covered by their owning compile, load, unit, or integration gates. +- Type declarations, Cordis loadability, plugin metadata, service method APIs, and pure algebra remain covered by their owning compile, load, unit, or integration gates. - Runtime failures identify the owning npm package and point to an inconsistent observation rather than restating a required API shape. - The original selection, blocklist precedence, duplicate ownership, rollback, disposal, and HMR service contracts remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md index 29fc2ecf29..a7432ca032 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -73,6 +73,6 @@ Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantServi - 每个包都有可见的所有权与发布 wiring,但只有具备合理运行时关系的包才会增加 listener 或 trace 状态。 - 空 companion 是带包专属说明、可评审的决策;删除说明后门禁会失败。 -- 类型声明、Cordis 可加载性、插件 metadata、服务方法形状和纯代数继续由所属的编译、加载、单元或集成门禁覆盖。 +- 类型声明、Cordis 可加载性、插件 metadata、服务方法 API 和纯代数继续由所属的编译、加载、单元或集成门禁覆盖。 - 运行时失败会标明所属 npm 包,并指出不一致的观测,而不是复述必要的 API 形状。 - 原有 selection、blocklist 优先级、重复所有权、回滚、dispose 和 HMR(热模块替换)服务约定保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml index a6875e43df..012b51c442 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.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/architecture/2026-07-19-package-owned-invariant-service.md -2026-07-19-package-owned-invariant-service.md: 296c55b21b947d32412acff54715d3687cf9c43b -2026-07-19-package-owned-invariant-service.zh.md: 8aa776feabdef84a4007dc317115e6d72f6bbc50 +2026-07-19-package-owned-invariant-service.md: f1918ec1d31f9d91538b6b92070d98217567c5c5 +2026-07-19-package-owned-invariant-service.zh.md: 71354b3ceabfc102eae6004399644a308d78c253 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md index 296c55b21b..f1918ec1d3 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md @@ -49,11 +49,11 @@ Blocklist matches override allowlist matches. Each list entry is a case-sensitiv The public registration boundary is `ctx.invariants.register(packageName, installer)`. It reserves one active registration per full npm package name even when filters disable installation, and returns the effect disposer. Disposing the companion or service releases the reservation and all contribution state. -An enabled installer runs in a dedicated child Cordis fiber owned by the service. `InvariantInstaller.inject` declares the child fiber's service surface explicitly; the registry carries no product-specific dependency metadata. The service joins a returned installer promise before registration succeeds, so asynchronous startup checks remain transactional. The installer receives a bound `fail(message)` reporter. Calling it throws an `Error` subclass named `InvariantError` with stable code `INVARIANT` and the registering `packageName`; it does not extend a product-package error base. +An enabled installer runs in a dedicated child Cordis fiber owned by the service. `InvariantInstaller.inject` declares the child fiber's service API explicitly; the registry carries no product-specific dependency metadata. The service joins a returned installer promise before registration succeeds, so asynchronous startup checks remain transactional. The installer receives a bound `fail(message)` reporter. Calling it throws an `Error` subclass named `InvariantError` with stable code `INVARIANT` and the registering `packageName`; it does not extend a product-package error base. Registration setup is transactional. If an installer fails after registering listeners, the child fiber is disposed completely and the name reservation is released before the failure escapes. Filtered registrations create no child but retain their reservation until disposal. Reloading a companion therefore begins with one clean installer state; stateful contributions rebuild baselines from their owning services. -The former functional-plugin entrypoint and one-argument `InvariantError` constructor are not retained as compatibility surfaces. The repository is pre-release and all call sites move to the service and package-attributed error together. +The former functional-plugin entry point and one-argument `InvariantError` constructor are not retained as compatibility APIs. The repository is pre-release and all call sites move to the service and package-attributed error together. ### Initial stateful companions and exhaustive ownership @@ -76,7 +76,7 @@ The generated scoped-event subject resolver lives in `dsh-scope`, beside the con The example agent spine mounts the service and all four stateful companion subpaths, forwarding `enabled`, `package_allowlist`, and `package_blocklist` to the service. Generated SDK Cordis composition emits the same entries. A subpath entry adds its installable root npm package rather than treating the subpath as a package name. The shipped `dsh` TUI and Web config trees omit the service and companions under the [shipped-config decision](../simplification/2026-08-03-omit-invariants-from-shipped-config.md). -Workspace constraints recognize the separate invariant bundle, and package exports, project references, build configuration, dependency declarations, and the lockfile describe the same publication surface. Generated config catalogs, module graphs, and API documentation derive from those sources. +Workspace constraints recognize the separate invariant bundle, and package exports, project references, build configuration, dependency declarations, and the lockfile describe the same publication metadata. Generated config catalogs, module graphs, and API documentation derive from those sources. ## Testing diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md index 8aa776feab..71354b3cea 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -49,11 +49,11 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 公开注册边界是 `ctx.invariants.register(packageName, installer)`。即使过滤器禁止安装,它也会为每个完整 npm 包名保留唯一的活跃注册,并返回 effect disposer。卸载伴随插件或服务都会释放注册名及全部贡献状态。 -启用的 installer 在服务拥有的独立 Cordis 子 fiber 中运行。`InvariantInstaller.inject` 显式声明该子 fiber 的服务表面;注册服务不携带产品专用依赖元数据。服务会在注册成功前等待 installer 返回的 promise,因此异步启动检查仍具有事务性。installer 接收绑定后的 `fail(message)` 报告器。调用它会抛出名为 `InvariantError` 的 `Error` 子类,保留稳定代码 `INVARIANT` 并记录注册方 `packageName`;该错误不继承产品包中的错误基类。 +启用的 installer 在服务拥有的独立 Cordis 子 fiber 中运行。`InvariantInstaller.inject` 显式声明该子 fiber 的服务 API;注册服务不携带产品专用依赖元数据。服务会在注册成功前等待 installer 返回的 promise,因此异步启动检查仍具有事务性。installer 接收绑定后的 `fail(message)` 报告器。调用它会抛出名为 `InvariantError` 的 `Error` 子类,保留稳定代码 `INVARIANT` 并记录注册方 `packageName`;该错误不继承产品包中的错误基类。 注册启动是事务性的。如果 installer 在注册监听器后失败,子 fiber 会完整释放,并在失败向外传播前解除包名占用。被过滤的注册不创建子 fiber,但会保留占用直到 dispose。伴随插件重载时总会从干净的 installer 状态开始;有状态贡献从其所属服务重建基线。 -原有函数式插件入口与单参数 `InvariantError` 构造函数不作为兼容表面保留。仓库尚未发布,所有调用方会一起迁移到服务和带包归属的错误。 +原有函数式插件入口与单参数 `InvariantError` 构造函数不作为兼容 API 保留。仓库尚未发布,所有调用方会一起迁移到服务和带包归属的错误。 ### 首批有状态伴随插件与完整所有权 @@ -76,7 +76,7 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 示例 agent spine 会挂载服务和四个有状态伴随子路径,并把 `enabled`、`package_allowlist` 与 `package_blocklist` 转发给服务。生成的 SDK Cordis 组合输出相同条目。子路径条目添加可安装的根 npm 包,而不会把子路径误当成包名。根据[交付配置决策](../simplification/2026-08-03-omit-invariants-from-shipped-config.md),交付的 `dsh` TUI 与 Web 配置树会省略该服务及其伴随插件。 -Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、构建配置、依赖声明和 lockfile 描述同一发布表面。生成的配置目录、模块图和 API 文档都从这些源派生。 +Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、构建配置、依赖声明和 lockfile 描述同一份发布元数据。生成的配置目录、模块图和 API 文档都从这些源派生。 ## 测试 diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml index be5e4be24f..30e6f150a5 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.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/architecture/2026-07-22-slot-type-chain-implementation.md -2026-07-22-slot-type-chain-implementation.md: 9930aaa62bd4b1f37dac9736517524b7e01b1746 -2026-07-22-slot-type-chain-implementation.zh.md: 2778d223cd89b4394bd629661dbb58105e6655cb +2026-07-22-slot-type-chain-implementation.md: 41a5c4592b22cc66d2f717794534305972c89eb3 +2026-07-22-slot-type-chain-implementation.zh.md: 16b923376c7e6de63cb556cb666a0e35bfd9f080 diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md index 9930aaa62b..41a5c4592b 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md @@ -78,7 +78,7 @@ export function createChatStore() { One factory, three consumption points: (a) `register` — pass the factory for an exclusive store, or call it once in `apply` and pass the same handle to several registers to share the instance (cross-plugin sharing is constructively impossible: the handle never leaves the package); (b) `PropsStore>` derives the component's store share with zero hand-written members; (c) tests call the factory and `.create()` a real engine instance, feeding `useSelector`/`actions` straight in as props — production outlets run the very same `create` path, so there is no second machinery. -Store scope is **derived from the mounting entry's scope** (session slot → one instance per session, living and dying with the session; root slot → one per entry). Read = `props.useStore`; write = `props.actions.*` only — the raw instance (with `update`/`set`) never reaches a component, so the declared actions are the complete, auditable mutation surface. Production code never calls the factory or `create` outside `apply`. +Store scope is **derived from the mounting entry's scope** (session slot → one instance per session, living and dying with the session; root slot → one per entry). Read = `props.useStore`; write = `props.actions.*` only — the raw instance (with `update`/`set`) never reaches a component, so the declared actions are the complete, auditable mutation API. Production code never calls the factory or `create` outside `apply`. ### inject: the registrant's business face, on its own ctx @@ -103,19 +103,19 @@ Two hardening decisions in the register signature exist because the obvious alte ## Consequences -Render authority is enforceable rather than conventional: who renders what is a load-time fact, and auditing the UI structure = reading the register calls; for chain slots, WHO renders is additionally a render-time fact, but the deciding selectors are register-site declarations, so the audit surface stays the register calls. Every props surface is statically derived from one source (SlotMap entry, children keys, store factory, inject return), so a schema change propagates by compiler rather than by grep. Plugins carry no subscription machinery of their own — store lifecycle (per-session instances, disposal, persistence) is framework semantics keyed to the entry axis. Costs: registration options are dense (children spec objects); the framework carries real inference machinery (`defineStore`'s init/actions same-round inference may need a curried fallback); and the compile-time double locks mean prototype-stage drift is a hard error, not a warning. +Render authority is enforceable rather than conventional: who renders what is a load-time fact, and auditing the UI structure = reading the register calls; for chain slots, WHO renders is additionally a render-time fact, but the deciding selectors are register-site declarations, so the audit scope stays the register calls. Every props API is statically derived from one source (SlotMap entry, children keys, store factory, inject return), so a schema change propagates by compiler rather than by grep. Plugins carry no subscription machinery of their own — store lifecycle (per-session instances, disposal, persistence) is framework semantics keyed to the entry axis. Costs: registration options are dense (children spec objects); the framework carries real inference machinery (`defineStore`'s init/actions same-round inference may need a curried fallback); and the compile-time double locks mean prototype-stage drift is a hard error, not a warning. ## Alternatives considered | Rejected | One-line reason | |---|---| | Separate define/register two-step API | The split leaves render authority unenforced and invites ordering bugs; children-in-register settles declaration, authorization, and spec in one visible place | -| Whitelist face objects (`ScopedSlots` + narrowing helpers) | With the whitelist already in the component's props type, the face is derivable by machinery; a mintable face object is a third authority surface with runtime-only checks | +| Whitelist face objects (`ScopedSlots` + narrowing helpers) | With the whitelist already in the component's props type, the face is derivable by machinery; a mintable face object is a third authority API with runtime-only checks | | Assembly handles carrying root ctx into inject | Bypasses declared inject topology — every factory could reach every service, so package.json dependency declarations stop meaning anything | | `children` as a key array | kind/scope are runtime dispatch data; SlotMap is erased, so an array forces a second spec-registration API — a definition API reborn | | Business hand-made hooks / raw observables in component props | Every plugin becomes its own subscription machine; the inject `hooks` compartment carries the same facts through the one audited binding machinery | | Module-level store handles | A module-scope handle is a singleton across plugin reloads and test cases; the factory form scopes identity to apply/test invocation | -| Components receiving the store instance | `update`/`set` in render code makes the mutation surface unauditable; declared actions keep "what can change" a register-site fact | +| Components receiving the store instance | `update`/`set` in render code makes the mutation API unauditable; declared actions keep "what can change" a register-site fact | | `FC` at the register position / inferring `I` from the component | FC statics generate covariant noise that rejects valid components; component-side inference absorbs props drift silently (see rulings above) | | Keyed dispatch with owner-side routing for takeover slots | The owner accumulates per-entry contracts and a hardcoded routing table (`find` + `entryKey` per takeover); the chain currency keeps new takeover registrations at zero owner edits | | Components declining by rendering null | Declining requires mounting first — hooks and effects run for nothing, and mount/unmount churn breaks memoization and key semantics; a pure selector decides without a component instance | diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md index 2778d223cd..16b923376c 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md @@ -110,12 +110,12 @@ register 签名里的两条硬化裁定之所以存在,是因为显然的替 | Rejected | One-line reason | |---|---| | 独立的 define/register 两步式 API | 拆分让渲染权威无从强制、招来时序 bug;children 进 register 让声明、授权、spec 在同一个可见位置结清 | -| 白名单面对象(`ScopedSlots` + 收窄辅助件) | 白名单已在组件的 props 类型里,面可由机械推导;可铸造的面对象是第三个权威面,且只有运行时校验 | +| 白名单面对象(`ScopedSlots` + 收窄辅助件) | 白名单已在组件的 props 类型里,该对象可由机械推导;可铸造的面对象是第三套权威 API,且只有运行时校验 | | 装配句柄把 root ctx 带进 inject | 绕开声明的 inject 拓扑——每个工厂都摸得到每个服务,package.json 的依赖声明就此失去意义 | | `children` 用键数组形 | kind/scope 是运行时分派数据;SlotMap 已被擦除,数组形必然逼出第二个 spec 注册 API——定义 API 复活 | | 业务手造 hook / 组件 props 里递裸 observable | 每个插件都变成自己的订阅机械;inject `hooks` 格让同样的事实走那一台受审计的绑定机械 | | 模块级 store 句柄 | 模块级句柄是跨插件重载与跨测试用例的单例;工厂形把身份圈定在单次 apply/测试调用内 | -| 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更面就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 | +| 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更 API 就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 | | 注册位用 `FC` / 从组件推断 `I` | FC 静态位产生协变噪音、拒绝合法组件;组件侧推断静默吸收 props 漂移(见上文裁定) | | 接管 slot 用 keyed 分派 + owner 侧路由 | owner 会不断攒下逐 entry 约定与硬编码路由表(每种接管一份 `find` + `entryKey`);chain 货币让新增接管注册保持 owner 零改动 | | 组件靠渲染 null 表示不接 | 不接也得先挂载——hook 与 effect 白跑,挂载/卸载抖动破坏 memo 化与 key 语义;纯选择器无需组件实例即可裁决 | diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index 85012597e6..cebb8b554a 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.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/architecture/2026-07-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: 21289c5dcebc7244e98c602e9f10bac7eb365bc3 -2026-07-23-client-plugin-loading-model.zh.md: c3c4ef598c1d92d4ebec7b9691d31cf33c7c62a2 +2026-07-23-client-plugin-loading-model.md: 860186294059facf17d1eba38423092acf7a4a7d +2026-07-23-client-plugin-loading-model.zh.md: 68f2e70253485d4e215c981d3b338d5046390247 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index 21289c5dce..8601862940 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -46,7 +46,7 @@ Four edge rules govern imports across the two kinds. None of them depends on any ### One module system, one plugin governor -The browser mirrors the host's division of labor. `dsh-client-modules` (`ClientModuleSystem`) takes the module-system seat that Node's internal ESM loader holds host-side; the same vendored `@cordisjs/plugin-loader` keeps the governance seat on both sides. The line between them in one sentence: **the module system owns module identity and bytes — how code arrives, registers, and becomes an export surface; the Loader owns plugin lifecycle — when a plugin mounts, what it waits for, and how it is torn down.** +The browser mirrors the host's division of labor. `dsh-client-modules` (`ClientModuleSystem`) takes the module-system seat that Node's internal ESM loader holds host-side; the same vendored `@cordisjs/plugin-loader` keeps the governance seat on both sides. The line between them in one sentence: **the module system owns module identity and bytes — how code arrives, registers, and becomes an exports; the Loader owns plugin lifecycle — when a plugin mounts, what it waits for, and how it is torn down.** `ClientModuleSystem` is a lazy CJS table. Executing a bundle only **registers** its factory — the bundle calls `window.__ModuleLoader__.load({ id, factory })` and nothing else happens. Every module body side effect, CSS injection included, lives inside the factory closure and runs at materialization: the first `require`/import of that id, memoized after that. A factory that requires a registered-but-unmaterialized sibling materializes it recursively, so no sort order exists anywhere. When asked to import an id, the table resolves through a fixed branch order: seed word → memoized record → static registration (shell-own modules, e.g. app-shell) → registered factory → graph-row external classic-script load → loud throw. That final throw is the runtime mirror of the build-time purity gate. The system also keeps per-module bookkeeping — owned `