diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f40435c..f8b104ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,8 @@ jobs: node-version: ${{ matrix.node }} cache: npm - run: npm ci + - name: Run native DSH Skill discovery smoke + run: npm run test:dsh-native - name: Run Vitest run: npm run test:ci - name: Verify Harness DSL generated sources diff --git a/docs/adapters/README.md b/docs/adapters/README.md index d523a4e7..5b2257d1 100644 --- a/docs/adapters/README.md +++ b/docs/adapters/README.md @@ -2,7 +2,8 @@ This is the single entry point for Claude Code, Codex, Qoder, Cursor, Qwen, GitHub Copilot, Pi, Kimi Code, WorkBuddy, and Grok host boundaries, plus the -DeepSeek Harness (DSH) developer-preview session-only slice. Do not +DeepSeek Harness (DSH) verified install/discovery and developer-preview session +slices. Do not create `docs/adapters/claude-code.md`, `docs/adapters/codex.md`, `docs/adapters/qoder.md`, `docs/adapters/cursor.md`, `docs/adapters/qwen.md`, `docs/adapters/copilot.md`, `docs/adapters/pi.md`, @@ -44,7 +45,7 @@ project `.kimi-code/skills/`), then runs `/skill:better-harness`. | Kimi Code | Analysis-capable source-local host | `.kimi-plugin/plugin.json` | `scripts/agent-customize/providers/kimi.mjs` | `scripts/session-analysis/platforms/kimi.mjs` | self-contained HTML + Markdown | `AGENTS.md` + `~/.kimi-code/skills` + project `.kimi-code/skills`/`.kimi/skills` + `~/.kimi-code/mcp.json` | `harness evidence-bundle --platform kimi` -> validated `html` render | | WorkBuddy | Analysis-capable source-local host | none (skills install into `~/.workbuddy/skills`) | `scripts/agent-customize/providers/workbuddy.mjs` | `scripts/session-analysis/platforms/workbuddy.mjs` | self-contained HTML + Markdown | `~/.workbuddy` `AGENTS.md` + identity files + `.agents` + `AGENTS.md` | `session-analysis --platform workbuddy sources` -> validated `html` render | | Grok | Analysis-capable source-local host | none (skills install into `~/.grok/skills`) | `scripts/agent-customize/providers/grok.mjs` | `scripts/session-analysis/platforms/grok.mjs` | self-contained HTML + Markdown | `~/.grok` + `.grok` + `.agents` + `AGENTS.md` | `session-analysis --platform grok sources` -> skill symlink -> validated `html` render | -| DeepSeek Harness (DSH) | Partial session-evidence adapter (developer preview) | none / unavailable | unavailable | `scripts/session-analysis/platforms/dsh.mjs`; `dsh-v1` for the audited format-0 session-evidence slice from DSH `dsh-v0.1.0-rc.7` and `dsh-v0.1.0-rc.8`, raw `.jsonl` and feature-detected `.jsonl.zstd` | unavailable; no report route | unavailable | read-only `node scripts/session-analysis.mjs sources --platform dsh --workspace [--dsh-home ]` or `node scripts/session-analysis.mjs facts --platform dsh --workspace [--dsh-home ]` | +| DeepSeek Harness (DSH) | Verified install/discovery for headless/base and Web `standard`/`code`/`cordis`; partial session evidence (developer preview) | local DSH Cordis policy at `scripts/dsh-skill-discovery/index.mjs`; no lifecycle shell | unavailable | `scripts/session-analysis/platforms/dsh.mjs`; `dsh-v1` for the audited format-0 session-evidence slice from DSH `dsh-v0.1.0-rc.7` and `dsh-v0.1.0-rc.8`, raw `.jsonl` and feature-detected `.jsonl.zstd` | unavailable; no report route | canonical Skill from the complete root; model Skill calls rejected | `npm run test:dsh-native`; read-only session `sources`/`facts` commands remain separate | ## Read-only Plugin Lifecycle @@ -84,9 +85,9 @@ Plans never execute and always preserve native surface differences: Kimi Code, Grok, and DSH are absent from this table on purpose: none has a validated native lifecycle contract yet, so lifecycle targets reject them with `UNKNOWN_HOST` instead of borrowing another host's install route. Kimi Code and -Grok retain their configured-asset and session evidence. DSH retains only its -partial session-evidence slice and has no lifecycle profile or native lifecycle -claim. +Grok retain their configured-asset and session evidence. DSH retains its +bounded verified discovery and partial session-evidence slices, but has no +lifecycle profile or native lifecycle claim. The lifecycle commands do not read raw session transcripts, contact a registry, edit host settings, or register an `apply` path. @@ -192,8 +193,28 @@ edit host settings, or register an `apply` path. `signals.json`). The adapter honors `GROK_HOME`. Grok has no install shell in this repository; skills install manually into `~/.grok/skills` (symlink is enough for `/better-harness`). -- DeepSeek Harness has a developer-preview, JSONL-only session adapter at - `scripts/session-analysis/platforms/dsh.mjs`. Home resolution is strictly +- DeepSeek Harness has two independent bounded slices. Verified + install/discovery uses DSH `0.1.1-rc.2` at audited source + `b150a551b8d465e31e418e1b2eaf5e79bbb7d28e`. The sole supported discovery + route points the active DSH `skill-filesystem.customSkillDirs` at the + absolute `/skills` directory and loads the local Cordis + policy at `scripts/dsh-skill-discovery/index.mjs` with the same complete root. + The policy verifies the winning DSH definition's `custom` source, absolute + `SKILL.md` path, directory `resourceBase`, two-parent root invariant, and + required `scripts/`, `references/`, `models/`, and `templates/` resources + before direct `/better-harness` injection. It rejects model-facing + `skill({ name: "better-harness" })` calls without changing shared Skill + frontmatter or other hosts. The route is qualified for headless/base and for + a Web user preset copied from `standard`, `code`, or `cordis`, where the + active scoped `skill-filesystem` row is edited. Web `minimal` mounts no Skill + loader and remains unsupported. Project `.dsh/skills` and `.agents/skills` + candidates retain DSH precedence; a same-name shadow fails canonical + verification. Copies, symlinks/junctions, relative paths, and literal `~` + values are not canonical routes. Moving the Better Harness root requires + updating every configured absolute path. The credential-free native owner + smoke is `npm run test:dsh-native`. +- Separately, DeepSeek Harness has a developer-preview, JSONL-only session + adapter at `scripts/session-analysis/platforms/dsh.mjs`. Home resolution is strictly `--dsh-home` over `DSH_HOME` over `~/.dsh`, and the only source root is `/sessions`. Discovery is read-only and accepts only the fixed nested `session.jsonl` or `session.jsonl.zstd` layout. Workspace qualification uses @@ -216,12 +237,12 @@ edit host settings, or register an `apply` path. feature-detected; where it is absent, including Node.js 23.0 through 23.7, compressed evidence is unavailable while independent raw JSONL evidence remains readable. There is no fallback dependency or shell. - This slice does not provide native DSH installation or invocation, live PTY - or process state, configured assets or Skills, plugin lifecycle, a shell, - manifest or package integration, report/output routing, README Quickstart or - Installation placement, SQLite or custom persistence, automatic - optimization, plugin fault or causality attribution, or artifact repair or - writes. See [Story #93](https://github.com/QoderAI/better-harness/issues/93) + The combined DSH boundary does not provide live PTY or process state, + configured assets, plugin lifecycle, a managed shell, manifest or package + integration, report/output routing, README Quickstart, SQLite or custom + persistence, automatic optimization, plugin fault or causality attribution, + or artifact repair or writes. See + [Story #93](https://github.com/QoderAI/better-harness/issues/93) and the [dated support specification](../specs/2026-08-18-93-deepseek-harness-session-evidence.md). ## Output Modes diff --git a/docs/docs/hosts/adapter-matrix.md b/docs/docs/hosts/adapter-matrix.md index 95f4ddf4..739d5f6d 100644 --- a/docs/docs/hosts/adapter-matrix.md +++ b/docs/docs/hosts/adapter-matrix.md @@ -14,11 +14,12 @@ host-neutral. ## Support levels Better Harness currently declares ten more complete capability-level host -adapters plus one DSH session-only partial slice. Six have verified public +adapters plus bounded DSH discovery and session slices. Six have verified public Quickstart paths. Pi, Kimi Code, WorkBuddy, and Grok are visible as adapter support because their installation and end-to-end evidence boundaries differ -from that six-host set. DSH is visible only as a developer-preview session -evidence contract, not as a runnable report adapter. The [canonical adapter matrix](https://github.com/QoderAI/better-harness/blob/main/docs/adapters/README.md) +from that six-host set. DSH has Verified install/discovery for a qualified +runtime/preset boundary plus a developer-preview session-evidence contract; it +is not a runnable report adapter. The [canonical adapter matrix](https://github.com/QoderAI/better-harness/blob/main/docs/adapters/README.md) remains the complete capability-level source of truth. ## Supported host adapters @@ -35,7 +36,7 @@ remains the complete capability-level source of truth. | Kimi Code | Adapter support | Analysis-capable source-local host | `.kimi-plugin/plugin.json` | Workspace-matching Kimi wire transcripts | Self-contained HTML + Markdown | | WorkBuddy | Adapter support | Analysis-capable source-local host | None; skills use WorkBuddy-owned paths | Workspace-matching WorkBuddy JSONL transcripts | Self-contained HTML + Markdown | | Grok | Adapter support | Analysis-capable source-local host | None; skills use Grok-owned paths | Workspace-matching Grok session dirs (`updates.jsonl`) | Self-contained HTML + Markdown | -| DeepSeek Harness (DSH) | Session analysis only | Partial, developer-preview contract | None | DSH JSONL backend session format `0`: raw `.jsonl` and feature-detected `.jsonl.zstd` | Unavailable | +| DeepSeek Harness (DSH) | Verified install/discovery | Qualified headless/base and Web `standard`/`code`/`cordis`; partial session evidence | Local DSH Cordis policy; no lifecycle shell | DSH JSONL backend session format `0`: raw `.jsonl` and feature-detected `.jsonl.zstd` | Unavailable | The `@qoder-ai/better-harness` npm package includes all seven plugin metadata roots. Pi reuses install metadata in the existing `package.json`, so it does @@ -62,10 +63,11 @@ unavailable, transient Pi update/remove are not applicable, and WorkBuddy returns `PLUGIN_LIFECYCLE_UNSUPPORTED`. Kimi Code and Grok have no validated native lifecycle contract yet, so lifecycle targets reject them with `UNKNOWN_HOST` while their adapter evidence stays available. DSH likewise has -no lifecycle profile: lifecycle targets reject it with `UNKNOWN_HOST`, and only -its partial session evidence remains available. The shadow host -profiles do not replace the canonical adapter matrix while ADR-0002 remains -proposed. +no lifecycle profile: lifecycle targets reject it with `UNKNOWN_HOST`; its +manually configured verified discovery and partial session evidence remain +available. The shadow host profiles do not replace the canonical adapter matrix +while ADR-0002 remains proposed. DSH's verified discovery does not add a +lifecycle target. ## Output modes @@ -120,8 +122,31 @@ smoke is observed. ### DeepSeek Harness (DSH) {#deepseek-harness-dsh} -DSH coverage is a developer-preview, JSONL-only session slice, with Better -Harness adapter metadata `dsh-v1`. Its format-0 session-evidence slice is +DSH has Verified install/discovery against DSH `0.1.1-rc.2` at audited source +`b150a551b8d465e31e418e1b2eaf5e79bbb7d28e`. The only supported route points +the active `skill-filesystem.customSkillDirs` at the absolute +`/skills` directory and loads the Better Harness DSH policy +from the same complete root. The policy fails closed unless DSH's winning +definition has the expected `custom` source, `SKILL.md` path, directory +`resourceBase`, two-parent root, and required root resources. A direct user +`/better-harness` gesture then injects the canonical Skill at DSH's pre-model +step boundary, while a model-facing Better Harness `skill` tool call is +rejected. + +This route is qualified for headless/base. In Web it is qualified only for an +active user preset copied from `standard`, `code`, or `cordis` and configured +through that preset's scoped `skill-filesystem` row. Web `minimal` has no Skill +loader and remains unsupported. DSH's project-local same-name roots keep their +native higher precedence, but such a winner is reported unverified rather than +canonical. Standalone copies and symlinks/junctions are not supported install +routes. Paths must be absolute; DSH resolves relative paths from its process +working directory and does not expand a literal `~`. Moving the complete Better +Harness root requires reconfiguring every absolute path. The Installation page +documents the configuration boundary; run the pinned, credential-free owner +smoke with `npm run test:dsh-native`. + +Separately, DSH has a developer-preview JSONL session slice with Better Harness +adapter metadata `dsh-v1`. Its format-0 session-evidence slice is validated against DSH `dsh-v0.1.0-rc.7` and `dsh-v0.1.0-rc.8`, including RC8 interrupted assistant messages and required team-event vocabulary. Team events are validated and accounted, not projected as team analytics. Home resolution @@ -150,10 +175,10 @@ The implemented source-checkout smoke boundary is read-only: node scripts/session-analysis.mjs sources --platform dsh --workspace [--dsh-home ] ``` -This is not evidence of native DSH installation or invocation. DSH has no live -PTY/process integration, configured-asset or Skill discovery, plugin lifecycle, -shell, manifest, package integration, report/output route, README Quickstart or -Installation path, SQLite or custom persistence support, automatic +Verified discovery does not imply a complete report loop. DSH has no live +PTY/process integration, configured-assets support, plugin lifecycle, managed +shell, manifest, package integration, report/output route, public Quickstart, +SQLite or custom persistence support, automatic optimization, plugin-fault attribution, or artifact mutation/recovery. See the [canonical source matrix](https://github.com/QoderAI/better-harness/blob/main/docs/adapters/README.md) and [Story #93](https://github.com/QoderAI/better-harness/issues/93). diff --git a/docs/docs/installation.mdx b/docs/docs/installation.mdx index 4035c955..a65fc4cd 100644 --- a/docs/docs/installation.mdx +++ b/docs/docs/installation.mdx @@ -58,10 +58,92 @@ managed plugin lifecycle surface. There is no `plugin apply` command. Git can clone the Cursor manifest for inspection, but manifest presence alone does not establish a supported installation route. +## DeepSeek Harness verified discovery (not Quickstart) {#deepseek-harness-dsh} + +DeepSeek Harness (DSH) has a bounded **Verified install/discovery** route for +the qualified DSH `0.1.1-rc.2` contract. This is not a public Quickstart or a +complete report loop. + +Start from a complete Better Harness source checkout or npm package directory. +Call its absolute directory `` below. It must contain +`skills/better-harness/SKILL.md`, `scripts/better-harness.mjs`, `references/`, +`models/`, and `templates/`. + +### Headless/base + +In the active headless profile's `cordis.patch.yml`, configure the existing +global Skill filesystem row and insert the Better Harness DSH policy: + +```yaml +- id: skill-filesystem + config: + customSkillDirs: + - /absolute/path/to/better-harness/skills + +- insert: + - id: better-harness-explicit-only + name: /absolute/path/to/better-harness/scripts/dsh-skill-discovery/index.mjs + config: + betterHarnessRoot: /absolute/path/to/better-harness +``` + +The profile file is normally +`$DSH_HOME/profiles/headless/cordis.patch.yml`. Replace every example with the +same real absolute root before starting a new session. + +### Web `standard`, `code`, or `cordis` + +The Web host owns Skill discovery inside the selected agent preset. In Web's +Agent Presets settings, copy `standard`, `code`, or `cordis` to a user preset. +Edit that copy's active `skill-filesystem` row in +`$DSH_HOME/.agent-presets//agent.cordis.yml`: + +```yaml +- id: skill-filesystem + name: '@deepseek-ai/dsh-skill-filesystem' + config: + customSkillDirs: + - /absolute/path/to/better-harness/skills +``` + +Then insert the same `better-harness-explicit-only` policy row shown above in +`$DSH_HOME/profiles/web/cordis.patch.yml`, select the copied user preset, and +start a new session. Editing only the global Web `skill-filesystem` row is not +the qualified route. Web `minimal` does not mount the Skill loader and remains +unsupported. + +### Verify the boundary + +Enter the gesture directly as the user: + +```text +/better-harness +``` + +DSH must inject the canonical Skill before model request derivation. The local +policy checks DSH's winning source, path, directory resource base, complete-root +invariant, and required root resources. It rejects a higher-precedence +project-local same-name Skill and rejects model-facing +`skill({ name: "better-harness" })` calls. + +Use an absolute path on macOS, Linux, and Windows, including when it contains +spaces or Unicode. DSH resolves relative `customSkillDirs` from its process +working directory and does not expand a literal `~`. Do not install a standalone +Skill copy or use a symlink/junction as the canonical route. If the complete +Better Harness root moves, update both the `customSkillDirs` value and policy +plugin path/configuration. Repository contributors can repeat the pinned, +credential-free native proof with `npm run test:dsh-native`. + +This maturity level does not provide configured assets, evidence-bundle or +report registration, output routing, rendering, lifecycle management, +MCP/profile product support, Web `minimal`, or a full report workflow. The +[adapter matrix](./hosts/adapter-matrix#deepseek-harness-dsh) tracks the exact +boundary. + :::tip Don't see your Coding Agent? The six tabs below are the verified Quickstart paths, while the project tracks -ten host adapters in total. [Compare all adapter support boundaries](./hosts/adapter-matrix), +ten fuller host adapters plus bounded DSH slices. [Compare all adapter support boundaries](./hosts/adapter-matrix), then [follow the new-host contribution workflow and worked pull requests](./hosts/contributing-new-coding-agent) if you want to add or complete an integration. You can also [browse current repository pull requests](https://github.com/QoderAI/better-harness/pulls) diff --git a/docs/specs/2026-08-22-99-deepseek-harness-skill-discovery.md b/docs/specs/2026-08-22-99-deepseek-harness-skill-discovery.md new file mode 100644 index 00000000..bbff9276 --- /dev/null +++ b/docs/specs/2026-08-22-99-deepseek-harness-skill-discovery.md @@ -0,0 +1,288 @@ +# Verified DeepSeek Harness Skill Discovery + +## Traceability + +- Spec ID: deepseek-harness-skill-discovery +- Story: #99 +- Status: Implemented + +## Intent + +[Issue #99](https://github.com/QoderAI/better-harness/issues/99) advances +DeepSeek Harness (DSH) by one bounded maturity step: + +```text +Partial adapter -> Verified install/discovery -> Public Quickstart + ^ Story #99 stops here +``` + +Better Harness already supports DSH `sessionAnalysis` under Story #93. DSH has +no Better Harness-supported, verified native route for loading the canonical +Better Harness Skill. This Story defines that route and its trust and invocation +contract. It does not add the complete report loop or full host support. + +The user outcome is: + +> A DSH user can configure one documented DSH-native discovery route, have DSH +> load the canonical Better Harness Skill from the complete Better Harness +> root, explicitly invoke `/better-harness`, and Better Harness can verify that +> the winning Skill is canonical and unavailable for model-initiated +> invocation. + +### Native contract evidence + +The qualified npm prerelease is `@deepseek-ai/dsh@0.1.1-rc.2`; its tag and +current `master` both resolve to +`b150a551b8d465e31e418e1b2eaf5e79bbb7d28e`. The audited owners are +byte-identical to the prior `dsh-v0.1.1-rc.1` audit at +`528c682e061696f5a160f363f236ecbf53cbd006`. Support remains pinned to these +source-owned contracts: + +1. [`FileSystemSkillProvider`, root ranking, `customSkillDirs`, path resolution, and `resourceBase`](https://github.com/deepseek-ai/deepseek-harness/blob/b150a551b8d465e31e418e1b2eaf5e79bbb7d28e/packages/skill/skill-filesystem/src/index.ts) +2. [Skill registry identity and winning-candidate behavior](https://github.com/deepseek-ai/deepseek-harness/blob/b150a551b8d465e31e418e1b2eaf5e79bbb7d28e/packages/skill/skill/src/index.ts) +3. [Slash injection and the model-facing Skill tool](https://github.com/deepseek-ai/deepseek-harness/blob/b150a551b8d465e31e418e1b2eaf5e79bbb7d28e/packages/skill/tool-skill/src/index.ts) +4. [Base/headless Skill composition](https://github.com/deepseek-ai/deepseek-harness/blob/b150a551b8d465e31e418e1b2eaf5e79bbb7d28e/packages/bundle/base/cordis.patch.yml) +5. [Web host composition and preset-owned Skill rows](https://github.com/deepseek-ai/deepseek-harness/blob/b150a551b8d465e31e418e1b2eaf5e79bbb7d28e/packages/bundle/web-app/cordis.patch.yml) +6. [Standard preset](https://github.com/deepseek-ai/deepseek-harness/blob/b150a551b8d465e31e418e1b2eaf5e79bbb7d28e/apps/cli/config/agent-presets/standard/agent.cordis.yml), [code preset](https://github.com/deepseek-ai/deepseek-harness/blob/b150a551b8d465e31e418e1b2eaf5e79bbb7d28e/apps/cli/config/agent-presets/code/agent.cordis.yml), [Cordis preset](https://github.com/deepseek-ai/deepseek-harness/blob/b150a551b8d465e31e418e1b2eaf5e79bbb7d28e/apps/cli/config/agent-presets/cordis/agent.cordis.yml), and [minimal preset](https://github.com/deepseek-ai/deepseek-harness/blob/b150a551b8d465e31e418e1b2eaf5e79bbb7d28e/apps/cli/config/agent-presets/minimal/agent.cordis.yml) + +Later DSH releases are not implicitly qualified. Implementation must relock +these owners and update evidence if the developer-preview contract moves. + +### Canonical route and runtime boundary + +The sole supported route is to configure the active DSH +`skill-filesystem.customSkillDirs` with the absolute path: + +```text +/skills +``` + +This native route discovers `skills/better-harness/SKILL.md` with +`resourceBase` equal to `/skills/better-harness`. Two +parents above that Skill directory is the complete Better Harness root, so its +`scripts/`, `references/`, `models/`, and `templates/` resources remain valid. +It creates no copied state and requires no symlink or junction privileges. +At the Better Harness spec baseline +`465e9bdfe4d9330a45d019ab192eac7bf1ed47ea`, the [canonical Skill +frontmatter](https://github.com/QoderAI/better-harness/blob/465e9bdfe4d9330a45d019ab192eac7bf1ed47ea/skills/better-harness/SKILL.md) +contains only `name` and `description`; its description says to invoke only +through the slash command, but those two fields alone leave both DSH invocation +surfaces enabled. + +The qualified modes are: + +- headless/base, through its active global `skill-filesystem` instance; +- Web with the selected `standard`, `code`, or `cordis` user preset, through + that preset's active scoped `skill-filesystem` instance. + +Web `minimal` is unsupported because its preset does not mount the required +Skill filesystem/loader. This Story does not add it. A standalone Skill copy, +a symlink/junction installation, and a project-local copied Skill are negative +controls, not canonical installation routes. + +DSH resolves `customSkillDirs` through the process working directory and does +not expand `~`; therefore documentation and verification require an absolute +path. Verification must address the active instance rather than merely finding +the same configuration key in an inactive Web composition layer. + +### Canonical identity and trust + +Discovery of a Skill named `better-harness` is insufficient. DSH's default +precedence lets project `.dsh/skills` and `.agents/skills` candidates outrank a +custom root. A project-local same-name Skill may therefore win legitimately +under DSH rules. + +Verified install/discovery requires the winning definition's source, resolved +Skill file path, and directory `resourceBase` to identify +`/skills/better-harness` under the expected complete root. +It must also establish the two-parent root invariant and the required root +resources. A shadow, stale root, malformed candidate, or identity mismatch must +fail closed without redesigning DSH precedence. + +### Explicit-only policy decision + +The product outcome is fixed: a direct user `/better-harness` gesture loads and +injects the winning canonical Skill deterministically before model execution, +while DSH's model-facing Skill catalog/tool cannot invoke it. + +The implementation mechanism is deliberately unspecified. Implementation must +investigate existing shared frontmatter, host-specific policy, +verifier/configuration behavior, or another existing mechanism and select the +smallest evidenced cross-host-safe option. In particular, this specification +does not prescribe adding `disable-model-invocation` to the shared Skill. Any +shared metadata or parser change must remain compatible with all affected +canonical Skill consumers and validators. + +If implementation research shows that every available mechanism requires a +breaking shared Skill-contract trade-off, stop and return the decision to the +maintainer rather than weakening either invocation outcome. + +## Acceptance Scenarios + +### AC-1: Canonical discovery route + +Exactly one supported documented route configures the absolute +`/skills` path through the active DSH +`skill-filesystem.customSkillDirs`. Standalone copy, symlink/junction, and +project-local copy are not claimed as canonical routes. + +### AC-2: Runtime boundary + +The route is qualified for headless/base and for a selected Web `standard`, +`code`, or `cordis` user preset. Web `minimal` remains explicitly unsupported. + +### AC-3: Native discovery smoke + +A pinned, credential-free native DSH smoke discovers `better-harness` and +proves that the winning source and path are canonical, its `resourceBase` is +`/skills/better-harness`, the two-parent root invariant +holds, and the required CLI/root resources exist. + +### AC-4: Explicit invocation + +A direct user `/better-harness` gesture deterministically loads and injects the +winning canonical Skill body before model execution. + +### AC-5: Explicit-only invocation + +DSH model-initiated Better Harness Skill invocation is unavailable while direct +user slash invocation remains available. The implementation mechanism is +intentionally unspecified. + +### AC-6: Cross-host safety + +If implementation changes shared canonical Skill frontmatter, metadata, or +parsing behavior, every affected current Better Harness Skill consumer, +runtime, packaging path, and validator remains compatible. + +### AC-7: Shadowing and trust + +A higher-precedence same-name Skill is never reported as canonical. Verification +binds the winning source, resolved path, and `resourceBase` to the expected +Better Harness root without changing DSH precedence. + +### AC-8: Invalid or stale configuration + +A malformed candidate, missing or stale Better Harness root, unsupported path +form, or invalid canonical identity fails or reports deterministically and +never produces a false Verified install/discovery result. + +### AC-9: Cross-platform paths + +The canonical setup requires no symlink privileges and validates relevant +macOS, Linux, and Windows behavior, including paths with spaces and Unicode. +Documentation requires an absolute path and states that `~` is not expanded +and relative values depend on DSH's process working directory. + +### AC-10: Maturity claim + +After all acceptance scenarios pass, documentation may identify DSH as +**Verified install/discovery** only for the qualified runtime/preset boundary. +Configured assets, report routing, lifecycle, the complete report workflow, +and Public Quickstart remain unclaimed. + +## Non-goals + +- configured-assets inventory +- evidence-bundle/report registration +- output routing +- report rendering +- full `/better-harness -> report` end-to-end support +- Public Quickstart promotion +- plugin lifecycle +- MCP/profile product support +- session-analysis changes +- new DSH persistence/schema support +- provider-backed report end-to-end testing +- Web `minimal` preset support +- DSH upstream modification +- full DSH host support +- unrelated Better Harness cleanup + +These exclusions are outside this Story, not permanent product decisions. + +## Plan and Tasks + +1. Lock the current native DSH release, source SHA, and contract-owner evidence. +2. Add RED native discovery and verification tests where appropriate. +3. Establish the canonical active `customSkillDirs` configuration route. +4. Prove canonical winning identity, path, `resourceBase`, and root resources. +5. Prove deterministic direct-user slash injection before model execution. +6. Investigate explicit-only implementation mechanisms without assuming one. +7. Select the smallest mechanism that preserves current cross-host contracts. +8. Prove the DSH model-facing path cannot invoke Better Harness. +9. Run the affected cross-host consumer, validator, and packaging regressions. +10. Add bounded path, stale-root, malformed-candidate, and shadowing negatives. +11. Update installation documentation and the support maturity matrix. +12. Run the complete repository readiness and attribution gates. + +No plan item is implemented by this specification commit. + +## Test and Review Evidence + +### Native credential-free proof + +Pin the official DSH build and run the real filesystem provider, Skill registry, +and slash pre-step owner without a provider/API credential. The fixture must use +a complete Better Harness source checkout or npm package root. Capture bounded +assertions for discovery source/path/`resourceBase`, root resolution and required +resources, slash-body injection ordering, and absence from or rejection by the +model-facing catalog/tool. Do not substitute a Better Harness-only parser or a +mocked provider for the native ownership boundary. + +### Negative and trust proof + +Bounded fixtures cover a higher-precedence project-local same-name shadow, +malformed candidate, stale or missing Better Harness root, standalone-copy +negative control, symlink negative/control behavior where useful, relative +`customSkillDirs`, literal `~`, spaces, and Unicode. They exist to prevent false +verification claims, not to expand this Story into general filesystem support. + +### Cross-host compatibility proof + +Before choosing a shared-policy mechanism, enumerate the affected consumers +from the current repository rather than relying on a frozen host list. At spec +preparation time the shared Skill is consumed or packaged through Qoder, +Claude, Cursor, Codex, Qwen Code, GitHub Copilot, Kimi, Pi, and the generated +Antigravity artifact; current manual/inventory routes also cover Grok and +WorkBuddy. Any shared metadata or parser change must pass the applicable Skill +contract, manifest, lifecycle, packaging, artifact, and host-specific validators +for the then-current affected set. An unaffected consumer needs no invented +test, but an affected consumer may not be omitted. + +### Acceptance evidence map + +| Contract | Required evidence | +| --- | --- | +| AC-1, AC-2 | Configuration documentation plus active headless and selected Web preset composition proof | +| AC-3 | Pinned native filesystem-provider discovery smoke and root/resource assertions | +| AC-4, AC-5 | Native pre-step slash injection plus model catalog/tool exclusion or rejection | +| AC-6 | Then-current affected consumer, validator, and packaging regression suite | +| AC-7, AC-8 | Shadow, malformed, stale-root, copy, and unsupported-path negative fixtures | +| AC-9 | macOS/Linux/Windows path tests, including absolute, spaces, and Unicode cases | +| AC-10 | Documentation and support-matrix assertions bounded to Verified install/discovery | + +### Risks + +| Risk | Mitigation/test | Residual boundary | +| --- | --- | --- | +| Shared Skill metadata incompatibility | Research mechanisms first; run every affected consumer and validator gate for shared changes | Stop for maintainer direction if every mechanism is breaking | +| Wrong active DSH Web preset/config owner | Assert the selected preset's scoped `skill-filesystem` wins; document the active owner | Only standard/code/cordis are qualified | +| Same-name project-local Skill shadowing | Negative fixture verifies winning source/path/`resourceBase`, not name alone | DSH precedence is unchanged; the install is reported unverified | +| Stale absolute Better Harness root | Validate canonical Skill, two-parent root, CLI, and required root resources | Moving the root requires configuration update and re-verification | +| Cross-platform path behavior | Exercise Windows, macOS, Linux, spaces, Unicode, relative, and literal-`~` cases | Canonical documentation requires an absolute path and no symlink guarantee | +| DSH developer-preview contract churn | Pin release/SHA and relock owner files before implementation and release claims | New DSH versions require requalification | + +### Documentation claim boundary + +After all ACs pass, documentation may say: + +> DSH has Verified install/discovery for the qualified runtime/preset boundary. + +It must not say that DSH is fully supported, Public Quickstart-ready, or that +the complete `/better-harness` report workflow is supported. + +Specification review must trace Story #99 through AC-1..AC-10, native evidence +ownership, canonical trust, cross-host compatibility, negative tests, risks, +non-goals, and the maturity claim. Readiness requires zero BLOCKER and zero +MUST FIX findings. Any SHOULD FIX item must be explicit and non-blocking. diff --git a/package.json b/package.json index 5241dde3..d71852a1 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,8 @@ "preview": "node scripts/harness-analysis/canvas-preview-server.mjs", "preview:canvas": "node scripts/harness-analysis/canvas-preview-server.mjs", "test": "vitest run", - "test:ci": "vitest run --config vitest.ci.config.mjs" + "test:ci": "vitest run --config vitest.ci.config.mjs", + "test:dsh-native": "node scripts/dsh-skill-discovery/native-smoke.mjs" }, "packageManager": "npm@10.9.3", "publishConfig": { diff --git a/scripts/dsh-skill-discovery/index.mjs b/scripts/dsh-skill-discovery/index.mjs new file mode 100644 index 00000000..c84e97d3 --- /dev/null +++ b/scripts/dsh-skill-discovery/index.mjs @@ -0,0 +1,147 @@ +import { lstat } from "node:fs/promises"; +import path from "node:path"; + +export const name = "better-harness-explicit-only"; +export const inject = ["skills", "tools"]; + +export const DSH_NATIVE_VERSION = "0.1.1-rc.2"; +export const DSH_NATIVE_SOURCE_SHA = "b150a551b8d465e31e418e1b2eaf5e79bbb7d28e"; + +const SKILL_NAME = "better-harness"; +const SKILL_GESTURE = /(^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/g; +const REQUIRED_PATHS = [ + { key: "root", relative: ".", kind: "directory" }, + { key: "skillsRoot", relative: "skills", kind: "directory" }, + { key: "skillDirectory", relative: "skills/better-harness", kind: "directory" }, + { key: "skillFile", relative: "skills/better-harness/SKILL.md", kind: "file" }, + { key: "cli", relative: "scripts/better-harness.mjs", kind: "file" }, + { key: "references", relative: "references", kind: "directory" }, + { key: "models", relative: "models", kind: "directory" }, + { key: "templates", relative: "templates", kind: "directory" }, +]; + +export function resolveCanonicalPaths(betterHarnessRoot, { pathApi = path } = {}) { + if (typeof betterHarnessRoot !== "string" || betterHarnessRoot.length === 0) { + throw new TypeError("betterHarnessRoot must be a non-empty absolute path"); + } + if (betterHarnessRoot === "~" || betterHarnessRoot.startsWith("~/") || betterHarnessRoot.startsWith("~\\")) { + throw new Error("betterHarnessRoot must be absolute; DSH does not expand a literal tilde"); + } + if (!pathApi.isAbsolute(betterHarnessRoot)) { + throw new Error("betterHarnessRoot must be an absolute path"); + } + + const root = pathApi.resolve(betterHarnessRoot); + const skillsRoot = pathApi.join(root, "skills"); + const skillDirectory = pathApi.join(skillsRoot, SKILL_NAME); + return { + root, + skillsRoot, + skillDirectory, + skillFile: pathApi.join(skillDirectory, "SKILL.md"), + cli: pathApi.join(root, "scripts", "better-harness.mjs"), + resourceDirectories: ["references", "models", "templates"].map((entry) => pathApi.join(root, entry)), + references: pathApi.join(root, "references"), + models: pathApi.join(root, "models"), + templates: pathApi.join(root, "templates"), + }; +} + +export async function inspectLocalPath(target) { + try { + const info = await lstat(target); + return { + kind: info.isFile() ? "file" : info.isDirectory() ? "directory" : "other", + symbolicLink: info.isSymbolicLink(), + }; + } catch (error) { + if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return undefined; + throw error; + } +} + +export async function verifyCanonicalSkill({ + betterHarnessRoot, + skill, + inspectPath = inspectLocalPath, + pathApi = path, +}) { + const paths = resolveCanonicalPaths(betterHarnessRoot, { pathApi }); + const reasons = []; + + if (skill === undefined) { + return { verified: false, reasons: ["skill-not-discovered"], paths }; + } + if (skill.source !== "custom") reasons.push("winner-source-mismatch"); + if (skill.path !== paths.skillFile) reasons.push("winner-path-mismatch"); + if (skill.resourceBase?.kind !== "directory" || skill.resourceBase.path !== paths.skillDirectory) { + reasons.push("resource-base-mismatch"); + } + const discoveredRoot = skill.resourceBase?.kind === "directory" + ? pathApi.dirname(pathApi.dirname(skill.resourceBase.path)) + : undefined; + if (discoveredRoot !== paths.root) reasons.push("root-invariant-mismatch"); + if (skill.invocation?.userInvocable !== true) reasons.push("user-invocation-disabled"); + + for (const required of REQUIRED_PATHS) { + const target = paths[required.key]; + const info = await inspectPath(target); + if (info === undefined || info.kind !== required.kind) { + reasons.push(`required-resource-missing:${required.relative}`); + } else if (info.symbolicLink) { + reasons.push(`symbolic-link-not-supported:${required.relative}`); + } + } + + return { verified: reasons.length === 0, reasons, paths }; +} + +export function guardBetterHarnessModelInvocation(execution) { + if (execution?.name !== "skill") return undefined; + if (typeof execution.arguments !== "object" || execution.arguments === null) return undefined; + if (execution.arguments.name !== SKILL_NAME) return undefined; + return "Better Harness requires explicit /better-harness invocation by a user."; +} + +export function containsExplicitBetterHarnessGesture(messages) { + for (const message of messages ?? []) { + if (message?.source?.kind !== "user") continue; + for (const block of message.content ?? []) { + if (block?.type !== "text") continue; + for (const match of block.text.matchAll(SKILL_GESTURE)) { + if (match[2] === SKILL_NAME) return true; + } + } + } + return false; +} + +export function createPlugin(inspectPath = inspectLocalPath) { + return function register(ctx, config = {}) { + const paths = resolveCanonicalPaths(config.betterHarnessRoot); + ctx.tools.guard(guardBetterHarnessModelInvocation); + ctx.on("agent/pre-step", async ({ agent, messages, signal }, next) => { + if (!containsExplicitBetterHarnessGesture(messages)) return next(); + signal.throwIfAborted(); + const skill = await ctx.skills.get(SKILL_NAME, { + cwd: agent.session.header.cwd, + signal, + scope: agent, + }); + signal.throwIfAborted(); + const verification = await verifyCanonicalSkill({ + betterHarnessRoot: paths.root, + skill, + inspectPath, + }); + if (!verification.verified) { + throw new Error(`Better Harness DSH verification failed: ${verification.reasons.join(", ")}`); + } + return next(); + }); + }; +} + +export const apply = createPlugin(); + +export default { name, inject, apply }; diff --git a/scripts/dsh-skill-discovery/native-smoke.mjs b/scripts/dsh-skill-discovery/native-smoke.mjs new file mode 100644 index 00000000..f006c90c --- /dev/null +++ b/scripts/dsh-skill-discovery/native-smoke.mjs @@ -0,0 +1,282 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import policy, { + DSH_NATIVE_SOURCE_SHA, + DSH_NATIVE_VERSION, + verifyCanonicalSkill, +} from "./index.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPOSITORY_ROOT = path.resolve(HERE, "../.."); +const DSH_PACKAGES = [ + "@deepseek-ai/dsh-agent", + "@deepseek-ai/dsh-llm", + "@deepseek-ai/dsh-session", + "@deepseek-ai/dsh-skill", + "@deepseek-ai/dsh-skill-filesystem", + "@deepseek-ai/dsh-scope", + "@deepseek-ai/dsh-system-prompt", + "@deepseek-ai/dsh-tool-skill", + "@deepseek-ai/dsh-tools", +]; + +async function installNativeOwners(prefix) { + const specs = ["@deepseek-ai/cordis@4.0.1", ...DSH_PACKAGES.map((entry) => `${entry}@${DSH_NATIVE_VERSION}`)]; + const args = [ + "install", + "--prefix", prefix, + "--no-package-lock", + "--no-save", + "--ignore-scripts", + "--no-audit", + "--no-fund", + ...specs, + ]; + const npmCli = process.env.npm_execpath; + const command = npmCli ? process.execPath : process.platform === "win32" ? "npm.cmd" : "npm"; + const commandArgs = npmCli ? [npmCli, ...args] : args; + await new Promise((resolve, reject) => { + const child = spawn(command, commandArgs, { + stdio: "inherit", + shell: !npmCli && process.platform === "win32", + }); + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`native DSH owner install failed (${signal ?? code})`)); + }); + }); +} + +async function runSmoke(nodeModules, scratch) { + const load = async (packageName) => { + const packageRoot = path.join(nodeModules, ...packageName.split("/")); + const manifest = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8")); + if (packageName.startsWith("@deepseek-ai/dsh-")) { + assert.equal(manifest.version, DSH_NATIVE_VERSION, `${packageName} must remain pinned`); + } + return import(pathToFileURL(path.join(packageRoot, manifest.main ?? "lib/index.js"))); + }; + + const { Context } = await load("@deepseek-ai/cordis"); + const { default: SystemPrompt } = await load("@deepseek-ai/dsh-system-prompt"); + const { default: ToolRuntime } = await load("@deepseek-ai/dsh-tools"); + const { default: AgentRegistry, Inbox, agentEvents } = await load("@deepseek-ai/dsh-agent"); + const { default: SkillRegistry } = await load("@deepseek-ai/dsh-skill"); + const SkillFileSystem = await load("@deepseek-ai/dsh-skill-filesystem"); + const toolSkill = await load("@deepseek-ai/dsh-tool-skill"); + const { Session, SessionId } = await load("@deepseek-ai/dsh-session"); + const { createScope } = await load("@deepseek-ai/dsh-scope"); + const { CallId, createUserMessage } = await load("@deepseek-ai/dsh-llm"); + + let sequence = 0; + const agentFor = (cwd) => { + sequence += 1; + const id = SessionId(`better-harness-native-${sequence}`); + const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd }); + return { + ctx: new Context(), + id, + options: {}, + session, + inbox: new Inbox(session, { inserted() {}, discarded() {}, claimed() {} }), + status: "idle", + send() {}, + followup() {}, + steer() {}, + inject() {}, + cancel() {}, + runMaintenance: (task) => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + }; + }; + + const setup = async ({ betterHarnessRoot = REPOSITORY_ROOT, customSkillDirs, withPolicy = true } = {}) => { + const home = await mkdtemp(path.join(scratch, "home-")); + const ctx = new Context(); + await ctx.plugin(SystemPrompt); + await ctx.plugin(ToolRuntime); + await ctx.plugin(AgentRegistry); + await ctx.plugin(SkillRegistry); + await ctx.plugin(SkillFileSystem, { + dshHome: path.join(home, ".dsh"), + agentsHome: path.join(home, ".agents"), + customSkillDirs: customSkillDirs ?? [path.join(betterHarnessRoot, "skills")], + includeDefaultRoots: true, + watch: false, + }); + await ctx.plugin(toolSkill); + if (withPolicy) await ctx.plugin(policy, { betterHarnessRoot }); + return ctx; + }; + + const userMessage = (text) => createUserMessage({ + content: [{ type: "text", text }], + source: { kind: "user" }, + }); + const preStep = (ctx, agent, messages) => agentEvents(ctx, agent).waterfall( + "agent/pre-step", + { messages, turn: 1, step: 1, signal: new AbortController().signal }, + () => Promise.resolve({ kind: "enter", messages }), + ); + + const workspace = await mkdtemp(path.join(scratch, "workspace-")); + await mkdir(path.join(workspace, ".git")); + const ctx = await setup(); + const agent = agentFor(workspace); + const winner = await ctx.skills.get("better-harness", { cwd: workspace, scope: agent }); + const identity = await verifyCanonicalSkill({ betterHarnessRoot: REPOSITORY_ROOT, skill: winner }); + assert.equal(identity.verified, true, identity.reasons.join(", ")); + assert.equal(winner.source, "custom"); + assert.equal(winner.path, identity.paths.skillFile); + assert.deepEqual(winner.resourceBase, { kind: "directory", path: identity.paths.skillDirectory }); + + const explicit = await preStep(ctx, agent, [userMessage("/better-harness inspect this harness")]); + assert.equal(explicit.kind, "enter"); + const injection = explicit.messages.find((message) => message.source?.kind === "skill-invocation"); + assert.equal(injection?.source?.name, "better-harness"); + assert.match(injection.content[0].text, //); + assert.match(injection.content[0].text, /# Better Harness/); + + const catalogDecision = await preStep(ctx, agentFor(workspace), [userMessage("ordinary prompt")]); + assert.equal(catalogDecision.kind, "enter"); + const catalog = catalogDecision.messages.find((message) => message.source?.kind === "skill-catalog"); + assert.equal(catalog?.source?.entries?.some((entry) => entry.name === "better-harness"), true); + const modelResult = await ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId("better-harness-model-call"), + name: "skill", + arguments: { name: "better-harness" }, + agent, + }); + assert.equal(modelResult.isError, true); + assert.match(modelResult.content[0].text, /explicit \/better-harness/); + + const webHome = await mkdtemp(path.join(scratch, "web-home-")); + const webCtx = new Context(); + await webCtx.plugin(SystemPrompt); + await webCtx.plugin(ToolRuntime); + await webCtx.plugin(AgentRegistry); + await webCtx.plugin(SkillRegistry); + await webCtx.plugin(policy, { betterHarnessRoot: REPOSITORY_ROOT }); + const webAgent = agentFor(workspace); + assert.equal(await webCtx.skills.get("better-harness", { cwd: workspace, scope: webAgent }), undefined); + let webScope; + await webCtx.plugin(Object.assign((inner) => { + webScope = createScope(inner, webAgent); + }, { inject: ["tools"] })); + await webScope.ctx.plugin(SkillFileSystem, { + dshHome: path.join(webHome, ".dsh"), + agentsHome: path.join(webHome, ".agents"), + customSkillDirs: [path.join(REPOSITORY_ROOT, "skills")], + watch: false, + }); + await webScope.ctx.plugin(toolSkill); + const webWinner = await webCtx.skills.get("better-harness", { cwd: workspace, scope: webAgent }); + assert.equal(webWinner?.source, "custom"); + const webExplicit = await preStep(webCtx, webAgent, [userMessage("/better-harness")]); + assert.equal(webExplicit.kind, "enter"); + assert.equal(webExplicit.messages.some((message) => message.source?.kind === "skill-invocation"), true); + + const shadowWorkspace = await mkdtemp(path.join(scratch, "shadow-")); + const shadowDirectory = path.join(shadowWorkspace, ".dsh", "skills", "better-harness"); + await mkdir(path.join(shadowWorkspace, ".git")); + await mkdir(shadowDirectory, { recursive: true }); + await writeFile(path.join(shadowDirectory, "SKILL.md"), [ + "---", + "name: better-harness", + "description: Project shadow", + "---", + "", + "Shadow body.", + ].join("\n")); + const shadowAgent = agentFor(shadowWorkspace); + const shadow = await ctx.skills.get("better-harness", { cwd: shadowWorkspace, scope: shadowAgent }); + assert.equal(shadow.source, "project-dsh"); + await assert.rejects( + () => preStep(ctx, shadowAgent, [userMessage("/better-harness")]), + /winner-source-mismatch.*winner-path-mismatch.*resource-base-mismatch/, + ); + + const malformedWorkspace = await mkdtemp(path.join(scratch, "malformed-")); + const malformedDirectory = path.join(malformedWorkspace, ".dsh", "skills", "better-harness"); + await mkdir(path.join(malformedWorkspace, ".git")); + await mkdir(malformedDirectory, { recursive: true }); + await writeFile(path.join(malformedDirectory, "SKILL.md"), "---\nname: better-harness\ndescription: [unterminated\n---\n"); + const malformedAgent = agentFor(malformedWorkspace); + const malformedWinner = await ctx.skills.get("better-harness", { cwd: malformedWorkspace, scope: malformedAgent }); + assert.equal(malformedWinner.source, "custom"); + assert.equal((await verifyCanonicalSkill({ betterHarnessRoot: REPOSITORY_ROOT, skill: malformedWinner })).verified, true); + + const standalone = await mkdtemp(path.join(scratch, "standalone-")); + const standaloneSkill = path.join(standalone, "skills", "better-harness"); + await mkdir(standaloneSkill, { recursive: true }); + await writeFile(path.join(standaloneSkill, "SKILL.md"), await readFile(identity.paths.skillFile, "utf8")); + const standaloneCtx = await setup({ betterHarnessRoot: standalone, withPolicy: false }); + const standaloneAgent = agentFor(workspace); + const standaloneWinner = await standaloneCtx.skills.get("better-harness", { cwd: workspace, scope: standaloneAgent }); + const standaloneIdentity = await verifyCanonicalSkill({ betterHarnessRoot: standalone, skill: standaloneWinner }); + assert.equal(standaloneIdentity.verified, false); + assert.equal(standaloneIdentity.reasons.includes("required-resource-missing:scripts/better-harness.mjs"), true); + + const previousCwd = process.cwd(); + process.chdir(REPOSITORY_ROOT); + try { + const relativeCtx = await setup({ customSkillDirs: ["skills"], withPolicy: false }); + assert.equal((await relativeCtx.skills.get("better-harness", { cwd: workspace }))?.source, "custom"); + const tildeCtx = await setup({ customSkillDirs: ["~/skills"], withPolicy: false }); + assert.equal(await tildeCtx.skills.get("better-harness", { cwd: workspace }), undefined); + } finally { + process.chdir(previousCwd); + } + + return { + dshVersion: DSH_NATIVE_VERSION, + sourceSha: DSH_NATIVE_SOURCE_SHA, + credentialRequired: false, + owners: ["SkillRegistry", "FileSystemSkillProvider", "tool-skill agent/pre-step", "ToolRuntime.guard"], + discovery: "verified", + explicitInvocation: "injected before model request derivation", + modelInvocation: "rejected", + headlessBase: "verified global skill-filesystem owner", + webSelectedPreset: "verified scoped skill-filesystem owner", + webMinimal: "unsupported without scoped Skill loader", + shadow: "rejected", + malformedShadow: "canonical fallback verified", + standaloneCopy: "unverified", + relativePath: "resolved from process cwd", + literalTilde: "not expanded", + }; +} + +let installation; +let cleanup = false; +try { + const provided = process.env.DSH_NATIVE_NODE_MODULES; + if (provided) { + installation = path.resolve(provided); + } else { + const prefix = await mkdtemp(path.join(os.tmpdir(), "better-harness-dsh-native-")); + cleanup = true; + await installNativeOwners(prefix); + installation = path.join(prefix, "node_modules"); + } + const scratch = await mkdtemp(path.join(os.tmpdir(), "better-harness-dsh-smoke-")); + try { + const result = await runSmoke(installation, scratch); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } finally { + await rm(scratch, { recursive: true, force: true }); + } +} finally { + if (cleanup && installation) { + await rm(path.dirname(installation), { recursive: true, force: true }); + } +} diff --git a/test/skills-docs/dsh-skill-discovery.test.mjs b/test/skills-docs/dsh-skill-discovery.test.mjs new file mode 100644 index 00000000..281b351f --- /dev/null +++ b/test/skills-docs/dsh-skill-discovery.test.mjs @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { test } from "vitest"; + +let dsh; +try { + dsh = await import("../../scripts/dsh-skill-discovery/index.mjs"); +} catch { + dsh = undefined; +} + +function requireSubject() { + assert.ok(dsh, "the DSH verified-discovery owner must exist"); + return dsh; +} + +function canonicalSkill(paths, overrides = {}) { + return { + name: "better-harness", + source: "custom", + provider: "skill-filesystem", + path: paths.skillFile, + resourceBase: { kind: "directory", path: paths.skillDirectory }, + invocation: { modelInvocable: true, userInvocable: true }, + content: "Canonical Better Harness body.", + ...overrides, + }; +} + +function completeInspector(paths, overrides = new Map()) { + const expected = new Map([ + [paths.root, { kind: "directory", symbolicLink: false }], + [paths.skillsRoot, { kind: "directory", symbolicLink: false }], + [paths.skillDirectory, { kind: "directory", symbolicLink: false }], + [paths.skillFile, { kind: "file", symbolicLink: false }], + [paths.cli, { kind: "file", symbolicLink: false }], + ...paths.resourceDirectories.map((entry) => [entry, { kind: "directory", symbolicLink: false }]), + ]); + return async (target) => overrides.has(target) ? overrides.get(target) : expected.get(target); +} + +test("DSH verification binds the winning native definition to the complete canonical root", async () => { + const subject = requireSubject(); + const root = path.resolve("/tmp/Better Harness 演示"); + const paths = subject.resolveCanonicalPaths(root); + const result = await subject.verifyCanonicalSkill({ + betterHarnessRoot: root, + skill: canonicalSkill(paths), + inspectPath: completeInspector(paths), + }); + + assert.equal(result.verified, true); + assert.deepEqual(result.reasons, []); + assert.equal(path.dirname(path.dirname(result.paths.skillDirectory)), result.paths.root); +}); + +test("DSH verification fails closed for shadows, wrong roots, missing resources, and links", async () => { + const subject = requireSubject(); + const root = path.resolve("/tmp/better-harness-root"); + const paths = subject.resolveCanonicalPaths(root); + const shadow = canonicalSkill(paths, { + source: "project-dsh", + path: path.join(root, "workspace/.dsh/skills/better-harness/SKILL.md"), + resourceBase: { kind: "directory", path: path.join(root, "workspace/.dsh/skills/better-harness") }, + }); + const shadowResult = await subject.verifyCanonicalSkill({ + betterHarnessRoot: root, + skill: shadow, + inspectPath: completeInspector(paths), + }); + assert.equal(shadowResult.verified, false); + assert.deepEqual(shadowResult.reasons, [ + "winner-source-mismatch", + "winner-path-mismatch", + "resource-base-mismatch", + "root-invariant-mismatch", + ]); + + const missing = new Map([[paths.cli, undefined]]); + const missingResult = await subject.verifyCanonicalSkill({ + betterHarnessRoot: root, + skill: canonicalSkill(paths), + inspectPath: completeInspector(paths, missing), + }); + assert.equal(missingResult.verified, false); + assert.deepEqual(missingResult.reasons, ["required-resource-missing:scripts/better-harness.mjs"]); + + const linked = new Map([[paths.skillDirectory, { kind: "directory", symbolicLink: true }]]); + const linkedResult = await subject.verifyCanonicalSkill({ + betterHarnessRoot: root, + skill: canonicalSkill(paths), + inspectPath: completeInspector(paths, linked), + }); + assert.equal(linkedResult.verified, false); + assert.deepEqual(linkedResult.reasons, ["symbolic-link-not-supported:skills/better-harness"]); +}); + +test("DSH verification accepts a skipped malformed shadow only when native DSH returns the canonical winner", async () => { + const subject = requireSubject(); + const root = path.resolve("/tmp/better-harness-root"); + const paths = subject.resolveCanonicalPaths(root); + const canonical = await subject.verifyCanonicalSkill({ + betterHarnessRoot: root, + skill: canonicalSkill(paths), + inspectPath: completeInspector(paths), + }); + const absent = await subject.verifyCanonicalSkill({ + betterHarnessRoot: root, + skill: undefined, + inspectPath: completeInspector(paths), + }); + + assert.equal(canonical.verified, true); + assert.deepEqual(absent.reasons, ["skill-not-discovered"]); +}); + +test("DSH path contract covers POSIX and Windows absolute roots with spaces and Unicode", () => { + const subject = requireSubject(); + const posix = subject.resolveCanonicalPaths("/opt/Better Harness/工程", { pathApi: path.posix }); + assert.equal(posix.skillFile, "/opt/Better Harness/工程/skills/better-harness/SKILL.md"); + + const windows = subject.resolveCanonicalPaths("C:\\Tools\\Better Harness\\工程", { pathApi: path.win32 }); + assert.equal(windows.skillFile, "C:\\Tools\\Better Harness\\工程\\skills\\better-harness\\SKILL.md"); + + assert.throws(() => subject.resolveCanonicalPaths("relative/better-harness"), /absolute/); + assert.throws(() => subject.resolveCanonicalPaths("~/better-harness"), /absolute|tilde/); +}); + +test("DSH policy rejects only model-facing Better Harness Skill calls", () => { + const subject = requireSubject(); + assert.match( + subject.guardBetterHarnessModelInvocation({ name: "skill", arguments: { name: "better-harness" } }), + /explicit \/better-harness/, + ); + assert.equal(subject.guardBetterHarnessModelInvocation({ name: "skill", arguments: { name: "another-skill" } }), undefined); + assert.equal(subject.guardBetterHarnessModelInvocation({ name: "bash", arguments: { name: "better-harness" } }), undefined); +}); + +test("DSH plugin verifies direct-user gestures and leaves native slash injection to tool-skill", async () => { + const subject = requireSubject(); + const root = path.resolve("/tmp/better-harness-root"); + const paths = subject.resolveCanonicalPaths(root); + const guards = []; + const listeners = []; + const ctx = { + tools: { guard: (guard) => guards.push(guard) }, + skills: { get: async () => canonicalSkill(paths) }, + on: (name, listener) => listeners.push({ name, listener }), + }; + subject.createPlugin(completeInspector(paths))(ctx, { betterHarnessRoot: root }); + + assert.equal(guards.length, 1); + const preStep = listeners.find((entry) => entry.name === "agent/pre-step")?.listener; + assert.equal(typeof preStep, "function"); + const messages = [{ source: { kind: "user" }, content: [{ type: "text", text: "please run /better-harness now" }] }]; + const downstream = { kind: "enter", messages: [{ source: { kind: "plugin" }, content: [] }] }; + assert.equal(await preStep({ + agent: { session: { header: { cwd: "/workspace" } } }, + messages, + signal: new AbortController().signal, + }, async () => downstream), downstream); + + ctx.skills.get = async () => canonicalSkill(paths, { source: "project-agents" }); + await assert.rejects(() => preStep({ + agent: { session: { header: { cwd: "/workspace" } } }, + messages, + signal: new AbortController().signal, + }, async () => downstream), /winner-source-mismatch/); +});