diff --git a/docs/adapters/README.md b/docs/adapters/README.md index d0204aec..df0e3153 100644 --- a/docs/adapters/README.md +++ b/docs/adapters/README.md @@ -3,7 +3,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) verified install/discovery, developer-preview -configured-assets, and developer-preview session slices. Do not +configured-assets, session, Asset Practices, neutral Harness analysis, and +Evidence Bundle 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`, @@ -45,7 +46,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) | Verified install/discovery for headless/base and Web `standard`/`code`/`cordis`; partial configured assets and session evidence (developer preview) | local DSH Cordis policy at `scripts/dsh-skill-discovery/index.mjs`; no lifecycle shell | `scripts/agent-customize/providers/dsh.mjs`; filesystem Skills and cwd-sensitive Instructions, configured-not-observed | `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`; `npm run test:dsh-configured-assets-native`; read-only session `sources`/`facts` commands remain separate | +| DeepSeek Harness (DSH) | Verified install/discovery for headless/base and Web `standard`/`code`/`cordis`; shared read-only analysis over developer-preview configured and Session evidence | local DSH Cordis policy at `scripts/dsh-skill-discovery/index.mjs`; no lifecycle shell | `scripts/agent-customize/providers/dsh.mjs`; filesystem Skills and cwd-sensitive Instructions, configured-not-observed | `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; neutral `harness analyze` and Evidence Bundle only | canonical Skill from the complete root; model Skill calls rejected | `npm run test:dsh-native`; `npm run test:dsh-configured-assets-native`; shared analysis remains inline/no-files | ## Read-only Plugin Lifecycle @@ -220,8 +221,9 @@ edit host settings, or register an `apply` path. `--include-user-home` is supplied. The evidence is `configured-not-observed`: runtime/in-process Skills and active Cordis, Profile, and Preset composition remain unresolved. DSH advertises exactly - `sessionAnalysis` and `agentCustomize`; it does not gain asset-practices, - checkup, evidence-bundle, report, rendering, or output support. See + `sessionAnalysis`, `agentCustomize`, `assetPractices`, `harnessReport`, and + `evidenceBundle`. Shared analysis accepts a canonical contained `--cwd` and + remains inline/no-files because checkup, rendering, and output are unsupported. See [DeepSeek Harness Configured Assets](../../references/agent-customize/platforms/dsh.md) and run the credential-free owner smoke with `npm run test:dsh-configured-assets-native`. @@ -250,7 +252,7 @@ edit host settings, or register an `apply` path. remains readable. There is no fallback dependency or shell. The combined DSH boundary does not provide live PTY or process state, complete runtime configured-asset resolution, plugin lifecycle, a managed - shell, manifest or package integration, report/output routing, README + shell, manifest or package integration, rendered report/output routing, README Quickstart, SQLite or custom persistence, automatic optimization, plugin fault or causality attribution, or artifact repair or writes. See @@ -269,9 +271,9 @@ Canonical templates live under `templates/reporting/`. `findings.json`, `report.md`, and `report.html`. - Markdown-only output has no visual companion. -DSH is intentionally absent from these output-mode host lists. Its -`sessionAnalysis` capability does not grant report routing, HTML, Canvas, or -Markdown output support. +DSH is intentionally absent from these output-mode host lists. Its neutral +`harnessReport` capability does not grant rendering, HTML, Canvas, or Markdown +output support. ## Split Triggers diff --git a/docs/docs/hosts/adapter-matrix.md b/docs/docs/hosts/adapter-matrix.md index f8529545..5778fda6 100644 --- a/docs/docs/hosts/adapter-matrix.md +++ b/docs/docs/hosts/adapter-matrix.md @@ -20,7 +20,8 @@ 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 has Verified install/discovery for a qualified runtime/preset boundary plus developer-preview configured-assets and -session-evidence contracts; it is not a runnable report adapter. The +session-evidence contracts. It supports shared read-only Asset Practices, +neutral Harness analysis, and Evidence Bundles, but is not an output 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. @@ -38,7 +39,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) | Verified install/discovery | Qualified headless/base and Web `standard`/`code`/`cordis`; partial configured-assets and session evidence | Local DSH Cordis policy; no lifecycle shell | 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`; shared read-only analysis over partial configured-assets and session evidence | Local DSH Cordis policy; no lifecycle shell | DSH JSONL backend session format `0`: raw `.jsonl` and feature-detected `.jsonl.zstd` | Unavailable; inline/no-files analysis only | 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 @@ -82,8 +83,8 @@ lifecycle target. (see the [sample report](pathname:///demo/better-harness-report/)). - **Markdown-only** — no visual companion. -DSH is not an output-mode host. Session-analysis evidence does not grant HTML, -Canvas, Markdown, or any report route. +DSH is not an output-mode host. Neutral Harness analysis and Evidence Bundle +support do not grant HTML, Canvas, Markdown, or durable report routing. ## Adapter support boundaries @@ -157,8 +158,11 @@ better-harness agent-customize inventory --provider dsh --workspace [--cw User-home Skills and Instructions are not read by default. Runtime/in-process Skills and active Cordis, Profile, and Preset composition remain unresolved. -The host advertises exactly `sessionAnalysis` and `agentCustomize`; this does -not add asset-practices, evidence-bundle, report, rendering, or output support. +The host advertises exactly `sessionAnalysis`, `agentCustomize`, +`assetPractices`, `harnessReport`, and `evidenceBundle`. Shared analysis freezes +canonical `--cwd` for current configured practice while leaving historical +Session scope unchanged. Rendering, output routing, Checkup, and Quickstart +remain unsupported. Repository contributors can run the pinned credential-free comparison with `npm run test:dsh-configured-assets-native`. See [DeepSeek Harness Configured Assets](https://github.com/QoderAI/better-harness/blob/main/references/agent-customize/platforms/dsh.md). @@ -192,9 +196,9 @@ The implemented source-checkout smoke boundary is read-only: node scripts/session-analysis.mjs sources --platform dsh --workspace [--dsh-home ] ``` -Verified discovery does not imply a complete report loop. DSH has no live +Shared analysis does not imply a complete report loop. DSH has no live PTY/process integration, complete runtime configured-asset resolution, plugin -lifecycle, managed shell, manifest, package integration, report/output route, +lifecycle, managed shell, manifest, package integration, rendered 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) diff --git a/docs/docs/installation.mdx b/docs/docs/installation.mdx index ef4ead88..c9c53537 100644 --- a/docs/docs/installation.mdx +++ b/docs/docs/installation.mdx @@ -153,10 +153,12 @@ comparison with `npm run test:dsh-configured-assets-native`. The complete configured-assets boundary is documented in the [adapter matrix](./hosts/adapter-matrix#deepseek-harness-dsh). -This maturity level does not provide complete runtime configured-asset -resolution, evidence-bundle or report registration, output routing, rendering, -lifecycle management, MCP/profile product support, Web `minimal`, or a full -report workflow. The +Shared read-only analysis is available through `harness evidence-bundle` and +`harness analyze`; pass `--workspace --cwd --platform dsh`. +The configured cwd must resolve inside the workspace. DSH still does not provide +complete runtime configured-asset resolution, output routing, rendering, +Checkup, lifecycle management, MCP/profile product support, Web `minimal`, a +public Quickstart, or a full durable report workflow. The [adapter matrix](./hosts/adapter-matrix#deepseek-harness-dsh) tracks the exact boundary. diff --git a/docs/specs/2026-08-24-104-deepseek-harness-shared-analysis-evidence-bundle.md b/docs/specs/2026-08-24-104-deepseek-harness-shared-analysis-evidence-bundle.md new file mode 100644 index 00000000..65e8df38 --- /dev/null +++ b/docs/specs/2026-08-24-104-deepseek-harness-shared-analysis-evidence-bundle.md @@ -0,0 +1,384 @@ +# DeepSeek Harness Shared Analysis and Frozen-Cwd Evidence Bundle + +## Traceability + +- Spec ID: deepseek-harness-shared-analysis-evidence-bundle +- Story: #104 +- Status: Implemented +- Approved scope: [Issue #104](https://github.com/QoderAI/better-harness/issues/104) + +## Intent + +DeepSeek Harness (DSH) already exposes independent persisted Session evidence +and configured filesystem Skills plus cwd-sensitive Instructions. The canonical +`/better-harness` workflow cannot combine those sources because DSH is rejected +at the shared Asset Practices, Harness Report, and Evidence Bundle gates. + +This Story qualifies those three shared capabilities together. Evidence Bundle +v3 freezes canonical cwd as part of evidence identity; Asset Baseline v2 retains +the minimum current configured-snapshot provenance needed to keep configuration +separate from historical Session observation. Existing generic inventory, +lint, integrity, Project Harness, task-loop, and neutral Harness analysis owners +remain canonical. + +The implementation is one Story and one PR. Report rendering and Checkup remain +unsupported. + +## Privacy blocker resolution + +The privacy blocker is resolved for the scope of #104 by inheriting the current +shared cross-host Session Evidence behavior: + +- bounded sanitized ordinary user-intent prose may remain in + `session-core-facts.candidates[].request.summary`; +- existing credential, recognized-secret, identifier, injected-context, + transcript-tail, and selected private-path sanitization remains in force; +- the independent lead may retain its existing bounded request sample only + where the shared lead contract already permits it; +- #104 adds no DSH-specific Session privacy projector and no new lead text path. + +This inherited boundary is not claimed to be privacy-complete. The earlier DSH +Session Evidence specification documented exposure of this same shared field as +a pre-existing unresolved privacy concern. That concern remains unresolved and +outside #104. Any future tightening belongs in shared Session and lead privacy +owners for every host, not in a DSH-only Bundle rule. + +## Story boundary + +Before #104, DSH advertises: + +```text +SESSION_ANALYSIS +AGENT_CUSTOMIZE +``` + +After #104, DSH advertises exactly: + +```text +SESSION_ANALYSIS +AGENT_CUSTOMIZE +ASSET_PRACTICES +HARNESS_REPORT +EVIDENCE_BUNDLE +``` + +It remains absent from: + +```text +REPORT_RENDERING +CHECKUP +``` + +The three new gates land together only after their executable routes and tests +are complete. `HARNESS_REPORT` means neutral shared Harness evidence, not HTML, +Markdown, Canvas, Studio, or another durable output. + +## Canonical pipeline + +```text +/better-harness +-> harness evidence-bundle +-> Evidence Bundle v3 frozen context + |- Session population -> DSH facts -> sessionEvidence + |- existing Project Harness -> projectHarness + |- DSH current configured inventory at frozen cwd + | -> shared lint + public inventory + integrity + | -> Asset Baseline v2 -> agentCustomize + `- same Session population -> generic task-loop source + -> independent current practice recollection at frozen cwd + -> neutral evidence + summaryFacts -> lead +-> complete / partial / failed Bundle +-> specialists and lead reconciliation +``` + +Session and Project collection consume workspace, topology, analysis scope, and +the frozen window. They do not consume configured cwd. Only current configured +and practice collection consumes cwd. + +## Acceptance scenarios + +### AC-1: Exact capability promotion + +DSH gains exactly `ASSET_PRACTICES`, `HARNESS_REPORT`, and `EVIDENCE_BUNDLE` in +addition to its existing two capabilities. `REPORT_RENDERING` and `CHECKUP` +remain absent. + +### AC-2: Evidence Bundle v3 and frozen cwd + +`EVIDENCE_BUNDLE_SCHEMA_VERSION` becomes `3`. Every emitted v3 context contains: + +```js +{ + workspace: "", + cwd: "", + provider: "dsh", + language: "en" | "zh-CN", + depth: "quick" | "normal", + window: { since: "", until: "" }, + evidenceLimit: 1 | 2 | 3 | 4 | 5, + authority: { + includeUserHome: boolean, + includeMemories: boolean + }, + topology: "", + analysisScope: "" +} +``` + +The complete frozen context, including cwd, identifies the evidence collection. +No new identity hash, v2 migration reader, or unrelated schema field is added. + +### AC-3: Canonical cwd contract + +```text +effectiveCwd = explicit cwd ?? workspace +``` + +Workspace and cwd must exist as directories and are canonicalized through the +real filesystem path. Canonical cwd must equal or be contained by canonical +workspace. A lexical in-workspace symlink resolving outside workspace is +rejected. The stable failures are `INVALID_CONFIGURED_CWD` and +`CONFIGURED_CWD_OUTSIDE_WORKSPACE`. + +The implementation uses native `node:path` semantics and supports Windows, +macOS, Linux, spaces, Unicode, drives, and UNC paths. Cwd is never derived from +Session evidence. + +### AC-4: Cwd propagation + +One canonical frozen cwd reaches Evidence Bundle context, Agent Customize, +Asset Baseline, public configured inventory, Agent Lint, Harness report-run, and +task-loop configured-practice collection. It does not become a Session analyzer +or Session selection option and does not replace Project Harness's existing +Git-root/workspace cwd. + +### AC-5: Nested DSH Instruction selection + +For workspace `repo/packages/api` and cwd `repo/packages/api/src`, shared +configured/practice collection includes an applicable +`src/AGENTS.local.md`. With cwd equal to `repo/packages/api`, that nested local +Instruction is not applicable. Existing #101 DSH native selection semantics are +reused unchanged. + +### AC-6: Asset Baseline v2 + +`ASSET_BASELINE_SCHEMA_VERSION` becomes `2`. A successful DSH baseline contains: + +```js +{ + kind: "agent-asset-baseline", + schemaVersion: 2, + status: "complete" | "partial" | "failed", + scope: { + provider: "dsh", + workspace: "", + cwd: "", + includeUserHome: boolean, + includeMemories: false + }, + configuredSnapshot, + envelopes: { lint, inventory, integrity }, + diagnostics +} +``` + +Lint, inventory, and integrity consume one raw configured inventory snapshot +inside the Baseline owner. Empty unsupported DSH collections are valid observed +emptiness, not failures. + +### AC-7: Compact configuredSnapshot + +A successful DSH baseline projects exactly: + +```js +{ + collectedAt: "", + evidenceKind: "configured-not-observed", + configurationSource: "qualified-defaults" | "caller-overrides", + userHomeCollection: "included" | "not-authorized", + instructionCollection: "enabled" | "disabled-by-byte-limit", + qualification: { + provider: "dsh", + version: "0.1.1-rc.2", + sourceSha: "b150a551b8d465e31e418e1b2eaf5e79bbb7d28e" + }, + runtimeResolution: { + cordis: false, + profile: false, + preset: false, + runtimeSkills: false + } +} +``` + +It excludes diagnostics arrays, configured paths, bodies, digests, symlink +targets, and duplicate cwd. Failed raw inventory does not fabricate the field. + +### AC-8: Current configuration is not historical observation + +The analysis may state that an asset is currently configured, when it was +collected, or that current configuration was not observed in bounded Session +evidence. It must not infer that a current Skill or Instruction existed during, +was used by, or influenced a historical Session; that a same-name current asset +is the historical asset; or that current absence proves historical absence. + +### AC-9: Generic Skills and Rules practice coverage + +`collectProviderInventory()` derives `summary.practiceCoverageRows` from shared +configured surfaces. Non-empty DSH Skills and Rules produce their existing +generic rows with identity-deduplicated counts and bounded locators. Empty +collections produce no phantom row. Representative existing generic hosts and +Qoder Memory behavior remain compatible. + +### AC-10: Asset Practices qualification + +DSH uses the existing public inventory, `agent-assets-review` lint, integrity, +and Asset Baseline owners. No DSH-specific lint or integrity engine is added. +User-home collection remains default-closed. + +### AC-11: Bundle-facing path privacy + +Direct DSH Agent Customize retains its qualified lexical evidence. Bundle-facing +Baseline findings use only: + +| Source | Locator | +| --- | --- | +| workspace itself | `` | +| inside workspace | `/` | +| inside Git root above workspace | `/` | +| authorized home | `~/` | +| unsafe off-tree | omitted or existing `` redaction | + +The rule applies to lint/integrity finding paths, owner routes, coverage paths, +and bounded free text. Symlink target realpaths never enter output. + +### AC-12: Configured content privacy + +Raw Skill bodies, Instruction prose, configured digests, symlink target +realpaths, provider diagnostic content, raw credentials, and raw recognized +secrets remain absent from Asset Baseline, the configured Bundle lane, and lead +configured evidence. + +### AC-13: Inherited Session request-summary boundary + +`PRIVATE_ORDINARY_PROSE_X` may survive in the existing bounded +`request.summary`. `sk-test-secret-credential` and a recognized private home path +must not survive raw. A bounded ordinary request sample may survive only in the +existing eligible active-long lead projection. The old DSH request-summary +privacy concern remains unresolved and outside #104. + +### AC-14: Generic Harness Report + +DSH is admitted to `harness analyze` and reuses the existing task-loop source, +report-source validation, neutral evidence formatter, and `summaryFacts` +projection. Configured-practice collection receives frozen cwd; Session and +repository scopes remain unchanged. `--canvas-out` remains Qoder/Cursor-only. + +### AC-15: Multi-owner current snapshots + +P0 retains `collectionMode: "frozen-context-multi-owner"`. The Baseline shares +one raw snapshot among its stages; the lead independently recollects current +practice evidence using the same frozen parameters. Object identity, timestamp +equality, and atomic filesystem snapshotting are not promised. + +### AC-16: Project Harness remains generic + +DSH Bundle collection invokes the existing Project Harness owner with topology, +analysis scope, and Git-root/workspace scope. Configured cwd is not forwarded to +Project Harness. No DSH-specific Project Harness module is added. + +### AC-17: Bundle completeness + +For normal depth, complete topology, all three specialist lanes available, and +an available lead produce `complete`; a partial/unavailable specialist, +incomplete topology, or unavailable lead produces `failed`. For quick depth, +an incomplete specialist/topology with an available lead produces `partial`; +lead or population/binding failure produces `failed`. Valid observed emptiness +may remain available. + +### AC-18: `/better-harness` flow and isolation + +The Skill's single Step 1 command passes `--platform dsh`, `--workspace`, and +`--cwd`. Explicit inline/no-files DSH analysis may proceed through specialists +and reconciliation. Durable HTML, Markdown, Canvas, Studio, and report-file +output are not fabricated. Checkup remains unsupported. + +## Public CLI + +The only new general option is `--cwd `. It is accepted by Evidence +Bundle and the direct configured/practice and Harness analysis surfaces that +must reproduce the same DSH Instruction selection. Existing `--dsh-home` and +authority flags retain their current meaning. No byte-budget, candidate, +snapshot, path-mode, or atomic-snapshot control becomes public. + +## Evidence Bundle completeness + +| Component | Available/complete | Partial | Failure/rejection | +| --- | --- | --- | --- | +| Session | Valid facts and compatible binding | `unobserved`/`partial` coverage | Invalid discovery/facts/binding | +| Project Harness | Evidence pack `status: ok` | Valid non-`ok` pack | Invalid envelope/collector error | +| Agent Customize | Baseline v2 complete | Baseline v2 partial | Failed/invalid Baseline | +| Lead | Valid evidence, summaryFacts, binding | No partial lead state | Invalid/unavailable/conflicting lead | +| Normal Bundle | All available and topology complete | Not emitted | Any incomplete required input | +| Quick Bundle | All complete | Incomplete specialist/topology with lead available | Lead/population/binding failure | + +## Failure boundaries + +- Invalid cwd fails before configured asset reads. +- Session discovery failure makes Session and lead unavailable. +- Missing configured assets may be valid observed emptiness. +- User-home disabled means not authorized, not globally absent. +- Unresolved DSH runtime composition remains explicit provenance, not failure. +- Rendering and Checkup requests continue through their existing unsupported + capability contracts. + +## Non-goals + +- report rendering, HTML, Markdown, Canvas, Studio, or durable output routing +- Checkup, lifecycle, Quickstart, or the complete default durable workflow +- complete Cordis, Profile, Preset, MCP, Plugin, Hook, or Custom Agent support +- runtime/in-process/scoped Skill enumeration +- atomic configured-filesystem snapshots +- causal configured-to-Session correlation +- DSH Session Analysis or configured-assets native-semantic redesign +- DSH-specific lint, integrity, report, Project Harness, or privacy owners +- shared Session privacy tightening or arbitrary-prose removal +- configured asset body/content collection +- schema migration infrastructure +- upstream DSH changes +- package/release/version/changelog/roadmap work + +## Plan and tasks + +1. Add RED tests for the exact capability set, Bundle v3, Baseline v2, and cwd. +2. Add RED tests for DSH Asset Practices, shared coverage, provenance, paths, + configured content, and temporal separation. +3. Add RED tests for generic Harness analysis, Bundle composition/status, + Project Harness isolation, Skill flow, rendering, and Checkup isolation. +4. Implement one shared canonical cwd resolver and propagate it only through + current configured/practice paths. +5. Implement generic inventory/Baseline qualifications and path compaction. +6. Implement generic report and Bundle admission, then promote capabilities. +7. Update only the canonical DSH capability/reference documentation owners. +8. Run focused tests, full tests, Node 22, cross-platform CI, doc-link checks, + package verification, and change-traceability review. + +## RED test plan and review evidence + +| Acceptance | RED proof | +| --- | --- | +| AC-1 | Exact host capability projection and negative gates | +| AC-2–AC-4 | Bundle schema/context, cwd validation, identity, and propagation spies | +| AC-5–AC-7 | Nested DSH fixture, Baseline v2, and configuredSnapshot assertions | +| AC-8 | Historical Session/current configuration negative claims | +| AC-9–AC-10 | Generic inventory, lint, integrity, and compatibility tests | +| AC-11–AC-12 | Synthetic path and configured-content canaries | +| AC-13 | Existing shared summary sanitizer plus DSH Bundle admission canary | +| AC-14–AC-16 | Report-run, task-loop, multi-owner, and Project Harness tests | +| AC-17 | Normal/quick complete, partial, and failed matrix | +| AC-18 | Better Harness Skill and capability-isolation tests | + +RED is valid only when the pre-change relevant suite is green and every new +failure reaches missing #104 production behavior rather than a syntax, fixture, +mock, import, platform, or Node-version error. The implementation begins only +after the RED contract is independently reviewable. diff --git a/references/agent-customize/platforms/dsh.md b/references/agent-customize/platforms/dsh.md index ad68baa1..b51a7b4f 100644 --- a/references/agent-customize/platforms/dsh.md +++ b/references/agent-customize/platforms/dsh.md @@ -57,9 +57,12 @@ digests, rendered framing, or symlink targets. Every result is `configured-not-observed`. Filesystem configuration and applicability do not prove runtime use. Runtime/in-process or scoped Skill providers and active Cordis, Profile, and Preset composition remain unresolved. -DSH advertises only `sessionAnalysis` and `agentCustomize`; this provider does -not add asset-practices, checkup, evidence-bundle, report, rendering, output, -lifecycle, managed-shell, or public Quickstart support. +DSH advertises `sessionAnalysis`, `agentCustomize`, `assetPractices`, +`harnessReport`, and `evidenceBundle`. The shared Asset Baseline and Evidence +Bundle freeze a canonical cwd, retain compact configured provenance, and keep +current configuration distinct from historical Session observation. This does +not add checkup, rendering, output, lifecycle, managed-shell, or public +Quickstart support; `/better-harness` is inline/no-files only for DSH. Repository contributors can compare the Better Harness collector with pinned native DSH behavior without credentials or a model request: @@ -68,5 +71,8 @@ native DSH behavior without credentials or a model request: npm run test:dsh-configured-assets-native ``` -The canonical acceptance contract is the -[dated configured-assets specification](../../../docs/specs/2026-08-23-101-deepseek-harness-configured-assets.md). +The [#101 configured-assets specification](../../../docs/specs/2026-08-23-101-deepseek-harness-configured-assets.md) +remains canonical for native DSH configured-assets semantics and Skills / Instructions collection qualification. +The [#104 shared-analysis specification](../../../docs/specs/2026-08-24-104-deepseek-harness-shared-analysis-evidence-bundle.md) +is canonical for shared Harness analysis admission, Asset Practices qualification, Asset Baseline v2, +Evidence Bundle v3 with frozen cwd, Harness Report admission, and DSH inline/no-files analysis behavior. diff --git a/scripts/agent-customize/providers/claude.mjs b/scripts/agent-customize/providers/claude.mjs index 78dbc791..7a48300a 100644 --- a/scripts/agent-customize/providers/claude.mjs +++ b/scripts/agent-customize/providers/claude.mjs @@ -286,11 +286,11 @@ function pluginSetting(id, settings) { return undefined; } -function pluginApplicable(record, workspace, settings, id) { +async function pluginApplicable(record, workspace, settings, id) { const scope = normalizeInstallScope(record.scope); if (scope === "user") return true; if (!settings[scope]) return true; - if (record.projectPath) return normalizeWorkspace(record.projectPath) === workspace; + if (record.projectPath) return pathsReferToSameRoot(record.projectPath, workspace); return settings[scope].get(id) === true; } @@ -394,7 +394,7 @@ async function collectClaudePlugin(record, settings, workspace) { manifest.displayName || packageJson.displayName || heading || titleCase(manifest.name || packageJson.name || record.name), record.name, ); - const applicable = pluginApplicable(record, workspace, settings, record.id); + const applicable = await pluginApplicable(record, workspace, settings, record.id); const configured = pluginSetting(record.id, settings); const enabled = applicable && (configured ? configured.enabled : manifest.defaultEnabled !== false); const metadataPath = await pluginMetadataEvidencePath(pluginRoot, [CLAUDE_PLUGIN_MANIFEST, ["package.json"]]); diff --git a/scripts/agent-lint/cli.mjs b/scripts/agent-lint/cli.mjs index 8731fdf5..ffe51655 100644 --- a/scripts/agent-lint/cli.mjs +++ b/scripts/agent-lint/cli.mjs @@ -53,6 +53,15 @@ function parseArgs(argv) { options.workspace = arg.slice("--workspace=".length); continue; } + if (arg === "--cwd") { + options.cwd = argv[index + 1]; + index += 1; + continue; + } + if (arg.startsWith("--cwd=")) { + options.cwd = arg.slice("--cwd=".length); + continue; + } if (arg === "--workspace-root") { options.workspaceRoot = argv[index + 1]; index += 1; @@ -146,11 +155,12 @@ function parseArgs(argv) { function usage() { return [ - "Usage: better-harness agent-lint [--workspace ] [--profile agents-md-review|agent-assets-review] [--json|--format markdown]", + "Usage: better-harness agent-lint [--workspace ] [--cwd ] [--profile agents-md-review|agent-assets-review] [--json|--format markdown]", " better-harness agent-lint --workspace-root --scan-children --profile agents-md-review", ` better-harness agent-lint --profile agent-assets-review --provider <${hostPipeList(ASSET_HOSTS)}> [--skill ]`, "", "Parse agent instruction entrypoints and bounded local Markdown references into review evidence.", + "Configured-practice options: --cwd , --dsh-home .", "", ].join("\n"); } diff --git a/scripts/agent-lint/index.mjs b/scripts/agent-lint/index.mjs index 1b7f9d28..43217a13 100644 --- a/scripts/agent-lint/index.mjs +++ b/scripts/agent-lint/index.mjs @@ -5,7 +5,12 @@ import { collectAgentCustomizeInventory } from "../agent-customize/index.mjs"; import { parseFrontmatter } from "../agent-customize/core/items.mjs"; import { enrichFindingWithRecommendation } from "../findings-recommend.mjs"; import { isDirectory, normalizeWorkspace, pathExists } from "../session-analysis/index.mjs"; -import { ownerRouteForPath, routeContains } from "../workspace-topology/index.mjs"; +import { + ownerRouteForPath, + pathIsContained, + resolveConfiguredCwd, + routeContains, +} from "../workspace-topology/index.mjs"; import { reviewHostInstructions } from "./host-instructions.mjs"; import { reviewHookAssets } from "./hook-review.mjs"; @@ -328,8 +333,7 @@ function safeDecodeURIComponent(value) { } function isInsideWorkspace(workspace, filePath) { - const relative = path.relative(workspace, filePath); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + return pathIsContained(workspace, filePath); } async function resolveReference({ workspace, ownerPath, ownerHeadings, link }) { @@ -999,7 +1003,7 @@ function relativeAssetPath(workspace, filePath) { return undefined; } const relative = normalizeSlash(path.relative(workspace, filePath)); - return relative.startsWith("..") ? filePath : relative; + return pathIsContained(workspace, filePath) ? relative : filePath; } async function parseAssetMarkdown(filePath, workspace, options = {}) { @@ -1659,17 +1663,26 @@ export async function applyAgentAssetsReviewProfile(graph, options = {}) { } async function singleWorkspacePayload(options = {}) { - const graph = await collectAgentInstructionGraph(options); - const profileResult = options.profile === PROFILE_AGENTS_MD_REVIEW - ? await applyAgentsMdReviewProfile(graph, options) - : options.profile === PROFILE_AGENT_ASSETS_REVIEW - ? await applyAgentAssetsReviewProfile(graph, options) + const scopedOptions = options.profile === PROFILE_AGENT_ASSETS_REVIEW + ? { + ...options, + ...resolveConfiguredCwd({ + workspace: options.workspace ?? ".", + cwd: options.cwd, + }), + } + : options; + const graph = await collectAgentInstructionGraph(scopedOptions); + const profileResult = scopedOptions.profile === PROFILE_AGENTS_MD_REVIEW + ? await applyAgentsMdReviewProfile(graph, scopedOptions) + : scopedOptions.profile === PROFILE_AGENT_ASSETS_REVIEW + ? await applyAgentAssetsReviewProfile(graph, scopedOptions) : { findings: [], manifestEvidence: [], assetInventory: undefined }; const findings = profileResult.findings.map((item) => { - if (!options.topology || typeof item?.file !== "string") return item; + if (!scopedOptions.topology || typeof item?.file !== "string") return item; const route = normalizeSlash(item.file); if (!route || path.isAbsolute(route) || route === ".." || route.startsWith("../")) return item; - const packageRoute = ownerRouteForPath(options.topology, route); + const packageRoute = ownerRouteForPath(scopedOptions.topology, route); return { ...item, packageRoute, @@ -1678,7 +1691,7 @@ async function singleWorkspacePayload(options = {}) { }); return { kind: "agent-lint", - profile: options.profile, + profile: scopedOptions.profile, summary: { ...summarizeGraph(graph), ...summarizeFindings(findings), diff --git a/scripts/coding-agent-practices/asset-baseline.mjs b/scripts/coding-agent-practices/asset-baseline.mjs index 98fe8ea7..1aff820b 100644 --- a/scripts/coding-agent-practices/asset-baseline.mjs +++ b/scripts/coding-agent-practices/asset-baseline.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import path from "node:path"; +import os from "node:os"; import { stat as statPath } from "node:fs/promises"; import { fileURLToPath } from "node:url"; @@ -13,11 +14,12 @@ import { } from "../host-support/index.mjs"; import { runAgentLint } from "../agent-lint/index.mjs"; import { normalizeWorkspace, parseArgs, parseBooleanFlag } from "../session-analysis/index.mjs"; +import { canonicalPath, pathIsContained, resolveConfiguredCwd } from "../workspace-topology/index.mjs"; import { reviewAssetIntegrity } from "./asset-integrity.mjs"; import { collectProviderInventory, collectQoderInventory } from "./inventory.mjs"; export const ASSET_BASELINE_KIND = "agent-asset-baseline"; -export const ASSET_BASELINE_SCHEMA_VERSION = 1; +export const ASSET_BASELINE_SCHEMA_VERSION = 2; export const MAX_BASELINE_FINDINGS = 16; export const MAX_BASELINE_OWNER_ROUTES = 16; const MAX_OWNER_ROUTE_STAT_CONCURRENCY = 32; @@ -37,42 +39,217 @@ const OWNER_KIND_RANK = Object.freeze({ workflows: 8, }); const OWNER_SCOPE_RANK = Object.freeze({ workspace: 0, project: 0, inherited: 1, user: 2, plugin: 3 }); +const BASELINE_STATUSES = new Set(["complete", "partial", "failed"]); +const BASELINE_ENVELOPE_NAMES = Object.freeze(["lint", "inventory", "integrity"]); + +function invalidBaseline(message) { + throw Object.assign(new Error(`invalid Agent Asset Baseline v2: ${message}`), { + code: "INVALID_AGENT_ASSET_BASELINE", + }); +} + +function record(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function nonemptyString(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function validateConfiguredSnapshot(snapshot) { + if (!record(snapshot)) invalidBaseline("configuredSnapshot must be an object"); + if (!nonemptyString(snapshot.collectedAt) || Number.isNaN(Date.parse(snapshot.collectedAt))) { + invalidBaseline("configuredSnapshot.collectedAt must be an ISO timestamp"); + } + if (snapshot.evidenceKind !== "configured-not-observed") { + invalidBaseline("configuredSnapshot.evidenceKind is unsupported"); + } + if (!["qualified-defaults", "caller-overrides"].includes(snapshot.configurationSource)) { + invalidBaseline("configuredSnapshot.configurationSource is unsupported"); + } + if (!["included", "not-authorized"].includes(snapshot.userHomeCollection)) { + invalidBaseline("configuredSnapshot.userHomeCollection is unsupported"); + } + if (!["enabled", "disabled-by-byte-limit"].includes(snapshot.instructionCollection)) { + invalidBaseline("configuredSnapshot.instructionCollection is unsupported"); + } + if (!record(snapshot.qualification) + || snapshot.qualification.provider !== "dsh" + || !nonemptyString(snapshot.qualification.version) + || !/^[a-f0-9]{40}$/u.test(snapshot.qualification.sourceSha ?? "")) { + invalidBaseline("configuredSnapshot.qualification is malformed"); + } + if (!record(snapshot.runtimeResolution) + || ["cordis", "profile", "preset", "runtimeSkills"] + .some((name) => snapshot.runtimeResolution[name] !== false)) { + invalidBaseline("configuredSnapshot.runtimeResolution is malformed"); + } +} + +export function validateAssetBaselineV2(baseline, expected = {}) { + if (!record(baseline) || baseline.kind !== ASSET_BASELINE_KIND) { + invalidBaseline("kind is unsupported"); + } + if (baseline.schemaVersion !== ASSET_BASELINE_SCHEMA_VERSION) { + invalidBaseline("schemaVersion is unsupported"); + } + if (!BASELINE_STATUSES.has(baseline.status)) invalidBaseline("status is unsupported"); + if (!record(baseline.scope) + || !nonemptyString(baseline.scope.provider) + || !nonemptyString(baseline.scope.workspace) + || !nonemptyString(baseline.scope.cwd) + || typeof baseline.scope.includeUserHome !== "boolean" + || typeof baseline.scope.includeMemories !== "boolean") { + invalidBaseline("scope is malformed"); + } + for (const name of ["provider", "workspace", "cwd", "includeUserHome", "includeMemories"]) { + if (expected[name] !== undefined && baseline.scope[name] !== expected[name]) { + invalidBaseline(`scope.${name} does not match the frozen context`); + } + } + if (!record(baseline.envelopes)) invalidBaseline("envelopes must be an object"); + for (const name of BASELINE_ENVELOPE_NAMES) { + const envelope = baseline.envelopes[name]; + if (!record(envelope) || !["available", "unavailable"].includes(envelope.status)) { + invalidBaseline(`${name} envelope is malformed`); + } + if (envelope.status === "available" && !record(envelope.data)) { + invalidBaseline(`${name} envelope is missing data`); + } + if (envelope.status === "unavailable" + && (!record(envelope.error) + || !nonemptyString(envelope.error.code) + || !nonemptyString(envelope.error.message))) { + invalidBaseline(`${name} envelope is missing an error`); + } + } + if (!record(baseline.diagnostics)) invalidBaseline("diagnostics must be an object"); + if (baseline.status === "complete" + && BASELINE_ENVELOPE_NAMES.some((name) => baseline.envelopes[name].status !== "available")) { + invalidBaseline("complete status requires all envelopes"); + } + if (baseline.status === "failed" + && BASELINE_ENVELOPE_NAMES.some((name) => baseline.envelopes[name].status !== "unavailable")) { + invalidBaseline("failed status requires unavailable envelopes"); + } + if (baseline.scope.provider === "dsh") { + if (baseline.status !== "failed") validateConfiguredSnapshot(baseline.configuredSnapshot); + else if (baseline.configuredSnapshot !== undefined) validateConfiguredSnapshot(baseline.configuredSnapshot); + } else if (baseline.configuredSnapshot !== undefined) { + invalidBaseline("configuredSnapshot is only supported for DSH"); + } + return baseline; +} function text(value, limit = 320) { return String(value ?? "").replace(/[\u0000-\u001f\u007f]/gu, " ").replace(/\s+/gu, " ").trim().slice(0, limit); } -function compactError(error, stage) { +function compactError(error, stage, context) { return { code: `${stage.toUpperCase().replace(/[^A-Z0-9]+/gu, "_")}_UNAVAILABLE`, - message: text(error instanceof Error ? error.message : error), + message: boundedText(error instanceof Error ? error.message : error, context), }; } -function compactFinding(finding = {}) { +function portable(relativePath) { + return relativePath.split(path.sep).join("/"); +} + +function canonicalIfPresent(filePath) { + try { + return canonicalPath(filePath); + } catch { + return filePath; + } +} + +function pathLocator(filePath, context) { + if (typeof filePath !== "string" || !filePath.trim()) return undefined; + if (filePath === "" || filePath.startsWith("") || filePath.startsWith("") || filePath.startsWith("~/")) { + return filePath.split(/[\\/]/u).includes("..") ? "" : filePath; + } + if (process.platform !== "win32" && /^(?:[A-Za-z]:[\\/]|\\\\)/u.test(filePath)) return ""; + const absolute = path.resolve(context.workspace, filePath); + const resolved = canonicalIfPresent(absolute); + const workspace = canonicalIfPresent(context.workspace); + const lexicalWorkspace = context.workspaceInput ?? context.workspace; + const lexicalGitRoot = context.gitRootInput ?? context.gitRoot; + const gitRoot = lexicalGitRoot ? canonicalIfPresent(lexicalGitRoot) : undefined; + const lexicalHome = context.includeUserHome ? context.home : undefined; + const home = lexicalHome ? canonicalIfPresent(lexicalHome) : undefined; + if (pathIsContained(lexicalWorkspace, absolute) + && (resolved === absolute || pathIsContained(workspace, resolved))) { + const relative = path.relative(lexicalWorkspace, absolute); + return relative ? `/${portable(relative)}` : ""; + } + if (lexicalGitRoot && pathIsContained(lexicalGitRoot, absolute) + && (resolved === absolute || pathIsContained(gitRoot, resolved))) { + const relative = path.relative(lexicalGitRoot, absolute); + return relative ? `/${portable(relative)}` : ""; + } + if (lexicalHome && pathIsContained(lexicalHome, absolute) + && (resolved === absolute || pathIsContained(home, resolved))) { + const relative = path.relative(lexicalHome, absolute); + return relative ? `~/${portable(relative)}` : "~"; + } + if (pathIsContained(workspace, resolved)) { + const relative = path.relative(workspace, resolved); + return relative ? `/${portable(relative)}` : ""; + } + if (gitRoot && pathIsContained(gitRoot, resolved)) { + const relative = path.relative(gitRoot, resolved); + return relative ? `/${portable(relative)}` : ""; + } + if (home && pathIsContained(home, resolved)) { + const relative = path.relative(home, resolved); + return relative ? `~/${portable(relative)}` : "~"; + } + return ""; +} + +function boundedText(value, context, knownPath) { + let result = text(value); + if (!result) return result; + const marker = "BETTER_HARNESS_PATH_LOCATOR"; + const locator = knownPath ? pathLocator(knownPath, context) ?? "" : undefined; + if (knownPath) result = result.replaceAll(String(knownPath), marker); + return result + .replace( + /(["'])((?:[A-Za-z]:[\\/]|\\\\|\/)[^"']+)\1/gu, + (_candidate, quote, candidate) => `${quote}${pathLocator(candidate, context) ?? ""}${quote}`, + ) + .replace(/\\\\[^\s,;)'"\]]+\\[^\s,;)'"\]]+/gu, (candidate) => pathLocator(candidate, context) ?? "") + .replace(/[A-Za-z]:[\\/][^\s,;)'"\]]+/gu, (candidate) => pathLocator(candidate, context) ?? "") + .replace(/\/(?:[^\s,;)'"\]]+\/?)+/gu, (candidate) => pathLocator(candidate, context) ?? "") + .replaceAll(marker, locator ?? ""); +} + +function compactFinding(finding = {}, context) { + const file = pathLocator(finding.file, context); return Object.fromEntries(Object.entries({ id: text(finding.id, 96), severity: text(finding.severity, 24), assetKind: text(finding.assetKind ?? finding.kind, 48), assetName: text(finding.assetName, 96), scope: text(finding.scope, 32), - file: text(finding.file, 180), + file, line: Number.isInteger(finding.line) ? finding.line : undefined, - evidence: text(finding.evidence), - why: text(finding.why), - whyThisMatters: text(finding.whyThisMatters), + evidence: boundedText(finding.evidence, context, finding.file), + why: boundedText(finding.why, context, finding.file), + whyThisMatters: boundedText(finding.whyThisMatters, context, finding.file), sourceLabel: text(finding.sourceLabel, 96), rubricRef: text(finding.rubricRef, 120), }).filter(([, value]) => value !== undefined && value !== "")); } -function compactFindings(findings = []) { +function compactFindings(findings = [], context) { const ordered = [...findings].sort((left, right) => (SEVERITY_RANK[left?.severity] ?? 9) - (SEVERITY_RANK[right?.severity] ?? 9) || String(left?.id ?? "").localeCompare(String(right?.id ?? "")), ); return { - items: ordered.slice(0, MAX_BASELINE_FINDINGS).map(compactFinding), + items: ordered.slice(0, MAX_BASELINE_FINDINGS).map((finding) => compactFinding(finding, context)), total: ordered.length, omitted: Math.max(0, ordered.length - MAX_BASELINE_FINDINGS), truncated: ordered.length > MAX_BASELINE_FINDINGS, @@ -83,7 +260,7 @@ function workspaceRoute(filePath, workspace) { if (!filePath) return undefined; const absolute = path.resolve(filePath); const relative = path.relative(workspace, absolute); - if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return undefined; + if (!relative || !pathIsContained(workspace, absolute)) return undefined; return relative.split(path.sep).join("/"); } @@ -101,15 +278,21 @@ async function ownerRoutes(inventory, workspace, options = {}) { const name = text(item?.displayName ?? item?.name ?? item?.label, 96); if (!name) continue; const sourcePath = item?.path ?? item?.filePath ?? item?.rootPath; + const rawRoute = text(item?.originRoute, 180) + || workspaceRoute(item?.path ?? item?.filePath, workspace); + const locatedSource = sourcePath ? pathLocator(sourcePath, options.pathContext) : undefined; const route = Object.fromEntries(Object.entries({ kind: text(surface.type, 32), scope: text(item?.scope ?? surface.scope, 24), name, version: text(item?.version, 32), owner: text(item?.pluginName ?? item?.ownerName ?? item?.sourceLabel, 96), - route: text(item?.originRoute, 180) - || workspaceRoute(item?.path ?? item?.filePath, workspace), - effectiveTarget: text(item?.effectiveTarget, 180), + route: locatedSource === "" ? "" : rawRoute, + effectiveTarget: item?.effectiveTarget + ? (path.isAbsolute(item.effectiveTarget) + ? pathLocator(item.effectiveTarget, options.pathContext) + : text(item.effectiveTarget, 180)) + : undefined, }).filter(([, value]) => value !== undefined && value !== "")); const key = [route.kind, route.scope, route.name, route.version, route.owner, route.route].join(":"); if (!routes.has(key)) routes.set(key, { route, sourcePath }); @@ -176,8 +359,8 @@ async function compactInventory(inventory, workspace, options = {}) { ...row, ...(row?.surface === "Memories" ? { paths: undefined } - : Array.isArray(row?.paths) && row.paths.length > 5 - ? { paths: row.paths.slice(0, 5) } + : Array.isArray(row?.paths) + ? { paths: row.paths.slice(0, 12).map((filePath) => pathLocator(filePath, options.pathContext)) } : {}), })).map((row) => Object.fromEntries(Object.entries(row).filter(([, value]) => value !== undefined))); const inventorySummary = Object.fromEntries(Object.entries(inventory?.summary ?? {}) @@ -201,29 +384,29 @@ async function compactInventory(inventory, workspace, options = {}) { }; } -function compactLint(lint) { +function compactLint(lint, context) { return { kind: lint.kind, profile: lint.profile, summary: lint.summary, assetInventory: lint.assetInventory, - findings: compactFindings(lint.findings), + findings: compactFindings(lint.findings, context), }; } -function compactIntegrity(integrity) { +function compactIntegrity(integrity, context) { return { kind: integrity.kind, profile: integrity.profile, status: integrity.status, contentPolicy: integrity.contentPolicy, summary: integrity.summary, - findings: compactFindings(integrity.findings), + findings: compactFindings(integrity.findings, context), }; } -function unavailable(error, stage) { - return { status: "unavailable", error: compactError(error, stage) }; +function unavailable(error, stage, context) { + return { status: "unavailable", error: compactError(error, stage, context) }; } function available(data) { @@ -236,7 +419,7 @@ function inheritedWorkspaceRoots(topology, workspace) { return []; } const relative = path.relative(topology.gitRoot, workspace); - if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return []; + if (!relative || !pathIsContained(topology.gitRoot, workspace)) return []; const parts = relative.split(path.sep).filter(Boolean); const roots = [topology.gitRoot]; for (let index = 1; index < parts.length; index += 1) { @@ -253,8 +436,7 @@ function inheritedItem(item, topology) { const filePath = rawItemPath(item); const relative = filePath ? path.relative(topology.gitRoot, path.resolve(filePath)) : ""; const originRoute = relative - && !relative.startsWith("..") - && !path.isAbsolute(relative) + && pathIsContained(topology.gitRoot, path.resolve(filePath)) ? relative.split(path.sep).join("/") : undefined; return { @@ -305,34 +487,84 @@ function mergeInheritedInventories(projectInventory, inheritedInventories, topol }; } +function baselineConfiguredScope(options) { + const workspace = normalizeWorkspace(options.workspace ?? "."); + const cwd = normalizeWorkspace(options.cwd ?? workspace); + return resolveConfiguredCwd({ workspace, cwd }); +} + +function configuredSnapshot(rawInventory, provider) { + if (provider !== "dsh") return undefined; + const diagnostics = rawInventory?.diagnostics; + if (!diagnostics || !rawInventory?.generatedAt) return undefined; + return { + collectedAt: rawInventory.generatedAt, + evidenceKind: diagnostics.evidenceKind, + configurationSource: diagnostics.configurationSource, + userHomeCollection: diagnostics.userHomeCollection, + instructionCollection: diagnostics.instructionCollection, + qualification: { + provider: "dsh", + version: diagnostics.qualifiedDshVersion, + sourceSha: diagnostics.qualifiedDshSourceSha, + }, + runtimeResolution: { + cordis: false, + profile: false, + preset: false, + runtimeSkills: false, + }, + }; +} + export async function collectAssetBaseline(options = {}, dependencies = {}) { const provider = options.provider ?? options.platform ?? "qoder"; if (!PROVIDERS.has(provider)) { throw new Error(`Unsupported provider: ${provider}. Supported providers: ${ASSET_PRACTICE_HOSTS.join(", ")}.`); } - const workspace = normalizeWorkspace(options.workspace ?? "."); + const configuredScope = baselineConfiguredScope(options); + const { workspace, cwd } = configuredScope; + const topology = options.topology?.gitRoot + ? { ...options.topology, gitRoot: canonicalIfPresent(options.topology.gitRoot) } + : options.topology; const includeUserHome = parseBooleanFlag(options.includeUserHome ?? options["include-user-home"] ?? false); const memoryOption = options.includeMemories ?? options["include-memories"]; + const requestedMemories = memoryOption === undefined ? undefined : parseBooleanFlag(memoryOption); + if (provider === "dsh" && requestedMemories === true) { + throw Object.assign(new Error("DSH does not support Memory collection"), { + code: "UNSUPPORTED_DSH_MEMORY_COLLECTION", + }); + } // Qoder can isolate title metadata to the selected project. Keep that // metadata in the normal project baseline while user/global Memory remains // behind includeUserHome. Other providers expose Memory as user-level data. - const includeMemories = memoryOption === undefined + const includeMemories = requestedMemories === undefined ? provider === "qoder" - : parseBooleanFlag(memoryOption); + : requestedMemories; const common = { ...options, provider, platform: provider, workspace, + cwd, + topology: options.topology, includeUserHome, includeGlobalHooks: includeUserHome, includeMemories, }; + const pathContext = { + workspace, + workspaceInput: normalizeWorkspace(options.workspace ?? "."), + gitRoot: topology?.gitRoot, + gitRootInput: options.topology?.gitRoot, + home: dependencies.homeDirectory?.() ?? os.homedir(), + includeUserHome, + }; const collectRawInventory = dependencies.collectRawInventory ?? collectAgentCustomizeInventory; let rawInventory; try { rawInventory = await collectRawInventory(common); - const inheritedRoots = inheritedWorkspaceRoots(options.topology, workspace); + const inheritedRoots = inheritedWorkspaceRoots(topology, workspace); if (inheritedRoots.length > 0) { const inheritedInventories = []; for (const inheritedWorkspace of inheritedRoots) { @@ -344,15 +576,15 @@ export async function collectAssetBaseline(options = {}, dependencies = {}) { includeMemories: false, })); } - rawInventory = mergeInheritedInventories(rawInventory, inheritedInventories, options.topology); + rawInventory = mergeInheritedInventories(rawInventory, inheritedInventories, topology); } } catch (error) { - const failed = unavailable(error, "inventory"); + const failed = unavailable(error, "inventory", pathContext); return { kind: ASSET_BASELINE_KIND, schemaVersion: ASSET_BASELINE_SCHEMA_VERSION, status: "failed", - scope: { provider, workspace, includeUserHome, includeMemories }, + scope: { provider, workspace, cwd, includeUserHome, includeMemories }, envelopes: { lint: failed, inventory: failed, integrity: failed }, diagnostics: { sharedInventorySnapshot: false, compact: true }, }; @@ -366,24 +598,28 @@ export async function collectAssetBaseline(options = {}, dependencies = {}) { inventoryRunner({ ...common, inventory: rawInventory }), ]); const lintEnvelope = lintResult.status === "fulfilled" - ? available(compactLint(lintResult.value)) - : unavailable(lintResult.reason, "lint"); + ? available(compactLint(lintResult.value, pathContext)) + : unavailable(lintResult.reason, "lint", pathContext); const inventoryEnvelope = inventoryResult.status === "fulfilled" ? available(await compactInventory(inventoryResult.value, workspace, { stat: dependencies.stat, now: dependencies.now, + pathContext, })) - : unavailable(inventoryResult.reason, "inventory"); + : unavailable(inventoryResult.reason, "inventory", pathContext); let integrityEnvelope; if (inventoryResult.status === "fulfilled") { try { const review = dependencies.reviewIntegrity ?? reviewAssetIntegrity; - integrityEnvelope = available(compactIntegrity(review(inventoryResult.value, { locale: options.language }))); + integrityEnvelope = available(compactIntegrity( + review(inventoryResult.value, { locale: options.language }), + pathContext, + )); } catch (error) { - integrityEnvelope = unavailable(error, "integrity"); + integrityEnvelope = unavailable(error, "integrity", pathContext); } } else { - integrityEnvelope = unavailable(new Error("The shared public inventory is unavailable."), "integrity"); + integrityEnvelope = unavailable(new Error("The shared public inventory is unavailable."), "integrity", pathContext); } const envelopes = { lint: lintEnvelope, inventory: inventoryEnvelope, integrity: integrityEnvelope }; const availableCount = Object.values(envelopes).filter((envelope) => envelope.status === "available").length; @@ -394,6 +630,7 @@ export async function collectAssetBaseline(options = {}, dependencies = {}) { const sampledStages = [ inventoryEnvelope.data?.ownerRoutes?.truncated ? "inventory-owner-routes" : null, ].filter(Boolean); + const snapshot = configuredSnapshot(rawInventory, provider); return { kind: ASSET_BASELINE_KIND, schemaVersion: ASSET_BASELINE_SCHEMA_VERSION, @@ -402,7 +639,8 @@ export async function collectAssetBaseline(options = {}, dependencies = {}) { : availableCount === 0 ? "failed" : "partial", - scope: { provider, workspace, includeUserHome, includeMemories }, + scope: { provider, workspace, cwd, includeUserHome, includeMemories }, + ...(snapshot ? { configuredSnapshot: snapshot } : {}), envelopes, diagnostics: { sharedInventorySnapshot: true, @@ -445,11 +683,13 @@ Collect one compact, read-only AI evidence envelope from a shared asset snapshot Options: --workspace Workspace root (default: current directory) + --cwd Configured-practice cwd (default: workspace) --include-user-home Include authorized user/global asset metadata --include-memories Include authorized Memory title metadata (default: selected Qoder project) --claude-home Claude config root override --claude-state Claude state-file override --kimi-home Kimi Code data root override + --dsh-home DeepSeek Harness config root override --language Integrity finding language (default: en) --format Output format (default: json) --json Emit JSON diff --git a/scripts/coding-agent-practices/asset-integrity.mjs b/scripts/coding-agent-practices/asset-integrity.mjs index 7458aa79..6567ff99 100644 --- a/scripts/coding-agent-practices/asset-integrity.mjs +++ b/scripts/coding-agent-practices/asset-integrity.mjs @@ -355,6 +355,8 @@ and Hooks. Memory bodies are never read. Options: --workspace Workspace root (default: current directory) + --cwd Configured-practice cwd (default: workspace) + --dsh-home DeepSeek Harness config root override --qoder-home Qoder home override --codex-home Codex home override --claude-home Claude config root override diff --git a/scripts/coding-agent-practices/inventory.mjs b/scripts/coding-agent-practices/inventory.mjs index ccc872a4..2bfe8fb2 100644 --- a/scripts/coding-agent-practices/inventory.mjs +++ b/scripts/coding-agent-practices/inventory.mjs @@ -24,6 +24,7 @@ import { hostPipeList, normalizedHostHomeOptions, } from "../host-support/index.mjs"; +import { pathIsContained, resolveConfiguredCwd } from "../workspace-topology/index.mjs"; const SESSION_HOST_SET = hostIdSetFor(HOST_CAPABILITIES.SESSION_ANALYSIS); const ASSET_PRACTICE_HOSTS = hostIdsFor(HOST_CAPABILITIES.ASSET_PRACTICES); @@ -424,8 +425,12 @@ function summarize(surfaces) { }; } -function publicScope(options = {}) { - const workspace = normalizeWorkspace(options.workspace); +function publicScope(options = {}, dependencies = {}) { + const configuredScope = resolveConfiguredCwd({ + workspace: normalizeWorkspace(options.workspace), + cwd: options.cwd === undefined ? undefined : normalizeWorkspace(options.cwd), + }, dependencies); + const { workspace, cwd } = configuredScope; const environmentHome = process.env.QODER_HOME; const environmentBase = path.basename(String(environmentHome ?? "")).toLowerCase(); const environmentIsAssetHome = environmentBase === ".qoder" || environmentBase === "qoder"; @@ -441,6 +446,7 @@ function publicScope(options = {}) { return { platform: "qoder", workspace, + cwd, qoderHome, includeUserHome: normalizeBoolean(options.includeUserHome ?? options["include-user-home"] ?? false), includeMemories: normalizeBoolean(options.includeMemories ?? options["include-memories"] ?? false), @@ -448,8 +454,12 @@ function publicScope(options = {}) { }; } -function providerScope(options = {}, platform = options.platform ?? "qoder") { - const workspace = normalizeWorkspace(options.workspace); +function providerScope(options = {}, platform = options.platform ?? "qoder", dependencies = {}) { + const configuredScope = resolveConfiguredCwd({ + workspace: normalizeWorkspace(options.workspace), + cwd: options.cwd === undefined ? undefined : normalizeWorkspace(options.cwd), + }, dependencies); + const { workspace, cwd } = configuredScope; const host = getHostDescriptor(platform); const home = hostHomeValue(options, platform); const sharedCache = options.sharedCache ?? options["shared-cache"]; @@ -459,6 +469,7 @@ function providerScope(options = {}, platform = options.platform ?? "qoder") { return { platform, workspace, + cwd, includeUserHome: normalizeBoolean(options.includeUserHome ?? options["include-user-home"] ?? false), includeGlobalHooks: normalizeBoolean(options.includeGlobalHooks ?? options["include-global-hooks"] ?? false), includeMemories: normalizeBoolean(options.includeMemories ?? options["include-memories"] ?? false), @@ -668,12 +679,13 @@ async function buildConfiguredAssetSurfaces(inventory, scope) { return surfaces; } -export async function collectProviderInventory(options = {}) { +export async function collectProviderInventory(options = {}, dependencies = {}) { const platform = options.platform ?? "qoder"; - const scope = providerScope(options, platform); + const scope = providerScope(options, platform, dependencies); const inventory = options.inventory ?? await collectAgentCustomizeInventory({ provider: platform, workspace: scope.workspace, + cwd: scope.cwd, ...normalizedHostHomeOptions(scope, platform), qoderSharedClientCacheRoot: scope.qoderSharedClientCacheRoot, codexAppPath: scope.codexAppPath, @@ -734,7 +746,10 @@ export async function collectProviderInventory(options = {}) { } return { scope, - summary: summarize(surfaces), + summary: { + ...summarize(surfaces), + practiceCoverageRows: practiceCoverageRows(surfaces, scope), + }, surfaces, sessionSourceHints, memories, @@ -749,12 +764,12 @@ function boundedReportPath(filePath, workspace) { if (typeof filePath !== "string" || !filePath.trim()) return undefined; const absolute = path.resolve(filePath); const workspaceRelative = path.relative(workspace, absolute); - if (workspaceRelative && !workspaceRelative.startsWith("..") && !path.isAbsolute(workspaceRelative)) { + if (workspaceRelative && pathIsContained(workspace, absolute)) { return workspaceRelative.split(path.sep).join("/"); } if (!workspaceRelative) return "."; const homeRelative = path.relative(os.homedir(), absolute); - if (homeRelative && !homeRelative.startsWith("..") && !path.isAbsolute(homeRelative)) { + if (homeRelative && pathIsContained(os.homedir(), absolute)) { return `~/${homeRelative.split(path.sep).join("/")}`; } return undefined; @@ -811,8 +826,8 @@ function practiceCoverageRows(surfaces, scope) { return rows; } -export async function collectQoderInventory(options = {}) { - const scope = publicScope(options); +export async function collectQoderInventory(options = {}, dependencies = {}) { + const scope = publicScope(options, dependencies); const providerInventory = await collectProviderInventory({ ...options, platform: "qoder", @@ -822,7 +837,7 @@ export async function collectQoderInventory(options = {}) { ? path.dirname(scope.sharedCache) : scope.sharedCache, includeUserHome: scope.includeUserHome, - }); + }, dependencies); const surfaces = [...providerInventory.surfaces]; const memories = scope.includeMemories ? await collectMemories(scope) @@ -938,6 +953,7 @@ Inspect configured coding-agent assets and practice evidence for one platform. Options: --platform <${hostPipeList(ASSET_PRACTICE_HOSTS)}> Select the platform (default: qoder; may also be the first positional) --workspace Workspace root to inspect (default: current directory) + --cwd Configured-practice cwd (default: workspace) --json Emit JSON (default) --format Output format --include-user-home Include user/global assets @@ -945,6 +961,7 @@ Options: --claude-home Claude config root override --claude-state Claude state-file override --kimi-home Kimi Code data root override + --dsh-home DeepSeek Harness config root override -h, --help Print this help `; diff --git a/scripts/harness-analysis/evidence-bundle/agent-customize.mjs b/scripts/harness-analysis/evidence-bundle/agent-customize.mjs index 6a703066..11817ca3 100644 --- a/scripts/harness-analysis/evidence-bundle/agent-customize.mjs +++ b/scripts/harness-analysis/evidence-bundle/agent-customize.mjs @@ -1,4 +1,7 @@ -import { collectAssetBaseline } from "../../coding-agent-practices/asset-baseline.mjs"; +import { + collectAssetBaseline, + validateAssetBaselineV2, +} from "../../coding-agent-practices/asset-baseline.mjs"; import { HOST_CAPABILITIES, hostIdSetFor } from "../../host-support/index.mjs"; import { availableLane, unavailableLane } from "./contract.mjs"; @@ -12,6 +15,7 @@ export async function collectAgentCustomize(context, options = {}, dependencies const data = await collect({ provider: context.provider, workspace: context.workspace, + cwd: context.cwd, language: context.language, topology: context.topology, analysisScope: context.analysisScope, @@ -20,10 +24,16 @@ export async function collectAgentCustomize(context, options = {}, dependencies ...(options[`${context.provider}-home`] ? { [`${context.provider}-home`]: options[`${context.provider}-home`] } : {}), ...(context.provider === "claude" && options["claude-state"] ? { "claude-state": options["claude-state"] } : {}), }); - if (data?.kind !== "agent-asset-baseline") { - throw Object.assign(new Error("agent asset evidence returned an invalid contract"), { - code: "INVALID_AGENT_CUSTOMIZE_EVIDENCE", + try { + validateAssetBaselineV2(data, { + provider: context.provider, + workspace: context.workspace, + cwd: context.cwd, + includeUserHome: context.authority.includeUserHome, + includeMemories: context.authority.includeMemories, }); + } catch { + return unavailableLane("agent-customize", { code: "INVALID_AGENT_CUSTOMIZE_EVIDENCE" }); } if (data.status === "failed") { return { diff --git a/scripts/harness-analysis/evidence-bundle/cli.mjs b/scripts/harness-analysis/evidence-bundle/cli.mjs index c6876860..2bed72c3 100644 --- a/scripts/harness-analysis/evidence-bundle/cli.mjs +++ b/scripts/harness-analysis/evidence-bundle/cli.mjs @@ -21,6 +21,7 @@ Harness, and Agent Customize specialists plus the lead analyzer. Options: --workspace Target workspace (required) + --cwd Configured-practice cwd (default: workspace) --platform ${formatHostList(EVIDENCE_HOSTS)} (default: qoder) --language Evidence language (default: en) --depth 7-day/3-item or 30-day/5-item review (default: normal) @@ -34,6 +35,7 @@ Options: --kimi-home Kimi Code data root override --workbuddy-home WorkBuddy data root override --grok-home Grok CLI data root override + --dsh-home DeepSeek Harness config root override --canvas-out With Qoder or Cursor, initialize canvas.json from lead facts --replace-canvas Replace that canvas.json when explicitly authorized --format json JSON only @@ -41,7 +43,7 @@ Options: `; const ALLOWED = new Set([ - "workspace", "platform", "provider", "language", "depth", "since", "until", + "workspace", "cwd", "platform", "provider", "language", "depth", "since", "until", "evidence-limit", "include-user-home", "include-memories", "canvas-out", "replace-canvas", "format", "json", ...hostHomeOptionKeys(EVIDENCE_HOSTS), "claude-state", "help", "h", diff --git a/scripts/harness-analysis/evidence-bundle/contract.mjs b/scripts/harness-analysis/evidence-bundle/contract.mjs index 39ad0128..cff095e2 100644 --- a/scripts/harness-analysis/evidence-bundle/contract.mjs +++ b/scripts/harness-analysis/evidence-bundle/contract.mjs @@ -1,14 +1,12 @@ -import { existsSync, realpathSync } from "node:fs"; -import path from "node:path"; - import { HOST_CAPABILITIES, hostIdSetFor } from "../../host-support/index.mjs"; import { analysisScopeFromTopology, + resolveConfiguredCwd, validateWorkspaceTopology, } from "../../workspace-topology/index.mjs"; export const EVIDENCE_BUNDLE_KIND = "better-harness.evidence-bundle"; -export const EVIDENCE_BUNDLE_SCHEMA_VERSION = 2; +export const EVIDENCE_BUNDLE_SCHEMA_VERSION = 3; export const EVIDENCE_LANE_NAMES = Object.freeze([ "sessionEvidence", "projectHarness", @@ -69,10 +67,11 @@ export function freezeEvidenceBundleContext(options = {}, now = new Date()) { } const topology = options.topology; const analysisScope = options.analysisScope; - const requestedWorkspace = path.resolve(String(options.workspace)); - const canonicalWorkspace = existsSync(requestedWorkspace) - ? realpathSync(requestedWorkspace) - : requestedWorkspace; + const configuredScope = resolveConfiguredCwd({ + workspace: options.workspace, + cwd: options.cwd, + }); + const canonicalWorkspace = configuredScope.workspace; if (topology !== undefined) { validateWorkspaceTopology(topology); if (canonicalWorkspace !== topology.requestedWorkspace) { @@ -102,6 +101,7 @@ export function freezeEvidenceBundleContext(options = {}, now = new Date()) { } return Object.freeze({ workspace: topology?.requestedWorkspace ?? canonicalWorkspace, + cwd: configuredScope.cwd, provider, language: String(options.language ?? "en"), depth, diff --git a/scripts/harness-analysis/evidence-bundle/index.mjs b/scripts/harness-analysis/evidence-bundle/index.mjs index 22617006..e31cb837 100644 --- a/scripts/harness-analysis/evidence-bundle/index.mjs +++ b/scripts/harness-analysis/evidence-bundle/index.mjs @@ -35,6 +35,7 @@ async function collectLead(context, options, analyze, sessionPopulation) { try { const data = await analyze({ workspace: context.workspace, + cwd: context.cwd, platform: context.provider, language: context.language, since: context.window.since, diff --git a/scripts/harness-analysis/report-run.mjs b/scripts/harness-analysis/report-run.mjs index 7087cabd..6419ef59 100644 --- a/scripts/harness-analysis/report-run.mjs +++ b/scripts/harness-analysis/report-run.mjs @@ -12,6 +12,7 @@ import { hostIdsFor, } from "../host-support/index.mjs"; import { parseArgs } from "../session-analysis/index.mjs"; +import { resolveConfiguredCwd } from "../workspace-topology/index.mjs"; import { formatHarnessEvidence } from "./evidence-brief.mjs"; import { validateHarnessReportSource } from "./report-source/index.mjs"; import { projectTaskLoopReportFacts, taskLoopCanvasFromSummaryFacts } from "./task-loop-report.mjs"; @@ -28,6 +29,7 @@ an explicit Qoder Canvas output is requested. Options: --workspace Target workspace (required) + --cwd Configured-practice cwd (default: workspace) --platform ${formatHostList(REPORT_PLATFORMS)} (default: qoder) --language en or zh-CN (default: en) --since Include sessions at or after the frozen window start @@ -64,7 +66,7 @@ function flagEnabled(value) { function assertCliOptions(options) { const allowed = new Set([ - "workspace", "platform", "language", "since", "until", "format", "canvas-out", "replace-canvas", "include-global-capabilities", + "workspace", "cwd", "platform", "language", "since", "until", "format", "canvas-out", "replace-canvas", "include-global-capabilities", ...hostHomeOptionKeys(REPORT_PLATFORMS), ]); const positional = Array.isArray(options._) ? options._ : []; @@ -162,7 +164,7 @@ export async function analyzeHarnessEvidence(options = {}) { const sourceResult = options.sourceInput ? { source: clone(options.sourceInput), sessionBinding: options.sessionBinding ?? null } : await createTaskLoopSourceFromSessions({ - workspace, + ...resolveConfiguredCwd({ workspace, cwd: options.cwd ?? workspace }), platform, language, since: options.since, diff --git a/scripts/harness-analysis/task-loop-source.mjs b/scripts/harness-analysis/task-loop-source.mjs index c0f59ed4..c8bfb34a 100644 --- a/scripts/harness-analysis/task-loop-source.mjs +++ b/scripts/harness-analysis/task-loop-source.mjs @@ -932,6 +932,7 @@ export async function collectAgentLintPracticeEvidence(options = {}) { const assetReviewSupported = ASSET_PRACTICE_HOST_SET.has(provider); const common = { workspace: options.workspace, + cwd: options.cwd ?? options.workspace, provider, ...normalizedHostHomeOptions(options, provider), topology: options.topology, @@ -987,6 +988,7 @@ export function collectTaskLoopPracticeInventory(options = {}, platform = option return collectInventory({ platform, workspace: options.workspace, + cwd: options.cwd ?? options.workspace, includeUserHome: includeGlobalCapabilities, includeGlobalHooks: true, includeMemories, @@ -1098,8 +1100,9 @@ export async function createTaskLoopSourceFromSessions(options = {}) { ?? options.until ?? options.snapshotUntil ?? new Date().toISOString(); + const { cwd: _configuredPracticeCwd, ...sessionOptions } = options; const analyzerOptions = { - ...options, + ...sessionOptions, platform, workspace: options.workspace, since: selectionProfile?.scope?.since ?? options.since, diff --git a/scripts/host-support/index.mjs b/scripts/host-support/index.mjs index 47e51e56..5a1ae49a 100644 --- a/scripts/host-support/index.mjs +++ b/scripts/host-support/index.mjs @@ -114,6 +114,9 @@ export const HOST_DESCRIPTORS = Object.freeze([ capabilities: [ HOST_CAPABILITIES.SESSION_ANALYSIS, HOST_CAPABILITIES.AGENT_CUSTOMIZE, + HOST_CAPABILITIES.ASSET_PRACTICES, + HOST_CAPABILITIES.HARNESS_REPORT, + HOST_CAPABILITIES.EVIDENCE_BUNDLE, ], sessionScopeTokens: [".dsh", "dsh", "deepseek-harness"], }), diff --git a/scripts/workspace-topology/configured-cwd.mjs b/scripts/workspace-topology/configured-cwd.mjs new file mode 100644 index 00000000..29455a84 --- /dev/null +++ b/scripts/workspace-topology/configured-cwd.mjs @@ -0,0 +1,53 @@ +import { realpathSync, statSync } from "node:fs"; +import path from "node:path"; + +function invalidConfiguredCwd(message, cause) { + return Object.assign(new Error(message, cause ? { cause } : undefined), { + code: "INVALID_CONFIGURED_CWD", + }); +} + +export function canonicalPath(value) { + return realpathSync.native(path.resolve(value)); +} + +function canonicalDirectory(value, label, dependencies = {}) { + if (typeof value !== "string" || value.trim().length === 0 || value.includes("\u0000")) { + throw invalidConfiguredCwd(`${label} must be a non-empty directory path`); + } + try { + const canonicalizePath = dependencies.canonicalizePath ?? canonicalPath; + const canonical = canonicalizePath(path.resolve(value)); + if (!statSync(canonical).isDirectory()) { + throw invalidConfiguredCwd(`${label} must resolve to a directory`); + } + return canonical; + } catch (error) { + if (error?.code === "INVALID_CONFIGURED_CWD") throw error; + throw invalidConfiguredCwd(`${label} must resolve to an existing directory`, error); + } +} + +export function pathIsContained(parent, candidate) { + const relative = path.relative(parent, candidate); + return relative === "" || ( + relative !== ".." + && !relative.startsWith(`..${path.sep}`) + && !path.isAbsolute(relative) + ); +} + +export function resolveConfiguredCwd({ workspace, cwd } = {}, dependencies = {}) { + const canonicalWorkspace = canonicalDirectory(workspace, "workspace", dependencies); + const effectiveCwd = cwd === undefined ? workspace : cwd; + const canonicalCwd = canonicalDirectory(effectiveCwd, "cwd", dependencies); + if (!pathIsContained(canonicalWorkspace, canonicalCwd)) { + throw Object.assign(new Error("cwd must resolve inside workspace"), { + code: "CONFIGURED_CWD_OUTSIDE_WORKSPACE", + }); + } + return Object.freeze({ + workspace: canonicalWorkspace, + cwd: canonicalCwd, + }); +} diff --git a/scripts/workspace-topology/index.mjs b/scripts/workspace-topology/index.mjs index cc1ee3cb..c6d15959 100644 --- a/scripts/workspace-topology/index.mjs +++ b/scripts/workspace-topology/index.mjs @@ -1,6 +1,7 @@ -import { realpath } from "node:fs/promises"; import path from "node:path"; +import { canonicalPath } from "./configured-cwd.mjs"; + import { WORKSPACE_TOPOLOGY_KIND, WORKSPACE_TOPOLOGY_SCHEMA_VERSION, @@ -96,7 +97,7 @@ export async function resolveWorkspaceTopology(options = {}, dependencies = {}) } const requestedPath = path.resolve(String(input.workspace)); await (dependencies.assertWorkspaceDirectory ?? assertWorkspaceDirectory)(requestedPath); - const requestedWorkspace = await realpath(requestedPath); + const requestedWorkspace = (dependencies.canonicalizePath ?? canonicalPath)(requestedPath); const gitProbe = (dependencies.resolveGitRoot ?? resolveGitRoot)(requestedWorkspace); const gitRoot = gitProbe.gitRoot; const topologyRoot = gitRoot ?? requestedWorkspace; @@ -180,3 +181,4 @@ export { findingTargetFromTopology, validateFindingTarget, } from "./finding-target.mjs"; +export { canonicalPath, pathIsContained, resolveConfiguredCwd } from "./configured-cwd.mjs"; diff --git a/scripts/workspace-topology/inventory.mjs b/scripts/workspace-topology/inventory.mjs index ef22d956..a5481487 100644 --- a/scripts/workspace-topology/inventory.mjs +++ b/scripts/workspace-topology/inventory.mjs @@ -1,8 +1,8 @@ import { spawn, spawnSync } from "node:child_process"; -import { realpathSync } from "node:fs"; import { readdir, stat } from "node:fs/promises"; import path from "node:path"; +import { canonicalPath } from "./configured-cwd.mjs"; import { normalizeRoute } from "./contract.mjs"; const GIT_MAX_BUFFER_BYTES = 128 * 1024 * 1024; @@ -132,7 +132,7 @@ export function resolveGitRoot(workspace) { } const candidate = String(result.stdout ?? "").trim(); try { - return { gitRoot: realpathSync(candidate), gitAvailable: true, warning: null }; + return { gitRoot: canonicalPath(candidate), gitAvailable: true, warning: null }; } catch { return { gitRoot: path.resolve(candidate), diff --git a/skills/better-harness/SKILL.md b/skills/better-harness/SKILL.md index 971ab0f1..92be7be7 100644 --- a/skills/better-harness/SKILL.md +++ b/skills/better-harness/SKILL.md @@ -19,11 +19,11 @@ Resolve the Skill path, `` as `../..`, a supported `` and `` as ` /scripts/better-harness.mjs`. Stop if any owner is missing; never select another cache or runtime by search order. -Resolve the absolute target, decision, acceptance boundary, risks, locale (the -user's request language unless explicitly changed), output mode, provider -scope, and evidence depth. Quick uses three assets or Episodes and the previous 7 days; normal uses five and the previous 30 days. -Default Qoder/Cursor to durable Canvas; default others to durable HTML. -Only an explicit inline or no-files request writes nothing. +Resolve absolute target, decision, acceptance boundary, risks, locale (request +language by default), output mode, provider, and depth. Quick uses three items +and 7 days; normal uses five and 30 days. Default Qoder/Cursor to durable Canvas +and other rendering hosts to HTML. DSH without REPORT_RENDERING proceeds only +inline or no-files and must not create HTML, Markdown, or Canvas output. Keep providers separate. Use the current one unless project-wide review explicitly authorizes multiple supported providers. Qoder project Memory title metadata is part of @@ -35,7 +35,7 @@ Before delegation, collect one versioned evidence bundle per authorized provider: ```text - harness evidence-bundle --platform --workspace --language --depth --since --until --format json [--include-memories] [--include-user-home] [--canvas-out /canvas.json] + harness evidence-bundle --platform --workspace --cwd --language --depth --since --until --format json [--include-memories] [--include-user-home] [--canvas-out /canvas.json] ``` Use `--canvas-out` only for Qoder/Cursor durable reports. For Qoder, keep the diff --git a/test/agents/agent-asset-baseline.test.mjs b/test/agents/agent-asset-baseline.test.mjs index 187c743b..758a93ec 100644 --- a/test/agents/agent-asset-baseline.test.mjs +++ b/test/agents/agent-asset-baseline.test.mjs @@ -1,9 +1,9 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { test } from "vitest"; +import { onTestFinished, test } from "vitest"; import { ASSET_BASELINE_KIND, @@ -15,6 +15,42 @@ import { const cliPath = path.resolve("scripts/better-harness.mjs"); +async function temporaryWorkspace(prefix) { + const root = await mkdtemp(path.join(os.tmpdir(), prefix)); + const workspace = path.join(root, "workspace"); + await mkdir(workspace, { recursive: true }); + onTestFinished(() => rm(root, { recursive: true, force: true })); + return { root, workspace }; +} + +function emptyBaselineDependencies(onRawInventory = () => {}) { + return { + collectRawInventory: async (options) => { + onRawInventory(options); + return {}; + }, + runLint: async () => ({ kind: "agent-lint", profile: "agent-assets-review", summary: {}, findings: [] }), + collectPublicInventory: async (options) => ({ + scope: { + platform: options.provider, + includeUserHome: options.includeUserHome, + includeMemories: options.includeMemories, + }, + summary: { practiceCoverageRows: [] }, + surfaces: [], + memories: { included: options.includeMemories, categories: [] }, + warnings: [], + }), + reviewIntegrity: () => ({ + kind: "asset-integrity-review", + profile: "asset-integrity-review", + status: "reviewed", + summary: { findingCount: 0 }, + findings: [], + }), + }; +} + function findings(count) { return Array.from({ length: count }, (_, index) => ({ id: `finding-${String(index).padStart(2, "0")}`, @@ -29,20 +65,26 @@ function findings(count) { test("asset baseline shares one inventory snapshot and emits compact AI envelopes", async () => { const rawInventory = { marker: "shared-raw-inventory" }; let rawCalls = 0; + let rawOptions; + let lintOptions; + let inventoryOptions; let lintInventory; let publicInventory; - const workspace = path.resolve("/tmp/better-harness-baseline-project"); + const { workspace } = await temporaryWorkspace("better-harness-baseline-project-"); const result = await collectAssetBaseline({ provider: "codex", workspace, + cwd: workspace, includeMemories: true, language: "en", }, { - collectRawInventory: async () => { + collectRawInventory: async (options) => { rawCalls += 1; + rawOptions = options; return rawInventory; }, runLint: async (options) => { + lintOptions = options; lintInventory = options.inventory; return { kind: "agent-lint", @@ -54,6 +96,7 @@ test("asset baseline shares one inventory snapshot and emits compact AI envelope }; }, collectPublicInventory: async (options) => { + inventoryOptions = options; publicInventory = options.inventory; return { scope: { platform: "codex", includeUserHome: false }, @@ -65,7 +108,7 @@ test("asset baseline shares one inventory snapshot and emits compact AI envelope { type: "skills", scope: "workspace", - items: [{ name: "review", scope: "workspace", path: path.join(workspace, "skills/review/SKILL.md") }], + items: [{ name: "review", scope: "workspace", path: path.join(options.workspace, "skills/review/SKILL.md") }], }, { type: "plugins", @@ -99,7 +142,13 @@ test("asset baseline shares one inventory snapshot and emits compact AI envelope }), }); + const canonicalWorkspace = await realpath(workspace); + assert.equal(result.schemaVersion, 2); + assert.equal(result.scope.cwd, canonicalWorkspace); assert.equal(rawCalls, 1); + assert.equal(rawOptions.cwd, canonicalWorkspace); + assert.equal(lintOptions.cwd, canonicalWorkspace); + assert.equal(inventoryOptions.cwd, canonicalWorkspace); assert.equal(lintInventory, rawInventory); assert.equal(publicInventory, rawInventory); assert.equal(result.kind, ASSET_BASELINE_KIND); @@ -134,6 +183,252 @@ test("asset baseline shares one inventory snapshot and emits compact AI envelope assert.doesNotMatch(serialized, /private title only/u); }); +test("DeepSeek Harness baseline freezes cwd and exposes only compact configured provenance", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-dsh-baseline-")); + onTestFinished(async () => { + await rm(root, { recursive: true, force: true }); + }); + + const repository = path.join(root, "repository"); + const workspace = path.join(repository, "packages", "api"); + const cwd = path.join(workspace, "src"); + const dshHome = path.join(root, "isolated-dsh-home"); + const skillDirectory = path.join(repository, ".dsh", "skills", "project-review"); + await mkdir(path.join(repository, ".git"), { recursive: true }); + await mkdir(cwd, { recursive: true }); + await mkdir(dshHome, { recursive: true }); + await mkdir(skillDirectory, { recursive: true }); + await writeFile(path.join(repository, "AGENTS.md"), "repository instruction canary\n", "utf8"); + await writeFile(path.join(workspace, "AGENTS.md"), "workspace instruction canary\n", "utf8"); + await writeFile(path.join(cwd, "AGENTS.local.md"), "nested instruction canary\n", "utf8"); + await writeFile( + path.join(skillDirectory, "SKILL.md"), + "---\nname: project-review\ndescription: Project review skill\n---\nsecret skill body canary\n", + "utf8", + ); + + const result = await collectAssetBaseline({ + provider: "dsh", + workspace, + cwd, + dshHome, + includeUserHome: false, + }); + + assert.equal(result.schemaVersion, 2); + assert.deepEqual(result.scope, { + provider: "dsh", + workspace: await realpath(workspace), + cwd: await realpath(cwd), + includeUserHome: false, + includeMemories: false, + }); + const { collectedAt, ...configuredSnapshot } = result.configuredSnapshot; + assert.equal(typeof collectedAt, "string"); + assert.deepEqual(configuredSnapshot, { + evidenceKind: "configured-not-observed", + configurationSource: "caller-overrides", + userHomeCollection: "not-authorized", + instructionCollection: "enabled", + qualification: { + provider: "dsh", + version: "0.1.1-rc.2", + sourceSha: "b150a551b8d465e31e418e1b2eaf5e79bbb7d28e", + }, + runtimeResolution: { + cordis: false, + profile: false, + preset: false, + runtimeSkills: false, + }, + }); + assert.equal(result.status, "complete"); + assert.ok(result.envelopes.inventory.data.coverageRows.length >= 2); + assert.match(JSON.stringify(result), /AGENTS\.local\.md/u); + assert.doesNotMatch( + JSON.stringify(result), + /instruction canary|secret skill body canary|configurationDigest|contentDigest|shadowedSkills|skippedSkills|instructionDecisions|symlinkTarget/u, + ); +}); + +test("asset baseline canonicalizes direct configured scope before inventory collection", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-baseline-cwd-")); + const workspace = path.join(root, "workspace"); + const dotted = path.join(workspace, "..valid-child", "nested"); + const outside = path.join(root, "outside"); + const escape = path.join(workspace, "escape"); + try { + await Promise.all([mkdir(dotted, { recursive: true }), mkdir(outside)]); + await symlink(outside, escape, "dir"); + + let received; + const omitted = await collectAssetBaseline( + { provider: "codex", workspace }, + emptyBaselineDependencies((options) => { received = options; }), + ); + assert.equal(omitted.scope.workspace, await realpath(workspace)); + assert.equal(omitted.scope.cwd, omitted.scope.workspace); + assert.equal(received.cwd, omitted.scope.cwd); + + const nested = await collectAssetBaseline( + { provider: "codex", workspace, cwd: dotted }, + emptyBaselineDependencies((options) => { received = options; }), + ); + assert.equal(nested.scope.cwd, await realpath(dotted)); + assert.equal(received.cwd, nested.scope.cwd); + + for (const cwd of [outside, escape, path.join(workspace, "missing")]) { + await assert.rejects( + collectAssetBaseline({ provider: "codex", workspace, cwd }, emptyBaselineDependencies()), + (error) => error?.code === (cwd.endsWith("missing") + ? "INVALID_CONFIGURED_CWD" + : "CONFIGURED_CWD_OUTSIDE_WORKSPACE"), + ); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("DSH baseline never advertises unsupported Memory collection", async () => { + const { workspace } = await temporaryWorkspace("better-harness-dsh-memory-scope-"); + const defaults = await collectAssetBaseline( + { provider: "dsh", workspace }, + emptyBaselineDependencies(), + ); + const explicitFalse = await collectAssetBaseline( + { provider: "dsh", workspace, includeMemories: false }, + emptyBaselineDependencies(), + ); + assert.equal(defaults.scope.includeMemories, false); + assert.equal(explicitFalse.scope.includeMemories, false); + await assert.rejects( + collectAssetBaseline( + { provider: "dsh", workspace, includeMemories: true }, + emptyBaselineDependencies(), + ), + (error) => error?.code === "UNSUPPORTED_DSH_MEMORY_COLLECTION", + ); + + const codex = await collectAssetBaseline( + { provider: "codex", workspace, includeMemories: true }, + emptyBaselineDependencies(), + ); + assert.equal(codex.scope.includeMemories, true); +}); + +test("asset baseline compacts finding, coverage, owner, and free-text paths to stable locators", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-baseline-locators-")); + onTestFinished(async () => { + await rm(root, { recursive: true, force: true }); + }); + + const gitRoot = path.join(root, "repository"); + const workspace = path.join(gitRoot, "packages", "api"); + const userHome = path.join(root, "synthetic-home"); + const outside = path.join(root, "outside", "private.txt"); + const escape = path.join(workspace, "escape-to-outside"); + await mkdir(workspace, { recursive: true }); + await mkdir(userHome, { recursive: true }); + await mkdir(path.dirname(outside), { recursive: true }); + await writeFile(outside, "synthetic off-tree content\n", "utf8"); + await symlink(outside, escape, "file"); + + const rawInventory = (selectedWorkspace) => ({ + provider: "codex", + workspace: selectedWorkspace, + plugins: [], + manage: { + plugins: [], mcps: [], skills: [], subagents: [], rules: [], commands: [], hooks: [], + }, + diagnostics: {}, + }); + const pathSamples = [ + workspace, + path.join(workspace, "skill.md"), + path.join(gitRoot, "AGENTS.md"), + path.join(userHome, "skills", "global", "SKILL.md"), + outside, + escape, + ]; + const result = await collectAssetBaseline({ + provider: "codex", + workspace, + cwd: workspace, + codexHome: path.join(userHome, ".codex"), + includeUserHome: true, + topology: { + requestedWorkspace: workspace, + workspace, + gitRoot, + target: { kind: "workspace-member", workspace }, + }, + }, { + homeDirectory: () => userHome, + collectRawInventory: async (options) => rawInventory(options.workspace), + runLint: async () => ({ + kind: "agent-lint", + profile: "agent-assets-review", + summary: { findings: pathSamples.length }, + findings: pathSamples.map((file, index) => ({ + id: `path-${index}`, + severity: "warning", + file, + evidence: `bounded source ${file}`, + })), + }), + collectPublicInventory: async () => ({ + scope: { platform: "codex", includeUserHome: true }, + summary: { + practiceCoverageRows: [{ surface: "Rules", scopes: ["Project"], count: 5, paths: pathSamples }], + }, + surfaces: [{ + type: "rules", + scope: "workspace", + items: pathSamples.map((file, index) => ({ name: `rule-${index}`, scope: "workspace", path: file })), + }], + memories: { included: false, categories: [] }, + warnings: [], + }), + reviewIntegrity: () => ({ + kind: "asset-integrity-review", + profile: "asset-integrity-review", + status: "reviewed", + summary: { findingCount: pathSamples.length }, + findings: pathSamples.map((file, index) => ({ + id: `integrity-path-${index}`, + severity: "warning", + file, + why: `bounded integrity source ${file}`, + })), + }), + }); + + const expectedLocators = [ + "", + "/skill.md", + "/AGENTS.md", + "~/skills/global/SKILL.md", + "", + "", + ]; + assert.deepEqual( + result.envelopes.lint.data.findings.items.map((finding) => finding.file), + expectedLocators, + ); + assert.deepEqual( + result.envelopes.integrity.data.findings.items.map((finding) => finding.file), + expectedLocators, + ); + assert.deepEqual(result.envelopes.inventory.data.coverageRows[0].paths, expectedLocators); + assert.equal( + result.envelopes.inventory.data.ownerRoutes.items.every((route) => + !path.isAbsolute(route.route ?? "")), + true, + ); + assert.equal(JSON.stringify(result.envelopes).includes(root), false); +}); + test("asset baseline preserves partial stage failures without hiding healthy envelopes", async () => { const result = await collectAssetBaseline({ provider: "cursor", workspace: "." }, { collectRawInventory: async () => ({}), @@ -153,8 +448,20 @@ test("asset baseline preserves partial stage failures without hiding healthy env assert.match(markdown, /inventory: unavailable/); }); +test("failed raw inventory never fabricates configuredSnapshot", async () => { + const result = await collectAssetBaseline({ provider: "codex", workspace: "." }, { + collectRawInventory: async () => { + throw Object.assign(new Error("synthetic inventory failure"), { code: "SYNTHETIC_FAILURE" }); + }, + }); + + assert.equal(result.status, "failed"); + assert.equal(Object.hasOwn(result, "configuredSnapshot"), false); + assert.equal(result.diagnostics.sharedInventorySnapshot, false); +}); + test("asset baseline samples the latest 16 owner routes with explicit freshness coverage", async () => { - const workspace = path.resolve("/tmp/better-harness-latest-owner-routes"); + const { workspace } = await temporaryWorkspace("better-harness-latest-owner-routes-"); const observedAt = new Date("2026-08-03T08:00:00.000Z"); const modifiedBase = Date.parse("2026-08-01T00:00:00.000Z"); let activeStats = 0; @@ -215,7 +522,8 @@ test("asset baseline samples the latest 16 owner routes with explicit freshness test("Qoder asset baseline includes selected-project Memory titles by default", async () => { let publicOptions; - const result = await collectAssetBaseline({ provider: "qoder", workspace: "/tmp/qoder-project" }, { + const { workspace } = await temporaryWorkspace("better-harness-qoder-project-"); + const result = await collectAssetBaseline({ provider: "qoder", workspace }, { collectRawInventory: async () => ({}), runLint: async () => ({ kind: "agent-lint", profile: "agent-assets-review", summary: {}, findings: [] }), collectPublicInventory: async (options) => { diff --git a/test/agents/agent-asset-integrity.test.mjs b/test/agents/agent-asset-integrity.test.mjs index 8dde4792..3f2d0e19 100644 --- a/test/agents/agent-asset-integrity.test.mjs +++ b/test/agents/agent-asset-integrity.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -132,7 +132,8 @@ test("public asset-integrity CLI stays read-only and omits Memory body text and const workspace = path.join(root, "workspace"); const qoderHome = path.join(root, ".qoder"); try { - const slug = path.resolve(workspace).replace(/^[A-Za-z]:/u, "").replace(/[\\/]+/gu, "-").replace(/^-+|-+$/gu, ""); + await mkdir(workspace, { recursive: true }); + const slug = (await realpath(workspace)).replace(/^[A-Za-z]:/u, "").replace(/[\\/]+/gu, "-").replace(/^-+|-+$/gu, ""); const memoryDir = path.join(qoderHome, "memories", "account", "projects", slug, "project_introduction"); await mkdir(memoryDir, { recursive: true }); await writeFile(path.join(memoryDir, "Project Overview.md"), "private body must stay private\n"); @@ -236,3 +237,76 @@ test("public asset-integrity CLI reviews Codex Memory metadata without reading b await rm(root, { recursive: true, force: true }); } }); + +test("public asset-integrity CLI admits empty DSH metadata surfaces with frozen cwd", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-dsh-integrity-")); + const workspace = path.join(root, "workspace"); + const cwd = path.join(workspace, "..valid-child", "src"); + const dshHome = path.join(root, "isolated-dsh-home"); + try { + await mkdir(path.join(workspace, ".git"), { recursive: true }); + await mkdir(cwd, { recursive: true }); + await mkdir(dshHome, { recursive: true }); + const script = path.join(process.cwd(), "scripts", "better-harness.mjs"); + const { stdout, stderr } = await execFileAsync(process.execPath, [ + script, + "coding-agent-practices", + "asset-integrity", + "dsh", + "--workspace", + workspace, + "--cwd", + cwd, + "--dsh-home", + dshHome, + "--json", + ], { + env: { ...process.env, HOME: root, USERPROFILE: root, DSH_HOME: dshHome }, + }); + + assert.equal(stderr, ""); + const result = JSON.parse(stdout); + assert.equal(result.status, "reviewed"); + assert.equal(result.summary.findingCount, 0); + assert.equal(result.summary.memories.titleCount, 0); + assert.equal(result.summary.plugins.enabledPluginCount, 0); + assert.equal(result.summary.hooks.enabledHookCount, 0); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("public asset-integrity CLI rejects a configured cwd that resolves outside workspace", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-dsh-integrity-escape-")); + const workspace = path.join(root, "workspace"); + const outside = path.join(root, "outside"); + const escape = path.join(workspace, "escape"); + const dshHome = path.join(root, "isolated-dsh-home"); + try { + await Promise.all([ + mkdir(workspace, { recursive: true }), + mkdir(outside, { recursive: true }), + mkdir(dshHome, { recursive: true }), + ]); + await symlink(outside, escape, "dir"); + const script = path.join(process.cwd(), "scripts", "better-harness.mjs"); + await assert.rejects( + execFileAsync(process.execPath, [ + script, + "coding-agent-practices", + "asset-integrity", + "dsh", + "--workspace", + workspace, + "--cwd", + escape, + "--dsh-home", + dshHome, + "--json", + ], { env: { ...process.env, HOME: root, USERPROFILE: root, DSH_HOME: dshHome } }), + (error) => error?.code === 1 && /cwd must resolve inside workspace/u.test(error.stderr), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/agents/agent-lint.test.mjs b/test/agents/agent-lint.test.mjs index c2a6540d..6b9dfc97 100644 --- a/test/agents/agent-lint.test.mjs +++ b/test/agents/agent-lint.test.mjs @@ -962,3 +962,82 @@ description: Use when reviewing configured assets. await rm(root, { recursive: true, force: true }); } }); + +test("agent-assets-review CLI admits DSH and forwards nested cwd selection", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-dsh-agent-lint-")); + const repository = path.join(root, "repository"); + const workspace = path.join(repository, "packages", "api"); + const cwd = path.join(workspace, "..valid-child", "src"); + const dshHome = path.join(root, "isolated-dsh-home"); + const cliPath = path.join(process.cwd(), "scripts", "better-harness.mjs"); + + try { + await mkdir(path.join(repository, ".git"), { recursive: true }); + await mkdir(cwd, { recursive: true }); + await mkdir(dshHome, { recursive: true }); + await writeText(path.join(workspace, "AGENTS.md"), "# Workspace instruction\n"); + await writeText(path.join(cwd, "AGENTS.local.md"), "# Nested local instruction\n"); + + const { stdout, stderr } = await execFileAsync(process.execPath, [ + cliPath, + "agent-lint", + "--workspace", + workspace, + "--cwd", + cwd, + "--profile", + "agent-assets-review", + "--provider", + "dsh", + "--dsh-home", + dshHome, + "--json", + ], { + env: { ...process.env, HOME: root, USERPROFILE: root, DSH_HOME: dshHome }, + }); + + assert.equal(stderr, ""); + const payload = JSON.parse(stdout); + assert.equal(payload.assetInventory.provider, "dsh"); + assert.equal(payload.assetInventory.summary.rules, 2); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("agent-assets-review rejects a configured cwd that resolves outside workspace before asset reads", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-dsh-agent-lint-escape-")); + const workspace = path.join(root, "workspace"); + const outside = path.join(root, "outside"); + const escape = path.join(workspace, "escape"); + const dshHome = path.join(root, "isolated-dsh-home"); + const cliPath = path.join(process.cwd(), "scripts", "better-harness.mjs"); + try { + await Promise.all([ + mkdir(workspace, { recursive: true }), + mkdir(outside, { recursive: true }), + mkdir(dshHome, { recursive: true }), + ]); + await symlink(outside, escape, "dir"); + await assert.rejects( + execFileAsync(process.execPath, [ + cliPath, + "agent-lint", + "--workspace", + workspace, + "--cwd", + escape, + "--profile", + "agent-assets-review", + "--provider", + "dsh", + "--dsh-home", + dshHome, + "--json", + ], { env: { ...process.env, HOME: root, USERPROFILE: root, DSH_HOME: dshHome } }), + (error) => error?.code === 1 && /cwd must resolve inside workspace/u.test(error.stderr), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/agents/coding-agent-practices-inventory.test.mjs b/test/agents/coding-agent-practices-inventory.test.mjs index 1eb08967..4cff54b6 100644 --- a/test/agents/coding-agent-practices-inventory.test.mjs +++ b/test/agents/coding-agent-practices-inventory.test.mjs @@ -1,12 +1,15 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { test } from "vitest"; -import { collectQoderInventory } from "../../scripts/coding-agent-practices/inventory.mjs"; +import { + collectProviderInventory, + collectQoderInventory, +} from "../../scripts/coding-agent-practices/inventory.mjs"; const execFileAsync = promisify(execFile); @@ -147,7 +150,7 @@ test("Qoder global inventory separates project, user, plugin, session, and memor assert.ok(result.surfaces.some((surface) => surface.id === "project-qoder-hooks")); assert.equal( result.surfaces.find((surface) => surface.id === "project-qoder-hooks")?.items[0]?.scriptPath, - path.join(fixture.workspace, "check.js"), + path.join(await realpath(fixture.workspace), "check.js"), ); assert.ok( result.surfaces.some( @@ -235,7 +238,7 @@ test("Qoder inventory includes current-project memories without global capabilit const fixture = await makeFixture(); try { - const workspaceSlug = path.resolve(fixture.workspace) + const workspaceSlug = (await realpath(fixture.workspace)) .replace(/^[A-Za-z]:/u, "") .replace(/[\\/]+/gu, "-") .replace(/^-+|-+$/gu, ""); @@ -364,7 +367,7 @@ test("Qoder inventory CLI keeps project Memory semantic scope while emitting a h const fixture = await makeFixture(); try { - const workspaceSlug = path.resolve(fixture.workspace) + const workspaceSlug = (await realpath(fixture.workspace)) .replace(/^[A-Za-z]:/u, "") .replace(/[\\/]+/gu, "-") .replace(/^-+|-+$/gu, ""); @@ -512,6 +515,15 @@ test("inventory CLI keeps Codex memory metadata separate from configured assets assert.equal(json.summary.userAssets, 2); assert.equal(json.summary.pluginAssets, 2); assert.equal(json.summary.memories, 2); + assert.ok(Array.isArray(json.summary.practiceCoverageRows)); + const skillsCoverage = json.summary.practiceCoverageRows.find((row) => row.surface === "Skills"); + const hooksCoverage = json.summary.practiceCoverageRows.find((row) => row.surface === "Hooks"); + const memoryCoverage = json.summary.practiceCoverageRows.find((row) => row.surface === "Memories"); + assert.equal(skillsCoverage.count, 2); + assert.deepEqual(skillsCoverage.scopes, ["Project", "Global"]); + assert.equal(hooksCoverage.count, 2); + assert.deepEqual(hooksCoverage.scopes, ["Project", "Global"]); + assert.equal(memoryCoverage.count, 2); assert.ok(json.surfaces.some((surface) => surface.id === "project-codex-skills")); assert.ok(json.surfaces.some((surface) => surface.id === "user-codex-skills")); assert.ok(json.surfaces.some((surface) => surface.id === "plugin-codex-plugins")); @@ -565,3 +577,208 @@ test("Codex inventory keeps user, Plugin, and Memory metadata closed without sco await rm(fixture.root, { recursive: true, force: true }); } }); + +test("shared provider inventory emits deterministic Skills and Rules practice coverage", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-practice-coverage-")); + const workspace = path.join(root, "workspace"); + try { + await mkdir(workspace); + const canonicalWorkspace = await realpath(workspace); + const result = await collectProviderInventory({ + platform: "dsh", + workspace, + inventory: { + provider: "dsh", + workspace: canonicalWorkspace, + cwd: canonicalWorkspace, + plugins: [], + manage: { + plugins: [], + mcps: [], + skills: [ + { + id: "skill-project-review", + name: "review", + displayName: "review", + scope: "project", + evidence: { + path: path.join(canonicalWorkspace, ".dsh", "skills", "review", "SKILL.md"), + relativePath: path.join("review", "SKILL.md"), + }, + }, + ], + subagents: [], + rules: [ + { + id: "rule-project-agents", + name: "AGENTS.md", + displayName: "AGENTS.md", + scope: "project", + evidence: { path: path.join(canonicalWorkspace, "AGENTS.md"), relativePath: "AGENTS.md" }, + }, + ], + commands: [], + hooks: [], + }, + diagnostics: {}, + }, + }); + + assert.deepEqual(result.summary.practiceCoverageRows, [ + { surface: "Rules", scopes: ["Project"], count: 1, paths: ["AGENTS.md"] }, + { surface: "Skills", scopes: ["Project"], count: 1, paths: [".dsh/skills/review/SKILL.md"] }, + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("shared provider inventory classifies canonical workspace assets through one path authority", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-practice-path-authority-")); + const canonicalWorkspace = path.join(root, "canonical-workspace"); + const workspaceAlias = path.join(root, "workspace-alias"); + try { + await Promise.all([ + mkdir(canonicalWorkspace, { recursive: true }), + mkdir(workspaceAlias, { recursive: true }), + ]); + const result = await collectProviderInventory({ + platform: "dsh", + workspace: workspaceAlias, + inventory: { + provider: "dsh", + plugins: [], + manage: { + plugins: [], + mcps: [], + skills: [], + subagents: [], + rules: [{ + id: "rule-project-agents", + name: "AGENTS.md", + displayName: "AGENTS.md", + scope: "project", + evidence: { path: path.join(canonicalWorkspace, "AGENTS.md"), relativePath: "AGENTS.md" }, + }], + commands: [], + hooks: [], + }, + diagnostics: {}, + }, + }, { + canonicalizePath: (value) => path.resolve(value) === path.resolve(workspaceAlias) + ? canonicalWorkspace + : path.resolve(value), + }); + + assert.deepEqual(result.summary.practiceCoverageRows, [ + { surface: "Rules", scopes: ["Project"], count: 1, paths: ["AGENTS.md"] }, + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("Qoder project Memory identity follows the shared canonical workspace authority", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-memory-path-authority-")); + const canonicalWorkspace = path.join(root, "canonical-workspace"); + const workspaceAlias = path.join(root, "workspace-alias"); + const qoderHome = path.join(root, ".qoder"); + const workspaceSlug = path.resolve(canonicalWorkspace) + .replace(/^[A-Za-z]:/u, "") + .replace(/[\\/]+/gu, "-") + .replace(/^-+|-+$/gu, ""); + try { + await Promise.all([ + mkdir(canonicalWorkspace, { recursive: true }), + mkdir(workspaceAlias, { recursive: true }), + ]); + await writeText( + path.join(qoderHome, "memories", "account-1", "projects", workspaceSlug, "project_introduction", "memory.md"), + "private project memory should not appear\n", + ); + + const result = await collectQoderInventory({ + workspace: workspaceAlias, + qoderHome, + includeMemories: true, + }, { + canonicalizePath: (value) => path.resolve(value) === path.resolve(workspaceAlias) + ? canonicalWorkspace + : path.resolve(value), + }); + + assert.deepEqual(result.memories.categories.map((item) => item.category), ["project_introduction"]); + assert.deepEqual(result.memories.categories.map((item) => item.scope), ["Project"]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("shared provider inventory emits no phantom practice row for empty configured collections", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-empty-practice-coverage-")); + const workspace = path.join(root, "workspace"); + try { + await mkdir(workspace); + const result = await collectProviderInventory({ + platform: "dsh", + workspace, + inventory: { + provider: "dsh", + workspace, + cwd: workspace, + plugins: [], + manage: { + plugins: [], + mcps: [], + skills: [], + subagents: [], + rules: [], + commands: [], + hooks: [], + }, + diagnostics: {}, + }, + }); + + assert.deepEqual(result.summary.practiceCoverageRows, []); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("direct provider inventory canonicalizes configured cwd before using injected evidence", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-provider-cwd-")); + const workspace = path.join(root, "workspace"); + const dotted = path.join(workspace, "..valid-child"); + const outside = path.join(root, "outside"); + const escape = path.join(workspace, "escape"); + const inventory = { + provider: "dsh", + plugins: [], + manage: { plugins: [], mcps: [], skills: [], subagents: [], rules: [], commands: [], hooks: [] }, + diagnostics: {}, + }; + try { + await Promise.all([mkdir(dotted, { recursive: true }), mkdir(outside)]); + await symlink(outside, escape, "dir"); + + const omitted = await collectProviderInventory({ platform: "dsh", workspace, inventory }); + assert.equal(omitted.scope.workspace, await realpath(workspace)); + assert.equal(omitted.scope.cwd, omitted.scope.workspace); + + const nested = await collectProviderInventory({ platform: "dsh", workspace, cwd: dotted, inventory }); + assert.equal(nested.scope.cwd, await realpath(dotted)); + + for (const cwd of [outside, escape, path.join(workspace, "missing")]) { + await assert.rejects( + collectProviderInventory({ platform: "dsh", workspace, cwd, inventory }), + (error) => error?.code === (cwd.endsWith("missing") + ? "INVALID_CONFIGURED_CWD" + : "CONFIGURED_CWD_OUTSIDE_WORKSPACE"), + ); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/governance/workspace-topology.test.mjs b/test/governance/workspace-topology.test.mjs index ba456c7b..54f509b7 100644 --- a/test/governance/workspace-topology.test.mjs +++ b/test/governance/workspace-topology.test.mjs @@ -12,10 +12,87 @@ import { findingTargetErrors, findingTargetFromTopology, ownerRouteForPath, + resolveConfiguredCwd, resolveWorkspaceTopology, validateWorkspaceTopology, } from "../../scripts/workspace-topology/index.mjs"; +test("configured cwd uses segment-aware canonical containment", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-configured-cwd-")); + const workspace = path.join(root, "work"); + const dottedChild = path.join(workspace, "..valid-child"); + const nestedDottedChild = path.join(dottedChild, "nested"); + const sibling = path.join(root, "work-other"); + const outside = path.join(root, "outside"); + const escape = path.join(workspace, "escape"); + try { + await Promise.all([ + mkdir(nestedDottedChild, { recursive: true }), + mkdir(sibling, { recursive: true }), + mkdir(outside, { recursive: true }), + ]); + await symlink(outside, escape, "dir"); + + const canonicalWorkspace = await realpath(workspace); + assert.deepEqual(resolveConfiguredCwd({ workspace }), { + workspace: canonicalWorkspace, + cwd: canonicalWorkspace, + }); + assert.equal(resolveConfiguredCwd({ workspace, cwd: dottedChild }).cwd, await realpath(dottedChild)); + assert.equal(resolveConfiguredCwd({ workspace, cwd: nestedDottedChild }).cwd, await realpath(nestedDottedChild)); + + for (const cwd of [path.join(workspace, "..", "outside"), sibling, escape]) { + assert.throws( + () => resolveConfiguredCwd({ workspace, cwd }), + (error) => error?.code === "CONFIGURED_CWD_OUTSIDE_WORKSPACE", + cwd, + ); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("configured cwd delegates equivalent aliases to one canonical path authority", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-configured-cwd-authority-")); + const canonicalWorkspace = path.join(root, "canonical-workspace"); + const canonicalCwd = path.join(canonicalWorkspace, "packages", "api"); + const firstWorkspaceAlias = path.join(root, "workspace-alias-a"); + const firstCwdAlias = path.join(firstWorkspaceAlias, "packages", "api"); + const secondWorkspaceAlias = path.join(root, "workspace-alias-b"); + const secondCwdAlias = path.join(secondWorkspaceAlias, "packages", "api"); + try { + await Promise.all([ + mkdir(canonicalCwd, { recursive: true }), + mkdir(firstCwdAlias, { recursive: true }), + mkdir(secondCwdAlias, { recursive: true }), + ]); + const identities = new Map([ + [path.resolve(firstWorkspaceAlias), canonicalWorkspace], + [path.resolve(firstCwdAlias), canonicalCwd], + [path.resolve(secondWorkspaceAlias), canonicalWorkspace], + [path.resolve(secondCwdAlias), canonicalCwd], + ]); + const dependencies = { + canonicalizePath: (value) => identities.get(path.resolve(value)) ?? path.resolve(value), + }; + + const first = resolveConfiguredCwd({ + workspace: firstWorkspaceAlias, + cwd: firstCwdAlias, + }, dependencies); + const second = resolveConfiguredCwd({ + workspace: secondWorkspaceAlias, + cwd: secondCwdAlias, + }, dependencies); + + assert.deepEqual(first, { workspace: canonicalWorkspace, cwd: canonicalCwd }); + assert.deepEqual(second, first); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + function git(cwd, args) { const result = spawnSync("git", args, { cwd, diff --git a/test/plugins/antigravity-plugin-artifact.test.mjs b/test/plugins/antigravity-plugin-artifact.test.mjs index f68e579b..b4afe94f 100644 --- a/test/plugins/antigravity-plugin-artifact.test.mjs +++ b/test/plugins/antigravity-plugin-artifact.test.mjs @@ -462,7 +462,7 @@ test("freezes the pinned canonical Markdown closure and source link classificati const closure = await verifyMarkdownSourceClosure(repositoryRoot); assert.deepEqual( { nodes: closure.nodes, edges: closure.edges, files: closure.files.length }, - { nodes: 106, edges: 305, files: 109 }, + { nodes: 107, edges: 306, files: 110 }, ); for (const required of [ "AGENTS.md", @@ -983,7 +983,7 @@ test("builds, verifies, runs, and atomically replaces the real pinned artifact", const verified = await verifyAntigravityPluginArtifact(outputRoot); assert.deepEqual( { nodes: verified.markdownClosure.nodes, edges: verified.markdownClosure.edges, files: verified.markdownClosure.files.length }, - { nodes: 106, edges: 305, files: 109 }, + { nodes: 107, edges: 306, files: 110 }, ); assert.equal(verified.runtimeClosure.modules, 19); const help = spawnSync(process.execPath, ["scripts/better-harness.mjs", "--help"], { diff --git a/test/plugins/host-support.test.mjs b/test/plugins/host-support.test.mjs index 1bbff24e..212e0ef4 100644 --- a/test/plugins/host-support.test.mjs +++ b/test/plugins/host-support.test.mjs @@ -48,14 +48,21 @@ test("capability projections are immutable, ordered, and independently addressed assert.deepEqual(dsh.capabilities, [ HOST_CAPABILITIES.SESSION_ANALYSIS, HOST_CAPABILITIES.AGENT_CUSTOMIZE, + HOST_CAPABILITIES.ASSET_PRACTICES, + HOST_CAPABILITIES.HARNESS_REPORT, + HOST_CAPABILITIES.EVIDENCE_BUNDLE, ]); - assert.equal(hostIdsFor(HOST_CAPABILITIES.SESSION_ANALYSIS).includes("dsh"), true); - assert.equal(hostIdsFor(HOST_CAPABILITIES.AGENT_CUSTOMIZE).includes("dsh"), true); for (const capability of [ + HOST_CAPABILITIES.SESSION_ANALYSIS, + HOST_CAPABILITIES.AGENT_CUSTOMIZE, HOST_CAPABILITIES.ASSET_PRACTICES, HOST_CAPABILITIES.HARNESS_REPORT, - HOST_CAPABILITIES.REPORT_RENDERING, HOST_CAPABILITIES.EVIDENCE_BUNDLE, + ]) { + assert.equal(hostIdsFor(capability).includes("dsh"), true, capability); + } + for (const capability of [ + HOST_CAPABILITIES.REPORT_RENDERING, HOST_CAPABILITIES.CHECKUP, ]) { assert.equal(hostIdsFor(capability).includes("dsh"), false, capability); diff --git a/test/reporting/better-harness-evidence-bundle.test.mjs b/test/reporting/better-harness-evidence-bundle.test.mjs index f0032e05..384fa4c8 100644 --- a/test/reporting/better-harness-evidence-bundle.test.mjs +++ b/test/reporting/better-harness-evidence-bundle.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { test } from "vitest"; @@ -16,7 +16,9 @@ import { } from "../../scripts/harness-analysis/evidence-bundle/session-evidence.mjs"; import { workspaceToClaudeSlugVariants } from "../../scripts/session-analysis/platforms/claude.mjs"; import { collectAgentCustomize } from "../../scripts/harness-analysis/evidence-bundle/agent-customize.mjs"; +import { collectProjectHarness } from "../../scripts/harness-analysis/evidence-bundle/project-harness.mjs"; import { EVIDENCE_BUNDLE_HELP } from "../../scripts/harness-analysis/evidence-bundle/cli.mjs"; +import { collectAssetBaseline } from "../../scripts/coding-agent-practices/asset-baseline.mjs"; const NOW = new Date("2026-07-24T08:00:00.000Z"); @@ -115,10 +117,56 @@ function leadEvidence(overrides = {}) { }; } -test("evidence-bundle help advertises WorkBuddy and its isolated home override", () => { - assert.match(EVIDENCE_BUNDLE_HELP, /pi, kimi, workbuddy, or grok/u); +function validAssetBaseline(context, overrides = {}) { + return { + kind: "agent-asset-baseline", + schemaVersion: 2, + status: "complete", + scope: { + provider: context.provider, + workspace: context.workspace, + cwd: context.cwd, + includeUserHome: context.authority.includeUserHome, + includeMemories: context.authority.includeMemories, + }, + ...(context.provider === "dsh" ? { + configuredSnapshot: { + collectedAt: "2026-07-24T07:00:00.000Z", + evidenceKind: "configured-not-observed", + configurationSource: "qualified-defaults", + userHomeCollection: "not-authorized", + instructionCollection: "enabled", + qualification: { + provider: "dsh", + version: "0.1.1-rc.2", + sourceSha: "b150a551b8d465e31e418e1b2eaf5e79bbb7d28e", + }, + runtimeResolution: { + cordis: false, + profile: false, + preset: false, + runtimeSkills: false, + }, + }, + } : {}), + envelopes: { + lint: { status: "available", data: {} }, + inventory: { status: "available", data: {} }, + integrity: { status: "available", data: {} }, + }, + diagnostics: {}, + ...overrides, + }; +} + +test("evidence-bundle help advertises qualified hosts, cwd, and isolated home overrides", () => { + assert.match(EVIDENCE_BUNDLE_HELP, /workbuddy/u); + assert.match(EVIDENCE_BUNDLE_HELP, /grok/u); + assert.match(EVIDENCE_BUNDLE_HELP, /dsh/u); + assert.match(EVIDENCE_BUNDLE_HELP, /--cwd /u); assert.match(EVIDENCE_BUNDLE_HELP, /--workbuddy-home /u); assert.match(EVIDENCE_BUNDLE_HELP, /--grok-home /u); + assert.match(EVIDENCE_BUNDLE_HELP, /--dsh-home /u); }); function topologyResolution(workspace = ".", status = "complete") { @@ -185,10 +233,11 @@ test("evidence bundle freezes the three canonical lane names and normal scope", }, dependencies()); assert.equal(result.kind, EVIDENCE_BUNDLE_KIND); - assert.equal(result.schemaVersion, 2); + assert.equal(result.schemaVersion, 3); assert.equal(result.status, "complete"); assert.deepEqual(Object.keys(result.lanes), ["sessionEvidence", "projectHarness", "agentCustomize"]); assert.equal(result.context.provider, "codex"); + assert.equal(result.context.cwd, await realpath(".")); assert.equal(result.context.depth, "normal"); assert.equal(result.context.evidenceLimit, 5); assert.deepEqual(result.context.window, { @@ -202,6 +251,55 @@ test("evidence bundle freezes the three canonical lane names and normal scope", assert.equal(result.diagnostics.collectionMode, "frozen-context-multi-owner"); }); +test("evidence bundle v3 freezes canonical default, nested, and aliased cwd identity", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-bundle-cwd-")); + t.onTestFinished(() => rm(root, { recursive: true, force: true })); + const workspace = path.join(root, "workspace"); + const nested = path.join(workspace, "packages", "api", "src with space", "\u5b50\u76ee\u5f55"); + const alias = path.join(workspace, "cwd-alias"); + await mkdir(nested, { recursive: true }); + await symlink(nested, alias, "dir"); + + const omitted = freezeEvidenceBundleContext({ workspace }, NOW); + const explicitWorkspace = freezeEvidenceBundleContext({ workspace, cwd: workspace }, NOW); + const explicitNested = freezeEvidenceBundleContext({ workspace, cwd: nested }, NOW); + const aliasedNested = freezeEvidenceBundleContext({ workspace, cwd: alias }, NOW); + + assert.equal(omitted.cwd, await realpath(workspace)); + assert.equal(explicitWorkspace.cwd, omitted.cwd); + assert.equal(explicitNested.cwd, await realpath(nested)); + assert.equal(aliasedNested.cwd, explicitNested.cwd); + assert.notEqual(explicitNested.cwd, omitted.cwd); +}); + +test("evidence bundle cwd validation fails closed before configured collection", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-bundle-cwd-invalid-")); + t.onTestFinished(() => rm(root, { recursive: true, force: true })); + const workspace = path.join(root, "workspace"); + const outside = path.join(root, "outside"); + const file = path.join(workspace, "not-a-directory.txt"); + const escape = path.join(workspace, "escape"); + await mkdir(workspace, { recursive: true }); + await mkdir(outside, { recursive: true }); + await writeFile(file, "not a directory\n"); + await symlink(outside, escape, "dir"); + + for (const cwd of ["", `${workspace}\u0000invalid`, path.join(workspace, "missing"), file]) { + assert.throws( + () => freezeEvidenceBundleContext({ workspace, cwd }, NOW), + (error) => error?.code === "INVALID_CONFIGURED_CWD", + cwd || "blank cwd", + ); + } + for (const cwd of [outside, escape]) { + assert.throws( + () => freezeEvidenceBundleContext({ workspace, cwd }, NOW), + (error) => error?.code === "CONFIGURED_CWD_OUTSIDE_WORKSPACE", + cwd, + ); + } +}); + test("evidence bundle resolves topology once and shares the frozen binding with every consumer", async () => { let resolutions = 0; let canonicalTopology; @@ -351,6 +449,361 @@ test("session lane uses all eligible facts with the frozen limit and window", as assert.equal(received.limit, 3); assert.equal(received.since, "2026-07-20T00:00:00.000Z"); assert.equal(received.until, "2026-07-24T00:00:00.000Z"); + assert.equal(Object.hasOwn(received, "cwd"), false); +}); + +test("agentCustomize forwards frozen cwd to Asset Baseline", async () => { + const canonicalCwd = await realpath("."); + const context = { + ...freezeEvidenceBundleContext({ workspace: ".", platform: "codex" }, NOW), + cwd: canonicalCwd, + }; + let received; + const lane = await collectAgentCustomize(context, {}, { + collectAssetBaseline: async (options) => { + received = options; + return validAssetBaseline(context); + }, + }); + + assert.equal(lane.status, "available"); + assert.equal(received.cwd, canonicalCwd); +}); + +test("agentCustomize rejects malformed Asset Baseline v2 contracts", async () => { + const context = freezeEvidenceBundleContext({ workspace: ".", platform: "dsh" }, NOW); + const cases = [ + ["schemaVersion 1", (value) => { value.schemaVersion = 1; }], + ["unknown schemaVersion", (value) => { value.schemaVersion = 99; }], + ["invalid status", (value) => { value.status = "mystery"; }], + ["missing scope", (value) => { delete value.scope; }], + ["wrong provider", (value) => { value.scope.provider = "codex"; }], + ["missing cwd", (value) => { delete value.scope.cwd; }], + ["malformed lint envelope", (value) => { value.envelopes.lint = { status: "available" }; }], + ["missing configuredSnapshot", (value) => { delete value.configuredSnapshot; }], + ["malformed configuredSnapshot", (value) => { value.configuredSnapshot.qualification.provider = "codex"; }], + ]; + + for (const [name, mutate] of cases) { + const candidate = structuredClone(validAssetBaseline(context)); + mutate(candidate); + const lane = await collectAgentCustomize(context, {}, { + collectAssetBaseline: async () => candidate, + }); + assert.equal(lane.status, "unavailable", name); + assert.equal(lane.error.code, "INVALID_AGENT_CUSTOMIZE_EVIDENCE", name); + } +}); + +test("agentCustomize preserves valid partial and failed Baseline v2 semantics", async () => { + const context = freezeEvidenceBundleContext({ workspace: ".", platform: "dsh" }, NOW); + const partial = validAssetBaseline(context, { + status: "partial", + envelopes: { + lint: { status: "available", data: {} }, + inventory: { status: "unavailable", error: { code: "INVENTORY_UNAVAILABLE", message: "bounded" } }, + integrity: { status: "unavailable", error: { code: "INTEGRITY_UNAVAILABLE", message: "bounded" } }, + }, + }); + const failed = validAssetBaseline(context, { + status: "failed", + configuredSnapshot: undefined, + envelopes: { + lint: { status: "unavailable", error: { code: "LINT_UNAVAILABLE", message: "bounded" } }, + inventory: { status: "unavailable", error: { code: "INVENTORY_UNAVAILABLE", message: "bounded" } }, + integrity: { status: "unavailable", error: { code: "INTEGRITY_UNAVAILABLE", message: "bounded" } }, + }, + }); + + const partialLane = await collectAgentCustomize(context, {}, { + collectAssetBaseline: async () => partial, + }); + const failedLane = await collectAgentCustomize(context, {}, { + collectAssetBaseline: async () => failed, + }); + assert.equal(partialLane.status, "partial"); + assert.equal(failedLane.status, "unavailable"); + assert.equal(failedLane.error.code, "AGENT_CUSTOMIZE_BASELINE_FAILED"); +}); + +function dshRawInventory() { + return { + provider: "dsh", + generatedAt: "2026-07-24T07:00:00.000Z", + diagnostics: { + evidenceKind: "configured-not-observed", + configurationSource: "qualified-defaults", + userHomeCollection: "not-authorized", + instructionCollection: "enabled", + qualifiedDshVersion: "0.1.1-rc.2", + qualifiedDshSourceSha: "b150a551b8d465e31e418e1b2eaf5e79bbb7d28e", + }, + }; +} + +function healthyBaselineStages() { + return { + collectRawInventory: async () => dshRawInventory(), + runLint: async () => ({ + kind: "agent-lint", + profile: "agent-assets-review", + summary: {}, + findings: [], + }), + collectPublicInventory: async () => ({ + scope: { platform: "dsh" }, + summary: {}, + surfaces: [], + memories: { included: false, categories: [] }, + warnings: [], + }), + reviewIntegrity: () => ({ + kind: "agent-asset-integrity", + profile: "agent-assets-review", + status: "reviewed", + contentPolicy: "metadata-only", + summary: {}, + findings: [], + }), + }; +} + +const NESTED_BASELINE_ERROR_CASES = [ + { + name: "failed raw inventory with a spaced POSIX path", + privatePath: "/Users/example/private project/raw inventory.json", + stages: ["lint", "inventory", "integrity"], + configure: (message) => ({ + ...healthyBaselineStages(), + collectRawInventory: async () => { throw new Error(message); }, + }), + }, + { + name: "partial lint with a Windows drive path", + privatePath: "C:\\Users\\example\\private folder\\lint.json", + stages: ["lint"], + configure: (message) => ({ + ...healthyBaselineStages(), + runLint: async () => { throw new Error(message); }, + }), + }, + { + name: "partial inventory with a spaced POSIX path", + privatePath: "/Users/example/private project/inventory.json", + stages: ["inventory"], + configure: (message) => ({ + ...healthyBaselineStages(), + collectPublicInventory: async () => { throw new Error(message); }, + }), + }, + { + name: "partial integrity with a UNC path", + privatePath: "\\\\server\\private share\\integrity.json", + stages: ["integrity"], + configure: (message) => ({ + ...healthyBaselineStages(), + reviewIntegrity: () => { throw new Error(message); }, + }), + }, +]; + +for (const current of NESTED_BASELINE_ERROR_CASES) { + test(`serialized Bundle sanitizes ${current.name}`, async () => { + const safeContext = "stage context remains available"; + const message = `collector could not inspect '${current.privatePath}' while ${safeContext}`; + const result = await collectEvidenceBundle({ + workspace: ".", + cwd: ".", + platform: "dsh", + depth: "quick", + }, dependencies({ + collectAgentCustomize: undefined, + collectAssetBaseline: (options) => collectAssetBaseline(options, current.configure(message)), + })); + const serialized = JSON.stringify(result); + + assert.equal(serialized.includes(current.privatePath), false); + assert.match(serialized, //u); + assert.match(serialized, /stage context remains available/u); + for (const stage of current.stages) { + assert.equal(result.lanes.agentCustomize.data.envelopes[stage].status, "unavailable"); + assert.match(result.lanes.agentCustomize.data.envelopes[stage].error.code, /_UNAVAILABLE$/u); + } + assert.doesNotMatch( + serialized, + /PRIVATE_SKILL_SECRET_X|PRIVATE_INSTRUCTION_SECRET_Y|sk-test-secret-credential|configuredDigest|symlinkTargetRealpath/u, + ); + assert.doesNotMatch( + serialized, + /existedAtSessionTime|usedInSession|influencedSession|sameHistoricalAsset|historicalAbsence/u, + ); + }); +} + +test("lead receives configured cwd while Project Harness remains on its generic Git scope", async () => { + const canonicalCwd = await realpath("."); + let leadOptions; + const bundle = await collectEvidenceBundle({ + workspace: ".", + cwd: ".", + platform: "codex", + }, dependencies({ + analyzeHarnessEvidence: async (options) => { + leadOptions = options; + return leadEvidence(); + }, + })); + + assert.equal(bundle.context.cwd, canonicalCwd); + assert.equal(leadOptions.cwd, canonicalCwd); + + const context = { + ...bundle.context, + cwd: path.join(canonicalCwd, "configured-nested-cwd"), + }; + let projectOptions; + const projectLane = await collectProjectHarness(context, {}, { + buildEvidencePack: async (options) => { + projectOptions = options; + return { kind: "core-change-watch-evidence-pack", status: "ok" }; + }, + }); + assert.equal(projectLane.status, "available"); + assert.equal(projectOptions.cwd, context.topology.gitRoot); + assert.notEqual(projectOptions.cwd, context.cwd); + assert.equal(Object.hasOwn(projectOptions, "configuredCwd"), false); +}); + +test("DSH bundle composes all lanes without merging current configuration into historical observation", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-dsh-bundle-")); + try { + const workspace = path.join(root, "workspace"); + const cwd = path.join(workspace, "src"); + await mkdir(cwd, { recursive: true }); + const safeRequestSummary = "PRIVATE_ORDINARY_PROSE_X api_key= "; + const currentConfiguredAt = "2026-07-24T07:00:00.000Z"; + let leadOptions; + const result = await collectEvidenceBundle({ + workspace, + cwd, + platform: "dsh", + depth: "normal", + }, dependencies({ + collectSessionEvidence: async () => availableLane(sessionFacts({ + candidates: [{ + timestamp: "2026-07-01T07:00:00.000Z", + request: { summary: safeRequestSummary }, + configuredSkills: [], + observedSkills: [], + }], + })), + collectAgentCustomize: async () => availableLane({ + kind: "agent-asset-baseline", + schemaVersion: 2, + status: "complete", + scope: { + provider: "dsh", + workspace, + cwd, + includeUserHome: false, + includeMemories: false, + }, + configuredSnapshot: { + collectedAt: currentConfiguredAt, + evidenceKind: "configured-not-observed", + }, + envelopes: { + lint: { status: "available", data: {} }, + inventory: { status: "available", data: { coverageRows: [] } }, + integrity: { status: "available", data: {} }, + }, + }), + analyzeHarnessEvidence: async (options) => { + leadOptions = options; + return leadEvidence(); + }, + })); + + assert.equal(result.schemaVersion, 3); + assert.equal(result.status, "complete"); + assert.equal(result.context.provider, "dsh"); + assert.equal(result.context.cwd, await realpath(cwd)); + assert.deepEqual( + Object.fromEntries(Object.entries(result.lanes).map(([name, lane]) => [name, lane.status])), + { sessionEvidence: "available", projectHarness: "available", agentCustomize: "available" }, + ); + assert.equal(result.lead.status, "available"); + assert.equal(leadOptions.cwd, result.context.cwd); + assert.equal(result.diagnostics.collectionMode, "frozen-context-multi-owner"); + assert.equal(result.lanes.agentCustomize.data.configuredSnapshot.collectedAt, currentConfiguredAt); + assert.equal(result.lanes.sessionEvidence.data.candidates[0].configuredSkills.length, 0); + assert.equal(result.lanes.sessionEvidence.data.candidates[0].observedSkills.length, 0); + const serialized = JSON.stringify(result); + assert.match(serialized, /PRIVATE_ORDINARY_PROSE_X/u); + assert.doesNotMatch(serialized, /sk-test-secret-credential|\/Users\/synthetic-private-home/u); + assert.doesNotMatch( + serialized, + /existedAtSessionTime|usedInSession|influencedSession|sameHistoricalAsset|historicalAbsence/u, + ); + assert.doesNotMatch( + serialized, + /PRIVATE_SKILL_SECRET_X|PRIVATE_INSTRUCTION_SECRET_Y|configuredDigest|symlinkTargetRealpath/u, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("DSH preserves the existing normal and quick Bundle completeness matrix", async () => { + const cases = [ + { name: "normal complete", depth: "normal", overrides: {}, expected: "complete" }, + { + name: "normal specialist partial", + depth: "normal", + overrides: { collectAgentCustomize: async () => ({ status: "partial", data: { kind: "agent-asset-baseline" } }) }, + expected: "failed", + }, + { + name: "normal topology partial", + depth: "normal", + overrides: { resolveWorkspaceTopology: async ({ workspace }) => topologyResolution(workspace, "partial") }, + expected: "failed", + }, + { + name: "normal lead unavailable", + depth: "normal", + overrides: { analyzeHarnessEvidence: async () => { throw Object.assign(new Error("lead failed"), { code: "LEAD_FAILED" }); } }, + expected: "failed", + }, + { name: "quick complete", depth: "quick", overrides: {}, expected: "complete" }, + { + name: "quick specialist partial", + depth: "quick", + overrides: { collectAgentCustomize: async () => ({ status: "partial", data: { kind: "agent-asset-baseline" } }) }, + expected: "partial", + }, + { + name: "quick topology partial", + depth: "quick", + overrides: { resolveWorkspaceTopology: async ({ workspace }) => topologyResolution(workspace, "partial") }, + expected: "partial", + }, + { + name: "quick lead unavailable", + depth: "quick", + overrides: { analyzeHarnessEvidence: async () => { throw Object.assign(new Error("lead failed"), { code: "LEAD_FAILED" }); } }, + expected: "failed", + }, + ]; + + for (const current of cases) { + const result = await collectEvidenceBundle({ + workspace: ".", + platform: "dsh", + depth: current.depth, + }, dependencies(current.overrides)); + assert.equal(result.status, current.expected, current.name); + } }); test("session lane preserves empty coverage but lowers incomplete Cursor coverage", async () => { @@ -407,7 +860,7 @@ test("Claude agentCustomize lane routes the provider and isolated config paths", }, { collectAssetBaseline: async (options) => { received = options; - return { kind: "agent-asset-baseline", status: "complete" }; + return validAssetBaseline(context); }, }); @@ -425,10 +878,9 @@ test("normal agentCustomize evidence accepts a disclosed latest-route sample", a depth: "normal", "include-user-home": true, }, NOW); - const baseline = { - kind: "agent-asset-baseline", - status: "complete", + const baseline = validAssetBaseline(context, { envelopes: { + lint: { status: "available", data: {} }, inventory: { status: "available", data: { @@ -441,9 +893,10 @@ test("normal agentCustomize evidence accepts a disclosed latest-route sample", a }, }, }, + integrity: { status: "available", data: {} }, }, diagnostics: { truncatedStages: [], sampledStages: ["inventory-owner-routes"] }, - }; + }); const lane = await collectAgentCustomize(context, {}, { collectAssetBaseline: async () => baseline, }); @@ -465,7 +918,7 @@ test("Qwen agentCustomize lane routes the provider and isolated config paths", a }, { collectAssetBaseline: async (options) => { received = options; - return { kind: "agent-asset-baseline", status: "complete" }; + return validAssetBaseline(context); }, }); @@ -488,7 +941,7 @@ test("Pi agentCustomize lane routes the provider and isolated config paths", asy }, { collectAssetBaseline: async (options) => { received = options; - return { kind: "agent-asset-baseline", status: "complete" }; + return validAssetBaseline(context); }, }); @@ -511,7 +964,7 @@ test("Kimi agentCustomize lane routes the provider and isolated config paths", a }, { collectAssetBaseline: async (options) => { received = options; - return { kind: "agent-asset-baseline", status: "complete" }; + return validAssetBaseline(context); }, }); @@ -686,7 +1139,7 @@ test("WorkBuddy agentCustomize lane routes the provider and isolated config path }, { collectAssetBaseline: async (options) => { received = options; - return { kind: "agent-asset-baseline", status: "complete" }; + return validAssetBaseline(context); }, }); diff --git a/test/reporting/harness-report-run.test.mjs b/test/reporting/harness-report-run.test.mjs index e8b4ca45..4dcb8725 100644 --- a/test/reporting/harness-report-run.test.mjs +++ b/test/reporting/harness-report-run.test.mjs @@ -65,8 +65,8 @@ function candidateSource(workspace, platform = "qoder") { return source; } -function candidateSourceWithUsage(workspace) { - const source = candidateSource(workspace); +function candidateSourceWithUsage(workspace, platform = "qoder") { + const source = candidateSource(workspace, platform); source.manifest.selection = { ...source.manifest.selection, eligibleCount: 2, @@ -352,6 +352,46 @@ test("analyze labels non-Qoder provider evidence without creating host directori } }); +test("analyze admits DSH to the neutral JSON Harness evidence contract", async () => { + await withTempDir("harness-analyze-dsh-", async (workspace) => { + const cwd = path.join(workspace, "src"); + const sourceInput = candidateSourceWithUsage(workspace, "dsh"); + + const result = await analyzeHarnessEvidence({ + workspace, + cwd, + platform: "dsh", + sourceInput, + format: "json", + }); + + assert.equal(result.kind, "better-harness.harness-analysis-evidence"); + assert.equal(result.schemaVersion, 1); + assert.match(result.evidence, /Platform: dsh/u); + assert.equal(result.summaryFacts.evidenceBoundary.manifest.platform, "dsh"); + assert.equal(result.summaryFacts.usageActivity.sessions.total, 2); + assert.equal(result.summaryFacts.usageEfficiency.selection.analyzedSessionCount, 2); + assert.equal(Object.hasOwn(result, "findings"), false); + assert.doesNotMatch(result.evidence, /report\.html|report\.md|findings\.json/u); + assert.deepEqual(await readdir(workspace), []); + }); +}); + +test("DSH Harness admission still rejects Canvas rendering", async () => { + const workspace = path.join("workspace", "example"); + const sourceInput = candidateSource(workspace, "dsh"); + await assert.rejects( + analyzeHarnessEvidence({ + workspace, + platform: "dsh", + sourceInput, + format: "json", + "canvas-out": path.join(os.tmpdir(), "canvas.json"), + }), + (error) => error?.code === "CANVAS_OUTPUT_REQUIRES_CANVAS_HOST", + ); +}); + test("root CLI exposes read-only formats and explicit Qoder Canvas output", () => { const result = spawnSync(process.execPath, [cliPath, "harness", "analyze", "--help"], { encoding: "utf8" }); assert.equal(result.status, 0, result.stderr || result.stdout); @@ -360,6 +400,7 @@ test("root CLI exposes read-only formats and explicit Qoder Canvas output", () = assert.match(result.stdout, /--format /u); assert.match(result.stdout, /--since /u); assert.match(result.stdout, /--until /u); + assert.match(result.stdout, /--cwd /u); assert.match(result.stdout, /--canvas-out /u); assert.match(result.stdout, /--replace-canvas/u); assert.doesNotMatch(result.stdout, /--out|--run-dir|--json|prepare|finalize|decision|staging/iu); diff --git a/test/reporting/task-loop-source.test.mjs b/test/reporting/task-loop-source.test.mjs index 2181c568..63907c03 100644 --- a/test/reporting/task-loop-source.test.mjs +++ b/test/reporting/task-loop-source.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { test } from "vitest"; @@ -79,6 +79,68 @@ test("Codex task-loop inventory scans only authorized Memory metadata", async () } }); +test("DSH task-loop recollects current configured practice with frozen cwd and closed authority", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-dsh-task-loop-")); + const repository = path.join(root, "repository"); + const workspace = path.join(repository, "packages", "api"); + const cwd = path.join(workspace, "src"); + const dshHome = path.join(root, "isolated-dsh-home"); + try { + await mkdir(path.join(repository, ".git"), { recursive: true }); + await mkdir(cwd, { recursive: true }); + await mkdir(path.join(repository, ".dsh", "skills", "current-review"), { recursive: true }); + await mkdir(path.join(dshHome, "skills", "private-user"), { recursive: true }); + await writeFile(path.join(workspace, "AGENTS.md"), "PRIVATE_INSTRUCTION_SECRET_Y\n"); + await writeFile(path.join(cwd, "AGENTS.local.md"), "nested local instruction\n"); + await writeFile( + path.join(repository, ".dsh", "skills", "current-review", "SKILL.md"), + "---\nname: current-review\ndescription: Current review\n---\nPRIVATE_SKILL_SECRET_X\n", + ); + await writeFile( + path.join(dshHome, "skills", "private-user", "SKILL.md"), + "---\nname: private-user\ndescription: Private user skill\n---\nUSER_HOME_CANARY\n", + ); + + const options = { + workspace, + cwd, + dshHome, + includeGlobalCapabilities: false, + }; + const first = await collectTaskLoopPracticeInventory(options, "dsh"); + const second = await collectTaskLoopPracticeInventory(options, "dsh"); + const canonicalWorkspace = await realpath(workspace); + const canonicalCwd = await realpath(cwd); + + assert.ok(first); + assert.notEqual(first, second); + assert.equal(first.scope.platform, "dsh"); + assert.equal(first.scope.workspace, canonicalWorkspace); + assert.equal(first.scope.cwd, canonicalCwd); + assert.equal(first.scope.includeUserHome, false); + assert.deepEqual( + first.summary.practiceCoverageRows.map((row) => row.surface), + ["Rules", "Skills"], + ); + assert.ok(first.surfaces.some((surface) => + surface.type === "rules" + && surface.items.some((item) => item.path === path.join(canonicalCwd, "AGENTS.local.md")))); + const serialized = JSON.stringify(first); + assert.doesNotMatch(serialized, /PRIVATE_SKILL_SECRET_X|PRIVATE_INSTRUCTION_SECRET_Y|USER_HOME_CANARY/u); + for (const forbiddenClaim of [ + "existedAtSessionTime", + "usedInSession", + "influencedSession", + "historicalAbsence", + "sameHistoricalAsset", + ]) { + assert.equal(serialized.includes(forbiddenClaim), false); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("explicit Checkup scan files bridge into sanitized durable report evidence", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-checkup-source-")); const scanPath = path.join(root, "checkup.scan.json"); diff --git a/test/skills-docs/better-harness-skill.test.mjs b/test/skills-docs/better-harness-skill.test.mjs index f41b0859..de358c47 100644 --- a/test/skills-docs/better-harness-skill.test.mjs +++ b/test/skills-docs/better-harness-skill.test.mjs @@ -3,6 +3,9 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { test } from "vitest"; +import { RENDER_REPORT_PLATFORMS } from "../../scripts/harness-analysis/render-report.mjs"; +import { HOST_CAPABILITIES, hostIdsFor } from "../../scripts/host-support/index.mjs"; + const ROOT = process.cwd(); const SKILL_PATH = "skills/better-harness/SKILL.md"; @@ -70,6 +73,7 @@ test("Better Harness Skill exposes one evidence command and one render command", for (const option of [ "--platform ", "--workspace ", + "--cwd ", "--depth ", "--since ", "--until ", @@ -87,3 +91,16 @@ test("Better Harness Skill exposes one evidence command and one render command", "--json", ]) assert.equal(renderCommands[0].includes(option), true, `render command must include ${option}`); }); + +test("Better Harness Skill routes DSH through one cwd-aware bundle command and no durable renderer", () => { + const commandLines = textFenceLines(read(SKILL_PATH)); + const evidenceCommands = commandLines.filter((line) => line.startsWith(" harness evidence-bundle ")); + + assert.equal(evidenceCommands.length, 1); + assert.equal(evidenceCommands[0].includes("--platform "), true); + assert.equal(evidenceCommands[0].includes("--workspace "), true); + assert.equal(evidenceCommands[0].includes("--cwd "), true); + assert.equal(hostIdsFor(HOST_CAPABILITIES.EVIDENCE_BUNDLE).includes("dsh"), true); + assert.equal(hostIdsFor(HOST_CAPABILITIES.REPORT_RENDERING).includes("dsh"), false); + assert.equal(RENDER_REPORT_PLATFORMS.includes("dsh"), false); +});